Vol.1 写完之后,我继续往下练,这次碰到了两个新主题:二分法和滑动窗口。
有意思的是,两者都有一种"缩小范围"的感觉——但缩小的方式完全不同。二分是每次折半、精准排除;滑动窗口是维护一个区间,动态地伸缩边界。把它们放在一起写,是因为我在练习过程中发现了一些值得记下来的细节,尤其是二分返回值的语义问题,困惑了我一段时间。
一、为什么这两个放在一起
表面上看,二分法和滑动窗口没什么关系。但练完之后我意识到,它们都在做同一件事:在一个有序或连续的结构里,用两个边界来描述"当前关注的范围",并通过移动边界来逼近答案。
区别在于:
二分法 → 每次折半,指数级缩小范围,找"一个精确位置"
滑动窗口 → 每次移动一格,线性推进,找"一段满足条件的区间"
二、二分法
意象
把一本按页码排好的书翻到中间,根据当前页码判断目标在左边还是右边,然后只看那一半——每次都排除一半的可能性,直到找到目标或确认不存在。
识别信号
- 有序数组
- 查找某个值是否存在
- 查找某个值应该插入的位置
- 时间复杂度要求 O(log n)
真实场景
前端里最直接的对应是有序列表的快速定位。比如一个按时间排序的日志列表,要找某个时间戳附近的记录,用二分比线性扫描快得多。另一个场景是 Array.prototype 里没有内置的 sortedIndex——如果自己实现"往有序数组里插入元素并保持有序",底层就是二分找插入位。
代码范式
// 场景:在有序数组中查找目标值
// 核心:每次比较中间值,排除一半的搜索范围
let left = 0;
let right = arr.length - 1;
while (left <= right) { // 注意:是 <=,不是 <
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
// 找到了
} else if (arr[mid] < target) {
left = mid + 1; // 目标在右半,左边界右移
} else {
right = mid - 1; // 目标在左半,右边界左移
}
}
// while 结束后:left > right
// 此时 left 的语义是"target 应该插入的位置"
// 根据题目需要,返回 -1 或 left
while 条件写 left <= right 而不是 left < right,是为了覆盖搜索范围缩小到单个元素的情况——如果写 <,最后一个元素永远不会被检查。
例题一:LC 704 — 二分查找
给你一个升序整数数组 nums 和目标值 target,返回 target 在数组中的下标,不存在则返回 -1。
输入:nums = [1, 3, 5, 7, 9], target = 5 → 返回 2
输入:nums = [1, 3, 5, 7, 9], target = 4 → 返回 -1
// 环境:浏览器 / Node.js
// LeetCode 704 - Binary Search
// @param {number[]} nums
// @param {number} target
// @return {number}
function binarySearch(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // 找不到,明确返回"不存在"
}
console.log(binarySearch([1, 3, 5, 7, 9], 5)); // 2
console.log(binarySearch([1, 3, 5, 7, 9], 4)); // -1
执行过程拆解(以 [1,3,5,7,9], target=5 为例):
初始:left=0, right=4
[1, 3, 5, 7, 9]
^ ^
l r
第1轮:mid=2, nums[2]=5 === target,返回 2
初始:left=0, right=4,target=4
[1, 3, 5, 7, 9]
第1轮:mid=2, nums[2]=5 > 4,right=1
第2轮:mid=0, nums[0]=1 < 4,left=1
第3轮:mid=1, nums[1]=3 < 4,left=2
此时 left=2 > right=1,while 退出,返回 -1
例题二:LC 35 — 搜索插入位置
给你一个升序、无重复元素的数组 nums 和目标值 target。如果存在返回下标,不存在返回它应该被插入的位置。要求 O(log n)。
输入:nums = [1, 3, 5, 6], target = 5 → 返回 2
输入:nums = [1, 3, 5, 6], target = 2 → 返回 1
输入:nums = [1, 3, 5, 6], target = 7 → 返回 4
输入:nums = [1, 3, 5, 6], target = 0 → 返回 0
// 环境:浏览器 / Node.js
// LeetCode 35 - Search Insert Position
// @param {number[]} nums
// @param {number} target
// @return {number}
function searchInsert(nums, target) {
let left = 0;
let right = nums.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left; // 找不到,返回"应该插入的位置"
}
console.log(searchInsert([1, 3, 5, 6], 5)); // 2
console.log(searchInsert([1, 3, 5, 6], 2)); // 1
console.log(searchInsert([1, 3, 5, 6], 7)); // 4
console.log(searchInsert([1, 3, 5, 6], 0)); // 0
核心问题:两道题代码几乎一样,为什么返回值不同?
第一次写完这两道题,我愣了一下——代码主体完全相同,唯一的区别是最后一行:
// LC 704
return -1;
// LC 35
return left;
这不是随意选择的,背后是 while 结束时 left 的语义问题。
while 结束时发生了什么?
while (left <= right) 退出的条件是 left > right,也就是 left = right + 1。此时搜索范围已经收缩为空——left 和 right 错位了。
关键在于:left 此时指向的位置,正好是 target 应该被插入的地方。
用 target=2, nums=[1,3,5,6] 来验证:
第1轮:mid=1, nums[1]=3 > 2,right=0
第2轮:mid=0, nums[0]=1 < 2,left=1
此时 left=1 > right=0,退出
left=1,nums[1]=3,target=2 应该插在 3 的前面,也就是下标 1 ✓
再用 target=7, nums=[1,3,5,6] 验证边界:
第1轮:mid=1, nums[1]=3 < 7,left=2
第2轮:mid=2, nums[2]=5 < 7,left=3
第3轮:mid=3, nums[3]=6 < 7,left=4
此时 left=4 > right=3,退出
left=4,数组长度也是 4,target 应该插在末尾 ✓
所以 left 在 while 结束时不是随机值,它有确定的语义:target 应该所在的位置。
变体表格总结:
| 场景 | 最后一行 | 原因 |
|---|---|---|
| 找到返回下标,找不到返回"不存在" | return -1 | 明确表示目标不在数组中 |
| 找到返回下标,找不到返回插入位置 | return left | left 收敛到 target 应在的位置 |
两道题的代码完全一样,只是对 while 结束后 left 语义的不同利用。
三、滑动窗口
意象
一列火车从左往右开,车窗框住一段连续的景色。固定窗口的车窗大小不变;可变窗口的车窗可以伸缩——当窗口内的内容不满足条件时,缩小窗口;满足条件时,尝试扩大窗口。
识别信号
- 连续子数组 / 子字符串
- 最大 / 最小 / 恰好满足某个条件
- 不需要考虑非连续的组合
真实场景
前端性能监控里常见的滑动时间窗口:统计最近 N 秒内的请求数、错误率,用的就是固定窗口。可变窗口的场景则更像防抖/节流的变体——找出一段连续操作中满足某个条件的最短或最长区间。
固定窗口
窗口大小固定,每次整体向右滑动一格。进一个新元素,出一个旧元素。
代码范式:
// 场景:固定大小为 k 的窗口,统计每个窗口的某个值
// 第一步:初始化第一个窗口
let windowValue = 0;
for (let i = 0; i < k; i++) {
windowValue += arr[i]; // 根据题目换成对应的操作
}
let result = windowValue;
// 第二步:滑动窗口,每次移动一格
let left = 0;
while (left + k < arr.length) {
left++;
windowValue = windowValue - arr[left - 1] // 移出左边的元素
+ arr[left + k - 1]; // 移入右边的新元素
result = Math.max(result, windowValue); // 根据题目更新结果
}
return result;
例题:LC 643 — 子数组最大平均数 I
给你一个整数数组 nums 和整数 k,找出长度为 k 的连续子数组,返回其最大平均数。
输入:nums = [1, 12, -5, -6, 50, 3], k = 4
输出:12.75(子数组 [12, -5, -6, 50] 的平均数)
为什么是固定窗口? 窗口大小固定为 k,每次整体右移,不需要伸缩。
// 环境:浏览器 / Node.js
// LeetCode 643 - Maximum Average Subarray I
// @param {number[]} nums
// @param {number} k
// @return {number}
function findMaxAverage(nums, k) {
// 初始化第一个窗口的和
let sum = 0;
for (let i = 0; i < k; i++) {
sum += nums[i];
}
let maxSum = sum;
// 滑动:每次移出最左元素,移入新的最右元素
let left = 0;
while (left + k < nums.length) {
left++;
sum = sum - nums[left - 1] + nums[left + k - 1];
maxSum = Math.max(maxSum, sum);
}
return maxSum / k; // 返回平均数
}
console.log(findMaxAverage([1, 12, -5, -6, 50, 3], 4)); // 12.75
console.log(findMaxAverage([5], 1)); // 5
执行过程拆解(以 [1,12,-5,-6,50,3], k=4 为例):
初始窗口:[1, 12, -5, -6],sum=2,maxSum=2
left=1:移出 nums[0]=1,移入 nums[4]=50
[12, -5, -6, 50],sum=51,maxSum=51
left=2:移出 nums[1]=12,移入 nums[5]=3
[-5, -6, 50, 3],sum=42,maxSum=51
返回 51 / 4 = 12.75
可变窗口
窗口大小不固定,根据条件动态伸缩。right 负责扩张窗口,left 负责在条件不满足时收缩窗口。
代码范式:
// 场景:找满足某条件的最长 / 最短连续子数组
let left = 0;
let result = 0; // 或 Infinity,取决于找最大还是最小
for (let right = 0; right < arr.length; right++) {
// 1. 扩张:将 arr[right] 纳入窗口
// window.add(arr[right]);
// 2. 收缩:当窗口不满足条件时,从左边移出元素
while (/* 窗口不满足条件 */) {
// window.remove(arr[left]);
left++;
}
// 3. 此时窗口满足条件,更新结果
result = Math.max(result, right - left + 1);
}
return result;
right 每轮必走,left 只在窗口违规时才往前收缩——这个结构和 Vol.1 的 reader/writer 有相似之处:一个负责探索,一个负责维护边界。
例题:LC 3 — 无重复字符的最长子串
给定字符串 s,找出不含重复字符的最长子串的长度。
输入:s = "abcabcbb" → 3("abc")
输入:s = "bbbbb" → 1("b")
输入:s = "pwwkew" → 3("wke")
为什么是可变窗口? 窗口需要在遇到重复字符时收缩,大小不固定。
思路:
- 用
Set记录当前窗口内的字符 right扩张:把新字符加入Set- 遇到重复字符:
left右移,同时从Set里移出left对应的字符,直到窗口内无重复 - 每次更新最大长度
// 环境:浏览器 / Node.js
// LeetCode 3 - Longest Substring Without Repeating Characters
// @param {string} s
// @return {number}
function lengthOfLongestSubstring(s) {
const seen = new Set(); // 记录当前窗口内的字符
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
// 遇到重复字符,收缩左边界直到无重复
while (seen.has(s[right])) {
seen.delete(s[left]);
left++;
}
// 将新字符加入窗口
seen.add(s[right]);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
console.log(lengthOfLongestSubstring("abcabcbb")); // 3
console.log(lengthOfLongestSubstring("bbbbb")); // 1
console.log(lengthOfLongestSubstring("pwwkew")); // 3
console.log(lengthOfLongestSubstring("")); // 0
执行过程拆解(以 "abcabcbb" 为例):
right=0:加入 'a',seen={a},len=1
right=1:加入 'b',seen={a,b},len=2
right=2:加入 'c',seen={a,b,c},len=3,maxLen=3
right=3:'a' 重复!移出 s[0]='a',left=1
seen={b,c},加入 'a',seen={b,c,a},len=3
right=4:'b' 重复!移出 s[1]='b',left=2
seen={c,a},加入 'b',seen={c,a,b},len=3
right=5:'c' 重复!移出 s[2]='c',left=3
seen={a,b},加入 'c',seen={a,b,c},len=3
right=6:'b' 重复!移出 s[3]='a',left=4
移出 s[4]='b',left=5
seen={c},加入 'b',seen={c,b},len=2
right=7:'b' 重复!移出 s[5]='c',left=6
seen={},加入 'b' ... 等等 seen 还有 'b'
移出 s[6]='b',left=7
seen={},加入 'b',seen={b},len=1
返回 maxLen=3
固定窗口 vs 可变窗口
| 固定窗口 | 可变窗口 | |
|---|---|---|
| 窗口大小 | 题目给定,不变 | 根据条件动态伸缩 |
| 左边界移动 | 每次固定移动一格 | 只在条件违规时收缩 |
| 典型题目 | 最大平均子数组、定长子数组 | 最长无重复子串、最小覆盖子串 |
| 识别关键词 | "长度为 k 的子数组" | "最长"、"最短"、"满足条件的子串" |
可变窗口的核心问题是:什么时候收缩?收缩到什么程度? 这由具体的"窗口合法条件"决定,不同题目换的就是这个判断逻辑。
四、识别流程(更新版)
在 Vol.1 的判断树基础上,加入这两个新模式:
输入是什么结构?
│
├── 两个有序数组
│ └── → 分离双指针
│
└── 一个数组 / 字符串
│
├── 有序 + O(log n) 要求
│ └── → 二分法
│ ├── 找精确位置 → return -1 / return mid
│ └── 找插入位置 → return left
│
├── 连续子数组 / 子字符串 + 最大最小或满足条件
│ └── → 滑动窗口
│ ├── 长度固定 → 固定窗口
│ └── 长度可变 → 可变窗口
│
├── 有序 + 找两个满足条件的数
│ └── → 对撞指针
│
└── 原地修改 / 过滤 / 链表
└── → 快慢指针(reader/writer)
小结
这篇是我目前练完这些题之后的理解,记几个对我帮助最大的认知:
二分法:代码骨架是固定的,变的只有最后一行。return -1 和 return left 不是随意选的,背后是 while 结束后 left 的语义——它收敛到了 target 应该所在的位置。理解这个之后,两道看起来不同的题在我脑子里合并成了同一道题。
滑动窗口:固定窗口比较直觉,难的是可变窗口——难点不在代码结构,而在"窗口合法条件"的定义。right 扩张、left 收缩的框架是固定的,每道新题需要想清楚的是:什么叫"窗口违规",违规了要移出什么。
还有一个隐约的感受:Vol.1 的 reader/writer 模型和可变窗口的 right/left 结构有相似之处——都是一个指针负责探索、另一个负责维护边界。可能背后有更统一的思维模型,等练到更多题型再来验证。
还没解锁的区域(Vol.3 待续)
- 链表快慢指针:走1步 vs 走2步,检测环(LC 141)
- 对撞指针进阶:盛最多水的容器(LC 11)
- 滑动窗口进阶:最小覆盖子串(LC 76),可变窗口 + 字符频率统计