舞蹈链(DLX)学习笔记

舞蹈链(DLX)学习笔记

我好菜

WC 没听懂,于是来自己写一下。

前置知识:搜索,大致了解双向链表、循环链表。

精确覆盖问题

有一个 \(n\times m\)\(0/1\) 矩阵,选出若干行,使得每一列恰有一个 \(1\)

换句话说,有 \(n\) 个集合,集合元素取值在 \(1\sim m\),选出一些集合,使得它们的并是全集,且任意两个的交是空集。

容易想到的暴力

暴力一

枚举选哪些行,暴力 check。

时间复杂度为 \(\mathcal{O}(2^n\cdot nm)\)

暴力二

记一下现在选的元素的并(状压后为或),可以降低复杂度。

时间复杂度为 \(\mathcal{O}(2^n\cdot n)\)

X 算法

X 算法是 Donald E. Knuth 提出的一个算法。这个名字可能会很熟悉,因为他是 KMP 中的 K。

我们从 OI Wiki 贺一个矩阵过来,以这个为例:

\[\begin{pmatrix} 0 & 0 & 1 & 0 & 1 & 1 & 0 \\ 1 & 0 & 0 & 1 & 0 & 0 & 1 \\ 0 & 1 & 1 & 0 & 0 & 1 & 0 \\ 1 & 0 & 0 & 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 & 0 & 0 & 1 \\ 0 & 0 & 0 & 1 & 1 & 0 & 1 \\ \end{pmatrix} \]

首先尝试选取第一行,发现这一行有三个 \(1\),因为一列只能有一个 \(1\),所以它们所在列不能再有 \(1\) 了。

在下图中第一行被标为\(\color{blue}{蓝色}\)\(1\) 所在列被标为\(\color{red}{红色}\),在那些列中有 \(1\) 的行被标为\(\color{orange}{橙色}\)(记住这些颜色的意义,在后面不会再重复写了):

\[\begin{pmatrix} \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 & \color{blue}1 & \color{blue}1 & \color{blue}0 \\ 1 & 0 & \color{red}0 & 1 & \color{red}0 & \color{red}0 & 1 \\ \color{orange}0 & \color{orange}1 & \color{red}1 & \color{orange}0 & \color{red}0 & \color{red}1 & \color{orange}0 \\ 1 & 0 & \color{red}0 & 1 & \color{red}0 & \color{red}0 & 0 \\ 0 & 1 & \color{red}0 & 0 & \color{red}0 & \color{red}0 & 1 \\ \color{orange}0 & \color{orange}0 & \color{red}0 & \color{orange}1 & \color{red}1 & \color{red}0 & \color{orange}1 \\ \end{pmatrix} \]

那么上图中的\(\color{orange}{橙色}\)的行都不能取了,因为会与取了的\(\color{blue}{蓝色}\)的行冲突。

\(\color{blue}{蓝色}\)的行取过了,\(\color{orange}{橙色}\)的行都不能取了,留着只会浪费资源,所以把它们删掉。\(\color{red}{红色}\)的列中所有的 \(1\) 所在行都被删掉了,只剩 \(0\) 了,没有意义,也删掉。

就得到了下面这个可爱的小矩阵(即没有染色的部分):

\[\begin{pmatrix} 1 & 0 & 1 & 1 \\ 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ \end{pmatrix} \]

我们继续重复上面的步骤,选择第一行标成\(\color{blue}{蓝色}\),把所有的 \(1\) 所在列标成\(\color{red}{红色}\),在\(\color{red}{红色}\)列中找到所有的 \(1\) 把所在行标为\(\color{orange}{橙色}\)

\[\begin{pmatrix} \color{blue}1 & \color{blue}0 & \color{blue}1 & \color{blue}1 \\ \color{red}1 & \color{orange}0 & \color{red}1 & \color{red}0 \\ \color{red}0 & \color{orange}1 & \color{red}0 & \color{red}1 \\ \end{pmatrix} \]

\(\color{orange}{橙色}\)行会与\(\color{blue}{蓝色}\)行冲突,继续把标色的部分删掉:

\[\begin{pmatrix} \end{pmatrix} \]

发现矩阵空了!但是上一次选择的行:

\[\begin{pmatrix} \color{blue}1 & \color{blue}0 & \color{blue}1 & \color{blue}1 \\ \end{pmatrix} \]

不是全 \(1\) 的,这意味这一行中 \(0\) 所在列无法被任何行选择了,因此这个选择方案不合法。我们回溯到上一步:

\[\begin{pmatrix} 1 & 0 & 1 & 1 \\ 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ \end{pmatrix} \]

这次选择第二行:

\[\begin{pmatrix} \color{red}1 & \color{orange}0 & \color{red}1 & \color{orange}1 \\ \color{blue}1 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{red}0 & 1 & \color{red}0 & 1 \\ \end{pmatrix} \]

把它们删掉:

\[\begin{pmatrix} 1 & 1 \\ \end{pmatrix} \]

继续递归,选择第一行并完成标记:

\[\begin{pmatrix} \color{blue}1 & \color{blue}1 \\ \end{pmatrix} \]

把它们删掉:

\[\begin{pmatrix} \end{pmatrix} \]

发现矩阵又空了!这次说明了什么呢?上一个选择的行全为 \(1\),在小矩阵中所有列都覆盖了。而大矩阵中其他列呢?其他列在前面的步骤就被删掉了,说明它们已经被标成了\(\color{red}{红色}\),不然不会被删除。而\(\color{red}{红色}\)列的意义是(好像打脸了说好的“不会再重复写了”呢)选择的\(\color{blue}{蓝色}\)行中 \(1\) 所在列,说明这一列已经有 \(1\) 了。整个选择符合题意,找出了一种解,算法结束。

为了确保能够正确理解,下面会重新给你一遍这个矩阵,强烈建议不要看上面内容,手玩一遍再继续看!

\[\begin{pmatrix} 0 & 0 & 1 & 0 & 1 & 1 & 0 \\ 1 & 0 & 0 & 1 & 0 & 0 & 1 \\ 0 & 1 & 1 & 0 & 0 & 1 & 0 \\ 1 & 0 & 0 & 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 & 0 & 0 & 1 \\ 0 & 0 & 0 & 1 & 1 & 0 & 1 \\ \end{pmatrix} \]

总结一下 X 算法的流程:

  • 对于现在的矩阵 \(M\),选出一行标成\(\color{blue}{蓝色}\),并相应地标记\(\color{red}{红色}\)\(\color{orange}{橙色}\),将\(\color{blue}{蓝色}\)行放到答案 \(S\) 中。
  • 删除所有被染色的行和列,把它们删除,得到可爱的小矩阵 \(M'\)
  • 如果 \(M'\) 为空,且刚刚选择的\(\color{blue}{蓝色}\)行全为 \(1\),找到了一种解,返回被删除的行组成的集合 \(S\)
  • 如果 \(M'\) 为空,但刚刚选择的\(\color{blue}{蓝色}\)行不全为 \(1\),则无解,返回无解并回溯。
  • 如果 \(M'\) 不为空,回到第一步继续递归。
  • 如果尝试了所有行都无解,返回无解并回溯。

DLX 算法

闲话

我们需要大量的“删除行”“删除列”“恢复行”“恢复列”操作,删除在递归时用,恢复在回溯时用。那么这东西怎么维护呢?Donald E. Knuth 想到了双向十字链表。

在双向十字链表上的操作被形象地比喻成“跳跃”,因此用双向十字链表优化的 X 算法被称为“Dancing Links X”(DLX/舞蹈链)。

宏定义

#define move(i,A,x) for(int i=A[x];i!=x;i=A[i])

也就是从 \(A_x\) 开始,向 \(A\) 方向走,直到走一圈。

这个链表是循环链表,\(A\)\(L,R,U,D\),详见下面。

双向十字链表定义

一个双向十字链表的节点存储所在的行号和列号,同时指出去四个指针,如图:

这个双向十字链表是循环链表,也就是说,每一行最左边节点的 \(L\) 指针指向最右边节点,其他三个指针同理。

每一行的行首有一个行指示(为下文的 \(hd\) 数组),每一列的列首有一个列指示(是一个节点,编号从 \(0\sim m\)\(i\) 号即为第 \(i\) 列的列首标记)。

我们还用 \(sz\) 表示每一列的元素个数。

在双向十字链表中,我们只维护为 \(1\) 的点,不维护为 \(0\) 的点。点的总数为 \(tot\)

\(ans\) 类似于上文 X 算法中的 \(S\)\(totAns\) 是选出行的个数。

int n, m, hd[N], sz[N], L[N], R[N], U[N], D[N], col[N], row[N], tot, ans[N], totAns;

remove 操作

remove(c):在舞蹈链中删除第 \(c\) 列及其相关行。

我们知道第 \(c\) 列的列首标记为 \(c\),首先把 \(c\) 从横向链表中删除:

L[R[c]] = L[c];
R[L[c]] = R[c];

相当于这个操作:(第一行的节点是虚拟节点,为列指示标记)

然后顺着这一列往下走,把每一行都删掉。类似上面的代码,只不过更改的是 \(U,D\) 指针。不要忘记修改每一列的节点数 \(sz\)

void remove(int c) {
    L[R[c]] = L[c];
    R[L[c]] = R[c];
    move(i, D, c) {
        move(j, R, i) {
            U[D[j]] = U[j];
            D[U[j]] = D[j];
            --sz[col[j]];
        }
    }
}

recover 操作

recover(c):在舞蹈链中还原第 \(c\) 列及其相关行。

就是 remove(c) 的逆操作,不详细讲解,注意要把 remove 的操作顺序反过来。

void recover(int c) {
    move(i, U, c) {
        move(j, L, i) {
            U[D[j]] = j;
            D[U[j]] = j;
            ++sz[col[j]];
        }
    }
    L[R[c]] = c;
    R[L[c]] = c;
}

build 操作

build(r,c):初始化一个 \(r\times c\) 的双向十字链表,初始为空(除了列指示标记)。

新建 \(c+1\) 个点作为列指示,编号为 \(0\sim c\),则 \(0\) 为列指示标记的行指示(有点绕),\(i\) 为第 \(i\) 列的列指示。

因为是循环链表,所以 \(i\)\(L,R,U,D\) 标记分别为 \(i-1,i+1,i,i\)(取模意义下)。

void build(int r, int c) {
    memset(hd, 0, sizeof(hd));
    memset(sz, 0, sizeof(sz));
    n = r; m = c; tot = c;
    rep(i, 0, c) {
        L[i] = !i ? c : i - 1;
        R[i] = i == c ? 0 : i + 1;
        U[i] = i;
        D[i] = i;
    }
}

insert 操作

insert(r,c):在位置 \((r,c)\) 插入一个节点。

分两类讨论:

如果第 \(r\) 行是空的,那么新建一个节点,并使 \(hd_r\) 指向这个节点。

如果第 \(r\) 行不是空的,那么用以下方式把节点插进去(假设这个节点编号为 \(u\)):

  • \(u\) 插到 \(c\) 的正下方,\(c\) 下面原来的节点放到 \(u\) 下面,参考图论中的链式前向星。
  • \(u\) 插到 \(hd_r\) 的正右方,\(hd_r\) 右面原来的节点放到 \(u\) 右面,参考图论中的链式前向星。
  • 记得更新所在列的节点数 \(sz\)

相当于这个操作:

注意在青色边和绿色边上标的 \(R\)\(D\) 可能不符合直觉,但确实是这样的,这保证了复杂度。

这一步比较难理解,建议仔细看看。

void insert(int r, int c) {
    ++tot; ++sz[c];
    row[tot] = r; col[tot] = c;
    U[tot] = c;
    D[tot] = D[c];
    U[D[c]] = tot;
    D[c] = tot;
    if(!hd[r]) {
        hd[r] = tot;
        L[tot] = tot;
        R[tot] = tot;
    }
    else {
        L[tot] = hd[r];
        R[tot] = R[hd[r]];
        L[R[hd[r]]] = tot;
        R[hd[r]] = tot;
    }
}

dance 操作

dance():递归地解决剩余的矩阵。

类似于前文 X 算法部分,这部分的操作如下:

  • 如果 \(0\)\(R\) 指针为空,则矩阵为空,记录答案并返回。
  • 选择一列并删掉。
  • 遍历这一列所有 \(1\),删掉对应行。
  • 递归调用 dance(),返回是否有解,如果无解,恢复被删除的部分。
  • 如果没有可行选法,则返回无解。

不过为了减小搜索树,在第二步时常使用启发式的优化,找到 \(sz\) 最小(\(1\) 最少)的列。

bool dance() {
    int c = R[0];
    if(!c) return 1;
    move(i, R, 0) if(sz[i] < sz[c]) c = i;
    remove(c);
    ++totAns;
    move(i, D, c) {
        ans[totAns] = row[i];
        move(j, R, i) remove(col[j]);
        if(dance()) return 1;
        move(j, L, i) recover(col[j]);
    }
    recover(c);
    --totAns;
    return 0;
}

完整代码

//By: Luogu@rui_er(122461)
#include <bits/stdc++.h>
#define rep(x,y,z) for(int x=y;x<=z;x++)
#define per(x,y,z) for(int x=y;x>=z;x--)
#define debug printf("Running %s on line %d...\n",__FUNCTION__,__LINE__)
#define fileIO(s) do{freopen(s".in","r",stdin);freopen(s".out","w",stdout);}while(false)
using namespace std;
typedef long long ll;
const int N = 2e4+5;

int n, m, x;
template<typename T> void chkmin(T& x, T y) {if(x > y) x = y;}
template<typename T> void chkmax(T& x, T y) {if(x < y) x = y;}
struct DLX {
    int n, m, hd[N], sz[N], L[N], R[N], U[N], D[N], col[N], row[N], tot, ans[N], totAns;
    #define move(i,A,x) for(int i=A[x];i!=x;i=A[i])
    void remove(int c) {
        L[R[c]] = L[c];
        R[L[c]] = R[c];
        move(i, D, c) {
            move(j, R, i) {
                U[D[j]] = U[j];
                D[U[j]] = D[j];
                --sz[col[j]];
            }
        }
    }
    void recover(int c) {
        move(i, U, c) {
            move(j, L, i) {
                U[D[j]] = j;
                D[U[j]] = j;
                ++sz[col[j]];
            }
        }
        L[R[c]] = c;
        R[L[c]] = c;
    }
    void build(int r, int c) {
        memset(hd, 0, sizeof(hd));
        memset(sz, 0, sizeof(sz));
        n = r; m = c; tot = c;
        rep(i, 0, c) {
            L[i] = !i ? c : i - 1;
            R[i] = i == c ? 0 : i + 1;
            U[i] = i;
            D[i] = i;
        }
    }
    void insert(int r, int c) {
        ++tot; ++sz[c];
        row[tot] = r; col[tot] = c;
        U[tot] = c;
        D[tot] = D[c];
        U[D[c]] = tot;
        D[c] = tot;
        if(!hd[r]) {
            hd[r] = tot;
            L[tot] = tot;
            R[tot] = tot;
        }
        else {
            L[tot] = hd[r];
            R[tot] = R[hd[r]];
            L[R[hd[r]]] = tot;
            R[hd[r]] = tot;
        }
    }
    bool dance() {
        int c = R[0];
        if(!c) return 1;
        move(i, R, 0) if(sz[i] < sz[c]) c = i;
        remove(c);
        ++totAns;
        move(i, D, c) {
            ans[totAns] = row[i];
            move(j, R, i) remove(col[j]);
            if(dance()) return 1;
            move(j, L, i) recover(col[j]);
        }
        recover(c);
        --totAns;
        return 0;
    }
    void print() {
        rep(i, 1, totAns) printf("%d ", ans[i]);
    }
    #undef move
}dlx;

int main() {
    scanf("%d%d", &n, &m);
    dlx.build(n, m);
    rep(i, 1, n) {
        rep(j, 1, m) {
            scanf("%d", &x);
            if(x) dlx.insert(i, j);
        }
    }
    if(dlx.dance()) dlx.print();
    else puts("No Solution!");
    return 0;
}

时间复杂度

时间复杂度为 \(\mathcal{O}(c^n)\),其中 \(c\) 是一个接近 \(1\) 的常数,\(n\) 是矩阵中 \(1\) 的个数。但 DLX 的常数很小,十分优秀。

posted @ 2022-01-23 14:49  rui_er  阅读(328)  评论(3编辑  收藏  举报