4.1动态规划DP核心原理

371 阅读11分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


一、动态规划解题核心框架概述

这类问题也是最具有技巧性,最有意思的。

  • 动态规划是什么?
  • 解决动态规划问题有什么技巧?
  • 如何学习动态规划?

动态规划问题的⼀般形式就是求最值。动态规划其实是运筹学的⼀种最优化⽅法,只不过在计算机问题上应⽤⽐较多,⽐如说让你求最⻓递增⼦序列呀,最⼩编辑距离呀等等。

求解动态规划的核⼼问题是穷举。因为要求最值,肯定要把所有可⾏的答案穷举出来,然后在其中找最值呗。

首先,动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果暴⼒穷举的话效率会极其低下, 所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

⽽且,动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得到原问题的最值。

另外,虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化,穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅程」,才能正确地穷举。

以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。

在实际的算法问题中,写出状态转移⽅程是最困难的。

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。

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

1.斐波那契数列

只有简单的例⼦才能让你把精⼒充分集中在算法背后的通⽤思想和技巧上,⽽不会被那些隐晦的细节问题搞的莫名其妙。

题目描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n)

🌸「示例:」

示例 1:

输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3

解答

解法一:暴力递归

class Solution {
    public int fib(int n) {
        
        if (n == 0) return 0;
        if (n == 1) return 1;
            
        return fib(n - 1) + fib(n - 2);
    }
}

十分低效!

PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨⼤帮助。

image.png

递归算法的时间复杂度怎么计算?就是⽤⼦问题个数乘以解决⼀个⼦问题需要的时间。

⾸先计算⼦问题个数,即递归树中节点的总数。显然⼆叉树节点总数为指数级别,所以⼦问题个数为 O(2^n)

然后计算解决⼀个⼦问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) ⼀个加法操作,时间为 O(1)

所以,这个算法的时间复杂度为⼆者相乘,即 O(2^n),指数级别,爆炸。

观察递归树,很明显发现了算法低效的原因:存在⼤量重复计算,⽐如 f(18) 被计算了两次,⽽且你可以看到,以 f(18) 为根的这个递归树体量巨⼤,多算⼀遍,会耗费巨⼤的时间。更何况,还不⽌ f(18) 这⼀个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第⼀个性质:重叠⼦问题。下⾯,我们想办法解决这个问题。

解法二:带备忘录的递归解法

明确了问题,其实就已经把问题解决了⼀半。

即然耗时的原因是重复计算,那么我们可以造⼀个「备忘录」,每次算出某个⼦问题的答案后别急着返回,先记到「备忘录」⾥再返回;每次遇到⼀个⼦问题先去「备忘录」⾥查⼀查,如果发现之前已经解决过这个问题了,直接把答案拿出来⽤,不要再耗时去计算了。

⼀般使⽤⼀个数组充当这个「备忘录」,当然你也可以使⽤哈希表(字典),思想都是⼀样的。

//解法2:带备忘录的递归解法
class Solution {
    public int fib(int n) {
        int[] memo = new int[n + 1];

        return help(memo, n);
    }
    
    int help(int[] memo, int n) {
        if (n == 0 || n == 1) return n;
        //思考🤔
        if (memo[n] != 0) return memo[n];

        memo[n] = help(memo, n - 1) + help(memo, n - 2);
        return memo[n];
    }

}

⼦问题个数,即图中节点的总数,由于本算法不存在冗余计算,⼦问题就是 f(1), f(2), f(3) ... f(20),数量和输⼊规模 n = 20 成正⽐,所以⼦问题个数为 O(n)

解决⼀个⼦问题的时间,同上,没有什么循环,时间为 O(1)

所以,本算法的时间复杂度是 O(n)。⽐起暴⼒算法,是降维打击。

⾄此,带备忘录的递归解法的效率已经和迭代的动态规划解法⼀样了。

实际上,这种解法和迭代的动态规划已经差不多了,只不过这种⽅法叫做「⾃顶向下」,动态规划叫做「⾃底向上」。

啥叫「⾃顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从⼀个规模较⼤的原问 题⽐如说 f(20),向下逐渐分解规模,直到 f(1)f(2) 这两个 base case,然后逐层返回答案,这就叫「⾃顶向下」。

啥叫「⾃底向上」?反过来,我们直接从最底下,最简单,问题规模最⼩的 f(1)f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划⼀般都脱离了递归,⽽是由循环迭代完成计算。

解法三:dp数组的迭代解法

有了上⼀步「备忘录」的启发,我们可以把这个「备忘录」独⽴出来成为⼀张表,就叫做 DP table 吧,在这张表上完成「⾃底向上」的推算岂不美哉!

class Solution {
    public int fib(int n) {
        if (n == 0) return 0;
        int[] dp = new int[n + 1];
        
        dp[0] = 0; dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

image.png

这⾥,引出「状态转移⽅程」这个名词,实际上就是描述问题结构的数学形式:

image.png

为啥叫「状态转移⽅程」?其实就是为了听起来⾼端。你把 f(n) 想做⼀个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移⽽来,这就叫状态转移,仅此⽽已。

你会发现,上⾯的⼏种解法中的所有操作,例如 return f(n - 1) + f(n - 2)dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个⽅程式的不同表现形式。可⻅列出「状态转移⽅程」的重要性,它是解决问题的核⼼。⽽且很容易发现,其实状态转移⽅程直接代表着暴⼒解法。

千万不要看不起暴⼒解,动态规划问题最困难的就是写出这个暴⼒解,即状态转移⽅程。只要写出暴⼒解,优化⽅法⽆⾮是⽤备忘录或者 DP table,再⽆奥妙可⾔。

这个例⼦的最后,讲⼀个细节优化。细⼼的读者会发现,根据斐波那契数列的状态转移⽅程,当前状态只和 之前的两个状态有关,其实并不需要那么⻓的⼀个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就⾏了。

所以,可以进⼀步优化,把空间复杂度降为 O(1):

class Solution4 {
    public int fib(int n) {
        if (n == 0) return 0;
        if (n == 1 || n == 2) return 1;

        int pre = 1; int cur = 1;
        for (int i = 3; i <= n; i++) {
            int sum = pre + cur;
            pre = cur;
            cur = sum;
        }

        return cur;
    }
}

这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的⼀部分,那么可以尝试⽤状态压缩来缩⼩ DP table 的⼤⼩,只记录必要的数据,上述例⼦就相当于把DP table 的⼤⼩从 n 缩⼩到2

2.凑零钱问题(零钱兑换M322)

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

🌸「示例:」

示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

示例 2:
输入:coins = [2], amount = 3
输出:-1

示例 3:
输入:coins = [1], amount = 0
输出:0

解答

解法一:暴力递归

// 这个问题其实解决了,只不过需要消除⼀下重叠⼦问题
class Solution322 {
    public int coinChange(int[] coins, int amount) {
        // 题⽬要求的最终结果是 dp(amount)
        return dp(coins, amount);
    }

    int dp(int[] coins, int amount){
        if (amount == 0) return 0;
        if (amount < 0) return -1;

        int res = Integer.MAX_VALUE;

        for (int coin : coins) {
            // 计算子问题的结果
            int subProblem = dp(coins, amount - coin);

            // 子问题无解则跳过
            if (subProblem == -1) continue;

            // 子问题中选择最优解。然后加1
            res = Math.min(res, subProblem + 1);
        }

        return res == Integer.MAX_VALUE ? -1 : res;
    }
}

image.png

递归树

image.png

递归算法的时间复杂度分析:⼦问题总数 x 每个⼦问题的时间。

⼦问题总数为递归树节点个数,这个⽐较难看出来,是 O(n^k),总之是指数级别的。每个⼦问题中含有⼀个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别。

解法二:带备忘录的递归

// 解法2:带备忘录的迭代算法
class Solution {

    int[] memo;

    public int coinChange(int[] coins, int amount) {
        memo = new int[amount + 1];
        //Arrays.fill(memo, -1);
        Arrays.fill(memo, -2);
        return dp(coins, amount);
    }

    private int dp(int[] coins, int amount) {

        // base case
        if (amount == 0) return 0;
        if (amount < 0) return -1;

        // 查备忘录,防止重复计算
        if (memo[amount] != -2) return memo[amount];

        //int res = amount + 1;
        int res = Integer.MAX_VALUE;
        for (int coin : coins) {
            // 求解子问题
            int subProblem = dp(coins, amount - coin);
            if (subProblem == -1) continue;
            // 子问题中选择最优解,然后加1
            res = Math.min(res, subProblem + 1);
        }
        // 保存到备忘录
        // 子问题无解,需要返回-1
        //memo[amount] = res;
        memo[amount] = res == Integer.MAX_VALUE ? -1 : res;

        return memo[amount];
    }
}

解法三:dp数组的迭代解法

class Solution {
    public int coinChange(int[] coins, int amount) {
        // dp 数组的定义:当⽬标⾦额为 i 时,⾄少需要 dp[i] 枚硬币凑出。
        // 数组⼤⼩为 amount + 1,初始值也为 amount + 1
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);

        // base case
        dp[0] = 0;
        // 遍历所有状态的所有取值
        for (int i = 0; i < dp.length; i++) {
            // 求所有选择的最⼩值
            for (int coin : coins) {
                // 子问题无解,跳过
                if (i - coin < 0){
                    continue;
                }
                dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
            }
        }
        return (dp[amount] == amount + 1) ? -1 : dp[amount];
    }
}

为啥 dp 数组初始化为 amount + 1 呢?

因为凑成 amount ⾦额的硬币数最多只可能等于 amount(全⽤ 1 元⾯值的硬币),所以初始化为 amount + 1 就相当于初始化为正⽆穷,便于后续取最⼩值。为啥不直接初始化为 int 型的最⼤值 Integer.MAX_VALUE 呢?因为后⾯有 dp[i -coin] + 1,这就会导致整型溢出。

总结

第⼀个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」的⽅法来优化递归树,并且明确了 这两种⽅法本质上是⼀样的,只是⾃顶向下和⾃底向上的不同⽽已。

第⼆个凑零钱的问题,展示了如何流程化确定「状态转移⽅程」,只要通过状态转移⽅程写出暴⼒递归解,剩下的也就是优化递归树,消除重叠⼦问题⽽已。

计算机解决问题其实没有任何奇技淫巧,它唯⼀的解决办法就是穷举,穷举所有可能性。算法设计⽆⾮就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

备忘录、DP table 就是在追求“如何聪明地穷举”。⽤空间换时间的思路,是降低时间复杂度的不⼆法⻔,除 此之外,试问,还能玩出啥花活?

之后我们会有⼀章专⻔讲解动态规划问题,如果有任何问题都可以随时回来重读本⽂,希望读者在阅读每个题⽬和解法时,多往「状态」和「选择」上靠,才能对这套框架产⽣⾃⼰的理解,运⽤⾃如。

二、base case 和备忘录的初始值怎么定?

3.下降路径最小和

题目描述

给你一个 n x n 的 方形 整数数组 matrix ,请你找出并返回通过 matrix 的下降路径 的 最小和 。

下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置 (row, col) 的下一个元素应当是 (row + 1, col - 1)、(row + 1, col) 或者 (row + 1, col + 1) 。

🌸「示例:」

image.png

输入: matrix = [[2,1,3],[6,5,4],[7,8,9]]
输出: 13
解释: 如图所示,为和最小的两条下降路径

image.png

输入: matrix = [[-19,57],[-40,-5]]
输出: -59
解释: 如图所示,为和最小的下降路径

解答

输⼊为⼀个 n * n 的⼆维数组 matrix,请你计算从第⼀⾏落到最后⼀⾏,经过的路径和最⼩为多少。

函数签名如下:

int minFallingPathSum(int[][] matrix);

你可以站在 matrix 的第⼀⾏的任意⼀个元素,需要下降到最后⼀⾏。

每次下降,可以向下、向左下、向右下三个⽅向移动⼀格。也就是说,可以从 matrix[i][j] 降到matrix[i+1][j] 或 matrix[i+1][j-1] 或 matrix[i+1][j+1] 三个位置。

解法一:带备忘录的递归解法

⽤备忘录的⽅法消除重叠⼦问题

class Solution {
    // 备忘录
    int[][] memo;

    public int minFallingPathSum(int[][] matrix) {
        int n = matrix.length;
        int res = Integer.MAX_VALUE;

        // (1)添加备忘录
        memo = new int[n][n];
        for (int[] ints : memo) {
            Arrays.fill(ints, 66666);
        }

        // 终点可能在任何一列
        for (int i = 0; i < n; i++) {
            res = Math.min(res, dp(matrix, n - 1, i));
        }
        return res;
    }


    int dp(int[][] matrix, int i, int j) {
        // 非法索引检查
        if (i < 0 || j < 0 || i >= matrix.length || j >= matrix[0].length) {
            return 99999;
        }

        // base case
        if (i == 0){
            return matrix[0][j];
        }

        // (2)查询备忘录
        if (memo[i][j] != 66666) {
            return memo[i][j];
        }

        // 状态转移
        // (3)记录到被备忘录
        memo[i][j] =  matrix[i][j] + min(
                dp(matrix, i - 1, j - 1),
                dp(matrix, i - 1, j),
                dp(matrix, i - 1, j + 1)
        );
        return memo[i][j];
    }

    private int min(int dp, int dp1, int dp2) {
        return Math.min(dp, Math.min(dp1, dp2));
    }
}

解法二

三、最优⼦结构和 dp 数组的遍历⽅向怎么定?

四、提升刷题幸福感的小技巧

刷⼒扣题是直接在⽹⻚上刷⽐较好还是在本地 IDE 上刷⽐较好?

如果是⽜客⽹笔试那种⾃⼰处理输⼊输出的判题形式,⼀定要在 IDE 上写,这个没啥说的。

但像⼒扣这种判题形式,建议直接在⽹⻚上刷,原因有⼆:

  • 方便
  • 实用
    • 到时候⾯试的时候,⾯试官给你出的算法题⼤都是希望你直接在⽹⻚上完成的,最好是边写边讲你的思路。
    • 如果平时练习的时候就习惯没有 IDE 的⾃动补全,习惯⼿写代码⼤脑编译,到时候⾯试的时候写代码就能更快更从容。

⽐较让⼈头疼的的应该是递归算法的问题排查。

建议是直接在递归函数内部打印关键值,配合缩进,直观地观察递归函数执⾏情况。