面了 50 个候选人,90% 都挂在 LeetCode 3 无重复字符的最长子串:滑动窗口的 5 个致命坑

📌 本文导读|一道题吃透滑动窗口,面试官当场点头

刷题 500 道,挂在 LeetCode 3?别慌,这篇就是你的"面试急救包"。

🎯 一句话总结

从暴力 O(n³) 到滑动窗口 O(n),再到 int128 性能碾压版,我把字节/美团/滴滴高频母题拆到骨头里------审题、思路演进、三版代码、5 个致命坑、7 个连环追问、一套模板刷 5 道题,全给你整理好了。

📦 本文你将白嫖到

  • 🧠 面试官视角:这道题到底在考什么?为什么"背过答案"最容易露馅?
  • 🪟 滑动窗口核心洞察:一只贪吃蛇 + 一张动图,彻底看懂 left 为什么能跳
  • 💻 三版代码默写级:HashSet 版、HashMap 版、int128 数组版(附花式写法)
  • 🕳️ 5 个踩坑地图:abba 反例、while 写成 if、忘 fill(-1)、长度忘 +1、字符集适配
  • 🎤 7 个高频追问:不用 HashMap?中文/emoji?返回子串本身?最多 k 个重复?流式输入?
  • 🚀 面试加分话术:左指针单调不回退、摊还 O(n)、数组为什么快一个数量级
  • 📋 30 秒背诵版:面试前扫一眼,开口就是标准答案
  • 🔗 一套模板刷五道题:LC 340 / 424 / 438 / 76 / 209 换皮只改一行

👀 适合谁看

  • 正在准备大厂算法面试,尤其是字节/美团/滴滴的同学
  • 刷过 LeetCode 3 但一问边界就心虚的"背题党"
  • 想真正吃透滑动窗口模板,而不是死记硬背的进阶选手

🔥 本文最炸的 3 个点

  1. 面试官心理话:很多人背过能做出来,但一问优化、一问边界,立刻现原形。
  2. abba 反例:左指针回退的经典陷阱,一句话讲清 Math.max 栅栏为什么必须加。
  3. int128 碾压 HashMap:无装箱、无哈希冲突、缓存友好,常数级提速 3~5 倍。

🧠 核心口诀先记牢

右指针冲锋,左指针瞬移,max 防回退,数组提速度。

📖 阅读建议

先看第二、三、四部分建立骨架,再重点啃第五部分踩坑地图和第七部分高频追问。代码建议手敲一遍,面试前直接翻到"30 秒背诵版"。

💬 互动一下

你在面试中被追问过最狠的滑动窗口问题是什么?评论区聊聊。点赞过 100,我直接把 LC 76 最小覆盖子串的拆解安排上 👇

🌟 关注我,每周拆一道"看着会、一问就懵"的高频面试题,带你少背题、多拿 offer。


LeetCode 3 无重复字符的最长子串:滑动窗口这道坎,面试官真正想听的是什么

刷过题的人都知道,LeetCode 第 3 题在字节、美团、滴滴的算法轮里出现频率高得离谱,堪称滑动窗口的"母题"。可现实是:十个人里九个"背过",一问优化、一问边界,当场露馅。这篇文章我会从面试官视角,把这道题从审题、思路演进、三种解法、核心代码、踩坑地图到高频追问拆个底朝天。适合正在准备大厂面试、或者想真正吃透滑动窗口这套模板的同学。


一、面试官到底在考什么

先别急着写代码。面试官出这道题,核心考察三件事:

考察维度 具体表现 权重
🧠 算法思维 能否从暴力 O(n³) 想到滑动窗口 O(n) ⭐⭐⭐⭐⭐
💻 编码能力 双指针 + 哈希的边界处理是否干净 ⭐⭐⭐⭐
🔍 深度追问 能否回答"如果不允许用 HashMap 呢?" ⭐⭐⭐

💡 面试官心理话:这道题很多人背过能做出来,但一问优化、一问边界,立刻现原形。所以展现完整的思考路径,比直接甩出最优解重要得多。

审题三连:开口前先确认这三件事

真实面试里,我习惯先把边界问清楚,避免返工:

  • 📌 字符集范围? ASCII 还是含中文/Unicode?→ 直接决定用数组 还是 HashMap
  • 📌 返回值是长度还是子串本身? → 本题返回长度
  • 📌 空串 "" 算不算? → 返回 0

题目本身一句话就能说清:给定字符串 s,找出其中不含重复字符 的连续子串 的最大长度。两个关键词:连续 (不是子序列!)和不重复。

text 复制代码
示例:s = "abcabcbb"  → 输出 3("abc")
      s = "bbbbb"     → 输出 1("b")
      s = "pwwkew"    → 输出 3("wke",注意 "pwke" 是子序列不是子串)

看到"子串/子数组 + 连续区间 + 求最长/最短"这类题眼,条件反射就该蹦出四个字:滑动窗口 🪟。


二、思路演进:从暴力到最优,一步都不能省

面试答题的正确节奏是:先说暴力思路 → 引出滑动窗口动机 → 画图解释 left 跳跃 → 写 HashMap 版 → 主动提数组优化。这样面试官会觉得你是真的理解,而不是背题。

暴力法:枚举所有子串 → O(n³) ❌

最朴素的思路:两层循环枚举所有子串的起止位置,再用一层循环 + HashSet 判重。三层嵌套,字符串稍长就超时,面试官直接摇头。面试里提一嘴当铺垫就行,别深入写。

滑动窗口:核心洞察就一句话

如果 s[i..j] 有重复字符,那么 s[i..j+1]、s[i..j+2]... 全都有重复。 所以不需要枚举所有起点,左指针只需向右移动,永不回头。

你可以把窗口想象成一只贪吃蛇:right 是蛇头,一路向右探路吞字符;一旦吞到重复的果子,蛇尾(left)就缩回来,把肚子里那个重复的果子吐掉 🐍。整个过程中,窗口内永远保持"无重复"这个不变量。

而优化的关键差异在于:左指针不是一步一步挪,而是用哈希表/数组记录每个字符最后出现的位置,O(1) 直接跳到位。

滑动过程可视化(s = "abcabcbb")

右指针 字符 左边界 left 窗口内容 最大长度 操作说明
0 a 0 [a] 1 记录 a 的位置为 0
1 b 0 [a,b] 2 记录 b 的位置为 1
2 c 0 [a,b,c] 3 记录 c 的位置为 2
3 a 1 [b,c,a] 3 a 在窗口内重复,left 跳到 0+1=1
4 b 2 [c,a,b] 3 b 在窗口内重复,left 跳到 1+1=2
5 c 3 [a,b,c] 3 c 在窗口内重复,left 跳到 2+1=3
6 b 5 [c,b] 3 b 在窗口内重复,left 跳到 4+1=5
7 b 7 [b] 3 b 在窗口内重复,left 跳到 6+1=7

核心口诀:右指针探路扩窗口,左指针跳转缩重复;两个指针都只向右走,永不回退。 这个单调性,正是 O(n) 复杂度的根源。


三、四种解法横评:你该写出哪一版

解法 时间 空间 面试评价
暴力枚举 + HashSet 判重 O(n³) O(min(m,n)) ❌ 直接被 pass,只用来引思路
HashSet 滑动窗口(左指针逐格收缩) O(n),均摊 O(2n) O(min(m,n)) ✅ 合格,好理解
HashMap 滑动窗口(左指针跳跃) O(n) O(min(m,n)) ✅ 标准答案
int128 数组滑动窗口 O(n) O(1) ⭐ 最优,面试加分项

m = 字符集大小(ASCII 为 128),n = 字符串长度。数组法因为字符集大小固定,空间是严格的 O(1)。

很多人会问:HashSet 版已经是 O(n) 了,为什么还要优化?

因为 HashSet 版遇到重复时,左指针是一个一个 remove 往前挪(内层 while),而 HashMap/数组版用 lastIndex[c] + 1 一步跳到位,省掉了内层循环的常数开销。面试时主动讲清这一点,面试官眼睛会亮 ✨。


四、核心代码:三版都要能默写

🌱 版本一:HashSet 经典滑动窗口(保证正确性)

java 复制代码
class Solution {
    public int lengthOfLongestSubstring(String s) {
        Set<Character> set = new HashSet<>();
        int left = 0, maxLen = 0;

        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            // 🔥 只要重复,左指针就持续收缩
            while (set.contains(c)) {
                set.remove(s.charAt(left++));
            }
            set.add(c);
            maxLen = Math.max(maxLen, right - left + 1);
        }
        return maxLen;
    }
}

☕ 版本二:HashMap 记录字符最新位置(通用稳妥)

java 复制代码
public int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> last = new HashMap<>(); // 字符 -> 最近一次下标
    int left = 0, ans = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (last.containsKey(c)) {
            // ⭐ 关键:Math.max 保证左指针只进不退
            left = Math.max(left, last.get(c) + 1);
        }
        last.put(c, right);
        ans = Math.max(ans, right - left + 1);
    }
    return ans;
}

🚀 版本三:int128 数组优化(性能碾压版,大厂加分项)

字符集是 ASCII 时,用 int[128] 干掉 HashMap:无装箱拆箱、无哈希冲突、缓存友好,常数级提速 3~5 倍。

java 复制代码
class Solution {
    public int lengthOfLongestSubstring(String s) {
        // ASCII 共 128 个字符,last[c] 记录字符 c 最后出现的位置
        int[] last = new int[128];
        // ⚠️ 必须初始化为 -1,不然下标 0 会被误判为"出现过"
        Arrays.fill(last, -1);

        int left = 0, maxLen = 0;
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            // 🔥 一行合并"判重+收缩":-1+1=0 天然兼容首次出现
            left = Math.max(left, last[c] + 1);
            last[c] = right;
            maxLen = Math.max(maxLen, right - left + 1);
        }
        return maxLen;
    }
}

🎯 亮点解读 :last[c] = -1 初始化后,last[c] + 1 = 0,首次出现的字符不用写 if 分支 ,代码少一行、bug 少一个。HashMap<Character,Integer> 每次 get/put 都要把 char 自动装箱成 Character,高频调用会产生大量临时对象;字符串短看不出来,量级上 int[] 直接拿数组下标随机访问,快一个数量级。

数组版还有两种等价的"花式"写法,面试被追问时可以信手拈来:

java 复制代码
// 写法 A:存"下一次合法起始位置",初始 0 刚好对应字符串起始索引,无需判空
int[] nextValidPos = new int[128];
for (int right = 0; right < s.length(); right++) {
    char c = s.charAt(right);
    left = Math.max(left, nextValidPos[c]);
    nextValidPos[c] = right + 1;
    maxLen = Math.max(maxLen, right - left + 1);
}

// 写法 B:1-based 索引,存 right+1,用 0 表示"未出现"
int[] last = new int[128];
for (int right = 0; right < s.length(); right++) {
    char c = s.charAt(right);
    if (last[c] > left) {          // last[c] 存的是位置+1,> left 即在窗口内
        left = last[c];            // 直接就是下一位
    }
    last[c] = right + 1;
    maxLen = Math.max(maxLen, right - left + 1);
}

🚀 为什么数组更快 :HashMap 操作要走"哈希计算 → 解决冲突 → 定位桶 → 读写",O(1) 还是均摊的;数组操作就是 base + offset 直接读写。对比下来:常数因子 HashMap 大、数组极小;内存 HashMap 有对象头开销、数组只需 128×4B;缓存友好性数组完胜。

写到这如果你觉得有收获,欢迎关注我的公众号,每周都会拆一道这种"看着会、一问就懵"的高频面试题 👀。


五、踩坑地图:这五个坑,每个都刷掉过候选人

# 技术难点 常见踩坑 解决方案
1 左指针回退 遇到重复就直接移动左边界,导致窗口错误收缩(典型反例 "abba") left = Math.max(left, last[c]+1),或加 last[c] >= left 栅栏,保证左指针只前进不后退
2 while 写成 if(HashSet 版) "pwwkew" 中只删一次,窗口里还残留重复 必须 while 循环清到不重复为止
3 int\[\] 初始化忘了 fill(-1) 默认全 0,第一个字符的下标 0 被误判为"出现过" Arrays.fill(last, -1),或改用存 right+1 的 1-based 写法
4 窗口长度忘 +1 right - left 少算 1 闭区间 [left, right] 长度公式:right - left + 1
5 字符集适配 不分场景一律 HashMap 造成浪费,或 int[128] 遇到中文越界 ASCII 用数组;中文(BMP)用 int[65536] 或 HashMap;emoji 需 codePoint 版本

重点说说坑 1,这是面试官最爱追问的那个:

text 复制代码
反例:s = "abba"

right=2 遇到第二个 'b':last['b']=1 ≥ left=0 → left=2
right=3 遇到第二个 'a':last['a']=0 但 0 < left=2,不在窗口内!

❌ 若无栅栏:left 被拉回 1,窗口"回退"成 "bba",答案算成 3(错)
✅ 有 Math.max:left = max(2, 0+1) = 2,保持不动,答案 = 2(对)

🎤 面试话术:"左指针单调不回退,是整个算法 O(n) 摊还复杂度的核心保证,所以必须判 >= left 或者用 Math.max 兜底。" 这句话一出口,面试官基本就点头了。

另外一个容易被忽略的点:"存新不删旧" 。用 Map/数组存下标时,窗口外的过期记录根本不用删除------旧下标 < left 时会被 >= left 或 Math.max 天然屏蔽,比 HashSet 反复删元素优雅得多。


六、复杂度分析:为什么是 O(n) 不是 O(n²)

被追问"双层逻辑为啥不是 O(n²)"时的标准回答:左右指针都只会单向前进,绝不回退,每个字符最多被访问两次(一次进窗口、一次出窗口),总操作数 ≤ 2n,摊还下来每步 O(1)。

边界 case 也不用单独处理,循环结构天然兼容:

  • 空串 "" → 循环不执行,返回 0 ✅
  • 全重复 "bbbbb" → left 紧跟 right,返回 1 ✅
  • 单字符 → 1 ✅;全不重复 → n ✅

七、面试官连环追问:提前备好这几波

真实面试往往是"一题三问",下面这些问题我在素材里反复见到,全部整理给你:

Q1:时间复杂度还能优化到 O(log n) 吗?

不能。必须至少遍历每个字符一次才能确定最长子串,O(n) 已是理论最优。同理,能不能不用额外空间?可以,但只能退化成 O(n²)。"记住每个字符上次在哪"这个信息无法省略,O(m) 空间就是理论下界。

Q2:不用 HashMap 呢? (滴滴真考过)

就是上面的 int[128] 数组版。ASCII 场景数组访问是哈希表的数倍,无装箱、无 GC 压力。

Q3:如果是中文/Unicode 字符呢?

中文在 Java 里是单个 char(BMP 内),int[128] 不够 → 换 HashMap<Character,Integer> 或 int[65536](空间换安全)。如果是 emoji 或生僻字(增补平面),Java 的 char 是 UTF-16 码元,emoji 是代理对(2 个 char),会被拆散------必须走 codePointAt(i) 遍历 + Map<Integer,Integer>,步进用 Character.charCount(cp)。

Q4:返回最长子串本身,不只是长度?

更新 maxLen 时同步记录起点,最后一刀切:

java 复制代码
public String longestSubstringWithoutRepeating(String s) {
    int[] lastIdx = new int[128];
    Arrays.fill(lastIdx, -1);
    int maxLen = 0, maxStart = 0, left = 0;

    for (int right = 0; right < s.length(); right++) {
        left = Math.max(left, lastIdx[s.charAt(right)] + 1);
        lastIdx[s.charAt(right)] = right;
        if (right - left + 1 > maxLen) {
            maxLen = right - left + 1;
            maxStart = left;  // 同步记录起始位置
        }
    }
    return s.substring(maxStart, maxStart + maxLen);
}

Q5:如果最多允许 k 个重复字符呢?

把"重复检测"变成"窗口内字符计数 > k 时收缩左边界",用 int[128] 计数即可,骨架不变。

Q6:超大/流式输入怎么办?

本算法单遍扫描、只存 O(m) 状态,天然适配流式数据,只需把 s.charAt 换成逐字符读取。

Q7:这套思想在实际业务里用在哪里?

Redis 滑动窗口限流!Redis 用 ZSet 记录请求时间戳,窗口滑动统计请求数,本质都是"维护一个合法区间"。此外还有 TCP 流量控制、视频流滑动窗口重传、日志流实时去重统计等。


八、一套模板刷五道题:滑动窗口的迁移力

这题真正的价值不在它本身,而在它是滑动窗口的母题。记住这套骨架:

java 复制代码
int left = 0, ans = 0;
for (int right = 0; right < n; right++) {
    // 1. 把 s[right] 加入窗口(更新计数/下标)
    // 2. while (窗口不合法) 收缩 left,移出 s[left]
    // 3. ans = Math.max(ans, right - left + 1);
}

换皮题只需要改其中一两行:

变形题 LeetCode 改的是哪一行
至多含 K 个不同字符的最长子串 340 第 2 步:while (map.size() > k)
替换 K 个字符后的最长重复串 424 第 2 步:while (len - maxFreq > k)
找所有字母异位词 438 窗口定长,第 3 步改成收集结果
最小覆盖子串 76 第 2 步合法条件 + 第 3 步取 min
长度最小的子数组 209 窗口内是"和"而非字符

🎯 加分话术 :"滑动窗口的本质是单调性------右指针前进时窗口单调扩张,左指针前进时窗口单调收缩,两指针都不回退,才能把 O(n²) 降到 O(n)。这个性质在 LC 76、209 里都通用。"


九、面试现场的节奏感(附 30 秒背诵版)

最后聊聊软实力。同样的答案,讲法不同,面试官的感受天差地别:

  1. 不要上来就甩最优解:先讲暴力法的缺陷,再讲怎么想到滑动窗口,再一步步讲优化点。完整的思考路径比背代码值钱。
  2. 主动提坑点 case :主动说 abba、bbbbb 这类样例你是怎么处理的,以及数组和 HashMap 的选型差异。
  3. 主动分析复杂度:讲完思路主动说时间、空间复杂度以及推导原因,常规加分项。
  4. 写完主动跑样例 :口头过一遍 abcabcbb、pwwkew、abba 的执行流程,自证正确性。

30 秒背诵版(建议内化后用自己的话说):

🗣️ "我用滑动窗口,left/right 两个指针。right 一路向右扩展,同时用 Map 记录每个字符最后一次出现的下标。当 right 遇到的字符上次出现位置在当前窗口内(即 last[c] >= left),说明冲突了,就把 left 直接跳到 last[c] + 1。全程维护 right - left + 1 的最大值。因为两个指针都只向右走不回退,每个字符最多被访问两次,时间 O(n),空间是字符集大小。两个细节:一是必须加 >= left 判断,否则 abba 这种用例左指针会回退;二是字符范围确定时用 int[128] 数组代替 HashMap,避免自动装箱的开销。"


写在最后

这道题浓缩成一句话:滑动窗口的本质,是用"窗口内无重复"这个不变量,把三重循环降为一次遍历;关键细节是防回退。

记忆口诀送给大家:右指针冲锋,左指针瞬移,max 防回退,数组提速度。

落地建议:今天就把三个版本(HashSet、HashMap、int 数组)各默写一遍,然后顺着 LC 340 → 424 → 438 → 76 → 209 这条线刷下去,滑动窗口这一整块就拿下了。更多的面试题拆解我会持续更新在公众号,点个关注不迷路 🙌。

互动一下:你在面试中被问到滑动窗口时,被追过的最狠的一个问题是什么?评论区聊聊,点赞过 100 我就把 LC 76 最小覆盖子串的拆解安排上 👇

相关推荐
Rain的Java大神实战圈4 天前
面了上百人,我最爱问的LeetCode 1「两数之和」:能背代码的很多,能讲清“先查后存”的没几个
算法198题