【Coel.学习笔记】舞蹈链(Dancing Links X)
本来想补充几个 LCT 例题的,但还是太懒放弃了。
有这个时间多点几个技能不好吗?反正 LCT 又不是 NOI 的考点~
说得好像舞蹈链在大纲里面一样
引入
舞蹈链(\(\text{Dancing Links}\)) 是一种支持上下左右查询的双向十字链表,配合 X 算法使用合称 \(\text{Dancing Links X}\)(下称 DLX) 可以解决精确覆盖问题(使用普通搜索)和重复覆盖问题(使用启发式迭代加深搜索)。
朴素的搜索算法时间复杂度为 \(O(nm2^n)\),DLX 则可以做到 \(O(c^k)\),其中 \(k\) 为能贡献答案的点的个数, \(c\) 为一个非常接近 \(1\) 的常数。实际上,DLX 在实际应用中很难达到上限,对于 \(k\leq 10^4\) 的数据也能非常快地通过。
具体操作
DLX 重在建模,所以先看例题。
【模板】舞蹈链(DLX)
洛谷传送门
给定一个 \(01\) 矩阵,在矩阵中选出若干行,使得这些行的每一列都有且仅有一个数字 \(1\)。
解析:这是一个 \(\mathsf{NP}\) 完全的问题,所以只能爆搜。当然,即使是爆搜也有优化的方式。
先考虑如下问题:
怎么存矩阵?
使用十字链表。对于每个数字 \(1\) 建立节点,然后看看每个节点的上、下、左、右四个方向应该与谁相连。在实际操作中,我们通常按行建立链表。
先做初始化,然后用两个指针维护左右关联的节点。每次插入时上下和本行一致,左右利用指针即可。
怎么利用链表搜索?
从没选到的行中任意选择一行开始做 dfs,再递归处理。显然,这样做可以保证搜到所有结果。
做哪些剪枝?
- 每次枚举的时候选择 \(1\) 的个数最少的列。
- 删掉不合法的所有列。比如说选择了第一行,这时第一行有 \(1\) 的列是第二列,那么第二列有 \(1\) 的都得删除。别忘了回溯时还得恢复整个链表,操作和删除操作正好相反。
\(\text{Talk is cheap, show you the code.}\)
代码注释粗略解释了各个操作的做法。
#include <algorithm>
#include <cstdio>
#include <iostream>
#include <vector>
using namespace std;
const int maxn = 1e4 + 10;
int n, m;
int l[maxn], r[maxn], u[maxn], d[maxn]; //上下左右
int row[maxn], col[maxn], s[maxn], cnt; //行,列,本列 1 的个数
vector<int> ans;
void init_DLX() { //初始化,没什么好说的
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;
cnt = m + 1;
}
void add(int &head, int &tail, int x, int y) { //给某一个点创建节点
row[cnt] = x, col[cnt] = y, s[y]++;
u[cnt] = y, d[cnt] = d[y], u[d[y]] = cnt, d[y] = cnt;
r[head] = l[tail] = cnt, r[cnt] = tail, l[cnt] = head;
tail = cnt++;
}
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 resume(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 true; //搜到边界
int p = r[0]; // p 为当前所有列中 1 最少的
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.push_back(row[i]); //保存答案
for (int j = r[i]; j != i; j = r[j]) remove(col[j]);
if (dfs()) return true; //往下能到达边界,答案正确
for (int j = l[i]; j != i; j = l[j])
resume(col[j]); //答案不正确,恢复现场
ans.pop_back(); //删除不正确的答案
}
resume(p); //所有答案均不正确,恢复 p
return false;
}
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
init_DLX();
for (int i = 1; i <= n; i++) {
int head = cnt, tail = cnt;
for (int j = 1, x; j <= m; j++) {
// x = 1 才需要建节点
cin >> x;
if (x) add(head, tail, i, j);
}
}
if (dfs()) {
sort(ans.begin(), ans.end());
for (auto x : ans) cout << x << ' ';
} else
cout << "No Solution!";
return 0;
}
例题讲解 - 重复覆盖问题
DLX 和网络流一样,解题使用的模板都差不多,但要认真分析题目性质、建立模型才能求解,这也就需要大量的练习与思考。
下面看几道 DLX 的应用。
SP1110 SUDOKU - Sudoku
洛谷传送门 双倍经验
给定一个 \(16\times 16\) 的数独,用大写英文字母 \(A\sim P\) 填满整个数独,使得每行、每列、每个 \(4\times 4\) 的宫中不存在重复字母。数据保证数独有且只有一组解。
解析:精确覆盖问题有一个常用的方法,把选法对应为 DLX 的行,限制对应为 DLX 的列。
对于这题,我们把每个格子填入的字母对应成行。列呢?数独限制比较多,我们仔细梳理一下。
- 每个格子只填一个字母。
- 每行的字母 \(A\sim P\) 都恰好只出现一次。
- 每列的字母都恰好只出现一次。
- 每宫的字母都恰好只出现一次。
对每个限制都开设一个节点,就成功建立了模型。类似网络流,可以证明这个模型做出的答案可以与原问题的方案一一对应,因此这个模型是可行的。
此外注意交到 UVA 上的时候最后不能输出两个 \n,要特判一下。
int main(void) {
bool OvO = false;
ios::sync_with_stdio(false);
cin.tie(nullptr);
while (cin >> g[0]) {
if (OvO) cout << '\n';
OvO = true;
for (int i = 1; i < 16; i++) cin >> g[i];
init_DLX();
for (int i = 0, n = 1; i < 16; i++)
for (int j = 0; j < 16; j++) {
int a = 0, b = 15;
if (g[i][j] != '-') a = b = g[i][j] - 'A';
for (int k = a; k <= b; k++, n++) {
int head = cnt, tail = cnt;
op[n] = {i, j, char(k + 'A')};
add(head, tail, n, i * 16 + j + 1);
add(head, tail, n, 256 + i * 16 + k + 1);
add(head, tail, n, 256 * 2 + j * 16 + k + 1);
add(head, tail, n,
256 * 3 + (i / 4 * 4 + j / 4) * 16 + k + 1);
}
}
dfs();
for (auto x : ans) {
auto t = op[x];
g[t.x][t.y] = t.z;
}
for (int i = 0; i < 16; i++) cout << g[i] << '\n';
}
return 0;
}
[NOI2005] 智慧珠游戏
洛谷传送门
给出一个智慧珠的初始布局,求一种可行的智慧珠摆放方案,使所有的零件都能放进盘件中。智慧珠的摆放规则参见原题面。
解析:这是一道非常恶心的码农题,需要很强的代码力和 Debug 力……不管怎样,我们先看看怎么把问题转化成精确覆盖问题的模型。
对于行,我们用每种零件的摆法对应。零件最多翻转四次、旋转四次、平移 \(55\) 个格子,其中翻转又有 \(12\) 种零件,总共就是 \(12\times 8 \times 55=5280\) 行。
对于列,我们有如下几个限制:
- 每个格子只能放上零件的一个珠子,对应 \(55\) 列;
- 每个零件只能用一次,对应 \(12\) 列。
这样,对应到 DLX 上的 \(1\) 就有 \(5280 \times(5+1)=31680\) 个。
在实际建立十字链表的时候,我们显然不能写上千行特判,要“少写多想”简化代码。对于翻转操作,沿中线交换两边即可;对于旋转操作,可以用两次翻转操作替代;对于平移操作,暴力枚举偏移量。
下面的代码把 DLX 部分用一个类封装起来。以后写数据结构也会尽量用这种方式~
#include <algorithm>
#include <cstring>
#include <iostream>
#include <set>
#include <vector>
using namespace std;
const int maxn = 5e4 + 10, m = 55 + 12;
char Pearl[12][4][5] = {
//字符数组存图,暴力但优雅
{"OO", "O "}, // A
{"OOOO", " ", " ", " "}, // B
{"OOO", "O ", " "}, // C
{"OO", "OO"}, // D
{"O ", "O ", "OOO"}, // E
{"OOOO", " O ", " ", " "}, // F
{"OOO", "O O", " "}, // G
{"OOO", "OO ", " "}, // H
{"OOO ", " OO", " ", " "}, // I
{" O ", "OOO", " O "}, // J
{"O ", "OO ", " OO"}, // K
{"OOOO", "O ", " ", " "}, // L
};
int id[10][10], s[maxn];
char chr[maxn], g[20][20];
vector<vector<vector<int>>> sol;
set<vector<vector<int>>> S;
class Dancing_Links_X {
private:
int u[maxn], d[maxn], l[maxn], r[maxn];
int row[maxn], col[maxn];
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 resume(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;
}
public:
int cnt = 0;
vector<int> ans;
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;
cnt = m + 1;
}
void add(int& head, int& tail, int x, int y) {
row[cnt] = x, col[cnt] = y, s[y]++;
u[cnt] = y, d[cnt] = d[y], u[d[y]] = cnt, d[y] = cnt;
r[head] = l[tail] = cnt, r[cnt] = tail, l[cnt] = head;
tail = cnt++;
}
bool dfs() {
if (!r[0]) return true;
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.push_back(row[i]);
for (int j = r[i]; j != i; j = r[j]) remove(col[j]);
if (dfs()) return true;
for (int j = l[i]; j != i; j = l[j]) resume(col[j]);
ans.pop_back();
}
resume(p);
return false;
}
} DLX;
void rev_plk(char str[4][5]) { //水平
int len = strlen(str[0]);
for (int i = 0, j = len - 1; i < j; i++, j--)
for (int k = 0; k < len; k++) swap(str[i][k], str[j][k]);
}
void rev_dia(char str[4][5]) { //对角线
int len = strlen(str[0]);
for (int i = 0; i < len; i++)
for (int j = 0; j < i; j++) swap(str[i][j], str[j][i]);
}
auto get(char str[4][5], int a, int b) {
vector<vector<int>> res;
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
if (str[i][j] == 'O') res.push_back({i + a, j + b});
return res;
}
bool check(char str[4][5], int c, int a, int b) {
auto A = get(str, a, b);
vector<vector<int>> B;
for (int i = 0; i < 10; i++)
for (int j = 0; j <= i; j++)
if (g[i][j] == c + 'A') B.push_back({i, j});
if (!B.empty()) {
sort(A.begin(), A.end());
sort(B.begin(), B.end());
return A == B;
}
for (auto& p : A) {
int x = p[0], y = p[1];
if (x < 0 || x >= 10 || y < 0 || y >= 10) return false;
if (g[x][y] != '.') return false;
}
return true;
}
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
DLX.init();
for (int i = 0, k = 1; i < 10; i++)
for (int j = 0; j <= i; j++, k++) id[i][j] = k;
for (int i = 0; i < 10; i++) cin >> g[i];
for (int i = 0; i < 12; i++)
for (int u = 0; u < 2; u++) {
for (int j = 0; j < 4; j++) {
for (int a = -10; a < 10; a++)
for (int b = -10; b < 10; b++) {
if (check(Pearl[i], i, a, b)) {
auto t = get(Pearl[i], a, b);
if (S.count(t)) continue;
int head = DLX.cnt, tail = DLX.cnt;
for (auto& p : t) {
int x = p[0], y = p[1];
DLX.add(head, tail, sol.size(), id[x][y]);
}
DLX.add(head, tail, sol.size(), 56 + i);
chr[sol.size()] = i + 'A';
sol.push_back(t);
S.insert(t);
}
}
rev_plk(Pearl[i]);
rev_dia(Pearl[i]);
}
rev_plk(Pearl[i]);
}
if (DLX.dfs()) {
for (auto x : DLX.ans) {
auto& l = sol[x];
char z = chr[x];
for (auto& p : l) {
int x = p[0], y = p[1];
g[x][y] = z;
}
}
for (int i = 0; i < 10; i++) cout << g[i] << '\n';
} else
cout << "No solution";
return 0;
}
上面提到的都是精确覆盖问题的模型,下面看几个重复覆盖问题模型。
例题讲解 - 重复覆盖问题
下面的题有可能洛谷未收录,就放其他 OJ 的传送门了。
【模板】舞蹈链 - 精确覆盖问题
AcWing传送门
给定一个 \(01\) 矩阵,找到最少的行数,使得这些行的每一列都有 \(1\)。
解析:重复覆盖问题使用启发式迭代加深搜索(IDA*)实现。
搜索的顺序和精确覆盖问题是一样的,区别在于选择一行时,只会删除与其相关联的所有其他列,而不会连锁删除。为了保证搜索的效率,加上迭代加深,迭代层数为当前使用的行数;并使用启发式搜索,把估价函数设计为“最少还需要选择多少行才能完全覆盖所有列”。实践证明,这个估价函数是合理的。
尽管加上了各种优化,重复覆盖问题效率仍然不如精确覆盖问题,只能解决 \(n\leq 100\) 的数据范围。
#include <cstring>
#include <iostream>
#include <vector>
using namespace std;
const int maxn = 100 * 100 + 10;
int n, m, dep;
class Dancing_Links_X {
private:
int l[maxn], r[maxn], u[maxn], d[maxn];
int col[maxn], row[maxn], s[maxn];
bool vis[maxn];
int h() {
int res = 0;
memset(vis, 0, sizeof(vis));
for (int i = r[0]; i; i = r[i]) {
if (vis[col[i]]) continue;
res++;
vis[col[i]] = true;
for (int j = d[i]; j != i; j = d[j])
for (int k = r[j]; k != j; k = r[k]) vis[col[k]] = true;
}
return res;
}
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 resume(int p) {
for (int i = u[p]; i != p; i = u[i]) r[l[i]] = i, l[r[i]] = i;
}
public:
int cnt = 0, ans[maxn];
void init() {
for (int i = 0; i <= m; i++) {
l[i] = i - 1, r[i] = i + 1;
col[i] = u[i] = d[i] = i;
s[i] = 0;
}
l[0] = m, r[m] = 0, cnt = m + 1;
}
void add(int &head, int &tail, int x, int y) {
row[cnt] = x, col[cnt] = y, s[y]++;
u[cnt] = y, d[cnt] = d[y], u[d[y]] = cnt, d[y] = cnt;
r[head] = l[tail] = cnt, r[cnt] = tail, l[cnt] = head;
tail = cnt++;
}
bool IDA(int k, int dep) {
if (k + h() > dep) return false;
if (!r[0]) return true;
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 (IDA(k + 1, dep)) return true;
for (int j = l[i]; j != i; j = l[j]) resume(j);
resume(i);
}
return false;
}
} DLX;
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
DLX.init();
for (int i = 1; i <= n; i++) {
int head = DLX.cnt, tail = DLX.cnt;
for (int j = 1, x; j <= m; j++) {
cin >> x;
if (x) DLX.add(head, tail, i, j);
}
}
while (!DLX.IDA(0, dep)) dep++;
cout << dep << '\n';
for (int i = 0; i < dep; i++) cout << DLX.ans[i] << ' ';
return 0;
}
[POJ1084] Square Destroyer
洛谷传送门
给定一个由若干根火柴组成的网格(不一定完整),求出至少拿走多少根火柴棒可以使得网格中没有正方形。
解析:重复覆盖问题的模型和精确覆盖问题一样,都是用行表示选择,列表示限制。本题中,行表示选择的火柴,列对应正方形。
在建模过程中,如果某根火柴可以破坏掉某些正方形,就意味着模型中火柴对应的行可以覆盖上正方形对应的列,给相交区域建立节点即可。这样就可以保证,重复覆盖问题的模型中每一个方案都能与原问题的解一一对应,那么模型求出的最优方案就是原问题的最优解。
另外,我们还得考虑一下怎么实现建图。先枚举所有边长为 \(1\) 的正方形,再枚举边长为 \(2\) 的正方形……对于每一个正方形,我们都可以用一个 vector 存下所有关联的火柴,然后对每根火柴,暴力枚举正方形中是否存在这根火柴。如果有,就可以直接在 DLX 里加节点了。
火柴最多有 \(2\times 5\times(5+1)=60\) 根,正方形最多有 \(1^2+2^2+3^2+4^2+5^2=55\) 个,最多就有 \(60\times 55=3300\) 个节点。实际上 DLX 中的节点量比这个值少很多,可以轻松通过本题。
#include <algorithm>
#include <cstring>
#include <iostream>
#include <vector>
using namespace std;
const int maxn = 3300 + 50;
int n, m, k, T, dep;
bool vis[100];
vector<int> rec[100];
class Dancing_Links_X {
private:
int l[maxn], r[maxn], u[maxn], d[maxn];
int col[maxn], row[maxn], s[maxn];
int h() {
int res = 0;
memset(vis, 0, sizeof(vis));
for (int i = r[0]; i; i = r[i]) {
if (vis[i]) continue;
vis[i] = true;
res++;
for (int j = d[i]; j != i; j = d[j])
for (int k = r[j]; k != j; k = r[k]) vis[col[k]] = true;
}
return res;
}
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 resume(int p) {
for (int i = u[p]; i != p; i = u[i]) r[l[i]] = l[r[i]] = i;
}
public:
int cnt;
void init() {
for (int i = 0; i <= m; i++) {
l[i] = i - 1, r[i] = i + 1;
col[i] = u[i] = d[i] = i;
s[i] = 0;
}
l[0] = m, r[m] = 0;
cnt = m + 1;
}
void add(int& head, int& tail, int x, int y) {
row[cnt] = x, col[cnt] = y, s[y]++;
u[cnt] = y, d[cnt] = d[y], u[d[y]] = cnt, d[y] = cnt;
r[head] = l[tail] = cnt, r[cnt] = tail, l[cnt] = head;
tail = cnt++;
}
bool IDA(int k, int dep) {
if (k + h() > dep) return false;
if (!r[0]) return true;
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]) {
remove(i);
for (int j = r[i]; j != i; j = r[j]) remove(j);
if (IDA(k + 1, dep)) return true;
for (int j = l[i]; j != i; j = l[j]) resume(j);
resume(i);
}
return false;
}
} DLX;
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T;
while (T--) {
memset(vis, 0, sizeof(vis));
m = 0;
cin >> n >> k;
for (int i = 1, x; i <= k; i++) {
cin >> x;
vis[x] = true;
}
for (int l = 1; l <= n; l++) //正方形边长
for (int a = 1; a <= n + 1 - l; a++) //正方形行号
for (int b = 1; b <= n + 1 - l; b++) { //正方形列号
auto& S = rec[++m];
S.clear();
int d = n * 2 + 1;
for (int i = 0; i < l; i++) {
S.push_back((a - 1) * d + b + i);
S.push_back((a - 1) * d + b + i + d * l);
S.push_back((a - 1) * d + b + n + d * i);
S.push_back((a - 1) * d + b + n + d * i + l);
}
for (auto x : S)
if (vis[x]) {
m--;
break;
}
}
DLX.init();
for (int i = 1; i <= n * (n + 1) * 2; i++) {
if (!vis[i]) {
int head = DLX.cnt, tail = DLX.cnt;
for (int j = 1; j <= m; j++) {
auto& S = rec[j];
if (find(S.begin(), S.end(), i) != S.end())
DLX.add(head, tail, i, j);
}
}
}
dep = 0;
while (!DLX.IDA(0, dep)) dep++;
cout << dep << '\n';
}
return 0;
}