1. 动态规划算法精解与实战案例
动态规划(Dynamic Programming,简称DP)是算法竞赛中最核心的解题方法之一,也是信息学奥赛选手必须掌握的"屠龙技"。本文将系统剖析动态规划的核心思想,并通过《信息学奥赛一本通》中的经典例题,带你从零基础成长为DP高手。
提示:动态规划的本质是通过"记忆化存储"避免重复计算,其核心在于状态定义和状态转移方程的建立。
1.1 动态规划基础概念
动态规划适用于具有最优子结构和重叠子问题特性的问题。简单来说,就是大问题的最优解可以由小问题的最优解推导出来,而且在求解过程中会反复遇到相同的子问题。
动态规划解题的四个关键步骤:
- 定义状态(最重要且最难的部分)
- 建立状态转移方程
- 确定初始条件和边界情况
- 确定计算顺序(自顶向下或自底向上)
1.2 最长不下降子序列(LNDS)详解
让我们以《信息学奥赛一本通》中的1259题为例,深入分析最长不下降子序列问题的解法。
1.2.1 问题描述
给定一个长度为n的序列,求其中最长的子序列,使得这个子序列中的元素非严格递增(即a[i] ≤ a[i+1])。
1.2.2 状态定义
定义f[i]表示以第i个元素结尾的最长不下降子序列的长度。我们的目标是求出所有f[i]中的最大值。
1.2.3 状态转移方程
对于每个i,我们需要检查前面所有j < i的元素:
code复制f[i] = max(f[j] + 1) 其中j < i且a[j] ≤ a[i]
如果不存在这样的j,则f[i] = 1(即自身构成序列)
1.2.4 路径回溯技巧
为了输出具体的序列,我们需要维护一个pre数组记录前驱节点:
cpp复制int a[N], f[N], pre[N];
void print_path(int k) {
if (k == 0) return;
print_path(pre[k]);
cout << a[k] << " ";
}
1.2.5 完整代码实现
cpp复制#include <iostream>
#include <algorithm>
using namespace std;
const int N = 210;
int n, a[N], f[N], pre[N], res[N], cnt;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int ans = 0, last = 0;
for (int i = 1; i <= n; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] <= a[i] && f[j] + 1 > f[i]) {
f[i] = f[j] + 1;
pre[i] = j;
}
}
if (f[i] > ans) {
ans = f[i];
last = i;
}
}
cout << "max=" << ans << endl;
for (int i = last; i != 0; i = pre[i])
res[++cnt] = a[i];
for (int i = cnt; i >= 1; i--)
cout << res[i] << (i == 1 ? "" : " ");
return 0;
}
1.2.6 算法优化
上述解法时间复杂度为O(n²),对于n≤10⁵的情况会超时。可以使用贪心+二分查找优化到O(nlogn):
cpp复制vector<int> d;
for (int i = 1; i <= n; i++) {
auto it = upper_bound(d.begin(), d.end(), a[i]);
if (it == d.end()) d.push_back(a[i]);
else *it = a[i];
}
cout << d.size() << endl;
2. 典型动态规划问题分类解析
2.1 线性动态规划
2.1.1 拦截导弹问题(1260题)
这是最长不上升子序列的典型应用。第一问直接求LNDS,第二问则转化为Dilworth定理的应用——最少不上升子序列划分数等于最长上升子序列长度。
关键代码段:
cpp复制// 第一问:最长不上升子序列
for (int i = 1; i <= n; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] >= a[i])
f[i] = max(f[i], f[j] + 1);
}
ans1 = max(ans1, f[i]);
}
// 第二问:最长上升子序列
for (int i = 1; i <= n; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] < a[i])
f[i] = max(f[i], f[j] + 1);
}
ans2 = max(ans2, f[i]);
}
2.1.2 合唱队形问题(1264题)
这是LIS的变种,需要同时计算从左到右和从右到左的LIS,然后求某个位置两者之和的最大值。
状态转移:
cpp复制// 从左向右LIS
for (int i = 1; i <= n; i++) {
f[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] < a[i])
f[i] = max(f[i], f[j] + 1);
}
}
// 从右向左LIS(相当于从左向右的LDS)
for (int i = n; i >= 1; i--) {
g[i] = 1;
for (int j = n; j > i; j--) {
if (a[j] < a[i])
g[i] = max(g[i], g[j] + 1);
}
}
// 求最大值
int max_k = 0;
for (int i = 1; i <= n; i++)
max_k = max(max_k, f[i] + g[i] - 1);
2.2 区间动态规划
2.2.1 城市交通路网(1261题)
典型的DAG上的动态规划问题,采用逆拓扑序进行计算。
状态转移方程:
cpp复制f[n] = 0; // 终点到终点距离为0
for (int i = n-1; i >= 1; i--) {
f[i] = INF;
for (int j = i+1; j <= n; j++) {
if (a[i][j] > 0 && f[j] + a[i][j] < f[i]) {
f[i] = f[j] + a[i][j];
nxt[i] = j;
}
}
}
2.3 背包问题专题
2.3.1 0/1背包问题(1267题)
最基本的背包问题,每个物品只能选或不选。
状态转移方程(二维):
cpp复制for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
f[i][j] = f[i-1][j];
if (j >= w[i])
f[i][j] = max(f[i][j], f[i-1][j-w[i]] + c[i]);
}
}
优化到一维:
cpp复制for (int i = 1; i <= n; i++) {
for (int j = m; j >= w[i]; j--) {
f[j] = max(f[j], f[j-w[i]] + c[i]);
}
}
2.3.2 完全背包问题(1268题)
每个物品可以选无限次,与0/1背包的区别仅在于遍历顺序。
状态转移方程:
cpp复制for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= m; j++) {
f[j] = max(f[j], f[j-w[i]] + c[i]);
}
}
2.3.3 多重背包问题(1269题)
每个物品有数量限制,可以通过二进制拆分优化。
二进制拆分实现:
cpp复制for (int i = 1; i <= n; i++) {
int v, w, s;
cin >> v >> w >> s;
for (int k = 1; k <= s; k <<= 1) {
cnt++;
new_v[cnt] = k * v;
new_w[cnt] = k * w;
s -= k;
}
if (s > 0) {
cnt++;
new_v[cnt] = s * v;
new_w[cnt] = s * w;
}
}
2.3.4 混合背包问题(1270题)
结合了0/1、完全和多重背包,需要根据物品类型选择不同的处理方式。
核心代码:
cpp复制for (int i = 1; i <= n; i++) {
int w, c, p;
cin >> w >> c >> p;
if (p == 0) { // 完全背包
for (int j = w; j <= m; j++)
f[j] = max(f[j], f[j-w] + c);
} else { // 多重背包(包括0/1背包)
for (int k = 1; k <= p; k <<= 1) {
for (int j = m; j >= k*w; j--)
f[j] = max(f[j], f[j-k*w] + k*c);
p -= k;
}
if (p > 0) {
for (int j = m; j >= p*w; j--)
f[j] = max(f[j], f[j-p*w] + p*c);
}
}
}
3. 动态规划优化技巧
3.1 状态压缩
当状态维度较高时,可以考虑:
- 滚动数组优化空间
- 位运算压缩状态(如状压DP)
- 降维处理(如背包问题从二维降到一维)
3.2 单调队列优化
适用于决策单调的情况,可以将时间复杂度从O(n²)降到O(n)。
3.3 斜率优化
处理形如dp[i] = min(dp[j] + f(i,j))的问题,通过维护凸包来优化。
4. 动态规划解题心得
- 画状态转移图:在纸上画出状态之间的转移关系,有助于理清思路
- 打印DP表:调试时可以打印出整个DP数组,观察状态变化
- 从暴力递归开始:先写暴力递归,再改记忆化搜索,最后转为递推
- 注意边界条件:特别是数组下标为0或1时的初始化
- 长期训练形成直觉:大量练习后会对状态定义产生直觉
注意:动态规划的学习曲线较为陡峭,建议从简单的线性DP开始,逐步过渡到区间DP、树形DP等复杂类型。每道题至少做3遍:第一次看题解,第二次独立实现,第三次尝试优化。