1
小宇:閃客,我最近在研究動態規劃,但感覺就是想不明白,你能不能給我講講呀?
閃客:沒問題,這個我擅長,你先說說提到動態規劃,你最先想到的是什麼?
小宇:就什麼子問題呀、狀態轉移方程呀亂七八糟的,哎呀不行不行,我一想到這些腦子又嗡嗡響了。
閃客:你先別急,你先把所有的名詞都拋在腦後,聽我講。
小宇:好滴,你說吧。
閃客:小宇我問你,從 1 一直加到 100 等於多少?
1 + 2 + 3 + ... + 100 = ?
小宇:5050!
閃客:你這,怎麼不按套路出牌呀,你應該說不知道。
小宇:人家高斯早就算出來了,我還裝不知道,這也太假了吧。
全劇終...
2
閃客:好吧,那我再給你出一個題。
小宇:行,你說吧,這回我肯定說不知道。
閃客:一個樓梯有 10 級臺階,你從下往上走,每跨一步只能向上邁 1 級或者 2 級臺階,請問一共有多少種走法?
小宇:額,這我真不知道了,我想想哈。
小宇:不行了不行了,實在想不明白,想了後面的就忘了前面的。
閃客:你還是陷入了窮舉的思想,你仔細想想我給你出的第一個題,看看有沒有思路。
小宇:啊!原來是有關聯的呀。
閃客:對呀,我本來想說假如我告訴你 1+...+99 是多少,你是不是就直接能算出 1+...+100 的值了。
小宇:哦你這麼一提示我有點感覺了!要想走到第 10 級臺階,要麼是先走到第 9 級,然後再邁一步 1 級臺階上去,要麼是先走到第 8 級,然後一次邁 2 級臺階上去。
閃客:太棒了!你找到感覺了!接著往下說。
小宇:這樣的話,走到 10 級臺階的走法數,就等於走到 9 級臺階的走法數,加上走到 8 級臺階的走法數。
閃客:很好,那假如走到第 x 級臺階的走法數我們定義為 F(x),那你能把剛剛的描述公式化麼?
小宇:那太簡單了,公式就是:
F(10) = F(9) + F(8)
閃客:沒錯,而且不光是 10 級臺階如此,走到任何一級臺階的走法數,都符合這個邏輯,因此就可以得出一個通用公式:
F(x) = F(x-1) + F(x-2)
小宇:嗯嗯,這樣計算 F(10),只需要知道 F(9) 和 F(8) 就可以了,而計算 F(8),就只需要知道 F(7) 和 F(6) 就可以了,依次類推。
閃客:沒錯,那你想想看 F(2) 和 F(1) 怎麼計算?
小宇:簡單,還是剛剛都邏輯被,想知道 F(2),只需要知道 F(1) 和 F(0),誒不對 F(0) 是什麼鬼?還有 F(1) 的計算需要知道 F(0) 和 F(-1),不行呀,這解釋不通了。
閃客:哈哈,別急,在這道題裡,如果只邁到 1 級臺階,那一共就一種走法;如果只邁到 2 級臺階,就只有兩種走法。可以直接很直觀地得出,沒必要推導。
小宇:哦哦我懂了,這道題裡由於每一個遞推項都需要前兩項的支援,所以必須有最開頭的兩項作為已知,就是你說的 F(1) = 1 和 F(2) = 2。
閃客:沒錯。
小宇:嗯嗯,感覺這樣就推出全部結果了!我寫一下程式你看看。
閃客:先別急,由於這道題是一道經典的動態規劃題,所以我們以這道題為例子來定義動態規劃的三要素,在本題中
F(x-1) 和 F(x-2) 被稱為 F(x) 的最優子結構
F(x) = F(x-1) + F(x-2) 叫狀態轉移方程
F(1) = 1, F(2) = 2 是問題的邊界
之後做動態規劃問題,只要找好這三個要素就好了。
小宇:哇,昇華了誒,逼格瞬間高了不少呢。
閃客:先別說這些廢話了,那接下來你看看能不能寫出程式,計算出 F(10) 的結果,這才是難點。
小宇:程式設計的話這似乎是個遞迴問題,簡單!
int getWays(int n) { if (n == 1) { return 1; } if (n == 2) { return 2; } return getWays(n-1) + getWays(n-2); }
閃客:嗯不錯,這樣很簡潔,但複雜度太高了,是 O(2^n),具體你可以之後想想為什麼。現在你看看能不能將複雜度降低。
小宇:我想想看,計算 F(10) 時需要計算 F(9) 和 F(8),而在遞迴計算 F(9) 時要計算 F(8) 和 F(7),這樣 F(8) 在這裡重複計算了,浪費了時間。
閃客:沒錯,其實計算新一個階段的值,只需要一直將其前兩個階段的值儲存起來,就可以一直算到最終的結果了。比如定義兩個變數 a 和 b 用於儲存前兩個階段的值,在計算 F(3) 時。
計算 F(4) 時,F(1) 的值就不用儲存了,a 和 b 依次替換新值。
依此類推,最終就算出了 F(10) 的值。
當然你也可以把之前的值都保留,但這樣就增加了空間複雜度,看你的需求了。
小宇:好的,那這樣程式碼也很好寫,就這樣。
int getWays2(int n) { if (n == 1) { return 1; } if (n == 2) { return 2; } int a = 1; int b = 2; int temp = 0; for (int i = 3; i <= n; i++) { temp = a + b; a = b; b = temp; } return temp; }
閃客:不錯,這就是這道題正確的動態規劃解法,而且時間複雜度是 O(N),空間複雜度是 O(1)
小宇:哇,這就是動態規劃呀,原來這麼簡單。
3
閃客:不錯,動態規劃理解起來不難,難在當需要考慮的因素,也就是變化的維度多起來的時候,有的人就會頭腦發矇,不好找遞推公式了,而且這也確實是個難點。
小宇:哦是嗎?
閃客:那當然,我再給你出一道題。
小宇:來吧兄弟。
閃客:咳咳,那你聽好了。
有一個揹包,可以裝載重量為 5kg 的物品。
有 4 個物品,他們的重量和價值如下。
那麼請問,在不得超過揹包的承重的情況下,將哪些物品放入揹包,可以使得總價值最大?
小宇:明白了,就是我用這個揹包最多能裝走多少錢的東西。
閃客:是的。
小宇:哎呀不行,我又陷入走樓梯時的遍歷思想了。
閃客:沒關係,這道題能想出遍歷思想,其實也不容易了,你可以先說一下,找找感覺。
小宇:嗯嗯,那就是每個物品都可以有放入揹包和不放入揹包兩種選擇。
如果總重量超過了揹包承重,那就不算,或者說將價值記為 0,然後將所有情況中價值最大的那個作為結果。
這樣的複雜度也很容易得出,就是 O(2^N)
閃客:沒錯,這個複雜度很高的演算法你已經說的很明白了,那接下來你想想看用動態規劃思想,能不能解決這個問題。
小宇:好的,你之前說過,動態規劃的三要素是最優子結構、狀態轉移方程和邊界
閃客:沒錯,之前的變數很少所以比較簡單,現在變數多了,定義就變得難了起來,我們先來幾個定義方便描述。我們將 4 個物品的重量和價值分別表示為:w1,w2,w3,w4,v1,v2,v3,v4。
假如我們用
F(W,i)
表示
用載重為 W 的揹包,裝前 i 件物品的最大價值
那本題其實就是
用載重為 5kg 的揹包,裝前 4 件物品的最大價值
其實就是求解
F(5,4)
你能找到狀態轉移方程麼?
小宇:我想想,單看這個物品 4,有兩種可能:
第一種可能:如果選擇把它裝入揹包,那已經得到了 6 元錢。
此時揹包剩餘載重為 1kg(5kg-4kg),剩餘物品是除去物品 4 後的前 3 件物品。
那這部分能獲取到的最大價值,相當於
用一個載重為 1kg 的揹包,裝前 3 件物品的最大價值
哇,那這部分就是
F(1,3)
閃客:哈哈,你這自己說著說著就說對啦!
小宇:所以最終,如果選擇將物品 4 放入揹包,這種情況下,最大價值就等於二者之和。
F(1, 3) + 6
閃客:太好了小宇,那另一種情況呢?
小宇:第二種可能:如果選擇不裝這個物品 4,那更簡單了,就直接等於用一個載重為 5 的揹包裝前 3 件物品的價值。
F(5, 3)
閃客:沒錯,而且就只有這兩種情況!所以你看看 F(5,4)是否能用這兩種情況的值表示呢?
小宇:哈哈,很簡單,就等於這兩種情況當中的最大值唄。
F(5,4) = max { F(1, 3) + 6,F(5, 3) }
閃客:太好了,現在狀態轉移方程出來了,此時我們畫個表格。
我們的目標就是要計算右下角那個值,即揹包載重 W = 5 時,選擇前 4 件物品放入揹包的最大價值 F(5,4)
小宇:哇這個表格好清晰呀,根據上面的公式
F(5,4) = max { F(1,3) + 6, F(5,3) }
那也就是說只要知道 F(1,3) 和 F(5,3) 的值就可以了對吧?
閃客:沒錯,那你再看看 F(1,3) 怎麼計算?
小宇:好的,F(1,3) 此時揹包重量為 1,如果選擇放第三件物品的話,誒?好像不行,第三件物品根本放不下呀!
閃客:是的,所以這種情況就沒必要討論放第三件物品的情況了,因為根本放不下,因此 F(1,3) 直接就等於 F(1,2),所以只需要知道 F(1,2) 即可。
同理 F(1,2) 也直接等於 F(1,1),因為在揹包重量為 1 時第二件物品也放不下。
閃客:小宇你想想看,那 F(1,1) 又等於什麼呢?
小宇:顯然嘛,現在只有一件物品可以選了,那能放下當然就放咯,所以最大價值就是第一件物品的價值 3,即 F(1,1) = 3
閃客:沒錯,這樣我們就找到了一個邊界值,小宇你想想看還有哪些邊界值可以直接得出?你寫在表格裡吧。
小宇:好的,首先第一列表示揹包重量為 0 時的情況,那顯然什麼都裝不了,就全都是 0 了。
然後第一行也比較好算,揹包重量 >= 1 時可以放下第一件物品,所以最大價值都等於 3
閃客:很好,接下來,就依次把表格的所有項都填出來,自然就可以算出 F(5,4) 啦。
小宇:哇塞,這樣看好清晰呀!
閃客:是呀,不過剛剛我們用的都是具體的數字,那我們試著把這個問題抽象化,用一個載重為 W 的揹包,裝載 N 件物品,每件物品的重量和價值分別用 wi 和 vi 來表示,那剛剛的狀態轉移方程是什麼呢?
小宇:emm,剛剛 F(5,4) = max { F(1,3) + 6, F(5,3) },如果都用變數表示的話,就是
F(W,N) = max { F(W-wn, N-1) + vn,F(W, N-1) }
閃客:很好,這就是狀態轉移方程。
F(W-wn, N-1) 和 F(W, N-1) 就是 F(W,N) 的最優子結構。
而剛剛表格中的第一行和第一列,即 F(0,...) 和 F(...,1) 就是邊界值!
小宇:哇塞我愛你閃客!終於有點理解動態規劃的思想了呢!
4
閃客:別高興太早,雖然過程看著清晰了,但程式碼寫起來還是有難度的,你今天回去就把程式碼試著實現一下吧。
小宇:好的,保證完成任務。
閃客:快到晚飯時間了,旁邊新開了家餃子館,要不要一塊去吃呀?
小宇:哦不了,晚上想利用晚飯時間再去消化消化動態規劃的知識,不是還得程式碼實現呢麼,下次吧,
閃客:哦好吧~
後記
本文通過直觀演示 01 揹包問題的解題思路,簡單說明了動態規劃思想的演算法核心。可能不少人覺得動態規劃難在理解,所以花很多時間在理解其思想上。但其實理解核心思想,這一篇文章就夠了,更多的是通過不斷做題,反過來幫助自己理解動態規劃的思想。所以希望讀者在讀完本文後,和小宇一樣,動手將其程式碼實現,並找來其他變種題目,繼續鞏固。