状态空间搜索
http://www.lencomputer.com/xk2008/lesson19/search_algorithm.htm
狀態空間搜索是程序設計中的最基本方法之一。它通過在狀態空間中的初始狀態出發,按照一定的順序和條件對空間中的狀態進行遍歷,最終找到目標狀態。一般的狀態空間搜索方法有枚舉、深度/廣度優先搜索、啟發式搜索等等,由于枚舉法相對比較易懂,這里不再加以介紹;同時介于篇幅的限制,我們在這一講也不打算單獨啟發式搜索。于是,本講的主要內容就是介紹深度/廣度優先搜索以及一些常見的優化技巧。
第一部分 深度優先搜索(Depth-first Search)
大多數程序員最先學習的搜索方法恐怕就是深度優先搜索(DFS)了,它那直接的思考方式很容易被人們所理解并接受。基于堆棧技術的回溯法就是DFS的一個很成功的應用,而我們所熟悉的“八皇后問題”便是回溯法中最為經典的問題。來看一看DFS是如何在狀態空間中搜索的吧:
在圖1-1的狀態空間圖中,如果我們選擇每次先擴展最左邊的結點,再擴展右邊的結點,那么我們的搜索順序就是1-> 2 -> 4 -> 8 -> 9 -> 5 -> 10 -> 11 -> 3 -> 6 -> 12 -> 13 -> 7 -> 14 -> 15。
DFS回溯法的遞歸實現框架大致如下:
void Dfs(int a) {//設置出口for(循環枚舉可能值){//修改一些參量值。Dfs(a + 1);//恢復之前修改的參量值。} }1. 普通深度優先搜索
與廣度優先搜索(BFS)相比,DFS的一個最大好處就是它所需要的空間開銷非常小,因為它只需要記錄當前的狀態即可。于是,在處理需要遍歷大量結點的問題時,DFS就體現出了它在空間復雜度方面的優勢。當然,在另一些問題,比如處理形如“目標結點較淺”類型的問題時,相對DFS來說,BFS的時間優勢就會相當明顯。
來看一個DFS的例子吧:
[例 1-1, POJ 1167] 公交車問題
一男子于12:00來到一個公交車站,他記錄下了12:00-12:59所有公交車的到站情況。現在我們假設:
1) 同一條線路的公交車到站是有規律的,也就是每隔一個固定時間就會有一輛車到站。
2) 公交車的到站是以分鐘為最小計量單位的。
3) 每一條線路的車至少到站兩次。
4) 經過該車站的公交車線路≤17條。
5) 不同線路的公交車可以同時到達該車站。
6) 不同線路的公交車到該車站的時刻和時間間隔均可以相同。
現在問,根據該男子的記錄,最少有多少條公交車線路經過該車站?
[分析]
此題是明顯的搜索題,且DFS較為合適。由于每條線路只需要第一和第二輛車到達的時刻,就可以完全確定,因此我們可以依次考慮12:00-12:59的車輛到達情況。假設某一個時刻有一輛車到達本站,那么它有兩種可能:
1) 它是一條新線路的第一輛車;2) 它是已有某條線路的第二輛車。
如果是情況1),那么我們需將它作一下標記。如果是情況2),那么我們就枚舉該線路的第一輛車在何時刻到達,有了此時刻,那么就可以完全確定這條線路,隨后我們可以把這一條線路從記錄中刪除。再根據題干中提到的那么約束條件,我們就可以寫出程序了。
2. 迭代加深搜索(Iterative Deepening Search)
盡管DFS對空間復雜度的要求很低,但它也有著不少問題。其中很突出的一點就是,DFS在處理一些“目標結點較淺”的問題時,往往效率不夠高。比如圖1-1中,如果目標狀態為3結點,那么它將在第9步才找到。假如這棵狀態樹更深一些,或者它是無限長的,那么DFS就很有可能在有限時間內找不到解。而事實上我們看到,目標狀態3其實離初始狀態1非常接近。
因此這種情況下,我們需要對DFS進行一定的改進。最直接的想法就是,為它設定一個深度限制d,使得我們只在深度d范圍內搜索。這樣如果目標結點在該范圍內,那么就很輕易的找到了解。而只要我們讓d從1開始逐漸遞增,那么就可以在有限步內找到目標結點。這就是迭代加深搜索思想(IDS)(圖1-2)。
在IDS方法中,很多結點都被重復搜索了多次。這對它的效率有一定的影響。分析一下,假設每個結點的子結點個數為b,那么可以看到,每加深一層(由第i層增加到第i+1層),新擴展出的結點數為bi+1,而之前的結點數為1+b+b2+…+bi=bi+1-1,差不多有一半結點被重復了。不過盡管如此,可以看到它的時間復雜度比DFS只是增加了常數倍,從數量級上來看是一樣的。
第二部分 廣度優先搜索(Breadth-first Search)
廣度優先搜索(BFS)也是最為常用的搜索方法之一。與DFS相比,BFS最大的不同是它的搜索順序是按照逐層擴展的,對于圖1-1來說,BFS依次擴展出的結點為1->2->3->…->15,因此它所用來存儲的數據結構是隊列而不是堆棧。BFS的實現框架大致如下:
void BFS() {while(隊列可擴展且尚未找到目標狀態){//從隊首依次取出隊列中未擴展的結點進行擴展,并將新結點加入隊尾。} }1. 普通廣度優先搜索
對于求“最短步數”一類的問題,一般BFS會比DFS快一些。原因就在于DFS經常會糾纏于那些找不到目標結點的分支中。來看一看下面這個經典的例子:
[例2-1, POJ 1077] 八數碼問題
給定一個3*3的格子,里面已經填上了數字1-8以及一個空格,問是否能夠通過空格移動數字,使得最終能得到如下圖案:
[分析]
如果盲目移動數字,那么很容易就會出現重復的情況。因此我們需要判重。那么如何記錄一個圖案狀態呢?如果我們將圖上的9個格子中的數(空格看成9)線性地連起來,那么這樣一個排列就能唯一地確定一個圖。接下來我們只需給出一個排列的序號即可:
對于一個序列:a1a2…a9,可以依次考慮數字ai(1≤i≤9)后有幾個數字小于它,設ai后有bi個數比它小,那么ai貢獻的權值為bi*(9-i)!,最后計算 ,就得到了它的序號。這個方法很直接也好理解,就不再解釋了。由于9個數的排列有9!種方法,因此我們需要大小為9!的數組來判重。
接下來就是用什么搜索方法的問題了。可以看到,雖然題目里沒有明確說求最短的移動方法,但是相對來說,如果我們能找到最短的移動方法,那么自然算法的速度相對會快一些。所以這里我們可以使用BFS(當然帶判重DFS也是可以的)來解題。隊列的初始狀態就是初始圖案上數字的線性排列,比如圖2-1(a)中就是973851624,我們需要得到的目標狀態是123456789。通過BFS的基本框架,實現算法應該是較為容易的。
這里給出一個小技巧,如果題目是多Case的,那么可以作預處理,即把初始狀態設為123456789,用一遍BFS算出所有可達狀態是如何到達的,然后根據給定的輸入數據,將原先的移動過程顛倒,就得到我們所需要的答案。這樣多個Case只需一遍BFS,而不是多遍BFS就能解決了。
2. 雙向廣度優先搜索
對于一棵搜索狀態樹,如果每個結點的子結點數目很多,那么狀態數每加深一層,就會擴展出指數倍的狀態樹。因此,我們希望能對狀態數目做適當的控制。雙向廣搜就是這樣一種方法,假如我們知道初始狀態S和目標狀態T,也知道由初始到目標最多需要經過的步數K,那么我們可以這樣做:
先用BFS對S擴展出深度≤[k/2]的所有結點,假如沒有找到T,那么再對T用BFS反向擴展出深度≤k-[k/2]的所有結點,這些結點中必然與之前的某些結點有重復,考察這些結點,我們就能夠找到一條最短路徑。用圖表示如下:
可以看出,雙向廣搜的最大好處就是可以少擴展出指數倍的無用結點,為搜索節省了大量的空間和時間。
第三部分 搜索的優化技巧
1.改變搜索順序
搜索順序的確立對于搜索的效率是很重要的,如果我們每次都選擇先擴展更接近目標狀態的結點,那么就會更快的找到最優解,并為剪枝提供依據。因此,搜索順序的改變往能起到意想不到的效果。事實上搜索順序的不同也是BFS和DFS之間最大的區別之一。這一節我們沒有對A*算法做介紹,事實上,A*算法就是一種典型的通過調整搜索順序來更快找到解的方法。它利用一個評價函數來決定子結點的優先級,然后按優先級從高到低的順序依次進行遍歷。這顯然比盲目搜索法要好得多。
2.增加剪枝策略
剪枝是搜索中最基本,也是最重要的技巧之一。有時一條好的剪枝可以去掉一棵搜索狀態樹中大部分的無用結點。剪枝策略的好壞,決定了搜索法的最終效率。有時,一個問題可以采用幾種不同的搜索方法,這時,我們就會選擇其中具有最好剪枝手段的那個方法。用一個例子來的剪枝技巧吧:
例3,POJ 1011] 合并木棒
我們有n(n≤64)根小木棍,每根長度均不大于50。現在需要將它們拼接成長度相同的若干長木棍,使得這些長木棍最短。
[分析]
此題也是很明顯的搜索題。最直接的思路就是從小到大枚舉每個拼成后的木棍長(d),之所以要從小到大,是因為按題意,最小的長度才是我們要找的解,因此,按從小到大的順序找到的第一個滿足題意的長度d就是該問題的解。假設所有木棍總長為t,那么一共就要拼d/t根木棍。隨后我們用小木棍將這d/t根木棍一根一根地拼完即可。當然,由于數據量不算小,如果直接做很難在規定時間內出解,故我們需要有一些好的剪枝技巧:
1) 讓我們先對木棍長從大到小排個序,之所以從大到小,是因為直觀上,先拼上長木棍,接下來用短木棍補充似乎更容易成功拼完。如果先費了一番功夫拼短木棍,最后發現接上剩下的任何一根長木棍都已超過當前枚舉的長度d,那么就浪費了之前的搜索。
2)枚舉的長度d也有要求,首先d應該不小于最長的小木棍長度,不大于木棍總長t。其次d必須是t的約數。這個剪枝很容易想到,也對減小搜索量非常有幫助。
接下來就可以進行搜索了,可以用一個函數
bool solve(int rest, int start, int step)進行DFS,其中rest表示正在拼的當前木棍還需要多少長度,start表示當前可以從第幾根開始拼,Step表示當前還剩下多少根小木棍。
搜索中同樣可以找到好些剪枝策略。
3) 我們從start到n枚舉小木棍編號i,如果此時rest=len(i),那么顯然把這根木棍拼上是最好的選擇了,也不需要枚舉i+1…n號木棍了。
4) 如果len(i)…len(j)都具有相同的長度,那么試探完i后應該跳過這些相同長度的小木棍,直接枚舉第(j+1)號小木棍。
5) 如果當前挑選的小木棍是目前正在拼的木棍的首根木棍,即rest == d, 那么試完i后我們沒有必要再去試i+1,而是直接跳回遞歸上一層。因為對于一個合理的分配,小木棍i一定屬于某一根待拼木棍,而當前木棍還沒有開始拼,既然是這樣,那么就可以認為它屬于當前木棍。如果拼上它后,沒有能夠形成滿足條件的解。那么也就沒有必要繼續嘗試了,因為在上一層的木棍分配上肯定已經出現了錯誤。
可以看出,以上說的這些剪枝條件很多都不能直接能想到,需要仔細分析搜索過程后才能得出。剪枝策略是多種多樣的,不同的問題對應有的不同策略。同時,由于每次都需要一些適當的判斷,剪枝也會相應地付出一些代價。有些剪枝的“成本”甚至超過了它所帶來的益處,這顯然是不可取的。那些能夠補償策略本身所花費代價的剪枝,才能稱得上是好的剪枝。
總結
- 上一篇: 最新29刷网课平台系统源码+带教程
- 下一篇: ios 边录音边放_iOS 录音、音频的