基环树就是「一棵树再多一条边」,因此
阅读前建议先掌握 树的入门 和 拓扑排序。环的判定与提取本质上是 DFS 与拓扑排序的应用,找环的更多写法见 找环。
1 什么是基环树
一棵树有
反过来也成立:如果一个连通无向图有
结构上可以这样理解:
把环上的点记作
1.1 三种常见形态
| 形态 | 判定 | 结构 | 典型题 |
|---|---|---|---|
| 无向基环树 | 环 + 挂树 | 基环树直径、基环树 DP | |
| 内向基环树 | 每个点出度 = 1 | 环 + 指向环的树(箭头朝环) | luogu P2921、luogu P2607 |
| 外向基环树 | 每个点入度 = 1 | 环 + 从环长出的树(箭头离环) | 少单独作考点,常由内向基环树反向建图得到 |
内向与外向互为反图:把每条边反向,内向就变成外向。所以真正要写的只有内向那一套——外向的题反向建图就归约过来了(实测见下文「内向基环树」一节)。
名称容易记反,抓一条就好:判断依据是「环上的边朝哪边」。
- 每个点都往外(指向环)有一条出边 → 内向;
- 每个点都从环(离环)收到一条入边 → 外向。
题目给的数据往往是「每个点选一个后继」这种形式,那就是每点一条出边,属于内向。
有些题解会把 luogu P2607 叫做「基环外向树」,那是因为它们先反向建图再做树形 DP。两种叫法只差一个反图,但只要你自己统一口径就不会混。
「
2 核心套路:先找环,再把环当「根」
几乎所有基环树题都遵循同一个三步流程:
1. 找出环上的点(和环上的边)
2. 把每个环上点的挂树处理掉(通常是树上 DP / DFS)
3. 处理环本身
第 3 步是真正有变化的地方,常见的有三种:
| 手法 | 做法 | 适用 |
|---|---|---|
| 断环成链 | 在环上挑一条边断开,图就变成树了;但断掉的边可能被用到,所以要么枚举、要么对断点两端分类讨论 | 环上 DP、最大权独立集 |
| 破环成倍长链 | 把环复制一份接在后面,用前缀和 + 单调队列计算环上两点间的最远/最优 | 基环树直径 |
| 缩点 | 把整个环缩成一个点,得到一棵树,在树上做 | 求距离、计数类 |
其中「断环成链」最容易理解也最容易写错。以最大权独立集为例:断掉环上一条边
3 找环
「找环」是基环树题的入口。这里给两种思路,建议先读第一种——它不更短(两版行数接近),但明显更好懂,而且无向基环树和内向基环树用的是同一个想法。
一、 剥叶法(推荐先看)
核心只有一句话:反复删掉度数为 1 的点,剥到最后剩下的必然全是环上的点。
为什么?环上的每个点至少和环上两个邻居相连,度数永远降不到 1;而环外的点都是挂在环上的树,一棵树最外层的叶子度数为 1,删掉它之后上一层就可能变成新叶子,于是从外往里一层层剥干净。
代码就是一个队列加一个「沿环走一圈」:
#include <bits/stdc++.h>
using namespace std;
// 无向基环树找环:剥叶法。
// 核心只有一句话:反复删掉度数为 1 的点(叶子),
// 剥到最后剩下的点必然全都待在环上。
// 输出格式与 find_cycle.cpp(DFS 版)完全一致,方便逐行对照。
// 约定:n 个点 n 条边的连通无向图(可以有重边,但环长至少为 2)。
const int maxn = 200000 + 5;
int n, m;
/*------------------------ 存图 开始 ------------------------*/
struct Edge {
int to; // 边指向的点
int w; // 边权
int id; // 这条有向边的编号;成对存放时 id 与 id^1 互为反向边
};
vector<Edge> g[maxn];
int ecnt;
void add_edge(int u, int v, int w) {
g[u].push_back({v, w, ecnt++});
g[v].push_back({u, w, ecnt++});
}
/*------------------------ 存图 结束 ------------------------*/
/*------------------------ 剥叶法找环 开始 ------------------------*/
int deg[maxn]; // deg[u]:u 当前的度数,剥叶过程中会不断减小
bool on_cyc[maxn]; // on_cyc[u]:u 还没被剥掉,也就是最终留在环上
long long cyc_sum; // 环上边权之和
vector<int> cyc_v; // 环上的点,按环的顺序存放
vector<int> cyc_w; // cyc_w[i]:cyc_v[i] 到 cyc_v[i+1](末项回到 cyc_v[0])的边权
void find_cycle() {
// 第一步:剥叶。度数变成 1 的点就是叶子,删掉它会让邻居的度数再减一。
queue<int> q;
for (int u = 1; u <= n; ++u) {
on_cyc[u] = true;
if (deg[u] == 1) q.push(u);
}
while (!q.empty()) {
int u = q.front();
q.pop();
on_cyc[u] = false; // u 不是环上的点
for (const Edge &e : g[u]) {
int v = e.to;
if (!on_cyc[v]) continue; // 这个邻居已经被剥掉了,不用管
if (--deg[v] == 1) q.push(v); // v 变成了新的叶子
}
}
// 第二步:剩下的一定是环,沿环走一圈,记录顺序和每条环边的权值。
int start = 0;
for (int u = 1; u <= n; ++u) {
if (on_cyc[u]) {
start = u;
break;
}
}
cyc_sum = 0;
int pre_id = -1; // 刚走过的那条边的编号;用 pre_id^1 跳过它的反向边
int cur = start;
do {
cyc_v.push_back(cur);
for (const Edge &e : g[cur]) {
if (!on_cyc[e.to]) continue; // 只走环上的点
if (e.id == (pre_id ^ 1)) continue; // 不走刚来的那条边,重边也不会走回头
cyc_w.push_back(e.w);
cyc_sum += e.w;
pre_id = e.id;
cur = e.to;
break;
}
} while (cur != start);
}
/*------------------------ 剥叶法找环 结束 ------------------------*/
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; ++i) {
int u, v, w;
cin >> u >> v >> w;
add_edge(u, v, w);
++deg[u];
++deg[v];
}
find_cycle();
int cyc_n = (int)cyc_v.size();
cout << cyc_n << '\n';
for (int i = 0; i < cyc_n; ++i) {
cout << cyc_v[i] << ' ' << cyc_v[(i + 1) % cyc_n] << ' ' << cyc_w[i] << '\n';
}
cout << cyc_sum << '\n';
return 0;
}
对比一下它省掉了什么:没有递归、没有时间戳、没有父亲数组,也不需要理解「非树边一定连向祖先」这种 DFS 性质。整段就是「读度数 → 剥叶子 → 走一圈」。
剥叶法依赖一个前提:整张图恰好只有一个环。它就是基环树的定义,所以对本文所有题目都成立。
但如果图里有多个环(不连通的基环树森林,或一般图),剥叶会把这些环全部保留下来——那时它只能告诉你「哪些点在某个环上」,不能直接给出某一个环的顺序。遇到这种情况请用下面的 DFS 法。
二、 内向基环树:同样是剥叶
内向基环树(每个点出度恰好为 1)把「剥叶」换一个说法就成型了:环外的点最终都会顺着出边走进环,所以只要不断删掉入度为 0 的点,剩下的就是环。
思路和上面完全一样,只是「叶子」的判定从「度数为 1」换成了「入度为 0」。
#include <bits/stdc++.h>
using namespace std;
// 内向基环树(每个点恰好一条出边,即函数图):找环并求每个点的答案。
// 对应洛谷 P2921:从 i 出发沿 nxt 一直走,直到回到去过的点,问经过多少个点。
// 约定:n 个点 n 条边,nxt[u] 是 u 的唯一出边指向的点,允许自环。
const int maxn = 100000 + 5;
int n;
int nxt[maxn]; // nxt[u]:u 的唯一后继
/*------------------------ 内向基环树 代码 开始 ------------------------*/
int indeg[maxn]; // indeg[u]:u 的入度
bool in_cycle[maxn]; // in_cycle[u]:u 是否在环上
int cyc_len[maxn]; // cyc_len[u]:u 所在环的长度(只对环上的点有意义)
int ans[maxn]; // ans[u]:从 u 出发回到已访问点时经过的点的个数
vector<int> order; // 拓扑剥叶的出队顺序,环外的点都在里面
// 第一步:拓扑排序剥掉所有不在环上的点。
// 出度为 1 的图里,“入度能降到 0 的点”就是挂在环外面的点;
// 剥完之后只剩下环,剥不掉。
void peel() {
queue<int> q;
for (int u = 1; u <= n; ++u) {
in_cycle[u] = true;
if (indeg[u] == 0) q.push(u);
}
while (!q.empty()) {
int u = q.front();
q.pop();
in_cycle[u] = false; // 能出队就说明不在环上
order.push_back(u);
int v = nxt[u];
if (--indeg[v] == 0) q.push(v);
}
}
// 第二步:给环上的每个点标上它所在环的长度。
// 从任意一个还没标记的环上点出发绕一圈,走回自己就是整圈。
void mark_cycle_len() {
for (int u = 1; u <= n; ++u) {
if (!in_cycle[u] || cyc_len[u]) continue;
int len = 0;
int x = u;
do {
cyc_len[x] = 0; // 先占位,避免重复进入
x = nxt[x];
++len;
} while (x != u);
x = u;
do {
cyc_len[x] = len;
x = nxt[x];
} while (x != u);
}
}
// 第三步:环外的点逆着拓扑序递推。
// 因为它下一步一定走向 nxt[u],而 nxt[u] 的答案此时已经算好。
void calc_answer() {
for (int u = 1; u <= n; ++u) {
ans[u] = in_cycle[u] ? cyc_len[u] : 0;
}
for (int i = (int)order.size() - 1; i >= 0; --i) {
int u = order[i];
ans[u] = ans[nxt[u]] + 1; // 走一步到 nxt[u],再加上它之后的步数
}
}
/*------------------------ 内向基环树 代码 结束 ------------------------*/
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int u = 1; u <= n; ++u) {
cin >> nxt[u];
++indeg[nxt[u]];
}
peel();
mark_cycle_len();
calc_answer();
for (int u = 1; u <= n; ++u) {
cout << ans[u] << '\n';
}
return 0;
}
这段代码顺便解决了一类题:求出每个点「沿出边一直走,到回到已访问点」的步数。环上的点答案是环长,环外的点递推 ans[u] = ans[nxt[u]] + 1,按拓扑序逆序处理即可。
三、 DFS + 时间戳(通用备选)
如果不想依赖「恰好一个环」这个前提,或者你的图是有向的一般图,就用标准的 DFS 找环。
在无向图上 DFS,所有非树边都连向祖先(没有横叉边)。所以一旦沿着某条边走到一个「时间戳更小的已访问点」,就发现了一个环:它是「当前点到该祖先」的树上路径,再加上这条回边。
#include <bits/stdc++.h>
using namespace std;
// 无向基环树找环:找出一棵树加一条边后形成的那个唯一环。
// 输出环上的点(按环的顺序)、每条环边以及环长。
// 约定:n 个点 n 条边的连通无向图(可以有重边,但环长至少为 2)。
const int maxn = 200000 + 5;
int n, m;
/*------------------------ 存图 开始 ------------------------*/
struct Edge {
int to; // 边指向的点
int w; // 边权
int id; // 这条有向边的编号;成对存放时 id 与 id^1 互为反向边
};
vector<Edge> g[maxn];
int ecnt; // 已存的边数(无向边记两条)
void add_edge(int u, int v, int w) {
g[u].push_back({v, w, ecnt++});
g[v].push_back({u, w, ecnt++});
}
/*------------------------ 存图 结束 ------------------------*/
/*------------------------ 找环 代码 开始 ------------------------*/
int dfn[maxn], timer; // dfn[u]:DFS 时间戳,0 表示未访问
int fa[maxn]; // fa[u]:DFS 树上的父亲
int faw[maxn]; // faw[u]:边 (fa[u], u) 的权值
int cyc_n; // 环上的点数
vector<int> cyc_v; // 环上的点,按环的顺序存放
vector<int> cyc_w; // cyc_w[i]:cyc_v[i] 到 cyc_v[i+1](末项回到 cyc_v[0])的边权
long long cyc_sum; // 环上边权之和
// 找到回边 u -> v(v 是 u 的祖先)后,沿父亲指针把整条环拼出来。
void build_cycle(int u, int v, int back_w) {
vector<int> tmp;
// 从 u 一路回溯到 v,得到 [u, ..., v]
for (int x = u; x != v; x = fa[x]) tmp.push_back(x);
tmp.push_back(v);
cyc_n = (int)tmp.size();
cyc_v.resize(cyc_n);
cyc_w.resize(cyc_n);
// 反转成 [v, ..., u],这样相邻两点之间就是一条环边
for (int i = 0; i < cyc_n; ++i) cyc_v[i] = tmp[cyc_n - 1 - i];
cyc_sum = 0;
for (int i = 0; i + 1 < cyc_n; ++i) {
cyc_w[i] = faw[cyc_v[i + 1]]; // cyc_v[i] -> cyc_v[i+1] 就是儿子那条父亲边
cyc_sum += cyc_w[i];
}
cyc_w[cyc_n - 1] = back_w; // 最后一条是那条回边 u -> v
cyc_sum += back_w;
}
// 返回 true 表示已经找到环,可以提前停止。
// in_id 是进入 u 的那条有向边的编号,用它跳过反向边 id^1,重边也不会误判。
bool dfs(int u, int in_id) {
dfn[u] = ++timer;
for (const Edge &e : g[u]) {
if (e.id == (in_id ^ 1)) continue; // 不走刚来的那条边的反向
int v = e.to;
if (!dfn[v]) {
fa[v] = u;
faw[v] = e.w;
if (dfs(v, e.id)) return true;
}
else if (dfn[v] < dfn[u]) {
// 无向图 DFS 中,非树边一定连向祖先。
// 只从时间戳大的一侧处理一次,避免同一个环被算两遍。
build_cycle(u, v, e.w);
return true;
}
}
return false;
}
/*------------------------ 找环 代码 结束 ------------------------*/
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; ++i) {
int u, v, w;
cin >> u >> v >> w;
add_edge(u, v, w);
}
dfs(1, -1); // -1 ^ 1 == -2,不会等于任何合法边编号
cout << cyc_n << '\n';
for (int i = 0; i < cyc_n; ++i) {
cout << cyc_v[i] << ' ' << cyc_v[(i + 1) % cyc_n] << ' ' << cyc_w[i] << '\n';
}
cout << cyc_sum << '\n';
return 0;
}
几个关键点:
- 用
in_id记录「进来的那条有向边」的编号,跳过它的反向边(id ^ 1)。这样即使有重边也不会立刻走回去。 - 只从时间戳大的一侧处理(
dfn[v] < dfn[u]),否则同一个环会被从两端各算一次。 - 沿父亲指针回溯时,环的边权就是「儿子那条父亲边」的权值;最后补上回边的权值。
3.4 两版怎么选
| 剥叶法 | DFS + 时间戳 | |
|---|---|---|
| 代码量 | 相近(都要走一圈拿环序) | 相近 |
| 概念难度 | 低:队列 + 度数 | 中:非树边连祖先、时间戳方向、回溯拼接 |
| 前提 | 整图恰好一个环 | 无,一般图也能用 |
| 适用 | 基环树的全部题目 | 需要找「任意一个环」时 |
两版输出格式完全一致(环上点序 + 每条环边权 + 环长),可以互相替换着跑。
4 经典例题
一、 内向基环树 + 最大权独立集
每个骑士有且仅有一个最厌恶的人,且两人不能同时入选。把「厌恶」看成一条从
弱化版是树上最大权独立集,也就是「没有上司的舞会」:设
放到基环树上,就按前面说的断环:在环上挑一条边 f[a][0] 与 f[b][0],两者取 max。
为什么这样就够了?设最终方案为 f[a][0] 覆盖;若 f[b][0] 覆盖。两次 DP 都在「断开后的树」上做,只比原图少一条约束,所以结果一定合法。
注意本题是基环树森林,每个连通块算完再求和。完整的可运行代码见下面「五、城市环路」——那题就是这套做法的标准版本。
二、 内向基环树练手
functional_cycle.cpp 的原题,数据里允许自环,本文模板已经覆盖。
三、 基环树直径
直径只有两种可能:
- 不经过环:完全在某棵挂树内部。用普通树形 DP 求最长链即可。
- 经过环:设环上点
的挂树中最长链为 ,环上两点 的距离为 ,则候选值是
第 2 类需要破环成链:把环复制一遍接在尾部,用前缀和
条件
四、 枚举断边 + 贪心
luogu P5022: [NOIP 2018 提高组] 旅行
五、 城市环路
这是「断环 + 树形 DP」的标准版本,第一题(luogu P2607)就是同一套代码换个输入。
本题输入是「点权 + 无向边」的单圈图,输出要把最大权值和乘上常数
#include <bits/stdc++.h>
using namespace std;
// 基环树最大权独立集:断环 + 树形 DP。对应洛谷 P1453 城市环路。
//
// 思路:环上任意一条边 (a,b) 断开后,图就成了一棵树,可以直接做「没有上司的舞会」。
// 但断掉的那条边原本禁止 a、b 同时被选,所以分两次 DP 把这个约束补回来:
// 第一次以 a 为根,取 a 不选的答案 f[a][0] (a 不选,则 (a,b) 自然满足)
// 第二次以 b 为根,取 b 不选的答案 f[b][0] (b 不选,同上)
// 任何一个合法方案必然有「a 不选」或「b 不选」,所以两者取 max 就是答案。
//
// 约定:n 个点 n 条边的连通无向图(单圈图),点编号 0..n-1。
const int maxn = 100000 + 5;
int n;
long long p[maxn]; // p[u]:u 的点权(人流量)
/*------------------------ 存图 开始 ------------------------*/
struct Edge {
int to; // 边指向的点
int id; // 这条有向边的编号;成对存放时 id 与 id^1 互为反向边
};
vector<Edge> g[maxn];
int ecnt;
void add_edge(int u, int v) {
g[u].push_back({v, ecnt++});
g[v].push_back({u, ecnt++});
}
/*------------------------ 存图 结束 ------------------------*/
/*------------------------ 断环 + 树形DP 开始 ------------------------*/
int deg[maxn]; // deg[u]:u 当前的度数,剥叶过程中会减小
bool on_cyc[maxn]; // on_cyc[u]:u 还没被剥掉,也就是最终留在环上
int cut_id; // 被断开的环边编号
int cut_a; // 被断边的两个端点
int cut_b;
// 第一步:剥叶找环,然后随便挑一条「两端都在环上」的边作为要断的边。
// 注意必须记录边而不是只记录两个端点:二元环(两点之间两条边)时,
// 只按端点删会把两条边一起删掉。
void find_cut_edge() {
queue<int> q;
for (int u = 1; u <= n; ++u) {
on_cyc[u] = true;
if (deg[u] == 1) q.push(u);
}
while (!q.empty()) {
int u = q.front();
q.pop();
on_cyc[u] = false;
for (const Edge &e : g[u]) {
if (!on_cyc[e.to]) continue;
if (--deg[e.to] == 1) q.push(e.to);
}
}
cut_id = -1;
for (int u = 1; u <= n && cut_id < 0; ++u) {
if (!on_cyc[u]) continue;
for (const Edge &e : g[u]) {
if (!on_cyc[e.to]) continue;
cut_id = e.id;
cut_a = u;
cut_b = e.to;
break;
}
}
}
long long f[maxn][2]; // f[u][0]:u 不选;f[u][1]:u 选
// 第二步:在断边后的树上做最大权独立集。
// 跳过被断的那条边(正反两个编号都要跳)。
void dfs(int u, int fa) {
f[u][0] = 0;
f[u][1] = p[u];
for (const Edge &e : g[u]) {
int v = e.to;
if (v == fa) continue;
if (e.id == cut_id || e.id == (cut_id ^ 1)) continue; // 这条边已断
dfs(v, u);
f[u][0] += max(f[v][0], f[v][1]); // u 不选,儿子随意
f[u][1] += f[v][0]; // u 选,儿子只能不选
}
}
// 第三步:分别以两端为根各跑一遍,取「根不选」的较大值。
long long solve() {
find_cut_edge();
dfs(cut_a, 0);
long long ans_a = f[cut_a][0];
dfs(cut_b, 0);
long long ans_b = f[cut_b][0];
return max(ans_a, ans_b);
}
/*------------------------ 断环 + 树形DP 结束 ------------------------*/
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> p[i + 1]; // 题目编号 0..n-1,这里统一平移到 1..n
}
for (int i = 1; i <= n; ++i) {
int u, v;
cin >> u >> v;
add_edge(u + 1, v + 1);
++deg[u + 1];
++deg[v + 1];
}
double k;
cin >> k;
long long best = solve();
printf("%.1f\n", (double)best * k);
return 0;
}
两处值得注意的细节:
- 断边要记录「边」,不能只记录两个端点。 二元环(两点之间有两条边)时,按端点删会把两条边一起删掉,图就断了。模板里用
cut_id与cut_id ^ 1精确跳过一条边。 - 为什么取
f[root][0]而不是max(f[root][0], f[root][1])。 取f[root][1]等于允许根被选,而根恰好是被断边的端点,可能与另一端冲突。只取f[root][0]才保证这条约束一定成立。
5 易错点
- 把「
点 边」当成连通图。 它只能保证「环的个数 = 边数 − 点数 + 连通块数」,图不连通时请按基环树森林处理,对每个环分别算再汇总。 - 无向图找环时不判反向边(两版都适用)。 一定要跳过
in_id ^ 1,否则重边(两点之间有两条边)会被误判成「立刻走回去」而找不到环。 - 同一个环被算两遍(仅 DFS 版)。 回边要从时间戳大的一侧处理,只认
dfn[v] < dfn[u]。 - 环的顺序搞反(仅 DFS 版)。 沿父亲回溯得到的是「从回边起点到祖先」的顺序,通常需要反转才能让「相邻两点之间是一条环边」。环边权与环上点要一一对齐,写错会得到随机错误。
- 该用剥叶法时却写了 DFS。 基环树(含内向形态)用剥叶更省事,还顺带得到拓扑序,可以直接拿去递推答案;只有当图里可能有多个环、需要「任意找一个环」时才动用 DFS。
- 剥叶法用在了多环图上。 它会把所有环一起保留下来,此时只能回答「哪些点在环上」,拿不到某一个环的顺序。
- 自环没考虑。 内向基环树允许
,此时环长是 ;模板里绕一圈的写法能自然处理,但手写时要确认不会死循环。 - 外向/内向弄混。 出度为 1 是内向(箭头朝环),入度为 1 是外向(箭头离环)。两者互为反图,做题前先确认题目给的是哪一个。
- 断环后忘了补约束。 断掉的那条边仍然约束着两个端点,必须通过「强制某一端不选」跑两遍来补回来,只删不管会算大。
- 基环树直径只算了经过环的情况。 挂在环上的树自己也可能藏着直径,两种都要取
。 - 递归深度。 基环树常配
的数据,链形挂树会让递归 DFS 达到 深度,栈要留够或改迭代。
6 相关阅读
7 相关题解
暂无相关题解