談談動態規劃的本質

 

 

前言

 

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

 

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

 

但是動態規劃遠不止這麼簡單。

 

今天這篇文章,讓我們深入動態規劃,一窺動態規劃的本質。

 

我們既然要徹底搞清楚動態規劃,那麼一個不可避免的問題就是:

 

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

 

  • 動態規劃遞歸 :只是單純的空間換時間嗎? 並不是,斐波那切數列的例子很好的推翻了這個觀點。

  • 動態規劃貪心:只是貪心的加強版嗎?並不是,零錢兌換的例子同樣推翻了這個觀點。

 

那麼,動態規劃的核心到底是什麼?

 

要回答這個問題,我們不妨先回答下面這個問題:

 

到底哪些問題適合用動態規劃即?怎麼鑑定 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 項小

 

大家理解下,是不是這麼回事~

 

回憶一下我們是怎麼做的?

 

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

 

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

 

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

 

下面我們嘗試寫出代碼。

 

代碼

 

首先我們定義 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[]),這也是動態規劃的本質所在。

 

小結

 

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

 

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

 

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

 

如果大家有什麼想了解的算法和題目類型,非常歡迎在評論區留言告訴我,我們下期見!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章