一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 7 天,点击查看活动详情。
算法中的“动态规划”相信大家都不陌生,之前的文章中也简单介绍了一下,今天用几个比较贴近生活的小例子进一步了解动态规划的使用,更好地掌握“动规”解法套路。
1.爬楼梯
这个例子大家都很熟悉,爬楼梯的时候你可能每次走一级台阶,或者两级台阶。现在你来到一个“好汉坡”,你不知道有多少个楼梯,但我问你爬到第 3 级台阶有多少种走法。简单的思考一下:
- 要么一级级走;1+1+1
- 先一步后两步;1+2
- 先两步后一步;2+1 你就可以很快地给出答案是 3 种走法。那么 100 级台阶,1000 级,甚至 10000 级台阶又有多少种走法呢,我们可以用“动规”的思路解决一下。
解题思路
回顾刚才的解题思路你会发现,走到 n 级台阶的走法数依赖于前 n-1 级和前 n-2 级的走法。即当你在 n 级台阶的时候,回头看是从前一步或者前两步走过来的。前面的例子看到,在第 3 级台阶时,走法是 1 级台阶走法数加上 2 级台阶的走法数:1+2 = 3。
假设到 n 级台阶的走法记为 s(n),那么可以得到的:
- 状态定义:s(n);走到第 n 级台阶的走法数
- 递推公式为:s(n) = s(n-1) + s(n-2);每级台阶的走法数为前一级和两级走法的总和
有了上面的分析后代码就很容易实现啦:
function climb(n){
// 三级台阶前直接返回结果
if(n<=2) return n;
let arr = [1,2];
for(var i=2;i<n;i++){
arr[i] = arr[i-1]+arr[i-2];
}
return arr[n-1]
}
细心的同学会发现这个解法和斐波那契数列是一样的,那么我们就可以对这个解法进行优化,因为这里的 arr 会根据 n 值创建长度为 n 的数组(空间复杂度为O(n)),但数组我们用不上可以改造一下:
function climb(n){
// 三级台阶前直接返回结果
if(n<=2) return n;
let arr = [1,2];
for(var i=2;i<n;i++){
let sum = a[0]+a[1]
a[0] = a[1]
a[1] = sum
}
return arr[1]
}
相比之下这种解法空间复杂度可以降为 O(1)。感兴趣的同学可以查看 LeetCode 第 70 题。
2.找零钱
虽然现在生活中很少使用现金进行交易,但找零钱的场景大家还是熟悉的。用 5 块钱买一瓶 3 块钱的冰红茶,老板自然要找给你 2 块钱。但要特别说明的是,我国现行流通的货币中已没有 2 元面值的币种,所以老板需要找给你两张 1 元的纸币或者两枚硬币。
这就引出我们的问题,找零钱的时候,老板如何用最小数量的方式把钱找给你。比如老板要找给你 6 块钱,可以选择 5+1(数量2) 或者六个一元(数量6),这里我们不考虑你兜里有四个铜板的情况,那我们想要的答案就是第一种方式:2.
解题思路
那么这个问题该如何思考呢,拿刚才的例子 6 元来看,从人类的常识角度出发:币种里有 1 元,拿出一张;(6-1) 元再看币种里有 1 元或 5 元,那理当选择 5 元,得出正常答案。但却不是计算机思维 —— 计算机在没有对比完所有币种后是不知道该选择哪一种方式的。如果用“动规”的方式思考,就要拆解问题,找 6 块钱的钱币数量和找 5 块钱的钱币数量是什么关系?可以看到前者是后者找钱数加上 1 块钱差值的钱币数。那么可以得到:
- 状态定义:c(n); 表示零钱为 n 是所需最少钱币数
- 递推公式:c(n) = min(c(n),c(n-面额)+1); 比较是否使用当前面额的情况,如果使用了当前面额即意味着结果+1,看和不使用当前面额比较谁的数量小
上面的递推公式告诉我们一个讯息:如果要比较 min(c(n),c(n-面额)+1),那我们的 c(n) 肯定初始要设置比较大的值,如果都设置为 0,这个结果永远只会返回 0。
根据上面的分析,我们先看下代码实现:
/**
* @param {number[]} coins 币种数
* @param {number} amount 找零钱
* @return {number}
*/
const coinChange = function(coins, amount) {
// 因为 0 元没有意义,至少从 1 块钱开始;
// 所有的 c[n] 钱币数初始设置大于 amount,用于后续比较
let c = new Array(amount+1).fill(amount+1);
c[0] = 0;
for(let i=1;i<=amount;i++){
for(let j=0;j<coins.length;j++){
if(i - coins[j] >=0){
// c[i] 初始为 amount+1,便于比对
// 选择了当前面额 coins[j],意味着钱币数要加一
c[i] = Math.min(c[i], c[i-coins[j]] + 1)
}
}
}
// 如果最后要找的零钱 amount 没有更新,则返回 -1,说明没有满足条件的面额组合
// 否则返回找零钱币数
return coins[amount] > amount ? -1 : coins[amount]
}
这个解法关键在于 c[i] = Math.min(c[i], c[i-coins[j]] + 1) 的理解,这个被称为问题的最优子结构,之前的爬楼梯最优子结构比较简单 a[n]= a[n-1]+a[n-2]。但爬楼梯或斐波那契不算真正体现“动规”思想,因为它们可以改造成存储替换 sum = a[0]+a[1]。找零钱就真正需要依赖子问题的答案:c[i-coins[j]]。
关于这个例子可以查看 LeetCode 的第 320 题了解更多内容。
3.打家劫舍
这个问题在现实生活中不太可能出现,但我们可以用它来尝试把握“动规”解法的套路。首先看题目介绍:
盗贼从 n 个房间里盗取财宝,每个房间有报警器,如果从两个相邻房间盗取则会引起警报;如何在不引起警报的前提下盗取最多财宝
1)定义子问题状态
首先找到子问题是什么,从而定义状态。要从 n 个房间里盗取最多财宝,那如果只有 1 个房间,那直接盗取即可;如果是 2 个房间,因为不能相连,那肯定取财宝最大的房间。于是我们可以定义状态:
- d(n); 表示从 n 个房间能获得的最多财宝。
2)定义递推公式
接下来分析递推公式。首先思考,我们获取 d(n) 的值是否依赖于 d(n-1),即盗取 n 个房间和 n-1 个房间有关系么。题目描述里说不能盗取连续的房间,那看来是有关系的,而且还和 n-2 有关系。那我们的问题状态也得修正一下,应该是前 n 个房间获得的最多财宝:
- d(n); 表示从前 n 个房间能获得的最多财宝 前 n 个房间最大价值的财宝依赖于前 n-1 和 n-2 房间的最大财宝。也就是说,当我们来到第 n 个房子门前,要看前两个房间的情况决定是否开门进去偷盗。所以假设当前房间价值 room[n],递推公式如下:
- d(n) = Math.max(d(n-2)+room[n], d(n-1)); 比较前 n-2 个加当前房间价值和前 n-1 个房间价值
3) 代码实现
根据前面的分析,做如下的代码实现:
/**
* @param {number[]} nums
* @return {number}
*/
const rob = function(nums) {
let dp = [], len = nums.length;
// 两个房间或以下,直接返回结果
dp[0] = nums[0]; // 一个房间的情况
dp[1] = Math.max(nums[0], nums[1]) // 两个房间的情况
if(len<3) return dp[len-1]
// 遍历大于 2 个房间的情况
let flag = 2;
while(flag < len){
// 比较前两个房间和前一个房间的价值情况
dp[flag] = Math.max(dp[flag-2]+nums[flag], dp[flag-1])
flag ++
}
return dp[len-1]
}
想要了解更多内容可以查看 LeetCode 第 198 题。
4.小结
至此我们完成了三个例子的讲解,可以初步掌握“动规”的解法套路。
- 定义最小子问题状态
- 递推公式 “动规”的核心是把问题拆解,拆解到最小子问题后能找出递推公式。但其实说起来容易,实际操作还是要多加练习。另外如何判断一个问题是否需要使用“动规”解法,可以把我两个准绳:
- 存在重复子问题
- 当前问题的解依赖于前面的子问题的解
以上,感谢阅读。
题图来自Abstract-logo