动态规划
从斐波那契数列看动态规划
递归
最容易的就是使用递归
def fibnacci(n):
if n ==1 or n == 2:
return 1
else:
return fibnacci(n-1) + fibnacci(n-2)
非递归
我们也可以使用非递归算法
def fibnacci_no_recurision(n):
f = [0,1,1]
if n > 2:
for i in range(n-2):
num = f[-1] + f[-2]
f.append(num)
return f[n]
运行后,我们会发现当n取到100甚至更大时,递归方法就会很慢,非递归则会很快。
问题就出在递归会调用许多次子问题,例如:
#f(5) = f(4) + f(3)
#f(4) = f(3) + f(2)
#f(3) = f(2) + f(1)
#f(3) = f(2) + f(1)
此处f(3)要计算多次,有许多f(n)要算许多次。非递归的好处就在于每算过一次,就会放入列表中,从而避免重复计算。
这种非递归就是动态规划。
动态规划(DP)的思想
1.递推式
在斐波那契问题中,递推式就是已知的:
fibnacci(n)=fibnacci(n-1) + fibnacci(n-2)
而在大部分问题中,递推式我们需要自行发现。
2.重复子问题
因为存在重复子问题,我们就很想使用递归计算。使用我们会使用上述例题中的循环方式,把需要的子问题存起来,保证避免重复计算。
钢条切割问题
某公司出售钢条,出售价格与钢条长度之间的关系如下表:
问:现有一段长度为n的钢条和上面的价格表,求切割钢条方案,使得总收益最大。
我们可以从长度为4的钢条下手
我们可以发现,c方案是最优的。
思考:长度为n的钢条不同的切割方案有几种?
可以证明到,是2的(n-1)次方,显然一个个计算是不现实的。
我们再来看长度为1-10
r[i]是长度为i的钢条切割后的最大收益。
从长度为2开始看,如果选择不切,价格是5;如果切,则是1+1,价格是2.因此2的最大价格是5
看长度为3,如果选择不切,价格是8;如果切,则是1+2,价格是6.因此2的最大价格是8;此时,有人可能要问为什么2不切成1+1,事实上在长度为2的问题中,我们就已经考虑过长度为2的最大价格是5,因此在长度为3的问题中就不需要再去思考长度2的最大价格。
跳过中间,来看长度为9,如果选择不切,价格是24;如果切,则是1+8,价格是23,或是2+7,价格是23,或是3+6,价格是25,或是4+5,价格是23.因此9的最大价格是25.其中被分成的两段我们无需去考虑,因为在此前的问题中我们就已经算出了每一段的最大价格。
钢条切割问题的递推式
rn是长度为n的钢条切割后的最大收益。
从前面的思路我们可以想到:
rn = max(pn,r1+r(n-1),r2+r(n-2),…,r(n-1)+r1)
pn为不切割的价格,选择切割则一共有n-1套方案(因为长度为n,一共有n-1个切割点),最后计算所有n套组合,计算最大值。
最优子结构
产生子问题
可以将求解规模为n的原问题,划分为规模更小的子问题
在钢条切割问题,完成一次切割后,可以将产生的两段钢条看成两个独立的钢条切个问题。
构成最优解
组合两个子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大的,构成原问题的最优解。
在钢条切割问题,例如在考虑长度为9的钢条时,9切成3和6,如果3和6的都是最大时,9就可以保证最大(在切成3和6的情况下),再去思考其他的方案,找到总价格,再把所有的这些价格取最大值。
钢条切割问题
钢条切割满足最优子结构:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
钢条切割问题的递推式的优化
事实上,对于:rn = max(pn,r1+r(n-1),r2+r(n-2),…,r(n-1)+r1)
还可以进行简化:从钢条的左边切割下长度为i的一段,只对右边剩下的一段继续进行切割,左边的不再切割,那么递推式简化为rn = max (pi+r(n—i))(1≤i≤n)
不做切割的方案就可以描述为∶左边一段长度为n,收益为pn,剩余一段长度为0,收益为r0=0。
自顶向下实现(递归)
未优化
def cut_rod_recurision_1(p,n):
if n == 0:
return 0
else:
res = p[n] #p[n]是不切割的价格
for i in range(1,n):
res = max(res,cut_rod_recurision_1(p,i) + cut_rod_recurision_1(p,n-i))
return res
p = [0,1,5,8,9,10,17,17,20,24,30]
print(cut_rod_recurision_1(p,5))
优化
def cut_rod_recurision_2(p,n):
if n == 0:
return 0
else:
res = 0
for i in range(1,n+1):
res = max(res,p[i] + cut_rod_recurision_2(p,n-i))
return res
p = [0,1,5,8,9,10,17,17,20,24,30]
print(cut_rod_recurision_2(p,5))
时间复杂度:O(2的n次方)
这种方法为什么会如此之慢,问题就在于子问题的重复计算
自底向上实现(动态规划)
代码实现
def cut_rod_dp(p,n):
r = [0]
for i in range(1,n+1): #计算长度为i的最大价格
res = 0
for j in range(1,i+1): #计算长度为i的n种方案(包括不切割)
res = max(res,p[j] + r[i-j])
r.append(res) #存入列表,便于后面直接调用,避免递归调用重复计算
return r[n]
p = [0,1,5,8,9,10,17,17,20,24,30]
print(cut_rod_dp(p,5))
时间复杂度:O(n的2次方)
当遇到重复子问题时,是直接去取值,而不是去重复计算
钢条切割问题对的重构解
思路
每个钢条首先被分成左边不切割的部分和右边切割的部分,首先我们记录下左边不切割的部分,然后去看右边切割的部分,该部分又会被分成左边不切割的部分和右边切割的部分,再记录下左边不切割的部分,以此类推。
举个例子,假设长度为9的钢条被切割成左边不切割的部分3和右边切割的部分6(不一定正确),那么我们首先记录下左边不切割的部分3,再去看右边切割的部分6,6又被分成左边不切割的部分2和右边切割的部分4(不一定正确),再去看右边切割的部分4,以此类推
代码实现
def cut_rod_ectend(p,n):
r = [0]
s = [0]
for i in range(1,n+1):
res_r = 0 #价格的最大值
res_s = 0 #价格最大值对应方案的左边不切割的长度
for j in range(1,i+1):
if p[j] + r[i-j] > res_r:
res_r = p[j] + r[i-j]
res_s = j #给res_r赋左边不切割的长度
r.append(res_r)
s.append(res_s)
return r[n],s #返回价格最大值和每个长度对应左边不切割的长度
p = [0,1,5,8,9,10,17,17,20,24,30]
print(cut_rod_ectend(p,10))
def cut_rod_solution(p,n):
r,s = cut_rod_ectend(p,n)
ans = []
while n > 0:
ans.append(s[n]) #把长度n对应左边不切割的长度加入到ans
n -= s[n] #然后把剩余部分(即右边切割的部分)赋给n
return ans
print(cut_rod_solution(p,5))
print(cut_rod_solution(p,8))
print(cut_rod_solution(p,10))
最长公共子序列
子序列
一个序列的子序列是在该序列中删去若干元素后得到的序列。
例如,ABCD和BDF都是ABCDEFG的子序列,注意:子序列不一定是序列中连续的字符,可以不连续,因为是删去若干元素后得到的序列,这个例子中BDF就不是连续 。
最长公共子序列(Lcs)问题
给定两个序列X和Y,求X和Y长度最大的公共子序列。
例如,X=‘ABBCBDE’ Y=‘DBBCDB’,则Lcs(X,Y)=‘BBCD’
LCS最优子结构定理
定理
令X={x1,x2,…,xm}和Y={y1,y2,…,yn}为两个序列,Z={z1,z2,…,zk}为X和Y的LCS。
1.如果xm=yn,则zk=xm=yn且Zk-1是Xm-1和Yn-1的一个 LCS。
2.如果xm≠yn,那么zk≠xm,意味着Z是Xm-1和Y的一个 LCS。
3.如果xm≠yn,那么zk≠yn,意味着Z是X和Yn-1的一个 LCS。
解释
定理1:例如X=‘ABCD’,B=‘ABD’,则xm=yn=D,那么最长公共子序列Z就是ABD,且zk=xm=yn=D,同时Zk-1(AB)是Xm-1(ABC)和Yn-1(AB)的一个 LCS。
定理2和定理3,例如a='ABCBDAB’与b=‘BDCABA’,由于最后一位B≠A,则Lcs(a,b) 来源于Lcs(a[:-1],b)和 Lcs(a,b[:-1])的最大值。
同时我们也可以用过一个矩阵来理解:
每个空格是每两个对应字符串的最长公共子序列的长度。
第一行与第一列是空串和每一个完整字符串的最长公共子序列,因此都是0.
看1号位置,由于两个字符串末尾A≠B,所以最终的最长公共子序列的长度来源于6和7号位置的最大的值,即为0.
看2号位置,由于两个字符串末尾B=B,所以两个字符串都去掉末位,求最长公共子序列的长度并+1,即5号位置+1,最后的答案是1。
看4号位置,由于两个字符串末尾B≠D,所以最终的最长公共子序列的长度来源于2和5号位置的最大的值,即为1.
所以,可以得到:
代码实现最长公共子序列(Lcs)的长度
def lcs_length(x,y):
m = len(x)
n = len(y)
c = [[0 for _ in range(n+1)] for _ in range(m+1)] #创建一个m+1行n+1列的矩阵
for i in range(1,m+1):
for j in range(1,n+1):
if x[i-1] == y[j-1]:
c[i][j] = c[i-1][j-1] + 1
else:
c[i][j] = max(c[i-1][j],c[i][j-1])
for _ in c:
print(_)
return c[m][n]
print(lcs_length('ABCBDAB','BDCABA'))
和我们最初的矩阵一样
代码实现最长公共子序列(Lcs)的路径
首先标记每个格子的来源
对于每个格子,来源只有可能是左方,上方,左上方,所以我们依次用1 2 3分别代表左上方 上方 左方
def lcs(x,y):
m = len(x)
n = len(y)
c = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
b = [[0 for _ in range(n + 1)] for _ in range(m + 1)] #用于存放方向 定义1左上方 2上方 3左方向
for i in range(1,m+1):
for j in range(1,n+1):
if x[i-1] == y[j-1]:
c[i][j] = c[i-1][j-1] + 1
b[i][j] = 1
elif c[i-1][j] > c[i][j-1]: #来自上方
c[i][j] = c[i-1][j]
b[i][j] = 2
else:
c[i][j] = c[i][j-1]
b[i][j] = 3
return c[m][n],b
c, b =lcs('ABCBDAB','BDCABA')
for _ in b:
print(_)
使用回溯法记录路径
用数组b记录路径
def lcs_trackback(x,y):
c,b = lcs(x,y)
i = len(x)
j = len(y)
res = []
while i > 0 and j > 0:
if b[i][j] == 1: #说明匹配,且来自左上角
res.append(x[i-1])
i -= 1
j -= 1 #往左上角走
elif b[i][j] == 2: #说明不匹配,且来自上方
i -= 1 #往上方走
else: #说明不匹配,且来自左方
j -= 1 #往左方走
return "".join(reversed(res))
print(lcs_trackback('ABCBDAB','BDCABA'))
但需要注意,此方法仅会返回一个现最长公共子序列,事实上这个例子还有一个现最长公共子序列是BCAB,这取决于这里是否有=:
小结
事实上回溯法与动态规划经常要搭配使用,因为动态规划只计算出最优值,过程则需要回溯法求得。