Langchain系列文章目录

01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来

Python系列文章目录

PyTorch系列文章目录

机器学习系列文章目录

深度学习系列文章目录

Java系列文章目录

JavaScript系列文章目录

Python系列文章目录

Go语言系列文章目录

Docker系列文章目录

数据结构与算法系列文章目录

01-【数据结构与算法-Day 1】程序世界的基石:到底什么是数据结构与算法?
02-【数据结构与算法-Day 2】衡量代码的标尺:时间复杂度与大O表示法入门
03-【数据结构与算法-Day 3】揭秘算法效率的真相:全面解析O(n^2), O(2^n)及最好/最坏/平均复杂度
04-【数据结构与算法-Day 4】从O(1)到O(n²),全面掌握空间复杂度分析
05-【数据结构与算法-Day 5】实战演练:轻松看懂代码的时间与空间复杂度
06-【数据结构与算法-Day 6】最朴素的容器 - 数组(Array)深度解析
07-【数据结构与算法-Day 7】告别数组束缚,初识灵活的链表 (Linked List)
08-【数据结构与算法-Day 8】手把手带你拿捏单向链表:增、删、改核心操作详解
09-【数据结构与算法-Day 9】图解单向链表:从基础遍历到面试必考的链表反转
10-【数据结构与算法-Day 10】双向奔赴:深入解析双向链表(含图解与代码)
11-【数据结构与算法-Day 11】从循环链表到约瑟夫环,一文搞定链表的终极形态
12-【数据结构与算法-Day 12】深入浅出栈:从“后进先出”原理到数组与链表双实现



摘要

欢迎来到数据结构与算法系列的第 12 篇。在探索了数组和链表这两种基础线性结构后,今天我们将学习一种非常重要但又受限的线性数据结构——栈 (Stack)。栈以其独特的 “后进先出” (LIFO) 原则,在计算机科学领域扮演着不可或缺的角色,从我们日常使用的软件中的“撤销”功能,到支撑现代编程语言运行的函数调用,背后都有栈的身影。本文将系统地剖析栈的定义、核心特性与操作,并手把手带你用数组链表两种方式实现一个功能完备的栈,深入对比它们的优劣与适用场景,为你彻底掌握栈结构打下坚实的基础。

一、什么是栈 (Stack)?

在进入代码实现之前,我们必须先理解栈的核心思想。它非常直观,贴近生活。

1.1 栈的定义与特性

栈 (Stack) 是一种特殊的线性表,它只允许在固定的一端进行插入和删除元素的操作。进行数据插入和删除操作的一端称为栈顶 (Top),另一端称为栈底 (Bottom)

栈最大的特性就是后进先出 (Last-In, First-Out),简称 LIFO。这个原则可以用生活中的例子来形象地理解:

  • 一摞盘子:想象一下你在洗一摞盘子,你总是把洗好的盘子放在最上面,而需要用盘子时,也总是从最上面拿。最后放上去的盘子,最先被取走。
  • 羽毛球筒:往羽毛球筒里放球时,第一个放进去的球会掉到最底下,最后一个放进去的球在最顶上。取球时,也只能从顶部一个一个地取。

在栈中,数据元素的插入操作称为入栈 (Push),删除操作称为出栈 (Pop)

1.2 栈的抽象数据类型 (ADT)

从功能的角度,一个栈结构通常需要支持以下几种核心操作:

  • push(element): 将一个元素压入栈顶。
  • pop(): 从栈顶弹出一个元素,并返回该元素。
  • peek(): 查看栈顶元素,但不弹出。
  • isEmpty(): 判断栈是否为空。
  • size(): 返回栈中元素的个数。

这些操作共同构成了栈的抽象数据类型(Abstract Data Type),定义了栈“应该做什么”,而不用关心其“内部如何实现”。

1.3 栈的应用场景

栈虽然结构简单,但应用极其广泛,是许多复杂算法和系统的基石:

  • 函数调用栈:程序在调用函数时,会将调用信息(如返回地址、参数、局部变量)压入一个栈中,函数返回时再从栈中弹出。这是递归得以实现的基础。
  • 表达式求值:编译器使用栈来转换和计算中缀表达式(如 3 + 4 * 2)为后缀表达式(逆波兰表达式 3 4 2 * +),并求值。
  • 括号匹配校验:在代码编辑器中,判断括号((), [], {})是否成对出现,可以用栈轻松实现。
  • 浏览器的前进后退:浏览器的历史记录后退功能,就像一个栈,每访问一个新页面就入栈,点击后退按钮就出栈。
  • 软件的撤销(Undo)操作:在文本编辑器或绘图软件中,每执行一个操作,就将该操作的状态入栈,点击撤销时,就从栈顶弹出一个状态进行恢复。

我们将在下一篇文章中详细探讨这些应用。今天,我们的重点是把栈的两种主要实现方式学透。

二、基于数组实现栈 (静态栈)

使用数组实现栈是最直观的方式之一。其核心是利用数组的连续内存空间来存储数据,并用一个指针(或索引)来时刻指向栈顶。

2.1 设计思路

  1. 底层容器:使用一个固定大小的数组来存放栈内元素。
  2. 栈顶指针:定义一个变量,比如 top 或者 size,来跟踪栈顶的位置。这里我们采用 size 变量,它既能表示栈中元素的数量,也能通过 size - 1 定位到栈顶元素的索引。
  3. 容量限制:由于数组长度固定,这种实现的栈是有容量限制的,我们称之为“静态栈”。当栈满时,无法再进行 push 操作(称为“上溢”);当栈空时,无法进行 pop 操作(称为“下溢”)。

2.2 核心操作实现 (以 Java 为例)

下面我们用 Java 语言实现一个基于数组的泛型栈。

// 使用泛型 E,可以存储任意类型的元素
public class ArrayStack<E> {

    private E[] data;    // 底层数组
    private int size;    // 栈中元素的数量,也指向下一个待插入位置的索引

    /**
     * 构造函数,传入栈的容量
     * @param capacity 栈的初始容量
     */
    public ArrayStack(int capacity) {
        // 由于 Java 不支持直接 new E[],需要一个类型转换
        data = (E[]) new Object[capacity];
        size = 0;
    }

    /**
     * 获取栈中元素的个数
     */
    public int getSize() {
        return size;
    }

    /**
     * 判断栈是否为空
     */
    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 获取栈的容量
     */
    public int getCapacity() {
        return data.length;
    }

    /**
     * 入栈操作
     * @param e 待入栈的元素
     */
    public void push(E e) {
        // 检查栈是否已满
        if (size == data.length) {
            throw new IllegalArgumentException("Push failed. Stack is full.");
        }
        
        // 在 size 指向的位置放入新元素
        data[size] = e;
        // 维护 size
        size++;
    }

    /**
     * 出栈操作
     * @return 栈顶元素
     */
    public E pop() {
        // 检查栈是否为空
        if (isEmpty()) {
            throw new IllegalArgumentException("Pop failed. Stack is empty.");
        }

        // 取出栈顶元素(索引为 size - 1)
        E ret = data[size - 1];
        // 维护 size
        size--;
        // [可选优化] 将出栈位置置为null,帮助垃圾回收 (Loitering objects)
        data[size] = null; 
        return ret;
    }

    /**
     * 查看栈顶元素
     * @return 栈顶元素
     */
    public E peek() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Peek failed. Stack is empty.");
        }
        return data[size - 1];
    }
}

2.2.1 时间复杂度分析

  • push(e): O ( 1 ) O(1) O(1)。仅涉及一次数组赋值和一次整数自增。
  • pop(): O ( 1 ) O(1) O(1)。仅涉及一次数组访问和一次整数自减。
  • peek(): O ( 1 ) O(1) O(1)。仅涉及一次数组访问。
  • isEmpty() / getSize(): O ( 1 ) O(1) O(1)

可见,基于数组实现的栈,其核心操作效率非常高。

2.3 优缺点分析

特性优点缺点
存储方式内存连续,数据紧凑容量固定,无法动态扩展(除非实现复杂的动态扩容机制)
性能缓存友好性好(Cache Locality),访问速度快若实现动态扩容,单次 push 操作可能导致 O ( n ) O(n) O(n) 的时间复杂度
空间无需额外指针开销,空间利用率高可能造成空间浪费(分配了很大容量但实际使用很少)
实现逻辑简单,易于实现需要处理栈满(上溢)的边界情况

三、基于链表实现栈 (动态栈)

为了克服数组实现栈的固定容量问题,我们可以使用链表作为底层结构。链表天然支持动态增删,非常适合实现一个容量可变的“动态栈”。

3.1 设计思路

  1. 底层容器:使用一个单向链表。
  2. 栈顶:将链表的头结点作为栈顶。这是关键设计点,因为对链表头部的增删操作时间复杂度都是 O ( 1 ) O(1) O(1)。如果选择链表尾部作为栈顶,push 操作需要遍历整个链表找到尾部,时间复杂度为 O ( n ) O(n) O(n),效率低下。
  3. 动态容量:链表在内存中按需分配节点,因此没有固定的容量限制,只要内存足够,就可以一直 push

下面是一个链表栈操作的示意图:

再执行 pop()
执行 push(D)
初始状态 (Stack: A -> B -> C)
A
栈顶
B
C
null
弹出 D
D
栈顶
A
B
C
null
A
栈顶
B
C
null

3.2 核心操作实现 (以 Java 为例)

我们同样用 Java 实现一个基于链表的泛型栈。

public class LinkedListStack<E> {

    // 内部私有节点类
    private class Node {
        public E e;
        public Node next;

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        public Node(E e) {
            this(e, null);
        }
    }

    private Node head;  // 链表头结点,即为栈顶
    private int size;   // 栈中元素的数量

    public LinkedListStack() {
        head = null;
        size = 0;
    }

    /**
     * 获取栈中元素的个数
     */
    public int getSize() {
        return size;
    }

    /**
     * 判断栈是否为空
     */
    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 入栈操作
     * @param e 待入栈的元素
     */
    public void push(E e) {
        // 创建一个新节点,让它的 next 指向当前的 head
        Node newNode = new Node(e, head);
        // 更新 head 为这个新节点
        head = newNode;

        // 等价于下面两行代码:
        // Node node = new Node(e);
        // node.next = head;
        // head = node;
        
        size++;
    }

    /**
     * 出栈操作
     * @return 栈顶元素
     */
    public E pop() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Pop failed. Stack is empty.");
        }
        
        // 保存待删除的头结点
        Node retNode = head;
        // head 指向下一个节点
        head = head.next;
        // 断开原头结点的链接(便于垃圾回收)
        retNode.next = null;
        
        size--;
        return retNode.e;
    }

    /**
     * 查看栈顶元素
     * @return 栈顶元素
     */
    public E peek() {
        if (isEmpty()) {
            throw new IllegalArgumentException("Peek failed. Stack is empty.");
        }
        return head.e;
    }
}

3.2.1 时间复杂度分析

  • push(e): O ( 1 ) O(1) O(1)。仅涉及一次新节点的创建和一次引用的改变。
  • pop(): O ( 1 ) O(1) O(1)。仅涉及一次引用的改变。
  • peek(): O ( 1 ) O(1) O(1)。仅涉及一次 head 节点的访问。
  • isEmpty() / getSize(): O ( 1 ) O(1) O(1)

可以看到,基于链表实现的栈,核心操作效率同样非常高。

3.3 优缺点分析

特性优点缺点
存储方式内存非连续,动态分配每个元素都需要额外的指针开销
性能增删操作稳定在 O ( 1 ) O(1) O(1)缓存不友好,理论上访问速度可能略慢于数组
空间按需分配,没有容量限制相比数组,每个元素占用的总内存稍大
实现无需处理栈满的情况涉及指针操作,实现逻辑相对数组稍复杂

四、两种实现方式的对比

现在,让我们将这两种实现方式放在一起进行一个全面的对比,以便在实际开发中做出正确的选择。

对比项基于数组的栈 (ArrayStack)基于链表的栈 (LinkedStack)
底层结构连续内存的数组非连续内存的链表节点
容量固定,需预先指定动态,无容量限制(受限于内存)
核心操作
(push, pop, peek)
均为 O ( 1 ) O(1) O(1)均为 O ( 1 ) O(1) O(1)
内存使用元素本身占用空间。可能因预分配过大而浪费每个元素 + 一个指针的开销
缓存性能 (Cache-friendly),因为数据连续,因为节点在内存中可能分散
适用场景栈的最大容量已知可预估时;对性能和内存局部性有极致要求的场景栈的容量未知变化剧烈时;不希望处理扩容逻辑的场景
主要问题栈上溢 (Stack Overflow)指针的额外开销

选择建议
在大多数高级编程语言中,内置的 Stack 类(如 Java 的 java.util.Stack,虽然已不推荐使用,推荐用 Deque)底层往往是基于动态数组(如 VectorArrayList)实现的。这是一种折中方案:兼具数组的缓存优势,同时通过动态扩容机制解决了固定容量的问题,尽管扩容时会有性能抖动。

对于我们自己实现而言:

  • 如果能确定数据规模上限,且对性能要求苛刻,数组栈是更好的选择。
  • 如果不确定数据规模,或希望实现更灵活、平滑的内存增长,链表栈是更省心的选择。

五、总结

今天,我们系统地学习了栈这一重要的数据结构,现在对全文核心内容进行梳理:

  1. 栈的核心:栈是一种遵循后进先出 (LIFO) 原则的受限线性表,所有操作都在栈顶进行。
  2. 核心操作:栈的 ADT 主要包括 push(入栈)、pop(出栈)、peek(查看栈顶)、isEmpty(判空)和 size(大小),它们的时间复杂度都应为 O ( 1 ) O(1) O(1)
  3. 数组实现:基于数组的静态栈实现简单,缓存友好,但容量固定。适用于数据规模可预知的场景。
  4. 链表实现:基于链表的动态栈容量灵活,无上溢风险,但有额外的指针开销。适用于数据规模不确定的场景。
  5. 技术权衡:在选择实现方式时,需在空间效率时间效率实现复杂度之间做出权衡。

掌握了栈的底层原理和实现方法后,你就拥有了一把解决许多经典算法问题的利器。在下一篇文章中,我们将进入实战环节,运用今天所学的栈来解决括号匹配、表达式求值等经典问题,敬请期待!


Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐