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

第 3.11 章:二叉树

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

二叉树是竞赛编程中一些最重要数据结构的基础——从二叉搜索树(BST)到线段树再到堆。深刻理解它们将使图论算法、树上 DP 和 USACO Gold 题目变得更容易上手。


3.11.1 二叉树基础

二叉树是一种层级数据结构:

  • 每个节点最多有 2 个子节点:左子节点和右子节点
  • 恰好有一个根节点(无父节点)
  • 每个非根节点恰好有一个父节点
🌳
核心术语
根节点 — 最顶层的节点(深度 0)
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径
深度 — 从根到该节点的距离
子树 — 一个节点及其所有后代

图示

Binary Tree Structure

在这棵树中:

  • 高度 = 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

三种遍历顺序访问相同的树,但顺序完全不同——各有独特的使用场景:

Binary Tree Traversals


3.11.2 二叉搜索树(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  ✓

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 种情况:

  1. 节点无子节点(叶节点):直接删除
  2. 节点有一个子节点:用子节点替换该节点
  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 性质,直到找到空位:

BST Insert

BST 插入操作逐步追踪

⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表

[1]
  \
  [2]
    \
    [3]        ← 每次操作 O(N),不是 O(log N)!
      \
      [4]
        \
        [5]

这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::setstd::map 用红黑树实现——始终保证 O(log N)

AVL 树旋转:左旋 & 右旋

🔗 关键结论:竞赛编程中,用 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)——暴力方法

有根树中两个节点 uvLCA 是它们的最深公共祖先。

📄 有根树中两个节点 `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;
}
暴力法
每次查询 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.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

  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)

❓ 常见问题

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

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