手撕算法这四个字,几乎每个面过试的人都懂。白板或共享屏幕上只有一道题,没有自动补全,没有编译器帮你兜底,你需要在十几分钟里写出能跑、能把思路讲明白的代码。而在所有“被撕频率”最高的题目里,滑动窗口绝对是排在第一梯队的东西——尤其是3. 无重复字符的最长子串和438. 找到字符串中所有字母异位词,这两道题我见得太多次出现在面试题单里了,从字节到微软,从实习到社招,换着花样考。
这两道题放在一起刷,价值远大于“多做了两道题”。它们一个是不定长窗口,一个是定长窗口,一个是找最长,一个是找所有匹配起点,恰好覆盖了滑动窗口最核心的两种用法。把这两道吃透,你就能顺手解决最小覆盖子串、字符串排列、最长连续不重复子序列这些变体。这篇文章我会从暴力解法的死穴讲起,逐步拆到滑动窗口的直觉来源和标准写法,再把438题那个“用计数数组替代哈希表”的细节给你掰开揉碎,最后补上我自己刷这些题时踩过的坑,以及面试时怎么一步步把思路讲给面试官听。
1. 这两道题为什么必须放在一起刷
刷LeetCode最怕的不是题难,而是刷完就忘。今天会了第3题,明天碰到438题又觉得是“新题”,其实这两道题的底层逻辑是同一套东西,只不过换了层皮。把它们放在一起对比着学,远比单独刷十道类似的题更高效。
1.1 “连续子串”问题的共同死穴
先看这两道题的共同点:都是在一个字符串里找连续的子串,都要求时间复杂度尽量接近O(n)。如果你没用过滑动窗口,第一反应大概率是暴力枚举。
第3题暴力做法是枚举所有子串,再判断每个子串内部有没有重复字符,复杂度O(n²)甚至O(n³)。第438题更惨,枚举s中所有长度为len(p)的子串,再逐个比较每个子串的字符频次,复杂度O(n·m),其中m是p的长度。一旦字符串长度上到十万级别,这种写法直接超时。
这就是“连续子串”类问题的死穴:相邻子串之间有大量重叠信息,暴力解法把这些信息全浪费了。比如第438题里,窗口从[0, 3)滑到[1, 4),只多了一个字符、少了一个字符,你却重新统计了一遍全部字符出现次数,这不是白干活吗?
1.2 滑动窗口的直觉:不该从头再来
滑动窗口想解决的问题只有一个——别反复从头扫描。
想象你拿着一把尺子在字符串上量长度。尺子右边每向右移动一格,左边不一定动;当窗口内的状态不满足要求时,左边才被迫收缩。整个过程里,你只需要维护窗口两端的指针和一份“窗口当前状态”的记录,就能做到每个字符最多被进出窗口各一次,整体复杂度降到O(n)。
第3题和第438题恰好演示了滑动窗口的两种典型节奏:
- 第3题是可变窗口:右指针不断右移,遇到重复字符时左指针跳到正确位置,窗口的长度动态变化,每次移动后更新答案。
- 第438题是定长窗口:窗口长度固定为
len(p),右指针每右移一格,左指针也必跟着右移一格,窗口像一条固定长度的履带向前滚动,每次移动后检查窗口内容是否合法。
这两种节奏只要都亲手写过一遍,以后看到“子串”“连续”“最长/最短/所有位置”这些关键词,脑子里自然会浮现出左右指针的轮廓。
1.3 一个模板吃透一大类
我自己刷了三百多道题之后总结出一个经验:大部分滑动窗口题都能用同一套模板框架稳住,再按题目微调。先记这个骨架:
初始化左右指针 left = 0, right = 0 初始化窗口状态(哈希表/数组/计数器) 初始化答案 while right < len(s): 把 s[right] 纳入窗口状态 while 窗口不满足条件: 把 s[left] 移出窗口状态 left += 1 更新答案 right += 1第3题就是在“窗口不满足条件”这步做文章,第438题则是在“更新答案”这步做文章。你把这套骨架刻在脑子里,下面两道题的所有代码都只是往里面填细节。
2. 第3题:无重复字符的最长子串——窗口收缩的时机是核心
这道题是滑动窗口的人门题,也是面试官最爱拿来“热场”的题。它本身不难,但想一遍写对并不容易,因为窗口收缩的逻辑里藏着两个非常微妙的边界点。
2.1 题目本质:最长连续区间
题目要求:给定字符串s,找出其中不含重复字符的最长子串长度。
什么叫“子串”?必须是连续的。"abc"的子串是"a", "ab", "abc", "b", "bc", "c",但"ac"不是子串,它是子序列。这个区分很重要,因为“连续”正是滑动窗口能用的前提。
输入"abcabcbb",答案是3,因为最长无重复子串是"abc",长度3。输入"bbbbb",答案是1。输入"pwwkew",答案是3,对应"wke"或"kew"。
2.2 关键突破口:用哈希表记录每个字符最近出现的位置
网上很多解法用Set或数组来“判断字符是否出现过”,但真正优雅的做法是记录字符最近一次出现的下标。为什么?因为窗口收缩时你需要知道左指针到底该跳到哪。
举个具体例子。s = "abba",右指针走到第3个字符'a'时,发现'a'之前出现过,而且位置是0。这时候左指针应该跳到哪里?如果你只知道“出现过”,你可能会把左指针跳到1,那就错了——因为窗口内[1, 3]是"bb",显然不对。正确做法是让左指针直接跳到上一次出现位置的下一个位置,即0 + 1 = 1。
等一下,这里有个陷阱。"abba"在第2个字符时,左指针已经因为重复的'b'从0跳到了2(或者写成跳到上次'b'出现位置的下一位,即1 + 1 = 2)。到第3个字符'a'时,'a'上次出现的位置是0,但0已经在窗口外面了,你还能把左指针跳到0 + 1 = 1吗?不能!因为那会让左指针回退,窗口就乱套了。
这就是这道题第一个关键点:左指针只能往右走,不能回退,所以要取max(左指针当前位置, 上次出现位置 + 1)。
2.3 完整的解题步骤
有了上面这个认知,代码其实就三部分:
- 右指针从头到尾遍历字符串,记录每个字符最近一次出现的位置。
- 遇到重复字符时,根据“上次出现位置 + 1”和当前左指针位置取较大值,更新左指针。
- 每次迭代都计算
right - left + 1,维护一个最大值作为答案。
Python代码:
def lengthOfLongestSubstring(s: str) -> int: char_index = {} left = 0 ans = 0 for right, ch in enumerate(s): # 如果字符出现过,并且出现位置还在窗口内 if ch in char_index and char_index[ch] >= left: left = char_index[ch] + 1 # 记录/更新字符最近出现位置 char_index[ch] = right # 更新最长长度 ans = max(ans, right - left + 1) return ansJava版本:
class Solution { public int lengthOfLongestSubstring(String s) { Map<Character, Integer> lastIndex = new HashMap<>(); int left = 0; int ans = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); if (lastIndex.containsKey(c) && lastIndex.get(c) >= left) { left = lastIndex.get(c) + 1; } lastIndex.put(c, right); ans = Math.max(ans, right - left + 1); } return ans; } }char_index[ch] >= left这个判断就是防止左指针回退的保险栓。有了它,你可以放心地处理"abba"这种左指针已经右移过的场景。
2.4 为什么使用数组替代哈希表
如果字符串只包含ASCII字符,也就是256个以内的字符集,可以用长度256的数组替代哈希表,速度更快,更适合面试手撕。
def lengthOfLongestSubstring(s: str) -> int: last = [-1] * 256 left = 0 ans = 0 for right, ch in enumerate(s): idx = ord(ch) if last[idx] >= left: left = last[idx] + 1 last[idx] = right ans = max(ans, right - left + 1) return ans数组的优势是没有哈希冲突、没有装箱开销,在字符集固定的场景下是更好的选择。但要注意,如果题目没说明字符集范围,用哈希表更稳妥。面试时可以先问一句“字符集是ASCII还是Unicode”,这既是专业性的体现,也能帮你决定数据结构。
2.5 这道题最常见的三个边界错误
我批改过很多人的代码,发现错误集中在这三个地方:
错误一:更新左指针时忘记取max。直接写left = last[ch] + 1,处理"abba"这种用例时左指针回退,答案直接错。这是最经典的错误,没有之一。
错误二:更新字符位置放在判断之前。先记录char_index[ch] = right再判断重复,会导致判断时字符位置已经是当前位置,char_index[ch] >= left永远成立,左指针每次都跳到right + 1,窗口永远长度为0。
错误三:循环结束后才更新答案。漏掉最后一个窗口的统计。比如字符串"abc",如果只在收缩时才更新答案,最后返回0。正确思路是每次右指针移动后都更新一次答案,因为窗口可能随时变大,最大值不会只在收缩时出现。
3. 第438题:找到字符串中所有字母异位词——定长窗口的另类玩法
第3题如果你写顺了,第438题其实很难做错,但前提是你得扭转一个思维定式:前面是窗口长度变化,这里窗口长度从头到尾都是固定的。
3.1 题目本质:窗口长度固定为len(p)
题目要求:给定字符串s和p,返回s中所有p的异位词的起始索引。异位词指字母相同、排列不同的字符串。
比如s = "cbaebabacd", p = "abc",输出[0, 6]。位置0的子串"cba"是"abc"的异位词,位置6的子串"bac"也是。而位置2的"aeb"虽然有三个字符,但字符集不同,不是异位词。
暴力做法:枚举s中所有长度等于len(p)的子串,对每个子串统计字符频次,和p的频次比对。时间O(n·m),空间O(1)或O(m)。这当然能过小数据,但面试官一定会追问“能不能优化到O(n)”。
3.2 核心思想:字符频次数组
字母异位词的判断不需要排序,只需要比较两个字符串中每个字符出现的次数是否相等。因为题目明确给的是小写字母(LeetCode原题限制s和p只包含小写英文字母),你可以直接用长度26的整数数组,下标0对应'a',1对应'b',依此类推。
用p_count保存p的字符频次,用s_count维护当前滑动窗口的字符频次。每次窗口滑动一格,更新频次后比较s_count和p_count是否相等。相等就是答案之一。
问题来了:每次都比较整个长度26的数组,复杂度是O(26·n),常数项比较大。26是常数,所以理论上这依然是O(n),但面试中如果你直接说“我每次都比较整个数组”,面试官通常会追问“能不能优化这个比较”。
3.3 优化方案:用一个计数器避免全量比较
用一个整数matched记录当前窗口内有多少个字符的频次已经和p完全一致。当matched == 26时,说明26个字符的频次全部一致,窗口就是一个异位词。
维护规则:
- 右指针纳入新字符
c时,s_count[c]++。如果p_count[c] > 0并且p_count[c] == s_count[c],说明字符c的频次正好对齐了,matched++。 - 左指针移出旧字符时(定长窗口必须同步左移),如果
p_count[旧字符] > 0并且移动前s_count[旧字符] == p_count[旧字符],说明这个字符本来是对齐的,移出后就不对齐了,matched--。
这个技巧在“最小覆盖子串”题里也是核心,学会一次,到处用。
3.4 标准解法代码
def findAnagrams(s: str, p: str) -> List[int]: if len(s) < len(p): return [] p_count = [0] * 26 s_count = [0] * 26 for ch in p: p_count[ord(ch) - ord('a')] += 1 res = [] matched = 0 n, m = len(s), len(p) for right, ch in enumerate(s): idx = ord(ch) - ord('a') s_count[idx] += 1 if p_count[idx] > 0 and s_count[idx] == p_count[idx]: matched += 1 # 窗口长度超过m时,左指针需要右移 if right >= m: left_ch = s[right - m] left_idx = ord(left_ch) - ord('a') # 移动前如果这个字符是对齐的,移动后就不对齐了 if p_count[left_idx] > 0 and s_count[left_idx] == p_count[left_idx]: matched -= 1 s_count[left_idx] -= 1 # 如果所有字符频次都对齐,且窗口长度正好为m if matched == 26 and right >= m - 1: res.append(right - m + 1) return res这里有一点必须注意:matched统计的是“有多少个字符的频次完全相等”,所以即使p没有某个字符,比如u,只要窗口内u的频次不为0,u永远不会被算进matched。最终只有matched == 26才能说明整个窗口的每个字符频次都和p一致。
不过你可能会想:p只包含某些字符,比如"abc",那窗口里出现了'x',matched并不会因为'x'变成不对齐,对吧?确实不会,但matched == 26这个条件本身就要求所有26个字符频次都对齐,而'x'出现在窗口里会导致s_count['x'] > p_count['x'] = 0,它永远也到不了对齐状态,所以matched永远凑不满26。这就是为什么这个方案是安全的。
3.5 进一步简化:更直观的等长滑动
有一个更朴素的写法,不用matched,而是直接在遍历时维护一个定长窗口,然后比较数组:
def findAnagrams(s: str, p: str) -> List[int]: n, m = len(s), len(p) if n < m: return [] p_count = [0] * 26 window_count = [0] * 26 for ch in p: p_count[ord(ch) - ord('a')] += 1 res = [] for i in range(n): # 加入右边新字符 window_count[ord(s[i]) - ord('a')] += 1 # 移除左边旧字符,保证窗口长度为m if i >= m: window_count[ord(s[i - m]) - ord('a')] -= 1 # 窗口长度正好为m时检查 if i >= m - 1 and window_count == p_count: res.append(i - m + 1) return res这个写法看起来更直白,每次比较长度26的数组。面试时如果对matched维护不够熟悉,我建议你写这种直观版本,把思路说清楚比追求最优常数更重要。等面试官追问“能不能优化”,你再引出matched这个优化点,反而能展示你的深度。
3.6 第3题和第438题的对照表
对比维度 第3题 第438题 窗口长度 动态变化 固定为len(p) 左指针移动时机 遇到重复字符 每前进一格都移动 左指针跳转规则 跳到上次重复位置+1或保持 固定+1 核心数据结构 哈希表记录最后位置 计数数组记录频次 记录答案时机 每次移动都更新最长长度 窗口长度合法时判断4. 从两道题提炼一套可复用的滑动窗口方法论
很多刷题的人有个误区:题目刷得多,但都是“背代码”,换个变体就不会了。如果你能停下来把第3题和第438题的共性抽出来,碰到下一道滑窗题就会轻松很多。
4.1 四步走:牢牢记住这个思考框架
我给自己总结的滑窗四步是:
- 初始化:确定窗口用什么数据结构维护状态(哈希表、数组、计数器),初始化左右指针。
- 扩展右边界:右指针每走一步,把新字符纳入窗口状态,更新对应的计数或位置。
- 收缩左边界:根据题目条件决定左指针移动规则。可变窗口是“不满足条件就收缩”,定长窗口是“每走一步就收缩”。
- 更新答案:答案的更新时机千变万化——有的在收缩后更新,有的在每次移动后更新,有的在恰好满足某个条件时更新。第3题是每步都更新,第438题是窗口长度合法时判断。
这套框架最难把握的是第3步和第4步的配合。我的建议是做题时先问自己三个问题:
- 窗口内维护的信息是什么?是“有没有重复”,还是“频次是否匹配”?
- 什么时候左指针必须移动?是发现了冲突,还是窗口超长?
- 什么时候更新答案?是最值比较,还是条件判断?
这三个问题的答案,基本上就能确定代码骨架长什么样。
4.2 可变窗口 vs 定长窗口:写法差异的根源
第3题和第438题最大的不同,在于左指针的移动时机。
可变窗口的核心是while循环收缩:右指针不断加入字符,一旦窗口状态非法(有重复字符),就持续收缩左指针直到合法。你在外面套一层while,而不是写一个if,这是为了防止收缩一次还不够的情况。
第3题用if也能过,是因为记录“上次出现位置”的做法直接确定了左指针应该跳到的位置,不需要循环试探。但对很多其他滑窗题(比如“无重复字符的最长子串”用Set实现那种写法),就必须用while。面试时我建议你在第3题也用while加Set实现再讲一遍,能让面试官看到你对两种模型的理解。
定长窗口的核心是右指针移动一步,左指针也必然移动一步,保持窗口长度为固定值。所以在遍历循环里,你既要做“纳入右边新字符”的操作,也要做“移除左边旧字符”的操作。
4.3 同一套模板能解决哪些衍生题
把这两题吃透后,下面这些题看起来就会非常亲切:
- 76. 最小覆盖子串:可变窗口,暴力滑到覆盖所有目标字符后收缩左边界找最短。
- 567. 字符串的排列:和438题一模一样,只是要求返回布尔值。
- 424. 替换后的最长重复字符:可变窗口,但不是用窗口内字符是否重复做条件,而是用“窗口长度减最多出现字符数是否超过k”做收缩条件。
- 1004. 最大连续1的个数 III:和424题几乎同构,把“最多可翻转0的个数”换成“最多允许的0的个数”。
- 3. 无重复字符的最长子串本身就是最基础的模板。
做这些题的时候,你甚至可以先用第3题的模板跑一遍,改改收缩条件,再对照第438题的模板跑一遍,改改答案更新逻辑。多对比几次,滑动窗口就不再是“玄学”了。
5. 刷题过程中的真实踩坑与面试表现建议
这部分是我实际刷题、面试和帮别人review代码时攒下的经验,常规题解里很少写。
5.1 写代码时的常见错误
第一,左右指针的初始值不统一。我见过很多人把left初始化为0,right初始化为1,导致代码里到处都是right - left然后还要加1减1的补丁。建议统一写法:left = 0, right = 0,循环遍历right从0到len(s)-1,窗口区间用左闭右开[left, right)或左闭右闭[left, right]都行,但一旦选定就全程保持一致。
第二,字符频次数组的下标越界。438题里ord(ch) - ord('a')很好写,但如果你在循环里重复写这个表达式,很容易打错。建议循环开始前先把索引算出来存到变量里。
第三,忘记处理空串或len(s) < len(p)的边界。第3题空串返回0,第438题s长度小于p时直接返回空列表。这些边界条件在面试代码里一定要在一开始就处理掉,否则跑测试用例时立刻露馅。
第四,把哈希表的“存在性判断”和“位置判断”混在一起。第3题里if ch in char_index这个判断不能单独使用,必须加char_index[ch] >= left的窗口内判断。原因前面说过了,字符可能出现在窗口外面,不能因为“以前出现过”就收缩窗口。
5.2 复杂度分析不能只背结论
这两道题的时间复杂度都是O(n),空间复杂度O(1)(固定字符集时)或O(字符集大小)。但面试官可能会追问“为什么是O(n),不是O(n·m)?”
关键在于:每个字符最多被右指针访问一次,被左指针访问一次。在右指针的循环里,左指针虽然也会移动,但总移动次数不会超过n次,因为左指针永远不会回退。所以总操作次数是2n量级,线性复杂度。
这一点和第3题里“为什么左指针取max就能防止回退”是同一个道理。你把它讲清楚,面试官心里会给你加分。
5.3 面试时怎么展示思路
手撕算法时,面试官看的不仅是代码对不对,还有你思考问题的方式。我建议按这个顺序讲:
- 先说暴力解:告诉面试官“我可以先枚举所有子串,但这样复杂度太高,O(n²)以上”。这证明你能独立想出基础解法。
- 再说滑动窗口的由来:“我发现在遍历过程中,很多信息是重复计算的,相邻子串之间只差一个字符,所以可以用两个指针维护一个窗口,状态只增删一个字符。”
- 然后给出代码骨架:先写
while right < len(s)的外层循环,再解释窗口状态怎么维护,最后说明答案更新逻辑。 - 最后补边界:自动提出“如果
len(s) < len(p)直接返回空”、“空串返回0”等边界处理,这比面试官问了你再补要强得多。
5.4 刷完这两题后,建议顺手做的小练习
如果你今天刚把这篇文章里两题都手写过,建议立刻做三件事:
- 用Set加while循环重新写一遍第3题,感受一下“条件收缩”和“直接跳转”两种写法的差异。
- 把438题的代码改造成567题“字符串的排列”,只需要改返回值类型。
- 尝试用第3题的思路去写76题“最小覆盖子串”,你会发现除了收缩条件复杂一点,整体框架完全没变。
做完这三个练习,你对滑动窗口的理解会比单独刷十道题还深。
我个人在实际刷题过程中的体会是:滑动窗口不是一个需要死记硬背的算法,它更像是一种“复用已经扫过的信息”的思维方式。第3题和第438题之所以经典,是因为它们恰好把这种思维用两种最典型的方式展现了出来——一个教你动态收缩左边界,一个教你固定窗口滚动。把这两道题彻底吃透,后面整个滑窗题族都会变得非常顺。最后再分享一个小技巧:刷题时别急着看题解,先用笔在纸上画出"abba"或"cbaebabacd"的窗口滑动过程,每次脱手写代码前都先画一遍,坚持一段时间,你对指针的掌控感会有质的提升。