二叉树专题(下)------ 进阶操作
本篇覆盖二叉树专题的后 7 题:BST 中第 K 小的元素、二叉树的右视图、二叉树展开为链表、从前序与中序遍历序列构造二叉树、路径总和 III、二叉树的最近公共祖先、二叉树中的最大路径和。这部分题目综合性更强,需要对遍历框架有更灵活的运用。
一、二叉搜索树中第 K 小的元素(#230)
题意
给一棵 BST 的根节点和整数 k,返回 BST 中第 k 小的元素。
3
/ \
1 4
\
2
k=1,输出:1
k=3,输出:3
思路
BST 的中序遍历结果是升序序列,第 k 小的元素就是中序遍历的第 k 个节点。
不需要把中序结果全部存下来,用一个计数器 count,中序遍历过程中每访问一个节点就 count--,减到 0 时记录答案并停止。
中序遍历:1 → 2 → 3 → 4
k=3,count从3开始
访问1:count=2
访问2:count=1
访问3:count=0 → 记录答案3,停止
代码
cpp
class Solution {
int count, res;
public:
void dfs(TreeNode* root) {
if (!root || count == 0) return;
dfs(root->left);
count--;
if (count == 0) { res = root->val; return; }
dfs(root->right);
}
int kthSmallest(TreeNode* root, int k) {
count = k;
dfs(root);
return res;
}
};
复杂度
- 时间 :O(H+k)O(H + k)O(H+k),HHH 为树高,最坏 O(n)O(n)O(n)
- 空间 :O(H)O(H)O(H),递归栈深度
二、二叉树的右视图(#199)
题意
给二叉树根节点,想象站在树的右侧,返回从上到下每一层能看到的节点值(即每层最右边的节点)。
1 ← 看到 1
/ \
2 3 ← 看到 3
\ \
5 4 ← 看到 4
输出:[1, 3, 4]
思路
层序遍历,每层的最后一个节点就是右视图能看到的节点。
复用上篇层序遍历的框架,每层遍历结束时把最后一个节点的值加入结果。
第1层:[1],最右=1
第2层:[2,3],最右=3
第3层:[5,4],最右=4
结果:[1,3,4]
代码
cpp
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
if (!root) return {};
vector<int> res;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
TreeNode* node = q.front(); q.pop();
if (i == size - 1) res.push_back(node->val); // 每层最后一个
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return res;
}
};
复杂度
- 时间 :O(n)O(n)O(n)
- 空间 :O(n)O(n)O(n)
三、二叉树展开为链表(#114)
题意
给二叉树根节点,将其展开为链表(原地),展开后链表的顺序与前序遍历一致,用 right 指针连接,left 指针全部置为 null。
1 1
/ \ \
2 5 → 2
/ \ \ \
3 4 6 3
\
4
\
5
\
6
思路
前序遍历顺序:根 → 左 → 右。展开后,每个节点的 right 指向前序遍历的下一个节点。
后序思路:先递归展开左子树和右子树,再把左子树接到根的右边,把原来的右子树接到左子树展开后的末尾。
以节点1为例,假设左右子树已经展开:
左链表:2→3→4
右链表:5→6
操作:
1. 找左链表的末尾节点(4)
2. 把原右链表(5→6)接到末尾:4->right = 5→6
3. 把左链表接到根右边:1->right = 2→3→4→5→6
4. 左指针置空:1->left = null
结果:1→2→3→4→5→6
代码
cpp
class Solution {
public:
void flatten(TreeNode* root) {
if (!root) return;
flatten(root->left);
flatten(root->right);
// 此时左右子树都已展开为链表
TreeNode* left = root->left;
TreeNode* right = root->right;
if (!left) return; // 没有左子树,不需要操作
// 找左链表的末尾
TreeNode* tail = left;
while (tail->right) tail = tail->right;
// 把右链表接到左链表末尾
tail->right = right;
// 把左链表接到根的右边
root->right = left;
root->left = nullptr;
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个节点访问一次,找末尾节点的总步数也是 O(n)O(n)O(n)
- 空间 :O(n)O(n)O(n),递归栈
四、从前序与中序遍历序列构造二叉树(#105)
题意
给二叉树的前序遍历序列 preorder 和中序遍历序列 inorder,构造并返回二叉树。
preorder = [3, 9, 20, 15, 7]
inorder = [9, 3, 15, 20, 7]
构造结果:
3
/ \
9 20
/ \
15 7
思路
前序遍历的第一个元素一定是根节点。
找到根节点值在中序遍历中的位置 idx,idx 左边是左子树的中序序列(长度为 leftSize),右边是右子树的中序序列。
根据 leftSize,在前序遍历中划分出左子树和右子树的前序序列,递归构造。
preorder = [3, 9, 20, 15, 7]
inorder = [9, 3, 15, 20, 7]
根节点 = preorder[0] = 3
3 在 inorder 中的位置 idx=1
左子树大小 leftSize = 1
左子树:
前序:preorder[1..1] = [9]
中序:inorder[0..0] = [9]
→ 根=9,无子节点
右子树:
前序:preorder[2..4] = [20,15,7]
中序:inorder[2..4] = [15,20,7]
→ 根=20,左子=15,右子=7
用哈希表存中序遍历每个值的下标,查找 idx 时 O(1)O(1)O(1)。
代码
cpp
class Solution {
unordered_map<int, int> indexMap; // val → 中序下标
TreeNode* build(vector<int>& preorder, int preLeft, int preRight,
vector<int>& inorder, int inLeft, int inRight) {
if (preLeft > preRight) return nullptr;
int rootVal = preorder[preLeft];
int idx = indexMap[rootVal]; // 根在中序中的位置
int leftSize = idx - inLeft; // 左子树节点数
TreeNode* root = new TreeNode(rootVal);
root->left = build(preorder, preLeft + 1, preLeft + leftSize,
inorder, inLeft, idx - 1);
root->right = build(preorder, preLeft + leftSize + 1, preRight,
inorder, idx + 1, inRight);
return root;
}
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
for (int i = 0; i < inorder.size(); i++)
indexMap[inorder[i]] = i;
return build(preorder, 0, preorder.size() - 1,
inorder, 0, inorder.size() - 1);
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个节点创建一次,哈希查找 O(1)O(1)O(1)
- 空间 :O(n)O(n)O(n),哈希表 + 递归栈
五、路径总和 III(#437)
题意
给二叉树根节点和整数 targetSum,返回路径和等于 targetSum 的路径数目。路径方向向下(从父节点到子节点),但不需要从根节点开始,也不需要在叶节点结束。
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
targetSum = 8
满足条件的路径:
5→3 = 8
5→2→1 = 8
-3→11 = 8
输出:3
思路
暴力做法:对每个节点作为起点,向下搜索所有路径,O(n2)O(n^2)O(n2)。
前缀和 + 哈希表 ,O(n)O(n)O(n)。
和「子数组和为 K」一题思路完全一致,只是从数组换到了树上。
定义从根节点到当前节点的路径和为 prefix,则以当前节点为终点、路径和为 targetSum 的路径数,等于之前路径中前缀和为 prefix - targetSum 的数量。
用 unordered_map<long, int> cnt 记录根到当前节点路径上所有前缀和的出现次数,DFS 过程中:
-
进入节点:查询
cnt[prefix - targetSum],累加到答案;然后把当前prefix加入cnt -
离开节点(回溯):把当前
prefix从cnt中移除,恢复现场cnt 初始化:{0: 1}(空路径前缀和为0)
进入10:prefix=10,查 10-8=2,cnt里无 → ans+=0,cnt={0:1, 10:1}
进入5:prefix=15,查 15-8=7,cnt里无 → ans+=0,cnt={...,15:1}
进入3:prefix=18,查 18-8=10,cnt里有1 → ans+=1(路径5→3)
...
离开5:cnt中移除15
代码
cpp
class Solution {
unordered_map<long, int> cnt;
int target;
int dfs(TreeNode* root, long prefix) {
if (!root) return 0;
prefix += root->val;
int res = cnt.count(prefix - target) ? cnt[prefix - target] : 0;
cnt[prefix]++;
res += dfs(root->left, prefix);
res += dfs(root->right, prefix);
cnt[prefix]--; // 回溯,恢复现场
return res;
}
public:
int pathSum(TreeNode* root, int targetSum) {
cnt[0] = 1; // 空路径
target = targetSum;
return dfs(root, 0);
}
};
复杂度
- 时间 :O(n)O(n)O(n),每个节点访问一次
- 空间 :O(n)O(n)O(n),哈希表 + 递归栈
六、二叉树的最近公共祖先(#236)
题意
给二叉树根节点和两个节点 p、q,找到它们的最近公共祖先(LCA)。最近公共祖先定义为:能同时是 p 和 q 祖先的最深节点(节点也可以是自身的祖先)。
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
p=5, q=1 → LCA=3
p=5, q=4 → LCA=5
思路
后序遍历,递归函数的返回值含义:
- 如果当前节点是
p或q,返回当前节点 - 如果当前节点为空,返回
null - 递归处理左右子树,分别得到
left和right:-
left和right都不为空:说明p和q分别在左右子树,当前节点就是 LCA -
只有
left不为空:p和q都在左子树,返回left -
只有
right不为空:p和q都在右子树,返回rightp=5, q=4
递归到节点5:5==p,直接返回5(不继续递归)
递归到节点1:左右子树都找不到p或q,返回null
节点3处:left=5(非空),right=null → 返回left=5?等等,p=5, q=4,4是5的子节点。
递归到5时直接返回5,向上传递。
节点3处:left=5,right=null → 返回5。这意味着5就是LCA,正确。
原因:找到p(5)后直接返回,不继续向下找q(4),
但能保证q一定在p的子树里(题目保证p,q都存在),
所以p就是它们的LCA。
-
代码
cpp
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || root == p || root == q) return root;
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
if (left && right) return root; // p、q分别在左右子树
return left ? left : right; // 返回非空的那个
}
};
复杂度
- 时间 :O(n)O(n)O(n)
- 空间 :O(n)O(n)O(n)
七、二叉树中的最大路径和(#124)
题意
给二叉树根节点,找路径(不需要经过根节点,方向任意)中节点值之和最大的路径,返回该最大路径和。节点值可以为负数。
-10
/ \
9 20
/ \
15 7
最大路径:15→20→7,路径和=42
思路
和「二叉树直径」思路几乎完全一致,只是把"路径长度(边数)"换成了"路径节点值之和"。
定义辅助函数 maxGain(node):返回以 node 为起点向下延伸的路径中,能贡献的最大值(如果最大值为负,取 0,即不选这条路)。
maxGain(node)=node.val+max(maxGain(left), maxGain(right), 0)maxGain(node) = node.val + \max(maxGain(left),\ maxGain(right),\ 0)maxGain(node)=node.val+max(maxGain(left), maxGain(right), 0)
以每个节点为"转折点"(路径最高点)时,路径和为:
node.val+max(maxGain(left), 0)+max(maxGain(right), 0)node.val + \max(maxGain(left),\ 0) + \max(maxGain(right),\ 0)node.val+max(maxGain(left), 0)+max(maxGain(right), 0)
后序遍历,在计算每个节点 maxGain 的同时更新全局最大路径和。
节点15:maxGain=15,以15为转折点路径和=15
节点7:maxGain=7,以7为转折点路径和=7
节点20:
left_gain = max(15,0) = 15
right_gain = max(7,0) = 7
以20为转折点:20+15+7=42 → 更新res=42
maxGain(20) = 20+max(15,7) = 35
节点9:maxGain=9,以9为转折点路径和=9
节点-10:
left_gain = max(9,0) = 9
right_gain = max(35,0) = 35
以-10为转折点:-10+9+35=34 < 42,不更新res
maxGain(-10) = -10+max(9,35) = 25
最终res=42
代码
cpp
class Solution {
int res = INT_MIN;
public:
int maxGain(TreeNode* root) {
if (!root) return 0;
int left = max(maxGain(root->left), 0); // 负收益不选
int right = max(maxGain(root->right), 0);
res = max(res, root->val + left + right); // 以当前节点为转折点
return root->val + max(left, right); // 向上只能选一侧
}
int maxPathSum(TreeNode* root) {
maxGain(root);
return res;
}
};
注意 res 初始化为 INT_MIN 而不是 0,因为节点值可以全为负数,路径必须至少包含一个节点。
复杂度
- 时间 :O(n)O(n)O(n)
- 空间 :O(n)O(n)O(n)
八、本篇小结
| 题目 | 遍历方式 | 核心思路 | 时间 | 空间 |
|---|---|---|---|---|
| BST 第 K 小 | 中序 | 中序第 k 个节点即为答案 | O(H+k)O(H+k)O(H+k) | O(H)O(H)O(H) |
| 右视图 | BFS | 层序遍历取每层最后一个节点 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 展开为链表 | 后序 | 先展开子树,再拼接 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 前中序构造二叉树 | 前序 | 前序首元素为根,中序划分左右子树 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 路径总和 III | 前序+回溯 | 前缀和+哈希表,回溯恢复现场 | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 最近公共祖先 | 后序 | 左右子树分别找,都找到则当前节点为LCA | O(n)O(n)O(n) | O(n)O(n)O(n) |
| 最大路径和 | 后序 | 每个节点为转折点,负收益不选 | O(n)O(n)O(n) | O(n)O(n)O(n) |
二叉树专题到此全部结束。纵观 15 道题,后序遍历出现频率最高,原因是后序能拿到左右子树的计算结果,适合"自底向上汇总信息"的场景。路径总和 III 和最大路径和是本专题难度最高的两道,前者把前缀和移植到树上,后者需要清楚区分"向上返回的值"和"更新答案时用的值"这两个不同的量。