登录
OAmaster
滑动窗口与双指针

不定长滑动窗口(求最长)

把"定长滑窗"升级到"while 收缩"通用模板,统一拿下 LC 3 / 424 / 340 / 904

Medium预计阅读 30 分钟更新于 2026-05-17

1. 核心原理

定长滑窗的"窗口大小恒为 k"是一种强约束:每次右端进 1 个、左端必出 1 个,节奏机械。但北美面试更高频的形态是这样:

"给定数组/字符串,求满足某个条件最长连续子区间长度。"

条件可能是"无重复字符"(LC 3)、"最多 k 个不同字符"(LC 340)、"窗口内 0 的个数 ≤ k"(LC 1004)、"替换至多 k 次后全相同"(LC 424)。这些题目窗口长度不再固定——它能多长,取决于条件什么时候被破坏。

从定长升级到不定长的关键

只需要换一个动作:把"左端必定弹出"换成"左端条件性收缩"。三段式模板从

in → out (固定) → update-ans

升级为

in → while 不满足条件: shrink → update-ans

关键洞察一句话:右指针稳步前进一格不回头,左指针在条件被破坏时才一步一步前进直到条件重新满足。两个指针都只朝右走,每个元素至多进窗口 1 次、出窗口 1 次,所以总操作数是 $O(n)$(均摊证明见第 5 节)。

与定长的对比

维度定长滑窗不定长滑窗(求最长)
窗口大小恒为 k动态变化
左指针推进时机每步固定推进 1 格只在条件被破坏时收缩
out 逻辑if i >= k: 一次性弹 1 个while not_ok(): 可能弹多个
答案更新窗口满 k 时更新每轮 while 收敛后都更新
复杂度$O(n)$$O(n)$(均摊)

北美面试切入点

LC 3 (Longest Substring Without Repeating Characters) 是这套模板的母题,LC 340 → LC 424 → LC 904 等都是它的小幅变形。

把这套"通用三段式"记熟,整张表上的 4 道题(LC 3 / 1004 / 340 / 424 / 904)都可以用同一个骨架写完,只换 add / remove / not_satisfied 三个钩子。

2. 通用模板

把脑子里的伪代码先固化下来,所有不定长求最长的题都是它的变体:

l = 0
for r, x in enumerate(s):
    add(x)                    # 1) in:        右端纳入新元素
    while not_satisfied():    # 2) shrink:    条件破坏则收缩左端
        remove(s[l])
        l += 1
    ans = max(ans, r - l + 1) # 3) update:    每轮收敛后更新最长长度

三段对应的钩子在不同题里的具体化:

  • add / remove:通常是 set.add / set.discardcnt[x] += 1 / cnt[x] -= 1
  • not_satisfied:LC 3 是 s[r] 在 set 中已经出现,LC 340 是 len(cnt) > k,LC 1004 是 窗口内 0 的个数 > k
  • r - l + 1:当前窗口长度,因为这是"求最长",所以每轮 while 退出后都要更新一次

3. ASCII 图解

以 LC 3 在字符串 "abcabcbb" 上为例,演示窗口如何"先扩张、再收缩、再扩张"。set 维护窗口内字符集合,r 每步前进 1,l 仅在新字符已出现时收缩:

Step 1: [a] b   c   a   b   c   b   b      set={a},       ans=1
         l                                  (in: +a)
         r

Step 2: [a   b] c   a   b   c   b   b      set={a,b},     ans=2
         l   r                              (in: +b)

Step 3: [a   b   c] a   b   c   b   b      set={a,b,c},   ans=3
         l       r                          (in: +c)

Step 4: 不满足: s[r]='a' 已在 set 中, 收缩左端
         a  [b   c   a] b   c   b   b      set={b,c,a},   ans=3
             l       r                      (out: -a, in: +a)

Step 5: 不满足: s[r]='b' 已在 set 中, 继续收缩
         a   b  [c   a   b] c   b   b      set={c,a,b},   ans=3
                 l       r                  (out: -b, in: +b)

Step 6: 不满足: s[r]='c' 已在 set 中
         a   b   c  [a   b   c] b   b      set={a,b,c},   ans=3
                     l       r              (out: -c, in: +c)

Step 7: 不满足: s[r]='b' 已在 set 中, 连续收缩两次直到弹出旧 b
         a   b   c   a   b  [c   b] b      set={c,b},     ans=3
                             l   r          (out: -a,-b, in: +b)

Step 8: 不满足: s[r]='b' 已在 set 中, 再次收缩
         a   b   c   a   b   c   b [b]     set={b},       ans=3
                                     l
                                     r      (out: -c,-b, in: +b)

注意三个关键现象:

  • Step 3 → Step 4:右指针碰到重复字符 a 时,左指针不是跳到 a 后面,而是逐个 remove 直到 a 被踢出。这种"一步一步"的方式是均摊 $O(n)$ 的关键
  • Step 7:一次 while 可能触发多次 shrink(连续弹了两个字符),不是只弹 1 个
  • 最终 ans = 3,出现在多个窗口(abc / bca / cab / abc),第一次达到是 Step 3

不定长滑动窗口(求最长)

LeetCode 3 · 无重复字符的最长子串

右指针进
01234567
a
b
c
a
b
c
b
b
l,r
右指针进窗口:新字符 'a' 不冲突,加入 set
窗口: [0, 0]"a"
set:{a}
ans = 0
step 01/23

4. 母题精讲

LeetCode 3. Longest Substring Without Repeating Characters

题目(中文翻译):给定字符串 s,找出其中不含重复字符最长子串的长度。

样例 1

输入:  s = "abcabcbb"
输出:  3
解释:  最长无重复子串是 "abc", 长度为 3.

样例 2

输入:  s = "bbbbb"
输出:  1
解释:  最长无重复子串是 "b", 长度为 1.

思路推导

为什么用 hash set? 我们需要 $O(1)$ 时间判断"窗口里是否已经有了 s[r]",以及 $O(1)$ 时间增删元素。Python 的 set 平均 $O(1)$,是天然选择。如果改用 listin 是 $O(n)$,整体退化到 $O(n^2)$。

为什么左指针只前进不回退? 这是滑动窗口能均摊 $O(n)$ 的核心性质。直觉证明:当我们因为 s[r] 重复而收缩 l 时,"重复"这件事只可能发生在 s[r] 与窗口里某个旧字符冲突。一旦把旧字符弹出,l 之前的位置就永远不可能比当前位置更好——因为更左的起点只会让窗口包含更多字符、更容易出现重复。所以左指针单调右移,不需要"回头看"。

均摊 $O(n)$:右指针走 $n$ 步,左指针在最坏情况下也只走 $n$ 步(永远跟不上右指针)。每个字符最多入 set 1 次、出 set 1 次,总操作数 $\leq 2n$。

Python 工业级实现

from typing import Set

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        # window 是窗口内出现过的字符集合, set 提供 O(1) 增删查
        window: Set[str] = set()

        l = 0    # 左指针, 窗口左边界 (含)
        ans = 0  # 历史最长长度

        for r, ch in enumerate(s):
            # ---------- 2) shrink: 在 add 之前先把重复挤出去 ----------
            # 注意是 while 不是 if: 理论上 ch 在窗口里只会出现 1 次,
            # 所以这里 while 最多执行 1 次完整循环就会退出.
            # 用 while 写法的好处是与"最多 k 个不同字符"等题型完全同构.
            while ch in window:
                window.remove(s[l])
                l += 1

            # ---------- 1) in: 右端纳入新字符 ----------
            window.add(ch)

            # ---------- 3) update-ans: 当前窗口已合法, 更新最长 ----------
            # r - l + 1 是当前窗口长度 (闭区间 [l, r])
            cur_len = r - l + 1
            if cur_len > ans:
                ans = cur_len

        return ans

C++ 备份实现

#include <unordered_set>
#include <string>
using namespace std;

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_set<char> window;
        int l = 0, ans = 0;

        for (int r = 0; r < (int)s.size(); ++r) {
            char ch = s[r];
            // shrink: 把重复字符挤出去
            while (window.count(ch)) {
                window.erase(s[l]);
                ++l;
            }
            // in
            window.insert(ch);
            // update-ans
            ans = max(ans, r - l + 1);
        }
        return ans;
    }
};

代码逐段讲解

变量含义

  • window:滚动维护的"当前窗口内出现过的字符集合",是这道题的核心状态
  • l:闭区间左边界。s[l .. r] 是当前窗口
  • ans:从初始 0 开始单调不减地记录历史最长

循环不变量:每轮迭代结束时,window == set(s[l .. r]) 且窗口内无重复。这是 update-ans 那一行能直接信任 r - l + 1 的前提。

为什么 while 不是 if 对于 LC 3 而言,if 确实够用——同一个字符只能重复一次,shrink 一次就够。但写成 while 是为了让模板可迁移:到了 LC 340(最多 k 个不同字符)、LC 1004(窗口内 0 的个数 ≤ k),shrink 一次可能不够,需要持续弹直到条件再次满足。统一用 while 写,跨题型零成本。

为什么 shrink 写在 add 之前? 两种写法都对,但顺序不同:

  • 本文写法(shrink 先 / add 后):进入循环时 window 是"加入 ch 之前的快照",判断 ch in window 直接对应"是否会产生重复"
  • 另一种(add 先 / shrink 后):进入循环后立即 add(ch),shrink 条件改成 cnt[ch] > 1,需要把 set 升级为 Counter

前一种更简洁,本章统一沿用。

边界 l = 0r - l + 1:当 l = r = 0 时窗口长度 = 1,对应单字符场景。空串时 for 循环根本不执行,ans 保持 0,行为正确。

5. 复杂度分析

  • 时间复杂度:$O(n)$ — 均摊证明:右指针 r 从 0 走到 n-1 共 n 步;左指针 l 全程单调右移,最坏也只走 n 步。每个字符最多 set.add 一次、set.remove 一次,每次操作平均 $O(1)$,总操作数 $\leq 2n + O(n)$ = $O(n)$
  • 空间复杂度:$O(\min(n, |\Sigma|))$ — window 大小上界是字符集大小 $|\Sigma|$ 与字符串长度 n 的较小值。ASCII 时 $|\Sigma| = 128$,扩展 ASCII $|\Sigma| = 256$,Unicode 理论上 $|\Sigma| = 2^21$ 但实际远小于 n
  • 暴力 $O(n^2 \cdot k)$ vs. 滑窗 $O(n)$ 的真实差距:暴力枚举每个起点 i(n 种),再枚举每个终点 j(n 种),再判断 s[i..j] 是否含重复(k 次扫描),是 $O(n^3)$。即便用 set 判重压到 $O(n^2)$,当 $n = 5 \times 10^4$ 时也要 $2.5 \times 10^9$ 次操作,LeetCode 直接 TLE。滑窗只要 $5 \times 10^4$ 次

类似的去重 / 不重复段统计在流式数据处理里也很常见,O(n) 与 O(n²) 的差距决定能否在线处理。

6. 边界条件拆解

(1) 空串 / 单字符串s = "" 时 n = 0,for 循环根本不执行,ans 保持初值 0——这是正确答案。s = "a" 时只走一次循环,window = {a}l = 0r = 0ans = 1,也正确。不需要任何特判,模板天然处理这两个 corner case。

(2) 全相同字符 "aaaa":第一次循环 window = {a}ans = 1;第二次进入时 ch = 'a' 已在 set 中,shrink 弹出 s[0]='a'l = 1window = {},然后 add 进新 aans = max(1, 1) = 1。后续每一步都是这个模式,最终 ans = 1说明 while 的存在让"每一步都收敛"成立,即使在退化输入上也不会越界

(3) Unicode(汉字 / emoji):Python 的 str 默认按 Unicode 码点遍历,set 也直接支持任意 str。例如 s = "你好你":迭代时 ch 依次是 '你' / '好' / '你',第三步检测到 '你' in window,shrink 弹出前两个字符。关键提醒:不要为了优化而用 [0] * 128 这种 ASCII 数组,否则汉字会越界。emoji 更危险,单个 emoji 可能由多个 code point(surrogate pair / ZWJ 序列)组成,如果题目允许 emoji,建议先 import unicodedata 做规范化。

(4) 巨长 ASCII 字符串的常数优化:当题目保证仅 ASCII(如 LeetCode 3 的 constraint "consists of English letters, digits, symbols and spaces"),可以把 set 换成 last = [-1] * 128(记录每个字符上次出现的位置),用下标查找代替 hash 查找,常数大约提速 2-3 倍。代码示例:

def lengthOfLongestSubstring(self, s: str) -> int:
    last = [-1] * 128   # 仅 ASCII 时的常数优化
    l, ans = 0, 0
    for r, ch in enumerate(s):
        code = ord(ch)
        if last[code] >= l:
            l = last[code] + 1   # 直接跳到旧位置后面
        last[code] = r
        ans = max(ans, r - l + 1)
    return ans

这是工业代码里更常见的写法(直接跳跃 l,跳过逐个 remove),但面试时建议先写 set 版本展示思路,再口头提到这个优化——分两步走更容易拿到信号。

7. 通用模板

下面这 8 行骨架覆盖本章所有"求最长"题:

# 不定长滑动窗口 (求最长) 三段式模板
l, ans = 0, 0
for r, x in enumerate(arr):
    add(x)                          # 1) in:     右端纳入
    while not_satisfied():          # 2) shrink: 条件破坏则收缩左端
        remove(arr[l])
        l += 1
    ans = max(ans, r - l + 1)       # 3) update: 每轮收敛后更新最长
return ans

其中:

  • add(x) / remove(x):通常是 set.add/discardcnt[x] += 1 / -= 1
  • not_satisfied():题目条件的"破坏判定",如 x in window / len(cnt) > k / zeros > k

8. 热身题:LC 1004 Max Consecutive Ones III

题目:给定 0/1 数组 nums 和整数 k,可以把至多 k 个 0 翻成 1,求翻完后最长连续 1 的长度。

关键洞察:把题面翻译成滑窗约束——"窗口内 0 的个数 ≤ k" 时,整个窗口都能变成全 1。问题立刻变成"求满足窗口内 0 的个数 ≤ k 的最长窗口长度",直接套模板:

from typing import List

class Solution:
    def longestOnes(self, nums: List[int], k: int) -> int:
        l, zeros, ans = 0, 0, 0
        for r, x in enumerate(nums):
            if x == 0:
                zeros += 1                  # in: 0 的计数 +1
            while zeros > k:                # shrink: 0 太多了就收缩
                if nums[l] == 0:
                    zeros -= 1
                l += 1
            ans = max(ans, r - l + 1)       # update
        return ans

5 行讲解:

  • 不需要 set / hashmap,只需一个标量 zeros 计窗口里 0 的个数
  • shrink 条件是 zeros > k——只有 0 超额时才需要收缩
  • 收缩时若弹出的是 0,zeros -= 1;若弹出的是 1,不影响 zeros
  • update 阶段所有窗口都已合法(zeros ≤ k),直接取 r - l + 1
  • 这就是"把现实约束翻译到滑窗约束"的能力——后面的 LC 424 / 340 / 904 都是同一招
Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新

On this page