目錄
一、深度優先搜索
1、DFS
2、基於DFS的記憶化搜索
3、基於DFS的剪枝
1) 可行性剪枝
2) 最優性剪枝
4、基於DFS的A* (迭代加深,IDA*)
二、廣度優先搜索
1、BFS
2、基於BFS的A*
3、雙向廣搜
三、搜索題集整理
一、深度優先搜索
1、DFS
1) 算法原理
深度優先搜索即Depth First Search,是圖遍歷算法的一種。用一句話概括就是:“一直往下走,走不通回頭,換條路再走,直到無路可走”。
DFS的具體算法描述爲選擇一個起始點v作爲當前結點,執行如下操作:
a. 訪問 當前結點,並且標記該結點已被訪問,然後跳轉到b;
b. 如果存在一個和 當前結點 相鄰並且尚未被訪問的結點u,則將u設爲 當前結點,繼續執行a;
c. 如果不存在這樣的u,則進行回溯,回溯的過程就是回退 當前結點;
上述所說的當前結點需要用一個棧來維護,每次訪問到的結點入棧,回溯的時候出棧(也可以用遞歸實現,更加方便易懂)。
如圖1所示,對以下圖以深度優先的方式進行遍歷,假設起點是1,訪問順序爲1 -> 2 -> 4,由於結點4沒有未訪問的相鄰結點,所以這裏需要回溯到2,然後發現2還有未訪問的相鄰結點5,於是繼續訪問2 -> 5 -> 6 -> 3 -> 7,這時候7回溯到3,3回溯到6,6回溯到5,5回溯到2,最後2回溯到起點1,1已經沒有未訪問的結點了,搜索終止,圖中圓圈代表路點,紅色箭頭表示搜索路徑,藍色虛線表示回溯路徑。
圖1
2) 算法實現
深搜最簡單的實現就是遞歸,寫成僞代碼如下:
1 def DFS(v):
2 visited[v] = true
3 dosomething(v)
4 for u in adjcent_list[v]:
5 if visited[u] is false:
6 DFS(u)
其中dosomething表示訪問時具體要乾的事情,根據情況而定,並且DFS是允許有返回值的。
3) 基礎應用
a. 求N的階乘;
令f(N) = N!,那麼有f(N) = N * f(N-1) (其中N>0)。由於滿足遞歸的性質,可以認爲是一個N個結點的圖,結點 i (i >= 1 ) 到結點 i-1 有一條權值爲i的有向邊,從N開始深度優先遍歷,遍歷的終點是結點0,返回1(因爲0!
= 1)。如圖2所示,N!的遞歸計算看成是一個深度優先遍歷的過程,並且每次回溯的時候會將遍歷的結果返回給上一個結點(這只是一個思想,並不代表這是求N!的高效算法)。
b. 求斐波那契數列的第N項;
令g(N) = g(N-1) + g(N-2), (N > 2),其中g(1) = g(2) = 1,同樣可以利用圖論的思想,從結點N向N-1和N-2分別引一條權值爲1的有向邊,每次求g(N)就是以N作爲起點,對N進行深度優先遍歷,然後將N-1和N-2回溯的結果相加作爲N結點的值,即g(N)。這裏會帶來一個問題,g(n)的計算需要用到g(n-1)和g(n-2),而g(n-1)的計算需要用到g(n-2)和g(n-3),所以我們發現g(n-2)被用到了兩次,而且每個結點都存在這個問題,這樣就使得整個算法的複雜度變成指數級了,爲了規避這個問題,下面會講到基於深搜的記憶化搜索。
c. 求N個數的全排列;
全排列的種數是N!,要求按照字典序輸出。這是最典型的深搜問題。我們可以把N個數兩兩建立無向邊(即任意兩個結點之間都有邊,也就是一個N個結點的完全圖),然後對每個點作爲起點,分別做一次深度優先遍歷,當所有點都已經標記時輸出當前的遍歷路徑,就是其中一個排列,這裏需要注意,回溯的時候需要將原先標記的點的標記取消,否則只能輸出一個排列。如果要按照字典序,則需要在遍歷的時候保證每次遍歷都是按照結點從小到大的方式進行遍歷的。
4) 高級應用
a. 枚舉:
數據範圍較小的的排列、組合的窮舉;
b. 容斥原理:
利用深搜計算一個公式,本質還是做枚舉;
c. 基於狀態壓縮的動態規劃:
一般解決棋盤擺放問題,k進製表示狀態,然後利用深搜進行狀態轉移;
d.記憶化搜索:
某個狀態已經被計算出來,就將它cache住,下次要用的時候不需要重新求,此所謂記憶化。下面會詳細講到記憶化搜索的應用範圍;
e.有向圖強連通分量:
經典的Tarjan算法;
求解2-sat問題的基礎;
f. 無向圖割邊割點和雙連通分量:
經典的Tarjan算法;
g. LCA:
最近公共祖先遞歸求解;
h.博弈:
利用深搜計算SG值;
i.二分圖最大匹配:
經典的匈牙利算法;
最小頂點覆蓋、最大獨立集、最小值支配集 向二分圖的轉化;
j.歐拉回路:
經典的圈套圈算法;
k. K短路:
依賴數據,數據不卡的話可以採用2分答案 + 深搜;也可以用廣搜 + A*
l. 線段樹
二分經典思想,配合深搜枚舉左右子樹;
m. 最大團
極大完全子圖的優化算法。
n. 最大流
EK算法求任意路徑中有涉及。
o. 樹形DP:
即樹形動態規劃,父結點的值由各個子結點計算得出。
2、基於DFS的記憶化搜索
1) 算法原理
上文中已經提到記憶化搜索,其實就是類似動態規劃的思想,每次將已經計算出來的狀態的值存儲到數組中,下次需要的時候直接讀數組中的值,避免重複計算。
來看個例子,如圖5所示,圖中的橙色小方塊就是傳說中的作者,他可以在一個N*M的棋盤上行走,但是隻有兩個方向,一個是向右,一個是向下(如綠色箭頭所示),棋盤上有很多的金礦,走到格子上就能取走那裏的金礦,每個格子的金礦數目不同(用藍色數字表示金礦的數量),問作者在這樣一個棋盤上最多可以拿到多少金礦。
圖5
我們用函數DFS(i, j)表示從(1, 1)到(i, j)可以取得金礦的最大值,那麼狀態轉移方程 DFS(i, j) = v[i][j] + max{ DFS(i, j-1), DFS(i-1, j) }(到達(i, j)這個點的金礦最大值的那條路徑要麼是上面過來的,要麼是左邊過來的),滿足遞歸性質就可以進行深度優先搜索了,於是遇到了和求斐波那契數列一樣的問題,DFS(i,
j)可能會被計算兩次,每個結點都被計算兩次的話複雜度就是指數級了。
所以這裏我們可以利用一個二維數組,令D[i][j] = DFS(i, j),初始化所有的D[i][j] = -1,表示尚未計算,每次搜索到(i, j)這個點時,檢查D[i][j]的值,如果爲-1,則進行計算,將計算結果賦值給D[i][j];否則直接返回D[i][j]的值。
記憶化搜索雖然叫搜索,實際上還是一個動態規劃問題,能夠記憶化搜索的一般都能用動態規劃求解,但是記憶化搜索的編碼更加直觀、易寫。
3、基於DFS的剪枝
1) 算法原理
搜索的過程可以看作是從樹根出發,遍歷一棵倒置的樹——搜索樹的過程。而剪枝,顧名思義,就是通過某種判斷,避免一些不必要的遍歷過程,形象的說,就是剪去了搜索樹中的某些“枝條”,故稱剪枝(原話取自1999年OI國家集訓隊論文《搜索方法中的剪枝優化》(齊鑫))。如圖6所示,它是一棵利用深度優先搜索遍歷的搜索樹,可行解(或最優解)位於黃色的葉子結點,那麼根結點的最左邊的子樹完全沒有必要搜索(因爲不可能出解)。如果我們在搜索的過程中能夠清楚地知道哪些子樹不可能出解,就沒必要往下搜索了,也就是將連接不可能出解的子樹的那根“枝條”剪掉,圖中紅色的叉對應的“枝條”都是可以剪掉的。
圖6
好的剪枝可以大大提升程序的運行效率,那麼問題來了,如何進行剪枝?我們先來看剪枝需要滿足什麼原則:
a. 正確性
剪掉的子樹中如果存在可行解(或最優解),那麼在其它的子樹中很可能搜不到解導致搜索失敗,所以剪枝的前提必須是要正確;
b. 準確性
剪枝要“準”。所謂“準”,就是要在保證在正確的前提下,儘可能多得剪枝。
c. 高效性
剪枝一般是通過一個函數來判斷當前搜索空間是否是一個合法空間,在每個結點都會調用到這個函數,所以這個函數的效率很重要。
剪枝大致可以分成兩類:可行性剪枝、最優性剪枝(上下界剪枝)。
2) 可行性剪枝
可行性剪枝一般是處理可行解的問題,如一個迷宮,問能否從起點到達目標點之類的。
舉個最簡單的例子,如圖7,問作者能否在正好第11秒的時候避過各種障礙物(圖中的東西一看就知道哪些是障礙物了,^_^)最終取得愛心,作者每秒能且只能移動一格,允許走重複的格子。
仔細分析可以發現,這是永遠不可能的,因爲作者無論怎麼走,都只能在第偶數秒的時候到達愛心的位置,這是他們的曼哈頓距離(兩點的XY座標差的絕對值之和)的奇偶性決定的,所以這裏我們可以在搜索的時候做奇偶性剪枝(可行性剪枝)。
類似的求可行解的問題還有很多,如N (N <= 25) 根長度不一的木棒,問能否選取其中幾根,拼出長度爲K的木棒,具體就是枚舉取木棒的過程,每根木棒都有取或不取兩種狀態,所以總的狀態數爲2^25,需要進行剪枝。用到的是剩餘和不可達剪枝(隨便取的名字,即當前S根木棒取了S1根後,剩下的N-S根木棒的總和
加上 之前取的S1根木棒總和如果小於K,那麼必然不滿足,沒必要繼續往下搜索),這個問題其實是個01揹包,當N比較大的時候就是動態規劃了。
3) 最優性剪枝(上下界剪枝)
最優性剪枝一般是處理最優解的問題。以求兩個狀態之間的最小步數爲例,搜索最小步數的過程:一般情況下,需要保存一個“當前最小步數”,這個最小步數就是當前解的一個下界d。在遍歷到搜索樹的葉子結點時,得到了一個新解,與保存的下界作比較,如果新解的步數更小,則令它成爲新的下界。搜索結束後,所保存的解就是最小步數。而當我們已經搜索了k歩,如果能夠通過某種方式估算出當前狀態到目標狀態的理論最少步數s時,就可以計算出起點到目標點的理論最小步數,即估價函數h
= k + s,那麼當前情況下存在最優解的必要條件是h < d,否則就可以剪枝了。最優性剪枝是不斷優化解空間的過程。
4、基於DFS的A*(迭代加深,IDA*)
1) 算法原理
迭代加深分兩步走:
1、枚舉深度。
2、根據限定的深度進行DFS,並且利用估價函數進行剪枝。
2) 算法實現
迭代加深寫成僞代碼如下:
1 def IDA_Star(STATE startState):
2 maxDepth = 0
3 while true:
4 if( DFS(startState, 0, maxDepth) ):
5 return
6 maxDepth = maxDepth + 1
圖8
3) 基礎應用
如圖8所示,一個“井”字形的玩具,上面有三種數字1、2、3,給出8種操作方式,A表示將第一個豎着的列循環上移一格,並且A和F是一個逆操作,B、C、D...的操作方式依此類推,初始狀態給定,目標狀態是中間8個數字相同。問最少的操作方式,並且要求給出操作的序列,步數一樣的時候選擇字典序最小的輸出。圖中的操作序列爲AC。
大致分析一下,一共24個格子,每個格子三種情況,所以最壞情況狀態總數爲3^24,但實際上,我們可以分三種情況討論,先確定中間的8個數字的值,假設爲1的話,2和3就可以看成是一樣的,於是狀態數變成了2^24。
對三種情況分別進行迭代加深搜索,令當前需要搜索的中間8個數字爲k,首先枚舉本次搜索的最大深度maxDepth(即需要的步數),從初始狀態進行狀態擴展,每次擴展8個結點,當搜索到深度爲depth的時候,那麼剩下可以移動的步數爲maxDepth - depth,我們發現每次移動,中間的8個格子最多多一個k,所以如果當前狀態下中間8個格子有sum個k,那麼需要的剩餘步數的理想最小值s
= 8 - sum,那麼估價函數:
h = depth + (8 - sum)
當h > maxDepth時,表明在當前這種狀態下,不可能在maxDepth歩以內達成目標,直接回溯。
當某個深度maxDepth至少有一個可行解時,整個算法也就結束了,可以設定一個標記,直接回溯到最上層,或者在DFS的返回值給定,對於某個搜索樹,只要該子樹下有解就返回1,否則返回0。
迭代加深適合深度不是很深,但是每次擴展的結點數很多的搜索問題。
二、廣度優先搜索
1、BFS
1) 算法原理
廣度優先搜索即Breadth First Search,也是圖遍歷算法的一種。用一句話概括就是:“我會分身我怕誰?!”。
BFS的具體算法描述爲選擇一個起始點v放入一個先進先出的隊列中,執行如下操作:
a. 如果隊列不爲空,彈出一個隊列首元素,記爲當前結點,執行b;否則算法結束;
b. 將與 當前結點 相鄰並且尚未被訪問的結點的信息進行更新,並且全部放入隊列中,繼續執行a;
維護廣搜的數據結構是隊列和HASH,隊列就是官方所說的open-close表,HASH主要是用來標記狀態的,比如某個狀態並不是一個整數,可能是一個字符串,就需要用字符串映射到一個整數,可以自己寫個散列HASH表,不建議用STL的map,效率奇低。
廣搜最基礎的應用是用來求圖的最短路。
如圖9所示,對以下圖進行廣度優先搜索,假設起點爲1,將它放入隊列後。那麼第一次從隊列中彈出的一定是1,將和1相鄰未被訪問的結點繼續按順序放入隊列中,分別是2、3、4、5、7,並且記錄下它們距離起點的距離dis[x] = dis[1] + 1 (x 屬於集合 {2, 3, 4, 5, 7});然後彈出的元素是2,和2相鄰未被訪問的結點是10,將它也放入隊列中,記錄dis[10]
= dis[2] + 1;然後彈出5,放入6(4由於已經被訪問過,所以不需要再放入隊列中);彈出7,放入8、9。隊列爲空後結束搜索,搜索完畢後,dis數組就記錄了起點1到各個點的最短距離;
2) 算法實現
廣搜一般用隊列維護狀態,寫成僞代碼如下:
def BFS(v):
resetArray(visited,false)
visited[v] = true
queue.push(v)
while not queue.empty():
v = queue.getfront_and_pop()
for u in adjcent_list[v]:
if visited[u] is false:
dosomething(u)
queue.push(u)
3) 基礎應用
a. 最短路:
bellman-ford最短路的優化算法SPFA,主體是利用BFS實現的。
絕大部分四向、八向迷宮的最短路問題。
b. 拓撲排序:
首先找入度爲0的點入隊,彈出元素執行“減度”操作,繼續將減完度後入度爲0的點入隊,循環操作,直到隊列爲空,經典BFS操作;
c. FloodFill:
經典洪水灌溉算法;
4) 高級應用
a. 差分約束:
數形結合的經典算法,利用SPFA來求解不等式組。
b. 穩定婚姻:
二分圖的穩定匹配問題,試問沒有穩定的婚姻,如何有心思學習算法,所以一定要學好BFS啊;
c. AC自動機:
字典樹 + KMP + BFS,在設定失敗指針的時候需要用到BFS。
d. 矩陣二分:
矩陣乘法的狀態轉移圖的構建可以採用BFS;
e. 基於k進制的狀態壓縮搜索:
這裏的k一般爲2的冪,狀態壓縮就是將原本多維的狀態壓縮到一個k進制的整數中,便於存儲在一個一維數組中,往往可以大大地節省空間,又由於k爲2的冪,所以狀態轉移可以採用位運算進行加速,HDU1813和HDU3278以及HDU3900都是很好的例子;
f. 其它:
還有好多,一時間想不起來了,佔坑;
2、基於BFS的A*
1) 算法原理
在搜索的時候,結點信息要用堆(優先隊列)維護大小,即能更快到達目標的結點優先彈出。
2) 基礎應用
a.八數碼問題
如圖10所示,一個3*3的棋盤,放置8個棋子,編號1-8,給定任意一個初始狀態,每次可以交換相鄰兩個棋子的位置,問最少經過多少次交換使棋盤有序。
圖10
遇到搜索問題一般都是先分析狀態,這題的狀態數可以這麼考慮:將數字1放在九個格子中的任意一個,那麼數字2有八種擺放方式,3有七種,依此類推;所以狀態總數爲9的排列數,即9!(9的階乘) = 362880。每個狀態可以映射到0到362880-1的一個整數,
對於廣搜來說這個狀態量不算大,但是也不小,如果遇到無解的情況,就會把所有狀態搜遍,所以這裏必須先將無解的情況進行特判,採用的是曼哈頓距離和逆序數進行剪枝,具體參見 SGU 139的解法:
網上對A*的描述寫的都很複雜,我嘗試用我的理解簡單描述一下,首先還是從公式入手:
f(state) = g(state) + h(state)
g(state) 表示從初始狀態 到 state 的實際行走步數,這個是通過BFS進行實時記錄的,是一個已知量;
h(state) 表示從 state 到 目標狀態 的期望步數,這個是一個估計值,不能準確得到,只能通過一些方法估計出一個值,並不準確;
f(state) 表示從 初始狀態 到 目標狀態 的期望步數,這個沒什麼好說的,就是前兩個數相加得到,也肯定是個估計值;
對於廣搜的狀態,我們是用隊列來維護的,所以state都是存在於隊列中的,我們希望隊列中狀態的f(state)值是單調不降的(這樣才能儘量早得搜到一個解),g(state)可以在狀態擴展的時候由當前狀態的父狀態pstate的g(pstate)+1得到;那麼問題就在於h(state),用什麼來作爲state的期望步數,這個對於每個問題都是不一樣的,在八數碼問題中,我們可以這樣想:
這個棋盤上每個有數字的格子都住了一位老爺爺 (-_-|||),每位老爺爺都想回家,老爺爺的家就對應了目標狀態每個數字所在的位置,對於 i 號老爺爺,他要回家的話至少要走的路程爲當前狀態state它在的格子pos[i]
和 目標狀態他的家target[i] 的曼哈頓距離。每位老爺爺都要回家,所以最少的回家距離就是所有的這些曼哈頓距離之和,這就是我們在state狀態要到達目標狀態的期望步數h(state),不理解請回到兩行前再讀一遍或者看下面的公式。
h(state) = sum( abs(pos[i].x - (i-1)/3) + abs(pos[i].y - (i-1)%3) ) (其中 1 <= i <= 8, 0 <= pos[i].x, pos[i].y < 3 )
b.K短路問題
求初始結點到目標結點的第K短路,當K=1時,即最短路問題,K=2時,則爲次短路問題,當K >= 3時需要A*求解。
還是一個h(state)函數,這裏可以採用state到目標結點的最短距離爲期望距離;
3、雙向廣搜
1) 算法原理
初始狀態 和 目標狀態 都知道,求初始狀態到目標狀態的最短距離;
利用兩個隊列,初始化時初始狀態在1號隊列裏,目標狀態在2號隊列裏,並且記錄這兩個狀態的層次都爲0,然後分別執行如下操作:
a.若1號隊列已空,則結束搜索,否則從1號隊列逐個彈出層次爲K(K >= 0)的狀態;
i. 如果該狀態在2號隊列擴展狀態時已經擴展到過,那麼最短距離爲兩個隊列擴展狀態的層次加和,結束搜索;
ii. 否則和BFS一樣擴展狀態,放入1號隊列,直到隊列首元素的層次爲K+1時執行b;
b.若2號隊列已空,則結束搜索,否則從2號隊列逐個彈出層次爲K(K >= 0)的狀態;
i. 如果該狀態在1號隊列擴展狀態時已經擴展到過,那麼最短距離爲兩個隊列擴展狀態的層次加和,結束搜索;
ii. 否則和BFS一樣擴展狀態,放入2號隊列,直到隊列首元素的層次爲K+1時執行a;
如圖11,S表示初始狀態,T表示目標狀態,紅色路徑連接的點爲S擴展出來的,藍色路徑連接的點爲T擴展出來的,當S擴展到第三層的時候發現有一個結點已經在T擴展出來的集合中,於是搜索結束,最短距離等於3 + 2 = 5;
雙廣的思想很簡單,自己寫上一兩個基本上就能總結出固定套路了,和BFS一樣屬於盲搜。
三、搜索題集整理
2、IDA* (確定是迭代加深後就一個套路,枚舉深度,然後 暴力搜索+強剪枝)
3、BFS
Puzzle
★★★★★ 幾乎嘗試了所有的搜索 -_-||| 讓人慾仙欲死的題
4、雙向BFS(適用於起始狀態都給定的問題,一般一眼就能看出來,固定套路,很難有好的剪枝)