📖 第 5.5 章 ⏱️ 约 60 分钟 🎯 中级

第 5.5 章:二叉树与树算法

前置条件 你应该熟悉:递归(第 2.3 章)、C++ 中的指针/结构体,以及基本的图概念(邻接关系、节点、边)。本章综合了二叉树基础与进阶树算法(LCA 倍增、欧拉序),是 USACO Silver/Gold 的核心。

🎯 本章目标: 从零开始理解二叉树,先掌握节点、递归和遍历,再学习 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 的有序性,会更自然。

🌳
核心术语
根节点 — 最顶层的节点(深度 0)
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径(边数)
深度 — 从根到该节点的距离(边数)
子树 — 一个节点及其所有后代

💡 深度 vs 高度的区别: 深度是从根往下看,高度是从叶往上量。就像一栋楼——你住的楼层是"深度",这栋楼的总层数是"高度"。

图示

Binary Tree Structure

在这棵树中:

  • 高度 = 2(最长的根到叶路径:A → B → D,经过了 2 条边)
  • 根 = A叶节点 = D, E, F
  • B 是 D 和 E 的父节点D 是 B 的左子节点E 是 B 的右子节点

在程序里如何表示二叉树

二叉树常见有两种表示方式:

  1. 指针表示:每个节点保存 leftright 指针,最贴近树的结构,适合讲解 BST、递归遍历、构造/删除节点。
  2. 数组表示:如果树接近完全二叉树,可以用下标表示父子关系,例如堆中常用 left = 2*iright = 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 遍历访问的是同一棵树,但「处理根节点」的时机不同:

Binary Tree Traversals

递归遍历的统一模板

看到树的递归代码时,可以固定问自己 3 个问题:

  1. 当前节点为空怎么办? 这是递归的基础情况。
  2. 当前节点要做什么? 比如输出值、统计答案、更新深度。
  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 性质
左子树 < 节点 < 右子树
搜索
平均 O(log N)
插入
平均 O(log N)
删除
平均 O(log N)
最坏情况
O(N)

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::setstd::map 用红黑树实现——始终保证 O(log N)

AVL 树旋转:左旋 & 右旋

🔗 关键结论:竞赛编程中,用 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 是你们的爸爸

有根树中两个节点 uv 的 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;
}
暴力法
每次查询 O(N)
二进制倍增
每次查询 O(log N)
构建时间
O(N log N)

💡 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 + RMQO(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

  1. 忘记 nullptr 基础情况 —— 立即导致段错误
  2. 插入/删除后没有返回(可能是新的)根节点 —— 树结构损坏
  3. 栈溢出 —— N > 10^5 时用迭代遍历
  4. 内存泄漏 —— 始终 delete 删除的节点(或用智能指针)
  5. 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(左高, 右高) + 1O(N)
LCA(暴力)找路径再比较每次查询 O(N)
LCA(二进制倍增)预处理 2^k 祖先预处理 O(N log N),查询 O(log N)
欧拉序DFS 时间戳展平树预处理 O(N),子树查询 O(1)~O(log N)

🧩 从浅到深的完整脉络

  1. 二叉树基础:先理解根、父子、叶子、深度、高度。
  2. 遍历:学会前序、中序、后序、层序,这是所有树算法的基本动作。
  3. 高度/平衡/计数:用递归合并子树答案。
  4. BST:给二叉树加上排序规则,得到高效搜索结构。
  5. LCA:从两个节点向上找最近公共祖先,暴力法先建立直觉。
  6. 二进制倍增:把一步步爬祖先优化成按 2 的幂跳跃。
  7. 欧拉序:把树的子树变成数组区间,连接线段树和树状数组。

❓ 常见问题

Q1:什么时候用 BST vs std::set?

A:竞赛编程中几乎始终用 std::setstd::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 章:并查集