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

1 今日打卡

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

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

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

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

2 dp五部曲

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

确定递推公式

dp数组如何初始化

确定遍历顺序

举例推导dp数组

3 不同路径

3.1 思路

第一步:

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

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

第二步:

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

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

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

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

第三步:

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

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

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

第四步:

状态转移方程中,dpij依赖dpi-1j(上方)和dpij-1(左方),因此需要:

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

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

代码中的遍历逻辑:

第五步:

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

初始化:

第一列:dp00=1、dp10=1、dp20=1;

第一行:dp00=1、dp01=1、dp02=1;

计算dp11:dp01 + dp10 = 1 + 1 = 2;

计算dp12:dp02 + dp11 = 1 + 2 = 3;

计算dp21:dp11 + dp20 = 2 + 1 = 3;

计算dp22:dp12 + dp21 = 3 + 3 = 6;

最终dp22 = 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 的状态转移方程:dpij = dpi-1j + dpij-1

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

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

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

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

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

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

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

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

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

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

  1. 确定状态转移方程

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

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

  1. dp 数组如何初始化

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

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

  1. 确定遍历顺序

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

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

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

  1. 举例推导一维 dp 数组

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

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

遍历第 1 行(i=1):

j=1:dp1 = dp1(上一行1) + dp0(当前行1) = 1+1=2 → dp = 1,2,1

j=2:dp2 = dp2(上一行1) + dp1(当前行2) = 1+2=3 → dp = 1,2,3

遍历第 2 行(i=2):

j=1:dp1 = dp1(上一行2) + dp0(当前行1) = 2+1=3 → dp = 1,3,3

j=2:dp2 = dp2(上一行3) + dp1(当前行3) = 3+3=6 → dp = 1,3,6

最终dp2 = 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 思路

第一步:

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

第二步:

若obstacleGridij = 1(当前位置是障碍物):dpij = 0(直接跳过,无路径);

若obstacleGridij = 0(当前位置可通行):状态转移方程和基础版一致 → dpij = dpi-1j + dpij-1

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

第三步:

第一列(j=0):

从(0,0)开始向下遍历,只要当前位置obstacleGridi0 = 0(无障碍物),则dpi0 = 1(只能向下走);

一旦遇到obstacleGridi0 = 1(障碍物),后续所有dpi+10, dpi+20...都为 0(被障碍物挡住,无法到达);

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

第一行(i=0):

从(0,0)开始向右遍历,只要当前位置obstacleGrid0i = 0(无障碍物),则dp0i = 1(只能向右走);

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

代码中用i < n && obstacleGrid0i == 0控制循环。

第四步:

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

第五步:

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

初始化第一列:

obstacleGrid00=0 → dp00=1;

obstacleGrid10=0 → dp10=1;

obstacleGrid20=0 → dp20=1;

初始化第一行:

obstacleGrid00=0 → dp00=1;

obstacleGrid01=0 → dp01=1;

obstacleGrid02=0 → dp02=1;

遍历计算:

i=1, j=1:obstacleGrid11=1 → 跳过,dp11=0;

i=1, j=2:obstacleGrid12=0 → dp12 = dp02 + dp11 = 1 + 0 = 1;

i=2, j=1:obstacleGrid21=0 → dp21 = dp11 + dp20 = 0 + 1 = 1;

i=2, j=2:obstacleGrid22=0 → dp22 = dp12 + dp21 = 1 + 1 = 2;

最终dp22 = 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 的含义

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

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

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

第二步:

  1. 确定状态转移方程

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

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

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

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

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

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

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

第三步:

  1. dp 数组如何初始化

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

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

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

第四步:

  1. 确定遍历顺序

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

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

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

第五步:

  1. 举例推导 dp 数组

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

初始化:dp2 = 1;

i=3:

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

Math.max(1*(3-1), 1*dp2) = Math.max(2, 1*1)=2 → dp3 = 2;

i=4:

j=1:Math.max(1*3, 1*dp3)=Math.max(3,2)=3 → dp4=3;

j=2(4/2=2):Math.max(2*2, 2*dp2)=Math.max(4,2)=4 → dp4更新为 4;

i=5:

j=1:Math.max(1*4,1*dp4)=Math.max(4,4)=4 → dp5=4;

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

i=6:

j=1:Math.max(1*5,1*dp5)=Math.max(5,6)=6 → dp6=6;

j=2:Math.max(2*4,2*dp4)=Math.max(8,8)=8 → dp6更新为 8;

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

最终dp6 = 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为例,一步步看dp6的更新过程,你就能直观理解:

初始状态

dp6 = 0(数组默认值)。

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

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

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

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

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

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

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

6 不同的二叉搜索树

6.1 思路

第一步:

dp i 及其下标 i 的含义

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

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

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

dp1 = 1:1 个节点只能组成 1 种 BST;

dp2 = 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);

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

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

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

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

第三步:

dp 数组如何初始化

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

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

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

这两个初始化值是后续所有dpi计算的基础。

第四步:

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

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

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

第五步:

举例推导 dp 数组

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

初始化:dp0=1,dp1=1;

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

i=3(3 个节点):j=1(以 1 为根):左dp0=1,右dp2=2 → 1×2=2;j=2(以 2 为根):左dp1=1,右dp1=1 → 1×1=1;j=3(以 3 为根):左dp2=2,右dp0=1 → 2×1=2;dp3 = 2+1+2 = 5;

最终dp3=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];
    }
}
相关推荐
梦@_@境18 分钟前
面向 Spring Boot 的可观测业务流程编排引擎
java·spring boot·后端
云烟成雨TD29 分钟前
Spring AI Alibaba 1.x 系列【77】执行取消
java·人工智能·spring
醇氧37 分钟前
【Linux】Java 服务生产级部署指南:实现常驻后台、开机自启与系统服务化管理
java·开发语言
JAVA面经实录9171 小时前
Netty 全套系统化学习文档(零基础到高阶面试完整版)
java·后端
菜鸟‍1 小时前
LeetCode 1 27 和 704 || 两数之和 移除元素 二分查找
算法·leetcode·职场和发展
weixin_523185321 小时前
Java面试高频题:Integer缓存机制与 equals、== 区别
java·缓存·面试
Hui Baby1 小时前
MCP SSE协议发送注意
java
仙俊红1 小时前
SpringBoot启动原理
java·spring boot·后端
星间都市山脉2 小时前
Android STS(Security Test Suite)完整介绍与测试流程
android·java·linux·windows·ubuntu·android studio·androidx
namexingyun2 小时前
拆解Fable 5三重安全护栏:模型路由、蒸馏防护与生物安全分类器的技术原理 - 微元算力(weytoken)
java·人工智能·python·安全·架构·ai编程