动态规划是比较难的地方,在前面有关函数和递归的部分熟悉了之后,我们可以一起来探索动态规划的世界啦!动态规划难点在于状态的设计和状态转移方程,本文根据不同的题型进行分类讲解。
一、引入
我们先看几个比较简单的例子。
题目:【函数作业】猴子爬山
一个小猴子蹦蹦跳跳去爬山。从山下到山顶共有m个台阶(1 ≤ m ≤ 3000)。小猴子很顽皮,有时候一次跳1个台阶,有时候一次跳3个台阶,还有时候一次跳5个台阶。请问,小猴子上山有多少种不同的跳法。
#include <iostream>
#define mod 1000000
using namespace std;
long long dp[3010]; // dp[i]代表跳到第i个台阶时的方法数
int main() {
int n;
cin >> n;
dp[1] = 1, dp[2] = 1, dp[3] = 2, dp[4] = 3, dp[5] = 5;
for (int i = 6; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 3] + dp[i - 5];
dp[i] %= mod;
}
cout << dp[n] << endl;
return 0;
}
题目:【期末综合练习】三角形最佳路径问题
#include <iostream>
using namespace std;
int a[110][110], dp[110][110];
// dp[i][j]表示从三角形的最底端(第n行)走到(i,j)位置的最佳路径
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> a[i][j];
for (int i = 1; i <= n; i++) dp[n][i] = a[n][i];
for (int i = n - 1; i >= 1; i--) {
for (int j = i; j >= 1; j--) {
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]);
dp[i][j] += a[i][j];
}
}
cout << dp[1][1] << endl;
return 0;
}
动态规划的状态可以理解为“问题所在的局面”,例如:“到第x个台阶的方法数”、“从最下层走到第x行第y个数字的最大数字和”都是状态。状态的答案应该只依赖于状态定义的局面和一些状态以外的常量(例如猴子一次能跳的台阶数相对于状态来说是常量)。
状态之间存在的计算关系式称为(状态)转移方程。转移存在两种方式,一种是发送型转移,一种是接受型转移。前者枚举状态的后继,并计算对后继的贡献;后者枚举状态的前驱,当即计算出状态的答案。
二、线性状态动态规划
(一)最长上升子序列 Longest Increasing Subsequence, LIS
题目1:【函数作业】拦截导弹
【题目分析】这道题本质上在找最长不增子序列,它属于最常规的动态规划类型之一---线性状态动态规划,指的是状态定义与题设内容线性相关。题设中有序列(数组),那动态规划的状态就是一维的;如果题设中有棋盘(网络),那么动态规划的状态就是二维的...线性状态动态规划定义状态的时候经常会考虑“某类有序时间中前若干个子事件的答案”。
f[i]表示只考虑前i个数,并且以第i个数结尾的子序列长度,明确定义序列的结尾便于转移。计算f[i]时,只需要考虑把第i个数接在某个已知序列的后面,这个序列必然是以a1到ai-1中大于等于a1的某一个结尾的,而且越长越好。
状态转移方程:
这里基于一个性质:最长子序列一定是由前面的某个最长子序列转移而来的,即最长子序列长度满足此题的最优子结构。所以即使在对子序列其他信息一无所知,只知道结尾和子序列长度的情况下也能放心的转移。像这类只需要考虑相邻时间(“新事件”和“上一个事件”)关系的问题,是“子序列提取”题的一类常见模型。
【代码实现】
#include <iostream>
using namespace std;
int a[30], f[30];
int main() {
int k;
int ans = 0;
cin >> k;
for (int i = 1; i <= k; i++) cin >> a[i];
for (int i = 1; i <= k; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] >= a[i]) {
f[i] = max(f[i], f[j] + 1);
}
}
ans = max(ans, f[i]);
}
cout << ans << endl;
return 0;
}
题目2:【期末综合练习】最长等差数列子集
一个等差数列是指以增序排列后,相邻的两个数后一个与前一个数之差值恒定。规定一个等差数列中的数不应少于3个。任给 n ( 2 < n < 5000)个正整数,请判断可否从中选择子集构成等差数列。若能,则选出满足条件的元素个数最多的子集合,并按从小到大输出子集中的元素,逗号间隔。若有多个子集同时满足最大,则取差值最大的子集;若同时有多个子集满足集合大小最大,且差值也相等,则取起始元素最大的子集。如果没有满足条件的子集合,则输出 NO。
【题目分析】:这道题本质上依旧是“子序列提取”的问题,不过这次转移的条件不再只取决于相邻两数:想要保证加入下一个数能是等差数列,我们需要知道前面两个数或者公差。给动态规划状态加上公差这一维度:用f[i][d]表示以第i个数结尾,公差为d的子序列(长度≥2)最大长度。
利用动态规划满足的最优子结构的性质,可以往回找到子集的元素。
#include <iostream>
#include <algorithm>
#define maxn 5005
using namespace std;
int a[maxn], f[maxn][maxn], t[maxn]; // 数组t用来记录答案
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + 1 + n);
int ans = 0, cha = 0, last;
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
f[i][a[i] - a[j]] = max(f[i][a[i] - a[i]], f[j][a[i] - a[j]] + 1);
if (f[i][a[i] - a[j]] >= ans) {
if (f[i][a[i] - a[j]] == ans) {
if (a[i] - a[j] < cha) continue;
else cha = a[i] - a[j], last = i;
} else {
ans = f[i][a[i] - a[j]];
last = i;
cha = a[i] - a[j];
}
}
}
}
if (ans == 1) {
cout << "NO" << endl;
return 0;
}
t[1] = a[last];
int cnt = 1, j = last;
for (int i = last - 1; i >= 1; i--) {
if (a[j] - a[i] == cha) {
t[++cnt] = a[i];
j = i;
}
}
for (int i = cnt; i >= 2; i--) cout << t[i] << ',';
cout << t[1] << endl;
return 0;
}
题目3:【期末综合练习】合唱队形
【题目分析】要找从1~(i-1)的最长上升子序列和i~n的最长下降子序列的长度之和的最大值。
【代码实现】
#include <iostream>
using namespace std;
int n, t[105];
int shang(int x) { // 从1-x的最长上升子序列
int f[105] = {0}; // f[i]表示以前i个元素的最长上升子序列长度
int ans = 0;
for (int i = 1; i <= x; i++) {
f[i] = 1;
for (int j = 1; j <= i - 1; j++) {
if (t[j] < t[i]) f[i] = max(f[i], f[j] + 1);
}
ans = max(ans, f[i]);
}
return ans;
}
int jiang(int x) { // 最长下降子序列
int f[105] = {0};
int ans = 0;
for (int i = x; i <= n; i++) {
f[i] = 1;
for (int j = x; j <= i - 1; j++) {
if (t[j] > t[i]) f[i] = max(f[i], f[j] + 1);
}
ans = max(ans, f[i]);
}
return ans;
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> t[i];
int maxl = -1;
for (int i = 1; i <= n; i++) {
int len1 = shang(i - 1);
int len2 = jiang(i);
maxl = max(maxl, len1 + len2);
}
cout << n - maxl << endl;
return 0;
}
(二)最大字段和
给出一段序列,选出其中连续且非空的一段使这段和最大。序列长度n≤200000,序列元素的绝对值≤10000。
【题目分析】明显的阶段只有序列,以f[i]表示前i个数中选出一个字段,若是以序列为阶段,那么加入下一个数时就要么是合并在前面的字段、要么新开一个字段。因而我们定义的状态应该保证当前正在处理的数在选中的字段里,所以我们更改状态的定义:以f[i]表示以第i个数结尾的最大字段和。f[i]代表的字段要么是f[i-1]后面接上a[i],要么是a[i]单个数。
#include <iostream>
#define maxn 20005
using namespace std;
int a[maxn], f[maxn], n, ans = -2000000;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) {
f[i] = max(f[i - 1], 0) + a[i];
ans = max(ans, f[i]);
}
cout << ans << endl;
return 0;
}