算法 数据结构 搜索 Dancing Links POJO 2024-08-24 2024-08-28 Dancing Links
本页将介绍精确覆盖问题、重复覆盖问题,解决这两个问题的算法「X
算法」,以及用来优化 X 算法的双向十字链表 Dancing
Link。本页也将介绍如何在建模的配合下使用 DLX 解决一些搜索题。
更多细节:Dancing
Links
精确覆盖问题
定义
精确覆盖问题(英文:Exact Cover Problem)是指给定许多集合 以及一个集合 ,求满足以下条件的无序多元组 :
解释
例如,若给出
则
为一组合法解。
问题转化
将
中的所有数离散化,可以得到这么一个模型:
给定一个 01
矩阵,你可以选择一些行(row),使得最终每列(column)都恰好有一个 1。
举个例子,我们对上文中的例子进行建模,可以得到这么一个矩阵:
其中第 行表示着 ,而这一行的每个数依次表示 。
实现
暴力 1
一种方法是枚举选择哪些行,最后检查这个方案是否合法。
因为每一行都有选或者不选两种状态,所以枚举行的时间复杂度是 的;
而每次检查都需要
的时间复杂度。所以总的复杂度是 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 int ok = 0 ; for (int state = 0 ; state < 1 << n; ++state) { int flag = 1 ; for (int i = 1 ; i <= n; ++i) { if ((1 << i - 1 ) & state) { (对于当前行记录哪些已选) } (和其他行(之前已记录的行)对比看是不是互补) if (冲突了) { flag = 0 ; break ; } } if (!flag) { continue ; } else { ok = 1 ; for (int i = 1 ; i <= n; ++i) if ((1 << i - 1 ) & state) printf ("%d " , i); puts ("" ); } } if (!ok) puts ("No solution." );
暴力 2
考虑到 01 矩阵的特殊性质,每一行都可以看做一个 位二进制数。
因此原问题转化为
给定 个
位二进制数,要求选择一些数,使得任意两个数的与都为 0,且所有数的或为
。tmp
表示的是截至目前被选中的二进制数的或。
因为每一行都有选或者不选两种状态,所以枚举行的时间复杂度为 ;
而每次计算 tmp 都需要 的时间复杂度。所以总的复杂度为 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int ok = 0 ;for (int i = 1 ; i <= n; ++i) for (int j = m; j >= 1 ; --j) num[i] = num[i] << 1 | a[i][j]; for (int state = 0 ; state < 1 << n; ++state) { int tmp = 0 ; for (int i = 1 ; i <= n; ++i) if ((1 << i - 1 ) & state) { if (tmp & num[i]) break ; tmp |= num[i]; } if (tmp == (1 << m) - 1 ) { ok = 1 ; for (int i = 1 ; i <= n; ++i) if ((1 << i - 1 ) & state) printf ("%d " , i); puts ("" ); } } if (!ok) puts ("No solution." );
重复覆盖问题
重复覆盖问题与精确覆盖问题类似,但没有对元素相似性的限制。下文介绍的
X 算法
原本针对精确覆盖问题,但经过一些修改和优化(已标注在其中)同样可以高效地解决重复覆盖问题。
X 算法
Donald E. Knuth 提出了 X 算法 (Algorithm
X),其思想与刚才的暴力差不多,但是方便优化。
过程
继续以上文中中提到的例子为载体,得到一个这样的 01 矩阵:
此时第一行有 个 ,第二行有 个 ,第三行有 个 ,第四行有 个 ,第五行有 个 ,第六行有 个 。选择第一行,将它删除,并将所有 所在的列打上标记;
选择所有被标记的列,将它们删除,并将这些列中含
的行打上标记(重复覆盖问题无需打标记);
选择所有被标记的行,将它们删除;
这表示这一行已被选择,且这一行的所有 所在的列不能有其他 了 。
于是得到一个新的小 01 矩阵:
此时第一行(原来的第二行)有 个 ,第二行(原来的第四行)有 个 ,第三行(原来的第五行)有 个 。选择第一行(原来的第二行),将它删除,并将所有
所在的列打上标记;
选择所有被标记的列,将它们删除,并将这些列中含 的行打上标记;
选择所有被标记的行,将它们删除;
这样就得到了一个空矩阵。但是上次删除的行 1 0 1 1 不是全
的,说明选择有误;
回溯到步骤 4,考虑选择第二行(原来的第四行),将它删除,并将所有
所在的列打上标记;
选择所有被标记的列,将它们删除,并将这些列中含 的行打上标记;
选择所有被标记的行,将它们删除;
于是我们得到了这样的一个矩阵:
此时第一行(原来的第五行)有 个 ,将它们全部删除,得到一个空矩阵:
上一次删除的时候,删除的是全 的行,因此成功,算法结束。
答案即为被删除的三行: 。
强烈建议自己模拟一遍矩阵删除、还原与回溯的过程后,再接着阅读下文。
通过上述步骤,可将 X 算法的流程概括如下:
对于现在的矩阵 ,选择并标记一行 ,将 添加至 中;
如果尝试了所有的
却无解,则算法结束,输出无解;
标记与 相关的行 和 (相关的行和列与 X
算法 中第 2 步定义相同,下同);
删除所有标记的行和列,得到新矩阵 ;
如果 为空,且 为全 ,则算法结束,输出被删除的行组成的集合
;
如果 为空,且 不全为 ,则恢复与 相关的行 以及列 ,跳转至步骤 1;
如果 不为空,则跳转至步骤
1。
不难看出,X
算法需要大量的「删除行」、「删除列」和「恢复行」、「恢复列」的操作。
一个朴素的想法是,使用一个二维数组存放矩阵,再用四个数组分别存放每一行与之相邻的行编号,每次删除和恢复仅需更新四个数组中的元素。但由于一般问题的矩阵中
0 的数量远多于 1 的数量,这样做的空间复杂度难以接受。
Donald E. Knuth 想到了用双向十字链表来维护这些操作。
而在双向十字链表上不断跳跃的过程被形象地比喻成「跳跃」,因此被用来优化
X 算法的双向十字链表也被称为「Dancing Links」。
Dancing Links 优化的 X 算法
预编译命令
1 #define IT(i, A, x) for (i = A[x]; i != x; i = A[i])
定义
双向十字链表中存在四个指针域,分别指向上、下、左、右的元素;且每个元素
在整个双向十字链表系中都对应着一个格子,因此还要表示 所在的列和所在的行,如图所示:
大型的双向链表则更为复杂:
每一行都有一个行首指示,每一列都有一个列指示。
行首指示为 first[],列指示是我们新建的
个哨兵结点。值得注意的是,行首指示并非是链表中的哨兵结点 。它是虚拟的,类似于邻接表中的
first[] 数组,直接指向
这一行中的首元素。
同时,每一列都有一个 siz[] 表示这一列的元素个数。
特殊地,
号结点无右结点等价于这个 Dancing Links 为空。
1 2 3 4 constexpr int MS = 1e5 + 5 ;int n, m, idx, first[MS], siz[MS];int L[MS], R[MS], U[MS], D[MS];int col[MS], row[MS];
过程
remove 操作
remove(c) 表示在 Dancing Links 中删除第 列以及与其相关的行和列。
先将 删除,此时:
左侧的结点的右结点应为 的右结点。
右侧的结点的左结点应为 的左结点。
即 L[R[c]] = L[c], R[L[c]] = R[c];。
然后顺着这一列往下走,把走过的每一行都删掉。
如何删掉每一行呢?枚举当前行的指针 ,此时:
上方的结点的下结点应为 的下结点。
下方的结点的上结点应为 的上结点。
注意要修改每一列的元素个数。
即 U[D[j]] = U[j], D[U[j]] = D[j], --siz[col[j]];。
remove 函数的代码实现如下:
1 2 3 4 5 6 7 8 9 void remove (const int &c) { int i, j; L[R[c]] = L[c], R[L[c]] = R[c]; IT (i, D, c) IT (j, R, i) U[D[j]] = U[j], D[U[j]] = D[j], --siz[col[j]]; }
recover 操作
recover(c) 表示在 Dancing Links 中还原第 列以及与其相关的行和列。
recover(c) 即 remove(c)
的逆操作,这里不再赘述。
值得注意的是, recover(c)
的所有操作的顺序与 remove(c)
的操作恰好相反。
recover(c) 的代码实现如下:
1 2 3 4 5 void recover (const int &c) { int i, j; IT (i, U, c) IT (j, L, i) U[D[j]] = D[U[j]] = j, ++siz[col[j]]; L[R[c]] = R[L[c]] = c; }
build 操作
build(r, c) 表示新建一个大小为 ,即有 行, 列的 Dancing Links。
新建
个结点作为列指示。
第 个点的左结点为 ,右结点为 ,上结点为 ,下结点为 。特殊地, 结点的左结点为 ,
结点的右结点为 。
于是我们得到了一个环状双向链表:
这样就初始化了一个 Dancing Links。
build(r, c) 的代码实现如下:
1 2 3 4 5 6 7 8 9 10 void build (const int &r, const int &c) { n = r, m = c; for (int i = 0 ; i <= c; ++i) { L[i] = i - 1 , R[i] = i + 1 ; U[i] = D[i] = i; } L[0 ] = c, R[c] = 0 , idx = c; memset (first, 0 , sizeof (first)); memset (siz, 0 , sizeof (siz)); }
insert 操作
insert(r, c) 表示在第 行,第 列插入一个结点。
插入操作分为两种情况:
如果第
行没有元素,那么直接插入一个元素,并使 first[r]
指向这个元素。
这可以通过 first[r] = L[idx] = R[idx] = idx;
来实现。
如果第
行有元素,那么将这个新元素用一种特殊的方式与 和 连接起来。
设这个新元素为 ,然后:
把 插入到 的正下方,此时:
下方的结点为原来 的下结点;
下方的结点(即原来 的下结点)的上结点为 ;
的上结点为 ;
的下结点为 。
注意记录
的所在列和所在行,以及更新这一列的元素个数。
1 2 col[++idx] = c, row[idx] = r, ++siz[c]; U[idx] = c, D[idx] = D[c], U[D[c]] = idx, D[c] = idx;
强烈建议读者完全掌握这几步的顺序后再继续阅读本文。
把 插入到 的正右方,此时:
右侧的结点为原来 的右结点;
原来
右侧的结点的左结点为 ;
的左结点为 ;
的右结点为 。
1 2 L[idx] = first[r], R[idx] = R[first[r]]; L[R[first[r]]] = idx, R[first[r]] = idx;
强烈建议读者完全掌握这几步的顺序后再继续阅读本文。
insert(r, c) 这个操作可以通过图片来辅助理解:
留心曲线箭头的方向。
insert(r, c) 的代码实现如下:
1 2 3 4 5 6 7 8 9 10 void insert (const int &r, const int &c) { row[++idx] = r, col[idx] = c, ++siz[c]; U[idx] = c, D[idx] = D[c], U[D[c]] = idx, D[c] = idx; if (!first[r]) first[r] = L[idx] = R[idx] = idx; else { L[idx] = first[r], R[idx] = R[first[r]]; L[R[first[r]]] = idx, R[first[r]] = idx; } }
dance 操作
dance() 即为递归地删除以及还原各个行列的过程。
如果
号结点没有右结点,那么矩阵为空,记录答案并返回;
选择列元素个数最少的一列,并删掉这一列;
遍历这一列所有有
的行,枚举它是否被选择;
递归调用
dance(),如果可行,则返回;如果不可行,则恢复被选择的行;
如果无解,则返回。
dance() 的代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 bool dance (int dep) { int i, j, c = R[0 ]; if (!R[0 ]) { ans = dep; return 1 ; } IT (i, R, 0 ) if (siz[i] < siz[c]) c = i; remove (c); IT (i, D, c) { stk[dep] = row[i]; IT (j, R, i) remove (col[j]); if (dance (dep + 1 )) return 1 ; IT (j, L, i) recover (col[j]); } recover (c); return 0 ; }
其中 stk[] 用来记录答案。
注意我们每次优先选择列元素个数最少的一列进行删除,这样能保证程序具有一定的启发性,使搜索树分支最少。
对于重复覆盖问题,在搜索时可以用估价函数(与 A*
中类似)进行剪枝:若当前最好情况下所选行数超过目前最优解,则可以直接返回。
模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 #include <bits/stdc++.h> const int N = 500 + 10 ;int n, m, ans;int stk[N];int read () { int x = 0 , f = 0 , ch; while (!isdigit (ch = getchar ())) f |= ch == '-' ; while (isdigit (ch)) x = (x << 1 ) + (x << 3 ) + (ch ^ 48 ), ch = getchar (); return f ? -x : x; } struct DLX { static const int MAXSIZE = 1e5 + 10 ; int n, m, tot, first[MAXSIZE + 10 ], siz[MAXSIZE + 10 ]; int L[MAXSIZE + 10 ], R[MAXSIZE + 10 ], U[MAXSIZE + 10 ], D[MAXSIZE + 10 ]; int col[MAXSIZE + 10 ], row[MAXSIZE + 10 ]; void build (const int &r, const int &c) { n = r, m = c; for (int i = 0 ; i <= c; ++i) { L[i] = i - 1 , R[i] = i + 1 ; U[i] = D[i] = i; } L[0 ] = c, R[c] = 0 , tot = c; memset (first, 0 , sizeof (first)); memset (siz, 0 , sizeof (siz)); } void insert (const int &r, const int &c) { col[++tot] = c, row[tot] = r, ++siz[c]; D[tot] = D[c], U[D[c]] = tot, U[tot] = c, D[c] = tot; if (!first[r]) first[r] = L[tot] = R[tot] = tot; else { R[tot] = R[first[r]], L[R[first[r]]] = tot; L[tot] = first[r], R[first[r]] = tot; } } void remove (const int &c) { int i, j; L[R[c]] = L[c], R[L[c]] = R[c]; for (i = D[c]; i != c; i = D[i]) for (j = R[i]; j != i; j = R[j]) U[D[j]] = U[j], D[U[j]] = D[j], --siz[col[j]]; } void recover (const int &c) { int i, j; for (i = U[c]; i != c; i = U[i]) for (j = L[i]; j != i; j = L[j]) U[D[j]] = D[U[j]] = j, ++siz[col[j]]; L[R[c]] = R[L[c]] = c; } bool dance (int dep) { if (!R[0 ]) { ans = dep; return 1 ; } int i, j, c = R[0 ]; for (i = R[0 ]; i != 0 ; i = R[i]) if (siz[i] < siz[c]) c = i; remove (c); for (i = D[c]; i != c; i = D[i]) { stk[dep] = row[i]; for (j = R[i]; j != i; j = R[j]) remove (col[j]); if (dance (dep + 1 )) return 1 ; for (j = L[i]; j != i; j = L[j]) recover (col[j]); } recover (c); return 0 ; } } solver; int main () { n = read (), m = read (); solver.build (n, m); for (int i = 1 ; i <= n; ++i) for (int j = 1 ; j <= m; ++j) { int x = read (); if (x) solver.insert (i, j); } solver.dance (1 ); if (ans) for (int i = 1 ; i < ans; ++i) printf ("%d " , stk[i]); else puts ("No Solution!" ); return 0 ; }
性质
DLX 递归及回溯的次数与矩阵中
的个数有关,与矩阵的
等参数无关。因此,它的时间复杂度是 指数级
的,理论复杂度大概在
左右,其中 为某个非常接近于 的常数, 为矩阵中 的个数。
但实际情况下 DLX 表现良好,一般能解决大部分的问题。
建模
DLX 的难点,不全在于链表的建立,而在于建模。
请确保已经完全掌握 DLX 模板后再继续阅读本文。
我们每拿到一个题,应该考虑行和列所表示的意义:
对于某一行而言,由于不同的列的值不尽相同,我们
由不同的状态,定义了一个决策 。
数独
题目描述
数独是根据
盘面上的已知数字,推理出所有剩余空格的数字,并满足每一行、每一列、每一个粗线宫内的数字均含
,不重复。每一道合格的数独谜题都有且仅有唯一答案,推理方法也以此为基础,任何无解或多解的题目都是不合格的。
芬兰一位数学家号称设计出全球最难的“数独游戏”,并刊登在报纸上,让大家去挑战。
这位数学家说,他相信只有“智慧最顶尖”的人才有可能破解这个“数独之谜”。
据介绍,目前数独游戏的难度的等级有一到五级,一是入门等级,五则比较难。不过这位数学家说,他所设计的数独游戏难度等级是十一,可以说是所以数独游戏中,难度最高的等级。他还表示,他目前还没遇到解不出来的数独游戏,因此他认为“最具挑战性”的数独游戏并没有出现。
输入格式
一个未填的数独。
输出格式
填好的数独。
样例 #1
样例输入 #1
1 2 3 4 5 6 7 8 9 8 0 0 0 0 0 0 0 0 0 0 3 6 0 0 0 0 0 0 7 0 0 9 0 2 0 0 0 5 0 0 0 7 0 0 0 0 0 0 0 4 5 7 0 0 0 0 0 1 0 0 0 3 0 0 0 1 0 0 0 0 6 8 0 0 8 5 0 0 0 1 0 0 9 0 0 0 0 4 0 0
样例输出 #1
1 2 3 4 5 6 7 8 9 8 1 2 7 5 3 6 4 9 9 4 3 6 8 2 1 7 5 6 7 5 4 9 1 2 8 3 1 5 4 2 3 7 8 9 6 3 6 9 8 4 5 7 2 1 2 8 7 1 6 9 5 3 4 5 2 1 9 7 4 3 6 8 4 3 8 5 2 6 9 1 7 7 9 6 3 1 8 4 5 2
题解
决策是什么?
在这一题中,每一个决策可以用形如 的有序三元组表示。
注意到「宫」并不是决策的参数,因为它 可以被每个确定的 表示 。
因此有
行。
状态是什么?
我们思考一下
这个决将会造成什么影响。记
所在的宫为 。
第 行用了一个 (用 列表示);
第 列用了一个 (用 列表示);
第 宫用了一个 (用 列表示);
中填入了一个数(用 列表示)。
因此有 列,共
个 。
至此,我们成功地将
的数独问题转化成了一个 有 行, 列,共 个 的精确覆盖问题。
太难了 改日再战