JavaScript 数据结构进阶:从数组到链表,彻底搞懂“栈”

38 阅读4分钟

在计算机科学中,栈(Stack) 是一种非常基础且重要的数据结构。它是遵循后进先出(LIFO, Last In First Out)原则的线性数据结构。

本文将深入 JavaScript 语言特性,从最简单的数组实现出发,进阶到 ES6 类的封装,再到底层链表的实现,最后通过一道经典的算法题来巩固理解。

1. 原生数组:开箱即用的“栈”

在 JavaScript 中,数组(Array)本身就是一个非常强大的线性数据结构,它天然支持栈的操作。

基础操作

我们可以利用数组的 pushpop 方法完全模拟栈的行为:

  • 入栈(Push) :使用 push() 在数组尾部插入元素。
  • 出栈(Pop) :使用 pop() 在数组尾部删除元素。
  • 查看栈顶(Peek) :访问 length - 1 的索引。
// 简单的数组栈实现
const stack = [];

// 入栈
stack.push(1); 

// 访问栈顶
const peek = stack[stack.length - 1]; 

// 出栈
const pop = stack.pop(); 

// 判空
const isEmpty = stack.length === 0; 

虽然数组还提供了 unshift(头部插入)和 shift(头部删除),但为了保持栈的 LIFO 特性并保证性能(头部操作通常需要移动所有元素),我们通常只操作数组的尾部。

2. 进阶封装:ES6 Class 与私有属性

虽然直接使用数组很方便,但在复杂的工程中,我们通常需要封装(Encapsulation) 。ES6 的 Class 特性为我们提供了更好的模板。

为什么需要封装?

  1. 安全性:使用 ES6 的 # 私有属性语法,可以保护内部数据不被外部随意修改。
  2. 语义化:通过定义 ADT(抽象数据类型),明确了 pushpoppeek 等标准操作。

基于数组的类实现 (ArrayStack)

class ArrayStack {
    #stack; // 私有属性,保护内部数组
    
    constructor() {
        this.#stack = []; 
    }

    // 使用 getter 语法,调用时无需括号:stack.size
    get size() {
        return this.#stack.length; 
    }

    isEmpty() {
        return this.size === 0; 
    }

    push(num) {
        this.#stack.push(num); 
    }

    pop() {
        if (this.isEmpty()) throw new Error('栈为空');
        return this.#stack.pop(); 
    }

    peek() {
        if (this.isEmpty()) throw new Error("栈为空");
        return this.#stack[this.size - 1]; 
    }
}

注意:在类中使用 get 关键字定义的属性,访问时像普通属性一样(如 this.size),不需要加括号。

3. 深入底层:基于链表的实现

虽然数组实现简单且高效,但在某些场景下,链表(Linked List) 是更优秀的选择。

链表 vs 数组:性能大比拼

根据分析:

  • 数组

    • 优点:入栈出栈在预分配内存中进行,通常是 O(1)。
    • 缺点:当超出容量触发扩容时,需要复制所有元素,时间复杂度瞬间变为 O(n)。
  • 链表

    • 优点:扩容非常灵活,永远是 O(1),没有整体复制的开销,性能表现更稳定。
    • 缺点:节点实例化(new ListNode)有一定开销,且每个节点需要额外存储指针,空间占用相对较大。

链表栈的代码实现

我们需要先定义节点 ListNode,利用 next 指针实现离散存储。

class ListNode {
    constructor(val) {
        this.val = val;
        this.next = null;
    }
}

class LinkedListStack {
    #stackPeek; // 栈顶指针
    #size = 0;  // 手动维护大小

    constructor() {
        this.#stackPeek = null; 
    }

    push(num) {
        const node = new ListNode(num);
        node.next = this.#stackPeek; // 新节点指向原栈顶
        this.#stackPeek = node;      // 更新栈顶指针
        this.#size++;                
    }

    pop() {
        if (this.isEmpty()) throw new Error('栈为空');
        const num = this.#stackPeek.val;
        this.#stackPeek = this.#stackPeek.next; // 指针后移
        this.#size--;
        return num; 
    }
    
    peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
    }

    get size() {
        return this.#size; 
    }

    isEmpty() {
        return this.#size === 0; 
    }
}

4. 实战应用:有效括号问题

掌握了栈的原理,我们来看一道经典的算法题:判断括号字符串是否有效(如 ()[]{} 为真,(] 为假)。

这道题是栈的典型应用场景:利用栈维护左括号,遇到右括号时进行匹配

解题思路

  1. 建立一个 Map 维护左括号到右括号的映射。

  2. 遍历字符串:

    • 如果是左括号,压入对应的期望右括号进栈。
    • 如果是右括号,弹出栈顶元素对比。如果不匹配或栈为空,则无效。
  3. 最后检查栈是否为空(防止残留左括号)。

const isValid = function(s) {
    if (s.length === 0) return true;
    
    // 映射关系
    const leftToRight = {
        "(": ")",
        "[": "]",
        "{": "}"
    }; //

    const stack = [];
    
    for(let i = 0; i < s.length; i++) {
        const ch = s[i];
        // 如果是左括号,将对应的右括号入栈
        if(ch === "(" || ch === "[" || ch === "{") {
            stack.push(leftToRight[ch]); 
        } else {
            // 如果是右括号,检查栈顶是否匹配
            if(stack.length === 0 || stack.pop() !== ch) {
                return false; 
            }
        }
    }
    return stack.length === 0; 
}

总结

  • 概念:栈是 LIFO(后进先出)的数据结构。

  • 实现

    • 数组:适合快速开发,平均效率高,但有扩容风险。
    • 链表:适合对性能稳定性要求高、扩容频繁的场景,但空间消耗稍大。
  • 语法:ES6 的 class#privateget 访问器让我们的代码更规范、更安全。