【LeetCode Hot100 刷题日记 (11/100)】239. 滑动窗口最大值 —— 单调队列 、优先队列 、 分块预处理 🪟

36 阅读7分钟

📌 题目链接: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 < jnums[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),但在单次查询中不如单调队列简洁。


解题思路(分步拆解)

  1. 理解问题本质:动态维护固定大小窗口的最大值。
  2. 排除暴力法:O(nk) 在 1e5 规模下不可接受。
  3. 观察窗口特性:仅一进一出,有重叠 → 可复用历史信息。
  4. 选择合适数据结构
    • 堆:支持最大值,但删除不便;
    • 单调队列:天然支持“过期淘汰”+“单调维护”。
  5. 设计队列规则
    • 存下标(便于判断是否过期);
    • 队尾维护单调递减;
    • 队首保证在窗口内。
  6. 边界处理
    • 初始化第一个窗口;
    • 滑动时先加新元素,再删旧元素。

算法分析总结

方法时间复杂度空间复杂度是否推荐适用场景
暴力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)!


📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!