21 道 LeetCode 題解帶你搞懂動態規劃!

動態規劃是前端面試中常考的一種算法,下面就來看看動態規劃的概念和使用場景,以及 LeetCode 中常考的動態規劃題目該如何解答!

一、動態規劃概述

1. 基本概念

動態規劃算法通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,而我們希望找到具有最優值的解。動態規劃算法與分治法類似,基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解

動態規劃問題經分解得到的子問題往往不是互相獨立的。需要保存已解決的子問題的答案,而在需要時再找出已保存的答案,這樣就可以避免大量的重複計算。可以用一個表來記錄所有已解的子問題的答案。不管該子問題以後是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規劃法的基本思路。

動態規劃有兩個重要的概念:

動態規劃解題步驟:

  1. 狀態定義:找出子問題抽象定義。

  2. 確定狀態轉移方程:找出狀態與狀態之間的遞推關係。

  3. 初始狀態和邊界情況:最簡單的子問題的結果,也是程序的出口條件 。

  4. 返回值:對於簡單問題,返回值可能就是最終狀態;對於複雜問題可能還需對最終狀態做一些額外處理。

下面就通過爬樓梯問題來看看動態規劃的具體應用。

題目描述:假設正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?其中 n 是一個正整數。

示例 1

輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。
1. 1 階 + 1 階
2. 2 階

示例 2

輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。
1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階

這道題有兩個關鍵特徵:

這樣的問題往往可以用動態規劃進行求解。對於這個問題,每次爬樓梯只有兩種情況:

f(n) 爲以上兩種情況之和,即 f(n)=f(n-1)+f(n-2),這就是本題用到的遞推關係。下面就根據動態規劃的四個步驟來看一下:

  1. 狀態定義:初始化一個 f 數組,f[i] 表示爬到 i 級臺階的方法數量;

  2. 狀態轉移方程:f(n)=f(n-1)+f(n-2);

  3. 初始狀態:一級臺階時,共 1 種爬法;兩級臺階時,可以一級一級爬,也可以一次爬兩級,共有 2 種爬法。即 f[1] = 1,f[2] = 2;

  4. 返回值:f[n] ,即 n 級臺階共有多少種爬法。

動態規劃實現代碼如下:

/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
    // 初始化狀態數組
    const f = [];
    // 初始化已知值
    f[1] = 1;
    f[2] = 2;
    // 動態更新每一層樓梯對應的結果
    for(let i = 3;i <= n;i++){
        f[i] = f[i-2] + f[i-1];
    }
    // 返回目標值
    return f[n];
};

2. 使用場景

上面用動態規劃的思想解決了 “爬樓梯” 的問題,當然我們的目的並不是爲了解決這個問題,而是通過這個問題來看動態規劃,下面就來重新認識一下動態規劃。

上面說過分支問題,它的核心思想是:把一個問題分解爲相互獨立的子問題,逐個解決子問題後,再組合子問題的答案,就得到了問題的最終解。

動態規劃的思想和 “分治” 有點相似。不同之處在於,“分治”思想中,各個子問題之間是獨立的:比如說歸併排序中,子數組之間的排序並不互相影響。而動態規劃劃分出的子問題,往往是相互依賴、相互影響的。

那什麼樣的題應該用動態規劃來做?要抓以下關鍵特徵:

所以,只要需要解決的問題符合這三個關鍵特徵,就可以使用動態規劃來求解。

二、LeetCode 路徑問題

1. 不同路徑

一個機器人位於一個 m x n網格的左上角 (起始點在下圖中標記爲 “Start” )。機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記爲 “Finish” )。

問總共有多少條不同的路徑?示例 1:

輸入:m = 3, n = 7
輸出:28

示例 2:

輸入:m = 3, n = 2
輸出:3
解釋:
從左上角開始,總共有 3 條路徑可以到達右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

示例 3:

輸入:m = 7, n = 3
輸出:28

示例 4:

輸入:m = 3, n = 3
輸出:6

提示:

這個題目和爬樓梯問題其實是一樣的思路,只不過爬樓梯問題算是一維的問題,而這個問題是一個二維的問題。看到這個問題,我們自然而然得就能想到動態規劃

每一個網格的路徑數都和其上側和左側的路徑數相關,可以得出遞推方程:

a[i][j] = a[i - 1][j] + a[i][j - 1]

首先初始化一個 m * n 的二維數組,數組的所有節點值都先初始爲 0,由於最上邊一行和最左邊一列都是邊界,只能有一種走法,所以初始爲 1。然後根據遞推方程求解即可。

/**
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var uniquePaths = function(m, n) {
    const dp = new Array(m).fill(0).map(() => new Array(n).fill(0))

    for(let i = 0; i < m; i++){
        dp[i][0] = 1
    }
    for(let j = 0; j < n; j++){
        dp[0][j] = 1
    }

    for(let i = 1; i < m; i++){ 
        for(let j = 1; j < n; j++){
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        }
    }
    return dp[m - 1][n - 1]
};

複雜度分析:

2. 不同路徑 II

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

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

現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑?

網格中的障礙物和空位置分別用 10 來表示。

示例 1:

輸入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
輸出:2
解釋:
3x3 網格的正中間有一個障礙物。
從左上角到右下角一共有 2 條不同的路徑:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

示例 2:

輸入:obstacleGrid = [[0,1],[0,0]]
輸出:1

提示:

這道題目和 62 題不同路徑 是一樣的思路:動態規劃。

不同的是,這個題目中出現了障礙物,所以在遍歷的時候需要注意以下兩點:

以上兩點就是本題和 62 題的不同之處,根據這個思路實現即可。

/**
 * @param {number[][]} obstacleGrid
 * @return {number}
 */
var uniquePathsWithObstacles = function(obstacleGrid) {
    if(!obstacleGrid.length || obstacleGrid[0][0] === 1){
        return 0
    }

    const m = obstacleGrid.length, n = obstacleGrid[0].length
    const dp = new Array(m).fill(0).map(() =>  new Array(n).fill(0))

    for (let i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
        dp[i][0] = 1;
    }
    for (let j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
        dp[0][j] = 1;
    }
    
    for(let i = 1; i < m; i++){ 
        for(let j = 1; j < n; j++){
            if(obstacleGrid[i][j] === 0){
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
            }
        }
    }
    return dp[m - 1][n - 1]
};

複雜度分析:

3. 最小路徑和

給定一個包含非負整數的 _m_ x _n_ 網格 grid ,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。** 說明:** 每次只能向下或者向右移動一步。 

示例 1:

輸入:grid = [[1,3,1],[1,5,1],[4,2,1]]
輸出:7
解釋:因爲路徑 1→3→1→1→1 的總和最小。

示例 2:

輸入:grid = [[1,2,3],[4,5,6]]
輸出:12

提示:

對於這道題目,路徑的方向只能是從上到下,從左向右。我們可以知道,當前點的路徑和都和上一個點的路徑和相關,所以這裏我們可以使用動態規劃來解答。

對於第一行的元素,它只能是左邊的元素移動過來的,當前的元素的路徑總和關係如下:

grid[i][0] += grid[i - 1][0]

對於第一列的元素,它只能是上邊的元素移動過來的,當前的元素的路徑總和關係如下:

grid[0][j] += grid[0][j - 1]

對於其他位置的元素,他可以是上邊移動過來的,也可以是左邊移動過來的,因爲要求的是最小路徑和,所以我們只需要選取左邊和上面的路徑和最小值,當前的元素的路徑總和關係如下:

grid[i][j] = Math.min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j];

這樣,經過遍歷之後,每個節點的值就是當前的最小路徑和。最後只需要返回右下角元素的值即可。

/**
 * @param {number[][]} grid
 * @return {number}
 */
var minPathSum = function(grid) {
    let m = grid.length, n = grid[0].length

    for(let i = 1; i < m; i++){
        grid[i][0] += grid[i - 1][0]
    }
    for(let j = 1; j < n; j++){
        grid[0][j] += grid[0][j - 1]
    }

    for(let i = 1; i < m; i++){
        for(let j = 1; j < n; j++){
            grid[i][j] = Math.min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j];
        }
    }
    return grid[m - 1][n - 1]
}

複雜度分析:

4. 三角形最小路徑和

給定一個三角形 triangle ,找出自頂向下的最小路徑和。

每一步只能移動到下一行中相鄰的結點上。** 相鄰的結點 ** 在這裏指的是 **下標** 與 **上一層結點下標** 相同或者等於 **上一層結點下標 + 1** 的兩個結點。也就是說,如果正位於當前行的下標 i ,那麼下一步可以移動到下一行的下標 ii + 1 。 **示例 1:**

輸入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
輸出:11
解釋:如下面簡圖所示:
   2
  3 4
 6 5 7
4 1 8 3
自頂向下的最小路徑和爲 11(即,2 + 3 + 5 + 1 = 11)。

示例 2:

輸入:triangle = [[-10]]
輸出:-10

提示:

進階:

這道題目和最小路徑和那道題的階梯思路類似,都是使用動態規劃來解決。

這裏,其實我們並不需要初始化一個數組來保存每一步的狀態(每個節點的最小路徑值),可以在原數組上進行操作,因爲每個節點都只遍歷一次,在遍歷完之後,我們只需要將當前節點的狀態賦值給當前節點即可。

這裏同樣需要處理兩個邊界的問題,對於第一列元素,他只能是上面的元素下來的,所以他的狀態轉移方程是:

triangle[i][j] += triangle[i - 1][j]

對於每一行的最後一位,它只能是上一行的最後一位下來的,所以他的狀態轉移方程是:

triangle[i][j] += triangle[i - 1][j - 1]

對於其他的元素,可以是其對應序號以及對應序號減一的元素移動下來的,所以他的狀態轉移方程是:

triangle[i][j] += Math.min(triangle[i - 1][j], triangle[i - 1][j - 1])

最後我們只需要返回最後一行元素的最小值即可,這裏我們用... 擴展運算符配合 Math 的 min 方法求得最後一行的最小值。

/**
 * @param {number[][]} triangle
 * @return {number}
 */
var minimumTotal = function(triangle) {
    const n = triangle.length

    for(let i = 1; i < n; i++){
        for(let j = 0; j <= i; j++){
            if(j === 0){
                triangle[i][j] += triangle[i - 1][j]
            }else if(j === i){
                triangle[i][j] += triangle[i - 1][j - 1]
            }else{
                triangle[i][j] += Math.min(triangle[i - 1][j], triangle[i - 1][j - 1])
            }
        }
    }

    return Math.min(...triangle[n - 1])
};

複雜度分析:

三、LeetCode 買賣股票問題

1. 買賣股票的最佳時機

給定一個數組,它的第 i 個元素是一支給定股票第 i 天的價格。如果你最多隻允許完成一筆交易(即買入和賣出一支股票一次),設計一個算法來計算你所能獲取的最大利潤。注意:你不能在買入股票前賣出股票。

示例 1:

輸入: [7,1,5,3,6,4]
輸出: 5
解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 5 天(股票價格 = 6)的時候賣出,最大利潤 = 6-1 = 5 。
     注意利潤不能是 7-1 = 6, 因爲賣出價格需要大於買入價格;同時,你不能在買入前賣出股票。

示例 2:

輸入: [7,6,4,3,1]
輸出: 0
解釋: 在這種情況下, 沒有交易完成, 所以最大利潤爲 0。

(1)直接遍歷我們需要對股票進行一次買入、一次賣出,賣出在買入之後,並且要計算最大的利潤。

這裏初始化一個最小值 min,和一個最大的結果值 max。遍歷數組,如果當前數組元素小於最小值民,就更新最小值,始終讓其保持最小。如果當前值減去最小值大於最大值,就更新最大值。直到遍歷完數組所有的元素,返回最後的結果。

(2)動態規劃對於這道題,我們可以使用動態規劃來解決。這裏我們只需要進行一次買入賣出。那到最後交易時,可能會有三種狀態:

由於第一種狀態未進行任何操作,所以可以不用記錄。然後我們對後兩種狀態進行轉移:

(1)直接遍歷

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    let max = 0
    let min = prices[0]

    prices.forEach( item ={
        if(item < min) min = item
        if(item - min > max) max = item - min
    })
    return max
};

複雜度分析:

(2)動態規劃

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    let len = prices.length;
    const dp = [0, -prices[0], 0]
    
    for (let i = 1; i < len; i++) {
        dp[1] = Math.max(dp[1], -prices[i])
        dp[2] = Math.max(dp[2], dp[1] + prices[i])
    }
    return dp[2];
}

複雜度分析:

2. 買賣股票的最佳時機 II

給定一個數組,它的第 i 個元素是一支給定股票第 i 天的價格。設計一個算法來計算你所能獲取的最大利潤。你可以儘可能地完成更多的交易(多次買賣一支股票)。注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

示例 1:

輸入: [7,1,5,3,6,4]
輸出: 7
解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 3 天(股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
     隨後,在第 4 天(股票價格 = 3)的時候買入,在第 5 天(股票價格 = 6)的時候賣出, 這筆交易所能獲得利潤 = 6-3 = 3 。

示例 2:

輸入: [1,2,3,4,5]
輸出: 4
解釋: 在第 1 天(股票價格 = 1)的時候買入,在第 5 天 (股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接連購買股票,之後再將它們賣出。
     因爲這樣屬於同時參與了多筆交易,你必須在再次購買前出售掉之前的股票。

示例 3:

輸入: [7,6,4,3,1]
輸出: 0
解釋: 在這種情況下, 沒有交易完成, 所以最大利潤爲 0。

提示:

對於這道題目,我們可以使用動態規劃來解答。每個點的狀態描述:手裏有股票或者沒股票。

1)dp[i][0] 表示:第 i 天手裏沒股票,至今(第 i 天)的最大收益。第 i 天手裏沒股票,有兩種可能:

2)dp[i][1] 表示:第 i 天手裏有股票,至今(第 i 天)的最大收益。第 i 天手裏有股票,有兩種可能:

最終目標是求出:dp[prices.length-1][0]dp[prices.length-1][1]的較大者,前者肯定 >= 後者,求dp[prices.length-1][0]即可。

對於開始:

/**
 * @param {number[]} prices
 * @return {number}
 */
function maxProfit(prices) {
  const len = prices.length;
  if (len < 2) {
    return 0;
  };
  const dp = new Array(len);
  dp[0] = [0, -prices[0]];
  for (let i = 1; i < len; i++) {
    dp[i] = new Array(2);
    dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]); // 沒有股票
    dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]); // 有股票
  }
  return dp[len - 1][0];
}

複雜度分析:

3. 買賣股票的最佳時機 III

給定一個數組,它的第 i 個元素是一支給定的股票在第 i 天的價格。設計一個算法來計算你所能獲取的最大利潤。你最多可以完成 兩筆 交易。注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

示例 1:

輸入:prices = [3,3,5,0,0,3,1,4]
輸出:6
解釋:在第 4 天(股票價格 = 0)的時候買入,在第 6 天(股票價格 = 3)的時候賣出,這筆交易所能獲得利潤 = 3-0 = 3 。
     隨後,在第 7 天(股票價格 = 1)的時候買入,在第 8 天 (股票價格 = 4)的時候賣出,這筆交易所能獲得利潤 = 4-1 = 3 。

示例 2:

輸入:prices = [1,2,3,4,5]
輸出:4
解釋:在第 1 天(股票價格 = 1)的時候買入,在第 5 天 (股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。   
     注意你不能在第 1 天和第 2 天接連購買股票,之後再將它們賣出。   
     因爲這樣屬於同時參與了多筆交易,你必須在再次購買前出售掉之前的股票。

示例 3:

輸入:prices = [7,6,4,3,1] 
輸出:0 
解釋:在這個情況下, 沒有交易完成, 所以最大利潤爲 0。

示例 4:

輸入:prices = [1]
輸出:0

提示:

對於這道題,我們可以使用動態規劃來解決。在《買賣股票的最佳時機》中,我們只能進行一次買入賣出。而這道題,我們可以進行至多兩次的買入賣出,那到最後交易時,可能會有五種狀態:

由於第一種狀態未進行任何操作,所以可以不用記錄。然後我們對後四種狀態進行轉移:

/**
 * @param {number[]} prices
 * @return {number}
 */
function maxProfit(prices) {
    let len = prices.length;
    const dp = [0, -prices[0], -prices[0], 0, 0]
    
    for (let i = 1; i < len; i++) {
        dp[1] = Math.max(dp[1], -prices[i])
        dp[2] = Math.max(dp[2], dp[1] + prices[i])
        dp[3] = Math.max(dp[3], dp[2] - prices[i])
        dp[4] = Math.max(dp[4], dp[3] + prices[i])
    }
    return dp[4];
};

複雜度分析:

4. 買賣股票的最佳時機 IV

給定一個整數數組 prices ,它的第 i 個元素 prices[i] 是一支給定的股票在第 i 天的價格。設計一個算法來計算你所能獲取的最大利潤。你最多可以完成 k 筆交易。注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

示例 1:

輸入:k = 2, prices = [2,4,1]
輸出:2
解釋:在第 1 天 (股票價格 = 2) 的時候買入,在第 2 天 (股票價格 = 4) 的時候賣出,這筆交易所能獲得利潤 = 4-2 = 2 。

示例 2:

輸入:k = 2, prices = [3,2,6,5,0,3]
輸出:7
解釋:在第 2 天 (股票價格 = 2) 的時候買入,在第 3 天 (股票價格 = 6) 的時候賣出, 這筆交易所能獲得利潤 = 6-2 = 4 。
     隨後,在第 5 天 (股票價格 = 0) 的時候買入,在第 6 天 (股票價格 = 3) 的時候賣出, 這筆交易所能獲得利潤 = 3-0 = 3 。

提示:

在題目《買賣股票的最佳時機》中,我們只能進行一次買入賣出,在題目 123《買賣股票的最佳時機 III》中,我們可以進行兩次買入賣出操作。而在這道題目中,我們可以進行 k 次買入賣出操作。這裏我們也可以使用動態規劃來解答。

每次我們只能進行[1, k]次中的某次交易或不交易,所以可能有 2k+1 中狀態:

我們可以枚舉一天的所有可能,取現金最大值:

/**
 * @param {number} k
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(k, prices) {
    const dp = new Int16Array(k * 2).fill(-prices[0])
    
    for (let i = 0; i < prices.length; i++) {
        for (let j = 0; j < dp.length; j++) 
            {
                dp[j] = Math.max(dp[j](dp[j - 1] || 0) + (& 1 ? prices[i] : -prices[i]))
            }
    }
    return Math.max(0, ...dp) 
};

複雜度分析:

四、LeetCode 打家劫舍問題

1. 打家劫舍

你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警

給定一個代表每個房屋存放金額的非負整數數組,計算你 ** 不觸動警報裝置的情況下 **,一夜之內能夠偷竊到的最高金額。

示例 1:

輸入:[1,2,3,1]
輸出:4
解釋:偷竊 1 號房屋 (金額 = 1) ,然後偷竊 3 號房屋 (金額 = 3)。
     偷竊到的最高金額 = 1 + 3 = 4 。

示例 2:

輸入:[2,7,9,3,1]
輸出:12
解釋:偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接着偷竊 5 號房屋 (金額 = 1)。
     偷竊到的最高金額 = 2 + 9 + 1 = 12 。

提示:

對於這道題目,我們可以使用動態規劃來實現。首先來看最簡單的兩種情況,如果只有一間房屋,那這個屋子就是最高的金額,如果有兩間房屋,那不能同時偷,只能偷其中其中金額高的那間,如果大於兩間屋子,就要進行討論了。

這兩者,我們只要取總金額的較大值即可。

我們可以用 dp[i] 表示前 i 間房屋能偷竊到的最高總金額,那麼就有如下的狀態轉移方程:

dp[i]=max(dp[i−2]+nums[i],dp[i−1])

邊界條件爲:

最終的答案即爲 dp[n−1],其中 n 是數組的長度。

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
    const len = nums.length
    if(!len){
        return 0
    }

    const dp = new Array(len + 1)
    dp[0] = 0
    dp[1] = nums[0]

    for(let i = 2; i <= len; i++){
        dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i-1]);
    }
    return dp[len];
};

複雜度分析:

2. 打家劫舍 II

你是一個專業的小偷,計劃偷竊沿街的房屋,每間房內都藏有一定的現金。這個地方所有的房屋都 圍成一圈 ,這意味着第一個房屋和最後一個房屋是緊挨着的。同時,相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警

給定一個代表每個房屋存放金額的非負整數數組,計算你 在不觸動警報裝置的情況下 ,能夠偷竊到的最高金額。 示例 1:

輸入:nums = [2,3,2]
輸出:3
解釋:你不能先偷竊 1 號房屋(金額 = 2),然後偷竊 3 號房屋(金額 = 2), 因爲他們是相鄰的。

示例 2:

輸入:nums = [1,2,3,1]
輸出:4
解釋:你可以先偷竊 1 號房屋(金額 = 1),然後偷竊 3 號房屋(金額 = 3)。
     偷竊到的最高金額 = 1 + 3 = 4 。

示例 3:

輸入:nums = [0]
輸出:0

提示:

打家劫舍這類問題其實都可以使用動態規劃來解答,這個題目和打家劫舍類似,不過就是多了兩種情況:

這樣就可以分類討論,當不偷第一家時,就排除到第一家,對其他家進行計算,當不偷最後一家時,就排除掉最後一家,對其他家進行計算。

當前節點的最大值就是當前節點和之前的第二個節點的和與上個節點的值的最大值,這樣說可能比較繞,狀態轉移方程代碼:

dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i])

代碼實現:

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
    const len = nums.length
    let res1 = 0, res2 = 0
    if(len === 0) return 0
    if(len === 1) return nums[0]

    const dp = new Array(len)
    
    // 不偷第一家
    dp[0] = 0
    dp[1] = nums[1]
    for(let i = 2; i <= len - 1; i++){
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    res1 = dp[len - 1]

    // 不偷最後一家
    dp[0] = nums[0]
    dp[1] = Math.max(nums[0], nums[1])
    for(let i = 2; i <= len - 2; i++){
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    res2 = dp[len - 2]
    return Math.max(res1, res2)
};

複雜度分析:

3. 打家劫舍 III

在上次打劫完一條街道之後和一圈房屋後,小偷又發現了一個新的可行竊的地區。這個地區只有一個入口,我們稱之爲 “根”。除了“根” 之外,每棟房子有且只有一個“父“房子與之相連。一番偵察之後,聰明的小偷意識到“這個地方的所有房屋的排列類似於一棵二叉樹”。如果兩個直接相連的房子在同一天晚上被打劫,房屋將自動報警。

計算在不觸動警報的情況下,小偷一晚能夠盜取的最高金額。示例 1:

輸入: [3,2,3,null,3,null,1]
     3
    / \
   2   3
      
     3   1
輸出: 7 
解釋: 小偷一晚能夠盜取的最高金額 = 3 + 3 + 1 = 7.

示例 2:

輸入: [3,4,5,1,3,null,1]
     3
    / \
   4   5
  /   
 1   3   1
輸出: 9
解釋: 小偷一晚能夠盜取的最高金額 = 4 + 5 = 9.

對於這道題目,可以使用動態規劃來解答。

對於二叉樹,每個節點都有兩種狀態,選中或者不選中,我們可以使用深度優先遍歷來遍歷這棵二叉樹:

最後返回左右子樹中最大值即可。

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var rob = function(root) {
    const dfs = (node) ={
        if (node === null) {
            return [0, 0];
        }
        const left = dfs(node.left);
        const right = dfs(node.right);
      
        const select = node.val + left[1] + right[1];
        const notSelect = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
        return [select, notSelect];
    }
    const res = dfs(root)
    return Math.max(res[0], res[1])
};

複雜度分析:

五、LeetCode 迴文串問題

1. 迴文子串

給定一個字符串,你的任務是計算這個字符串中有多少個迴文子串。具有不同開始位置或結束位置的子串,即使是由相同的字符組成,也會被視作不同的子串。

示例 1:

輸入:"abc"
輸出:3
解釋:三個迴文子串: "a""b""c"

示例 2:

輸入:"aaa"
輸出:6
解釋:6個迴文子串: "a""a""a""aa""aa""aaa"

提示:輸入的字符串長度不會超過 1000 。

這個題目最直接的方法就是使用暴力循環來解決,遍歷每一種可能,具體思路如下:

複雜度分析:

/**
 * @param {string} s
 * @return {number}
 */
var countSubstrings = function(s) {
    let count = s.length
    let len = s.length
    for(let i = 0; i < len; i++){
        for(let j = i + 1; j < len; j++){
            let temp = s.substr(i, j-i+1)
            if(isSub(temp)){
                count++
            }
        }
    }
    return count
};
const isSub = str ={
        let a = 0
        let b = str.length - 1
        while(a <= b){
            if(str[a] !== str[b]){
                return false
            }
            a++
            b--
        }
        return true
    }

2. 最長迴文子串

給定一個字符串 s,找到 s 中最長的迴文子串。你可以假設 s 的最大長度爲 1000。示例 1:

輸入: "babad"
輸出: "bab"
注意: "aba" 也是一個有效答案。

示例 2:

輸入: "cbbd"
輸出: "bb"

這裏使用兩種方法來解答這個問題:(1)方法一:中心擴展法中心擴展法的思想就是枚舉出可能出現的迴文串的中心,從這個中心位置儘可能的向兩邊擴散出去,得到一個迴文串,具體實現步驟如下:

代碼實現:

/**
 * @param {string} s
 * @return {string}
 */
var longestPalindrome = function(s) {
    if(s == null || s.length <1){
        return ''
    }
    let start = 0
    let end = 0
    // 定義中心擴展的方法
    const fn = (s,left,right) ={
        while(left >=&& right< s.length && s[left] === s[right]){
            left--
            right++
        }
        return right - left -1
    }
    // 遍歷字符串
    for(let i = 0; i<s.length; i++){
        const len1 = fn(s, i, i)
        const len2 = fn(s, i, i+1)
        const len = Math.max(len1, len2)
        // 判斷起始位置是否更新
        if(len > end - start){
            start = i- Math.floor((len-1)/2)
            end = i+ Math.floor(len/2)
        }
    }
    return s.substring(start, end+1)
};

複雜度分析:

(2)方法二:動態規劃解決這類問題的核心思想就是兩個字 “延伸”,具體來說

事實上,上面的分析已經建立了大問題小問題之間的關聯,因爲我們可以建立動態規劃模型。可以用 dp[i][j] 表示 s 中從 i 到 j(包括 i 和 j)是否可以形成迴文,狀態轉移方程只是將上面的描述轉化爲代碼即可:

if (s[i] === s[j] && dp[i + 1][j - 1]) {
  dp[i][j] = true;
}

其中:

總結一下,使用動態規劃的具體實現步驟如下:

代碼實現:

 /**
 * @param {string} s
 * @return {string}
 */
// 擴展中心
var longestPalindrome = function(s) {
   let res = '';
    let n = s.length;
    let dp = Array.from(new Array(n)() => new Array().fill(0));
    
    for(let i = n-1; i >=0; i--) {
        for(let j = i; j < n; j++) {
            dp[i][j] = s[i] === s[j] && ( j - i < 2 || dp[i+1][j-1])
            if(dp[i][j] && j - i + 1 > res.length) {
                res = s.substr(i,j - i + 1);
            }
        }
    }
    return res;
}

複雜度分析:

3. 最長迴文子序列

給定一個字符串 s ,找到其中最長的迴文子序列,並返回該序列的長度。可以假設 s 的最大長度爲 1000 。 示例 1:

輸入: "bbbab"
輸出: 4
一個可能的最長迴文子序列爲 "bbbb"

示例 2:

輸入: "cbbd"
輸出: 2
一個可能的最長迴文子序列爲 "bb"

提示:

對於這種迴文子串的問題,我們可以考慮能否使用動態規劃來求解。

這裏我們嘗試使用動態規劃來解答,初始化一個 dp 二維數組來保存子串的長度,dp[i][j] 表示 s 中的第 i 個字符到第 j 個字符組成的子串中,最長的迴文序列的長度。

下面最重要的就是找出狀態轉移方程:

這裏需要注意遍歷時的順序,i 是從最後一個字符開始遍歷的,j 是從 i+1 開始向後遍歷,這樣就能保證每個子問題都計算好了。最後只要返回dp[0][len-1]即可。

/**
 * @param {string} s
 * @return {number}
 */
var longestPalindromeSubseq = function(s) {
    let len = s.length;
    
    let dp = new Array(len)
    for (let i = 0; i < len; i++) {
        dp[i] = new Array(len).fill(0);
    }
    
    for (let i = len - 1; i >= 0; i--) {
        dp[i][i] = 1;
        for (let j = i+1; j < len; j++) {
            if (s[i] === s[j]) {
                dp[i][j] = dp[i+1][j-1] + 2;
            } else {
                dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1])
            }
        }
    }
    return dp[0][len-1];
};

複雜度分析:

六、LeetCode 子序列問題

1. 最大子序和

給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。示例:

輸入: [-2,1,-3,4,-1,2,1,-5,4]
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

進階: 如果你已經實現複雜度爲 O(n) 的解法,嘗試使用更爲精妙的分治法求解。

**動態規劃求解:**通常我們遍歷子串或者子序列有三種遍歷方式

其中,第一種方式通常會使用暴力方法的求解,第二種方式在上面第五題已將用到過了,重點是第三種方式:因爲可以產生遞推關係, 採用動態規劃時, 經常通過此種遍歷方式, 如揹包問題、最大公共子串 , 這裏的動態規劃解法也是以先遍歷出以某個節點爲結束節點的所有子序列的思路。

代碼實現:

var maxSubArray = function(nums) {
    let sum = 0, res = nums[0]
    for(let num of nums){
        sum > 0 ? sum += num : sum = num
        res = Math.max(sum, res)
    }
    return res
};

複雜度分析:

2. 最長遞增子序列

給你一個整數數組 nums ,找到其中最長嚴格遞增子序列的長度。

子序列是由數組派生而來的序列,刪除(或不刪除)數組中的元素而不改變其餘元素的順序。例如,[3,6,2,7] 是數組 [0,3,1,6,2,2,7] 的子序列。

示例 1:

輸入:nums = [10,9,2,5,3,7,101,18]
輸出:4
解釋:最長遞增子序列是 [2,3,7,101],因此長度爲 4 。

示例 2:

輸入:nums = [0,1,0,3,2,3]
輸出:4

示例 3:

輸入:nums = [7,7,7,7,7,7,7]
輸出:1

提示:

進階:

碰到子序列的問題,我們最容易想到的就是動態規劃。首先初始化一個數組 dp 來保存每個子問題的最優解,dp[i] 表示數組前 n 的元素的最長連續子序列,最後返回所有子序列中最長的序列就可以了。

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    const n = nums.length
    if(!n){
        return 0
    }

    let dp = new Array(n).fill(1)
    for(let i = 1; i < n; i++){
        for(let j = 0; j < i; j++){
            if(nums[i] > nums[j]){
                dp[i] = Math.max(dp[i], dp[j] + 1)
            }
        }
    }
    return Math.max(...dp)
};

複雜度分析:

3. 乘積最大的子數組

給你一個整數數組 nums ,請你找出數組中乘積最大的連續子數組(該子數組中至少包含一個數字),並返回該子數組所對應的乘積。 示例 1:

輸入: [2,3,-2,4]
輸出: 6
解釋: 子數組 [2,3] 有最大乘積 6。

示例 2:

輸入: [-2,0,-1]
輸出: 0
解釋: 結果不能爲 2, 因爲 [-2,-1] 不是子數組。

對於這道題目,我們可以使用動態規劃來解答。

我們只需要在遍歷數組時,不斷更新最大值即可,這個過程中,我們需要維護兩個值:

我們這裏求的是最大值,那爲啥還要保存最小值呢?這是因爲數組中可能會有負數,噹噹前的值是負數時,與之前的值相乘就會導致最大值和最小值交換,所以我們需要維護一個最大值和一個最小值。然後不斷使用當前的最大值保存爲結果,最後返回結果即可。

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxProduct = function(nums) {
    let res = -Infinity, max = 1, min = 1;

    for(let i = 0; i < nums.length; i++){
        if(nums[i] < 0){
            let temp = max
            max = min
            min = temp
        }
        max = Math.max(nums[i], nums[i] * max)
        min = Math.min(nums[i], nums[i] * min)

        res = Math.max(res, max)
    }
    return res
};

複雜度分析:

4. 最長重複子數組

給兩個整數數組 AB ,返回兩個數組中公共的、長度最長的子數組的長度。 示例:

輸入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
輸出:3
解釋:
長度最長的公共子數組是 [3, 2, 1] 。

提示:

對於這道題目,我們可以使用動態規劃來解決。動態規劃就是要保持上一個狀態和下一個狀態有關係,並且是連續的。這裏的子數組就相當於子串,是連續的。

這裏我們初始化一個 dp 數組保存當前的最大連續值,dp[i][j] 表示數組 A 的前 i 個元素和數組 B 的前 j 個元素組成的最長公共子數組的長度。

在遍歷數組時:

在遍歷的過程中,不斷更新最長公共子序列最大值。

/**
 * @param {number[]} A
 * @param {number[]} B
 * @return {number}
 */
var findLength = function(A, B) {
    const m = A.length, n = B.length;

    let dp = new Array(m + 1)
    for (let i = 0; i <= m; i++) { 
        dp[i] = new Array(n + 1).fill(0);
    }

    let res = 0

    for(let i = 1; i <= m; i++){
        for(let j = 1; j <= n; j++){
            if(A[i - 1] === B[j - 1]){
                dp[i][j] = dp[i - 1][j - 1] + 1
            }
            res = Math.max(dp[i][j], res)
        }
    }
    return res
};

複雜度分析:

七、LeetCode 其他問題

1. 接雨水

給定 n 個非負整數表示每個寬度爲 1 的柱子的高度圖,計算按此排列的柱子,下雨之後能接多少雨水。

示例 1:

輸入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
輸出:6
解釋:上面是由數組 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度圖,在這種情況下,可以接 6 個單位的雨水(藍色部分表示雨水)。

示例 2:

輸入:height = [4,2,0,3,2,5]
輸出:9

提示:

看到這道題,我們自然而然的可以想到木桶效應,每根柱子上的雨水的深度取決於它兩側最高的柱子中較短的那根柱子的長度。

對於下標 i,下雨後水能到達的最大高度等於下標 i 兩邊的最大高度的最小值,下標 i 處能接的雨水量等於下標 i 處的水能到達的最大高度減去 height[i]。

最直接的做法是對於數組 height 中的每個元素,分別向左和向右掃描並記錄左邊和右邊的最大高度,然後計算每個下標位置能接的雨水量。使用動態規劃的方法,可以在 O(n) 的時間內預處理得到每個位置兩邊的最大高度。

/**
 * @param {number[]} height
 * @return {number}
 */
var trap = function(height) {
    let len = height.length, sum = 0
    for(let i = 0; i < len - 1; i++){
        // 計算當前柱子左側的最大值
        let left = 0
        for(let j = i - 1; j >= 0; j--){
            left = Math.max(height[j], left)
        }
        // 計算當前柱子右側的最大值
        let right = 0
        for(let j = i + 1; j < len; j++){
            right = Math.max(height[j],right)
        }
        // 計算當前柱子能接的雨水量
        if(min > height[i]){
            sum += Math.min(left, right) - height[i]
        }
    }
    return sum
};

複雜度分析:

2. 爬樓梯

假設你正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?

注意: 給定 n 是一個正整數。示例 1:

輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。
1.  1 階 + 1 階
2.  2 階

示例 2:

輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。
1.  1 階 + 1 階 + 1 階
2.  1 階 + 2 階
3.  2 階 + 1 階

看到這個題目,我們應該想到的就是動態規劃將一個大問題分解成多個子問題。首先來看:

所以可以得出遞推公式:f(n) = f(n−1) + f(n−2) 這樣,我們就可以通過遞歸完成計算。

上面這種普通遞歸很顯然,有很多的重複計算,所以,我們可以將每次計算的結果進行保存,以便下次計算時直接使用。

普通遞歸:

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    const dp = []
    dp[0] = 1
    dp[1] = 1
    for(let i = 2; i <= n; i ++){
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    return dp[n]
};

記憶遞歸:

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    let a = 1, b = 1, res = 1;
    for (let i = 1; i < n; i++) {
        a = b
        b = res
        res = a + b
    }
    return res
};

普通遞歸複雜度分析:

記憶遞歸複雜度分析:

3. 最大正方形

在一個由 '0''1' 組成的二維矩陣內,找到只包含 '1' 的最大正方形,並返回其面積。

示例 1:

輸入:matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
輸出:4

示例 2:

輸入:matrix = [["0","1"],["1","0"]]
輸出:1

示例 3:

輸入:matrix = [["0"]]
輸出:0

提示:

對於這道題目,可以使用動態規劃來解決,這裏我們需要初始化與一個 dp 數組,dp[i][i] 表示以 (i, j) 爲右下角,且只包含 1 的正方形的邊長最大值。我們只需要遍歷這個二維矩陣,計算機每個 dp 的值,選出最大值,即正方形的最大邊長,最後返回這個正方形的面積即可。

計算 dp 的每個值有以下規則:

dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1

除此之外,我們還需要考慮二維矩陣的最左邊一列和最上面一行,如果值是 1,就直接將 dp[i][j] 賦值爲 1。

代碼實現:

/**
 * @param {character[][]} matrix
 * @return {number}
 */
var maximalSquare = function(matrix) {
    const m = matrix.length, n = matrix[0].length
    let res = 0
    if(!matrix || m === 0 || n === 0){
        return 0
    }

    let dp = new Array(m)
    for(let i = 0; i < m; i++){
        dp[i] = new Array(n).fill(0)
    }

    for(let i = 0; i < m; i++){
        for(let j = 0; j < n; j++){
            if(matrix[i][j] === '1'){
                if(i === 0 || j === 0){
                    dp[i][j] = 1
                }else{
                    dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
                }
                res = Math.max(dp[i][j], res)
            }
        }
    }
    return res * res
};

複雜度分析:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tWwFK5M2cRCibEM1KHiitg