用一个栈搞定二叉树前/中/后序遍历(非递归版):把递归"翻译"成 while 循环
很多人写递归遍历很顺手,但一到非递归就开始迷糊:栈怎么压?什么时候弹?为什么后序还要 prev?
其实核心只有一句话:
非递归遍历 = 用显式栈模拟递归调用栈,再用"访问时机"决定前/中/后序。
三种遍历的区别不在"走法",而在"什么时候把节点加入结果"。
- 前序:第一次见到节点就访问(根最早)
- 中序:左边走到底后,弹栈时访问(根居中)
- 后序:左右都处理完,最后才访问(根最晚)(所以需要额外信息判断"右子树是否已经处理过")
下面按你给的三段代码逐段讲。
0. 先统一一个"心智模型":栈里装的是什么?
递归遍历时,每次函数调用会把"当前节点"和"接下来要做的事"压到调用栈里。
非递归就是把"当前节点"手动压到 stack,并用 cur 指针控制下一步去哪。
常见骨架长这样:
java
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
stack.push(cur);
cur = cur.left;
}
// 到这里说明:左链走到底了
TreeNode top = stack.pop() or stack.peek();
// ...访问 or 转向右子树...
}
差别在:
- 前序:压栈时就访问
- 中序:弹栈时访问
- 后序:满足"左右都完成"才弹栈并访问
1) 中序遍历(左-根-右)非递归:最经典模板
中序代码:
java
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {//左根右
Stack<TreeNode> stack = new Stack<>();
List<Integer> list = new ArrayList<>();
TreeNode cur = root;
while(cur != null || !stack.isEmpty()){
while(cur != null){
stack.push(cur);
cur = cur.left;
}
TreeNode top = stack.pop();
list.add(top.val);
cur = top.right;
}
return list;
}
}
过程讲解(像在走图)
Step A:一路向左"钻到底",沿途压栈
java
while(cur != null){
stack.push(cur);
cur = cur.left;
}
这一步把从当前节点出发的"左链"全部压栈。
压栈顺序大概就是:根、根的左、左的左......
Step B:左边到头了,开始"回溯"
java
TreeNode top = stack.pop();
list.add(top.val);
cur = top.right;
pop()代表:递归里"左子树做完了,回到当前节点"- 此时访问
top,就实现了"左-根" - 然后
cur = top.right转向右子树,继续同样流程
为什么是中序?
因为访问发生在 左子树处理完之后、右子树开始之前,刚好是"左根右"的根位置。
✅ 这份代码可以当作中序非递归的"标准答案模板"。
2) 后序遍历(左-右-根)非递归:关键在 prev
后序代码:
java
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
if(root == null) return list;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
TreeNode prev = null;
while(cur != null || !stack.isEmpty()){
while(cur != null){
stack.push(cur);
cur = cur.left;
}
TreeNode top = stack.peek();
if(top.right == null || top.right == prev){
list.add(top.val);
stack.pop();
prev = top;
}else{
cur = top.right;
}
}
return list;
}
}
为什么后序比中序难?
后序要"根最后访问"。
但当左链走到底时,栈顶节点的左子树确实做完了------可右子树可能还没做 。
所以不能像中序那样直接 pop() 访问。
这就是 prev 的意义:
prev记录"刚刚完成访问(出栈)的节点",用它判断右子树是不是已经处理过。
过程拆解(核心分支只看这一段)
java
TreeNode top = stack.peek();
if(top.right == null || top.right == prev){
// 右子树为空 或 右子树刚处理完
visit(top); pop(top); prev = top;
}else{
// 右子树存在且没处理
cur = top.right;
}
情况 1:top.right == null
右子树为空,那就说明:
- 左子树已经完成(因为能来到 peek)
- 右子树不存在
- 所以当前节点可以访问(根最后)
情况 2:top.right == prev
这句是精髓:
"栈顶节点的右孩子刚刚被访问完",说明右子树已经处理完毕。
于是当前节点也满足"左右都完成",可以访问并出栈。
情况 3:右子树存在且未处理
这时候不能访问根,必须先去右子树:
java
cur = top.right;
然后循环会再次走 "一路向左压栈",把右子树的左链压进去。
为什么这一定是后序?
因为每个节点只有在 左子树完成 且 右子树完成 之后才会被访问,根必然最后。
⭐ 易错点重点标识
- 如果没有
prev,很容易在"从右子树回来"时无法判断是否该访问根,导致反复进入右子树或死循环。 peek()必须配合条件判断;这里不能直接pop(),否则会提前访问根,顺序变坏。
3) 前序遍历(根-左-右)非递归:访问时机前移
前序代码:
java
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
if(root == null) return list;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
stack.push(cur);
while(!stack.isEmpty()){
while(cur != null){
stack.push(cur);
list.add(cur.val);
cur = cur.left;
}
TreeNode top = stack.pop();
cur = top.right;
}
return list;
}
}
先讲它的核心思想
前序要求"根最先访问"。
所以访问时机要比中序更早:第一次遇到节点就访问。
你这份代码确实做到了:在压栈时就 list.add(cur.val)。
java
while(cur != null){
stack.push(cur);
list.add(cur.val);
cur = cur.left;
}
然后左链走完,弹一个节点出来,转向它的右子树:
java
TreeNode top = stack.pop();
cur = top.right;
但这里有个小提醒(实现细节)
这段代码里有一行:
java
stack.push(cur); // 进入 while 前又 push 了一次 root
而在循环里又 stack.push(cur),因此根节点会被压栈两次(不过访问只发生一次,所以结果通常不受影响,但栈操作会冗余,逻辑也不够干净)。
更常见、更"干净"的前序非递归写法通常是下面两种之一:
写法 A:一路向左,访问并压栈(不需要额外先 push)
java
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()) {
while (cur != null) {
res.add(cur.val); // 前序:先访问
stack.push(cur);
cur = cur.left;
}
TreeNode top = stack.pop();
cur = top.right;
}
return res;
}
写法 B:标准"栈模拟"模板(弹出就访问,先压右再压左)
java
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
res.add(node.val);
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
return res;
}
两种都对。写法 A 和你中序的骨架更一致,适合"统一模板记忆";写法 B 很直观,适合初学者。
4) 三种遍历的对照总结:差别只在"访问时机"
把三段代码放在一起看,其实只改了一个点:什么时候把 top.val 加进结果。
| 遍历 | 顺序 | 非递归共同骨架 | 访问时机 |
|---|---|---|---|
| 前序 | 根-左-右 | 左链压栈 + 转右 | 压栈/第一次遇到节点时访问 |
| 中序 | 左-根-右 | 左链压栈 + 转右 | 弹栈时访问(左完成后) |
| 后序 | 左-右-根 | 左链压栈 + 条件转右 | 左右都完成时访问 (靠 prev 判断) |
5) 最容易踩的坑(建议贴在笔记顶部)
-
后序一定要有"右子树是否处理完"的判断
没有
prev或等价机制,极容易死循环或顺序错。 -
中序的关键动作是:pop 后访问,再转 right
少一步都会乱序。
-
前序的关键是:访问要发生在走左之前
把
add放到 pop 后,就变成中序/后序味道了。 -
尽量用
ArrayDeque代替Stack(工程上更推荐)
Stack是老类(继承自 Vector),同步开销大;Deque更现代更快。算法逻辑不变。
结尾:把递归"翻译"成迭代的诀窍
真正的诀窍不是背代码,而是把递归的三件事想明白:
- 递归"往下走" → 用
cur和while(cur != null)模拟 - 递归"回到父节点" → 用
stack保存路径 - 前/中/后序的区别 → 访问发生在:下潜前 / 回溯时 / 左右都完成后
理解到这层,换题、换写法都不会慌。