动态规划(Dynamic Programming,简称DP),虽然抽象后进行求解的思路并不复杂,但具体的形式千差万别,找出问题的子结构以及通过子结构重新构造最优解的过程很难统一。为了解决动态规划问题,只能靠多练习、多思考了。本文主要是对一些常见的动态规划题目的收集,希望能有所帮助。难度评级受个人主观影响较大,仅供参考。
动态规划求解的一般思路:
判断问题的子结构(也可看作状态),当具有最优子结构时,动态规划可能适用。
求解重叠子问题。一个递归算法不断地调用同一问题,递归可以转化为查表从而利用子问题的解。分治法则不同,每次递归都产生新的问题。
重新构造一个最优解。
备忘录法:
动态规划的一种变形,使用自顶向下的策略,更像递归算法。
初始化时表中填入一个特殊值表示待填入,当递归算法第一次遇到一个子问题时,计算并填表;以后每次遇到时只需返回以前填入的值。
实例可以参照矩阵链乘法部分。
1.硬币找零
假设有几种硬币,如1、3、5,并且数量无限。请找出能够组成某个数目的找零所使用最少的硬币数。
用待找零的数值k描述子结构/状态,记作sum[k],其值为所需的最小硬币数。对于不同的硬币面值coin[0…n],有sum[k] = min(sum[k-coin[0]] , sum[k-coin[1]], …)+1。对应于给定数目的找零total,需要求解sum[total]的值。
扩展:
(1)一个矩形区域被划分为N*M个小矩形格子,在格子(i,j)中有A[i][j]个苹果。现在从左上角的格子(1,1)出发,要求每次只能向右走一步或向下走一步,最后到达(N,M),每经过一个格子就把其中的苹果全部拿走。请找出能拿到最多苹果数的路线。
这道题中,当前位置(i,j)是状态,用M[i][j]来表示到达状态(i,j)所能得到的最多苹果数,那么M[i][j] = max(M[i-1][j],M[i][j-1]) + A[i][j] 。特殊情况是M[1][1]=A[1][1],当i=1且j!=1时,M[i][j] = M[i][j-1] + A[i][j];当i!=1且j=1时M[i][j] = M[i-1][j] + A[i][j]。
(2)装配线调度(《算法导论》15.1)
2.字符串相似度/编辑距离(edit distance)
对于序列S和T,它们之间距离定义为:对二者其一进行几次以下的操作(1)删去一个字符;(2)插入一个字符;(3)改变一个字符。每进行一次操作,计数增加1。将S和T变为同一个字符串的最小计数即为它们的距离。给出相应算法。
解法:
将S和T的长度分别记为len(S)和len(T),并把S和T的距离记为m[len(S)][len(T)],有以下几种情况:
如果末尾字符相同,那么m[len(S)][len(T)]=m[len(S)-1][len(T)-1];
如果末尾字符不同,有以下处理方式
修改S或T末尾字符使其与另一个一致来完成,m[len(S)][len(T)]=m[len(S)-1][len(T)-1]+1;
在S末尾插入T末尾的字符,比较S[1…len(S)]和S[1…len(T)-1];
在T末尾插入S末尾的字符,比较S[1…len(S)-1]和S[1…len(T)];
删除S末尾的字符,比较S[1…len(S)-1]和S[1…len(T)];
删除T末尾的字符,比较S[1…len(S)]和S[1…len(T)-1];
总结为,对于i>0,j>0的状态(i,j),m[i][j] = min( m[i-1][j-1]+(s[i]==s[j])?0:1 , m[i-1][j]+1, m[i][j-1] +1)。
这里的重叠子结构是S[1…i],T[1…j]。
%%%%%
基于此还可以进行子串匹配,求最长公共子序列。
3.最长公共子序列(Longest Common Subsequence,lcs)
对于序列S和T,求它们的最长公共子序列。例如X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A}则它们的lcs是{B,C,B,A}和{B,D,A,B}。求出一个即可。
解法:
和2类似,对于X[1…m]和Y[1…n],它们的任意一个lcs是Z[1…k]。
(1)如果X[m]=Y[n],那么Z[k]=X[m]=Y[n],且Z[1…k-1]是X[1…m-1]和Y[1…n-1]的一个lcs;
(2)如果X[m]!=Y[n],那么Z[k]!=X[m]时Z是X[1…m-1]和Y的一个lcs;
(3)如果X[m]!=Y[n],那么Z[k]!=Y[n]时Z是X和Y[1…n-1]的一个lcs;
下面是《算法导论》上用伪码描述的lcs算法。其中c[i][j]记录当前lcs长度,b[i][j]记录到达该状态的上一个状态。
扩展1:
如何输出所有的LCS?
分析:
根据上面c[i,j]和b[i,j]的构造过程可以发现如果c[i-1,j]==c[i,j-1],那么分别向上和向左返回的上一个状态都是可行的。如果将其标记为“左/上”并通过递归调用来生成从c[m,n]到c[1,1]的所有路径,就能找出所有的LCS。时间复杂度上界为O(mn)。
扩展2:
通过LCS获得最长递增自子序列。
分析:
对于1个序列,如243517698,最大值9,最小值1,那么通过将它与123456789求LCS得到的就是最长连续递增子序列23568。
这种做法不适用于最长连续非递减子序列,除非能获得重复最多的元素数目,如2433517698,那么可以用112233445566778899与之比较。
使用专门的最长递增子序列算法可以进行优化,详见下一部分。
4.最长递增子序列(Longest Increasing Subsequence,lis)
对于一个序列如1,-1,2,-3,4,-5,6,-7,其最长第增子序列为1,2,4,6。
解法:
除了利用3中lcs来求解,这里使用求解lis问题的专门方法。
先看看如何确定子结构的表示。对于长度为k的序列s[1…k],如果用lis[k]记录这个序列中最长子序列似乎没什么用,因为在构造lis[k+1]时,需要比较s[k]与前面长度为lis[k]的lis的最后一个元素、s[1…k]中长度为lis[k]-1的序列的最后一个元素等等,没有提供什么便利,这个方案被否决。
为了将每个lis[k]转化为构造lis[k+1]时有用的数据,把子结构记为以s[k]为结尾的lis的长度,那么对于s[k+1],需要检查所有在它前面且小于它的元素s[i],并令lis[k+1] = max(lis[i]+1),(i=1 to k,s[k+1]>s[i])。这样,一个O(n2)的算法便写成了。为了在处理完成后不必再一次遍历lis[1…n],可以使用一个MaxLength变量保存当前记录中最长的lis。
扩展:
求解lis的加速
分析:
在构造lis[k+1]的时候可以发现,对于s[k+1],真正有用的元素s[i] 可以发现使用数组MaxV[1…MAXLENGTH]其中MaxV[i]表示长度为i的lis的最小末尾元素,完全可以在s[k+1]时进行lis[k+1]的更新。进一步地发现,其实lis[]数组已经没有用了,对于MaxV[1…MAXLENGTH]中值合法对应的最大下标,就是当前最长的lis,也即利用MaxV[]更新自身。
同时,根据MaxV[]的更新过程,可以得出当i
5.最大连续子序列和/积
输入是具有n个数的向量x,输出时输入向量的任何连续子向量的最大和。
解法:
求和比较简单,有过比较全面的分析:http://www.cnblogs.com/wuyuegb2312/p/3139925.html#title4
这里只把O(n)的动态规划解法列在下面,其中只用一个变量保存过去的状态。
扩展1:
给定一个正浮点数数组,求它的一个最大连续子序列乘积的值。
解法:
对数组中每个元素取对数,构成新的数列,在新的数列上使用求最大连续子序列的算法。
如果求对数开销较大,建议使用扩展2的方法。
扩展2:
给定一个浮点数数组,其值可正可负可零,求它的一个最大连续子序列乘积的值。(假定计算过程中,任意一个序列的积都不超过浮点数最大表示)
解法:
在最大连续子序列和算法的基础上进行修改。由于负负得正,对于当前状态array[k],需要同时计算出它的最大值和最小值。即:
new_maxendinghere = max3(maxendingherearray[k],minendingherearray[k],array[k])
new_minendinghere = min3(maxendingherearray[k],minendingherearray[k],array[k])
此后对已遍历部分的最大积进行更新:
maxsofar = max(maxsofar,new_maxendinghere)
如果不习惯用常数个变量来表示,可以看看http://blog.csdn.net/wzy_1988/article/details/9319897,再想想用数组保存是不是浪费了空间。(计算max[k]、min[k]只用到了max[k-1]、min[k-1],没有必要保存全部状态)
6.矩阵链乘法
一个给定的矩阵序列A1A2…An计算连乘乘积,有不同的结合方法,并且在结合时,矩阵的相对位置不能改变,只能相邻结合。根据矩阵乘法的公式,10100和1005的矩阵相乘需要做101005次标量乘法。那么对于维数分别为10100、1005、550的矩阵A、B、C,用(AB)C来计算需要101005 + 105500 =7500次标量乘法;而A(BC)则需要100550+1010050=75000次标量乘法。
那么对于由n个矩阵构成的链
解法:
尽管可以通过递归计算取1<=k
[m[i][j]=\left{\begin{matrix} 0& if \ i=j\ \underset{i\leqslant k
扩展:
矩阵链乘法的备忘录解法(伪码),来自《算法导论》第15章。
7.0-1背包
一个贼在偷窃一家商店时发现了n件物品,其中第i件值vi元,重wi磅。他希望偷走的东西总和越值钱越好,但是他的背包只能放下W磅。请求解如何放能偷走最大价值的物品,这里vi、wi、W都是整数。
解法:
如果每个物品都允许切割并只带走其一部分,则演变为部分背包问题,可以用贪心法求解。0-1背包问题经常作为贪心法不可解决的实例(可通过举反例来理解),但可以通过动态规划求解。
为了找出子结构的形式,粗略地分析发现,对前k件物品形成最优解时,需要决策第k+1件是否要装入背包。但是此时剩余容量未知,不能做出决策。因此把剩余容量也考虑进来,形成的状态由已决策的物品数目和剩余容量两者构成。这样,所有状态可以放入一个n*(W+1)的矩阵c中,其值为当前包中物品总价值,这时有
[c[i][j]=\left{\begin{matrix} c[i-1][j]& if \ w_{i}>j\ \max\begin{Bmatrix} c[i-1][j-w_{i}]+v_{i} \ ,\ c[i-1][j] \end{Bmatrix} & if \ w_{i}\leqslant j \end{matrix}\right.]
8.有代价的最短路径
无向图G中有N个顶点,并通过一些边相连接,边的权值均为正数。初始时你身上有M元,当走过i点时,需要支付S(i)元,如果支付不起表示不能通过。请找出顶点1到顶点N的最短路径。如果不存在则返回一个特殊值,如果存在多条则返回最廉价的一条。限制条件:1
如果不考虑经过顶点时的花费,这就简化成了一个一般的两点间最短路径问题,可以用Dijkstra算法求解。加入了花费限制之后,就不能直接求解了。
考察从顶点0到达顶点i的不同状态,会发现它们之间的区别是:总花费相同但路径长度不同、总花费不同但路径长度不同。为了寻找最短路径,必然要保存到达i点的最短路径;同时为了找到最小开销,应该把到达i点的开销也进行保存。根据题目的数值限制,可以将总开销作为到达顶点i的一个状态区分。这样,就可以把Min[i][j]表示为到达顶点i(并经过付钱)时还剩余j元钱的最短路径的长度。在此基础上修改Dijkstra算法,使其能够保存到达同一点不同花费时的最短长度,最终的Min[N-1][0…M]中最小的即为所求。以下是求解过程的伪代码。
9.瓷砖覆盖(状态压缩DP)
用 1 * 2 的瓷砖覆盖 n * m 的地板,问共有多少种覆盖方式?
解法:
分析子结构,按行铺瓷砖。一块1*2瓷砖,横着放对下一行的状态没有影响;竖着放时,下一行的对应一格就会被占用。因此,考虑第i行的铺法时只需考虑由第i-1行造成的条件限制。枚举枚举第i-1行状态即可获得i行可能的状态,这里为了与链接一文一致,第i-1行的某格只有两个状态:空或者放置。空表示第i行对应位置需要放置一个竖着的瓷砖,这时在铺第i行时,除去限制以外,只需考虑放还是不放横着的瓷砖这2种情况即可(不必分为放还是不放、横到下一层还是竖着一共4种)。同时对于第i-1行的放法,用二进制中0和1表示有无瓷砖,那么按位取反恰好就是第i行的限制条件。
10.工作量划分
假设书架上一共有9本书,每本书各有一定的页数,分配3个人来进行阅读。为了便于管理,分配时,各书要求保持连续,比如第1、2、3本书分配给第1人,4、5分配给第二人,6,7,8,9分配给第3人,但不能1,4,2分配给第1人,3,5,6分配给第2人。即用两个隔板插入8个空隙中将9本书分成3部分,书不能换位。同时,分配时必须整本分配,同一本书不能拆成两部分分给两个人。为了公平起见,需要将工作量最大的那一部分最小化,请设计分配方案。用s1,…,sn表示各本书的页数。
解法:
继续从子结构的角度出发,发现如果前面的k-1份已经分好了,那么第k份自然也就分好了。用M[n][k]表示将n本书分成k份时最小化的k份中的最大工作量,从第k份也就是最后一份的角度来看,总数-它的不同情况下数量 = 前k-1份的数量和。
[M[n][k] = \overset{n}{\underset{i=1}{min}}\max(M[i][k-1],\sum_{j=i+1}^{n}s_{j})]
除此以外,初始化为
[M[1][k] = s_{1},for \ all \ k>0\ M[n][1] = \sum_{i=1}^{n}s_{1}]
自底向上地可以求得使M[n][k]最小化的解。
其他:
这个问题被称为线性分割(linear partition)问题,有不少的应用情形。如,一系列任务分配给几个并行进程,那么分配工作量最大任务的那个进程将成为影响最终完成时间的瓶颈。将最大的工作量尽量减少,能够使所有工作更快地完成。
11.三次捡苹果
(问题1的相关问题(1)的进一步扩展)一个矩形区域被划分为N*M个小矩形格子,在格子(i,j)中有A[i][j]个苹果。现在从左上角的格子(1,1)出发,要求每次只能向右走一步或向下走一步,每经过一个格子就把其中的苹果全部拿走,最后到达(N,M)。此时,只允许向上或向左走一步,反方向走回(1,1)。这一趟可以不走第一趟的路线,但当经过第一趟所经过的格子时,里面已经没有苹果了。到达(1,1)后,再次反方向地只允许向右或向下走,走到(N,M),同样可以不走前两趟走过的路线。求这三趟的走法,使得最终能拿取最多数量的苹果。
解法:
这个问题有两个难点,首先要理清三条路线的关系。可以发现,虽然第二趟方向相反,但其实和从(1,1)走到(N,M)是一样的,即三趟路线全部可以转化为从(1,1)向下或向右走到(N,M)的过程。
观察三条路线可以发现,实际中走的时候如果路线有交叉,可以把这种情况转化为相遇而不交错的情况如下图:
这样做的好处是,对于红线和蓝线上同一行j的点的坐标(i,j)(i’,j),总有i<=i’,这样就能够把三条路线划分成左、中、右三条有序的路线。
经过两次转化,可以构造子结构了。用Max[y-1][i][j][k]表示在y-1行时,三条路线分别在i、j、k时所能取得的最大苹果数,用Max[y-1][i][j][k]可以求解任意的Max[y][i’][j’][k’],其中i’ = i to j’ , j’ = j to k’, k’ = k to M. 如果线路重叠,苹果已经被取走,不用重复考虑。因此处理每一行时为了简单起见最好维护一个该位置苹果是否被取走的标志位,方便在路线重叠时计算。根据上面的范围关系,先求k’的所有情况,然后是j’,最后才是i’。这样Max[N][M][M][M]就是三趟后所能取得的最多苹果数。