marp: true
headingDivider: true
theme: default
backgroundColor: #db8
header: 中山市迪茵公学
footer: “2024.2.20”
paginate: true
style: |
section {
font-family: STKaiti, sans-serif;
font-size: 25px;
}
h1 {
font-family: 宋体, sans-serif;
font-size: 40px;
}
h2 {
font-family: 宋体, sans-serif;
font-size: 35px;
}
h3 {
font-family: 宋体, sans-serif;
font-size: 30px;
}
h4 {
font-family: 楷体, sans-serif;
font-size: 25px;
}
DLX 算法
DLX的全称为“Dancelinks X”,即使用舞蹈链“Dance links”实现的X算法。X算法是由图灵奖获得者、计算机理论大师Knuth教授发明的,主要用于解决一类精确覆盖问题的算法。这个算法采用“Dance link”来实现,有着极高的效率。
精确覆盖问题是指这样一类问题:给一个全集
S
S
S,求出某些子集,使得这些子集没有交集,而其并集等于全集
S
S
S.
通常可以用
01
01
01矩阵来表示精确覆盖问题。
给一个
n
×
m
n \times m
n×m的
01
01
01矩阵,每一行代表一个子集,每一列表示某个元素。如果第
i
i
i行表示的子集包含第
j
j
j个元素,则
01
01
01矩阵的第
i
i
i行第
j
j
j列为
1
1
1.
你要选择若干行,使得这些行包含所有的元素,且没有两行包含相同元素。
基本上所有的数独问题能转化为精确覆盖问题,从而可以快速求解。
精确覆盖问题
将 01 01 01矩阵,用一个双向循环十字链表数组来存储。
struct node{
int val, row, col, lp, rp, up, dn;
};
链表的每个元素,可以很方便的访问其上下左右相邻的元素。
第 0 0 0行看作是列头元素,如果其中第 i i i个元素被删除,就意味着第 i i i列被删除;
其他的元素被删除,就只是代表它自己被删除。
因为是双向链表,删除的元素实际上还在那里,只是其前驱的后继和其后继的前驱都不再指向它了。重新恢复是很容易的,因为它还保留着指向前驱和后继的指针,可以很快恢复。
具体实现过程
假设
01
01
01矩阵有
n
n
n行
m
m
m列。
我们要选择一个行的集合,使得其中每一列都有
1
1
1.
一、如何设计十字链表呢?
1、 设计第 0 0 0行
-
第 0 0 0行有 m + 1 m+1 m+1个元素,其中第 0 0 0个元素表示整个表头,当它的左右指针为空时,代表十字链表为空。第 0 0 0行的每个元素都有左右指针,分别指向该行中它左边的元素和右边的元素。第 0 0 0个元素的左指针指向第 m m m个元素,第 m m m个元素的右指针指向第 0 0 0个元素。
-
后面的 m m m个元素,每个元素都是某一列的列头,每个元素都有一个上指针和一个下指针。分别指向该列中的上一个元素和下一个元素。初始时,它们都指向自己,每插入一个元素后,都需要修改相应的指针。
假设有一个 6 × 6 6 \times 6 6×6的 01 01 01矩阵:
[ 0 1 1 0 0 0 1 0 1 0 0 1 0 1 0 1 1 1 1 0 0 0 0 1 0 0 0 1 1 0 0 1 0 1 0 1 ] \:\: \: \: \begin{aligned} \begin{bmatrix} &0 &1 &1 &0 &0 &0 & \\ &1 &0 &1 &0 & 0 &1 \\ &0 &1 & 0 & 1 & 1 & 1 \\ &1 &0 &0 &0 & 0 &1 \\ &0 &0 &0 &1 &1 &0 \\ &0 &1 &0 &1 &0 &1 \end{bmatrix} \end{aligned} 010100101001110000001011001010011101
开始时,设计第
0
0
0行的链表如下图所示:
接下来将 01 01 01 矩阵的第一行加入十字链表中。
0 1 1 0 0 0
图形做了一点简化,所有的连接首尾的箭头都用单向箭头,前方没有节点。
接下来再将 01 01 01矩阵的第二行加入十字链表中。
1 0 1 0 0 1
此时十字链表变成:
再加入第三行:
0 1 0 1 1 1
此时十字链表变成下图:
相信大家应该能够明白这个十字链表的建立过程了吧。
最终十字链表是这样的:
首先从列着手,先选中某一列,将它从列头(第 0 0 0行)的链表中删除,然后,遍历该列,将该列为 1 1 1的行删除,此处只删除该行其他位置在其列中的存在关系,当前列所有 1 1 1的位置的上下关系并不需要删除,因为该列已经整体在列头中删除掉了。
以上操作可以作为一个基本操作单元,命名为remove©。
然后我们再从第 c c c列中,依次尝试选择某个为 1 1 1的位置所在的行(一定要选择一行,但是并不知道最终应该选择哪一行,所以每一行都要尝试),将该行中其他位置的 1 1 1所在的列再来进行remove操作。
重复以上的操作,直到列头(即第 0 0 0行只剩下元素 0 0 0,此时表示所有的列都已经被删掉了)。于是之前的选择的行就是我们的答案。
每次我们 r e m o v e ( c ) remove(c) remove(c)时,可以选择 1 1 1最少的列操作,这样可以极大的加快速度。道理很显然,即尽量减少dfs搜索树的大小。
例1 舞蹈链(DLX)
题目描述
给定一个
N
N
N 行
M
M
M 列的矩阵,矩阵中每个元素要么是
1
1
1,要么是
0
0
0。
你需要在矩阵中挑选出若干行,使得对于矩阵的每一列
j
j
j,在你挑选的这些行中,有且仅有一行的第
j
j
j个元素为
1
1
1。
如果有多种选择方案,请任意输出一种方案。
数据范围
对于 100 % 100\% 100% 的数据, N , M ≤ 500 N,M\leq 500 N,M≤500,保证矩阵中 1 1 1 的数量不超过 5000 5000 5000 个。
分析:这是一道模板题
#define maxn 505
#define maxt 250005
int arr[maxn][maxn];
int n, m, tot;
int chead[maxn], rhead[maxn], up[maxt], down[maxt], lt[maxt], rt[maxt], cid[maxt], rid[maxt];
int selrow[maxn], cnt[maxn];
void remove(int c){
rt[lt[c]] = rt[c], lt[rt[c]] = lt[c];
for(int i = down[c]; i != c; i = down[i]){
for(int j = rt[i]; j != i; j = rt[j]){
up[down[j]] = up[j], down[up[j]] = down[j];
cnt[cid[j]]--;
}
}
}
void reset(int c){
for(int i = up[c]; i != c; i = up[i]){
for(int j = lt[i]; j != i; j = lt[j]){
up[down[j]] = j, down[up[j]] = j;
cnt[cid[j]]++;
}
}
rt[lt[c]] = c, lt[rt[c]] = c;
}
bool dfs(){
if(rt[0] == 0){
for(int i = 1; i <= n; i++){
if(selrow[i] == 1) printf("%d ", i);
}
printf("\n");
return 1;
}
int minz = maxn, col = 0;
for(int i = rt[0]; i != 0; i = rt[i]){
if(cnt[i] < minz)minz = cnt[i], col = i;
}
if(minz == 0) return 0;
remove(col);
for(int i = down[col]; i != col; i = down[i]){
selrow[rid[i]] = 1;
for(int j = rt[i]; j != i; j = rt[j])remove(cid[j]);
if(dfs())return 1;
for(int j = lt[i]; j != i; j = lt[j]) reset(cid[j]);
selrow[rid[i]] = 0;
}
reset(col);
return 0;
}
int main(){
scanf("%d %d", &n, &m);
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
scanf("%d", &arr[i][j]);
}
}
for(int i = 0; i <= m; i++){
up[i] = down[i] = i;
lt[i] = i - 1;
rt[i] = i + 1;
chead[i] = i;
}
lt[0] = m, rt[m] = 0;
tot = m;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
if(arr[i][j] == 1){
tot++;
cid[tot] = j, rid[tot] = i;
if(rhead[i] == 0){
lt[tot] = rt[tot] = tot;
}
else{
lt[tot] = rhead[i], rt[tot] = rt[rhead[i]];
rt[rhead[i]] = tot, lt[rt[tot]] = tot;
}
rhead[i] = tot;
up[tot] = chead[j], down[tot] = down[chead[j]];
down[chead[j]] = tot, up[down[tot]] = tot;
chead[j] = tot;
cnt[j]++;
}
}
}
if(dfs() == 0) printf("No Solution!\n");
return 0;
}
例2、 数独问题
数独问题怎么转化为精确覆盖问题?
在以下的每个空格中填入一个 1 ∼ 9 1 \sim 9 1∼9的整数,使得每一行、每一列、每个九宫格中 1 ∼ 9 1 \sim 9 1∼9都刚好出现一次。
数独问题
数独问题转化为精确覆盖问题
一、分析:
对于上述
9
×
9
9 \times 9
9×9数独问题,第
i
i
i行、第
j
j
j列填的数为
k
k
k, 一共有
729
729
729情况,从中选择出来
81
81
81种情况,要求这
81
81
81种情况要满足一些约束条件,即每行不能有相同的数,每列不能有相同的数,每个九宫格中不能有相同的数,每行每列只能填一个数。
例2、数独问题
数独问题转化为精确覆盖问题
二、建模
建立一个
729
729
729行
324
324
324列的
01
01
01矩阵,行代表所有行为,列代表所有约束。
具体来讲:在九宫格的第 i i i行第 j j j列写上数字 k k k,这一行为对应于 01 01 01矩阵的第 ( i − 1 ) × 81 + ( j − 1 ) × 9 + k (i-1) \times 81+(j-1) \times 9+k (i−1)×81+(j−1)×9+k行。
01
01
01矩阵一共有
324
324
324列,分成
4
4
4类,每类
81
81
81列。
第一类表示某行是否出现某值,共81种;第二类表示某列是否出现某值,共
81
81
81种,第三类表示某宫中是否出现某个值,共
81
81
81种,第四类表示某行某列已填好,共
81
81
81种。
对于每一种行为,比如在第 i i i行第 j j j列中填上了数字 k k k, 则先找到对应的行,然后将第一类中第 ( i − 1 ) × 9 + k (i-1) \times 9 + k (i−1)×9+k列设为 1 1 1, 将第二类中第 ( j − 1 ) × 9 + k (j-1) \times 9+k (j−1)×9+k列设为 1 1 1,将第三类中第 g × 9 + k g \times 9+k g×9+k列设为 1 1 1,其中 g = ( i − 1 ) / 3 × 3 + ( j − 1 ) / 3 + 1 g=(i-1)/3\times 3+(j-1)/3+1 g=(i−1)/3×3+(j−1)/3+1,表示在第几个 3 × 3 3\times 3 3×3的宫中;将第四类的第 ( i − 1 ) × 9 + j (i-1) \times 9 + j (i−1)×9+j设为 1 1 1.
数独问题
现在 9 × 9 9 \times 9 9×9的数独问题已经转化为了 729 × 324 729 \times 324 729×324的 01 01 01矩阵上的精确覆盖问题了。
每行刚好 4 4 4个 1 1 1,一共 324 324 324列,刚好要选择 81 81 81行,才有可能保证每一列中都有 1 1 1,且每一列中刚好一个 1 1 1.
列的前3类中,每一列中都只能出现一个 1 1 1,刚好对应于九宫格的约束条件,即每行、每列、每宫中都能出现某个值 1 1 1次。
最后一类的列中,只能出现一个 1 1 1, 对应于每行每列只能填一个数。
比如第一类中的第一列的 1 1 1,表示九宫格的第 1 1 1行出现了 1 1 1, 第二列中的 1 1 1,表示九宫格的第 1 1 1行出现了 2 2 2。
参考代码
以下为用到的数组的定义
int matrix[maxn][maxc]; //转换后的01矩阵
int arr[20][20]; //原九宫格
int cascnt, row, col;
int chead[maxc], rhead[maxn]; //chead[i]表示第i列最新加入链表的元素,rhead[i]表示第i行最新加入列表的元素。
int up[maxt], down[maxt], lp[maxt], rp[maxt]; //上下左右指针
int rid[maxt], cid[maxt], cnt[maxc]; //十字链表中每个元素对应的行号和列号
int tot, rowcnt, colcnt; //tot:十字链表中的元素个数,rowcnt:十字链表的行数,colcnt:十字链表的列数
bool sel[maxn]; //某行是否选中
int ans_r, ans_c, ans_x; //
bool haveans;
int n;
参考代码
以下为模型转换的代码:
void convert(int n){
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
for(int k = 1; k <= n; k++){
if(arr[i][j] != 0 && arr[i][j] != k) continue;
row = (i - 1) * (n * n) + (j - 1) * n + k;
for(int cas = 1; cas <= cascnt; cas++){
for(int ii = 1; ii <= n; ii++){
for(int jj = 1; jj <= n; jj++){
col = (cas - 1) * (n * n) + (ii - 1) * n + jj;
if(cas == 1) if(ii == i && jj == k) matrix[row][col] = 1;
if(cas == 2) if(ii == j && jj == k) matrix[row][col] = 1;
if(cas == 3) if(ii == (i - 1) / 3 * 3 + (j - 1) / 3 + 1 && jj == k) matrix[row][col] = 1;
if(cas == 4) if(ii == i && jj == j) matrix[row][col] = 1;
}
}
}
}
}
}
}
以上模型转换代码比较好理解,但是比较慢。实际上可以简化为只用四层循环,速度会快很多:
void convert() {
row = 0, col = 0;
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= 9; j++) {
for (int k = 1; k <= 9; k++) {
if (arr[i][j] != 0 && arr[i][j] != k)
continue;
row++;
i_[row] = i, j_[row] = j, k_[row] = k; //当前行对应的i,j,k记录下来
for (int cas = 1; cas <= 4; cas++) {
if (cas == 1)
col = (i - 1) * 9 + k;
if (cas == 2)
col = 81 + (j - 1) * 9 + k;
if (cas == 3)
col = 162 + ((i - 1) / 3 * 3 + (j - 1) / 3) * 9 + k;
if (cas == 4)
col = 243 + (i - 1) * 9 + j;
matrix[row][col] = 1;
}
if (arr[i][j] == k)
break;
}
}
}
}
参考代码
以下为建十字链表的代码
void init(){
for(int i = 0; i <= colcnt; i++) {
up[tot] = tot, down[tot] = tot, lp[tot] = tot - 1, rp[tot] = tot + 1;
chead[i] = tot;
cid[tot] = i, rid[tot] = 0;
++tot;
}
rp[tot - 1] = 0, lp[0] = tot - 1, rp[0] = 1;
for(int i = 1; i <= rowcnt; i++){
for(int j = 1; j <= colcnt; j++){
if(matrix[i][j] == 1){
cid[tot] = j, rid[tot] = i;
if(rhead[i] == 0){
rhead[i] = tot;
lp[tot] = tot, rp[tot] = tot;
}
else{
lp[tot] = rhead[i], rp[tot] = rp[rhead[i]];
lp[rp[rhead[i]]] = tot, rp[rhead[i]] = tot;
rhead[i] = tot;
}
up[tot] = chead[j], down[tot] = down[chead[j]], up[down[chead[j]]] = tot, down[chead[j]] = tot;
chead[j] = tot;
cnt[j]++;
++tot;
}
}
}
}
参考代码
以下为删除某列和恢复某列的代码:
void remove(int col){
lp[rp[col]] = lp[col], rp[lp[col]] = rp[col];
for(int i = down[col]; i != col; i = down[i]){
for(int j = rp[i]; j != i; j = rp[j]) up[down[j]] = up[j], down[up[j]] = down[j], cnt[cid[j]]--;
}
}
void reset(int col){
for(int i = up[col]; i != col; i = up[i]){
for(int j = lp[i]; j != i; j = lp[j]) up[down[j]] = j, down[up[j]] = j, cnt[cid[j]]++;
}
rp[lp[col]] = col, lp[rp[col]] = col;
}
参考代码
以下为dfs的代码,即在十字链表中找出最少的行的集合,保证能够覆盖所有的列。
bool dfs(){
if(rp[0] == 0){
for(int i = 1; i <= rowcnt; i++){
if(sel[i] == 1) {
ans_r = (i - 1) / (n * n) + 1;
ans_c = (i - 1) % (n * n) / n + 1;
ans_x = (i - 1) % n + 1;
arr[ans_r][ans_c] = ans_x;
}
}
return 1;
}
int maxz = INF, s_col = 0;
for(int i = rp[0]; i != 0; i = rp[i]){
if(cnt[i] < maxz) maxz = cnt[i], s_col = i;
}
remove(s_col);
for(int i = down[s_col]; i != s_col; i = down[i]){
sel[rid[i]] = 1;
for(int j = rp[i]; j != i; j = rp[j])remove(cid[j]);
if(dfs()) return 1;
for(int j = lp[i]; j != i; j = lp[j]) reset(cid[j]);
sel[rid[i]] = 0;
}
reset(s_col);
return 0;
}
int main(){
n = 9;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
scanf("%d", &arr[i][j]);
}
}
cascnt = 4;
rowcnt = n * n * n;
colcnt = n * n * cascnt;
convert(n);
init();
bool haveans = dfs();
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
printf("%d ", arr[i][j]);
}
printf("\n");
}
return 0;
}
例3 智慧珠游戏
题目描述
智慧珠游戏拼盘由一个三角形盘件和 12 个形态各异的零件组成。拼盘和零件如图1所示:
对于由珠子构成的零件,可以放到盘件的任一位置,条件是能有地方放,且尺寸合适,所有的零件都允许旋转(0º、90º、180º、270º)和翻转(水平、竖直)。
现给出一个盘件的初始布局,求一种可行的智慧珠摆放方案,使所有的零件都能放进盘件中。
以下为一种方案:
方案保证唯一
分析
模型转换
对于12个零件,我们可以选择其左上方的一个珠子设为参考点
(
0
,
0
)
(0,0)
(0,0),求出其他珠子的相对坐标。
因为零件可以旋转、翻转等,相当于坐标值交换,取反等操作的组合,共有8种。即
(
x
,
y
)
,
(
x
,
−
y
)
,
(
−
x
,
y
)
,
(
−
x
,
−
y
)
,
(
y
,
x
)
,
(
−
y
,
x
)
,
(
y
,
−
x
)
,
(
−
y
,
−
x
)
(x,y),(x,-y),(-x,y),(-x,-y),(y,x),(-y,x),(y,-x),(-y,-x)
(x,y),(x,−y),(−x,y),(−x,−y),(y,x),(−y,x),(y,−x),(−y,−x).
相当于每个零件,最多有8个不同的变种。
每个零件的每个变种,其原点可以放到每个位置,所以对应的行数最多为
55
×
12
×
8
55 \times 12 \times 8
55×12×8。但实际上要不了这么多行。
55
55
55个位置都需要放智慧珠,表示该位置必须被占用。我们不用管该位置放的那种零件,只要有零件,就设置为1,所以需要55列作为约束条件。同时,每种零件都必须出现一次,共12种零件,所以再增加12列,出现了该零件,则标记为1。一共需要67列。
这样就转换为一个
01
01
01矩阵了。
对
01
01
01矩阵的每一行,我们要记录一下它对应的是哪个零件。
对
01
01
01的前
55
55
55列,我们记录它对应于棋盘的哪一行哪一列。
重复覆盖问题
重复覆盖问题是指选择满足条件的若干子集,要求覆盖全集,但允许子集有交集, 选择最少数量的子集,或者符合某些属性的子集等。
如果用 01 01 01矩阵的行来表示集合,列表示元素, 则重复覆盖问题指的是选择若干行,使得每一列都至少有一个 1 1 1.
重复覆盖因为行与行之间不再存在互斥关系,所以不能因为选了某一行,而将其关联行全部删除。
具体做法如下:
- 选择某列,让该列消失:除了当前行的 1 1 1之外,该列上其他的 1 1 1的左右节点都断开与它的联系。
- 选择该列上某个 1 1 1所在的行,将该行上的 1 1 1所在的列消失:每列除了当前行的 1 1 1之外, 该列其他的 1 1 1的左右节点都断开与它的联系。
- 再配合一个 A ∗ A* A∗剪枝,即利用一个估价函数来剪枝,加快搜索。剪枝搜索一般为剩下的 1 1 1通过精确覆盖还至少需要选择多少行,设估价函数的值为 h h h, 当前已经选择的行数为 k k k, 如果 k + h > a n s k+h > ans k+h>ans, 则认为当前局面搜索下去,不可能出现最优解(假设是最少的行数),可以提前回溯。
第二步要留当前行的 1 1 1不删除,一是因为这个 1 1 1的存在,不会导致再次搜索到该列;二是因为未来的恢复操作需要这些 1 1 1来做线索。
例4 HDU2295 雷达
题目描述
爪哇王国的 N N N个城市需要被雷达覆盖,因为王国有 M M M个雷达站,但只有 K K K个操作员,所以我们最多只能操作 K K K个雷达。所有雷达的覆盖范围都是半径为 R R R的圆形。我们的目标是在使用不超过 K K K个雷达的情况下,尽量减小 R R R,同时覆盖整个城市。
数据范围
1 ≤ T ≤ 20 , 1 ≤ N , M ≤ 50 , 1 ≤ K ≤ M , 0 ≤ X , Y ≤ 1000 1 ≤ T ≤ 20,1 ≤ N, M ≤ 50,1 ≤ K ≤ M,0 ≤ X, Y ≤ 1000 1≤T≤20,1≤N,M≤50,1≤K≤M,0≤X,Y≤1000
分析
二分雷达覆盖的半径。
求出该半径下每个雷达覆盖哪些城市,建立01矩阵。行表示雷达,列表示城市,如果雷达i能够覆盖城市j,则矩阵的第
i
i
i行第
j
j
j列设为1.
然后用DLX求重复覆盖,看能否满足要求。如果可以满足,则继续缩小半径;否则增大半径。
struct DLX {
const static int ROW = 1003, COL = 1003, SIZE = ROW * COL;
int L[SIZE], R[SIZE], U[SIZE], D[SIZE]; //模拟指针
int col[SIZE], row[SIZE]; //所在列 所在行
int visn, visited[COL]; //用于估价函数
int sel[ROW], seln; //选择的行
int sz[COL]; //列元素数
int total/*节点编号*/, H[ROW];
void init(int clen) { //初始化列头指针
for(int i = 0; i <= clen; ++i) {
L[i] = i - 1; R[i] = i + 1;
U[i] = D[i] = i; sz[i] = 0;
}
for(int i=0;i<ROW;i++) H[i]=-1;
for(int i=0;i<COL;i++) visited[i]=0; visn = 0; //用于重复覆盖的A*剪枝
L[0] = clen; R[clen] = 0; total = clen + 1;
}
参考代码
void _remove(const int &c) {
for(int i = D[c]; i != c; i = D[i])
L[R[i]] = L[i], R[L[i]] = R[i];
}
void _resume(const int &c) {
for(int i = U[c]; i != c; i = U[i])
L[R[i]] = R[L[i]] = i;
}
参考代码
int Astar(){ //估价函数
int res = 0; ++visn;
for(int i = R[0]; i != 0; i = R[i]) {
if(visited[i] != visn) {
++res; visited[i] = visn;
for(int j = D[i]; j != i; j = D[j])
for(int k = R[j]; k != j; k = R[k])
visited[col[k]] = visn;
}
} return res;
}
参考代码
bool _dance(int now){
if(now + Astar() > limit) return false;
if(R[0] == 0) return now<=limit;
int c=R[0], i, j;
for(i = R[0]; i != 0; i = R[i]) //选择元素最少的列c
if(sz[c] > sz[i]) c = i;
for(i = D[c]; i != c; i = D[i]) {
_remove(i);
for(j = R[i]; j != i; j = R[j])
_remove(j);
if(_dance(now + 1)) { //对于不同的题 这个地方常常需要改动
return true;
}
for(j = L[i]; j != i; j = L[j])
_resume(j);
_resume(i);
}
return false;
}
}