树的基础教程

GESP 六级备考 · 从零理解树的所有核心概念

树的概念 四种遍历 子树递归 LCA 与距离

什么是树?

在学 DFS/BFS 的时候,你已经在树上做过搜索了。但"树"到底有哪些性质?这一节我们从零开始,把树的核心概念一次讲清楚。

树的定义

树(Tree)是一种特殊的有向/无向图,它满足:

1. 连通:任意两个节点之间都有路径相连

2. 无环:没有回路,不会绕一圈走回来

3. n 个节点,n-1 条边:这是树的标志性性质

如果你去掉一条边,树就会变成两棵更小的树(断开连通);如果加一条边,就会出现环(不再无环)。所以 n-1 条边恰好是"连通且无环"的黄金数量。

核心术语一览

下面这些术语在所有树相关题目中都会反复出现。先看一遍定义,然后在互动树里亲手感受。

根节点 (Root)
树的"起点",深度为 0。一棵树选不同的根,结构会不同。
叶子节点 (Leaf)
没有子节点的节点。一棵 n 个节点的树至少有 1 个叶子。
父节点 (Parent)
节点 u 的上一级节点。根节点没有父节点。
子节点 (Child)
节点 u 的下一级节点。一个节点可以有多个子节点。
深度 (Depth)
从根到该节点的边数。根的深度为 0。
高度 (Height)
树中最深的叶子节点的深度。也就是树有几"层"。
度 (Degree)
一个节点的子节点个数。叶子节点的度为 0。
兄弟 (Sibling)
拥有同一个父节点的节点互为兄弟。

互动树:点击节点查看属性

点击树上的任意节点,右侧面板会显示它的深度、度数、父节点和子节点。试着找出根节点、叶子节点,看看不同节点的属性有什么不同。

节点属性

点击树上的任意节点查看属性

根节点
叶子节点
内部节点
选中节点
重要性质

1. 树中任意两个节点之间有且仅有唯一路径(因为没有环)。

2. 这条唯一路径的长度 = 两点之间的距离

3. 选不同的根节点,树的"形状"会不同,但边的连接关系不变。这叫做有根树 vs 无根树

4. 竞赛中最常用的存储方式是 邻接表vector<int> adj[MAXN],每条边存两次(双向)。

树的邻接表表示

C++ · 邻接表建树
const int MAXN = 200005;
vector<int> adj[MAXN];  // 邻接表

// 读入 n-1 条边
for (int i = 0; i < n - 1; i++) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);  // 双向存边
    adj[v].push_back(u);
}

树的遍历:四种方式

树的遍历就是按某种顺序访问所有节点。不同的遍历方式,访问顺序不同,用途也不同。下面用同一棵树,对比四种遍历方式。

选择一种遍历方式,点击"播放"开始动画。
访问顺序:
已访问
当前访问
未访问
前序遍历 (Preorder)

访问顺序:根 → 左子树 → 右子树

先访问根节点,然后递归遍历左子树,最后递归遍历右子树。这是最"自然"的 DFS 顺序。

用途:复制一棵树、输出目录结构、表达式树的前缀表达式(波兰表达式)。

四种遍历对比

遍历方式 访问顺序 核心特点 典型用途
前序 根 → 左 → 右 根在第一个 复制树、目录结构
中序 左 → 根 → 右 根在中间 二叉搜索树排序
后序 左 → 右 → 根 根在最后 删除树、子树计算
层序 逐层从左到右 BFS 方式 求深度、最短路径
C++ · DFS 遍历模板
void dfs(int u, int fa) {
    // 前序:在这里访问 u(根→左→右)
    visit(u);

    for (int v : adj[u]) {
        if (v != fa) {       // 不走回头路
            dfs(v, u);
        }
    }
    // 后序:在这里访问 u(左→右→根)
}

void bfs(int root) {      // 层序遍历
    queue<int> q;
    q.push(root);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        visit(u);
        for (int v : adj[u])
            if (!vis[v]) { vis[v] = true; q.push(v); }
    }
}
关键技巧:fa 参数

在无根树(邻接表存储)上做 DFS 时,需要传入 fa(父节点)参数,避免"走回头路"。这是树上 DFS 最重要的模板,几乎所有树题都会用到。

如果是有根树(每个节点知道自己的子节点列表),则不需要 fa,直接遍历 children[u] 即可。

子树与递归:自底向上的思考

树上很多问题都需要用到递归:先解决子问题(子树),再把结果合并到当前节点。这种"自底向上"的思考方式是树形 DP 的基础。

什么是子树?

选一个根节点后,对于树上任意节点 u,子树(u) 包含 u 本身以及 u 的所有后代节点。如果 u 是叶子,它的子树只有自己。

经典问题:求每棵子树的大小

定义:size[u] = 以 u 为根的子树中节点的个数(包括 u 自身)。

递推:size[u] = 1 + sum(size[v]),其中 v 是 u 的所有子节点。

边界:叶子节点的 size = 1。

这就是典型的"后序遍历"思想——先算子节点,再算自己。

动画演示:计算子树大小

点击"播放",看 DFS 如何从叶子开始,自底向上计算每个节点的子树大小。注意右侧的递归调用栈,它展示了递归的"深度"。

递归调用栈
点击"播放"开始动画,看子树大小如何自底向上计算。
已计算 size
正在计算
未访问
C++ · 子树大小计算
int size[MAXN];

void calcSize(int u, int fa) {
    size[u] = 1;  // 先算上自己
    for (int v : adj[u]) {
        if (v != fa) {
            calcSize(v, u);    // 递归计算子节点
            size[u] += size[v];  // 累加子树大小
        }
    }
}

// 调用:calcSize(1, 0);
// 此时 size[1] = n(整棵树的大小)
举一反三

同样的"后序递推"模式可以解决很多子树问题:

1. 子树最大值mx[u] = max(val[u], max(mx[v]))

2. 子树节点和sum[u] = val[u] + sum(sum[v])

3. 子树深度h[u] = 1 + max(h[v])(叶子 h=1)

4. 子树中满足条件的节点数:根据条件调整递推关系

核心模板都是:先递归子节点,再合并结果到当前节点

LCA 与树上距离

树上任意两点之间有唯一路径。理解这条路径,是解决很多树题的关键。最近公共祖先(LCA)是这条路径上的关键枢纽。

什么是 LCA?

对于节点 u 和 v,它们的 LCA(u, v) 是同时是 u 和 v 祖先的节点中,深度最大的那个(离 u、v 最近的)。

直观理解

想象一棵家谱树,你是节点 u,你朋友是节点 v。你们的 LCA 就是你们最近的共同祖先——可能是你们的爷爷,也可能是更上面的人。

特例:如果 u 是 v 的祖先,那么 LCA(u, v) = u。

互动演示:选两个节点,看 LCA 和路径

点击树上的两个节点,系统会高亮它们的路径,并标出 LCA。观察路径的走向:它从 u 上行到 LCA,再从 LCA 下行到 v。

LCA 查询

点击树上的两个节点

操作

LCA 节点
路径上的节点
其他节点
树上距离公式

因为树上任意两点之间有唯一路径,所以距离 = 路径长度。而路径一定经过 LCA:

dist(u, v) = depth[u] + depth[v] - 2 * depth[LCA]

解释:u 到 LCA 的距离 = depth[u] - depth[LCA],LCA 到 v 的距离 = depth[v] - depth[LCA]。加起来就是:

(depth[u] - depth[LCA]) + (depth[v] - depth[LCA]) = depth[u] + depth[v] - 2*depth[LCA]

与"树上漫步"的联系

回到 P11962 树上漫步:偶数步能到达 ⟺ 距离为偶数 ⟺ depth[u] + depth[v] - 2*depth[LCA] 为偶数。

2*depth[LCA] 一定是偶数,所以距离的奇偶性只取决于 depth[u] + depth[v] 的奇偶性,即 depth[u] 和 depth[v] 奇偶性相同

这就是为什么"偶数步到达 ⟺ 深度奇偶性相同"——LCA 是理解这个结论的关键一步!

如何求 LCA?

方法一:向上标记法(朴素)

1. 先 BFS 求出每个节点的深度和父节点。

2. 把 u 和 v 中较深的那个向上走,直到和另一个一样深。

3. 两个一起向上走,直到相遇——相遇点就是 LCA。

时间复杂度 O(n) 每次查询。适合 n 较小的题。

C++ · 朴素 LCA
int depth[MAXN], parent[MAXN];

void bfs(int root) {  // BFS 求深度和父节点
    queue<int> q;
    q.push(root);
    depth[root] = 0; parent[root] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u])
            if (v != parent[u]) {
                parent[v] = u; depth[v] = depth[u] + 1;
                q.push(v);
            }
    }
}

int lca(int u, int v) {
    while (depth[u] > depth[v]) u = parent[u];  // 深的先走
    while (depth[v] > depth[u]) v = parent[v];
    while (u != v) { u = parent[u]; v = parent[v]; }  // 一起走
    return u;
}
方法二:倍增法(进阶)

对于 n 很大的题(如 n ≤ 2×10^5,q ≤ 10^5 次查询),朴素法 O(n) 每次查询太慢。倍增法预处理 O(n log n),每次查询 O(log n)。

核心思想:让 fa[u][k] 表示 u 向上走 2^k 步到达的节点。利用二进制分解,把"向上走 d 步"变成 log d 次跳跃。

这是 GESP 六级以上的进阶内容,理解朴素法后再学倍增会轻松很多。

知识总结与学习路径

核心知识点回顾

1. 树的定义:连通无环图,n 个节点 n-1 条边,任意两点唯一路径

2. 树的概念:根、叶子、父/子、深度、高度、度——选根后这些才有意义

3. 四种遍历:前序(根→左→右)、中序(左→根→右)、后序(左→右→根)、层序(BFS)

4. 子树递归:先递归子节点,再合并到当前节点——后序遍历的思想

5. LCA:两节点最近公共祖先,是树上路径的枢纽

6. 树上距离dist(u,v) = depth[u] + depth[v] - 2*depth[LCA]

学习建议

1. 先掌握邻接表建树DFS 遍历模板(fa 参数),这是所有树题的基础。

2. 理解后序遍历的"先子后根"思想,这是子树计算和树形 DP 的核心。

3. 把 LCA 和树上距离公式记住,很多树题都间接用到这个公式。

4. 回到"树上漫步":现在你应该能完整理解为什么偶数步到达 ⟺ 深度奇偶性相同了——它就是 LCA 距离公式的直接推论。

推荐练习顺序

1. 树的遍历(模板题)→ 2. 子树大小计算 → 3. 树上漫步 P11962(深度奇偶性)→ 4. LCA 模板题 → 5. 倍增 LCA → 6. 树形 DP 入门

每一步都建立在前一步的基础上。不要跳步,扎扎实实走完这条线,树相关的题就不再可怕了。