Skip to main content

Walkthrough — Module 20 (read after each stage is green)

Design commentary on reference/src/m20_networks.rs. This is the "compare with Knuth's answer" step: why the reference is shaped the way it is, the invariant that makes each piece correct, and the idioms worth stealing. You do not need any of this to pass — only to deepen.

Stage 1: Comparison lower bounds by decision trees​

min_comparisons_lower_bound deliberately avoids f64::log2. Floating-point logs are treacherous exactly where we care most: lg 8! = lg 40320 ≈ 15.299, but near a true power of two a rounding error of 10⁻¹⁰ flips the ceiling. So the reference builds n! in a u128 and finds the least c with 2^c ≥ n! by shifting — pure integer arithmetic, exact by construction. This is the general lesson: when a value is defined by an integer inequality (smallest c with 2^c ≥ N), compute it with integers, never by rounding a float.

sort_and_count is a binary insertion sort, chosen because its comparison count is transparent and always at least the information bound's neighbourhood — it makes the "watch a real sort sit above ⌈lg n!⌉" demo honest. Note the count is data-independent (binary search always probes ⌈lg(i+1)⌉ positions), which is a nice contrast with Stage 2's data-dependent merge insertion.

Stage 2: Merge insertion (Ford–Johnson)​

The one design decision that makes this tractable: sort indices, not values. mi_sort(idx, key, comps) orders the indices idx by key[idx]. Why? Because step M1 pairs elements and step M2 recursively sorts the larger of each pair — and after that recursion we must still know each larger element's smaller partner. If you recurse on bare values you lose the association; recursing on indices lets partner[hi] = lo survive, since indices are stable identities.

The invariant that makes M3 correct: the chain is always sorted, and b_i is known to be < a_i. So inserting b_i means binary-searching only the prefix of the chain before a_i's current position — never the whole thing. The reference finds that position with chain.iter().position(...); O(n) per insertion, but n ≤ 50 in the tests, so clarity wins over a fancier index map.

jacobsthal_order is where the optimality lives. It emits the 1-based indices 2..=maxi grouped by Jacobsthal numbers (1,3,5,11,21,…, generated by jcur += 2*jprev), each group from the top index down. The invariant it maintains — that when index i in group k is inserted, the searchable prefix has size 2^k − 1 — is what forces every binary search to cost exactly k with nothing wasted. Removing this ordering (inserting left to right) still sorts but blows the comparison budget; the reference's merge_insertion_meets_worst_ case_bound test would then fail because the exhaustive worst case would exceed F(n). The odd straggler is folded in as a phantom "highest index" with an unbounded search — Knuth's trick of treating the leftover as a b whose partner is +∞.

An idiom worth stealing: pass a &mut u64 counter down the recursion instead of returning counts and summing them. It keeps every comparison site a single *comps += 1 next to the actual <, so the count cannot drift from the code.

Stage 3: Sorting networks — Batcher's odd-even merge​

The reference is a near-verbatim transcription of Batcher's recursion into a Vec<(usize,usize)>. The subtle line is the merge sweep, while i + r < lo + n { push((i, i+r)); i += m; } — this is the Python range(lo+r, lo+n-r, m) rewritten to avoid usize underflow (lo+n-r is fine here, but i + r < lo + n reads more safely). Getting the bounds wrong yields a network that is one comparator short and silently fails only on some inputs — which is exactly why Stage 3 pins the count (19 for n=8, 63 for n=16) as well as sortedness: the count is a cheap structural checksum.

apply_network is the whole point of obliviousness in three lines: it walks a fixed list and compare-exchanges. No branch decides which comparator comes next. Note comparators may list either wire first — apply_network always puts the min on the first-listed wire, so Stage 4's descending bitonic comparators (j, i) with j > i reuse the same primitive with zero special-casing.

network_depth computes the critical path with an ASAP schedule: each wire's ready layer is the earliest it is free, a comparator lands on max(ready[i], ready[j]), and both wires advance. Because the comparator list is already in a valid execution order, this single forward pass yields the true minimum depth — no need to build the dependency DAG explicitly. That it matches t(t+1)/2 is a non-trivial confirmation that the transcription is faithful.

Stage 4: The zero-one principle and bitonic sorting​

sorts_all_zero_one is the constructive content of Theorem Z: it enumerates 2^n bitmasks (guarded by assert!(n < 32) so the 1u32 << n is safe) and checks each is sorted after the network runs. This is the 2^n certificate that replaces the n! brute force — the reason we can verify a 16-wire network at all. The dropping_a_comparator_breaks_it test exercises the converse value of the principle: a defective network is caught by some 0-1 counterexample, and the certificate finds it.

bitonic_sort_network mirrors the odd-even structure but carries a direction flag. The one idiom worth internalizing: a "descending comparator" is not a new kind of device — it is the same compare-exchange with its wires named in the other order, (i+m, i). Encoding direction in the tuple order rather than in apply_network keeps the applier dumb and universal, which is the whole spirit of oblivious sorting. The reference uses m = n/2 for the greatest power of two below n because n is constrained to a power of two — for general n you would take the greatest power of two strictly less than n, and the zero-one argument still goes through.