
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(vectorint nums) { int n nums.size(); int left 0, right 0; unordered_mapint, 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_mapchar, 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, vectorint 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 单调队列的原理与实现单调递减队列的意思是队列中的元素从队首到队尾的值是递减的队首永远是最大值。入队时把队列尾部所有比当前值小的元素全部弹出再把当前值从队尾入队出队时只需要判断队首元素的值是否等于被移出窗口的那个值如果等于就把它弹出。这样做的直觉是那些比新元素小、又排在它前面的元素永远不会成为后续窗口的最大值了因为新元素更大、且生命周期更长。逐个淘汰已经“没有前途”的元素让队列始终保持精简。vectorint maxSlidingWindow(vectorint nums, int k) { dequeint q; vectorint 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_mapchar, 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(vectorint 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 使用率、数据流里做滑动平均降噪等。把算法课上学到的东西迁移到手头的工作里那才是“学以致用”的真正价值。就我个人经验来说滑动窗口之所以值得花时间弄透不只是因为它常刷常考更因为它提供了一个重要的思维模型遇到连续区间问题时先想想能不能只维护边界变化的影响而不是每次都全量重算。这种“增量更新”的思维在你后续接触动态规划、线段树、以及各类工程性能优化时都会反复出现。