首页 > 技术文章 > 什么是动态规划

gofighting 2016-04-27 15:31 原文

 

动态规划( D ynamic P rogramming ,所以我们简称动态规划为 DP )是 运筹学 的一个分支,是求解决策过程(decision process) 最优化的数学方法。 20 世纪 50 年代初 美国 数学家R.E.Bellman 等人在研究多阶段决策过程 (multistep decision process) 的优化问题时,提出了著名的最优化原理 (principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法 —— 动态规划。 1957 年出版了他的名著《 Dynamic Programming 》,这是该领域的第一本著作。

动态规划算法通常基于一个递推公式及一个或多个初始状态。当前子问题的解将由上一次子问题的解推出。使用动态规划来解题只需要多项式时间复杂度,因此它比回溯法、暴力法等要快许多。

说了这么多术语,想必大家都很头疼, 现在让我们通过一个例子来了解一下DP 的基本原理。

首先,我们要找到某个状态的最优解,然后在它的帮助下,找到下一个状态的最优解。 这句话暂时理解不了没关系,请看下面的例子 :

如果我们有面值为1 元、 3 元和 5 元的硬币若干枚,如何用最少的硬币凑够 11 元?

我们凭直观感觉告诉自己,先选面值最大,因此最多选 2枚 5 元的硬币,现在是 10 元了,还差一元,接下来我们挑选第二大的 3 元硬币,发现不行( 10+3=13 超了),因此我们继续选第三大的硬币也就是 1元硬币,选一个就可以( 10+1=11 ),所以总共用了 3 枚硬币凑够了 11 元。这就是贪心法,每次选最大的。但是我们将面值改为 2 元, 3 元和 5 元的硬币,再用贪心法就不行了。为什么呢?按照贪心思路,我们同样先取 2 枚最大 5 元硬币,现在 10 元了,还差一元,接下来选第二大的,发现不行,再选第三大的,还是不行,这时用贪心方法永远凑不出 11 元,但是你仔细看看,其实我们可以凑出 11 元的, 2 枚 3元硬币和 1 枚五元硬币就行了,这是人经过思考判断出来了的,但是怎么让计算机算出来呢?这就要用动态规划的思想:

首先我们思考一个问题,如何用最少的硬币凑够i 元 (i<11) ?为什么要这么问呢?两个原因: 1. 当我们遇到一个大问题时,总是习惯把问题的规模变小,这样便于分析讨论。 2. 这个规模变小后的问题和原来的问题是同质的,除了规模变小,其它的都是一样的,本质上它还是同一个问题 ( 规模变小后的问题其实是原问题的子问题 ) 。

好了,让我们从最小的i 开始吧。当 i=0 ,即我们需要多少个硬币来凑够 0 元。由于 1 , 3 , 5 都大于 0,即没有比 0 小的币值,因此凑够 0 元我们最少需要 0 个硬币。 ( 这个分析很傻是不是?别着急,这个思路有利于我们理清动态规划究竟在做些什么。 ) 这时候我们发现用一个标记来表示这句 “ 凑够 0 元我们最少需要 0 个硬币。 ” 会比较方便,如果一直用纯文字来表述,不出一会儿你就会觉得很绕了。那么,我们用 d(i)=j 来表示凑够 i 元最少需要 j 个硬币。于是我们已经得到了 d(0)=0 ,表示凑够 0 元最小需要 0 个硬币。当 i=1 时,只有面值为 1 元的硬币可用,因此我们拿起一个面值为 1 的硬币,接下来只需要凑够 0 元即可,而这个是已经知道答案的,即 d(0)=0 。所以, d(1)=d(1-1)+1=d(0)+1=0+1=1 。当 i=2 时,仍然只有面值为 1 的硬币可用,于是我拿起一个面值为 1 的硬币,接下来我只需要再凑够 2-1=1 元即可 ( 记得要用最小的硬币数量 ) ,而这个答案也已经知道了。所以 d(2)=d(2-1)+1=d(1)+1=1+1=2 。一直到这里,你都可能会觉得,好无聊,感觉像做小学生的题目似的。因为我们一直都只能操作面值为 1 的硬币!耐心点,让我们看看 i=3 时的情况。当 i=3 时,我们能用的硬币就有两种了: 1 元的和 3 元的 ( 5 元的仍然没用,因为你需要凑的数目是 3 元! 5 元太多了亲 ) 。既然能用的硬币有两种,我就有两种方案。如果我拿了一个 1 元的硬币,我的目标就变为了:凑够 3-1=2 元需要的最少硬币数量。即 d(3)=d(3-1)+1=d(2)+1=2+1=3 。这个方案说的是,我拿 3 个 1 元的硬币;第二种方案是我拿起一个 3 元的硬币,我的目标就变成:凑够 3-3=0 元需要的最少硬币数量。即 d(3)=d(3-3)+1=d(0)+1=0+1=1. 这个方案说的是,我拿 1 个 3 元的硬币。好了,这两种方案哪种更优呢?记得我们可是要用最少的硬币数量来凑够 3元的。所以,选择 d(3)=1 ,怎么来的呢?具体是这样得到的: d(3)=min{d(3-1)+1, d(3-3)+1} 。

OK,码了这么多字讲具体的东西,让我们来点抽象的。从以上的文字中,我们要抽出动态规划里非常重要的两个概念:状态和状态转移方程。

上文中d(i) 表示凑够 i 元需要的最少硬币数量,我们将它定义为该问题的 " 状态 " ,这个状态是怎么找出来的呢?根据子问题定义状态。你找到子问题,状态也就浮出水面了。最终我们要求解的问题,可以用这个状态来表示: d(11) ,即凑够 11 元最少需要多少个硬币。那状态转移方程是什么呢?既然我们用 d(i)表示状态,那么状态转移方程自然包含 d(i) ,上文中包含状态 d(i) 的方程是: d(3)=min{d(3-1)+1, d(3-3)+1} 。没错,它就是状态转移方程,描述状态之间是如何转移的。当然,我们要对它抽象一下,

d(i)=min{ d(i-v j )+1 },其中 i-v j >=0, v j 表示第j 个硬币的面值 ;

有了状态和状态转移方程,这个问题基本上也就解决了。当然了,Talk is cheap,show me the code!

 

[cpp] view plain copy
 
print ? 在CODE上查看代码片 派生到我的代码片
  1. int main()
  2. {
  3. int a[3] = {1,3,5},sum = 11,cent = 0,dp[12];
  4. dp[0] = 0;
  5. for ( int i = 1; i <= sum; i++) dp[i] = i; //我们假设存在1元的硬币那么i元最多只需要i枚1元硬币,当然最好设置dp[i]等于无穷大
  6. for ( int i = 1; i <= sum; i++){
  7. for ( int j = 0; j < 3; j++){
  8. if (i >= a[j] && dp[i - a[j]] + 1 < dp[i]){
  9. dp[i] = dp[i- a[j] ] + 1;
  10. }
  11. }
  12. }
  13. cout<<dp[sum]<<endl;
  14. return 0;
  15. }


 

 

下图是当i 从 0 到 11 时的解:

 

 

从上图可以得出,要凑够11 元至少需要 3 枚硬币。

此外,通过追踪我们是如何从前一个状态值得到当前状态值的,可以找到每一次我们用的是什么面值的硬币。比如,从上面的图我们可以看出,最终结果d(11)=d(10)+1( 面值为 1) ,而 d(10)=d(5)+1( 面值为 5),最后 d(5)=d(0)+1 ( 面值为 5) 。所以我们凑够 11 元最少需要的 3 枚硬币是: 1 元、 5 元、 5 元。

 

通过硬币问题我们初识 DP的原理,其实可以说贪心问题是 DP 问题的特例,现在我们通过几道题目加深对 DP 问题的理解

数塔问题 是动态规划经典的题目,下面来初步讲解下

将一个由N 行数字组成的三角形,如图所以,设计一个算法,计算出三角形的由顶至底的一条路径,使该路径经过的数字总和最大。

学弟学妹们你们之前学过 DFS和 BFS ,第一眼看过去这题应该用 DFS 解决,没错, DFS 也可以,但是我们观察下 n 行总共有 (1 + 2 + 3 + 4+...+n) = ( 1+n ) *n/2 个节点,在递归求解的过程中很多节点被重复访问了,这就导致时间大大增加,必然超时

比如用递归的话,18 这个节点被访问了两次

但是如果用DP的话这个节点可以只访问一次

 

好了,现在我们用DP解决这道问题

 

将上图转化一下:

 

 

假设上图用map[][] 数组保存。

令f[i][j] 表示从顶点 (1, 1) 到顶点 (i, j) 的最大值。

则可以得到状态转移方程:

f[i][j] = max(f[i+1][j], f[i+1][j+1]) + map[i][j]

此题既适合自顶而下的方法做,也适合自底而上的方法,

当用自顶而下的方法做时,最后要在在最后一列数中找出最大值,

而用自底而上的方法做时,f[1][1] 即为最大值。

所以我们将图2 根据状态转移方程可以得到图 3 :

 

 

最大值是30.

代码如下:

 

[cpp] view plain copy
 
print ? 在CODE上查看代码片 派生到我的代码片
  1. 1 #include <cstdio>
  2. 2. #include <iostream>
  3. 3. #include <algorithm>
  4. 4. #include <cstring>
  5. 5. using namespace std;
  6. 6. int a[2000][2000];
  7. 7. int main()
  8. 8. {
  9. 9. int t,n,i,j;
  10. 10. while (~scanf( "%d" ,&n))
  11. 11.     {
  12. 12. for (i=0; i<n; i++)
  13. 13. for (j=0; j<=i; j++)
  14. 14.                 scanf("%d" ,&a[i][j]);
  15. 15. for (i=n-1; i>0; i--)
  16. 16. for (j=0; j<i; j++)
  17. 17.                 a[i-1][j]+=max(a[i][j],a[i][j+1]);
  18. 18.         printf("%d\n" ,a[0][0]);
  19. 19.     }
  20. 20. return 0;
  21. 21. }


 

 

上面讨论了 两个 非常简单的例子。现在让我们来看看对于更复杂的问题,如何找到状态之间的转移方式(即找到状态转移方程 ) 。为此我们要引入一个新词叫递推关系来将状态联系起来 ( 说的还是状态转移方程)

OK,上例子,看看它是如何工作的。

一个序列有N 个数: A[1],A[2],…,A[N] ,求出最长非降子序列的长度。 ( 讲 DP 基本都会讲到的一个问题LIS : longest increasing subsequence)

正如上面我们讲的,面对这样一个问题,我们首先要定义一个“ 状态 ” 来代表它的子问题,并且找到它的解。注意,大部分情况下,某个状态只与它前面出现的状态有关,而独立于后面的状态。

让我们沿用“ 入门 ” 一节里那道简单题的思路来一步步找到 “ 状态 ” 和 “ 状态转移方程 ” 。假如我们考虑求A[1],A[2],…,A[i] 的最长非降子序列的长度,其中 i<N ,那么上面的问题变成了原问题的一个子问题 ( 问题规模变小了,你可以让 i=1,2,3 等来分析 ) 然后我们定义 d(i) ,表示前 i 个数中以 A[i] 结尾的最长非降子序列的长度。 OK ,对照 “ 入门 ” 中的简单题,你应该可以估计到这个 d(i) 就是我们要找的状态。如果我们把 d(1) 到 d(N) 都计算出来,那么最终我们要找的答案就是这里面最大的那个。状态找到了,下一步找出状态转移方程。

为了方便理解我们是如何找到状态转移方程的,我先把下面的例子提到前面来讲。如果我们要求的这N 个数的序列是:

5, 3 , 4 , 8 , 6 , 7

根据上面找到的状态,我们可以得到:(下文的最长非降子序列都用LIS 表示)

· 前1 个数的 LIS 长度 d(1)=1( 序列: 5)

· 前2 个数的 LIS 长度 d(2)=1( 序列: 3 ; 3 前面没有比 3 小的 )

· 前3 个数的 LIS 长度 d(3)=2( 序列: 3 , 4 ; 4 前面有个比它小的 3 ,所以 d(3)=d(2)+1)

· 前4 个数的 LIS 长度 d(4)=3( 序列: 3 , 4 , 8 ; 8 前面比它小的有 3 个数,所以d(4)=max{d(1),d(2),d(3)}+1=3)

OK,分析到这,我觉得状态转移方程已经很明显了,如果我们已经求出了 d(1) 到 d(i-1) ,那么 d(i) 可以用下面的状态转移方程得到:

d(i) = max{1, d(j)+1},其中 j<i,A[j]<=A[i]

用大白话解释就是,想要求d(i) ,就把 i 前面的各个子序列中,最后一个数不大于 A[i] 的序列长度加 1 ,然后取出最大的长度即为 d(i) 。当然了,有可能 i 前面的各个子序列中最后一个数都大于 A[i] ,那么d(i)=1 ,即它自身成为一个长度为 1 的子序列。

分析完了,上图:( 第二列表示前 i 个数中 LIS 的长度,第三列表示, LIS 中到达当前这个数的上一个数的下标,根据这个可以求出 LIS 序列 )

 

代码:

 

[cpp] view plain copy
 
print ? 在CODE上查看代码片 派生到我的代码片
  1. 1. #include <cstdio>
  2. 2. #include <iostream>
  3. 3. #include <algorithm>
  4. 4. #include <cstring>
  5. 5. usingnamespace std;
  6. 6.
  7. 7. int main()
  8. 8. {
  9. 9. int dp[2000],a[2000],n;
  10. 10. while (cin>>n)
  11. 11.     {
  12. 12.         memset(dp,0,sizeof (dp));
  13. 13.         intres = 0;
  14. 14. for (inti = 0; i < n; i++) cin>>a[i];
  15. 15.
  16. 16. for (inti = 0; i < n; i++)
  17. 17.         {
  18. 18.             dp[i] = 1;
  19. 19. for (intj = 0; j < i; j++)
  20. 20.             {
  21. 21. if (a[j] < a[i])
  22. 22.                 dp[i] = max(dp[i],dp[j] + 1);
  23. 23.             }
  24. 24.
  25. 25.           res = max(res,dp[i]);
  26. 26.         }
  27. 27.
  28. 28.         cout<<res<<endl;
  29. 29.     }
  30. 30.     return0;
  31. 31. }


 

该算法的时间复杂度是O(n 2 ),并不是最优的解法。还有一种很巧妙的算法可以将时间复杂度降到O(nlogn) ,网上已经有各种文章介绍它,这里就不再赘述。此题还可以用 “ 排序 +LCS” 来解,感兴趣的话可自行 Google , Baidu 。

 

最后讲一下最长上升公共子序列问题 :

问题描述

什么是最长公共子序列呢 ? 好比一个数列S,如果分别是两个或多个已知数列的子序列,且是所有符合此条件序列中最长的,则S 称为已知序列的最长公共子序列。

举个例子,如:有两条随机序列,如 1 3 4 5 5 , and 2 4 5 5 7 6 ,则它们的最长公共子序列便是: 4 5 5。

LCS问题的解决思路

·

穷举法

·

解最长公共子序列问题时最容易想到的算法是穷举搜索法,即对 X 的每一个子序列,检查它是否也是 Y的子序列,从而确定它是否为 X 和 Y 的公共子序列,并且在检查过程中选出最长的公共子序列。 X 和 Y的所有子序列都检查过后即可求出 X 和 Y 的最长公共子序列。 X 的一个子序列相应于下标序列{1, 2, …, m} 的一个子序列,因此, X 共有 2m个不同子序列(Y 亦如此,如为 2^n ),从而穷举搜索法需要指数时间( 2^m * 2^n )。

· 动态规划算法

事实上,最长公共子序列问题也有最优子结构性质。

记:

Xi=﹤ x1 , ⋯ , xi ﹥即 X 序列的前 i 个字符 (1≤i≤m) (前缀)

Yj=﹤ y1 , ⋯ , yj ﹥即 Y 序列的前 j 个字符 (1≤j≤n) (前缀)

假定Z= ﹤ z1 , ⋯ , zk ﹥ ∈LCS(X , Y) 。

·

若 xm=yn (最后一个字符相同),则不难用反证法证明:该字符必是X 与 Y 的任一最长公共子序列 Z(设长度为 k )的最后一个字符,即有 zk = xm = yn 且显然有 Zk-1∈LCS(Xm-1 , Yn-1) 即 Z 的前缀 Zk-1是 Xm-1 与 Yn-1 的最长公共子序列 。此时,问题化归成求Xm-1 与 Yn-1 的 LCS ( LCS(X , Y)的长度等于 LCS(Xm-1 , Yn-1 )的长度加 1 )。

·

·

若 xm≠yn ,则亦不难用反证法证明:要么Z∈LCS(Xm-1, Y) ,要么 Z∈LCS(X , Yn-1) 。由于 zk≠xm 与zk≠yn 其中至少有一个必成立,若 zk≠xm 则有 Z∈LCS(Xm-1 , Y) ,类似的,若 zk≠yn 则有Z∈LCS(X , Yn-1) 。此时,问题化归成求 Xm-1 与 Y 的 LCS 及 X 与 Yn-1 的 LCS 。 LCS(X , Y) 的长度为: max{LCS(Xm-1 , Y) 的长度 , LCS(X , Yn-1) 的长度 } 。

·

由于上述当xm≠yn的情况中,求LCS(Xm-1 , Y) 的长度与 LCS(X , Yn-1) 的长度,这两个问题不是相互独立的:两者都需要求 LCS(Xm-1 , Yn-1) 的长度。另外两个序列的 LCS 中包含了两个序列的前缀的 LCS,故问题具有最优子结构性质考虑用动态规划法。

也就是说,解决这个 LCS 问题,你要求三个方面的东西:1、LCS ( Xm-1 , Yn-1 ) +1 ;2、LCS (Xm-1 , Y ), LCS ( X , Yn-1 );3、max{LCS ( Xm-1 , Y ), LCS ( X , Yn-1 ) }。

行文至此,其实对这个 LCS 的动态规划解法已叙述殆尽,不过,为了成书的某种必要性,下面,我试着再多加详细阐述这个问题。

第三节、动态规划算法解LCS 问题

3.1、最长公共子序列的结构

最长公共子序列的结构有如下表示:

设序列 X=<x1, x2, …, xm>和 Y=<y1, y2, …, yn>的一个最长公共子序列 Z=<z1, z2, …, zk>,则:

1. 若x m =y n ,则z k =x m =y n 且Z k-1 是X m-1 和Y n-1 的最长公共子序列;

2. 若x m ≠y n 且z k ≠x m , 则Z 是 X m-1 和Y 的最长公共子序列;

3. 若x m ≠y n 且z k ≠y n ,则 Z 是 X 和 Y n-1 的最长公共子序列。

其中 Xm-1=<x1, x2, …, xm-1>, Yn-1=<y1, y2, …, yn-1>, Zk-1=<z1, z2, …, zk-1>。

3、 2. 子问题的递归结构

由最长公共子序列问题的最优子结构性质可知,要找出 X=<x1, x2, …, xm>和 Y=<y1, y2, …, yn>的最长公共子序列,可按以下方式递归地进行:当 xm=yn时,找出Xm-1和Yn-1的最长公共子序列,然后在其尾部加上xm(=yn)即可得 X 和 Y 的一个最长公共子序列。当 xm≠yn时,必须解两个子问题,即找出Xm-1和Y 的一个最长公共子序列及 X 和 Yn-1的一个最长公共子序列。这两个公共子序列中较长者即为X 和 Y 的一个最长公共子序列。

由此递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如,在计算 X 和 Y 的最长公共子序列时,可能要计算出 X 和 Yn-1及Xm-1和Y 的最长公共子序列。而这两个子问题都包含一个公共子问题,即计算 Xm-1和Yn-1的最长公共子序列。

与矩阵连乘积最优计算次序问题类似,我们来建立子问题的最优值的递归关系。用 c[i,j] 记录序列 Xi和Yj的最长公共子序列的长度。其中Xi=<x1, x2, …, xi>, Yj=<y1, y2, …, yj>。当 i=0 或 j=0 时,空序列是 Xi和Yj的最长公共子序列,故c[i,j]=0 。其他情况下,由定理可建立递归关系如下:

 

代码如下 :

 

[cpp] view plain copy
 
print ? 在CODE上查看代码片 派生到我的代码片
  1. 1. #include <cstdio>
  2. 2. #include <iostream>
  3. 3. #include <algorithm>
  4. 4. #include <cstring>
  5. 5. using namespace std;
  6. 6.
  7. 7. int main()
  8. 8. {
  9. 9.     string str1,str2;
  10. 10. int dp[200][200];
  11. 11. while (cin>>str1>>str2)
  12. 12.     {
  13. 13.         memset(dp,0,sizeof (dp));
  14. 14.
  15. 15. int la = str1.length();
  16. 16. int lb = str2.length();
  17. 17.
  18. 18. for ( int i = 1; i <= la; i++)
  19. 19. for ( int j = 1; j <= lb; j++)
  20. 20.         {
  21. 21. if (str1[i - 1] == str2[j - 1])
  22. 22.             {
  23. 23.                 dp[i][j] = dp[i-1][j-1]+1;
  24. 24.             }
  25. 25. else dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
  26. 26.         }
  27. 27.         cout<<dp[la][lb]<<endl;
  28. 28.     }
  29. 29. return 0;


 

 

讲到这想必对 DP问题有一个大概的认识了吧?乘热打铁,我们去 HDU 刷几道简单题练练手感!

 

HDU2191

HDU1159

HDU1432

HDU2084

 

 

推荐阅读