跳到主要内容

动态规划

· 阅读需 21 分钟
Yana Ching
Front End Engineer

原题

70. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?



示例 1:

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶


提示:

1 <= n <= 45

伪代码

函数 爬楼梯(台阶数 n):
如果 n 是 1:
返回 1
如果 n 是 2:
返回 2
否则:
返回 爬楼梯(n-1) + 爬楼梯(n-2)


dp[1] = 1
dp[2] = 2
从 3 开始:
dp[3] = dp[2] + dp[1]
dp[4] = dp[3] + dp[2]
……
dp[n] = dp[n-1] + dp[n-2]

编码

/**
* 推荐写法,优化空间
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
// 动态规划自底向上计算,从最小层级开始计算
let arr = new Array(n + 1)

if (n == 1) return 1
if (n == 2) return 2

let a = 1
let b = 2
let res = 0

for (let i = 3; i <= n; i++) {
res = a + b
a = b
b = res
}

return res
}
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
if (n == 1) return 1
if (n == 2) return 2

let dp = []
dp[1] = 1
dp[2] = 2
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]
}

return dp[n]
}

198 打家劫舍

原题

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 。


提示:

1 <= nums.length <= 100
0 <= nums[i] <= 400

误区

并不是间隔偷就是最优解

而是求每个位置之前偷窃的最优解

拆解就是



1 2 3 1

0 --- nums[0]
1 --- max(0) / nums[1]
2 --- max(1) / max(0) + nums[2]
3 --- max(2) / max(1) + nums[3]

...

n --- max(n-1) / max(n-2) + nums[n]

伪代码

函数 rob(nums):
如果 nums 是空,返回 0
如果 只有 1 个房子,返回 nums[0]

创建数组 dp,长度为 nums.length
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])

对 i 从 2 到 nums.length - 1:
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])

返回 dp[最后一个位置]

编码

/**
* @param {number[]} nums
* @return {number}
*/
var rob = function (nums) {
// 动态规划的题目都要手动推导一遍,找出规律
if (nums.length == 1) return nums[0]
if (nums.length == 2) return Math.max(nums[0], nums[1])
let max = []
max[0] = nums[0]
max[1] = Math.max(nums[0], nums[1])
for (let i = 2; i < nums.length; i++) {
max[i] = Math.max(max[i - 1], max[i - 2] + nums[i])
}

return max[nums.length - 1]
}

746 使用最小花费爬楼梯

原题


746. 使用最小花费爬楼梯

给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。



示例 1:

输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。
示例 2:

输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。


提示:

2 <= cost.length <= 1000
0 <= cost[i] <= 999

推导

1 100 1 1 1 100 1 1 100 1
可以爬1/2个台阶,所以如果从最底部向上爬,需要付费为 0
总阶梯数量 花费
n = 0, sum = 0
n = 1, sum = 0
n = 2, sum = 0
n = 3, sum = nums[0] / nums[1] --- min
n = 4, sum = min(3) / min(2)
n = 5, sum = min(4) / min(3)

...

n sum = min(n-1) / min(n - 2)

伪代码

function minCostClimbingStairs(cost):
n = length of cost

dp[0] = 0 // 到达第0阶(起点)不花钱
dp[1] = 0 // 到达第1阶(也可作为起点)不花钱

for i from 2 to n:
// 你可以从 i-1 跨一步,或者从 i-2 跨两步
dp[i] = min(
dp[i - 1] + cost[i - 1],
dp[i - 2] + cost[i - 2]
)

return dp[n]

我的解法

你写法正确写法
状态定义正确 ✅正确 ✅
状态转移正确 ✅正确 ✅
初始化 dp多余 ❌精简 ✅
额外 min[2]不必要 ❌不需要,循环中计算 ✅
/**
* @param {number[]} cost
* @return {number}
*/
var minCostClimbingStairs = function (cost) {
// 求最小花费 + 每次可以跨1或者2个台阶
// ==> 求到最后一个台阶的花费
// ==> 倒数第二个台阶+到倒数第二个台阶的花费 ~ 倒数第一个台阶+到倒数第一个台阶的花费
// ==> 求两个花费得最小值
if (cost.length <= 1) return 0

let min = []
min[0] = 0
min[1] = 0
min[2] = Math.min(cost[0], cost[1])

for (let i = 3; i <= cost.length; i++) {
min[i] = Math.min(min[i - 2] + cost[i - 2], min[i - 1] + cost[i - 1])
}
// console.log({ min, target: min[cost.length] })
return min[cost.length]
}
/**
* @param {number[]} cost
* @return {number}
*/
var minCostClimbingStairs = function (cost) {
if (cost.length <= 1) return 0

let min = [0, 0] // 0 1 位置的最小花费都是 0
for (let i = 2; i <= cost.length; i++) {
min[i] = Math.min(min[i - 2] + cost[i - 2], min[i - 1] + cost[i - 1])
}

return min[cost.length]
}
/**
* @param {number[]} cost
* @return {number}
*/
var minCostClimbingStairs = function (cost) {
if (cost.length <= 1) return 0

let prev = 0 // dp[i-2]
let curr = 0 // dp[i-1]
for (let i = 2; i <= cost.length; i++) {
const next = Math.min(prev + cost[i - 2], curr + cost[i - 1])
prev = curr
curr = next
}

return curr
}

1137 第 N 个斐波那契数

原题


1137. 第 N 个泰波那契数

提示
泰波那契序列 Tn 定义如下:

T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的条件下 Tn+3 = Tn + Tn+1 + Tn+2

给你整数 n,请返回第 n 个泰波那契数 Tn 的值。



示例 1:

输入:n = 4
输出:4
解释:
T_3 = 0 + 1 + 1 = 2
T_4 = 1 + 1 + 2 = 4
示例 2:

输入:n = 25
输出:1389537


提示:

0 <= n <= 37
答案保证是一个 32 位整数,即 answer <= 2^31 - 1。

伪代码

函数 Tribonacci(n):
如果 n 等于 0,返回 0
如果 n 小于 3,返回 1

初始化数组 prev 为 [0, 1, 1]

从 i = 3 到 n,进行以下操作:
prev[i] = prev[i - 3] + prev[i - 2] + prev[i - 1]

返回 prev[n]

编码

/**
* @param {number} n
* @return {number}
*/
var tribonacci = function (n) {
if (n == 0) return 0
if (n < 3) return 1
let prev = [0, 1, 1]
for (let i = 3; i <= n; i++) {
prev[i] = prev[i - 3] + prev[i - 2] + prev[i - 1]
}
return prev[n]
}

213 打家劫舍 Ⅱ

原题

伪代码

函数 rob(nums):
如果只有 1 个房子:
返回它的钱

情况一:偷第 0 到 n-2 号房子(不偷最后一间)
情况二:偷第 1 到 n-1 号房子(不偷第一间)

分别计算这两种情况的最大偷盗金额:
max1 =[0, n-2] 之间的最大金额
max2 =[1, n-1] 之间的最大金额

返回 max(max1, max2)

函数 偷[start, end]:
dp[i] 表示偷到第 i 间房子为止,最多偷到多少钱

dp[start] = nums[start]
dp[start + 1] = max(nums[start], nums[start + 1])

从 i = start + 2 到 end:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])

返回 dp[end]

注意点

  • 如果 dp[i] 表示的是偷到第 i 个房子,就直接用 nums[i];
  • 如果 dp[i] 表示的是偷到第 i - 1 个房子,就用 nums[i - 1]。

编码

/**
* 小偷专用函数:打家劫舍 II(房子围成一圈)
* @param {number[]} nums - 每间房子的现金列表
* @return {number} - 最多能偷到多少钱
*/
var rob = function (nums) {
/**
* 小偷的助手函数:负责在一条直线上的房子里偷东西
* @param {number[]} nums - 所有房子里的现金
* @param {number} start - 从第几个房子开始
* @param {number} end - 偷到第几个房子结束
* @return {number} - 从 start 到 end 之间最多能偷多少钱
*/
function rob_item(nums, start, end) {
// 如果房子是空的,啥都偷不到
if (nums.length == 0) return 0

// 如果只有一个房子,那就直接偷它!
if (nums.length == 1) return nums[0]

// 如果 start 和 end 是同一个房子,就只能偷这一家
if (start == end) return nums[start]

// 创建一个记账本 dp,用来记每一步最多能偷多少钱
let dp = []

// 第一个能偷的房子是 start,把它偷了
dp[start] = nums[start]

// 第二个房子是 start+1,这时我们要选:偷 start 还是 start+1,谁钱多偷谁
dp[start + 1] = Math.max(nums[start], nums[start + 1])

// 从第三个房子开始(也就是 start + 2),我们一个一个往后看
for (let i = start + 2; i <= end; i++) {
// 有两个选择:
// 1. 不偷这一家,那就保持前一个的偷法(dp[i - 1])
// 2. 偷这一家,那前一家不能偷,所以是 dp[i - 2] + nums[i]
// 然后比较这两个选项,选钱多的那个
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])
}

// 最后返回偷到第 end 家时最多能偷多少钱
return dp[end]
}

const len = nums.length - 1

// 特别情况:如果只有一间房子,那直接偷它就好
if (nums.length === 1) return nums[0]

// 情况一:不偷最后一家 → 偷第 0 到 len-1 家
const max1 = rob_item(nums, 0, len - 1)

// 情况二:不偷第一家 → 偷第 1 到 len 家
const max2 = rob_item(nums, 1, len)

// 比较两个方案哪个偷的钱更多,返回最多的那个
const res = Math.max(max1, max2)

return res
}

740 删除并获得点数

原题

740. 删除并获得点数

提示
给你一个整数数组 nums ,你可以对它进行一些操作。

每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数。之后,你必须删除 所有 等于 nums[i] - 1 和 nums[i] + 1 的元素。

开始你拥有 0 个点数。返回你能通过这些操作获得的最大点数。



示例 1:

输入:nums = [3,4,2]
输出:6
解释:
删除 4 获得 4 个点数,因此 3 也被删除。
之后,删除 2 获得 2 个点数。总共获得 6 个点数。
示例 2:

输入:nums = [2,2,3,3,3,4]
输出:9
解释:
删除 3 获得 3 个点数,接着要删除两个 2 和 4 。
之后,再次删除 3 获得 3 个点数,再次删除 3 获得 3 个点数。
总共获得 9 个点数。


提示:

1 <= nums.length <= 2 * 104
1 <= nums[i] <= 104

思路

  1. 理解题目,选中数 i 的之后,i-1i+1 也要被移除,获得点数 i,即,i 一旦被移除掉,旁边两个数值也得被移除掉 ====> 数据需要按照大小排列,想办法让数值和 index 关联起来 ====> 用哈希表存储,key值 ~ value出现次数

  2. 那么,如果存在相同的 i 呢? -----> 一旦第一个 i 被移除掉,就会直接删掉相邻的点,后面再遇到相同的 i 不需要再删除相邻 点数了,因此有 x 个 i,就有 x*i 的点数

  3. 所以用于最后计算的哈希表要和总分挂钩,由 key值 ~ value出现个数 得到 key值 ~ value总分

  4. 然后逐个分析规律

    1. 处理到第一个元素的最大得分是 dp[i] = nums[i]
    2. 处理到第二个元素的最大得分是 dp[i] = max(dp[i-1], nums[i])
    3. 处理到第三个元素的最大得分是 dp[i] = max(dp[i-1], dp[i-2]+nums[i]) 【可能最后一个选中的值是 nums[i] ,也可能是 num[i-1],如果最后一个是 num[i-1] 那么 nums[i] 会被删除不处理;如果最后一个是 num[i] ,那就需要计算 dp[i-2]+nums[i]】
  5. 由此发现这个问题其实是打家劫舍 问题的变形

联想

✅ 转化为打家劫舍问题如果你选择了数字 x,就不能选择 x-1 和 x+1 可以先把同一个数字的总得分算出来,比如:

  • nums = [2, 2, 3, 3, 3, 4] =>
  • count[2] = 4 // 2 + 2
  • count[3] = 9 // 3 + 3 + 3
  • count[4] = 4 // 4

然后你要在 count[i] 上做选择:选 i 就不能选 i-1,就成了打家劫舍问题的动态规划形式: dp[i] = max(dp[i-1], dp[i-2] + count[i])

想象你在“吃糖果”:

  • 每种数字是一种糖果,吃一个数字就把它全都吃光(得分 = 数字 × 数量)
  • 但吃掉一种糖果之后,相邻的糖果就会被扔掉(不能再得分)
  • 所以你要聪明地安排先吃哪些,后吃哪些,才能得到最多糖果!

编码

/**
* @param {number[]} nums
* @return {number}
*/
var deleteAndEarn = function (nums) {
// 1. 边界处理
if (nums.length == 0) return 0
if (nums.length == 1) return nums[0]

// 2. 不关注排序,只关注最大值
const maxVal = Math.max(...nums)
// 3. 初始化总数数组,避免数组越界
let count = new Array(maxVal + 1).fill(0)
// 4. 遍历数组,获取 总数-下标 关联数组
for (let num of nums) {
count[num] += num
}

let prev2 = count[0] // 相当于 dp[i - 2]
let prev1 = Math.max(count[0], count[1]) // 相当于 dp[i - 1]

for (let i = 2; i <= maxVal; i++) {
const curr = Math.max(prev1, prev2 + count[i])
prev2 = prev1
prev1 = curr
}

return prev1
}

62 不同的路径

原题

问题理解

  • 一个 m x n 的网格
  • 机器人只能 向右 或 向下 移动
  • 起点是左上角 (0, 0),终点是右下角 (m-1, n-1)
  • 问:有多少种路径从起点走到终点?

动态规划思路

  1. 定义一个二维数组 dp[i][j] 表示从起点到 (i, j) 位置的路径总数

  2. 初始化第一行和第一列:机器人只能一直往右/往下走,所以路径数都是 1

  3. 状态转移方程:

dp[i][j] = dp[i-1][j] + dp[i][j-1] 从上面来一步:dp[i-1][j]

从左边来一步:dp[i][j-1]

最终答案是:dp[m-1][n-1]

伪代码

function uniquePaths(m, n):
创建二维数组 dp,大小为 m x n,所有元素初始为 1

for i 从 1 到 m-1:
for j 从 1 到 n-1:
dp[i][j] = dp[i-1][j] + dp[i][j-1]

返回 dp[m-1][n-1]

举例说明(m = 3, n = 3)

初始状态:

1 1 1
1 ? ?
1 ? ?

一步步填充:

1 1 1
1 2 3
1 3 6

最后的答案是:6

分析

✅ 我使用的写法:用 Array.from() 一步搞定

const dp = Array.from({ length: m }, () => Array(n).fill(1))

🔍 它做的事情就是:

- { length: m }:只是告诉 Array.from 要生成 m 个元素
- () => Array(n).fill(1):告诉它每一项该填啥 —— 就是一个 n 长度、值全是 1 的数组

编码

/**
* @param {number} m
* @param {number} n
* @return {number}
*/
var uniquePaths = function (m, n) {
let dp = Array.from({ length: m }, () => Array(n).fill(1))
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
// 计算到 (i,j) 的路径,限制只能向右向下
// 所以可以从 dp[i-1][j],dp[i][j-1] 两个点过来
}
}

return dp[m - 1][n - 1]
}

力扣239-滑动窗口中的最大值

· 阅读需 6 分钟
Yana Ching
Front End Engineer

原题

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值 。



示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:

输入:nums = [1], k = 1
输出:[1]


提示:

1 <= nums.length <= 105
-104 <= nums[i] <= 104
1 <= k <= nums.length

解题思路

理解题目逻辑:窗口长度为 k,获取该窗口下最大的值,窗口收缩会出现多个可能的子集,在子集中找到最大的值

  1. 遍历数组 nums,循环执行条件是 r < nums.length
  2. 每次移动右侧指针时,将 nums[r] 与存储最值得队列尾巴对比,如果队列的尾部元素小于 nums[r],一直将队列尾部出队直到尾部元素大于 nums[r]
  3. 一旦 r - l + 1 > k即窗口长度超过k,需要收缩 l(l++),收缩之前判断 num[l] 是否和队列头部元素相等,如果相等,出队队列头部元素

PS: 出队头部元素不关注 nums[l] 在队列中部或者尾部的情况,因为有新的 nums[r] 大于队列除了队头以外的任意一个值,队列内的元素都有可能被出队 或者简单记忆(只关注最大值)

  1. 移动右指针之前将队头的值存储待定结果集
  2. 返回结果集中的最大值

错误点:

  1. 将不符合的窗口区间也记录下来了(一定确保 [l,r] 窗口要合格结果才有效)
  2. 区间合格条件需要注意 k 是长度,r 是序列(从0开始),所以区间合格条件是 r >= k - 1

代码

/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function (nums, k) {
// 窗口长度为 k,一旦窗口长度超过 k,就需要收缩左区间
// 每次移动右区间,判断区间是否合法,如果合法,更新最大值
/**
理解题目逻辑:窗口长度为 k,获取该窗口下最大的值,窗口收缩会出现多个可能的子集,在子集中找到最大的值

1. 遍历数组 nums,循环执行条件是 r < nums.length
2. 每次移动右侧指针时,将 nums[r] 与存储最值得队列尾巴对比,如果队列的尾部元素小于 nums[r],一直将队列尾部出队直到尾部元素大于 nums[r]
3. 一旦 r - l + 1 > k即窗口长度超过k,需要收缩 l(l++),收缩之前判断 num[l] 是否和队列头部元素相等,如果相等,出队队列头部元素

PS: 出队头部元素不关注 nums[l] 在队列中部或者尾部的情况,因为有新的 nums[r] 大于队列除了队头以外的任意一个值,队列内的元素都有可能被出队
或者简单记忆(只关注最大值)

4. 移动右指针之前将队头的值存储待定结果集
5. 返回结果集中的最大值

错误点:
1. 将不符合的窗口区间也记录下来了(一定确保 [l,r] 窗口要合格结果才有效)
2. 区间合格条件需要注意 k 是长度,r 是序列(从0开始),所以区间合格条件是 r >= k - 1

*/
let l = 0
let r = 0
let deque = []
let res = []
while (r < nums.length) {
while (deque.length && nums[r] > deque[deque.length - 1]) {
deque.pop()
}
deque.push(nums[r])
while (r - l + 1 > k) {
// 收缩之前判断即将被排除的左端元素是否在单调递减队列头部,若存在,移除该元素
if (nums[l] == deque[0]) {
deque.shift()
}
// 收缩左侧指针
l++
}
if (r >= k - 1) {
// 区间合格的时候,队列最大值才有意义
res.push(deque[0] || 0)
}
r++
}

return res
}

力扣1438-绝对差不超过限制的最长连续子数组

· 阅读需 7 分钟
Yana Ching
Front End Engineer

原题


给你一个整数数组 nums ,和一个表示限制的整数 limit,请你返回最长连续子数组的长度,该子数组中的任意两个元素之间的绝对差必须小于或者等于 limit 。

如果不存在满足条件的子数组,则返回 0 。



示例 1:

输入:nums = [8,2,4,7], limit = 4
输出:2
解释:所有子数组如下:
[8] 最大绝对差 |8-8| = 0 <= 4.
[8,2] 最大绝对差 |8-2| = 6 > 4.
[8,2,4] 最大绝对差 |8-2| = 6 > 4.
[8,2,4,7] 最大绝对差 |8-2| = 6 > 4.
[2] 最大绝对差 |2-2| = 0 <= 4.
[2,4] 最大绝对差 |2-4| = 2 <= 4.
[2,4,7] 最大绝对差 |2-7| = 5 > 4.
[4] 最大绝对差 |4-4| = 0 <= 4.
[4,7] 最大绝对差 |4-7| = 3 <= 4.
[7] 最大绝对差 |7-7| = 0 <= 4.
因此,满足题意的最长子数组的长度为 2 。
示例 2:

输入:nums = [10,1,2,4,7,2], limit = 5
输出:4
解释:满足题意的最长子数组是 [2,4,7,2],其最大绝对差 |2-7| = 5 <= 5 。
示例 3:

输入:nums = [4,2,2,2,4,4,2,2], limit = 0
输出:3


提示:

1 <= nums.length <= 10^5
1 <= nums[i] <= 10^9
0 <= limit <= 10^9

分析

  1. 求连续子数组,考虑滑动窗口
  2. 求差值考虑连续区间内的最大、最小值[每次更新极值会把不符合的数据都清除出去]
  3. 窗口不符合时,检查极端值是否包含区间值,如果是,移除这个极端值队头

解题思路


整理题目: 数组 nums,连续元素间的绝对值 <= limit,要求返回符合的窗口长度(max)

- 👉 绝对值考虑区间的最大最小值
- 👉 两个队列来分别记录区间拥有的最大、最小值
- 👉 没有长度限制,因此更新绝对值没有限定条件,每次移动右指针都需要更新
- 关注:何时收缩左指针
- 👉 一旦绝对值 > limit 就需要收缩左指针

- 收缩左指针注意事项:

判断左指针对应元素是否在两个队列的队头,如果在,将队列头部出队(单调队列只关注队头,nums[l] 出 现在队中或者队尾,一旦有
新的更符合条件的元素入队,随时会被出队,因此不关注)

1. 遍历整个 nums,循环结束条件是 r < nums.length
2. 判断极值队列中不为空,将队列尾部大于/小于 nums[r] 的元素移除出队
3. 分别将 nums[r] 压入队列
4. while 循环设置收缩左指针的逻辑 Math.abs(max-min) > k 时,收缩左指针 l+【收缩之前查看即将被排除的 nums[l] 是否在极值队
列的队头影响结果,如果在,将队列队头元素出队】

伪代码

nums = [8, 2, 4, 7]
limit = 4
rightnums[right]maxDequeminDequemax-minleftres当前窗口
08[8][8]001[8]
12[8,2][2]611[2]
24[4][2,4]212[2,4]
37[7][2,4,7] → [2,4]522[4,7]

代码

/**
* @param {number[]} nums
* @param {number} limit
* @return {number}
*/
var longestSubarray = function (nums, limit) {
/**
整理题目:
数组 nums,连续元素间的绝对值 <= limit,要求返回符合的窗口长度(max)
👉 绝对值考虑区间的最大最小值
👉 两个队列来分别记录区间拥有的最大、最小值
👉 没有长度限制,因此更新绝对值没有限定条件,每次移动右指针都需要更新
关注:何时收缩左指针 👉 一旦绝对值 > limit 就需要收缩左指针
收缩左指针注意事项:
判断左指针对应元素是否在两个队列的队头,如果在,将队列头部出队(单调队列只关注队头,nums[l] 出现在队中或者队尾,一旦有新的更符合条件的元素入队,随时会被出队,因此不关注)

1. 遍历整个 nums,循环结束条件是 r < nums.length
2. 判断极值队列中不为空,将队列尾部大于/小于 nums[r] 的元素移除出队
2. 分别将 nums[r] 压入队列
3. while 循环设置收缩左指针的逻辑 Math.abs(max-min) > k 时,收缩左指针 l+【收缩之前查看即将被排除的 nums[l] 是否在极值队列的队头影响结果,如果在,将队列队头元素出队】
*/
let l = 0
let r = 0
let queueMax = []
let queueMin = []

let res = 0
while (r < nums.length) {
while (queueMax.length && nums[r] > queueMax[queueMax.length - 1]) {
queueMax.pop()
}
queueMax.push(nums[r])

while (queueMin.length && nums[r] < queueMin[queueMin.length - 1]) {
queueMin.pop()
}

queueMin.push(nums[r])

// l 收缩逻辑
if (queueMax.length && queueMin.length && Math.abs(queueMax[0] - queueMin[0]) > limit) {
// 收缩之前检查(检查一次就行,不需要用 while)
if (queueMax[0] == nums[l]) {
queueMax.shift()
}
if (queueMin[0] == nums[l]) {
queueMin.shift()
}

// 收缩区间
l++
}

res = Math.max(r - l + 1, res)
r++
}
console.log({ res })
return res
}

推荐关联(easy -> hard)

  • 1004 基础滑动窗口
  • 239 单调队列
  • 1438 滑动窗口 + 单调队列
  • 1696/862 单调队列优化 DP
  • 480 中位数窗口复杂的数据结构维护

力扣1004-最大连续1的个数 Ⅲ

· 阅读需 5 分钟
Yana Ching
Front End Engineer

原题

给定一个二进制数组 nums 和一个整数 k,假设最多可以翻转 k 个 0 ,则返回执行操作后 数组中连续 1 的最大个数 。



示例 1:

输入:nums = [1,1,1,0,0,0,1,1,1,1,0], K = 2
输出:6
解释:[1,1,1,0,0,1,1,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 6。
示例 2:

输入:nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3
输出:10
解释:[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 10。


提示:

1 <= nums.length <= 105
nums[i] 不是 0 就是 1
0 <= k <= nums.length

分析

  1. 问题转换
可以翻转 k 个 0 后数组中最长的连续 1 的长度
👇
只能包含 k 个 0 的连续 1 的数组
👇
滑动窗口记录包含 k 个 0 的结果集,返回最长的结果集的长度

解题思路

  1. 连续 + 最长 ==> 考虑滑动窗口
  2. 翻转 k 个 0 为 1 得到连续 1 的最长长度 ==> "包含 k 个 0 的最长 1 组成的最长长度"
    • 一次遍历,遇到 1 右端扩张 r++
    • 遇到 0 记录 0 的个数
    • 一旦 0 的个数超过 k(count > k),收缩左边(收缩之前左端元素为 0,更新窗口中 0 的个数 - 1 )
    • 每次移动右指针之后都记录当前窗口的长度(结果的子集)

伪代码

代码

for 循环

/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var longestOnes = function (nums, k) {
// 翻转 k 个 0 得到最长连续 1 的长度
// 把最长连续1看作窗口,内部只能包含 <= k 个,否则窗口不合法(>k),就需要收缩左区间
// 直到内部0的个数 < k之前,都一直收缩左区间
// 每次移动右指针都记录当前连续 1 的长度

let l = 0
let count = 0
let len = 0
for (let r = 0; r < nums.length; r++) {
if (nums[r] == 0) {
// 右指针遍历的时候遇到 0 就更新当前窗口中 0 的个数
count++
}
while (count > k) {
// 窗口中 0 的个数超过题目限制的 k ,收缩左区间确保窗口是合法的
if (nums[l] == 0) count--
// 右移左指针
l++
}
// 每次移动右指针的时候,记录当前窗口的长度
len = Math.max(r - l + 1, len)
}
return len
}

while 循环

/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var longestOnes = function (nums, k) {
// 翻转 k 个 0 得到最长连续 1 的长度
// 把最长连续1看作窗口,内部只能包含 <= k 个,否则窗口不合法(>k),就需要收缩左区间
// 直到内部0的个数 < k之前,都一直收缩左区间
// 每次移动右指针都记录当前连续 1 的长度

let l = 0
let r = 0
let count = 0
let len = 0
while (r < nums.length) {
// 遇到 0 更新 count
if (nums[r] == 0) count++
// 一旦 count > k 收缩左指针
while (count > k) {
// 收缩之前判断,即将被排除的元素是否为0,是则更新 count
if (nums[l] == 0) count--
l++
}
// 移动右指针之前记录当前区间的长度
len = Math.max(len, r - l + 1)
// 扩张右区间
r++
}
console.log({ len })
return len
}

intro

· 阅读需 1 分钟
Yana Ching
Front End Engineer

我的算法练习小记

力扣35-搜索插入位置[二分法]

· 阅读需 4 分钟
Yana Ching
Front End Engineer

原题

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

请必须使用时间复杂度为 O(log n) 的算法。


示例 1:

> 输入: nums = [1,3,5,6], target = 5
> 输出: 2

示例 2:

> 输入: nums = [1,3,5,6], target = 2
> 输出: 1

示例 3:

> 输入: nums = [1,3,5,6], target = 7
> 输出: 4
提示
  • 1 <= nums.length <= 10⁴
  • -10⁴ <= nums[i] <= 10⁴
  • nums 为 无重复元素 的 升序 排列数组
  • -10⁴ <= target <= 10⁴

解题思路

已知

  • 有序数组
  • 目标值 target

  • 若有 target,返回索引
  • 若无 target 返回顺序插入位置(right 位置)

空状态处理

  • 空数组,返回 0

解题过程

  1. 初始化左右指针:left = 0, right = len(nums) - 1
    • (假设区间为 [l, r] 左右均闭合区间,结束外层循环时: l > r,当 target 比 l 小的时候,l 指向数组头;当 target < r 时,l 指向数组尾)
    • (假设区间为 [l, r) 左闭右开区间,结束外层循环时: l == r,直接返回 r 或者 l 都行 )
  2. 空数组,返回 0
  3. 二分查找 target
    • 找到匹配的 target,返回 target 索引
    • 找不到匹配的 target,返回 right 指针位置

复杂度

代码

[l,r]

/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var searchInsert = function (nums, target) {
// [l, r]
const n = nums.length
let l = 0
let r = n - 1
// l <= r 条件,循环结束的时候 r > l,此时 r 为左边界,适合处理插入尾部的操作和空数组的情况
// l < r 条件,循环结束的时候 r == l,此时直接返回即可
while (l <= r) {
mid = (l + r) >>> 1
if (target === nums[mid]) {
return mid
} else if (target > nums[mid]) {
l = mid + 1
} else {
r = mid - 1
}
}
return r + 1
}

[l,r)

/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var searchInsert = function (nums, target) {
// [l, r]
const n = nums.length
let l = 0
let r = n
// l <= r 条件,循环结束的时候 r > l,此时 r 为左边界,适合处理插入尾部的操作和空数组的情况
// l < r 条件,循环结束的时候 r == l,此时直接返回即可
while (l < r) {
mid = (l + r) >>> 1
if (target === nums[mid]) {
return mid
} else if (target > nums[mid]) {
l = mid + 1
} else {
r = mid
}
}
return r
// return l 都是可以的
}

力扣34-在排序数组中查找元素的第一个和最后一个位置[二分法]

· 阅读需 5 分钟
Yana Ching
Front End Engineer

原题

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。

请你找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [-1, -1]。

你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。


示例 1:

> 输入:nums = [5,7,7,8,8,10], target = 8
> 输出:[3,4]

示例 2:

> 输入:nums = [5,7,7,8,8,10], target = 6
> 输出:[-1,-1]

示例 3:

> 输入:nums = [], target = 0
> 输出:[-1,-1] :::tip
提示
  • 0 <= nums.length <= 10⁵

  • -10⁹ <= nums[i] <= 10⁹

  • nums 是一个非递减数组

  • -10⁹ <= target <= 10⁹

解题思路

已知:

  1. 数组有序 - 定位 二分法
  2. nums
  3. target 值
  4. 有可能有重复元素

求:

  1. target 出现的第一个出现的位置
  2. target 出现的最后一个出现位置

寻找最左:

  1. 找到 mid 符合 target 之后,更新 LBoder 为 mid
  2. r 移动到 mid 左边,寻找更左侧的 target
  3. 有就继续收缩,没有直接返回 -1

寻找最右:

  1. 找到 mid 符合 target 之后,更新 RBoder 为 mid
  2. l 移动到 mid 右边,寻找更右侧的 target
  3. 有就继续收缩,没有直接返回 -1

明确隐藏条件:

  1. 若找不到 first,那么 last 也是不存在,因此首次判断 first 存在与否再决定是否寻找 last
  2. 当能找到 first 时,last 最小也只能是 first,不会出现 -1
  3. 检查空数组,空数组直接返回 [-1, -1]

解题过程

  1. 初始化左右指针:left = 0, right = len(nums) - 1
  2. 二分查找第一个位置
    • 如果找到匹配的 target,将 right 指针移到中点的左侧继续查找,直到锁定第一个出现的 target。
  3. 二分查找最后一个位置
    • 如果找到匹配的 target,将 left 指针移到中点的右侧继续查找,直到锁定最后一个出现的 target。
  4. 返回结果:如果没有找到目标值,返回 [-1, -1]

复杂度

  • 时间复杂度: O(logN)

  • 空间复杂度: O(1)

代码

/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var searchRange = function (nums, target) {
// 直接检查空数组
const n = nums.length
if (n === 0) return [-1, -1]

function getFirst(nums, target, n) {
// [l, r]
let l = 0
let r = n - 1
let first = -1
while (l <= r) {
const mid = (l + r) >>> 1
// 找到 target 位置后更新 first 位置,收缩右边界,继续向mid左边寻找是否有更左值
if (target === nums[mid]) {
first = mid
r = mid - 1
} else if (target > nums[mid]) {
l = mid + 1
} else {
r = mid - 1
}
}
return first
}

function getLast(nums, target, n) {
// [l, r]
let l = 0
let r = n - 1
let last = -1
while (l <= r) {
const mid = (l + r) >>> 1
// 找到 target 位置后更新 last 位置,收缩左边界,继续向mid右边寻找是否有更右值
if (target === nums[mid]) {
last = mid
l = mid + 1
} else if (target > nums[mid]) {
l = mid + 1
} else {
r = mid - 1
}
}
return last
}
let first = getFirst(nums, target, n)
// 如果找不到第一个位置,说明数组中没有这个元素,那么最后一个位置也是找不到的
// 循环条件上理解则是 一旦 left 为 -1了,在循环条件 left <= right这个上, right也只能是 -1 了
if (first === -1) return [-1, -1]
// 一旦能够找到第一个位置,那么 last 最小也只能是 first
// 循环条件上理解则是 一旦 left > 0 了,在循环条件 left <= right这个上, right也只能是 > 0 了
let last = getLast(nums, target, n)
return [first, last]
}

力扣209 - 长度最小子数组[滑动窗口]

· 阅读需 4 分钟
Yana Ching
Front End Engineer

原题描述

提示

给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其总和大于等于 target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果 不存在符合条件的子数组,返回 0 。


示例 1

输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2

输入:target = 4, nums = [1,4,4]
输出:1
示例 3

输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0

提示:

* 1 <= target <= 109
* 1 <= nums.length <= 105
* 1 <= nums[i] <= 105

滑动窗口解法

整体的解题思路是

  1. 遍历 t 中所有字符,记录所有字符的出现次数
  2. 判断 left-right 区间内的值是否包含 t 中所有的元素
  3. 如果包含了,判断当前字串与已记录的最小字串,符合更小标准则更新 minL 和 minStart
  4. 在已经包含所有 t 中元素的基础上,移动左指针,移动左指针之前将左指针元素加到原来的哈希表中(不影响,因为不在 t 中元素 在哈希表中查询的结果是 undefined,此时直接加 1,结果就是 NaN,不影响算法结果)

优化点:

  1. 只用了一个哈希表(空间复杂度上减小了)
  2. 每次只更新子串的头节点和长度,仅在最后结果时候做字符串截取
/**
* @param {string} s
* @param {string} t
* @return {string}
*/
function minWindow(s, t) {
let left = 0
let right = 0
let required = t.length
let minL = Infinity
let minStart = 0
const ht = {}
for (let ct of t) {
ht[ct] = (ht[ct] || 0) + 1
}

let n = s.length
while (right < n) {
// right 指针走完 s 的长度为止
let retR = s[right]
if (ht[retR] > 0) {
// 如果是t中元素的话,值为数值,而不是 undefined
required--
}
ht[retR]-- // 无论是否为 t 中元素,直接自减去哈希值 undefined - 1 = NaN
right++

while (required === 0) {
// 判断区间长度是否小于 minL,如果小于则更新 minL、minStart
if (right - left < minL) {
minL = right - left
minStart = left
}
// 右移左指针,每次移动都直接增加哈希表中该元素值(undefined + 1 = NaN 不影响算法)
let retL = s[left]
ht[retL]++
// 若是该哈希值大于零,则 left 所指的元素是 t 中值
if (ht[retL] > 0) required++
left++
}
}
return minL === Infinity ? '' : s.substring(minStart, minStart + minL)
}