理解DLX算法之前首先了解精確覆蓋問題和重復覆蓋問題
精確覆蓋問題
何為精確覆蓋問題
在一個全集X中若干子集的集合為S,精確覆蓋(Exactcover)是指,S的子集S*,滿足X中的每一個元素在S*中恰好出現一次。
定義
S*中任意兩個集合沒有交集,即X中的元素在S*中出現最多一次
S*中集合的全集為X,即X中的元素在S*中出現最少一次
合二為一,即X中的元素在S*中出現恰好一次。
N={}
O={1,3}
E={2,4}
P={2,3}.
其中一個子集{O,E}是X的一個精確覆蓋,因為O={1,3}而E={2,4}的並集恰好是X={1,2,3,4}。同理,{N,O,E}也是X.的一個精確覆蓋。空集並不影響結論。
精確覆蓋問題的表示方式
一般的,我們用一個集合s包含s中的元素的單向關系表示精確覆蓋問題。常用的有以下兩種方法:
- 矩陣表示法
包含關系可以用一個關系矩陣表示。.矩陣每行表示S的一個子集,每列表示X中的一個元素。矩陣行列交點元素為1表示對應的元素在對應的集合中,不在則為0。
通過這種矩陣表示法,求一個精確覆蓋轉化為求矩陣的若干個行的集合,使每列有且僅有一個1。同時,該問題也是精確覆蓋的典型例題之一。
下表為其中一個例子:
S*={B,D,F}便是一個精確覆蓋。
- 圖論表示法
可將精確覆蓋問題轉化為一個二分圖,左側為集合,右側為元素,左側集合若與右側元素有包含關系則連邊,通過將左側節點與其所有邊保留與否求解一個右側的每一個節點恰好有一條邊的匹配。
重復覆蓋問題
即選取一個01矩陣中的幾行,使這幾行組成的新矩陣的每一列至少有一個1,也就是說每一列上可以有多個1。 該問題在精確覆蓋問題上減少了一個約束條件。
Dancing Links X 算法
歷史
算法大師Donald E.Knuth(《計算機程序設計藝術》的作者)提出了DLX(Dancing Links X)算法。實際上,他把上面求解的過程稱為X算法,而他提出的舞蹈鏈(Dancing Links)實際上並不是一種算法,而是一種數據結構。一種非常巧妙的數據結構,他的數據結構在緩存和回溯的過程中效率驚人,不需要額外的空間,以及近乎線性的時間。而在整個求解過程中,指針在數據之間跳躍着,就像精巧設計的舞蹈一樣,故Donald E.Knuth把它稱為Dancing Links(中文譯名舞蹈鏈)。
算法思想
Dancing Links的核心是基於雙向鏈的方便操作(移除、恢復加入)
我們用例子來說明
假設雙向鏈的三個連續的元素,A1、A2、A3,每個元素有兩個分量Left和Right,分別指向左邊和右邊的元素。由定義可知
A1.Right=A2,A2.Right=A3
A2.Left=A1,A3.Left=A2
在這個雙向鏈中,可以由任一個元素得到其他兩個元素,A1.Right.Right=A3,A3.Left.Left=A1等等
現在把A2這個元素從雙向鏈中移除(不是刪除)出去,那么執行下面的操作就可以了
A1.Right=A3,A3.Left=A1
那么就直接連接起A1和A3。A2從雙向鏈中移除出去了。但僅僅是從雙向鏈中移除了,A2這個實體還在,並沒有刪除。只是在雙向鏈中遍歷的話,遍歷不到A2了。
那么A2這個實體中的兩個分量Left和Right指向誰?由於實體還在,而且沒有修改A2分量的操作,那么A2的兩個分量指向沒有發生變化,也就是在移除前的指向。即A2.Left=A1和A2.Right=A3
如果此時發現,需要把A2這個元素重新加入到雙向鏈中的原來的位置,也就是A1和A3的中間。由於A2的兩個分量沒有發生變化,仍然指向A1和A3。那么只要修改A1的Right分量和A3的Left就行了。也就是下面的操作
A1.Right=A2,A3.Left=A2
仔細想想,上面兩個操作(移除和恢復加入)對應了什么?是不是對應了之前的算法過程中的關鍵的兩步?
移除操作對應着緩存數據、恢復加入操作對應着回溯數據。而美妙的是,這兩個操作不再占用新的空間,時間上也是極快速的
在很多實際運用中,把雙向鏈的首尾相連,構成循環雙向鏈
Dancing Links用的數據結構是交叉十字循環雙向鏈
而Dancing Links中的每個元素不僅是橫向循環雙向鏈中的一份子,又是縱向循環雙向鏈的一份子。
因為精確覆蓋問題的矩陣往往是稀疏矩陣(矩陣中,0的個數多於1),Dancing Links僅僅記錄矩陣中值是1的元素。
Dancing Links中的每個元素有6個分量
分別:Left指向左邊的元素、Right指向右邊的元素、Up指向上邊的元素、Down指向下邊的元素、Col指向列標元素、Row指示當前元素所在的行
Dancing Links還要准備一些輔助元素(為什么需要這些輔助元素?沒有太多的道理,大師認為這能解決問題,實際上是解決了問題)
Ans():Ans數組,在求解的過程中保留當前的答案,以供最后輸出答案用。
Head元素:求解的輔助元素,在求解的過程中,當判斷出Head.Right=Head(也可以是Head.Left=Head)時,求解結束,輸出答案。Head元素只有兩個分量有用。其余的分量對求解沒啥用
C元素:輔助元素,稱列標元素,每列有一個列標元素。本文開始的題目的列標元素分別是C1、C2、C3、C4、C5、C6、C7。每一列的元素的Col分量都指向所在列的列標元素。列標元素的Col分量指向自己(也可以是沒有)。在初始化的狀態下,Head.Right=C1、C1.Right=C2、……、C7.Right=Head、Head.Left=C7等等。列標元素的分量Row=0,表示是處在第0行。
下圖就是根據題目構建好的交叉十字循環雙向鏈(構建的過程后面的詳述)
就上圖解釋一下
每個綠色方塊是一個元素,其中Head和C1、C2、……、C7是輔助元素。橙色框中的元素是原矩陣中1的元素,給他們標上號(從1到16)
左側的紅色,標示的是行號,輔助元素所在的行是0行,其余元素所在的行從1到6
每兩個元素之間有一個雙向箭頭連線,表示雙向鏈中相鄰兩個元素的關系(水平的是左右關系、垂直的是上下關系)
單向的箭頭並不是表示單向關系,而因為是循環雙向鏈,左側的單向箭頭和右側的單向箭頭(上邊的和下邊的)組成了一個雙向箭頭,例如元素14左側的單向箭頭和元素16右側的單項箭頭組成一個雙向箭頭,表示14.Left=16、16.Right=14;同理,元素14下邊的單項箭頭和元素C4上邊的單向箭頭組成一個雙向箭頭,表示14.Down=C4、C4.Up=14
接下來,利用圖來解釋Dancing Links是如何求解精確覆蓋問題
1、首先判斷Head.Right=Head?若是,求解結束,輸出解;若不是,求解還沒結束,到步驟2(也可以判斷Head.Left=Head?)
2、獲取Head.Right元素,即元素C1,並標示元素C1(標示元素C1,指的是標示C1、和C1所在列的所有元素、以及該元素所在行的元素,並從雙向鏈中移除這些元素)。如下圖中的紫色部分。
如上圖可知,行2和行4中的一個必是答案的一部分(其他行中沒有元素能覆蓋列C1),先假設選擇的是行2
3、選擇行2(在答案棧中壓入2),標示該行中的其他元素(元素5和元素6)所在的列首元素,即標示元素C4和標示元素C7,下圖中的橙色部分。
注意的是,即使元素5在步驟2中就從雙向鏈中移除,但是元素5的Col分量還是指向元素C4的,這里體現了雙向鏈的強大作用。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
一下子空了好多,是不是轉換為一個少了很多元素的精確覆蓋問題?,利用遞歸的思想,很快就能寫出求解的過程來。我們繼續完成求解過程
4、獲取Head.Right元素,即元素C2,並標示元素C2。如下圖中的紫色部分。
如圖,列C2只有元素7覆蓋,故答案只能選擇行3
5、選擇行3(在答案棧中壓入3),標示該行中的其他元素(元素8和元素9)所在的列首元素,即標示元素C3和標示元素C6,下圖中的橙色部分。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
6、獲取Head.Right元素,即元素C5,元素C5中的垂直雙向鏈中沒有其他元素,也就是沒有元素覆蓋列C5。說明當前求解失敗。要回溯到之前的分叉選擇步驟(步驟2)。那要回標列首元素(把列首元素、所在列的元素,以及對應行其余的元素。並恢復這些元素到雙向鏈中),回標列首元素的順序是標示元素的順序的反過來。從前文可知,順序是回標列首C6、回標列首C3、回標列首C2、回標列首C7、回標列首C4。表面上看起來比較復雜,實際上利用遞歸,是一件很簡單的事。並把答案棧恢復到步驟2(清空的狀態)的時候。又回到下圖所示
7、由於之前選擇行2導致無解,因此這次選擇行4(再無解就整個問題就無解了)。選擇行4(在答案棧中壓入4),標示該行中的其他元素(元素11)所在的列首元素,即標示元素C4,下圖中的橙色部分。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
8、獲取Head.Right元素,即元素C2,並標示元素C2。如下圖中的紫色部分。
如圖,行3和行5都可以選擇
9、選擇行3(在答案棧中壓入3),標示該行中的其他元素(元素8和元素9)所在的列首元素,即標示元素C3和標示元素C6,下圖中的橙色部分。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
10、獲取Head.Right元素,即元素C5,元素C5中的垂直雙向鏈中沒有其他元素,也就是沒有元素覆蓋列C5。說明當前求解失敗。要回溯到之前的分叉選擇步驟(步驟8)。從前文可知,回標列首C6、回標列首C3。並把答案棧恢復到步驟8(答案棧中只有4)的時候。又回到下圖所示
11、由於之前選擇行3導致無解,因此這次選擇行5(在答案棧中壓入5),標示該行中的其他元素(元素13)所在的列首元素,即標示元素C7,下圖中的橙色部分。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
12、獲取Head.Right元素,即元素C3,並標示元素C3。如下圖中的紫色部分。
13、如上圖,列C3只有元素1覆蓋,故答案只能選擇行3(在答案棧壓入1)。標示該行中的其他元素(元素2和元素3)所在的列首元素,即標示元素C5和標示元素C6,下圖中的橙色部分。
把上圖中的紫色部分和橙色部分移除的話,剩下的綠色部分就如下圖所示
14、因為Head.Right=Head。故,整個過程求解結束。輸出答案,答案棧中的答案分別是4、5、1。表示該問題的解是第4、5、1行覆蓋所有的列。如下圖所示(藍色的部分)
從以上的14步來看,可以把Dancing Links的求解過程表述如下
1、Dancing函數的入口
2、判斷Head.Right=Head?,若是,輸出答案,返回True,退出函數。
3、獲得Head.Right的元素C
4、標示元素C
5、獲得元素C所在列的一個元素
6、標示該元素同行的其余元素所在的列首元素
7、獲得一個簡化的問題,遞歸調用Daning函數,若返回的True,則返回True,退出函數。
8、若返回的是False,則回標該元素同行的其余元素所在的列首元素,回標的順序和之前標示的順序相反
9、獲得元素C所在列的下一個元素,若有,跳轉到步驟6
10、若沒有,回標元素C,返回False,退出函數。
之前的文章的表述,為了表述簡單,采用面向對象的思路,說每個元素有6個分量,分別是Left、Right、Up、Down、Col、Row分量。
但在實際的編碼中,用數組也能實現相同的作用。例如:用Left()表示所有元素的Left分量,Left(1)表示元素1的Left分量
在前文中,元素分為Head元素、列首元素(C1、C2等)、普通元素。在編碼中,三種元素統一成一種元素。如上題,0表示Head元素,1表示元素C1、2表示元素C2、……、7表示元素C7,從8開始表示普通元素。這是統一后,編碼的簡便性。利用數組的下標來表示元素,宛若指針一般。
精確覆蓋例題:Sudoku ZOJ - 3122 鏈接:https://zoj.pintia.cn/problem-sets/91827364500/problems/91827367537
代碼:
1 #include <cstdio> 2 #include <fstream> 3 #include <algorithm> 4 #include <cmath> 5 #include <deque> 6 #include <vector> 7 #include <queue> 8 #include <string> 9 #include <cstring> 10 #include <map> 11 #include <stack> 12 #include <set> 13 #include <sstream> 14 #include <iostream> 15 #define mod 1000000007 16 #define eps 1e-6 17 #define ll long long 18 #define INF 0x3f3f3f3f 19 using namespace std; 20 21 const int maxn=18000; 22 //ans用來記錄答案的編號 23 int ans[maxn]; 24 struct DLX 25 { 26 27 //左右上下,四個數組 28 int left[maxn],right[maxn],up[maxn],down[maxn]; 29 //列數組,頭數組,loc代表這個數在數獨中的位置和數值 30 int col[maxn],head[maxn],loc[maxn][3]; 31 //num數組保存每列有幾個數,id為編號 32 int num[1030],id; 33 //創建有m列的矩陣 34 void init(int n) 35 { 36 for(int i=0;i<=n;i++) 37 { 38 up[i]=down[i]=i; 39 left[i]=i-1; 40 right[i]=i+1; 41 } 42 left[0]=n; right[n]=0; 43 id=n; 44 memset(num,0,sizeof(num)); 45 memset(head,-1,sizeof(head)); 46 } 47 //插入位於x,y的數,並對其上下左右,列和編號初始化,對px,py,pz存入loc數組 48 void Link(int x,int y,int px,int py,int k) 49 { 50 ++id; 51 down[id]=y; 52 up[id]=up[y]; 53 down[up[y]]=id; 54 up[y]=id; 55 loc[id][0]=px,loc[id][1]=py,loc[id][2]=k;//存放數的位置和數 56 col[id]=y; 57 num[y]++;//此列1的數量加一 58 if(head[x]==-1) 59 { 60 head[x]=left[id]=right[id]=id; 61 } 62 else 63 { 64 int a=head[x]; 65 int b=right[a]; 66 left[id]=a; right[a]=id; 67 right[id]=b; left[b]=id; 68 head[x]=id; 69 } 70 } 71 //移除c列和c列上數所在的每一行, 72 void Remove(int c) 73 { 74 left[right[c]]=left[c]; 75 right[left[c]]=right[c]; 76 for(int i=down[c];i!=c;i=down[i]) 77 for(int j=right[i];j!=i;j=right[j]) 78 { 79 up[down[j]]=up[j]; 80 down[up[j]]=down[j]; 81 num[col[j]]--; 82 } 83 } 84 //恢復c列和c列上數所在的每一行, 85 void Resume(int c) 86 { 87 for(int i=up[c];i!=c;i=up[i]) 88 for(int j=right[i];j!=i;j=right[j]) 89 { 90 num[col[j]]++; 91 up[down[j]]=j; 92 down[up[j]]=j; 93 } 94 left[right[c]]=c; 95 right[left[c]]=c; 96 } 97 bool dfs(int step) 98 { 99 //如果走到第256步時已走完所有的數獨中的數,所以退出 100 if(step==256) return true; 101 //如果頭指向第0列,說明所有列已刪除 102 if(right[0]==0) return false; 103 int c=right[0]; 104 //用循環是c優先指向列中數少的列 105 for(int i=right[0];i;i=right[i]) 106 { 107 if(num[i]<num[c]) 108 { 109 c=i; 110 } 111 } 112 //刪除第c列 113 Remove(c); 114 for(int i=down[c];i!=c;i=down[i]) 115 { 116 //記錄每此循環選的編號 117 ans[step]=i; 118 //遍歷i所在的行,並刪除j所在的列 119 for(int j=right[i];j!=i;j=right[j]) Remove(col[j]); 120 //如果循環下去有解,則返回true 121 if(dfs(step+1)) return true; 122 //遍歷i所在的行,並恢復j所在的列 123 for(int j=left[i];j!=i;j=left[j]) Resume(col[j]); 124 } 125 //恢復第c列 126 Resume(c); 127 //所有操作完成后仍無解,則返回false 128 return false; 129 } 130 }dlx; 131 int main() 132 { 133 //str數組存放輸入的數據 134 char str[260]; 135 int kase=0; 136 while(cin>>str) 137 { 138 //換行 139 if(kase) 140 { 141 cout<<endl; 142 } 143 kase++; 144 for(int i=1;i<=15;i++) 145 { 146 cin>>str+i*16; 147 } 148 dlx.init(256*4); 149 int r=0,js=0;//r代表行 150 for(int x=0;x<16;x++) 151 { for(int y=0;y<16;y++) 152 { 153 char ch=str[js]; 154 js++; 155 int s=(x/4)*4+y/4;//宮 156 int a,b,c,d;//a表示約束一,b表示約束二,c表示約束三,d表示約束四 157 if(ch=='-') 158 { 159 //此位置上可能是1到16, 160 for(int i=1;i<=16;i++) 161 { 162 a=x*16+y+1; 163 b=x*16+i+256; 164 c=y*16+i+256+256; 165 d=s*16+i+256+256+256; 166 ++r; 167 dlx.Link(r,a,x,y,i); 168 dlx.Link(r,b,x,y,i); 169 dlx.Link(r,c,x,y,i); 170 dlx.Link(r,d,x,y,i); 171 } 172 } 173 else 174 { 175 int i=ch-64; 176 a=x*16+y+1; 177 b=x*16+i+256; 178 c=y*16+i+256+256; 179 d=s*16+i+256+256+256; 180 ++r; 181 dlx.Link(r,a,x,y,i); 182 dlx.Link(r,b,x,y,i); 183 dlx.Link(r,c,x,y,i); 184 dlx.Link(r,d,x,y,i); 185 } 186 } 187 } 188 dlx.dfs(0); 189 char res[16][16]; 190 for(int i=0;i<256;i++)//將答案存放到一個數獨數組中 191 { 192 int a=ans[i]; 193 int x=dlx.loc[a][0],y=dlx.loc[a][1],k=dlx.loc[a][2]-1; 194 res[x][y]=k+'A'; 195 } 196 for(int i=0;i<16;i++) 197 { 198 for(int j=0;j<16;j++) 199 { 200 printf("%c",res[i][j]); 201 } 202 printf("\n"); 203 } 204 } 205 206 }
重復覆蓋例題:Airport HDU - 5046 鏈接:https://vjudge.net/problem/HDU-5046
代碼:
1 #include <cstdio> 2 #include <fstream> 3 #include <algorithm> 4 #include <cmath> 5 #include <deque> 6 #include <vector> 7 #include <queue> 8 #include <string> 9 #include <cstring> 10 #include <map> 11 #include <stack> 12 #include <set> 13 #include <sstream> 14 #include <iostream> 15 #define mod 998244353 16 #define eps 1e-6 17 #define ll long long 18 #define INF 0x3f3f3f3f 19 using namespace std; 20 21 const int maxn=4010; 22 int k; 23 struct 24 { 25 //左右上下,四個數組 26 int left[maxn],right[maxn],up[maxn],down[maxn]; 27 //頭數組,列數組 28 int head[65],col[maxn]; 29 //num數組保存每列有幾個數,id為編號 30 int num[65],id; 31 //創建有m列的矩陣 32 void init(int m) 33 { 34 for(int i=0;i<=m;i++) 35 { 36 left[i]=i-1; 37 right[i]=i+1; 38 up[i]=down[i]=i; 39 col[i]=i; 40 } 41 id=m; 42 left[0]=m; 43 right[m]=0; 44 memset(head,-1,sizeof(head)); 45 memset(num,0,sizeof(num)); 46 } 47 //插入位於x,y的數,並對其上下左右,列和編號初始化 48 void link(int x,int y) 49 { 50 id++; 51 down[id]=down[y]; 52 up[down[y]]=id; 53 up[id]=y; 54 down[y]=id; 55 num[y]++; 56 col[id]=y; 57 if(head[x]==-1) 58 { 59 head[x]=left[id]=right[id]=id; 60 } 61 else 62 { 63 right[id]=right[head[x]]; 64 left[right[head[x]]]=id; 65 left[id]=head[x]; 66 right[head[x]]=id; 67 } 68 } 69 //只移除c所在的一列和c所在列上數所在的每一行 70 void remove(int c) 71 { 72 for(int i=down[c];i!=c;i=down[i]) 73 { 74 left[right[i]]=left[i]; 75 right[left[i]]=right[i]; 76 } 77 } 78 //恢復c所在的一列和c所在列上數所在的每一行 79 void reback(int c) 80 { 81 for(int i=up[c];i!=c;i=up[i]) 82 { 83 left[right[i]]=right[left[i]]=i; 84 } 85 } 86 bool bj[maxn]; 87 //計算還需要多少步 88 int A() 89 { 90 int ans=0; 91 for(int c=right[0];c!=0;c=right[c]) 92 { 93 bj[c]=true; 94 } 95 for(int c=right[0];c!=0;c=right[c]) 96 { 97 if(bj[c]) 98 { 99 ans++; 100 bj[c]=false; 101 for(int i=down[c];i!=c;i=down[i]) 102 { 103 for(int j=right[i];j!=i;j=right[j]) 104 { 105 bj[col[j]]=false; 106 } 107 } 108 } 109 } 110 return ans; 111 } 112 //核心函數,step表示步數 113 bool danc(int step) 114 { 115 //如果還要走的步數加上已走的步數,比現有的答案多,則不需要走了,因此退出 116 if(A()+step>k)//剪枝 117 { 118 return false; 119 } 120 //如果頭指向第0列,說明所有列已刪除 121 if(right[0]==0) 122 { 123 return step<=k; 124 } 125 int c=right[0]; 126 //用循環是c優先指向列中數少的列 127 for(int i=c;i!=0;i=right[i]) 128 { 129 if(num[i]<num[c]) 130 { 131 c=i; 132 } 133 } 134 //遍歷c列中的數 135 for(int i=down[c];i!=c;i=down[i]) 136 { 137 //刪除i列 138 remove(i); 139 //遍歷i所在的行,並刪除j所在的列 140 for(int j=right[i];j!=i;j=right[j]) 141 { 142 remove(j); 143 } 144 //如果循環下去有解,則返回true 145 if(danc(step+1)) 146 { 147 return true; 148 } 149 //遍歷i所在的行,並恢復j所在的列 150 for(int j=left[i];j!=i;j=left[j]) 151 { 152 reback(j); 153 } 154 //恢復i列 155 reback(i); 156 } 157 //所有操作完成后仍無解,則返回false 158 return false; 159 } 160 }dlx; 161 struct node//小島信息 162 { 163 ll x,y; 164 }no[65]; 165 ll dis(node a,node b)//計算距離 166 { 167 ll dx=a.x-b.x; 168 if(dx<0) 169 { 170 dx=-dx; 171 } 172 ll dy=a.y-b.y; 173 if(dy<0) 174 { 175 dy=-dy; 176 } 177 return dx+dy; 178 } 179 int main() 180 { 181 int t,ans=0;; 182 scanf("%d",&t); 183 while(t--) 184 { 185 ans++; 186 int n; 187 ll x[65],y[65]; 188 scanf("%d %d",&n,&k); 189 for(int i=1;i<=n;i++) 190 { 191 scanf("%lld %lld",&no[i].x,&no[i].y); 192 } 193 ll le=0,rig=100000000000LL; 194 //二分法一步一步縮小距離 195 while(rig-le>0) 196 { 197 dlx.init(n); 198 ll mid=(rig+le)/2; 199 for(int i=1;i<=n;i++) 200 { 201 for(int j=1;j<=n;j++) 202 { 203 if(dis(no[i],no[j])<=mid)//島之間的距離比航距小時 204 { 205 dlx.link(i,j); 206 } 207 } 208 } 209 if(dlx.danc(0))//如果當前航距需要的最少飛機的數量比k小為true 210 { 211 rig=mid; 212 } 213 else 214 { 215 le=mid+1; 216 } 217 } 218 printf("Case #%d: %lld\n",ans,le); 219 } 220 }