夜深人靜寫算法(九)- Dancing Links X(跳舞鏈)

目錄  

一、引例
      1、買點彩票壓壓驚
二、精確覆蓋
      1、精確覆蓋的定義
      2、窮舉法
      3、狀態壓縮
      4、回溯法
三、Dancing Links X算法
      1、X算法
      2、搜索樹
      3、傳統矩陣存儲
      4、Dancing Links
      5、十字交叉雙向循環鏈表
      6、額外結點的意義
四、Dancing Links X算法的具體實現
      1、結點定義DLXNode
      2、鏈表定義DLX
      3、初始化
      4、結點插入
      5、刪列
      6、刪行
      7、開始跳舞
五、精確覆蓋的應用
1、開關切換問題
      2、N皇后問題
      3、骨牌覆蓋問題
      4、數獨問題
六、重複覆蓋
      1、重複覆蓋的定義
      2、迭代加深(IDA*)
      3、啓發式函數
      4、引用計數
七、重複覆蓋的應用
      1、回顧彩票問題
      2、雷達系統
      3、支配集問題
八、跳舞鏈相關題集整理


一、引例
  1、買點彩票壓壓驚
  例題1有這樣一種彩票,規則如下:總共八個數字,範圍是[1,8]。八選五,如果五個都中,則爲特等獎;如果中了其中四個,則爲一等獎。作者覺得連年會都抽不到獎的人來說特等獎的概率太小了,所以對特等獎基本不抱希望。但是想嘗試下一等獎,於是他想知道至少要買多少張彩票才能使得他中一等獎的概率爲100%
  如果這個問題問的是讓特等獎概率爲100%,會變得簡單許多。可以這麼考慮,C(8,5)種情況下,每種情況都有可能中獎,所以每張彩票都必須買才能保證沒有漏網之魚。所以只要買下C(8,5)=56張彩票,就能保證一定有一張能中特等獎。
  但是,如果五個裏面中四個,情況就不一樣了。假設我買了兩張彩票,一張爲{1,2,3,4,5},一張爲{4,5,6,7,8},但是中一等獎的四個數字爲{1,2,3,6}。雖然兩張彩票覆蓋了所有數字,但是第一張只中了三個數字{1,2,3};第二張之中了一個數字{6},因而都不算中獎。
圖一-1-1
  爲了應對任何一種中獎情況,我們需要做這樣一件事情:從所有的C(8,5)種組合,也就是彩票中挑選出K種彩票,使得這K種彩票裏能夠找到任意的C(8,4)的組合({1,2,3,4},{1,2,3,5},...{4,5,6,7}...等等),並且使得這個K最小。爲了使問題更加通俗易懂,我們減小數據量,考慮“四選三中二”的情況(四個數字選三個,中其中二個纔算中獎)。
  如圖一-1-2,這是一個矩陣,矩陣的行代表所有的彩票組合(四選三),矩陣的列代表所有中獎組合(四中二)。對於每一行,如果這種組合包含對應的中獎組合,那麼將它對應矩陣的位置圖上顏色。爲了看起來不混淆,第一種組合方案採用紅色,第二種橙色,以此類推...
圖一-1-2
  然後我們把這個矩陣數字化,有顏色的地方置爲1,沒有顏色的置爲0,得到了如下矩陣:
圖一-1-3
  這個矩陣有一個特點就是:“每行三個1”,這是肯定的,因爲對於每一行來說,要從三個數字中挑出所有中了兩個數字的情況,即C(3,2)=3。但是這不是我們關心的重點,我們關心的是如何選擇一些行集合,使得所有列都能被選到(或者說覆蓋到)。
更加官方的描述是:在這個矩陣上找到一些行集合,使得集合中每一列至少一個“1”,並且保證選出的行數最少。
  這就是經典的重複覆蓋問題。接下來我們通過從精確覆蓋入手,引入DancingLinks結構,逐步抽絲剝繭,揭曉如何高效的求解重複覆蓋問題。

二、精確覆蓋
  1、精確覆蓋的定義
  【例題2】給定一個R×C(R, C <= 15)的01矩陣,問是否存在這樣一個行集合,使得集合中每一列恰有一個“1”。
  如圖二-1-1,表示的是一個6×7的01矩陣。我們可以通過選擇第1、5、6行使得這些行集合中每列恰有一個“1”(“恰有”的意思是有且僅有)。
圖二-1-1
  這類問題就是經典的精確覆蓋問題,沒有多項式算法,屬於NP完全問題。通用解法是窮舉法。
  2、窮舉法
  窮舉的意思就是枚舉所有狀態,每一行的狀態有“選”和“不選”兩種,那麼R行的狀態數就是2^R。所以窮舉的複雜度是指數級的。窮舉的常用實現就是深度優先搜索:對於一個R×C的矩陣,枚舉每一行r的“選”與“不選”,第r行“選”的條件是它和已經選擇的行集合都沒有衝突(兩行衝突的定義是兩行中至少存在一列上的兩個數字均爲“1”)。當已經選擇的行集合中所有的列都恰有一個“1”時算法終止。
  那麼,枚舉每行選與不選的時間複雜度爲O(2^R),每次選行時需要進行衝突判定,需要遍歷之前選擇的行集合的所有列,衝突判定的最壞時間複雜度爲O(R*C),所以整個算法的最壞複雜度O(R*C*2^R)。
  這裏可以加入一個很明顯的優化:由於問題的特殊性,即選中的行集合的每列只能有一個“1”。所以可以利用一個全局的哈希數組H[]標記當前狀態下列的選擇情況(H[c]=1表示第c列已經有一個“1”了)。這樣每次進行衝突判定的時候就不需要遍歷之前所有的行,而只需要遍歷這個哈希數組H,遍歷的最壞複雜度爲O(C)。選中一個沒有衝突的行之後,用該行裏有“1”的列去更新哈希數組H,複雜度也是O(C)(需要注意的是:深搜在回溯的時候,需要將哈希數組標記回來)。所以整個算法的最壞複雜度爲O(C*2^R)。對於R和C都在15以內的情況,時間複雜度的數量級大概在10^6,已經可以接受了。
  【例題3】給定一個R×C(R <= 20, C <= 50)的01矩陣,問是否存在這樣一個行集合,使得集合中每一列恰有一個“1”。
  這題和上一題的區別在於R和C的數據量,總數據規模是之前的四倍多,觀察數據量,如果利用窮舉+哈希,那麼時間複雜度是10^8的數量級,已經無法滿足我們的需求。這時可以採用狀態壓縮。
  3、狀態壓縮
  狀態壓縮一般用在動態規劃中,這裏我們可以將它進行擴展,運用到搜索裏。
  考慮上述矩陣的某一行,這一行中的每個元素的值域是[0, 1],所以可以把每個元素想象成二進制數字的某一位,那麼我們可以將一個二維矩陣的每一行壓縮成一個二進制數,使得一個二維矩陣變成一個一維數組(降維)。
圖二-3-1
  由於列數C的上限是50,所以可以把每一行用一個64位的整型來表示。
  然後我們可以把問題的求解相應做一個轉化,變成了求一個一維數組的子集,子集中的數滿足兩個條件:
   1) 任意兩個數的“位與”(C++中的'&')等於0;
   2) 所有數的“位或”(C++的'|')等於2^C - 1;
  第1)條很容易理解,倘若存在某兩個數的“位與”不等於0,那麼在這兩個數的二進制表示中勢必存在某一位都爲1,即一列上至少有兩個“1”,不滿足題目要求;第2)條可以這麼理解,所有數的“位或”等於2^C - 1,代表選出的數中所有位都至少有一個“1”,結合第1)條,代表選出的數中所有位至多有一個“1”。與之前的矩陣精確覆蓋問題等價轉化。
  那麼我們依舊採用窮舉法,枚舉每個數的“選”與“不選”。需要用到一個64位整型的輔助標記X(相對於之前的哈希數組H),X表示所有已經選擇數的“位或”和。那麼第r個數“選”的話則需要滿足它和X的“位與”等於0(這個簡單的操作相當於之前提到的行衝突判定)。輔助標記X的判定和更新的時間複雜度都是O(1)的,所以總的時間複雜度就是窮舉的複雜度,即O(2^R)。
  【例題4】給定一個R×C(R <= 50, C <= 200)的01矩陣,問是否存在這樣一個行集合,使得集合中每一列恰有一個“1”。
  數據量進一步擴大,我們發現單純的窮舉已經完全沒法滿足需求,需要對算法進行進一步改進。
  4、回溯法
  還是採用枚舉的思想,不同的是這次的枚舉相對較智能化。具體思路是當枚舉某一行的時候,預先把和這行衝突的行、列都從矩陣中刪除,這樣一來避免下次枚舉到無用的行,大大減少搜索的狀態空間。
  以之前的6×7的矩陣爲例,當枚舉第一行“選”時,這行中有“1”的列就可以從原矩陣中刪除了。如圖二-4-1所示,其中紅色的框代表選中的行,藍色的框代表刪除的列。
圖二-4-1
  再深入一點,我們可以把那些刪除的列上有“1”的行也進行刪除,這個是顯然的,因爲一列上只能有一個“1”,所以已經選了一行,其它的行就都不需要了。如圖二-4-2,左數第一個藍色框(第二列)的第三個元素爲“1”,所以可以直接刪除第三行。同樣的,第二個藍色框(第六列)的第二個元素爲“1”,所以直接刪除第二行。綠色的框代表刪除的行。
圖二-4-2
  這樣一來,一個6×7的矩陣經過一次枚舉+刪除,轉變成了一個3×5的矩陣。繼續用同樣的方式求解這個小規模的矩陣。如圖二-4-3所示,紅色框的行選中後,需要刪除藍色列和綠色行。刪除完後剩下的矩陣變成了空矩陣。本次搜索一共選擇兩行,選中的行中有“1”的列數爲5,而總列數爲7,所以這種方案宣告失敗。
圖二-4-3
  這時候我們需要回溯,首先將空矩陣恢復爲3×5的矩陣,然後選擇第二行。如圖二-4-4,進行列和行的刪除後,剩下的爲一個1×3的矩陣,而且三個數字均爲“1”。很明顯,這一行必選。至此,我們找到了一個精確覆蓋的可行解。
  這裏需要注意的就是,在解輸出時需要轉換成最原始矩陣對應的行號,即第1行、第5行、第6行。
圖二-4-4
三、Dancing Links X算法
  1、X算法
  上述的回溯法求解精確覆蓋問題的算法,是由算法大師Donald Knuth提出的,被稱爲“X算法”。它是一個遞歸、非確定性、深度優先的回溯算法。算法的描述如下(譯自維基百科https://en.wikipedia.org/wiki/Knuth%27s_Algorithm_X):
   1) 如果矩陣A沒有列(即空矩陣),則當前記錄的解爲一個可行解;算法終止,成功返回;
   2) 否則選擇矩陣A中“1”的個數最少的列c;(確定性選擇)
   3) a.如果存在A[r][c]=1的行r,將行r放入可行解列表,進入步驟4);(非確定性選擇)
     b.如果不存在A[r][c]=1的行r,則剩下的矩陣不可能完成精確覆蓋,說明之前的選擇有錯(或者根本就無解),需要回溯,並且恢復此次刪除的行和列,然後跳到步驟3)a;
   4)對於所有的滿足A[r][j]=1的列j
        對於所有滿足A[i][j]=1的行i,將行i從矩陣A中刪除;
     將列j從矩陣A中刪除;
   5) 在不斷減少的矩陣A上遞歸重複調用上述算法;
  上述的行r的“非確定性選擇”的意思是這個算法本質把自身複製給多個獨立的子算法;每個子算法繼承當前矩陣A。所有子算法搭建了一棵搜索樹,樹根是初始矩陣,每個被刪除行列的矩陣是這棵搜索樹樹上的內部結點,空矩陣是葉子結點,回溯就是前序遍歷樹的過程,即深度優先。任意一個深度爲k的搜索樹結點中的矩陣代表了一個選擇k行後剩下的矩陣。
  2、搜索樹
  如圖三-2-1展示的是一棵矩陣A經過X算法後產生的搜索樹。

圖三-2-1
  黑色箭頭代表了搜索樹的樹邊,連接的兩個樹結點分別代表搜索前的矩陣狀態和搜索後的矩陣狀態,正向(即橙色箭頭)代表了狀態轉移,也就是選擇某一行,並且根據規則進行刪列、刪行的過程;反向(即咖啡色箭頭)代表了回溯,也就是恢復之前刪除的行、列的過程。爲了使問題更加清晰,我們將上面的圖進行一個轉化。
圖三-2-2
  如圖三-2-2,每個矩陣代表一個狀態,S0代表了初始矩陣,S1代表選中第四行並且刪除相應列和行之後剩下的矩陣,S2、S3同理可得,S4代表空矩陣。這個就是本次X算法的完整搜索樹,隨着行數的增加,搜索樹的結點數可能呈指數級增長。
  在這個搜索流程中,涉及到了大量的矩陣緩存和矩陣回溯,於是,設計一種合適的數據結構來存儲矩陣顯得尤爲重要。
  3、傳統矩陣存儲
  傳統存儲矩陣的方式是二維數組,但是在搜索中刪除行列之後需要將後面的行列數據相應往前移,對於一個R×C的矩陣來說,最壞的時間複雜度是O(R*C)。更加糟糕的是,每次搜索失敗的時候,需要回退刪除的數據,也就是矩陣回溯,又是一個O(R*C)的時間複雜度,理解起來顯得很繁瑣,實現起來也很容易寫錯。當然,你可以選擇每次搜索時生成一個新的矩陣,然後將原矩陣中未刪除的數據拷貝過來,雖然相對來說易於理解,但是時間複雜度還是沒有解決,而且帶來了一個額外空間開銷,當遞歸深度過深還可能導致棧溢出。
  4、Dancing Links
  於是算法大師Donald Knuth提出了Dancing Links X算法,Dancing Links翻譯成舞蹈鏈,並不是算法本身,而是一種鏈式的數據結構,利用鏈表的性質,在緩存和回溯矩陣中運用得恰到好處,不需要額外開闢空間,在這種結構存儲的矩陣,列刪除是O(1)的、行刪除是O(C)的(當然這個取決於如何實現)。正是由於這種刪除、恢復的操作是指針之間的跳躍彷彿精妙的舞蹈一般,由此得名。
  “Dancing Links X”的含義正是利用“舞蹈鏈”來求解“X算法”的意思。
  設想一個雙向循環鏈表中的某個結點x,x->left 和 x->right 分別指向它的左右結點。我們可以通過圖三-4-1的操作將x從這個雙向循環鏈表中移除。
圖三-4-1
  你可能會說,這種寫法存在一定隱患。因爲移除後,x的內存還在,x->left 和 x->right 還是指向鏈表中的元素,應該將它們置空並且釋放掉x的內存。
  但是上面說的是將x移除,而並非刪除,也就是說x對於我們來說還有利用價值,所以我們只是暫時將它從鏈表中移除,等到時機成熟,我們還想將它添加回鏈表中。如圖三-4-2,由於當時移除x的時候並沒有修改x的left和right指針,也沒有釋放x的內存,所以重新添加x就變得如此簡單。
圖三-4-2
  這兩步操作正是Dancing Links的精華,聯想之前的矩陣的刪除和恢復,x的移除類似矩陣的刪行和刪列,而將x重新添加回來類似矩陣的回溯。只不過矩陣是二維的,這個雙向循環鏈表是一維的,那麼沒關係,我們可以將維數進行擴展。
  Dancing Links正是十字交叉雙向循環鏈表。
  5、十字交叉雙向循環鏈表
  這種鏈表結構的每個結點有兩類數據,分別爲指針域和數據域。指針域爲left、right、up、down,分別指向左、右、上、下四個其它結點;數據域則存儲一些信息,比如這個結點對應於原始矩陣的行編號rowIdx,列編號colIdx等等。
  原始矩陣中值爲“1”的位置對應了一個Dancing Links結點,“0”的位置不是我們需要關心的。
  那麼接下來我們來看下,如何將一個矩陣轉變爲一個十字交叉雙向循環鏈表。我們把Dancing Links結點分成以下四類:總表頭head、列首結點col[]、行首結點row[]、元素結點node。
    1) 總表頭head:將列首結點col[]在水平方向串聯起來,head->right指向矩陣的第一列的列首結點,head->left指向矩陣的最後一列的列首結點。特別的,當這個矩陣爲空矩陣,也就是沒有任何列時,head->right和head->left指向head本身,這也正是X算法的終止條件。
    2) 列首結點col[]:令初始矩陣的列數爲colCount,那麼col[i]->right指向col[i+1],特殊的,col[colCount-1]->right指向head;同理,col[0]->left指向head,其它的col[i]->left指向col[i-1]。col[i]->down和col[i]->up分別指向第i列的第一個“1”和最後一個“1”對應的結點,當col[i]的up和down都指向本身說明這列全是0;
    3) 行首結點row[]:令初始矩陣的行數爲rowCount,那麼row[i]->up和row[i]->down都是無用指針,直接指向自己即可;row[i]->right和row[i]->left分別指向第i行的第一個“1”和最後一個“1”對應的結點。
    4) 元素結點node:矩陣中“1”對應的結點,up、down指向其它node或列首結點;left、right指向其它node或行首結點。
  如圖三-5-1表示了之前的那個矩陣的十字交叉雙向循環鏈表的數據結構表示。所有箭頭的左右邊界循環相連(上下邊界亦循環相連)。每個元素結點代表了原矩陣中的那個“1”,即圖中的藍色方塊,其中的數字代表對應內存池中的編號。初始化時,所有的行首結點的左右指針和列首結點的上下指針都指向自己,然後對矩陣進行行、列分別遞增的順序進行讀取,讀到“1”就執行結點插入操作,這正對應了圖中藍色結點的遞增序。別以爲這是飛行棋...

圖三-5-1
  6、額外結點的意義
  我們發現,圖三-5-1中,除了藍色結點,其它三種結點都是額外的,那麼爲什麼要引入額外結點呢?
  列首結點、行首結點都是存在既有數組中的,所以進行插入操作的時候可以達到O(1),試想如果只有列首結點沒有行首結點,那麼插入一個處於(r, c)位置的結點時,c可以定位到列首結點col[c],在進行對應行的插入時只能遍歷豎向鏈表,插入的時間複雜度就變成O(R)了;同樣,如果只有行首結點沒有列首結點,那麼插入複雜度就是O(C)的。
  列首結點還有一個作用是區分不存在的列和全“0”的列。如果列c在搜索過程中被刪除,那麼列c的列首結點不會出現在鏈表結構中;而一個全“0”的列c,列首結點會在鏈表結構中,並且它的上下指針都指向自己。
  總表頭head主要還是爲了空矩陣而存在的,試想如果一個矩陣爲空,那麼勢必它的所有列首結點都沒有了,那用什麼來表示空矩陣呢?引入總表頭後,只要總表頭的左右指針都指向自己,就代表這是一個空矩陣。
四、Dancing Links X算法的具體實現
  1、結點定義DLXNode
    四類結點都定義爲DLXNode,並且除了left、right、up、down四個指針數據外,還需要一些額外信息記錄:
    1)對於總表頭,不需要額外記錄信息;
    2)對於列首結點,需要記錄列編號colIdx,該列的結點個數colSum;
    3)對於行首結點,需要記錄行編號rowIdx;
    4)對於元素結點,需要記錄行編號rowIdx,列首指針colhead;
/*
DLXNode
	left, right        十字交叉雙向循環鏈表的左右指針
	up, down           十字交叉雙向循環鏈表的上下指針

	<用於列首結點>
	colSum             列的結點總數
	colIdx             列的編號
		
	<用於行首結點/元素結點>
	colHead            指向列首結點的指針
	rowIdx             DLXNode結點在原矩陣中的行標號
*/
class DLXNode {
public:
	DLXNode *left, *right, *up, *down;
	union {
		struct {
			DLXNode *colHead;   
			int rowIdx;
		}node;
		struct {
			int colIdx;
			int colSum;
		}col;
	}data;
};
  2、鏈表定義DLX
    十字交叉雙向循環鏈表對於整個搜索來說,只有一個對象,所以這裏採用單例實現。因爲結點個數可能很多,所以可以將結點內存放在堆上避免棧溢出,row和col分別代表行首和列首結點,dlx_pool則爲元素結點的對象池。可以在構造函數中利用new生成這些動態結點,在析構函數中delete。
/*
DLX (單例)
	head               head 只有左右(left、right)兩個指針有效,指向列首
	rowCount, colCount 本次樣例矩陣的規模(行列數)
	row[]              行首結點列表
	col[]              列首結點列表
	
	dlx_pool           結點對象池(配合dlx_pool_idx取對象)
*/
class DLX {
	DLXNode *head;             // 總表頭
	int rowCount, colCount;    // 本次樣例矩陣的規模(行列數) 
	DLXNode *row, *col;        // 行首結點列表 / 列首結點列表
	
	DLXNode *dlx_pool;         // 結點對象池
	int dlx_pool_idx;              // 結點對象池下標
};

	dlx_pool = new DLXNode[MAXR*MAXC];
	col = new DLXNode[MAXC+1];
	row = new DLXNode[MAXR];


  3、初始化
    1)設置本次問題的規模總行數rowCount,總列數colCount,結點對象池下標dlx_pool_idx置零;
    2)初始化列首結點,將總表頭head和col[i]在水平方向用left和right指針串聯起來,col[i]的up和down指針指向自己,代表這列在矩陣中均爲“0”;對於每個列首結點col[i],將其列編號置爲i,列結點總數colSum置零;
    3)初始化行首結點,將行首結點row[i]的四個指針都指向自己,將其行編號rowIdx置爲i,對應列首結點的指針置NULL;
  4、結點插入
    按行遞增、列遞增的方式枚舉R×C的矩陣A,如果第r行第c列的值A[r][c] = 1,則插入一個(r, c)的結點:
    1)取出結點對象池中的一個結點Node(注意需要返回指針或者引用);
    2)取列首結點col[c],將它設置爲Node的列首結點,並且將Node插入到col[c]和col[c]->up之間,將col[c]的結點總數colSum自增1;
    3)取行首結點row[r],將Node的行編號rowIdx設置爲r,並且將Node插入到row[r]和row[r]->left之間;
  5、刪列
    刪除列c包含兩步:
    1)移除列首結點col[c],這裏的移除指只移除水平方向,豎直方向不作任何修改;
    2)從列首結點col[c]往下枚舉,將每個元素結點對應的行進行移除(即刪行);
  6、刪行
    刪除行r的操作只需要修改row[r]上所有元素結點的up和down指針,只移除豎直方向,水平方向不作任何修改;
  7、開始跳舞
    X算法的主體,具體步驟之前已經描述過,現直接給出深度優先搜索的實現如下:
bool DLX::dance(int depth) {
	// 當前矩陣爲空,說明找到一個可行解,算法終止 
	if(isEmpty()) {
		resultCount = depth;
		return true;
	}
	DLXNode *minPtr = get_min_col();
	// 刪除minPtr指向的列 
	cover(minPtr);
	// minPtr爲結點數最少的列,枚舉這列上所有的行
	for(DLXNode *p = minPtr->down; p != minPtr; p = p->down) {
		// 令r = p->getRowIdx(),行r放入當前解 
		result[depth] = p->getRowIdx();
		// 行r上的結點對應的列進行刪除 
		for(DLXNode *q = p->right; q != p; q = q->right) {
			cover(q->getColHead());
		}
		// 進入搜索樹的下一層 
		if(dance(depth+1, maxDepth)) {
			return true;
		}
		// 行r上的結點對應的列進行恢復 
		for(DLXNode *q = p->left; q != p; q = q->left) {
			uncover(q->getColHead());
		}
	}
	// 恢復minPtr指向的列
	uncover(minPtr); 
	return false;
}
  其中cover和uncover對應列的刪除和恢復,傳入參數爲列首結點的指針。
(注:本文結尾會給出Dancing Links X算法的完整代碼)

五、精確覆蓋的應用
  對於精確覆蓋問題有很多變形,但是不變的是都可以轉化成01矩陣,並且矩陣的行代表問題的所有情況,矩陣的列代表問題的約束條件。
  1、開關切換問題
  【例題5】N盞燈由M個開關控制(N<=100, M<=100),每個開關可以調出“-”、“0”、“+”三種狀態,給出三維01矩陣T[M][N][3]。其中T[i][j][s]=1代表了第i個開關能夠在s狀態下將第j個燈點亮。特殊的,如果多個開關同時點亮某盞燈,會將它熄滅。求一種方案使得所有燈都被點亮。
  行代表問題的所有情況,列代表問題的約束條件。那麼問題的所有情況,即所有開關的所有狀態,情況數爲3*M(即Dancing Links的01矩陣的每行代表每個開關的其中一種狀態)。問題的約束條件有三個,其中第三個是隱含條件:
  1) 一盞燈只能被一個開關控制;
  2) 所有燈都亮起;
  3) 每種開關只能調一種狀態;
  那麼構建Dancing Links的01矩陣,矩陣前N列代表在這個開關的狀態下,對應的燈是否亮起(“1”代表亮,“0”代表不亮);後M列代表使用了對應的開關,也就是第i個開關代表的三行(因爲有三種狀態所以是三行)對應的列的值爲“1”。
所以總的矩陣規模爲3*M行,N+M列的01矩陣,對前N列求一次精確覆蓋即可。
圖五-1-1
  2、N皇后問題
  【例題6】在一個N×N的棋盤上放置N個皇后(N <= 50),使得任何兩個皇后之間不相互攻擊(即同一行、同一列、同一對角線不能有大於1個皇后)。求一種擺放方案。
  經典N皇后問題有構造算法。這裏介紹一下將問題轉換成精確覆蓋後用Dancing Links X求解。Dancing Links矩陣的行代表問題的所有情況,列代表問題的約束條件。每個皇后能夠放置的位置總共有N^2種,也就是我們構建的Dancing Links矩陣會有N^2行。
列分爲四種約束條件:
   1)列[0, N)       代表了棋盤N行的佔據情況
   2)列[N, 2N)      代表了棋盤N列的佔據情況
   3)列[2N, 4N-1)    代表了棋盤2N-1條主對角線的佔據情況
   4)列[4N-1, 6N-2)  代表了棋盤2N-1條副對角線的佔據情況
  所以這是個行N^2,列6N-2的01矩陣。

圖五-2-1
  四個約束條件分情況討論:對於(i,j)的位置,佔據的行爲i;佔據的列爲j;主對角線可以通過i和j的相對情況來判斷,所有i-j相同的佔據的主對角線爲N+i-j;副對角線類似,所有i+j相同的位置佔據的副對角線爲i+j-1;
  枚舉所有情況,對每個(i,j)(其中(i,j)屬於[1,N]×[1, N])建立對應的約束條件,就相當於建立了Dancing Links的01矩陣(見圖五-2-1)。
  然而,N皇后問題不能直接求精確覆蓋,因爲我們發現,行和列必須完全覆蓋到,但是主和副對角線沒有要求一定要全部覆蓋,所以我們問題的求解轉變成對於這樣一個01矩陣,求選出一些行,使得前2N列每列恰有一個“1”(這2N列分別對應行和列的約束條件)。那麼也很簡單,只需要修改兩個地方:
   1)在選擇“1”元素最少的列的時候只選擇[0,2N);
   2)如果[0,2N)列中都已經沒有“1”可以選擇了,那麼算法終止;
  以上就是求解N皇后問題的全部過程。

圖五-2-2
  【思考題1】在一個N×N的棋盤上放置N個皇后(N <= 50),使得任何兩個皇后之間不相互攻擊(即同一行、同一列、同一對角線不能有大於1個皇后),並且有K個皇后已經放置在某些互不攻擊的位置,求放置剩餘N-K個皇后滿足N皇后問題。
(提示:還是精確覆蓋問題)

  3、骨牌覆蓋問題
  【例題7】給出以下12塊骨牌,每個骨牌只能用一次,但是可以旋轉或者翻轉,要求鋪滿一個M×N的棋盤,保證M×N = 60。求方案數。如圖五-3-2,爲3×20的棋盤的其中一種方案。
圖五-3-1

圖五-3-2
  先來考慮一個簡化點的版本,假如每個骨牌無法旋轉和翻轉,該如何求解?
我們可以將每個骨牌嘗試在M×N的棋盤的每個位置都去擺一遍。之前說了Dancing Links矩陣的行代表了問題的所有情況,那麼每個骨牌在每個位置都放一遍正好對應了所有的這些情況(估計約60×12=720行)。而列的約束條件,分爲兩種:
1) 每個格子是否被佔據;總共60個格子,即60列;
2) 當前骨牌放置方案用的是哪類骨牌,總共12列; 
對於條件1)每個格子都要被覆蓋,對於條件2)每個骨牌都需要用到,於是就轉化成了一個720×72的精確覆蓋問題。
引入骨牌的翻轉/旋轉後,只是讓矩陣的行數增加,列數不變,即能夠擺放的情況數增多了。這裏需要注意翻轉和旋轉後骨牌最多有8(2×4)種情況,但是如果一旦翻轉或旋轉得到的結果一樣的話只能算一種,所以這裏可以採用二進制哈希標記每種旋轉/翻轉狀態。如圖五-3-3,代表了Dancing Links的矩陣。

圖五-3-3
  4、數獨問題
【例題8】對於一個N階的數獨,由N^2×N^2個格子組成,如圖爲一個3階的數獨。要求滿足四個限制條件:
1) 每個格子只能填1個數;
2) 每行的數字集合爲[1, N^2],且不能重複;
3) 每列的數字集合爲[1, N^2],且不能重複;
4) 每個“宮”的數字集合爲[1, N^2],且不能重複其中“宮”的意思就是N×N的格子。對於N=3的情況,就是“九宮格”);
現在問題是給定一個已經填了一些數字的數獨,求當N=3時的一種解,滿足以上四個限制條件。
圖五-4-1
轉變爲精確覆蓋問題。行代表問題的所有情況,列代表問題的約束條件。每個格子能夠填的數字爲[1,9],並且總共有9×9(即3^2×3^2)個格子,所以總的情況數爲729種。也就是DancingLinks的行爲729行。
列則分爲四種:
1) [0, 81)列  分別對應了81個格子是否被放置了數字。
2) [82, 2*81)列  分別對應了9行,每行[1, 9]個數字的放置情況;
3) [2*81, 3*81)列 分別對應了9列,每列[1, 9]個數字的放置情況;
4) [3*81, 4*81)列 分別對應了9個“宮”,每“宮”[1, 9]個數字的放置情況;
所以總的列數爲4*81=324列。如圖五-4-2所示。

圖五-4-2
  舉個例子,對於在數獨棋盤的i行j列的格子(i, j)上放置一個數字k,那麼對應的Dancing Links的01矩陣行,一行上有四個“1”,分別對應四種約束條件:
    1) 格子限制: 行號*9 + 列號
    2) 行不重複限制: 81 + 行號*9 + (k-1)
    3) 列不重複限制: 2*81 + 列號*9 + (k-1)
    4) “宮”不重複限制:3*81 + 宮號*9 + (k-1)
  行號是i,列號是j,比較好理解;那麼宮號我們定義如下圖:
圖五-4-3
  宮號的計算方式可以通過行號和列號得出。即 宮號 = (i/3)*3 + (j/3);
  那麼構建01矩陣的時候,我們從上到下,從左到右遍歷數獨,對於在(i, j)上有數字k的只需要插入一行,這行上有四列爲“1”。對於沒有填寫數字的需要枚舉[1, 9],把在(i, j)位置上填[1, 9]的情況都進行插入,一共9行。
  矩陣構建完畢,求一次精確覆蓋即可。
  【思考題2】請設計一個四階的數獨的精確覆蓋矩陣,並說說和三階的區別是什麼?

六、重複覆蓋
  1、重複覆蓋的定義
  【例題9】給定一個R×C(R, C <= 50)的01矩陣,問是否存在這樣一個行集合,使得集合中每一列至少一個“1”。
  重複覆蓋是精確覆蓋的一般情況,限制條件遠遠沒有精確覆蓋強。回憶一下X的算法思路,我們發現重複覆蓋可以參照精確覆蓋的方法構建Dancing Links鏈表,然後枚舉行的選取,進而刪除該行上有“1”的列,但是僅此而已,無法再刪除列對應的行。如圖六-1-1所示,選擇第一行(紅色框),然後刪除藍色的列(試想一下,如果是精確覆蓋,我們還可以刪除綠色的行),然而重複覆蓋無法刪除綠色的行,這是因爲選取的行集合允許在每列上有多個“1”,如果過多的刪除有可能導致可行解的擦肩而過。

圖六-1-1
 這樣帶來的問題就是矩陣規模的下降速度會大大減慢,從而使得搜索的狀態空間樹十分龐大,這時候往往需要剪枝,在介紹剪枝之前,讓我們先來看一個更加複雜的情況。
  【例題10】給定一個R×C(R, C <= 50)的01矩陣,找出最少的行集合,使得集合中每一列至少一個“1”。
爲了滿足搜索的行數最小這個條件,我們需要引入迭代加深。

  2、迭代加深(IDA*)
迭代加深,顧名思義,就是深度的迭代。即枚舉一個最大深度,然後對問題進行搜索,搜索過程記錄當前深度,如果當前深度大於最大深度則無條件返回。例如,假設枚舉的最大深度爲3,那麼搜索選取01矩陣的行時最多隻能選擇3行,當深度大於3深搜函數必須返回。
那麼可以枚舉深度,然後再進行搜索。

  3、啓發式函數
引入最大深度的原因,除了能第一時間找到“最少”,更大程度上是便於啓發性剪枝。考慮到當前枚舉深度depth,最大枚舉深度maxDepth,令K = ()。則在這種情況下,還有K步決策,或者說是隻能再最多選擇K行,那麼如果我們能夠設計一個估價函數H(),函數返回的是至少還需要多少行才能完成重複覆蓋(這是個估計值,不是確定的)。並且H() > K,則代表當前搜索條件下,頭已經不可能搜到可行解了,可以直接返回,使得搜索樹的一些分支不需要再進行無謂的搜索,此所謂“剪枝”。
H()函數是一個估計值,並不能精確計算出來(如果能精確計算出來,那問題本身就可以直接用這個函數來計算了),並且這個估計值一定是要比實際值小的,即 實際值 > 估計值H() > K。
H()函數原理:X算法的終止條件是列爲空,那麼我們現在要做的就是要模擬刪除所有的列,這裏說的刪除並不是真正的刪除,而是做一個標記。假設Dancing Links 的01矩陣的列數小於64,那麼每一行可以壓縮成一個INT64的整型(當然,如果列數大於64的話,可以壓縮在一個INT64的數組裏,總之目的就是利用位運算減少輪詢操作),用R[i]表示第i行的那個64位整數。用一個全局標記X來記錄剩下列的模擬刪除情況(X的二進制第i位爲“1”代表第i列已經被模擬刪除)。
H()函數計算過程:任意找一個未被模擬刪除的列c,計數器cnt+1,選中列c上有“1”的行r,令X = X or R[r],依次往復直到不存在這樣的列c。最後的計數器cnt就是那個估計值。
  4、引用計數
重複覆蓋的時候,每次選擇一行,刪除行上有“1”的列時,有可能會枚舉到已經刪除的列,如果已經刪除則需要進行標記,但是不能標記爲已經“刪除”和“未刪除”兩種狀態。因爲除了刪除,還需要恢復,所以刪除的狀態其實是有“被刪除0次”、“被刪除1次”、“被刪除2次”、...“被刪除N次”這樣的多種狀態組成的。
正確做法是用一個標記數組D[i],標記第i列刪除的次數。每次執行刪除時,標記+1,並且判斷標記爲1才執行刪除;每次執行恢復時,標記-1,並且判斷標記爲0時才執行恢復。
七、重複覆蓋的應用
  1、回顧彩票問題
相信經過前面的解說,彩票那個問題都已經會了,直接轉化成01矩陣後,利用IDA*求解重複覆蓋即可。

  2、雷達系統
  【例題11】給定N個城市和M個雷達的位置,雷達掃描範圍爲圓形,半徑未知(N,M <= 50)。現在想選出其中K個雷達,覆蓋所有的城市,爲了節約成本,希望掃描半徑最小。求這個最小的掃描半徑。
  這個問題非常經典,曾經是百度之星決賽的題目。很容易看出來的是,掃描半徑如果無限大,那麼必然可以找到K個雷達覆蓋所有城市;同理,如果掃描半徑爲0,那麼這件事情就很難辦到。雷達覆蓋所有城市的概率相對於掃描半徑的大小單調遞增,換言之,掃描半徑越大,雷達覆蓋城市越容易。
  於是,我們可以二分枚舉這個半徑R,然後根據這個半徑來建立雷達和城市之間的關係矩陣A。A[i][j]=1當且僅當第i個雷達到第j個城市的距離小於等於R,建立好關係矩陣後,我們發現,現在的問題就是看能不能選擇K行(每行代表每個雷達)滿足每行上的列都至少有一個“1”(每個列代表每個城市),完美轉化成重複覆蓋問題。
  3、支配集問題
重複覆蓋問題其實是個支配問題,用“行”來支配“列”,任何能夠轉化爲行列關係的問題,都可以轉換成重複覆蓋問題進行求解。

八、跳舞鏈相關題集整理

精確覆蓋

EasyFinding                      ☆☆           赤裸精確覆蓋

TreasureMap                      ☆☆           經典精確覆蓋

Dominoes                         ☆☆           經典骨牌覆蓋

APuzzlingProblem                 ☆☆           經典骨牌覆蓋

NQUEEN                           ☆☆           N皇后問題

Lamp                             ☆☆           精確覆蓋

PowerStations                    ☆☆           精確覆蓋


數獨系列

SudokuKiller                     ☆☆☆☆           基礎題

Su-Su-Sudoku                     ☆☆☆☆           基礎題

Sudoku                           ☆☆☆☆           基礎題

Sudoku                           ☆☆☆☆           基礎題

Sudoku                           ☆☆☆           基礎題

Sudoku                           ☆☆☆           基礎題

Sudoku                           ☆☆☆           基礎題

Sudoku                           ☆☆           有意思的題

Sudoku                           ☆☆           最全的數獨題

SquigglySudoku                   ☆☆           數獨+連通分量


重複覆蓋

神龍的難題                         ☆☆☆           基礎重複覆蓋

whosyourdaddy                    ☆☆           重複覆蓋A*基礎

Radar                            ☆☆           二分+重複覆蓋A*

Bomberman                        ☆☆           重複覆蓋A*

RepairDepots                                三角形外心+重複覆蓋A*

Firestation                      ☆☆           重複覆蓋A*

StreetFighter                               精確覆蓋+重複覆蓋

SquareDestroyer                  ☆☆☆           古董題

Airport                          ☆☆☆           基礎重複覆蓋A*

ASimpleMathProblem               ☆☆☆           重複覆蓋+打表



Dancing Links X算法C++代碼:DLX完整代碼
Dancing Links X算法 解題報告:DLX解題報告


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章