题目信息
给你一个整数数组
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]提示:
1 <= nums.length <= 105-104 <= nums[i] <= 1041 <= k <= nums.length
初步的自己的思考
读完了这个题目,好像思维难度不大?没有觉得需要去想这个题目 要如何去处理?可能是压在了数据结构和时间复杂度上面?
写代码发现了一个问题,一个最大的难点是,如果此时滑动窗口的最大值刚好在最左侧,而新进来的数字要小于这个此时的最大值,那我就需要额外遍历去找到第二大的值,好像复杂化了这个问题
GPT启发思考
你要维护什么?你要用怎样的数据结构来维护这个东西?
换一个思路,当新进来一个数字 x
当前维护的数字中,比x小的数字还有意义吗? 答案是没有,因为我看的是最大值,而且数字x肯定比这个数字后离开滑动窗口。所以所有位于x左侧且不大于x的数字,都应该不再维护,被淘汰掉
例如当前候选者是:
9, 7, 5
新数字 6 进入:
5` 可以淘汰,因为 `6` 更大且离开得更晚
剩下:
9, 7, 6
当 9 从窗口左侧离开时,7 就能立即接任最大值,不需要重新遍历窗口。
你接下来只需思考三个问题:
- 候选者的数值应该保持什么顺序,才能让最大值始终位于固定位置?
- 新数字进入时,需要从候选序列的哪一端淘汰较小数字?
- 窗口左侧数字过期时,如何判断候选者是否已经离开窗口?
第三个问题提示你:候选序列中最好保存数组下标,而不仅仅是数值。
最终你需要寻找一种结构,它能够:
从一端删除已经过期的最大值
从另一端删除已经失去竞争资格的较小值
快速查看当前最大值
用示例手动模拟一下:
nums = [1, 3, -1, -3, 5]
k = 3
候选变化如下:
1 进入: [1]
3 进入: 1 永远竞争不过更新的 3,所以淘汰 1
[3]
-1 进入: [3, -1]
第一个完整窗口是:
[1, 3, -1]
候选队列是:
[3, -1]
队头 3 就是最大值。
继续:
-3 进入: [3, -1, -3]
这时原来的 1 虽然离开窗口,但它早已不在候选队列中,所以不用处理它。
再向后滑动时,3 会离开窗口。因为 3 位于候选队头,可以根据它的下标判断它已经过期,然后删除,后面的候选者自然接班。
新数字 5 进入时:
[3, -1, -3]
这些数字都不可能再竞争过更新的 5,所以可以从队尾连续淘汰,最后只剩:
[5]
你现在可以把每一轮概括成三个动作:
- 删除队头已经离开窗口的下标。
- 从队尾淘汰所有不大于新元素的候选者。
- 把新元素的下标加入队尾。
参考代码
python
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
"""
刷到现在,我感觉确实就是两个核心问题:
- 你要维护什么?进阶的就是对于你维护的这个东西,你如何去更新(代码的核心逻辑
- 你要用什么样的数据结构去维护这组量?
自己初步思考以后,结合GPT的启发,给予以下的理解和思考:
- 你维护的量不能只是当前 / 上一个 滑动窗口的最大值,这样维护的信息太片面了,当这个最大值离开当前滑动窗口,那么新的最大值会是新进入滑动窗口的值,还是还在滑动窗口的剩余值,又需要回到遍历,这样就会有逻辑冗余
- 因此我需要维护的是所有的候选值,所有可能为 滑动窗口最大值 的那些候选值
- 更新逻辑是什么?
1. 首先是下标排除。不在滑动窗口的必须要排除
2. 当扫描到一个新的数字x,所有候选窗口(已经扫过的数字中 的可能会出现的最大值) / 维护变量中,比这个数字小的都应该被删除。因为那些数字为较小值,且会更快的离开滑动窗口,所以当x出现后的所有合法滑动窗口中,之可能是x或者比x更大的数字
"""
queue = deque() # 用一个双端队列来维护候选值的下标!!(注意这里是记录下标而不是记录具体的值. 下标关联的东西更多, 可以看到是否在滑动窗口等等,而且从下标到具体的值走一个o(1)的映射就好了),因为两侧都需要操作候选值,具体为什么是两侧见后续注释
res = []
lens = len(nums)
for right in range(lens):
# 删除已经离开滑动窗口的下标
while queue and queue[0] <= right - k:
queue.popleft()
# 找到队列中比所有x更小的值并删除.
# 这里注意一个逻辑,按照我们刚刚说的,进来一个x,删除掉左侧 / 已经遍历过的所有比x小的数字,所以这个维护的候选集必然是一个不递增的,也就是nums[queue[0]] >= nums[queue[1]] >= nums[queue[2]]. 所以这里从尾部开始遍历
while queue and nums[queue[-1]] <= nums[right]:
queue.pop()
queue.append(right) # 加入维护队列. 这里的不同点是,我自己的思维逻辑是维护旧的,处理新的,但这个代码好像是根据新数字处理旧数字,新的数字下标直接append
if right >= k - 1:
res.append(nums[queue[0]])
return res
注意状态更新那里的那个注释,我和GPT理了一下逻辑:
维护旧状态和处理新状态并不是二选一的关系,更合理的表达或者说是逻辑是:"用新的元素更新旧状态,再把新的元素加入状态"。
判断的逻辑应该是:
新元素是否能够证明某些旧元素以后永远不可能产生更优答案?
如果可以,就利用新元素去更新旧的状态(例如本题的更新就是删除 / 过滤);如果不能,就必须继续保存旧元素,可能需要堆、平衡树或完整历史记录。
而且在本题当中,新元素虽然不一定马上是最大候选值,但必须加入维护 / 队列的原因是:
- 它可能比未来的队尾候选大。
- 它的位置更新,因此比旧元素更晚过期。
在本代码中,queue这里实际保存的是下标,为了直观显示对应数值。
两段删除分别负责不同事情:
while queue and queue[0] <= right - k:
queue.popleft()
负责删除已经离开窗口的元素。
while queue and nums[queue[-1]] <= nums[right]:
queue.pop()
负责删除仍在窗口中、但已经不可能成为未来最大值的元素。
每个下标最多入队一次、出队一次,所以:
时间复杂度:O(n)
空间复杂度:O(k)