滑动窗口
滑动窗口(Sliding Window)是一种常用的双指针技巧,主要用于解决数组或字符串的子数组/子串问题,尤其是涉及连续、固定或可变长度、满足某种条件的最值问题(如最长、最短、包含特定元素等)。
基本思想
使用两个指针(通常叫 left 和 right)维护一个“窗口”:
- 窗口:由两个指针
left和right定义的连续子数组[left, right]。 - 滑动:
right不断向右扩展(扩大窗口),当窗口内不满足条件时,left向右收缩(缩小窗口)。 - 目标:在滑动过程中,记录满足条件的最优解(如最长/最短长度、最大和等)。
时间复杂度通常是 O(n) ,因为每个元素最多被访问两次(进窗口一次,出窗口一次)
通用模板
let left = 0;
for (let right = 0; right < n; right++) {
// 1. 将 right 元素加入窗口(更新状态)
while (窗口不满足条件) {
// 2. 移除 left 元素,收缩窗口
left++;
}
// 3. 此时窗口满足条件,更新答案
}
1. 无重复字符的最长子串
题目描述
给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
解题思路
使用可变滑动窗口 + 哈希集合(Set):
right指针不断扩张窗口,将新字符加入Set;- 当遇到重复字符(
Set.has(c)为真)时,说明窗口不合法; - 此时移动
left指针,逐个删除左侧字符,直到窗口中不再包含该重复字符; - 每次窗口合法时,更新最大长度
ans = max(ans, right - left + 1)。
💡 关键洞察:窗口
[left, right]始终维护一个“无重复字符”的子串。通过收缩左边界,确保每次加入新字符后窗口依然合法。
代码实现
/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function(s) {
let ans = 0;
let left = 0;
const window = new Set();
for (let right = 0; right < s.length; right++) {
const c = s[right];
// 若窗口中已有 c,收缩左边界直到无重复
while (window.has(c)) {
window.delete(s[left]);
left++;
}
window.add(c);
ans = Math.max(ans, right - left + 1);
}
return ans;
};
复杂度分析
- 时间复杂度:O(n),每个字符最多被
left和right各访问一次; - 空间复杂度:O(∣Σ∣),其中 ∣Σ∣ 为字符集大小(如 ASCII 为 128)。
2. 找到字符串中所有字母异位词
题目描述
给定两个字符串 s(主串)和 p(模式串),找出 s 中所有 p 的字母异位词的起始索引。
字母异位词:由相同字符以任意顺序组成的字符串(如 "abc" 与 "bca")。
解题思路
使用固定长度滑动窗口 + 哈希表频次匹配:
- 先统计
p中每个字符的出现次数,存入map; - 初始化窗口为
s的前pLen个字符,对应减少map中的计数; - 若此时
map中所有值均为 0,说明窗口与p是异位词,记录索引0; - 窗口向右滑动:每次移出左侧字符(计数 +1),移入右侧字符(计数 -1);
- 每次滑动后检查
map是否全零,若是则记录当前起始位置。
代码实现
/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function (s, p) {
let sLen = s.length
let pLen = p.length
let ans = []
if (pLen > sLen) return ans
const map = new Map()
// 用 Map 记录 p 中每个字符的频次(目标频次)
for (let c of p) {
map.set(c, (map.get(c) || 0) + 1)
}
// 初始化滑动窗口
for (let i = 0; i < pLen; i++) {
if (map.has(s[i])) map.set(s[i], map.get(s[i]) - 1)
}
if (isAllZero(map)) ans.push(0)
// 开始滑动窗口
for(let right = pLen; right<sLen;right++){
const left = right-pLen
// 右边界扩展
if(map.has(s[right]))map.set(s[right],map.get(s[right])-1)
// 左边界收缩
if(map.has(s[left]))map.set(s[left],map.get(s[left])+1)
// 窗口满足条件,更新答案
if(isAllZero(map)) ans.push(left+1)
}
return ans
};
function isAllZero(map) {
for (let [key, val] of map) {
if (val !== 0) return false
}
return true
}
复杂度分析
- 时间复杂度:O(n),其中 n 为
s的长度。初始化窗口 O(m),滑动过程 O(n - m),isAllZero最坏 O(k)(k 为字符种类),但 k 通常很小(如 26); - 空间复杂度:O(k),哈希表存储字符频次。
总结对比
| 题目 | 窗口类型 | 核心技巧 |
|---|---|---|
| 无重复字符的最长子串 | 可变窗口 | 动态去重,遇重复收缩左边界 |
| 找到所有字母异位词 | 固定窗口 | 频次匹配,滑动时更新计数 |
右扩左缩,维护状态,条件判断,更新答案
- 右指针:负责探索新元素,扩大窗口
- 左指针:在满足/不满足条件时收缩窗口
- 状态维护:用哈希表、计数器、sum 等记录窗口信息
- 结果更新:在合适时机(通常在收缩过程中)更新最优解
掌握这两种模式,你就能轻松应对 LeetCode 中绝大多数子串类问题。滑动窗口 + 哈希表,是处理字符串连续区间问题的黄金组合 💫。
希望这篇解析对你有帮助!如果你喜欢这类深入浅出的算法讲解,欢迎关注我的 LeetCode 系列文章 👋