お前も舞うか——浅谈 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一题的题解。

仔细剖析数独,数独其实只有四个约束条件——

  1. 每行恰有 $16$ 个不同的数;

  2. 每列恰有 $16$ 个不同的数;

  3. 每个宫每恰有 $16$ 个不同的数;

  4. 每个位置恰好有一个数。

其中第 $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;
}
posted @ 2022-07-30 12:03  青鸢挽歌  阅读(213)  评论(0编辑  收藏  举报
浏览器标题切换
浏览器标题切换end