图论2:连通性问题
Tarjan
对于一个图,我们常常要关注某些点的联通关系,这点在较复杂的图论建模和应用中十分常见。
那么要解决这一类问题,必不可少的就是各种 Tarjan 算法,接下来是对一些经典模型的介绍。
共通之处
首先 dfs 求出一个原图(无向图或者有向图)的 dfs 树,观察这一步,可以发现许多非常好的性质:
首先,节点不可能和 \(dfn\)(注:dfs 时间戳) 大于本节点的点连非树边(否则就应该直接变成子节点),这点在各种论证中疯狂经常使用,初次接触 tarjan 容易引发不理解的也是这个性质。
有向图强连通分量(SCC)
定义:一张任意点都存在到其他所有点的路径的有向图是强连通图。有向图中最大化的一个强连通的分量是 SCC。
SCC 本质就是一个环。
我们将节点按照 dfs 序一个个压入栈,如果已经处理完某些节点的 SCC,就把他们弹出(具体实现后续会讲)。
需要维护每个节点 \(u\) 的 \(dfn_u\) 和 \(low_u\),其中 \(low_u\) 表示 \(u\) 的子树中节点或子树中节点经过一条非树边可以到达的最小 \(dfn\)。
那么怎么维护这个东西呢?
对于节点 \(u\),有边为 \(u\rightarrow v\):
- 如果还没有遍历到 \(v\),那么 \(v\) 是 \(u\) 的子节点,边是树边,所以 \(low_u\gets \min(low_u,low_v)\)。
- 已经遍历过 \(v\),但是还没有处理完 \(v\)(\(v\) 还在栈里面),则是非树边,去 \(v\) 就不能继续走了,所以 \(low_u\gets \min(low_u,dfn_v)\)。
- 处理完 \(v\) 了,那么它的 SCC 早就处理完了,不用管。
搞这个 \(low\) 数组干什么呢?
设 SCC 的根是其中 \(dfn\) 最小的节点 \(x\),那么容易发现 \(low_x=dfn_x\),所以一旦发现 \(low_x=dfn_x\) 的 \(x\) 就弹出栈中元素直到弹出 \(x\),这些被新弹出的元素都是这个 SCC 的。
::::info[证明]
\(low_x=dfn_x\) 是节点 \(x\) 为某个 SCC 的根节点的充要条件。
如果 \(low_x\ne dfn_x\),则这个 SCC 还可以扩大,因为存在没出栈的 \(y\) 使得 \(x\rightarrow y,dfn_y<dfn_x\),由 dfs 树的性质和元素出栈的条件,\(y\) 一定能通过某条路径到达 \(x\),所以 \(x,y\) 是一个 SCC 里的元素,而这个 SCC 的根节点一定是 \(x\) 的某个祖先。
因为栈中在 \(x\) 点之上堆着的点都是 \(x\) 的孩子(由 dfs 树),而其没有出栈说明 \(low\) 值都 \(\le x\),所以这些点和 \(x\) 处于一个 SCC。
::::
接下来就能愉快地把强连通分量进行记录啦。
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n, m, num = 0, dfn[10010], low[10010], bel[10010], scn = 0;
bool vis[10010], inS[10010];
stack <int> st;
vector <int> e[10010], sc[10010];
bool prt[10010];
void dfs(int x) {
low[x] = dfn[x] = ++num, vis[x] = inS[x] = 1, st.push(x);
for (int v: e[x]) {
if (vis[v] && inS[v]) low[x] = min(low[x], dfn[v]);
else if (!vis[v]) dfs(v), low[x] = min(low[x], low[v]);
}
if (low[x] == dfn[x]) {
scn++;
while (1) {
int u = st.top();
st.pop();
sc[scn].push_back(u), bel[u] = scn, inS[u] = 0;
if (u == x) break;
}
}
}
int main() {
cin >> n >> m;
for (int i = 1, x, y; i <= m; i++) {
cin >> x >> y;
e[x].push_back(y);
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) dfs(i);
cout << scn << endl;
for (int i = 1; i <= n; i++) {
if (prt[i]) continue;
if (!sc[bel[i]].empty()) {
sort(sc[bel[i]].begin(), sc[bel[i]].end());
for (int x: sc[bel[i]]) {
cout << x << ' ';
prt[x] = 1;
}
cout << endl;
}
}
return 0;
}
::::
Tarjan缩点
意思就是把有向图每个强连通分量分别缩成一个点,同时维护信息。就变成了一个 DAG(环都被缩了),可以方便地进行线性 DP 等操作。
强连通分量的所有点都可以把其他点走一遍,因为点权非负,所以相当于分量中点权和都能拿到。所以考虑缩点,然后在 DAG 上做 DP,求最大路径即可。
考虑实现 DAG,需要缩点之后对新点连边。然后拓扑排序即可。
::::warning[提示]{open}
缩点之后对新点连边时注意,因为是 SCC 里面所有点连边,所以别连自己了。
当然处理得当的 DP 也不会有这个问题。
::::
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n,m,a[10010],dfn[10010],num,low[10010],bel[10010];
bool inS[10010];
stack<int> st;
vector<int> e[10010],scc[10010];
int N,A[10010],in[10010],dp[10010],ans=0;
vector<int> E[10010];
void dfs(int fa,int x){
dfn[x]=low[x]=++num,inS[x]=1,st.push(x);
for(int v:e[x]){
if(!dfn[v]){
dfs(x,v);
low[x]=min(low[x],low[v]);
}else if(inS[v]){
low[x]=min(low[x],dfn[v]);
}
}
if(dfn[x]==low[x]){
++N;
while(1){
int p=st.top();st.pop();
scc[N].push_back(p);
bel[p]=N,A[N]+=a[p],inS[p]=0;
if(p==x) break;
}
}
}
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1,x,y;i<=m;i++){
cin>>x>>y;
if(x==y) continue;
e[x].push_back(y);
}
for(int i=1;i<=n;i++)
if(!dfn[i]) dfs(0,i);
for(int i=1;i<=N;i++){
for(int u:scc[i]){
for(int v:e[u]){
if(bel[v]==i) continue;
E[i].push_back(bel[v]),in[bel[v]]++;
}
}
}
queue<int> q;
for(int i=1;i<=N;i++) if(!in[i]) q.push(i),dp[i]=A[i];
while(!q.empty()){
int u=q.front();q.pop();
ans=max(ans,dp[u]);
for(int v:E[u]){
dp[v]=max(dp[v],dp[u]+A[v]);
in[v]--;
if(!in[v]) q.push(v);
}
}
cout<<ans;
return 0;
}
::::
例题:最大半连通子图
由于要最大化,所以一个强连通分量要么全部在一个最大半连通子图,要么全部不在。
所以缩点,维护每个点中有多少个点。然后构造 DAG。我们发现在 DAG 上的最大半连通子图一定都是一条链(删去不必要边之后)。所以就变成了带权 DAG 上求最长链长度和个数问题。
::::info[为什么是一条链?]{open}
首先,去掉可以被其他路径替代的边。
如果不是一条链,一定有分叉,那么分叉出去的两条边所到达的点是不能从其中一个到达另外一个的。
::::
具体见代码。
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n,m,mod,dfn[100010],dfnnum,low[100010],bel[100010],num;
bool inS[100010];
stack<int> st;
vector<int> e[100010],scc[100010];
set<int> dag[100010];
int sz[100010],in[100010],l[100010],dp[100010];
int ansl=0,ansnum=0;
queue<int> q;
void dfs(int x){
dfn[x]=low[x]=++dfnnum,inS[x]=1,st.push(x);
for(int v:e[x]){
if(!dfn[v]){
dfs(v);
low[x]=min(low[x],low[v]);
}else if(inS[v]){
low[x]=min(low[x],dfn[v]);
}
}
if(low[x]==dfn[x]){
++num;
while(1){
int p=st.top(); st.pop();
scc[num].push_back(p),bel[p]=num;
sz[num]++,inS[p]=0;
if(p==x) break;
}
}
}
int main(){
cin>>n>>m>>mod;
for(int i=1,x,y;i<=m;i++){
cin>>x>>y;
e[x].push_back(y);
}
for(int i=1;i<=n;i++)
if(!dfn[i]) dfs(i);
for(int i=1;i<=num;i++){
for(int u:scc[i]){
for(int v:e[u]){
if(bel[v]==i) continue;
if(dag[i].insert(bel[v]).second) in[bel[v]]++;
}
}
}
for(int i=1;i<=num;i++)
if(!in[i]) q.push(i),l[i]=sz[i],dp[i]=1;
while(!q.empty()){
int u=q.front(); q.pop();
for(int v:dag[u]){
if(l[u]+sz[v]>l[v]){
l[v]=l[u]+sz[v];
dp[v]=dp[u];
}else if(l[u]+sz[v]==l[v]){
dp[v]=(dp[v]+dp[u])%mod;
}
if(!--in[v]) q.push(v);
}
}
for(int u=1;u<=num;u++){
if(l[u]>ansl){
ansl=l[u];
ansnum=dp[u];
}else if(l[u]==ansl){
ansnum=(ansnum+dp[u])%mod;
}
}
cout<<ansl<<"\n"<<ansnum;
return 0;
}
::::
2-SAT
这是一个代码简单而有一定思维难度的算法。
问题描述
有 \(n\) 个变量,只能赋值为 0/1。现在有 \(m\) 个必须满足的条件,每个条件形如:
- \(\neg x_i \lor x_j\)
- \(\neg x_i \lor \neg x_j\)
- \(x_i \lor x_j\)
中的一个。即 "\(x_i\) 为 (0/1) 或 \(x_j\) 为 (0/1)"。
我们需要判断能否进行合法赋值,如果可以,输出一组可行解。
建图
我们以一个条件为例:\(x_1=1\) 或 \(x_2=0\)。此时我们知道:
- 如果 \(x_1=0\),那么 \(x_2=0\)。(条件 1)
- 如果 \(x_2=1\),那么 \(x_1=1\)。(条件 2)
这两个条件得到保证,也就可以满足原条件。
建图,对于每个 \(x_i\) 建一个 \(x_i=1\) 和 \(x_i=0\) 的点,分别是 \(x_{i,1}\) 和 \(x_{i,0}\)。所以一共有 \(2n\) 个点。
然后考虑建边。比如对于条件 1,可以用一条 $ x_{1,0}\rightarrow x_{2,0}$ 的有向边来表述。就是每条边的起点一旦满足,终点也必须满足。所以一共有 \(2m\) 条边。
原条件 \(x_1=1\) 或 \(x_2=0\) 的图:

算法流程
接下来就是处理了,假设我们有这样 5 个条件:
- \(x_3\lor \neg x_1\)
- \(\neg x_2\lor\neg x_3\)
- \(x_1\lor x_2\)
- \(\neg x_1\lor x_4\)
- \(\neg x_1\lor \neg x_4\)
如图:

容易发现在一个强连通分量中的点代表的条件,要么全部实现,要么全部不实现。
所以进行 Tarjan 缩点化为 DAG。

然后我们逐个判断 \(x_i\) 应该怎么选。
对于 \(x_i\):
- \(x_{i,0}\) 和 \(x_{i,1}\) 在一个点(强连通分量)中。完啦,这两个不能同时满足或不满足。赋值答案不存在。
- 不在同一个点,那么分情况:
-
- 不存在其中一个点到另一个点的路径,那么可以随意赋值了
- 存在 \(x_{i,0}\rightarrow x_{i,1}\) 的一条路,那么选前者肯定不行,所以赋值为 1。
- 存在 \(x_{i,1}\rightarrow x_{i,0}\) 的一条路,同理,赋值为 0。
至于判断怎么赋值,其实按照拓扑序即可。拓扑序在后的肯定不会有向另一个点的路径(因为那个点拓扑选在前)。所以直接选拓扑序在后的点作为赋值。
例如在例子中,就要必须选 \(\neg x_1,x_2,\neg x_3\),即 \(x_1=0,x_2=1,x_3=0\)。对于 \(x_4\) 因为没有路径可以某个到达某个,0/1 都可以赋值。
Tarjan 缩点顺序是反拓扑序
关于 Tarjan 缩点,其实缩出来的点顺序就是反着的拓扑序。因为这两条性质:
- 点所连向的 dfn 序更大的点的边一定是树边。
- 本分量弹出栈时这些分量里面的点一定不连向 dfn 比分量根小的点(由 Tarjan 算法 \(low_r=dfn_r\) 等式)。而连向的 dfn 更大的点肯定已经出栈(或者现在正在出)了。
对于第二条,即:按倒着的拓扑排序思路,就是没有出边了,可以入拓扑序。
所以实现就极为简单。
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n, m, dfn[2000010], dfx, low[2000010], inS[2000010], bel[2000010], N;
vector <int> e[2000010];
stack <int> st;
int fan(int x) { return x > n ? (x - n) : (x + n); }
void dfs(int fa, int x) {
dfn[x] = low[x] = ++dfx;
inS[x] = 1, st.push(x);
for (int v: e[x]) {
if (!dfn[v]) dfs(x, v), low[x] = min(low[x], low[v]);
else if (inS[v]) low[x] = min(low[x], dfn[v]);
}
if (low[x] == dfn[x]) {
++N;
while (1) {
int p = st.top();
st.pop();
bel[p] = N, inS[p] = 0;
if (p == x) break;
}
}
}
int ans[1000010], flg;
int main() {
scanf("%d%d", & n, & m);
for (int i = 1, x1, y1, x2, y2; i <= m; i++) {
scanf("%d%d%d%d", & x1, & y1, & x2, & y2);
int x = x1 + y1 * n, y = x2 + y2 * n;
e[fan(x)].push_back(y);
e[fan(y)].push_back(x);
}
for (int i = 1; i <= 2 * n; i++)
if (!dfn[i]) dfs(0, i);
for (int i = 1; i <= n; i++) { // 枚举 x_i=0 的情况的点
if (bel[i] == bel[fan(i)]) flg = 1; // 两个不能同时满足/不满足,直接无解
else if (bel[i] < bel[fan(i)]) ans[i] = 0; // 缩点顺序(反拓扑序)小,所以拓扑序大
else ans[i] = 1; // 同理
}
if (flg) cout << "IMPOSSIBLE" << endl;
else {
cout << "POSSIBLE" << endl;
for (int i = 1; i <= n; i++)
cout << ans[i] << " ";
}
return 0;
}
::::
使用技巧
变量取值
有时需要求出变量取值一组解,满足若干条件。
设取值范围 \(V\),变量 \(n\) 个。
如果允许 \(O(nV)\),那么可以给用 \(V\) 个点表示每个变量,点 \(u_{i,j}\) 表示 \([x_i\ge j]\)。
显然需要满足 \(u_{i,j}=0\) 或者 \(u_{i,j+1}=1\),容易用 2-SAT 刻画。
其他条件看情况而定。
无向图求割点
定义:割点是无向图中删去之后使连通块数量增加的点。
维护每个节点的 \(dfn_u\) 和 \(low_u\),其中 \(low_u\) 表示子树中不包含 \(u\) 的节点本身或经过一条非树边可以达到的最小 \(dfn\)。即:
对于节点 \(u\),有边为 \(u \iff v\)(\(v\) 不是 \(u\) 的父亲):
- 还没有遍历到 \(v\),那么 \(v\) 是 \(u\) 的子节点,可以随便走,所以 \(low_u\gets \min(low_u,low_v)\)。
- 已经遍历过 \(v\),去 \(v\) 就不能继续走了,所以 \(low_u\gets \min(low_u,dfn_v)\)。
对于一个点 \(u\),假设有其子节点 \(v\),\(low_v\ge dfn_u\),则 \(u\) 是割点,因为 \(v\) 子树没有任何节点能不经过 \(u\) 和 \(u\) 之前的节点连通,删掉 \(u\) 连通块个数会增加。
对于源点就不对了,但是很容易发现有超过 1 个子节点的源点是割点,特判即可。
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n, m, dfn[100010], low[100010], num, Gn;
vector <int> e[100010];
bool isG[100010];
void dfs(int fa, int x) {
dfn[x] = low[x] = ++num;
int ch = 0;
for (int v: e[x]) {
if (v == fa) continue;
if (!dfn[v]) {
ch++;
dfs(x, v);
low[x] = min(low[x], low[v]);
if (low[v] >= dfn[x] && !isG[x] && fa) ++Gn, isG[x] = 1;
} else {
low[x] = min(low[x], dfn[v]);
}
}
if (fa == 0 && ch > 1) ++Gn, isG[x] = 1;
}
int main() {
cin >> n >> m;
for (int i = 1, x, y; i <= m; i++) {
cin >> x >> y;
e[x].push_back(y);
e[y].push_back(x);
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) dfs(0, i);
cout << Gn << endl;
for (int i = 1; i <= n; i++)
if (isG[i]) cout << i << " ";
return 0;
}
::::
无向图求割边
定义:割边是无向图中删去之后使连通块数量增加的边。
割边类似割点,将 \(low_v\ge dfn_u\) 改成 \(low_v> dfn_u\) 即可。此时 \(u\) 到 \(v\) 的边是割边。源点的特殊情况也不用考虑了,非常简便。
为什么 \(low_v> dfn_u\) 就是割边?因为 \(v\) 子树没有任何节点能不经过这条边 和 \(u\) 之前的节点连通,删掉后连通块个数会增加。
实现的时候有一个细节,如果两个节点之间有重边,那么重边都不是割边。具体做法可以让 \(low_v\) 在第二次尝试往它父亲 \(u\) 连的时候就让其更新了,不阻止。
::::info[点击展开代码]
int n, m, num, dfn[500010], low[500010], f[500010], vis[500010];
struct edge { int v, id; };
vector <edge> e[500010];
bool isB[2000010];
void dfs(int fa, int x) {
bool flg = 0;
low[x] = dfn[x] = ++num, f[x] = fa;
for (edge t: e[x]) {
int v = t.v, id = t.id;
if (!dfn[v]) {
dfs(x, v);
low[x] = min(low[x], low[v]);
if (low[v] > dfn[x]) isB[id] = 1;
} else {
if (v != fa || flg) low[x] = min(low[x], dfn[v]);
else flg = 1;
}
}
}
::::
边双连通分量
定义:一张无向图,如果删掉任意一条边后所有点还都两两连通,就是边双连通图。无向图中最大化的一个边双连通的分量是边双连通分量。
求边双连通分量只需要把图中的割边都删掉,然后朴素的 dfs 统计有多少个连通块即可。
::::info[为什么可以这么做]{open}
对于不是割边的边,单独删掉之后连通块不会增加,对答案毫无影响,留着即可。
对于割边,删掉之后连通块数量增加,从而我们可以看出这两个连通块之间不是双连通的,所以要删掉。
等等!你可能会问,那删掉很多个割边是否会影响“删掉任意一条边后所有点还都两两连通”的定义呢?
当然是不会的。如果一个分量里有多条割边,那么删掉任意一条都不连通了,肯定不是双连通分量。如果一个分量里没有割边,我们删割边和它没关系,不会多删。
::::
::::info[点击展开代码]
#include<bits/stdc++.h>
using namespace std;
int n, m, num, dfn[500010], low[500010], f[500010], vis[500010];
struct edge { int v, id; };
vector <edge> e[500010];
bool isB[2000010];
int ans = 0;
vector <int> output[500010];
void dfs(int fa, int x) {
bool flg = 0;
low[x] = dfn[x] = ++num, f[x] = fa;
for (edge t: e[x]) {
int v = t.v, id = t.id;
if (!dfn[v]) {
dfs(x, v);
low[x] = min(low[x], low[v]);
if (low[v] > dfn[x]) isB[id] = 1;
} else {
if (v != fa || flg) low[x] = min(low[x], dfn[v]);
else flg = 1;
}
}
}
void dfs2(int x, int o) {
output[o].push_back(x), vis[x] = 1;
for (edge t: e[x]) {
int v = t.v, id = t.id;
if (!vis[v] && !isB[id]) dfs2(v, o);
}
}
int main() {
cin >> n >> m;
for (int i = 1, x, y; i <= m; i++) {
cin >> x >> y;
e[x].push_back({y,i});
e[y].push_back({x,i});
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) dfs(0, i);
for (int i = 1; i <= n; i++)
if (!vis[i]) dfs2(i, ++ans);
cout << ans << endl;
for (int i = 1; i <= ans; i++) {
cout << output[i].size() << " ";
for (int x: output[i]) cout << x << " ";
cout << endl;
}
return 0;
}
::::
点双连通分量
定义:一张无向图,如果删掉任意一个点后剩余点还都两两连通,就是点双连通图。无向图中最大化的一个点双连通的分量是点双连通分量。
发现点双连通分量中割点最多连接了一条边,否则删掉就断开了。
类似强连通的方法,用一个栈,发现 \(low_v\ge dfn_u\) 的 \(v\) 就将栈不断弹出直到 \(v\),这些都在以点 \(u\) 为根的点双连通分量中,最后再加上一个 \(u\) 点也在这个点双连通分量中(无需从栈弹出)。
::::info[为什么可以这么做]{open}
这就相当于处理割点,每个割点最多连一条边,因此一旦发现 \(u\) 是割点,这个点双连通分量就不可能在往上延伸了,到此为止。
::::
::::info[点击展开代码]
void Dfs(int fa,int x){
s.push(x),dfn[x]=++dft,low[x]=dft;
for(int v:G[x]){
if(v==fa) continue;
if(!dfn[v]){
Dfs(x,v);
low[x]=min(low[x],low[v]);
if(low[v]>=dfn[x]){
++btp;
while(1){
int u=s.top(); s.pop();
b[btp].push_back(u);
if(u==v) break;
}
b[btp].push_back(x);
}
}else low[x]=min(low[x],dfn[v]);
}
}
#include<bits/stdc++.h>
using namespace std;
int n,m,dfn[500010],num,low[500010];
vector<int> e[500010],rt;
stack<int> st;
bool isrt[500010],vis[500010];
int ans=0;
vector<int> dcc[500010];
void dfs(int fa,int x){
dfn[x]=low[x]=++num,st.push(x);
if(!fa&&!e[x].size()) dcc[++ans]={x};
for(int v:e[x]){
if(!dfn[v]){
dfs(x,v);
low[x]=min(low[x],low[v]);
if(low[v]>=dfn[x]){
ans++;
while(1){
int x=st.top(); st.pop();
dcc[ans].push_back(x);
if(x==v) break;
}
dcc[ans].push_back(x);
}
}else if(v!=fa){
low[x]=min(low[x],dfn[v]);
}
}
if(fa==0&&!isrt[x]) isrt[x]=1,rt.push_back(x);
}
int main(){
cin>>n>>m;
for(int i=1,x,y;i<=m;i++){
cin>>x>>y;
if(x==y) continue;
e[x].push_back(y);
e[y].push_back(x);
}
for(int i=1;i<=n;i++)
if(!dfn[i]) dfs(0,i);
cout<<ans<<endl;
for(int i=1;i<=ans;i++){
cout<<dcc[i].size()<<" ";
for(int x:dcc[i]) cout<<x<<" ";
cout<<endl;
}
return 0;
}
带上记录边(每条边属于一个点双)。
void dfs(int fa,int x){
dfn[x]=++dft,vis[x]=1,low[x]=dft;
s.push(x);
for(int v:e[x]){
if(v==fa) continue;
if(!vis[v]){
se.push({x,v});
dfs(x,v),low[x]=min(low[x],low[v]);
if(low[v]>=dfn[x]){
++btp;
while(1){
int u=s.top(); s.pop();
cnt[btp]++,V[btp].push_back(u);
if(u==v) break;
}
cnt[btp]++,V[btp].push_back(x);
while(1){
pii p=se.top(); se.pop();
cntg[btp]++;
if(p.fr==x&&p.sc==v) break;
}
}
}else if(dfn[v]<dfn[x]){ // only record edges to anscestor
low[x]=min(low[x],dfn[v]);
se.push({x,v});
}
}
}
::::
圆方树

一些性质
- 点双内,任选两个点和一条边,都有过那条边的两个点之间简单路径。

浙公网安备 33010602011771号