力扣 239 题:滑动窗口最大值的两种高效解法

文章目录

问题描述

解法一:优先队列(大顶堆)

我觉得,从题目中给出的信息应该就很直观的体现出这是 top K 问题了,既然是 top K 问题,很容易就能想到可以使用堆来解决问题

解题思路

  1. 如果堆存储的只是 numsi,那么我们并不能清楚当前的堆所在的位置,所以很自然的就能想到我们需要存储一个键值对,用于记录当前的数值和所映射的位置。而且堆顶为最大值,也就是窗口最大值,所以 numsi 很自然的就为 pair.first,而 i 就为 pair.second
  2. 首先将前 k 个元素放入优先队列中,此时堆顶元素就是第一个窗口的最大值
  3. 在每次滑动的时候,就对应着出堆和入堆的过程
  4. 检查堆顶元素是否在当前窗口内,如果不在则移除,直到堆顶元素处于窗口内
  5. 将合法的堆顶元素加入结果集

可能有的人会问,这样堆中是不是会存在前面非窗口中的元素(即过期元素)?

答案是是的,但是那又有什么关系呢?它并不会影响结果的准确性。因为:

  • 若过期元素是堆顶(最大元素),会被直接出堆
  • 若过期元素是非堆顶元素,其大小小于当前堆顶,既不会干扰最大值选取,后续即便因上层元素出堆成为新堆顶,也会因索引校验失败被移除。

题中示例:nums = [1,3,-1,-3,5,3,6,7], k = 3,输出应为 [3,3,5,5,6,7]

我们来通过堆的思路来简单解释一下:

  • 初始时,将前 3 个元素 [1,3,-1] 放入优先队列,堆顶为 3,此时结果集添加 3
  • 加入元素 -3,优先队列中有 [1,3,-1,-3],堆顶还是 3,且 3 的索引 1 在窗口 [1,3]i=3 时,窗口范围为 1 到 3)内,结果集添加 3
  • 加入元素 5,优先队列中有 [1,3,-1,-3,5],堆顶是 55 的索引 4 在窗口 [2,4] 内,结果集添加 5
  • 以此类推,完成所有元素的处理

代码实现

cpp 复制代码
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        // 维护一个堆,存储(值, 索引)对
        priority_queue<pair<int, int>> q;
        for (int i = 0; i < k; ++i) {
            q.emplace(nums[i], i);
        }
        vector<int> ans = {q.top().first};
        
        // 滑动窗口向右移动
        for (int i = k; i < n; ++i) {
            q.emplace(nums[i], i);
            // 移除堆顶所有不在当前窗口[i-k+1, i]内的元素
            while (q.top().second <= i - k) {
                q.pop();
            }
            ans.push_back(q.top().first);
        }
        return ans;
    }
};

复杂度分析​

  • 时间复杂度O(NlogN)。每个元素入堆和出堆的操作都是 O(logN),总共有 N 个元素。虽然在最坏情况下,每个元素可能会被弹出一次,但总体的时间复杂度仍然是 O(NlogN)
  • 空间复杂度O(N)。在最坏情况下,堆可能需要存储所有元素,比如当数组是严格递增的时候,每个元素都会被加入堆中,且不会被提前弹出。

优先队列解法的核心矛盾​

优先队列的核心优势是能快速获取最大值(O(1) 时间),但在滑动窗口场景中暴露出两个关键问题:​

  1. 空间冗余 :堆中会积累大量过期元素(已超出窗口范围)。例如当处理严格递增数组时,堆中会存储所有元素,空间复杂度达到 O(N),远高于窗口大小 k
  2. 时间损耗 :每次取最大值前需反复弹出堆顶过期元素,这些操作本质上是对无效数据的处理。在最坏情况下,每个元素可能被推入堆后又弹出,虽然整体复杂度仍为 O(NlogN),但常数因子较大。

这些问题的根源在于优先队列的设计目标是全局维护最大值 ,而滑动窗口问题需要的是动态维护局部最大值------ 这就需要一种能精准管理窗口内有效元素的数据结构。

从 "全局维护" 到 "局部筛选" 的思路转变

通过解法一,我们可以观察到,其实在队列新增一个较大的元素,那么有些元素永远都不可能到达堆顶。

例如:假设此时 k = 4,且某一时刻,窗口中的内容为 [5, 1, 3, 4],此时如果新增一个元素 6,你会发现这前面的元素永远都成不了 窗口中的最大元素 ,所以我们维护有些元素并没有价值。总结特性为:当新元素进入窗口时,所有比它小的旧元素都不可能再成为后续窗口的最大值 。这个特性让我们可以提前筛选 目标元素,即有潜力称为最大值 的元素。

解法二:单调队列

单调队列的设计逻辑推导​

基于上述观察,我们可以构建一个只保留 "有潜力成为最大值" 元素的队列,其设计逻辑如下:​

  1. 队列存储索引而非值:既便于判断元素是否在窗口内(通过索引范围校验),又能通过索引获取对应值进行比较。
  2. 维持队列单调性:要求队列中元素对应的值严格递减。这样队头元素自然就是当前窗口的最大值,无需像优先队列那样通过堆结构维护。
  3. 动态清理无效元素
    • 移除窗口外的元素(通过队头索引与当前位置的比较)
    • 移除所有小于新入队元素的值(这些元素已失去成为最大值的可能)

这种设计完美解决了优先队列的缺陷:​

  • 空间上,队列大小被严格控制在 O(k) 以内,因为任何时刻队列中最多包含当前窗口内的元素
  • 时间上,每个元素仅入队和出队各一次,总操作次数为 O(N),且避免了优先队列的 logN 级别的元素调整成本

解题步骤

  1. 维护一个存储元素索引的双端队列,队列内元素对应的值保持递减
  2. 对于每个新元素,移除队列中所有小于当前元素的值(因为它们不可能成为后续窗口的最大值)
  3. 移除队列中超出当前窗口范围的元素
  4. 将当前元素索引加入队列
  5. 当窗口形成后,队列头部元素即为当前窗口的最大值

同样结合示例说明:​

  • 处理索引 0(值 1):队列空,直接入队,队列是 0
  • 处理索引 1(值 3):3 大于 1,移除索引 0,队列空,入队 1,队列是 [1]
  • 处理索引 2(值 -1):-1 小于 3,入队 2,队列是 [1,2],此时窗口形成(i=2 >= 2),结果集添加 nums [1]=3​
  • 处理索引 3(值 -3):先判断队头 1 是否 <= 3-3=01>0,不弹出;-3 小于 -1,入队 3,队列是 [1,2,3],窗口形成,结果集添加 3
  • 处理索引 4(值 5):先判断队头 1 <= 4-3=11<=1,弹出 1;此时队头是 2nums[2]=-1 <5,弹出 2;队头是 3nums[3]=-3 <5,弹出 3;队列空,入队 4,窗口形成,结果集添加 5
  • 以此类推,完成后续元素的处理

代码实现

cpp 复制代码
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    int n = nums.size();
    deque<int> q;  // 存储元素索引,按值降序排列
    vector<int> ans;

    for (int i = 0; i < n; ++i) {
        // 移除窗口外的过期元素(队头)
        if (!q.empty() && q.front() <= i - k) {
            q.pop_front();
        }

        // 维护队列单调性:删除队尾所有比当前元素小的值
        while (!q.empty() && nums[q.back()] < nums[i]) {
            q.pop_back();
        }

        q.push_back(i);  // 当前元素入队

        // 窗口形成后,队头即为最大值
        if (i >= k - 1) {
            ans.push_back(nums[q.front()]);
        }
    }
    return ans;
}

复杂度分析​

  • 时间复杂度O(N)。每个元素只会入队和出队各一次,总操作次数为 O(N)。虽然代码中有嵌套的循环,但每个元素最多被弹出一次,所以整体时间复杂度是线性的。
  • 空间复杂度O(k)。队列中最多存储 k 个元素,因为在任何时候,队列中的元素都是当前窗口或可能在后续窗口中成为最大值的元素,且数量不会超过 k

两种方法对比

方法 时间复杂度 空间复杂度 优势 劣势
优先队列 O(NlogN) O(N) 实现简单,思路直观,不需要太多的技巧 时间复杂度较高,空间占用较大,在数据量很大时性能会有明显下降
单调队列 O(N) O(k) 时间和空间效率更优,适用于大规模数据处理 实现稍复杂,需要理解单调队列的维护逻辑,对初学者来说有一定难度
相关推荐
地平线开发者2 小时前
J6B vio scenario sample
算法
BothSavage14 小时前
Trae远程开发中DeepSeek自定义模型4054错误的排查与修复
算法
小林ixn14 小时前
从暴力到KMP:一道题彻底搞懂字符串匹配的前世今生
算法
烬羽15 小时前
字符串算法入门:从反转字符串到回文判断,面试不再慌
算法·面试
郝学胜_神的一滴15 小时前
CMake 034:生成器表达式:解耦构建时序、精简分支逻辑的终极利器
c++·cmake
先吃饱再说1 天前
判断回文字符串,从一行代码到双指针优化
算法
见过夏天1 天前
C++ 基础入门完全指南
c++
黄敬峰1 天前
深入理解算法核心:从递归思想、数组扁平化到快速排序
算法