算法学习笔记 Vol.2:二分法 + 滑动窗口

0 阅读12分钟

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 > 4right=12轮:mid=0, nums[0]=1 < 4left=13轮:mid=1, nums[1]=3 < 4left=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。此时搜索范围已经收缩为空——leftright 错位了。

关键在于:left 此时指向的位置,正好是 target 应该被插入的地方。

target=2, nums=[1,3,5,6] 来验证:

1轮:mid=1, nums[1]=3 > 2right=02轮:mid=0, nums[0]=1 < 2left=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 应该插在末尾 ✓

所以 leftwhile 结束时不是随机值,它有确定的语义:target 应该所在的位置

变体表格总结:

场景最后一行原因
找到返回下标,找不到返回"不存在"return -1明确表示目标不在数组中
找到返回下标,找不到返回插入位置return leftleft 收敛到 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 -1return left 不是随意选的,背后是 while 结束后 left 的语义——它收敛到了 target 应该所在的位置。理解这个之后,两道看起来不同的题在我脑子里合并成了同一道题。

滑动窗口:固定窗口比较直觉,难的是可变窗口——难点不在代码结构,而在"窗口合法条件"的定义。right 扩张、left 收缩的框架是固定的,每道新题需要想清楚的是:什么叫"窗口违规",违规了要移出什么。

还有一个隐约的感受:Vol.1 的 reader/writer 模型和可变窗口的 right/left 结构有相似之处——都是一个指针负责探索、另一个负责维护边界。可能背后有更统一的思维模型,等练到更多题型再来验证。


还没解锁的区域(Vol.3 待续)

  • 链表快慢指针:走1步 vs 走2步,检测环(LC 141)
  • 对撞指针进阶:盛最多水的容器(LC 11)
  • 滑动窗口进阶:最小覆盖子串(LC 76),可变窗口 + 字符频率统计

参考资料