浅谈 Dancing Links X 算法

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 数独不是梦想!

posted @ 2020-04-09 13:43  bifanwen  阅读(814)  评论(1编辑  收藏  举报