动态规划Dynamic Programming,DP问题
- 动态规划问题一般形式就是求最值(最长递增子序列、最小编辑距离)
- 其本质就是穷举,但不是暴力穷举,其思想源于暴力穷举,但使用了“备忘录”或DP Table进行优化,此外再无奥妙可言(思考如何穷举->追求聪明地穷举)
ps. 以后看到求最值问题,养成条件反射:首先思考如何穷举所有可能结果
动态规划与暴力穷举的区别
- 回溯(DFS)/BFS 都是暴力穷举所有可能结果,而如果问题具有最优子结构时,可以用以前计算的重叠子问题来求解更大的问题,此时就产生了动态规划
- 因此,动态规划的核心是记忆:
①可以记忆,即最优子结构,问题可以被“分治”,用拆分的子问题来解决大问题
②需要记忆,即重叠子问题。(这是DP与回溯/DFS的主要区别,也是是能够使用动态规划的前提。像八皇后问题,没有重叠子问题,用 dp 也不能优化,反而浪费空间)
动态规划三要素
- 动态规划三要素:重叠子问题、最优子结构、状态转移方程
①有重叠子问题决定了动态规划与暴力穷举的不同,动态规划能使用“备忘录”或DP Table进行优化 [这是能够使用动态规划的前提]
子问题:将问题拆分为子问题,先解决子问题,再利用子问题解决大问题
②具备最优子结构,子问题相互独立,互不影响,才能保证通过子问题的最优解得到全局最优解
③列出正确的状态转移方程,是动态规划的核心 - ps. 何为最优子结构:子问题相互独立,互不影响
例如,要考出最高总分,那么每门课都要尽量考最高分,各门课互不干扰
反之,如果加条件:语文考得高数学就会考得低,子问题相互制约不独立,语文数学成绩不能同时达到最优,就不是最优子结构 - 一般递归问题是“自顶向下”求解,需不断调用递归函数,引发重叠子问题
DP问题中,多使用“自底向上”求解,从而脱离了递归,由循环完成计算(利用DP Table避免重复计算子问题)
如何列出状态转移方程
核心问题就是状态、选择、DP数组的定义
- 思考问题的base case(最简单情况)是什么
- 思考问题有什么“状态”,即子问题中的变量
- 思考对于每个状态,有什么“选择”使状态改变
ps. 技巧:类比数学归纳思想,假定已知dp[0]...dp[i-1]
,问自己怎么通过这些结果算出dp[i]
,最后将这个模式套入所有dp的推导即可 - 思考如何用DP数组/函数的含义来表现“状态”和“选择”,一般DP数组保存的变量(或DP函数的参数)就是2中的“状态”
最终,DP问题的整体框架就是
# 初始化base case
dp[0][0][...] = base
case
# 状态转移
for 状态1 in 其所有可能取值:
for 状态2 in 其所有可能取值:
for ...:
dp[状态1][状态2][...] = 求最值(选择1, 选择2, ...)
展开说明动态规划的思路
总体而言,就是思考如何穷举->追求聪明地穷举,即确定状态转移方程,然后通过状态转移方程写出暴力递归解,然后优化递归树,消除重叠子问题
- 暴力递归,递归过程中有大量重叠子问题
- 利用备忘录的递归,自顶向下,避免重复计算
- 利用DP Table的非递归解法,自底向上,本质同上
- 优化:状态压缩
求斐波那契数列
- 暴力递归
def fib(N):
if N == 0:
return 0
if N == 1 or N == 2:
return 1
return fib(N - 1) + fib(N - 2)
凡是递归问题,画出递归树,由助于分析算法复杂度和算法低效的原因
递归算法复杂度 = 子问题个数 x 解决一个问题所需时间(无循环则为1)
这个解法存在大量重叠子问题,计算f(19)最终需要计算f(18)、f(17)、…、f(1),而计算f(20)又需要重复一遍f(19)的计算过程,最终递归树是一个二叉树,复杂度O(2^n)
- 带备忘录的递归
额外维护“备忘录”*(数组或哈希表dict),只计算没有答案的子问题,计算计算过的子问题将被保存,需要时直接取用
def fib(N):
global memo
if N not in memo: # 计算未知结果并保存到备忘录
memo[N] = fib(N - 1) + fib(N - 2)
return memo[N] # 从备忘录中取出已有结果
memo = {0: 0, 1: 1, 2: 2} # 备忘录
print(fib(100))
也可以直接使用Python提供的函数装饰器@functiontools.lru_cache(None)
,实现等价的功能
- 有修饰器,内部的
print('solving problem')
只执行10次;- 没有修饰器,
print('solving problem')
执行55次可见,该修饰器能够完成“备忘录”功能,遇到已经计算过的问题,直接返回既有结果
import functools
@functools.lru_cache(None) # lru装饰器,完成记忆化搜索
def fib(N):
if N == 0:
return 0
if N == 1 or N == 2:
return 1
print('solving problem')
return fib(N - 1) + fib(N - 2)
print(fib(10))
子问题每个只计算一遍,复杂度降为O(n)
- dp数组的迭代解法
一般递归问题是“自顶向下”求解,需不断调用递归函数
DP问题中,多使用“自底向上”求解,从而脱离了递归,由循环完成计算
其实就是把上一步的“备忘录”独立出来为DP Table,两者的效率基本相同
def fib(N):
if N == 0:
return 0
if N == 1 or N == 2:
return 1
dp = [0 for _ in range(N + 1)]
# base case
dp[1] = dp[2] = 1
for i in range(3, N + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[N]
暴力解法是return fib(N - 1) + fib(N - 2)
,动态规划解法是dp[i] = dp[i - 1] + dp[i - 2]
,可见状态转移方程直接代表着暴力解法,这再次印证了:动态规划本质就是暴力穷举,只不过使用DP Table做了优化
- 状态压缩:空间复杂度优化
进一步的,由于当前状态只和前两个状态dp[i - 1]
和dp[i - 2]
有关,因此可以不使用数组,直接用两个变量解决问题,进一步把空间复杂度降低为O(1)
动态规划与贪心算法
- 动态规划是每一步计算出所有可能选择及其结果,然后比较求最值
- 贪心是一类特殊的动态规划,比动态规划多一个性质:贪心选择性质:局部最优叠加,得到全局最优,每一步直接做出“看起来最优”的选择即可
- 一般来说,贪心能解决的问题都能一眼看出来;
如果看不出来,且使用动态规划都超时,那么就该考虑问题是否存在贪心选择性质了
贪心算法举例:区间调度问题
给出很多区间[start,end]
,求这些区间中最多有几个互不相交的区间
- 贪心思想:各个区间按结束时间升序排列,每次选出结束最早的那个区间
x
,(这样尽可能为后面其他区间留下更多空间,就更有机会选出不相交的区间) - 然后删除与
x
相交的区间,再重复第一步
这个问题运用于实际,可描述为:一天中有很多活动,同一时间只能参加一个活动,求今天最多能参加几个活动?
具体问题有
LeetCode 435. 无重叠区间:给出一些区间,求需要移除区间的最小数量,使剩余区间互不重叠(先求最多有几个不相交区间,再用区间总数减去它)
LeetCode 452. 用最少数量的箭引爆气球