1 今日打卡
不同路径Ⅱ 63. 不同路径 II - 力扣(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 五部曲分析
- dp [j] 及其下标 j 的含义
dp[j] 表示:遍历到第 i 行时,从左上角 (0,0) 到达当前行第 j 列位置的不同路径数;
下标 j 对应网格的列号(从 0 开始),数组长度为 n(列数);
每遍历一行,dp[j]会被更新为当前行第 j 列的路径数,覆盖上一行的值。
- 确定状态转移方程
优化后的状态转移方程:dp[j] = dp[j] + dp[j-1](i ≥ 1且j ≥ 1)
等号右边的dp[j]:上一行第 j 列的路径数(未更新前的值);
等号右边的dp[j-1]:当前行第 j-1 列的路径数(已更新后的值);
等号左边的dp[j]:当前行第 j 列的路径数(更新后的值)。
- dp 数组如何初始化
和二维解法一致,第一行所有位置的路径数都是 1(只能向右走),因此:
初始化一维数组dp[j] = 1(0 ≤ j < n),对应二维解法中dp[0][j] = 1。
- 确定遍历顺序
外层循环:遍历行,从第 1 行到第 m-1 行(i 从 1 到 m-1),因为第一行已初始化;
内层循环:遍历列,从第 1 列到第 n-1 列(j 从 1 到 n-1),因为第 0 列的路径数始终是 1(无需更新);
必须先遍历行、再遍历列,且列要从左到右遍历,保证计算dp[j]时,dp[j-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)。
第二步:
- 确定状态转移方程
要找到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结果相同,无需重复计算)。
第三步:
- dp 数组如何初始化
初始化需贴合 "至少拆分成两个数" 的规则:
dp[0]和dp[1]:无意义(0 和 1 无法拆分成至少两个正整数),无需初始化;
dp[2] = 1:2 只能拆成 1+1,乘积为 1,这是最小的可拆分情况,也是后续计算的基础。
第四步:
- 确定遍历顺序
外层循环:遍历被拆分的数i,从 3 到n(i=2已初始化),因为dp[i]依赖dp[i-j](i-j < i),需从小到大遍历;
内层循环:遍历拆分的第一个数j,从 1 到i/2(优化后),枚举所有可能的拆分方式;
遍历逻辑:先确定i,再枚举j,计算每种拆分方式的乘积,更新dp[i]的最大值。
第五步:
- 举例推导 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];
}
}