本部分主要是学习动态规划的一些笔记
全文下载地址:http://download.csdn.net/detail/wearenoth/6022339
1 分治法与动态规划
分治法的思路是将大问题分成若干子问题,依次求解,最后合并解的答案。但是分治法并没有说明应该划分成什么样的子问题,毕竟这不是意见容易的事情。
在分治法能够分解的问题中,有一类结构很特殊,让我们可以遵循一定的套路去解决问题。这一类具有特殊结构的子问题的分解、分析方法被称作是动态规划。
利用动态规划进行分析的问题,要求分解后的问题必须是最优子结构,也就是一个问题的最优解包含了子问题的最优解。
对于如何寻找最优子结构,《算法导论》中给出了一些启发式的方法:
Ø 问题的一个解可以是做一个选择。
Ø 假设一个给定的问题,已知的是一个可以导致最优解的选择。
Ø 在已知这个选择后,要确定哪些子问题会随之发生,以及如何最好地描述所得到的子问题。
Ø 证明问题的一个最优解中,使用的子问题的解本身也是最优的。
可以这样理解,在分析的时候,我们假定已经对问题规模为n-1的情况下做出了最优的选择,那剩下的事情就是对最后一个问题输入进行选择。而且最后一个物品的选择后得到的解就是输入规模为n的情况下的最优解。这个过程其实和数学归纳的过程很像,不是么。
要利用这种分析方法,可以按照如下的步骤:
Ø 描述一个子问题的解,也就是常说的状态。
Ø 在得到状态描述以后,常常就可以确定初始解。
Ø 获取一个递归的解表达式,也就是经常看到的递归方程,常称作状态转移方程
Ø 申请足够的空间用于保存所有解状态。
Ø 使用递归或递推的方式求解递归方程,得到初始的代码。
Ø 利用一些优化技术优化代码,降低算法的空间复杂度和时间复杂度。
动态规划的解可以画成一棵树的形状,问题的最终解位于树的根部,树的叶子节点的解是已知的。这样一来,动态规划就可以有两种求解思路:
Ø 从树的根部向下求解,也就是自顶向下求解,这种方法的一个缺点就是很可能会出现大量的重叠子问题,解决的办法就是利用记忆搜索法来记录每个解的答案。利用这种方法写出来的代码就是一个递归函数,利用分治法的模板很容易写出来,所以这种方法的另外一个缺点就是可能会因为递归而带来的爆栈以及效率问题。
Ø 从树的叶子节点开始向上求解,也就是自底向上求解。这种方法也就是所谓的递推,这种方法有一定优势,没有了重叠子问题以及递归造成的爆栈问题,效率也比递归快。缺点就是代码不一定好写,比较考验思维。
通过上述写出来的动态规划代码不一定最优,时间复杂度和空间复杂度都可能比较高。
动态规划的优化都需要根据具体问题进行优化,总体的原则就是减少状态数量以及提高状态转移效率。当然也有一些常见的技巧,如利用单调队列优化、凸包优化、斜率优化等。
1.1 寻找最优子结构
如果希望利用动态规划解决问题,第一步就是需要确定最优子结构到底长什么样?
1.2 定义状态
当确定了最优子结构后,状态的定义就相对简单许多,一般站在一个角度》》》》
1.3 定义状态转移方程与初始解
当定义了状态,剩下的事情就是如何表述状态转移方程》》
1.4 将方程转换成代码
将方程转换成代码并不难,如果使用递归方式求解,那就需要考虑重叠子问题,如果采用递推方式。
1.4.1 重叠子问题
采用递归求解的时候最容易出现重叠子问题,这时候就需要考虑如何减少重叠子问题的求解。
1.5 开始维护解
虽然动态规划解决问题过程固定,但是需要考虑的事情非常多,所以一开始并不一定要维护解,可以等到将整个算法的框架给编写出来以后才开始维护答案。
1.6 优化
动态规划的优化需要通过观察,也有一些经典的优化方式。
1.6.1 单调队列优化
1.6.1.1 单调队列与滑窗
假设有一个数组A[],给定一个下标i,希望可以在O(1)的时间内求出A[i-j]到A[i]范围内的最大值。对于这个问题,很容易得到表达式:
f[i]=max{A[k] | j<=k<=i}
这个问题可以利用单调队列进行求解,如下图,下方的指针表示要求的区间范围[st, ed],则单调队列中含有的值则是图中标注颜色的部分。
图 6
要保持单调队列比较简单,单调队列其实是一个双向队列,可以参考下图。例如,如果保持一个单调递减的队列,则操作为:
Ø 如果当前元素小于(<)队尾元素,则直接入队。
Ø 如果当前元素大于等于(>=)队尾元素,则队尾元素出队。重复这个步骤一直遇到第一个大于当前元素时停止,然后再将当前元素入队。
Ø 每次移动区间都需要将队列首部中不在区间范围内的元素清除。
图 7
使用单调队列判断这个数组的代码如下所示。此外,很容易就知道单调队列的最大长度不会超过区间大小。
观察这个过程,其实很像一个滑动窗口,单调队列保证了每次都能保持这个固定窗口中间的最值。
表 34 单调队列遍历数组代码
classLocation{ public: intkey; intindex; }; #defineQUEUESIZE100 Locationqueue[QUEUESIZE]; inthead=0; inttail=0; voidsearch(inta[],intn,intr){ r--; for(inti=0;i<n;i++){ //出队操作 while(head<tail&&queue[tail-1].key<a[i]){ tail--; } //入队操作 queue[tail].key=a[i]; queue[tail].index=i; tail++; //判断队列头部 while(queue[head].index<=i-r){ head++; } } } |
1.6.1.2 单调队列可以优化的动态规划问题
我们先考察下面的这个递归表达式:
dp[i]=max{dp[k-1]-sum[k]+sum[i] | i-r<=k<=i}
直接求解这个递归表达式容易得到时间复杂度为O(NK),其中N是i的取值范围,r是k的取值范围。
将dp[k-1]-sum[k]看做是一个数组,只要使用一个单调队列维护这个数组在区间范围[i-r, i]内的最大值,则在求解dp[i]时候就可以在O(1)的时间复杂度内求解得到。
单调队列可以用于优化DP问题,但它只能优化一些特定的DP问题而不是所有,所有诸如上述形式的状态转移方程就可以利用单调队列进行优化。
利用单调队列优化多重背包问题的时候,需要先进行一些方程上的转换,适应上述的格式。目标是去除f[v-k*c[i]]中同时出现v与k的情况。
令b=v%c[i],a=v/c[i],则v可以表示为:v=a*c[i]+b。则状态转移方程变成:
f[i][a*c[i]+b]=max{f[i-1][(a-k)*c[i]+b]+k*w[i] | 0<=k<=n [i],n[i]<V/c[i]}
令k’=a-k,则可以得到新的状态转移方程:
f[i][a*c[i]+b]=max{f[i-1][k’*c[i]+b]+(a-k’)*w[i] | n[i]-a<=k’<=a,n[i]<V/c[i]}
这时候就可以利用单调队列进行优化。可以将这个状态转移方程降纬,最后得到的状态转移方程为:
f[a*c[i]+b]=max{f[k’*c[i]+b]+(a-k’)*w[i] | n[i]-a<=k’<=a,n[i]<V/c[i]}
图 8
1.6.2 斜率优化
1.6.3 凸包优化
2 利用动态规划解决实际问题
2.1 背包问题
背包问题变种非常多,看到的最好的一篇背包问题的文章是《背包九讲》,作者讲述了0-1背包问题,完全背包问题、多重背包问题、混合背包问题、分组背包问题、有依赖的背包问题、泛化物品等多种背包问题。下面的内容就是根据这篇文章的内容整理出来的。
2.1.1 0-1背包问题
2.1.1.1 问题描述
物品数量为N,背包容量为V。第i件物品费用为c[i],价值为w[i],每件物品数量为1。问:放入哪些物品后,背包中物品价值总和达到最大。
2.1.1.2 基本思路
使用DP进行求解。首先需要定义状态:设f[i][v]表示前i件物品放入一个容量为v的背包中获取的最大价值。
假设我们已经知道放入前i-1个物品到容量为v的背包中可以获取的最大价值。则在考虑第i个物品的时候,第i个物品的处理方法只有两种:放、不放。
Ø 不放:如果不放入第i个物品,则背包中物品的总价值为f[i-1][v]。
Ø 放:如果放入第i个物品,则背包中物品的总价值为f[i-1][v-c[i]]+w[i]。即前i-1个物品只能放在容量为v-c[i]的背包中。
这样状态转移方程为:
f[i][v]=max{f[i-1][v], f[i-1][v-c[i]]+w[i]}
将这个思路转换成代码就是
表 35 0-1背包问题代码
#definePACKAGESIZE1000 #defineMATERIALSSIZE100 intf[MATERIALSSIZE][PACKAGESIZE]; voidzeroOnePack(intcost[],intvalue[],intnum,intpackSize){ for(intindex=0;index<num;index++){ for(v=cost[in |