大话算法之动态规划——初探
对于动态规划,之前学习过了,但是总感觉理解不深刻。今天正好讲道动态规划算法,感觉有了一些新的认识和看法,打算详细的写下来,一是帮助自己理清,二是希望给刚刚接触的ACMer一个简明的理解思路吧。
大话算法之动态规划——初探
一、引例: 数塔问题
之所以好多地方均以这个东西作为例子,我想能是因为这个问题首先是易于理解,其次就是一定有解动态规划问题的通性。举一反三,就不难学习一个新的算法了,重点就在于,这个算法的本质在于什么呀。
题目大意
pc老师最爱出难题了,于是他给出一个数塔,每次只能选择邻近的向左下或者向右走,要求求出从数塔顶端到数塔底端路径和最大是多少。
首先我们不去想动态规划,想想用什么学过的算法或者思想来解决这道题。
(此处应该思考5分钟)
1.暴力/搜索/枚举
这怕不是最容易想到的办法,于是你从根节点开始,枚举每一种向下路径,然后计算出每种路径的和,取最大即可。你手动计算了一波复杂度:
对于第1层:没得选,1种
对于第2层:有向左或向右,2种
对于第3层:依旧按照这样的方式,有4种
对于第4层:左边和右边有2种,中间的一个节点有4种,共8种
规律: 对于N层的数塔,共有2^(N-1) 种枚举方案,而且对于每种方案,还要依次计算其路径上的和,这样指数级的复杂度,一旦N过大,那么时间消耗不敢想象。
2.记忆化搜索(或用递归实现)
你冥思苦想,对暴力方法再分析,不难发现,其如此大的枚举量在于重复的计算。如果想计算如图示意的2条路径,我们会发现13-11这条路径其实计算了多次。那么便可以在第一次算到的时候记下来,以减少计算量。于是又算了一波复杂度:
相当于对于每一个节点我们只访问一算了一次,只需要计算解答树的节点总数即可。
根据暴力法的结论,每一层有2^(N-1) 个节点,故对于N层的数塔,共有2^N-1个节点。复杂度 O(2^N), ,哎,效果依旧不理想。
3.贪心算法
看来枚举对这道题是没什么作用了。于是你打算动动歪脑筋了。既然如此多的种类,有没有什么好的策略能达到类似剪枝的方法呢? 相信很多人都会想到,我们从当前点出发,只要选择最大的计算,到达下一层,继续选择最大的,一直算到底层,结果就应该是最大的啊。 不然,请看图中反例:
贪心算法的结果是 13-11-21
而最优解为 13-8-40
看来贪心算法输就输在最后一步棋了,奈何人家是40啊。 由于本题是只能从某一节点向左,或者向右,并不是从一层某一个节点跳到下一层任意一个节点。否则,贪心就能取得最优解了。当然,对于贪心算法,只要能举出一例反例,就能说明贪心是不适合此类问题的。
4.分治法
又一次尝试的失败,看来又要想新的办法了。此时你灵光一闪,突然想到,既然这个数塔这么有规律,能不能缩小规模,然后分而治之呢?嗯,貌似有一点意思……
对于13为顶点的数塔,我们只要分别求出来以11为顶点,和以8为顶点的数塔的最大值,取两者最大,再加上13不就行了吗?接着,对于以11为顶点的数塔,只要求出来以12为顶点和以7为顶点的数塔的最大值,取两者最大,在加上11不就行了吗?…… 直到你发现,到了数塔底端, 你发现这个可以用max口报答案了。你很开心,加上记忆化,貌似复杂度降低了不少,于是找我帮你算一波复杂度,我勉强答应了:
嗯,掐指一算,不错,现在复杂度是O(N^2)的,相比之前的指数级,优化了不少嘛。
不过转念一想,n^2还是有点惨,能不能再快一些呢? 你又开始了思考……
5.递推
苦苦思索,你发现其实分治的思想是把大规模的问题分解为相同的小问题,解决小问题后合并。那为何不直接从小问题入手呢?于是你直接瞄准了数塔的底层,打算逆向求解。
既然要求求最大的, 那么就直接从倒数第二层开始,从其两个分支里面,选择一个最大的加上这个位置的数字,表示从底层开始到达【当前节点】的最大值。于是你发现,这样一直递推,每次求解一层,推到顶层,就能得到正确的答案。
可是为什么是正确的呢?
PS: 此处正推,逆推均可以呀。 强烈建议大家可以试试如何正推!
二、原理简介
从一开始的暴力,到后来暴力优化,再到分而治之,最后递推求解。我们不难发现,问题首先是从缩小问题的规模来得到大幅度优化的(如分治策略)。其次我们发现,我们可以直接入手最小规模的问题,进而一步一步求解出最优解。
1.阶段
上文所说的一步一步,恰恰是动态规划中一个重要的概念,阶段。
如在逆推过程中,我们先从叶子节点开始,求得N-1层的情况,然后再根据N-1层,求得N-2层的情况,最后一个阶段接着一个阶段,求出第1层的情况。这样一个阶段紧接着一个阶段求解问题的过程,给他个洋气的名字,多阶段决策。而动态规划是解决多阶段决策问题一个重要的方法。
2.状态
(状态6:图中表示第N-1层不同的状态)
在每一层中,我们同时求解了多个不同的数值,这样的不同数值,叫做状态。你可以发现,在一个阶段可能有多个状态,但是最后我们求解出来的最优解,只是选取了每个阶段的一个状态,这个状态在这个阶段,一定是最优的。
3.最优子结构
如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。什么意思呢?从刚才给出来的逆推图来看,如果我们要求到第2层的最右节点8的最大路径之和,所求解的路径和求到顶端的最大路径和是一样的。求到节点8这个问题,就是求到最顶端问题的子问题,而后者的最优解,是包含前者的最优解的,于是这就满足了最优子结构性质。一旦发现满足这个性质,就可以考虑用动态规划求解问题了。
下面再举个反例,说明有些多阶段决策问题是不满足最优子结构的。
【例】 余数最少的路径
如图所示,有4个点,分别是A、B、C、D,相邻两点用两条连线,表示两条通行的道路。连线上的数字表示道路的长度。定义从A到D的所有路径中,长度除以4所得余数最小的路径为最优路径。
求一条最优路径。
【分析】
不妨先缩小问题的规模,我们求从A-D的,不妨先求从B-D的,然后再根据A-B来决策不就好了吗?(数塔:先求2层到N层的,再求1-2层的,类比过来的。)
好了,若求从B-D的最优路径,那一定是(1+3) % 4 = 0 这样的一条路径。然后再求解由B-A,那就是 (0+2) % 4 = 2.可是我们发现,由A-D的最优解却是(2+1+1) % 4 = 0。 在这个最优解中,并不包含其子问题的最优解,即此题不满足最优子结构,于是不能用动态规划求解。
4.重叠子问题
在分治算法中,我们将规模大的问题分解为规模小的同种问题,然后分别求解,最后合并结果,求得最终结果。 重叠子问题与这个十分相似。我们每次求解的问题,或者求解合并后的问题,均是相同类型的。如在数塔中,先求从N层到N-1层这个阶段的不同状态。
问题是:如何做操作保证取得最大。 解决方案是:取其相邻叶子最大的那个数值再加上这个位置的数值。 每次我们都在反反复复求解这个问题,最终汇总到根节点。 可见面对的问题,具有重叠性,便可以用相同的方法解决,也就满足了重叠子问题。
5.状态转移
我们在求解某个阶段的某个状态时,应该是从已经求解出来的状态转移过来的,这就叫做状态转移。状态之间的转移,需要借助于状态转移方程,这个就是设计算法的关键。
6.无后效性
那么什么是无后效性呢?
在做状态转移的时候,转移之和当前状态有关,和之前状态没有关系。换个形象的解释。
在数塔问题中,想求解第N-1层的状态,只于第N层的状态有关。在求解第N-2层的状态时,只于第N-1层的状态有关,而与第N层一丁丁点关系都没有。 换句话说,第N层的状态无法对第N-2层,N-3层的状态发生影响,这叫做无后效性。
7.小结
balabala说了这么多,那么解决问题的关键在哪呢?
首先要确定这道问题能用动态规划来求解,即满足最优子结构,重叠子问题(若发现子问题有交叉,那就不行了)。然后划分阶段,确定表示状态需要哪些参量,找到状态转移方程。这样问题就解决了。
三、牛刀小试
1.最长不下降序列
【状态的求解,不一定是从相邻的状态转移得到的】
同样是一道经典的问题,目标就是理解上面那句话。
【题意简述】
一正整数序列b1,b2,…,bn,若下标为i1< i2<…< ik且有bi1<=bi2<=…<=bik,则称存在一个长度为k的不下降序列。可能有多个不下降序列,输出最长序列的长度。
【样例输入】
13 7 9 16 38 24 37 18
【样例输出】
5 (Tip:7 9 16 24 37)
面对这个问题,一起做下面几件事:
1.什么是这道题的阶段,即如何划分阶段?
2.对于每个阶段,什么是状态,即怎么表示状态?
3.状态之间是如何转移的?
(此处应该思考5mins)
对于阶段的确定,个人认为按照最最自然的求解步骤就好。如我们对于序列线性扫描,每扫描到一个数字,即是一个阶段,然后我们需要确定这个阶段不同的状态。
状态是什么呢?本题要求出最长序列的长度,那么状态即为序列长度的值。
继续之前,总结一下确立的阶段和状态的含义:
原序列存储到数组a[]中,状态存储到数组b[]中。
i表示到扫描到第i个数字的这一个阶段,b[i]表示以a[i]结尾的最长序列长度(这很关键)。
状态转移方程的求解:
对于阶段i,我们希望求出这个阶段的最长序列长度。很自然的就可以想到,要向前找。但是找什么呢? 一定是找到一个数字a[j] (1<=j< i)满足a[j]<=a[i],然后b[i] = b[j]+1 ,这样就从j那个状态转移到i那个状态了。 由于j那个状态是之前我们已经求解出来的,依据此就可以轻松转移到i阶段的的状态。 当然,若某个j不满足a[j]<=a[j],那么就不发生状态的转移。
到此,便可以设计出程序。
根据此例可以看出,与数塔不同,这里状态转移并不是从其邻近的状态转移过来的,而是在满足一定条件的时候,发生状态转移。
2.初识01背包
【对于多重约束的情况,在用数组表示的时候,可以选择多增加一维的方法】
【输入格式】
第一行为两个整数,依次表示背包的重量W和物件的总数n。
接下来共有n行,每行有两个用一个空格隔开的整数,依次表示每个物品重量和价值。
【输出格式】
输出仅一行为一个整数,表示背包最终装载物品的最大总价值。
【样例输入】
10 5
4 6
5 4
6 5
2 3
2 6
【样例输出】
15
继续考虑这三个问题:
1.什么是这道题的阶段,即如何划分阶段?
2.对于每个阶段,什么是状态,即怎么表示状态?
3.状态之间是如何转移的?
(此处应该思考5mins)
阶段的确定:这个相对比较容易,对于每一个物品,我们有装或者不装两种决策,所以阶段就是物品的件数,即i=1表示对前1件物品操作,i=2表示对前2件物品做操作,i=3表示对前3件物品做操作。(这和数塔分层异曲同工)。
状态的确定:对于一个状态i,我们有i件物品来决定装或者不装,这时候想起来比较混乱。我们先缩小问题规模。当i=1时,及对于前1件物品,我们什么时候才能决定它 能装进去 还是 不能装进去呢? 当然是若当前背包容量大于物品重量时,就能装进去,否则不能。所以,状态就是当前背包的容量。于是需要枚举容量j∈[1.W]. 对不同的j进行决策。于是我们有这样的一个二维数组表格:
状态转移方程的确定:用dp[i][j]来表示当背包容量为j时,对于前i件物品,背包所能容纳的最大的价值。 若当前背包能装下第i件物品,那么当然选择装进去就有dp[i][j] = dp[i-1][j-w[i]]+v[i];
若装不下第i件物品,则有dp[i][j] = dp[i-1][j]。则会填出图中所示的表格: