Skip to main content

Walkthrough — Module 17: ZDDs and Exact Covering with Colors

Read this AFTER a stage is green — it explains how the reference solution is built and why.

Stage 1: the Zdd arena: sinks, single, queries, union​

The whole ZDD lives in one place: mk. Its first line, if hi == Ref(0) { return lo }, is the entire difference from a BDD — and note what it does not do: there is no if lo == hi { return lo }. A ZDD node with LO = HI is kept, because it means "v is optional." Everything downstream (canonicity, sparsity, the no-2^skip counting) is a consequence of that one asymmetry. The arena seeds the two sinks with a closure so both get the sentinel var u32::MAX, which is what lets level compare any node against a sink as "sinks sit below every variable" — the top-variable recursions rely on that ordering without a special case.

The queries are each shaped by the semantics. count_sets is the naked c(LO)+c(HI) with no 2^skip factor, because a skipped variable in a ZDD is absent (one possibility), not free (two) — the memo makes it O(Z(f)). contains_set sorts and dedups the input, then walks: the decisive line is if i < s.len() && s[i] < n.var { return false } — a wanted element smaller than the current node's variable was zero-suppressed away, so no member below contains it, answered in O(1). union's design is the template for stage 2: trivial cases first (∅ ∪ g = g, f ∪ f = f via the f == g check), a commutative memo key (if f.0 <= g.0), then expand on the smaller top variable — and when only f tests v, the recursion is mk(v, union(f_lo, g), f_hi) precisely because g contributes nothing to the v-level.

Stage 2: the family algebra​

All three operations share the shape of union but diverge exactly where the set semantics diverge, and the reference makes each divergence a one-liner you can read against §2's table. intersect: when only f tests v, the answer is intersect(f_lo, g) — f's entire HI branch dies, because no set of g contains v so nothing in f's "contains v" side can survive the intersection. diff: the mirror asymmetry — when only f tests v, mk(v, diff(f_lo, g), f_hi) keeps f's HI (g cannot cancel a set g doesn't reach), but when only g tests v it is diff(f, g_lo) (g's HI is idle). The load-bearing detail is the memo key: union/intersect/join normalize (f, g) because they are commutative, but diff's key is (Op::Diff, f, g) in order — a commutative key here would return f\g when you asked for g\f, and the semiring-law tests would fail while the brute-force mirror still passed (the classic bug the lesson warns about).

join is the richest and the one idiom most worth internalizing. Both-test-v expands to four sub-joins distributed over the splits, and because v·v = v the three that contribute a v collapse into one HI via two union calls: h = union(union(join(f_hi,g_hi), join(f_hi,g_lo)), join(f_lo,g_hi)). That join calls union is why the memo is keyed by an Op tag and shared across operations — the recursion is genuinely mutually recursive, and one table serves all of it.

Stage 3: matchings_zdd, independent_sets_zdd​

Both public functions are thin adapters over one private engine, conflict_free_count(n_vars, conflicts), which is the G1–G3 recipe verbatim: build the power set P by folding join(P, union(unit, single(v))) over the variables, then for each conflict fold diff(fam, join(P, {{u,v}})), then count_sets. The design win is that the two counters differ only in how they compute conflicts and what n_vars means — matchings_zdd runs a nested loop over edge pairs pushing a conflict when they share an endpoint (and asserts each edge joins two distinct in-range vertices), while independent_sets_zdd passes the edges themselves as the conflict list with n_vars = n_vertices. Everything hard already happened in stage 2; this stage is proof that a proved algebra lets you assemble a graph counter out of four calls and get Fibonacci, Lucas, and telephone numbers for free.

The subtle correctness point the reference gets right: the "bad" family for a conflict is join(P, {{u,v}}), i.e. every subset containing both u and v, not just {{u,v}} itself — subtracting only the singleton pair would leave larger violating sets in place. Using the full power set P as the multiplier is what makes one diff per conflict remove all members violating it.

Stage 4: Xcc​

The reference is Module 09's four-way-ring dancing links with the color machinery layered on so that the O(1)-undo discipline survives bit for bit. The constructor encodes the primary/secondary split structurally: the root ring threads only headers 1..=n_primary (secondary headers are horizontally self-linked, reached only through their vertical lists), so choose_item physically cannot propose a secondary item — that is Knuth's N₁ boundary, realized as a ring topology instead of an index comparison. Each node carries color: 0 for primary, c+1 for a secondary node with user color c, −1 once purified.

The heart is the pair hide/purify and their inverses. hide and unhide both guard with if self.color[q] >= 0, skipping purified nodes so that a node marked −1 by a purify stays linked through an enclosing hide/unhide — this is what fact 3 of §4 (LIFO exactness) needs to be true. purify(p) fixes the item to color c: same-color nodes become −1 but stay linked (their options are compatible and must remain choosable), different-color nodes are hided wholesale (unlinking their rows from every primary list so C5 can never pick them again). commit dispatches on color — cover a primary, purify a live secondary, do nothing on −1 (an equal-color commitment already fixed the item, so there is nothing to undo either). The search is Algorithm X with commit/uncommit substituted for cover/uncover, committing rightward in C5 and uncommitting leftward in C6 so undo exactly reverses do; solve_all restores the structure completely, which is why nearly every stage-4 test solves twice and demands identical output.