栈的深度解析:概念、实现与典型应用

85 阅读8分钟

栈的深度解析:概念、实现与典型应用

在数据结构的世界里,栈是一种简单却至关重要的线性结构。它遵循"先进后出"的核心原则,就像我们日常生活中堆叠的盘子——最后放上去的盘子,总能最先被取下来。本文将从栈的基本概念出发,带你用JavaScript实现两种不同底层的栈结构,并通过经典算法题巩固应用,全程结合ES6新特性,兼顾实用性与规范性。

一、栈的核心概念与ADT定义

1. 什么是栈?

栈是一种先进后出的线性数据结构,仅允许在结构的一端(称为"栈顶")进行元素的插入(入栈)和删除(出栈)操作,另一端(称为"栈底")则固定不可直接操作。

核心特性:先进后出,即最早入栈的元素最晚出栈,最晚入栈的元素最早出栈。

2. 栈的ADT(抽象数据类型)

抽象数据类型定义了栈的核心操作接口,无论底层如何实现,都应满足以下功能:

操作描述
push(num)入栈:将元素压入栈顶
pop()出栈:移除并返回栈顶元素(栈空时抛错)
peek()查看栈顶元素(栈空时抛错)
isEmpty()判断栈是否为空(返回布尔值)
size获取栈的元素个数(只读属性)
toArray()将栈转换为数组(便于查看所有元素)

二、ES6实现栈:数组 vs 链表

栈的底层实现主要有两种方式:基于数组和基于链表。下面结合ES6的class、私有属性等新特性,分别实现这两种栈,并分析其优缺点。

1. 基于数组的栈实现

数组是JavaScript中内置的线性数据结构,其push()pop()方法天生符合栈的入栈、出栈逻辑(操作数组尾部,时间复杂度O(1)),因此实现起来非常简洁。

class ArrayStack {
  // 私有属性:存储栈元素(外部无法直接访问,保护实现细节)
  #stack;

  constructor() {
    // 初始化空数组作为栈的存储容器
    this.#stack = [];
  }

  // 只读属性:获取栈的大小(通过getter实现,避免直接修改)
  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];
  }

  // 转换为数组(便于查看所有元素)
  toArray() {
    // 返回数组副本,避免外部修改内部存储
    return this.#stack;
  }
}

这段代码实现了一个封装良好的栈(Stack)数据结构,基于 JavaScript 数组,并利用现代语法增强安全性与可读性:

  • 使用 私有字段 #stack 隐藏内部实现,防止外部直接访问;
  • 提供标准栈操作:push(入栈)、pop(出栈)、peek(查看栈顶)、isEmpty(判空)和只读 size 属性;
  • 对空栈操作进行错误检查,提升稳定性;
  • 通过 get 暴露栈大小,保持接口简洁。

2. 基于链表的栈实现

链表是另一种线性数据结构,通过节点串联数据。基于链表实现栈时,我们选择头插法(操作链表头部)实现入栈和出栈,确保操作时间复杂度为O(1)。

// 链表节点类(封装节点数据和指针)
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++;                   // 栈大小+1
  }

  // 查看栈顶元素(栈空时抛错)
  peek() {
    if (!this.#stackPeek) throw new Error("栈为空,无法查看栈顶元素");
    return this.#stackPeek.val;
  }

  // 出栈:移除栈顶节点(栈空时抛错)
  pop() {
    const num = this.peek();     // 先获取栈顶值(触发空栈校验)
    this.#stackPeek = this.#stackPeek.next; // 栈顶指针指向后一个节点
    this.#size--;                   // 栈大小-1
    return num;
  }

  // 只读属性:获取栈的大小
  get size() {
    return this.#size;
  }

  // 判断栈是否为空
  isEmpty() {
    return this.#size === 0;
  }

  // 转换为数组(从栈顶到栈底遍历,反向存储为数组)
  toArray() {
    let node = this.#stackPeek;
    const res = new Array(this.size);
    // 从栈底到栈顶的顺序存储(与入栈顺序一致)
    for (let i = res.length - 1; i >= 0; i--) {
      res[i] = node.val;
      node = node.next;
    }
    return res;
  }
}

const stack = new LinkedListStack();
console.log(stack.size); // 0

这段代码实现了一个基于单向链表的栈(LinkedListStack),具有以下特点:

  • 核心结构:以链表头部作为栈顶,通过“头插法”实现入栈和出栈,时间复杂度均为 O(1);
  • 封装良好:使用私有属性 #stackPeek 和 #size 防止外部直接访问或修改内部状态;
  • 接口完整:提供 push、pop、peek、isEmpty、只读 size 等标准栈操作,并对空栈操作进行错误校验;
  • 数组转换正确:toArray() 方法将栈中元素按入栈顺序(从栈底到栈顶)返回为数组,逻辑清晰;
  • 无容量限制:相比数组实现,链表栈可动态扩展,适合不确定大小的场景。

3. 两种实现的优缺点对比

对比维度基于数组的栈基于链表的栈
时间效率入栈/出栈平均O(1);数组扩容时O(n)(低频操作,平均效率高)入栈/出栈稳定O(1);无扩容开销
空间效率可能浪费空间(数组预分配容量);无额外指针开销扩容灵活(按需创建节点);节点需存储指针,空间开销较大
实现复杂度简单(利用数组内置方法)稍复杂(需手动实现链表节点和指针操作)
适用场景元素数量可预估、追求简洁实现元素数量不确定、需要稳定时间复杂度

三、ES6新特性在栈实现中的应用

上面的实现中,我们用到了ES6的多个核心特性,这里单独梳理,帮助大家理解其设计价值:

1. class 类语法

class 是ES6引入的语法糖,用于定义类(本质是函数的语法糖),相比ES5的原型链写法,结构更清晰、更易维护,完美适配栈这种抽象数据类型的封装需求。

2. 私有属性 #xxx

通过 # 前缀定义的私有属性(如#stack#stackPeek),外部无法直接访问或修改,只能通过类提供的方法操作。这实现了封装性,保护了类的内部实现细节,避免外部误操作导致的数据不一致。

3. get 访问器

使用 get size() 定义只读属性,外部可以通过 stack.size 直接获取栈的大小,而无需调用方法(如stack.getSize()),语法更简洁,同时避免了外部直接修改size的值。

4. 构造函数 constructor

用于初始化类的实例属性(如初始化数组、栈顶指针),在创建对象时自动执行,确保实例从创建时就处于正确的初始状态。

四、栈的经典应用:有效的括号

栈的"先进后出"特性非常适合解决括号匹配问题,这是LeetCode上的经典算法题(LeetCode 20),下面结合栈的实现来解决该问题。

题目描述

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

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。

解题思路

  1. 用一个哈希表 leftToRight 维护左括号与右括号的对应关系,便于快速查找匹配的右括号。
  2. 用栈存储左括号对应的右括号:遇到左括号时,将其对应的右括号压入栈;遇到右括号时,弹出栈顶元素,判断是否与当前右括号匹配。
  3. 匹配失败的情况:① 栈为空时遇到右括号(无对应的左括号);② 弹出的栈顶元素与当前右括号不匹配。
  4. 遍历结束后,栈必须为空(所有左括号都有对应的右括号闭合)。

代码实现

// 维护左括号与右括号的对应关系
const leftToRight = {
  "(": ")",
  "[": "]",
  "{": "}"
};

const isValid = function(s) {
  // 空字符串直接返回true
  if (!s) return true;

  const stack = []; // 用数组模拟栈
  const len = s.length;

  for (let i = 0; i < len; i++) {
    const ch = s[i];
    // 遇到左括号,将对应的右括号压入栈
    if (ch === "(" || ch === "[" || ch === "{") {
      stack.push(leftToRight[ch]);
    } else {
      // 遇到右括号:栈空(无匹配左括号)或栈顶不匹配,返回false
      if (!stack.length || stack.pop() !== ch) {
        return false;
      }
    }
  }

  // 遍历结束后,栈必须为空(所有左括号都已匹配)
  return stack.length === 0;
};

// 测试用例
console.log(isValid("()"));      // true
console.log(isValid("()[]{}"));  // true
console.log(isValid("(]"));      // false
console.log(isValid("([)]"));    // false
console.log(isValid("{[]}"));    // true

五、总结

栈作为一种先进后出的线性数据结构,虽然接口简单,但应用场景广泛(除了括号匹配,还包括函数调用栈、表达式求值等)。本文通过ES6的class语法,实现了基于数组和链表的两种栈结构,对比了它们的优缺点,并通过经典算法题巩固了栈的核心特性。

核心要点回顾:

  1. 栈的核心原则:先进后出。
  2. 两种实现:数组实现简洁高效,链表实现扩容灵活。
  3. ES6特性:class、私有属性、get访问器提升代码的封装性和可读性。
  4. 经典应用:括号匹配问题是栈的典型场景,利用栈的"先进后出"特性实现高效匹配。

希望本文能帮助你深入理解栈的概念与实现,在实际开发和算法题中灵活使用这一基础数据结构。