算法学习之路:使用最简单的方式给你讲讲动态规划算法

动态规划算法

动态规划算法

  • 动态规划问题的一般形式就是求最值:
    • 动态规划是运筹学的一种最优化方法
  • 动态规划的应用场景:
    • 求最长递增子序列
    • 求最小编辑距离
  • 动态规划的核心问题:
    • 穷举
    • 因为要求最值,肯定要将所有可行的答案穷举出来,然后在其中找最值
  • 动态规划的穷举很特殊:
    • 存在重叠子问题:
      • 如果暴力穷举效率低下
      • 需要使用备忘录或者DP Table来优化穷举过程,避免不必要的计算
    • 具备最优子结构: 这样才能通过子问题的最值找到原问题的最值
    • 列出正确的状态转移方程才能正确地穷举: 因为穷举出所有可行解并不是一件容易的事
  • 动态规划三要素:
    • 重叠子问题
    • 最优子结构
    • 状态转移方程
  • 状态转移方程思维框架:
    • 明确状态
    • 定义DP数组或者函数的含义
    • 明确选择
    • 明确base case

斐波那契数列

直接递归

  • 斐波那契数列数列的数学形式就是递归的,代码如下:
int fib(int N) {
    if (N == 1 || N == 2) {
        return 1;
    } 
    return fib(N-1) + fib(N-2);
}

这是最简单易懂的直接递归的方法,同时也是效率最低的方法,可以通过画出递归树看出

  • 递归的问题分析,最好都画出递归树,方便分析算法的复杂度,寻找算法低效的原因
  • 递归算法的时间复杂度 = 子问题个数 * 解决一个子问题需要的时间
  • 当N=20时,斐波那契数列递归树分析:
    • 要计算f(20),就要计算出f(19)和f(18),然后要计算f(19),就要计算出f(18)和f(17),以此类推.最后到f(2)和f(1)的时候,结果已知.此时递归树结束
    • 根据递归算法时间复杂度等于子问题个数乘以解决一个子问题需要的时间:
      • 子问题个数: 也就是递归树中节点的总数.因为二叉树的节点总数为指数级别,所有子问题的个数为O(2N)
      • 解决一个子问题需要的时间: 因为这个算法中没有循环,只有f(n-1)+f(n-2)一个加法操作,所有时间为O(1)
      • 直接递归算法时间复杂度: O(2n),为指数级别.相当复杂
    • 观察递归树,分析出算法低效的原因:
      • 存在大量的重复计算
      • f(18)被计算了两次,以f(18)为根的递归树体量巨大,多算一遍,就会耗费大量时间
      • 而且,不止f(18)这一个节点会被重复计算,进而导致算法低下
      • 这个问题就是动态规划的第一个性质 : 重叠子问题

带备忘录的递归算法

  • 重叠子问题解决:
    • 因为耗时的原因是因为重复计算
    • 那么可以创建一个备忘录:
      • 每次算出某个子问题的答案先记录到备忘录中再返回
      • 每次遇到一个子问题先去备忘录中查询
      • 如果发现备忘录中已经存在这个解决过的问题,直接获取答案,不需要再耗时计算了
  • 通常使用数组作为备忘录, 也可以使用Hash表作为备忘录:
int fib(int N) {
    if (N < 1) {
        return 0;
    }
    // 备忘录初始化为0 
    vector<int> memo(N+1, 0);
    // 初始化最简情况
    return helper(memo, N); 
}

int helper(vector<int>& memo, int n) {
    if (n == 1 || n == 2) {
        return 1;
    }
    // 已经计算过的直接返回
    if (memo[n] != 0) {
        return memo[n];
    }
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}
  • 带备忘录的递归算法的递归树分析:
    • 备忘录的递归树算法是将一棵存在巨量冗余的递归树通过剪枝, 改造成了一幅不存在冗余的递归图,极大减少了子问题,即递归树中节点的个数
    • 根据递归算法时间复杂度等于子问题个数乘以解决一个子问题需要的时间:
      • 子问题个数: 即递归树中节点的总数.由于不存在冗余计算,子问题就是f(1),f(2)...f(20),数量和输入的规模n成正比,所以子问题的个数就是O(n)
      • 解决一个子问题需要的时间: 因为这个算法中没有没有循环,只有加法操作,所以时间为O(1)
      • 带备忘录的递归算法时间复杂度: O(n).比直接递归算法高效得多
  • 带备忘录的递归解法的效率和迭代的动态规划解法一样:
    • 带备忘录的递归解法: 自顶向下
      • 从上向下延伸
      • 从一个规模较大的源问题,向下逐渐分解问题规模,直到触底.然后逐层返回答案
    • 迭代的动态规划解法: 自底向上
      • 直接从问题规模最小的最底层开始往上推,直到推导出需要的答案.这就是动态规划的思路
      • 动态规划一般都脱离了递归,去采用循环迭代完成计算

DP数组的迭代算法

  • 备忘录的基础上,将备忘录独立出来成为一张表,叫作DP Table
  • DP Table上完成自底向上的推算:
int fib(int N) {
    vector<int> dp(N+1, 0);
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[N];
}

DP Table和备忘录的递归树剪枝后的结构很相似,只不过是反过来计算而已.实际上,带备忘录的递归解法中的备忘录, 最终完成的就是这个DP Table. 所以带备忘录的递归算法和DP数组的迭代算法在大部分情况下的算法效率是相同的

  • 状态转移方程: 一种描述问题结构的数学形式
    • 状态转移: 比如想要将f(n)做成一个状态n,这个状态n是由状态n-1和状态n-2相加转移而来
    • 状态转义方程是解决问题的核心
    • 状态转移方程直接代表着暴力解法
  • 动态规划最困难的就是写出状态转移方程

斐波那契数列解法补充

  • 根据斐波那契数列的状态转移方程:
    • 当前状态只和之前的两个状态有关
    • 所以并不需要一个很长的DP Table来存储所有状态
    • 只要存储之间的两个状态就可以
    • 可以将空间复杂度优化为O(1)
int fib(int n) {
    if (n == 1 || n ==2) {
        return 1;
    }
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

凑零钱问题

  • 题目:
    • k种面值的硬币,硬币面值分别为c1, c2,...ck. 每种硬币的数量无限,需要凑够总金额amount. 最少需要几枚硬币凑出这个金额,如果不能凑出则返回 -1
  • 对于计算机来说,解决这个问题的方法就是将所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币

直接递归

  • 要符合最优子结构,子问题之间必须互相独立
  • 凑零钱问题分析:
    • 这个问题是动态规划问题,因为具有最优子结构
      • 原问题: 比如想要求amount=11时的最少硬币数
      • 子问题: 如果知道凑出amount=10的最少硬币数
      • 只需要将子问题的答案加1. 再选一枚面值为1的硬币后就是原问题的答案
      • 因为硬币的数量是没有限制的,子问题之间没有限制,是相互独立的
  • 确定是动态规划问题后,就要思考如何列出状态转移方程:
    • 先确定状态:
      • 状态就是原问题和子问题中变化的数量
      • 由于硬币数量是无限的,所以唯一的状态就是目标金额amount
    • 然后确定DP函数的定义:
      • 当前的目标金额是n,至少需要dp(n)个硬币凑出该金额
    • 然后确定选择并择优:
      • 也就是对于每个状态,可以做出什么选择改变当前状态
      • 无论当前的目标金额是多少,选择就是从面额列表coins中选出一个硬币,然后目标金额就会减少
      • 伪码如下:
         def coinChange(coins: List[int], amount: int):
          # 定义: 要凑出金额n,至少需要dp(n)个硬币
          def dep(n):
              # 做选择: 选择需要硬币最少的那个结果
              for coin in coins:
                  res = min(res, 1 + dp(n - coin))
              return res;
          # 计算出要求的问题dp(amount)
          return dp(amount)
        
    • 最后明确base case:
      • 明确初始的已知状态
      • 目标金额为0时,所需硬币数量为0.当目标金额小于0时,无解.返回-1
def coinChange(coins: List[int], amount: int):
    def dp(n):
        if n==0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coins)
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)
        return res if res != float('INF') else -1
    return dp(amount);
  • 以上代码的数学形式就是通过直接递归获取到的状态转移方程
  • 消除重叠子问题:
    • 直接递归算法的递归树分析:
      • 根据时间复杂度等于子问题总数乘以每个子问题的时间:
        • 子问题总数: 这个问题的子问题具体个数比较难看出,可以分析为O(nk),为指数级别的个数
        • 每个子问题的时间: 每个子问题含有一个for循环,所以时间复杂度为O(k)
        • 算法时间复杂度: O(k * nk).是指数级别

带备忘录的递归算法

  • 可以通过备忘录消除重叠子问题:
def coinChanges(coins: List[int], amount: int):
    # 备忘录
    memo = dict()
    def dp(n):
        # 查看备忘录,避免重复计算
        if n in memo: return memo[n]
        # 如果备忘录中不存在,则进行计算
        if n = 0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dep(n - coin)
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)
        # 将计算结果记入备忘录
        memo[n] = res if res != float('INF') else -1
        return memo[n]
    return dep(amount) 
  • 带备忘录的递归算法的递归树分析:
    • 根据算法时间复杂度等于子问题总数乘以每个子问题的时间
      • 子问题总数: 由于引用了备忘录,大大减少了子问题数目,完全消除了子问题的冗余,所以子问题的总数不会超过n,即子问题的数目为O(n)
      • 每个子问题的时间: 由于存在for循环,每个子问题的处理时间为O(k)
      • 算法时间复杂度: O(kn).大大提高了算法效率

DP数组的迭代算法

  • 自底向上使用DP Table来消除重叠子问题
int coinChanges(vector<int> coins, int amount) {
    // 数组大小为amount+1,初始值也为amount+1
    vector<int> dp(amount + 1, amount + 1);
    /*
     * dp[i] = x:
     *  当目标金额为i时,至少需要x枚硬币   
     */
     for (int i = 0; i < dp.size(); i++) {
        // 内层for求所有子问题+1的最小值
        for (coin in coin) {
            // 如果子问题无解,则跳过循环
            if (i - coin < 0) {
                continue;
            }
            dp[i] = min(dp[i], 1 + dp[i - coin])
        }
     }
     return (dp[amount] == dp[amount + 1]) ? -1 : dp[amount];
}
  • 数组之所以初始化为amount+1. 是因为凑成amount金额的硬币数最多只可能等于amount, 全用面值为1元面值的硬币时
  • 初始化为amount+1就相当于初始化为正无穷, 以便后续取最小值

总结

  • 斐波那契问题:
    • 解释了如何通过备忘录或者DP Table的方法来优化递归树
    • 明确了这两种方法本质上是一样的,只是自顶向下(备忘录)自底向上(DP Table) 的不同
  • 凑零钱问题:
    • 展示了如何流程化确定状态转移方程
    • 只要通过状态转义方程写出直接递归的算法,然后就可以通过优化递归树,消除重叠子问题
  • 计算机解决问题的唯一的解决办法就是穷举:
    • 穷举所有可能性
    • 算法设计就是先思考如何穷举
    • 然后再追求优化穷举的方式
  • 如何穷举: 列出动态转移方程
    • 列出动态转移方程就是在解决如何穷举的问题.之所以列出动态转移方程困难是因为:
    • 很多穷举需要递归实现
    • 有的问题本身的解空间复杂,不容易穷举完整
  • 优化穷举的方式: 备忘录和DP Table
    • 备忘录和DP Table就是在追求优化穷举的方式
    • 运用的是用空间换时间的思路来降低算法时间复杂度
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容