文章首發於:github.com/USTB-musion…前端
如今競爭愈來愈激烈,以往前端算法面試只問問排序的日子一去不復返了。如今大廠喜歡問一些進階性的算法問題,好比今天要聊的面試中常常出現但理解起來有些困難的一種算法思想——「動態規劃」。git
先看下幾個常見的面試題:github
上面這些問題是很是常見的動態規劃的題目,你能夠先思考一下如何回答上邊的問題🤔,而後帶着答案來閱覽接下來的內容。面試
動態規劃,英文是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)時,一顆完整的遞歸樹就出來了,這其實就是一個二叉樹。bash
遞歸算法的時間複雜度怎麼求?子問題個數乘與解決一個子問題所須要的時間數據結構
從上圖中能夠看出,隨着問題規模的增加,這是一個指數級別的算法,時間複雜度爲O(2^n)。從上圖f(10)爲例,暴力遞歸有大量的子問題被重複計算。f(7)被計算了2次,f(6)被計算了4次,而上層的每一次計算更是把底層的f(1)和f(2)都計算了,能夠看出這是一種及其低效的作法。那有木有什麼改進的方法呢❓post
既然暴力遞歸低效的根本緣由是有大量的子問題被重複計算,那能不能把這些子問題緩存起來呢?把這些子問題放在特定的數據結構裏,當計算某個子問題時,先去這個數據結構裏查一下,若是原來有緩存,則直接返回。若是原來沒有緩存,則把這個子問題緩存起來,方便下次使用。這樣就能優化暴力遞歸低效的緣由了。
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個條件:最優子問題,邊界條件和狀態轉移方程
f(10)=f(9)+f(8),就是f(10)問題的最優子問題,若是求出f(9)和f(8)的最優子問題,那麼就是f(10)的最優子問題了
動態規劃是自頂向下的設計思想,以爬樓梯爲例,最後分解到底層的邊界條件就是f(1)=1,f(2)=2。
其實,動態規劃最難的步驟就是寫出狀態轉移方程,那麼如何來寫出狀態轉移方程呢?狀態轉移方程能夠理解是描述數學問題的數學方程式,對於爬樓梯問題來講,能夠發現其狀態轉移方程爲 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
複製代碼
以下圖所示:一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記爲「Start」 )。機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記爲「Finish」)。如今考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不一樣的路徑❓
這是一道leetcode的原題,若是你在面試中遇到這道題,該怎麼應答呢?還記得上面說的動態規劃三要素嗎❓
動態規劃是自底向上的思想,可能跟大部分人的思路是相反的。如上圖所示,咱們想達到終點F(7*3),無非有兩種狀況,一種是F(7*2)向下走一步,一種是F(6*3)向右走一步。因此咱們能夠得出打到終點的最優子問題是F(7*2)和F(6*3)。
根據最優子問題的分析,容易想出狀態轉移方程爲F(m*n) = F((m-1)*n) + F(m*(n-1))。
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
複製代碼
在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
複製代碼
動態規劃適合解決重疊子問題和最優子結構性質的問題,三要素爲「最優子問題」,「邊界條件」和「狀態轉移方程」,其中解決動態規劃這類問題的關鍵在於寫出「狀態轉移方程」,而寫出狀態轉移方程法的思路爲: