前言
時隔這么久才發了這篇早在三周前就應該發出來的課堂筆記,由於懶癌犯了,加上各種原因,實在是應該反思。好多課堂上老師說的重要的東西可能細節上有一些急記不住了,但是幸好做了一些筆記,還能夠讓自己回想起來。動態規划算是我的一道大坎了,本科的時候就基本沒有學過,研一的時候老師上課也是吃力的跟上了老師的步伐,其實那個時候老師總結的還是挺好的:把動態規划的題目都分成了一維動規、二維遍歷、二維不遍歷等一系列的問題。這次聽了老師的課程,覺得還是需要更加集中的去把各種題進行一個分類吧,然后有針對的去准備,雖然據說這一塊在面試中也不容易考到,但是畢竟是難點,還是需要好好准備一下的。因為在dp這個方面,我算是一個比較新手的新手,所以大家可以當作一起入門內容來看這篇博客。
Outline:
- 了解動態規划
- Triangle
- 動態規划的適用范圍
- 坐標型動態規划
- Minimum Path Sum
- Climbing Stairs
- Jump Game
- Longest Increasing Subsequence
-
單序列動態規划
- Word Break
- 雙序列動態規划
- Longest Common Subsequence
- 總結
課堂筆記
1.了解動態規划
就不過多的做解釋了,直接來一個經典的題目。
給定一個數字三角形,找到從頂部到底部的最小路徑和。每一步可以移動到下面一行的相鄰數字上。
樣例
比如,給出下列數字三角形:
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
從頂到底部的最小路徑和為11 ( 2 + 3 + 5 + 1 = 11)。
拿到這個題目,如果不知道動態規划的話,想必大家第一反應就是遍歷全部的路徑,然后求出最小的值就可以。這個想法的話,跟二叉樹的遍歷有一點類似,但是大體還是不一樣的,因為二叉樹在分岔以后就各自保留子樹,而這個題的不能考慮為二叉樹的情況,這個結構可以畫成如下的情況比較直觀:
[2],
[3,4],
[6,5,7],
[4,1,8,3]
其中,2只能移動到3、4,3只能移動到6、5,同理,5只能移動到1,8……所以總結下來就是:當前的元素只能移動到下方和右下方的元素,即(i,j)只能移動到(i+1,j)或(i+1,j+1)。這樣的話,DFS來做搜索就好了。
int bestans = INT_MAX; void travers(int i, int j, int sum, vector<vector<int> > &triangle) { if (i == triangle.size()) { // 遍歷到最底層 bestans = bestans > sum ? sum : bestans; return; } travers(i + 1, j, sum + triangle[i][j], triangle); travers(i + 1, j + 1, sum + triangle[i][j], triangle); } int minimumTotal(vector<vector<int> > &triangle) { // write your code here travers(0, 0, 0, triangle); return bestans; }
這種算是最暴力的方法了,顯然時間復雜度是0(2^n)的,因為每層的每個元素都有兩個選擇。我就沒有在lintcode上提高了,顯然是LTE的。這時候就需要回顧我們之前學過的分治法了,也可以用分治的方法分別求出下方和右下方兩種選擇的和,然后來求出最小的。直接把代碼貼出來吧(Bug Free):
int DivideConquer(int i, int j, vector<vector<int> > &triangle) { if (i == triangle.size()) { return 0; } return triangle[i][j] + min( DivideConquer(i + 1, j, triangle), DivideConquer(i + 1, j+ 1, triangle)); } int minimumTotal(vector<vector<int> > &triangle) { // write your code here return DivideConquer(0, 0, triangle); }
這個方法比起直接做travers來的更加容易思考一些,回顧了一下上節課講的東西,但是復雜度還是一樣的。到這里大家應該能夠想到了,因為和都是由上面的節點累加起來的,我們可以只遍歷一次,把前面得到的結果記錄下來,這樣就不需要從頭去做遍歷了。所以可以對分治法進行改進,代碼如下(Bug Free):
int minimumTotal(vector<vector<int> > &triangle) { // write your code here int n = triangle.size(); int m = triangle[n-1].size(); vector<vector<int> > dp(n, vector<int>(m)); // 初始化原點 dp[0][0] = triangle[0][0]; // 初始化三角形的邊緣 for (int i = 1; i < n; ++i) { dp[i][0] = dp[i - 1][0] + triangle[i][0]; dp[i][i] = dp[i - 1][i - 1] + triangle[i][i]; } for (int i = 1; i < n; ++i) { for (int j = 1; j < i; ++j) { dp[i][j] = min(dp[i - 1][j], dp[i - 1][j - 1]) + triangle[i][j]; } } return *min_element(dp[n - 1].begin(),dp[n - 1].end()); }
這個應該算最基本的動態規划了,其中用到的一個想法就是:打小抄。用一個dp二維數組來存儲之前的路徑的和,能夠很大程度減小搜索的次數。這里又需要談一下之前說過的二叉樹的問題了,如果這個問題是一個二叉樹的話,就不需要用動態規划的方法來做了,因為二叉樹沒有重復計算的部分,左子樹不會有到右子樹的部分,這樣就沒有打小抄的必要了。這里也就引出了動態規划和分治法的根本區別:動態規划存在重復計算的部分,而分治法是沒有的,也就是說,由全局的問題分成子問題的時候,分治法的子問題是完全獨立的,相互之間沒有交集,而動態規划的方法是有交叉部分的。
2.動態規划的適用范圍
這個內容我個人認為對於面試是非常重要的,因為之前有面試官給我出過一個求出所有可行解的問題,當時我就是用dp來考慮,顯然最后就用一個三維動態規划來解決了,這種就給了自己很大的麻煩。所以動態規划在一定程度上很容易和DFS這樣的場景混淆。
滿足下面三個條件之一:
- 求最大值最小值
- 判斷是否可行
- 統計方案個數
則極有可能是使用動態規划的方法來求解的。之前求所有解的話,肯定是要去遍歷然后求出滿足情況的解的方法,而不是動態規划這樣的模式。
以下情況是不使用動態規划的情況:
- 求出所有具體的方案
- 輸入數據是一個集合而不是序列
- 暴力算法的復雜度已經是多項式級別
- 動態規划擅長於優化指數級別的復雜度到多項式級別
動態規划就是四個重要的要素:
- 狀態
- 方程
- 初始化
- 答案
3. 坐標型動態規划
這種類型的題目在面試中出現的概率大概是15%,比如第1部分的那個題目就是一個坐標型動態規划的題。它的四要素如下:
- state:f[x]表示從起點走到坐標x
- function:研究走到x,y這個點之前的一步
- initiaize:起點
- answer:終點
這樣的題目主要就是在坐標上來進行一個處理。
先上一個極度簡單的題目:
Minimum Path Sum
(http://www.lintcode.com/zh-cn/problem/minimum-path-sum/)
給定一個只含非負整數的m*n網格,找到一條從左上角到右下角的可以使數字和最小的路徑。
這里就不需要多說了,跟我們上面那個題目其實就是一樣的道理,這里不過是從上方或者左方兩個方向到達該點,直接用這個方法來計算就好了。直接上代碼(Bug Free):
int minPathSum(vector<vector<int> > &grid) { // write your code here int m = grid.size(); int n = grid[0].size(); vector<vector<int> > dp(m + 1, vector<int>(n + 1)); // initialize dp[0][0] = grid[0][0]; for (int i = 1; i < m; ++i) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } for (int j = 1; j < n; ++j) { dp[0][j] = dp[0][j - 1] + grid[0][j]; } // state and function for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1]); } } // answer return dp[m - 1][n - 1]; }
不得不提一句,其實這里可以使用滾動數組,不斷更新dp的值,就不需要開辟m*n那么大的空間,具體的滾動數組的方法我會在之后的進階篇里面寫到。
然后就是一個比較簡單的題目,Climbing Stairs,題目如下:
Climbing Stairs
(http://www.lintcode.com/zh-cn/problem/climbing-stairs/)
假設你正在爬樓梯,需要n步你才能到達頂部。但每次你只能爬一步或者兩步,你能有多少種不同的方法爬到樓頂部?
樣例
比如n=3,1+1+1=1+2=2+1=3,共有3中不同的方法
返回 3
這個題目對我本人來說還是有淵源的,我記得第一次面試的時候問的算法題就是這個題,當時我是真的算法渣,完全沒有考慮到該怎么做,就連斐波那契爾數列都沒有想到,所以就用暴力求解的方法做出來了,現在回想一下,當年大三的時候真是太low了。
其實這個題就是一個斐波那契爾數列,因為一次可以走兩步或者一步,也就是說第i步的前一步可能是i-2,也可能是i-1,所以就跟上一題走方格是一樣的問題,然后把前面兩種情況加起來就可以,這個題也可以用遞歸來做,復雜度是n^2,用動態規划的情況復雜度是n。代碼如下(Bug Free):
int climbStairs(int n) { // write your code here vector<int> dp(n + 1); dp[0] = 1; dp[1] = 1; dp[2] = 2; for(int i = 3; i <= n; ++i) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; }
接下來再來一題:
Jump Game
(http://www.lintcode.com/zh-cn/problem/jump-game/)
給出一個非負整數數組,你最初定位在數組的第一個位置。
數組中的每個元素代表你在那個位置可以跳躍的最大長度。
你的目標是使用最少的跳躍次數到達數組的最后一個位置。
樣例
給出數組A = [2,3,1,1,4],最少到達數組最后一個位置的跳躍次數是2(從數組下標0跳一步到數組下標1,然后跳3步到數組的最后一個位置,一共跳躍2次)
這個題是動態規划里面的典型題目,不過還是需要用到一些小trick。直接上代碼吧:
int jump(vector<int> A) { // wirte your code here int n = A.size(); vector<int> dp(n + 1); dp[0] = 0; for (int i = 1; i < n; ++i) { dp[i] = INT_MAX; for (int j = 0; j < i; ++j) { if (dp[j] != INT_MAX && A[j] + j >= i) { dp[i] = dp[j] + 1; break; } } } return dp[n - 1]; }
方法很簡單,就是用一個dp數組存儲當前第i步需要多少步能夠到達,有一個關鍵的地方就是:每次在判斷當前位置i的時候,需要賦值為最大值,這里就可以用這個INT_MAX來作為判斷第j個點是否能夠到達,如果可以的話,就把i從j的位置+1,用這種方法來求出當前i的點需要的步數,然后直接break就可以了。
說到坐標型動態規划的代表題,那一定就是(LIS)這個題目了。雖然說這個是求最長遞增自序列,看上去像是一個序列的問題,但是它更多的是去解決一個坐標跳轉的問題。
Longest Increasing Subsequence
(http://www.lintcode.com/problem/longest-increasing-subsequence/)
給定一個整數序列,找到最長上升子序列(LIS),返回LIS的長度。
說明
最長上升子序列的定義:
最長上升子序列問題是在一個無序的給定序列中找到一個盡可能長的由低到高排列的子序列,這種子序列不一定是連續的或者唯一的。
https://en.wikipedia.org/wiki/Longest_increasing_subsequence
樣例
給出 [5,4,1,2,3],LIS 是 [1,2,3],返回 3
給出 [4,2,4,5,3,7],LIS 是 [2,4,5,7],返回 4
這個題目我認為是需要大家背下來的,能夠在2分鍾之內不暇思索就要寫出來的題目,其實就是考慮第i個元素,是否加上前面的某個元素j,平且判斷當前的個數是否大於加上j以后的個數。然后在所有的dp數組里面找到最大的那個值就是最長子序列的長度。直接上代碼吧(Bug Free):
int longestIncreasingSubsequence(vector<int> nums) { // write your code here int n = nums.size(); if (n == 0) { return 0; } vector<int> dp(n + 1, 1); for (int i = 1; i < n; ++i) { for (int j = 0; j < i; ++j) { if (nums[j] < nums[i]) { dp[i] = max(dp[i], dp[j] + 1); } } } return *max_element(dp.begin(), dp.end()); }
4. 單序列動態規划
這種類型的動態規划一般在面試中出現的概率是30%,它的四要素表示如下:
- state:f[i]表示前i個位置/數字/字符,第i個...
- function: f[i]=f[j]...j是i之前的一個位置
- initialize: f[0]
- answer: f[n]..
- 一般answer是f(n)而不是f(n-1)
- 因為對於n個字符,包含前0個字符(空串),前1個字符......前n個字符。
其中有一個小技巧:
一般有N個數字/字符,就開N+1個位置的數組,第0個位置單獨留出來作初始化.(跟坐標相關的動態規划除外)
那就直接來做一個題目吧,引出這個章節:
Word Break
(http://www.lintcode.com/problem/word-break/)
給出一個字符串s和一個詞典,判斷字符串s是否可以被空格切分成一個或多個出現在字典中的單詞。
樣例
給出
s = "lintcode"
dict = ["lint","code"]
返回 true 因為"lintcode"可以被空格切分成"lint code"
這個就是一個典型的序列的問題,用i表示當前位置,j表示字符串的長度,在這之前可以先遍歷整個dict,求出其中最長的字符串MaxLength,然后保證j小於這個數即可。代碼如下:
int getMaxLength(unordered_set<string> &dict) { int maxLength = 0; // 試試看中文 for (unordered_set<string>::iterator it = dict.begin(); it != dict.end(); ++it) { maxLength = maxLength > (*it).length() ? maxLength : (*it).length(); } return maxLength; } bool wordBreak(string s, unordered_set<string> &dict) { // write your code here int n = s.length(); vector<bool> dp(n + 1, false); dp[0] = true; int MaxLength = getMaxLength(dict); for (int i = 1; i <= n; ++i) { for (int j = 1; j <= MaxLength && j <= i; ++j) { string tmp = s.substr(i - j, j); if (dp[i - j] && dict.find(tmp) != dict.end()) { dp[i] = true; break; } } } return dp[n]; }
這個題如果不用MaxLength來控制j的范圍的話,會超時。
5. 雙序列動態規划
這種題目我個人的理解就是字符串的對應關系,分別用i和j去表示兩個字符串,然后通過操作來計算相應的問題。
Longest Common Subsequence
(http://www.lintcode.com/zh-cn/problem/longest-common-subsequence/)
給出兩個字符串,找到最長公共子序列(LCS),返回LCS的長度。
說明
最長公共子序列的定義:
- 最長公共子序列問題是在一組序列(通常2個)中找到最長公共子序列(注意:不同於子串,LCS不需要是連續的子串)。該問題是典型的計算機科學問題,是文件差異比較程序的基礎,在生物信息學中也有所應用。
- https://en.wikipedia.org/wiki/Longest_common_subsequence_problem
樣例
給出"ABCD" 和 "EDCA",這個LCS是 "A" (或 D或C),返回1
給出 "ABCD" 和 "EACB",這個LCS是"AC"返回 2
這個題目其實考察的地方就在於狀態轉移方程。如果字符串A的第i個位置與字符串B的第j個位置相等,那么當前狀態自動從(i-1,j-1)狀態+1即可;如果不相等,那么從(i-1,j)或者(i,j-1)中取得最大值來作為當前的狀態的最大值。代碼如下(Bug Free):
int longestCommonSubsequence(string A, string B) { // write your code here int n = A.size(); int m = B.size(); vector<vector<int> > dp(n + 1, vector<int>(m + 1)); for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); if (A[i - 1] == B[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } } } return dp[n][m]; }
總結
動態規划是沒有打過競賽的小伙伴們都怕的一個章節,這個章節我總結的不多,是因為有些題目還沒有理解的足夠深,所以怕誤導大家,就不敢放上來了。但是面試還是需要好好准備一下,記住之前所說的幾種可能用到動態規划和不可能用到動態規划的情況即可,個人感覺面試過程能夠寫出多項式級別的復雜度已經算還可以了,如果之后能夠進一步到滾動數組或者壓縮到一維數組之類的,那就更能夠加分了。
