为什么要学?
-
起初刷算法一直沉溺于贪心和暴力解的我,仿佛找到了“舒适区”。
-
直到遇到了
零钱兑换、打家劫舍、最长递增子序列这些题目。 -
开始发现常规解法力不从心了,就算逻辑正确,但会出现:
低效通过:
或者是:
为了解决这些尴尬的场景,产生了优化算法的意识,接触到了动态规划
是什么?
动态规划(Dynamic Programming,简称 DP),解决问题的核心思想是把全局的问题聚焦到局部的实现上,避免重复计算,通过复用已计算的子问题结果,最终高效得到全局解。
五步走
// 示例:求斐波那契数列的第 i 项
const dp = [0, 1, 1, 2, 3, 5, .... dp[i] ];
- 写出状态定义:dp[i] 代表什么
dp 数组在第 i 项时所要表达的什么状态。
- 写状态转移式:当前状态怎么由更小状态推出来
dp[i] = dp[i-1] + dp[i-2]
- 初始值:最小子问题
base case类似于数学归纳法,初始化第 0 项和第 1 项。
- 确定遍历方向(正序/倒序):保证用到的子问题先算好。
- 代入小样例手推一遍(防止转移错)。
例题实践:零钱兑换(322)
题目:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。
输入:coins = [1, 2, 5], amount = 11 输出:3;(11 = 5 + 5 +1)
步骤:
-
状态定义:确定 dp 数组 和
dp[i]dp[i]表示凑出金额 i 所需的最少硬币数(能否兑换出来不确定),i 则是当前兑换的 amount,一直到 11。- 定义dp数组:
[Infinity, Infinity ... Infinity],长度为 12(因为要取到第 11 项目)一开始我们还不知道大多数金额能不能凑出来,所以先把它们标记成“当前不可达”。
Infinity就是最合适的“不可达哨兵值”
-
状态转移:
-
思考:求兑换出 11 的最少硬币组合,会和前面局部 amount (10、9 ... 1、0)存在某种联系?
-
dp[11]的结果可以是11 - 1 = dp[10] + 111 - 2 = dp[9] + 111 - 5 = dp[6] + 1 -
可得:
for (let j = 0; j < coins.length; j++) { if (i >= coins[j]) { dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1); } }挨个减去硬币面额,取最小的为最终值。
-
-
初始化边界:i = 0 时:可兑换的组合为 0;
dp[0] = 0 -
确定循环:
从 i = 1 开始,遍历 dp 数组;确定 dp[i] 的值,正序需要 11 次;
-
验证:
- i = 1 时:
内层 j = 1:
dp[1] = Math.min(dp[1], dp[1 - 1] + 1) = Math.min(Infinity, 1) = 1;结束内层循环:dp[1] = 1 - i = 2 时:
内层 j = 1:
dp[2] = Math.min(dp[2], dp[2 - 1] + 1) = Math.min(Infinity, 2) = 2;内层 j = 2:dp[2] = Math.min(dp[2], dp[2 - 2] + 1) = Math.min(2, 1) = 1;结束内层循环:dp[2] = 1
- i = 1 时:
示例代码:
function coinChange(coins, amount) {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i < dp.length; i++) {
for (let j = 0; j < coins.length; j++) {
if (i >= coins[j]) {
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount]; // 兑换失败的则是 Infinity 返回 -1 即可
}
- 时间复杂度:
O(amount * coins.length)- 空间复杂度:
O(amount)
总结:动态规划的关键始终是这两步:先“拆问题”(定义状态)、再“找关系”(写转移)。说是 “套模板” 也不为过。