Approximation Algorithms
Many important problems are NP-complete, so nobody knows a fast exact algorithm. This lesson shows the alternative: fast algorithms that are guaranteed to return an answer within a known factor of the best possible one. You will run approxVertexCover, approxTspTour, greedySetCover, randomized max-3SAT, approxMinWeightVc (linear-programming rounding), mergeLists, exactSubsetSum, trim and approxSubsetSum step by step, and every run also computes the brute-force optimum so you can see the real ratio against the promised one, with a ratio bar chart and a proof-chain table under every procedure.
What an approximation ratio means
An optimization problem asks for the best answer among many valid ones: the shortest tour, the fewest guards, the largest total that still fits. Many important optimization problems are NP-hard (see C34 · NP-completeness): nobody knows an algorithm that is guaranteed to find the best answer in polynomial time (time that grows like n², n³, … rather than 2ⁿ), and finding one would settle the biggest open question in computer science. This lesson shows the practical escape hatch: a fast algorithm that is not always perfect but comes with a proof of how far from perfect it can be.
For an optimization problem, let C* be the cost of an optimal solution and C the cost of the solution our algorithm returns. The algorithm is a ρ(n)-approximation if for every input of size n, max(C/C*, C*/C) ≤ ρ(n). For a minimization problem this is C ≤ ρ·C*; for a maximization problem it is C* ≤ ρ·C. The ratio is always ≥ 1, and 1 means "exactly optimal".
Small example. A minimization algorithm returns a cover of 6 guards while the true minimum is 4: ratio 6/4 = 1.5. A maximization algorithm satisfies 7 of 8 rules while the best possible is 8: ratio 8/7 ≈ 1.14. Both numbers are "how many times worse than perfect", and both are at least 1.
Two stronger notions appear at the end of this lesson. An approximation scheme takes an extra input ε > 0 (a small number such as 0.1, "I accept an answer 10% off") and is a (1+ε)-approximation for any fixed ε. It is a PTAS if it runs in polynomial time for each fixed ε, and a FPTAS if the time is polynomial in both the input size and 1/ε. approxSubsetSum below is an FPTAS.
How to read the animations: each one runs the pseudocode printed beside it, one row per line, and finishes with a brute-force optimum (try every candidate answer; feasible only because the inputs are tiny) and the measured ratio. Below every procedure a ratio bar chart compares the measured ratio of several inputs with the proven bound, and a proof-chain table shows the inequalities of the proof with real numbers.
Maths → code. The approximation ratio of a solution of cost C against the optimum C* is max(C/C*, C*/C): the first quotient for minimization, the second for maximization, always ≥ 1, and exactly 1 only for an optimal answer.
Input size → what's feasible. Computing the exact C* needs brute force (n ≤ 25 for subset problems, n ≤ 10 for tours), so measured ratios on this page use tiny inputs; the provable ratios below hold for every input size.
approxVertexCover
A graph is dots (vertices) joined by lines (edges); see C22 · graph basics. A vertex cover of an undirected graph is a set of vertices such that every edge has at least one endpoint in the set. Finding a smallest one is NP-hard. The procedure below repeatedly takes an arbitrary remaining edge (u, v), adds both endpoints, and deletes every edge touching u or v. (This page always takes the first remaining edge in list order.)
Small example. A path a–b–c–d has edges ab, bc, cd. Pick ab: guards at a and b, which also watch bc. Pick cd: guards at c and d. The algorithm hires 4 guards, but guards at b and c alone would already watch all three streets, so the optimum is 2 and the ratio is exactly 2.
Reading the pseudocode. Line 1 starts with no guards. Line 2 copies the edge set into E′, the "still unwatched" streets. Lines 3–6 are the loop: pick any unwatched edge (line 4), hire both its endpoints (line 5), discard every street they watch (line 6). Line 7 returns the guards. Every loop round permanently removes at least the picked edge, so the loop ends after at most E rounds.
Ratio bar chart and proof chain (all numbers computed live on this page by the same procedure plus a brute-force optimum):
Dart implementation (vertices 0..n−1, edges as [u, v] pairs). Index shift: the pseudocode is already index-free (it works with vertices and edge sets, not array positions), so nothing needs shifting from pseudocode to Dart; only the set E′ becomes the list rest, refilled each round.
Maths → code. The 2-approximation argument says |C| = 2|A| ≤ 2|C*|, where A is the set of edges picked on line 4. The code below rebuilds A, checks that it is a matching (no shared endpoint), that |C| = 2|A|, that any cover needs at least |A| vertices, and so |C| ≤ 2|C*|.
Input size → what's feasible. V + E ≤ 104 → the list-filtering Dart version above (O(E) per round, O(E²) worst case) is instant; E ≈ 106 → mark vertices in a boolean array and make one pass over the edges for Θ(V + E). Computing the exact optimum by trying all 2n subsets stops at n ≈ 25.
Running time. With adjacency lists and a marker per vertex it is O(V + E): each edge is examined once when chosen or removed. The Dart version above re-filters the list each round (O(E) per round, O(E·V) worst case) to keep the code obvious.
approxTspTour
The traveling-salesman problem (TSP) asks for the cheapest Hamiltonian cycle (a round trip visiting every vertex exactly once) in a complete graph (every pair of towns has a road) with edge costs c. The cost function satisfies the triangle inequality if c(u, w) ≤ c(u, v) + c(v, w) for all u, v, w: going directly is never worse than going via a third town. Straight-line (Euclidean) distances always satisfy it, and the animations below use Euclidean points. A minimum spanning tree (MST) is the cheapest set of edges connecting all vertices with no cycle; the procedure calls primMst from C23 · minimum spanning trees, which is why Prim's steps appear inside the animation. A preorder tree walk lists a vertex when first reached, then walks each child subtree in turn.
Small example. Four towns on a straight road at positions 0, 2, 5, 9. The MST is the road itself (weight 9). The preorder walk visits a, b, c, d and returns from d to a: total 2 + 3 + 4 + 9 = 18, and no tour can beat 18, so here the tour is optimal.
Reading the pseudocode. Line 1 chooses the starting vertex (the root). Line 2 grows the MST from the root with Prim (each step adds the cheapest edge leaving the tree; the green edges in the animation). Line 3 lists the vertices in preorder; the animation shows the detour a full walk would take and the shortcut H takes instead. Line 4 returns that list as a cycle (closing the last vertex back to r).
Dart implementation. Index shift: the pseudocode names vertices a, b, c, … (vertex a is index 0) and starts Prim from the root; the Dart code fixes the root to vertex 0 (any root works) and keeps Prim's state in 0-based arrays parent, key, inTree.
Maths → code. Line 2 is primMst from root 0. This is the array version of Prim's algorithm (extract-min by linear scan) with an independent Kruskal check of the tree weight.
Maths → code. Lines 3–4: the tour H is the preorder of the tree. A full walk W of the tree (every edge down and back up) revisits vertices; keeping only the first visit of each vertex (the shortcut) gives exactly the preorder, which is the tour the procedure returns.
Maths → code. The 2-approximation proof as three inequalities, c(T) ≤ c(H*), c(W) = 2c(T) and c(H) ≤ c(W) (triangle inequality), combined into c(H) ≤ 2c(H*).
Maths → code. General TSP: without the triangle inequality no constant ratio ρ is possible unless P = NP. The reduction gives edges of G cost 1 and non-edges cost ρ|V| + 1; a ρ-approximation then answers Hamiltonian cycle, which the code checks against brute force.
Input size → what's feasible. n ≤ 3000 cities → the Θ(V²) Prim here is about 107 distance evaluations (fine); n = 105 → build the MST from a geometric structure (Delaunay, O(n log n)) instead; the exact optimum needs n ≤ 10 by brute force ((n−1)! tours) or n ≤ 20 with Held–Karp.
Running time. Θ(V²) with the array-based primMst used here (the graph is complete, so E = Θ(V²)); the preorder walk is Θ(V). The optimum in each animation is found by trying all (n−1)! tours, which is only sensible for n ≤ 8.
greedySetCover
An instance of set cover is a finite set X and a family F of subsets of X whose union is X. A cover is a subfamily C ⊆ F whose union is X, and we want the fewest sets. Greedy (see C16 · greedy algorithms) means "take the locally best-looking choice now and never undo it". The procedure keeps U, the uncovered elements, and each round picks the set covering the most elements of U (ties go to the lowest-numbered set on this page; any tie-break is allowed).
Small example. X = {1,…,6}, S1 = {1,2,3,4}, S2 = {1,2,5}, S3 = {3,4,6}. Greedy takes S1 (4 new elements), then S2 (covers 5), then S3 (covers 6): 3 sets. But S2 ∪ S3 already covers everything with 2 sets. Greedy was tempted by the big set S1 and paid for it: ratio 3/2.
Reading the pseudocode. Lines 1–2 start with everything uncovered and nothing chosen. Line 3 loops while something is uncovered. Line 4 is the greedy decision: pick the set with the largest |S ∩ U|. Lines 5–6 mark its elements covered and record it. The animation prints every set's gain so the maximum is visible.
Dart implementation. Index shift: elements are numbered 1..m (kept 1-based on purpose: they are values, not positions), while the family f is a 0-based list, so the set labelled Sk in the animation is f[k-1] and the function returns 0-based set indices.
Maths → code. The harmonic bound: each element pays 1 / (number of new elements covered by the set that first covers it); the prices add up to |C|, every set of an optimal cover pays at most H(|S|) ≤ H(d), and H(d) ≤ ln d + 1. The code computes the prices and checks each step.
Input size → what's feasible. |X|, |F| ≤ 103 → the plain Dart loop (O(|X|·|F|) per round) is fine; Σ|S| ≈ 106 → use bucket queues for O(Σ|S|); the exact optimum needs |F| ≤ 25 (2|F| subsets).
Running time. Naively O(|X|·|F|·min(|X|, |F|)); with counters and buckets it is O(Σ|S|), linear in the input size.
Randomized max-3SAT
A literal is a variable x or its negation ¬x. A clause is an OR of literals (true when at least one literal is true), and a 3-CNF formula is an AND of clauses, each with exactly three literals on three different variables. max-3SAT asks for the truth assignment that makes as many clauses true as possible. A randomized algorithm may flip coins; its guarantee is about the expected value (the average over all coin outcomes; see C05 · probabilistic analysis for indicator variables and linearity of expectation).
Small example. Take the single clause (x1 ∨ ¬x2 ∨ x3). It is false only for x1 = 0, x2 = 1, x3 = 0: exactly 1 of the 2³ = 8 equally likely assignments. So it is satisfied with probability 7/8, and with m such clauses we expect 7m/8 of them to be satisfied.
The algorithm sets every variable to 1 independently with probability 1/2. The three rows below spell that out; the random bits come from a seeded generator so runs repeat exactly.
Reading the pseudocode. Rows 1–2 set each variable by a fair coin, independently; row 3 returns the assignment. The animation then scores each clause: green means at least one literal is 1, red means all three are 0. The last frame compares the single run, the average over 300 other runs, and the brute-force optimum.
Dart implementation. Index shift: variables are numbered 1..n in formulas and in literals (¬x2 is written −2); the code reads literal l as variable x[l.abs() - 1], converting to 0-based storage, and l > 0 means "positive literal".
Maths → code. Clause probability: a clause fails only when all three literals are 0, probability 1/8, so each clause is satisfied with probability 7/8 and E[Y] = 7m/8; the optimum is at most m, so the expected ratio is at most 8/7. The code averages over all 2n assignments for the exact value, then compares a seeded simulation.
Input size → what's feasible. m up to 107 clauses → drawing and scoring is Θ(n + m), trivially fast; exact enumeration of all assignments (used only to check the 7m/8 identity) is for n ≤ 20.
approxMinWeightVc (LP rounding)
In minimum-weight vertex cover each vertex v has a weight w(v) (its price), and we minimize the total weight of the cover. Write it as a 0/1 integer program (x(v) = 1 means v is in the cover, 0 means not): minimize Σ w(v)x(v) subject to x(u) + x(v) ≥ 1 for every edge (u, v). The LP relaxation (linear program, see C29 · linear programming) allows any 0 ≤ x(v) ≤ 1, which makes the problem solvable in polynomial time. Its optimum is a lower bound on the integer optimum, because every real cover is also a feasible fractional solution.
Small example. A triangle a–b–c with all weights 1. Fractional solution: x = ½ everywhere (every edge gets ½ + ½ = 1), cost 3/2. Rounding turns all three up to 1: cover weight 3. The best real cover uses 2 vertices, so the ratio is 3/2 ≤ 2, and 3 ≤ 2 × (3/2), exactly the proof's inequality.
Line 2 of the procedure solves the LP; a real implementation uses a general LP solver. For the tiny graphs here, the page computes the LP optimum by enumerating every assignment of x(v) ∈ {0, ½, 1}, which is enough because the basic optimal solutions of this particular LP are half-integral (every coordinate is 0, ½ or 1).
Reading the pseudocode. Line 1 starts empty; line 2 gets the fractional optimum x̄; lines 3–5 visit every vertex and add it when x̄(v) ≥ ½; line 6 returns. The animation shows each vertex's weight w and its fractional value x̄ so you can see why some vertices are rounded down.
Dart implementation. Index shift: vertices are 0..n−1. The LP solution is stored in halves (0, 1, 2 mean x̄ = 0, ½, 1) so all arithmetic stays in integers; hence the test x[v] >= 1 means x̄(v) ≥ ½.
Maths → code. The rounding bound: rounding every x̄(v) ≥ ½ up gives w(C) ≤ 2·LP* and LP* ≤ OPT. The code checks the LP constraint, that each edge keeps an endpoint, the weight inequality, and shows the integrality gap on Kn (LP* = n/2 versus OPT = n − 1, ratio → 2).
Input size → what's feasible. n ≤ 14 → the {0, ½, 1}n enumeration used on this page (3n candidates) is enough to teach the idea; real graphs with n ≈ 105 → solve the LP with a solver (or by the bipartite-matching reduction), still polynomial.
Running time. One LP solve (polynomial) plus Θ(V) for rounding; the page's 3n enumeration is only a stand-in for a real LP solver on tiny graphs.
mergeLists
The subset-sum procedures below repeatedly combine two sorted lists into one. mergeLists(a, b) returns the sorted list that is the merge of its two sorted input lists with duplicate values removed. It works like the MERGE step of merge sort (C02), and runs in time O(|a| + |b|).
Small example. a = ⟨0, 130⟩ and b = ⟨134, 264⟩. Compare 0 and 134: 0 goes. Compare 130 and 134: 130 goes. a is used up, so the rest of b (134, 264) is appended: ⟨0, 130, 134, 264⟩, the list for S = ⟨130, 134, …⟩ in the example below.
Reading the pseudocode. Two pointers i and j mark the next unused element of each list (the pink markers). Each loop round compares the two pointed-to values and moves exactly one value into the output out (or one shared value, advancing both pointers). When one list runs out, the other's remainder is already sorted and is appended in one go (line 10).
Dart implementation. Index note: the pointers i, j start at 0 in the pseudocode and in Dart, and the loop test "i < |a|" is i < a.length.
Maths → code. Pointers are positions counted from 0: the next unused element of a is a[i], so the loop runs while i < a.length and j < b.length, and a finished list is exactly one whose pointer equals its length.
Maths → code. Each loop pass advances at least one pointer, so mergeLists takes at most |a| + |b| passes and produces at most |a| + |b| values: Θ(|a| + |b|).
Input size → what's feasible. |a| + |b| ≤ 107 → two pointers is instant (107 steps ≈ 0.1 s); re-sorting would cost an extra log factor and ignore the sortedness.
exactSubsetSum
In the subset-sum problem we are given a set S of positive integers and a target t, and want the subset with the largest sum not exceeding t. Let Pi be the set of all sums of subsets of the first i numbers; then Pi = Pi−1 ∪ (Pi−1 + xi) (each old sum either skips xi or uses it), where L + x means "add x to every element of L".
Small example. S = ⟨1, 4, 5⟩, t = 6. L₀ = ⟨0⟩. After coin 1: ⟨0, 1⟩. After coin 4: merge ⟨0, 1⟩ with ⟨4, 5⟩ = ⟨0, 1, 4, 5⟩. After coin 5: merge with ⟨5, 6, 9, 10⟩, drop sums above 6: ⟨0, 1, 4, 5, 6⟩. The largest is 6 = 1 + 5.
Reading the pseudocode. Line 1 starts the list with the empty sum 0. Lines 2–5 are the loop over the numbers: line 3 builds the shifted copy, line 4 merges it in (via mergeLists) and line 5 drops sums above t (they can never come back down because all numbers are positive). Line 6 returns the largest survivor.
Dart implementation. Index shift: the pseudocode's s[i] becomes the loop variable of for (final x in s), so no index arithmetic remains; the successive lists are one variable l that is overwritten each round; "the largest value in L" is l.last because the list is sorted.
Maths → code. Each round can at most double the list: |Li| ≤ 2i. For the numbers 1, 2, 4, …, 512 every subset sum is different, so the lists really do reach 2i (until the target t cuts them off).
Input size → what's feasible. n ≤ 25 or t ≤ 106 → exact lists stay small enough (225 ≈ 3·107); n = 100 with large numbers → use the trimmed version below.
trim
Trimming is the idea that makes the exact procedure fast. We use a trimming parameter δ with 0 < δ < 1. To trim a sorted list L by δ means to remove as many elements as possible so that every removed y still has a stand-in z remaining in the list with y/(1+δ) ≤ z ≤ y.
Small example. δ = 0.1 and L = ⟨10, 11, 12, 15⟩. Keep 10. Is 11 > 10 × 1.1 = 11? No (equal), so 10 stands in for 11. Is 12 > 11? Yes, keep it. Is 15 > 12 × 1.1 = 13.2? Yes, keep it. Result ⟨10, 12, 15⟩.
trim(L, δ) scans a sorted list once. The first value is always kept. Any later value L[i] is dropped when it is at most last·(1+δ), where last is the most recently kept value, because last already represents it: last ≤ L[i] ≤ last·(1+δ).
Reading the pseudocode. Lines 1–3 keep the first value and remember it as last. Line 4 loops over the rest; line 5 is the decision "can last stand in for L[i]?"; lines 6–7 keep L[i] and make it the new last. Line 8 returns the shortened list.
Dart implementation. Index shift: the pseudocode's L[0]…L[m − 1] and loop for i from 1 to m − 1 are l[0]…l[m-1] and for (int i = 1; …); "m ← length of L" is l.length.
Maths → code. trim keeps a subsequence in which consecutive kept values differ by more than a factor 1 + δ, and every dropped value y has a kept z with z ≤ y ≤ (1 + δ)z. Hence at most log1+δ(max) + 2 values survive.
Input size → what's feasible. |L| ≤ 107 → one linear pass, Θ(|L|), instant.
Running time. Θ(m) for a list of m values: one pass, one comparison per value.
approxSubsetSum (an FPTAS)
The procedure is exactSubsetSum plus one trim call per round with δ = ε/2n (n = number of numbers, ε = the accepted error). It returns z, the largest value in the final list; the guarantee is that the optimum t* satisfies t* ≤ (1+ε)·z. Because the running time is polynomial in n, ln t (the input length) and 1/ε, it is a FPTAS (fully polynomial-time approximation scheme). C15 covers the related pseudo-polynomial knapsack dynamic program.
Small example. S = ⟨130, 134, 180, 163⟩, t = 480, ε = 0.40 (so δ = 0.40 / 8 = 0.05): trimming merges nearby sums along the way, and the procedure returns z = 473 against the optimum 477 = 134 + 180 + 163. That is about 0.8% below the optimum, far inside the allowed 40%.
Reading the pseudocode. Same skeleton as exactSubsetSum with one new line 5 that trims the merged list by ε/2n before the over-target sums are dropped (line 6). Line 7 picks the largest surviving value z and line 8 returns it.
Dart implementation. Index shift: as in exactSubsetSum, the xi disappear into a for-in loop; n is only needed for δ = ε/(2n).
Maths → code. The FPTAS guarantee, with δ = ε/2n: (a) the loop invariant that every achievable y ≤ t keeps a representative z in Li with y/(1 + δ)i ≤ z ≤ y; (b) (1 + ε/2n)n ≤ 1 + ε so OPT ≤ (1 + ε)z; (c) |Li| ≤ log1+δ t + 2 = O(n ln t / ε), so the time is O(n² ln t / ε).
Input size → what's feasible. n = 500, t = 109, ε = 0.1 → lists have at most 2·500·ln(109)/0.1 ≈ 2·105 values and the whole run costs about n²·ln t/ε ≈ 5·107 steps (well under a second); the exact algorithm at n = 500 would face up to 2500 sums.
Quiz
Interview questions
Cheat sheet
| Algorithm | Problem | Ratio | Time (best / average / worst) | Space | Key idea / lower bound on OPT | When to use |
|---|---|---|---|---|---|---|
| approxVertexCover | min vertex cover | 2 | Θ(V + E) best, average and worst (each edge handled once) | O(V + E) | Maximal matching: every cover needs one endpoint per matched edge | Need a fast, guaranteed cover on a huge graph |
| approxTspTour | metric TSP | 2 | Θ(V²) best, average and worst (array Prim on a complete graph) | O(V) | MST weight ≤ optimal tour; preorder shortcuts by triangle inequality | Distances obey the triangle inequality (maps, Euclidean) |
| greedySetCover | min set cover | H(d) ≤ ln |X| + 1 | O(Σ|S|) with buckets; naive O(|X|·|F|·min(|X|,|F|)); no best/worst gap | O(Σ|S|) | Charge 1/(new elements) to each covered element | Covering with overlapping sets (sensors, tests, skills); no polynomial algorithm does asymptotically better unless P = NP |
| Random assignment | max-3SAT | 8/7 expected | Θ(n + m) best, average and worst (Θ(n) to draw, Θ(m) to score) | O(n) | Each clause satisfied w.p. 7/8; linearity of expectation | Baseline for max SAT; quick warm start for local search |
| approxMinWeightVc | weighted vertex cover | 2 | LP solve (polynomial) + Θ(V) rounding; no best/worst gap | LP size | LP optimum ≤ integer optimum; round x̄ ≥ ½ up | Vertices have different costs, so unweighted matching does not apply |
| mergeLists | merge two sorted lists | exact | Θ(|a| + |b|) best, average and worst | O(|a| + |b|) | Two pointers; equal values kept once | Sub-step of the subset-sum procedures |
| exactSubsetSum | subset sum | 1 (exact) | best Θ(n) (every number exceeds t, lists stay ⟨0⟩); worst O(2n); O(n·t) when t is small (pseudo-polynomial) | O(min(2n, t)) | Merge shifted lists; drop sums above t | Small n, or small t (pseudo-polynomial) |
| trim | list compression | each dropped y within 1+δ of a kept z | Θ(m) best, average and worst | O(m) | Keep y only if y > last·(1+δ) | Shrink a sorted list of numbers when small relative error is fine |
| approxSubsetSum | subset sum | 1 + ε (FPTAS) | O(n² ln t / ε) worst; typically far less since lists are usually shorter than the bound | O(n ln t / ε) | Trim with δ = ε/2n after every round | Big n or big t and a few percent error is acceptable |