status
date
type
summary
tags
category
slug
password
icon

「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。
我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。
我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。
notion image

栈常用操作

栈的常用操作,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 push()pop()peek() 命名为例。
方法名
描述
时间复杂度
push()
元素入栈(添加至栈顶)
O(1)
pop()
栈顶元素出栈
O(1)
peek()
访问栈顶元素
O(1)
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”当作栈来使用,并在程序逻辑上忽略与栈无关的操作。

栈的实现

栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,因此栈可以视为一种受限制的数组或链表。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。

1、基于链表的实现

使用链表实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。
对于入栈操作,我们只需将元素插入链表头部,这种节点插入方法被称为“头插法”。而对于出栈操作,只需将头节点从链表中删除即可。
step1:LinkedListStack
notion image
step2:push()
notion image
step3:pop()
notion image
基于链表实现栈的入栈出栈操作
基于链表实现栈的示例代码:
C版本
C++版本

2、基于数组的实现

使用数组实现栈时,我们可以将数组的尾部作为栈顶。入栈与出栈操作分别对应在数组尾部添加元素与删除元素,时间复杂度都为 O(1) 。
step1:ArrayStack
notion image
step2:push()
notion image
step3:pop()
notion image
基于数组实现栈的入栈出栈操作
由于入栈的元素可能会源源不断地增加,因此我们可以使用动态数组,这样就无须自行处理数组扩容问题。以下为示例代码:
C版本
C++版本

两种实现对比

支持操作
两种实现都支持栈定义中的各项操作。数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般不会用到。
时间效率
在基于数组的实现中,入栈和出栈操作都在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 O(n) 。
在基于链表的实现中,链表的扩容非常灵活,不存在上述数组扩容时效率降低的问题。但是,入栈操作需要初始化节点对象并修改指针,因此效率相对较低。不过,如果入栈元素本身就是节点对象,那么可以省去初始化步骤,从而提高效率。
综上所述,当入栈与出栈操作的元素是基本数据类型时,例如 int 或 double ,我们可以得出以下结论。
  • 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。
  • 基于链表实现的栈可以提供更加稳定的效率表现。
空间效率
在初始化列表时,系统会为列表分配“初始容量”,该容量可能超出实际需求;并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容的,扩容后的容量也可能超出实际需求。因此,基于数组实现的栈可能造成一定的空间浪费
然而,由于链表节点需要额外存储指针,因此链表节点占用的空间相对较大
综上,我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析。

栈典型应用

  • 浏览器中的后退与前进、软件中的撤销与反撤销。每当我们打开新的网页,浏览器就会对上一个网页执行入栈,这样我们就可以通过后退操作回到上一个网页。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。
  • 程序内存管理。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会不断执行出栈操作。

队列

「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列尾部,而位于队列头部的人逐个离开。
将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。
notion image

队列常用操作

方法名
描述
时间复杂度
push()
元素入队,即将元素添加到队尾
O(1)
pop()
队首元素出队
O(1)
peek()
访问队首元素
O(1)

队列实现

为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。链表和数组都符合要求。

1、基于链表的实现

可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。
step1:LinkedListQueue
notion image
step2:push()
notion image
step3:pop()
notion image
基于链表实现队列的入队出队操作
以下是用链表实现队列的代码:
C版本
C++版本

2、基于数组的实现

在数组中删除首元素的时间复杂度为 O(n) ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。
我们可以使用一个变量 front 指向队首元素的索引,并维护一个变量 size 用于记录队列长度。定义 rear = front + size ,这个公式计算出的 rear 指向队尾元素之后的下一个位置。
基于此设计,数组中包含元素的有效区间为 [front, rear - 1]
  • 入队操作:将输入元素赋值给 rear 索引处,并将 size 增加 1 。
  • 出队操作:只需将 front 增加 1 ,并将 size 减少 1 。
可以看到,入队和出队操作都只需进行一次操作,时间复杂度均为 O(1) 。
step1:ArrayQueue
notion image
step2:push()
notion image
step3:pop()
notion image
基于数组实现队列的入队出队操作
在不断进行入队和出队的过程中,front 和 rear 都在向右移动,当它们到达数组尾部时就无法继续移动了。为了解决此问题,我们可以将数组视为首尾相接的“环形数组”。
对于环形数组,我们需要让 front 或 rear 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示:
C版本
C++版本
以上实现的队列仍然具有局限性:其长度不可变。

队列典型应用

  • 淘宝订单。购物者下单后,订单将加入队列中,系统随后会根据顺序处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题。
  • 各类待办事项。任何需要实现“先来后到”功能的场景,例如打印机的任务队列、餐厅的出餐队列等,队列在这些场景中可以有效地维护处理顺序。

双向队列

在队列中,我们仅能删除头部元素或在尾部添加元素。「双向队列 double-ended queue」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。
notion image

双向队列常用操作

方法名
描述
时间复杂度
pushFirst()
将元素添加到队首
O(1)
pushLast()
将元素添加到队尾
O(1)
popFirst()
删除队首元素
O(1)
popLast()
删除队尾元素
O(1)
peekFirst()
访问队首元素
O(1)
peekLast()
访问队尾元素
O(1)
使用C++实现的双向队列类:

双向队列实现

双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。

1、基于双向链表的实现

对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。
们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
step1:LinkedListDeque
notion image
step2:pushLast()
notion image
step3:pushFirst()
notion image
step4:popLast()
notion image
step5:popFirst()
notion image
基于链表实现双向队列的入队出队操作
实现代码如下所示:
C版本
C++版本

2、基于数组的实现

与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。
step1:ArrayDeque
notion image
step2:pushLast()
notion image
step3:pushFirst()
notion image
step4:popLast()
notion image
step5:popFirst()
notion image
基于数组实现双向队列的入队出队操作
在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法:
C版本
C++版本

双向队列的应用

双向队列兼具栈与队列的逻辑,因此它可以实现这两者的所有应用场景,同时提供更高的自由度
我们知道,软件的“撤销”功能通常使用栈来实现:系统将每次更改操作 push 到栈中,然后通过 pop 实现撤销。然而,考虑到系统资源的限制,软件通常会限制撤销的步数(例如仅允许保存 50 步)。当栈的长度超过 50 时,软件需要在栈底(队首)执行删除操作。但栈无法实现该功能,此时就需要使用双向队列来替代栈。请注意,“撤销”的核心逻辑仍然遵循栈的先入后出原则,只是双向队列能够更加灵活地实现一些额外逻辑。

小结

  • 栈是一种遵循先入后出原则的数据结构,可通过数组或链表来实现。
  • 在时间效率方面,栈的数组实现具有较高的平均效率,但在扩容过程中,单次入栈操作的时间复杂度会劣化至 O(n) 。相比之下,栈的链表实现具有更为稳定的效率表现。
  • 在空间效率方面,栈的数组实现可能导致一定程度的空间浪费。但需要注意的是,链表节点所占用的内存空间比数组元素更大。
  • 队列是一种遵循先入先出原则的数据结构,同样可以通过数组或链表来实现。在时间效率和空间效率的对比上,队列的结论与前述栈的结论相似。
  • 双向队列是一种具有更高自由度的队列,它允许在两端进行元素的添加和删除操作。
数组与链表
雨落波敛
雨落波敛
早上好中午好晚上好
统计
文章数:
15
公告
status
date
type
summary
tags
category
slug
password
icon
🎉 Welcome To My Blog 🎉
✍🏼记录生活与学习日常🗓️
👓偶尔分享看的推文💬
🧙🏼‍♂️保持热爱🥳