本文是個人 91 算法第一期的部分講義內容。 91 算法第一期已經接近尾聲,二期的具體時間關注個人公衆號便可,一旦開放,會第一時間在公衆號《力扣加加》通知你們。
動態規劃能夠理解爲是查表的遞歸(記憶化)。那麼什麼是遞歸?什麼是查表(記憶化)?git
定義: 遞歸是指在函數的定義中使用函數自身的方法。github
算法中使用遞歸能夠很簡單地完成一些用循環實現的功能,好比二叉樹的左中右序遍歷。遞歸在算法中有很是普遍的使用,包括如今日趨流行的函數式編程。算法
純粹的函數式編程中沒有循環,只有遞歸。
有意義的遞歸算法會把問題分解成規模縮小的同類子問題,當子問題縮寫到尋常的時候,咱們能夠知道它的解。而後咱們創建遞歸函數之間的聯繫便可解決原問題,這也是咱們使用遞歸的意義。準確來講, 遞歸併非算法,它是和迭代對應的一種編程方法。只不過,咱們一般藉助遞歸去分解問題而已。編程
一個問題要使用遞歸來解決必須有遞歸終止條件(算法的有窮性),也就是順遞歸會逐步縮小規模到尋常。數組
雖然如下代碼也是遞歸,但因爲其沒法結束,所以不是一個有效的算法:瀏覽器
def f(n): return n + f(n - 1)
更多的狀況應該是:緩存
def f(n): if n == 1: return 1 return n + f(n - 1)
一個簡單練習遞歸的方式是將你寫的迭代所有改爲遞歸形式。好比你寫了一個程序,功能是「將一個字符串逆序輸出」,那麼使用迭代將其寫出來會很是容易,那麼你是否可使用遞歸寫出來呢?經過這樣的練習,可讓你逐步適應使用遞歸來寫程序。函數式編程
若是你已經對遞歸比較熟悉了,那麼咱們繼續往下看。函數
遞歸中可能存在這麼多的重複計算,爲了消除這種重複計算,一種簡單的方式就是記憶化遞歸。即一邊遞歸一邊使用「記錄表」(好比哈希表或者數組)記錄咱們已經計算過的狀況,當下次再次碰到的時候,若是以前已經計算了,那麼直接返回便可,這樣就避免了重複計算。而動態規劃中 DP 數組其實和這裏「記錄表」的做用是同樣的。性能
敬請期待個人新書。
使用遞歸函數的優勢是邏輯簡單清晰,缺點是過深的調用會致使棧溢出。這裏我列舉了幾道算法題目,這幾道算法題目均可以用遞歸輕鬆寫出來:
當你已經適應了遞歸的時候,那就讓咱們繼續學習動態規劃吧!
若是你已經熟悉了遞歸的技巧,那麼使用遞歸解決問題很是符合人的直覺,代碼寫起來也比較簡單。這個時候咱們來關注另外一個問題 - 重複計算 。咱們能夠經過分析(能夠嘗試畫一個遞歸樹),能夠看出遞歸在縮小問題規模的同時是否可能會重複計算。 279.perfect-squares 中 我經過遞歸的方式來解決這個問題,同時內部維護了一個緩存來存儲計算過的運算,這麼作能夠減小不少運算。 這其實和動態規劃有着殊途同歸的地方。
小提示:若是你發現並無重複計算,那麼就沒有必要用記憶化遞歸或者動態規劃了。
所以動態規劃就是枚舉因此可能。不過相比暴力枚舉,動態規劃不會有重複計算。所以如何保證枚舉時不重不漏是關鍵點之一。 遞歸因爲使用了函數調用棧來存儲數據,所以若是棧變得很大,那麼會容易爆棧。
咱們結合求和問題來說解一下,題目是給定一個數組,求出數組中全部項的和,要求使用遞歸實現。
代碼:
function sum(nums) { if (nums.length === 0) return 0; if (nums.length === 1) return nums[0]; return nums[0] + sum(nums.slice(1)); }
咱們用遞歸樹來直觀地看一下。
這種作法自己沒有問題,可是每次執行一個函數都有必定的開銷,拿 JS 引擎執行 JS 來講,每次函數執行都會進行入棧操做,並進行預處理和執行過程,因此內存會有額外的開銷,數據量大的時候很容易形成爆棧。
瀏覽器中的 JS 引擎對於代碼執行棧的長度是有限制的,超過會爆棧,拋出異常。
咱們再舉一個重複計算的例子,問題描述:
一我的爬樓梯,每次只能爬 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); }
咱們繼續用一個遞歸樹來直觀感覺如下:
紅色表示重複的計算
能夠看出這裏面有不少重複計算,咱們可使用一個 hashtable 去緩存中間計算結果,從而省去沒必要要的計算。
那麼動態規劃是怎麼解決這個問題呢? 答案也是「查表」,不過區別於遞歸使用函數調用棧,動態規劃一般使用的是 dp 數組,數組的索引一般是問題規模,值一般是遞歸函數的返回值。遞歸是從問題的結果倒推,直到問題的規模縮小到尋常。 動態規劃是從尋常入手, 逐步擴大規模到最優子結構。
若是上面的爬樓梯問題,使用動態規劃,代碼是這樣的:
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; }
之因此能這麼作,是由於爬樓梯問題的狀態轉移方程中當前狀態只和前兩個狀態有關,所以只須要存儲這兩個便可。 動態規劃問題有不少這種討巧的方式,這個技巧叫作滾動數組。
再次強調一下:
能夠看出,用遞歸解決也是同樣的思路
在上面講解的爬樓梯問題中,若是咱們用 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), ... 就是各個獨立的狀態。
不過狀態的定義都有特色的套路。 好比一個字符串的狀態,一般是 dp[i] 表示字符串 s 以 i 結尾的 ....。 好比兩個字符串的狀態,一般是 dpi 表示字符串 s1 以 i 結尾,s2 以 j 結尾的 ....。
固然狀態轉移方程可能不止一個, 不一樣的轉移方程對應的效率也可能截然不同,這個就是比較玄學的話題了,須要你們在作題的過程當中領悟。
搞定了狀態的定義,那麼咱們來看下狀態轉移方程。
爬樓梯問題因爲上第 n 級臺階必定是從 n - 1 或者 n - 2 來的,所以 上第 n 級臺階的數目就是 上 n - 1 級臺階的數目加上 n - 1 級臺階的數目
。
上面的這個理解是核心, 它就是咱們的狀態轉移方程,用代碼表示就是 f(n) = f(n - 1) + f(n - 2)
。
實際操做的過程,有可能題目和爬樓梯同樣直觀,咱們不難想到。也可能隱藏很深或者維度太高。 若是你實在想不到,能夠嘗試畫圖打開思路,這也是我剛學習動態規劃時候的方法。當你作題量上去了,你的題感就會來,那個時候就能夠不用畫圖了。
狀態轉移方程實在是沒有什麼靈丹妙藥,不一樣的題目有不一樣的解法。狀態轉移方程同時也是解決動態規劃問題中最最困難和關鍵的點,你們必定要多多練習,提升題感。接下來,咱們來看下不那麼困難,可是新手疑問比較多的問題 - 如何枚舉狀態。
前面說了如何枚舉狀態,才能不重不漏是枚舉狀態的關鍵所在。
這樣能夠保證不重不漏。
可是實際操做的過程有不少細節好比:
其實這個東西和不少因素有關,很難總結出一個規律,並且我認爲也徹底沒有必要去總結規律。不過這裏我仍是總結了一個關鍵點,那就是:
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 對應關係便可。
動態規劃問題要畫表格,可是有的人不知道爲何要畫,就以爲這個是必然的,必要要畫表格纔是動態規劃。
其實動態規劃本質上是將大問題轉化爲小問題,而後大問題的解是和小問題有關聯的,換句話說大問題能夠由小問題進行計算獲得。這一點是和用遞歸解決同樣的, 可是動態規劃是一種相似查表的方法來縮短期複雜度和空間複雜度。
畫表格的目的就是去不斷推導,完成狀態轉移, 表格中的每個 cell 都是一個小問題
, 咱們填表的過程其實就是在解決問題的過程,
咱們先解決規模爲尋常的狀況,而後根據這個結果逐步推導,一般狀況下,表格的右下角是問題的最大的規模,也就是咱們想要求解的規模。
好比咱們用動態規劃解決揹包問題, 其實就是在不斷根據以前的小問題A[i - 1][j] A[i -1][w - wj]
來詢問:
至於判斷的標準很簡單,就是價值最大,所以咱們要作的就是對於選擇和不選擇兩種狀況分別求價值,而後取最大,最後更新 cell 便可。
其實大部分的動態規劃問題套路都是「選擇」或者「不選擇」,也就是說是一種「選擇題」。 而且大多數動態規劃題目還伴隨着空間的優化(滾動數組),這是動態規劃相對於傳統的記憶化遞歸優點的地方。除了這點優點,就是上文提到的使用動態規劃能夠減小遞歸產生的函數調用棧,所以性能上更好。
本篇文章總結了算法中比較經常使用的兩個方法 - 遞歸和動態規劃。遞歸的話能夠拿樹的題目練手,動態規劃的話則將我上面推薦的刷完,再考慮去刷力扣的動態規劃標籤便可。
你們前期學習動態規劃的時候,能夠先嚐試使用記憶化遞歸解決。而後將其改造爲動態規劃,這樣多練習幾回就會有感受。以後你們能夠練習一下滾動數組,這個技巧頗有用,而且相對來講比較簡單。 比較動態規劃的難點在於枚舉因此狀態(無重複) 和 尋找狀態轉移方程。
若是你只能記住一句話,那麼請記住:遞歸是從問題的結果倒推,直到問題的規模縮小到尋常。 動態規劃是從尋常入手, 逐步擴大規模到最優子結構。
另外,你們能夠去 LeetCode 探索中的 遞歸 I 中進行互動式學習。