【笔记】动态规划刷题总结 2.0
个人心得
问题本质
动态规划名字看起来高大上,感觉是种很复杂的算法,令人“望文生畏”,其实一句话概括,就是 数学归纳法,推公式 。动态规划问题的每一个状态是由上一个状态通过 状态转移方程 推导得出(对于存在很多状态的问题,需要画状态图辅助推导出正确的状态转移方程,类似《编译原理》的自动状态机)。动态规划是一种 “聪明的穷举” ,所谓具备 “最优子结构” 和存在 “重叠子问题” 。本质上来说,动态规划是通过引入dp数组这种 “空间换时间” 的方法来降低暴力算法的时间复杂度。
解题步骤
参考Carl的方法,牢牢把握“五部曲”:
-
确定dp数组以及下标的含义
-
确定递推公式
-
dp数组如何初始化
-
确定遍历顺序
-
举例推导dp数组
解释说明:
- 根据题目选择二维数组还是滚动数组(一维),状态数决定二维dp数组的列数
- 2先于3是因为不同的状态转移方程需要不同的初始化方法
- 深刻理解1,才能初始化好3
- 遍历顺序主要集中于背包问题:用滚动数组解决01背包时,注意先物品再背包且背包倒序遍历(防止重复)。完全背包正序、倒序均可,若求排列问题只能先遍历背包,若求组合问题只能先遍历物品
- 如果存在错误,打印dp数组日志来验证每一步过程,下附“灵魂三问”,解决好这三个问题debug将不再困难:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
一、基础题目,用于熟悉“五部曲”解题
509.斐波那契数
// 时间、空间复杂度O(N)
class Solution {
public:
int fib(int n) {
if (n == 0 || n == 1) return n;
vector<int> dp(n + 1, 0);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n ; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
// 当然可以发现,只需要维护两个数值就可以了,不需要记录整个序列。下面写法空间复杂度O(1)
class Solution {
public:
int fib(int N) {
if (N <= 1) return N;
int dp[2];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= N; i++) {
int sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1];
}
};
70.爬楼梯
class Solution {
public:
int climbStairs(int n) {
if (n < 3) return n;
vector<int> dp(n + 1);
// 本题考虑关于dp[0]的初始化没意义
// i从3开始,直接跳过dp[0]
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
// 还可以优化空间复杂度
// 【扩展】完全背包:每次走m下,几种方法到n层台阶
746.使用最小花费爬楼梯
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
// 初始化包含了将dp[0],dp[1]设为0
// 此题并非跳到最后一级台阶截止,而是要跳出所有楼梯,dp要多一个
vector<int> dp(cost.size() + 1, 0);
for (int i = 2; i <= cost.size(); i++) {
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[cost.size()];
}
};
// 还可以优化空间复杂度O(1)
62.不同路径
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector(n, 0));
// 第一行,第一列的所有元素均只有一种到达方式
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
// 从(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 - 1][n - 1];
}
};
62.不同路径II
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
//如果在起点或终点出现了障碍,直接返回0
if (obstacleGrid[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1] == 1 || obstacleGrid[0][0] == 1) return 0;
vector<vector<int>> dp(obstacleGrid.size(), vector<int>(obstacleGrid[0].size(), 0));
for (int i = 0; i < obstacleGrid.size(); i++) {
if (obstacleGrid[i][0] == 1)
break;
dp[i][0] = 1;
}
for (int j = 0; j < obstacleGrid[0].size(); j++) {
if (obstacleGrid[0][j] == 1)
break;
dp[0][j] = 1;
}
for (int i = 1; i < obstacleGrid.size(); i++) {
for (int j = 1; j < obstacleGrid[0].size(); j++) {
if (obstacleGrid[i][j] == 0) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
}
};
343.整数拆分(比较难想,注意理解)
class Solution {
public:
int integerBreak(int n) {
if (n == 2) return 1;
vector<int> dp(n + 1, 0);
dp[2] = 1;
for (int i = 3; i <= n; i++) {
// 因为拆分一个数 n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的
// 只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
// 那么 j 遍历,只需要遍历到 i/2 就可以,后面就没有必要遍历了,一定不是最大值。
for (int j = 1; j <= i / 2; j++) {
// 从1遍历j,然后有两种渠道得到dp[i]
// 一个是j * (i - j) 直接相乘,一个是j * dp[i - j]
// j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘
// j怎么就不拆分呢?j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了
dp[i] = max(dp[i], max(dp[i - j] * j, (i - j) * j));
}
}
return dp[n];
}
};
96.不同的二叉搜索树(很难想,注意理解)
class Solution {
public:
int numTrees(int n) {
if (n == 1) return n;
vector<int> dp(n + 1, 0);
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i; j++) {
// dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
// j 相当于是头结点的元素,从1遍历到i为止
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};
二、背包问题(难点是遍历顺序,其次是递推公式)
引用自:代码随想录-背包总结篇
1、0-1背包理论基础
二维数组版本
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
1、确定dp数组以及下标的含义
使用二维数组,即dp[i] [j]表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少
2、确定递推公式
有两个方向推出来dp[i] [j],
- 不放物品i:由dp[i - 1] [j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i] [j]就是dp[i - 1] [j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
- 放物品i:由dp[i - 1] [j - weight[i]]推出,dp[i - 1] [j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1] [j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);
3、dp数组如何初始化
首先从dp[i] [j]的定义出发,如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为0;那么很明显当 j < weight[0]的时候,dp[0] [j] 应该是 0,因为背包容量比编号0的物品重量还小,当j >= weight[0]时,dp[0] [j] 应该是value[0],因为背包容量放足够放编号0物品
4、确定遍历顺序
递推数据由上、左上得来,先遍历物品、背包均可。为了好理解建议先遍历物品,即i在外循环j在内循环
5、举例推导dp数组
最终结果就是dp[2] [4]
一维dp数组(滚动数组)
背包问题其实状态都是可以压缩的。可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i] [j] = max(dp[i] [j], dp[i] [j - weight[i]] + value[i]);与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
【遍历顺序的讨论】
【为什么倒序遍历背包容量?】二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。为什么呢?倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
【为什么二维dp数组历的时候不用倒序?】
因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,本层的dp[i] [j]并不会被覆盖!
【为什么不能先遍历背包容量嵌套遍历物品呢?】
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
五部曲省略,直接上测试代码
void test_1_wei_bag_problem() {
vector<int> weight = {
1, 3, 4};
vector<int> value = {
15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) {
// 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) {
// 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
2、递推公式分类总结
① 问能否能装满背包(或者最多装多少)
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:
动态规划:416.分割等和子集
只有确定了如下四点,才能把0-1背包问题套到本题上来。
- 背包的体积为sum / 2
- 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
- 背包如果正好装满,说明找到了总和为 sum / 2 的子集
- 背包中每一个元素是不可重复放入
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums)
sum += num;
if (sum % 2 == 1) return false;
int target = sum / 2;
// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 总和不会大于20000,背包最大只需要其中一半,所以dp大小设置为10001就可以了
// 但我们已经计算出了实际上总和的一半,因此比一半大即可
vector<int> dp(target + 1, 0);
for (int i = 0; i < nums.size(); i++) {
// 每一个元素一定是不可重复放入,所以从大到小遍历
for (int j = target; j >= nums[i]; j--) {
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
if (dp[target] == target) return true;
return false;
}
};
动态规划:1049.最后一块石头的重量 II
本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for (int stone : stones)
sum += stone;
int half = sum / 2;
vector<int> dp(half + 1, 0);
for (int i = 0; i < stones.size(); i++) {
for (int j = half; j >= stones[i]; j--) {
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
// 与 416 仅返回值的处理不同
// 此时dp[half]中存放最接近但一定小于等于一半重量的石头,另一堆重量是sum - dp[half]
return sum - 2 * dp[half];
}
};
② 问装满背包有几种方法:
dp[j] += dp[j - nums[i]] ,注意到有累加求和,对应题目如下:
动态规划:494.目标和
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
// 本质是找两堆数,差为target,也可以剪枝回溯,但是carl这版的思路还需要在理解一下
// 假设正数和为x,即将但还未添加负号的负数和为sum - x,目标和target = x - (sum - x)
// 则 x = (target + sum) / 2,为背包大小
int sum = accumulate(nums.begin(), nums.end(), 0);
if (abs(target) ></