【LeetCode】------ 无重复字符的最长子串:滑动窗口经典题详解
导语
LeetCode 第 3 题"无重复字符的最长子串"(Longest Substring Without Repeating Characters)是滑动窗口算法的入门经典。这道题在面试中出现频率极高,不仅考察你对字符串处理的理解,更是检验你是否真正掌握滑动窗口思想的试金石。
很多初学者拿到这题会想到暴力枚举所有子串,但那样做的时间复杂度是 O(n³),在 LeetCode 上直接超时。用滑动窗口可以做到 O(n),这就是本文要讲的核心。
一、题目描述
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
注意答案必须是子串,"pwke" 是一个子序列,不是子串。
约束条件:
0 <= s.length <= 5 * 10⁴s由英文字母、数字、符号和空格组成
二、核心概念:什么是滑动窗口
滑动窗口本质上是双指针的一种应用。维护一个窗口 [left, right],窗口内的元素满足某个条件(这里是"无重复字符")。通过移动左右边界来探索所有可能的合法窗口,同时记录最优解。
说白了,就是用两个指针框住一段字符串,右边往右扩直到不满足条件,左边往右缩来恢复条件,过程中不断更新答案。
本章小结
| 概念 | 说明 |
|---|---|
| 窗口 | 由 left 和 right 两个指针界定的子串 |
| 窗口扩张 | right 右移,尝试纳入新字符 |
| 窗口收缩 | left 右移,移除左侧字符以消除重复 |
| 合法条件 | 窗口内无重复字符 |
| 最优解 | 所有合法窗口中的最大长度 |
三、解法一:暴力枚举(理解用,实际不推荐)
暴力思路:枚举所有子串,检查每个子串是否无重复,取最长的。
python
def lengthOfLongestSubstring(s: str) -> int:
def all_unique(sub: str) -> bool:
return len(set(sub)) == len(sub)
max_len = 0
for i in range(len(s)):
for j in range(i + 1, len(s) + 1):
if all_unique(s[i:j]):
max_len = max(max_len, j - i)
return max_len
复杂度分析:
- 时间:O(n³) --- 枚举子串 O(n²),检查是否重复 O(n)
- 空间:O(min(n, m)),m 为字符集大小
这个解法在 LeetCode 上会 TLE(超时),放在这里只是为了让你理解问题的朴素形式。
四、解法二:滑动窗口 + 哈希集合(标准解法)
核心思路:
- 用
left和right两个指针维护一个窗口 - 用一个
Set记录窗口内已有字符 right不断右移,如果s[right]不在集合中,加入集合,更新最大长度- 如果
s[right]已在集合中(重复了),就不断右移left并从集合中移除s[left],直到重复消失 - 重复 3-4 直到
right遍历完整个字符串
图解过程
以 s = "abcabcbb" 为例:
步骤 窗口 集合 max_len
1 [a] {a} 1
2 [ab] {a,b} 2
3 [abc] {a,b,c} 3
4 [abca]b 重复! 左移 →
a[bca]b {b,c,a} 3
5 ...继续推进...
代码实现(Python)
python
def lengthOfLongestSubstring(s: str) -> int:
char_set = set()
left = 0
max_len = 0
for right in range(len(s)):
# 如果当前字符已在窗口中,收缩左边界
while s[right] in char_set:
char_set.remove(s[left])
left += 1
# 当前字符加入窗口
char_set.add(s[right])
# 更新最大长度
max_len = max(max_len, right - left + 1)
return max_len
代码实现(Java)
java
public int lengthOfLongestSubstring(String s) {
Set<Character> charSet = new HashSet<>();
int left = 0, maxLen = 0;
for (int right = 0; right < s.length(); right++) {
while (charSet.contains(s.charAt(right))) {
charSet.remove(s.charAt(left));
left++;
}
charSet.add(s.charAt(right));
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
代码实现(Go)
go
func lengthOfLongestSubstring(s string) int {
charSet := make(map[byte]bool)
left, maxLen := 0, 0
for right := 0; right < len(s); right++ {
for charSet[s[right]] {
delete(charSet, s[left])
left++
}
charSet[s[right]] = true
if right-left+1 > maxLen {
maxLen = right - left + 1
}
}
return maxLen
}
复杂度分析:
- 时间:O(n) --- 虽然有内层 while 循环,但每个字符最多被
left扫过一次,总体是 O(2n) = O(n) - 空间:O(min(n, m)),m 为字符集大小
五、解法三:滑动窗口 + 哈希表(优化版)
上面的解法中,当发现重复时,left 需要一步步右移。有没有办法直接跳到重复字符的下一个位置?
用哈希表记录每个字符最后出现的位置。当发现 s[right] 重复时,直接把 left 跳到 重复位置 + 1,省去中间的逐步收缩。
代码实现(Python)
python
def lengthOfLongestSubstring(s: str) -> int:
char_index = {} # 字符 -> 最后出现的索引
left = 0
max_len = 0
for right in range(len(s)):
char = s[right]
# 如果字符重复且在当前窗口内,直接跳到重复位置+1
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)
return max_len
关键点:char_index[char] >= left
这个条件很重要。如果记录的重复位置在 left 左边,说明那个重复字符已经不在当前窗口里了,不需要跳。
代码实现(Java)
java
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> charIndex = new HashMap<>();
int left = 0, maxLen = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (charIndex.containsKey(c) && charIndex.get(c) >= left) {
left = charIndex.get(c) + 1;
}
charIndex.put(c, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
复杂度分析:
- 时间:O(n) --- 只需一次遍历
- 空间:O(min(n, m))
六、三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 暴力枚举 | O(n³) | O(min(n,m)) | 理解用,会超时 |
| 滑动窗口+集合 | O(n) | O(min(n,m)) | 标准解法,容易理解 |
| 滑动窗口+哈希表 | O(n) | O(min(n,m)) | 优化版,减少不必要的收缩 |
实际面试中,写解法二就够了。解法三是锦上添花,能体现你对滑动窗口的深入理解。
七、常见问题与避坑
1. 为什么 left 可以直接跳而不是逐步收缩?
因为在哈希表优化版中,我们记录了每个字符的最后位置。当发现重复时,我们确定重复字符在 char_index[char] 这个位置,直接跳到它的下一位就行。中间那些字符肯定不重复(否则之前就会被处���掉),所以可以安全跳过。
2. 为什么判断条件是 char_index[char] >= left?
举个例子:s = "abba"
- 处理到第二个
b时,left = 0,char_index['b'] = 1,需要跳到left = 2 - 处理到最后一个
a时,char_index['a'] = 0,但left = 2,0 < 2,说明第一个a已经不在窗口内了,不应该跳
不加这个条件,结果会错。
3. 空字符串怎么处理?
题目约束 0 <= s.length,所以空串是合法输入。上述代码对空串自然返回 0,不需要特殊处理。
4. 全是相同字符怎么办?
比如 s = "bbbb",窗口会一直保持长度 1,left 和 right 同步右移,最终返回 1。没问题。
5. 字符集只有 ASCII 吗?
题目说"英文字母、数字、符号和空格",但实际代码对 Unicode 也适用,因为哈希表/集合可以存任意字符。
八、滑动窗口的通用模式
这道题的解法可以抽象为滑动窗口的通用模板:
python
def sliding_window(s):
window = {} # 或 set,视题目而定
left = 0
result = 0
for right in range(len(s)):
# 1. 扩大窗口
c = s[right]
window[c] = window.get(c, 0) + 1
# 2. 收缩窗口(当窗口不满足条件时)
while 窗口需要收缩:
d = s[left]
window[d] -= 1
left += 1
# 3. 更新结果
result = max(result, right - left + 1)
return result
掌握这个模板后,很多子串/子数组问题都能套用,比如:
- LeetCode 76:最小覆盖子串
- LeetCode 438:找到字符串中所有字母异位词
- LeetCode 209:长度最小的子数组
九、总结
核心要点:
- 滑动窗口是解决"最长/最短子串/子数组"问题的利器,时间复杂度 O(n)
- 用集合记录窗口内容判断重复,用哈希表可以进一步优化跳跃
left跳跃时要检查char_index[char] >= left,避免跳出窗口边界- 滑动窗口的套路是固定的:扩大 → 收缩 → 更新结果
验证清单:
- 能手写滑动窗口 + 集合的标准解法
- 理解哈希表优化版中
>= left条件的作用 - 能用通用模板解决类似的子串问题
- 能分析时间复杂度并解释为什么是 O(n)
参考资源
- LeetCode 第 3 题:https://leetcode.cn/problems/longest-substring-without-repeating-characters/
- 滑动窗口算法详解:建议在 LeetCode 题解区搜索"滑动窗口"获取更多图解