Java演算法之動態規劃詳解-買賣股票最佳時機

yiwanbin發表於2023-10-11

①動態規劃

動態規劃(Dynamic Programming,DP)是運籌學的一個分支,是求解決策過程最最佳化的過程。20世紀50年代初,美國數學家貝爾曼(R.Bellman)等人在研究多階段決策過程的最佳化問題時,提出了著名的最最佳化原理,從而創立了動態規劃。動態規劃的應用極其廣泛,包括工程技術、經濟、工業生產、軍事以及自動化控制等領域,並在揹包問題、生產經營問題、資金管理問題、資源分配問題最短路徑問題和複雜系統可靠性問題等中取得了顯著的效果

⓿動規五部曲

  1. 確定dp陣列以及下標的含義
  2. 確定遞推公式
  3. dp陣列如何初始化
  4. 確定遍歷順序
  5. 舉例推導dp陣列

❶基礎規劃題

509. 斐波那契數

斐波那契數 (通常用 F(n) 表示)形成的序列稱為 斐波那契數列 。該數列由 01 開始,後面的每一項數字都是前面兩項數字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

給定 n ,請計算 F(n)

示例 1:

輸入:n = 2
輸出:1
解釋:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

輸入:n = 3
輸出:2
解釋:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

輸入:n = 4
輸出:3
解釋:F(4) = F(3) + F(2) = 2 + 1 = 3
//動態規劃
class Solution {
    public int fib(int n) {
        if (n < 2) return n;
        int dp[] = new int[n + 1];
        dp[0] = 0;
        dp[1] = 1;
        for(int i = 2; i <= n; i++){
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}
//最佳化
class Solution {
    public int fib(int n) {
        if (n < 2) return n;
        int a = 0, b = 1, c = 0;
        for (int i = 2; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        return c;
    }
}

//遞迴
class Solution {
    public int fib(int n) {
        if (n < 2) return n;
        return fib(n - 1) + fib(n - 2);
    }
}

70. 爬樓梯

假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 12 個臺階。你有多少種不同的方法可以爬到樓頂呢?

示例 1:

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

示例 2:

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

本問題其實常規解法可以分成多個子問題,爬第n階樓梯的方法數量,等於 2 部分之和

  1. 爬上 n−1 階樓梯的方法數量。因為再爬1階就能到第n階
  2. 爬上 n−2 階樓梯的方法數量,因為再爬2階就能到第n階

所以我們得到公式 dp[n] = dp[n−1] + dp[n−2],同時需要初始化 dp[0]=1 和 dp[1]=1

時間複雜度:O(n)

class Solution {
    public int climbStairs(int n) {
        int dp[] = new int[n + 1];
        dp[0] = 1;
        dp[1] = 1;
        for(int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

746. 使用最小花費爬樓梯

給你一個整數陣列 cost ,其中 cost[i] 是從樓梯第 i 個臺階向上爬需要支付的費用。一旦你支付此費用,即可選擇向上爬一個或者兩個臺階。

你可以選擇從下標為 0 或下標為 1 的臺階開始爬樓梯。

請你計算並返回達到樓梯頂部的最低花費。

示例 1:

輸入:cost = [10,15,20]
輸出:15
解釋:你將從下標為 1 的臺階開始。
- 支付 15 ,向上爬兩個臺階,到達樓梯頂部。
總花費為 15 。

示例 2:

輸入:cost = [1,100,1,1,1,100,1,1,100,1]
輸出:6
解釋:你將從下標為 0 的臺階開始。
- 支付 1 ,向上爬兩個臺階,到達下標為 2 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 4 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 6 的臺階。
- 支付 1 ,向上爬一個臺階,到達下標為 7 的臺階。
- 支付 1 ,向上爬兩個臺階,到達下標為 9 的臺階。
- 支付 1 ,向上爬一個臺階,到達樓梯頂部。
總花費為 6 。

  1. 確定dp陣列以及下標的含義
  • dp[i]的定義:到達第i臺階所花費的最少體力為dp[i]
  1. 確定遞推公式
  • 有兩個途徑得到dp[i],一個是dp[i-1] 一個是dp[i-2]
    • dp[i - 1] 跳到 dp[i] 需要花費 dp[i - 1] + cost[i - 1]。
    • dp[i - 2] 跳到 dp[i] 需要花費 dp[i - 2] + cost[i - 2]。
  • 那麼究竟是選從dp[i - 1]跳還是從dp[i - 2]跳呢?
  • 一定是選最小的,所以 dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
  1. dp陣列如何初始化

題目描述中明確說了 “你可以選擇從下標為 0 或下標為 1 的臺階開始爬樓梯。” 也就是說到達第 0 、1個臺階是不花費的,但從 第0 個臺階往上跳的話,需要花費 cost[0]。所以初始化 dp[0] = 0,dp[1] = 0;

  1. 確定遍歷順序

因為是模擬臺階,而且dp[i]由dp[i-1]dp[i-2]推出,所以是從前到後遍歷cost陣列就可以了。

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;
        int dp[] = new int[n + 1];
        dp[0] = 0;
        dp[1] = 0;
        for(int i = 2; i <= n; i++){
            dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
       return dp[n];
    }
}

118. 楊輝三角

給定一個非負整數 numRows生成「楊輝三角」的前 numRows 行。

在「楊輝三角」中,每個數是它左上方和右上方的數的和。

img

示例 1:

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

示例 2:

輸入: numRows = 1
輸出: [[1]]
class Solution {
    public List<List<Integer>> generate(int numRows) {
        List<List<Integer>> res = new ArrayList<>();
        for(int i = 0; i < numRows; i++){
            List<Integer> row = new ArrayList<Integer>();
            for (int j = 0; j <= i; j++) {
                if(j == 0 || j == i) row.add(1);
                else row.add(res.get(i - 1).get(j - 1) + res.get(i - 1).get(j));
            }
            res.add(row);
        }
        return res;
    }
}

62. 不同路徑

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

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

問總共有多少條不同的路徑?

示例 1:

img

輸入: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

我們令 dp[i][j] 是到達 i, j 最多路徑

動態方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]

注意,對於第一行 dp[0][j],或者第一列 dp[i][0],由於都是在邊界,所以只能為 1

class Solution {
    public int uniquePaths(int m, int n) {
        int dp[][] = new int[m][n];
        //初始化
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int i = 0; i < n; i++) {
            dp[0][i] = 1;
        }
        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                //到達i行j列的路徑條數
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
}

63. 不同路徑 II

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

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

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

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

示例 1:

img

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

示例 2:

img

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

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length;
        int n = obstacleGrid[0].length;
        int dp[][] = new int[m][n];
        for(int i = 0; i < m && obstacleGrid[i][0] == 0; i++){
            dp[i][0] = 1;
        }
        for(int j = 0; j < n && obstacleGrid[0][j] == 0; j++){
            dp[0][j] = 1;
        }
        for(int i = 1; i < m; i++){
            for(int 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];
    }
}

343. 整數拆分

劍指 Offer 14- I. 剪繩子 - 力扣(Leetcode)

給定一個正整數 n ,將其拆分為 k正整數 的和( k >= 2 ),並使這些整數的乘積最大化。

返回 你可以獲得的最大乘積

示例 1:

輸入: n = 2
輸出: 1
解釋: 2 = 1 + 1, 1 × 1 = 1。

示例 2:

輸入: n = 10
輸出: 36
解釋: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。

動規五部曲,分析如下:

  1. 確定dp陣列以及下標的含義

dp[i]:分拆數字 i,可以得到的最大乘積為dp[i]

  1. 確定遞推公式

i ≥ 2 時,假設對正整數 i 拆分出的第一個正整數是 j1 ≤ j < i),則有以下兩種方案:

  • i 拆分成 ji−j 的和,且 i−j 不再拆分成多個正整數,此時的乘積是 j×(i−j)
  • i 拆分成 ji−j 的和,且 i−j 繼續拆分成多個正整數,此時的乘積是 j×dp[i−j]

遞推公式:dp[i]=max{dp[i], j×(i−j), j×dp[i−j]}

  1. dp的初始化

初始化 dp[2] = 1

  1. 確定遍歷順序
  • 2 < i <= n
  • 1 < j < i
for (int i = 3; i <= n ; i++) {
    for (int j = 1; j < i - 1; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}
  1. 舉例推導dp陣列

。。。

class Solution {
    public int integerBreak(int n) {
        int dp[] = new int[n + 1];
        dp[2] = 1;
        for(int i = 3; i <= n; i++){
            for(int j = 1; j < i; j++){
                dp[i] = Math.max(dp[i], Math.max(j * (i - j), j * dp[i - j]));
            }
        } 
        return dp[n];
    }
}

劍指 Offer 14- II. 剪繩子 II

給你一根長度為 n 的繩子,請把繩子剪成整數長度的 m 段(m、n都是整數,n>1並且m>1),每段繩子的長度記為 k[0],k[1]...k[m - 1] 。請問 k[0]*k[1]*...*k[m - 1] 可能的最大乘積是多少?例如,當繩子的長度是8時,我們把它剪成長度分別為2、3、3的三段,此時得到的最大乘積是18。

答案需要取模 1e9+7(1000000007),如計算初始結果為:1000000008,請返回 1。

示例 1:

輸入: 2
輸出: 1
解釋: 2 = 1 + 1, 1 × 1 = 1

示例 2:

輸入: 10
輸出: 36
解釋: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36

每段長度取3最好

class Solution {
    public int cuttingRope(int n) {
        if(n == 2)
            return 1;
        if(n == 3)
            return 2;
        long res = 1;
        while(n > 4){
            //每一段取3進行累積
            res *= 3;
            res = res % 1000000007;
            n -= 3;
        }
        return (int)(res * n % 1000000007);//最後一段不能被3整除,累積上再取mod
    }
}

96. 不同的二叉搜尋樹

給你一個整數 n ,求恰由 n 個節點組成且節點值從 1n 互不相同的 二叉搜尋樹 有多少種?返回滿足題意的二叉搜尋樹的種數。

示例 1:

img

輸入:n = 3
輸出:5

示例 2:

輸入:n = 1
輸出:1

  1. 確定dp陣列(dp table)以及下標的含義

dp[i] : 1到i為節點組成的二叉搜尋樹的個數為dp[i]。

  1. 確定遞推公式

假設一共i個節點,對於根節點為j時,左子樹的節點個數為j-1,右子樹的節點個數為i-j

對於根節點為j時,其遞推關係, dp[i] = ∑(1<=j<=i) dp[左子樹節點數量] * dp[右子樹節點數量]j是頭結點的元素,從1遍歷到i為止。

所以遞推公式:dp[i] += dp[j - 1] * dp[i - j];

  1. dp陣列如何初始化
  • dp[0] = 1;
  • dp[1] = 1;
  1. 確定遍歷順序
for (int i = 2; i <= n; i++) {
    for (int j = 1; j <= i; j++) {
        
    }
}
class Solution {
    public int numTrees(int n) {
        int[] dp = new int[n + 1];
        dp[0] = 1;
        dp[1] = 1;
        for(int i = 2; i <= n; i++){
            for(int j = 1; j <= i; j++){
                dp[i] += dp[j - 1] * dp[i - j];   
            }
        }
        return dp[n];
    }
}

338. 位元位計數

給你一個整數 n ,對於 0 <= i <= n 中的每個 i ,計算其二進位制表示中 1 的個數 ,返回一個長度為 n + 1 的陣列 ans 作為答案。

示例 1:

輸入:n = 2
輸出:[0,1,1]
解釋:
0 --> 0
1 --> 1
2 --> 10

示例 2:

輸入:n = 5
輸出:[0,1,1,2,1,2]
解釋:
0 --> 0
1 --> 1
2 --> 10
3 --> 11
4 --> 100
5 --> 101

進階:

  • 很容易就能實現時間複雜度為 O(nlogn) 的解決方案,你可以線上性時間複雜度 O(n) 內用一趟掃描解決此問題嗎?
  • 你能不使用任何內建函式解決此問題嗎?(如,C++ 中的 __builtin_popcount

動態規劃法

  1. 如果這個數是偶數,那麼其二進位制中1的個數一定和其1/2的數二進位制中1的個數一樣,因為1/2就相當於右移1位,即把偶數的最後一個0去掉,不會影響1的個數。
    • res[i] = res[i / 2]
  2. 如果這個數是奇數:那麼其二進位制中1的個數一定是其上一個偶數二進位制1的個數+1,因為就相當於將上一個偶數二進位制的最後1位的0變成1
    • res[i] = res[i - 1] + 1—>res[i] = res[i / 2] + 1
  3. 上述兩種情況可以合併成:res[i]的值等於 res[i/2]的值加上 i % 2
    • res[i / 2] + (i % 2)—>res[i >> 1] + (i & 1)
class Solution {
    public int[] countBits(int n) {
        int[] res = new int[n+1];
        res[0] = 0;
        for (int i = 1; i <= n; i++) {
            if (i % 2 == 1) {
                // 奇數:當前奇數的1的個數一定比上一個偶數+1
                res[i] = res[i - 1] + 1;
            } else {
                // 偶數:偶數1的個數一定和其1/2的偶數1的個數一樣
                res[i] = res[i / 2];
            }
        }
        return res;
    }
}

//最佳化
class Solution {
    public int[] countBits(int n) {
        int[] res = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            res[i] = res[i >> 1] + (i & 1);
        }
        return res;
    }
}

1137. 第 N 個泰波那契數

泰波那契序列 Tn 定義如下:

T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的條件下 Tn+3 = Tn + Tn+1 + Tn+2

給你整數 n,請返回第 n 個泰波那契數 Tn 的值。

示例 1:

輸入:n = 4
輸出:4
解釋:
T_3 = 0 + 1 + 1 = 2
T_4 = 1 + 1 + 2 = 4

示例 2:

輸入:n = 25
輸出:1389537 

class Solution {
    int[] dp = new int[38]; //用於防止重複計算
    public int tribonacci(int n) {
        if(n == 0) return 0;
        if(n <= 2) return 1;
        int a = 0, b = 1, c = 1;
        for(int i = 3; i <= n; i++){
            int d = a + b + c;
            a = b;
            b = c;
            c = d;
        }
        return c;
    }
}

❷01揹包問題

?解題方法

有n件物品和一個最多能背重量為w的揹包。第i件物品的重量是weight[i],得到的價值是value[i] 。每件物品只能用一次,求解將哪些物品裝入揹包裡物品價值總和最大。

二維陣列
  1. 確定dp陣列以及下標的含義

dp[i][j] 表示從下標為[0-i]的物品裡任意取,放進容量為j的揹包,價值總和最大是多少

  1. 確定遞推公式

有兩個方向推出來dp[i][j]

  • 不放物品i:由dp[i - 1][j]推出,即揹包容量為j,裡面不放物品i的最大價值,其實就是當物品i的重量大於揹包j的重量時,物品i無法放進揹包中,所以被揹包內的價值依然和前面相同。
  • 放物品i:由dp[i - 1][j - weight[i]]推出,那麼dp[i - 1][j - weight[i]] + value[i] ,就是揹包放物品i得到的最大價值

所以遞迴公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

  1. dp陣列如何初始化
  • 如果揹包容量j0的話,即dp[i][0],無論是選取哪些物品,揹包價值總和一定為0

  • 如果

    i
    

    0
    

    的話,即:

    dp[0][j]
    

    ,存放編號0的物品的時候,各個容量的揹包所能存放的最大價值。

    • j < weight[0]的時候,dp[0][j] 應該是 0,因為揹包容量比編號0的物品重量還小。
    • j >= weight[0]時,dp[0][j] 應該是value[0],因為揹包容量放足夠放編號0物品。
// dp陣列預先初始化就為0,這一步就可以省略,
for (int j = 0 ; j < weight[0]; j++) {
    dp[0][j] = 0;
}
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}
  1. 確定遍歷順序

先遍歷物品還是先遍歷揹包重量呢?其實都可以!! 但是先遍歷物品更好理解

// weight陣列的大小 就是物品個數
for(int i = 1; i < weight.size(); i++) { // 遍歷物品 從1開始
    for(int j = 0; j <= bagweight; j++) { // 遍歷揹包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j]; 
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

    }
}

具體實現程式碼

public class BagProblem {
    /**
     * 動態規劃獲得結果
     * @param weight  物品的重量
     * @param value   物品的價值
     * @param bagSize 揹包的容量
     */
    public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){

        // 建立dp陣列
        int goods = weight.length;  // 獲取物品的數量
        int[][] dp = new int[goods][bagSize + 1];

        // 初始化dp陣列
        for (int j = weight[0]; j <= bagSize; j++) {
            dp[0][j] = value[0];
        }

        // 填充dp陣列
        for (int i = 1; i < weight.length; i++) {
            for (int j = 1; j <= bagSize; j++) {
                if (j < weight[i]) {
                    /**
                     * 當前揹包的容量都沒有當前物品i大的時候,是不放物品i的
                     * 那麼前i-1個物品能放下的最大價值就是當前情況的最大價值
                     */
                    dp[i][j] = dp[i-1][j];
                } else {
                    /**
                     * 當前揹包的容量可以放下物品i
                     * 那麼此時分兩種情況:
                     *    1、不放物品i
                     *    2、放物品i
                     * 比較這兩種情況下,哪種揹包中物品的最大價值最大
                     */
                    dp[i][j] = Math.max(dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]);
                }
            }
        }

        // 列印dp陣列
        for (int i = 0; i < goods; i++) {
            for (int j = 0; j <= bagSize; j++) {
                System.out.print(dp[i][j] + "\t");
            }
            System.out.println("\n");
        }
    }
    public static void main(String[] args) {
        int[] weight = {1,3,4};
        int[] value = {15,20,30};
        int bagSize = 4;
        testWeightBagProblem(weight,value,bagSize);
    }
}
滾動陣列
  1. 確定dp陣列的定義

dp[j]表示:容量為j的揹包,所背的物品價值可以最大為dp[j]

  1. 一維dp陣列的遞推公式

dp[j]可以透過dp[j - weight[i]]推匯出來,dp[j - weight[i]]表示容量為j - weight[i]的揹包所背的最大價值。dp[j - weight[i]] + value[i] 表示容量為j的揹包,放入物品i了之後的價值

此時dp[j]有兩個選擇

  • 一個是取自己dp[j] 相當於二維dp陣列中的dp[i-1][j],即不放物品i
  • 一個是取dp[j - weight[i]] + value[i],即放物品i

所以遞迴公式為:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

可以看出相對於二維dp陣列的寫法,就是把dp[i][j]中i的維度去掉了。

  1. 一維dp陣列如何初始化
  • dp[0] = 0,因為揹包容量為0所背的物品的最大價值就是0。
  1. 一維dp陣列遍歷順序
for(int i = 0; i < weight.size(); i++) { // 遍歷物品 從0開始
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍歷揹包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

這裡大家發現和二維dp的寫法中,遍歷揹包的順序是不一樣的!

二維dp遍歷的時候,揹包容量是從小到大,而一維dp遍歷的時候,揹包是從大到小。

倒序遍歷是為了保證物品i只被放入一次!

具體實現程式碼

public class BagProblem {
    public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){
        int wLen = weight.length;
        //定義dp陣列:dp[j]表示揹包容量為j時,能獲得的最大價值
        int[] dp = new int[bagWeight + 1];
        //遍歷順序:先遍歷物品,再遍歷揹包容量
        for (int i = 0; i < wLen; i++){
            for (int j = bagWeight; j >= weight[i]; j--){
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }
        //列印dp陣列
        for (int j = 0; j <= bagWeight; j++){
            System.out.print(dp[j] + " ");
        }
    }
    public static void main(String[] args) {
        int[] weight = {1, 3, 4};
        int[] value = {15, 20, 30};
        int bagWight = 4;
        testWeightBagProblem(weight, value, bagWight);
    }
}

416. 分割等和子集

給你一個 只包含正整數非空 陣列 nums 。請你判斷是否可以將這個陣列分割成兩個子集,使得兩個子集的元素和相等。

示例 1:

輸入:nums = [1,5,11,5]
輸出:true
解釋:陣列可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

輸入:nums = [1,2,3,5]
輸出:false
解釋:陣列不能分割成兩個元素和相等的子集。

二維陣列

對於這個問題,我們可以先對集合求和,得出 sum,然後把問題轉化為揹包問題:

給一個可裝載重量為 sum / 2 的揹包和 N 個物品,每個物品的重量為 nums[i]。現在讓你裝物品,是否存在一種裝法,能夠恰好將揹包裝滿

dp 陣列的定義:dp[i][j] = x 表示,對於前 i 個物品,當前揹包的容量為 j 時,若 xtrue,則說明可以恰好將揹包裝滿,若 xfalse,則說明不能恰好將揹包裝滿。

根據 dp 陣列含義,可以根據「選擇」對 dp[i][j] 得到以下狀態轉移:

  • 如果不把這第 i 個物品裝入揹包,那麼是否能夠恰好裝滿揹包,取決於上一個狀態 dp[i-1][j],繼承之前的結果。
  • 如果把這第 i 個物品裝入了揹包,那麼是否能夠恰好裝滿揹包,取決於狀態 dp[i-1][j-nums[i]]
class Solution {
    public boolean canPartition(int[] nums) {
        int sum = 0;
        for(int num : nums){
            sum += num;
        }
        // 和為奇數時,不可能劃分成兩個和相等的集合
        if (sum % 2 != 0) return false;
        int n = nums.length;
        sum = sum / 2;
        boolean[][] dp = new boolean[n][sum + 1];
        for (int i = 0; i < n; i++){
            dp[i][0] = true;
        }
        for(int i = 1; i < n; i++){
            for(int j = 1; j <= sum; j++){
                if (j - nums[i] < 0) {
                    // 揹包容量不足,不能裝入第 i 個物品
                    dp[i][j] = dp[i - 1][j];
                } else {
                    // 裝入或不裝入揹包
                    dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
                }
            }
        }
        return dp[n - 1][sum];
    }
}
滾動陣列
class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;
        int sum = 0;
        for(int num : nums) {
            sum += num;
        }
        //總和為奇數,不能平分
        if(sum % 2 != 0) return false;
        sum = sum / 2;
        int[] dp = new int[sum + 1];
        for(int i = 0; i < n; i++) {
            for(int j = sum; j >= nums[i]; j--) {
                //物品 i 的重量是 nums[i],其價值也是 nums[i]
                dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        return dp[sum] == sum;
    }
}

1049. 最後一塊石頭的重量 II

有一堆石頭,用整數陣列 stones 表示。其中 stones[i] 表示第 i 塊石頭的重量。

每一回合,從中選出任意兩塊石頭,然後將它們一起粉碎。假設石頭的重量分別為 xy,且 x <= y。那麼粉碎的可能結果如下:

  • 如果 x == y,那麼兩塊石頭都會被完全粉碎;
  • 如果 x != y,那麼重量為 x 的石頭將會完全粉碎,而重量為 y 的石頭新重量為 y-x

最後,最多隻會剩下一塊 石頭。返回此石頭 最小的可能重量 。如果沒有石頭剩下,就返回 0

示例 1:

輸入:stones = [2,7,4,1,8,1]
輸出:1
解釋:
組合 2 和 4,得到 2,所以陣列轉化為 [2,7,1,8,1],
組合 7 和 8,得到 1,所以陣列轉化為 [2,1,1,1],
組合 2 和 1,得到 1,所以陣列轉化為 [1,1,1],
組合 1 和 1,得到 0,所以陣列轉化為 [1],這就是最優值。

示例 2:

輸入:stones = [31,26,33,21,40]
輸出:5

問題轉化為:把一堆石頭分成兩堆,求兩堆石頭重量差最小值

進一步分析:要讓差值小,兩堆石頭的重量都要接近sum/2

進一步轉化:將一堆stone放進最大容量為sum/2的揹包,求放進去的石頭的最大重量x,最終答案即為sum-2*x

  1. 確定dp陣列以及下標的含義

dp[j]表示容量為j的揹包,最多可以背最大重量為dp[j]

  1. 確定遞推公式

dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);

  1. dp陣列如何初始化

dp[0] = 0

  1. 確定遍歷順序

如果使用一維dp陣列,物品遍歷的for迴圈放在外層,遍歷揹包的for迴圈放在內層,且內層for迴圈倒序遍歷!

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for (int i : stones) {
            sum += i;
        }
        int target = sum >> 1;
        //初始化dp陣列
        int[] dp = new int[target + 1];
        for (int i = 0; i < stones.length; i++) {
            //採用倒序
            for (int j = target; j >= stones[i]; j--) {
                //兩種情況,要麼放,要麼不放
                dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return sum - 2 * dp[target];
    }
}

494. 目標和

給你一個整數陣列 nums 和一個整數 target

向陣列中的每個整數前新增 '+''-' ,然後串聯起所有整數,可以構造一個 表示式

  • 例如,nums = [2, 1] ,可以在 2 之前新增 '+' ,在 1 之前新增 '-' ,然後串聯起來得到表示式 "+2-1"

返回可以透過上述方法構造的、運算結果等於 target 的不同 表示式 的數目。

示例 1:

輸入:nums = [1,1,1,1,1], target = 3
輸出:5
解釋:一共有 5 種方法讓最終目標和為 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

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

回溯

回溯過程中維護一個計數器 count,當遇到一種表示式的結果等於目標數 target 時,將 count 的值加 1。遍歷完所有的表示式之後,即可得到結果等於目標數 target 的表示式的數目。

class Solution {
    int count = 0;

    public int findTargetSumWays(int[] nums, int target) {
        backtrack(nums, target, 0, 0);
        return count;
    }

    public void backtrack(int[] nums, int target, int index, int sum) {
        if (index == nums.length) {
            if (sum == target) {
                count++;
            }
        } else {
            backtrack(nums, target, index + 1, sum + nums[index]);
            backtrack(nums, target, index + 1, sum - nums[index]);
        }
    }
}
二維陣列

記陣列的元素和為 sum,新增 - 號的元素之和為 x,則其餘新增 + 的元素之和為 sum − x,得到的表示式的結果為(sum − x) − x = targetx = sum − target / 2

由於陣列 nums 中的元素都是非負整數,x 也必須是非負整數,所以上式成立的前提是 sum−target非負偶數。若不符合該條件可直接返回 0。

1 ≤ i ≤ n 時,對於陣列 nums 中的第 i 個元素 num(i 的計數從 1 開始),遍歷 0 ≤ j ≤ x,計算 dp[i][j] 的值:

  • 如果 j,則不能選 num,此時有 dp[i][j]=dp[i−1][j]`;
  • 如果 j≥num,則如果不選 num,方案數是 dp[i−1][j],如果選 num,方案數是 dp[i−1][j−num],此時有 dp[i][j]=dp[i−1][j]+dp[i−1][j−num]
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        int diff = sum - target;
        if (diff < 0 || diff % 2 != 0) {
            return 0;   
        }
        int n = nums.length, x = diff / 2;
        int[][] dp = new int[n][x + 1];
        dp[0][0] = 1;
        // 第一行要初始化,即物品0,對容量剛好為nums[0]的揹包,設定其方式dp[0][nums[0]]為1。
        // 有可能第一件物品重量就為0,所以會覆蓋dp[0][0],正確應該 +=
        for(int j = 0; j < n; j++) {
            if(nums[0]==j) dp[0][j]+=1; 
        }
        for (int i = 1; i < n; i++) {
            for (int j = 0; j <= x; j++) {
                if (j < nums[i]) {
                    // 揹包容量不足,不能裝入第 i 個物品
                    dp[i][j] = dp[i - 1][j];
                } else {
                    // 裝入或不裝入揹包
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
                }
            }
        }
        return dp[n - 1][x];
    }
}
滾動陣列
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        int diff = sum - target;
        if (diff < 0 || diff % 2 != 0) {
            return 0;   
        }
        int n = nums.length, x = diff / 2;
        int[] dp = new int[x + 1];
        dp[0] = 1;

        for (int i = 0; i < n; i++) {
            for (int j = x; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[x];
    }
}

❸完全揹包問題

?解題方法

有N件物品和一個最多能背重量為W的揹包。第i件物品的重量是weight[i],得到的價值是value[i] 。每件物品都有無限個(也就是可以放入揹包多次),求解將哪些物品裝入揹包裡物品價值總和最大。

完全揹包和01揹包問題唯一不同的地方就是,每種物品有無限件

01揹包和完全揹包解題步驟唯一不同就是體現在遍歷順序上

首先在回顧一下01揹包的核心程式碼

for(int i = 0; i < weight.size(); i++) { // 遍歷物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍歷揹包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

我們知道01揹包內嵌的迴圈是從大到小遍歷,為了保證每個物品僅被新增一次。

而完全揹包的物品是可以新增多次的,所以要從小到大去遍歷,即:

// 先遍歷物品,再遍歷揹包
for(int i = 0; i < weight.size(); i++) { // 遍歷物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍歷揹包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

具體實現程式碼

//先遍歷物品,再遍歷揹包
private static void testCompletePack(){
    int[] weight = {1, 3, 4};
    int[] value = {15, 20, 30};
    int bagWeight = 4;
  
    int[] dp = new int[bagWeight + 1];
    for (int i = 0; i < weight.length; i++){ // 遍歷物品
        for (int j = weight[i]; j <= bagWeight; j++){ // 遍歷揹包容量
            dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
  
    for (int maxValue : dp){
        System.out.println(maxValue + "   ");
    }
}

322. 零錢兌換

給你一個整數陣列 coins ,表示不同面額的硬幣;以及一個整數 amount ,表示總金額。

計算並返回可以湊成總金額所需的 最少的硬幣個數 。如果沒有任何一種硬幣組合能組成總金額,返回 -1

你可以認為每種硬幣的數量是無限的。

示例 1:

輸入:coins = [1, 2, 5], amount = 11
輸出:3 
解釋:11 = 5 + 5 + 1

示例 2:

輸入:coins = [2], amount = 3
輸出:-1

示例 3:

輸入:coins = [1], amount = 0
輸出:0

  1. 確定dp陣列以及下標的含義

dp[j]:湊足總額為j所需錢幣的最少個數為dp[j]

  1. 確定遞推公式

湊足總額為j - coins[i]的最少個數為dp[j - coins[i]],那麼只需要再加上一個錢幣coins[i],即dp[j] = dp[j - coins[i]] + 1

遞推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);

  1. dp陣列如何初始化

考慮到遞推公式的特性,dp[j]必須初始化為一個大的數,否則就會在min(dp[j - coins[i]] + 1, dp[j])比較的過程中被初始值覆蓋。所以下標非0的元素都是應該是大值。

湊足總金額為0所需錢幣的個數一定是0,那麼dp[0] = 0;

class Solution {
    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        int max = amount + 1;
        //初始化dp陣列為最大值
        for (int j = 0; j < dp.length; j++) { // Arrays.fill(dp, max);
            dp[j] = max;
        }
       //當金額為0時需要的硬幣數目為0
        dp[0] = 0;
        for(int i = 0; i < coins.length; i++) {
            //正序遍歷:完全揹包每個硬幣可以選擇多次
            for(int j = coins[i]; j <= amount; j++){
                if (coins[i] <= j) {
                    dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
                }
            }
        }
        return dp[amount] == max ? -1 : dp[amount];
    }
}

518. 零錢兌換 II

給你一個整數陣列 coins 表示不同面額的硬幣,另給一個整數 amount 表示總金額。

請你計算並返回可以湊成總金額的硬幣組合數。如果任何硬幣組合都無法湊出總金額,返回 0

假設每一種面額的硬幣有無限個。 題目資料保證結果符合 32 位帶符號整數。

示例 1:

輸入:amount = 5, coins = [1, 2, 5]
輸出:4
解釋:有四種方式可以湊成總金額:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:

輸入:amount = 3, coins = [2]
輸出:0
解釋:只用面額 2 的硬幣不能湊成總金額 3 。

示例 3:

輸入:amount = 10, coins = [10] 
輸出:1

  1. 確定dp陣列以及下標的含義

dp[j]:湊足總額為j的硬幣組合數為dp[j]

  1. 確定遞推公式
  • 如果不選第 i 個硬幣:dp[j]
  • 如果選第 i 個硬幣:dp[j - coins[i]]

遞推公式:dp[j] += dp[j - coins[i]]

  1. dp陣列如何初始化

湊足總金額為0組合數是0,那麼dp[0] = 1;

class Solution {
    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;
        for(int i = 0; i < coins.length; i++){
            for(int j = coins[i]; j <= amount; j++){
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
}

279. 完全平方數

給你一個整數 n ,返回 和為 n 的完全平方數的最少數量

完全平方數 是一個整數,其值等於另一個整數的平方;換句話說,其值等於一個整數自乘的積。例如,14916 都是完全平方數,而 311 不是。

示例 1:

輸入:n = 12
輸出:3 
解釋:12 = 4 + 4 + 4

示例 2:

輸入:n = 13
輸出:2
解釋:13 = 4 + 9

動規五部曲分析如下:

  1. 確定dp陣列(dp table)以及下標的含義

dp[j]:和為j的完全平方數的最少數量

  1. 確定遞推公式

dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以湊成dp[j]。

此時我們要選擇最小的dp[j],所以遞推公式:dp[j] = min(dp[j - i * i] + 1, dp[j])

  1. dp陣列如何初始化

dp[0]表示和為0的完全平方數的最小數量,那麼dp[0]一定是0。

非0下標的dp[j]應該是多少呢?

從遞迴公式中可以看出每次dp[j]都要選最小的,所以非0下標的dp[j]一定要初始為最大值,這樣dp[j]在遞推的時候才不會被初始值覆蓋。

  1. 確定遍歷順序
i`必然落在區間 `[1, √n]`。`j`必然落在區間`[i*i, n]
//先遍歷物品, 再遍歷揹包
class Solution {
    public int numSquares(int n) {
        //dp[j]: 和為j的完全平方數的最少數量
        int[] dp = new int[n + 1];
        int max = n + 1;
        for (int j = 0; j <= n; j++) {
            dp[j] = max;
        }
        dp[0] = 0;
        for(int i = 1; i <= Math.sqrt(n); i++){
            for(int j = i*i; j <= n; j++){
                dp[j] = Math.min(dp[j], dp[j - i*i] + 1); 
            }
        }
        return dp[n];
    }
}


//先遍歷揹包, 再遍歷物品
class Solution {
    public int numSquares(int n) {
        int max = n + 1;
        int[] dp = new int[n + 1];
        // 初始化
        Arrays.fill(dp, max);
        // 當和為0時,組合的個數為0
        dp[0] = 0;
        // 遍歷揹包
        for (int j = 1; j <= n; j++) {
            // 遍歷物品
            for (int i = 1; i * i <= j; i++) {
                dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }
}

377. 組合總和 Ⅳ

給你一個由 不同 整陣列成的陣列 nums ,和一個目標整數 target 。請你從 nums 中找出並返回總和為 target 的元素組合的個數。題目資料保證答案符合 32 位整數範圍。

示例 1:

輸入:nums = [1,2,3], target = 4
輸出:7
解釋:
所有可能的組合為:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
請注意,順序不同的序列被視作不同的組合。

示例 2:

輸入:nums = [9], target = 3
輸出:0

本題要求的是排列,那麼這個for迴圈巢狀的順序可以有說法了。

class Solution {
    public int combinationSum4(int[] nums, int target) {

        int[] dp = new int[target + 1];
        dp[0] = 1;
         for(int j = 0; j <= target; j++){ //揹包容量
            for(int i = 0; i < nums.length; i++){ //物品個數
                if (nums[i] <= j) {
                    dp[j] += dp[j - nums[i]];
                }
            }
        }
        return dp[target];
    }
}

139. 單詞拆分

給你一個字串 s 和一個字串列表 wordDict 作為字典。請你判斷是否可以利用字典中出現的單詞拼接出 s

注意:不要求字典中出現的單詞全部都使用,並且字典中的單詞可以重複使用。

示例 1:

輸入: s = "leetcode", wordDict = ["leet", "code"]
輸出: true
解釋: 返回 true 因為 "leetcode" 可以由 "leet" 和 "code" 拼接成。

示例 2:

輸入: s = "applepenapple", wordDict = ["apple", "pen"]
輸出: true
解釋: 返回 true 因為 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
     注意,你可以重複使用字典中的單詞。

示例 3:

輸入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
輸出: false

dp[i] 表示字串 s 前 i 個字元組成的字串 s[0..i−1] 是否能被空格拆分成若干個字典中出現的單詞

public class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        Set<String> set = new HashSet(wordDict);
        boolean[] dp = new boolean[n + 1];
        dp[0] = true;
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j < i; j++) {
                // if (set.contains(s.substring(j, i))) dp[i] |= dp[j];
                if (dp[j] && set.contains(s.substring(j, i))) {
                    dp[i] = true;
                    //break;
                }
            }
        }
        return dp[n];
    }
}

❹揹包問題總結

遞推公式

問能否能裝滿揹包(或者最多裝多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]),對應題目如下:

問裝滿揹包有幾種方法:dp[j] += dp[j - nums[i]] ,對應題目如下:

問裝滿揹包所有物品的最小個數:dp[j] = min(dp[j - coins[i]] + 1, dp[j]) ,對應題目如下:

遍歷順序

01揹包

  • 二維dp陣列01揹包先遍歷物品還是先遍歷揹包都是可以的,且第二層for迴圈是從小到大遍歷。
  • 一維dp陣列01揹包只能先遍歷物品再遍歷揹包容量,且第二層for迴圈是從大到小遍歷。

完全揹包

完全揹包的一維dp陣列實現,先遍歷物品還是先遍歷揹包都是可以的,且第二層for迴圈是從小到大遍歷。

  • 如果求組合數就是外層for迴圈遍歷物品,內層for遍歷揹包。
  • 如果求排列數就是外層for遍歷揹包,內層for迴圈遍歷物品。

❺股票買賣問題

?解題方法

參考:買賣股票系列一個方法團滅 LeetCode 股票買賣問題

力扣 特點 難度
121. 買賣股票的最佳時機 只進行一次交易 ?
122. 買賣股票的最佳時機 II 不限交易次數 ?
123. 買賣股票的最佳時機 III 限制 2 次交易 ?
188. 買賣股票的最佳時機 IV 限制 k 次交易 ?
309. 買賣股票的最佳時機含冷凍期 加了交易「冷凍期」 ?
714. 買賣股票的最佳時機含手續費 加了交易「手續費」 ?
劍指 Offer 63. 股票的最大利潤 同第一題 ?

動態規劃演算法本質上就是窮舉「狀態」,然後在「選擇」中選擇最優解。

股票買賣問題,每天都有三種「選擇」:買入( buy)、賣出(sell)、無操作(rest

但並不是每天都可以任意選擇這三種選擇的:

    1. sell 必須在 buy 之後,buy 必須在 sell 之後。
    1. rest 應該分兩種狀態
    • 一種是 buy 之後的 rest(持有了股票);
    • 一種是 sell 之後的 rest(沒有持有股票)。
  • 而且還有交易次數 k 的限制,即 buy 只能在 k > 0 的前提下操作。

股票買賣問題的「狀態」有三個,第一個是天數,第二個是允許交易的最大次數,第三個是當前的持有狀態(不妨用 1 表示持有,0 表示沒有持有)。然後我們用一個三維陣列就可以裝下這幾種狀態的全部組合:

dp[i][k][0 or 1]
  
// 0 <= i < n, 1 <= k <= K
// n 為天數,大 K 為交易數的上限,0 和 1 代表是否持有股票。
// 此問題共 n × K × 2 種狀態,全部窮舉就能搞定。

for 0 <= i < n:
    for 1 <= k <= K:
        for s in {0, 1}:
            dp[i][k][s] = max(buy, sell, rest)

例如:

dp[3][2][1] 的含義:第三天,我現在手上持有著股票,至今已進行 2 次交易。

dp[2][3][0] 的含義:第二天,我現在手上沒有持有股票,至今已進行 3 次交易。

我們想求的最終答案是 dp[n - 1][K][0],即最後一天,已進行 K 次交易,最多獲得多少利潤。

確定遞推公式

狀態轉移圖

今天沒有持有股票,有兩種可能,我從這兩種可能中求最大利潤:

  • 1、我昨天就沒有持有,且截至昨天已進行交易次數為 k;然後我今天選擇 rest,所以我今天還是沒有持有,已進行交易次數為 k。程式碼表示:dp[i-1][k][0]
  • 2、我昨天持有股票,且截至昨天已進行交易次數為 k-1;但是今天我 sell 了,所以我今天沒有持有股票了,已進行交易次數為 k。程式碼表示:dp[i-1][k-1][1] + prices[i]
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k-1][1] + prices[i])

今天我持有著股票,有兩種可能,我從這兩種可能中求最大利潤:

  • 1、我昨天就持有著股票,且截至昨天已進行交易次數為 k;然後今天選擇 rest,所以我今天還持有著股票,已進行交易次數依然為 k。程式碼表示:dp[i-1][k][1]
  • 2、我昨天沒有持有,且截至昨天已進行交易次數為 k;但今天我選擇 buy,所以今天我就持有股票了,已進行交易次數為 k。程式碼表示:dp[i-1][k][0] - prices[i]

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k][0] - prices[i])

時刻牢記「狀態」的定義,狀態 k 的定義並不是「已進行的交易次數」,而是「最大交易次數的上限限制」。如果確定今天進行一次交易,且要保證截至今天最大交易次數上限為 k,那麼昨天的最大交易次數上限必須是 k - 1

總結一下:

// 遞推公式:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])


base case:
dp[-1][...][0] = 0
// 解釋:因為 i 是從 0 開始的,所以 i = -1 意味著還沒有開始,這時候的利潤當然是 0。

dp[-1][...][1] = -infinity
// 解釋:還沒開始的時候,是不可能持有股票的。
// 因為我們的演算法要求一個最大值,所以初始值設為一個最小值,方便取最大值。

dp[...][0][0] = 0
// 解釋:因為 k 是從 1 開始的,所以 k = 0 意味著根本不允許交易,這時候利潤當然是 0。

dp[...][0][1] = -infinity
// 解釋:不允許交易的情況下,是不可能持有股票的。
// 因為我們的演算法要求一個最大值,所以初始值設為一個最小值,方便取最大值。

121. 買賣股票的最佳時機

給定一個陣列 prices ,它的第 i 個元素 prices[i] 表示一支給定股票第 i 天的價格。

你只能選擇 某一天 買入這隻股票,並選擇在 未來的某一個不同的日子 賣出該股票。設計一個演算法來計算你所能獲取的最大利潤。

返回你可以從這筆交易中獲取的最大利潤。如果你不能獲取任何利潤,返回 0

示例 1:

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

示例 2:

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

方法一:暴力法

  • 找出給定陣列中兩個數字之間的最大差值(即,最大利潤)。
  • 此外,第二個數字(賣出價格)必須大於第一個數字(買入價格)。

即對於每組 i 和 j,其中 j > i ,我們需要找出 max⁡(prices[j]−prices[i])

class Solution {
    public int maxProfit(int[] prices) {
        int res = 0;
        for(int i = 0; i < prices.length - 1; i++){
            for(int j = i + 1; j < prices.length; j++){
                res = Math.max(res, prices[j] - prices[i]); 
            }
        }
        return res;
    }
}
  • 時間複雜度:O(n^2)。迴圈執行 n(n−1)/2次。
  • 空間複雜度:O(1)。只使用了常數個變數。

方法二:貪心法

我們用一個變數記錄一個歷史最低價格 minprice,我們就可以假設自己的股票是在那天買的。那麼我們在第 i 天賣出股票能得到的利潤就是 prices[i] - minprice。

class Solution {
    public int maxProfit(int prices[]) {
        int minprice = Integer.MAX_VALUE;
        int res = 0;
        for (int i = 0; i < prices.length; i++) {
            minprice = Math.min(minprice, prices[i]);
            res = Math.max(res, prices[i] - minprice);
        }
        return res;
    }
}
  • 時間複雜度:O(n),只需要遍歷一次。
  • 空間複雜度:O(1),只使用了常數個變數。

方法三:動態規劃最佳化

套用前文解題方法中遞推公式,相當於 k = 1 的情況:

dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][0] - prices[i]) = max(dp[i-1][1][1], -prices[i])
// 解釋:k = 0 的 base case,所以 dp[i-1][0][0] = 0。

//現在發現 k 都是 1,不會改變,即 k 對狀態轉移已經沒有影響了。可以進行進一步化簡去掉所有 k:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], -prices[i])
class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        // dp[i][0]代表第i天不持有股票的最大收益
        // dp[i][1]代表第i天持有股票的最大收益
        int[][] dp = new int[n][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for (int i = 1; i < n; i++) {
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            // 由於只能買入和賣出一次,因此如果在第 i 天買入則在買入之前的利潤一定是 0,
           // 因此在第 i 天買入對應的利潤是 −prices[i] 而不是 dp[i−1][0]−prices[i]
            dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
        }
        return dp[n - 1][0];
    }
}

方法四:動態規劃最佳化

  1. 確定dp陣列以及下標的含義

下標為0記錄持有股票所得最多現金,下標為1記錄不持有股票所得最多現金。

所以本題dp陣列就是一個長度為2的陣列!

  1. 確定遞推公式

如果第i天持有股票即dp[0], 那麼可以由兩個狀態推出來

  • 第i-1天就持有股票,那麼就保持現狀,所得現金就是昨天持有股票的所得現金 dp[0],
  • 第i天買入股票,所得現金就是買入今天的股票後所得現金即:-prices[i]
    • 其實一開始現金是0,那麼加入第i天買入股票現金就是 -prices[i], 這是一個負數。
  • dp[0] = Math.max(dp[0], -prices[i - 1]);

如果第i天不持有股票即dp[1], 也可以由兩個狀態推出來

  • 第i-1天就不持有股票,那麼就保持現狀,所得現金就是昨天不持有股票的所得現金 即:dp[1]
  • 第i天賣出股票,所得現金就是按照今天股票價格賣出後所得現金即:prices[i] + dp[0]
  • dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
class Solution {
  public int maxProfit(int[] prices) {
    int[] dp = new int[2];
    // 記錄一次交易,一次交易有買入賣出兩種狀態
    // 0代表持有,1代表賣出
    dp[0] = -prices[0];
    dp[1] = 0;
    for (int i = 1; i <= prices.length; i++) {
      // 前一天持有;或當天買入
      dp[0] = Math.max(dp[0], -prices[i - 1]);
      // 前一天賣出;或當天賣出, 當天要賣出,得前一天持有才行
      dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
    }
    return dp[1];
  }
}

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

給你一個整數陣列 prices ,其中 prices[i] 表示某支股票第 i 天的價格。

在每一天,你可以決定是否購買和/或出售股票。你在任何時候 最多 只能持有 一股 股票。你也可以先購買,然後在 同一天 出售。返回 你能獲得的 最大 利潤。

示例 1:

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

示例 2:

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

示例 3:

輸入:prices = [7,6,4,3,1]
輸出:0
解釋:在這種情況下, 交易無法獲得正利潤,所以不參與交易可以獲得最大利潤,最大利潤為 0 。 

方法一:動態規劃

套用前文解題方法中遞推公式,相當於 k = ∞ 的情況:

如果 k 為正無窮,那麼就可以認為 kk - 1 是一樣的。可以這樣改寫框架:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
            = max(dp[i-1][k][1], dp[i-1][k][0] - prices[i])

我們發現陣列中的 k 已經不會改變了,也就是說不需要記錄 k 這個狀態了:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])

注意這裡和121. 買賣股票的最佳時機唯一不同的地方,就是推導dp[i][1]的時候,第i天買入股票的情況,因為股票全程只能買賣一次,所以如果買入股票,那麼第i天持有股票即dp[i][1]一定就是 -prices[i],而本題,因為一隻股票可以買賣多次,所以當第i天買入股票的時候,所持有的現金可能有之前買賣過的利潤。

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for(int i = 1; i < n; i++){
            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[n - 1][0];
    }
}

方法二:貪心法

這道題 「貪心」 的地方在於,只要「今天的股價 > 昨天的股價」就累加差值

class Solution {
    public int maxProfit(int[] prices) {
        int res = 0;
        for (int i = 1; i < prices.length; i++) {
            if(prices[i] > prices[i - 1]){
                res += (prices[i] - prices[i-1]);
            }
        }
        return res;
    }
}

309. 買賣股票的最佳時機含冷凍期

給定一個整數陣列prices,其中第 prices[i] 表示第 i 天的股票價格 。

設計一個演算法計算出最大利潤。在滿足以下約束條件下,你可以儘可能地完成更多的交易(多次買賣一支股票):

  • 賣出股票後,你無法在第二天買入股票 (即冷凍期為 1 天)。

注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

示例 1:

輸入: prices = [1,2,3,0,2]
輸出: 3 
解釋: 對應的交易狀態為: [買入, 賣出, 冷凍期, 買入, 賣出]

示例 2:

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

套用前文解題方法中遞推公式,和上一題類似,區別在於每次 sell 之後要等一天才能繼續交易,只要把這個特點融入狀態轉移方程即可:

dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-2][0] - prices[i])
// 解釋:第 i 天選擇 buy 的時候,要從 i-2 的狀態轉移,而不是 i-1 。

由於 i - 2 也可能小於 0,所以再新增一個 i = 1 的 base case

class Solution {
    public int maxProfit(int[] prices) {
        if (prices == null || prices.length < 2) {
            return 0;
        }
        int n = prices.length;
        int[][] dp = new int[n][2];
        // base case
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        dp[1][0] = Math.max(dp[0][0], dp[0][1] + prices[1]);
        dp[1][1] = Math.max(dp[0][1], dp[0][0] - prices[1]);
        for(int i = 2; i < n; i++){
            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-2][0] - prices[i]);
        }
        return dp[n - 1][0];
    }
}

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        // base case
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for (int i = 1; i < n; i++) {
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i - 1][1], ((i - 2) < 0 ? 0 : dp[i - 2][0]) - prices[i]);
        }
        return dp[n - 1][0];
    }
}

714. 買賣股票的最佳時機含手續費

給定一個整數陣列 prices,其中 prices[i]表示第 i 天的股票價格 ;整數 fee 代表了交易股票的手續費用。

你可以無限次地完成交易,但是你每筆交易都需要付手續費。如果你已經購買了一個股票,在賣出它之前你就不能再繼續購買股票了。返回獲得利潤的最大值。

注意:這裡的一筆交易指買入持有並賣出股票的整個過程,每筆交易你只需要為支付一次手續費。

示例 1:

輸入:prices = [1, 3, 2, 8, 4, 9], fee = 2
輸出:8
解釋:能夠達到的最大利潤:  
在此處買入 prices[0] = 1
在此處賣出 prices[3] = 8
在此處買入 prices[4] = 4
在此處賣出 prices[5] = 9
總利潤: ((8 - 1) - 2) + ((9 - 4) - 2) = 8

示例 2:

輸入:prices = [1,3,7,5,10,3], fee = 3
輸出:6

方法一:動態規劃

套用前文解題方法中遞推公式,相當於 k = ∞ 且含手續費的情況:

class Solution {
    public int maxProfit(int[] prices, int fee) {
        int n = prices.length;
        int[][] dp = new int [n][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for(int i = 1; i < n; i++){
            dp[i][0] = Math.max(dp[i-1][0], dp[i - 1][1] + prices[i] - fee);
            dp[i][1] = Math.max(dp[i-1][1], dp[i - 1][0] - prices[i]);
        }
        return dp[n-1][0];
    }
}                                                       

方法二:貪心演算法

我們開始用buy記錄購買時的費用,開始購買時連帶交易費一起計算了,之後我們分三種情況進行討論:

  1. 這一天的價格+交易費小於前一天的buy,這時候說明這一天比前一天購買花費更低,將buy更新為這一天的價格+交易費
  2. 這一天的價格大於前一天的buy,說明我們是有利潤可以獲得的,所以我們賣出股票。然後再讓buy = 這一天的價格,這相當於一個後悔機制,如果之後我們接著遇到了比這一天賣出價格更高的時候,我們就選擇從價格更高的那天賣出。
  3. 這一天價格等於前一天的buy(價格+交易費),這時候我們不做操作
class Solution {
    public int maxProfit(int[] prices, int fee) {
        int n = prices.length;
        int buy = prices[0] + fee;
        int profit = 0;
        for (int i = 1; i < n; ++i) {
            if (prices[i] + fee < buy) {
                buy = prices[i] + fee;
            } else if (prices[i] > buy) {
                profit += prices[i] - buy;
                buy = prices[i];
            }
        }
        return profit;
    }
}

123. 買賣股票的最佳時機 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

套用前文解題方法中遞推公式,相當於 k = 2 的情況:

class Solution {
    public int maxProfit(int[] prices) {
        int K = 2;
        int n = prices.length;
        int[][][] dp = new int[n][K + 1][2];
        // base case:
        for (int k = 0; k <= K; k++) {
            dp[0][k][0] = 0;
            dp[0][k][1] = -prices[0];
        }
        // 可省略
        // for (int i = 1; i < n; i++) {
        //     dp[i][0][0] = 0;
        //     dp[i][0][1] = Math.max(dp[i - 1][0][1], dp[i - 1][0][0] - prices[i]);
        // }

        // state transition:
        for (int i = 1; i < n; i++) {
            for (int k = 1; k <= K; k++) {
                dp[i][k][0] = Math.max(dp[i - 1][k][0], dp[i - 1][k][1] + prices[i]);
                dp[i][k][1] = Math.max(dp[i - 1][k][1], dp[i - 1][k-1][0] - prices[i]);
            }
        }
        return dp[n - 1][K][0];
    }
}

由於我們最多可以完成兩筆交易,因此在任意一天結束之後,我們會處於以下五個狀態中的一種:

  • 未進行過任何操作;
  • 只進行過一次買操作;
  • 進行了一次買操作和一次賣操作,即完成了一筆交易;
  • 在完成了一筆交易的前提下,進行了第二次買操作;
  • 完成了全部兩筆交易。

由於第一個狀態的利潤顯然為 000,因此我們可以不用將其記錄。對於剩下的四個狀態,我們分別將它們的最大利潤記為 buy1,sell1,buy2 以及 sell2。

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        //第一次交易的買入和賣出
        int buy1 = -prices[0], sell1 = 0;
        //第二次交易的買入和賣出
        int buy2 = -prices[0], sell2 = 0;
        for (int i = 1; i < n; ++i) {
            // 要麼保持不變,要麼沒有就買,有了就賣
            buy1 = Math.max(buy1, -prices[i]);
            sell1 = Math.max(sell1, buy1 + prices[i]);
            // 這已經是第二次交易了,所以得加上前一次交易賣出去的收穫
            buy2 = Math.max(buy2, sell1 - prices[i]);
            sell2 = Math.max(sell2, buy2 + prices[i]);
        }
        return sell2;
    }
}

188. 買賣股票的最佳時機 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 。

class Solution {
    public int maxProfit(int K, int[] prices) {
        int n = prices.length;
        int[][][] dp = new int[n][K + 1][2];
        // base case:
        for (int k = 0; k <= K; k++) {
            dp[0][k][0] = 0;
            dp[0][k][1] = -prices[0];
        }
        // state transition:
        for (int i = 1; i < n; i++) {
            for (int k = 1; k <= K; k++) {
                dp[i][k][0] = Math.max(dp[i - 1][k][0], dp[i - 1][k][1] + prices[i]);
                dp[i][k][1] = Math.max(dp[i - 1][k][1], dp[i - 1][k-1][0] - prices[i]);
            }
        }
        return dp[n - 1][K][0];
    }
}

總結

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

相當於 k = K 的情況

class Solution {
    public int maxProfit(int K, int[] prices) {
        int n = prices.length;
        int[][][] dp = new int[n][K + 1][2];
        // base case:
        for (int k = 0; k <= K; k++) {
            dp[0][k][0] = 0;
            dp[0][k][1] = -prices[0];
        }
        // state transition:
        for (int i = 1; i < n; i++) {
            for (int k = 1; k <= K; k++) {
                dp[i][k][0] = Math.max(dp[i - 1][k][0], dp[i - 1][k-1][1] + prices[i]);
                dp[i][k][1] = Math.max(dp[i - 1][k][1], dp[i - 1][k][0] - prices[i]);
            }
        }
        return dp[n - 1][K][0];
    }
}

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

相當於 k = 2 的情況

class Solution {
    public int maxProfit(int[] prices) {
        int K = 2;
        int n = prices.length;
        int[][][] dp = new int[n][K + 1][2];
        // base case:
        for (int k = 0; k <= K; k++) {
            dp[0][k][0] = 0;
            dp[0][k][1] = -prices[0];
        }
        // state transition:
        for (int i = 1; i < n; i++) {
            for (int k = 1; k <= K; k++) {
                dp[i][k][0] = Math.max(dp[i - 1][k][0], dp[i - 1][k-1][1] + prices[i]);
                dp[i][k][1] = Math.max(dp[i - 1][k][1], dp[i - 1][k][0] - prices[i]);
            }
        }
        return dp[n - 1][K][0];
    }
}

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

相當於 k = ∞ 的情況

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        // base case:
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // state transition:
        for(int i = 1; i < n; i++){
            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[n - 1][0];
    }
}

121. 買賣股票的最佳時機

相當於 k = 1 的情況

當然,直接套用程式碼有點大材小用,只需兩個變數記錄第i天的最大收益和歷史最低股價即可,狀態轉移:maxProfit=max⁡(maxProfit,prices[i]−minPrice)

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        // base case
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // state transition:
        for (int i = 1; i < n; i++) {
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
        }
        return dp[n - 1][0];
    }
}

public class Solution {
    public int maxProfit(int prices[]) {
        int minprice = Integer.MAX_VALUE;
        int maxProfit = 0;
        for (int i = 0; i < prices.length; i++) {
            minprice = Math.min(minprice, prices[i]);
            maxProfit = Math.max(maxProfit, prices[i] - minprice);
        }
        return maxProfit;
    }
}

309. 買賣股票的最佳時機含冷凍期

相當於 k = ∞ 且含冷凍期的情況

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        // base case
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // state transition:
        for (int i = 1; i < n; i++) {
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i - 1][1], ((i - 2) < 0 ? 0 : dp[i - 2][0]) - prices[i]);
        }
        return dp[n - 1][0];
    }
}

714. 買賣股票的最佳時機含手續費

相當於 k = ∞ 且含手續費的情況

class Solution {
    public int maxProfit(int[] prices, int fee) {
        int n = prices.length;
        int[][] dp = new int [n][2];
        // base case
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // state transition:
        for(int i = 1; i < n; i++){
            dp[i][0] = Math.max(dp[i-1][0], dp[i - 1][1] + prices[i] - fee);
            dp[i][1] = Math.max(dp[i-1][1], dp[i - 1][0] - prices[i]);
        }
        return dp[n-1][0];
    }
}                                                       

❻打家劫舍問題

力扣 特點 難度
198. 打家劫舍 標準動態規劃 ?
213. 打家劫舍 II 融入環形陣列 ?
337. 打家劫舍 III 樹形動態規劃 ?
劍指 Offer II 089. 房屋偷盜 同第一題 ?
劍指 Offer II 090. 環形房屋偷盜 同第二題 ?

198. 打家劫舍

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

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

示例 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 。 

如果房屋數量大於兩間,應該如何計算能夠偷竊到的最高總金額呢?對於第 j (j>2) 間房屋,有兩個選項:

  1. 不偷竊第 j 間房屋,偷竊總金額為前 j−1 間房屋的最高總金額。dp[j - 1]
  2. 偷竊第 j 間房屋,那麼就不能偷竊第 j−1 間房屋,偷竊總金額為前 j−2 間房屋的最高總金額與第 j 間房屋的金額之和。 dp[j - 2] + nums[j - 1]
class Solution {
    public int rob(int[] nums) {
        // dp[j]: j個房屋能夠偷竊到的最高金額
        int[] dp = new int[nums.length + 1];
        dp[0] = 0;
        dp[1] = nums[0];
        for(int j = 2; j <= nums.length; j++){
            dp[j] = Math.max(dp[j - 1], dp[j - 2] + nums[j - 1]);
        }
        return dp[nums.length];
    }
}

上述方法使用了陣列儲存結果。考慮到每間房屋的最高總金額只和該房屋的前兩間房屋的最高總金額相關,因此可以使用滾動陣列,在每個時刻只需要儲存前兩間房屋的最高總金額。

class Solution {
    public int rob(int[] nums) {
        int length = nums.length;
        int first = 0, second = nums[0];
        for (int i = 1; i < length; i++) {
            int temp = second;
            second = Math.max(first + nums[i], second);
            first = temp;
        }
        return second;
    }
}

213. 打家劫舍 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 = [1,2,3]
輸出:3

n 個房間,編號為 0...n - 1,分成兩種情況:

  1. 偷編號為 0...n - 2n - 1 個房間; 即 偷首元素
  2. 偷編號為 1...n - 1n - 1 個房間; 即 不偷首元素
class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if (n == 1) return nums[0];
        int[] dp1 = new int[n];    // dp1搶首元素,則不搶尾元素
        int[] dp2 = new int[n];   // dp2不搶首元素,則搶尾元素
        dp1[0] = nums[0];
        dp1[1] = Math.max(nums[1], nums[0]);
        dp2[1] = nums[1];
        for (int i = 2; i < n; ++i) {
            dp1[i] = Math.max(dp1[i - 1], dp1[i - 2] + nums[i]);
            dp2[i] = Math.max(dp2[i - 1], dp2[i - 2] + nums[i]);
        }
        return Math.max(dp1[n - 2], dp2[n - 1]);
    }
}
class Solution {
    public int rob(int[] nums) {
        int length = nums.length;
        if (length == 1) {
            return nums[0];
        } else if (length == 2) {
            return Math.max(nums[0], nums[1]);
        }
        return Math.max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
    }

    public int robRange(int[] nums, int start, int end) {
        int first = nums[start], second = Math.max(nums[start], nums[start + 1]);
        for (int i = start + 2; i <= end; i++) {
            int temp = second;
            second = Math.max(first + nums[i], second);
            first = temp;
        }
        return second;
    }
}

337. 打家劫舍 III

小偷又發現了一個新的可行竊的地區。這個地區只有一個入口,我們稱之為 root

除了 root 之外,每棟房子有且只有一個“父“房子與之相連。一番偵察之後,聰明的小偷意識到“這個地方的所有房屋的排列類似於一棵二叉樹”。 如果 兩個直接相連的房子在同一天晚上被打劫 ,房屋將自動報警。

給定二叉樹的 root 。返回 在不觸動警報的情況下 ,小偷能夠盜取的最高金額

示例 1:

img

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

示例 2:

img

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

簡化一下這個問題:一棵二叉樹,樹上的每個點都有對應的權值,每個點有兩種狀態(選中和不選中),問在不能同時選中有父子關係的點的情況下,能選中的點的最大權值和是多少。

  1. dp陣列以及下標的含義

下標為0記錄不偷該節點所得到的的最大金錢,下標為1記錄偷該節點所得到的的最大金錢。

所以本題dp陣列就是一個長度為2的陣列!

  1. 遞迴式

討論當前節點搶還是不搶。如果搶了當前節點,兩個孩子就不能動,如果沒搶當前節點,就可以考慮搶左右孩子

  • 偷當前節點:左孩子不偷 + 右孩子不偷 + 當前節點偷
    • dp[1] = left[0] + right[0] + root.val;
  • 不偷當前節點:Max(左孩子不偷,左孩子偷) + Max(右孩子不偷,右孩子偷)
    • dp[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1])
  1. 確定遍歷順序

首先明確的是使用後序遍歷。 因為要透過遞迴函式的返回值來做下一步計算。

透過遞迴左節點,得到左節點偷與不偷的金錢。

透過遞迴右節點,得到右節點偷與不偷的金錢。

class Solution {
    public int rob(TreeNode root) {
        int[] dp = robAction(root);
        return Math.max(dp[0], dp[1]);
    }

    int[] robAction(TreeNode root) {
        int dp[] = new int[2];
        if (root == null)
            return dp;

        int[] left = robAction(root.left);
        int[] right = robAction(root.right);

        dp[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
        dp[1] = root.val + left[0] + right[0];
        return dp;
    }
}

❼子序列/串問題

53. 最大子陣列和

給你一個整數陣列 nums ,請你找出一個具有最大和的連續子陣列(子陣列最少包含一個元素),返回其最大和。

子陣列 是陣列中的一個連續部分。

示例 1:

輸入:nums = [-2,1,-3,4,-1,2,1,-5,4]
輸出:6
解釋:連續子陣列 [4,-1,2,1] 的和最大,為 6 。

示例 2:

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

示例 3:

輸入:nums = [5,4,-1,7,8]
輸出:23

dp[i]:表示以 nums[i] 結尾連續 子陣列的最大和。dp[i] 有兩種「選擇」:

  • 要麼與前面的相鄰子陣列連線,形成一個和更大的子陣列;
  • 要麼不與前面的子陣列連線,自成一派,自己作為一個子陣列。

在這兩種選擇中擇優,就可以計算出最大子陣列,而且空間複雜度還可以說使用「滾動變數」最佳化空間

狀態轉移方程:dp[i] = max(nums[i], nums[i] + dp[i - 1]);

class Solution {
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        dp[0] = nums[0];
        for(int i = 1; i < n; i++){
            dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
        }
        int res = Integer.MIN_VALUE;
        for(int i = 0; i < n; i++){
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

dp[i] 僅僅和 dp[i-1] 的狀態有關,那麼可以進行進一步最佳化,將空間複雜度降低:

class Solution {
    public int maxSubArray(int[] nums) {
        int n = nums.length;
        int dp_0 = nums[0];
        int dp_1 = 0, res = dp_0;
        for (int i = 1; i < n; i++) {
            dp_1 = Math.max(nums[i], nums[i] + dp_0);
            dp_0 = dp_1;
            // 順便計算最大的結果
            res = Math.max(res, dp_1);
        }
        return res;
    }
}

升級:返回最大子陣列

class Solution {
    public int maxSubArray(int[] nums) {
        int ans = nums[0]; 
        // dp[i] = 以nums[i]結尾的子序列的最大和
        int[] dp = new int[nums.length]; 
        dp[0] = nums[0];
        int maxStart = 0, maxLen = 1; // 記錄最大連續子序列的起點和長度
        int start = 0, len = 1; // 記錄連續子序列的起點和長度
        for(int i = 1; i < nums.length; ++i){
            // dp[i] = Math.max(dp[i-1] + nums[i], nums[i]); // 是繼續在後面新增,還是另起一個新序列
            if(dp[i-1] > 0){ 
                dp[i] = dp[i-1] + nums[i];
                len++;
            } else {   
                dp[i] = nums[i];
                start = i;
                len = 1;
            }
            if(dp[i] > ans){
                maxStart = start;
                maxLen = len;
                ans = dp[i];
            }
        }
        System.out.println(maxLen);
        System.out.println(Arrays.toString(Arrays.copyOfRange(nums, maxStart, maxStart+maxLen)));
        return ans; 
    }
}

152. 乘積最大子陣列

給你一個整數陣列 nums ,請你找出陣列中乘積最大的非空連續子陣列(該子陣列中至少包含一個數字),並返回該子陣列所對應的乘積。

測試用例的答案是一個 32-位 整數。

子陣列 是陣列的連續子序列。

示例 1:

輸入: nums = [2,3,-2,4]
輸出: 6
解釋: 子陣列 [2,3] 有最大乘積 6。

示例 2:

輸入: nums = [-2,0,-1]
輸出: 0
解釋: 結果不能為 2, 因為 [-2,-1] 不是子陣列。

在 53 題中,子陣列 nums[0..i] 的最大元素和是由 nums[0..i-1] 的最大元素和推匯出的,但本題變成子陣列的乘積則不一定。

比如 nums[i] = -1nums[0..i-1] 子陣列的最大元素乘積為 10,那麼我能不能說 nums[0..i] 的最大元素乘積為 max(-1, -1 * 10) = -1 呢?

其實不行,因為可能nums[0..i-1] 子陣列的最小元素乘積為 -6,那麼 nums[0..i] 的最大元素乘積應該為 max(-1, -1 * 10, -1 * -6) = 6

所以這道題和 53 題的最大區別在於,要同時維護「以 nums[i] 結尾的最大子陣列」和「以 nums[i] 結尾的最小子陣列」,以便適配 nums[i] 可能為負的情況。

class Solution {
    public int maxProduct(int[] nums) {
        int n = nums.length;
        // 定義:以 nums[i] 結尾的子陣列,乘積最大為 dp1[i]
        int[] dp1 = new int[n];
        // 定義:以 nums[i] 結尾的子陣列,乘積最小為 dp2[i]
        int[] dp2 = new int[n];

        dp1[0] = nums[0];
        dp2[0] = nums[0];

        for(int i = 1; i < n; i++){
            dp1[i] = max(dp1[i - 1] * nums[i], dp2[i - 1] * nums[i], nums[i]);  
            dp2[i] = min(dp1[i - 1] * nums[i], dp2[i - 1] * nums[i], nums[i]);
        }
            
        int res = Integer.MIN_VALUE;
        for(int i = 0; i < n; i++){
            res = Math.max(res, dp1[i]);
        }
        return res;

    }

    int min(int a, int b, int c) {
        return Math.min(Math.min(a, b), c);
    }

    int max(int a, int b, int c) {
        return Math.max(Math.max(a, b), c);
    }
}

5. 最長迴文子串

給你一個字串 s,找到 s 中最長的迴文子串。

如果字串的反序與原始字串相同,則該字串稱為迴文字串。

亞信安全2024提前批:最長迴文子字串

求最長迴文子字串 (多組迴文長度相同則返回第一組),雙指標實現

輸入:s = “sjdaiwasdesdsfsdsedsaw1h3u238dsahji1”
輸出:”wasdesdsfsdsedsaw”

示例 1:

輸入:s = "babad"
輸出:"bab"
解釋:"aba" 同樣是符合題意的答案。

示例 2:

輸入:s = "cbbd"
輸出:"bb"

方法一:動態規劃

迴文天然具有「狀態轉移」性質:一個長度大於 2 的迴文去掉頭尾字元以後,剩下的部分依然是迴文。反之,不是迴文。「動態規劃」的方法根據這樣的性質得到。

dp[i][j] 表示:子串 s[i..j] 是否為迴文子串,這裡子串 s[i..j] 定義為左閉右閉區間,即可以取到 s[i]s[j]

根據頭尾字元是否相等,需要分類討論:

dp[i][j] = (s[i] == s[j]) and (j - i < 3 || dp[i + 1][j - 1])
  • 當只有一個字元時,比如 a 自然是一個迴文串。
  • 當有兩個字元時,首尾相等,則必是一個迴文串。
  • 當有三個字元時,首尾相等,則必是一個迴文串。
  • 當有三個以上字元時,去掉首尾再判斷剩下串是否是迴文
class Solution {
    public String longestPalindrome(String s) {
        int n = s.length();
        // dp[i][j] 表示:子串 s[i..j] 是否為迴文子串
        boolean[][] dp = new boolean[n][n];
        //記錄迴文長度和起始位置
        int maxLen = 1;
        int begin = 0;
        for (int j = 0; j < n; j++) {
            for (int i = 0; i <= j; i++) {
                //dp[i][j] = (s.charAt(i) == s.charAt(j)) && (j - i < 3 || dp[i + 1][j - 1]);
                if (s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1])) {
                    dp[i][j] = true;
                }
                //計算最長迴文串,只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是迴文
                //對比所有的迴文串,取出長度最大的,迴文串的起始位置
                if(dp[i][j] && j - i + 1 > maxLen){
                    maxLen = j - i + 1;
                    begin = i;
                }
            }
        }
        return s.substring(begin, begin + maxLen);
    }
}



class Solution {
    public String longestPalindrome(String s) {
        int n = s.length();
        if(n < 2) {
            return s;
        }
        boolean[][] dp = new boolean[n][n];
        char[] charArr = s.toCharArray();
        // 初始化:所有長度為 1 的子串都是迴文串
        for(int i = 0; i < n; i++){
            dp[i][i] = true;
        }
        //記錄迴文長度和起始位置
        int maxLen = 1;
        int begin = 0;
        // 列舉子串長度
        for(int j = 1; j < n; j++){
            for(int i = 0; i < j; i++){
                if(charArr[i] == charArr[j]){
                    //首尾相等且長度為2的是迴文
                    if(j - i < 3){
                        dp[i][j] = true;
                    } else{
                        //首尾相等且長度大於2的,去掉首尾再判斷
                        dp[i][j] = dp[i + 1][j - 1];
                    }
                } else{
                    //首尾不等的串不是迴文
                    dp[i][j] = false;
                }

                //計算最長迴文串,只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是迴文
                //對比所有的迴文串,取出長度最大的,迴文串的起始位置
                if(dp[i][j] && j - i + 1 > maxLen){
                    maxLen = j - i + 1;
                    begin = i;
                }
            }
        }
        return s.substring(begin, begin + maxLen);
    }
}

方法二:中心擴散法

找回文串的難點在於,迴文串的的長度可能是奇數也可能是偶數,解決該問題的核心是從中心向兩端擴散的雙指標技巧

如果迴文串的長度為奇數,則它有一箇中心字元;如果迴文串的長度為偶數,則可以認為它有兩個中心字元。所以我們可以先實現這樣一個函式:

// 在 s 中尋找以 s[l] 和 s[r] 為中心的最長迴文串
String palindrome(String s, int l, int r) {
    // 防止索引越界
    while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
        // 雙指標,向兩邊展開
        l--; 
        r++;
    }
    // 返回以 s[l] 和 s[r] 為中心的最長迴文串
    return s.substring(l + 1, r);
}

如果輸入相同的 lr,就相當於尋找長度為奇數的迴文串,

如果輸入相鄰的 lr,則相當於尋找長度為偶數的迴文串。

那麼回到最長迴文串的問題,解法的大致思路就是:

String longestPalindrome(String s) {
    String res = "";
    for (int i = 0; i < s.length(); i++) {
        // 以 s[i] 為中心的最長迴文子串
        String s1 = palindrome(s, i, i);
        // 以 s[i] 和 s[i+1] 為中心的最長迴文子串
        String s2 = palindrome(s, i, i + 1);
        // res = longest(res, s1, s2)
        res = res.length() > s1.length() ? res : s1;
        res = res.length() > s2.length() ? res : s2;
    }
    return res;
}

最終程式碼

class Solution {
    public String longestPalindrome(String s) {
        String res = "";
        for (int i = 0; i < s.length(); i++) {
            // 以 s[i] 為中心的最長迴文子串
            String s1 = palindrome(s, i, i);
            // 以 s[i] 和 s[i+1] 為中心的最長迴文子串
            String s2 = palindrome(s, i, i + 1);
            // res = longest(res, s1, s2)
            res = res.length() > s1.length() ? res : s1;
            res = res.length() > s2.length() ? res : s2;
        }
        return res;
    }

    // 在 s 中尋找以 s[l] 和 s[r] 為中心的最長迴文串
    String palindrome(String s, int l, int r) {
        // 防止索引越界
        while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
            // 向兩邊展開
            l--;
            r++;
        }
        // 返回以 s[l] 和 s[r] 為中心的最長迴文串
        return s.substring(l + 1, r);
    }
}

647. 迴文子串

給你一個字串 s ,請你統計並返回這個字串中 迴文子串 的數目。

迴文字串 是正著讀和倒過來讀一樣的字串。

示例 1:

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

示例 2:

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

方法一:動態規劃

class Solution {
    public int countSubstrings(String s) {
        int n = s.length();
        boolean[][] dp = new boolean[n][n];
        int count = 0;
        for(int j = 0; j < n; j++){
            for(int i = 0; i <= j; i++){
                if(s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1])){
                    dp[i][j] = true;
                    count++;
                }
            }
        }
        return count;
    }
}

方法二:中心擴散法

class Solution {
    public int countSubstrings(String s) {
        int a = 0;
        int b = 0;
        for(int i = 0; i < s.length(); i++){
            //奇數中心點
            a += parlinDrome(s, i, i);
            //偶數中心點
            b += parlinDrome(s, i, i+1);
        }
        return a+b;
    }
    public int parlinDrome(String s, int l, int r){
        int count = 0;
        while(l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)){
            l--;
            r++;
            count++;
        }
        return count;
    }
}



// 中心點有 2 * len - 1 個,分別是 len 個單字元和 len - 1 個雙字元。
class Solution {
    public int countSubstrings(String s) {
        int ans = 0;
        for (int center = 0; center < 2 * s.length() - 1; center++) {
            int left = center / 2;
            int right = left + center % 2;
            while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
                ans++;
                left--;
                right++;
            }
        }
        return ans;
    }
}

516. 最長迴文子序列

給你一個字串 s ,找出其中最長的迴文子序列,並返回該序列的長度。

子序列定義為:不改變剩餘字元順序的情況下,刪除某些字元或者不刪除任何字元形成的一個序列。

示例 1:

輸入:s = "bbbab"
輸出:4
解釋:一個可能的最長迴文子序列為 "bbbb" 。

示例 2:

輸入:s = "cbbd"
輸出:2
解釋:一個可能的最長迴文子序列為 "bb" 。

dp[i][j] 表示 子串 s[i..j] ,最長的迴文序列長度是多少。

  • 如果 s 的第 i 個字元和第 j 個字元相同的話
dp[i][j] = dp[i + 1][j - 1] + 2
  • 如果 s 的第 i 個字元和第 j 個字元不同的話
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])

然後注意遍歷順序,i 從最後一個字元開始往前遍歷,j 從 i + 1 開始往後遍歷,這樣可以保證每個子問題都已經算好了。

初始化dp[i][i] = 1 單個字元的最長迴文序列是 1 ,結果dp[0][n - 1]

class Solution {
    public int longestPalindromeSubseq(String s) {
        int n = s.length();
        // dp 陣列全部初始化為 0
        int[][] dp = new int[n][n];
        // base case
        for (int i = 0; i < n; i++) {
            dp[i][i] = 1;
        }
        // 反著遍歷保證正確的狀態轉移
        for (int i = n - 1; i >= 0; i--) {
            for (int j = i + 1; j < n; j++) {
                // 狀態轉移方程
                if (s.charAt(i) == s.charAt(j)) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }
        // 整個 s 的最長迴文子串長度
        return dp[0][n - 1];
    }
}

// 降維dp
class Solution {
    public int longestPalindromeSubseq(String s) {
        int n = s.length();
        int[] dp = new int[n];
        for (int i = 0; i < n; i++){
            dp[i] = 1;
        }
        for (int i = n - 1; i >= 0; i--) {
            int pre = 0;
            for (int j = i + 1; j < n; j++) {
                int temp = dp[j];
                if (s.charAt(i) == s.charAt(j)){
                    dp[j] = pre + 2;
                } 
                else {
                    dp[j] = Math.max(dp[j], dp[j - 1]);
                }
                pre = temp;
            }
        }
        return dp[n - 1];
    }
}

300. 最長遞增子序列

給你一個整數陣列 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[i] 的值代表以 nums[i] 結尾的最長子序列長度。

j∈[0,i),考慮每輪計算新 dp[i] 時,遍歷 [0,i) 列表區間,做以下判斷:

  • 當 nums[i] > nums[j] 時: nums[i] 可以接在 nums[j]之後,此情況下最長上升子序列長度為 max(dp[j])+1
  • 當 nums[i] <= nums[j] 時:nums[i] 無法接在 nums[j] 之後,此情況上升子序列不成立,跳過。
  • 初始狀態:dp[i]所有元素置 1,含義是每個元素都至少可以單獨成為子序列,此時長度都為 1。
class Solution {
    public int lengthOfLIS(int[] nums) {
        // nums[i] 結尾的最長遞增子序列的長度
        int n = nums.length;
        int[] dp = new int[n];
        int res = 0;
        for(int i = 0; i < n; i++){
            dp[i] = 1;
            for(int j = 0; j < i; j++){
                if(nums[i] > nums[j]){
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

class Solution {
    public int lengthOfLIS(int[] nums) {
        // dp[i] 表示以 nums[i] 這個數結尾的最長遞增子序列的長度
        int[] dp = new int[nums.length];
        // base case:dp 陣列全都初始化為 1
        Arrays.fill(dp, 1);
        for (int i = 0; i < nums.length; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j])
                    dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }

        int res = 0;
        for (int i = 0; i < dp.length; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

方法二:貪心+二分

狀態設計思想:依然著眼於某個上升子序列的 結尾的元素,如果 已經得到的上升子序列的結尾的數越小,那麼遍歷的時候後面接上一個數,會有更大的可能構成一個長度更長的上升子序列。既然結尾越小越好,我們可以記錄 在長度固定的情況下,結尾最小的那個元素的數值,這樣定義以後容易得到「狀態轉移方程」。

新建陣列 tails,用於儲存最長上升子序列。tail[i] 表示:長度為 i + 1所有 上升子序列的結尾的最小值。

對原序列進行遍歷,將每位元素二分插入 tails 中。

  • 如果 tails 中元素都比它小,將它插到最後
  • 否則,用它覆蓋掉比它大的元素中最小的那個。

總之,思想就是讓 tails 中儲存比較小的元素。這樣,tails 未必是真實的最長上升子序列,但長度是對的。

class Solution {
    public int lengthOfLIS(int[] nums) {
        //tails陣列是以當前長度連續子序列的最小末尾元素
        //如tail[0]是求長度為1的連續子序列時的最小末尾元素
        //例:在 1 6 4中 tail[0]=1 tail[1]=4 沒有tail[2] 因為無法到達長度為3的連續子序列
        int tails[] = new int[nums.length];
        //注意:tails一定是遞增的 因為看題解那個動畫 我們最開始的那個元素一定找的是該陣列裡最小的 不然如果不是最小 由於我們需要連續 後面的數一定會更大(這樣不好的原因是 數越小 我們找到一個比該數大的數的機率肯定會更大)
        int res = 0;
        for(int num : nums){
            //每個元素開始遍歷 看能否插入到之前的tails陣列的位置 如果能 是插到哪裡
            int i = 0, j = res;
            while(i < j){
                int m = (i + j) / 2;
                if(tails[m] < num) i = m + 1;
                else j = m;
            }
             //如果沒有到達j==res這個條件 就說明tail陣列裡只有部分比這個num要小 那麼就把num插入到tail陣列合適的位置即可 但是由於這樣的子序列長度肯定是沒有res長的 因此res不需要更新
            tails[i] = num;
            //j==res 說明目前tail陣列的元素都比當前的num要小 因此最長子序列的長度可以增加了 
            if(j == res) res++; 
        }
        return res;
    }
}

72. 編輯距離

給你兩個單詞 word1word2請返回將 word1 轉換成 word2 所使用的最少運算元

你可以對一個單詞進行如下三種操作:

  • 插入一個字元
  • 刪除一個字元
  • 替換一個字元

示例 1:

輸入:word1 = "horse", word2 = "ros"
輸出:3
解釋:
horse -> rorse (將 'h' 替換為 'r')
rorse -> rose (刪除 'r')
rose -> ros (刪除 'e')

示例 2:

輸入:word1 = "intention", word2 = "execution"
輸出:5
解釋:
intention -> inention (刪除 't')
inention -> enention (將 'i' 替換為 'e')
enention -> exention (將 'n' 替換為 'x')
exention -> exection (將 'n' 替換為 'c')
exection -> execution (插入 'u')

dp[i + 1][j + 1] 代表 word1[0..i] 轉換成 word2[0..j] 需要最少步數

  • word1[i] == word2[j]dp[i][j] = dp[i-1][j-1]
  • word1[i] != word2[j]dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1

其中,dp[i-1][j-1]+1 表示替換操作,dp[i-1][j]+1 表示刪除操作,dp[i][j-1]+1 表示插入操作。

img

class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length(), n = word2.length();
        int[][] dp = new int[m + 1][n + 1];
        // base case
        for(int i = 1; i <= m; i++){
            dp[i][0] = i;
        }
        for(int j = 1; j <= n; j++){
            dp[0][j] = j;
        }
      
        for(int i = 1; i <= m; i++){
             for(int j = 1; j <= n; j++){
                 if(word1.charAt(i - 1) == word2.charAt(j - 1)){
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = min(
                        dp[i - 1][j] + 1,
                        dp[i][j - 1] + 1,
                        dp[i - 1][j - 1] + 1
                    );
                }
            }
        }
        return dp[m][n];
    }
    int min(int a, int b, int c) {
        return Math.min(a, Math.min(b, c));
    }
}

1143. 最長公共子序列

劍指 Offer II 095. 最長公共子序列 - 力扣(Leetcode)

給定兩個字串 text1text2,返回這兩個字串的最長 公共子序列 的長度。如果不存在 公共子序列 ,返回 0

一個字串的 子序列 是指這樣一個新的字串:它是由原字串在不改變字元的相對順序的情況下刪除某些字元(也可以不刪除任何字元)後組成的新字串。

  • 例如,"ace""abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

兩個字串的 公共子序列 是這兩個字串所共同擁有的子序列。

示例 1:

輸入:text1 = "abcde", text2 = "ace" 
輸出:3  
解釋:最長公共子序列是 "ace" ,它的長度為 3 。

示例 2:

輸入:text1 = "abc", text2 = "abc"
輸出:3
解釋:最長公共子序列是 "abc" ,它的長度為 3 。

示例 3:

輸入:text1 = "abc", text2 = "def"
輸出:0
解釋:兩個字串沒有公共子序列,返回 0 。

class Solution {
    public int longestCommonSubsequence(String s1, String s2) {
        int m = s1.length(), n = s2.length();
        // 定義:s1[0..i-1] 和 s2[0..j-1] 的 lcs 長度為 dp[i][j]
        int[][] dp = new int[m + 1][n + 1];
        // 目標:s1[0..m-1] 和 s2[0..n-1] 的 lcs 長度,即 dp[m][n]
        // base case: dp[0][..] = dp[..][0] = 0

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                // 現在 i 和 j 從 1 開始,所以要減一
                if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
                    // s1[i-1] 和 s2[j-1] 必然在 lcs 中
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    // s1[i-1] 和 s2[j-1] 至少有一個不在 lcs 中
                    dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
                }
            }
        }

        return dp[m][n];
    }
}

718. 最長重複子陣列

❽用動態規劃玩遊戲

10. 正規表示式匹配

劍指 Offer 19. 正規表示式匹配

給你一個字串 s 和一個字元規律 p,請你來實現一個支援 '.''*' 的正規表示式匹配。

  • '.' 匹配任意單個字元
  • '*' 匹配零個或多個前面的那一個元素

所謂匹配,是要涵蓋 整個 字串 s的,而不是部分字串。

亞信安全2024提前批-筆試:最長迴文子字串

給定一個字串和一個匹配模式,實現支援.*的正則匹配,其中.匹配任意單個字元,*匹配在它之前的零個或多個字元,被匹配字串只包含字母,可以為空字串,匹配模式字串包含字母和.*,也客以不帶.*,可以為空字串

示例 1:

輸入:s = "aa", p = "a"
輸出:false
解釋:"a" 無法匹配 "aa" 整個字串。

示例 2:

輸入:s = "aa", p = "a*"
輸出:true
解釋:因為 '*' 代表可以匹配零個或多個前面的那一個元素, 在這裡前面的元素就是 'a'。因此,字串 "aa" 可被視為 'a' 重複了一次。

示例 3:

輸入:s = "ab", p = ".*"
輸出:true
解釋:".*" 表示可匹配零個或多個('*')任意字元('.')。

狀態定義

dp[i][j] 表示 s 的前 i 個字元和 p 的前 j 個字元能否匹配。

狀態轉移

s 中的字元是固定不變的,我們考慮 p 的第 j 個字元與 s 的匹配情況:

  • 1、p[j] 是一個小寫字母a-z,則 s[i] 必須也為同樣的小寫字母方能完成匹配

  • 2、p[j]= '.',則 p[j] 一定可以與 s[i] 匹配成功,此時有

  • 3、p[j]= ‘*‘,則表示可對 p[j] 的前一個字元 p[j−1] 複製任意次(包括 0 次)。

    • s[i]≠p[j-1] && p[j-1] != '.',dp[i][j] = dp[i][j - 2]
    • 反之 dp[i][j] = dp[i][j - 2] | dp[i - 1][j]

    img

最終的狀態轉移方程

初始化

記 s 的長度為 m,p的長度為 n 。為便於狀態更新,減少對邊界的判斷,初始二維 dp 陣列維度為 (m+1)×(n+1),其中第一行和第一列的狀態分別表示字串 s 和 p 為空時的情況。顯然,dp[0][0]=True

對於其他 dp[0][j],當 p[j]≠'*'時,p[0,…,j] 無法與空字元匹配,因此有 dp[0][j]=False;而當 p[j]='*'時,則有 dp[0][j]=dp[0][j−2]

⚠️ 需要特別注意的是,由於 dp 陣列維度為 (m+1)×(n+1),在具體程式碼實現時,s[i−1] 和 p[j−1] 才是分別表示 s 和 p 中的第 i 和第 j 個字元。

class Solution {
    public boolean isMatch(String s, String p) {
        int m = s.length();
        int n = p.length();
        //初始化
        boolean[][] dp = new boolean[m + 1][n + 1];
        dp[0][0] = true;
        for (int j = 1; j <= n; j++) {
            if (p.charAt(j - 1) == '*') {
                dp[0][j] = dp[0][j - 2];
            }
        }
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                //情況1和2
                if (p.charAt(j - 1) == s.charAt(i - 1) || p.charAt(j - 1) == '.') {
                    dp[i][j] = dp[i - 1][j - 1];
                //情況3
                } else if (p.charAt(j - 1) == '*') {
                    if(p.charAt(j - 2) != s.charAt(i - 1) && p.charAt(j - 2) != '.') {
                        dp[i][j] = dp[i][j - 2];
                    } else {
                        dp[i][j] = dp[i][j - 2] | dp[i - 1][j];
                    }
                }
            }
        }
        return dp[m][n];
    }
}

44. 萬用字元匹配

42. 接雨水

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

示例 1:

img

輸入: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,能夠裝的水為

water[i] = min(
               max(height[0..i]),    // 左邊最高的柱子
               max(height[i..end])  // 右邊最高的柱子
            ) - height[i]           // 自己柱子高度
    
img img
暴力演算法
// 時間複雜度 O(N^2),空間複雜度 O(1)
class Solution {
    public int trap(int[] height) {
        int n = height.length;
        int res = 0;
        for(int i = 0; i < n; i++){ //柱子i
            int l_max = 0, r_max = 0;
            //柱子i 右邊最大高度
            for(int j = i; j < n; j++){
                r_max = Math.max(r_max, height[j]);
            }
            //柱子i 左邊最大高度
            for(int j = i; j >= 0; j--){
                l_max = Math.max(l_max, height[j]);
            }
            res += Math.min(l_max, r_max) - height[i]; //累加每個柱子水量
        }
        return res;
    }
}
備忘錄最佳化

我們開兩個陣列 r_maxl_max 充當備忘錄,l_max[i] 表示位置 i 左邊最高的柱子高度,r_max[i] 表示位置 i 右邊最高的柱子高度。預先把這兩個陣列計算好,避免重複計算:

// 時間複雜度 O(N),空間複雜度 O(N)
class Solution {
    public int trap(int[] height) {
        int n = height.length;
        int res = 0;
        // 陣列充當備忘錄
        int[] l_max = new int[n]; //l_max[i] 表示位置 i 左邊最高的柱子高度
        int[] r_max = new int[n]; //r_max[i] 表示位置 i 右邊最高的柱子高度
        // 初始化 base case
        l_max[0] = height[0];
        r_max[n - 1] = height[n - 1];
        // 從左向右計算 l_max
        for (int i = 1; i < n; i++){
             l_max[i] = Math.max(height[i], l_max[i - 1]);
        }
        // 從右向左計算 r_max
        for (int i = n - 2; i >= 0; i--){
            r_max[i] = Math.max(height[i], r_max[i + 1]);
        }
        // 計算答案
        for (int i = 0; i < n; i++){
            res += Math.min(l_max[i], r_max[i]) - height[i];
        }
        return res;
    }
}
雙指標

用雙指標邊走邊算,節省下空間複雜度。

// 時間複雜度 O(N),空間複雜度 O(1)
class Solution {
    int trap(int[] height) {
        int left = 0, right = height.length - 1;
        int l_max = 0, r_max = 0;
        int res = 0;
        while (left < right) {
            l_max = Math.max(l_max, height[left]);  //l_max 是 height[0..left] 中最高柱子的高度
            r_max = Math.max(r_max, height[right]); //r_max 是 height[right..end]最高柱子的高度

            // res += min(l_max, r_max) - height[i]
            if (l_max < r_max) {
                res += l_max - height[left];
                left++;
            } else {
                res += r_max - height[right];
                right--;
            }
        }
        return res;
    }
}

l_maxr_max 代表的是 height[0..left]height[right..end] 的最高柱子高度。比如這段程式碼:

if (l_max < r_max) {
    res += l_max - height[left];
    left++; 
} 

此時的 l_maxleft 指標左邊的最高柱子,但是 r_max 並不一定是 left 指標右邊最高的柱子

img img

我們只在乎 min(l_max, r_max)對於上圖的情況,我們已經知道 l_max < r_max 了,至於這個 r_max 是不是右邊最大的,不重要。重要的是 height[i] 能夠裝的水只和較低的 l_max 之差有關

64. 最小路徑和

劍指 Offer II 099. 最小路徑之和 - 力扣(Leetcode)

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

示例 1:

img

img

輸入: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

//時間複雜度:O(mn),空間複雜度:O(mn)
class Solution {
    public int minPathSum(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        // dp[i][j] 代表走到 (i,j) 的最小路徑和。
        int[][] dp = new int[m][n];
        // 初始化
        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];
        }
        
        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
            }
        }
        return dp[m - 1][n - 1];
    }
}

//最佳化 時間複雜度:O(mn),空間複雜度:O(1)
//其實我們完全不需要建立 dp 矩陣浪費額外空間,直接遍歷 grid[i][j] 修改即可。這是因為:grid[i][j] = min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j] ;原 grid 矩陣元素中被覆蓋為 dp 元素後(都處於當前遍歷點的左上方),不會再被使用到。
class Solution {
    public int minPathSum(int[][] grid) {
        int n = grid.length;
        int m = grid[0].length;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(i == 0 && j == 0) continue;
                else if(i == 0)  grid[i][j] = grid[i][j - 1] + grid[i][j];
                else if(j == 0)  grid[i][j] = grid[i - 1][j] + grid[i][j];
                else grid[i][j] = Math.min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j];
            }
        }
        return grid[n - 1][m - 1];
    }
}

221. 最大正方形

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

示例 1:

img

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

示例 2:

img

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

示例 3:

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

matrix[i][j] 為 1,且它的左邊、上邊、左上邊都存在正方形時,matrix[i][j] 才能夠作為一個更大的正方形的右下角

所以我們可以定義這樣一個二維 dp 陣列:以 matrix[i][j] 為右下角元素的最大的全為 1 正方形矩陣的邊長為 dp[i][j]

有了這個定義,狀態轉移方程就是:

if (matrix[i][j] == 1)
    // 類似「水桶效應」,最大邊長取決於邊長最短的那個正方形
    dp[i][j] = min(dp[i-1][j], dp[i-1][j-1], dp[i][j-1]) + 1;
else
    dp[i][j] = 0;

題目最終想要的答案就是最大邊長 max(dp[..][..]) 的平方。

class Solution {
    public int maximalSquare(char[][] matrix) {
        int m = matrix.length;
        int n = matrix[0].length;
        //定義:以 matrix[i][j] 為右下角元素的全為 1 正方形矩陣的最大邊長為 dp[i][j]。
        int[][] dp = new int[m][n];
        // base case,第一行和第一列的正方形邊長
        for(int i = 0; i < m; i++){
            dp[i][0] = matrix[i][0] - '0';
        }
        for(int i = 0; i < n; i++){
            dp[0][i] = matrix[0][i] - '0';
        }
        // 進行狀態轉移
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                if (matrix[i][j] == '0') {
                    // 值為 0 不可能是正方形的右下角
                    continue;
                }
                dp[i][j] = Math.min(Math.min(
                    dp[i - 1][j],
                    dp[i][j - 1]),
                    dp[i - 1][j - 1]
                ) + 1;
            }
        }
        int len = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                len = Math.max(len, dp[i][j]);
            }
        }
        return len * len; 
    }
}

790. 多米諾和托米諾平鋪

有兩種形狀的瓷磚:一種是 2 x 1 的多米諾形,另一種是形如 “L” 的托米諾形。兩種形狀都可以旋轉。

img

給定整數 n ,返回可以平鋪 2 x n 的皮膚的方法的數量。返回對 109 + 7 取模 的值。

平鋪指的是每個正方形都必須有瓷磚覆蓋。兩個平鋪不同,當且僅當皮膚上有四個方向上的相鄰單元中的兩個,使得恰好有一個平鋪有一個瓷磚佔據兩個正方形。

示例 1:

img

輸入: n = 3
輸出: 5
解釋: 五種不同的方法如上所示。

示例 2:

輸入: n = 1
輸出: 1

定義 f[i] 表示平鋪 2×i 皮膚的方案數,那麼答案為 f[n]。

嘗試計算 f 的前幾項,並從中找到規律,得到 f[n] 的遞推式:

img

class Solution {
    static final long MOD = (long) 1e9 + 7;
    public int numTilings(int n) {
        if (n == 1) return 1;
        // dp[n] 平鋪 2 x n 皮膚方法的數量
        long[] dp = new long[n + 1];
        dp[0] = dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; ++i)
            dp[i] = (dp[i - 1] * 2 + dp[i - 3]) % MOD;
        return (int)dp[n];
    }
}

相關文章