滑动窗口最大值的高效实现与思路解析

在数组操作类面试题中,“滑动窗口最大值”是一道极具代表性的题目,它不仅考察对数组遍历的基础能力,更侧重对时间复杂度优化数据结构选型的理解。许多初学者会用暴力解法实现,但面对大规模数据时会因效率问题失败。本文将从题目解析、思路演进、多语言实现到优化总结,带你掌握兼顾效率与鲁棒性的解题方案。

一、题目解析:明确需求与约束

1. 题目要求

给定一个数组 nums 和一个滑动窗口的大小 k,让滑动窗口从数组的最左侧移动到最右侧,要求输出每一次滑动后窗口内元素的最大值。注意:滑动窗口每次只向右移动一位,且数组长度 n 满足 n >= k >= 1

2. 输入输出示例

为更直观理解滑动窗口的移动与结果输出,以下是典型示例:

输入数组 nums

窗口大小 k

滑动过程与输出结果

[2,3,4,2,6,2,5,1]

3

窗口依次为 [2,3,4](max=4)、[3,4,2](max=4)、[4,2,6](max=6)、[2,6,2](max=6)、[6,2,5](max=6)、[2,5,1](max=5),最终输出 [4,4,6,6,6,5]

[1,3,-1,-3,5,3,6,7]

3

输出 [3,3,5,5,6,7]

[1]

1

输出 [1](单个元素,窗口无移动)

二、核心难点:为什么暴力解法不可行?

这道题的核心挑战在于如何在滑动窗口移动时,快速找到窗口内的最大值,而非重复遍历窗口元素。我们先分析常见的暴力解法问题,再引出高效思路:

  • 暴力解法思路:对于每个窗口(共 n - k + 1 个),遍历窗口内的 k 个元素,找到最大值并记录。

  • 时间复杂度O(n*k)。当 n 达到 10^5k 达到 10^4 时,计算量会达到 10^9,远超程序运行的时间上限(通常为 10^8 次操作/秒),必然会超时。

  • 关键矛盾:每次窗口滑动仅新增右侧一个元素、移除左侧一个元素,暴力解法却重复遍历整个窗口,存在大量冗余计算。因此,高效解法的核心是用合适的数据结构记录窗口内的“候选最大值”,避免重复遍历

三、实现思路:单调队列(双端队列)的核心应用

目前业界公认的最优解法是使用单调队列(Monotonic Queue)(通常用双端队列 Deque 实现),其核心思想是:让队列内始终保持“从队首到队尾元素递减”的顺序,队首元素即为当前窗口的最大值。具体操作分为以下 3 个步骤:

步骤 1:初始化双端队列,处理第一个窗口

  • 遍历数组的前 k 个元素(第一个窗口),对于每个元素 nums[i]

  1. 若队列不为空,且当前元素 nums[i] 大于等于队列尾部元素,则将尾部元素弹出(因为这些元素在当前窗口内,不可能成为最大值,后续窗口也不会用到它们);

  2. 将当前元素 nums[i] 加入队列尾部;

  • 此时队列队首元素即为第一个窗口的最大值,加入结果列表。

  • 步骤 2:滑动窗口移动,维护队列单调性

    从第 k 个元素开始(窗口开始向右移动),对于每个元素 nums[i](当前窗口为 [i - k + 1, i]):

    子步骤 2.1:移除“窗口外”的元素

    判断队列队首元素是否等于 nums[i - k](即上一个窗口的左侧元素,当前窗口已移出该元素):

    • 若是,则将队首元素弹出(该元素不再属于当前窗口,不能作为最大值);

    • 若不是,则无需操作(队首元素仍在当前窗口内)。

    子步骤 2.2:维护队列递减顺序

    重复步骤 1 的逻辑:若队列不为空,且 nums[i] 大于等于队列尾部元素,则弹出尾部元素,直至队列满足“递减”要求,再将 nums[i] 加入队列尾部。

    子步骤 2.3:记录当前窗口最大值

    此时队列队首元素即为当前窗口的最大值,加入结果列表。

    步骤 3:输出结果列表

    遍历结束后,结果列表中存储的就是所有窗口的最大值,返回该列表。

    为什么单调队列高效?

    • 时间复杂度O(n)。每个元素最多被加入队列一次、弹出队列一次,整体操作次数为 2n,无冗余计算;

    • 空间复杂度O(k)。队列内最多存储 k 个元素(极端情况:数组严格递减,队列存储整个窗口元素)。

    四、多语言实现:代码与详细解读

    不同语言的双端队列 API 略有差异,以下分别提供 Java(标准双端队列) 和 Python(collections.deque) 的实现,覆盖主流面试场景。

    1. Java 实现(基于 LinkedList 双端队列)

    Java 中可用 LinkedList 实现双端队列功能(addLast()removeLast()peekFirst() 等方法),代码逻辑严格遵循上述步骤:

    import java.util.ArrayList;
    import java.util.LinkedList;
    import java.util.List;
    
    public class Solution {
        public int[] maxSlidingWindow(int[] nums, int k) {
            // 边界判断:数组为空或窗口大小为0,返回空数组
            if (nums == null || nums.length == 0 || k == 0) {
                return new int[0];
            }
            int n = nums.length;
            // 结果数组长度 = 数组长度 - 窗口大小 + 1
            int[] result = new int[n - k + 1];
            int resIndex = 0; // 结果数组的索引
            // 双端队列:存储数组元素的索引(而非元素值,便于判断是否在窗口内)
            LinkedList<Integer> deque = new LinkedList<>();
    
            for (int i = 0; i < n; i++) {
                // 步骤1/2.2:维护队列递减顺序,弹出尾部小于当前元素的索引
                while (!deque.isEmpty() && nums[i] >= nums[deque.peekLast()])           {
                    deque.removeLast();
                }
                // 将当前元素索引加入队列尾部
                deque.addLast(i);
    
                // 步骤2.1:移除窗口外的元素(窗口左边界为 i - k)
                while (deque.peekFirst() <= i - k) {
                    deque.removeFirst();
                }
    
                // 步骤1/2.3:从第 k-1 个元素开始(第一个窗口遍历完成),记录最大值
                if (i >= k - 1) {
                    result[resIndex++] = nums[deque.peekFirst()];
                }
            }
            return result;
        }
    
        // 测试示例
        public static void main(String[] args) {
            Solution solution = new Solution();
            int[] nums1 = {2,3,4,2,6,2,5,1};
            int k1 = 3;
            int[] res1 = solution.maxSlidingWindow(nums1, k1);
            // 输出 [4,4,6,6,6,5]
            for (int num : res1) {
                System.out.print(num + " ");
            }
    
            System.out.println();
            int[] nums2 = {1,3,-1,-3,5,3,6,7};
            int k2 = 3;
            int[] res2 = solution.maxSlidingWindow(nums2, k2);
            // 输出 [3,3,5,5,6,7]
            for (int num : res2) {
                System.out.print(num + " ");
            }
        }
    }
    Java 代码关键说明:
    • 存储索引而非元素值:队列中存储数组元素的索引,而非元素本身,这是判断“元素是否在当前窗口内”的关键(通过 deque.peekFirst() <= i - k 判断);

    • while 循环维护单调性:使用 while 而非 if,是因为可能存在多个尾部元素小于当前元素(如窗口内元素为 [4,3,2],当前元素为 5,需弹出 4、3、2 三个元素);

    • 结果记录时机:当 i >= k - 1 时,第一个窗口遍历完成,之后每遍历一个元素就对应一个新窗口,需记录最大值。

    2. Python 实现(基于 collections.deque)

    Python 中 collections.deque 提供了高效的双端队列操作(append()pop()popleft() 等),代码逻辑与 Java 一致,但语法更简洁:

    from collections import deque
    from typing import List
    
    class Solution:
        def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
            # 边界判断:数组为空或窗口大小为0,返回空列表
            if not nums or k == 0:
                return []
            n = len(nums)
            result = []
            # 双端队列:存储元素索引
            dq = deque()
    
            for i in range(n):
                # 维护队列递减顺序,弹出尾部小于当前元素的索引
                while dq and nums[i] >= nums[dq[-1]]:
                    dq.pop()
                # 加入当前元素索引
                dq.append(i)
    
                # 移除窗口外的元素(窗口左边界为 i - k)
                while dq[0] <= i - k:
                    dq.popleft()
    
                # 从第一个窗口(i >= k-1)开始记录最大值
                if i >= k - 1:
                    result.append(nums[dq[0]])
            
            return result
    
    # 测试示例
    if __name__ == "__main__":
        solution = Solution()
        nums1 = [2,3,4,2,6,2,5,1]
        k1 = 3
        res1 = solution.maxSlidingWindow(nums1, k1)
        print(res1)  # 输出 [4,4,6,6,6,5]
    
        nums2 = [1,3,-1,-3,5,3,6,7]
        k2 = 3
        res2 = solution.maxSlidingWindow(nums2, k2)
        print(res2)  # 输出 [3,3,5,5,6,7]
    Python 代码关键说明:
    • deque 操作简化dq[-1] 获取队尾元素,dq[0] 获取队首元素,pop() 弹出队尾,popleft() 弹出队首,操作直观;

    • 边界判断简洁if not nums 直接判断数组是否为空,无需额外判断长度;

    • 结果列表动态添加:用 result.append() 动态记录每个窗口的最大值,无需提前指定列表长度。

    五、常见误区与优化拓展

    1. 容易踩坑的 3 个点

    • 队列存储元素值而非索引:若直接存储元素值,无法判断该元素是否在当前窗口内(如数组 [1,2,1,0],窗口大小 3,队首元素 2 可能来自索引 1,当窗口移动到 [2,1,0] 时,2 已移出窗口,但无法通过值判断);

    • 用 if 而非 while 维护单调性:若用 if 仅弹出一次尾部元素,可能导致队列仍存在小于当前元素的元素(如窗口 [5,4,3],当前元素 6,用 if 仅弹出 3,队列变为 [5,4],仍不满足递减,需用 while 弹出所有小于 6 的元素);

    • 忽略边界场景:未处理“数组长度等于窗口大小”(如 [1,2,3],k=3)或“k=1”(每个元素都是窗口最大值)的场景,导致代码报错。

    2. 其他解法对比(了解即可,不推荐)

    除了单调队列,还有两种常见解法,但均存在明显缺陷,面试中不推荐作为首选:

    • 优先队列(大顶堆)

      • 思路:用大顶堆存储窗口内元素,堆顶即为最大值;窗口移动时,移除堆中不属于当前窗口的元素。

      • 缺陷:移除堆中指定元素的时间复杂度为 O(k),整体时间复杂度变为 O(nk),与暴力解法效率相当,且实现复杂。

    • 分块预处理

      • 思路:将数组分为 n/k 个块,预处理每个块的“前缀最大值”和“后缀最大值”,每个窗口的最大值为“右侧块的前缀最大值”与“左侧块的后缀最大值”的较大值。

      • 缺陷:仅适用于 k 固定且数组长度能被 k 整除的场景,通用性差,且预处理逻辑复杂。

    3. 拓展场景:滑动窗口最小值

    若题目改为“滑动窗口最小值”,只需将单调队列的维护逻辑改为“队列从队首到队尾递增”,队首元素即为最小值,其他操作完全一致。例如:

    # 滑动窗口最小值(仅修改单调性判断条件)
    def minSlidingWindow(nums: List[int], k: int) -> List[int]:
        if not nums or k == 0:
            return []
        dq = deque()
        result = []
        for i in range(len(nums)):
            # 维护队列递增顺序,弹出尾部大于当前元素的索引
            while dq and nums[i] <= nums[dq[-1]]:
                dq.pop()
            dq.append(i)
            # 移除窗口外元素
            while dq[0] <= i - k:
                dq.popleft()
            # 记录最小值
            if i >= k - 1:
                result.append(nums[dq[0]])
        return result

    六、总结

    “滑动窗口最大值”的核心是用单调队列解决“重复计算”问题,其解题逻辑可总结为:

    1. 队列单调性:始终保持队列从队首到队尾递减,确保队首是当前窗口最大值;

    2. 窗口边界控制:通过存储元素索引,判断队首元素是否在当前窗口内,及时移除无效元素;

    3. 高效操作:每个元素仅入队、出队一次,时间复杂度优化至 O(n),是大规模数据场景的最优解。

    掌握该题不仅能应对面试,更能理解“用合适数据结构优化算法效率”的核心思想,为后续解决“滑动窗口求和”“最长无重复子串”等问题打下基础。

    这个Markdown格式的文档可直接用于博客发布、笔记整理或面试复习。如果需要调整标题层级、补充示例代码注释,或增加特定语言(如C++)的实现版本,你可以随时告诉我,我会进一步优化内容。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值