目录
栈与队列
1. 理论基础
1.1 栈(stack)
基本概念:栈是一种后进先出(LIFO,Last In First Out)的数据结构,意味着最后添加到栈中的元素将是第一个被移除的元素。
基本操作:
- push:向栈顶添加一个元素。
- pop:移除栈顶的元素,并返回它。
- peek 或 top:查看栈顶元素,但不移除它。
- isEmpty:检查栈是否为空。
1.2 队列(queue)
基本概念:队列是一种先进先出(FIFO,First In First Out)的数据结构,意味着最先添加到队列中的元素将是第一个被移除的元素。
基本操作:
- enqueue:在队列的末尾添加一个元素。
- dequeue:移除队列的前端元素,并返回它。
- front:查看队列前端的元素,但不移除它。
- isEmpty:检查队列是否为空。
1.3 相关概念的比较
栈的首尾:
- 栈顶(Top):栈的顶部,新元素被添加的地方,也是元素被移除的地方。在栈中,最后被添加的元素会最先被移除,这被称为后进先出(LIFO,Last In First Out)的特性。
- 栈底(Bottom):通常不用于操作,可以理解为栈的起始位置,但在实际使用中,栈底并不用于元素的添加或移除。
队列的首尾:
- 队首(Front):队列的前端,元素被移除的地方。在队列中,最先被添加的元素会最先被移除,这被称为先进先出(FIFO,First In First Out)的特性。
- 队尾(Rear or Tail):队列的后端,新元素被添加的地方。
2. 相关题目
232.用栈实现队列
题目描述: 使用栈实现队列的下列操作:
- push(x) – 将一个元素放入队列的尾部。
- pop() – 从队列首部移除元素。
- peek() – 返回队列首部的元素。
- empty() – 返回队列是否为空。
你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
思路: 用栈模拟队列的行为,需要两个栈,一个输入栈,一个输出栈。在python中,采用list模拟栈,list方法中与队列中操作的对应关系如下:
- push(x) -> 列表的.append(x):将元素 x 添加到栈的顶部
- pop() -> 列表的.pop():弹出栈的顶部元素
- empty() -> not list:检查栈是否为空
class MyQueue:
def __init__(self):
# 创建输入栈和输出栈,in主要负责push,out主要负责pop
self.stack_in = []
self.stack_out = []
def push(self, x: int) -> None:
# 新元素进来,就往in里面append
self.stack_in.append(x)
def pop(self) -> int:
# 弹出元素需要从out里面弹出
# 如果out里面没有元素,先要将所有元素从in里面转移到out里
if self.empty(): # 先判断是否为空
return None
if self.stack_out:
return self.stack_out.pop()
else:
for i in range(len(self.stack_in)):# 将in中的元素全部转移到out里
self.stack_out.append(self.stack_in.pop())
return self.stack_out.pop()
def peek(self) -> int: # 查看栈顶元素的操作,但不移除它
ans = self.pop()
self.stack_out.append(ans) # 将弹出的队首元素重新压入栈内(相当于队首)
return ans
def empty(self) -> bool: # 只要in或者out有元素,说明队列不为空
return not (self.stack_in or self.stack_out)
# Your MyQueue object will be instantiated and called as such:
# obj = MyQueue()
# obj.push(x)
# param_2 = obj.pop()
# param_3 = obj.peek()
# param_4 = obj.empty()
225. 用队列实现栈
题目描述: 使用队列实现栈的下列操作:
- push(x) – 元素 x 入栈
- pop() – 移除栈顶元素
- top() – 获取栈顶元素
- empty() – 返回栈是否为空
思路: 队列模拟栈,用一个队列就够了。队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。在python中,使用双向队列deque()
来模拟队列,采用其popleft()和append()方法,且deque可以用索引访问。
from collections import deque
class MyStack:
def __init__(self):
self.que = deque()
def push(self, x: int) -> None: # 直接append
self.que.append(x) # 直接在队尾进行添加
def pop(self) -> int:
if self.empty(): # 首先确定不空
return None
for i in range(len(self.que)-1): # 将元素从队首取出,在添加到队尾,循环len(self.que)-1次后弹出下一个元素
self.que.append(self.que.popleft())
return self.que.popleft()
def top(self) -> int: # 和pop不同的是,top不会弹出这个元素,而是只显示
if self.empty():
return None
for i in range(len(self.que)-1):
self.que.append(self.que.popleft())
temp = self.que.popleft()
self.que.append(temp)
return temp
def empty(self) -> bool:
return not self.que
# Your MyStack object will be instantiated and called as such:
# obj = MyStack()
# obj.push(x)
# param_2 = obj.pop()
# param_3 = obj.top()
# param_4 = obj.empty()
20. 有效的括号
题目描述: 给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串,判断字符串是否有效。有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 注意空字符串可被认为是有效字符串。
思路: 实际上就是判断对于给定的某个左括号,是否有与之相配的右括号。这里关于正确匹配有两点要注意的:(1)匹配性:每个左括号(包括 (, {, [)必须与相同类型的右括号(包括 ), }, ])闭合。(2)顺序性:左括号必须按照正确的顺序闭合,即不能出现一个右括号在它的匹配左括号之前。
而对于不匹配的情况,包含以下几种情况:
(1)括号数目不对:左括号数 != 右括号数
(2)括号匹配错误:不是按照正确的括号进行匹配,如 【[}】。那么我们如何判断左右括号是否匹配呢?可以借用栈,每次遇到左括号【 [{(】,就将相应的右括号压入栈【 ]})】,直到遇到第一个右括号【)】,将右括号与栈顶的元素进行比较【)】,如果匹配,则将栈内的右括号弹出(pop),如果不匹配,则返回false。如果栈已经空了,但仍然碰到了新的括号,说明左右括号数目不匹配,也返回false。
class Solution:
def isValid(self, s: str) -> bool:
stack = []
for item in s:
if item == '(':
stack.append(')')
elif item == '{':
stack.append('}')
elif item == '[':
stack.append(']')
elif not stack or stack[-1] != item: # 这里采用列表的[-1]检查栈顶元素
return False
else:
stack.pop()
return True if not stack else False
1047. 删除字符串中的所有相邻重复项
题目描述: 给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。在 S 上反复执行重复项删除操作,直到无法继续删除。在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
思路: 和上一题“有小括号很类似”,这里的不同的字母等效于上述的括号类型。
方法一:使用栈
class Solution:
def removeDuplicates(self, s: str) -> str:
res = list() #创建一个列表,用来模拟栈
for item in s: #遍历s中的字符
if res and res[-1] == item: # 如果该列表不为空,则弹出栈顶元素
res.pop()
else:
res.append(item)
return ''.join(res)
方法二:采用快慢指针
class Solution:
def removeDuplicates(self, s: str) -> str:
fast = slow = 0
res = list(s)
while fast < len(res):
# 赋值操作不能放在后面,因为fast+1后可能=len(res),会溢出
res[slow] = res[fast]
# 一定不要漏掉边界值slow > 0
if slow > 0 and res[slow] == res[slow-1]:
slow -= 1
else:
slow += 1
fast += 1
return ''.join(res[0:slow])
150. 逆波兰表达式求值
题目描述: 根据 逆波兰表示法,求表达式的值。有效的运算符包括 + , - , * , / 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
说明:整数除法只保留整数部分。 给定逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。
思路: 逆波兰表达式是一种后缀表达式,所谓后缀就是指运算符写在后面。平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * ) 。
- 逆波兰表达式有以下两个优点:
- 去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
- 适合用栈操作运算:遇到数字则入栈;遇到运算符则取出栈顶两个数字进行计算,并将结果压入栈中。
- 代码随想录中引入了operator库【from operator import add, sub, mul】,我在不采用额外库函数的情况下实现代码。还需要注意一点,在弹出两个栈顶的元素时,是遵从先进后出的规则,比如对于栈中[1,2]两个元素进行相减的操作,需要用后出栈的元素减去先出栈的元素。
- 关于除法有点要注意的地方,按照提议,逆波兰表达式中的除法取两者商的整数部分,这其实是一种向零取整的做法。而python中的
//
是一种向-∞取整的做法,所以需要区分复数除法和非负数除法。
class Solution:
def evalRPN(self, tokens):
stack = []
for token in tokens:
if token in {'+', '-', '*', '/'}:
op2 = stack.pop()
op1 = stack.pop()
if token == '+': # 弹出两个栈顶的元素进行加法运算
stack.append(op1 + op2)
elif token == '-': # 应该用后弹出的减去先弹出的
stack.append(op1 - op2)
elif token == '*':
stack.append(op1 * op2)
elif token == '/': # 注意处理除数为0的情况
if op2 == 0:
raise ValueError('division by zero')
else:
if op1 * op2 >= 0:
stack.append( op1 // op2) # 使用整数除法
else:
stack.append( - abs(op1 //abs(op2)))
else: # 如果是数字,直接压栈
stack.append(int(token)) # 记得转换为int在压入栈。
return stack.pop()
# 创建 Solution 类的实例
solution = Solution()
# 准备测试用例的 tokens
tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
# 调用 evalRPN 方法并传入 tokens
result = solution.evalRPN(tokens)
# 打印结果
print("The result of the RPN expression is:", result)
239. 滑动窗口最大值
题目描述: 给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
- 进阶:你能在线性时间复杂度内解决此题吗?
思路: 这里采用一种单调队列的方法,它是一种特殊类型的队列数据结构,用于维护一个序列的滑动窗口的特定性质,最常见的是单调性。具有以下特点:
(1)动态维护:单调队列可以在窗口滑动时动态维护其单调性。
(2)快速访问:在单调递减队列中,最大元素始终位于队列的前端;在单调递增队列中,最小元素始终位于队列的前端。
(3)高效操作:入队、出队和访问极值的操作都可以在 O(1) 时间内完成。
- 实现:单调队列通常使用双端队列(deque)实现,因为双端队列允许在两端快速地添加和删除元素。
- 根据题意,我们要求出滑动窗口中的最大值,实际上就是要维护单调队列中的最大值。但不意味着碰到比最大值小的值都丢弃不用,而是确保它的单调性。单调队列在这里实际上用作了一个“缓冲区”,它确保了在任何时候,窗口的最大值都可以快速访问,并且随着窗口的滑动,最大值可以被快速更新。
class MyQueue: # 单调队列
def __init__(self):
self.queue = deque() # 采用双向队列实现单调队列,直接使用list会超时?也不太方便
# 每次弹出时,要比较当前要弹出的值是否等于队列出口的元素,如果相等,则弹出;
# 否则说明要弹出的数并非此前滑动窗口中的最大值
# 同时pop之前要判断队列当前是否为空
def pop(self, value):
if self.queue and value == self.queue[0]:
self.queue.popleft() # list.pop()时间复杂度为O(n),而deque中的popleft()复杂度为O(n)
# 如果push的值大于队列末尾的数值,则将队列后端的值弹出,直到该value小于等于当前队列末尾的数值
# 这样确保队列里的数值是单调从大到小的
def push(self,value):
while self.queue and value > self.queue[-1]:
self.queue.pop() # 弹出右端的元素
self.queue.append(value)
# 查询当前队列中的最大值,直接返回队列前段也就是fromt即可
def front(self):
return self.queue[0]
class Solution:
def maxSlidingWindow(self,nums,k):
que = MyQueue() # 创建一个队列
result = [] # 记录每个滑动窗口的最大值
# 先将初始滑动窗口的前k个元素放进队列
for i in range(k):
que.push(nums[i])
result.append(que.front())
for i in range(k, len(nums)):
que.pop(nums[i-k]) # 移除滑动窗口最前端的元素,调用pop函数改变单调队列
que.push(nums[i]) # 加入新的的元素,调用push函数改变单调队列
result.append(que.front()) # 记录最大值
return result
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
# 创建队列
que = MyQueue()
result = []
# 将前k个元素放进队列
for i in range(k):
que.push(nums[i])
result.append(que.front())
for i in range(k, len(nums)):
que.pop(nums[i-k]) # 移除窗口最前面元素
que.push(nums[i]) # 加入新元素
result.append(que.front())
return result
347.前 K 个高频元素
题目描述: 给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
思路: 我们可以用哈希表来统计每个数字出现的频率,如何统计频率前k高的元素,可以采用优先级队列的思想。
优先级队列: 一种按照元素的优先级(权值)来排序的特殊队列。在很多编程语言中,优先级队列通常是基于堆数据结构实现的。堆是一种特殊的完全二叉树,其中每个节点的值都必须大于或等于其子节点的值(大顶堆),或者小于或等于其子节点的值(小顶堆)。
- 使用大顶堆还是小顶堆?
- 如果使用大顶堆,堆顶始终是当前最大元素,这会导致我们无法保留前 K 高频元素。使用小顶堆,我们可以逐步移除频率最小的元素,最终保留下来的是频率最高的 K 个元素。
- 为什么不采用快速排序?
- 快速排序(快排)需要将整个数组排序,这将导致时间复杂度为 O(n log n)。
- 使用优先级队列,特别是小顶堆,可以在 O(n log k) 的时间复杂度内解决问题,这在 k 远小于 n 时更优。
算法逻辑:
- 首先,遍历数组并使用哈希表统计每个元素的出现次数。
- 将哈希表中的元素和它们的频率转换为一个 vector,并根据频率对它们进行排序。
- 使用小顶堆,将 vector 中的元素依次推入堆中,如果堆的大小超过 K,就弹出堆中频率最小的元素(即堆顶元素)。
- 最后,堆中的元素即为出现频率前 K 高的元素。
import heapq # 导入python的堆排序模块
class Solution:
def topKFrequent(self, nums, k):
map1 = {} # 创建一个字典,存储每个数字出现的次数
for i in range(len(nums)):
# 如果字典 map_ 中已经存在键 nums[i],则取出其对应的值,加一,然后将结果重新赋值给 map_[nums[i]]。
# 如果字典 map_ 中不存在键 nums[i],则使用默认值 0,加一,然后将结果 1 赋值给 map_[nums[i]]。
map1[nums[i]] = map1.get(nums[i], 0 ) + 1
# 对频率排序,定义一个小顶堆,大小为k
pri_que = [] # 小顶堆
# 用固定大小为k的小顶堆,扫描所有频率的数值
for key,freq in map1.items(): # item是字典对象的一个方法,它返回一个包含所有(键,值)对的视图对象
heapq.heappush(pri_que, (freq, key))# 将频率和元素组成的元组推入堆中,保持堆的性质
if len(pri_que) > k:
heapq.heappop(pri_que)
# 找出前k个高频元素,因为小顶堆最先弹出的是最小的,所以倒序输出到数组
result = [0]*k
for i in range (k-1 , -1, -1): # 逆序遍历;或者可以正序遍历,再采用result[::-1]
result[i] = heapq.heappop(pri_que)[1] # 注意弹出的是(freq,key)元组,我们要取这个元组的第二个值,故用索引[1]
return result
nums = list(map(int, input().split()))
k = int(input())
sol = Solution()
result = sol.topKFrequent(nums,k)
print(result)
总结
- 栈是一种数据结构,它的操作受限于两个基本动作:压栈(push)和弹栈(pop)。压栈是将元素添加到栈顶,而弹栈是从栈顶移除元素。这遵循了后进先出的原则。
- 栈和递归是可以互换的。原因在于他们都遵循后进先出(LIFO,Last In First Out)的原则。递归是一种编程技术,其中函数调用自身。在递归调用中,最后一个被调用的函数会是第一个完成执行的,这同样遵循了后进先出的原则。在递归中,每次函数调用都会创建一个新的执行上下文,包括参数、局部变量等。这些上下文可以想象成被“压入”到一个隐式的调用栈中。当递归到达基本情况(base case)时,函数开始返回,每个函数调用的上下文被逐个“弹出”,直到最初的调用完成。