回溯法

Hang3發表於2024-10-09

演算法導論

這個文件是學習“演算法設計與分析”課程時做的筆記,文件中包含的內容包括課堂上的一些比較重要的知識、例題以及課後作業的題解。主要的參考資料是 Introduction to algorithms-3rd(Thomas H.)(對應的中文版《演算法導論第三版》),除了這本書,還有的參考資料就是 Algorithms design techniques and analysis (M.H. Alsuwaiyel)。

回溯法

在現實世界中,大多數問題都可以透過搜尋大量但是有限的可能性來找到解決方案。

此外,對於幾乎所有這些問題,不存在使用窮舉搜尋以外的方法來得到解決方案。因此,希望能夠有一種更加系統的搜尋方法,將搜尋空間儘可能地縮小,從而提高效率。

這種用於系統搜尋的通用技術被稱為回溯法(backtracking),而回溯法可以理解為一種能夠避免搜尋所有可能的結果的組織性窮舉搜尋(organized exhaustive search)。

在回溯法中,問題的解空間可以被組織成一個解空間樹或者搜尋樹(search tree),並且使用深度優先的方法進行搜尋,演算法搜尋至解空間樹的任意節點時,先判斷該節點是否能夠達到正確的解,如果肯定不能到達正確的解,則跳過以該節點為根節點的子樹的搜尋,逐層向其祖先節點回溯;否則,進入該子樹,繼續按深度優先的方法搜尋。

下面回顧一個之前在介紹貪心法時介紹過的問題————揹包問題。現在將會演示如何使用回溯法解決揹包問題。

假設現在有三個物品,每個物品的重量分別為(20, 15, 10),每個物品的價值分別為(20, 30, 25),揹包的容量為25。

下圖為使用回溯法搜尋解空間樹的過程:

pack_problem

路徑上的1,0分別表示選擇該物品或者丟棄該物品,節點中的數值表示當前揹包的容量,可以看到當揹包的物品的容量為負數時必然不可能得到正確的解決方案,那麼就向父節點回溯,尋找其他解決方案。使用回溯法可以找出所有可能的方案,並且可以避免搜尋所有可能的結果。

3-著色問題

3著色問題(3-coloring problem),給定一個無向圖 G = (V, E),要求為圖中每個節點塗上三種顏色(denoted as 1, 2, 3)中的一種,並且任意兩個相鄰的節點不能使用相同的顏色。

可以將每個節點的著色方案表示為一個n元組 \((c_1, c_2,...,c_n),c_i\in \lbrace 1, 2, 3 \rbrace\) ,比如 (1, 2, 2, 3, 1)表示圖中 5 個節點的著色方案。那麼這個問題的解空間中包含 \(3^n\) 種可能。

比如下面的例子,要給一個包含5個節點的無向圖著色:

3-coloring

下圖是使用回溯法找到一種有效解決方法的過程:

3-coloring_solution

上圖對應的著色方案是 (1, 2, 2, 1, 3)。注意,上圖只是找到一種的解決方法,而沒有完整地搜尋整個解空間樹並找到所有有效的解決方法。

在上面的例子中需要注意的是,首先,節點是按照深度優先的順序生成的;其次,沒有必要儲存整個解空間樹,而只需要儲存從根節點到當前節點的路徑即可。事實上,並沒有真正生成節點,而解空間樹存在於概念上,並不是實際存在的,只需要追蹤著色分配即可。

3-著色問題的回溯演算法的虛擬碼如下:

3-coloring_algorithm

這裡給出的演算法是使用巢狀迴圈實現的,實際上這個演算法也可以使用遞迴的方式實現。在這個虛擬碼中,內層的迴圈用於生成新的節點,即對每個節點依次遍歷3中可能的顏色,然後檢視當前的著色方案是否是能夠得到正確方案的(partial)或者是合法的解決方案(legal coloring);外層的迴圈用於回溯,如果發現當前的著色方案不可行,那麼就回到上一個節點。

通用的回溯方法

回溯法通常作為一種系統搜尋的方法,用於解決的搜尋問題的解決方案通常由滿足預定義的限制條件的向量 \((x_1, ..., x_i)\) 組成,其中 i 可以是 0n 的任意整數,n 是問題的規模。

在前面介紹的3-著色問題或者另一個比較經典的8-皇后問題中,i 都是固定為 n 的,然而 i 其實是可變的,也就是說同一個問題的不同解向量(solution vector)的長度可能是不一樣的。

比如下面的這個例子,給定一個包含 n 個整數的集合 \(X=\lbrace x_1, x_2, ..., x_n \rbrace\) 以及一個整數 y ,找到 X 一個子集,使其中元素的和恰好等於 y,比如下面這樣:

X = {10, 20, 30, 40, 50, 60}y = 60,那麼就有下面不同的解決方案:{10, 20, 30}, {20, 40}, {60}。

很容易透過回溯的方法找到這個問題的解,思路和前面提到揹包問題類似,這些解的長度都是不固定的。

當然也可以使用n維的布林元組來表示上面這個問題的解,比如上面的解也可以表示為 {1, 1, 1, 0, 0, 0}, {0, 1, 0, 1, 0, 0}, {0, 0, 0, 0, 0, 1}。

在回溯法中,解向量的每一個分量 \(x_i\) 都屬於一個有限的集合 \(X_i\),因此,回溯法相當於是按照字典順序考慮笛卡爾積 \(X_1\times X_2\times \cdots X_n\) 中的元素。

最初,演算法從一個空向量(empty vector)開始,隨後選擇集合 \(X_1\) 中最小的(least)一個元素作為解向量的第一個分量 \(x_1\)。如果 \((x_1)\) 是部分解(即能夠得到正確解的),那麼演算法將會繼續選擇第二個集合 \(X_2\) 中最小的元素最為解向量的第二個分量 \(x_2\)。如果 \((x_1, x_2)\) 是部分解,那麼將會選擇第三個集合 \(X_3\) 中的最小元素作為解向量的第三個分量;否則,將會選擇 \(X_2\) 中的另一個元素作為解向量的第二個分量。

更一般地講,如果演算法已經得到了一個能夠得到正確解的(partial)解向量 \((x_1, x_2, ..., x_j)\),那麼考慮向量 \(v = (x_1,x_2,...,x_j,x_{j+1})\) 將會有下面幾種情況:

  1. 如果 v 表示一個最終的解,那麼就將其記錄為該問題的一個解。演算法要麼終止要麼繼續,取決於是否要找到多個解;
  2. (The advance step): 如果 v 表示一個部分解,那麼就演算法將會進而選擇 \(X_{j+2}\) 中最小的元素;
  3. 如果 v 既不是最終解也不是部分解,那麼將會有兩種情況考慮:
    • 如果在集合 \(X_{j+1}\) 中還有別的可選元素,那麼解向量的 \(x_{j+1}\) 將會選擇該集合的下一個元素
    • (The backtrack step) 如果集合 \(X_{j+1}\) 中沒有別的可選元素,那麼演算法將會選擇 \(X_j\) 集合中的下一個元素;如果 \(X_j\) 中也沒有別的可選元素,那麼將會回溯到 \(X_{j-1}\) 集合。並按此規律回溯。

下面給出通用回溯法的虛擬碼:

general_backtracking

上面的虛擬碼是使用迭代實現的,當然也可以使用遞迴的方式實現。和前面介紹的例子一樣,內層的迴圈用於向前組織解向量(the advance step),外層的迴圈用於回溯(the backtrack step)。

通常,為了使用回溯得到搜尋問題的解,可以使用上述的演算法作為框架,圍繞該框架可以設計專門針對手頭問題定製的演算法。

分支限界

分支限界與回溯法十分類似,也是生成解空間樹來尋找一個或多個解。然而回溯法用於搜尋滿足某一屬性的解,而分支限界通常只關心某個給定函式的最值。

此外,在分支限界演算法會在每個節點 x 處計算一個界限,即以該節點 x 為根節點的子樹所給出的所有可能解的取值界限。如果這個界限比之前計算出的界限更加糟糕,那麼就丟棄以 x 為根節點的子樹,即不會再以該節點生成子節點。也就是對於任何一個部分解 \((x_1,x_2,...,x_{k-1})\) 以及它的擴充套件 \((x_1,x_2,...,x_k)\) 必須滿足下面的關係:

\(cost(x_1,x_2,...,x_{k-1}) \le cost(x_1,x_2,...,x_k)\)

由於這樣的關係,如果想要找到一個代價為 c 的解,並且有一個部分解的代價至少是 c ,那麼就不會再繼續擴充套件這個部分解。

分支限界中的一個經典問題是旅行售貨員問題(Traveling Salesman Problem, TSP)。

給定一個有向圖,圖中每個節點代表一個城市,城市間的路徑就是圖中的邊,路徑的長度透過鄰接矩陣給出。旅行售貨員會以封閉的路徑訪問這些城市,也就是訪問每個城市並回到起點,且每個城市只去一次,求滿足要求的最短路徑。下圖就是一個代表路徑長度的鄰接矩陣:

TSP_instance

對於給定部分解 \((x_1,x_2,...,x_k)\) ,定義這個解的成本下限為按照 \(x_1,x_2,...,x_k\) 的順序旅行過這些城市的最低成本。

對TSP問題,可以觀察到的是,那麼為了對每個城市都訪問僅一次並且回到起點,假設有 n 個節點,那麼就需要 n 條邊,並且這 n 條邊是分別落在不同行不同列的。為了定義成本下限,可以從成本矩陣(cost matrix)中的每一行和每一列都選擇一條邊,然後對該行或者該列減去一個數值使得選中的邊變為0,這樣,矩陣的每一行和每一列都會包含一個0。如下圖所示:

TSP_reduction

由矩陣 A 得到上面這個矩陣 B 的方法很簡單,每行先減去該行最小的值,減完後發現第 4 列還沒有包含 0 的值,於是對第 4 列減去該列最小的值,得到該矩陣。

將每行和每列減去的數值加起來,就是當前的成本下限。比如上圖中的成本下限就是 63。

下圖是使用分支限界的方法尋找最優解的搜尋樹,從(3,5)邊開始,後面會解釋為什麼從這條邊開始。從該節點開始,生成兩個子節點,右邊的子節點不包含(3,5)邊,即以該節點為根節點的子樹中解中不會包含(3,5)邊,將 (3,5) 置為無窮大後的計算矩陣 D 的成本下限:

TSP_search_tree_1

在矩陣 D 中,由於第三行不包含 0,所以需要在第三行將去12,那麼成本下限就變成了75。

左邊的子節點包含(3,5)邊,如果選擇了這條邊,那麼將無法從節點3到達除節點5以外的任何節點,也無法從除節點3以外的任何節點到達節點5,所以除去矩陣 B 第 3 行第 5 列得到矩陣 C 並計算成本下限,並且將第 5 行第 3 列置為無窮大,然後計算矩陣 C 的成本下限,因為每行每列都包含0,因此矩陣 C 的成本下限不變,和父節點一樣是 63,比矩陣 D 的成本下限低,因此接下來考慮矩陣 C 的(2,3)邊生成子節點。

分支限界與回溯的另一個區別在於,回溯總是深度優先的搜尋,而分支限界則是將節點放入一個優先順序佇列,按照優先順序佇列的順序進行搜尋。

按照上面的規律可以得到完整的搜尋樹如下:

TSP_search_tree

所以得到的最優解為:\(1\to 3\to 5\to 4\to 1\to 2,\to 1\),該路徑總成本為 7 + 12 + 18 + 21 + 9 = 67。

在上面生成搜尋樹的過程中生成子節點所選擇的邊主要是考慮儘可能生成比較少的節點而得到最優解。所以一開始選擇(3,5)邊是為了讓右邊子節點的成本下限得到最大的增加,這樣以右邊子節點為根節點的子樹的優先順序就會降低很多。

儘管分支限界演算法通常都是複雜且難以程式設計的,但在實際應用中確實是高效的。

相關文章