提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
力扣题目连接347. 前k个高频元素
这道题目主要涉及到如下三块内容:
1.要统计元素出现频率
2.对频率排序
3.找出前K个高频元素
首先统计元素出现的频率,这一类的问题可以使用map来进行统计。
然后是对频率进行排序,这里我们可以使用一种 容器适配器就是 优先级队列。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题我们就要使用优先级队列来对部分频率进行排序。
为什么不用快排呢, 使用快排要将map转换为vector的结构,然后对整个数组进行排序, 而这种场景下,我们其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。
使用快排的时间复杂度是O(nlogn), 但是使用堆去找前k个频率的复杂度是O(nlogk); 当k比较小的时候,还是能快一些的
是使用小顶堆呢,还是大顶堆?
有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。
那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。
而且使用大顶堆就要把所有元素都进行排序,那能不能只排序k个元素呢?
所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
寻找前k个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为3的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)
把用法介绍的文档链接给在这里了heapq库用法介绍
heapq模块可以接受元组对象,默认元组的第一个元素作为priority,即按照元组的第一个元素构成 小根堆,若第一个元素是原先的负数,则可以利用元组构造大顶堆,符合一般的升序需求
heappush( Q , tuple )
快排,堆排,归并… 排序算法的坑我还要补一下
import heapq
array = [10, 17, 50, 7, 30, 24, 27, 45, 15, 5, 36, 21]
heap = []
for num in array:
heapq.heappush(heap, num)
print("array:", array)
print("heap: ", heap)
heapq.heapify(array)
print("array:", array)
可以用heapq.heappush(堆的名字,元素tuple) 一个个push值去创建一个堆
可以用heapq.heappop(堆的名字) 去把堆顶最小值给pop出来
再给一些heapq的操作吧:
import heapq
# (1)创建一个空堆,并加入数据
heap = []
for item in [2, 3, 1, 4]:
heapq.heappush(heap, item)
print heap # 输出 [1, 3, 2, 4]
# (2)根据链表构建一个堆 --> heapify
l = [2, 3, 1, 4]
heapq.heapify(l)
print l # 输出 [1, 3, 2, 4]
# (2)向堆中追加元素 -->heappush
heapq.heappush(l, -10)
print l # 输出 [-10, 1, 2, 4, 3]
# 另外heapq的元素可以是元组,元组的每个元素都可以比较大小,具体比较大小,是按照元组元素的先后次序进行比较
# 例如:heapq.heappush(x, (1, 'sd'))
# (3) 弹出堆头(返回堆头之后堆再进行翻转,堆头保持最小值) -->heappop
print heapq.heappop(l) # 输出 -10
print l # 输出 [1, 3, 2, 4]
print heapq.heappop(l) # 输出 1
print l # 输出 [2, 3, 4]
# (4) 替换第一个元素,并构建堆 --> heapreplace
l = [2, 3, 1, 4]
print heapq.heapreplace(l, 100) # 输出 2
print l # 输出 [1, 3, 100, 4]
# (5)合并多个链表 --> merge
l = [1, 3, 2]
l2 = [5, 2, 3]
l3 = [9, 2, 3, 1]
print list(heapq.merge(l, l2, l3)) # 输出 [1, 3, 2, 5, 2, 3, 9, 2, 3, 1]
# (6)多路归并 --> merge
# 对每一个链表进行排序,再对排序后的列表进行合并
print list(heapq.merge(sorted(l), sorted(l2), sorted(l3)))
# (7)返回最大的元素 --> nlargest
l = [2, 3, 1, 4]
print heapq.nlargest(2, l) # 输出 [4, 3]
# (8)返回最小的元素 --> nsmallest
l = [2, 3, 1, 4]
print heapq.nsmallest(2, l) # 输出 [1, 2]
# (9)向堆中追加一个数据,再弹出堆头(弹出后堆不会发生翻转) --> heappushpop
l = [2, 3, 1, 4]
print heapq.heappushpop(l, -10) # 输出 -10
print l # 输出 [2, 3, 1, 4]
那么如何用heapq创建一个大顶堆呢?
就是把元组的第一个元素priority变成相反的负数,那这个元组去构建堆,最后pop出来的堆顶节点tuple的tuple[1], 就是最大的值
from heapq import *
def FindMaxProfit(profits, key=lambda x: -x):
maxHeap1 = []
for i in range(len(profits)):
heappush(maxHeap1, (-profits[i], profits[i])) # 大顶堆
# heappush(maxHeap1, profits[i]) # 默认小顶堆
return heappop(maxHeap1)
profits = [3, 2, 4, 9]
print(FindMaxProfit(profits)) # (-9, 9) 最大值是元组的第二个元素 9
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
nums_dict = collections.Counter(nums)
heap = []
for key, freq in nums_dict.items():
heapq.heappush(heap, (freq,key))
if len(heap) > k:
heapq.heappop(heap) #小顶堆 heappop出的是小的,剩下的是排好序的大的
res = [0] * k
#找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来pop到数组里,
for i in range(k-1, -1, -1):
res[i] = heapq.heappop(heap)[1] #因为heap里装的是元组,[0]是频率, [1]是元素本身,res要装topk的元素
return res
我们习惯看到的表达式都是中缀表达式,因为符合我们的习惯,但是中缀表达式对于计算机来说就不是很友好了。
例如:4 + 13 / 5,这就是中缀表达式,计算机从左到右去扫描的话,扫到13,还要判断13后面是什么运算法,还要比较一下优先级,然后13还和后面的5做运算,做完运算之后,还要向前回退到 4 的位置,继续做加法,你说麻不麻烦!
那么将中缀表达式,转化为后缀表达式之后:[“4”, “13”, “5”, “/”, “+”] ,就不一样了,计算机可以利用栈里顺序处理,不需要考虑优先级了。也不用回退了, 所以后缀表达式对计算机来说是非常友好的。
可以说本题不仅仅是一道好题,也展现出计算机的思考方式。
在1970年代和1980年代,惠普在其所有台式和手持式计算器中都使用了RPN(后缀表达式),直到2020年代仍在某些模型中使用了RPN。
这是这道题的力扣链接150.逆波兰表达式求值
在代码随想录上一篇文章中在字符串中删除相邻重复项中提到了 递归就是用栈来实现的,所以栈与递归之间在某种程度上是可以转换的! 这一点我们在后续讲解二叉树的时候,会更详细的讲解到。
下面两道也是经典的用栈解决的匹配问题,可以放在一起去总结
20.有效的括号
1047.删除字符串中相邻重复项
其实逆波兰表达式相当于是二叉树的后序遍历。 大家可以把运算符作为中间节点,按照后序遍历的规则画出一个二叉树。
但我们没有必要从二叉树的角度去解决这个问题,只要知道逆波兰表达式是用后续遍历的方式把二叉树序列化了,就可以了。
在进一步看,本题中每一个子表达式要得出一个结果,然后拿这个结果再进行运算,那么这岂不就是一个相邻字符串消除的过程,和1047.删除字符串中的所有相邻重复项中的对对碰游戏是不是就非常像了。
总结,思路就是:
def evalRPN(self, tokens: List[str]) -> int:
stack = []
for i in range(len(tokens)):
if tokens[i] in ['+','-','*','/']:
num1, num2 = int(stack.pop()), int(stack.pop()) #进栈1 2 出栈 2 1
if tokens[i] == '+':
stack.append(num2+num1)
elif tokens[i] == '-':
stack.append(num2-num1)
elif tokens[i] == '*':
stack.append(num2*num1)
else:
stack.append(num2/num1)
else:
stack.append(tokens[i])
return int(stack.pop())
力扣题目链接239.滑动窗口最大值
遇到这道题我很快就使用了经典滑动窗口的写法:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
l, r = 0, 0
win = deque()
res = []
for r in range(n):
win.append(nums[r])
while l <= r and len(win) == k:
res.append(max(win))
win.popleft()
l += 1
return res
遍历一遍的过程中每次从窗口中在找到最大的数值,其实我觉得这个写法不是O(nk),我倾向于O(nlogk), 但是反正确实不是线性的复杂度,这样写是会超时的。因为本题的k是 1到10的5次方的
所以确实,难点是如何求一个区间里的最大值。
有的同学可能会想用一个大顶堆(优先级队列)来存放这个窗口里的k个数字,这样就可以知道最大的最大值是多少了, 但是问题是这个窗口是移动的,而大顶堆每次只能弹出最大值,我们无法移除其他数值,这样就造成大顶堆维护的不是滑动窗口里面的数值了。所以不能用大顶堆。
此时我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。
**这个队列应该长这个样子:**每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口添加元素的数值),然后que.front()就返回我们要的最大值。
分析一下,队列里的元素一定是要排序的,而且要最大值放在出队口,要不然怎么知道最大值呢。
但如果把窗口里的元素都放进队列里,窗口移动的时候,队列需要弹出元素。
那么问题来了,已经排序之后的队列 怎么能把窗口要移除的元素(这个元素可不一定是最大值)弹出呢?
其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队里里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列
不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。来看一下单调队列如何维护队列里的元素:
对于窗口里的元素{2, 3, 5, 1 ,4},单调队列里只维护{5, 4} 就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。
此时大家应该怀疑单调队列里维护着{5, 4} 怎么配合窗口经行滑动呢?
设计单调队列的时候,pop,和push操作要保持如下规则:
1.pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
2.push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
为了更直观的感受到单调队列的工作过程,以题目示例为例,输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,动画如下:
那么我们用什么数据结构来实现这个单调队列呢?使用deque最为合适,在文章栈和队列的基础知识中,我们就提到了常用的queue在没有指定容器的情况下,deque就是默认底层容器。
from collections import deque
class myqueue:
def __init__(self):
self.queue = deque()
def pop(self, val):
if self.queue and val == self.queue[0]:
self.queue.popleft()
def push(self, val):
while self.queue and val > self.queue[-1]:
self.queue.pop()
self.queue.append(val)
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
Python中的标准库collections中有一个deque,该对象与list列表相似。这里的“双向”指的是deuqe的结构使用双向链表,它提供了两端都可以操作的序列,这意味着,我们可以在序列前后都执行添加或删除。大多操作与List相同,如访问元素,求序列长度等,同样deque序列中的元素类型也不唯一。参考python中的双向队列deque
相比于list实现的队列,deque实现拥有更低的时间和空间复杂度。
list实现出队(pop)和插入(insert)时的空间复杂度大约为O(n),
deque在出队(pop)和入队(append)时的时间复杂度是O(1)。
这是因为:列表实现是基于数组的。pop(0)从列表中删除第一个项,它需要左移len(lst) - 1个项来填补空白。
deque()实现使用双向链表。因此无论deque有多大,deque.popleft()都需要一个常量的操作数。
from collections import deque
queue = deque()
#deque提供了类似list的操作方法:
#增删
queue.append(1)
queue.append(2)
queue.append(3)
print(queue) #deque([1, 2, 3])
queue.appendleft(0)
print(queue) #deque([0, 1, 2, 3])
a = queue.popleft() #从队首(左边) pop
b = queue.pop() #从队尾(右边) pop
print(a,b) # 0 3
print(queue) #deque([1, 2])
#在指定位置增删
queue.insert(2,3) #.insert(location, value)
print(queue) #deque([1, 2, 3])
queue.remove(3) #.remove(val)
print(queue) #deque([1, 2])
栈和队列是什么
可以出一道面试题:栈里面的元素在内存中是连续分布的么?
这个问题有两个陷阱:
陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中是不是连续分布。
陷阱2:缺省情况下,默认底层容器是deque,那么**deque的在内存中的数据分布是什么样的呢? 答案是:不连续的,**下文也会提到deque。
所以这就是考察候选者基础知识扎不扎实的好问题。
栈和队列的基础我在之前的打卡博客有总结,代码随想录里的总结是栈和队列理论基础
里面提到了灵魂四问:
1.C++中stack,queue 是容器么?
2.我们使用的stack,queue是属于那个版本的STL?
3.我们使用的STL中stack,queue是如何实现的?
4.stack,queue 提供迭代器来遍历空间么?
栈和队列的基本操作的题:1.栈实现队列 2. 队列实现栈
71.简化路径
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,就是使用了栈这种数据结构。
再举个例子,linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。
cd a/b/c/…/…/
这个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用。这在leetcode上也是一道题目,编号:71. 简化路径,大家有空可以做一下。
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
所以栈在计算机领域中应用是非常广泛的。
有的同学经常会想学的这些数据结构有什么用,也开发不了什么软件,大多数同学说的软件应该都是可视化的软件例如APP、网站之类的,那都是非常上层的应用了,底层很多功能的实现都是基础的数据结构和算法。
所以数据结构与算法的应用往往隐藏在我们看不到的地方!
20.有效的括号
1047. 删除字符串中的所有相邻重复项
150. 逆波兰表达式求值
239. 滑动窗口最大值
这道题目还是比较绕的,如果第一次遇到这种题目,需要反复琢磨琢磨
主要思想是队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来一个单调队列
而且不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
设计单调队列的时候,pop,和push操作要保持如下规则:
pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
push(value):如果push的元素value大于入口元素的数值,那么就将队列出口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
一些同学还会对单调队列都有一些困惑,首先要明确的是,题解中单调队列里的pop和push接口,仅适用于本题。
单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。
不要以为本地中的单调队列实现就是固定的写法。
我们用deque作为单调队列的底层数据结构,C++中deque是stack和queue默认的底层实现容器(这个我们之前已经讲过),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。
求前 K 个高频元素
小顶堆的应用
通过求前 K 个高频元素,引出另一种队列就是优先级队列。
什么是优先级队列呢?
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题就要使用优先级队列来对部分频率进行排序。 注意这里是对部分数据进行排序而不需要对所有数据排序!
所以排序的过程的时间复杂度是 O ( log k ) O(\log k) O(logk),整个算法的时间复杂度是 O ( n log k ) O(n\log k) O(nlogk)。