数据结构与算法——自学总结之栈和队列

现在来介绍一下栈,栈就是一种数据结构,是之前我之前所提到的线性表的一种,特点是后进先出的原则,什么是后进先出呢,意思就是你后放入的元素是最先出来的,接下来就是栈的结构,栈分为栈顶和栈底,这里面找个图片演示一下,如下图就明显很多了。

栈基本操作

压栈(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.函数调用管理

在程序执行过程中,每次调用一个函数时,相关的状态信息(如局部变量、返回地址)被压入栈中。当函数执行完毕后,这些信息会从栈中弹出,以便程序继续执行。

想象一下你在厨房里做饭,每次你开始做一道新菜,你会把所需的食材和菜谱放在一个新的盘子里,等这道菜做完后,你会把盘子上的东西清理干净,然后用另一个盘子做下一道菜。

如何对应到调用栈:
  1. 主菜main()函数):你开始准备晚餐,把所有食材和菜谱放在一个盘子上。这就是你函数调用的起始点。

  2. 开始做第一道菜functionA()函数):你开始做第一道菜,为了这道菜,你再准备一个新的盘子,把所需的食材和做菜步骤放上去。这是将一个新的栈帧压入栈中。

  3. 做第二道菜functionB()函数):在做第一道菜的时候,你决定做第二道菜,于是又准备一个新的盘子,把第二道菜的食材和步骤放上去。这是将另一个新的栈帧压入栈中。

  4. 清理盘子functionB()完成):你完成了第二道菜,清理这个盘子,换成之前的盘子(回到第一道菜)。这对应于弹出functionB的栈帧,恢复到functionA的状态。

  5. 继续做第一道菜functionA()继续):你回到第一道菜,完成剩下的步骤,之后清理掉这个盘子,回到最初的准备晚餐的盘子(main()函数)。这对应于弹出functionA的栈帧,恢复到main的状态。

  6. 晚餐完成main()结束):晚餐准备完毕,清理主盘子,完成整个过程。

每次你开始新的菜品时,都在厨房中创建一个新的盘子,并在完成菜品后清理它。调用栈就像厨房中的这些盘子,帮助你管理和返回到之前的状态。

2.表达式求值(逆波兰表达式

栈被用于计算后缀表达式(逆波兰表示法)。操作数被压入栈中,当遇到操作符时,从栈中弹出操作数,进行计算,然后将结果压入栈中

例子:计算 5 3 + 2 *
步骤 1:初始化
  • 创建一个空栈。
步骤 2:处理每个元素

我们逐个处理表达式中的元素:

  1. 遇到 5

    • 操作数 5 被压入栈中。
    • 当前栈状态: [5]
  2. 遇到 3

    • 操作数 3 被压入栈中。
    • 当前栈状态: [5, 3]
  3. 遇到 +

    • 遇到操作符 +,从栈中弹出两个操作数:35
    • 计算 5 + 3 = 8
    • 将结果 8 压入栈中。
    • 当前栈状态: [8]
  4. 遇到 2

    • 操作数 2 被压入栈中。
    • 当前栈状态: [8, 2]
  5. 遇到 *

    • 遇到操作符 *,从栈中弹出两个操作数:28
    • 计算 8 * 2 = 16
    • 将结果 16 压入栈中。
    • 当前栈状态: [16]
步骤 3:完成

            表达式处理完毕,栈中只剩下一个元素 16,这就是计算的结果。

3.撤销操作

许多应用程序(如文本编辑器)使用栈来实现撤销功能。每次执行操作时,将操作的状态保存到栈中,撤销操作时,从栈中恢复之前的状态。没啥好解释的

4.括号匹配

栈可以用于检查括号是否匹配。遍历表达式时,将开括号压入栈中,遇到闭括号时,弹出栈顶元素并进行匹配。最终栈应为空,表示所有括号都已正确匹配。

假设你在检查一个房间的门和窗户的配对。开门或开窗代表开括号,关门或关窗代表闭括号。

示例表达式:{[()()]}
  1. 初始化

    • 空栈。
  2. 遍历字符

    • 遇到 {:开括号,压入栈。

      • 栈状态:[{]
    • 遇到 [:开括号,压入栈。

      • 栈状态:[{, []
    • 遇到 (:开括号,压入栈。

      • 栈状态:[{, [, (]
    • 遇到 ):闭括号,从栈中弹出一个开括号 (,并检查它是否匹配。如果匹配,继续。

      • 栈状态:[{, []
    • 遇到 (:开括号,压入栈。

      • 栈状态:[{, [, (]
    • 遇到 ):闭括号,从栈中弹出一个开括号 (,并检查它是否匹配。如果匹配,继续。

      • 栈状态:[{, []
    • 遇到 ]:闭括号,从栈中弹出一个开括号 [,并检查它是否匹配。如果匹配,继续。

      • 栈状态:[{]
    • 遇到 }:闭括号,从栈中弹出一个开括号 {,并检查它是否匹配。如果匹配,继续。

      • 栈状态:[]
  3. 检查栈的状态

    • 遍历完成后,栈为空,所有开括号都有匹配的闭括号。
另一种示例表达式:{[(])}
  1. 初始化

    • 空栈。
  2. 遍历字符

    • 遇到 {:开括号,压入栈。

      • 栈状态:[{]
    • 遇到 [:开括号,压入栈。

      • 栈状态:[{, []
    • 遇到 (:开括号,压入栈。

      • 栈状态:[{, [, (]
    • 遇到 ]:闭括号,从栈中弹出一个开括号 (,发现不匹配 ](栈顶是 (,应该匹配 )),括号不匹配。

下面我们继续研究

队列

先进先出原则(FIFO)

在队列中,第一个被插入的元素会第一个被移除。可以把队列想象成排队等候的队伍,先到的人先得到服务

队列的基本操作
  1. Enqueue(入队):

    • 定义:将一个元素添加到队列的末尾。
    • 操作:将数据插入到队列的尾部。
    • 时间复杂度:O(1),即操作时间是常量时间。
  2. Dequeue(出队):

    • 定义:从队列的前端移除一个元素。
    • 操作:移除并返回队列的第一个元素。
    • 时间复杂度:O(1)。
  3. Peek/Front(查看队首元素):

    • 定义:查看队列前端的元素,但不移除它。
    • 操作:返回队首元素的值,队列状态不变。
    • 时间复杂度:O(1)。
  4. IsEmpty(检查队列是否为空):

    • 定义:检查队列是否有元素。
    • 操作:如果队列中没有元素,返回 true;否则,返回 false。
    • 时间复杂度:O(1)
队列的实现方式

数组实现

描述:使用固定大小的数组来实现队列。使用两个指针(frontrear)来跟踪队列的前端和末尾。

优点:实现简单,访问速度快

缺点:需要预分配固定大小的数组,可能会出现空间浪费或不足的问题。

链表实现

描述:使用链表来实现队列。每个节点包含一个数据部分和一个指向下一个节点的指针。队列有一个指向头部和一个指向尾部的指针。

优点:动态调整大小,无需预分配固定大小的存储空间。

缺点:每次操作需要额外的内存开销(链表节点的指针),可能会导致额外的内存消耗。

队列的应用场景
1.任务调度

在操作系统中,任务或进程调度通常使用队列来管理。新任务被添加到队列的末尾,正在执行的任务从队列的前端移除。

2.消息传递

在消息队列系统中,消息被加入到队列的末尾,然后被处理和移除。这确保了消息按顺序传递。

3.广度优先搜索(BFS)

BFS 算法在图或树的遍历中使用队列来保证按层次顺序访问节点。节点被按顺序添加到队列中,然后逐个处理。

BFS 的图示例

    A
   / \
  B   C
 / \
D   E
BFS 遍历:
  1. 初始化

    • 队列:[A]
    • 已访问节点:{A}
  2. 访问节点 A

    • 从队列中取出 A。
    • 访问 A。
    • 将 A 的邻居 B 和 C 添加到队列中。
    • 队列:[B, C]
    • 已访问节点:{A, B, C}
  3. 访问节点 B

    • 从队列中取出 B。
    • 访问 B。
    • 将 B 的邻居 D 和 E 添加到队列中。
    • 队列:[C, D, E]
    • 已访问节点:{A, B, C, D, E}
  4. 访问节点 C

    • 从队列中取出 C。
    • 访问 C。
    • C 没有未访问的邻居。
    • 队列:[D, E]
    • 已访问节点:{A, B, C, D, E}
  5. 访问节点 D

    • 从队列中取出 D。
    • 访问 D。
    • D 没有未访问的邻居。
    • 队列:[E]
    • 已访问节点:{A, B, C, D, E}
  6. 访问节点 E

    • 从队列中取出 E。
    • 访问 E。
    • E 没有未访问的邻居。
    • 队列:[]
    • 已访问节点:{A, B, C, D, E}
  7. 结束

    • 队列为空,遍历完成。

4.生产者-消费者问题

在生产者-消费者问题中,生产者将产品添加到队列中,消费者从队列中取出产品。队列用于协调生产和消费的速度

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值