【算法】动态规划问题的常见解决办法
做这个的起因是我一开始觉得动态规划问题的题解都长得好相似啊,然后再研究了紫书,感觉这个可以总结一下,有些人给的是挨个推的讲解,我觉得他们讲的很好,但是有点停留了,我的目的是把整个推演的表示给提炼出来,废话不说了开始吧。
零、引言
动态规划问题是一种常见的算法比赛问题,极为经典也较为困难,原因是大多数题目类似于递归问题,但是如果真的用递归去解决的话会出现诸如超时等丢分问题,为此,动态规划问题的常用解决办法便诞生了,开门见山,直接列方法:
一、思想框架
思想:用空间换取时间。
首先我们不看动态规划方法,而是先去了解递归方法,递归方法的逻辑很简单“解决父问题就要先解决子问题,解决子问题就要解决孙问题......”但是当存在两个以上的子问题的时候,可能会出现调用同一个孙问题的情况,这样就会无端的浪费时间,多重调用,造成浪费,为此,动态规划方法出现了,动态规划方法解决这个时间浪费的方法是:把已经解决了的问题存储下来,每次遇到直接调用就行了,节省了多次重复。
这就是动态规划思想“用空间换时间”的由来。
二、三大步骤
解决这种动态规划问题常用三个步骤
1 >构建状态数组dp,动态规划常用的思想就是状态,当你处在什么状态的时候,对应的数组f[i][j]就存储着该种状态。而且这一步也非常关键,只有能理解了“状态”这个词的概念,才能运用状态转移方程。
2 >状态转移方程,一般形式是数组的形式写出来的,以《数字三角形》[2]为例,该问题的状态转移方程为 dp[i][j] = max/min(dp[i,j],dp[i + 1, j]+ a[i])
状态转移方程的理解就是:前一个问题dp[i][j]对后一个问题会产生什么影响呢?每一个状态的数学表示可以表示为 z = dp[i][j]有三个参数 z,i,j。这三个参数z代表了这个状态的量值是多少 i一般代表了该状态的序号,j一般代表了该状态的第几个情况。
而影响“状态”数组的有两个,一是前一个状态,没什么好说,递归都是这样。二则是状态转移时候的“代价”,而代价,一般就是我们一开始输入的内容,譬如爬楼梯需要的高度,摘苹果需要的体力等等。
3 >循环构成,因为整个状态数组的构建过程是需要通过一个遍历过程的(i变量的遍历)。而且在这个过程中,每个状态中又有着多种可能(j变量的遍历),所以确定遍历,便是第三个关键点,这三个关键点确定了,便可以确定整个核心算法了。
三、三大要素
代价数组 a[N]//存储每一个代价
状态数组 dp[N][M] //存储每一个状态
状态转移方程 f(dp) //一般表示形式是 本状态 = f(本状态 or 前状态 + 代价) f是某一状态的选择方式,比如最小或者最大
四、代码框架
这里只讨论迭代方法:
//C++掺杂文字表示伪代码
#include <iostream>
using namespace std;
const int N = 12345;
const int M = 123;
int a[N]; //代价数组
int dp[N][M]; //状态数组
int main()
{
int n;
// 输入n的值
//用for循环输入a数组的值
//将dp数组初始化为某一特定值
memset(dp, 0x3f, sizeof(dp)); //初始化所有的f动态规划数组为无穷大
for (int i = 0; i < length1; i++)
{
for (int j = 0; j < length2; j++)
{
// 可能还有for循环
// 这里是状态转移方程
}
}
// 一般情况是这样的一个结构,但是可能会出现有点第二维度很小,比如下面的例题,不需要两层for循环。
return 0;
}
五、举例子
例题1、http://lx.lanqiao.cn/problem.page?gpid=T581
算法提高 秘密行动
问题描述
小D接到一项任务,要求他爬到一座n层大厦的顶端与神秘人物会面。这座大厦有一个神奇的特点,每层的高度都不一样,同时,小D也拥有一项特殊能力,可以一次向上跳跃一层或两层,但是这项能力无法连续使用。已知向上1高度消耗的时间为1,跳跃不消耗时间。由于事态紧急,小D想知道他最少需要多少时间到达顶层。
输入格式
第一行包含一个整数n,代表楼的高度。
接下来n行每行一个整数ai,代表i层的楼层高度(ai <= 100)。
输出格式
输出1行,包含一个整数,表示所需的最短时间。
样例输入 5 3 5 1 8 4
样例输出 1
数据规模此处略
http://lx.lanqiao.cn/problem.page?gpid=T581
题解
#define _CRT_SECURE_NO_WARNINGS
#include <cstdio>
#include <iostream>
#include <string>
#include <cstring>
#include <stack>
#include <queue>
#include <vector>
#include <cmath>
#include <algorithm>
using namespace std;
const int mod = 1000000007;
const int M = 123456;
//const int N = 10000;
const int N = 1e5 + 10;
typedef long long ll;
int a[N];
int dp[N][2]; //z = f[i][j] 表示到达第i层用第j种方式需要消耗的高度为z 假设一开始是在高度为0的地方,所以f[0]都是0
int n;
// 按照步数来,一步(跳or爬) 两步 只有跳 的方式区分
int main()
{
cin >> n; //输入楼高
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]); //按顺序输入每一层楼
memset(dp, 0x3f, sizeof(dp)); //初始化所有的f动态规划数组为无穷大
dp[0][1] = dp[0][0] = 0; //跳或不跳都是从高度为0开始
for (int i = 1; i <= n; i++)
{
//到达第i层
dp[i][0] = min(dp[i][0], dp[i - 1][0] + a[i]);//代表在上一次是爬的基础上 再爬
dp[i][0] = min(dp[i][0], dp[i - 1][1] + a[i]);//代表在上一次是跳的基础上 再爬
dp[i][1] = min(dp[i][1], dp[i - 1][0]); //代表在上一次是爬的基础上 跳(因为跳的话,上一次只能是爬) 这个机制保证了每次都是跳 爬 不会出现跳 跳的情况
//避免一直跳,如果上一次是跳的
if (i >= 2)
dp[i][1] = min(dp[i][1], dp[i - 2][0]); //代表跳两个格子,在跳一次
//这个保证了每次都是先爬后跳,只不过跳的大小不同
}
int res = min(dp[n][0], dp[n][1]); //在最后一次是爬和最后一次是跳里选一个最小的
printf("%d", res);
return 0;
}
例题2、http://lx.lanqiao.cn/problem.page?gpid=T575
算法提高 和谐宿舍2
问题描述
墙上有n张作品。这些作品都是宽度为1,高度不定的矩形,从左到右排成一排,且底边在同一水平线上。
宿舍评比就要来了,为了及格,我们决定买不多于m块的矩形木板,把这些作品和谐掉。要求木板也从左到右排成一排,且底边与作品的底边在同一水平线上。
在能够把所有作品和谐掉的前提下,我们希望这些木板的面积和最小,问最小面积和。
输入格式
第一行两个数n和m,表示作品数和木板数;
第二行n个数Hi,表示从左到右第i个作品的高度。
输出格式
一行一个数ans,表示答案。
样例输入
5 2
4 2 3 5 4
样例输出 22
http://lx.lanqiao.cn/problem.page?gpid=T575
题解
using namespace std;
const int mod = 1000000007;
const int M = 123456;
const int N = 123;
long a[N][N] = { 0 };
static long long maxl = 0;
int max(int x, int y)
{
return (x >= y ? x : y);
}
int min(int x, int y)
{
return (x <= y ? x : y);
}
int height[N] = { 0 }, maxh[N][N] = { 0 };
int f[N][N] = { 0 };
int main()
{
int n,m;
cin >> n >> m;//n是画的数量 m是板子的数量
for (int i = 1; i <= n; i++)
{
cin >> height[i];
}
for (int i = 1; i <= n; i++)
{
maxh[i][i] = height[i];
for (int j = i + 1; j <= n; j++)
{
maxh[i][j] = max(maxh[i][j - 1], height[j]);
}
}
for (int i = 1; i <= n; i++)
{
f[i][1] = i * maxh[1][i];
for (int j = 2; j <=m &&j<=i ; j++)
{
f[i][j] = INF;
for (int k = 1;k<=i-j+1 ; k++)
{
f[i][j] = min(f[i][j], f[i - k][j - 1] + k * maxh[i - k + 1][i]);
}
}
}
cout << f[n][m];
return 0;
}
虽然这两个并非用了两层for循环的,但是基本可以看出来,“状态遍历-状态内多种不同选择遍历”的逻辑。
六、小节
动态规划问题,理解上最困难的就是状态,状态转移,以及代价的概念了,网上有很多为什么要这样做的解释,b站也有很多类似的视频,记得有一个讲的背包问题讲的不错,但是我还是更倾向于用数学的表示方法或者是代码+注释的方式来表示出来,这只是我个人的总结而已。
七、参考资料
[1] 算法竞赛入门经典++第二版 刘汝佳
[2] 数字三角形题目 http://lx.lanqiao.cn/problem.page?gpid=T312