お前も舞うか——浅谈 Dancing Links X
2024 再来翻这篇文章,发现有些地方的删除线在后台编辑的时候显示正常,查看的时候就没了,算了,凑活着看吧。
什么是 Dancing Links X?
在计算机科学中, Dancing Links ,舞蹈链, 也叫 DLX, 是由 Donald Knuth 提出的数据结构,目的是快速实现他的 X 算法。X 算法是一种递归算法,时间复杂度不确定, 深度优先, 通过回溯寻找精确覆盖问题所有可能的解。有一些著名的精确覆盖问题,包括铺砖块,八皇后问题,数独问题。
——百度百科
简单来说,DLX 就是用来解决精确覆盖问题的一种高效的算法(下文会讲到,也可以用来解决重复覆盖问题)。
什么是精确覆盖?
给定 $n$ 个 $01$ 串,长度都为 $m$,要求选取其中 $k$ 个,对于 $\forall\ i\ (i \in [1,m])$ ,有且仅有一个被选出的 $01$串的第 $i$ 位是 $1$。
这就是最典型的精确覆盖问题,其他所有的精确覆盖问题都可以转化为这种问题。
DLX 的原理
DLX 的思想是搜索回溯+剪枝,以下图为例(红色方块代表 $1$,白色代表 $0$ ):
$p.s.$ 这组数据好像有点水,但是我不是故意的......
假如已经选择了第一行,那么第 $2$、$3$、$5$ 行绝对不可能是答案,因为它们的红色方块位置有重叠,因此可以看做删除了这几行,这幅图变成了这样:
另外,因为第一行的染色的位置在剩下的几行中的对应位置肯定没有染色(因为有的话就被删掉了嘛......),所以我们可以当做这几列都不存在。
至此,选了第一行后所有处理都弄完了,所以第一行也没有存在的必要了,把它也删了。
然后继续选择,现在只剩下第四行了,所以选择第四行,然后删行、删列、删第四行......然后这个图就没了。
如果当操作完后发现这个图已经空了并且每列已经覆盖完,就代表找到了一组合法的解,即第 $1$、$4$ 行。
(你以为就这么结束了吗?)
上面讲到,“DLX 的思想是搜索回溯+剪枝”,搜索有了,剪枝也有了,那么回溯呢?
刚才的情况比较巧合,第一遍搜索就找到了解,如果没找到呢?或者有多个解的情况呢?
所以我们需要——回溯。
还是上图的情况,在所有处理完后,回溯,把第四行再加回来,并且撤销删行、删列操作。
选下一行,因为没有下一行了,继续返回,把第一行加回来,并且撤销删行、删列操作。
选下一行,选定第二行,接下来删除红色方块位置有重叠的第 $1$、$5$ 行,删除第二行所有有红色方块的列,再删除第二行。
继续选择,选第三行,进行删行、删列、删第三行操作。
操作完以后,我们发现图已经空了,但是还有两列没有被覆盖过(就是最开始图中的第 $1$、$5$ 列),所以该方案失败,回溯。
接下来自己模拟吧,图画的累死了,还有一组解是选 $3$、$5$ 两行。
由于这种(这句话后面应该还有内容的,但我忘记了当时想说什么,但问题应该不大?)
写到这里好想鸽了吖QAQ。
代码实现
十字链表
Dancing Links X 使用了一种巧妙的结构,叫做十字链表。
能学 DLX 算法想来应该都会链表,十字链表与普通的链表唯一的区别是它链了上下左右四个方向,因为在上文的图解中讲到,DLX 需要不断进行删行、删列及加行、加列的操作,所以需要一个能连通行列的数据结构(具体怎么链的看图罢,下文有代码)。
也就是说,我们需要在初始化的时候,每读入一个节点(就是红色方块),就把它以十字链表的方式链到图里去。
它的思想及实现极其精巧且简洁,建议配合下方代码食用。
代码
下面的写法是先选列来加速搜索,再是上文的依次尝试选择这一列所有节点对应的行,将其尝试加入答案,同时删除不可行的行和多余的列,上文模拟时没有进行选列操作,请读者注意。
需要的函数
int n,m,l[N],r[N],u[N],d[N]; //分别代表行、列、节点的左、右、上、下节点编号(十字链表嘛)
int idx,ansn,s[N],row[N],col[N],ans[N]; //分别代表节点索引、已选取的行的数量、每列节点个数、每个节点编号对应所在的行和列、已选取的行
初始化
void init()
{
for(int i=0;i<=m;i++) //m代表列的数量
{
l[i]=i-1,r[i]=i+1; //与左右列连接
u[i]=d[i]=col[i]=i;//最开始每列的列首节点都连向自己
}
l[0]=m;r[m]=0; //环形,遍历需要
idx=m+1;//每个列首都作为一个节点,红色方块节点编号从m+1开始
}
加点
void add(int &hd,int &tl,int x,int y) //hd代表当前队头,tl代表当前队尾
{
row[idx]=x,col[idx]=y,s[y]++;//记录当前节点的行、列,对应列节点数+1
u[idx]=y,d[idx]=d[y];//当前节点 向上指向 列首,当前节点 向下指向 原列首 向下指向的点
u[d[y]]=idx,d[y]=idx;//原列首 向下指向的点 向上指向 当前节点,列首 向下指向 当前元素
l[tl]=r[hd]=idx,l[idx]=hd,r[idx]=tl;//正常链表操作
tl=idx++;
}
删列
void remove(int p)
{
r[l[p]]=r[p],l[r[p]]=l[p]; //正常链表操作
for(int i=d[p];i!=p;i=d[i]) //向下遍历该列的节点所在的行(就是上文的删有重叠行操作)
for(int j=r[i];j!=i;j=r[j]) //向右遍历当前行所有节点,然后删掉
{
s[col[j]]--;//当前节点对应列的节点数-1
u[d[j]]=u[j],d[u[j]]=d[j];
}
}
加列
void resume(int p)
{
for(int i=u[p];i!=p;i=u[i])
for(int j=l[i];j!=i;j=l[j])
{
u[d[j]]=j,d[u[j]]=j;
s[col[j]]++;
}
r[l[p]]=p,l[r[p]]=p;
}
加列操作和删列差不多,但是!必须!完全!反着来!
原来向下遍历,现在向上便利;原来向右遍历,现在向左遍历......即一步步倒退回去,别管为什么,这么做就行了。
然后就是 DLX 最精妙的部分:Dance。(这是只有一个解的写法,多解的类似)
搜索回溯
bool dance()
{
if(!r[0]) return 1; //如果第0列右边还是0,说明没有 没有遍历的 列了,说明找到了,就返回。
int p=r[0];
for(int i=r[0];i;i=r[i])
if(s[i]<s[p]) p=i; //向右遍历,找到节点数最少的列去删(这样比较快)
remove(p);//删了
for(int i=d[p];i!=p;i=d[i])
{
ans[++ansn]=row[i]; //选择这一行
for(int j=r[i];j!=i;j=r[j]) remove(col[j]); //删除这一行中所有列,同时在remove函数中删除这一列上有重叠的行
if(dance()) return 1; //继续搜索是否可行,若有解直接返回
for(int j=l[i];j!=i;j=l[j]) resume(col[j]); //搜索失败,不选这一行,加回来(回溯)
ansn--;
}
resume(p);//删p列没用,加回来
return 0;
}
完整代码
#include<iostream>
#include<cstdio>
using namespace std;
const int N=3e5+5;
int n,m,l[N],r[N],u[N],d[N];
int idx,ansn,s[N],row[N],col[N],ans[N];
void init()
{
for(int i=0;i<=m;i++)
{
l[i]=i-1,r[i]=i+1;
u[i]=d[i]=i;
}
l[0]=m,r[m]=0;
idx=m+1;
}
void add(int &hd,int &tl,int x,int y)
{
row[idx]=x,col[idx]=y,s[y]++;
u[idx]=y,d[idx]=d[y];
u[d[y]]=idx,d[y]=idx;
l[tl]=r[hd]=idx,l[idx]=hd,r[idx]=tl;
tl=idx++;
}
void remove(int p)
{
r[l[p]]=r[p],l[r[p]]=l[p];
for(int i=d[p];i!=p;i=d[i])
for(int j=r[i];j!=i;j=r[j])
{
s[col[j]]--;
u[d[j]]=u[j],d[u[j]]=d[j];
}
}
void resume(int p)
{
for(int i=u[p];i!=p;i=u[i])
for(int j=l[i];j!=i;j=l[j])
{
u[d[j]]=j,d[u[j]]=j;
s[col[j]]++;
}
r[l[p]]=p,l[r[p]]=p;
}
bool dance()
{
if(!r[0]) return 1;
int p=r[0];
for(int i=r[0];i;i=r[i])
if(s[i]<s[p]) p=i;
remove(p);
for(int i=d[p];i!=p;i=d[i])
{
ans[++ansn]=row[i];
for(int j=r[i];j!=i;j=r[j]) remove(col[j]);
if(dance()) return 1;
for(int j=l[i];j!=i;j=l[j]) resume(col[j]);
ansn--;
}
resume(p);
return 0;
}
int main()
{
scanf("%d %d",&n,&m);
init();
for(int i=1;i<=n;i++)
{
int hd=idx,tl=idx;
for(int j=1;j<=m;j++)
{
int x;
scanf("%d",&x);
if(x) add(hd,tl,i,j);
}
}
if(!dance()) printf("No Solution!");
else for(int i=1;i<=ansn;i++)
printf("%d ",ans[i]);
return 0;
}
$p.s.$ 为什么叫 Dancing Links 呢?因为创造他的人认为这种指针上的跳动极其精美,如同舞蹈一样。
DLX 的应用
因为本人比较菜,所以主要只讲一种应用——数独。
(我认为数独是DLX非常精巧的应用,可能也是唯一比较有用的应用)
数独属于NP完全问题,也就是说一定得用搜索做,DLX是一种比较优秀的搜索方式,下文以 $16 \times 16$ 的数独举例(数独是什么不用我多说了罢)。
下文主要摘自我在 AcWing 数独2一题的题解。
仔细剖析数独,数独其实只有四个约束条件——
-
每行恰有 $16$ 个不同的数;
-
每列恰有 $16$ 个不同的数;
-
每个宫每恰有 $16$ 个不同的数;
-
每个位置恰好有一个数。
其中第 $4$ 个条件是显而易见的,但是实现时不能遗漏。
所以,如果我们把数独每个位置裂成四个点,每个点对应一个条件,数独要求就变成了每个位置填一个适当的数,使全部条件都有且只有一个点满足。
用三元组 $(i,j,k)$ 表示在 $(i,j)$ 上的数是 $k$,对这个问题建模,每个可能的三元组构成 $01$ 矩阵的一行,每个约束条件构成 $01$ 矩阵的一列。
对于列,具体地说,有 $16 \times 16=256$ 列记录数独每个数是否出现,$256$ 列记录数独 $16$ 行每行 $16$ 个数是否出现,$256$ 列记录数独 $16$ 列每列 $16$ 个数是否出现,$256$ 列记录数独 $16$ 宫 $16$ 个数是否出现,
一个完成的数独应当由 $16 \times 16=256$ 个三元组表出,并且每个三元组满足四个约束条件。换句话说,目标是选出 $256$ 行,每行 $4$ 个 $1$,并且每列恰好 $1$ 个 $1$,这就转化成了 DLX 问题。
对于数独已确定的位置占一个三元组 $(i,j,a_{i,j})$,对于不确定的位置有 $16$ 种可能,用 $16$ 个三元组表示。
行的数量(最多):$16 \times 16 \times 16=4096$,列的数量:$16 \times 16 \times 4=1024$。只需枚举数独所有可能的情况,使用 DLX 算法判断是否可行。
所有数独问题都可以这么转化,特别的,当多解的数独不同位置有权重时,也可以直接记录每个节点的权重,找到答案后不返回,选择是否更新答案即可,代码几乎没有区别(还不懂?看这题和代码)。
DLX 解数独的优势在于快,不用管多快,反正比其他大多数搜索+剪枝+卡常+奇奇怪怪的想法快多了(谁喜欢卡常和剪枝呢(摊手))。
重复覆盖问题
前置知识
前置知识:启发式搜索。
重复覆盖与精确覆盖的唯一区别就是每列不必只被一行覆盖,所以之前选定行后删重叠行的方法就失效了,使 DLX 无法正常运作。
暴力搜索?不,DLX 利用十字链表删除列的思想仍然可以借用,而实现选行的操作则只能牺牲一点复杂度使用 搜索+剪枝。
实现这一目的,我们需要借用启发式搜索的精髓——估价函数。
在我理解,启发式搜索,像什么 A*,IDA*,就是加了剪枝的搜索,只是这个剪枝特别优秀——估价函数,用处是在当前局面下快速估计需要花费的最小代价,如果最小代价+当前代价已经大于找到的答案的代价,当前方案绝对不够优秀,就及时返回。
重复覆盖问题的规模一般不会太大,验证的复杂度几乎忽略,减少的计算量十分可观。
重复覆盖用的就是这样的思路,用dfs求估价函数,如果当前已选行数+至少需要的行数>总行数,说明当前方案一定不可行,果断返回。
代码放下面,可以和精确覆盖代码做下对比理解。
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cmath>
using namespace std;
const int N=1e4+5;
int n,m,l[N],r[N],u[N],d[N],s[N];
int idx,tot,ans[N],col[N],row[N];
bool st[N];
void init()
{
for(int i=0;i<=m;i++)
{
l[i]=i-1,r[i]=i+1;
u[i]=d[i]=col[i]=i;
}
l[0]=m;r[m]=0;
idx=m+1;
}
int f()
{
int cnt=0;
memset(st,0,sizeof(st));
for(int i=r[0];i;i=r[i])
{
if(st[col[i]]) continue;
cnt++,st[col[i]]=1;
for(int j=d[i];j!=i;j=d[j])
for(int k=r[j];k!=j;k=r[k]) st[col[k]]=1;
}
return cnt;
}
void add(int &hd,int &tl,int x,int y)
{
row[idx]=x,col[idx]=y,s[y]++;
u[idx]=y,d[idx]=d[y];
u[d[y]]=idx,d[y]=idx;
r[hd]=l[tl]=idx,r[idx]=tl,l[idx]=hd;
tl=idx++;
}
void remove(int p)
{
for(int i=d[p];i!=p;i=d[i])
r[l[i]]=r[i],l[r[i]]=l[i];
}
void resum(int p)
{
for(int i=u[p];i!=p;i=u[i])
r[l[i]]=i,l[r[i]]=i;
}
bool dance(int k)
{
if(k+f()-1>tot)return 0;
if(!r[0])return 1;
int p=r[0];
for(int i=r[0];i;i=r[i])
if(s[p]>s[i]) p=i;
for(int i=d[p];i!=p;i=d[i])
{
ans[k]=row[i];
remove(i);
for(int j=r[i];j!=i;j=r[j]) remove(j);
if(dance(k+1)) return 1;
for(int j=l[i];j!=i;j=l[j]) resum(j);
resum(i);
}
return 0;
}
int main()
{
scanf("%d%d",&n,&m);
init();
for(int i=1;i<=n;i++)
{
int hd=idx,tl=idx;
for(int j=1;j<=m;j++)
{
int x;
scanf("%d",&x);
if(x) add(hd,tl,i,j);
}
}
tot=0;
while(!dance(1)) tot++;
for(int i=1;i<=tot;i++) printf("%d ",ans[i]);
return 0;
}