算法学习笔记——动态规划:概述(动态规划的要素、动态规划与DFS/BFS/贪心算法的区别)

动态规划Dynamic Programming,DP问题

  • 动态规划问题一般形式就是求最值(最长递增子序列、最小编辑距离)
  • 本质就是穷举,但不是暴力穷举,其思想源于暴力穷举,但使用了“备忘录”或DP Table进行优化,此外再无奥妙可言(思考如何穷举->追求聪明地穷举)

ps. 以后看到求最值问题,养成条件反射:首先思考如何穷举所有可能结果

动态规划与暴力穷举的区别
  • 回溯(DFS)/BFS 都是暴力穷举所有可能结果,而如果问题具有最优子结构时,可以用以前计算的重叠子问题来求解更大的问题,此时就产生了动态规划
  • 因此,动态规划的核心是记忆:
    ①可以记忆,即最优子结构,问题可以被“分治”,用拆分的子问题来解决大问题
    ②需要记忆,即重叠子问题。(这是DP与回溯/DFS的主要区别,也是是能够使用动态规划的前提。像八皇后问题,没有重叠子问题,用 dp 也不能优化,反而浪费空间)

动态规划三要素

  • 动态规划三要素:重叠子问题、最优子结构、状态转移方程
    ①有重叠子问题决定了动态规划与暴力穷举的不同,动态规划能使用“备忘录”或DP Table进行优化 [这是能够使用动态规划的前提]
    子问题:将问题拆分为子问题,先解决子问题,再利用子问题解决大问题
    ②具备最优子结构,子问题相互独立,互不影响,才能保证通过子问题的最优解得到全局最优解
    ③列出正确的状态转移方程,是动态规划的核心
  • ps. 何为最优子结构:子问题相互独立,互不影响
    例如,要考出最高总分,那么每门课都要尽量考最高分,各门课互不干扰
    反之,如果加条件:语文考得高数学就会考得低,子问题相互制约不独立,语文数学成绩不能同时达到最优,就不是最优子结构
  • 一般递归问题是“自顶向下”求解,需不断调用递归函数,引发重叠子问题
    DP问题中,多使用“自底向上”求解,从而脱离了递归,由循环完成计算(利用DP Table避免重复计算子问题)

如何列出状态转移方程

核心问题就是状态、选择、DP数组的定义

  1. 思考问题的base case(最简单情况)是什么
  2. 思考问题有什么“状态”,即子问题中的变量
  3. 思考对于每个状态,有什么“选择”使状态改变
    ps. 技巧:类比数学归纳思想,假定已知dp[0]...dp[i-1],问自己怎么通过这些结果算出dp[i],最后将这个模式套入所有dp的推导即可
  4. 思考如何用DP数组/函数的含义来表现“状态”和“选择”,一般DP数组保存的变量(或DP函数的参数)就是2中的“状态”

最终,DP问题的整体框架就是

# 初始化base case
dp[0][0][...] = base
case
# 状态转移
for 状态1 in 其所有可能取值:
    for 状态2 in 其所有可能取值:
        for ...:
            dp[状态1][状态2][...] = 求最值(选择1, 选择2, ...)

展开说明动态规划的思路

总体而言,就是思考如何穷举->追求聪明地穷举,即确定状态转移方程,然后通过状态转移方程写出暴力递归解,然后优化递归树,消除重叠子问题

  1. 暴力递归,递归过程中有大量重叠子问题
  2. 利用备忘录的递归,自顶向下,避免重复计算
  3. 利用DP Table的非递归解法,自底向上,本质同上
  4. 优化:状态压缩

求斐波那契数列

  1. 暴力递归
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)

  1. 带备忘录的递归
    额外维护“备忘录”*(数组或哈希表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)

  1. 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做了优化

  1. 状态压缩:空间复杂度优化
    进一步的,由于当前状态只和前两个状态dp[i - 1]dp[i - 2]有关,因此可以不使用数组,直接用两个变量解决问题,进一步把空间复杂度降低为O(1)

动态规划与贪心算法

  • 动态规划是每一步计算出所有可能选择及其结果,然后比较求最值
  • 贪心是一类特殊的动态规划,比动态规划多一个性质:贪心选择性质:局部最优叠加,得到全局最优,每一步直接做出“看起来最优”的选择即可
  • 一般来说,贪心能解决的问题都能一眼看出来;
    如果看不出来,且使用动态规划都超时,那么就该考虑问题是否存在贪心选择性质了

贪心算法举例:区间调度问题

给出很多区间[start,end],求这些区间中最多有几个互不相交的区间

  1. 贪心思想:各个区间按结束时间升序排列,每次选出结束最早的那个区间x,(这样尽可能为后面其他区间留下更多空间,就更有机会选出不相交的区间)
  2. 然后删除与x相交的区间,再重复第一步

这个问题运用于实际,可描述为:一天中有很多活动,同一时间只能参加一个活动,求今天最多能参加几个活动?
具体问题有
LeetCode 435. 无重叠区间:给出一些区间,求需要移除区间的最小数量,使剩余区间互不重叠(先求最多有几个不相交区间,再用区间总数减去它)
LeetCode 452. 用最少数量的箭引爆气球

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值