Four problems, four ideas
- A national statistics office in Jakarta must sort the records of 280 million people by an identity number. Comparing every pair would take about comparisons; a good algorithm needs about .
- A rural electrification agency in Malawi must connect twelve villages to a grid with as little cable as possible. A complete graph on twelve villages has spanning trees; we cannot try them all.
- An ambulance dispatcher in São Paulo needs the fastest route from the station to every address in the city, recomputed whenever traffic changes.
- A biologist in Nairobi compares a gene from a cassava virus with a gene from a related strain, looking for the longest stretch of shared structure — a longest common subsequence of two strings of thousands of letters.
Each problem has an obvious but hopeless method: try everything. Each has a clever method that is fast, and — this is the mathematical part — provably correct. The four clever methods illustrate four ideas that account for a large share of practical algorithms: divide and conquer (sorting), greedy choice (the cable network and the routes), dynamic programming (the gene comparison) and graph search.
This lesson builds on algorithms, correctness and efficiency (loop invariants, counting steps, growth rates) and graphs (paths, connectivity, trees). The recurrences that measure divide-and-conquer algorithms are solved in recurrences and generating functions.
Two questions for every algorithm
Every algorithm faces two questions: Is it correct? — does it always produce a right answer — and how much does it cost? — how many steps, as a function of the input size, in the worst case. The four ideas answer them in characteristic ways.
- Divide and conquer. Split the problem into smaller instances of the same problem, solve them recursively, and combine. Correctness is proved by (strong) induction on the size; cost is measured by a recurrence such as .
- Greedy. Build a solution one piece at a time, always taking the piece that looks best right now, and never reconsider. Greedy algorithms are fast and simple — and often wrong. When they are right, the proof is usually an exchange argument: any optimal solution can be transformed, without getting worse, into one that agrees with the greedy choice.
- Dynamic programming. Write the optimal value as a recurrence over subproblems (prefixes, sub-intervals, smaller capacities). If the same subproblems recur many times, compute each once and store it in a table. Correctness is the recurrence (proved by an "optimal substructure" argument); cost is (number of subproblems) × (work per subproblem).
- Graph search. Explore a graph from a source, keeping a frontier of discovered but unexplored vertices. The order of exploration — a queue for breadth-first search, a stack for depth-first search, a priority queue for Dijkstra — decides what the search computes.
Definitions
Definition— cost model and running time
We measure cost by counting basic operations (comparisons, arithmetic on machine-sized numbers, reading or writing one memory cell), each taken to cost one unit. The worst-case running time is the largest number of basic operations over all inputs of size . We usually state it up to a constant factor, with , and (defined in recurrences and generating functions).
Definition— loop invariant
A loop invariant is a statement that holds before the first iteration of a loop and is preserved by every iteration. If it holds when the loop stops, together with the stopping condition it proves what the loop achieves.
Definition— weighted graph, path weight, distance
A weighted graph is a graph (directed or undirected) with a real number on each edge. The weight of a path is the sum of the weights of its edges. The distance is the least weight of a path from to (and if there is none). A shortest path is a path achieving it.
Definition— spanning tree and minimum spanning tree
A spanning tree of a connected undirected graph is a subgraph that contains every vertex and is a tree (connected and acyclic); it has exactly edges. A minimum spanning tree (MST) is a spanning tree of least total weight.
Definition— cut
A cut of a graph is a partition of its vertex set into two non-empty parts and . An edge crosses the cut if it has one endpoint in each part.
Definition— subsequence
A subsequence of a string is a string with — obtained by deleting letters, keeping the order. A longest common subsequence (LCS) of two strings is a subsequence of both, of maximum length. For example, BCBA is an LCS of ABCBDAB and BDCABA.
Divide and conquer: mergesort and the limits of sorting
Mergesort sorts a list of items: if , return it; otherwise split it into halves of sizes and , sort each half recursively, and merge the two sorted halves by repeatedly moving the smaller of the two front items to the output.
Theorem— Lemma (merging)
Merging sorted lists of lengths and produces a sorted list containing exactly their items, using at most comparisons.
Proof
Loop invariant: the output so far is sorted, consists of the items already removed from the two lists, and every output item is every item still in either list. It holds at the start (empty output). Each step compares the two front items and moves the smaller, , to the output; is the previous output items (by the invariant) and both fronts, hence everything remaining, because each list is sorted. So the invariant is preserved. When one list is empty, the rest of the other list is appended; it is sorted and its items are the output, so the final output is sorted. Each comparison moves one item, and no comparison is needed for the last item, so at most comparisons are made.
∎
Theorem— Theorem 1 (mergesort)
Mergesort returns its input in sorted order, and on items it makes at most comparisons.
Proof
Correctness, by strong induction on : lists of length are sorted; for both halves are shorter, so by the inductive hypothesis they come back sorted, and the lemma shows that merging them gives a sorted rearrangement of the input.
Cost. Let be the worst-case number of comparisons. Then and, by the lemma, . We show where , by strong induction. For it is clear. For , gives , so , and by the inductive hypothesis applied to both halves
∎
So mergesort is . Can any sorting method do fundamentally better? Not if it learns about the input only by comparing items.
Theorem— Theorem 2 (the comparison lower bound)
Every algorithm that sorts distinct items using only comparisons makes at least comparisons on some input. Moreover , so the bound is .
Proof
Fix and model the algorithm as a decision tree: the root is its first comparison "is ?", the two subtrees describe what it does after each answer, and each leaf is a point where it stops and outputs an ordering. On an input it follows one root-to-leaf path, and the number of comparisons is the length of that path; the worst case is the height of the tree.
Different orderings of the input must reach different leaves: if two of the orderings reached the same leaf, the algorithm would rearrange them in the same way, and it cannot sort both. So the tree has at least leaves. A binary tree of height has at most leaves (induction: each subtree of the root has height at most , hence at most leaves). Therefore , i.e. .
For the estimate, at least of the factors of are , so .
∎
The two bounds nearly meet: for the lower bound is and mergesort's worst case is (the first experiment below computes both exactly). Stirling's formula gives , while mergesort's worst case is when is a power of two: mergesort is within about comparisons of optimal.
More divide and conquer. Binary search satisfies , so . Karatsuba's method multiplies two -digit numbers with three half-size multiplications instead of four, using ; its recurrence gives . The master theorem in recurrences and generating functions solves such recurrences in general.
Graph search: breadth first
Breadth-first search (BFS) from a source keeps a queue. Initially is marked with and is the only vertex in the queue. Repeatedly remove the vertex at the front; for each unmarked neighbour , mark it with and add it to the back of the queue.
Theorem— Theorem 3 (BFS computes distances)
In an unweighted graph (every edge of length ), BFS from marks exactly the vertices reachable from , and equals the number of edges on a shortest path from to . It runs in time .
Proof
First, the queue always holds marked vertices whose values are non-decreasing from front to back and differ by at most : this holds initially and is preserved, because vertices added while processing get , which is at least every value in the queue and at most one more than the front. Hence vertices are removed in non-decreasing order of .
Every marked has , since following the "marked by" links back from gives a path of length from . Suppose some reachable is unmarked or has , and choose one with as small as possible; , so . Let be the vertex before on a shortest path; then , so was marked with and was eventually removed from the queue. At that moment either was unmarked — and then it got — or it was already marked, by a vertex removed no later than , whose is at most , so . Either way , a contradiction.
Each vertex enters the queue at most once and each edge is examined at most twice (once from each end), so the time is .
∎
Depth-first search (DFS) explores as far as possible along each branch before backtracking (a stack instead of a queue). It also runs in and finds connected components, cycles, and a topological order of a directed acyclic graph (list vertices in decreasing order of the time DFS finishes with them).
Greedy algorithms and exchange arguments
Kruskal's algorithm for a minimum spanning tree: sort the edges by weight; go through them in that order, adding an edge to a growing forest unless it would create a cycle (i.e. unless its endpoints are already in the same component of ).
Theorem— Theorem 4 (Kruskal)
For a connected graph with real edge weights, Kruskal's algorithm returns a minimum spanning tree.
Proof
We prove the invariant: after each step, the forest is contained in some minimum spanning tree. It holds at the start ( empty; an MST exists because there are finitely many spanning trees and at least one, being connected).
Suppose for an MST , and the algorithm now adds the edge . Let be the set of vertices of the component of containing ; then , so crosses the cut . Claim: every edge crossing this cut has weight . Indeed, an edge processed before was either added to (and edges of do not cross , a component of ), or rejected because its endpoints were already in the same component of the forest at that time — hence in the same component now, so does not cross either. Edges processed after weigh at least .
If , then and we are done. Otherwise contains exactly one cycle, and it passes through . Walking around this cycle from across to and back to , we must cross the cut again, along some edge of . Then (it crosses ), and by the claim. Exchange: is connected (removing an edge of a cycle does not disconnect) and has edges, so it is a spanning tree, with . So is an MST, and it contains .
At the end, is a spanning tree: it is acyclic, and if it had two components, some edge of the connected graph would join them, and that edge would have been added when it was processed. A spanning tree contained in an MST has as many edges as , so it is .
∎
The argument contains the general cut property: for any cut, a lightest edge crossing it belongs to some MST. With a union–find data structure to test "same component?", Kruskal runs in time, dominated by sorting.
Dijkstra's algorithm computes distances from a source in a graph with non-negative weights. It keeps a set of finished vertices and a tentative value for every vertex: initially , otherwise, and . Repeatedly choose the unfinished vertex with the smallest , add it to , and relax its edges: for each edge set .
Theorem— Theorem 5 (Dijkstra)
If every edge weight is non-negative, then whenever Dijkstra's algorithm adds a vertex to , . With a binary heap it runs in time.
Proof
Each finite is the weight of some path from to (by induction over the relaxations), so always . We prove when is chosen, assuming (induction) that it holds for all vertices already in . If there is nothing to prove. Otherwise take a shortest path from to . Since (it is chosen first) and , there is a first vertex on outside ; let be its predecessor on , so . When was finished, the edge was relaxed, so
Here because the part of up to is a shortest path to ; because the rest of , from to , has non-negative weight; and because was chosen with the smallest tentative value. So all are equal, and .
∎
With negative weights (but no cycle of negative total weight) the Bellman–Ford algorithm is correct: relax every edge times, in time. Adding a constant to every weight to make them non-negative does not work: it penalises paths with many edges, so it can change which path is shortest.
When greedy fails. Paying an amount with as few coins as possible, the greedy rule "use the largest coin that fits" is optimal for coins — but with coins it pays as , while uses two coins. Nothing in the form of a greedy algorithm tells you whether it is correct; only a proof does. (For change-making, dynamic programming always works: the fewest coins for amount is .)
Dynamic programming: the longest common subsequence
Let and , and write for the length of an LCS of the prefixes and .
Theorem— Theorem 6 (optimal substructure)
, and for :
Proof
The empty prefix has only the empty subsequence. Now let , and let be an LCS of the two prefixes, so ; fix a way of finding inside each prefix.
Case . Since and are the last letters of the prefixes, only can be matched to them. If is matched to neither, then is a longer common subsequence, which is impossible. If is matched to exactly one of them — say to , and to some with — then , and we may re-match to instead, because the earlier letters of are matched before position . So we may assume is matched to both and ; then is a common subsequence of and , so . Conversely, appending to an LCS of the shorter prefixes gives .
Case . The last letter cannot be matched to both and . If it is not matched to , then lies inside , so ; otherwise it is not matched to , and . The reverse inequalities hold because a common subsequence of shorter prefixes is one of longer prefixes.
∎
Computed recursively as written, this recurrence is disastrous: on a mismatch it makes two recursive calls, and the same pairs are recomputed again and again — the number of calls grows exponentially (see the third experiment). But there are only different subproblems. Dynamic programming fills a table row by row, each entry in time from entries already computed, for time in all. An actual LCS is recovered by walking back from : on a match, take the letter and move diagonally; otherwise move to the larger neighbour.
Example— ABCBDAB and BDCABA
The table for ABCBDAB and BDCABA ends with , and walking back gives BCBA. (Other longest common subsequences, such as BDAB, exist: the LCS is not unique, its length is.)
The recipe. (1) Describe the optimal value of a problem in terms of optimal values of smaller subproblems, and prove the recurrence. (2) Check that there are few distinct subproblems — polynomially many. (3) Order them so that each is computed after the ones it needs, and fill a table. The same recipe gives the edit distance used by spell-checkers and DNA aligners (Needleman–Wunsch), the knapsack problem, optimal matrix-chain multiplication, and the Bellman–Ford algorithm itself. Its simplest instance is computing Fibonacci numbers by remembering the last two values instead of recursing.
Algorithms in Lean
Mergesort. Core Lean's List.mergeSort comes with its specification proved: the output is a permutation of the input, and it is sorted. Both are needed — the function that returns [] is "sorted", and the identity function is "a permutation".
/-- Mergesort returns a rearrangement (permutation, `~`) of its input. This applies core Lean's
`List.mergeSort_perm`; the comparison `le` is a Boolean-valued function. -/
theorem mergeSort_perm (l : List ℕ) : (l.mergeSort (fun a b => decide (a ≤ b))).Perm l :=
List.mergeSort_perm l _/-- Mergesort's output is sorted: every earlier entry is `≤` every later one (`List.Pairwise`).
This applies core Lean's `List.pairwise_mergeSort`, which needs the comparison to be transitive
and total. Together with `mergeSort_perm` this is the full specification of a sorting
algorithm. -/
theorem mergeSort_sorted (l : List ℕ) :
(l.mergeSort (fun a b => decide (a ≤ b))).Pairwise (fun a b => decide (a ≤ b)) :=
List.pairwise_mergeSort
(fun a b c hab hbc => by simp only [decide_eq_true_eq] at *; omega)
(fun a b => by simp only [Bool.or_eq_true, decide_eq_true_eq]; omega) lThe comparison count of Theorem 1 is proved here independently. The recurrence is a definition by well-founded recursion, and the proof is the strong induction of the lesson, with Mathlib's Nat.clog 2 n :
/-- The worst-case number of comparisons made by mergesort on `n` items: sort the two halves
(sizes `⌊n/2⌋` and `⌈n/2⌉`), then merge, which takes at most `n − 1` comparisons. -/
def mergeComparisons (n : ℕ) : ℕ :=
if n ≤ 1 then 0 else mergeComparisons (n / 2) + mergeComparisons (n - n / 2) + (n - 1)
decreasing_by all_goals omega/-- **Mergesort makes at most `n ⌈log₂ n⌉` comparisons.** Strong induction: with
`k = ⌈log₂ n⌉`, both halves have size at most `2ᵏ⁻¹`, so each needs at most (its size)·`(k − 1)`
comparisons, and `n (k − 1) + (n − 1) ≤ n k`. Mathlib's `Nat.clog 2 n` is `⌈log₂ n⌉`, and
`Nat.clog_of_two_le` gives `⌈log₂ n⌉ = ⌈log₂ ⌈n/2⌉⌉ + 1` for `n ≥ 2`. -/
theorem mergeComparisons_le (n : ℕ) : mergeComparisons n ≤ n * Nat.clog 2 n := by
induction n using Nat.strong_induction_on with
| _ n ih =>
rw [mergeComparisons]
split_ifs with h
· exact Nat.zero_le _
· rw [not_le] at h
have hk : Nat.clog 2 n = Nat.clog 2 ((n + 2 - 1) / 2) + 1 :=
Nat.clog_of_two_le (by norm_num) h
have hb : n - n / 2 = (n + 2 - 1) / 2 := by omega
set j := Nat.clog 2 ((n + 2 - 1) / 2) with hj
have ha : Nat.clog 2 (n / 2) ≤ j := Nat.clog_mono_right 2 (by omega)
have h1 : mergeComparisons (n / 2) ≤ n / 2 * j :=
(ih (n / 2) (by omega)).trans (Nat.mul_le_mul_left _ ha)
have h2 : mergeComparisons (n - n / 2) ≤ (n - n / 2) * j := by
have := ih (n - n / 2) (by omega)
rw [hb] at this ⊢
exact this
rw [hk]
calc mergeComparisons (n / 2) + mergeComparisons (n - n / 2) + (n - 1)
≤ n / 2 * j + (n - n / 2) * j + (n - 1) := by omega
_ = n * j + (n - 1) := by
rw [← Nat.add_mul, Nat.add_sub_cancel' (Nat.div_le_self n 2)]
_ ≤ n * (j + 1) := by rw [Nat.mul_succ]; omegaThe lower bound. Decision trees are an inductive type; the counting core of Theorem 2 — a tree of height has at most leaves — is a structural induction:
/-- A binary **decision tree**: each internal node is a comparison "is `aᵢ ≤ aⱼ`?" with a
subtree for each answer, and each leaf is an output. (We record only the shape, which is all
the counting argument needs.) -/
inductive DecisionTree where
| leaf : DecisionTree
| node : DecisionTree → DecisionTree → DecisionTree/-- The number of leaves (possible outcomes) of a decision tree. -/
def DecisionTree.leaves : DecisionTree → ℕ
| .leaf => 1
| .node l r => l.leaves + r.leaves/-- The height of a decision tree: the largest number of comparisons on a path from the root
to a leaf, i.e. the worst-case number of comparisons. -/
def DecisionTree.height : DecisionTree → ℕ
| .leaf => 0
| .node l r => max l.height r.height + 1/-- **A tree of height `h` has at most `2ʰ` leaves.** Induction on the tree: each subtree of a
node has height at most `h − 1`, hence at most `2ʰ⁻¹` leaves, and `2ʰ⁻¹ + 2ʰ⁻¹ = 2ʰ`. -/
theorem leaves_le_two_pow_height (t : DecisionTree) : t.leaves ≤ 2 ^ t.height := by
induction t with
| leaf => simp [DecisionTree.leaves, DecisionTree.height]
| node l r ihl ihr =>
simp only [DecisionTree.leaves, DecisionTree.height]
have hl : 2 ^ l.height ≤ 2 ^ max l.height r.height :=
Nat.pow_le_pow_right (by norm_num) (le_max_left _ _)
have hr : 2 ^ r.height ≤ 2 ^ max l.height r.height :=
Nat.pow_le_pow_right (by norm_num) (le_max_right _ _)
rw [pow_succ]
omega/-- **The comparison lower bound.** A decision tree that sorts `n` distinct items must reach a
different leaf for each of the `n!` orderings, so it has at least `n!` leaves; therefore
`n! ≤ 2^(height)`, i.e. the worst case needs at least `log₂ n!` comparisons. -/
theorem sorting_lower_bound (n : ℕ) (t : DecisionTree) (h : n.factorial ≤ t.leaves) :
n.factorial ≤ 2 ^ t.height :=
h.trans (leaves_le_two_pow_height t)/-- A concrete case: sorting `5` items needs at least `7` comparisons in the worst case,
because `5! = 120 > 64 = 2⁶`. (Seven comparisons are in fact enough for five items.) -/
theorem five_items_need_seven (t : DecisionTree) (h : (5 : ℕ).factorial ≤ t.leaves) :
7 ≤ t.height := by
have h2 := sorting_lower_bound 5 t h
by_contra hlt
rw [not_le] at hlt
have : 2 ^ t.height ≤ 2 ^ 6 := Nat.pow_le_pow_right (by norm_num) (by omega)
have h5 : (5 : ℕ).factorial = 120 := rfl
omega/-- Sorting `4` items needs at least `5` comparisons in the worst case: `4! = 24 > 16 = 2⁴`.
(Five comparisons suffice: mergesort uses at most `5` on four items.) -/
theorem four_items_need_five (t : DecisionTree) (h : (4 : ℕ).factorial ≤ t.leaves) :
5 ≤ t.height := by
have h2 := sorting_lower_bound 4 t h
by_contra hlt
rw [not_le] at hlt
have : 2 ^ t.height ≤ 2 ^ 4 := Nat.pow_le_pow_right (by norm_num) (by omega)
have h4 : (4 : ℕ).factorial = 24 := rfl
omegaWhat is not formalised is the modelling step: that every comparison-based algorithm gives a decision tree in which different orderings reach different leaves. That step is a statement about a model of computation, which is exactly where formalisation would have to start from a precise definition of "algorithm".
Dynamic programming. The LCS recursion below compares all three options; Theorem 6 shows that the third dominates when the letters agree, so it computes the same values. The two theorems say together that it computes the length of a longest common subsequence — no common subsequence is longer (lcs_optimal), and one has exactly this length (lcs_achieved). Both proofs follow the case analysis of Theorem 6, using List.Sublist (<+, "obtained by deleting elements"):
/-- The length of a longest common subsequence, by the recursion that dynamic programming
tabulates: drop the first letter of one word or of the other, or — if the first letters agree —
keep it in both. (The usual table only takes the third option when the letters agree; that it
loses nothing is a further lemma. Here all options are compared, which makes the proofs short.)
-/
def lcs : List α → List α → ℕ
| [], _ => 0
| _ :: _, [] => 0
| x :: xs, y :: ys =>
max (max (lcs xs (y :: ys)) (lcs (x :: xs) ys)) (if x = y then lcs xs ys + 1 else 0)
termination_by xs ys => xs.length + ys.length/-- **No common subsequence is longer than `lcs`.** By induction along the recursion: a
common subsequence `z :: zs` of `x :: xs` and `y :: ys` either avoids `x` (so it is a common
subsequence of `xs` and `y :: ys`), or avoids `y`, or uses both — and then `x = z = y` and `zs`
is a common subsequence of `xs` and `ys`. (`List.sublist_cons_iff` is the case split.) -/
theorem lcs_optimal : ∀ (xs ys zs : List α), zs <+ xs → zs <+ ys → zs.length ≤ lcs xs ys
| [], _, zs, h1, _ => by simp [List.sublist_nil.mp h1, lcs]
| _ :: _, [], zs, _, h2 => by simp [List.sublist_nil.mp h2, lcs]
| x :: xs, y :: ys, zs, h1, h2 => by
rw [lcs]
rcases List.sublist_cons_iff.mp h1 with h1' | ⟨r, rfl, hr1⟩
· have := lcs_optimal xs (y :: ys) zs h1' h2
omega
· rcases List.sublist_cons_iff.mp h2 with h2' | ⟨r', hr', hr2⟩
· have := lcs_optimal (x :: xs) ys (x :: r) h1 h2'
omega
· obtain ⟨rfl, rfl⟩ := List.cons.inj hr'
have := lcs_optimal xs ys r hr1 hr2
rw [ite_eq_left rfl, List.length_cons]
omega
termination_by xs ys => xs.length + ys.length/-- **Some common subsequence has length exactly `lcs`.** By induction along the same recursion,
building the subsequence from whichever option attains the maximum. Together with
`lcs_optimal`, the recursion computes the length of a *longest* common subsequence. -/
theorem lcs_achieved :
∀ (xs ys : List α), ∃ zs, zs <+ xs ∧ zs <+ ys ∧ zs.length = lcs xs ys
| [], _ => ⟨[], List.nil_sublist _, List.nil_sublist _, by simp [lcs]⟩
| _ :: _, [] => ⟨[], List.nil_sublist _, List.nil_sublist _, by simp [lcs]⟩
| x :: xs, y :: ys => by
obtain ⟨a, ha1, ha2, ha⟩ := lcs_achieved xs (y :: ys)
obtain ⟨b, hb1, hb2, hb⟩ := lcs_achieved (x :: xs) ys
obtain ⟨c, hc1, hc2, hc⟩ := lcs_achieved xs ys
rw [lcs]
by_cases hxy : x = y
· subst hxy
rw [ite_eq_left rfl]
by_cases h3 : max (lcs xs (x :: ys)) (lcs (x :: xs) ys) ≤ lcs xs ys + 1
· exact ⟨x :: c, hc1.cons_cons x, hc2.cons_cons x, by simp [hc, max_eq_right h3]⟩
· rw [not_le] at h3
rw [max_eq_left h3.le]
by_cases h4 : lcs (x :: xs) ys ≤ lcs xs (x :: ys)
· exact ⟨a, ha1.cons x, ha2, by rw [ha, max_eq_left h4]⟩
· rw [not_le] at h4
exact ⟨b, hb1, hb2.cons x, by rw [hb, max_eq_right h4.le]⟩
· rw [ite_eq_right hxy, max_eq_left (Nat.zero_le _)]
by_cases h4 : lcs (x :: xs) ys ≤ lcs xs (y :: ys)
· exact ⟨a, ha1.cons x, ha2, by rw [ha, max_eq_left h4]⟩
· rw [not_le] at h4
exact ⟨b, hb1, hb2.cons y, by rw [hb, max_eq_right h4.le]⟩
termination_by xs ys => xs.length + ys.length/-- The example from the lesson: `ABCBDAB` and `BDCABA` have a longest common subsequence of
length `4` (for instance `BCBA`). Lean's kernel evaluates the recursion (`decide +kernel`). -/
theorem lcs_example : lcs "ABCBDAB".toList "BDCABA".toList = 4 := by
decide +kernelThe Lean function is the recursion, not the table: it is a specification, proved correct, while the efficient table-filling program is in the experiment below. Proving that a table implementation equals the recursion is a typical next step in verified programming. In miniature, here is a remembered pair that provably equals the Fibonacci recursion:
/-- Dynamic programming in miniature: compute the pair `(Fₙ, Fₙ₊₁)` by remembering the two most
recent values, `n` additions in all, instead of the exponential naive recursion. -/
def fibPair : ℕ → ℕ × ℕ
| 0 => (0, 1)
| n + 1 => ((fibPair n).2, (fibPair n).1 + (fibPair n).2)/-- The remembered pair is correct: `fibPair n = (Fₙ, Fₙ₊₁)`, by induction on `n` using
`Fₙ₊₂ = Fₙ + Fₙ₊₁` (Mathlib's `Nat.fib_add_two`). -/
theorem fibPair_eq (n : ℕ) : fibPair n = (Nat.fib n, Nat.fib (n + 1)) := by
induction n with
| zero => rfl
| succ k ih => simp [fibPair, ih, Nat.fib_add_two]Greedy algorithms. A counterexample is a perfectly good formal theorem:
/-- The greedy change-making algorithm, with the coin values listed from largest to smallest:
use as many of the largest coin as possible, then continue with the remainder. Returns the
number of coins used. -/
def greedyCoins : List ℕ → ℕ → ℕ
| [], _ => 0
| c :: cs, amount => amount / c + greedyCoins cs (amount % c)/-- **A greedy algorithm needs a proof.** With coins `4, 3, 1`, greedy pays `6` as `4 + 1 + 1`
(three coins), but `3 + 3` uses only two. (For coins `25, 10, 5, 1` greedy happens to be
optimal; that is a theorem about those particular values.) -/
theorem greedy_not_optimal :
greedyCoins [4, 3, 1] 6 = 3 ∧
∃ l : List ℕ, (∀ c ∈ l, c ∈ [4, 3, 1]) ∧ l.sum = 6 ∧ l.length = 2 :=
⟨by decide, [3, 3], by decide, rfl, rfl⟩Kruskal's and Dijkstra's algorithms are not formalised here (Mathlib has graphs, walks and trees, but not these algorithms). Mathlib does prove the structural facts behind Kruskal's output — a maximal acyclic subgraph of a connected graph is a spanning tree, and a tree on vertices has edges:
/-- **What Kruskal's algorithm builds.** Kruskal adds edges of a connected graph `G` in order of
weight, skipping any edge that would close a cycle, so its output is a *maximal* acyclic
subgraph of `G`. Mathlib's `SimpleGraph.Connected.maximal_le_isAcyclic_iff_isTree` says that such
a subgraph is exactly a spanning tree. (That it has *minimum* weight is the cut-property proof
in the lesson, not formalised here.) -/
theorem maximal_acyclic_is_tree {V : Type*} {G T : SimpleGraph V} (hG : G.Connected)
(hT : T ≤ G) : Maximal (fun H => H ≤ G ∧ H.IsAcyclic) T ↔ T.IsTree :=
hG.maximal_le_isAcyclic_iff_isTree hT/-- A tree on `n` vertices has exactly `n − 1` edges (stated as `|E| + 1 = |V|`), so Kruskal's
algorithm adds exactly `n − 1` edges. This applies Mathlib's `SimpleGraph.IsTree.card_edgeFinset`. -/
theorem tree_edges {V : Type*} [Fintype V] {T : SimpleGraph V} [Fintype T.edgeSet]
(hT : T.IsTree) : T.edgeFinset.card + 1 = Fintype.card V :=
hT.card_edgeFinsetExperiments: counting comparisons, testing greed, filling tables
The first experiment counts comparisons. For it tries all inputs, so the worst cases are exact; for larger it averages over random inputs and compares mergesort with insertion sort, whose average is about .
"""How many comparisons does sorting need? Mergesort against the lower bound.
We count the comparisons made by mergesort and by insertion sort. For small n
we try every one of the n! orderings, so the worst case is computed exactly
and can be compared with the decision-tree lower bound ceil(log2 n!) and with
the recurrence C(n) = C(floor(n/2)) + C(ceil(n/2)) + n - 1 for mergesort. For
larger n we sort seeded random permutations and compare the average counts
with n log2 n and with n^2/4, the average for insertion sort.
"""
import itertools
import math
import random
def merge_sort(items):
"""Return (sorted list, number of comparisons) for mergesort."""
if len(items) <= 1:
return list(items), 0
mid = len(items) // 2
left, c1 = merge_sort(items[:mid])
right, c2 = merge_sort(items[mid:])
out, i, j, count = [], 0, 0, c1 + c2
while i < len(left) and j < len(right):
count += 1
if left[i] <= right[j]:
out.append(left[i])
i += 1
else:
out.append(right[j])
j += 1
out.extend(left[i:])
out.extend(right[j:])
return out, count
def insertion_sort(items):
"""Return (sorted list, number of comparisons) for insertion sort."""
out, count = list(items), 0
for k in range(1, len(out)):
x, j = out[k], k - 1
while j >= 0:
count += 1
if out[j] <= x:
break
out[j + 1] = out[j]
j -= 1
out[j + 1] = x
return out, count
def merge_recurrence(n):
"""Worst-case mergesort comparisons from C(n) = C(n//2) + C(n - n//2) + n - 1."""
if n <= 1:
return 0
return merge_recurrence(n // 2) + merge_recurrence(n - n // 2) + n - 1
def lower_bound(n):
"""The decision-tree bound: any comparison sort needs ceil(log2 n!) in the worst case."""
return math.ceil(math.log2(math.factorial(n))) if n > 1 else 0
def worst_case(sort, n):
"""Largest comparison count of `sort` over all n! orderings of range(n)."""
return max(sort(list(p))[1] for p in itertools.permutations(range(n)))
def average_case(sort, n, trials, rng):
"""Mean comparison count over `trials` random permutations."""
total = 0
for _ in range(trials):
items = list(range(n))
rng.shuffle(items)
result, count = sort(items)
if result != sorted(items):
raise AssertionError("sort failed")
total += count
return total / trials
def main():
print("Worst cases over all n! inputs (exact):")
print(" n n! lower bound mergesort recurrence insertion n(n-1)/2")
for n in range(1, 8):
print(f"{n:2d} {math.factorial(n):5d} {lower_bound(n):12d}"
f" {worst_case(merge_sort, n):10d} {merge_recurrence(n):11d}"
f" {worst_case(insertion_sort, n):10d} {n * (n - 1) // 2:9d}")
print()
rng = random.Random(2024)
print("Averages over seeded random inputs (experimental):")
print(" n mergesort n log2 n insertion n^2/4")
for n, trials in [(16, 200), (128, 20), (1024, 2)]:
m = average_case(merge_sort, n, trials, rng)
s = average_case(insertion_sort, n, trials, rng)
print(f"{n:5d} {m:11.1f} {n * math.log2(n):9.0f} {s:11.1f}"
f" {n * n / 4:8.0f}")
print()
print("Epistemic status: the first table is exact for n <= 7 (every input")
print("tried); mergesort's worst case matches its recurrence and stays")
print("within one or two comparisons of the lower bound, which is a")
print("theorem for every n. The averages are experiments on a few seeded")
print("random inputs: evidence about typical behaviour, not a proof.")
if __name__ == "__main__":
main()The second experiment tests the greedy algorithms against brute force: Kruskal against every spanning tree, Dijkstra against Bellman–Ford (with and without negative weights), and greedy change-making against dynamic programming for every coin system with .
"""Greedy algorithms against brute force: when they work and when they fail.
1. Kruskal's algorithm is compared with an exhaustive search over all
spanning trees of seeded random weighted graphs on 5 vertices.
2. Dijkstra's algorithm is compared with Bellman-Ford on random directed
acyclic graphs, first with non-negative weights and then allowing negative
ones, where Dijkstra's greedy choice can be wrong.
3. Greedy change-making is compared with dynamic programming for every coin
system {1, a, b} with b <= 12. By a theorem of Kozen and Zaks (1994), if
greedy is ever suboptimal, it already fails for some amount below a + b,
so checking those amounts decides each system completely.
"""
import heapq
import itertools
import random
def find(parent, x):
"""Union-find root of x, with path halving."""
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def kruskal(n, edges):
"""Total weight of a minimum spanning tree; edges are (w, u, v)."""
parent, total, used = list(range(n)), 0, 0
for w, u, v in sorted(edges):
ru, rv = find(parent, u), find(parent, v)
if ru != rv:
parent[ru] = rv
total, used = total + w, used + 1
if used != n - 1:
raise ValueError("graph is not connected")
return total
def brute_force_mst(n, edges):
"""Minimum weight over all (n-1)-edge subsets that form a spanning tree."""
best = None
for subset in itertools.combinations(edges, n - 1):
parent, ok = list(range(n)), True
for _, u, v in subset:
ru, rv = find(parent, u), find(parent, v)
if ru == rv:
ok = False
break
parent[ru] = rv
if ok:
weight = sum(w for w, _, _ in subset)
best = weight if best is None else min(best, weight)
return best
def dijkstra(n, adj, source=0):
"""Distances by Dijkstra's algorithm; a vertex is final once popped."""
dist, done = [None] * n, [False] * n
dist[source], heap = 0, [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if done[u]:
continue
done[u] = True
for v, w in adj[u]:
if not done[v] and (dist[v] is None or d + w < dist[v]):
dist[v] = d + w
heapq.heappush(heap, (dist[v], v))
return dist
def bellman_ford(n, adj, source=0):
"""Distances by Bellman-Ford (correct with negative weights, no negative cycles)."""
dist = [None] * n
dist[source] = 0
for _ in range(n - 1):
for u in range(n):
if dist[u] is None:
continue
for v, w in adj[u]:
if dist[v] is None or dist[u] + w < dist[v]:
dist[v] = dist[u] + w
return dist
def random_dag(n, rng, low, high):
"""Random DAG on 0..n-1 (edges i -> j for i < j), weights in [low, high]."""
adj = [[] for _ in range(n)]
for i, j in itertools.combinations(range(n), 2):
if rng.random() < 0.5:
adj[i].append((j, rng.randint(low, high)))
return adj
def greedy_coins(coins, amount):
"""Number of coins used by the greedy method (coins include 1)."""
count = 0
for c in sorted(coins, reverse=True):
count += amount // c
amount %= c
return count
def optimal_coins(coins, limit):
"""Fewest coins for every amount 0..limit, by dynamic programming."""
best = [0] + [None] * limit
for x in range(1, limit + 1):
best[x] = 1 + min(best[x - c] for c in coins if c <= x)
return best
def first_greedy_failure(coins):
"""Smallest amount below a + b (the two largest coins) where greedy is worse."""
a, b = sorted(coins)[-2:]
best = optimal_coins(coins, a + b)
for x in range(a + b):
if greedy_coins(coins, x) > best[x]:
return x
return None
def main():
rng = random.Random(7)
agree = 0
for _ in range(300):
edges = [(rng.randint(1, 9), u, v)
for u, v in itertools.combinations(range(5), 2)]
agree += kruskal(5, edges) == brute_force_mst(5, edges)
print(f"1. Kruskal = exhaustive minimum over spanning trees: {agree}/300")
for low, label in [(0, "weights 0..9"), (-4, "weights -4..9")]:
same = sum(dijkstra(6, g) == bellman_ford(6, g)
for g in (random_dag(6, rng, low, 9) for _ in range(300)))
print(f"2. Dijkstra = Bellman-Ford, {label}: {same}/300 graphs")
adj = [[(1, 1), (2, 2)], [], [(1, -2)]]
print(f" s->a (1), s->b (2), b->a (-2): Dijkstra gives d(a) ="
f" {dijkstra(3, adj)[1]}, true d(a) = {bellman_ford(3, adj)[1]}")
systems = [(1, a, b) for a in range(2, 12) for b in range(a + 1, 13)]
fails = {s: first_greedy_failure(s) for s in systems}
good = sum(1 for x in fails.values() if x is None)
print(f"3. Coin systems {{1, a, b}}, b <= 12: greedy optimal for {good}"
f" of {len(systems)}")
print(f" {{1, 3, 4}}: first failure at {fails[(1, 3, 4)]}"
f" (greedy {greedy_coins((1, 3, 4), 6)} coins, best"
f" {optimal_coins((1, 3, 4), 6)[6]})")
print(f" {{1, 5, 10}}: failures below 15: {fails[(1, 5, 10)]}")
print()
print("Epistemic status: parts 1 and 2 test random instances - evidence")
print("that agrees with the correctness proofs of Kruskal (cut property)")
print("and Dijkstra (non-negative weights), and counterexamples showing")
print("the weight hypothesis is needed. Part 3 is an exhaustive check of")
print("finitely many coin systems, conclusive only because of the")
print("Kozen-Zaks bound.")
if __name__ == "__main__":
main()The third fills the LCS table for the example above and measures how much work the table saves compared with the plain recursion.
"""Longest common subsequence: the dynamic-programming table, and its savings.
The length L(i, j) of a longest common subsequence of the first i letters of
x and the first j letters of y satisfies L(i, j) = L(i-1, j-1) + 1 when the
i-th and j-th letters agree, and max(L(i-1, j), L(i, j-1)) otherwise. We fill
the (m+1) x (n+1) table for the classic example ABCBDAB / BDCABA, recover a
longest common subsequence by walking back through the table, and compare
the number of table cells with the number of calls made by the same
recursion without memory, on seeded random DNA strings.
"""
import random
def lcs_table(x, y):
"""The (len(x)+1) x (len(y)+1) table of LCS lengths of prefixes."""
m, n = len(x), len(y)
table = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if x[i - 1] == y[j - 1]:
table[i][j] = table[i - 1][j - 1] + 1
else:
table[i][j] = max(table[i - 1][j], table[i][j - 1])
return table
def traceback(x, y, table):
"""One longest common subsequence, read off the table from the end."""
i, j, out = len(x), len(y), []
while i > 0 and j > 0:
if x[i - 1] == y[j - 1]:
out.append(x[i - 1])
i, j = i - 1, j - 1
elif table[i - 1][j] >= table[i][j - 1]:
i -= 1
else:
j -= 1
return "".join(reversed(out))
def is_subsequence(s, t):
"""True if s can be obtained from t by deleting letters."""
it = iter(t)
return all(ch in it for ch in s)
def naive_calls(x, y):
"""(LCS length, number of calls) for the recursion without memory."""
calls = 0
def rec(i, j):
nonlocal calls
calls += 1
if i == 0 or j == 0:
return 0
if x[i - 1] == y[j - 1]:
return rec(i - 1, j - 1) + 1
return max(rec(i - 1, j), rec(i, j - 1))
return rec(len(x), len(y)), calls
def main():
x, y = "ABCBDAB", "BDCABA"
table = lcs_table(x, y)
print(" " * 6 + "".join(f"{c:>3}" for c in y))
for i, row in enumerate(table):
label = " " if i == 0 else x[i - 1]
print(f" {label} " + " ".join(str(v).rjust(2) for v in row))
z = traceback(x, y, table)
print(f"LCS length {table[-1][-1]}; one LCS: {z}; subsequence of both:"
f" {is_subsequence(z, x) and is_subsequence(z, y)}")
print()
rng = random.Random(42)
print(" length LCS table cells calls without memory")
for n in (4, 6, 8, 10, 12):
a = "".join(rng.choice("ACGT") for _ in range(n))
b = "".join(rng.choice("ACGT") for _ in range(n))
length, calls = naive_calls(a, b)
assert length == lcs_table(a, b)[-1][-1]
print(f"{n:7d} {length:4d} {(n + 1) ** 2:12d} {calls:21d}")
print()
print("Epistemic status: the table and traceback are exact computations")
print("for one example; the lesson proves the recurrence correct for all")
print("inputs (and Lean checks it). The call counts are measurements on a")
print("few seeded random strings: they illustrate exponential growth of")
print("the plain recursion but are not a worst-case analysis.")
if __name__ == "__main__":
main()Explore: spanning trees and growth rates
Why not find a minimum spanning tree by trying every spanning tree? Build a few spanning trees of this small network of towns by clicking edges, and look at the exact number of spanning trees the widget reports (by Kirchhoff's matrix-tree theorem). On towns all joined to each other there are (Cayley's formula): for towns. Kruskal looks at each edge once.
Spanning trees of a small network
Click edges to build a spanning tree. How many are there in all?
Edges chosen: 0. A spanning tree on 5 vertices has exactly 4 edges (|V| − 1).
- No cycle ✓
- Reaches every vertex ✗The chosen edges leave 5 separate pieces.
- A spanning tree ✗
Altogether this graph has 21 different spanning trees (counted exactly by Kirchhoff's matrix-tree theorem).
The second picture compares growth rates: mergesort's worst case is about , insertion sort's average about , and no comparison sort can beat roughly . For small insertion sort's curve is below mergesort's — which is why practical sorting libraries switch to insertion sort for short runs.
Comparisons needed to sort n items
Move the sliders and trace along the curves to read off values.
- n log₂ n (mergesort, worst case)
- n²/4 (insertion sort, average)
- ≈ log₂ n! (lower bound)
- n log₂ n (mergesort, worst case): y = 0
- n²/4 (insertion sort, average): y = 0.25
- ≈ log₂ n! (lower bound): y = −1.4427
Exercises
1. Algorithm design and analysis
Warm-upHumanTwo sorted lists have lengths and . What is the largest number of comparisons the merging procedure can need to merge them?
2. Algorithm design and analysis
Warm-upHumanWhat is the decision-tree lower bound on the worst-case number of comparisons needed to sort distinct items?
3. Algorithm design and analysis
CoreHumanMergesort's worst-case comparison count satisfies and for a power of two. Compute .
4. Algorithm design and analysis
CoreHumanThe network of towns in the Explore section has these cable costs: , , , , , , . Run Kruskal's algorithm. What is the total cost of the minimum spanning tree?
5. Algorithm design and analysis
CoreHumanRun Dijkstra's algorithm from on the directed graph with edges (weight ), (weight ) and (weight ). What does it report for ?
6. Algorithm design and analysis
CoreHumanIn the proof of Theorem 5 (Dijkstra), which step uses the hypothesis that all edge weights are non-negative?
7. Algorithm design and analysis
CoreHumanSuppose a currency has coins worth , and . For which amount does the greedy method (always use the largest coin that fits) not use the fewest coins?
8. Algorithm design and analysis
CoreHumanWhat is the length of a longest common subsequence of AGGTAB and GXTXAYB?
9. Algorithm design and analysis
CoreFormalWhich specification correctly says that a Lean function sort : List ℕ → List ℕ is a sorting function? (Sorted l means each entry is ≤ the later ones; l₁ ~ l₂ means l₁ is a permutation of l₂.)
10. Algorithm design and analysis
StretchComputationalWrite lcs_length(x, y) that returns the length of a longest common subsequence of the strings x and y, by filling the dynamic-programming table of Theorem 6 in time.
Common misconceptions
Misconception
"Dijkstra's algorithm works with any weights." Its proof uses non-negativity at one precise step, and the three-vertex example above shows it can fail with a single negative edge. Use Bellman–Ford when weights can be negative (and no cycle has negative total weight). Shifting all weights by a constant does not rescue Dijkstra: it changes which paths are shortest.
Misconception
"A greedy algorithm that works on my examples is correct." Greedy change-making works for coins and fails for . A greedy algorithm needs a proof — typically an exchange argument, as for Kruskal — or a counterexample.
Misconception
"An algorithm is always faster than an one." Big-O hides constant factors and only describes large . For small inputs insertion sort beats mergesort, and good library sorts use it on short runs. Asymptotic analysis predicts what happens as inputs grow, not which program wins on a given input.
Misconception
"No sorting algorithm can beat ." The lower bound applies to algorithms that learn about the input only by comparisons. If the keys are integers in a small range , counting sort runs in time, and radix sort sorts -digit keys in for base .
Misconception
"Dynamic programming is just recursion with a cache, so it is always fast." It is fast only when there are few distinct subproblems. The LCS of two strings has subproblems; a problem whose natural subproblems are all subsets of an -element set has of them, and a cache does not change that.
Many ways of seeing
Combinatorics— Matroids: exactly where greed is good
Why does greed work for spanning trees but not for coins? A matroid is a finite set with a non-empty family of "independent" subsets that is closed under taking subsets and has the exchange property: if and , some has . The edge sets of forests in a graph form a matroid (the graphic matroid), and so do linearly independent sets of vectors. The theorem of Richard Rado (1957), David Gale (1968) and Jack Edmonds (1971) says: a hereditary family is a matroid if and only if the greedy algorithm finds a maximum-weight independent set for every choice of weights. Kruskal's algorithm is this greedy algorithm on the graphic matroid (with weights negated to minimise). The exchange property is the abstract form of the exchange step in Theorem 4.
Topology— Cycles, cuts and Betti numbers
A graph is a one-dimensional space (a cell complex with vertices as points and edges as intervals), and its number of independent cycles is a topological invariant: the first Betti number , where is the number of connected components. A spanning tree is a maximal subgraph with , a contractible "skeleton" of a connected graph; each of the edges outside it closes exactly one cycle (its fundamental cycle), and these cycles form a basis of the cycle space . Kruskal's algorithm rejects precisely the edges that would raise . Over the cycle space and the cut space are orthogonal complements, and this duality pairs the cut property ("a lightest edge across any cut is in some MST") with the cycle property ("if weights are distinct, the heaviest edge of any cycle is in no MST"). See algebraic topology.
Algebra— Shortest paths are linear algebra over (min, +)
Replace addition by and multiplication by . These operations on form the tropical (min-plus) semiring: is associative and commutative with identity , distributes over , and is the multiplicative identity. If is the matrix of edge weights (with on the diagonal and for missing edges), then the entry of the min-plus power is the least weight of a path from to with at most edges — matrix multiplication literally is "extend paths by one edge and keep the best". Bellman–Ford computes repeatedly, and repeated squaring computes all distances in operations. Many dynamic programs are linear recurrences over such semirings. See linear algebra.
Probability— Randomness as a design tool
Quicksort picks a pivot, splits the items into those smaller and larger, and recurses. With a uniformly random pivot, items (in sorted order) are compared exactly when one of them is the first pivot chosen among the items between them, which happens with probability . Linearity of expectation gives an expected number of comparisons , for every input: the randomness is in the algorithm, not in the data. Karger's contraction algorithm (1993) finds a minimum cut of a graph with probability at least per run, so repeating it about times makes failure unlikely. See probability theory.
Geometry— Distances, wavefronts and Euclidean spanning trees
With positive weights, is a metric on the vertices of a connected undirected graph, and Dijkstra's algorithm explores the metric balls in order of increasing radius , like a wavefront spreading from . As an analogy (not a proof), this is Huygens' picture of light, and its continuous limit is the eikonal equation for arrival times, which "fast marching" methods solve with a Dijkstra-like ordering. In the plane, geometry speeds up the MST: for points with Euclidean distances, a minimum spanning tree can be found among the edges of the Delaunay triangulation, giving an algorithm instead of considering all pairs.
Computation— Easy problems, hard problems
Every problem in this lesson has a polynomial-time algorithm. Small changes make problems hard: the shortest tour through all vertices (the travelling salesperson problem) is NP-hard, as is the longest common subsequence of an arbitrary number of strings (Maier, 1978), and no polynomial algorithm is known for them; whether one exists is the P versus NP question. Even inside P there are limits: the edit distance of two strings of length cannot be computed in time unless the Strong Exponential Time Hypothesis fails (Backurs and Indyk, 2015) — a conditional lower bound. See computational complexity.
History and global context
- Ancient algorithms. Euclid's algorithm for the greatest common divisor appears in the Elements (Book VII, c. 300 BCE). Āryabhaṭa's kuṭṭaka ("pulveriser", 499 CE) solved linear Diophantine equations by a related recursive procedure, and Chinese mathematicians, from the Sunzi Suanjing (3rd–5th century) to Qin Jiushao's dayan method (1247), solved simultaneous congruences algorithmically. The word "algorithm" comes from the name of Muḥammad ibn Mūsā al-Khwārizmī, who worked in Baghdad in the 9th century and whose book on Hindu–Arabic numerals taught the methods of written arithmetic.
- Sorting. John von Neumann described mergesort in 1945. C. A. R. Hoare invented quicksort in 1959–61. Anatoly Karatsuba's 1960 multiplication method refuted Andrey Kolmogorov's conjecture that -digit multiplication needs order operations.
- Spanning trees. Otakar Borůvka (1926) solved the minimum spanning tree problem to design an electrical network for southern Moravia; Vojtěch Jarník (1930) gave the algorithm later rediscovered by Robert Prim (1957) and Edsger Dijkstra (1959); Joseph Kruskal published his in 1956.
- Shortest paths and dynamic programming. Dijkstra designed his shortest-path algorithm in 1956 and published it in 1959. Richard Bellman developed dynamic programming at the RAND Corporation in the 1950s; the Bellman–Ford algorithm appears in work of Lester Ford (1956), Bellman (1958) and Edward Moore (1959), whose paper also describes breadth-first search for mazes. Saul Needleman and Christian Wunsch (1970) brought sequence alignment by dynamic programming to biology, and Robert Wagner and Michael Fischer (1974) gave the edit-distance algorithm.
- Efficiency as a concept. Alan Cobham and Jack Edmonds (both 1965) proposed polynomial time as the mathematical meaning of "efficient". Donald Knuth's The Art of Computer Programming (from 1968) made the precise analysis of algorithms a mathematical discipline.
Further reading
- Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest and Clifford Stein, Introduction to Algorithms (1990; 4th edition 2022) — the standard reference, with complete proofs.
- Jon Kleinberg and Éva Tardos, Algorithm Design (2005) — organised by design paradigm, with especially clear exchange arguments.
- Sanjoy Dasgupta, Christos Papadimitriou and Umesh Vazirani, Algorithms (2006) — short and mathematical.
- Donald E. Knuth, The Art of Computer Programming, Volume 3: Sorting and Searching (1973; 2nd edition 1998) — everything about comparisons, including optimal sorting of small sets.
- Alexander Schrijver, Combinatorial Optimization: Polyhedra and Efficiency (2003) — matroids, spanning trees and shortest paths in depth, with their history.
- Steven S. Skiena, The Algorithm Design Manual (1997; 3rd edition 2020) — practical advice on choosing and adapting algorithms.
Next in the Lumenary: computational complexity, graph theory (matchings and flows, the algorithmic side of Hall's and Menger's theorems) and optimisation.