📌 题目链接:leetcode.cn/problems/sl…
🔍 难度:困难 | 🏷️ 标签:队列、滑动窗口、单调队列、堆(优先队列)
⏱️ 目标时间复杂度:O(n)(最优解)
💾 空间复杂度:O(k)(单调队列)或 O(n)(优先队列)
🪟在算法面试中,滑动窗口类问题是高频考点,而「滑动窗口最大值」更是其中的经典代表。它不仅考察你对基础数据结构的理解,更深入检验你对时间复杂度优化和边界控制的掌握程度。
本题要求我们在线性时间内高效地维护一个动态窗口中的最大值,看似简单,实则暗藏玄机。暴力法虽然直观,但无法通过大规模测试用例;而借助单调队列,我们可以实现真正的 O(n) 时间复杂度,这也是面试官最希望看到的解法!
题目分析
给定一个整数数组 nums 和窗口大小 k,窗口从左向右滑动,每次移动一位,要求返回每个窗口内的最大值。
- 输入规模大:
1 <= nums.length <= 10^5,意味着 O(nk) 的暴力解法必然超时。 - 窗口动态变化:每次只移出一个元素、移入一个元素,具有高度重叠性。
- 核心挑战:如何在 O(1) 或均摊 O(1) 时间内获取当前窗口最大值?
这正是**单调队列(Monotonic Queue)**大显身手的场景!
核心算法及代码讲解
✅ 方法一:单调队列(推荐!面试首选)
🧠 核心思想
维护一个双端队列(deque),队列中存储的是数组下标,且对应的元素值严格单调递减。
- 队首:始终是当前窗口中的最大值的下标。
- 队尾:在加入新元素前,不断弹出小于等于当前元素的下标(因为它们不可能成为后续窗口的最大值)。
- 窗口滑动时:检查队首是否已滑出窗口(
<= i - k),若是则弹出。
💡 为什么可以“永久删除”较小元素?
假设i < j且nums[i] <= nums[j],那么只要j在窗口中,i就不可能成为最大值。因此i可以安全丢弃。
📜 代码详解(含行注释)
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> q; // 存储下标,对应元素单调递减
// 初始化第一个窗口 [0, k-1]
for (int i = 0; i < k; ++i) {
// 维护单调性:队尾元素 <= 当前元素 → 弹出队尾
while (!q.empty() && nums[i] >= nums[q.back()]) {
q.pop_back();
}
q.push_back(i); // 加入当前下标
}
vector<int> ans = {nums[q.front()]}; // 第一个窗口最大值
// 滑动窗口:i 为新加入元素的下标(窗口右端)
for (int i = k; i < n; ++i) {
// 同样维护单调性:弹出队尾所有 <= nums[i] 的元素
while (!q.empty() && nums[i] >= nums[q.back()]) {
q.pop_back();
}
q.push_back(i);
// 移除已滑出窗口的队首元素(下标 <= i - k)
while (q.front() <= i - k) {
q.pop_front();
}
// 队首即为当前窗口最大值
ans.push_back(nums[q.front()]);
}
return ans;
}
};
⏱️ 复杂度分析
- 时间复杂度:O(n)
每个元素最多入队、出队各一次,总操作次数为 2n。 - 空间复杂度:O(k)
队列中最多保存 k 个元素(实际 ≤ k+1)。
✅ 面试加分点:强调“均摊 O(1)”、“严格单调递减”、“下标存储而非值”等关键词。
⚠️ 方法二:优先队列(堆)——可行但非最优
使用大根堆(priority_queue<pair<int, int>>)存储 (值, 下标)。
- 每次取堆顶,若其下标不在当前窗口,则持续弹出。
- 缺点:堆无法直接删除无效元素,最坏情况堆中存 n 个元素。
📜 代码(官方提供,保留原样)
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);
while (q.top().second <= i - k) {
q.pop();
}
ans.push_back(q.top().first);
}
return ans;
}
};
⏱️ 复杂度分析
- 时间复杂度:O(n log n)
每次插入 O(log n),最坏 n 次。 - 空间复杂度:O(n)
❗ 面试提醒:若被问“有没有更优解?”,应立即引出单调队列。
🧩 方法三:分块 + 预处理(进阶技巧)
将数组按 k 分块,预处理每块的前缀最大值和后缀最大值。
- 对任意窗口
[i, i+k-1]:- 若跨块:
max(suffixMax[i], prefixMax[i+k-1]) - 若不跨块:两者相等,仍适用上式。
- 若跨块:
📜 代码(官方提供)
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> prefixMax(n), suffixMax(n);
for (int i = 0; i < n; ++i) {
if (i % k == 0) {
prefixMax[i] = nums[i];
} else {
prefixMax[i] = max(prefixMax[i - 1], nums[i]);
}
}
for (int i = n - 1; i >= 0; --i) {
if (i == n - 1 || (i + 1) % k == 0) {
suffixMax[i] = nums[i];
} else {
suffixMax[i] = max(suffixMax[i + 1], nums[i]);
}
}
vector<int> ans;
for (int i = 0; i <= n - k; ++i) {
ans.push_back(max(suffixMax[i], prefixMax[i + k - 1]));
}
return ans;
}
};
⏱️ 复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(n)
🔍 适用场景:适合离线查询、多次滑动窗口问题(如 RMQ),但在单次查询中不如单调队列简洁。
解题思路(分步拆解)
- 理解问题本质:动态维护固定大小窗口的最大值。
- 排除暴力法:O(nk) 在 1e5 规模下不可接受。
- 观察窗口特性:仅一进一出,有重叠 → 可复用历史信息。
- 选择合适数据结构:
- 堆:支持最大值,但删除不便;
- 单调队列:天然支持“过期淘汰”+“单调维护”。
- 设计队列规则:
- 存下标(便于判断是否过期);
- 队尾维护单调递减;
- 队首保证在窗口内。
- 边界处理:
- 初始化第一个窗口;
- 滑动时先加新元素,再删旧元素。
算法分析总结
| 方法 | 时间复杂度 | 空间复杂度 | 是否推荐 | 适用场景 |
|---|---|---|---|---|
| 暴力 | O(nk) | O(1) | ❌ | 小数据 |
| 优先队列 | O(n log n) | O(n) | ⚠️ | 快速实现 |
| 单调队列 | O(n) | O(k) | ✅✅✅ | 面试首选 |
| 分块预处理 | O(n) | O(n) | ✅ | 多次查询/RMQ |
💡 高频面试追问:
- “如果要同时维护最大值和最小值怎么办?” → 使用两个单调队列。
- “如果窗口大小可变呢?” → 单调队列依然适用,只需动态判断队首是否过期。
- “能否用栈实现?” → 不行,栈无法从两端操作。
完整可运行代码(含测试)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> q;
for (int i = 0; i < k; ++i) {
while (!q.empty() && nums[i] >= nums[q.back()]) {
q.pop_back();
}
q.push_back(i);
}
vector<int> ans = {nums[q.front()]};
for (int i = k; i < n; ++i) {
while (!q.empty() && nums[i] >= nums[q.back()]) {
q.pop_back();
}
q.push_back(i);
while (q.front() <= i - k) {
q.pop_front();
}
ans.push_back(nums[q.front()]);
}
return ans;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
vector<int> nums1 = {1,3,-1,-3,5,3,6,7};
int k1 = 3;
auto res1 = sol.maxSlidingWindow(nums1, k1);
for (int x : res1) cout << x << " "; // 输出: 3 3 5 5 6 7
cout << "\n";
vector<int> nums2 = {1};
int k2 = 1;
auto res2 = sol.maxSlidingWindow(nums2, k2);
for (int x : res2) cout << x << " "; // 输出: 1
cout << "\n";
return 0;
}
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第12题 —— 240.搜索二维矩阵 II(中等)
🔹 题目:编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target。该矩阵具有如下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
🔹 核心思路:从右上角(或左下角)开始,利用行列有序性进行“剪枝式”搜索。
🔹 考点:二维搜索、贪心、边界控制。
🔹 难度:中等,但解法优雅,是展示“观察力”的绝佳题目!
💡 提示:不要用二分查找每行!那样是 O(m log n),而最优解是 O(m + n)!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!