刷题 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 个点
- 面试官心理话:很多人背过能做出来,但一问优化、一问边界,立刻现原形。
- abba 反例:左指针回退的经典陷阱,一句话讲清 Math.max 栅栏为什么必须加。
- 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" 是子序列不是子串)
看到"子串/子数组 + 连续区间 + 求最长/最短"这类题眼,条件反射就该蹦出四个字:滑动窗口 🪟。
二、思路演进:从暴力到最优,一步都不能省
面试答题的正确节奏是:先说暴力思路 → 引出滑动窗口动机 → 画图解释 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)) | ✅ 标准答案 |
| **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²)
被追问"双层逻辑为啥不是 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 秒背诵版)
最后聊聊软实力。同样的答案,讲法不同,面试官的感受天差地别:
- 不要上来就甩最优解:先讲暴力法的缺陷,再讲怎么想到滑动窗口,再一步步讲优化点。完整的思考路径比背代码值钱。
- 主动提坑点 case:主动说
abba、bbbbb这类样例你是怎么处理的,以及数组和 HashMap 的选型差异。 - 主动分析复杂度:讲完思路主动说时间、空间复杂度以及推导原因,常规加分项。
- 写完主动跑样例:口头过一遍
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 最小覆盖子串的拆解安排上 👇
价值在于把高频面试题拆成可背诵的思考路径与边界清单,适合大厂算法面试冲刺和想真正吃透滑动窗口模板的读者。