目錄
- 定義
- 動態規划的步驟
- 例題分析
- 算法對比
- 總結
一、定義
1.1 定義
把多階段過程轉化為一系列單階段問題,利用各階段之間的關系,逐個求解,創立了解決這類過程優化問題的新方法——動態規划 --百度定義
動態規划算法(Dynamic Programming-DP)是通過拆分問題,定義問題狀態和狀態之間的關系,將待求解的問題分解為若干個子問題(階段),按順序求解子階段,前一子問題的解,為后一子問題的求解提供了有用的信息。在求解任一子問題時,列出各種可能的局部解,通過決策選取那些有可能達到最優的局部解。依次解決各子問題,最后一個子問題就是初始問題的解。
1.2 能用動規解決的問題的特點
- 問題具有最優子結構性質。如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質。
- 無后效性:某狀態以后的過程不會影響以前的狀態,只與當前狀態有關。
- 有重疊子問題:即子問題之間是不獨立的,一個子問題在下一階段決策中可能被多次使用到。(該性質並不是動態規划適用的必要條件,但是如果沒有這條性質,動態規划算法同其他算法相比就不具備優勢)
1.3 說明
已知問題規模為n的前提A,求解一個未知解B。(我們用An表示"問題規模為n的已知條件")
此時,如果把問題規模降到0,即已知A0,可以得到A0->B.
1)如果從A0添加一個元素,得到A1的變化過程。即A0->A1; 進而有A1->A2; A2->A3; …… ; Ai->Ai+1. 這就是嚴格的歸納推理,也就是我們經常使用的數學歸納法;
對於Ai+1,只需要它的上一個狀態Ai即可完成整個推理過程(而不需要更前序的狀態)。我們將這一模型稱為馬爾科夫模型。對應的推理過程叫做"貪心法"。
2)然而,Ai與Ai+1往往不是互為充要條件,隨着i的增加,有價值的前提信息越來越少,我們無法僅僅通過上一個狀態得到下一個狀態,因此可以采用如下方案:
{A1->A2}; {A1, A2->A3}; {A1,A2,A3->A4};……; {A1,A2,...,Ai}->Ai+1. 這種方式就是第二數學歸納法。
對於Ai+1需要前面的所有前序狀態(或幾個狀態)才能完成推理過程。我們將這一模型稱為高階馬爾科夫模型。對應的推理過程叫做"動態規划法"。
二、動態規划的步驟
這個步驟可能有點枯燥,建議結合后面例題進行閱讀。
2.1 定義子問題
子問題是和原問題相似,但規模較小的問題。第一步就是縮小規模,利用小規模子問題刻畫其結構特征。
- 划分階段:按照問題的時間或空間特征,把問題分為若干個階段。在划分階段時,注意划分后的階段一定要是有序的或者是可排序的,否則問題就無法求解。
- 確定狀態和狀態變量:將問題發展到各個階段時所處於的各種客觀情況用不同的狀態表示出來。當然,狀態的選擇要滿足無后效性。
說明:創建一個一維數組或者二維數組,保存每一個子問題的結果,具體創建一維數組還是二維數組看題目而定
注意:子問題的解一旦求出就會被保存,所以每個子問題只需求 解一次。
2.2 寫出子問題的遞推關系
找出狀態轉換方程,也就是說找到每個狀態跟他上一個狀態的關系,根據狀態轉化方程寫出代碼。
- 確定一些初始狀態(邊界狀態)的值
- 確定狀態轉移方程: 定義出什么是"狀態",以及如何從一個或多個"值"已知的 "狀態",求出另一個"狀態"的"值"(遞推型)。狀態的遷移可以用遞推公式表示,此遞推公式也可被稱作"狀態轉移方程"。
2.3 確定 DP 數組的計算順序
在確定了子問題的遞推關系之后,下一步就是依次計算出這些子問題了。一般地,動態規划有兩種計算順序:
- 自頂向下的、使用備忘錄的遞歸方法
- 自底向上的、使用 dp 數組的循環方法。
不過在普通的動態規划題目中,99% 的情況我們都不需要用到備忘錄方法,即自底向上的 dp 數組。
2.4 空間優化(可選)
目的:空間復雜度的較小。
在很多問題中,DP數組並非全部用上,很多情況下只使用一小部分或空間可重復利用,這給空間壓縮帶來可能。
三、例題分析 - leetcode198. 打家劫舍
3.1 問題
你是一個專業的小偷,計划偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。
給定一個代表每個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。
示例 1:
輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,然后偷竊 3 號房屋 (金額 = 3)。 偷竊到的最高金額 = 1 + 3 = 4 。
示例 2:
輸入: [2,7,9,3,1]
輸出: 12
解釋: 偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接着偷竊 5 號房屋 (金額 = 1)。偷竊到的最高金額 = 2 + 9 + 1 = 12 。
3.2 求解
1)步驟一:定義子問題

- f(k)表示從前面k個房子能獲取的最大金額;
- 原問題要能由子問題表示。例如這道小偷問題中,k=n 時實際上就是原問題;
- 一個子問題的解要能通過其他子問題的解求出。例如本題:f(k) 可以由 f(k-1)和 f(k-2)求出,具體原理后面會解釋。這個性質就是所說的"最優子結構"。如果定義不出這樣的子問題,那么這道題實際上沒法用動態規划解。
2)寫出子問題的遞推關系

每個房子的金額用H表示,那么k個房子有兩種偷發,如上圖所示。容易得出遞推公式(重要):

注意這里涉及到邊界值:


3)確定 DP 數組的計算順序


那么,既然 DP 數組中的依賴關系都是向右指的,DP 數組的計算順序就是從左向右。這樣我們可以保證,計算一個子問題的時候,它所依賴的那些子問題已經計算出來了。
確定了 DP 數組的計算順序之后,我們就可以寫出題解代碼了:見3.3的代碼一。
4 )空間優化(可選)
最后一步計算 f(n) 的時候,實際上只用到了 f(n-1)和 f(n-2) 的結果。那么只用兩個變量保存兩個子問題的結果,就可以依次計算出所有的子問題。下面的圖比較了空間優化前和優化后的對比關系,代碼見3.3代碼二:

3.3 代碼
代碼一:未進行優化的DP
def rob(self, nums: List[int]) -> int: if len(nums) == 0: return 0 # 子問題: # f(k) = 偷 [0..k) 房間中的最大金額 # f(0) = 0 # f(1) = nums[0] # f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) } N = len(nums) dp = [0] * (N+1) dp[0] = 0 dp[1] = nums[0] for k in range(2, N+1): dp[k] = max(dp[k-1], nums[k-1] + dp[k-2]) return dp[N]
代碼二:優化后的DP
def rob(self, nums: List[int]) -> int: prev = 0 curr = 0 # 每次循環,計算“偷到當前房子為止的最大金額” for i in nums: # 循環開始時,curr 表示 dp[k-1],prev 表示 dp[k-2] # dp[k] = max{ dp[k-1], dp[k-2] + i } prev, curr = curr, max(curr, prev + i) # 循環結束時,curr 表示 dp[k],prev 表示 dp[k-1] return curr
四、算法對比
4.1 動態規划和分治區別
動態規划算法:它通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,我們希望找到具有最優值的解。動態規划算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然后從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規划求解的問題,經分解得到子問題往往不是互相獨立的。
分治法:若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重復計算了很多次。如果我們能夠保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重復計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。
分治算法不強調記錄算過的數據,動態規划為了避免重復計算,一定會記錄數據。
五、總結
不管該子問題以后是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規划法的基本思路。
參考文獻:
【1】 六大算法之三:動態規划
【2】leetcode動態規划的解析
