LeetCode 3. 无重复字符的最长子串:用不变量把 O(n²) 压到 O(n)

「求最长无重复子串」大概是面试出场率最高的字符串题之一:它短到十分钟可以写完,又深到能连续追问五六层——为什么内层循环不影响复杂度、哈希表该记下标还是记计数、abba 为什么会让一种流行写法悄悄出错。这道题真正的价值不在答案本身,而在它第一次把「滑动窗口 + 不变量」这套思维完整地摆在面前:先承认暴力在重复劳动,再让两个指针各自单调前进,用一条恒成立的性质换取线性复杂度。本文所有代码都已在本地用题面用例、边界用例与 500 轮随机差分(以暴力为基准)验证通过,n 取 10^5 时毫秒级出结果。

题目回顾

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

约束条件:0 <= s.length <= 10^5,s 由英文字母、数字、符号和空格组成。这两条都值得多看一眼。第一,n 可以为 0,空串是合法输入,答案为 0——用「先取 s[0] 再进循环」起手的写法在这里会直接抛异常。第二,n 最大到 10^5,任何 O(n²) 以上的做法都要掂量一下:10^5 的平方是 10^10,量级上就已经出局,题目在暗示你找线性做法。第三,「字母、数字、符号和空格」意味着字符集是 ASCII 可打印字符,规模不超过百级——这给空间复杂度定了上界,后文复杂度分析会用到。

示例有三个,信息量各有侧重:

  • s = "abcabcbb" 输出 3,最长无重复子串是 "abc";注意 "bca"、"cab" 也都是合法答案——题目只要长度,不要求还原具体子串。
  • s = "bbbbb" 输出 1,全相同字符把窗口压到最短。
  • s = "pwwkew" 输出 3,最长是 "wke";题面特意强调 "pwke" 是子序列而不是子串——子串必须连续,跳过中间的 w 就不合法了。这是审题层面的第一大陷阱。

暴力解法与它的瓶颈

最直接的想法:全枚举

把所有子串枚举一遍,逐个检查是否含重复字符,取无重复者的最大长度:

// 暴力:枚举每个子串,逐个检查是否无重复 —— O(n^3)
function lengthOfLongestSubstringBrute(s) {
  const n = s.length;
  let ans = 0;
  for (let i = 0; i < n; i++) {
    for (let j = i; j < n; j++) {
      const seen = new Set();
      let ok = true;
      for (let k = i; k <= j; k++) {
        if (seen.has(s[k])) { ok = false; break; }
        seen.add(s[k]);
      }
      if (ok) ans = Math.max(ans, j - i + 1);
    }
  }
  return ans;
}

外层两重循环枚举起点 i 与终点 j,子串共有 n(n+1)/2 个,即 O(n²) 个;每个子串用哈希集合查重需要 O(n)。总代价 O(n²·n),也就是 O(n³)。代入 n = 10^5,操作数约 10^15——就算每秒做十亿次操作也要跑上百小时。这个解法在 LeetCode 上连中等规模的用例都过不去,但它是最好的起点:所有优化都始于看清暴力到底在重复什么。

重复劳动在哪

盯住相邻两次内层检查:刚查完 s[i..j] 无重复,转脸查 s[i..j+1] 时又从 s[i] 数起——可前一次的结论明明还在手里。同理,起点换成 i+1 后,s[i+1..j] 的查重信息也早就隐含在上一步里。暴力把 O(n²) 个子串当成彼此无关的问题各算一遍,而事实上相邻子串之间只差一个字符。把「每步只多一个字符」利用起来,就是滑动窗口的全部动机。

顺着这个思路还能得到一个中间版本:固定起点 i,让 j 一路右移直到撞上第一个重复字符才停下,途中带着集合增量查重。最坏情况(比如全不同)仍是 O(n²),但已经能通过本题——因为字符集不超过百级,j 至多右移百余步就会撞车。这个「够用但不漂亮」的版本是面试官追问「还能再快吗」时你自己应该主动越过的一站。

滑动窗口:一个不变量压到 O(n)

把「查重」变成「维护」

既然相邻子串只差一个字符,就让右指针 right 每次只前进一格,把新字符 s[right] 增量放进一个集合;一旦发现它已经在窗口里,就把左端字符逐个移出集合、left 逐格右移,直到冲突解除。全程维护一条不变量:集合里的字符,恰好是当前窗口 s[left..right] 里的字符,且互不重复。窗口合法时随手用 right - left + 1 更新答案。

// 滑动窗口 + 集合:右端扩张,遇重复逐格收缩左端 —— O(n)
function lengthOfLongestSubstringSet(s) {
  const window = new Set();
  let left = 0, ans = 0;
  for (let right = 0; right < s.length; right++) {
    const c = s[right];
    while (window.has(c)) {
      window.delete(s[left]);
      left++;
    }
    window.add(c);
    ans = Math.max(ans, right - left + 1);
  }
  return ans;
}

为什么不漏解

正确性论证只有两句话。第一,对每个 right,代码停下来时 left 恰好是「以 right 结尾的无重复子串」的最小起点——while 循环只在窗口非法时才收缩,一旦合法立即停止,所以从不多缩;于是 right - left + 1 正是「以 right 结尾」这一类答案里最长的那个。第二,任何一个最优子串总有某个右端点 right*,它在「以 right* 结尾」这一类里不会漏。两类合起来,全局最优必然被 ans 捕获。

为什么是 O(n)

直觉上两层循环嵌套该是 O(n²),但这里不是:left 与 right 各自只增不减,right 从 0 走到 n-1 共 n 步,left 每次收缩累积也不会超过 n 步——内层 while 的总执行次数摊到整个运行过程里是 n 次。两个指针合计至多 2n 次单步移动,总复杂度 O(n),这就是双指针的摊还分析。换个角度看也一样:窗口内无重复,而字符集不超过百级,窗口长度被 Σ 封了顶,每个字符至多进窗一次、出窗一次。空间上,集合最多装 min(n, Σ) 个字符,即 O(min(n, Σ))。

至此四种解法可以放在一张表里了:

解法 时间 空间 核心思想
全枚举逐一检查 O(n³) O(min(n, Σ)) 枚举 O(n²) 个子串,各查重 O(n)
起点延伸法 O(n·Σ) O(min(n, Σ)) 固定起点右移,撞到重复即停
窗口 + 集合 O(n) O(min(n, Σ)) 不变量:窗口内字符互不重复
窗口 + 记下标 O(n) O(min(n, Σ)) left 直接跳到重复字符的下一格
窗口 + 记计数 O(n) O(min(n, Σ)) 违反不变量就收缩,直到恢复

两种窗口收缩写法:记下标与记计数

集合版要收缩多少格,取决于重复字符离左端多远,最坏一趟要把窗口排空。哈希表能把这件事做得更聪明——把「重复字符在哪」直接记下来,就衍生出两种经典写法。

写法一:哈希表记下标,left 跳跃

让哈希表记录每个字符最近一次出现的下标。当新字符 c 与窗口内某字符冲突时,冲突对象只可能是 c 自己,而它的上一个位置一查便知,于是 left 不必逐格挪,直接跳到「上一位置加一」:

// 滑动窗口 + 哈希表记下标:左端直接跳到重复字符的下一格 —— O(n)
function lengthOfLongestSubstringIndex(s) {
  const lastIndex = new Map(); // 字符 -> 最近一次出现的下标
  let left = 0, ans = 0;
  for (let right = 0; right < s.length; right++) {
    const c = s[right];
    if (lastIndex.has(c) && lastIndex.get(c) >= left) {
      left = lastIndex.get(c) + 1;
    }
    lastIndex.set(c, right);
    ans = Math.max(ans, right - left + 1);
  }
  return ans;
}

两个细节撑起正确性。其一,为什么只处理新字符 c 的冲突就够了?因为 left 单调右移,此前每次冲突都把对应旧下标甩到了窗口外,lastIndex 里可能残留「出窗」的陈旧记录——这正是条件里 lastIndex.get(c) >= left 的用途:只有下标还在窗口内(不小于 left)才算真冲突,否则保持 left 不动。其二,left = lastIndex.get(c) + 1 是一次 O(1) 赋值,跳跃之后窗口 [left, right] 依然无重复,不变量在跳变中保持成立。

写法二:哈希表记计数,逐格收缩

第二种写法让哈希表记录每个字符在窗口内的出现次数。新字符入窗后计数加一,一旦它超过 1,说明不变量被破坏,就把左端字符计数减一、left 右移,反复收缩直到 c 的计数回到 1:

// 滑动窗口 + 哈希表记计数:靠收缩恢复「窗口无重复」的不变量 —— O(n)
function lengthOfLongestSubstringCount(s) {
  const count = new Map(); // 字符 -> 窗口内出现次数
  let left = 0, ans = 0;
  for (let right = 0; right < s.length; right++) {
    const c = s[right];
    count.set(c, (count.get(c) || 0) + 1);
    while (count.get(c) > 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;
}

减到 0 就把键删掉是个值得保留的小习惯:这样 count.size 始终等于窗口内的字符种类数,下一节的泛化直接依赖这个性质。两种写法的复杂度同为 O(n)——记下标版每步 O(1)、常数更小;记计数版虽然逐格收缩,但每个字符依旧至多进出窗口各一次。

怎么选

  • 只解本题、追求常数:记下标版。跳跃一步到位,逻辑最短,也是各语言题解里流传最广的版本。
  • 面试与工程:记计数版。它的骨架是「扩张,违反不变量就收缩到恢复」,收缩条件可以整体替换,能原样迁移到一大类窗口题(下一节泛化就是现成例子);记下标版则依赖「冲突对象必是新字符自身」这一特殊结构,换了问题就得推倒重来。
  • Python 读者可对照记下标版的直译实现:
# 滑动窗口 + 哈希表记下标(Python 对照版)
def lengthOfLongestSubstring(s: str) -> int:
    last_index = {}   # 字符 -> 最近一次出现的下标
    left = 0
    ans = 0
    for right, c in enumerate(s):
        if c in last_index and last_index[c] >= left:
            left = last_index[c] + 1
        last_index[c] = right
        ans = max(ans, right - left + 1)
    return ans

边界用例与常见错误

边界用例

写完先过这张清单,每个格子都对应一类实现漏洞:

输入 期望输出 考察点
"" 0 约束允许空串,别先取 s[0]
"a" 1 单字符,循环体走一遍
"bbbbb" 1 全相同,收缩逻辑反复触发
"abcdef" 6 全不同,right 走到底不收缩
" " 1 空格也是普通字符,照常入窗
"!?" 2 符号与字母同权
"abba" 2 陈旧下标陷阱(见错误二)
"tmmzuxt" 5 重复字符出窗后又回来

其中 " " 与 "!?" 专门对应约束里「符号和空格」:空格开头、纯符号的串都是合法输入,把它们当特殊值跳过反而会错。"tmmzuxt" 值得手动模拟一遍:处理到末尾的 t 时,t 的上一次出现早已被甩出窗口,靠的正是 >= left 这道判断挡住了误跳。

错误一:收缩条件写成「大于」

把真冲突的判断从 lastIndex.get(c) >= left 手滑写成 >——漏掉了「恰好等于 left」的情形。在 "aa" 上就会翻车:处理第二个 a 时,旧下标是 0,left 也是 0,0 > 0 不成立,不收缩,窗口被当成 "aa",输出 2 而正确答案是 1。相等意味着重复字符就站在窗口左端点上,必须收缩,边界上的等号不能丢。

错误二:漏掉「下标可能已出窗」

反过来,只写 if (lastIndex.has(c)) 就跳,left 会被陈旧下标拽着倒退。经典反例是 "abba":处理末尾 a 时,它记录的下标是 0,而 left 已到 2,0 开头的记录早该作废;无守卫的版本会把 left 跳回 1,窗口 "bba" 含重复,输出 3,正确答案是 2。这也是面试官最爱挖的坑——追问一句「abba 你的代码输出多少」,写法是否有守卫当场现形。

错误三:收缩不彻底或忘了删字符

两个同源的毛病。一是把收缩循环写成 if:每步至多收缩一格,可能还没恢复不变量就继续扩张。在 "abcbd" 上实测:if 版输出 4(把含重复的 "bcbd" 当成了合法窗口),正确答案是 3——收缩循环必须用 while,恢复不变量这件事要做到底。二是集合版收缩时只 left++、忘了 window.delete(s[left]):集合里留下「幽灵字符」,要么窗口虚长,要么 while (window.has(c)) 条件永远为真直接死循环。收缩移动左端点时,移出的字符必须同步移出集合,这条纪律没有例外。

延伸:至多 K 个不同字符与相关题

泛化:窗口模板的真正威力

把问题换成 LeetCode 340:求至多包含 K 个不同字符的最长子串。刚才记计数版的价值立刻显现——不变量从「每种字符至多一个」换成「种类数至多 K」,收缩条件从「c 的计数超过 1」换成「种类数超过 k」,其余一行不改。靠 count.size 直接读出种类数(这正是上一节「减到 0 就删键」的回报):

// 泛化:至多包含 K 个不同字符的最长子串(LeetCode 340)—— O(n)
function lengthOfLongestSubstringKDistinct(s, k) {
  if (k === 0) return 0;
  const count = new Map(); // 字符 -> 窗口内出现次数
  let left = 0, ans = 0;
  for (let right = 0; right < s.length; right++) {
    const c = s[right];
    count.set(c, (count.get(c) || 0) + 1);
    while (count.size > k) {
      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;
}

k = 0 的特判别省:收缩循环靠 count.size > k 驱动,k 为 0 时它推不出合法窗口,必须在入口挡住。这个泛化版本同样通过了与暴力基准的 500 轮随机差分。面试里被问「滑动窗口学来干什么」时,这就是最好的答案:本题的解法会过期,模板不会。

顺着这条线可以继续刷的题:

  • LeetCode 159 至多包含两个不同字符的最长子串:k = 2 的特例,适合当泛化版的第一道练习。
  • LeetCode 76 最小覆盖子串:窗口题的另一极——求最短而不是最长,收缩发生在「窗口已满足条件」时,方向感与本题相反。
  • LeetCode 209 长度最小的子数组:把「字符不重复」换成「元素和不超过阈值」,窗口装的对象变了,骨架一字不易。
  • LeetCode 424 替换后的最长重复字符:窗口内维护「最多字符的计数」,是记计数写法的进阶变体。
  • LeetCode 1004 最大连续 1 的个数 III:把「翻转 k 个 0」翻成「窗口内至多 k 个 0」,同一模板的又一变身。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?