算法篇 | 滑动窗口
前言
在上一章中,我们讲了双指针——通过两个指针的协作来高效处理”一对元素”的问题。这一章我们来聊滑动窗口(Sliding Window),它本质上是双指针的一个特化版本,专门用来解决连续子序列问题。
滑动窗口的核心思想可以用一句话概括:维护一个窗口(由左右两个指针界定),通过右指针扩大窗口、左指针缩小窗口的方式,在 O(n) 时间内找到满足条件的最优子序列。
为什么滑动窗口值得单独拿出来讲?因为它是前端面试中出现频率最高的算法题型之一。“无重复字符的最长子串”、“最小覆盖子串”这类题,几乎是大厂面试的标配。而且滑动窗口有一个非常好用的通用模板,一旦掌握,就能套用到各种变体题目上。
本章我们会讲解:固定窗口 vs 可变窗口的区别、通用模板代码、三道经典 LeetCode 题目,让你彻底吃透这个技巧。
诊断自测
Q1:给定字符串 "abcabcbb",找出不含重复字符的最长子串的长度。你的思路是什么?
点击查看答案
使用滑动窗口。维护一个窗口 [left, right] 和一个 Set/Map 记录窗口内的字符。右指针不断右移扩大窗口;当窗口内出现重复字符时,左指针右移缩小窗口直到没有重复。过程中记录窗口的最大长度。答案是 3(“abc”)。
Q2:固定窗口和可变窗口的区别是什么?分别适用于什么场景?
点击查看答案
固定窗口:窗口大小不变,两个指针同步移动。适用于”大小为 k 的子数组”类问题,如”大小为 k 的子数组的最大平均值”。可变窗口:窗口大小可伸缩,右指针扩大窗口、左指针按条件收缩。适用于”满足某条件的最长/最短子数组”类问题。
一、固定窗口 vs 可变窗口
1.1 固定窗口
窗口大小是预先确定的(通常题目会给出 k),两个指针保持固定间距同步右移。
// 固定窗口模板
function fixedWindow(arr, k) {
// 先初始化第一个窗口
let windowSum = 0;
for (let i = 0; i < k; i++) {
windowSum += arr[i];
}
let maxSum = windowSum;
// 窗口滑动:加入右边新元素,移出左边旧元素
for (let i = k; i < arr.length; i++) {
windowSum += arr[i]; // 新元素进入窗口
windowSum -= arr[i - k]; // 旧元素离开窗口
maxSum = Math.max(maxSum, windowSum);
}
return maxSum;
}
固定窗口比较简单,面试中出现频率不高,我们重点讲可变窗口。
1.2 可变窗口
窗口大小可伸缩,这才是滑动窗口的精髓。根据目标不同,可变窗口又分为两种:
- 找最长:右指针尽量扩大窗口,只有当窗口不满足条件时才收缩左指针
- 找最短:右指针扩大窗口直到满足条件,然后尝试收缩左指针来寻找更短的答案
二、可变窗口通用模板
下面这个模板可以覆盖绝大多数可变窗口的题目:
function slidingWindow(s) {
const window = new Map(); // 维护窗口内的状态
let left = 0;
let result = 初始值;
for (let right = 0; right < s.length; right++) {
// 1. 右指针元素进入窗口,更新窗口状态
const charIn = s[right];
// ... 更新 window ...
// 2. 判断是否需要收缩窗口
while (窗口需要收缩的条件) {
// 3. 左指针元素离开窗口,更新窗口状态
const charOut = s[left];
// ... 更新 window ...
left++;
}
// 4. 更新结果(根据题目要求,可能在收缩前/后/过程中更新)
result = 更新逻辑;
}
return result;
}
模板的关键点:
- 右指针只管往右走,不会回退——这保证了 O(n) 的时间复杂度
- 左指针在内层循环中收缩,但它在整个过程中也只会从左走到右,总共最多移动 n 次
- 窗口状态用 Map 或其他数据结构维护,保证进出窗口的操作都是 O(1)
- 收缩条件和结果更新时机是每道题的差异点,也是需要思考的核心
三、经典题目
3.1 无重复字符的最长子串(LeetCode 3)
给定一个字符串
s,找出其中不含有重复字符的最长子串的长度。
这是滑动窗口最经典的入门题。
思路:窗口内不能有重复字符。右指针扩大窗口时,如果新字符已经在窗口中了,就收缩左指针直到窗口中没有重复字符。
function lengthOfLongestSubstring(s) {
const window = new Set();
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
// 如果右指针的字符已经在窗口中,收缩左指针
while (window.has(s[right])) {
window.delete(s[left]);
left++;
}
// 右指针字符进入窗口
window.add(s[right]);
// 更新结果
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
还有一种用 Map 的优化写法,可以直接跳过中间的字符:
function lengthOfLongestSubstring(s) {
const map = new Map(); // 记录每个字符最后出现的位置
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
if (map.has(s[right]) && map.get(s[right]) >= left) {
// 直接跳到重复字符的下一个位置
left = map.get(s[right]) + 1;
}
map.set(s[right], right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
复杂度分析:
- 时间复杂度:O(n),每个字符最多被访问两次(进窗口一次,出窗口一次)
- 空间复杂度:O(min(n, m)),m 是字符集大小
3.2 长度最小的子数组(LeetCode 209)
给定一个正整数数组
nums和一个正整数target,找出满足和 >= target 的最短连续子数组的长度。
这是”找最短”的典型题目。
思路:右指针扩大窗口累加元素,当窗口和 >= target 时,尝试收缩左指针来寻找更短的答案。
function minSubArrayLen(target, nums) {
let left = 0;
let sum = 0;
let minLen = Infinity;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
// 窗口和满足条件时,尝试收缩
while (sum >= target) {
minLen = Math.min(minLen, right - left + 1);
sum -= nums[left];
left++;
}
}
return minLen === Infinity ? 0 : minLen;
}
注意:这道题之所以可以用滑动窗口,是因为数组元素都是正整数。正整数保证了窗口和的单调性——右指针右移和一定增大,左指针右移和一定减小。如果数组包含负数,滑动窗口就失效了(参见前缀和那章的误区四)。
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
3.3 最小覆盖子串(LeetCode 76)
给定字符串
s和t,在s中找出包含t所有字符的最短子串。
这是滑动窗口的”终极 Boss”题目,面试中出现频率非常高,难度也最大。
思路:
- 先用一个 Map 统计
t中每个字符需要的个数 - 右指针扩大窗口,每次新字符进入时更新”已满足的字符数”
- 当所有字符都满足时,尝试收缩左指针,找更短的答案
- 在收缩过程中更新最优解
function minWindow(s, t) {
// need:t 中每个字符需要的个数
const need = new Map();
for (const c of t) {
need.set(c, (need.get(c) || 0) + 1);
}
// window:当前窗口中各字符的个数
const window = new Map();
let left = 0;
let valid = 0; // 已满足条件的字符种类数
let start = 0; // 最优子串的起始位置
let minLen = Infinity;
for (let right = 0; right < s.length; right++) {
const charIn = s[right];
// 右指针字符进入窗口
if (need.has(charIn)) {
window.set(charIn, (window.get(charIn) || 0) + 1);
if (window.get(charIn) === need.get(charIn)) {
valid++;
}
}
// 所有字符都满足时,尝试收缩窗口
while (valid === need.size) {
// 更新最优解
if (right - left + 1 < minLen) {
start = left;
minLen = right - left + 1;
}
const charOut = s[left];
left++;
// 左指针字符离开窗口
if (need.has(charOut)) {
if (window.get(charOut) === need.get(charOut)) {
valid--;
}
window.set(charOut, window.get(charOut) - 1);
}
}
}
return minLen === Infinity ? '' : s.substring(start, start + minLen);
}
代码解读:
need记录目标字符串t中每个字符需要出现的次数window记录当前窗口中每个字符的个数valid记录当前窗口中已经满足条件的字符种类数(不是字符个数)- 当
valid === need.size时,说明窗口已经包含了t的所有字符 - 进出窗口时,只有当某个字符的数量恰好达到或恰好不足时,才更新
valid
复杂度分析:
- 时间复杂度:O(n + m),n 是 s 的长度,m 是 t 的长度
- 空间复杂度:O(m)
面试追问:为什么用 valid === need.size 而不是直接比较两个 Map?
答:直接比较两个 Map 需要遍历所有键值对,每次比较是 O(m)。而维护一个计数器 valid,每次只需要 O(1) 的判断。这是一个常见的优化技巧。
四、如何判断一道题是否适合滑动窗口?
- 题目求的是连续子序列(子串/子数组) — 不是子序列(可以不连续)
- 窗口的扩大和缩小有单调性 — 扩大窗口一定”更满足”或”更不满足”条件
- 正整数数组的区间和 — 可以用滑动窗口
- 含负数的数组 — 通常不能用滑动窗口,考虑前缀和
滑动窗口 vs 双指针 vs 前缀和的选择:
| 特征 | 技巧 |
|---|---|
| 有序数组找一对元素 | 对撞指针 |
| 连续子序列 + 正整数 | 滑动窗口 |
| 连续子序列 + 含负数 | 前缀和 + 哈希表 |
| 原地操作保持顺序 | 快慢指针 |
常见误区
误区一:“滑动窗口的内层 while 循环使得时间复杂度变成 O(n²)”
这是最常见的误解。虽然代码里有两层循环,但左指针在整个过程中只从左到右移动了一次,总共最多移动 n 步。所以内层循环的总执行次数是 O(n),而不是每次外层循环都执行 O(n) 次。总时间复杂度仍然是 O(n)。
一个好的理解方式是:每个元素最多进入窗口一次、离开窗口一次,所以总操作次数是 2n。
误区二:“滑动窗口只能用在字符串题上”
虽然最经典的几道题都是字符串题,但滑动窗口在数组中同样适用。“长度最小的子数组”、“乘积小于 K 的子数组”(LeetCode 713)、“最大连续 1 的个数 III”(LeetCode 1004)都是数组上的滑动窗口题。
误区三:“窗口收缩的条件一定是 while”
大多数”找最短”的题用 while 收缩(只要条件满足就一直缩),但”找最长”的题有时候用 if 就够了(只缩一步,保持窗口不会太大)。具体用 while 还是 if,取决于你是要找所有满足条件的最优解,还是只需要保证窗口状态合法。
误区四:“含负数的数组也可以用滑动窗口求子数组和”
不行。滑动窗口依赖一个关键假设:右指针右移使窗口”更接近满足条件”,左指针右移使窗口”更接近不满足条件”(或反过来)。负数的存在会打破这种单调性。比如 sum < target 时右指针右移,如果下一个元素是负数,sum 反而变小了,窗口收缩的逻辑就失效了。
小结
滑动窗口是双指针的一种特化形式,专门解决连续子序列问题。它的核心价值在于:右指针只往右走、左指针也只往右走,保证每个元素最多被处理两次,从而将暴力的 O(n²) 优化到 O(n)。
核心要点
- 固定窗口:两个指针保持固定间距同步移动,适用于”大小为 k”的子数组问题
- 可变窗口:右指针扩大、左指针收缩,适用于”最长/最短满足条件”的子序列问题
- 通用模板:for 循环驱动右指针,while 循环收缩左指针,Map 维护窗口状态
- 适用前提:窗口的扩大/缩小有单调性(正整数和、字符出现次数等)
- 与前缀和的区别:含负数的子数组和问题用前缀和,不用滑动窗口
本章思维导图
- 核心思想
- 双指针维护一个连续窗口
- 右指针扩大、左指针收缩
- 每个元素最多进出窗口各一次 → O(n)
- 固定窗口
- 窗口大小 k 不变
- 新元素进、旧元素出
- 适用:"大小为 k 的子数组"类问题
- 可变窗口
- 找最长:窗口不合法时收缩
- 找最短:窗口合法时收缩(找更优解)
- 通用模板:for + while + Map
- 经典题
- 无重复字符的最长子串(LeetCode 3)
- 长度最小的子数组(LeetCode 209)
- 最小覆盖子串(LeetCode 76)
- 适用条件
- 连续子序列(非子序列)
- 窗口扩大/缩小有单调性
- 正整数和 → 可以
- 含负数 → 不行,用前缀和
- 窗口状态维护
- Map/Set 记录字符出现次数
- valid 计数器避免每次比较 Map
练习挑战
第一题 ⭐:大小为 K 的子数组的最大平均值(LeetCode 643)
给定一个数组 nums 和一个整数 k,找出长度为 k 的子数组中的最大平均值。
// 示例
// 输入: nums = [1, 12, -5, -6, 50, 3], k = 4
// 输出: 12.75(子数组 [12, -5, -6, 50] 的平均值)
点击查看答案
function findMaxAverage(nums, k) {
// 初始化第一个窗口
let windowSum = 0;
for (let i = 0; i < k; i++) {
windowSum += nums[i];
}
let maxSum = windowSum;
// 滑动窗口
for (let i = k; i < nums.length; i++) {
windowSum += nums[i] - nums[i - k];
maxSum = Math.max(maxSum, windowSum);
}
return maxSum / k;
}
固定窗口模板。先算出第一个窗口的和,然后每次加入右边新元素、减去左边旧元素。因为 k 固定,最后除以 k 即可。
时间复杂度 O(n),空间复杂度 O(1)。
第二题 ⭐⭐:字符串的排列(LeetCode 567)
给定字符串 s1 和 s2,判断 s2 是否包含 s1 的排列(即 s2 中是否存在一个子串是 s1 的某种排列)。
// 示例
// 输入: s1 = "ab", s2 = "eidbaooo"
// 输出: true(s2 包含 s1 的排列 "ba")
点击查看答案
function checkInclusion(s1, s2) {
const need = new Map();
for (const c of s1) {
need.set(c, (need.get(c) || 0) + 1);
}
const window = new Map();
let left = 0;
let valid = 0;
for (let right = 0; right < s2.length; right++) {
const charIn = s2[right];
if (need.has(charIn)) {
window.set(charIn, (window.get(charIn) || 0) + 1);
if (window.get(charIn) === need.get(charIn)) {
valid++;
}
}
// 窗口大小超过 s1 长度时,收缩左边
while (right - left + 1 > s1.length) {
const charOut = s2[left];
left++;
if (need.has(charOut)) {
if (window.get(charOut) === need.get(charOut)) {
valid--;
}
window.set(charOut, window.get(charOut) - 1);
}
}
// 窗口大小恰好等于 s1 长度且所有字符都匹配
if (valid === need.size) {
return true;
}
}
return false;
}
思路:固定大小为 s1.length 的窗口在 s2 上滑动,用 Map 记录字符频次,当所有字符都满足时说明找到了排列。
时间复杂度 O(n),空间复杂度 O(1)(字符集大小固定)。
第三题 ⭐⭐⭐:最大连续 1 的个数 III(LeetCode 1004)
给定一个二进制数组 nums 和一个整数 k,最多可以翻转 k 个 0 为 1,返回最长的连续 1 的个数。
// 示例
// 输入: nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2
// 输出: 6(翻转下标 5 和 10 的 0)
点击查看答案
function longestOnes(nums, k) {
let left = 0;
let zeroCount = 0;
let maxLen = 0;
for (let right = 0; right < nums.length; right++) {
if (nums[right] === 0) {
zeroCount++;
}
// 窗口内 0 的个数超过 k 时,收缩左指针
while (zeroCount > k) {
if (nums[left] === 0) {
zeroCount--;
}
left++;
}
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
思路:转化问题——“最多翻转 k 个 0 后的最长连续 1” 等价于 “包含最多 k 个 0 的最长子数组”。用滑动窗口维护窗口内 0 的个数,超过 k 就收缩左指针。
时间复杂度 O(n),空间复杂度 O(1)。
自我检测
- 能说清楚固定窗口和可变窗口的区别及各自的适用场景
- 能默写可变窗口的通用模板(for + while + Map)
- 能用滑动窗口解决”无重复字符的最长子串”
- 能解决”最小覆盖子串”,并解释
valid计数器的作用 - 能解释为什么滑动窗口的时间复杂度是 O(n) 而不是 O(n²)
- 能判断一道题是否适合用滑动窗口(关键:连续子序列 + 单调性)
- 能区分什么时候用滑动窗口、什么时候用前缀和
- 能用滑动窗口解决”长度最小的子数组”,并解释为什么数组必须是正整数
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90