前置芝士
——精确覆盖问题
简单来说,就是有多个集合,要你选若干个拼在一起,包含的数和另一个给出的集合完全相同。
这个问题也可以用矩阵表示出来,每一行表示一个集合,如果这个集合中出现了数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,所以我们要将 与这一行中的1同一列 的1给找出来,然后将找出来的1们与所在的那些行一起删掉。(因为如果一个集合中有一个数不能选,那么整个集合都是不能选的)
- 将选中的那一行给删掉,并且将这一行的1们所在的列给删掉,表示这一列我已经搞到一个1了。
- 判断结果:(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。
相信大家瞬间就想出来怎么做了,只需要每次选取的时候只是把列给删掉,不删 除了被选取的行 之外的行即可。
代码就懒得给出啦~