Algorithm Design and Analysis

Approximation Algorithms

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.

6 0 8 1 4 2 5 3 2 4 3 5 9 6
The seven suites J, in the order they arrive (minutes). Three runners; the total is 37 minutes of work.

In general there are n jobs with sizes p1,…,pn and m 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 mn work: 2,187 schedules here, fine, but 320=3,486,784,401 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 n and in the total size ∑pj, 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 α≥1, an α-approximation algorithm runs in time polynomial in the input size and, on every instance I, returns a feasible solution whose cost satisfies ALG(I)≤α·OPT(I).

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 ALG≤α·OPT when OPT is exactly the number we can't compute? Every proof in this module has the same shape. Find a quantity L that we can reason about and that no solution can beat, so L≤OPT, then prove ALG≤αL. 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:

OPT≥maxjpj,OPT≥1m∑jpj.

For the seven suites that gives max(9,⌈37/3⌉)=13 (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 L≤OPT.

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.

+6 +8 +4 +5 +2 +3 +9 runner 0 runner 1 runner 2 6 0 0 6 8 0 6 8 4 6 8 9 8 8 9 11 8 9 11 17 9
Greedy list scheduling of J: one row per suite, in arrival order, showing the three loads after it is placed; the shaded cell is the runner that took it (the least loaded, lowest number on a tie). The last suite, 9 minutes, starts at load 8 on runner 1 and ends at 17, against a lower bound (and optimum) of 13.
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 37−9=28. In general the start is at most (∑kpk−pj)/m, which is 28/3≈9.33 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 2−1/m

Theorem (Graham, 1966)

For every m≥1 and every list of jobs with positive sizes, in any order, greedy list scheduling returns a makespan of at most (2−1/m)·OPT.

Proof. Let machine i attain the makespan and let job j be the last job placed on it, starting at load s, so ALG=s+pj. When j was placed, machine i was least loaded, so every machine had load at least s, and those loads consist of jobs other than j. Hence

ms≤∑kpk−pj.

Dividing by m and adding pj:

ALG≤1m∑kpk+(1−1m)pj.

The first term is at most OPT (the average-load bound) and pj≤OPT (the largest-job bound), so ALG≤(2−1/m)OPT. ◻

The step not to skip is leaving pj out of the average. Put it back in and the same argument only gives 2OPT. The −1/m is real, because the bound is tight: for every m, some instance meets it exactly. Give greedy m(m−1) jobs of size 1 and then one job of size m. The unit jobs spread evenly, load m−1 everywhere, and the big job lands on top: makespan 2m−1. The optimum puts the big job alone and the unit jobs m to a machine elsewhere: makespan m. The ratio is (2m−1)/m=2−1/m.

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 m≥1 and every list of jobs with positive sizes, LPT returns a makespan of at most (43−13m)OPT, and the bound is tight for every m.

Sketch. Let j be the last job on the busiest machine. Jobs placed after j don't change the makespan and removing them can't raise OPT, so assume j is placed last, which in LPT order makes it the smallest job. If pj≤OPT/3, the greedy argument gives

ALG≤OPT+pj≤43OPT.

Otherwise every job is larger than OPT/3, 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 −1/(3m).

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 ε>0 there is even a (1+ε)-approximation whose running time is polynomial in n (Hochbaum and Shmoys, 1987), cited here, not proved; its exponent grows as ε shrinks.

Vertex cover: round the linear program

A different problem. A graph G=(V,E) has a weight wv>0 on each vertex, and a vertex cover is a set S of vertices that touches every edge. Find one of minimum total weight.

0 1 2 3 4 5 6
Graph H: a triangle 0 1 2, the edge 2-3, and a 4-cycle 3 4 5 6 with the chord 4-6. Weights w = 2, 4, 1, 4, 7, 5, 3 for vertices 0 to 6. Green: the cheapest cover {0, 2, 4, 6}, weight 13.

Write the problem as an integer program with a 0/1 variable xv for "v is in the cover", then relax it (module 15) by allowing fractions:

minimise∑vwvxv

subject to xu+xv≥1 for every edge uv, and 0≤xv≤1 for every vertex.

Every cover is a feasible point of this LP, so the LP's minimum is a lower bound: LP≤OPT. 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 x=(1,0,1,12,12,12,12), 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 xv∈{0,12,1} (every corner of its polytope is of this form; Nemhauser and Trotter, 1974). So the block searches those points, written as y=2x:

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 xv≥12.

Theorem (LP rounding)

For every graph with nonnegative vertex weights, the set {v:xv≥12} of an optimal LP solution x is a vertex cover of weight at most 2LP≤2OPT.

Proof. Feasibility: every edge has xu+xv≥1, so one of its ends has x≥12 and is kept. Cost: each kept vertex has 1≤2xv, so wv≤2wvxv, and summing over the kept vertices gives at most 2∑vwvxv=2LP. ◻

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 12+12.

Predict: rounding at 0.6 instead of 1/2 would keep fewer vertices. What goes wrong?

On H the vertices 3 and 4 both have x=12, so neither is kept, and the edge 3–4 is covered by nobody. The threshold 12 is exactly what xu+xv≥1 guarantees for one end; any higher threshold can lose feasibility.

No polynomial-time algorithm with ratio 2−ε, for a constant ε>0, 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 M. Any cover needs a separate vertex for each edge of M, so OPT≥|M|, and the algorithm took 2|M|. ◻

The lower bound L is now |M|, the size of the matching the scan built. On H, 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 n points and return to the start, as cheaply as possible. The distance d must be a metric: d(x,x)=0, symmetric, and obeying the triangle inequality d(x,z)≤d(x,y)+d(y,z). Here six points on a street grid use the Manhattan distance |x1−x2|+|y1−y2|, which is a metric.

Tree doubling. Build a minimum spanning tree T (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.

2 2 5 5 4 0 1 2 3 4 5
Points 0 (2,5), 1 (3,8), 2 (8,8), 3 (5,3), 4 (9,3) and 5 (3,6) under the Manhattan distance. Blue: a minimum spanning tree T, weight 2 + 2 + 5 + 5 + 4 = 18.
5 4 9 2 5 9 0 1 2 3 4 5
The preorder tour 0 3 4 5 1 2 and back to 0: length 5 + 4 + 9 + 2 + 5 + 9 = 34, within 2 w(T) = 36. The hops 4 to 5 and 2 to 0 are the shortcuts past points already visited.

Theorem (tree doubling)

For every set of points under a metric, the preorder tour of a minimum spanning tree has length at most 2w(T)≤2OPT.

Proof. Delete one edge from an optimal tour: what is left is a path through every point, which is a spanning tree, so w(T)≤OPT. The full walk around T has length 2w(T). 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 3/2; a 2021 randomized algorithm (Karlin, Klein and Oveis Gharan) improves 3/2, in expectation, by a constant above 10−36. 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; n is the number of jobs, vertices or points.

Algorithm Ratio, on every instance Time
greedy list scheduling 2−1/m, tight O(nm), or O(nlogm) with a heap
LPT 4/3−1/(3m), tight O(nlogn) with a heap
LP rounding, weighted cover 2 polynomial (the LP), then O(n)
maximal matching, unweighted cover 2 O(n+|E|)
tree doubling, metric TSP 2 O(n2) with array-based Prim

Exact search costs mn schedules, 2n vertex sets or (n−1)!/2 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 m∈{2,3}, 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 10×10 grid), the worst ratios observed against exact brute force were:

Algorithm Worst observed Proved bound
greedy list scheduling 1.412 3/2 for m=2, 5/3 for m=3
LPT 1.083 7/6 for m=2, 11/9 for m=3
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 5/3, and LPT not near 11/9, 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 x=12 everywhere (value 6): rounding keeps all three vertices, weight 12, while the cover {0,2} 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 (2−1/m)-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: ALG≤αL and L≤OPT, with L 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 O(n2) here, plus solving one LP) within factor 2−1/m, 4/3−1/(3m) or 2 of the optimum on every instance, against mn, 2n and (n−1)!/2 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

  1. Why is greedy list scheduling within 2−1/m of optimal, and where does the −1/m come from?
  2. Round the vertex-cover LP at 0.6, or drop the triangle inequality from the tour problem. What breaks in each proof?
  3. On H, 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.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…