题解:洛谷 P8435 点双连通分量
【题目来源】
【题目描述】
对于一个 \(n\) 个节点 \(m\) 条无向边的图,请输出其点双连通分量的个数,并且输出每个点双连通分量。
【输入】
第一行,两个整数 \(n\) 和 \(m\)。
接下来 \(m\) 行,每行两个整数 \(u, v\),表示一条无向边。
【输出】
第一行一个整数 \(x\) 表示点双连通分量的个数。
接下来的 \(x\) 行,每行第一个数 \(a\) 表示该分量结点个数,然后 \(a\) 个数,描述一个点双连通分量。
你可以以任意顺序输出点双连通分量与点双连通分量内的结点。
【输入样例】
5 8
1 3
2 4
4 3
1 2
4 5
5 1
2 4
1 1
【输出样例】
1
5 1 2 3 4 5
【核心思想】
-
问题分析:给定一个包含 \(n\) 个节点 \(m\) 条边的无向图,需要找出所有的点双连通分量。点双连通分量是指图中不存在割点的最大子图,即删除任意一个节点后子图仍然连通。这是一个点双连通分量分解问题,可以使用 Tarjan 算法求解。
-
算法选择:
- Tarjan 算法求点双连通分量:基于 DFS,通过 \(dfn\) 和 \(low\) 数组判断割点并分解点双连通分量
- \(dfn\) 数组:记录每个节点的 DFS 序(时间戳)
- \(low\) 数组:记录通过回边能到达的最小 \(dfn\) 值
- 割点判断条件:对于节点 \(x\) 的子节点 \(y\),若 \(low[y] \geq dfn[x]\),则 \(x\) 是割点
- 栈维护:使用栈记录当前 DFS 路径上的节点,遇到割点时弹出形成点双连通分量
-
关键步骤:
- 建图:读入 \(n\)(节点数)、\(m\)(边数),使用邻接表存储无向图(忽略自环)
- Tarjan DFS(从节点 \(x\) 出发,\(root\) 为当前 DFS 树的根):
- 初始化 \(dfn[x] = low[x] = ++tot\),节点入栈
- 特殊情况:若 \(x\) 是孤立节点(无邻接边),单独形成一个点双连通分量
- 遍历邻接点 \(y\):
- 树边(\(y\) 未访问):
- 递归访问 \(y\),回溯时 \(low[x] = \min(low[x], low[y])\)
- 割点判断:若 \(low[y] \geq dfn[x]\),说明 \(y\) 无法通过回边到达 \(x\) 的祖先,\(x\) 是割点
- 根节点特殊处理:若 \(x\) 是根且子树数 \(> 1\),则 \(x\) 是割点
- 创建点双连通分量:\(cnt++\),弹出栈中节点直到 \(y\),这些节点与 \(x\) 构成一个分量
- 回边(\(y\) 已访问):\(low[x] = \min(low[x], dfn[y])\)
- 树边(\(y\) 未访问):
- 处理非连通图:遍历所有节点,对未访问的节点执行 Tarjan
- 输出结果:输出点双连通分量数量及每个分量的节点
-
时间/空间复杂度:
- 时间复杂度:\(O(n + m)\),每个节点和边只被访问一次
- 空间复杂度:\(O(n + m)\),邻接表、\(dfn\)/\(low\) 数组、栈、分量数组
- 适用于点双连通性分析和图的分解问题
-
点双连通分量的核心思想:
- 点双连通分量定义:不存在割点的最大子图,删除任意一个节点后子图仍连通
- 割点的作用:割点是连接不同点双连通分量的关键节点,一个割点可能属于多个分量
- 与边双连通分量的区别:
- 点双连通分量关注节点,边双连通分量关注边
- 点双连通分量中任意两点间有两条点不相交路径
- 边双连通分量中任意两点间有两条边不相交路径
- 栈的使用技巧:DFS 过程中节点入栈,遇到割点时弹出形成分量,割点保留在栈中供后续使用
- 割点的多重归属:割点会被加入多个点双连通分量,非割点只属于一个分量
- 适用于图的分解、网络可靠性分析、关键节点识别等场景
【解题思路】


【算法标签】
普及+ #双连通分量
【代码详解】
#include <bits/stdc++.h>
using namespace std;
const int N = 500005; // 最大节点数
vector<int> e[N]; // 原图的邻接表
vector<int> ne[N]; // 缩点后新图的邻接表(未使用)
int dfn[N]; // DFS序(时间戳)
int low[N]; // 通过回边能到达的最小DFN值
int tot; // 时间戳计数器
stack<int> stk; // Tarjan算法栈
vector<int> dcc[N]; // 存储每个点双连通分量的节点
int cut[N]; // 标记节点是否为割点
int root; // 当前DFS树的根节点
int cnt; // 点双连通分量计数器
int num; // 新图节点计数器(用于缩点建图)
int id[N]; // 节点在新图中的编号(用于缩点)
int n, m, a, b; // n:节点数, m:边数, a,b:临时变量
/**
* Tarjan算法求点双连通分量
* @param x 当前节点
*/
void tarjan(int x)
{
// 初始化当前节点的DFN和LOW值
dfn[x] = low[x] = ++tot;
// 将当前节点压入栈
stk.push(x);
// 特殊情况:孤立节点(没有邻接边)
if (!e[x].size())
{
dcc[++cnt].push_back(x); // 创建一个新的点双连通分量
return;
}
int child = 0; // 记录根节点的子树数量(用于割点判断)
// 遍历当前节点的所有邻接节点
for (int y : e[x])
{
// 如果邻接节点y未被访问(树边)
if (!dfn[y])
{
tarjan(y); // 递归访问y
low[x] = min(low[x], low[y]); // 更新low值
// 判断割点条件:low[y] >= dfn[x]
if (low[y] >= dfn[x])
{
child++; // 增加子树计数
// 割点判断:如果不是根节点,或者根节点有多个子树
if (x != root || child > 1)
{
cut[x] = true; // 标记x为割点
}
// 创建一个新的点双连通分量
cnt++;
// 弹出栈中节点直到遇到y
while (true)
{
int z = stk.top();
stk.pop();
dcc[cnt].push_back(z); // 将节点加入当前分量
if (z == y)
{
break;
}
}
// 将当前节点x也加入该分量(割点属于多个分量)
dcc[cnt].push_back(x);
}
}
// 如果y已被访问(回边)
else
{
low[x] = min(low[x], dfn[y]); // 通过回边更新low值
}
}
}
int main()
{
// 输入节点数和边数
cin >> n >> m;
// 输入所有边
while (m--)
{
cin >> a >> b;
// 忽略自环边
if (a == b)
{
continue;
}
// 添加无向边
e[a].push_back(b);
e[b].push_back(a);
}
// 对每个未访问的节点执行Tarjan算法
for (root = 1; root <= n; root++)
{
if (!dfn[root])
{
tarjan(root);
}
}
// 为新图分配节点编号(割点单独编号)
num = cnt; // 初始编号从cnt+1开始
for (int i = 1; i <= n; i++)
{
if (cut[i])
{
id[i] = ++num; // 为割点分配新编号
}
}
// 输出点双连通分量信息
cout << cnt << endl; // 输出分量数量
for (int i = 1; i <= cnt; i++)
{
// 输出当前分量的节点数和节点列表
cout << dcc[i].size() << " ";
for (int j = 0; j < dcc[i].size(); j++)
{
cout << dcc[i][j] << " ";
}
cout << endl;
}
return 0;
}
#include <bits/stdc++.h>
using namespace std;
const int N = 500005, M = 2000005 * 2;
int n, m; // n: 节点数,m: 边数
int h[N], e[M], ne[M], idx; // 邻接表存储图
int dfn[N], low[N], tot; // dfn: DFS序,low: 追溯值,tot: 时间戳
stack<int> stk; // 栈,用于Tarjan算法
int con_cnt, con_size[N], con_id[N]; // con_cnt: 点双连通分量数量,con_size: 分量大小,con_id: 节点所属分量编号
bool cut[N]; // 记录节点是否为割点
vector<int> ans[N]; // 存储每个点双连通分量的节点
// 添加无向边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// Tarjan算法求点双连通分量
void tarjan(int u, int fa)
{
int child = 0; // 子树计数
dfn[u] = low[u] = ++tot; // 初始化dfn和low为时间戳
stk.push(u); // 节点入栈
for (int i = h[u]; i != -1; i = ne[i]) // 遍历u的所有邻接点
{
int v = e[i]; // 邻接点v
if (!dfn[v]) // 如果v未被访问
{
child++;
tarjan(v, u); // 递归访问v
low[u] = min(low[u], low[v]); // 更新low[u]
if (low[v] >= dfn[u]) // 判断u是否为割点
{
cut[u] = true; // 标记u为割点
con_cnt++; // 增加点双连通分量计数
while (1) // 弹出栈中节点,直到遇到v
{
int y = stk.top(); // 取出栈顶
stk.pop(); // 弹出栈顶
con_id[y] = con_cnt; // 记录节点y所属的分量编号
con_size[con_cnt]++; // 更新分量大小
ans[con_cnt].push_back(y); // 将节点加入分量
if (y == v) // 直到弹出v为止
break;
}
ans[con_cnt].push_back(u); // 将割点u加入分量
}
}
else if (v != fa) // 如果是回退边且不是父节点
{
low[u] = min(low[u], dfn[v]); // 更新low[u]
}
}
if (fa == 0 && child == 0) // 如果u是孤立节点
{
con_cnt++; // 增加点双连通分量计数
con_id[u] = con_cnt; // 记录节点u所属的分量编号
con_size[con_cnt]++; // 更新分量大小
ans[con_cnt].push_back(u); // 将节点加入分量
stk.pop(); // 弹出栈顶
}
}
int main()
{
cin >> n >> m; // 输入节点数和边数
memset(h, -1, sizeof(h)); // 初始化邻接表
while (m--) // 读入所有无向边
{
int a, b;
cin >> a >> b;
add(a, b), add(b, a); // 添加无向边
}
for (int i = 1; i <= n; i++) // 遍历所有节点
if (dfn[i] == 0) // 如果节点未访问
tarjan(i, 0); // 执行Tarjan算法
cout << con_cnt << endl; // 输出点双连通分量数量
for (int i = 1; i <= con_cnt; i++) // 遍历所有点双连通分量
{
cout << ans[i].size() << " "; // 输出分量大小
for (int j = 0; j < ans[i].size(); j++) // 输出分量内的所有节点
cout << ans[i][j] << " ";
cout << endl;
}
return 0;
}
【运行结果】
5 8
1 3
2 4
4 3
1 2
4 5
5 1
2 4
1 1
1
5 5 2 4 3 1
浙公网安备 33010602011771号