Skip to main content

Walkthrough — Module 22 (read after your stage is green)

Design commentary on the reference implementation (reference/src/m22_hamilton.rs). Not needed to pass — only to deepen. Each section is the "compare with Knuth's answer" step made explicit.

Stage 1: Hamiltonian paths by backtracking​

Shape. One recursive extend(mat, start, path, used, want_cycle) serves both hamiltonian_path and hamiltonian_cycle; the single want_cycle flag decides the acceptance test at the leaf (path.len() == n). Reusing the engine keeps the invariant in one place and means a bug can only be fixed once. The count uses a separate count_cycles recursion because it must exhaust all leaves rather than return at the first.

The invariant. At every entry to extend, path is a simple path in the graph and used[v] is exactly path.contains(v). push + used[v]=true before the recursive call and pop + used[v]=false after it keep the two in lockstep — exact state restoration. This is why backtracking needs no copying of the whole state: the mutations are perfectly undone. Steal this pattern; it is the difference between an O(1)-per-node search and an O(n)-per-node one.

Why fix start 0 for cycles. Every Hamiltonian cycle passes through every vertex, so in particular through 0; searching from 0 alone loses no cycle and saves an n× factor over trying all starts. For paths we cannot fix the start (a path need not touch any particular vertex first), so we loop over starts.

Divide by two, and only by two. count_hamiltonian_cycles counts directed closed walks from the fixed start 0, then halves. The halving is exact for every graph with n ≥ 3 because each undirected cycle is walked in exactly two directions from 0 — never one (a cycle has a well-defined reverse) and never more (the start is fixed). That is a counting-by-symmetry argument: fix a representative, divide by the orbit size.

Beating the naive version. The naive approach generates all n! permutations and filters; ours prunes the instant an edge is missing, so on sparse graphs (Petersen: 3-regular) it visits a tiny fraction of the tree. The adjacency_matrix precomputation trades O(n²) space for O(1) adjacency tests inside the hot loop — worth it because the loop runs far more often than n².

Stage 2: Warnsdorff's heuristic — the knight's tour​

Shape. knight_moves lists destinations in a fixed offset order; that order is not cosmetic — it is the deterministic tie-break that makes warnsdorff_tour reproducible. Change the offset order and you change which starts succeed. Encoding the tie-break in a stable data order (rather than a scattered if) is an idiom worth stealing.

The greedy choice, as a min over a pair. The core is best = min over unvisited neighbors of (onward_degree, square). Folding the tie-break into the second component of the tuple means one comparison handles both the primary rule (fewest onward moves) and the tie-break (smaller index), with no special-casing. The match best { … } fold expresses "keep the running minimum" without a sentinel usize::MAX.

The invariant that makes it fast (not correct). Warnsdorff has no correctness invariant — it is a heuristic and can fail. What it has is a progress guarantee: each iteration marks one square, so it terminates in board² steps regardless of success. Returning None on a dead end is a first-class outcome, not an error; the reference treats "stuck" and "done" as the two normal loop exits.

Beating a naive tour search. A backtracking tour solver is correct but can be slow on large boards; Warnsdorff is O(board²) and usually wins outright. The reference's honesty is the point: its doc comment and unit test record specific failures (5×5 sq 2, 8×8 sq 24) where a tour provably exists — so you never mistake this fast tool for a guarantee. The right production design pairs the two: try Warnsdorff, fall back to backtracking on None.

Stage 3: Hamiltonian cycles on the hypercube are Gray codes​

Shape. Three tiny functions, and that tininess is the lesson: because the hypercube has so much structure, the Hamiltonian cycle needs no search at all — gray_code_cycle is the single expression k ^ (k >> 1). When you can recognize structure, you replace an exponential search with a closed form.

The invariant. is_hamiltonian_cycle_on_hypercube verifies two things: a permutation (all 2^d vertices once, via a seen array) and the cyclic single-bit-step property. The subtlety is the wrap: cycle[(i+1) % n] includes the closing edge from last back to first. Forgetting the % n would validate a Hamiltonian path while claiming a cycle — a classic off-by-one that the tests catch by construction.

Why count_ones() == 1 is the whole story. Two hypercube vertices are adjacent iff their XOR is a power of two, i.e. has exactly one set bit. So the adjacency test, the Gray-code "changes one bit" property, and "is a hypercube edge" are literally the same predicate. The reference leans on that identity rather than re-deriving three separate checks.

Beating the naive version. You could run stage 1's general backtracking on Q_d to find a Hamiltonian cycle — and it would work — but that is exponential effort to rediscover a formula. Recognizing Q_d's cycle as the reflected Gray code is the difference between O(2^d)-search and O(2^d)-write-down.

Stage 4: Held–Karp — shortest Hamiltonian path by bitmask DP​

Shape. A flat dp[1<<n][n] table filled in a single forward pass, "push" style: from each reachable (S, j) we relax every extension (S|1<<k, k). The push form reads naturally as "extend this path by one vertex" and avoids the inner min needing to know its predecessors explicitly.

The invariant / evaluation order. The correctness of the single increasing for s in 1..full loop rests on one fact: S | (1<<k) > S whenever bit k was clear. So when we read dp[S][j] it is already final — every state is completed before any state that depends on it. That is a topological order for free, no recursion or memo table needed. The if cur >= INF { continue } guard skips unreachable states so INF + weight never overflows (and INF = u64::MAX/4 leaves headroom even without the guard).

Why the subset dimension is the whole trick. Brute force carries the entire order of visited vertices — n! of them. Held–Karp observes the future depends only on the set visited and the current vertex, collapsing n! histories into 2^n subsets. The bitmask (Module 13) makes that subset a machine word, so S\{j} and S∪{k} are single instructions. The reference cycle variant fixes dp[1][0]=0 and forbids stepping onto 0 mid-tour (for k in 1..n), then closes with + d[j][0] — the minimal change from path to TSP.

Beating the naive version. The stage-4 tests keep an explicit brute-force permutation enumerator as an oracle for n ≤ 9; the DP agrees with it exactly while running in O(2^n n²) instead of O(n·n!). The bench.rs example makes the ~2×-per-city growth visible — the honest face of NP-hardness, and the reason the lesson spends real time on heuristics and approximation.