1. 滑动窗口是什么:先建立画面感
我第一次接触“滑动窗口算法”这个词的时候,以为是某个图形界面里的窗口拖动效果。后来在力扣上刷到一道字符串匹配的题,评论区有人甩了一句“经典滑动窗口”,我才意识到这是一个专门的数据结构和算法套路。说实话,光看名字很难猜出它到底在解决什么问题,直到我把它和“暴力解”放到一起对比,才真正通了。
滑动窗口算法的核心就是一句话:在数组或字符串上维护一个区间,这个区间像一条拉链一样不断往右滑动,每次只调整窗口的边界,从而把暴力枚举的时间复杂度从 O(n²) 甚至更高降下来。它最常见的形态是双指针,左指针管左边界,右指针管右边界,两个指针都只能往前走,所谓“滑”就是右指针扩展、左指针收缩的交替过程。
为什么这东西这么重要?因为很多问题表面上需要“找出所有连续子数组/子串”,而连续子数组的总数是 O(n²) 级别的,逐个枚举在数据量上来以后直接超时。滑动窗口的高明之处在于:它不回溯左边界,也不重复扫描窗口内的元素,而是借助一个“状态变量”记录当前窗口的内容,每次只处理边界变化带来的影响,整体扫描一遍数组就完成求解。
拿快递分拣来类比:如果每次都要把货架上从某个位置到另一个位置之间的所有包裹重新数一遍,那效率极低;但如果你手里始终拿着一个清单,每当有包裹从左边滑出、或者从右边滑入,只需要更新清单上少数几条记录,整个过程就轻快得多。滑动窗口就是这个“清单”,状态变量就是清单上的重点数据。
这篇文章会从零开始讲了滑动窗口的本质、代码模板、经典应用场景,再到常见的坑位和调试技巧。无论你是刚开始刷题的学生,还是工作中需要优化连续数据段处理的工程师,都可以照着文章里的思路直接上手。
2. 暴力解法为什么慢:从重复计算说起
2.1 一个具体场景引发的思考
假设题目是这样的:“给定一个整数数组 nums 和一个目标值 target,找出该数组中满足其和大于等于 target 的长度最小的连续子数组。”
第一反应是什么?肯定是用两层 for 循环,外层枚举子数组的起点,内层往后累加,一旦发现和大于等于 target,就记录长度,然后跳出内层循环、移动起点继续。代码很直白,逻辑也没有错,但它的时间复杂度是 O(n²),当数组长度来到十万级别就彻底歇菜了。
关键问题在哪?大量计算被重复了。比如起点为 0 时,我们计算了 nums[0] 到 nums[5] 的累加和;等到起点变成 1 时,又从头计算 nums[1] 到 nums[5] 的累加和,这之间明明只差了最左边的一个数,却白白重复算了五步加法。数据量越大,这种浪费越致命。
要是手里一直留着一个“当前窗口的加和”变量,那么从窗口 [2, 5] 滑到 [3, 5] 时,只需要做一步减法(sum -= nums[2]),不需要把 3 到 5 重新加一遍。这就是滑动窗口在“和”类问题上的核心优势。
2.2 从两层循环到一层循环的演进
我把这个演进过程拆成三步:
第一步,保持一个右指针,不断往右扩展,直到当前窗口满足题目条件。以“和大于等于 target”为例,右指针从 0 开始累加,一旦累加和超过 target,就停下来。
第二步,在窗口满足条件的前提下,尝试收缩左指针,也就是把左边界往右移动,每次记录窗口长度,看能不能让窗口更短的同时依然满足条件。收缩到不满足条件为止,再继续扩展右指针。
第三步,重复第二步和第一步,右指针一直走到数组末尾,整个过程结束。
这个过程看起来像是“右指针负责前进,左指针负责见好就收”,两者配合,每个元素最多被加入窗口一次、移出窗口一次,所以时间复杂度是 O(n)。从 O(n²) 到 O(n),这不是小优化,是数量级的差距。
2.3 滑动窗口的适用边界
滑动窗口好,但它也不是万能的。它解决的问题有一个共同特征:数据是连续排列的,所求结果和连续子区间有关。顺序敏感的字符串问题、子数组问题,滑动窗口很好用;但如果是求一个数组的所有组合、或者要同时满足多个无关联的约束条件,滑动窗口就不太合适。
我自己的判断标准是:题目里的数据结构能不能被两个指针夹出来的区间表达?如果能,并且所有约束在窗口滑动过程中只会改变边界元素的影响,那就值得用滑动窗口;否则还是老老实实去想动态规划、哈希表或者排序预处理。
3. 一套代码模板打通入门关卡
3.1 模板长什么样
看了那么多题解之后,我发现滑动窗口其实有比较统一的代码结构。不管题目是求最大窗口、最小窗口,还是满足某种条件的窗口个数,都可以套一个基础骨架。
int slidingWindow(vector<int>& nums) { int n = nums.size(); int left = 0, right = 0; unordered_map<int, int> window; int ans = 0; // 根据题目调整 while (right < n) { // 1. 扩展右边界,把 nums[right] 加入窗口 int c1 = nums[right]; window[c1]++; right++; // 2. 收缩左边界(条件不满足时) while (需要收缩) { int c2 = nums[left]; window[c2]--; if (window[c2] == 0) window.erase(c2); left++; } // 3. 更新答案 ans = max(ans, right - left); } return ans; }这个模板里最需要注意的就是“需要收缩”这个条件到底如何写。收缩的时机决定了算法是找最小窗口还是找最大窗口。
- 如果是求“满足条件的最小窗口”,那么在窗口有效时收缩,一边收缩一边更新最优解。
- 如果是求“满足条件的最大窗口”,那么窗口无效时收缩,收缩到再次有效后更新答案。
3.2 最小覆盖子串是怎么写出来的
力扣 76 题是滑动窗口的经典题:给定字符串 S 和 T,求 S 中涵盖 T 所有字符的最小子串。
我第一次做这道题时卡了很久,总觉得“涵盖 T 所有字符”需要统计每个字符出现多少次,窗口一变就得重新判断。后来发现,滑动窗口天然适合这种“维护计数”的场景,解法一下就清晰了。
思路是用一个哈希表 need 记录 T 中每个字符还需要多少个,再用一个变量 count 表示“已满足条件的字符种类数”。右指针扩展时,如果新字符在 T 中出现过,并且当前窗口内该字符的数量还没达到需求,就把 count 加一;左指针收缩时,如果移出的字符导致某个字符数量低于需求,则 count 减一。
当 count 等于 need 中字符种类数时,当前窗口就是一个合法覆盖,此时不断收缩左指针,找最短长度。
string minWindow(string s, string t) { unordered_map<char, int> need; for (char c : t) need[c]++; int left = 0, right = 0; int valid = 0; int start = 0, len = INT_MAX; while (right < s.size()) { char c = s[right]; right++; if (need.count(c)) { need[c]--; if (need[c] == 0) valid++; } while (valid == need.size()) { if (right - left < len) { start = left; len = right - left; } char d = s[left]; left++; if (need.count(d)) { if (need[d] == 0) valid--; need[d]++; } } } return len == INT_MAX ? "" : s.substr(start, len); }判断条件是窗口内该字符数量等于需求数量,valid 才增加,这样就能准确反映“当前窗口是否完整覆盖了 T”。收缩时逻辑对称,移出字符后如果该字符数量刚好低于需求,valid 减一。
3.3 长度最小的子数组:带数值限制的变体
求“和大于等于 target 的长度最小子数组”,模板类似,但状态变量由一个哈希表变成了一个整数 sum,收缩条件也很直白:只要当前窗口之和仍然大于等于 target,就继续收缩。
int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); int left = 0, sum = 0, ans = INT_MAX; for (int right = 0; right < n; right++) { sum += nums[right]; while (sum >= target) { ans = min(ans, right - left + 1); sum -= nums[left]; left++; } } return ans == INT_MAX ? 0 : ans; }这个题充分展示了“状态变量”的选择逻辑:这道题需要维护的只是排列在窗口里的所有数的和。你不需要精确知道窗口里有哪些数,只需要知道总和,而总和在窗口滑动时用加右减左的方式来维护,代价极低。
4. 滑动窗口的进阶:最大值、最小值与变式
4.1 定长窗口内求最大值
力扣 239 题“滑动窗口最大值”是另一个标志性的题目:给定一个数组和一个大小为 k 的窗口,窗口每次右移一格,返回每个窗口中的最大值。
这道题如果直接在每个窗口内重新遍历求最大值,时间复杂度是 O(nk),k 大一点直接爆炸。但如果你仔细看看“窗口移动时发生了什么”,会发现窗口内元素是先进先出的顺序,天然对应一个队列:右边界进队,左边界出队。
关键问题变成了:如何快速维护当前窗口的最大值?答案是用一个“单调递减队列”。
4.2 单调队列的原理与实现
单调递减队列的意思是:队列中的元素从队首到队尾的值是递减的,队首永远是最大值。入队时,把队列尾部所有比当前值小的元素全部弹出,再把当前值从队尾入队;出队时,只需要判断队首元素的值是否等于被移出窗口的那个值,如果等于,就把它弹出。
这样做的直觉是:那些比新元素小、又排在它前面的元素,永远不会成为后续窗口的最大值了,因为新元素更大、且生命周期更长。逐个淘汰已经“没有前途”的元素,让队列始终保持精简。
vector<int> maxSlidingWindow(vector<int>& nums, int k) { deque<int> q; vector<int> res; for (int i = 0; i < nums.size(); i++) { if (!q.empty() && q.front() == i - k) q.pop_front(); while (!q.empty() && nums[q.back()] <= nums[i]) q.pop_back(); q.push_back(i); if (i >= k - 1) res.push_back(nums[q.front()]); } return res; }这里存的是下标而不是值,因为需要判断队首元素是否已经滑出窗口。如果你存值时,会碰到重复元素无法区分是否出队的尴尬问题。我起初就折在这个地方,后来改成存下标,逻辑立刻清爽了。
同样的思路还可以处理“滑动窗口最小值”,只需要把单调递减队列改成单调递增队列。这组方法在信号处理、金融时间序列分析里都有应用,比如一段股价数据上计算滚动最低点。
4.3 字符串排列:窗口大小其实固定
力扣 567 题“字符串的排列”也是一道很好的变式:给定两个字符串 s1 和 s2,判断 s2 是否包含 s1 的排列之一。
这道题和“最小覆盖子串”的区别在于:它要求窗口大小必须等于 s1 的长度。所以模板里的收缩条件不再是“窗口不满足条件”,而是“窗口长度超过 len(s1)”就收缩。
bool checkInclusion(string s1, string s2) { unordered_map<char, int> need; for (char c : s1) need[c]++; int left = 0, right = 0, valid = 0; while (right < s2.size()) { char c = s2[right]; right++; if (need.count(c)) { need[c]--; if (need[c] == 0) valid++; } while (right - left == s1.size()) { if (valid == need.size()) return true; char d = s2[left]; left++; if (need.count(d)) { if (need[d] == 0) valid--; need[d]++; } } } return false; }这种变体让我体会到,滑动窗口很多时候并不是“一个套路打天下”,而是“一个框架配多种收缩策略”。掌握好右扩展和左收缩的时机,基本就能应对绝大多数滑动窗口题。
4.4 至少/至多类问题:转化为固定逻辑
有一类题目问“有多少个子数组满足条件”,比如“乘积小于 k 的子数组个数”。滑动窗口配合一种常用技巧:记录右端点固定时,左端点的可选数量。
核心逻辑是:每扩展一次右指针,如果窗口满足条件,则[left, right]内的所有以 right 为结尾的子数组都满足条件,数量为 right - left + 1;一旦窗口不满足,收缩左指针直到重新满足。
int numSubarrayProductLessThanK(vector<int>& nums, int k) { if (k <= 1) return 0; int left = 0, prod = 1, ans = 0; for (int right = 0; right < nums.size(); right++) { prod *= nums[right]; while (prod >= k) { prod /= nums[left]; left++; } ans += right - left + 1; } return ans; }这个题的答案计数逻辑一开始不太直觉,但一旦想通了就会觉得非常巧妙:窗口只要合法,右边界的每一种左起点对应一个不同的子数组,这些子数组的个数正好是窗口长度。用一句生活话解释:窗口里的所有左端点,都可以和当前的右端点组成一个新的合法子数组,一个不落。
5. 滑动窗口的拓展开外:从算法到工程
5.1 计算机网络里的滑动窗口
很多人不知道,滑动窗口算法在计算机网络里也是一个经典机制,只不过那里的“窗口”不是代码里的双指针,而是一个“允许发送但尚未确认”的数据包范围。
TCP 协议里,发送方维护一个发送窗口,窗口大小表示可以连续发送多少个数据包而不用等待确认;接收方也有一个接收窗口,表示自己还能接收多少数据。每收到一个确认,窗口就“滑动”一格,然后发送方可以继续发送新数据。这种机制在保证可靠性的同时,让网络链路保持高利用率,不至于每发一个包就停下来等应答。
这和算法题里的滑动窗口本质上是同构的:都是在连续序列上维护一个动态区间,通过区间的移动和收缩来控制整体行为的效率与正确性。理解了一边,另一边也容易理解。
5.2 滑动窗口滤波与信号处理
热词里反复出现“滑动窗口滤波”和“滑动窗口滤波 verilog”,这里多说一句。滑动窗口滤波通常指对时间序列信号(比如传感器数据)做移动平均或加权平均,每次只取最近 N 个点算平均值,窗口每前进一步,丢掉最老的点,加入最新的点。
工程实现上,如果每来一个新数据都重新加总所有 N 个点,复杂度是 O(N);但用滑动窗口的思路维护一个累加和,每步只做一次减法和一次加法,复杂度就变成 O(1)。在大规模实时信号处理场景下,这个优化相当有用。
如果你用 FPGA 做滑动窗口滤波,Verilog 里通常会设计一个移位寄存器链来存储窗口内的 N 个样本,每个时钟周期把新样本移入、最老样本移出,再配合加法器和累加器完成均值计算。这里“滑动窗口”的思想和算法题里一模一样,只是表达形式变成了硬件电路。
5.3 定时任务与限流降级
我之前在一个后端项目里处理过“固定时间窗内接口调用次数限制”的需求:每个用户每分钟最多允许调用 100 次。一种实现方式就是用滑动窗口记录这个用户在最近一分钟内每次调用的时间戳,新请求到达时,把窗口左侧早于当前时间点 60 秒的时间戳全部移除,然后看窗口内的记录数是否达到上限。
固定窗口计数会存在临界问题:在第一个窗口最后 10 秒调用 100 次,第二个窗口最初 10 秒又调用 100 次,实际上 20 秒内调用了 200 次,明显违反限流意图。滑动窗口因为允许记录更细粒度的时间戳,能比较平滑地应对这类突刺流量。
6. 常见坑位与调试技巧
6.1 边界条件:一上来就踩的坑
滑动窗口代码虽然短,但边界条件一不小心就写错。最常见的坑包括:右指针越界后仍然尝试更新答案;左指针跳出右指针(窗口空白)还继续访问数组;哈希表里计数减到负数后误判窗口合法性。
我自己调试时最常用的方法就是“打印机模拟”:在每个循环节点打印 left、right、window 内容以及 sum/valid 的值,对照样例手工推演。窗口的滑动本质上是状态变量的迁移,打印出状态迁移过程,代码逻辑是否正确就一目了然。
6.2 什么时候用 while 收缩、什么时候用 if 收缩
这是滑动窗口新手最容易混淆的地方。
先说结论:如果右指针加入一个元素后,多个元素可能需要被移出窗口,就用 while;如果最多只需要移出一个元素,用 if 就行。
什么情况会出现“多个元素需要被移出”?比如“最小覆盖子串”,窗口中间可能积累了太多冗余字符,左指针连续向右移动好几个位置才能让窗口重新变合法,此时必须用 while。
什么情况只需要 if?“定长窗口最大值”里,每轮左指针只需要移出一个元素,因为窗口的长度是固定的,右指针每前进一步,左指针最多跟着前进一步。这种情况下用 if 就足够,当然你用 while 也不会错,只是多写了一个判断。
6.3 最容易被忽视的“窗口为空”场景
当整个数组都是正数、而 target 是 1 时,左指针可能一路收缩到 right 甚至超过 right。这时候窗口是空窗口,sum 可能是 0 或者因为浮点误差变成负数。代码里如果还使用 left <= right 作为条件,就可能会访问到空窗口内的元素。
更安全的方式是:窗口有效性的判断不要依赖“left <= right”这类指针关系,而是依赖你维护的状态变量。比如 sum 为 0 且 left > right,就直接认为窗口为空,不进入收缩逻辑。
6.4 用调试技巧定位“死循环”
有一段时间我反复遇到“提交超时”的提示,一查发现是 while 收缩条件写反了,导致左指针在某一个值上停住再也不前进,窗口越来越大,状态越来越差。
排查这类问题的关键是:检查每一轮循环中 left 是否必然会向右移动。如果存在一种情况,窗口不满足条件但同时也不满足收缩条件,那程序就会死循环。比如条件写成了 sum > target,但 sum 恰好等于 target 时窗口是需要收缩的,这个等号就会漏判,导致 left 卡住。
我给自己的一个小约束是:写完收缩逻辑后,一定问自己一个问题——“如果当前窗口已经满足条件,下一次循环会发生什么?”如果答案是“窗口左边界不动”,大概率逻辑有漏洞。
6.5 做题路线与心法
如果你想把滑动窗口吃透,我建议按这样的顺序循序渐进:
- 力扣 209 题“长度最小的子数组”:理解最基础的扩展/收缩逻辑。
- 力扣 76 题“最小覆盖子串”:理解哈希表维护、窗口合法性判断。
- 力扣 567 题“字符串的排列”:理解固定长度窗口与唯一排列判断。
- 力扣 239 题“滑动窗口最大值”:接触单调队列这个重要的进阶数据结构。
- 力扣 3 题“无重复字符的最长子串”:练习哈希表 + 窗口收缩的综合应用。
除了刷题,我强烈建议你在真实项目中找一找滑动窗口的影子。比如日志处理里统计最近 5 分钟内某接口的错误率、监控系统里计算过去 10 秒的平均 CPU 使用率、数据流里做滑动平均降噪等。把算法课上学到的东西迁移到手头的工作里,那才是“学以致用”的真正价值。
就我个人经验来说,滑动窗口之所以值得花时间弄透,不只是因为它常刷常考,更因为它提供了一个重要的思维模型:遇到连续区间问题时,先想想能不能只维护边界变化的影响,而不是每次都全量重算。这种“增量更新”的思维,在你后续接触动态规划、线段树、以及各类工程性能优化时,都会反复出现。