一、什么是滑动窗口?
核心思想:在数组/字符串上维护一个"窗口",通过移动窗口的左右边界,在一次遍历中解决问题。
滑动窗口解决的核心问题是"子数组/子串"类问题——传统需要 $O(N^2)$ 嵌套循环,滑动窗口降到 $O(N)$。
两种窗口:
- 定长窗口:窗口大小固定,像火车一样整体平移。
- 不定长窗口:窗口大小动态变化,右边界不断扩张,左边界按条件收缩。
二、不定长窗口——无重复字符的最长子串(LeetCode 3)
问题:给定字符串,找出不包含重复字符的最长子串长度。
思路:
s = "abcabcbb"
窗口演变(用 set 或 map 记录窗口内字符):
[a] left=0, right=0 → 不重复,窗口扩大
[a b] left=0, right=1 → 不重复,窗口扩大
[a b c] left=0, right=2 → 不重复,窗口扩大
[a b c a] left=0, right=3 → 'a' 重复!左边界右移
[b c a] left=1, right=3 → 不重复,窗口扩大
[b c a b] left=1, right=4 → 'b' 重复!左边界右移
[c a b] left=2, right=4 → 不重复
...继续
最大窗口长度 = 3("abc" 或 "bca" 或 "cab")
整体操作流程:
- 初始化:创建一个数组
last_seen记录每个字符上次出现的索引(初始为 -1),左指针left = 0。 - 扩展右边界:遍历字符串,每次将右指针
right指向的字符纳入窗口。 - 检测重复并收缩:如果当前字符在窗口内出现过(
last_seen[c] >= left),将左边界跳到重复字符的下一个位置(left = last_seen[c] + 1)。 - 更新位置:更新当前字符的最新出现位置为
right。 - 更新结果:计算当前窗口长度
right - left + 1,更新全局最大值。 - 返回结果:遍历结束后返回最大长度。
int lengthOfLongestSubstring(string s) {
vector<int> last_seen(128, -1); // ① 记录每个字符上次出现的索引
int max_len = 0;
int left = 0; // ② 左指针(窗口左边界)
for (int right = 0; right < s.size(); right++) {// ③ 右指针向右扩展
char c = s[right];
if (last_seen[c] >= left) { // ④ c 在窗口内重复了
left = last_seen[c] + 1; // ⑤ 左边界跳到重复字符之后
}
last_seen[c] = right; // ⑥ 更新 c 的最新位置
max_len = max(max_len, right - left + 1);// ⑦ 更新最大窗口长度
}
return max_len;
}
关键点:用 vector<int>(128, -1) 比 unordered_map 更快(哈希表有计算开销),因为 ASCII 字符范围有限,用数组直接索引。
时间复杂度:$O(N)$。right 遍历一次,left 只右移不左移。 空间复杂度:$O(1)$。固定 128 大小的数组。
三、不定长窗口——最小覆盖子串(LeetCode 76)
问题:在 s 中找到包含 t 所有字符的最短子串。
这是滑动窗口最难的模板题,掌握了这道题,所有"找包含某集合的最短子串"都能做。
思路:
s = "ADOBECODEBANC", t = "ABC"
1. 先统计 t 中每个字符需要几个:{A:1, B:1, C:1}
2. 右边界不断扩张,直到窗口覆盖了 t 的所有字符
3. 然后收缩左边界,找到最短的覆盖子串
4. 重复:右边界再扩张,寻找下一个覆盖窗口
窗口过程:
[A] 还需 B,C
[A D] 还需 B,C
[A D O] 还需 B,C
[A D O B] 还需 C ← 有 A 和 B 了
[A D O B E] 还需 C
[A D O B E C] 全了!窗口 "ADOBEC",尝试收缩
[D O B E C] 还有 (但只有 D 被移除)
[O B E C] 不够了 (A 没了) → 右边界继续
[O B E C O] 还需 A
[O B E C O D] 还需 A
[O B E C O D E] 还需 A
[O B E C O D E B] 还需 A
[O B E C O D E B A] 全了!"ODEBANC"
[B E C O D E B A] 收缩 → "BECODEBA"
[E C O D E B A] → "ECODEB A"
[C O D E B A] → "CODEBA"
[O D E B A] 不够了 → 右边界继续...
最终最短的覆盖子串 = "BANC"
整体操作流程:
- 初始化需求:统计
t中每个字符的出现次数到need数组,required = t.size()记录还需要匹配的字符总数。 - 扩展右边界:遍历
s,每次将s[right]纳入窗口,need[rc]--。如果该字符是t中需要的(need[rc] > 0),required--。 - 检查是否覆盖:当
required == 0时,说明当前窗口已包含t的所有字符。 - 收缩左边界:在
required == 0的情况下,尝试收缩左边界:- 记录当前窗口长度,更新最短结果。
- 将
s[left]移出窗口,need[lc]++。 - 如果
need[lc] > 0,说明移出的字符是t需要的且不够了,required++。
- 重复:回到步骤 2 继续扩展右边界,寻找下一个覆盖窗口。
- 返回:返回最短覆盖子串(若未找到则返回空串)。
string minWindow(string s, string t) {
if (s.size() < t.size()) return "";
vector<int> need(128, 0); // ① need[c] > 0 表示还缺 c
for (char c : t) need[c]++; // ② 统计 t 中每个字符的需求量
int left = 0, right = 0;
int min_len = INT_MAX, min_start = 0;
int required = t.size(); // ③ 还需匹配的字符总数
while (right < s.size()) { // ④ 右边界遍历整个 s
char rc = s[right];
right++;
if (need[rc] > 0) required--; // ⑤ rc 是需要的 → 匹配数减一
need[rc]--; // ⑥ rc 纳入窗口(need 可为负,表示多余)
while (required == 0) { // ⑦ 窗口已覆盖 t 的所有字符
int len = right - left;
if (len < min_len) { // ⑧ 更新最短覆盖子串
min_len = len;
min_start = left;
}
char lc = s[left];
left++; // ⑨ 收缩左边界
need[lc]++; // ⑩ lc 移出窗口,需求恢复
if (need[lc] > 0) required++; // ⑪ 移出后不够了 → required++
}
}
return min_len == INT_MAX ? "" : s.substr(min_start, min_len);
}
时间复杂度:$O(N)$。right 遍历一次 + left 最多移动 N 次。 空间复杂度:$O(1)$。固定 128 数组。
四、定长窗口——字符串排列(LeetCode 567)
问题:s2 是否包含 s1 的排列(即 s1 的某个排列是 s2 的连续子串)。
思路:
s1 = "ab", s2 = "eidbaooo"
固定窗口大小为 s1.size() = 2
窗口在 s2 上滑动:
[e i] → a:0,b:0? → 不匹配(需要 a=1,b=1)
[i d] → a:0,b:0? → 不匹配
[d b] → a:0,b:1? → a 不够
[b a] → a:1,b:1 ✅ 匹配!
整体操作流程:
- 长度检查:如果
s1比s2长,直接返回false。 - 初始化需求:统计
s1中每个字符的出现次数到need数组,required = s1.size()记录还需匹配的字符数。 - 滑动窗口遍历
s2:- 扩展右边界,纳入
s2[right],更新need和required。 - 定长控制:如果窗口大小超过了
s1.size(),收缩左边界(移出左侧字符,恢复need和required)。 - 匹配检测:如果
required == 0且窗口大小等于s1.size(),说明找到了一个排列,返回true。
- 扩展右边界,纳入
- 遍历结束:未找到匹配,返回
false。
bool checkInclusion(string s1, string s2) {
if (s1.size() > s2.size()) return false; // ① s1 比 s2 长 → 不可能
vector<int> need(26, 0); // ② 26 个小写字母
for (char c : s1) need[c - 'a']++; // ③ 统计 s1 中字符需求
int left = 0, right = 0;
int required = s1.size(); // ④ 还需匹配的字符数
while (right < s2.size()) { // ⑤ 右边界遍历 s2
char rc = s2[right];
right++;
if (need[rc - 'a'] > 0) required--; // ⑥ rc 是需要的 → 匹配数减一
need[rc - 'a']--;
if (right - left > s1.size()) { // ⑦ 窗口超过定长 → 收缩左边界
char lc = s2[left];
left++;
need[lc - 'a']++;
if (need[lc - 'a'] > 0) required++;
}
if (required == 0 && right - left == s1.size()) {
return true; // ⑧ 找到匹配的排列
}
}
return false; // ⑨ 未找到
}
时间复杂度:$O(N)$。固定窗口一次遍历。
五、滑动窗口通用模板(面试直接套)
int slidingWindow(string s) {
// ① 用数组/哈希表记录窗口内的状态
vector<int> window(128, 0); // 或 unordered_map
int left = 0, right = 0;
int result = 0; // 根据问题定义
while (right < s.size()) {
// ② 移入右边界字符,更新窗口
char rc = s[right];
right++;
// 更新窗口状态...
// ③ 收缩左边界(条件取决于问题)
while (/* 窗口需要收缩的条件 */) {
char lc = s[left];
left++;
// 更新窗口状态...
}
// ④ 更新结果(此时窗口满足条件)
result = max(result, right - left);
}
return result;
}
六、面试追问 Q&A
Q1:滑动窗口什么时候不能用? A:当问题的解不连续时不能用。滑动窗口要求解是一段连续的区间。如果问题允许跳过元素(如"最长子序列"而非"最长子串"),滑动窗口不适用,要用 DP。
Q2:定长窗口和不定长窗口怎么选? A:题目明确说"大小为 k 的子数组/子串"就是定长。题目说"某个条件成立的最大/最小窗口"就是不定长。最小覆盖子串是不定长收缩到最短;无重复字符是最长所以不定长扩大。
Q3:vector<int>(128) 和 unordered_map<char,int> 选哪个?
A:字符串题目(字符范围有限)用 vector<int>(128),这是数组索引,比哈希表快 5-10 倍。如果键是更一般的类型,才用 unordered_map。
Q4:三数之和的 while (left < right) 里为什么有去重?
A:如果不跳过重复元素,比如 [-1, -1, 0, 1, 2],固定 -1 时 left 在第二个 -1 上,会得到重复的 [-1, 0, 1]。去重保证三元组唯一,但 left++ 和 right-- 后的去重不能漏掉合法解(先记录结果,再跳重复)。