动态规划
把一个大问题分解成很多小问题,然后把小问题的答案保存起来,避免重复计算,最后根据小问题的答案推出大问题的答案,这样的算法叫动态规划(DP).
作用与核心思想
- 作用:高效地解决一些复杂的问题,例如最短路径,背包问题等.
- 核心思想:把一个大问题分解成很多小问题,然后把小问题的答案保存起来,避免重复计算,最后根据小问题的答案推出大问题的答案.
概念
- 状态:描述问题的某个子问题的情况
- 初始状态:最简单的情况,边界条件
- 状态转移方程:状态之间转换的规律,即如果得知前面的状态,怎么退出后面的状态(一种状态一般会由多种状态堆叠而成)
- 最终状态:最终的答案
例题:求斐波那契数列的第 n 项
设定一个数组为 dp[i],表示斐波那契数列的第 i 项
状态:dp[i] 表示前两个数之和
初始状态:dp[1]=1,dp[2]=1
状态转移方程:`
最终状态:dp[n]
动态规划基本步骤
- 定义状态
- 确定初始状态
- 找出状态转换方程(可以倒过来思考,先想好每一个状态可以迁移到哪些状态)
- 返回最终状态
性质
- 最优子结构:一个问题的最优解可以由它的子问题来得出.
- 无后效性:如果知道了一个状态的值,那么就不需要再考虑它是怎么得到的,也不需要考虑它之前的状态是什么.因为状态的值只取决于当前问题的情况,并不受之前具体选择的影响,简化状态转移方程,并且避免一些不必要的计算.
常见优化方案
- 预处理
- 影响参数化,参数结果化
- 如果一个问题有后效性,考虑将影响参数化,增加一维参数
- 尽力将参数消去,这样可以减少时间复杂度
典型案例
最长上升子序列问题
给出一个由 个不超过 的正整数组成的序列.请输出这个序列的最长上升子序列的长度.最长上升子序列是指,从原序列中按顺序取出一些数字排在一起,这些数字是逐渐增大的.
带入基本步骤:
设立原数组为 a 数组,长度为 n
状态:dp[i] 表示 a[1]~a[i] 中最长子序列的长度
初始状态:dp[1] = 1
状态转移方程:,即枚举在 a[i] 之前且小于 a[i] 的元素 a[j],并更新 dp[i]
最终状态:max(dp[i])
时间复杂度:由于上述方案,对于每一个元素,都要枚举其前面的元素,时间复杂度为
优化
定义数组 d:
d[len]表示:长度为len的所有递增子序列中,末尾元素的最小值.- 若不存在长度为
len的递增子序列,则记为INF().
不难发现,d 数组是严格单调递增的(对于严格递增子序列):
为什么?因为长度为
len+1的子序列末尾,一定大于某个长度为len的子序列末尾,而d[len]取最小值,所以必然有d[len] < d[len+1].
遍历原序列中的每一个元素 x = A[i]:
- 在
d数组中,找到最大的下标j,使得d[j] < x. - 令目标位置为
pos = j + 1. - 更新
d[pos] = x.
注意:
- 找到一个长度为
j的尾巴比x小的子序列,把x接在后面,会得到 长度为j+1的子序列.所以必须更新d[j+1],而不是d[j]. - 不需要更新 ,是因为当前的值比
d[pos-x]大.
由于 d 严格递增,“最大的 j 使 d[j] < x” 等价于 “找到第一个 pos 使 d[pos] >= x”.
此时有对应关系:
因此,算法可以简化为:在 d 数组中二分查找第一个大于等于 x 的位置 pos,然后将 d[pos] 替换为 x.
复杂度分析
- 每次查找使用二分查找:
- 总共遍历
n个元素 - 总时间复杂度:
- 空间复杂度:
标程
使用 lower_bound 直接查找第一个 >= x 的位置
#include <bits/stdc++.h>using namespace std;int ans;
const int N = 5e5 + 100;int n, a[N], dp[N]; // dp[i] = 长度i时,末尾最小值
int main() { cin >> n; memset(dp, 0x3f, sizeof(dp)); for (int i = 1; i <= n; i++) { cin >> a[i]; } for (int i = 1; i <= n; i++) { int pos = lower_bound(dp + 1, dp + n + 1, a[i]) - dp; dp[pos] = a[i]; ans = max(ans, pos); } cout << ans; return 0;}如果需要求出子序列具体的元素,需要额外维护:
idx[i]表示dp[i]所取元素的下标,即满足dp[i] = a[idx[i]]pre[i]表示a[i]在目标序列的前驱
在找到 pos 之后,
- 若
pos为 1,则表示a[i]为开头,设置pre[i] = 0 - 否则,
a[i]会接在dp[pos-1]之后,设置pre[i] = idx[pos-1]
最后,从最后一个元素开始,也就是 dp[ans],往前跳,将每一次跳的结果保留在 answer 中,并翻转,就是路径.
标程
#include <bits/stdc++.h>using namespace std;int ans;
const int N = 5e5 + 100;int n, a[N], dp[N]; // dp[i] = 长度i时,末尾最小值int idx[N], pre[N];// idx[i] := dp[i]值对应元素的下标// pre[i] := a[i]可以接在a[pre[i]]的后面
int main() { cin >> n; memset(dp, 0x3f, sizeof(dp)); for (int i = 1; i <= n; i++) { cin >> a[i]; } for (int i = 1; i <= n; i++) { int pos = lower_bound(dp + 1, dp + n + 1, a[i]) - dp; if (pos == 1) { // 如果 a[i] 可以作为开头 pre[i] = 0; // a[i] 前面没有东西 } else { // 否则 a[i] 会接在 dp[pos-1] 后面 pre[i] = idx[pos - 1]; } // 维护数据 idx[pos] = i; dp[pos] = a[i]; ans = max(ans, pos); } vector<int> answer; int ptr = idx[ans]; while (ptr != 0) { answer.push_back(a[ptr]); ptr = pre[ptr]; } reverse(answer.begin(), answer.end()); for (auto k : answer) { cout << k << " "; } return 0;}背包问题
01 背包
有 个物品和一个容量为 的背包,每个物品有重量 和价值 两种属性,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量.
在上述例题中,由于每个物体只有两种可能的状态(取与不取),对应二进制中的 0 和 1
,这类问题便被称为0-1 背包问题.
定义 dp[i][j] 表示考虑前 i 个元素,背包占用空间达到 j,所能够获取的最大空间.初始时 dp[1][0]=0
对于所有的物品,都有选和不选两种选择,因此,有如下的状态转移方程:
不难发现,dp[i][j] 依赖于以下项:
dp[i-1][j]dp[i-1][j-w[i]]
由于第一维只与 i-1 有关,因此,可以倒着遍历 j,如果这样,第一维状态就可以省去.
完全背包
有 种物品和一个容量为 的背包,每种物品有重量 和价值 两种属性,且每一种都有无限个,要求选若干物品放入背包使背包中物品的总价值最大,且背包中物品的总重量不超过背包的容量.
相对于 01 背包,完全背包的每个物品可以取无限个,状态定义和 01 背包一样,不过,对于每一个物品,都有选 0 个、选 1 个……直到重量到达 .因此,有如下转移方程:
显然,这样时间复杂度是 .
优化
在 dp[i][j] 的转移中,需要枚举 dp[i-1][j-1*w[i]]、dp[i-1][j-2*w[i]]、dp[i-1][j-3*w[i]]……而在 dp[i][j-w[i]] 的转移中,需要枚举 dp[i-1][j-2*w[i]]、dp[i-1][j-3*w[i]]……显然,我们发现,dp[i][j] 的枚举之比 dp[i][j-w[i]] 多了一个 dp[i][j-w[i]].
由此,就有以下转移方程:
由于第一维只与 i-1 和 i 有关,因此,可以正着遍历 j,如果这样,第一维状态就可以省去.
多重背包
有 种物品和一个容量为 的背包,每种物品有重量 和价值 两种属性,且每一种都有 个,要求选若干物品放入背包使背包中物品的总价值最大,且背包中物品的总重量不超过背包的容量.
相对于 01 背包,多重背包可以选择至多 次,对于每一个物品,都有选 0 个、选 1 个……选 个.因此,有如下转移方程:
显然,转移是 .
优化 1
对于 的情况,说明 个都取不到,此时退化成完全背包,可以使用完全背包的关系式.
优化 2
对于一件有 个的物品,我们可以将其二进制拆解为:
,其中 是满足 的最大整数.
设其二进制拆解的第 项为 ,就可以将 件物品拆解为 件价值为 、 件价值为 ……的物品.这样转化为 01 背包.
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


