動態規劃用於解決重疊子問題的示例(Python版)

atupal發表於2014-05-16

【編注】:動態規劃(Dynamic programming)是一種在數學電腦科學經濟學中使用的,通過把原問題分解為相對簡單的子問題的方式求解複雜問題的方法。 動態規劃常常適用於有重疊子問題最優子結構性質的問題,動態規劃方法所耗時間往往遠少於樸素解法。

動態規劃背後的基本思想非常簡單。大致上,若要解一個給定問題,我們需要解其不同部分(即子問題),再合併子問題的解以得出原問題的解。 通常許多子問題非常相似,為此動態規劃法試圖僅僅解決每個子問題一次,從而減少計算量: 一旦某個給定子問題的解已經算出,則將其記憶化儲存,以便下次需要同一個子問題解之時直接查表。 這種做法在重複子問題的數目關於輸入的規模呈指數增長時特別有用。—— 維基百科

動態規劃是一種用來解決定義了一個狀態空間的問題的演算法策略。這些問題可分解為新的子問題,子問題有自己的引數。為了解決它們,我們必須搜尋這個狀態空間並且在每一步作決策時進行求值。得益於這類問題會有大量相同的狀態的這個事實,這種技術不會在解決重疊的子問題上浪費時間。

正如我們看到的,它也會導致大量地使用遞迴,這通常會很有趣。

為了說明這種演算法策略,我會用一個很好玩的問題來作為例子,這個問題是我最近參加的 一個程式設計競賽中的 Tuenti Challenge #4 中的第 14 個挑戰問題。

Train Empire

我們面對的是一個叫 Train Empire 的棋盤遊戲(Board Game)。在這個問題中,你必須為火車規劃出一條最高效的路線來運輸在每個火車站的貨車。規則很簡單:

  • 每個車站都有一個在等待著的將要運送到其他的車站的貨車。
  • 每個貨車被送到了目的地會獎勵玩家一些分數。貨車可以放在任意車站。
  • 火車只在一條單一的路線上執行,每次能裝一個貨車,因為燃料有限只能移動一定的距離。

我們可以把我們的問題原先的圖美化一下。為了在燃料限制下贏得最大的分數,我們需要知道貨車在哪裡裝載,以及在哪裡解除安裝。

我們在圖片中可以看到,我們有兩條火車路線:紅色和藍色。車站位於某些座標點上,所以我們很容易就能算出它們之間的距離。每一個車站有一個以它的終點命名的貨車,以及當我們成功送達它可以得到的分數獎勵。

現在,假定我們的貨車能跑3千米遠。紅色路線上的火車可以把 A 車站的火車送到它的 終點 E (5點分數),藍色路線上的火車可以運送貨車 C(10點分數),然後運送貨車 B(5點分數)。 可以取得最高分20分。

狀態表示

我們把火車的位置,以及火車所走的距離和每個車站的貨車表格叫做一個問題狀態。 改變這些值我們得到的仍是相同的問題,但是引數變了。我們可以看到每次我們移動 一列火車,我們的問題就演變到一個不同的子問題。為了算出最佳的移動方案,我們 必須遍歷這些狀態然後基於這些狀態作出決策。讓我們開始把。

我們將從定義火車路線開始。因為這些路線不是直線,所以圖是最好的表示方法。

TrainRoute 類實現了一個非常基本的有向圖,它把頂點作為車站存在一個集合中,把車站間 的連線存在一個字典中。請注意我們把 (u, v) 和 (v, u) 兩條邊都加上了,因為火車可以 向前向後移動。

在 next_stations 方法中有一個有趣東西,在這裡我使用了一個很酷的 Python 3 的特性 yield from。這允許一個生成器 可以委派到另外一個生成器或者迭代器中。因為每一個車站都對映到一個車站的集合,我們只 需要迭代它就可以了。

讓我們來看一下 main class:

我省略了一些程式碼,但是我們可以看到一些有趣的東西。兩個 命名元組 將會幫助保持我們的資料整齊而簡單。main class 有我們的火車能夠執行的最長的距離,燃料, 和路線以及車站這些引數。maximum_score 方法計算每條路線的分數的總和,將成為解決問題的 介面,所以我們有:

  • 一個 main class 持有路線和車站之間的連線
  • 一個車站元組,存有名字,位置和當前存在的貨車列表
  • 一個帶有一個值和目的車站的貨車

動態規劃

我已經嘗試解釋了動態規劃如何高效地搜尋狀態空間的關鍵,以及基於已有的狀態進行最優的決策。 我們有一個定義了火車的位置,火車剩餘的燃料,以及每個貨車的位置的狀態空間——所以我們已經可以表示初始狀態。

我們現在必須考慮在每個車站的每一種決策。我們應該裝載一個貨車然後把它送到目的地嗎? 如果我們在下一個車站發現了一個更有價值的貨車怎麼辦?我們應該把它送回去或者還是往前 移動?或者還是不帶著貨車移動?

很顯然,這些問題的答案是那個可以使我們獲得更多的分數的那個。為了得到答案,我們必須求出 所有可能的情形下的前一個狀態和後一個狀態的值。當然我們用求分函式 score 來求每個狀態的值。

從每個狀態出發都有幾個選擇:要麼帶著貨車移動到下一個車站,要麼不帶貨車移動。停留不動不會進入一個新的 狀態,因為什麼東西都沒改變。如果當前的車站有多個貨車,移動它們中的一個都將會進入一個不同的狀態。

next_states 是一個以一個狀態為引數然後返回所有這個狀態能到達的狀態的生成器。 注意它是如何在所有的貨車都移動到了目的地後停止的,或者它只進入到那些燃料仍然足夠的狀態。wagon_choices 函式可能看起來有點複雜,其實它僅僅返回那些可以從當前車站到下一個車站的貨車集合。

這樣我們就有了實現動態規劃演算法需要的所有東西。我們從初始狀態開始搜尋我們的決策,然後選擇 一個最有策略。看!初始狀態將會演變到一個不同的狀態,這個狀態也會演變到一個不同的狀態! 我們正在設計的是一個遞迴演算法:

  • 獲取狀態
  • 計算我們的決策
  • 做出最優決策

顯然每個下一個狀態都將做這一系列的同樣的事情。我們的遞迴函式將會在燃料用盡或者所有的貨車都被運送都目的地了時停止。

完成動態規劃策略的最後一個陷阱:在程式碼中,你可以看到我使用了一個 max_score 字典, 它實際上快取著演算法經歷的每一個狀態。這樣我們就不會重複一遍又一遍地遍歷我們的我們早就已經 經歷過的狀態的決策。

當我們搜尋狀態空間的時候,一個車站可能會到達多次,這其中的一些可能會導致相同的燃料,相同的貨車。 火車怎麼到達這裡的沒關係,只有在那個時候做的決策有影響。如果我們我們計算過那個狀態一次並且儲存了 結果,我們就不在需要再搜尋一遍這個子空間了。

如果我們沒有用這種記憶化技術,我們會做大量完全相同的搜尋。 這通常會導致我們的演算法很難高效地解決我們的問題。

總結

Train Empire 提供了一個絕佳的的例子,以展示動態規劃是如何在有重疊子問題的問題做出最優決策。 Python 強大的表達能力再一次讓我們很簡單地就能把想法實現,並且寫出清晰且高效的演算法。

完整的程式碼在 contest repository

相關文章