作为一名经历过无数次算法面试的老手,我深知二叉树遍历在技术面试中的高频出现率。今天我想和大家深入探讨非递归遍历的实现技巧,以及如何通过Morris算法进行极致优化。相信看完这篇,你不仅能轻松应对面试,更能真正理解这些算法背后的精妙设计。
首先明确一点:递归遍历虽然简洁,但在实际工程中往往存在栈溢出风险。非递归实现不仅能规避这个问题,更能体现你对数据结构和算法本质的理解。所有非递归遍历的核心思想都是手动模拟递归的系统栈,通过栈的"后进先出"特性控制节点的访问顺序。
关键理解:递归的本质是系统帮我们维护了一个调用栈,而非递归实现则是我们自己用数据结构显式地维护这个栈。
前序遍历(根→左→右)的非递归实现可以说是三种遍历中最直观的。其核心逻辑是:先处理根节点,再利用栈的"后进先出"特性,先压右子树、后压左子树,保证弹出时左子树先处理。
java复制public List<Integer> preorderTraversal(TreeNode root) {
Stack<TreeNode> stack = new Stack<>();
List<Integer> result = new ArrayList<>();
if(root == null) return result;
stack.push(root);
while(!stack.isEmpty()){
TreeNode cur = stack.pop();
result.add(cur.val); // 处理当前节点
if(cur.right != null) stack.push(cur.right); // 先右
if(cur.left != null) stack.push(cur.left); // 后左
}
return result;
}
为什么这个顺序能保证前序遍历?
中序遍历(左→根→右)的实现比前序稍复杂,核心逻辑是:先遍历到左子树最深处(沿途节点入栈),回溯时处理根节点,最后处理右子树。
java复制public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
TreeNode curr = root;
while(curr != null || !stack.isEmpty()){
// 一路向左
while(curr != null){
stack.push(curr);
curr = curr.left;
}
curr = stack.pop();
result.add(curr.val); // 处理当前节点
curr = curr.right; // 转向右子树
}
return result;
}
为什么中序遍历不能像前序那样先入栈根节点?
这是一个很好的思考题。前序遍历可以先入栈根节点是因为它的核心是"根优先";而中序遍历的核心是"左子树优先",如果直接入栈根节点:
注意到中序遍历的while条件是curr != null || !stack.isEmpty(),这比前序遍历多了一个条件。这是因为:
curr != null处理两种情况:
!stack.isEmpty()处理:
后序遍历(左→右→根)的标准非递归实现较为复杂,需要记录节点的访问状态。但在面试中,我们可以使用一个取巧的方法:对前序遍历稍作修改,最后反转结果。
java复制public List<Integer> postorderTraversal(TreeNode root) {
LinkedList<Integer> result = new LinkedList<>();
Stack<TreeNode> stack = new Stack<>();
if(root == null) return result;
stack.push(root);
while(!stack.isEmpty()){
TreeNode node = stack.pop();
result.addFirst(node.val); // 添加到头部,相当于反转
// 注意入栈顺序与前序相反
if(node.left != null) stack.push(node.left);
if(node.right != null) stack.push(node.right);
}
return result;
}
为什么这个方法有效?
虽然反转法简单,但了解正统的实现也很重要:
java复制public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
TreeNode prev = null; // 记录前一个访问的节点
while(root != null || !stack.isEmpty()){
while(root != null){
stack.push(root);
root = root.left;
}
root = stack.pop();
if(root.right == null || root.right == prev){
// 右子树已访问或为空
result.add(root.val);
prev = root;
root = null;
} else {
// 右子树未访问
stack.push(root);
root = root.right;
}
}
return result;
}
这种方法通过记录前一个访问的节点来判断右子树是否已处理,虽然复杂但更符合后序遍历的本质。
Morris遍历的惊人之处在于它实现了O(1)空间复杂度的遍历,核心思想是利用树中的空指针来临时保存信息,避免使用额外空间。
Morris遍历的关键步骤:
java复制public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
TreeNode curr = root;
while(curr != null){
if(curr.left == null){
res.add(curr.val);
curr = curr.right;
} else {
// 找到前驱节点
TreeNode prev = curr.left;
while(prev.right != null && prev.right != curr){
prev = prev.right;
}
if(prev.right == null){
// 建立线索
prev.right = curr;
curr = curr.left;
} else {
// 断开线索
prev.right = null;
res.add(curr.val);
curr = curr.right;
}
}
}
return res;
}
java复制public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
TreeNode curr = root;
while(curr != null){
if(curr.left == null){
res.add(curr.val);
curr = curr.right;
} else {
TreeNode prev = curr.left;
while(prev.right != null && prev.right != curr){
prev = prev.right;
}
if(prev.right == null){
res.add(curr.val); // 与前序的唯一区别
prev.right = curr;
curr = curr.left;
} else {
prev.right = null;
curr = curr.right;
}
}
}
return res;
}
优势:
局限:
这是一个经典问题,要求将二叉树原地展开为一个单链表,且顺序与前序遍历相同。使用Morris算法可以优雅地解决:
java复制public void flatten(TreeNode root) {
TreeNode curr = root;
while(curr != null){
if(curr.left != null){
// 找到左子树的最右节点
TreeNode prev = curr.left;
while(prev.right != null){
prev = prev.right;
}
// 将右子树接到前驱节点
prev.right = curr.right;
// 左子树移到右边
curr.right = curr.left;
curr.left = null;
}
curr = curr.right;
}
}
关键理解:
| 遍历方式 | 核心特征 | 处理节点时机 | 入栈顺序 | 空间复杂度 |
|---|---|---|---|---|
| 前序 | 根优先 | 节点弹出时立即处理 | 先右后左 | O(h) |
| 中序 | 左子树优先 | 左子树空后弹栈处理 | 左子树沿途入栈 | O(h) |
| 后序 | 根最后处理 | 复杂的状态判断 | 多种实现方式 | O(h) |
中序遍历忘记移动指针:
java复制while(curr != null){ // 错误:缺少!stack.isEmpty()条件
stack.push(curr);
curr = curr.left;
}
后序遍历的状态判断错误:
java复制if(root.right == null){ // 错误:还需要判断root.right == prev
result.add(root.val);
prev = root;
root = null;
}
Morris遍历中未正确恢复树结构:
java复制if(prev.right == curr){
// 忘记断开链接
res.add(curr.val); // 应该在断开链接后处理
curr = curr.right;
}
二叉树遍历是许多复杂算法的基础,比如:
在实际工程中,除了算法选择外,还可以:
经过多年的算法实践和面试经验,我对二叉树遍历有几点深刻体会:
最后分享一个小技巧:在面试中,可以先写出递归版本,然后说"我们知道递归可能有栈溢出风险,下面我展示如何改为非递归实现",这样既展示了基础知识,又体现了进阶能力。