动态规划专题
摘要:本文先从例子出发,讲解动态规划的一个实际例子,然后再导出动态规划的《运筹学》定义和一般解法。接着运用《运筹学》中的阶段、状态和状态转移方程三个关键词来分析例2的解法。紧接着又给出了《算法导论》中动态规划的定义和一般解法,并运用《算法导论》中的最优子结构、子问题重叠、自下而上三个关键词来分析例3.并比较了这两种做法的优劣。最后列举了几个例子,并给出了部分实现代码。适合初学者学习动态规划。
例1 整钱划分问题
问题描述:我们有面值为1元、3元和5元的硬币若干枚,如何用最少的硬币凑够11元?
问题分析:为什么是凑够11元,而不是其他的数目(比如说是10元、9元等等)?这里将11改成10元,改变问题的本质吗?深入思考一下发现,我们将问题抽象出来,如何用最少的硬币凑够i(i<11)元,i的取值只是决定了问题的解的规模,而没有改变的问题的本质。于是我们从i=0开始说起。
我们规定:d(i)=j;表示i元至少需要j个硬币。
i=0,
显然只需要0个硬币可以凑成0元,即有d(0)=0;
i=1,
要凑够一元,我们只能使用1元的硬币,即d(1)=d(0)+1=1;
I=2,
要凑够两元,我们还是只能使用1元的硬币,即d(2)=d(1)+1=2;
I=3,
要凑够三元,就有两种情况了,我们既可以使用1元的硬币,同时也可以使用3元的硬币,于是有d(3)=d(3-3)+1=1(使用3元的硬币);还可以有:d(3)=d(3-1)+1=d(2)+1=3;通过比较这两种方法,发现通过使用3元硬币使得使用的硬币数目最少,所以d(3)=1;
I=4,
要凑够4元,可以使用3元硬币,也可以使用1元硬币,如果使用3元硬币:d(4)=d(4-3)+1=d(1)+1=2;如果使用1元硬币:d(4)=d(4-1)+1=d(3)+1=2;由此可得d(4)=2;
I=5,
要凑够5元,
使用1元硬币:d(5)=d(5-1)+1=d(4)+1=3;
使用3元硬币:d(5)=d(5-3)+1=d(2)+1=3;
使用5元硬币:d(5)=d(5-5)+1=d(0)+1=1;
于是有d(5)=1;
到此为止,我们可以找到递推公式:
( d(i)=min{d(i-vj)+1 | vj<=i && vj属于{1,3,5}})
…依次类推,可以推到d (11).
解析:我们现在回顾是如何解决这个问题的。题目要求我们求解11元的最小分解时,我们没有直接去求解d(11),而是将这个问题分解成了许多相同(或相似)的子问题。而这些子问题的解的方式相同(或相似)。同时注意到,这些子问题的解具有层次性,一个子问题的解需要用到其他子问题的解。说到这里,我们似乎对动态规划有了一个初步认识。
动态规划 (基于《运筹学》)
- 1. 什么是动态规划?
《运筹学》给出的定义是在多阶段决策问题中,各个阶段所采取的决策一般来说是与时间(也可能是空间,根据实际情况而定)相关的,决策依赖于当前的状态,又随即引起状态的转移,一个决策序列就是在变化的状态中产生出来的,故有”动态“的含义。因此把处理它的方法称为动态规划方法。说白了动态规划算法是按照阶段将原问题分解成一个一个的状态, 当前的状态将由上一次的状态利用状态转移方程推导出。动态规划主要需要抓住三个关键词:阶段、状态和状态转移方程。
1.1 阶段
把所给问题的过程,恰当的分解成若干个相互联系的阶段,以便能够按照一定的次序去求解。例如上面按照需要分解的钱的值(即变量i)来划分阶段。
1.2 状态
状态是问题的子问题,大部分情况下,状态之间是相关的,且状态只与前面出现的状态有关。我们需要用一张表来保留各个阶段的状态。而且状态是由底层往上推导的。
1.3状态转移方程
如何由前一个状态推导出后一个状态,这就需要状态转移方程。状态转移方程对于所有的子问题来说是通用的。状态转移方程是动态规划的核心内容,它表现了如何利用前面的状态进行决策的过程。
乘热打铁,我们运用上面的方法可以进行更加深入的讨论了,以上的例1是一维的动态规划,下面我们将介绍如何解决二维的动态问题。
例2 二维动态规划问题
问题描述:在M*N个格子里,每个格子装着若干个苹果,用A[i][j]表示(i,j)位置的苹果数目。你从左上角的格子开始, 每一步只能向下走或是向右走,每次走到一个格子上就把格子里的苹果收集起来, 这样下去,你最多能收集到多少个苹果。
问题分析:按照《运筹学》对动态规划的阐述,我们主要抓住三点:阶段、状态、状态转移方程。题目求解的是最多能够收集到多少个苹果,没有走动的步骤限制,即理论上可以到达任意一个格子的位置。紧接着考虑什么时候收集的苹果数目最多?当然是走到(M,N)位置的时候,即右下角时候收集的苹果数目最多。再接着考虑,怎样走到右下角?可以从(M-1,N)的位置往下走一行或者从(M,N-1)的位置往右走一行即可到达(M,N)位置,那么怎么取舍呢?当然是选择这两种方式中苹果比较多的一个位置来移动到(M,N)位置。这样分析而来就可以很容易知道状态和状态转移方程分别是什么了。
(a)位置(m,n)表示目前所处的阶段;
(b)D(m,n)表示走到(m,n)时能够收集到的最多的苹果数目,为状态;
(c)D(m,n)=max{D(m-1,n)(m>0);D(m,n-1)(n>0)}+A(m,n)为状态转移方程。
根据初始状态和状态转移方程,可以得到递归版本的解见Program-2-1:递归版本(java)。同时我们提供了一个非递归版本的解法,见Program-2-2:非递归版本(java)。读者可以先看看这两者之间的区别,后面会对递归和非递归进行详细的探讨。
Program-2-1:递归版本(java)
//这里采用的是递归解法。 public static int getMaxApples(int i,int j,int[][] maxGet,int[][] apple){ if(i==0 && j==0) maxGet[i][j]=apple[i][j]; else{ int temp1=0; int temp2=0; if(i>0) temp1=getMaxApples(i-1,j,maxGet,apple); //从上面过来 if(j>0) temp2=getMaxApples(i,j-1,maxGet,apple); //从左面过来 maxGet[i][j]=temp1>temp2? temp1+apple[i][j] : temp2+apple[i][j]; } return maxGet[i][j]; }
Program-2-2:非递归版本(java)
//非递归版本 public static int getMinNumber_(int total,int[] coins){ int minNumber[]=new int[total+1];//保存每一步骤的结果 minNumber[0]=0;//初始状态 for(int i=1;i<=total;i++){ minNumber[i]=Integer.MAX_VALUE; for(int j=0;j<coins.length;j++){ if(coins[j]<=i && minNumber[i-coins[j]]+1<minNumber[i]){ minNumber[i]=minNumber[i-coins[j]]+1; } } } return minNumber[total]; }
例2拓展:现在我们改变一下所求问题。其他条件不变,求走动K步后能够得到的最多的苹果数目。
通过例1和例2的训练,我们差不多已经知道这类问题的一般性解法了。主要抓住阶段、状态和状态转移方程三个关键词。仔细思考如何将原来的问题按照这三个标准来进行分解。笔者认为《运筹学》是一本很好的很切合实际的教材,所以将其思想列在前面,后续篇章将从《算法导论》中汲取知识,进行总结。这是本文的重点所在。
例3:装配线问题
问题描述:一个汽车公司在有2条装配线的工厂内生产汽车,每条装配线有n个装配站,不同装配线上对应的装配站执行的功能相同,但是每个站执行的时间是不同的。在装配汽车时,为了提高速度,可以在这两天装配线上的装配站中做出选择,即可以将部分完成的汽车在任何装配站上从一条装配线移到另一条装配线上。装配过程如下图所示:
装配过程的时间包括:进入装配线时间e、每装配线上各个装配站执行时间a、从一条装配线移到另外一条装配线的时间t、离开最后一个装配站时间x。举个例子来说明,现在有2条装配线,每条装配线上有6个装配站,各个时间如下图所示:
这道题看上去很复杂的样子,仔细理一理思路,还是很简单很基础的一道动态规划题目,如果运用《运筹学》的思想,很容易抽出三个关键词。
D[i,j]表示到达第i条装配线的第j个站点时所需的最短时间;那么它可能由第1条装配线的第j-1个站点而来,也可能是由第2条装配线的第j-1个站点而来,故状态转移方程为:
D[1,j]=min{D[1,j-1]+a[1,j] , D[2,j-1]+t[2,j-1]+a[1,j]}
D[2,j]=min{D[2,j-1]+a[2,j] , D[1,j-1]+t[1,j-1]+a[2,j]}
初始状态有:
D[1,1]=9;
D[2,1]=12;
最终状态有:
Dfinal=min{D[1,n]+x1 , D[2,n]+x2}。
现在,我们换一种思路,暂时忘掉阶段、状态、状态转移方程三个关键词。我们将引进最优子结构、子问题重叠、自底向上三个关键词。
动态规划(基于《算法导论》)
- 1. 动态规划定义
和分治算法一样,动态规划是通过组合子问题[1]的解而解决整个问题的。分值算法是将问题分成一些独立的子问题,递归的求解各个子问题,然后合并子问题的解而得到原问题的解。与此不同,动态规划适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题[2]。在这种情况下,若用分治算法则会做出许多不必要的工作,即重复的求解公共子问题。动态规划算法对每个子子问题只求解一次,将其结果保存在一张表[3]中,从而避免每次遇到子问题时重新计算。
[1] 组合子问题:动态规划是将原问题的解分解成若干个子问题,那么这种分解是否需要满足什么规律?-最优子结构。
[2] 各子问题包含公共的子子问题:即分解产生的若干子问题,他们的子子问题具有公共部分。-子问题重叠。
[3]结果保存在一张表:将每次得到的一个子问题的解保存在一张表中,这张表自底向上依次构建,下次遇到相同的子问题时,查阅该表即可。-自底向上。
通过上面的解析,我们下面重点讲解一下:最优子结构、子问题重叠和自底向上三个重要的关键词。
1.1 最优子结构
用动态规划求解的第一步是找出最优子结构。最如果问题的一个最优解包含了子问题的最优解,则该问题具有最优子结构。什么意思呢?拿例3来说,对于D[1,j]来说,他可以是前面一个1号线装配站[1,j-1]过来的,也可以是2号线装配站[2,j-1]过来的。有且仅有这两种选择。我们假设选择了k号线过来的装配站,那么对于装配站来说[k,j-1]也必须是耗时最短的。因为如果存在另外一条线路使得[k,j-1]的耗时更短,我们就选择更短的那个耗时,而不是原来的那个。(说起来有点拗口,但是原理和贪心算法类似)
1.2 子问题重叠
子问题重叠是动态规划算法区别于分治算法的重要原因。子问题重叠的意思是不同子问题可能会用到相同的子子问题。例如再计算D[1,6]的时候,他必然会用到D[1,2],计算D[1,5]的时候,他也必然会用到D[1,2],所以子问题D[1,6]和D[1,5]的子问题有重叠。这也是动态规划能够提高计算效率的本质原因。
1.3 自底向上
可以说这个是动态规划能够有效提高计算效率的关键技术所在。(另外一种技术是使用备忘录,也可以起到相同的效果,详细请参考《算法导论》)动态规划建表的顺序是自底向上的,由最底层的子问题开始,逐步网上推导,最终得到原问题的解。
- 2. 动态规划使用方法
上面介绍了动态规划的定义,下面简单讲解一下如何运用这种思想来解题。第一步:找到最优子结构。观察原问题,尝试修改原问题的规模,比如将11元改成10元等等,看看原问题是否可以分解,而且这种分解是否还满足最优子结构性质。如何检查问题是否满足最优子结构性质,可以采用“剪贴法”。第二步:判断子问题是否重叠。第三步:自底向上简历表格。我们得到例3的动态规划解法,见Program-3-1.
Program-3-1.非递归版(java)
public static void main(String args[]){ /*测试数据 2 4 6 7 9 3 4 8 4 8 5 6 4 5 7 2 3 1 3 4 2 1 2 2 1 3 2 */ Scanner input=new Scanner(System.in); System.out.println("请输入分别进入装配线1,2所需要的时间:"); int e1=input.nextInt(); int e2=input.nextInt(); System.out.println("请输入装配线的机器数目:"); int N=input.nextInt(); System.out.println("请输入1.2 装配线机器加工时间"); int a[][]=new int[2][N]; //每台机器装配所需时间 for(int i=0;i<N;i++) a[0][i]=input.nextInt(); for(int i=0;i<N;i++) a[1][i]=input.nextInt(); int t[][]=new int[2][N-1]; //交换装配线所需的转移时间 System.out.println("请输入转移装配线所需时间:"); for(int i=0;i<N-1;i++) t[0][i]=input.nextInt(); for(int i=0;i<N-1;i++) t[1][i]=input.nextInt(); System.out.println("请输入输出装配线所需时间:"); int x1=input.nextInt(); int x2=input.nextInt(); //函数主程序 int d[][]=new int[2][N];//存储到达[i,j]装配站时的最小时间 int d_final=0; //存储最终所需最小时间 //初始状态 d[0][0]=e1+a[0][0]; d[1][0]=e2+a[1][0]; //状态转移方程 for(int j=1;j<N;j++){ //修改d[0][j] d[0][j]=d[0][j-1]<d[1][j-1]+t[1][j-1] ? d[0][j-1]+a[0][j] : d[1][j-1]+t[1][j-1]+a[0][j]; //修改d[1][j] d[1][j]=d[1][j-1]<d[0][j-1]+t[0][j-1] ? d[1][j-1]+a[1][j] : d[0][j-1]+t[0][j-1]+a[1][j]; } d_final=d[0][N-1]+x1<d[1][N-1]+x2 ? d[0][N-1]+x1 : d[1][N-1]+x2; System.out.println("所需最小时间为:"+d_final); }
《运筹学》和《算法导论》
看到这里,我们可以做一个简单的总结。
《运筹学》从阶段、状态、状态转移方程三个关键词来描述问题、建立模型和解决问题;《算法导论》从最优子结构、子问题重叠、自下而上三个关键词来描述问题、建立模型和解决问题。这两者之间有什么联系和区别呢?仔细想想就可以发现确实有一一对应关系。状态对应子问题,状态转移方程对应最优子结构!他们是对同一类问题从不同的角度出发去解决问题,都有自己的优缺点。笔者认为《运筹学》从细节出发,去发现采用什么样的粒度将问题分解比较合适,注重问题分解的过程。而《算法导论》一上来就是先将问题分解,然后再思考怎么将这些分解的问题自底向上合并起来。此外,算法导论还说明了如果问题不满足最优子结构,则不能使用动态规划,这种说法非常严谨,而运筹学没有强调这个关键性问题。
既然《算法导论》更为严谨,那笔者为什么还要介绍《运筹学》呢?因为笔者脑子不够用啊(= =||),确实如此,如果接触不多动态规划,谁能够第一眼就看出最优子结构?(对于接下来笔者要解析的例4和例5,读者可以自己尝试一下)如果我们按照运筹学的思想,将问题抽丝剥茧,分成阶段、状态,进而找到状态转移方程,岂不是很好的一种入门手段?
所以说,对于初学者来说,可以先利用《运筹学》的思想,来找到状态转移方程(最优子结构),然后再利用《算法导论》思想,讲其转换成非递归的自下而上逐层建表的模式。我们将在例4详细阐述这种做法。
例4. 矩阵链乘法
问题描述:给定n个矩阵构成的一个链<A1,A2,A3,…An>,矩阵Ai为pi-1*pi维数(i=1,2,3,…n),对乘积A1A2A3…An以一种最小化标量乘法次数的方式进行加全部括号。
问题分析:对于n个矩阵相乘,他们是怎么进行运算的呢?举个例子来说,当两个矩阵相乘时,只有一种运算方式,直接相乘即可。当三个矩阵相乘时,可以先将前两个矩阵相乘,然后再与第三个矩阵相乘,也可以先将后两个矩阵相乘,然后再与第一个矩阵相乘。可见随着矩阵数量的增加,这种相乘的顺序会急剧增长。如何采用一种相乘方式来使得乘法运算总次数最少呢?这么一分析,我们已经找到了状态。
必须注意到这样一个现象:对于任意的n>1,他都可以分解成两个矩阵的乘积。具体来说对于矩阵链A1A2A3…An来说,它可以分解成(A1A2A3…Ak)和(Ak+1AK+2Ak+3…An)这两个矩阵的乘积,其中k=1,2,…n-1.为了叙述简便,我们记
Ai…j表示AiAi+1Ai+2…Aj。
M[i,j]表示Ai…j所需最少的乘法运算次数。
那么要使得A1…n乘法运算次数最少,这里的k必须使得A1…k 和 Ak+1…n 乘法运算次数之和加上p0pk-1pn(表示分解成A1…k和Ak+1…n后的两个矩阵相乘时乘法运算次数)的值最少。要求得最优的M[1,n],则对于分解后的A1…k和 Ak+1…n来说,他们的子分解也必须是最优的。于是有M[1,n]=min{M[1,k]+M[k+1,n]+ p0pk-1pn}(1<=k<n)。这样我们就找到了最优子结构,同时也找到了状态转移方程。
于是就得到了递归方程:
根据递归方程,我们可以得到一个递归解(见Program-4-1)。但是需要注意的是,如果用递归来解决这个题目,就违背了动态规划的本质。动态规划是递归,递归不一定是动态规划。这就是动态规划和递归的本质区别。动态规划强调的是自底向上构建一个表,遇到重叠的子问题,直接查找表格即可,而不是再次的去计算。
那么如何构建这样的一个表呢?
图1.
如图1所示,我们从最底层出发,逐层往上求解,即可求得【1,6】的值(见Program-4-2)。
Program-4-1:递归版本(java)
View Code
Program-4-2:非递归版本(java)
View Code
例5:最长公共子序列(LCS)
问题描述:给定两个序列X=<x1,x2,x3,…,xm>和Y=<y1,y2,y3,…,yn>,找出X和Y的最大长度公共子序列。
问题分析:这道题第一次看到的时候,不知道该怎么下手。首先我们来看看最大长度公共子序列的性质:
假设Z=<z1,z2,z3,…,zk>是X和Y的任意一个最大长度公共子序列,则
(1) 若xm= yn ,则有Zk-1是Xm-1和Yn-1的一个最大长度公共子序列。
(2) 若xm yn ,则由zk yn可以得出Zk是Xm和Yn-1的一个最大长度子序列
(3) 若xm yn ,则由zk xm可以得出Zk是Xm-1和Yn的一个最大长度子序列
简单解释一下这三条性质的含义。
(1) 第一条的意思是如果X和Y的末尾含有相同元素,则此相同元素一定在最长共公共子序列中,那么X、Y和Z就可以同时减掉最后一个相同元素。
(2) 第二条和第三条是说如果X和Y的末尾元素不相同,那么Z的末尾元素不可能同时和xm,yn相等。(即有三种情况:(a)zk yn,zk=xm,(b)zk xm ,zk yn,(c)zk yn,zk xm)果Z末尾元素不是xm,则可以将xm从X的末尾移除而不影响结果;同样的道理适用于Y。
有了这些性质,我们该怎样运用呢?即未知变量该如何设置?这个未知变量要能够同时包含X和Y的相关信息,设M[i,j]表示X=< x1,x2,x3,…,xi >和Y=<y1,y2,y3,…,yj>的最大公共子串的长度。我们将上面的性质翻译成数学符号:
根据递归式,可以得到一个递归解(见Program-5-1)。
Program-5-1(java)
View Code
同时,我们继续思考如何构建一个表格来建立自下而上的求解过程。这个表的建立过程类似于例2,如图2所示。
图2.
如图展示了长度为4的字符串和长度为3的字符串计算子问题时的顺序。由第一层出发,逐层往上计算。具体代码见Program-5-2
Program-5-2(java)