【数据结构与算法-Day 12】深入浅出栈:从“后进先出”原理到数组与链表双实现
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】深入浅出栈:从“后进先出”原理到数组与链表双实现
文章目录
- Langchain系列文章目录
- Python系列文章目录
- PyTorch系列文章目录
- 机器学习系列文章目录
- 深度学习系列文章目录
- Java系列文章目录
- JavaScript系列文章目录
- Python系列文章目录
- Go语言系列文章目录
- Docker系列文章目录
- 数据结构与算法系列文章目录
- 摘要
- 一、什么是栈 (Stack)?
- 1.1 栈的定义与特性
- 1.2 栈的抽象数据类型 (ADT)
- 1.3 栈的应用场景
- 二、基于数组实现栈 (静态栈)
- 2.1 设计思路
- 2.2 核心操作实现 (以 Java 为例)
- 2.2.1 时间复杂度分析
- 2.3 优缺点分析
- 三、基于链表实现栈 (动态栈)
- 3.1 设计思路
- 3.2 核心操作实现 (以 Java 为例)
- 3.2.1 时间复杂度分析
- 3.3 优缺点分析
- 四、两种实现方式的对比
- 五、总结
摘要
欢迎来到数据结构与算法系列的第 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 设计思路
- 底层容器:使用一个固定大小的数组来存放栈内元素。
- 栈顶指针:定义一个变量,比如
top
或者size
,来跟踪栈顶的位置。这里我们采用size
变量,它既能表示栈中元素的数量,也能通过size - 1
定位到栈顶元素的索引。 - 容量限制:由于数组长度固定,这种实现的栈是有容量限制的,我们称之为“静态栈”。当栈满时,无法再进行
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;// 维护 sizesize++;}/*** 出栈操作* @return 栈顶元素*/public E pop() {// 检查栈是否为空if (isEmpty()) {throw new IllegalArgumentException("Pop failed. Stack is empty.");}// 取出栈顶元素(索引为 size - 1)E ret = data[size - 1];// 维护 sizesize--;// [可选优化] 将出栈位置置为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 设计思路
- 底层容器:使用一个单向链表。
- 栈顶:将链表的头结点作为栈顶。这是关键设计点,因为对链表头部的增删操作时间复杂度都是 O(1)O(1)O(1)。如果选择链表尾部作为栈顶,
push
操作需要遍历整个链表找到尾部,时间复杂度为 O(n)O(n)O(n),效率低下。 - 动态容量:链表在内存中按需分配节点,因此没有固定的容量限制,只要内存足够,就可以一直
push
。
下面是一个链表栈操作的示意图:
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 指向当前的 headNode 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
)底层往往是基于动态数组(如 Vector
或 ArrayList
)实现的。这是一种折中方案:兼具数组的缓存优势,同时通过动态扩容机制解决了固定容量的问题,尽管扩容时会有性能抖动。
对于我们自己实现而言:
- 如果能确定数据规模上限,且对性能要求苛刻,数组栈是更好的选择。
- 如果不确定数据规模,或希望实现更灵活、平滑的内存增长,链表栈是更省心的选择。
五、总结
今天,我们系统地学习了栈这一重要的数据结构,现在对全文核心内容进行梳理:
- 栈的核心:栈是一种遵循后进先出 (LIFO) 原则的受限线性表,所有操作都在栈顶进行。
- 核心操作:栈的 ADT 主要包括
push
(入栈)、pop
(出栈)、peek
(查看栈顶)、isEmpty
(判空)和size
(大小),它们的时间复杂度都应为 O(1)O(1)O(1)。 - 数组实现:基于数组的静态栈实现简单,缓存友好,但容量固定。适用于数据规模可预知的场景。
- 链表实现:基于链表的动态栈容量灵活,无上溢风险,但有额外的指针开销。适用于数据规模不确定的场景。
- 技术权衡:在选择实现方式时,需在空间效率、时间效率和实现复杂度之间做出权衡。
掌握了栈的底层原理和实现方法后,你就拥有了一把解决许多经典算法问题的利器。在下一篇文章中,我们将进入实战环节,运用今天所学的栈来解决括号匹配、表达式求值等经典问题,敬请期待!