「舞蹈链 DLX 」学习笔记

  • 前言

    挖坑,开始填。--2021/04/26

    填完了(?竟然没鸽。) --2021/04/27

    《关于我不想搞 whk 于是就来写博客这件事。》

    不是什么鬼啊喂。

    如果有不足的地方欢迎随时指出,然后求赞(?)。


  • DLX 是什么

    Dancing Links X 即舞蹈链是一种高效的数据结构,用来优化一些搜索。

    处理的问题主要包括 (2) 种类型,一类是精确覆盖问题,另一类是重复覆盖问题

    那么接下来先给出精确覆盖问题的模板题,即洛谷 P4929 【模板】舞蹈链(DLX)

    给定一个 (n)(m) 列的矩阵,矩阵中每个元素要么是 (1) ,要么是 (0)

    你需要在矩阵中挑选出若干行,使得对于矩阵的每一列 (j) ,在你挑选的这些行中,有且仅有一行的第 (j) 个元素为 (1)

    首先易得,大暴搜的复杂度是 (mathcal{O}(mcdot2^n))

    实际上,这属于 NP 完全问题,也就是没有多项式算法。

    但是 DLX 却可以在大大提高搜索的效率,在本题中,可以解决的范围为 (n,m le 500) ,其中保证矩阵中 (1) 的数量不超过 (5000) 个。

    重复覆盖问题类似,只有两点不同。下边已加粗。

    由于洛谷并没有重复覆盖模板题,所以以下给出模板问题:

    给定一个 (N)(M) 列的矩阵,矩阵中每个元素要么是 (1) ,要么是 (0)

    你需要在矩阵中选择若干行,使每一列都至少包含一个 (1) ,并且要求选择的行数最少


  • 十字链表

    专门用来 DLX 的东西,属于前置知识。

    顾名思义, 十字样子的链表。下面用 (row_i) 表示行 (col_j) 表示列。

    以下是十字链表初始状态 ,什么中间太空了放点东西好耶

    接下来,因为我不喜欢这个样例,所以我手造一个来讲吧。

    1 0 0
    0 1 0
    1 0 1
    

    首先加上第一个点 ((1,1))

    同理,加上点 ((2,2))((3,3))

    然后加上 ((3,1))

    删除的时候,类似把上面的图倒着看(?)。

    这些就是十字链表的要用的知识了。下面进入正题。


  • 精确覆盖问题

    首先,先来考虑大暴搜如何优化。

    给定一个矩阵。

    1 1 1 0 0 0 0
    0 0 1 1 1 0 1
    0 0 0 1 1 1 1
    1 0 0 0 1 1 1
    0 0 0 1 1 0 0
    

    比如我们首先选了第 (1) 行,然后发现第一行的前 (3) 列都是 (1)

    那么我们肯定不能再选含有前 (3) 列为 (1) 的行,也就是第 (2,4) 行。

    那么我们将他们一起删除。

    那么接下来也就同理了。

    所有列都被删后,就可以判定有解并且返回了。

    所以接下来的问题就是如何高效判断要删除的行。

    很自然的想到了十字链表 (因为他是前置知识) ,每次删除时,在对应一列下去的把每个点对应一横溜过去的每个点都删掉。

    所以可以发现,DLX 的效率和 (1) 数量有关。

    此外,每次找行是,我们优先选择一行里面 (1) 数量最多的列。

    另外,因为是在 dfs 中,所以回溯的时候要把删的点在加回去。

    现在放出代码。

#include<iostream>
#include<cstdio>
#include<cmath>
#include<cstring>
using namespace std;
const int Maxn=5000+500+10;
int n,m,l[Maxn],r[Maxn],u[Maxn],d[Maxn];// 十字链表左右上下
int idx,ansn,ans[Maxn],s[Maxn],row[Maxn],col[Maxn];
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;
	r[hd]=l[tl]=idx;r[idx]=tl;l[idx]=hd;
	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 resum(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 dfs()
{	
	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; // 优先选择一行里面 1 多的列。 
	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(dfs())return 1;
		for(int j=l[i];j!=i;j=l[j])resum(col[j]);
		ansn--;
	}
	resum(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(dfs())
		for(int i=1;i<=ansn;i++)
			printf("%d ",ans[i]);
	else printf("No Solution!");
	printf("
");
	return 0;
}

  • 重复覆盖问题

    解决方法和精确覆盖类似,但有前置知识 IDA* 算法。 建议没有学过的先去补一下。

    因为是至少一个 (1) ,所以同一列存在多个 (1) 是合法的。

    换句话说,就相当于我们之前的同时删除多个行的做法,不 行 了 。

    那么就相当于之前找列也没意义了,因为每一列都有可能。

    所以我们简单改一下代码。

    首先是修改和恢复部分,不再需要删除一列过去的点。

    每次优先找到 (1) 最多的列时,也不再需要删除这一列了。

    但是这样效率明显差很多了。

    于是我们考虑类似 IDA* 来搞。

    先用一个数组 (tot) 来从小到大枚举要的行数,在 dfs 中用一个估价函数,估一下目前还要多少行,如果当前行数加上估价函数大于 (tot) 返回无解即可。

    而估价函数呢,用贪心技数下就可以了。

    具体详见代码。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cmath>
using namespace std;
const int Maxn=10000+5;
int n,m,l[Maxn],r[Maxn],u[Maxn],d[Maxn],s[Maxn];
int idx,tot,ans[Maxn],col[Maxn],row[Maxn];
bool st[Maxn];
void init()
{	
	for(int i=0;i<=m;i++)
	{	
		l[i]=i-1;r[i]=i+1;
		u[i]=d[i]=col[i]=i;
		s[i]=0;
	}
	l[0]=m;r[m]=0;
	idx=m+1;
}
int h() //估价函数 
{	
	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 dfs(int k)
{	
	if(k+h()-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;
	// 注意选完列,p 所在的列并不会删除。 
	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(dfs(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(!dfs(1))tot++;
	printf("%d
",tot);
	for(int i=1;i<=tot;i++)
		printf("%d ",ans[i]);
	return 0;
}

  • DLX 的练习题

    这是我之前整理的一份题单:「舞蹈链DLX」の 学习题单

    但是因为我题刷的少,见识也少,题单里的题数不多,还有 (2,3) 题几乎是多倍经验。

    但题单是处于持续更新状态,如果往后做题还碰到,会不定期的补充,也欢迎大家来补充。

    下面举例两个例题。


  • SP1110 SUDOKU - Sudoku

    题目链接:Link.

    一个数独的网格是由 (16 imes 16) 个小格组成的, 这些小格又被分为 (4 imes 4=16) 个小组, 一些小格填入了从 (A)(P) (字母表前 (16) 个)的字母,不过一般填成数字。

    游戏目的是将所有小格填满,而使每行,每列和每个(4 imes 4) 小组中不重复的包含这 (16) 个字母。

    给出的初始状态满足以上要求且满足唯一解。

    说真的,做舞蹈链的题,你首先不能把他当矩阵看(

    数独是真的算很经典的题了。

    首先我们简化一下题目,对于一个 (16 imes 16) 的矩阵,每一行必须有 (1-16) ,每一列必须有 (1-16) ,每一 (16) 宫格必须有 (1-16)

    再换个说法,比如我们定义一个 ((i,j,k) in { 0,1 }) ,表示位于 ((i,j)) 填的数字是否 (k)

    也就是,对于一个 (16 imes 16) 的矩阵,每一行都必须满足对于每个 (k) ,存在一个为 (1) 每一列都必须满足对于每个 (k) ,存在一个为 (1) ,每一宫格都必须满足对于每个 (k) ,存在一个为 (1)

    发现这就是个 DLX 精确覆盖问题。

    那么问题解决了。具体代码细节如下:

#include<iostream>
#include<cstdio>
#include<cmath>
#include<cstring>
using namespace std;
const int Maxn=20000;
struct node{
	int x,y;
	char c;
}op[Maxn];
int m=16*16*4,l[Maxn],r[Maxn],u[Maxn],d[Maxn];// 十字链表左右上下
int idx,ansn,ans[Maxn],s[Maxn],row[Maxn],col[Maxn];
char c[20][20];
void init() // 预处理 
{	
	for(int i=0;i<=m;i++)
	{	
		l[i]=i-1;r[i]=i+1;s[i]=0;
		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;
	r[hd]=l[tl]=idx;r[idx]=tl;l[idx]=hd;
	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 resum(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 dfs()
{	
	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(dfs())return 1;
		for(int j=l[i];j!=i;j=l[j])resum(col[j]);
		ansn--;
	}
	resum(p);
	return 0;
}
void w()
{	
	memset(r,0,sizeof(r));memset(u,0,sizeof(u));
	memset(l,0,sizeof(l));memset(u,0,sizeof(u));
	memset(s,0,sizeof(s));
	memset(col,0,sizeof(col));
	memset(row,0,sizeof(row));
}
int main()
{	int T,cases=0;
	scanf("%d",&T);
	while(T--)
	{	
		for(int i=0;i<16;i++)
			scanf("%s",c[i]);
		if(cases)printf("
");
		cases++;
		w();
		init();
		for(int i=0,n=1;i<16;i++)
			for(int j=0;j<16;j++)
			{	
				int a=0,b=15;
				if(c[i][j]!='-')a=b=c[i][j]-'A';
				for(int k=a;k<=b;k++,n++)
				{	
					int hd=idx,tl=idx;
					op[n].x=i;op[n].y=j;op[n].c=k+'A';
					add(hd,tl,n,i*16+j+1);
					add(hd,tl,n,256+i*16+k+1);
					add(hd,tl,n,256*2+j*16+k+1);
					add(hd,tl,n,256*3+(i/4*4+j/4)*16+k+1);
				}
			}
		dfs();
		for(int i=1;i<=ansn;i++)
			c[op[ans[i]].x][op[ans[i]].y]=op[ans[i]].c;
		for(int i=0;i<16;i++)
			printf("%s
",c[i]);
	}
	return 0;
}

  • UVA1603 破坏正方形 Square Destroyer

    这篇我甚至写过题解那就直接复制过来嗯就这样。

    题目链接:Link.

    一个 (n imes n) 的网格,共 (2 imes n imes (n + 1)) 条边,现在已经删除了一些边,问至少还需删去多少边,以使得剩下的边不能构成正方形。

    一个正方形有 (4) 条边(每条边若干火柴),选择一根火柴就可以破坏掉正方形。但是也可以选择多根。

    要保证每个正方形内部至少有一根火柴被选择,且要求选择火柴数量最少

    再想想重复覆盖问题。

    很明显,这是一道重复覆盖问题。

    接下来就是如何构建了。

    首先,把这些火柴抽象成 (2n(n+1)) 个点。

    然后每根火柴和所在边长为 (len (1 le len le n)) 的矩形的边上所有火柴连起来。

    如下图:(举例点 (1)

    具体细节见代码,以下为代码:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<cmath>
#include<vector> 
#include<algorithm>
using namespace std;
const int Maxn=3600+5;
int T,n,m,l[Maxn],r[Maxn],u[Maxn],d[Maxn],s[Maxn];
int idx,tot,ans[Maxn],col[Maxn],row[Maxn];
bool st[Maxn]; // 1.作为估价函数 2.作为建图 
vector<int> sq[60];
void init()
{	
	for(int i=0;i<=m;i++)
	{	
    	l[i]=i-1;r[i]=i+1;
		u[i]=d[i]=col[i]=i;
		s[i]=0;
	}
	l[0]=m;r[m]=0;
	idx=m+1;
}
int h() //估价函数 
{	
	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 dfs(int k)
{	
	if(k+h()-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(dfs(k+1))return 1;
		for(int j=l[i];j!=i;j=l[j])resum(j);
		resum(i);
	}
	return 0;
}
int main()
{	
	scanf("%d",&T);
	while(T--)
	{	
		int k;
		scanf("%d%d",&n,&k);
		m=idx=0;
		memset(st,0,sizeof(st));
		memset(col,0,sizeof(col));
		for(int i=1;i<=k;i++)
		{	
			int x;
			scanf("%d",&x);
			st[x]=1;
		}
		for(int len=1;len<=n;len++)
			for(int x=1;x+len-1<=n;x++)
				for(int y=1;y+len-1<=n;y++)
				{
					m++;
					sq[m].clear();
					int dd=n*2+1;//一行过去(横n加上竖n+1)个数
					for(int i=0;i<len;i++) //向一个矩形的 4 边(上下左右)连去 
					{	
						sq[m].push_back((x-1)*dd+y+i); //上 
						sq[m].push_back((x-1)*dd+y+i+dd*len); //下 
						sq[m].push_back((x-1)*dd+y+n+i*dd); //左 
						sq[m].push_back((x-1)*dd+y+n+i*dd+len); //右 
					}
					for(int i=0;i<sq[m].size();i++) //相连的边有一个断了,直接删除所有即可 
						if(st[sq[m][i]]){m--;break;}
				}
		init();
		for(int i=1;i<=n*(n+1)*2;i++) //全部边
			if(!st[i])
			{	
				int hh=idx,tl=idx;
				for(int j=1;j<=m;j++)
					if(find(sq[j].begin(),sq[j].end(),i)!=sq[j].end()) //连边存在 
						add(hh,tl,i,j);
			} 
		tot=0;
		while(!dfs(1))tot++;
		printf("%d
",tot);
	}
	return 0;
}

[ ext{by Rainy7} ]

原文地址:https://www.cnblogs.com/Rainy7/p/Dancing-Links-X-note.html