当前:LC3 · 重复字符追捕 · 安检门滑窗 · 首次出现于 Day 5 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC3 · Longest Substring Without Repeating Characters · 滑窗

无重复字符的最长子串:窗口吞新,遇重则跳

右边界贪心地扩张,左边界只在重复时行动——而且一跳就跳过重复点,绝不一格一格地退。

维护窗口 [left, right] 与 lastSeen(字符 → 最近下标)。right 不断右移扩张;若 s[right] 在 lastSeen 中且其下标 ≥ left,说明窗口内重复,把 left 跳到 old+1。更新 lastSeen[s[right]] = right,并比较 best 与 right−left+1。每个字符至多进出窗口一次,O(n)。

时间 O(n)空间 O(min(n, 字符集))结论先行 · 全文约 6 节
导读

给定字符串 s,找出其中不含重复字符的最长子串的长度。

示例:s = "abcabcbb" → 3("abc" 或 "bca"),s = "bbbbb" → 1。

子串是连续的。暴力枚举所有子串是 O(n²),滑窗利用「最长」的单调性把它压到 O(n)。

这篇文章先从暴力为什么慢讲起,再建立一个不变量(窗口永远无重复),最后看左边界如何用一次跳跃而不是一格一格收缩来保持最优。

先看清「子串」这个约束

要找的是子串——必须连续。"abc" 是 "abcabcbb" 的子串,但 "aab" 不是(a 和 b 不相邻)。

无重复字符,指窗口内每个字符最多出现一次。"abc" 合法,"abca" 不合法(a 出现两次)。

这两个约束叠加起来,就排除了「用集合乱选字符」的直觉:你必须在数组里圈出一段连续区间,并保证区间内无重复。

于是问题变成:在所有「无重复」的连续子串里,找最长的一个。这天然指向「维护一个滑动窗口」。

暴力为什么 O(n²) 且浪费

最直接的想法:枚举所有左端点,对每个左端点向右扩展,直到遇到重复字符为止,记录最长长度。

这要做 O(n²) 次「检查是否重复」的判断。更糟的是,大部分重复检查都是白做——比如已确定以 0 开头最长到 2,下一步换 left=1 时,又要从 1 重新扫一遍 1、2、3……前面的结果完全没有被复用。

这种「每次换起点都从头扫」的浪费,正是滑动窗口要消灭的。

浪费暴力换一个起点就重新扫一遍。滑窗的哲学是:右指针永远只前进,绝不回头。

建立一个不变量:窗口永远无重复

定义两个指针:left 和 right,窗口就是 [left, right]。我们维护的承诺是:在每次循环开始时,窗口内没有重复字符。

让 right 从 0 向右扫。每次把 s[right] 纳入窗口之前,先检查它是否已在窗口内出现过。

如果没有,直接纳入,窗口仍然无重复,这是「扩张」。如果有,就必须先收缩 left——直到把那个重复字符赶出窗口。

有了这个不变量,「最长」就落在每次扩张后对窗口长度的比较上:扩张只会让窗口变长,收缩则不会。

不变量循环不变量:窗口 [left, right] 始终无重复。每次迭代先保证它,再比较长度。
右边界扩张:窗口吞下不重复的字符,best 随之更新
abcabcbb
LR
left=0 right=0窗口 "a"最长 = 1
right=0:'a' 未重复,窗口 [0,0]="a",best=1

遇到重复:left 直接跳,不倒退

现在 right 走到下标 3,字符是 'a'。lastSeen 显示 'a' 上次出现在下标 0,而 0 ≥ left(0),所以窗口 [0,3] 内有两个 'a'——重复了。

关键决策:left 应该怎么收缩?可以一格一格往右挪,每挪一格检查一次,直到把第一个 'a' 赶出去(left 到 1)。

但观察:任何以 left ≤ 0 开头、右端 ≥ 3 的子串都必然包含两个 'a',长度不可能超过当前 best。既然已经知道重复点在哪,直接让 left = 旧位置 + 1 = 1 即可,一次到位。

这就是「跳跃」:left 不倒退、不逐格试探,而是根据 lastSeen 里的精确位置,一次跳过重复点。窗口没有重复之后,继续扩张。

一次到位left = lastSeen[s[right]] + 1。它跳过的是「那个重复字符的下一个位置」,一步到位,不逐格退让。
s="abcabcbb":right=3 遇到重复 'a',left 从 0 跳到 1
abcabcbb
LR
left=0 right=3窗口 "abca"重复点 ↑
right=3:'a' 的上次位置是 0,且 0 ≥ left(0) → 重复

为什么 right 永远不回退

滑窗成立的前提是一条单调性:以 left 开头的最长无重复子串,其右端点一定随 left 增大而单调不减。

直观理解:left 右移只是把窗口左边砍掉一段,被砍掉的部分不可能阻碍右侧扩张——原本无重复的更长区间,砍掉开头后依然无重复。

所以 right 只需要前进,永远不用回头。每一步 right 前进、必要时 left 跳跃,然后记录 best = max(best, right − left + 1)。

这样每个字符至多被 right 加入一次、被 left 移出一次,总操作 O(n)——比暴力少了一个数量级的重复检查。

单调性「以 left 开头的最优右端点」随 left 单调不减。这是滑窗可以只用一次遍历的根据。
滑到 'b' 处:'b' 上次在 1,left 跳过它,best 保持 3
abcabcbb
LR
left=3 right=6窗口 "abcb"重复点 ↑
right=6 的 'b':上次位置 1 < left(3) → 不在窗口内,不跳跃

复杂度与易错点

时间 O(n):right 遍历一次,left 至多也移动 n 次(因为 left 只增不减)。

空间 O(min(n, 字符集)):lastSeen 最多存字符集大小的键。固定字符集(如 ASCII 256 或小写字母 26)时空间是常数。

易错点一:跳跃条件必须写成 lastSeen[s[right]] ≥ left,而不只是「存在」——如果旧位置在窗口外,不需要跳。

易错点二:每次循环都要更新 lastSeen[s[right]] = right,无论是否跳跃。漏了更新,下次会用到过期位置。

易错点三:best 的比较发生在跳跃之后,用新的 right − left + 1 比较,不要用旧的窗口长度。

面试表达先讲暴力浪费,再立「窗口无重复」不变量,最后用「遇到重复直接跳」给出 O(n)。三步走完,考官无话可说。

八行 Go:滑窗 + lastSeen

solution.goGo
func lengthOfLongestSubstring(s string) int {
last := [256]int{}
for i := range last { last[i] = -1 }
left, best := 0, 0
for right := 0; right < len(s); right++ {
if last[s[right]] >= left { left = last[s[right]] + 1 }
last[s[right]] = right
if right-left+1 > best { best = right - left + 1 }
}
return best
}

1last 记录每个字符最近出现的下标,-1 表示从没出现过。

2left 是窗口左边界,best 是答案。

3右指针遍历整个字符串。

4若字符上次位置在窗口内(≥ left),把 left 跳到它后面。

5无论是否跳跃,都要更新该字符的最新位置。

6用新窗口长度更新 best。

总结

右指针只进不退,遇到重复 left 一步跳过重复点,best 全程取最大窗口。

  • 不变量:窗口 [left, right] 始终无重复字符。
  • 跳跃条件 lastSeen[s[right]] ≥ left:只跳窗口内的重复,窗口外的旧位置不影响。
  • 每次循环必更新 lastSeen,比较 best 在跳跃之后。
  • 单调性保证 right 不回退,因此严格 O(n)。
同族题目
LC76最小覆盖子串(双指针计数)LC438找到字符串中所有字母异位词LC159至多包含两个不同字符的最长子串