基本变量
dfn[u]:搜索时间戳(DFS 序)
low[u]:能回溯到的最早时间戳
low[u] 的更新:
- 初始化:
low[u] = dfn[u]
- 树边
u → v(u = fa(v)):回溯时更新,low[v] 已知
low[u] = min(low[u], low[v])
- 非树边
(u, v) ∉ dfs_tree:
low[u] = min(low[u], dfn[v])
以下内容注重算法和代码,基本概念定义请自行搜索了解。
割点
- 判定法则:
dfn[u] <= low[v],其中 v 为搜索树上 u 的子节点。特别地,u = root 时,需子节点 v1, v2 均满足。
- 证明:考虑包含
(u, v) 的环结构。若 u 为割点(环顶),则仅当 (x, v) 一定包含 (u, v),即外点 x 到达 v 必须晚于到 u,u 为必经关卡。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e5+5; vector<int> g[N];
bool cv[N]; int n,m,dfn[N],low[N],cnt,i,ans; void tarjan(int u) { low[u]=dfn[u]=++cnt; int son=0; for(int v:g[u]) { if(!dfn[v]) { tarjan(v); low[u]=min(low[u],low[v]); if(low[v]>=dfn[u]) { son++; if(u!=i||son>1) cv[u]=1; } } else low[u]=min(low[u],dfn[v]); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; while(m--) { int x,y; cin>>x>>y; g[x].push_back({y}); g[y].push_back({x}); } for(i=1;i<=n;i++) { if(!dfn[i]) tarjan(i); } for(int i=1;i<=n;i++) { if(cv[i]) ans++; } cout<<ans<<'\n'; for(int i=1;i<=n;i++) { if(cv[i]) cout<<i<<' '; } return 0; }
|
割边
P1656 炸铁路
- 判定法则:
dfn[u] < low[v],其中 v 为搜索树上 u 的子节点(不能取等,否则如果有重边则割边之后仍连通)。
- 虽然是按点深搜,但其实是在遍历边。故不能走反边。
- 如何标记反边?
- 序号标记,使正反边的序号满足成对一一映射。用
x ^ 1 = y。则 (x, y) 为相邻的奇数和偶数,例如 (2, 3):2 ^ 1 = 3,3 ^ 1 = 2。
- 标记父节点(无法处理重边)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=151,M=5*1e3+5; struct Edge{ int a,b; }; vector<Edge> e; vector<int> g[N]; int tot,cnt,len; int n,m,dfn[N],low[N]; Edge bridge[M]; bool cmp(Edge x,Edge y) { if(x.a==y.a) return x.b<y.b; return x.a<y.a; } void tarjan(int u,int in_edge) { dfn[u]=low[u]=++tot; for(int id:g[u]) { int v=e[id].b; if(!dfn[v]) { tarjan(v,id); low[u]=min(low[u],low[v]); if(dfn[u]<low[v]) { bridge[++len]={min(u,v),max(u,v)}; } } else if(id!=(in_edge^1)) low[u]=min(low[u],dfn[v]); } }
int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; for(int i=1;i<=m;i++) { int x,y; cin>>x>>y; e.push_back({y,x}),g[y].push_back(e.size()-1); e.push_back({x,y}),g[x].push_back(e.size()-1); } tarjan(1,0); sort(bridge+1,bridge+1+len,cmp); for(int i=1;i<=len;i++) { cout<<bridge[i].a<<' '<<bridge[i].b<<'\n'; } return 0; }
|
强连通分量(SCC)
由 OI WIKI,
在有向图 $G$ 中,如果两个顶点间至少存在一条路径,称两个顶点强连通(strongly connected)。如果有向图 $G$ 的每两个顶点都强连通,称 $G$ 是一个强连通图。非强连通图有向图的极大强连通子图,称为强连通分量(strongly connected components)。
对于一个这样的 DFS 搜索树:

- 树边(tree edge):示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
- 反祖边(back edge):示意图中以红色边表示(即 $7 \rightarrow 1$),也被叫做回边,即指向祖先结点的边。
- 横叉边(cross edge):示意图中以蓝色边表示(即 $9 \rightarrow 7$),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点并不是当前结点的祖先。
- 前向边(forward edge):示意图中以绿色边表示(即 $3 \rightarrow 6$),它是在搜索的时候遇到子树中的结点的时候形成的。
反祖边(横叉边可能)$\to$ 环 $\to$ SCC。
所以对于一个 SCC,肯定含至少一个环,取环顶编号 $u$ 为特征值。(low[u] = dfn[u],因为环内最小编号,low 值不更新)
在搜索树上表现为:如果结点 $u$ 是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以 $u$ 为根的子树中。结点 $u$ 被称为这个强连通分量的根。比如上图 $1 \rightarrow 2 \rightarrow 7 \rightarrow 1$ 的根就是 $1$,且 low[1,2,7] = 1。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| vector<int> g[M]; stack<int> s; int n,m,cnt,tot; int scc[N],siz[N],dfn[N],low[N]; void tarjan(int u) { dfn[u]=low[u]=++tot; s.push(u); for(int v:g[u]) { if(!dfn[v]) { tarjan(v); low[u]=min(low[u],low[v]); } else if(!scc[v]) low[u]=min(low[u],dfn[v]);
} if(dfn[u]==low[u]) { int x;cnt++; do{ x=s.top(); s.pop(); scc[x]=cnt; siz[cnt]++; }while(x!=u); } }
|
SCC 缩点
其实就是 SCC 的应用。将每个 SCC 缩成点,原图就变成了 DAG,有关于出入度等特殊性质。
P3387 【模板】缩点
直接进行 DFS 的话,就会在环那里转圈耗时过长(因为可重复经过,不能用 vis 标记)。类似题有 DAG 的最长路径,一个做法利用其无后效性 DP。如何将该题的有环图 $\to$ 无环图?缩点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e6+5,M=1e5+5; vector<int> g[M]; vector<int> e[M]; stack<int> s; int n,m,cnt,tot; int dfn[N],low[N],w[N],dp[N],scc[N],a[N]; void tarjan(int x) { dfn[x]=low[x]=++tot; s.push(x); for(int y:g[x]) { if(!dfn[y]) { tarjan(y); low[x]=min(low[y],low[x]); } else if(!scc[y]) low[x]=min(low[x],dfn[y]); } if(dfn[x]==low[x]) { int y;cnt++; do{ y=s.top();s.pop(); scc[y]=cnt; w[cnt]+=a[y]; }while(y!=x); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; for(int i=1;i<=n;i++) cin>>a[i]; for(int i=1;i<=m;i++) { int x,y; cin>>x>>y; g[x].push_back({y}); } for(int i=1;i<=n;i++) { if(!dfn[i]) tarjan(i); } for(int u=1;u<=n;u++) { for(int v:g[u]) { if(scc[v]!=scc[u]) e[scc[u]].push_back({scc[v]}); } } for(int u=cnt;u>=1;u--) { if(!dp[u]) dp[u]=w[u]; for(int v:e[u]) dp[v]=max(dp[u]+w[v],dp[v]); } for(int u=1;u<=cnt;u++) dp[0]=max(dp[0],dp[u]); cout<<dp[0]; return 0; }
|
边双连通分量(eDCC)
边双连通图:不含割边的无向连通图。
边双连通分量:极大边双连通子图。极大 $:=$ 不存在完全包含该图(点和边)的边双连通子图。
定理
$$
\text{边双连通图} \iff \forall e,\ e \in \text{简单环(不自交的环)}
$$
证明:
- 充分性:$\forall x,y \in V$,$(x,y)$ 有 2 条。删除任意一条边,$(x,y) \ge 1$ 条,不符合割边定义。
- 必要性:不含割边,即 $\forall x,y \in V$,$(x,y)$ 至少有 2 条(否则路径上所有边都为割边),显然满足环的定义。
算法:类似 SCC。由定理,其实就是在无向图上求环。
P8436 【模板】边双连通分量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=5*1e5+5,M=2*1e6+5; vector<pair<int,int> > e; vector<int> g[N]; stack<int> s; vector<int> edcc[N]; int n,m,tot,cnt,dfn[N],low[N]; void tarjan(int x,int in_edge) { dfn[x]=low[x]=++tot; s.push(x); for(int id:g[x]) { int y=e[id].second; if(!dfn[y]) { tarjan(y,id); low[x]=min(low[x],low[y]); } else if(id!=(in_edge^1)) low[x]=min(low[x],dfn[y]); } if(dfn[x]==low[x]) { int y;cnt++; do{ y=s.top();s.pop(); edcc[cnt].push_back(y); }while(y!=x); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; for(int i=1;i<=m;i++) { int x,y; cin>>x>>y; e.push_back({y,x}),g[y].push_back(e.size()-1); e.push_back({x,y}),g[x].push_back(e.size()-1); } for(int i=1;i<=n;i++) if(!dfn[i]) tarjan(i,0); cout<<cnt<<'\n'; for(int i=1;i<=cnt;i++) { cout<<edcc[i].size(); for(int u:edcc[i]) cout<<' '<<u; cout<<'\n'; } return 0; }
|
eDCC 缩点
SCC 缩点,得到 DAG。
eDCC 缩点 $\to$ 树/森林(因为满足每两个点间存在唯一路径)。
利用树的性质构造答案。
P2860 [USACO06JAN] Redundant Paths G
路径问题,考虑边双缩点。由定理,eDCC 里的不用加边,只需考虑割边。
问题转化为:树 $\to$ 每个点 $\in$ 一个简单环 的最短加边数。
连接叶子节点,可以连接在它上面的所有点形成环,显然最优。
对树的叶子节点数分奇偶讨论,尽可能两两配对,画几个图找规律即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| #include<bits/stdc++.h> #define f(i,l,r) for(int i=l;i<=r;i++) #define N 10000+5 using namespace std; int n,m,cnt,num; int ver[2*N],nxt[2*N],head[2*N],tot=1; int dfn[N],low[N],deg[N],c[N]; bool bri[N]; stack<int> s;
void add(int x,int y) { ver[++tot]=y,nxt[tot]=head[x],head[x]=tot; } void tarjan(int x,int e) { dfn[x]=low[x]=++cnt; s.push(x); for(int i=head[x];i;i=nxt[i]) { int y=ver[i]; if(!dfn[y]) { tarjan(y,i); low[x]=min(low[x],low[y]); if(low[y]>dfn[x]) bri[i]=bri[i^1]=1; } else if(i!=(e^1)) low[x]=min(low[x],dfn[y]); } if(low[x]==dfn[x]) { int y;++num; do{ y=s.top(); s.pop(); c[y]=num; }while(y!=x); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n>>m; f(i,1,m) { int x,y; cin>>x>>y; add(x,y),add(y,x); }
tarjan(1,0); for(int i=2;i<=tot;i+=2) if(bri[i]) deg[c[ver[i]]]++,deg[c[ver[i^1]]]++; int sum=0; f(i,1,num) { if(deg[i]==1) sum++; } cout<<(sum+1)/2; return 0; }
|
点双连通分量(vDCC)
类比边双定义:
- 点双连通图:不含割点的无向连通图。
- 点双连通分量(vDCC):极大点双连通子图。
Thm1 任意割点 $\in$ 至少 2 个 vDCC 中(裂点)
证明:去掉任意一个割点 $x$,图至少会分裂成两个连通子图。每个连通子图去掉自己的割点,直接与割点 $x$ 相连的部分显然可以合并成一个 vDCC。
Lemma 2 个 vDCC 至多 1 个公共点
证明:若 2 个,则 2 个点都可作为割点,可以合并成一个 vDCC。
Thm2 非割点最多只在一个点双连通分量内
证明:反设只有(Lemma)一个非割点 $x$ 在 2 个 vDCC 中。由于非割点,去掉 $x$ 后 2 个 vDCC 仍连通,可以合并。矛盾。
Thm3 一张无向图是点双连通图,当且仅当满足下列两个条件之一:
(1) 图的顶点数不超过 2;
(2) 图中任意两点都同时包含在至少一个简单环中。其中“简单环”指的是不自交的环,也就是我们通常画出的环。
证明:
(1)显然。
(2)充分性:有环就有两条路,使一条路上割掉点 $p$,两点仍以另一条连通。排除 $p$ 是割点。
必要性:反证法。
- 不是环?$(x,y)$ 要是一条路,路上都是割点。
- 自交?交点成割点。

关于根节点的入栈问题解释
关于出栈另一种写法的问题解释
算法:
- 进栈;
dfn[x] <= low[y],则 x 为割点/根节点。由 Thm1,找 y 为环顶的 vDCC(具体就是弹出节点,直至 y 被弹出)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=5*1e5+5,M=2*1e6+5; vector<int> g[N]; stack<int> s; vector<int> vdcc[N]; int n,m,tot,cnt,root,cv[N],dfn[N],low[N]; void tarjan(int x) { dfn[x]=low[x]=++tot; s.push(x); if(!g[x].size()) { vdcc[++cnt].push_back(x); return; } for(int y:g[x]) { if(!dfn[y]) { tarjan(y); low[x]=min(low[x],low[y]); if(dfn[x]<=low[y]) { int t;cnt++; do{ t=s.top();s.pop(); vdcc[cnt].push_back(t); }while(t!=y); vdcc[cnt].push_back(x); } } else low[x]=min(low[x],dfn[y]); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; while(m--) { int x,y; cin>>x>>y; if(x==y) continue; g[x].push_back({y}); g[y].push_back({x}); } for(root=1;root<=n;root++) if(!dfn[root]) tarjan(root); cout<<cnt<<'\n'; for(int i=1;i<=cnt;i++) { cout<<vdcc[i].size(); for(int u:vdcc[i]) cout<<' '<<u; cout<<'\n'; } return 0; }
|
典例
P7687 [CEOI2005] Critical Network Lines
删掉该路图会不连通,所以为割边。筛出符合的即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e5+5,M=1e6+5; vector<int> g[N]; vector<pair<int,int> > e; int n,m,k,l,tot,cnt; int low[N],dfn[N],cv[N],cnl[M],a[N],b[N]; void add(int x,int y) { e.push_back({x,y}); g[x].push_back(e.size()-1); } void tarjan(int x,int in_edge) { dfn[x]=low[x]=++tot; for(int id:g[x]) { int y=e[id].second; if(!dfn[y]) { tarjan(y,id); low[x]=min(low[x],low[y]); if(dfn[x]<low[y]) { if(!a[y]||!b[y]||a[y]==k||b[y]==l) cnl[++cnt]=id; } a[x]+=a[y];b[x]+=b[y]; } else if(id!=(in_edge^1)) low[x]=min(low[x],dfn[y]); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m>>k>>l; for(int i=1;i<=k;i++) { int x; cin>>x; a[x]=1; } for(int i=1;i<=l;i++) { int x; cin>>x; b[x]=1; } for(int i=1;i<=m;i++) { int x,y; cin>>x>>y; add(x,y),add(y,x); } tarjan(1,0); cout<<cnt<<'\n'; for(int i=1;i<=cnt;i++) cout<<e[cnl[i]].first<<' '<<e[cnl[i]].second<<'\n'; return 0; }
|
P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G
喜欢它等价于可达它。
任意一个 SCC 内点都是互相可达的,属于同一类点。真正的三角恋
考虑缩点,那么 Cow Star 显然就是新图唯一的叶子结点(出度为 0)。因为每个新点都可到达它。
不唯一则叶子结点间不互相可达。无 cow star。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| #include<bits/stdc++.h> using namespace std; typedef long long ll; const int N=1e4+5; vector<int> g[N]; vector<int> e[N]; stack<int> s; int n,m,cnt,tot; int dfn[N],low[N],scc[N],siz[N],deg[N]; void tarjan(int x) { dfn[x]=low[x]=++tot; s.push(x); for(int y:g[x]) { if(!dfn[y]) { tarjan(y); low[x]=min(low[x],low[y]); } else if(!scc[y]) low[x]=min(low[x],dfn[y]); } if(low[x]==dfn[x]) { int y;cnt++; do{ y=s.top();s.pop(); scc[y]=cnt; siz[cnt]++; }while(y!=x); } } int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); cin>>n>>m; for(int i=1;i<=m;i++) { int x,y; cin>>x>>y; g[x].push_back(y); } for(int i=1;i<=n;i++) { if(!dfn[i]) { tarjan(i); } } for(int x=1;x<=n;x++) { for(int y:g[x]) { int a=scc[x],b=scc[y]; if(a!=b) deg[a]++; } } int ans=0,ed=0; for(int i=1;i<=cnt;i++) if(!deg[i]) ed=i,ans++; if(ans>1) cout<<0; else cout<<siz[ed]; return 0; }
|