首页 > 编程语言 > 详细

浅谈 Dancing Links X 算法

时间:2020-04-09 14:30:49      阅读:85      评论:0      收藏:0      [点我收藏+]

CSDN同步

前置知识:

  • 一维链表。(单向,双向,循环)
  • 部分集合运算,如 \(\bigcap\),\(\bigcup\).

前言

在计算机科学中,X算法可用来求解精确覆盖问题。

精确覆盖问题 是哪一类问题呢? \(X\) 算法又是什么高深的算法呢?

背景

  • 你的同学通过某种算法迅速 \(\text{AC}\) 了 P1784 数独,然后他兴致勃勃地 带领学生 \(1s\) 搞定数独竞赛 。

小时候,你玩数独;长大了,数独玩你。你该怎么办?

小时候,你玩智慧珠;长大了,智慧珠玩你。你该怎么办?

定义

\(\text{Dancing Links X}\) 是优化后的 \(X\) 算法,用于解决 精确覆盖问题。

精确覆盖问题 是这样一类问题:给定若干集合 \(S_1 , S_2 \cdots S_n\) 和一个集合 \(X\),求出 \(T1 , T2 \cdots T_k\) 使得满足:

\[T_i \bigcap T_j = \varnothing (i \not = j) \]

\[\bigcup_{i=1}^k T_i = X \]

\[? i \in [1,k] , T_i \in {S_1 , S_2 \cdots S_n} \]

翻译成人话 其实就是在 \(n\) 个集合中选出若干 两两不相交 的集合且 这些集合的并为 \(X\).

比方说:

\[S_1 = {666,2,4} \]

\[S_2={2,4,8,119} \]

\[S_3={8,5} \]

\[S_4={666,2,4,119} \]

\[S_5={666,4,5} \]

\[X={666,2,8,4,5,119} \]

那么这时答案就应该是取 \(S_4\) 和 \(S_3\).

那么,如何解决这类问题呢?

算法一

暴力搜索解决问题。

我们用 \(2^n\) 的时间枚举每个集合是否被选择。然后 \(O(n)\) 验证即可。

时间复杂度:\(O(2^n \times nm)\).(\(m\) 为 \(\bigcup_{i=1}^n S_i\) 的大小)

算法二

不得不说算法一时间无法承受。

我们首先将 \(S\) 与 \(X\) 离散化,并构造矩阵 \(a (n \times m)\) ,其中 \(a_{i,j}\) 表示 \(S_i\) 是否含有离散化后的 \(j\);\(m\) 为 \(\bigcup_{i=1}^n S_i\) 的大小。

那么,对上述问题建矩阵可得:

\[\begin{Bmatrix} 1 & 1 & 0 & 1 & 0 & 0 \0 & 1 & 1 & 0 & 1 & 1 \0 & 0 & 1 & 0 & 1 & 0 \1 & 1 & 0 & 1 & 0 & 1 \\ 1 & 0 & 0 & 1 & 1 & 0 \\end{Bmatrix} \]

其中 \(1\) ~ \(6\) 列分别表示:\(\in 666 , \in 2 , \in 8 , \in 4 , \in 5 , \in 119\).

问题转化为:求 \(01\) 矩阵选出若干行,使得每列 有且只有 \(1\) 个 \(1\).

考虑将每行看做一个二进制数,即要求所有选出行的 或 为 \(2^m-1\),且任意两个数的 与 为 \(0\).

用一个变量记录当前或值,然后计算即可。

时间复杂度:\(O(2^n \times n)\).

算法三

考虑 \(X\) 算法。回顾一下这个图:

\[\begin{Bmatrix} 1 & 1 & 0 & 1 & 0 & 0 \0 & 1 & 1 & 0 & 1 & 1 \0 & 0 & 1 & 0 & 1 & 0 \1 & 1 & 0 & 1 & 0 & 1 \\ 1 & 0 & 0 & 1 & 1 & 0 \\end{Bmatrix} \]

首先我们选择第一行,然后把第一行的 \(1\) 所在列标记,把所有 被标记的列上有 \(1\) 的那一行删除。 得到:

技术分享图片

绿色表示标记的列,红色表示当前行,紫色表示被删除的行。

为什么呢?因为 当前列有了 \(1\),别的列不能再有了;有的肯定不能选,删除它们是一个有力的剪枝。

然后你发现你只能选第 \(3\) 行,验证后发现第 \(6\) 列没有 \(1\),所以回溯,不选第 \(1\) 行。回溯之前,需要 恢复之前删掉的行 。

技术分享图片

注:图上 \(a_{2,2}\) 忘记标了,不过第 \(1\) 行不在我们考虑的范围内(已经确定不能选了),不会影响结果;但忘记标了可能会影响理解,大家谅解一下。

当然,这里如果为了方便理解把第一行删去也是可以的。

然后同理,你发现第 \(2\) 行选了之后无行可选。(因为前面的搜索已经确定了第 \(1\) 行选了无解,所以不选)

验证,发现第 \(1\) 列没有选 \(1\) (后面的不用再看,一列无解就是无解了),直接回溯,说明第 \(2\) 行也不能选,恢复删除行。

然后选第 \(3\) 行。

技术分享图片

咦?你发现可以递归下去选第 \(4\) 行。

验证发现正确!

所以得到答案:\(S_3 , S_4\),退出搜索。

所以,\(X\) 算法的步骤大致为:

  1. 对当前矩阵删除当前行,并删去与当前行有公共元素(即同列有 \(1\)) 的所有行,递归下一层。

  2. 如果所有行返回无解即返回无解给上一层,并恢复行,回到第 \(1\) 步。

  3. 如果发现所有列都被标记则输出答案,结束搜索。

  4. 所有搜索无解则返回无解。

你发现,该搜索需要大量的 删除行,恢复行 的操作。

而 \(X\) 算法的时间复杂度是 指数级 的,其回溯、搜索层数取决于 矩阵中 \(1\) 的个数 而不是 \(n\) 或者 \(m\) 的大小。其时间复杂度大概可以接受 \(n,m \leq 100\) 的情况。

算法四

\(\text{Dancing Links 意为:跳舞的链}\)

为什么叫做 跳舞的链 呢?是这样的。

假设我们要写这样一道题目:

给定一个 \(n \times m\) 的二维矩阵,你需要支持 \(q\) 个操作:

  1. 删除 第 \(x\) 行 ;
  2. 查询 \(a_{i,j}\) 的值。
    数据范围:\(n,m \leq 500\),\(q \leq 10^5\).

当然如果你暴力删除的话,时间复杂度是 \(O(n \cdot m \cdot q)\).

但是我们想一个弱化版:

给定一个长为 \(n\) 的数组,你需要支持 \(q\) 个操作:

  1. 删除第 \(x\) 个数;
  2. 查询第 \(a_i\) 的值。
    数据范围:\(n \leq 500\),\(q \leq 10^5\).

这直接用一维链表弄一下就可以了是不?

所以,一个叫 \(\texttt{Donald E. Knuth}\) 的计算机科学家,发明了 “十字链表” 解决此类问题。

十字链表的图大概是这样的:

(下面十字链表的图,代码思路摘自 DLX 详细讲解)
技术分享图片
似乎非常简单的样子,那再给一张:

技术分享图片

说白了你第一眼看到我也是这个感觉:

技术分享图片

技术分享图片

别扯了,说正题。回到这个图:

技术分享图片

每个节点弄个指针指向它上下左右的节点,然后再开数组记录每行,每列的元素个数。 \(fir_i\) 是我们在每行链表之前虚构的一个元素,每次从它开始遍历。

STEP 1

#define FOR(i,A,x) for(int i=A[x];i!=x;i=A[i])

预处理优化宏定义都好用

定义数组。

int n,m,id,fir[N],siz[N];
int L[N],R[N],U[N],D[N];
int col[N],row[N],ans;
int stk[N]; //stk 记录答案

STEP 2

如何建立一个 \(n \times m\) 的 \(\text{Dancing Link?}\) 很显然,对于一行是这样的:

技术分享图片

inline void build(int x,int y) {
//	printf("build : %d %d\n",x,y);
	n=x,m=y; for(int i=0;i<=y;i++)
		L[i]=i-1,R[i]=i+1,U[i]=D[i]=i; //左右是 i-1,i+1 , 上下就是自己
	L[0]=y,R[y]=0,id=y;
	memset(fir,0,sizeof(fir));
	memset(siz,0,sizeof(siz));	
}

那你会说,嗯,不对呀?这样我们只是初始化一行而已?

对,所以我们对每个集合插入节点,通过插入来实现。

技术分享图片

如果 \(\text{idx}\) 的位置已经有了,那么把它插入到已有的下面去,这样 同列的 \(1\) 就会被放在不同行 了;否则已经有的话就直接按照链表思路插入。插入顺序要记清!

inline void insert(int x,int y) {
//	printf("insert : %d %d\n",x,y);
	col[++id]=y,row[id]=x,++siz[y]; //记录个数,插入
//	U[id]=D[id]=y,U[D[y]]=id,D[y]=id;
	D[id]=D[y],U[D[y]]=id,U[id]=y,D[y]=id; //维护上下
	if(!fir[x]) fir[x]=L[id]=R[id]=id; //没出现则自己作为第一个
	else R[id]=R[fir[x]],L[R[fir[x]]]=id,L[id]=fir[x],R[fir[x]]=id; //接在第一个后面
}

早警告过你今天前置知识是链表,看你不会链表一个也写不了了

STEP 3

那么如何删除一行呢?

技术分享图片

嗯,上下互相指,然后每列个数减。是不是很简单?

inline void remove(int x) {
//	printf("remove : %d\n",x);
	L[R[x]]=L[x]; R[L[x]]=R[x];
	FOR(i,D,x) FOR(j,R,i) U[D[j]]=U[j],D[U[j]]=D[j],--siz[col[j]];
}

那么同理考虑删除列:

技术分享图片

inline void recover(int x) {
//	printf("recover : %d\n",x);
	FOR(i,U,x) FOR(j,L,i) U[D[j]]=D[U[j]]=j,++siz[col[j]];
	L[R[x]]=R[L[x]]=x;
}

算法五

回忆一下 \(X\) 算法的过程:

  1. 对当前矩阵删除当前行,并删去与当前行有公共元素(即同列有 \(1\)) 的所有行,递归下一层。

  2. 如果所有行返回无解即返回无解给上一层,并恢复行,回到第 \(1\) 步。

  3. 如果发现所有列都被标记则输出答案,结束搜索。

  4. 所有搜索无解则返回无解。

加个剪枝: 选择列元素最少的删除,这个剪枝太显然了吧。因为元素少的更容易被决定。

你发现 删除行,列 可以直接用上面的十字链表维护。

我们要开始写最重要的 \(\text{dance}\) 啦! 其实十字链表真的很想跳舞的,不然不会叫这么名字的。

强烈建议读者返回亲自推一下那个图,不然代码实在理解不了。

inline bool dance(int dep) {
//	printf("dance : %d\n",dep);
	if(!R[0]) {ans=dep;return 1;} //0 号没有右边元素 , 即整列都被标记,说明有答案
	int wz=R[0]; FOR(i,R,0) if(siz[i]<siz[wz]) wz=i; // 找到最小的那个
	remove(wz); FOR(i,D,wz) { //删除
		stk[dep]=row[i]; FOR(j,R,i) remove(col[j]); //标记的全部删除
		if(dance(dep+1)) return 1; //往下走
		FOR(j,L,i) recover(col[j]); //恢复
	} recover(wz); return 0; //恢复 , 返回无解
}

嗯,好了,大家只要弄明白每部分的意思,我们就来看题吧!

P4929 【模板】舞蹈链(DLX)

时间复杂度:\(O(c^n)\). 是指数级的,\(n\) 是矩阵 \(1\) 的个数,\(c\) 是某个很接近 \(1\) (\(>1\)) 的常数。但一般而言不会出现卡 \(\text{Dancing Links X}\) 的出题人吧。

实际得分:\(100pts\).

#pragma GCC optimize(2)
#include<bits/stdc++.h>
using namespace std;

const int N=1e5+1; 
#define FOR(i,A,x) for(int i=A[x];i!=x;i=A[i])

inline int read(){char ch=getchar();int f=1;while(ch<‘0‘ || ch>‘9‘) {if(ch==‘-‘) f=-f; ch=getchar();}
	int x=0;while(ch>=‘0‘ && ch<=‘9‘) x=(x<<3)+(x<<1)+ch-‘0‘,ch=getchar();return x*f;}

int n,m,id,fir[N],siz[N];
int L[N],R[N],U[N],D[N];
int col[N],row[N],ans;
int stk[N];

inline void remove(int x) {
//	printf("remove : %d\n",x);
	L[R[x]]=L[x]; R[L[x]]=R[x];
	FOR(i,D,x) FOR(j,R,i) U[D[j]]=U[j],D[U[j]]=D[j],--siz[col[j]];
}

inline void recover(int x) {
//	printf("recover : %d\n",x);
	FOR(i,U,x) FOR(j,L,i) U[D[j]]=D[U[j]]=j,++siz[col[j]];
	L[R[x]]=R[L[x]]=x;
}

inline void build(int x,int y) {
//	printf("build : %d %d\n",x,y);
	n=x,m=y; for(int i=0;i<=y;i++)
		L[i]=i-1,R[i]=i+1,U[i]=D[i]=i;
	L[0]=y,R[y]=0,id=y;
	memset(fir,0,sizeof(fir));
	memset(siz,0,sizeof(siz));	
}

inline void insert(int x,int y) {
//	printf("insert : %d %d\n",x,y);
	col[++id]=y,row[id]=x,++siz[y];
//	U[id]=D[id]=y,U[D[y]]=id,D[y]=id;
	D[id]=D[y],U[D[y]]=id,U[id]=y,D[y]=id;
	if(!fir[x]) fir[x]=L[id]=R[id]=id;
	else R[id]=R[fir[x]],L[R[fir[x]]]=id,L[id]=fir[x],R[fir[x]]=id;
}

inline bool dance(int dep) {
//	printf("dance : %d\n",dep);
	if(!R[0]) {ans=dep;return 1;}
	int wz=R[0]; FOR(i,R,0) if(siz[i]<siz[wz]) wz=i;
	remove(wz); FOR(i,D,wz) {
		stk[dep]=row[i]; FOR(j,R,i) remove(col[j]);
		if(dance(dep+1)) return 1;
		FOR(j,L,i) recover(col[j]);
	} recover(wz); return 0;
}

int main(){
	n=read(),m=read(); build(n,m);
	for(int i=1;i<=n;i++) for(int j=1;j<=m;j++) 
		if(read()) insert(i,j);
	dance(1);
	if(ans) for(int i=1;i<ans;i++) printf("%d ",stk[i]);
	else puts("No Solution!");	
	return 0;
}

嗯,博主之后会更数独的解法,还有智慧珠。不过现在这儿咕一会儿吧。

利用精确覆盖解决问题!

分支题解

Link 代码

有了 DLX 解决精确覆盖,我们 1s AK 数独不是梦想!

浅谈 Dancing Links X 算法

原文:https://www.cnblogs.com/bifanwen/p/12666423.html

踩
(1)
赞
(1)
   
举报
评论 一句话评论(0)
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!