第 5.5 章:二叉树与树算法
🎯 本章目标: 从零开始理解二叉树,先掌握节点、递归和遍历,再学习 BST、LCA、欧拉序等竞赛常用技巧。每一步都会用直觉、图示和代码解释,确保你不仅会写代码,更理解背后的「为什么」。
1. 先认识树的基本结构:根、父子关系、深度、高度。
2. 再学习树的表示方式与递归思维:为什么树天然适合递归。
3. 接着学习遍历:前序、中序、后序、层序,这是后面所有算法的共同基础。
4. 然后进入第一个应用:二叉搜索树(BST),理解有序结构如何支持搜索、插入、删除。
5. 最后扩展到竞赛树算法:LCA 二进制倍增与欧拉序,把树上查询优化到
O(log N) 或更快。
🧭 阅读建议: 如果你第一次学树,请按顺序阅读;如果你已经会遍历,可以直接跳到 BST 或 LCA。但不要跳过遍历思想——LCA、欧拉序、树形 DP 本质上都建立在 DFS 遍历之上。
5.5.1 从树到二叉树:先建立模型
用生活直觉理解二叉树
想象一个公司的组织架构:CEO 在最顶端,他直接管理两个部门(左:技术部,右:市场部);每个部门经理又各管两个小组…… 这种每个节点最多分两叉的层级结构,就是二叉树。
更准确地定义:
二叉树是一种层级数据结构:
- 每个节点最多有 2 个子节点:左子节点和右子节点
- 恰好有一个根节点(无父节点)
- 每个非根节点恰好有一个父节点
先分清 3 个概念
很多初学者会把「二叉树」「二叉搜索树」「普通树」混在一起。它们的关系是:
| 名称 | 限制 | 典型用途 |
|---|---|---|
| 普通树 | 每个节点可以有任意多个子节点 | 家族关系、文件夹、通用树题 |
| 二叉树 | 每个节点最多 2 个子节点 | 遍历、表达式树、堆、线段树基础 |
| 二叉搜索树(BST) | 二叉树 + 左小右大 | 有序集合、搜索、插入、删除 |
🔑 关键区别: 二叉树只限制「最多两个孩子」,并不要求左边小、右边大;BST 才有排序性质。先理解二叉树的结构与遍历,再理解 BST 的有序性,会更自然。
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径(边数)
深度 — 从根到该节点的距离(边数)
子树 — 一个节点及其所有后代
💡 深度 vs 高度的区别: 深度是从根往下看,高度是从叶往上量。就像一栋楼——你住的楼层是"深度",这栋楼的总层数是"高度"。
图示
在这棵树中:
- 高度 = 2(最长的根到叶路径:A → B → D,经过了 2 条边)
- 根 = A,叶节点 = D, E, F
- B 是 D 和 E 的父节点;D 是 B 的左子节点,E 是 B 的右子节点
在程序里如何表示二叉树
二叉树常见有两种表示方式:
- 指针表示:每个节点保存
left和right指针,最贴近树的结构,适合讲解 BST、递归遍历、构造/删除节点。 - 数组表示:如果树接近完全二叉树,可以用下标表示父子关系,例如堆中常用
left = 2*i、right = 2*i+1。
本章前半部分用指针表示,因为它最容易看清楚「节点—左子树—右子树」的递归关系。后面的 LCA 和欧拉序会改用邻接表,因为竞赛题通常给的是普通树,而不是指针二叉树。
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。
5.5.2 树的遍历:先学会如何访问节点
遍历 = 恰好访问每个节点一次。学习树算法时,遍历是第一块地基:后面的 BST 中序输出、树高计算、LCA 预处理、欧拉序,本质上都依赖遍历。
三种 DFS 遍历访问的是同一棵树,但「处理根节点」的时机不同:
递归遍历的统一模板
看到树的递归代码时,可以固定问自己 3 个问题:
- 当前节点为空怎么办? 这是递归的基础情况。
- 当前节点要做什么? 比如输出值、统计答案、更新深度。
- 左右子树什么时候处理? 根在前就是前序,根在中间就是中序,根在最后就是后序。
void dfs(TreeNode* root) {
if (root == nullptr) return; // 1. 空节点直接返回
// 2. 在这里处理 root,就是前序
dfs(root->left); // 3. 递归处理左子树
// 2. 在这里处理 root,就是中序
dfs(root->right); // 3. 递归处理右子树
// 2. 在这里处理 root,就是后序
}
有 4 种基本遍历:
| 遍历 | 顺序 | 使用场景 |
|---|---|---|
| 前序 | 根 → 左 → 右 | 复制树、前缀表达式 |
| 中序 | 左 → 根 → 右 | BST 有序输出 |
| 后序 | 左 → 右 → 根 | 删除树、后缀表达式 |
| 层序 | BFS 按层 | 最短路、层级操作 |
💡 记忆口诀: 前序的"根"在最前,中序的"根"在中间,后序的"根"在最后。名字就暗示了顺序!
5.5.2.1 前序遍历
前序遍历就像领导视察:先看当前部门,再去左边,再去右边。
📄 查看代码:5.5.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);
}
}
5.5.2.2 中序遍历
中序遍历就像读一本书:先读左边页,再读当前页,再读右边页。对于 BST,中序遍历会产生有序序列——这是 BST 最重要的性质。
📄 查看代码:5.5.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;
}
}
5.5.2.3 后序遍历
后序遍历就像收拾房间:先收拾左边,再收拾右边,最后收拾当前这块。最适合用来释放内存(先删子节点,最后删根节点,安全!)。
📄 查看代码:5.5.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; // 最后删这个节点(安全:子节点已删)
}
5.5.2.4 层序遍历(BFS)
层序遍历就像从上往下扫描一栋楼:先看第一层,再看第二层……逐层往下。
📄 查看代码:5.5.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
5.5.3 树的高度、规模与平衡
5.5.3.1 计算树的高度
学完遍历后,最自然的第一个问题是:这棵树有多深?高度、节点数、叶子数都可以用同一种思想解决:先解决子树,再合并答案。这就是后序思想的典型应用。
树的高度就像量一栋楼有多少层:最高的那栋楼的高度就是整栋楼的层数。对树来说,就是从根到最远叶节点的边数。
📄 查看代码:5.5.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 表示当前节点
}
5.5.3.2 检查平衡
平衡二叉树要求每个节点的左右子树高度差不超过 1。
为什么需要平衡?因为不平衡的 BST 会退化(像前面说的链表),操作变慢。就像一栋楼如果左边盖了 100 层,右边只有 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;
}
5.5.3.3 节点计数
📄 查看代码:5.5.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);
}
5.5.4 二叉搜索树(BST):让树支持有序搜索
前面我们只把树当作一种层级结构。现在给二叉树加上一条排序规则,就得到 BST。BST 的核心价值是:每次比较都能决定下一步往左还是往右。
用查字典的直觉理解 BST
想象你在一本按字母顺序排列的字典里查 "mango":
- 翻到中间,看到 "lemon" —— M 在 L 后面,往后翻
- 再翻一半,看到 "papaya" —— M 在 P 前面,往前翻
- 再翻一半,看到 "mango" —— 找到了!
每次比较都能排除一半的范围,这就是 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 ✓
💡 为什么这很重要? BST 性质保证了中序遍历必然输出有序序列——这是 BST 最强大的性质,后面会反复用到。
5.5.4.1 BST 搜索
BST 搜索就像查字典:每次比较后,目标值要么更小(向左),要么更大(向右),每次都能排除半个搜索空间。
📄 查看代码:5.5.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; // 未找到
}
5.5.4.2 BST 插入
插入就是在搜索路径的末端创建一个新节点——就像字典里加一个新词,你需要顺着字母顺序找到正确的空位。
📄 查看代码:5.5.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);
5.5.4.3 BST 删除
删除是最复杂的 BST 操作。为什么?因为删掉一个节点后,树可能会「断裂」,你需要用一个合适的节点来「补位」。有 3 种情况,由简到繁:
情况 1:节点是叶节点(无子节点) —— 最简单,直接删掉就行。
[5] [5]
/ \ → / \
[3] [8] [3] [8]
/
[7] ← 删除这个叶节点
情况 2:节点只有一个子节点 —— 用子节点「顶替」它。
[5] [5]
/ \ → / \
[3] [8] [3] [7]
/ \
[7] ← 删除 [8],让 [7] 顶替它的位置
\
[9] ← [7] 的子树跟着 [7] 一起过去
情况 3:节点有两个子节点 —— 最复杂。用一个中序后继(右子树中的最小值)来替换,然后删除后继节点。
为什么用中序后继?因为中序后继是比被删节点大的最小值——用它来替换,BST 性质仍然成立。
[5] [5]
/ \ → / \
[3] [8] [3] [7]
/ \ / \ / \ \
[1] [4][7][10] [1] [4] [10]
删除 [8]:中序后继是 [7](右子树中最小)
把 [8] 的值替换为 [7],然后删掉原来的 [7](叶节点,情况 1)
📄 完整代码:BST 删除
// 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;
}
5.5.4.4 BST 退化问题
⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表:
[1]
\
[2]
\
[3] ← 每次操作 O(N),不是 O(log N)!
\
[4]
\
[5]
想象一个极端的字典:如果所有词都按字母顺序从 A 到 Z 排列,你每次查 Z 都要从头翻到尾——这就失去了"每次排除一半"的优势。
这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::set 和 std::map 用红黑树实现——始终保证 O(log N)。
std::set / std::map 代替手写 BST。它们始终保持平衡。学习 BST 基础是为了理解它们为什么有效,竞赛中直接用 STL(见第 3.8 章)。
5.5.5 完整 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;
}
5.5.6 从遍历序列重建树
经典题:给定前序和中序遍历,重建原始树。
核心思路:
- 前序数组的第一个元素始终是根
- 在中序数组中,根将其分为左右子树
让我们一步步来看:
💡 Code 代码(12 行)
前序:[3, 9, 20, 15, 7]
中序:[9, 3, 15, 20, 7]
第一步:前序第一个 = 3,所以根是 3
第二步:在中序里找 3,把它左边和右边分开
[9, | 3 |, 15, 20, 7]
↑左子树 ↑ ↑右子树↑
第三步:递归处理左子树和右子树
左子树:前序 [9],中序 [9] → 叶节点 9
右子树:前序 [20, 15, 7],中序 [15, 20, 7]
→ 根 20,左 15,右 7
📄 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);
}
5.5.7 最近公共祖先(LCA)——从暴力方法开始
前面的内容都围绕二叉树展开。竞赛中的树题通常是普通树:每个节点可能有很多个孩子,输入也常常是边列表。LCA 是普通树上最重要的查询之一。我们先从最直觉的暴力方法学起,再升级到二进制倍增。
用家族族谱理解 LCA
LCA 就是找两个人的最近共同祖先。比如:
- 你和表哥的 LCA 是你们的爷爷(而不是太爷爷)
- 你和亲兄弟的 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
暴力法的思路非常直觉:从根分别走到 u 和 v,记录路径,然后找两条路径的最后一个公共点。就像两个人从北京出发,一个人去了上海,一个人去了广州,他们的 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.8 讲解 O(log N) 的二进制倍增 LCA 用于更难的题目。
5.5.8 LCA 进阶:二进制倍增(O(log N))
本节将 §5.5.7 的朴素 LCA 升级为 O(N log N) 预处理 + O(log N) 查询,是 USACO Gold 的必备技术。
从暴力法到倍增法
暴力 LCA 慢在一个地方:每次查询都可能从节点一路爬到根。如果查询很多,重复爬同样的祖先链会浪费大量时间。倍增法的做法是:提前记住每个节点向上跳 1、2、4、8…… 步分别会到哪里。
核心思想
朴素 LCA 每次查询最多爬 O(N) 步太慢。二进制倍增预先存储:
anc[v][k] = v 的第 2^k 个祖先
将 N 步拆成最多 log N 次「跳跃」,每次跳 2 的幂次。
直觉理解: 想象你要爬 1000 级楼梯。朴素法是一步步走(1000 步)。倍增法是:先跳 512 级,再跳 256 级,再跳 128 级…… 总共不到 10 次跳跃就到了!这就是二进制倍增的威力——用二进制的思想把线性步数压缩到对数级。
构建 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。
复杂度对比
| 方法 | 预处理 | 单次查询 | 适用场景 |
|---|---|---|---|
| 朴素爬树(§5.5.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.9 欧拉序(DFS 时间戳):把子树变成区间
LCA 解决的是两个点之间的祖先关系;欧拉序解决的是另一个常见问题:如何快速处理某个节点的整棵子树。欧拉序将树「展平」为一个线性数组,将子树查询转化为区间查询,从而用线段树或树状数组 O(log N) 回答。
核心思想
DFS 时给每个节点记录进入时间 in[u] 和退出时间 out[u]:
💡 Code 代码(12 行)
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.10 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)
⚠️ 常见错误
空指针崩溃:
📄 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) |
🧩 从浅到深的完整脉络
- 二叉树基础:先理解根、父子、叶子、深度、高度。
- 遍历:学会前序、中序、后序、层序,这是所有树算法的基本动作。
- 高度/平衡/计数:用递归合并子树答案。
- BST:给二叉树加上排序规则,得到高效搜索结构。
- LCA:从两个节点向上找最近公共祖先,暴力法先建立直觉。
- 二进制倍增:把一步步爬祖先优化成按 2 的幂跳跃。
- 欧拉序:把树的子树变成数组区间,连接线段树和树状数组。
❓ 常见问题
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:线段树(第 5.7 章)是完全二叉树,但不是 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 几乎必考。本章 §5.5.5 的暴力 LCA 处理 N ≤ 5000 的情况;§5.5.8 的二进制倍增 LCA 处理 N, Q ≤ 5×10^5 的大型树,是竞赛必备。
🔗 与其他章节的联系
- 第 2.3 章(函数与数组):递归基础——二叉树遍历是递归的完美应用
- 第 3.8 章(映射与集合):
std::set/std::map由平衡 BST 支持;理解 BST 能更好地使用它们 - 第 5.7 章(线段树):线段树是完全二叉树;build/query/update 的递归结构与 BST 遍历完全相同
- 第 5.2 章(图论算法):树是特殊的无向图(连通无环);所有树算法都是图算法的特例
- §5.5.8 LCA 倍增 + §5.5.9 欧拉序:直接建立在本章树遍历基础上,是 Gold 级核心技术
基础练习题
题目 5.5.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) 递归栈。
题目 5.5.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)。
题目 5.5.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)。
题目 5.5.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 时取下中位数
}
大树优化: 用题目 5.5.2 的第 K 小方法直接查找——无需展平:kthSmallest(root, (n+1)/2),节省 O(N) 内存。
复杂度: O(N) 时间和空间(或用第 K 小方法 O(H + N/2))。
题目 5.5.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 — 子树求和(通用树) 🟢 简单
题目: 读取一棵有根树(根 = 节点 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 个节点的无权无向树,求树的直径(任意两点间最长路径的长度)。
注: 题目 5.5.3 只处理二叉树结构。本题处理通用树(每个节点可有任意多个子节点)。
样例:
输入:5
1 2 / 1 3 / 3 4 / 3 5
输出:3(路径 2-1-3-4 或 2-1-3-5)
思路: 两次 BFS——第一次从任意节点找最远点 u,第二次从 u 出发找直径。
直觉上:树的直径是一条最长的「链」,它的两个端点一定是叶子。从任意点出发,BFS 找到的最远点一定是直径的一个端点;再从这个端点出发,BFS 找到的最远点就是另一个端点。
✅ 完整题解
#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.8 的 LCA 二进制倍增实现:
// 见 §5.5.8 完整实现(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 章:并查集