1. The Exact Cover Problem & Knuth's Algorithm X
Given a universe of constraints $C$ (columns) and a collection of candidate options $R$ (rows), where each row $r \in R$ is a subset of $C$ represented as a $0/1$ matrix $A$, the Exact Cover Problem asks for a subset of rows $R^* \subseteq R$ such that every column $c \in C$ is covered by exactly one row in $R^*$. Exact Cover is NP-complete (Karp, 1972), yet countless constraint satisfaction problems—Sudoku, $N$-Queens, Pentomino tiling, Latin squares—reduce directly to Exact Cover:
- Sudoku as Exact Cover ($729 \text{ rows} \times 324 \text{ columns}$): Each candidate placement $(r, c, d) \in \{0..8\}^3$ is a row (total $9^3 = 729$ rows). Each row has exactly 4 ones across 324 columns:
- Cell constraint (81 cols): Cell $(r, c)$ contains a digit.
- Row-digit constraint (81 cols): Row $r$ contains digit $d$.
- Column-digit constraint (81 cols): Column $c$ contains digit $d$.
- Box-digit constraint (81 cols): Box $b(r, c)$ contains digit $d$.
Knuth's Algorithm X solves Exact Cover recursively: If matrix $A$ has no columns left, report the current solution. Otherwise, deterministically choose a column $c$ with the minimum number of 1s (the S-heuristic / MRV). Nondeterministically choose a row $r$ such that $A[r, c] = 1$, add $r$ to the partial solution, and for every column $j$ where $A[r, j] = 1$, delete column $j$ and delete every row $i$ that also has $A[i, j] = 1$ (since $i$ conflicts with $r$ on column $j$). Recurse, then restore everything.
Core Invariant — The Dancing Links (DLX) Pointer Restoration Identity
In a circular doubly linked list, removing node $x$ executes:
L[R[x]] = L[x]; R[L[x]] = R[x];
Because $L[x]$ and $R[x]$ on $x$ itself are never overwritten, provided we undo removals in exact LIFO (reverse) order, restoring $x$ is simply:
L[R[x]] = x; R[L[x]] = x;
No memory allocation, no search, and no conditionals are needed.
2. Toroidal 2D Doubly Linked Matrix Representation
Only the $1$-entries of $A$ are represented as nodes. Every node $x$ has four pointers (L, R, U, D) and a pointer/index C to its column header. Each column header also tracks S[c], the current number of active 1s in column $c$, and is linked horizontally into a master header list rooted at head.
Hardware Reality — Flat Struct-of-Arrays Arena vs. Heap-Allocated Node Pointers
If each DLX node is allocated separately with new Node(), traversing U[i] and R[j] chases 64-bit pointers scattered across the heap, causing L1/L2 cache misses. Production DLX solvers allocate a single flat contiguous array of 32-bit integer indices (int L[MAXN], R[MAXN], U[MAXN], D[MAXN], Col[MAXN]). For Sudoku, all $\le 324 + 4 \times 729 = 3{,}240$ nodes fit inside 64 KB—residing 100% inside the CPU's L1/L2 data cache!
3. Complexity Matrix
Step through O(1) bitmask attack propagation (cols, ld, rd) for N-Queens, Knuth Algorithm X circular doubly-linked cover/uncover, and Meet-in-the-Middle subset search.
Invariant & State
| Operation | Dense Matrix Copy | DLX Sparse Torus | Notes |
|---|---|---|---|
| Select Min-Size Column ($S$-Heuristic) | O(|C|) | O(|C_active|) | Scans only remaining uncovered column headers |
| Cover / Uncover Column $c$ | O(|R| * |C|) | O(1) per removed 1-node | Work proportional strictly to non-zero entries removed |
| Undo / Backtrack Restoration | O(|R| * |C|) | O(1) per restored 1-node | Exact LIFO pointer relinking; zero allocations |
| Total Space Complexity | O(d * |R| * |C|) | O(|C| + nnz(A)) | $nnz(A)$ is the number of 1s in the initial matrix |
4. Reference C++20 Implementation
#include <cstdint>
#include <limits>
#include <vector>
class DancingLinksSolver {
struct Node {
int L, R, U, D, col, row_id;
};
std::vector<Node> nodes;
std::vector<int> col_size;
std::vector<int> solution;
void cover(int c) {
nodes[nodes[c].R].L = nodes[c].L;
nodes[nodes[c].L].R = nodes[c].R;
for (int i = nodes[c].D; i != c; i = nodes[i].D) {
for (int j = nodes[i].R; j != i; j = nodes[j].R) {
nodes[nodes[j].D].U = nodes[j].U;
nodes[nodes[j].U].D = nodes[j].D;
--col_size[nodes[j].col];
}
}
}
// MUST traverse in exact reverse order (U then L) of cover (D then R)!
void uncover(int c) {
for (int i = nodes[c].U; i != c; i = nodes[i].U) {
for (int j = nodes[i].L; j != i; j = nodes[j].L) {
++col_size[nodes[j].col];
nodes[nodes[j].D].U = j;
nodes[nodes[j].U].D = j;
}
}
nodes[nodes[c].R].L = c;
nodes[nodes[c].L].R = c;
}
public:
explicit DancingLinksSolver(int num_cols) : nodes(num_cols + 1), col_size(num_cols + 1, 0) {
for (int c = 0; c <= num_cols; ++c) {
nodes[c] = { (c == 0 ? num_cols : c - 1), (c == num_cols ? 0 : c + 1), c, c, c, -1 };
}
}
void add_row(int row_id, const std::vector<int>& cols_one_based) {
int first = -1;
for (int c : cols_one_based) {
int idx = static_cast<int>(nodes.size());
int up = nodes[c].U;
nodes.push_back({ idx, idx, up, c, c, row_id });
nodes[up].D = idx;
nodes[c].U = idx;
++col_size[c];
if (first == -1) {
first = idx;
} else {
int left = nodes[first].L;
nodes[idx].L = left;
nodes[idx].R = first;
nodes[left].R = idx;
nodes[first].L = idx;
}
}
}
bool search(std::vector<int>& out_rows) {
if (nodes[0].R == 0) { // All primary columns covered!
out_rows = solution;
return true;
}
// S-Heuristic: pick column with minimum remaining 1s
int best_c = nodes[0].R;
for (int c = nodes[0].R; c != 0; c = nodes[c].R) {
if (col_size[c] < col_size[best_c]) best_c = c;
}
if (col_size[best_c] == 0) return false; // Dead end: uncovered column has 0 options
cover(best_c);
for (int r = nodes[best_c].D; r != best_c; r = nodes[r].D) {
solution.push_back(nodes[r].row_id);
for (int j = nodes[r].R; j != r; j = nodes[j].R) {
cover(nodes[j].col);
}
if (search(out_rows)) return true;
for (int j = nodes[r].L; j != r; j = nodes[j].L) {
uncover(nodes[j].col);
}
solution.pop_back();
}
uncover(best_c);
return false;
}
};
Production Pitfall — Violating LIFO Order in uncover() or Secondary Columns
In cover(c), rows are traversed downward (D) and row elements rightward (R), and inside search(), row columns are covered rightward (R). If your uncover(c) or loop restoration does not reverse this order—traversing L before U—when two deleted rows share a column, the intermediate pointers will be restored to stale neighbours, silently corrupting the torus! Also, for problems like $N$-Queens where diagonals are at-most-one constraints rather than exactly-one, those diagonal columns must be secondary columns (not linked into the master header 0 so they don't require coverage, but still covered when used).