Stack<T> 就像一摞东西:用 Push 把元素放到最上面,用 Pop 从最上面取下来,所以最后放进去的元素最先出来(LIFO,后进先出)。只能访问栈顶,而对栈顶的每个操作都是常数时间。
Push、Pop 和 Peek
输出:
On top: green
Count: 3
Took green
On top: red
Took red
Took blue
Count: 0
green 是最后压入的,所以它最先被弹出。Peek 返回栈顶元素但不改变栈,在决定是否取走之前,可以用它先看看 Pop 会给你什么。
空栈异常与 TryPop
对空栈调用 Pop 或 Peek 会抛出 InvalidOperationException。这种情况最常出现在解析器和算法中:输入里的闭合项比开启项还多。
输出:
Caught InvalidOperationException
True 10
False 0
TryPop 和 TryPeek(.NET Core 2.0 及以后版本)在栈为空时返回 false,并把 out 变量设为默认值,这里是 0。在 .NET Framework 中,请先检查 Count > 0。
遍历顺序:栈顶在前
枚举栈不会移除任何元素,而且是从栈顶往下进行的,顺序和 Pop 返回元素的顺序一样:
输出:
checkout products home
checkout > products > home
True
home
checkout
颠倒的副本常常让人措手不及:构造函数接收任意 IEnumerable<T> 并按顺序压入其中的元素,而栈枚举时栈顶在前,所以原来的栈顶最终落到了副本的底部。先把序列反转(LINQ 的 Reverse() 会从栈底开始返回元素),得到的副本栈顶就和原来一样了。
把一组元素压入一个新栈也会把它们反转,这是反转序列的一个快捷方法:new Stack<char>("hello") 弹出的顺序是 o, l, l, e, h。
示例:撤销历史
编辑器会把每一次修改都放进栈里。撤销会弹出最近一次修改并将其还原;重做则用第二个栈保存被撤销的修改。
输出:
Hello, world!
Hello, world
Hello
Hello, world
保存完整快照是最简单的做法。真正的编辑器压入的是小型命令对象(插入了什么、插入在哪里),每个对象都有一个撤销自身的方法,但两个栈的工作方式是一样的。
示例:括号匹配
检查 (、[ 和 { 是否按正确的顺序闭合,是栈的标准练习题,每个编译器和 JSON 解析器内部都有同样的逻辑。
输出:
"f(a[i], {x: 1})" -> True
"(]" -> False
"((a)" -> False
"a)b(" -> False
"" -> True
三项失败检查对应括号出错的三种情况:没有打开的括号却出现了右括号(a)b(,由 Count == 0 捕获,而不是让 Pop 抛出异常),右括号类型不对((]),以及左括号始终没有闭合(((a),由最后的检查捕获)。
其他用途
- 深度优先搜索。 把广度优先搜索中的队列换成栈,遍历就会先深入再展开。当输入的嵌套深到可能引发
StackOverflowException(这个异常无法被捕获)时,也可以用显式的栈来代替递归。 - 表达式求值。 后缀表达式(
3 4 + 2 *)的求值方法是:遇到数字就压入,遇到运算符就弹出两个数。 - 回溯。 浏览历史、走迷宫和解析器状态都会压入一个位置,遇到死路时再弹回到这个位置。
先进先出的对应数据结构见 Queue。
Stack、Queue 与 List 对比
Stack<T> | Queue<T> | List<T> | |
|---|---|---|---|
| 取出顺序 | 最新的在前 | 最旧的在前 | 任意,按索引 |
| 添加 | Push | Enqueue | Add、Insert |
| 移除 | Pop(栈顶) | Dequeue(队首) | Remove、RemoveAt |
| 查看 | Peek | Peek | list[i] |
| 安全版本 | TryPop、TryPeek | TryDequeue、TryPeek | 不需要 |
多线程场景下,System.Collections.Concurrent 中的 ConcurrentStack<T> 提供了无需加锁的 Push、TryPop 和 TryPeek。
常见错误
- 弹出前不做检查。 空栈会抛出
InvalidOperationException;检查Count或使用TryPop。 - 以为
foreach会从第一个压入的元素开始。 它是从栈顶开始的。 - 用
new Stack<T>(stack)复制栈。 得到的副本是反的。 - 在遍历同一个栈的
foreach中压入元素。 会抛出异常;请使用while (stack.Count > 0)循环。
常见问题
C# 中的 Stack 是什么?
System.Collections.Generic 中的 Stack<T> 是后进先出(LIFO)的集合。Push 把元素放到栈顶,Pop 移除并返回栈顶元素,Peek 返回栈顶元素但不移除它。这三个操作都是常数时间。
在 C# 中对空栈调用 Pop 会怎样?
栈为空时,Pop 和 Peek 会抛出 InvalidOperationException。可以先检查 stack.Count > 0,或者使用 TryPop(out var item) 和 TryPeek(out var item),它们不会抛出异常,而是返回 false(.NET Core 2.0 及以后版本)。
foreach 是按什么顺序遍历 Stack 的?
从栈顶往下:最后压入的元素最先出现,和 Pop 返回它们的顺序相同。ToArray() 也使用同样的顺序。由此带来的结果是,new Stack<T>(otherStack) 会得到一个顺序颠倒的副本,因为构造函数是按枚举的顺序依次压入元素的。
C# 中 Stack 和 Queue 有什么区别?
Stack<T> 先返回最新的元素(后进先出),而 Queue<T> 先返回最旧的元素(先进先出)。撤销历史、嵌套结构和深度优先搜索用栈;按到达顺序处理任务和广度优先搜索用队列。
C# 中如何检查括号是否匹配?
把字符串扫描一遍。每遇到一个左括号,就把它压入 Stack<char>。每遇到一个右括号,栈必须不为空,并且栈顶必须是与之配对的左括号,然后把它弹出。扫描结束时栈为空,括号就是匹配的。