动态规划入门(持续更新)
随笔概要
本文通过01背包问题引入动态规划,来介绍各种背包与初等动态规划问题,持续更新中...
01背包问题
问题概述:有n个重量和价值分别为和的物品。从这些物品中挑选出总重量不超过的物品,求所有挑选方案中价值总和的最大值。(下标从1开始)
样例:
思路引入:我们最容易想到的方案是暴力搜索,即对所有物品是否放入背包进行搜索。代码如下:
int n, W;
int v[maxn], w[maxn];
//从第i个物品开始挑选总重量小于j的部分
int dfs(int i, int j) {
int ans = 0;
if (i > n) //已经没有物品了
ans = 0;
else if (j < w[i]) //物品过重,超出背包剩余容量,无法挑选
ans = dfs(i + 1, j);
else //挑选和不挑选都尝试一下
ans = max(dfs(i + 1, j), dfs(i + 1, j - w[i]) + v[i]);
return ans;
}
但这种方案搜索深度为n,最坏需要的时间复杂度,如何优化呢?
我们建立递归树可以看到,dfs以(3,2)为参数调用了两次。如果参数相同,则返回结果一定相同。我们可以很容易的想到用一个数组来记录已经被算出来的部分,从而避免相同参数计算多次的情况。这里我们用数据dp来记录已经被求解的部分。代码如下:
int n, W;
int v[maxn], w[maxn], dp[maxn][maxn];
//从第i个物品开始挑选总重量小于j的部分
int dfs(int i, int j) {
if (dp[i][j] >= 0)
return dp[i][j];
int ans = 0;
if (i > n)
ans = 0;
else if (j < w[i])
ans = dfs(i + 1, j);
else
ans = max(dfs(i + 1, j), dfs(i + 1, j - w[i]) + v[i]);
return dp[i][j] = ans;
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
memset(dp, -1, sizeof(dp));
cout << dfs(0, W) << endl;
return 0;
}
这种称为记忆化搜索,接下来我们可以利用记忆化搜索,来引入一定的递推式,利用递推式来求解问题的思路则为动态规划(DP:Dynamic Programming)。
动态规划解决方案:我们设表示为从前i个物品中挑选,放入最大容量为j的背包中所能得到的最大价值。对于每个物品,只有拿与不拿两种情况(即0与1),根据此思路可得如下递推式:
根据规律可得下表:
墙裂建议亲自动手推表!!!
代码如下:
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int maxn = 2e3 + 5;
int n, W;
int v[maxn], w[maxn], dp[maxn][maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> W;
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= W; j++)
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
cout << dp[n][W] << endl;
return 0;
}
滚动数组优化:我们可以从递推式中发现,dp[j]的所有数只和dp[j-1]有关,和dp[j-2],dp[j-3]等没有任何关系。我们可以想到:去掉数组第1维,对于第n次dp数组更新来说,在更新之前,dp[1..W]保存的是第n-1次更新中已经更新完的数据。可得:
化简得:
但是我们需要注意,j的遍历需要逆序进行!原因是:如果正序进行,第n次dp数组更新会覆盖掉第n-1次dp数组更新,例如遍历完dp[3]时,此时的dp[3]若被更新,则是选择了第n号物品,但是后续更新需要的是第n-1轮更新所得的dp数组,违背了第n轮更新只需要第n-1轮更新的原则。可得如下代码:
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int maxn = 2e3 + 5;
int n, W;
int v[maxn], w[maxn], dp[maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> W;
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = W; j >= 1; j--)
if(j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
cout << dp[W] << endl;
return 0;
}
例题:https://www.luogu.com.cn/problem/P1048
完全背包问题
问题概述:有n种重量和价值分别为和的物品,每种物品的数量是无限的。从这些物品中挑选出总重量不超过的物品,求所有挑选方案中价值总和的最大值。(下标从1开始)
完全背包与01背包的不同在于每个物品的数量,完全背包每种物品的数量是无限的,01背包每种物品的数量只有1个。
样例:
算法思路:我们可以将完全背包问题转化为01背包问题。由于每个物品的数量有无数个,即对于任意物品i,可以拿个 , , 递推式如下
可得表如下:
可写出代码:
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int maxn = 2e3 + 5;
int n, W;
int v[maxn], w[maxn], dp[maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> W;
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = W; j >= 1; j--)
for(int k = 0;k <= j / w[i];k++)
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
cout << dp[W] << endl;
return 0;
}
但是写出如上代码并不能说明已经掌握了完全背包。我们可以想到,三重循环的时间复杂度是很大的,那么如何继续优化我们的时间效率呢?让我们回归最原始的dp递推式,即表示为从前i个物品中挑选,放入最大容量为j的背包中所能得到的最大价值。但不同的是,完全背包可以拿多个同一物品,对于物品i,如果拿k个物品, 则可以看做是在中拿取个物品i。完全背包既可以从的状态转移,也可以从的状态转移,取两者中更大的值。
我们发现:01背包与完全背包的状态转移方程只差在了第二项的第一维,即完全背包第二项为, 01背包第二项为。下面我们根据滚动数组优化的理论将完全背包在空间上继续优化,我们发现完全背包的状态转移方程也为
竟然和01背包完全相同。那不同点究竟在哪里?我们来深入思考一下他们的区别,01背包在第n次更新中,数组记录的是第n-1次更新的结果,但是,完全背包在第n次更新中,数组记录的是第n-1次更新与第n次更新内容的混合。即01背包用到的是旧数据,完全背包用到的是已经刷新的新数据。故01背包必须逆序更新(防止上一次更新的数据被篡改),而完全背包需要顺序更新(不需要防止上一次更新的数据被篡改)。代码如下:
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int maxn = 2e3 + 5;
int n, W;
int v[maxn], w[maxn], dp[maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n >> W;
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = w[i]; j <= W; j++) // 顺序
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
cout << dp[W] << endl;
return 0;
}
多重背包
问题描述:有n种重量和价值分别为和的物品,每种物品的数量是。从这些物品中挑选出总重量不超过的物品,求所有挑选方案中价值总和的最大值。(下标从1开始)
算法思路:看到这里,相信大家对于次问题已经能独立想出解决发放了。我们只需把多重背包转化为01背包问题。可得代码如下:
for (int i = 1; i <= n; i++) {
for (int j = W; j >= 0; j--) {
for (int k = 0; k <= c[i] && j >= k * w[i]; k++)
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
}
}
printf("%d\n", dp[n]);
但是我们会感觉这份代码怪怪的,因为复杂度好大的样子,那能否可以继续优化呢?当然是可以的
二进制优化:一个正整数n,可以被分解成的形式。其中,k是满足的最大整数。例如,假设给定价值为2,数量为10的物品,依据二进制优化思想可将10分解为1+2+4+3,则原来价值为2,数量为10的物品可等效转化为价值分别为12,22,42,32,即价值分别为2,4,8,6,数量均为1的物品。
所以,当我们更新dp数组时,对任意物品i,我们都遍历了i的各种数量取值,可将的复杂度将为。
例题:http://acm.hdu.edu.cn/showproblem.php?pid=2191
例题代码:
const int maxn = 1e2 + 5;
int dp[maxn], t, w[605], v[605], p, h, c, n, m, cnt;
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
scanf("%d", &t);
while (t--) {
scanf("%d%d", &n, &m);
memset(dp, 0, sizeof(dp)); //三个数组每次都要清0,否则WA
memset(w, 0, sizeof(w));
memset(v, 0, sizeof(v));
cnt = 1;
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &p, &h, &c);
for (int k = 1; k <= c; k <<= 1) {
w[cnt] = k * p;
v[cnt++] = k * h;
c -= k;
}
if (c > 0) {
w[cnt] = c * p;
v[cnt++] = c * h;
}
}
for (int i = 1; i < cnt; i++) {
for (int j = n; j >= 0; j--) {
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
printf("%d\n", dp[n]);
}
return 0;
}
最长上升子序列问题(LIS, Longest Increasing Subsequence)
问题概述:有一个长为n的数列。请求出这个序列中最长的上升子序列的长度。上升子序列指的是对于任意都满足的子序列。
样例:
思路1:定义是以结尾的最长上升子序列的长度,可分为两种情况:
- 仅包含,长度为1的子序列
- 满足且的以结尾的最长上升子序列,再追加上
由此可得递推式:
这一递推式可在时间内解决这个问题
代码:
int n = 0, a[maxn], dp[maxn], res = 0;
int main() {
for (int i = 0; i < n; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (a[j] < a[i])
dp[i] = max(dp[i],dp[j] + 1);
}
res = max(res, dp[i]);
}
printf("%d", res);
}
思路2:定义长度为的上升子序列中末尾元素的最小值(不存在就是INF)
我们来看看如何更新这个数组。
对于一个上升子序列,显然其结尾元素越小,越有利于在后面接其他的元素,也就越可能变得更长,最开始全部的的值全为INF,由前到后考虑逐个元素。
因此,我们只需要维护数组,对于任意一个,如果,就把接到当前LIS后面,即,当时,我们从中找到第一个大于等于的元素,并将替换为(注意:当这种情况发生时,中存放的并不是当前的LIS,仅能表示长度),但从头扫一遍的话,复杂度仍然是。我们发现:数组是非单调递减的,所以我们可以二分数组,找出第一个大于等于的元素,所以总的时间复杂度是
代码:
int main() {
fill(dp, dp + n, INF);
for (int i = 0; i < n; i++)
*lower_bound(dp, dp + n, a[i]) = a[i];
printf("%d\n", lower_bound(dp, dp + n, INF) - dp);
}
例题1:Super Jumping! Jumping! Jumping!
题意:有N个数字构成的序列,求最大递增子段和,即递增子序列和的最大值,思路就是定义,表示以结尾的最大递增子段和,双重for循环,每次求出以结尾的最大递增子段和。(由于思路2的数组不能表示LIS的具体情况,故不能使用思路2)
代码:
#define INF 0x3f3f3f3f
using namespace std;
typedef long long ll;
const int maxn = 1e3 + 5;
int n, a[maxn], dp[maxn],res = 0;
int main() {
while (true) {
res = 0;
scanf("%d", &n);
if (!n)break;
for (int i = 0; i < n; i++)
scanf("%d", &a[i]);
for (int i = 0; i < n; i++) {
dp[i] = a[i];
for (int j = 0; j < i; j++) {
if (a[j] < a[i])
dp[i] = max(dp[i], dp[j] + a[i]);
}
res = max(res, dp[i]);
}
printf("%d\n", res);
}
}
划分数
问题描述:有n个无区别的物品,将他们划分成不超过m组,求出划分方法数模M的余数。
限制条件:
样例:
思路:设表示将分成份的划分方法数。考虑互为补集的两种情况:
- 每份中都不含有1这个数,即保证每份,可先取出个1分到每一份,再把剩下的分成份即可
- 至少有一份含有1这个数,可以先取出一个1作为独立的一份,剩下的再分为份
可得:
代码:
const int maxn = 1e3 + 5;
int dp[maxn][maxn],n,m,M;
int main() {
dp[0][0] = 1;
for (int i = 0; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (i - j >= 0)
dp[i][j] = (dp[i - j][j] + dp[i - 1][j - 1]) % M;
}
}
printf("%d\n", dp[n][m]);
}
多重集组合数
问题描述:有n种物品,第种物品有个。不同种类的物品可以相互区分但同种类的物品无法区分。从这些物品中去取出m个的话,有多少种取法?求出方案数模M的余数。
限制条件:
样例:
思路:设表示从前个物品中拿了个的方法数。为了从前个物品中取出个,可以从前个物品中取出个,在从物品中取出个,可以得到如下递推式:
这样的复杂度是,不过上式可以化简。
-
当时,,则
-
当时,,则
综上所述,递推式为:
复杂度为
代码:
int main() {
for (i = 0; i <= n; i++)
dp[i][0] = 1;
for (i = 0; i < n; i++) {
for (int j = 1; j <= m; j++) {
if (j > a[i])
dp[i + 1][j] = (dp[i][j] + dp[i + 1][j - 1] - dp[i][j - 1 - a[i]] + M) % M;
//此处+M是防止减法操作得到一个负数, 加一个M不影响结果并保证了答案不为负数。
else {
dp[i + 1][j] = dp[i][j] + dp[i + 1][j - 1];
}
}
}
printf("%d\n", dp[n][m]);
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix