滑动窗口专题总结:从懵逼到掌握valid计数器
学习记录
- 时间: 3天(10.5 - 10.7)
- 题量: 8题
- 完成度: 8/8(全部AC,第3题一次AC!)
我的学习过程
这是我算法学习的第5-7天,继续深化滑动窗口。
Day04-05(10.5-10.6): 滑动窗口基础(4题)
- 和≥target最短子数组、无重复字符、字母异位词、最大连续1
- 前两题犯了"索引vs值"错误,第3题一次AC了!
Day06(10.7): 滑动窗口进阶(4题)
- 字符串排列、水果成篮、将x减到0、最小覆盖子串
- 第1题数组越界,第4题是BOSS题,完全不会,学了valid计数器
滑动窗口的核心概念(我的理解)
什么是滑动窗口?
我的理解就是:用两个指针(left和right)维护一个"窗口",窗口在数组上滑动,每次滑动都更新窗口内的数据。
关键是:
- 什么时候扩大窗口?(right++)
- 什么时候缩小窗口?(left++)
- 什么时候更新结果?
滑动窗口 vs 双指针
相同点: 都是两个指针
不同点:
- 双指针:两个指针各自独立移动
- 滑动窗口:两个指针维护一个区间,right一直往右,left按条件移动
滑动窗口的两种类型
通过这8道题,我发现滑动窗口主要有2种:
1. 可变窗口(求最长/最短)
特点: 窗口大小动态变化
适用: 找最长/最短满足某条件的子数组/子串
模板:
for(int left = 0, right = 0; right < n; right++) {// 1. 进窗口进窗口操作(right);// 2. 判断 + 出窗口(条件不满足时收缩)while(/* 条件不满足 */) {出窗口操作(left);left++;}// 3. 更新结果ret = max(ret, right - left + 1); // 求最长
}
我做过的题:
- 和≥target最短子数组(LeetCode 209)
- 无重复字符的最长子串(LeetCode 3)⭐
- 最大连续1的个数III(LeetCode 1004)
- 水果成篮(LeetCode 904)
- 将x减到0(LeetCode 1658)
- 最小覆盖子串(LeetCode 76)⭐⭐⭐
2. 固定窗口(异位词问题)
特点: 窗口大小固定不变
适用: 找固定长度的满足某条件的子数组/子串
模板:
// 1. 建立第一个窗口
for(int i = 0; i < len; i++) {window[s[i]]++;
}
if(window == target) ret.push_back(0);// 2. 滑动窗口
for(int right = len; right < n; right++) {int left = right - len;// 出窗口window[s[left]]--;if(window[s[left]] == 0) {window.erase(s[left]); // ⚠️ erase很重要!}// 进窗口window[s[right]]++;// 判断if(window == target) {ret.push_back(left + 1);}
}
我做过的题:
- 找到字符串中所有字母异位词(LeetCode 438)
- 字符串的排列(LeetCode 567)
典型题目分类
类型1:可变窗口(求最长)
LeetCode 3. 无重复字符的最长子串 ⭐⭐⭐
我的第一次错误:又是索引vs值!
while(hash[right] > 1) { // ❌ right是索引,不是字符!hash[s[left++]]--;
}
错在哪: right
是索引(0,1,2…),s[right]
才是字符(‘a’,‘b’,‘c’…)
正确写法:
while(hash[s[right]] > 1) { // ✅ s[right]是字符hash[s[left++]]--;
}
又是第5次犯这个错误了! 这次我终于意识到:写代码前先问自己,要索引还是值?
完整正确代码:
int left = 0, right = 0;
int hash[128] = {0}; // 字符频次
int ret = 0;while(right < s.size()) {hash[s[right]]++; // 进窗口while(hash[s[right]] > 1) { // 有重复,收缩窗口hash[s[left++]]--;}ret = max(ret, right - left + 1); // 更新结果right++;
}
关键点:
- 用hash数组记录字符出现次数
- 当某个字符出现2次时,收缩左边界
- 求最长,所以在while外更新结果
LeetCode 1004. 最大连续1的个数III
这题是我第一次一次AC!✨
思路: 最多替换k个0 → 找一个窗口,窗口内最多有k个0
int ret = 0;
for(int left = 0, right = 0, zero = 0; right < nums.size(); right++) {if(nums[right] == 0) zero++; // 进窗口while(zero > k) { // 0太多了,收缩if(nums[left++] == 0) zero--;}ret = max(ret, right - left + 1); // 更新结果
}
为什么一次AC?
- 因为前面做了3题可变窗口,模板已经熟了
- 而且这次没犯"索引vs值"错误!
nums[right] == 0
写对了!
类型2:可变窗口(求最短)
LeetCode 76. 最小覆盖子串 ⭐⭐⭐⭐⭐
这题是滑动窗口的BOSS题!我一开始完全不会。
我的第一次尝试(完全错了):
for(auto c: target) { // ❌ 应该是 for(auto c: t)target[t[c]]++;
}int len = t.size();
for(int i = 0; i < len; i++) { // ❌ 以为是固定窗口windows[s[i]]++;
}if(windows >= target) { // ❌ map不能直接比较// ...
}
错误点:
- 统计target时写错了
- 以为是固定窗口,其实是可变窗口
- 不知道如何判断窗口是否满足条件
核心难点:如何判断窗口包含了所有目标字符?
答案:用 valid
计数器!
int valid = 0; // 满足条件的字符种类数// 进窗口时:
if(window[c] == target[c]) { // 刚好满足valid++;
}// 判断满足条件:
if(valid == target.size()) {// 所有字符都满足了!
}// 出窗口时:
if(window[d] == target[d]) { // 出窗口前刚好满足valid--; // 出窗口后就不满足了
}
为什么用valid?
如果不用valid,需要每次都遍历map比较:
// ❌ 这样太慢了(每次O(k))
bool check() {for (auto& [ch, cnt] : target) {if (window[ch] < cnt) return false;}return true;
}
用valid只需要O(1):
// ✅ 只需要判断一个变量
if (valid == target.size()) {// 满足条件
}
完整正确代码:
unordered_map<char, int> target, window;
for (char c : t) target[c]++;int left = 0, right = 0, valid = 0;
int start = 0, len = INT_MAX;while (right < s.size()) {// 进窗口char c = s[right++];if (target.count(c)) {window[c]++;if (window[c] == target[c]) valid++;}// 收缩窗口(满足条件时尝试缩小)while (valid == target.size()) {// 更新结果(求最短,在while内更新)if (right - left < len) {start = left;len = right - left;}// 出窗口char d = s[left++];if (target.count(d)) {if (window[d] == target[d]) valid--;window[d]--;}}
}return len == INT_MAX ? "" : s.substr(start, len);
关键点:
- valid计数器:记录满足要求的字符种类数(不是字符个数!)
- 求最短:更新结果在while内(和求最长相反)
- 返回字符串:要记录start和len,不能只记录长度
- 窗口移动:right一直往右,left只在满足条件时才往右收缩
类型3:固定窗口(异位词问题)
LeetCode 438. 找到字符串中所有字母异位词
我的第一次错误:
for(int j = left; j < right; j++) { // ❌ 应该是 j <= rightd[s[j]]++;
}
问题1: 初始窗口统计不完整,漏掉了最后一个字符
问题2: 每次都重新统计整个窗口,应该是增量更新
问题3: 窗口移动顺序错了
正确思路(固定窗口):
- 先统计p的字符频次
- 初始化第一个完整窗口 [0, len-1]
- 循环:判断 → 出窗口 → 进窗口
// 1. 统计p
for (char c : p) target[c]++;// 2. 初始化第一个窗口
for (int i = 0; i < len; i++) {window[s[i]]++;
}
if (window == target) ret.push_back(0);// 3. 滑动窗口
for (int right = len; right < n; right++) {int left = right - len;// 出窗口window[s[left]]--;if (window[s[left]] == 0) {window.erase(s[left]); // 必须erase!}// 进窗口window[s[right]]++;// 判断if (window == target) {ret.push_back(left + 1);}
}
为什么必须erase?
如果不erase,值为0的key还在map里:
target = {'a':1, 'b':1, 'c':1} // 3个key
window = {'a':1, 'b':1, 'c':1, 'd':0} // 4个keywindow == target? → false ❌ // key数量不同(3 vs 4)
C++的map比较会比较"key的数量",即使value=0,key存在就算不同!
LeetCode 567. 字符串的排列
这题和上一题几乎一样,只是返回bool。
我的错误:数组越界
int len = s1.size();
for(int i = 0; i < len; i++) { // ❌ 如果s2比s1短,越界!windows[s2[i]]++;
}
教训:固定窗口的第0步:先判断边界!
if(s2.size() < s1.size()) return false; // 必须先判断
类型4:思维转换(难点)
LeetCode 1658. 将x减到0的最小操作数
这题的关键是思维转换!
原问题: 从左边或右边移除元素,使得移除的和 = x,求最小操作数
转换后: 找中间最长连续子数组,使得和 = sum - x
为什么可以转换?
数学推导:
[左边移除] + [中间保留] + [右边移除] = nums设:
- 左边移除的和 = L
- 中间保留的和 = M
- 右边移除的和 = R
- 总和 = sum = L + M + R原问题要求:L + R = x
→ sum - M = x
→ M = sum - x目标转换:
最小化移除的元素个数
= 最大化保留的元素个数
= 找最长的中间连续子数组,和 = sum - x
正确代码:
int sum = 0;
for(int c : nums) sum += c;int target = sum - x;
if(target < 0) return -1; // 边界情况int ret = -1;
for(int left = 0, right = 0, tmp = 0; right < nums.size(); right++) {tmp += nums[right]; // 进窗口while(tmp > target) { // 和太大,收缩tmp -= nums[left++];}if(tmp == target) { // 找到了ret = max(ret, right - left + 1);}
}return ret == -1 ? -1 : nums.size() - ret;
关键点:
- 转换后变成标准的可变窗口求最长子数组
- 答案 = 总长度 - 保留的长度
我踩的坑(总结)
坑1:固定窗口的边界判断(第1次遇到)
int len = s1.size();
for(int i = 0; i < len; i++) {window[s2[i]]++; // ❌ 如果s2比s1短,越界!
}
教训: 固定窗口第0步永远是:先判断边界!
if(s2.size() < s1.size()) return false;
坑2:固定窗口必须erase
window[s[left]]--;
// ❌ 如果不判断,值为0的key还在
必须判断:
if(window[s[left]] == 0) {window.erase(s[left]);
}
原因: map比较时会比较key的数量,值为0的key也算一个key。
坑3:可变窗口的更新位置
求最长: 更新结果在while外
while(条件不满足) {出窗口;
}
ret = max(ret, right - left + 1); // 在while外
求最短: 更新结果在while内
while(条件满足) {ret = min(ret, right - left); // 在while内出窗口;
}
原因:
- 求最长:窗口越大越好,收缩后更新(取收缩前的最大值)
- 求最短:窗口越小越好,收缩前更新(取收缩中的最小值)
坑4:valid计数器的维护(最难)
进窗口:
if(window[c] == target[c]) { // 刚好满足时valid++;
}
出窗口:
if(window[d] == target[d]) { // 出窗口前刚好满足valid--;
}
window[d]--; // 注意顺序!先判断再减
如果顺序错了:
window[d]--; // ❌ 先减
if(window[d] == target[d]) {valid--; // 判断的是减之后的值,错了!
}
坑5:窗口移动的理解
我一开始的疑惑: 满足条件就收缩,不满足就不收缩?还是一定会往右走?
答案: 两个指针独立移动!
- right: 一直往右走(每次外层循环都+1)
- left: 只在满足条件时往右收缩(内层循环)
while(right < n) { // 外层:right一直往右进窗口;right++;while(条件满足) { // 内层:left只在满足条件时才往右更新结果;出窗口;left++;}
}
我的薄弱环节
- valid计数器的维护:最小覆盖子串的valid还不够熟练
- 思维转换能力:将x减到0的转换思路,要再理解一遍
- 固定窗口 vs 可变窗口:有时不知道该用哪个
下一步计划
- 最小覆盖子串要再做一遍(valid计数器是核心)
- 异位词问题要再做一遍(erase的理解)
- 继续学习其他专题(可能是栈、队列或二分查找)
典型模板总结
可变窗口模板(求最长)
for(int left = 0, right = 0; right < n; right++) {进窗口(right);while(条件不满足) {出窗口(left);left++;}ret = max(ret, right - left + 1); // 在while外
}
可变窗口模板(求最短)
while(right < n) {进窗口(right);right++;while(条件满足) {ret = min(ret, right - left); // 在while内出窗口(left);left++;}
}
固定窗口模板
// 第0步:判断边界
if(n < len) return false;// 第1步:初始化第一个窗口
for(int i = 0; i < len; i++) {window[s[i]]++;
}
if(window == target) ret.push_back(0);// 第2步:滑动窗口
for(int right = len; right < n; right++) {int left = right - len;// 出窗口window[s[left]]--;if(window[s[left]] == 0) window.erase(s[left]);// 进窗口window[s[right]]++;// 判断if(window == target) ret.push_back(left + 1);
}
最小覆盖子串模板(valid计数器)
unordered_map<char, int> target, window;
for(char c : t) target[c]++;int left = 0, right = 0, valid = 0;while(right < n) {char c = s[right++];if(target.count(c)) {window[c]++;if(window[c] == target[c]) valid++;}while(valid == target.size()) {// 更新结果char d = s[left++];if(target.count(d)) {if(window[d] == target[d]) valid--;window[d]--;}}
}
我的理解
滑动窗口的本质:
- 双指针的进阶版,两个指针维护一个区间
- right一直往右走,left按条件收缩
- 通过维护窗口内的状态(和、频次等)来优化时间复杂度
两种类型的选择:
- 固定窗口: 题目要求固定长度 → 异位词问题
- 可变窗口: 题目要求找最长/最短 → 大部分滑动窗口题
三个关键操作:
- 进窗口: 更新窗口内的状态(sum、hash、count等)
- 判断: 检查是否满足条件
- 出窗口: 更新状态,移动left
更新结果的位置:
- 求最长:在while外更新(窗口越大越好)
- 求最短:在while内更新(窗口越小越好)
什么时候复习:
- 最小覆盖子串必须再做一遍(valid计数器是核心技巧)
- 字母异位词再做一遍(erase的理解)
- 将x减到0再做一遍(思维转换)
valid计数器的理解:
- 记录的是"满足要求的字符种类数",不是字符个数
window[c] == target[c]
时才valid++
(刚好满足)- 出窗口时也要判断(先判断再减)
- 这是滑动窗口最难的技巧,但也是最核心的
写于: 2025年10月8日
刷题进度: 9题 → 17题
上个专题: 双指针(已完成9题)
下个专题: 栈和队列 或 二分查找