Tarjan 回溯时若树边 u-v 满足 low[v] >= dfn[u],说明 v 子树必须经过 u 才能连到外部,此时把点栈弹到 v 再加上 u,就得到一个点双连通分量。
OJ: luogu
题目 ID: P8435
难度:提高+/省选-
标签:图论tarjan双连通分量割点
日期: 2026-06-20 02:01
题意
给一张允许有重边、自环,而且可能不连通的无向图。
要求输出:
- 点双连通分量的个数
- 每个点双连通分量里有哪些点
这题采用的定义是:
- 一个极大的“没有割点”的连通子图,就是一个点双连通分量
所以这题和 P3388 的关系非常直接:
- 割点会把图切成多个点双
- 同一个割点可能同时属于多个点双
样例图
下面用样例三说明“割点把点双切开”:
graph G {
1 -- 2;
1 -- 3;
2 -- 3;
2 -- 4;
4 -- 6;
5;
}
这张图里:
2是割点,因为删掉它以后,4、6那一支会和左边断开4也是割点,因为删掉它以后,点6会单独断开
所以图被切成四个点双:
{1,2,3}{2,4}{4,6}{5}
思路
先看一个更直观的小数据教学版:
cpp
// brute.cpp:更直观的递归 Tarjan 教学版。
// 它和正式做法的判定逻辑一样,但递归深度只适合小数据,
// 主要用来帮助理解点栈出栈过程,并辅助对拍。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 20;
const int MAXM = 100;
int n, m;
int head[MAXN], to[MAXM], nxt[MAXM], edge_cnt;
int dfn[MAXN], low[MAXN], dfs_clock;
int parent_edge[MAXN];
bool is_cut[MAXN];
vector<int> node_stack;
vector<int> component_nodes;
vector< vector<int> > answer;
vector< vector<int> > deferred_answer;
void init_graph() {
edge_cnt = 0;
dfs_clock = 0;
answer.clear();
deferred_answer.clear();
node_stack.clear();
for (int i = 1; i <= n; i++) {
head[i] = -1;
dfn[i] = 0;
low[i] = 0;
parent_edge[i] = -1;
is_cut[i] = false;
}
}
void add_edge(int u, int v) {
to[edge_cnt] = v;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
edge_cnt++;
}
void dfs(int u, int in_edge, int root) {
dfn[u] = low[u] = ++dfs_clock;
node_stack.push_back(u);
component_nodes.push_back(u);
int child_cnt = 0;
for (int i = head[u]; i != -1; i = nxt[i]) {
int v = to[i];
if (i == (in_edge ^ 1)) {
continue;
}
if (!dfn[v]) {
child_cnt++;
parent_edge[v] = i;
dfs(v, i, root);
low[u] = min(low[u], low[v]);
if (low[v] >= dfn[u]) {
if (u != root) {
is_cut[u] = true;
}
vector<int> bcc;
while (true) {
int x = node_stack.back();
node_stack.pop_back();
bcc.push_back(x);
if (x == v) {
break;
}
}
bcc.push_back(u);
answer.push_back(bcc);
}
}
else if (dfn[v] < dfn[u]) {
low[u] = min(low[u], dfn[v]);
}
}
if (u == root) {
if (child_cnt == 0) {
answer.push_back(vector<int>(1, u));
}
else if (child_cnt > 1) {
is_cut[u] = true;
}
}
}
void solve_component(int root) {
int answer_before = answer.size();
component_nodes.clear();
dfs(root, -1, root);
if (!node_stack.empty() && node_stack.back() == root) {
node_stack.pop_back();
}
bool has_cut = false;
for (size_t i = 0; i < component_nodes.size(); i++) {
if (is_cut[component_nodes[i]]) {
has_cut = true;
break;
}
}
int new_bcc_cnt = (int)answer.size() - answer_before;
if (new_bcc_cnt == 1 && !has_cut) {
sort(answer[answer_before].begin(), answer[answer_before].end());
if ((int)answer[answer_before].size() > 1) {
deferred_answer.push_back(answer[answer_before]);
answer.pop_back();
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
init_graph();
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
add_edge(u, v);
add_edge(v, u);
}
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
solve_component(i);
}
}
for (size_t i = 0; i < deferred_answer.size(); i++) {
answer.push_back(deferred_answer[i]);
}
cout << answer.size() << '\n';
for (size_t i = 0; i < answer.size(); i++) {
cout << answer[i].size();
for (size_t j = 0; j < answer[i].size(); j++) {
cout << ' ' << answer[i][j];
}
cout << '\n';
}
return 0;
}brute.cpp 还是 Tarjan 的判定逻辑,但它用递归来写,代码更容易看出“什么时候出栈形成一个点双”。
它适合拿来理解和对拍;正式代码之所以不能直接照搬,是因为这题数据到 5e5 个点,递归深度可能直接爆栈。
核心观察和你书里的点双模板一致。
设 u 在 DFS 树里有一个儿子 v。如果:
low[v] >= dfn[u]
说明 v 子树无法绕过 u 回到 u 的祖先。
于是 u 就成了这部分图和外界之间的分界点。
这时可以确定一整个点双:
- 把点栈从栈顶一直弹到
v - 再把
u加进来 - 这些点共同组成一个点双连通分量
这里和强连通分量、边双有两个关键区别:
- 点双是“在处理儿子
v回溯时”形成的,不是等u整体回溯完才形成 u可能属于多个点双,所以u不能像 SCC 那样在形成一个分量后就永久出栈
实现上还要补两个细节:
- 图里可能有重边,不能只写
v != fa,必须记录进入当前点的是哪条边,只跳过它的反向边 - 正式代码把递归 DFS 改成了显式栈模拟 DFS,这样大数据下也不会爆栈
另外,孤立点和只有自环的单点也要单独算一个点双,这正是题面特别提醒的坑点。
代码
cpp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500000 + 5;
const int MAXM = 4000000 + 5;
struct Frame {
int u; // 当前点
int in_edge; // 进入当前点的边编号
int iter_edge; // 当前枚举到哪条边
int child_cnt; // DFS 树儿子个数
};
int n, m;
// 链式前向星,边编号从 0 开始,方便用 i ^ 1 找反向边。
int head[MAXN], to[MAXM], nxt[MAXM], edge_cnt;
// Tarjan 时间戳。
int dfn[MAXN], low[MAXN], dfs_clock;
// DFS 树父子关系。
int parent_node[MAXN], parent_edge[MAXN];
// 标记割点。
bool is_cut[MAXN];
// 点双使用“点栈”维护当前尚未归属的点。
vector<int> node_stack;
// 当前连通块里访问到的点,用来判断这个连通块里是否存在割点。
vector<int> component_nodes;
// 最终答案。
vector< vector<int> > answer;
vector< vector<int> > deferred_answer;
void init_graph() {
edge_cnt = 0;
dfs_clock = 0;
answer.clear();
deferred_answer.clear();
node_stack.clear();
for (int i = 1; i <= n; i++) {
head[i] = -1;
dfn[i] = 0;
low[i] = 0;
is_cut[i] = false;
}
}
void add_edge(int u, int v) {
to[edge_cnt] = v;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
edge_cnt++;
}
// 处理一个连通块。
// 为了避免 5e5 深度时递归爆栈,这里改成显式栈模拟 DFS。
void solve_component(int root) {
int answer_before = answer.size();
component_nodes.clear();
vector<Frame> call_stack;
call_stack.push_back({root, -1, head[root], 0});
parent_node[root] = 0;
parent_edge[root] = -1;
dfn[root] = low[root] = ++dfs_clock;
node_stack.push_back(root);
component_nodes.push_back(root);
while (!call_stack.empty()) {
Frame &cur = call_stack.back();
int u = cur.u;
if (cur.iter_edge != -1) {
int e = cur.iter_edge;
cur.iter_edge = nxt[e];
int v = to[e];
// 只跳过进入当前点那条边的反向边,重边要保留。
if (e == (cur.in_edge ^ 1)) {
continue;
}
if (!dfn[v]) {
cur.child_cnt++;
parent_node[v] = u;
parent_edge[v] = e;
call_stack.push_back({v, e, head[v], 0});
dfn[v] = low[v] = ++dfs_clock;
node_stack.push_back(v);
component_nodes.push_back(v);
continue;
}
// 返祖边只能用祖先的 dfn 更新 low。
if (dfn[v] < dfn[u]) {
low[u] = min(low[u], dfn[v]);
}
continue;
}
call_stack.pop_back();
if (cur.in_edge == -1) {
// 根节点如果没有 DFS 儿子,说明它是孤立点,
// 或者只有自环,总之单独构成一个点双。
if (cur.child_cnt == 0) {
answer.push_back(vector<int>(1, u));
}
else if (cur.child_cnt > 1) {
is_cut[u] = true;
}
}
else {
int p = parent_node[u];
low[p] = min(low[p], low[u]);
if (low[u] >= dfn[p]) {
// 非根节点:存在儿子回不到祖先,则它是割点。
if (parent_edge[p] != -1) {
is_cut[p] = true;
}
vector<int> bcc;
while (true) {
int x = node_stack.back();
node_stack.pop_back();
bcc.push_back(x);
if (x == u) {
break;
}
}
bcc.push_back(p);
answer.push_back(bcc);
}
}
}
// 这个连通块处理结束后,根节点会残留在点栈里,弹掉即可。
if (!node_stack.empty() && node_stack.back() == root) {
node_stack.pop_back();
}
bool has_cut = false;
for (size_t i = 0; i < component_nodes.size(); i++) {
if (is_cut[component_nodes[i]]) {
has_cut = true;
break;
}
}
int new_bcc_cnt = (int)answer.size() - answer_before;
// 如果这个连通块本身就没有割点,那么它只会形成一个点双。
// 做一次升序整理,并把这种“整块就是一个点双”的答案延后输出,
// 这样可以和题面样例保持同样的展示顺序。
if (new_bcc_cnt == 1 && !has_cut) {
sort(answer[answer_before].begin(), answer[answer_before].end());
if ((int)answer[answer_before].size() > 1) {
deferred_answer.push_back(answer[answer_before]);
answer.pop_back();
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
init_graph();
node_stack.reserve(n);
component_nodes.reserve(n);
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
add_edge(u, v);
add_edge(v, u);
}
for (int i = 1; i <= n; i++) {
if (!dfn[i]) {
solve_component(i);
}
}
for (size_t i = 0; i < deferred_answer.size(); i++) {
answer.push_back(deferred_answer[i]);
}
cout << answer.size() << '\n';
for (size_t i = 0; i < answer.size(); i++) {
cout << answer[i].size();
for (size_t j = 0; j < answer[i].size(); j++) {
cout << ' ' << answer[i][j];
}
cout << '\n';
}
return 0;
}复杂度
每个点进出 DFS 一次,每条边只会被常数次访问,所以:
- 时间复杂度
- 空间复杂度
总结
这题的主线可以直接记成一句话:
- 割点把点双切开,
low[v] >= dfn[u]时,u和v子树的一段点会形成一个新的点双
真正实现时要特别留意三件事:
- 割点可以属于多个点双,所以
u不能被永久弹栈 - 重边要用边编号过滤父边
- 大数据下递归会爆栈,正式代码最好改成非递归
一图流解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

