Dancing links——DLX搜索详解

前置芝士

——精确覆盖问题

百度百科

简单来说,就是有多个集合,要你选若干个拼在一起,包含的数和另一个给出的集合完全相同。

这个问题也可以用矩阵表示出来,每一行表示一个集合,如果这个集合中出现了数x,那么在第x列就放个1,否则就是0。

打个比方~

假如有这么几个集合:
{1,2}
{3,4,6}
{1,4,5}
{2,5}
{1}
那么搞成矩阵就变成了这个样子:
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} 1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ 1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
假设我们需要拼成的集合为{1,2,3,4,5,6},也就是
( 1 1 1 1 1 1 ) \left( \begin{array}{} 1 & 1 & 1 & 1 & 1 & 1\\ \end{array} \right) (111111)
那么问题就转变成了在矩阵中选若干行,使得每一列恰好有一个1

——X算法

说实话,这就是个暴力……

具体做法:

  1. 在矩阵中找一个还有1的列,然后将这一列中的1遍历一遍,对于每一个遍历到的1,我们假设这一次选它所在的那一行。
  2. 因为要求每一列恰好只能有一个1,所以我们要将 与这一行中的1同一列 的1给找出来,然后将找出来的1们与所在的那些行一起删掉。(因为如果一个集合中有一个数不能选,那么整个集合都是不能选的)
  3. 将选中的那一行给删掉,并且将这一行的1们所在的列给删掉,表示这一列我已经搞到一个1了。
  4. 判断结果:(1)如果此时全部列都已经被删除了,那么就是找到了一种答案,返回       ;       (2)如果还有列没被删除,那么就重复以上操作,如果任何一列中不包含1,那么就是没找到合法方案,返回。

我又来打比方了~

还是上面那个例子。
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} 1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ 1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
首先找出第一列,遍历到第一个1,于是找出该行
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} \textcolor{red}1 & \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0\\ 0 & 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ 1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
找出与这行的1同一列的1
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} \textcolor{red}1 & \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0\\ 0 & 0 & 1 & 1 & 0 & 1\\ \textcolor{red}1 & 0 & 0 & 1 & 1 & 0\\ 0 & \textcolor{red}1 & 0 & 0 & 1 & 0\\ \textcolor{red}1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
将这几行删除,并且将选中的那一行的1所在的列删除,就变成了
( 1 1 0 1 ) \left( \begin{array}{} 1 & 1 & 0 & 1\\ \end{array} \right) (1101)
显然,现在的第3列没有1,没有找到合法方案,回溯

这次,我们选另一行
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} 1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}1 & \textcolor{red}1 & \textcolor{red}0\\ 0 & 1 & 0 & 0 & 1 & 0\\ 1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
同样的,找出与这一行的1同一列的1
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} \textcolor{red}1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & \textcolor{red}1 & 0 & 1\\ \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}1 & \textcolor{red}1 & \textcolor{red}0\\ 0 & 1 & 0 & 0 & \textcolor{red}1 & 0\\ \textcolor{red}1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
删除之后……啥也没了
( ) () ()
说是啥也没了,但其实还有三列,但是这三列中都没有数了,也就是没有1,于是,这还是没找到合法方案

不能灰心!继续回溯,选择最后一行
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} 1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0\\ \end{array} \right) 101011001001000011000011001000
同样的,找出与这一行的1同一列的1
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} \textcolor{red}1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ \textcolor{red}1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}0\\ \end{array} \right) 101011001001000011000011001000
将这几行删除,并且将选中的那一行的1所在的列删除,就变成了这样
( 0 1 1 0 1 1 0 0 1 0 ) \left( \begin{array}{} 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 0\\ \end{array} \right) (0110100110)
于是继续选择第一列,找到这一列上的1,选择这一行
( 0 1 1 0 1 1 0 0 1 0 ) \left( \begin{array}{} 0 & 1 & 1 & 0 & 1\\ \textcolor{red}1 & \textcolor{red}0 & \textcolor{red}0 & \textcolor{red}1 & \textcolor{red}0\\ \end{array} \right) (0110100110)
并没有和这一行的1在同一列的1,于是将这一行中的1所在的列以及这一行删掉即可
( 1 1 1 ) \left( \begin{array}{} 1 & 1 & 1 \end{array} \right) (111)
下面就懒得说了,显然这就是一种合法方案~

至此,我们找到了一种合法方案,也就是选择2、4、5这三行。

这做法说是简单,但是发现每次都需要造一个新的矩阵,时间、空间、代码复杂度一起爆炸,使得这个算法菜的一笔。

于是,大佬(Donald E.Knuth)跳出来解决了这个问题,提出了一种巧妙的算法——Dancing links!

进入正题!

说是个算法,本质上应该算是个数据结构。

做法还是与上面一样的原汁原味,但是这个结构可以使得造矩阵那个部分变得十分方便。

大佬发现,矩阵中的0全都是废的,于是他果断地舍弃掉这些0,将剩下的1连接起来。具体的连接方式,按网上的大佬所说,是叫交叉十字循环双向链表

具体一点,每个1拥有4个指针,分别指向自己上面的1,下面的1,左边的1,右边的1。还有两个属性,记录自己所在的行和列。(这个行列是指在一开始的矩阵中的行列,不随着矩阵的变换而改变)

因为X算法中每次需要找一列遍历下去,大佬认为,为了方便一点,每一列需要有一个领头羊,于是矩阵的上面多了一行,这一行是大佬加进去的c数组,就是用来引领每一列的。(假定c数组在矩阵的第0行)

那么为了方便判断是否找到合法方案,大佬觉得,还要加一个head,放在c数组的前面,这个head和这个c数组之间,也用双向循环链表连接,c数组中的元素会随着搜索时列的删除而一起被删除掉,那么当head的指向右边的指针指向自己时,就说明所有列都被删除掉了,此时找到了一个合法答案。

网上这位大佬的图特好,于是偷偷仿照一下~

先把上面那个栗子给搬下来
( 1 1 0 0 0 0 0 0 1 1 0 1 1 0 0 1 1 0 0 1 0 0 1 0 1 0 0 0 0 0 ) \left( \begin{array}{} 1 & 1 & 0 & 0 & 0 & 0\\ 0 & 0 & 1 & 1 & 0 & 1\\ 1 & 0 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 0 & 1 & 0\\ 1 & 0 & 0 & 0 & 0 & 0\\ \end{array} \right) 101011001001000011000011001000
按上面的说法,建出来的交叉十字循环双向链是这个样子的:
在这里插入图片描述
有发现什么问题么?

……

这是错的。

我们要的是交叉十字双向循环链表,所以,应该是这个样子的:
在这里插入图片描述
也就是说,我们需要将链首和链尾双向连接起来。

那我们用这个再走一遍流程~

首先找到第一列,也就是c1,遍历到第一个1,选中这一行

在这里插入图片描述
将与这一行的1同一列的1给找到,也就是找到和紫色节点同一列的节点
在这里插入图片描述
将红色节点和紫色节点所在的行删除,并且将紫色节点所在的列删除。

在这里插入图片描述
看完这一段,各位应该看出来Dancing links 的优势所在了,它造一个新的矩阵,只需要在原来的基础上修改即可,而不需要真的一个。

go on~继续模拟

此时发现c5下面没有1了,也就是说没有找到一个合法方案,于是回溯。
在这里插入图片描述
c1向下找到第二个1,选择这一行。
在这里插入图片描述
将与这一行的1同一列的1找到,也就是与紫色节点在同一列的节点
在这里插入图片描述
将紫色节点所在的列以及紫色节点和红色节点所在的行删掉
在这里插入图片描述
发现此时c2、c3、c6的下面都没有1了,没有找到合法方案,回溯

这次,我们选择最后一行
在这里插入图片描述
同样的,找到与紫色节点在同一列的节点
在这里插入图片描述
将紫色节点和红色节点所在的行删掉,并且删掉紫色节点所在的列
在这里插入图片描述
然后我们找到c2,向下找到第一个1,选择这一行
在这里插入图片描述
发现并没有和紫色节点同一行的节点,于是将紫色节点所在的行和列删除即可。
在这里插入图片描述
然后找到c3,向下找到第一个1,选择这一行
在这里插入图片描述
同样的,将紫色节点所在的行和列删去
在这里插入图片描述
于是,只剩下了一个孤零零的head,head发现它的左边正好是自己,于是此时找到了一个合法方案。

于是,这个算法和数据结构就差不多讲完了呢。

但是,其实DLX算法,还有一个玄学优化(乱搞)。

显然,对于任意一个时刻,我们无法知道选择哪一行可以更快地找到答案,也就是说,我们不能人为地压榨这颗搜索树的深度,但是我们发现我们是可以压榨这颗搜索树的宽度的,也就是说,每次搜索,我们可以选择枚举包含的1的个数最少的那一列,这样就尽可能地压榨了这颗搜索树的宽度。

虽然不能一定使得搜索能够更快地找到答案,但是个人测试发现是能够较普遍地优化代码时间复杂度的。(用DLX做数独的话在hihocoder上如果不加这个优化是会TLE的)

接下来就是代码了(顺手贴上例题

方便理解,分开讲~

先介绍一下每个节点包含的东西

struct node{
	node *u,*d,*l,*r;//up,down,left,right四个指针
	int col,line;//line和column记录这个节点所在的行和列
	node():u(NULL),d(NULL),l(NULL),r(NULL),col(0),line(0){}
};

然后就是构建交叉十字循环双向链表的代码了(笔者很菜所以只能写那么长了,大家凑合着看看吧qwq)

void input()
{
	scanf("%d %d",&n,&m);
	node *p;
	for(int i=1;i<=n;i++)//先构建横向的双向循环链表
	{
		p=NULL;//记录这一行的链尾,用来帮助构建这一行的双向循环链表
		for(int j=1;j<=m;j++)
		{
			scanf("%d",&map[i][j]);
			if(map[i][j]==1)
			{
				matrix[i][j]=new node();
				matrix[i][j]->line=i;matrix[i][j]->col=j;//记录行列
				if(p==NULL)p=matrix[i][j],p->l=p->r=p;//假如这是第一个节点
				else
				{
					p->r->l=matrix[i][j];//将新加入的节点和链首连接好
					matrix[i][j]->r=p->r;
					p->r=matrix[i][j];//将新加入的节点和链尾连接好
					matrix[i][j]->l=p;
					p=matrix[i][j];//更新链尾
				}
			}
			else matrix[i][j]=NULL;
		}
	}
	p=head=new node;p->l=p->r=p;//这里是构建head和c数组间的双向循环链表
	//p的作用同上
	for(int i=1;i<=m;i++)
	{
		c[i]=new node;
		p->r->l=c[i];c[i]->r=p->r;
		p->r=c[i];c[i]->l=p;p=c[i];
		c[i]->col=i;c[i]->line=0;//注意c数组也要记录line和column,下面还是有用的
	}
	for(int j=1;j<=m;j++)//接下来构建c数组和矩阵的纵向双向循环链表,实现类似横向
	{
		p=c[j];p->d=p->u=p;
		for(int i=1;i<=n;i++)
		{
			if(map[i][j]==1)
			{
				p->d->u=matrix[i][j];matrix[i][j]->d=p->d;
				p->d=matrix[i][j];matrix[i][j]->u=p;
				p=matrix[i][j];
			}
		}
	}
}

接下来再讲讲如何删除节点

要删除一个节点的话,只需要将跟它连接的点连在一起即可。

代码如下

inline void del1(node *x){x->r->l=x->l,x->l->r=x->r;}
//将左边节点的右指针连向我右边的节点,右边同理
inline void del2(node *x){x->d->u=x->u,x->u->d=x->d;}

考虑到还有回溯操作,于是还要搞两个函数将它还原

inline void reset1(node *x){x->l->r=x;x->r->l=x;}//将指针还原就好了
inline void reset2(node *x){x->d->u=x;x->u->d=x;}

但还有一个需要注意的地方,就是还原时的顺序要和删除时的顺序相反,也就是要做到先删的后还原,后删的先还原,这样才能保证正确性。(原因的话想想就明白啦)

具体的代码体现就在这个搜索函数里面啦!

void dfs()//很神奇的不需要任何参数qwq
{
	node *p=head->r;
	if(p==head)//如果所有列都被删掉了,说明找到了一个合法方案
	{
		ans=true;
		return;
	}
	for(node *i=p->r;i!=head;i=i->r)//找到包含1的数量最少的列
	if(tot[i->col]<tot[p->col])p=i;
	for(node *i=p->d;i!=p;i=i->d)//枚举选哪一个1
	{
		int st=tt;//后面回溯时用
		//========================================
		//down是找到与选择的那一行的1在同一行的1并且将这些找到的1所在的行给删掉
		down(i),del1(c[i->col]);//del1(c[i->col])是将这一列删掉
		for(node *j=i->r;j!=i;j=j->r)//枚举选的这一行包含的所有1,操作和上面相同
		down(j),del1(c[j->col]);//down函数后面会给出
		//双向循环链表的遍历比较麻烦,因为在双向循环链表中没有链首和链尾的概念
		//个人的做法就是单独处理当前节点,然后再从他的左边或右边出发将整条链遍历一遍
		//如果可以记录链的长度的话自然是方便许多,但是这是个交叉十字双向循环链表
		//记录起来比较麻烦,所以。。。(何况还有删除操作呢!)
		//========================================
		zhan[++tt]=i;//将i压入栈中,表示将i所在的行压入栈中(感性理解一下~)
		del2(i),tot[i->col]--;//最后将选择的这一行给删除掉,删除的时候要注意更新tot
		for(node *j=i->r;j!=i;j=j->r)
		del2(j),tot[j->col]--;
		dfs();//继续向下搜索
		if(ans)return;
		//这里是还原被删除的列(c)
		//因为要使删除的顺序和还原的顺序相反,所以写成下面这种遍历方式
		for(node *j=zhan[tt]->l;j!=zhan[tt];j=j->l)//(注意这里是l)
		reset1(c[j->col]);
		reset1(c[zhan[tt]->col]);//这里也需要注意!这个语句要放在循环后面
		while(tt>st)//取出栈顶,将这一行还原
		{
			reset2(zhan[tt]),tot[zhan[tt]->col]++;
			for(node *j=zhan[tt]->r;j!=zhan[tt];j=j->r)
			reset2(j),tot[j->col]++;
			tt--;
		}
	}
}

最后就是down函数了:

void down(node *x)
{
	for(node *i=x->d;i!=x;i=i->d)//遍历和x同一行的1
	{
		if(i->line==0)continue;//如果遍历到c数组就跳过
		zhan[++tt]=i;//将这一行压入栈中
		//因为被删除的行不会再被遍历到,所以不用担心某一行被多次压入栈中
		del2(i),tot[i->col]--;//删除这一行
		for(node *j=i->r;j!=i;j=j->r)
		del2(j),tot[j->col]--;
	}
}

整合起来就是这样了

#include <cstdio>
#include <cstring>
#define maxn 110

struct node{
	node *u,*d,*l,*r;
	int col,line;
	node():u(NULL),d(NULL),l(NULL),r(NULL),col(0),line(0){}
};
node *matrix[maxn][maxn];
node *c[maxn],*head;
int n,m,map[maxn][maxn];
int tot[maxn];
void input()
{
	scanf("%d %d",&n,&m);
	node *p;
	for(int i=1;i<=n;i++)
	{
		p=NULL;
		for(int j=1;j<=m;j++)
		{
			scanf("%d",&map[i][j]);
			if(map[i][j]==1)
			{
				matrix[i][j]=new node();
				matrix[i][j]->line=i;matrix[i][j]->col=j;
				if(p==NULL)p=matrix[i][j],p->l=p->r=p;
				else
				{
					p->r->l=matrix[i][j];
					matrix[i][j]->r=p->r;
					p->r=matrix[i][j];
					matrix[i][j]->l=p;
					p=matrix[i][j];
				}
			}
			else matrix[i][j]=NULL;
		}
	}
	p=head=new node;p->l=p->r=p;
	for(int i=1;i<=m;i++)
	{
		c[i]=new node;tot[i]=0;
		p->r->l=c[i];c[i]->r=p->r;
		p->r=c[i];c[i]->l=p;p=c[i];
		c[i]->col=i;c[i]->line=0;
	}
	for(int j=1;j<=m;j++)
	{
		p=c[j];p->d=p->u=p;
		for(int i=1;i<=n;i++)
		{
			if(map[i][j]==1)
			{
				tot[j]++;
				p->d->u=matrix[i][j];matrix[i][j]->d=p->d;
				p->d=matrix[i][j];matrix[i][j]->u=p;
				p=matrix[i][j];
			}
		}
	}
}
node *zhan[maxn];
int tt;
bool ans;
inline void del1(node *x){x->r->l=x->l,x->l->r=x->r;}
inline void del2(node *x){x->d->u=x->u,x->u->d=x->d;}
inline void reset1(node *x){x->l->r=x;x->r->l=x;}
inline void reset2(node *x){x->d->u=x;x->u->d=x;}
void down(node *x)
{
	for(node *i=x->d;i!=x;i=i->d)
	{
		if(i->line==0)continue;
		zhan[++tt]=i;
		del2(i),tot[i->col]--;
		for(node *j=i->r;j!=i;j=j->r)
		del2(j),tot[j->col]--;
	}
}
void dfs()
{
	node *p=head->r;
	if(p==head)
	{
		ans=true;
		return;
	}
	for(node *i=p->r;i!=head;i=i->r)
	if(tot[i->col]<tot[p->col])p=i;
	for(node *i=p->d;i!=p;i=i->d)
	{
		int st=tt;
		down(i),del1(c[i->col]);
		for(node *j=i->r;j!=i;j=j->r)
		down(j),del1(c[j->col]);
		zhan[++tt]=i;
		del2(i),tot[i->col]--;
		for(node *j=i->r;j!=i;j=j->r)
		del2(j),tot[j->col]--;
		dfs();
		if(ans)return;
		for(node *j=zhan[tt]->l;j!=zhan[tt];j=j->l)
		reset1(c[j->col]);
		reset1(c[zhan[tt]->col]);
		while(tt>st)
		{
			reset2(zhan[tt]),tot[zhan[tt]->col]++;
			for(node *j=zhan[tt]->r;j!=zhan[tt];j=j->r)
			reset2(j),tot[j->col]++;
			tt--;
		}
	}
}

int main()//主函数没什么好讲的吧= =
{
	int t;
	scanf("%d",&t);
	while(t--)
	{
		input();
		ans=false;tt=0;
		dfs();
		if(ans)printf("Yes\n");
		else printf("No\n");
	}
}

所以DLX算法是可以很优秀地解决精确覆盖问题的,所以,遇到搜索题的时候,可以考虑将它转化成一个精确覆盖问题,然后用DLX算法求解。

不仅如此,DLX算法还可以解决重复覆盖问题。

重复覆盖问题和精确覆盖问题很相似,它的定义是:在矩阵内选若干行,使得每一列至少有一个1。

相信大家瞬间就想出来怎么做了,只需要每次选取的时候只是把列给删掉,不删 除了被选取的行 之外的行即可。

代码就懒得给出啦~

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值