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