day34 代码随想录算法训练营 动态规划专题2

1 今日打卡

不同路径62. 不同路径 - 力扣(LeetCode)

不同路径Ⅱ 63. 不同路径 II - 力扣(LeetCode)

整数拆分 343. 整数拆分 - 力扣(LeetCode)

不同的二叉搜索树 96. 不同的二叉搜索树 - 力扣(LeetCode)

2 dp五部曲

确定dp数组(dp table)以及下标的含义

确定递推公式

dp数组如何初始化

确定遍历顺序

举例推导dp数组

3 不同路径

3.1 思路

第一步:

dp[i][j] 表示:从网格的左上角 (0,0) 位置出发,到达网格中第 i 行第 j 列位置时,一共有多少条不同的路径;

下标i 对应网格的行号(从 0 开始),下标j 对应网格的列号(从 0 开始);最终返回dp[m-1][n-1]。

第二步:

要到达(i,j)位置,只有两种可能的来源:

从上方(i-1,j)位置向下移动一步到达;

从左方(i,j-1)位置向右移动一步到达;

状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1](i ≥ 1且j ≥ 1),即到达(i,j)的路径数 = 到达上方位置的路径数 + 到达左方位置的路径数。

第三步:

初始化要贴合 "只能向下 / 向右走" 的规则,边界位置(第一行、第一列)的路径数是固定的:

第一列(j=0):无论第几行i,从(0,0)到(i,0)只能一直向下走,因此只有 1 条路径 → dp[i][0] = 1(0 ≤ i < m);

第一行(i=0):无论第几列j,从(0,0)到(0,j)只能一直向右走,因此只有 1 条路径 → dp[0][j] = 1(0 ≤ j < n);

第四步:

状态转移方程中,dp[i][j]依赖dp[i-1][j](上方)和dp[i][j-1](左方),因此需要:

先遍历行,再遍历列(或先列后行均可);

行从 1 到m-1,列从 1 到n-1(因为 0 行 0 列已初始化);

代码中的遍历逻辑:

第五步:

以m=3,n=3(3 行 3 列网格)为例,手动推导验证:

初始化:

第一列:dp[0][0]=1、dp[1][0]=1、dp[2][0]=1;

第一行:dp[0][0]=1、dp[0][1]=1、dp[0][2]=1;

计算dp[1][1]:dp[0][1] + dp[1][0] = 1 + 1 = 2;

计算dp[1][2]:dp[0][2] + dp[1][1] = 1 + 2 = 3;

计算dp[2][1]:dp[1][1] + dp[2][0] = 2 + 1 = 3;

计算dp[2][2]:dp[1][2] + dp[2][1] = 3 + 3 = 6;

最终dp[2][2] = 6,即 3x3 网格从左上角到右下角有 6 条不同路径,符合实际逻辑。

3.2 实现代码

java 复制代码
class Solution {
    public int uniquePaths(int m, int n) {
        // 1. 定义dp数组:dp[i][j]表示从(0,0)到(i,j)的不同路径数
        // 二维数组大小为m行n列,覆盖所有网格位置
        int[][] dp = new int[m][n];
        
        // 2. 初始化dp数组:第一列所有位置路径数为1(只能向下走)
        for(int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        
        // 初始化dp数组:第一行所有位置路径数为1(只能向右走)
        for(int i = 0; i < n; i++) {
            dp[0][i] = 1;
        }
        
        // 3. 确定遍历顺序:从第1行第1列开始,逐行逐列计算
        // 行从1到m-1(0行已初始化)
        for(int i = 1; i < m; i++) {
            // 列从1到n-1(0列已初始化)
            for(int j = 1; j < n; j++) {
                // 4. 状态转移方程:到达(i,j)的路径数 = 上方路径数 + 左方路径数
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        
        // 5. 返回到达右下角(m-1, n-1)的路径数
        return dp[m-1][n-1];
    }
}

3.3 优化

先回顾二维 DP 的状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]

dp[i-1][j]:上一行第 j 列的值(当前一维数组中dp[j]还未更新,保留的就是上一行的值);

dp[i][j-1]:当前行第 j-1 列的值(当前一维数组中dp[j-1]已经更新,是当前行的最新值)。

因此,我们可以用一维数组dp[j]替代二维数组,每遍历一行就更新一次dp数组:

初始时,dp[j]表示第一行(i=0)到第 j 列的路径数(全为 1);

遍历第 i 行时,dp[j] = dp[j](上一行j列) + dp[j-1](当前行j-1列)。

二、优化后的 DP 五部曲分析

  1. dp [j] 及其下标 j 的含义

dp[j] 表示:遍历到第 i 行时,从左上角 (0,0) 到达当前行第 j 列位置的不同路径数;

下标 j 对应网格的列号(从 0 开始),数组长度为 n(列数);

每遍历一行,dp[j]会被更新为当前行第 j 列的路径数,覆盖上一行的值。

  1. 确定状态转移方程

优化后的状态转移方程:dp[j] = dp[j] + dp[j-1](i ≥ 1且j ≥ 1)
等号右边的dp[j]:上一行第 j 列的路径数(未更新前的值);
等号右边的dp[j-1]:当前行第 j-1 列的路径数(已更新后的值);

等号左边的dp[j]:当前行第 j 列的路径数(更新后的值)。

  1. dp 数组如何初始化

和二维解法一致,第一行所有位置的路径数都是 1(只能向右走),因此:

初始化一维数组dp[j] = 1(0 ≤ j < n),对应二维解法中dp[0][j] = 1。

  1. 确定遍历顺序

外层循环:遍历行,从第 1 行到第 m-1 行(i 从 1 到 m-1),因为第一行已初始化;

内层循环:遍历列,从第 1 列到第 n-1 列(j 从 1 到 n-1),因为第 0 列的路径数始终是 1(无需更新);

必须先遍历行、再遍历列,且列要从左到右遍历,保证计算dp[j]时,dp[j-1]已更新为当前行的值。

  1. 举例推导一维 dp 数组

仍以m=3,n=3为例,手动推导:

初始化:dp = [1, 1, 1](对应第一行);

遍历第 1 行(i=1):

j=1:dp[1] = dp[1](上一行1) + dp[0](当前行1) = 1+1=2 → dp = [1,2,1];

j=2:dp[2] = dp[2](上一行1) + dp[1](当前行2) = 1+2=3 → dp = [1,2,3];

遍历第 2 行(i=2):

j=1:dp[1] = dp[1](上一行2) + dp[0](当前行1) = 2+1=3 → dp = [1,3,3];

j=2:dp[2] = dp[2](上一行3) + dp[1](当前行3) = 3+3=6 → dp = [1,3,6];

最终dp[2] = 6,和二维解法结果一致。

java 复制代码
class Solution {
    public int uniquePaths(int m, int n) {
        // 1. 定义一维dp数组:dp[j]表示当前行第j列的路径数
        // 数组长度为列数n,空间复杂度从O(m*n)优化为O(n)
        int[] dp = new int[n];
        
        // 2. 初始化dp数组:第一行所有列的路径数都是1(只能向右走)
        for (int j = 0; j < n; j++) {
            dp[j] = 1;
        }
        
        // 3. 遍历顺序:外层遍历行(从第1行到m-1行),内层遍历列(从第1列到n-1列)
        // 第一行已初始化,无需处理
        for (int i = 1; i < m; i++) {
            // 第0列路径数始终为1,从第1列开始更新
            for (int j = 1; j < n; j++) {
                // 4. 状态转移方程:
                // dp[j](更新前)= 上一行第j列的路径数
                // dp[j-1] = 当前行第j-1列的路径数
                // 相加后得到当前行第j列的路径数,覆盖原dp[j]
                dp[j] = dp[j] + dp[j-1];
            }
        }
        
        // 5. 返回右下角(最后一行最后一列)的路径数
        return dp[n-1];
    }
}

4 不同路径2

4.1 思路

第一步:

dp[i][j] 表示:从网格左上角 (0,0) 出发,到达第 i 行第 j 列位置时,能走的不同有效路径数(有效路径指不经过任何障碍物的路径);

第二步:

若obstacleGrid[i][j] = 1(当前位置是障碍物):dp[i][j] = 0(直接跳过,无路径);

若obstacleGrid[i][j] = 0(当前位置可通行):状态转移方程和基础版一致 → dp[i][j] = dp[i-1][j] + dp[i][j-1];

(即到达(i,j)的路径数 = 从上方来的路径数 + 从左方来的路径数)。

第三步:

第一列(j=0):

从(0,0)开始向下遍历,只要当前位置obstacleGrid[i][0] = 0(无障碍物),则dp[i][0] = 1(只能向下走);

一旦遇到obstacleGrid[i][0] = 1(障碍物),后续所有dp[i+1][0], dp[i+2][0]...都为 0(被障碍物挡住,无法到达);

代码中用i < m && obstacleGrid[i][0] == 0控制循环,遇到障碍物直接终止初始化。

第一行(i=0):

从(0,0)开始向右遍历,只要当前位置obstacleGrid[0][i] = 0(无障碍物),则dp[0][i] = 1(只能向右走);

一旦遇到障碍物,后续所有dp[0][i+1], dp[0][i+2]...都为 0;

代码中用i < n && obstacleGrid[0][i] == 0控制循环。

第四步:

逐行逐列从左到右、从上到下遍历

第五步:

以obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]](3 行 3 列,中间 (1,1) 是障碍物)为例:

初始化第一列:

obstacleGrid[0][0]=0 → dp[0][0]=1;

obstacleGrid[1][0]=0 → dp[1][0]=1;

obstacleGrid[2][0]=0 → dp[2][0]=1;

初始化第一行:

obstacleGrid[0][0]=0 → dp[0][0]=1;

obstacleGrid[0][1]=0 → dp[0][1]=1;

obstacleGrid[0][2]=0 → dp[0][2]=1;

遍历计算:

i=1, j=1:obstacleGrid[1][1]=1 → 跳过,dp[1][1]=0;

i=1, j=2:obstacleGrid[1][2]=0 → dp[1][2] = dp[0][2] + dp[1][1] = 1 + 0 = 1;

i=2, j=1:obstacleGrid[2][1]=0 → dp[2][1] = dp[1][1] + dp[2][0] = 0 + 1 = 1;

i=2, j=2:obstacleGrid[2][2]=0 → dp[2][2] = dp[1][2] + dp[2][1] = 1 + 1 = 2;

最终dp[2][2] = 2,即从左上角到右下角有 2 条有效路径(绕开中间障碍物)。

4.2 实现代码

java 复制代码
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        // 获取网格的行数m和列数n
        int m = obstacleGrid.length;
        int n = obstacleGrid[0].length;
        
        // 1. 定义dp数组:dp[i][j]表示从(0,0)到(i,j)的有效路径数
        int[][] dp = new int[m][n];
        
        // 2. 初始化dp数组:第一列(j=0)
        // 从(0,0)向下走,只要当前位置无障碍物就初始化为1,遇到障碍物则终止(后续都是0)
        for(int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
            dp[i][0] = 1;
        }
        
        // 初始化dp数组:第一行(i=0)
        // 从(0,0)向右走,只要当前位置无障碍物就初始化为1,遇到障碍物则终止(后续都是0)
        for(int i = 0; i < n && obstacleGrid[0][i] == 0; i++) {
            dp[0][i] = 1;
        }
        
        // 3. 确定遍历顺序:逐行逐列(从1行1列开始)
        for(int i = 1; i < m; i++) {
            for(int j = 1; j < n; j++) {
                // 4. 状态转移:先判断当前位置是否是障碍物
                if(obstacleGrid[i][j] == 1) {
                    // 障碍物位置路径数为0,直接跳过(数组默认值已为0)
                    continue;
                }
                // 无障碍物时,路径数=上方路径数+左方路径数
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        
        // 5. 返回到达右下角(m-1, n-1)的有效路径数
        return dp[m-1][n-1];
    }
}

5 整数拆分

5.1 思路

第一步:

dp [i] 及其下标 i 的含义

dp[i] 表示:将正整数i拆分成至少两个正整数的和后,这些整数的乘积的最大值;

下标i 对应被拆分的正整数(从 2 开始,因为 1 无法拆分);

核心:dp[i]是 "拆分 i" 能得到的最大乘积,比如dp[2]=1(2 拆成 1+1,乘积 1),dp[3]=2(3 拆成 1+2,乘积 2)。

第二步:

  1. 确定状态转移方程

要找到dp[i]的最大值,需遍历所有可能的拆分方式,核心逻辑:

把i拆分成j和i-j两部分(1 ≤ j < i),有两种情况:

i-j不拆分:乘积为 j * (i-j);

i-j继续拆分:乘积为 j * dp[i-j](dp[i-j]是i-j拆分后的最大乘积);

对每个j,取两种情况的最大值,再和当前dp[i]比较,保留最大的那个;

状态转移方程:dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j]));

优化:j只需遍历到i/2即可(因为拆分j和i-j与拆分i-j和j结果相同,无需重复计算)。

第三步:

  1. dp 数组如何初始化

初始化需贴合 "至少拆分成两个数" 的规则:

dp[0]和dp[1]:无意义(0 和 1 无法拆分成至少两个正整数),无需初始化;

dp[2] = 1:2 只能拆成 1+1,乘积为 1,这是最小的可拆分情况,也是后续计算的基础。

第四步:

  1. 确定遍历顺序

外层循环:遍历被拆分的数i,从 3 到n(i=2已初始化),因为dp[i]依赖dp[i-j](i-j < i),需从小到大遍历;

内层循环:遍历拆分的第一个数j,从 1 到i/2(优化后),枚举所有可能的拆分方式;

遍历逻辑:先确定i,再枚举j,计算每种拆分方式的乘积,更新dp[i]的最大值。

第五步:

  1. 举例推导 dp 数组

以n=6为例,手动推导验证:

初始化:dp[2] = 1;

i=3:

j=1(仅遍历到 3/2=1):

Math.max(1*(3-1), 1*dp[2]) = Math.max(2, 1*1)=2 → dp[3] = 2;

i=4:

j=1:Math.max(1*3, 1*dp[3])=Math.max(3,2)=3 → dp[4]=3;

j=2(4/2=2):Math.max(2*2, 2*dp[2])=Math.max(4,2)=4 → dp[4]更新为 4;

i=5:

j=1:Math.max(1*4,1*dp[4])=Math.max(4,4)=4 → dp[5]=4;

j=2(5/2=2):Math.max(2*3,2*dp[3])=Math.max(6,4)=6 → dp[5]更新为 6;

i=6:

j=1:Math.max(1*5,1*dp[5])=Math.max(5,6)=6 → dp[6]=6;

j=2:Math.max(2*4,2*dp[4])=Math.max(8,8)=8 → dp[6]更新为 8;

j=3(6/2=3):Math.max(3*3,3*dp[3])=Math.max(9,6)=9 → dp[6]更新为 9;

最终dp[6] = 9(6 拆成 3+3,乘积 9),符合最优解。

5.2 实现代码

java 复制代码
class Solution {
    public int integerBreak(int n) {
        // 1. 定义dp数组:dp[i]表示将i拆分成至少两个数的和后,乘积的最大值
        // 数组长度为n+1,覆盖从0到n的所有整数(0和1无意义)
        int[] dp = new int[n + 1];
        
        // 2. 初始化dp数组:2只能拆成1+1,乘积为1
        dp[2] = 1;
        
        // 3. 遍历顺序:外层遍历被拆分的数i(从3到n)
        for(int i = 3; i <= n; i++) {
            // 内层遍历拆分的第一个数j(1到i/2,避免重复计算)
            for(int j = 1; j <= i / 2; j++) {
                // 4. 状态转移方程:
                // 情况1:i-j不拆分,乘积=j*(i-j)
                // 情况2:i-j拆分,乘积=j*dp[i-j](dp[i-j]是i-j拆分后的最大乘积)
                // 取两种情况的最大值,再和当前dp[i]比较,保留最大值
                dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j]));
            }
        }
        
        // 5. 返回将n拆分后的最大乘积
        return dp[n];
    }
}

用具体例子(i=6)拆解:为什么要和 dp [i] 取 max

我们以i=6为例,一步步看dp[6]的更新过程,你就能直观理解:

初始状态

dp[6] = 0(数组默认值)。

第一步:j=1(拆分 6=1+5)

计算当前拆分方式的乘积:Math.max(1*(6-1), 1*dp[5]) = Math.max(5, 6) = 6。此时需要把这个 6 和dp[6]的当前值(0)比较,取大的:dp[6] = Math.max(0, 6) = 6 → 现在dp[6] = 6。

第二步:j=2(拆分 6=2+4)

计算当前拆分方式的乘积:Math.max(2*(6-2), 2*dp[4]) = Math.max(8, 8) = 8。此时需要把 8 和dp[6]的当前值(6)比较,取大的:dp[6] = Math.max(6, 8) = 8 → 现在dp[6] = 8。

第三步:j=3(拆分 6=3+3)

计算当前拆分方式的乘积:Math.max(3*(6-3), 3*dp[3]) = Math.max(9, 6) = 9。此时需要把 9 和dp[6]的当前值(8)比较,取大的:dp[6] = Math.max(8, 9) = 9 → 最终dp[6] = 9。

关键结论:Math.max(dp[i], ...)的作用是 "保留历史最优解"

6 不同的二叉搜索树

6.1 思路

第一步:

dp [i] 及其下标 i 的含义

dp[i] 表示:由i个不同的节点(1~i)能组成的不同二叉搜索树(BST)的总数;

下标i 对应节点的个数(从 0 开始),比如:

dp[0] = 1:空树(0 个节点)只有 1 种形态(辅助计算,无实际业务意义);

dp[1] = 1:1 个节点只能组成 1 种 BST;

dp[2] = 2:2 个节点能组成 2 种 BST(1 为根 / 2 为根)。

第二步:

核心逻辑基于二叉搜索树(BST)的特性:若以j为根节点(1≤j≤i),则左子树由 1~j-1 共j-1个节点组成,右子树由 j+1~i 共i-j个节点组成。

对于i个节点:

枚举每个节点j作为根节点(1≤j≤i);

左子树的组合数为dp[j-1](j-1个节点的 BST 数);

右子树的组合数为dp[i-j](i-j个节点的 BST 数);

以j为根的 BST 总数 = 左子树组合数 × 右子树组合数(乘法原理);

状态转移方程:dp[i] += dp[j-1] * dp[i-j](遍历所有 j,累加所有根节点对应的组合数);

第三步:

dp 数组如何初始化

初始化需满足 "乘法原理的基准条件"(空树的组合数为 1,否则乘法结果会为 0):

dp[0] = 1:空树的组合数定义为 1(辅助计算,比如当 j=1 时,左子树 0 个节点,dp[0]×dp[i-1]才能正确计算);

dp[1] = 1:1 个节点只有 1 种 BST,是最基础的可计算单元;

这两个初始化值是后续所有dp[i]计算的基础。

第四步:

外层循环:遍历节点个数i,从 2 到n(i=0/1已初始化),因为dp[i]依赖dp[0]~dp[i-1],需从小到大遍历;

内层循环:遍历根节点j,从 1 到i,枚举所有可能的根节点,累加每种根节点对应的组合数;

遍历逻辑:先确定节点总数i,再枚举每个根j,计算左右子树的组合数乘积,累加到dp[i]。

第五步:

举例推导 dp 数组

以n=3为例,手动推导验证(对应卡特兰数C3​=5):

初始化:dp[0]=1,dp[1]=1;

i=2(2 个节点):j=1(以 1 为根):左子树 0 个节点(dp[0]=1),右子树 1 个节点(dp[1]=1)→ 1×1=1;j=2(以 2 为根):左子树 1 个节点(dp[1]=1),右子树 0 个节点(dp[0]=1)→ 1×1=1;dp[2] = 1+1 = 2;

i=3(3 个节点):j=1(以 1 为根):左dp[0]=1,右dp[2]=2 → 1×2=2;j=2(以 2 为根):左dp[1]=1,右dp[1]=1 → 1×1=1;j=3(以 3 为根):左dp[2]=2,右dp[0]=1 → 2×1=2;dp[3] = 2+1+2 = 5;

最终dp[3]=5,即 3 个节点能组成 5 种不同的 BST,符合实际结果。

6.2 实现代码

java 复制代码
class Solution {
    public int numTrees(int n) {
        // 1. 定义dp数组:dp[i]表示i个节点能组成的不同BST的总数
        // 数组长度为n+1,覆盖0到n个节点的情况
        int[] dp = new int[n + 1];
        
        // 2. 初始化dp数组:
        // dp[0]=1:空树的组合数为1(辅助乘法计算)
        // dp[1]=1:1个节点只能组成1种BST
        dp[0] = 1;
        dp[1] = 1;
        
        // 3. 遍历顺序:外层遍历节点总数i(从2到n)
        for(int i = 2; i <= n; i++) {
            // 内层遍历根节点j(从1到i,枚举所有可能的根)
            for(int j = 1; j <= i; j++) {
                // 4. 状态转移方程:
                // 以j为根时,左子树有j-1个节点(组合数dp[j-1])
                // 右子树有i-j个节点(组合数dp[i-j])
                // 总组合数 = 左子树组合数 × 右子树组合数(乘法原理)
                // 累加所有根节点对应的组合数,得到dp[i]
                dp[i] += dp[j-1] * dp[i-j];
            }
        }
        
        // 5. 返回n个节点能组成的不同BST总数
        return dp[n];
    }
}
相关推荐
亓才孓1 小时前
【MyBatis Exception】Public Key Retrieval is not allowed
java·数据库·spring boot·mybatis
We་ct1 小时前
LeetCode 105. 从前序与中序遍历序列构造二叉树:题解与思路解析
前端·算法·leetcode·链表·typescript
J_liaty2 小时前
Java设计模式全解析:23种模式的理论与实践指南
java·设计模式
万象.2 小时前
redis集群算法,搭建,故障处理及扩容
redis·算法·哈希算法
plus4s2 小时前
2月19日(85-87题)
c++·算法
Desirediscipline2 小时前
cerr << 是C++中用于输出错误信息的标准用法
java·前端·c++·算法
Demon_Hao2 小时前
JAVA快速对接三方支付通道标准模版
java·开发语言
Renhao-Wan2 小时前
Java 算法实践(八):贪心算法思路
java·算法·贪心算法
w***71102 小时前
常见的 Spring 项目目录结构
java·后端·spring