DSA DSA In Depth
0%
lesson dancing-linksexact-coverknuth

Exact Cover & Knuth's Algorithm X with Dancing Links (DLX)

7 min read Module 6.1: Recursion, Backtracking, Pruning & Greedy Exchange Arguments Updated Oct 23, 2026
📄 Cheat Sheet
Donald Knuth: Dancing Links (Annual Christmas Lecture 2018)
Stanford Online Donald Knuth: Dancing Links (Annual Christmas Lecture 2018) Watch on YouTube ↗

Donald Knuth himself explains the exact cover matrix formulation, Algorithm X, and the doubly linked list pointer trick that powers Dancing Links.

0:00 / --:--

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:
    1. Cell constraint (81 cols): Cell $(r, c)$ contains a digit.
    2. Row-digit constraint (81 cols): Row $r$ contains digit $d$.
    3. Column-digit constraint (81 cols): Column $c$ contains digit $d$.
    4. 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.

flowchart TD H["Master Head (h)"] <--> C1["Col 1 (S=2)"] <--> C2["Col 2 (S=1)"] <--> C3["Col 3 (S=2)"] C1 <--> N11["Row A, Col 1"] <--> N21["Row B, Col 1"] C2 <--> N12["Row A, Col 2"] C3 <--> N23["Row B, Col 3"] <--> N33["Row C, Col 3"] N11 <--> N12 N21 <--> N23

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

N-Queens Bitmask Pruning & Knuth Dancing Links (DLX)
O(1) Bitmask / Exact Cover

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.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Row 0 Queen at c=1 sets cols=0100₂, ld=1000₂, rd=0010₂. Row 1 avail = ~(cols|ld|rd) & 1111₂ = 0001₂ (only col 3 safe).
♛✕✕✕
cols Mask
0100₂
ld / rd Masks
1000₂ / 0010₂
Next Row Avail
0001₂ (c=3)
[Step 1] Placed Queen at (0, 1); bitwise OR prunes 3 of 4 columns on Row 1 in O(1).
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).

Check yourself Question 1 of 4
Concept Check The Dancing Links Pointer Property recall

What fundamental property of doubly linked lists allows Knuth's Dancing Links (`DLX`) to restore a removed node `x` via `L[R[x]] = x; R[L[x]] = x;` in $O(1)$ time?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

In Dancing Links, why is it mandatory that `uncover(c)` traverses rows upward (`U`) and row elements leftward (`L`) when `cover(c)` traversed them downward (`D`) and rightward (`R`)?

Key Takeaways

Exact Cover models constraint satisfaction as selecting a subset of rows in a 0/1 matrix so every primary column has exactly one 1.
Knuth's Dancing Links (DLX) exploits the fact that unlinked doubly-linked list nodes retain their old pointers, making undo O(1) per node.
Always select the uncovered column with the minimum count S[c] (S-heuristic / MRV) to minimize branching and detect 0-option dead ends immediately.
Uncovering columns and rows must execute in strict reverse (LIFO) order of covering (L instead of R, U instead of D) to preserve pointer integrity.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue