動態規劃到底有多難?

動態規劃到底有多難?

動態規劃是一個從其餘行業借鑑過來的詞語。git

它的大概意思先將一件事情分紅若干階段,而後經過階段之間的轉移達到目標。因爲轉移的方向一般是多個,所以這個時候就須要決策選擇具體哪個轉移方向。github

動態規劃所要解決的事情一般是完成一個具體的目標,而這個目標每每是最優解。而且:正則表達式

  1. 階段之間能夠進行轉移,這叫作動態。
  2. 達到一個可行解(目標階段) 須要不斷地轉移,那如何轉移才能達到最優解?這叫規劃。

每一個階段抽象爲狀態(用圓圈來表示),狀態之間可能會發生轉化(用箭頭表示)。能夠畫出相似以下的圖:算法

狀態轉移圖解

那咱們應該作出如何的決策序列才能使得結果最優?換句話說就是每個狀態應該如何選擇到下一個具體狀態,並最終到達目標狀態。這就是動態規劃研究的問題。編程

每次決策實際上不會考慮以後的決策,而只會考慮以前的狀態。 形象點來講,實際上是走一步看一步這種短視思惟。爲何這種短視能夠來求解最優解呢?那是由於:api

  1. 咱們將全部可能的轉移所有模擬了一遍,最後挑了一個最優解。
  2. 無後向性(這個咱們後面再說,先賣個關子)
而若是你沒有模擬全部可能,而直接走了一條最優解,那就是貪心算法了。

沒錯,動態規劃剛開始就是來求最優解的。只不過有的時候順即可以求總的方案數等其餘東西,這實際上是動態規劃的副產物數組

好了,咱們把動態規劃拆成兩部分分別進行解釋,或許你大概知道了動態規劃是一個什麼樣的東西。可是這對你作題並無幫助。那算法上的動態規劃到底是個啥呢?緩存

在算法上,動態規劃和查表的遞歸(也稱記憶化遞歸) 有不少類似的地方。我建議你們先從記憶化遞歸開始學習。本文也先從記憶化遞歸開始,逐步講解到動態規劃。數據結構

記憶化遞歸

那麼什麼是遞歸?什麼是查表(記憶化)?讓咱們慢慢來看。函數式編程

什麼是遞歸?

遞歸是指在函數中調用函數自身的方法。

有意義的遞歸一般會把問題分解成規模縮小的同類子問題,當子問題縮寫到尋常的時候,咱們能夠直接知道它的解。而後經過創建遞歸函數之間的聯繫(轉移)便可解決原問題。

是否是和分治有點像? 分治指的是將問題一分爲多,而後將多個解合併爲一。而這裏並非這個意思。

一個問題要使用遞歸來解決必須有遞歸終止條件(算法的有窮性),也就是說遞歸會逐步縮小規模到尋常。

雖然如下代碼也是遞歸,但因爲其沒法結束,所以不是一個有效的算法:

def f(x):
  return x + f(x - 1)

上面的代碼除非外界干預,不然會永遠執行下去,不會中止。

所以更多的狀況應該是:

def f(n):
  if n == 1: return 1
  return n + f(n - 1)

使用遞歸一般可使代碼短小,有時候也更可讀。算法中使用遞歸能夠很簡單地完成一些用循環不太容易實現的功能,好比二叉樹的左中右序遍歷。

遞歸在算法中有很是普遍的使用,包括如今日趨流行的函數式編程。

遞歸在函數式編程中地位很高。 純粹的函數式編程中沒有循環,只有遞歸。

實際上,除了在編碼上經過函數調用自身實現遞歸。咱們也能夠定義遞歸的數據結構。好比你們所熟知的樹,鏈表等都是遞歸的數據結構。

Node {
    value: any; // 當前節點的值
    children: Array<Node>; // 指向其兒子
}

如上代碼就是一個多叉樹的定義形式,能夠看出 children 就是 Node 的集合類,這就是一種遞歸的數據結構

不只僅是普通的遞歸函數

本文中所提到的記憶化遞歸中的遞歸函數實際上指的是特殊的遞歸函數,即在普通的遞歸函數上知足如下幾個條件:

  1. 遞歸函數不依賴外部變量
  2. 遞歸函數不改變外部變量
知足這兩個條件有什麼用呢?這是由於咱們須要函數給定參數,其返回值也是肯定的。這樣咱們才能記憶化。關於記憶化,咱們後面再講。

若是你們瞭解函數式編程,實際上這裏的遞歸其實嚴格來講是函數式編程中的函數。若是不瞭解也不要緊,這裏的遞歸函數其實就是數學中的函數

咱們來回顧一下數學中的函數:

在一個變化過程當中,假設有兩個變量 x、y,若是對於任意一個 x 都有惟一肯定的一個 y 和它對應,那麼就稱 x 是自變量,y 是 x 的函數。x 的取值範圍叫作這個函數的定義域,相應 y 的取值範圍叫作函數的值域 。

本文所講的全部遞歸都是指的這種數學中的函數。

好比上面的遞歸函數:

def f(x):
  if x == 1: return 1
  return x + f(x - 1)
  • x 就是自變量,x 的全部可能的返回值構成的集合就是定義域。
  • f(x) 就是函數。
  • f(x) 的全部可能的返回值構成的集合就是值域。

自變量也能夠有多個,對應遞歸函數的參數能夠有多個,好比 f(x1, x2, x3)。

經過函數來描述問題,並經過函數的調用關係來描述問題間的關係就是記憶化遞歸的核心內容。

每個動態規劃問題,實際上均可以抽象爲一個數學上的函數。這個函數的自變量集合就是題目的全部取值,值域就是題目要求的答案的全部可能。咱們的目標其實就是填充這個函數的內容,使得給定自變量 x,可以惟一映射到一個值 y。(固然自變量可能有多個,對應遞歸函數參數可能有多個)

解決動態規劃問題能夠當作是填充函數這個黑盒,使得定義域中的數並正確地映射到值域。

數學函數vs動態規劃

遞歸併非算法,它是和迭代對應的一種編程方法。只不過,咱們一般藉助遞歸去分解問題而已。好比咱們定義一個遞歸函數 f(n),用 f(n) 來描述問題。就和使用普通動態規劃 f[n] 描述問題是同樣的,這裏的 f 是 dp 數組。

什麼是記憶化?

爲了你們可以更好地對本節內容進行理解,咱們經過一個例子來切入:

一我的爬樓梯,每次只能爬 1 個或 2 個臺階,假設有 n 個臺階,那麼這我的有多少種不一樣的爬樓梯方法?

思路:

因爲第 n 級臺階必定是從 n - 1 級臺階或者 n - 2 級臺階來的,所以到第 n 級臺階的數目就是 到第 n - 1 級臺階的數目加上到第 n - 1 級臺階的數目

遞歸代碼:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

咱們用一個遞歸樹來直觀感覺如下(每個圓圈表示一個子問題):

重疊子問題

紅色表示重複的計算。即 Fib(N-2) 和 Fib(N-3) 都被計算了兩次,實際上計算一次就夠了。好比第一次計算出了 Fib(N-2) 的值,那麼下次再次須要計算 Fib(N-2)的時候,能夠直接將上次計算的結果返回。之因此能夠這麼作的緣由正是前文提到的咱們的遞歸函數是數學中的函數,也就是說參數必定,那麼返回值也必定不會變,所以下次若是碰到相同的參數,咱們就能夠將上次計算過的值直接返回,而沒必要從新計算。這樣節省的時間就等價於重疊子問題的個數。

以這道題來講,原本須要計算 $2^n$ 次,而若是使用了記憶化,只須要計算 n 次,就是這麼神奇。

代碼上,咱們可使用一個 hashtable 去緩存中間計算結果,從而省去沒必要要的計算。

咱們使用記憶化來改造上面的代碼:

memo = {}
def climbStairs(n):
  if n == 1:return 1
  if n == 2: return 2
  if n in memo: return memo[n]
  ans = func(n - 1) + func(n-2)
  memo[n] = ans
  return ans
climbStairs(10)

這裏我使用了一個名爲 memo 的哈希表來存儲遞歸函數的返回值,其中 key 爲參數,value 爲遞歸函數的返回值。

哈希表示意圖

key 的形式爲 (x, y),表示的是一個元祖。一般動態規劃的參數有多個,咱們就可使用元祖的方式來記憶化。或者也可採起多維數組的形式。對於上圖來講,就可以使用二維數組來表示。

你們能夠經過刪除和添加代碼中的 memo 來感覺一下記憶化的做用。

小結

使用遞歸函數的優勢是邏輯簡單清晰,缺點是過深的調用會致使棧溢出。這裏我列舉了幾道算法題目,這幾道算法題目均可以用遞歸輕鬆寫出來:

  • 遞歸實現 sum
  • 二叉樹的遍歷
  • 走樓梯問題
  • 漢諾塔問題
  • 楊輝三角

遞歸中若是存在重複計算(咱們稱重疊子問題,下文會講到),那就是使用記憶化遞歸(或動態規劃)解題的強有力信號之一。能夠看出動態規劃的核心就是使用記憶化的手段消除重複子問題的計算,若是這種重複子問題的規模是指數或者更高規模,那麼記憶化遞歸(或動態規劃)帶來的收益會很是大。

爲了消除這種重複計算,咱們可以使用查表的方式。即一邊遞歸一邊使用「記錄表」(好比哈希表或者數組)記錄咱們已經計算過的狀況,當下次再次碰到的時候,若是以前已經計算了,那麼直接返回便可,這樣就避免了重複計算。下文要講的動態規劃中 DP 數組其實和這裏「記錄表」的做用是同樣的

若是你剛開始接觸遞歸, 建議你們先去練習一下遞歸再日後看。一個簡單練習遞歸的方式是將你寫的迭代所有改爲遞歸形式。好比你寫了一個程序,功能是「將一個字符串逆序輸出」,那麼使用迭代將其寫出來會很是容易,那麼你是否可使用遞歸寫出來呢?經過這樣的練習,可讓你逐步適應使用遞歸來寫程序。

當你已經適應了遞歸的時候,那就讓咱們繼續學習動態規劃吧!

動態規劃

講了這麼多遞歸和記憶化,終於到了咱們的主角登場了。

動態規劃的基本概念

咱們先來學習動態規劃最重要的兩個概念:最優子結構和無後效性。

其中:

  • 無後效性決定了是否可以使用動態規劃來解決。
  • 最優子結構決定了具體如何解決。

最優子結構

動態規劃經常適用於有重疊子問題和最優子結構性質的問題。前面講了重疊子問題,那麼最優子結構是什麼?這是我從維基百科找的定義:

若是問題的最優解所包含的子問題的解也是最優的,咱們就稱該問題具備最優子結構性質(即知足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。

舉個例子:若是考試中的分數定義爲 f,那麼這個問題就能夠被分解爲語文,數學,英語等子問題。顯然子問題最優的時候,總分這個大的問題的解也是最優的。

再好比 01 揹包問題:定義 f(weights, values, capicity)。若是咱們想要求 f([1,2,3], [2,2,4], 10) 的最優解。咱們能夠將其劃分爲以下子問題:

  • 將第三件物品裝進揹包,也就是 f([1,2], [2,2], 10)
  • 不將第三件物品裝進揹包,也就是 f([1,2,3], [2,2,4], 9)
顯然這兩個問題仍是複雜,咱們須要進一步拆解。不過,這裏不是講如何拆解的。

原問題 f([1,2,3], [2,2,4], 10) 等於以上兩個子問題的最大值。只有兩個子問題都是最優的時候總體纔是最優的,這是由於子問題之間不會相互影響。

無後效性

即子問題的解一旦肯定,就再也不改變,不受在這以後、包含它的更大的問題的求解決策影響。

繼續以上面兩個例子來講。

  • 數學考得高不能影響英語(現實其實可能影響,好比時間必定,投入英語多,其餘科目就少了)。
  • 揹包問題中 f([1,2,3], [2,2,4], 10) 選擇是否拿第三件物品,不該該影響是否拿前面的物品。好比題目規定了拿了第三件物品以後,第二件物品的價值就會變低或變高)。這種狀況就不知足無後向性。

動態規劃三要素

狀態定義

動態規劃的中心點是什麼?若是讓我說的話,那就是定義狀態

動態規劃解題的第一步就是定義狀態。定義好了狀態,就能夠畫出遞歸樹,聚焦最優子結構寫轉移方程就行了,所以我才說狀態定義是動態規劃的核心,動態規劃問題的狀態確實不容易看出。

可是一旦你能把狀態定義好了,那就能夠順藤摸瓜畫出遞歸樹,畫出遞歸樹以後就聚焦最優子結構就好了。可是可以畫出遞歸樹的前提是:對問題進行劃分,專業點來講就是定義狀態。那怎麼才能定義出狀態呢?

好在狀態的定義都有特色的套路。 好比一個字符串的狀態,一般是 dp[i] 表示字符串 s 以 i 結尾的 ....。 好比兩個字符串的狀態,一般是 dpi 表示字符串 s1 以 i 結尾,s2 以 j 結尾的 ....。

也就是說狀態的定義一般有不一樣的套路,你們能夠在作題的過程當中進行學習和總結。可是這種套路很是多,那怎麼搞定呢?

說實話,只能多練習,在練習的過程當中總結套路。具體的套路參考後面的動態規劃的題型 部份內容。以後你們就能夠針對不一樣的題型,去思考大概的狀態定義方向。

兩個例子

關於狀態定義,真的很是重要,以致於我將其列爲動態規劃的核心。所以我以爲有必要舉幾個例子來進行說明。我直接從力扣的動態規劃專題中抽取前兩道給你們講講。

力扣動態規劃專題

第一道題:《5. 最長迴文子串》難度中等

給你一個字符串 s,找到 s 中最長的迴文子串。

 

示例 1:

輸入:s = "babad"
輸出:"bab"
解釋:"aba" 一樣是符合題意的答案。
示例 2:

輸入:s = "cbbd"
輸出:"bb"
示例 3:

輸入:s = "a"
輸出:"a"
示例 4:

輸入:s = "ac"
輸出:"a"
 

提示:

1 <= s.length <= 1000
s 僅由數字和英文字母(大寫和/或小寫)組成

這道題入參是一個字符串,那咱們要將其轉化爲規模更小的子問題,那無疑就是字符串變得更短的問題,臨界條件也應該是空字符串或者一個字符這樣。

所以:

  • 一種定義狀態的方式就是 f(s1),含義是字符串 s1 的最長迴文子串,其中 s1 就是題目中的字符串 s 的子串,那麼答案就是 f(s)。
  • 因爲規模更小指的是字符串變得更短,而描述字符串咱們也能夠用兩個變量來描述,這樣實際上還省去了開闢字符串的開銷。兩個變量能夠是起點索引 + 子串長度,也能夠是終點索引 + 子串長度,也能夠是起點座標 + 終點座標。隨你喜歡,這裏我就用起點座標 + 終點座標。那麼狀態定義就是 f(start, end),含義是子串 s[start:end+1]的最長迴文子串,那麼答案就是 f(0, len(s) - 1)
s[start:end+1] 指的是包含 s[start],而不包含 s[end+1] 的連續子串。

這無疑是一種定義狀態的方式,可是一旦咱們這樣去定義就會發現:狀態轉移方程會變得難以肯定(實際上不少動態規劃都有這個問題,好比最長上升子序列問題)。那究竟如何定義狀態呢?我會在稍後的狀態轉移方程繼續完成這道題。咱們先來看下一道題。

第二道題:《10. 正則表達式匹配》難度困難

給你一個字符串 s 和一個字符規律 p,請你來實現一個支持 '.' 和 '*' 的正則表達式匹配。

'.' 匹配任意單個字符
'*' 匹配零個或多個前面的那一個元素
所謂匹配,是要涵蓋 整個 字符串 s的,而不是部分字符串。

 
示例 1:

輸入:s = "aa" p = "a"
輸出:false
解釋:"a" 沒法匹配 "aa" 整個字符串。
示例 2:

輸入:s = "aa" p = "a*"
輸出:true
解釋:由於 '*' 表明能夠匹配零個或多個前面的那一個元素, 在這裏前面的元素就是 'a'。所以,字符串 "aa" 可被視爲 'a' 重複了一次。
示例 3:

輸入:s = "ab" p = ".*"
輸出:true
解釋:".*" 表示可匹配零個或多個('*')任意字符('.')。
示例 4:

輸入:s = "aab" p = "c*a*b"
輸出:true
解釋:由於 '*' 表示零個或多個,這裏 'c' 爲 0 個, 'a' 被重複一次。所以能夠匹配字符串 "aab"。
示例 5:

輸入:s = "mississippi" p = "mis*is*p*."
輸出:false
 

提示:

0 <= s.length <= 20
0 <= p.length <= 30
s 可能爲空,且只包含從 a-z 的小寫字母。
p 可能爲空,且只包含從 a-z 的小寫字母,以及字符 . 和 *。
保證每次出現字符 * 時,前面都匹配到有效的字符

這道題入參有兩個, 一個是 s,一個是 p。沿用上面的思路,咱們有兩種定義狀態的方式。

  • 一種定義狀態的方式就是 f(s1, p1),含義是 p1 是否可匹配字符串 s1,其中 s1 就是題目中的字符串 s 的子串,p1 就是題目中的字符串 p 的子串,那麼答案就是 f(s, p)。
  • 另外一種是 f(s_start, s_end, p_start, p_end),含義是子串 p1[p_start:p_end+1] 是否能夠匹配字符串 s[s_start:s_end+1],那麼答案就是 f(0, len(s) - 1, 0, len(p) - 1)

而這道題實際上咱們也可採用更簡單的狀態定義方式,不過基本思路都是差很少的。我仍舊賣個關子,後面講轉移方程再揭曉。

搞定了狀態定義,你會發現時間空間複雜度都變得很明顯了。這也是爲啥我反覆強調狀態定義是動態規劃的核心。

時間空間複雜度怎麼個明顯法了呢?

首先空間複雜度,我剛纔說了動態規劃其實就是查表的暴力法,所以動態規劃的空間複雜度打底就是表的大小。再直白一點就是上面的哈希表 memo 的大小。而 memo的大小基本就是狀態的個數。狀態個數是多少呢? 這不就取決你狀態怎麼定義了麼?好比上面的 f(s1, p1) 。狀態的多少是多少呢?很明顯就是每一個參數的取值範圍大小的笛卡爾積。s1 的全部可能取值有 len(s) 種,p1 的全部可能有 len(p)種,那麼總的狀態大小就是 len(s) * len(p)。那空間複雜度是 $O(m * n)$,其中 m 和 n 分別爲 s 和 p 的大小。

我說空間複雜度打底是狀態個數, 這裏暫時先不考慮狀態壓縮的狀況。

其次是時間複雜度。時間複雜度就比較難說了。可是因爲咱們不管如何都要枚舉全部狀態,所以時間複雜度打底就是狀態總數。以上面的狀態定義方式,時間複雜度打底就是$O(m * n)$。

若是你枚舉每個狀態都須要和 s 的每個字符計算一下,那時間複雜度就是 $O(m^2 * n)$。

以上面的爬樓梯的例子來講,咱們定義狀態 f(n) 表示到達第 n 級臺階的方法數,那麼狀態總數就是 n,空間複雜度和時間複雜度打底就是 $n$ 了。(仍然不考慮滾動數組優化)

再舉個例子:62. 不一樣路徑

一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記爲「Start」 )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記爲「Finish」)。

問總共有多少條不一樣的路徑?

這道題是和上面的爬樓梯很像,只不過從一維變成了二維,我把它叫作二維爬樓梯,相似的換皮題還不少,你們慢慢體會。

這道題我定義狀態爲 f(i, j) 表示機器人到達點 (i,j) 的總的路徑數。那麼狀態總數就是 i 和 j 的取值的笛卡爾積,也就是 m * n 。

二維爬樓梯

總的來講,動態規劃的空間和時間複雜度打底就是狀態的個數,而狀態的個數一般是參數的笛卡爾積,這是由動態規劃的無後向性決定的。

臨界條件是比較最容易的

當你定義好了狀態,剩下就三件事了:

  1. 臨界條件
  2. 狀態轉移方程
  3. 枚舉狀態

在上面講解的爬樓梯問題中,若是咱們用 f(n) 表示爬 n 級臺階有多少種方法的話,那麼:

f(1) 與 f(2) 就是【邊界】
f(n) = f(n-1) + f(n-2) 就是【狀態轉移公式】

我用動態規劃的形式表示一下:

dp[0] 與 dp[1] 就是【邊界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【狀態轉移方程】

能夠看出記憶化遞歸和動態規劃是多麼的類似。

實際上臨界條件相對簡單,你們只有多刷幾道題,裏面就有感受。困難的是找到狀態轉移方程和枚舉狀態。這兩個核心點的都創建在已經抽象好了狀態的基礎上。好比爬樓梯的問題,若是咱們用 f(n) 表示爬 n 級臺階有多少種方法的話,那麼 f(1), f(2), ... 就是各個獨立的狀態

搞定了狀態的定義,那麼咱們來看下狀態轉移方程。

狀態轉移方程

動態規劃中當前階段的狀態每每是上一階段狀態和上一階段決策的結果。這裏有兩個關鍵字,分別是 :

  • 上一階段狀態
  • 上一階段決策

也就是說,若是給定了第 k 階段的狀態 s[k] 以及決策 choice(s[k]),則第 k+1 階段的狀態 s[k+1] 也就徹底肯定,用公式表示就是:s[k] + choice(s[k]) -> s[k+1], 這就是狀態轉移方程。須要注意的是 choice 可能有多個,所以每一個階段的狀態 s[k+1]也會有多個。

繼續以上面的爬樓梯問題來講,爬樓梯問題因爲上第 n 級臺階必定是從 n - 1 或者 n - 2 來的,所以 上第 n 級臺階的數目就是 上 n - 1 級臺階的數目加上 n - 1 級臺階的數目

上面的這個理解是核心, 它就是咱們的狀態轉移方程,用代碼表示就是 f(n) = f(n - 1) + f(n - 2)

實際操做的過程,有可能題目和爬樓梯同樣直觀,咱們不難想到。也可能隱藏很深或者維度太高。 若是你實在想不到,能夠嘗試畫圖打開思路,這也是我剛學習動態規劃時候的方法。當你作題量上去了,你的題感就會來,那個時候就能夠不用畫圖了。

好比咱們定義了狀態方程,據此咱們定義初始狀態和目標狀態。而後聚焦最優子結構,思考每個狀態究竟如何進行擴展使得離目標狀態愈來愈近

以下圖所示:

狀態轉移圖解

理論差很少先這樣,接下來來幾個實戰消化一下。

ok,接下來是解密環節。上面兩道題咱們都沒有講轉移方程,咱們在這裏補上。

第一道題:《5. 最長迴文子串》難度中等。上面咱們的兩種狀態定義都很差,而我能夠在上面的基礎上稍微變更一點就可使得轉移方程變得很是好寫。這個技巧在不少動態題目都有體現,好比最長上升子序列等,須要你們掌握

以上面提到的 f(start, end) 來講,含義是子串 s[start:end+1]的最長迴文子串。表示方式咱們不變,只是將含義變成子串 s[start:end+1]的最長迴文子串,且必須包含 start 和 end。通過這樣的定義,實際上咱們也沒有必要定義 f(start, end)的返回值是長度了,而僅僅是布爾值就好了。若是返回 true, 則最長迴文子串就是 end - start + 1,不然就是 0。

這樣轉移方程就能夠寫爲:

f(i,j)=f(i+1,j−1) and s[i] == s[j]

第二道題:《10. 正則表達式匹配》難度困難。

以咱們分析的 f(s_start, s_end, p_start, p_end) 來講,含義是子串 p1[p_start:p_end+1] 是否能夠匹配字符串 s[s_start:s_end+1]。

實際上,咱們能夠定義更簡單的方式,那就是 f(s_end, p_end),含義是子串 p1[:p_end+1] 是否能夠匹配字符串 s[:s_end+1]。也就是說固定起點爲索引 0,這一樣也是一個很常見的技巧,請務必掌握。

這樣轉移方程就能夠寫爲:

  1. if p[j] 是小寫字母,是否匹配取決於 s[i] 是否等於 p[j]:

$$ f(i,j)=\left\{ \begin{aligned} f(i-1, j-1) & & s[i] == p[j] \\ false & & s[i] != p[j] \\ \end{aligned} \right. $$

  1. if p[j] == '.',必定可匹配:
f(i,j)=f(i-1,j−1)
  1. if p[j] == '*',表示 p 能夠匹配 s 第 j−1 個字符匹配任意次:

$$ f(i,j)=\left\{ \begin{aligned} f(i-1, j) & & match & & 1+ & & times \\ f(i, j - 2) & & match & & 0 & & time \\ \end{aligned} \right. $$

相信你能分析到這裏,寫出代碼就不是難事了。具體代碼可參考個人力扣題解倉庫,咱就不在這裏講了。

注意到了麼?全部的狀態轉移方程我都使用了上述的數學公式來描述。沒錯,全部的轉移方程均可以這樣描述。我建議你們作每一道動態規劃題目都寫出這樣的公式,起初你可能以爲很煩麻煩。不過相信我,你堅持下去,會發現本身慢慢變強大。就好像我強烈建議你每一道題都分析好複雜度同樣。動態規劃不只要搞懂轉移方程,還要本身像我那樣完整地用數學公式寫出來。

是否是以爲狀態轉移方程寫起來麻煩?這裏我給你們介紹一個小技巧,那就是使用 latex,latex 語法能夠方便地寫出這樣的公式。另外西法還貼心地寫了一鍵生成動態規劃轉移方程公式的功能,幫助你們以最快速度生成公訴處。 插件地址:https://leetcode-pp.github.io...

插件用法

狀態轉移方程實在是沒有什麼靈丹妙藥,不一樣的題目有不一樣的解法。狀態轉移方程同時也是解決動態規劃問題中最最困難和關鍵的點,你們必定要多多練習,提升題感。接下來,咱們來看下不那麼困難,可是新手疑問比較多的問題 - 如何枚舉狀態

固然狀態轉移方程可能不止一個, 不一樣的轉移方程對應的效率也可能截然不同,這個就是比較玄學的話題了,須要你們在作題的過程當中領悟。

如何枚舉狀態

前面說了如何枚舉狀態,才能不重不漏是枚舉狀態的關鍵所在。

  • 若是是一維狀態,那麼咱們使用一層循環能夠搞定。
for i in range(1, n + 1):
  pass

一維狀態

  • 若是是兩維狀態,那麼咱們使用兩層循環能夠搞定。
for i in range(1, m + 1):
  for j in range(1, n + 1):
    pass

二維狀態

  • 。。。

可是實際操做的過程有不少細節好比:

  • 一維狀態我是先枚舉左邊的仍是右邊的?(從左到右遍歷仍是從右到左遍歷)
  • 二維狀態我是先枚舉左上邊的仍是右上的,仍是左下的仍是右下的?
  • 裏層循環和外層循環的位置關係(能夠互換麼)
  • 。。。

其實這個東西和不少因素有關,很難總結出一個規律,並且我認爲也徹底沒有必要去總結規律。

不過這裏我仍是總結了一個關鍵點,那就是:

  • 若是你沒有使用滾動數組的技巧,那麼遍歷順序取決於狀態轉移方程。好比:
for i in range(1, n + 1):
  dp[i] = dp[i - 1] + 1

那麼咱們就須要從左到右遍歷,緣由很簡單,由於 dp[i] 依賴於 dp[i - 1],所以計算 dp[i] 的時候, dp[i - 1] 須要已經計算好了。

二維的也是同樣的,你們能夠試試。
  • 若是你使用了滾動數組的技巧,則怎麼遍歷均可以,可是不一樣的遍歷意義一般不不一樣的。好比我將二維的壓縮到了一維:
for i in range(1, n + 1):
  for j in range(1, n + 1):
    dp[j] = dp[j - 1] + 1;

這樣是能夠的。 dp[j - 1] 實際上指的是壓縮前的 dpi

而:

for i in range(1, n + 1):
  #  倒着遍歷
  for j in range(n, 0, -1):
    dp[j] = dp[j - 1] + 1;

這樣也是能夠的。 可是 dp[j - 1] 實際上指的是壓縮前的 dpi - 1。所以實際中採用怎麼樣的遍歷手段取決於題目。我特地寫了一個 【徹底揹包問題】套路題(1449. 數位成本和爲目標值的最大數字 文章,經過一個具體的例子告訴你們不一樣的遍歷有什麼實際不一樣,強烈建議你們看看,並順手給個三連。

  • 關於裏外循環的問題,其實和上面原理相似。

這個比較微妙,你們能夠參考這篇文章理解一下 0518.coin-change-2

小結

關於如何肯定臨界條件一般是比較簡單的,多作幾個題就能夠快速掌握。

關於如何肯定狀態轉移方程,這個其實比較困難。 不過所幸的是,這些套路性比較強, 好比一個字符串的狀態,一般是 dp[i] 表示字符串 s 以 i 結尾的 ....。 好比兩個字符串的狀態,一般是 dpi 表示字符串 s1 以 i 結尾,s2 以 j 結尾的 ....。 這樣遇到新的題目能夠往上套, 實在套不出那就先老實畫圖,不斷觀察,提升題感。

關於如何枚舉狀態,若是沒有滾動數組, 那麼根據轉移方程決定如何枚舉便可。 若是用了滾動數組,那麼要注意壓縮後和壓縮前的 dp 對應關係便可。

動態規劃 VS 記憶化遞歸

上面咱們用記憶化遞歸的問題巧妙地解決了爬樓梯問題。 那麼動態規劃是怎麼解決這個問題呢?

答案也是「查表」,咱們日常寫的 dp table 就是表,其實這個 dp table 和上面的 memo 沒啥差異。

而通常咱們寫的 dp table,數組的索引一般對應記憶化遞歸的函數參數,值對應遞歸函數的返回值。

看起來二者彷佛沒任何思想上的差別,區別的僅僅是寫法?? 沒錯。不過這種寫法上的差別還會帶來一些別的相關差別,這點咱們以後再講。

若是上面的爬樓梯問題,使用動態規劃,代碼是怎麼樣的呢?咱們來看下:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

你們如今不會也不要緊,咱們將前文的遞歸的代碼稍微改造一下。其實就是將函數的名字改一下:

function dp(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return dp(n - 1) + dp(n - 2);
}

通過這樣的變化。咱們將 dp[n] 和 dp(n) 對比看,這樣是否是有點理解了呢? 其實他們的區別只不過是遞歸用調用棧枚舉狀態, 而動態規劃使用迭代枚舉狀態。

若是須要多個維度枚舉,那麼記憶化遞歸內部也可使用迭代進行枚舉,好比最長上升子序列問題。

動態規劃的查表過程若是畫成圖,就是這樣的:

動態規劃查表

虛線表明的是查表過程

滾動數組優化

爬樓梯咱們並無必要使用一維數組,而是藉助兩個變量來實現的,空間複雜度是 O(1)。代碼:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;

  let a = 1;
  let b = 2;
  let temp;

  for (let i = 3; i <= n; i++) {
    temp = a + b;
    a = b;
    b = temp;
  }

  return temp;
}

之因此能這麼作,是由於爬樓梯問題的狀態轉移方程中當前狀態只和前兩個狀態有關,所以只須要存儲這兩個便可。 動態規劃問題有不少這種討巧的方式,這個技巧叫作滾動數組。

這道題目是動態規劃中最簡單的問題了,由於僅涉及到單個因素的變化,若是涉及到多個因素,就比較複雜了,好比著名的揹包問題,挖金礦問題等。

對於單個因素的,咱們最多隻須要一個一維數組便可,對於如揹包問題咱們須要二維數組等更高緯度。

回答上面的問題:記憶化遞歸和動態規劃除了一個用遞歸一個用迭代,其餘沒差異。那二者有啥區別呢?我以爲最大的區別就是記憶化遞歸沒法使用滾動數組優化(不信你用上面的爬樓梯試一下),記憶化調用棧的開銷比較大(複雜度不變,你能夠認爲空間複雜度常數項更大),不過幾乎不至於 TLE 或者 MLE。所以個人建議就是沒空間優化需求直接就記憶化,不然用迭代 dp

再次強調一下:

  • 若是說遞歸是從問題的結果倒推,直到問題的規模縮小到尋常。 那麼動態規劃就是從尋常入手, 逐步擴大規模到最優子結構。
  • 記憶化遞歸和動態規劃沒有本質不一樣。都是枚舉狀態,並根據狀態直接的聯繫逐步推導求解。
  • 動態規劃性能一般更好。 一方面是遞歸的棧開銷,一方面是滾動數組的技巧。

動態規劃的基本類型

  • 揹包 DP(這個咱們專門開了一個專題講)
  • 區間 DP

區間類動態規劃是線性動態規劃的擴展,它在分階段地劃分問題時,與階段中元素出現的順序和由前一階段的哪些元素合併而來有很大的關係。令狀態 $f(i,j)$ 表示將下標位置 $i$ 到 $j$ 的全部元素合併能得到的價值的最大值,那麼 $f(i,j)=\max\{f(i,k)+f(k+1,j)+cost\}$,$cost$ 爲將這兩組元素合併起來的代價。

區間 DP 的特色:

合併:即將兩個或多個部分進行整合,固然也能夠反過來;

特徵:能將問題分解爲能兩兩合併的形式;

求解:對整個問題設最優值,枚舉合併點,將問題分解爲左右兩個部分,最後合併兩個部分的最優值獲得原問題的最優值。

推薦兩道題:

關於狀壓 DP 能夠參考下我以前寫過的一篇文章: 狀壓 DP 是什麼?這篇題解帶你入門

  • 數位 DP

數位 DP 一般是這:給定一個閉區間 ,讓你求這個區間中知足某種條件的數的總數。

推薦一道題 Increasing-Digits

  • 計數 DP 和 機率 DP

這兩個我就很少說。由於沒啥規律。

之因此列舉計數 DP 是由於兩個緣由:

  1. 讓你們知道確實有這個題型。
  2. 計數是動態規劃的副產物。

機率 DP 比較特殊,機率 DP 的狀態轉移公式通常是說一個狀態有多大的機率從某一個狀態轉移過來,更像是指望的計算,所以也叫指望 DP。

更多題目類型以及推薦題目見刷題插件的學習路線。插件獲取方式:公衆號力扣加加回復插件。

何時用記憶化遞歸?

  • 從數組兩端同時進行遍歷的時候使用記憶化遞歸方便,其實也就是區間 DP(range dp)。好比石子游戲,再好比這道題 https://binarysearch.com/prob...

若是區間 dp 你的遍歷方式大概須要這樣:

class Solution:
    def solve(self, s):
        n = len(s)
        dp = [[0] * n for _ in range(n)]
        # 右邊界倒序遍歷
        for i in range(n - 1, -1, -1):
            # 左邊界正序遍歷
            for j in range(i + 1, n):
                # do something
        return  dp[0][m-1] # 通常都是使用這個區間做爲答案

若是使用記憶化遞歸則不需考慮遍歷方式的問題。

代碼:

class Solution:
    def solve(self, s):
        @lru_cache(None)
        def helper(l, r):
            if l >= r:
                return 0

            if s[l] == s[r]:
                return helper(l + 1, r - 1)

            return 1 + min(helper(l + 1, r), helper(l, r - 1))

        return helper(0, len(s) - 1)
  • 選擇 比較離散的時候,使用記憶化遞歸更好。好比馬走棋盤。

那何時不用記憶化遞歸呢?答案是其餘狀況都不用。由於普通的 dp table 有一個重要的功能,這個功能記憶化遞歸是沒法代替的,那就是滾動數組優化。若是你須要對空間進行優化,那必定要用 dp table。

熱身開始

理論知識已經差很少了,咱們拿一道題來試試手。

咱們以一個很是經典的揹包問題來練一下手。

題目:322. 零錢兌換

給定不一樣面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算能夠湊成總金額所需的最少的硬幣個數。若是沒有任何一種硬幣組合能組成總金額,返回 -1。

你能夠認爲每種硬幣的數量是無限的。

 

示例 1:

輸入:coins = [1, 2, 5], amount = 11
輸出:3
解釋:11 = 5 + 5 + 1

這道題的參數有兩個,一個是 coins,一個是 amount。

咱們能夠定義狀態爲 f(i, j) 表示用 coins 的前 i 項找 j 元須要的最少硬幣數。那麼答案就是 f(len(coins) - 1, amount)。

由組合原理,coins 的全部選擇狀態是 $2^n$。狀態總數就是 i 和 j 的取值的笛卡爾積,也就是 2^len(coins) * (amount + 1)。

減 1 是由於存在 0 元的狀況。

明確了這些,咱們須要考慮的就是狀態如何轉移,也就是如何從尋常轉移到 f(len(coins) - 1, amount)。

如何肯定狀態轉移方程?咱們須要:

  • 聚焦最優子結構
  • 作選擇,在選擇中取最優解(若是是計數 dp 則進行計數)

對於這道題來講,咱們的選擇有兩種:

  • 選擇 coins[i]
  • 不選擇 coins[i]

這無疑是完備的。只不過僅僅是對 coins 中的每一項進行選擇與不選擇,這樣的狀態數就已是 $2^n$ 了,其中 n 爲 coins 長度。

若是僅僅是這樣枚舉確定會超時,由於狀態數已是指數級別了。

而這道題的核心在於 coins[i] 選擇與否其實沒有那麼重要,重要的實際上是選擇的 coins 一共有多少錢

所以咱們能夠定義 f(i, j) 表示選擇了 coins 的前 i 項(怎麼選的不關心),且組成 j 元須要的最少硬幣數。

舉個例子來講,好比 coins = [1,2,3] 。那麼選擇 [1,2] 和 選擇 [3] 雖然是不同的狀態,可是咱們壓根不關心。由於這二者沒有區別,咱們仍是誰對結果貢獻大就 pick 誰。

以 coins = [1,2,3], amount = 6 來講,咱們能夠畫出以下的遞歸樹。

(圖片來自https://leetcode.com/problems...

所以轉移方程就是 min(dp[i][j], dp[i-1][j - coins[j]] + 1),含義就是: min(不選擇 coins[j], 選擇 coins[j]) 所需最少的硬幣數。

用公式表示就是:

$$ dp[i]=\left\{ \begin{aligned} min(dp[i][j], dp[i-1][j - coins[j]] + 1) & & j >= coins[j] \\ amount + 1 & & j < coins[j] \\ \end{aligned} \right. $$

amount 表示無解。由於硬幣的面額都是正整數,不可能存在一種須要 amount + 1 枚硬幣的方案。

代碼

記憶化遞歸:

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        @lru_cache(None)
        def dfs(amount):
            if amount < 0: return float('inf')
            if amount == 0: return 0
            ans = float('inf')
            for coin in coins:
                ans = min(ans, 1 + dfs(amount - coin))
            return ans
        ans = dfs(amount)
        return -1 if ans == float('inf') else ans

二維 dp:

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        if amount < 0:
            return - 1
        dp = [[amount + 1 for _ in range(len(coins) + 1)]
              for _ in range(amount + 1)]
        # 初始化第一行爲0,其餘爲最大值(也就是amount + 1)

        for j in range(len(coins) + 1):
            dp[0][j] = 0

        for i in range(1, amount + 1):
            for j in range(1, len(coins) + 1):
                if i - coins[j - 1] >= 0:
                    dp[i][j] = min(
                        dp[i][j - 1], dp[i - coins[j - 1]][j] + 1)
                else:
                    dp[i][j] = dp[i][j - 1]

        return -1 if dp[-1][-1] == amount + 1 else dp[-1][-1]

dpi 依賴於dp[i][j - 1]dp[i - coins[j - 1]][j] + 1) 這是一個優化的信號,咱們能夠將其優化到一維。

一維 dp(滾動數組優化):

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        dp = [amount + 1] * (amount + 1)
        dp[0] = 0

        for j in range(len(coins)):
            for i in range(1, amount + 1):
                if i >= coins[j]:
                    dp[i] = min(dp[i], dp[i - coins[j]] + 1)

        return -1 if dp[-1] == amount + 1 else dp[-1]

推薦練習題目

最後推薦幾道題目給你們,建議你們分別使用記憶化遞歸和動態規劃來解決。若是使用動態規劃,則儘量使用滾動數組優化空間。

總結

本篇文章總結了算法中比較經常使用的兩個方法 - 遞歸和動態規劃。遞歸的話能夠拿樹的題目練手,動態規劃的話則將我上面推薦的刷完,再考慮去刷力扣的動態規劃標籤便可。

你們前期學習動態規劃的時候,能夠先嚐試使用記憶化遞歸解決。而後將其改造爲動態規劃,這樣多練習幾回就會有感受。以後你們能夠練習一下滾動數組,這個技巧頗有用,而且相對來講比較簡單。

動態規劃的核心在於定義狀態,定義好了狀態其餘都是水到渠成。

動態規劃的難點在於枚舉全部狀態(不重不漏)尋找狀態轉移方程

參考

  • oi-wiki - dp 這個資料推薦你們學習,很是全面。只不過更適合有必定基礎的人,你們能夠配合本講義食用哦。

另外,你們能夠去 LeetCode 探索中的 遞歸 I 中進行互動式學習。

相關文章
相關標籤/搜索