LeetCode 3. 无重复字符的最长子串:从错误直觉到滑动窗口,彻底讲透为什么必须判断 map.get(c) >= left
摘要
LeetCode 3. 无重复字符的最长子串(Longest Substring Without Repeating Characters) 是滑动窗口题目的经典入门题。题目本身不复杂,但真正容易卡住人的地方并不是代码长度,而是几个关键理解点:
- 为什么"遇到重复字符就整段重开"会漏答案
- 为什么这题不能简单地把每一段无重复字符存起来比较
- 为什么标准解法一定要维护一个动态窗口
- 为什么代码里必须写
map.get(c) >= left - 为什么少了这段判断,在
"abba"这种例子中会错误输出 3 而不是 2
这篇文章会从错误思路出发,一步一步推导到标准解法,并重点讲透这道题最容易被忽视、但又最关键的一句判断逻辑。
目录
文章目录
- [LeetCode 3. 无重复字符的最长子串:从错误直觉到滑动窗口,彻底讲透为什么必须判断 `map.get(c) >= left`](#LeetCode 3. 无重复字符的最长子串:从错误直觉到滑动窗口,彻底讲透为什么必须判断
map.get(c) >= left) -
- 摘要
- 目录
- 一、题目描述
- 二、这道题真正的难点是什么
- 三、为什么"遇到重复就整段重开"不对
- [四、为什么 `Map<List<Character>, Integer>` 这种设计不适合这题](#四、为什么
Map<List<Character>, Integer>这种设计不适合这题) -
- [1. 题目不需要保存所有子串](#1. 题目不需要保存所有子串)
- [2. `List` 作为 `HashMap` 的 key 很危险](#2.
List作为HashMap的 key 很危险)
- 五、正确思路:滑动窗口
-
- [1. `left`](#1.
left) - [2. `i`](#2.
i)
- [1. `left`](#1.
- 六、标准代码
- 七、代码逐行详解
-
- [1. 定义哈希表](#1. 定义哈希表)
- [2. 定义左边界](#2. 定义左边界)
- [3. 遍历字符串](#3. 遍历字符串)
- [4. 遇到重复字符时,移动左边界](#4. 遇到重复字符时,移动左边界)
- [5. 更新字符最近出现位置](#5. 更新字符最近出现位置)
- [6. 更新最大长度](#6. 更新最大长度)
- [八、为什么必须写 `map.get(c) >= left`](#八、为什么必须写
map.get(c) >= left) - [九、为什么少了这句判断,`"abba"` 会错算成 3](#九、为什么少了这句判断,
"abba"会错算成 3) -
- 正确写法的过程
- [i = 0,字符 `'a'`](#i = 0,字符
'a') - [i = 1,字符 `'b'`](#i = 1,字符
'b') - [i = 2,字符 `'b'`](#i = 2,字符
'b') - [i = 3,字符 `'a'`](#i = 3,字符
'a')
- [十、如果没有 `map.get(c) >= left` 会发生什么](#十、如果没有
map.get(c) >= left会发生什么) - 十一、这句判断的本质到底是什么
- 十二、为什么这题一定要用滑动窗口
-
- [1. 题目要求的是一个连续子串](#1. 题目要求的是一个连续子串)
- [2. 窗口有明确合法条件](#2. 窗口有明确合法条件)
- [3. 当窗口不合法时,可以通过移动左边界恢复合法性](#3. 当窗口不合法时,可以通过移动左边界恢复合法性)
- 十三、复杂度分析
- 十四、如果字符集固定,还可以更快
- 十五、面试高频追问总结
-
- [1. 为什么"遇到重复就整段重开"不行](#1. 为什么“遇到重复就整段重开”不行)
- [2. 为什么不能只判断 `containsKey(c)`](#2. 为什么不能只判断
containsKey(c)) - [3. 为什么 `left` 不能回退](#3. 为什么
left不能回退) - [4. 为什么 `"abba"` 少了这句判断会输出 3](#4. 为什么
"abba"少了这句判断会输出 3)
- 十六、推荐的面试写法
- 十七、整道题的学习路线总结
-
- 第一步:先否定错误直觉
- 第二步:建立窗口视角
- 第三步:用哈希表记录字符最近出现位置
- [第四步:彻底理解 `map.get(c) >= left`](#第四步:彻底理解
map.get(c) >= left)
- 十八、结语
一、题目描述
给定一个字符串 s,请找出其中 不含有重复字符 的 最长子串 的长度。
示例 1:
java
输入:s = "abcabcbb"
输出:3
解释:因为无重复字符的最长子串是 "abc",所以其长度为 3
示例 2:
java
输入:s = "bbbbb"
输出:1
解释:因为无重复字符的最长子串是 "b",所以其长度为 1
示例 3:
java
输入:s = "pwwkew"
输出:3
解释:因为无重复字符的最长子串是 "wke",所以其长度为 3
二、这道题真正的难点是什么
很多人第一次做这道题时,会想到这样一种直觉:
从左到右扫描字符,先维护一个当前不重复的子串;
一旦遇到重复字符,就结束当前子串,再重新开一个新的子串。
这个想法听起来很自然,但它其实不正确。
因为这道题的关键不在于"切成很多段后取最长",而在于:
重复出现时,当前候选子串往往不是整体作废,而是只需要缩掉左边一部分。
也就是说,这道题真正考察的是:
- 如何维护一个始终合法的"无重复窗口"
- 当重复发生时,如何精确地移动左边界,而不是粗暴重开
三、为什么"遇到重复就整段重开"不对
先看一个非常经典的反例:
java
s = "dvdf"
如果按照"遇到重复就整段重开"的思路:
- 先得到
"dv",长度为 2 - 遇到第二个
'd',认为重复,于是把当前段结束 - 然后从新的
'd'重新开始,得到"df",长度为 2
最后答案会得到 2。
但正确答案其实是:
java
"vdf"
长度为 3。
为什么?
因为第二个 'd' 出现时,不是整个当前串都无效了,而是只需要把最左边那个旧 'd' 排除掉。
也就是说:
- 原窗口是
"dv" - 遇到新的
'd' - 把旧
'd'从窗口中移出 - 新窗口变成
"vd" - 再继续加入
'f' - 得到
"vdf"
这就是为什么这题不能简单"断了重开",而必须维护一个可以动态收缩的窗口。
四、为什么 Map<List<Character>, Integer> 这种设计不适合这题
有些人会想到用:
java
Map<List<Character>, Integer>
来存储若干个不重复字符列表及其长度,然后比较最长值。
这个方向本质上不太适合,原因有两个。
1. 题目不需要保存所有子串
这道题最终只关心一个结果:
java
最长无重复子串的长度
不需要把每个候选子串都保存下来。
只要能维护当前窗口,并动态更新最大值就够了。
2. List 作为 HashMap 的 key 很危险
因为 List 是可变对象。
如果把一个 List<Character> 放进 HashMap 做 key,后面又继续修改这个 List,它的内容和 hashCode() 都会变化,这会导致哈希表行为异常。
所以从数据结构设计角度看,这并不是一个合适的方向。
五、正确思路:滑动窗口
这道题最经典、最高效的做法就是:
滑动窗口 + 哈希表记录字符最近一次出现的位置
核心变量有两个:
1. left
表示当前窗口的左边界。
2. i
表示当前遍历到的字符位置,也就是窗口右边界。
再配合一个哈希表:
java
Map<Character, Integer>
记录每个字符最近一次出现的位置。
这样,当扫描到新字符时:
- 如果它没有在当前窗口中重复,就直接扩展窗口
- 如果它在当前窗口中重复了,就把
left移动到重复字符上次出现位置的后一位
整个过程始终维护一个合法的"无重复窗口"。
六、标准代码
下面给出这道题最推荐掌握的 Java 解法。
java
import java.util.HashMap;
import java.util.Map;
class Solution {
public int lengthOfLongestSubstring(String s) {
// 记录每个字符最近一次出现的位置
Map<Character, Integer> map = new HashMap<>();
// 当前窗口左边界
int left = 0;
// 最长无重复子串长度
int maxLen = 0;
// i 作为窗口右边界,从左到右遍历字符串
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 如果字符 c 之前出现过,并且它上次出现的位置仍然在当前窗口内
// 说明当前字符与窗口内某个字符重复了
// 这时需要把左边界移动到"上次出现位置 + 1"
if (map.containsKey(c) && map.get(c) >= left) {
left = map.get(c) + 1;
}
// 更新字符 c 最近一次出现的位置
map.put(c, i);
// 当前窗口长度为 i - left + 1
maxLen = Math.max(maxLen, i - left + 1);
}
return maxLen;
}
}
七、代码逐行详解
下面把这段代码彻底拆开讲清楚。
1. 定义哈希表
java
Map<Character, Integer> map = new HashMap<>();
它的作用是:
记录每个字符最近一次出现的下标。
例如扫描到某一步时,哈希表可能是:
java
'a' -> 3
'b' -> 5
'c' -> 7
表示:
'a'最近一次出现在下标 3'b'最近一次出现在下标 5'c'最近一次出现在下标 7
2. 定义左边界
java
int left = 0;
left 表示当前无重复子串窗口的起点。
需要特别记住一个性质:
left只能向右移动,绝不能向左移动。
因为窗口是不断向前滑动的。
3. 遍历字符串
java
for (int i = 0; i < s.length(); i++)
这里 i 表示当前扫描到的位置,也可以理解为当前窗口的右边界。
4. 遇到重复字符时,移动左边界
java
if (map.containsKey(c) && map.get(c) >= left) {
left = map.get(c) + 1;
}
这一句是整道题最关键的地方。
它表示:
- 当前字符
c以前出现过 - 并且它上一次出现的位置还在当前窗口内
这才说明"当前窗口内出现了重复",需要收缩左边界。
如果只是"以前出现过",但那个旧位置已经在窗口外了,就不应该影响当前窗口。
5. 更新字符最近出现位置
java
map.put(c, i);
每扫描到一个字符,都要把它最近一次出现的位置更新为当前下标。
6. 更新最大长度
java
maxLen = Math.max(maxLen, i - left + 1);
当前窗口就是:
java
[left, i]
所以长度就是:
java
i - left + 1
每一步都用它来更新最大值。
八、为什么必须写 map.get(c) >= left
这是整道题最容易出错、但也最重要的细节。
很多人会想,既然字符之前出现过,那直接:
java
left = map.get(c) + 1;
不就行了吗?
其实不行。
因为:
字符之前出现过,不代表它在当前窗口里还重复。
一个字符虽然曾经出现过,但如果它上次出现的位置已经在当前窗口左边界之外,那么它已经不属于当前窗口了,不能算重复。
所以必须额外判断:
java
map.get(c) >= left
它的含义是:
这个旧字符的位置,是否还在当前窗口里。
只有在窗口里,才是真正的重复。
九、为什么少了这句判断,"abba" 会错算成 3
下面用最经典的例子手推一遍。
java
s = "abba"
正确写法的过程
初始状态:
java
left = 0
maxLen = 0
map = {}
i = 0,字符 'a'
'a'没出现过- 放入 map:
'a' -> 0 - 当前窗口是
"a" - 长度为 1
更新后:
java
left = 0
maxLen = 1
map = {'a': 0}
i = 1,字符 'b'
'b'没出现过- 放入 map:
'b' -> 1 - 当前窗口是
"ab" - 长度为 2
更新后:
java
left = 0
maxLen = 2
map = {'a': 0, 'b': 1}
i = 2,字符 'b'
'b'之前出现过,位置是 1- 并且
1 >= left(0),说明旧'b'还在当前窗口"ab"里 - 所以发生了窗口内重复,必须移动左边界
执行:
java
left = 1 + 1 = 2
然后更新 map:
java
'b' -> 2
当前窗口变成:
java
"b"
状态变为:
java
left = 2
maxLen = 2
map = {'a': 0, 'b': 2}
i = 3,字符 'a'
'a'之前出现过,位置是 0- 但此时
0 >= left(2)不成立 - 说明这个旧
'a'已经不在当前窗口里了
当前窗口是:
java
"b"
把现在这个 'a' 加进来,得到:
java
"ba"
长度是 2。
最终答案仍然是:
java
2
这就是正确结果。
十、如果没有 map.get(c) >= left 会发生什么
假设错误写法是:
java
if (map.containsKey(c)) {
left = map.get(c) + 1;
}
还是看 "abba"。
在 i = 2 扫描到第二个 'b' 时:
left会被正确更新为 2
但到了 i = 3 扫描到 'a' 时:
- map 中
'a'的位置是 0 - 由于没有判断它是否还在窗口内
- 程序会直接执行:
java
left = 0 + 1 = 1
注意,这一步把 left 从 2 改回了 1。
这就出问题了,因为:
left本来只能右移,绝不能往左退。
结果当前窗口会被错误算成:
java
[1, 3] -> "bba"
长度是 3。
但 "bba" 明明有重复字符 'b',根本不是合法的无重复子串。
所以最终就会错误输出 3,而不是正确答案 2。
十一、这句判断的本质到底是什么
这句判断:
java
map.get(c) >= left
本质上是在问:
当前字符
c上一次出现的位置,是否还属于当前窗口?
如果答案是"是",那么它才真的构成重复。
如果答案是"否",那么这个旧字符已经被窗口甩掉了,不能影响当前答案。
所以可以把它理解成一句非常核心的话:
"出现过"不等于"当前窗口内重复"。
这就是为什么这句判断不能省略。
十二、为什么这题一定要用滑动窗口
这题之所以适合滑动窗口,是因为它满足滑动窗口题目的典型特征:
1. 题目要求的是一个连续子串
而不是任意子序列。
2. 窗口有明确合法条件
合法条件就是:
窗口中不能有重复字符。
3. 当窗口不合法时,可以通过移动左边界恢复合法性
这正是滑动窗口最擅长处理的场景。
所以,这题是一个非常标准的滑动窗口模型。
十三、复杂度分析
时间复杂度
每个字符最多被处理两次:
- 一次被右指针
i扫到 - 一次在逻辑上被左边界越过
整体时间复杂度为:
java
O(n)
其中 n 是字符串长度。
空间复杂度
哈希表最多存储字符集中的若干字符,因此空间复杂度为:
java
O(k)
其中 k 是字符集大小。
若只考虑字符串中实际出现过的字符,也可写作 O(n) 上界。
十四、如果字符集固定,还可以更快
如果题目字符集是 ASCII,可以不用 HashMap,而改用数组记录字符最后出现位置,这样速度更快。
java
import java.util.Arrays;
class Solution {
public int lengthOfLongestSubstring(String s) {
int[] last = new int[128];
Arrays.fill(last, -1);
int left = 0;
int maxLen = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (last[c] >= left) {
left = last[c] + 1;
}
last[c] = i;
maxLen = Math.max(maxLen, i - left + 1);
}
return maxLen;
}
}
这种写法的核心逻辑完全一样,只是把哈希表换成了定长数组。
十五、面试高频追问总结
1. 为什么"遇到重复就整段重开"不行
因为很多最优子串和前一个窗口是有重叠的,不是简单断开重来。例如 "dvdf" 的正确答案是 "vdf",不是 "dv" 或 "df"。
2. 为什么不能只判断 containsKey(c)
因为字符虽然以前出现过,但它上次出现的位置可能已经在当前窗口外了。只有 map.get(c) >= left 时,才是真正的窗口内重复。
3. 为什么 left 不能回退
因为 left 表示当前合法窗口的左边界,窗口是向右滑动的,一旦回退,就会把已经排除掉的非法部分重新纳入窗口,导致错误结果。
4. 为什么 "abba" 少了这句判断会输出 3
因为最后一个 'a' 的旧位置在窗口外,但程序错误地把它当成当前窗口重复字符,导致 left 被错误回退,从而把 "bba" 当成合法窗口。
十六、推荐的面试写法
如果是面试中讲解这题,推荐直接使用下面这版:
java
import java.util.HashMap;
import java.util.Map;
class Solution {
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> map = new HashMap<>();
int left = 0;
int maxLen = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (map.containsKey(c) && map.get(c) >= left) {
left = map.get(c) + 1;
}
map.put(c, i);
maxLen = Math.max(maxLen, i - left + 1);
}
return maxLen;
}
}
这版代码的优点是:
- 逻辑清晰
- 变量少
- 容易手推
- 很适合当作滑动窗口模板记忆
十七、整道题的学习路线总结
真正掌握这道题,建议按下面这个顺序理解:
第一步:先否定错误直觉
不要把它想成"遇到重复就整段结束,再重新开一段"。
第二步:建立窗口视角
把当前无重复子串看成一个动态窗口。
第三步:用哈希表记录字符最近出现位置
这样就能快速判断重复字符是否在当前窗口中。
第四步:彻底理解 map.get(c) >= left
这是整道题最重要的细节。
它的作用不是判断"字符是否出现过",而是判断"旧字符是否仍然在当前窗口内"。
十八、结语
LeetCode 3. 无重复字符的最长子串 是滑动窗口题目中的经典代表。它的难点不在代码量,而在于是否真正理解窗口的动态变化逻辑。
这道题最值得记住的,不只是那几行代码,而是下面这句话:
旧字符出现过,不代表当前窗口重复;
只有旧字符仍在当前窗口中,才需要移动左边界。
这也是为什么:
java
map.get(c) >= left
这句判断必不可少。
把这层逻辑学透之后,不只是这道题,后面很多滑动窗口题都会变得清晰很多。