用栈和队列揭示边界
摘要
一篇技术博文,解释了在树的遍历中,使用栈和队列相比于递归的优势,并附有Rust代码示例。
<p><a href="https://lobste.rs/s/aw5hgu/revealing_frontier_with_stacks_queues">评论</a></p>
查看缓存全文
缓存时间: 2026/06/03 09:45
# 揭示栈与队列的前沿
来源:https://dystroy.org/blog/stack-and-queues/
能够用栈和队列思考是一种被忽视的超能力。当涉及树和其他图结构时,这往往比递归更有利于看清问题本质。计算机科学课程喜欢教递归,因为它看起来简单又优雅——尤其在函数式编程成为课程内容时,更尤其是在黑板上画图演示时。你可能已经读过很多次,也可能亲身经历过:递归并不总是高效的,尤其在现代计算机上。但更有趣的一点是,它往往也是一种糟糕的心智模型,会让编程变得更复杂、更不易适应。而更有趣的是,递归有时确实大放异彩,这也是它至今未被淘汰的原因。但即便如此,这篇文章的真正重点也不在于此。
## 树的遍历
我们先从文件树的遍历开始探索。在现实世界中,你永远不可能一次性列出目录中所有深层文件:你必须处理用户中断、超时以及各种限制。我们简化为仅设置一个最大项目数来模拟这种复杂度。
### 深度优先遍历(自然顺序)
在 Rust 中,递归代码可能是这样:
```rust
fn add_first_paths_recursive(
dir: &Path,
list: &mut Vec<PathBuf>,
max: usize,
) -> Result<(), io::Error> {
if list.len() >= max {
return Ok(());
}
list.push(dir.to_path_buf());
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
add_first_paths_recursive(&path, list, max)?;
if list.len() >= max {
break;
}
}
}
Ok(())
}
```
它的作用是以标准树表示的方式列出文件,即深度优先搜索(DFS),且文件顺序与系统返回的顺序一致。这段代码很直观,你知道不会有意外,顺序正如预期。当然存在栈分配的隐性开销,但这很小。提前退出的行为则稍显麻烦,无论是代码上(很难避免重复)还是执行上(调用栈展开时每一层都必须执行同样的检查)。
现在,让我们看看基于栈的、以同样顺序进行相同遍历的函数。它并没有想象中那么美观:
```rust
fn add_first_paths_with_stack_dfs(
dir: &Path,
list: &mut Vec<PathBuf>,
max: usize,
) -> Result<(), io::Error> {
let mut todo = vec![dir.to_path_buf()];
while let Some(current) = todo.pop() {
if list.len() >= max {
break;
}
if current.is_dir() {
let mut children = Vec::new();
for entry in fs::read_dir(¤t)? {
let path = entry?.path();
children.push(path);
}
list.push(current);
for path in children.into_iter().rev() {
todo.push(path);
}
} else {
list.push(current);
}
}
Ok(())
}
```
当我们向待办列表添加路径并出栈时,我们必须反向迭代(因此有了 `children.into_iter().rev()`)。这使得代码更难理解(甚至第一次很难写对)。提前退出的处理要好得多,但说实话,这段代码远没有那么直观。**这里递归大放异彩**。在处理现实应用中的约束(如中断)时,递归可能效率稍低,但在这个场景下,递归代码与我们的意图完美契合。
### 当不需要特定顺序时
如果我们并不需要实时打印出树形结构,递归就不是最简单的方案了。下面这个方案非常简洁,使用一个栈作为待访问目录的待办列表:
```rust
fn add_first_paths_with_stack(
dir: &Path,
list: &mut Vec<PathBuf>,
max: usize,
) -> Result<(), io::Error> {
let mut todo = vec![dir.to_path_buf()];
while let Some(current) = todo.pop() {
if list.len() >= max {
break;
}
if current.is_dir() {
for entry in fs::read_dir(¤t)? {
let path = entry?.path();
todo.push(path);
}
}
list.push(current);
}
Ok(())
}
```
这个版本没有任何别扭之处。而且由于我们将“前沿”具体化了,改变控制流、处理中断、暂停迭代、交错任务(比如同时遍历另一棵树)都非常容易。
### 广度优先搜索
如果你想先完全探索当前层,再深入下一层呢?只需做个简单改动:把 LIFO 栈换成 FIFO 队列(在 Rust 中使用 `VecDeque` (https://doc.rust-lang.org/beta/std/collections/struct.VecDeque.html)):
```rust
fn add_first_paths_with_queue(
dir: &Path,
list: &mut Vec<PathBuf>,
max: usize,
) -> Result<(), io::Error> {
let mut todo = VecDeque::from([dir.to_path_buf()]);
while let Some(current) = todo.pop_front() {
if list.len() >= max {
break;
}
if current.is_dir() {
for entry in fs::read_dir(¤t)? {
let path = entry?.path();
todo.push_back(path);
}
}
list.push(current);
}
Ok(())
}
```
控制流依然容易处理,逻辑也可以调优。例如,[broot](https://dystroy.org/broot) 正是基于此实现了高效搜索和并行下降,同时不会锁定 UI。
## 图的探索
当图的连通性比树更强时,纯递归遍历变得不可能。但栈/队列的方法依然简单。例如,下面的代码返回所有从某个节点可达的节点:
```rust
fn reachable_from(root: Node) -> HashSet<Node> {
let mut visited = HashSet::new();
let mut todo = VecDeque::new();
visited.insert(root);
todo.push_back(root);
while let Some(current) = todo.pop_front() {
for neighbour in neighbours_of(current) {
if visited.insert(neighbour) { // 如果已存在则返回 false
todo.push_back(neighbour);
}
}
}
visited
}
```
这非常容易调优:
- 把 `VecDeque` 换成 `Vec` 作为 `todo`,你就会先探索远处的节点(等价于 DFS)。
- 不返回所有已访问节点,而是返回第一个满足某个约束的节点,从而检查能否到达某个集合。
只需做一点额外修改,你就得到了路径搜索:为每个节点存储它来自哪个节点。
```rust
fn find_path(from: Node, to: Node) -> Option<Vec<Node>> {
let mut todo = VecDeque::from([from]);
// 同时充当“已访问”集合和历史追踪器
let mut came_from: HashMap<Node, Node> = HashMap::new();
while let Some(current) = todo.pop_front() {
if current == to {
// 最后重建路径
let mut path = vec![current];
while let Some(&parent) = came_from.get(path.last().unwrap()) {
path.push(parent);
}
path.reverse();
return Some(path);
}
for neighbour in neighbours_of(current) {
if neighbour != from && !came_from.contains_key(&neighbour) {
came_from.insert(neighbour, current);
todo.push_back(neighbour);
}
}
}
None
}
```
把队列换成二叉堆,优先搜索估计离目标最近的节点,你就得到了 [A* 算法](https://en.wikipedia.org/wiki/A*_search_algorithm)。注意到这仍然是一个线性操作,你能添加测试、暂停,甚至存储搜索状态吗?
## 核心思路
这种思路的核心不仅仅是栈、队列和二叉堆,而是将“前沿”具体化——把时序执行转化为数据,使其变得可管理、可暂停、更易于推理。一旦习惯了这种思考方式,整个世界的问题都会变得容易解决,不是通过照搬书本上的计算机科学算法,而是自然而然地,同时满足现实世界问题的所有约束。
我在这里只提到了图,但你在递归下降解析器、正则表达式引擎(可以重新设计为显式自动机)或游戏行为树中也会遇到完全相同的架构性挑战。每当你把执行流转化为数据时,你的系统就变得本质上可控、可测试、可操作。
相似文章
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。
Zerostack – 一个受Unix启发的纯Rust编写的编码助手
Zerostack 是一个完全用 Rust 构建的受 Unix 启发的编码助手,旨在帮助开发人员进行代码生成和自动化。
Rust中Pretty Printer实现的新设计
一篇博客文章,探讨了Rust中Pretty Printer实现的新设计,解决了将函数式编程研究适应到没有垃圾回收的系统语言中的挑战,并比较了现有的方法,比如'pretty' crate和Oppen风格的Pretty Printer。
递归模式的隐秘历史
一场演讲,追溯从goto面条代码到结构化循环,再到递归模式的演化历程,展示控制流抽象如何映射数据结构,以及为何大多数语言仍把最好的组合子藏起来。
我们如何(及为何)将生产环境的C++前端基础设施重写为Rust
NearlyFreeSpeech.NET 将其生产环境的C++前端基础设施(nfsncore)重写为Rust,该系统负责所有传入请求的路由、缓存和访问控制。迁移的动机是Rust的安全性保证、性能、生态系统优势以及老化的C++代码库的局限性。