滑动窗口这四个字,在Java算法面试里的出现频率,比我写过的任何设计模式都高。用Java写题的开发者,看到“连续子数组”“子串”这类描述,脑子里就应该立刻蹦出这套打法。既然标题叫“滑动窗口 2”,我默认你已经见过基础的双指针滑动写法,所以这篇会直接聊更关键的东西:窗口收缩的时机怎么判断、Java代码里到底有哪些容易出问题的细节、四道高频题怎么一步步写出来、以及我踩过的调试坑。如果你连滑动窗口是什么都还没摸清,从这篇开始也没问题,我会把原理部分讲得足够直白。滑动窗口解决的核心问题,是在一段线性数据上寻找满足某个约束的连续区间,暴力两重循环能出结果,但面试和实际数据处理都不会满意,窗口打法把它压成一次遍历,这就是它最大的价值。
1. 滑动窗口为什么值得单开一篇:先搞懂它快在哪
1.1 窗口的本质是“带状态的连续区间”
滑动窗口说白了就是:左指针和右指针圈出一个连续区间,右指针负责扩张、左指针负责收缩,区间在遍历过程中像条毛毛虫一样往前爬。但真正让它高效的不是“双指针”这个外壳,而是“状态持续维护”这个内核。
很多人写暴力解法为什么会慢?因为每次枚举一个新的起点,都要重新计算一遍这个起点到终点的所有信息。比如求“无重复字符的最长子串”,暴力写法里每移动一个起点,就要重新扫描到末尾看看哪里开始重复,重复扫描的次数多了,复杂度自然变成O(n²)。而滑动窗口的做法是,手里始终维护一个窗口的完整记录,右指针往前走一步,我往记录里加一个新字符;左指针往前走一步,我从记录里删掉一个旧字符。每次只做常数级的增量更新,而不是全量重算。
我用一个生活化类比来解释:你站在一条传送带前检查连续产品的合格区间,暴力做法是每移动一个位置就把前面所有产品重新数一遍;滑动窗口的做法是,手里永远只维护一箱产品的台账,传送带往前送一个,你从台账里加一个新品、去掉一个旧品,随时知道当前箱子里什么情况。整个流水线走完,你也只是每个产品进出一次台账,工作量线性增长。
1.2 时间复杂度的数学直觉
滑动窗口的时间复杂度是O(n),这个结论很多面试的人都会背,但被追问“为什么内层还有一个while循环,复杂度不是O(n²)吗”就卡住了。其实道理很简单:right指针从0走到n-1,每一步都可能触发left指针的收缩,但left指针总共也只能从0走到n-1。也就是说,整个算法过程中,left指针移动的总次数不会超过n,right指针移动的总次数也不会超过n,加在一起最多2n次操作。内层那个while无论执行多少次,它消耗的都是left指针的总移动额度,而不是每次外层循环都重新移动n次,所以均摊下来依然是O(n)。
这个分析方式是面试加分项。很多人只会写代码,讲不清楚复杂度怎么来的,而面试官恰恰喜欢在这个点深挖。你如果把“每个元素最多被right加入一次、被left移出一次”这句话说出来,在面试官眼里就和背模板的选手拉开了差距。
1.3 识别滑动窗口题型的三个信号
刷题多了你会发现,滑动窗口不是万能的,但它有非常明显的适用特征。我总结成三个信号,同时看到三个基本就可以往窗口上想。
第一个信号,题目要求的是连续子数组或子串,而不是子序列。子序列允许跳着选,窗口是管不了的,窗口只能处理原始顺序中连成一体的区间。第二个信号,题目里出现了“最长”“最短”“包含”“覆盖”“至少”“不超过”这类对区间整体性质的限定,而且这个性质随区间扩大或缩小是单调变化的。什么叫单调?窗口越大,和越大;窗口越大,越容易出现重复字符;窗口越大,越容易覆盖目标字符集。这种单向变化的趋势,正是left指针可以放心收缩的依据。第三个信号,数据规模提示你需要O(n)解法,通常在几万到几十万这个量级,O(n²)基本要超时。
反过来也要会排除。如果题目要求的是“子序列的某种最优值”,窗口直接失效;如果要求窗口内元素满足非单调的组合条件,比如“窗口内元素互质”之类,left收缩的判断依据不成立,滑动窗口也无能为力。我见过不少人拿到题就套窗口模板,最后套不进去还怀疑自己写错了,其实是题型判断就偏了。
| 题目特征 | 是否适合滑动窗口 |
|---|---|
| 连续子数组/子串,求最大最小值 | 适合 |
| 子序列,允许跳选 | 不适合 |
| 窗口扩大时约束单向变化 | 适合 |
| 约束不具备单调性 | 不适合 |
| 数据量在10^5左右 | 适合,窗口O(n)通过 |
2. Java实现滑动窗口的3个关键细节:指针节奏、统计结构与状态设计
2.1 左右指针的移动节奏:先加右还是先缩左
Java写窗口题,骨架就一句话:外层while控制右指针扩张,内层while控制左指针收缩。但很多人折在“更新答案的位置”上。这里没有一劳永逸的公式,关键在于想清楚:你手里的窗口在哪个时刻是“候选答案”。
拿“无重复字符的最长子串”举例,右指针把一个新字符加进来之后,窗口可能已经出现重复,必须先收缩到合法,此时的窗口长度right - left才是有效的候选值,所以在收缩之后更新答案。而“长度最小的子数组”这类题,右指针加数之后窗口和变大,满足条件后开始收缩,收缩过程的每一步都可能出现更短的合法窗口,所以答案要在收缩过程中持续更新。再比如“最小覆盖子串”,收缩到刚好不满足条件的前一步,那个窗口才是当前右边界下的最优解。
我把这三类情况拆开讲透,就是下面这个小节的内容。但先记住一个朴素原则:往窗口里加元素时,只加右指针指向的元素;从窗口移除元素时,只移左指针指向的元素。顺序永远是“先改状态,再移动指针”,这两步的顺序一旦写反,窗口状态和指针位置就对不上,调试时特别难发现。
2.2 频次统计用HashMap还是int数组:性能与坑的取舍
窗口题最常见的状态就是“某个字符/数字出现了几次”。Java里两条路:HashMap<Character, Integer>,或者int[128]/int[256]数组。我的习惯很明确:只要输入是ASCII字符范围,一律用int数组;只有值域不可控的大整数场景,才用HashMap。
int数组的好处不只是快。它避免了Integer装箱拆箱的开销,也躲开了一个经典坑:HashMap的get方法在键不存在时返回null,而null参与加减运算直接抛NullPointerException。用getOrDefault可以规避,但每次写起来都得多打几个字符。还有一个更隐蔽的坑:Integer有缓存区间,-128到127之间的Integer对象用==比较可能“碰巧”相等,超出这个范围就出错。如果你用map的value做频次判断,等值比较一定不要用==。用int数组,下标就是字符本身,值就是int,完全没有这些心智负担。
提示:字符统计用int[128]覆盖ASCII、int[256]覆盖扩展ASCII;如果是小写字母题,int[26]更省,记得减'a'。
如果是数字题且值域可控,比如题目告诉你数组元素不超过某个最大值,直接开对应长度的数组;如果值域很大又稀疏,HashMap是合理选择,但建议对value做intValue()处理,避免自动拆箱带来的潜在问题。
2.3 窗口状态设计:把“数量”和“种类”分开维护
窗口题翻车,大多不是指针移动错,而是窗口内“状态”没设计对。最典型的是最小覆盖子串这类题:既要统计每个字符在窗口里出现了几次,又要判断当前窗口是否覆盖了目标串的所有字符。这里有两个概念必须分开:目标串某个字符需要多少个,以及一共有多少种字符还没被满足。
举例,目标串是“AAB”,那么字符A需要2个、字符B需要1个,但“还有几种字符没满足”这个值是2,因为A和B都是缺的。如果你用一个计数器统计总缺失个数,A缺2个、B缺1个加起来是3,这个值在窗口加入一个A后会变成2,看起来好像“满足程度”提升了,但实际A这种字符依然缺1个,缺失种类数还是2。所以正确做法是:用一个need数组记录每个字符还缺多少,用一个missing变量记录“还有多少种字符没补齐”。need数组的增减在每次加字符、移字符时同步更新,但missing只在某个字符的need从正数变为0(补齐)或从0变为正数(重新变缺)时才变化。
这个设计不是死记硬背的模板,而是从问题语义里自然推导出来的。你写窗口题时,可以先问自己一句:我需要在窗口里追踪哪些独立维度?是频次、是和、是乘积,还是“哪些条件满足了”?把维度列清楚,代码结构自然就清晰了。
3. 四道高频滑动窗口题实战拆解:从无重复子串到固定窗口
3.1 无重复字符的最长子串:把可变窗口玩明白
题目要求给定字符串s,找出其中不含重复字符的最长子串长度。这题是可变窗口的入门题,也是我建议所有人都能徒手写出来的题。
public int lengthOfLongestSubstring(String s) { int n = s.length(); int[] freq = new int[128]; int left = 0, right = 0; int ans = 0; while (right < n) { char c = s.charAt(right); freq[c]++; right++; while (freq[c] > 1) { char d = s.charAt(left); freq[d]--; left++; } ans = Math.max(ans, right - left); } return ans; }这段代码最巧妙的地方在于:判断是否需要收缩时,只检查刚加入的字符c的频次。为什么可以只检查它?因为在加入c之前,窗口已经是合法窗口,没有任何重复字符;加入c之后,唯一可能让窗口变违法的就是c自己出现了第二次。所以只要freq[c] > 1,说明c重复了,此时收缩左边界,把左指针指向的字符一个一个移出去,直到c的频次回到1,窗口再次合法。这个“破坏条件的一定是新元素”的局部性思维,是很多窗口题的快速判断依据。
答案在收缩后更新,因为right - left此时对应的正是一个合法且可能最长的窗口。空串的情况天然被覆盖,因为外层while不会进入,ans保持0。
3.2 最小覆盖子串:窗口里的计数与缺失标记
题目给两个字符串s和t,在s中找到包含t所有字符(含重复字符)的最短连续子串,没有则返回空串。这题比上一题难一个档次,也是面试里区分“会写模板”和“真懂窗口”的经典题。
public String minWindow(String s, String t) { int[] need = new int[128]; for (char c : t.toCharArray()) need[c]++; int missing = 0; for (int v : need) { if (v > 0) missing++; } int left = 0, right = 0; int minLen = Integer.MAX_VALUE; int start = 0; int n = s.length(); while (right < n) { char c = s.charAt(right); need[c]--; if (need[c] == 0) missing--; right++; while (missing == 0) { if (right - left < minLen) { minLen = right - left; start = left; } char d = s.charAt(left); if (need[d] == 0) missing++; need[d]++; left++; } } return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen); }这里need数组的含义是“这个字符还缺多少个”,初始值是t的需求量。右指针加入字符c时,need[c]减一;如果need[c]恰好从1变成0,说明这个字符种类的缺口被补齐,missing减一。注意如果need[c]减成负数,说明窗口里这个字符已经盈余了,missing不要动,因为该字符的种类已经满足了。
收缩时的判断是对称的。左指针移出字符d之前,先看need[d]是否为0:如果为0,说明d刚好够用,移除它之后缺一个,所以missing加一;然后need[d]加一。判断时机必须在need[d]变化之前,写完再看一眼顺序就明白了。收缩过程中每移动一次left就更新一次答案,因为每一次移动都可能让窗口变成“新的合法且更短”的状态。
3.3 长度最小的子数组:简单题反而最容易错答案位置
题目给一个正整数数组nums和一个正整数target,找出满足和大于等于target的长度最小的连续子数组并返回长度。这题看着简单,但我在实际辅导里发现错误率不低,原因就是答案更新位置。
public int minSubArrayLen(int target, int[] nums) { int left = 0, sum = 0; int ans = Integer.MAX_VALUE; for (int right = 0; right < nums.length; right++) { sum += nums[right]; while (sum >= target) { ans = Math.min(ans, right - left + 1); sum -= nums[left]; left++; } } return ans == Integer.MAX_VALUE ? 0 : ans; }答案更新写在while循环开头,先记录当前合法窗口长度,再收缩。如果把更新写在收缩完成之后,记录的很可能是已经不满足条件的窗口长度,答案就会偏大。为什么这题要在收缩过程中更新,而“无重复字符的最长子串”要在收缩后更新?因为前者收缩的每一步都可能得到更短合法窗口;后者收缩只是为了排除重复,排除完成后窗口才是合法的。
这道题还有一个隐藏前提:数组必须是正整数。因为正整数保证了sum随窗口扩大单调递增,left收缩的时机才有据可依。如果数组里有负数,sum不再单调,窗口收缩的判断就失效了,这题得用前缀和加其他方案。
3.4 字符串排列匹配:固定窗口的滑动技巧
题目给两个字符串s1和s2,判断s2是否包含s1的某个排列。翻译成人话就是:s2中是否存在一个长度等于s1的连续子串,它的字符频次和s1完全一样。
这题是固定窗口的典型。窗口长度固定为s1的长度,每次移动相当于右边进来一个新字符、左边出去一个旧字符,然后比较当前窗口的频次是否与s1一致。直接比较两个HashMap也能过,但每次都要全量比较一遍,时间常数大。更好的方案是维护差分计数和一个diff变量,记录两个频次数组有多少个位置的差异,移动窗口时只局部更新diff。
public boolean checkInclusion(String s1, String s2) { int n = s1.length(), m = s2.length(); if (n > m) return false; int[] cnt = new int[26]; for (int i = 0; i < n; i++) { cnt[s1.charAt(i) - 'a']++; cnt[s2.charAt(i) - 'a']--; } int diff = 0; for (int v : cnt) { if (v != 0) diff++; } if (diff == 0) return true; for (int i = n; i < m; i++) { int add = s2.charAt(i) - 'a'; int rem = s2.charAt(i - n) - 'a'; if (cnt[add] == 0) diff++; cnt[add]--; if (cnt[add] == 0) diff--; if (cnt[rem] == 0) diff++; cnt[rem]++; if (cnt[rem] == 0) diff--; if (diff == 0) return true; } return false; }diff的维护逻辑是:某位置从0变成非0,说明多了一处差异,diff加一;从非0变成0,说明差异消失,diff减一。每次更新一个字符时,先记录旧值是否为0,再做频次修改,再检查新值是否为0。顺序错了,diff就会统计错误。这个局部更新思路不仅适用于这题,遇到任何“判断两个窗口内容是否相等”的场景都可以套用。
3.5 一个可以直接套用的Java通用模板
综合上面几道题,我沉淀出一个固定骨架。写新题时,你只需要替换“状态更新”和“收缩判断”两处。
int left = 0, right = 0; // 状态:频次数组、计数变量、sum等 while (right < n) { // 1. 加入右边元素,更新状态 // 2. 当窗口违反约束时,收缩左边界 while (需要收缩) { // 可选:在此处更新答案,适合“求最短/最小”类 // 移除左边元素,更新状态 left++; } // 3. 窗口已经合法,更新答案,适合“求最长/最大”类 right++; }记住两条经验:求最短/最小,答案常在收缩过程中更新;求最长/最大,答案常在收缩完成后更新。但这不是铁律,关键还是要回到题目语义里确认,你用来更新答案的窗口在当时是否满足问题条件。
4. 滑动窗口常见Bug排查与调试心得:现场实录
4.1 死循环:left和right的推进顺序写反了
我见过最多的bug,就是内层while收缩时忘了在循环体最后让left自增,导致同一个字符被反复移除,程序卡死。另一个常见问题是right的自增写在状态更新之前,指针已经移动了,窗口状态却没跟上,后续判断全部错乱。
我的排查习惯是:先检查“先改状态,再移动指针”这个顺序。右指针扩张时,一定是先根据当前right指向的元素更新状态,再right++;左指针收缩时,一定是先根据当前left指向的元素更新状态,再left++。如果反过来了,窗口的边界和内容就对不上,轻则结果错误,重则死循环。
4.2 Integer缓存与自动拆箱:HashMap统计频次的隐形坑
用HashMap<Character, Integer>做窗口统计时,有一个藏得很深的坑:Integer对象有缓存区间,==比较在-128到127之间可能返回true,超出范围返回false。如果你在窗口题里用==比较两个频次是否相等,小范围测试数据能过,换大范围数据就莫名其妙出错。频次比较一律用intValue()或者直接操作int。
另一个是空指针。window.get(c)在键不存在时返回null,直接做加减运算会抛NullPointerException。正确写法是getOrDefault(c, 0)。我见过太多初学Java的人在这里栽跟头,而且报错信息在力扣上往往被截断,不好定位。用int[128]之后,这类问题直接消失,这也是我强烈建议字符统计用数组的原因。
4.3 日志调试法:把窗口内容打印出来看
遇到窗口题逻辑不对,我从来不一上来就断点debug。先打日志,打印每次迭代前后的left、right和窗口内容。对于字符串题,可以直接打印s.substring(left, right),一眼就能看到当前窗口到底是什么。
调试代码长这样:左指针、右指针、频次数组、窗口文本,四个信息打在一行。跑一遍小数据,比如“abcabcbb”,观察每行输出的窗口文本变化,很快就能看出左指针是否在正确时机收缩、窗口是否在正确范围滑动。调试完记得把打印语句删掉,或者用日志开关控制,不然提交的时候输出一堆调试信息,结果页面上全是日志,我自己就犯过这毛病。
4.4 验证复杂度的实操方法
写完窗口代码,我会用三种数据自测。第一,题目给的示例,验证基本逻辑;第二,构造最坏情况,比如全是相同字符的超长字符串,确保left指针持续收缩但不卡死;第三,构造百万级随机数据,体感一下运行时间是否接近线性。
如果想更严谨,可以在代码里加一个计数器,统计内层while的总执行次数。跑完后如果发现总次数约等于n,说明每个元素平均被移出一次,复杂度没问题;如果总次数远超n,说明收缩逻辑有重复消耗,肯定有bug。
注意:不要只在示例数据上验证就提交。窗口题最容易栽在空输入、全同字符、目标串长度大于主串这类边界上,提前构造好测试用例能省大量提交时间。
4.5 为什么有的题先更新答案、有的题后更新:一次讲清楚
我辅导过不少人,对“答案更新位置”始终模糊。这里给一个对比矩阵,你对照着看就清楚了。
| 题目类型 | 更新时机 | 原因 |
|---|---|---|
| 无重复字符的最长子串 | 收缩完成后更新 | 窗口收缩后才恢复合法,此时长度才是候选答案 |
| 长度最小的子数组 | 收缩过程中更新 | 收缩的每一步都可能产生更短的合法窗口 |
| 最小覆盖子串 | 收缩过程中更新 | 收缩到不满足条件的前一步,窗口刚好合法且最短 |
| 字符串排列匹配 | 固定窗口,每次移动后判断 | 窗口长度固定,移动后立即判断即可 |
说到底,答案更新位置不是背出来的,而是由“哪个时刻的窗口满足题目要求”决定的。你每次写窗口题,先问自己这个问题,答案位置自然就对了。
4.6 我的几个实操习惯
最后分享几个我写窗口题一直用的习惯。第一,动手写代码前,先在一张草稿纸上画出窗口移动的瞬间状态,标出left和right的位置以及窗口内容的变化。尤其是可变窗口,纸面上能看出左指针什么时候动、右指针什么时候停,比直接敲代码清晰得多。第二,每写完一段状态更新,立刻用一句话描述它的语义,比如“这句的意思是:把字符c加入窗口,频次加一”。如果描述不出来,说明状态设计有问题。第三,把经典题反复手写,直到不依赖模板也能在几分钟内在纸上写出核心逻辑。手写和看代码完全是两回事,尤其是指针移动和状态更新的耦合关系,只有亲手写几遍才能真正建立肌肉记忆。
我个人在实际操作中体会到,滑动窗口这套打法之所以在Java算法题里高频出现,不只是因为它能帮忙应付面试,更因为它是一种通用的区间优化思维:任何需要在连续数据上维护动态范围信息的场景,都可以先想想“能不能用窗口把全量重算变成增量更新”。这个思路迁移到日志分析、流数据处理、实时统计这类工程问题里,照样能派上用场。窗口不大,但把它吃透,收益远比会背几道题大得多。