workshop private

← all viz

Unlink

viz · created 2026-10-12

Dancing Links drawn from its own pointers, because the trick is that there is no undo log — cover writes to a node's two neighbours and never touches the node, so the removed node still points into the list and uncover just reads it back. A 2.4-billion-update pentomino enumeration allocates nothing and hands the matrix back bit-identical.

algorithmscanvasinterview-prep

Every backtracking search has to undo things. The usual ways to afford that are a trail of changes, a stack of snapshots, or copying the state at each node — all of which cost memory that grows with depth. Knuth’s Dancing Links costs nothing, and the reason is four lines long.

The four writes

To remove node j from the vertical list it sits in:

j.D.U = j.U;
j.U.D = j.D;

Two writes, both to j’s neighbours. j.U and j.D are not touched. So the list no longer contains j, and j still points at exactly where it was. Putting it back is:

j.D.U = j;
j.U.D = j;

The removed node is the undo record. Nothing was saved anywhere, because nothing was lost. Knuth’s line for it is that the operations are “quadrilly reversible”; the demo’s version is that a removed node is drawn hollow with its two stale pointers still dashed in, pointing at neighbours that have stopped pointing back.

That is the only idea here. Algorithm X — pick an uncovered column, try each row in it, recurse — is three lines of ordinary backtracking on top.

Four instances, one search:

instancerowscolumnsnodeswhat a column iswhat a row is
Knuth’s matrix6716a letter A–Ga subset of letters
sudoku7293242,916a cell, or a digit in a row/column/boxplacing digit d at r,c
n queensn²2n primary + (4n−2) secondary4n²a rank, file or diagonala queen on one square
pentominoes2,05660 cells + 12 pieces12,336a board cell, or a pieceone placement of one piece

The sudoku view draws a 9×9 grid, but the grid is a renderer. Nothing under it has heard of sudoku; it is covering columns. Modelling the problem is all of the work, and it is the part that never appears in the algorithm.

node scripts/measure.mjs runs all of them. Every line below is its output.

The matrix comes back

instancesolutionsupdatesrows triedmsunlinksrelinksrestored
Knuth’s matrix134501717yes
sudoku, 30 clues1918510459459yes
sudoku, 17 clues11,818640909909yes
8 queens9233,3481,198116,67416,674yes
12 queens14,2009,976,806327,8121634,988,4034,988,403yes
pentominoes 3×20821,479,49032,64318310,739,74510,739,745yes
pentominoes 8×8, hole520198,556,800289,3261,51799,278,40099,278,400yes
pentominoes 4×151,472542,817,120843,8515,869271,408,560271,408,560yes
pentominoes 5×124,0401,482,565,1002,255,94126,110741,282,550741,282,550yes
pentominoes 6×109,3562,428,015,9403,620,98719,4211,214,007,9701,214,007,970yes

“Restored” is a checksum, not a claim. checksum() is FNV-1a over every L, R, U, D and size in the structure. Taken before the search and after it, those two values are compared, and the 6×10 enumeration — 1.2 billion unlinks, 1.2 billion relinks — comes back to 0x42302561 both times.

The solution counts are the arithmetic that says the model is right rather than merely consistent. Pentomino tilings are quoted in the literature up to each board’s symmetry, so the search should find exactly that many times the group size, and it does:

boardfound÷ symmetrypublished
6×109,356÷4 = 2,3392,339
5×124,040÷4 = 1,0101,010
4×151,472÷4 = 368368
3×208÷4 = 22
8×8 with a 2×2 hole520÷8 = 6565

n-queens lands on 4, 92, 724, 2,680, 14,200 for n = 6, 8, 10, 11, 12. The twelve pentominoes come out to 63 orientations between them. A mistyped piece or an off-by-one in the constraint mapping would show up in all of those at once, which is the point of having them.

The search allocates nothing

The matrix is the only memory. There is no trail, no snapshot stack, no copy per node — so the footprint of a search is the footprint of the matrix, and the depth it reaches does not enter into it. Measured around the full 6×10 enumeration, with --expose-gc so the figure settles:

2,428,015,940 updates, 9,356 solutions, 20.7 s
heapUsed 27.60 MB -> 27.63 MB   (delta 0.03 MB)
nodes: 12,336 before, 12,336 after, 12,336 live

A consequence worth naming: the demo shows “live nodes” sixty times a second as nodeCount - (unlinks - relinks), one subtraction, because every unlink removes exactly one node and every relink restores exactly one. measure.mjs checks that cheap number against an O(nodes) walk of all four pointers of every node, 388 times mid-flight on a running search. Largest disagreement: zero. The structure never needed to be asked.

The high-water mark matters too. Across the 3×20 enumeration the most nodes ever spliced out at one time is a few thousand of 7,416 — so even if you did keep an undo log, it would be bounded by the matrix. The saving is not that the log is small. It is that the log is the thing itself.

Which column you pick is the entire algorithm

Algorithm X says “choose a column”. It does not say which, and that freedom is where all the performance lives. Same instance, same code, five rules, giving up after 200,000,000 updates:

instancefewest 1s (MRV)leftmostrightmostmost 1srandom
sudoku, 30 clues91892,70032,262gave up46,200,678
sudoku, 17 clues1,818gave up3,506,118gave upgave up
sudoku, 21 clues7,08030,078,0542,563,578gave upgave up
pentomino 3×20, first172,420gave upgave upgave upgave up
8 queens, all2,1902,9462,9466,1081,374
10 queens, first1,3143,1383,13814,73618,462
12 queens, all9,976,80619,922,94019,922,940132,366,22830,519,216

Two things in that table. One: on sudoku the spread is past 100,000× and three rules never finish at all — they are not a slower version of the same search, they are a different search. Two: on n-queens, MRV is worth about 4×, and a rule that picks a column at random beats it.

Why 100,000× there and 4× here

The size of the column you choose is the branching factor at that node, so the thing to measure is that size, and the depth it gets compounded over:

instancechoice pointsforced (size 1)dead (size 0)branch, MRVbranch, leftmostdepth
sudoku, 30 clues51100%0%1.001.4250
sudoku, 17 clues64100%0%1.001.3863
sudoku, 21 clues25786%6%1.081.3759
8 queens, all1,10755%23%1.401.567
12 queens, all313,61354%24%1.381.5811
pentomino 3×202329%70%4.073.3311
pentomino 8×8, hole28,69712%67%3.063.1611

Raise the ratio of the two branching factors to the depth and you have a prediction for the spread in the previous table. It is worth watching where that prediction breaks:

instancepredictedmeasured
sudoku, 30 clues~46,000,000×>217,865× (budget-capped)
8 queens2×4×
12 queens5×13×
pentomino 3×20leftmost should winMRV ahead by >1,160×

So the model holds on sudoku and queens and is flatly wrong on pentominoes. The honest reading is that MRV is two heuristics with one name:

Clue count is not sudoku difficulty

A placement the search never takes back was forced. So “rows tried minus cells to fill” is exactly the amount of guessing in a whole solve:

puzzlecluescells to fillrows triedtaken backupdates
30 clues3051510918
17 clues17646401,818
23 clues2358145873,624
21 clues21602571977,080
no clues0818104,374

The 17-clue grid is the sparsest a uniquely-solvable sudoku can be, and DLX solves it with no backtracking at all — 64 placements for 64 empty cells, every one of them forced. The 21-clue grid, with four more clues, takes 197 placements back. Clues constrain a puzzle; what makes it expensive is whether the constraint ever narrows to exactly one option, and those are not the same property. (The empty grid is also backtrack-free, for a different reason: with everything open, the first thing MRV tries always works.)

That also prices the part a human never does:

puzzleupdates to find the answerupdates to prove it is the only oneratio
30 clues9189181.0×
17 clues1,8181,8181.0×
23 clues3,62435,1429.7×
21 clues7,080162,46222.9×

Where nothing was taken back, the proof is free — the tree was a single path and walking to the end of it is the search. Where there was guessing, proving uniqueness costs an order of magnitude more than finding the answer did.

Secondary columns, and the constraint exact cover cannot say

Exact cover means exactly once. N-queens needs at most once for diagonals, which is a different thing, and the way Knuth gets it is not a special case in the algorithm — it is a build-time decision about the header ring. A secondary column is created self-linked:

col.L = col.R = col;   // not spliced into the root's ring

It is in every row that touches it, so covering a row still unlinks through it and it still blocks a second queen. But it is unreachable from the root header, so no chooser can see it and h.R === h still means solved. The demo’s queens board is 20 primary columns and 38 secondary ones, and the search cannot tell the difference.

What is load-bearing and what is a drawing

Reuse

src/unlink.js is framework-free and has no DOM in it:

src/problems.js carries the four instances and nothing else. Adding a fifth is a function returning { columns, rows }.

The demo is demo/index.html with its own copy of both modules (ADR-0002) and a window.__demo hook the screenshot rig drives. node scripts/screenshot-demo.mjs regenerates the thumb and all seven media shots and doubles as the smoke test: it drives a real search in a real browser and asserts the live-node identity and the restoration checksum on the way out.