3. 无重复字符的最长子串 (Medium)

专题归类: 02-双指针与滑动窗口 · 01-哈希表 LeetCode 链接: https://leetcode.cn/problems/longest-substring-without-repeating-characters/


在线做题: 写 Python 代码并运行 · Java 跳转 LeetCode

题目描述

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

示例:

输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3

补充说明:

  • 字符串长度范围:0 <= s.length <= 5 * 10^4
  • 字符串由英文字母、数字、符号和空格组成(ASCII 字符集)

题目详细分析

数据范围含义:

  • 长度最大 5×10^4,O(n^2) 会超时(2.5×10^9 次操作),需要 O(n) 解法。
  • 字符集为 ASCII(128 个字符),而不是仅限小写字母。这对哈希表的大小有影响——不能用固定 26 的数组,但可以用 128 的数组,或者直接用哈希集合/字典。

核心概念:

  • 子串(substring) 是原字符串中连续的一段,不是子序列(subsequence)。
  • 不含有重复字符意味着子串中每个字符最多出现一次。
  • 目标是最大长度,不是子串本身。

边界条件:

  • 空字符串:长度为 0,结果为 0。
  • 字符串全为同一字符:如 “aaaaa”,最长无重复子串长度为 1。
  • 字符串全无重复:如 “abcde”,最长无重复子串就是整个字符串,长度 len(s)。
  • 单个字符:长度为 1,结果为 1。

隐藏条件:

  • 滑动窗口是解决子串问题的标准模板。核心是维护一个”窗口”,用哈希表记录窗口内的字符出现情况。
  • 窗口的”扩大”和”缩小”对应着 right 和 left 指针的移动。
  • 可以用哈希集合存储窗口内字符,也可以用哈希字典存储每个字符最后出现的位置(实现”跳跃式”移动 left)。

小白版直白理解

想象你在看一条长长的彩带,上面画着各种符号。你想找到最长的一段,这段里面没有重复的符号

笨办法: 从每个位置开始,往右看,记下看到过的符号,直到遇到重复的为止。每个起点都试一遍。

聪明办法(滑动窗口): 你拿一个放大镜(窗口)在彩带上从左往右滑:

  1. 放大镜的右端不断往右移动,看到新符号就收进来。
  2. 如果新符号和放大镜里已有的符号重复了,就把放大镜的左端往右缩,直到把重复的符号”挤出去”。
  3. 每次右端移动后,记下放大镜当前覆盖的长度,取最大值。

这个过程就像一条毛毛虫在彩带上爬行——头往前伸,如果遇到障碍就把尾巴往前收。整个过程只需要爬一次!


解题思路

思路一:滑动窗口 + 哈希集合(推荐,容易理解)

核心想法: 用哈希集合 char_set 存储当前窗口内的字符。右指针扩展窗口,如果遇到重复字符,左指针收缩直到窗口内无重复。

三问法分析:

  1. 什么时候扩大? 右指针字符加入后无重复 → 右移 right。
  2. 什么时候缩小? 出现重复字符 → 左移 left 直到无重复。
  3. 什么时候更新答案? 扩大窗口后(窗口变大,可能产生更优解)。
def lengthOfLongestSubstring(s):
    """
    滑动窗口 + 哈希集合
    char_set 记录窗口内的字符,用于检测重复
    """
    char_set = set()
    left = 0
    max_len = 0
    
    for right, ch in enumerate(s):
        # 如果 ch 重复,收缩左边界直到窗口内无重复
        while ch in char_set:
            char_set.remove(s[left])
            left += 1
        
        # 加入当前字符
        char_set.add(ch)
        
        # 更新最大长度
        max_len = max(max_len, right - left + 1)
    
    return max_len

思路二:滑动窗口 + 哈希字典(优化版,跳跃式移动 left)

核心想法: 不用逐个移动 left,而是用字典记录每个字符最近一次出现的位置。遇到重复字符时,直接将 left 跳到 上次出现位置 + 1

关键洞察: 思路一中,while 循环逐个移除字符是 O(1) 摊销,但可以有更直接的方式——如果知道重复字符的位置,可以直接跳过去。

def lengthOfLongestSubstring(s):
    """
    滑动窗口 + 哈希字典(优化版)
    char_index 记录每个字符最近一次出现的位置
    left 可以直接跳跃到重复字符的下一个位置
    """
    char_index = {}  # 字符 -> 最近一次出现的下标
    left = 0
    max_len = 0
    
    for right, ch in enumerate(s):
        # 如果 ch 出现过且在窗口内,直接跳跃 left
        if ch in char_index and char_index[ch] >= left:
            left = char_index[ch] + 1
        
        # 记录当前字符的位置
        char_index[ch] = right
        
        # 更新最大长度
        max_len = max(max_len, right - left + 1)
    
    return max_len

两个版本对比:

版本left 移动方式空间特点
集合版逐个移动O(k)直观,适合初学者
字典版跳跃移动O(k)更高效,但需要处理”过期”字符

易错点

  • 子串 vs 子序列: 子串必须连续,不重复子序列可以跳过中间字符。题目要求的是子串,所以用滑动窗口。如果搞成子序列,就变成完全不同的题了。
  • 字典版中 left 不能回退: 需要检查 char_index[ch] >= left,因为 char_index 中可能记录了该字符但不在当前窗口中(已经被 left 越过了)。如果不加这个判断,left 可能错误地”回退”到更早的位置。
  • 字符集大小: 题目中字符集是 ASCII,不是仅限小写字母。如果用固定数组,需要 128 或 256 大小,不能只开 26。
  • 空字符串处理: s 可能为空,此时 max_len 保持为 0,直接返回 0。
  • 更新答案的时机: 是在窗口扩大后(right 移动后)更新,不是在窗口收缩时。对集合版来说,while 移除重复后立即更新。对字典版来说,跳跃 left 后立即更新。

框架提炼

滑动窗口通用模板:

滑动窗口是解决”连续子串/子数组”问题的标准框架,核心是用两个指针维护一个窗口,避免重复遍历。

def sliding_window(s):
    """
    滑动窗口通用模板
    """
    window = {}      # 或 set/list,记录窗口内元素
    left = 0
    result = 0        # 或初始化为极小值/极大值
    
    for right, val in enumerate(s):
        # 1. 扩展窗口:加入右指针元素
        add_to_window(window, val)
        
        # 2. 收缩窗口:当窗口不满足约束时,移动左指针
        while not is_valid(window):
            remove_from_window(window, s[left])
            left += 1
        
        # 3. 更新答案:窗口满足约束时
        result = max(result, right - left + 1)  # 或 min
    
    return result

滑动窗口三问法:

  1. 什么时候扩大窗口?(右移 right)
  2. 什么时候缩小窗口?(左移 left)
  3. 什么时候更新答案?(扩大后 / 缩小前)

关联题目

  • 76-最小覆盖子串 — 同样是滑动窗口,但本题是”找最长无重复”(约束是不重复),76 是”找最短覆盖”(约束是全覆盖)。本题窗口收缩条件是有重复,76 是已经全覆盖
  • 438-找到字符串中所有字母异位词 — 定长滑动窗口,窗口大小固定为 len(p),每次滑动后比较窗口内字符计数与目标是否一致。本题是不定长窗口
  • 159-至多包含两个不同字符的最长子串 — 本题的变体,约束条件从”无重复”变为”最多两种不同字符”,滑动窗口模板思路完全一致,只需修改收缩条件