古典叙事 · 技术札记

序章

山河有卷
人间有声

写代码,也写长风、旧城与未熄的灯。
愿每一篇随笔,都有自己的山水与回声。
阅览随笔

图论树论常见总结

想提升图和树题的“思维广度”,最有效的不是背更多算法,而是建立一套判断流程:

先识别结构,再把题意翻译成标准模型,最后根据数据范围选择工具。

下面给你一套可以长期使用的框架。

一、拿到图论题,先问这 8 个问题

1. 图是什么类型?

先在草稿上标出来:

  • 有向还是无向?
  • 有权还是无权?
  • 权值是否可能为负?
  • 连通还是不连通?
  • 是否保证无环?
  • 是否有重边、自环?
  • 图是静态的还是会不断加边、删边?
  • 图比较稀疏还是稠密?

这些信息经常直接决定算法。

例如:

无向 + 连通 + n-1 条边

立刻得到:

这是一棵树
有向 + 无环

立刻想到:

拓扑排序、DAG DP
无权图最短路

立刻想到:

BFS

2. 题目到底在问什么?

常见目标可以归为:

  • 连通性;
  • 最短路;
  • 最长路;
  • 最小生成树;
  • 路径查询;
  • 环;
  • 遍历所有边;
  • 选择一些点或边;
  • 最大值最小、最小值最大;
  • 动态加边、删边;
  • 路径数量、方案数量。

把题目中的故事去掉,翻译成一句数学语言。

例如之前的巡逻题:

建立新边,使闭合巡逻路线最短

可以翻译为:

基础代价 2(n-1),最大化新边带来的节省

这种“答案 = 固定基础值 - 最大收益”是很常见的转换。

3. 数据范围允许什么复杂度?

大致可以这样判断:

数据范围 常见复杂度
(n\le 20) 状压、指数搜索
(n\le 500) (O(n^3)) 可能可以
(n\le 5000) 考虑 (O(n^2))
(n\le 2\times10^5) 通常需要 (O(n)) 或 (O(n\log n))
(n,m\le 10^6) 尽量线性

先想一个暴力做法,然后看它慢在哪里。

不要一开始就强行猜高级算法。

二、一般图的常用模型

1. 连通性

看到:

能否互相到达
连通块数量
不断合并两个集合

考虑:

  • 静态图:DFS/BFS;
  • 只加边、不删边:并查集;
  • 离线删边:倒序变成加边;
  • 更复杂动态连通:可撤销并查集、线段树分治。

并查集擅长维护:

哪些点属于同一个集合

但它本身不知道集合内部的路径结构。想维护更多信息,需要在根节点额外保存,例如:

size[root]
diameter[root]
edgeCount[root]

2. 最短路

边权情况 常用算法
所有边权都是 1 BFS
边权只有 0 和 1 0-1 BFS
边权非负 Dijkstra
有负边 Bellman-Ford 或特殊结构 DP
有向无环图 拓扑排序后 DP
点数很小,求任意两点 Floyd

一个重要习惯:

看到“最短路”先看边权,不要直接写 Dijkstra。

3. 最小生成树

看到:

连接所有点
选择一些边
总权值最小
不要求原图中的路径最短

考虑:

  • Kruskal:边排序 + 并查集;
  • Prim:不断选择连接当前集合的最小边。

注意最短路和最小生成树不同:

最短路:关注一个点到另一个点的距离
生成树:关注连接所有点的总成本

4. 有向无环图

看到:

任务依赖
先做 A 才能做 B
课程顺序
无环有向图

考虑:

  • 拓扑排序;
  • 拓扑序上的 DP;
  • 判断环;
  • 最长链、最短链、方案数。

DAG 上的最长路是容易做的;一般图的最长简单路径通常很难。

5. 强连通、桥和割点

看到:

删掉一条边后不连通
删掉一个点后不连通
有向图中互相可达

考虑:

  • 无向图桥、割点:Tarjan;
  • 有向图强连通分量:Tarjan/Kosaraju;
  • 缩点后得到 DAG,再进行 DP。

6. 二分图、匹配、网络流

看到:

人和任务配对
左边集合与右边集合匹配
每个对象最多选一次

考虑二分图匹配。

看到:

容量限制
资源从源点流向汇点
若干条不相交路径
分配问题

考虑最大流、最小费用最大流。

7. 欧拉路和哈密顿路要分清

欧拉路:

每条边经过一次

核心看节点度数。

哈密顿路:

每个点经过一次

一般图中通常很难,不要把两者混淆。

三、树为什么特殊?

树有几个极强的性质:

  1. 任意两点之间只有一条简单路径;
  2. n-1 条边;
  3. 任意删掉一条边都会断开;
  4. 任意加入一条边都会形成唯一的环;
  5. 选定根以后,每个节点只有一个父亲;
  6. 子树之间除了父节点外互不相交。

很多树题都是利用“唯一路径”把全局问题拆成局部问题。

所以拿到树题后,可以依次想:

能否定根?
能否从儿子向父亲汇总信息?
一条路径能否拆成两条向下链?
能否从叶子反向处理?
能否把子树变成一个连续区间?

四、树上的基础工具

1. DFS/BFS 和定根

定根之后:

无向树
↓
父子关系
↓
子树结构

最基础的树形 DP 框架:

void dfs(int u, int father) {
    // 初始化 u 的状态

    for (int v : tree[u]) {
        if (v == father) continue;

        dfs(v, u);

        // 用儿子 v 的状态更新 u
    }
}

所有状态都必须先用一句话定义清楚,例如:

dp[u] 表示以 u 为根的子树中的答案

或者:

dp[u] 表示从 u 向下走的最大收益

状态含义含糊,转移几乎一定会写乱。

2. 树的直径、中心和半径

树的直径:

树上最长简单路径

无权树可以两次 DFS/BFS:

  1. 任意点出发找最远点 s
  2. s 出发找最远点 t
  3. s-t 是直径。

树的半径:

[
\left\lceil\frac{\text{直径}}2\right\rceil
]

树的中心是直径中间的一个或两个点。

触发词:

树上最远距离
最长路径
选一个点使最远距离最小

但不要看到树就立刻求直径。分叉结构、选多个点等问题,往往不能只看直径。

3. 最长链与次长链

任何一条经过节点 u 的路径都可以拆成:

某个子树向上到 u + 从 u 向下到另一个子树

所以维护:

first[u]:从 u 向下的最长链
second[u]:从 u 向下的次长链

经过 u 的最大路径:

first[u] + second[u]

这适合:

  • 求树的直径;
  • 求最大带权路径;
  • 从两个不同子树选择路径;
  • 前面巡逻题中的第二条净收益路径。

重点是:

最长链和次长链必须来自不同儿子方向。

4. 换根 DP

普通树形 DP只知道“子树内部”。

如果每个点都要作为根计算答案,就考虑换根 DP:

  1. 第一次 DFS:计算子树贡献;
  2. 第二次 DFS:把父亲方向的贡献传给儿子。

触发词:

对每个节点求答案
每个点作为起点
所有点到其他点的距离和
删掉某个点后的最大部分

典型例子:

求每个点到所有点的距离之和

5. 树上背包

看到:

选择恰好 k 个节点
在每棵子树中选择若干个
选择节点且满足父子限制

考虑树形背包。

常见状态:

dp[u][j]

表示在 u 的子树中选择 j 个点的最优答案。

合并儿子时枚举:

当前已经选多少
从新儿子的子树中再选多少

复杂度经常是 (O(nk^2)) 或经过优化后的 (O(nk))。

6. LCA

看到大量两点路径问题:

两点距离
路径长度
路径上的公共祖先

考虑最近公共祖先 LCA。

无权树中:

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

一般使用倍增,预处理 (O(n\log n)),单次查询 (O(\log n))。

7. DFS 序

一次 DFS 可以让某个节点的整棵子树对应一个连续区间:

子树 u ↔ [dfn[u], out[u]]

于是:

子树修改、子树查询

可以转换为:

数组区间修改、区间查询

然后使用:

  • 前缀和;
  • 树状数组;
  • 线段树。

8. 树链剖分

DFS 序只能直接处理子树。

如果题目是:

修改 u-v 路径
查询 u-v 路径

考虑树链剖分,把一条树上路径拆成 (O(\log n)) 个连续区间,再交给线段树。

9. 删叶子

看到:

树的中心
从外向内缩小
保留连通核心
最远距离最小

考虑一层一层删叶子。

删一层叶子,相当于从树的边界向内推进一个单位。

刚才的核心城市题就是:

删到剩余点数不超过 k

删了多少层就是最小最大距离。

10. 点分治

看到大量距离相关问题,并且普通 DFS 很难:

树上是否存在距离为 k 的点对
统计距离不超过 k 的点对
动态查询某点附近的信息

考虑点分治。

这是偏高级的内容,可以等 LCA、树形 DP、树链剖分熟练后再学。

五、最重要的题意转换技巧

1. 最大值最小:固定答案检查

看到:

最小化最大距离
最大化最小距离

先考虑:

如果答案固定为 R,能否判断是否可行?

若存在单调性:

R 可行,则更大的 R 也可行

就可以二分答案。

典型题:

  • 最小化最大分段和;
  • 最大化点之间最小距离;
  • 最小化覆盖半径;
  • 限定最长路径后能否完成。

不是所有题都需要二分。有时固定答案后可以直接逐层推进,比如删叶子。

2. 正着选择困难,就反着删除

例如:

选择哪些点作为核心

正面选择有很多组合。

反过来想:

哪些边缘点可以安全删除?

往往立刻变成删叶子。

其他常见反向思考:

动态删边 → 倒序加边
最后状态 → 从最后一步向前推
选择保留什么 → 考虑删除什么

3. 基础代价加收益

先计算一个必然可行的基础答案:

基础答案

然后问每个操作能节省多少:

[
答案=基础代价-最大收益
]

巡逻题中:

基础代价 = 2(n-1)
新路对应路径的边可以少走一次

因此问题变成最大化路径收益。

这种思路常出现在:

  • 重复经过边;
  • 修改少量边;
  • 增加少量设施;
  • 在已有方案上进行优化。

4. 按点贡献或按边贡献

如果直接计算整体很难,可以问:

每个点贡献多少?
每条边贡献多少?

树上一条边被多少条路径经过,通常可以通过切断这条边后的两侧大小计算。

例如一条边切开后两边大小为 sn-s,经过这条边的点对数量是:

[
s(n-s)
]

这种“边贡献”非常常见。

5. 加一条边,先看唯一形成的环

树中加入一条 u-v 边,会形成唯一的环:

新边 + 原树中的 u-v 路径

因此所有“树上加一条边”的题,都应该先研究原来的 u-v 路径。

之前巡逻题就是典型例子。

6. 路径问题找最高点

定根后,任何路径 u-v 都可以拆成:

u → LCA(u,v)
LCA(u,v) → v

树形 DP 中,一条路径也可以看成:

从某个儿子向上到 u
再从 u 向下到另一个儿子

所以:

最长链 + 次长链

本质上是在枚举路径的最高点。

7. 只保存未来真正需要的信息

动态森林直径题中,一个连通块未来合并时只需要:

并查集根 + 当前直径

不需要真的加入新边、重建树。

这种思想叫“状态压缩”:

不保存整个历史,只保存足够决定未来的统计量。

做动态题时经常问:

未来操作究竟需要知道当前结构的哪些信息?

六、如何设计树形 DP

设计树形 DP时按这四步来。

第一步:定义状态

一定写成完整句子:

dp[u] 表示……

例如:

dp[u] 表示从 u 出发,只向子树方向走的最大路径权值

第二步:考虑叶子

叶子没有儿子,状态应该是什么?

例如最长向下链:

dp[leaf] = 0;

叶子的初始化经常能帮助你检验状态定义是否正确。

第三步:考虑一个儿子如何贡献

假设已经知道 dp[v],它怎样更新 dp[u]

gain = dp[v] + weight(u,v);

第四步:答案在哪里产生?

答案可能:

  • 就是根节点的 dp[root]
  • 在每个节点合并两个儿子时产生;
  • 需要第二次换根;
  • 需要对所有 dp[u] 取最大值。

最长路径问题的答案通常不是 dp[root],而是:

max(first[u] + second[u])

七、正确性证明的常用模板

1. 下界 + 构造

先证明任何方案都至少是 X,再构造一个恰好达到 X 的方案。

例如合并两棵树的直径:

任何连接方式至少受到原直径和半径的限制
连接两个中心可以达到这个下界

2. 交换论证

假设最优方案不是我们想要的结构,证明可以把其中一部分替换掉,并且答案不会变差。

常用于:

  • 贪心;
  • 最小生成树;
  • 区间选择;
  • 排序策略。

3. 子树归纳

先假设所有儿子的状态正确,再证明按照转移得到的父节点状态正确。

这是树形 DP 最标准的证明。

4. 唯一路径分解

证明树上一条路径一定经过某个节点或某条边,然后枚举这个关键位置。

最长、次长链就是在枚举路径的最高点。

5. 单调性证明

二分答案前必须证明:

R 可行 ⇒ R+1 一定可行

不能因为答案是数字就直接二分。

八、常见误区

1. 看到树就求直径

直径适合“单条最长路径”或“单个中心”。

但如果选择可以分叉,例如选多个连通节点,直径可能无法描述全部结构。

2. 把最长和次长直接相加

必须确认它们来自不同方向,否则可能重复经过同一条边。

3. 负权树仍然两次 DFS 求直径

两次找最远点的方法依赖边权非负。

出现负权后,使用树形 DP求最大路径更稳妥。

4. 节点数和边数混淆

路径:

v1-v2-...-vk

k 个节点,但只有 k-1 条边。

5. DFS 全局变量没有重置

两次 DFS 前常见的:

res = -1;
w = start;

必须重新初始化。

6. 递归爆栈

树可能是一条几十万节点的链。

逻辑正确但运行时错误时,要考虑:

  • 改为迭代 DFS;
  • 显式维护栈;
  • 用正序和逆序模拟后序遍历。

7. 状态含义变化

不能一会儿让:

dp[u] = 子树直径

一会儿又让它表示:

dp[u] = 向下最长链

需要不同数组或清晰命名。

九、建议的学习顺序

可以按下面顺序练习。

第一阶段:图的遍历

  • DFS/BFS;
  • 连通块;
  • 二分图染色;
  • 拓扑排序;
  • 无权最短路。

第二阶段:树的基础

  • 子树大小;
  • 深度和父亲;
  • 树的直径;
  • 树的中心;
  • 最长链、次长链;
  • 删叶子。

第三阶段:树形 DP

  • 最大独立集;
  • 树上选择与不选择;
  • 换根 DP;
  • 树形背包;
  • 带权最大路径。

第四阶段:路径查询

  • LCA;
  • 树上差分;
  • DFS 序;
  • 树状数组和线段树;
  • 树链剖分。

第五阶段:高级图论

  • 强连通分量;
  • 桥和割点;
  • 最小生成树;
  • 网络流、二分图匹配;
  • 点分治、可撤销并查集。

十、做题时可以使用的检查表

以后遇到图或树题,可以在草稿上快速写:

1. 图的类型是什么?
2. 数据范围允许什么复杂度?
3. 去掉故事后,目标是什么?
4. 先写出的暴力是什么?
5. 重复计算在哪里?
6. 是点贡献、边贡献,还是路径贡献?
7. 最大最小问题能否固定答案?
8. 正着选择困难,能否反向删除?
9. 树上能否定根并从儿子合并?
10. 最终答案在根、某个节点,还是某条路径上?

你最近做的三道题刚好代表三种非常典型的转换:

题目 表面问题 核心转换
动态森林直径 不断连接连通块 并查集维护连通块直径
巡逻加路 新增道路减少巡逻距离 基础代价减收益,重合边赋 -1
核心城市 选择连通的 k 个点 从边缘逐层删叶子

真正要积累的不是这三道题的代码,而是这三个信号:

动态合并 → 并查集
基础方案上优化 → 计算收益
连通核心、最远距离 → 从叶子反向缩小

当这些信号见得足够多,你思考新题时就不会只有“DFS 或直径”一条路,而会自然地同时尝试状态压缩、反向处理、贡献统计、树形 DP和固定答案检查。

posted @ 2026-08-18 15:31  Morphis‘  阅读(10)  评论(0)    收藏  举报