概述:为什么滑动窗口是子串与子数组问题的通用武器
学完双指针之后,下一类非常值得尽快掌握的技巧,就是滑动窗口。
很多初学者第一次接触这类题时,会觉得它和双指针有点像,但又总觉得"写起来很乱"。
原因很简单:滑动窗口并不是单纯维护两个指针,而是要维护一个动态区间。
这类题通常长这样:
- 求最长不重复子串
- 求最短满足条件的连续子数组
- 判断某个排列是否出现在另一个字符串中
- 统计某个区间内是否满足某种字符频率要求
它们的共同点非常明显:
- 处理对象通常是连续区间
- 区间会不断向右移动
- 每次移动时,都要维护窗口内的状态
所以这篇文章的目标,不是让你死记某一道题的代码,而是帮你建立滑动窗口最重要的 3 个意识:
- 滑动窗口解决的是连续区间问题
- 核心不只是左右指针,而是窗口状态维护
- 很多题都能归结为"右指针扩张,左指针收缩"
学完这篇,你应该能看出一道题能不能用滑动窗口,以及知道窗口里到底该维护什么。
核心概念:滑动窗口到底是什么
所谓滑动窗口,你可以把它理解成:
在数组或字符串上维护一个连续区间
[left, right],随着指针移动,不断更新区间信息,从而避免每次都重新统计整段内容。
它之所以叫"窗口",是因为你可以把这个区间想象成一块不断向右滑动的透明玻璃:
- 窗口右边扩张:把新元素纳入考虑
- 窗口左边收缩:把旧元素移出考虑
只要窗口移动时,你能高效维护窗口内的信息,就能避免大量重复计算。
滑动窗口和双指针有什么关系
滑动窗口本质上也是双指针的一种应用,但它更强调:
- 区间必须连续
- 需要维护窗口内状态
- 指针移动通常有明确的触发条件
比如:
- 双指针更像"两个位置怎么配合"
- 滑动窗口更像"一个连续区间怎么动态更新"
滑动窗口是双指针的进阶用法,重点不在两个指针本身,而在窗口内信息怎么维护。
原理:为什么滑动窗口能避免重复遍历
很多连续区间问题,最容易想到的暴力解法就是:
- 枚举每一个起点
- 枚举每一个终点
- 对每个区间重新统计信息
这种思路通常会导致:
text
O(n^2)
甚至更高。
滑动窗口之所以高效,是因为它抓住了一个核心事实:
相邻两个窗口之间,绝大部分内容是重叠的。
例如:
text
[1, 2, 3]
[2, 3, 4]
这两个窗口只有一个元素进来、一个元素出去。
如果你每次都重新计算整段内容,就浪费了大量重复工作。
滑动窗口的优化思路就是:
- 右指针右移时,只处理"新进入窗口"的元素
- 左指针右移时,只处理"离开窗口"的元素
这样每个元素通常只会被加入窗口一次、移出窗口一次,所以很多题的复杂度都能降到:
text
O(n)
先建立框架:滑动窗口最常见的两种模型
并不是所有滑动窗口题都长一样。
初学阶段,你可以先把它分成两类。
1. 固定长度窗口
窗口大小是固定的,比如:
- 长度为
k的子数组最大和 - 所有长度为
k的子串统计
这类题最典型的特征是:
窗口大小始终不变,只是在向右平移。
2. 可变长度窗口
窗口大小不是固定的,而是根据条件动态扩张和收缩,比如:
- 最长不重复子串
- 最短满足和至少为
target的子数组 - 包含某些字符条件的最短子串
这类题最典型的动作是:
- 先用
right扩张窗口 - 当窗口满足或不满足某个条件时,再移动
left
固定窗口重在"平移更新",可变窗口重在"满足条件时收缩,不满足时扩张"。
模板一:固定长度滑动窗口
来看一个最基础的问题:
给定数组
nums和整数k,求长度为k的连续子数组的最大和。
暴力写法
如果每次都重新计算长度为 k 的区间和,写法通常是:
java
public static int maxSumBruteForce(int[] nums, int k) {
int n = nums.length;
int ans = Integer.MIN_VALUE;
for (int i = 0; i <= n - k; i++) {
int sum = 0;
for (int j = i; j < i + k; j++) {
sum += nums[j];
}
ans = Math.max(ans, sum);
}
return ans;
}
时间复杂度通常是:
text
O(n * k)
滑动窗口写法
更高效的方法是:
- 先算出第一个窗口的和
- 每次窗口右移一格时,减去左边出去的数,加上右边进来的数
java
public static int maxSumFixedWindow(int[] nums, int k) {
int n = nums.length;
int windowSum = 0;
for (int i = 0; i < k; i++) {
windowSum += nums[i];
}
int ans = windowSum;
for (int right = k; right < n; right++) {
windowSum += nums[right];
windowSum -= nums[right - k];
ans = Math.max(ans, windowSum);
}
return ans;
}
为什么这段代码高效
因为窗口从:
text
[i, i + k - 1]
移动到:
text
[i + 1, i + k]
时,中间绝大部分元素没有变化。
你只需要:
- 减去离开窗口的元素
- 加上新进入窗口的元素
整个过程可以理解为:
#mermaid-svg-7zFAwLXBOZiNeZXW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7zFAwLXBOZiNeZXW .error-icon{fill:#552222;}#mermaid-svg-7zFAwLXBOZiNeZXW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7zFAwLXBOZiNeZXW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .marker.cross{stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7zFAwLXBOZiNeZXW p{margin:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label text{fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label span{color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label span p{background-color:transparent;}#mermaid-svg-7zFAwLXBOZiNeZXW .label text,#mermaid-svg-7zFAwLXBOZiNeZXW span{fill:#333;color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .node rect,#mermaid-svg-7zFAwLXBOZiNeZXW .node circle,#mermaid-svg-7zFAwLXBOZiNeZXW .node ellipse,#mermaid-svg-7zFAwLXBOZiNeZXW .node polygon,#mermaid-svg-7zFAwLXBOZiNeZXW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .rough-node .label text,#mermaid-svg-7zFAwLXBOZiNeZXW .node .label text,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label,#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label{text-anchor:middle;}#mermaid-svg-7zFAwLXBOZiNeZXW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .rough-node .label,#mermaid-svg-7zFAwLXBOZiNeZXW .node .label,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label,#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label{text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .node.clickable{cursor:pointer;}#mermaid-svg-7zFAwLXBOZiNeZXW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .arrowheadPath{fill:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7zFAwLXBOZiNeZXW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster text{fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster span{color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7zFAwLXBOZiNeZXW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW rect.text{fill:none;stroke-width:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape p,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label rect,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7zFAwLXBOZiNeZXW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7zFAwLXBOZiNeZXW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 先统计第一个长度为 k 的窗口
窗口右移一格
减去左边移出的元素
加上右边进入的元素
更新答案
时间复杂度:
text
O(n)
空间复杂度:
text
O(1)
模板二:可变长度滑动窗口
滑动窗口真正高频的,其实是可变长度模型。
这类题的关键不在于窗口有多大,而在于:
当前窗口是否满足某个条件。
最常见的套路是:
- 右指针不断向右扩张
- 把新元素加入窗口状态
- 当窗口满足或破坏某个条件时,移动左指针调整
- 在合适时机更新答案
一个通用模板可以先记成下面这样:
java
public static void slidingWindowTemplate(String s) {
int left = 0;
for (int right = 0; right < s.length(); right++) {
char in = s.charAt(right);
// 1. 处理新进入窗口的元素
while (窗口需要收缩) {
char out = s.charAt(left);
// 2. 处理离开窗口的元素
left++;
}
// 3. 在这里更新答案
}
}
你可以把它理解成一句话:
right负责扩张,left负责纠偏,答案在过程中更新。
经典例题一:最长不重复子串
这是滑动窗口最经典、也最值得反复练熟的一道题。
题目大意:
给定一个字符串
s,求不含重复字符的最长子串长度。
为什么暴力法不理想
如果你枚举所有子串,再逐个检查有没有重复字符,复杂度会比较差。
真正高效的做法是维护一个"当前不重复"的窗口。
窗口思路
当右指针加入一个字符时:
- 如果这个字符没重复,窗口可以继续扩张
- 如果重复了,就移动左指针,直到窗口重新变成"不重复"
这里最常见的维护方式是用哈希集合。
java
import java.util.HashSet;
import java.util.Set;
public static int lengthOfLongestSubstring(String s) {
Set<Character> window = new HashSet<>();
int left = 0;
int ans = 0;
for (int right = 0; right < s.length(); right++) {
char ch = s.charAt(right);
while (window.contains(ch)) {
window.remove(s.charAt(left));
left++;
}
window.add(ch);
ans = Math.max(ans, right - left + 1);
}
return ans;
}
这段代码的核心逻辑
比如字符串:
text
abcabcbb
前面窗口扩张到 abc 时没有问题;
当新的 a 进来时,窗口里已经有 a 了,这时就必须不断移动左边,把旧的 a 移出去,直到窗口重新合法。
这个过程说明一件事:
可变窗口的关键不是"每次窗口扩大多少",而是"什么时候必须收缩"。
时间复杂度:
text
O(n)
空间复杂度:
text
O(k)
这里的 k 表示窗口中不同字符的数量,最坏可看作 O(n)。
经典例题二:长度最小的连续子数组
再来看另一道非常典型的题:
给定一个正整数数组
nums和正整数target,找出和大于等于target的最短连续子数组长度。
这题为什么适合滑动窗口
因为题目要找的是:
- 连续子数组
- 而且要求的是"满足条件的最短长度"
这种题的标准动作通常就是:
- 右指针不断扩张,让窗口和变大
- 一旦窗口和已经满足要求,就尽量移动左指针,把窗口收缩到最短
java
public static int minSubArrayLen(int target, int[] nums) {
int left = 0;
int 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
很多初学者这里容易写成 if,这是不对的。
原因是:
- 当窗口已经满足
sum >= target - 左边可能还可以继续缩
- 你必须一直缩到"不再满足"为止
只有这样,才能保证当前右端点下的最短合法窗口被找到。
求"最短满足条件的连续区间",通常就是"满足条件后疯狂收缩左边"。
经典例题三:字符串排列是否出现
滑动窗口在字符串题里还有一类非常经典的用法:
判断某个短字符串的排列,是否出现在另一个长字符串中。
例如:
s1 = "ab"s2 = "eidbaooo"
因为 "ba" 是 "ab" 的排列,所以答案为 true。
这题的关键观察
如果 s1 长度是 m,那么只需要看 s2 中所有长度为 m 的子串。
这本质上就是一个固定长度窗口。
只不过窗口里维护的不再是"和",而是"字符频率"。
java
public static boolean checkInclusion(String s1, String s2) {
if (s1.length() > s2.length()) {
return false;
}
int[] need = new int[26];
int[] window = new int[26];
for (int i = 0; i < s1.length(); i++) {
need[s1.charAt(i) - 'a']++;
window[s2.charAt(i) - 'a']++;
}
if (matches(need, window)) {
return true;
}
for (int right = s1.length(); right < s2.length(); right++) {
window[s2.charAt(right) - 'a']++;
window[s2.charAt(right - s1.length()) - 'a']--;
if (matches(need, window)) {
return true;
}
}
return false;
}
private static boolean matches(int[] need, int[] window) {
for (int i = 0; i < 26; i++) {
if (need[i] != window[i]) {
return false;
}
}
return true;
}
这道题很重要,因为它会让你意识到:
滑动窗口维护的状态,不一定是和,也可能是计数、频率、去重状态或某种合法性条件。
滑动窗口到底在维护什么
很多人写滑动窗口时觉得乱,根源通常不是不会移动指针,而是没想清楚窗口里应该维护什么信息。
常见的窗口状态包括:
| 题型 | 窗口里维护的信息 |
|---|---|
| 定长子数组求和 | 当前窗口和 |
| 最长不重复子串 | 当前窗口字符集合或频次 |
| 最短满足和的子数组 | 当前窗口和 |
| 排列匹配问题 | 当前窗口字符频率 |
| 最小覆盖子串 | 当前窗口是否满足目标计数 |
所以做题时一定先问自己:
- 这题的窗口是否合法,靠什么判断?
- 当元素进入窗口时,哪些信息要更新?
- 当元素离开窗口时,哪些信息要回退?
- 状态是在扩张后更新,还是在收缩时更新?
滑动窗口真正难的地方,不是指针,而是状态设计。
易错点:新手写滑动窗口最容易踩的坑
1. 题目不是连续区间,却硬套滑动窗口
滑动窗口最适合的是:
- 子串
- 子数组
- 连续区间
如果题目不要求连续,通常就不是这套模型。
2. 没想清楚窗口合法条件
有的题是:
- 窗口内不能重复
有的题是:
- 窗口和必须大于等于某个值
有的题是:
- 窗口字符频率必须匹配目标
如果合法条件没先想明白,后面代码一定会乱。
3. 该用 while 收缩时写成了 if
这是最常见的错误之一。
只收缩一次,往往不能把窗口调回正确状态。
4. 加入窗口和移出窗口的更新不对称
例如:
- 加入时做了
sum += nums[right] - 但移出时忘了做
sum -= nums[left]
或者字符频率加了却没减,都会直接导致结果错误。
5. 更新状态的位置放错了
有些题应该在窗口合法后更新状态;
有些题应该在收缩过程中更新状态。
这个位置放错,即使整体框架对了,结果也可能不对。
6. 忘记处理空串、短串或 k 越界
比如固定窗口题里,如果:
text
k > nums.length
那就必须先特殊处理。
7. 把滑动窗口当成死模板硬背
模板当然重要,但模板只是骨架。
真正决定题目能不能做对的,是你是否知道窗口里该维护哪些状态。
复杂度总结:为什么滑动窗口经常是线性的
滑动窗口之所以常见,是因为它在很多题里都能把暴力枚举压到线性复杂度。
核心原因通常是:
right只会从左到右走一遍left也只会从左到右走一遍
也就是说,两个指针虽然都在动,但总移动次数通常不会超过 2n 级别,所以整体仍然是:
text
O(n)
下面这张表可以帮助你快速建立直觉:
| 题型 | 暴力复杂度 | 滑动窗口复杂度 |
|---|---|---|
长度为 k 的最大子数组和 |
O(n * k) |
O(n) |
| 最长不重复子串 | O(n^2) 甚至更高 |
O(n) |
| 最短满足和的连续子数组 | O(n^2) |
O(n) |
当然,前提是你维护窗口状态的更新本身不能太慢。
如果每次更新都重新扫描整个窗口,那就失去了滑动窗口的意义。
总结
滑动窗口的本质,是维护一个会移动的连续区间。
滑动窗口不是在机械移动两个指针,而是在动态维护一个连续区间的有效信息。
当你真正掌握这一点后,很多子串和子数组题都会开始出现明显的"模板感"。