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

946 阅读15分钟

01. 什么是栈?

  1. 又名堆栈。

  2. "特殊" 的线性存储结构。

  3. 一种高效的数据结构。

  4. 受限操作。限定它仅能在一端进行插入或删除操作。可进行操作的一端称为栈顶,不可操作的一端称为栈底。

  5. 增加元素的操作又称为进栈、入栈、或压栈;意思是把元素添加到栈顶的位置。

  6. 删除元素的操作又称为是出栈、退栈;意思是把元素从栈顶移除。使的于其相邻的元素称为新的栈顶元素。

  7. 栈的基本操作除了在栈顶进行插入和删除外,还有栈的初始化,判空以及取栈顶元素。

栈是一种只能从一端存取数据且遵循 "先进后出"(LIFO)原则的线性存储结构。

02. 为什么会产生栈?

你是否有一种疑惑,相比于我们之前的数组和链表,栈的出现,所带来的只有限制,并没有任何明显的优势。从功能上来说,数组和链表完全是可以替代栈的。

原因:

  1. 特定的数据结构其实是对特定场景的一种抽象,用来处理某些特定场景的问题。

  2. 数组和链表,操作灵活,但是比较不可控。

所以:如果某一组数据,符合一端插入和删除数据,并且符合先入后出的原则,此时应该首选栈。

03. 栈的具体实现 

1. 顺序栈:顺序存储结构

class Stack {
  constructor() {
    this.data = [] // 使用数组来实现栈
    this.top = 0 // 记录栈顶位置
  }

  push(item) {
    this.data[this.top++] = item
  }

  pop() {
    // 栈为空
    if (this.top === 0) {
      return null
    }
    // 返回下标为top-1 的数组元素,并将栈中元素个数减一
    return this.data[--this.top]
  }

  clear() {
    this.top = 0
  }

  get length() {
    return this.top
  }
}

2. 链栈:链式存储结构

class StackNode {
  constructor(data) {
    this.data = data
    this.next = false
  }
  
  getData = () => this.data
  setData = (data) => (this.data = data)
  getNext = () => this.next
  setNext = (next) => (this.next = next)
}

class Stack {
  constructor() {
    this.head = false
    this.tail = false
  }
  empty() {
    return this.head === false
  }
  push(data) {
    let temp = new StackNode(data)
    if (!this.head) {
      this.head = this.tail = temp
    } else {
      temp.setNext(this.head)
      this.head = temp
    }
  }
  pop() {
    if (this.empty()) return false
    let data = this.head
    this.head = this.head.getNext()
    return data.getData()
  }
}

04. 时间/空间复杂度

不管是顺序栈还是链栈,在入栈和出栈的过程中,都是在操作栈顶元素,只需要一两个临时变量,所以空间复杂度为 O(1);时间复杂度为 O(1)

空间复杂度是指除了原本数据存储空间外,执行操作所需要的存储空间。

05. 应用

1. 表达式求值

  1. 案例内容: 3 + 6 * 2 - 6 = ?

  2. 方法:通过两个栈来完成,其中一个保存操作数的栈,另一个是保存运算符的栈。

  3. 具体过程:我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

2. 括号匹配

给定一个只包括 '('')''{''}''['']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:
    左括号必须用相同类型的右括号闭合。
    左括号必须以正确的顺序闭合。

()[]{} ==> true
([)]   ==> false
([])   ==> true

图解:

解法:

function validate(str: string): boolean {
  if (str.length % 2 !== 0) return false;
  const stack = [];

  for (let item of str) {
    switch (item) {
      case "{":
      case "[":
      case "(":
        stack.push(item);
        break;
      case "}":
        if (stack.pop() !== "{") return false;
        break;
      case "]":
        if (stack.pop() !== "[") return false;
        break;
      case ")":
        if (stack.pop() !== "(") return false;
        break;
    }
  }

  return !stack.length;
}

console.log('TEST_R', validate('') === true);
console.log('TEST_R', validate('[])') === false);
console.log('TEST_R', validate('[](){}') === true);

3. 函数调用 —— JS 执行上下文栈

几个概念:

作用域:变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。

执行上下文(Execution context,EC):当前 JavaScript 代码被解析和执行时所在环境的抽象概念。每当 JS 代码执行的时候,都是在执行上下文中执行的。Javascript 执行上下文

执行上下文分类:

1. 全局执行上下文

2. 函数执行上下文

3. Eval 函数执行上下文

执行上下文栈(Execution context stack,ECS):当代码开始执行前会形成一个栈内存(执行上下文栈),用于存储在代码执行期间创建的所有执行上下文。代码执行过程中,会进入不同的执行上下文,执行上下文创建好后就会进入栈中,执行完毕后会退出栈。

🌰:【Debug演示】

let a = "global var";

function initPage() {
  console.log("on start");
  initState();
  console.log("end start");
}

function initState() {
  console.log("init state");
}

initPage();

console.log("Inside Global Execution Context");

当上述代码在浏览器中加载时,JavaScript 引擎会创建一个全局执行上下文并且将它推入当前的执行栈。当调用 initPage() 函数时,JavaScript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。

当在 initPage() 函数中调用 initState() 函数时,Javascript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。当 initState() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文,即 initPage() 函数的执行上下文。

当 initPage() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到全局执行上下文。一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。

更严格的定义是:

执行上下文栈 ===> 调用栈 Call Stack

执行上下文 ===> 调用帧 Call Frame

Koa 中间件的洋葱模型实现原理,也是一个典型的实践案例。

github.com/koajs/koa/b…

在开发中,如何利用好调用栈?

  • 浏览器中可调试查看当前函数执行的调用栈(调用关系),用来调试代码。

  • 根据收集来的异常错误堆栈来定位报错点。

4. 浏览器历史堆栈

浏览器历史堆栈管理案例:

当你依次访问完一串页面 A-B-C之后,点击浏览器的后退按钮,就可以查看之前浏览过的页面 B 和 B。当你后退到页面 A,点击前进按钮,就可以重新查看页面 B 和 C。但是,如果你后退到页面 B 后,点击了新的页面 D,那就无法再通过前进、后退功能查看页面 C 了。

使用栈来完成浏览页面的历史记录存储,前进后退都重新触发浏览器的加载。

5. Vue-stack-router 栈式路由

vue-stack-router 实现了前端路由的栈式管理,适配 hybrid 场景的使用。

以下内容是源码中摘抄的一个方法。

  private routeStack: Array<IRouteAndConfig<Component>> = []; // 路由堆栈
  
  // https://github.com/luojilab/vue-stack-router/blob/65d3719a1e5b0c7d83c813aef54517981fbd756a/src/lib/Router.ts#L300
  private updateRouteRecords(type: RouteActionType, routeInfo: IRouteAndConfig<Component>, transition?: unknown): void {
    switch (type) {
      case RouteActionType.PUSH:
        this.routeStack.push(routeInfo);
        this.componentChange(type, transition);
        break;
      case RouteActionType.REPLACE:
        const preRoute = this.routeStack.pop();
        this.routeStack.push(routeInfo);
        this.componentChange(type, transition);
        if (preRoute) {
          this.emit(RouteEventType.DESTROY, [preRoute.route.id]);
        }
        break;
      case RouteActionType.POP:
        const index = this.routeStack.findIndex(i => routeInfo.route.id === i.route.id);
        if (index === -1) {
          this.routeStack = [routeInfo];
          this.componentChange(type, transition);
        } else {
          const destroyedIds = this.routeStack
            .splice(index + 1, this.routeStack.length - index - 1)
            .map(r => r.route.id);
          this.componentChange(type, transition);
          this.emit(RouteEventType.DESTROY, destroyedIds);
        }
        break;
      default:
        this.componentChange(type, transition);
    }
  } 

06. 栈溢出

1. 什么是栈溢出?

调用栈(执行上下文栈)是有大小的,当入栈的调用帧(执行上下文)超过一定数目,JavaScript引擎就会报错,我们把这种错误叫做栈溢出。

导致堆栈溢出的场景,以 JavaScript 长递归导致的堆栈溢出为例来分析:

计算阶乘的🌰:

1
2x1
3x2x1
4x3x2x1
5x4x3x2x1
6x5x4x3x2x1

解法:

// 解法一:使用循环 累积乘法的值
function factorial (number) {
  let result = 1
  while (number > 1) {
    result = result * number * (number - 1)
    number = number - 2 // 循环跨度为2
  }
  return result
}

// 解法二:递归调用
function factorial (number) {
  if (number < 2) {
    return 1
  } else {
    return number * factorial(number - 1)
  }
}

两种方案的对比:

  • 循环性能更高,只会产生一个调用帧,内存占用少。

  • 使用递归,则代码更容易理解,需要产生许多个调用帧,内存占用大。

2. 长递归为什么会容易引发栈溢出?

递归的实质是函数不断的调用自身。在JS里会一直在调用堆栈中产生调用帧(call frame)。每产生一个调用帧就会占据一定的内存。而且 JS 中是单线程,并且只有一个调用堆栈,且堆栈的 size 也是有一定限度的,所以会容易产生栈溢出。

可以使用如下方法来计算调用栈的大小:

function computeMaxCallStackSize () {
  try {
    return 1 + computeMaxCallStackSize()
  } catch (e) {
    // Call stack overflow
    return 1
  }
}
num = computeMaxCallStackSize() // 11385

3. 尾调用优化

1. 尾调用

  1. 概念:某个函数的最后一步是调用另一个函数,这个函数就属于尾调用函数。

  2. 案例:

2. 尾调用优化

  1. 原理:尾调用因为函数最后一步是调用另一个函数,因此调用位置、变量信息、作用域链等就不需要继续存储了,直接使用内层函数的调用记录取代外层函数的调用记录就行。这样每次调用时候就只会产生一个调用记录,极为的节省内存,且永远不会出现栈溢出。

3. 尾递归

  1. 定义:函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

4. 尾递归优化

  1. 原理:尾递归来说,递归且采用尾调用的形式,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。

  2. 案例:

5. 示意图:

6. 其他

  1. 复杂度从 O(n) 降低到了 O(1)。由此可见,尾递归优化对于递归操作来说意义重大。

  2. 尾递归优化是语言规范的一部分,不清楚其他语言支持度如何;在 ES6 以下版本的 JS 中是没有实现尾递归优化的。ES6 中部署了尾递归优化 (Proper Tail Calls / PTC)。

  3. 尾递归优化的支持度:ES6在各大平台上的兼容性

  4. ES6的尾调用优化只在严格模式下开启,正常模式是无效的。

  5. 更多内容 JS 中尾递归 PTC 与 STC

参考文献:

队列

01. 什么是队列?

  1. 线性存储结构

  2. 受限操作。使用队列存取数据元素时,数据元素只能从表的一端进入队列,另一端出队列。

  3. 插入(insert)操作也称作入队(enqueue),新元素始终被添加在队列的末尾。 删除(delete)操作也被称为出队(dequeue)。 你只能移除第一个元素。

  4. 称进入队列的一端为队尾;出队列的一端为队头。

  5. 队列从一端存入数据,另一端调取数据的原则称为“先进先出”原则

队列是遵循先进先出(FIFO,也称为先来先服务)原则的一组有序的项。

02. 队列的具体实现

  • 实现存储队列中元素的数据结构有很多方式,比如:数组、链表、对象等。这里使用一个对象来存元素。

  • 还需要声明一个 count 属性来帮助我们控制队列的大小。此外,由于我们将要从队列前端移除元素,同样需要一个变量 lowestCount 来帮我们追踪第一个元素。

    class Queue { constructor() { this.data = {} // 存储数据 this.lowestCount = 0 // 记录队尾 this.count = 0 // 记录队头 } enqueue(element) { this.data[this.count] = element this.count++ } dequeue() { if (this.isEmpty()) { return undefined } const result = this.data[this.lowestCount] delete this.data[this.lowestCount] this.lowestCount++ return result } peek() { if (this.isEmpty()) { return undefined } return this.data[this.lowestCount] } isEmpty() { return this.count - this.lowestCount === 0 } size() { return this.count - this.lowestCount } clear() { this.data = {} this.count = 0 this.lowestCount = 0 } }

03. 复杂度

同栈类似,入队和出队操作的时间复杂度和空间复杂度均是 O(1)

04. 循环队列

上面的队列模型,是一种简单、低效的队列。

原因:在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。

更有效的方法是使用循环队列。 具体来说,我们可以使用固定大小的数组和两个指针来指示起始位置和结束位置。 目的是重用我们之前提到的被浪费的存储。

// 循环队列
class Queue {
  constructor(len) {
    // 生成同来保存数据长度为K的数据结构
    this.list = new Array(len)
    // 队首指针
    this.front = 0
    // 队尾指针
    this.rear = 0
    // 队列的长度
    this.max = len
  }

  enqueue(element) {
    // 首先判断是否队列为满的状态
    if (this.isFull()) {
      return false
    } else {
      this.list[this.rear] = value
      // 尾指针循环
      this.rear = (this.rear + 1) % this.max
      return true
    }
  }

  dequeue() {
    if (!this.isEmpty()) {
      const result = this.list[this.front]
      this.list[this.front] = ""
      this.front = (this.front + 1) % this.max
      return result
    }
    return false
  }

  getFront() {
    if (this.isEmpty()) {
      return -1
    }
    return this.list[this.front]
  }

  getRear() {
    if (this.isEmpty()) {
      return -1
    }
    let rear = this.rear - 1
    return this.list[rear < 0 ? this.max - 1 : rear]
  }

  isEmpty() {
    return this.front === this.rear && !this.list[this.front]
  }

  isFull() {
    return this.front === this.rear && !!this.list[this.front]
  }
}

05. 双端队列数据结构

双端队列(deque,或称 double-ended queue)是一种允许我们同时从前端和后端添加和移除元素的特殊队列。

双端队列同时遵守了先进先出和后进先出原 则,可以说它是把队列和栈相结合的一种数据结构。

  1. 案例:一个刚买了票的 人如果只是还需要再问一些简单的信息,就可以直接回到队伍的头部。另外,在队伍末尾的人如 果赶时间,他可以直接离开队伍。

  2. 实现

  3. 双端队列相比队列多了两端都可以出入元素,因此普通队列中的获取队列大小、清空队列、队列判空、获取队列中的所有元素这些方法同样存在于双端队列中且实现代码与之相同。

  4. 由于双端队列两端都可以出入元素,那么我们需要实现以下函数:

  • 队首添加元素 addFront

  • 队尾添加元素 addBack

  • 获取队首元素 peekFront

  • 获取队尾元素 peekBack

  • 删除队首元素 removeFront

  • 删除队尾元素 removeBack

    // 双端队列 class DQueue { constructor() { this.items = {} this.lowestCount = 0 // 记录队尾 this.count = 0 // 记录队头 }

    peekFront() { if (this.isEmpty()) { return undefined } return this.items[this.lowestCount] }

    peekBack() { if (this.isEmpty()) { return undefined } return this.items[this.count] }

    isEmpty() { return this.count - this.lowestCount === 0 }

    size() { return this.count - this.lowestCount }

    clear() { this.items = {} this.count = 0 this.lowestCount = 0 }

    // 添加元素到队首 addFront(element) { if (this.isEmpty()) { this.addBack(element) } else if (this.lowestCount > 0) { this.lowestCount-- this.items[this.lowestCount] = element } else { // lowestCount = 0 的情况,整体向后移动 for (let i = this.count; i > 0; i--) { this.items[i] = this.items[i - 1] } this.count++ this.lowestCount = 0 this.items[0] = element // {4} } }

    // 添加元素到队末(同原有入队操作) addBack(element) { this.items[this.count] = element this.count++ }

    // 移除队首元素(同原有出队操作) removeFront() { if (this.isEmpty()) { return undefined } const result = this.items[this.lowestCount] delete this.items[this.lowestCount] this.lowestCount++ return result } // 移除队尾元素(类似出栈操作) removeBack() { if (this.isEmpty()) { return undefined } const result = this.items[this.count] delete this.items[this.count] this.count-- return result } }

06. 应用 —— Javascript 任务队列

1. 认识任务队列

"任务队列" 本质就是队列数据结构,执行的任务都会依次被推入队列中,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,队列中第一位的事件就自动进入主线程执行。

2. Javascript 任务队列出现的原因

Javascript 是单线程,单线程就意味着执行任务需要排队,一个任务结束,另一个任务才能开始。如果前一个任务耗时很长,那么后一个任务就会被阻塞。

耗时长的任务往往不是计算量大的任务,可能是IO任务,此时CPU是空闲的,导致CPU利用效率极低。

所以出现了任务队列。使用主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

3. Javascript 任务队列的使用

Javascript 中使用任务队列的情况有:

  • 浏览器 Event Loop 中需要用到任务队列

  • NodeJS 中的 Event Loop 也需要用到任务队列

  • 其他场景:线程池

以浏览器中的 Event Loop 为例子:

console.log('script start');
setTimeout(function () {
  console.log('setTimeout');
}, 0);

Promise.resolve()
  .then(function () {
    console.log('promise1');
  })
  .then(function () {
    console.log('promise2');
  });

console.log('script end');

// Chrome 执行结果:
// script start
// script end
// promise1
// promise2
// setTimeout

首先复习下事件循环的过程:

  • JavaScript是单线程,单线程任务被分为同步任务和异步任务。

  • 同步任务在调用栈中等待主线程依次执行,遇到异步任务会将其放入指定的任务队列中。

  • 异步任务会在有了结果之后,将回调函数注册到任务队列,等待主线程空闲(调用栈为空),再从任务队列中放入执行栈等待主线程执行。

  • 执行栈在执行完同步任务之后,如果执行栈为空,就会去检查微任务(MicroTask)队列是否为空,如果为空的话,就会去执行宏任务队列(MacroTask)。

  • 否则就会一次性执行完所有的微任务队列。

  • 每次一个宏任务执行完成之后,都会去检查微任务队列是否为空,如果不为空就会按照先进先出的方式执行完微任务队列。然后再执行下一个宏任务,如此循环执行。直到结束。

具体执行过程:

  • 第一步:初始状态

  • 第二步:执行同步任务

  • 第三步:setTimeout callback 入Task 任务队列

  • 第四步:promise callback 入 Microtasks 任务队列

  • 第五步:继续执行同步任务

  • 第六步:同步任务执行完毕,退栈

  • 第七步:首先执行 Microtasks 任务队列中的 微任务 ,回调函数入栈

  • 第八步:promise1执行完毕后,下一个 promise callback 作为一个新的微任务入队

  • 第九步:第一个 promise 回调执行完毕后,回调函数帧出栈,第一个primise任务出队。继续执行第二个微任务。

  • 第十步:第二个微任务执行,其回调函数入栈,log日志打印

  • 第十一:第二个微任务执行完毕后,微任务队列清空,准备执行下一个宏任务队列中的宏任务

  • 第十二:宏任务执行,回调函数入栈

  • 第十三:宏任务执行完毕,回调函数帧出栈,宏任务出队,队列清空,执行完毕。

线程池:

简单查了下,传统的线程池,维护一个共享的任务队列,然后多个线程通过加锁互斥的方式访问该队列,取出任务执行。比如libuv,nginx。

比如下面这种场景:(iget-icon中下一步迭代中的一个环节)

背景:通过一个接口完成资源打包(build)、发包(publish)的一系列操作。

初步方案:

  • 通过 nodejs 的 child_process 子线程来完成。

  • 创建子线程来执行任务,子线程执行完毕后通过微信通知发布者。

  • 当然我们不能来一个请求就创建一个子线程,因为创建太多的线程反而会增加负担。

  • 需要维护一个子线程池(有大小限度),每一个子线程都有自己的任务队列,每来一个任务就会分配到一个子线程中去执行,子线程中的任务首先会进入其任务队列。等待执行。

异步队列:

怎么让异步函数可以顺序执行。方法有很多,callback,promise,generator,async/await等都可以做到这种串行的需求。

比如某种场景下:有多个异步任务,需要挨个执行。

task.then(() => task1.then(() => task2.then(...)))

async () => {
    await task1();
    await task2();
    await task3();
}

// ... 

要是有一个队列就好了,往队列里添加异步任务,执行的时候让队列开始 run 就好了。

const queue = () => {
  const list = []; // 队列
  let index = 0; // 游标

  const next = () => {
    if (index >= list.length - 1) return;

    const cur = list[++index];
    cur(next);
  }

  // 添加任务
  const add = (...fn) => {
    list.push(...fn);
  }

  // 执行
  const run = (...args) => {
    const cur = list[index];
    typeof cur === 'function' && cur(next);
  }

  // 返回一个对象
  return {
    add,
    run,
  }
}

// 模拟的异步任务
const asyncFunc = (x) => {
  return (next) => {
    setTimeout(() => {
      console.log(x);
      next();  // 异步任务完成调用
    }, 1000);
  }
}

const q = queue();
const tasks = '123456'.split('').map(x => asyncFunc(x));

q.add(...tasks);

q.run();

感想与总结

了解完这些我们只是对这两种数据结构有了一定的认识,更重要的还是要去多实践。

虽然我们前端日常开发中遇到需要使用数据结构的场景也不多,但是遇到问题我们还是可以从多角度去思考的,比如利用某种数据结构是否可以方便的解决问题。

平常遇到的业务场景可能都比较简单,需要用到算法的场景不是很多,但是也有(比如:电商里的多规格选择器)。

利用 Map、Set、Array 等的 api 去解决问题,也是我们前端非常重要的功底之一。平时业务场景大多比较简单,我们想把这个功底打扎实,还是得多去力扣做题。