导读

信息学能够有助于孩子未来工作发展,提升孩子的综合能力。


动态规划是信息学竞赛中非常重要,也非常有难度的内容,今天这节课,我们通过理论和实例相结合的方式,带领大家深入了解动态规划基本理论和基本使用方法。


1 引入

我们先来看一个简单的例子。

1 拿最多的宝藏

之前我们讲解贪心算法的时候,有讲过这样一道道题目:



信息学赛培 | 12 重要!!!动态规划算法理论与实例详解_状态转移

​信息学集训 | 14 贪心算法理论与实战​


经验丰富的探险家到了沙漠,看到一片宝藏。现在他的背包能够装下重量为35的宝藏,每种宝藏可以只拿走其中一部分。问探险家最多能够拿走价值多少的宝藏?


当时我们采用了贪心算法获得了价值最多的宝藏。现在我们简单改变一下题目:




经验丰富的探险家到了沙漠,看到一片宝藏,一共有三类。现在他的背包能够装下重量为15的宝藏。


每种宝藏如果质量相同,那么他们的价值也是相同的。现在已知三类宝藏的重量分别为:11, 5, 1,并且宝藏不可拆分。


由于时间有限,在能带走的宝藏价值最大的情况下,最少搬运多少次呢?


如果使用我们之前的贪心算法。我们应该考虑每次都拿能拿的价值最大的。首先拿11。然后我们发现还能带走重量为4的宝物。只能每次搬运重量为1的,一共搬运四次,所以总体一共搬运了5次。


但其实我们再分析,我们就能知道,我们只需要3次搬运,每次搬运重量为5的宝藏就可以了。


所以,贪心算法在这个时候就不能达到最优解了。

2 分析

为什么会产生上面的情况呢?这个涉及到了贪心算法的弊端:贪心算法的策略是让现有的尽可能的接近最终结果


这种策略的弊端是只会考虑到眼前,不会考虑到未来。即“鼠目寸光”。如果使用枚举法,时间复杂度太高,对于一些题目不适合。


我们也可以使用枚举算法,但是枚举算法效率太低。


对此我们采用如下的分析方法:


我们不妨设 f(n) 来表示“搬运重量为n的宝藏所需的最少的搬运次数”。我们考虑第一次可以搬运的重量,按照贪心策略,我们应该先考虑11,然后考虑5,最后考虑1。所以我们可以得到:


f(15) = 1 + f(4)
f(15) = 1 + f(10)
f(15) = 1 + f(14)


以第一个式子为例,该式子说明,搬运重量为15的宝藏,搬运了一次11,还需要搬运重量为4的宝藏。如果第一次搬运11,那么搬运15次的最少次数应该是搬运11的一次加上搬运4的最少次数。


然后我们可以使用递归或者循环实现如下的过程:


(1)初始化f[i]为极大值;
(2)i>=1,则 f[i]更新为当前最小值(当前值与 f[i-1] + 1做比较)。
(3)i>=5,则 f[i]更新为当前最小值(当前值与 f[i-5] + 1做比较)。
(4)i>=11,则 f[i]更新为当前最小值(当前值与 f[i-11] + 1做比较)。


输出f[15]


这种做法会动态调整最终解,直到达到最优解。这就是我们今天要讲的动态规划。

2 动态规划基本理论

我们先来看一下动态规划的基本理论吧!

1 什么是动态规划

动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。


决策过程有如下特点:


(1)过程是若干个互相联系的阶段,会根据不同情况产生多个状态;
(2)每个阶段都要做出决策,每个状态的结果为当前最优决策的结果;
(3)当前阶段的决策不一定基于前面每个阶段中的最优决策。
(4)整个决策的最终结果必须是最优的 。


2 动态规划重要概念

动态规划有很多重要概念:


1、阶段:把所给求解问题的过程恰当地分成若干个相互联系的阶段,以便于求解。过程不同,阶段数就可能不同。


2、阶段变量:描述阶段的变量称为阶段变量。


3、状态:状态表示每个阶段开始面临的自然状况或客观条件。状态不以人们的主观意志为转移,也称为不可控因素。


4、无后效性:满足下面两条的性质称为无后效性:


(1)某一阶段以后过程的发展不受这阶段以前各段状态的影响;
(2)所有各阶段都确定时,整个过程也就确定了。


状态的这个性质意味着过程的历史只能通过当前的状态去影响它的未来的发展


5、决策:从已确定状态演变到下一阶段某个状态的一种选择(行动)称为决策。在许多问题中,决策表示为一个数或一组数。不同的决策对应着不同的数值。因状态满足无后效性,故决策只需考虑当前的状态,无须考虑历史状态。


6、策略:由每个阶段的决策组成的序列称为策略。对于每一个实际的多阶段决策过程,可供选取的策略有一定的范围限制,这个范围称为允许策略集合


7、最优策略:允许策略集合中达到最优效果的策略 。


8、最优化原理:整个过程的最优策略满足:相对前面决策所形成的状态而言,余下的子策略必然构成“最优子策略”。最优性原理实际上是要求问题的最优策略的子策略也是最优


9、状态转移方程动态规划中本阶段的状态通过上一阶段状态和上一阶段决策得到,这个过程就是状态转移方程。


如果给定了第k阶段的状态  以及决策  ,则第k+1阶段的状态  也就完全确定。简单来说就是我们通过上一阶段可以得到下一个阶段,即存在一个递归关系能够从第一阶段一直到最后一个阶段。


状态转移方程是动态规划中很重要的一个概念;我们应用动态规划求解问题,本质上就是找到状态转移方程

3 动态规划局限性

动态规划虽然能解决贪心算法在一些情况下不能达到全局最优的情况,并且对于解决多阶段决策问题的效果是明显的,但是依然存在一些局限性。主要有如下几个方面:





(1)它没有统一的处理方法;必须根据问题的各种性质并结合一定的技巧来处理;


(2)从参赛角度说,如何将动态规划思想引入到题目中,有时候比较难,如果对动态规划掌握不熟练,在竞赛中慎重使用动态规划


(3)另外当变量的维数增大时,总的计算量及存贮量急剧增大。受计算机的存贮量及计算速度的限制,当今的计算机仍不能用动态规划方法来解决较大规模的问题,这就是“维数障碍” 。


但是这并不影响动态规划是非常伟大的算法,也是信息学竞赛最热门的、最重要的算法之一。


4 动态规划与枚举

大家会发现,其实动态规划和枚举是很类似的,都要遍历n的所有值。


但是动态规划的效率还是远高于枚举算法。


例如:


f[15] = f[5] + f[10]


使用枚举算法,f[5]和f[10]要重新计算。但是使用动态规划,前面我们已经分别得到两者的最优解了,我们直接使用即可,这样就提高了效率。

3 动态规划例题

本节课的作业,就是复习上面的所有知识,并完成下面两道题目!

1 引入题目

我们的引入就是一道非常好的动态规划的题目;


【分析】


做动态规划的题目,最重要的就是要找到正确的合适的状态转移方程


上面的引入,我们可以得到状态转移方程如下,后面的f[n]是计算到当前为止f[n]的最小值:


f[n] = min(f[i] + f[n-i], f[n])


上面的状态转移方程我们可以使用三目运算符实现:


#include<iostream>
using namespace std;
int f[20];
int main(){
for(int i=1;i<=15;i++) f[i] = 100; //MAX
for(int i=1;i<=15;i++){
if(i>=1) f[i] = 1+f[i-1]<f[i] ? 1+f[i-1] : f[i];
if(i>=5) f[i] = 1+f[i-5]<f[i] ? 1+f[i-5] : f[i];
if(i>=11) f[i] = 1+f[i-11]<f[i] ? 1+f[i-11] : f[i];
}
cout<<f[15]<<endl;
return 0;
}


2 合并土堆

地上一排地摆放着N堆土堆。小朋友们要将土堆合并成一堆。合并的时候要挪动土堆,为了节省力气小朋友每次只合并相邻的两堆,合并后两堆的总的重量和小朋友消耗的体力成正比。我们假设合并前后土堆的重量不变(不考虑合并过程中土堆的损耗)。假设土堆的重量和小朋友消耗的体力的比值为1,求小朋友消耗的最少体力。


【输入格式】
第一行为一个正整数N (2≤N≤100);
第二行,N个正整数,小于100,分别表示第i堆土堆的重量(1≤i≤N)。
【输出格式】
为一个正整数,即消耗的最少体力。


【分析】


我们想要把土堆合并,并且希望到最后合并的消耗最少。一种基于贪心的策略是,我们每次都合并当前状态中相邻最小的。但是可能会导致最终变大。


最好的方式是采用动态规划。


我们首先要知道状态转移方程,即如何一步步得到最终合并成一堆的过程。


我们想要将土堆中第i堆和第j堆最终合并为一堆,那么他们一定会和i和j的中间的所有堆合并。例如,第3堆和第8堆合并之前,第5堆一定和第3堆或者第8堆合并了。


那么我们将第i堆和第j堆合并可以记为一个二维数组:


f[i][j]


将第i堆和第j堆合并的时候,他们在合并过程中消耗的体力是从第i堆到第j堆的土堆的重量之和。


这样,我们就可以使用一个数组存放前i堆的土堆重量之和:


s[i]


那么从第i堆到第j堆的重量之和为:


s[j] - s[i-1]


然后我们还应该考虑其之前的状态,假设中间值为k,我们想要将第i堆和第j堆合并,我们可以遍历i和j之间的所有k值,让i和k合并,让k+1和j合并。这样我们就能得到状态转移方程了:


f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);


有了转移方程之后,我们就可以写代码了。


首先我们需要一个判断最小值的函数:


int min(int a,int b) {
return a > b ? b:a;
}


然后我们就可以在主函数中完成剩余部分了。


框架如下:


#include<iostream>
#include<string.h>
using namespace std;

int f[101][101];
int s[101];
int n,i,j,k,x;

int min(int a,int b) {
return a > b ? b:a;
}

int main() {

return 0;
}


其中f[101][101]为状态转移数组,f[i][j]表示从第i堆合并到第j堆上所需消耗的最少体力,s[101]中的第i项为前i堆的和。n是堆的数量。i和j表示的是要从第i堆合并到第j堆上。k为i和j中间的值。x是输入数据。


因为s存放的是前n项的和,所以我们用如下输入方式并初始化:


cin>>n;
for (i=1; i<=n; i++)
{
cin>>x;
s[i]=s[i-1]+x;
}
memset(f,1000000,sizeof(f));
for (i=1; i<=n; i++) f[i][i]=0; //自己到自己不需要移动


输入数据后,我们就可以使用状态转移方程求任意两堆合并的时候所消耗的总体力。因为我们从第i堆合并到第j堆的时候,要先保重i后面的第k堆已经合并,所以我们要从后往前合并。所以i要从n-1开始,到1结束。实现了第1堆和第n堆的合并,就是所有的都合并到了一起。所以我们最后输出f[1][n]。


for (i=n-1; i>=1; i--) //前面要合并到后面,要先合并到中间。所以要从后往前合并 
for (j=i+1; j<=n; j++)
for (k=i; k<j; k++)
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);
cout<<f[1][n]<<endl;


全部代码如下:


#include<iostream>
#include<string.h>
using namespace std;

int f[101][101];
int s[101];
int n,i,j,k,x;

int min(int a,int b) {
return a > b ? b:a;
}

int main() {
cin>>n;
for (i=1; i<=n; i++)
{
cin>>x;
s[i]=s[i-1]+x;
}
memset(f,1000000,sizeof(f));
for (i=1; i<=n; i++) f[i][i]=0; //自己到自己不需要移动
for (i=n-1; i>=1; i--) //前面要合并到后面,要先合并到中间。所以要从后往前合并
for (j=i+1; j<=n; j++)
for (k=i; k<j; k++)
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);
cout<<f[1][n]<<endl;
return 0;
}


执行结果如下:


信息学赛培 | 12 重要!!!动态规划算法理论与实例详解_动态规划_02


4 作业

本节课的作业,就是复习上面的所有知识,并完成下面两道题目!

1 找零钱

小明去超市买日用品,花了A元,给了收银员100元。收银员需要给小明找100-A元。超市有100元,50元,20元,10元,5元,2元,1元零钱,现在收银员希望给小明找的钱张数最少。请你帮收银员计算最少找几张。



AI与区块链技术

信息学赛培 | 12 重要!!!动态规划算法理论与实例详解_c++_03