Chapter 14: Complexity and Big O Notation
A school office keeps its student records in a single file. A student writes a small Java program to sort that file by name, tests it on the forty entries of one class, and finds that it finishes before the cursor has stopped blinking. The same program is then handed the register of the whole school — about four thousand entries — and it takes noticeably longer. Handed a district-level list of four hundred thousand entries, it does not finish at all in any reasonable time. Nothing about the program has changed. Nothing is wrong with the machine. What has changed is the amount of work the method itself demands as the list grows.
The tool used to describe that growth is older than the computer. The symbol O was introduced in 1894 by the German mathematician Paul Bachmann in a book on number theory, and was made widely known by Edmund Landau soon after. It sat quietly in mathematics for decades until computer scientists — Donald Knuth foremost among them — recognised that it answered exactly the question programmers kept asking: not how long a program took on one afternoon on one machine, but how its cost would grow when the data grew.
Diagram
Fig. 14.0-1 – graph with input size n on the horizontal axis and number of steps on the vertical axis; two curves drawn from the origin, one a straight line labelled “n steps” rising gently, one a steep upward curve labelled “n² steps”; a dashed vertical line at a marked point on the axis showing the gap between the two curves at that input size; both curves labelled at their right-hand ends
This chapter answers the questions below.
14.1 Why the Cost of an Algorithm Matters
You have spent this year learning to write programs that produce the right answer. Correctness is the first demand, and nothing replaces it. But a program that gives the right answer after four hours is of no use to an office that needs the answer before the counter closes.
So a second question sits beside the first: not only does this method work, but what does it cost to run. That question has a precise answer, and the answer can be worked out on paper, before the program is ever typed in.
An algorithm is a finite, ordered set of unambiguous steps that takes some input and produces the required output. Computational complexity is the study of what an algorithm costs to carry out — how much work it demands and how that demand grows as the data grows.
14.1.1 Two Programs for the Same Task
Consider a task you can solve in two very different ways: finding the sum of the first \(n\) natural numbers.
One method is the obvious one. Start a running total at zero and add \(1, 2, 3, \ldots, n\) to it in turn. The other method uses the standard result that this sum equals \(n(n+1)/2\), so a single expression gives the answer with no repetition at all.
Both methods are correct. Both produce the same number for every \(n\). The program below implements each as a separate method so that they can be compared side by side.
Now count the work, not the time. In sumByLoop(), the addition inside the loop body is performed once for every value of i from 1 to n — that is, \(n\) times — and the loop control does a comparison and an increment each time round as well. In sumByFormula(), one addition, one multiplication and one division are performed, and that is all, whatever the value of n.
| Value of n | Additions in sumByLoop() |
Arithmetic operations in sumByFormula() |
|---|---|---|
| 10 | 10 | 3 |
| 100 | 100 | 3 |
| 1000 | 1000 | 3 |
| 100000 | 100000 | 3 |
Read down the two columns. The first grows exactly in step with \(n\); the second does not grow at all. This is the whole idea of complexity in miniature: the cost of a method is not a single number but a pattern of growth, and two methods that agree on every answer can disagree completely on cost.
| Iteration | Value of i at entry |
total before addition |
total after addition |
|---|---|---|---|
| 1 | 1 | 0 | 1 |
| 2 | 2 | 1 | 3 |
| 3 | 3 | 3 | 6 |
| 4 | 4 | 6 | 10 |
| 5 | 5 | 10 | 15 |
Result: the method returns 15. The trace shows that the loop body ran exactly five times for \(n = 5\) — one execution per value added — which is the counting rule the table above is built on.
The cost belongs to the method
Notice what the count did not depend on. It did not depend on the make of the computer, on the amount of memory installed, on whether the program was run in BlueJ or from the command line, or on how many other windows were open. Those things change how long a step takes. They do not change how many steps the method demands. That number is a property of the method itself, and it can be worked out by reading the code.
14.1.2 Input Size
Every statement about cost is a statement about something growing. Before a cost can be quoted, it must be clear what is growing.
The input size is the measure of how much data the algorithm is given, written as a number, usually called \(n\). For a method that processes an array, \(n\) is normally the number of elements in the array. For a method that examines a string, \(n\) is its length. For the summing methods above, \(n\) is the value supplied, since that is what fixes how many numbers are to be added.
Saying what \(n\) counts is not a formality. “This method takes \(n\) steps” is an incomplete claim; “this method takes \(n\) steps, where \(n\) is the number of elements in the array” is a complete one. Throughout this chapter, every cost is quoted with its \(n\) named.
14.1.3 Counting Operations Rather Than Seconds
It is tempting to settle the question by experiment: run both methods, note the time each takes, and declare a winner. Java even makes the timing easy. The trouble is that the number obtained describes the afternoon, not the algorithm.
A faster machine helps, but only in one particular way. Suppose one computer performs each step in half the time of another. Then every algorithm run on it finishes in half the time — the step count is untouched, and each step is simply cheaper. In Fig. 14.1-1 the same eight steps are shown running on a slower and a faster machine.
Diagram
Fig. 14.1-1 – two side-by-side panels showing the same algorithm on the same input; each panel contains an identical vertical column of eight labelled step boxes, Step 1 to Step 8; left panel headed “slower machine – 2 time units per step – total 16 units”, right panel headed “faster machine – 1 time unit per step – total 8 units”; a single caption line beneath both panels reading “the number of steps is unchanged; only the time per step differs”
Hardware buys you a constant multiplier. Growth is a different thing altogether. If one method needs \(n\) steps and another needs \(n^2\) steps, then at \(n = 1000\) the second needs a thousand times as many steps as the first, at \(n = 100000\) it needs a hundred thousand times as many, and no fixed multiplier from better hardware can close a gap that widens without limit.
14.1.4 Time Complexity and Space Complexity
An algorithm consumes two resources worth measuring: the work it does, and the memory it occupies. Complexity is quoted separately for each.
Time complexity measures the number of steps an algorithm performs, as a function of the input size. It is a count of operations, not a measurement in seconds, for the reasons just given.
Space complexity measures the amount of memory the algorithm needs beyond the input itself, again as a function of the input size. A useful picture is the rough paper you use while working a problem by hand: the question is not how much paper the question is printed on, but how many extra sheets the method makes you fill.
Both sumByLoop() and sumByFormula() use a fixed handful of variables whatever the value of n, so neither needs extra memory that grows with the input. Their space costs are identical; only their time costs differ. That is common enough, but not universal — a method that copies an array into a second array of the same length needs memory that grows in step with \(n\), while one that rearranges the array in place does not.
| Feature | Time complexity | Space complexity |
|---|---|---|
| What is measured | Number of operations performed | Amount of memory occupied |
| Unit of the count | Basic operations such as a comparison, an assignment or an arithmetic operation | Storage cells such as variables and array elements |
| Depends on the machine | No — the count is fixed by the algorithm | No — the count is fixed by the algorithm |
| Typical way it grows | With the number of times a loop body is entered | With the number of extra variables or array cells created |
| Reduced by | Doing less repeated work | Reusing storage instead of making copies |
The two can be traded against each other. A method can often be made faster by storing results it would otherwise recompute, which costs memory; it can be made to use less memory by recomputing values instead of keeping them, which costs time. Recognising that trade is part of choosing an algorithm rather than merely writing one.
For the rest of this chapter the discussion is about time complexity, because that is where the differences between standard algorithms are sharpest, and where the growth patterns are easiest to establish by counting. Where a method’s memory demand is unusual, it is mentioned separately.
14.2 Estimating Complexity in Terms of Methods
A method is built out of a small number of construct types: plain statements, statements placed one after another, conditionals, loops, and loops inside loops. If you know what each of these costs, you can read any method and write down its cost without running it.
The procedure is always the same. Decide what \(n\) counts. Go through the method construct by construct, asking of each statement how many times it is executed when the method is called on input of size \(n\). Add the counts. What comes out is a formula in \(n\).
A basic operation is a single elementary action that the machine performs in a fixed amount of time — one comparison, one assignment, one arithmetic operation, one array access. Counting basic operations is what makes the estimate independent of the machine.
14.2.1 Simple Statements
A statement that does a fixed amount of work, no matter how large the input is, runs in constant time. An assignment, an arithmetic expression, a comparison, reading one element of an array — each of these takes the same effort whether the array holds ten values or ten million.
int mid = (low + high) / 2;This performs one addition, one division and one assignment. Three basic operations, always three, whatever \(n\) may be.
There is one trap here, and it is worth naming early. A statement that looks simple may not be. A call such as arrangeInOrder(marks) occupies one line, but its cost is the cost of whatever that method does, which may be very large. When you count, a method call costs what the method costs, not one operation. Only calls whose bodies you have already established as constant time may be counted as one.
14.2.2 Consecutive Statements
When statements follow one another, each is executed once and their costs add.
int a = 0;
int b = a + 5;
System.out.println(b);Three constant-time statements one after another give a constant total. Nothing has grown, so the whole block is still constant time.
The same addition rule applies to larger blocks. If a method first passes through an array once, at a cost of \(n\) operations, and then passes through it again, at a further cost of \(n\) operations, the total is \(n + n = 2n\) operations. Two separate passes over the same data cost twice one pass — they do not multiply.
14.2.3 Conditional Statements
An if else statement first evaluates its test condition, and then executes exactly one of the two branches. Its cost is therefore the cost of the test, plus the cost of whichever branch is taken.
if (a > b)
{
max = a;
}
else
{
max = b;
}Here the test is constant time and each branch is a single assignment, so the whole statement is constant time.
But which branch is taken depends on the data, and the two branches need not cost the same. Suppose the if part is a single assignment while the else part contains a loop over the whole array. Then the same statement costs a constant on some inputs and \(n\) operations on others. When a single figure is wanted, the usual choice is the costlier branch, because that is what the method might have to do. Section 14.5 gives this practice its proper name and its proper limits.
An if else if chain works the same way. Each test is evaluated in turn until one succeeds, and then one block runs. A chain of a fixed number of constant-time tests and constant-time blocks is still constant time — a chain of four tests is not “four times slower in a way that matters”, because four does not grow with \(n\).
14.2.4 Loops
A loop is where growth enters. Its cost is the number of times the body is executed, multiplied by the cost of one execution of the body, plus the cost of the loop control itself.
for (int i = 0; i < n; i++)
{
sum = sum + m[i];
}The body is a constant-time statement, and it is executed once for each value of i from 0 to n - 1 — that is \(n\) times. The loop control performs a comparison and an increment on each pass, which adds a fixed number of operations per pass. The total is therefore some constant multiple of \(n\): the work grows in direct proportion to the input size. Double the array and you double the work.
The same reasoning applies to while and do while loops. What matters is not which keyword was used but how many times the body is entered.
Two variations are worth checking against the rule.
If the control variable advances by two instead of one, the body runs about \(n/2\) times, so the count is halved but it still grows in direct proportion to \(n\). If the loop bound is a fixed number rather than n, as in for (int i = 1; i <= 100; i++), then the body runs one hundred times no matter how large the data is, and the loop is constant time. A loop is not automatically expensive; it is expensive when the number of passes is tied to \(n\).
14.2.5 Nested Loops
A nested loop is a loop placed inside the body of another loop. For every single pass of the outer loop, the inner loop runs through its whole range. The counts therefore multiply rather than add.
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
c = c + 1;
}
}The outer loop makes \(n\) passes. Each of those passes drives the inner loop through \(n\) passes of its own. The statement c = c + 1 is therefore executed \(n \times n = n^2\) times. At \(n = 10\) that is 100 executions; at \(n = 1000\) it is 1 000 000.
If the two loops run to different limits — the outer to \(m\) and the inner to \(n\) — the body runs \(m \times n\) times, and both quantities must be named when the cost is quoted.
When the inner loop depends on the outer
Sorting methods rarely use two full-range loops. Far more often the inner loop starts where the outer one has reached, so that the range shrinks as the outer variable advances.
for (int i = 1; i <= n; i++)
{
for (int j = i + 1; j <= n; j++)
{
c = c + 1;
}
}When i is 1 the inner loop runs \(n - 1\) times; when i is 2 it runs \(n - 2\) times; and so on down to zero when i reaches n. The total is
\[(n-1) + (n-2) + \cdots + 2 + 1 + 0 = \frac{n(n-1)}{2}\]
which multiplies out to \(\dfrac{n^2}{2} - \dfrac{n}{2}\). Fig. 14.2-1 shows why the sum comes out as half of a square: the pairs of values that actually occur fill exactly the region above the diagonal of an \(n \times n\) grid.
Diagram
Fig. 14.2-1 – square grid of n rows and n columns, rows labelled i = 1 to n from the top and columns labelled j = 1 to n from the left; every cell strictly above the main diagonal shaded, the diagonal cells and every cell below it left blank; the shaded region labelled “pairs for which the inner body runs” and a caption beneath reading “n(n−1)/2 cells shaded out of n² cells”
So this loop pattern does roughly half the work of the full nested pair, but it still grows as the square of the input size. Doubling \(n\) still multiplies the work by about four. Section 14.3 explains why the halving is not treated as a difference in kind.
The program below lets you check the formula rather than take it on trust.
14.2.6 Loops That Halve the Data
One more loop pattern appears constantly, and it behaves quite differently from the ones above. Instead of stepping through the data one item at a time, the loop discards half of what is left on every pass.
int size = n;
int steps = 0;
while (size > 1)
{
size = size / 2;
steps = steps + 1;
}| Pass | size at entry |
size after halving |
steps after the pass |
|---|---|---|---|
| 1 | 64 | 32 | 1 |
| 2 | 32 | 16 | 2 |
| 3 | 16 | 8 | 3 |
| 4 | 8 | 4 | 4 |
| 5 | 4 | 2 | 5 |
| 6 | 2 | 1 | 6 |
Result: the loop ends with steps equal to 6. Sixty-four items were reduced to one in six passes, and \(2^6 = 64\) — the trace shows that the number of passes is the number of times \(n\) must be halved to reach 1.
That relationship can be written down directly. After \(k\) passes the remaining size is \(n/2^k\), and the loop stops when this reaches 1:
\[\frac{n}{2^k} = 1 \quad \Rightarrow \quad n = 2^k \quad \Rightarrow \quad k = \log_2 n\]
So the loop makes about \(\log_2 n\) passes. This is an extraordinarily slow-growing quantity. Doubling the data adds one extra pass, not twice as many passes. An array of a thousand items is exhausted in ten passes, a million in twenty, a thousand million in thirty.
Two honest qualifications. First, the derivation assumed \(n\) was an exact power of two; when it is not, the integer division rounds down and the count is the same figure rounded, never differing from \(\log_2 n\) by more than one pass. Second, this pattern only arises when the method genuinely discards half the remaining data on each pass. Halving is not available for free — the data usually has to be arranged in a particular way first, as section 14.6 will show.
A loop that multiplies its control variable by two on each pass, rather than dividing, behaves identically: for (int i = 1; i <= n; i = i * 2) also makes about \(\log_2 n\) passes, because it too crosses the range in doublings.
The counting rules together
Everything in this section reduces to five rules, collected here for reference.
| Construct | Rule | Resulting count |
|---|---|---|
| A simple statement | Fixed work, independent of \(n\) | Constant |
| Statements one after another | Add the counts | Sum of the parts |
if else or if else if |
Test, plus the branch actually taken | Cost of the costlier branch, when one figure is wanted |
| A loop tied to \(n\) | Passes × cost of one pass | Proportional to \(n\) for a simple body |
| A loop inside a loop | Multiply the counts | \(n^2\) for two full-range loops, \(n(n-1)/2\) when the inner range shrinks |
| A loop that halves the data | Count the halvings | About \(\log_2 n\) |
Applying these to a whole method gives a formula such as \(3n^2 + 50n + 200\). That formula is more detail than anyone needs, and the next section explains which part of it survives — and why discarding the rest loses nothing that matters.
14.3 The Dominant Term and the Role of Constants
Counting a whole method the way section 14.2 counts it produces something like
\[3n^2 + 50n + 200\]
Every part of that expression is honest — the \(n^2\) came from a nested loop, the \(50n\) from a single loop with a heavy body, the 200 from the setting up and finishing off. And almost none of it is useful.
The reason is that the expression answers a question nobody asked. Nobody needs to know the exact step count of a method at \(n = 37\). What is needed is the shape of its growth: what happens to the cost when the data gets larger. For that question, most of the expression is noise.
14.3.1 Identifying the Dominant Term
Work out what each of the three terms actually contributes as \(n\) increases.
| n | \(3n^2\) | \(50n\) | 200 | Total | Share from \(3n^2\) |
|---|---|---|---|---|---|
| 1 | 3 | 50 | 200 | 253 | 1.2% |
| 10 | 300 | 500 | 200 | 1 000 | 30.0% |
| 100 | 30 000 | 5 000 | 200 | 35 200 | 85.2% |
| 1 000 | 3 000 000 | 50 000 | 200 | 3 050 200 | 98.4% |
| 10 000 | 300 000 000 | 500 000 | 200 | 300 500 200 | 99.8% |
Read the last column downwards. At \(n = 1\) the squared term is almost irrelevant and the constant 200 is doing most of the work. By \(n = 100\) the squared term accounts for over four-fifths of the cost. By \(n = 10\,000\) everything else together contributes less than a fifth of one per cent. The other terms have not disappeared; they have simply been left behind.
This happens for a reason that has nothing to do with the particular numbers chosen. Multiply \(n\) by ten and the constant term does not change at all, the linear term becomes ten times larger, and the squared term becomes a hundred times larger. Each round of growth widens the gap, and the widening never stops. Whichever term grows fastest will eventually account for as much of the total as you please.
That term has a name.
The dominant term is chosen by rate of growth, not by size at any one input. In \(3n^2 + 50n + 200\) the dominant term is \(3n^2\), even though at \(n = 1\) it is the smallest of the three.
14.3.2 Why Constants Are Dropped
Having reduced \(3n^2 + 50n + 200\) to \(3n^2\), the usual practice goes one step further and reduces it to \(n^2\). The multiplying constant is dropped as well. That deserves justification, because at first sight throwing away a factor of three looks careless.
The argument is the one from section 14.1.3. A constant multiplier is exactly what a change of machine, a change of compiler or a slightly different way of writing the same statements produces. Rewrite the loop body so that it does two additions instead of six and the count halves; run the program on a machine twice as quick and the time halves again. None of these changes the fact that doubling the data quadruples the work. The constant describes the circumstances; the growth pattern describes the method.
And a constant, however large, cannot save a method whose growth is worse. Suppose one method takes \(100n\) steps and another takes \(n^2\) steps.
| n | \(100n\) steps | \(n^2\) steps | Which is fewer |
|---|---|---|---|
| 10 | 1 000 | 100 | \(n^2\) method |
| 50 | 5 000 | 2 500 | \(n^2\) method |
| 100 | 10 000 | 10 000 | equal |
| 200 | 20 000 | 40 000 | \(100n\) method |
| 1 000 | 100 000 | 1 000 000 | \(100n\) method |
| 10 000 | 1 000 000 | 100 000 000 | \(100n\) method |
The factor of one hundred buys the second method an advantage up to \(n = 100\), and not one step beyond. Fig. 14.3-1 shows the two curves meeting at that point and separating for good afterwards. Replace the hundred by a thousand and the crossing moves out to \(n = 1000\); it does not go away.
Diagram
Fig. 14.3-1 – graph with input size n on the horizontal axis marked at 50, 100, 200 and 500, and number of steps on the vertical axis; a straight line through the origin labelled “100n” and an upward-bending curve labelled “n²”; the two meet at the point (100, 10 000), which is marked with a dot and a dashed vertical line down to the axis; the region left of the crossing labelled “n² method does less work”, the region right of it labelled “100n method does less work”
So the constant fixes only where the crossing lies, and the growth pattern fixes what happens after it. Since the interesting inputs are usually the large ones, it is the growth pattern that is reported.
14.3.3 Where Constants Still Matter
Dropping constants is a deliberate simplification, and like every simplification it throws away something real. It is worth being clear about what.
If two methods have the same growth pattern, the constants are the only thing left to choose between them. A method costing \(n^2\) steps and one costing \(10n^2\) steps are described identically, yet on the same data the first finishes in a tenth of the time. Anyone actually deciding which to use must look past the growth pattern and count more carefully — or measure.
The simplification is also silent about small inputs. Below the crossing point, the method with the worse growth pattern is genuinely the faster one, and if the data never gets large, it is the better choice. This is not a hypothetical nicety: it is exactly why a method with a poor growth pattern but a very small constant, such as the insertion sort of section 14.7.3, is preferred for short lists.
Two kinds of change to a method are therefore worth telling apart:
Changes that alter the growth pattern — removing a nested loop, arranging the data so that half of it can be discarded on each pass, avoiding a repeated pass over the whole array. These change what the method is.
Changes that alter only the constant — moving a computation out of a loop body, replacing two array accesses by one, using a faster machine. These make the same method cheaper by a fixed factor.
The first kind is where the large gains are, and it is the kind this chapter is about. The second kind is real, and worth doing, but it cannot rescue a method that grows badly. If a program is too slow at a hundred thousand records, tightening the loop body will not help; a different method will.
14.4 Big O Notation
Section 14.3 left a working method: count the steps, keep the dominant term, drop the constants. What remains is a bare growth pattern — \(n\), or \(n^2\), or \(\log n\). All that is missing is a settled way of writing it down, so that two people reading the same statement understand the same thing.
That way of writing it is big O notation. The symbol was introduced in 1894 by Paul Bachmann for use in number theory, long before there was any machine whose running time needed describing; computer scientists adopted it much later because it says exactly what is wanted about an algorithm.
14.4.1 Writing and Reading Big O
A cost is written as the capital letter O followed by an expression in \(n\) inside brackets: \(O(n)\), \(O(n^2)\), \(O(\log n)\). The expression inside is the growth pattern, with the dominant term kept and all constants removed.
Reading it aloud, \(O(n^2)\) is “order \(n\) squared”. The phrase “on the order of” is the sense intended: the method’s cost rises like \(n^2\) rises.
Applying the rules to the expression that opened section 14.3:
\[3n^2 + 50n + 200 \quad \longrightarrow \quad O(n^2)\]
A method whose cost does not grow with \(n\) at all is written \(O(1)\), read “order one” or “constant time”. The formula method of section 14.1.1 is \(O(1)\); the loop method is \(O(n)\), where \(n\) is the number to be summed.
Three conventions complete the notation.
Constants inside the brackets are removed, so \(O(2n)\) is written \(O(n)\) and \(O(n^2/2)\) is written \(O(n^2)\). Lower-order terms inside the brackets are removed, so \(O(n^2 + n)\) is written \(O(n^2)\). And the base of a logarithm is usually omitted, because changing the base only multiplies by a constant — \(\log_2 n\) and \(\log_{10} n\) differ by a fixed factor of about 3.32, which the notation discards anyway. Where the halving structure is the point, as in section 14.6.2, writing \(O(\log_2 n)\) makes the origin of the cost clearer, and both forms are in common use.
Every big O statement must say what \(n\) counts. “This method is \(O(n^2)\)” is incomplete; “this method is \(O(n^2)\), where \(n\) is the number of elements in the array” can be checked by someone else.
14.4.2 The Common Orders of Growth
Only a handful of growth patterns appear in ordinary programs. The order of growth of an algorithm is the pattern its cost follows as \(n\) increases, and knowing the standard ones by sight is most of the skill.
| Order | Name | Typically produced by |
|---|---|---|
| \(O(1)\) | constant | a fixed sequence of statements, with no loop tied to \(n\) |
| \(O(\log n)\) | logarithmic | a loop that halves the remaining data on each pass |
| \(O(n)\) | linear | a single loop that visits each item once |
| \(O(n \log n)\) | — | a pass over all the data combined with a halving structure |
| \(O(n^2)\) | quadratic | two nested loops, each tied to \(n\) |
| \(O(n^3)\) | cubic | three nested loops, each tied to \(n\) |
| \(O(2^n)\) | exponential | a method that doubles its work each time one item is added |
| \(O(n!)\) | factorial | a method that tries every arrangement of the data |
The orders are listed from cheapest to costliest, and that ranking holds for every large enough \(n\):
\[O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)\]
The methods studied in this chapter occupy the first five of these positions. No method producing \(O(n \log n)\) is developed here; the order is listed because it sits between the linear and the quadratic and is met constantly in later work. The last two are included as a warning: they are what happens when a method examines every possible subset or every possible ordering of the data, and they are unusable at any input size a school office would recognise.
14.4.3 Comparing Orders as the Input Grows
The ranking above is easier to trust once the actual numbers are in front of you.
| Order | n = 10 | n = 100 | n = 1000 |
|---|---|---|---|
| \(O(\log_2 n)\) | about 3 | about 7 | about 10 |
| \(O(n)\) | 10 | 100 | 1 000 |
| \(O(n \log_2 n)\) | about 33 | about 664 | about 9 966 |
| \(O(n^2)\) | 100 | 10 000 | 1 000 000 |
| \(O(n^3)\) | 1 000 | 1 000 000 | 1 000 000 000 |
| \(O(2^n)\) | 1 024 | about \(1.3 \times 10^{30}\) | about \(1.1 \times 10^{301}\) |
Read across the rows rather than down the columns. The logarithmic row barely moves: multiplying the data by a hundred adds seven steps. The linear row keeps pace with the data exactly. The quadratic row is a hundred times worse each time the data grows tenfold. And the exponential row leaves the page entirely — at \(n = 100\) its figure already exceeds the number of atoms usually estimated to be in the observable universe, so no improvement in hardware is of the slightest relevance to it.
Fig. 14.4-1 shows the same information as curves.
Diagram
Fig. 14.4-1 – single graph with input size n on the horizontal axis and number of steps on the vertical axis; five curves drawn from the same origin region and labelled at their right-hand ends: a nearly flat curve “O(log n)”, a straight line “O(n)”, a gently bending curve “O(n log n)”, a steeper curve “O(n²)” and a near-vertical curve “O(2ⁿ)”; the curves ordered from lowest to highest at the right edge of the graph
Another way to read the same table is to ask what one doubling of the data does to the cost. This is often the most useful single fact about an algorithm, because data tends to grow by doubling rather than by neat powers of ten.
| Order | Effect of doubling n |
|---|---|
| \(O(1)\) | no change |
| \(O(\log n)\) | one extra step |
| \(O(n)\) | twice as many steps |
| \(O(n^2)\) | four times as many steps |
| \(O(n^3)\) | eight times as many steps |
| \(O(2^n)\) | the step count is squared |
These six lines are worth carrying in your head. They convert an abstract order into a prediction: an \(O(n^2)\) method that takes one second on a file will take about four seconds when the file doubles, sixteen seconds when it doubles again, and something over four minutes after four doublings — while an \(O(n)\) method on the same sequence of files goes from one second to sixteen.
That is also the honest limit of what big O will tell you. It predicts the shape of the increase, not the starting figure. Whether the first file takes one second or one minute depends on constants the notation has thrown away, and the only way to find that out is to count more precisely or to measure.
14.5 Best, Average and Worst Case
So far each method has been given a single cost. That is only possible when the method does the same work on every input of a given size — as the summing loop of section 14.1.1 does, since it adds \(n\) numbers whatever those numbers are.
Most methods are not like that. They contain a decision, and the decision depends on the data. Two arrays of exactly the same length can send the same method along two quite different paths, and the step counts can differ enormously. A single figure will not describe both.
14.5.1 What the Three Cases Mean
Consider a method that checks whether an array is already arranged in ascending order. It compares each element with the one after it, and the moment it finds a pair out of order it can stop and answer false — there is no point examining the rest.
Now count what happened on each of the two test arrays, both of which have five elements.
On p, the very first comparison is 90 against 12. That pair is out of order, so the method returns immediately. One comparison, and the size of the array made no difference at all — the same would have happened with five thousand elements.
On q, no pair is ever out of order, so the loop runs to the end: four comparisons for five elements, and \(n - 1\) comparisons in general.
Same method, same input size, one comparison against four. This is why complexity is quoted in three cases — the best case complexity, the worst case complexity and the average case complexity.
For isSorted(), the best case is a single comparison, which is \(O(1)\); the worst case is \(n - 1\) comparisons, which is \(O(n)\), where \(n\) is the number of elements. Fig. 14.5-1 shows the general picture: for each input size, many different inputs are possible, each with its own step count, and the three cases pick out the bottom, the middle and the top of that spread.
Diagram
Fig. 14.5-1 – graph with input size n on the horizontal axis and number of steps on the vertical axis; at each of five input sizes a vertical scatter of dots representing different inputs of that size; three curves drawn through the scatter and labelled at their right-hand ends — “worst case” through the highest dot at each size, “average case” through the middle of the dots, “best case” through the lowest dot at each size
The average case is the awkward one, and it is worth being precise about why. To average, you must first say how likely each input is, and that assumption is a claim about the world, not about the algorithm. If the arrays handed to isSorted() are in genuinely random order, then a pair out of order turns up within the first few comparisons almost every time, and the average is close to the best case. If, on the other hand, the method is used to check files that are usually already in order, almost every call runs to the end and the average is close to the worst case. The algorithm has not changed. Only the assumption has.
14.5.2 Which Case to Quote
Three figures are more than most discussions need, and in practice one of them is usually singled out.
The worst case is the one normally quoted, for two reasons. It is a guarantee: whatever data arrives, the method will not do worse than this, and a promise that holds for every input is worth more than one that holds for some. It is also the case that decides whether a program is usable — a method that is quick on most files but unusable on one is a method that will fail on the day that file arrives.
The best case, quoted alone, is close to worthless. Every searching method finds the item immediately if the item happens to be first, so a best case of \(O(1)\) distinguishes nothing. It becomes interesting only in comparison — when one method’s best case is much better than another’s, or when a method can be arranged so that the common inputs fall near its best case.
The average case is the most realistic figure and the hardest to defend, because it rests on an assumption about the data that may not hold. Where it is quoted in this chapter, the assumption is stated alongside it.
| Feature | Best case | Average case | Worst case |
|---|---|---|---|
| What it measures | Fewest steps over all inputs of size \(n\) | Steps averaged over all inputs of size \(n\) | Most steps over all inputs of size \(n\) |
| Depends on an assumption about the data | No | Yes — how likely each input is | No |
| What it promises | The method can be this quick | The method is usually about this quick | The method is never slower than this |
| Difficulty of working it out | Easiest | Hardest | Straightforward |
| Chief use | Comparing two methods at their most favourable | Estimating everyday running cost | Guaranteeing behaviour on any input |
One caution before moving on. The three cases are not three different algorithms, and a method is not “an \(O(n)\) method sometimes and an \(O(1)\) method at other times”. It is one method, whose cost is described by three separate statements, each of which must name the case it belongs to. Saying “linear search is \(O(1)\)” because it might find the item first is not a shortcut; it is a false claim about an algorithm that will often examine the whole array.
14.6 Complexity of the Searching Algorithms
Searching is the plainest task in which two methods differ not by a little but by an entire order of growth. You met both methods in Class XI as ways of finding a value in an array. Here the question is different: what do they cost, and what does one of them demand in return for being so much cheaper.
Throughout this section \(n\) is the number of elements in the array, and the operation counted is the comparison of an array element with the value being looked for. Everything else the methods do — an assignment, an index calculation — happens a fixed number of times per comparison, so counting comparisons alone gives the correct growth pattern.
14.6.1 Linear Search
Linear search makes no assumption whatever about the array. It looks at the first element, then the second, and so on, stopping when it finds the value or when it runs out of elements.
| Comparison | i |
m[i] |
m[i] == key |
Action |
|---|---|---|---|---|
| 1 | 0 | 14 | false | continue |
| 2 | 1 | 9 | false | continue |
| 3 | 2 | 23 | false | continue |
| 4 | 3 | 7 | false | continue |
| 5 | 4 | 18 | true | return 4 |
Result: the method returns 4, after five comparisons. The trace shows the pattern the cost depends on — the number of comparisons is the position of the value, so a value near the front is cheap and one near the back is not.
14.6.2 Binary Search
Linear search is slow because it learns almost nothing from a failed comparison. Finding that m[0] is not the value tells you nothing about m[1], so the remaining work is undiminished.
That changes completely if the array is in ascending order, and binary search is the method that exploits it. Then a comparison with the middle element tells you which side the value must be on, and the other side — half the array — can be discarded without being examined at all. This is the halving loop of section 14.2.6, applied to real data.
| Pass | low |
high |
mid |
m[mid] |
Comparison result | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 41 | 41 < 77 | discard indices 0 to 4; set low = 5 |
| 2 | 5 | 9 | 7 | 77 | equal | return 7 |
Result: the method returns 7, after two comparisons of an element with the key. Linear search on the same array would have needed eight. The trace shows the point of the method: the first comparison threw away five of the ten elements without looking at any of them.
Fig. 14.6-1 shows the same two passes drawn on the array.
Diagram
Fig. 14.6-1 – the ten-element array 12, 19, 26, 33, 41, 58, 64, 77, 85, 96 drawn twice as a row of boxes with indices 0 to 9 beneath; in the upper row, markers labelled low at index 0, mid at index 4 and high at index 9, with the cells at indices 0 to 4 crossed out and labelled “discarded after one comparison”; in the lower row, markers labelled low at index 5, mid at index 7 and high at index 9, with the cell at index 7 highlighted and labelled “key found”
14.6.3 Linear Search Compared with Binary Search
The two methods solve the same problem at strikingly different cost, but the cheaper one comes with a condition attached.
| Feature | Linear search | Binary search |
|---|---|---|
| Requirement on the data | None; any order will do | The array must be in sorted order |
| How it proceeds | Examines elements one after another from the start | Compares with the middle and discards half the range |
| Best case | \(O(1)\) — the value is first | \(O(1)\) — the value is in the middle |
| Worst case | \(O(n)\) | \(O(\log_2 n)\) |
| Average case, successful search | About \(n/2\) comparisons, so \(O(n)\) | Close to the worst case, so \(O(\log_2 n)\) |
| Behaviour if the requirement is broken | Not applicable | Reports a present value as absent |
| Suited to | Small arrays, or data that changes constantly | Large arrays that are searched far more often than they are altered |
Put the worst-case figures side by side and the difference is hard to overstate.
| Number of elements \(n\) | Linear search | Binary search |
|---|---|---|
| 100 | 100 | 7 |
| 1 000 | 1 000 | 10 |
| 100 000 | 100 000 | 17 |
| 1 000 000 | 1 000 000 | 20 |
That comparison is genuine, but it is not the whole account, and the last row of Table 14.15 is the reason. Binary search only works on sorted data, and sorting is not free — as section 14.7 shows, the sorting techniques on this course cost \(O(n^2)\), which is far more than the \(O(n)\) that a single linear search would have cost. Sorting an array once in order to run one search on it is a poor bargain.
The bargain becomes a good one when the array is searched repeatedly. Sort a list of a hundred thousand entries once, and every subsequent lookup costs seventeen comparisons instead of a hundred thousand. This is precisely why a telephone directory, a dictionary and a school’s admission register are all kept in order: the arranging is done once, by whoever prepares the list, and every reader is repaid on every lookup.
14.7 Complexity of the Sorting Techniques
Section 14.6 ended on a debt. Binary search is spectacularly cheap, but it demands sorted data, and this section works out what arranging that data costs.
Three sorting techniques are studied on this course — bubble sort, selection sort and insertion sort. All three rearrange the array in place, using only a fixed number of extra variables, so their space demands are identical and there is nothing to choose between them there. All three, in the worst case, cost \(O(n^2)\). Yet they are not the same method wearing three hats, and the differences between them are exactly what this section is about.
Throughout, \(n\) is the number of elements in the array. Two operations are worth counting separately: the comparison of two elements, and the exchange of two elements. They are not equally cheap — an exchange moves data about, a comparison only inspects it — and the three techniques differ far more in exchanges than in comparisons.
14.7.1 Bubble Sort
The idea is to compare each adjacent pair, exchanging them whenever they are out of order. One sweep along the array is enough to carry the largest value all the way to the last position, because as soon as the sweep reaches it, it wins every comparison from there on. A second sweep carries the second largest into place, and so on.
Fig. 14.7-1 shows one such sweep.
Diagram
Fig. 14.7-1 – a row of five boxes holding 38, 15, 47, 22, 9 with indices 0 to 4 beneath, drawn as four successive stages of one sweep; in each stage the adjacent pair being compared is ringed and an arrow labelled “exchange” or “no exchange” is placed beneath it; the final stage shows 15, 38, 22, 9, 47 with the last box shaded and labelled “largest value now in place”
Sweep (i) |
Comparisons made | Exchanges made | Array after the sweep |
|---|---|---|---|
| 0 | 4 | 3 | 15, 38, 22, 9, 47 |
| 1 | 3 | 2 | 15, 22, 9, 38, 47 |
| 2 | 2 | 1 | 15, 9, 22, 38, 47 |
| 3 | 1 | 1 | 9, 15, 22, 38, 47 |
Result: the array is left as 9, 15, 22, 38, 47 after 10 comparisons and 7 exchanges. The trace shows the shrinking sweeps — 4, then 3, then 2, then 1 — which is the pattern that produces the cost.
14.7.2 Selection Sort
Bubble sort moves elements towards their places gradually, one exchange at a time. Selection sort does the opposite: it works out where an element belongs first, and moves it exactly once.
The array is thought of as two parts — a sorted part at the front, empty to begin with, and an unsorted part behind it. Each pass finds the smallest element of the unsorted part and exchanges it into the position just after the sorted part, which then grows by one.
Diagram
Fig. 14.7-2 – a row of five boxes holding 64, 12, 45, 8, 31 with indices 0 to 4 beneath, drawn as four stages; in each stage a bracket labelled “sorted part” spans the leading cells and a bracket labelled “unsorted part” spans the rest, the smallest cell of the unsorted part is ringed and labelled “minimum”, and a double-headed arrow shows it being exchanged with the first cell of the unsorted part; the final stage shows 8, 12, 31, 45, 64 entirely under the “sorted part” bracket
Pass (i) |
Comparisons made | min at the end of the scan |
Exchange performed | Array after the pass |
|---|---|---|---|---|
| 0 | 4 | 3 | 64 with 8 | 8, 12, 45, 64, 31 |
| 1 | 3 | 1 | 12 with itself | 8, 12, 45, 64, 31 |
| 2 | 2 | 4 | 45 with 31 | 8, 12, 31, 64, 45 |
| 3 | 1 | 4 | 64 with 45 | 8, 12, 31, 45, 64 |
Result: the array is left as 8, 12, 31, 45, 64 after 10 comparisons and 4 exchanges. Compare this with the bubble sort trace: the same 10 comparisons, but 4 exchanges instead of 7 — and 4 is \(n-1\), which is the most this method can ever perform.
14.7.3 Insertion Sort
The third technique is the one card players use without being taught it. Hold the cards already picked up in order; take the next card; slide it back past every card larger than itself until it reaches its place.
Here the front of the array is kept in order, and each remaining element in turn is lifted out and inserted into that ordered front, the larger elements shifting one place right to make room.
Diagram
Fig. 14.7-3 – a row of five boxes holding 5, 27, 34, 19, 11 with indices 0 to 4 beneath, showing one insertion in three stages; stage one has the cell holding 19 lifted above the row and labelled “value held in t”, stage two shows 34 and 27 each moved one place to the right with curved arrows labelled “shift right”, stage three shows 19 dropped into the vacated cell; a bracket over the leading cells in each stage is labelled “part already in order”
Pass (i) |
Value in t |
Comparisons made | Values shifted right | Array after the pass |
|---|---|---|---|---|
| 1 | 5 | 1 | 27 | 5, 27, 34, 19, 11 |
| 2 | 34 | 1 | none | 5, 27, 34, 19, 11 |
| 3 | 19 | 3 | 34, 27 | 5, 19, 27, 34, 11 |
| 4 | 11 | 4 | 34, 27, 19 | 5, 11, 19, 27, 34 |
Result: the array is left as 5, 11, 19, 27, 34 after 9 comparisons. Pass 2 is the interesting row: 34 was already larger than everything before it, so a single comparison settled it and nothing moved. That is the behaviour the next paragraph turns into a cost.
14.7.4 Comparing the Three Sorting Techniques
Before comparing them with each other, it is worth seeing what \(O(n^2)\) means as an actual quantity of work, since all three techniques land there in the worst case.
| Number of elements \(n\) | Comparisons \(\dfrac{n(n-1)}{2}\) |
|---|---|
| 10 | 45 |
| 100 | 4 950 |
| 1 000 | 499 500 |
| 10 000 | 49 995 000 |
Multiplying the data by ten multiplies the work by about a hundred. This is the arithmetic behind the story that opened the chapter: a program that sorts a class list in no time at all is doing about fifty million comparisons on a list of ten thousand, and about five thousand million on a list of a hundred thousand.
Within that shared order, the three techniques differ in ways that matter.
| Feature | Bubble sort | Selection sort | Insertion sort |
|---|---|---|---|
| Basic idea | Exchange adjacent elements that are out of order | Find the smallest of the rest and place it | Insert each element into an ordered front part |
| Comparisons, worst case | \(\dfrac{n(n-1)}{2}\) | \(\dfrac{n(n-1)}{2}\) | \(\dfrac{n(n-1)}{2}\) |
| Comparisons, best case | \(\dfrac{n(n-1)}{2}\) as written here | \(\dfrac{n(n-1)}{2}\) | \(n-1\) |
| Best case complexity | \(O(n^2)\) | \(O(n^2)\) | \(O(n)\) |
| Average and worst case complexity | \(O(n^2)\) | \(O(n^2)\) | \(O(n^2)\) |
| Exchanges or shifts | Up to \(\dfrac{n(n-1)}{2}\) exchanges | Exactly \(n-1\) exchanges | Up to \(\dfrac{n(n-1)}{2}\) shifts |
| Notices that the data is already in order | No | No | Yes |
| Extra memory needed | A fixed number of variables | A fixed number of variables | A fixed number of variables |
| Suited to | Teaching the idea; small arrays | Data where moving an element is expensive | Small arrays, and data already nearly in order |
Two entries in that table carry most of the weight.
Selection sort is the technique to reach for when the elements themselves are costly to move — large records rather than single integers — because it guarantees at most \(n-1\) exchanges however disordered the data is. Bubble sort, on the same data, may perform hundreds of times as many.
Insertion sort is the technique to reach for when the data is already close to being in order, which happens far more often in practice than a textbook example suggests — a register with a few late entries appended, a price list with three items changed. It is the only one of the three whose cost falls when the data is favourable.
None of this rescues \(O(n^2)\) as such. All three techniques become unusable at input sizes that a linear or logarithmic method would handle without effort, and no amount of choosing between them changes that. Methods with a better order of growth for sorting do exist and are met in later study; the choice within this group is a choice about constants and about favourable data, made under a ceiling that none of the three can lift.
14.8 Analysing Some Other Standard Methods
Searching and sorting are the standard illustrations, but the counting rules of section 14.2 apply to any method at all. This section applies them to three more kinds of code you have already written: methods that work across a two-dimensional array, the elimination method for solving simultaneous equations, and methods that call themselves.
Each of the three brings out a point that the searching and sorting examples left aside.
14.8.1 Methods on Two-Dimensional Arrays
A method that visits every cell of a two-dimensional array needs two nested loops — one for the rows and one for the columns within a row.
Count the additions. The inner loop body runs once for every column of every row, so with \(r\) rows and \(c\) columns it runs \(r \times c\) times. Fig. 14.8-1 makes the count visible: every cell of the grid is entered exactly once.
Diagram
Fig. 14.8-1 – rectangular grid of 3 rows and 4 columns holding the marks 72, 65, 80, 91 / 58, 77, 63, 70 / 88, 90, 84, 79, with row indices 0 to 2 down the left and column indices 0 to 3 across the top; a dashed arrow snakes left to right along row 0, then along row 1, then along row 2, labelled “order in which cells are visited”; a note at the right reading “12 cells, 12 additions”
For a square table of \(n\) rows and \(n\) columns the count is \(n^2\), so the method is \(O(n^2)\) where \(n\) is the number of rows. That looks alarming beside the \(O(n)\) of linear search — until you notice what \(n\) counts. A square table of \(n\) rows holds \(n^2\) values, and the method reads each of them once. Measured against the amount of data present, it does the least work anyone could ask for.
This is the clearest possible warning about naming \(n\). The same method is \(O(n^2)\) when \(n\) is the number of rows and \(O(N)\) when \(N\) is the number of cells, and both statements are correct. A nested loop is not evidence of waste; a nested loop that passes over the same data many times is.
Multiplying two \(n \times n\) matrices raises the count further. Each of the \(n^2\) cells of the result is a sum of \(n\) products, so three nested loops are needed and the method is \(O(n^3)\). Doubling the size of the matrices multiplies the work by eight.
14.8.2 Solving a System of Linear Equations
The elimination method you use in mathematics for simultaneous equations is an algorithm like any other, and its cost can be counted in the same way.
To solve \(n\) equations in \(n\) unknowns, the coefficients are held in a table of \(n\) rows and \(n+1\) columns, the last column holding the constant terms. The method removes one unknown at a time.
The count comes straight from the three nested loops. The outer loop runs \(n\) times, once per unknown eliminated; for each of those, up to \(n\) rows must be adjusted; and adjusting a row means updating up to \(n+1\) coefficients. That gives roughly \(n \times n \times n = n^3\) arithmetic operations. The back substitution of step 6 needs only about \(n^2\) operations, and by the rule of section 14.3 the \(n^2\) is absorbed by the \(n^3\) that dominates it.
The practical reading of \(O(n^3)\) is the last row of Table 14.10: doubling the number of unknowns multiplies the work by eight. A system of 10 equations costs about a thousand operations and is instant. A system of 1000 equations — an ordinary size in engineering work — costs about a thousand million, which is a different kind of task altogether, and it is why so much effort goes into methods that exploit special structure in the coefficients.
14.8.3 The Cost of a Recursive Method
A recursive method contains no loop, so the counting rules of section 14.2 do not apply directly. The question to ask instead is: how many calls are made altogether, and what does each call cost on its own.
| Call | Value of n |
Base case reached | Value returned |
|---|---|---|---|
| 1 | 6 | no | \(6 \times 120 = 720\) |
| 2 | 5 | no | \(5 \times 24 = 120\) |
| 3 | 4 | no | \(4 \times 6 = 24\) |
| 4 | 3 | no | \(3 \times 2 = 6\) |
| 5 | 2 | no | \(2 \times 1 = 2\) |
| 6 | 1 | yes | 1 |
Result: factorial(6) returns 720. Read the table downwards to follow the calls being made, and upwards to follow the values coming back — the sixth call returns first, and each earlier call multiplies its own n into the value it receives. Six calls were made for \(n = 6\).
Diagram
Fig. 14.8-2 – four boxes stacked vertically and labelled from the top factorial(4), factorial(3), factorial(2), factorial(1); a downward arrow on the left labelled “calls made, each waiting for the one below”; an upward arrow on the right labelled “values returned: 1, 2, 6, 24”; a bracket beside the stack labelled “depth 4 — memory held at the deepest point”
| Feature | Iterative version | Recursive version |
|---|---|---|
| How repetition is obtained | A loop | The method calls itself |
| Time complexity for factorial | \(O(n)\) | \(O(n)\) |
| Extra memory used | A fixed number of variables, \(O(1)\) | One stored call per level of depth, \(O(n)\) |
| What ends the repetition | The loop condition becoming false | The base case being reached |
| Failure when it goes wrong | The loop never ends | The calls never end and the memory holding them runs out |
| Chief merit | Cheap in memory | Often much closer to the definition of the problem |
Recursion, then, does not change the number of basic operations here — it changes where the cost is paid. The time is the same as the loop’s; the memory is not.
One caution completes the picture. Not every recursive method is \(O(n)\). The factorial method makes one call at each level, so the number of calls equals the depth. A method that calls itself twice at each level doubles the number of calls from one level to the next, giving about \(2^n\) calls in total — the exponential row of Table 14.9, and unusable for any but the smallest \(n\). When analysing a recursive method, count the calls; the depth alone is not the answer.
14.9 Summary
What complexity measures
An algorithm is a finite, ordered set of unambiguous steps that takes an input and produces the required output. Computational complexity is the study of what carrying out those steps costs, and of how that cost grows as the data grows.
The input size, written \(n\), is the number that measures how much data the algorithm is given. Every statement of complexity is meaningless until it is said what \(n\) counts — the elements of an array, the characters of a string, the rows of a table.
Time complexity is the number of basic operations performed, expressed as a function of \(n\). Space complexity is the memory required, expressed as a function of \(n\). The two are quoted separately and can be traded against each other.
Complexity is obtained by counting operations, not by timing a run. A measured time belongs to one machine on one afternoon; a step count belongs to the algorithm, can be worked out by reading the code, and predicts behaviour at input sizes not yet tested.
A faster machine divides the time per step by a fixed factor. It does not change the number of steps, so it cannot rescue a method whose step count grows badly.
Counting the constructs
A simple statement runs in constant time: its operation count does not depend on \(n\). A method call costs whatever the method costs, not one operation.
Statements written one after another add their counts. A conditional costs the test plus the branch actually taken, and where one figure is wanted the costlier branch is used.
A loop costs the number of times its body is entered multiplied by the cost of one execution. A loop whose bound is a fixed number is constant time; a loop tied to \(n\) is proportional to \(n\).
A loop inside a loop multiplies the counts. Two full-range nested loops execute the inner body \(n^2\) times; when the inner range starts where the outer has reached, the count is \(\dfrac{n(n-1)}{2}\), which is still governed by \(n^2\).
A loop that halves the remaining data on each pass makes about \(\log_2 n\) passes, because \(n/2^k = 1\) gives \(k = \log_2 n\). Doubling the data adds one pass, not twice as many.
Dominant term, constants and big O notation
The dominant term of a step-count expression is the term that grows fastest as \(n\) increases. For large enough \(n\) it accounts for almost the whole cost, so the remaining terms may be dropped. It is chosen by rate of growth, never by which term is largest at one particular input.
The constant multiplying the dominant term is dropped as well, because a constant factor is what a change of machine, compiler or coding style produces. A constant fixes only where two growth curves cross, never whether they cross.
Big O notation: an algorithm has complexity \(O(f(n))\) if, from some input size onwards, its step count is at most a constant multiple of \(f(n)\). The clause “from some input size onwards” is part of the claim — big O says nothing about small inputs.
Writing the notation: constants and lower-order terms are removed from inside the brackets, so \(3n^2 + 50n + 200\) becomes \(O(n^2)\) and \(2n\) becomes \(O(n)\). A method whose cost does not grow with \(n\) is \(O(1)\), which means a fixed number of steps and not necessarily one step. The base of a logarithm is usually omitted, since changing it alters only a constant factor.
The common orders of growth, from cheapest to costliest, are \(O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)\).
Doubling the input leaves an \(O(1)\) cost unchanged, adds one step to \(O(\log n)\), doubles \(O(n)\), quadruples \(O(n^2)\), multiplies \(O(n^3)\) by eight and squares the step count of \(O(2^n)\).
Constants still matter in two situations big O ignores: between two methods of the same order, where the constant is all that separates them, and below the crossing point, where the method with the worse order may genuinely be the faster.
Best, average and worst case
The three cases compare different inputs of the same size, not different sizes. Best case is the fewest steps over all inputs of size \(n\), worst case the most, average case the mean taken under a stated assumption about how likely each input is.
Worst case is usually quoted, because it is a guarantee that holds for every input. Best case alone distinguishes little. Average case is the most realistic and the least secure, since it rests on an assumption about the data that may not hold.
Costs of the standard methods
Linear search needs no order in the data. It costs 1 comparison at best, \(n\) at worst and about \(\dfrac{n+1}{2}\) on average for a successful search with the value equally likely at any position — that is \(O(1)\), \(O(n)\) and \(O(n)\), with \(n\) the number of elements.
Binary search requires the array to be sorted. Each comparison discards half the remaining range, so it costs about \(\log_2 n\) comparisons in the worst case, giving \(O(\log_2 n)\). On a million elements that is about twenty comparisons against a possible million for linear search. Applied to unsorted data it reports present values as absent.
Sorting is what buys the right to use binary search, and the three techniques on this course all cost \(O(n^2)\) in the worst case. Sorting once to run a single search is a poor bargain; sorting once to serve many searches is a good one.
Bubble sort exchanges adjacent elements that are out of order. It makes \(\dfrac{n(n-1)}{2}\) comparisons in every case, so best, average and worst are all \(O(n^2)\), and it may make as many exchanges as comparisons. Ending the sweeps early when a whole sweep passes without an exchange gives it a best case of \(O(n)\).
Selection sort finds the smallest element of the unsorted part and places it. It makes \(\dfrac{n(n-1)}{2}\) comparisons in every case, so all three cases are \(O(n^2)\), but it performs exactly \(n-1\) exchanges, which makes it the choice when moving an element is expensive.
Insertion sort inserts each element into an ordered front part. It costs \(n-1\) comparisons on data already in order, which is \(O(n)\), and \(\dfrac{n(n-1)}{2}\) on data in exactly descending order, which is \(O(n^2)\); the average case is \(O(n^2)\). It is the only one of the three whose cost falls when the data is favourable.
All three sorting techniques rearrange the array in place and need only a fixed number of extra variables, so their space demands are identical.
Other methods analysed
A method that visits every cell of a table of \(n\) rows and \(n\) columns is \(O(n^2)\) where \(n\) is the number of rows — yet it reads each of the \(n^2\) stored values exactly once, so it is doing the least work possible on that data. A nested loop is not evidence of waste; repeated passes over the same data are.
Multiplying two \(n \times n\) matrices needs three nested loops and is \(O(n^3)\).
Solving \(n\) linear equations in \(n\) unknowns by elimination costs about \(n^3\) operations for the elimination and about \(n^2\) for the back substitution, so the method is \(O(n^3)\), with \(n\) the number of unknowns.
For a recursive method, the count to make is the number of calls and the cost of each. Recursive factorial makes \(n\) calls each doing constant work, so it is \(O(n)\) in time; it is also \(O(n)\) in space, because every call not yet returned is held in memory, where an iterative version needs only \(O(1)\). A method that calls itself twice at each level makes about \(2^n\) calls and is unusable except at the smallest sizes.
14.10 Key Terms
Algorithm — a finite, ordered set of unambiguous steps that takes an input and produces the required output.
Average case complexity — the number of steps an algorithm takes averaged over all inputs of size \(n\), calculated under a stated assumption about how likely each input is.
Base case — the condition in a recursive method under which it returns a value directly instead of calling itself, so that the chain of calls comes to an end.
Basic operation — a single elementary action performed in a fixed amount of time, such as one comparison, one assignment, one arithmetic operation or one array access.
Best case complexity — the smallest number of steps an algorithm takes over all inputs of size \(n\).
Big O notation — a way of stating an algorithm’s cost: it has complexity \(O(f(n))\) if, from some input size onwards, its step count is at most a constant multiple of \(f(n)\).
Binary search — a searching method for data already in sorted order, which compares the value sought with the middle element and discards half the remaining range at each step.
Bubble sort — a sorting technique that repeatedly sweeps the array, exchanging each adjacent pair found out of order, so that one element settles into its final position on every sweep.
Comparison — in the analysis of searching and sorting, the operation of testing one element against another or against a sought value; the operation usually counted when the cost is quoted.
Computational complexity — the study of what an algorithm costs to carry out, and of how that cost grows as the input grows.
Constant time — the property of a statement or method whose number of basic operations does not depend on the input size; written \(O(1)\).
Dominant term — the term of a step-count expression that grows fastest as \(n\) increases, and which alone is kept when the cost is reported.
Exchange — the operation of swapping the values held in two positions of an array; counted separately from comparisons because moving data is the more expensive of the two.
Input size — a single number, written \(n\), measuring how much data an algorithm is given; every statement of complexity must say what \(n\) counts.
Insertion sort — a sorting technique that keeps the front of the array in order and inserts each remaining element into that ordered part, shifting larger elements one place to the right.
Linear search — a searching method that examines the elements one after another from the beginning until the value is found or the data is exhausted; it requires no order in the data.
Nested loop — a loop placed inside the body of another loop, so that the inner loop runs through its whole range on every pass of the outer one and the two counts multiply.
Order of growth — the pattern an algorithm’s cost follows as the input size increases, such as constant, logarithmic, linear, quadratic, cubic or exponential.
Selection sort — a sorting technique that finds the smallest element of the unsorted part of the array and exchanges it into the position at the front of that part.
Space complexity — the amount of memory an algorithm requires, expressed as a function of the input size \(n\).
Time complexity — the number of basic operations an algorithm performs, expressed as a function of the input size \(n\).
Worst case complexity — the largest number of steps an algorithm takes over all inputs of size \(n\); the figure usually quoted, because it holds for every input.
14.11 Points to Ponder
Big O deliberately discards constant factors. Suppose a program is rewritten so that it runs ten times slower on every input, while its order of growth is unchanged. Nothing in its big O description has altered — so what, exactly, is big O for, and what should be used to describe the change that did happen?
Sorting an array costs \(O(n^2)\) with the techniques of this chapter, and it buys searches at \(O(\log_2 n)\) instead of \(O(n)\). How many searches must a list receive before the sorting has paid for itself? What does your answer depend on besides \(n\) — and what happens to it if the list is altered between searches?
The worst case is quoted because it is a guarantee that holds for every input. Consider a method that is quick on every input anyone is ever likely to supply, and catastrophic on one contrived arrangement of the data. Is it honest to describe that method by its worst case? Is it safe not to?
A method that visits every cell of an \(n \times n\) table is \(O(n^2)\) in the number of rows and \(O(N)\) in the number of cells, and both statements are correct. If two correct descriptions of the same method can sound so different, what does that ask of anyone who quotes a complexity — and how would you decide which \(n\) to choose?
Recursive factorial and iterative factorial perform the same number of multiplications, but one holds \(n\) unfinished calls in memory and the other holds none. When is a method that is easier to read but heavier on memory the better method, and who should make that judgement — the person writing the program or the person who will maintain it?
Counting steps is machine-independent, which is exactly why it is preferred to timing a run. Yet programmers still measure running times constantly. What can a measurement tell you that a step count cannot, and is there any question about an algorithm that both methods answer equally well?
14.12 Objective Questions
- The term of a step-count expression that grows fastest as the input size increases is called the: [1 mark]
- constant term
- dominant term
- leading index
- input size
- A loop that discards half of the remaining data on every pass makes about how many passes for an input of size n? [1 mark]
- \(n/2\)
- \(n\)
- \(\log_2 n\)
- \(n^2\)
- Which of the following can be applied only to data already arranged in sorted order? [1 mark]
- linear search
- binary search
- bubble sort
- insertion sort
- The number of exchanges performed by selection sort on an array of n elements is at most: [1 mark]
- 1
- \(n - 1\)
- \(\dfrac{n(n-1)}{2}\)
- \(n^2\)
- A method performs exactly 40 basic operations for every input, whatever its size. Its time complexity is: [1 mark]
- \(O(40)\)
- \(O(n)\)
- \(O(1)\)
- \(O(\log n)\)
Fill in the blank: doubling the input size multiplies the step count of an \(O(n^2)\) method by ____________ . [1 mark]
Fill in the blank: a statement of complexity is incomplete until it has been said what ____________ counts. [1 mark]
State whether true or false: insertion sort makes the same number of comparisons whatever the arrangement of the data. [1 mark]
State whether true or false: a computer twice as fast halves the number of steps an algorithm performs. [1 mark]
Match each method in Column A with its worst case time complexity in Column B, where n is the number of elements or the number of rows as appropriate. [1 mark]
| Column A | Column B |
|---|---|
| a. linear search | i. \(O(n^2)\) |
| b. binary search | ii. \(O(n^3)\) |
| c. selection sort | iii. \(O(n)\) |
| d. multiplication of two \(n \times n\) matrices | iv. \(O(\log_2 n)\) |
- Assertion (A): A method whose step count is \(2n^2 + 500n\) is described as \(O(n^2)\). Reason (R): In big O notation, constant factors and lower-order terms are discarded. Choose the appropriate option: [1 mark]
- Both Assertion (A) and Reason (R) are true and Reason (R) is the correct explanation of Assertion (A).
- Both Assertion (A) and Reason (R) are true but Reason (R) is not the correct explanation of Assertion (A).
- Assertion (A) is true but Reason (R) is false.
- Assertion (A) is false but Reason (R) is true.
- Assertion (A): Binary search may be applied to an array whose elements are in any order. Reason (R): Binary search discards half of the remaining range after each comparison. Choose the appropriate option: [1 mark]
- Both Assertion (A) and Reason (R) are true and Reason (R) is the correct explanation of Assertion (A).
- Both Assertion (A) and Reason (R) are true but Reason (R) is not the correct explanation of Assertion (A).
- Assertion (A) is true but Reason (R) is false.
- Assertion (A) is false but Reason (R) is true.
14.13 Check Your Concepts
Define the input size of an algorithm, and state why a complexity claim is incomplete without it. [2 marks]
Define constant time, and give one example of a statement that runs in constant time. [2 marks]
State what is meant by the dominant term of a step-count expression, and name the dominant term of \(7n^3 + 200n^2 + 5000\). [2 marks]
Differentiate between time complexity and space complexity, giving one example of a method for which the two behave differently. [3 marks]
A method’s step count works out to \(4n^2 + 12n + 9\). Express its complexity in big O notation, and justify each simplification you make. [3 marks]
Differentiate between best case and worst case complexity, using linear search as your example and stating the input that produces each. [3 marks]
Explain, by counting the halvings, why binary search costs about \(\log_2 n\) comparisons in the worst case. State clearly what \(n\) counts. [4 marks]
State the best case, average case and worst case complexity of insertion sort, and explain what arrangement of the data produces each. [4 marks]
Explain why a computer ten times faster is of almost no help to a method of complexity \(O(2^n)\), while it materially helps a method of complexity \(O(n)\). [4 marks]
Compare bubble sort and selection sort with respect to comparisons, exchanges, best case complexity and the kind of data each suits. State, with reasons, which of the two you would choose for arranging 500 records in which each record is large and expensive to move. [6 marks]
A list of n names is searched repeatedly. Sorting it with the techniques of this chapter costs \(O(n^2)\); thereafter each lookup costs \(O(\log_2 n)\) by binary search, against \(O(n)\) by linear search on the unsorted list. Set out, with reasoning, the circumstances under which sorting the list first is worthwhile, and the circumstances under which it is not. [6 marks]
14.14 Output & Debugging
- Study the fragment and answer the questions that follow. [3 marks]
int n = 5;
int c = 0;
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= i; j++)
{
c = c + 1;
}
}
System.out.println(c);(a) State the value printed. (b) Write an expression in n for the number of times the statement c = c + 1 is executed. (c) State the complexity of the fragment in big O notation.
- The fragment below is intended to display the largest value in the array, but it fails when run. [3 marks]
int m[] = {4, 9, 2, 7};
int max = m[0];
for (int i = 1; i <= m.length; i++)
{
if (m[i] > max)
{
max = m[i];
}
}
System.out.println(max);(a) State what happens when the fragment is executed and name the fault. (b) Rewrite the line that is at fault. (c) State the complexity of the corrected fragment in terms of the number of elements.
- Study the fragment and answer the questions that follow. [3 marks]
int size = 100;
int count = 0;
while (size > 1)
{
size = size / 2;
count = count + 1;
}
System.out.println(count);(a) State the value printed. (b) State the value held in size immediately before the loop ends. (c) Name the order of growth this loop illustrates, and say which searching method of this chapter depends on it.
- Study the method below and answer the questions that follow. [3 marks]
static int mystery(int m[], int k)
{
int c = 0;
for (int i = 0; i < m.length; i++)
{
if (m[i] < k)
{
c = c + 1;
}
}
return c;
}(a) State in one sentence what the method returns. (b) State the number of comparisons m[i] < k it performs on an array of n elements. (c) State whether its cost depends on the values stored in the array, giving a reason.
- The fragment below is intended to compute \(2^n\) but does not terminate. [3 marks]
int n = 10;
int i = 1;
int p = 1;
while (i <= n)
{
p = p * 2;
}
System.out.println(p);(a) Explain why the loop never ends. (b) Add the single statement needed to correct it, stating where it must be placed. (c) State the complexity of the corrected fragment in terms of n.
14.15 Programming Practice
A shop keeps the number of units of each of its items in an integer array. Write a class with a method that searches the array for a given quantity using linear search, returning the index at which it is found or \(-1\) if it is absent, and counting the comparisons it makes. The
main()method must test the search twice — once with a quantity stored in the last position of the array and once with a quantity that is not present at all — displaying the result and the comparison count each time. Submit: the algorithm in pseudocode, the complete class, a variable description table, and one line stating the worst case complexity of your method with what n counts. [6 marks]Write a class that arranges an integer array in ascending order using selection sort. The sorting method must count the comparisons and the exchanges it performs, and the program must display the sorted array together with both counts. Run the program on an array of eight values already in ascending order and again on the same eight values in descending order. Submit: the complete class, a variable description table, and a short note stating whether either count changed between the two runs and explaining why. [6 marks]
A table of examination marks has \(r\) rows, one per student, and \(c\) columns, one per subject. Write a single method that accepts such a two-dimensional array and returns the highest mark stored anywhere in it. The method must not assume that the table is square, and must not use any sorting. Submit: the method alone, together with one line stating its complexity in terms of \(r\) and \(c\), and one line explaining why that figure cannot be improved upon for this task. [4 marks]