题目
给你一个整数数组 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]
提示
1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
1 <= k <= nums.length
代码
#include <stdio.h>
#include <limits.h>
#include <stdlib.h>
/**
* Note: The returned array must be malloced, assume caller calls free().
*/
int* maxSlidingWindow(int* nums, int numsSize, int k, int* returnSize) {
int *p_nums = nums;
int* returnarr = (int*)malloc((numsSize - k + 1) * sizeof(int));
int lastout = 0;
for (int i = 0; i < (numsSize - k + 1); i++)
{
returnarr[i] = INT_MIN;
}
(*returnSize) = 0;
while((*returnSize) < (numsSize - k + 1))
{
if (p_nums != nums && nums[(p_nums - nums) + k - 1] >= returnarr[(*returnSize) - 1]) // 新进入的值比上一个窗口最大值还大
{
returnarr[(*returnSize)] = nums[(p_nums - nums) + k - 1];
}
else if(p_nums != nums && lastout != returnarr[(*returnSize) - 1] && nums[(p_nums - nums) + k - 1] <= returnarr[(*returnSize) - 1]) // 上一个出去的不是上一个窗口的最大值且本次进入的数字比上一个窗口最大值小,则不用计算,直接复用最大值
{
returnarr[(*returnSize)] = returnarr[(*returnSize) - 1];
}
else
{
for (int i = (p_nums - nums); i < k + (p_nums - nums); i++)
{
if(nums[i] > returnarr[(*returnSize)])
{
returnarr[(*returnSize)] = nums[i];
}
}
}
lastout = (*p_nums);
(*returnSize)++;
p_nums++;
}
return returnarr;
}
详细解析
思路分析
该问题的核心在于如何高效地找到每个滑动窗口中的最大值。为了实现这一目标,我们可以通过维护一个滑动窗口并在窗口移动时计算其最大值。
代码解析
初始化和内存分配
int *p_nums = nums;
int* returnarr = (int*)malloc((numsSize - k + 1) * sizeof(int));
int lastout = 0;
for (int i = 0; i < (numsSize - k + 1); i++)
{
returnarr[i] = INT_MIN;
}
这里我们初始化了指向数组 nums
的指针 p_nums
和用于存储结果的数组 returnarr
。lastout
用于记录上一个滑出窗口的元素,初始化为0。我们为每个滑动窗口的最大值初始化为 INT_MIN
。
主循环逻辑
(*returnSize) = 0;
while((*returnSize) < (numsSize - k + 1))
{
if (p_nums != nums && nums[(p_nums - nums) + k - 1] >= returnarr[(*returnSize) - 1])
{
returnarr[(*returnSize)] = nums[(p_nums - nums) + k - 1];
}
else if(p_nums != nums && lastout != returnarr[(*returnSize) - 1] && nums[(p_nums - nums) + k - 1] <= returnarr[(*returnSize) - 1])
{
returnarr[(*returnSize)] = returnarr[(*returnSize) - 1];
}
else
{
for (int i = (p_nums - nums); i < k + (p_nums - nums); i++)
{
if(nums[i] > returnarr[(*returnSize)])
{
returnarr[(*returnSize)] = nums[i];
}
}
}
lastout = (*p_nums);
(*returnSize)++;
p_nums++;
}
这里,我们通过一个 while
循环来遍历整个数组,并根据以下条件更新滑动窗口的最大值:
- 新进入的值比上一个窗口最大值还大:直接更新最大值。
- 上一个滑出的值不是上一个窗口的最大值且本次进入的数字比上一个窗口最大值小:复用最大值。
- 重新计算窗口内最大值:遍历当前滑动窗口,找到最大值。
复杂度分析
- 时间复杂度:
O(n * k)
,其中n
是数组的长度,因为在最坏情况下,我们需要遍历每个滑动窗口内的所有元素来找到最大值。 - 空间复杂度:
O(n - k + 1)
,用于存储结果数组。
结果
一题多解
双端队列法
使用双端队列可以优化滑动窗口最大值问题,将时间复杂度降低到 O(n)
。双端队列维护了窗口内可能成为最大值的元素的下标,保证队首元素是当前窗口的最大值。
双端队列代码
#include <stdio.h>
#include <stdlib.h>
/**
* Note: The returned array must be malloced, assume caller calls free().
*/
typedef struct {
int* data;
int front;
int rear;
int size;
} Deque;
Deque* createDeque(int size) {
Deque* deque = (Deque*)malloc(sizeof(Deque));
deque->data = (int*)malloc(size * sizeof(int));
deque->front = 0;
deque->rear = -1;
deque->size = size;
return deque;
}
void destroyDeque(Deque* deque) {
free(deque->data);
free(deque);
}
void dequePushBack(Deque* deque, int value) {
deque->rear = (deque->rear + 1) % deque->size;
deque->data[deque->rear] = value;
}
void dequePopFront(Deque* deque) {
deque->front = (deque->front + 1) % deque->size;
}
void dequePopBack(Deque* deque) {
deque->rear = (deque->rear - 1 + deque->size) % deque->size;
}
int dequeFront(Deque* deque) {
return deque->data[deque->front];
}
int dequeBack(Deque* deque) {
return deque->data[deque->rear];
}
int dequeIsEmpty(Deque* deque) {
return deque->front == (deque->rear + 1) % deque->size;
}
int* maxSlidingWindow(int* nums, int numsSize, int k, int* returnSize) {
Deque* deque = createDeque(k + 1);
int* result = (int*)malloc((numsSize - k + 1) * sizeof(int));
*returnSize = 0;
for (int i = 0; i < numsSize; ++i) {
if (!dequeIsEmpty(deque) && dequeFront(deque) == i - k) {
dequePopFront(deque);
}
while (!dequeIsEmpty(deque) && nums[dequeBack(deque)] < nums[i]) {
dequePopBack(deque);
}
dequePushBack(deque, i);
if (i >= k - 1) {
result[(*returnSize)++] = nums[dequeFront(deque)];
}
}
destroyDeque(deque);
return result;
}
代码解析
-
创建和销毁双端队列:
createDeque
函数分配内存并初始化一个双端队列。destroyDeque
函数释放双端队列占用的内存。
-
双端队列操作:
dequePushBack
将元素添加到队尾。dequePopFront
将元素从队首移除。dequePopBack
将元素从队尾移除。dequeFront
返回队首元素。dequeBack
返回队尾元素。dequeIsEmpty
检查队列是否为空。
-
滑动窗口逻辑:
- 遍历数组
nums
,维护一个双端队列来存储滑动窗口内可能成为最大值的元素的下标。 - 如果队首元素已经滑出窗口,则移除队首元素。
- 移除队尾所有小于当前元素的元素,因为它们不可能成为未来窗口的最大值。
- 将当前元素下标添加到队尾。
- 如果当前下标大于等于
k-1
,则将当前窗口的最大值(即队首元素)添加到结果数组中。
- 遍历数组
在实现双端队列(deque)时,通过取余操作来维护队列的循环性质。这种做法确保队列的元素能够在数组中循环使用,而不会超出数组的边界。下面是具体的原因和取余操作的意义:
双端队列的循环特性
假设我们有一个固定大小的数组来实现双端队列。我们希望这个数组可以循环使用,以便在添加新元素时,如果到达数组末尾,可以回到数组的起点继续存储元素。这种设计避免了动态数组的复杂性,并有效利用了固定大小的数组空间。
取余操作的作用
取余操作 (%
) 使得队列的索引可以在数组中循环,从而实现环形队列(circular queue)。具体来说:
- 当添加元素时,通过取余操作,可以确保
rear
索引在到达数组末尾后,能够回到数组的起点。 - 当移除元素时,通过取余操作,可以确保
front
索引在到达数组末尾后,能够回到数组的起点。
代码解释
deque->rear = (deque->rear + 1) % deque->size;
这行代码的作用是将 rear
索引向后移动一个位置,并确保不会超出数组的边界。具体来说:
deque->rear + 1
:将rear
索引向后移动一个位置。(deque->rear + 1) % deque->size
:通过取余操作,将索引限制在0
到deque->size - 1
之间。这样,当rear
索引到达数组末尾时,能够回到数组的起点,从而实现循环。
举例说明
假设队列的大小为 5
(deque->size = 5
),并且当前 rear
索引为 4
(数组的最后一个位置)。当添加一个新元素时,rear
索引需要移动到数组的起点(索引 0
):
deque->rear + 1
等于5
。(deque->rear + 1) % deque->size
等于5 % 5
,结果为0
。
通过这种方式,rear
索引回到数组的起点,实现了循环队列。
为了更直观地理解为什么要取余操作,我们可以通过一个示意图来展示双端队列(deque)的循环特性。
假设我们有一个固定大小为 5
的数组来实现双端队列,表示为 deque->size = 5
。
初始状态
队列为空时,front
和 rear
初始化为 0
和 -1
:
Index: 0 1 2 3 4
Data: [ ][ ][ ][ ][ ]
Front: 0
Rear: -1
添加元素
我们依次添加元素 1, 2, 3, 4, 5
,通过取余操作,确保索引在数组范围内循环:
- 添加元素
1
:
Index: 0 1 2 3 4
Data: [ 1 ][ ][ ][ ][ ]
Front: 0
Rear: 0
- 添加元素
2
:
Index: 0 1 2 3 4
Data: [ 1 ][ 2 ][ ][ ][ ]
Front: 0
Rear: 1
- 添加元素
3
:
Index: 0 1 2 3 4
Data: [ 1 ][ 2 ][ 3 ][ ][ ]
Front: 0
Rear: 2
- 添加元素
4
:
Index: 0 1 2 3 4
Data: [ 1 ][ 2 ][ 3 ][ 4 ][ ]
Front: 0
Rear: 3
- 添加元素
5
:
Index: 0 1 2 3 4
Data: [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ]
Front: 0
Rear: 4
环形特性
现在队列已满,rear
索引为 4
,如果继续添加元素,rear
索引需要回到数组起点(索引 0
),通过取余操作实现循环:
- 添加元素
6
(假设移除1
):
Index: 0 1 2 3 4
Data: [ 6 ][ 2 ][ 3 ][ 4 ][ 5 ]
Front: 1
Rear: 0
这里的 rear = (rear + 1) % size
计算如下:
- 当前
rear
为4
。 rear + 1 = 5
。(rear + 1) % size = 5 % 5 = 0
。
这使得 rear
回到起点,实现循环。
图示
通过图示更直观地展示这一过程:
初始状态:
Front
|
v
+---+---+---+---+---+
| | | | | |
+---+---+---+---+---+
^
|
Rear
添加元素 1:
Front Rear
| |
v v
+---+---+---+---+---+
| 1 | | | | |
+---+---+---+---+---+
添加元素 2:
Front Rear
| |
v v
+---+---+---+---+---+
| 1 | 2 | | | |
+---+---+---+---+---+
...
添加元素 5:
Front Rear
| |
v v
+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 |
+---+---+---+---+---+
添加元素 6(回到起点):
Front Rear
| |
v v
+---+---+---+---+---+
| 6 | 2 | 3 | 4 | 5 |
+---+---+---+---+---+
通过这种循环特性,我们能够有效利用固定大小的数组,实现双端队列的高效操作。
复杂度分析
- 时间复杂度:
O(n)
,其中n
是数组的长度,我们只需要遍历nums一遍。 - 空间复杂度:
O((numsSize - k + 1) + (k + 1))
,(k + 1)
用于队列存储,(numsSize - k + 1)
用于结果数组。