【数据结构与算法-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】深入浅出栈:从“后进先出”原理到数组与链表双实现
文章目录
摘要
欢迎来到数据结构与算法系列的第 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;
// 维护 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 设计思路
- 底层容器:使用一个单向链表。
- 栈顶:将链表的头结点作为栈顶。这是关键设计点,因为对链表头部的增删操作时间复杂度都是
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 指向当前的 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)底层往往是基于动态数组(如 Vector 或 ArrayList)实现的。这是一种折中方案:兼具数组的缓存优势,同时通过动态扩容机制解决了固定容量的问题,尽管扩容时会有性能抖动。
对于我们自己实现而言:
- 如果能确定数据规模上限,且对性能要求苛刻,数组栈是更好的选择。
- 如果不确定数据规模,或希望实现更灵活、平滑的内存增长,链表栈是更省心的选择。
五、总结
今天,我们系统地学习了栈这一重要的数据结构,现在对全文核心内容进行梳理:
- 栈的核心:栈是一种遵循后进先出 (LIFO) 原则的受限线性表,所有操作都在栈顶进行。
- 核心操作:栈的 ADT 主要包括
push(入栈)、pop(出栈)、peek(查看栈顶)、isEmpty(判空)和size(大小),它们的时间复杂度都应为 O ( 1 ) O(1) O(1)。 - 数组实现:基于数组的静态栈实现简单,缓存友好,但容量固定。适用于数据规模可预知的场景。
- 链表实现:基于链表的动态栈容量灵活,无上溢风险,但有额外的指针开销。适用于数据规模不确定的场景。
- 技术权衡:在选择实现方式时,需在空间效率、时间效率和实现复杂度之间做出权衡。
掌握了栈的底层原理和实现方法后,你就拥有了一把解决许多经典算法问题的利器。在下一篇文章中,我们将进入实战环节,运用今天所学的栈来解决括号匹配、表达式求值等经典问题,敬请期待!
更多推荐
所有评论(0)