Dancing Links X专题学习
写在前面:本人萌新,这篇博文是建立在众位大佬的博客之上,然后在自己学习的过程中加入自我分析。欢迎吐槽!
关键词:递归 | 回溯 | 深度优先 | 非确定性的
应用:精确覆盖 | 数独 | 重复覆盖 | 等
一 . 精确覆盖
【引例】Girl Match
在一个寒冷的冬天,卖女孩的小火柴 yy 来到了一家奇特的餐馆 ——Match Girl。该餐馆中有 N 种食品(食品按 1~N 编号)。在这个奇怪的餐馆里,食品都按套餐出售。已知有 M 种套餐,对于每一种套餐,给出该套餐包含的食品编号(保证不存在两个完全相同的套餐)。yy 饿极了,她希望能够品尝到每一种食品。但 yy 由于没有能够凭借美色拐到足够的女孩,现在穷得响叮当。她希望能够购买到所有的食品且不重复购买同一种食品。她已经饿得没有力气继续思考了,而你是她拐回来的唯一的“女孩”。作为回报,她不会把你卖了。她能够实现这小小的愿望吗?如果能,输出"Yes";否则,输出"No"。
现有 6 种食品,5 种套餐:
套餐 1 :1 3
套餐 2 :1 3 4 6
套餐 3 :2 5
套餐 4 :3 4 5 6
套餐 5 :1 2 3 4 5
答案为:Yes(选择套餐2和套餐3)
穷举、状压等方法都可以完成 N、M 较小的部分数据,这里不再一一赘述。直接引入 Dancing Links X 的思考过程。
将套餐和对应的食品编号转化成0/1矩阵,如下图,第 i 行代表第 i 个套餐对应的食品编号(1为包含该编号的食品)。我们要解决的问题就成了选取一个行集合,使得选出集合后所得到矩形的每一列上有且仅有一个1。
1 0 1 0 0 0 —— 第一行
1 0 1 1 0 1 —— 第二行
0 1 0 0 1 0 —— 第三行
0 0 1 1 1 1 —— 第四行
1 1 1 1 1 0 —— 第五行
这就是经典的精确覆盖问题。更常见的问题,需要同时保证所选的行数最少,并输出方案。
再回到这个问题。
对于该0/1矩阵,首先假定我们选择了第一行。那么第一行以及与第一行有冲突的行都没有可能在后面选择到(*有"冲突"表示的是:该行与其它行存在共同的都为 1 的列)。所以,我们可以将与第一行有冲突的行都删除掉。同时,在以后选择的行中不再可能存在与第一行所有共同为 1 的列,将第一行对应为 1 的列也对应删除掉,如 图1-1 所示:
同样,再进行删除,将得到一个空矩阵。由此,我们可以发现:我们在原矩阵中选择了 2 行,但只选择了 4列(共 6 列)。所以,该选择方案不成立,那么不断回溯,重新进行选择。由于最后的选择只有一种,那么直接回溯到第一次的选择。
选择删除第二行,如图 1-2 :
再进行删除。此次操作,我们在原矩阵中选择了 2 行,对应选择了 6 列(共 6 列)。得到答案。
在这个搜索过程中,我们调用了大量矩阵缓存和矩阵回溯。如果用传统的矩阵存储,每次的删除,我们最坏的时间复杂度为O( NM )。如果该方案搜索失败,又需要用O( NM )的复杂度回溯。这种操作的时间复杂度难以解决。
于是,由算法大师Donald Knuth提出了"X算法"。这是一种递归的、非确定的、深度优先的回溯算法。Dancing Links,即舞蹈链,本身是一种链式的数据结构。利用链表的性质,不再在删除操作中开辟更多的空间,以O(1)的时间实现删除列,以<=O(N)的时间删除行。也是这样,Dancing Links由此得名。而"Dancing Links X"的含义正是利用"舞蹈链"来求解"X算法"的意思。(*本段转载非原创,详见 英雄哪里出来 文章第三部分Dancing Links X算法 第 4 点Dancing Links)
假设我们现在 A 以链表的形式存储,在链表的更改操作如下:
A[A[i].Left].Right->A[i].Left
A[A[i].Right].Left->A[i].Right
我们可以发现:A[i].Left 和 A[i].Right 的指向都发生了变化,但是 A[i] 本身没有,如果我们想要将 A[i] 重新放入,只需要更改为
A[A[i].Left].Right->i
A[A[i].Right].Left->i
即可。
Dancing Links其实是一种十字交叉双向循环链表。依靠链表的这一性质,快速地将某一列或某一行先移除,然后在更改回来。Dancing Links的节点可以分为以下四类。
- 总表头:连接行首与列首节点。
- 列首节点
- 行首节点:可以看做是一个指针数组,Row[i]记录了第 i 行第一个节点的编号。
- 元素节点
如 图1-3 所示:
(* 注明:行首节点 Row 由于是单向不再在图中画出)
算法的流程如下(译自维基百科):
-
判断矩阵是否为空(行) 。行为空且列为空,记录答案并终止查询;列不为空,返回上层并继续查询。否则,继续往后。
-
选择列 c (确定性选择)
-
选择一行 r,使得 A[r,c]=1 (非确定性选择)
-
将 r 统计进临时答案变量中
-
枚举每列 j 使得 A[r,j]=1;
枚举每行 k 使得 A[k,j]=1;
从矩阵中删除第 i 行 -
在简化矩阵中重复算法
用 引例 中给出的数据,模拟过程为:
移除第一行以及与它冲突的。
继续移除:
发现矩阵不为空(依旧存在列首节点),没有找到答案。
回溯到 图1-6 所示:
回溯到 图1-7 所示:
移除第二行以及与它冲突的:
继续移除:
矩阵为空,找到答案。
再做 Dancing Links 的过程中,每次寻找哪一列的 1 个数最少。然后枚举该列为 1 的行,选择删除。这样能够较大幅度加快寻找的速度。
预处理:
void Ready(){
for(int i=0;i<=C;i++){
// 0 为 Head
Sum[i]=0;
Node[i].Up=i;
Node[i].Down=i;
Node[i].Left=i-1;
Node[i].Right=i+1;
Node[i].Col=i;
Node[i].Row=0;
}//第一行(列首节点)初始化
Node[0].Left=C;
Node[C].Right=0;
cnt=C;
for(int i=1;i<=R;i++) Row[i]=0;//行首节点初始化
}
加入元素节点:
void Push(int x,int y){
cnt++;//新建一个点
Sum[y]++;//统计该列节点数
Node[cnt].Down=Node[y].Down;//将当前元素放在第 y 列的第一个
Node[cnt].Up=y;
Node[Node[y].Down].Up=cnt;
Node[y].Down=cnt;
Node[cnt].Row=x;
Node[cnt].Col=y;
if(!Row[x]){
Row[x]=cnt;
Node[cnt].Left=cnt;
Node[cnt].Right=cnt;//当前行没有节点
}else{
Node[cnt].Left=Row[x];
Node[cnt].Right=Node[Row[x]].Right;
Node[Node[Row[x]].Right].Left=cnt;
Node[Row[x]].Right=cnt;//将新建节点放在该行的第一个节点的后面
}
}//节点插入的写法有很多
删除操作:
void Delete(int c){
//选中第 c 列
Node[Node[c].Left].Right=Node[c].Right;
Node[Node[c].Right].Left=Node[c].Left;
for(int i=Node[c].Down;i!=c;i=Node[i].Down)
for(int j=Node[i].Right;j!=i;j=Node[j].Right){
Node[Node[j].Up].Down=Node[j].Down;
Node[Node[j].Down].Up=Node[j].Up;
Sum[Node[j].Col]--;
}//删除该列所有 1 所在行的所有数
}
回溯:
void Return(int c){
for(int i=Node[c].Up;i!=c;i=Node[i].Up)
for(int j=Node[i].Left;j!=i;j=Node[j].Left){
Node[Node[j].Up].Down=j;
Node[Node[j].Down].Up=j;
Sum[Node[j].Col]++;
}
}
舞蹈:
bool Dancing(int Dep){
if(!Node[0].Right){
N=Dep;
return true;
}//矩阵是否为空
int c=Node[0].Right;
for(int i=Node[c].Right;i;i=Node[i].Right){
if(Sum[i]<Sum[c])
c=i;
}//找最小
Delete(c);
for(int i=Node[c].Down;i!=c;i=Node[i].Down){
Ans[Dep]=Node[i].Row;//记录答案
for(int j=Node[i].Right;j!=i;j=Node[j].Right) Delete(Node[j].Col);
if(Dancing(Dep+1)) return true;
for(int j=Node[i].Left;j!=i;j=Node[j].Left) Return(Node[j].Col);
}
Return(c);
return false;
}
*由于此题不存在,不附上完全代码
【例题1】Easy Finding
给定一个M*N(M<=16,N<=300)的0/1矩阵,寻找一些行,使得重新得到的矩阵的每一列上有且仅有一个1。如果有解,输出"Yes, I found it";否则,输出"It is impossible"。
Dancing Links X的模板题。按照描述的矩阵构图套上模板。
#include<bits/stdc++.h>
int R,C,cnt,N,a[510][1010]={
},Row[510]={
},Sum[1010]={
};
struct Dancing_Link_X{
int Up,Down,Left,Right;
int Col,Row;
} Node[500010]={
};
void Ready(){
for(int i=0;i<=C;i++){
Sum[i]=0;
Node[i].Up=i;
Node[i].Down=i;
Node[i].Left=i-1;
Node[i].Right=i+1;
Node[i].Col=i;
Node[i].Row=0;
}
Node[0].Left=C;
Node[C].Right=0;
cnt=C;
for(int i=1;i<=R;i++)
Row[i]=-1;
}
void Push(int x,int y){
Sum[y]++;
cnt++;
Node[cnt].Down=Node[y].Down;
Node[cnt].Up=y;
Node[Node[y].Down].Up=cnt;
Node[y].Down=cnt;
Node[cnt].Row=x;
Node[cnt].Col=y;
if(Row[x]<0){
Row[x]=cnt;
Node[cnt].Left=cnt;
Node[cnt].Right=cnt;
}else{
Node[cnt].Left=Row[x];
Node[cnt].Right=Node[Row[x]].Right;
Node[Node[Row[x]].Right].Left=cnt;
Node[Row[x]].Right=cnt;
}
}
void Delete(int c){
Node[Node[c].Left].Right=Node[c].Right;
Node[Node[c].Right].Left=Node[c].Left;
for(int i=Node[c].Down;i!=c;i=Node[i].Down)
for(int j=Node[i].Right;j!=i;j=Node[j].Right){
Node[Node[j].Up].Down=Node[j].Down;
Node[Node[j].Down].Up=Node[j].Up;
Sum[Node[j].Col]--;
}
}
void Return(int c){
for(int i=Node[c].Up;i!=c;i=Node[i].Up)
for(int j=Node[i].Left;j!=i;j=Node[j].Left){
Node[Node[j].Up].Down=j;
Node[Node[j].Down].Up=j;
Sum[Node[j].Col]++;
}
Node[Node[c].Left].Right=c;
Node[Node[c].Right].Left=c;
}
bool Dancing(int Dep){
if(!Node[0].Right) return true;
int c=Node[0].Right;
for(int i=Node[c].Right;i;i=Node[i].Right){
if(Sum[i]<Sum[c])
c=i;
}
Delete(c);
for(int i=Node[c].Down;i!=c;i=Node[i].Down){
for(int j=Node[i].Right;j!=i;j=Node[j].Right) Delete(Node[j].Col);
if(Dancing(Dep+1)) return true;
for(int j=Node[i].Left;j!=i;j=Node[j].Left) Return(Node[j].Col);
}
Return(c);
return false;
}
int main(){
for(;scanf("%d%d",&R,&C)!=EOF;){
Ready();
for(int i=1;i<=R;i++)
for(int j=1;j<=C;j++){
scanf("%d",&a[i][j]);
if(!a[i][j]) continue;
Push(i,j);
}
if(Dancing(0))printf("Yes, I found it\n");
else printf("It is impossible\n");
}
return 0;
}
【例题2】Treasure Map
你的老板有许多同张藏宝图的副本。不幸的是,这些藏宝图的副本被打破成若干矩形的碎片。更糟糕的是,有些碎片丢失了。还好,可以得知每个碎片在藏宝图中的位置(给出一个范围,用x1,y1,x2,y2表示,x1<x2,y1<y2)现在老板要求你用这些碎片组成一张完整的藏宝图。(不必使用所有的碎片,但不允许重叠)。藏宝图的大小为N*M(N,M<=30),碎片数不超过300.(如图)
将N*M个格子的覆盖情况看作Dancing Links的列,每个小碎片看作行。将每个碎片所能覆盖的格子标为 1 ,作为元素节点加入。
细节的处理上,由于藏宝图的左下角由(0,0)开始,为了方便处理,将所有碎片的 x1、y1 都加上1。由于题目保证 x1<x2 且 y1<y2,碎片的相对位置没有改变,不会对答案造成影响。这张图就变为左下角由(1,1)开始的了。
#include<bits/stdc++.h>
using namespace std;
const int R=30*30,C=500;
int Ans,N,M,P,cnt,sum[R+10]={
},Row[C+10]={
};
struct Node{
int L,R,U,D;
int Row,Col;
} pnt[R*C+10]={
};
void Ready(int r,int c){
for(int i=0;i<=c;i++){
pnt[i].U=pnt[i].D=i;
pnt[i].L=i-1;
pnt[i].R=i+1;
pnt[i].Col=i;
sum[i]=0;
}
pnt[0].L=c;
pnt[c].R=0;
cnt=c;
for(int i=1;i<=r;i++) Row[i]=0;
}
inline void Add(int x,int y){
cnt++;
sum[y]++;
pnt[cnt].Row=x;
pnt[cnt].Col=y;
pnt[cnt].U=y;
pnt[cnt].D=pnt[y].D;
pnt[pnt[y].D].U=cnt;
pnt[y].D=cnt;
if(!Row[x]){
Row[x]=cnt;
pnt[cnt].L=pnt[cnt].R=cnt;
}else{
pnt[cnt].R=Row[x];
pnt[cnt].L=pnt[Row[x]].L;
pnt[pnt[Row[x]].L].R=cnt;
pnt[Row[x]].L=cnt;
Row[x]=cnt;
}
}
inline void del(int c){
pnt[pnt[c].L].R=pnt[c].R;
pnt[pnt[c].R].L=pnt[c].L;
for(int i=pnt[c].D;i!=c;i=pnt[i].D){
for(int j=pnt[i].R;j!=i;j=pnt[j].R){
pnt[pnt[j].U].D=pnt[j].D;
pnt[pnt[j].D].U=pnt[j].U;
sum[pnt[j].Col]--;
}
}
}
inline void ret(int c){
for(int i=pnt[c].U;i!=c;i=pnt[i].U){
for(int j=pnt[i].L;j!=i;j=pnt[j].L){
pnt[pnt[j].U].D=pnt[pnt[j].D].U=j;
sum[pnt[j].Col]++;
}
}
pnt[pnt[c].L].R=pnt[pnt[c].R].L=c;
}
inline void Dancing(int Dep){
if(Dep>=Ans&&Ans!=-1) return ;
if(!pnt[0].R){
if(Ans!=-1) Ans=min(Dep,Ans);
else Ans=Dep;
return ;
}
int c=pnt[0].R;
for(int i=pnt[0].R;i;i=pnt[i].R){
if(sum[i]<sum[c])
c=i;
}
del(c);
for(int i=pnt[c].D;i!=c;i=pnt[i].D){
for(int j=pnt[i].R;j!=i;j=pnt[j].R) del(pnt[j].Col);
Dancing(Dep+1);
for(int j=pnt[i].L;j!=i;j=pnt[j].L) ret(pnt[j].Col);
}
ret(c);
}
int main(){
int T; scanf("%d",&T);
for(;T--;){
scanf("%d%d%d",&N,&M,&P);
Ready(P,N*M);
Ans=-1;
for(int i=1;i<=P;i++){
int x,y,X,Y;
scanf("%d%d%d%d",&x,&y,&X,&Y);
for(int j=x+1;j<=X;j++)
for(int k=y+1;k<=Y;k++)
Add(i,(j-1)*M+k);
}
Dancing(0);
printf("%d\n",Ans);
}
return 0;
}
【例题3】Dominoes
需要用12种方块覆盖一个NM的棋盘,每个方块只能使用一次。求有多少种覆盖方案(方案翻转或旋转后相同算同一种方案。方块可以旋转,可以翻转)。N*M=60
由图可知,每个方块的大小为5,60的棋盘覆盖必定每一个方块都必须用上。且如果min(N,M) < 3,那么答案必定会为0。所以,60个格子的覆盖情况和12种方块的使用情况看作列,所有单个方块可能置的情况看作行。方块旋转和翻转后,不同的情况一共有63种,63种情况又可能放在棋盘中不同的位置(<63 * 60个),由这么多种情况看作行。直接做的话会超时。由于N*M=60,而60的因子十分少。我们可以用Dancing Links先处理处每一种答案,然后打表。
在细节的处理上,需要注意在选择列时,只需要选择前60列进行移除。12种方块的使用情况相当于一个约束条件:在方块放置时,该种方块的其他情况会被删除,相当于只选择了一次该方块。
#include<bits/stdc++.h>
using namespace std;
const int R=60*63,C=60+12;
const int mp[63][5][5]={
{
{
1,0,0},
{
1,0,0},
{
1,1,1},
},{
{
1,1,1},
{
1,0,0},
{
1,0,0},
},{
{
1,1,1},
{
0,0,1},
{
0,0,1},
},{
{
0,0,1},
{
0,