滑动窗口最大值的 O(n) 解法:为什么"比你年轻还比你强"就该被踢出?
javascript
// 给你一个数组和一个窗口大小 k,窗口从左滑到右,每滑一步输出窗口内最大值
// 输入: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
// 期望: [3, 3, 5, 5, 6, 7]
// 如果你第一次见到这题,大概率会这样写:
var maxSlidingWindow = function(nums, k) {
const result = [];
for (let i = 0; i <= nums.length - k; i++) {
result.push(Math.max(...nums.slice(i, i + k)));
}
return result;
};
你猜这段代码能不能过?LeetCode 上会直接超时。
我第一反应也是这么写的,然后盯着红色的 Time Limit Exceeded 陷入了沉思:Math.max 明明是 O(k),总共 n-k+1 个窗口,不就是 O(nk) 吗?数组长度到 10^5,k 到 10^4,乘起来 10^9------不超时才怪。
这篇文章就是解决这个问题的:我会从暴力解法出发,一步步推导出 O(n) 的单调队列解法。你看完不止会背模板,还能理解每一步优化"为什么"有效。
第一步:暴力解法的问题在哪?
先看清楚暴力解法做了什么。
ini
nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
窗口1: [1, 3, -1] → max([1, 3, -1]) = 3 ← 扫描 3 个元素
窗口2: [3, -1, -3] → max([3, -1, -3]) = 3 ← 又扫描 3 个元素
窗口3: [-1, -3, 5] → max([-1, -3, 5]) = 5 ← 又扫描 3 个元素
...
每次窗口右移一格,就重新扫描整个窗口 求最大值。但你会发现------窗口 1 和窗口 2 有 [3, -1] 两个元素是重叠的!暴力解法完全无视了这些重叠,每次都像没见过这些元素一样重新算。
核心问题:暴力解法丢弃了上一个窗口的所有信息,而重叠部分的信息完全可以复用。
怎么复用?我们需要的不是"每个元素是什么",而是------当前窗口里谁最大。
第二步:一个朴素的优化思路
能不能维护一个"候选最大值"?当窗口滑动时:
- 新元素进来 → 和当前候选比,谁大谁留下
- 旧元素出去 → 如果它正好是候选最大值,需要找下一个
这个思路对了,但有个致命问题:"候选过期了怎么办?"
如果我们只记了一个最大值,它离开窗口时,我们又要 O(k) 重新扫描整个窗口找新最大值。最坏情况下(比如数组严格递减),每次都是候选离开窗口,复杂度又退化成 O(nk)。
这个思路的核心缺陷是:只记第一名是不够的,你得有一个"候补名单"。
第三步:引入候补名单
想象你是 NBA 球队经理,要一直知道队里谁最强。每个月都有球员加入、有球员合同到期离开。
你的策略:
- 来了个新球员 → 看看他和现有队员谁强。如果他比队里某个人强,那个人就永远不可能成为"全队最强"了------因为有比他更强的、而且比他年轻的在队里。
- 球员合同到期 → 如果他是当前最强,他走了,你从候补名单里找下一个。
"比你年轻还比你强------你永远没机会了,直接退役。" 这就是单调队列的全部思想。
用这个逻辑管理窗口:
维护一个队列(存下标),保持队列对应值单调递减。
队首 = 当前窗口最大值。
我们把这个逻辑一步步推演一遍,用题目数据:
核心代码讲解
javascript
// 滑动窗口最大值 --- 单调队列解法
var maxSlidingWindow = function(nums, k) {
const result = [];
const deque = []; // 🔑 存的是下标,不是值
for (let i = 0; i < nums.length; i++) {
// ⚠️ 步骤1:维护单调递减 --- 队尾所有 ≤ 当前值的,踢出去
// 为什么?因为它们更早进入窗口(更老),值还更小(更弱),
// 在当前值离开窗口之前,它们永远不可能成为最大值
while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
deque.pop();
}
deque.push(i); // 新元素从队尾进入
// ⚠️ 步骤2:清理过期元素 --- 队首下标滑出窗口左边界,踢出去
if (deque[0] <= i - k) {
deque.shift();
}
// ⚠️ 步骤3:窗口形成后才记录结果
// 前 k-1 个元素还没凑够一个完整窗口
if (i >= k - 1) {
result.push(nums[deque[0]]); // 🔑 队首永远是当前窗口最大值
}
}
return result;
};
现在用数据一步一步跑:
ini
nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
deque 存的是下标,为便于理解,写成 deque: [下标(值)] 的形式
i = 0,元素 1:
arduino
deque 为空 → 0(1) 直接入队 → deque: [0(1)]
窗口未形成(i < 2)
i = 1,元素 3:
scss
队尾 0(1) 的值 1 ≤ 3 → 弹出 0(1) // 1 比 3 小还比 3 老 → 淘汰
0(1) 被弹出后 deque 为空 → 1(3) 入队 → deque: [1(3)]
窗口未形成
i = 2,元素 -1:
arduino
队尾 1(3) 的值 3 > -1 → 不弹出
2(-1) 入队 → deque: [1(3), 2(-1)]
窗口形成 → 输出 deque[0] = 3 ✓ // 窗口 [1,3,-1] 最大值确实是 3
i = 3,元素 -3:
ini
队尾 2(-1) 的值 -1 > -3 → 不弹出
3(-3) 入队 → deque: [1(3), 2(-1), 3(-3)]
检查过期:deque[0]=1,而 i-k=0,1 > 0,不过期
输出 deque[0] = 3 ✓ // 窗口 [3,-1,-3] 最大值是 3
i = 4,元素 5:
scss
队尾 3(-3) 的值 -3 ≤ 5 → 弹出 3(-3)
队尾 2(-1) 的值 -1 ≤ 5 → 弹出 2(-1)
队尾 1(3) 的值 3 ≤ 5 → 弹出 1(3)
全部弹出 → 4(5) 入队 → deque: [4(5)]
输出 deque[0] = 5 ✓ // 窗口 [-1,-3,5] 最大值是 5
看到没?当 5 出现时,它一口气踢掉了前面三个------因为 5 是最新进的(最年轻),值又最大(最强),前面那三个在 5 离开之前永远没机会当最大值。
i = 5,元素 3:
arduino
队尾 4(5) 的值 5 > 3 → 不弹出
5(3) 入队 → deque: [4(5), 5(3)]
输出 deque[0] = 5 ✓ // 窗口 [-3,5,3] 最大值是 5
i = 6,元素 6:
scss
队尾 5(3) 的值 3 ≤ 6 → 弹出 5(3)
队尾 4(5) 的值 5 ≤ 6 → 弹出 4(5)
全部弹出 → 6(6) 入队 → deque: [6(6)]
输出 deque[0] = 6 ✓ // 窗口 [5,3,6] 最大值是 6
i = 7,元素 7:
arduino
队尾 6(6) 的值 6 ≤ 7 → 弹出 6(6)
7(7) 入队 → deque: [7(7)]
输出 deque[0] = 7 ✓ // 窗口 [3,6,7] 最大值是 7
最终结果:[3, 3, 5, 5, 6, 7] ✓
为什么这叫"单调"队列?
因为队列里存的值永远是单调递减的:
csharp
deque 的值变化过程:
[1]
[3] ← 1 被 3 顶掉
[3, -1]
[3, -1, -3]
[5] ← 3,-1,-3 全被 5 顶掉
[5, 3] ← 3 不够强,乖乖排在后面
[6] ← 5,3 被 6 顶掉
[7] ← 6 被 7 顶掉
每个元素最多入队一次、出队一次,所以总操作次数是 O(2n) = O(n)。
对比:三种写法的差距有多大
我们跑一下实际数据对比(虽然截图中看不到,但你可以在本地运行):
| 解法 | 时间复杂度 | n=10^5, k=10^4 的耗时 | LeetCode 结果 |
|---|---|---|---|
暴力 Math.max(...slice) |
O(nk) | ~10 秒 | TLE ❌ |
| 只记一个候选 + 过期重扫 | O(nk) 最坏 | ~10 秒 | TLE ❌ |
| 单调队列 | O(n) | ~20 ms | 通过 ✅ |
差距是 500 倍。这就是"想清楚再写"的价值。
单调队列的通用模板
你掌握了滑动窗口最大值的推导过程,以后遇到所有「窗口 + 最值」问题都能套用这个框架:
javascript
// 🔑 单调队列通用框架
function slidingWindowTemplate(nums, k) {
const result = [];
const deque = []; // 存下标
for (let i = 0; i < nums.length; i++) {
// 1. 维护单调性(队尾淘汰)
while (deque.length && /* 队尾不满足单调条件 */) {
deque.pop();
}
deque.push(i);
// 2. 清理过期元素(队首淘汰)
if (deque[0] <= i - k) {
deque.shift();
}
// 3. 窗口形成后取结果
if (i >= k - 1) {
result.push(nums[deque[0]]);
}
}
return result;
}
变体只需要改第 1 步的条件:
| 问题 | 单调方向 | 队尾淘汰条件 |
|---|---|---|
| 滑动窗口最大值 | 递减 | nums[deque[last]] <= nums[i] |
| 滑动窗口最小值 | 递增 | nums[deque[last]] >= nums[i] |
| 窗口内中位数 | 需要更复杂结构 | 单调队列不适用(上堆) |
三个易错点,每个我都踩过
⚠️ 坑 1:deque 存下标还是存值?
必须存下标。因为你需要判断"队首元素是否滑出窗口"------如果存值,你根本不知道这个值的原始位置在哪,无法判断过期。
javascript
// ❌ 存值 --- 无法判断是否过期
deque.push(nums[i]);
// 你只知道 deque 里有 3,但不知道这个 3 是哪来的、是否还在窗口内
// ✅ 存下标 --- 可以判断过期
deque.push(i);
if (deque[0] <= i - k) { /* 过期了 */ }
⚠️ 坑 2:队尾淘汰用 < 还是 <=?
arduino
// ≤(推荐):严格保持递减,每个值唯一。不会有重复值同时留在队列里。
while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
// <:相等的值都保留,队列可能变长,但结果也正确(队首仍是最大值)。
// 只是多存了几个无用的重复值,浪费一点空间。
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
两种都能 AC,但 <= 更优------相等时新的比老的年轻,老的留着也没机会,干脆踢掉。这个细节体现了你真正理解了「比你年轻还比你强」这条规则。
⚠️ 坑 3:为什么用 shift()?它不是 O(n) 吗?
对,数组 shift() 是 O(n) 的。但在这个场景里,每个元素最多被 shift 一次------因为队首元素被移出窗口后就再也回不来了。所以总 shift 次数 ≤ n,均摊 O(1)。
如果你在面试中想展示严谨性,可以自己实现双指针队列避免 shift():
javascript
// 双指针优化(纯 O(1) 出队)
class Deque {
constructor() {
this.data = {};
this.head = 0;
this.tail = 0;
}
pushBack(x) { this.data[this.tail++] = x; }
popBack() { return this.data[--this.tail]; }
popFront() { return this.data[this.head++]; }
front() { return this.data[this.head]; }
back() { return this.data[this.tail - 1]; }
get size() { return this.tail - this.head; }
}
记住这句话:
"数据结构的选择,本质上是选择数据的处理顺序。栈是'后来的先处理',队列是'先来的先处理',而单调队列是'没用的不处理'------在进队之前就淘汰掉永远不可能成为答案的元素。"
下次你写滑动窗口相关代码时,问自己三个问题:哪些元素进来(入队条件)?哪些元素出去(过期条件)?哪些元素在进来之前就可以淘汰了(单调性条件)?这三个问题答上来,单调队列就真的掌握了。
延伸思考
如果窗口不是固定大小,而是动态扩张和收缩 呢?如果要求不只是最大值,而是窗口内的中位数呢?如果数据不是一维数组,而是二维矩阵上的滑动窗口呢?
这些问题的解法不同,但思考方式一样------在数据的进出之间,找到"不可能成为答案"的那些元素,大胆淘汰掉。
欢迎在评论区聊聊:你在哪些实际业务场景里遇到过"滑动窗口 + 最值"类的问题?比如日志监控的 QPS 峰值、K 线图的技术指标、还是别的什么?