前言
同学,你好!我是 嘟老板。看到标题,想必你也猜到了本文要讲的内容了,没错,就是 栈结构。对于前端同学来说,栈结构肯定不陌生。毕竟我们常用的 JavaScript 底层就有对栈结构的深度应用 - 调用栈。今天,我将深入探索 栈结构 的世界,看看它有哪些神奇的地方。
阅读本文您将收获:
- 了解栈结构定义及特性。
- 使用不同的存储方式,实现栈结构。
- 了解栈结构的应用场景,包括 JS 调用栈原理。
定义
栈 是限定只能在 表尾 进行插入和删除的 线性表。其中允许插入、删除的一端称作 栈顶,即线性表的表尾,另一端则称作 栈底。
从定义中可以看出,栈结构 属于线性表,也具备线性关系,存在前驱和后继元素。只不过比较特殊,限定了插入和删除的位置,始终只能在栈顶操作,具备 后进先出(LIFO,Last In First Out) 的特性,即 后入栈的元素先出栈。
栈的插入操作,叫做 入栈,也可叫做压栈、进栈;
栈的删除操作,叫做 出栈,也可叫做弹栈;
不了解线性表的同学,可点击 《前端应该了解的数据结构 | 线性表》。
栈结构操作示意图:
实现
栈的数据类型
由于栈属于特殊的线性表,理论上线性表的特性它都具备。不过因为栈结构的插入和删除操作存在特殊性,我们将其分别更名为push 和 pop,以更直观的表达 入栈 和 出栈 的意思。
基础操作如下:
initStack(data)
:初始化栈结构,data 为栈元素结合。clearStack()
:清空栈中的所有元各。getTop()
:若栈存在且非空,返回栈顶元素。push(e)
:将元素e
添加到栈顶。pop()
:删除栈顶元素。length
:返回栈中的元素个数。
基本类定义:
/**
* 栈
*/
class Stack {
// 栈容量
MAX_SIZE = 10
// 栈元素数据结合
data = []
// 栈元素个数
length = 0
// 栈顶指针
top = -1
constructor(data) {
this.initStack(data)
}
// 初始化栈结构
initStack(data) {}
// 清空栈
clearStack() {}
// 获取栈顶元素
getTop() {}
// 将元素e插入栈顶
push(e) {}
// 删除栈顶元素
pop() {}
}
顺序存储结构
我们可以将 栈的顺序存储结构 简称为 顺序栈。可以用 数组 来实现。
动手之前我们先考虑的一个问题:数组的哪一端用来做栈底?没错,下标为 0 的一端做栈底更合适,因为这端作栈底,新增元素时操作最少,不需要移动元素。
明确了栈底和栈顶端,就可以着手实现了。
我们之前定义的类结构中包含以下属性:
- MAX_SIZE:定义栈最大容量。
- length:栈中元素个数。
- data:存储栈元素。
- top:栈顶指针,始终指向栈顶元素的下标,若栈为空,则为 -1。
其中 MAX_SIZE 作为常量属性,可以先忽略。
以下是 MAX_SIZE 为 5 的栈结构示意图:
入栈
入栈操作步骤如下:
- 边界判断,若栈已满,则报错。
- 栈顶指针向上移动一个位置。
- 将新元素添加到栈顶指针指向的位置。
- 栈元素数量 +1。
操作示意图如下:
以下是具体实现:
// 将元素e插入栈顶
push(e) {
if (this.top === this.MAX_SIZE - 1) {
throw new Error('栈已满')
}
// top 指针上移
this.top++
// 将新元素 e 添加到栈中
this.data[this.top] = e
// 栈元素数量 +1。
this.length++
}
出栈
出栈操作步骤如下:
- 若栈为空,报错。
- 获取当前的栈顶元素,作为返回结果。
- 将栈顶指针下移一个位置。
- 栈元素数量 -1。
操作示意图如下:
以下是具体实现:
出栈
出栈操作步骤如下:
- 若栈为空,报错。
- 获取当前的栈顶元素,作为返回结果。
- 将栈顶指针下移一个位置。
- 栈元素数量 -1。
操作示意图如下:
以下是具体实现:
// 删除栈顶元素
pop() {
if (this.top === -1) {
throw new Error('栈为空')
}
const e = this.data[this.top]
this.top--
this.length--
return e
}
由于 插入 和 删除 操作都没有涉及循环,因此时间复杂度均为 O(1)。
链式存储结构
讲完了 顺序存储结构,我们再来看看栈的 链式存储结构,我们可以将其简称为 链栈。
与 顺序栈 同样的问题,使用链表实现栈结构,哪一端做栈顶,哪一端作栈底呢?
阅读 《前端应该了解的数据结构 | 线性表》 后我们能知道, 单链表 有一个 头指针,而栈结构也有一个 栈顶指针,如果能将两个指针结合起来,是不是就完美了?没错,单链表的 头部 作为栈顶最合理。而 链栈 与单链表不同之处在于,栈结构的栈顶指针始终指向栈顶元素,因此 链栈 不需要头结点,头指针直接指向第一个栈元素即可。
以下是 链栈 的结构示意图:
以下是 链栈 的结构代码:
// 链栈节点结构
class LinkStackNode {
// 数据域
data = null
// 后继指针域
next = null
}
// 链栈结构
class LinkStack {
// 栈顶指针
top = null
// 栈元素个数
length = 0
}
与 单链表 类似,链栈节点结构 包含 数据域(data) 和 指针域(next),分别存储 节点数据 和 后继节点指针;链栈 结构包含 栈顶指针(top) 和 节点个数(length)。栈为空时,top 指针 指向 null。链栈不存在栈满的情况,除非计算机内存不足。
入栈
链栈入栈操作步骤大致如下:
- 生成新的 节点 s。
- 将当前栈顶元素赋值给 节点 s 的后继。
- 将栈顶指针指向 节点 s。
- 栈元素数量 +1。
操作示意图如下:
以下是具体实现:
// 将数据 data 插入栈顶
push(data) {}{
// 1. 生成新的 **节点 s**。
const s = new LinkStackNode(data)
// 2. 将当前栈顶元素赋值给 **节点 s** 的后继。
s.next = this.top
// 3. 将栈顶指针指向 **节点 s**。
this.top = s
// 4. 栈元素个数 +1。
this.length++
}
出栈
链栈出栈操作步骤大致如下:
- 判断栈是否为空,若为空,报错。
- 获取栈顶节点 s,用于后续返回。
- 将栈顶指针向下移动。
- 栈元素数量 -1。
操作示意图如下:
以下是具体实现:
// 删除栈顶元素
pop() {
// 1. 判断栈是否为空,若为空,报错。
if (this.length === 0) {
throw new Error('栈为空')
}
// 获取栈顶节点 s
const s = this.top
// 将栈顶指针向下移动。
this.top = this.top.next
// 栈元素数量 -1。
this.length--
return s.data
}
链栈的 入栈 和 出栈 操作都没有涉及到循环处理,时间复杂度均为 O(1)。
存储结构对比
对比上面的两种时间方式,可以发现:
顺序栈 长度固定,可能存在空间的浪费或不足;
链栈 在长度上无限制,但是每个节点多存储了一个指针域,多了一些空间上的开销。
综合来看,若栈元素的数量不可预料,可能很大也可能很小,建议使用 链栈;若栈元素数量变化可控,建议使用 顺序栈。
应用
Javascript 调用栈
了解了栈的基础内容,我们再看看 JavaScript 对于栈的最典型应用 - 调用栈。
那么什么是调用栈呢?
众所周知,JS 是 单线程语言,所有的同步任务都会运行在主线程中,JS 引擎如何处理这些同步任务的调度呢?这就用到了 调用栈。每执行一个函数,就会将相关的执行上下文(包含变量和函数等)压入到栈中,执行完成后,再将该上下文从栈中弹出,以实现单线程处理同步任务的目的。
例如以下代码片段:
const a = 1
function sum(a, b) {
const b = 2
return a + b
}
sum(a, b)
js 引擎的调度流程如下:
- 在执行代码前,js 引擎会首先创建一个全局上下文,包括所有声明的变量及函数,此时变量 a 的值还是 undefined。
2. 接下来执行 a 的赋值代码,因为都是在全局上下文执行的操作,所以调用栈没有变化。然后执行 sum 函数,js 引擎对于函数会执行以下处理:
- 从全局上下文中取出 sum 函数的代码;
- 对 sum 函数的代码进行编译,创建该函数的执行上下文和可执行代码,并将执行上下文压入栈中。
- 执行 sum 函数,返回结果,并将其执行上下文从栈中弹出。
- 调用栈回到第一步的状态,只剩下全局上下文,执行完毕。
可见,调用栈主要用来 管理函数执行上下文,可以跟踪每个函数调用时的执行状态,包括局部变量、参数、返回值等。并通过 后进先出(LIFO) 的栈结构,确保同步函数按照正确的顺序执行。
递归
递归并不算是栈的应用,确切的说应该是 JS 调用栈的直观体现。
那么什么是递归呢?
递归就是 函数调用自己,可以是直接调用,也可以是间接调用。其中调用自己的函数,叫做 递归函数。
举个例子,菲波那切数列,0 1 1 2 3 5...,规律就是 前面两个数相加的结果,等于后面的数。
假设我们要打印菲波那切数列的前 100 个数字,怎么实现?
直接上代码:
function sum(i) {
if (i < 2) return i === 0 ? 0 : 1
return sum(i - 2) + sum(i - 1)
}
function getNumbers() {
for (let i = 0; i < 100; i++) {
console.log(sum(i))
}
}
getNumbers()
其中,sum 函数就是递归函数。
结合代码,我们梳理下递归的执行过程:
- 函数调用:递归开始时,函数被调用并进入调用栈。
- 条件检查:函数内部检查是否满足递归终止条件,对应代码中的 i < 2 判断。
- 递归调用:如果不满足终止条件,函数再次调用自己,创建新的执行上下文并入栈。
- 达到基准情况:当满足终止条件时,递归开始回退。
- 执行上下文出栈:每次递归返回时,当前的执行上下文出栈,并将结果传递给上层。
- 最终返回:当所有递归调用都完成后,最终结果返回给最初的调用者。
可见,递归就是函数上下文不断入栈、出栈的过程。通过使用递归,可以使程序结构更简洁,更容易理解。
不过递归虽好,也不能滥用。使用时需要注意以下两点:
- 递归函数必须有一个或多个明确的终止条件,以避免无限递归导致栈溢出,如上面代码中 i < 2 的判断,就是用来终止递归的。
- 由于递归会不断将新的上下文压入调用栈,在大量递归调用的情况下,可能会因为占用太多调用栈空间而导致性能问题。
结语
本文重点介绍了一个基础而重要的数据结构 —— 栈,并详细探讨了它的两种实现方式 - 线性存储结构 和 链式存储结构 及其应用场景,旨在帮助小伙伴快速掌握栈结构。
如果您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。
技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。