【算法图解】滑动窗口:双指针的艺术

滑动窗口把“枚举所有子数组”压缩成线性扫描,是字符串与数组题的高频解法。本文讲清窗口不变量、何时扩张/收缩、与前缀和的区别,并用最小覆盖子串、无重复最长子串等题型做模板化总结。

18 分钟阅读
小明

【算法图解】滑动窗口:双指针的艺术

滑动窗口(Sliding Window)是很多题的“降维打击”:

  • 本来要枚举所有子数组/子串:$O(n^2)$
  • 用窗口维护一个区间:一趟扫描就搞定:$O(n)$

关键不在“双指针”,在窗口不变量

你维护的窗口始终满足某个条件,一旦不满足就收缩。


1. 滑动窗口适用的典型条件

它适合“连续区间”的问题:

  • 最长/最短满足条件的子串
  • 统计满足条件的子数组数量
  • 固定长度窗口的最大/最小值(常配单调队列)

不适合:

  • 子序列(不连续)
  • 需要全局组合枚举(通常是回溯/DP)

2. 通用模板:扩张右边界,必要时收缩左边界

function slidingWindow(s) {
  let left = 0
  const window = new Map()

  for (let right = 0; right < s.length; right++) {
    const ch = s[right]
    window.set(ch, (window.get(ch) ?? 0) + 1)

    while (/* 窗口不满足条件 */) {
      const d = s[left]
      window.set(d, window.get(d) - 1)
      if (window.get(d) === 0) window.delete(d)
      left++
    }

    // 此时窗口满足条件,可更新答案
  }
}

你要做的就是定义:

  • 什么时候窗口满足
  • 什么时候不满足

3. 例题:无重复字符的最长子串

目标:窗口内所有字符都不重复。

当加入新字符导致重复,就收缩左边界直到不重复。

function lengthOfLongestSubstring(s) {
  let left = 0
  let ans = 0
  const count = new Map()

  for (let right = 0; right < s.length; right++) {
    const ch = s[right]
    count.set(ch, (count.get(ch) ?? 0) + 1)

    while (count.get(ch) > 1) {
      const d = s[left]
      count.set(d, count.get(d) - 1)
      if (count.get(d) === 0) count.delete(d)
      left++
    }

    ans = Math.max(ans, right - left + 1)
  }

  return ans
}

4. 例题:最小覆盖子串(模板升级)

这类题是滑动窗口“进阶版”:

  • 你不仅要“不重复”,还要“包含某个需求集合”

核心思路:

  • need:目标字符计数
  • window:当前窗口计数
  • valid:满足要求的字符种类数

valid === need.size 时,说明窗口已经覆盖需求,此时尝试收缩求最小。

(代码略长,这里先讲框架;需要的话我可以补完整实现。)


5. 与前缀和的区别

  • 前缀和擅长“可加性”与区间和查询
  • 滑动窗口擅长“单调可维护”的条件(例如字符频次、distinct 数)

很多题两者都能做,但窗口更像“在线维护”。


6. 面试速答模板

  • 我用滑动窗口维护 left, right 区间。
  • 右指针扩张加入元素,更新窗口状态。
  • 一旦窗口不满足条件,就移动 left 收缩直到满足。
  • 过程中记录最优答案,整体每个指针最多移动 n 次,所以是 O(n)。

总结

  • 滑动窗口的核心是窗口不变量。
  • 右扩张、左收缩,整体线性。
  • 高频题:最长无重复、最小覆盖、固定窗口最大值(配单调队列)。