news 2026/9/10 5:21:32

滑动窗口算法详解:三道经典题吃透固定窗口、变长窗口与逆向思维

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
滑动窗口算法详解:三道经典题吃透固定窗口、变长窗口与逆向思维

滑动窗口这套东西,几乎每个刷算法题的人都会撞上。面试里它是常客,刷题平台上的标签也总是挂着它,但真正能把滑动窗口讲明白、用明白的文章不多。我见过太多人一看到“滑动窗口”就条件反射套模板,结果窗口怎么扩张、怎么收缩、什么时候更新答案全凭感觉,写出来的代码跑几个用例就崩。这篇文章我想拿三道经典题目串起整个滑动窗口体系——从字母异位词这种固定窗口,到无重复最长子串这种变长窗口,再到“将 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 收缩时窗口和只会变小。这就完全符合滑动窗口的使用前提。

实现步骤:

  1. 计算数组总和 total。
  2. 如果 total == x,直接返回 n。
  3. 令 target = total - x。
  4. 初始化 left = 0,windowSum = 0,maxLen = -1。
  5. 遍历 right 从 0 到 n - 1:
    • windowSum += nums[right];
    • 当 windowSum > target 时,windowSum -= nums[left],left++;
    • 如果 windowSum == target,maxLen = max(maxLen, right - left + 1)。
  6. 循环结束后,如果 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 高频自查清单

刷题时遇到滑动窗口,我建议按照下面这个清单逐个检查:

  1. 题目是否要求连续子数组/子串?不是连续区间,滑动窗口大概率不适用。
  2. 窗口状态是否具备单调性?right 扩张时状态单向变化,left 收缩时状态反向变化。
  3. 窗口是固定长度还是变长?固定长度用 if 收缩,变长长度用 while 收缩。
  4. 收缩条件写的是“不合法”而不是“合法”吗?while 循环里应该写不合法条件。
  5. 更新答案的位置对吗?固定窗口在窗口长度满足后更新,变长窗口在收缩完成后更新。
  6. 边界条件处理了吗?比如空输入、窗口长度超过数组长度、target 为负数等。

我把这个清单保存成一个笔记,每次刷题前过一遍,能减少大量低级错误。


做算法题这件事,刷得多不如总结得透。滑动窗口这套技巧我前前后后刷了不下四十道,最后发现核心就是两件事:窗口怎么维护,答案怎么更新。异位词、无重复子串、减零问题这三道题,恰好把固定窗口、变长窗口和逆向思维三种考法串成了一条线。你把这三道题彻底吃透,再去刷其他滑动窗口标签的题目,会发现大部分都是它们的变体。

最后分享一个我自己的小习惯:每次写滑动窗口代码前,我会在纸上手写一遍窗口从空到满、再到收缩的全过程,把 left、right 的每一步变化都写出来。写完之后再动手敲代码,正确率会高很多。这个过程就像是在心里模拟一次 TCP 滑动窗口的传输过程——虽然算法题和网络协议应用场合不同,但“通过指针维护一个动态关注区间”的直觉是相通的。下次你遇到一道看起来跟滑动窗口八竿子打不着的题,不妨先问一句:能不能把它转换成连续子数组的问题?也许答案就藏在窗口里。

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

Ruffle Flash 模拟器:从0到1完整上手,10分钟让SWF重获新生

Ruffle Flash 模拟器&#xff1a;从0到1完整上手&#xff0c;10分钟让SWF重获新生 【免费下载链接】ruffle A Flash Player emulator written in Rust 项目地址: https://gitcode.com/GitHub_Trending/ru/ruffle Ruffle 是一个用 Rust 编写的开源 Adobe Flash Player 模…

作者头像 李华
网站建设 2026/9/10 5:17:13

Firefox自动化僵死诊断与瑞数反爬七层穿透实战

1. CamoFox Browser&#xff1a;一个被误读的命名陷阱与真实技术图谱“CamoFox Browser”——这个词组最近在开发者社区、爬虫论坛和自动化测试群组里频繁闪现&#xff0c;但它既不是Mozilla官方发布的火狐变体&#xff0c;也不是某个知名开源组织维护的浏览器项目。我第一次在…

作者头像 李华
网站建设 2026/9/10 5:17:06

MCU语音唤醒实战:ML-KWS-for-MCU源码架构与TFLM移植全解析

最近在评估边缘端语音唤醒方案时&#xff0c;我把 ARM 官方开源的 ML-KWS-for-MCU 整个仓库拉下来&#xff0c;做了一次完整的源码静态评测和工程架构拆解。这个项目在 TinyML 圈子里名气不小&#xff0c;不光是 ARM 官方的关键词唤醒参考实现&#xff0c;更是 TensorFlow Lite…

作者头像 李华
网站建设 2026/9/10 5:16:41

EMD-PCA-LSTM组合模型:光伏功率预测的Matlab实现与调优

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

作者头像 李华
网站建设 2026/9/10 5:13:31

Flipper Zero BadUSB快速上手:5个步骤如何验证你的USB输入防御

Flipper Zero BadUSB快速上手&#xff1a;5个步骤如何验证你的USB输入防御 【免费下载链接】Flipper Playground (and dump) of stuff I make or modify for the Flipper Zero 项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper 当你想验证自己实验室里那台终端…

作者头像 李华