动态规划套路框架

动态规划套路

动态规划问题的一般形式就是求最值求解动态规划的核心问题是穷举

  1. 存在「重叠子问题」,需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

  2. 一定会具备「最优子结构」,能通过子问题的最值得到原问题的最值,子问题必须相互独立。

  3. 只有列出正确的「状态转移方程」才能正确地穷举。

以上就是动态规划的三要素

思考状态转移方程的辅助思维框架:

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

菲波那切数列

  1. 暴力递归

    int fib(int N) {
        if (N == 1 || N == 2) return 1;
        return fib(N - 1) + fib(N - 2);
    }
    
  2. 带备忘录的递归解法(自顶向下,通过备忘录剪枝)

    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) {
        // base case 
        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];
    }
    
  3. dp 数组的迭代解法(自底向上,使用循环迭代)

    int fib(int N) {
        vector<int> dp(N + 1, 0);
        // base case
        dp[1] = dp[2] = 1;
        for (int i = 3; i <= N; i++)
            dp[i] = dp[i - 1] + dp[i - 2];
        return dp[N];
    }
    

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

递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。

状态转移方程:

f(n) = 1, n=1, 2

f(n) = f(n-1) + f(n-2), n > 2

千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。

优化方法无非是用备忘录或者 DP table,再无奥妙可言

凑零钱问题

给你k种面值的硬币,面值分别为c1, c2 ... ck,每种硬币的数量无限,再给一个总金额amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

暴力递归

你认为计算机应该如何解决这个问题?显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

为什么说它符合最优子结构呢?比如你想求amount = 11时的最少硬币数(原问题),如果你知道凑出amount = 10的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案,因为硬币的数量是没有限制的,子问题之间没有相互限制,是互相独立的。

思考如何列出正确的状态转移方程:

  1. 先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额amount

  2. 然后确定dp函数的定义:函数 dp(n)表示,当前的目标金额是n,至少需要dp(n)个硬币凑出该金额。

  3. 然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当前的目标金额是多少,选择就是从面额列表coins中选择一个硬币,然后目标金额就会减少:

# 伪码框架
def coinChange(coins: List[int], amount: int):
    # 定义:要凑出金额 n,至少要 dp(n) 个硬币
    def dp(n):
        # 做选择,需要硬币最少的那个结果就是答案
        for coin in coins:
            res = min(res, 1 + dp(n - coin))
        return res
    # 我们要求目标金额是 amount
    return dp(amount)
  1. 最后明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1
def coinChange(coins: List[int], amount: int):
    def dp(n):
        # base case
        if n == 0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            # 子问题无解,跳过
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)
        return res if res != float('INF') else -1
    return dp(amount)

至此,这个问题其实就解决了,只不过需要消除一下重叠子问题

带备忘录的递归

def coinChange(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 = dp(n - coin)
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)
        # 记入备忘录
        memo[n] = res if res != float('INF') else -1
        return memo[n]
      
    return dp(amount)

dp 数组迭代解法

int coinChange(vector<int>& coins, int amount) {
    // 数组大小为 amount + 1初始值也为 amount + 1
    vector<int> dp(amount + 1, amount + 1);
    // base case
    dp[0] = 0;
    for (int i = 1; i < dp.size(); i++) {
        // 内层 for 在求所有子问题 + 1 的最小值
        for (int coin : coins) {
            // 子问题无解跳过
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

总结

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

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

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

列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

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