62. 不同路径
一个机器人位于一个
m x n网格的左上角 (起始点在下图中标记为 "Start" )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish" )。
问总共有多少条不同的路径?
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// 1. 定义 DP 数组
// dp[i][j] 表示:从起点 (0,0) 走到 (i,j) 位置的不同路径数量
vector<vector<int>> dp(m, vector<int>(n, 0));
// 2. 初始化边界条件
// 第一列:只能从上面往下走,只有一种路径
for (int i = 0; i < m; i++) dp[i][0] = 1;
// 第一行:只能从左边往右走,只有一种路径
for (int j = 0; j < n; j++) dp[0][j] = 1;
// 3. 状态转移(填表)
// 遍历顺序:从上到下,从左到右(因为当前位置依赖上边和左边的数据)
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
// 递推公式:
// 走到 (i,j) 只能从上面 (i-1,j) 或左边 (i,j-1) 过来
// 所以路径数 = 上面过来的路径数 + 左边过来的路径数
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
// 4. 返回结果:终点位置的路径数
return dp[m - 1][n - 1];
}
};
总结
1. 二维 DP 的解题套路
与一维 DP 类似,但需要处理两个维度的遍历:
- 定义:
dp[i][j]代表终点的状态。 - 递推公式:
dp[i][j] = dp[i-1][j] + dp[i][j-1]。这是组合数学中的加法原理。 - 遍历顺序:因为是
i依赖i-1,j依赖j-1,所以必须 先算出上边和左边的值。双重循环的顺序(从上到下、从左到右)是正确的。
2. 复杂度分析
- 时间复杂度:O(m×n)。需要遍历整个二维矩阵。
- 空间复杂度:O(m×n)。需要一个二维矩阵存储所有状态。
63. 不同路径 II
给定一个
m x n的整数数组grid。一个机器人初始位于 左上角(即grid[0][0])。机器人尝试移动到 右下角(即grid[m - 1][n - 1])。机器人每次只能向下或者向右移动一步。网格中的障碍物和空位置分别用
1和0来表示。机器人的移动路径中不能包含 任何 有障碍物的方格。返回机器人能够到达右下角的不同路径数量。
测试用例保证答案小于等于
2 * 109。
cpp
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
// 1. 特殊情况判断
// 如果起点或终点本身就有障碍物,直接无法到达
if (obstacleGrid[0][0] == 1 || obstacleGrid[m - 1][n - 1] == 1) return 0;
// 2. 定义 DP 数组
vector<vector<int>> dp(m, vector<int>(n, 0));
// 3. 初始化第一列
// 遇到障碍物后,后面的格子都无法通过,直接 break
for (int i = 0; i < m; i++) {
if (obstacleGrid[i][0] == 1) break;
dp[i][0] = 1;
}
// 4. 初始化第一行
// 同理,遇到障碍物后,后面的格子都无法通过
for (int j = 0; j < n; j++) {
if (obstacleGrid[0][j] == 1) break;
dp[0][j] = 1;
}
// 5. 状态转移
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];
}
// 如果是障碍物,dp[i][j] 保持为 0(初始值),表示路径数为 0
}
}
return dp[m - 1][n - 1];
}
};
总结
1. 易错点分析
- 错误写法:不管有没有障碍物,把第一行第一列全初始化为 1,遇到障碍物时才设为 0。
- 后果:障碍物后面的格子本应是 0(不可达),却被错误地初始化成了 1。
- 正确写法:使用
break。一旦遇到障碍物,循环终止,障碍物及其后面的格子都保持初始值 0。这是最标准、最高效的初始化方式。
2. 复杂度分析
- 时间复杂度:O(m×n)。遍历整个网格。
- 空间复杂度:O(m×n)。存储 DP 表。
343. 整数拆分
给定一个正整数
n,将其拆分为k个 正整数 的和(k >= 2),并使这些整数的乘积最大化。返回 你可以获得的最大乘积 。
cpp
class Solution {
public:
int integerBreak(int n) {
// 1. 定义 DP 数组
// dp[i] 表示:将整数 i 拆分成至少两个正整数后,获得的最大乘积
vector<int> dp(n + 1, 0);
// 2. 初始化
// dp[0] 和 dp[1] 无意义,因为没法拆分
dp[2] = 1; // 2 拆分成 1+1,乘积为 1
// 3. 状态转移
// 从 3 开始遍历到 n
for (int i = 3; i <= n; i++) {
// 遍历拆分的第一个整数 j
// j 的范围是 [1, i/2] (对称性,拆分超过一半就重复了)
for (int j = 1; j <= i / 2; j++) {
// 核心递推公式:
// 我们有两种选择来计算乘积:
// 1. 只拆成两个数:j * (i-j)
// 2. 拆成多个数:j * dp[i-j] (即对 i-j 继续拆分,取其最大乘积)
// 需要在两者中取最大值,同时也要和之前的 dp[i] 比较取最大
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
return dp[n];
}
};
总结
1. 递推公式深度解析
当我们把整数 i 拆分成 j 和 i-j 两部分时:
- 情况一:不再拆分。
- 直接计算乘积:
j * (i - j)。
- 直接计算乘积:
- 情况二:继续拆分。
j保持不变,将i - j继续拆分成更多份。- 此时乘积为:
j * dp[i - j](利用之前计算过的最优解)。
2. 遍历范围的优化
使用 j <= i/2,这是一个很好的优化点。
因为拆分具有对称性(比如拆 10,拆成 3+7 和 7+3 是一样的),所以只需遍历到一半即可。如果不写 i/2 写成 j < i 也是对的,只是会多做无用功。
3. 复杂度分析
- 时间复杂度:O(n2)。两层循环。
- 空间复杂度:O(n)。
96. 不同的二叉搜索树
给你一个整数
n,求恰由n个节点组成且节点值从1到n互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
cpp
class Solution {
public:
int numTrees(int n) {
// 1. 定义 DP 数组
// dp[i] 表示:由 i 个节点组成的互不相同的 BST 的数量
vector<int> dp(n + 1, 0);
// 2. 初始化
// dp[0] = 1 代表空树也是一种 BST
// 这是一个数学上的辅助项,保证递推公式能跑通(乘法中的 1)
dp[0] = 1;
// 3. 状态转移
// 遍历每一个节点总数 i
for (int i = 1; i <= n; i++) {
// 遍历每一个可能的根节点 j
for (int j = 1; j <= i; j++) {
// 递推公式:
// 以 j 为根节点:
// 左子树有 j-1 个节点,方案数为 dp[j-1]
// 右子树有 i-j 个节点,方案数为 dp[i-j]
// 乘积就是以 j 为根时的 BST 总数
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
总结
1. 核心思路:以谁为根?
要把 i 个节点组成 BST,关键在于 "根节点是谁"。
- 如果根节点是
j:- 比
j小的j-1个节点只能放在左子树。 - 比
j大的i-j个节点只能放在右子树。
- 比
- 组合问题:左子树有
dp[j-1]种摆法,右子树有dp[i-j]种摆法,根据乘法原理,总数就是两者相乘。 - 累加:因为
j可以取1到i,所以要把所有情况加起来。
2. 复杂度分析
- 时间复杂度:O(n2)。双层循环。
- 空间复杂度:O(n)。
3. 为什么 dp[0] = 1?
如果 dp[0] = 0,那么当 j=1 时,左子树节点数为 0,dp[0] * dp[i-1] 就会变成 0,导致结果错误。空树也是一种形态,所以必须初始化为 1。