这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战
前言
栈是一种线性表结构,基本上它只有两个操作:入栈和出栈。入栈(push)的意思是,将数据作为栈帧压入栈;出栈(pop)的意思是,去除栈顶的栈帧。
入栈流程:
- 先将“数据1”入栈,栈里面就有了“数据1”,这个时候栈底和栈顶都是“数据1”
- 再把“数据2”入栈,栈里面就有了“数据2”,这个时候栈底就是“数据1”,栈顶就是“数据2”
出栈流程:
- 假设栈内已经入栈了3个数据,现在出栈了栈顶的“数据3”,栈内的数据就剩下了“数据1”和“数据2”,“数据2”变为新的栈顶。
从上面的入栈流程和出栈流程,我们可以找到一个规律:越早入栈的数据,越晚出栈;越晚入栈的数据,越早出栈。 这就是栈的特点:“先进后出”(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 的数组原本就有 push 和 pop 方法,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的栈顶数据。
当浏览器浏览一个网页时,就入栈到“栈A”,假设现在先访问了“网页1”,再访问“网页2”,最后访问“网页3”,那么“栈A”就有“网页1”、“网页2”、“网页3”的记录,那么当前浏览器显示的页面就是“栈A”的栈顶“网页3”。
当浏览器后退时,“栈A”就出栈且出栈的数据放入“栈B”,这里假设后退了两次,那么“网页3”、“网页2”依次入栈到“栈B”,当前浏览器显示的页面就是“栈A”的栈顶“网页1”。
如果浏览器这时前进一次,“栈B”就出栈且出栈的“网页2”放入“栈A”,浏览器显示的页面由于始终都指向“栈A”的栈顶,因此这时浏览器显示的页面是“网页2”。
当然栈的应用不单止是浏览器的前进后退功能,主要是“先进后出”结构的问题,都可以尝试用栈来解决。