Interactive course · ~180 minutes

Introduction to Algorithms

Here is the dare. Two programs get the same list of a million numbers to put in order. One finishes before you can blink. The other is still working when you come back from lunch. Same computer, same list, same language. The only difference is the method, and somebody had to work that method out. You are going to work out all of them, then race them.

How this works

Every method here runs for real, with a counter wired into each comparison, each swap and each function call. None of the numbers you will see are copied out of a table. You pick the data, break three of the methods on purpose, and watch a sorted list turn quicksort into the slow method it was invented to beat.

What you need first

Arrays, hash tables, trees, heaps and graphs, from the Data Structures course. Those are the containers. This course is about what to do with them, so they turn up here without ceremony: the heap becomes the engine of a shortest-path finder, and the hash table becomes the reason a slow method suddenly gets fast.

What each step builds

Step 1

Counting, not timing

Before any clever method, the smallest question: how do you tell that one way of doing a job beats another? A way of doing a job, written out precisely enough that a machine could follow it without asking you anything, is called an algorithm. Long division is one, and so is the routine you use to look a word up in a dictionary. This course says method and algorithm to mean the same thing, and the whole of it is about the fact that the same job usually has several, some of them thousands of times better than others. So, back to the question. The obvious way to tell two of them apart is to time them both. Run each one, watch the clock, declare a winner.

So try it. Below, one job is done two ways. The job is checking a list of ID numbers for a repeat. The first way compares every pair of IDs. The second walks the list once and remembers what it has already seen in a hash table. Press Run five or six times without changing anything, and watch the two columns of numbers.

How can the second way check "have I seen this?" so cheaply

A hash table is a container built for one question: have I seen this before? It turns the thing you hand it into a slot number by arithmetic, then goes straight to that slot instead of searching.

Asking and storing each cost about one step, however many IDs are already in there. That is why the second method can afford one lookup per ID and then stop.

Lab 1 · Two ways to find a repeat
Try this firstPress Run both five or six times without touching the size slider. A row appears each time you press it, holding milliseconds for each method and step counts for each method. Read down the four columns.
Notice which column moves. The milliseconds wobble from run to run. The machine was busy with something else. The clock only ticks in lumps rather than continuously. And the program actually speeds up as it goes, because the system watches which parts are running over and over and quietly rewrites those parts into something faster, while the program is still running. The step counts do not wobble at all. Same numbers every time, on any machine, in any language.
Count the work, not the seconds

Seconds measure the method plus the machine plus whatever else the machine was doing. Steps measure the method. So for the rest of this course the question is always "how many comparisons, how many moves, how many calls", and that number is something you can predict on paper before you write a line of code.

The two counts have names. From here on, n means how many items you have, and a number written against a letter multiplies it, so 3n is three times n and is n multiplied by itself. Comparing every pair of n items takes n(n−1)/2 comparisons; one pass with a hash table takes n lookups. At 2,000 IDs that is about two million against two thousand, which is the whole reason anybody bothers learning this. When n is 100, 3n + 400 works out at 700 and n² works out at 10,000.

Where n(n−1)/2 comes from

Compare the first ID with every other one: n−1 comparisons. The second with everything after it: n−2. Keep going and the total is (n−1) + (n−2) + … + 1, which adds up to n(n−1)/2.

The halving is there because comparing A with B is the same test as comparing B with A. With 2,000 IDs it is 2,000 × 1,999 / 2, a little under two million.

I have never read a program before

You can skip every code block in this course and lose nothing. The prose always says in words what the code says in symbols. If you want to read them, here is the whole of what you need for this one. A line starting with def gives a name to a job and lists what you hand it, so def has_repeat_pairs(ids) means "here is a job called has_repeat_pairs, and you hand it a list of ids". A named job like that is called a function, and running one is called calling it. Anything after a # is a note to the human reader and the machine ignores it. Lines that belong inside something are pushed to the right, so the two indented lines under the for are the ones that get repeated.

A for line repeats what is under it once for each value. for x in ids means "do this once with x set to the first id, once with x set to the second, and so on". A single = puts a value into a name; a double == asks whether two things are equal, and the doubling is there so the two can never be confused. return hands an answer back and stops the job there and then. That is the whole vocabulary of both functions below: one walks every pair and returns as soon as two match, the other keeps a set of what it has already seen and returns as soon as something turns up twice.

def has_repeat_pairs(ids):          # every pair: n(n-1)/2 comparisons
    for i in range(len(ids)):
        for j in range(i + 1, len(ids)):
            if ids[i] == ids[j]:
                return True
    return False

def has_repeat_seen(ids):           # one pass: n lookups
    seen = set()
    for x in ids:
        if x in seen:
            return True
        seen.add(x)
    return False
Method A always takes 3n + 400 steps. Method B takes n² / 100 steps. Which one should you use?
It depends on n, and the crossover is near 400. Set the two formulas equal and you get n = 400: below that B's small constant beats A's big one, above it A's gentler growth takes over and never gives the lead back. Both halves of that sentence matter. Growth decides who wins eventually; constants decide who wins on the data you actually have.
How you get n = 400 out of those two formulas

Work both formulas out at a few sizes and watch the lead change hands. At n = 100, A takes 3 × 100 + 400 = 700 steps and B takes 10,000 ÷ 100 = 100 steps, so B is seven times better. At n = 400, A takes 1,600 and B takes 160,000 ÷ 100 = 1,600 as well. Dead level. At n = 1,000, A takes 3,400 and B takes 10,000, so now A is three times better, and it stays better forever after.

That is the crossover: 400. You can find it by hand, by trying sizes until the two columns cross, and that is a perfectly good way to find it. If you have met quadratic equations, setting 3n + 400 = n²/100 and solving gives exactly 400 as well. If you have not met them, nothing later in this course needs them. The point to keep is the shape of what happened, not the arithmetic: B started ahead because of its small constant, A finished ahead because of its gentler growth, and there is always a size where they swap.

Step 2

Looking one at a time

The simplest search there is: start at the front, check each item, stop when you find what you wanted. That is linear search, and on unsorted data nothing beats it. Worth being clear about why: any item you have not looked at could be the one you want, so ruling an item out means looking at it.

Why it is called "linear"

Linear means straight line. Plot looks against list length and that is the graph you get: a list twice as long takes twice as many looks. The name describes the shape of the cost, not the shape of the code.

Order changes that completely. In a sorted row, one look tells you something about every item you did not look at.

What "sorted" means when the items are not numbers

Sorted needs only a rule that, given any two items, says which comes first. Numbers have one. So do words, by dictionary order, and dates, by which came earlier. Anything with such a rule can be put in order and hunted by halving.

Most languages let you hand that rule in yourself, which is how a list of people ends up sorted by surname rather than by whatever the computer would have picked.

Both rows below hold the same sixteen numbers, one shuffled and one sorted. The values are face down. Click to turn one over, and hunt the target in each row. Your clicks are the counter.

Lab 2 · Hunt the number, twice
Try this firstThe number to hunt for is at the top. Click any card in the Shuffled row to turn it over, then keep clicking until you find that number. Do the same in the Sorted row. Each click turns one card face up and the line underneath says what that one look told you.
Watch the crossed-out block spread. In the shuffled row only the card you actually turned over fades, so one look eliminates exactly one slot. In the sorted row a single look crosses out a whole block of cards you never touched, because a number too small rules out everything below it. That crossed-out block is the entire value of keeping data in order.
Sorting is an investment

Sorting is not free, so it only pays if you search the same data more than once. One search of a million unsorted items costs about a million looks. Sorting first costs around twenty million steps, and then every search costs twenty. Search once and sorting was a waste. Search a thousand times and it was the best decision in the program.

Where the twenty and the twenty million come from

Twenty is how many times you can halve a million before you are down to one: 1,000,000 then 500,000 then 250,000, twenty steps in all. Step 3 turns that count into a search.

Twenty million is a million items times those twenty levels, which is what a good sort costs. Step 5 builds it. Both numbers are worked out properly later; here they are only being used to compare two plans.

A program reads a 10,000-line file into memory, looks up exactly one name in it, and exits. A colleague says to sort the list first "because sorted search is faster". Are they right?
No. The one scan you are trying to speed up costs at most 10,000 looks. Sorting the list first costs well over 100,000 steps, so you pay ten times the price to save one search. The faster operation lost because of what it cost to get there, which is the question to ask about every optimisation, not only this one.
Step 3

Halving, and the traps in it

Now do the sorted hunt properly. Look at the middle item. Too big? Throw away the middle and everything above it. Too small? Throw away the middle and everything below. Repeat on what is left. That is binary search, and each look throws away half of what remains.

What log₂ n counts

log₂ n is the number of times you can halve n before you reach 1. Halve 16 and you get 8, 4, 2, 1: four halvings, so log₂ 16 = 4. Doubling n adds exactly one to it, which is why a thousand items need ten looks and a million need twenty.

When people write "log n" about an algorithm they nearly always mean this halving count, and leave the small 2 off.

Everyone understands it in ten seconds and writes it wrong the first time. Binary search was published in 1946; a version correct for lists of every length did not appear in print until 1962. A midpoint bug sat inside one of the most widely used implementations in the world, the one that ships with Java, for about eight years before anyone noticed. The idea is easy. The three variables are not.

What lo, hi and mid are, and how to read this loop

Three numbers do all the work, and they are the three you will drive in the lab below. lo is the lowest slot still worth looking at and hi is the highest, so together they fence off the part of the list the target could still be hiding in. mid is the slot halfway between them, the one you are about to turn over. At the start lo is 0 and hi is the last slot, so the fence holds everything. Each look moves one of the two fenceposts inward, and when they cross, the fenced-off part is empty and the target was never there.

In the code, int just says these three names hold whole numbers. The while line is the loop: everything inside the braces repeats as long as the test in the brackets is true. lo <= hi means "as long as lo is less than or equal to hi", which is the same as "as long as at least one slot is still fenced in". A single = puts a value into a name, a double == asks whether two things are equal, and a[mid] means the value sitting in slot mid. Anything between /* and */ is a note to the human reader. return hands the answer back and stops, and returning −1 is the usual way of saying "not here", since there is no slot numbered −1.

int find(int a[], int n, int target) {
    int lo = 0, hi = n - 1;              /* hi is the last valid index */
    while (lo <= hi) {                   /* <= : a range of one is still a range */
        int mid = lo + (hi - lo) / 2;    /* not (lo+hi)/2: that can overflow */
        if (a[mid] == target) return mid;
        if (a[mid] < target) lo = mid + 1;   /* mid+1, not mid, or it stalls */
        else                 hi = mid - 1;
    }
    return -1;                           /* not here */
}

Below you pick the middle yourself, and you can swap the update rule for the wrong one. Choose lo = mid and predict what happens to the range before you press anything.

Lab 3 · Drive the loop, then break it
Try this firstLeave the update rule on lo = mid + 1 (correct) and press Use the middle three or four times. The teal band is the part of the list the target could still be in, and it should halve on every press until the target turns up. Then set the rule to lo = mid, press Start over, and press Run to the end.
Break it deliberately. With lo = mid the live range stops shrinking once it is down to two items, and the round counter climbs forever. It stops here after forty rounds because something has to stop it. In a real program nothing does: the process pins a core and never returns.
Why lo + (hi − lo) / 2

On a list of more than a billion items, lo + hi can exceed the largest number a 32-bit integer holds, wrap round to negative, and index somewhere absurd. Subtracting first can never overflow because hi − lo is smaller than hi. This is the Java bug: correct for twenty years of small arrays, wrong the moment arrays got big.

What a 32-bit integer holds, and what wrapping round means

A whole number in many languages is kept in 32 bits, and one bit records the sign, so the largest value is 2,147,483,647. Add one and the bits roll over the way an odometer rolls from 999999 to 000000, except that here the result comes out as the most negative number instead.

An index of −2,147,483,648 is not a position in your array, so the program reads whatever happens to be there, or stops.

A colleague stores a sorted sequence in a linked list and says binary search is still fine there,"because it is still about log n comparisons". What have they missed?
Getting to the middle is the problem. Binary search assumes that jumping to any index is one piece of arithmetic. A linked list has no such arithmetic: reaching the middle means hopping there from the front. The comparison count really is about log n, and the total work is worse than just scanning the list. Counting only the comparisons hides the cost of reaching the thing you compare.
Why the middle of a linked list is so far away

An array keeps its items side by side, so item 500 sits at a known distance from item 0 and one multiplication gets you there. A linked list keeps each item wherever there was room, and each item holds the address of the next one.

The only way to reach the 500th is to follow 500 arrows from the front. Same items, same order, and a completely different price for "go to the middle".

Step 4

The two sorts you invent yourself

Sorting is where methods separate hardest, so start with the two that people invent unaided. Selection sort: scan the whole list for the smallest item, put it at the front, then do the same for the rest. Insertion sort: take each item in turn and slide it back into the already-sorted part until it fits, the way you tidy a hand of cards.

Both sorts, done by hand on five cards

Take 5, 2, 9, 1, 6. Selection sort scans all five and finds 1, so 1 goes first. It scans the remaining four and finds 2. Then 5, then 6, then 9. Five scans, each one shorter than the last, and the scan happens whatever order the cards were in.

Insertion sort takes 2 and slides it past 5. Takes 9 and leaves it alone. Takes 1 and slides it past 9, 5 and 2. Takes 6 and slides it past 9. The work depends on how far each card has to travel, which is why tidy input is cheap.

Both do roughly n²/2 comparisons on random data, which is about 128 comparisons for sixteen items and about 512 for thirty-two, so on paper they look like the same method twice. Run them side by side and watch the two counters, then set the input to nearly sorted and run again.

Where the n²/2 comes from

Selection sort scans n items, then n−1, then n−2, all the way down to 1. That sum is n(n−1)/2, the same total as the all-pairs count in Step 1, which is a shade under half of n².

Insertion sort has the same worst case: if every item must slide past everything before it, item number k costs k−1 comparisons. On random data it averages about half of that, so the shape stays n² and only the constant changes.

Lab 4 · Selection against insertion
Try this firstHow to read this. Each bar is one item in the list, and the taller the bar the bigger the number. The list is sorted when the bars form a clean staircase from short on the left to tall on the right, so you can see a sort finish without reading a single number. Orange marks the items being compared at this instant. Green marks the part that is in order so far, and it grows from the left in both panes, at very different speeds. The two greens do not promise quite the same thing: selection sort's green is finished for good, while insertion sort's green is only in order among itself, so a later item can still slide back into it and push those bars along. Teal is everything still to do. Press Run both first and just watch; then press One step and read the two counters underneath each pane, which are the real answer.
Set the input to nearly sorted and compare. Insertion sort's comparison count collapses, because each item stops sliding as soon as it meets a smaller one. Selection sort's count does not move by a single comparison, because it scans the entire remaining list every round whether or not the list is already in order. One method can be handed good luck. The other cannot notice it.
The one that stayed useful

Every programming language comes with a pile of code somebody else already wrote and tested, so that ordinary jobs like sorting a list are one line rather than an afternoon. That pile is called its library, and almost nobody writes their own sort. Insertion sort costs about n comparisons and zero moves on data that is already in order, and it is very cheap per step. That is why insertion sort is still inside the sorting library you use: the fast methods in the next steps hand their small leftover chunks to insertion sort rather than recursing all the way down. Selection sort has one real virtue: it swaps at most once for each item in the list, however jumbled that list is, which is useful when moving an item is far more expensive than comparing two.

I have not met "recursing" yet

A method recurses when it calls itself on a smaller piece of the same problem. Steps 5 and 6 both do it, and Step 8 takes the idea apart properly.

For this sentence the picture is enough: the fast sorts keep cutting the list into smaller lists, and stopping that cutting early to finish with insertion sort is cheaper than cutting all the way down to single items.

A server writes a log file that is almost in timestamp order, though occasionally an entry lands a few positions late. You need it fully sorted on every read. Which of these two would you use, and why?
Insertion sort, and by a huge margin. Each entry slides back only as far as it has to, so an item out of place by three positions costs about three comparisons rather than n. On nearly ordered data the total is close to n, not n²/2."They are both n²" is true about the worst case and useless here, because the worst case is not the case you have.
Step 5

Cut the problem in half

To get past n² you need a different shape of idea, and here it is. Sorting a long list is hard. Sorting a list of one item is free, because it is already sorted. So cut the list in half, and in half again, until every piece has one item. Then put the pieces back together in order.

Putting two already sorted piles together is easy work: look at the front of each pile, take the smaller one, repeat. That is one pass over both piles. And the number of times you can halve a list before the pieces are single items is log₂n: ten halvings for a thousand items, twenty for a million. Log n levels, one pass each, so n log n in total.

Why the two counts multiply

There are log₂ n levels of merging, and each level does about n comparisons, because each level touches every item once. Adding n up once per level is n × log₂ n.

A thousand items: 1,000 × 10. A million items: 1,000,000 × 20. The list got a thousand times longer and the cost per item only doubled.

Why merging needs somewhere to put the output

Merging reads the front of each pile and writes the smaller one out. If it wrote back over the input it would land on an item it has not read yet, so the output goes into a second array and is copied back afterwards.

Merges that work in place do exist. In place means rearranging the items inside the array you were given, using no second array to hold the result. They cost more comparisons and moves than the spare array does, so almost nobody uses them.

Split it and merge it below, and count the comparisons level by level.

Lab 5 · Split all the way down, then merge back up
Try this firstPress Split once and keep pressing. Each press cuts every piece in half and adds one row to the picture, and no items are compared on the way down. When every piece holds a single number that same button changes to Merge one level: keep pressing, and each press rebuilds one level in order and prints what that level cost in comparisons.
Count the levels, then count per level. Every level of merging touches each item once, so a level costs about n comparisons no matter how the level is cut up. Doubling the amount of data adds one level, not double the work. That single sentence is the difference between a second and an afternoon.
What n log n buys you

On a million items, n²/2 is five hundred billion comparisons. n log n is about twenty million. That is a factor of twenty-five thousand from the same list on the same machine, and it is the reason nobody ships selection sort. The gap grows with the data: at a billion items it is a factor of nearly seventeen million.

Where five hundred billion and twenty million come from

A million squared is a million million, and half of that is five hundred billion. That is the n²/2 count for selection sort on a million items.

n log n on the same list is a million times twenty, which is twenty million. Divide one by the other: five hundred billion over twenty million is twenty-five thousand.

Merge sort needs a second array to merge into, so it uses about twice the memory. When is that objection strong enough to change your mind?
When the copy will not fit. Memory is the one resource that fails hard rather than getting slowly worse: run out and the program stops, whereas a few more comparisons only cost time. So when the array already fills the machine, an in-place method wins even at more comparisons, which is exactly the argument for the next step. On ordinary data the extra array is well worth the twenty-five-thousand-fold speedup.
Step 6

Quicksort and the pivot problem

Same halving idea, different order of work. Choose one item as the pivot. Walk the list and push everything smaller to its left and everything bigger to its right. After that single pass the pivot is exactly where it belongs in the finished list, and it never moves again. Then do the same to the left part and the right part.

Why the pivot never has to move again

After the pass, everything left of the pivot is smaller than it and everything right of it is bigger. Sorting those two sides only shuffles items inside them, so nothing ever crosses the pivot.

Its position is therefore the position it has in the finished list. Each partition nails down one item for good, which is why quicksort needs no merging step at the end.

No second array, less copying, and in practice faster than merge sort per comparison. All of it rests on one assumption: that the pivot lands somewhere near the middle, so each partition really does halve the problem. Choose badly and you split a list of 100 into a piece of 1 and a piece of 99.

So break it on purpose. Set the pivot rule to "first item", set the input to "already sorted", and write down what you think the recursion depth will be. Then go to the lower half of the lab, under the heading "And now the whole sort, all the way down", and press Run the whole sort to see it. The upper half shows you one partition in slow motion; the lower half runs the whole thing and draws one row per level of recursion.

What "recursion depth" means here

Quicksort partitions a list, then does the same to each side, and those sides do the same again. Depth is how many of those rounds are stacked up at the deepest point.

An even split halves the list every time, so the depth is about log₂ n: twenty for a million items. A split that peels off one item leaves depth n, and every one of those rounds is a real function call the machine has to hold on to.

Lab 6 · Choose the pivot, watch the partition
Try this firstPress One step of the partition and keep pressing. Each bar is one item and the taller the bar the bigger the number. Pink is the pivot, orange is the item being compared with it right now, green is the part left of the marker that is already smaller than the pivot, and teal is everything not looked at yet. The line under the bars says in words what that step did.
Sorted input plus a first-item pivot is the disaster. Every partition peels off exactly one item, so the recursion is n deep and the comparison count climbs to about n²/2: the cost of the method quicksort was built to beat, on the most ordinary input there is. Switch the pivot to middle or random and the depth drops back to about log n.
Someone will send you the bad input on purpose

If an attacker knows your pivot rule, they can construct the input that makes every partition lopsided. A search box that normally answers in a blink then takes minutes of processor time to answer that one visitor, and while it is busy nobody else gets served. That is a real denial-of-service technique, and the defence is the same as for hash tables: introduce randomness the attacker cannot predict. A random pivot costs one random number per partition.

What a denial-of-service attack is

Not a break-in. The attacker sends requests that are expensive for you to answer, until the machine has nothing left for anyone else. A search box that takes a tenth of a second normally and forty seconds on one carefully chosen list is enough.

Anything whose cost depends on the shape of its input is worth checking for this, and the usual fix is to add a choice the attacker cannot predict.

Since sorted input is the bad case, a colleague proposes checking whether the list is already sorted at the start and skipping the sort if it is. Does that fix quicksort?
No, because fully sorted input is only the most obvious bad case. Reverse the list and a first-item pivot is just as lopsided. Make it nearly sorted and the depth is still close to n. Fill it with one repeated value and a careless partition goes quadratic too, which is the word for a cost that grows like n²: double the items and quadruple the work. Patching the one input you thought of leaves the family it came from untouched, which is why the real fix changes the pivot rule rather than the input.
Step 7

The race

Five methods, one list, counted as they run. Bubble sort is in the field as a reference point for the worst reasonable thing you could write; the other four you have just built.

What bubble sort does

Walk the list comparing each item with its neighbour and swapping the two if they are the wrong way round. One pass drags the largest item to the end. Repeat until a pass makes no swaps at all.

It does about n²/2 comparisons, like selection sort, but far more moves, because it only ever swaps neighbours. It is in the race as the slow end of the scale.

Pick the size and the shape of the input, then start them. Watch the bars move and the counters climb, and read the finishing order rather than the clock. Then change the shape to nearly sorted and run the same race again, because the winner changes.

Lab 7 · Five sorts, one list, every comparison counted
Try this firstPress Start the race and watch the five lanes. How to read the lanes. Each row is one method sorting its own copy of the same list, one bar per item, tallest to the right once it is sorted. Orange marks the items being compared right now and green the part that is in order so far, which for bubble sort collects at the right-hand end rather than the left. cmp counts comparisons, mv counts item moves, and the # at the end is finishing position. A lane gets a green surround when it is done. How to read the last column of the table. Comparing every pair of items would cost about n²/2, which at the starting size of 32 items is 496. The last column adds up each method's comparisons and moves and divides by that number, which says what fraction of the dumbest possible amount of work this one did. Near 1.00 means it did about as much work as comparing everything with everything, and bubble sort usually lands above 1 because it pays in moves as well. Half of 1 means it did half as much. Smaller is better, and the gap between the small ones and the big ones grows every time you raise the item count.
Race each shape at least twice. On random data the n log n pair wins by a distance that grows with n. On nearly sorted data insertion sort beats both of them outright. On reversed data insertion sort does exactly as much work as bubble sort, which is the worst it ever gets. No method is the fast one. Each is fast on some shapes of input and slow on others, and knowing your data is half of choosing.
What your language actually calls

The sort in your standard library is a hybrid of everything in this race. Python's sort, and Java's for objects, is Timsort: it hunts for stretches that are already in order and merges them. C++ implementations use a quicksort that counts its own recursion depth and switches to heapsort if it goes too deep, so the n² case cannot happen. All of them stop dividing at a couple of dozen items and finish the job with insertion sort.

Heapsort, in a paragraph

A heap is a pile that always knows its largest item, and costs about log n steps to add one or take the top one off. Pour every item in, then pull them out one at a time and they come out in order. n items times log n each is n log n, with no second array.

It is slower than quicksort in practice because it jumps around in memory rather than walking through it, which is why libraries keep it as the safety net rather than the first choice.

Why would a carefully written library sort stop dividing at about sixteen items and finish the job with insertion sort, which is the n² method?
Constants win at small sizes. For sixteen items, n²/2 is 128 and n log n is 64: within a factor of two of each other rather than thousands apart, so the tie is broken by what a step costs. Insertion sort's step is a compare and a shift in one tight loop over neighbouring memory; a merge step involves recursion, bookkeeping and a second array. Same reasoning as the crossover in Step 1. When one method is called "asymptotically better" than another, asymptotically means once n is large enough, and at sixteen items you are nowhere near large enough.
Check the sixteen-item arithmetic yourself

16 × 16 is 256, and half of that is 128. log₂ 16 is 4, so n log n is 16 × 4 = 64. The n² method does about twice the comparisons of the n log n one, not thousands of times more, and twice as many cheap steps can easily beat half as many dear ones.

Put a million into the same two formulas and you get five hundred billion against twenty million. The growth rate only takes over once n is big.

Step 8

Recursion, and the same work twice

Merge sort and quicksort both worked by calling themselves on smaller pieces. That is recursion, and it is a way of saying: the answer for this size is built out of the answer for something smaller. Every one of those calls takes a frame on the call stack, which is why a recursion that never reaches its base case ends in a stack overflow.

The call stack, and what a base case is

Every call sets aside a small block: the arguments, the local variables, and the place to return to. The arguments are the values you handed the function when you called it, so in fib(30) the argument is 30. The local variables are the scratch values that call is using and no other call can see. And the place to return to is where the machine should carry on once this call has an answer. Those blocks are stacked up, the top one being the call running right now, and each one is thrown away when its call returns.

A base case is a size answered outright with no further call, like "a list of one item is already sorted". Without one the stack keeps growing until the space set aside for it runs out, and the program stops with a stack overflow.

Recursion has a failure mode that has nothing to do with the stack. The Fibonacci numbers are defined as fib(n) = fib(n−1) + fib(n−2), and writing that down literally gives a correct function that is unusable by n = 40. The cost has a name and a shape. Each call makes two more calls, so every time you add 1 to n you add another layer to the tree and roughly double the number of calls. A count that doubles every time you add one is written 2ⁿ, said "two to the n". It means 2 multiplied by itself n times, so 2⁵ is 2 × 2 × 2 × 2 × 2, which is 32. Doubling sounds gentle and is not. Thirty doublings is a billion. Guess how many calls fib(30) makes before you press it.

What the Fibonacci numbers are

Start with 0 and 1. Every number after that is the two before it added together: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34. So fib(6) is 8.

They turn up here because the definition is two lines long and becomes a recursive function without any thought at all, which is exactly how the trouble starts.

Lab 8 · The call tree, with and without a notebook
Try this firstLeave the slider at a small n, around 6, so the tree fits on screen. Every circle is one call and the number in it is the n that call was given. Press Count the calls and read the two counts that appear. Then press with a notebook and watch most of the tree vanish.
Look for the repeats in the tree. fib(3) is computed from scratch again and again in different branches, and each of those recomputations drags its whole subtree along with it. Switch the notebook on and the pink nodes are answers read back instead of recomputed. At n = 30 that is 2.7 million calls against 59.
Why the plain version costs about 2ⁿ

Each call makes two more, and those make two more, until the branches hit the base cases. Adding one to n adds another level to the tree, which roughly doubles the number of nodes. Doubling per step is what 2ⁿ means.

The true count grows slightly slower than doubling, by a factor of about 1.6 per step, because the two branches are not the same height. Skip that detail if you like; nothing later needs it.

Memoisation

Keep a dictionary from argument to answer. Before computing, look it up; after computing, store it. Two extra lines, and the exponential tree collapses to one node per distinct argument, because repeated work was the entire cost. It only helps when subproblems actually repeat. Memoising merge sort would do nothing at all, since every call gets a different piece of the array.

How to read those two extra lines

memo={} hands the function a notebook to write in, empty to begin with. A notebook of that kind is a dictionary. You look things up in it by a key rather than by position, the way you look up a word instead of counting to the four-hundredth word. n in memo asks "is there anything filed under n", memo[n] is whatever is filed under n, and memo[n] = … files something new there.

So the four lines read: if n is 0 or 1, answer outright; if the answer is already filed under n, hand that back and stop; otherwise work it out the slow way, file it under n, and hand it back. All of the saving is in the second of those, because handing an answer back stops a whole subtree of calls before it starts.

def fib(n, memo={}):
    if n < 2:      return n
    if n in memo:  return memo[n]        # already worked this out
    memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
    return memo[n]
You add memoisation to a recursive function and it gets slower. What is the most likely explanation?
Nothing repeated. Memoisation trades memory and a lookup per call for the chance of skipping a whole subtree. If every call has arguments no other call will ever use, there is no subtree to skip, so all you have bought is a hash of the arguments and a growing dictionary. Look at the shape of the call tree first: if it is a tree with no repeated labels, a notebook has nothing to remember.
Step 10

When roads have lengths

Breadth-first search counts hops, and it is right whenever every hop costs the same. Real maps are not like that: three short streets can easily beat one long ring road. Give the links lengths and breadth-first search starts returning wrong answers. You can make it do so below, on purpose, in one click.

Dijkstra's method fixes it with two ideas. Keep a tentative distance for every place, starting at infinity for everywhere except where you are. Then always settle the closest unsettled place next, and when you settle one, check each of its neighbours to see whether the route through the place you just settled is shorter than their current best. That check is called relaxing the link.

Why every distance starts at infinity

Infinity here means "no route known yet". It is stored as a value larger than any real total, so the first route found is always an improvement on it and the comparison needs no special case.

A place still sitting at infinity when the run finishes is a place you cannot reach at all.

Which leaves one question: how do you find the closest unsettled place cheaply, over and over, while its distance keeps changing? A heap, shown next to the map below.

What the heap is doing here

A heap is a pile that always knows its smallest item. Taking that item out, or adding a new one, costs about log n steps instead of a scan through everything.

Dijkstra asks "which unsettled place is nearest" once for every place, and without the heap each of those questions means looking at every place. With it, the whole run costs about (V + E) log V rather than about V². On a road map of a million junctions that is the difference between roughly eighty million steps and a million million.

Why the same place shows up twice in the heap

A place's tentative distance can improve more than once while it waits. When it does, the tidy thing would be to hunt down its old entry in the heap and correct it, but hunting costs more than the alternative: push a second entry with the better distance and leave the old one where it is. So the heap panel will sometimes show the same place twice, once at 9 and once at 7.

That is safe because the better entry is smaller, so it always comes out first. When the stale one finally surfaces, the place has already been settled and the method simply drops it and takes the next. The line under the lab tells you when this happens. Doing the cheap wrong-looking thing and cleaning up later is a common trade, and it is worth noticing here rather than being puzzled by it.

Lab 10 · Settle the nearest, relax its links
Try this firstPress Settle the next place and keep pressing. How to read the map. Teal is the place being settled at this instant. Green is a place already settled, whose distance is final. Orange is a place waiting in the heap with a distance that could still improve. A plain circle has not been reached at all. The number under each circle is the best distance from the start found so far, and ∞ means no route to it is known yet. The panel beside the map shows the heap and the table of distances, and the line at the bottom says in words what each press did.
Step it, and watch a distance improve twice. A place's tentative distance can drop several times while it sits unsettled, and never once it has been settled. That is the promise the method rests on. Change a weight and run both answers again: on this map the fewest-hops route and the shortest route are different routes about half the time.
Where the promise breaks

The whole argument is: if the closest unsettled place is 7 away, no route through anywhere else can reach it in less than 7, because every other route starts by going somewhere at least 7 away. Introduce a link with a negative length and that reasoning collapses: a longer first leg can be repaid by a negative second one. Dijkstra then settles a place too early and returns a wrong answer without complaining. Negative weights need Bellman-Ford instead.

What Bellman-Ford does instead

It gives up on settling places in a clever order. Instead it relaxes every link in the graph, then does that again, V−1 times over, because a shortest route can pass through at most V−1 links.

That costs V × E rather than about E log V, and in exchange it copes with negative lengths. Run one extra round: if any distance still improves, there is a loop you can go round forever getting shorter, so no shortest route exists. Nothing later needs this.

A route finder works perfectly until someone models a toll refund as a road with cost −5. Now one route is reported longer than it is. Why does that single negative number break it?
Settling early is only safe when detours cannot help. Dijkstra marks a place finished the moment it is the nearest unsettled one, and never revisits it. With a −5 link, a route that looked worse at that moment can end up shorter, and the finished place is now wrong, along with everything computed from it. Notice how it fails. Nothing crashes and nothing warns; you get a confident wrong number, which is the harder kind of bug to find.
Step 11

Take the biggest bite

Dijkstra was an example of a greedy method: at every step take whatever looks best right now, and never go back on it. Greedy methods are short and easy to write, and they are sometimes correct and sometimes wrong. Nothing about the code tells you which.

What "optimal" means here

Optimal means no other answer scores better on the measure you picked. For change it means no set of coins makes that amount with fewer coins. For Dijkstra it means no route is shorter.

Which is why the measure has to be said out loud. Fewest coins and lightest pocketful of coins are different questions, and they have different best answers.

Take giving change with the fewest coins. With 1, 5, 10 and 25 the greedy rule, always hand over the largest coin that fits, gives the fewest coins for every amount there is. Change the coin set and it starts failing, and it fails on amounts you would never think to test.

So go and find a failure yourself. Pick the second coin set and hunt for an amount where the greedy answer uses more coins than the best answer.

How you find the best answer at all, to compare against

By trying every combination: every way of making the amount out of that coin set, keeping whichever used fewest coins. It is slow, and on small amounts it still finishes instantly, which is all you need in order to catch a greedy rule out.

Step 12 does the same job with a table and no wasted work. Here the slow answer exists only to disagree with the fast one.

Lab 11 · Find an amount where greedy loses
Try this firstStart with the coins 1, 5, 10, 25 and press Test every amount up to 40. How to read the strip. Each little box is one amount, 1 through 40. A box turns green with a tick once you have tested it and the greedy rule gave the fewest coins possible. It turns red with a cross when greedy used more coins than it needed to. Untested amounts stay plain, and the box with the teal border is the amount currently in the slider. On this coin set every box goes green, which is why nobody notices the problem with greedy. Now switch to 1, 3, 4 and press it again.
With coins 1, 3 and 4, ask for 6. Greedy takes the 4 first and then has to make 2 out of two 1s: three coins. Two 3s do it in two. The greedy rule was not slightly off. It was led astray by its first move, and it has no way to reconsider. Nothing about the coin set looks different from a set where greedy works.
When greedy is provably right

Greedy is not a guess. For some problems greedy can be proved right, and the proof always has the same shape. Take any answer that beats the greedy one. Show that you can change its first choice to greedy's first choice without making it any worse. Then do the same to its second choice, and its third, until you have turned it into the greedy answer, all without ever making it worse. If that always works, then no answer can beat greedy, because every one of them can be turned into it. That argument works for Dijkstra, for building a minimum spanning tree, and for Huffman codes inside every compressed file you own. It fails for coin change on most coin sets. The lesson is not to avoid greedy methods, it is to know which of the two situations you are in before you ship one.

Minimum spanning trees and Huffman codes, briefly

A minimum spanning tree is the cheapest set of links that still connects everything, with no loops. Repeatedly taking the cheapest link that joins two so-far separate pieces gives the best answer, and that can be proved.

A Huffman code gives short bit patterns to common letters and long ones to rare ones, by repeatedly merging the two rarest items. It is inside every zip file and every JPEG. Both are greedy, both come with proofs, and neither is needed for the rest of this course.

You test a greedy solution on 200 random inputs and it matches the best answer every time. Your colleague says that settles it. What is the flaw in that reasoning?
Tests find failures; they do not certify their absence. Coin change is a good warning here: for the set 1, 3, 4 the greedy rule is right for amounts 1 to 5 and wrong at 6, so a test that happened to stop at 5 would have looked perfect. More random cases raise your confidence and change nothing about the guarantee. Either prove the greedy choice is safe, or compute the optimum with a table, which is the next step.
Step 12

Fill in a table instead

Memoisation in Step 8 was recursion plus a notebook: work top down, and remember. Turn that around. Work out which subproblems depend on which, then fill in every answer from the bottom up, in an order that guarantees the pieces you need are already there. Same answers, no call stack, and the whole computation laid out where you can see it. This is dynamic programming and the name means nothing: it was chosen to sound harmless to a government funding committee.

Why the name is so odd

Richard Bellman named it in the 1950s at a research company living on defence money, whose paymasters disliked anything that sounded like mathematical research."Programming" then meant planning rather than code, and "dynamic" was picked because it sounded impressive and hard to argue with.

The name tells you nothing about the method. Nothing later depends on knowing where it came from.

The example worth learning it on is edit distance: the fewest single-character insertions, deletions and substitutions, a substitution being one letter changed into a different one, that turn one word into another. Take kitten and sitting. Before answering the whole question you can answer smaller ones: how many edits turn kit into sit? Three letters against three. That answer is one square of a grid, and the grid holds every such smaller question, with the rows counting letters of the first word and the columns counting letters of the second. So the square in row 3, column 3 holds the answer for kit against sit and the square in the bottom-right corner holds the answer you actually wanted. Written generally: cell (i, j) holds the answer for the first i letters of one word and the first j letters of the other. It depends on exactly three neighbours: above, left, and above-left.

What one cell's rule actually says

Three moves can produce the first i letters of one word from the first j letters of the other, and each one reuses a cell you have already filled: delete a letter, which is the cell above plus 1; insert a letter, which is the cell to the left plus 1; or line the two letters up, which is the cell above-left, plus 1 if the letters differ and plus 0 if they match.

The cell takes the smallest of those three. That is the whole method. The table is bookkeeping for it.

Type your own two words and fill the table one cell at a time. The recurrence for the current cell is spelled out with its actual numbers as you go.

"Recurrence" is the word for that rule

A recurrence gives an answer in terms of smaller answers of the same kind: fib(n) = fib(n−1) + fib(n−2) in Step 8, or the three-neighbour rule here.

Finding the recurrence is the hard part of dynamic programming. Once you have it the loop order is decided for you: fill the cells in any order that reaches a cell only after the cells it names.

Lab 12 · Fill the table, one cell at a time
Try this firstPress Fill the next cell five or six times, reading the line under the table each time. How to read the table. The teal cell is the one being worked out now, and the three orange cells are the three neighbours it reads to get its answer. The row and column headed (none) stand for the empty word, the word with no letters at all, which is why they are already filled in when you start. When every cell has a number, press Show the path back and the route through the table turns green.
Each cell reads three neighbours and nothing else. That is why filling row by row works: by the time you reach a cell, all three of the cells it needs are already done. Once you can write down what one cell depends on, the loop order writes itself, and the total cost is one cheap step per cell, which is the length of one word times the length of the other.
Where you have already used this table

Spelling suggestions rank candidate words by edit distance from what you typed. Programmers keep every old version of the files they work on, and constantly ask what changed between two of them. The answer is a list of which lines were added and which removed, and it is called a diff. It is this same table run over whole lines instead of single letters: line against line instead of letter against letter. Read the path back through the table and the number turns into the list of changes. Biologists align DNA sequences with a weighted version of it. One table, filled in three different ways.

The memoised recursion and the filled table do the same amount of arithmetic. Why do real programs usually prefer the table?
Constants and the stack. Both do one unit of work per distinct subproblem, so their growth is identical, and the table's unit is a loop iteration reading three neighbouring memory cells, where the recursion's unit is a function call plus a dictionary lookup. On long inputs the recursion also risks running the call stack out, while the loop cannot. Note the exception the other way: if only a few of the subproblems are ever needed, the memo skips the rest and the table computes them all.
Step 13

Shapes, and measuring rather than guessing

Every count in this course has a shape. Doubling the data leaves some counts untouched, adds one step to others, doubles a third kind and quadruples a fourth. Big-O notation is a name for that shape and nothing else: it throws away constants, ignores which machine you are on, and answers one question: what happens when the data gets bigger.

How to say O(n) out loud, and what the O is doing

Say it "oh of n", or "order n". The O is not a number and not a function. It is shorthand for "the count grows like this, give or take a fixed multiplier".

So O(n) covers a method taking 3n steps and one taking 100n steps, because both double when n doubles. Throwing that difference away is what makes it useful for picking a shape, and useless for choosing between two methods of the same shape.

  • O(1): the count does not change. A hash-table lookup, taking the top of a heap.
  • O(log n): doubling the data adds one step. Binary search, a lookup in a balanced tree.
  • O(n): doubling the data doubles the count. Linear search, one merge pass, breadth-first search on a fixed number of links per node.
  • O(n log n): doubling the data slightly more than doubles the count. Merge sort, and every sort your library will actually run.
  • O(n²): doubling the data quadruples the count. Selection sort, the all-pairs check from Step 1, quicksort with an unlucky pivot.
  • O(2ⁿ): adding one item doubles the count. Naive Fibonacci, and anything that tries every subset. Hopeless past about forty items unless you find repeated work to remember.
Why nobody writes the base of the log

Halving gives log₂ n. Splitting into ten pieces gives log₁₀ n. The two differ by a fixed multiplier of about 3.32 whatever n is, and big-O has already thrown fixed multipliers away, so the base is left off.

When you want an actual count of levels rather than a shape, the base matters again, and it is whatever you are dividing by. Nothing else in this course turns on it.

You do not have to take any of those on trust. Below, pick a task and a size, and the work is counted for real at four sizes at once. The ratio columns tell you the shape: divide the measured count by each candidate shape and the column that stays flat as n doubles is the one that fits.

What dividing by a shape actually tells you

If a method really takes about c × n² steps, then dividing the measured count by n² leaves roughly c whatever n is, so that column sits still while n doubles.

Divide the same count by n instead and you are left with c × n, which doubles every time. Divide it by n³ and it halves every time. The flat column names the shape, and the drifting ones rule the others out.

Lab 13 · Measure a shape instead of asserting it
Try this firstLeave the task on Insertion sort, random input and press Measure it at four sizes. Four rows appear, one per size, and the last three columns divide the counted work by n, by n log n and by n². Read down each of those three columns: two of them keep growing or shrinking, and one stays about the same. The one that stays about the same is shaded, and it is the shape.
Read the flat column. Steps divided by the right shape stays roughly constant while n doubles; divided by any other shape it drifts up or down. That is the whole content of a big-O claim, arrived at by measurement. Look at the milliseconds column too. It is noisier than the counts and sometimes disagrees with them, which is Step 1's point returning at the end.
Counting and clocking disagree, and both are right

Steps tell you how a method scales. Seconds tell you what it costs today, on this machine, with this data in this layout. They come apart for real reasons: memory read in order is several times faster than memory read in a scattered pattern, so an n log n method that jumps around can lose to an n² method that walks straight through a small array. Count to choose the method, then measure to find out whether you were right.

Where to go next

  • Algorithm Design, on what to do when no clever method exists: randomness, answers that are provably close enough, and flow through networks.
  • Inside a Database, the same searching and sorting with one rule changed: reading from disk costs ten thousand times what reading from memory does. B-trees and external merge sort come straight out of that one change.
  • Performance Engineering, on the gap between counting and clocking, measured properly: caches, branches, and why the array keeps winning.
Worth noticing

You started with two ways to find a repeated ID. You now have a sorted-data hunt that takes twenty looks in a million items, two sorts that finish while another is still starting, a call tree you collapsed with a dictionary, a shortest-path finder built out of a heap, and a table that computes the best answer where a greedy rule got it wrong. None of these were obvious to anyone before they were worked out.

Your program takes 1.0 seconds on 10,000 records and 4.1 seconds on 20,000. What shape is that, and what should you expect at 40,000?
n², so expect around 16 seconds. Doubling the data multiplied the time by four, which is the signature of a squared shape: n log n would have given about 2.2 seconds and linear about 2.0. Two measurements at different sizes tell you more about a program than any amount of staring at one measurement, and the prediction is testable, which is the point of making it.
Step 14

State what must stay true

An algorithm includes more than a list of instructions. It is a promise about which inputs it accepts, what answer it returns and what remains true while it works. Engineers write those promises down before optimising, because a fast answer to a different question is still wrong.

  • Precondition: what must already be true. Binary search needs a sorted sequence and a comparison rule that orders every pair consistently.
  • Postcondition: what a successful return means. If an index is returned, the item at that index equals the target; if “not found” is returned, no item equals it.
  • Invariant: a fact that is true before and after every loop round. In binary search, if the target exists, it remains inside the live range.
  • Variant: a non-negative measure that gets smaller every round. The live range length proves that binary search must stop.
Lab 14 · Audit one binary-search contract
Try this firstSwitch among the four cases. Find which promise fails and whether the result, correctness proof or termination proof is lost.
Name the broken promise. “It failed” is not yet a diagnosis. “The input broke the sorted precondition” or “the live range did not shrink” tells another engineer what must change.
How an invariant becomes a proof

Show three things. First, the invariant is true before the loop. Second, one round keeps it true. Third, when the loop stops, the invariant plus the stopping condition gives the postcondition.

Then prove termination separately with the variant. Correctness has two halves: if the method stops its answer is right, and the method really does stop.

A loop keeps its invariant forever but its live range sometimes stays the same. What has not been proved?
Termination. An invariant says the loop remains safe; a decreasing variant says it cannot remain in that safe state forever.
Step 15

Try to break it before release

Examples explain an algorithm; tests challenge its contract. Start with the smallest legal input, empty input if the API permits it, repeated values, already ordered and reverse-ordered data, negative or very large values, and inputs arranged to trigger the worst branch. Keep one slow, simple method as an oracle and compare the clever method against it on many generated cases.

Properties catch more than a list of expected answers. Sorting must preserve every input item, put the output in order and give the same result when sorted twice. A shortest path must use real edges and its reported length must equal their sum. These checks still work when nobody knows the answer beforehand.

Lab 15 · Test a rushed maximum finder
Try this firstThe rushed method starts its answer at zero. Try ordinary, repeated, negative-only and empty inputs. Compare it with the contract-aware oracle.
One ordinary example is weak evidence. The negative-only case exposes an invented zero; the empty case forces the API to decide whether to return no answer or reject the input.
Generated tests, shrinking and adversaries

A property-based test generates many valid inputs and checks a property or oracle. When one fails, shrinking removes pieces until the smallest useful counterexample remains. “Fails on [-3]” is easier to diagnose than a page of random numbers.

Random cases are not enough. Add inputs chosen to hurt the method: sorted data for a first-pivot quicksort, hash collisions for a table, a long thin graph for recursion, or values at integer limits. Measure time, memory and recursion depth as well as the returned answer.

Continue the engineering loop

Analysis of Algorithms turns invariants, recurrences and bounds into full proofs. Algorithm Design adds randomisation, approximation and flow. Parallel Algorithms asks what changes when work overlaps, while Performance Engineering checks the machine costs that step counts intentionally leave out.

A sort passes 10,000 random tests. Which evidence would most directly catch it silently dropping one copy of a repeated value?
Check the multiset. Sorted order alone does not prove that nothing was lost or invented; correctness properties should cover the whole contract.