本系列文章:
文章同步在公众号:萌萌哒草头将军
书接上回,上篇文章我们主要讲解了我使用记忆化搜索和开始接触动态规划的经历,简单总结下上文的要点:
递归
的存在两个明显的缺点:
- 函数大量调用开销
- 存在大量重复计算的问���
但是我们知道可以
- 使用
记忆化搜索
改良递归
重复计算的问题 - 使用
动态规划
从已知的小问题入手,逐步解决大问题,来解决递归
的函数大量调用的开销问题。
接下来,我会详细介绍动态规划的一些经典问题和解题思路。
除了上篇文章提到的裴波那契数列,还有一个裴波那契的变种问题:青蛙跳台阶
入门
青蛙跳台阶
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
解决动态规划问题的关键是找到状态转移方程(Dynamic Programming,简称DP)
,从已知值逐步解决问题。
分析
上第一阶台阶,只有一种可能,f(1)=1
上第二阶台阶,有两种可能,从第一阶上来或者一开始直接上到第二阶,所以f(2)=2
上第三阶台阶,是在第二台阶基础上,虽然最多只能跳两个台阶,但是从第二台阶到第三台阶只能跳一个台阶,所以,f(3)=3
上第四个台阶,我们可能是从第三个台阶上来,或者从第二个台阶跨过第三台阶直接上来。所以,f(3) = f(2)+f(3)=5
依此类推:f(n)=f(n-1)+f(n-2)
我们可以得到我们需要的状态转移方程为:DP[i] = DP[i-1] + DP[i-2]
ts
export function jumpFloor(number: number): number {
const dp = [];
dp[1] = 1;
dp[2] = 2;
if (number > 2) {
for (let i = 3; i <= number; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
}
return dp[number];
}
最小花费爬楼梯
给定一个整数数组 𝑐𝑜𝑠𝑡 cost ,其中 𝑐𝑜𝑠𝑡[𝑖] cost[i] 是从楼梯第𝑖 i 个台阶向上爬需要支付的费用,下标从0开始。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
我们还是要想办法找到状态转移方程。
分析
先从0开始,你会跳第一阶还是直接跳第二阶,你肯定会想,哪个台阶的花费最便宜。
现在假设给定数组:
js
[1, 100, 1, 1, 1, 90, 1, 1, 80, 1]
跳第一台阶,只需要1块钱,跳第二台阶需要100块钱,那么你肯定会选择第一个台阶。
现在你站在第一台阶了,那么你可以选择跳第二个台阶,或者第三个台阶,很明显,第二个台阶100块钱,你肯定会跳第三个台阶
依此类推....。你在某个台阶时,肯定是从前面两个台阶中最便宜的台阶基础上跳上来的,
那么我们可以推断出,我们的状态转移方程为:
DP[i] = min{DP[i-1] + cost[i-1], DP[i-2] + cost[i-2]}
ts
export function minCostClimbingStairs(cost: number[]): number {
let dp = [0, 0];
for (let i = 2; i <= cost.length; i++) {
dp[i] = Math.min(
dp[i - 1] + cost[i - 1],
dp[i - 2] + cost[i - 2]
);
}
return dp[cost.length];
}
是不是很简单,现在我们开始上升一个难度。
中等
把数字翻译成字母字符串
有一种将字母编码成数字的方式:'a'->1, 'b->2', ... , 'z->26'。
现在给一串数字,返回有多少种可能的译码结果
分析
首先,可以知道一些关键信息:
- 只有
26
个字母 - 没有
0
对应的字母 10
、20
只有一种情况
接着我们通过分析找到转移方程。看似无从下手,但是仔细推演下,就可以发现规律
以输入值"123120"为例:
- 输入为1:只有一种可能
- 输入为12:两种可能,
A、B
或者L
- 输入123:三种可能,
A、B、C
或者A、W
或者L、C
(由于23
小于26
,所以需要考虑和前位组合在一起的情况) - 输入1231:三种可能,
A、B、C、A
或者A、W、A
或者L、C、A
(由于31
大于26
,所以不考虑和前位组合在一起的情况) - 输入12312:六种可能,
A、B、C、A、B
或A、W、A、B、
或L、C、A、B
或A、B、C、L
或A、W、L
或L、C、L
- 输入123120,
A、B、C、A、T
或者A、W、T
或者L、C、T
, - ......
可以发现新的关键信息:
- 既
考虑
当前位的影响,也考虑
和前位的组合的影响- 当前位置大于0,并且前位大于0小于3,并且当前位为2时,当前位置必须小于6
- 只
考虑
当前位的影响- 当前位置大于0,前位等于0或者大于2,或者当前位置大于6并且前位等于2
- 只
考虑
和前位的组合的影响- 当前位置等于0,并且前位大于0小于3
- 既不
考虑
当前位的影响,也不考虑
和前位的组合的影响(非法的情况,需要排除)- 当前位置等于0,并且前位大于2
接着我们思考下,具体是怎么影响的:
- 如果既考虑当前位的影响,也考虑和前位的组合的影响(对应4),这种情况可以除了当前前位的可能性,还需考虑新增位和每种可能性的前位的组合,可能性为
dp[i]=dp[i-1]+dp[i-2]
- 如果只考虑当前位置(对应5),那么可能性不变,
dp[i]=dp[i-1]
,比如123:三种可能,1231也是三种可能; - 如果只考虑和前位的组合(对应6)需要往前倒两步,如果倒两步的那个位置有多少种可能,就有多少种可能,
dp[i] = dp[i-2]
。换句话说,当前位置为0,那么需要丢弃前位的可能性,例如:1231:三种可能,12312:六种可能,但是123120:就只有三种可能了。
现在我们总结下获得的状态转移方程:
当前位置有效,并且和前位组合也有效:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i]=dp[i-1]+dp[i-2] </math>dp[i]=dp[i−1]+dp[i−2]
仅当前位置有效:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> d p [ i ] = d p [ i − 1 ] dp[i]=dp[i-1] </math>dp[i]=dp[i−1]
仅和前位组合有效:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> d p [ i ] = d p [ i − 2 ] dp[i] = dp[i-2] </math>dp[i]=dp[i−2]
出现其余情况是非法的,返回0,
ts
export function solve(nums: string): number {
// write code here
if (nums.length === 0) {
return 0;
}
if (nums === "0") return 0;
if (nums === "10" || nums === "20") return 1;
const n = nums.length;
const dp: number[] = new Array(n + 1).fill(1);
// 记录是否是无效的
let invalid = false;
for (let i = 2; i <= n; i++) {
// 当前位置有效,
if (nums[i - 1] > "0") {
// 可能性首先继承上次的
dp[i] = dp[i - 1];
// 需要考虑和前位组合影响
if (
nums[i - 2] === "1"
|| (nums[i - 2] === "2" && nums[i - 1] < "7")
) {
dp[i] = dp[i - 1] + dp[i - 2];
}
// 当前位置无效
} else if (nums[i - 1] === "0") {
// 和前位组合有效
if (nums[i - 2] > "0" && nums[i - 2] < "3") {
dp[i] = dp[i - 2];
} else {
invalid = true;
break;
}
}
}
return invalid ? 0 : dp[n];
}
可以发现,即使问题复杂,从边界入手,一点点分析推进,慢慢的就能发现其中的奥秘!
总结
本文使用了三个例子,从入门到中等难度,演示了我解决动态规划问题的思路。总的来说,就是先将问题化解为最小单元,比如我从第一步开始推演,一步一步发现规律,这也是动态规划的主要思想。
另外不管是哪种动态规划问题,都可以看到裴波那契数列的影子。
今天的分享就到这了,下篇文章我继续介绍中等和较难的动态规划问题。包括最长递增子序列已经在Vue diff算法中的体现。
所以一定要记得关注我公众号:萌萌哒草头将军
文章中难免会出现错误的地方,欢迎指正!