今天我想和大家分享一个经典的算法问题——滑动窗口最大值。这个问题在LeetCode上编号239,属于高频面试题之一。我们先来看看问题的具体描述:
给定一个整数数组nums和一个整数k,有一个大小为k的滑动窗口从数组的最左端移动到最右端。我们需要返回每个滑动窗口中的最大值。
最直观的解法是暴力法:对于每个窗口,遍历其中的k个元素找出最大值。这种方法的时间复杂度是O(n*k),当k很大时(比如n/2),时间复杂度会退化为O(n²),这在算法竞赛或面试中是完全不可接受的。
提示:在算法面试中,当n的规模达到10^5时,O(n²)的算法通常会因为超时而被淘汰。
为了优化时间复杂度,我们需要一种能够在O(1)时间内获取当前窗口最大值的数据结构。这就是单调队列(Monotonic Queue)的用武之地。
单调队列是一种特殊的双端队列(deque),它能够保持队列中的元素按照某种单调性(递增或递减)排列。在本题中,我们使用单调递减队列,这样队首元素始终是当前窗口的最大值。
单调队列的核心在于两个维护操作:
cpp复制deque<int> dq; // 存储的是元素下标
vector<int> ans;
for (int i = 0; i < nums.size(); ++i) {
// 维护单调性
while (!dq.empty() && nums[i] >= nums[dq.back()]) {
dq.pop_back();
}
dq.push_back(i);
// 移除过期元素
if (dq.front() <= i - k) {
dq.pop_front();
}
// 记录结果
if (i >= k - 1) {
ans.push_back(nums[dq.front()]);
}
}
很多初学者会疑惑为什么要存储下标而不是直接存储数值。这主要有两个原因:
虽然代码中有嵌套循环,但每个元素最多入队和出队各一次,因此总的时间复杂度是O(n)。这比暴力解法的O(n*k)有了质的提升。
队列中最多同时存储k个元素(当窗口滑动到数组末尾时),因此空间复杂度是O(k)。
让我们通过一个具体例子来理解算法的执行过程。假设nums = [1,3,-1,-3,5,3,6,7],k=3。
| 步骤(i) | 当前值 | 队列操作 | 队列内容(下标) | 窗口最大值 |
|---|---|---|---|---|
| 0 | 1 | 压入0 | [0] | - |
| 1 | 3 | 弹出0,压入1 | [1] | - |
| 2 | -1 | 压入2 | [1,2] | 3 |
| 3 | -3 | 压入3 | [1,2,3] | 3 |
| 4 | 5 | 弹出1,2,3,压入4 | [4] | 5 |
| 5 | 3 | 压入5 | [4,5] | 5 |
| 6 | 6 | 弹出4,5,压入6 | [6] | 6 |
| 7 | 7 | 弹出6,压入7 | [7] | 7 |
在实际编码中,有几个边界条件需要特别注意:
cpp复制vector<int> ans;
ans.reserve(nums.size() - k + 1); // 提前分配空间
使用数组模拟双端队列:在某些语言中,用数组模拟双端队列可能比使用库中的deque更高效。
循环队列实现:对于固定窗口大小的问题,可以使用循环队列来减少内存使用。
单调队列的思想可以应用于多种滑动窗口问题:
cpp复制class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
if (nums.empty() || k <= 0) return {};
if (k == 1) return nums;
deque<int> dq;
vector<int> ans;
ans.reserve(nums.size() - k + 1);
for (int i = 0; i < nums.size(); ++i) {
// 维护单调递减性
while (!dq.empty() && nums[i] >= nums[dq.back()]) {
dq.pop_back();
}
dq.push_back(i);
// 移除过期元素
if (dq.front() <= i - k) {
dq.pop_front();
}
// 记录结果
if (i >= k - 1) {
ans.push_back(nums[dq.front()]);
}
}
return ans;
}
};
| 方法 | 时间复杂度 | 空间复杂度 | 适合场景 |
|---|---|---|---|
| 暴力法 | O(n*k) | O(1) | k很小的时候 |
| 单调队列 | O(n) | O(k) | 通用解法 |
| 分块预处理 | O(n) | O(n) | 需要多次查询不同k的情况 |
好的测试用例应该包括:
例如:
cpp复制TEST_CASE("Sliding Window Maximum") {
Solution s;
// 常规测试
vector<int> nums1 = {1,3,-1,-3,5,3,6,7};
vector<int> expected1 = {3,3,5,5,6,7};
REQUIRE(s.maxSlidingWindow(nums1, 3) == expected1);
// k=1的情况
vector<int> nums2 = {1,2,3,4};
REQUIRE(s.maxSlidingWindow(nums2, 1) == nums2);
// k等于数组长度
vector<int> nums3 = {4,2,1,3};
vector<int> expected3 = {4};
REQUIRE(s.maxSlidingWindow(nums3, 4) == expected3);
}
在某些情况下,我们可以复用输入数组来存储结果,将空间复杂度降低到O(1)(不算输出空间)。不过这种优化通常意义不大,因为输出本身就需要O(n-k+1)的空间。
对于非常大的数组,可以考虑将数组分块,使用多线程分别处理不同的块,最后合并结果。不过需要注意边界处的窗口处理。
在某些特定场景下,可以使用优先队列(堆)来实现,虽然时间复杂度会变为O(n log k),但在某些情况下可能更易于实现。
在实际编码中,我发现很多同学容易忽略窗口刚开始形成的那段时间(i < k-1时)不需要记录结果。这是一个常见的错误点,需要特别注意。另外,在维护单调队列时,使用while循环而不是if语句来移除所有较小的元素也是关键点之一。