本文共 4547 字,大约阅读时间需要 15 分钟。
本题的标签是Heap
,Sliding Window
和Dequeue
,是滑动窗口和双端队列结合的一道经典题。本文将着重讲解双端队列解法,同时对动态规划的解法进行简单的讲解。
注意:文章中一切注解皆为Python代码
题目非常简单。给定一个数列,和一个窗口,在窗口从左向右滑动的时候,持续计算窗口中子数列的最大值。
最暴力的解法就是在窗口滑动的过程中持续计算最大值,代码就一行,如下
class Solution: def maxSlidingWindow(self, nums: 'List[int]', k: 'int') -> 'List[int]': return [max(nums[i-k:i]) for i in range(k, len(nums)+1)]
很明显,暴力解法需要的时间复杂度是O(NK)
,其中N
是数据规模,K
是窗口大小。如果窗口大小K
等于N/2
的话,时间复杂度可以表示为O(N^2)
。对于题目中输入范围为105 这种级别,显然O(N^2)
这种时间复杂度是不行的,因此需要探索其他解法。
想到更优的解法其实并不难,首先我们要意识到这题的两个操作,一是遇到新数字时对当前最大值的更新,二是淘汰过期数字时对当前最大值的更新。
对于第一种情况,我们都知道一个前缀数组(prefix array
)就可以解决。那么如何去淘汰过期的数字,并更新最大值呢?显然单纯的前缀数组不好使了,怎么办:
queue
),先进先出那什么样的数据结构可以高效的提供这样(内容不断变化且能保持一定顺序)的信息呢?
heap
)就可以这里的堆(heap
)很好理解,堆的加入和删除都仅仅耗费O(logK)
,并且,它时时刻刻能保证顺序;最终的时间复杂度可以近似为O(NlogN)
。
那么如何去理解单调递减的双端队列呢?
双端队列本身不用多讲,先进先出,两端都可以以O(1)
的时间加入和删除;对于单调递减,我们事实上是为了维护一种单调递减的顺序,也就是之前我提到的
淘汰“过期”数字后,需要选出第二大的数字,依次类推,可能还需要第三、第四、第K大的数字
这本质上是一种单调递减的状态,看个例子:
给出数列[9,5,7,6,11]
,窗口长度为3
,起初单调递减队列dq
为空,指针i = 0
i = 0, dq = [9], 当前max = 9, 窗口中数列为[9], 滑动最大值还未开始计算,因为窗口内数组不满3个
i = 1, dq = [9, 5], 当前max = 9, 窗口中数列为[9, 5], 滑动最大值还未开始计算,因为窗口内数组不满3个
i = 2, dq = [9, 7], 当前max = 9, 窗口中数列为[9, 5, 7], 滑动最大值[9]
; i = 3, dq = [7, 6], 当前max = 7, 窗口中数列为[5, 7, 6], 滑动最大值[9, 7]
i = 4, dq = [11], 当前max = 11, 窗口中数列为[7, 6, 11], 滑动最大值[9, 7, 11]
[9, 7, 11]
我想上面的过程你可能已经理解了,但是这里再强调一下,这题“巧妙”的部分在于,把单调性和双端队列结合起来了。一般算法题中更为常见的是,单调性和栈的结合,即单调栈(Monotonic Stack
),比如另一道经典题。
人们对于没有见过的事物总是会感到恐惧,学生对于没有练习过的题目总会感到无从下手,这大概就是本题最优解的难点所在吧。
class Solution: def maxSlidingWindow(self, nums: 'List[int]', k: 'int') -> 'List[int]': ans, heap, n = [], [], len(nums) for i in range(n): heapq.heappush(heap, (-nums[i], i)) # 在heap中加入一个tuple(值,指针) while heap and heap[0][1] + k <= i: # 如果值对应的指针过期的话, heapq.heappop(heap) # 就heappop直到找到没有过期的为止 if i >= k-1: # 一旦窗口内元素达到K个,就开始计算滑动最大值 ans.append(-heap[0][0]) # 值是按负数存进去的,模拟的是最大堆maxheap return ans
因为每个数字最多被访问两次,所以最终的时间复杂度是O(N)
,空间复杂度是O(N-K+1)
class Solution: def maxSlidingWindow(self, nums: 'List[int]', k: 'int') -> 'List[int]': dq, ans, n = collections.deque(), [], len(nums) for i in range(n): while dq and nums[i] > dq[-1]: # 在加入新数字前,弹出比当前值小的数字, dq.pop() # 以确保加入后的单调递减性 else: dq.append(nums[i]) # 上面的循环完成后,加入新数字 if i >= k and nums[i-k] == dq[0]: # 如果当前要被淘汰的数字nums[i-k]是双端队列的左端点, dq.popleft() # 即说明,当前最大值过期要被淘汰掉,从左侧弹出队列 if i >= k-1: # 一旦窗口内元素达到K个,就开始计算滑动最大值, ans.append(dq[0]) # 最大值永远是单点递减双端队列的左端点 return ans
本题的最优解是单调双端队列的解法,但是为了完整性,这里提一下同样巧妙的动态规划解法。
之前提到过前缀数组可以很好的计算当前最大值,但是没法帮助我们处理淘汰数字后的情况;动态规划的解法用到了一左一右,两个前缀数组,以及数学上的一种巧思。
设问我们要怎么知道给定一小段窗口中的最大最小值呢?显然你可以暴力算一遍,这不是我想讲的。提示一下,给一个从左到右的最大前缀数组,和一个从右到左的最大前缀数组,可以算出来吗?
好像不行,因为无论从左到右还是从右到左,都有可能包含已经过期的值。那进一步去想,如何在此基础上处理过期数据呢?有什么方法可以让前缀数组包含是否过期的信息呢?
答案就是分段。把前缀数组按照每K
个一组去分段,每一段从新开始计算最大值,这样一来:
K
个数据K
个数据LeetCode的官方解法说的很好,这里就不多解释了,总之:两个前缀分段数组。
解法的实现上我稍微优化了一下,看起来更直截了当class Solution: def maxSlidingWindow(self, nums: 'List[int]', k: 'int') -> 'List[int]': n = len(nums) left, right = [0] * n, [0] * n left[0], right[n-1] = nums[0], nums[n-1] for i in range(1, n): if i % k == 0: # 计算左前缀数列 left[i] = nums[i] else: left[i] = max(left[i - 1], nums[i]) j = n - i - 1 if (j + 1) % k == 0: # 计算右前缀数列 right[j] = nums[j] else: right[j] = max(right[j + 1], nums[j]) # 比较两者取最大值 return [max(left[i + k - 1], right[i]) for i in range(n - k + 1)]
我认为这是一道好题,一部分原因是因为双端队列的题目比较稀有,此外它还把单调性和双端队列结合起来;其中也还融合了,滑动窗口、堆、动态规划等等;不愧为一道经典Hard
题。
如果有接触过单调栈的同学可能会更容易想到这题的最优解,没有接触过的同学如果努力思考,至少可以做到堆的实现。
就像我之前说的,人们对于没有见过的事物总是会感到恐惧,学生对于没有练习过的题目总会感到无从下手,这大概就是本题最优解的难点所在吧。
至于能轻松解开这些问题的人们,其中真正的“天才”也是凤毛麟角,大多数无非是接触过类似的问题,或者干脆就是复习、默写了一遍,大道至简:
无他,但手熟尔
还在练手的人们请谨记这一点,并持之以恒。
转载地址:http://mqgoi.baihongyu.com/