把 簡書 的文章轉過來,這邊看的人應該多點。
舞蹈鏈(Dancing links)實際上是一種數據結構,可以用來實現 X算法,以解決精確覆蓋問題。
什麼是精確覆蓋(Exact Cover)問題呢?維基百科上對精確覆蓋的定義如下:在一個全集 X 中若干子集的集合爲 S。S* 是 S 的一個子集,當且僅當 X 中的每一個元素在 S* 中恰好出現一次時,S* 稱之爲一個精確覆蓋。在計算機科學中,精確覆蓋問題指找出這樣的一種覆蓋,或證明其不存在。這是一個NP-完全問題。
例如,S = {A,B,C,D,E,F} 是全集 X = {1,2,3,4,5,6,7} 的一個子集的集合,其中:
A = {1, 4, 7}
B = {1, 4}
C = {4, 5, 7}
D = {3, 5, 6}
E = {2, 3, 6, 7}
F = {2, 7}
那麼,S 的一個子集 S* = {B, D, F} 是 X 的一個精確覆蓋,因爲 X 中的每個元素恰好在 S* 中出現了一次。
可以用 0-1 矩陣來表示精確覆蓋問題。我們用矩陣的每行表示 S 的一個元素,也就是 X 的一個子集;用矩陣的每列表示 X 的一個元素。矩陣中的 1 代表這一列的元素存在於這一行對應的子集中,0 代表不存在。那麼精確覆蓋問題可以轉化成求出矩陣若干行的集合,使得集合中的每一列恰好都有一個 1。
比如前面的問題可以用矩陣的形式表示成
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array} {c|ccccccc}
& 1 & 2 & 3 & 4 & 5 & 6 & 7 \\
\hline
A & 1 & 0 & 0 & 1 & 0 & 0 & 1 \\
\color{#d00}{B} & \color{#d00}{1} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{1} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{0} \\
C & 0 & 0 & 0 & 1 & 1 & 0 & 1 \\
\color{#d00}{D} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{1} & \color{#d00}{0} & \color{#d00}{1} & \color{#d00}{1} & \color{#d00}{0} \\
E & 0 & 1 & 1 & 0 & 0 & 1 & 1 \\
\color{#d00}{F} & \color{#d00}{0} & \color{#d00}{1} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{0} & \color{#d00}{1}
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
那麼選擇紅色的 B,D,F 能滿足每列都恰好包含一個 1。
可以用 Knuth 提出的 X算法 來解決精確覆蓋問題。X算法是一個非確定性的深度優先回溯算法。它的具體步驟如下:
如果矩陣A A A 爲空(沒有任何列),則當前局部解即爲問題的一個解,返回成功;否則繼續。
根據一定方法選擇第 c 列。如果某一列中沒有 1,則返回失敗,並去除當前局部解中最新加入的行。
選擇第 r 行,使得A r , c A_{r, c} A r , c = 1(該步是非確定性的)。
將第 r 行加入當前局部解中。
對於滿足A r , j A_{r, j} A r , j = 1的每一列j,從矩陣A A A 中刪除所有滿足A i , j A_{i, j} A i , j = 1的行,最後再刪除第 j 列。
對所得比 A 小的新矩陣遞歸地執行此算法。
讓我們用 X算法 解決上面的精確覆蓋問題。
首先,當前矩陣不爲空,算法繼續進行。那麼先選擇 1 最少的一列。因爲 1,2,3,5,6 列都只有 2 個 1,因此我們隨便選擇 1 個,比如第 1 列。
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array}{c|ccccccc}
& \color{#d00}{1} & 2 & 3 & 4 & 5 & 6 & 7 \\
\hline
A & \color{#d00}{1} & 0 & 0 & 1 & 0 & 0 & 1 \\
B & \color{#d00}{1} & 0 & 0 & 1 & 0 & 0 & 0 \\
C & 0 & 0 & 0 & 1 & 1 & 0 & 1 \\
D & 0 & 0 & 1 & 0 & 1 & 1 & 0 \\
E & 0 & 1 & 1 & 0 & 0 & 1 & 1 \\
F & 0 & 1 & 0 & 0 & 0 & 0 & 1
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
行 A 和 B 都含有 1,因此要在這兩行中進行選擇。
先嚐試選擇行 A。將行 A 加入到當前的解中。
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array}{c|ccccccc}
& \color{#AD3}{1} & 2 & 3 & \color{#AD3}{4} & 5 & 6 & \color{#AD3}{7} \\
\hline
\color{#d00}{A} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} \\
B & 1 & 0 & 0 & 1 & 0 & 0 & 0 \\
C & 0 & 0 & 0 & 1 & 1 & 0 & 1 \\
D & 0 & 0 & 1 & 0 & 1 & 1 & 0 \\
E & 0 & 1 & 1 & 0 & 0 & 1 & 1 \\
F & 0 & 1 & 0 & 0 & 0 & 0 & 1
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
行 A 的 1,4,7 列爲 1,根據第 5 步,需要把所有在 1,4,7 列中含有 1 的行都刪除掉,因此需要刪除掉行 A,B,C,E,F,同時刪除掉第 1,4,7 列
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array}{c|ccccccc}
& \color{#AD3}{1} & 2 & 3 & \color{#AD3}{4} & 5 & 6 & \color{#AD3}{7} \\
\hline
\color{#AD3}{A} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} \\
\color{#AD3}{B} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & 0 \\
\color{#AD3}{C} & 0 & 0 & 0 & \color{#d00}{1} & 1 & 0 & \color{#d00}{1} \\
D & 0 & 0 & 1 & 0 & 1 & 1 & 0 \\
\color{#AD3}{E} & 0 & 1 & 1 & 0 & 0 & 1 & \color{#d00}{1} \\
\color{#AD3}{F} & 0 & 1 & 0 & 0 & 0 & 0 & \color{#d00}{1}
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
刪除之後,矩陣只剩下行 D 和第 2,3,5,6 列:
2 3 5 6 D 0 1 1 1
\begin{array}{c|cccc}
& 2 & 3 & 5 & 6 \\
\hline
D & 0 & 1 & 1 & 1 \\
\end{array}
D 2 0 3 1 5 1 6 1
進入遞歸,回到第 1 步,矩陣非空,算法繼續執行。
再進入第2步,此時選擇 1 最少的第 2 列,裏面沒有 1,因此返回失敗,同時將行 A 從當前的解中移除;
算法進入另一個分支,選擇行 B,並將其加入到當前的解中:
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array}{c|ccccccc}
& \color{#AD3}{1} & 2 & 3 & \color{#AD3}{4} & 5 & 6 & 7 \\
\hline
A & 1 & 0 & 0 & 1 & 0 & 0 & 1 \\
\color{#d00}{B} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & 0 \\
C & 0 & 0 & 0 & 1 & 1 & 0 & 1 \\
D & 0 & 0 & 1 & 0 & 1 & 1 & 0 \\
E & 0 & 1 & 1 & 0 & 0 & 1 & 1 \\
F & 0 & 1 & 0 & 0 & 0 & 0 & 1
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
行 B 的第 1,4 列爲 1,因此要把 1,4 列中包含 1 的行都刪掉。需要刪除掉行 A,B,C,再刪除掉 1,4 列。
1 2 3 4 5 6 7 A 1 0 0 1 0 0 1 B 1 0 0 1 0 0 0 C 0 0 0 1 1 0 1 D 0 0 1 0 1 1 0 E 0 1 1 0 0 1 1 F 0 1 0 0 0 0 1
\begin{array}{c|ccccccc}
& \color{#AD3}{1} & 2 & 3 & \color{#AD3}{4} & 5 & 6 & 7 \\
\hline
\color{#AD3}{A} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & 1 \\
\color{#AD3}{B} & \color{#d00}{1} & 0 & 0 & \color{#d00}{1} & 0 & 0 & 0 \\
\color{#AD3}{C} & 0 & 0 & 0 & \color{#d00}{1} & 1 & 0 & 1 \\
D & 0 & 0 & 1 & 0 & 1 & 1 & 0 \\
E & 0 & 1 & 1 & 0 & 0 & 1 & 1 \\
F & 0 & 1 & 0 & 0 & 0 & 0 & 1
\end{array}
A B C D E F 1 1 1 0 0 0 0 2 0 0 0 0 1 1 3 0 0 0 1 1 0 4 1 1 1 0 0 0 5 0 0 1 1 0 0 6 0 0 0 1 1 0 7 1 0 1 0 1 1
此時矩陣變爲
2 3 5 6 7 D 0 1 1 1 0 E 1 1 0 1 1 F 1 0 0 0 1
\begin{array}{c|ccccc}
& 2 & 3 & 5 & 6 & 7 \\
\hline
D & 0 & 1 & 1 & 1 & 0 \\
E & 1 & 1 & 0 & 1 & 1 \\
F & 1 & 0 & 0 & 0 & 1
\end{array}
D E F 2 0 1 1 3 1 1 0 5 1 0 0 6 1 1 0 7 0 1 1
進入遞歸,回到第 1 步,矩陣非空,因此算法繼續。
當前包含 1 最少的一列是第 5 列,那麼將從第 5 列中選擇含有 1 的行進行搜索。
2 3 5 6 7 D 0 1 1 1 0 E 1 1 0 1 1 F 1 0 0 0 1
\begin{array}{c|ccccc}
\ & 2 & 3 & \color{#d00}{5} & 6 & 7 \\
\hline
D & 0 & 1 & \color{#d00}{1} & 1 & 0 \\
E & 1 & 1 & 0 & 1 & 1 \\
F & 1 & 0 & 0 & 0 & 1
\end{array}
D E F 2 0 1 1 3 1 1 0 5 1 0 0 6 1 1 0 7 0 1 1
第 5 列中行 D 含有 1,因此選擇行 D,將其加入當前解中,算法進入新的一層搜索。
2 3 5 6 7 D 0 1 1 1 0 E 1 1 0 1 1 F 1 0 0 0 1
\begin{array}{c|ccccc}
& 2 & \color{#AD3}{3} & \color{#AD3}{5} &\color{#AD3}{6} & 7 \\
\hline
\color{#d00}{D} & 0 & \color{#d00}{1} & \color{#d00}{1} & \color{#d00}{1} & 0 \\
E & 1 & 1 & 0 & 1 & 1 \\
F & 1 & 0 & 0 & 0 & 1
\end{array}
D E F 2 0 1 1 3 1 1 0 5 1 0 0 6 1 1 0 7 0 1 1
行 D 的第 3,5,6 列包含 1,我們要刪掉這幾列中包含 1 的所有行,同時刪掉這幾列
2 3 5 6 7 D 0 1 1 1 0 E 1 1 0 1 1 F 1 0 0 0 1
\begin{array} {c|ccccc}
& 2 & \color{#AD3}{3} & \color{#AD3}{5} &\color{#AD3}{6} & 7 \\
\hline
\color{#AD3}{D} & 0 & \color{#d00}{1} & \color{#d00}{1} & \color{#d00}{1} & 0 \\
\color{#AD3}{E} & 1 & \color{#d00}{1} & 0 & \color{#d00}{1} & 1 \\
F & 1 & 0 & 0 & 0 & 1
\end{array}
D E F 2 0 1 1 3 1 1 0 5 1 0 0 6 1 1 0 7 0 1 1
那麼我們需要刪掉行 D,E 和第 3,5,6 列,矩陣變爲
2 7 F 1 1
\begin {array}{c|cc}
& 2 & 7 \\
\hline
F & 1 & 1
\end{array}
F 2 1 7 1
再次遞歸執行,回到第 1 步,矩陣非空,因此算法繼續
選擇當前包含 1 最少的一列,這裏選擇第 2 列。第 2 列中只有行 F 包含 1, 因此選擇行 F
將行 F 加入到當前解中,算法進入第 3 層搜索
2 7 F 1 1
\begin{array} {c|cc}
& 2 & 7 \\
\hline
\color{#d00}{F} & \color{#d00}{1} & \color{#d00}{1}
\end{array}
F 2 1 7 1
行 F 中第 2,7列爲 1,第 2,7 列中行 F 包含 1,因此移除行 F 和第 2,7 列
2 7 F 1 1
\begin{array} {c|cc}
& \color{#AD3}{2} & \color{#AD3}{7} \\
\hline
\color{#AD3}{F} & \color{#d00}{1} & \color{#d00}{1}
\end{array}
F 2 1 7 1
算法再次進入遞歸執行,回到第 1 步,此時所有的列都被移除了,矩陣爲空,因此返回成功,找到了一個解:{B, D, F}
繼續搜索,沒有其他可以選擇的行,返回上一層;
第 2 層也沒有其他可以選擇的行,再返回上一層;
第 1 層也沒有其他可以選擇的行,再返回上一層;
第 0 層也沒有其他可以選擇的行,算法終止。
以上就是 X 算法的執行過程。Knuth 提出 X 算法主要是爲了說明舞蹈鏈的作用,他發現用舞蹈鏈來執行 X 算法效率特別高。那麼什麼是舞蹈鏈呢?它是基於雙向鏈表的一種數據結構。
讓我們先來看看雙向鏈表:
上圖是一個簡單的雙向鏈表,每個節點有兩個指針,分別指向自己的前驅和後繼節點。那麼如果我們想把其中一個節點,比如 B 從鏈表中刪掉,只需要執行下面的操作:
B.left.right = B.right
B.right.left = B.left
注意:此時雖然 B 從鏈表中移除了,但它的兩個指針依然保持不變,還是指向之前的前驅和後繼節點。
因此,如果我想把 B 再添加到鏈表原來的位置上,此時並不需要修改 B 的指針,只需要再把 B 的前驅和後繼節點的指針恢復就可以了:
B.left.right = B
B.right.left = B
理解了這一點之後,讓我們再來看看舞蹈鏈的結構是怎麼樣的:
上面這個圖是一個舞蹈鏈的結構,描述的是前面 X 算法中用到的矩陣。它由幾部分構成:
最上面的藍色部分是一個水平的環狀雙向鏈表。最左邊是頭節點,它是整個數據結構的根節點。其餘是列頭節點,每個代表矩陣中的一列。
每一列又是一個縱向的環狀雙向鏈表。除了最上面的列頭節點,其他的每個節點都代表前面的矩陣中的一個 1。這實際上是一個稀疏矩陣,爲了優化存儲和效率,只保留了值爲 1 的節點,把每個節點按順序保存到數組中。最早的 Dancing Links 算法,也就是 Knuth 在 2000 年發表的論文中,下面的每一行也都是一個雙向鏈表。但後來他發現每一行在算法執行過程中實際上不會發生變化,因此他把水平的雙向鏈表取消了,只保留了最頂上的列頭節點之間的水平雙向鏈表。下面的每一行之間的前後節點可以直接通過數組的索引得到。兩邊是Space節點,用來標記一行的開始和結束。
每個普通節點 A 都包含 4 個 字段,A.up 和 A.down 代表雙向鏈表的兩個指針,分別指向 A 上面和下面的節點。還有一個 A.col ,指向 A 所在列的頭節點,需要根據這個字段定位到節點所在的列。另外還有一個 A.row,主要是方便在遞歸的過程中緩存當前的解。
列頭節點還要再多幾個字段,left 和 right 分別指向水平雙向鏈表的左節點和右節點。另外還有一個 count 字段,代表這一列當前一共有幾個元素。X 算法的第 2 步,選擇 1 最少的列時會用到這個字段。
理解了舞蹈鏈的數據結構之後,我們再來看看是怎樣用舞蹈鏈來實現 X 算法的。這部分算法很精妙,也是舞蹈鏈這個名字的來由,通過對鏈表上的節點反覆刪除和插入實現了遞歸的回溯,就好像一個個鏈表在舞臺上翩翩起舞一樣。
具體的算法實現可以參照 Knuth 的論文,我們還是用圖的方式來說明一下。
遍歷這一列的每個節點,開始進行覆蓋操作:
首先將節點所在行作爲解的一部分,加入到當前解中;
遍歷這一行的所有節點,將每個節點所在列都刪除掉,同時刪除掉與這些列有交集的所有行:
2a. 遍歷節點所在列的每個節點,將每個節點所在行的所有節點從它所在的列中移除掉,同時將列頭節點的計數減 1:
node.up.down = node.down
node.down.up = node.up
col_node.count -= 1
2b. 還要將這一列從鏈表中移除:
col_node.left.right = col_node.right
col_node.right.left = col_node.left
進入遞歸調用,判斷鏈表是否爲空;
不爲空則選擇節點數最少的列,再遍歷這一列的節點,進行覆蓋操作:
移除掉所有節點之後,進入遞歸調用,發現鏈表不爲空,但節點數最少的列中沒有普通節點了,返回失敗;
開始做鏈表的還原操作。注意還原的順序需要和移除的順序相反 。如果我們是從上至下,從左至右移除節點,那麼還原的時候就從右至左,從下至上。否則的話可能會出現問題,導致一個節點被還原多次,這樣列中節點的計數就不準確了。
node.up.down = node
node.down.up = node
col_node.count += 1
並且把刪除的列也取消覆蓋
col_node.left.right = col_node
col_node.right.left = col_node
遞歸返回到上一層,還原之後,發現列中沒有其他節點可以選擇,再返回到上一層,選擇下一個節點所在的行。
和之前的方法相同,遍歷這一行的所有節點,將每個節點所在列都刪除掉,同時刪除掉與這些列有交集的所有行:
再選擇節點最少的列,遍歷這一列的所有節點的所在行:
遍歷這一行的所有節點,刪除掉每個節點所在列,以及與這些列有交集的所有行:
再次進入遞歸調用,判斷矩陣不爲空,選擇節點最少的一列,遍歷每個節點,刪除掉所在行的所有列,與這些列有交集的所有行,最後我們得到一個空矩陣。
此時將得到的解輸出,並返回,接下來還要進行還原操作,然後搜索下一個解。
以上就是舞蹈鏈算法的執行過程。