news 2026/10/2 9:36:21

LeetCode子串专题四题精讲:滑动窗口、前缀和与单调队列套路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode子串专题四题精讲:滑动窗口、前缀和与单调队列套路

如果你打开 LeetCode 的 Hot 100 题库,切到“分类”视角,会看到里面有个特别有意思的分组:子串。别的分组动辄十几二十道题,这个分组只有 4 道题,但刷过的人都知道,这 4 道题几乎覆盖了字符串和数组里所有跟“连续区间”相关的考法。我身边不少朋友刚刷到这一组时,觉得“4 道题不是分分钟搞定”,结果一上来就被 239 滑动窗口最大值和 76 最小覆盖子串卡了两三天。这篇文章就把这 4 道题揉在一起讲:每道题的思路是怎么来的、标准代码怎么写、哪里容易写崩,以及面试时面试官会怎么变着法子延伸。

这篇文章适合三类人:正在按 Hot 100 刷题准备面试的求职者、学过基础数据结构但还没形成“套路感”的中级学习者,以及那些已经二刷三刷、却依然对滑动窗口边界细节模棱两可的人。子串这个专题讲究“套路成型、细节量多”,把这 4 题吃透,后面遇到任何连续区间、子数组、覆盖类问题,你至少能立刻想到两条清晰的路径。

1. 子串专题的整体认知:为什么只放这 4 道题

1.1 子串题到底在考什么

子串、子数组、区间,这三类问题本质上是一回事:在给定的线性序列里,找一个满足某种条件的连续片段。这里的“连续”是关键词,它决定了你能用哪些方法,也决定了暴力解的天花板。

如果没有“连续”这个限制,很多题会变成排列组合问题,复杂度直接爆炸。而正是因为连续,我们才能用前缀和来快速计算任意区间的总和,用滑动窗口来维护一个不断变化的区间状态,用单调队列来高效查询窗口内最值。换句话说,子串题考察的不是某个高深数据结构,而是你对“连续区间状态维护”这件事的熟练度。

面试里子串题之所以高频,是因为它很能区分基本功:写出来不难,但写出能在边界条件下不崩、能分析清楚复杂度、能面对变体迅速切换思路的人,才是面试官想要的。

1.2 四道题的分工:一套组合拳

这 4 道题在 Hot 100 里是固定组合,我按刷题顺序排列如下:

题目核心考点主用数据结构刷题价值
560. 和为 K 的子数组前缀和 + 哈希表HashMap建立区间和的快速计算意识
438. 找到字符串中所有字母异位词固定窗口 + 频次比较频次数组/哈希表理解定长窗口的滑入滑出
76. 最小覆盖子串双指针 + 可变窗口哈希表 + 计数器掌握窗口收缩的时机
239. 滑动窗口最大值单调队列ArrayDeque窗口内最值的在线维护

这个顺序不是随便排的。560 告诉你“连续区间求和”可以不用每次都重新算,而是通过前缀和做 O(1) 查询;438 则把“连续区间”这个概念搬到字符串上,而且窗口长度是固定的,相对好想;76 在 438 的基础上把窗口变成可变,难度提升一截,但思路仍然是“先扩后缩”;239 更进一层,它不再只统计数量、频次,而是要维护窗口的极值,必须引入专门的结构。

如果你按这个顺序刷,会发现每个新题都是上一个思路的变体,而不是凭空冒出新东西。

1.3 一个让我很意外的点

我第一次刷完这组题后,最大的感触是:并不是每道题都能用滑动窗口。560 那题很多人一看是“连续子数组”,就想用双指针滑动窗口,结果死活做不对,因为数组里有负数,窗口和不存在单调性。这个问题我后文会详细讲,但先记住一句话:看到连续子数组,第一反应不应该是滑动窗口,而是先判断数据是否支持窗口收缩。这个判断错误的代价,我帮不少朋友排查过,基本都能卡一晚上。

2. 四道题逐题拆解:思路、代码与易错点

2.1 560. 和为 K 的子数组:前缀和的魔法

题目要求很简单:给一个数组nums和一个整数k,问有多少个连续子数组的和等于k。

暴力做法是枚举每个起点 i 和终点 j,然后用一个循环累加i..j的和,复杂度 O(n³)。稍微优化一下,可以在枚举终点时累加,做到 O(n²)。但这里 n 的范围动辄 2 万甚至更多,O(n²) 在面试中绝对过不了,必须想 O(n) 的办法。

这里的关键转换是前缀和。定义pre[i]表示数组前 i 个元素的和,那么子数组nums[j..i]的和就等于pre[i+1] - pre[j]。于是问题变成了:找有多少对 (j, i),满足pre[i] - pre[j] == k,也就是pre[j] == pre[i] - k。

你看,原来的问题是对“连续片段”求和,现在变成了对“前缀和数组”做统计。我们可以一遍遍历,用哈希表记录每个前缀和出现的次数;每到一个位置 i,查一下哈希表里有多少个pre[i+1] - k,累加入答案,同时把当前前缀和的出现次数加一。

给出 Java 实现:

import java.util.HashMap; import java.util.Map; public int subarraySum(int[] nums, int k) { Map<Integer, Integer> count = new HashMap<>(); // 前缀和为0时出现1次,代表空前缀 count.put(0, 1); int pre = 0; int ans = 0; for (int num : nums) { pre += num; // 当前前缀和 pre,要找目标 pre - k ans += count.getOrDefault(pre - k, 0); count.put(pre, count.getOrDefault(pre, 0) + 1); } return ans; }

这里有几个极其容易写错的点。

第一,count.put(0, 1)不能省。假设nums = [1, 2, 3],k = 3,遍历到pre = 3时,我们想找子数组从下标 0 开始的整个区间[1,2],它对应的是pre[j] == 0的那个空前缀。如果不初始化 0,你永远统计不到从头开始的子数组。

第二,先查询再更新,顺序不能反。如果你先把当前前缀和放进哈希表,再查询pre - k,那么当k == 0时,你会把“当前这个位置本身”也当成一个答案,多算一次。举个小例子:nums = [1],k = 0,正确结果是 0,但先更新后查询会得到 1。

第三,这道题不能用滑动窗口。因为数组里有负数,当窗口右端扩展使和变大时,左端收缩却不一定使和变小,可能负数让和继续增大,也可能正数让和继续增大,窗口和没有单调性,你没法决定何时收缩。很多人第一反应是“和大于 k 就收缩”,遇到负数就崩了。所以看到“连续子数组、定值”这类组合,优先想前缀和,而不是滑动窗口。

2.2 438. 找到字符串中所有字母异位词:固定窗口的掩护

438 这道题,说句实话,单独拿出来不难,但它是后面 76 题的跳板。题目是:给定字符串s和p,返回s中所有p的异位词子串的起始索引。所谓异位词,就是两个字符串字符种类和数量都相同,只是顺序不同。

因为异位词长度必然等于p.length(),所以窗口长度是固定的。我们只要能快速判断“当前窗口内字符频次”是否和p的字符频次一致就行。

最直观的写法是用两个长度为 26 的数组记录字符频次,一次滑动时只更新进窗口和出窗口的两个字符:

import java.util.ArrayList; import java.util.Arrays; import java.util.List; public List<Integer> findAnagrams(String s, String p) { List<Integer> res = new ArrayList<>(); int sLen = s.length(), pLen = p.length(); if (sLen < pLen) return res; int[] pFreq = new int[26]; int[] winFreq = new int[26]; for (char c : p.toCharArray()) pFreq[c - 'a']++; // 初始化第一个窗口 for (int i = 0; i < pLen; i++) { winFreq[s.charAt(i) - 'a']++; } if (Arrays.equals(pFreq, winFreq)) res.add(0); // 滑动 for (int i = pLen; i < sLen; i++) { winFreq[s.charAt(i) - 'a']++; // 右边进一个 winFreq[s.charAt(i - pLen) - 'a']--; // 左边出一个 if (Arrays.equals(pFreq, winFreq)) res.add(i - pLen + 1); } return res; }

这个解法思路清晰,复杂度 O(n * 26),几乎可以认为是 O(n)。但我在面试时更推荐另一种写法:用一个count变量来记录“已经满足条件的字符种类数”,避免每次Arrays.equals扫描整个数组。那种写法更装逼也更高效,但代码细节更多。如果面试官让你优化,你再拿出来。

用count写法时,我最常看到有人掉进的坑是:出窗口字符的更新顺序反了。比如窗口内某个字符数量刚好等于目标数量时,count要减一;但如果你先把字符数减一,再判断winFreq[d] == pFreq[d],判断大概率不成立,count就漏减了。正确顺序是:先判断是否相等、再减少计数值。

还有一个边界:如果s.length() < p.length(),直接返回空列表,不用进循环。这个判断放在最前面,能省掉不少麻烦。

2.3 76. 最小覆盖子串:滑动的精髓在于收缩

76 题是全组里最考验套路的一题。给定一个字符串s和一个字符串t,要求在s中找出包含t全部字母的最短子串。注意:t里可能有重复字符,所以“覆盖”意味着每个字符出现的次数都要满足要求。

这道题的核心是可变窗口 + 双指针:右指针不断向右扩展,直到窗口内包含了t的全部字符;然后左指针开始收缩,每次缩一个字符,看窗口是否仍然满足覆盖条件,并记录最短长度;当窗口不再满足条件时,右指针继续向右扩展。如此循环。

我用一个计数变量count表示“还剩多少个字符需要匹配”。初始时count = t.length(),每次右指针移入一个t中需要的字符,count就减一;当count == 0时说明窗口已经完全覆盖。

下面是带注释的完整实现:

public String minWindow(String s, String t) { if (s.length() < t.length()) return ""; int[] need = new int[128]; for (char c : t.toCharArray()) need[c]++; int left = 0, right = 0; int count = t.length(); int minLen = Integer.MAX_VALUE; int start = 0; while (right < s.length()) { char c = s.charAt(right); right++; if (need[c] > 0) count--; // 这个字符是 t 需要的 need[c]--; // 窗口中多了一个字符,需求减一 while (count == 0) { // 窗口已覆盖 t,尝试收缩左边界 if (right - left < minLen) { minLen = right - left; start = left; } char d = s.charAt(left); left++; need[d]++; // 窗口少了一个字符,需求加一 if (need[d] > 0) count++; // 如果这个字符变得“不够了”,重新需要匹配 } } return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen); }

这个写法初看有点反直觉:我们对need数组中每个字符都做增减,而不仅仅是t里有的字符。比如s里有字符'z',但t里没有'z',那么need['z']初始为 0,右移时变成 -1,左移时加回 0,并不会影响count的判断。这种“把窗口内所有字符都纳入统计”的思路,是很多滑动窗口模板的共同点,好处是逻辑统一,不需要额外判断字符在不在t中。

这个题的易错点有两个。

一个是count初始值到底设成什么。我看到有人按“需要满足的字符种类数”设count,而不是按字符总数。如果用字符总数,写起来直接跟t.length()挂钩,不用额外统计种类,更简洁。

另一个是左边界收缩的时机。很多人以为每个循环都要收缩,其实不是,收缩只发生在count == 0时。如果窗口还没有覆盖t,你收缩了反而可能永远无法覆盖,所以必须把“收缩”放在内层while里,并且收缩之后要继续判断,直到窗口刚好不再满足条件为止。

我在面试中遇到过面试官追问:“如果t里允许重复字符,你的解法还能直接跑吗?”这个问题其实是在考察哈希表计数的基本功。上面这个代码恰好天然支持重复字符,因为need数组记录的是频次,count记录的是剩余总需求。如果你用HashSet去重,反而会写崩。所以刷这道题的时候,一定要想清楚你记录的是“种类数”还是“总数”。

2.4 239. 滑动窗口最大值:单调队列的正确姿势

最后一题是 239,给一个数组nums和一个窗口大小k,求出每个窗口的最大值。

这题的直观做法是用优先队列(大顶堆),每次窗口移动时把新元素加进去,但问题是堆顶可能已经在窗口外了,你需要“延迟删除”。虽然能 AC,但复杂度是 O(n log k),而且延迟删除的判断很容易写错。

最优解法是单调队列,而且必须用双端队列。单调队列维护的核心思想是:窗口内所有“不可能成为最大值”的元素,直接淘汰。比如窗口里有 5、3 两个元素,现在来了个 4。3 比 4 小,而且 4 比 3 晚进窗口、活得更久,那么在 4 存在期间,3 永远不可能成为最大值,可以直接丢掉。5 虽然比 4 大,但可能比 4 先滑出窗口,所以要保留 5。

实现时,队列里存的是下标,而不是值。因为只有存下标,才能判断队首是否已经滑出窗口。

import java.util.ArrayDeque; import java.util.Deque; public int[] maxSlidingWindow(int[] nums, int k) { int n = nums.length; int[] res = new int[n - k + 1]; Deque<Integer> deque = new ArrayDeque<>(); for (int i = 0; i < n; i++) { // 队尾所有小于等于当前元素的下标全部弹出 while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) { deque.pollLast(); } deque.offerLast(i); // 队首下标超出窗口范围时弹出 if (deque.peekFirst() <= i - k) { deque.pollFirst(); } // 窗口完全形成后,每移动一次就记录一次结果 if (i >= k - 1) { res[i - k + 1] = nums[deque.peekFirst()]; } } return res; }

这里我用了<=而不是<,含义是:当新元素和队尾元素相等时,队尾元素同样没有保留价值,因为新元素更新、更晚过期,用它能撑更久。如果你写成<,理论上也能过,但队列里会多存一些相同值的旧下标,性能稍差且逻辑上不够优雅。

这个题还有一个容易被忽略的细节:队首过期判断要在记录结果之前完成。如果先记录结果再判断过期,在窗口恰好滑出队首元素的那一轮,你可能会把旧的最大值多输出一次。我自己在第一次手写这个问题时,就犯过“先取结果后清理”的错误,单测跑出来结果是[6, 5, ...],排查了半天才发现是顺序问题。

3. 实操记录:一道子串题从审题到 AC 的完整复盘

3.1 拿到题我先做什么

我不建议一上来就看题解。以 560 为例,拿到题第一步永远是确认数据范围。看一眼nums.length,如果最大只有几百,O(n²) 或许能行;如果上万,直接放弃暴力想优化。数据范围决定思路边界,这是刷 LeetCode 养成的条件反射。

然后是确认题目有没有“连续”这个限定,以及数据里有没有负数。有负数意味着滑动窗口不可用,你只能走前缀和。没有负数,很多问题可以简化,甚至可以二分,但这是另一套思路了。

3.2 从 O(n²) 到 O(n) 的思维跳跃过程

以 560 为例,我一开始写的是双层循环:

for (int i = 0; i < n; i++) { int sum = 0; for (int j = i; j < n; j++) { sum += nums[j]; if (sum == k) ans++; } }

这个枚举思想其实是“以每个起点为锚点,延伸终点”。它的问题在于每次枚举新的起点,都要重新累加,大量重复计算。前缀和存在的意义就是把“任意区间和”从 O(n) 降到 O(1)。想通了这一步,代码反而很简短。

我个人觉得,刷这类题最有价值的时刻就是完成这个跳跃的瞬间:当你能写出pre - k作为哈希表 key 去查答案时,你对“空间换时间”这句话的理解会深一层。

3.3 边界条件与测试用例

写完后,一定要在本地或编辑器里跑几个小 case。我常用的自测集合:

  • 全是正数的常规数组,比如[1, 2, 3],k=3,答案是 2([1,2]和[3])。
  • 包含负数和零的数组,比如[1, -1, 0],k=0,答案是 2([1,-1]和[0]),用来验证更新顺序。
  • 全部元素都相同的数组,比如[1, 1, 1, 1],k=2,答案是 3,用来验证重复计数。
  • 空数组和k=0的组合,用来验证初始化。

我之前见过一个很难察觉的 bug:前缀和哈希表更新和查询顺序写反了,导致k=0的时候答案总是多 1。通过上面第三个用例能立刻发现问题。

3.4 关于周赛的延伸思考

最近一次周赛里有一道子串统计题,其实就是 438 的变体:把目标字符串换成若干模式串,再要求返回起始索引。用固定的窗口长度加上频次数组,直接套模板就能过。这说明 LeetCode 的命题风格越来越偏向“经典套路 + 外壳包装”,而热门的 Hot 100 子串专题,恰好就是内核。

另外,偶尔会看到有人把“爱吃香蕉的狒狒”(Koko Eating Bananas)这道二分题和子串专题放在一起讨论。它们确实不在一个分类,但二分法里检查“当前速度是否够用”的那个循环,本质上也是一次连续区间的统计。等你把子串专题刷熟,再去做二分验证类题目,会感觉思路非常顺,因为它们共享“线性遍历 + 条件判断”的底子。

4. 常见问题与排查技巧实录

4.1 前缀和哈希表更新顺序的坑

这个前面已经提过,但值得单列出来。规律是这样:先算答案,再更新状态。对应到 560,就是先ans += count.get(pre - k),再count.put(pre, ...)。对应到 76 题,就是先判断need[c] > 0再修改count。这个原则在几乎所有滑动窗口/前缀和问题里都成立。我自己刷题时会用红色标注这个顺序,因为它是 Top 级易错点。

4.2 滑动窗口无限循环的排查

用双指针时,最常见的 bug 是右指针在某种条件下没有前进,导致死循环。正常逻辑里,外层循环每次都让right++,但如果你在收缩时把left更新成了right或者更新到超过了右边界,就会出现“左右指针互相追逐”的错乱。

排查方法是打印每一步的left、right、count值。比如最小覆盖子串,如果count初始值设置错误,内层while(count == 0)可能永远进不去,右指针走到底直接结束,返回空串。这时你要先检查count的更新逻辑,而不是去看窗口长度判断。

4.3 单调队列到底存索引还是存值

239 这道题,一定要存索引。存值的话,你无法判断队首元素是否还在窗口内。每次移动后,先清理队头过期索引,再取队头作为答案。同样地,清理队尾时用<=比用<更优,道理前面讲过。

4.4 为什么有的题不能用滑动窗口

这个属于“概念纠偏”。滑动窗口能工作的前提是窗口维护的性质具有单调性。比如“窗口内元素和最小覆盖”这种满足一定条件下可以收缩;“窗口内最大值”这种来了新元素就可以淘汰旧元素。但“子数组和恰好等于 k”这个条件不具备单调性,因为和变大变小的方向不受控制,所以只能前缀和。

面试时如果有人问“什么时候用前缀和,什么时候用滑动窗口”,可以给出一个粗糙但好记的标准:如果窗口扩大时结果一定变好,缩小时一定变差,就用滑动窗口;如果只是统计满足某个确切值的数量,首选前缀和。

4.5 面试延伸:大字符串、外排序、在线数据库

子串问题在真实业务场景里也常出现,比如日志分析里找某个时间窗口内请求量最大的时间点、监控系统里找连续报警时间最长的窗口。面试官有时会问:“如果s特别长,不能一次性读进内存怎么办?”本质上是在考你对外部存储和流式处理的理解。

对于 239,如果你面对的是流式数据,单调队列依然适用,因为每个元素只进出一次。对于 560,如果数据太大且前缀和会溢出,你可能要考虑分段哈希或者用long存储前缀和。这些超纲问题虽然不会在 Hot 100 里出现,但理解了数组/字符串的窗口维护机制,你就能在工程里迁移这套想法。

为了让你对照排查时不迷糊,我把这 4 道题最容易出错的位置汇总成一张表:

题目高频出错点一句话规避方法
560初始化漏掉map.put(0,1),或先更新后查询先查答案,再更新哈希表
438出窗口字符的计数更新顺序反了先判断相等/减 valid,再减少频次
76count初始化错误或左右指针更新时机混乱明确 count 是“剩余总需求”,只在覆盖时收缩
239队列存值、过期判断放在取结果之后存索引,先清理过期再记录答案

5. 刷完这 4 道题之后的下一步

按我个人刷题经验,这 4 道题吃透之后,可以顺手把这三类问题再过一遍:二分查找、单调栈、以及常见的“双指针 + 哈希表”组合。子串专题特别适合用来建立“连续区间状态维护”的肌肉记忆,它不会让你成为算法大师,但足以让你在多数数据结构和算法面试中,面对数组、字符串相关题时有一个非常明确的反应链:能不能用固定窗口?能不能用前缀和?需不需要维护最值?

最后分享一个我自己的习惯:我会把每道题的模板压缩成一份“最小可记忆版本”放在本地笔记里,面试前只翻这个笔记。560 记住“前缀和 + 先查后更新”,438 记住“固定窗口 + 频次数组”,76 记住“need 计数 + 右扩左缩”,239 记住“单调队列 + 存索引”。四个记忆点对应四道题,面试时遇到再复杂的包装,剥开来还是这四件事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 9:36:15

麻雀搜索算法混合策略改进:从混沌初始化到Levy飞行

1. 为什么大家都在给SSA麻雀算法“打补丁”&#xff1a;动机先想清楚我最早接触麻雀搜索算法&#xff08;Sparrow Search Algorithm&#xff0c;简称SSA&#xff09;是2020年底&#xff0c;当时被它的三层分工机制吸引——发现者、加入者&#xff08;也叫跟随者&#xff09;、侦…

作者头像 李华
网站建设 2026/10/2 9:35:50

双馈风机调频仿真:虚拟惯量与下垂控制全解析

晚上八点负荷高峰&#xff0c;电网频率一路跌到49.8Hz&#xff0c;调度电话打到风电场&#xff0c;希望风机把有功往上顶一顶。结果现场反馈很无奈&#xff1a;双馈风机正按MPPT最大功率点跟踪跑得好好的&#xff0c;转子侧变流器把转速和电网频率完全解耦&#xff0c;频率跌了…

作者头像 李华
网站建设 2026/10/2 9:35:50

Azure Resource Graph 实战:用 KQL 查询策略分配与合规状态

先从一次真实的工作经历说起。去年我在一个多订阅环境里做云治理巡检&#xff0c;管理层要求一份“当前所有策略分配执行情况”和“不合规资源分布”的汇总清单。如果用 Azure 门户自带的策略符合性仪表盘&#xff0c;一个分配一个分配地翻&#xff0c;再跨订阅比对&#xff0c…

作者头像 李华
网站建设 2026/10/2 9:33:20

双层优化解构AI鲁棒性机制:从黑箱防御到可解释建模

1. 项目概述&#xff1a;这不是在调参&#xff0c;是在解构模型的“免疫系统”“Learning the Robustness Mechanism with Bilevel Optimization”——光看标题&#xff0c;很多人第一反应是&#xff1a;“又一个带‘robustness’和‘bilevel’的论文名字&#xff0c;估计又是理…

作者头像 李华
网站建设 2026/10/2 9:32:04

COCO标注格式详解:从bbox字段到跨框架数据统一

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华