动态规划算法的应用总结和代码实现
转载请注明出处:
http://blog.csdn.net/xiaohongsimon/article/details/10264735
动态规划(dynamic programming)
动态规划的一个英文解释非常到位:whenever the results of subproblems are needed, they have alreadybeen computed, and can simply be looked up in a table。即所有子问题的计算都能由查表来完成,动态规划的优势在于尽可能避免了子问题的重复计算。
1.1 O(N)问题
复杂度为O(N)的DP一般可以抽象为求f(N)的问题,还记得高中的数列题么,“已知f(N) = 2f(N-1),f(0)=1,求f(N)的通向表达式”,是不是很简单?只不过实际问题往往需要我们自己去发现这种递推关系,从而把复杂的问题简单化。
1.1.1 跳台阶问题
一个台阶总共有n级,如果一次可以跳1级,也可以跳2级。求总共有多少总跳法,并分析算法的时间复杂度。
把n级台阶时的跳法看成是n的函数,记为f(n)。当n>2时,第一次跳的时候就有两种不同的选择:一是第一次只跳1级,此时跳法数目等于后面剩下的n-1级台阶的跳法数目,即为f(n-1);另外一种选择是第一次跳2级,此时跳法数目等于后面剩下的n-2级台阶的跳法数目,即为f(n-2)。因此n级台阶时的不同跳法的总数f(n)=f(n-1)+(f-2)。
1.1.1.1 递归求法
分析到这里,相信很多人都能看出这就是我们熟悉的Fibonacci序列。直接去递归求解的话,会涉及到很多重复计算,我们以求解f(10)作为例子来分析递归求解的过程。要求得f(10),需要求得f(9)和f(8)。同样,要求得f(9),要先求得f(8)和f(7)……
在分析它的复杂度之前,我们还是直观的看下测试的时间曲线好了:
#encoding=utf8 def Fibonacci(n): if n == 1 or n ==0: return 1 return Fibonacci(n-1)+Fibonacci(n-2)
实现/长度 |
10 |
20 |
30 |
40 |
递归 |
4.69e-05 |
0.0046 |
0.5660 |
69.8280 |
看到这个结果,其实我自己都有些吃惊,长度还没有过百,花费时间已经到了一个让人无法忍受的地步了,为了更清晰的认知复杂度,还是画一下吧:
乍一看,以为是线性关系是吧?纵坐标可是log10哦,也就是说,递归解决这个问题,时间复杂度是指数级。那么这个指数级是怎么推导的呢?
仅从递推式来看,n每加1,计算时间近似要增加到两倍(如果f(N)=2f(N-1),那么很容易看出O(N) = 2^N),实际上复杂度为O(1.618^N),具体求法大家可以去参考一些特征值计算的方法,此处略过。
1.1.1.2 动态规划
def Fibonacci_dp(n): res = [1]*(n+1) if n == 1 or n ==0: return 1 for i in range(2,n+1): res[i]= res[i-1]+ res[i-2] return res[n]
实现/长度 |
10 |
20 |
30 |
40 |
递归 |
4.69e-05 |
0.0046 |
0.5660 |
69.8280 |
DP |
1.09e-05 |
9.05e-06 |
9.06e-06 |
1.09e-05 |
非常明显,DP的时间复杂度是O(N),而且只需要固定的物理空间开销,之所以把这个问题归类为一维DP,也是因为它需要一个一维辅助数组res[n+1]。
相比较之下,性能的提升已经非常明显。
1.1.1.3 矩阵求解
这个问题还有LogN的解法,不过与我们这次的DP主题无关了,以后有时间再补充吧。
1.1.2 最大和子序列
输入一个整形数组lst[],数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。求所有子数组的和的最大值以及这个子数组。要求时间复杂度为O(n)。
1.1.2.1 问题抽象:
F(n)表示序列0,1,…n的最大和,也就是说f(n)表示以lst[n]为结尾的数组的最大和。
1.1.2.2 递推关系:
f(n) =max(0,f(n-1)+lst(n))。
举例:
Lst[]:[-5,7,-2,-6, 5,-1, 4]
F[]: [0, 7, 5 ,0, 5, 4, 8]
F的最大值为8,也就是我们的最后所求。从这个位置开始往前回溯,一直遇到0位置,这个区间就是最大和子序列。时间复杂度O(N),空间复杂度O(N)。
def max_subsum(lst): n = len(lst) f = [0]*n f[0]= max(lst[0],0) for i in range(1,n): f[i]= max(0,f[i-1]+lst[i]) idx_max = f.index(max(f)) idx_min = idx_max while idx_min>=0and f[idx_min]> 0: idx_min -=1 return lst[idx_min+1:idx_max+1]
1.1.2.3 最大子矩阵
这其实是“最大子数组”问题的一种变形,只不过将一维问题转换成了2维。当然解决方法就是将2维问题,转换成1维。
首先遍历得到子矩阵a[i][n],i=1,2,…N,所有列加和得到一维数组,然后转换为最大子数组问题,复杂度O(N^3).
网上有一种叫做“悬线法”的解决方案,(《浅谈用极大化思想解决最大子矩形问题》王知昆),可以N^2内解决此问题,有机会再补充。
1.1.3 约瑟夫环问题
问题描述:n个数,0~n-1,0作为第1个数,删掉第m个数,则删掉的数的下一个数作为下一轮的第一个数,依次删除,直到剩下一个数为止,求这个数在原始序列中的下标。
分析:
f(n,m)表示n个人,按照规则最后剩下的人的下标。第一个删除的数下标是m-1,之后m变成了下一轮(f(n-1,m))的开头,m+1变成了1…
f(n,m)的下标 |
调整后 |
f(n-1,m)的下标 |
0 |
m |
0 |
1 |
M + 1 |
1 |
… |
M + 2 |
2 |
m – 1(要被删除) |
… |
… |
m |
N – 2 |
N – M – 2 |
m + 1 |
N – 1 |
N – M – 1 |
… |
0 |
N – M |
N – 2 |
1… |
N – (M – 1)… |
N – 1 |
M - 2 |
N – 2 |
通过观察不难发现,如果剩下的最后一个数在f(n-1,m)中的下标为x的话,那么它在f(n,m)中的下标就是(x+m)%n,而最后一个数的下标一定为0,所以可以用DP求导
相比较而言,这道题的娱乐性更强一些,在应用上没有其他几个问题更广泛。
1.2 O(N^2)问题
1.2.1 最长递增/递减子序列(LIS,LDS)
给定一个序列 a1,a2,...an,在不改变相对顺序的情况下,找出其最大的子集,满足对于任意的i<j,都有ai<aj
问题抽象:
令f[k]表示以ak结尾的最长LIS的长度
递推关系:
f[k] =max(f[i]) + 1, i<k && ai < ak
def lis_n2(seq): n = len(seq) if n <=0 : return 0 e = [1]* n #e[i] is thelength of sub lis which end with seq[i] step = [-1]* n #step[i] is predecessor of the sub seq.ending at seq[i] for i in range(1,n): _max = 0 for j in range(i): if seq[i]> seq[j]and e[j]>_max: _max = e[j] step[i]= j e[i]= _max+1 # e[i] = max( e[j]) + 1, j= 0...i-1 and seq[i]>seq[j] # max(e) is thelength of lis # further we getthe lis more than its length res = [] idx = e.index(max(e)) while(idx!= -1): res += [seq[idx]] idx = step[idx] res = res[::-1] # reverse return res
1.2.2 最长公共子序列(Longest Common Subsequence)
1.2.2.1 问题描述
给定两个序列(C中可认为是数组或字符串,python中可认为是list),找到二者的最大公共子序列。子序列中元素相对顺序不变,且不一定连续,例如“abcdef”中,"abc","ace"都算作子序列,当然不难得出一个结论,一个长度为n的序列,子序列成分为2^n个(回想下排列组合)
1.2.2.2 递归
对于指数复杂度问题,往往不能一步到位(直接穷举当然是不能接受的),所以考虑是否能通过迂回的办法,尝试解决掉它的子问题。对于两个序列x,y,长度分别为n,m,可以发现x,y的LCS结果可以由它的三个子问题其中之一得出:
1. LCS(x1...n-1,y1...m)
2. LCS(x1...n, y1...m-1)
3. LCS(x1...n-1,y1...m-1) + 公共尾元素
def lcs_len(x, y): """This function returns length of longest commonsequence of x and y.""" if len(x)== 0 or len(y)== 0: return 0 if x[-2]== y[-2]: # if last but one elements of x and y areequal return lcs_len(x[:-1], y[:-1])+ 1 else: return max(lcs_len(x[:-1], y), lcs_len(x, y[:-1]))
1.2.2.3 动态规划O(N^2)
显然,递归操作引入了很多重复计算。动态规划正好能解决这一问题,它的一个英文解释非常到位:whenever the results of subproblems are needed, they have alreadybeen computed, and can simply be looked up in a table。即所有子问题的计算都能由查表来完成!先来看下代码:
def lcs_dp(x, y): n = len(x) m = len(y) table = dict() # a hashtable, but we'll use it as a 2Darray here for i in range(n+1): # i=0,1,...,n for j in range(m+1): # j=0,1,...,m if i== 0or j ==0: table[i, j]= 0 elif x[i-1]== y[j-1]: table[i, j]= table[i-1, j-1]+ 1 else: table[i, j]= max(table[i-1, j], table[i, j-1]) # Now, table[n, m]is the length of LCS of x and y. # Let's go onestep further and reconstruct # the actualsequence from DP table: def recon(i, j): if i == 0 or j ==0: return"" elif x[i-1]== y[j-1]: return recon(i-1, j-1)+ str(x[i-1]) elif table[i-1, j] > table[i, j-1]: return recon(i-1, j) else: return recon(i, j-1) return recon(n, m)
代码中用到了一个2D的表,table(i,j)则表示子问题(i,j)的LCS_LEN,经过分析,它的值只可能是table(i-1,j-1),table(i,j-1),table(i-1,j)之一,所以从上到下,从左到右的赋值方式不会出现table(i,j)无法赋值的情况; 当然求得LCS_LEN并不是我们的最终目的,特别是在应用中,一般都需要得到这个LCS,那么可以通过table来求得结果(见代码)。
1.2.2.4 滚动数组—空间优化
这个问题的解决过程,可以看做从上到下、从左到右填充矩阵table的过程,所以它的时间和空间复杂度都是O(N^2)。如果时间复杂度还说的过去的话,那空间复杂度实在是无法容忍,一个进程的栈空间大概就是几M这个量级,想象一下N=1024时(这个长度一点都不算过分),如果每个元素4Byte,那么矩阵要消耗4M的栈空间,如果一个系统里用到了这样一个函数,那么再去实现其他功能就非常危险了,很有可能导致系统崩溃。那么,如何优化呢?
其实在计算过程中,table[i,j]只依赖三个值:table[i-1, j-1], table[i-, j] table[i, j-1],那么我们只需要两层就够了table[2][n],当我们想对第i行读写时,只需要利用下标i%2即可。这样空间复杂度也就降到了O(N)
def lcs_dp_v1(x, y): n = len(x) m = len(y) table = dict() # a hashtable, but we'll use it as a 2Darray here for i in range(n+1): # i=0,1,...,n for j in range(m+1): # j=0,1,...,m if i== 0or j ==0: table[i%2, j]= 0 elif x[(i-1+2)%2]== y[j-1]: table[i, j]= table[(i-1+2)%2, j-1]+ 1 else: table[i, j]= max(table[(i-1+2)%2, j], table[i, j-1]) return table[n%2,m]
代码中有一个细节需要注意,就是直接取(i-1)%2是非常危险的,所以需要转换成(i-1+2)%2加以保护。
1.2.3 编辑距离(Edited distance)
1.2.3.1 问题描述
两个字符串之间的编辑距离等于使一个字符串变成另外一个字符串而进行的(1)插入、(2)删除、(3)替换或(4)相邻字符交换位置而进行操作的最少次数。
1.2.3.2 递推关系
ed[i][j] = max(i,j)
ed[i][j]= min(edit(i-1, j) + 1, edit(i, j-1) + 1, edit(i-1, j-1) + f(i, j))
3. 若i>=2 and j>=2 and s1[i-1]==s2[j-2] ands1[i-2]==s2[j-1]:
ed[i][j]= min(edit(i-1, j) + 1, edit(i, j-1) + 1, edit(i-1, j-1) + f(i, j), edit(i-2, j-2)+ 1)
乍一看有些复杂,其实它的思路和LCS很像,过程都是对一个二维矩阵进行填充,只不过这里需要保存三层的数据。
1.2.3.3 O(N^2)实现
def edited_distance(s1,s2): mat = {} for i in range(0,len(s1)+1): for j in range(0,len(s2)+1): if i==0or j==0: mat[i,j]= i+j continue mat[i,j]= mat[i-1,j-1]+ int(s1[i-1]==s2[j-1]) if mat[i,j]> mat[i-1,j]+1: mat[i,j]= mat[i-1,j]+1 if mat[i,j]> mat[i,j-1]+1: mat[i,j]= mat[i,j-1]+1 if i>=2and j>=2and s1[i-2]==s2[j-1]and s1[i-1]==s2[j-2]and mat[i,j]>mat[i-2,j-2]+1: mat[i,j]= mat[i-2,j-2]+1 return mat[len(s1),len(s2)]
1.2.3.4 优化
首先,空间上也可以按照LCS中提到的滚动数组加以优化,其次,编辑距离中存在一个比较隐含的问题,就是在生成二维矩阵中,一个元素一定比它相邻右下角的元素小,具体推导就先略过了,至于它的用处,如果我们想实现一个基于某个阈值的查找(比如编辑距离超过5以后,就不需要再计算下去),那么在时间上会加速不少。
1.2.4 背包问题
1.2.4.1 问题描述
一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。
1.2.4.2 递推关系
其中dp[i,j]表示的是前i个物品装入容量为j的背包里所能产生的最大价值:
if (w[i]>j)
dp[i,j] = dp[i-1,j]
else
dp[i,j] = max(dp[i-1,j-w[i]]+v[i],dp[i-1,j])//是否要i两种情况
1.3 O(N^3)问题
1.3.1 邮局选址
1.3.1.1 问题描述
某条街上的周围有一些村庄(在一条直线上),需要在这些村庄中设立一些邮局(某个村庄最多设定一个),每个村庄到最近邮局的距离和最小。
1.3.1.2 递推关系:
状态转移方程:dp[i][j]表示前i个邮局放前j个村庄的最优解。
dp[i][j] =min( dp[i-1][k]+cost[k+1][j] ) i-1=<k <=j-1
#include <stdio.h> int v,p; int position[310]; int getCost(int i,int j) //村庄i到j之间建一个邮局的最小路径和 { int sum=0; while(i<j){ sum += (position[j--]-position[i++]); } return sum; } int main() { … for(j=1;j<=v;j++) dp[1][j]= getCost(1,j); for(i=2;i<=p;i++){ for(j=i;j<=v;j++){ minSum =0x7fffffff; for(k=i-1;k<=j-1;k++){ minSum= (minSum>dp[i-1][k]+getCost(k+1,j))? dp[i-1][k]+getCost(k+1,j): minSum; } dp[i][j]= minSum; } } printf("%d",dp[p][v]); return 0; }
1.3.2 最短路