談談動態規劃的本質

前言

在上一篇文章動態規劃的文章中,咱們先由 Fibonacci 例子引入到了動態規劃中,而後藉助兌換零錢的例子,分析了動態規劃最主要的三個性質,即:java

  1. 重疊子問題
  2. 最優子結構
  3. 狀態轉移方程

可是動態規劃遠不止這麼簡單。算法

今天這篇文章,讓咱們深刻動態規劃,一窺動態規劃的本質。數組

咱們既然要完全搞清楚動態規劃,那麼一個不可避免的問題就是:學習

遞歸,貪心,記憶化搜索和動態規劃之間到底有什麼不一樣?code

  • 動態規劃遞歸 :只是單純的空間換時間嗎? 並非,斐波那切數列的例子很好的推翻了這個觀點。
  • 動態規劃貪心:只是貪心的增強版嗎?並非,零錢兌換的例子一樣推翻了這個觀點。

那麼,動態規劃的核心究竟是什麼?遞歸

要回答這個問題,咱們不妨先回答下面這個問題:遊戲

到底哪些問題適合用動態規劃即?怎麼鑑定 DP 可解問題?內存

相信當咱們認識到哪些問題能夠用 DP 解決,咱們也就天然找到了 DP 和其它算法思想的區別,也就是動態規劃的核心。ci

動態規劃核心

首先咱們要搞清楚,動態規劃只適用於某一類問題,只是某一類問題的解決方法。get

那麼這「某一類問題」是什麼問題呢?

聊這個以前咱們有必要稍微瞭解下計算機的本質。

基於馮諾依曼體系結構的計算機本質上是一個狀態機,爲何這麼說呢?由於 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

咱們如何進行狀態的定義狀態間轉移的定義呢?

1、狀態的定義

首先咱們應該進行問題的拆分,即進行這個問題子問題的定義。

因此,咱們從新定義一下這個問題:

給定一個數列,長度爲 N,

設 F~k~爲:給定數列中第 k 項結尾的最長遞增子序列的長度

求 F~1~到 F~N~的最大值

是否是上邊這個定義與原問題同樣?

顯然兩者等價,不過明顯第二種定義的方式,咱們找到了子問題。

對於 F~k~來說,F~1~到 F~k-1~都是 F~k~的子問題。

上述新問題的 F~k~ 就叫作 狀態

F~k~爲數列中第 k 項結尾的 LIS 的長度 即爲狀態的定義。

2、狀態轉移方程的定義

狀態定義好以後,狀態與狀態之間的關係式,就叫狀態轉移方程。

此題以 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 項小

你們理解下,是否是這麼回事~

回憶一下咱們是怎麼作的?

  1. 咱們經過拆分問題進行了問題(子問題)的重定義(狀態的定義);
  2. 經過狀態的定義,再結合狀態的邊界狀況,咱們寫出了狀態與狀態之間轉移即狀態轉移方程的定義。

寫出了狀態轉移方程,能夠說到此,動態規劃算法核心的思想咱們已經表達出來了。

剩下的只不過是用記憶化地求解遞推式的方法來解決就好了。

下面咱們嘗試寫出代碼。

代碼

首先咱們定義 dp 數組:

int[] dp = new int[nums.length];

(注意這裏 dp 數組的大小跟上一篇文章兌換零錢的例子有一丟丟不一樣,即這裏沒有+1,你們能夠再點擊這裏看下上一篇文章仔細理解一下。)

那麼這裏 dp 數組的含義就是:

dp[i] 保存的值便是給定數組 i 位以前最長遞增子序列的長度。

那麼咱們的初始狀態是什麼呢?

咱們知道狀態的邊界狀況爲:

F~1~ = 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[]),這也是動態規劃的本質所在。

小結

關於動態規劃有不少誤區和誤解,好比最多見的可能就是說它是空間換時間,以及搞不清楚它和貪心的區別。

但願這兩篇動態規劃的文章能幫你消除這些誤區,而且更好的理解到動態規劃的本質,理解狀態和狀態方程。

固然,僅僅這兩篇文章想說透動態規劃是遠遠不夠的,因此接下來會具體的講解一些典型問題,好比揹包問題、石子游戲、股票問題等等,但願能幫你在學習算法的道路上少走一些彎路。

若是你們有什麼想了解的算法和題目類型,很是歡迎在評論區留言告訴我,咱們下期見!

相關文章
相關標籤/搜索