动态规划问题的一般形式就是求最值
如何求最值呢? 首先,则将所有可行的答案穷举
出来,然后再其中找最值
以后遇到求最值问题,首先思考如何穷举所有可能的结果
动态规划三要素
三要素:重叠子问题、最优子结构、状态转移方程
1️⃣穷举时可能存在“重叠子问题
”,暴力穷举可能导致效率低下,所以需要“备忘录”或“DP table”来优化穷举过程,避免重复计算
2️⃣其次,动态规划问题具备“最优子结构
”,这样才能通过子问题的最值
得到原问题的最值
3️⃣列出正确的“状态转移方程
”,才能正确的穷举
如何写出正确的状态转移方程?
- 明确
base case
(最简单的情况) - 明确
「状态」
- 对于每个「状态」,可以做出说明
「选择」
使得「状态」发生改变 - 定义
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 的硬币)就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立
的。
如何列出正确的状态转移方程?
- 确定
base case
:当目标金额⭐️amount
为 0 时,算法返回 0,此时为base case
- 确定
状态
:硬币数量无限,硬币的面额也是给定的,目标金额会不断地向base case
靠近,所以唯一的「状态」就是⭐️目标金额amount
- 确定
选择
(导致状态改变的行为):每选择一枚硬币,就相当于减少了目标金额。所以说⭐️所有硬币的面值
,就是你的「选择」 - 明确
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 就是在追求“如何聪明地穷举”,用空间换时间的思路,是降低时间复杂度的不二法门