题目 239. 滑动窗口最大值
题目描述:
给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
进阶:
你能在线性时间复杂度内解决此题吗?
解题思路:
先定义一个自己的队列类(MyQueue),这个队列有一个特点,就是队列里的元素从队首到队尾是从大到小的。
-
push
方法用于将一个元素添加到队列中。这个方法在添加新元素的同时,保持了队列元素的单调性。如果待添加的元素值大于队尾的元素,那么就将队尾的元素弹出,直到待添加的元素值小于等于队尾元素的值为止,然后再将新元素添加到队列尾部。这样就保证了队列里的元素是单调从大到小的。 -
pop
方法用于将一个元素从队列中移除。这个方法在移除元素时,只有当要移除的元素值等于队首的元素值时才真正移除队首元素,否则不做任何操作。 -
front
方法用于获取队列的队首元素,由于队列元素的单调性,队首元素就是队列中的最大值。
主要解决的问题部分是 maxSlidingWindow
函数。这个函数用一个滑动窗口来遍历给定的数组,对于滑动窗口中的每一个位置,都将窗口中的元素添加到队列中,并将窗口最左侧的元素从队列中移除(如果它还在队列中的话),然后将队列的队首元素(即窗口中的最大值)添加到结果列表中。
代码:
from collections import deque
class MyQueue:
def __init__(self):
self.queue=deque()
#每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
#同时pop之前判断队列当前是否为空。
def pop(self,value):
if self.queue and value==self.queue[0]:
self.queue.popleft()
#如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
#这样就保持了队列里的数值是单调从大到小的了。
def push(self,value):
while self.queue and value > self.queue[-1]:
self.queue.pop()
self.queue.append(value)
#查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
def front(self):
return self.queue[0]
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
que = MyQueue()
result = []
for i in range(k): #先将前k的元素放进队列
que.push(nums[i])
result.append(que.front()) #result 记录前k的元素的最大值
for i in range(k, len(nums)):
que.pop(nums[i - k]) #滑动窗口移除最前面元素
que.push(nums[i]) #滑动窗口前加入最后面的元素
result.append(que.front()) #记录对应的最大值
return result
复杂度:
该算法的时间复杂度是 O(n),其中 n 是输入数组 nums
的长度。这是因为算法需要遍历数组中的每个元素,并且每个元素只会被添加到队列一次,从队列中移除一次。
尽管在 MyQueue.push
方法中存在一个看似是循环的操作(即当待添加的元素值大于队尾的元素,就将队尾的元素弹出),但由于每个元素只会被添加和移除一次,因此这个操作的总体复杂度也是 O(n)。
该算法的空间复杂度是 O(k),其中 k 是滑动窗口的大小。这是因为我们使用了一个队列来存储滑动窗口中的元素,队列的大小不会超过 k。此外,还需要一个长度为 n 的列表来存储结果。所以,总的空间复杂度是 O(n + k),但如果 n >> k,那么空间复杂度可以简化为 O(n)。
额外知识点:
deque
是 Python 的 collections
模块中的一个类,它是 "double-ended queue" 的缩写,即双端队列。
deque
和 list
在 Python 中都用于存储一系列数据,但它们之间有几个主要的区别:
-
性能:
deque
支持在两端进行插入和删除的操作,这些操作的时间复杂度都是 O(1)。而list
在列表的开始插入或删除元素时,其时间复杂度为 O(n)。因此,如果需要频繁在序列的开始或结束添加或删除元素,deque
通常比list
更有效率。 -
功能:
deque
具有一些list
没有的额外功能,例如rotate
,这可以轻松地将deque
中的元素向左或向右移动。 -
线程安全:
deque
是线程安全的,可以在多线程环境中安全地使用,而list
不是。 -
内存使用:相比之下,
deque
通常会使用更多的内存,因为它需要存储指向前一个和下一个元素的指针。
题目 347.前 K 个高频元素
题目描述:
给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
示例 1:
- 输入: nums = [1,1,1,2,2,3], k = 2
- 输出: [1,2]
示例 2:
- 输入: nums = [1], k = 1
- 输出: [1]
提示:
- 你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
- 你的算法的时间复杂度必须优于 $O(n \log n)$ , n 是数组的大小。
- 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的。
- 你可以按任意顺序返回答案。
解题思路:
-
统计元素出现频率:在这个步骤中,首先初始化一个字典
map_
。然后遍历输入的整数列表nums
,用每个数字作为键,用这个数字出现的次数作为值。字典的get
方法用于返回指定键的值,如果键不存在则返回第二个参数的默认值(这里是0),然后将其加1。 -
建立小顶堆:首先初始化一个列表
pri_que
作为小顶堆。然后遍历map_
中的每个键值对,其中键是元素,值是频率。将每个键值对作为一个元组(freq, key)
加入小顶堆。Python 的 heapq 模块提供的heappush
函数可以保证堆的性质,即堆中任意节点的值都不大于其子节点的值。 -
维持小顶堆的大小为 K:在向小顶堆中添加元素后,如果堆的大小大于 K,则需要将堆顶元素弹出,这样可以保证堆中始终保留频率最高的 K 个元素。这一步通过
heappop
函数实现。 -
输出结果:因为小顶堆的性质是堆顶元素是最小的,所以需要倒序输出堆中的元素到结果列表
result
。首先初始化一个大小为 K 的列表result
,然后用一个倒序的循环将堆顶元素依次弹出并加入result
。注意在弹出的元组中,频率是第一个元素,元素是第二个元素,所以在加入结果列表时用的是[1]
。
代码:
import heapq
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
#要统计元素出现频率
map_ = {} #nums[i]:对应出现的次数
for i in range(len(nums)):
map_[nums[i]] = map_.get(nums[i], 0) + 1
#对频率排序
#定义一个小顶堆,大小为k
pri_que = [] #小顶堆
#用固定大小为k的小顶堆,扫描所有频率的数值
for key, freq in map_.items():
heapq.heappush(pri_que, (freq, key))
if len(pri_que) > k: #如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
heapq.heappop(pri_que)
#找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组
result = [0] * k
for i in range(k-1, -1, -1):
result[i] = heapq.heappop(pri_que)[1]
return result
复杂度:
-
时间复杂度:遍历一次输入列表
nums
花费的时间复杂度是 O(n),其中 n 是nums
的长度。接着,遍历哈希表map_
和维护一个大小为 K 的堆花费的时间复杂度是 O(n log K)。因此,总时间复杂度是 O(n log K)。 -
空间复杂度:哈希表
map_
中最多会存储 n 个元素,所以哈希表的空间复杂度是 O(n)。小顶堆pri_que
的大小是固定的(K),所以其空间复杂度是 O(K)。结果列表result
的大小也是固定的(K),所以其空间复杂度也是 O(K)。因此,总空间复杂度是 O(n + K)。
知识点:
堆:
堆是一种特殊的完全二叉树,对于二叉树来说,如果其节点数为 N,那么树的深度大约是 log N 的底为2的对数(这里的 log 是以2为底的对数)。这是因为在完全二叉树中,每一层的节点数是上一层的两倍。也就是说,根节点的层数为1,第二层有2个节点,第三层有4个节点,第四层有8个节点,依此类推。如果我们有 N 个节点,那么深度就是 log N。
我们通常会说堆的深度是 log K,其中 K 是堆的元素数量。这里的 "深度" 就是表示在最坏情况下,一个新插入的元素或者被删除的元素需要经过多少层才能到达它最终应该在的位置。
假设我们有一个列表 nums = [1, 1, 1, 2, 2, 3]
,并且我们想要找到前 2 个最高频的元素,也就是 k = 2
。
首先,我们会遍历这个列表,建立一个哈希表 map_
来存储每个元素出现的频率,得到的结果是 {1: 3, 2: 2, 3: 1}
。
然后我们遍历这个哈希表,对于哈希表中的每一对键值对,我们将它加入一个小顶堆。在这个过程中,我们会保持这个小顶堆的大小始终为 2。也就是说,如果堆的大小超过了 2,我们就会将堆顶元素(即最小元素)弹出。这个过程可以用下面的表格来表示:
键值对 | 小顶堆 | 动作 |
---|---|---|
(3, 1) | [(3, 1)] | 入堆 |
(2, 2) | [(2, 2), (3, 1)] | 入堆 |
(1, 3) | [(2, 2), (3, 1)] | 入堆后立即出堆,因为堆的大小超过了2 |
注意堆中元组的顺序并不重要,只要满足堆的性质即可(即任意节点的值都不大于其子节点的值)。
最后,我们将堆中的元素按照频率从高到低的顺序输出到结果列表,得到 [1, 2]
。这个过程是通过一个倒序的循环实现的,即从 K-1 到 0,将堆顶元素弹出并加入结果列表。
以上就是这个算法的运行过程。每一次入堆和出堆的操作的时间复杂度都是 O(log K),而我们总共做了 n 次这样的操作,所以总的时间复杂度是 O(n log K)。