文章首發於:github.com/USTB-musion…
寫在前面
現在競爭越來越激烈,以往前端演算法面試只問問排序的日子一去不復返了。現在大廠喜歡問一些進階性的演算法問題,比如今天要聊的面試中經常出現但理解起來有些困難的一種演算法思想——「動態規劃」。
先看下幾個常見的面試題:
- 假如樓梯有n個臺階,每次可以走1個或2個臺階,請問走完這n個臺階有幾種走法(動態規劃實現)❓
- 如下圖所示:一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑❓
- 在M件物品裡取出若干件放在大小為W的揹包裡,每件物品的體積為W1,W2,W3····Wn,與這些物品對應的價值分別對應為P1,P2,P3·····Pn,如何求出這個揹包能裝的最大價值❓
上面這些問題是非常常見的動態規劃的題目,你可以先思考一下如何回答上邊的問題?,然後帶著答案來閱覽接下來的內容。
什麼是動態規劃❓
動態規劃,英文是Dynamic Programming,簡稱DP,擅長解決“多階段決策問題”,利用各個階段階段的遞推關係,逐個確定每個階段的最優決策,並最終得到原問題的最優決策。
動態規劃與遞迴
- 動態規劃本質上不是遞迴,甚至可以理解是和遞迴相反的一種演算法設計思想。
- 遞迴是自頂向下的,從頂部開始分解問題,然後通過解決分解出的小問題,從而解決出整個問題
- 動態規劃是自底向上的,從底部開始解決問題,按照順序一步一步擴大問題的規模從而去解決整個問題
先來看一道面試題:假如樓梯有n個臺階,每次可以走1個或2個臺階,請問走完這n個臺階有幾種走法❓具體如何分析這道題目,可以看下筆者前段時間寫的文章:聊一聊前端演算法面試——遞迴
第一種方法:暴力遞迴
function climbStairs(n) {
if (n == 1) return 1
if (n == 2) return 2
return climbStairs(n-1) + climbStairs(n-2)
}
複製程式碼
暴力遞迴這種方法通俗易懂,但是非常低效,我們可以來看下它的遞迴樹:
這個遞迴樹怎麼理解?這是一種自頂向下的方法,我們想求出f(10),得先求出子問題f(9)和f(8),並且滿足f(10)=f(9)+f(8),同理可得f(9)=f(8)+f(7),f(8)=f(7)+f(6),······f(3)=f(2)+f(1)。最後遇到f(2)或者f(1)時,一顆完整的遞迴樹就出來了,這其實就是一個二叉樹。
遞迴演算法的時間複雜度怎麼求?子問題個數乘與解決一個子問題所需要的時間
從上圖中可以看出,隨著問題規模的增長,這是一個指數級別的演算法,時間複雜度為O(2^n)。從上圖f(10)為例,暴力遞迴有大量的子問題被重複計算。f(7)被計算了2次,f(6)被計算了4次,而上層的每一次計算更是把底層的f(1)和f(2)都計算了,可以看出這是一種及其低效的做法。那有木有什麼改進的方法呢❓
第二種方法:加上memorize操作,即備忘錄的遞迴
既然暴力遞迴低效的根本原因是有大量的子問題被重複計算,那能不能把這些子問題快取起來呢?把這些子問題放在特定的資料結構裡,當計算某個子問題時,先去這個資料結構裡查一下,如果原來有快取,則直接返回。如果原來沒有快取,則把這個子問題快取起來,方便下次使用。這樣就能優化暴力遞迴低效的原因了。
var calculated = []
function climbStairs(n) {
if(n == 1) {
return 1
}else if (n == 2) {
return 2
}else {
if(!calculated[n-1]){
calculated[n-1] = climbStairs(n-1)
}
if(!calculated[n-2]){
calculated[n-2] = climbStairs(n-2)
}
return calculated[n-1] + calculated[n-2]
}
}
複製程式碼
我們來看一下時間複雜度為多少?通過memorize操作,把巨大的遞迴樹進行“剪枝”操作,把需要重複計算的子問題都快取起來,沒有冗餘的計算,時間複雜度和問題規模成正比,即為O(n)。
第三種方法:動態規劃
動態規劃需要滿足3個條件:最優子問題,邊界條件和狀態轉移方程
1.最優子問題
f(10)=f(9)+f(8),就是f(10)問題的最優子問題,如果求出f(9)和f(8)的最優子問題,那麼就是f(10)的最優子問題了
2.邊界條件
動態規劃是自頂向下的設計思想,以爬樓梯為例,最後分解到底層的邊界條件就是f(1)=1,f(2)=2。
3.狀態轉移方程
其實,動態規劃最難的步驟就是寫出狀態轉移方程,那麼如何來寫出狀態轉移方程呢?狀態轉移方程可以理解是描述數學問題的數學方程式,對於爬樓梯問題來說,可以發現其狀態轉移方程為 f[i]=f[i-1]+f[i-2],從最開始的1和2個臺階兩個狀態開始,自底向上進行求解:
function climbStairs(n) {
var val = [];
for ( var i = 0; i <= n ; ++i) {
val[i] = 0
}
if (n <= 2) {
return n
} else {
val[1] = 1
val[2] = 2
for (var i = 3; i <= n; ++i) {
val[i] = val[i-1] + val[i-2]
}
return val[n]
}
}
console.log(climbStairs(10)) // 55
複製程式碼
面試題2:不同路徑問題
如下圖所示:一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑❓
這是一道leetcode的原題,如果你在面試中遇到這道題,該怎麼應答呢?還記得上面說的動態規劃三要素嗎❓
1.最優子問題
動態規劃是自底向上的思想,可能跟大部分人的思路是相反的。如上圖所示,我們想達到終點F(7*3),無非有兩種情況,一種是F(7*2)向下走一步,一種是F(6*3)向右走一步。所以我們可以得出打到終點的最優子問題是F(7*2)和F(6*3)。
2.狀態轉移方程
根據最優子問題的分析,容易想出狀態轉移方程為F(m*n) = F((m-1)*n) + F(m*(n-1))。
3.邊界條件
- 如果只有1行,遍歷第一行,如果有一個格點初始值為 1 ,說明當前節點有障礙物,沒有路徑可以通過,設值為 0 ;
- 如果只有1列,遍歷第一列,如果有一個格點初始值為 1 ,說明當前節點有障礙物,沒有路徑可以通過,設值為 0 ;
var uniquePathsWithObstacles = function(arr) {
// arr為二維陣列,m為行,n為列
let n = arr.length, m = arr[0].length;
let temp = [];
// 初始化將格子填充為0
for (let i = 0; i < n; i++) {
temp[i] = Array(m).fill(0)
}
// 如果起始或終止目標有障礙物,則直接返回0
if (arr[0][0] == 1 || arr[n - 1][m - 1] == 1) {
return 0
}
// 遍歷二維陣列的列數
for (i = 0; i < n; i++) {
// 遍歷二維陣列的行數
for (let j = 0; j < m; j++) {
if (i == 0 && j == 0) {
temp[i][j] = 1;
// 第一種邊界情況:1行n列
} else if (i == 0) {
if (arr[i][j] != 1 && temp[i][j - 1] != 0) {
temp[i][j] = 1;
} else {
temp[i][j] = 0;
}
// 第二種邊界情況: m行1列
} else if (j == 0) {
if (arr[i][j] != 1 && temp[i - 1][j] != 0) {
temp[i][j] = 1;
} else {
temp[i][j] = 0;
}
} else if (arr[i][j] != 1) {
// 如果不是上述的兩種邊界情況,終止條件的到達方式是i-1,j和i,j-1的和
temp[i][j] = temp[i - 1][j] + temp[i][j - 1]
}
}
}
return temp[n - 1][m - 1]
};
console.log(uniquePathsWithObstacles([[0,0,0],[0,1,0],[0,0,0]])) // 2
複製程式碼
面試3:揹包問題
在M件物品裡取出若干件放在大小為W的揹包裡,每件物品的體積為W1,W2,W3····Wn,與這些物品對應的價值分別對應為P1,P2,P3·····Pn,如何求出這個揹包能裝的最大價值❓
function beibao(M, W, arrP, arrW) {
var result = []
for (var i = 0; i <= M; i++) {
result[i] = []
for (var j = 0; j <= W; j++) {
if ( i == 0) {
result[i][j] = 0
} else if ( arrW[i-1] > j) {
result[i][j] = result[i-1][j]
} else {
result[i][j] = Math.max(arrP[i-1] + result[i-1][j - arrW[i-1]], result[i-1][j])
}
}
}
return result[i-1][j-1]
}
var M = 5; // 物體個數
var W = 16; // 揹包總容量
var arrP = [4,5,10,11,13]; // 物體價值
var arrW = [3,4,7,8,9]; // 物體個數
console.log(beibao(M, W, arrP, arrW)); // 23
複製程式碼
總結
動態規劃適合解決重疊子問題和最優子結構性質的問題,三要素為「最優子問題」,「邊界條件」和「狀態轉移方程」,其中解決動態規劃這類問題的關鍵在於寫出「狀態轉移方程」,而寫出狀態轉移方程法的思路為:
- 找出最優子問題
- 寫出狀態轉移方程
- 將狀態轉移方程翻譯成程式碼