动态规划题型

动态规划解题套路框架

动态规划问题的一般形式就是求最值

如何求最值呢? 首先,则将所有可行的答案穷举出来,然后再其中找最值

以后遇到求最值问题,首先思考如何穷举所有可能的结果

动态规划三要素

三要素:重叠子问题、最优子结构、状态转移方程

1️⃣穷举时可能存在“重叠子问题”,暴力穷举可能导致效率低下,所以需要“备忘录”或“DP table”来优化穷举过程,避免重复计算

2️⃣其次,动态规划问题具备“最优子结构”,这样才能通过子问题的最值得到原问题的最值

3️⃣列出正确的“状态转移方程”,才能正确的穷举

如何写出正确的状态转移方程?

  1. 明确 base case(最简单的情况)
  2. 明确「状态」
  3. 对于每个「状态」,可以做出说明「选择」使得「状态」发生改变
  4. 定义 dp 数组/函数的含义来表现「选择」和「状态」

Fib

目的:明白什么是重叠子问题

题目

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/fibonacci-number
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给你 n ,请计算 F(n) 。


暴力递归

var fib = (n) => {
    if (n == 0) return 0
    if (n == 1 || n == 2) return 1
    return fib(n - 1) + fib(n - 2)
}

算法低效的原因:存在大量重复计算,比如 f(19) 需要计算 f(17) 和 f(18),而 f(18) 还需要计算 f(17), 重复计算 2 次 f(17)

动态规划问题的第一个性质:重叠子问题

带备忘录的递归解法

我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。


带备忘录:自顶向下

自顶向下:从一个规模较大的原问题,向下逐渐分解规模,直到 f(1)f(2) 这两个 base case,然后逐层返回答案

var fib = function (n) {
    let memory = new Array(n + 1).fill(0)
    const helper = (memory, n) => {
        if (n == 0) return 0
        if (n == 1 || n == 2) return 1
        if (memory[n] != 0) return memory[n]
        memory[n] = helper(memory, n - 1) + helper(memory, n - 2)
        return memory[n]
    }
    return helper(memory, n)
};

执行用时:76 ms, 在所有 JavaScript 提交中击败了68.79%的用户

内存消耗:37.3 MB, 在所有 JavaScript 提交中击败了96.69%的用户


dp 数组的迭代解法:自底向上

将「备忘录」独立出来成为一张表,叫做 DP table 吧,在这张表上完成「自底向上」的推算

var fib = function (n) {
    if (n == 0) return 0
    if (n == 1 || n == 2) return 1
    let dp = new Array(n + 1).fill(0)
    dp[1] = dp[2] = 1
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    return dp[n]
};

执行用时:64 ms, 在所有 JavaScript 提交中击败了94.16%的用户

内存消耗:37.6 MB, 在所有 JavaScript 提交中击败了75.78%的用户

「状态转移方程」:描述问题结构的数学形式

在这里插入图片描述
f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移


进一步优化:状态压缩(只记录必要的数据)

当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了

var fib = function (n) {
    if (n == 0) return 0
    if (n == 1 || n == 2) return 1
    let pre = 1, cur = 1
    for (let i = 3; i <= n; i++) {
        let sum = pre + cur
        pre = cur
        cur = sum
    }
    return cur
};

执行用时:64 ms, 在所有 JavaScript 提交中击败了94.16%的用户

内存消耗:37.5 MB, 在所有 JavaScript 提交中击败了82.35%的用户

凑金币

目的:如何列出状态转移方程

题目
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
你可以认为每种硬币的数量是无限的
比如硬币的面值分别为 1,2,5,总金额 amount = 11,那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/coin-change


这个问题是动态规划问题,因为它具有「最优子结构」的

要符合「最优子结构」,子问题间必须互相独立

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

如何列出正确的状态转移方程?

  1. 确定base case :当目标金额⭐️ amount 为 0 时,算法返回 0,此时为base case
  2. 确定状态:硬币数量无限,硬币的面额也是给定的,目标金额会不断地向 base case 靠近,所以唯一的「状态」就是⭐️目标金额 amount
  3. 确定选择(导致状态改变的行为):每选择一枚硬币,就相当于减少了目标金额。所以说⭐️ 所有硬币的面值,就是你的「选择」
  4. 明确 dp 函数/数组 的定义:输入一个目标金额 n,返回凑出目标金额 n 的⭐️ 最少硬币数量

暴力递归

var coinChange = function (coins, amount) {
    // 4 define input and will be return minimum number of coins
    const dp = (n) => {
        // 1 base case 
        if (n < 0) return -1
        if (n == 0) return 0
        let res = Number.MAX_VALUE
        // 3 make a choice 
        for (let coin of coins) {
            let subproblem = dp(n - coin)
            if (subproblem == -1) continue
            // 2 change status: amount
            res = Math.min(res, 1 + subproblem)
        }
        return res == Number.MAX_VALUE ? -1 : res
    }
    return dp(amount)
};
console.log(coinChange([1, 2, 5], 11));

状态:超出时间限制

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

带“备忘录”递归

var coinChange = function (coins, amount) {
    // 4 define input and will be return minimum number of coins
    let memory = new Map()
    const dp = (n) => {
        // find a memory to avoid repeated calculation
        if (memory.has(n)) return memory.get(n)
        // 1 base case 
        if (n < 0) return -1
        if (n == 0) return 0
        let res = Number.MAX_VALUE
        // 3 make a choice 
        for (let coin of coins) {
            let subproblem = dp(n - coin)
            if (subproblem == -1) continue
            // 2 change status: amount
            res = Math.min(res, 1 + subproblem)
        }
        // set memory
        memory.set(n, res == Number.MAX_VALUE ? -1 : res)
        return memory.get(n)
    }
    return dp(amount)
};
console.log(coinChange([1, 2, 5], 100));

执行用时:244 ms, 在所有 JavaScript 提交中击败了14.14%的用户

内存消耗:48.2 MB, 在所有 JavaScript 提交中击败了5.04%的用户

dp 数组的迭代解法

var coinChange = function (coins, amount) {
    let dp = new Array(amount + 1).fill(amount + 1)
    dp[0] = 0
    for (let i = 0; i < dp.length; i++) {
        for (let coin of coins) {
            if (i - coin < 0) continue
            dp[i] = Math.min(dp[i], 1 + dp[i - coin])
        }
    }
    return dp[amount] == amount + 1 ? -1 : dp[amount]
};

执行用时:128 ms, 在所有 JavaScript 提交中击败了65.83%的用户

内存消耗:42.3 MB, 在所有 JavaScript 提交中击败了76.45%的用户


计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性

算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举

列出动态转移方程,就是在解决“如何穷举”的问题

备忘录、DP table 就是在追求“如何聪明地穷举”,用空间换时间的思路,是降低时间复杂度的不二法门

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值