文章目录
1. 题目描述
给你一个整数数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入:nums = [1], k = 1
输出:[1]
示例 3:
输入:nums = [1,-1], k = 1
输出:[1,-1]
示例 4:
输入:nums = [9,11], k = 2
输出:[11]
示例 5:
输入:nums = [4,-2], k = 2
输出:[4]
提示:
1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
1 <= k <= nums.length
2. 理解题目
这道题需要我们求解滑动窗口内的最大值。滑动窗口是固定大小为k的子数组,从数组左端开始,每次向右移动一个位置,直到窗口右端达到数组末尾。
关键理解:
- 窗口大小固定为k
- 窗口每次向右移动一个位置
- 需要返回每个窗口位置的最大值
- 最终结果是一个长度为
n-k+1
的数组,其中n是原数组长度
例如,对于示例1中的数组[1,3,-1,-3,5,3,6,7]
和k=3:
- 第一个窗口包含元素
[1,3,-1]
,最大值是3 - 第二个窗口包含元素
[3,-1,-3]
,最大值是3 - 第三个窗口包含元素
[-1,-3,5]
,最大值是5 - 依此类推…
最终输出:[3,3,5,5,6,7]
3. 解法一:暴力法
3.1 思路
最直接的思路是对每个窗口位置,遍历窗口内的所有元素来找出最大值。
具体步骤:
- 对于每个窗口位置(从0到n-k),遍历窗口内的k个元素
- 找出这k个元素中的最大值
- 将最大值添加到结果数组中
3.2 Java代码实现
public class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0 || k == 0) {
return new int[0];
}
// 结果数组长度为 n-k+1
int[] result = new int[n - k + 1];
// 对每个窗口位置
for (int i = 0; i <= n - k; i++) {
int max = nums[i]; // 初始化为窗口内第一个元素
// 遍历窗口内的其他元素
for (int j = i + 1; j < i + k; j++) {
max = Math.max(max, nums[j]);
}
result[i] = max; // 保存当前窗口的最大值
}
return result;
}
}
3.3 代码详解
- 首先处理边界情况:如果数组为空或窗口大小为0,返回空数组
- 创建结果数组,其长度为
n-k+1
- 外层循环遍历每个窗口的起始位置
- 内层循环遍历当前窗口内的元素,找出最大值
- 将当前窗口的最大值存入结果数组
3.4 复杂度分析
- 时间复杂度:O(n*k),其中n是数组长度。对于每个窗口位置(共n-k+1个),我们需要O(k)时间找出窗口内的最大值。
- 空间复杂度:O(n-k+1),即结果数组的大小。
3.5 适用场景
暴力法适用于数组长度不大且窗口大小较小的情况。当数组长度或窗口大小较大时,这种方法效率较低。
4. 解法二:优先队列(最大堆)
4.1 思路
我们可以使用优先队列(最大堆)来维护窗口中的元素,这样可以在O(log k)时间内获取窗口中的最大值。
为了处理元素移出窗口的情况,我们需要在队列中存储元素值和索引的对,这样可以判断堆顶元素是否还在当前窗口内。
具体步骤:
- 创建一个最大堆,存储元素值和索引的对
- 初始将前k个元素加入堆
- 对于每个窗口位置:
- 检查堆顶元素是否在当前窗口内(通过索引判断)
- 如果不在,弹出堆顶元素,直到找到一个在窗口内的元素
- 将当前窗口的最大值(堆顶元素)添加到结果中
- 将下一个元素加入堆(如果存在)
4.2 Java代码实现
import java.util.PriorityQueue;
import java.util.Comparator;
public class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0 || k == 0) {
return new int[0];
}
// 结果数组
int[] result = new int[n - k + 1];
// 创建最大堆,按元素值降序排列
PriorityQueue<int[]> maxHeap = new PriorityQueue<>(
(a, b) -> b[0] - a[0] // 降序排列,最大值在堆顶
);
// 初始将前k个元素加入堆
for (int i = 0; i < k; i++) {
maxHeap.offer(new int[]{nums[i], i});
}
// 获取第一个窗口的最大值
result[0] = maxHeap.peek()[0];
// 处理后续窗口
for (int i = k; i < n; i++) {
// 添加当前元素到堆
maxHeap.offer(new int[]{nums[i], i});
// 移除不在当前窗口内的元素
while (!maxHeap.isEmpty() && maxHeap.peek()[1] <= i - k) {
maxHeap.poll();
}
// 当前窗口的最大值
result[i - k + 1] = maxHeap.peek()[0];
}
return result;
}
}
4.3 代码详解
- 处理边界情况:如果数组为空或窗口大小为0,返回空数组
- 创建结果数组,其长度为
n-k+1
- 创建最大堆,存储元素值和索引的二元组,按元素值降序排列
- 初始将前k个元素加入堆,并获取第一个窗口的最大值
- 对于每个后续窗口:
- 将窗口中的新元素加入堆
- 移除不在当前窗口内的元素(即索引小于或等于i-k的元素)
- 将堆顶元素的值(当前窗口最大值)存入结果数组
4.4 复杂度分析
- 时间复杂度:O(n log k),其中n是数组长度,k是窗口大小。每个元素最多入堆和出堆一次,每次操作时间为O(log k)。
- 空间复杂度:O(k),优先队列中最多有k个元素。
4.5 适用场景
优先队列法适用于窗口大小较大的情况,因为它对每个窗口的处理不需要遍历所有k个元素。但当k很小时,暴力法可能更快。
5. 解法三:双端队列(Deque)
5.1 思路
双端队列法是本题的最优解法之一。它通过维护一个单调递减的队列(存储元素索引),保证队首始终是当前窗口的最大元素索引。
关键思想:
- 如果一个元素比它前面的元素大,那么前面的元素就永远不会成为窗口的最大值
- 保持队列中的元素索引对应的值是单调递减的
- 队首元素始终是当前窗口的最大值索引
具体步骤:
- 创建一个双端队列,存储元素索引
- 遍历数组:
- 移除队列中不在当前窗口的元素(即索引小于i-k+1的元素)
- 从队尾开始,移除所有小于当前元素的索引
- 将当前元素的索引加入队尾
- 如果当前位置大于等于k-1(即已形成第一个窗口),将队首元素对应的值添加到结果中
5.2 Java代码实现
import java.util.ArrayDeque;
import java.util.Deque;
public class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0 || k == 0) {
return new int[0];
}
// 结果数组
int[] result = new int[n - k + 1];
// 双端队列,存储元素索引
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 移除不在当前窗口的元素
if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 移除所有小于当前元素的索引,保持队列单调递减
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
// 将当前元素索引加入队尾
deque.offerLast(i);
// 当窗口首次形成并开始滑动时,添加结果
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
}
5.3 代码详解
- 处理边界情况:如果数组为空或窗口大小为0,返回空数组
- 创建结果数组,其长度为
n-k+1
- 创建双端队列,用于存储元素的索引
- 遍历数组:
- 移除队列中不在当前窗口范围内的元素索引
- 从队尾开始,移除所有对应值小于当前元素的索引,这样保证队列中元素的单调递减性
- 将当前元素的索引加入队尾
- 如果已经形成窗口(i >= k-1),则将队首元素对应的值添加到结果数组
5.4 复杂度分析
- 时间复杂度:O(n),其中n是数组长度。每个元素最多入队和出队一次,所有操作都是O(1)时间。
- 空间复杂度:O(k),双端队列中最多有k个元素。
5.5 适用场景
双端队列法是该问题的最优解法,尤其适用于大型数组和大窗口。由于其线性时间复杂度,在大多数情况下都优于前两种方法。
6. 解法四:动态规划法
6.1 思路
动态规划法利用了预处理技术,将数组分为大小为k的块,并预先计算每个块内的最大值。
具体步骤:
- 将数组分为大小为k的块
- 预处理两个辅助数组:
leftMax[i]
:从块的左边界到位置i的最大值rightMax[i]
:从块的右边界到位置i的最大值
- 对于每个窗口,其最大值为max(rightMax[i], leftMax[i+k-1]),其中i是窗口的左边界
6.2 Java代码实现
public class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
if (n == 0 || k == 0) {
return new int[0];
}
// 结果数组
int[] result = new int[n - k + 1];
// 预处理数组
int[] leftMax = new int[n];
int[] rightMax = new int[n];
// 计算leftMax
for (int i = 0; i < n; i++) {
// 如果i是块的左边界
if (i % k == 0) {
leftMax[i] = nums[i];
} else {
leftMax[i] = Math.max(leftMax[i - 1], nums[i]);
}
}
// 计算rightMax
for (int i = n - 1; i >= 0; i--) {
// 如果i是块的右边界,或者是数组的最后一个元素
if (i % k == k - 1 || i == n - 1) {
rightMax[i] = nums[i];
} else {
rightMax[i] = Math.max(rightMax[i + 1], nums[i]);
}
}
// 计算每个窗口的最大值
for (int i = 0; i <= n - k; i++) {
// 窗口的右边界是i+k-1
result[i] = Math.max(rightMax[i], leftMax[i + k - 1]);
}
return result;
}
}
6.3 代码详解
- 处理边界情况
- 创建结果数组和两个辅助数组
leftMax
和rightMax
- 计算
leftMax
数组:- 如果i是块的左边界,
leftMax[i] = nums[i]
- 否则,
leftMax[i] = max(leftMax[i-1], nums[i])
- 如果i是块的左边界,
- 计算
rightMax
数组:- 如果i是块的右边界或数组的最后一个元素,
rightMax[i] = nums[i]
- 否则,
rightMax[i] = max(rightMax[i+1], nums[i])
- 如果i是块的右边界或数组的最后一个元素,
- 计算每个窗口的最大值:
- 对于窗口[i, i+k-1],最大值为max(rightMax[i], leftMax[i+k-1])
6.4 复杂度分析
- 时间复杂度:O(n),其中n是数组长度。预处理数组和计算结果都只需一次遍历。
- 空间复杂度:O(n),需要两个辅助数组。
6.5 适用场景
动态规划法与双端队列法一样高效,但实现更直观。当需要处理静态数据(不会动态更新)时,这种方法尤其适用。
7. 优化与提升
7.1 滑动窗口处理技巧
在处理滑动窗口问题时,有一些常用技巧:
-
窗口形式的循环:
for (int right = 0; right < nums.length; right++) { // 扩展窗口右边界 while (/* 需要缩小窗口的条件 */) { // 缩小窗口左边界 left++; } // 计算当前窗口的结果 }
-
双指针技术:使用左右两个指针表示窗口的边界,根据条件移动指针。
-
队列/堆的应用:使用适当的数据结构来高效维护窗口内的最值。
7.2 双端队列优化
上述双端队列解法可以进一步优化:
-
初始填充队列:先处理前k个元素,构建初始队列:
// 填充初始队列 for (int i = 0; i < k; i++) { while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) { deque.pollLast(); } deque.offerLast(i); } result[0] = nums[deque.peekFirst()]; // 处理后续元素 for (int i = k; i < nums.length; i++) { // 常规处理 }
-
减少不必要的检查:对于大型数组,可以考虑每k个元素清空并重建队列,避免频繁检查元素是否在窗口内。
7.3 处理大规模数据
对于非常大的数组,可以考虑以下优化:
- 分块处理:将数组分成多个块,分别处理后合并结果。
- 并行计算:利用多线程并行计算不同块的结果。
- 内存优化:对于动态规划解法,可以使用循环数组减少内存使用。
8. 进阶思考与变体问题
8.1 滑动窗口最小值
与本题相似,找出滑动窗口中的最小值。解法与找最大值类似,只需调整比较方向:
public int[] minSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 移除不在当前窗口的元素
if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 保持队列单调递增
while (!deque.isEmpty() && nums[deque.peekLast()] > nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
8.2 多个窗口大小
如果需要计算多个不同大小的窗口的最大值,一个有效的方法是预处理出各种范围的最大值(稀疏表/Sparse Table):
// 构建稀疏表
int[][] sparseTable = new int[n][log2(n) + 1];
for (int i = 0; i < n; i++) {
sparseTable[i][0] = nums[i];
}
for (int j = 1; (1 << j) <= n; j++) {
for (int i = 0; i + (1 << j) - 1 < n; i++) {
sparseTable[i][j] = Math.max(sparseTable[i][j-1],
sparseTable[i + (1 << (j-1))][j-1]);
}
}
// 查询范围[L,R]的最大值
public int queryMax(int L, int R) {
int j = (int) Math.log(R - L + 1) / Math.log(2);
return Math.max(sparseTable[L][j], sparseTable[R - (1 << j) + 1][j]);
}
8.3 实时数据流中的滑动窗口最大值
对于实时数据流,可以使用双端队列方法,不断添加新元素并移除过期元素:
class SlidingWindowMaximum {
private Deque<Integer> deque = new ArrayDeque<>();
private int[] nums;
private int k;
private int count = 0;
public SlidingWindowMaximum(int k) {
this.k = k;
this.nums = new int[k];
}
public int add(int val) {
// 计算当前元素在数组中的位置
int index = count % k;
count++;
nums[index] = val;
// 重建队列
deque.clear();
for (int i = 0; i < k; i++) {
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
}
return nums[deque.peekFirst()];
}
}
9. 常见错误与优化
9.1 常见错误
-
忘记移除窗口外的元素:
// 错误:没有移除窗口外的元素 for (int i = 0; i < n; i++) { // 添加当前元素 deque.offerLast(i); // 检查当前窗口最大值 // ... }
正确做法:
for (int i = 0; i < n; i++) { // 移除窗口外的元素 if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) { deque.pollFirst(); } // 添加当前元素 // ... }
-
错误的队列维护:
// 错误:没有正确维护队列的单调性 while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) { deque.pollLast(); }
这个比较条件应该是
<
还是<=
取决于我们是否需要处理重复元素。如果队列中保留了重复的最大值,当最大值移出窗口时,可能会错误地保留这个值。 -
索引混淆:
// 错误:窗口索引计算错误 result[i - k] = nums[deque.peekFirst()];
正确做法:
result[i - k + 1] = nums[deque.peekFirst()];
9.2 性能优化
-
避免频繁检查窗口边界:
// 优化前 for (int i = 0; i < n; i++) { if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) { deque.pollFirst(); } // ... } // 优化后 for (int i = 0; i < n; i++) { // 只有当队列非空且第一个元素肯定过期时才检查 if (!deque.isEmpty() && i - deque.peekFirst() >= k) { deque.pollFirst(); } // ... }
-
预分配足够的空间:
// 预先知道结果大小 int[] result = new int[n - k + 1]; Deque<Integer> deque = new ArrayDeque<>(k); // 指定初始容量
-
使用数组实现双端队列:自定义双端队列实现,避免Java内置集合类的开销:
class ArrayDeque { private int[] array; private int front, rear; private int capacity; public ArrayDeque(int capacity) { this.capacity = capacity; array = new int[capacity]; front = rear = -1; } // 实现offerLast, pollFirst, peekFirst, peekLast等方法 // ... }
10. 完整的 Java 解决方案
下面是结合了各种优化的完整解法,使用双端队列实现:
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0 || k <= 0) {
return new int[0];
}
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>(k);
// 处理前k个元素,建立初始单调队列
for (int i = 0; i < k; i++) {
// 保持队列单调递减
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
}
// 第一个窗口的最大值
result[0] = nums[deque.peekFirst()];
// 处理剩余元素
for (int i = k; i < n; i++) {
// 移除不在当前窗口的元素
if (!deque.isEmpty() && deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 保持队列单调递减
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
// 当前窗口的最大值
result[i - k + 1] = nums[deque.peekFirst()];
}
return result;
}
}
11. 实际运用示例
11.1 LeetCode提交结果
双端队列解法在LeetCode上提交的结果通常如下:
- 执行用时:约 15-30 ms(击败约 90-95% 的 Java 提交)
- 内存消耗:约 50-60 MB(击败约 80-85% 的 Java 提交)
11.2 应用场景
滑动窗口最大值问题在实际编程中有很多应用场景,例如:
- 股票市场中计算n天内的最高价格
- 网络流量监控中检测特定时间窗口内的峰值流量
- 图像处理中的最大值滤波器
- 数据流处理中的实时数据分析
11.3 扩展用例
public class SlidingWindowMaximumApplication {
public static void main(String[] args) {
Solution solution = new Solution();
// 基本测试用例
test(solution, new int[]{1, 3, -1, -3, 5, 3, 6, 7}, 3);
// 边界测试用例
test(solution, new int[]{1}, 1);
test(solution, new int[]{1, -1}, 1);
// 窗口大小等于数组长度
test(solution, new int[]{1, 3, 5, 7}, 4);
// 大型测试用例
int[] largeArray = new int[10000];
for (int i = 0; i < 10000; i++) {
largeArray[i] = (int)(Math.random() * 10000);
}
long startTime = System.currentTimeMillis();
solution.maxSlidingWindow(largeArray, 100);
long endTime = System.currentTimeMillis();
System.out.println("大型测试用例耗时: " + (endTime - startTime) + "ms");
}
private static void test(Solution solution, int[] nums, int k) {
int[] result = solution.maxSlidingWindow(nums, k);
System.out.print("数组: ");
printArray(nums);
System.out.println("窗口大小: " + k);
System.out.print("结果: ");
printArray(result);
System.out.println();
}
private static void printArray(int[] arr) {
System.out.print("[");
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]);
if (i < arr.length - 1) {
System.out.print(", ");
}
}
System.out.println("]");
}
}
12. 总结与技巧
12.1 解题要点
- 理解滑动窗口概念:理解窗口的移动过程和窗口内元素的更新方式。
- 选择合适的数据结构:根据问题特点选择合适的数据结构,如双端队列、优先队列等。
- 单调队列技巧:掌握维护单调队列的方法,这是解决滑动窗口最值问题的关键。
- 处理边界情况:注意窗口初始形成和结束时的特殊处理。
- 优化时间复杂度:尽量避免重复计算,争取O(n)时间复杂度。
12.2 学习收获
通过学习这道题,你可以掌握:
- 滑动窗口算法的基本思想
- 双端队列的应用
- 单调队列的维护方法
- 优化算法的常用技巧
- 处理序列和窗口问题的通用方法
12.3 面试技巧
在面试中遇到类似问题时:
- 先分析最简单的解法(如暴力法)
- 分析其时间和空间复杂度的瓶颈
- 引入双端队列或堆等优化方法
- 讨论维护窗口最大值的策略(单调队列)
- 处理各种边界情况