Problem: 239. 滑动窗口最大值
题目:给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值 。
【LeetCode 热题 100】239. 滑动窗口最大值——(解法一)滑动窗口+暴力解
【LeetCode 热题 100】239. 滑动窗口最大值——(解法二)滑动窗口+单调队列
整体思路
这段代码同样是解决 “滑动窗口最大值” 问题的最优解法,其核心算法依然是 单调队列。但与使用Java标准库 ArrayDeque
的版本不同,此实现通过一个普通数组 q
和两个整型指针 head
、tail
来手动模拟一个双端队列。这种方式在性能上可能略有优势,因为它避免了库函数的调用开销和潜在的自动装箱/拆箱。
算法的逻辑与上一个版本完全一致,只是数据结构的手动实现带来了语法上的差异:
-
数据结构模拟:
int[] q = new int[n]
: 创建一个足够大的数组来充当队列的底层存储。存储的仍然是nums
数组的索引。int head = 0
: 模拟队首指针。q[head]
是队首元素。int tail = -1
: 模拟队尾指针。q[tail]
是队尾元素。tail < head
的状态表示队列为空。
-
单调队列的维护:
- 算法通过一个
right
指针遍历nums
数组,对每个新元素进行处理。
a. 维护单调性 (队尾操作):while (head <= tail && nums[q[tail]] <= nums[right])
: 在添加新元素nums[right]
的索引前,检查队尾。如果队尾索引对应的元素小于等于新元素,说明队尾元素已无用,将其“弹出”。tail--
: 这是手动的“队尾弹出”操作,直接移动tail
指针即可。
b. 入队 (队尾操作):q[++tail] = right;
: 手动的“队尾压入”操作。先将tail
指针加一,然后在新的tail
位置存入right
索引。
c. 维护窗口大小 (队首操作):if (right - q[head] + 1 > k)
: 检查队首索引q[head]
是否已滑出当前窗口。head++
: 手动的“队首弹出”操作,移动head
指针,抛弃过期的元素。
d. 记录结果:if (right >= k - 1)
: 当窗口形成后,队首q[head]
存储的索引始终对应着当前窗口的最大值。ans[right - k + 1] = nums[q[head]]
: 记录结果。
- 算法通过一个
通过手动管理数组和指针,该实现以最底层、最高效的方式实现了单调队列算法。
完整代码
class Solution {
/**
* 计算滑动窗口的最大值(优化版,手动模拟队列)。
* @param nums 整数数组
* @param k 窗口大小
* @return 包含每个窗口最大值的数组
*/
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
// 计算结果数组的长度
int m = n - k + 1;
int[] ans = new int[m];
// 使用普通数组 q 来模拟一个双端队列,存储元素的索引
int[] q = new int[n];
// head 是队首指针,指向 q 数组中的索引
int head = 0;
// tail 是队尾指针,指向 q 数组中的索引。tail < head 表示队列为空
int tail = -1;
// right 指针作为窗口的右边界,遍历整个数组
for (int right = 0; right < n; right++) {
// 步骤 1: 维护队列的单调递减性
// 当队列不为空 (head <= tail),且队尾元素小于等于当前元素时,
// 将队尾元素“弹出”。
while (head <= tail && nums[q[tail]] <= nums[right]) {
// 通过移动 tail 指针实现“队尾弹出”
tail--;
}
// 将当前元素的索引“压入”队尾
q[++tail] = right;
// 步骤 2: 维护窗口的有效范围
// 检查队首元素的索引是否已经滑出窗口左边界
if (right - q[head] + 1 > k) {
// 通过移动 head 指针实现“队首弹出”
head++;
}
// 步骤 3: 记录结果
// 当窗口大小达到 k 时,开始记录最大值
if (right >= k - 1) {
// 队首 q[head] 始终是当前窗口最大值的索引
ans[right - k + 1] = nums[q[head]];
}
}
return ans;
}
}
时空复杂度
时间复杂度:O(N)
- 循环:外层
for
循环遍历nums
数组一次,执行N
次。 - 指针操作:
right
指针从0
移动到N-1
。tail
指针在[-1, N-1]
的范围内移动。每个元素的索引right
最多使tail
增加一次。head
指针在[0, N]
的范围内移动。- 关键在于,
nums
中的每个元素的索引最多只会被压入q
数组一次(通过tail++
),也最多只会被移出一次(通过head++
或tail--
)。 - 因此,所有对
head
和tail
指针的操作,在整个算法生命周期内的总次数是 O(N) 级别。 - 将这些操作的成本均摊到
N
次外层循环中,每次循环的均摊时间复杂度为 O(1)。
综合分析:
算法由 N
次均摊时间复杂度为 O(1) 的操作构成,因此总的时间复杂度是 O(N)。
空间复杂度:O(N)
- 主要存储开销:算法使用了一个整型数组
q
来模拟队列。 - 空间大小:
- 代码中
q
被初始化为new int[n]
,直接分配了与输入数组等大的空间。因此,从内存分配的角度看,空间复杂度是 O(N)。 - 值得注意的细节:虽然分配了 O(N) 的空间,但队列在任意时刻的实际元素数量(即
tail - head + 1
)并不会超过窗口大小k
。所以,算法在逻辑上使用的空间是 O(k)。但在分析代码的实际内存占用时,应以上限 O(N) 为准。
- 代码中
- 结果数组:
ans
数组不计入额外辅助空间。
综合分析:
根据代码 int[] q = new int[n];
的直接内存分配,该算法的额外辅助空间复杂度为 O(N)。