动态规划入门(1)
前言
此文来自百度文库:https://wenku.baidu.com/view/ac0738232f60ddccda38a053.html
原文为txt格式且排版混乱,原作者已不可考(从文中例题看,估计写于10几年前)
此处贴出为Markdown格式重排版,修正部分错误,且所有Pascal代码已用Python重写,由于时间有限,文中部分公式未重排
原文为32讲,此处重新划分为8篇
基本概念
一、动态规划三要素:阶段,状态,决策。
- 他们的概念到处都是,我就不多说了,我只说说我对他们的理解:
- 如果把动态规划的求解过程看成一个工厂的生产线,阶段就是生产某个商品的不同的环节,状态就是工件当前的形态,决策就是对工件的操作。显然不同阶段是对产品的一个前面各个状态的小结,有一个个的小结构成了最终的整个生产线。每个状态间又有关联(下一个状态是由上一个状态做了某个决策后产生的)。
下面举个例子: - 要生产一批雪糕,在这个过程中要分好多环节:购买牛奶,对牛奶提纯处理,放入工厂加工,加工后的商品要包装,包装后就去销售……,这样没个环节就可以看做是一个阶段;产品在不同的时候有不同的状态,刚开始时只是白白的牛奶,进入生产后做成了各种造型,从冷冻库拿出来后就变成雪糕(由液态变成固态=_=||)。每个形态就是一个状态,那从液态变成固态经过了冰冻这一操作,这个操作就是一个决策。
- 一个状态经过一个决策变成了另外一个状态,这个过程就是状态转移,用来描述状态转移的方程就是状态转移方程。
- 经过这个例子相信大家对动态规划有所了解了吧。
- 如果把动态规划的求解过程看成一个工厂的生产线,阶段就是生产某个商品的不同的环节,状态就是工件当前的形态,决策就是对工件的操作。显然不同阶段是对产品的一个前面各个状态的小结,有一个个的小结构成了最终的整个生产线。每个状态间又有关联(下一个状态是由上一个状态做了某个决策后产生的)。
- 下面在说说我对动态规划的另外一个理解:
- 用图论知识理解动态规划:把动态规划中的状态抽象成一个点,在有直接关联的状态间连一条有向边,状态转移的代价就是边上的权。这样就形成了一个有向无环图AOE网(为什么无环呢?往下看)。对这个图进行拓扑排序,删除一个边后同时出现入度为0的状态在同一阶段。这样对图求最优路径就是动态规划问题的求解。
二、动态规划的适用范围
动态规划用于解决多阶段决策最优化问题,但是不是所有的最优化问题都可以用动态规划解答呢?
一般在题目中出现求最优解的问题就要考虑动态规划了,但是否可以用还要满足两个条件:
- 最优子结构(最优化原理)
- 最优化原理在下面的最短路径问题中有详细的解答;
- 无后效性
- 什么是无后效性呢?
- 就是说在状态i求解时用到状态j而状态j求解有用到状态k。。状态N。
- 而求状态N时有用到了状态i这样求解状态的过程形成了环就没法用动态规划解答了,这也是上面用图论理解动态规划中形成的图无环的原因。
- 也就是说当前状态是前面状态的完美总结,现在与过去无关。。。
- 当然,有时换一个划分状态或阶段的方法就满足无后效性了,这样的问题仍然可以用动态规划解。
三、动态规划解决问题的一般思路
拿到多阶段决策最优化问题后,第一步要判断这个问题是否可以用动态规划解决,如果不能就要考虑搜索或贪心了。当确定问题可以用动态规划后,就要用下面介绍的方法解决问题了:
模型匹配法
最先考虑的就是这个方法了。挖掘问题的本质,如果发现问题是自己熟悉的某个基本的模型,就直接套用,但要小心其中的一些小的变动,现在考题办都是基本模型的变形套用时要小心条件,三思而后行。这些基本模型在先面的分类中将一一介绍。
三要素法
仔细分析问题尝试着确定动态规划的三要素,不同问题的确定方向不同:
先确定阶段的问题:数塔问题,和走路问题(详见解题报告)
先确定状态的问题:大多数都是先确定状态的。
先确定决策的问题:背包问题。(详见解题报告)
一般都是先从比较明显的地方入手,至于怎么知道哪个明显就是经验问题了,多做题就会发现。
寻找规律法
这个方法很简单,耐心推几组数据后,看他们的规律,总结规律间的共性,有点贪心的意思。
边界条件法
找到问题的边界条件,然后考虑边界条件与它的领接状态之间的关系。这个方法也很起效。
放宽约束和增加约束
这个思想是在陈启锋的论文里看到的,具体内容就是给问题增加一些条件或删除一些条件使问题变的清晰。
动态规划分类讨论
这里用状态维数对动态规划进行了分类:
1. 状态是一维的
1.1 下降/非降子序列问题
问题描述:
(挖掘题目的本质,一但抽象成这样的描述就可以用这个方法解)
在一个无序的序列a1,a2,a3,a4…an里,找到一个最长的序列满足:ai<=aj<=ak…<=am,且i<j<k…<m.(最长非降子序列)或ai>aj>ak…>am,且i>j>k…>m.(最长下降子序列)。
问题分析:
如果前i-1个数中用到ak (ak>ai或ak<=ai)构成了一个的最长的序列加上第i个数$a_i$就是前i个数中用到i的最长的序列了。那么求用到ak构成的最长的序列有要求前k-1个数中……
从上面的分析可以看出这样划分问题满足最优子结构,那满足无后效性么?显然对于第i个数时只考虑前i-1个数,显然满足无后效性,可以用动态规划解。
分析到这里动态规划的三要素就不难得出了:如果按照序列编号划分阶段,设计一个状态opt[i]表示前i个数中用到第i个数所构成的最优解。那么决策就是在前i-1个状态中找到最大的opt[j]使得aj>ai(或aj<=ai),opt[j]+1就是opt[i]的值;用方程表示为:{我习惯了这种写法,但不是状态转移方程的标准写法 }
$$
最长上升序列: opt[i]=max(opt[j])+1 (0<=j<i 且a_j<=a_i) \
最长下降序列: opt[i]=max(opt[j])+1 (0<=ja_i)
$$
实现求解的部分代码:
opt[0]:=maxsize;{maxsize 为maxlongint或-maxlongint}
for i:=1 to n do
for j:=0 to i-1 do
if ( a[j]>a[i]) and (opt[j]+1>opt[i]) then
opt[i]:=opt[j]+1;
ans:=-maxlongint;
for i:=1 to n do
if opt[i]>ans then
ans:=opt[i]; {ans 为最终解}
复杂度:
从上面的实现不难看出时间复杂度为$O(N^2)$,空间复杂度$O(N)$;
例题1 拦截导弹
(missile.pas/c/cpp)
来源:NOIP1999(提高组) 第一题
【问题描述】
某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。
输入导弹依次飞来的高度(雷达给出的高度数据是不大于30000的正整数),计算这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。
【输入文件】missile.in
单独一行列出导弹依次飞来的高度。
【输出文件】missile.out
两行,分别是最多能拦截的导弹数,要拦截所有导弹最少要配备的系统数
【输入样例】
389 207 155 300 299 170 158 65
【输出样例】
6 2
【问题分析】
有经验的选手不难看出这是一个求最长下降序列问题,显然标准算法是动态规划。
以导弹依次飞来的顺序为阶段,设计状态opt[i]表示前i个导弹中拦截了导弹i可以拦截最多能拦截到的导弹的个数。
状态转移方程:
$$
opt[i]=max(opt[j])+1 \ (h[i]>=h[j],0=<j<i)
$$
其中h[i]存第i个导弹的高度
最大的opt[i]就是最终的解。
这只解决了第一问,对于第二问最直观的方法就是求完一次opt[i]后把刚才要打的导弹去掉,在求一次opt[i]直到打完所有的导弹,但这样做就错了。
不难举出反例: 6 1 7 3 2
错解:6 3 2/1/7
正解:6 1/7 3 2
其实认真分析一下题就回发现:每一个导弹最终的结果都是要被打的,如果它后面有一个比它高的导弹,那打它的这个装置无论如何也不能打那个导弹了,经过这么一分析,这个问题便抽象成在已知序列里找最长上升序列的问题。
求最长上升序列和上面说的求最长非升序列是一样的,这里就不多说了。
复杂度:时间复杂度为$O(N^2)$,空间复杂度$O(N)$;
【源代码】
def missile():
high = [0] + [int(s) for s in input().split(' ')]
n = len(high)
dp1 = [0] * n
dp2 = [0] * n
#寻找最长上升子序列
high[0] = -9999999999
ans2 = 0
for i in range(1, n):
for j in range(i - 1, -1, -1):
if high[i] > high[j] and dp2[j] + 1 > dp2[i]:
dp2[i] = dp2[j] + 1
if dp2[i] > ans2:
ans2 = dp2[i]
#寻找最长非升子序列
high[0] = 9999999999
ans1 = 0
for i in range(1, n):
for j in range(i - 1, -1, -1):
if high[i] < high[j] and dp1[j] + 1 > dp1[i]:
dp1[i] = dp1[j] + 1
if dp1[i] > ans1:
ans1 = dp1[i]
print(ans1)
print(ans2)
missile()
例题2 合唱队形
(chorus.pas/c/cpp)
来源:NOIP2004(提高组) 第一题
N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学排成合唱队形。
合唱队形是指这样的一种队形:设K位同学从左到右依次编号为1,2…,K,他们的身高分别为T1,T2,…,TK, 则他们的身高满足T1<...<Ti>Ti+1>…>TK(1<=i<=K)
。你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。
【输入文件】
输入文件chorus.in的第一行是一个整数N(2<=N<=100),表示同学的总数。第一行有n个整数,用空格分隔,第i个整数Ti(130<=Ti<=230)是第i位同学的身高(厘米)。
【输出文件】
输出文件chorus.out包括一行,这一行只包含一个整数,就是最少需要几位同学出列。
【样例输入】
8 186 186 150 200 160 130 197 220
【样例输出】
4
【数据规模】
对于50%的数据,保证有n<=20;
对于全部的数据,保证有n<=100。
【问题分析】
出列人数最少,也就是说留的人最多,也就是序列最长。
这样分析就是典型的最长下降子序列问题。只要枚举每一个人站中间时可以的到的最优解。显然它就等于,包括他在内向左求最长上升子序列,向右求最长下降子序列。
我们看一下复杂度:
计算最长下降子序列的复杂度是O(N2),一共求N次,总复杂度是O(N3)。这样的复杂度对于这个题的数据范围来说是可以AC的。但有没有更好的方法呢?
其实最长子序列只要一次就可以了。因为最长下降(上升)子序列不受中间人的影响。
只要用OPT1求一次最长上升子序列,OPT2求一次最长下降子序列。这样答案就是N-max(opt1[i]+opt2[i]-1).
复杂度由O(N3)降到了O(N2)。
【源代码】
def chorus():
n = int(input())
high = [int(s) for s in input().split(' ')]
dp1 = [1] * n
dp2 = [1] * n
#寻找最长下降子序列
for i in range(1, n):
for j in range(i - 1, -1, -1):
if high[i] > high[j] and dp1[j] + 1 > dp1[i]:
dp1[i] = dp1[j] + 1
#反向寻找最长下降子序列
for i in range(n - 2, -1, -1):
for j in range(i + 1, n):
if high[i] > high[j] and dp2[j] + 1 > dp2[i]:
dp2[i] = dp2[j] + 1
ans = 0
for i in range(0, n):
if dp1[i] + dp2[i] - 1 > ans:
ans = dp1[i] + dp2[i] - 1
print(n - ans)
chorus()
例题3 逢低吸纳
来源: USACO 4-3-1
【问题描述】
“逢低吸纳”是炒股的一条成功秘诀。如果你想成为一个成功的投资者,就要遵守这条秘诀:
“逢低吸纳,越低越买”
这句话的意思是:每次你购买股票时的股价一定要比你上次购买时的股价低.按照这个规则购买股票的次数越多越好,看看你最多能按这个规则买几次。
给定连续的N天中每天的股价。你可以在任何一天购买一次股票,但是购买时的股价一定要比你上次购买时的股价低。写一个程序,求出最多能买几次股票。
以下面这个表为例, 某几天的股价是:
天数 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 【输入文件】buylow.in
第1行: N (1 <= N <= 5000), 表示能买股票的天数。
第2行以下: N个正整数 (可能分多行) ,第i个正整数表示第i天的股价. 这些正整数大小不会超过longint(pascal)/long(c++).
【输出文件】buylow.out
只有一行,输出两个整数:
能够买进股票的天数长度,达到这个值的股票购买方案数量
在计算解的数量的时候,如果两个解所组成的字符串相同,那么这样的两个解被认为是相同的(只能算做一个解)。因此,两个不同的购买方案可能产生同一个字符串,这样只能计算一次。
【输入样例】
12 68 69 54 64 68 64 70 67 78 62 98 87
【输出样例】
4 2
【提交链接】
【问题分析】
从题目描述就可以看出这是最长下降子序列问题,于是求解第一问不是难事,以天数为阶段,设计状态opt[i] 表示前i天中要买第i天的股票所能得到的最大购买次数。
状态转移方程:
opt[i]=max(opt[j])+1 (a[i]>=a[j],0=<j<i) {a[i]存第i天股价}
最大的opt[i]就是最终的解。
第二问呢,稍麻烦一点。
从问题的边界出发考虑问题,当第一问求得一个最优解opt[i]=X时,
在1到N中如果还有另外一个opt[j]=x那么选取J的这个方案肯定是成立的。
是不是统计所有的opt[j]=x 的个数就是解了呢?
显然没那么简单,因为选取J这天的股票构成的方案不见得=1,看下面一个例子:
5 6 4 3 1 2
方案一:5431
方案二:5432
方案三:6431
方案四:6432
x = 4
也就是说虽然opt[5]=X 和opt[6]=X,但个数只有两个,而实际上应该有四个方案,但在仔细观察发现,构成opt[5]=x 的这个方案中 opt[j]=x-1的方案数有两个,opt[j]=x-2的有一个,opt[j]=x-3 的有一个……
显然这是满足递归定义的,设计函数F(i)表示前i张中用到第i张股票的方案数。
递推式:
1 (i=0)
F(i) =
Sum(F(j)) (0<=j<=n, a[j]>a[i], opt[j]=opt[i]-1)
答案 = sum(F(j)) {0<j<=n, opt[j]=x}
复杂度:
求解第一问时间复杂度是O(N2),求解第二问如果用递推或递归+记忆化时间复杂度仍为O(N2)但要是用赤裸裸的递归那就复杂多了……,因为那样造成了好多不必要的计算。
你认为这样做就解决了这道题目了么?还没有,还要注意一些东西:
(1)如果有两个方案中出现序列一样,视为一个方案,要需要加一个数组next用next[i] 记录和第i个数情况一样(即:opt[i]=opt[j] 且 a[i]=a[j])可看做一个方案的最近的位置。
递推时j只要走到next[i]即可。
(2)为了方便操作可以将a[n+1]赋值为-maxlongint这样可以认为第n+1个一定可以买,答案就是sum(F(n+1))。
(3)看数据规模显然要用高精度。
注:USACO上最后一个点错了。我在程序中做了处理。
【源代码】
def buylow():
n = int(input())
price = [int(s) for s in input().split(' ')] + [-99999999999]
dp1 = [1] * (n + 1)
next = [0] * (n + 1)
#寻找最长下降子序列
for i in range(1, n + 1):
for j in range(i - 1, -1, -1):
if price[i] < price[j] and dp1[j] + 1 > dp1[i]:
dp1[i] = dp1[j] + 1
for i in range(1, n + 1):
j = i - 1
while j and (dp1[i] != dp1[j] or price[i] != price[j]):
j -= 1
next[i] = j
#方案数
f = [1 if dp1[i] == 1 else 0 for i in range(n + 1)]
for i in range(1, n + 1):
for j in range(next[i], i):
if price[i] < price[j] and dp1[j] + 1 == dp1[i]:
f[i] += f[j]
#print(dp1, f)
print(dp1[n] - 1, f[n])
buylow()
例题4 船
(ships.pas/c/cpp)
来源:《奥赛经典》(提高篇)【问题描述】
PALMIA国家被一条河流分成南北两岸,南北两岸上各有N个村庄。北岸的每一个村庄有一个唯一的朋友在南岸,且他们的朋友村庄彼此不同。
每一对朋友村庄想要一条船来连接他们,他们向政府提出申请以获得批准。由于河面上常常有雾,政府决定禁止船只航线相交(如果相交,则很可能导致碰船)。
你的任务是编写一个程序,帮助政府官员决定批准哪些船只航线,使得不相交的航线数目最大。
【输入文件】ships.in
输入文件由几组数据组成。每组数据的第一行有2个整数X,Y,中间有一个空格隔开,X代表PALMIA河的长度(10<=X<=6000),Y代表河的宽度(10<=Y<=100)。第二行包含整数N,表示分别坐落在南北两岸上的村庄的数目(1<=N<=5000)。在接下来的N行中,每一行有两个非负整数C,D,由一个空格隔开,分别表示这一对朋友村庄沿河岸与PALMIA河最西边界的距离(C代表北岸的村庄,D代表南岸的村庄),不存在同岸又同位置的村庄。最后一组数据的下面仅有一行,是两个0,也被一空格隔开。
【输出文件】ships.out
对输入文件的每一组数据,输出文件应在连续的行中表示出最大可能满足上述条件的航线的数目。
【输入样例】
30 4 7 22 4 2 6 10 3 15 12 9 8 17 17 4 2 0 0
【输出样例】
4
【问题分析】
这道题目相对来说思考难度较高,从字面意义上看不出问题的本质来,有点无法下手的感觉。这里我给大家推荐两种思考这类问题的方法。
法一:寻找规律法(我前面总结提到的第3个方法)
寻找规律自然要推几组数据,首先当然有从样例入手;
仔细观察上图:红色航线是合法的,那么他们满足什么规律呢?
C1 C2 C3 C4
北岸红线的端点: 4 9 15 17
南岸红线的端点: 2 8 12 17
D1 D2 D3 D4
不难看出无论线的斜率如何,都有这样的规律:
C1<C2<C3<C4 且 D1<D2<D3<D4
如果我们把输入数据排升序,问题变抽象为:
在一个序列(D)里找到最长的序列满足DI<DJ<Dk……且i<j<k
这样的话便是典型的最长非降子序列问题了。。。。
法二:边界条件法(我前面总结提到的第4个方法)
边界法其实就是把数据往小了缩,显然N=1是答案是1。N=2时呢?
考虑这样一组数据:
N=2
C1 D1
C2 D2
当 C1
当 C1>C2 时,如果D1<D2 那么一定会相交,反之则不会相交。
N=3
C1 D1
C2 D2
C3 D3
……
其实不用在推导N=3了,有兴趣的可以推导去。看N=2时就能得出:
对于任意两条航线如果满足Ci<Cj 且Di<Dj 则两条航线不相交。这样的话要想在一个序列里让所有的航线都不相交那比然满足,C1<C2<C3…Cans且D1<D2<D3…<Dans ,也就是将C排序后求出最长的满足这个条件的序列的长度就是解。
这样分析后显然是一个最长非降子序列问题。
复杂度:排序可以用O(nlogn)的算法,求最长非降子序列时间复杂度是O(n2).
总复杂度为O(n2).
【源代码】
def ships():
w, l = [int(s) for s in input().split(' ')]
while w and l:
n = int(input())
pairs = []
for i in range(n):
pairs.append([int(s) for s in input().split(' ')])
pairs.sort(key = lambda x : x[0])
#寻找最长上升子序列
dp = [1] * n
for i in range(1, n):
for j in range(i):
if pairs[j][1] < pairs[i][1] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
print(max(dp))
#下一个测试
w, l = [int(s) for s in input().split(' ')]
ships()