滑动窗口算法详解与JavaScript实现

9 阅读10分钟

滑动窗口算法详解与JavaScript实现

滑动窗口算法概述

滑动窗口算法是一种高效的双指针技术,主要用于处理连续子数组或子串问题 。其核心思想是通过维护一个动态的窗口区间,来避免重复计算,从而将时间复杂度从O(n²)优化到O(n)。在处理像滑动窗口最大值这类问题时,滑动窗口算法能显著提高效率,特别适合大规模数据处理场景。

滑动窗口可以分为固定长度窗口和可变长度窗口两种类型 。固定长度窗口适用于需要计算特定长度子序列的情况(如本题),而可变长度窗口常用于满足特定条件的最长子序列问题(如无重复字符的最长子串)。两种类型的实现方式有所不同,但都基于双指针的基本思想。

滑动窗口算法原理

窗口基本操作

滑动窗口算法通过两个指针(通常称为left和right)来维护一个区间,该区间被称为"窗口" 。right指针负责扩展窗口,left指针负责收缩窗口。窗口的基本操作包括:

  1. 进窗口:right指针向右移动,将新元素加入窗口
  2. 出窗口:left指针向右移动,将旧元素从窗口中移除
  3. 更新结果:在窗口满足特定条件时,记录当前窗口的结果

滑动窗口最大值问题分析

对于滑动窗口最大值问题,给定一个整数数组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]

代码解析

  1. 初始化处理:首先处理特殊情况,当数组为空或窗口大小为0时返回空数组;当窗口大小为1时直接返回原数组。

  2. 双端队列实现:使用JavaScript数组模拟双端队列,push()pop()分别对应队尾入队和出队,shift()对应队首出队。

  3. 窗口扩展与维护

    • 移除过期元素:当队列头部元素的索引超出当前窗口范围(即i - k)时,将其从队列中移除。
    • 维护单调性:将当前元素与队列尾部元素比较,如果当前元素更大,则移除队列尾部元素,直到队列为空或队列尾部元素大于当前元素,然后将当前元素索引加入队列尾部。
  4. 记录结果:当窗口形成(即i >= k - 1)时,队列头部元素即为当前窗口的最大值,将其存入结果数组。

时间复杂度分析

每个元素最多被加入队列一次,最多被移出队列一次,因此总时间复杂度为O(n) 。虽然JavaScript的shift()操作在数组头部移除元素的时间复杂度为O(k),但由于每个元素最多被shift()一次,整体时间复杂度仍为O(n)。

滑动窗口算法的应用场景

滑动窗口算法适用于多种连续子序列问题,主要分为固定窗口和可变窗口两种类型:

固定窗口类型

  1. 滑动窗口最大值/最小值(LeetCode 239):使用单调队列维护窗口极值,时间复杂度O(n) 。
  2. 大小为K的子数组最大平均值(LeetCode 1456):固定窗口滑动,维护窗口内元素和,时间复杂度O(n) 。
  3. 连续子数组的最大和(LeetCode 53):虽然通常使用动态规划,但也可以用滑动窗口思想优化。

可变窗口类型

  1. 无重复字符的最长子串(LeetCode 3):使用哈希集合记录字符位置,动态调整窗口左边界,时间复杂度O(n) 。
  2. 长度最小的子数组(LeetCode 209):寻找和≥target的最短连续子数组,时间复杂度O(n) 。
  3. 最小覆盖子串(LeetCode 76):寻找包含目标所有字符的最短子串,使用哈希表统计字符频率,时间复杂度O(n) 。

滑动窗口算法的优势与适用条件

算法优势

  1. 时间效率高:将暴力解法的O(n²)优化到O(n),适合大规模数据处理 。
  2. 空间复杂度低:通常仅需O(1)或O(k)的额外空间,内存占用小 。
  3. 逻辑简单清晰:双指针控制窗口边界,状态更新直观,易于理解和实现。
  4. 灵活多变:可通过调整窗口大小和状态维护逻辑,适应多种问题场景 。

适用条件

  1. 连续子区间问题:元素必须连续,窗口滑动方向固定(如从左到右) 。
  2. 状态可快速更新:窗口扩展或收缩时,能通过简单操作(如加减元素、哈希表增减)维护当前状态 。
  3. 单调性或极值需求:如求最大值/最小值时,可通过数据结构(如单调队列)高效维护极值 。
  4. 问题具有贪心性质:在满足条件的情况下,局部最优解可以推导出全局最优解 。

其他经典滑动窗口问题示例

问题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) 。其核心在于找到问题的窗口特性,并选择合适的数据结构(如双端队列、哈希表)来维护窗口状态。

在实现滑动窗口算法时,需要特别注意以下几点:

  1. 窗口边界处理:确保left和right指针的移动逻辑正确,避免越界或遗漏元素。
  2. 状态维护:根据问题需求,选择合适的数据结构来记录窗口内的状态(如和、最大值、字符频率等)。
  3. 结果记录:确定何时记录结果以及如何记录,通常是在窗口形成后或满足特定条件时。
  4. 边界条件:处理空数组、窗口大小为1、窗口大小超过数组长度等特殊情况。

滑动窗口算法的应用范围广泛,从字符串处理到数组统计,从图像识别到实时数据流分析,都能找到其身影。掌握滑动窗口算法不仅能够解决LeetCode中的经典问题,还能为实际工程中的高效数据处理提供有力工具

在实际应用中,滑动窗口算法可以与其他数据结构(如堆、哈希表)结合使用,进一步扩展其解决能力。例如,当需要处理更复杂的窗口极值问题时,可以结合单调队列;当需要处理多维度的窗口状态时,可以结合哈希表或计数器。