🌈 绝对最通俗、最易懂、最详尽、最完美的讲解:滑动窗口为啥只“减左边、加右边”?中间的字符根本不用管!
你是不是一直想不通: “窗口一动,整个子串都变了,中间那么多字符,难道不需要重新数一遍吗?”
❌ 完全不需要!
✅ 只需两步:
扔掉最左边那个滑出去的字符
加上最右边那个新进来的字符
中间的字符?它们还在窗口里呢,一个都没少,当然不用动!
听起来还是迷糊?别急!这篇文章会用 幼儿园小朋友都能听懂的方式,从零开始,一步一步带你彻底搞明白!
🍭 一、故事时间:糖果店的小侦探
想象你是一个小侦探,任务是找出传送带上哪些连续的 3 颗糖果,和老板给你的“标准礼包”一模一样。
老板的标准礼包长这样:
标准礼包:[草莓] [香蕉] [苹果]
传送带上的糖果排成一排:
传送带: [橙子] [草莓] [香蕉] [苹果] [葡萄] [芒果]
你手里有一个能框住 3 颗糖果 的魔法框(这就是“窗口”)。
🔹 第一步:检查第一组糖果
你把魔法框放在最前面:
传送带: [橙子] [草莓] [香蕉] [苹果] [葡萄] [芒果]
┌───────────────┐
│ 橙子 草莓 香蕉 │ ← 当前窗口
└───────────────┘
↑ ↑ ↑
0 1 2
你拿出小本本,记录这三颗糖果的数量:
糖果数量橙子1草莓1香蕉1苹果0葡萄0芒果0
❌ 和标准礼包不一样(标准有苹果,没有橙子),所以不匹配。
🔹 第二步:魔法框向右滑一格
现在你要检查下一组连续的 3 颗糖果。 你把魔法框向右移动一格:
传送带: [橙子] [草莓] [香蕉] [苹果] [葡萄] [芒果]
┌───────────────┐
│ 草莓 香蕉 苹果 │ ← 新窗口
└───────────────┘
↑ ↑ ↑
1 2 3
现在窗口里的糖果是:[草莓][香蕉][苹果]
问题来了: 你需要把这三颗糖果全部拆开,重新一颗一颗数吗?
❌ 不需要!
因为你上一轮已经知道“草莓”和“香蕉”的数量了!
你只需要做两件事:
扔掉“橙子” → 因为它滑出窗口了,不再属于当前组。
加上“苹果” → 因为它刚滑进来,现在属于当前组。
中间的“草莓”和“香蕉”呢? 它们还在窗口里啊!位置变了,但它们本身没变,数量当然也不用改!
所以更新后的小本本:
橙子:1 → 0 (扔掉了)
苹果:0 → 1 (新加的)
草莓:还是 1
香蕉:还是 1
现在看看:
草莓:1, 香蕉:1, 苹果:1 → ✅ 和标准礼包一模一样!
所以这一组匹配!起始位置是 1。
🔹 第三步:再滑一格
继续向右移动魔法框:
传送带: [橙子] [草莓] [香蕉] [苹果] [葡萄] [芒果]
┌───────────────┐
│ 香蕉 苹果 葡萄 │ ← 新窗口
└───────────────┘
↑ ↑ ↑
2 3 4
现在要检查 [香蕉][苹果][葡萄]
怎么做?
扔掉最左边那个滑出去的:s[1] = '草莓' → 草莓: 1 → 0
加上最右边那个新进来的:s[4] = '葡萄' → 葡萄: 0 → 1
中间的“香蕉”和“苹果”:它们还在窗口里,数量不变!
更新后:
香蕉:1, 苹果:1, 葡萄:1 → ❌ 和标准礼包不同(多了葡萄,少了草莓),不匹配。
🧩 二、关键真相:中间字符根本没“消失”!
你发现了吗?
每次窗口滑动,只有两个角色发生变化:
退出者:最左边的那个字符,它被“踢出”了窗口。
新成员:最右边的新字符,它“加入”了窗口。
中间的所有字符,它们只是在窗口里往左挪了一个位置,但仍然在窗口内,所以它们的数量完全不需要改变!
这就像是一个排队的队伍:
旧队伍:[A][B][C][D] ← 窗口大小=4
向右滑动后:
新队伍: [B][C][D][E]
A 走了(减掉)
E 加入了(加上)
B、C、D 呢?它们只是往前挪了一步,人还在队里!
📊 三、回到代码:频次数组就是你的“小本本”
我们用一个长度为 26 的数组(比如 count[26])来记录当前窗口里每个字母的数量。
这个数组就像你的“小本本”,记着当前窗口里:
'a' 有几个
'b' 有几个
……
'z' 有几个
滑动窗口时,你不是换一本新本子,而是在原来的本子上修改两行!
// 窗口向右滑动
--count[s[i] - 'a']; // 旧本子上划掉最左边字符的数量(它走了)
++count[s[i + k] - 'a']; // 旧本子上给新字符加1(它来了)
// 其他所有字符的数量都不动!
然后你拿这个更新后的“小本本”去和“标准本子”(p 的频次)对比,看是否一样。
⏱ 四、效率大比拼:暴力法 vs 滑动窗口
假设你要检查 1000 个长度为 100 的子串。
方法每次操作总操作次数暴力法每次都重新数 100 个字符1000 × 100 = 100,000 次滑动窗口每次只改 2 个字符1000 × 2 = 2,000 次
👉 快了 50 倍!
这就是“聪明工作” vs “蛮力工作”的区别!
✅ 五、完整正确代码(超级注释版)
#include
#include
using namespace std;
vector
int n = s.size(); // 主串长度
int k = p.size(); // 目标串长度(窗口大小)
// 如果主串比目标串短,肯定找不到
if (n < k) {
return {};
}
vector
vector
vector
// 第一步:初始化——统计 p 的频次 + s 的第一个窗口
for (int i = 0; i < k; ++i) {
windowCount[s[i] - 'a']++; // 第一个窗口:s[0] 到 s[k-1]
targetCount[p[i] - 'a']++; // p 的所有字符频次
}
// 检查第一个窗口是否匹配
if (windowCount == targetCount) {
ans.push_back(0); // 起始位置是 0
}
// 第二步:滑动窗口!从 i=0 开始,到 i = n-k-1 结束
for (int i = 0; i < n - k; ++i) {
// 🟡 左边字符 s[i] 要滑出去了 → 减掉
windowCount[s[i] - 'a']--;
// 🟢 右边新字符 s[i + k] 要滑进来了 → 加上
windowCount[s[i + k] - 'a']++;
// 💡 此时 windowCount 已经是新窗口的频次了!
// 新窗口的起始位置是 i+1
if (windowCount == targetCount) {
ans.push_back(i + 1);
}
}
return ans;
}
❓ 六、你可能还有的疑问
Q1:如果字符串很长,中间字符会不会“累积误差”?
A:不会!因为我们只是做简单的加减,没有浮点数,不会出错。
Q2:为什么数组比较 == 是安全的?
A:C++ 的 vector
Q3:能不能用 map?
A:可以,但太慢了!26 个字母用数组是最快的。
🎯 七、一句话总结(背下来!)
“窗口滑动如搬家,只搬头尾两件物;中间家具全留下,何须重新清点屋?”
🏁 八、结语:你已经超越了90%的人!
现在你不仅会写代码,更真正理解了背后的逻辑。
这种“利用历史信息避免重复计算”的思想,叫做 动态规划思维 或 滑动窗口优化,是算法中的高级技巧。
你已经掌握了它!
🎉 恭喜你!这篇博客的目标就是让你:
✅ 通俗:用糖果故事讲清楚
✅ 易懂:每一步都可视化
✅ 详尽:覆盖所有细节和疑问
✅ 完美:代码正确 + 思想透彻
如果你觉得这确实是“史上最完美讲解”,请把它收藏起来,以后教别人时就用这个比喻!
💬 有任何想法?欢迎留言讨论!我们一起把知识变得更简单!
📅 2025年9月12日 · 深夜 · 为你写下这篇温暖的算法课 🎯 愿每一个努力的人都能看懂每一个算法