内容概要:
- 动态规划的状态及状态转移
- 0-1背包问题
- 背包问题的若干变种
状态及状态转移方程
上篇文章我们知道,可以用动态规划解决的问题往往都具有重叠子问题和最优子结构,我们每次解决其中的一个子问题,最终将子问题的答案并到一块,原问题也就得到解决。为了更加清晰地描述这种求解(决策)过程,在动态规划中定义状态及状态转移。所谓状态就是在算法里我们要保存的每一步的求解结果,而状态转移就是后续要求解的子问题与当前已有的1个或多个状态之间的关系,或称状态转移方程。比如在求数列的第
项时,每一项都是一个状态,每求一个项只需要之前的两个状态,状态转移方程:
下面来通过一个简单的例子看一看状态及状态转移方程的作用:
LeetCode198 House Robber问题链接
对于这个问题,定义状态:
则有:
这可以看做是系统初始状态,不难理解
表示没有可偷的房间,故为0;
表示只能偷最后一个房间,故为
,这样接着写下去:
不难写出对应的状态转移方程:
于是很轻松地给出动态规划代码:
class Solution198_1 {//动态规划
public:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 0)return 0;
vector<int> memo(n+1, -1);
memo[n] = 0; //抢劫[index,n)
memo[n - 1] = nums[n - 1];
for (int i = n - 2; i >= 0; i--) {
memo[i] = max(nums[i]+memo[i+2],memo[i+1]);
}
return memo[0];
}
};
上述代码时间复杂度,空间复杂度
(因为我们用memo将所有状态保存了下来。实际上如果看计算过程,会发现每个状态的求解只用到了前两个状态,故空间复杂度可以优化到
)。
当然状态的定义也可以不同,这样一来得到的代码逻辑也会有区别,比如也可以定义
此时,接着往下写不难得到状态转移方程:
对应的动态规划代码:
class Solution198_2 {//动态规划
public:
int rob(vector<int>& nums) {
int memo_pre = 0, memo_cur = 0;
for (int i = 0; i < nums.size(); i++) {
int temp = memo_cur;
memo_cur = max(nums[i] + memo_pre, memo_cur);
memo_pre = temp;
}
return memo_cur;
}
};
该代码的时间复杂是,空间复杂度是
(因为只保留了会参与计算的两种状态。)
不管用哪种状态定义,我们看到,当状态和状态转移清晰时,代码的书写也会非常的流畅,所以有必要掌握寻找状态和状态转移方程的能力。下面再来看一个动态规划中的经典问题。
0-1背包问题
0-1背包问题描述:给定编号从0到n-1的一组物品,每种物品都有自己的重量w和价值v,现在有一个承重有限的背包,承重量为C,问:我们如何选择装入背包的物品,才能使得物品的总价值最高。
定义状态:
于是得到状态转移方程:
按照这个逻辑,首先给出问题的递归(记忆化搜索)代码:
#include <iostream>
#include <vector>
#include <cassert>
#include <algorithm>
using namespace std;
class Knapsack01_2 {
// 记忆化搜索
// 时间复杂度: O(n * C) 其中n为物品个数; C为背包容积
// 空间复杂度: O(n * C)
public:
vector<vector<int>>memo;
//用[0,...,index] 的物品,填充容积为c的背包获得的最大价值
int bestValue(const vector<int>& w, const vector<int> v, int index, int c) {
if (index < 0 || c <= 0)return 0;//无法选物品或者没有容量
if (memo[index][c] != -1)return memo[index][c];
int res = bestValue(w, v, index - 1, c);//策略1,不选index号物品
if (c >= w[index])//背包还能容纳编号为index的物品
res = max(res, v[index] + bestValue(w, v, index - 1, c - w[index]));//策略2
memo[index][c] = res;
return res;
}
int solve(const vector<int>& w, const vector<int>& v, int C) {
int n = w.size() - 1;
memo = vector<vector<int>>(n, vector<int>(C + 1, -1));
return bestValue(w, v, n - 1, C);
}
};
int main() {//测试
int n, C;
cout << "请输入物品数量n和背包的承重量C:" << endl;
cin >> n >> C;
int v, w;
vector<int> vs, ws;
cout << "请输入各个物品的重量和价值:" << endl;
for (int i = 0; i < n; i++) {
cin >> w >> v;
vs.push_back(v);
ws.push_back(w);
}
cout << "最大价值为:" << Knapsack01_2().solve(ws, vs, C) << endl;
return 0;
}
设背包承重量为5,各个物品重量和价值如下表,测试上述代码。
index | 0 | 1 | 2 | 3 |
---|---|---|---|---|
weight | 1 | 2 | 3 | 4 |
value | 9 | 10 | 12 | 8 |
测试结果如下:
可以检验,结果是正确的。
下面再给出动态规划的代码:
class Knapsack01_1 {
// 动态规划O(n*C)O(n*C)
public:
int solve(const vector<int>& w, const vector<int>& v, int C) {
assert(w.size() == v.size());
int n = w.size();
if (n == 0)return 0;
vector<vector<int>>memo(n, vector<int>(C + 1, -1));
for (int j = 0; j <= C; j++)
memo[0][j] = (j >= w[0] ? v[0] : 0);//基础状态
for (int i = 1; i < n; i++)
for (int j = 0; j <= C; j++) {
memo[i][j] = memo[i - 1][j];
if (j >= w[i])
memo[i][j] = max(memo[i][j], v[i] + memo[i - 1][j - w[i]]);
}
return memo[n - 1][C];
}
};
其中的状态量memo[i][j]表示[0:i]号物品放入承重量位j的背包的最大价值。
读者可自行测试其正确性。
0-1背包问题的空间复杂度优化
观察状态转移方程我们发现在求解一个新状态时,实际上只用到了两个状态,这样我们可以将空间复杂度优化到。
class Knapsack01_3 {
public:
int solve3(const vector<int>& w, const vector<int>& v, int C) {
assert(w.size() == v.size());
int n = w.size();
if (n == 0)return 0;
vector<int>memo(C + 1, -1);
for (int j = 0; j <= C; j++)
memo[j] = (j >= w[0] ? v[0] : 0);//基础状态
for (int i = 1; i < n; i++)
for (int j = C; j >= w[i]; j--)
memo[j] = max(memo[j], v[i] + memo[j - w[i]]);
return memo[C];
}
};
0-1背包问题的变种
完全背包问题和多重背包问题
完全背包问题是每种物品数量无限的背包问题,实际上由于背包容量有限,每种物品还是有上限的,这类问题可以转化为0-1背包问题。
多重背包问题是每种物品数量有限,同样可以转化为0-1背包问题。
多维费用背包问题
这类背包问题对背包的限制会增多,如从体积和容量两个维度约束背包。
物品互相排斥或依赖的背包问题
排斥:放入某些物品后就不能放入另一些物品了;
依赖:放入某些物品后必须放入另一些问题。
几个可转化为0-1背包问题的例子
LeetCode416 链接
这个问题就是典型的0-1背包问题变种,相当于在个物品中选取一部分物品,填满容量为
的背包(C在本题中为数组和的一半),而且这个问题更加简单,因为不牵扯物品的价值,不过这里返回的是布尔值:
设表示使用[0:n-1]的n个物品物品填满容量为
的背包,于是对于
,有状态转移方程:
它表示当考虑[0,k]区间的物品能否填满容量为C的背包时,首先看[0:k-1]是否能填满,否则看当前物品加入后是否能填满,也就是[0:k-1]是否能填满总容量C减去第k个物品的重量。
由状态转移方程,不难给出动态规划代码:
class Solution {//O(n*sum)
//动态规划
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2 == 1)return false;
int n = nums.size();
int C = sum / 2;
vector<bool>memo(C + 1, false);
//看容量为i的情况下第一个物品是否能填满它
for (int i = 0; i <= C; i++)
memo[i] = (nums[0] == i);
for (int k = 1; k < n; k++)
for (int i = C; i >= nums[k]; i--)
memo[i] = memo[i] || memo[i - nums[k]];
return memo[C];
}
};
LeetCode322 链接
这个问题也是背包问题的一个简单变种,定义为组成金额
所需最少的硬币数量,则对应的状态转移方程应为:
其中 代表的是第
枚硬币的面值,上述状态转移方程的含义是,我们枚举的最后一枚硬币面额是
,则需要从
这个金额的状态
转移过来,再算上枚举的这枚硬币数量1的贡献,所以
为前面能转移过来的状态的最小值加上枚举的硬币数量1。动态规划代码:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//最坏全是1元硬币,不可能超过amount+1个;
vector<int> memo(amount + 1, amount + 1);
memo[0] = 0;//金额为0不能由硬币组成
for (int i = 1; i <= amount; ++i)
for (int j = 0; j < coins.size(); ++j)
if (coins[j] <= i)
memo[i] = min(memo[i], memo[i - coins[j]] + 1);
return memo[amount] > amount ? -1 : memo[amount];
}
};
网友评论