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

文章来源声明: 原文作者:Rain的Java大神之路; 来源站点:掘金; 原文链接:https://juejin.cn/post/7700814369023115306; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

价值在于把高频面试题拆成可背诵的思考路径与边界清单,适合大厂算法面试冲刺和想真正吃透滑动窗口模板的读者。

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

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

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

📦 本文你将白嫖到

  • 🧠 面试官视角:这道题到底在考什么?为什么“背过答案”最容易露馅?
  • 🪟 滑动窗口核心洞察:一只贪吃蛇 + 一张动图,彻底看懂 left 为什么能跳
  • 💻 三版代码默写级:HashSet 版、HashMap 版、int[128] 数组版(附花式写法)
  • 🕳️ 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. int[128] 碾压 HashMap:无装箱、无哈希冲突、缓存友好,常数级提速 3~5 倍。

🧠 核心口诀先记牢

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

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

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

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


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

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


一、面试官到底在考什么

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

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

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

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

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

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

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

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

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

image.png


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

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

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

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

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

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

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

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

image.png

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

右指针字符左边界 left窗口内容最大长度操作说明
0a0`[a]`1记录 a 的位置为 0
1b0`[a,b]`2记录 b 的位置为 1
2c0`[a,b,c]`3记录 c 的位置为 2
3a1`[b,c,a]`3a 在窗口内重复,left 跳到 0+1=1
4b2`[c,a,b]`3b 在窗口内重复,left 跳到 1+1=2
5c3`[a,b,c]`3c 在窗口内重复,left 跳到 2+1=3
6b5`[c,b]`3b 在窗口内重复,left 跳到 4+1=5
7b7`[b]`3b 在窗口内重复,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))✅ 标准答案
**int\[128\] 数组滑动窗口****O(n)****O(1)**⭐ 最优,面试加分项

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

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

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


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

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

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 记录字符最新位置(通用稳妥)

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;
}

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

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

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[] 直接拿数组下标随机访问,快一个数量级。

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

// 写法 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,这是面试官最爱追问的那个:

反例: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²)

image.png

被追问"双层逻辑为啥不是 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 时同步记录起点,最后一刀切:

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 流量控制、视频流滑动窗口重传、日志流实时去重统计等。


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

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

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 最小覆盖子串的拆解安排上 👇