算法筆記(III) 狀態空間搜索

動態規劃

在一般的算法書中,動態規劃總是一個複雜的算法設計技巧。在幾種普遍涉及的算法設計技巧:窮舉貪心回溯分治動態規劃中,動態規劃往往是最複雜技巧性最強的一個技巧,但是常常最有效的算法,甚至在一些看似簡單的算法中,都蘊含着動態規劃的深刻思想,例如最短路徑的floyd算法。

理解動態規劃往往需要過程,既要閱讀書本理解最優子問題結構馬爾可夫性這兩個理論要點,同時也要編寫程序,理解動態規劃的實現要點。疏於理論往往不能舉一反三,如果只關心理論,疏於實踐,難免流於表面,另外,算法的學習和運籌學範疇內的動態規劃的學習是不一樣的,真正用程序寫出來,纔是真的領會。

對於上述的兩個理論要點不再贅述,參看wikipedia即可;關於如何實現動態規劃則有必要在此歸納一番: 

遞歸(+記憶化)與遞推

同編譯原理的語法分析方法[改作: 語義分析中的屬性計算]一樣,基於DAG有向無環圖)的算法往往有兩種實現方法,即自頂向下自底向上,也可以稱之爲:遞歸與遞推。這跟計算機問題的結構相關,因爲對於很多問題可以分解爲子問題,通過解答子問題繼而獲得原問題的答案。但是相對於分治法(D&C)來說,分解的子問題往往overlap,不像D&C往往是孤立的子問題(甚至有些分治法僅僅將大規模的問題轉化成一個小規模的子問題,如此我們稱之爲decrease and conquer);而且除了理論上需要的兩個要素,動態規劃之所以有效就是因爲子問題的重複性。重複性越高,性能越好。

上述,我們談到動態規劃的兩種主要的實現形式:遞歸與遞推;對於遞歸來說,其往往易於從問題的開始狀態推向終止狀態,就像遍歷樹一樣,遵循自頂向下的方式遍歷樹結構(當然說法並不嚴謹,因爲畢竟因爲子問題重複,所以並不是樹結構),同時遞歸實現必須與記憶化結合起來,也就是一個表結構,我們首先判別當前的狀態是不是我們已經計算出結果了,如果已經遍歷過這個狀態節點了,我們顯然不需要在遍歷了,因此我們的表設計可以方便的而迅速的訪問狀態的結果(例如採用二進制位表示集合的狀態)。上述思路是動態規劃的樸素實現,即遞歸與記憶化;這種樸素的實現結構很方便實現以及理解,甚至我們還可以加入剪枝等操作。

其僞代碼可以寫作: dp(n) = opr(n) + dp(n-1); 即狀態定義是到達當前狀態後,還可以獲得值(至於這個值是多少並不需要我們知道,我們只需明白遞歸回來就知道了)

遞推對應的是一種自底向上的思路,也就是說我們先計算子問題,再將這些子問題逐步的組合成更大的問題,直到回到原問題;從狀態的角度來理解就是從問題的終止狀態推向起始狀態。遞推方案相對於遞歸方案節省了存儲空間(例如只選擇有用的變量進行存儲)以及遞歸函數調用的開銷,但是不好構造,思路沒有遞歸方案好理解,同時,對於有些問題終止狀態往往是很難確定的,就像一棵樹的分支很多,錯綜複雜。就像遍歷樹一樣:從上向下走容易,而從繁雜的葉子節點一路通過複雜的節點關聯,走向根節點就難了。遞推方案的狀態定義往往是到達當前狀態已經獲得了多少,當然這並不絕對,對於揹包問題,兩種狀態定義均可以用遞推方案實現,遞歸也是一樣。

自頂向下與自底向上

說到這裏,我們簡單的提一提:自頂向下以及自底向上這兩個名詞。在設計學上,這是兩個重要的設計思路,多見於各項領域工程設計中(自然包括軟件工程學[參見 unix 設計藝術 與 代碼大全2]),對於自頂向下來說,我將大系統分作各個實現獨立功能的子模塊,至於怎樣實現,我並不關心;設計者一直重複這個過程直到模塊的粒度已經夠方便的實現了。這就像很多書的講解結構一樣(特別見於一些深入淺出的國外教材),首先就告訴你這本書要讓你明白一件什麼事,要明白這個事需要做那幾個方面的準備,繼而劃分章節。這是一種自頂向下的講授方式,是一種見林的方式,全局到局部的過程。這種方式比較容易讓大部人接受。 

反之,對於一些設計,我們的方案是自底向上,用繁雜的小零件組合成一個大的組件。這種方式常見於CAD等等,這種方式就像遞推方案一樣高效,但是特別依賴於設計者的豐富經驗。因此,在實踐中這兩種設計方法往往混合使用。 

動態規劃與拓撲序 附記 2011-4-9:

上面一段關於遞推方案的解釋,其實有點問題,我們說並不是因爲從目的狀態推向起始狀態難,或者說從葉子節點推到根難。本質上是因爲劃分階段的困難。因爲對於每一層需要等到所有的前面的狀態都退出來了,才能更新當前一層。這就暗涵和一種序關係,這種序關係在遞歸方案中是隱式的,也就是拓撲序(DFS的順序),因此,爲了遞推的順利進行,而不會出現前面的一層還沒有推完,就開始推下一層,我們必須要按着拓撲序[參見wiki]進行搜索,有了拓撲序就可以保證算法的最優性,我想這一點是很清楚的。通俗的說,拓撲序的優美就是幫我們理清節點或者對象狀態之間的複雜耦合關係。

顯然,遞歸+記憶化的方案在一些階段性不明顯,階段之間關係複雜的情況中符合人的直覺思考,而在階段性很明顯的問題上,遞推是非常的顯然,做起來就非常的方便,例如數塔。

另外,階段的表示也有很多的技巧,比如節點判別是否重複,可以採用hash並加一個驗證過程,完成。狀態的表示,可以採用二進制位表示子集方案,但是顯然位不可以很高,例如int類型是32位(long long 是 64位),可以表示元素爲32的集合的冪集,即規模是2^32,但是顯然實際上這是不可能的,計算機的內存開這麼大,而且狀態過多,DP的效果很差。通常據lda說,超過20的位就不考慮這個方案了。

貪心算法與動態規劃  附記於2011-3-20:

CLRS中,作者很有意思的提到,在關於最優子問題結構中,貪心法和動態規劃的本質差異在於,貪心法是一種自頂向下利用最優子問題結構,即選擇了當前看似最優的選擇後,則然後求解其因爲這次選擇而形成的子問題。而動態規劃是自底向上的利用這個最優子問題結構,即把所有子問題的結果做完之後,在作出選擇。 顯然,動態規劃的方案是一定可以求出最優解的,而貪心法則不然。 不過要提到的是,我們上面說到 動態規劃有兩種實現方式:自頂向下(遞歸)和自底向上(遞推),這和其利用子問題的結構的方式是矛盾的。 無論是遞歸也好和遞推也好,其本質確實是 求完子問題後才做的選擇(自底向上的方式),只是有時候自頂向下的方案符合思維方式一點。 這裏補充一下,看了這麼多的數據結構和算法書,還是覺得CLRS最好,果然不是蓋得。如果你不喜歡CLRS,顯然你還沒有領會其中的優點:講解透徹,詳細點到,原理清晰等等。  

動態規劃與Dijkstra

讓我們回到動態規劃的實現上,除了遞歸和遞推外,還有一種漸進方法實現,我們稱之爲Reaching方法,例如Dijkstra算法:儘管在通常的書中,我們並不講其作爲動態規劃的實例來講解,而是作爲貪心算法。因爲,事實上Dijkstra算法並沒有遍歷樹,比如我們設想對於最短路徑問題,有一個節點介於終點與起點間,但是它離起點或終點的一方的距離非常的遠,因此在實現Dijkstra的時候,顯然我們不會把這個點並進我們的狀態集合中去,我們不會做一個很笨的決策走了這個長路的後果是怎樣(直到當前的距離都很遠,有必要擴展爲止),但是對於動態規劃的另兩個實現來說,他們是會擴展這個節點的,因爲每一個決策他們都會去嘗試的(這也許就是爲什麼Dijkstra算法爲什麼高效的原因了)。但是從動態規劃的角度來看,其也是一種動態規劃實現方法。

 

A*算法:一種新方法  2011-4-8

另外,如A*一類啓發式方法相對於Dijkstra又有了優勢,這一點主要體現於引入了啓發性信息,我們不妨取一個極端的例子,假設你使用Dijkstra算法去尋找最短路徑,假設有一段有很多節點連接起來的路,這條由很多很短(至少比其他的邊都短)的邊構成的路徑確是導向偏離目的地的,如果使用Dijkstra算法,我們將不斷的將這些節點併入集合中去,而事實上,現在所擴展的這條路徑確實離目的地更遠的。顯然,Dijkstra算法在這種情況下,並不反映我們直覺上的最短路徑尋找,我們其實會在擴展點的時候,考慮是不是離我們的目的地近了,即使一個點離我們很近,但確實背道而馳的,我們當然不會選擇它。所以,A*算法出現了,A*的啓發式意義就在於它幫我們估計出到底是那個點更可能離我們的目的地更近了,至少保證是向着目的地方向去走的。在最短路徑問題上一個優良選擇就是 節點離目的地的歐式距離,這顯然也符合我們實際生活中的常識。

同時,爲什麼A*算法要求我們要給出一個當前點離目的地實際距離的下界而不是上界呢?

  • 首先這是一種直覺和經驗,從人的角度來講,給出一個下界要比上界更容易,也更符合直覺。例如,當我們再實際生活中間,總是約莫着一個直線距離向着目的地去搜索路徑以及選擇路徑,這個約莫一般是 直線距離(即歐氏距離),我們常常說,望着近了,這就是一種直線距離。當然這種約莫着,也就是啓發式信息並不可靠,實際生活中,我們會發現,看着近了,走過去卻有一堵牆。也就是說,我們往往低估了這個距離或者代價。
  • 其次,常識之下是一種正確的思路。試想,如果我們估計一個上界,或者總是悲觀的估計距離,顯然,我們很可能失去真正的最優解,因爲一旦一個並不好的路徑的悲觀估計值稍稍比真正的最短路徑的估計值要大的話,我們就會失去這個選擇。例如,我們還舉極端例子,當一段河隔開我們和目的地時,事實上這條河是可以乘船,甚至趟過去的,只要我們走到河岸(或者碼頭)這個節點上就可以看到實際上過河是這麼容易,但是我們悲觀的預測沒有船或者河很深,我們就不會走向河岸(或者碼頭)這個節點上。也就是說實際上,我們只需要走過這條河寬的長度就可以到達終點了。因爲我們的不自信,認爲這是不可能過去的,也就是無窮遠的距離。於是,我們繞道而行,顯然這就不再是最短路徑了。那怎樣才能保證我們一定能發現最短路徑呢?那就是估計距離的下界,一條河算什麼,也是坦途。這樣我們的估計值就與實際的距離:河寬一致了。我保證一定會搜索到最優路徑的(河這個例子只是一個說明,其他的問題也是一致的)。總之,上述的說明,就是想說明,樂觀的估計一個下界其實是爲了保證不失去最優解。

不,我們仍還有疑問:估計值既然是不準確的,假設一條路徑A估計的下界低於另一路徑B的下界,而實際的長度是B要比A小,看來我們一定會選擇A,這樣我們不是漏掉了最優路徑麼? 不會,因爲對於那些估計值確實不準確,遠比實際值小的情況,即使一時得到了我們的青睞,終會有一天暴露他的實際距離。原形畢露,就是這個意思。上面所說的“牆”就是這樣一個極端的例子。只要我們對最優路徑的估計是樂觀的,我們最終會認清真相,終修成正果的。並且,如何我們的啓發式信息取得好,我們因選擇那些期望的選擇,要更比dijkstra快的搜索成功,相對於DFS、BFS以及DP,顯然太愚蠢了,他們會每一個策略都試一試,這個顯然更低效。回顧,A*算法的歷史,其就是爲了提升Dijkstra算法效率而提出的。

上面就是A*思想的通俗演繹了,其實A*本身就來自人工智能,迴歸本源去講解也是自然。

現在,A*算法的概況已經介紹了,其條件啓發式函數條件也介紹了,也就是 h < or = h*; 其中h* 表示真實值,h表示我們給出的啓發函數;還需要注意的 我們的g函數本質上也是更迭的(而且只可能越來越小),這一點與Dijkstra算法一致,也就是實質上有一個g*函數的概念,表示實際從起點到當前點的最優開銷,我們使用g函數進行逼近這個最優開銷g*[參考A*算法講義],直至當前點被擴展,我們猜得到真正的g*值,也就是說 g > or = g*;  

A*算法的一致性 

A*算法的h函數設計有兩個性質要考慮:可容納性一致性;可容納性,即h<=h*, 可以保證A*算法的最優性,即我們不會錯過最優解;而對於一致性,hi <= hj + dij,其中一致性已經暗涵了可容納性,證明很簡單,只需要考慮目標節點的啓發函數值爲0即可 ;則可以保證一旦一個節點被擴展,就不需再對其進行更新了,這一點與Dijkstra算法類似;一致性在A*原論文中認爲是最優性的必需條件(可能是受到歐氏距離自然的三角不等式的直覺影響),但是在其後給出的更正中說明其不是需要的。也就是說 可容納性就可以保證了最優性。一致性之所以重要,是用於帶有close set 的A*框架中,減少節點過多的更新(一旦放入close集合,即被擴展後無須在行更新)。


  A* 算法的框架(close set方案):
  
Best_First_Search() {
     OPEN = [起始節點]; CLOSED = [];
     while ( OPEN表非空 ) 
     {
          從OPEN表中彈出一個節點X。
          if (X是目標節點)
          {
               求得最優解;返回最優路徑PATH;
          }
          foreach (X的每一個後繼節點Y)
          {
               if( Y不在OPEN表或CLOSE表中 )
               {
                    求Y的估價值;並將Y插入OPEN表中;
               }
               else {
                    if( Y在OPEN表中 )
                    {
                         if( Y的估價值小於OPEN表中原來的估價值 )
                              更新OPEN表中的估價值;
                    }
                    else //Y在CLOSE表中
                    {
                         if( Y的估價值小於CLOSE表中原來的估價值 ) {// 
注意這一步,對於那些不滿足一致性的h函數,是必須的。
                              更新CLOSE表中的估價值;
                              從CLOSE表中移出節點,並放入OPEN表中;
                         }
                    }          
               }//end else
               對OPEN表排序
          }//end foreach
          將X節點插入CLOSE表中;
     }//end while
}//end func

                                                                ”

  • 如果沒有一致性條件,我們將必須對close set 中的點進行更新,否則會失去最優性,如下圖是一個例子:

                                                                圖片
  • 假若 我們對B的啓發式估值是 hB = 2; 而A的估值爲hA=5的話;即hA > hB + AB,違反一致性;  
  • 運行A*: 首先B點將被擴展(因爲 fB = hB + ),繼而,其發現目的地還是很遠,然後就去擴展A,
  • 當A擴展是後,發現其離目的點更遠,還沒有B近;
  • 這是我們有兩種做法:
  • 1. 假如我們去不再更新B,顯然方案出來了,答案就是從起點走到B在走到目的地:3+10=13,顯然這是錯的。最佳路徑是 經A到B,在到目的地;
  • 2. 再次更新已經擴展了的B點,將其離起點的距離3改成2,並將其狀態改成非擴展狀態,於是在下一步再次擴展了B(因爲fB=hB+2 = 4),
  • 然後再一次就會將目的地擴展進來,此次是最優解了:12;

     事實上,不滿足三角不等式的h函數在實際應用中並不常見(也就是說我們近乎於捏造一個這樣的滿足可容納性而不滿足一致性的反例),參見《人工智能:一種現代方法(中文版)》p80,另翻譯不佳,可見英文版p99。

    狀態空間:搜索算法的統一

    上述關於貪心算法的講述,其實可以統一成 f = g + h 的A*算法形式,也就是說,我們可以將BFS,DFS ,貪心算法,Dijkstra 統一成A*算法的特例。首先這四種算法都是盲目搜索(也就是不利用啓發式信息的搜索)[參考演算法筆記以及wikipedia];注意,我們在數據結構層次上對BFS、DFS、Dijkstra以及分支定界搜索,進行了統一,即在搜索時(或者說遍歷搜索樹)維護特定的數據結構,隊列,棧,優先隊列等而構成不同的搜索方式,這次搜索算法的統一的高度更高一層,即人工智能的角度

    • BFS:不考慮啓發式函數h,並且將節點的深度作爲函數g,顯然其會每次優先擴展那個層數最少的節點,也就是寬度優先;
    • DFS:其也是A*的特例,其只採用h函數作爲指導。首先,它有一個很大全局變量作爲計數器,我們設爲C;每一次擴展節點,我們就爲新擴展的節點分配C值,並將C減去1,於是算法總是青睞於那些C值小的節點,也就是更深的節點;例如我們取C爲100,訪問一個樹:
    • root — 100
    •                 — 99
    •                      — 98
    •                          ........
    •         — 100
    •         — 100
    • 貪心算法:其考慮的是,從上一個已擴展節點(而不是從目的點至當前點的距離)到當前點的長度(也就是開銷)作爲g函數,而不考慮h函數;
    • Dijkstra:其是隻考慮了g函數的A*算法,h函數爲0(而不考慮啓發式信息的h函數),但是這個g函數與貪心不同,其是從目的點出發到當前點的開銷。注意一下的是,儘管我們看Dijkstra的算法每次擴展的似乎是以一種貪心的方法以離上一層擴展點到當前點的開銷去選擇下一個擴展點的,但是由於最優子問題結構,其實是與從起點到當前點的開銷:即我們常說的,一旦這個點被擴展,其到起點的最優路徑已經確定了。同時注意,在A*中的g函數也類似於Dijkstra的g函數,其可能只是一個對從起點到當前點的開銷“估計”,回想在Dijkstra算法中,對於只是被finger,而並沒有被擴展的節點,其值是有可能被反覆更新的,直至被擴展,其才真正確定了開銷(最佳路徑)

    上述搜索算法,在狀態空間概念框架下得到了統一,參見《人工智能:一種現代方法》;其還包含以上算法的擴展版本,如分支定界(+剪枝),迭代加深,迭代加深A*,雙向廣度優先 等等算法[參見wiki];(注:動態規劃其實是一種算法設計思想,其實現方式可以DFS以及BFS等多樣,不宜歸入搜索一類)

     

    rongekuta

    2011-4-9

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