
✨ 把代码写进星轨,用逻辑丈量宇宙。
| 导航 | 链接 |
|---|---|
| 个人主页 | 🏠 星轨初途 |
| 基础语言专栏 | 💻 C语言 、 📚 数据结构 |
| C++ 进阶专栏 | 🏆 C++学习(竞赛类) 、 ⚙️ C++专栏(开发类) |
| 刷题实战专栏 | 🚀 算法及编程题分享 、 📝 LeetCode 每日一题 |


🚀 热题 100 打卡来到 day11!今天的窗口会不断右移,但我们不能每次都把窗口重新扫描一遍。
这次沿着自己的代码走三步:先用单调栈预处理"右边第一个更大值",再用优先队列直接维护最大值,最后把候选元素压进单调队列,做到真正的线性扫描。一步一步来,把最大值牢牢留在队头!( •̀ ω •́ )✧
LeetCode 热题 100------day11 滑动窗口最大值
-
- 题目描述
- 从哪里开始想
- [方法一:单调栈预处理 + 下标跳转](#方法一:单调栈预处理 + 下标跳转)
- 方法二:优先队列维护最大值
- 方法三(主解):单调队列
-
- 哪些元素已经永远不可能成为最大值
- 每轮遍历的三个动作
- 用样例走一遍
- 完整代码
- [为什么嵌套 `while` 仍然是 `O(n)`](#为什么嵌套
while仍然是O(n)) - 复杂度分析
- 边界与易错点
-
- [1. 过期条件别写反](#1. 过期条件别写反)
- [2. 为什么队尾判断使用 `<=`](#2. 为什么队尾判断使用
<=) - [3. 为什么清理过期队头使用 `if` 就够了](#3. 为什么清理过期队头使用
if就够了) - [4. `k = 1` 和 `k = n`](#4.
k = 1和k = n) - [5. 全是负数或存在重复值](#5. 全是负数或存在重复值)
- 三种方法对比
- [总结 `(๑•̀ㅂ•́)و✧`](#总结
(๑•̀ㅂ•́)و✧)
题目描述
给定整数数组 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) 时间,数据一大就吃不消了。
从哪里开始想
窗口右移时,只有两个变化:
- 左边有一个旧元素离开;
- 右边有一个新元素进入。
真正麻烦的是:旧元素离开后,谁来接替它成为最大值?
为了回答这个问题,我们的代码走过了三条路:
- 提前算出每个位置右边第一个更大元素,查询时沿下标向右跳;
- 把"值 + 下标"放入大根堆,堆顶负责给出当前最大值;
- 只保留仍有机会成为最大值的下标,让队列中的值保持单调递减。
第三种就是最终主解,不过前两种尝试也很有价值:它们恰好把"怎样快速找到最大值"这件事一点点推到了最优。
方法一:单调栈预处理 + 下标跳转
思路是怎样想到的
如果从窗口左端点出发,能够快速跳到右边某个更大的元素,就不必把中间所有较小值逐个比较。
于是我们先从右向左遍历数组,用单调栈求出:
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;
}
};
复杂度分析
- 时间复杂度:预处理
nextGreater是O(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] 也一定在,而且后者不小于前者。旧元素再也不可能成为窗口最大值,可以放心从队尾删除。
所以队列只保留下标,并维持两个性质:
- 下标从队头到队尾递增,表示元素进入窗口的先后顺序;
- 对应的
nums值从队头到队尾严格递减,队头自然就是最大值。
比新元素小或相等的队尾元素,值不占优、寿命还更短,可以直接淘汰。
每轮遍历的三个动作
遍历到下标 i 时,窗口左边界为:
text
left = i - k + 1
每一轮按下面的顺序处理:
- 清理队头 :若
queue[head] < left,说明队头已经滑出窗口; - 维护单调性 :不断删除所有满足
nums[queue[tail]] <= nums[i]的队尾; - 当前下标入队 :把
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 模拟队列的思路,只把 hh、tt 调整为更容易读懂的 head、tail:
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 = 1 和 k = n
k = 1时,每个窗口只有一个元素,答案就是原数组;k = n时,只形成一个窗口,答案只有整个数组的最大值。
原代码不需要额外特判,这两个边界都能自然覆盖。
5. 全是负数或存在重复值
算法比较的是元素之间的相对大小,与正负无关。重复值则通过队尾的 <= 保留更新的那个下标,同样不会漏掉最大值。
三种方法对比
| 方法 | 找最大值的方式 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|---|
| 单调栈 + 下标跳转 | 沿 nextGreater 链寻找窗口最大值 |
最坏 O(nk) |
O(n) |
思路巧,但窗口之间仍有重复跳转 |
| 优先队列 | 大根堆堆顶给出最大值,过期元素延迟删除 | O(n log n) |
O(n) |
直观稳妥,维护成本来自堆操作 |
| 单调队列 | 只保留仍可能成为最大值的候选下标 | O(n) |
当前实现 O(n) |
每个下标至多进出一次,是本文主解 |
从三种写法可以看到,优化的关键并不是"换一个更厉害的容器",而是不断删掉无效信息:
- 第一种记录向右变大的跳转关系;
- 第二种允许无效元素暂留,来到堆顶再删除;
- 第三种在新元素进入时,就把永远不可能成为最大值的候选全部淘汰。
信息越精简,查询最大值就越快。
总结 (๑•̀ㅂ•́)و✧
这道题的本质,是在窗口右移时维护一组"仍有资格成为最大值"的候选者。
优先队列可以随时取最大值,但单调队列更进一步:新元素到来时,立刻淘汰那些值更小、还更早过期的旧元素。记住两个关键点------队列保存下标,nums 值保持单调递减,这道题的核心就抓住了。
🎉 day11 打卡完成!以后遇到"固定长度连续区间 + 动态最值",单调队列就该进入候选名单啦!