滑动窗口算法详解与JavaScript实现
滑动窗口算法概述
滑动窗口算法是一种高效的双指针技术,主要用于处理连续子数组或子串问题 。其核心思想是通过维护一个动态的窗口区间,来避免重复计算,从而将时间复杂度从O(n²)优化到O(n)。在处理像滑动窗口最大值这类问题时,滑动窗口算法能显著提高效率,特别适合大规模数据处理场景。
滑动窗口可以分为固定长度窗口和可变长度窗口两种类型 。固定长度窗口适用于需要计算特定长度子序列的情况(如本题),而可变长度窗口常用于满足特定条件的最长子序列问题(如无重复字符的最长子串)。两种类型的实现方式有所不同,但都基于双指针的基本思想。
滑动窗口算法原理
窗口基本操作
滑动窗口算法通过两个指针(通常称为left和right)来维护一个区间,该区间被称为"窗口" 。right指针负责扩展窗口,left指针负责收缩窗口。窗口的基本操作包括:
- 进窗口:right指针向右移动,将新元素加入窗口
- 出窗口:left指针向右移动,将旧元素从窗口中移除
- 更新结果:在窗口满足特定条件时,记录当前窗口的结果
滑动窗口最大值问题分析
对于滑动窗口最大值问题,给定一个整数数组nums和一个窗口大小k,要求返回每个滑动窗口中的最大值。如果直接使用暴力解法,每个窗口都需要遍历k个元素找出最大值,时间复杂度为O(nk),对于大规模数据来说效率低下。
关键观察点:当窗口滑动时,大部分元素并没有发生变化,只有左端移出一个元素,右端移入一个新元素。因此,如果我们能够高效地维护窗口中的最大值,就可以避免重复计算 。
单调队列解法
为了解决滑动窗口最大值问题,我们可以使用单调队列(双端队列)来维护窗口中的元素,使得队列中的元素保持单调递减的顺序 。队列中存储的是元素的索引,而非元素本身,这样可以方便地判断元素是否已经滑出窗口 。
单调队列的特性使得队列头部始终是当前窗口的最大值,这样我们就可以在O(1)的时间复杂度内获取窗口的最大值。整个算法的时间复杂度为O(n),空间复杂度为O(k)(队列中最多容纳k个元素)。
滑动窗口最大值的JavaScript实现
function maxSlidingWindow(nums, k) {
// 处理特殊情况
if (nums.length === 0 || k === 0) return [];
if (k === 1) return nums;
const deque = [];
const result = [];
for (let i = 0; i < nums.length; i++) {
// 移除队首超出窗口左边界的元素
while (deque.length > 0 && deque[0] <= i - k) {
deque.shift();
}
// 维护单调递减队列
while (deque.length > 0 && nums[deque[deque.length - 1]] <= nums[i]) {
deque.pop();
}
deque.push(i);
// 当窗口形成时,记录最大值
if (i >= k - 1) {
result.push(nums[deque[0]]);
}
}
return result;
}
// 示例
const nums1 = [1,3,-1,-3,5,3,6,7];
const k1 = 3;
console.log(maxSlidingWindow(nums1, k1)); // 输出: [3,3,5,5,6,7]
const nums2 = [1];
const k2 = 1;
console.log(maxSlidingWindow(nums2, k2)); // 输出: [1]
代码解析
-
初始化处理:首先处理特殊情况,当数组为空或窗口大小为0时返回空数组;当窗口大小为1时直接返回原数组。
-
双端队列实现:使用JavaScript数组模拟双端队列,
push()和pop()分别对应队尾入队和出队,shift()对应队首出队。 -
窗口扩展与维护:
- 移除过期元素:当队列头部元素的索引超出当前窗口范围(即
i - k)时,将其从队列中移除。 - 维护单调性:将当前元素与队列尾部元素比较,如果当前元素更大,则移除队列尾部元素,直到队列为空或队列尾部元素大于当前元素,然后将当前元素索引加入队列尾部。
- 移除过期元素:当队列头部元素的索引超出当前窗口范围(即
-
记录结果:当窗口形成(即
i >= k - 1)时,队列头部元素即为当前窗口的最大值,将其存入结果数组。
时间复杂度分析
每个元素最多被加入队列一次,最多被移出队列一次,因此总时间复杂度为O(n) 。虽然JavaScript的shift()操作在数组头部移除元素的时间复杂度为O(k),但由于每个元素最多被shift()一次,整体时间复杂度仍为O(n)。
滑动窗口算法的应用场景
滑动窗口算法适用于多种连续子序列问题,主要分为固定窗口和可变窗口两种类型:
固定窗口类型
- 滑动窗口最大值/最小值(LeetCode 239):使用单调队列维护窗口极值,时间复杂度O(n) 。
- 大小为K的子数组最大平均值(LeetCode 1456):固定窗口滑动,维护窗口内元素和,时间复杂度O(n) 。
- 连续子数组的最大和(LeetCode 53):虽然通常使用动态规划,但也可以用滑动窗口思想优化。
可变窗口类型
- 无重复字符的最长子串(LeetCode 3):使用哈希集合记录字符位置,动态调整窗口左边界,时间复杂度O(n) 。
- 长度最小的子数组(LeetCode 209):寻找和≥target的最短连续子数组,时间复杂度O(n) 。
- 最小覆盖子串(LeetCode 76):寻找包含目标所有字符的最短子串,使用哈希表统计字符频率,时间复杂度O(n) 。
滑动窗口算法的优势与适用条件
算法优势
- 时间效率高:将暴力解法的O(n²)优化到O(n),适合大规模数据处理 。
- 空间复杂度低:通常仅需O(1)或O(k)的额外空间,内存占用小 。
- 逻辑简单清晰:双指针控制窗口边界,状态更新直观,易于理解和实现。
- 灵活多变:可通过调整窗口大小和状态维护逻辑,适应多种问题场景 。
适用条件
- 连续子区间问题:元素必须连续,窗口滑动方向固定(如从左到右) 。
- 状态可快速更新:窗口扩展或收缩时,能通过简单操作(如加减元素、哈希表增减)维护当前状态 。
- 单调性或极值需求:如求最大值/最小值时,可通过数据结构(如单调队列)高效维护极值 。
- 问题具有贪心性质:在满足条件的情况下,局部最优解可以推导出全局最优解 。
其他经典滑动窗口问题示例
问题1:字符串中的排列(LeetCode 567)
题目描述:给定两个字符串s1和s2,判断s2中是否存在一个长度与s1相等的子串,其字符排列与s1完全一致。
解法思路:使用固定窗口大小为s1长度的滑动窗口,在s2上滑动,通过字符频率数组比较是否匹配 。
function checkInclusion(s1, s2) {
if (s1.length > s2.length) return false;
const countS1 = new Array(26).fill(0);
const countS2 = new Array(26).fill(0);
// 统计s1的字符频率
for (let i = 0; i < s1.length; i++) {
countS1[s1 charAt(i).charCodeAt(0) - 'a'.charCodeAt(0)]++;
}
let left = 0;
for (let right = 0; right < s2.length; right++) {
const c = s2 charAt(right);
countS2[c charCodeAt(0) - 'a'.charCodeAt(0)]++;
// 当窗口大小超过s1长度时,移除左端字符
if (right >= s1.length) {
const d = s2 charAt(left);
countS2[d charCodeAt(0) - 'a'.charCodeAt(0)]--;
left++;
}
// 比较两个频率数组是否相等
if (countS1.join(',') === countS2.join(',')) {
return true;
}
}
return false;
}
问题2:爱生气的书店老板(LeetCode 1052)
题目描述:书店老板可以连续X分钟不生气(只能使用一次),找出最多有多少客户能够感到满意。
解法思路:固定窗口大小为X,维护一个滑动窗口,计算窗口内可以增加的满意顾客数 。
function maxSatisfied(customers, grumpy, X) {
let total = 0;
// 计算原本满意的顾客数
for (let i = 0; i < customers.length; i++) {
if (grumpy[i] === 0) {
total += customers[i];
}
}
if (X === 0) return total;
let maxIncrease = 0;
let currentIncrease = 0;
// 初始化窗口
for (let i = 0; i < X; i++) {
if (grumpy[i] === 1) {
currentIncrease += customers[i];
}
}
maxIncrease = currentIncrease;
// 滑动窗口
for (let i = X; i < customers.length; i++) {
if (grumpy[i - X] === 1) {
currentIncrease -= customers[i - X];
}
if (grumpy[i] === 1) {
currentIncrease += customers[i];
}
maxIncrease = Math.max(maxIncrease, currentIncrease);
}
return total + maxIncrease;
}
问题3:无重复字符的最长子串(LeetCode 3)
题目描述:找出字符串中不含有重复字符的最长子串的长度。
解法思路:使用可变窗口大小,哈希表记录字符最后出现的位置,动态调整窗口左边界 。
function lengthOfLongestSubstring(s) {
const charMap = new Map();
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
const c = s[right];
// 如果字符已出现且在当前窗口内
if (charMap.has(c) && charMap.get(c) >= left) {
left = charMap.get(c) + 1;
}
charMap.set(c, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
滑动窗口算法的进阶应用
问题4:最小覆盖子串(LeetCode 76)
题目描述:在字符串s中找到涵盖t所有字符的最小子串。
解法思路:使用可变窗口大小,哈希表统计字符频率,滑动窗口扩展与收缩时更新状态 。
function minWindow(s, t) {
if (t.length === 0) return "";
const need = new Map();
const window = new Map();
// 统计t中各字符出现次数
for (const c of t) {
need.set(c, (need.get(c) || 0) + 1);
}
let left = 0, right = 0;
let valid = 0;
let start = 0, length = Number.MAX_VALUE;
const required = t.length; // t中不同字符的种类数
while (right < s.length) {
const c = s[right];
right++;
// 更新窗口状态
if (need.has(c)) {
window.set(c, (window.get(c) || 0) + 1);
if (window.get(c) === need.get(c)) {
valid++;
}
}
// 当窗口满足条件时,尝试收缩
while (valid === required && left <= right) {
// 更新最小覆盖子串
if (right - left < length) {
start = left;
length = right - left;
}
const d = s[left];
left++;
// 更新窗口状态
if (need.has(d)) {
if (window.get(d) === need.get(d)) {
valid--;
}
window.set(d, window.get(d) - 1);
}
}
}
return length === Number.MAX_VALUE ? "" : s.slice(start, start + length);
}
滑动窗口算法的总结与思考
滑动窗口算法作为一种高效的双指针技术,通过动态维护窗口边界和内部状态,避免了重复计算,将时间复杂度从O(n²)优化到O(n) 。其核心在于找到问题的窗口特性,并选择合适的数据结构(如双端队列、哈希表)来维护窗口状态。
在实现滑动窗口算法时,需要特别注意以下几点:
- 窗口边界处理:确保left和right指针的移动逻辑正确,避免越界或遗漏元素。
- 状态维护:根据问题需求,选择合适的数据结构来记录窗口内的状态(如和、最大值、字符频率等)。
- 结果记录:确定何时记录结果以及如何记录,通常是在窗口形成后或满足特定条件时。
- 边界条件:处理空数组、窗口大小为1、窗口大小超过数组长度等特殊情况。
滑动窗口算法的应用范围广泛,从字符串处理到数组统计,从图像识别到实时数据流分析,都能找到其身影。掌握滑动窗口算法不仅能够解决LeetCode中的经典问题,还能为实际工程中的高效数据处理提供有力工具。
在实际应用中,滑动窗口算法可以与其他数据结构(如堆、哈希表)结合使用,进一步扩展其解决能力。例如,当需要处理更复杂的窗口极值问题时,可以结合单调队列;当需要处理多维度的窗口状态时,可以结合哈希表或计数器。