一、动态规划介绍
动态规划(英语:Dynamic programming,简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
简单说就是动态规划是把一个问题逐步划分为子问题,直到子问题能够被求解,在求解过程中往往有重复的计算,利用备忘录把答案记录下来,从而减少求解原问题的时间和空间。
二、核心思想、特征:
动态规划最核心的思想就是划分子问题,如何把一个问题逐步分解开来是尤为重要,该子问题需要有无后效性,这是最重要的一点。
1、多阶段最优子结构:当我们选择的结果是最优的,那么由它分解出来的子问题或子结构也应该是最优的。
2、重复子问题:动态规划问题往往有很多重复的子问题,我们可以记录这些子问题的解,加快解题速度
4、无后效性:指未来不再影响过去,在我们分解子问题时,分解出的子问题的逻辑不再受我们原问题的影响,对于一个dp数组,dp[n-1]的值不受dp[n]的影响,这就是无后效性
解题思路:
1、暴力求解(想象一下如何枚举)
2、记忆化搜索(考虑如何记录一些解)
3、考虑如何用dp解决问题(状态如何表示使得问题多阶段子结构最优,状态的边界如何定义,如何定义状态转移方程去描述状态的转移)
4、优化内存(滚动数组、二进制优化)
三、背包问题(九类)
(一)0-1背包问题
题目:
有N件物品(每种仅有一个)和一w的背包。第i件物品的重量费用是c[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
先剖析问题结构,我们要如何设计问题使得满足dp的特征(最优子结构、无后效性),在考虑问题的时候我们大多数都会从结果出发,逆推答案,对于一个物品,我们会考虑如果我拿了它能得到多大价值,我不拿它能得到多大价值,我们会选择其中价值更大的一个,然后再考虑该问题的子问题,考虑下一个物品(这是我们平常的思维),那么如何用语言来表达呢?
价值受什么影响?首先是物品的数量,我们要逐个考虑每个物品是否存在于背包中的价值,其次是背包能承受的重量,如果我们的背包很大,那我们可以全都要,如果背包很小,可能某些物品都装不进去,所以我们使用一个二维数组 dp[i][j] 来表示前i件物品恰放入一个容量为j的背包可以获得的最大价值。
这个方程非常重要,基本上所有跟背包相关的问题的都由它衍生来。所以有必要将它详细解释一下:“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”;如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为v-c[i]的背包中”,此时能获得的最大价值就是f [i-1][w-c[i]]再加上通过放入第i件物品获得的价值w[i]。
那么得到状态转移方程:
for(int i=0;i<n;i++)
for(int j=1;j<=w;j++)
dp[i][j]=max(dp[i-1][j],dp[i−1][j−c[i]]+v[i])
边界问题:
dp[i][0]=0 背包承重为0,那能得到的价值也为0
dp[0][j]=0 没有物品价值也同样为0
优化空间复杂度:
我们可以看到第i、j格的值是只和第i-i行影响,如下图:
也就是说我们只用记录前一行的数据即可推出下一行,我们可以用滚动数组来实现空间的优化,那么我们是否要像上面一样从前往后遍历呢,我们假设有一个一维数组dp[n],从1开始逐步更新数组,这是dp[1]就变成了第i行的数组,此时新数据覆盖了后数据,使得后续的数组无法利用已知数据,但从后往前遍历就可以避开问题,结合代码理解:
for(int i=0;i<n;i++)
for(int j=w;j>=1;j--)
dp[j]=max(dp[j],dp[j−c[i]]+v[i])
(二)完全背包问题(二进制优化)
相比较0-1背包,完全背包问题去掉了可取物品数量的限制:
有N种物品和一W的背包,每种物品都有无限件可用。第i种物品的费用是c[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
当作0-1背包来做:
有了0-1背包的思路,我们知道对于一件物品我们有取和不取两种方法,那么对于完全背包问题,同一个物品我们可以拆分成多个物品,一件物品最多取w/c[i]次,我们可以把它当成存在w/c[i]个该物品,对每一个物品求取和不取的最大价值,也是类似0-1背包的。当然我们也可以进行一个性价比的优化,对于两件物品i、j满足c[i]<=c[j]且v[i]>=v[j],去掉j物品,这能减少物品的件数,但治标不治本,有可能特别设计的数据可以一件物品也去不掉。
这是一个简单的方法,但也有缺陷,当物品的数量、背包的容量较大时,这样的方法会很耗时,效率并不高,我们需要对0-1背包进行一个提升,跳出0-1背包。
二进制优化:
在二进制中,我们可以用、、、……、 表示以内的任何数,在完全背包问题中我们可以把他们拆成0-1背包是指把他们拆成一个一个的物品以表示我们能购买的物品数,同样我们可以利用二进制的特点来优化,把物品分成、、、……、 件来表示能够买的物品数,也就是说对于一个件数价值为v的物品,我们把它分成 价值为 v 的物品、 价值为v的物品…… 当我们需要购买n个物品时(如7个)就可以转化为买对应二进制拆分的物品各一个(7的二进制=111,即购买0-1背包版本的价值为 v 的物品、价值为v的物品、价值为v的物品)这样既把复杂的问题转化为简单问题,又不至于爆空间和爆时间。
/*
多重背包问题:二进制优化
本文中某些变量和文章不太一致,结合注释来看
*/
# include<iostream>
using namespace std;
const int MAXN = 10010; //定义最大物品数量
const int MAXV = 10010; //定义最大背包容量
int N; //物品数量
int V; //背包容量
int w[MAXN];//储存每件物品的重量w[i]
int c[MAXN];//储存每件物品的价值c[i]
int s[MAXN];//储存每件物品的数量s[i]
int dp[MAXV]; //滚动dp数组
//用滚动dp数组求解0-1背包问题
void knapsack() {
for (int i = 0; i <= V; i++)//边界处理
dp[i] = 0;
for (int i = 1; i <= N; i++) {//状态更新
//倒序枚举v (V-0)
for (int v = V; v >= w[i]; v--)
dp[v] = max(dp[v - w[i]] + c[i], dp[v]);
}
}
int main(){
int tempw[MAXN], tempc[MAXN];//储存实际物品重量和价值
int k = 0;//把背包二进制划分后得到的物品的编码
cin >> N >> V;//物品个数 最大背包容量W
//读取数据
for (int i = 1; i <= N; i++)
cin >> tempw[i];
for (int i = 1; i <= N; i++)
cin >> tempc[i];
for (int i = 1; i <= N; i++) {//利用二进制优化的方法拓展行
cin >> s[i];
for (int j = 1; j <= s[i]; j <<= 1) {
k++;
w[k] = tempw[i] * j;
c[k] = tempc[i] * j;
s[i] -= j;
}
if (s[i] != 0) {
k++;
w[k] = tempw[i] * s[i];
c[k] = tempc[i] * s[i];
}
}
N = k;
knapsack();
cout << dp[V] << endl;
}
//输入数据
/*
4 10
3 4 2 5
2 3 2 3
2 2 1 4
*/
//输出结果:8
(三)多重背包问题
完全背包的特殊版,完全背包不限制物品的数量,多重背包限制物品数量为某一个数:
有N种物品和一个容量为V的背包。第i种物品最多有n[i]件可用,每件费用是c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
直接转化为0-1背包问题和利用二进制优化都可以求解,不再赘述
分享|股票问题系列通解(转载翻译) - 力扣(LeetCode)https://leetcode.cn/circle/discuss/qiAgHn/该帖是力扣第一的大佬转的关于股票问题的解析,本质就是动态规划的背包问题,其中121和122就是前文的两类问题,123就是本题相关内容,作者还提供了另一种解法,其他内容也很实在有用,可以跟练。
. - 力扣(LeetCode). - 备战技术面试?力扣提供海量技术面试资源,帮助你高效提升编程技能,轻松拿下世界 IT 名企 Dream Offer。https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iii/
(四)混合背包问题
顾名思义就是前三种背包问题的结合
(1)0-1背包和完全背包混合
这个可以考虑把0-1背包和完全背包当成一个判断来做:
伪代码如下:
for i=1..N
if 第i件物品是01背包
for v=W..0
f[v]=max{f[v],f[v-c[i]]+v[i]};
else if 第i件物品是完全背包
for v=0..W
f[v]=max{f[v],f[v-c[i]]+v[i]};
(2)再加上多重背包:
如果再加上有的物品最多可以取有限次,那么原则上也可以给出O(VN)的解法:遇到多重背包类型的物品用单调队列解即可。但如果不考虑超过NOIP范围的算法的话,用P03中将每个这类物品分成O(log n[i])个01背包的物品的方法也已经很优了。
for (int i = 1; i <= n; i++) {
cin >> c >> v >> p;
if (p == 0) //完全背包
for (int j = c; j <= W; j++)
f[j] = max(f[j], f[j - c] + v);
else if (p == -1) //01背包
for (int j = V; j >= c; j--)
f[j] = max(f[j], f[j - c] + v);
else { //多重背包二进制优化
int num = min(p, W / c);
for (int k = 1; num > 0; k <<= 1) {
if (k > num) k = num;
num -= k;
for (int j = V; j >= c * k; j--)
f[j] = max(f[j], f[j - c * k] + v * k);
}
}
}
(五)二维费用的背包问题
二维费用的背包问题是指:对于每件物品,具有两种不同的费用;选择这件物品必须同时付出这两种代价;对于每种代价都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。设这两种代价分别为代价1和代价2,第i件物品所需的两种代价分别为a[i]和b[i]。两种代价可付出的最大值(两种背包容量)分别为W1和W2。物品的价值为v[i]。
思想很简单,既然cost费用维度增加,那么只要我们的维度增加即可,设f [ i ] [ j ] [ k ] f[i][j][k]f[i][j][k]表示前i ii件物品付出两种代价分别最大为j jj和k kk时可获得的最大价值,那么状态转移方程:
这也是满足最优子结构的动态规划转移方程。
for (int i = 1; i <= n; i++)
for (int j = W1; j >= c[i]; j--)
for (int k = W2; k >= g[i]; k--)
f[j][k] = max(f[j][k], f[j - a[i]][k - b[i]] + v[i]);
有时,“二维费用”的条件是以这样一种隐含的方式给出的:最多只能取M件物品。这事实上相当于每件物品多了一种“件数”的费用,每个物品的件数费用均为1,可以付出的最大件数费用为M。换句话说,设f[v][m]表示付出费用v、最多选m件时可得到的最大价值,则根据物品的类型(01、完全、多重)用不同的方法循环更新,最后在f[0..V][0..M]范围内寻找答案。另外,如果要求“恰取M件物品”,则在f[0..V][M]范围内寻找答案。
(六)分组背包问题
有N件物品和一W的背包。第i件物品的费用是c[i],价值是v[i]。这些物品被划分为若干组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
这就从0-1问题跳出来变成了组数选择问题:是选择本组某一件,还是不选。也就是说设f[k][c]表示前k组物品花费费用c能取得的最大权值,则有f[k][v]=max{f[k-1][c],f[k-1][c-c[i]]+v[i]|物品i属于第k组}。
for (int i = 1; i <= n; i++) {
cin >> s; // 第i组的物品数量
for (int j = 1; j <= s; j++) cin >> c[j] >> w[j]; //组中每个物品的属性
for (int j = V; j >= 0; j--)
for (int k = 1; k <= s; k++)
if (j >= c[k])
f[j] = max(f[j], f[j - c[k]] + w[k]);
// 由于每组物品只能选一个,所以可以覆盖之前组内物品最优解的来取最大值
}