二叉树
二叉树
二叉树是一种非常适合递归处理的数据结构, 因为每个节点都可以看作是一个子树的根节点.
必须回答的三个核心问题(递归的灵魂)
下面是二叉树递归的三个核心问题。每当你面对一个新的二叉树问题时,都要问自己这三个问题,从而设计出合适的递归函数。
递归边界:nullptr vs. 叶子节点? - if (root == nullptr)(空节点边界) - 这是最常用、最健壮的边界, 几乎所有二叉树递归都应该使用它作为基础边界条件(但不一定是唯一边界条件)。 - 含义:“我这个递归函数被要求去处理一个不存在的节点,我什么也不用做,直接 return(或 return 0, return true 等默认值)。” - 优点:它能统一处理所有情况,包括: - 整个树为空(dfs(head) 一开始 head 就是 nullptr)。 - 一个节点只有左孩子(dfs(root->right) 时会触发)。 - 一个节点是叶子节点(dfs(root->left) 和 dfs(root->right) 都会触发)。 - if (root->left == nullptr && root->right == nullptr)(叶子节点边界) - 这是极少数情况,只在问题逻辑必须在“叶子”处终止时才使用, 但也需要额外加上 if (root == nullptr) 作为基础边界。 - 含义:“我这个 dfs 函数在到达子节点时,就要停止‘递’,开始‘归’。” - 典型案例: - (112. 路径总和):你必须在叶子节点处判断 sum == targetSum,而不是在 nullptr 处。 - (404. 左叶子之和):你必须从父节点判断 parent->left 是 一个叶子,才能累加。 - 结论:永远默认使用 if (root == nullptr) 作为递归边界。 只有当题目(如“路径总和”)明确要求你必须在叶子节点做判断时,才额外增加对叶子节点的判断。
返回类型: void vs. 有返回值? - void dfs(…)
(无返回值) - 含义:你不需要从子节点获取“答案”。 - 用途 1
(自顶向下):父节点向子节点传递信息。比如 dfs(root,
currentSum),父节点把“当前的路径和”传给子节点。 - 用途 2
(外部变量):你使用一个类成员变量或引用传递的变量来存储最终答案。
-
void dfs(root, vector<int>& path, vector<vector<int>>& op),
或者是类成员变量 vector<vector<int>> results;。
- 在递归过程中不断更新 path,当到达叶子节点时,把 path 加入 results。 -
典型案例: - 回溯 (113. 路径总和 II):你需要一个 path
变量一路传下去。 - 自顶向下 (129. 求根节点到叶节点数字之和):dfs(root,
currentNum * 10 + root->val)。 - T dfs(…) (有返回值,如 int, bool,
TreeNode)
-
含义:父节点必须等到子节点的递归调用返回一个结果后,才能计算出父节点“自己”的结果。这是“自底向上”的核心。
- 典型案例: - (104. 二叉树的最大深度): - int dfs(root) - int leftDepth
= dfs(root->left); - int rightDepth = dfs(root->right); -
return 1 + std::max(leftDepth, rightDepth); -
(父节点的答案依赖于子节点的答案) - (110. 平衡二叉树):int
dfs(root)(返回高度,如果-1则表示不平衡)。 - (236.
最近公共祖先):TreeNode dfs(root, …)(返回 p 或 q 或 nullptr)。 -
结论:问自己:“父节点的答案,是否依赖于子节点的计算结果?”
- 是 → 有返回值
(自底向上)。 - 否 →
void (自顶向下,或使用外部变量)。
自顶向下 (Top-Down) vs. 自底向上 (Bottom-Up)? - 这是对上面“返回类型”的进一步理解。 - 自顶向下 (Pre-order / 先序遍历) - 执行顺序:1. 处理 root → 2. 递归 left → 3. 递归 right - 含义:“继承”——父节点处理完自己的逻辑,然后把信息传递给子节点。 - 代码模板 (104. 最大深度 - Top-Down 解法):
1 | int maxDepth = 0; // 类成员变量或外部变量 |
1 | int dfs(TreeNode* node) { |
二叉树的遍历
二叉树的遍历主要有三种方式: 前序遍历 (Preorder), 中序遍历 (Inorder), 后序遍历 (Postorder). 这三种遍历方式的区别在于访问节点的顺序不同, 名称反映的是访问根节点的时机
三种遍历都可以使用递归实现, 较为简单. 例如:
1 | // 前序遍历 (根-左-右) |
使用迭代实现时, 通常需要借助栈 (Stack) 来手动模拟递归调用栈的行为.
迭代遍历
对于前序遍历, 迭代实现相对简单, 只需要一个栈来辅助即可.
不过需要注意的是, 前序遍历中, 我们需要先将右子节点入栈, 再将左子节点入栈, 这样才能保证左子节点先被访问.(访问顺序和入栈顺序相反)
1 | std::vector<int> preorderIterative(TreeNode* root) { |
对于后序遍历, 迭代实现相对复杂一些, 因为我们需要确保根节点在左右子节点之后被访问. 这里有两种常见的做法: 1. 将后序遍历转换为“根-右-左”的前序遍历, 然后再将结果反转. 2. 使用一个栈和一个指针: 通过一个指针来追踪上一个访问的节点, 以判断当前节点的右子树是否已经被访问过. 这里我们展示第二种方法:
1 | std::vector<int> postorderIterative(TreeNode* root) { |
1 | std::vector<int> postorderIterative(TreeNode* root) { |
对于中序遍历, 我们需要模拟“一路向左,直到尽头;访问;然后转向右子树”的过程。
- 使用一个 curr 指针指向当前节点,初始为 root。
- 外层循环:只要 curr 不为空 或 栈不为空,就继续。
- 步骤 A (深入左子树):while (curr !=
nullptr):只要当前节点不为空,说明它有(或它就是)一个需要稍后访问的“根”。
- 将其入栈(表示“我稍后会回来访问你”)。
- curr = curr->left;(继续深入左子树)。
- 步骤 B (访问根并转向右):当 while 循环结束时,curr
必定为空,表示我们已到达最左侧的尽头。
- 此时从栈中弹出一个节点(这个节点是“左-根-右”中的根)。
- 访问它(加入结果列表)。
- curr = popped_node->right;(将 curr
指针转向该节点的右子树,准备在下一轮外层循环中处理右子树)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29std::vector<int> inorderIterative(TreeNode* root) {
std::vector<int> result;
std::stack<TreeNode*> s;
TreeNode* curr = root; // 使用一个指针来追踪当前节点
// 循环条件:当前节点不为空(表示还有子树待处理)
// 或者 栈不为空(表示还有已入栈的父节点待访问)
while (curr != nullptr || !s.empty()) {
// 1. 深入左子树:不断将当前节点入栈,并向左下方移动
// 直到 curr 为空,表示到达了最左侧
while (curr != nullptr) {
s.push(curr); // 将路径上的“根”节点入栈
curr = curr->left;
}
// 2. 访问根节点:当curr为空时,从栈中弹出节点
// 这是"左-根-右"中的"根",也是最左侧的节点
curr = s.top();
s.pop();
result.push_back(curr->val); // 访问根节点
// 3. 转向右子树:将当前节点指向右子树
// 在下一轮循环中,将对这个右子树重复“步骤1”
curr = curr->right;
}
return result;
}
- 步骤 A (深入左子树):while (curr !=
nullptr):只要当前节点不为空,说明它有(或它就是)一个需要稍后访问的“根”。
层序遍历 (Level-order)
还有一种遍历是层序遍历 (Level-order), 也称为广度优先遍历 (BFS). 这是唯一的主流非递归解法。当你需要按层处理问题时,BFS 是首选。
层序遍历通常使用队列 (Queue) 来实现, 通过逐层访问节点来实现. 实现代码如下:
1 | class Solution { |
特点是: 对于每个节点, 左子树的所有节点值都小于该节点值, 右子树的所有节点值都大于该节点值.
一般使用中序遍历来处理二叉搜索树的问题, 因为中序遍历会得到一个有序序列.
最近公共祖先 (LCA)
最近公共祖先问题是二叉树中一个经典问题, 其核心思想是使用递归来查找两个节点的公共祖先. 这是一个经典的自底向上 (后序遍历) 递归。(LC 236) - dfs(root, p, q) 函数的语义(返回值)是:“在以 root 为根的子树中,我能找到 p 还是 q 还是它俩的 LCA?” - 如果找到了 p 或 q,返回对应的节点指针。 - 如果找到了 LCA,返回 LCA 节点指针。 - 如果都没找到,返回 nullptr。
1 | TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { |
从…构造二叉树
从前序和中序遍历构造二叉树是一个经典问题, 其核心思想是利用前序遍历确定根节点, 然后利用中序遍历划分左右子树. (LC 105)
从前序和中序遍历构造二叉树
- 先序遍历 (Preorder): [ 根节点, [左子树…], [右子树…] ]
- preorder 数组的第一个元素(preorder[0])永远是整棵树的根节点 (Root)。
- 中序遍历 (Inorder): [ […左子树…], 根节点, […右子树…] ]
- inorder 数组中根节点 (Root) 的位置,恰好划分了哪些节点属于左子树,哪些节点属于右子树。
- 根节点左边的所有元素都在左子树, 根节点右边的所有元素都在右子树。
1 | unordered_map<int, int> inorderMap; // (步骤说明):一个全局索引,用于追踪先序遍历数组 |