NOIP 算法笔记全部章节
本章目录

第 6 章 树上问题

本章定位:近四届 NOIP 里,2022 T3(缩点以后是一棵树)、2024 的 T3 和 T4、2025 T3 都是树上的题,只有 2023 年没有;CSP-S 2026 的 T4 也是树。这些题的正解都难,但部分分给得很规矩:总有一档“n 很小”,经常有一档“树是一条链”,有时还有一档“菊花”。

所以这一章有两个目标。第一,把树的基本功(存树、DFS、LCA、树形 DP)写熟——它们是正解的零件,也是部分分的零件。第二,学会 6.9:把“小数据、链、菊花”这三档分拿到手。6.9 是本章最值钱的一节。

6.1 存树与一次 DFS 【必会】

一句话:树是 n 个点、n−1 条边、连通的无向图。选一个点当根,一次 DFS 就能求出每个点的父亲、深度、子树大小。 识别信号:“n 个点 n−1 条边”“任意两点之间有且只有一条路线”“除了 1 号,每个点都有一个上级”。 复杂度:O(n)。

本章统一使用这几个量:

  • fa[u]:父亲,u 往根的方向走一步到的点。根没有父亲,记成 0。
  • dep[u]:深度,从根走到 u 一共经过几个点。根的深度是 1,树外的 0 号点深度是 0。
  • siz[u]:子树大小,u 和它下面所有的点一共有几个。
  • 叶子:没有儿子的点。
// 树的基本信息:一次 DFS 求出每个点的父亲、深度、子树大小、DFS 序。以 1 号点为根。
// 输入:n,接着 n-1 行 u v(树边)。
// 输出四行:fa[1..n](根的父亲记 0)、dep[1..n](根的深度记 1)、siz[1..n]、dfn[1..n](儿子按编号从小到大访问)。
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
vector<int> adj[N];
int fa[N], dep[N], siz[N], dfn[N], timer;

void dfs(int u, int parent) {
    fa[u] = parent;
    dep[u] = dep[parent] + 1;  // dep[0] 是 0,所以根的深度是 1
    siz[u] = 1;                // 先算上自己
    dfn[u] = ++timer;          // 进入 u 的时刻。u 的子树正好占据 DFS 序上 dfn[u] 到 dfn[u]+siz[u]-1 这一段
    for (int v : adj[u]) {
        if (v == parent) continue;  // 无向边会指回父亲,跳过,否则死循环
        dfs(v, u);
        siz[u] += siz[v];           // 儿子的子树算完了,累加上来
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);  // 树也是无向图,边存两次
        adj[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) sort(adj[i].begin(), adj[i].end());  // 只为让 DFS 序唯一,平时不需要这一行
    dfs(1, 0);
    for (int i = 1; i <= n; i++) cout << fa[i] << " \n"[i == n];
    for (int i = 1; i <= n; i++) cout << dep[i] << " \n"[i == n];
    for (int i = 1; i <= n; i++) cout << siz[i] << " \n"[i == n];
    for (int i = 1; i <= n; i++) cout << dfn[i] << " \n"[i == n];
    return 0;
}

记住一条规律:从上往下传的信息(父亲、深度、到根的路程)在递归之前算;从下往上收的信息(子树大小、子树里的和或最大值)在递归之后算。 后面所有的树上算法都是这两个动作的组合。

边有长度时,adj[u] 里放 (v, w),DFS 时多算一个 dis[v] = dis[u] + w(根到 v 的路程,用 long long)。

有的题不给边,直接给每个点的父亲(CSP-S 2019 括号树)。这时只存“父亲 → 儿子”一个方向:son[fa[i]].push_back(i),DFS 里不用再跳过父亲。如果题面还保证父亲的编号比自己小(f_i < i),连 DFS 都可以省掉:

dep[1] = 1;
for (int i = 2; i <= n; i++) dep[i] = dep[fa[i]] + 1;  // 正着扫:轮到 i 时,它的父亲已经算过
for (int i = 1; i <= n; i++) siz[i] = 1;
for (int i = n; i >= 2; i--) siz[fa[i]] += siz[i];     // 倒着扫:轮到 i 时,它的儿子都已经加进来了

易错点

  • 边只有 n−1 条。读边的循环是 for (int i = 1; i < n; i++);写成 i <= n 会多读一行,把后面的输入全读乱。
  • 无向边存两次;DFS 里必须跳过父亲(if (v == parent) continue;),否则无限递归。
  • siz[u] += siz[v] 写在 dfs(v, u) 的后面。写在前面,加上去的是 0。
  • 根不一定是 1。题目可能指定根(P3379 的 s),可能要你自己找(P1352 里没有上司的那个人),也可能根本没有根——这时自己选 1 号当根。
  • n = 1 时一条边也没有。用这个输入测一下,程序不能崩溃。
  • 多组数据:每组开头 adj[i].clear()、timer = 0。

练一练:洛谷 P2052 道路修建(NOI 2011:一次 DFS 求出 siz 就能算答案;n 到 10⁶,正好接着练 6.2);本系统 E03(先把树读进来)。

6.2 递归爆栈与不用递归的写法 【必会】

一句话:树很深(比如一条长链)时,递归 DFS 一层一层地占用栈空间,栈不够大,程序就崩溃。换成“BFS 序 + 倒着扫”,一层递归也不需要。 识别信号:n 到了 10⁵~10⁶,而且树可能是一条链(数据范围表里有“链”这一档,就一定有这样的数据);小数据都对,大数据一运行就崩溃(段错误),什么也不输出。 复杂度:O(n),和递归一样。

先分清哪里会爆、哪里不会:

  • NOIP 考场的评测:题面首页的注意事项里有一条“程序可使用的栈空间内存限制与题目的内存限制一致”(NOIP 2025 是第 6 条)。所以递归几十万层没有问题。但栈也算内存:在一台 Mac 上实测,tree_basic.cpp 的 dfs 每递归一层占 64 字节(换个编译器、多几个局部变量,数字会变,量级不变),一百万层就是几十 MB,要算进内存限制里。
  • 自己的电脑:默认的栈很小,Linux 和 Mac 是 8 MB,Windows 通常只有 1~2 MB。同一台 Mac 上,把 tree_basic.cpp 的数组开大以后,13 万个点的链能跑完,13.1 万个点的链直接崩溃(8 MB ÷ 64 字节 ≈ 13.1 万层)。同一份代码交上去是对的,你在本地看到的却是崩溃,很容易以为自己写错了。
  • 本系统的 judge.py:和考场一样,栈等于内存限制。“judge.py 评测是对的,自己手动运行却崩溃”,先怀疑栈。

对策有两个,都要会:

  1. 手动跑大数据之前,先在同一个终端里把栈调大:Linux(考场的 NOI Linux)用 ulimit -s unlimited;Mac 最多只能调到 64 MB,用 ulimit -s 65532;Windows 要在编译命令里加 -Wl,--stack=536870912。ulimit 只对当前这个终端有效。
  2. 会写不用递归的版本。它在哪里都不会爆栈,n = 10⁶ 的题用它最放心。
// 不用递归求父亲、深度、子树大小:先 BFS 得到“从上到下”的顺序,再把这个顺序倒过来扫一遍。以 1 号点为根。
// 输入:n,接着 n-1 行 u v。输出三行:fa[1..n](根记 0)、dep[1..n](根记 1)、siz[1..n]。
#include <bits/stdc++.h>
using namespace std;

const int N = 1000005;
vector<int> adj[N];
int fa[N], dep[N], siz[N];
int order[N];  // BFS 的访问顺序。父亲一定排在儿子前面;倒过来看,儿子一定排在父亲前面

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    int head = 1, tail = 0;  // 直接拿 order 数组当队列用
    order[++tail] = 1;
    dep[1] = 1;
    while (head <= tail) {  // 第一遍,从上到下:父亲的信息传给儿子(父亲、深度)
        int u = order[head++];
        for (int v : adj[u]) {
            if (v == fa[u]) continue;
            fa[v] = u;
            dep[v] = dep[u] + 1;
            order[++tail] = v;
        }
    }
    for (int i = n; i >= 1; i--) {  // 第二遍,从下到上:儿子的信息交给父亲(子树大小)
        int u = order[i];
        siz[u] += 1;                // 轮到 u 时,它的儿子都已经处理完,再加上自己就是最终值
        siz[fa[u]] += siz[u];       // 交给父亲。根的父亲是 0 号,加到 siz[0] 上没有影响
    }
    for (int i = 1; i <= n; i++) cout << fa[i] << " \n"[i == n];
    for (int i = 1; i <= n; i++) cout << dep[i] << " \n"[i == n];
    for (int i = 1; i <= n; i++) cout << siz[i] << " \n"[i == n];
    return 0;
}

为什么对:BFS 是一层一层往下走的,所以在 order 里,父亲一定排在儿子前面。

  • 从前往后扫 order:轮到 u 时,它的父亲已经处理过。适合“父亲传给儿子”的信息:fa、dep、到根的路程、倍增数组(6.5)、换根 DP 的第二遍(6.8)。
  • 从后往前扫 order:轮到 u 时,它的儿子全部处理过。适合“儿子交给父亲”的信息:siz、树形 DP、树上差分最后的累加(6.6)。

递归 DFS 做的无非是这两件事,所以树上的计算绝大多数都能这样改写。比如 6.8 的“没有上司的舞会”,把递归换成倒着扫就是:

for (int i = n; i >= 1; i--) {  // 倒着扫:轮到 u 时,它的儿子都已经把结果交上来了
    int u = order[i];
    f[u][1] += r[u];                       // 儿子们交上来的是 f[v][0] 之和,这里补上 u 自己
    f[fa[u]][1] += f[u][0];                // 再把 u 的结果交给父亲
    f[fa[u]][0] += max(f[u][0], f[u][1]);
}

易错点

  • 先分清崩溃的原因。小数据也崩溃,是代码错了(数组越界;忘了跳过父亲导致无限递归)。只有又大又深的数据才崩溃,是栈不够。
  • 倒着扫时,根会把自己的值加到 0 号点上(siz[0]、f[0][...])。一般没有影响;如果 0 号点另有用处(比如 6.8 选课里的虚拟根),要留意。
  • order 数组同时当队列用,head 是队头,tail 是队尾,别用反。
  • 大数组不要开在函数里面(第 0 章 0.5)。那也会爆栈,而且和递归深度没有关系。

练一练:洛谷 P2052 道路修建(n ≤ 10⁶,用不递归的写法);本系统 E03、E15(数据里都有 2×10⁵ 个点的长链,递归和不递归各写一遍)。

6.3 树的直径 【常考】

一句话:树上离得最远的两个点之间的那条路径,也指它的长度。 识别信号:“最远的两个点”“最长的一条路径”;只选一条路径的那一档(NOIP 2018 赛道修建 m = 1 时,答案就是直径)。 复杂度:O(n)。

做法一:两次 BFS。 从任意一个点出发,找到离它最远的点 A;再从 A 出发,找到离 A 最远的点 B。A 到 B 就是一条直径。它成立的原因是:边的长度都不是负数时,从任何一个点出发,离它最远的点一定是某条直径的端点。

// 树的直径:树上相距最远的两个点之间的距离。
// 两次 BFS:从任意一点出发找到最远的点 A,再从 A 出发找到最远的点 B,A 到 B 的距离就是直径。
// 输入:n,接着 n-1 行 u v w(边权非负)。输出:直径的长度。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N = 200005;
vector<pair<int, int>> adj[N];
ll dist[N];

int farthest(int s, int n) {  // 求 s 到所有点的距离,返回离 s 最远的点
    for (int i = 1; i <= n; i++) dist[i] = -1;
    queue<int> q;
    dist[s] = 0;
    q.push(s);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (auto &e : adj[u]) {
            int v = e.first, w = e.second;
            if (dist[v] != -1) continue;  // 树上两点之间只有一条路,第一次到达时算出的就是距离
            dist[v] = dist[u] + w;
            q.push(v);
        }
    }
    return max_element(dist + 1, dist + n + 1) - dist;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }
    int a = farthest(1, n);  // 离 1 最远的点一定是某条直径的一个端点
    int b = farthest(a, n);
    cout << dist[b] << '\n';
    return 0;
}

做法二:树形 DP。 经过点 u 的最长路径,是从 u 往下走的最长的两条路(走向两个不同的儿子)拼起来的。合并儿子时顺手更新答案:

ll down1[N], ans;  // down1[u]:从 u 往下走的最长距离(可以不走,所以至少是 0)
void dfs(int u, int parent) {
    for (auto &e : adj[u]) {
        int v = e.first, w = e.second;
        if (v == parent) continue;
        dfs(v, u);
        ans = max(ans, down1[u] + down1[v] + w);  // 先用“之前最长的一条”和“新的这一条”拼出经过 u 的路径
        down1[u] = max(down1[u], down1[v] + w);   // 再把新的这一条并进来。这两行的顺序不能反
    }
}

怎么选:

  • 边的长度都不是负数:两种都对。两次 BFS 直接给出两个端点,而且不用递归。
  • 有负数的边:两次 BFS 是错的,只能用 DP。
  • 要知道直径上有哪些点:第二次 BFS 时记下每个点是从谁走过来的(pre[v] = u),再从 B 沿着 pre 走回 A。

易错点

  • 第二次 BFS 必须从 A 出发,不是再从 1 出发。
  • DP 写法里先更新 ans、再更新 down1。反过来写,down1[u] 里已经含有 v 这条路,再加一次 v,同一条路就被用了两遍。
  • 距离用 long long。“没访问过”用 −1 表示,不要用 0(长度为 0 的边、起点自己的距离都是 0)。
  • 题目问的是边数、路程还是点数。点数比边数多 1。

练一练:洛谷 B4016 树的直径(边没有长度)、P1099 树网的核(NOIP 2007,先求出直径,再在直径上枚举)、P5021 赛道修建 m = 1 的那一档。

6.4 树的重心 【常考】

一句话:把这个点删掉,树会裂成几块;让最大的那一块尽量小的点,叫重心。 识别信号:“选一个点,使所有点到它的距离之和最小”“删掉一个点后,最大的连通块尽量小”“找最平衡的那个点”。 复杂度:O(n)。

删掉 u 以后剩下的块有两类:u 的每个儿子的子树各是一块(大小是 siz[v]);u 上面的所有点是一块(大小是 n - siz[u])。

// 树的重心:删掉这个点后,剩下的若干连通块里最大的那一块尽量小。重心有 1 个或 2 个。
// 输入:n,接着 n-1 行 u v。输出:一行,所有重心的编号,从小到大。
#include <bits/stdc++.h>
using namespace std;

const int N = 200005;
vector<int> adj[N];
int siz[N], worst[N], n;  // worst[u]:删掉 u 之后,最大的连通块有多少个点

void dfs(int u, int parent) {
    siz[u] = 1;
    for (int v : adj[u]) {
        if (v == parent) continue;
        dfs(v, u);
        siz[u] += siz[v];
        worst[u] = max(worst[u], siz[v]);  // 往下:每个儿子的子树是一块
    }
    worst[u] = max(worst[u], n - siz[u]);  // 往上:整棵树去掉 u 的子树,剩下的是一块
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    dfs(1, 0);
    int best = *min_element(worst + 1, worst + n + 1);
    vector<int> centroids;
    for (int i = 1; i <= n; i++)
        if (worst[i] == best) centroids.push_back(i);
    for (size_t i = 0; i < centroids.size(); i++) cout << centroids[i] << " \n"[i + 1 == centroids.size()];
    return 0;
}

重心的三条性质,记住结论就行:

  • 重心有 1 个或 2 个;有 2 个时它们相邻。
  • 删掉重心后,每一块的点数都不超过 n/2。反过来,满足这一条的点就是重心。
  • 所有点到重心的距离之和最小。点上有权重(人口、货物量)时,把“点数”换成“权重之和”,结论不变。

易错点

  • 别漏了“上面那一块” n - siz[u]。
  • n 在 dfs 里要用到,开成全局变量。
  • 重心可能有两个。题目要“编号最小的”还是“所有的”,看清楚。
  • 重心和“直径的中点”不是一回事:重心让距离之和最小,直径的中点让最远的距离最小。

练一练:洛谷 P1395 会议(输出编号最小的重心和距离之和)、P1364 医院设置(点上有人口;n ≤ 100,可以先用暴力做对,再用重心的想法做)。

6.5 LCA:最近公共祖先 【必会】

一句话:a 和 b 各自往根的方向走,最先碰到的共同的点。它也是 a 到 b 那条路径上最高的点。 识别信号:“树上两点之间的距离”“从 u 走到 v 经过的……”,而且询问很多。和树上路径有关的题,第一步几乎都是求 LCA。 复杂度:预处理 O(n log n),每次询问 O(log n)。

先看最朴素的写法:谁深谁往上走一步,直到两个点相遇。

int lca_slow(int a, int b) {  // 每次询问最坏要走 O(n) 步
    while (a != b) {
        if (dep[a] < dep[b]) swap(a, b);  // 让 a 是更深的那个
        a = fa[a];                        // 深的那个往上走一步
    }
    return a;
}

它只需要 6.1 的 fa 和 dep,只有几行,很难写错。n 和询问数都不超过几千时,它就是满分做法。不会倍增的时候,它就是你的部分分。

倍增把“一步一步走”换成“一次跳 2^k 步”。up[u][k] 表示 u 往上跳 2^k 步到达的点;它等于“先跳 2^(k−1) 步,再跳 2^(k−1) 步”。

// LCA(最近公共祖先)的倍增写法。洛谷 P3379 的格式。
// 输入:n m s(s 是根),接着 n-1 行树边,再接着 m 行询问 a b。输出:每个询问一行,a 和 b 的最近公共祖先。
#include <bits/stdc++.h>
using namespace std;

const int N = 500005, LOG = 20;  // 2^19 = 524288 > 5e5,向上跳的步数用 19 个二进制位就能表示,LOG 取 20 留一点余量
vector<int> adj[N];
int up[N][LOG], dep[N];          // up[u][k]:u 向上跳 2^k 步到达的点;跳出树了就是 0

void dfs(int u, int parent) {
    up[u][0] = parent;
    dep[u] = dep[parent] + 1;    // 根的深度是 1,0 号点(树外)的深度是 0
    for (int k = 1; k < LOG; k++) up[u][k] = up[up[u][k - 1]][k - 1];  // 跳 2^k 步 = 先跳 2^(k-1) 步,再跳 2^(k-1) 步
    for (int v : adj[u])
        if (v != parent) dfs(v, u);
}

int lca(int a, int b) {
    if (dep[a] < dep[b]) swap(a, b);    // 保证 a 是更深的那个
    for (int k = LOG - 1; k >= 0; k--)  // 第一步:把 a 提到和 b 一样深。k 必须从大到小
        if (dep[up[a][k]] >= dep[b]) a = up[a][k];
    if (a == b) return a;               // b 本来就是 a 的祖先
    for (int k = LOG - 1; k >= 0; k--)  // 第二步:两个点一起往上跳,只要跳完还不相遇就跳
        if (up[a][k] != up[b][k]) a = up[a][k], b = up[b][k];
    return up[a][0];                    // 现在两个点不同、但父亲相同,父亲就是答案
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m, s;
    cin >> n >> m >> s;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    dfs(s, 0);
    while (m--) {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << '\n';
    }
    return 0;
}

读这段代码的三个要点:

  • 第一步“提到一样深”:k 从大到小试,跳了以后不会比 b 浅,就跳。
  • 第二步“一起往上跳”:跳完以后两个点还不相同,才跳。跳完相同,说明这一步会到达或越过 LCA,所以不跳。循环结束时,两个点都停在 LCA 的正下方,再往上一步就是答案。
  • 第一步做完如果 a == b,说明 b 本来就是 a 的祖先,直接返回。漏掉这个判断,返回的会是 LCA 的父亲。

有了 LCA 能做什么:

  • 两点之间有几条边:dep[a] + dep[b] - 2 * dep[lca]。
  • 边有长度:DFS 时求出根到每个点的路程 dis,两点之间的路程是 dis[a] + dis[b] - 2 * dis[lca]。
  • 点 x 在不在 a 到 b 的路径上:看 dist(a, x) + dist(x, b) == dist(a, b) 是否成立。
  • 从 u 往上走 d 步到哪里:把 d 按二进制拆开来跳。
int kth_ancestor(int u, int d) {  // 从 u 往上走 d 步;走出树了返回 0
    for (int k = 0; k < LOG; k++)
        if (d >> k & 1) u = up[u][k];
    return u;
}

易错点

  • 根的深度必须是 1,不能是 0。 从根再往上跳,到的都是 0 号点,dep[0] 是 0。如果根的深度也是 0,“提到一样深”那一步会把 a 一路提到 0 号点上。
  • 两个 for 里,k 都是从大到小。
  • LOG 取 20:2²⁰ 大于 10⁶,n 不超过 10⁶ 都够用。
  • 数组是 up[N][LOG],两维别写反。n = 5×10⁵ 时它有 10⁷ 个 int,占 40 MB,要看一眼内存限制。
  • 根不一定是 1:P3379 的根是输入给的 s。
  • 树可能是长链时,dfs 换成 6.2 的写法:BFS 出队时填 up[u][k](父亲先出队,它的那一行已经填好了)。
  • 路程用 long long。

练一练:洛谷 P3379 【模板】最近公共祖先(LCA)、P4281 紧急集合 / 聚会(三个点两两求 LCA)、P3398 仓鼠找 sugar(判断两条路径有没有公共点);本系统 E06。

6.6 树上差分 【常考】

一句话:很多次“把 s 到 t 的路径上每个点(或每条边)都加 1”,全部加完才问结果。每次只在几个点上打标记,最后把标记累加一遍。 识别信号:“m 条路径”“被经过了多少次”“被所有路径都经过的边”;修改很多,询问只在最后。 复杂度:每次修改 O(log n),花在求 LCA 上;最后累加 O(n)。

先回忆一维差分(第 1 章 1.5):给区间 [l, r] 加 1,只做 d[l]++ 和 d[r + 1]--,最后求一遍前缀和。树上是同一个想法,只是“前缀和”换成了“子树和”:一个点最后的值 = 它的子树里所有标记之和。 所以在点 x 上打一个 +1,等于给 x 到根这条路上的每个点都加了 1。

点差分(路径上的每个点加 1),记 p 是 s 和 t 的 LCA:

  • diff[s]++、diff[t]++:s 到根、t 到根各加了 1。两条路在 p 汇合,所以 p 和它上面的点被加了 2。
  • diff[p]--:p 和它上面的点减掉 1。p 现在是 1,对了;p 的父亲往上还多着 1。
  • diff[fa[p]]--:把多出来的 1 抵消。
// 树上差分(点差分):给出 m 条路径,每条路径上的所有点都加 1,问最后值最大的点是多少。洛谷 P3128 的格式。
// 输入:n m,接着 n-1 行树边,再接着 m 行 s t(一条路径的两个端点)。输出:最大的点值。
#include <bits/stdc++.h>
using namespace std;

const int N = 50005, LOG = 17;
vector<int> adj[N];
int up[N][LOG], dep[N];
int diff[N];  // 差分数组:先只在几个点上打标记,最后“子树内标记之和”才是每个点真正的值

void dfs(int u, int parent) {
    up[u][0] = parent;
    dep[u] = dep[parent] + 1;
    for (int k = 1; k < LOG; k++) up[u][k] = up[up[u][k - 1]][k - 1];
    for (int v : adj[u])
        if (v != parent) dfs(v, u);
}

int lca(int a, int b) {
    if (dep[a] < dep[b]) swap(a, b);
    for (int k = LOG - 1; k >= 0; k--)
        if (dep[up[a][k]] >= dep[b]) a = up[a][k];
    if (a == b) return a;
    for (int k = LOG - 1; k >= 0; k--)
        if (up[a][k] != up[b][k]) a = up[a][k], b = up[b][k];
    return up[a][0];
}

void collect(int u, int parent) {  // 把子树里的标记累加上来
    for (int v : adj[u]) {
        if (v == parent) continue;
        collect(v, u);
        diff[u] += diff[v];
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    dfs(1, 0);
    while (m--) {
        int s, t;
        cin >> s >> t;
        int p = lca(s, t);
        diff[s]++, diff[t]++;  // s 和 t 各往上“发”一个 +1,一直影响到根
        diff[p]--;             // 两个 +1 在 lca 汇合,lca 被多算了一次,减掉一个
        diff[up[p][0]]--;      // lca 的父亲及更上面的点不在路径上,把剩下的那个 +1 也抵消
    }
    collect(1, 0);
    cout << *max_element(diff + 1, diff + n + 1) << '\n';
    return 0;
}

边差分(路径上的每条边加 1):让每条边归它下面的那个端点管,点 v 管的是 v 和 fa[v] 之间的那条边,根什么也不管。s 到 t 的路径上的边,对应的正好是路径上除了 p 以外的点,所以两个 +1 要在 p 这里全部抵消:

diff[s]++, diff[t]++;
diff[lca(s, t)] -= 2;  // lca 管的是它上面的那条边,不在路径上

最后同样求子树和,diff[v] 就是“v 和它父亲之间的那条边”被经过的次数。

易错点

  • 点差分:lca 和 fa[lca] 各减 1。边差分:lca 减 2。记混了只会错一点点,样例常常还能过,要用小数据手算核对。
  • 打完标记必须做最后那一遍“子树求和”。直接输出 diff 是最常见的错。
  • LCA 是根时,fa[lca] 是 0,diff[0]-- 没有影响,不用特别处理。
  • s 等于 t 时:点差分的结果是这个点加 1,边差分的结果是什么都没加。两个都是对的,可以拿来当测试数据。
  • 每次加的不是 1 而是一个很大的数时,diff 用 long long。

练一练:洛谷 P3128 Max Flow P(点差分的模板题)、P3258 松鼠的新家(点差分;路径首尾相接的地方会重复计算,要减掉)、P2680 运输计划(NOIP 2015,二分答案 + 边差分,正解难,先拿 m = 1 和链两档);本系统 E15。

6.7 DFS 序:把子树变成区间 【常考】

一句话:按 DFS 进入的先后给每个点编号 dfn[u],那么 u 的子树里的点,编号正好是连续的一段:从 dfn[u] 到 dfn[u] + siz[u] - 1。 识别信号:“给 u 的子树里每个点都加上 x”“u 的子树里的和是多少”“v 是不是 u 的后代”。 复杂度:求 DFS 序 O(n);之后每个子树操作就是一次区间操作。

dfn 和 siz 在 6.1 的模板里已经求出来了。三种用法:

  • 判断 v 在不在 u 的子树里(也就是 u 是不是 v 的祖先):
bool in_subtree(int u, int v) {  // v 在不在 u 的子树里(v == u 也算)
    return dfn[u] <= dfn[v] && dfn[v] <= dfn[u] + siz[u] - 1;
}
  • 子树求和、不带修改:直接用树形 DP 把儿子的和加上来,不需要 DFS 序。
  • 子树修改和子树询问交替出现:把点权按 DFS 序排成数组 b[dfn[u]] = a[u],在 b 上建树状数组或线段树(第 5 章)。“子树加”变成“区间加”,“子树和”变成“区间和”。

易错点

  • 区间的右端点是 dfn[u] + siz[u] - 1,别忘了减 1。
  • 数据结构里的下标是 dfn[u],不是 u。b[dfn[u]] = a[u] 最容易写反。
  • DFS 序只能把子树变成区间,不能把路径变成区间。路径问题先想 LCA 和树上差分。

练一练:洛谷 P2982 Slowing down G(子树加、单点询问,DFS 序 + 树状数组)。

6.8 树形 DP

选或不选 【必会】

一句话:状态定义在“以 u 为根的子树”上,u 的答案由各个儿子的答案合并出来。 识别信号:树上求最大、最小或方案数;每个点要做一个决定(选不选、染什么颜色),限制只发生在相邻的点之间。 复杂度:O(n × 每个点的状态数)。

三步是固定的:

  1. 定义 f[u][状态]:只看 u 的子树,u 自己处在这个状态时的最优值。
  2. 先递归算完所有儿子,再用儿子的 f 算 u 的 f。
  3. 答案在根上。
// 树形 DP(选 / 不选):没有上司的舞会。洛谷 P1352 的格式。
// 输入:n,接着 n 行每人的快乐值 r_i(可以是负数),再接着 n-1 行 l k(k 是 l 的直接上司)。
// 输出:选一些人参加舞会,任何人都不和自己的直接上司同时参加,快乐值之和最大是多少。
#include <bits/stdc++.h>
using namespace std;

const int N = 6005;
vector<int> son[N];
int r[N];
int f[N][2];  // f[u][1]:u 参加时,u 的子树里快乐值之和的最大值;f[u][0]:u 不参加时的最大值
bool has_boss[N];

void dfs(int u) {
    f[u][1] = r[u];
    f[u][0] = 0;
    for (int v : son[u]) {
        dfs(v);                            // 先算完儿子,再用儿子的结果算自己
        f[u][1] += f[v][0];                // u 参加,儿子就不能参加
        f[u][0] += max(f[v][0], f[v][1]);  // u 不参加,儿子参加与否取较大的
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> r[i];
    for (int i = 1; i < n; i++) {
        int l, k;
        cin >> l >> k;
        son[k].push_back(l);  // 输入直接给了上下级,只存“上司 -> 下属”这一个方向
        has_boss[l] = true;
    }
    int root = 1;
    while (has_boss[root]) root++;  // 没有上司的那个人是根。题目没说根是 1,不能想当然
    dfs(root);
    cout << max(f[root][0], f[root][1]) << '\n';
    return 0;
}

易错点

  • 状态里要带上“u 自己选了没有”。只定义一个 f[u] 表示子树的最优值,父亲就不知道自己还能不能选,没法转移。
  • u 自己的初值(f[u][1] = r[u]、f[u][0] = 0)在合并儿子之前赋好。
  • 根要找对(这题是没有上司的那个人)。
  • 改成求方案数时:几个儿子各选各的、互不影响,方案数要相乘;同一个点的几种状态是“或者”的关系,方案数相加;每一步都取模(第 3 章 3.7)。
  • 树可能是长链时,换成 6.2 的倒序扫描。

练一练:洛谷 P1352 没有上司的舞会、P2016 战略游戏、P1122 最大子树和;本系统 E03 的正解(状态要比“选或不选”多带一点信息)。

树上背包 【常考】

一句话:f[u][j] 表示在 u 的子树里选 j 个点时的最优值。每个儿子是一组物品——从这个儿子的子树里选几个,只能取一种——所以它是分组背包(第 3 章 3.3)。 识别信号:“在树上选恰好 m 个点”“保留 q 条边”,并且选一个点必须先选它的父亲。 复杂度:O(n²)。前提是像模板那样,把两层循环的范围限制在子树大小以内。

// 树上背包:选课。洛谷 P2014 的格式。
// 输入:n m,接着 n 行 k_i s_i(第 i 门课的先修课是 k_i,0 表示没有先修课;学分是 s_i)。
// 输出:选 m 门课(要选一门课,必须先选它的先修课),学分之和最大是多少。
#include <bits/stdc++.h>
using namespace std;

const int N = 305;
vector<int> son[N];
int s[N], siz[N];
int f[N][N];  // f[u][j]:在 u 的子树里选 j 门课,并且一定选了 u,学分之和的最大值

void dfs(int u) {
    siz[u] = 1;
    f[u][1] = s[u];  // 只选 u 自己
    for (int v : son[u]) {
        dfs(v);
        for (int j = siz[u]; j >= 1; j--)      // j:在 u 和已经合并的儿子里选几门。倒序,保证读到的 f[u][j] 还没被 v 更新过
            for (int k = 1; k <= siz[v]; k++)  // k:在 v 的子树里选几门
                f[u][j + k] = max(f[u][j + k], f[u][j] + f[v][k]);
        siz[u] += siz[v];  // 合并完才把 v 的大小算进来:两层循环的范围都不超过实际的点数
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        int k;
        cin >> k >> s[i];
        son[k].push_back(i);  // 没有先修课的课挂在虚拟的 0 号课下面,一片森林就连成了一棵树
    }
    dfs(0);
    cout << f[0][m + 1] << '\n';  // 0 号课必选、学分是 0,所以总共要选 m+1 门
    return 0;
}

易错点

  • j 必须倒序。正序会让同一个儿子被合并多次,道理和 01 背包的一维写法相同。
  • siz[u] += siz[v] 写在合并之后。写在前面,j 的范围里就混进了还没有合并的点。
  • 输入是森林(很多课没有先修课)时,用 0 号点当虚拟的根,答案要多选一个:f[0][m + 1]。
  • “必须先选 u”体现在 j 从 1 开始,f[u][0] 不参加转移。
  • 这题学分都是正数、每个状态都凑得出来,所以 f 没有初始化成负无穷。价值可以是负数,或者有的 j 凑不出来时,要初始化成 −INF,并跳过不合法的状态。
  • f[N][N]:n = 300 没有问题;n 到 5000 是 2500 万个 int,占 100 MB,要看内存限制。

练一练:洛谷 P2014 选课、P2015 二叉苹果树(物品在边上)、P1273 有线电视网(难一些)。

换根 DP 【常考】

一句话:题目问“每个点分别当根时的答案”。先以 1 为根算一遍,再从上往下用父亲的答案推出儿子的答案,一共两遍。 识别信号:“对每个点求……”“选哪个点当根最好”;每个点各做一次 DFS 是 O(n²),而 n 有 10⁵~10⁶。 复杂度:O(n)。

以“所有点的深度之和”为例。根从 u 的父亲挪到 u:u 的子树里的 siz[u] 个点离根都近了 1,其余 n - siz[u] 个点都远了 1。所以 f[u] = f[fa[u]] - siz[u] + (n - siz[u])。

// 换根 DP:以哪个点为根时,所有点的深度之和最大(深度 = 到根要走几条边)。洛谷 P3478 的模型。
// 输入:n,接着 n-1 行 u v。输出:一个点的编号;有多个答案时输出编号最小的。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N = 1000005;
vector<int> adj[N];
int fa[N], order[N];  // order:BFS 序,父亲排在儿子前面(写法见 tree_bfs_order.cpp)
ll siz[N];
ll down[N];           // down[u]:以 1 为根时,u 的子树里所有点到 u 的距离之和
ll f[N];              // f[u]:以 u 为根时,所有点的深度之和

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    int head = 1, tail = 0;
    order[++tail] = 1;
    while (head <= tail) {
        int u = order[head++];
        for (int v : adj[u])
            if (v != fa[u]) fa[v] = u, order[++tail] = v;
    }
    for (int i = n; i >= 1; i--) {  // 第一遍,自下而上:只看子树里面,算出 siz 和 down
        int u = order[i];
        siz[u] += 1;
        siz[fa[u]] += siz[u];
        down[fa[u]] += down[u] + siz[u];  // u 的子树里每个点到 fa[u],都比到 u 多走一条边
    }
    f[1] = down[1];                 // 根的答案就是 down
    for (int i = 2; i <= n; i++) {  // 第二遍,自上而下:用父亲的答案推出儿子的答案
        int u = order[i];
        f[u] = f[fa[u]] - siz[u] + (n - siz[u]);  // 根从 fa[u] 挪到 u:u 子树里的点各近 1,其余的点各远 1
    }
    int best = 1;
    for (int i = 2; i <= n; i++)
        if (f[i] > f[best]) best = i;
    cout << best << '\n';
    return 0;
}

三步:

  1. 第一遍,自下而上:以 1 为根,求出只和子树有关的信息(这里是 siz 和 down)。
  2. 根的答案直接由第一遍得到。
  3. 第二遍,自上而下:想清楚“根从父亲挪到儿子,答案变了多少”,写出式子。

易错点

  • 两遍的方向相反:第一遍先算儿子、再算自己;第二遍先算自己、再算儿子。
  • 式子里的 siz[u] 是以 1 为根时的子树大小,换根的过程中不要改它。
  • 答案很大:n = 10⁶ 的链,深度之和约为 5×10¹¹,用 long long。
  • n = 10⁶ 而且可能是链,所以模板没有用递归。
  • 换根的式子容易推错。用暴力核对:以每个点为根各做一次 DFS,O(n²)。这个暴力本身也是 n 不超过几千那一档的分。

练一练:洛谷 P3478 STA-Station(答案不唯一,输出任意一个都算对)、P1395 会议(也可以用换根做)。

6.9 树题的部分分 【必会】

一句话:树题的数据范围表里,前几档总有“n 很小”,经常有“树是一条链”,有时还有“树是菊花”。这三档不需要正解,各有固定的打法。

下面是真题里实际出现过的档(都对照洛谷上的题面核对过):

题目 表里写的 这一档其实是什么
NOIP 2018 赛道修建(P5021) m = 1 求树的直径(6.3)
同上 b_i = a_i + 1,即链 把一个数组切成 m 段,让最短的一段尽量长:二分答案(第 1 章 1.3)
同上 a_i = 1,即菊花 每条赛道只能是一条边或两条边:排序后配对
CSP-S 2019 括号树(P5658) f_i = i − 1,即链;20 个测试点里占 11 个 一个括号序列上的线性 DP
NOIP 2015 运输计划(P2680) m = 1;第 i 条航道连接 i 和 i+1 只有一条路径;数组上的区间问题
NOIP 2016 天天爱跑步(P1600) 链;所有起点都是 1;所有终点都是 1 每一档都有不用正解的做法
NOIP 2022 建造军营(P8867) n ≤ 8、m ≤ 10;链 枚举所有选法;图退化成序列
NOIP 2024 树上查询(P11364) 树是一条链,而且根的度数是 1 模拟考二会遇到这道题,这一格留给你考完自己填
NOIP 2025 树的价值(P14637) n ≤ 7、13、18;树的高度不超过 2、10、50 没有链和菊花,给的是“n 很小”和“树很矮”

下面用同一道题把三档各写一遍。题目是 6.8 的舞会问题,说成树的语言:每个点有一个权值(可正可负),选一些点,有边相连的两个点不能同时选,权值之和最大是多少。

第一档:n 很小

n ≤ 20 左右时,枚举每个点选或不选,一共 2ⁿ 种,逐个检查、取最优(第 2 章)。

ll solve_brute() {  // n <= 20:每个点选或不选,一共 2^n 种,逐个检查
    ll best = 0;
    for (int mask = 0; mask < (1 << n); mask++) {
        bool ok = true;
        for (int i = 1; i < n && ok; i++)  // 有没有哪条边的两端同时被选。eu[i]、ev[i] 是第 i 条边的两个端点
            if ((mask >> (eu[i] - 1) & 1) && (mask >> (ev[i] - 1) & 1)) ok = false;
        if (!ok) continue;
        ll sum = 0;
        for (int i = 1; i <= n; i++)
            if (mask >> (i - 1) & 1) sum += a[i];
        best = max(best, sum);
    }
    return best;
}

小数据的其他常见打法:

  • “选 k 个点”:同样枚举,只看恰好选了 k 个的(CSP-S 2026 T4 的 15 分就是这样拿的)。
  • “所有点对”“所有路径”:n 不超过几千时,从每个点出发各做一次 BFS,得到所有点对的距离,O(n²)。
  • “每次询问一条路径”:用 6.5 的 lca_slow,沿着路径把点一个一个走出来,每次询问 O(n)。

第二档:链

链就是一个序列。先把序列拿到手,再把树上的工具换成序列上的工具:

树上 链上
树形 DP 线性 DP(第 3 章 3.2)
路径 s → t 区间:从 min(pos[s], pos[t]) 到 max(pos[s], pos[t])
路径加(树上差分) 区间加(一维差分,第 1 章 1.5)
两点之间的距离 位置之差;边有长度时是前缀和之差。不需要 LCA
直径 整条链
LCA(根在链的一端) 两个点里更靠近根的那个

“拿到序列”分两种情况:

  • 题面保证“第 i 条边连接 i 和 i+1”:点的编号就是它在序列里的位置,pos[v] = v,什么都不用做。
  • 只保证“每个点最多连两条边”,编号是乱的:找一个只连着一条边的点当起点,一步一步走到另一头。下面的模板做的就是这件事,它顺便也把菊花认出来。
// 认出两种特殊形态的树:链(每个点最多连两条边)和菊花(有一个点连着其余所有的点)。
// 数据范围表里的“特殊性质”常常就是这两种,认出来就能换成简单得多的做法。
// 输入:n,接着 n-1 行 u v。
// 输出两行。第一行:是链就输出 CHAIN,后面跟着从编号较小的端点出发沿链依次经过的点;不是链输出 NOT_CHAIN。
//          第二行:是菊花就输出 STAR 和中心的编号(有多个点符合时取编号最小的);不是菊花输出 NOT_STAR。
#include <bits/stdc++.h>
using namespace std;

const int N = 200005;
vector<int> adj[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    bool chain = true;
    int start = 0, center = 0;
    for (int i = 1; i <= n; i++) {
        int degree = adj[i].size();                   // 度数:这个点连着几条边
        if (degree > 2) chain = false;                // 链上每个点最多两个邻居
        if (degree <= 1 && start == 0) start = i;     // 邻居不超过一个的点是链的端点,取编号最小的那个
        if (degree == n - 1 && center == 0) center = i;  // 连着其余所有点的,就是菊花的中心
    }
    if (!chain) {
        cout << "NOT_CHAIN\n";
    } else {
        cout << "CHAIN";
        for (int prev = 0, cur = start; cur != 0;) {  // 从端点出发,每一步走向“不是刚才来的那个”邻居
            cout << ' ' << cur;
            int next = 0;
            for (int v : adj[cur])
                if (v != prev) next = v;
            prev = cur, cur = next;
        }
        cout << '\n';
    }
    if (center == 0) cout << "NOT_STAR\n";
    else cout << "STAR " << center << '\n';
    return 0;
}

舞会问题放到链上(seq[i] 是链上的第 i 个点),就是“相邻的两个数不能同时选”:

ll solve_chain() {  // 链:序列上相邻的两个不能同时选
    ll take = a[seq[1]], skip = 0;  // take:选了当前点时的最大和;skip:没选当前点时的最大和
    for (int i = 2; i <= n; i++) {
        ll new_take = skip + a[seq[i]];
        ll new_skip = max(take, skip);
        take = new_take, skip = new_skip;
    }
    return max(take, skip);
}

6.6 的“路径上每个点加 1”放到链上(pos[v] 是点 v 在链上排第几):

int l = min(pos[s], pos[t]), r = max(pos[s], pos[t]);  // 路径 s..t 就是链上的第 l 个到第 r 个点
d[l]++, d[r + 1]--;                                    // 全部加完后求前缀和,点 v 的值是 d[pos[v]]

6.5 的“两点距离”放到链上:边没有长度,是 abs(pos[a] - pos[b]);边有长度,先求 pre[i](从链的第 1 个点走到第 i 个点的路程),距离是 llabs(pre[pos[a]] - pre[pos[b]])。

留意根在哪里。距离、路径和根没有关系;子树、深度、LCA 和根有关。根在链的一端时,LCA 是两个点里靠近根的那个。根在链的中间时:两个点在根的同一侧,LCA 是离根近的那个;在根的两侧,LCA 就是根。

第三档:菊花

菊花只有两种点:一个中心,n−1 个叶子。叶子之间互不相邻,任意两个叶子的距离都是 2。把“中心选不选”分开讨论,剩下的通常只是排序或者一个式子。

ll solve_star(int center) {  // 菊花:选了中心就不能选叶子;不选中心,叶子互不相邻,正的全选
    ll leaves = 0;
    for (int i = 1; i <= n; i++)
        if (i != center && a[i] > 0) leaves += a[i];
    return max((ll)a[center], leaves);
}
  • 路径只有两种:“叶子—中心”和“叶子—中心—叶子”。
  • 以中心为根时,两个不同的叶子的 LCA 是中心。
  • 中心不一定是 1 号点。除非题面明说(比如 a_i = 1),否则用度数去找,就像上面的模板那样。

拼起来

main 里按“小数据 → 链 → 菊花 → 其他”的顺序分流,每一档一个函数,框架见第 9 章 9.4。先把这三档写完、测完,再去想正解。

易错点

  • 性质要从输入里判断出来(数每个点连了几条边),不要凭感觉假设。
  • 链的起点必须是端点。从中间的点出发,只能走到一头。
  • n = 1、n = 2、n = 3 的树既是链又是菊花。分流时哪一档先判都可以,但每个函数在这几种输入上都不能出错。
  • 先判 n 小的那一档。暴力最可靠,小数据全部交给它。
  • 链和菊花的函数写完,都用暴力那一档对拍(第 0 章 0.10):造 n ≤ 12 的链和菊花,两个函数的结果必须一样。上面三段代码就是这样验证过的。
  • 不会做的那一档也要输出一个格式正确的结果并正常结束(第 9 章)。

练一练:本系统 E03(目标 60 分:小数据 + 链 + 菊花)、E06 和 E15 的链那一档;洛谷 P5021 赛道修建(先拿 m = 1、链、菊花三档)、P5658 括号树(先拿链的 55 分)。

本章必会清单

  • 能默写 tree_basic.cpp:存树,一次 DFS 求出 fa、dep、siz
  • 知道本地崩溃可能只是栈不够,会用 ulimit -s
  • 能默写 BFS 序 + 倒序扫描(tree_bfs_order.cpp),并用它改写一个树形 DP
  • 能默写倍增 LCA,说得出根的深度为什么是 1(lca.cpp)
  • 会用 LCA 求两点距离;不会倍增时能写出只有几行的 lca_slow
  • 能默写“选或不选”的树形 DP(tree_dp.cpp)
  • 拿到树题,先在数据范围表里找:n 小、链、菊花
  • 能把链变成序列(tree_shape.cpp),说得出树形 DP、树上差分、两点距离在链上各变成什么
  • 做过 E03,并拿到 60 分