动态规划(Dynamic Programming, DP)
动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法,动态规划中每一个状态一定是由上一个状态推导出来的。
由于动态规划并不是某种具体的算法,而是一种解决特定问题的方法,因此它会出现在各式各样的数据结构中,与之相关的题目种类也更为繁杂。
解题条件:
能用动态规划解决的问题,需要满足三个条件:最优子结构,无后效性和子问题重叠。
基本思路:
- 将原问题划分为若干 阶段,每个阶段对应若干个子问题,提取这些子问题的特征(称之为 状态);
- 寻找每一个状态的可能 决策,或者说是各状态间的相互转移方式(用数学的语言描述就是 状态转移方程)。
- 按顺序求解每一个阶段的问题。
解题步骤:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
509. 斐波那契数 ●
F(0) = 0,F(1) = 1
求 F(n) = F(n - 1) + F(n - 2),其中 n > 1
1. 递归
- 时间复杂度: O ( 2 n ) O(2^n) O(2n)
- 空间复杂度:O(n),递归调用栈
class Solution {
public:
int fib(int N) {
if (N < 2) return N;
return fib(N - 1) + fib(N - 2);
}
};
2. DP
- 确定dp数组,dp[i]定义为:第i个数的斐波那契数值是dp[i]
- 确定递推公式(状态转移方程): dp[i] = dp[i - 1] + dp[i - 2];
- dp数组初始化 dp[0] = 0; dp[1] = 1;
- 确定遍历顺序:从前到后遍历
- 举例推导dp数组
- 时间复杂度:O(n)
- 空间复杂度:O(n)
class Solution {
public:
int fib(int n) {
if(n < 2) return n;
int dp[n+1];
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i <= n; ++i){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
};
70. 爬楼梯 ●
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
- dp[i]: 爬到第i层楼梯,有dp[i]种方法
- dp[i] = dp[i - 1] + dp[i - 2];
(上 i-1 层楼梯,有dp[i - 1]种方法,那么再一个台阶就是dp[i];上 i-2 层楼梯,有dp[i - 2]种方法,那么再两个个台阶就是dp[i]) - dp[1] = 1; dp[2] = 2;
- 从前到后遍历
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度: O ( n ) O(n) O(n)
class Solution {
public:
int climbStairs(int n) {
if(n <= 2) return n;
int dp[n+1];
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n; ++i){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
};
- 空间复杂度优化: O ( 1 ) O(1) O(1)
class Solution {
public:
int climbStairs(int n) {
if(n <= 2) return n;
int dp[3];
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n; ++i){ // 遍历后面的楼梯
int sum = dp[1] + dp[2];
dp[1] = dp[2];
dp[2] = sum;
}
return dp[2];
}
};
- 扩展:一次最多能跨越m阶楼梯(完全背包问题,物品为{1,2,…m},target 为 n)
class Solution {
public:
int climbStairs(int n, int m) { // n为最终的目标楼梯,m为一次最多能爬m阶
if(n <= m) return m;
int dp[n+1];
dp[0] = 1;
for(int i = 1; i <= n; ++i){
for(int j = 1; j <= m; ++j){ // 前m阶内的组合
if(i-j > 0) dp[i] += dp[i-j];
}
}
return dp[n];
}
};
746. 使用最小花费爬楼梯 ●
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
- dp[i]:到达第 i 阶消耗的最少体力(下标从0开始),达到楼梯顶部指下标为数组长度的阶梯;
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
(分别达到前两个阶梯与该阶梯向上爬需要消耗体力的最小值)- 达到前两个阶梯即dp[0]、dp[1] 不需要体力值,因为从其一开始出发;
- 从前往后遍历。
- 时间复杂度:O(n)
- 空间复杂度:O(n)
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int len = cost.size();
int dp[len+1];
dp[0] = 0; // 能跨两个台阶,因此只有一个台阶时为0
dp[1] = 0;
for(int i = 2; i <= len; ++i){
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
}
return dp[len];
}
};
- 空间复杂度优化: O ( 1 ) O(1) O(1)
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int len = cost.size();
int dp[3];
dp[0] = 0; // 能跨两个台阶,因此只有一个台阶时为0
dp[1] = 0;
for(int i = 2; i <= len; ++i){
dp[2] = min(dp[1] + cost[i-1], dp[0] + cost[i-2]);
dp[0] = dp[1];
dp[1] = dp[2];
}
return dp[2];
}
};
62. 不同路径 ●●
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
1. DP
- dp[ i ] [ j ] :从(0,0)出发到(i, j) 不同的路径数量;
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
左边和上边两个方向过来;vector<vector<int>> dp(m+1, vector<int>(n+1, 1));
都初始化为1,下标从1开始;- 从左往右,一层一层遍历。
- 时间复杂度:O(m × n)
- 空间复杂度:O(m × n)
class Solution {
public:
int uniquePaths(int m, int n) {
if(m == 1 || n == 1) return 1;
vector<vector<int>> dp(m+1, vector<int>(n+1, 1));
for(int i = 2; i <= m; ++i){
for(int j = 2; j <= n; ++j){
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m][n]; // 下标从1开始
}
};
- 空间复杂度优化:O(n)
一维数组(滚动数组),dp[j] = dp[j-1] + dp[j];
dp[j-1]为当前层更新后 j - 1 列(即左边)的路径数量,dp[ j ]为上一层更新的j列(即上边)路径数量
class Solution {
public:
int uniquePaths(int m, int n) {
if(m == 1 || n == 1) return 1; // 一行、一列都为1
vector<int> dp(n+1,1); // 下标从1开始
for(int i = 2; i <= m; ++i){ // 第二个数开始
for(int j = 2; j <= n; ++j){
dp[j] = dp[j-1] + dp[j];// dp[j-1]为当前层更新后j-1列(即左边)的路径数量,dp[j]为上一层更新的j列(即上边)路径数量
}
}
return dp[n];
}
};
2. 数论方法
无论怎么走,走到终点都需要 m + n - 2 步,其中一定有 m - 1 步是要向下走的,不用管什么时候向下走。
那么有几种走法呢? 可以转化为组合问题,即给你m + n - 2个不同的数,随便取m - 1个数,有 C ( m + n − 2 , m − 1 ) C(m+n-2, m-1) C(m+n−2,m−1)种取法。
求组合的时候,要防止两个int相乘溢出! 所以不能把算式的分子都算出来,分母都算出来再做除法。需要在计算分子的时候,不断除以分母,代码如下:
- 时间复杂度:O(m)
- 空间复杂度:O(1)
class Solution {
public:
int uniquePaths(int m, int n) {
long long numerator = 1; // 分子
int denominator = m - 1; // 分母
int count = m - 1;
int t = m + n - 2;
while (count--) {
numerator *= (t--);
while (denominator != 0 && numerator % denominator == 0) {
numerator /= denominator;
denominator--;
}
}
return numerator;
}
};
63. 不同路径 II ●●
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
- dp[ i ] [ j ] :从(0,0)出发到(i, j) 不同的路径数量;
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
左边和上边两个方向过来,遇到障碍物时为0;- 全初始化为0,有障碍物时不操作;第一行和第一列 dp 单独初始化,与前一个位置相等,则dp[0][0]也要考虑;
- 从左往右,一层一层遍历。
- 时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度
- 空间复杂度:O(n × m)
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(); // 行数m
int n = obstacleGrid[0].size(); // 列数n
vector<vector<int>> dp(m, vector<int>(n, 0));
dp[0][0] = 1 - obstacleGrid[0][0]; // 起点初始化
for(int i = 1; i < n; ++i){ // 第一行初始化
dp[0][i] = dp[0][i-1] * (1 - obstacleGrid[0][i]) ;
}
for(int i = 1; i < m; ++i){ // 第一列初始化
dp[i][0] = dp[i-1][0] * (1 - obstacleGrid[i][0]);
}
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]; // 上边 + 左边 路径数量之和
}
}
}
return dp[m-1][n-1]; // 返回终点,下标从0开始
}
};
- 空间复杂度优化:O(n)
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(); // 行数m
int n = obstacleGrid[0].size(); // 列数n
vector<int> dp(n, 0);
dp[0] = 1 - obstacleGrid[0][0]; // 起点初始化
for(int i = 1; i < n; ++i){ // 第一行初始化
dp[i] = dp[i-1] * (1 - obstacleGrid[0][i]) ;
}
for(int i = 1; i < m; ++i){ // 递推,滚动数组
for(int j = 0; j < n; ++j){
if(obstacleGrid[i][j] == 1){
dp[j] = 0; // 障碍物处为0
}else if(j > 0){
dp[j] = dp[j-1] + dp[j]; // 上边 + 左边 路径数量之和
}
}
}
return dp[n-1]; // 返回终点,下标从0开始
}
};
343. 整数拆分 ●●
给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
- 非完全动态规划
- dp[ i ] :数字 i 的最大拆分乘积;
dp[i] = max(dp[i], dp[j] * dp[i-j])
拆分为两个数,然后再取这两个数的最大乘积,遍历所有两数拆分的组合,取最大值;- 对2、3单独操作,因为2、3的拆分乘积比原数更小,后续递推过程中直接用dp[2] = 2, dp[3] = 3代替;
- 从小到大遍历。
class Solution {
public:
int integerBreak(int n) {
if(n == 2) return 1;
if(n == 3) return 2;
vector<int> dp(n+1, 0); // 初始化
dp[2] = 2;
dp[3] = 3;
for(int i = 4; i <= n; ++i){ // 递推
for(int j = 1; j <= i / 2; ++j){
dp[i] = max(dp[i], dp[j] * dp[i-j]);
}
}
return dp[n];
}
};
- 动态规划
- dp[ i ] :数字 i 的最大拆分乘积;
dp[i] = max(dp[i], max(j * dp[i-j], j * (i-j)));
从 j = 1到 i-1 开始遍历,将 i 拆分成 j 和 i−j 的和,若 i−j 不再拆分成多个正整数,此时的乘积是 j×(i−j);若 i−j 继续拆分成多个正整数,此时的乘积是 j×dp[i−j],同时与当前的最大乘积比较,取最值。(dp[j] 将在j = i - j 的时候计算)- dp[1] = 0;
- 从小到大遍历。
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution {
public:
int integerBreak(int n) {
vector<int> dp(n+1, 0);
dp[1] = 0; // 初始化
for(int i = 2; i <= n; ++i){ // 递推
for(int j = 1; j <= i-1; ++j){
dp[i] = max(dp[i], max(j * dp[i-j], j * (i-j)));
}
}
return dp[n];
}
};
96. 不同的二叉搜索树 ●●
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
输入:n = 3
输出:5
- dp[i] 为 i 个节点的种数;
dp[i] += dp[j] * dp[i-1-j];
在根节点的大小i
从1到n改变时,根节点将该数左右两边的数组分成两棵左右子树,每次改变共有dp[j] * dp[i-1-j]
种组合。- dp[0] = 1;
- 从小到大遍历。
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n+1, 0);
dp[0] = 1;
for(int i = 2; i <= n; ++i){
for(int j = 0; j <= i-1; ++j){
dp[i] += dp[j] * dp[i-1-j];
}
}
return dp[n];
}
};
背包问题
(1) 01背包
有n件物品和一个最多能背重量为 w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
weight = {1, 3, 4};
value = {15, 20, 30};
w = 4;
dp[i][j]
表示从 0 - i 物品中放到容量为 j 的背包中的最大价值;dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
在遍历物品到 i 物品时,有两种选择(方向):
(1) 不取物品 i : dp[i-1][j]
(2) 取物品 i :dp[ i - 1 ][ j - weight[i]] + value[i],并与(1)取最值- dp[i][0] = 0;
dp[0][j] = value[0];下标 j 从 weight[0] 开始 - 先遍历 物品 还是先遍历 背包重量,都可以,循环内的代码也一致;因为虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,不影响dp[i][j]公式的推导。
#include <iostream>
#include <vector>
using namespace std;
int getThing_two(vector<int>& weight, vector<int>& value, int w) {
int n = value.size();
vector<vector<int>> dp(n, vector<int>(w + 1, 0)); // dp[i][j] 表示从0-i物品中放到容量为j的背包中的最大价值
for(int j = weight[0]; j <= w; ++j) dp[0][j] = value[0]; // 第一行dp[0][j]初始化,即只有一个物品时,下标从weight[0]开始
for(int i = 1; i < n; ++i){
for(int j = 1; j <= w; ++j){
if( j >= weight[i]){ // 能取当前第i个物品,取最大值
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); // dp[i-1][j-weight[i]] + value[i] 为取第i件物品的价值
}else{
dp[i][j] = dp[i-1][j]; // 不能取第i个物品,继承上一个物品的最大值
}
}
}
for(int i = 0; i <= n; ++i){ // 打印输出价值表
for(int j = 0; j <= w; ++j){
cout << dp[i][j] << " ";
}
cout << endl;
}
return dp[n][w]; // 返回w容量的最大价值
}
int main(){
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int w = 4;
getThing_two(weight, value, w);
return 0;
}
- 一维滚动数组
- dp[j] 表示容量为 j 的背包中的最大价值
dp[j] = max(dp[j], dp[j-weight[i]] + value[i]);
- dp[0] = 0;
- 先物品,再背包,背包从大到小遍历。
倒序遍历背包是为了保证物品 i 只被放入一次!
dp[j-weight[i]] + value[i]
正序遍历时前面的值被更新,会导致重复取物品。
#include <iostream>
#include <vector>
using namespace std;
// 一维数组dp[i]
int getThing_one(vector<int>& weight, vector<int>& value, int w) {
int n = value.size();
vector<int> dp(w + 1, 0); // dp[j] 表示容量为j的背包中的最大价值
for(int i = 0; i < n; ++i){ // 从下标0开始,先物品
for(int j = w; j >= weight[i]; --j){ // 再背包
// 能取当前第i个物品,取最大值
dp[j] = max(dp[j], dp[j-weight[i]] + value[i]);
// dp[j-weight[i]] + value[i] 为取第i件物品的价值
// 不能取第i个物品,跳过,继承上一个物品的最大值
cout << dp[j] << " ";
}
cout << endl;
}
return dp[w]; // 返回w容量的最大价值
}
int main(){
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int w = 4;
getThing_one(weight, value, w);
return 0;
}
416. 分割等和子集 ●●
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
01背包问题转换:
(1)背包的体积为sum / 2
(2)背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
(3)背包如果正好装满,说明找到了总和为 sum / 2 的子集。
(4)背包中每一个元素是不可重复放入。
- dp[j] 表示: 容量为 j 的背包,所背的物品价值可以最大为dp[j]。
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
取或不取物品 i,取最值;- dp[0] = 0;
- 滚动数组,可选数值从前往后,背包容积从后往前遍历。
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int num : nums) sum += num;
if(sum % 2 != 0) return false; // 奇数,无法平分
int target = sum / 2; // 背包最大目标值容量
vector<int> dp(target+1, 0); // dp[j] 为容量为j时的最大和
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; // 判断最终取得目标值,返回true
return false;
}
};
1049. 最后一块石头的重量 II ●●
有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。
输入:stones = [2,7,4,1,8,1]
输出:1
本题中不必纠结于石头的组合,而是需要把所有石头分为两堆,使它们之间的总重量差值达到最小,便是最后的答案。
因此,转化为01背包就是,容积为总重量一半target的背包,在所有石头中能取得的最大值组合为dp[target] <= target,另一堆石头重量为sum - dp[target] >= target,两堆石头互撞,得到的最小差值则为sum - 2 * dp[target]
。
- 时间复杂度:O(m × n) , m是石头总重量(准确的说是总重量的一半),n为石头块数
- 空间复杂度:O(m)
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for(int num : stones) sum += num;
int target = sum / 2; // 总重量一半
vector<int> dp(target+1, 0); // 容积为总重量一半target的背包
for(int i = 0; i < stones.size(); ++i){
for(int j = target; j >= stones[i]; --j){
dp[j] = max(dp[j], dp[j-stones[i]] + stones[i]); // 滚动数组
}
}
return sum - 2 * dp[target]; // 两堆石头的最小差值
}
};
494. 目标和 ●● (组合)
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式 “+2-1” 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
元素前添加 + 或 -,则一定有 left组合 - right组合 = target;
则 left - (sum - left) = target;
left = (sum + target) / 2;
其中sum 和 target 已知且固定,因此存在唯一的和为 left 的组合。
此时问题就、转化为在集合nums中找出和为 left 的组合数量,且不可重复取值。
1. 回溯
在集合nums中找出和为 left 的组合数量,且不可重复取值,与回溯算法39. 组合总和 ●●类似。
超出时间限制
class Solution {
public:
void backtrack(int start, vector<int>& nums, int diff, int& ans){
if(diff <= 0){
if(diff == 0) ++ans;
return;
}
for(int i = start; i < nums.size(); ++i){
diff -= nums[i];
backtrack(i + 1, nums, diff, ans); // 不可重复取值
diff += nums[i]; // 回溯
}
}
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int num : nums) sum += num;
if(target > sum) return 0;
if((sum + target) % 2) return 0;
int left = (sum + target) / 2; // 目标组合和
int ans = 0;
sort(nums.begin(), nums.end());
backtrack(0, nums, left, ans); // 回溯
return ans;
}
};
2. DP
01背包 组合问题:装满容量为 left 的背包,有几种方法。
- dp[j] 表示:填满 j 容积的包,有dp[j]种方法;
dp[j] += dp[j - nums[i]];
遍历到nums[i]时,凑成dp[j]就有dp[j - nums[i]] 种方法,最终的组合数量为所有dp[j - nums[i]]累加;dp[j] = 0;
dp[0] = 1;
(装满容量为0的背包,有1种方法,就是装0件物品。)
从递归公式也可以看出,dp[j] (j>0)要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。- 滚动数组,nums放在外循环,target在内循环,且内循环倒序。
输入:nums: [1, 1, 1, 1, 1], target = 3
left = (target + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
- 时间复杂度:O(n × m),n为正数个数,m为背包容量
- 空间复杂度:O(m),m为背包容量
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int num : nums) sum += num;
if(abs(target) > sum) return 0;
if((sum + target) % 2) return 0;
int left = (sum + target) / 2; // 目标组合和
vector<int> dp(left+1, 0);
dp[0] = 1;
for(int i = 0; i < nums.size(); ++i){
for(int j = left; j >= nums[i]; --j){ // 倒序遍历
dp[j] += dp[j - nums[i]]; // 组合数量
}
}
return dp[left];
}
};
474. 一和零 ●●
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
输入:strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {“10”,“0001”,“1”,“0”} ,因此答案是 4 。
01背包问题转化:
本题中 strs 数组里的元素就是待装物品,每个物品最多只取一次!
而 m 和 n 相当于是一个背包的两个维度。
- dp[ i ][ j ] 表示 0 和 1 容量分别为 i ,j 的背包装的子集大小,二维滚动数组;
dp[i][j] = max(dp[i][j], dp[i-numZero][j-numOne] + 1);
numZero、numOne为 strs[k] 中 0 和 1 的数量;- dp[i][j] = 0;
- 字符串数组外循环,i、j 内层倒序循环(哪个维度在前都可以)
- 以输入:[“10”,“0001”,“111001”,“1”,“0”],m = 3,n = 3为例,最后dp数组的状态如下所示:
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for(int k = 0; k < strs.size(); ++k){
int numZero = 0; // 该字符串中 0 的数量
int numOne = 0; // 该字符串中 1 的数量
for(char ch : strs[k]){
if(ch == '0') ++numZero;
else ++numOne;
}
for(int i = m; i >= numZero; --i){
for(int j = n; j >= numOne; --j){
// 递推,
dp[i][j] = max(dp[i][j], dp[i-numZero][j-numOne] + 1);
}
}
}
return dp[m][n];
}
};
(2) 完全背包
有n件物品和一个最多能背重量为 w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
weight = {1, 3, 4};
value = {15, 20, 30};
w = 4;
01背包和完全背包唯一不同就是体现在遍历顺序上,完全背包的物品是可以添加多次的,所以要从小到大去遍历(一维滚动数组)!
01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!
因为 dp[j] 是根据 下标 j 之前所对应的 dp[j] 计算出来的。 只要保证下标j之前的 dp[j] 都是经过计算的就可以了。
#include <iostream>
#include <vector>
using namespace std;
// 二维数组dp[i][j],与01背包的初始化不相同
int getThing_two(vector<int>& weight, vector<int>& value, int w) {
int n = value.size();
vector<vector<int>> dp(n, vector<int>(w + 1, 0)); // dp[i][j] 表示从0-i物品中放到容量为j的背包中的最大价值
for(int j = weight[0]; j <= w; ++j) dp[0][j] = max(dp[0][j-1], dp[0][j-weight[0]] + value[0]); // 取第一件物品
for(int i = 1; i < n; ++i){
for(int j = 1; j <= w; ++j){
if(j >= weight[i]){
dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]] + value[i]);
}else{
dp[i][j] = dp[i-1][j]; //max(dp[i-1][j], dp[i][j-1]);
}
}
}
for(int i = 0; i <= n; ++i){ // 打印输出价值表
for(int j = 0; j <= w; ++j){
cout << dp[i][j] << " ";
}
cout << endl;
}
return dp[n][w]; // 返回w容量的最大价值
}
// 一维数组dp[j]
int getThing_one(vector<int>& weight, vector<int>& value, int w) {
vector<int> dp(w + 1, 0);
int n = weight.size();
for(int i = 0; i < n; ++i){
cout << endl;
for(int j = weight[i]; j <= w; ++j){ // 可重复取,从小到大遍历
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
cout << dp[j] << " ";
}
}
return dp[w];
}
int main(){
vector<int> weight = {1, 2, 4};
vector<int> value = {15, 35, 40};
int w = 4;
// getThing_two(weight, value, w);
cout << endl;
getThing_one(weight, value, w);
return 0;
}
518. 零钱兑换 II ●● (组合)
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
–
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
凑成总金额的个数,组合问题,不强调元素的顺序。
二维数组
- dp[ i ][ j ]为0~i个数中,和凑成 j 的组合数量;
dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]];
dp[ i ][ 0 ] = 1;
dp[0][j] = 0 (或 1);
- 外层遍历硬币金额 coins[i],内层遍历容量 j 。
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<vector<int>> dp(n, vector<int>(amount+1, 1)); // 初始化置1,dp[0][j] = 1;
for(int j = 1; j <= amount; ++j) if(j % coins[0] != 0) dp[0][j] = 0; // 第一行初始化
for(int i = 1; i < n; ++i){
for(int j = 1; j <= amount; ++j){
if(j >= coins[i]){
dp[i][j] = dp[i-1][j] + dp[i][j-coins[i]]; // 组合问题
}
else{
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n-1][amount];
}
};
一维滚动数组
- dp[ j ]:凑成总金额 j 的货币组合数;
dp[j] = dp[j] + dp[j-coins[i]];
(遍历到 coins[ i ]时)- dp[ 0 ] = 1;
- 外层遍历硬币金额 coins[i],内层遍历容量 j (可重复取值,正序遍历),组合问题无顺序关系,6 = 1 + 5;
两个 for 循环先后顺序不能变,如果先遍历容量 j,在内层遍历硬币金额,则会变成求排列问题,元素之间将存在顺序关系,6 = 1 + 5,6 = 5 + 1。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
int n = coins.size();
for(int i = 0; i < n; ++i){
for(int j = coins[i]; j <= amount; ++j){ // 可重复取,从小到大遍历
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
377. 组合总和 Ⅳ ●● (排列)
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
请注意,顺序不同的序列被视作不同的组合。
–
输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:(1, 1, 1, 1)、(1, 1, 2)、(1, 2, 1)、(1, 3)、(2, 1, 1)、(2, 2)、(3, 1)
1. DP
求排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。如果本题要把排列都列出来的话,只能使用回溯算法爆搜。
- dp[ j ]:凑成目标数 j 的组合数;
dp[j] = dp[j] + dp[j-nums[i]];
遍历到 nums[ i ]时,对于元素之和等于 j−nums[ i ] 的每一种排列,在最后添加 nums[ i ] 之后即可得到一个元素之和等于 j 的排列,因此在计算 dp[j] 时,应该计算所有的 dp[j − nums[ i ]] 之和。- dp[ 0 ] = 1;
- 先遍历目标数 j,再内层正序遍历数组元素,元素之间将存在顺序关系,6 = 1 + 5,6 = 5 + 1。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
C++测试用例有两个数相加超过int的数据,所以需要在 if 里加上dp[j] < INT_MAX - dp[j - nums[i]]
。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target+1, 0);
dp[0] = 1;
for(int j = 0; j <= target; ++j){
for(int i = 0; i < nums.size(); ++i){
if(j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]]){
dp[j] += dp[j-nums[i]]; // 遍历到nums[i]时
}
}
}
return dp[target];
}
};
如果给定的数组中含有负数,则会导致出现无限长度的排列。
例如,假设数组 nums 中含有正整数 a 和负整数 −b(其中 a>0,b>0,-b<0),则有 a×b+(−b)×a=0,对于任意一个元素之和等于 target 的排列,在该排列的后面添加 b 个 a 和 a 个 −b 之后,得到的新排列的元素之和仍然等于 target,而且还可以在新排列的后面继续 b 个 a 和 a 个 −b。因此只要存在元素之和等于 target 的排列,就能构造出无限长度的排列。
如果允许负数出现,则必须限制排列的最大长度,避免出现无限长度的排列,才能计算排列数。
2. 回溯(记忆化搜索)
如果本题要把排列都列出来的话,只能使用回溯算法爆搜。
用memo数组记录相应的排列数。
#include <iostream>
#include <vector>
using namespace std;
int res = 0;
vector<int> memo;
void backtrack(vector<int>& nums, int target) {
if (target == 0) {
++res;
return;
}
for(int i :nums) {
int diff = target - i;
if(diff >= 0){
if(memo[diff] == -1){ // 已存在和为diff的排列数
int pre_res = res;
res = 0;
cout << "开始计算排列数: " << diff << endl;
backtrack(nums, diff);
memo[diff] = res; // 和为diff的排列数
cout << "完成计算" << diff << "的排列数: " << res << endl;
res += pre_res;
}
else{
cout << "直接调用 " << diff << endl;
res += memo[diff];
}
}
}
}
int combinationSum4(vector<int>& nums, int target) {
memo = vector<int> (target+1, -1);
backtrack(nums, target);
for(int i : memo) cout << i << endl;
return res;
}
int main(){
vector<int> nums = {1, 2, 3};
int target = 4;
combinationSum4(nums, target);
return 0;
}
开始计算排列数: 3
开始计算排列数: 2
开始计算排列数: 1
开始计算排列数: 0
完成计算0的排列数: 1
完成计算1的排列数: 1
直接调用 0
完成计算2的排列数: 2
直接调用 1
直接调用 0
完成计算3的排列数: 4
直接调用 2
直接调用 1
1
1
2
4
-1
322. 零钱兑换 ●●
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
1. DP
- dp[j]:凑足总额为 j 所需钱币的最少个数为dp[j];
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
(遍历到 coins[i] 时,若dp[j - coins[i]]存在,则考虑取或不取coins[i],取则dp[j - coins[i]] + 1,不取则dp[j])- dp[ 0 ] = 0; dp[ j ] = INT_MAX;
- 本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。所以本题并不强调集合是组合还是排列。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, INT_MAX);
dp[0] = 0;
for(int i = 0; i < coins.size(); ++i){
for(int j = coins[i]; j <= amount; ++j){
// 当dp[j- coins[i]]不存在时,直接跳过;
if(dp[j- coins[i]] < INT_MAX) dp[j] = min(dp[j], dp[j - coins[i]] + 1); // 取 或 不取 coins[i]
}
}
return dp[amount] == INT_MAX? -1 : dp[amount];
}
};
2.记忆化搜索
为了避免重复的计算,我们将每个子问题的答案存在一个数组中进行记忆化,如果下次还要计算这个问题的值直接从数组中取出返回即可,这样能保证每个子问题最多只被计算一次。
- 时间复杂度:O(Sn),其中 S 是金额,n 是面额数。我们一共需要计算 S 个状态的答案,且每个状态 F(S) 由于上面的记忆化的措施只计算了一次,而计算一个状态的答案需要枚举 n 个面额值,所以一共需要 O(Sn) 的时间复杂度。
- 空间复杂度:O(S),我们需要额外开一个长为 S 的数组来存储计算出来的答案 F(S) 。
class Solution {
public:
int backtrack(vector<int>& coins, int amount, vector<int> &dp) {
if(amount == 0) return 0;
if(amount < 0) return -1;
if(dp[amount]) return dp[amount]; // 存在则直接返回
int Min = INT_MAX; // 计算dp[amount]
for(int num : coins){
int count = backtrack(coins, amount - num, dp); // 隐式回溯
if(count >= 0 && count < Min){
Min = count + 1; // 取num,加上dp[amount - num]
}
}
dp[amount] = Min == INT_MAX ? -1 : Min;
return dp[amount]; // 和为amount的最少硬币个数
}
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, 0); // 记忆化搜索数组
return backtrack(coins, amount, dp);
}
};
279. 完全平方数 ●●
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
1. DP
- dp[j] 表示和为 j 的完全平方数的最少数量;
- dp[j] = min(dp[j], dp[j-i*i] + 1); 当
dp[j-i*i]
存在时,取或不取 i 2 i^2 i2; - dp[ 0 ] = 0; dp[ j ] = INT_MAX;
- 本题并不强调集合是组合还是排列,两种循环顺序都可以。
- 时间复杂度: O ( n n ) O(n\sqrt{n}) O(nn),其中 n 为给定的正整数。状态转移方程的时间复杂度为 O ( n ) O(\sqrt{n}) O(n),共需要计算 n 个状态,因此总时间复杂度为 O ( n n ) O(n \sqrt{n}) O(nn)。
- 空间复杂度:O(n)。需要 O(n) 的空间保存状态。
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1, INT_MAX);
dp[0] = 0;
for(int j = 1; j <= n; ++j){ // 遍历背包
for(int i = 1; i * i <= j; ++i){ // 遍历物品
dp[j] = min(dp[j], dp[j-i*i] + 1);
}
}
return dp[n];
}
};
2. 记忆化搜索
class Solution {
public:
int backtrack(int n, vector<int>& dp){
if(n < 0) return -1; // 差值小于0,返回-1
if(n == 0) return 0; // 差值为0,+1
if(dp[n] < INT_MAX) return dp[n]; // 已搜索过,直接返回
int Min = INT_MAX;
for(int i = 1; i*i <= n; ++i){ // 开始计算dp[n]
int count = backtrack(n-i*i, dp); // 隐式回溯
if(count >= 0 && count < Min){
Min = count + 1; // 取i*i, count = dp[n-i*i]
}
}
dp[n] = Min;
return dp[n];
}
int numSquares(int n) {
vector<int> dp(n+1, INT_MAX);
return backtrack(n, dp);
}
};
139. 单词拆分 ●●
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
–
输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以由 “apple” “pen” “apple” 拼接成。
1. 动态规划
- dp[ j ]表示长度为 j 的字符串拆分结果;
dp[j] = dp[j-wordLen[i]] && (s.substr(j-wordLen[i], wordLen[i]) == wordDict[i]);
dp[j-wordLen[i]]为true的情况下,再判断单词i的匹配情况才有意义- dp[ j ] 初始化为false,dp[ 0 ] = true;
- 先遍历字符串长度(背包),再内层遍历字典(物品)
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int len = s.length();
int num = wordDict.size();
vector<bool> dp(len+1, false); // dp[j]表示长度为 j 的字符串能否被拆分
dp[0] = true; // dp[0] 初始化为 true
vector<int> wordLen(num, 0);
int minLen = INT_MAX; // 字典中的最小单词长度
for(int i = 0; i < num; ++i){
wordLen[i] = wordDict[i].length(); // 第i个单词的长度
minLen = min(minLen, wordLen[i]);
}
for(int j = minLen; j <= len; ++j){ // 从最小长度开始
for(int i = 0; i < num; ++i){
if(dp[j]) break; // dp[j]已为true
if(j < wordLen[i]) continue; // 长度小于单词i
// dp[j-wordLen[i]]为true的情况下,再判断单词i的匹配情况才有意义
dp[j] = dp[j-wordLen[i]] && (s.substr(j-wordLen[i], wordLen[i]) == wordDict[i]);
}
}
return dp[len];
}
};
2. 记忆化搜索
(3) 多重背包
扩充 01 背包
有 N 种物品和一个容量为 V 的背包。第 i 种物品最多有 Mi 件可用,每件耗费的空间是 Ci ,价值是 Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
具体有两种实现方式:
时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
- 先扩充物品数组,再遍历:
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
for (int i = 0; i < nums.size(); i++) {
while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
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]);
}
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
- 把每种商品个数放在01背包里面再遍历一遍
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
// 以上为01背包,然后加一个遍历个数
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
// 打印一下dp数组
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
背包问题总结
递推公式
(1)能否装满背包(最多装多少):
------ 416.分割等和子集
------ 1049.最后一块石头的重量 II
------ 474.一和零
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
(2)装满背包有几种方法:
------ 494.目标和
------ 518.零钱兑换 II
------ 377.组合总和Ⅳ
------ 70.爬楼梯(完全背包)
dp[j] += dp[j - nums[i]]
(3)装满背包所需物品的最小数量:
------ 322.零钱兑换
------ 279.完全平方数
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
遍历顺序
(1)01背包
- 二维dp数组 dp[ i ][ j ]
先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。 - 一维滚动数组dp[ j ]
只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。
(2)完全背包
- 二维dp数组 dp[ i ][ j ]
初始化与01背包有所区别,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。 - 一维滚动数组dp[ j ]
先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
但是,对于
- 组合问题:外层for循环遍历物品,内层for遍历背包;
------ 518.零钱兑换II - 排列问题:外层for遍历背包,内层for循环遍历物品。
------ 377.组合总和Ⅳ
------ 70.爬楼梯(完全背包) - 求最小数:两层for循环的先后顺序都可以。
------ 322. 零钱兑换
------ 279.完全平方数