Approximation Algorithms
Seven test suites, three runners
A team's continuous-integration pipeline runs seven test suites that take 6, 8, 4, 5, 2, 3 and 9 minutes. There are three identical runners, each runs one suite at a time, and a suite can't be split. The team wants every suite finished as early as possible: it wants to minimise the makespan, the time at which the last runner finishes.
In general there are jobs with sizes and identical machines. A schedule assigns each job to one machine, a machine's load is the sum of its jobs' sizes, and the makespan is the largest load.
from itertools import product
J, m = [6, 8, 4, 5, 2, 3, 9], 3
def makespan(jobs, machine, m):
loads = [0] * m
for p, i in zip(jobs, machine):
loads[i] += p
return max(loads)
def best_makespan(jobs, m):
"""Try all m ** n schedules (small inputs only)."""
return min(makespan(jobs, s, m) for s in product(range(m), repeat=len(jobs)))
assert sum(J) == 37 and 3 ** 7 == 2187
assert best_makespan(J, m) == 13
Why not just find the best schedule?
Trying every schedule is work: 2,187 schedules here, fine, but for twenty suites, and a real pipeline has hundreds. No shortcut is known. Minimum makespan is NP-hard already for two machines, and so are the other two problems in this module, minimum-weight vertex cover and the travelling salesman tour. This course takes NP-hardness as given, without the reductions: it means that no polynomial-time algorithm solves the problem exactly on every instance unless P = NP.
(Two refinements are worth knowing. For a fixed number of machines, a dynamic program over loads runs in time polynomial in and in the total size , which is pseudo-polynomial, as in module 09's knapsack. And the hardness is about the worst instance: many instances are easy.)
So this module gives up exactness, and keeps a proof.
The model: a factor on every instance
Definition
For a minimisation problem and a number , an -approximation algorithm runs in time polynomial in the input size and, on every instance , returns a feasible solution whose cost satisfies .
The ratio is a worst-case guarantee over all instances, not typical accuracy. A thousand instances where an algorithm is within 1% prove nothing, and one instance above refutes the claim.
But how can we prove when OPT is exactly the number we can't compute? Every proof in this module has the same shape. Find a quantity that we can reason about and that no solution can beat, so , then prove . For makespan, two lower bounds hold for every schedule. Some machine runs the largest job, and the busiest machine carries at least the average load:
For the seven suites that gives (loads are whole numbers, so we may round up), and the search above shows that 13 is achievable. On other instances the lower bound may be strictly below OPT; the proofs only need .
Greedy on the seven suites
Greedy list scheduling takes the jobs in the order given and puts each one on a machine whose load is currently smallest (the lowest-numbered one on a tie). It never looks ahead, so it could even run while suites are still arriving.
Predict: the 9-minute suite started at time 8. Could it have started later than the average load of the other six suites?
No. When it was placed, its runner was the least loaded, so all three runners were at load 8 or more, from the other six suites only. Three runners at 8 or more need at least 24 minutes of those suites, and they have . In general the start is at most , which is here.
The block below checks that the trace follows the rule. It doesn't schedule anything: it takes the runners the figure names and confirms that each one was least loaded at the time.
def follows_greedy(jobs, machine, m):
loads = [0] * m
for p, i in zip(jobs, machine):
if loads[i] != min(loads) or loads.index(min(loads)) != i:
return False
loads[i] += p
return True
greedy_J = [0, 1, 2, 2, 0, 0, 1]
assert follows_greedy(J, greedy_J, m) and makespan(J, greedy_J, m) == 17
assert 3 * 8 <= 37 - 9 # the 9 started at load 8
Why greedy is within
Theorem (Graham, 1966)
For every and every list of jobs with positive sizes, in any order, greedy list scheduling returns a makespan of at most .
Proof. Let machine attain the makespan and let job be the last job placed on it, starting at load , so . When was placed, machine was least loaded, so every machine had load at least , and those loads consist of jobs other than . Hence
Dividing by and adding :
The first term is at most OPT (the average-load bound) and (the largest-job bound), so .
The step not to skip is leaving out of the average. Put it back in and the same argument only gives . The is real, because the bound is tight: for every , some instance meets it exactly. Give greedy jobs of size 1 and then one job of size . The unit jobs spread evenly, load everywhere, and the big job lands on top: makespan . The optimum puts the big job alone and the unit jobs to a machine elsewhere: makespan . The ratio is .
tight = [1] * 6 + [3] # m = 3: six unit jobs, then a 3
assert follows_greedy(tight, [0, 1, 2, 0, 1, 2, 0], m)
assert makespan(tight, [0, 1, 2, 0, 1, 2, 0], m) == 5
assert best_makespan(tight, m) == 3 # 5/3 = 2 - 1/3
Sort first: largest processing time
The tight instance hurts greedy because the big job comes last. LPT (largest processing time first) sorts the jobs by decreasing size, then runs greedy. On the seven suites it places 9, 8, 6, 5, 4, 3, 2 and ends with loads 12, 12 and 13: makespan 13, optimal here.
Theorem (Graham, 1969)
For every and every list of jobs with positive sizes, LPT returns a makespan of at most , and the bound is tight for every .
Sketch. Let be the last job on the busiest machine. Jobs placed after don't change the makespan and removing them can't raise OPT, so assume is placed last, which in LPT order makes it the smallest job. If , the greedy argument gives
Otherwise every job is larger than , so the optimum has at most two jobs per machine, and a case analysis shows LPT is optimal then. Skipped: that case analysis and the refinement to the exact .
LPT is not optimal in general. On three machines, [5, 5, 4, 4, 3, 3, 3] is its tight case:
lpt_J = [2, 1, 1, 2, 2, 0, 0] # runner of each suite of J, in J's order
order = sorted(range(7), key=lambda k: -J[k])
assert follows_greedy([J[k] for k in order], [lpt_J[k] for k in order], m)
assert makespan(J, lpt_J, m) == 13
trap = [5, 5, 4, 4, 3, 3, 3]
assert follows_greedy(trap, [0, 1, 2, 2, 0, 1, 0], m)
assert makespan(trap, [0, 1, 2, 2, 0, 1, 0], m) == 11
assert best_makespan(trap, m) == 9 # {5,4} {5,4} {3,3,3}: 11/9 = 4/3 - 1/9
For every fixed there is even a -approximation whose running time is polynomial in (Hochbaum and Shmoys, 1987), cited here, not proved; its exponent grows as shrinks.
Vertex cover: round the linear program
A different problem. A graph has a weight on each vertex, and a vertex cover is a set of vertices that touches every edge. Find one of minimum total weight.
Write the problem as an integer program with a 0/1 variable for " is in the cover", then relax it (module 15) by allowing fractions:
subject to for every edge , and for every vertex.
Every cover is a feasible point of this LP, so the LP's minimum is a lower bound: . And the LP can be solved in time polynomial in the input's bit length (module 15). The LP optimum is usually fractional. Here it is , of value 12.5.
A classical fact, stated here without proof, makes that easy to check on small graphs: the vertex-cover LP always has an optimal solution with every (every corner of its polytope is of this form; Nemhauser and Trotter, 1974). So the block searches those points, written as :
from fractions import Fraction
H = [(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 5), (5, 6), (6, 3), (4, 6)]
w = [2, 4, 1, 4, 7, 5, 3]
def lp_half(n, edges, w):
"""(LP value, x) over x in {0, 1/2, 1}^n: exact, by the half-integrality fact."""
best = min((sum(wi * yi for wi, yi in zip(w, y)), y)
for y in product((0, 1, 2), repeat=n)
if all(y[u] + y[v] >= 2 for u, v in edges))
return Fraction(best[0], 2), [Fraction(yi, 2) for yi in best[1]]
def cheapest_cover(n, edges, w):
covers = (S for S in product((0, 1), repeat=n) if all(S[u] or S[v] for u, v in edges))
return min(sum(wi for wi, s in zip(w, S) if s) for S in covers)
lp, x = lp_half(7, H, w)
assert lp == Fraction(25, 2) and x == [1, 0, 1, Fraction(1, 2), Fraction(1, 2), Fraction(1, 2), Fraction(1, 2)]
assert cheapest_cover(7, H, w) == 13
Rounding. Keep every vertex with .
Theorem (LP rounding)
For every graph with nonnegative vertex weights, the set of an optimal LP solution is a vertex cover of weight at most .
Proof. Feasibility: every edge has , so one of its ends has and is kept. Cost: each kept vertex has , so , and summing over the kept vertices gives at most .
keep = {v for v in range(7) if x[v] >= Fraction(1, 2)}
assert keep == {0, 2, 3, 4, 5, 6} and sum(w[v] for v in keep) == 22 <= 2 * lp
Rounding keeps weight 22 against the optimum's 13: a ratio of about 1.69, within 2. The lower bound here is 12.5, and every edge of the 4-cycle and chord gets exactly .
Predict: rounding at 0.6 instead of 1/2 would keep fewer vertices. What goes wrong?
On the vertices 3 and 4 both have , so neither is kept, and the edge 3–4 is covered by nobody. The threshold is exactly what guarantees for one end; any higher threshold can lose feasibility.
No polynomial-time algorithm with ratio , for a constant , is known for vertex cover. Under the Unique Games Conjecture none exists (Khot and Regev, 2008); that is a conditional result, cited here.
Without weights: a matching is a lower bound
When every weight is 1, there is a cheaper route that needs no LP. Scan the edges in any order. Whenever an edge has neither end taken yet, take both ends.
Theorem (maximal matching)
For every graph and every scan order, the taken vertices form a vertex cover with at most twice as many vertices as a smallest one.
Proof. Every edge is covered, since when it was scanned an end was already taken or both were taken then. The edges that triggered a "take both" share no vertex (their ends were untaken when they were chosen), so they form a matching . Any cover needs a separate vertex for each edge of , so , and the algorithm took .
The lower bound is now , the size of the matching the scan built. On , scanning in the listed order picks the edges 0–1, 2–3 and 4–5:
M = [(0, 1), (2, 3), (4, 5)]
ends = [v for e in M for v in e]
assert len(set(ends)) == 6 # a matching
assert all(u in ends or v in ends for u, v in H) # nothing left to add: a cover
assert cheapest_cover(7, H, [1] * 7) == 4 # 6 <= 2 * 4
The matching bound ignores weights, and with weights it fails: a cheap vertex matched to an expensive one drags the expensive one in. So the scan is a 2-approximation for the unweighted problem only; with weights, use LP rounding.
Metric travelling salesman: double a tree
The last problem: visit points and return to the start, as cheaply as possible. The distance must be a metric: , symmetric, and obeying the triangle inequality . Here six points on a street grid use the Manhattan distance , which is a metric.
Tree doubling. Build a minimum spanning tree (Prim's or Kruskal's algorithm, from a data-structures course). Walk around it depth first, which crosses every tree edge twice, and list each point the first time the walk reaches it (a preorder). Return to the start.
Theorem (tree doubling)
For every set of points under a metric, the preorder tour of a minimum spanning tree has length at most .
Proof. Delete one edge from an optimal tour: what is left is a path through every point, which is a spanning tree, so . The full walk around has length . The preorder tour visits the points in the walk's order but jumps straight past points already seen. Each jump replaces a stretch of the walk by one direct hop, and by the triangle inequality (applied along the stretch) the hop is no longer than the stretch.
from itertools import combinations, permutations
P = [(2, 5), (3, 8), (8, 8), (5, 3), (9, 3), (3, 6)]
d = lambda a, b: abs(P[a][0] - P[b][0]) + abs(P[a][1] - P[b][1])
length = lambda t: sum(d(t[k], t[(k + 1) % len(t)]) for k in range(len(t)))
def connected(edges, n):
seen, todo = {0}, [0]
while todo:
a = todo.pop()
for b in {v for e in edges if a in e for v in e} - seen:
seen.add(b)
todo.append(b)
return len(seen) == n
# A stand-in for Prim's algorithm: the lightest of all 3,003 sets of 5 edges that connect the points.
mst_weight = min(sum(d(a, b) for a, b in T)
for T in combinations(combinations(range(6), 2), 5) if connected(T, 6))
T = [(0, 5), (5, 1), (1, 2), (0, 3), (3, 4)]
tour = [0, 3, 4, 5, 1, 2] # preorder from 0, children in increasing order
assert sum(d(a, b) for a, b in T) == mst_weight == 18
assert length(tour) == 34 <= 2 * mst_weight
assert min(length((0,) + p) for p in permutations(range(1, 6))) == 24 # the optimum
The tour 0, 3, 4, 5, 1, 2 has length 34, within the promised 36; the best tour has length 24 (ratio about 1.42). Christofides' algorithm (1976) adds a minimum-weight perfect matching on the tree's odd-degree vertices instead of doubling every edge and reaches ; a 2021 randomized algorithm (Karlin, Klein and Oveis Gharan) improves , in expectation, by a constant above . Both are cited, not proved.
The triangle inequality is not a technicality. Without it, for any constant , no polynomial-time -approximation exists for the travelling salesman problem unless P = NP (Sahni and Gonzalez, 1976).
Complexity
All times are worst case in the word-RAM model, with sizes, weights and coordinates that fit in a machine word; is the number of jobs, vertices or points.
| Algorithm | Ratio, on every instance | Time |
|---|---|---|
| greedy list scheduling | , tight | , or with a heap |
| LPT | , tight | with a heap |
| LP rounding, weighted cover | 2 | polynomial (the LP), then |
| maximal matching, unweighted cover | 2 | |
| tree doubling, metric TSP | 2 | with array-based Prim |
Exact search costs schedules, vertex sets or tours. The ratios are proved; the tight instances show that greedy's and LPT's can't be improved.
Measure the claim
On random small instances generated with seed 451 (200 job lists with , at most 7 jobs of size 1 to 20; 200 graphs of at most 8 vertices with weights 1 to 9; 100 sets of at most 7 points on a grid), the worst ratios observed against exact brute force were:
| Algorithm | Worst observed | Proved bound |
|---|---|---|
| greedy list scheduling | 1.412 | for , for |
| LPT | 1.083 | for , for |
| LP rounding | 2.0 | 2 |
| maximal matching | 2.0 | 2 |
| tree doubling | 1.467 | 2 |
These are measurements on these instances, not theorems. Random instances rarely find the worst case: greedy never came near , and LPT not near , although the tight instances above meet both bounds exactly. The two cover algorithms did reach 2, on tiny graphs. The matching scan takes both ends of a single edge, where one end suffices. Rounding reached 2 on a triangle with weights 3, 6 and 3, where the LP has several optimal solutions and the search returned everywhere (value 6): rounding keeps all three vertices, weight 12, while the cover costs 6. Worst cases are built, not sampled.
A problem that looks different
A courier firm packs parcels of sizes between 0 and 1 into vans of capacity 1, and wants to use as few vans as possible. Nobody can compute the minimum for a day's thousands of parcels. What number is surely below the minimum, and what simple packing rule could you charge to it? It is not solved here, and it is not the lab's last problem.
Practise
In the lab you schedule jobs greedily while the drawing shows why the last job can't start too late; predict and then watch the cover that an edge scan builds on a new graph, next to the LP's rounding; write tree doubling and test it against brute force; build instances on which LPT meets its bound exactly and measure how far random instances stay from it; and solve a placement problem that doesn't say what it is.
Recap
You can now: say what an -approximation guarantees; prove greedy list scheduling -approximate and build its tight instance; round the vertex-cover LP and prove the factor 2; prove the maximal-matching cover and tree doubling 2-approximate, naming the lower bound each one uses.
Invariant: every proof charges the algorithm to a lower bound: and , with the largest job and the average load, the LP value, the size of a matching, or the weight of a minimum spanning tree.
Complexity achieved: polynomial time (at most here, plus solving one LP) within factor , or 2 of the optimum on every instance, against , and for exact search.
Failure mode: trusting a ratio measured on random instances. Worst cases are constructed, and the tight families meet the bound exactly.
In real software: NetworkX's approximation package has min_weighted_vertex_cover, a
2-approximation for weighted vertex cover by the local-ratio method, and christofides for
metric tours; see the module notes for versions.
Retrieval (module 17): on a bipartite graph, does rounding the vertex-cover LP ever lose anything? What does total unimodularity say about the LP's corners there?
Check yourself
- Why is greedy list scheduling within of optimal, and where does the come from?
- Round the vertex-cover LP at 0.6, or drop the triangle inequality from the tour problem. What breaks in each proof?
- On , compare LP rounding, the matching cover and the optimum. Which lower bound does each proof charge to, and which algorithm would you use with weights?
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.