總目錄 > 4 動態規划 > 4.1 記憶化搜索與動態規划
前言
最近又做了一些比較基礎的 DP,感覺自己無敵了,應該有資格寫篇文章來介紹了!
本文主要介紹動態規划的概念,記憶化搜索以及動態規划的核心。
更新日志
Update - 20200616
寫完搜索部分后再回頭看了下這篇起到銜接作用的文章,發現對記憶化搜索的概念還是有點偏差,加上之前在記憶化搜索和動規的過渡本身就有點牽強,於是進行了大幅修改。
同時划上了許多重點。
子目錄列表
1、介紹
2、記憶化搜索?動規?
3、不要遞歸的記憶化搜索
4、動態規划的核心
5、總結
4.1 記憶化搜索與動態規划
1、介紹
動態規划 (dynamic programming) 是運籌學的一個分支,是求解決策過程 (decision process) 最優化的數學方法。其本質是將一個復雜的問題拆分成若干個相對簡單的子問題,所以常適用於有重疊子問題和最優子結構問題。
這樣介紹動態規划是很空洞而抽象的,我們從更簡單的方式切入。
2、記憶化搜索?動規?
【Luogu P1048】【NOIP2005】【采葯】山洞里有 m 株不同的草葯,第 i 株草葯的價值為 v[i],采第 i 株草葯需要時間 t[i]。在時間 T 內,要求采集一些草葯,使總價值最高。
不知道動規碰到這道題的唯一方法為暴力搜索 —— 進行 DFS(請參見:3.1 DFS / BFS 搜索),對於每一株有選與不選兩個選項,從第 1 株開始逐一進行二選一,如果出現時間超過限定則回溯;直到對 m 株都進行了選擇,記錄當前的價值並和最大價值比較,選擇較大值,以此反復,可得最優解。核心代碼如下:
1 void dfs(int o, int ot, int ov) { 2 if (ot > T) return; 3 if (o == m + 1) { 4 ans = max(ans, ov); 5 return; 6 } 7 dfs(o + 1, ot, ov); 8 dfs(o + 1, ot + t[o], ov + v[o]); 9 }
時間復雜度為 O(2 ^ n),太美。
它的三個參數 o, ot, ov 可以看作這層 DFS 的狀態,表示着選擇完前 o - 1 株草葯(並不包括當前的第 o 株,因為還沒有選擇它是否要采集)時已經耗費的時間 ot 和已經獲得的價值 ov。
先不談如何優化,來講講一個概念 —— 外部變量,進行鋪墊。外部變量,即在 DFS 函數外定義且隨 DFS 過程發生變化的變量。如上代碼,ans 即為一個外部變量。ans 是用來記錄最終答案的,我們嘗試着能不能以另一種形式記錄答案 —— 返回值。將 DFS 中的第三個參數 ov 移除,並將函數更改為 int 類型,則 dfs(o, ot) 表示在時間 ot 內采集后 i 個草葯獲得的最大收益,再來看代碼:
1 int dfs(int o, int ot) { 2 if (o == m + 1) return f[o][ot] = 0; 3 int res1, res2 = -INF; 4 res1 = dfs(o + 1, ot); 5 if (ot >= t[o]) res2 = dfs(o + 1, ot - t[o]) + v[o]; 6 return f[o][ot] = max(res1, res2); 7 }
這時,res1 保存的是不采集第 o 株的返回值,res2 保存的是采集第 o 株的。而最終的答案,就是 dfs(m, T) 的返回值,通過回溯,最后返回給主函數。
上述兩份代碼是等價的,那為什么要提后面這種呢?我們嘗試着去記錄每一層 DFS 的返回值,震驚地發現,對於相同的 o, ot,其返回值也是相同的!其實也不難理解,因為本身不再借助外部變量,所以 DFS 函數是完全獨立的運行,不受外界影響,對於相同的參數,其返回值相同也是必然的。
那么,利用這一點,我們就可以對搜索進行大幅度的優化了!對於 dfs(o, ot),如果之前已經出現過 (o, ot) 這個狀態了,那么大可不必再次進行后續的計算,直接將之前得到的返回值拿來就行,所以我們可以定義一個數組 f[i][j] 用以記錄 dfs(i, j) 的返回值,每次遞歸時先判斷 f[i][j] 是否已經有返回值,如果沒有則繼續執行,如果有則直接跳過。
所以,在上述代碼的基礎上增加一行即可:
1 int dfs(int o, int ot) { 2 if (f[o][ot] != -1) return f[o][ot]; 3 if (o == m + 1) return f[o][ot] = 0; 4 int res1, res2 = -INF; 5 res1 = dfs(o + 1, ot); 6 if (ot >= t[o]) res2 = dfs(o + 1, ot - t[o]) + v[o]; 7 return f[o][ot] = max(res1, res2); 8 }
普通的暴力搜索,是沒有記憶的;相比之下,我們增加的數組給搜索添加了記憶功能,使其不會走彎路,吃一塹長一智,這樣的搜索,我們稱之為 —— 記憶化搜索。
它本身屬於 3.3 搜索優化 中的一種,同時又和動態規划有着極其緊密的聯系,用於搜索和動規之間的銜接最合適不過了。那么,它和動態規划是什么關系呢?
3、不要遞歸的記憶化搜索
還是上面這道例題。首先前面給出了一個定義 —— dfs(o, ot) 表示在時間 ot 內采集后 o 個草葯獲得的最大收益,同時 f[i][j] 記錄的則是 dfs(i, j) 的返回值。
通過遞歸過程的分析不難發現,對於參數 o,其遞歸順序是單調的,從最后一株草葯一直到第一株,也就是說在對第 i 個草葯進行采集還是不采集的選擇時,只和第 i - 1 個(從后 i 個遞歸到后 i + 1 個,即相當於從第 i 個草葯遞歸到第 i - 1 個草葯)草葯存在直接的轉移關系(也正是因為這個特性,這道題的動規方程可以只使用一維數組,即滾動數組,但這里不提,在 4.2 背包 DP 會提及)。
而對於參數 ot,隨着草葯采集的增多,ot 必然是變小的。對於第 i 個草葯,如果選擇采集,那么返回值就存在 f[i][j] = f[i - 1][j - t[i]] + v[i] 的關系;如果不采集,f[i][j] = f[i - 1][j]。也就是說,o 和 ot 兩個參數在遞歸的過程中都是單調變化的,為何要大費周章地用遞歸去實現?
看下這段代碼。
1 for (int i = 1; i <= m; i++) 2 for (int j = 1; j <= T; j++) 3 if (j >= t[i]) 4 f[i][j] = max(f[i - 1][j - t[i]] + v[i], f[i - 1][j]); 5 else 6 f[i][j] = f[i - 1][j];
通過簡單的二重循環,圓滿完成了遞歸完成的任務 —— 兩個參數都是單調的,for 循環完全可以實現。
而使用的這個 f 數組,恰好就是遞歸過程中使用的記錄數組,即 f[i][j] 表示對於后 i 個草葯在時間 j 內獲得的最大價值,最終的結果為 f[n][1..T] 中的最大值,因為並未限定時間必須為多少。那么這樣一個數組,在動態規划中,我們稱之為狀態數組,即用來表示由若干個參數組成的狀態值;而諸如 f[i][j] = max(f[i - 1][j - t[i]] + v[i], f[i - 1][j]) 這樣的式子,我們稱之為狀態轉移方程,用於建立起相關狀態間的聯系。
你可以說,記憶化搜索約等於動態規划,也可以說,記憶化搜索是一種以遞歸 / 搜索形式實現的動態規划,總而言之,兩者是密不可分的。但同時也並非完全等同。
相比之下,記憶化搜索可以避免搜索到一些無用的狀態,所以它也是被視作搜索優化的一種;更好理解,沒有動規那么抽象。缺點的話,首先是遞歸形式,效率相比 for 循環肯定是低的;不能使用滾動數組來降低維度,空間復雜度難以優化;代碼量較大。
4、動態規划的核心
從上面的例題中,我們提煉出動態規划的幾個核心:
① 划分狀態
一項任務能不能用動態規划的思想來完成,首先判斷能否或者是否比較輕松的划分出子問題,以定義每個子問題的狀態。
比如例題中草葯,時間,價值,以及之間的關系。
② 狀態表示
幾乎所有動態規划離不開一個 f 數組。f 數組是一個抽象數組,它並沒有例題中“v[i] 表示第 i 株草葯的價值”這么直白的定義,而是以其若干個維度表示的參數所組成的一個狀態。
比如例題中的 f[i][j]。
③ 狀態轉移
如何從一個狀態轉移到另一個狀態是動態規划的關鍵,明確了狀態轉移方程,才能順水推舟地一路遞推下去,直到獲得結果。
例題中的狀態轉移方程見上。
④ 狀態范圍
初始狀態是什么?每一個參數的范圍在哪里?最終狀態又是什么?正如你已經知道了前行的路徑,還需要定義起點和終點才能出發。
比如例題中,我們從最后一株草葯開始選擇是否采集,時間則是從 0 開始。
5、最后
動態規划最基礎的概念就是這些,而這僅僅只是一些皮毛,接下來還有動態規划的各種基本模型,以及進一步拓展的各種高級動態規划,以及動態規划的優化方法。
