[POJ 3694] Network
算法
暴力
容易想到的做法是, 对于每次加边, 跑一次 \(\rm{Tarjan}\) 求割点, 但是每次 \(\rm{Tarjan}\) 的复杂度是 \(\mathcal{O} (n + m)\) , 总的时间复杂度可以卡到 \(\mathcal{O} (Qn + Qm)\) , 显然不能用
正解
暴力的慢是显然的, 主要原因是每次加边对于原图的改变并不大, 完全不应该重跑一遍 \(\rm{Tarjan}\)
考虑优化
显然, 原图将边双联通分量缩点之后将变成一颗树
我们考虑在树上处理每一次询问
问题转化成,
对于一颗树, 有 \(Q\) 次加边, 每次加边之后, 输出缩点之后树上边的个数
显然的, 加边之后产生的环上割边都会被消除, 问题转化成如何计算这个环上有多少点
观察到环的形式大概是 \(u \to \rm{LCA}(u, v) \to v \to u\)
考虑求 \(\rm{LCA}\) , 顺便将路过的点标记上
具体的, 将每个点属于的 \(\rm{SCC}\) 用并查集更新, 并标记割边不存在
代码
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
const int MAXN = 3e5 + 20;
const int MAXM = 3e5 + 20;
int N, M, Q;
int CutNum; // 桥梁个数
class Graph_Class
{
private:
public:
struct node
{
int to, w;
int next;
} Edge[MAXM << 2];
int Edge_Cnt;
int head[MAXN << 1];
void head_init() { for (int i = 0; i <= N + 1; i++) head[i] = -1; }
void init() {
head_init();
Edge_Cnt = 0;
memset(Edge, 0, sizeof(Edge));
}
inline void addedge(int u, int v) {
Edge[++Edge_Cnt].to = v, Edge[Edge_Cnt].w = 0;
Edge[Edge_Cnt].next = head[u];
head[u] = Edge_Cnt;
return;
}
int fa[MAXN << 1], dep[MAXN << 1];
} Graph1, Graph2;
class Sol_Class
{
private:
int low[MAXN << 1], num;
std::basic_string<int> SCC[MAXN << 1];
void init() {
memset(low, 0, sizeof(low));
num = 0;
for(int i = 1; i <= N; i++) SCC[i].clear();
return;
}
/*找到对应的 SCC*/
void tarjan(int Now, int fa)
{
low[Now] = ++num;
for(int i = Graph1.head[Now]; ~i; i = Graph1.Edge[i].next) {
int NowTo = Graph1.Edge[i].to;
if (NowTo == fa) continue;
if (!low[NowTo]) tarjan(NowTo, Now);
low[Now] = std::min(low[Now], low[NowTo]);
}
SCC[low[Now]] += Now;
}
struct DSU_Struct
{
int Bel[MAXN << 1];
void init() { for (int i = 1; i <= N; i++) Bel[i] = i; }
int find(int x) { return Bel[x] = ((x == Bel[x]) ? x : find(Bel[x])); }
inline void merge(int u, int v) {
int fa_u = find(u), fa_v = find(v);
if (fa_u == fa_v) return ;
Bel[fa_v] = fa_u;
}
} ;
/*计算 Graph2 树的父亲节点及深度*/
void dfs1(int Now, int fa, int depth) {
Graph2.dep[Now] = depth;
Graph2.fa[Now] = fa;
for(int i = Graph2.head[Now]; ~i; i = Graph2.Edge[i].next) {
int NowTo = Graph2.Edge[i].to;
if(NowTo == fa) continue;
dfs1(NowTo, Now, depth + 1);
}
}
/*在之前记录的树的信息的帮助下, 找到最近公共祖先, 这里只需要使用朴素算法即可*/
int FindLCA(int u, int v)
{
while (Graph2.dep[u] != Graph2.dep[v])
{
if (Graph2.dep[u] > Graph2.dep[v]) {
u = Graph2.fa[u];
}else if (Graph2.dep[u] < Graph2.dep[v]) {
v = Graph2.fa[v];
}else {
u = Graph2.fa[u];
v = Graph2.fa[v];
}
u = DSU.find(u), v = DSU.find(v);
}
return u;
}
public:
DSU_Struct DSU;
/*计算 SCC 个数 & 建立新图*/
void BuildSCC()
{
CutNum = 0;
init();
tarjan(1, -1);
DSU.init();
for (int i = 1; i <= N; i++) {
if (SCC[i].size()) {
for (int j = 1; j < SCC[i].size(); j++) {
DSU.merge(SCC[i][j - 1], SCC[i][j]);
}
CutNum++;
}
}
CutNum--;
Graph2.init();
for (int i = 1; i <= N; i++)
for (int j = Graph1.head[i]; ~j; j = Graph1.Edge[j].next) {
int NowTo = Graph1.Edge[j].to;
int fa_i = DSU.find(i), fa_j = DSU.find(NowTo);
if (fa_i == fa_j) continue;
Graph2.addedge(fa_i, fa_j);
Graph2.addedge(fa_j, fa_i);
}
dfs1(DSU.find(1), -1, 1); // 以 1 在 Graph2 中的对应点为根节点记录数据
}
/*计算 u, v 连边之后桥梁的个数*/
void solve(int u, int v)
{
int LCA = FindLCA(u, v);
while(u != LCA) {
DSU.merge(LCA, u);
u = DSU.find(Graph2.fa[u]);
CutNum--;
}
while(v != LCA) {
DSU.merge(LCA, v);
v = DSU.find(Graph2.fa[v]);
CutNum--;
}
}
} Sol;
int main()
{
int Cnt = 0;
while(++Cnt)
{
scanf("%d %d", &N, &M);
if (N == 0 && M == 0) break;
printf("Case %d:\n", Cnt);
Graph1.init();
for (int i = 1; i <= M; i++) {
int u, v;
scanf("%d %d", &u, &v);
Graph1.addedge(u, v), Graph1.addedge(v, u);
}
Sol.BuildSCC();
scanf("%d", &Q);
while (Q--) {
int u, v;
scanf("%d %d", &u, &v);
int fa_u = Sol.DSU.find(u), fa_v = Sol.DSU.find(v);
if (fa_u == fa_v) printf("%d\n", CutNum);
else {
Sol.solve(fa_u, fa_v);
printf("%d\n", CutNum);
}
}
printf("\n");
}
return 0;
}
代码实现细节较多, 这里写下我代码时发现的一些问题:
- \(\rm{tarjan}\) 跑 \(\rm{SCC}\) 注意不用 \(dfn\) 数组
- 关于 \(\rm{SCC}\) 缩点 : 缩完之后先建立新图, 后边的更改每次只会将一个环变成一个 \(\rm{SCC}\) , 不需要建立新图
- 关于如何将一个环缩成一个点 : 先找 \(\rm{LCA}\) , 在向上直到 \(\rm{LCA}\) 的过程中 , 将路过的点属于的 \(SCC\) 全部标记到 \(\rm{LCA}\) 属于的 \(\rm{SCC}\) 上
- 关于 \(dep\) 数组 : 在后面, \(dep\) 数组可能不连续, 但是 \(\rm{LCA}\) 只需要一个大小的关系即可, 这也导致了这份代码中求 \(\rm{LCA}\) 的代码片段略有不同
润回来复习
建立 \(\rm{SCC}\) 树
这里我们直接按照意义真正建立一颗树
在 \(\rm{SCC}\) 树上进行处理
你发现每次重跑 \(\rm{tarjan}\) 很糖, 所以考虑就在原来的树上处理新的 \(\rm{SCC}\)
你发现加上边 \((u, v)\) 之后, 会多一个 \(u \to \textrm{LCA} (u, v) \to v \to u\) 的环, 考虑怎么处理现在的 \(\rm{SCC}\)
容易发现的是, 最终环上的点都会进入一个 \(\rm{SCC}\) , 但是我们不可能再想最早一样, 真正的建立一颗树, 只能在现在的树上打标记以建立新树
具体怎么做, 我们考虑使用并查集处理每一个 \(\rm{SCC}\) , 钦定一个实点表示这个 \(\rm{SCC}\) 的信息, 其他都是虚点, 并查集就维护这个 \(\rm{SCC}\) 对应的实点
实现方法 \(1\)
在 \(u, v\) 往上跳到 \(\rm{LCA}\) 的时候一定可以遍历完环上的点, 这个过程中用并查集标记点, 然后每次处理遇到一个点就并查集找他的实点处理
任意两点在 \(\rm{LCA}\) 处合并, 在这题中可以保持信息的有效性
实现方法 \(2\)
每次跳 \(\rm{LCA}\) 的时候维护一个点, 然后把虚点插进去, 这样子更有一般性, 时间复杂度要差些
也要用并查集标记每个 \(\rm{SCC}\) 对应的实点, 注意实点还有可能变成另一个点的虚点
总结
暴力 \(\to\) 优化
善于分析问题本质
这样一类缩点题, 通常只需要建立一次新图, 后面只需在图上做处理即可
更一般的, 在新图上做处理就只能打标记而不能再建立新图了
打标记一般就用并查集, 注意随时找实点即可
树有重边一定要用边判断不要用 \(fa\) , 警钟长鸣

浙公网安备 33010602011771号