动态规划初步
林厚从
引例:城市交通
有n个城市,编号1~n,有些城市之间有路相连,有些则没有,有路则当然有一个距离。现在规定只能从编号小的城市到编号大的城市,问你从编号为1的城市到编号为n的城市之间的最短距离是多少?
输入格式:先输入一个n,表示城市数,n小于100。
下面的n行是一个n*n的邻接矩阵map[i,j],其中map[i,j]=0表示城市i和城市j之间没有路相连,否则为两者之间的距离。
如:11
0 5 3 0 0 0 0 0 0 0 0
5 0 0 1 6 3 0 0 0 0 0
3 0 0 0 8 0 4 0 0 0 0
0 1 0 0 0 0 0 5 6 0 0
0 6 8 0 0 0 0 5 0 0 0
0 3 0 0 0 0 0 0 0 8 0
0 0 4 0 0 0 0 0 0 3 0
0 0 0 5 5 0 0 0 0 0 3
0 0 0 6 0 0 0 0 0 0 4
0 0 0 0 0 8 3 0 0 0 3
0 0 0 0 0 0 0 3 4 3 0
输出格式:一个数,表示最少要多少时间。
输入数据保证可以从城市1飞到城市n。
程序:
program ex;
const max=100;
var dis:array[1..max] of integer;
map:array[1..max,1..max] of integer;
n,i,j,min:integer;
begin
readln(n);
for i:=1 to n do
begin
for j:=1 to n do read(map[i,j]);
readln;
end;
dis[1]:=0;
for i:=2 to n do
begin
min:=maxint;
for j:=1 to i-1 do
if map[j,i]<>0 then
if dis[j]+map[j,i]<min then min:=dis[j]+map[j,i];
dis[i]:=min;
end;
writeln('min=',dis[n]);
end.
输出:min=13
一、动态规划简介
动态规划是运筹学的一个分支。它是解决多阶段决策过程最优化问题的一种方法。1951年,美国数学家贝尔曼(R.Bellman)提出了解决这类问题的“最优化原则”,1957年发表了他的名著《动态规划》,该书是动态规划方面的第一本著作。动态规划问世以来,在工农业生产、经济、军事、工程技术等许多方面都得到了广泛的应用,取得了显著的效果。
动态规划运用于信息学竞赛是在90年代初期,它以独特的优点获得了出题者的青睐。此后,它就成为了信息学竞赛中必不可少的一个重要方法,几乎在所有的国内和国际信息学竞赛中,都至少有一道动态规划的题目。所以,掌握好动态规划,是非常重要的。
下面,我们先通过两个具体问题认识一下“动态规划”。
例1、拦截导弹(NOIP1999)
[问题描述]
某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。
输入导弹的枚数和导弹依次飞来的高度(雷达给出的高度数据是不大于30000的正整数,每个数据之间至少有一个空格),计算这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。
[输入输出样例]
INPUT:
8
389 207 155 300 299 170 158 65
OUTPUT:
6(最多能拦截的导弹数)
2(要拦截所有导弹最少要配备的系统数)
[问题分析]
我们先解决第一问。一套系统最多能拦多少导弹,跟它最后拦截的导弹高度有很大关系。假设a[i]表示拦截的最后一枚导弹是第i枚时,系统能拦得的最大导弹数。例如,样例中a[5]=3,表示:如果系统拦截的最后一枚导弹是299的话,最多可以拦截第1枚(389)、第4枚(300)、第5枚(299)三枚导弹。显然,a[1]~a[8]中的最大值就是第一问的答案。关键是怎样求得a[1]~a[8]。
假设现在已经求得a[1]~a[7](注:在动态规划中,这样的假设往往是很必要的),那么怎样求a[8]呢?a[8]要求系统拦截的最后1枚导弹必须是65,也就意味着倒数第2枚被拦截的导弹高度必须不小于65,则符合要求的导弹有389、207、155、300、299、170、158。假如最后第二枚导弹是300,则a[8]=a[4]+1;假如倒数第2枚导弹是299,则a[8]=a[5]+1;类似地,a[8]还可能是a[1]+1、a[2]+1、……。当然,我们现在求得是以65结尾的最多导弹数目,因此a[8]要取所有可能值的最大值,即a[8]=max{a[1]+1,a[2]+1,……,a[7]+1}=max{a[i]}+1 (i=1..7)。
类似地,我们可以假设a[1]~a[6]为已知,来求得a[7]。同样,a[6]、a[5]、a[4]、a[3]、a[2]也是类似求法,而a[1]就是1,即如果系统拦截的最后1枚导弹是389,则只能拦截第1枚。
这样,求解过程可以用下列式子归纳:
a[1]=1
a[i]=max{a[j]}+1 (i>1,j=1,2,…,i-1,且j同时要满足:h[j]>=h[i])
最后,只需把a[1]~a[8]中的最大值输出即可。这就是第一问的解法,这种解题方法就称为“动态规划”。
第二问比较有意思。由于它紧接着第一问,所以很容易受前面的影响,多次采用第一问的办法,然后得出总次数,其实这是不对的。要举反例并不难,比如长为7的高度序列“7 5 4 1 6 3 2”, 最长不上升序列为“7 5 4 3 2”,用多次求最长不上升序列的结果为3套系统;但其实只要2套,分别击落“7 5 4 1”与“6 3 2”。所以不能用“动态规划”做,那么,正确的做法又是什么呢?
我们的目标是用最少的系统击落所有导弹,至于系统之间怎么分配导弹数目则无关紧要,上面错误的想法正是承袭了“一套系统尽量多拦截导弹”的思维定势,忽视了最优解中各个系统拦截数较为平均的情况,本质上是一种贪心算法,但贪心的策略不对。如果从每套系统拦截的导弹方面来想行不通的话,我们就应该换一个思路,从拦截某个导弹所选的系统入手。
题目告诉我们,已有系统目前的瞄准高度必须不低于来犯导弹高度,所以,当已有的系统均无法拦截该导弹时,就不得不启用新系统。如果已有系统中有一个能拦截该导弹,我们是应该继续使用它,还是另起炉灶呢?事实是:无论用哪套系统,只要拦截了这枚导弹,那么系统的瞄准高度就等于导弹高度,这一点对旧的或新的系统都适用。而新系统能拦截的导弹高度最高,即新系统的性能优于任意一套已使用的系统。既然如此,我们当然应该选择已有的系统。如果已有系统中有多个可以拦截该导弹,究竟选哪一个呢?当前瞄准高度较高的系统的“潜力”较大,而瞄准高度较低的系统则不同,它能打下的导弹别的系统也能打下,它够不到的导弹却未必是别的系统所够不到的。所以,当有多个系统供选择时,要选瞄准高度最低的使用,当然瞄准高度同时也要大于等于来犯导弹高度。
解题时用一个数组sys记下当前已有系统的各个当前瞄准高度,该数组中实际元素的个数就是第二问的解答。
[参考程序]
program noip1999_2;
const maxn=100;
var i,j,n,maxlong,tail,minheight,select:longint;
h,longest,sys:array [1..maxn] of longint;
begin
write('Input n:');
readln(n);
for i:=1 to n do read(h[i]);
readln;
longest[1]:=1; {以下求第一问}
for i:=2 to n do
begin
maxlong:=1;
for j:=1 to i-1 do
if h[i]<=h[j] then
if longest[j]+1>maxlong then maxlong:=longest[j]+1;
longest[i]:=maxlong
end;
maxlong:=longest[1];
for i:=2 to n do
if longest[i]>maxlong then maxlong:=longest[i];
writeln('max=',maxlong);
sys[1]:=h[1]; {以下求第二问}
tail:=1; {数组下标,最后也就是所需系统数}
for i:=2 to n do
begin
minheight:=maxint;
for j:=1 to tail do {找一套最适合的系统}
if sys[j]>h[i] then
if sys[j]<minheight then
begin minheight:=sys[j]; select:=j end;
if minheight=maxint {开一套新系统}
then begin tail:=tail+1; sys[tail]:=h[i] end
else sys[select]:=h[i]
end;
writeln('total=',tail);
readln
end.
[部分测试数据]
输入1:
7
300 250 275 252 200 138 245
输出1:
5
2
输入2:
7
181 205 471 782 1033 1058 1111
输出2:
1
7
输入3:
13
465 978 486 476 324 575 384 278 214 657 218 445 123
输出3:
7
4
输入4:
15
236 865 858 565 545 445 455 656 844 735 638 652 659 714 845
输出4:
6
7
例2、数字三角形(IOI1994)
[问题描述]
如下所示为一个数字三角形:
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
请编一个程序计算从顶至底的某处的一条路径,使该路径所经过的数字的总和最大。
● 每一步可沿直线向下或右斜线向下走;
● 1<三角形行数≤100;
● 三角形中的数字为整数0,1,……,99;
[问题输入]
输入文件tringle.in,其中第一行为一个自然数,表示数字三角形的行数n,接下来的n行表示一个数字三角形。
[问题输出]
输出文件tringle.out,仅有一行包含一个整数,表示要求的最大总和。
[输入样例]
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
[输出样例]
30
[问题分析]
本题用穷举法显然会超时,因为最多100行,有299条路径。
还有一种思路是贪心法,显然贪心法不能保证正确,反例很容易举。
既然如此,我们假设从顶至数字三角形的某一位置的所有路径中,所经过的数字的总和最大的那条路径,我们称之为该位置的最大路径。由于问题规定每一步只能沿直线向下或沿斜线向右下走,若要走到某一位置,则其前一位置必为其左上方或正上方两个位置之一,由此可知,当前位置的最优路径必定与其左上方或正上方两个位置的最优路径有关,且来自于其中更优的一个。
我们可以用一个二维数组a记录数字三角形中的数,a[i,j]表示数字三角形中第i行第j列的数,再用一个二维数组sum记录每个位置的最优路径的数字总和,则可得出:
sum[i,j]=max{sum[i-1,j],sum[i-1,j-1]} +a[i,j] (2<=i<=n,2<=j<=i)
边界条件:
sum[i,1]=sum[i-1,1]+a[i,1] {第1列}
sum[i,i]=sum[i-1,i-1]+a[i,i] {对角线}
这个问题呈现出明显的阶段性:三角形的每一行都是一个阶段。对最大路径的求解过程,实际上是从上往下不断地按照阶段的顺序求解。对问题适当地划分阶段是动态规划解题中的一个重要步骤,下文会仔细介绍。
[参考程序]
program ioi1994_1;
const maxn=100;
var a:array[1..maxn,1..maxn] of longint;
sum:array[1..maxn,0..maxn] of longint;
i,j,k,n,ans:longint;
begin
assign(input,’ tringle.in’);
assign(output,’ tringle.out’);
reset(input);
rewrite(output);
readln(n);
for i:=1 to n do
begin
for j:=1 to i do read(a[i,j]);
readln;
end;
fillchar(sum,sizeof(sum),0);
sum[1,1]:=a[1,1];
for i:=2 to n do
for j:=1 to i do
if sum[i-1,j-1]>sum[i-1,j]
then sum[i,j]:=sum[i-1,j-1]+a[i,j]
else sum[i,j]:=sum[i-1,j]+a[i,j];
ans:=0;
for j:=1 to n do
if sum[n,j]>ans then ans:=sum[n,j];
writeln(ans);
close(input);
close(output)
end.
[深入思考]
假如本题还要你输出最大值的那条路径,即不仅要求出最优值、还要你构造出最优方案,你怎么办呢?
用1个三维数组 sum[1..maxn,0..maxn,1..2]来记忆最优值及最优值的来源,在递推的同时记下路径,即:a[x,y,1]表示第x行、y列能够取得的最大值,a[x,y,2]表示该最大值是从上一行的哪个位置得来的(如-1表示从左上方得到的,0表示从正上方得到的)。
最后输出时,从下往上倒过来推即可!
二、动态规划的几个基本概念
首先介绍一下动态规划中的几个基本概念。
1、阶段和阶段变量
用动态规划求解一个问题时,需要将问题的全过程恰当地划分成若干个相互联系的阶段,以便按一定的次序去求解。描述阶段的变量称为阶段变量,通常用K表示。
阶段的划分一般是根据时间和空间的自然特征来定的,一般要便于把问题转化成多阶段决策的过程。
2、状态和状态变量
某一阶段的出发位置称为状态,通常一个阶段包含若干状态。状态通过一个变量来描述,这个变量称为状态变量。状态表示的是事物的性质。
3、决策、决策变量
对问题的处理中做出某种选择性的行动就是决策。一个实际问题可能要有多次决策和多个决策点,在每一个阶段中都需要有一次决策。决策也可以用一个变量来描述,称为决策变量。在实际问题中,决策变量的取值往往限制在某一个范围之内,此范围称为允许决策集合。
4、策略和最优策略
所有阶段依次排列构成问题的全过程。全过程中各阶段决策变量所组成的有序总体称为策略。在实际问题中,从决策允许集合中找出最优效果的策略称为最优策略。
5、状态转移方程
前一阶段的终点就是后一阶段的起点,前一阶段的决策变量就是后一阶段的状态变量,这种关系描述了由K阶段到K+1阶段状态的演变规律,是关于两个相邻阶段状态的方程,称为状态转移方程,是动态规划的核心。
6、指标函数和最优化概念
用来衡量多阶段决策过程优劣的一种数量指标,称为指标函数。它应该在全过程和所有子过程中有定义、并且可度量。指标函数的最优值,称为最优值函数。
最优化概念是在一定条件下,找到一种途径,在对各阶段的效益经过按问题具体性质所确定的运算后,使得全过程的总效益达到最优。
总之,动态规划所处理的问题是一个“多阶段决策问题”,目的是得到一个最优解(方案)。大概思想如下图所示:
三、运用动态规划的条件
上面介绍了两个动态规划的简单例子和几个基本概念,那么,什么样的“多阶段决策问题”才可以用动态规划的方法求解呢?一般来说,能够采用动态规划方法求解的问题必须满足最优化原理和无后效性原则。
1、最优化原理
作为整个过程的最优策略具有:无论过去的状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略的性质。
也可以通俗地理解为子问题的局部最优将导致整个问题的全局最优,即问题具有最优子结构的性质。也就是说一个问题的最优解只取决于其子问题的最优解,非最优解对问题的求解没有影响。
在例2中,如果我们想求走到某一位置的最优路径,我们只需要知道其左上方或正上方两个位置之一的最优值,而不用考虑其它的路径,因为其它的非最优路径肯定对当前位置的结果没有影响。如果我们把问题稍微改变一下:将三角形中的数字改为-100~100之间的整数,计算从顶至底的某处的一条路径,使该路径所经过的数字的总和最接近于零。这个问题就不具有最优子结构性质了,因为子问题最优(最接近于零)反而可能造成问题的解远离零,这样的反例是不难构造的,改变以后的问题显然就不能用动态规划的方法解决。
我们再来看一个问题:
例3、有4个点,分别是A、B、C、D,如下图所示,相邻两点用两条连线C2k-1,C2k(1≤k≤3)表示两条通行的道路。连线上方的数字表示道路的长度。我们定义从A到D的所有路径中,长度除以4所得余数最小的路径为最优路径。现在请你求一条最优路径。
问题分析:
在这个题目中,我们如果还按照刚才的方法来求解就会发生错误。例如,按照例2的思维,假如我们倒过来推,A最优取值可以由B的最优取值来确定,而B的最优取值可以由C的最优值来确定,而C的最优值为1,推出B的最优值为0,再推出A的最优值为2。而实际上,路径C1—C3—C5可得最优值为0,所以,B的最优路径并不是A的最优路径的子路径,也就是说,A的最优取值不是由B的最优取值决定的,其不具有最优子结构性质。
由此可见,并不是所有的“决策问题”都可以用“动态规划”来解决。所以,只有当一个问题呈现出最优子结构时,“动态规划”才可能是一个合适的侯选方法。
2、无后效性原则
所谓无后效性原则,指的是这样一种性质:某一阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响。也就是说“未来与过去无关”。当前的状态是此前历史的一个完整总结,此前的历史只能通过当前的状态去影响过程未来的演变。
具体地说,如果一个问题被划分各个阶段之后,阶段I中的状态只能由阶段I+1中的状态转移方程得来,与其他没有关系,特别是与未发生的状态没有关系,这就是无后效性。从图论的角度去考虑,如果把这个问题中的状态定义成图中的顶点,两个状态之间的转移定义为边,转移过程中的权值增量定义为边的权值,则构成一个有向无环加权图,因此,这个图可以进行“拓扑排序”,至少可以按他们的拓扑排序的顺序去划分阶段。
看下面这个问题:
例4、旅行路线问题:给定一个平面上的n个点的坐标,规定必须从最左边一个点开始,严格地从左至右访问到最右边的点,再严格地由右向左访问到出发点,编程确定一条连接各个点的闭合的游历路线,要求整个路程最短的路径长度。
问题分析:
本题可以根据[起点,终点]划分阶段,且满足无后效性原则,可以考虑用动态规划做。但如果把“规定必须从最左边一个点开始,严格地从左至右访问到最右边的点,再严格地由右向左访问到出发点”这个限制条件去掉,则阶段与阶段之间没有什么必然的“顺序”,而且把各个阶段画成一个图后会出现“环路”,所以有“后效性”,就不能用动态规划做了。
一般来说,只要问题可以划分成规模更小的子问题,并且原问题的最优解中包含了子问题的最优解(即满足最优化原理),则可以考虑用动态规划解决。动态规划法所针对的问题有一个显著的特征,即它所对应的子问题呈现大量的重复。动态规划法的关键就在于,对于重复出现的子问题,只在第一次遇到时加以求解,并把答案保存起来,让以后再遇到时直接引用,不必重新求解。
动态规划的实质是分治思想和解决冗余,因此,动态规划是一种将问题分解为更小的、相似的子问题,并存储子问题的解而避免计算重复的子问题,以解决最优化问题的算法策略。这类问题会有多种可能的解,每个解都有一个值,而动态规划找出其中最优(最大或最小)解。若存在多个最优解的话,它只取其中的一个。
由此可知,动态规划法与分治法类似,它们都是将问题归纳为更小的、相似的子问题,并通过求解子问题产生一个全局最优解。其中分治法中的各个子问题是独立的(即不包含公共的子问题),因此一旦递归地求出各子问题的解后,便可自下而上地将子问题的解合并成问题的解。但不足的是,如果各子问题是不独立的,则分治法要做许多不必要的工作,重复地解公共的子问题。
另外,适合于动态规划方法解决的问题必须是子问题空间要较小,也就是用来解原问题的一个递归算法可反复地解同样的子问题,而不是总在产生新的子问题,即子问题的总数是问题规模的一个多项式。当一个递归算法不断地遇到同一问题时,我们说该最优化问题包含有重叠子问题。相反地,适合用分治法解决的问题往往在递归的每一步都产生出全新的问题来,如快速排序算法。动态规划总是充分利用重叠子问题,对每个子问题只解一次,把解放在一个数组中,需要时直接查看就行。为说明重叠子问题性质,我们对数字三角形问题编一个递归算法,该算法的源程序如下:
program ex2;
const maxline=100;
var i,j,t,line:integer;
a:array [1..maxline,1..maxline] of integer;
function maxsum(i,j:integer):integer;
begin
if i=line
then maxsum:=a[i,j]
else if maxsum(i+1,j)>maxsum(i+1,j+1)
then maxsum:=maxsum(i+1,j)+a[i,j]
else maxsum:=maxsum(i+1,j+1)+a[i,j]
end;
begin {main}
write('Input number of line(<=100):'); readln(line);
for i:=1 to line do
begin
for j:=1 to i do
begin
a[i,j]:=random(99)+1;
write(a[i,j]:3)
end;
writeln
end;
writeln('Maxsum=',maxsum(1,1));
readln
end.
通过运行两个程序进行对比,我们可以看出用递推的方法对每个子问题(求maxsum[i,j])只求一次,子问题总数为O(n*n),时间复杂度也是O(n*n)。而用递归的方法,由于子问题重叠,如求maxsum(i,j)和maxsum(i,j+1)都要调用子问题maxsum(i-1,j),从而造成子问题的大量重复调用,其运行时间呈指数增长,从实际运行时间看,与分析出的理论值完全符合。
四、动态规划的解题模式
拿到一道题目后,应先分析其是否可以转化为一个多阶段决策问题,然后第一步判断其是否满足“无后效性原则”和“最优化原理”,若不满足则须考虑其它的算法,如搜索等;若满足则可以使用动态规划。要使用动态规划,接着第二步必须分析最优解性质、确定状态和状态变量;第三步将问题分为n个阶段;接下来第四步建立状态转移方程及边界条件(递归地定义最优值);最后根据状态转移方程以自底向上的递推方式或自顶向下的记忆化方法(备忘录法),计算出最优解;如果需要,还要根据最优值,构造一个最优解。其中第一步判断是使用动态规划的基础,而第二步状态变量的确定则是构造动态规划模型的关键一步。作为状态变量,其必须能够表现出状态之间互相演变的关系,同时其也应包含到达此状态前的必要信息,只有这样才可以根据无后效应将问题分割为n个互为联系的阶段,实现对每个阶段的独立研究,完成下面的几步。即动态规划解题的流程图如下:
多阶段决策问题 |
是否满足无后效性原则 和最优化原理 |
其他算法 |
将问题划分阶段 |
分析最优解性质、确定状态和状态变量 |
建立状态转移方程式及边界条件(递归地定义最优值) |
以自底向上的递推方式或自顶向下的记忆化方法(备忘录法),计算出最优解 |
N |
Y |
根据最优值,构造一个最优解 |
在实际解题过程中,我们往往不须拘泥于上图的模式,要根据题目综合考虑。需要说明的是,在利用状态转移方程计算最优解时,一般都是用自底向上的递推手段求解。具体来说就是从递归式的边界条件出发,按问题规模从小到大依次求出每个子问题的最优解,直至求出问题最终的最优解为止,这一过程通常可用循环结构实现,如例2就是如此。
当然,因为状态转移方程是递归定义的,也可以用递归,但这样显然会让效率大打折扣。
那么动态规划算法能不能用自顶向下的递归策略加以优化来实现呢?首先让我们来看看直接递归到底慢在什么地方?递归的缺点就在于会反复地求解同一个子问题,从而浪费了大量的时间。如果我们将每个子问题的最优解在第一次递归求出后保存起来,今后遇到要递归求相同的子问题的最优解时,我们先看一看这个子问题解过没有,如解过即不再进行递归调用,直接将前面求得的最优解取出使用,这就自然而然地形成了一种新的求解方法:记忆化的递归(或称为备忘录法),即把已求得的状态储存下来,供下一次使用,从而避免了反复求子问题的解,这种方法实现起来非常直观,而且时间复杂度与自底向上的递推差不多。
一个记忆化的递归算法需要开辟一个数组用来记录每个子问题的最优解,开始时该数组中每个元素都包含一个特殊值,表示该子问题的最优解尚未解出,当在递归算法的执行中第一次遇到某个子问题时,计算出其最优解并记录在相应的数组元素中,以后每次遇到该子问题时,只要查看数组中先前记录的值即可。
五、动态规划应用举例
例5、挖地雷
[问题描述]
在一个地图上有N个地窖(N<=200),每个地窖中埋有一定数量的地雷。同时,给出地窖之间的连接路径,并规定路径都是单向的。某人可以从任一处开始挖地雷,然后沿着指出的连接往下挖(仅能选择一条路径),当无连接时挖地雷工作结束。设计一个挖地雷的方案,使他能挖到最多的地雷。
[输入]
N {地窖的个数}
W1,W2,……WN {每个地窖中的地雷数}
X1,Y1 {表示从X1可到Y1}
X2,Y2
……
0,0 {表示输入结束}
[输出]
K1——K2——……——Kv {挖地雷的顺序}
MAX {最多挖出的地雷数}
[问题分析]
本题是一个经典的动态规划问题。很明显,题目规定所有路径都是单向的,所以满足无后效性原则和最优化原理。设W(i)为第i个地窖所藏有的地雷数,A(i,j)表示第i个地窖与第j个地窖之间是否有通路,F(i)为从第i个地窖开始最多可以挖出的地雷数,则有如下递归式:
F(i)=MAX { F(j) + W(i) } (i<j<=n , A(i,j)=1)
边界:F(n)=W(n)
于是就可以通过递推的方法,从后(F(n))往前逐个找出所有的F(i),再从中找一个最大的即为问题2的解。对于具体所走的路径(问题1),可以通过一个向后的链接来实现,具体请看下面的程序清单和注解。
[参考程序]
program NOIP1996;
const maxn=200;
var a:array[1..maxn,1..maxn] of byte;
w:array[1..maxn] of integer;
f:array[1..maxn] of record
sum:longint; {F[i].sum为从第i个地窖开始最多可以挖出的地雷数}
next:byte; {F[i].next为向后连接,即应该再向后挖的地窖的编号}
end;
i,j,n,k,max:longint;
begin
write('input n:');
readln(n);
write('input w[i]:');
for i:=1 to n do read(w[i]);
readln;
fillchar(a,sizeof(a),0);
fillchar(f,sizeof(f),0);
writeln('input x->y(0 0 is over!):');
repeat
readln(i,j);
if (i<>0) and (j<>0) then a[i,j]:=1;
until (i=0) and (j=0); {前面为读入和初始化工作}
f[n].sum:=w[n]; {从F[n]开始,向前求每一个F[i]}
for i:=n-1 downto 1 do {求第i+1到第n个地窖中,与第i个地窖连接的、所挖地雷数最大的地窖K}
begin
max:=0; {求MAX{F[j].sum}作为F[i]的后继}
k:=0;
for j:=i+1 to n do
if (a[i,j]=1) and (max<f[j].sum) then begin
max:=f[j].sum;
k:=j;
end;
f[i].sum:=w[i]+max; {得到F[i]}
f[i].next:=k
end;
k:=1; {找一个MAX{F[i].sum}作为问题的解}
for i:=2 to n do
if f[i].sum>f[k].sum then k:=i;
max:=f[k].sum;
write('path:',k);k:=f[k].next; {输出路径}
while k<>0 do
begin
write('--',k);
k:=f[k].next
end;
writeln;
writeln('max=',max);
readln;
end.
[测试数据]
输入:
6
5 10 20 5 4 5
1 2
1 4
2 4
3 4
4 5
4 6
5 6
0 0
输出:
3-4-5-6
34
输入:
6
10 15 20 15 5 10
0 0
输出:
3
20
例6、复制书稿(BOOKS)
[问题描述]
有M本书(编号为1,2,…,M),每本书都有一个页数(分别是P1,P2,…,PM)。想将每本都复制一份。将这M本书分给K个抄写员(1<=K<=M<=500),每本书只能分配给一个抄写员进行复制。每个抄写员至少被分配到一本书,而且被分配到的书必须是连续顺序的。复制工作是同时开始进行的,并且每个抄写员复制一页书的速度都是一样的。所以,复制完所有书稿所需时间取决于分配得到最多工作的那个抄写员的复制时间。试找一个最优分配方案,使分配给每一个抄写员的页数的最大值尽可能小。
[输入]
第一行两个整数M、K;(K<=M<=100)
第二行M个整数,第i个整数表示第i本书的页数。
[输出]
共K行,每行两个正整数,第i行表示第i个人抄写的书的起始编号和终止编号。K行的起始编号应该从小到大排列,如果有多解,则尽可能让前面的人少抄写。
[样例]
BOOK.IN
9 3
1 2 3 4 5 6 7 8 9
BOOK.OUT
1 5
6 7
8 9
[问题分析]
该题中M本书是顺序排列的,K个抄写员选择数也是顺序且连续的。不管以书的编号,还是以抄写员标号作为参变量划分阶段,都符合策略的最优化原理和无后效性。考虑到K<=M,以抄写员编号来划分阶段会方便些。
设F(I,J)为前I个抄写员复制前J本书的最小“页数最大数”。于是便有F(I,J)=MIN{ F(I-1,V),T(V+1,J)} (1<=I<=K,I<=J<=M-K+I,I-1<=V<=J-1)。 其中T(V+1,J)表示从第V+1本书到第J本书的页数和。边界条件F(1,i)=T(1,j)。
[参考程序]
program books;
const maxm=100;
var p,t:array[1..maxm] of longint;
f:array[1..maxm,1..maxm] of longint;
m,k,i,j,v:longint;
function max(a,b:longint):longint;
begin
if a>b then max:=a else max:=b;
end;
procedure out(i,j:longint); {递归输出}
var v:longint;
begin
if i=1 then
begin
writeln(1,' ',j);
exit;
end;
for v:=i-1 to j-1 do
if max(f[i-1,v],t[j]-t[v])<=f[k,m] then
begin
out(i-1,v);
writeln(v+1,' ',j);
exit;
end;
end;
begin {main}
assign(input,'book.in');
assign(output,'book.out');
reset(input);rewrite(output);
read(m,k);
for i:=1 to m do read(p[i]); {p[i]存放每本书的页数}
t[1]:=p[1];
for i:=2 to m do t[i]:=t[i-1]+p[i]; {t[i]存放前i本书的总页数}
for j:=1 to m do f[1,j]:=t[j]; {边界条件、初始状态}
for i:=2 to k do
for j:=i to m do {记忆化递归}
begin
f[i,j]:=maxlongint;
for v:=i-1 to j-1 do
if f[i-1,v]>t[j]-t[v]
then begin
if f[i-1,v]<f[i,j] then
f[i,j]:=f[i-1,v];
end
else begin
if t[j]-t[v]<f[i,j] then
f[i,j]:=t[j]-t[v];
end;
end;
out(k,m);
close(input);
close(output);
end.
例7、苹果(apples)
[问题描述]
农场的夏季是收获的好季节。在Farmer John的农场,他们用一种特别的方式来收苹果:Bessie摇苹果树,苹果落下,然后Farmer John尽力接到尽可能多的苹果。
作为一个有经验的农夫, Farmer John将这个过程坐标化。他清楚地知道什么时候(1<=t<=1,000,000)什么位置(用二维坐标表示,-1000<=x,y<=1000)会有苹果落下。他只有提前到达那个位置,才能接到那个位置掉下的苹果。
一个单位时间,Farmer John能走s(1<=s<=1000)个单位。假设他开始时(t=0)站在(0,0)点,他最多能接到多少个苹果?
[输入格式]
第一行:两个整数,N(苹果个数,n<=5000)和S(速度);
第2..N+1行:每行三个整数Xi,Yi,Ti,表示每个苹果掉下的位置和落下的时间。
[输出格式]
仅一行,一个数,表示最多能接到几个苹果
[样例输入]
apples.in
5 3
0 0 1
0 3 2
-5 12 6
-1 0 3
-1 1 2
[样例输出]
apples.out
3 (Farmer John可以接到第1,5,4个苹果)
[问题分析]
首先划分阶段,这道题的阶段还是很明显的,即按照苹果掉落的时间先后顺序来划分阶段,所以一上来就有必要按时间从小到大给各个苹果排个序,并按顺序标上1..n的编号。假如Jhon现在正站在某个位置上接苹果,那为了使他到当前为止接到的苹果数最大,我们想要知道的是他前一步在哪个位置接苹果,并且要知道到那个位置为止接到的苹果最多是多少。dis(i,j)表示第i个苹果与第j个苹果之间的直线距离。time(i)表示第i个苹果掉落的时刻。F(i)表示Jhon当前站在第i个苹果的位置上最多能接到的苹果总数(包括他以前接的)。于是有如下的式子:
F(i)=max{F(j)+1},其中0<=j<=i-1,且dis(i,j)<=(time(i)-time(j))*S
初始条件:F(0)=0,表示Jhon站在出发点(0,0)时一个苹果也没接到。
[参考程序]
program apples;
const maxapp=5000;
type pointype=record
x,y,time:longint;
end;
var f:array [0..maxapp] of longint; {f[i]表示当前站在第i个苹果的位置上最多可以接到的苹果总数}
app:array[0..maxapp] of pointype; {存放i个苹果落地的坐标x,y和时刻time}
n,s:longint;
i,j,ans:longint;
m:real;
procedure readata; {输入}
var i,j,k:longint;
begin
readln(n,s);
for i:=1 to n do
readln(app[i].x,app[i].y,app[i].time);
app[0].x:=0; app[0].y:=0; app[0].time:=0; {加上边界,初始状态}
end;
procedure sort(l,r: longint); {按落地时刻,快速排序}
var i,j,x: longint;
tmp:pointype;
begin
i:=l;
j:=r;
x:=app[(l+r) div 2].time;
repeat
while app[i].time<x do inc(i);
while x<app[j].time do dec(j);
if not(i>j) then
begin
tmp:=app[i];
app[i]:=app[j];
app[j]:=tmp;
inc(i);
j:=j-1;
end;
until i>j;
if l<j then sort(l,j);
if i<r then sort(i,r);
end;
function dis(i,j:longint):real; {算第i与第j个苹果的直线距离}
begin
dis:=sqrt(sqr(app[i].x-app[j].x)+sqr(app[i].y-app[j].y));
end;
begin {main}
assign(input,'apples.in');
reset(input);
assign(output,'apples.out');
rewrite(output);
readata;
sort(1,n);
f[0]:=0; {动态规划求解,初始状态}
ans:=0;
for i:=1 to n do
begin
f[i]:=0;
for j:=0 to i-1 do
if (j=0)or((j<>0)and(f[j]<>0)) then
begin
m:=dis(i,j);
if ( m<=s*(app[i].time-app[j].time) )and( f[j]+1>f[i] )
then f[i]:=f[j]+1;
end;
if f[i]>ans then ans:=f[i];
end;
write(ans);
close(input);close(output);
end.
例8、低价购买(buylow)
[问题描述]
“低价购买”这条建议是在股票市场取得成功的一半规则。要想被认为是伟大的投资者,你必须遵循以下的购买建议:“低价购买;再低价购买”。每次你购买一支股票,你必须用低于你上次购买它的价格购买它。买的次数越多越好!你的目标是在遵循以上建议的前提下,求你最多能购买股票的次数。你将被给出一段时间内一支股票每天的出售价(216范围内的正整数),你可以选择在哪些天购买这支股票。每次购买都必须遵循“低价购买;再低价购买”的原则。写一个程序计算最大购买次数。
这里是某支股票的价格清单:
日期 1 2 3 4 5 6 7 8 9 10 11 12
价格 68 69 54 64 68 64 70 67 78 62 98 87
最优秀的投资者可以购买最多4次股票,可行方案中的一种是:
日期 2 5 6 10
价格 69 68 64 62
[输入]
输入文件共两行,第1行: N (1 <= N <= 5000),股票发行天数;
第2行: N个数,是每天的股票价格。
[输出]
输出文件仅一行,包含两个数:最大购买次数和拥有最大购买次数的方案数(<=231),当两种方案“看起来一样”时(就是说它们构成的价格队列一样的时候),这2种方案被认为是相同的。
[样例]
BUYLOW.IN
12
69 68 54 70 68 64 70 67 78 62 98 87
BUYLOW.OUT
4 4
[问题分析]
先探索一下样例,最大购买次数为4次,共有4种方案,分别为69、68、64、62;69、68、67、62;70、68、64、62;70、68、67、62。
我们发现,这道题和例1的第一问几乎是完全相同的。实际上是在一个数列中选出一个序列,使得这个序列是一个下降序列(即序列中的任意一个数必须大于它后面的任何一个数),且要使这个序列的长度最长。但是这道题要输出总的方案数,这就须要对原有的求解过程作一些变动。求方案总数最主要的是要剔除重复方案。在样例中,第2和第5个数都是68。以第一个68结尾的最长下降序列的方案为69、68;以第二个68结尾的最长下降序列的方案为69、68 和70、68——这时候就产生了方案的重复,即产生了两个69、68。显然后面的68要比前面一个更优,因为后面的68位置更靠后,以这个68结尾的最长下降序列的总数肯定要比前面一个多,而且其方案必定囊括了前面一个68的所有方案。因此,在解题过程中,我们就可以只考虑后面一个68。推广开来,如果当前状态之前存在重复的状态,我们只要考虑离当前状态位置最近的那一个即可。
设F(i)表示到第i天,能够购买的最大次数,显然有:F(1)=1,F(i)=max{F(j)+1} (1<=j<=i-1,且OK[j]=true),OK[j]=true表示相同价格时,该位置更优。
[参考程序]
program buylow;
const maxn=5000;
var stock,f,sum:array [1..maxn] of longint;
ok:array[1..maxn] of boolean;
n,i,j ans1,ans2:longint;
begin
assign(input,'buylow.in');
reset(input);
assign(output,'buylow.out');
rewrite(output);
readln(n);
for i:=1 to n do read(stock[i]);
for i:=1 to n do ok[i]:=true;
ans1:=0;
for i:=1 to n do
begin
f[i]:=1;
sum[i]:=1;{sum[i]表示与第i天的最大购买次数一样的方案有几种,为第2问准备}
for j:=1 to i-1 do
begin
if stock[j]=stock[i] then ok[j]:=false;
if (stock[j]>stock[i])and(ok[j]) then
if f[j]+1>f[i]
then begin
f[i]:=f[j]+1;sum[i]:=sum[j];
end
else if f[j]+1=f[i] then sum[i]:=sum[i]+sum[j];
end;
if f[i]>ans1 then ans1:=f[i];
end;
ans2:=0; {第2问:与ans1相等的f[i]的个数,且f[i]=true}
for i:=1 to n do
if (f[i]=ans1)and(ok[i]) then ans2:=ans2+sum[i];
write(ans1,' ',ans2);
close(input);
close(output);
end.
例9、拔河比赛(tug)
[问题描述]
一个学校举行拔河比赛,所有的人被分成了两组,每个人必须(且只能够)在其中的一组,要求两个组的人数相差不能超过1,且两个组内的所有人体重加起来尽可能地接近。
[输入]
输入数据的第1行是一个n,表示参加拔河比赛的总人数,n<=100,接下来的n行表示第1到第n个人的体重,每个人的体重都是整数(1<=weight<=450)。
[输出]
输出数据应该包含两个整数:分别是两个组的所有人的体重和,用一个空格隔开。注意如果这两个数不相等,则请把小的放在前面输出。
[输入样例]
tug.in
3
100
90
200
[输出样例]
tug.out
190 200
[问题分析]
这道题目不满足动态规划最优子结构的特性。因为最优子结构要求一个问题的最优解只取决于其子问题的最优解。就这道题目而言,当前n-1个人的分组方案达到最优时,并不意味着前n个人的分组方案也最优。但题目中标注出每个人的最大体重为450,这就提醒我们可以从这里做文章,否则的话,命题者大可把最大体重标注到长整型。假设w[i]表示第i个人的体重。c[i,j,k]表示在前i个人中选j个人在一组,他们的重量之和等于k是否可能。显然,c[i,j,k]是boolean型,其值为true代表有可能,false代表没有可能。那c[i,j,k]与什么有关呢?从前i个人中选出j个人的方案,不外乎两种情况:⑴第i个人没有被选中,此时就和从前面i-1个人中选出j个人的方案没区别,所以c[i,j,k]与c[i-1,j,k]有关。⑵第i个人被选中,则c[i,j,k]与c[i-1,j-1,k-w[i]]有关。综上所述,可以得出:
c[i,j,k]=c[i-1,j,k] or c[i-1,j-1,k-w[i]]。
这道题占用的空间看似达到三维,但因为i只与i-1有关,所以在具体实现的时候,可以把第一维省略掉。另外在操作的时候,要注意控制j与k的范围(0<=j<=i/2,0<=k<=j*450),否则有可能超时。
这种方法的实质是把解本身当作状态的一个参量,把最优解问题转化为判定性问题,用递推的方法求解。这种问题有一个比较明显的特征,就是问题的解被限定在一个较小的范围内,如这题中人的重量不超过450。
[参考程序]
program Tug;
const maxn=100;
maxn2=maxn div 2;
maxrange=450;
var c:array [0..maxn2,0..maxn2*maxrange] of boolean;
w:array [1..maxn] of longint;
n,n2,sum,i,j,k,min,ans:longint;
begin
assign(input,'tug.in');
assign(output,'tug.out');
reset(input);
rewrite(output);
read(n); n2:=(n+1) div 2;
sum:=0;
for i:=1 to n do
begin
read(w[i]);
inc(sum,w[i]);
end;
fillchar(c,sizeof(c),0);
c[0,0]:=true;
for i:=1 to n do
for j:=n2-1 downto 0 do
for k:=maxrange*i downto 0 do
if c[j,k] then c[j+1,k+w[i]]:=true;
min:=maxlongint;
ans:=0;
for k:=0 to maxrange*n2 do
if c[n2,k] and (abs(sum-k-k)<min) then
begin
min:=abs(sum-k-k);
if k<=sum div 2 then ans:=k else ans:=sum-k;
end;
write(ans,' ',sum-ans);
close(output);
close(input);
end.
例10、最长公共子序列(LCS)
[问题描述]
一个给定序列的子序列是在该序列中删去若干元素后得到的序列。确切地说,若给定序列X=<x1,x2,……,xm>,则另一序列Z=<z1,z2,……,zk>是X的子序列是指存在一个严格递增的下标序列 <i1,i2,……,ik>,使得对于所有j=1,2,……,k有:
例如,序列Z=<B,C,D,B>是序列X=<A,B,C,B,D,A,B>的子序列,相应的递增下标序列为<2,3,5,7>。给定两个序列X和Y,当另一序列Z既是X的子序列又是Y的子序列时,称Z是序列X和Y的公共子序列。例如,若X=<A,B,C,B,D,A,B>和Y=<B,D,C,A,B,A>,则序列<B,C,A>是X和Y的一个公共子序列,序列<B,C,B,A>也是X和Y的一个公共子序列。而且,后者是X和Y的一个最长公共子序列,因为X和Y没有长度大于4的公共子序列。
给定两个序列X=<x1,x2,……,xm>和Y=<y1,y2,……,yn>,要求找出X和Y的一个最长公共子序列。
[问题输入]
输入文件LCS.IN,共有两行,每行为一个由大写字母构成的长度不超过200的字符串,表示序列X和Y。
[问题输出]
输出文件LCS.OUT,第一行为一个非负整数,表示所求得的最长公共子序列的长度,若不存在公共子序列,则输出文件仅有一行输出一个整数0,否则在输出文件的第二行输出所求得的最长公共子序列(也用一个大写字母组成的字符串表示),若符合条件的最长公共子序列不止一个,只需输出其中任意的一个。
[输入样例]
LCS.IN:
ABCBDAB
BDCABA
[输出样例]
LCS.OUT:
4
BCBA
[评分标准]
若输出的最长公共子序列的长度正确,则得一半分;若输出的最长公共子序列也正确,则再得一半分。
[问题分析]
动态规划算法可有效地解此问题。下面我们按照动态规划算法设计的各个步骤来设计一个解此问题的有效算法。
1.最长公共子序列的结构
解最长公共子序列问题时最容易想到的算法是穷举搜索法,即对X的每一个子序列,检查它是否也是Y的子序列,从而确定它是否为X和Y的公共子序列,并且在检查过程中选出最长的公共子序列。X的所有子序列都检查过后即可求出X和Y的最长公共子序列。X的一个子序列相应于下标序列{1,2,……, m}的一个子序列,因此,X共有2m个不同子序列,从而穷举搜索法需要指数时间。
事实上,最长公共子序列问题也有最优子结构性质,因为我们有如下定理:
定理: LCS的最优子结构性质
设序列X=<x1, x2,……, xm>和Y=<y1, y2, ……,yn>的一个最长公共子序列Z=<z1, z2,……, zk>,则:
若xm=yn,则zk=xm=yn且Zk-1是Xm-1和Yn-1的最长公共子序列;
若xm≠yn且zk≠xm ,则Z是Xm-1和Y的最长公共子序列;
若xm≠yn且zk≠yn ,则Z是X和Yn-1的最长公共子序列。
其中Xm-1=<x1,x2,……,xm-1>,Yn-1=<y1,y2,……,yn-1>,Zk-1=<z1,z2,……,zk-1>。
证明:
用反证法。若zk≠xm,则<z1, z2, …, zk ,xm >是X和Y的长度为k十1的公共子序列。这与Z是X和Y的一个最长公共子序列矛盾。因此,必有zk=xm=yn。由此可知Zk-1是Xm-1和Yn-1的一个长度为k-1的公共子序列。若Xm-1和Yn-1有一个长度大于k-1的公共子序列W,则将xm加在其尾部将产生X和Y的一个长度大于k的公共子序列。此为矛盾。故Zk-1是Xm-1和Yn-1的一个最长公共子序列。
由于zk≠xm,Z是Xm-1和Y的一个公共子序列。若Xm-1和Y有一个长度大于k的公共子序列W,则W也是X和Y的一个长度大于k的公共子序列。这与Z是X和Y的一个最长公共子序列矛盾。由此即知Z是Xm-1和Y的一个最长公共子序列。
这个定理告诉我们,两个序列的最长公共子序列包含了这两个序列的前缀的最长公共子序列。因此,最长公共子序列问题具有最优子结构性质。
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。其他情况下,由定理可建立递归关系如下:
3.计算最优值
直接利用上式容易写出一个计算c[i,j]的递归算法,但其计算时间是随输入长度指数增长的。由于在所考虑的子问题空间中,总共只有O(m*n)个不同的子问题,因此,用动态规划算法自底向上地计算最优值能提高算法的效率。
计算最长公共子序列长度的动态规划算法LCS_LENGTH(X,Y)以序列X=<x1,x2,……,xm>和Y=<y1,y2, ……,yn>作为输入。输出两个数组c[0..m ,0..n]和b[1..m ,1..n]。其中c[i,j]存储Xi与Yj的最长公共子序列的长度,b[i,j]记录指示c[i,j]的值是由哪一个子问题的解达到的,这在构造最长公共子序列时要用到。最后,X和Y的最长公共子序列的长度记录于c[m,n]中。由于每个数组单元的计算耗费Ο(1)时间,算法LCS_LENGTH耗时Ο(mn)。
4.构造最长公共子序列
由算法LCS_LENGTH计算得到的数组b可用于快速构造序列X=<x1,x2, ……,xm>和Y=<y1, y2,……,yn>的最长公共子序列。首先从b[m,n]开始,沿着其中的箭头所指的方向在数组b中搜索。当b[i,j]中遇到"↖"时,表示Xi与Yj的最长公共子序列是由Xi-1与Yj-1的最长公共子序列在尾部加上xi得到的子序列;当b[i,j]中遇到"↑"时,表示Xi与Yj的最长公共子序列和Xi-1与Yj的最长公共子序列相同;当b[i,j]中遇到"←"时,表示Xi与Yj的最长公共子序列和Xi与Yj-1的最长公共子序列相同。
下面的算法LCS(b,X,i,j)实现根据b的内容打印出Xi与Yj的最长公共子序列。
在算法LCS中,每一次的递归调用使i或j减1,因此算法的计算时间为O(m+n)。
[参考程序]
Program lcs(Input, Output);
const maxlen=200;
var i,j:longint;
c:array[0..maxlen,0..maxlen] of byte;
x,y,z:string;
begin
assign(input,'lcs.in');
reset(input);
readln(x);
readln(y);
close(input);
fillchar(c,sizeof(c),0);
for i:=1 to length(x) do
for j:=1 to length(y) do
if x[i]=y[j]
then c[i,j]:=c[i-1,j-1]+1
else if c[i-1,j]>c[i,j-1]
then c[i,j]:=c[i-1,j]
else c[i,j]:=c[i,j-1];
z:='';
i:=length(x);
j:=length(y);
assign(output,'lcs.out');
rewrite(output);
writeln(c[i,j]);
while (i>0) and (j>0) do
if x[i]=y[j]
then begin z:=x[i]+z;i:=i-1;j:=j-1 end
else if c[i-1,j]>c[i,j-1]
then i:=i-1
else j:=j-1;
if z<>'' then writeln(z);
close(output)
end.
例11、回文词
[问题描述]
回文词是一种对称的字符串——也就是说,一个回文词,从左到右读和从右到左读得到的结果是一样的。任意给定一个字符串,通过插入若干字符,都可以变成一个回文词。你的任务是写一个程序,求出将给定字符串变成回文词所需插入的最少字符数。
比如字符串“Ab3bd”,在插入两个字符后可以变成一个回文词(“dAb3bAd”“Adb3bdA”)。然而,插入两个以下的字符无法使它变成一个回文词。
[问题输入]
输入文件PALIN.IN。
文件的第一行包含一个整数N,表示给定字符串的长度,3≤N≤5000。
文件的第二行是一个长度为N的字符串。字符串仅由大写字母“A”到“Z”,小写字母“a”到“z”和数字“0”到“9”构成。大写字母和小写字母将被认为是不同的。
[问题输出]
输出文件PALIN.OUT,文件只有一行,包含一个整数,表示需要插入的最少字符数。
[输入样例]
PALIN.IN
5
Ab3bd
[输出样例]
PALIN.OUT
2
[问题分析]
使用类似数字三角形的动态规划过程。令Cost[i,j]表示将原串St从第j位开始长度为i的子串SubString变成回文词的最小添加字符数。按SubString的长度大小依次进行递推求解:
Cost[i,j]= Cost[i-2,j+1] 若St[j]=St[i+j-1],即首尾对称
Cost[i,j]= Min{ Cost[i-1,j]+1, Cost[i-1,j+1]+1 } 若St[j]<>St[i+j-1]。
(2≤i≤n,1≤j≤n-i+1)
显然,当 i=1或0 时,Cost值均为0。
[参考程序]
Program palin(Input, Output);
type arr=array[0..5000] of integer;
var h1,m1,s1,ms1,h2,m2,s2,ms2:word;
ch,ch1:array[1..5000] of char;
a,b:arr;
min,n,b1,b2:integer;
procedure init;
var i:integer;
begin
assign(input,'palin.in');
reset(input);
readln(n);
for i:=1 to n do
begin
read(ch[i]);
ch1[n-i+1]:=ch[i];
end;
close(input);
end;
function smaller(a,b:integer):integer;
begin
if a>b then smaller:=b else smaller:=a;
end;
procedure doit;
var x,i,j:integer;
begin
fillchar(a,sizeof(a),0);
min:=n;
for i:=1 to n do
begin
b2:=0;
for j:=1 to smaller(n-i,n div 2) do
begin
b1:=b2;
b2:=a[j];
if ch[i]=ch1[j] then begin if b1+1>a[j] then a[j]:=b1+1; end
else if a[j-1]>a[j] then a[j]:=a[j-1];
if i+j>=n-1 then
begin
x:=n-a[j]*2-(n-i-j);
if min>x then min:=x;
end;
end;
end;
assign(output,'palin.out');rewrite(output);
writeln(min);
close(output)
end;
begin {main}
init;
doit;
end.
例12、堆塔问题(pile)
[问题描述]
设有n个边长为1的正立方体,在一个宽为1的轨道上堆塔,但塔本身不能分离。
例如:n=1时,只有1种方案: □
n=2时,有2种方案:
堆塔的规则为底层必须有支撑,如下列的堆法是合法的:
下面的堆法是不合法的:
程序要求:输入n(n<=40),求出:
① 总共有多少种不同的方案
② 堆成每层的方案数是多少,例如n=6时,堆成1层的方案数为1,……,堆成6层的方案数为1,……
[问题分析]
问题①要求n个边长为1的正立方体总共有多少种不同的堆塔方案,首先考虑将n个边长为1的正立方体排成k(1<=k<=n)列的情况,每列的个数依次为x1,x2, …,xk,则x1+x2+…+xk=n,这里按题目要求x1,x2, …,xk必须为正整数,设y1=x1-1,y2=x2-1,…,yk=xk-1,则原方程转化为y1+y2+…+yk=n-k,根据上述定理,该方程的解的总数有C(n-k+k-1,k-1)=C(n-1,k-1),堆塔方案总数为C(n-1,0)+C(n-1,1)+…+C(n-1,n-1)=2n。
关于问题②,可以这样考虑,将n个立方体的第一列去掉的话,则成为一个比n小的堆塔问题,这样问题②就可以用动态规划法来解。具体方法是从1到n依次求出堆成每层的方案数,设f(n,k)为堆成k层的方案数,则递推公式为:
[算法设计]
由于n最大可达到40,所以堆塔的总方案数超过了长整形数的范围,程序采用了高精度加法运算。
[参考程序]
program pile(input,output);
const maxn=40; maxlen=maxn div 3;
type arraytype=array[0..maxlen] of integer;
var i,j,k,n,nn:longint;
total:arraytype;
f:array [0..maxn,0..maxn] of arraytype;
procedure add(var a:arraytype;b:arraytype);
var i:longint;
begin
for i:=0 to maxlen do a[i]:=a[i]+b[i];
for i:=0 to maxlen-1 do
if a[i]>=10 then
begin
a[i+1]:=a[i+1]+1;
a[i]:=a[i]-10
end
end;
procedure print(a:arraytype);
var i,j:longint;
begin
i:=maxlen;
while (i>0) and (a[i]=0) do i:=i-1;
for j:=i downto 0 do write(a[j])
end;
begin {main}
write('Input n:');
readln(n);
for i:=1 to maxlen do total[i]:=0;
total[0]:=1;
for i:=1 to n-1 do add(total,total);
for i:=1 to maxn do
for j:=1 to maxn do
for k:=0 to maxlen do f[i,j,k]:=0;
for i:=1 to maxn do f[i,1,0]:=1;
for i:=1 to maxn do f[i,i,0]:=1;
for nn:=2 to n do
for k:=2 to nn-1 do
begin
for i:=1 to k-1 do add(f[nn,k],f[nn-i,k]);
for i:=1 to k do
if nn-k>=i then add(f[nn,k],f[nn-k,i])
end;
write('Total=');writeln(total);
for k:=1 to n do
begin
write('Height=',k:2,'Kind=':10);
writeln(f[n,k]);
end;
readln
end.
例13、投资问题(invest)
[问题描述]
有n万元的资金,可投资于m个项目,其中m和n为小于100的自然数。对第i(1≤i≤m)个项目投资j万元(1≤j≤n,且j为整数)可获得的回报为Q(i,j),请你编一个程序,求解并输出最佳的投资方案(即获得回报总值最高的投资方案)。
输入数据放在文件invest.in中,格式如下:
m n
Q(1,0) Q(1,1)……Q(1,n)
Q(2,0) Q(2,1)……Q(2,n)
……
Q(m,0) Q(m,1)……Q(m,n)
输出数据放在文件invest.out中,格式为:
r(1) r(2) ······ r(m) P
其中r(i)(1≤i≤m)表示对第i个项目的投资万元数,P为总的投资回报值,保留两位有效数字,任意两个数之间空一格。当存在多个并列的最佳投资方案时,只要求输出其中之一即可。
[样例]
invest.in:
2 3
0 1.1 1.3 1.9
0 2.1 2.5 2.6
invest.out:
1 2 3.6
[问题分析]
本题可供考虑的递推角度只有资金和项目两个角度,从项目的角度出发,逐个项目地增加可以看出只要算出了对前k个项目投资j万元最大投资回报值(1≤j≤n),就能推出增加第k+1个项目后对前k+1个项目投资j万元最大投资回报值(1≤j≤n),设P[k,j]为前k个项目投资j万元最大投资回报值,则P[k+1,j]= Max(P[k,i]+Q[k+1,j-i]),0<=i<=j,k=1时,对第一个项目投资j万元最大投资回报值(0≤j≤n)是已知的(边界条件)。
[算法设计]
动态规划的时间复杂度相对于搜索算法是大大降低了,却使空间消耗增加了很多。所以在设计动态规划的时候,需要尽可能节省空间的占用。本题中如果用二维数组存储原始数据和最大投资回报值的话,将造成空间不够,事实上,从前面的问题分析可知:在计算对前k+1个项目投资j万元最大投资回报值时,只要用到矩阵Q的第k+1行数据和对前k个项目投资j万元最大投资回报值,而与前面的数据无关,后者也只需有一个一维数组存储即可,程序中用一维数组Q存储当前项目的投资回报值,用一维数组maxreturn存储对当前项目之前的所有项目投资j万元最大投资回报值(0≤j≤n),用一维数组temp存储对到当前项目为止的所有项目投资j万元最大投资回报值(0≤j≤n)。为了输出投资方案,程序中使用了一个二维数组invest,invest[k,j]记录了对前k个项目投资j万元获得最大投资回报时投资在第k个项目上的资金数。
[参考程序]
program invest(input,output);
const maxm=100; maxn=100;
type arraytype=array [0..maxn] of real;
var i,j,k,m,n,rest:integer;
q,maxreturn,temp:arraytype;
invest:array[1..maxm,0..maxn] of integer;
result:array[1..maxm] of integer;
begin
assign(input,’invest.in’);
reset(input);
readln(m,n);
for j:=0 to n do read(q[j]);
readln;
for i:=1 to m do
for j:=0 to n do invest[i,j]:=0;
maxreturn:=q;
for j:=0 to n do invest[1,j]:=j;
for k:=2 to m do
begin
temp:=maxreturn;
for j:=0 to n do invest[k,j]:=0;
for j:=0 to n do read(q[j]);
readln;
for j:=0 to n do
for i:=0 to j do
if maxreturn[j-i]+q[i]>temp[j] then
begin
temp[j]:=maxreturn[j-i]+q[i];
invest[k,j]:=i
end;
maxreturn:=temp
end;
close(input);
assign(output,’invest.out’);
rewrite(output);
rest:=n;
for i:=m downto 1 do
begin
result[i]:=invest[i,rest];
rest:=rest-result[i]
end;
for i:=1 to m do write(result[i],' ');
writeln(maxreturn[n]:0:2);
close(output)
end.
例13、方格取数(pane)
[问题描述]
设有N*N的方格图(N≤8),我们将其中的某些方格中填入正整数,而其他的方格中则放入数字0。
如下图所示(见样例):
向右 | ||||||||||
| A | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|
1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| |
2 | 0 | 0 | 13 | 0 | 0 | 6 | 0 | 0 |
| |
3 | 0 | 0 | 0 | 0 | 7 | 0 | 0 | 0 |
| |
4 | 0 | 0 | 0 | 14 | 0 | 0 | 0 | 0 |
| |
向 下 | 5 | 0 | 21 | 0 | 0 | 0 | 4 | 0 | 0 |
|
6 | 0 | 0 | 15 | 0 | 0 | 0 | 0 | 0 |
| |
7 | 0 | 14 | 0 | 0 | 0 | 0 | 0 | 0 |
| |
8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | B |
某人从图的左上角的A点出发,可以向下行走,也可以向右走,直到到达右下角的B点。在走过的路上,他可以取走方格中的数(取走后的方格中将变为数字0)。
此人从A点到B点共走两次,试找出2条这样的路径,使得取得的数之和为最大。
[输入]
输入文件pane.in,第一行为一个整数N(表示N*N的方格图),接下来的每行有三个整数,前两个表示位置,第三个数为该位置上所放的数。一行单独的0表示输入结束。
[输出]
输出文件pane.out,只需输出一个整数,表示2条路径上取得的最大的和。
[样例]
输入:
8
2 3 13
2 6 6
3 5 7
4 4 14
5 2 21
5 6 4
6 3 15
7 2 14
0 0 0
输出:
67
[问题分析]
本题是从1997年国际信息学奥林匹克的障碍物探测器一题简化而来,如果人只能从A点到B点走一次,则可以用动态规划算法求出从A点到B点的最优路径。具体的算法描述如下:从A点开始,向右和向下递推,依次求出从A点出发到达当前位置(i,j)所能取得的最大的数之和,存放在sum数组中,原始数据经过转换后用二维数组data存储,为方便处理,对数组sum和data加进零行与零列,并置它们的零行与零列元素为0。易知
sum[i,j]= |
max(sum[i-1,j],sum[i,j-1])+ data[i,j] 当i>0,且j>0
求出sum数组以后,通过倒推即可求得最优路径,具体算法如下:
置sum数组零行与零列元素为0
for i:=1 to n do
for j:=1 to n do
if sum[i-1,j]>sum[i,j-1]
then sum[i,j]:=sum[i-1,j]+data[i,j]
else sum[i,j]:=sum[i,j-1]+data[i,j];
i:=n; j:=n;
while (i>1) or (j>1) do
if (i>1) and (sum[i,j]=sum[i-1,j]+data[i,j])
then begin data[i,j]:=-1; i:=i-1 end
else begin data[i,j]:=-1; j:=j-1 end;
data[1,1]:=-1;
凡是被标上-1的格子即构成了从A点到B点的一条最优路径。
那么是否可以按最优路径连续走两次而取得最大数和呢?这是一种很自然的想法,并且对样例而言也是正确的,具体算法如下:
1、 求出数组sum,
2、 s1:=sum[n,n],
3、 求出最优路径,
4、 将最优路径上的格子中的值置为0,
5、 将数组sum的所有元素置为0,
6、 第二次求出数组sum,
7、 输出s1+sum[n,n]。
虽然这一算法保证了连续的两次走法都是最优的,但却不能保证总体最优,相应的反
例也不难给出,请看下图:
图二按最优路径走一次后,余下的两个数2和3就不可能同时取倒了,而按图三中的非最优路径走一次后却能取得全部的数,这是因为两次走法之间的协作是十分重要的,而图2中的走法并不能考虑全局,因此这一算法只能算是“贪心算法”。虽然在一些情况下,贪心算法也能够产生最优解,但总的来说“贪心算法”是一种有明显缺陷的算法。
既然简单的动态规划行不通,那么看看穷举行不行呢?因为问题的规模比较小,启发我们从穷举的角度出发去思考,首先让我们来看看N=8时,从左上角A到达右下角B的走法共有多少种呢?显然从A点到B点共需走14步,其中向右走7步,向下走7步,共有 =3432种不同的路径,走两次的路径组合总数为 =3432*3431/2=5887596,从时间上看是完全可以承受的,但是如果简单穷举而不加优化的话,对极限数据还是会超时的,优化的最基本的方法是以空间换时间,具体到本题就是预先将每一条路径以及路径上的数字之和(称之为路径的权weight)求出并记录下来,然后用双重循环穷举任意两条不同路径之组合即可。考虑到记录所有的路径需要大量的存储空间,我们可以将所有的格子逐行进行编号,这样原来用二维坐标表示的格子就变成用一个1到n2之间的自然数来表示,格子(i,j)对应的编号为(i-1)*n+j。一条路径及其权使用以下的数据结构表示:
const maxn=8;
type arraytype=array[1..2*maxn-2] of byte;
recordtype=record path:arraytype;
weight:longint
end;
数组path依次记录一条路径上除左上和右下的全部格子的编号。
将所有的路径以及路径的权求出并记录下来的算法可用一个递归的过程实现,其中i,j表示当前位置的行与列,step表示步数,sum记录当前路径上到当前位置为止的数之和,当前路径记录在数组position中(不记录起始格),主程序通过调用try(1,1,0,data[1,1])求得所有路径。
procedure try(i,j,step,sum:longint);
begin
if (i=n) and (j=n)
then begin
total:=total+1;
a[total].path:=position;
a[total].weight:=sum;
end
else begin
if i+1<=n then
begin
position[step]:=i*n+j;
try(i+1,j,step+1,sum+data[i+1,j])
end;
if j+1<=n then
begin
position[step]:=(i-1)*n+j+1;
try(i,j+1,step+1,sum+data[i,j+1])
end
end
end;
在穷举了二条不同的路径后,只要将二条路径的权相加再减去二条路径中重叠格子中的数即为从这二条路径连走两次后取得的数之和,具体算法如下:
for i:=1 to n do {将二维数组转化为一维数组}
for j:=1 to n do d1[(i-1)*n+j]:=data[i,j];
max:=0;
for i:=1 to total-1 do
for j:=i+1 to total do
begin
current:=a[i].weight+a[j].weight;
for k:=1 to 2*n-3 do {判断重叠格子,但不包括起点的终点}
begin
if a[i].path[k]=a[j].path[k]{第k步到达同一方格}
then current:=current-d1[a[i].path[k]]
end;
if current>max then max:=current
end;
writeln(max-data[1,1]-data[n,n]);
应该看到穷举的效率是十分低下的,如果问题的规模再大一些,穷举法就会超时,考虑到走一次可以使用动态规划,则只需穷举走第一次的路径,而走第二次可以用动态规划,这样可大大提高程序的效率,其算法复杂度为 ,实现时只需将前面二种算法结合起来即可,但这样做仍然不能使人满意,因为只要穷举了从A点到B点所有路径,算法的效率就不可能很高,要想对付尽可能大的n,还是要依靠动态规划算法。实际上本问题完全可以用动态规划解决,只是递推起来更为复杂些而已,前面在考虑只走一次的情况,只需考虑一个人到达某个格子(i,j)的情况,得出sum[i,j]=max(sum[i-1,j],sum[i,j-1])+data[i,j],现在考虑两个人同时从A出发,则需考虑两个人到达任意两个格子(i1,j1)与(i2,j2)的情况,显然要到达这两个格子,其前一状态必为(i1-1,j1),(i2-1,j2);(i1-1,j1),(i2,j2-1);(i1,j1-1),(i2-1,j2);(i1,j1-1),(i2,j2-1) 四种情况之一,类似地,可以推导出:设p=max(sum[i1-1,j1,i2-1,j2],sum[i1-1,j1,i2,j2-1],sum[i1,j1-1,i2-1,j2],sum[i1,j1-1,i2,j2-1]),则
sum[i1,j1,i2,j2]= |
p + data[i1,j1] 当i1,j1,i2,j2均不为零,且i1=i2,j1=j2
p + data[i1,j1]+data[i2,j2] 当i1,j1,i2,j2均不为零,且i1≠i2或j1≠j2
具体算法如下:
置sum数组所有元素全为0;
for i1:=1 to n do
for j1:=1 to n do
for i2:=1 to n do
for j2:=1 to n do
begin
if sum[i1-1,j1,i2-1,j2]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1-1,j1,i2-1,j2];
if sum[i1-1,j1,i2,j2-1]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1-1,j1,i2,j2-1];
if sum[i1,j1-1,i2-1,j2]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1,j1-1,i2-1,j2];
if sum[i1,j1-1,i2,j2-1]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1,j1-1,i2,j2-1];
sum[i1,j1,i2,j2]:=sum[i1,j1,i2,j2]+data[i1,j1];
if (i1<>i2) or (j1<>j2)
then sum[i1,j1,i2,j2]:=sum[i1,j1,i2,j2]+data[i2,j2]
end;
writeln(sum[n,n,n,n])
[参考程序]
program pane;
const maxn=10;
type arraytype=array [0..maxn,0..maxn] of longint;
var i,j,k,n,i1,i2,j1,j2:longint;
data:arraytype;
sum:array [0..maxn,0..maxn,0..maxn,0..maxn] of longint;
function max(x,y:longint):longint;
begin
if x>y then max:=x else max:=y;
end;
BEGIN {main}
Assign(input,’pane.in’);
Assign(output,’pane.out’);
Reset(input);
Rewrite(output);
for i:=1 to maxn do
for j:=1 to maxn do data[i,j]:=0;
readln(n);
repeat
readln(i,j,k);
data[i,j]:=k
until (i=0) and (j=0) and (k=0);
fillchar(sum,sizeof(sum),0);
for i1:=1 to n do
for j1:=1 to n do
for i2:=1 to n do
for j2:=1 to n do
begin
if sum[i1-1,j1,i2-1,j2]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1-1,j1,i2-1,j2];
if sum[i1-1,j1,i2,j2-1]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1-1,j1,i2,j2-1];
if sum[i1,j1-1,i2-1,j2]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1,j1-1,i2-1,j2];
if sum[i1,j1-1,i2,j2-1]>sum[i1,j1,i2,j2]
then sum[i1,j1,i2,j2]:=sum[i1,j1-1,i2,j2-1];
sum[i1,j1,i2,j2]:=sum[i1,j1,i2,j2]+data[i1,j1];
if (i1<>i2) or (j1<>j2)
then sum[i1,j1,i2,j2]:=sum[i1,j1,i2,j2]+data[i2,j2]
end;
writeln(sum[n,n,n,n]);
close(input);
close(output)
END.
[运行示例]
pane.in:
3
1 1 10
1 3 5
2 2 6
2 3 4
3 1 8
3 2 2
0 0 0
pane.out:
30