从数组栈到链表栈:深入理解JavaScript中的栈实现
前言:理解抽象数据结构
在开始学习栈之前,我们需要明白一个重要的概念:链表和栈都不是JavaScript的基本数据类型,它们是一种抽象的线性数据结构。
链表是由一个一个的节点连接起来的,节点的作用就是存储数据和指针。每个节点包括两个部分:数据域和指针域。数据域存储实际的数据,指针域则指向下一个节点。
举个例子:
节点1[数据1,指针1=>(节点2[数据2,指针2=>节点3[数据3,指针3=>......]])]
我们要用链表来实现栈的数据结构,说白了就是模仿栈的特性。栈有什么特性?先进后出,后进先出。今天我们就从基础开始,一步步理解这两种不同的栈实现方式。
什么是栈?
栈就像是一摞盘子,你只能从最上面取放盘子。最后放上去的盘子总是最先被取走,这就是后进先出的特性。
栈的基本操作:
push()- 入栈,添加元素到栈顶pop()- 出栈,移除栈顶元素peek()- 查看栈顶元素isEmpty()- 判断栈是否为空size()- 获取栈的大小
数组栈的实现
数组栈利用JavaScript内置的数组来实现栈的功能,代码简洁直观:
class ArrayStack {
constructor() {
this.stack = [];
// 初始化一个空数组来存储栈元素
// console.log("构造函数的this指向:", this); // this指向创建的实例对象
}
// 入栈 - 直接使用数组的push方法
push(element) {
this.stack.push(element);
// console.log("push方法的this指向:", this); // this指向同一个实例
}
// 出栈 - 使用数组的pop方法,遵循后进先出
pop() {
if (this.isEmpty()) {
return undefined;
}
// console.log("pop方法的this指向:", this);
return this.stack.pop(); // 移除并返回最后一个元素
}
// 查看栈顶元素 - 返回最后一个元素但不移除
peek() {
if (this.isEmpty()) {
return undefined;
}
// console.log("peek方法的this指向:", this);
return this.stack[this.stack.length - 1]; // 直接访问数组最后一个元素
}
// 判断是否为空
isEmpty() {
// console.log("isEmpty方法的this指向:", this);
return this.stack.length === 0;
}
// 获取栈大小
size() {
// console.log("size方法的this指向:", this);
return this.stack.length; // 直接返回数组长度
}
// 清空栈
clear() {
// console.log("clear方法的this指向:", this);
this.stack = []; // 重新赋值为空数组
}
}
// 使用示例
const stack = new ArrayStack();
console.log(stack); // 查看初始化的栈
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop()); // 3
console.log(stack.peek()); // 2
console.log(stack.size()); // 2
console.log(stack.isEmpty()); // false
数组栈的优点:
- 实现简单,代码直观
- 利用数组原生方法,性能较好
- 易于理解和维护
链表栈的实现
现在让我们进入更有趣的部分——用链表来实现栈。首先,我们需要理解节点的概念。
节点类(Node) - 构建链表的基本单元
class Node {
constructor(data) {
// 节点的作用就是存数据和指针,因为链表和栈都不是js的基本数据类型,它们是一种抽象的线性数据结构
// 链表是由一个一个的节点连接起来的,每个节点包括两个变量:数据域和指针域
// 数据域表示当前节点存储的数据,而指针则指向下一个节点
// 比如:节点1[数据1,指针1=>(节点2[数据2,指针2=>......])]
this.data = data; // 数据域 - 存储实际数据
this.next = null; // 指针域 - 指向下一个节点,初始为null
// console.log("Node的this指向:", this); // this指向新创建的节点实例
}
}
链表栈类(Linked) - 用链表模拟栈的特性
class Linked {
// 创建好了节点类,我们开始创建栈类,一个一个的节点相连,就组成了一个栈(链表栈)
// 我们先创建一个构造函数来对栈进行初始化
constructor() {
// 设置栈顶元素和大小,初始化栈,没有元素,大小等于0
this.top = null; // 栈顶元素,初始为null
this._size = 0; // 栈大小,初始为0
// console.log("Linked的this指向:", this); // this指向Linked的实例
}
push(num) {
// 我要在链表里添加数据,模仿栈的数据结构
// 首先,你得先实例化一个节点
// 这里new Node(num)的过程:
// 1. 先创建一个空对象 obj = {}
// 2. this会指向这个obj对象,this绑定到新对象上
// 3. 执行构造函数,设置data和next属性
const node = new Node(num);
// 我们要在栈添加元素,就是在连接节点
// 如何把两个不相关的节点连接起来?我们只需要把第一个节点的指针,指向我们要连接的节点上面
// 然后,更新栈顶的元素值(把要连接的节点的数据赋值给栈顶元素)
// 新节点的next指向原来的栈顶
node.next = this.top;
// 更新栈顶为新节点
this.top = node;
this._size++; // 栈大小加1
}
isEmpty() {
// 判断栈是否为空,只需要检查_size是否为0
if (this._size === 0) {
return true;
} else {
// console.log(this._size);
return false;
}
}
pop() {
// 弹出栈顶的元素,就是把最外层的节点移除
// 如何移除?
// 1. 先保存栈顶的元素,用来返回
// 2. 直接将当前栈顶指向下一个节点
// 注意:我们这里不需要移动指针,因为当我们添加数据时,指针已经存在了
if (this._size === 0) {
throw new Error("栈空,无法移除栈顶元素");
} else {
const num = this.top.data; // 保存栈顶数据
this.top = this.top.next; // 栈顶指向下一个节点
this._size--; // 栈大小减1
return num; // 返回被移除的数据
}
}
// 使用getter方法,可以通过stack.size直接访问,不需要括号
get size() {
return this._size;
}
peek() {
// 查看栈顶元素,但不移除
if (this._size !== 0) {
return this.top.data; // 直接返回栈顶节点的数据
} else {
throw new Error("栈空");
}
}
}
// 使用示例
const stack1 = new Linked();
stack1.push(1);
stack1.push(2);
stack1.push(3);
console.log(stack1.pop()); // 3
console.log(stack1); // 查看栈的当前状态
console.log(stack1.isEmpty()); // false
console.log(stack1.size); // 2 - 注意这里不需要括号
console.log(stack1.peek()); // 2
链表栈的工作原理详解
让我们通过详细的步骤理解链表栈的工作过程:
初始状态:
栈结构:
top → null
_size = 0
执行 stack1.push(1):
创建节点1:
Node { data: 1, next: null }
栈变化:
top → [1, next=null]
_size = 1
执行 stack1.push(2):
创建节点2:
Node { data: 2, next: 指向节点1 }
栈变化:
top → [2, next] → [1, next=null]
_size = 2
执行 stack1.push(3):
创建节点3:
Node { data: 3, next: 指向节点2 }
栈变化:
top → [3, next] → [2, next] → [1, next=null]
_size = 3
执行 stack1.pop():
移除节点3:
top从节点3变为节点2
栈变化:
top → [2, next] → [1, next=null]
返回: 3
_size = 2
数组栈 vs 链表栈 - 深度对比
性能对比分析
| 操作 | 数组栈 | 链表栈 | 说明 |
|---|---|---|---|
| 入栈(push) | O(1) 平均 | O(1) | 数组栈在需要扩容时会有O(n)的时间 |
| 出栈(pop) | O(1) | O(1) | 两者都是常数时间 |
| 查看栈顶(peek) | O(1) | O(1) | 直接访问,效率相同 |
| 空间复杂度 | O(n) | O(n) | 但链表有额外的指针开销 |
内存分配机制
数组栈:
- 连续内存分配
- 可能需要动态扩容(当数组容量不足时)
- 内存利用率较高
链表栈:
- 非连续内存分配
- 每个节点单独分配内存
- 有额外的指针内存开销
- 不需要预分配空间
this关键字的深入理解
在我的代码注释中多次提到了this的指向问题,这里详细解释一下:
constructor() {
// 这里的this指向新创建的Linked实例
this.top = null; // 实例属性top
this._size = 0; // 实例属性_size
console.log("Linked的this指向:", this);
}
push(num) {
// 这里的this同样指向Linked的实例
const node = new Node(num);
// Node构造函数中的this指向新创建的Node实例
node.next = this.top; // 这个this指向Linked实例
this.top = node; // 这个this也指向Linked实例
this._size++;
}
选择数组栈的场景:
- 需要随机访问元素
- 内存使用效率很重要
- 追求实现简单性和可读性
- 栈的大小相对稳定
选择链表栈的场景:
- 栈大小变化频繁
- 想要避免内存重新分配的开销
- 学习数据结构底层原理
- 内存碎片化不是主要问题
实际应用场景
栈在编程中有广泛的应用,理解这两种实现有助于我们在不同场景做出合适的选择:
- 函数调用栈 - 通常使用系统栈,但理解原理很重要
- 表达式求值 - 中缀表达式转后缀表达式
- 括号匹配 - 检查代码中的括号是否成对
- 浏览器历史 - 前进后退功能
- 撤销操作 - 文本编辑器的撤销功能
- 递归算法 - 递归本质就是栈的应用
总结与思考
通过亲手实现数组栈和链表栈,并结合详细的代码注释,我们可以得出以下结论:
数组栈的优势:
- 代码简洁,易于理解和维护
- 利用语言内置功能,性能稳定
- 适合大多数日常应用场景
链表栈的价值:
- 深入理解数据结构的底层原理
- 学习指针和内存管理的概念
- 为学习更复杂的数据结构打下基础
我的学习心得: 在编写这些注释的过程中:
- 理解
this指向是掌握JavaScript面向对象编程的关键 - 链表栈的实现让我真正理解了"节点"和"指针"的概念
- 通过对比两种实现,我明白了不同数据结构适用的场景
无论选择哪种实现方式,理解栈的后进先出特性才是最重要的。
希望这篇结合了我学习注释的文章能帮助你更好地理解栈数据结构在JavaScript中的实现!