前言
在上一篇文章動態規劃的文章中,我們先由 Fibonacci 例子引入到了動態規劃中,然後藉助兌換零錢的例子,分析了動態規劃最主要的三個性質,即:
- 重疊子問題
- 最優子結構
- 狀態轉移方程
但是動態規劃遠不止這麼簡單。
今天這篇文章,讓我們深入動態規劃,一窺動態規劃的本質。
我們既然要徹底搞清楚動態規劃,那麼一個不可避免的問題就是:
遞歸,貪心,記憶化搜索和動態規劃之間到底有什麼不同?
-
動態規劃於遞歸 :只是單純的空間換時間嗎? 並不是,斐波那切數列的例子很好的推翻了這個觀點。
-
動態規劃於貪心:只是貪心的加強版嗎?並不是,零錢兌換的例子同樣推翻了這個觀點。
那麼,動態規劃的核心到底是什麼?
要回答這個問題,我們不妨先回答下面這個問題:
到底哪些問題適合用動態規劃即?怎麼鑑定 DP 可解問題?
相信當我們認識到哪些問題可以用 DP 解決,我們也就自然找到了 DP 和其它算法思想的區別,也就是動態規劃的核心。
動態規劃核心
首先我們要搞清楚,動態規劃只適用於某一類問題,只是某一類問題的解決方法。
那麼這“某一類問題”是什麼問題呢?
聊這個之前我們有必要稍微瞭解下計算機的本質。
基於馮諾依曼體系結構的計算機本質上是一個狀態機,爲什麼這麼說呢?因爲 CPU 要進行計算就必須和內存打交道。
因爲數據存儲在內存當中(寄存器和外盤性質也一樣),沒有數據 CPU 計算個空氣啊?所以內存就是用來保存狀態(數據)的,內存中當前存儲的所有數據構成了當前的狀態,CPU 只能利用當前的狀態計算下一個狀態。
我們用計算機處理問題,無非就是在思考:如何用變量來儲存狀態,以及如何在狀態之間轉移:由一些變量計算出另一些變量,由當前狀態計算出下一狀態。
基於這些,我們也就得到了評判算法的優劣最主要的兩個指標:
-
空間複雜度:就是爲了支持計算所必需存儲的狀態
-
時間複雜度:就是初始狀態到最終狀態所需多少步
如果上述表述還不是很清楚,那我們還是舉之前 Fibonacci 的例子來說:
- 要計算當前 f(n),只需要知道 f(n - 1) 和 f(n - 2).
即:
- 要計算當前狀態 f(n),只需要計算狀態 f(n - 1)和 f(n -2).
也就是說當前狀態只與前兩個狀態有關,所以對於空間複雜度:我們只需保存前兩個狀態即可。
這也就很好的解釋了爲什麼動態規劃並不是單純的空間換時間,因爲它其實只跟狀態有關。
由一個狀態轉移到另一狀態所需的計算時間也是常數,故線性增加的狀態,其總的時間複雜度也是線性的。
以上便是動態規劃的核心,即:
狀態的定義及狀態之間的轉移(狀態方程的定義)。
那麼如何定義所謂的“狀態”和“狀態之間的轉移”呢?
我們引入維基百科的定義:
dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.
那就是通過拆分問題,定義問題狀態和狀態之間的關係,使得問題能夠以遞推(或者說分治)的方式去解決。
紙上談來終覺淺,下邊我們再來看一道同樣非常經典的例題。
最長遞增子序列
這是 LeetCode 第 300 題。
給定一個數列,長度爲 N,求這個數列的最長上升(遞增)子數列(LIS)的長度.
示例 1:
輸入:nums = [10,9,2,5,3,7,101,18] 輸出:4 解釋:最長遞增子序列是 [2,3,7,101],因此長度爲4
示例 2:
輸入:nums = [0,1,0,3,2,3] 輸出:4 解釋:最長遞增序列是 [0,1,2,3],因此長度爲4
我們如何進行狀態的定義及狀態間轉移的定義呢?
一、狀態的定義
首先我們應該進行問題的拆分,即進行這個問題子問題的定義。
所以,我們重新定義一下這個問題:
給定一個數列,長度爲 N,
設 Fk爲:給定數列中第 k 項結尾的最長遞增子序列的長度
求 F1到 FN的最大值
是不是上邊這個定義與原問題一樣?
顯然二者等價,不過明顯第二種定義的方式,我們找到了子問題。
對於 Fk來講,F1到 Fk-1都是 Fk的子問題。
上述新問題的 Fk 就叫做 狀態。
Fk爲數列中第 k 項結尾的 LIS 的長度 即爲狀態的定義。
二、狀態轉移方程的定義
狀態定義好之後,狀態與狀態之間的關係式,就叫狀態轉移方程。
此題以 Fk的定義來說:
設 Fk爲:給定數列中第 k 項結尾的最長遞增子序列的長
思考,狀態之間應該怎麼轉移呢?
還記得我們之前說的拆分問題不,在這裏同樣我們可以沿用這一招,即拆分數據。
如果數列只有一個數呢?那我們應該返回 1(我們找到了狀態邊界情況)。
那麼我們可以寫出以下狀態轉移方程:
F1 = 1
Fk = max ( Fi + 1 | i ∈(1,k-1))(k > 1)
即:以第 k 項結尾的 LIS 的長度是:max { 以第 i 項結尾的 LIS 長度 + 1 }, 第 i 項比第 k 項小
大家理解下,是不是這麼回事~
回憶一下我們是怎麼做的?
- 我們通過拆分問題進行了問題(子問題)的重定義(狀態的定義);
- 通過狀態的定義,再結合狀態的邊界情況,我們寫出了狀態與狀態之間轉移即狀態轉移方程的定義。
寫出了狀態轉移方程,可以說到此,動態規劃算法核心的思想我們已經表達出來了。
剩下的只不過是用記憶化地求解遞推式的方法來解決就行了。
下面我們嘗試寫出代碼。
代碼
首先我們定義 dp 數組:
int[] dp = new int[nums.length];
(注意這裏 dp 數組的大小跟上一篇文章兌換零錢的例子有一丟丟不同,即這裏沒有+1,大家可以再點擊這裏看下上一篇文章仔細理解一下。)
那麼這裏 dp 數組的含義就是:
dp[i] 保存的值即是給定數組 i 位之前最長遞增子序列的長度。
那麼我們的初始狀態是什麼呢?
我們知道狀態的邊界情況爲:
F1 = 1
- 即如果數據只有一位那麼應該返回 1;
- 當數據個數 > 1 時,如果整個數列沒有出現第二個遞增的數,那麼同樣返回 1.
所以,初始狀態我們給 dp 數組每個位置都賦爲 1.
Arrays.fill(dp, 1);
然後,我們從給定數組的第一個元素開始遍歷,即寫出外層的 for 循環:
for(int i = 0; i < nums.length;i++){
......
}
當我們外層遍歷到某元素時,我們怎麼做呢?
我們得找一下,在這個外層元素之前,存不存在比它小的數,如果存在,那麼我們就更新此外層元素的 dp[i]
如果某元素之前有比它小的數,那麼這不就構成了遞增子序列了嗎?
因此我們可以寫出內層 for 循環:
for (int j = 0; j < i; j++) {
//如果前面有小於當前外層nums[i]的數,那麼就令當前dp[i] = dp[j] + 1
if (nums[j] < nums[i]) {
//因爲當前外層nums[i]前邊可能有多個小於它的數,即存在多種組合,我們取最大的一組放到dp[i]裏
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
兩層循環結束時,dp[] 數組裏存儲的就是相應元素位置之前的最大遞增子序列長度,我們只需遍歷 dp[] 數組尋找出最大值,即可求得整個數組的最大遞增子序列長度:
int res = 0;
for(int k = 0; k < dp.length; k++){
res = Math.max(res, dp[k]);
}
此題代碼也就寫完了,下面貼出完整代碼:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums.length < 2) return 1;
int[] dp = new int[nums.length];
Arrays.fill(dp,1);
for(int i = 0;i < nums.length;i++){
for(int j = 0;j < i;j++){
if(nums[j] < nums[i]){
dp[i] = Math.max(dp[i],dp[j] + 1);
}
}
}
int res = 0;
for(int k = 0;k < dp.length;k++){
res = Math.max(res,dp[k]);
}
return res;
}
}
這個題兩層 for 循環跟之前兌換零錢的代碼基本上差不多,大家可以結合上一篇文章再一起對比理解。
不同之處只是內層 for 循環的判斷條件和狀態轉移方程的表達(如何更新 dp[]),這也是動態規劃的本質所在。
小結
關於動態規劃有很多誤區和誤解,比如最常見的可能就是說它是空間換時間,以及搞不清楚它和貪心的區別。
希望這兩篇動態規劃的文章能幫你消除這些誤區,並且更好的理解到動態規劃的本質,理解狀態和狀態方程。
當然,僅僅這兩篇文章想說透動態規劃是遠遠不夠的,所以接下來會具體的講解一些典型問題,比如揹包問題、石子游戲、股票問題等等,希望能幫你在學習算法的道路上少走一些彎路。
如果大家有什麼想了解的算法和題目類型,非常歡迎在評論區留言告訴我,我們下期見!