JS/TS算法---dp和贪心
一、动态规划
动态规划(dynamic programming, DP)是一种将复杂问题分解成更小的子问题来解决的优化技术。
注意,动态规划和分而治之是不同的方法。分而治之方法是把问题分解成相互独立的子问题,然后组合它们的答案,而动态规划则是将问题分解成相互依赖的子问题。
用动态规划解决问题时,要遵循三个重要步骤:
- 定义子问题;
- 实现要反复执行来解决子问题的部分(这一步要参考前一节讨论的递归的步骤);
- 识别并求解出基线条件。
能用动态规划解决的一些著名问题如下。
- 背包问题:给出一组项,各自有值和容量,目标是找出总值最大的项的集合。这个问题的限制是,总容量必须小于等于“背包”的容量。
- 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变余下元素的顺序而得到)。
- 矩阵链相乘:给出一系列矩阵,目标是找到这些矩阵相乘的最高效办法(计算次数尽可能少)。相乘运算不会进行,解决方案是找到这些矩阵各自相乘的顺序。
- 硬币找零:给出面额为d1, …, dn的一定数量的硬币和要找零的钱数,找出有多少种找零的方法。
- 图的全源最短路径:对所有顶点对(u, v),找出从顶点u到顶点v的最短路径。通常使用Floyd-Warshall算法
重点
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。 对于动态规划问题,我将拆解为如下五步曲
- dp数组以及其下标的含义
dp[i][j]、dp[i]
- 递推公式
- dp数组如何初始化
- 遍历顺序
- 打印dp数组
leetcode题精选
入门------dp仅一维数组
[509] 斐波那契数
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。示例 1:
输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
这⾥我们要⽤⼀个⼀维dp数组来保存递归的结果
- 确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
-
确定递推公式 为什么这是⼀道⾮常简单的⼊⻔题⽬呢?
因为题⽬已经把递推公式直接给我们了:状态转移⽅程 dp[i] = dp[i - 1] + dp[i - 2];
-
dp数组如何初始化
题⽬中把如何初始化也直接给我们了,如下:
dp[0] = 0;
dp[1] = 1;
-
确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序 ⼀定是从前到后遍历的
-
举例推导dp数组
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导⼀下,当N为10的时候,dp数组应该是如下的 数列:
0 1 1 2 3 5 8 13 21 34 55
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是⼀致的
function fib(n: number): number {
if(n<=1) return n;
const dp:number[]= [];
//初始化dp数组
dp[0] = 0;
dp[1] = 1;
//从前向后遍历
for(let i=2;i<=n;i++){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n]
};
时间:O(n) 空间:O(n)
function fib(n: number): number {
if(n<=1) return n;
const dp:number[]= [];
//初始化dp数组
dp[0] = 0;
dp[1] = 1;
//从前向后遍历
for(let i=2;i<=n;i++){
let sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum
}
return dp[1]
};
[70] 爬楼梯
爬到第⼀层楼梯有⼀种⽅法,爬到⼆层楼梯有两种⽅法。
那么第⼀层楼梯再跨两步就到第三层 ,第⼆层楼梯再跨⼀步就到第三层。
所以到第三层楼梯的状态可以由第⼆层楼梯 和 到第⼀层楼梯状态推导出来,那么就可以想到动态规划 了。
我们来分析⼀下,
动规五部曲:
定义⼀个⼀维数组来记录不同楼层的状态
-
确定dp数组以及下标的含义
dp[i]: 爬到第i层楼梯,有dp[i]种⽅法
-
确定递推公式
如果可以推出dp[i]呢?
从dp[i]的定义可以看出,dp[i] 可以有两个⽅向推出来。
⾸先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种⽅法,那么再⼀步跳⼀个台阶不就是dp[i]了么。
还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种⽅法,那么再⼀步跳两个台阶不就是dp[i]了么。
那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!
所以dp[i] = dp[i - 1] + dp[i - 2] 。
在推导dp[i]的时候,⼀定要时刻想着dp[i]的定义,否则容易跑偏。 这体现出确定dp数组以及下标的含义的重要性!
-
dp数组如何初始化
在回顾⼀下dp[i]的定义:爬到第i层楼梯,有dp[i]中⽅法。
那么i为0,dp[i]应该是多少呢,这个可以有很多解释,但都基本是直接奔着答案去解释的。 例如强⾏安慰⾃⼰爬到第0层,也有⼀种⽅法,什么都不做也就是⼀种⽅法即:dp[0] = 1,相当于直接站 在楼顶。 但总有点牵强的成分。 那还这么理解呢:我就认为跑到第0层,⽅法就是0啊,⼀步只能⾛⼀个台阶或者两个台阶,然⽽楼层是 0,直接站楼顶上了,就是不⽤⽅法,dp[0]就应该是0. 其实这么争论下去没有意义,⼤部分解释说dp[0]应该为1的理由其实是因为dp[0]=1的话在递推的过程 中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1。 从dp数组定义的⻆度上来说,dp[0] = 0 也能说得通。
需要注意的是:题⽬中说了n是⼀个正整数,题⽬根本就没说n有为0的情况。 所以本题其实就不应该讨论dp[0]的初始化! 我相信dp[1] = 1,dp[2] = 2,这个初始化⼤家应该都没有争议的。 所以我的原则是:不考虑dp[0]如果初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样 才符合dp[i]的定义。
-
确定遍历顺序 从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序⼀定是从前向后遍历的
-
举例推导dp数组
举例当n为5的时候,dp table(dp数组)应该是这样
1 2 3 5 8 function climbStairs(n: number): number { if(n<=1) return n; const dp:number[] = []; dp[1] = 1; dp[2] = 2; for(let i=3 ;i<=n;i++){ let sum = dp[1] + dp[2]; dp[1] = dp[2]; dp[2] = sum; } return dp[2] };
[198] 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
-
确定dp数组以及下标的含义
dp[i]
:i
代表房屋的索引,dp[i]
代表偷窃最大值 -
确定递推公式
决定
dp[i]
的因素就是第i房间偷还是不偷。如果偷第i房间,那么
dp[i] = dp[i - 2] + nums[i]
,即:第i-1
房⼀定是不考虑的,找出 下标i-2
(包括i-2) 以内的房屋,最多可以偷窃的⾦额为dp[i-2]
加上第i房间偷到的钱。如果不偷第i房间,那么
dp[i] = dp[i - 1]
,即考虑i-1
房,(注意这⾥是考虑,并不是⼀定要偷i-1房,这是 很多同学容易混淆的点) 然后dp[i]
取最⼤值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
-
dp数组如何初始化
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]。
dp[0] = nums[0]; dp[1] = max(nums[0], nums[1]);
-
确定遍历顺序 从递推公式遍历顺序⼀定是从前向后遍历的
-
举例推导dp数组
//空间O(n)
function rob(nums: number[]): number {
if(!nums) return 0;
const dp:number[] = [];
dp[0] = nums[0];
dp[1] = Math.max(nums[0],nums[1]);
for(let i=2; i<nums.length; i++){
dp[i] = Math.max(dp[i-1],dp[i-2]+nums[i])
}
return dp[nums.length-1];
};
//空间O(1)
[213] 打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 3:输入:nums = [1,2,3]
输出:3
对于一个数组,成环的话主要有如下三种情况:
- 情况一:考虑不包含首尾元素
- 情况二:考虑包含首元素,不包含尾元素
- 情况三:考虑包含尾元素,不包含首元素
注意我这里用的是"考虑",例如情况三,虽然是考虑包含尾元素,但不一定要选尾部元素!对于情况三,取nums[1] 和 nums[3]就是最大的。
而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了。
分析到这里,本题其实比较简单了。剩下的和198.打家劫舍就是一样的了。
function rob(nums: number[]): number {
if(!nums) return 0;
if(nums.length===1) return nums[0];
//不包括首元素
const res1 = robRange(nums,1,nums.length-1);
//不包括尾元素
const res2 = robRange(nums,0,nums.length-2);
return Math.max(res1,res2);
};
function robRange(nums:number[],start:number,end:number):number{
if(!nums) return 0;
if(start==end) return nums[start];
// const dp:number[] = [];
const dp = new Array(nums.length).fill(0);
dp[start] = nums[start];
dp[start+1] = Math.max(nums[start],nums[start+1]);
for(let i=start+2; i<=end; i++){
dp[i] = Math.max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[end];
}
打家劫舍III是关于树形dp的,先不讲
0-1背包/完全背包
直接看代码随想录讲解
01背包
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
在下面的讲解中,我举一个例子:
背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
以下讲解和图示中出现的数字都是以这个例子为例。
二维dp数组01背包
依然动规五部曲分析一波。
- 确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dp[i][j]
表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
只看这个二维数组的定义,大家一定会有点懵,看下面这个图:
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。
- 确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dp[i][j],
由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]
由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
- dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]
的定义触发,如果背包容量j为0的话,即dp[i][0]
,无论是选取哪些物品,背包价值总和一定为0。如图:
再看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
; 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j]
,即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
代码如下:
// 倒叙遍历
for (let j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0]; // 初始化i为0时候的情况
}
大家应该发现,这个初始化为什么是倒叙的遍历的?正序遍历就不行么?
正序遍历还真就不行,dp[0][j]
表示容量为j的背包存放物品0时候的最大价值,物品0的价值就是15,因为题目中说了每个物品只有一个!所以dp[0][j]
如果不是初始值的话,就应该都是物品0的价值,也就是15。
但如果一旦正序遍历了,那么物品0就会被重复加入多次!例如代码如下:
// 正序遍历
for (let j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
例如dp[0][1]
是15,到了dp[0][2] = dp[0][2 - 1] + 15
; 也就是dp[0][2] = 30
了,那么就是物品0被重复放入了。
所以一定要倒叙遍历,保证物品0只被放入一次!这一点对01背包很重要,后面在讲解滚动数组的时候,还会用到倒叙遍历来保证物品使用一次!
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0]
都已经初始化了,那么其他下标应该初始化多少呢?
dp[i][j]
在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。
如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (let j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
费了这么大的功夫,才把如何初始化讲清楚,相信不少同学平时初始化dp数组是凭感觉来的,但有时候感觉是不靠谱的。
- 确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
那么问题来了,先遍历 物品还是先遍历背包重量呢?
其实都可以!!但是先遍历物品更好理解。
那么我先给出先遍历物品,然后遍历背包重量的代码。
// weight数组的大小 就是物品个数
for(let i = 1; i < weight.length; i++) { // 遍历物品
for(let j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 这个是为了展现dp数组里元素的变化
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)
例如这样:
// weight数组的大小 就是物品个数
for(let j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(let i = 1; i < weight.size(); i++) { // 遍历物品
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
为什么也是可以的呢?
要理解递归的本质和递推的方向。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。
dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正左和正上两个方向),那么先遍历物品,再遍历背包的过程如图所示:
再来看看先遍历背包,再遍历物品呢,如图:
大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]
所需要的数据就是左上角,根本不影响dp[i][j]
公式的推导!
但先遍历物品再遍历背包这个顺序更好理解。
其实背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了。
- 举例推导dp数组
来看一下对应的dp数组的数值,如图:
最终结果就是dp[2][4]
。
建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。
做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!
很多同学做dp题目,遇到各种问题,然后凭感觉东改改西改改,怎么改都不对,或者稀里糊涂就改过了。
主要就是自己没有动手推导一下dp数组的演变过程,如果推导明白了,代码写出来就算有问题,只要把dp数组打印出来,对比一下和自己推导的有什么差异,很快就可以发现问题了。
完整测试代码
//先遍历物品再遍历背包的写法
function wei_bag_problem01(weight:number[],value:number[]) {
//背包重量 物品价值
const dp:number[][] = new Array(value.length+1).fill(0).map(x=>new Array(wight.length+1).fill(0));
//初始化dp
for (int j = weight.length; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
//遍历
for(let i=1; i<value.length; i++){
for(let j=0; j<=weight.length; j++){
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
[416] 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
钱币找零---完全背包问题
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
同样leetcode上没有纯完全背包问题,都是需要完全背包的各种应用,需要转化成完全背包问题,所以我这里还是以纯完全背包问题进行讲解理论和原理。
在下面的讲解中,我依然举这个例子:
背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
每件商品都有无限个!
问背包能背的物品最大价值是多少?
01背包和完全背包唯一不同就是体现在遍历顺序上,所以本文就不去做动规五部曲了,我们直接针对遍历顺序经行分析!
我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。
而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:
// 先遍历物品,再遍历背包
for(let i = 0; i <value .length; i++) { // 遍历物品
for(int j = weight[i]; j < weight.length ; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
dp状态图如下:
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!
因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。只要保证下标j之前的dp[j]都是经过计算的就可以了。
遍历物品在外层循环,遍历背包容量在内层循环,状态如图:
遍历背包容量在外层循环,遍历物品在内层循环,状态如图:
看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])。
先遍历被背包在遍历物品,代码如下:
// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cout << endl;
}
[322] 零钱兑换
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:输入:coins = [2], amount = 3
输出:-1
示例 3:输入:coins = [1], amount = 0
输出:0
金币无限,很容易联想到完全背包问题
- 确定dp数组以及下标的含义
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
- 确定递推公式
得到dp[j](考虑coins[i]),只有一个来源,dp[j - coins[i]](没有考虑coins[i])。
凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。
递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
- dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
所以下标非0的元素都是应该是最大值。
代码如下:
dp[j] = infinity;
dp[0] = 0;
- 确定遍历顺序
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。所以本题并不强调集合是组合还是排列。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。/ 如果求排列数就是外层for遍历背包,内层for循环遍历物品。
在动态规划专题我们讲过了求组合数是动态规划:518.零钱兑换II,求排列数是动态规划:377. 组合总和 Ⅳ。
所以本题的两个for循环的关系是:外层for循环遍历物品,内层for遍历背包或者外层for遍历背包,内层for循环遍历物品都是可以的!
那么我采用coins放在外循环,target在内循环的方式。
本题钱币数量可以无限使用,那么是完全背包。所以遍历的内循环是正序
综上所述,遍历顺序为:coins(物品)放在外循环,target(背包)在内循环。且内循环正序。
- 举例推导dp数组
以输入:coins = [1, 2, 5], amount = 5为例
dp[amount]为最终结果。
function coinChange(coins: number[], amount: number): number {
const dp:number[] = new Array(amount+1).fill(Infinity);
dp[0] = 0;
for(let i = 0; i < coins.length; i++) {
for(let j = coins[i]; j <= amount; j++) {
dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
};
[518] 零钱兑换 II
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 1:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
示例 2:输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。
示例 3:输入:amount = 10, coins = [10]
输出:1
排列数 外层for循环遍历物品,内层for遍历背包 每个顺序都要算
- 确定dp数组以及下标的含义
dp[j]:凑成总金额j的货币组合数为dp[j]
- 确定递推公式
dp[j] (考虑coins[i]的组合总和) 就是所有的dp[j - coins[i]](不考虑coins[i])相加。
所以递推公式:dp[j] += dp[j - coins[i]];
这个递推公式大家应该不陌生了,我在讲解01背包题目的时候在这篇动态规划:目标和!中就讲解了,求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];
- dp数组如何初始化
首先dp[0]一定要为1,dp[0] = 1是 递归公式的基础。
从dp[i]的含义上来讲就是,凑成总金额0的货币组合数为1。
下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]
- 确定遍历顺序
本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?
我在动态规划:关于完全背包,你该了解这些!中讲解了完全背包的两个for循环的先后顺序都是可以的。
但本题就不行了!
因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!
而本题要求凑成总和的组合数,元素之间要求没有顺序。
所以纯完全背包是能凑成总结就行,不用管怎么凑的。
本题是求凑出来的方案个数,且每个方案个数是为组合数。
那么本题,两个for循环的先后顺序可就有说法了。
我们先来看 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。
代码如下:
for (let i = 0; i < coins.length; i++) { // 遍历物品
for (let j = coins[i]; j <= amount; j++) { // 遍历背包容量
dp[j] += dp[j - coins[i]];
}
}
假设:coins[0] = 1,coins[1] = 5。
那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。
所以这种遍历顺序中dp[j]里计算的是组合数!
如果把两个for交换顺序,代码如下:
for (let j = 0; j <= amount; j++) { // 遍历背包容量
for (int i = 0; i < coins.length; i++) { // 遍历物品
if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
}
}
背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。
此时dp[j]里算出来的就是排列数!
可能这里很多同学还不是很理解,建议动手把这两种方案的dp数组数值变化打印出来,对比看一看!(实践出真知)
- 举例推导dp数组
输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:
最后红色框dp[amount]为最终结果。
function coinChange(coins: number[], amount: number): number {
const dp:number[] = new Array(amount+1).fill(0);
//凑成总金额0的货币组合数为1,为了递推公式成立,默认设为1。
dp[0] = 1;
for(let i = 0; i < coins.length; i++) {
for(let j = coins[i]; j <= amount; j++) {
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
};
子串和子序列
- 子序列(不连续)
- 300.最长上升子序列
- 1143.最长公共子序列
- 1035.不相交的线
- 子序列(连续)
- 674.最长连续递增序列
- 718.最长重复子数组
- 53.最大子序和
- 编辑距离
- 392.判断子序列
- 115.不同的子序列
- 583.两个字符串的删除操作
- 72.编辑距离
- 回文
- 647.回文子串
- 516.最长回文子序列
[300] 最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:输入:nums = [7,7,7,7,7,7,7]
输出:1
-
dp[i]的定义
dp[i]表示i之前包括i的最⻓上升⼦序列。
-
状态转移⽅程
所以:
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
注意这⾥不是要dp[i] 与 dp[j] + 1进⾏⽐较,⽽是我们要取dp[j] + 1的最⼤值。
-
dp[i]的初始化
dp[i] 每一个i 都是1
-
确定遍历顺序
dp[i] 是有0到i-1各个位置的最⻓升序⼦序列 推导⽽来,那么遍历i⼀定是从前向后遍历。 j其实就是0到i-1,遍历i的循环⾥外层,遍历j则在内层,代码如下
for (int i = 1; i < nums.size(); i++) { for (int j = 0; j < i; j++) { if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1); } if (dp[i] > result) result = dp[i]; // 取⻓的⼦序列 }
-
举例推导dp数组
一维全0 数组:new Array(m).fill(0)
function lengthOfLIS(nums: number[]): number {
if (nums.length <= 1) return nums.length;
let dp = new Array(nums.length).fill(1);
let res = dp[0];
for(let i=1 ;i<nums.length; i++){
for(let j= 0; j<i; j++){
if(nums[i]>nums[j]) {
dp[i] = Math.max(dp[i],dp[j]+1)
res = Math.max(res,dp[i]);
}
}
}
return res
};
[674] 最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。
示例 1:
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
示例 2:输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1。
function findLengthOfLCIS(nums: number[]): number {
if(nums.length<=1) return nums.length;
const dp:number[] = new Array(nums.length).fill(1);
let res = 0;
for(let i=0;i<nums.length-1;i++){
if(nums[i+1]>nums[i])
dp[i+1] = dp[i]+1;
res = Math.max(dp[i+1],res);
}
return res
};
贪心
[1143] 最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。示例 1:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace" ,它的长度为 3 。
示例 2:输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。
示例 3:输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。
-
确定dp数组(dp table)以及下标的含义
dp[i][j]
表示长度为[0,i-1] 和 [0,j-1] 的两个字符串的最长公共子序列长度 设长度为length+1,从1处开始,更方便些 -
确定递推公式
比较text1[i-1]和text2[j-1]是否一样,如果一样,则为
dp[i-1][j-1]+1
如果不一样则分别在text1和text2向后多取一位,取最大值,即
Math.max(dp[i-1][j],dp[i-1][j])
-
确定初始化的值
dp[i][0]=0
dp[0][j]=0
-
确定遍历顺序
从前往后双层遍历
-
打印dp数组

根据状态表得出状态转移方程,当两个字符相同时,仍然是左上角的单元格加一,否则比较左和上两个单元格的值,取较大值。
dp[i][j] = (dp[i-1][j-1] + 1) | max(dp[i-1][j], dp[i][j-1])
二维全0数组定义:new Array(m).fill(0).map((x) => new Array(n).fill(0))
function findLengthOfLCIS(nums: number[]): number {
if(nums.length<=1) return nums.length;
const dp:number[] = new Array(nums.length).fill(1);
let res = 0;
for(let i=0;i<nums.length-1;i++){
if(nums[i+1]>nums[i])
dp[i+1] = dp[i]+1;
res = Math.max(dp[i+1],res);
}
return res
};
[718] 最长重复子数组
给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度 。
示例 1:
输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出:3
解释:长度最长的公共子数组是 [3,2,1] 。
示例 2:输入:nums1 = [0,0,0,0,0], nums2 = [0,0,0,0,0]
输出:5
-
确定dp数组(dp table)以及下标的含义
dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最⻓重复⼦数组⻓度为dp[i][j]。
此时细⼼的同学应该发现,那dp[0][0]是什么含义呢?总不能是以下标-1为结尾的A数组吧。 其实dp[i][j]的定义也就决定着,我们在遍历dp[i][j]的时候i 和 j都要从1开始。 那有同学问了,我就定义dp[i][j]为 以下标i为结尾的A,和以下标j 为结尾的B,最⻓重复⼦数组⻓度。不 ⾏么? ⾏倒是⾏! 但实现起来就麻烦⼀点,⼤家看下⾯的dp数组状态图就明⽩了。
-
确定递推公式
根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。 即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1; 根据递推公式可以看出,遍历i 和 j 要从1开始
-
确定初始化的值
根据dp[i][j]的定义,dp[i][0] 和dp[0][j]其实都是没有意义的! 但dp[i][0] 和dp[0][j]要初始值,因为 为了⽅便递归公式dp[i][j] = dp[i - 1][j - 1] + 1; 所以dp[i][0] 和dp[0][j]初始化为0。 举个例⼦A[0]如果和B[0]相同的话,dp[1][1] = dp[0][0] + 1,只有dp[0][0]初始为0,正好符合递推公式 逐步累加起来。
-
确定遍历顺序
外层for循环遍历A,内层for循环遍历B。 那⼜有同学问了,外层for循环遍历B,内层for循环遍历A。不⾏么? 也⾏,⼀样的,我这⾥就⽤外层for循环遍历A,内层for循环遍历B了。
-
打印dp数组
//注意使用==或===
function findLength(nums1: number[], nums2: number[]): number {
const dp:number[][] = new Array(nums1.length+1).fill(0).map(x=>new Array(nums2.length+1).fill(0));
let res = 0;
for(let i=1; i<=nums1.length; i++){
for(let j=1; j<=nums2.length; j++){
if(nums1[i-1]==nums2[j-1]){
dp[i][j] = dp[i - 1][j - 1] + 1;
}
res = Math.max(res,dp[i][j]);
}
}
return res;
};
另一种解法 ------ 滚动数组
[53] 最大子数组和
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
示例 2:输入:nums = [1]
输出:1
示例 3:输入:nums = [5,4,-1,7,8]
输出:23
-
确定dp数组(dp table)以及下标的含义
dp[i]:包括下标i之前的最⼤连续⼦序列和为dp[i]。
-
确定递推公式
dp[i]只有两个⽅向可以推出来:
dp[i - 1] + nums[i],即:nums[i]加⼊当前连续⼦序列和
nums[i],即:从头开始计算当前连续⼦序列和
⼀定是取最⼤的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);
-
确定初始化的值
从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。
dp[0]应该是多少呢?
更具dp[i]的定义,很明显dp[0]因为为nums[0]即dp[0] = nums[0]。
-
确定遍历顺序
递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。
-
打印dp数组
-
function maxSubArray(nums: number[]): number {
if(!nums) return 0;
const dp:number[] = new Array(nums.length).fill(0);
let res = nums[0];
dp[0] = nums[0];
for(let i=1; i<nums.length; i++){
dp[i] = Math.max(dp[i-1] + nums[i],nums[i])
res = Math.max(dp[i],res);
}
return res;
};
[392] 判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
进阶:
如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
致谢:
特别感谢 @pbrother 添加此问题并且创建所有测试用例。
示例 1:
输入:s = "abc", t = "ahbgdc"
输出:true
示例 2:输入:s = "axc", t = "ahbgdc"
输出:false
这道题应该算是编辑距离的⼊⻔题⽬,因为从题意中我们也可以发现,只需要计算删除的情况,不⽤考 虑增加和替换的情况。 所以掌握本题也是对后⾯要讲解的编辑距离的题⽬打下基础。
- 确定dp数组(dp table)以及下标的含义
dp[i][j]
表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]
。
注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。
有同学问了,为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?
用i来表示也可以!
但我统一以下标i-1为结尾的字符串来计算,这样在下面的递归公式中会容易理解一些,如果还有疑惑,可以继续往下看。
- 确定递推公式
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:
-
if (s[i - 1] == t[j - 1])
-
- t中找到了一个字符在s中也出现了
-
if (s[i - 1] != t[j - 1])
-
- 相当于t要删除元素,继续匹配
if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1
(如果不理解,在回看一下dp[i][j]
的定义)
if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];
- dp数组如何初始化
从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。
这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]
。
因为这样的定义在dp二维矩阵中可以留出初始化的区间,如图:
如果要是定义的dp[i][j]
是以下标i为结尾的字符串s和以下标j为结尾的字符串t,初始化就比较麻烦了。
这里dp[i][0]和dp[0][j]是没有含义的,仅仅是为了给递推公式做前期铺垫,所以初始化为0。
- 确定遍历顺序
同理从从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1]
,那么遍历顺序也应该是从上到下,从左到右
如图所示:
- 举例推导dp数组
以示例一为例,输入:s = "abc", t = "ahbgdc",dp状态转移图如下:
dp[i][j]
表示以下标i-1为结尾的字符串s和以下标j-1为结尾的字符串t 相同子序列的长度,所以如果dp[s.size()][t.size()]
与 字符串s的长度相同说明:s与t的最长相同子序列就是s,那么s 就是 t 的子序列。
图中dp[s.size()][t.size()] = 3
, 而s.size() 也为3。所以s是t 的子序列,返回true
function isSubsequence(s: string, t: string): boolean {
// if(!s) return false;
const dp:number[][] = new Array(s.length+1).fill(0).map(x=>new Array(t.length+1).fill(0));
for(let i=1; i<=s.length; i++){
for(let j=1; j<=t.length; j++){
if(s[i-1]==t[j-1]){
//如果相同+1
dp[i][j] = dp[i-1][j-1] + 1;
}else{
//不同的话向前找
dp[i][j] = dp[i][j-1]
}
}
}
return dp[s.length][t.length]==s.length?true:false;
};
[72] 编辑距离
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
示例 2:输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
- 确定dp数组(dp table)以及下标的含义
dp[i][j]
表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,转换最少操作数。
- 确定递推公式
-
if (word1[i - 1] == word2[j - 1])
-
- 不操作
-
if (word1[i - 1] != word2[j - 1])
-
- 增
- 删
- 换
也就是如上四种情况。
if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];
word1[i - 1] 与 word2[j - 1]相等了,那么就不用编辑了,以下标i-2为结尾的字符串word1和以下标j-2为结尾的字符串word2的最近编辑距离dp[i - 1][j - 1] 就是 dp[i][j]了。
if (word1[i - 1] != word2[j - 1]),此时就需要编辑了,如何编辑呢?
操作一:word1增加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-2为结尾的word1 与 i-1为结尾的word2的最近编辑距离 加上一个增加元素的操作。
即 dp[i][j] = dp[i - 1][j] + 1;
操作二:word2添加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个增加元素的操作。
即 dp[i][j] = dp[i][j - 1] + 1;
这里有同学发现了,怎么都是添加元素,删除元素去哪了。
word2添加一个元素,相当于word1删除一个元素,例如 word1 = "ad" ,word2 = "a",word2添加一个元素d,也就是相当于word1删除一个元素d,操作数是一样!
操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作。
即 dp[i][j] = dp[i - 1][j - 1] + 1;
综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
递归公式代码如下:
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
else {
dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
- dp数组如何初始化
dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。
那么dp[i][0]就应该是i,对空字符串做添加元素的操作就可以了,即:dp[i][0] = i;
同理dp[0][j] = j;
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
- 确定遍历顺序
从如下四个递推公式:
dp[i][j] = dp[i - 1][j - 1]
dp[i][j] = dp[i - 1][j - 1] + 1
-----替换dp[i][j] = dp[i][j - 1] + 1
------减少dp[i][j] = dp[i - 1][j] + 1
------增加
可以看出dp[i][j]
是依赖左方,上方和左上方元素的,如图:
所以在dp矩阵中一定是从左到右从上到下去遍历。
代码如下:
for (int i = 1; i <= word1.size(); i++) {
for (int j = 1; j <= word2.size(); j++) {
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
else {
dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
}
}
- 举例推导dp数组
以示例1,输入:word1 = "horse", word2 = "ros"为例,dp矩阵状态图如下:
function minDistance(word1: string, word2: string): number {
const dp:number[][] = new Array(word1.length+1).fill(0).map(x=>new Array(word2.length).fill(0));
//初始化
for (let i = 0; i <= word1.length; i++) dp[i][0] = i;
for (let j = 0; j <= word2.length; j++) dp[0][j] = j;
//遍历
for (let i = 1; i <= word1.length; i++){
for (let j = 1; j <= word2.length; j++){
if(word1[i-1]==word2[j-1]){
dp[i][j] = dp[i-1][j-1];
}else{
dp[i][j] = Math.min(dp[i-1][j-1],dp[i-1][j],dp[i][j-1])+1
}
}
}
return dp[word1.length][word2.length]
};
[647] 回文子串---dp为布尔类型数组
给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
示例 1:
输入:"abc"
输出:3
解释:三个回文子串: "a", "b", "c"示例 2:
输入:"aaa"
输出:6
解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"提示:输入的字符串长度不会超过 1000。
两层for循环,遍历区间起始位置和终止位置,然后判断这个区间是不是回文。
时间复杂度:O(n^3)
动规解法:
- 确定dp数组(dp table)以及下标的含义
布尔类型的dp[i][j]
:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]
为true,否则为false。
- 确定递推公式
在确定递推公式时,就要分析如下几种情况。
整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。
当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]
一定是false。
当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况
- 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
- 情况二:下标i 与 j相差为1,例如aa,也是文子串
- 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看
dp[i + 1][j - 1]
是否为true。
以上三种情况分析完了,那么递归公式如下:
if (s[i] == s[j]) {
if (j - i <= 1) { // 情况一 和 情况二
result++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1]) { // 情况三
result++;
dp[i][j] = true;
}
}
result就是统计回文子串的数量。
注意这里我没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]
初始化的时候,就初始为false。
- dp数组如何初始化
dp[i][j]
可以初始化为true么?当然不行,怎能刚开始就全都匹配上了。
所以dp[i][j]
初始化为false。
- 确定遍历顺序
遍历顺序可有有点讲究了。
首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]
是否为true,在对dp[i][j]
进行赋值true的。
dp[i + 1][j - 1]
在 dp[i][j]
的左下角,如图:
如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1]
,也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。
所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]
都是先经过计算的。
有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]
都是经过计算的。
- 举例推导dp数组
举例,输入:"aaa",dp[i][j]
状态如下:
图中有6个true,所以就是有6个回文子串。
注意因为dp[i][j]
的定义,所以j一定是大于等于i的,那么在填充dp[i][j]
的时候一定是只填充右上半部分。
function countSubstrings(s: string): number {
if(!s) return 0;
const dp:boolean[][] = new Array(s.length).fill(false).map(x=>new Array(s.length).fill(false));
let res = 0;
for(let i=s.length-1;i>=0;i--){
for (let j = i; j < s.length; j++){
if(s[i] == s[j]){
if (j - i <= 1) { // 情况一 和 情况二
res++;
dp[i][j] = true;
} else if (dp[i + 1][j - 1]) { // 情况三
res++;
dp[i][j] = true;
}
}
}
}
return res;
};
[516] 最长回文子序列---非连续的
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入:"babad"
输出:"bab"
注意:"aba" 也是一个有效答案。
示例 2:
输入:"cbbd"
输出:"bb"
求最长回文子串,这是一道非常经典的字符串问题,需要重点理解。
- 确定dp数组(dp table)以及下标的含义
dp[i][j]
:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]
。
- 确定递推公式
在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。
如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;
如图:
(如果这里看不懂,回忆一下dp[i][j]
的定义)
如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。
加入s[j]的回文子序列长度为dp[i + 1][j]
。
加入s[i]的回文子序列长度为dp[i][j - 1]
。
那么dp[i][j]
一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
;
代码如下:
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
- dp数组如何初始化
首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2
; 可以看出 递推公式是计算不到 i 和j相同时候的情况。
所以需要手动初始化一下,当i与j相同,那么dp[i][j]
一定是等于1的,即:一个字符的回文子序列长度就是1。
其他情况dp[i][j]
初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
; 中dp[i][j]
才不会被初始值覆盖。
vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
- 确定遍历顺序
从递推公式dp[i][j] = dp[i + 1][j - 1] + 2 和 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
可以看出,dp[i][j]
是依赖于dp[i + 1][j - 1] 和 dp[i + 1][j]
,
也就是从矩阵的角度来说,dp[i][j]
下一行的数据。所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的。
递推公式:dp[i][j] = dp[i + 1][j - 1] + 2,dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
分别对应着下图中的红色箭头方向,如图:
代码如下:
for (int i = s.size() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.size(); j++) {
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
- 举例推导dp数组
输入s:"cbbd" 为例,dp数组状态如图:
红色框即:dp[0][s.size() - 1]
; 为最终结果。
function longestPalindromeSubseq(s: string): number {
if(!s) return 0;
const dp:number[][] = new Array(s.length).fill(0).map(x=>new Array(s.length).fill(0));
for (let i = 0; i < s.length; i++) dp[i][i] = 1;
for(let i=s.length-1;i>=0;i--){
for(let j=i+1;j<s.length;j++){
if(s[i]==s[j]){
dp[i][j] = dp[i+1][j-1] +2
}else{
dp[i][j] = Math.max(dp[i][j-1],dp[i+1][j])
}
}
}
return dp[0][s.length-1]
};
股票
[121] 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例 2:输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i][0]
表示第i天持有股票所得现金 ,规定一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。
dp[i][1]
表示第i天不持有股票所得现金
注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态
- 确定递推公式
如果第i天持有股票即dp[i][0]
, 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:
dp[i - 1][0]
- 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:
dp[i - 1][1]
- 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:
prices[i] + dp[i - 1][0]
同样dp[i][1]
取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0])
;
- dp数组如何初始化
由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
可以看出
其基础都是要从dp[0][0]和dp[0][1]
推导出来。
那么dp[0][0]
表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];
dp[0][1]
表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0
;
- 确定遍历顺序
从递推公式可以看出dp[i]都是有dp[i - 1]推导出来的,那么一定是从前向后遍历。
- 举例推导dp数组
以示例1,输入:[7,1,5,3,6,4]为例,dp数组状态如下:
121.买卖股票的最佳时机
dp[5][1]
就是最终结果。
为什么不是dp[5][0]
呢?
因为本题中不持有股票状态所得金钱一定比持有股票状态得到的多!
function maxProfit(prices: number[]): number {
if(!prices) return 0;
const dp:number[][] = new Array(prices.length).fill(0).map(x=>new Array(2).fill(0));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for(let i=1;i<prices.length;i++){
dp[i][0] = Math.max(dp[i-1][0],-prices[i]);
dp[i][1] = Math.max(dp[i-1][1],dp[i-1][0]+prices[i])
}
return dp[prices.length-1][1];
};
滚动数组
function maxProfit(prices: number[]): number {
if(!prices) return 0;
const dp:number[][] = new Array(2).fill(0).map(x=>new Array(2).fill(0));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for(let i=1;i<prices.length;i++){
dp[i % 2][0] = Math.max(dp[(i - 1) % 2][0], -prices[i]);
dp[i % 2][1] = Math.max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
}
return dp[(prices.length - 1) % 2][1];
};
[122] 买卖股票的最佳时机 II-------多次
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。示例 2:
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。示例 3:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。提示:
- 1 <= prices.length <= 3 * 10 ^ 4
- 0 <= prices[i] <= 10 ^ 4
本题和121. 买卖股票的最佳时机的唯一区别本题股票可以买卖多次了(注意只有一只股票,所以再次购买前要出售掉之前的股票)
在动规五部曲中,这个区别主要是体现在递推公式上,其他都和121. 买卖股票的最佳时机一样一样的。
所以我们重点讲一讲递推公式。
这里重申一下dp数组的含义:
dp[i][0]
表示第i天持有股票所得现金。dp[i][1]
表示第i天不持有股票所得最多现金
如果第i天持有股票即dp[i][0]
, 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:
dp[i - 1][0]
- 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:
dp[i - 1][1] - prices[i]
注意这里和121. 买卖股票的最佳时机唯一不同的地方,就是推导dp[i][0]
的时候,第i天买入股票的情况。
在121. 买卖股票的最佳时机中,因为股票全程只能买卖一次,所以如果买入股票,那么第i天持有股票即dp[i][0]
一定就是 -prices[i]。
而本题,因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润。
那么第i天持有股票即dp[i][0]
,如果是第i天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 即:dp[i - 1][1] - prices[i]。
在来看看如果第i天不持有股票即dp[i][1]
的情况, 依然可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:
dp[i - 1][1]
- 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:
prices[i] + dp[i - 1][0]
注意这里和121. 买卖股票的最佳时机就是一样的逻辑,卖出股票收获利润(可能是负值)天经地义!
function maxProfit(prices: number[]): number {
if(!prices) return 0;
const dp:number[][] = new Array(prices.length).fill(0).map(x=>new Array(2).fill(0));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for(let i=1;i<prices.length;i++){
dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1]-prices[i]);
dp[i][1] = Math.max(dp[i-1][1],dp[i-1][0]+prices[i])
}
return dp[prices.length-1][1];
};
//滚动数组
function maxProfit(prices: number[]): number {
if(!prices) return 0;
const dp:number[][] = new Array(2).fill(0).map(x=>new Array(2).fill(0));
dp[0][0] = -prices[0];
dp[0][1] = 0;
for(let i=1;i<prices.length;i++){
dp[i % 2][0] = Math.max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1]-prices[i]);
dp[i % 2][1] = Math.max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
}
return dp[(prices.length - 1) % 2][1];
};
其他
[42] 接雨水-------困难题
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例 2:输入:height = [4,2,0,3,2,5]
输出:9
单调栈+动态规划
二、贪心算法
贪心算法(Greedy Algorithm)会在每一步选择中都采取当前状态下最好或最优(即最有利)的选择,不能回退,从而希望结果是最好或最优的算法。
「贪心的本质是选择每一阶段的局部最优,从而达到全局最优」。
这么说有点抽象,来举一个例子:
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。
再举一个例子如果是 有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。
动态规划和贪心的区别
如果某⼀问题有很多重叠⼦问题,使⽤动态规划 是最有效的。 所以动态规划中每⼀个状态⼀定是由上⼀个状态推导出来的,这⼀点就区分于贪⼼,贪⼼没有状态推导,⽽是从局部直接选最优的
我举了⼀个背包问题的例⼦。 例如:有
N
件物品和⼀个最多能背重量为W
的背包。第i件物品的重量是weight[i]
,得到的价值是value[i]
。每件物品只能⽤⼀次,求解将哪些物品装⼊背包⾥物品价值总和最⼤。 动态规划中dp[j]是由dp[j-weight[i]]
推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])
。 但如果是贪⼼呢,每次拿物品选⼀个最⼤的或者最⼩的就完事了,和上⼀个状态没有关系。 所以贪⼼解决不了动态规划的问题。
贪心算法在有最优子结构的问题中尤为有效(例如求图的最小生成树、哈夫曼编码等),最优子结构是指局部最优解能决定全局最优解。即问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。
什么时候用贪心
手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。
如何验证可不可以用贪心算法呢
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧
刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
leetcode题精选
一、分糖果
[455] 分发饼干
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
示例 1:
输入: g = [1,2,3], s = [1,1]
输出: 1
解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。
示例 2:输入: g = [1,2], s = [1,2,3]
输出: 2
解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.
先满足最小胃口的
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
「这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩」。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。
然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
function findContentChildren(g: number[], s: number[]): number {
g = g.sort((a,b)=>a-b);
s = s.sort((a,b)=>a-b);
let res = 0;
let len = s.length-1;
// 倒叙遍历小孩 并找倒叙找合适糖果
for(let i=g.length-1;i>=0;i--){
if(s[len]>=g[i]&&len>=0){
res++;
len--;
}
}
return res
};
二、区间调度
[435] 无重叠区间
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠 。
示例 1:
输入: intervals = [[1,2],[2,3],[3,4],[1,3]]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。
示例 2:输入: intervals = [ [1,2], [1,2], [1,2] ]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。
示例 3:输入: intervals = [ [1,2], [2,3] ]
输出: 0
解释: 你不需要移除任何区间,因为它们已经是无重叠的了。
找到所有不重叠的区域,再将总数相减
解题思路如下所列:
(1)根据终点对区间进行排列。
(2)从区间集合中选取一个终点最小的区间 [start, minEnd]。
(3)将所有与 [start, minEnd] 相交的区间从集合中移除。
(4)重复执行(2)和(3),直至遍历完集合。
我来按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。
此时问题就是要求非交叉区间的最大个数。
右边界排序之后,局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉。全局最优:选取最多的非交叉区间。
局部最优推出全局最优,试试贪心!
这里记录非交叉区间的个数还是有技巧的,如图:

区间,1,2,3,4,5,6都按照右边界排好序。
每次取非交叉区间的时候,都是可右边界最小的来做分割点(这样留给下一个区间的空间就越大),所以第一条分割线就是区间1结束的位置。
接下来就是找大于区间1结束位置的区间,是从区间4开始。那有同学问了为什么不从区间5开始?别忘已经是按照右边界排序的了。
区间4结束之后,在找到区间6,所以一共记录非交叉区间的个数是三个。
总共区间个数为6,减去非交叉区间的个数3。移除区间的最小数量就是3。
function eraseOverlapIntervals(intervals: number[][]): number {
//根据右边界进行排序
intervals.sort((a, b) => a[1] - b[1]);
let curEnd = intervals[0], //终点最小的区间
count = 1 ; //不重叠的区间数
intervals.forEach((value) => {
if (value[0] < curEnd[1]) { //过滤起点比curEnd终点小的区间
return;
}
count++;
curEnd = value;
});
return intervals.length-count;
};
三、钱币找零
[860] 柠檬水找零
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
示例 1:
输入:bills = [5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。
示例 2:输入:bills = [5,5,10,10,20]
输出:false
解释:
前 2 位顾客那里,我们按顺序收取 2 张 5 美元的钞票。
对于接下来的 2 位顾客,我们收取一张 10 美元的钞票,然后返还 5 美元。
对于最后一位顾客,我们无法退回 15 美元,因为我们现在只有两张 10 美元的钞票。
由于不是每位顾客都得到了正确的找零,所以答案是 false。
只需要维护三种金额的数量,5,10和20。
有如下三种情况:
- 情况一:账单是5,直接收下。
- 情况二:账单是10,消耗一个5,增加一个10
- 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5
此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实在情况三。
而情况三逻辑也不复杂甚至感觉纯模拟就可以了,其实情况三这里是有贪心的。
账单是20的情况,为什么要优先消耗一个10和一个5呢?
因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!
所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。
function lemonadeChange(bills: number[]): boolean {
let five: number = 0,
ten: number = 0;
for (let bill of bills) {
switch (bill) {
case 5:
five++;
break;
case 10:
if (five < 1) return false;
five--;
ten++
break;
case 20:
if (ten > 0 && five > 0) {
five--;
ten--;
} else if (five > 2) {
five -= 3;
} else {
return false;
}
break;
}
}
return true;
};
四、股票
[121] 买卖股票的最佳时机
[122] 买卖股票的最佳时机 II
这道题目可能我们只会想,选一个低的买入,在选个高的卖,在选一个低的买入.....循环反复。
如果想到其实最终利润是可以分解的,那么本题就很容易了!
如何分解呢?
假如第0天买入,第3天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
此时就是把利润分解为每天为单位的维度,而不是从0天到第3天整体去考虑!
那么根据prices可以得到每天的利润序列:(prices[i] - prices[i - 1]).....(prices[1] - prices[0])。
如图:

一些同学陷入:第一天怎么就没有利润呢,第一天到底算不算的困惑中。
第一天当然没有利润,至少要第二天才会有利润,所以利润的序列比股票序列少一天!
从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。
那么只收集正利润就是贪心所贪的地方!
局部最优:收集每天的正利润,全局最优:求得最大利润。
局部最优可以推出全局最优,找不出反例,试一试贪心!
function maxProfit(prices: number[]): number {
let resProfit: number = 0;
for (let i = 1, length = prices.length; i < length; i++) {
resProfit += Math.max(prices[i] - prices[i - 1], 0);
}
return resProfit;
};
五、子序列
[402] 移掉K位数字
给你一个以字符串表示的非负整数 num 和一个整数 k ,移除这个数中的 k 位数字,使得剩下的数字最小。请你以字符串形式返回这个最小的数字。
示例 1 :
输入:num = "1432219", k = 3
输出:"1219"
解释:移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219 。
示例 2 :输入:num = "10200", k = 1
输出:"200"
解释:移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。
示例 3 :输入:num = "10", k = 2
输出:"0"
解释:从原数字移除所有的数字,剩余为空就是 0 。
从左至右扫描,当前扫描的数还不确定要不要删,入栈暂存。
123531这样「高位递增」的数,肯定不会想删高位,会尽量删低位。
432135这样「高位递减」的数,会想干掉高位,直接让高位变小,效果好。
所以,如果当前遍历的数比栈顶大,符合递增,是满意的,让它入栈。
如果当前遍历的数比栈顶小,栈顶立刻出栈,不管后面有没有更大的,为什么?
因为栈顶的数属于高位,删掉它,小的顶上,高位变小,效果好于低位变小。
"1432219" k = 3
bottom[1 ]top 1入
bottom[1 4 ]top 4入
bottom[1 3 ]top 4出 3入
bottom[1 2 ]top 3出 2入
bottom[1 2 2 ]top 2入
bottom[1 2 1 ]top 2出 1入 出栈满3个,停止出栈
bottom[1 2 1 9 ]top 9入
照这么设计,如果是"0432219",0 遇不到比它更小的,最后肯定被留在栈中,变成 0219,还得再去掉前导0。
"0432219" k = 3
bottom[0 ]top 0入
bottom[0 4 ]top 4入
bottom[0 3 ]top 4出 3入
bottom[0 2 ]top 3出 2入
bottom[0 2 2 ]top 2入
bottom[0 2 1 ]top 2出 1入 出栈满3个,停止出栈
bottom[0 2 1 9 ]top 9入
能不能直接不让前导 0 入栈?——可以。
加一个判断:栈为空且当前字符为 "0" 时,不入栈。取反,就是入栈的条件:
if c != '0' || len(stack) != 0 {
stack = append(stack, c) // 入栈
}
这避免了 0 在栈底垫底。
注意到,遍历结束时,有可能还没删够 k 个字符,继续循环出栈,删低位。
删够了,但如果栈变空了,什么也不剩,则返回 "0"。
否则,将栈中剩下的字符,转成字符串返回。
var removeKdigits = function (num, k) {
const stack = [];
for (let i = 0; i < num.length; i++) {
const c = num[i];
while (k > 0 && stack.length && stack[stack.length - 1] > c) {
stack.pop();
k--;
}
if (c != '0' || stack.length != 0) {
stack.push(c);
}
}
while (k > 0) {
stack.pop();
k--;
}
return stack.length == 0 ? "0" : stack.join('');
};
可能你会问:什么时候用单调栈?
需要给当前的元素,找右边/左边第一个比它大/小的位置。
记住这两句话:
单调递增栈,利用波谷剔除栈中的波峰,留下波谷;
单调递减栈,利用波峰剔除栈中的波谷,留下波峰。
本题想维护高位递增,即,元素想找右边第一个比它小的数,即右侧第一个波谷。
单调递增栈遇到波谷,用它来剔除栈中的波峰,维持单增。
留下波谷保持了单增,而剔除掉的栈中字符,就是删掉的字符。
[392] 判断子序列
[1143] 最长公共子序列
六.跳跃游戏
[55] 跳跃游戏
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个位置。
示例 1:
- 输入: [2,3,1,1,4]
- 输出: true
- 解释: 我们可以先跳 1 步,从位置 0 到达 位置 1, 然后再从位置 1 跳 3 步到达最后一个位置。
示例 2:
- 输入: [3,2,1,0,4]
- 输出: false
- 解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 , 所以你永远不可能到达最后一个位置
刚看到本题一开始可能想:当前位置元素如果是3,我究竟是跳一步呢,还是两步呢,还是三步呢,究竟跳几步才是最优呢?
其实跳几步无所谓,关键在于可跳的覆盖范围!
不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。
这个范围内,别管是怎么跳的,反正一定可以跳过来。
那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!
每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。
贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。
局部最优推出全局最优,找不出反例,试试贪心!
如图:

i每次移动只能在cover的范围内移动,每移动一个元素,cover得到该元素数值(新的覆盖范围)的补充,让i继续移动下去。
而cover每次只取 max(该元素数值补充后的范围, cover本身范围)。
如果cover大于等于了终点下标,直接return true就可以了。
function canJump(nums: number[]): boolean {
let res = 0;
if(nums.length<=1) return true;
for(let i=0; i<=res; i++){
res = Math.max(i+nums[i],res)
if (res >= nums.length - 1) return true
}
return false
};
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南