一、从斐波那契数列看动态规划
斐波那契数列:Fn = Fn-1 + Fn-2
Fibonacci:除第一个和第二个数外,任意一个数可由前两个数相加得到。
1、练习:递归和非递归的方法来求解
使用递归和非递归的方法来求解斐波那契数列的第n项。
def fibnacci(n): """ 递归版本——斐波那契函数 :param n: :return: """ if n == 1 or n == 2: return 1 else: return fibnacci(n-1) + fibnacci(n-2) # 动态规划(DP)的思想 = 最优子结构 = 递推式 def fibnacci_no_recurision(n): """ 非递归版本——斐波那契 :param n: :return: """ f = [0, 1, 1] if n > 2: for i in range(n-2): num = f[-1] + f[-2] f.append(num) return f[n] # print(fibnacci(100)) # 需要计算很久 print(fibnacci_no_recurision(100)) # 354224848179261915075
2、递归速度问题——子问题重复计算
通过上例可以发现递归版本来计算斐波那契速度比非递归慢很多很多。
这是由于相同的问题算了很多遍,导致速度很慢。
为什么递归很慢:子问题的重复计算 f(5) = f(4)+f(3) f(4) = f(3)+f(2) f(3) = f(2)+f(1) f(2) = 1 f(1) = 1
而非递归版本就用到了 动态规划(DP)的思想 = 最优子结构 = 递推式 + 重复子问题
二、钢条切割问题
某公司出售钢条,出售价格与钢条长度之间的关系如下表:
问题:现有一段长度为n的钢条和上面的价格表,求切割钢条方案,使得总收益最大。
1、长度是n的钢条切割方案
(1)长度为4的钢条所有的切割方案如下所示:(c方案最优)
(2)思考:长度为n的钢条的不同切割方案有几种?
答:长度为n的钢条有n-1个可以切割的位置。每个可以切割的位置,都有切和不切两种选择。因此切割方案有2n-1个。
但如果过bd这样的情况看做是一种方案,这就比较难了,在组合数学里的叫整数分割问题。
(3)钢条长度和能卖出的最高价格关系如下所示:
比如钢条长度是4,整体卖的话价格是9,但如果切成2+2,则可以卖出10的价格;如果钢条长度是9,整体卖出价格是24,分拆为6+3,6最高可卖17,3最高可卖8,因此9可以卖出25的价格。
2、递推式
设长度为n的钢条切割后最优收益值为rn,可以得出递推式:
rn = max(pn, r1 + rn-1, r2 + rn-2, ..., rn-1 + r1)
- 第一个参数pn表示不切割的价格。
- 其他n-1个参数分别表示另外n-1种不同切割方案,对方案i=1,2,...,n-1
- 将钢条切割为长度为i和n-i两段
- 方案i的收益为切割两段的最优收益之和
- 考察所有的i,选择其中收益最大的方案。
3、最优子结构
可以将求解规模为n的原问题,划分为规模更小的子问题:完成一次切割后,可以将产生的两段钢条看成两个独立的钢条切割问题。
组合两个子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大的,构成原问题的最优解。
钢条切割满足最优子结构:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
4、递归求解简化
钢条切割问题还存在更简单的递归求解方法:
- 从钢条的左边切割下长度为i的一段,只对右边剩下的一段继续进行切割,左边的不再切割。
- 递推式简化为
- 不做切割的方案就可以描述为:左边一段长度为n,收益为pn,剩余一段长度为0,收益为r0=0。
5、钢条切割代码实现
(1)自顶向下实现
import time def cal_time(func): def wrapper(*args, **kwargs): t1 = time.time() result = func(*args, **kwargs) t2 = time.time() print("%s running time: %s secs." % (func.__name__, t2 - t1)) return result return wrapper # 钢条长度对应的价格 p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 21, 23, 24, 26, 27, 27, 28, 30, 33, 36, 39, 40] def cut_rod_recurision_1(p, n): """ 钢条切割——递归版(两边切割) :param p: 钢条价格 :param n: 钢条长度 :return: """ if n == 0: # 钢条不存在 return 0 else: res = 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 @cal_time def c1(p, n): return cut_rod_recurision_1(p, n) def cut_rod_recurision_2(p, n): """ 钢条切割——递归版(只一边切割) :param p:钢条价格 :param n:钢条长度 :return: """ if n == 0: return 0 else: res = 0 for i in range(1, n+1): # 从1到n,即1~n+i res = max(res, p[i] + cut_rod_recurision_2(p, n-i)) return res @cal_time def c2(p, n): return cut_rod_recurision_2(p, n) print(cut_rod_recurision_1(p, 9)) # 25 print(c1(p, 10)) print(c2(p, 10)) # 由于每次都少递归一次,效率高了很多 """ c1 running time: 0.013891935348510742 secs. 27 c2 running time: 0.0005898475646972656 secs. 27 """
自顶向下递归实现的效率这么差,原因分析:
仅分析n=4的情况就发现有大量相同子问题重复求解。
递归算法由于重复求解相同子问题,效率极低。时间复杂度:O(2n)
(2)自底向上实现
动态规划的思想:
- 每个子问题只求解一次,保存求解结果
- 之后需要此问题时,只需查找保存的结果
@cal_time def cut_rod_dp(p, n): """ 钢条切割——动态规划 :param p:钢条价格 :param n:钢条长度 :return: """ r = [0] # 长度为0时,最优收益为0 for i in range(1, n+1): # 计算出r1,r2,...,rn的最优收益 res = 0 # ri最小值默认是0 for j in range(1, i+1): # ri的i种方案 res = max(res, p[j] + r[i-j]) r.append(res) # 添加最优值 return r[n] print(cut_rod_dp(p, 10)) print(cut_rod_dp(p, 20)) """ cut_rod_dp running time: 2.7894973754882812e-05 secs. 27 cut_rod_dp running time: 7.104873657226562e-05 secs. 56 """
时间复杂度:O(n2),这是由于每个都是直接去取之前存好的值,而不是把值再算一遍,如下图所示:
6、重构解
如何修改动态规划算法,使其不仅输出最优解,还输出最优切割方案?
针对每个子问题,保存切割一次时左边切下的长度。
上表中,长度为i,r[i]是对应长度时最优收益,s[i]是左边切下的长度。
# 钢条长度对应的价格 # p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30] def cut_rod_extent(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 r.append(res_r) # 添加最优值 s.append(res_s) return r[n], s r, s = cut_rod_extent(p, 10) print(s) # 输出左边切下的长度 """ [0, 1, 2, 3, 2, 2, 6, 1, 2, 3, 10] """ def cut_rod_solution(p, n): """重构解——输出结果""" r, s = cut_rod_extent(p, n) ans = [] # 保存最后切成的样子 while n > 0: ans.append(s[n]) # 保存左边切下的长度 n -= s[n] # 剩下的长度 return ans print(cut_rod_solution(p, 9)) # [3, 6]
如果使用之前的钢条长度价格表:p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 21, 23, 24, 26, 27, 27, 28, 30, 33, 36, 39, 40]
print(cut_rod_dp(p, 20)) # 56 print(cut_rod_solution(p, 20)) # [2, 6, 6, 6] r, s = cut_rod_extent(p, 20) print(s) # [0, 1, 2, 3, 2, 2, 6, 1, 2, 3, 2, 2, 6, 1, 2, 3, 2, 2, 6, 1, 2]
三、动态规划问题关键特征
什么问题可以使用动态规划方法?
(1)最优子结构
- 原问题的最优解中涉及多少个子问题
- 在确定最优解使用哪些子问题时,需要考虑多少种选择
(2)重叠子问题
四、最长公共子序列(LCS)
一个序列的子序列是在该序列中删去若干元素后得到的序列。例如:“ABCD”和“BDF”都是“ABCDEFG”的子序列。
最长公共子序列(Longest Common Subsequence,简写LCS)问题:给定两个序列X和Y,求X和Y长度最大的公共子序列。例如:X=“ABBCBDE”, Y="DBBCDB", LCS(X,Y)="BBCD"
应用场景:字符串相似度比对、基因比对。
1、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。
2、最优解的递推式
c[i,j]表示Xi和Yj的LCS长度。
3、示例及解析
例如:要求a="ABCBDAB"与b="BDCABA"的LCS:
由于最后一位"B"≠"A",因此LCS(a,b)应该来源于LCS(a[:-1],b)与LCS(a,b[:-1])中的一个。意思就是最后一位不一样,要不去除a的最后一位,要不去除b的最后一位。
4、代码实现
def lcs_length(x, y): # x,y是列表或字符串 # 查看x,y的长度 m = len(x) n = len(y) # 用二维列表生成式生成一个m+1行,n+1列的二维列表 c = [[0 for _ in range(n+1)] for _ in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if x[i-1] == y[j-1]: # i,j位置上的字符匹配的时候,来自于左上方+1 c[i][j] = c[i-1][j-1] + 1 # 若i,j>0,且xi==yi else: c[i][j] = max(c[i-1][j], c[i][j-1]) # 若i,j>0,且xi!=yi for _ in c: # 将列表c逐行打印 print(_) return c[m][n] # print(lcs_length("ABCBDAB", "BDCABA")) # 4 """ [0, 0, 0, 0, 0, 0, 0] [0, 0, 0, 0, 1, 1, 1] [0, 1, 1, 1, 1, 2, 2] [0, 1, 1, 2, 2, 2, 2] [0, 1, 1, 2, 2, 3, 3] [0, 1, 2, 2, 2, 3, 3] [0, 1, 2, 2, 3, 3, 4] [0, 1, 2, 2, 3, 4, 4] """ 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]: # i,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(_) """ [0, 0, 0, 0, 0, 0, 0] [0, 2, 2, 2, 1, 3, 1] [0, 1, 3, 3, 2, 1, 3] [0, 2, 2, 1, 3, 2, 2] [0, 1, 2, 2, 2, 1, 3] [0, 2, 1, 2, 2, 2, 2] [0, 2, 2, 2, 1, 2, 1] [0, 1, 2, 2, 2, 1, 2] """ 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: # ==3,来自左方=》不匹配 j -= 1 print(res) return "".join(reversed(res)) print(lcs_trackback("ABCBDAB", "BDCABA")) # BCBA