二叉树(Binary Tree) 是计算机科学中最基础的层次结构。二叉树的核心思维模式是递归(Recursion) 。虽然迭代(Iteration)也能解决问题,但递归通常能更直观地映射树的自相似性(Self-Similarity)。
二叉树算法的核心难点在于:如何将宏观的逻辑(处理整棵树)拆解为微观的操作(处理当前节点、左子树、右子树)。
在 Java 中,二叉树的节点定义如下:
java
public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
一、 递归思维:深度优先搜索 (DFS)
深度优先搜索 (DFS) 是二叉树遍历的基础算法。它通过递归方式访问节点,先深入子树直到叶子节点,再回溯处理上级节点。这种方法模拟了栈的压入和弹出操作,适用于需要逐层深入探索的场景,如路径求解或结构分析。
在二叉树中,DFS 的递归实现依赖于函数调用栈的天然机制。每个递归调用处理一个节点,并将子问题委托给子调用。理解 DFS 需要掌握递归的本质:定义清晰的终止条件和单层处理逻辑,以避免无限递归或栈溢出风险。
1.1 三种遍历序的本质
三种遍历序根据处理当前节点 (Root) 的时机不同而区分:
- 前序遍历 (Pre-order) :Root → \rightarrow → Left → \rightarrow → Right
- 处理顺序:先访问当前节点,然后递归左子树和右子树。
- 特性:适合在遍历开始时立即获取节点信息,形成从根到叶的顺序序列。
- 应用:用于序列化二叉树、构建前缀表达式或复制树结构,其中根节点信息需优先处理。
- 中序遍历 (In-order) :Left → \rightarrow → Root → \rightarrow → Right
- 处理顺序:先递归左子树,然后访问当前节点,最后递归右子树。
- 特性:产生从左到右的有序访问,尤其在有序树中体现为升序序列。
- 应用:在二叉搜索树 (BST) 中用于生成有序节点值列表,或验证树结构的有序性。
- 后序遍历 (Post-order) :Left → \rightarrow → Right → \rightarrow → Root
- 处理顺序:先递归左子树和右子树,然后访问当前节点。
- 特性:自底向上汇总信息,当前节点处理依赖于子树结果。
- 应用:计算树的高度、节点总数或依赖子树计算的属性,如子树大小或平衡检查。
选择遍历序取决于问题需求:前序强调根优先,中序强调有序,后序强调汇总。实际编码中,可通过调整处理位置的代码行来切换顺序。
1.2 递归函数的编写模板
递归函数设计需定义终止条件和单层逻辑。终止条件 处理空节点,单层逻辑假设子树已处理完成。模板提供了一个通用框架,可根据遍历序插入具体操作。
通用递归模板:
java
void traverse(TreeNode root) {
if (root == null) {
return; // 终止条件:空节点直接返回,避免进一步递归
}
// 前序位置:在此处执行对当前节点的处理,例如收集值或修改属性
traverse(root.left); // 递归深入左子树
// 中序位置:在此处执行对当前节点的处理,适用于有序操作
traverse(root.right); // 递归深入右子树
// 后序位置:在此处执行对当前节点的处理,汇总子树结果
}
注意事项:
- 终止条件 :始终检查
root == null,返回适当值(如 0 或 null),防止空指针异常。 - 单层逻辑:假设子调用已返回正确结果,仅关注当前节点的计算或操作。这有助于简化复杂问题的分解。
- 参数传递:若需累积结果,可添加参数(如 List 收集节点值)或使用全局变量,但优先使用返回值以保持函数纯净。
1.3 复杂度分析
- 时间复杂度:DFS 遍历所有节点,因此为 O(N),其中 N 是节点数。每个节点访问常数次。
- 空间复杂度:O(H),H 为树高。最坏情况下(链状树)为 O(N),平均为 O(log N)(平衡树)。迭代版本可优化为 O(1) 额外空间,但实现更复杂。
二、 迭代思维:显式栈模拟
虽然递归代码简洁,但在处理极深层级的树中,Java 虚拟机栈(JVM Stack)的大小是有限的(通常几 MB)。过深的递归会导致 StackOverflowError。
将递归转化为迭代(Iteration),本质上是在堆内存 (Heap) 中维护一个显式的 Stack 来模拟系统调用栈。
2.1 前序遍历的迭代实现
题目链接 :LeetCode 144. Binary Tree Preorder Traversal
题目描述:给你二叉树的根节点 root ,返回它节点值的 前序 遍历。
逻辑推演 :
前序遍历的顺序是:根 (Root) → \rightarrow → 左 (Left) → \rightarrow → 右 (Right) 。
使用栈(LIFO,后进先出)来模拟。
- 处理根:每次从栈顶弹出一个节点,它就是当前需要处理的"根"。
- 入栈顺序 :因为栈是后进先出的,为了保证"左子树"先于"右子树"被处理,需要反向入栈 。
- 先压入 右孩子 (Right)。
- 再压入 左孩子 (Left)。
- 这样下一轮循环
pop出来的就是左孩子。
Java 代码:
java
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) return res;
// 使用 Deque 接口的实现类 ArrayDeque 作为栈,性能优于 Stack 类
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
// 弹出栈顶,记为当前根节点
TreeNode node = stack.pop();
// 立即处理(前序:根先处理)
res.add(node.val);
// 栈是 LIFO,所以先压右,后压左
// 这样下一次 pop 出来的就是左子节点
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
return res;
}
2.2 中序遍历的迭代实现
题目链接 :LeetCode 94. Binary Tree Inorder Traversal
题目描述:给你二叉树的根节点 root ,返回它节点值的 中序 遍历。
逻辑推演 :
中序遍历的顺序是:左 (Left) → \rightarrow → 根 (Root) → \rightarrow → 右 (Right) 。
这比前序遍历复杂,因为我们访问到根节点(Root)时,不能立即处理它,必须先把它存起来,去处理它的左子树。等到左子树全部处理完了,才能回来处理这个根节点。
指针双重角色 :
我们需要一个指针 curr 来遍历树。
- 访问阶段 (Push) :
curr一直向左走 (curr = curr.left),沿途将经过的节点压入栈中。 - 回溯阶段 (Pop) :当
curr为空,说明最近一个压栈节点的左子树处理完毕。弹出栈顶节点(即为"根"),记录其值,然后让curr转向右子树 (curr = curr.right)。
Java 代码:
java
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode curr = root;
// curr != null: 表示还有左子树没走完,需要继续入栈
// !stack.isEmpty(): 表示还有父节点待处理(回溯)
while (curr != null || !stack.isEmpty()) {
// 尽力向左走,模拟递归调用的压栈过程
while (curr != null) {
stack.push(curr);
curr = curr.left;
}
// 左边走到头了,弹出栈顶
// 这个弹出的节点,就是"左-根-右"中的"根"
curr = stack.pop();
// 处理当前节点
res.add(curr.val);
// 转向右子树
// 如果右子树为空,下一轮循环会直接继续弹栈(回溯到上一层)
// 如果右子树非空,下一轮循环会处理右子树的左分支
curr = curr.right;
}
return res;
}
三、 层序遍历:广度优先搜索 (BFS)
与深度优先搜索 (DFS) 沿树的深度方向遍历不同,广度优先搜索 (BFS) 严格按照树的层级(Level)进行遍历。即只有当第 k k k 层的节点全部被访问处理完毕后,才会开始访问第 k + 1 k+1 k+1 层的节点。
这种遍历方式在解决以下两类问题时具有绝对优势:
- 分层处理:需要按层打印、计算每一层的平均值/最大值、填充下一节点的指针。
- 最短路径/最小深度:在无权图中,BFS 首次到达目标节点时的路径长度即为最短路径(例如求二叉树的最小深度)。
3.1 核心数据结构:队列 (Queue)
BFS 的核心在于利用 队列 (Queue) 的 先进先出 (FIFO) 特性。
- 顺序保证:父节点先入队,子节点后入队。这保证了上层节点一定比下层节点先被处理。
- 实现类选择 :在 Java 中,
Queue是一个接口。虽然LinkedList实现了该接口,但在算法题及高性能场景下,推荐使用ArrayDeque。ArrayDeque:基于数组实现,内存连续,缓存命中率高,且没有LinkedList那样频繁的节点(Node)对象创建与销毁开销。LinkedList:基于链表实现,虽然入队出队也是 O ( 1 ) O(1) O(1),但涉及额外的对象分配,在数据量大时 GC 压力较大。
3.2 分层逻辑详解:为何需要 size?
题目链接 :LeetCode 102. Binary Tree Level Order Traversal
题目描述:给你二叉树的根节点 root ,返回其节点值的 层序遍历 。(即逐层地,从左到右访问所有节点)。
在标准的 BFS 模版中,最关键的一行代码是 int currentLevelSize = queue.size();。
逻辑推演 :
如果直接遍历队列直到为空,我们将得到一个所有节点的线性序列,但无法区分哪些节点属于同一层。为了实现"分层",需要在每一轮处理前进行快照:
- 锁定当前层 :在进入内层循环之前,
queue中包含的元素恰好 是当前这一层的所有节点(且不包含下一层的节点)。 - 固定次数遍历 :获取此时的
queue.size(),记为 N N N。这意味着接下来的 N N N 次出队操作 (poll),处理的都是当前层的节点。 - 动态入队 :在处理这 N N N 个节点的过程中,将它们的子节点(下一层)加入队列。由于队列的 FIFO 特性,这些新加入的节点会排在当前层节点的后面,不会影响当前内层循环的执行次数。
- 迭代:当内层循环结束时,当前层所有节点已出队,队列中剩下的全是下一层的节点。
Java 代码:
java
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null) return res;
// 使用 ArrayDeque 作为队列实现
Queue<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
// 在遍历当前层之前,先记录当前队列的大小
// 这个 size 代表了"这一层有多少个节点"
int currentLevelSize = queue.size();
// 存储当前层结果的列表
List<Integer> levelList = new ArrayList<>();
// 仅处理当前层的 currentLevelSize 个节点
for (int i = 0; i < currentLevelSize; i++) {
TreeNode node = queue.poll();
levelList.add(node.val);
// 将下一层的节点加入队列末尾
// 这些新节点不会在本次 for 循环中被处理,因为 i < currentLevelSize 限制了次数
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
// 当前层处理完毕,收集结果
res.add(levelList);
}
return res;
}
3.3 复杂度分析
- 时间复杂度 :O ( N ) O(N) O(N)。每个节点进队一次,出队一次,操作总数为线性。
- 空间复杂度 :O ( W ) O(W) O(W) ,其中 W W W 是树的最大宽度。
- 在最坏情况下(满二叉树),最后一层的节点数约为 N / 2 N/2 N/2。因此 BFS 的空间复杂度在最坏情况下为 O ( N ) O(N) O(N)。
- 这与 DFS 的空间复杂度不同(DFS 取决于树的高度 H H H)。对于非常宽但很浅的树,DFS 空间更优;对于非常深但很窄的树,BFS 空间更优。
四、 常见核心题型解析
掌握了 DFS 和 BFS 的基本框架后,解题的关键在于:明确递归函数到底要返回什么给父节点 ,以及在当前节点需要做什么计算。
4.1 场景一:子树信息的收集与整合(后序遍历)
这类问题的共同特征是:当前节点的计算结果依赖于左右子树的计算结果。因此,必须先递归处理子树,拿到返回值后,再处理当前节点(即后序遍历逻辑)。
例题 1:二叉树的最大深度
题目链接 :LeetCode 104. Maximum Depth of Binary Tree
题目描述:给定一个二叉树,找出其最大深度。
思路解析:
- 递归定义 :函数
maxDepth(root)的物理意义是"返回以 root 为根的树的高度"。 - 逻辑分解 :
- 左子树信息:递归计算左子树高度。
- 右子树信息:递归计算右子树高度。
- 当前节点整合 :当前树的高度 =
Math.max(左, 右) + 1(加 1 是加上当前节点这一层)。
Java 代码:
java
public int maxDepth(TreeNode root) {
// 递归终止条件: 空节点高度为 0
if (root == null) return 0;
// 获取子树高度
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
// 2. 整合并返回
return Math.max(leftDepth, rightDepth) + 1;
}
例题 2:二叉树的直径
题目链接 :LeetCode 543. Diameter of Binary Tree
题目描述:给定一棵二叉树,你需要计算它的直径长度。直径定义为任意两个节点路径长度中的最大值(这条路径可能不经过根节点)。
思路解析 :
这道题展示了一个核心技巧:递归函数的返回值 与题目的最终答案不一定相同。
- 问题分析 :路径是"左臂 + 右臂"。对于任意节点
u,穿过该节点的最长路径 =u 的左子树最大深度 + u 的右子树最大深度(节点数 计算子树高度,左右子树高度之和恰好等于以当前节点为根的路径的边数(即直径定义))。 - 变量分离 :
- 递归返回值 :必须返回以当前节点为根的子树深度,供父节点计算使用。
- 全局最大值 :在递归过程中,计算每个节点的"穿过该节点的路径长度",并更新全局变量
maxDiameter。
Java 代码:
java
// 全局变量记录最大直径
private int maxDiameter = 0;
public int diameterOfBinaryTree(TreeNode root) {
maxDepth(root);
return maxDiameter;
}
// 该函数的返回值依然是"深度",但在过程中更新直径
private int maxDepth(TreeNode root) {
if (root == null) return 0;
int left = maxDepth(root.left);
int right = maxDepth(root.right);
// 直径 = 左深度 + 右深度
// 在后序位置更新全局最大值
maxDiameter = Math.max(maxDiameter, left + right);
// 返回给父节点的是:单侧最长链的长度(深度)
return Math.max(left, right) + 1;
}
4.2 场景二:BST 的特性应用(中序遍历)
二叉搜索树 (BST) 的核心性质是:中序遍历的结果是一个严格递增的有序序列。所有 BST 相关题目,优先考虑中序遍历。
例题 1:验证二叉搜索树
题目链接 :LeetCode 98. Validate Binary Search Tree
题目描述:给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。
思路解析:
- 错误做法 :仅判断
root.left.val < root.val和root.right.val > root.val。这无法保证右子树中的所有节点都大于根节点(例如右子树中有一个极小值)。 - 正确做法 :利用中序遍历的单调递增性。
- 维护一个指针
prev,指向中序遍历中上一个访问的节点。 - 在访问当前节点
root时,检查prev.val < root.val是否成立。 - 如果不成立,说明破坏了递增顺序,返回
false。
- 维护一个指针
Java 代码:
java
// 记录前一个遍历的节点
private TreeNode prev = null;
public boolean isValidBST(TreeNode root) {
if (root == null) return true;
// 递归左子树
if (!isValidBST(root.left)) return false;
// 当前节点逻辑:必须严格大于前一个节点
if (prev != null && prev.val >= root.val) {
return false;
}
prev = root; // 更新 prev 指针
// 递归右子树
return isValidBST(root.right);
}
例题 2:二叉搜索树中第 K 小的元素
题目链接 :LeetCode 230. Kth Smallest Element in a BST
题目描述:给定一个二叉搜索树的根节点 root ,和一个整数 k ,请你设计一个算法查找其中第 k 小的元素。
思路解析:
- 由于 BST 中序遍历是有序的,第
k小的元素就是中序遍历序列中的第k个元素。 - 我们不需要生成整个数组,只需维护一个计数器
rank。 - 每当遍历到一个节点,
rank++。当rank == k时,当前节点即为答案,记录结果并停止递归。
Java 代码:
java
private int rank = 0;
private int result = 0;
public int kthSmallest(TreeNode root, int k) {
traverse(root, k);
return result;
}
private void traverse(TreeNode root, int k) {
if (root == null) return;
// 先去左边
traverse(root.left, k);
// 中序位置:处理当前节点
rank++;
if (rank == k) {
result = root.val;
return; // 找到后可提前结束
}
// 再去右边
traverse(root.right, k);
}
4.3 场景三:复杂路径问题与状态定义
这类题目通常涉及"路径",且路径的定义灵活(不一定从根开始,也不一定在叶子结束 )。解题关键在于区分 "贡献给父节点的收益" 和 "在当前节点内部计算的路径收益"。
例题 1:路径总和 III
题目链接 :LeetCode 437. Path Sum III
题目描述:给定一个二叉树的根节点 root ,求该树中有多少条路径,这条路径上所有节点值相加等于 targetSum。路径不需要从根节点开始,也不需要在叶子节点结束,但必须向下(父节点到子节点)。
思路解析 :
这道题需要双重递归。
- 外层递归 (
pathSum):遍历树的每一个节点。对于每一个节点,都把它当作"路径的起点"。 - 内层递归 (
countPaths) :计算以指定节点为起点 ,向下延伸且和为target的路径数量。- 在内层递归中,每经过一个节点,
target减去当前值。若target == 0,说明找到一条路径(注意:找到后需继续向下,因为后续节点和可能为 0)。
- 在内层递归中,每经过一个节点,
Java 代码:
java
public int pathSum(TreeNode root, long targetSum) {
if (root == null) return 0;
// 以当前 root 为起点的路径数量
int pathsFromRoot = countPaths(root, targetSum);
// 递归:去左子树找起点 + 去右子树找起点
int pathsFromLeft = pathSum(root.left, targetSum);
int pathsFromRight = pathSum(root.right, targetSum);
return pathsFromRoot + pathsFromLeft + pathsFromRight;
}
// 计算以 node 为起点,和为 target 的路径数
private int countPaths(TreeNode node, long target) {
if (node == null) return 0;
int count = 0;
// 找到一条,count + 1,但不能 return,需继续向下(可能后面有正负抵消)
if (node.val == target) {
count++;
}
count += countPaths(node.left, target - node.val);
count += countPaths(node.right, target - node.val);
return count;
}
例题 2:二叉树中的最大路径和
题目链接 :LeetCode 124. Binary Tree Maximum Path Sum
题目描述:路径被定义为一条从树中任意节点出发,沿父子 - 节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。求路径和的最大值。
思路解析:
- 逻辑分解 :
对于任意节点u,经过它的最大路径和 =u.val + 左子树提供的最大收益 + 右子树提供的最大收益。 - 贡献值计算(返回值) :
子树只能贡献一条 分支给父节点(因为路径不能分叉)。所以gain(u)=u.val + max(leftGain, rightGain)。 - 负数截断 :
如果子树计算出的收益是负数,则直接舍弃(贡献值记为 0),因为加上负数只会让路径和变小。
Java 代码:
java
private int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxGain(root);
return maxSum;
}
// 计算以 node 为根的子树,向父节点提供的最大单边贡献
private int maxGain(TreeNode node) {
if (node == null) return 0;
// 递归计算左右子树的贡献,如果是负数则截断为 0
int leftGain = Math.max(maxGain(node.left), 0);
int rightGain = Math.max(maxGain(node.right), 0);
// 更新全局最大值(这是"内部路径",可以同时包含左右子树)
int priceNewPath = node.val + leftGain + rightGain;
maxSum = Math.max(maxSum, priceNewPath);
// 返回给父节点(只能选一条边:要么走左,要么走右)
return node.val + Math.max(leftGain, rightGain);
}
例题比较 :
这个题与"二叉树的直径"在遍历框架上相似,都在后序位置利用左右子树的返回值更新全局最大值。但在核心逻辑上存在两点关键差异:
- 度量维度的转变 :"直径"计算的是路径长度 (边的数量),所有节点权重视为 1,且深度恒为非负数。本题计算的是节点值之和,节点值可能为负数,因此路径越长不一定和越大。
- 负数剪枝策略 :这是这个题的难点。在"直径"问题中,累加子树深度总是产生正向增益,因此无需舍弃。但在本题中,如果子树返回的最大路径和是负数 ,连接该子树反而会减少当前路径的总和。因此必须在获取子树贡献时进行剪枝 :
int gain = Math.max(recursive_call(), 0)。这意味着如果子树贡献为负,需要选择断开该分支(将其贡献视为 0),仅保留当前节点或另一侧非负的分支。
五、 总结与最佳实践
5.1 复杂度分析
二叉树算法的复杂度取决于节点的数量 N N N 和树的形状。
- 时间复杂度 :
无论是深度优先搜索 (DFS) 还是广度优先搜索 (BFS),标准遍历都需要访问每个节点一次,因此时间复杂度通常为 O ( N ) O(N) O(N)。 - 空间复杂度 :
空间消耗主要来自维持遍历状态的辅助结构(栈或队列)。- DFS (递归或栈迭代) :空间复杂度取决于树的高度 。在最坏情况(链状树)下为 O ( N ) O(N) O(N),在平衡树情况下为 O ( log N ) O(\log N) O(logN)。
- BFS (队列) :空间复杂度取决于树的最大宽度 。在最坏情况(满二叉树)下,最后一层的节点数约为 N / 2 N/2 N/2,因此空间复杂度为 O ( N ) O(N) O(N)。
5.2 算法选择策略
在解决二叉树问题时,应根据题目的具体需求选择合适的遍历方式:
- 二叉搜索树 (BST) 相关问题 :
优先选择 中序遍历。利用 BST 中序遍历结果为有序数组的特性,可以有效地解决验证、查找、第 K 小元素等问题。 - 依赖子树信息的问题 :
优先选择 后序遍历 (DFS)。例如计算树的高度、直径或最大路径和。这类问题通常需要先获取左右子树的计算结果,整合后再返回给父节点。 - 层级或最短路径问题 :
优先选择 层序遍历 (BFS)。例如求二叉树的最小深度、计算每一层的平均值或右视图。 - 递归与迭代的选择 :
递归代码逻辑清晰,易于实现,是首选方案。但在处理节点数量极大且深度很深的树时,为了防止系统栈溢出,应考虑使用显式栈(Deque)将递归转换为迭代。
5.3 注意事项
编写二叉树代码时,需要注意以下几个关键点以保证代码的正确性:
- 递归终止条件 :
所有递归函数必须包含终止条件 ,通常是判断当前节点是否为null。这是防止无限递归和栈溢出的基础。 - 空指针检查 :
在访问node.left或node.right之前,必须确保node本身不为null。 - 叶子节点的界定 :
区分"空节点"与"叶子节点"。某些题目明确要求路径终止于叶子节点,此时需要同时判断left == null和right == null。 - 全局变量与返回值的区别 :
在复杂递归中(如二叉树直径),递归函数的返回值往往是提供给父节点计算使用的(如子树深度),而题目的最终答案(如最大直径)可能需要在遍历过程中通过更新全局变量或成员变量来获得。