LeetCode 热题 100——day11 滑动窗口最大值

✨ 把代码写进星轨,用逻辑丈量宇宙。

导航 链接
个人主页 🏠 星轨初途
基础语言专栏 💻 C语言📚 数据结构
C++ 进阶专栏 🏆 C++学习(竞赛类)⚙️ C++专栏(开发类)
刷题实战专栏 🚀 算法及编程题分享📝 LeetCode 每日一题

LeetCode 239. 滑动窗口最大值

🚀 热题 100 打卡来到 day11!今天的窗口会不断右移,但我们不能每次都把窗口重新扫描一遍。

这次沿着自己的代码走三步:先用单调栈预处理"右边第一个更大值",再用优先队列直接维护最大值,最后把候选元素压进单调队列,做到真正的线性扫描。一步一步来,把最大值牢牢留在队头!( •̀ ω •́ )✧

LeetCode 热题 100------day11 滑动窗口最大值

题目描述

给定整数数组 nums 和窗口大小 k,窗口从数组最左侧开始,每次向右移动一格。我们需要返回每个长度为 k 的连续窗口中的最大值。

例如:

text 复制代码
输入:nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
输出:[3, 3, 5, 5, 6, 7]

题目中 nums.length 最大可到 10^5。如果每个窗口都重新遍历 k 个元素,最坏需要 O(nk) 时间,数据一大就吃不消了。

从哪里开始想

窗口右移时,只有两个变化:

  • 左边有一个旧元素离开;
  • 右边有一个新元素进入。

真正麻烦的是:旧元素离开后,谁来接替它成为最大值?

为了回答这个问题,我们的代码走过了三条路:

  1. 提前算出每个位置右边第一个更大元素,查询时沿下标向右跳;
  2. 把"值 + 下标"放入大根堆,堆顶负责给出当前最大值;
  3. 只保留仍有机会成为最大值的下标,让队列中的值保持单调递减。

第三种就是最终主解,不过前两种尝试也很有价值:它们恰好把"怎样快速找到最大值"这件事一点点推到了最优。


方法一:单调栈预处理 + 下标跳转

思路是怎样想到的

如果从窗口左端点出发,能够快速跳到右边某个更大的元素,就不必把中间所有较小值逐个比较。

于是我们先从右向左遍历数组,用单调栈求出:

text 复制代码
nextGreater[i] = i 右边第一个严格大于 nums[i] 的元素下标

栈中保存下标。处理 nums[i] 时,把栈顶所有小于等于它的元素弹出:

cpp 复制代码
while (!st.empty() && nums[st.top()] <= nums[i])
{
    st.pop();
}

弹完以后,如果栈不为空,栈顶就是右边第一个严格更大的位置。

接下来枚举每个窗口 [left, left + k)。先令 maxIndex = left,只要 nextGreater[maxIndex] 仍在窗口内,就继续向右跳:

text 复制代码
maxIndex
    → nextGreater[maxIndex]
    → nextGreater[nextGreater[maxIndex]]
    → ...

每跳一次,元素值都会严格增大。跳不动时,当前位置就是这个窗口的最大值。

为什么能找到窗口最大值

假设我们停在下标 j

  • 如果 nextGreater[j] == -1,说明数组右侧已经没有比 nums[j] 更大的值;
  • 如果 nextGreater[j] >= left + k,说明 j 到窗口右边界之间不存在更大的值,否则"右边第一个更大值"应该更早出现。

所以停下来时,nums[j] 一定是当前窗口最大值。

完整代码

cpp 复制代码
class Solution
{
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k)
    {
        int n = nums.size();
        stack<int> st;
        vector<int> nextGreater(n, -1);
        // 预处理每个位置右边第一个严格更大的元素
        for (int i = n - 1; i >= 0; --i)
        {
            while (!st.empty() && nums[st.top()] <= nums[i])
            {
                st.pop();
            }
            if (!st.empty())
            {
                nextGreater[i] = st.top();
            }
            st.push(i);
        }
        vector<int> answer;
        // 在每个窗口中沿 nextGreater 不断向右跳
        for (int left = 0; left + k <= n; ++left)
        {
            int maxIndex = left;
            while (nextGreater[maxIndex] != -1 &&
                   nextGreater[maxIndex] < left + k)
            {
                maxIndex = nextGreater[maxIndex];
            }
            answer.push_back(nums[maxIndex]);
        }
        return answer;
    }
};

复杂度分析

  • 时间复杂度:预处理 nextGreaterO(n),但查询阶段最坏为 O(nk)。例如数组严格递增时,每个窗口都可能连续跳近 k 次;
  • 空间复杂度:O(n),单调栈和 nextGreater 数组都可能保存 n 个元素。

这个方法答案没问题,但"每个窗口都重新走一遍跳转链"仍有重复计算。我们继续优化。


方法二:优先队列维护最大值

把最大值直接放到堆顶

既然每次都要询问最大值,那就使用大根堆。堆里保存:

text 复制代码
{nums[i], i}

也就是"元素值 + 下标"。元素值让最大值位于堆顶,下标则用来判断这个元素是否已经离开窗口。

当右端点来到 i 时,当前窗口为:

text 复制代码
[i - k + 1, i]

因此所有满足下面条件的下标都已过期:

text 复制代码
index < i - k + 1

也可以写成:

text 复制代码
index <= i - k

新元素先入堆,然后不断弹出已经过期的堆顶。为什么只清理堆顶就够了?因为我们只关心最大值;藏在堆内部的过期小元素暂时不会影响答案,等它以后来到堆顶时再删除即可,这就是延迟删除

堆顶只有同时满足"值最大"和"下标仍在窗口内",才能加入答案。

完整代码

cpp 复制代码
class Solution
{
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k)
    {
        int n = nums.size();
        priority_queue<pair<int, int>> heap;
        // 建立第一个窗口
        for (int i = 0; i < k; ++i)
        {
            heap.push({nums[i], i});
        }
        vector<int> answer;
        answer.push_back(heap.top().first);
        // 窗口继续右移
        for (int i = k; i < n; ++i)
        {
            heap.push({nums[i], i});
            // 下标小于等于 i - k 的元素已经离开窗口
            while (heap.top().second <= i - k)
            {
                heap.pop();
            }
            answer.push_back(heap.top().first);
        }
        return answer;
    }
};

复杂度分析

  • 时间复杂度:O(n log n)。每个元素入堆一次、至多出堆一次,单次堆操作为 O(log n)
  • 空间复杂度:O(n)。这份代码采用延迟删除,某些已经过期的小元素可能长期留在堆中,所以堆的最坏规模不是 O(k),而是 O(n)

优先队列已经比第一种方法稳定,但它保存了不少"当前没用、以后才清理"的元素。能不能只留下真正有竞争力的候选者?可以,这就轮到主解登场了。


方法三(主解):单调队列

哪些元素已经永远不可能成为最大值

假设新元素 nums[i] 准备进入窗口,队尾存在旧下标 j,并且:

text 复制代码
j < i
nums[j] <= nums[i]

那么 nums[j] 已经没有留下来的必要:

  • 它的值不比 nums[i] 大;
  • 它还比 nums[i] 更早离开窗口。

以后只要旧元素 nums[j] 仍在窗口中,新元素 nums[i] 也一定在,而且后者不小于前者。旧元素再也不可能成为窗口最大值,可以放心从队尾删除。

所以队列只保留下标,并维持两个性质:

  1. 下标从队头到队尾递增,表示元素进入窗口的先后顺序;
  2. 对应的 nums 值从队头到队尾严格递减,队头自然就是最大值。

比新元素小或相等的队尾元素,值不占优、寿命还更短,可以直接淘汰。

每轮遍历的三个动作

遍历到下标 i 时,窗口左边界为:

text 复制代码
left = i - k + 1

每一轮按下面的顺序处理:

  1. 清理队头 :若 queue[head] < left,说明队头已经滑出窗口;
  2. 维护单调性 :不断删除所有满足 nums[queue[tail]] <= nums[i] 的队尾;
  3. 当前下标入队 :把 i 放到队尾。当 i >= k - 1 时,队头对应的值就是当前答案。

这里保存的必须是下标,不能只保存数值。因为我们既要比较大小,也要判断元素有没有离开窗口。

用样例走一遍

令:

text 复制代码
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3

下面的"队列"一列展示完成过期清理、队尾弹出和当前元素入队后的 nums 值:

i 新元素 处理后的单调队列 当前窗口最大值
0 1 [1] 窗口未形成
1 3 [3] 窗口未形成
2 -1 [3, -1] 3
3 -3 [3, -1, -3] 3
4 5 [5] 5
5 3 [5, 3] 5
6 6 [6] 6
7 7 [7] 7

来到 i = 4 时最能看出这个方法的味道:

  • 下标 1 对应的 3 已经离开窗口,先从队头移除;
  • 新元素 5 大于 -3-1,它们依次从队尾出队;
  • 队列最后只剩 5,它就是窗口 [-1, -3, 5] 的最大值。

最终得到:

text 复制代码
[3, 3, 5, 5, 6, 7]

完整代码

下面保留原代码使用 vector 模拟队列的思路,只把 hhtt 调整为更容易读懂的 headtail

cpp 复制代码
class Solution
{
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k)
    {
        int head = 1;
        int tail = 0;
        vector<int> answer;
        vector<int> queue(nums.size() + 1);
        for (int i = 0; i < static_cast<int>(nums.size()); ++i)
        {
            int left = i - k + 1;
            // 队头下标已经滑出当前窗口
            if (head <= tail && queue[head] < left)
            {
                ++head;
            }
            // 删除不可能再成为最大值的队尾,保持对应值单调递减
            while (head <= tail && nums[queue[tail]] <= nums[i])
            {
                --tail;
            }
            queue[++tail] = i;
            // 第一个完整窗口在 i = k - 1 时形成
            if (i >= k - 1)
            {
                answer.push_back(nums[queue[head]]);
            }
        }
        return answer;
    }
};

为什么嵌套 while 仍然是 O(n)

代码里有一层 for 和一层 while,看起来像 O(n²),但复杂度不能只数循环层数。

每个下标只会:

  • 进入队列一次;
  • 从队头过期离开,或者从队尾被更强的新元素淘汰一次。

同一个下标不会被重复弹出,因此所有 while 循环的总执行次数不超过 n 级别。

复杂度分析

  • 时间复杂度:O(n),每个下标至多入队一次、出队一次;
  • 空间复杂度:O(n),当前代码直接申请了长度为 n + 1 的数组模拟队列。逻辑上队列中有效下标不会超过 k 个;若改用 deque,可将额外空间按有效元素规模记为 O(k)

边界与易错点

1. 过期条件别写反

当前窗口左边界是 i - k + 1,因此队头在下面这种情况下过期:

text 复制代码
queue[head] < i - k + 1

等价写法是:

text 复制代码
queue[head] <= i - k

如果写成 queue[head] < i - k,已经离开窗口的元素会多停留一轮,答案就可能出错。

2. 为什么队尾判断使用 <=

当队尾值和新元素相等时,留下更新的下标更划算。两者数值一样,但新下标会更晚过期,所以旧下标可以删除。

例如 nums = [2, 2]k = 1。第二个 2 到来后,第一个 2 已经没有任何优势。

3. 为什么清理过期队头使用 if 就够了

窗口每次只向右移动一格,左边界也只增加 1。上一轮结束时队头仍在窗口内,因此这一轮最多只有一个队头下标刚刚过期,使用 if 即可。

如果改成 while 也能得到正确结果,并且在维护逻辑改变时更具通用性;这里保留原代码的写法。

4. k = 1k = n

  • k = 1 时,每个窗口只有一个元素,答案就是原数组;
  • k = n 时,只形成一个窗口,答案只有整个数组的最大值。

原代码不需要额外特判,这两个边界都能自然覆盖。

5. 全是负数或存在重复值

算法比较的是元素之间的相对大小,与正负无关。重复值则通过队尾的 <= 保留更新的那个下标,同样不会漏掉最大值。


三种方法对比

方法 找最大值的方式 时间复杂度 空间复杂度 特点
单调栈 + 下标跳转 沿 nextGreater 链寻找窗口最大值 最坏 O(nk) O(n) 思路巧,但窗口之间仍有重复跳转
优先队列 大根堆堆顶给出最大值,过期元素延迟删除 O(n log n) O(n) 直观稳妥,维护成本来自堆操作
单调队列 只保留仍可能成为最大值的候选下标 O(n) 当前实现 O(n) 每个下标至多进出一次,是本文主解

从三种写法可以看到,优化的关键并不是"换一个更厉害的容器",而是不断删掉无效信息:

  • 第一种记录向右变大的跳转关系;
  • 第二种允许无效元素暂留,来到堆顶再删除;
  • 第三种在新元素进入时,就把永远不可能成为最大值的候选全部淘汰。

信息越精简,查询最大值就越快。

总结 (๑•̀ㅂ•́)و✧

这道题的本质,是在窗口右移时维护一组"仍有资格成为最大值"的候选者。

优先队列可以随时取最大值,但单调队列更进一步:新元素到来时,立刻淘汰那些值更小、还更早过期的旧元素。记住两个关键点------队列保存下标,nums 值保持单调递减,这道题的核心就抓住了。

🎉 day11 打卡完成!以后遇到"固定长度连续区间 + 动态最值",单调队列就该进入候选名单啦!

相关推荐
gbase_lmax2 小时前
深度递归与广度递归(DFS,BFS)实现
算法·深度优先·宽度优先
西柚研究生1234563 小时前
论文分析15:跨层特征交互的多尺度无人机小目标检测算法
人工智能·算法·目标检测
rannn_11110 小时前
【力扣hot100】链表专题|160、206、234、141、142
java·算法·leetcode·链表·面试·开发
数模竞赛Paid answer10 小时前
2026年华东杯数学建模B题医药物流安排问题解题全过程文档及程序
算法·数学建模·数据分析·华东杯
不会代码的小猴11 小时前
21. 泛型编程上
开发语言·c++·笔记·算法
营养充电站11 小时前
VS Code Git 工作树:解锁多分支并行开发的高效体验
leetcode·决策树·逻辑回归·散列表·模拟退火算法
青瓦梦滋11 小时前
传输层UDP/TCP协议
linux·网络·c++·网络协议·tcp/ip·udp
AI备案指南-满满11 小时前
大模型与算法备案全流程详解:从零到通过的完整指南
人工智能·算法·备案·大模型备案·算法备案
江畔柳前堤11 小时前
LLM 训练核心机制深度解析:Warmup、Cosine Decay 与 Perplexity 的完整知识体系
网络·人工智能·深度学习·算法·机器学习·语音识别