从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
当我们从数组和链表的“冰冷内存”转向字符串的“流式字符”时,滑动窗口才真正展现出它最优雅的一面。这道题,就是滑动窗口思想的“封神之作”。
前言
在连续攻克了链表专题的重重关卡——从反转链表(206)到LRU缓存(146)——之后,是时候进入一个全新的数据结构领域了。今天,我们首先要面对的,是字符串/数组专题中最经典、最基础、也是面试中出现频率最高的题目之一——力扣3. 无重复字符的最长子串(Longest Substring Without Repeating Characters)。
这道题在LeetCode上标记为中等(Medium),但它的江湖地位绝不亚于任何一道Hard题。在字节跳动、腾讯、Google、Amazon的面试中,它几乎是“开场白”级别的必考题。它完美地考察了**滑动窗口(Sliding Window)**这一核心算法思想,而且提供了从暴力到优化的完整进化路线。
题目描述极其简洁:给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。
很多同学一看:“找子串,无重复,这还不简单?双重循环遍历所有子串,再用Set判重就行了。”——这固然能解,但面对 10^5 级别的字符串长度时,O(n²) 的复杂度会让你在面试官面前直接“社死”。
今天,我们将从最直观的暴力枚举法出发,逐步进化到滑动窗口(HashSet版),最后抵达那个让时间复杂度彻底降至 O(n) 的滑动窗口(HashMap优化版)。这不仅仅是解一道题,更是一次对“双指针”和“状态维护”的深刻洗礼。
题目回顾
给定一个字符串
s,请你找出其中不含有重复字符的 最长子串 的长度。示例1:
输入: s = "abcabcbb" 输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。示例2:
输入: s = "bbbbb" 输出: 1示例3:
输入: s = "pwwkew" 输出: 3 解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。约束条件:
0 <= s.length <= 5 * 10^4,s由英文字母、数字、符号和空格组成。
核心难点:如何高效地“伸缩”窗口?
“无重复字符的子串”本质上是一个区间问题。我们需要在字符串上维护一个区间 [left, right],保证这个区间内的字符都没有重复。
当我们在 right 指针向右扩展时,如果发现新加入的字符在窗口内已经存在了,我们就必须移动 left 指针,把那个重复的字符“踢出”窗口。这个“踢出”的过程,决定了算法的效率:
- 如果
left一次只挪动一步,算法可能在某些情况下退化。 - 如果
left能直接跳到重复字符的下一个位置,效率将达到极致。
第一层:暴力法 —— 最直观的“地毯式搜索”
如果暂时想不到优化,暴力法就是我们思考的起点。
核心思想
枚举所有子串的起始位置 i 和结束位置 j,用 Set 检查子串 s[i:j] 是否有重复字符。如果没有重复,记录最大长度。
class Solution {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int maxLen = 0;
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
if (allUnique(s, i, j)) {
maxLen = Math.max(maxLen, j - i + 1);
}
}
}
return maxLen;
}
private boolean allUnique(String s, int left, int right) {
Set<Character> set = new HashSet<>();
for (int i = left; i <= right; i++) {
if (set.contains(s.charAt(i))) return false;
set.add(s.charAt(i));
}
return true;
}
}
复杂度分析:
- 时间复杂度:
O(n^3)。外层循环 O(n),内层循环 O(n),判重又是 O(n),三重循环直接爆炸。 - 空间复杂度:
O(min(n, m)),m为字符集大小。
点评: 这个解法显然无法通过 LeetCode 的极限测试,但它的意义在于让我们理解了问题的本质是区间合法性检查。接下来所有的优化,都围绕着“如何在不重复遍历的情况下,动态维护一个合法区间”展开。
第二层:滑动窗口(标准HashSet版) —— 双指针的初次登场
暴力法为什么慢?因为对于每一个起始位置 i,我们都要重新扫描一遍 j 来构建子串,这中间存在大量的重复计算。
如果我们在 i 固定的情况下,把 j 一直向右扩展,直到遇到重复字符为止,记录长度。然后我们移动 i(左指针),此时 j 没有必要回到 i 的位置重新开始,因为 [i+1, j] 这段区间内大概率也是合法的。这就是滑动窗口的雏形。
核心思想
维护一个窗口 [left, right],用 HashSet 存储窗口内的字符:
right指针不断向右移动,尝试扩大窗口。- 如果
s[right]不在Set中,加入Set,更新最大长度。 - 如果
s[right]已经在Set中,说明窗口内存在重复。此时我们需要移动left指针,将s[left]从Set中移除,直到窗口中不再包含s[right]这个重复字符。
class Solution {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
Set<Character> set = new HashSet<>();
int left = 0, right = 0;
int maxLen = 0;
while (right < n) {
char c = s.charAt(right);
// 如果窗口内已有 c,移动 left 缩小窗口,直到移除重复
while (set.contains(c)) {
set.remove(s.charAt(left));
left++;
}
// 此时窗口内没有重复字符
set.add(c);
maxLen = Math.max(maxLen, right - left + 1);
right++;
}
return maxLen;
}
}
复杂度分析:
- 时间复杂度:
O(2n) = O(n)。在最坏情况下(如"aaaaa"),left和right各自遍历了整个字符串一次。 - 空间复杂度:
O(min(n, m))。
点评:
这是滑动窗口的标准写法,思路清晰,容易理解。面试中写出这个,你已经可以拿到 80 分了。但仔细观察,你会发现 while 循环中的 left 是一次一次移动的。在极端情况下(比如 s 极长且全是重复字符),这个 while 虽然总体是 O(n),但在某些字符集很大的场景下,能不能“跳”得更快一点呢?
第三层:滑动窗口(HashMap跳表版) —— 极致的“跳跃”
在第二层的写法中,当遇到重复字符 c 时,我们是通过循环一步一步移动 left 来缩小窗口的。但实际上,我们知道 left 应该跳到什么位置——跳到上一次出现 c 的位置的后面一个位置。
如果我们用 HashMap<Character, Integer> 存储每个字符最近一次出现的位置,那么当遇到重复字符 c 时,我们可以直接将 left 更新为 map.get(c) + 1,而不需要一步一步地挪动。
但是!这里有一个巨大的陷阱: left 只能向前移动(不能后退)。如果 map.get(c) + 1 小于当前的 left,说明这个“上一次出现的位置”已经在窗口之外了,我们不应该让 left 后退,所以应该取 left 和 map.get(c) + 1 的最大值。
class Solution {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
Map<Character, Integer> map = new HashMap<>(); // 字符 -> 最新出现的位置(索引)
int left = 0;
int maxLen = 0;
for (int right = 0; right < n; right++) {
char c = s.charAt(right);
// 如果 c 出现过,并且它的位置在 [left, right] 区间内
if (map.containsKey(c) && map.get(c) >= left) {
// 左指针直接跳到重复字符的下一个位置
left = map.get(c) + 1;
}
// 更新 c 的最新位置(无论是否重复,都要更新为当前 right)
map.put(c, right);
// 计算当前窗口长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
}
图解流程(以 s = "abba" 为例):
right=0, 'a':left=0,max=1。map: a=0。right=1, 'b':left=0,max=2。map: a=0, b=1。right=2, 'b':发现 map 中 b=1 >= left(0),left = 1+1 = 2。max = max(2, 2-2+1=1) = 2。map: b=2。right=3, 'a':发现 map 中 a=0,但0 >= left(2)是 false,所以left不动(保持2)。max = max(2, 3-2+1=2) = 2。结果正确(最长子串 "ab" 或 "ba")。
复杂度分析:
- 时间复杂度:
O(n),left和right各遍历一次,且left是跳跃前进的,比第二层更快。 - 空间复杂度:
O(min(n, m))。
点评:
这是本题的最优解。它完美地利用了“最近出现位置”这个信息,将窗口调整的操作从 while 循环降维成了数学运算。在面试中,如果你能写出这个版本,并清楚地解释“为什么 left = Math.max(left, map.get(c) + 1)”,面试官一定会对你刮目相看。
第四层(终极优化):数组代替HashMap —— 极致的常数优化
由于题目只说了 s 由字母、数字、符号和空格组成,在 ASCII 码范围内(0-127,或者扩展到 256)。我们可以用一个 长度为 128 或 256 的整型数组 来代替 HashMap,实现更快的 O(1) 存取。
class Solution {
public int lengthOfLongestSubstring(String s) {
int[] lastIndex = new int[128]; // 初始化为 -1
Arrays.fill(lastIndex, -1);
int left = 0;
int maxLen = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
int idx = c; // 自动转型为 int ASCII 值
if (lastIndex[idx] >= left) {
left = lastIndex[idx] + 1;
}
lastIndex[idx] = right;
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
}
如果字符集扩展到 Unicode(比如汉字),数组就不够用了,还是得用 HashMap。但在绝大多数面试场景(英文字母)下,数组写法是加分项。
深度总结:三种解法进化图谱
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 | 面试推荐度 |
|---|---|---|---|---|
| 暴力法 | O(n^3) | O(min(n,m)) | 三重循环枚举+判重 | ⭐(仅用于理解定义) |
| 滑动窗口(HashSet) | O(2n) | O(min(n,m)) | 双指针维护窗口,循环收缩左边界 | ⭐⭐⭐⭐(标准答案) |
| 滑动窗口(HashMap跳表) | O(n) | O(min(n,m)) | 左指针直接跳到重复字符后一位 | ⭐⭐⭐⭐⭐(最优解,必会) |
| 数组代替HashMap | O(n) | O(128) | 常数级优化,极客风范 | ⭐⭐⭐⭐⭐(加分项) |
从这道题中我们学到了什么?
-
滑动窗口是处理“子串/子数组”问题的第一武器。当你看到“最长子串”、“最短子数组”、“包含特定条件”等关键词时,首先应该想到滑动窗口。
-
“空间换时间”的极致运用。从 Set 到 HashMap 再到数组,我们都在用额外的空间存储“历史位置”信息,从而让时间从 O(n^2) 降为 O(n)。
-
left指针的“不可后退性”。这是滑动窗口的精髓所在。left只能前进(或不变),绝对不能后退。在 HashMap 版本中,Math.max的存在正是为了守护这一原则,防止因为窗口外的过期数据导致left错误地回退。 -
从“循环缩窗”到“数学跳窗”。第二层用
while循环收缩,虽然总体是 O(n),但第三层用map.get直接跳转,大大减少了常数时间的开销。这种“利用额外信息替代循环”的思路,在算法优化中极其常见。
最后的一些心里话
力扣3是一道经典中的经典。它不像 LRU 那样需要手写复杂的数据结构,也不像反转链表那样考验指针的物理操作,它考验的是你对“区间状态”的敏锐感知。
很多同学在做这道题时,死记硬背了 HashMap 的模板,但当被问到“为什么要取 max”时,却支支吾吾。我希望你能记住我们今天讲的“过期数据”的概念:map 里的位置可能已经小于 left 了,这时候它就不是“窗口内”的数据,不应该影响 left 的跳跃。
从今天起,每当你看到一个字符串处理问题,先问问自己:“我能用滑动窗口吗?”——这将是你解题思路的第一步。
记住今天的口诀:
无重子串滑动窗,左移右扩拉长度。 哈希记录索引位,左针跳过重复处。 若遇旧位在窗外,千万莫把指针误。
如果你觉得这篇题解帮你彻底搞懂了无重复字符的最长子串,欢迎点赞、收藏、转发!我们下一站将剑指力扣5「最长回文子串」,不见不散! 🚀