前端应该了解的数据结构 | 栈

前言

同学,你好!我是 嘟老板。看到标题,想必你也猜到了本文要讲的内容了,没错,就是 栈结构。对于前端同学来说,栈结构肯定不陌生。毕竟我们常用的 JavaScript 底层就有对栈结构的深度应用 - 调用栈。今天,我将深入探索 栈结构 的世界,看看它有哪些神奇的地方。

阅读本文您将收获:

  1. 了解栈结构定义及特性。
  2. 使用不同的存储方式,实现栈结构。
  3. 了解栈结构的应用场景,包括 JS 调用栈原理。

定义

是限定只能在 表尾 进行插入和删除的 线性表。其中允许插入、删除的一端称作 栈顶,即线性表的表尾,另一端则称作 栈底

从定义中可以看出,栈结构 属于线性表,也具备线性关系,存在前驱和后继元素。只不过比较特殊,限定了插入和删除的位置,始终只能在栈顶操作,具备 后进先出(LIFO,Last In First Out) 的特性,即 后入栈的元素先出栈

栈的插入操作,叫做 入栈,也可叫做压栈、进栈;
栈的删除操作,叫做 出栈,也可叫做弹栈;

不了解线性表的同学,可点击 《前端应该了解的数据结构 | 线性表》

栈结构操作示意图:

image.png

实现

栈的数据类型

由于栈属于特殊的线性表,理论上线性表的特性它都具备。不过因为栈结构的插入和删除操作存在特殊性,我们将其分别更名为pushpop,以更直观的表达 入栈出栈 的意思。

基础操作如下:

  • 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 的栈结构示意图:

image.png

入栈

入栈操作步骤如下:

  1. 边界判断,若栈已满,则报错。
  2. 栈顶指针向上移动一个位置。
  3. 将新元素添加到栈顶指针指向的位置。
  4. 栈元素数量 +1。

操作示意图如下:

image.png

以下是具体实现:

// 将元素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. 若栈为空,报错。
  2. 获取当前的栈顶元素,作为返回结果。
  3. 将栈顶指针下移一个位置。
  4. 栈元素数量 -1。

操作示意图如下:

image.png

以下是具体实现:

出栈

出栈操作步骤如下:

  1. 若栈为空,报错。
  2. 获取当前的栈顶元素,作为返回结果。
  3. 将栈顶指针下移一个位置。
  4. 栈元素数量 -1。

操作示意图如下:

image.png

以下是具体实现:

// 删除栈顶元素
  pop() {
    if (this.top === -1) {
      throw new Error('栈为空')
    }
    const e = this.data[this.top]
    this.top--
    this.length--
    return e
  }

由于 插入删除 操作都没有涉及循环,因此时间复杂度均为 O(1)

链式存储结构

讲完了 顺序存储结构,我们再来看看栈的 链式存储结构,我们可以将其简称为 链栈

顺序栈 同样的问题,使用链表实现栈结构,哪一端做栈顶,哪一端作栈底呢?

阅读 《前端应该了解的数据结构 | 线性表》 后我们能知道, 单链表 有一个 头指针,而栈结构也有一个 栈顶指针,如果能将两个指针结合起来,是不是就完美了?没错,单链表的 头部 作为栈顶最合理。而 链栈 与单链表不同之处在于,栈结构的栈顶指针始终指向栈顶元素,因此 链栈 不需要头结点,头指针直接指向第一个栈元素即可。

以下是 链栈 的结构示意图:

image.png

以下是 链栈 的结构代码:

// 链栈节点结构
class LinkStackNode {
  // 数据域
  data = null
  // 后继指针域
  next = null
}

// 链栈结构
class LinkStack {
  // 栈顶指针
  top = null
  // 栈元素个数
  length = 0
}

单链表 类似,链栈节点结构 包含 数据域(data)指针域(next),分别存储 节点数据后继节点指针链栈 结构包含 栈顶指针(top)节点个数(length)。栈为空时,top 指针 指向 null。链栈不存在栈满的情况,除非计算机内存不足。

入栈

链栈入栈操作步骤大致如下:

  1. 生成新的 节点 s
  2. 将当前栈顶元素赋值给 节点 s 的后继。
  3. 将栈顶指针指向 节点 s
  4. 栈元素数量 +1。

操作示意图如下:

image.png

以下是具体实现:

// 将数据 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++
  }

出栈

链栈出栈操作步骤大致如下:

  1. 判断栈是否为空,若为空,报错。
  2. 获取栈顶节点 s,用于后续返回。
  3. 将栈顶指针向下移动。
  4. 栈元素数量 -1。

操作示意图如下:

image.png

以下是具体实现:

// 删除栈顶元素
  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 引擎的调度流程如下:

  1. 在执行代码前,js 引擎会首先创建一个全局上下文,包括所有声明的变量及函数,此时变量 a 的值还是 undefined

image.png

2. 接下来执行 a 的赋值代码,因为都是在全局上下文执行的操作,所以调用栈没有变化。然后执行 sum 函数,js 引擎对于函数会执行以下处理:

  • 从全局上下文中取出 sum 函数的代码;
  • 对 sum 函数的代码进行编译,创建该函数的执行上下文和可执行代码,并将执行上下文压入栈中。

image.png

  1. 执行 sum 函数,返回结果,并将其执行上下文从栈中弹出。
  2. 调用栈回到第一步的状态,只剩下全局上下文,执行完毕。

可见,调用栈主要用来 管理函数执行上下文,可以跟踪每个函数调用时的执行状态,包括局部变量、参数、返回值等。并通过 后进先出(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 函数就是递归函数。

结合代码,我们梳理下递归的执行过程:

  1. 函数调用:递归开始时,函数被调用并进入调用栈。
  2. 条件检查:函数内部检查是否满足递归终止条件,对应代码中的 i < 2 判断。
  3. 递归调用:如果不满足终止条件,函数再次调用自己,创建新的执行上下文并入栈。
  4. 达到基准情况:当满足终止条件时,递归开始回退。
  5. 执行上下文出栈:每次递归返回时,当前的执行上下文出栈,并将结果传递给上层。
  6. 最终返回:当所有递归调用都完成后,最终结果返回给最初的调用者。

可见,递归就是函数上下文不断入栈、出栈的过程。通过使用递归,可以使程序结构更简洁,更容易理解。

不过递归虽好,也不能滥用。使用时需要注意以下两点:

  • 递归函数必须有一个或多个明确的终止条件,以避免无限递归导致栈溢出,如上面代码中 i < 2 的判断,就是用来终止递归的。
  • 由于递归会不断将新的上下文压入调用栈,在大量递归调用的情况下,可能会因为占用太多调用栈空间而导致性能问题。

结语

本文重点介绍了一个基础而重要的数据结构 —— ,并详细探讨了它的两种实现方式 - 线性存储结构链式存储结构 及其应用场景,旨在帮助小伙伴快速掌握栈结构。

如果您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。

文章转自:https://juejin.cn/post/7374337202653102121

  • 14
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值