第 3.11 章:二叉树
二叉树是竞赛编程中一些最重要数据结构的基础——从二叉搜索树(BST)到线段树再到堆。深刻理解它们将使图论算法、树上 DP 和 USACO Gold 题目变得更容易上手。
3.11.1 二叉树基础
二叉树是一种层级数据结构:
- 每个节点最多有 2 个子节点:左子节点和右子节点
- 恰好有一个根节点(无父节点)
- 每个非根节点恰好有一个父节点
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径
深度 — 从根到该节点的距离
子树 — 一个节点及其所有后代
图示
在这棵树中:
- 高度 = 2(最长的根到叶路径:A → B → D)
- 根 = A,叶节点 = D, E, F
- B 是 D 和 E 的父节点;D 是 B 的左子节点,E 是 B 的右子节点
C++ 节点定义
本章使用统一的 struct TreeNode:
📄 本章使用统一的 `struct TreeNode`:
#include <bits/stdc++.h>
using namespace std;
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
// 构造函数:用值初始化,无子节点
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
💡 为什么用裸指针? 竞赛编程中为速度通常手动管理内存。
nullptr(C++11)始终比未初始化的指针安全——务必初始化left = right = nullptr。
三种遍历顺序访问相同的树,但顺序完全不同——各有独特的使用场景:
3.11.2 二叉搜索树(BST)
二叉搜索树是带有关键排序性质的二叉树:
BST 性质: 对每个节点 v:
- 左子树中所有值都严格小于
v.val - 右子树中所有值都严格大于
v.val
[5] ← 有效的 BST
/ \
[3] [8]
/ \ / \
[1] [4] [7] [10]
5 的左边 = {1, 3, 4} — 都 < 5 ✓
5 的右边 = {7, 8, 10} — 都 > 5 ✓
3.11.2.1 BST 搜索
📄 查看代码:3.11.2.1 BST 搜索
// BST 搜索 — 平均 O(log N),最坏 O(N)
// 返回值为 'target' 的节点指针,找不到返回 nullptr
TreeNode* search(TreeNode* root, int target) {
// 基础情况:空树或找到目标
if (root == nullptr || root->val == target) {
return root;
}
// BST 性质:target 更小则向左
if (target < root->val) {
return search(root->left, target);
}
// target 更大则向右
return search(root->right, target);
}
迭代版本(避免大树时的栈溢出):
// BST 搜索迭代版
TreeNode* searchIterative(TreeNode* root, int target) {
while (root != nullptr) {
if (target == root->val) return root; // 找到
else if (target < root->val) root = root->left; // 向左
else root = root->right; // 向右
}
return nullptr; // 未找到
}
3.11.2.2 BST 插入
📄 查看代码:3.11.2.2 BST 插入
// BST 插入 — 平均 O(log N)
// 返回子树(可能是新的)的根节点
TreeNode* insert(TreeNode* root, int val) {
// 到达空位时,在此创建新节点
if (root == nullptr) {
return new TreeNode(val);
}
if (val < root->val) {
root->left = insert(root->left, val); // 递归向左
} else if (val > root->val) {
root->right = insert(root->right, val); // 递归向右
}
// val == root->val:重复值,忽略(或按需处理)
return root;
}
// 用法:
// TreeNode* root = nullptr;
// root = insert(root, 5);
// root = insert(root, 3);
// root = insert(root, 8);
3.11.2.3 BST 删除
删除是最复杂的 BST 操作,有 3 种情况:
- 节点无子节点(叶节点):直接删除
- 节点有一个子节点:用子节点替换该节点
- 节点有两个子节点:用中序后继(右子树中最小值)替换,然后删除后继
📄 3. **节点有两个子节点**:用**中序后继**(右子树中最小值)替换,然后删除后继
// BST 删除 — 平均 O(log N)
// 辅助函数:找子树中最小节点
TreeNode* findMin(TreeNode* node) {
while (node->left != nullptr) node = node->left;
return node;
}
// 从以 'root' 为根的树中删除值为 'val' 的节点
TreeNode* deleteNode(TreeNode* root, int val) {
if (root == nullptr) return nullptr; // 值未找到
if (val < root->val) {
// 情况:目标在左子树
root->left = deleteNode(root->left, val);
} else if (val > root->val) {
// 情况:目标在右子树
root->right = deleteNode(root->right, val);
} else {
// 找到要删除的节点!
// 情况一:无子节点(叶节点)
if (root->left == nullptr && root->right == nullptr) {
delete root;
return nullptr;
}
// 情况二A:只有右子节点
else if (root->left == nullptr) {
TreeNode* temp = root->right;
delete root;
return temp;
}
// 情况二B:只有左子节点
else if (root->right == nullptr) {
TreeNode* temp = root->left;
delete root;
return temp;
}
// 情况三:有两个子节点——用中序后继替换
else {
TreeNode* successor = findMin(root->right); // 右子树中最小
root->val = successor->val; // 复制后继的值
root->right = deleteNode(root->right, successor->val); // 删除后继
}
}
return root;
}
3.11.2.4 BST 退化问题
下图展示了 BST 插入操作——搜索路径在每个节点处遵循 BST 性质,直到找到空位:
⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表:
[1]
\
[2]
\
[3] ← 每次操作 O(N),不是 O(log N)!
\
[4]
\
[5]
这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::set 和 std::map 用红黑树实现——始终保证 O(log N)。
std::set / std::map 代替手写 BST。它们始终保持平衡。学习 BST 基础是为了理解它们为什么有效,竞赛中直接用 STL(见第 3.8 章)。
3.11.3 树的遍历
遍历 = 恰好访问每个节点一次。有 4 种基本遍历:
| 遍历 | 顺序 | 使用场景 |
|---|---|---|
| 前序 | 根 → 左 → 右 | 复制树、前缀表达式 |
| 中序 | 左 → 根 → 右 | BST 有序输出 |
| 后序 | 左 → 右 → 根 | 删除树、后缀表达式 |
| 层序 | BFS 按层 | 最短路、层级操作 |
3.11.3.1 前序遍历
📄 查看代码:3.11.3.1 前序遍历
// 前序遍历 — O(N) 时间,O(H) 空间(H = 高度)
// 访问顺序:根,左子树,右子树
void preorder(TreeNode* root) {
if (root == nullptr) return; // 基础情况
cout << root->val << " "; // 先处理根
preorder(root->left); // 然后左子树
preorder(root->right); // 然后右子树
}
// 对于树: [5]
// / \
// [3] [8]
// / \
// [1] [4]
// 前序:5 3 1 4 8
迭代版前序(用栈):
📄 C++ 完整代码
// 前序遍历迭代版
void preorderIterative(TreeNode* root) {
if (root == nullptr) return;
stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top(); stk.pop();
cout << node->val << " "; // 处理当前节点
// 先压右子节点(这样左子节点先被处理——LIFO!)
if (node->right) stk.push(node->right);
if (node->left) stk.push(node->left);
}
}
3.11.3.2 中序遍历
📄 查看代码:3.11.3.2 中序遍历
// 中序遍历 — O(N) 时间
// 访问顺序:左子树,根,右子树
// 关键性质:BST 的中序遍历产生有序输出!
void inorder(TreeNode* root) {
if (root == nullptr) return;
inorder(root->left); // 先左子树
cout << root->val << " "; // 然后根
inorder(root->right); // 然后右子树
}
// 对 BST(值 {1, 3, 4, 5, 8}):
// 中序:1 3 4 5 8 ← 有序!这是 BST 最重要的性质
🔑 核心思路: 任何 BST 的中序遍历始终产生有序序列。这就是为什么
std::set能按有序迭代——内部使用中序遍历。
迭代版中序(稍复杂):
📄 C++ 完整代码
// 中序遍历迭代版
void inorderIterative(TreeNode* root) {
stack<TreeNode*> stk;
TreeNode* curr = root;
while (curr != nullptr || !stk.empty()) {
// 尽量向左走
while (curr != nullptr) {
stk.push(curr);
curr = curr->left;
}
// 处理最左侧未处理的节点
curr = stk.top(); stk.pop();
cout << curr->val << " ";
// 转向右子树
curr = curr->right;
}
}
3.11.3.3 后序遍历
📄 查看代码:3.11.3.3 后序遍历
// 后序遍历 — O(N) 时间
// 访问顺序:左子树,右子树,根
// 用于:删除树、求值表达式树
void postorder(TreeNode* root) {
if (root == nullptr) return;
postorder(root->left); // 先左子树
postorder(root->right); // 然后右子树
cout << root->val << " "; // 根最后
}
// ── 用后序遍历清理内存 ──
void deleteTree(TreeNode* root) {
if (root == nullptr) return;
deleteTree(root->left); // 先删左子树
deleteTree(root->right); // 再删右子树
delete root; // 最后删这个节点(安全:子节点已删)
}
3.11.3.4 层序遍历(BFS)
📄 查看代码:3.11.3.4 层序遍历(BFS)
// 层序遍历(BFS)— O(N) 时间,O(W) 空间(W = 最大层宽)
// 使用队列:逐层处理节点
void levelOrder(TreeNode* root) {
if (root == nullptr) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int levelSize = q.size(); // 当前层的节点数
for (int i = 0; i < levelSize; i++) {
TreeNode* node = q.front(); q.pop();
cout << node->val << " ";
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
cout << "\n"; // 层间换行
}
}
// 对 BST [5, 3, 8, 1, 4]:
// 第 0 层:5
// 第 1 层:3 8
// 第 2 层:1 4
遍历汇总
树: [5]
/ \
[3] [8]
/ \ /
[1] [4] [7]
前序: 5 3 1 4 8 7
中序: 1 3 4 5 7 8 ← 有序!
后序: 1 4 3 7 8 5
层序: 5 | 3 8 | 1 4 7
3.11.4 树的高度与平衡
3.11.4.1 计算树的高度
📄 查看代码:3.11.4.1 计算树的高度
// 树的高度 — O(N) 时间,O(H) 递归栈空间
// 高度 = 最长的根到叶路径的长度
// 约定:空树高度 = -1,叶节点高度 = 0
int height(TreeNode* root) {
if (root == nullptr) return -1; // 空子树高度 -1
int leftHeight = height(root->left); // 左子树高度
int rightHeight = height(root->right); // 右子树高度
return 1 + max(leftHeight, rightHeight); // +1 表示当前节点
}
3.11.4.2 检查平衡
平衡二叉树要求每个节点的左右子树高度差不超过 1。
📄 C++ 完整代码
// 检查平衡 BST — O(N) 时间
// 不平衡时返回 -1,否则返回子树高度
int checkBalanced(TreeNode* root) {
if (root == nullptr) return 0; // 空树平衡,高度 0
int leftH = checkBalanced(root->left);
if (leftH == -1) return -1; // 左子树不平衡
int rightH = checkBalanced(root->right);
if (rightH == -1) return -1; // 右子树不平衡
// 检查当前节点的平衡:高度差不超过 1
if (abs(leftH - rightH) > 1) return -1; // 不平衡!
return 1 + max(leftH, rightH); // 平衡时返回高度
}
bool isBalanced(TreeNode* root) {
return checkBalanced(root) != -1;
}
3.11.4.3 节点计数
📄 查看代码:3.11.4.3 节点计数
// 节点计数 — O(N)
int countNodes(TreeNode* root) {
if (root == nullptr) return 0;
return 1 + countNodes(root->left) + countNodes(root->right);
}
// 专门统计叶节点
int countLeaves(TreeNode* root) {
if (root == nullptr) return 0;
if (root->left == nullptr && root->right == nullptr) return 1; // 叶节点!
return countLeaves(root->left) + countLeaves(root->right);
}
3.11.5 最近公共祖先(LCA)——暴力方法
有根树中两个节点 u 和 v 的 LCA 是它们的最深公共祖先。
📄 有根树中两个节点 `u` 和 `v` 的 **LCA** 是它们的最深公共祖先。
[1]
/ \
[2] [3]
/ \ \
[4] [5] [6]
/
[7]
LCA(4, 5) = 2 (4 和 5 都是 2 的后代)
LCA(4, 6) = 1 (最深公共祖先是根 1)
LCA(2, 4) = 2 (节点 2 是 4 的祖先,也是自身的祖先)
O(N) 暴力 LCA
📄 查看代码:O(N) 暴力 LCA
// LCA 暴力法 — 每次查询 O(N)
// 策略:找从根到每个节点的路径,然后找最后一个公共节点
// 第一步:找从根到目标节点的路径
bool findPath(TreeNode* root, int target, vector<int>& path) {
if (root == nullptr) return false;
path.push_back(root->val); // 将当前节点加入路径
if (root->val == target) return true; // 找到目标!
// 先尝试左子树,再尝试右子树
if (findPath(root->left, target, path)) return true;
if (findPath(root->right, target, path)) return true;
path.pop_back(); // 回溯:目标不在这个子树中
return false;
}
// 第二步:用两条路径找 LCA
int lca(TreeNode* root, int u, int v) {
vector<int> pathU, pathV;
findPath(root, u, pathU); // 从根到 u 的路径
findPath(root, v, pathV); // 从根到 v 的路径
// 找两条路径的最后一个公共节点
int result = root->val;
int minLen = min(pathU.size(), pathV.size());
for (int i = 0; i < minLen; i++) {
if (pathU[i] == pathV[i]) {
result = pathU[i]; // 仍然公共
} else {
break; // 分叉了
}
}
return result;
}
💡 USACO 说明: 对于 USACO Silver 题目,O(N) 暴力 LCA 并非总是够用。N ≤ 10^5 个节点且 Q ≤ 10^5 次查询时,总计 O(NQ) = O(10^10)——太慢了。只在 N, Q ≤ 5000 时使用暴力。本章 §5.5.1 讲解 O(log N) 的二进制倍增 LCA 用于更难的题目。
3.11.6 完整 BST 实现
这是完整的、可直接用于竞赛的 BST:
📄 这是完整的、可直接用于竞赛的 BST:
#include <bits/stdc++.h>
using namespace std;
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
struct BST {
TreeNode* root;
BST() : root(nullptr) {}
// ── 插入 ──
TreeNode* _insert(TreeNode* node, int val) {
if (!node) return new TreeNode(val);
if (val < node->val) node->left = _insert(node->left, val);
else if (val > node->val) node->right = _insert(node->right, val);
return node;
}
void insert(int val) { root = _insert(root, val); }
// ── 搜索 ──
bool search(int val) {
TreeNode* curr = root;
while (curr) {
if (val == curr->val) return true;
curr = (val < curr->val) ? curr->left : curr->right;
}
return false;
}
// ── 中序遍历(有序输出) ──
void _inorder(TreeNode* node, vector<int>& result) {
if (!node) return;
_inorder(node->left, result);
result.push_back(node->val);
_inorder(node->right, result);
}
vector<int> getSorted() {
vector<int> result;
_inorder(root, result);
return result;
}
// ── 高度 ──
int _height(TreeNode* node) {
if (!node) return -1;
return 1 + max(_height(node->left), _height(node->right));
}
int height() { return _height(root); }
};
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
BST bst;
vector<int> vals = {5, 3, 8, 1, 4, 7, 10};
for (int v : vals) bst.insert(v);
cout << "有序输出:";
for (int v : bst.getSorted()) cout << v << " ";
cout << "\n";
// 输出:1 3 4 5 7 8 10
cout << "高度:" << bst.height() << "\n"; // 2
cout << "搜索 4:" << bst.search(4) << "\n"; // 1(真)
cout << "搜索 6:" << bst.search(6) << "\n"; // 0(假)
return 0;
}
3.11.7 USACO 风格练习题
题目:「奶牛家族树」(USACO Bronze 风格)
题目说明:
FJ 有 N 头奶牛,编号 1 到 N。奶牛 1 是所有奶牛的祖先(「根」)。对每头奶牛 i(2 ≤ i ≤ N),其父节点是 parent[i]。奶牛的深度定义为从根(奶牛 1)到该奶牛的边数(奶牛 1 的深度为 0)。
给定树和 M 次查询,每次查询「奶牛 x 的深度是多少?」
📄 给定树和 M 次查询,每次查询「奶牛 x 的深度是多少?」
// 奶牛家族树 — 深度查询
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
vector<int> children[MAXN]; // 邻接表:children[i] = i 的子节点列表
int depth[MAXN]; // depth[i] = 节点 i 的深度
// DFS 计算深度
void dfs(int node, int d) {
depth[node] = d;
for (int child : children[node]) {
dfs(child, d + 1); // 子节点深度 +1
}
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 2; i <= n; i++) {
int par;
cin >> par;
children[par].push_back(i); // par 是 i 的父节点
}
dfs(1, 0); // 从根(奶牛 1)以深度 0 开始 DFS
while (m--) {
int x;
cin >> x;
cout << depth[x] << "\n";
}
return 0;
}
// 时间:O(N + M)
// 空间:O(N)
3.11.8 从遍历序列重建树
经典题:给定前序和中序遍历,重建原始树。
核心思路:
- 前序数组的第一个元素始终是根
- 在中序数组中,根将其分为左右子树
📄 C++ 完整代码
// 从前序 + 中序重建树 — O(N^2) 朴素版
TreeNode* build(vector<int>& pre, int preL, int preR,
vector<int>& in, int inL, int inR) {
if (preL > preR) return nullptr;
int rootVal = pre[preL]; // 前序第一个 = 根
TreeNode* root = new TreeNode(rootVal);
// 在中序数组中找根
int rootIdx = inL;
while (in[rootIdx] != rootVal) rootIdx++;
int leftSize = rootIdx - inL; // 左子树节点数
// 递归构建左右子树
root->left = build(pre, preL+1, preL+leftSize, in, inL, rootIdx-1);
root->right = build(pre, preL+leftSize+1, preR, in, rootIdx+1, inR);
return root;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int n = preorder.size();
return build(preorder, 0, n-1, inorder, 0, n-1);
}
⚠️ 常见错误
空指针崩溃:
📄 C++ 完整代码
// ❌ 错误:没有空指针检查!
void inorder(TreeNode* root) {
inorder(root->left); // root 为空时崩溃
cout << root->val;
inorder(root->right);
}
// ✅ 正确:始终先检查空指针
void inorder(TreeNode* root) {
if (root == nullptr) return; // ← 关键!
inorder(root->left);
cout << root->val;
inorder(root->right);
}
大输入时的栈溢出:
📄 C++ 完整代码
// ❌ 危险:10^5 个节点的退化树(倾斜)
// 递归深度 = 10^5,默认栈 ~8MB,约 10^4~10^5 时溢出!
// ✅ 安全:大树用迭代
void dfsIterative(TreeNode* root) {
stack<TreeNode*> stk;
if (root) stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top(); stk.pop();
process(node);
if (node->right) stk.push(node->right);
if (node->left) stk.push(node->left);
}
}
5 大 BST/树 Bug
- 忘记
nullptr基础情况 —— 立即导致段错误 - 插入/删除后没有返回(可能是新的)根节点 —— 树结构损坏
- 栈溢出 —— N > 10^5 时用迭代遍历
- 内存泄漏 —— 始终
delete删除的节点(或用智能指针) - STL set 够用时却手写 BST —— 竞赛中直接用
std::set
本章总结
📌 核心要点
| 概念 | 要点 | 时间复杂度 |
|---|---|---|
| BST 搜索 | 根据比较结果向左/向右走 | 平均 O(log N),最坏 O(N) |
| BST 插入 | 找到正确位置,在空处插入 | 平均 O(log N) |
| BST 删除 | 3 种情况:叶节点、单子节点、双子节点 | 平均 O(log N) |
| 中序 | 左 → 根 → 右 | O(N) |
| 前序 | 根 → 左 → 右 | O(N) |
| 后序 | 左 → 右 → 根 | O(N) |
| 层序 | BFS 按层 | O(N) |
| 高度 | max(左高, 右高) + 1 | O(N) |
| LCA(暴力) | 找路径再比较 | 每次查询 O(N) |
| LCA(二进制倍增) | 预处理 2^k 祖先 | 预处理 O(N log N),查询 O(log N) |
| 欧拉序 | DFS 时间戳展平树 | 预处理 O(N),子树查询 O(1)~O(log N) |
❓ 常见问题
Q1:什么时候用 BST vs std::set?
A:竞赛编程中几乎始终用
std::set。std::set由红黑树(平衡 BST)支持,保证O(log N);手写 BST 可能退化到O(N)。只在需要自定义 BST 行为时(如追踪子树大小来查询「第 K 大」)才考虑手写,或使用__gnu_pbds::tree(策略树)。
Q2:线段树和 BST 是什么关系?
A:线段树(第 3.9 章)是完全二叉树,但不是 BST——节点存储区间聚合值(如区间和),而不是有序的键。两者都是二叉树,结构相似,但目的完全不同。理解 BST 的指针/递归操作使线段树代码更容易理解。
Q3:前序/中序/后序遍历,竞赛中最常用哪种?
A:中序最重要——它输出 BST 的有序序列。后序常用于树形 DP(先处理子节点再处理父节点)。**层序(BFS)**用于按层处理。前序较少用,但对树的序列化/反序列化有用。
Q4:递归和迭代实现哪个更好?
A:递归代码简洁易懂(竞赛中首选)。但 N ≥ 10^5 且树可能退化时,递归有栈溢出风险(默认栈 ~8MB,支持约 10^4~10^5 层)。USACO 题目通常用非退化树,所以递归通常没问题;但不确定时,迭代更安全。
Q5:LCA 在竞赛编程中有多重要?
A:非常重要!LCA 是树形 DP 和路径查询的基础。在 USACO Silver 偶尔出现,USACO Gold 几乎必考。本章 §3.11.5 的暴力 LCA 处理 N ≤ 5000 的情况;§5.5.1 的二进制倍增 LCA 处理 N, Q ≤ 5×10^5 的大型树,是竞赛必备。
🔗 与其他章节的联系
- 第 2.3 章(函数与数组):递归基础——二叉树遍历是递归的完美应用
- 第 3.8 章(映射与集合):
std::set/std::map由平衡 BST 支持;理解 BST 能更好地使用它们 - 第 3.9 章(线段树):线段树是完全二叉树;build/query/update 的递归结构与 BST 遍历完全相同
- 第 5.2 章(图论算法):树是特殊的无向图(连通无环);所有树算法都是图算法的特例
- §5.5.1 LCA 倍增 + §5.5.2 欧拉序:直接建立在本章树遍历基础上,是 Gold 级核心技术
练习题
题目 3.11.1 — BST 验证 🟢 简单 给定一棵二叉树(不一定是 BST),判断它是否满足 BST 性质。
提示
常见错误:只检查 `root->left->val < root->val` 是不够的。需要向下传递允许的 (minVal, maxVal) 范围。✅ 完整题解
核心思路: 向下传递允许的 (min, max) 范围,每个节点必须严格在其范围内。
#include <bits/stdc++.h>
using namespace std;
struct TreeNode { int val; TreeNode *left, *right; };
bool isValidBST(TreeNode* root, long long lo, long long hi) {
if (!root) return true;
if (root->val <= lo || root->val >= hi) return false;
return isValidBST(root->left, lo, root->val)
&& isValidBST(root->right, root->val, hi);
}
// 用法:isValidBST(root, LLONG_MIN, LLONG_MAX);
为什么需要最小/最大边界? 因为根的右子树中的某个节点,即使是某个祖先的左子节点,也必须 > 根。只传直接父节点不够。
复杂度: O(N) 时间,O(H) 递归栈。
题目 3.11.2 — BST 中序第 K 小 🟢 简单 找 BST 中第 K 小的元素。
提示
中序遍历按有序访问节点,访问到第 K 个节点时停止。✅ 完整题解
int kthSmallest(TreeNode* root, int k) {
stack<TreeNode*> st;
TreeNode* cur = root;
while (cur || !st.empty()) {
while (cur) { st.push(cur); cur = cur->left; }
cur = st.top(); st.pop();
if (--k == 0) return cur->val;
cur = cur->right;
}
return -1;
}
复杂度: O(H + K) —— 对小 K 远优于 O(N)。
题目 3.11.3 — 树的直径 🟡 中等 找任意两个节点间的最长路径(不需要经过根)。
提示
对每个节点,经过它的最长路径 = 左高度 + 右高度。单次 DFS:返回高度,同时更新全局直径。✅ 完整题解
核心思路: 后序 DFS。每个节点计算:(a) 供父节点使用的自身高度;(b) 经过它的最优路径(更新全局答案)。
int diameter = 0;
int height(TreeNode* root) {
if (!root) return 0;
int L = height(root->left);
int R = height(root->right);
diameter = max(diameter, L + R); // 经过该节点的路径:左边 L 条边 + 右边 R 条边
return 1 + max(L, R); // 返回给父节点的高度
}
// 答案:diameter(边数)。若要节点数,diameter+1。
为什么有效? 直径必然经过某个「顶点」节点——路径上的最高节点。该顶点的贡献 = height(左) + height(右)。我们把每个节点都当作潜在的顶点来访问。
复杂度: O(N)。
题目 3.11.4 — BST 展平/中位数 🟡 中等 给定有 N 个节点的 BST,找奶牛成绩的中位数(第 ⌈N/2⌉ 小的值)。
提示
中序遍历得到有序数组,返回下标 (N-1)/2 处的元素。✅ 完整题解
void inorder(TreeNode* root, vector<int>& arr) {
if (!root) return;
inorder(root->left, arr);
arr.push_back(root->val);
inorder(root->right, arr);
}
int findMedian(TreeNode* root) {
vector<int> arr;
inorder(root, arr);
return arr[(arr.size() - 1) / 2]; // 偶数 N 时取下中位数
}
大树优化: 用题目 3.11.2 的第 K 小方法直接查找——无需展平:kthSmallest(root, (n+1)/2),节省 O(N) 内存。
复杂度: O(N) 时间和空间(或用第 K 小方法 O(H + N/2))。
题目 3.11.5 — 最大路径和 🔴 困难 节点可能有负值,找任意两个节点间路径和最大的路径。
提示
对每个节点 v:经过它的最优路径 = max(0, left_max_down) + max(0, right_max_down) + v->val。负值分支夹在 0 处。✅ 完整题解
核心思路: DFS 返回「从该节点向下出发的最优单侧路径」。全局答案考虑「以该节点为顶点的最优双侧路径」。负值子路径夹在 0 处(不包含它们)。
int bestSum = INT_MIN;
int maxGain(TreeNode* root) {
if (!root) return 0;
// 夹到 0:可以选择不包含子树(如果它是负的)
int L = max(0, maxGain(root->left));
int R = max(0, maxGain(root->right));
// 以 root 为转折点的最优路径
bestSum = max(bestSum, root->val + L + R);
// 向父节点返回单侧路径(只能选一个分支)
return root->val + max(L, R);
}
// 答案:调用 maxGain(root) 后的 bestSum
关键思路: 路径是「V」形——先上到某个顶点,再下来。每个节点恰好作为顶点考虑一次。
复杂度: O(N)。
5.5.1 LCA 进阶:二进制倍增(O(log N))
本节将 §3.11.5 的朴素 LCA 升级为 O(N log N) 预处理 + O(log N) 查询,是 USACO Gold 的必备技术。
核心思想
朴素 LCA 每次查询最多爬 O(N) 步太慢。二进制倍增预先存储:
anc[v][k] = v 的第 2^k 个祖先
将 N 步拆成最多 log N 次「跳跃」,每次跳 2 的幂次。
构建 anc 表:
anc[v][0]= v 的直接父节点(DFS 时记录)anc[v][k]=anc[anc[v][k-1]][k-1](跳 2^k = 跳两次 2^(k-1))
完整实现
📄 查看代码:LCA 二进制倍增完整实现
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005, LOG = 20;
vector<int> adj[MAXN];
int depth[MAXN], anc[MAXN][LOG];
// DFS 建树,同时计算 anc[v][k]
void dfs(int u, int par, int d) {
depth[u] = d;
anc[u][0] = par; // 直接父节点
for (int k = 1; k < LOG; k++)
anc[u][k] = anc[anc[u][k-1]][k-1]; // 倍增构建
for (int v : adj[u])
if (v != par) dfs(v, u, d + 1);
}
// O(log N) LCA 查询
int lca(int u, int v) {
// 步骤1:把深度更大的节点提升到与另一个相同深度
if (depth[u] < depth[v]) swap(u, v);
int diff = depth[u] - depth[v];
for (int k = 0; k < LOG; k++)
if ((diff >> k) & 1) u = anc[u][k];
// 步骤2:两个在相同深度的节点同步上跳,直到相遇
if (u == v) return u; // 其中一个本来就是另一个的祖先
for (int k = LOG - 1; k >= 0; k--)
if (anc[u][k] != anc[v][k]) {
u = anc[u][k];
v = anc[v][k];
}
return anc[u][0]; // 此时 u, v 的父节点就是 LCA
}
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, q; cin >> n >> q;
for (int i = 0; i < n - 1; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
dfs(1, 1, 0); // 根为1,根的父节点设为自身
while (q--) {
int u, v; cin >> u >> v;
cout << lca(u, v) << "\n";
}
return 0;
}
步骤 2 的关键理解: 从高位到低位枚举,若跳 2^k 后仍不同,就跳(否则可能跳过 LCA)。最终 u 和 v 停在 LCA 的直接子节点上,anc[u][0] 即为 LCA。
复杂度对比
| 方法 | 预处理 | 单次查询 | 适用场景 |
|---|---|---|---|
| 朴素爬树(§3.11.5) | O(N) | O(N) | N ≤ 5000,代码简单 |
| 二进制倍增 | O(N log N) | O(log N) | N, Q ≤ 5×10^5,USACO Gold |
| Euler Tour + RMQ | O(N log N) | O(1) | 超高频查询(超竞赛范围) |
5.5.2 欧拉序(DFS 时间戳)
欧拉序将树「展平」为一个线性数组,将子树查询转化为区间查询,从而用线段树或树状数组 O(log N) 回答。
核心思想
DFS 时给每个节点记录进入时间 in[u] 和退出时间 out[u]:
1
/ \
2 3
/ \
4 5
DFS 顺序:1(in=1) → 2(in=2) → 4(in=3,out=3) → 5(in=4,out=4) → 2(out=4) → 3(in=5,out=5) → 1(out=5)
in = [_, 1, 2, 5, 3, 4] (节点1~5的进入时间)
out = [_, 5, 4, 5, 3, 4] (节点1~5的退出时间)
节点2的子树 = [in[2], out[2]] = [2, 4] = {节点2, 4, 5} ✓
关键性质: 节点 u 的子树 = 欧拉序数组中下标 [in[u], out[u]] 的连续区间。
📄 查看代码:欧拉序完整实现
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
vector<int> children[MAXN];
int val[MAXN];
int in_time[MAXN], out_time[MAXN], timer_val = 0;
int euler_arr[MAXN]; // euler_arr[in_time[u]] = val[u]
void dfs_euler(int u, int parent) {
in_time[u] = ++timer_val; // 进入:记录时间戳
euler_arr[timer_val] = val[u]; // 在展平数组中记录值
for (int v : children[u]) {
if (v != parent) dfs_euler(v, u);
}
out_time[u] = timer_val; // 退出:记录最终时间戳
}
// 查询节点 u 的子树中所有值的和
// 用前缀和数组 prefix 预处理 euler_arr
int subtree_sum(int u, int prefix[]) {
return prefix[out_time[u]] - prefix[in_time[u] - 1];
}
int main() {
int n; cin >> n;
for (int i = 0; i < n - 1; i++) {
int u, v; cin >> u >> v;
children[u].push_back(v);
children[v].push_back(u);
}
for (int i = 1; i <= n; i++) cin >> val[i];
dfs_euler(1, -1); // 从根 1 开始
// 构建前缀和
int prefix[MAXN] = {};
for (int i = 1; i <= n; i++)
prefix[i] = prefix[i-1] + euler_arr[i];
// 查询节点 u 的子树和
int u; cin >> u;
cout << subtree_sum(u, prefix) << "\n";
return 0;
}
实际应用:子树更新 + 子树查询
| 需求 | 工具 | 替换欧拉序后的复杂度 |
|---|---|---|
| 静态子树求和 | 前缀和 | O(1) 查询 |
| 动态单点修改 + 子树求和 | 树状数组(BIT) | O(log N) |
| 区间修改 + 子树查询 | 线段树(懒惰传播) | O(log N) |
5.5 补充练习题
题目 5.5.1 — 子树求和(通用树) 🟢 简单
题目: 读取一棵有根树(根 = 节点 1,N 个节点),每个节点有一个值。输出每个节点子树(包含自身)的值之和。
样例:
输入:5 个节点,值=[1,2,3,4,5],父节点数组=[_, 1,1,2,2]
输出:15 11 3 4 5
(节点1子树和=1+2+3+4+5=15;节点2子树=2+4+5=11;...)
✅ 完整题解
思路: 后序 DFS,从叶节点向上累加。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n; cin >> n;
vector<long long> val(n + 1);
for (int i = 1; i <= n; i++) cin >> val[i];
vector<vector<int>> children(n + 1);
for (int i = 2; i <= n; i++) {
int p; cin >> p;
children[p].push_back(i);
}
vector<long long> sub(n + 1);
function<void(int)> dfs = [&](int u) {
sub[u] = val[u];
for (int v : children[u]) { dfs(v); sub[u] += sub[v]; }
};
dfs(1);
for (int i = 1; i <= n; i++) cout << sub[i] << " \n"[i==n];
return 0;
}
复杂度: O(N) 时间与空间。
题目 5.5.2 — 树的直径(通用树,两次 BFS) 🟡 中等
题目: 给定一棵 N 个节点的无权无向树,求树的直径(任意两点间最长路径的长度)。
注: 题目 3.11.3 只处理二叉树结构。本题处理通用树(每个节点可有任意多个子节点)。
样例:
输入:5
1 2 / 1 3 / 3 4 / 3 5
输出:3(路径 2-1-3-4 或 2-1-3-5)
✅ 完整题解
思路: 两次 BFS——第一次找最远点 u,第二次从 u 出发找直径。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> adj[100005];
pair<int,int> bfs_far(int src) {
vector<int> dist(n + 1, -1);
queue<int> q;
dist[src] = 0; q.push(src);
int far = src;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
if (dist[v] > dist[far]) far = v;
}
}
}
return {far, dist[far]};
}
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
cin >> n;
for (int i = 0; i < n - 1; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v); adj[v].push_back(u);
}
auto [u, _] = bfs_far(1);
auto [v, d] = bfs_far(u);
cout << d << "\n";
return 0;
}
题目 5.5.3 — LCA 查询(二进制倍增) 🟡 中等
题目: 给定有根树(根为 1,N 个节点)和 Q 次查询,每次给出两个节点 u、v,输出它们的 LCA 编号。N, Q ≤ 5×10^5。
✅ 完整题解
直接使用 §5.5.1 的 LCA 二进制倍增实现:
// 见 §5.5.1 完整实现(dfs 预处理 + lca 查询函数)
// main() 中:
int n, q; cin >> n >> q;
// 读入树,dfs(1, 1, 0),然后 q 次查询 lca(u, v)
追踪(树:1-2-3-4 链,查询 lca(4,1)):
depth = [_, 0, 1, 2, 3]
anc[4][0]=3, anc[4][1]=1(3的父的父), anc[3][0]=2, ...
lca(4, 1): depth[4]=3 > depth[1]=0
diff=3=0b11, k=0时(diff>>0)&1=1, u=anc[4][0]=3
k=1时(diff>>1)&1=1, u=anc[3][1]=1
现在 depth[1]=depth[1]=0, u==v=1, 返回 1 ✓
题目 5.5.4 — 欧拉序子树求和(静态) 🟡 中等
题目: N 个节点的有根树,每个节点有一个值。Q 次查询,每次询问以节点 u 为根的子树中所有值之和。
✅ 完整题解
思路: 构建欧拉序后,用前缀和数组 O(1) 回答每次查询。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
vector<int> adj[MAXN];
long long val[MAXN];
int in_t[MAXN], out_t[MAXN], timer_v = 0;
long long ea[MAXN]; // 欧拉序展平后的值数组
void dfs(int u, int par) {
in_t[u] = ++timer_v;
ea[timer_v] = val[u];
for (int v : adj[u])
if (v != par) dfs(v, u);
out_t[u] = timer_v;
}
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, q; cin >> n;
for (int i = 1; i <= n; i++) cin >> val[i];
for (int i = 0; i < n - 1; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v); adj[v].push_back(u);
}
cin >> q;
dfs(1, -1);
// 前缀和
long long prefix[MAXN] = {};
for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + ea[i];
while (q--) {
int u; cin >> u;
cout << prefix[out_t[u]] - prefix[in_t[u]-1] << "\n";
}
return 0;
}
为什么正确? 欧拉序保证 u 的子树节点恰好占据 [in_t[u], out_t[u]] 区间,前缀和 O(1) 回答区间和。
题目 5.5.5 — 最小生成树(Kruskal) 🔴 困难
题目: 读取 N 个节点 M 条边的带权无向图,求最小生成树权重之和(若不连通输出 IMPOSSIBLE)。
✅ 完整题解
用 Kruskal 算法(并查集详见第 5.6 章):
#include <bits/stdc++.h>
using namespace std;
vector<int> par, rnk;
int find(int x) { return par[x]==x ? x : par[x]=find(par[x]); }
bool unite(int x, int y) {
x=find(x); y=find(y);
if (x==y) return false;
if (rnk[x]<rnk[y]) swap(x,y);
par[y]=x; if(rnk[x]==rnk[y]) rnk[x]++;
return true;
}
int main() {
int n, m; cin >> n >> m;
par.resize(n+1); rnk.assign(n+1,0);
iota(par.begin(),par.end(),0);
vector<tuple<int,int,int>> edges(m);
for (auto& [w,u,v] : edges) cin >> u >> v >> w;
sort(edges.begin(), edges.end());
long long ans = 0; int cnt = 0;
for (auto [w,u,v] : edges)
if (unite(u,v)) { ans+=w; if(++cnt==n-1) break; }
cout << (cnt==n-1 ? to_string(ans) : "IMPOSSIBLE") << "\n";
return 0;
}
第 5.5 章(树算法完整版)结束 — 下一章:第 5.6 章:并查集