动态规划(DP)初步

一、引入

        动态规划程序设计是对解最优化问题的一种途径、一种方法,而不是一种特殊算法。不像前面所述的那些搜索或数值计算那样,具有一个标准的数学表达式和明确清晰的解题方法。动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有各具特色的解题方法,而不存在一种万能的动态规划算法,可以解决各类最优化问题。因此除了要对基本概念和方法正确理解外,必须具体问题具体分析,以丰富的想象力去建立模型,用创造性的技巧去求解。我们也可以通过对若干有代表性的问题的动态规划算法进行分析、讨论,逐渐学会并掌握这一设计方法。

二、DP基本模型:多阶段决策过程的最优化问题

        在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。当然,各个阶段决策的选取不是任意确定的,它依赖于当前面临的状态,又影响以后的发展,当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线,这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题就称为多阶段决策问题。如下图所示:

        多阶段决策过程,是指这样的一类特殊的活动过程,问题可以按时间顺序分解成若干相互联系的阶段,在每一个阶段都要做出决策,全部过程的决策是一个决策序列。

1.      基本概念

(1)阶段和阶段变量:

        用动态规划求解一个问题时,需要将问题的全过程恰当地分成若干个相互联系的阶段,以便按一定的次序去求解。描述阶段的变量称为阶段变量,通常用K表示,阶段的划分一般是根据时间和空间的自然特征来划分,同时阶段的划分要便于把问题转化成多阶段决策过程。

(2)状态和状态变量:

        某一阶段的出发位置称为状态,通常一个阶段包含若干状态。一般地,状态可由变量来描述,用来描述状态的变量称为状态变量。

(3)决策、决策变量和决策允许集合:

        在对问题的处理中作出的每种选择性的行动就是决策。即从该阶段的每一个状态出发,通过一次选择性的行动转移至下一阶段的相应状态。一个实际问题可能要有多次决策和多个决策点,在每一个阶段的每一个状态中都需要有一次决策,决策也可以用变量来描述,称这种变量为决策变量。在实际问题中,决策变量的取值往往限制在某一个范围之内,此范围称为允许决策集合。

(4)策略和最优策略:

        所有阶段依次排列构成问题的全过程。全过程中各阶段决策变量所组成的有序总体称为策略。在实际问题中,从决策允许集合中找出最优效果的策略成为最优策略。

(5)状态转移方程【分析问题的核心】

        前一阶段的终点就是后一阶段的起点,对前一阶段的状态作出某种决策,产生后一阶段的状态,这种关系描述了由k阶段到k+1阶段状态的演变规律,称为状态转移方程。

2.       两个重要特性:最优化原理与无后效性

        一般来说,能够采用动态规划方法求解的问题,必须满足最优化原理和无后效性原则:

(1)动态规划的最优化原理:

        作为整个过程的最优策略具有:无论过去的状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略的性质。也可以通俗地理解为子问题的局部最优将导致整个问题的全局最优,即问题具有最优子结构的性质,也就是说一个问题的最优解只取决于其子问题的最优解,而非最优解对问题的求解没有影响。

(2)动态规划的无后效性原则:

        所谓无后效性原则,指的是这样一种性质:某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响。也就是说,“未来与过去无关”,当前的状态是此前历史的一个完整的总结,此前的历史只能通过当前的状态去影响过程未来的演变。

        即:一个问题被划分成各个阶段之后,阶段K中的状态只能由阶段K+1中的状态通过状态转移方程得来,与其它状态没有关系,特别是与未发生的状态没有关系。

        由此可见,对于不能划分阶段的问题,不能运用动态规划来解;对于能划分阶段,但不符合最优化原理的,也不能用动态规划来解;既能划分阶段,又符合最优化原理的,但不具备无后效性原则,还是不能用动态规划来解;误用动态规划程序设计方法求解会导致错误的结果。【三个条件缺一不可】

3.       动态规划设计方法的一般模式

        动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态;或倒过来,从结束状态开始,通过对中间阶段决策的选择,达到初始状态。这些决策形成一个决策序列,同时确定了完成整个过程的一条活动路线,通常是求最优活动路线。

        动态规划的设计都有着一定的模式,一般要经历以下几个步骤:

(1)划分阶段

        按照问题的时间或空间特征,把问题划分为若干个阶段。在划分阶段时,注意划分后的阶段一定是有序的或者是可排序的,否则问题就无法求解。

(2)确定状态和状态变量

        将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。

(3)确定决策并写出状态转移方程

        因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可以写出。但事实上常常是反过来做,根据相邻两段的各个状态之间的关系来确定决策。

(4)寻找边界条件

        给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。

四、典例

1. 数字三角形/数塔问题(DP入门题)

        有形如下图所示的数塔,从顶部出发,在每一结点可以选择向左走或是向右走,一起走到底层,要求找出一条路径,使路径上的值最大。

样例输入:

5

13

11 8

12 7 26

6 14 15 8

12 7 13 24 11

样例输出:

86(13->8->26->15->24)

【分析】这道题如果用枚举法,在数塔层数稍大的情况下(如40),则需要列举出的路径条数将是一个非常庞大的数目。如果用贪心法又往往得不到最优解。在用动态规划考虑数塔问题时可以自顶向下的分析,自底向上的计算。

        从顶点出发时到底向左走还是向右走应取决于是从左走能取到最大值还是从右走能取到最大值,只要左右两道路径上的最大值求出来了才能作出决策。同样的道理下一层的走向又要取决于再下一层上的最大值是否已经求出才能决策。这样一层一层推下去,直到倒数第二层时就非常明了。所以实际求解时,可从底层开始,层层递进,最后得到最大值。

        状态转移方程:dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+a[i][j];

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <cstring>

  4. #define maxn 105

  5. using namespace std;

  6. int n;

  7. int a[maxn][maxn];

  8. int dp[maxn][maxn]; //自底向上,记录从点(i,j)出发到数塔底层的路径最大和

  9. int main()

  10. {

  11. int i,j;

  12. scanf("%d",&n);

  13. for(i=0;i<n;i++)

  14. for(j=0;j<=i;j++)

  15. scanf("%d",&a[i][j]);

  16. memset(dp,0,sizeof(dp));

  17. for(i=0;i<n;i++) //填数塔最底层

  18. dp[n-1][i]=a[n-1][i];

  19. for(i=n-2;i>=0;i--) //更新除数塔最底层外的各个点的路径最大和

  20. for(j=0;j<=i;j++)

  21. dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+a[i][j];

  22. printf("%d\n",dp[0][0]);

  23. return 0;

  24. }

2. 序列DP

(1)最长上升子序列LIS

        输入n及一个长度为n的数列,求出此序列的最长上升子序列长度。上升子序列指的是对于任意的i<j都满足ai<aj的子序列。(1<=n<=1000,0<=ai<=1000000)

样例输入:

5

4 2 3 1 5

样例输出:

3(最长上升子序列为2, 3, 5)

【分析】

法一:定义dp[i]: 以ai为末尾的最长上升子序列的长度。以ai结尾的上升子序列是:

        1° 只包含ai的子序列

        2° 在满足j<i且aj<ai的以aj结尾的上升子列末尾,追加上ai后得到的子序列

        这二者之一。这样就能得到如下递推关系:

        dp[i]=max{1, dp[j]+1 | j<I 且aj<ai},复杂度为O(n2)。

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #define maxn 1005

  4. using namespace std;

  5. int n,a[maxn];

  6. int dp[maxn]; //dp[i]记录以a[i]为末尾的最长上升子序列的长度

  7. int main()

  8. {

  9. int i,j;

  10. int ret;

  11. while(scanf("%d",&n)!=EOF)

  12. {

  13. for(i=1;i<=n;i++)

  14. {

  15. scanf("%d",&a[i]);

  16. dp[i]=1; //初始化

  17. }

  18. ret=1;

  19. for(i=1;i<=n;i++)

  20. {

  21. for(j=1;j<i;j++) //遍历所有在a[i]之前的元素

  22. {

  23. if(a[j]<a[i]) //若存在aj<ai,则在以aj为结尾的上升子列末尾追加ai后得到的子序列 和 只包含ai的子序列中取长度较大者

  24. dp[i]=max(dp[i],dp[j]+1);

  25. }

  26. ret=max(ret,dp[i]); //注意随时更新ret

  27. }

  28. printf("%d\n",ret);

  29. }

  30. return 0;

  31. }

法二:此外,还可以定义其它的递推关系。前面利用DP求取针对最末位的元素的最长的子序列。如果子序列的长度相同,那么最末位的元素较小的在之后会更加有优势,所以我们再反过来用DP针对相同长度情况下最小的末尾元素进行求解:

        dp[i]: 长度为i+1的上升子序列中末尾元素的最小值(不存在的话就是INF)。

        过程分析:最开始全部dp[i]的值都初始化为INF,然后由前到后逐个考虑数列的元素。对于每个aj,如果i=0或dp[i-1]<aj的话,就用dp[i]=min(dp[i], aj)进行更新。最终找出使得dp[i]<INF的最大的i+1就是结果。

        此DP直接实现的话,能够与前面的方法一样在O(n2)的时间内给出结果,但这一算法还可以进一步优化:首先dp数列中除INF之外是单调递增的,所以可以知道对于每个aj最多只需要1次更新。对于这次更新究竟应在什么位置,不必逐个遍历,可以利用二分搜索,这样就可以在O(nlogn)时间内求出结果。

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <algorithm>

  4. #define maxn 1005

  5. #define INF 99999999

  6. using namespace std;

  7. int n,a[maxn];

  8. int dp[maxn]; //dp[i]:长度为i+1的上升子序列中末尾元素的最小值(不存在的话就是INF)

  9. int main()

  10. {

  11. int i,j;

  12. scanf("%d",&n);

  13. for(i=0;i<n;i++)

  14. scanf("%d",&a[i]);

  15. fill(dp,dp+n,INF); //初始化dp数组为INF

  16. for(i=0;i<n;i++) //找到更新dp[i]的位置并用a[i]更新之

  17. {

  18. *lower_bound(dp,dp+n,a[i])=a[i];

  19. for(j=0;j<n;j++) //观察dp数组的填充过程

  20. printf("%d ",dp[j]);

  21. printf("\n");

  22. }

  23. printf("%d\n",lower_bound(dp,dp+n,INF)-dp); //第一个INF出现的位置即为LIS长度

  24. return 0;

  25. }

        拓展:使用upper_bound和lower_bound两个函数求长度为n的有序数组a中的k的个数

        upper_bound(a, a+n, k)-lower_bound(a, a+n, k);

        另外,求最长下降/不上升/不下降子序列思路同此题,只是判断条件有变化。

(2)最长公共子序列LCS

        给定两个字符串s1和s2(长度均不超过1000),求出这两个字符串的最长公共子序列的长度。

【分析】定义dp[i][j]:串s1的前i个字符 和 串s2的前j个字符的最长公共子序列长度,则s1…si+1和t1…tj+1对应的公共子列可能是:

        ①si+1=tj+1时:在s1…si 和 t1…tj的公共子列末尾追加si+1(即LCS长度+1)

        ②否则可能为s1…si和t1…tj+1的公共子列长度l1 或s1…si+1和t1…tj的公共子列长度l2,二者取较大者。

        故状态转移方程为:

        dp[i+1][j+1]=dp[i][j]+1,                                      

                                max(dp[i][j+1], dp[i+1][j]),        

        最后dp[len1][len2]即为所求,其中len1、len2分别为串s1和s2的长度。

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <cstring>

  4. using namespace std;

  5. const int maxlen=1010;

  6. char s1[maxlen],s2[maxlen];

  7. int dp[maxlen][maxlen]; //dp[i][j]记录串s1的前i个字符和串s2的前j个字符的LCS长度

  8. int main()

  9. {

  10. int i,j;

  11. int len1,len2;

  12. while(scanf("%s",s1)!=EOF)

  13. {

  14. scanf("%s",s2);

  15. len1=strlen(s1);

  16. len2=strlen(s2);

  17. dp[0][0]=0; //初始化:两串均为空时,len(LCS)=0

  18. for(i=1;i<=len1;i++)//s2串为空时,不论s1中有多少字符,len(LCS)=0

  19. dp[i][0]=0;

  20. for(i=1;i<=len2;i++)//s1串为空时,不论s1中有多少字符,len(LCS)=0

  21. dp[0][i]=0;

  22. for(i=0;i<len1;i++)

  23. {

  24. for(j=0;j<len2;j++)

  25. {

  26. if(s1[i]==s2[j]) //s1与s2对应位置字符相等

  27. dp[i+1][j+1]=dp[i][j]+1;

  28. else //其它情况:两者取较大者

  29. dp[i+1][j+1]=max(dp[i][j+1],dp[i+1][j]);

  30. }

  31. }

  32. /*for(i=1;i<=len1;i++)

  33. {

  34. for(j=1;j<=len2;j++)

  35. printf("dp[%d][%d]=%d ",i,j,dp[i][j]);

  36. printf("\n");

  37. }*/

  38. printf("%d\n",dp[len1][len2]);

  39. }

  40. return 0;

  41. }

(3)最大公共子串LCS

        给定两个字符串s1和s2(长度均不超过1000),求出这两个字符串的最大公共子串的长度。

【分析】情境类似求最长公共子序列长度问题,不过需要注意的是:所求子串中的字符需要在串s1和串s2中连续出现。

        例:s1=”abcad”

                s2=”abd”

        它们的最长公共子序列长度为3(”abd”),而最大公共子串长度为2(”ab”)。

        因此,定义dp[i][j]:串s1的前i个字符 和 串s2的前j个字符的最大公共子串长度,则s1…si+1和t1…tj+1对应的公共子串可能是:

        ①si+1=tj+1时:在s1…si 和 t1…tj的公共子串末尾追加si+1(即LCS长度+1)

        ②否则dp[i][j]=0

        分析可知状态转移方程:

        dp[i+1][j+1]=dp[i][j]+1,                     

                                0,                                   

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <cstring>

  4. using namespace std;

  5. const int maxlen=1010;

  6. char s1[maxlen],s2[maxlen];

  7. int dp[maxlen][maxlen]; //dp[i][j]为串s1的前i个字符和串s2的前j个字符的最大公共子串长度

  8. int main()

  9. {

  10. int i,j;

  11. int len1,len2,ret; //ret记录结果

  12. while(scanf("%s",s1)!=EOF)

  13. {

  14. scanf("%s",s2);

  15. memset(dp,0,sizeof(dp)); //初始化:开始LCS长度均为0

  16. len1=strlen(s1);

  17. len2=strlen(s2);

  18. ret=0;

  19. for(i=0;i<len1;i++)

  20. {

  21. for(j=0;j<len2;j++)

  22. {

  23. if(s1[i]==s2[j])

  24. dp[i+1][j+1]=dp[i][j]+1;

  25. else

  26. dp[i+1][j+1]=0;

  27. ret=max(ret,dp[i+1][j+1]); //随时更新最大值

  28. }

  29. }

  30. printf("%d\n",ret);

  31. }

  32. return 0;

  33. }

五、练习

1. 拦截导弹(Noip1999)

  某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。

  输入导弹数n及n颗导弹依次飞来的高度(雷达给出的高度数据是不大于30000的正整数,导弹数不超过1000),计算这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。

样例输入:

8

389 207155 300 299 170 158 65

样例输出:

6(最多能拦截的导弹数)

2(要拦截所有导弹最少要配备的系统数)

【分析】DP+贪心法

        第一问即经典的最长不下降子序列问题,可以用一般的DP算法,也可以用高效算法(求LIS问题的法二),但这个题的数据规模不需要。

  第二问用贪心法即可。每颗导弹来袭时,使用能拦截这颗导弹的防御系统中上一次拦截导弹高度最低的那一套来拦截。若不存在符合这一条件的系统,则使用一套新系统。

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <cstring>

  4. using namespace std;

  5. const int maxn=1010;

  6. int n;

  7. int dp[maxn]; //dp[i]记录前i发导弹中最多拦截的颗数

  8. struct shell //炮弹

  9. {

  10. int height; //高度

  11. int is_catched; //是否被拦截

  12. } s[maxn];

  13. int main()

  14. {

  15. int i,j;

  16. int cur; //cur记录当前导弹高度

  17. int maxans,minret; //最多拦截的导弹数.拦截导弹最少配备的系统数

  18. while(scanf("%d",&n)!=EOF)

  19. {

  20. for(i=1;i<=n;i++)

  21. {

  22. scanf("%d",&s[i].height);

  23. s[i].is_catched=0;

  24. dp[i]=1;

  25. }

  26. maxans=1,minret=0;

  27. for(i=1;i<=n;i++)

  28. {

  29. for(j=1;j<i;j++)

  30. {

  31. if(s[j].height>=s[i].height)

  32. dp[i]=max(dp[i],dp[j]+1);

  33. }

  34. maxans=max(maxans,dp[i]);

  35. if(s[i].is_catched==1)

  36. continue;

  37. cur=s[i].height;

  38. minret++;

  39. for(j=i+1;j<=n;j++)

  40. {

  41. if(s[j].height<=cur)

  42. {

  43. cur=s[j].height;

  44. s[j].is_catched=1;

  45. }

  46. }

  47. }

  48. printf("%d\n",maxans);

  49. printf("%d\n",minret);

  50. }

  51. return 0;

  52. }

2. 合唱队形

  N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学排成合唱队形。

  合唱队形是指这样的一种队形:设K位同学从左到右依次编号为1, 2, …, K,他们的身高分别为T1, T2, …, TK,则他们的身高满足T1< T2 < … < Ti , Ti > Ti+1> … > TK (1≤i≤K)。

  你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。

输入:

  输入的第一行是一个整数N(2 ≤ N ≤ 100),表示同学的总数。第二行有N个整数,用空格分隔,第i个整数Ti(130 ≤ Ti ≤ 230)是第i位同学的身高(厘米)。

输出:

  一行,这一行只包含一个整数,就是最少需要几位同学出列。

样例输入:

8

186 186 150 200 160 130 197 220

样例输出:

4

【分析】最长上升子序列+最长下降子序列综合

        我们按照由左而右和由右而左的顺序,将n个同学的身高排成数列。如何分别在这两个数列中寻求递增的、未必连续的最长子序列,就成为问题的关键。设:

  a为身高序列,其中a[i]为同学i的身高;

  b为由左而右身高递增的人数序列,其中 b[i]为同学1‥同学i间(包括同学i)身高满足递增顺序的最多人数。显然b[i]=max{b[j]|同学j的身高<同学i的身高}+1;

  c为由右而左身高递增的人数序列,其中c[i]为同学n‥同学i间(包括同学i)身高满足递增顺序的最多人数。显然c[i]=max{c[j]|同学j的身高<同学i的身高}+1;

  由上述状态转移方程可知,计算合唱队形的问题具备了最优子结构性质(要使b[i]和c[i]最大,子问题的解b[j]和c[k]必须最大(1≤j≤i-1,i+1≤k≤n))和重迭子问题的性质(为求得b[i]和c[i],必须一一查阅子问题的解b[1]‥b[i-1]和c[i+1]‥c[n]),因此可采用动态程序设计的方法求解。

  显然,合唱队的人数为max{b[i]+c[i]}-1(公式中同学i被重复计算,因此减1),n减去合唱队人数即为解。

 
  1. #include <iostream>

  2. #include <cstdio>

  3. #include <cstring>

  4. using namespace std;

  5. const int maxn=105;

  6. int N,a[maxn];

  7. //idp[i]:以ai为末尾的最长上升子序列长度 ddp[i]:以ai为开头的最长下降子序列长度

  8. int idp[maxn],ddp[maxn];

  9. int main()

  10. {

  11. int i,j;

  12. int ret; //ret记录ai左侧LIS和右侧LDS的长度和

  13. while(scanf("%d",&N)!=EOF)

  14. {

  15. for(i=1;i<=N;i++)

  16. {

  17. scanf("%d",&a[i]);

  18. idp[i]=ddp[i]=1;

  19. }

  20. ret=0;

  21. for(i=1;i<=N;i++) //顺推,求maxlis

  22. {

  23. for(j=1;j<i;j++)

  24. {

  25. if(a[j]<a[i])

  26. idp[i]=max(idp[i],idp[j]+1);

  27. }

  28. }

  29. for(i=N;i>=1;i--) //逆推,求maxlds

  30. {

  31. for(j=i+1;j<=N;j++)

  32. {

  33. if(a[j]<a[i])

  34. ddp[i]=max(ddp[i],ddp[j]+1);

  35. }

  36. }

  37. for(i=1;i<=N;i++) //注意每个a[i]计算了两次

  38. {

  39. printf("%d %d\n",idp[i],ddp[i]);

  40. if(idp[i]+ddp[i]>ret)

  41. ret=idp[i]+ddp[i];

  42. }

  43. printf("%d\n",N-ret+1); //这里N-ret+1即为结果,注意加1

  44. }

  45. return 0;

  46. }

猜你喜欢

转载自blog.csdn.net/yuebaba/article/details/81483362