動手實現舞蹈鏈算法

簡書 的文章轉過來,這邊看的人應該多點。

舞蹈鏈(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。

比如前面的問題可以用矩陣的形式表示成

1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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}

那麼選擇紅色的 B,D,F 能滿足每列都恰好包含一個 1。

可以用 Knuth 提出的 X算法 來解決精確覆蓋問題。X算法是一個非確定性的深度優先回溯算法。它的具體步驟如下:

  1. 如果矩陣AA爲空(沒有任何列),則當前局部解即爲問題的一個解,返回成功;否則繼續。
  2. 根據一定方法選擇第 c 列。如果某一列中沒有 1,則返回失敗,並去除當前局部解中最新加入的行。
  3. 選擇第 r 行,使得Ar,cA_{r, c} = 1(該步是非確定性的)。
  4. 將第 r 行加入當前局部解中。
  5. 對於滿足Ar,jA_{r, j} = 1的每一列j,從矩陣AA中刪除所有滿足Ai,jA_{i, j} = 1的行,最後再刪除第 j 列。
  6. 對所得比 A 小的新矩陣遞歸地執行此算法。

讓我們用 X算法 解決上面的精確覆蓋問題。

首先,當前矩陣不爲空,算法繼續進行。那麼先選擇 1 最少的一列。因爲 1,2,3,5,6 列都只有 2 個 1,因此我們隨便選擇 1 個,比如第 1 列。

1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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 都含有 1,因此要在這兩行中進行選擇。

先嚐試選擇行 A。將行 A 加入到當前的解中。
1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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 的 1,4,7 列爲 1,根據第 5 步,需要把所有在 1,4,7 列中含有 1 的行都刪除掉,因此需要刪除掉行 A,B,C,E,F,同時刪除掉第 1,4,7 列
1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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}
刪除之後,矩陣只剩下行 D 和第 2,3,5,6 列:
2356D0111 \begin{array}{c|cccc} & 2 & 3 & 5 & 6 \\ \hline D & 0 & 1 & 1 & 1 \\ \end{array}
進入遞歸,回到第 1 步,矩陣非空,算法繼續執行。
再進入第2步,此時選擇 1 最少的第 2 列,裏面沒有 1,因此返回失敗,同時將行 A 從當前的解中移除;

算法進入另一個分支,選擇行 B,並將其加入到當前的解中:
1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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}
行 B 的第 1,4 列爲 1,因此要把 1,4 列中包含 1 的行都刪掉。需要刪除掉行 A,B,C,再刪除掉 1,4 列。
1234567A1001001B1001000C0001101D0010110E0110011F0100001 \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}
此時矩陣變爲
23567D01110E11011F10001 \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}
進入遞歸,回到第 1 步,矩陣非空,因此算法繼續。
當前包含 1 最少的一列是第 5 列,那麼將從第 5 列中選擇含有 1 的行進行搜索。
 23567D01110E11011F10001 \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}

第 5 列中行 D 含有 1,因此選擇行 D,將其加入當前解中,算法進入新的一層搜索。
23567D01110E11011F10001 \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 的第 3,5,6 列包含 1,我們要刪掉這幾列中包含 1 的所有行,同時刪掉這幾列
23567D01110E11011F10001 \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 和第 3,5,6 列,矩陣變爲
27F11 \begin {array}{c|cc} & 2 & 7 \\ \hline F & 1 & 1 \end{array}
再次遞歸執行,回到第 1 步,矩陣非空,因此算法繼續
選擇當前包含 1 最少的一列,這裏選擇第 2 列。第 2 列中只有行 F 包含 1, 因此選擇行 F

將行 F 加入到當前解中,算法進入第 3 層搜索
27F11 \begin{array} {c|cc} & 2 & 7 \\ \hline \color{#d00}{F} & \color{#d00}{1} & \color{#d00}{1} \end{array}
行 F 中第 2,7列爲 1,第 2,7 列中行 F 包含 1,因此移除行 F 和第 2,7 列
27F11 \begin{array} {c|cc} & \color{#AD3}{2} & \color{#AD3}{7} \\ \hline \color{#AD3}{F} & \color{#d00}{1} & \color{#d00}{1} \end{array}
算法再次進入遞歸執行,回到第 1 步,此時所有的列都被移除了,矩陣爲空,因此返回成功,找到了一個解:{B, D, F}

繼續搜索,沒有其他可以選擇的行,返回上一層;

第 2 層也沒有其他可以選擇的行,再返回上一層;

第 1 層也沒有其他可以選擇的行,再返回上一層;

第 0 層也沒有其他可以選擇的行,算法終止。

以上就是 X 算法的執行過程。Knuth 提出 X 算法主要是爲了說明舞蹈鏈的作用,他發現用舞蹈鏈來執行 X 算法效率特別高。那麼什麼是舞蹈鏈呢?它是基於雙向鏈表的一種數據結構。

讓我們先來看看雙向鏈表:

Doubly linked list

上圖是一個簡單的雙向鏈表,每個節點有兩個指針,分別指向自己的前驅和後繼節點。那麼如果我們想把其中一個節點,比如 B 從鏈表中刪掉,只需要執行下面的操作:

B.left.right = B.right
B.right.left = B.left

注意:此時雖然 B 從鏈表中移除了,但它的兩個指針依然保持不變,還是指向之前的前驅和後繼節點。

B is removed

因此,如果我想把 B 再添加到鏈表原來的位置上,此時並不需要修改 B 的指針,只需要再把 B 的前驅和後繼節點的指針恢復就可以了:

B.left.right = B
B.right.left = B

理解了這一點之後,讓我們再來看看舞蹈鏈的結構是怎麼樣的:

Dancing links

上面這個圖是一個舞蹈鏈的結構,描述的是前面 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 的論文,我們還是用圖的方式來說明一下。

  • 首先,判斷鏈表是否爲空,可以通過 head.right == head 來判斷。如果爲空則返回,並輸出當前的解。

  • 不爲空則選擇當前節點數最少的列。如果只有列頭節點,則返回失敗。

選擇一列

  • 遍歷這一列的每個節點,開始進行覆蓋操作:

    1. 首先將節點所在行作爲解的一部分,加入到當前解中;
      選擇列中的一個節點所在的行

    2. 遍歷這一行的所有節點,將每個節點所在列都刪除掉,同時刪除掉與這些列有交集的所有行:
      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
    
  • 遞歸返回到上一層,還原之後,發現列中沒有其他節點可以選擇,再返回到上一層,選擇下一個節點所在的行。
    選擇另一個節點所在行

  • 和之前的方法相同,遍歷這一行的所有節點,將每個節點所在列都刪除掉,同時刪除掉與這些列有交集的所有行:
    移除了選擇行的所有列,和每一列有交集的所有行

  • 再選擇節點最少的列,遍歷這一列的所有節點的所在行:
    選擇節點最少的列,遍歷這一列的節點所在行

  • 遍歷這一行的所有節點,刪除掉每個節點所在列,以及與這些列有交集的所有行:
    移除了選擇行的所有列,和每一列有交集的所有行

  • 再次進入遞歸調用,判斷矩陣不爲空,選擇節點最少的一列,遍歷每個節點,刪除掉所在行的所有列,與這些列有交集的所有行,最後我們得到一個空矩陣。
    空鏈表,只剩頭節點

  • 此時將得到的解輸出,並返回,接下來還要進行還原操作,然後搜索下一個解。

以上就是舞蹈鏈算法的執行過程。

發佈了26 篇原創文章 · 獲贊 0 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章