js 数据结构 - 栈

1,322 阅读8分钟

这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战

前言

栈是一种线性表结构,基本上它只有两个操作:入栈和出栈。入栈(push)的意思是,将数据作为栈帧压入栈;出栈(pop)的意思是,去除栈顶的栈帧。

入栈流程:

  • 先将“数据1”入栈,栈里面就有了“数据1”,这个时候栈底和栈顶都是“数据1”
  • 再把“数据2”入栈,栈里面就有了“数据2”,这个时候栈底就是“数据1”,栈顶就是“数据2”

入栈.png

出栈流程:

  • 假设栈内已经入栈了3个数据,现在出栈了栈顶的“数据3”,栈内的数据就剩下了“数据1”和“数据2”,“数据2”变为新的栈顶。

出栈.png

从上面的入栈流程和出栈流程,我们可以找到一个规律:越早入栈的数据,越晚出栈;越晚入栈的数据,越早出栈。 这就是栈的特点:“先进后出”(LIFO,last in first out),先进入的数据后出去。

栈有两种实现,一种是基于数组,另一种是基于链表。使用数组实现的栈,我们称为“顺序栈”;使用链表实现的栈,我们称为“链式栈”。

顺序栈

顺序栈就是使用数组存储数据的栈。

由于 js 实现顺序栈过于简单,直接贴出顺序栈的代码。

class ArrayStack {
  #arr = []
  #size = 0
  size() {
    return this.#size
  }
  // 入栈
  push(item) {
    if (!item) {
      return false
    }
    this.#arr.push(item)
    this.#size++
    return true
  }
  // 出栈
  pop() {
    var temp = this.#arr.pop()
    if (temp) {
      this.#size--
    }
    return temp
  }
  print() {
    for (var item of this.#arr) {
      console.log(item)
    }
    console.log('栈的长度:', this.#size)
  }
}

可以看到使用 js 实现“顺序栈”,实在太简单了,因为 js 的数组原本就有 pushpop 方法,js 数组本身就是一个栈了。

我们都知道 js 数组的长度是可变的,但是在其他语言里,数组的长度基本是不可变的,如:java 的数组。因此,如果我们要更加深入“顺序栈”,仅从 js 的角度去看“顺序栈”是不够的,从其他语言的角度去了解“顺序栈”会更加好。

下面就用 js 多年失散的亲哥哥 java 来重新介绍“顺序栈”(其他语言,我不会,哈哈哈)。

java 与 js 很相似,看懂代码应该是没问题的。

在 java 中,数组一旦成功实例化,后面就无法改变它的长度,因此我们在创建“顺序栈”实例时,需要初始化栈的空间大小。

java 代码

/**
 * 顺序栈
 */
public class ArrayStack {
    /**
     * 使用数组存储数据
     */
    private Integer[] items;
    /**
     * 栈里的数据个数(栈帧个数)
     */
    private int size;
    /**
     * 栈的大小(即数据空间大小)
     */
    private int space;

    /**
     * 初始化大小
     */
     ArrayStack(int n) throws Exception {
        if (n <= 0) {
            throw new Exception("栈的空间大小不应该小于或等于0");
        }
        items = new Integer[n];
        this.space = n;
        this.size = 0;
    }

    /**
     * 入栈
     * @param data 入栈数据
     * @return true: 入栈成功,false: 入栈失败
     */
    public boolean push(Integer data) {
        if (space == size) {
            return false;
        }
        items[size] = data;
        size++;
        return true;
    }

    /**
     * 出栈
     * @return null: 没有数据,出栈失败
     */
    public Integer pop() {
        if (size == 0) {
            return null;
        }
        Integer temp = items[size -1];
        items[size -1] = null;
        size--;
        return temp;
    }

    /**
     * 获取栈帧数量
     * @return
     */
    public int getSize() {
        return size;
    }

    /**
     * 获取栈的空间大小
     * @return
     */
    public int getSpace() {
        return space;
    }
}

我们可以看到上面的 java 代码比 js 实现的代码还多了一个 space 属性,用来表示栈空间大小(或叫长度上限),且入栈时,一旦遇到栈满的情况,就会入栈失败。

通常,我们更希望栈的空间是无限大的,防止入栈出错的情况,那么如何在类似 java 的语言里去实现一个“无限大”的顺序栈呢?

我们这里可以引入一个概念:动态扩容。

动态扩容的意思是:当容器满时且容器不可自动变长,若有新数据加入,则先申请一个新的容器,新的容器长度比原来容器的长度大一定的倍数(或者按照某些公式去扩容,通常代码底层实现都是 1.5 倍,因此下面的代码也用 1.5 倍),再将旧容器的数据依次放入新容器,新容器代替旧容器使用,再将新数据放入到新容器里。

那么,这里的顺序栈的数组是不是也可以改为可“动态扩容”的数组呢?

这里就衍生出一个“动态扩容顺序栈”。

java 代码

/**
 * 动态扩容顺序栈
 */
public class DynamicArrayStack {

    /**
     * 使用数组存储数据
     */
    private Integer[] items;
    /**
     * 栈里的数据个数(栈帧个数)
     */
    private int size;
    /**
     * 栈的大小(即数据空间大小)
     */
    private int space;

    /**
     * 初始化大小
     */
     DynamicArrayStack(int n) throws Exception {
        if (n <= 0) {
            throw new Exception("栈的空间大小不应该小于或等于0");
        }
        items = new Integer[n];
        this.space = n;
        this.size = 0;
    }

    /**
     * 入栈
     * @param data 入栈数据
     * @return true: 入栈成功,false: 入栈失败
     */
    public boolean push(Integer data) {
        // 如果栈空间满了,则重新申请一个新的空间(为原来的空间的1.5倍)
        if (space == size) {
            space = (int)(space * 1.5);
            Integer[] temp = new Integer[space];
            // 可以用 for 循环解决,也可以用 System.arraycopy 浅拷贝来解决,无论哪种方法,都避免不了时间复杂度变得更高,都为 O(n)
            System.arraycopy(items, 0, temp, 0, size);
            // 更换引用所指向的内存地址
            items = temp;
        }
        items[size] = data;
        size++;
        return true;
    }

    /**
     * 出栈
     * @return null: 没有数据,出栈失败
     */
    public Integer pop() {
        if (size == 0) {
            return null;
        }
        Integer temp = items[size -1];
        items[size -1] = null;
        size--;
        return temp;
    }

    /**
     * 获取栈帧数量
     * @return
     */
    public int getSize() {
        return size;
    }

    /**
     * 获取栈的空间大小
     * @return
     */
    public int getSpace() {
        return space;
    }

}

我们可以看到只是入栈的操作有了一些小改变,当栈满时,重新申请一个新的数组,新的数组的空间为原来的数组空间的 1.5倍,把所有数据移入到新的数组,新数组代替旧数组。我们也可以看到这种“搬移数据操作”会令“入栈操作”的最坏时间复杂度 O(1) 上升为 O(n)。

因此,像 java 这样的语言,在初始化栈时,务必考虑清楚应该设置多少的栈空间,防止出现“扩容”操作。

事实上 js 的数组也有申请空间大小的设定,如下:

var arr = new Array(2) // 申请带有两个成员的数组,每个成员都为空值

但是即使如此,数组的空间也不会有上限。

arr[10] = 1 // 不会报错

js 数组的扩容操作都是引擎内部完成的,无需我们操作。

但 js “数组”里面是大有学问的,js 的“数组”和传统的数组是有一点区别的,想了解更多有关 js “数组”,建议看下这篇别人写的一篇文章 “探究JS V8引擎下的“数组”底层实现 (qq.com)”。

链式栈

链式栈就是使用链表存储数据的栈。

链式栈的实现也很简单,下面用了双链表的结构实现,因为双链表拥有前驱指针,方便找到上一个结点。在链表里,头结点相当于栈底,尾结点相当于栈顶。每次入栈时,新数据作为新尾结点连接旧尾结点;每次出栈时,尾结点移除,原来尾结点的前驱结点作为新尾结点。

如果不熟悉链表,可以看下我以前写的文章“js 数据结构 - 链表 (juejin.cn)”。

js 代码

class Node {
  constructor(data, prev, next) {
    this.data = data
    this.prev = (prev === undefined ? null : prev)
    this.next = (next === undefined ? null : next)
  }
}

class LinkedStack {
  #head = null
  #tail = null
  #size = 0
  push(item) {
    if (!item) {
      return false
    }
    if (!this.#head) {
      this.#head = new Node(item)
      this.#tail = this.#head
    } else {
      this.#tail.next = new Node(item)
      this.#tail.next.prev = this.#tail
      this.#tail = this.#tail.next
    }
    this.#size++
    return true
  }
  pop() {
    var node
    if (this.#size === 0) {
      return
    } else if (this.#size === 1) {
      node = this.#head
      this.#head = null
      this.#tail = null
    } else {
      node = this.#tail
      this.#tail = this.#tail.prev
      this.#tail.next = null
    }
    this.#size--
    return node
  }
  print() {
    var node = this.#head
    while(node) {
      console.log(node.data)
      node = node.next
    }
    console.log('栈的长度:', this.#size)
  }
}

栈的应用

栈的最经典应用就是实现浏览器的前进后退功能。

首先浏览器里要有两个栈,假设这两个栈分别叫“栈A”和“栈B”,浏览器当前显示的页面始终指向栈A的栈顶数据。

image.png

当浏览器浏览一个网页时,就入栈到“栈A”,假设现在先访问了“网页1”,再访问“网页2”,最后访问“网页3”,那么“栈A”就有“网页1”、“网页2”、“网页3”的记录,那么当前浏览器显示的页面就是“栈A”的栈顶“网页3”。

image.png

当浏览器后退时,“栈A”就出栈且出栈的数据放入“栈B”,这里假设后退了两次,那么“网页3”、“网页2”依次入栈到“栈B”,当前浏览器显示的页面就是“栈A”的栈顶“网页1”。

image.png

如果浏览器这时前进一次,“栈B”就出栈且出栈的“网页2”放入“栈A”,浏览器显示的页面由于始终都指向“栈A”的栈顶,因此这时浏览器显示的页面是“网页2”。

image.png

当然栈的应用不单止是浏览器的前进后退功能,主要是“先进后出”结构的问题,都可以尝试用栈来解决。

参考

  1. 探究JS V8引擎下的“数组”底层实现 (qq.com)

js 数据结构系列文章

  1. js 数据结构 - 链表 (juejin.cn)