现在来介绍一下栈,栈就是一种数据结构,是之前我之前所提到的线性表的一种,特点是后进先出的原则,什么是后进先出呢,意思就是你后放入的元素是最先出来的,接下来就是栈的结构,栈分为栈顶和栈底,这里面找个图片演示一下,如下图就明显很多了。
栈基本操作
压栈(Push):将元素加到栈顶。
弹栈(Pop):拿出并返回栈顶的元素。
查看栈顶(Peek/Top):获取栈顶的元素,但不拿出它。
判断是否为空(IsEmpty):检查栈是否为空
这里我就没什么好解释的了和他们挂钩的就是时间和空间复杂度,这里我不多解释,主要
时间复杂度
Push 操作:O(1) Pop 操作: O(1)
Peek/Top 操作: O(1) IsEmpty 操作: O(1)
空间复杂度:
数组实现:O(n),其中 n 是栈的大小。空间复杂度取决于数组的大小。
链表实现:O(n),其中 n 是栈中元素的数量。每个元素除了存储数据,还需要额外的空间存储指针。ps:意思就是一根绳子除了结点代表的元素要占地方,结点本身作为一个门牌号也得占地方。
这里我要解释以下两种实现方式怎么回事,栈咱们不是说是一种特殊的线性表嘛,而线性表又分为数组和链表,所以说栈有两种实现方式。
栈实现方式
数组实现
结构:用固定大小的数组来存储栈的元素。(就说给一个固定的地方放东西)
优点:操作简单且速度快,因为直接通过数组索引访问元素
缺点:栈的大小在创建时就被固定,扩展不方便。(台阶大小尺寸都已经订好了,上一节提过)
操作:Push
:将元素放入数组的当前末尾,更新栈顶指针。(就把新元素放栈顶)
Pop
:移除并返回数组末尾的元素,更新栈顶指针(就是把顶上的元素弹到里一个新的栈里面,然后那个指针回到原来的栈,注意那个指针回去后的栈顶变了,为啥变了,就是因为刚才弹出去一个元素,所以得更新一下地址)
Peek
:返回数组末尾的元素,但不改变栈顶指针。(就是我得到这个元素的信息但不把它带走)
IsEmpty
:检查栈顶指针是否指向数组的起始位置
链表实现:
结构:用链表来存储栈的元素,每个节点包含数据和指向下一个节点的指针。
优点:栈的大小动态扩展,方便处理不确定数量的元素。(毋庸置疑)
缺点:需要额外的内存来存储指针,操作相对较慢。
操作:
Push
:创建一个新节点,将其放在链表的头部。
Pop
:移除并返回链表头部的节点,更新链表头指针。
Peek
:返回链表头部节点的值,但不改变链表。
IsEmpty
:检查链表头指针是否为空
下面我用一段python代码来演示一下
1.初始化栈(看个流程就行):
class Stack:
def __init__(self, size):
self.stack = [None] * size # 创建一个固定大小的数组
self.top = -1 # 栈顶指针初始化为-1,表示栈为空
2.压栈(Push)
def push(self, value):
if self.top == len(self.stack) - 1: # 检查栈是否已满
raise IndexError("Push to a full stack")
self.top += 1 # 移动栈顶指针
self.stack[self.top] = value # 在栈顶位置插入新元素
3.弹栈(Pop)
def pop(self):
if self.top == -1: # 检查栈是否为空
raise IndexError("Pop from an empty stack")
value = self.stack[self.top] # 获取栈顶元素
self.top -= 1 # 移动栈顶指针
return value # 返回移除的元素
4.查看栈顶(Peek/Top)
def peek(self):
if self.top == -1: # 检查栈是否为空
raise IndexError("Peek from an empty stack")
return self.stack[self.top] # 返回栈顶元素,但不移除
5.判断是否为空(IsEmpty)
def is_empty(self):
return self.top == -1 # 栈为空时,栈顶指针为-1
上面的代码是具体实现的方式,这东西就是这么个原理,就是这么写的,不要过分纠结。下面研究一下
栈的应用
1.函数调用管理
在程序执行过程中,每次调用一个函数时,相关的状态信息(如局部变量、返回地址)被压入栈中。当函数执行完毕后,这些信息会从栈中弹出,以便程序继续执行。
想象一下你在厨房里做饭,每次你开始做一道新菜,你会把所需的食材和菜谱放在一个新的盘子里,等这道菜做完后,你会把盘子上的东西清理干净,然后用另一个盘子做下一道菜。
如何对应到调用栈:
-
主菜(
main()
函数):你开始准备晚餐,把所有食材和菜谱放在一个盘子上。这就是你函数调用的起始点。 -
开始做第一道菜(
functionA()
函数):你开始做第一道菜,为了这道菜,你再准备一个新的盘子,把所需的食材和做菜步骤放上去。这是将一个新的栈帧压入栈中。 -
做第二道菜(
functionB()
函数):在做第一道菜的时候,你决定做第二道菜,于是又准备一个新的盘子,把第二道菜的食材和步骤放上去。这是将另一个新的栈帧压入栈中。 -
清理盘子(
functionB()
完成):你完成了第二道菜,清理这个盘子,换成之前的盘子(回到第一道菜)。这对应于弹出functionB
的栈帧,恢复到functionA
的状态。 -
继续做第一道菜(
functionA()
继续):你回到第一道菜,完成剩下的步骤,之后清理掉这个盘子,回到最初的准备晚餐的盘子(main()
函数)。这对应于弹出functionA
的栈帧,恢复到main
的状态。 -
晚餐完成(
main()
结束):晚餐准备完毕,清理主盘子,完成整个过程。
每次你开始新的菜品时,都在厨房中创建一个新的盘子,并在完成菜品后清理它。调用栈就像厨房中的这些盘子,帮助你管理和返回到之前的状态。
2.表达式求值(逆波兰表达式)
栈被用于计算后缀表达式(逆波兰表示法)。操作数被压入栈中,当遇到操作符时,从栈中弹出操作数,进行计算,然后将结果压入栈中
例子:计算 5 3 + 2 *
步骤 1:初始化
- 创建一个空栈。
步骤 2:处理每个元素
我们逐个处理表达式中的元素:
-
遇到
5
:- 操作数
5
被压入栈中。 - 当前栈状态:
[5]
- 操作数
-
遇到
3
:- 操作数
3
被压入栈中。 - 当前栈状态:
[5, 3]
- 操作数
-
遇到
+
:- 遇到操作符
+
,从栈中弹出两个操作数:3
和5
。 - 计算
5 + 3 = 8
。 - 将结果
8
压入栈中。 - 当前栈状态:
[8]
- 遇到操作符
-
遇到
2
:- 操作数
2
被压入栈中。 - 当前栈状态:
[8, 2]
- 操作数
-
遇到
*
:- 遇到操作符
*
,从栈中弹出两个操作数:2
和8
。 - 计算
8 * 2 = 16
。 - 将结果
16
压入栈中。 - 当前栈状态:
[16]
- 遇到操作符
步骤 3:完成
表达式处理完毕,栈中只剩下一个元素 16
,这就是计算的结果。
3.撤销操作
许多应用程序(如文本编辑器)使用栈来实现撤销功能。每次执行操作时,将操作的状态保存到栈中,撤销操作时,从栈中恢复之前的状态。没啥好解释的
4.括号匹配
栈可以用于检查括号是否匹配。遍历表达式时,将开括号压入栈中,遇到闭括号时,弹出栈顶元素并进行匹配。最终栈应为空,表示所有括号都已正确匹配。
假设你在检查一个房间的门和窗户的配对。开门或开窗代表开括号,关门或关窗代表闭括号。
示例表达式:{[()()]}
-
初始化:
- 空栈。
-
遍历字符:
-
遇到
{
:开括号,压入栈。- 栈状态:
[{]
- 栈状态:
-
遇到
[
:开括号,压入栈。- 栈状态:
[{, []
- 栈状态:
-
遇到
(
:开括号,压入栈。- 栈状态:
[{, [, (]
- 栈状态:
-
遇到
)
:闭括号,从栈中弹出一个开括号(
,并检查它是否匹配。如果匹配,继续。- 栈状态:
[{, []
- 栈状态:
-
遇到
(
:开括号,压入栈。- 栈状态:
[{, [, (]
- 栈状态:
-
遇到
)
:闭括号,从栈中弹出一个开括号(
,并检查它是否匹配。如果匹配,继续。- 栈状态:
[{, []
- 栈状态:
-
遇到
]
:闭括号,从栈中弹出一个开括号[
,并检查它是否匹配。如果匹配,继续。- 栈状态:
[{]
- 栈状态:
-
遇到
}
:闭括号,从栈中弹出一个开括号{
,并检查它是否匹配。如果匹配,继续。- 栈状态:
[]
- 栈状态:
-
-
检查栈的状态:
- 遍历完成后,栈为空,所有开括号都有匹配的闭括号。
另一种示例表达式:{[(])}
-
初始化:
- 空栈。
-
遍历字符:
-
遇到
{
:开括号,压入栈。- 栈状态:
[{]
- 栈状态:
-
遇到
[
:开括号,压入栈。- 栈状态:
[{, []
- 栈状态:
-
遇到
(
:开括号,压入栈。- 栈状态:
[{, [, (]
- 栈状态:
-
遇到
]
:闭括号,从栈中弹出一个开括号(
,发现不匹配]
(栈顶是(
,应该匹配)
),括号不匹配。
-
下面我们继续研究
队列
先进先出原则(FIFO)
在队列中,第一个被插入的元素会第一个被移除。可以把队列想象成排队等候的队伍,先到的人先得到服务
队列的基本操作
-
Enqueue(入队):
- 定义:将一个元素添加到队列的末尾。
- 操作:将数据插入到队列的尾部。
- 时间复杂度:O(1),即操作时间是常量时间。
-
Dequeue(出队):
- 定义:从队列的前端移除一个元素。
- 操作:移除并返回队列的第一个元素。
- 时间复杂度:O(1)。
-
Peek/Front(查看队首元素):
- 定义:查看队列前端的元素,但不移除它。
- 操作:返回队首元素的值,队列状态不变。
- 时间复杂度:O(1)。
-
IsEmpty(检查队列是否为空):
- 定义:检查队列是否有元素。
- 操作:如果队列中没有元素,返回 true;否则,返回 false。
- 时间复杂度:O(1)
队列的实现方式
数组实现
描述:使用固定大小的数组来实现队列。使用两个指针(front
和 rear
)来跟踪队列的前端和末尾。
优点:实现简单,访问速度快
缺点:需要预分配固定大小的数组,可能会出现空间浪费或不足的问题。
链表实现
描述:使用链表来实现队列。每个节点包含一个数据部分和一个指向下一个节点的指针。队列有一个指向头部和一个指向尾部的指针。
优点:动态调整大小,无需预分配固定大小的存储空间。
缺点:每次操作需要额外的内存开销(链表节点的指针),可能会导致额外的内存消耗。
队列的应用场景
1.任务调度:
在操作系统中,任务或进程调度通常使用队列来管理。新任务被添加到队列的末尾,正在执行的任务从队列的前端移除。
2.消息传递
在消息队列系统中,消息被加入到队列的末尾,然后被处理和移除。这确保了消息按顺序传递。
3.广度优先搜索(BFS)
BFS 算法在图或树的遍历中使用队列来保证按层次顺序访问节点。节点被按顺序添加到队列中,然后逐个处理。
BFS 的图示例
A
/ \
B C
/ \
D E
BFS 遍历:
-
初始化:
- 队列:
[A]
- 已访问节点:
{A}
- 队列:
-
访问节点 A:
- 从队列中取出 A。
- 访问 A。
- 将 A 的邻居 B 和 C 添加到队列中。
- 队列:
[B, C]
- 已访问节点:
{A, B, C}
-
访问节点 B:
- 从队列中取出 B。
- 访问 B。
- 将 B 的邻居 D 和 E 添加到队列中。
- 队列:
[C, D, E]
- 已访问节点:
{A, B, C, D, E}
-
访问节点 C:
- 从队列中取出 C。
- 访问 C。
- C 没有未访问的邻居。
- 队列:
[D, E]
- 已访问节点:
{A, B, C, D, E}
-
访问节点 D:
- 从队列中取出 D。
- 访问 D。
- D 没有未访问的邻居。
- 队列:
[E]
- 已访问节点:
{A, B, C, D, E}
-
访问节点 E:
- 从队列中取出 E。
- 访问 E。
- E 没有未访问的邻居。
- 队列:
[]
- 已访问节点:
{A, B, C, D, E}
-
结束:
- 队列为空,遍历完成。
4.生产者-消费者问题:
在生产者-消费者问题中,生产者将产品添加到队列中,消费者从队列中取出产品。队列用于协调生产和消费的速度