本文是阅读参考资料后整理出来学习动态规划的思路笔记。从简单好理解的问题出发,再借鉴思路解决复杂问题。水平有限,所以把学习过程中的详细思考过程都记录下来,帮助理解。
前置知识:算法复杂度
1. 楼梯问题
问题描述
一个楼梯有 10 级台阶,你从下往上走,每跨一步只能向上迈 1 级或者 2 级台阶,请问一共有多少种走法?
思路分析
重点:将问题拆分为子问题,逐步思考,找到规律。
类比:计算1+2+...+100,使用高斯解法结果为 5050。这个加法问题也可以拆解为先计算1+2+...+99的值,最后再加上 100。
回到 10 级台阶问题,由于每跨一步只能向上迈 1 级或者 2 级台阶。把最后一步摘出来看,有如下两种情况:
- 假如最后跨 1 级台阶,那么剩下就是计算 9 级台阶有多少种走法的问题
- 假如最后跨 2 级台阶,那么剩下就是计算 8 级台阶有多少种走法的问题
更具体的用数字举例:
- 走到第 9 步的走法,假设有 5 种,再跨一步到第 10 级,那么此时到第 10 级就是 5 种走法。
- 走到第 8 级的走法,假设有 4 种,再跨两步到第 10 级。那么此时到第 10 级就是 4 种走法。
注:到第 8 级时,理论上可以选择再走 1 步或者 2 步,但是走 1 步到第 9 级的情况,已经放到上一个 9+1 的走法情况下统计了。
那么一共到第 10 级就是 5+4 种走法。
所以走到第 10 步的走法,就等于走到第 8 步的走法加上走到第 9 步的走法。用函数表示为F(10) = F(9) + F(8)。
依次类推,F(9) = F(8) + F(7),最后我们就需要知道F(3) = F(2) + F(1)。那么最基础的值就是 F(2)和 F(1),这两个很容易算出来:
- F(1) = 1 一次迈一级台阶。1 种走法
- F(2) = 2 一次迈两级台阶;一次迈一级台阶,迈两次。2 种走法
类推下来,那么 F(n) = F(n-1) + F(n-2)
找到规律后,就可以归纳出动态规划的三个要素:
- F(n-1) 和 F(n-2) 是 F(n) 的最优子结构
- F(n) = F(n-1) + F(n-2) 状态转移方程
- F(1) = 1 和 F(2) = 2 问题的边界
解法
解法一
按照前面的思路,和计算斐波那契数列思路类似,使用递归:
let i1 = 0;
const fn1 = (n) => {
i1++;
if (n === 1) {
return 1;
}
if (n === 2) {
return 2;
}
return fn1(n - 1) + fn1(n - 2);
};
console.log("fn1", fn1(20)); // 10946
console.log("i1", i1); // 13529
以fn1(5)举例分析一下计算过程,类似于如下树结构:
5
4 3
3 2 2 1
2 1
过程大概是,具体可以断点调试看一下:
- 进入计算 F(5)
- 算加号左边,算 F(4)
- 算 F(3)
- 算 F(2)
- 算 F(1) 结合上一步 F(2) => F(3)有结果了
- 算 F(2) 结合上一步 F(3) => F(4)有结果了
- 再算右边,算 F(3)
- 算 F(2)
- 算 F(1) 结合上一步 => F(3)有结果了
- 算出 F(5)
因此当 n 趋近于无穷大时,执行次数符合次方。时间复杂度为。
看计算过程知道,很多值被重复计算了。那么以空间换时间,可以用一个对象把计算的值记录下来,重复利用。
解法二
利用 map 对象记录计算过的值,后续计算过程中如果能访问到则直接返回缓存的值。
const map = {};
let i2 = 0;
const fn2 = (n) => {
i2++;
if (n === 1) {
return 1;
}
if (n === 2) {
return 2;
}
// 简洁写法
// return map[n] ? map[n] : (map[n] = fn2(n - 1) + fn2(n - 2));
if (map[n]) {
return map[n];
}
map[n] = fn2(n - 1) + fn2(n - 2);
return map[n];
};
console.log("fn2", fn2(20)); // 10946
console.log("i2", i2); // 37
console.log("map", map);
时间复杂度 O(n),空间复杂度 O(n)。
按照刚开始分析的思路,我们在写代码时写的是先计算 F(n-1),再计算 F(n-2),而如果交换过来,先计算 F(n-2),也就是如下树,再详细分析一下执行过程:
5
3 4
2 1 3 2
2 1
左边计算到 3 时,此时存储有三个变量 F(1)、F(2)、F(3)对应的值,下一步需要计算 F(4)了,这时候刚好需要访问 F(2)、F(3),就能得到 F(4)。再下一步根据 F(3)、F(4)可以得到 F(5),不再需要访问 F(1)了。依次类推,全程只需要保存三个变量即可得到下一步的值。那么按照这个思路,空间复杂度可以进一步优化。
解法三
准备三个变量,a 存储 F(n-2),b 存储 F(n-1),temp 存储 F(n)。
前面推导出的过程也可以写成这样,就是一个累加的过程。 F(n) = F(n-1) + F(n-2) + ... + F(2) + F(1) 三个值是从 F(1)和 F(2)开始,不断往前推进,以获取到最终的值。 对于临界值 1 和 2,仍然是直接返回。剩下的就是类似做加法的过程,用一个循环解决。
let i3 = 0;
const fn3 = (n) => {
if (n === 1) {
return 1;
}
if (n === 2) {
return 2;
}
let a = 1;
let b = 2;
let temp = 0;
for (let i = 3; i <= n; i++) {
i3++;
temp = a + b;
a = b;
b = temp;
}
return temp;
console.log("fn3", fn3(20)); // 10946
console.log("i3", i3); // 18
};
时间复杂度 O(n),空间复杂度 O(1)。
资料
《漫画算法小灰的算法之旅》- 魏梦舒
你管这破玩意叫动态规划 - WX公众号《无聊的闪客》