滑动窗口这套东西,几乎每个刷算法题的人都会撞上。面试里它是常客,刷题平台上的标签也总是挂着它,但真正能把滑动窗口讲明白、用明白的文章不多。我见过太多人一看到“滑动窗口”就条件反射套模板,结果窗口怎么扩张、怎么收缩、什么时候更新答案全凭感觉,写出来的代码跑几个用例就崩。这篇文章我想拿三道经典题目串起整个滑动窗口体系——从字母异位词这种固定窗口,到无重复最长子串这种变长窗口,再到“将 x 减到 0 的最小操作数”这种必须逆向思考才能联想到滑动窗口的题目。这三道题难度递进,正好覆盖了滑动窗口最常见的三种考察姿势。
顺便先说明一点:这里聊的滑动窗口是算法领域的两指针技巧,跟计算机网络里 TCP 流量控制的滑动窗口协议、信号处理里的滑动窗口滤波模型完全是两码事。虽然名字都叫“窗口”,但思路和应用场景差别很大。面试的时候如果你能把这两者的区别讲清楚,反而是个加分项。
文章适合正在准备算法面试的人、刷题刷到滑动窗口标签但总觉得没吃透的人,以及想系统整理双指针技巧的人。我会把每一步的“为什么这样做”讲透,顺便附上我实际刷题踩过的坑,争取让你看完就能自己写出来。
1. 滑动窗口到底是什么:从一次窗口滑动的物理直觉说起
1.1 为什么窗口能“滑”:窗口的本质是避免重复计算
我第一次学滑动窗口的时候,最大的困惑是:这玩意不就是两个指针吗?为什么非得起个名字叫“窗口”?
后来我意识到,窗口这个比喻的关键在于“覆盖一段连续的空间”。想象你在一串数字上开了一个长度固定的观察窗,窗内的元素组成了当前子数组。你要统计某些性质,最朴素的办法是把窗内所有元素重新算一遍。但如果你每次只移动一格,那窗口里大部分元素都没变,只是左边出去一个、右边进来一个。这时候如果你还重新全量计算,就是浪费。
滑动窗口的核心就是:利用上一次计算的结果,只处理变化的部分。左边吐出一个元素,右边吞进一个元素,维护的统计量增量更新。这样一趟走完,每个元素最多被“吞”一次、“吐”一次,总复杂度是 O(n)。
这个思想跟网络里的滑动窗口协议有个共通点——都是让“当前关注的范围”逐步推进,而不是反复从头开始。但算法里的滑动窗口更接近一种枚举连续区间的优化手段,没有确认、重传那套机制。
1.2 所有滑动窗口题都逃不出的两个框架
刷了足够多的题之后你会发现,滑动窗口其实就两种形态。
第一种是固定窗口:窗口长度从一开始就确定,比如“长度必须为 k 的子数组”“长度必须为 p.length() 的异位词”。这种题你只需要让 right 指针每次前进一格,left 也跟着前进一格,窗口像履带一样匀速平移。具体代码通常长这样:
int left = 0; for (int right = 0; right < n; right++) { // 把 nums[right] 加入窗口统计 add(nums[right]); // 窗口长度超过 k 时,移出 nums[left] 并 left++ if (right - left + 1 > k) { remove(nums[left]); left++; } // 此时窗口长度一定等于 k if (right - left + 1 == k) { // 更新答案 } }第二种是变长窗口:窗口长度不固定,right 负责扩张,left 负责在条件不满足时收缩。典型如“无重复字符的最长子串”“最小覆盖子串”。框架长这样:
int left = 0; for (int right = 0; right < n; right++) { // 把 nums[right] 加入窗口 add(nums[right]); // 不满足题目条件时,不断收缩 left while (!valid()) { remove(nums[left]); left++; } // 此时窗口是合法的,更新答案 update(); }这两种框架之间怎么选?我一般看题目问的是“固定长度的子串/子数组”还是“最长的/最短的某个子串/子数组”。前者直接上固定窗口,后者大概率要变长窗口。当然也有例外,比如减零问题,窗口长度也是不固定的,但它考的重点不是收缩逻辑,而是你能不能想到用滑动窗口。
选型的时候记住一句话:滑动窗口适用于“连续子数组/子串”且有单调性的问题。单调性是指,窗口变大时某个状态只朝一个方向变化,比如字符频次只增不减、和只增不减。没有单调性,滑动窗口就要么用不了,要么得配合其他数据结构。
2. 第一题:字母异位词,固定窗口的标准打法
2.1 题目与暴力解法的成本分析
先看最经典的题目:给定字符串 s 和 p,找到 s 中所有 p 的字母异位词的子串,返回这些子串的起始索引。所谓字母异位词,就是字符种类和数量都相同、只是排列顺序不同的字符串。比如 p = "abc",那"bca"、"cab"都是它的异位词。
我用一个具体例子来说明。s = "cbaebabacd",p = "abc"。肉眼扫一遍,"cba"是异位词,起始索引 0;"bac"也是,起始索引 6。答案就是 [0, 6]。
暴力做法很直观:枚举 s 中所有长度等于 p.length() 的子串,然后对每个子串做字符频次统计,和 p 的频次对比。假设 s 长度是 n,p 长度是 m,枚举的子串有 n - m + 1 个,每个子串统计频次需要 O(m),总复杂度 O((n-m+1) * m),在 n 和 m 都是 10 的 5 次方级别时就彻底跑不动了。
这里还有一个隐藏的优化点:判断两个字符串互为异位词,不需要真的排序。排序的复杂度是 O(m log m),频次统计只需要 O(m)。如果所有字符都是小写字母,用长度 26 的数组就够了。这就是我接下来要说的。
2.2 用计数数组代替哈希表,差别有多大
很多人的第一反应是用哈希表 unordered_map<char, int> 来存字符频次。哈希表确实通用,但在字符集明确是小写字母的场景里,数组的效率远高于哈希表。
我说个实际测试感受:字符 26 个字母,用一个vector<int> cnt(26)来统计,每次访问下标s[i] - 'a',常数极小。而哈希表每次插入和查找都要计算哈希值,可能触发扩容,常数大不少。在大数据量下,数组版本能快出好几倍。
更重要的是,数组版本在做“判断当前窗口是否和 p 的频次完全一致”这件事上,可以玩出更漂亮的优化。
最朴素的做法是每移动一次窗口,就比较一遍两个长度为 26 的数组,复杂度 O(26 * n),因为 26 是常数,所以其实已经堪称 O(n) 了。但真正的高手写法是用一个count变量记录当前窗口中与 p 频次一致的字符个数。只要 count 等于 26,就说明窗口内的字符频次和 p 完全一致。
2.3 增量更新与 count 优化:这步是精髓
我直接给出一个非常精简的实现思路。维护两个数组,pCount记录 p 的字符频次,sCount记录当前窗口的字符频次,再用matches记录有多少个字符的频次已经对得上。
每次窗口滑动,右边新进一个字符 c,sCount[c]++。如果此时sCount[c] == pCount[c],说明 c 这个字符的频次刚好对齐了,matches 加一。但注意,如果sCount[c]从pCount[c] + 1变成了pCount[c] + 2,这个字符从“已经对齐”又变成了“没对齐”,matches 应该减一。
左边移出的字符同理,反向处理。最后如果matches == 26,当前窗口就是一个合法异位词。
我当初写的时候在 matches 的更新逻辑上绕了很久。最容易错的地方是:不要以为只要sCount[c]等于pCount[c]就该加 matches。你要先处理好“原本对齐、现在因为加字符变得不对齐”的情况,再处理“原本不对齐、现在刚好对齐”的情况。顺序写反,结果全是错的。
我建议你按这个顺序来:先处理 right 进入的字符,更新 sCount;紧接着检查这个字符是否恰好相等,然后更新 matches;再处理 left 移出的字符,同样先更新 sCount 再更新 matches。不要试图把两种情况压缩成一行,可读性差,还容易出 bug。
2.4 固定窗口最容易被忽略的三个细节
第一个细节是窗口初始化。不要在循环里先让 right 走 m 步再开始判断,而是从 right = 0 开始,每次循环先加入一个字符,再判断窗口长度是否超过 m。超过就移出左边界。这样窗口就自然保持在 m 的长度,逻辑统一。
第二个细节是 left 的移动时机的条件。固定窗口里,每次 right 移动后都要检查right - left + 1 > m,如果大于 m 就移动 left 并移除一个字符。这个>和>=很容易搞混。如果你希望在窗口长度为 m 时做判断,那收缩条件应该是>,而不是>=。原因很简单:等于 m 的时候窗口刚好符合要求,还不该收缩。
第三个细节是返回结果的索引。当matches == 26时,左边界 left 就是当前合法窗口的起点,直接加入结果即可。窗口的右边界是 right,左边界是 right - m + 1,但在固定窗口里 left 已经维护了左边界,直接用 left 就行。
3. 第二题:从固定窗口到变长窗口,什么时候该收缩
3.1 为什么要变长:异位词的窗口是定死的,但很多问题并不固定
异位词那题,窗口长度由 p 的长度固定死了,所以 left 和 right 同速前进,谁都不用让着谁。但现实中的子串问题往往没有给定长度,比如“最长无重复子串”“最短覆盖子串”,这时候你不可能提前知道窗口应该多长,只能动态调整。
变长窗口的核心矛盾变成了:right 什么时候扩张?left 什么时候收缩?答案什么时候更新?这三件事的顺序,决定了你的代码能不能跑对。
我举个最典型的例子:无重复字符的最长子串。题目要求从字符串中找出一个最长的子串,使得其中没有重复字符。比如 s = "abcabcbb",答案是 "abc",长度 3。
我一开始的错解是:right 每走一步,就把当前字符加入一个 set,如果发现 set 里已经有这个字符了,就 right++ 继续走,然后等 set 里没有重复时再更新答案。这个思路是错的,因为重复字符会让窗口不合法,但 right 继续扩张只会让窗口更不合法。
正确的做法是:right 每走一步,先把字符加入窗口;如果窗口中出现了重复字符,就不断收缩 left,直到重复被消除;此时窗口一定合法,再用窗口长度更新答案。
3.2 三件事的顺序不能乱:扩张、收缩、更新
我之前整理过一套口诀:先扩张、再收缩、后更新。
扩张就是 right 加入新元素;收缩是 while 不满足条件时移出 left 指向的元素;更新是在窗口满足条件后记录答案。
为什么更新放在最后?因为在变长窗口里,窗口合法的状态是在收缩之后才确定的。如果你在收缩之前就更新答案,拿到的可能是一个非法窗口的长度。反过来说,如果你要求的是“最小覆盖子串”这种找最短合法窗口的题,更新也必须放在收缩之后,因为收缩之后窗口可能更短,用更短的窗口更新答案才是对的。
这里有一个很多人没想明白的点:收缩到什么时候结束?答案是收缩到“刚刚不满足约束条件”之后再收缩一步,还是收缩到“刚好满足约束条件”为止?以无重复子串为例,约束条件是窗口内没有重复字符。那收缩到刚好没有重复字符时,窗口就是合法的。所以 while 循环的条件一般是“存在重复字符”,循环体里不断移出 left,直到重复字符被全部赶出去。
判断是否存在重复字符,可以用一个vector<int> freq(128)来记录 ASCII 频次,每次加入字符时 freq 自增,如果 freq 大于 1 就说明有重复。这样收缩条件是while (freq[s[right]] > 1),非常直观。
3.3 无重复最长子串的完整推演
我们拿 s = "abcabcbb" 完整走一遍,帮你把机制刻进脑子里。
初始 left = 0, right = 0,freq 全为 0,答案 ans = 0。
- right = 0,加入 'a',freq['a'] = 1。窗口 "a" 无重复,长度为 1,ans = 1。
- right = 1,加入 'b'。窗口 "ab" 无重复,长度 2,ans = 2。
- right = 2,加入 'c'。窗口 "abc" 无重复,长度 3,ans = 3。
- right = 3,加入 'a',freq['a'] 变成 2,有重复。进入 while 循环,移除 s[left] 即 'a',left 变 1。此时 freq['a'] 回到 1,循环结束。窗口变成 "bca",长度 3,ans 保持 3。
- right = 4,加入 'b',freq['b'] 变成 2。收缩:移除 s[1] = 'b',left 变 2;此时窗口是 "cab",长度 3。
- right = 5,加入 'c',freq['c'] 变成 2。收缩:移除 s[2] = 'c',left 变 3;窗口是 "abc",长度 3。
- right = 6,加入 'b',freq['b'] 变成 2。收缩:移除 s[3] = 'a',freq['a'] 变 1,但 freq['b'] 还是 2,继续收缩;移除 s[4] = 'b',freq['b'] 变 1,left 变 5;窗口是 "cb",长度 2,ans 仍为 3。
- right = 7,加入 'b',freq['b'] 变成 2。收缩:移除 s[5] = 'c',left 变 6;移除 s[6] = 'b',freq['b'] 变 1,left 变 7;窗口是 "b",长度 1。
最终答案 3。
看到没有,这个过程中 left 不是匀速前进的,而是根据冲突情况跳跃式收缩。这正是变长窗口和固定窗口最大的不同——固定窗口的 left 是靠条件if控制的,变长窗口的 left 是靠while控制的。
3.4 变长窗口的收缩条件怎么确定:单调性分析
我发现很多人卡在“不知道 while 条件怎么写”上。这里我分享一个方法:先想清楚窗口什么时候是合法的,然后 while 条件就是“不合法”。
比如最小覆盖子串问题:窗口合法当且仅当窗口内包含了 t 的所有字符。那“不合法”就是“还有某个 t 中的字符没被包含”。维护一个need计数变量,当 need > 0 时不合法,收缩 left;当 need 减到 0 时合法,更新答案。一旦收缩过头导致 need 变大,循环自然停止。
这个思路本质上依赖窗口的单调性:right 扩张会让窗口越来越“满足”条件,left 收缩会让窗口越来越“不满足”条件。如果题目不具备这种单调性,滑动窗口就不适用了。比如你要找的窗口同时满足两个互相矛盾的指标,left 收缩可能会先让指标 A 变不合法,再让指标 B 变合法,那 while 循环就没法收敛。
4. 第三题:减零问题,滑动窗口最容易被忽略的逆向思维
4.1 题目描述与一开始的错误想法
“将 x 减到 0 的最小操作数”是一道非常有意思的题。题目是这样的:给定一个整数数组 nums 和一个整数 x,你每次操作可以从数组的最左侧或最右侧移除一个元素,然后让 x 减去这个元素的值。问最少需要多少次操作,能让 x 恰好变成 0。如果做不到,返回 -1。
比如 nums = [1, 1, 4, 2, 3],x = 5。你可以移除左侧 1 和 1,再移除右侧 3,一共 3 次操作,x 变为 0。答案就是 3。
我第一次看到这题时,第一反应是贪心:每次比较左右两端的元素,谁小就移谁。但很快我就找到了反例。比如 nums = [3, 2, 20, 1, 1, 3],x = 10。如果你先移除左侧 3,再移除右侧 3,这时候 x 还剩 4,左右两端是 2 和 1,你无论怎么移,都无法正好凑出 4。但如果第一次不移 3,而是先移除右侧的 3、1、1,再移除左侧的 2 和 3,加起来正好是 10。贪心根本顾不到这种组合。
其实这道题的本质是:从数组两端删掉一些元素,要求这些元素的和等于 x。这等价于在原数组中保留中间一段连续子数组,这段子数组的和等于整个数组的总和减去 x。操作次数最少,就等于保留的子数组最长。于是问题从“两端删除”变成了“中间寻找最长连续子数组,使其和等于 target = total - x”。
这就是减零问题的“灵魂”:它不让你在两端操作,而是让你把注意力转向中间。一旦你完成了这个视角转换,滑动窗口就顺理成章了。
4.2 逆向转换:两端删减变成中间连续子数组
为什么可以这样转换?因为数组是连续的,你在左端删掉若干元素、在右端删掉若干元素之后,剩下的部分在数组中间,而且一定是连续的一段。反过来也一样:只要中间连续一段的和等于 total - x,那么两端剩余元素的和自然就是 x,操作次数就是两端元素的个数。
所以原问题就变成了:找最长的中间连续子数组,使它的和为 total - x。设这个子数组长度为 maxLen,答案就是 n - maxLen。
如果 total - x 等于 0,说明整个数组的元素加起来刚好等于 x,你只需要把所有元素都移除,答案就是 n。如果 total - x 小于 0,说明所有元素加起来都不够 x,直接返回 -1。这两个边界条件要提前处理。
我见过有人在这个转换上卡了很久,因为题目名字叫“减零”,脑子很容易被“减”字带偏,一直在想怎么从左从右减。换个角度想,你其实不是要“减到 0”,而是要“从中找出一段和固定的连续区间”,难度瞬间就下来了。
4.3 具体实现步骤与剩余长度计算
接下来是实现。因为是找“和正好等于 target”的最长连续子数组,数组元素全是正数(或非负数),所以窗口和具备单调性:right 扩张时窗口和只会变大,left 收缩时窗口和只会变小。这就完全符合滑动窗口的使用前提。
实现步骤:
- 计算数组总和 total。
- 如果 total == x,直接返回 n。
- 令 target = total - x。
- 初始化 left = 0,windowSum = 0,maxLen = -1。
- 遍历 right 从 0 到 n - 1:
- windowSum += nums[right];
- 当 windowSum > target 时,windowSum -= nums[left],left++;
- 如果 windowSum == target,maxLen = max(maxLen, right - left + 1)。
- 循环结束后,如果 maxLen 还是 -1,说明找不到,返回 -1;否则返回 n - maxLen。
这里我踩过一个坑:maxLen 的初始值我一开始设的是 0,结果当 target 正好等于 0 时,空子数组的长度就是 0,也能对应合法答案。但题目要求至少需要一次操作才能减少 x,如果 x 本身是 0,答案应该是 0 而不是 n。所以我在开头要先判断 x == 0 的情况,那么后面 maxLen = 0 的语义就变得模糊了。稳妥的做法是把 maxLen 初始化为 -1,找不到时返回 -1,找到合法子数组后才更新。
4.4 为什么说“减零”会卡人:正向贪心不可行的原因
再深入聊聊为什么这题容易卡人。滑动窗口本身并不复杂,但很多人的思维被题目描述里的“每次可以从左或者右移除”锁死了,一直在模拟两端删除的过程。模拟也没有错,但状态空间是指数级的,因为你每一步都有左右两个选择,不可能枚举完。
贪心之所以不可行,是因为你无法通过局部最优判断下一步应该删左边还是右边。删掉较小的值,可能会破坏掉后面凑数的可能性;删掉较大的值,又可能让 x 提前变成负数。这就是组合优化里典型的“无后效性缺失”,必须靠全局视角的等价转换来解决。
我把这三题的难度递进总结一下:异位词考的是固定窗口的细节处理;无重复字符子串考的是变长窗口的收缩时机;减零问题考的是能否想到把问题转换成滑动窗口。前两题是“给你一道题,你要会写”,第三题是“给你一道题,你得先发现它能滑动窗口”。这种从模板套用到思路迁移的跨越,才是面试真正想考察的东西。
5. 实操现场:三道题的完整代码与边界条件处理
5.1 异位词完整实现(C++ 版 + Python 版)
我先把字母异位词这道题的完整代码贴出来。C++ 版本的 matches 优化写法如下:
class Solution { public: vector<int> findAnagrams(string s, string p) { vector<int> res; int n = s.size(), m = p.size(); if (n < m) return res; vector<int> pCount(26, 0), sCount(26, 0); for (char c : p) pCount[c - 'a']++; int matches = 0; for (int i = 0; i < 26; i++) { if (pCount[i] == sCount[i]) matches++; } int left = 0; for (int right = 0; right < n; right++) { int idx = s[right] - 'a'; sCount[idx]++; if (sCount[idx] == pCount[idx]) { matches++; } else if (sCount[idx] == pCount[idx] + 1) { matches--; } if (right - left + 1 > m) { int leftIdx = s[left] - 'a'; sCount[leftIdx]--; if (sCount[leftIdx] == pCount[leftIdx]) { matches++; } else if (sCount[leftIdx] == pCount[leftIdx] - 1) { matches--; } left++; } if (matches == 26) { res.push_back(left); } } return res; } };这段代码里最值得注意的就是matches的增减逻辑。新加入字符后,如果频次从 pCount 下面的值刚刚追上 pCount,matches 加一;如果频次从刚好等于 pCount 变成比 pCount 多 1,说明这个字符从“对齐”变成了“多出来”,matches 要减一。左边移除时对称处理,但注意移除时频次下降,所以判断的是从 pCount 降到 pCount - 1 时 matches 减一,从 pCount - 1 刚刚回到 pCount 时 matches 加一。
Python 版用 collections.Counter 可以写得很短,但为了性能我还是建议用列表加手动维护 match 的方式:
class Solution: def findAnagrams(self, s: str, p: str) -> List[int]: n, m = len(s), len(p) if n < m: return [] p_count = [0] * 26 s_count = [0] * 26 for ch in p: p_count[ord(ch) - ord('a')] += 1 matches = sum(1 for i in range(26) if p_count[i] == s_count[i]) res = [] left = 0 for right in range(n): idx = ord(s[right]) - ord('a') s_count[idx] += 1 if s_count[idx] == p_count[idx]: matches += 1 elif s_count[idx] == p_count[idx] + 1: matches -= 1 if right - left + 1 > m: left_idx = ord(s[left]) - ord('a') s_count[left_idx] -= 1 if s_count[left_idx] == p_count[left_idx]: matches += 1 elif s_count[left_idx] == p_count[left_idx] - 1: matches -= 1 left += 1 if matches == 26: res.append(left) return res如果你在笔试环境里时间紧张,也可以用每次全量比较两个长度为 26 的数组的写法,代码更短:
if s_count == p_count: res.append(left)Python 里列表可以直接比较,C++ 里vector<int>也支持==运算符。但这种写法每次窗口滑动都要比较 26 次,虽然 26 是常数,但在极端大数据量下还是会慢一些。matches 优化的本质是把“全量比较”降为“局部增量更新”,这才是滑动窗口的精髓。
5.2 变长窗口完整实现
无重复字符的最长子串,C++ 版:
class Solution { public: int lengthOfLongestSubstring(string s) { vector<int> freq(128, 0); int left = 0, ans = 0; for (int right = 0; right < s.size(); right++) { freq[s[right]]++; while (freq[s[right]] > 1) { freq[s[left]]--; left++; } ans = max(ans, right - left + 1); } return ans; } };注意这里freq的大小是 128,覆盖 ASCII 字符集。如果你只考虑小写字母,用 26 也可以,但为了稳妥起见我直接开到 128,省得处理一些特殊字符时越界。
Python 版:
class Solution: def lengthOfLongestSubstring(self, s: str) -> int: freq = {} left = 0 ans = 0 for right, ch in enumerate(s): freq[ch] = freq.get(ch, 0) + 1 while freq[ch] > 1: freq[s[left]] -= 1 left += 1 ans = max(ans, right - left + 1) return ans这个 while 循环的条件写作freq[s[right]] > 1,含义是“当前新加入的字符产生了重复”。收缩时不断移除 left 字符,直到这个重复字符的频次降为 1。这个条件设计得非常巧妙:它只需要关注新加入的字符是否重复,而不需要去扫描整个窗口确认有没有别的重复。因为如果之前窗口没有重复字符,新加入一个字符后唯一可能产生重复的就是这个新字符。
5.3 减零问题完整实现
C++ 版:
class Solution { public: int minOperations(vector<int>& nums, int x) { int n = nums.size(); int total = 0; for (int num : nums) total += num; if (total == x) return n; int target = total - x; int left = 0, windowSum = 0, maxLen = -1; for (int right = 0; right < n; right++) { windowSum += nums[right]; while (windowSum > target && left <= right) { windowSum -= nums[left]; left++; } if (windowSum == target) { maxLen = max(maxLen, right - left + 1); } } return maxLen == -1 ? -1 : n - maxLen; } };Python 版:
class Solution: def minOperations(self, nums: List[int], x: int) -> int: n = len(nums) total = sum(nums) if total == x: return n target = total - x left = 0 window_sum = 0 max_len = -1 for right in range(n): window_sum += nums[right] while window_sum > target and left <= right: window_sum -= nums[left] left += 1 if window_sum == target: max_len = max(max_len, right - left + 1) return -1 if max_len == -1 else n - max_len这里while (windowSum > target && left <= right)中的left <= right是为了防止窗口收缩到空之后 left 继续越界。实际上当 left 超过 right 时,窗口为空,windowSum 应该是 0,循环自然会停止。但有这个条件更安全。
5.4 时间与空间复杂度汇总
| 题目 | 时间复杂度 | 空间复杂度 | 核心技巧 |
|---|---|---|---|
| 找到字符串中所有字母异位词 | O(n) | O(1)(26 长度数组) | 固定窗口、matches 增量更新 |
| 无重复字符的最长子串 | O(n) | O(1)(128 长度数组) | 变长窗口、收缩条件 |
| 将 x 减到 0 的最小操作数 | O(n) | O(1) | 逆向转换、找最长子数组 |
你可能注意到三题的空间复杂度都是 O(1),因为字符集大小固定。如果字符集不固定,比如输入是任意 Unicode 字符,那就要用哈希表,空间复杂度变成 O(字符种类数)。
6. 常见问题与排查技巧实录
6.1 窗口滑着滑着就死循环了
死循环最常见的原因是收缩时 left 没有递增。我见过有人写出这样的代码:
while (windowSum > target) { windowSum -= nums[left]; // 忘了 left++; }没有 left++,left 永远指向同一个元素,windowSum 减一次之后就不会再变了,while 条件永远为 true,直接卡死。排查这种问题最快的办法是在循环体里加一行打印,把 left、right、windowSum 全部打出来,一眼就能看到 left 是否在移动。
另一个死循环来源是收缩条件写成了>=,比如无重复子串里用while (freq[s[right]] >= 1)。这会导致窗口里任何一个字符存在就触发收缩,最后窗口永远为空,ans 永远为 0。这种 bug 比较隐蔽,因为代码能跑完,只是答案不对。
6.2 边界差一:left、right 到底取不取
Windows 的 left 和 right 都是闭区间的,也就是说窗口包含 left 和 right 指向的元素。这个约定一定要在一开始就明确,不然后面的长度计算全是错的。
窗口长度的公式是right - left + 1。如果 right = 2,left = 0,窗口包含下标 0、1、2 三个元素,长度是 3。
固定窗口收缩条件用if (right - left + 1 > k),意思是当窗口长度超过 k 时收缩。如果你写成>=,窗口长度为 k 时就收缩了,右边界刚进来一个元素就被移出去,窗口永远凑不满 k 个元素。
我建议你在写代码前先在注释里写下约定:“窗口为 [left, right] 闭区间”。养成这个习惯之后,边界条件基本不会错。
6.3 计数数组出现负数
计数数组出现负数,一般是移除元素的逻辑写错了,或者 left 移动的时机不对。比如你在加入元素之前就移除了一个元素,此时 left 指向的元素未必还在窗口里,重复移除就会导致频次变成负数。
还有一种情况是窗口收缩时没有先更新频次再移动 left,而是先移动 left 再更新频次。这会导致移除的是 left 移动之后的新窗口里的元素,而不是原来的元素。
我建议所有移除操作都遵循一个模板:先用频次数组把nums[left]减掉,再执行 left++。顺序不能反,否则 left 已经指向下一个元素了,你减的可能不是真正要移出的那个。
6.4 减零问题中的特殊边界:target 等于 0
减零问题里有一个边界很多人会踩:当 x 正好等于数组总和时,target = 0,你需要移除所有元素,答案是 n。我在代码里单独处理了这个情况。
如果不做这个特判,你有两个选择:一是把 maxLen 初始化为 0,这样 target = 0 时,空窗口长度 0 会被当作合法答案,最后返回 n - 0 = n。但这会引入另一个问题:如果 target 不等于 0 且没有合法子数组,maxLen 保持 0,返回值是 n,这显然是错的。所以需要用一个标志位来区分“找到了空窗口”和“没找到任何窗口”。
我的习惯是用 maxLen = -1 表示没找到,用单独判断total == x处理特殊情况。这样逻辑最清晰,不容易误判。
6.5 高频自查清单
刷题时遇到滑动窗口,我建议按照下面这个清单逐个检查:
- 题目是否要求连续子数组/子串?不是连续区间,滑动窗口大概率不适用。
- 窗口状态是否具备单调性?right 扩张时状态单向变化,left 收缩时状态反向变化。
- 窗口是固定长度还是变长?固定长度用 if 收缩,变长长度用 while 收缩。
- 收缩条件写的是“不合法”而不是“合法”吗?while 循环里应该写不合法条件。
- 更新答案的位置对吗?固定窗口在窗口长度满足后更新,变长窗口在收缩完成后更新。
- 边界条件处理了吗?比如空输入、窗口长度超过数组长度、target 为负数等。
我把这个清单保存成一个笔记,每次刷题前过一遍,能减少大量低级错误。
做算法题这件事,刷得多不如总结得透。滑动窗口这套技巧我前前后后刷了不下四十道,最后发现核心就是两件事:窗口怎么维护,答案怎么更新。异位词、无重复子串、减零问题这三道题,恰好把固定窗口、变长窗口和逆向思维三种考法串成了一条线。你把这三道题彻底吃透,再去刷其他滑动窗口标签的题目,会发现大部分都是它们的变体。
最后分享一个我自己的小习惯:每次写滑动窗口代码前,我会在纸上手写一遍窗口从空到满、再到收缩的全过程,把 left、right 的每一步变化都写出来。写完之后再动手敲代码,正确率会高很多。这个过程就像是在心里模拟一次 TCP 滑动窗口的传输过程——虽然算法题和网络协议应用场合不同,但“通过指针维护一个动态关注区间”的直觉是相通的。下次你遇到一道看起来跟滑动窗口八竿子打不着的题,不妨先问一句:能不能把它转换成连续子数组的问题?也许答案就藏在窗口里。