从 1 开始被动态规划玩转 (上)
文章目录
前言 (与本文无关, 可忽略~)
广告推荐的文章最近没写, 核心原因是花里胡哨的 Trick 在实践中并无甚用, 影响写作的积极性 (本质上是人堕落了…). 在思考写作内容时, 考虑到可以先切换下主题, 比如动态规划, 这是我比较感兴趣的问题, 之前就想稍微系统的总结下, 写了部分内容在草稿箱, 现今打算完成. 标题取得有些奇特, 理由很简单, 因为在可见的未来并不能玩转动态规划, 另外写了 “(上)” 不代表有 “(下)”, 对目前的自己不抱有这样的期待.
广而告之
可以在微信中搜索 “珍妮的算法之路” 或者 “world4458” 关注我的微信公众号, 可以及时获取最新原创技术文章更新.
另外可以看看知乎专栏 PoorMemory-机器学习, 以后文章也会发在知乎专栏中.
文章总览
本文通过对一定数量的动态规划问题的分析, 试图找寻求解这类问题的 “规律”. 在行文上, 尽量对能使用同一方法解决的问题进行聚类, 然后逐题进行思路分析与解答. 这样的好处是, 当介绍完一种思路后, 可以尝试用该思路去解决相似的问题, 以验证自己的掌握情况.
文章不介绍基础的动态规划概念(标题中所谓的 “从 1 开始”), 比如 “重叠子问题”、“最优子结构” 以及 “状态转移方程” 等, 开始选的题目都不难, 可以在做题时可以深刻体会这些概念.
编程语言采用 C++, 刷题时用到的语法并不复杂, 很容易转换为 Python/Java 等语言. 另外解法可能不是最优的, 比如可以进一步优化效率与空间, 但重点还是放在基本思路的阐述上.
文字比较多, 采用 Ctrl + F
搜索标题的方式来进行查阅会比较方便.
一维数组: dp[i] 表示以 nums[i] 结尾的 xxx
一般我们用 dp
(dynamic programming) 数组来表示状态转移方程, 对一维数组的问题, 常用的一个套路是令
dp[i]
表示以 nums[i] 结尾的 xxx
并开始分析 dp[i]
与 dp[i - 1]
或者 dp[i - j]
之间的关系, 从而完成问题的求解. 具体可以看如下几道习题.
最大子序和 [Easy]
题目链接: 53. 最大子数组和
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
提示:
1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
最大子序和是一道经典的动态规划题目, 它要统计子数组的和, 并返回其中最大的值.
要写出状态转移方程, 需要先定义 dp
数组的含义. 该题中变化的量只是各个元素, 因此只需要用一维数组来保存各个阶段的状态. 定义 dp[i]
表示以 nums[i]
结尾的子数组的最大和, 那么, 现在要查找 dp[i]
和 dp[i - 1]
的关系:
- 如果
dp[i - 1] + nums[i] < nums[i]
, 就是说dp[i -1]
对应的子数组最大和小于 0, 考虑到dp[i]
对应的子数组必须以nums[i]
结尾, 那么dp[i - 1]
对应的子数组就可以舍弃, 而直接从nums[i]
重新构建子数组; - 而如果
dp[i - 1] + nums[i] > nums[i]
, 那么此时nums[i]
就可以加入到dp[i - 1]
对应的子数组中.
因此状态转移方程为dp[i] = max(dp[i - 1] + nums[i], nums[i])
. 另外使用res
来保存所有子数组中的最大和.
动态规划中还需要设置好初始情况, 假设 i = 0
, dp[0]
就和 dp[-1]
有关, 一方面 dp[-1]
可以初始化为 0
; 另一方面由于不存在 -1
索引, 所以给 dp
数组大小设置为 N + 1
, 其中 N
为 nums
的大小.
这样的话, 相当于使用 dp[i + 1]
来保存以 nums[i]
为结尾的子数组的最大和. 因此下面代码中, 状态转移方程写成了 dp[i + 1] = max(dp[i] + nums[i], nums[i])
.
class Solution {
public:
int maxSubArray(vector<int>& nums) {
vector<int> dp(nums.size() + 1, 0);
int res = INT32_MIN;
for (int i = 0; i < nums.size(); ++ i) {
dp[i + 1] = max(dp[i] + nums[i], nums[i]);
res = max(res, dp[i + 1]);
}
return res;
}
};
写完动态规划的基本代码后, 需要再看下能否优化, 容易注意到 dp[i+1]
只和它前一个状态 dp[i]
有关, 所以只需要用一个变量来保存前一个状态的结果. 因此优化为如下代码, 减少空间占用.
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int dp = 0;
int res = INT32_MIN;
for (int i = 0; i < nums.size(); ++ i) {
dp = max(dp + nums[i], nums[i]);
res = max(res, dp);
}
return res;
}
};
最长递增子序列 [Medium]
题目链接: 300. 最长递增子序列
给你一个整数数组 `nums` ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
提示:
1 <= nums.length <= 2500
-10^4 <= nums[i] <= 10^4
给定一个未排序的整数数组, 将其中最长递增子序列的长度求出来. 考虑使用动态规划来进行求解. 定义 dp[i]
表示以 nums[i]
结尾的数组中最长递增子序列的长度. 注意这个定义中说的 “以 nums[i]
结尾”. 为了得到状态转移方程, 需要考察 dp[i]
和其他元素的关系. 我们发现, 对于 j < i
,
- 如果
nums[i] > nums[j]
, 那么就可以在dp[j]
对应的最长递增子序列末尾, 将nums[i]
插入, 此时仍满足递增的性质, 这时候有dp[i] = dp[j] + 1 (nums[i] > nums[j])
; - 而如果
nums[i] <= nums[j]
, 说明nums[i]
无法加入到dp[j]
对应的最长递增子序列中, 又因为对于dp[i]
的定义中说的是需要 “以nums[i]
结尾”, 那么dp[i] = 1
, 表示此时以nums[i]
结尾的数组中的最长递增子序列其实就是nums[i]
本身, 大小为 1. (因此,dp
初始化时, 元素均设置为1
很方便)
归纳上面的分析, 可以得到状态转移方程为:
for (int j = i - 1; j >= 0; -- j) {
if (nums[i] > nums[j])
dp[i] = max(dp[i], dp[j] + 1);
}
因此代码如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int res = 0;
vector<int> dp(nums.size(), 1);
for (int i = 1; i < nums.size(); ++ i) {
for (int j = i - 1; j >= 0; -- j) {
if (nums[i] > nums[j])
dp[i] = max(dp[i], dp[j] + 1);
}
res = max(res, dp[i]);
}
return res;
}
};
举个例子, 下面列出数组 nums
以及数组中每个元素对应的 dp 值:
nums: 4, 10, 4, 3, 8, 9
dp: 1, 2, 1, 1, 2, 3
最后求出 dp
中最大值为 3
, 即 LIS 长度为 3, 比如 3, 8, 9
或者 4, 8, 9
.
这道题还有一种扩展解法 (可以暂时忽略, 因为下面的方法不是动态规划的方法, 可以先跳过):
这道题的 Follow up
中提到可以使用 O(n log(n))
的复杂度求解. 思路是尝试将数组中的最长递增子序列给找出来. 具体方法是:
使用序列 r
来保存 nums
中的最长递增子序列. 遍历 nums
的每一个元素 v
, 然后在数组 r
中找到 v
对应的 lower_bound
, 即第一个大于或等于 v
的值.
- 如果在序列
r
中找不到v
, 说明v
比r
中的所有元素都大, 因此可以将v
加入到r
的末尾, - 而如果
r
中存在某元素(代码中用*p
表示)大于或等于v
, 那么用v
将这个元素替换, 这样的话, 一方面, 如果*p
原本就等于v
, 那么没任何影响; 而如果*p
原本大于v
, 那么此时更新成v
后, 相当于数值变小了, 以后插入新的元素, 有更多得机会使得序列增长.
代码如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> r;
for(auto v : nums) {
auto p = std::lower_bound(r.begin(), r.end(), v);
if (r.end() == p)
r.push_back(v);
else
*p = v;
}
return r.size();
}
};
乘积最大子数组 [Medium]
题目链接: 152. 乘积最大子数组
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。 子数组 是数组的连续子序列。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
提示:
1 <= nums.length <= 2 * 10^4
-10 <= nums[i] <= 10
nums 的任何前缀或后缀的乘积都 保证 是一个 32-位 整数
乘积最大子数组 是一道经典的动态规划题目, 它和 最大子序和 以及 最长递增子序列 给我留下的印象太深刻了, 看到名字就知道需用动态规划的方法 🤣🤣🤣
先定义状态转移方程, 比如 dp[i]
表示以 nums[i]
结尾的子数组的最大值 (乘积或者求和). 那么更新 dp[i + 1]
时, 对于上面介绍的 最大子序和 问题来说, 状态方程为 dp[i + 1] = max(dp[i] + nums[i], nums[i]);
, 然而对于本题来说, 求最大乘积, 我们却不能直接使用 dp[i + 1] = max(dp[i] * nums[i], nums[i]);
原因是本题数组中是存在负数的, 即使 dp[i]
是负数, 也可能通过和 nums[i]
进行乘积, 从而得到正数. 那么矛盾就出现了, 我们在定义时, 希望 dp[i]
始终保存最大值, 但是就算 dp[i]
不是最大值, 也有可能在 dp[i + 1]
取得最大值 (通过负负得正的方式).
那如何解决这个矛盾呢 ? 方法是使用两个 dp
数组, 其中数组 A
维护以 nums[i]
结尾的子数组的最大乘积值, 而引入数组 B
来维护以 nums[i]
结尾的子数组的最小值. 之后写状态方程时, 需要同步更新两个数组的状态.
对于数组 A
, 要维护每个元素始终表示 “以 nums[i]
结尾的子数组的最大乘积值”, 除了要比较 A[i - 1] * nums[i]
和 nums[i]
大小外, 还需要考虑 B[i - 1]
这个最小值可能通过和 nums[i]
进行乘积的方式 (比如负负得正) 得到较大的正数, 因此更新 A[i]
时, 要比较 A[i - 1] * nums[i]
和 B[i - 1] * nums[i]
以及 nums[i]
的大小:
A[i] = max(max(A[i - 1] * nums[i], B[i - 1] * nums[i]), nums[i]);
同理对于数组 B
, 要维护每个元素始终表示 “以 nums[i]
结尾的子数组的最小乘积值”, 也要比较 A[i - 1] * nums[i]
和 B[i - 1] * nums[i]
以及 nums[i]
的大小, 不过这里是比较它们中的最小值, 毕竟 A[i - 1] * nums[i]
也可能因为 A[i - 1]
为正数而 nums[i]
为负数, 导致得到的乘积结果最小:
B[i] = min(min(A[i - 1] * nums[i], B[i - 1] * nums[i]), nums[i]);
在实际编码时, 需要注意:
- 考虑到遍历数组时
i
从0
开始, 那么当i == 0
时,A[i - 1]
就是A[-1]
, 越界了, 为此, 我们可以定义A[i + 1]
表示以nums[i]
结尾的子数组的最大乘积, 这种技巧应该很容易就想到; A
和B
的初始化, 比如A[0]
和B[0]
均初始化为 1, 用于乘积;
下面看具体代码:
class Solution {
public:
int maxProduct(vector<int>& nums) {
vector<int> A(nums.size() + 1, 1), B(nums.size() + 1, 1);
int res = nums[0];
for (int i = 0; i < nums.size(); ++ i) {
A[i + 1] = max(max(A[i] * nums[i], B[i] * nums[i]), nums[i]);
B[i + 1] = min(min(A[i] * nums[i], B[i] * nums[i]), nums[i]);
res = max(res, A[i + 1]);
}
return res;
}
};
由于 A
维护最大值, 所以最后其实是求 A
中的最大值, 使用 *std::max_element(A.begin(), A.end())
来得到结果也是可以的.
写完代码后, 观察下是否能再优化一下, 比如 A[i + 1]
只和 A[i]
相关, 只需要使用一个变量来表示 A[i]
即可, 同理 B[i]
也如此, 这样可以优化空间的占用. 代码如下:
class Solution {
public:
int maxProduct(vector<int>& nums) {
int A = 1, nA = 1, B = 1, nB = 1;
int res = nums[0];
for (int i = 0; i < nums.size(); ++ i) {
nA = max(max(A * nums[i], B * nums[i]), nums[i]);
nB = min(min(A * nums[i], B * nums[i]), nums[i]);
A = nA;
B = nB;
res = max(res, A);
}
return res;
}
};
下面另一种解法更为巧妙一些:
class Solution {
public:
int maxProduct(vector<int>& nums) {
if (nums.empty()) return 0;
int imax = nums[0], imin = nums[0];
int res = nums[0];
for (int i = 1; i < nums.size(); ++i) {
if (nums[i] < 0) std::swap(imax, imin);
imax = max(nums[i], imax * nums[i]);
imin = min(nums[i], imin * nums[i]);
res = max(res, imax);
}
return res;
}
};
下面分析另一种套路.
一维数组: dp[i] 表示达到 i 时的最值状态
在该套路下, 可以使用 dp[i]
表示达到数组中第 i
个位置时的最大收益或者最小损失. 下面分析几个典型问题.
使用最小花费爬楼梯 [Easy]
题目链接: 746. 使用最小花费爬楼梯
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
提示:
2 <= cost.length <= 1000
0 <= cost[i] <= 999
对我来说这是一道迷人的题, 这题的神奇之处在于每次写的时候总感觉自己会写错, 但次次都能写对~ 之所以会产生这样的想法, 是因为我之前并没有将求解方法纳入现有的思考体系中.
本题采用动态规划求解, 使用 dp[i]
表示达到 i
时所需要付出的最小代价. 假设 cost
数组的大小为 N
, 最终的目标就是跳到第 N
个位置上, 所付出的代价就是 dp[N]
. 根据题意, 当你位于第 i
个位置时, 付出当前位置的花费 cost[i]
, 就可以跳一步或者两步. 那么要跳到第 i
个位置上时, 要么一开始就位于第 i - 1
的位置上, 然后付出 cost[i - 1]
的代价跳一步到达第 i
个位置; 要么一开始位于第 i - 2
的位置上, 然后付出 cost[i - 2]
的代价跳两步到达第 i
个位置;
综上, 状态转移方程呼之欲出:
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
要确定初始状态, 题目中说可以从第 0
个位置和第 1
个位置开始起跳, 那么:
dp[0] = 0;
dp[1] = 0;
再次注意 dp[i]
的含义是到达 i
时所需要付出的最小代价, 该位置上的花费 cost[i]
指的是你要离开 第 i
个位置时需要付出的代价, 由于我们可以直接从第 0
个位置和第 1
个位置开始起跳, 所以它们的初始值均设置为 0
. 最后我们只需要返回 dp[N]
就可以求解, 代码如下:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int N = cost.size();
vector<int> dp(N + 1, 0);
for (int i = 2; i <= N; ++ i)
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
return dp[N];
}
};
注意题目中说了数组的大小至少为 2
, 所以 N < 2
的情况不用考虑. 写到这, 代码还不算完, 还需要考虑能否进一步优化. 观察到 dp[i]
的结果只和 dp[i - 1]
和 dp[i - 2]
有关, 似乎不需要使用 vector
来记录所有历史结果, 只需要使用变量 dp0 = dp[i - 2], dp1 = dp[i - 1]
来保留历史状态, 同时不断维护 dp0
和 dp1
的状态.
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int N = cost.size();
int dp0 = 0, dp1 = 0, dp2 = 0;
for (int i = 2; i <= N; ++ i) {
dp2 = min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
dp0 = dp1;
dp1 = dp2;
}
return dp2;
}
};
打家劫舍 [Medium]
题目链接: 198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋
装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
这题的大意是小偷抢劫房子, 但不能抢劫相邻的, 求能获取的最大收益. 那么对于每所房子来说, 它有被抢和不抢两种状态, 设 dp[i]
为访问第 i
所房子时, 小偷能获得的最大收益, 那么状态转移方程就可以写成:
dp[i] = max(dp[i - 2] + nums[i], // 抢劫第 i 所房子
dp[i - 1] // 不抢
)
即如果抢劫了第 i
所房子, 那么它前一所房子(第 i - 1
所房子)就不能抢, 因此收益最大为 dp[i - 2] + nums[i]
; 而如果不抢第 i
所房子, 那么收益最大为访问到第 i - 1
所房子时所获得收益 dp[i - 1]
.
代码如下:
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.empty()) return 0;
if (nums.size() == 1) return nums[0];
int n = nums.size();
vector<int> dp(n, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < n; ++ i) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp.back();
}
};
如果不想判断 nums.size() == 1
的状态, 也可以将代码精简为:
class Solution {
public:
int rob(vector<int>& nums) {
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
for (int i = 1; i < nums.size(); ++ i) {
dp[i] = std::max(i - 2 >= 0 ? (dp[i - 2] + nums[i]) : nums[i], dp[i - 1]);
}
return dp.back();
}
};
打家劫舍 II [Medium]
题目链接: 213. 打家劫舍 II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,
这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的
房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 1000
本题是上一题 打家劫舍 的进阶, 增加了房屋成环的约束条件. 处理成环问题的一个常用思路是将环进行展开, 假设房屋总数是 N
, 那么想象用剪刀从位置 N - 1
和位置 0
之间剪开了一个口子. 将想象的情景落地成现实, 即是:
偷窃计划 1
: 如果小偷从第0
间房屋开始偷窃, 那么它最多偷到第N - 2
间屋子 (也就是说, 最后一间屋子N - 1
绝对不偷)偷窃计划 2
: 如果小偷从第1
间房屋开始偷窃, 那么它最多偷到第N - 1
间屋子
通过这样的拆解, 本题就转换成了两道 打家劫舍 的问题, 不过处理的序列长度从原来的 N
变成了 N - 1
, 打家劫舍 的代码可以直接拿过来用.
设 dp1[i]
为按照 偷窃计划 1
访问第 i
所房子时, 小偷能获得的最大收益, dp2[i]
为按照 偷窃计划 2
访问第 i
所房子时, 小偷能获得的最大收益, 转移转移方程均为:
dp[i] = max(dp[i - 2] + nums[i], // 抢劫第 i 所房子
dp[i - 1] // 不抢
)
最后只需要比较 dp1
和 dp2
中的最大值即可. 代码如下:
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
vector<int> dp1(nums.size(), 0), dp2(nums.size(), 0);
int res1 = nums[0], res2 = nums[1];
// 按偷窃计划 1 行事, 访问数组 nums[0, ..., N - 2]
dp1[0] = nums[0];
for (int i = 1; i < nums.size() - 1; ++ i) {
dp1[i] = std::max(i - 2 >= 0 ? dp1[i - 2] + nums[i] : nums[i], dp1[i - 1]);
res1 = std::max(res1, dp1[i]);
}
// 按偷窃计划 2 行事, 访问数组 nums[1, ..., N - 1]
dp2[1] = nums[1];
for (int i = 2; i < nums.size(); ++ i) {
dp2[i] = std::max(i - 2 >= 1 ? dp2[i - 2] + nums[i] : nums[i], dp2[i - 1]);
res2 = std::max(res2, dp2[i]);
}
return std::max(res1, res2);
}
};
二维数组: dp[i][j] 表示到达 (i, j) 时的目标和
当问题扩展到二维数组时, 位置 (i, j)
由两个变量决定, 相应的转态转移方程也要升级为 dp[i][j]
, 之后一般考察它与 dp[i][j - 1]
、dp[i - 1][j]
以及 dp[i - 1][j - 1]
的关系.
不同路径 [Easy]
题目链接: 62. 不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入:m = 3, n = 7
输出:28
示例 2:
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
提示:
1 <= m, n <= 100
题目数据保证答案小于等于 2 * 109
题目意图很明了, 要统计在一个 m x n
大小的网格中, 机器人从左上角 (0, 0)
处出发, 到达右下角 (m - 1, n - 1)
所有不同路径的总数, 机器人每次只能右移或下移一步.
该题的变量主要是位置 (i, j)
, 使用二维数组 dp[i][j]
表示机器人到达 (i, j)
时的不同路径总数. 由于机器人每次只能右移或下移一步, 那么, 为了到达 (i, j)
, 机器人要么是从上方 (i, j - 1)
的位置下移一步到达 (i, j)
, 要么是从左侧 (i - 1, j)
右移一步到达 (i, j)
. 由于到达上方 (i, j - 1)
的路径有 dp[i][j - 1]
条, 而到达左侧 (i - 1, j)
的路径有 dp[i - 1][j]
条, 因此到达 (i, j)
时的路径总数为:
dp[i][j] = dp[i][j - 1] + dp[i - 1][j]
确定好状态转移方程后, 再考虑 Base 状态和边界条件, 以求得正确的结果. 由于在 (0, 0)
这个位置的状态方程为 dp[0][0] = dp[0][-1] + dp[-1][0]
, 访问索引 -1
会导致数组越界, 因此常用的一种方法是在网格外围再扩展一层, 使网格的大小由 m x n
变化为 (m + 1) x (n + 1)
, 具体如下图:
此时, dp[i + 1][j + 1]
表示到达原始网格 (i, j)
处的路径个数. 令 dp
初始化为 0, 并令 dp[0][1] = 1
, 这是因为机器人在原始网格起点处 (0, 0)
的位置可以认为有 1
条路径能达到, 那么 dp[1][1] = dp[1][0] + dp[0][1] = 0 + 1 = 1
可以得到正确的结果. 正确的初始化之后, 可以写出如下代码:
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
dp[0][1] = 1;
for (int i = 1; i <= m; ++ i) {
for (int j = 1; j <= n; ++ j) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m][n];
}
};
不同路径 II [Easy]
题目链接: 63. 不同路径 II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
示例 1:
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
提示:
m == obstacleGrid.length
n == obstacleGrid[i].length
1 <= m, n <= 100
obstacleGrid[i][j] 为 0 或 1
在上一题 不同路径 的基础上, 增加限制, 即如果某些空格是不能走的, 存在障碍物, 那么到达 grid 的右下角有多少种走法.
方法仍是在网格外围再扩展一层, 使网格的大小由 m x n
变化为 (m + 1) x (n + 1)
, 同时令 dp[i + 1][j + 1]
表示到达原始网格 (i, j)
处的路径个数, 不过需要注意的是, 仅在 obstacleGrid[i][j] != 1
时, dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
成立.
完整代码如下:
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int> > &obstacleGrid) {
int m = obstacleGrid.size() , n = obstacleGrid[0].size();
vector<vector<long long>> dp(m+1,vector<long long>(n+1,0));
dp[0][1] = 1;
for(int i = 1 ; i <= m ; ++i)
for(int j = 1 ; j <= n ; ++j)
if(!obstacleGrid[i-1][j-1])
dp[i][j] = dp[i-1][j] + dp[i][j-1];
return dp[m][n];
}
};
注意 dp
被设置为 vector<vector<long long>>
, 是因为如果设置为 vector<vector<int>>
, 那有个测试用例会导致报错:
Line 44: Char 45: runtime error: signed integer overflow: 1053165744 + 1579748616 cannot be represented in type 'int' (solution.cpp)
1
设置为 long long
可以避免. 但两年前的提交代码中使用的就是 vector<vector<int>>
能通过 100% 的测试用例. 时代变了啊.
最小路径和 [Easy]
题目链接: 64. 最小路径和
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例 1:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
示例 2:
输入:grid = [[1,2,3],[4,5,6]]
输出:12
提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 200
0 <= grid[i][j] <= 100
这题是 不同路径 的变种, 此处考虑用 dp[i][j]
表示到达 (i - 1, j - 1)
时最小的数字总和 (对原网格向外扩展一层), 由于每次只能向下或向右移动一步, 因此可以轻松的写出状态转移方程:
dp[i][j] = std::min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
完整代码如下:
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT32_MAX));
dp[0][1] = 0;
for (int i = 1; i <= m; ++ i) {
for (int j = 1; j <= n; ++ j) {
dp[i][j] = std::min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
}
}
return dp[m][n];
}
};
三角形最小路径和 [Medium]
题目链接: 120. 三角形最小路径和
给定一个三角形 triangle ,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者
等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到
下一行的下标 i 或 i + 1 。
示例 1:
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
2
3 4
6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
示例 2:
输入:triangle = [[-10]]
输出:-10
提示:
1 <= triangle.length <= 200
triangle[0].length == 1
triangle[i].length == triangle[i - 1].length + 1
-10^4 <= triangle[i][j] <= 10^4
进阶:
你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题吗?
令 dp[i][j]
表示到达 (i, j)
时, 自顶向下的最小路径和. 对于节点 (i, j)
, 它要么是从上一层位于 (i - 1, j - 1)
的节点移动到的, 要么是从上一层位于 (i - 1, j)
的节点移动到的. 因此可以很轻松的写出状态转移方程:
dp[i][j] = triangle[i - 1][j - 1] + std::min(dp[i - 1][j - 1], dp[i - 1][j]);
但由于访问的是三角形而不是矩形, 因此有些边界条件需要注意, 完整代码如下, 在具体实现时, 考虑到边界情况, 可以将三角形向外扩展一层, 这种方式在前几道题有介绍, 这里不再详细说明. 另外注意当 j > i
时, 三角形中没有元素, 此时可以 continue
:
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
int n = triangle.size();
vector<vector<int>> dp(n + 1, vector<int>(n + 1, INT32_MAX));
dp[0][0] = 0;
int res = INT32_MAX;
for (int i = 1; i <= n; ++ i) {
for (int j = 1; j <= n; ++ j) {
if (j > i) continue;
dp[i][j] = triangle[i - 1][j - 1] + std::min(dp[i - 1][j - 1], dp[i - 1][j]);
if (i == n) res = std::min(res, dp[i][j]);
}
}
return res;
}
};
另外也写了不使用额外空间的做法, 仅做参考, 核心在需要更改 triangle
变量本身的值:
class Solution {
public:
int minimumTotal(vector<vector<int>>& triangle) {
for (int i = 1; i< triangle.size(); ++ i) {
for (int j = 0; j < triangle[i].size(); ++ j) {
int a = j - 1 >= 0 ? triangle[i - 1][j - 1] : INT32_MAX;
int b = j < triangle[i - 1].size() ? triangle[i - 1][j] : INT32_MAX;
triangle[i][j] += std::min(a, b);
}
}
int res = INT32_MAX;
for (auto &c : triangle.back()) res = std::min(res, c);
return res;
}
};
下降路径最小和 [Medium]
题目链接: 931. 下降路径最小和
给你一个 n x n 的 方形 整数数组 matrix ,请你找出并返回通过 matrix 的下降路径 的 最小和 。
下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选
元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置 (row, col)
的下一个元素应当是 (row + 1, col - 1)、(row + 1, col) 或者 (row + 1, col + 1) 。
示例 1:
输入:matrix = [[2,1,3],[6,5,4],[7,8,9]]
输出:13
解释:如下图所示,为和最小的两条下降路径
提示:
n == matrix.length == matrix[i].length
1 <= n <= 100
-100 <= matrix[i][j] <= 100
经过前面问题的训练, 此题可以轻松做出来. 使用 matrix
本身来记录到达 (i, j)
时下降路径的最小和, 因此状态转移方程是:
matrix[i][j] += std::min(
std::min(matrix[i - 1][j - 1], matrix[i - 1][j + 1]),
matrix[i - 1][j])
因为要到达 (i, j)
, 可以从上一层的 (i - 1, j - 1)
, (i - 1, j)
以及 (i - 1, j + 1)
三个位置移动, 实际编写代码时考虑写边界条件即可, 完整代码如下:
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& matrix) {
if (matrix.size() == 1) return matrix[0][0];
int res = INT32_MAX;
for (int i = 1; i < matrix.size(); ++ i) {
for (int j = 0; j < matrix[0].size(); ++ j) {
int a = j - 1 >= 0 ? matrix[i - 1][j - 1] : INT32_MAX;
int b = j + 1 < matrix[0].size() ? matrix[i - 1][j + 1] : INT32_MAX;
matrix[i][j] += std::min(std::min(a, b), matrix[i - 1][j]);
if (i == matrix.size() - 1) res = std::min(res, matrix[i][j]);
}
}
return res;
}
};
总结
文章稍显冗长, 主要是题目本身也占用较多字数, 通过将相同类型的题目汇聚在一起, 可以发现还是有些通用的规律的. 不过一期选择的题目基本都非常简单, 没有太多的弯弯绕绕, 还欠缺一些挑战性. 后续将总结更 Tricky 的题目, 以便加深这可怜的记忆. 技能的习得需要大量练习, 奈何我还是想偷偷懒, 不是很想花太多时间在这上面, 哈哈哈.