239. 滑动窗口最大值

题目

给你一个整数数组 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. 1 <= nums.length <= 10^5
  2. -10^4 <= nums[i] <= 10^4
  3. 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 和用于存储结果的数组 returnarrlastout 用于记录上一个滑出窗口的元素,初始化为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 循环来遍历整个数组,并根据以下条件更新滑动窗口的最大值:

  1. 新进入的值比上一个窗口最大值还大:直接更新最大值。
  2. 上一个滑出的值不是上一个窗口的最大值且本次进入的数字比上一个窗口最大值小:复用最大值。
  3. 重新计算窗口内最大值:遍历当前滑动窗口,找到最大值。

复杂度分析

  • 时间复杂度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;
}
代码解析
  1. 创建和销毁双端队列

    • createDeque 函数分配内存并初始化一个双端队列。
    • destroyDeque 函数释放双端队列占用的内存。
  2. 双端队列操作

    • dequePushBack 将元素添加到队尾。
    • dequePopFront 将元素从队首移除。
    • dequePopBack 将元素从队尾移除。
    • dequeFront 返回队首元素。
    • dequeBack 返回队尾元素。
    • dequeIsEmpty 检查队列是否为空。
  3. 滑动窗口逻辑

    • 遍历数组 nums,维护一个双端队列来存储滑动窗口内可能成为最大值的元素的下标。
    • 如果队首元素已经滑出窗口,则移除队首元素。
    • 移除队尾所有小于当前元素的元素,因为它们不可能成为未来窗口的最大值。
    • 将当前元素下标添加到队尾。
    • 如果当前下标大于等于 k-1,则将当前窗口的最大值(即队首元素)添加到结果数组中。

在实现双端队列(deque)时,通过取余操作来维护队列的循环性质。这种做法确保队列的元素能够在数组中循环使用,而不会超出数组的边界。下面是具体的原因和取余操作的意义:

双端队列的循环特性

假设我们有一个固定大小的数组来实现双端队列。我们希望这个数组可以循环使用,以便在添加新元素时,如果到达数组末尾,可以回到数组的起点继续存储元素。这种设计避免了动态数组的复杂性,并有效利用了固定大小的数组空间。

取余操作的作用

取余操作 (%) 使得队列的索引可以在数组中循环,从而实现环形队列(circular queue)。具体来说:

  • 当添加元素时,通过取余操作,可以确保 rear 索引在到达数组末尾后,能够回到数组的起点。
  • 当移除元素时,通过取余操作,可以确保 front 索引在到达数组末尾后,能够回到数组的起点。

代码解释

deque->rear = (deque->rear + 1) % deque->size;

这行代码的作用是将 rear 索引向后移动一个位置,并确保不会超出数组的边界。具体来说:

  1. deque->rear + 1:将 rear 索引向后移动一个位置。
  2. (deque->rear + 1) % deque->size:通过取余操作,将索引限制在 0deque->size - 1 之间。这样,当 rear 索引到达数组末尾时,能够回到数组的起点,从而实现循环。

举例说明

假设队列的大小为 5 (deque->size = 5),并且当前 rear 索引为 4(数组的最后一个位置)。当添加一个新元素时,rear 索引需要移动到数组的起点(索引 0):

  1. deque->rear + 1 等于 5
  2. (deque->rear + 1) % deque->size 等于 5 % 5,结果为 0

通过这种方式,rear 索引回到数组的起点,实现了循环队列。

为了更直观地理解为什么要取余操作,我们可以通过一个示意图来展示双端队列(deque)的循环特性。

假设我们有一个固定大小为 5 的数组来实现双端队列,表示为 deque->size = 5

初始状态

队列为空时,frontrear 初始化为 0-1

Index:   0   1   2   3   4
Data:  [   ][   ][   ][   ][   ]
Front: 0
Rear:  -1

添加元素

我们依次添加元素 1, 2, 3, 4, 5,通过取余操作,确保索引在数组范围内循环:

  1. 添加元素 1
Index:   0   1   2   3   4
Data:  [ 1 ][   ][   ][   ][   ]
Front: 0
Rear:  0
  1. 添加元素 2
Index:   0   1   2   3   4
Data:  [ 1 ][ 2 ][   ][   ][   ]
Front: 0
Rear:  1
  1. 添加元素 3
Index:   0   1   2   3   4
Data:  [ 1 ][ 2 ][ 3 ][   ][   ]
Front: 0
Rear:  2
  1. 添加元素 4
Index:   0   1   2   3   4
Data:  [ 1 ][ 2 ][ 3 ][ 4 ][   ]
Front: 0
Rear:  3
  1. 添加元素 5
Index:   0   1   2   3   4
Data:  [ 1 ][ 2 ][ 3 ][ 4 ][ 5 ]
Front: 0
Rear:  4

环形特性

现在队列已满,rear 索引为 4,如果继续添加元素,rear 索引需要回到数组起点(索引 0),通过取余操作实现循环:

  1. 添加元素 6(假设移除 1):
Index:   0   1   2   3   4
Data:  [ 6 ][ 2 ][ 3 ][ 4 ][ 5 ]
Front: 1
Rear:  0

这里的 rear = (rear + 1) % size 计算如下:

  • 当前 rear4
  • 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)用于结果数组。

结果

在这里插入图片描述

  • 20
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值