Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Algorithms and Data Structures

Choose the right structure and justify the choice: what a program costs, sorting and searching, trees and graphs, and the strategies that keep working.

Counting the cost

A program that returns the right answer can still be worthless, and nothing in an introductory programming course gives you a way to say so.

Two correct programs

Here is a task with no ambiguity in it. Given a list of n numbers, say whether any value appears twice. Both of the following solve it, and both are correct in the sense that they return the right answer on every input.

The first compares every element with every later element. For a list of six numbers that is five comparisons involving the first element, four more involving the second, and so on down to one, which is fifteen comparisons in total. In general it is n(n-1)/2: for a hundred numbers, 4950 comparisons; for a million, 499,999,500,000.

The second sorts the list first and then walks it once, checking each element against its neighbour. Sorting a million numbers well takes roughly nlog2n comparisons, about 19.9 million, and the walk adds a million more. Call it 21 million against 500 billion.

If the machine performs a billion comparisons a second, and both programs are otherwise identical in every respect, the first finishes in about eight minutes and the second in about a fiftieth of a second. The ratio at a million items is roughly 25,000. Push the input to a hundred million and the ratio passes 1.8 million: two and a half seconds against nearly two months.

Nothing in the previous course could distinguish those two programs. Correctness is a property they share. The difference between them is a property nobody has yet named, and this lesson names it: cost, meaning how the resources a program consumes grow as its input grows. The whole subject is the study of that quantity, because it is the only property of a program, apart from correctness, that survives being ported to a different machine.

The stopwatch measures the machine

The obvious way to measure cost is to run the program and time it. This is a real measurement and it is worth doing, but on its own it answers the wrong question.

Suppose you time the nested-loop version on a laptop and it takes 5.0 seconds on 100,000 numbers. What have you learned? That number depends on the processor's clock speed, on how much of the array fitted in cache, on whether the language was compiled or interpreted, on whether a browser was updating in the background, and on how many times the just-in-time compiler had already seen that loop. Move to a machine twice as fast and it becomes 2.5 seconds. Rewrite the same algorithm in C and it might become 0.8 seconds. None of those changes tell you anything about the algorithm, because the algorithm never changed.

Worse, the stopwatch answers only for the input you happened to try. Timing at n=100{,}000 does not tell you what happens at n=107, and the whole reason to care is that inputs grow. A measurement that has to be repeated for every size and every machine is not a theory, it is a table.

What is wanted is a number attached to the algorithm rather than to the run: a count of the operations the algorithm performs, expressed as a function of the size of its input. Two things follow immediately. First, "operation" has to be pinned down, since the count is meaningless until you say what you are counting. Second, the count has to be a function of a single number n, which means deciding what "size" means.

A model of a machine

The standard idealisation is the RAM model, short for random access machine, and it is the model every complexity claim in this course is made against. It says: the machine has an unbounded array of memory cells, each holding one value; reading or writing any cell costs one step regardless of its address; and a fixed set of primitive operations on those values, arithmetic, comparison, assignment, and the jumps that implement control flow, each cost one step.

Under that model, counting is mechanical. A single assignment is one step. A loop body of k steps run m times is km steps. Indexing a[i] is one step, whether i is 0 or 999,999.

The model is a lie in three specific places, and knowing where it lies is what stops you trusting it too far. It says all memory access costs the same, but real machines have caches, and a read that hits the fastest cache can be a hundred times quicker than one that goes to main memory. It says arithmetic on a value costs one step, which is true for machine-word integers and false for the thousand-digit numbers used in cryptography, where multiplication has its own cost function. And it counts steps rather than joules or bytes, so a program that is fast and needs an extra copy of the input looks free.

Nevertheless it is the right default. It is machine-independent, it is simple enough to count in, and its predictions about which of two algorithms wins at large n are almost always right. Where it fails, later lessons say so explicitly: the memory hierarchy is exactly why B-trees exist, and space is exactly the price mergesort pays.

Counting steps against input size

Size means the number of things in the input: the length of the array, the number of vertices in a graph, the number of characters in a string. It is written n, and where two quantities matter independently, as with a graph's V vertices and E edges, both are named.

Take the nested-loop duplicate check and count it exactly. The outer loop runs with i from 0 to n-2. For each i, the inner loop runs with j from i+1 to n-1, so it performs n-1-i comparisons. The total is

i=0n-2(n-1-i)=(n-1)+(n-2)++1=n(n-1)2

If each pass of the inner loop costs a comparison, an increment and a bounds test, three steps, the program costs about 3n(n-1)/2 steps plus a fixed amount of setup. Written out, 1.5n2-1.5n+c.

Example. How many comparisons does the nested-loop duplicate check perform on a list of 100 numbers, and on a list of 1000?

Substitute into n(n-1)/2. For n=100 that is 100×99/2=4950. For n=1000 it is 1000×999/2=499{,}500. The input grew by a factor of 10 and the work grew by a factor of about 101, which is the quadratic behaviour showing itself: the ratio approaches 102 from above because of the -n term.

Now you. How many comparisons does it perform on a list of 500 numbers? By what factor does that exceed the count for 100?

Answer

500×499/2=124{,}750 comparisons. Against 4950 for n=100, the ratio is 124{,}750/4950=25.2, close to the 52=25 that a purely quadratic cost would give.

Three questions, not one

An exact count of n(n-1)/2 is unusually tidy, because that algorithm does the same work on every input of a given size. Most do not. Linear search, scanning an array from the front until the target is found, is the standard illustration.

If the target sits in the first cell, the search costs one comparison. If it sits in the last, or is absent, it costs n. So "the cost of linear search on n elements" is not a single number, and pretending otherwise is how people end up surprised. There are three separate questions, and they have three separate answers.

The best case is the smallest cost over all inputs of size n: one comparison. It is nearly useless for prediction, because it describes the luckiest input rather than a typical one, and any algorithm can be made to look good on a case chosen for it.

The worst case is the largest cost over all inputs of size n: n comparisons. It is the default in this course and in most of the literature, for two reasons. It is a guarantee, which is what you need when the program controls an aircraft or a trading system, and it is a claim about all inputs, so it needs no assumption about which inputs occur.

The average case is the expected cost over some distribution of inputs, and the phrase hides the important part: which distribution. Suppose the target is present and equally likely to be in any of the n positions. The cost is i comparisons when the target is at position i, each with probability 1/n, so the expected cost is

1ni=1ni=1nn(n+1)2=n+12

about half the array. That number is only as good as the assumption. If the target is usually absent, every search costs n. If the data are requests to a web cache, where a few keys are asked for constantly, the average is far below n/2 and the uniform model is simply wrong. An average-case claim without a stated distribution is not a result.

Example. An array holds 1000 distinct values. Under the assumption that the searched-for value is present and equally likely to be at any position, what is the expected number of comparisons for linear search, and what is the worst case?

Expected cost is (n+1)/2=1001/2=500.5 comparisons. Worst case is n=1000, when the value is in the last cell. Note that the average is not half the worst case by coincidence: it is (n+1)/2 against n, a ratio approaching 1/2 as n grows.

Now you. The same array, but now half of all searches are for values that are not in the array at all, and the other half are for values uniformly placed within it. What is the expected number of comparisons?

Answer

An absent value costs the full 1000 comparisons, since the scan must reach the end to be sure. So the expectation is 0.5×1000+0.5×500.5=500+250.25=750.25 comparisons. Changing the input distribution, with no change at all to the algorithm, raised the average by half.

What "size" means, exactly

One trap deserves naming early, because it recurs in the last lesson of this course.

Consider testing whether an integer N is prime by trying every divisor from 2 up to N. That is about N operations, which sounds cheap. For N around 1018 it is 109 divisions, a second or so.

But the input to that program is not N things. It is the number N, written down, which takes about log2N bits: 60 bits for 1018. So the input size is b=60, and the cost N=2b/2 is exponential in the size of the input. Adding two digits to N multiplies the work by ten. At 200 digits, the numbers used in RSA, trial division would take longer than the age of the universe, and that is precisely why the method is safe to build a cryptosystem on.

The lesson is that n counts symbols in the input, not the magnitude of a number in it. For arrays and graphs the two coincide and nobody thinks about it. For algorithms whose input is a number, they do not, and the distinction is exactly what separates a polynomial-time algorithm from an exponential one.

Example. How many divisions does trial division perform on N=1012, and how many bits long is that input?

The divisor runs up to N=106, so about a million divisions, which is instant. The input is log21012=39.9 bits, so 40 bits, and the cost 106 is close to 220=2b/2 with b=40.

Now you. Repeat for N=1024.

Answer

N=1012 divisions, about seventeen minutes at a billion per second, and the input is log21024=79.7 bits, so 80. Doubling the length of the input squared the work, which is the signature of exponential cost in the input size even though N looks like a modest function of N.

Why exact counts do not survive

So the duplicate check costs 1.5n2-1.5n+c steps. Now try to defend that expression to somebody with a different compiler.

The 1.5 came from a guess that a comparison, an increment and a bounds test cost one step each. On a real processor, the comparison and the test may fuse into one instruction, the increment may be free because it overlaps with a memory load, and the whole loop may be vectorised to handle four elements at once. The constant is not 1.5. It is not knowable without naming the machine, and once you name the machine you are back to timing.

The -1.5n term is more solidly derived, since it comes from the arithmetic rather than the hardware. But look at what it contributes. At n=1000 the quadratic term is 1,500,000 and the linear term subtracts 1500, one part in a thousand. At n=106 it is one part in a million. Any term of lower degree is invisible at the sizes where cost actually matters.

What is left after discarding the indefensible constant and the invisible lower terms is the shape: this cost grows like n2. That single fact predicted the eight minutes against the fiftieth of a second at the start of this lesson, and it did so without knowing the machine. The next lesson makes "grows like" precise, with a definition that has explicit witnesses and can be proved rather than waved at, and then prices the resulting hierarchy of shapes in real seconds.

How cost grows

An exact step count is machine-specific and unusable, so the previous lesson ended by throwing away everything except the shape of the growth, and this lesson makes "shape" a definition rather than a gesture.

What the definition has to do

The claim to be formalised is "this cost grows like n2". Three properties are wanted from it.

It must ignore constant factors, because the constants come from the hardware and the compiler and cannot be defended. It must ignore lower-order terms, because at the sizes where cost matters they contribute a vanishing fraction. And it must be provable, so that a claim can be settled rather than argued about.

The standard device does all three at once. Instead of asking whether two functions are equal, ask whether one is eventually bounded by a multiple of the other. "Eventually" throws away small n, which is where lower-order terms live. "A multiple of" throws away the constant. And both are witnessed by numbers you can write down, which makes the claim checkable.

The notation is Paul Bachmann's, from 1894, popularised in analysis by Edmund Landau and brought into computing by Donald Knuth in a 1976 note that fixed the definitions used here.

Big O: an upper bound

Let f(n) and g(n) be functions from positive integers to non-negative reals. Then

f(n)=O(g(n))c>0,n0>0 such that f(n)cg(n) for all nn0

The pair (c,n0) is called a witness. To prove a big-O claim you produce one; to refute a claim you show no pair can exist. Nothing else is involved, and in particular no limits are needed.

The equals sign is an abuse of notation that everyone commits and nobody defends. O(g) is really a set of functions, and f=O(g) means fO(g). The abuse is harmless as long as you never read it symmetrically: n=O(n2) is true and O(n2)=n is nonsense.

Example. Show that 3n2+5n+100=O(n2) by producing a witness.

The strategy is to bound each term by a multiple of n2. For the middle term, 5n0.5n2 whenever n10. For the constant, 100n2 whenever n10. So for all n10,

3n2+5n+1003n2+0.5n2+n2=4.5n2

The witness is c=4.5, n0=10. Check it at the boundary: f(10)=300+50+100=450 and 4.5×100=450, so the inequality holds with equality there, and at n=9 it fails (388>364.5), which is why n0 could not be smaller for this particular c. Witnesses are never unique: c=108, n0=1 works too, and is just as valid a proof.

Now you. Produce a witness showing 2n3+7n2+3=O(n3).

Answer

Bound each term: 7n2n3 when n7, and 3n3 when n2. So for n7, 2n3+7n2+32n3+n3+n3=4n3, giving the witness c=4, n0=7. Checking at n=7: 686+343+3=10324×343=1372. The bound in fact holds from n=5 onward, which is fine: a witness only has to work, not to be the smallest.

Omega and Theta: the other two bounds

Big O alone is a weak statement, because it is an upper bound and nothing stops it being loose. Every algorithm in this course is O(2n), and saying so tells you nothing. Two companions fix that.

f(n)=Ω(g(n)) if there are c>0 and n0 with f(n)cg(n) for all nn0: a lower bound, used to say an algorithm cannot do better than something, and used for problems rather than algorithms when proving that no algorithm can.

f(n)=Θ(g(n)) if both hold: there are c1,c2>0 and n0 with c1g(n)f(n)c2g(n) for nn0. This is the tight statement, and it is what "grows like" actually means.

So 3n2+5n+100 is Θ(n2): the upper witness is above, and for the lower, 3n2+5n+1003n2 for every n1, giving c1=3, n0=1. It is also O(n3), truthfully but uselessly, and it is not Θ(n3), because no c1>0 has 3n2+5n+100c1n3 eventually: divide by n3 and the left side goes to zero while c1 does not.

Refutations work the same way, by contradiction on the witness. Is n2=O(n)? Suppose a witness (c,n0) existed, so n2cn for all nn0. Divide by n: nc for all nn0. But n is unbounded, so take n=max(c,n0)+1 and the inequality fails. No witness exists.

Prefer Θ wherever you can prove it. Most published results are stated with O out of caution, since an upper bound is what a guarantee needs, but when a source says an algorithm is O(nlogn) and you want to know whether it might secretly be linear, the answer is usually that the author could have written Θ and chose the weaker word.

The hierarchy, priced

Definitions settle arguments; a table shows why anyone cares. Assume a machine performing 109 steps per second, which is the right order of magnitude for a single core running simple loop code.

costn=100n=1000n=106
log2n6.6 ns10.0 ns19.9 ns
n100 ns1.0 µs1.0 ms
nlog2n664 ns10.0 µs19.9 ms
n210.0 µs1.00 ms16.7 minutes
n31.00 ms1.00 s31.7 years
2n4×1013 yearsbeyond writingbeyond writing

Read the columns rather than the rows. At n=100 every one of the polynomial costs is imperceptible, and choosing between them is a waste of a morning. At n=106 the same choices are the difference between an instant response, a coffee break and a career.

The entry worth staring at is 2n. At n=50 an exponential algorithm running a billion steps a second takes thirteen days; at n=60 it takes 36.5 years. Ten more items, a factor of a thousand. Buying a machine a thousand times faster buys you ten more items. That is why the last lesson of this course treats exponential cost as a qualitative barrier rather than a large number.

Example. A quadratic algorithm costing exactly n2 steps handles n=104 in 0.1 seconds on a given machine. A colleague proposes running it on n=106. How long will that take, and would a machine 100 times faster fix it?

Cost scales as n2, and n grew by a factor of 100, so the work grows by 1002=104. The time becomes 0.1×104=1000 seconds, about 17 minutes. A machine 100 times faster brings that to 10 seconds, which is a genuine improvement, but note what the same money buys on the input side: at fixed time budget, a machine 100 times faster handles only 10 times the input, since 100=10. Hardware buys you a square root; a better algorithm buys you a different exponent.

Now you. A cubic algorithm takes 1.0 second on n=1000. How long does it take on n=5000?

Answer

The input grew by a factor of 5, and cost grows as n3, so the work grows by 53=125. The time is 125 seconds, about two minutes. Going to n=105 instead would be a factor of 1003=106, or about 11.6 days.

Combining bounds

Four rules cover almost every analysis you will do, and each follows from the definition in a line or two.

Sums take the maximum. If f1=O(g1) and f2=O(g2) then f1+f2=O(max(g1,g2)). Two loops one after the other, one linear and one quadratic, cost O(n2) in total. This is why the dominant term is the only one written.

Products multiply. A loop running n times whose body costs O(n) costs O(n2). A loop running n times whose body costs O(logn) costs O(nlogn), which is the shape of "sort by inserting each element into a balanced tree".

Constants disappear. O(3n)=O(n), and logbn=log2n/log2b, so the base of a logarithm is a constant factor and vanishes: O(logn) needs no base. Note that this is not true in an exponent, where 2n and 3n differ by 1.5n, which is not a constant.

Nested loops are not automatically n2. A loop where the inner bound depends on the outer index needs the sum evaluated. The nested duplicate check of the previous lesson runs its inner loop n-1-i times, summing to n(n-1)/2=Θ(n2), whereas a loop that halves a counter each pass runs Θ(logn) times regardless of the outer loop.

Example. A function loops i from 1 to n, and for each i it loops j from 1 to i, doing constant work in the body. Then it makes one pass over the array doing constant work. What is the total cost, tightly?

The double loop performs i=1ni=n(n+1)/2 constant-cost iterations, which is Θ(n2). The single pass is Θ(n). By the sum rule the total is Θ(max(n2,n))=Θ(n2), and the Θ is justified because the double loop is bounded both above and below by multiples of n2: n(n+1)/2n2/2 and n2.

Now you. A function loops i from 1 to n, and for each i it repeatedly halves a counter starting at n until it reaches 1, doing constant work each halving. What is the cost?

Answer

The inner loop runs log2n+1 times, which is Θ(logn) and does not depend on i. By the product rule the total is Θ(nlogn). The common mistake is to call it Θ(n2) because two loops are nested; what matters is how many times each runs, not how deeply they sit.

Four ways this misleads

The notation is a tool for discarding information, and it is worth being explicit about what gets discarded, because every one of these has cost somebody a week.

Constants matter at real sizes. Suppose insertion sort costs n2/4 steps and mergesort costs 8nlog2n, which are plausible ratios once the recursion, the allocation and the copying in mergesort are counted. Setting them equal gives n=32log2n, solved by n=256. Below 256 elements the "worse" algorithm wins, and at n=50 it wins by a factor of 3.6. This is not a curiosity: real sort implementations switch to insertion sort on small blocks for exactly this reason, and Timsort, the sort in Python and in Java's Arrays.sort for objects, chooses a minimum run length between 32 and 64 and insertion-sorts anything shorter.

O is an upper bound, and people read it as tight. "Quicksort is O(n2)" and "quicksort is O(nlogn) on average" are both true statements about the same algorithm, and a reader who does not notice which bound is being quoted will draw the wrong conclusion. Worse is the common phrase "at least O(n)", which is meaningless: O is already an upper bound, so "at least an upper bound" says nothing. The word wanted there is Ω.

Asymptotic means eventually, and eventually can be absurd. Strassen's 1969 matrix multiplication algorithm costs Θ(nlog27)=Θ(n2.807) against the naive Θ(n3), and it does win on real hardware, with measured crossovers in tuned implementations somewhere from a few dozen to a few hundred. The descendants of Coppersmith and Winograd's method go much further, down to an exponent of about 2.372 in the current record, and none of them beats anything at any size a computer will ever be given, because their constants are astronomical. Such results are called galactic algorithms, and they are genuine mathematics with no engineering content.

Steps are not the only resource. The count says nothing about memory, and mergesort's extra array is the reason quicksort survives. It says nothing about cache behaviour, and a Θ(n) walk over a linked list scattered through memory can lose to a Θ(nlogn) pass over a contiguous array. It says nothing about the cost of the operations themselves, and every claim so far has quietly assumed that comparing two elements and reading a[i] cost the same fixed amount.

That last assumption is the one to attack next. Whether reading an element costs a constant depends entirely on how the elements are arranged in memory, and the two arrangements available, one block or a chain of separately allocated cells, give completely different cost tables for exactly the same operations. The next lesson starts with the block.

Arrays and amortised cost

Every cost claim so far assumed that reading a[i] costs one step, and whether that is true depends entirely on how the elements are laid out in memory.

One block, and what an index really is

An array is a single contiguous block of memory holding n elements of equal size s bytes, starting at some base address B. That is the whole definition, and every property of arrays follows from it mechanically.

The element at index i begins at address B+is. That is one multiplication and one addition, whatever i is. Reading a[0] and reading a[999999] cost the same, because neither involves looking at anything in between. This is what random access means, and it is not a small thing: it is the property that makes binary search possible three lessons from now, and the property a linked list gives up in the next lesson.

The cost is the same, but the time need not be, and the difference is worth naming once. Under the RAM model both reads are one step. On real hardware a read whose target is already in the level-1 cache takes about a nanosecond, and one that goes out to main memory takes on the order of a hundred. Because caches load whole lines, typically 64 bytes, walking an array from front to back gets seven or eight elements free for every one that costs a trip to memory. Arrays are fast in practice for a reason the RAM model cannot see, and every structure in this course that scatters its elements pays a price the step count does not show.

Example. An array of 8-byte numbers begins at address 4096. At what address does element 250 begin, and how many memory cells were examined to work that out?

The address is B+is=4096+250×8=6096. No cells were examined. The address came from arithmetic on i alone, which is precisely why the cost does not depend on i.

Now you. The same array is reindexed so that its first valid index is 1 rather than 0, with the block still starting at 4096 and element 1 stored first. Where does element 250 begin?

Answer

The formula becomes B+(i-1)s=4096+249×8=6088. One extra subtraction, still constant cost, which is why the choice between 0-based and 1-based indexing is a matter of convention rather than performance.

The price of contiguity

The same property that makes indexing free makes structural change expensive, and the two are the same fact seen from opposite sides. Contiguity says element i is at B+is; that formula has to keep holding after the change, so the elements must physically move.

To insert a value at position k in an array of n elements, every element from k to n-1 must move one slot to the right, which is n-k moves. Inserting at the front costs n moves; at the back it costs none. If insertion positions are uniformly distributed over the n+1 possible slots, the expected number of moves is about n/2. Deletion is the same in reverse: removing element k shifts n-k-1 elements left.

So an array's cost table has two very different entries. Read or write by index: Θ(1). Insert or delete anywhere but the end: Θ(n).

There is one escape worth knowing, because it is used constantly and looks like cheating. If the order of the elements does not matter, deleting element k can be done by moving the last element into slot k and shrinking the length, which is Θ(1). Order is a requirement people assume without noticing they have assumed it, and dropping it here turns a linear operation into a constant one.

An array that grows

A block of memory has a fixed size, decided when it is allocated. Yet every language offers something that behaves like an array you can push onto forever: JavaScript's Array, C++'s std::vector, Java's ArrayList, Python's list. All of them are the same construction, called a dynamic array or growable array, and the construction is worth deriving because its analysis needs a genuinely new idea.

The structure holds three things: a block, its capacity (how many elements the block can hold) and its length (how many it currently holds), with length never exceeding capacity. Pushing writes to slot length and increments the length, which is Θ(1), until length equals capacity. At that point the push cannot proceed, so the structure allocates a bigger block, copies every existing element across, frees the old block, and then does the write.

That copy is Θ(n). So a single push has worst-case cost Θ(n), and if you stop the analysis there you conclude that dynamic arrays are terrible, which contradicts the fact that everyone uses them. The worst case is a true statement about one operation and a misleading statement about a sequence of them, because an expensive push guarantees that the next many pushes are cheap.

Amortised cost

Amortised analysis asks a different question: not what the worst single operation costs, but what the worst sequence of m operations costs, divided by m. It is a worst-case statement, not an average-case one. There is no probability in it and no assumption about which inputs occur. It says: however you choose the operations, the total is bounded.

Take the growth rule "when full, allocate a block of double the capacity". Start from capacity 1 and push n elements. Reallocation happens when the length reaches 1, 2, 4, 8, and so on, and the copy at each of those points moves the current contents: 1 element, then 2, then 4, and so on. The total number of elements copied over the whole sequence is

1+2+4++2k-1=2k-1<2n

where 2k is the final capacity. Concretely, growing to a million elements from capacity 1 does 20 reallocations and copies 1,048,575 elements in total, roughly one copy per element pushed.

Add the n writes of the pushed values themselves and the total for n pushes is under 3n operations, so the amortised cost of a push is O(1): about two operations each, a write and a copy. No individual push is guaranteed cheap. The sequence is.

Now try the other obvious growth rule: when full, allocate a block with a fixed number of extra slots, say 1000 more. Reallocation now happens every 1000 pushes, and the copies are 1000, then 2000, then 3000, and so on. Reaching a million elements takes 999 reallocations and copies

1000+2000++999000=100099910002=499{,}500{,}000

elements: 476 times more copying than doubling, for the same result. In general, growing by a fixed c costs Θ(n2/c) total, so the amortised cost per push is Θ(n/c), which is linear, not constant. The constant c postpones the problem and does not remove it. This is the whole argument for multiplicative growth, and it is why every library implementation uses a growth factor rather than a growth increment.

Example. A dynamic array with growth factor 2 starts at capacity 1 and receives 1000 pushes. How many reallocations occur, and how many element copies in total?

Capacities go 1, 2, 4, ..., 1024, so reallocations happen at lengths 1, 2, 4, ..., 512: ten of them. The copies total 1+2+4++512=1023 elements. Against 1000 pushes that is 1.02 copies per push, and adding the pushes themselves gives about 2.0 operations per push, exactly as the amortised bound predicts.

Now you. The same array instead grows by 100 slots each time it fills, starting from capacity 100. How many copies in total to reach 1000 elements, and how does that compare?

Answer

Reallocations happen at lengths 100, 200, ..., 900, which is nine of them, copying 100, 200, ..., 900 elements: 100(1+2++9)=100×45=4500 copies. That is 4.4 times the doubling scheme's 1023, at n=1000. The gap widens with n, since one grows linearly and the other quadratically.

Which factor, and what it costs in memory

Doubling is not the only multiplicative rule, and the choice is a real engineering trade rather than a detail.

With growth factor g, the capacities form a geometric sequence and the total copies to reach n elements are about n/(g-1). So g=2 copies each element about once, g=1.5 copies it about twice, and g=4 about a third of a time. Larger g means less copying.

The price is memory. Immediately after a growth the block is g times larger than the data in it, so up to (g-1)/g of the allocation is empty: half the block wasted at g=2, three quarters at g=4. For an array of a hundred million records that is not a rounding error.

There is a subtler argument for g=1.5, and it is a nice example of a real constraint that no asymptotic analysis can see. Consider whether the memory freed by earlier reallocations can be reused for the next block. Under doubling, the total freed so far when asking for a block of size 2k is 1+2++2k-1=2k-1, which is always just short of what is needed, forever. Under any factor below the golden ratio φ=1.618, the freed blocks eventually add up to more than the next request, so an allocator can reuse them. Facebook's folly::fbvector uses 1.5 for exactly this reason, while std::vector in the GNU and LLVM standard libraries uses 2, and Python's list grows by roughly 1.125 with a small constant added, since it also cares about small lists.

Note also what shrinking must not do. If a dynamic array halves its block as soon as the length falls below half the capacity, then repeatedly pushing and popping across the boundary makes every operation reallocate, and the amortised bound collapses to Θ(n) per operation. The standard fix is hysteresis: shrink only when the length falls below a quarter of the capacity, which leaves room for the length to move without triggering another reallocation.

Example. A dynamic array uses growth factor 1.5. Roughly how many element copies does building it up to n=106 elements cost, and how much memory can be wasted at worst?

Total copies are about n/(g-1)=106/0.5=2×106, so about two copies per element, twice the doubling scheme's one. The wasted fraction just after a growth is (g-1)/g=0.5/1.5=1/3, so at worst about a third of the block is empty, against a half under doubling.

Now you. A dynamic array uses growth factor 4. How many copies per element does it make, and what fraction of the block can be empty?

Answer

Copies per element are about 1/(g-1)=1/3, so a third of a copy each, three times less work than doubling. The wasted fraction just after a growth is (g-1)/g=3/4, so up to three quarters of the allocation holds nothing. That is the trade in its starkest form: copying and memory move in opposite directions as g changes.

What amortised does and does not promise

Amortised constant is a strong guarantee about totals and no guarantee at all about any individual operation, and the difference matters in exactly one setting, which is worth stating plainly rather than leaving as a footnote.

If a program is rendering a frame every 16 milliseconds, or holding a lock, or steering something, then a single push that stops to copy ten million elements is a failure even though the average is fine. Systems with that requirement use structures with worst-case constant bounds, which exist and are built by copying incrementally: on each push, move a few elements from the old block to the new one, so that the migration finishes before the new block fills. The total work is the same and the peak is bounded, at the cost of holding two blocks at once and of more complicated code.

For everything else, amortised constant is the right guarantee and dynamic arrays are the right default. The remaining problem is the one this lesson could not fix. Growing is solved, but inserting or deleting anywhere except the end is still Θ(n), and no growth policy touches that, because it is forced by contiguity itself. The elements are neighbours in memory, so making room for one means moving the rest.

The only way out is to stop requiring them to be neighbours. If each element carries the address of the next one, the elements can sit anywhere, and inserting between two of them is a matter of rewriting two addresses. That buys constant-time insertion and, as the next lesson shows, immediately loses the constant-time indexing this one opened with.

Linked structures

Inserting into the middle of an array is linear because the elements are neighbours in memory, and the only way out is to stop requiring that they are.

A node that names its successor

The alternative to one block is many small blocks, each holding one element together with the address of the next. Such a block is a node, the address it holds is a pointer or reference, and a chain of them is a singly linked list. The list itself is nothing but a pointer to the first node, called the head; the last node's pointer is null, which is how the end is recognised.

The nodes may sit anywhere in memory, in any order. Nothing about their addresses encodes their position in the sequence, and that single fact generates the entire cost table.

Consider inserting a new value between two existing nodes. You allocate a node, point it at the second node, and rewrite the first node's pointer to point at the new one. Two pointer writes, one allocation, done, regardless of how long the list is or where in it you are. The array had to move n-k elements to achieve the same thing; the list moves nothing, because nothing is where it is for a reason.

Deletion of the node after a known node is the same idea: copy the doomed node's pointer into its predecessor and release it. Again constant, again independent of length.

That is the purchase. Now the price.

What contiguity was paying for

To reach the element at position i, there is no arithmetic to do, because the address of node i is not a function of i. It is stored inside node i-1, whose address is stored inside node i-2, and so on. The only way to find it is to start at the head and follow i pointers. Indexing is Θ(n) where the array's was Θ(1).

This poisons more than it first appears to. The constant-time insertion above assumed you already held a pointer to the node you were inserting after. If you know only the position, you must walk to it first, and the walk is linear, so "insert at position k" costs Θ(k) in a linked list and Θ(n-k) in an array. Neither is better in general; they are expensive in opposite places.

The honest statement is therefore narrow: a linked list gives constant-time insertion and deletion at a position you already hold a reference to. That is exactly the situation in an LRU cache, where a hash table hands you the node directly, or in an iterator that is already standing where it needs to edit. It is not the situation in most code, which is why the array wins more often than the textbook cost table suggests.

Example. A structure holds 10,000 elements and you want to insert a value at position 5000, knowing only the position. Count the elementary operations for an array and for a singly linked list.

The array shifts every element from index 5000 to 9999 one slot right: 10000-5000=5000 moves, then one write. The list follows 4999 pointers to reach node 4999, then does two pointer writes: about 5001 operations. The counts are within a fraction of a per cent of each other, both Θ(n), and the array's operations are sequential memory writes while the list's are scattered reads, so the array is faster in practice by a wide margin.

Now you. The same two structures, but now you want to insert at position 100. Count the operations for each.

Answer

The array shifts 10000-100=9900 elements. The list follows 99 pointers and writes two: 101 operations. Near the front the list wins by a factor of about 98, and near the back the array wins by the same kind of margin. The position of the edit, not the structure alone, decides.

Doubly linked, and what the second pointer buys

A singly linked list cannot delete a node it is standing on, because deletion requires rewriting the predecessor's pointer and the predecessor is unreachable. It also cannot be walked backwards. Both are fixed by giving each node a second pointer, to its predecessor, producing a doubly linked list.

Now deleting a node you hold is genuinely constant: read its two neighbours from its own fields, point them at each other, release it. Insertion before a known node becomes possible too. Most library list types, including std::list and Java's LinkedList, are doubly linked for these reasons, usually with a sentinel node joining the two ends into a ring so that the empty case and the end cases need no special code.

The cost is memory and bookkeeping. Suppose the payload is an 8-byte number on a 64-bit machine. An array of a million such values occupies 8 MB, exactly the data. A singly linked list needs 8 bytes of payload plus an 8-byte pointer per node, so 16 MB, double. A doubly linked list needs 24 MB, triple. And that is before the allocator: a general-purpose malloc typically adds a header of about 16 bytes to each separate allocation and rounds sizes up, so a real singly linked list of a million small nodes can occupy 32 MB or more, four times the data it holds.

The performance consequence is larger than the memory one. A cache line is typically 64 bytes, so an array of 8-byte elements delivers eight elements per trip to memory: a full scan of a million elements causes about 125,000 memory fetches. The nodes of a linked list, allocated at different times, are scattered, so each hop is likely to be its own fetch: about a million of them. At roughly 100 nanoseconds for a fetch that misses cache, the scan costs about 12 milliseconds against 100 milliseconds, a factor of eight that the step count says nothing about, both being Θ(n).

Stacks: last in, first out

The interesting move now is not to add capability but to remove it. A stack supports exactly three operations: push a value on the top, pop the top value off, and look at the top without removing it. All three are constant time, and the discipline that results is last in, first out.

Both layouts implement it well. On a dynamic array, push and pop act at the end, which is the cheap end, giving amortised constant cost with excellent cache behaviour. On a linked list, push and pop act at the head, which is the node you already hold, giving true worst-case constant cost with no reallocation ever.

The reason a restricted interface is worth having is that many problems are exactly stack-shaped, and saying so is a design decision rather than an implementation detail. Matching brackets in an expression is: push each opener, and on each closer check that the popped opener matches. Undo histories are. Depth-first search, in the graph lessons, is. And the call stack that made recursion possible in the previous course is literally one: each call pushes a frame holding the parameters and the return address, each return pops one, and a runaway recursion overflows it, which is what a stack overflow is.

Queues, and the ring buffer

A queue reverses the discipline: enqueue at the back, dequeue from the front, first in first out. It is the right structure whenever fairness or arrival order matters, which is most of scheduling, most of message passing, and the traversal in the graph lessons that finds shortest paths by edge count.

Implementing it on a linked list is immediate: keep pointers to both head and tail, enqueue at the tail, dequeue from the head, both constant.

Implementing it on an array takes one idea. Dequeuing from index 0 and shifting everything left is Θ(n) and unacceptable, so instead the queue keeps two indices, a head and a size, and lets the contents wrap around the end of the block. Element i of the queue lives at slot (head+i)modcapacity. Enqueue writes at (head+size)modcapacity and increments the size; dequeue returns slot head, advances head modulo the capacity, and decrements the size. That is a ring buffer or circular buffer, both operations constant, no shifting, no allocation, and it is what sits behind most audio pipelines, network buffers and lock-free producer-consumer channels.

A deque, or double-ended queue, allows insertion and removal at both ends, and is what you get by allowing head to move backwards too. Python's collections.deque is a linked list of fixed-size blocks; C++'s std::deque is an array of pointers to fixed-size blocks. Both are attempts to get the ring buffer's locality without its fixed capacity.

Example. A ring buffer has capacity 8, its head is at slot 6, and it currently holds 5 elements. Which slot holds queue element 3, and which slot will the next enqueue write to?

Element i is at (6+i)mod8, so element 3 is at (6+3)mod8=1. The next enqueue writes at (6+5)mod8=3. The occupied slots are 6, 7, 0, 1, 2, which is the wrap that gives the structure its name.

Now you. From that state, two elements are dequeued and three are enqueued. Where is the head now, how many elements are held, and which slot holds element 0?

Answer

Two dequeues move head from 6 to (6+2)mod8=0 and the size drops to 3. Three enqueues raise the size to 6, writing at slots (0+3)mod8=3, then 4, then 5. Element 0 is at the head, slot 0. The buffer now holds 6 of its 8 slots, occupying 0 through 5.

Choosing between them

The cost table is the smaller half of the decision, and it is worth writing out honestly, with n the number of elements.

operationdynamic arraysingly linkeddoubly linked
access element iΘ(1)Θ(i)Θ(i)
insert or delete at frontΘ(n)Θ(1)Θ(1)
insert or delete at backΘ(1) amortisedΘ(1) with a tail pointerΘ(1)
insert after a held nodeΘ(n)Θ(1)Θ(1)
delete a held nodeΘ(n)Θ(n)Θ(1)
memory per 8-byte element8 to 16 bytes32 bytes typical40 bytes typical
full scan of 106 elementsabout 12 msabout 100 msabout 100 ms

The last two rows are why the recommendation from practice is blunter than the table above them: prefer the dynamic array unless you have a specific reason not to. The reasons that count are holding stable references to elements that must survive insertions elsewhere, splicing whole sublists in constant time, needing a hard worst-case bound with no reallocation pause, and hardware without enough contiguous memory to hold a large block.

Example. A program keeps a list of a million records and its dominant operation is walking the whole list to sum a field, done thousands of times. Occasionally a record is inserted in the middle. Which structure?

The dynamic array. The dominant operation is a scan, where the array wins by about a factor of eight on memory traffic; the rare insertion costs Θ(n), about half a million moves of contiguous memory, which is a few hundred microseconds and happens rarely. Optimising the rare operation at the cost of the frequent one is the standard way to make a program slower while believing you improved it.

Now you. A cache holds a million entries and must, on every access, move the accessed entry to the front of a recency order, given a pointer to it from a hash table. Which structure, and why?

Answer

A doubly linked list. The operation is "unlink a node I already hold and relink it at the front", which is constant time only when the node knows both neighbours. In an array the same move is Θ(n) per access, and with a million entries accessed constantly that is fatal. This is the design of every LRU cache, and it is the case where the list genuinely wins.

Both layouts share one weakness, and it is total. To find an element whose position you do not know, there is nothing to do but look at them all: Θ(n), for an array and for a list alike, no matter how the memory is arranged. The next lesson removes that cost, at the price of a precondition that nothing so far can supply.

Searching a sorted array

Finding an element whose position you do not know costs a look at every element, in an array and in a linked list alike, and the only way to escape that is to know something about the order the elements are in.

What order buys

Take an array of n elements held in ascending order, and compare the target x with the element in the middle. Three outcomes are possible. If they are equal, the search is over. If x is smaller, then because the array is sorted, x cannot be anywhere in the upper half: every element there is at least as large as the middle one. If x is larger, it cannot be in the lower half.

A single comparison has eliminated half the array. Nothing about the layout of memory made that possible; the ordering did. And crucially, examining the middle element is only cheap because the structure is an array, where the middle is one multiplication away. A sorted linked list gains nothing from being sorted, because reaching its middle costs n/2 hops, which is the whole saving.

Both preconditions are essential and both are easy to violate silently. Binary search on unsorted data does not report an error; it returns a wrong answer, quickly.

The invariant

The algorithm keeps two indices, lo and hi, delimiting the part of the array still under consideration, and maintains one property throughout:

If x is present in the array at all, its index lies in [lo,hi].

Initially lo=0 and hi=n-1, and the invariant holds trivially. Each round computes mid between them and compares. If a[mid]=x, return. If a[mid]<x, then no index at or below mid can hold x, so setting lo=mid+1 preserves the invariant. If a[mid]>x, setting hi=mid-1 preserves it. When lo>hi the interval is empty, and the invariant then says x is not present, which is the correct return.

Termination is separate from correctness and needs its own argument. The interval width hi-lo+1 strictly decreases on every round, because mid lies within the interval and each branch excludes mid itself. A strictly decreasing non-negative integer cannot decrease forever, so the loop ends. Any implementation that lets the width stay the same on some round loops forever, and that is not hypothetical: it is the second of the two classic bugs below.

Example. Trace a binary search for 41 in the array 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, which has 15 elements at indices 0 to 14.

Round 1: lo=0, hi=14, mid=7, a[7]=19<41, so lo=8. Round 2: lo=8, hi=14, mid=11, a[11]=37<41, so lo=12. Round 3: lo=12, hi=14, mid=13, a[13]=43>41, so hi=12. Round 4: lo=12, hi=12, mid=12, a[12]=41, found.

Four comparisons, against an expected eight for a linear scan of a 15-element array.

Now you. Trace the same search for 13 in the same array. How many comparisons?

Answer

Round 1: lo=0, hi=14, mid=7, a[7]=19>13, so hi=6. Round 2: lo=0, hi=6, mid=3, a[3]=7<13, so lo=4. Round 3: lo=4, hi=6, mid=5, a[5]=13, found.

Three comparisons. Note that the count depends on where the element sits, not only on n: the maximum for 15 elements is four.

Counting it exactly

Each round at worst halves the interval, so after k rounds the interval holds at most n/2k elements. The search finishes when that falls below one, which happens at k=log2n+1, equivalently log2(n+1). The two expressions agree for every positive n, and either is the exact worst-case number of comparisons.

The numbers are what make the point.

nworst-case comparisons
154
100010
10620
10930
101240

Twenty comparisons to search a million items; thirty for a billion. Each doubling of the data adds exactly one comparison, which is what logarithmic cost means in practice and why it is treated as effectively free. A linear scan of the same billion items averages 500 million comparisons.

Note that the sorted array is not free. Keeping it sorted under insertions costs Θ(n) per insertion, because a new element has to be placed in order and everything after it shifted. Binary search is therefore the right structure for data that is read far more often than written: a lookup table, a static index, a sorted array of identifiers rebuilt in a batch.

Example. You have a million records and need to perform k lookups. Linear search averages n/2 comparisons each. Sorting first costs about nlog2n comparisons, after which each lookup costs 20. Above what k is sorting worth it?

Linear costs 500{,}000k. Sorting plus searching costs 106×19.93+20k=1.993×107+20k. Setting them equal: 499{,}980k=1.993×107, so k40. Above about forty lookups, sorting pays for itself; below it, scanning is cheaper. The answer is far smaller than most people guess, which is the practical lesson.

Now you. Same numbers, but the data changes constantly, so the array must be re-sorted after every lookup. Now which wins?

Answer

Sorting once per lookup costs about 1.993×107+20 comparisons per lookup, against 500,000 for a linear scan: sorting loses by a factor of about 40. The break-even calculation only works when the sort is amortised over many searches, which is the same reason a sorted array is a poor structure for write-heavy data. That gap is what binary search trees and hash tables exist to close.

Nobody can do better, and here is why

Twenty comparisons for a million elements is impressive, but impressive is not a proof. The stronger claim is that no algorithm restricted to comparing the target against elements can do better, and it can be proved with an argument that recurs several times later in this course.

Model any such algorithm as a decision tree. Each internal node is a comparison the algorithm might make, each edge is an outcome, and each leaf is an answer the algorithm can produce. Running the algorithm on a particular input traces one root-to-leaf path, and the number of comparisons made is that path's length. The worst-case comparison count is therefore the height of the tree.

Now count the leaves that must exist. The algorithm has to be able to answer "the target is at index 0", "at index 1", up to "at index n-1", and "not present": at least n+1 distinct answers. Distinct answers need distinct leaves, since the algorithm has learned nothing to separate them otherwise.

A binary tree of height h has at most 2h leaves. So 2hn+1, giving

hlog2(n+1)

and since h is an integer, hlog2(n+1). That is exactly what binary search achieves, so binary search is optimal, not merely good.

Be precise about what has been ruled out. The bound applies to algorithms whose only access to the data is comparing elements. It does not forbid an algorithm that looks at the value of the key and computes where it should be. Interpolation search does exactly that, guessing the position by linear interpolation between the endpoints, and on uniformly distributed data it costs about log2log2n comparisons: roughly 4.3 for a million items instead of 20. Its worst case, on badly skewed data such as exponentially spaced keys, degrades to Θ(n), which is why it is rarely the default. The same loophole, exploited harder, is what makes hash tables constant time, and it is the subject of a later lesson.

The two bugs everyone writes

Binary search is six lines long and notoriously difficult to write correctly. Jon Bentley reported that when he set it as an exercise to professional programmers, about ninety per cent of the submissions were wrong, and Knuth notes that although the method was published in 1946, the first correct published version of the general case did not appear until 1962.

The overflow. The natural way to compute the midpoint is mid=(lo+hi)/2. If indices are 32-bit signed integers and the array is large, the sum overflows before the division. With lo and hi near 231-1=2{,}147{,}483{,}647, their sum is about 4.29×109, which wraps to a negative number, and the subsequent index is out of bounds. This is not a textbook worry: it sat in the JDK's Arrays.binarySearch from version 1.2 until Joshua Bloch found and reported it in 2006, nine years, in code that had been read by thousands of people. The fix is to compute mid=lo+(hi-lo)/2, which is algebraically identical and never forms the large sum.

The non-shrinking interval. Write the loop as while (lo < hi) with the update hi=mid rather than mid-1, which is the shape used for finding a boundary rather than an exact match. When hi=lo+1, the floor division gives mid=lo, and if the branch taken sets lo=mid, the interval does not shrink and the loop runs forever. The rule that prevents it is the termination argument above: check that every branch strictly reduces hi-lo. Where the update is lo=mid, the midpoint must round up instead, mid=lo+(hi-lo)/2.

Searching for a boundary

Exact-match search is the least useful of the binary search family, and the variants matter more in practice. The two standard ones answer "where does x belong?" rather than "is x here?".

Lower bound returns the index of the first element not less than x: the position of x's first occurrence if it is present, and the insertion point if it is not. Upper bound returns the index of the first element strictly greater than x. The difference between them is the number of copies of x in the array, computed in 2log2(n+1) comparisons without examining the duplicates at all.

These are what libraries actually expose: std::lower_bound and std::upper_bound in C++, bisect_left and bisect_right in Python. They answer range queries ("how many values lie between 100 and 200?") and they never need a separate "not found" convention, since a position is always meaningful. They are also the reason binary search generalises past arrays: any monotone predicate over an ordered domain can be bisected, which is how you find the smallest capacity that satisfies a constraint, or the first commit that broke a build. That last one is git bisect, and it finds the culprit among a thousand commits in ten builds.

Example. In the array 2, 4, 4, 4, 7, 9, what do lower bound and upper bound return for x=4, and what does the pair tell you?

Lower bound returns 1, the first index whose element is not less than 4. Upper bound returns 4, the first index whose element exceeds 4. The difference 4-1=3 is the number of 4s in the array.

Now you. In the same array, what do lower bound and upper bound return for x=5, and what does that say?

Answer

Lower bound returns 4, the index of 7, the first element not less than 5. Upper bound also returns 4, since 7 is also the first element strictly greater than 5. The difference is 0, so 5 does not occur, and index 4 is where it would be inserted to keep the array sorted.

Every result in this lesson rests on a precondition that nothing in the course can yet supply: the array must already be in order. Producing that order is a problem in its own right, and the obvious methods for it turn out to cost quadratically, which is the subject of the next lesson.

Elementary sorting

Binary search needs its array in order, and nothing so far puts it there, so this lesson builds the three obvious methods and measures exactly how far they get.

Selection sort: the fewest possible moves

The first idea most people have is also the most direct. Find the smallest element and put it first. Find the smallest of what remains and put it second. Repeat.

Concretely, for i from 0 to n-2, scan the range from i to n-1 for the minimum and swap it into position i. After round i the first i+1 positions hold the i+1 smallest elements in order, which is the invariant, and after n-1 rounds the array is sorted.

The comparison count is exact and unconditional. Round i compares the current minimum against n-1-i candidates, so the total is

i=0n-2(n-1-i)=n(n-1)2

the same sum as the duplicate check two lessons ago: 499,500 comparisons for a thousand elements. Selection sort does exactly that many on every input of size n. Already-sorted input, reverse-sorted input, random input: identical work. Best case equals worst case equals average case, which is unusual and not a virtue, since it means the algorithm cannot notice that its job is already done.

What it does minimise is data movement. At most one swap per round, so at most n-1 swaps, Θ(n) rather than Θ(n2). That is the one situation where selection sort is the right answer: when comparisons are cheap and moving an element is very expensive, as when sorting large records in place rather than sorting an array of pointers to them.

Insertion sort, and what it is really counting

The second idea is how people sort a hand of cards. Take the elements one at a time and insert each into its place among the ones already sorted, shifting the larger ones right to make room.

For i from 1 to n-1, hold a[i] aside as the key, then walk backwards from i-1 shifting every element greater than the key one slot right, and drop the key into the gap. The invariant is that a[0..i-1] is sorted at the start of round i, which it is at i=1 because a single element is sorted.

Unlike selection sort, the work depends heavily on the input. On already-sorted input each key is compared once with its predecessor, fails the test immediately, and stays put: n-1 comparisons, zero moves, Θ(n) total. On reverse-sorted input every key travels the whole way to the front: n(n-1)/2 comparisons and as many moves. Between those extremes the cost is governed by one quantity, and naming it is the point of this section.

An inversion of an array is a pair of positions i<j with a[i]>a[j]: a pair that is in the wrong relative order. A sorted array has zero inversions. A reverse-sorted array has n(n-1)/2, the maximum, since every pair is wrong. And a random permutation has, on average, exactly half the maximum, n(n-1)/4, because for each of the n(n-1)/2 pairs the two orders are equally likely.

Here is the connection. Each move insertion sort makes shifts one element past the key, and that shift removes exactly one inversion, since the pair was out of order before and in order after. No move removes more than one and none removes fewer, so

moves=I

exactly, where I is the number of inversions in the input. The comparisons are the moves plus at most one extra per round, so they are at most I+n-1.

That is a much sharper statement than "insertion sort is Θ(n2)". It says the cost is linear in the disorder, so an array that is nearly sorted, meaning I=O(n), is sorted in linear time. It also explains why the average is quadratic: a random array has n(n-1)/4 inversions, about 250,000 for a thousand elements, so the average cost is half the worst case rather than a different shape.

Example. Run insertion sort on 5, 2, 4, 6, 1, 3. How many inversions does the input have, and how many moves and comparisons does the sort make?

The inverted pairs are (5,2), (5,4), (5,1), (5,3), (2,1), (4,1), (4,3), (6,1), (6,3): nine of them. Insertion sort makes exactly nine moves, as predicted. The comparisons number 12: the nine that caused a move, plus one wasted comparison at the end of each of the three rounds whose key did not travel all the way to the front.

Now you. How many inversions are in 2, 3, 5, 7, 11, 4, and how many moves will insertion sort make?

Answer

The only inverted pairs are (5,4), (7,4) and (11,4): three. So insertion sort makes exactly three moves. Comparisons are eight: three that shifted, plus one terminating comparison in each of the five rounds. An array of six elements that is one element away from sorted costs about as much as a linear scan, which is the adaptivity that keeps insertion sort in every serious library.

Bubble sort, and why it is the worst of the three

The third idea is to repeatedly sweep the array, swapping any adjacent pair that is out of order, until a whole sweep makes no swap. After the first sweep the largest element has been carried to the end, after the second the next largest, and so on, which is where the name comes from.

Bubble sort makes the same n(n-1)/2 comparisons in the worst case, and its swaps also equal the inversion count, since each adjacent swap fixes exactly one inversion. With the early-exit check it is Θ(n) on already-sorted input, so it is adaptive too. On every other input it loses to insertion sort, because it performs a full swap, three assignments, where insertion sort performs one shift, and it re-examines pairs that insertion sort has already settled.

It is worth being direct about this, because bubble sort is taught more than any other sort and used less than any. Knuth's verdict in The Art of Computer Programming is that it has nothing to recommend it except its catchy name and the fact that it leads to some interesting theoretical problems. There is no input and no machine on which bubble sort is the best of the three. Learn it to recognise it, and reach for insertion sort.

Stability, and why it is not a detail

A sort is stable if elements that compare equal keep their original relative order. Selection sort as described is not stable, because the long-range swap can jump one element over an equal one. Insertion sort is stable, provided the shifting test is strictly "greater than" rather than "greater or equal", since then an incoming key stops as soon as it meets an equal element and settles after it. Bubble sort is stable for the same reason.

Stability sounds like pedantry until you need to sort by two keys. Suppose a table of employees is to be ordered by department, and within each department by surname. With a stable sort the job is two passes: sort by surname, then sort by department. The second sort moves whole departments into place and, being stable, leaves the surname order inside each department untouched. With an unstable sort that trick does not work at all and you must write a comparison function that compares both fields, which is more code and, more importantly, has to be changed every time the user picks a different column to sort by. This is exactly why spreadsheet and table-view sorting is specified as stable.

The cost of stability is real: the fastest in-place sorts, quicksort and heapsort, are both unstable, and the standard stable sort, mergesort, needs extra memory. Language libraries split on the question. Java's Arrays.sort on objects is Timsort and stable, while on primitives it is a dual-pivot quicksort and unstable, on the grounds that two equal integers are indistinguishable so nobody can tell. C++ offers std::sort (unstable, fast) and std::stable_sort separately, which is the honest interface.

Example. A list holds (Ada, Engineering), (Bo, Sales), (Cy, Engineering), (Di, Sales) in that order. It is sorted by name, then by department, with a stable sort. What comes out?

Sorting by name gives Ada, Bo, Cy, Di. Sorting that by department moves both Engineering rows ahead of both Sales rows, and stability preserves the name order within each group, giving (Ada, Engineering), (Cy, Engineering), (Bo, Sales), (Di, Sales). Names ascend inside each department without any comparison function ever mentioning names and departments together.

Now you. The same list, sorted first by department and then by name, with a stable sort. What comes out, and what does that tell you about the order of the passes?

Answer

Sorting by department gives Ada, Cy, Bo, Di. Sorting that by name gives Ada, Bo, Cy, Di, with departments interleaved: the department grouping is destroyed. The rule is that the last sort is the primary key, so passes must run from the least significant key to the most significant. Getting this backwards is the standard bug, and the same principle reappears in radix sort three lessons from now.

The wall, in seconds

The three algorithms differ in constants and in adaptivity, and not at all in shape: all are Θ(n2) in the average and worst cases. It is worth seeing what that costs, on a machine doing 109 comparisons a second.

nquadratic sortnlog2n sort
10000.5 ms10 µs
10450 ms133 µs
1055.0 s1.7 ms
1068.3 minutes20 ms
10714 hours233 ms

At a thousand elements the difference is invisible and the simpler code wins. At a million it is eight minutes against a fiftieth of a second, and at ten million the quadratic sort has left the range of things anyone waits for. The wall arrives somewhere in the tens of thousands, and it arrives suddenly, which is the usual way a program that worked in testing fails in production.

Example. An insertion sort takes 5.0 seconds on 100,000 random records. A colleague suggests running it on 400,000. How long, and what would a machine four times faster achieve?

Cost scales as n2 on random input, and n grew by a factor of 4, so the work grows by 16: about 80 seconds. A machine four times faster brings that back to 20 seconds, still four times the original. The alternative is to change the shape: an nlog2n sort on 400,000 elements is about 7.4×106 comparisons, roughly 7 milliseconds, which is four orders of magnitude better than anything the hardware could buy.

Now you. The same insertion sort is instead given 400,000 records that are already nearly sorted, with about 300,000 inversions in total. Roughly how long?

Answer

Insertion sort's moves equal the inversion count, so the work is about 300{,}000+400{,}000=700{,}000 operations rather than the 4×1010 that random input of that size would need. At the same rate that the 100,000-element run implies, roughly 1010 operations in 5 seconds, this takes well under a millisecond. Nearly sorted input is not a slightly easier case for insertion sort; it is a different complexity class.

Why quadratic is unavoidable here

Every one of these three algorithms shares a structural feature: they compare and move adjacent or nearly adjacent elements. Insertion sort and bubble sort remove exactly one inversion per move, and a random array has Θ(n2) inversions, so any algorithm that removes inversions one at a time is stuck at Θ(n2) no matter how cleverly it is coded. This is not a limitation of the implementations; it is a limitation of the strategy.

Escaping it therefore requires moves that fix many inversions at once, which means comparing elements that are far apart. Selection sort's long-range swap does move elements far, but it learns nothing from the comparisons it makes along the way: each round rediscovers order it already had evidence for, throwing away n comparisons to place a single element.

So the requirement for something better is an algorithm that both moves elements over long distances and keeps what its comparisons told it. The next lesson supplies a general strategy that does both by attacking the array's size directly, splitting the problem rather than the array's disorder, and it produces the first sort with a guaranteed Θ(nlogn) bound.

Divide and conquer

Every quadratic sort is stuck because it removes disorder one inversion at a time, so beating it needs an algorithm with a different shape entirely.

The shape

Divide and conquer is a three-step pattern. Divide the problem into smaller instances of the same problem. Conquer them by solving each recursively, stopping at a base case small enough to solve outright. Combine the sub-answers into an answer for the whole.

Binary search is already an instance, in a degenerate way: it divides into two halves, conquers one of them, and needs no combination step at all. What makes the pattern powerful is the case where both halves are solved and the combination is cheap, because then the work at each level of recursion is proportional to the data at that level, and the number of levels is logarithmic.

The reason this can beat Θ(n2) is precisely the thing insertion sort could not do. When two sorted halves are merged, a single comparison between the fronts of the two halves can settle the relative order of one element against every remaining element of the other half at once. Each comparison carries more information than a comparison of neighbours, because the sortedness of the halves is being used.

Recurrences

A divide-and-conquer algorithm's cost is naturally written as an equation in terms of itself. If splitting into a subproblems of size n/b costs f(n) for the dividing and combining, the total is

T(n)=aT(n/b)+f(n)

with a base case such as T(1)=Θ(1). This is a recurrence, and solving it means finding a closed form for T(n).

The first method is the recursion tree, and it is the one to reach for because it shows why the answer is what it is. Draw the root as the top-level call, costing f(n). It has a children each costing f(n/b), each of which has a children costing f(n/b2), and so on until the subproblem size reaches 1, which happens at depth logbn. Sum the cost of each level, then sum the levels.

Take mergesort's recurrence, T(n)=2T(n/2)+n. The root costs n. Level 1 has two nodes costing n/2 each, total n. Level 2 has four nodes costing n/4 each, total n. Every level costs exactly n, and there are log2n+1 of them, so

T(n)=n(log2n+1)=Θ(nlogn)

The shape of the answer is visible in the drawing: the work per level is constant, so the total is work-per-level times number-of-levels.

Three other shapes occur. If the per-level total shrinks geometrically going down, as in T(n)=2T(n/2)+n2, where levels cost n2, n2/2, n2/4, the sum is dominated by the root and T(n)=Θ(n2). If it grows geometrically, as in T(n)=4T(n/2)+n, where levels cost n, 2n, 4n, the sum is dominated by the leaves. If it stays level, as in mergesort, the logarithm appears.

Example. Solve T(n)=2T(n/2)+Θ(1) by recursion tree, and say which algorithm it describes.

Level 0 costs 1, level 1 costs 2, level 2 costs 4, and level k costs 2k. At depth log2n there are n leaves, and the sum 1+2+4++n=2n-1 is dominated by the last level. So T(n)=Θ(n). This describes an algorithm that splits in two, recurses on both halves, and does constant work to combine: for instance finding the maximum of an array by halving, which does n-1 comparisons in total, the same as the obvious loop.

Now you. Solve T(n)=T(n/2)+Θ(1), and name the algorithm.

Answer

Each level has exactly one node costing a constant, and there are log2n+1 levels, so T(n)=Θ(logn). That is binary search: one subproblem of half the size, constant work to pick the midpoint and compare.

The master theorem

The recursion tree generalises into a formula, and the formula is worth having because most recurrences that arise in practice are of exactly this form. For T(n)=aT(n/b)+f(n) with a1 and b>1, compare f(n) against the quantity nlogba, which is the total cost of the leaves.

If f(n)=O(nlogba-ε) for some ε>0, the leaves dominate and T(n)=Θ(nlogba).

If f(n)=Θ(nlogba), every level costs about the same and T(n)=Θ(nlogbalogn).

If f(n)=Ω(nlogba+ε) for some ε>0, and additionally af(n/b)cf(n) for some c<1 and all large n, the root dominates and T(n)=Θ(f(n)).

Applied: mergesort has a=2, b=2, so nlog22=n, and f(n)=n matches, giving case two and Θ(nlogn). Binary search has a=1, b=2, so nlog21=n0=1, and f(n)=1 matches, giving Θ(logn). The obvious matrix multiplication algorithm splits each matrix into four blocks and does eight block multiplications plus Θ(n2) additions: a=8, b=2, nlog28=n3 dominates n2, so case one gives Θ(n3), agreeing with the triple loop. Strassen's trick does seven block multiplications instead of eight, so nlog27=n2.807, which is where that exponent comes from.

The theorem has gaps, and pretending otherwise causes errors. It says nothing when f(n) falls between two cases: T(n)=2T(n/2)+nlogn has nlogba=n, and nlogn is bigger than n but not by a factor of nε, so no case applies. (A recursion tree settles it: Θ(nlog2n).) It also assumes all subproblems have the same size, which quicksort's do not, so the next lesson needs a different method entirely.

Mergesort

Now build the sort. To sort n elements: if n1 it is already sorted; otherwise split the array in half, sort each half recursively, and merge the two sorted halves into one sorted whole.

The merge is the only part with content. Keep an index into each sorted half. Compare the two elements they point at, copy the smaller into the output, and advance that index. When one half is exhausted, copy the rest of the other. Every comparison consumes one element, so merging two runs of total length m costs at most m-1 comparisons and exactly m moves.

Correctness of the merge is the loop invariant that the output holds, in sorted order, exactly the elements already consumed from both inputs, and that every unconsumed element is at least as large as every consumed one. That second clause is what the sortedness of the halves buys, and it is why the smaller of the two front elements is safe to emit.

The cost recurrence is T(n)=2T(n/2)+Θ(n), so T(n)=Θ(nlogn) by the tree above, and this is a worst-case bound: no input makes mergesort slow, because the split does not look at the data. Counting comparisons exactly for n a power of two gives a worst case of nlog2n-n+1, which is 17 for n=8, 49 for n=16, and about 18.9 million for a million elements. The best case, on input where every merge exhausts one side first, is (nlog2n)/2, so the spread between best and worst is only a factor of two.

Mergesort is stable if the merge prefers the left run on ties, and that is the standard implementation. Its cost is memory: the merge cannot be done in place without either extra space or a much more complicated algorithm, so the usual version allocates a second array of size n. In-place merging exists but the known methods are slow enough in constant factors that libraries prefer paying the memory.

Example. Merge the sorted runs 2, 5, 8, 13 and 1, 3, 9, 11. How many comparisons does it take, and what is the fewest a merge of two four-element runs could take?

Comparing fronts: 2 against 1 emits 1; 2 against 3 emits 2; 5 against 3 emits 3; 5 against 9 emits 5; 8 against 9 emits 8; 13 against 9 emits 9; 13 against 11 emits 11; and the left run's 13 is copied without a comparison. Seven comparisons, which is the maximum m-1=8-1. The minimum is four, achieved when one run is entirely smaller than the other, as with 1, 2, 3, 4 and 5, 6, 7, 8: four comparisons exhaust the left run and the right is copied wholesale.

Now you. How many comparisons does mergesort make in the worst case on 16 elements, and how many on a million?

Answer

Using nlog2n-n+1: for n=16 that is 16×4-16+1=49. For n=106 it is 106×19.93-106+11.89×107, about 18.9 million. A quadratic sort on the same million elements would use 500 billion, a factor of 26,000.

The same idea elsewhere

Divide and conquer is not a sorting technique, and the clearest evidence is a problem with no order in it at all.

Multiplying two n-digit numbers by the method taught in school costs Θ(n2) digit multiplications. In 1960 Andrey Kolmogorov conjectured in a seminar that Θ(n2) was optimal; within a week the 23-year-old Anatoly Karatsuba had refuted him.

Split each number into halves: x=a10m+b and y=c10m+d with m=n/2. Then

xy=ac102m+(ad+bc)10m+bd

which needs four half-size multiplications: ac, ad, bc, bd. That gives T(n)=4T(n/2)+Θ(n), and nlog24=n2, so case one of the master theorem returns Θ(n2): no gain, which is expected, since this is the school method rearranged.

Karatsuba's observation is that the middle term can be had for one multiplication rather than two, because

(a+b)(c+d)=ac+ad+bc+bd

so ad+bc=(a+b)(c+d)-ac-bd, and ac and bd are already being computed. Three half-size multiplications suffice, with some extra additions, giving T(n)=3T(n/2)+Θ(n) and T(n)=Θ(nlog23)=Θ(n1.585).

Example. Multiply 1234×5678 by Karatsuba, splitting each into two-digit halves.

Here a=12, b=34, c=56, d=78, and m=2. The three products are ac=12×56=672, bd=34×78=2652, and (a+b)(c+d)=46×134=6164. The middle term is 6164-672-2652=2840. Assembling, 672×104+2840×102+2652=6{,}720{,}000+284{,}000+2652=7{,}006{,}652, which is the right answer, using three two-digit multiplications instead of four.

Now you. Multiply 3141×2718 the same way.

Answer

a=31, b=41, c=27, d=18. Then ac=837, bd=738, and (a+b)(c+d)=72×45=3240, so the middle term is 3240-837-738=1665. Assembling: 837×104+1665×102+738=8{,}370{,}000+166{,}500+738=8{,}537{,}238.

Be honest about the size at which this pays. The extra additions and the recursion overhead mean Karatsuba loses to the school method on small numbers, and library implementations such as GMP switch over somewhere around 300 to 2500 bits depending on the machine. At 4096 bits, the size of a large RSA key, the asymptotic gap is a factor of about 32, and there it is decisive.

What mergesort still costs

Mergesort delivers a guaranteed Θ(nlogn) and stability, and it charges Θ(n) extra memory for them. For a million records that is another eight megabytes, which is usually irrelevant; for sorting a large fraction of available memory, or on an embedded device, it is not.

The next lesson takes the opposite trade. It partitions the array in place, spending no extra memory beyond the recursion stack, and it is measurably faster than mergesort on real hardware because it moves data less and touches memory more sequentially. What it gives up is the guarantee: its worst case is quadratic, and the reason that rarely matters is a probability argument rather than a proof about the algorithm alone.

Quicksort and selection

Mergesort guarantees Θ(nlogn) and pays for it with a second array the size of the first, and quicksort is the trade in the other direction.

Partition

The idea is to divide the work before the recursion rather than after it. Choose one element as the pivot and rearrange the array so that everything not greater than the pivot comes first, then the pivot, then everything greater. The pivot is now in its final sorted position, and the two sides can be sorted independently with no combination step at all: no merge, no extra array.

The Lomuto scheme does this in one pass. Take the last element as pivot. Keep an index i marking the end of the small region, initially before the start. Sweep j across the rest; whenever a[j] is not greater than the pivot, advance i and swap a[i] with a[j]. At the end, swap the pivot into position i+1. The invariant is that a[0..i] holds elements not greater than the pivot and a[i+1..j-1] holds elements greater.

Partitioning n elements costs exactly n-1 comparisons and at most n swaps, and uses no memory beyond a few indices.

Example. Partition 7, 2, 9, 4, 1, 6, 8, 3 with the last element as pivot, using Lomuto. Show the array after each swap.

The pivot is 3 and i starts at -1. At j=0, 7>3, nothing. At j=1, 23, so i becomes 0 and a[0] swaps with a[1]: 2, 7, 9, 4, 1, 6, 8, 3. At j=2 and j=3, 9 and 4 both exceed 3. At j=4, 13, so i becomes 1 and a[1] swaps with a[4]: 2, 1, 9, 4, 7, 6, 8, 3. At j=5 and j=6, 6 and 8 exceed 3. Finally the pivot swaps into position i+1=2: 2, 1, 3, 4, 7, 6, 8, 9. Seven comparisons, three swaps, and 3 is in its final place.

Now you. Partition 5, 8, 1, 3, 9, 2, 7, 4 the same way.

Answer

Pivot 4, i=-1. At j=2, 14: i=0, swap giving 1, 8, 5, 3, 9, 2, 7, 4. At j=3, 34: i=1, swap giving 1, 3, 5, 8, 9, 2, 7, 4. At j=5, 24: i=2, swap giving 1, 3, 2, 8, 9, 5, 7, 4. Final swap puts the pivot at index 3: 1, 3, 2, 4, 9, 5, 7, 8. Notice that 5 and 8 have changed their relative order although neither was compared with the other, which is why quicksort is not stable.

The other standard scheme is Hoare's, from his original 1961 papers: two indices walk inward from the ends, each stopping at an element belonging on the other side, and the two are swapped. It does about three times fewer swaps than Lomuto and behaves far better on arrays containing many equal keys, which is why library implementations use it or a three-way variant of it. Lomuto is presented here because its invariant is easier to state, not because it is better.

Quicksort, and its two extremes

Quicksort is partition plus recursion: partition, then sort the part left of the pivot and the part right of it. The base case is a range of one element or none.

If the pivot always lands in the middle, the recurrence is T(n)=2T(n/2)+Θ(n), the same as mergesort's, giving Θ(nlogn).

If the pivot always lands at one end, the two subproblems are of size 0 and n-1, and

T(n)=T(n-1)+Θ(n)T(n)=Θ(n2)

with about n2/2 comparisons: 500 billion on a million elements, some eight minutes, against a fiftieth of a second.

The worst case is not a remote possibility, and this is the part that matters in practice. With the last element as pivot, an already sorted array produces exactly this behaviour: the pivot is the maximum every time. So does a reverse-sorted array, and so does an array of identical values under a naive two-way partition. Sorted input is the most common input shape there is. A textbook quicksort is therefore quadratic on exactly the data people feed it, and it also recurses n deep, which overflows the call stack long before it finishes.

The average case

Between the extremes, the behaviour is far closer to the good end than intuition suggests, and the derivation is worth doing.

Assume all n! orderings of distinct elements are equally likely, so the pivot is equally likely to be the k-th smallest for each k from 1 to n. Partitioning costs n-1 comparisons and leaves subproblems of size k-1 and n-k. So the expected comparison count satisfies

C(n)=(n-1)+1nk=1n[C(k-1)+C(n-k)]

The sum contains every C(0) through C(n-1) exactly twice, so it simplifies to C(n)=(n-1)+2nj=0n-1C(j). Solving it, by multiplying through by n, subtracting the same equation for n-1 and telescoping, gives the exact closed form

C(n)=2(n+1)Hn-4n

where Hn=1+12+13++1n is the harmonic number. Since Hnlnn+0.5772, this is asymptotically 2nlnn1.386nlog2n.

Put numbers on it. For a million elements the formula gives 24.8 million comparisons. Mergesort's worst case is 18.9 million. So quicksort makes about 31 per cent more comparisons on average, and by the time n is large enough for the lower-order terms to fade, about 39 per cent more.

That is the whole cost, and quicksort is still typically faster than mergesort in practice. The reasons are all outside the comparison count: it moves each element about a third as often, it writes into the array it is already reading rather than into a second one, its inner loop is a sequential scan that the hardware prefetcher predicts perfectly, and it allocates nothing. This is a clean example of the warning from the second lesson: two algorithms with the same asymptotic class can differ by a factor of two or three on constants alone, and the count does not see it.

Example. Using C(n)=2(n+1)Hn-4n with H10007.485, how many comparisons does quicksort average on 1000 elements, and how does that compare with nlog2n?

C(1000)=2×1001×7.485-4000=14{,}985-4000=10{,}985 comparisons. Meanwhile nlog2n=1000×9.966=9966. Quicksort averages about 10 per cent more at this size, and the gap widens slowly towards 39 per cent as n grows, because the -4n term fades relative to 2nlnn.

Now you. With H1005.187, how many comparisons does quicksort average on 100 elements?

Answer

C(100)=2×101×5.187-400=1047.8-400=647.8 comparisons, against nlog2n=664.4. At this size quicksort actually averages slightly fewer comparisons than nlog2n, because the -4n correction still dominates. The asymptotic constant 1.386 is a statement about large n and misleads at small n, which is exactly what the second lesson warned about.

Randomising the pivot

The average-case result assumed a random input, which is an assumption about the world and therefore not a guarantee. The fix is to stop assuming and start enforcing: choose the pivot uniformly at random from the range being partitioned, and swap it to the end before partitioning.

That single change moves the randomness from the input to the algorithm. The expected cost is now 2(n+1)Hn-4n for every input, including sorted ones, because the analysis no longer depends on the arrangement of the data at all. There remains a worst case, but no adversary who does not see the random numbers can arrange for it.

How unlikely is bad behaviour? A random pivot falls between the 25th and 75th percentile with probability 1/2, and such a split leaves the larger side with at most 3/4 of the elements. So on any root-to-leaf path, about half the splits are "good", and log4/3106=48 good splits suffice to reduce a million elements to one. The recursion depth is therefore O(logn) with overwhelming probability. Getting the quadratic case requires near-extreme pivots almost every time for a million consecutive independent choices, and the probability is far below any risk that is worth engineering against.

The cheap alternative is median of three: take the median of the first, middle and last elements as pivot. It makes sorted input into a best case rather than a worst case and costs almost nothing, and it is what most implementations did for decades. It is not a guarantee. In 1999 Doug McIlroy published A Killer Adversary for Quicksort, a comparison function that watches which comparisons a median-of-three quicksort makes and answers them so as to force the quadratic case, without ever contradicting itself. Any deterministic pivot rule can be defeated this way, which matters when the data comes from an untrusted source: a service that sorts user-supplied input with a deterministic quicksort can be brought down by a carefully chosen request. That is why randomisation, or a hard fallback, is the right answer and median-of-three alone is not.

The hard fallback is introsort, published by David Musser in 1997 and now the basis of std::sort. It runs quicksort but counts the recursion depth, and if it exceeds about 2log2n it abandons quicksort for heapsort on that subrange. The result has quicksort's speed in the ordinary case and heapsort's Θ(nlogn) guarantee in every case. Below a threshold of around 16 elements it switches again, to insertion sort, for the reason established two lessons ago. Practically every industrial sort is a hybrid of three algorithms, and it is worth knowing that none of them ships in the pure form taught here.

The same partition, used once

Partitioning is useful on its own, because it answers a question that does not require a full sort. Finding the k-th smallest element of an unsorted array is the selection problem, and k=n/2 is the median.

The obvious route is to sort and index, costing Θ(nlogn). But after one partition, the pivot sits at some index p, and you know immediately which side the answer is on: if p=k you are done; if k<p the answer lies to the left; otherwise to the right. So recurse into one side only. That is quickselect, Hoare's, from the same 1961 work.

With balanced splits the recurrence is

T(n)=T(n/2)+Θ(n)T(n)=Θ(n)

because the level costs form a geometric series n+n/2+n/4+<2n rather than staying constant. The expected comparison count with random pivots is 2n for finding the minimum and about 2(1+ln2)n3.39n for the median. Linear, with a small constant, to find the median of an unsorted array without sorting it.

The worst case is still Θ(n2), and here there is a genuine repair rather than just a probabilistic one: the median-of-medians algorithm of Blum, Floyd, Pratt, Rivest and Tarjan (1973) picks a pivot guaranteed to be between the 30th and 70th percentiles, giving worst-case Θ(n). Its constant is large enough that it is rarely used directly, and its usual role is as the fallback inside a randomised implementation.

Example. Using the partition performed in the first example, which left 2, 1, 3, 4, 7, 6, 8, 9 with the pivot 3 at index 2, find the 3rd smallest element.

The 3rd smallest is at index 2 in 0-based terms, and the pivot is exactly there. So the answer is 3, found after a single partition and seven comparisons, with no sorting of either side. Had the target been index 5, the search would continue into the right part 7, 6, 8, 9 only, discarding the other three elements permanently.

Now you. Using the second partition, 1, 3, 2, 4, 9, 5, 7, 8 with pivot 4 at index 3, find the 5th smallest element.

Answer

The 5th smallest is at index 4. The pivot is at index 3, so the answer is in the right part, 9, 5, 7, 8, occupying indices 4 to 7, and specifically it is the smallest of that part. Partitioning that part, or simply scanning it, gives 5. Total work: seven comparisons for the first partition and three for the scan, ten, against the 17 comparisons mergesort would need to sort all eight.

Where this leaves sorting

There are now two Θ(nlogn) sorts with opposite trades: mergesort, guaranteed and stable and hungry for memory; quicksort, in place and faster in practice and guaranteed only once randomised or bounded by a fallback.

Neither is meaningfully below nlog2n comparisons, and mergesort's worst case of nlog2n-n+1 is suspiciously close to the nlog2n that the shape suggests. That raises a question the course can now actually answer: is nlogn the end of the road, or just the best anybody has managed? The next lesson proves it is the end of the road for every algorithm that sorts by comparing, and then shows two algorithms that finish in linear time by not comparing.

The limits of sorting

Mergesort's worst case is nlog2n-n+1 comparisons, which is close enough to nlog2n to raise the question of whether anything can do better.

That question has an answer, and it is unusual in this course because it is a statement about every possible algorithm rather than about one. It says that a whole family of sorts is stuck where mergesort already is, and it says exactly why. Then it points at the escape, which is to stop being a member of that family.

What a comparison sort is allowed to know

Every sort so far, selection, insertion, bubble, merge and quicksort, learns about the input in exactly one way: it picks two elements and asks which is smaller. It never looks at a key's value, never does arithmetic on it, never uses it as an address. Such an algorithm is a comparison sort, and the restriction is not artificial: it is what lets one sorting routine work on integers, strings, dates and user-defined records alike, given only a comparison function.

Fix n and consider all the ways the input could be arranged. If the elements are distinct there are n! arrangements, and each one requires a different sequence of moves to put right. The algorithm must produce the right permutation for every one of them, and the only information it ever receives is the yes-or-no answers to its comparisons.

So the run of the algorithm on any particular input is a path through a decision tree. Each internal node is a comparison, "is ai<aj?", with two branches. Each leaf is the point at which the algorithm stops and outputs an ordering. Two inputs that give the same answers to every comparison follow the same path and reach the same leaf, so they get the same output. If those two inputs needed different outputs, the algorithm is wrong on at least one of them.

That is the entire argument in one sentence: a correct comparison sort needs at least n! distinct leaves, one for each permutation it might have to undo.

Counting the depth

A binary tree of height h has at most 2h leaves, because each level at most doubles the count. Needing L leaves therefore forces hlog2L. With L=n!,

hlog2(n!)

and h is the number of comparisons made on the longest path, which is the worst case. So no comparison sort, existing or yet to be invented, can sort n elements in fewer than log2(n!) comparisons in the worst case.

Now put a size on log2(n!). Stirling's approximation gives n!2πn(n/e)n, and taking logarithms,

log2(n!)=nlog2n-nlog2e+12log2(2πn)+

Since log2e=1.4427, the bound is nlog2n-1.4427n plus a term that grows only logarithmically. For a million elements that is 19,931,569 minus 1,442,695, so about 18,488,900 comparisons. Mergesort's worst case on the same input is 18,931,570. Mergesort is within 2.4 per cent of a bound that applies to every comparison sort that will ever be written. There is nothing significant left to win.

Example. What is the smallest number of comparisons that could possibly sort five elements, and is it achievable?

There are 5!=120 orderings, and log2120=6.907, so at least seven comparisons are needed, since a comparison count is a whole number. Seven is achievable: the merge insertion algorithm of Ford and Johnson (1959) sorts five elements in seven comparisons, and so does the more ordinary route of sorting the first two, inserting the third by binary search, and continuing. So the bound is tight here.

Now you. How many comparisons are needed at minimum for eight elements, and for ten?

Answer

8!=40{,}320 and log240{,}320=15.30, so at least 16. 10!=3{,}628{,}800 and log2 of that is 21.79, so at least 22. Both bounds happen to be achievable, at 16 and 22 respectively, but that is a fact about small numbers rather than a consequence of the argument.

What the bound does not say

The result is often quoted as "sorting is nlogn", which is wrong in three separate ways, and each of them matters.

It is not exact. The counting argument gives a floor, not a construction, and the floor is sometimes unreachable. For twelve elements log2(12!)=28.84, so the bound says 29, yet Mark Wells showed by exhaustive search in 1965 that 30 comparisons are genuinely required and 29 is impossible. The information count says how much has to be learned; it does not promise that comparisons can be arranged to learn it that efficiently.

It bounds comparisons, not time. An algorithm making the minimum number of comparisons can still be slow, because comparisons are not the only cost. Mergesort and quicksort make comparison counts within 40 per cent of each other and differ in running time by more, in quicksort's favour, entirely through data movement and memory access. A count of comparisons is a good proxy, not the thing itself.

It bounds the worst case over all inputs, which leaves the door open for inputs that are not arbitrary. If the array is already sorted, insertion sort finishes in n-1 comparisons, far below log2(n!), and there is no contradiction: that algorithm still needs a deep tree for the inputs it has not been given. Adaptive sorts exploit exactly this. Timsort, which ships in Python and in Java for objects, finds runs that are already ordered and merges them, so it costs Θ(n) on sorted or reverse-sorted input and Θ(nlogn) at worst.

The average case is bounded too, and by essentially the same amount. The average number of comparisons is the average leaf depth of the tree, and a binary tree with n! leaves has average depth at least log2(n!) minus a constant. So a comparison sort cannot be fast on average either. Randomising the input does not help; the bound holds regardless.

Not comparing

Every step of the argument used the assumption that the algorithm's only access to the data is through comparisons. Break that assumption and the bound simply does not apply.

Suppose the keys are integers from 0 to k-1. Then a key can be used as an array index, and indexing is not a comparison. Counting sort does this. Make an array C of k counters. Sweep the input once, incrementing C[x] for each key x. Replace C by its running totals, so that C[v] holds the number of keys less than or equal to v, which is the position just past where the last v belongs. Then sweep the input backwards, placing each key at position C[x]-1 and decrementing C[x].

The cost is Θ(n+k): two passes over n elements and one over k counters. When k is comparable to n this is linear, and it beats every comparison sort by a factor of logn. The backwards final sweep is what makes it stable, which is the property the next algorithm depends on entirely.

The catch is k. Sorting a million 32-bit integers this way needs 232 counters, sixteen gigabytes, to sort four megabytes of data. Counting sort is for small key ranges: exam marks, ages, bytes, priority levels.

Example. Counting-sort the marks 3, 1, 4, 1, 5, 0, 2, 3, 1, 4, where marks run from 0 to 5. Show the counters and the total work.

Counts: 0 appears once, 1 three times, 2 once, 3 twice, 4 twice, 5 once, so C=[1,3,1,2,2,1]. Running totals: C=[1,4,5,7,9,10]. Reading those, the single 0 goes to position 0, the three 1s to positions 1 to 3, the 2 to position 4, and so on, giving 0, 1, 1, 1, 2, 3, 3, 4, 4, 5. The work is n+k=10+6=16 operations, against the 22 comparisons that the counting bound says any comparison sort needs for ten elements.

Now you. Counting-sort 2, 5, 3, 0, 2, 3, 0, 3 over the same range 0 to 5.

Answer

Counts C=[2,0,2,3,0,1], running totals C=[2,2,4,7,7,8], output 0, 0, 2, 2, 3, 3, 3, 5. Work is 8+6=14. Note that the counter for 1 is zero and for 4 is zero, and the algorithm pays for them anyway: the k in Θ(n+k) is the width of the range, not the number of distinct keys present.

Radix sort

Counting sort is cheap when the key range is small, and a wide key can be cut into small pieces. That is radix sort: treat each key as a sequence of d digits in base r, and counting-sort by each digit in turn, least significant first.

It works only because counting sort is stable. After sorting by the units digit, records with equal units digits are in their original relative order; sorting by the tens digit preserves that order among equal tens digits, so ties in the tens are broken by the units, which are already right. By induction, after the pass on digit i the array is correctly sorted on the low i+1 digits. Use an unstable sort for a pass and the result is garbage.

Example. Radix-sort 458, 921, 305, 174, 638, 920, 057 by hand, one decimal digit per pass.

By units digit: 920, 921, 174, 305, 057, 458, 638. By tens digit, stably: 305, 920, 921, 638, 057, 458, 174. By hundreds digit: 057, 174, 305, 458, 638, 920, 921, which is sorted. Three passes over seven elements, 21 placements and no comparisons at all.

Now you. Radix-sort 213, 902, 130, 921, 013, 900 the same way.

Answer

Units: 130, 900, 921, 902, 213, 013. Tens: 900, 902, 213, 013, 921, 130. Hundreds: 013, 130, 213, 900, 902, 921. Watch 900 and 902 at the second pass: both have tens digit 0, and they keep the relative order the first pass gave them, which is what leaves them correct at the end.

The total cost is Θ(d(n+r)) for d passes over n elements with r counters each. The choice of r is a real optimisation. To sort a million 32-bit keys, an 8-bit digit gives d=4 and a cost of 4(106+256)4.0×106; an 11-bit digit gives d=3 and 3.0×106; a 16-bit digit gives d=2 and 2.1×106; a 20-bit digit still gives d=2 but a million counters, pushing the cost back up to 4.1×106. Sixteen bits wins, and the shape of that calculation, cost falling with wider digits until the counter array starts to dominate, is the whole of the tuning.

That is between four and nine times fewer operations than the 18.5 million comparisons a comparison sort needs. In practice the margin is smaller than the operation count suggests, because each pass writes elements to scattered positions and misses the cache, whereas quicksort's inner loop is a sequential scan. Radix sort wins on large arrays of fixed-width numeric keys and loses on small ones.

Bucket sort, and the assumption it hides

The third non-comparison sort assumes something about the distribution rather than the range. Bucket sort divides the key space into n equal intervals, drops each key into the bucket its value selects, sorts each bucket with insertion sort, and concatenates.

If the keys are independent and uniform over the interval, the expected number in a bucket is 1, and the expected total cost of the insertion sorts is Θ(n), giving a linear-time sort. If they are not uniform, the analysis evaporates: put every key in one bucket and the cost is the quadratic cost of insertion-sorting all of them. Bucket sort is a statement about the input, and it is only as reliable as that statement.

That is the honest summary of this whole section. Counting sort requires a small range, radix sort requires fixed-width keys and pays in cache misses, bucket sort requires a distribution. None of them defeats the counting argument, because none of them is a comparison sort, and none of them is a general-purpose replacement for one.

Where sorting ends

Sorting is now finished as a topic, and it leaves behind a result the rest of the course will keep using: an array can be put in order for Θ(nlogn), after which any element can be found in Θ(logn) by binary search.

That is a good deal, and it has one weakness. Sorting is a batch operation on a fixed array. Insert one new key into a sorted array and the shifting costs Θ(n), so a collection that changes while being searched pays linear time per update, and the logn lookup is bought with a cost that dwarfs it.

The next lesson attacks the lookup rather than the ordering. It asks whether Θ(logn) is really necessary to find a key, or whether a key could be made to compute the address where it lives, and answers in constant time on average. What that costs is order itself: the structure that results cannot tell you what the next largest key is, or list its contents in sequence, at any price.

Hash tables

A sorted array answers a lookup in about log2n comparisons, twenty of them for a million keys, and the previous lesson showed that no comparison-based method does better.

Twenty is not many. But a comparison sort is bound by that floor only because it insists on comparing, and the non-comparison sorts escaped it by using a key as an address instead. The same escape is available to searching, and it is worth far more there: it turns lookup from logarithmic into constant, independent of how many keys the structure holds.

Direct addressing, and why it does not scale

Start with the extreme case. If keys are integers from 0 to k-1 and each is used at most once, allocate an array of k slots and store the record for key x at index x. Lookup, insertion and deletion are one array access each, and there is nothing to analyse.

This is a direct-address table, and it is genuinely the right structure sometimes: a lookup by day of the year, by byte value, by HTTP status code. Its cost is Θ(k) memory regardless of how many keys are actually present.

That is what kills it in general. To store ten thousand customer records keyed by a nine-digit identifier, direct addressing wants a billion slots to hold ten thousand entries, a millionth of them occupied. The keys are drawn from an enormous universe U but only a tiny subset is ever present, and the table pays for the universe.

The fix is to compress the universe. Pick a table size m close to the number of keys expected, and a hash function h mapping U into {0,1,,m-1}. Store the record for key x at index h(x). Memory is now Θ(m) rather than Θ(|U|), and computing h(x) is a fixed amount of arithmetic that does not grow with n.

The commonest hash for integer keys is the division method, h(x)=xmodm, with m prime and not close to a power of two, since m=2p makes h depend on the low p bits alone and throws away the rest of the key. For strings, the standard trick is to treat the characters as digits in some base: Java's String.hashCode computes si31n-1-i over a 32-bit integer, and 31 is chosen because it is prime, odd, and 31x is computable as a shift and a subtract.

Collisions are not a risk, they are arithmetic

Since |U|>m, there must be two distinct keys with the same hash value. This is the pigeonhole principle and it admits no cleverness: no hash function whatever, however well designed, avoids collisions on a universe larger than its range.

Worse, collisions arrive far earlier than intuition suggests. Insert n random keys into m slots. The probability that all n land in different slots is the product mmm-1mm-n+1m, which falls below one half at roughly n=1.177m. This is the birthday problem: with m=365 the answer is 23 people.

Example. A hash table has a million slots. How many random keys can be inserted before a collision is more likely than not?

Multiplying out i=0n-1(1-i/106) until it drops below 0.5 gives n=1178. The approximation 1.177106=1177 agrees. So a table with a million slots and just over a thousand keys, occupancy of about one part in a thousand, is already at even odds of a collision.

Now you. How many keys is that for a table of a thousand slots?

Answer

1.1771000=37.2, and the exact product first falls below one half at n=38. Collision handling is not an edge case to bolt on later; it is most of the design of a hash table.

Chaining

The first resolution scheme is to stop insisting that a slot holds one record. In separate chaining, slot i holds a linked list of every record whose key hashes to i. Insertion prepends to the list in constant time. Lookup hashes, then walks that one list.

The analysis needs a name for how full the table is. The load factor is α=n/m, the average number of keys per slot, and with chaining it may exceed 1.

Assume simple uniform hashing: each key is equally likely to hash to any slot, independently of the others. Then an unsuccessful search examines a list of expected length α, so its expected cost is 1+α counting the hash itself. A successful search finds the target part-way along its list, and averaging over the keys gives 1+α/2 roughly. Both are constant when α is held constant, and that is the whole result: hash table operations are Θ(1) expected, provided the load factor is bounded.

The worst case is untouched by this. If every key hashes to the same slot, the table is one linked list and lookup is Θ(n). Expected constant time is a statement about the distribution of keys, not a guarantee about any particular set of them.

Open addressing

Chaining spends a pointer per record and scatters the records across the heap, which is expensive on hardware that rewards locality. Open addressing stores every record in the table itself and, when a slot is taken, probes a sequence of further slots until it finds a free one.

Linear probing is the simplest: try h(x), then h(x)+1, then h(x)+2, wrapping around. It was used by Gene Amdahl, Elaine McGraw and Arthur Samuel in 1954 in the assembler for the IBM 701, and Knuth's analysis of it in 1963 is the paper he credits with turning him towards the analysis of algorithms.

Example. Insert 27, 18, 29, 28, 39, 13, 16 into a table of 11 slots with h(x)=xmod11 and linear probing, counting probes.

The hashes are 5, 7, 7, 6, 6, 2, 5. Key 27 takes slot 5 in one probe, 18 takes 7 in one. Key 29 hashes to 7, which is taken, so it lands at 8 in two probes. Key 28 takes 6 in one. Key 39 hashes to 6 and finds 6, 7 and 8 occupied, landing at 9 in four probes. Key 13 takes 2 in one. Key 16 hashes to 5 and walks 5, 6, 7, 8, 9 before reaching 10, six probes. Total 16 probes for seven insertions, an average of 2.29, at a load factor of 7/11=0.64.

Now you. Insert 45, 31, 12, 23, 56, 19, 7 into a table of 13 slots with h(x)=xmod13.

Answer

Hashes 6, 5, 12, 10, 4, 6, 7. The first five keys all find their home slot free, one probe each. Key 19 hashes to 6, taken by 45, so it lands at 7 in two probes. Key 7 hashes to 7, now taken by 19, so it lands at 8 in two probes. Total 9 probes, an average of 1.29 at a load factor of 7/13=0.54. The same seven keys in a table one fifth larger cost 44 per cent fewer probes, which is what the next section quantifies.

Linear probing has a specific pathology visible in that first example: keys 5 to 10 formed a single occupied block, and any key hashing anywhere into it walks to the end. This is primary clustering. A long run grows faster than a short one, because it presents a wider target, so runs snowball. Quadratic probing, stepping by 1,4,9,, and double hashing, stepping by a second hash of the key, break the clusters up at the cost of losing the sequential memory access that made linear probing fast in the first place.

Deletion under open addressing needs care. Emptying a slot outright breaks every probe sequence that ran through it, so a deleted slot is marked with a tombstone that probing walks past but insertion may reuse. Tombstones accumulate and are cleared by rebuilding.

What the load factor buys

The probe counts are all functions of α alone, and never of n. Under uniform hashing, an unsuccessful search costs about 1/(1-α) probes and a successful one about (1/α)ln(1/(1-α)). Linear probing, with its clustering, is worse: Knuth's results give about 12(1+1/(1-α)) probes for a successful search and 12(1+1/(1-α)2) for an unsuccessful one.

Those formulas explode near α=1, and the numbers are worth seeing. At α=0.5, linear probing takes 1.5 probes for a hit and 2.5 for a miss. At α=0.75, 2.5 and 8.5. At α=0.9, 5.5 and 50.5. At α=0.95, 10.5 and 200.5. The cost is flat until the table is about three quarters full and then goes off a cliff.

Example. A linear-probing table of 1024 slots holds 768 keys. What does a search cost, and what does doubling the table to 2048 slots do?

The load factor is 768/1024=0.75, so a hit costs 12(1+4)=2.5 probes and a miss 12(1+16)=8.5. Doubling gives α=0.375, so a hit costs 12(1+1.6)=1.30 and a miss 12(1+2.56)=1.78. Doubling the memory cut the miss cost by a factor of 4.8.

Now you. The same table has 4096 slots and 3686 keys. What do a hit and a miss cost, and what would chaining cost instead?

Answer

α=3686/4096=0.90. Linear probing gives 12(1+10)=5.5 probes for a hit and 12(1+100)=50.5 for a miss. Chaining at the same load factor costs 1+α/2=1.45 for a hit and 1+α=1.9 for a miss, because a chained table degrades linearly in α rather than exploding. This is why open addressing is resized aggressively and chaining is not.

So the table must be kept below some threshold, which means resizing when it fills. Allocate a table of double the size and reinsert every key, since a key's slot depends on m and no old position survives. That rehash costs Θ(n), and it is exactly the growable-array argument from the third lesson: doubling makes the total cost of n insertions Θ(n), so the amortised cost per insertion stays constant. The threshold is a tuning decision, and real implementations differ: Java's HashMap resizes at 0.75, Python's dictionary at about 0.66, and Google's Swiss tables at around 0.875 because their probing is done sixteen slots at a time with vector instructions.

When the keys are chosen by an attacker

Every result above assumed the keys were not adversarial. Drop that assumption and expected constant time disappears completely.

If an attacker knows the hash function, they can compute a set of keys that all collide, and feed them in. A table with n colliding keys degrades to a linked list, so n insertions cost Θ(n2). Crosby and Wallach demonstrated this in 2003 against the Bro intrusion detection system and against Perl. At the 2011 Chaos Communication Congress, Klink and Wälde showed the same attack against the form-parsing code of PHP, Java, Python, Ruby and ASP.NET: a single HTTP POST of a few hundred kilobytes of colliding parameter names could occupy a server core for minutes.

The repair is a randomised hash: a family of functions with a per-process random seed chosen at startup, so the attacker cannot know which function is in use. Python enabled seed randomisation by default in version 3.3 in 2012, and SipHash, published by Aumasson and Bernstein the same year, is a keyed hash fast enough for short strings and designed for this exact role. Java took a different route in version 8: a HashMap bucket whose chain exceeds eight entries is converted to a balanced tree, so the worst case becomes Θ(logn) rather than Θ(n). That tree is the subject of the next two lessons.

What was traded away

A hash table gives expected constant-time insert, delete and lookup by exact key, at the price of the worst case, some memory to hold the load factor down, and a rehash whenever the table doubles. For lookup by exact key it is very hard to beat.

The price is order. A hash function's whole job is to scatter keys that were near each other in value, so the table has no notion of sequence at all. It cannot report the smallest key, or the next key after a given one, or every key between two bounds, or the keys in order, without inspecting all m slots and sorting them. In exchange for constant lookup it has destroyed every question except "is this exact key here?".

Many real questions are the other kind: the ten most recent entries, everything between two timestamps, the next available identifier. The next lesson builds a structure that keeps the halving behaviour of binary search while allowing insertion and deletion, which means keeping the keys ordered as they arrive, and it costs a factor of logn against the hash table to do it.

Binary search trees

A hash table answers "is this key present" in constant time and cannot answer "what is the next key after it" at all, because scattering the keys was the whole point.

Getting order back means giving up the scattering, and the structure that does it is a tree. The pieces are already familiar: binary search halves an interval, and a linked node points at other nodes instead of sitting next to them. A binary search tree is the two ideas combined, and the combination buys something neither had alone, which is insertion and deletion in logarithmic time on an ordered collection.

The ordering property

A binary search tree is a set of nodes, each holding a key and pointers to a left child, a right child, or nothing. One node is the root. The defining condition is local and applies at every node x:

Every key in the left subtree of x is less than x's key, and every key in the right subtree is greater.

That says subtree, not child. It is not enough for the left child to be smaller: every descendant to the left must be, all the way down. The condition holding at every node is what makes the tree searchable, because it is exactly what lets a comparison at x eliminate an entire subtree.

The tree carries no array, no contiguity and no index arithmetic. Its shape is not determined by its contents: the same set of keys can be stored in many different trees, all of them valid, and which one you get depends entirely on the order the keys arrived in. That fact is where the whole lesson ends up.

Searching

To find key k, start at the root. If k equals the node's key, stop. If k is smaller, go left; if larger, go right. If the pointer to follow is null, k is not present.

Each comparison discards one subtree, exactly as each comparison in binary search discarded half an interval. The difference is that binary search discarded half by construction, whereas here the size of the discarded subtree is whatever the tree's shape makes it. The number of comparisons is the depth of the node found, plus one, and the worst case over all keys is the height of the tree: the number of edges on the longest root-to-leaf path.

Insertion follows the same walk. Search for the key; when the walk falls off the bottom of the tree, attach a new node there. The new key is a leaf, and no existing node moves. This is the property that arrays could not offer: inserting into a sorted array costs Θ(n) to shift, while inserting here costs one walk and one pointer write.

Example. Insert 41, 23, 67, 12, 35, 55, 88, 29 into an empty tree, in that order. What is the height, and what does an average successful search cost?

41 becomes the root. 23 goes left of it, 67 right. 12 goes left of 23, and 35 right of 23. 55 goes left of 67, 88 right of 67. Finally 29 compares less than 41, greater than 23, less than 35, and becomes the left child of 35. The depths are 41 at 0; 23 and 67 at 1; 12, 35, 55 and 88 at 2; 29 at 3. The height is 3. The depths sum to 13, so the average depth is 13/8=1.625 and an average successful search costs 2.625 comparisons.

Now you. Insert the same eight keys in ascending order: 12, 23, 29, 35, 41, 55, 67, 88. What is the height now?

Answer

Every key is larger than everything already in the tree, so every insertion walks to the rightmost node and attaches on its right. The result is a single chain of eight nodes descending to the right, with height 7. The depths sum to 0+1++7=28, so the average search costs 4.5 comparisons rather than 2.625. The same eight keys, the same structure, and a search that costs 71 per cent more.

The keys come back out sorted

Take the tree and walk it as follows: recursively visit the left subtree, then emit this node's key, then recursively visit the right subtree. This is the in-order traversal, and it emits the keys in ascending order.

The reason is the ordering property applied once. At any node, everything in the left subtree is smaller than the node and everything in the right subtree is larger, so left, node, right is the correct relative order for those three groups. The recursion makes it true within each group. The walk visits every node once and does constant work at each, so it costs Θ(n).

Running an in-order walk on the tree from the example gives 12, 23, 29, 35, 41, 55, 67, 88. It also gives a sorting algorithm: insert n keys into a tree and walk it. That is exactly the shape of quicksort, and not by analogy. Quicksort's first pivot is the root, the two partitions are the two subtrees, and the total comparison count of quicksort equals the sum of the depths of the resulting tree. The formula 2(n+1)Hn-4n derived for quicksort's average is therefore also the expected sum of depths of a tree built from a random permutation, and for n=8 it gives 16.9 against the 13 the particular example produced.

The in-order walk answers the questions the hash table could not. The smallest key is found by following left pointers from the root until they run out; the largest by following right pointers. The successor of a node with a right subtree is the smallest key in that subtree, and if there is no right subtree it is the lowest ancestor from which the node is in the left subtree. Every key between two bounds can be listed by an in-order walk that prunes subtrees lying entirely outside the range. All of these cost the height, or the height plus the number of keys reported.

Deletion, in three cases

Deletion is the only operation that is not a straight walk, because removing a node from the middle of a tree leaves its children with no parent. There are three cases, distinguished by how many children the doomed node has.

A leaf is removed by setting its parent's pointer to null. Nothing else changes.

A node with one child is removed by connecting its parent directly to that child. The child's whole subtree keeps the same relationship to everything above it, because it was already entirely on the correct side of the deleted node.

A node with two children cannot be spliced out, because its parent has only one pointer to spare. Instead, do not remove the node at all: overwrite its key with the key that may legally sit in that position, then remove the node that key came from. There are exactly two such keys, the node's predecessor (the largest in the left subtree) and its successor (the smallest in the right subtree), because either sits between the two subtrees in sorted order. The successor is conventional. Crucially, the successor is the leftmost node of the right subtree, so it has no left child, and removing it is case one or case two.

Example. Delete 23 from the tree built earlier, whose relevant part is 23 with left child 12 and right child 35, and 35 with left child 29.

Node 23 has two children, so find its successor: the smallest key in its right subtree, which is the leftmost node under 35, namely 29. Copy 29 into the node that held 23, then delete the original 29, which is a leaf. The result has 29 where 23 was, with left child 12 and right child 35, and 35 now childless. An in-order walk gives 12, 29, 35, 41, 55, 67, 88, still sorted.

Now you. From the original tree, delete the root, 41.

Answer

41 has two children, 23 and 67. Its successor is the smallest key in the right subtree, which is the leftmost node under 67: that is 55, which has no children. Copy 55 into the root and delete the leaf 55. The root is now 55, with left child 23 (unchanged, with 12 and 35 below it) and right child 67, which now has only the child 88. Note that the height is unchanged at 3, but a different choice of key to promote would have given a different shape, which is why deletion strategies affect balance over time.

Every operation, search, insert, delete, minimum, successor and range query, costs the height of the tree. So the entire performance of the structure reduces to one question: how tall is it?

Height is everything, and it depends on arrival order

A binary tree with n nodes has height at least log2(n+1)-1, since a tree of height h holds at most 2h+1-1 nodes. A perfectly balanced tree of a million keys has height 19, and every operation costs about 20 comparisons: the same as binary search, but now with insertion and deletion at the same price.

At the other extreme, keys inserted in ascending order produce the chain seen above, height n-1. The tree is a linked list with a wasted pointer per node, search is Θ(n), and an average successful search on a million sorted keys costs 500,000 comparisons instead of 20. That is a factor of 25,000, and it comes from nothing but the order the keys arrived in.

In between is the random case, and it is much closer to the good end. If the n keys arrive in a uniformly random order, the expected depth of a node is 2lnn roughly, which is 1.386log2n: the quicksort constant again, for the reason given above. The expected height, meaning the deepest path rather than the average one, is about 4.311lnn, a result due to Bruce Reed in 2003.

Example. For a million keys, compare the expected search cost of a random binary search tree with a perfectly balanced one, and with the sorted-input case.

log2106=19.93, so a perfectly balanced tree costs about 20 comparisons. A random tree costs 2ln106=27.6 on average, about 39 per cent more, and its expected height is 4.311×13.82=54, so even the worst key in a random tree is found in about 54 comparisons. Sorted input costs 500,000 on average. The gap that matters is not between random and balanced, which is a constant factor, but between either of those and the degenerate case, which is a change of complexity class.

Now you. Repeat for a billion keys.

Answer

log2109=29.90, so balanced costs about 30. Random costs 2ln109=41.4 on average, with an expected height of 4.311×20.72=89. Sorted input costs 500 million. Ten times more keys added ten comparisons to the balanced and random cases and added 499.5 million to the degenerate one.

Why the bad case is the normal case

It would be comfortable to treat height n as a curiosity that random data avoids. It is not, and this is the part worth taking seriously.

Real keys arrive in order far more often than chance would suggest. Records are read out of a database with an ORDER BY, timestamps arrive as time passes, identifiers are assigned by an incrementing counter, files are processed in the order a directory listing returned them, and a tree rebuilt from a sorted export degenerates completely. Sorted or nearly sorted input is not an adversarial case; it is the default shape of data that came from anywhere organised.

There is also no averaging over deletions to fall back on. The random-tree analysis assumes insertions of a random permutation into an initially empty tree. Once deletions are mixed in, and particularly once the always-take-the-successor rule is used, the shape drifts: repeated insert-delete cycles leave a tree measurably taller than the random model predicts, an effect first noticed by Gary Knott in 1975 and still not fully characterised.

And an attacker who can choose insertion order can force the linear case deliberately, the same vulnerability the previous lesson described for hash tables, with the same consequence for any service that builds a tree from user-supplied keys.

So the unbalanced binary search tree is not a structure to ship. What it is, is the right data layout with no mechanism to defend it. The next lesson supplies the mechanism: a constant-time local restructuring called a rotation, applied on the way back up from each insertion and deletion under a rule that keeps the height within a small constant factor of log2n, in the worst case rather than on average, on every input including the sorted one.

Keeping a tree balanced

A binary search tree does everything in time proportional to its height, and its height is decided by the order the keys happened to arrive in, which nobody controls.

That is a fixable problem rather than a fundamental one. The tree does not have to keep the shape insertion gave it: many different trees hold the same keys, all satisfying the ordering property, and a structure that could move between them cheaply could keep itself short. This lesson builds that mechanism, prices it, and then shows why the winning version in practice is not a binary tree at all.

The rotation

The whole of balancing rests on one operation. Take a node y with a left child x. Rearranging so that x sits where y was, y becomes x's right child, and x's old right subtree becomes y's new left subtree, is a right rotation at y. A left rotation is the mirror image.

The point is that it preserves the ordering property exactly. Write the subtree's keys in in-order: everything in x's left subtree, then x, then everything in x's old right subtree, then y, then y's right subtree. Check the same sequence after the rotation and it is unchanged, because the middle subtree moved from being right-of-x to being left-of-y, and it was already between the two of them in value. Nothing else moves.

A rotation touches three pointers and no keys, so it costs constant time regardless of how large the subtrees are, and it changes the depth of everything in the left subtree by one and everything in the right subtree by one, in opposite directions. That is a lever on the height, applied for free.

Example. A subtree has root 30 with left child 20 and right child 40, and 20 has children 10 and 25. Perform a right rotation at 30 and check the in-order sequence.

Before: in-order is 10, 20, 25, 30, 40. After the rotation, 20 is the subtree root, its left child is 10, its right child is 30, and 30 has left child 25 and right child 40. The subtree 25, which was right of 20, has become left of 30. In-order now reads 10, 20, 25, 30, 40, unchanged. The height of the subtree went from 2 to 2, but 10 rose from depth 2 to depth 1 and 40 fell from depth 1 to depth 2.

Now you. A subtree has root 50 with left child 40 and right child 70, and 70 has children 60 and 80. Perform a left rotation at 50.

Answer

70 becomes the subtree root. Its left child is 50, which keeps 40 on its left and takes 60 as its right child. Its right child is 80. In-order is 40, 50, 60, 70, 80 before and after. The subtree 60 was left of 70 and is now right of 50, and it sits between those two keys in value either way, which is why the move is legal.

The AVL rule

Rotations give the power to reshape. A balancing scheme is a rule saying when to use it. The first one, published by Georgy Adelson-Velsky and Evgenii Landis in 1962, is the strictest in common use.

An AVL tree requires that at every node, the heights of the two subtrees differ by at most 1. That difference is the node's balance factor, stored in the node as two bits.

Insert as in an ordinary binary search tree, then walk back up from the new leaf updating heights. The first node whose balance factor reaches 2 or -2 is rebalanced, and there are four configurations, distinguished by which grandchild direction the new key went into.

If the insertion went into the left child's left subtree, one right rotation at the unbalanced node fixes it. Mirror-image for right-right. If it went into the left child's right subtree, a single rotation does not help: the middle subtree simply changes sides and the imbalance persists. That case needs a double rotation, a left rotation at the child followed by a right rotation at the node, which promotes the grandchild two levels. Mirror-image for right-left.

One rebalance, at most two rotations, restores the invariant for an insertion, and the walk back up is O(h), so insertion stays logarithmic. Deletion is slightly worse: fixing one node can shorten its subtree and unbalance its parent, so rebalancing may cascade all the way to the root, costing O(logn) rotations rather than a constant.

Example. Insert 50, 25, 75, 10, 30, 5 into an AVL tree. Where does the violation appear, and what fixes it?

The first five insertions are all legal: after them, 50 has children 25 and 75, and 25 has children 10 and 30. Inserting 5 puts it left of 10. Now 25 has a left subtree of height 1 and a right of height 0, a balance factor of 1, which is fine, but 50 has a left subtree of height 2 and a right of height 0, a balance factor of 2. The insertion went into 50's left child's left subtree, so this is the left-left case: one right rotation at 50. Afterwards 25 is the root, with left child 10 (holding 5) and right child 50 (holding 30 on the left and 75 on the right). Both of the root's subtrees now have height 1.

Now you. Insert 50, 25, 75, 10, 30, 27 instead.

Answer

27 goes left of 30. Node 50 again reaches balance factor 2, but the insertion went into its left child's right subtree, so this is the left-right case and one rotation is not enough. Left-rotate at 25 first, which lifts 30 into 25's place with 25 as its left child holding 10 and 27. Then right-rotate at 50. The result is 30 at the root, with left child 25 (children 10 and 27) and right child 50 (right child 75). In-order reads 10, 25, 27, 30, 50, 75, and the tree is balanced.

Why the bound is a Fibonacci argument

The invariant is local, and the height bound that follows from it is not obvious. Get at it by asking the opposite question: what is the fewest nodes an AVL tree of height h can contain? A sparse tall tree is the worst case, so bounding sparseness bounds height.

Call that minimum N(h). Such a tree has a root, and to be as sparse as possible its two subtrees should be as sparse as possible while still legal: one of height h-1 and, since the difference may be 1, the other of height h-2. So

N(h)=N(h-1)+N(h-2)+1

with N(0)=1 and N(1)=2. That is the Fibonacci recurrence with an offset, and indeed N(h)=F(h+3)-1 where F is the usual Fibonacci sequence. The values run 1, 2, 4, 7, 12, 20, 33, 54, 88, 143.

Since F(k) grows like φk/5 with φ=1.618, N(h) grows like φh, so h grows like logφn. Converting the base, logφn=log2n/log2φ=1.4404log2n, which gives the standard bound

h1.4405log2(n+2)-0.3277

For a million keys that evaluates to 28.4, and running the recurrence exactly shows the true maximum height is 27, against 19 for a perfectly balanced tree. So the worst possible AVL tree is 44 per cent taller than the best possible tree, and that is a worst case on every input, not an average over random ones. Compare the unbalanced tree's worst case of 999,999.

Red-black trees and the cost of strictness

AVL's invariant is tight, which makes lookups fast and updates expensive: every insertion and deletion must maintain exact heights, and deletions can rotate all the way up.

Red-black trees, from Rudolf Bayer's 1972 symmetric binary B-trees as reformulated by Guibas and Sedgewick in 1978, relax it. Each node is coloured red or black, the root and the leaves are black, a red node's children are both black, and every root-to-leaf path contains the same number of black nodes. The last two rules together mean the longest path is at most twice the shortest, since the longest alternates red and black and the shortest is all black. The height bound is

h2log2(n+1)

which is 39.9 for a million keys against AVL's exact worst case of 27. Lookups therefore cost up to about 48 per cent more comparisons. In return, an insertion needs at most two rotations and a deletion at most three, both constants, with the rest of the repair done by recolouring, which is cheaper than pointer surgery.

That trade is why red-black trees are the ones actually shipped. C++'s std::map and std::set, Java's TreeMap and the Linux kernel's interval and process schedulers are all red-black. AVL survives where reads vastly outnumber writes. Two other schemes are worth knowing: treaps give each key a random priority and keep a heap order on priorities, achieving the random-tree behaviour of the previous lesson deliberately rather than by luck, and splay trees do not balance at all but rotate every accessed node to the root, giving O(logn) amortised cost and making recently used keys cheap.

Wide nodes

Every scheme so far assumed two children per node, and that assumption is worth questioning, because on real hardware the cost of a tree operation is not the comparison count but the number of memory locations touched.

Reading one byte from a spinning disk costs about 10 milliseconds and delivers a whole 4 KB page for the same price. Reading one byte from main memory costs about 100 nanoseconds and delivers a 64-byte cache line. In both cases the transfer is nearly free once the seek is paid, so the right structure fills the transfer unit with keys.

A B-tree, from Bayer and McCreight in 1972, does exactly that. Each node holds many keys in sorted order, say b-1 of them, and has b children, one for each gap between and beyond them. Searching a node means a binary search within it, which is free because the node is already in memory; descending means one more page read. All leaves sit at the same depth, and the tree grows by splitting a full node and pushing its middle key up into the parent, so it grows at the root rather than the leaves.

The arithmetic is the reason the structure exists. With a 16 KB page and 16 bytes per entry, b=1000. The root holds about 1000 keys, its 1000 children about 106, and their 106 children about 109. So a billion keys sit three node reads from the root, and since the root and often the whole second level stay cached, most lookups cost one or two disk reads. A red-black tree over the same billion keys is up to 59 levels of pointer chasing, each one potentially its own seek.

Example. Compare the worst-case height of an AVL tree, a red-black tree and a B-tree with b=1000, all holding a billion keys.

log2(109)=29.9. AVL is bounded by 1.4405×29.9-0.33=42.7, and running the Fibonacci recurrence gives an exact maximum of 41. Red-black is bounded by 2×29.9=59.8, so 59. The B-tree needs log1000(109)=3 levels. The binary trees are within a small constant of each other and the B-tree is an order of magnitude shallower, entirely because each of its nodes settles ten bits of the answer instead of one.

Now you. How deep is that B-tree for a trillion keys, and how tall could the AVL tree get?

Answer

log1000(1012)=4 levels for the B-tree. For AVL, log2(1012)=39.86, so the bound is 1.4405×39.86-0.33=57.1. A thousand times more keys cost the B-tree one extra read and the AVL tree fourteen extra levels. This is why every relational database index, and every filesystem from NTFS to ext4 to APFS, is a B-tree or a close relative rather than a binary tree.

What is now available

Between them, the last three lessons deliver an ordered dictionary: insert, delete, look up, find the minimum, find the successor, and list a range, all in guaranteed O(logn) with an in-order walk in Θ(n). A hash table is faster for exact lookup and cannot do the rest. That is the whole trade, and it is enough to choose between them for almost any real problem.

It is also more than many problems need. A great many tasks never ask for a general lookup at all: they repeatedly ask for the smallest remaining item, or the largest, and nothing else. A scheduler wants the next job to run, a simulation wants the next event, a compressor wants the two least frequent symbols. Maintaining a full ordering to answer that one question is paying for an invariant that is never queried.

The next lesson keeps a much weaker invariant, only that each node is smaller than its children, which is loose enough to be maintained in a plain array with no pointers at all, and strong enough to hand over the minimum in constant time.

Heaps and priority queues

A balanced tree keeps every key in its correct place relative to every other, and a great many problems never ask a question that needs it.

A scheduler asks for the next job to run, a simulation for the next event to happen, a network router for the highest-priority packet, a compressor for the two least frequent symbols. Each of those is the same question, "which is smallest", asked repeatedly while new items arrive. Maintaining a total order to answer it is paying for an invariant that is never queried. This lesson builds the structure that answers exactly that question and nothing else, and the saving turns out to be large enough to matter.

The abstract operation, and the weaker invariant

The interface is the priority queue: insert an item with a priority, and extract the item of smallest priority. Optionally peek at the smallest without removing it, and change an item's priority.

An unsorted array does insertion in Θ(1) and extraction in Θ(n), since finding the minimum means scanning. A sorted array reverses that, Θ(n) to insert and Θ(1) to extract. A balanced tree does both in Θ(logn) and throws in everything else. The question is whether the restricted interface can be served more cheaply than the general one.

It can, because the ordering it needs is weaker. A binary min-heap is a binary tree satisfying one condition: every node's key is less than or equal to both of its children's keys. That is all. Nothing relates the two children to each other, nothing relates the left subtree to the right, and the same set of keys admits an enormous number of valid heaps.

What survives is exactly the one thing wanted. By transitivity, the root is less than or equal to every key in the tree, so the minimum is at a known position and reading it costs nothing.

No pointers at all

The second condition is structural. A heap is a complete binary tree: every level is full except possibly the last, which is filled from the left. That shape is rigid enough to be described without pointers.

Number the nodes in level order, starting from 0 at the root. Then the children of node i are at 2i+1 and 2i+2, and the parent of node i is at (i-1)/2. Navigating the tree is arithmetic, exactly as indexing an array was arithmetic in the third lesson, so the heap is a plain array with no node objects, no allocation per element, and no pointer overhead. For a million 8-byte keys that is 8 MB against the 24 MB a binary tree with two child pointers would need, and the array is contiguous, so a level-order sweep is a sequential scan.

Completeness also fixes the height at exactly log2n, with no balancing machinery required. A heap cannot degenerate, because its shape is not a consequence of the data at all.

Example. In a heap held in an array of 9 elements, indices 0 to 8, what are the children of index 1, the parent of index 7, and the height?

The children of index 1 are at 2(1)+1=3 and 2(1)+2=4. The parent of index 7 is at 6/2=3. The height is log29=3, and indeed index 8 sits at depth 3 with parent 3, grandparent 1 and great-grandparent 0.

Now you. In a heap of 100 elements, what are the children of index 40, the parent of index 40, and which indices are leaves?

Answer

The children of 40 are at 81 and 82, and its parent is at 39/2=19. A node is a leaf when it has no child inside the array, so when 2i+1100, meaning i49.5: indices 50 to 99 are the leaves, which is half the array. That half of all nodes are leaves is the fact the linear-time build below depends on.

Insert and extract

Both updates work by moving one element along a single root-to-leaf path, repairing the heap property as they go.

To insert, place the new key at the end of the array, which keeps the tree complete, and then sift up: while the key is smaller than its parent, swap them. Each swap moves the key one level up, so the cost is at most log2n comparisons and swaps. The property is restored because the only node that could have violated it was the new one relative to its ancestors, and swapping puts a smaller key above a larger one without disturbing either's other child.

To extract the minimum, take the root, move the last element of the array into the root position, shrink the array by one, and then sift down: compare the key with its two children, swap with the smaller of them if either is smaller, and repeat. The cost is again at most log2n levels, at two comparisons per level. Swapping with the smaller child is essential: swapping with the larger one would put it above its sibling and break the property immediately.

Example. Sift-down turns an arbitrary array into a heap. Apply it to 5, 13, 2, 25, 7, 17, 20, 8, 4, working from the last internal node backwards.

With nine elements the last internal node is index 3, holding 25, whose only child is 4 at index 8, so they swap. Index 2 holds 2, smaller than both 17 and 20, so nothing moves. Index 1 holds 13; its children are now 4 and 7, so 13 swaps with 4 and then, at index 3, swaps with its child 8. Index 0 holds 5; its children are 4 and 2, and 2 is smaller, so they swap, after which 5 is smaller than 17 and 20 and stops. The array is now 2, 4, 5, 8, 7, 17, 20, 13, 25, which satisfies the heap property everywhere, and it took four swaps.

Now you. Do the same for 9, 4, 7, 1, 8, 3, 6, 2, 5.

Answer

Index 3 holds 1, smaller than its child 2, so nothing moves. Index 2 holds 7 with children 3 and 6, so 7 swaps with 3. Index 1 holds 4 with children 1 and 8, so 4 swaps with 1, then at index 3 swaps with its child 2. Index 0 holds 9, which sinks past 1, then past 2, then past 4, three swaps. The result is 1, 2, 3, 4, 8, 7, 6, 9, 5, using six swaps. Note that the final array is not sorted and is not required to be.

Building a heap costs Θ(n), not Θ(nlogn)

Inserting n keys one at a time costs O(nlogn). The build just performed does better, and the reason is worth doing properly because the same style of argument recurs.

Sift-down from every internal node, working backwards from index n/2-1 to 0. Each call is correct because both subtrees are already heaps by the time the call is made, which is what processing in reverse index order guarantees.

The naive bound is n/2 calls each costing log2n, giving O(nlogn). That over-counts badly, because almost every node is near the bottom and a sift-down from near the bottom has almost nowhere to go. At height h above the leaves there are at most n/2h+1 nodes, and each costs O(h). So the total is

h=0log2nn2h+1h=n2h=0h2h

and h0h/2h converges to exactly 2, so the whole sum is at most n. Building a heap of a million elements costs about a million swaps, not twenty million. Half the nodes are leaves and cost nothing at all, a quarter can move one level, an eighth two levels, and the series converges before the height ever matters.

Heapsort

The heap gives a sort immediately: build a max-heap, then repeatedly swap the root with the last element of the active region, shrink the region by one, and sift down. The largest element lands at the end, the next largest just before it, and after n-1 rounds the array is sorted in ascending order, in place, with no recursion and no extra array.

Published by J. W. J. Williams in 1964, with the linear-time build added by Robert Floyd the same year, heapsort was the first sort to be simultaneously in place and guaranteed Θ(nlogn). Mergesort has the guarantee and needs Θ(n) memory; quicksort is in place and has no guarantee; heapsort has both.

It is nevertheless the slowest of the three in practice, by a factor of two or so, for two honest reasons. It makes about 2nlog2n comparisons, roughly 39.9 million for a million elements against mergesort's 18.9 million and quicksort's 24.8 million, because each sift-down level costs two comparisons rather than one. And its access pattern is the opposite of cache-friendly: sifting down from index i jumps to 2i+1, then 4i+3, striding further with every level, so the deep levels of a large heap miss the cache on every step. This is why its usual role is not as a sort in its own right but as introsort's fallback, guaranteeing the worst case while quicksort handles the ordinary one.

Top k without sorting

The heap answers a common question far more cheaply than sorting does. To find the k largest of n items, keep a min-heap of size k: push the first k items, then for each remaining item compare it with the heap's root, and if it is larger, replace the root and sift down. At the end the heap holds the k largest.

The cost is O(nlogk) time and Θ(k) memory, and neither nlogn nor Θ(n) appears. The memory is the more important half: the whole input never has to be held at once, so this works on a stream that does not fit in memory.

Example. Find the 100 largest values among a billion. Compare with sorting.

The heap method costs about 109×log2100=109×6.64=6.6×109 operations and holds 100 items. Sorting costs about 109×log2109=3.0×1010 comparisons and holds all 109 items, which at 8 bytes each is 8 GB. That is 4.5 times fewer operations and ten million times less memory, and the comparison flatters sorting by ignoring that 8 GB may not exist.

Now you. Find the 10 largest of a million.

Answer

106×log210=106×3.32=3.3×106 operations against 106×19.93=2.0×107 for a sort, a factor of six, with 10 items held instead of a million. Quickselect from the eighth lesson would also do this in O(n) expected time, and is the better choice when the data is already in an array in memory and may be rearranged; the heap wins when it is arriving as a stream.

Variants, and what comes next

Two extensions matter later. A d-ary heap gives each node d children instead of two, which makes the tree shallower, so sift-up costs logdn, at the price of d-1 comparisons per sift-down level. Since Dijkstra's algorithm does far more decrease-key operations, which sift up, than extractions, a 4-ary heap is measurably faster there.

Decrease-key is the operation of lowering an item's priority in place, which is a sift-up from wherever it sits and therefore O(logn), but it requires knowing where the item is: a heap has no lookup, so a separate index from item to array position must be maintained alongside. The Fibonacci heap of Fredman and Tarjan (1984) reduces decrease-key to O(1) amortised, which improves Dijkstra's theoretical bound to O(E+VlogV); its constants are large enough that ordinary binary heaps win on all but enormous graphs, and it is a case where the better complexity is genuinely the worse choice.

The course now has a structure for every shape of data it has met: arrays and lists for sequences, hash tables for exact lookup, balanced trees for order, heaps for priority. Each of them stores a collection whose elements have no relationships beyond their keys.

A great deal of real data is not like that. Roads connect towns, functions call functions, packages depend on packages, people know people. The relationships are the data, and none of the structures so far can represent them, let alone answer questions about paths through them. The next lesson introduces the object that can, and finds that this heap is exactly what its shortest-path algorithms will run on.

Graphs and traversal

Every structure so far holds a collection of items whose only relationship is the order of their keys, and a great deal of real data is not shaped like that.

Roads join towns, functions call functions, packages depend on packages, web pages link to web pages, people know people. In all of these the relationships are the data, and the questions asked are about them: is there a route, what is the shortest one, is anything circular, what must be built before what. None of those questions can even be stated in terms of an array, a hash table or a tree. They need an object whose whole content is which things connect to which.

Vertices and edges

A graph G=(V,E) is a set of vertices and a set of edges, each edge joining a pair of vertices. That is the entire definition, and its generality is the point: a tree is a graph, a linked list is a graph, a road map is a graph.

Three choices specialise it. Edges may be undirected, so an edge between u and v can be traversed either way, as with a friendship or a two-way street; or directed, an arrow from u to v only, as with a one-way street, a function call or a package dependency. Edges may be unweighted, when only their existence matters, or weighted, when each carries a number: a distance, a cost, a capacity, a duration. And the graph may or may not allow self-loops and repeated edges, which most applications do not.

The vocabulary is worth fixing because the algorithms are stated in it. The degree of a vertex is the number of edges at it, split into in-degree and out-degree when directed. A path is a sequence of vertices each joined to the next; a cycle is a path returning to its start. A graph is connected if a path joins every pair, and a directed graph is strongly connected if that holds respecting the arrows. A directed graph with no cycles is a DAG, a directed acyclic graph, which is the shape of every dependency system.

Two size facts govern everything below. An undirected graph on V vertices has at most V(V-1)/2 edges, so E=O(V2). A graph is called sparse when E is closer to V and dense when it approaches V2, and nearly every large real graph is sparse: road networks have an average degree near 3, because junctions have three or four roads, and social networks have an average degree in the hundreds against billions of users.

Two representations, and the arithmetic that decides

An adjacency matrix is a V×V table whose entry (i,j) says whether the edge ij exists, or holds its weight. Testing an edge is one array access. It costs Θ(V2) memory whether the graph has a million edges or none, and listing a vertex's neighbours costs Θ(V) because the whole row must be scanned.

An adjacency list stores, for each vertex, a list of its neighbours. Memory is Θ(V+E), listing a vertex's neighbours costs exactly its degree, and testing a specific edge costs the degree rather than constant time.

Example. The road network of the United States has about 24 million intersections and 58 million road segments. What does each representation cost?

The matrix has (2.4×107)2=5.76×1014 entries. Even at one bit each, that is 7.2×1013 bytes, about 72 terabytes, to store a graph in which 99.9999 per cent of the entries would be zero. The adjacency list stores V+2E=2.4×107+1.16×108=1.4×108 entries, since each undirected edge appears in both endpoints' lists, and at 8 bytes each that is 1.12 gigabytes. The list is about 64,000 times smaller and fits on a laptop.

Now you. A graph has 5000 vertices and 12 million edges, directed. Which representation is smaller, at 8 bytes per list entry and 1 bit per matrix entry?

Answer

The matrix is 50002=2.5×107 bits, which is 3.1 megabytes. The list is V+E=5000+1.2×107 entries at 8 bytes, about 96 megabytes. Here the matrix wins by a factor of 31, because the graph is dense: 12 million edges out of a possible 25 million means nearly half the entries are ones, and a bit per possible edge is cheaper than eight bytes per actual one. The crossover is roughly at E=V2/64.

So the choice is a calculation, not a preference. The list wins on sparse graphs, which is nearly all large ones; the matrix wins on dense graphs, on very small ones where V2 is trivial, and on algorithms that repeatedly ask "is this specific edge present" rather than "who are the neighbours". Everything in this course assumes adjacency lists.

The first question to ask of a graph is what a vertex can reach. Answering it systematically means visiting every reachable vertex exactly once, and the two ways of doing that differ only in which pending vertex is taken next.

Breadth-first search takes the oldest. Mark the source as visited at distance 0 and put it in a queue. Repeatedly remove a vertex from the front, and for each unvisited neighbour, mark it visited at one more than the current distance, record the current vertex as its parent, and add it to the back of the queue.

Because the queue is first in, first out, vertices come out in non-decreasing order of distance, so the whole graph is swept in rings: everything one edge away, then everything two edges away, and so on. The distance recorded is therefore the fewest number of edges from the source, and the parent pointers form a tree of shortest paths. That claim needs the queue specifically, and it is why an unweighted shortest path is a BFS and nothing more elaborate.

Each vertex enters the queue once and each edge is examined once from each end, so the cost is Θ(V+E) with adjacency lists. Nothing about it is logarithmic; a traversal has to touch everything.

Example. In an undirected graph with edges AB, AC, BD, CD, CE, DF, EF, FG, GH and EH, run a breadth-first search from A and give every distance.

A is 0. Its neighbours B and C are 1. From B comes D at 2, and from C comes E at 2, since D was already reached. From D comes F at 3, and from E comes H at 3. From F comes G at 4, and H is already known. The distances are A 0, B 1, C 1, D 2, E 2, F 3, H 3, G 4. Checking G by hand: A to B to D to F to G is four edges, and A to C to E to H to G is also four, so 4 is right and there is no shorter route.

Now you. Run it from H instead.

Answer

H is 0. Its neighbours G and E are 1. From G comes F at 2, from E come C at 2 and F already found. From F comes D at 3, from C come D already found and A at 3. From D and A comes B at 4. Distances: H 0, G 1, E 1, F 2, C 2, D 3, A 3, B 4. The distances are not the same as before reversed, because distance from A to H was 3 while distance from H to A is 3; but B was 1 from A and is 4 from H, which is fine: what BFS computes is distance from one source, not a symmetric table.

Depth-first search takes the newest pending vertex instead, which makes it a stack rather than a queue and, more naturally, a recursion: visit a vertex, then recursively visit each unvisited neighbour in turn. It plunges as deep as it can, backtracks when stuck, and plunges again.

The cost is the same Θ(V+E), and it visits the same set of vertices, so as a reachability test the two are interchangeable. What differs is the structure they expose. BFS produces layers and shortest distances. DFS produces a nesting: each vertex has a discovery time and a finish time, and because the recursion is a stack, the intervals for any two vertices are either disjoint or one contains the other. That nesting is what makes DFS the tool for questions about structure rather than distance.

Colour each vertex white when unvisited, grey while its recursive call is on the stack, and black when finished. Then an edge from the current vertex to a grey vertex points back into the chain of calls that led here, which means a cycle. That is the whole of cycle detection: a directed graph has a cycle if and only if a depth-first search finds an edge to a grey vertex, and one traversal answers it in Θ(V+E).

Topological order

A DAG describes things that must happen in some order, and the useful output is that order: a listing of the vertices in which every edge points forwards. It is called a topological order, and it exists exactly when the graph is acyclic, since a cycle would require a vertex to precede itself.

Two algorithms produce it. Kahn's, from 1962, is the more intuitive: compute every in-degree, put all the zero in-degree vertices in a ready set, and repeatedly remove one, output it, and decrement the in-degrees of its targets, adding any that reach zero. If the output is shorter than V when the ready set empties, the remaining vertices form a cycle, so this detects cycles as a side effect. The DFS route is shorter to state: run a depth-first search and output vertices in reverse order of finishing time. It works because a vertex finishes only after everything reachable from it has finished.

Neither order is unique. Any vertex with no unmet prerequisite may go next, so a graph generally has many valid topological orders, and an algorithm may return any of them.

Example. A build has these dependencies: A must precede B and C, B must precede D, C must precede D and F, D must precede E, and F must precede E. Give a topological order by Kahn's algorithm, breaking ties alphabetically.

In-degrees are A 0, B 1, C 1, D 2, E 2, F 1. Only A is ready, so output A, which frees B and C. Output B, which decrements D to 1, not yet ready. Output C, which decrements D to 0 and frees F. Output D, which decrements E to 1. Output F, which decrements E to 0. Output E. The order is A, B, C, D, F, E, and every one of the six edges points forwards in it.

Now you. Add the requirement that E must precede C. Is there still a topological order?

Answer

No. C precedes D, D precedes E, and now E precedes C, so C must come before itself. Kahn's algorithm shows it mechanically: A is output and frees B, then B is output, and now nothing has in-degree zero, since C is waiting on E, D on C, E on D and F on C. The algorithm halts having output 2 of the 6 vertices, and that shortfall is the cycle report. A depth-first search would find it as an edge from E to the grey vertex C.

What this gives, and what it does not

Two traversals, both Θ(V+E), answer a surprising number of questions. BFS gives reachability, connected components, the fewest-edge path between two vertices, and by running from both ends at once, the standard degrees-of-separation calculation. DFS gives cycle detection, topological order, and with a little more bookkeeping the strongly connected components, through Tarjan's 1972 algorithm or Kosaraju's, both still linear.

The limitation is in the word "fewest". BFS counts edges, and treats every edge as equally expensive. A road map is not like that: a route with three motorway segments beats one with two urban side streets, and the shortest path by distance may have many more edges than the shortest by count. The moment edges carry weights, BFS's ring-by-ring sweep is answering the wrong question, because the queue orders vertices by hop count and the cheapest route to a vertex may arrive later than the shortest one.

Fixing that means taking the pending vertex with the smallest accumulated cost rather than the one that has waited longest, which is to replace the queue with the priority queue built in the previous lesson. The next lesson does exactly that, proves that the resulting algorithm is correct only when weights are non-negative, gives the slower algorithm that survives negative ones, and then solves the related problem of connecting every vertex as cheaply as possible.

Shortest paths and spanning trees

Breadth-first search finds the route with the fewest edges, and on any graph whose edges carry a cost that is the wrong answer.

Three motorway segments beat two urban side streets, a flight with one connection may cost more than one with two, and a network path through four fast links beats one through two congested ones. The moment edges carry weights, "fewest" and "cheapest" come apart, and the sweep that made BFS correct, taking pending vertices in the order they were discovered, is exactly what stops working. This lesson repairs it, is precise about the condition under which the repair is valid, and then solves the closely related problem of wiring every vertex together as cheaply as possible.

Relaxation

Give every vertex an estimate d[v] of its distance from the source, initially 0 at the source and everywhere else. Every shortest-path algorithm in this lesson consists of repeating one operation until no estimate improves.

Relaxing the edge (u,v) of weight w means: if d[u]+w<d[v], then set d[v]=d[u]+w and record u as v's predecessor. In words, if going to u and then taking this edge is cheaper than the best route to v known so far, take it.

Two facts hold throughout. Every estimate is either infinite or the length of some real path, so no estimate is ever too small. And if d[u] is already correct and the edge (u,v) lies on a shortest path to v, relaxing it makes d[v] correct. The algorithms differ only in the order they relax edges: relax them in a bad order and the work repeats, in a good order and each edge is relaxed once.

The reason a good order exists is that shortest paths have optimal substructure: any subpath of a shortest path is itself a shortest path between its endpoints, since a cheaper subpath could be substituted to improve the whole. This licenses building long paths from short ones, and it is the same property the last two lessons of the course turn into a general technique.

Dijkstra's algorithm

Edsger Dijkstra designed this in 1956, by his own account in about twenty minutes in a cafe in Amsterdam, and published it in 1959.

The idea is to settle vertices in increasing order of true distance. Keep a set of settled vertices whose distances are final and a priority queue of the rest, keyed by their current estimate. Repeatedly extract the vertex with the smallest estimate, declare it settled, and relax all its outgoing edges.

The claim that makes this work is that when a vertex u is extracted, d[u] is already its true distance. Suppose not: then a genuinely shorter path to u exists, and that path must leave the settled set somewhere, at some unsettled vertex x. But x has already been given an estimate by the relaxation that reached it, that estimate is at most the cost of the path's prefix up to x, and that prefix is no longer than the whole path, which was assumed shorter than d[u]. So d[x]<d[u] and x would have been extracted before u. Contradiction.

Read that argument closely and the assumption is visible: the prefix is no longer than the whole path. That is true only if the remaining edges do not reduce the total, which is to say only if every weight is non-negative. Dijkstra's algorithm is correct under that condition and not otherwise.

The cost is one extraction per vertex and one possible decrease-key per edge. With the binary heap from the previous lesson that is O((V+E)logV), and on a sparse graph such as a road network, where E3V, that is close to VlogV. With an array instead of a heap, extraction is Θ(V) and the total is Θ(V2), which is actually faster on dense graphs.

Example. In an undirected graph with edges AB 4, AC 2, BC 1, BD 5, CD 8, CE 10, DE 2, DF 6 and EF 3, run Dijkstra from A.

A is settled at 0, relaxing B to 4 and C to 2. The smallest estimate is C at 2, which is settled; relaxing its edges improves B to 2+1=3, sets D to 2+8=10 and E to 2+10=12. Next is B at 3, whose edge to D improves it to 3+5=8. Next is D at 8, which improves E to 8+2=10 and sets F to 8+6=14. Next is E at 10, which improves F to 10+3=13. Finally F is settled at 13. The distances are A 0, C 2, B 3, D 8, E 10, F 13, and note that B was improved after its first estimate but before it was settled, which is the normal course of events.

Now you. Run it from F on the same graph.

Answer

F settles at 0, giving E 3 and D 6. E settles at 3, improving D to 3+2=5 and setting C to 3+10=13. D settles at 5, setting B to 5+5=10 and leaving C at 13 since 5+8 is also 13. B settles at 10, improving C to 10+1=11 and setting A to 10+4=14. C settles at 11, improving A to 11+2=13. A settles at 13, agreeing with the previous run, as it must on an undirected graph.

When a weight is negative

Negative weights are not exotic. A currency exchange with a favourable rate, a chemical reaction that releases energy, a financial transaction with a rebate, a game move that gains points: all are edges with negative cost.

Example. A directed graph has A to B of weight 2, A to C of weight 5, and C to B of weight -4. What does Dijkstra return for B, and what is the truth?

Dijkstra settles A at 0, giving B an estimate of 2 and C an estimate of 5. The smallest is B at 2, so B is settled at 2 and never reconsidered. But the real cheapest route is A to C to B, costing 5-4=1. Dijkstra returns 2, and the answer is 1.

Now you. Does adding a large constant to every edge, to make them all non-negative, repair this?

Answer

No. Add 4 to each: A to B becomes 6, A to C becomes 9, C to B becomes 0. Now A to B costs 6 and A to C to B costs 9, so the two-edge route lost, when in the original graph it won. Adding a constant per edge penalises paths in proportion to their number of edges, which changes which path is shortest. Only a per-vertex reweighting preserves the ordering, which is exactly what Johnson's algorithm does, using the next algorithm to compute it.

Bellman-Ford, from Richard Bellman in 1958 and Lester Ford in 1956, gives up the ordering entirely and pays for it. Relax every edge in the graph, and repeat that V-1 times. Since a shortest path has at most V-1 edges, and each full pass over the edges correctly extends every shortest path by at least one more edge, V-1 passes suffice. The cost is Θ(VE), which for a graph with a million vertices and three million edges is 3×1012 operations against Dijkstra's 8×107: about 40,000 times more.

Bellman-Ford also detects something Dijkstra cannot even define. If a V-th pass still improves an estimate, the graph contains a negative cycle reachable from the source, and there is no shortest path at all, because going round the cycle again is always cheaper. That check is one extra pass and is the reason the algorithm is used to detect arbitrage in currency data.

Connecting everything, cheaply

A different problem on the same weighted graphs: choose a subset of edges connecting every vertex, with the least total weight. Wire a set of buildings, lay a pipe network, cluster a set of points.

The answer must be a tree. It has to be connected by requirement, and it cannot contain a cycle, since deleting any edge of a cycle leaves everything still connected and reduces the total. A connected acyclic subgraph touching every vertex is a spanning tree, and the cheapest one is a minimum spanning tree, with exactly V-1 edges.

One fact settles both algorithms. Take any way of splitting the vertices into two non-empty parts, called a cut. The cheapest edge crossing that cut belongs to some minimum spanning tree. The proof is an exchange: take any minimum spanning tree not containing that edge e, add e, which creates exactly one cycle, and that cycle must cross the cut a second time at some edge f. Remove f. The result is still a spanning tree, and since e was the cheapest crossing edge, its weight is no greater than f's, so the new tree is no more expensive. This is the cut property, and both algorithms are just different choices of which cut to apply it to.

Prim's algorithm, from Vojtěch Jarník in 1930 and rediscovered by Robert Prim in 1957, grows one tree. Start from any vertex, and repeatedly add the cheapest edge from the tree to a vertex outside it, using the cut between tree and non-tree. That is Dijkstra with a different key: the priority queue holds the cost of the cheapest single edge reaching a vertex rather than the cost of the whole path to it. The cost is likewise O(ElogV).

Kruskal's algorithm, from Joseph Kruskal in 1956, grows a forest. Sort all edges by weight, and take each in turn, adding it if its endpoints are in different components and discarding it otherwise. Each accepted edge is the cheapest crossing the cut between its own component and everything else, so the cut property applies. The sort costs Θ(ElogE) and dominates.

Example. Run Kruskal on the graph above, with edges AB 4, AC 2, BC 1, BD 5, CD 8, CE 10, DE 2, DF 6 and EF 3.

Sorted: BC 1, AC 2, DE 2, EF 3, AB 4, BD 5, DF 6, CD 8, CE 10. Take BC, joining B and C. Take AC, joining A to them. Take DE. Take EF, giving a second component D, E, F. Reject AB, since A and B are already together. Take BD, which joins the two components into one containing all six vertices. That is five edges for six vertices, so the tree is complete and the remaining edges are never examined. Total weight 1+2+2+3+5=13.

Now you. Run it on the graph with edges PQ 7, PR 5, QR 8, QS 9, RS 15, RT 6, ST 8, SU 5 and TU 11.

Answer

Sorted: PR 5, SU 5, RT 6, PQ 7, QR 8, ST 8, QS 9, TU 11, RS 15. Take PR, take SU, take RT, take PQ. Reject QR, since Q and R are both in the component P, Q, R, T. Take ST, which merges S, U with that component, giving all six vertices in five edges. Total weight 5+5+6+7+8=31. Prim's algorithm from P would take the same five edges in the order PR, RT, PQ, ST, SU, which is a different order and the same tree.

Union-find

Kruskal's rejection test asks, for each edge, whether two vertices are already in the same component, and merges two components when they are not. Done naively, by relabelling every vertex of one component, a single merge costs Θ(V) and the algorithm becomes quadratic.

The structure that does it properly is union-find, also called disjoint-set. Each component is a tree of parent pointers whose root names the set. find(x) walks to the root; union(x, y) links one root under the other. Two refinements make it fast. Union by rank always links the shorter tree under the taller, keeping heights logarithmic. Path compression makes every node visited by a find point directly at the root afterwards, so the walk pays for itself.

With both, a sequence of m operations on n elements costs O(mα(n)), where α is the inverse Ackermann function, proved by Robert Tarjan in 1975. That function grows so slowly that α(n)4 for every n up to 265536. The cost is not constant, and Tarjan also proved that no structure can make it constant, but it is below any threshold that will ever be measured, so in Kruskal it is negligible against the sort.

What is settled and what is not

Shortest paths from one source cost O((V+E)logV) with non-negative weights and Θ(VE) without them. All pairs can be had by running Dijkstra from every vertex, or in Θ(V3) by Floyd and Warshall's 1962 triple loop over an adjacency matrix, which is better on dense graphs. Minimum spanning trees cost O(ElogV) either way.

Something the three algorithms have in common is worth naming. Dijkstra takes the vertex with the smallest estimate. Prim takes the cheapest edge leaving the tree. Kruskal takes the cheapest edge overall. In every case the algorithm takes the locally best option available and never goes back to reconsider it, and in every case the result is provably optimal.

That is remarkable, and it is not general. Taking the locally best option is a strategy that usually fails, and each of these algorithms needed its own argument, the cut property or the settling argument, to show that it does not. The next lesson names the strategy, states what has to be proved for it to be trusted, and shows a problem, one sentence away from a problem it solves perfectly, where it fails.

Greedy algorithms

Dijkstra takes the vertex with the smallest estimate, Prim the cheapest edge leaving the tree, Kruskal the cheapest edge anywhere, and all three are provably optimal.

That is worth being suspicious about. Each one commits to a choice at the moment it looks best and never revisits it, which is the behaviour that in ordinary life is called short-sighted, and the three proofs given in the previous lesson were separate, each needing its own argument. This lesson names the pattern, states exactly what must be proved before it can be believed, works the proof properly on one problem, computes a real code with it, and then breaks it on a problem one word away from a problem it solves perfectly.

The pattern and its obligation

A greedy algorithm builds a solution one piece at a time. At each step it picks whatever looks best by some local rule, adds it to the partial solution, and never undoes the choice.

Compared with divide and conquer, this is not a technique so much as a hope, and it is usually a false one. The obligation to discharge has two parts.

The greedy choice property: there exists an optimal solution containing the first choice the rule makes. Note the shape carefully. It is not that the greedy choice is obviously good, and it is not that every optimal solution contains it. It is that at least one optimal solution can be found that agrees with the greedy choice, which is what allows the choice to be made without loss.

Optimal substructure: after making that choice and removing it from the problem, the remainder is a smaller instance of the same problem, and an optimal solution to the remainder combines with the choice to give an optimal solution overall.

Together these give an induction: the first choice is safe, the rest of the problem is the same problem, so every choice is safe. The greedy choice property is where all the work is, and the standard way to prove it is an exchange argument: take any optimal solution, and show that it can be transformed into one containing the greedy choice without becoming worse.

Interval scheduling, done properly

One lecture theatre, and a set of talks each with a fixed start and finish. Overlapping talks cannot both be held. Schedule as many talks as possible.

The obvious rules are wrong. Take the talk that starts earliest, and a single talk running all day is chosen, excluding everything else. Take the shortest talk: given talks over the intervals (1,5), (4,6) and (5,9), the shortest is (4,6), which conflicts with both of the others, so one talk is scheduled where two were possible. Take the talk conflicting with fewest others, which sounds much more thoughtful, and there are known instances where it also fails.

The rule that works is: take the talk that finishes earliest, then discard everything conflicting with it, and repeat.

The exchange argument. Let g be the earliest-finishing talk overall, and let S be any optimal schedule. If gS, done. Otherwise, let f be the earliest-finishing talk in S. Since g finishes no later than f, and the other talks in S all start after f finishes, they all start after g finishes too. So S with f replaced by g is a valid schedule of the same size, and it contains g. An optimal schedule containing the greedy choice therefore exists. Optimal substructure is immediate: once g is fixed, the remaining problem is the same problem on the talks starting after g finishes.

The intuition the proof formalises is that finishing early leaves the most room for whatever comes next, and no other rule maximises the resource that later choices consume.

Sorting by finish time costs Θ(nlogn) and the sweep is Θ(n).

Example. Talks occupy the intervals (1,4), (3,5), (0,6), (5,7), (3,8), (5,9), (6,10), (8,11), (8,12), (2,13) and (12,14). Which does the rule choose, and what would earliest start give?

Sorted by finish, the first is (1,4): take it. The earliest-finishing talk starting at or after 4 is (5,7): take it. Then (8,11), then (12,14). Four talks. Sorting by start time instead takes (0,6) first, which rules out (1,4), (3,5) and (5,7), then (6,10), then (12,14): three talks, 25 per cent worse on an instance of eleven.

Now you. Apply the rule to (0,3), (2,5), (4,7), (1,8), (6,9), (8,10) and (7,11).

Answer

Sorted by finish: (0,3), (2,5), (4,7), (1,8), (6,9), (8,10), (7,11). Take (0,3). The next finishing at or after a start of 3 is (4,7), since (2,5) starts too early. Then (8,10), since (1,8), (6,9) all start before 7. Three talks. Note that (7,11) also starts at or after 7 and was passed over for (8,10), which finishes earlier and blocks nothing that (7,11) would have allowed.

Huffman coding

The best-known greedy algorithm is a compression scheme, from a 1951 term paper by David Huffman, then a graduate student who took the assignment instead of the final exam.

Fixed-width encoding gives every symbol the same number of bits: six distinct symbols need three bits each. That is wasteful when the symbols are not equally common, and the fix is to give common symbols short codes and rare symbols long ones. The danger is ambiguity, and it is avoided by a prefix code: no code word is a prefix of another, so a bit stream decodes with no separators and no lookahead. A prefix code is exactly a binary tree with symbols at the leaves, left meaning 0 and right meaning 1, and the length of a symbol's code is its depth.

The cost to minimise is sf(s)depth(s) over the symbols, with f the frequency. Huffman's rule is greedy and works from the bottom: repeatedly take the two least frequent items remaining, make them the children of a new node with their combined frequency, and put that node back. Stop when one node is left.

The greedy choice property is again an exchange. The two least frequent symbols x and y can be assumed to be siblings at the greatest depth of some optimal tree: take any optimal tree, look at the two deepest siblings a and b, and swap x with a and y with b. Since x and y have the smallest frequencies and a and b were at the greatest depth, each swap moves a smaller frequency to a deeper place and a larger one to a shallower place, so the total cannot increase. Optimal substructure follows because merging x and y into one symbol of combined frequency gives a smaller instance whose optimal tree extends to an optimal tree for the original.

Using a heap for the repeated "two smallest" query costs O(nlogn), which is the other half of why the previous lesson built one.

Example. Six symbols occur 40, 20, 15, 12, 8 and 5 times in a 100-symbol message. Build the Huffman code and measure the saving against fixed width.

Take the two smallest, 5 and 8, and merge them into 13. Now the smallest two are 12 and 13, merging to 25. Then 15 and 20 merge to 35. Then 25 and 35 merge to 60. Finally 40 and 60 merge to 100. Reading depths off the tree: the symbol of frequency 40 sits at depth 1, those of 20, 15 and 12 at depth 3, and those of 8 and 5 at depth 4. The total is 40(1)+20(3)+15(3)+12(3)+8(4)+5(4)=233 bits. Fixed width needs three bits for six symbols, so 300 bits. The saving is 67 bits, 22.3 per cent, and the average code length is 2.33 bits per symbol.

Now you. Do the same for frequencies 35, 25, 20, 10, 6 and 4.

Answer

Merge 4 and 6 into 10; merge that 10 with the symbol of frequency 10 into 20; merge that 20 with the symbol of frequency 20 into 40; merge 25 and 35 into 60; merge 40 and 60 into 100. Depths are 2 for the symbols of frequency 35, 25 and 20, 3 for the one of frequency 10, and 4 for those of 6 and 4. The total is 35(2)+25(2)+20(2)+10(3)+6(4)+4(4)=230 bits against 300, a saving of 23.3 per cent. Note that the most frequent symbol got a two-bit code here and a one-bit code in the previous example: the code depends on the whole frequency distribution, not on any symbol's frequency alone.

Be honest about the limit. Huffman is optimal among codes assigning a whole number of bits to each symbol, and that constraint costs something. Shannon's entropy for the first distribution is 2.278 bits per symbol against Huffman's 2.330, so rounding to whole bits wastes 2.3 per cent. When one symbol has probability 0.9, entropy is 0.47 bits and Huffman must still spend 1, wasting more than half. Arithmetic coding and the modern ANS family avoid the whole-bit constraint and reach the entropy, which is why they, and not Huffman, are inside modern formats such as Zstandard and AV1. Huffman survives inside DEFLATE, JPEG and MP3, and as the last stage of hybrids.

Greed that depends on the numbers

Between the proved cases and the failures sits a third category, where whether greed works depends on the particular data, and change-making is the standard illustration.

To make an amount from coins of given denominations using as few coins as possible, the obvious rule is to take the largest coin that fits and repeat. With British or American coins it is optimal, which is a fact about those denominations rather than about the rule.

Change the denominations to 1, 3 and 4 and it breaks at the sixth coin. Greed makes 6 as 4+1+1, three coins; the optimum is 3+3, two. A coin system for which the greedy rule is always optimal is called canonical, and deciding whether a given system is canonical is itself work: Pearson gave an algorithm in 2005 that tests it in O(m3) for m denominations.

So "greedy works here" is a claim about a specific problem with specific data, never about the strategy, and a greedy algorithm that passes every example is still unjustified without a proof.

Where greed fails

Now the failure, and it is worth how close it sits to a success.

The fractional knapsack problem: a bag of capacity W, items with values and weights, and items may be cut. The greedy rule is to sort by value per unit weight and take the best until the bag is full, cutting the last item to fit. It is optimal, by exchange: any solution containing a unit of a worse item and lacking a unit of a better one can be improved by swapping them.

The 0/1 knapsack problem is identical except that items must be taken whole. That one word destroys the argument, because there is no longer a unit to swap.

Example. A bag holds 50 kg. Item A is worth 60 and weighs 10, item B is worth 100 and weighs 20, item C is worth 120 and weighs 30. What does the ratio rule give, and what is optimal?

The ratios are 6, 5 and 4, so greed takes A then B, filling 30 kg for a value of 160, and C's 30 kg does not fit in the remaining 20. Greedy scores 160. But B and C together weigh exactly 50 and are worth 220. Greed is 27 per cent short, and no reordering of the rule fixes it: taking C first gives 120+100=220 by luck here, but ratio-first is the only rule with any general justification and it fails. Fractionally, the same instance allows A, B and two thirds of C, for 60+100+80=240.

Now you. A bag holds 10 kg. One item is worth 10 and weighs 6; two others are each worth 8 and weigh 5. What does greed give?

Answer

Ratios are 10/6=1.67 and 8/5=1.60, so greed takes the 6 kg item first, leaving 4 kg, into which neither 5 kg item fits. Value 10. The optimum is the two 5 kg items, exactly filling the bag, value 16. Greed loses 37.5 per cent, and it loses on an instance of three items, which is the point: this is not an asymptotic failure that appears at scale, it is wrong immediately.

What the failure is made of

The reason is precise. In interval scheduling, taking the earliest-finishing talk leaves a subproblem whose optimal solution does not depend on which talk was taken, only on the time now free. In 0/1 knapsack, taking an item leaves a subproblem parameterised by the remaining capacity, and whether taking the item was right depends on what best fills that remaining capacity, which is not known yet. The first choice cannot be evaluated without the answer to the rest.

Optimal substructure still holds: an optimal packing of the bag does contain an optimal packing of whatever capacity it leaves for the remaining items. What fails is only the greedy choice property. So the structure that made recursion possible survives, and the only thing lost is the right to commit to one branch.

If the choice cannot be made in advance, the alternative is to try both and keep the better. Done naively that is exponential, since n items give 2n subsets. But the subproblems that arise are described entirely by two numbers, how many items remain and how much capacity is left, and there are only nW such pairs however many of the 2n paths reach them. Solving each once and reusing the answer is the technique of the next lesson.

Dynamic programming

Greed fails on 0/1 knapsack because the value of taking an item depends on what best fills the capacity it leaves, which is not known when the choice has to be made.

The obvious response is to stop choosing and try both branches, take the item or leave it, and keep whichever turns out better. Done directly that is 2n subsets and hopeless. This lesson shows that it is not hopeless at all, because the recursion asks the same questions over and over, and that answering each question once turns an exponential algorithm into a polynomial one, at the cost of a table.

The same subproblem, again and again

The clearest demonstration needs no optimisation at all. Define F(n)=F(n-1)+F(n-2) with F(0)=0 and F(1)=1, and compute it by writing exactly that as a recursive function.

Count the calls. The call tree for F(5) contains F(3) twice, F(2) three times, F(1) five times. In general the number of calls satisfies the Fibonacci recurrence itself, so it grows like φn. Computing F(40) makes 331,160,281 calls, about a third of a second on a modern machine. F(50) makes 40,730,022,147, forty seconds. F(100) would outlast the machine.

Yet there are only 101 distinct values in play. The algorithm is exponential purely because it recomputes the same answers, and this is the condition that dynamic programming exploits, called overlapping subproblems: the recursion visits a small set of distinct subproblems an enormous number of times.

Overlapping subproblems is not the same as the divide-and-conquer situation. Mergesort's two halves are disjoint and share nothing, so there is nothing to reuse and no table would help. Dynamic programming applies when the subproblems overlap and the problem has optimal substructure, the same property that licensed greedy recursion. Both are needed: overlap without optimal substructure gives nothing to combine, and optimal substructure without overlap is just divide and conquer.

There are two ways to exploit it. Memoisation keeps the recursion and adds a cache: before computing, look the subproblem up; after computing, store it. Bottom-up tabulation discards the recursion, works out the order in which subproblems depend on each other, and fills a table in that order with loops.

Memoisation is easier to write from a working recursion and computes only the subproblems actually reached. Tabulation avoids the call overhead, has predictable memory access, and often allows the table to be shrunk, since a row may depend only on the row above. Both turn φn into n for Fibonacci: 39 additions rather than 331 million calls, a factor of eight million at n=40.

Rod cutting

Now a real optimisation. A rod of length n can be cut into whole-number pieces, and a piece of length i sells for pi. Maximise the revenue.

There are 2n-1 ways to cut, one for each subset of the n-1 internal positions, so enumeration is out. The recursive structure is: the leftmost piece has some length i between 1 and n, and whatever it is, the rest of the rod should be cut optimally. So

r(n)=max1in(pi+r(n-i))

with r(0)=0. That is optimal substructure stated directly, and the subproblems overlap heavily: r(n-1) is needed by r(n), by r(n+1), and so on. Filling r(0) upwards to r(n) costs Θ(n2), since computing r(k) examines k options.

Example. Pieces of length 1 to 8 sell for 2, 5, 7, 8, 10, 17, 17 and 20. Fill the table and find the best revenue for a rod of length 8.

r(1)=2. r(2)=max(2+r(1),5)=max(4,5)=5. r(3)=max(2+5,5+2,7)=7. r(4)=max(2+7,5+5,7+2,8)=10. r(5)=max(2+10,5+7,7+5,8+2,10)=12. r(6)=max(2+12,5+10,7+7,8+5,10+2,17)=17. r(7)=max(2+17,5+12,7+10,8+7,10+5,17+2,17)=19. r(8)=max(2+19,5+17,7+12,8+10,10+7,17+5,17+2,20)=22.

So 22, from a piece of length 2 and a piece of length 6, worth 5+17. Note that selling the whole 8 fetches only 20, and that the greedy rule of taking the best price per unit length would pick length 6 at 2.83 per unit first, then length 2 at 2.50, reaching 22 here by luck; change p6 to 16 and greed picks 6 then 2 for 21 while the optimum is r(8)=21 as well. Greed is not reliably wrong, it is merely unproved.

Now you. With the same prices, what is r(9) if a length-9 piece sells for 24?

Answer

r(9)=max(p1+r(8),p2+r(7),p3+r(6),p4+r(5),p5+r(4),p6+r(3),p7+r(2),p8+r(1),24), which is max(2+22,5+19,7+17,8+12,10+10,17+7,17+5,20+2,24)=24. Five different decompositions tie at 24, including the uncut rod. Ties are common and harmless: the algorithm returns a maximum, and any decomposition attaining it is a correct answer.

0/1 knapsack

Now the problem greed could not solve. Items 1 to n with weights wi and values vi, a bag of capacity W, each item taken whole or not at all.

The subproblem needs two parameters, and identifying them is the real work. Let K(i,w) be the best value obtainable from the first i items with capacity w. Item i is either left out, giving K(i-1,w), or taken, which is only possible if wiw and gives vi+K(i-1,w-wi). So

K(i,w)=max(K(i-1,w),vi+K(i-1,w-wi))

with K(0,w)=0 and the second branch dropped when the item does not fit. The table has (n+1)(W+1) cells and each costs constant work, so the whole thing is Θ(nW).

Example. A bag holds 8 kg. The items are (3 kg, 4), (4 kg, 5), (2 kg, 3) and (5 kg, 6). Fill the table and find the best value.

Row 0 is all zeros. Row 1, with only the 3 kg item, is 0 for capacities 0 to 2 and 4 from capacity 3 onwards. Row 2 adds the 4 kg item: capacities 0 to 2 stay 0, capacity 3 stays 4, capacities 4 to 6 give 5, and capacity 7 gives 4+5=9, as does capacity 8. Row 3 adds the 2 kg item: capacity 2 becomes 3, capacity 3 stays 4, capacity 4 stays 5, capacity 5 becomes 3+4=7, capacity 6 becomes 3+5=8, and capacities 7 and 8 stay 9. Row 4 adds the 5 kg item and changes only capacity 8, to 6+K(3,3)=6+4=10. The answer is 10.

Now you. A bag holds 9 kg, with items (2 kg, 3), (3 kg, 4), (4 kg, 5) and (5 kg, 6).

Answer

The final answer is 12. Working the last row: K(4,9)=max(K(3,9),6+K(3,4)), which is max(12,6+5)=12, so the 5 kg item is left out. The first three items weigh 2+3+4=9 exactly and are worth 3+4+5=12. Note that the ratio rule would have taken the 2 kg item first at 1.5 per kg, then the 3 kg at 1.33, then the 4 kg at 1.25, arriving at the same answer here; it is the previous lesson's instances, not all of them, where it goes wrong.

Reading the answer back

The table holds the value of the optimum, not the optimum itself, and the choices have to be recovered. Two ways.

Store a decision alongside each cell as it is filled, which costs memory but makes recovery a direct walk. Or reconstruct from the values alone, which costs nothing extra: start at K(n,W) and compare it with K(n-1,W). If they differ, item n must have been taken, so record it and move to K(n-1,W-wn); if they agree, item n was not needed, so move to K(n-1,W). Repeat down to row 0.

On the first knapsack example: K(4,8)=10 and K(3,8)=9, so item 4 was taken, leaving K(3,3). K(3,3)=4 and K(2,3)=4, so item 3 was not taken; nor was item 2, since K(2,3)=K(1,3)=4. K(1,3)=4 against K(0,3)=0, so item 1 was taken. The packing is items 1 and 4, weighing 3+5=8 and worth 4+6=10.

Edit distance

The best-known table in this family compares two strings. The edit distance between them, from Vladimir Levenshtein in 1965, is the fewest single-character insertions, deletions and substitutions turning one into the other.

Let D(i,j) be the distance between the first i characters of a and the first j of b. Consider the last operation performed. It either deleted ai, costing 1+D(i-1,j); or inserted bj, costing 1+D(i,j-1); or aligned ai with bj, costing D(i-1,j-1) plus 1 if the characters differ and 0 if they match. Take the minimum of the three. The base cases are D(i,0)=i and D(0,j)=j, since an empty string needs one operation per character.

The table is (m+1)(n+1) cells filled in Θ(mn) time, and only the previous row is ever needed, so the space can be cut to Θ(min(m,n)) if the alignment itself is not wanted.

Example. What is the edit distance between "kitten" and "sitting"?

Filling the table row by row gives 3, and the path shows what the operations are: substitute k with s, substitute e with i, and insert g at the end. Spot-checking one interior cell, D(3,3) compares "kit" with "sit" and is 1, which is right: one substitution.

Now you. Compute the edit distance between "sunday" and "saturday".

Answer
  1. The route is "sunday" to "saunday" by inserting a, to "satunday" by inserting t, to "saturday" by substituting r for n. The table gives the count without finding that alignment first, which is the point of filling a table rather than searching for a sequence of edits: the search space of edit sequences is enormous and the table has 7×9=63 cells.

Longest common subsequence is the same table with a different recurrence, and it is what diff computes: the unchanged lines are the longest common subsequence of the two files, and the added and removed lines are everything else. Sequence alignment in bioinformatics is the same table again, with a scoring matrix in place of the unit costs.

The cost, and the crack in it

The pattern is now visible. Identify the subproblem and its parameters, write the recurrence, decide the fill order, and the running time is the number of table cells times the work per cell. Rod cutting has n cells at Θ(n) work each, so Θ(n2). Knapsack has nW cells at constant work, so Θ(nW). Edit distance has mn cells at constant work, so Θ(mn).

Two of those are honestly polynomial. The knapsack one is not, and the reason is the subject of the last lesson.

The size of an input is the number of bits needed to write it down. A capacity of W is written in log2W bits, so a table of nW cells is exponential in the length of that number. With 100 items and a capacity of one kilogram measured in grams, the table has 105 cells and fills instantly. Measure the same capacity in micrograms and nothing about the problem has changed, but the table has 1011 cells. An algorithm polynomial in the numeric value of its input but exponential in its length is called pseudo-polynomial, and 0/1 knapsack has no algorithm known to be genuinely polynomial.

That is not a gap in this lesson. It is the edge of what anybody knows how to do, and the final lesson says what is on the other side of it.

Choosing a structure

The knapsack table has nW cells, and a capacity written in log2W bits makes that exponential in the size of the input rather than polynomial.

That is not a defect of the method. Nobody knows a genuinely polynomial algorithm for 0/1 knapsack, and the same is true of a long list of problems that look no harder. This last lesson says what is known about that boundary, what to do when a problem sits on the wrong side of it, and then closes the course by turning seventeen lessons of structures into a procedure for picking one.

What "efficient" was taken to mean

The second lesson priced the growth hierarchy and treated polynomial as tractable and exponential as not. That convention deserves a defence, because n100 is polynomial and 1.0001n is exponential, and for any realistic n the first is far worse.

The defence is twofold. Empirically, the polynomial algorithms that arise have small exponents, almost always 3 or less, and the exponential ones have bases of 2 or more. And structurally, polynomials are closed under addition, multiplication and composition, so a polynomial algorithm calling a polynomial subroutine polynomially often is still polynomial, which makes the class stable under the way algorithms are actually built. Exponentials are not.

The numbers make the boundary concrete. At 1012 operations per second, an n3 algorithm handles n=1000 in a millisecond and n=106 in eleven days. A 2n algorithm handles n=30 in a millisecond, n=50 in nineteen minutes, and n=100 in 1.3×1018 seconds, which is about three times the age of the universe.

Example. At 1012 operations per second, how large an input can a 2n algorithm finish in one hour, and what does a machine a thousand times faster change?

One hour is 3.6×1015 operations, and log2(3.6×1015)=51.7, so n=51. On a machine a thousand times faster the budget is 3.6×1018 operations and log2 of that is 61.6, so n=61. A thousandfold improvement in hardware bought ten more elements, because a factor of 1000 is about ten doublings and each doubling is worth exactly one element.

Now you. Answer the same two questions for an n3 algorithm.

Answer

3.6×10153=1.53×105, so about 153,000 elements in an hour. A thousand times faster gives 3.6×10183=1.53×106, ten times as many. Hardware multiplies what a polynomial algorithm can reach and merely adds to what an exponential one can reach, which is the practical content of the distinction.

P is the class of decision problems solvable in time polynomial in the input length. Sorting, shortest paths, minimum spanning trees, edit distance, matching, linear programming: all in P, and everything in the course so far except the knapsack table is in it.

Checking against finding

Now the second class, and it rests on a distinction worth stating slowly.

Consider subset sum: given a set of integers and a target, is there a subset adding to exactly the target? Searching for one means examining up to 2n subsets. But if somebody hands you a subset and claims it works, checking the claim takes n additions and a comparison.

Example. From the set 267, 493, 869, 961, 1034, 1289, 1522, is there a subset summing to 2590? Compare the work of finding an answer with the work of checking one.

Finding it means considering 27=128 subsets. Checking the proposed subset 267, 493, 869, 961 means three additions: 267+493=760, +869=1629, +961=2590. Correct. With seven numbers the gap is unimpressive. With 100 numbers, checking is still 99 additions and searching is 1.27×1030 subsets.

Now you. From 112, 348, 507, 683, 899, 1204, verify the claim that some subset sums to 1694, given the proposed subset 112, 683, 899.

Answer

112+683=795, +899=1694. Two additions confirm it. Note what the certificate does not do: it gives no help at all in deciding whether some subset sums to 1695, and no way to conclude that none does. A short certificate exists for a yes answer, and nothing here provides one for a no answer, which is the asymmetry the definition of NP is built on.

NP is the class of decision problems whose yes-instances have a certificate, of length polynomial in the input, verifiable in polynomial time. The name is "nondeterministic polynomial", not "non-polynomial", and the difference matters: every problem in P is in NP, since the solution can be recomputed instead of checked.

The open question is whether the reverse holds. Does the existence of a quickly checkable answer imply a quick way to find it? That is P versus NP, posed by Stephen Cook in 1971, one of the seven Clay Millennium Prize problems since 2000, and unanswered. Almost everyone expects P NP, and nobody can prove it.

Reduction, and the hardest problems in NP

The tool that organises this is reduction. Problem A reduces to problem B if any instance of A can be transformed, in polynomial time, into an instance of B with the same answer. A fast algorithm for B then gives a fast algorithm for A, so B is at least as hard as A.

A problem is NP-hard if every problem in NP reduces to it, and NP-complete if it is also in NP. An NP-complete problem is a hardest problem in NP: a polynomial algorithm for one would give a polynomial algorithm for all of them, and prove P = NP.

That such a problem exists at all is the Cook-Levin theorem, proved independently by Cook in 1971 and Leonid Levin in 1973: boolean satisfiability, asking whether a logical formula can be made true, is NP-complete. The following year Richard Karp reduced satisfiability to 21 other problems, showing them NP-complete too, and the list has grown to thousands. Knapsack, subset sum, the travelling salesman decision problem, graph colouring, vertex cover, clique, bin packing, and scheduling with deadlines are all on it.

The practical value is that a reduction is a licence to stop searching. If a new problem is shown NP-complete, no polynomial algorithm for it will be found without settling a 50-year-old open question, so effort is better spent elsewhere.

What to do instead

NP-complete does not mean unsolvable, and treating it as a wall is the most common mistake made with this material. Real instances get solved constantly, by five distinct routes.

Solve it exactly, but cleverly. Exponential is not uniform. The travelling salesman by brute force over all tours is Θ(n!), hopeless at n=25 where 25! is 1.6×1025. The Held-Karp dynamic programming algorithm from 1962, over subsets rather than tours, is Θ(n22n): 2.1×1010 operations at n=25, about twenty seconds. Still exponential, and still a change of what is reachable. Branch and bound goes much further: the Concorde solver proved an optimal tour through all 85,900 points of a circuit-board drilling instance in 2006.

Accept a bound on the error. An approximation algorithm runs in polynomial time and guarantees a result within a stated factor of optimal. For vertex cover, repeatedly picking both endpoints of any uncovered edge gives a cover at most twice the minimum, from three lines of code. For travelling salesman with distances obeying the triangle inequality, doubling a minimum spanning tree gives a factor of 2, and Christofides' 1976 refinement gives 1.5. Note that these guarantees need structure: for general travelling salesman with arbitrary distances, no constant-factor approximation exists unless P = NP.

Give up the guarantee and measure instead. Local search, simulated annealing and genetic methods offer no bound at all and routinely land within a per cent or two of optimal on real instances. Modern SAT solvers, which cannot possibly be fast in the worst case, dispatch industrial formulas with millions of variables, because real formulas have structure that random ones do not.

Bound the part that is hard. Vertex cover is solvable in O(1.28k+kn) time for a cover of size k, which is fast whenever k is small however large the graph is. This is parameterised complexity: the exponential blow-up is confined to a parameter that is small in practice.

Or notice that the real instance is a special case. Graph colouring is NP-complete in general and easy on trees, on interval graphs and on planar graphs when four colours suffice. Knapsack's pseudo-polynomial table is entirely usable when W is genuinely small, which for a physical bag it often is.

Choosing a structure

The course set out to make one decision reliably. Here is the procedure, and every step of it has been earned.

Start with the operations, not the data. Write down what will be asked of the collection and roughly how often. A structure is a set of trade-offs, and it can only be chosen against a workload. Every lesson here paid for one operation with another: hashing bought constant lookup with the loss of order, heaps bought a constant-time minimum by refusing every other query, linked lists bought constant insertion with the loss of indexing.

Then ask whether order is ever needed. If the only question is "is this exact key present", take a hash table: expected constant time, and nothing else comes close. If anything asks for a minimum, a maximum, a successor, a range, a rank, or an ordered listing, a hash table cannot answer it at any price and a balanced tree is the structure, at O(logn) for everything.

Then ask whether the ordering is needed in full. If the only ordered question is "what is the smallest", a heap gives it in constant time, updates in logn, and lives in a bare array. If the answer is needed once rather than continuously, sorting once at Θ(nlogn) and then binary searching beats maintaining a tree.

Then ask what the data is. A sequence with positional access is an array, and the array's contiguity is worth more in practice than any operation count suggests. A queue or a stack is an array with a restricted interface. A hierarchy is a tree. A set of relationships is a graph, stored as adjacency lists unless the edge count approaches V2.

Then check the worst case, and ask who supplies the input. Expected constant time and average logarithmic height are statements about the distribution of the data. If the data comes from an untrusted source, or arrives sorted, those statements do not hold, and the answer is a randomised hash, a balanced tree rather than a plain one, or a hard fallback.

Then measure. Asymptotic classes settle which structure to reach for and say nothing about the factor of three between two implementations of the same class. Linked lists lose to arrays, quicksort beats mergesort, and B-trees beat binary trees, all for reasons no operation count can see.

Example. A service holds 50 million session records, each keyed by a 32-character token, and must look a session up by token on every request while deleting all expired sessions once a minute. What structures?

Lookup by exact key with no ordered query at all is a hash table, keyed by token, with the load factor held below about 0.75 and a randomised hash because the tokens come in over the network. Expiry is a different question with a different answer: it repeatedly asks for the earliest expiry time and nothing else, which is a min-heap keyed by expiry, so the sweep pops until the root is in the future rather than scanning 50 million records. Two structures over the same objects, cross-referenced, each chosen for one query. That is the normal outcome rather than a compromise.

Now you. A leaderboard for 10 million players must report a player's current rank, list the top 100, and update a score. What structures?

Answer

Rank is an ordered query, so a hash table is out on its own and a heap cannot answer it either, since a heap knows only its minimum. Use a balanced binary search tree keyed by score, with each node additionally storing the number of nodes in its subtree; that count turns rank into a single root-to-leaf walk at O(logn), and the top 100 into a reverse in-order walk that stops after 100. Add a hash table from player identifier to tree node so that an update finds the node in constant time rather than searching for it by score. This is what Redis calls a sorted set, and it is implemented as exactly this pairing, with a skip list standing in for the balanced tree.

What a finisher has

Cost is measured against input size under a stated model, and asymptotic notation compares algorithms while hiding constants that sometimes matter more. Sorting is Θ(nlogn) and provably no better by comparison, and linear if the keys have structure to exploit. Lookup is constant with hashing, logarithmic with order, and the choice between them is a question about which queries will be asked. Trees stay short by rotation or by widening, graphs are traversed in Θ(V+E) and searched by weight with a heap. Greed needs an exchange argument before it can be believed, dynamic programming replaces the choice it cannot make with a table, and some problems have neither and are solved anyway by approximation, by parameterisation, or by exploiting the shape of the instance that actually arrived.

None of that is a catalogue to recall. It is a way of asking what a program costs, and then choosing the structure that makes the expensive question cheap, which is what the whole subject amounts to.

Algorithms and Data Structures, from libre.university