拿到Algorithms_4th之后,我有一个很矛盾的感觉:前面1.1和1.2还在讲二分查找、数学归纳法这种刚需基本功,到了1.3节,突然丢出三个看起来特别简单的结构——Bags、Queues、Stacks。我第一次读的时候心想,这不就是容器吗,Java里现成的集合类一把一把的,为什么还要花一整节来讲?真正把它啃完,才发现这一节在下一盘很大的棋:它不是在教三个数据结构,而是在教你一种对数据结构的思考方式——先定义API,再选择底层表示,最后分析性能影响。这种思路会贯穿整本书,也直接决定了你后面读排序、查找、图算法时能不能跟上节奏。
这篇文章就围绕1.3节的核心内容展开,我把自己的学习笔记、代码实验和踩坑记录都整理出来。如果你也正在读这本书,或者想补一下这三种基础结构的知识,这篇文章可以帮你少走不少弯路。
1. 三种基础数据结构:它们到底在解决什么问题
这一节开头,作者讲了一个很重要的观点:算法研究里,数据结构的价值往往不在结构本身,而在它对外暴露的访问策略。 Bag、Queue、Stack这三个东西,底层都可以用链表或者数组来实现,存进去的元素也可以是完全相同的对象集合。它们之间的本质区别,仅仅在于“取出来的时候,顺序怎么定”。
- Bag(包):只管收集数据,不关心顺序。你往里放若干元素,之后遍历一遍做统计或处理。它本质上是一个无序的多重集合。
- Queue(队列):先进先出(FIFO)。先放进去的元素先被处理,跟真实世界里排队一个逻辑。
- Stack(栈):后进先出(LIFO)。最新放进去的元素最先被处理,就像一叠盘子,你总是从顶上拿。
为什么把三个结构放在同一节里讲?因为它们在实现上高度同构。三个都可以用链表:Bag只需要维护一个头节点,往里插;Stack的push就是插头节点,pop就是删头节点;Queue稍微复杂一点,需要维护头尾两个指针,enqueue往尾上挂,dequeue从头取。三个也可以用动态数组:Queue因为是两头操作,需要处理好数组的循环复用问题。一旦你理解了其中任意一个的底层实现,另外两个基本是“换一层皮”的事。
我自己的理解方式是:把它们看成同一个“集合”接口的三种访问策略。底层存储可以完全一样,规则不同,使用的场景就完全不同。这个“访问策略决定数据结构性质”的视角,是我读这一节最大的收获。
1.1 访问规则为什么如此重要
很多人初学的时候容易陷入一个误区:以为栈和队列的区别只是“顺序反一下”,学好其中一个,另一个自然就会了。实际操作起来就会发现差很多。Stack的代码写起来非常自然,因为所有操作都在同一端;Queue则要时刻想清楚头尾两个指针怎么协同,否则很容易出现元素丢失、死循环或者越界。
访问规则还直接影响你能拿它做什么事。比如说,栈天然适合做“撤销”操作,因为用户总是撤销最近一步;队列天然适合做“任务调度”,因为先来的请求应该先被处理;Bag天然适合做“离线计算”,比如先收集一批样本,再统一算均值、方差。访问规则选错了,代码会越写越别扭。
1.2 从计算模型看三者的联系
书里提到了一个概念,算是在图灵机模型里,栈和队列都可以模拟任意计算过程。这一点听起来很理论,但实际意义是:当你在工程里遇到“需要临时存储一批数据,稍后再处理”的情况时,不用担心用栈还是用队列是不是不够通用。选哪个,核心就看两件事:数据的到达顺序和处理顺序是否一致。一致就用队列,相反就用栈,根本没规律就用Bag。把这三个问题想清楚,数据结构的选择就变成了一道填空题。
下一个小节我用实际例子展开,看每种结构到底能在哪些场景里发力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 应用场景拆解:什么时候用哪个,为什么
这一节不是让你背“栈用于递归、队列用于排队”这种考点,而是真正理解为什么这些场景和这些结构天生匹配。
2.1 Stack:递归、撤销、表达式求值
Stack最典型的特征就是回溯。这个场景遍布计算机世界的各个角落:
- 函数调用栈:函数调用一层层压栈,返回时一层层弹栈,这就是硬件和编译器对栈最直接的依赖。
- 编辑器的Undo/Redo:每执行一个操作就压栈,撤销就是从栈顶弹一个操作出来逆向执行。
- 括号匹配:遇到左括号压栈,遇到右括号弹栈并配对。如果最终栈为空且中途没出现不匹配,就说明括号是成对的。
- 深度优先搜索(DFS):无论是显式用栈还是用递归(递归底层也是系统栈),本质都是“沿着一条路走到黑,走不通了回头”。
- 表达式求值:书中1.3节后面给了Dijkstra双栈表达式求值算法。一个栈存操作数,一个栈存运算符,遇到右括号就弹出子表达式的结果。这个例子的精彩之处在于,它展示了栈如何天然支持“嵌套结构”。
我自己练习时写过一个简单的括号匹配:
java复制public boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
} else {
if (stack.isEmpty()) return false;
char top = stack.pop();
if (!match(top, c)) return false;
}
}
return stack.isEmpty();
}
注意我用的是ArrayDeque而不是java.util.Stack。这个后面讲踩坑的时候会细说。
2.2 Queue:调度、缓冲、广度优先
队列的核心价值在于解耦生产和消费的速度。生产者只管往队尾放,消费者只管从队头取,两边不需要互相等。这个特性让它成为系统设计里最常用的缓冲结构:
- 操作系统进程调度:时间片轮转就是一圈圈从就绪队列里取进程执行。
- 消息队列:请求进来先排队,后台worker按顺序处理。
- 打印机任务队列:多个文档排队打印,先提交的先出纸。
- 广度优先搜索(BFS):从起点出发,先访问所有距离为1的邻居,再访问距离为2的,天然就是按“先进先出”的顺序展开的。
BFS代码骨架:
java复制Queue<Node> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
Node cur = queue.poll();
for (Node next : cur.neighbors) {
if (!visited.contains(next)) {
visited.add(next);
queue.add(next);
}
}
}
你会发现,只要把BFS里的队列换成栈,就变成了DFS。同一个循环骨架,仅仅换一个容器,遍历顺序就完全不同。这也是为什么书里特意强调底层实现和访问规则分离——它们是两个可以自由组合的维度。
2.3 Bag:收集之后再统一处理
Bag可能是三个里最容易被忽视的,因为它看起来太简单了:一个只支持add和遍历的结构。但它对应着一种非常实用的编程模式:先收集足够的信息,再一次性做计算。
书里举例是计算一组数的均值和方差。如果数据是流式到达的,你可以边到边算;但如果数据需要先收集起来做多轮统计,或者你需要在收集过程中随时决定要算哪些指标,那Bag就很合适。
另一个典型场景是:从一批数据中随机抽样。你可以把所有候选数据放进一个Bag,然后遍历时可对每个元素以k/n的概率决定是否取出来。整个过程不需要知道数据总数,也不需要随机访问,Bag的语义刚刚好。
我自己在做一些离线数据分析脚本时,经常定义一个简单的Bag结构来收集日志中的异常信息,最后统一打印统计结果。相比直接用一个List,Bag的表达更忠实于“我只收集,不关心顺序”这个意图。
2.4 三种结构的选择对照
| 场景特征 | 推荐结构 | 原因 |
|---|---|---|
| 后到的先处理、需要回溯 | Stack | LIFO完美匹配嵌套和回溯逻辑 |
| 先到的先处理、生产消费速率不一致 | Queue | FIFO天然形成公平排队 |
| 只需要收集,后续统一处理 | Bag | 语义最简单,不需要顺序承诺 |
| 遍历所有元素且顺序无关 | Bag | 避免给别人造成“有序”的错觉 |
这三个结构还有一个共同点:每个核心操作(add、remove、遍历一个元素)在最坏情况下都是常数时间,使用链表或数组实现时都能做到。这个性能特征让它们可以放心地作为更高层算法的基础积木。
3. 两种实现路线的取舍:链表 vs 动态数组
书里的核心内容就是手把手实现这三个结构的链表版和数组版。这部分我建议不要只看,一定要自己敲一遍。下面我记录了自己实现时的关键细节和对比分析。
3.1 链表实现:节点设计是最关键的第一步
链表版的每个元素都是一个Node对象:
java复制private class Node {
Item item;
Node next;
}
这里有一个小细节:把Node定义成内部类,且属性不写访问修饰符。因为内部类可以访问外部类的私有成员,同时外部类所有方法也能直接访问Node的字段,省掉了getter/setter的噪音。这本书在后面的章节里也一直用这种写法。
Stack的链表实现核心就两个操作:
java复制public void push(Item item) {
Node oldFirst = first;
first = new Node();
first.item = item;
first.next = oldFirst;
size++;
}
public Item pop() {
if (isEmpty()) throw new NoSuchElementException();
Item item = first.item;
first = first.next;
size--;
return item;
}
注意pop里,我先把first.item取出来保存,然后让first指向下一个节点,最后返回保存的值。有人会问,为什么不在return里直接写first.next.item?如果先改变first再取item,逻辑就乱了,而且一定要先保存item再移动指针,否则等你把first换成下一个节点,原来的item就丢了。
然后是一个很容易被忽略的点:pop之后,那个被弹出的Node对象已经没有引用指向它了,Java的垃圾回收会收掉它。但是如果你用的是数组实现,情况就不一样,后面会专门说。
Queue的链表实现要多维护一个last指针:
java复制public void enqueue(Item item) {
Node oldLast = last;
last = new Node();
last.item = item;
last.next = null;
if (isEmpty()) {
first = last;
} else {
oldLast.next = last;
}
size++;
}
public Item dequeue() {
if (isEmpty()) throw new NoSuchElementException();
Item item = first.item;
first = first.next;
size--;
if (isEmpty()) {
last = null;
}
return item;
}
这里有个边界条件必须处理好:当队列只有一个元素时,first和last指向同一个Node。如果dequeue之后,要让last也变成null,否则你会持有一个已经出队的节点的引用,逻辑上出错,后续enqueue也会出问题。书里只用了一句话带过,但我第一次写漏了这个判断,调试半天才发现队尾指针变成了悬挂引用。
3.2 数组实现:扩容缩容的时机比想象中更难拿捏
数组实现最大的优势是内存连续、索引访问快,但需要一个预先分配的空间,所以得处理容量满了怎么办。
固定容量的栈最简单:
java复制public class FixedCapacityStack<Item> implements Iterable<Item> {
private Item[] a;
private int n;
@SuppressWarnings("unchecked")
public FixedCapacityStack(int capacity) {
a = (Item[]) new Object[capacity];
}
public boolean isEmpty() { return n == 0; }
public int size() { return n; }
public void push(Item item) {
if (n == a.length) {
resize(2 * a.length);
}
a[n++] = item;
}
public Item pop() {
if (n == 0) throw new NoSuchElementException();
Item item = a[--n];
a[n] = null;
if (n > 0 && n == a.length / 4) {
resize(a.length / 2);
}
return item;
}
private void resize(int capacity) {
Item[] temp = (Item[]) new Object[capacity];
if (n >= 0) System.arraycopy(a, 0, temp, 0, n);
a = temp;
}
}
这里面有几个细节值得展开说。
第一,扩容策略为什么是乘2,而不是加一个固定值?因为如果每次加1,push n个元素的总代价是O(n^2),均摊下来每个操作O(n)。而倍增可以让扩容的总代价控制在O(n),每个push平摊O(1)。这个均摊分析是算法思维的核心,书里到后面的章节会反复用到。
第二,缩容为什么是n == a.length / 4才触发,容量减半?如果等到n == a.length / 2时就缩容,可能会遇到一种反复横跳的情况:满了扩容一倍,然后pop一个就缩容一半,再push一个又扩容……每次操作都触发resize,均摊代价暴涨。所以留出空余空间,让扩容和缩容之间有缓冲区间。这是一种经典的防御性设计。
第三,pop时为什么不直接return a[--n]?因为数组不像链表,对象的生命周期不受引用计数管理。如果你只是把n减1,数组中仍保留着那个对象的引用,GC不会回收它。这就是传说中的对象游离(loitering)。解决办法就是a[n] = null,主动断开引用。这个细节不写测试基本发现不了,但它会造成内存泄漏,长时间运行的程序会很危险。
3.3 链表和数组的对比:没有绝对优劣
| 维度 | 链表实现 | 数组实现 |
|---|---|---|
| 每操作时间复杂度 | O(1) | 平摊O(1) |
| 空间占用 | 每个节点有额外对象头和引用 | 有预分配容量,可能浪费 |
| 内存局部性 | 差,节点散落在堆里 | 好,连续空间,缓存友好 |
| 实现复杂度 | 指针操作容易出错 | 扩容缩容逻辑需要小心 |
| 迭代稳定性 | 天然支持,只能next | 要注意数组下标边界 |
我自己习惯的判断是:元素总数可预估、操作频繁、对缓存敏感的场景用数组;元素数量不确定、需要频繁插入删除、不在乎一点内存开销的场景用链表。 书里给出的参考建议是一致的:先测试,再优化,别在实现细节上过早纠结。
4. API设计与泛型迭代器:这本书为什么要这么写
1.3节的另一个重点,是教你怎么设计一个“好用”的数据结构API。这一部分看似偏工程,实际上是为了让你以后能用同样思路去读标准库源码。
4.1 泛型:让容器和数据类型解耦
书里所有集合类都用Item这个泛型参数:
java复制public class Stack<Item> implements Iterable<Item>
这样你写一个栈,存整数、字符串、自定义对象都可以复用。Java泛型的实现方式是类型擦除,运行时JVM其实不知道Item是什么类型,所以编译器做了类型检查后就把它替换成了Object。
这里有一个经典坑:不能直接创建泛型数组。你不能写a = new Item[capacity],因为类型擦除后Item就没了。书里的做法是创建一个Object数组然后强制类型转换:
java复制a = (Item[]) new Object[capacity];
这行代码其实有一个不安全的警告,因为运行时数组的实际类型是Object[],如果你在别处把这个数组当作Item[]传给别的方法,擦除之后可能暴露问题。但对这本书的例子来说,这个写法足够安全,因为所有元素在push时都经过编译器检查,pop时类型不会错。
4.2 实现Iterable:让foreach成为可能
如果你只实现了一个iterator()方法,返回一个Iterator,那么用户就可以用增强for循环遍历你的结构:
java复制public Iterator<Item> iterator() {
return new StackIterator();
}
关键在迭代器的实现。对于链表栈,迭代器只需要维护一个当前节点游标:
java复制private class StackIterator implements Iterator<Item> {
private Node current = first;
public boolean hasNext() { return current != null; }
public Item next() {
if (!hasNext()) throw new NoSuchElementException();
Item item = current.item;
current = current.next;
return item;
}
public void remove() { throw new UnsupportedOperationException(); }
}
注意remove()方法,大多数只读迭代器都会写成抛异常。因为容器的迭代器通常不允许在遍历过程中修改集合,否则会出现并发修改的问题。Java标准库里的做法是在修改操作时把modCount加1,迭代器检查到modCount变了就抛ConcurrentModificationException。这本书里的简单实现选择直接禁止remove,是一个很合理的简化。
4.3 不变式:确保数据结构始终有效
书中每个结构都强调了一组不变式(invariants)。以链表栈为例:
- 栈要么为空,要么
first指向一个Node。 - Node的
next要么为null,要么指向另一个Node。 - 从
first出发沿着next走,正好能经过所有元素。
不变式是调试的锚点。每次操作前后,你都应该能在心里检查一遍这些条件是否成立。书里推荐的check()方法(先回收校验堆内存再递归检查)虽然只用于调试,但建议保留。我在实现每个结构后,都会写一个check方法,跑完随机操作序列后再验证,帮我抓到了好几个边界bug。
5. 学习这本书时值得注意的隐藏细节
这一节写几个我自己踩过的坑和观察到的容易被忽略的点,希望能帮你节省时间。
5.1 尽可能别直接使用 java.util.Stack
java.util.Stack是JDK 1.0时代的产物,它继承了Vector,所以它除了栈操作外还有add(index, element)、get(index)这些列表操作。这意味着你可以从栈中间插入元素,破坏了栈的封装性。更重要的是,Vector的方法都是synchronized的,在没有并发需求时纯属性能损耗。
官方现在推荐的栈实现是Deque接口,比如ArrayDeque。它同样支持push/pop/peek,而且底层是环形数组,速度更快。这本书因为是讲算法而不是讲Java标准库,所以没有讨论这个置换,但实际工程里你应该直接使用ArrayDeque。
5.2 数组实现的队列要注意“环形”这个概念
如果你用数组实现队列,不能像栈一样只在数组尾部操作。dequeue要从头部取,如果每次dequeue都把后面的元素往前搬,时间复杂度就是O(n)。正确做法是把数组看成一个环,维护head和tail两个索引,满了就扩容,空了就缩容。这个思路虽然比栈复杂一点,但你会得到真正的均摊O(1)队列。
很多人在这一步卡住,我觉得关键是画图。把数组画成一个圆弧,head和tail像指针一样绕圈走,比在脑内模拟要直观得多。我在本地跑了两百个随机操作的序列,才确定head和tail的边界更新都正确。
5.3 写测试比写实现更需要耐心
这本书每一节后面都有练习题,但我强烈建议自己先写一套简单的测试模板。下面是我覆盖栈和队列时固定跑一遍的用例:
- 从一个空栈pop,应该抛异常。
- push一个再pop,应该返回同一个元素。
- 连续push 10000个,再连续pop,顺序应该完全逆转。
- 队列enqueue 10000个,再dequeue,顺序应该完全一致。
- 在迭代过程中触发结构修改,应该尽快抛出异常。
- 内存占用测试:存储大量元素再全部pop,用内存分析工具确认不会残留引用。
这套简单测试帮我抓出了至少三个实现bug,而且每次改进代码后都能快速回归。如果你以后要做算法题或者写基础组件,这个习惯会非常值钱。
5.4 以“自己实现一遍”代替“读一遍就过”
我见过很多人读这本书,到1.3节觉得内容太简单,扫一眼就翻过去了。但后面的章节会大量用到栈和队列——二叉树遍历、图搜索、符号表实现、字符串处理——如果这里没有建立“底层是链表还是数组”的敏感度,后面读源码会非常吃力。
具体操作建议是:这一节至少动手实现三个结构、两种底层、再加上迭代器,总共六个文件。不用追求一次写对,重点是让代码跑通、测试通过、能解释清楚每一步在干什么。这个投入换来的是对整个算法体系的基础直觉,我觉得非常划算。
我自己把这段代码在本地保存成了一个小工程,后续做并查集、图遍历练习时直接复用了这些底层结构,省了很多事。这大概也是这本书把这三个结构放在第一章的用意:它们不是要学的对象,而是要用的工具。
