在上一篇文章動態規劃的文章中,咱們先由 Fibonacci 例子引入到了動態規劃中,而後藉助兌換零錢的例子,分析了動態規劃最主要的三個性質,即:java
可是動態規劃遠不止這麼簡單。算法
今天這篇文章,讓咱們深刻動態規劃,一窺動態規劃的本質。數組
咱們既然要完全搞清楚動態規劃,那麼一個不可避免的問題就是:學習
遞歸,貪心,記憶化搜索和動態規劃之間到底有什麼不一樣?code
那麼,動態規劃的核心究竟是什麼?遞歸
要回答這個問題,咱們不妨先回答下面這個問題:遊戲
到底哪些問題適合用動態規劃即?怎麼鑑定 DP 可解問題?內存
相信當咱們認識到哪些問題能夠用 DP 解決,咱們也就天然找到了 DP 和其它算法思想的區別,也就是動態規劃的核心。ci
首先咱們要搞清楚,動態規劃只適用於某一類問題,只是某一類問題的解決方法。get
那麼這「某一類問題」是什麼問題呢?
聊這個以前咱們有必要稍微瞭解下計算機的本質。
基於馮諾依曼體系結構的計算機本質上是一個狀態機,爲何這麼說呢?由於 CPU 要進行計算就必須和內存打交道。
由於數據存儲在內存當中(寄存器和外盤性質也同樣),沒有數據 CPU 計算個空氣啊?因此內存就是用來保存狀態(數據)的,內存中當前存儲的全部數據構成了當前的狀態,CPU 只能利用當前的狀態計算下一個狀態。
咱們用計算機處理問題,無非就是在思考:如何用變量來儲存狀態,以及如何在狀態之間轉移:由一些變量計算出另外一些變量,由當前狀態計算出下一狀態。
基於這些,咱們也就獲得了評判算法的優劣最主要的兩個指標:
若是上述表述還不是很清楚,那咱們仍是舉以前 Fibonacci 的例子來講:
即:
也就是說當前狀態只與前兩個狀態有關,因此對於空間複雜度:咱們只需保存前兩個狀態便可。
這也就很好的解釋了爲何動態規劃並非單純的空間換時間,由於它其實只跟狀態有關。
由一個狀態轉移到另外一狀態所需的計算時間也是常數,故線性增長的狀態,其總的時間複雜度也是線性的。
以上即是動態規劃的核心,即:
狀態的定義及狀態之間的轉移(狀態方程的定義)。
那麼如何定義所謂的「狀態」和「狀態之間的轉移」呢?
咱們引入維基百科的定義:
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,
設 F~k~爲:給定數列中第 k 項結尾的最長遞增子序列的長度
求 F~1~到 F~N~的最大值
是否是上邊這個定義與原問題同樣?
顯然兩者等價,不過明顯第二種定義的方式,咱們找到了子問題。
對於 F~k~來說,F~1~到 F~k-1~都是 F~k~的子問題。
上述新問題的 F~k~ 就叫作 狀態。
F~k~爲數列中第 k 項結尾的 LIS 的長度 即爲狀態的定義。
狀態定義好以後,狀態與狀態之間的關係式,就叫狀態轉移方程。
此題以 F~k~的定義來講:
設 F~k~爲:給定數列中第 k 項結尾的最長遞增子序列的長
思考,狀態之間應該怎麼轉移呢?
還記得咱們以前說的拆分問題不,在這裏一樣咱們能夠沿用這一招,即拆分數據。
若是數列只有一個數呢?那咱們應該返回 1(咱們找到了狀態邊界狀況)。
那麼咱們能夠寫出如下狀態轉移方程:
F~1~ = 1
F~k~ = max ( F~i~ + 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 位以前最長遞增子序列的長度。
那麼咱們的初始狀態是什麼呢?
咱們知道狀態的邊界狀況爲:
F~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[]),這也是動態規劃的本質所在。
關於動態規劃有不少誤區和誤解,好比最多見的可能就是說它是空間換時間,以及搞不清楚它和貪心的區別。
但願這兩篇動態規劃的文章能幫你消除這些誤區,而且更好的理解到動態規劃的本質,理解狀態和狀態方程。
固然,僅僅這兩篇文章想說透動態規劃是遠遠不夠的,因此接下來會具體的講解一些典型問題,好比揹包問題、石子游戲、股票問題等等,但願能幫你在學習算法的道路上少走一些彎路。
若是你們有什麼想了解的算法和題目類型,很是歡迎在評論區留言告訴我,咱們下期見!