1. 二叉树遍历基础:从理论到实践
作为一名算法工程师,二叉树遍历是我们每天都要打交道的基础技能。很多人觉得这很简单,但真正能把各种遍历方式的原理和实现细节讲清楚的人并不多。今天我就结合自己刷题和面试的经验,系统梳理一下二叉树遍历的核心要点。
二叉树遍历主要分为两大类:深度优先搜索(DFS)和广度优先搜索(BFS)。DFS又细分为前序、中序和后序三种遍历方式,而BFS通常指层序遍历。理解它们的区别和适用场景,对解决LeetCode上的二叉树问题至关重要。
2. 深度优先搜索(DFS)详解
2.1 递归实现:最直观的解法
递归是理解DFS最自然的方式。我们先来看一个通用的递归模板:
java复制private void dfs(TreeNode node, List<Integer> res) {
if (node == null) return;
// 前序位置
dfs(node.left, res);
// 中序位置
dfs(node.right, res);
// 后序位置
}
这个模板的精妙之处在于,只需调整处理当前节点的位置,就能实现三种不同的遍历顺序:
- 前序遍历:在处理左右子树之前访问当前节点(中→左→右)
- 中序遍历:在处理左子树之后,右子树之前访问当前节点(左→中→右)
- 后序遍历:在处理左右子树之后访问当前节点(左→右→中)
提示:递归虽然简洁,但要注意栈溢出问题。对于深度很大的树,迭代法更安全。
2.2 迭代实现:空指针标记法
面试中常被要求用迭代实现DFS,这里介绍一种优雅的"空指针标记法"。核心思想是用null标记已经访问过但还未处理的节点:
java复制public List<Integer> traversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
if (root != null) stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
if (node != null) {
// 根据不同遍历顺序调整入栈顺序
} else {
node = stack.pop();
res.add(node.val);
}
}
return res;
}
三种遍历方式的差异仅在于节点入栈顺序:
2.2.1 前序遍历
java复制// 入栈顺序:右→左→中→null
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
stack.push(node);
stack.push(null);
2.2.2 中序遍历
java复制// 入栈顺序:右→中→null→左
if (node.right != null) stack.push(node.right);
stack.push(node);
stack.push(null);
if (node.left != null) stack.push(node.left);
2.2.3 后序遍历
java复制// 入栈顺序:中→null→右→左
stack.push(node);
stack.push(null);
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
经验分享:空指针标记法的关键在于理解"null"的作用——它标记下一个弹出的节点应该被处理(加入结果集),而不是继续展开。
3. 广度优先搜索(BFS)与层序遍历
3.1 标准层序遍历模板
BFS通常使用队列实现,以下是标准的层序遍历模板:
java复制public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null) return res;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> level = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
res.add(level);
}
return res;
}
3.2 典型应用:连接相邻节点
LeetCode 116和117题要求连接每个节点的next指针,是层序遍历的经典应用:
java复制public Node connect(Node root) {
if (root == null) return null;
Queue<Node> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
Node node = queue.poll();
if (i < size - 1) node.next = queue.peek();
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return root;
}
注意事项:一定要在for循环开始前获取当前队列大小(size),因为队列长度在循环过程中会变化。
4. 二叉树深度问题解析
4.1 最大深度
最大深度是根节点到最远叶子节点的最长路径上的节点数:
java复制public int maxDepth(TreeNode root) {
if (root == null) return 0;
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
4.2 最小深度
最小深度需要注意:必须是到叶子节点的距离(左右子树都为空)
java复制public int minDepth(TreeNode root) {
if (root == null) return 0;
if (root.left == null && root.right == null) return 1;
int left = minDepth(root.left);
int right = minDepth(root.right);
if (root.left == null || root.right == null)
return Math.max(left, right) + 1;
return Math.min(left, right) + 1;
}
常见错误:直接像最大深度那样取min会出错,因为当某子树为空时,不能认为深度为0。
5. 遍历方式的选择与性能考量
5.1 DFS vs BFS 的选择依据
- 需要处理层级关系 → BFS
- 需要回溯或记录路径 → DFS
- 树很深但解可能在浅层 → BFS
- 树很宽 → DFS(节省空间)
5.2 递归与迭代的性能对比
- 递归:代码简洁,但可能有栈溢出风险
- 迭代:更可控,空指针标记法统一了三种DFS遍历
5.3 实际应用中的优化技巧
- 对于BFS,可以在进入下一层时插入null标记,避免维护size变量
- DFS迭代法中,前序遍历可以不使用标记法,更简洁
- 某些问题可以混合使用DFS和BFS,如找路径时先用DFS记录,再用BFS扩展
6. 常见问题排查与调试技巧
6.1 空指针异常
- 忘记检查root是否为null
- 遍历时忘记检查左右子节点是否存在
- 在层序遍历中,poll()之后直接操作node.left/right
6.2 顺序错误
- 前序/中序/后序的代码位置放错
- 迭代法中入栈顺序写反
- 层序遍历忘记在循环开始前记录队列大小
6.3 特殊边界条件
- 空树
- 只有根节点的树
- 所有节点只有左子树或只有右子树
- 完全二叉树
调试建议:用一个小例子(3-5个节点)手动模拟代码执行,画出每一步的栈/队列状态。
7. 题目训练建议
按照以下顺序练习效果最佳:
- 基础遍历:144(前序)、94(中序)、145(后序)、102(层序)
- 深度问题:104(最大深度)、111(最小深度)
- 特殊操作:116/117(连接节点)、226(翻转)
- 路径问题:112(路径和)、113(路径和II)
- 构造问题:105(前中序构造)、106(中后序构造)
每道题至少用两种方法实现(如递归和迭代),并分析时间/空间复杂度。我在准备面试时,会把每种遍历方式的代码默写10遍以上,直到形成肌肉记忆。