栈数据结构全解析:从实现原理到 LeetCode 实战

0 阅读12分钟

数据结构栈

在数据结构的世界里,有许多看似简单却支撑起无数复杂程序的 “基础构件”,

栈(Stack)就是其中之一。它不像链表那样灵活多变,也不像树那样层次分明,但凭借 先进后出的独特逻辑,在算法设计、内存管理、程序运行等场景中扮演着不可替代的角色。

今天我们就来深入聊聊栈 —— 这个看似 “固执”,却格外实用的数据结构。

数组栈:用数组实现的栈(含完整 JS 实现)

在栈的两种核心实现方式中,数组栈是最直观、效率最高的选择之一。它的底层基于普通数组,通过维护一个 “栈顶指针”(或直接利用数组长度)来遵循 “先进后出(FILO)” 规则,无论是入栈、出栈还是查看栈顶,都能做到 O (1) 的时间复杂度,非常适合场景简单、对性能有要求的开发需求。

一、数组栈的核心原理

数组栈的本质是 “受限的数组”—— 我们只允许在数组的 “一端”(通常是尾部)进行元素的添加(入栈)和删除(出栈)操作,另一端则完全封闭。核心依赖两个关键要素:

  • 底层数组:存储栈的所有元素,利用数组的连续内存特性实现快速访问;
  • 栈顶标记:标记当前栈顶元素的位置(可以是显式的top变量,也可以直接用数组长度length隐含表示,JS 中数组的push/pop方法本身就是操作尾部,用length更简洁)。

核心操作逻辑拆解

我们用 “数组长度作为栈顶标记” 来梳理核心操作(也是下面 JS 实现的逻辑),更符合 JS 数组的原生特性:

  1. 初始化:创建一个空数组,此时数组长度为 0,代表栈为空;
  2. 入栈(Push) :直接调用数组的push方法,将新元素添加到数组尾部(栈顶),无需手动维护top—— 数组长度自动 + 1,尾部就是栈顶;
  3. 出栈(Pop) :先判断栈是否为空(数组长度为 0),若为空则抛出 “栈下溢” 错误;若不为空,调用数组的pop方法移除并返回数组尾部元素(栈顶),数组长度自动 - 1;
  4. 查看栈顶(Peek) :判断栈不为空后,直接返回数组的最后一个元素(array[array.length - 1]),不改变栈的结构;
  5. 辅助操作:获取栈的大小(直接返回数组长度)、判断栈是否为空(数组长度是否为 0)、转为数组(返回原数组的副本,避免外部修改)。

JS 实现

// 数组来stack
class ArrayStack {
  #stack;
  constructor() {
    this.#stack = [];
  }
  get size() {
    return this.#stack.length;
  }
  isEmpty() {
    return this.size === 0;
  }
  push() {
    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;
  }
}

image.png

关键注意点

  • 栈溢出:在 JS 中,数组是动态扩容的(不像 Java 等语言的数组有固定大小),理论上不会出现 “栈溢出”(除非内存耗尽),但如果业务需要限制栈的最大容量,可以在push方法中添加判断;
  • 安全性:用私有属性(#stack)存储底层数组,避免外部直接操作数组(比如通过array[0]修改栈底元素),保证栈的操作只能通过暴露的 API 进行,符合 “封装” 原则。

小总结

数组栈是栈的 “最优解” 之一,在 JS 中尤其如此 —— 借助数组的原生方法和动态扩容特性,既能保证极致性能,又能简化实现逻辑。上面的代码包含了完整的边界处理(空栈出栈、满栈入栈)、封装设计(私有属性)和实用辅助方法(清空、转数组),可以直接用于生产环境,也可根据业务需求(如元素类型校验、深拷贝)进一步扩展。

链表栈:用链表实现的栈(含 JS 完整实现 + 原理剖析)

在栈的两种核心物理实现中,链表栈以其动态扩容、内存灵活的特性,成为数组栈的重要补充。

它基于单链表结构,通过维护栈顶指针遵循 “先进后出(FILO)” 规则,无需担心数组的固定容量限制,适合元素数量不确定、对内存利用率有要求的场景。

一、链表栈的核心原理

链表栈的本质是 “只能从表头操作的单链表”—— 我们将单链表的头节点作为栈顶,所有入栈、出栈操作都在表头完成(链表尾部作为栈底,不直接操作)。核心依赖两个关键要素:

  • 链表节点(ListNode) :存储单个元素的值(val)和指向下一个节点的指针(next),是链表栈的基本数据单元;
  • 栈顶指针(#stackPeek) :始终指向当前栈顶节点(表头),若栈为空则为 null;
  • 栈大小(#size) :记录栈中元素的个数,避免每次获取大小时遍历链表(优化时间复杂度)。

核心操作逻辑拆解

链表栈的所有操作都围绕 “栈顶(表头)” 展开,逻辑清晰且高效:

  1. 初始化:栈顶指针(#stackPeek)设为 null,栈大小(#size)设为 0,代表空栈;

  2. 入栈(Push)

    • 创建一个新节点,存储待入栈的元素;
    • 让新节点的 next 指针指向当前栈顶节点(#stackPeek);
    • 将栈顶指针更新为新节点(新节点成为新的栈顶);
    • 栈大小(#size)加 1;
  3. 出栈(Pop)

    • 先判断栈是否为空(#stackPeek 为 null),若为空则抛出 “栈下溢” 错误;
    • 保存当前栈顶节点的值(待返回的出栈元素);
    • 将栈顶指针更新为当前栈顶节点的 next 指针(原栈顶节点被 “移除”,GC 会自动回收);
    • 栈大小(#size)减 1;
    • 返回保存的栈顶节点值;
  4. 查看栈顶(Peek) :判断栈不为空后,直接返回栈顶指针指向节点的值(#stackPeek.val),不修改栈结构;

  5. 辅助操作:获取栈大小(直接返回 #size)、判断栈是否为空(#size === 0)、转为数组(从栈顶到栈底遍历链表,反向存储为数组,保证输出顺序为 “栈底→栈顶”)。

关键设计思路

  • 为什么选表头作为栈顶?  单链表的表头操作(添加 / 删除节点)时间复杂度是 O (1),而表尾操作需要遍历整个链表(O (n)),选择表头作为栈顶能保证入栈、出栈的高效性;
  • 为什么用私有字段?  #stackPeek 和 #size 设为私有字段,避免外部直接修改栈顶指针或栈大小,保证栈的 FILO 规则不被破坏,符合 “封装” 的设计原则;
  • 节点的离散存储:链表节点通过 next 指针关联,无需连续内存空间,内存利用率更高,且不会出现数组栈的 “扩容” 问题。
// 链表节点类:定义链表的基本单元
class ListNode {
  /**
   * 初始化链表节点
   * @param {*} val - 节点存储的值(支持任意类型)
   */
  constructor(val) {
    this.val = val; // 节点值
    this.next = null; // 指向下一个节点的指针(初始为null)
  }
}

// 链表栈类:基于单链表实现栈,遵循FILO规则
class LinkedListStack {
  // 私有字段:栈顶指针(指向栈顶节点,外部不可访问)
  #stackPeek;
  // 私有字段:栈的大小(外部不可访问)
  #size = 0;

  /**
   * 构造函数:初始化空栈
   */
  constructor() {
    this.#stackPeek = null; // 栈空时,栈顶指针为null
  }

  /**
   * 【核心操作】入栈:将元素添加到栈顶
   * @param {*} num - 待入栈的元素
   */
  push(num) {
    // 1. 创建新节点
    const newNode = new ListNode(num);
    // 2. 新节点的next指向当前栈顶(链接原有栈结构)
    newNode.next = this.#stackPeek;
    // 3. 栈顶指针更新为新节点(新节点成为新栈顶)
    this.#stackPeek = newNode;
    // 4. 栈大小加1
    this.#size++;
  }

  /**
   * 【核心操作】查看栈顶元素:不修改栈结构
   * @returns {*} 栈顶元素
   * @throws {Error} 栈为空时抛出错误
   */
  peek() {
    if (this.isEmpty()) {
      throw new Error("栈下溢:当前栈为空,无栈顶元素");
    }
    return this.#stackPeek.val;
  }

  /**
   * 【核心操作】出栈:移除并返回栈顶元素
   * @returns {*} 栈顶元素
   * @throws {Error} 栈为空时抛出错误
   */
  pop() {
    // 1. 先通过peek判断栈是否为空(复用逻辑,避免重复代码)
    const topVal = this.peek();
    // 2. 栈顶指针指向原栈顶的下一个节点(移除原栈顶节点)
    this.#stackPeek = this.#stackPeek.next;
    // 3. 栈大小减1
    this.#size--;
    // 4. 返回原栈顶节点的值
    return topVal;
  }

  /**
   * 【辅助操作】获取栈的当前大小(getter属性,可通过stack.size访问)
   * @returns {number} 栈中元素个数
   */
  get size() {
    return this.#size;
  }

  /**
   * 【辅助操作】判断栈是否为空
   * @returns {boolean} 空返回true,非空返回false
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 【辅助操作】将栈转为数组(输出顺序:栈底→栈顶)
   * @returns {array} 栈元素组成的数组
   */
  toArray() {
    // 遍历链表,从栈顶到栈底收集元素
    let currentNode = this.#stackPeek;
    const result = [];
    while (currentNode) {
      result.push(currentNode.val);
      currentNode = currentNode.next;
    }
    // 由于收集顺序是“栈顶→栈底”,需要反转数组,让输出符合“栈底→栈顶”的直觉
    return result.reverse();
  }

  /**
   * 【辅助操作】清空栈
   */
  clear() {
    this.#stackPeek = null; // 断开栈顶指针,所有节点会被GC回收
    this.#size = 0; // 重置栈大小
  }
}

image.png

小总结

链表栈是栈的经典实现之一,核心优势在于动态扩容、内存利用率高,完美解决了数组栈的固定容量限制问题。虽然实现时需要手动管理节点和指针,但核心操作的时间复杂度仍保持 O (1),性能稳定可靠。

上面的 JS 实现包含了完整的边界处理(空栈操作防护)、实用辅助方法(清空、转数组)和严格的封装设计(私有字段),可以直接用于生产环境。在实际开发中,只需根据 “元素数量是否确定”“内存是否敏感” 两个核心因素,就能在链表栈和数组栈之间做出最优选择。

数组栈 vs 链表栈:复杂度核心对比

一、时间复杂度对比

操作数组栈链表栈核心差异
入栈(Push)平均 O (1),最坏 O (n)稳定 O (1)数组栈扩容需拷贝元素(O (n)),链表栈无扩容压力
出栈(Pop)稳定 O (1)稳定 O (1)均直接操作栈顶,无额外开销
查看栈顶(Peek)稳定 O (1)稳定 O (1)直接访问栈顶元素
辅助操作(Size/isEmpty)稳定 O (1)稳定 O (1)维护独立 size 变量,直接返回

关键说明

  • 数组栈:大部分场景入栈 O (1),仅扩容时触发 O (n) 拷贝(低频操作,平均效率仍为 O (1));
  • 链表栈:入栈仅需创建节点 + 修改指针,无扩容开销,效率始终稳定 O (1);
  • 链表节点实例化属于 O (1) 常量开销,远低于数组扩容的 O (n) 线性开销。

二、空间复杂度对比

维度数组栈链表栈
整体复杂度O (n)(n 为元素个数)O (n)(n 为元素个数)
空间浪费可能存在预留内存浪费无浪费,但节点需额外存储 next 指针
核心差异连续内存存储,扩容时预留额外空间离散存储,每个节点多占用指针内存

力扣题目详解

20. 有效的括号 - 力扣(LeetCode)

这道题是栈数据结构的 “入门必刷” 题目,核心思路是利用栈 “先进后出” 的特性,匹配左右括号的闭合顺序。下面从解题思路、代码拆解、边界处理三个维度,带你彻底搞懂这道题。

一、解题核心思路

括号有效需满足两个核心条件:类型匹配顺序正确。栈的 “后进先出” 刚好能解决 “顺序正确” 的问题 —— 左括号入栈,遇到右括号时弹出栈顶元素校验匹配度:

  1. 用哈希表(leftToRight)维护左括号到右括号的映射关系,快速判断匹配类型;

  2. 遍历字符串时,遇到左括号就将对应的右括号入栈(相当于 “预约” 一个右括号来闭合);

  3. 遇到右括号时,弹出栈顶元素:

    • 若栈为空(没有对应的左括号),或弹出的元素与当前右括号不匹配,直接返回false
  4. 遍历结束后,若栈为空(所有左括号都被匹配闭合),返回true,否则返回false

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

有效字符串需满足:

左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
每个右括号都有一个对应的相同类型的左括号。 */

// 用一个map|json来维护左括号和右括号的对应关系
// 用一个栈来维护左括号 , 遇到右括号时,弹出栈顶元素,查看是否匹配
// 若不匹配或栈不为空返回false,遍历完毕后,栈为空,有效
const leftToRight = {
    "(" : ")",
    "{" : "}",
    "[" : "]"
};
const isValid = function(s){
    if(!s) return true;
    
    const stack = [];
    const len = s.length;
    if(len % 2 != 0){
        return false;
    }
    for(let i = 0 ; i < len ; i++){
        const ch = s[i];
    
    if (ch ==="(" || ch === "{" || ch ==="["){
        stack.push(leftToRight[ch]);
    } else {
        if(!stack.length || stack.pop()!==ch){
            return false;
        }
    }
    }
    return !stack.length;
}

image.png

总结

栈数据结构核心总结

  1. 核心定义:遵循 “先进后出(FILO)” 的线性数据结构,仅允许栈顶进行入栈(Push)、出栈(Pop)、查看栈顶(Peek)操作。

  2. 两种实现

    • 数组栈:基于数组,实现简单、平均效率高,扩容时最坏时间复杂度 O (n),可能有内存浪费;
    • 链表栈:基于单链表,无扩容压力、内存灵活,所有操作稳定 O (1),节点需额外存储指针。
  3. 复杂度:时间复杂度均以 O (1) 为主(数组栈扩容除外),空间复杂度均为 O (n),差异集中在扩容开销和内存占用形式。

  4. 核心应用:括号匹配、表达式求值、程序调用栈、浏览器前进 / 后退、编辑器撤销等,核心是利用 “记忆顺序” 特性解决匹配 / 回溯问题。

  5. 实战技巧:结合哈希表优化映射,重视边界处理,以 “入栈记录、出栈校验” 为核心思路,复杂度可控。