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.
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.
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
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.
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 n² 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
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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]
Breadth first, depth first
A graph is things and links, with no root and no order. The first question anyone asks of one is: can I get from here to there, and by what route. Both methods for answering it are the same five lines of code. Keep a collection of places you have reached but not yet explored; take one out, mark it seen, put its unseen neighbours in. Repeat until the collection is empty.
The one difference is what that collection is. Use a queue and you take places out in the order you found them, so the search spreads outward in rings: breadth-first. Use a stack and you take the most recently found place first, so the search runs down one path until it gets stuck and then backtracks: depth-first. One word of the program changes, and the behaviour is unrecognisable.
Queue and stack, one line each
A queue is a supermarket line: items come out in the order they went in, first in first out. A stack is a pile of plates: the last one you put down is the first one you pick up, last in first out.
Both add and remove in about one step. The only difference is which end you take from, and here that single choice decides the whole behaviour of the search.
Edit the map below, then run each search one step at a time and watch which circles change colour. Compare the route each one comes back with.
Both visit every reachable place once and look at every link once, so both are about V + E steps. They differ in memory: breadth-first holds a whole ring of the graph in its queue, which on a wide graph can be enormous, while depth-first holds only the current path. That is why a friend-of-friends query on a social network is careful about how many rings it expands.
What V and E stand for
V is the number of places, called vertices or nodes. E is the number of links between them, called edges. So "about V + E steps" means the search touches each place once and each link once.
Which of the two dominates depends on the graph. A road map has a few roads per junction, so E is a small multiple of V. A group where everybody knows everybody has E close to V², and that is where a search starts to hurt.
When depth-first is the one you want
Depth-first suits any question where you want a route rather than the shortest route: getting out of a maze, checking whether a graph contains a cycle, or visiting everything with almost no memory. It also writes itself as a recursive function, since the call stack is already a stack.
None of the later steps need this. It is here so that depth-first does not look like the losing option.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.