本篇文章写了将近一万五千字,整理了关于动态规划系列问题的绝大部分分支,包括动态规划的介绍,相关术语等基础内容,也有区间DP,状压DP等进阶知识。
不管你是刚学习该算法的小白,还是对该算法有了一定了解的人,这篇文章你都可以仔细认真的耐心来学。
对于小白来说,这是一个了解该算法并可以从小白蜕变成大佬的绝佳场地,能够帮助你在动态规划问题上变得游刃有余。
千里之行,始于足下,学习的事不是一蹴而就的,希望你们能从这篇文章中有所收获!
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。
动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。
我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
阶段:
状态:
无后效性:
决策:
一个阶段的状态给定以后,从该状态演变到下一阶段某个状态的一种选择(行动)称为决策。在最优控制中,也称为控制。在许多问题中,决策可以自然而然地表示为一个数或一组数。不同的决策对应着不同的数值。描述决策的变量称决策变量,因状态满足无后效性,故在每个阶段选择决策时只需考虑当前的状态而无须考虑过程的历史 。
决策变量的范围称为允许决策集合。
策略:
从斐波那契开始
什么叫做斐波那契数列?
1 1 2 3 5 8 13……f(n-2)+f(n-1) f(n-1)+f(n-2)
这样的数列就是斐波那契数列,总结起来斐波那契数列的表达式为
n=1, f(1) = 1
n=2, f(2) = 1
n=3,4,5……, f(n) = f(n-1)+f(n-2)
也就是说想要得到斐波那契数列中某个位置的数,还要得到它前两个位置的数据,那么非常常见的一种解决方法就是暴力递归
int f(int n){
if(n == 1 || n == 2)
return 1;
return f(n - 1) + f(n - 2);
}
可以很容易的看到,如果使用递归操作来解决斐波那契问题,很容易造成重复运算的问题,因此使用动态规划,即由底向上来解决问题
动态规划的步骤通常为:
1、划分阶段:按照问题的时间或空间特征,将问题划分成若干个阶段。
2、确定状态与状态变量:将问题发展到各种阶段的客观情况,通过状态的形式表达出来
3、状态转移方程
4、确定边界条件
#include
int d[1000]
void main(){
int n;
scanf("%d", &n);
d[0] = 1;
d[1] = 1;
for(int i = 2; i < 1000; i++)
d[i] = d[i - 1] + d[i - 2]
printf("%d\n", d[n - 1]);
}
可以看到这样就避免了递归解法的重复运算的问题
由于我之前整理过关于背包问题的一些文章,所以你们直接去点相应的链接进行学习就好了。这里我就不在做详细说明了,
背包问题入门:
如果觉得自己对背包问题有所了解的话,我建议去看一下众所周知的背包九讲
背包问题进阶:
问题描述
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
输入样例:
5 # 三角形的行数
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出样例:
30
从上到下计算的话,我们发现会有很多条路线,不容易计算
所以我们可以考虑从下往上计算:
想要到达(i, j) 这个点,可定是从(i - 1, j) 或(i - 1, j + 1) 这俩个点上来的,为了使该路线上的和最大,
那么d[ i ] [ j ] = max(d[ i - 1 ] [ j ], d[ i - 1 ] [ j + 1]) + value[i][j]
最后答案为d[1][1]
#include
#include
#include
using namespace std;
int a[105][105] = {0};
int dp[105][105] = {0};
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 = n - 1; i >= 1; i-- )
for( int j = 1; j <= i; j++ )
dp[i][j] = max(dp[i-1][j],dp[i-1][j+1]) + a[i][j];
cout<<d[1][1]<<endl;
return 0;
}
子序列问题包括:
该问题的详细内容我已整理到下面这篇文章中,请同学们点开链接进行学习
区间dp,顾名思义,在区间上dp,大多数题目的状态都是由区间(类似于dp[l][r]这种形式)构成的,就是我们可以把大区间转化成小区间来处理,然后对小区间处理后再回溯的求出大区间的值,主要的方法有两种,记忆化搜索和递推。
在用递推来求解时,关键在于递推是for循环里面的顺序,以及dp的关键:状态转移方程。
当然大部分的区间dp都是有特点的,我们可以考虑符合什么条件下,大区间可以转化成小区间,然后找出边界条件,进行dp求解。
区间dp也有很经典的板子部分,下面抛出代码和解析:
memset(dp,0,sizeof(dp))//初始dp数组
for(int len=2;len<=n;len++){//枚举区间长度
for(int i=1;i<n;++i){//枚举区间的起点
int j=i+len-1;//根据起点和长度得出终点
if(j>n) break;//符合条件的终点
for(int k=i;k<=j;++k)//枚举最优分割点
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+w[i][j]);//状态转移方程
}
}
题目描述
设有N堆石子排成一排,其编号为1,2,3,…,N。
每堆石子有一定的质量,可以用一个整数来描述,现在要将这N堆石子合并成为一堆。
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有4堆石子分别为 1 3 5 2, 我们可以先合并1、2堆,代价为4,得到4 5 2, 又合并 1,2堆,代价为9,得到9 2 ,再合并得到11,总代价为4+9+11=24;
如果第二步是先合并2,3堆,则代价为7,得到4 7,最后一次合并代价为11,总代价为4+7+11=22。
问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
输入格式
第一行一个数N表示石子的堆数N。
第二行N个数,表示每堆石子的质量(均不超过1000)。
输出格式
输出一个整数,表示最小代价。
数据范围
1≤N≤300
输入样例:
4
1 3 5 2
输出样例:
22
解题思路:
区间DP问题,用动态规划来做:
状态表示:f(i, j) 表示将第 i 堆石子到第 j 堆石子合并成一堆石子的代价的最小值*
如何求f(i, j)呢?
1.假设k为 i ~ j 堆石子的一个分界线,从 i 堆到 k 堆的最小代价已经求出,从 k + 1 堆到 j 堆的最小代价也已经求出,
我们要求 i 堆到 j 堆的最小代价,只需要将左右俩堆的最小代价加起来,再加上这次合并的代价,就可以得到我们的f(i, j)了。
2.这次的合并代价为 i ~ j 堆所有石子的重量之和,我们可以用前缀和的方法来得到任意区间的总和
3.状态转移方程为:f(i, j) = min(f(i, j), f(i, k) + f(k + 1, j) + s[j] - s[i - 1])
#include
using namespace std;
const int N = 310;
int f[N][N], a[N], s[N];
int main()
{
int n;
cin >> n;
for(int i = 1; i <= n; i++)
cin >> a[i];
for(int i = 1; i <= n; i++)
s[i] = a[i] + s[i - 1];
for(int i = 2; i <= n; i++){//枚举长度
for(int j = 1; j + i - 1 <= n; j++){//枚举这个长度下的起点和终点
int l = j, r = j + i - 1;
f[l][r] = 1e9;
for(int k = l; k < r; k++)//枚举分界线
f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
}
}
cout << f[1][n] <<endl;
return 0;
}
由于树有着天然的递归结构:父子结构 而且它作为一种特殊的图 可以描述许多复杂的信息 因此树就成了一种很适合DP的框架
问题:给你一棵树 要求用最少的代价(最大的收益)完成给定的操作
树形DP 一般来说都是从叶子节点推出根 当然 从根推叶子的情况也有 不过很少
一般实现方式: DFS(包括记忆化搜索),递推等
题目传送门
题目描述
Ural大学有N名职员,编号为1~N。
他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。
每个职员有一个快乐指数,用整数 HiHi 给出,其中 1≤i≤N。
现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。
在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。
输入格式
第一行一个整数N。
接下来N行,第 i 行表示 i 号职员的快乐指数Hi。
接下来N-1行,每行输入一对整数L, K,表示K是L的直接上司。
最后一行输入0,0。
输出格式
输出最大的快乐指数。
数据范围
1≤N≤6000
−128≤Hi≤127
输入样例:
7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5
0 0
输出样例:
5
解题思路:
就是在树或图上的一种DP,一般是某个父节点或子节点有特殊要求的时候用的一种DP
首先是建图,在图上遍历的时候进行DP操作,对于这道题来说我们用F(i, j)来表示i这个节点,状态为 j (用0来表示不选,用1来表示选)值的最大值,
对于每个节点我们有俩种操作:
1.选当前这个节点,j 状态为1,它的子节点只能不选,所以f(i, 1) = f(i, 1) + f(u, 0)(u表示 i 的子节点)
2.不选当前这个节点,j 的状态为0,它的子节点可以选,也可以不选,取俩者的最大值
所以f(i, 0) = f(i, 0) + max(f(u, 1), f(u, 0))(u表示 i 的子节点)
3.从任意一个跟节点开始搜索,所以还需要一个数组来储存哪些节点有父节点
#include
#include
using namespace std;
const int N = 6010;
int n;
int happy[N];
int h[N], e[N], ne[N], idx;//邻接表建图
int f[N][2];
bool vis[N];
void add(int a, int b)//添加边
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int cur)//搜索,树形DP
{
f[cur][1] = happy[cur];
for(int i = h[cur]; i != -1; i = ne[i]){//遍历子节点
int j = e[i];
dfs(j);
f[cur][0] += max(f[j][0], f[j][1]);
f[cur][1] += f[j][0];
}
}
int main()
{
cin >> n;
for(int i = 1; i <= n; i++)cin >> happy[i];//输入
memset(h, -1, sizeof h);
for(int i = 0; i < n - 1; i++){
int a, b;
cin >> a >> b;
vis[a] = true; //表示a有父节点
add(b, a);
}
int root = 1;
while(vis[root])root++;///找到一个根节点
dfs(root);
cout << max(f[root][0], f[root][1]);
return 0;
}
数位dp是一种计数用的dp,一般就是要统计一个区间[l, r]内满足一些条件数的个数。所谓数位dp,字面意思就是在数位上进行dp。
数位的含义:一个数有个位、十位、百位、千位…数的每一位就是数位啦!
数位dp的实质就是换一种暴力枚举的方式,使得新的枚举方式满足dp的性质,然后记忆化就可以了。
题目传送门
题目描述
科协里最近很流行数字游戏。
某人命名了一种不降数,这种数字必须满足从左到右各位数字呈非下降关系,如 123,446。
现在大家决定玩一个游戏,指定一个整数闭区间 [a,b],问这个区间内有多少个不降数。
输入格式
输入包含多组测试数据。
每组数据占一行,包含两个整数 a 和 b。
输出格式
每行给出一组测试数据的答案,即 [a,b] 之间有多少不降数。
数据范围
1≤a≤b≤231−1
输入样例:
1 9
1 19
输出样例:
9
18
解题思路
树形DP:
从最高位开始考虑:
假设第 i 位的数为 x, 它前面的数为 j, 将 x 分为0 ~ x - 1, x俩个分支,
考虑左分支的方案数: 假设填k(j <= k < x, 保证非下降)则方案为f[i][k](f(i, k)表示有i位,
且最高位为k的方案数,可用DP求出)
那么左分支的的方案为∑(j <= k < x)f[i][k]
右分支: 如果x < last 无法满足非下降条件, 直接break, 否则填x进入又分支,继续判断下一位的情况
如果i == 0, 可以填任意数,也是一种方案, res++
#include
#include
using namespace std;
const int N = 15;
int f[N][N]; //表示一共有 i 位, 且最高位为 j 的方案数
void init()
{
for(int i = 0; i <= 9; i++)f[1][i] = 1;
for(int i = 2; i < N; i++)
for(int j = 0; j <= 9; j++)
for(int k = j; k <= 9; k++)
f[i][j] += f[i - 1][k];
}
int dp(int n)
{
if(n == 0) return 1;
vector<int> nums;
while(n)nums.push_back(n % 10), n /= 10;
int res = 0; //记录方案数
int last = 0; //记录上一位的数是多少
for(int i = nums.size() - 1; i >= 0; i--){
int x = nums[i];
for(int j = last; j < x; j++)
res += f[i + 1][j];
if(x < last)break;
last = x;
if(i == 0)res++;
}
return res;
}
int main()
{
init();
int l, r;
while(cin >> l >> r){
cout << dp(r) - dp(l - 1) << endl;
}
return 0;
}
状态压缩动态规划,就是我们俗称的状压DP,是利用 计算机二进制的性质来描述状态的一种DP方式
很多棋盘问题都运用到了状压,同时,状压也很经常和BFS及DP连用
举个例子:有一个大小为n*n的农田,我们可以在任意处种田,现在来描述一下某一行的某种状态:
设n = 9;
有二进制数 100011011(九位),每一位表示该农田是否被占用,1表示用了,0表示没用,这样一种状态就被我们表示出来了:见下表
所以我们最多只需要 2^(n+1)−1 的十进制数就好(左边那个数的二进制形式是n个1)
现在我们有了表示状态的方法,但心里也会有些不安:上面用十进制表示二进制的数,枚举了全部的状态,DP起来复杂度岂不是很大?没错,状压其实是一种很暴力的算法,因为他需要遍历每个状态,所以将会出现2^n的情况数量,不过这并不代表这种方法不适用:一些题目可以依照题意,排除不合法的方案,使一行的总方案数大大减少从而减少枚举
有了状态,我们就需要对状态进行操作或访问
可是问题来了:我们没法对一个十进制下的信息访问其内部存储的二进制信息,怎么办呢?别忘了,操作系统是二进制的,编译器中同样存在一种运算符:位运算 能帮你解决这个问题
技巧:用于消去x的最后一位的1
x & (x - 1);
应用:计算在一个32位的整数的二进制表示中有多少个1
分析: 由 x & (x-1) 消去x最后一位知,循环使用x & (x-1)消去最后一位1,计算总共消去了多少次即可。
程序如下:
int count = 0;
while(x != 0)
{
x &= (x - 1);
count++;
}
题目传送门
题目描述
农夫约翰的土地由M*N个小方格组成,现在他要在土地里种植玉米。
非常遗憾,部分土地是不育的,无法种植。
而且,相邻的土地不能同时种植玉米,也就是说种植玉米的所有方格之间都不会有公共边缘。
现在给定土地的大小,请你求出共有多少种种植方法。
土地上什么都不种也算一种方法。
输入格式
第1行包含两个整数M和N。
第2…M+1行:每行包含N个整数0或1,用来描述整个土地的状况,1表示该块土地肥沃,0表示该块土地不育。
输出格式
输出总种植方法对100000000取模后的值。
数据范围
1 ≤ M,N ≤ 12
输入样例:
2 3
1 1 1
0 1 0
输出样例:
9
解题思路:
状态压缩DP:
1.明确状态表达式:假设第 i 行的状态为 j ,第 i - 1 的状态为 k ,DP[ i ] [ j ]表示前 i 行,状态为 j 时的方案数
则:d[ i ] [ j ] = d[ i ] [ j ] + d[ i - 1 ] [ k ]
2.先预处理出地图的状态, 并预处理出每一行所有状态的合法方案,
3.假设第 i 行的状态为state[ j ], 地图状态为map[ i ],则必须满足state[ j ] | map[ i ] == map[ i ],即该状态上
的玉米都种植到了合法的田地上
4.相邻俩行不能有相临边, 则state[ j ] & state[ k ] == 0
#include
using namespace std;
const int N = 15, M = 1 << 12, mod = 100000000;
int mp[M], temp[M];
int d[N][M];
int main()
{
int n, m;
cin >> n >>m;
int x;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
cin >> x;
if(x)mp[i] += (1 << (m - j)); //预处理出地图的状态
}
}
int cnt = 0;
for(int i = 0; i < 1 << m ;i++){
if(i & (i << 1))continue; // 去掉不合法的状态
temp[cnt++] = i;
}
d[0][0] = 1;
for(int i = 1; i <= n; i++){ //枚举每一行
for(int j = 0; j < cnt; j++){ //枚举第i行的所有状态
for(int k = 0; k < cnt; k++){ //枚举第i - 1行的所有状态
if((temp[j] & temp[k]) || ((temp[j] | mp[i]) != mp[i]))continue; //不能与上一行处于同一列,且不能再地图为0的地方
d[i][j] = (d[i][j] + d[i - 1][k]) % mod;
}
}
}
int ans = 0;
for(int i = 0; i < cnt; i++)
ans = (ans + d[n][i]) % mod;
cout << ans << endl;
return 0;
}
其实单调队列就是一种队列内的元素有单调性的队列,因为其单调性所以经常会被用来维护区间最值或者降低DP的维数已达到降维来减少空间及时间的目的。
单调队列的一般应用:
题目传送门
题目描述
输入一个长度为n的整数序列,从中找出一段长度不超过m的连续子序列,使得子序列中所有数的和最大。
注意: 子序列的长度至少是1。
输入格式
第一行输入两个整数n,m。
第二行输入n个数,代表长度为n的整数序列。
同一行数之间用空格隔开。
输出格式
输出一个整数,代表该序列的最大子序和。
数据范围
1≤n,m≤300000
输入样例:
6 4
1 -3 5 1 -2 3
输出样例:
7
解题思路
单调队列:
维护一个合法的窗口, 使得窗口具有单调性, 分3个步骤:
1.窗口是否合法:判断该窗口大小是否超出了范围,若超出,则出队
2.维护窗口的单调性:不断删除队尾, 直到当前的数大于或小于队尾的数,
3.将当前的数放入到队列中
#include
using namespace std;
const int N = 3e5 + 10;
int sum[N], a[N], st[N], l, r;
int main()
{
int n, m;
cin >> n >> m;
for(int i = 1; i <= n; i++){
cin >> a[i];
sum[i] = sum[i - 1] + a[i];
}
int ans = -1e9, r = -1;
for(int i = 1; i <= n; i++){
while(l <= r && i - st[l] > m) l++;
ans = max(ans, sum[i] - sum[st[l]]);
while(l <= r && sum[i] <= sum[st[r]])r--;
st[++r] = i;
}
cout << ans << endl;
return 0;
}
题目传送门
题目描述
有一个 a×b 的整数组成的矩阵,现请你从中找出一个 n×n 的正方形区域,使得该区域所有数中的最大值和最小值的差最小。
输入格式
第一行为三个整数,分别表示 a,b,n 的值;
第二行至第 a+1 行每行为 b 个非负整数,表示矩阵中相应位置上的数。
输出格式
输出仅一个整数,为 a×b 矩阵中所有“n×n 正方形区域中的最大整数和最小整数的差值”的最小值。
数据范围
2≤a,b≤1000
n≤a,n≤b,n≤100
矩阵中的所有数都不超过 109。
输入样例:
5 4 2
1 2 5 6
0 17 16 0
16 17 2 1
2 10 2 1
1 2 2 2
输出样例:
1
解题思路:
1.对于每一行,我们先求出以 i 为右端点的, 且长度在n的范围内的最大值和最小值, 分别存到row_max[i] 和
row_min[i] 中去
2.对于每一列, 我们遍历已经得到的 row_max 数组和 row_min 数组, 分别得到在这一列上以j为右下端点,且长度在n的范围内的最大
值和最小值分别存储到 b[j] 和 c[j] 中去,这样我们就得到了以 (i, j) 为右下顶点且行和列的长度为 n 的矩阵的最大值和最小值
3.遍历所有的列的同时,求出max(b[j] - c[j]) 就是最终的答案
#include
using namespace std;
const int N = 1010;
int w[N][N], row_min[N][N], row_max[N][N];
int st[N];
int n, m, k;
void get_min(int a[], int b[], int n) //获取最小值,
{
int l = 0, r = -1;
for(int i = 1; i <= n; i++){
while(l <= r && i - st[l] >= k)l++;
while(l <= r && a[i] <= a[st[r]])r--;
st[++r] = i;
b[i] = a[st[l]];
}
}
void get_max(int a[], int b[], int n) //获取最大值
{
int l = 0, r = -1;
for(int i = 1; i <= n; i++){
while(l <= r && i - st[l] >= k)l++;
while(l <= r && a[i] >= a[st[r]])r--;
st[++r] = i;
b[i] = a[st[l]];
}
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
for(int i = 1; i <= n; i++)
for(int j = 1; j <= m; j++)
scanf("%d", &w[i][j]);
for(int i = 1; i <= n; i++){
get_min(w[i], row_min[i], m);
get_max(w[i], row_max[i], m);
}
int ans = 1e9;
int a[N], b[N], c[N];
for(int i = k; i <= m; i++){
for(int j = 1; j <= n; j++) a[j] = row_max[j][i];
get_max(a, b, n);
for(int j = 1; j <= n; j++) a[j] = row_min[j][i];
get_min(a, c, n);
for(int j = k; j <= n; j++) ans = min(ans, b[j] - c[j]);
}
cout << ans << endl;
return 0;
}
终于写完了,累死我嘞个乖乖~
——————————————————————————————————————————————
作为一个过来人,当然懂得学习的过程是非常枯燥的,包括我傻乎乎的花了一天的时间去整理这么一个东西(我以后也用不上了,因为马上就退役了)
我第一次接触该算法的时候还是大一的时候学长为给我们讲解的,当时他出了一道很简单的题(当时笨笨的,啥也不会),想了很久也没想出来,最后还是学长点醒了我,他告诉我是用了动态规划的思想,那是我第一次知道原来算法这么神奇(之后就掉进了坑里再也没出来)
这一路走来还是收获了很多的东西,也拿到了很多荣誉,接下来我可能要花大量的时间去准备写一些项目,因为很快就要面临实习了,关于竞赛方面可能真的就到这里了,感谢这段时间一直努力的自己,未来也要继续努力!
终。。。