无重复字符的最长子串:窗口吞新,遇重则跳
右边界贪心地扩张,左边界只在重复时行动——而且一跳就跳过重复点,绝不一格一格地退。
维护窗口 [left, right] 与 lastSeen(字符 → 最近下标)。right 不断右移扩张;若 s[right] 在 lastSeen 中且其下标 ≥ left,说明窗口内重复,把 left 跳到 old+1。更新 lastSeen[s[right]] = right,并比较 best 与 right−left+1。每个字符至多进出窗口一次,O(n)。
给定字符串 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] 始终无重复。每次迭代先保证它,再比较长度。
遇到重复: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。它跳过的是「那个重复字符的下一个位置」,一步到位,不逐格退让。
为什么 right 永远不回退
滑窗成立的前提是一条单调性:以 left 开头的最长无重复子串,其右端点一定随 left 增大而单调不减。
直观理解:left 右移只是把窗口左边砍掉一段,被砍掉的部分不可能阻碍右侧扩张——原本无重复的更长区间,砍掉开头后依然无重复。
所以 right 只需要前进,永远不用回头。每一步 right 前进、必要时 left 跳跃,然后记录 best = max(best, right − left + 1)。
这样每个字符至多被 right 加入一次、被 left 移出一次,总操作 O(n)——比暴力少了一个数量级的重复检查。
单调性「以 left 开头的最优右端点」随 left 单调不减。这是滑窗可以只用一次遍历的根据。
复杂度与易错点
时间 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
func lengthOfLongestSubstring(s string) int {last := [256]int{}for i := range last { last[i] = -1 }left, best := 0, 0for right := 0; right < len(s); right++ {if last[s[right]] >= left { left = last[s[right]] + 1 }last[s[right]] = rightif 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)。