【码道初阶-Hot100】LeetCode 3. 无重复字符的最长子串:从错误直觉到滑动窗口,彻底讲透为什么必须判断 `map.get(c) >= left`

LeetCode 3. 无重复字符的最长子串:从错误直觉到滑动窗口,彻底讲透为什么必须判断 map.get(c) >= left

摘要

LeetCode 3. 无重复字符的最长子串(Longest Substring Without Repeating Characters) 是滑动窗口题目的经典入门题。题目本身不复杂,但真正容易卡住人的地方并不是代码长度,而是几个关键理解点:

  • 为什么"遇到重复字符就整段重开"会漏答案
  • 为什么这题不能简单地把每一段无重复字符存起来比较
  • 为什么标准解法一定要维护一个动态窗口
  • 为什么代码里必须写 map.get(c) >= left
  • 为什么少了这段判断,在 "abba" 这种例子中会错误输出 3 而不是 2

这篇文章会从错误思路出发,一步一步推导到标准解法,并重点讲透这道题最容易被忽视、但又最关键的一句判断逻辑。


目录

文章目录


一、题目描述

给定一个字符串 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

这句判断必不可少。

把这层逻辑学透之后,不只是这道题,后面很多滑动窗口题都会变得清晰很多。


相关推荐
菜菜小狗的学习笔记2 小时前
黑马程序员java web学习笔记--项目部署(Docker)
java·笔记·学习
junnhwan2 小时前
LeetCode Hot 100——贪心算法
java·算法·leetcode
xmlhcxr2 小时前
Redis
java·数据库·redis
魑魅魍魉都是鬼2 小时前
java 的排序算法
java·算法·排序算法
gechunlian882 小时前
SpringCloud系列教程:微服务的未来(十四)网关登录校验、自定义过滤器GlobalFilter、GatawayFilter
java·spring cloud·微服务
2401_853576502 小时前
并行算法在STL中的应用
开发语言·c++·算法
晓纪同学2 小时前
ROS2 -06-动作
java·数据库·python·算法·机器人·ros·ros2
无限进步_2 小时前
【C++】字符串中的字母反转算法详解
开发语言·c++·ide·git·算法·github·visual studio
qyzm2 小时前
Codeforces Round 927 (Div. 3)
数据结构·python·算法