动态规划
动态规划
动态规划(Dynamic Programming,简称DP)。动态规划分为线性dp、树形dp、数位dp等等。
注意:本文图文并茂
将提供以下图文链接供大家理解:
图文链接:
飞书图解链接🎉🎉🎉
密码:335#47C4
1. dp起源
数字三角形
P1216 [USACO1.5] [IOI1994]数字三角形 Number Triangles
案例1:
4
1
4 6
8 3 9
5 7 2 1
案例2:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
方法一:深搜(dfs)
代码如下:
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N][N];
int n, ans = 0;
void dfs(int x, int y, int sum){
if(x == n - 1) {
ans = max(ans, sum);
return;
}
dfs(x + 1, y, sum + a[x + 1][y]);
dfs(x + 1, y + 1, sum + a[x + 1][y + 1]);
}
int main(){
cin >> n;
for(int i = 0; i < n; i ++ ){
for(int j = 0; j <= i; j ++ ){
cin >> a[i][j];
}
}
dfs(0, 0, a[0][0]);
cout << ans << "\n";
return 0;
}
深搜代码时间复杂度为: \(n!\),n为行数,显然会超时。
方法二:记忆化搜索
记忆化搜索过程: 记忆化搜索从搜索树的下面开始向上回溯。
代码如下:
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N][N], f[N][N];
int n;
int dfs(int x, int y){
if(f[x][y]) return f[x][y]; // 便面重复遍历
if(x == n - 1) f[x][y] = a[x][y]; // 树根
else f[x][y] = a[x][y] + max(dfs(x + 1, y), dfs(x + 1, y + 1)); // 非树根,递归左右两边
return f[x][y]; // 返回该节点的结果
}
int main(){
cin >> n;
for(int i = 0; i < n; i ++ ){
for(int j = 0; j <= i; j ++ ){
cin >> a[i][j];
}
}
dfs(0, 0);
cout << f[0][0] << "\n";
return 0;
}
记忆化搜索时间复杂度为: \(n^2\),n为行数,依旧会超时。
方法三:线性dp
打印一下记忆化搜索结束f数组的值:
20
19 17
15 10 11
5 7 2 1
根据记忆化搜索的过程和f数组不难发现:
f[i][j] = f[i][j] + max(f[i + 1][j], f[i + 1][j + 1])
,则此表达式被称之为状态转移方程。
代码如下:
AC代码,展开查看
#include<iostream>
using namespace std;
const int N = 1e3 + 10;
int a[N][N];
int main(){
int n;
cin >> n;
for(int i = 0; i < n; i ++ ){
for(int j = 0; j <= i; j ++ ){
cin >> a[i][j];
}
}
for(int i = n - 2; i >= 0; i -- ){
for(int j = 0; j <= i; j ++ ){
a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]);
}
}
cout << a[0][0] << endl;
return 0;
}
根据线性dp代码中的
for
循环可判断算法时间复杂度为: \(n^2\),n为行数,不会超时。这个题被卡常了,所以记忆化搜索过不了😂。
思考:
- 可不可以打印出逆推的最大和的路径
逆推的最大和的路径代码如下:
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N][N], b[N][N];
int p[N][N]; // 定义y坐标增量数组, 每个元素代表y坐标增量
int n;
int main(){
cin >> n;
for(int i = 0; i < n; i ++ ){
for(int j = 0; j <= i; j ++ ){
cin >> a[i][j];
b[i][j] = a[i][j];
}
}
for(int i = n - 2; i >= 0; i -- ){
for(int j = 0; j <= i; j ++ ){
if(a[i + 1][j] > a[i + 1][j + 1]){
a[i][j] += a[i + 1][j];
p[i][j] = 0;
}else{
a[i][j] += a[i + 1][j + 1];
p[i][j] = 1;
}
}
}
for(int i = 0, j = 0; i < n; i ++ ){
if(i != n - 1) cout << b[i][j] << "->";
else cout << b[i][j];
j += p[i][j];
}
cout << "\n";
cout << a[0][0] << endl;
return 0;
}
- 可不可以正着递推?可不可以打印出正推的最大和的路径?
正着推代码如下:
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N][N];
int n, ans = 0;
int main(){
cin >> n;
for(int i = 1; i <= n; i ++ ){
for(int j = 1; j <= i; j ++ ){
cin >> a[i][j];
}
}
for(int i = 2; i <= n; i ++ ){
for(int j = 1; j <= i; j ++ ){
a[i][j] += max(a[i - 1][j - 1], a[i - 1][j]);
ans = max(a[i][j], ans);
}
}
cout << ans << "\n";
return 0;
}
正着推输出最大和路径:
1 2 3 4 5 p数组
1 7 0
2 3 8 0
3 8 1 0 1
4 2 7 4 4 0
5 4 5 2 6 5 0
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N][N], b[N][N];
int p[N]; // 定义y坐标增量数组, 索引代表行数,意义是每行的y坐标增量
int n, ans = 0, y; // y记录最大值和链的尾节点的y坐标,x坐标为n
int main(){
cin >> n;
for(int i = 1; i <= n; i ++ ){
for(int j = 1; j <= i; j ++ ){
cin >> a[i][j];
b[i][j] = a[i][j];
}
}
for(int i = 2; i <= n; i ++ ){
for(int j = 1; j <= i; j ++ ){
a[i][j] += max(a[i - 1][j - 1], a[i - 1][j]);
if(a[i][j] > ans){
y = j;
ans = a[i][j];
}
}
}
// 倒着追溯
for(int i = n, j = y; i >= 1; i -- ){
if(b[i - 1][j - 1] > b[i - 1][j]){ // 左上角 > 右上角
p[i - 1] = 1;
j -= 1;
}
}
for(int i = 1; i <= n; i ++ ){
cout << "p:" << p[i] << " ";
}
cout << "\n";
for(int i = 1, j = 1; i <= n; i ++ ){
if(i != n) cout << b[i][j] << "->";
else cout << b[i][j];
j += p[i];
}
cout << "\n";
cout << ans << "\n";
return 0;
}
2. 动态规划规律总结
我们可以观察一下数字三角形题目的逆推的过程,如下图所示:
意义:
i
: 行
j
: 列
a[i][j]
: 表示最大和路径的一个子路径的最大和(子问题最优解),一般这个结果是根据上一个子问题求解而来, 而此结果被称之为状态。
状态转移: 从上一个子问题最优解求得当前子问题最优解的过程。
我们可以总结出如下性质:
- 最优子结构
- 可以看出,在求解此问题的时,可以将其分解为一个类似于此结构的一个子问题
- 只要保证每个子问题的正确性,那么最终的结果一定是正确的
- 在动态规划中,最难的就是如何找到这个最优子结构
- 无后效性
- 可以看出上图类似于一个有向无环图,已经求解的子问题,不会再受到后续决策的影响。
- 子问题重叠
- 可以看出我们使用了数组来保存了(最优子问题)的中间结果,所以动态规划是一种以空间换时间的算法
3. 求解动态规划问题的一般思路
- 找到求解原问题的最优子结构(难点)
- 弄清楚子结构的含义,并画出最优子结构图
- 写出状态转移方程
- 按顺序求解每一个阶段的问题
4. 动态规划练习
线性dp
习题一
B3637 最长上升子序列
子序列: 不一定会连续
最优子结构如下图所示:
含义:
f[i]
: 代表以该元素结尾的最长上升子序列的长度,初始值设置为1代表最有子问题的初始状态,即单个元素本身的最长上升子序列的长度为1
以上满足动态规划的性质
动态转移的条件:
只有当 前一个状态的结尾元素小于当前元素,则状态转移,否则不转移。
动态转移的方程:
f[i] = max(f[j] + 1, f[i])
解释: 只有当前一个状态的结尾元素小于当前元素时,上升子序列的长度 + 1,且与原本的上升子序列长度比较,取最大值。
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
int a[N], f[N];
int n, ans = 1; // 初始值设置为1代表最有子问题的初始状态,即单个元素本身的最长上升子序列的长度为1
int main(){
cin >> n;
for(int i = 0; i < n; i ++ ) {
cin >> a[i];
f[i] = 1;
}
for(int i = 1; i < n; i ++ ){
for(int j = 0; j < i; j ++ ){
if(a[j] < a[i]){
f[i] = max(f[j] + 1, f[i]); // 只有当 前一个状态的结尾元素小于当前元素,则状态转移,否则不转移。
}
}
// 每次最优子问题计算完成取一次最大值
ans = max(ans, f[i]);
}
cout << ans;
return 0;
}
算法时间复杂度为: \(1 + 2 + 3 + \ldots + n - 1 = \frac{n*(n - 1)}{2}=O(n^2)\)
习题二
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int a[N], b[N]; // b数组存储维护单调的非答案的上升子序列
int n, len; // len 表示b数组的索引
int find(int x){ // 找到第一个大于等于x的元素
int l = -1, r = len + 1; // 二分模板对二分边界极为敏感,对于理解二分算法非常有帮助
while(l + 1 < r){
int mid = l + r >> 1;
if(x <= b[mid]) r = mid; // 缩小
else l = mid;
}
return r;
}
int main(){
scanf("%d", &n);
for(int i = 0; i < n; i ++ ) scanf("%d", &a[i]);
b[0] = a[0];
for(int i = 1; i < n; i ++ ){
if(a[i] > b[len]) b[ ++ len] = a[i]; // 大于添加
else if(a[i] < b[len]){ // 小于替换
b[find(a[i])] = a[i];
}
}
printf("%d", len + 1);
}
算法时间复杂度为: \(nlogn\)
习题三
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 10;
char a[N], b[N];
int f[N][N];
int main(){
int n, m;
scanf("%d%d", &n, &m);
scanf("%s%s", a, b);
for(int i = 1; i <= n; i ++ ){
for(int j = 1; j <= m; j ++ ){
if(a[i - 1] == b[j - 1]) f[i][j] = f[i - 1][j - 1] + 1;
else f[i][j] = max(f[i - 1][j], f[i][j - 1]);
}
}
printf("%d", f[n][m]);
return 0;
}
总结:dp,是对暴力求解的简化,利用空间换取时间
习题四
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int a[N], b[N];
int n, len;
int find(int x){ // 找到第一个大于等于x的元素
int l = -1, r = len + 1; // 二分模板对二分边界极为敏感,对于理解二分算法非常有帮助
while(l + 1 < r){
int mid = l + r >> 1;
if(x <= b[mid]) r = mid; // 缩小
else l = mid;
}
return r;
}
int main(){
unordered_map<int, int> hash;
cin >> n;
for(int i = 1; i <= n; i ++ ) {
cin >> a[i];
hash[a[i]] = i;
}
for(int i = 1; i <= n; i ++ ) {
cin >> b[i];
a[i - 1] = hash[b[i]];
}
b[0] = a[0];
for(int i = 1; i < n; i ++ ){
if(a[i] > b[len]) b[ ++ len] = a[i]; // 大于添加
else if(a[i] < b[len]){ // 小于替换
b[find(a[i])] = a[i];
}
}
printf("%d", len + 1);
return 0;
}
习题五
牛客 [编程题]最长公共子串
解法一(能AC,但不满足题意):
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 5e3 + 10;
char a[N], b[N];
int f[N][N];
int main() {
scanf("%s%s", a, b);
int n = strlen(a), m = strlen(b), x = 0, y = 0, max = 0;
for (int i = 1; i <= n; i ++ ) {
for (int j = 1; j <= m; j ++ ) {
if (a[i - 1] == b[j - 1]) f[i][j] = f[i - 1][j - 1] + 1;
else f[i][j] = 0;
if(f[i][j] > max){
max = f[i][j];
x = i, y = j;
}
}
}
string ans;
for(int i = x, j = y; f[i][j] != 0; i --, j -- ){
ans.push_back(a[i - 1]);
}
reverse(ans.begin(), ans.end());
if(ans.size()) printf("%s", ans.c_str());
else puts("-1");
return 0;
}
解法二:
我们可以把二维数组数组f打印出来,观察一下.为了达到额外空间复杂度O(1),我们可以在解法一的基础上使用两个变量循环从右上角至左下角去计算最长公共子串.
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 5e3 + 10;
char a[N], b[N];
int main() {
scanf("%s%s", a + 1, b + 1);
int n = strlen(a + 1), m = strlen(b + 1), x = 0, y = 0, max = 0, len = 0; // len是最长公共子序列
int row = 1, col = m; // 行、列
while(row <= n){ // 右上角至左下角
int i = row, j = col;
while(i <= n && j <= m){ // 遍历斜线
if(a[i] == b[j]) len ++ ;
else len = 0;
if(len > max){
max = len;
x = i, y = j; // 记录最大长度子串的最后一个元素(i,j)
}
i ++ , j ++ ;
}
// 遍历下一条斜线 len 从 0 开始
len = 0;
// 改变斜线起点
if (col > 1) col -- ;
else row ++ ;
}
string ans;
for(int i = x, j = y; i >=1 && j >= 1 && a[i] == b[j]; i --, j -- ){
ans.push_back(a[i]);
}
reverse(ans.begin(), ans.end());
if(ans.size()) printf("%s", ans.c_str());
else puts("-1");
return 0;
}
习题六
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 2e3 + 10;
char a[N], b[N];
int f[N][N];
int main(){
scanf("%s%s", a, b);
int n = strlen(a), m = strlen(b);
for(int i = 1; i <= n; i ++ ) f[i][0] = i;
for(int i = 1; i <= m; i ++ ) f[0][i] = i;
for(int i = 1; i <= n; i ++ ){
for(int j = 1; j <= m; j ++ ){
if(a[i - 1] == b[j - 1]){
f[i][j] = f[i - 1][j - 1];
}else{
f[i][j] = min({f[i - 1][j - 1], f[i][j - 1], f[i - 1][j]}) + 1;
}
}
}
printf("%d", f[n][m]);
return 0;
}
空间优化版本
AC代码,展开查看
#include<bits/stdc++.h>
using namespace std;
const int N = 2e3 + 10;
char a[N], b[N];
int f[N];
int main(){
scanf("%s%s", a + 1, b + 1);
int n = strlen(a + 1), m = strlen(b + 1);
for(int j = 1; j <= m; j ++ ) f[j] = j; // 循环列
for(int i = 1, t1, t2; i <= n; i ++ ){ // 循环行
t1 = f[0] ++ ; // 与f[j - 1]联动
for(int j = 1; j <= m; j ++ ){
t2 = f[j]; // 保存未更新前的f[j]
if(a[i] == b[j]){
f[j] = t1;
}else{
f[j] = min({t1, f[j - 1], f[j]}) + 1;
}
t1 = t2;
}
}
printf("%d", f[m]);
return 0;
}
树形dp
数位dp
本文参考自【董晓算法的个人空间-哔哩哔哩】
海纳百川,有容乃大!如果文章有什么不足之处,还请大神们评论区留言指出,我在此表达感谢🥰!若大家喜欢我的作品,欢迎点赞、收藏、打赏🎉🎉🎉!