# 无重复字符的最长子串 (Longest Substring Without Repeating Characters) LeetCode 3. Medium ## 题目描述 给定一个字符串 `s` ,请你找出其中不含有重复字符的 **最长子串** 的长度。 **示例 1**: ``` 输入: s = "abcabcbb" 输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。 ``` **示例 2**: ``` 输入: s = "bbbbb" 输出: 1 解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。 ``` **示例 3**: ``` 输入: s = "pwwkew" 输出: 3 解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。 请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。 ``` ## 思路推导 ### 暴力解法分析 **最直观的思路**:枚举所有可能的子串,检查是否有重复字符。 ```python def lengthOfLongestSubstring(s): max_len = 0 n = len(s) for i in range(n): for j in range(i+1, n+1): substring = s[i:j] if len(set(substring)) == len(substring): # 无重复 max_len = max(max_len, j-i) return max_len ``` **时间复杂度**:O(n³) - 外层循环:O(n) 枚举起始位置 - 内层循环:O(n) 枚举结束位置 - 检查重复:O(n) 创建集合 - 总计:O(n) × O(n) × O(n) = O(n³) **空间复杂度**:O(min(m, n)),m 为字符集大小 **问题分析**: 1. 效率太低:n=10⁵ 时,n³ 不可接受 2. 重复计算:很多子串被多次检查 3. 无法利用已知信息 ### 优化思考 - 第一步:滑动窗口 **观察**:如果 s[i:j] 无重复,检查 s[j] 是否在窗口内 ```python # 维护一个窗口 [left, right] # 每次向右扩展 right # 如果 s[right] 在窗口内重复,移动 left ``` **为什么这样思考?** - 窗口内的子串保证无重复 - 只需要向右移动,不需要回溯 - 每个字符最多被访问 2 次(进入和离开窗口) **优化后的思路**: ```python left = 0 max_len = 0 for right in range(len(s)): # 如果 s[right] 在窗口内,移动 left while s[right] in s[left:right]: left += 1 max_len = max(max_len, right - left + 1) ``` **时间复杂度**:O(n²) - 仍然有重复检查:`s[right] in s[left:right]` 是 O(n) ### 优化思考 - 第二步:哈希表优化 **问题**:如何快速判断字符是否在窗口内? **关键优化**:用哈希表记录字符最后出现的位置 ```python char_index = {} # 字符 → 最后出现的位置 left = 0 max_len = 0 for right, char in enumerate(s): # 如果字符在窗口内,移动 left if char in char_index and char_index[char] >= left: left = char_index[char] + 1 char_index[char] = right max_len = max(max_len, right - left + 1) ``` **为什么这样思考?** - 哈希表查找:O(1) - 直接定位到重复字符的位置 - left 可以跳跃式移动,不用逐个移动 **时间复杂度**:O(n) - 每个字符只处理一次 - 哈希表操作:O(1) ### 优化思考 - 第三步:数组代替哈希表 **进一步优化**:如果字符集有限(如 ASCII),用数组代替哈希表 ```python char_index = [-1] * 128 # ASCII 字符集 left = 0 max_len = 0 for right, char in enumerate(s): char = ord(char) # 转换为 ASCII 码 if char_index[char] >= left: left = char_index[char] + 1 char_index[char] = right max_len = max(max_len, right - left + 1) ``` **优势**: - 数组访问比哈希表更快 - 空间局部性更好(cache 友好) - 适合字符集有限的情况 ## 解题思路 ### 核心思想 **滑动窗口 + 哈希表**:维护动态窗口,用哈希表记录字符位置。 **为什么这样思考?** 1. **滑动窗口的原理**: - 窗口 [left, right] 内保证无重复字符 - 右边界不断扩展 - 左边界根据重复情况调整 2. **哈希表的作用**: - 记录每个字符最后出现的位置 - 快速判断重复字符是否在窗口内 - 支持 O(1) 时间复杂度的查找和更新 3. **关键判断**: - `char_index[char] >= left`:字符在窗口内 - `char_index[char] < left`:字符在窗口外(已失效) ### 详细算法流程 **步骤1:初始化数据结构** ```python char_index = {} # 字符 → 最后出现的位置 left = 0 # 窗口左边界 max_len = 0 # 最长长度 ``` **作用**: - `char_index`:快速判断重复 - `left`:标记当前窗口的起点 - `max_len`:记录结果 **步骤2:遍历字符串** ```python for right, char in enumerate(s): # 检查字符是否在窗口内 if char in char_index and char_index[char] >= left: # 重复字符在窗口内,移动 left left = char_index[char] + 1 # 更新字符位置 char_index[char] = right # 更新最大长度 max_len = max(max_len, right - left + 1) ``` **关键点详解**: 1. **为什么判断 `char_index[char] >= left`?** - 只关心重复字符是否在当前窗口内 - 如果在窗口外,可以忽略 - 示例: ``` s = "a b c a" left = 0, right = 3 char_index['a'] = 0 >= left → 重复,left = 1 s = "a b c a b c" left = 1, right = 5 char_index['b'] = 1 >= left → 重复,left = 2 s = "a b c a b" left = 1, right = 4 char_index['a'] = 0 < left → 不在窗口内,不移动 ``` 2. **为什么 `left = char_index[char] + 1`?** - 跳过重复字符,包括重复字符本身 - 新窗口从重复字符的下一位开始 - 示例: ``` s = "a b c a" 0 1 2 3 right = 3, char = 'a' char_index['a'] = 0 left = 0 + 1 = 1 新窗口:[1, 3] = "bca" ``` 3. **为什么先更新 left,再更新 char_index?** - 必须先判断重复,再更新位置 - 如果先更新,会覆盖旧位置 - 错误示例: ```python char_index[char] = right # 错误!先更新了 if char in char_index and char_index[char] >= left: left = char_index[char] + 1 # 永远成立 ``` **步骤3:返回结果** ```python return max_len ``` ### 关键细节说明 **细节1:为什么用 `enumerate` 而不是 `range`?** ```python # 推荐写法:同时获取索引和字符 for right, char in enumerate(s): # ... # 不推荐:需要额外索引 for i in range(len(s)): char = s[i] # ... ``` **细节2:为什么窗口长度是 `right - left + 1`?** ```python # 示例:s = "abc" left = 0, right = 2 窗口长度 = 2 - 0 + 1 = 3 索引:[0, 1, 2] # 为什么 +1? # 索引从 0 开始,需要 +1 才是实际长度 ``` **细节3:为什么 `char_index[char] >= left` 而不是 `> left`?** ```python # 示例:s = "abca" left = 0, right = 3, char = 'a' char_index['a'] = 0 # 如果用 > left if char_index[char] > left: # 0 > 0 → False # 不会移动 left,错误! # 正确:用 >= left if char_index[char] >= left: # 0 >= 0 → True left = 1 # 正确! ``` **细节4:为什么需要两个条件判断?** ```python # 条件1:字符是否出现过 if char in char_index: # 条件2:字符是否在窗口内 and char_index[char] >= left: # 为什么都需要? # 示例:s = "abcabcbb" # right = 3, char = 'a' # char_index['a'] = 0 < left(1) → 不在窗口内 # 虽然出现过,但不在窗口内,可以保留 ``` ### 边界条件分析 **边界1:空字符串** ``` 输入:s = "" 输出:0 处理:循环不执行,max_len = 0 ``` **边界2:全部相同字符** ``` 输入:s = "bbbbb" 过程: right=0: char='b', left=0, max_len=1 right=1: char='b', 重复, left=1, max_len=1 right=2: char='b', 重复, left=2, max_len=1 right=3: char='b', 重复, left=3, max_len=1 right=4: char='b', 重复, left=4, max_len=1 输出:1 ``` **边界3:全部不同字符** ``` 输入:s = "abcde" 过程: right=0: char='a', left=0, max_len=1 right=1: char='b', left=0, max_len=2 right=2: char='c', left=0, max_len=3 right=3: char='d', left=0, max_len=4 right=4: char='e', left=0, max_len=5 输出:5 ``` **边界4:重复字符在窗口外** ``` 输入:s = "abca" 过程: right=0: char='a', left=0, max_len=1 right=1: char='b', left=0, max_len=2 right=2: char='c', left=0, max_len=3 right=3: char='a', char_index['a']=0 < left=0? → False 实际:0 >= 0 → True, left=1, max_len=3 输入:s = "abcabcbb" 过程: right=3: char='a', char_index['a']=0 >= left=0 → left=1 right=4: char='b', char_index['b']=1 >= left=1 → left=2 right=5: char='c', char_index['c']=2 >= left=2 → left=3 right=6: char='b', char_index['b']=4 >= left=3 → left=5 right=7: char='b', char_index['b']=6 >= left=5 → left=7 输出:3 ``` ### 复杂度分析(详细版) **时间复杂度**: ``` - 外层循环:O(n),遍历字符串 - 哈希表操作:O(1),查找和更新 - 总计:O(n) 为什么是 O(n)? - 每个字符最多被访问 2 次(进入和离开窗口) - left 指针最多移动 n 次 - right 指针最多移动 n 次 - 总操作次数 = 2n = O(n) ``` **空间复杂度**: ``` - 哈希表:O(min(m, n)),m 为字符集大小 - ASCII:O(128) = O(1) - Unicode:O(n) - 指针变量:O(1) - 总计:O(min(m, n)) ``` --- ## 图解过程 ``` 字符串: "abcabcbb" 步骤1: [a]bcabcbb left=0, right=0, max_len=1 char_index = {'a': 0} 步骤2: [a,b]cabcbb left=0, right=1, max_len=2 char_index = {'a': 0, 'b': 1} 步骤3: [a,b,c]abcbb left=0, right=2, max_len=3 char_index = {'a': 0, 'b': 1, 'c': 2} 步骤4: a[b,c,a]bcbb (发现重复,left移动) left=1, right=3, max_len=3 char_index = {'a': 3, 'b': 1, 'c': 2} 步骤5: ab[c,a,b]cbb (发现重复,left移动) left=2, right=4, max_len=3 char_index = {'a': 3, 'b': 4, 'c': 2} 步骤6: abc[a,b,c]bb (发现重复,left移动) left=3, right=5, max_len=3 char_index = {'a': 3, 'b': 4, 'c': 5} 步骤7: abca[b,c,b]b (发现重复,left移动) left=5, right=6, max_len=3 char_index = {'a': 3, 'b': 6, 'c': 5} 步骤8: abcab[c,b,b] (发现重复,left移动) left=7, right=7, max_len=3 char_index = {'a': 3, 'b': 7, 'c': 5} 结果: max_len = 3 ``` --- ## 代码实现 ### 方法1:哈希表(推荐) ```go func lengthOfLongestSubstring(s string) int { // 记录字符最后出现的位置 charIndex := make(map[rune]int) maxLength := 0 left := 0 for right, char := range s { // 如果字符已存在且在窗口内,移动左边界 if idx, ok := charIndex[char]; ok && idx >= left { left = idx + 1 } // 更新字符位置 charIndex[char] = right // 更新最大长度 if right - left + 1 > maxLength { maxLength = right - left + 1 } } return maxLength } ``` ### 方法2:数组优化(ASCII) ```go func lengthOfLongestSubstring(s string) int { // 使用数组代替哈希表,适用于 ASCII 字符集 charIndex := [128]int{} // ASCII 字符集 for i := range charIndex { charIndex[i] = -1 } maxLength := 0 left := 0 for right := 0; right < len(s); right++ { char := s[right] // 如果字符已存在且在窗口内,移动左边界 if charIndex[char] >= left { left = charIndex[char] + 1 } // 更新字符位置 charIndex[char] = right // 更新最大长度 if right - left + 1 > maxLength { maxLength = right - left + 1 } } return maxLength } ``` --- ## 执行过程演示 **输入**:s = "abcabcbb" ``` 初始化:charIndex = {}, left = 0, max_len = 0 right=0, char='a': charIndex['a'] 不存在 charIndex = {'a': 0} max_len = max(0, 0-0+1) = 1 right=1, char='b': charIndex['b'] 不存在 charIndex = {'a': 0, 'b': 1} max_len = max(1, 1-0+1) = 2 right=2, char='c': charIndex['c'] 不存在 charIndex = {'a': 0, 'b': 1, 'c': 2} max_len = max(2, 2-0+1) = 3 right=3, char='a': charIndex['a'] = 0 >= left(0) → 重复 left = 0 + 1 = 1 charIndex = {'a': 3, 'b': 1, 'c': 2} max_len = max(3, 3-1+1) = 3 right=4, char='b': charIndex['b'] = 1 >= left(1) → 重复 left = 1 + 1 = 2 charIndex = {'a': 3, 'b': 4, 'c': 2} max_len = max(3, 4-2+1) = 3 right=5, char='c': charIndex['c'] = 2 >= left(2) → 重复 left = 2 + 1 = 3 charIndex = {'a': 3, 'b': 4, 'c': 5} max_len = max(3, 5-3+1) = 3 right=6, char='b': charIndex['b'] = 4 >= left(3) → 重复 left = 4 + 1 = 5 charIndex = {'a': 3, 'b': 6, 'c': 5} max_len = max(3, 6-5+1) = 3 right=7, char='b': charIndex['b'] = 6 >= left(5) → 重复 left = 6 + 1 = 7 charIndex = {'a': 3, 'b': 7, 'c': 5} max_len = max(3, 7-7+1) = 3 结果:max_len = 3 ``` --- ## 常见错误 ### 错误1:忘记判断字符是否在窗口内 ❌ **错误代码**: ```go if idx, ok := charIndex[char]; ok { left = idx + 1 // 错误!可能在窗口外 } ``` ✅ **正确代码**: ```go if idx, ok := charIndex[char]; ok && idx >= left { left = idx + 1 // 正确!只在窗口内时移动 } ``` **原因**: - 示例:s = "abcabcbb" - right=3, char='a', charIndex['a']=0, left=1 - 0 < 1,不在窗口内,不应该移动 left --- ### 错误2:更新 char_index 的时机错误 ❌ **错误代码**: ```go for right, char := range s { charIndex[char] = right // 错误!先更新了 if idx, ok := charIndex[char]; ok && idx >= left { left = idx + 1 // 永远成立 } } ``` ✅ **正确代码**: ```go for right, char := range s { if idx, ok := charIndex[char]; ok && idx >= left { left = idx + 1 // 先判断 } charIndex[char] = right // 再更新 } ``` **原因**: - 先更新会覆盖旧位置 - 导致判断永远成立 --- ### 错误3:窗口长度计算错误 ❌ **错误代码**: ```go max_len = max(max_len, right - left) // 错误!少了 +1 ``` ✅ **正确代码**: ```go max_len = max(max_len, right - left + 1) // 正确 ``` **原因**: - 索引从 0 开始 - 长度 = right - left + 1 - 示例:[0, 2] 长度为 3,不是 2 --- ## 进阶问题 ### Q1: 如何返回最长子串本身? ```go func longestSubstring(s string) string { charIndex := make(map[rune]int) maxLength := 0 left := 0 start := 0 // 记录起始位置 for right, char := range s { if idx, ok := charIndex[char]; ok && idx >= left { left = idx + 1 } charIndex[char] = right if right - left + 1 > maxLength { maxLength = right - left + 1 start = left } } return s[start : start+maxLength] } ``` **关键点**: - 记录最长子串的起始位置 - 在更新 max_len 时同时更新 start --- ### Q2: 如果字符集有限(如只有小写字母),如何优化? **优化**:使用数组代替哈希表 ```go func lengthOfLongestSubstring(s string) int { charIndex := [128]int{} // ASCII 字符集 for i := range charIndex { charIndex[i] = -1 } maxLength := 0 left := 0 for right := 0; right < len(s); right++ { char := s[right] if charIndex[char] >= left { left = charIndex[char] + 1 } charIndex[char] = right maxLength = max(maxLength, right-left+1) } return maxLength } func max(a, b int) int { if a > b { return a } return b } ``` **优势**: - 数组访问比哈希表更快 - 空间局部性更好 - 适合字符集有限的情况 --- ## P7 加分项 ### 深度理解 - **滑动窗口**:维护动态窗口,左边界根据重复字符调整 - **哈希表优化**:数组 vs HashMap,时间/空间权衡 - **边界处理**:重复字符在窗口外的情况 ### 实战扩展 - **流式数据**:处理超大字符串或流式输入 - **多线程**:分段计算后合并 - **业务场景**:日志去重、用户行为分析 ### 变形题目 1. [159. 至多包含两个不同字符的最长子串](https://leetcode.cn/problems/longest-substring-with-at-most-two-distinct-characters/) 2. [340. 至多包含 K 个不同字符的最长子串](https://leetcode.cn/problems/longest-substring-with-at-most-k-distinct-characters/) --- ## 总结 **核心要点**: 1. **滑动窗口**:动态调整窗口边界 2. **哈希表**:记录字符位置,快速判断重复 3. **双指针**:left 和 right 指针协同移动 **易错点**: - 忘记判断重复字符是否在窗口内(`idx >= left`) - 更新 left 的时机 - 数组越界(使用数组代替哈希表时) **最优解法**:滑动窗口 + 哈希表,时间 O(n),空间 O(min(m, n))