DP 动态规划初识
前面的 HMM 中参数求解, 都会用到动态规划, 全是各种概率公式, 是有一些抽象, 今天决定举个一波简单的栗子, 帮助理解DP 把一个大问题,不断划分为更小的子问题来求解的这种方式, 就是动态规划. 这是最为直观和通俗的理解.
DP vs 递归
我之前也是经常把 DP 和递归弄混淆. 递归, 其实就是, 函数调用自身. 在某种程度上来说, 递归和DP有其相似性. 我的理解是, DP 降低了 递归 的时间复杂度, 具体说, DP解决了递归的重复子问题计算(overlap) 问题. 为了说明其问题, 从 B站找了几段视频来帮助理解.
case: 斐波那契数列
已知: 1, 1, 2, 3, 5, 8, 13, 21, 34 ......
求第 n 个数的值.
其实这就是一个非常典型的, 用递归来表述的一类问题, 递归专注两个因素: 退出条件, 递推关系
"""
主题:
动态规划 之 斐波那契数列
问题特点:
类似递归, 有很多的重复子问题 overlap
描述:
f(1) = 1
f(2) = 1
f(3) = f(2) + f(3)
f(4) = f(3) + f(2)
...
f(n) = f(n-1) + f(n-1)
"""
def rec_opt(n):
"""递归-求解斐波那契数列"""
if n <= 2:
return 1
return rec_opt(n - 1) + rec_opt(n - 2)
非常好理解, 但这种写法会带来什么问题呢? 就是时间复杂度高, 是 \(O(2^n)\), 比如要计算 f(5):
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(4) 的时候, 计算了 f(3), f(2), f(1); 而右边在计算 f(3) 的时候, 又在重复计算 f(2), f(1), 随着规模越大, 重复计算的量会呈指数级增长, 这个就叫做 overlap. 这就是递归, 在这个时候, 会极大第增加时间复杂度.
其实我们如何这样想, 既然在计算 f(3) 的时候, 会用到, f(1), f(2); f(4) 的时候, 会用到 f(3) + f(2) ... 那, 我们为什么不会想到, 把这些计算好的历史数据给存起来, 直接用呢?
即, 可以用一个 list 将历史数据给存起来, 要用的时候, 直接索引就ok了, 复杂度是: \(O(n)\) 这样利用历史数据来推测后面的方法, 就是动态规划.
- 退出条件
- 初始值
- 状态转移方程
def dp_opt(n):
"""DP 求解斐波那契数列"""
# 用来存历史数据的列表
lst = [0 for _ in range(n)]
# 退出条件
if n < 2:
return 1
# 初始化数列的第 0,1 项
lst[0], lst[1] = 1, 1
for i in range(2, n):
# 状态转移方程
lst[i] = lst[i - 1] + lst[i - 2]
return lst[n - 1]
正好来测试一波时间对比
def calc_func_time(func, n):
print(f"test 规模为{n}的函数{func}运行时间为:")
start_time = time.time()
func(n)
end_time = time.time()
print("time used", end_time - start_time, "s.")
if __name__ == '__main__':
# 递归就是不容易使用装饰器写计时器来哦
n = 40 # 规模
calc_func_time(dp_opt, n)
calc_func_time(rec_opt, n)
C:\Python\Python36\python.exe E:/Pytest/base_case/DP-斐波那契数列.py
test 规模为40的函数<function dp_opt at 0x00000271927B7840>运行时间为:
time used 0.0 s.
test 规模为40的函数<function rec_opt at 0x0000027190902E18>运行时间为:
time used 39.237488985061646 s.
可以看到, 递归的复杂度真的太可怕了, 而DP非常淡定,
当规模超过1000, 递归, 我的电脑都不动了...而可以测试一波 DP, 更大的.
if __name__ == '__main__':
# 递归就是不容易使用装饰器写计时器来哦
n = 10000 # 1万规模
calc_func_time(dp_opt, n)
test 规模为100000的函数<function dp_opt at 0x0000026675A67840>运行时间为:
time used 0.5376913547515869 s.
DP 果然牛逼! 毕竟复杂度为 \(O(n)\) 嘛
DP 入门级案例
举个简单的栗子来熟悉求解流程即可, 稍微复杂的, 我自己个还在研究, 不敢妄言.....
全篇想说明一个核心技巧: 选 or 不选.
case1: 爬 n阶 台阶
事实是这样的: 小陈同学有一双令人无比羡慕的大长腿, 现在呢, 要去爬一个有 n 阶的台阶, 每次要么向上走1步, 要么向上走3步, 请问, 小陈同学从底部爬到台阶顶端, 一共有多要种可能的方案?
分析:
首先定义一个函数 f(n) 表示爬到第 n 阶级时有 最多的方案数 则
f(1) = 1 最多1种方案, 向上1步
f(2) = 1 最多1中方案, 走1步, 再走1步
f(3) = 2 最多2种方案, 走1步重复3次; 或直接走3步
f(4) = f(1) + f(3) 最多3种方案, 1步重复4次; 先1步,再3步; 先3步, 再1步
...
其实就是 f(n), 在要到达 n 前有2个选择: 要么从选择 f(n-1); 要么选择f(n-3) , 两种情况之和, 就是总方案数
f(n) = f(n-1) + f(n-3)
def dp_craw(n):
# 1. 用list来存储历史数据,可理解为最优方案的函数值
opt = [0 for _ in range(n+1)]
# 2. 写退出条件
if n <= 2:
return 1
elif n == 3:
return 2
# 3. 初始化历史数据
opt[0] = 1
opt[1] = 1
opt[2] = 2
# 4. 状态转移方程
for i in range(2, n):
opt[i] = opt[i-1] + opt[i - 3]
return opt[n-1]
if __name__ == '__main__':
print(dp_craw(50))
C:Python36\python.exe E:/Pytest/base_case/DP爬台阶.py
83316385
当然这这样写还是有重复项的, 复杂度并非 \(O(n)\) 嗯, 不想改了, 主要是阐明这种思路就好.
case2: 给定一堆数字求最大值 (带约束)
"""
描述:
arr = [1, 2, 4, 1, 7, 8, 3]
下标: 0, 1, 2, 3, 4, 5, 6
需求:
目标: 从数组(列表) 中选择任意个元素, 求出其之和最大的值
约束: 相邻元素不能选, 如选了8, 则不能选 7 和 3
思路:
定义一个最优方案函数 opt(n) 表示前n个中最好的选择方案
比如本例, opt(1) = 1, opt(2)=1
tips: 选和不选
抽象: 对于下标i, 有选和不选
if 选择当前值arr[i]:
值 = opt(i-2) + arr[i] 不能选相邻元素, 从前i-2个中选最好方案
else:
值 = opt(i-1) 不选,则中前i-1 个中选择最好方案
最后比较取最大, 即: max( (opt(i-1) + arr[i]), opt(i) )
递归出口:
opt[0] = arr[0] 前下标0个, 即第一位, 只有一种最优方案,就是选第一个值arr[0]
opt[1] = max( arr[0], arr[1]) 前两个,则从1,2中选最大
"""
def rec_opt(arr, i):
"""递归"""
if i == 0:
return arr[0]
elif i == 1:
return max(arr[0], arr[1])
else:
select_i = rec_opt(arr, i - 2) + arr[i]
no_select_i = rec_opt(arr, i - 1)
return max(select_i, no_select_i)
# 递归是产生很多的重叠子问题, overlap 之前演示过与DP的对比,复杂度高很多
def dp_opt(arr):
"""DP实现"""
n = len(arr)
# 用一个list来存储前 i 个最优方案的值, opt 比 lst 要更形象些
opt = [0 for _ in range(n)]
# 初始化前0, 前1的小标下的最优方案值
opt[0] = arr[0]
opt[1] = max(arr[0], arr[1])
# 从第3个元素起, 后面项将前面的项作为其子问题
for i in range(2, n):
# 选择下标为i的值和不选, 比较取最大, 前面的值都有存opt
select_i = opt[i - 2] + arr[i]
no_select_i = opt[i - 1]
opt[i] = max(select_i, no_select_i)
return opt[n - 1]
if __name__ == '__main__':
opt = [1, 2, 4, 1, 7, 8, 3]
opt1 = [4, 1, 1, 9, 1]
print(rec_opt(opt, 6))
print(dp_opt(opt1))
case3: 数字之和为定值
"""
描述:
arr = [3, 34, 4, 12, 5, 12]
S = 9
需求:
从 arr 中选择数字, 使其值和等于定值 S=9, 如果可以返回 True, 否则 False
思路:
跟之前一样的思路: 对于每个元素, 有两种选择,选 or 不选
定义一个subset(arr, i, s)
if 选择arr[i]:
A = subset(arr, i, s-arr[i])
else:
B = subset(arr, i-1, s)
A or B 是True 则True
退出条件:
if sub_set(arr[i], s) 中 s=0 则 return True
if sub_set(arr[0], s) 中 arr[0] != s return False
还约定, arr的每个元素都是正整数.即当 arr[i] > s, 只考虑不选, 即: sub_set(arr[i-1], s)
"""
def rec_subset(arr, i, s):
if s == 0:
return True
elif i == 0:
return arr[0] == s
elif arr[i] > s:
return rec_subset(arr, i - 1, s)
else:
# arr[i] < s, 有选和不选两种情况
select_i = rec_subset(arr, i - 1, s - arr[i])
no_select_i = rec_subset(arr, i - 1, s)
# 二者其中一个满足条件即可
return select_i or no_select_i
import numpy as np
# 用2维数组来记录,行代表arr[i], 列代表s=1, s=2...
def dp_subset(arr, s):
"""DP求解"""
subset = np.zeros((len(arr), s + 1), dtype=bool)
subset[:, 0] = True
subset[0, :] = False
subset[0, arr[0]] = True
for i in range(1, len(arr)):
for s in range(1, s + 1):
if arr[i] > s:
subset[i, s] = subset[i - 1, s]
else:
select_i = subset[i - 1, s - arr[i]]
no_select_i = subset[i - 1, s]
subset[i, s] = select_i or no_select_i
row, col = subset.shape
return subset[row - 1, col - 1]
if __name__ == '__main__':
lst = [3, 34, 4, 12, 5, 12]
print(rec_subset(lst, len(lst) - 1, 9))
print(dp_subset(lst, 9))
True
True
入门就到这吧, 后面再研究一波复杂的DP. 感觉还是非常锻炼思维能力的. 嗯, 咋说呢, 我感觉对我有点难, 尤其是多维数组, 图, 树这一块的抽象... 用我同事的话说, 就是我, 数论可以, 但空间不行...这感觉, 老天爷不赏饭呀....