一、基本概念
1.连通、强连通、弱连通
-
若一张无向图的节点两两互相可达,则称这张图是连通的。
-
G G G 是一个无向图。若 H H H 是 G G G 的一个连通子图,且不存在 F F F 满足 H ⊊ F ⊆ G H\subsetneq F\subseteq G H⊊F⊆G 且 F F F 为连通图,则 H H H 是 G G G 的一个连通块/连通分量(极大连通子图)。
-
若一张有向图的节点两两互相可达,则称这张图是强连通的。
-
若一张有向图的边替换为无向边后可以得到一张连通图,则称原来这张有向图是弱连通的。
-
与连通分量类似,也有弱连通分量(极大弱连通子图)和强连通分量(极大强连通子图)。
2.点双连通与边双连通
- 在一张连通的无向图中,对于某两个点 u , v u,v u,v,如果无论删去哪一条边都不能使它们不连通,我们就说 u , v u,v u,v 边双连通。
- 在一张连通的无向图中,对于某两个点 u , v u,v u,v,如果无论删去哪一个点都不能使剩下的图不连通,我们就说 u , v u,v u,v 点双连通。
- 边双连通具有传递性,而点双连通不具有传递性。
- 对于一个无向图中的极大边双连通的子图,我们称这个子图为一个边双连通分量。
- 对于一个无向图中的极大点双连通的子图,我们称这个子图为一个点双连通分量。
3.割点和桥
-
对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点或割顶。
-
对于一个无向图,如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者割边。
二、Tarjan算法
1.割点与点双连通分量
(1)基本思路
我们在图上做对每个点只访问一次的 DFS,这样就会在图上形成一颗搜索树。在从点 u u u 访问点 v v v 的时候,如果 v v v 之前没有被访问过,那么我们会对 v v v 继续做 DFS,这样 ( u , v ) (u,v) (u,v) 就是一条树边。如果 v v v 已经被访问,并且 v v v 是 u u u 的一个祖先,那么 ( u , v ) (u,v) (u,v) 就是一条反向边。
对于求割点,我们可以给出一个定理:
在无向连通图 G G G 的 DFS 树中,根节点 u u u 是割点当且仅当其有超过 1 1 1 个子节点;非根结点 u u u 是割点当且仅当 u u u 存在一个子结点 v v v,使得 v v v 及其所有后代都没有反向边连回 u u u 的祖先。
证明:根节点的情况十分显然,下面考虑非根节点的情况。
考虑 u u u 的任意子结点 v v v ,如果 v v v 及其后代不能连回 f f f, f f f 为 u u u 的某个祖先。那么在删除 u u u 之后, f f f 和 v v v 不再连通,则 u u u 就是一个割点;反过来如果 v v v 或者它的某个后代存在一条反向边连回 f f f,则删除 u u u 之后,以 v v v 为根的整棵子树都能通过这条反向边和 f f f 连通,则 u u u 不是一个割点。
(2)算法流程
- 符号声明:有了前面的定理,我们用 d f n ( u ) dfn(u) dfn(u) 来记录时间戳(DFS 的访问顺序),用一个 l o w ( u ) low(u) low(u) 来表示 u u u 以及其后代最多经过一条反向边能回到的最早的点的时间戳。初始状态, l o w ( u ) = d f n ( u ) low(u)= dfn(u) low(u)=dfn(u)。
- 求割点:对于树边 ( u , v ) (u,v) (u,v) 当有一个 v v v 满足 l o w ( υ ) ≥ d f n ( u ) low(υ)\geq dfn(u) low(υ)≥dfn(u) 时, u u u 就是割点。
- 更新
l
o
w
(
u
)
low(u)
low(u):
- 对于树边 ( u , v ) (u,v) (u,v),有 l o w ( u ) = min ( l o w ( u ) , l o w ( v ) ) low(u)=\min(low(u),low(v)) low(u)=min(low(u),low(v))
- 对于反向边 ( u , v ) (u,v) (u,v),有 l o w ( u ) = min ( l o w ( u ) , d f n ( v ) ) low(u)=\min(low(u),dfn(v)) low(u)=min(low(u),dfn(v))。
- 求点双连通分量:用一个栈保存边,每次访问一个树边或者反向边的时候,把这条边压入栈中。当通过边 ( u , v ) (u,v) (u,v) 找到一个割点的 u u u 的时候,实际上就出现了一个点双连通分量,我们一直弹出栈中的边,直到弹出边 ( u , v ) (u,v) (u,v),这过程中弹出来的所有的边都属于同一个点双连通分量。
- 所以我们可以在 O ( V + E ) O(V+E) O(V+E) 的时间复杂度内求出割点个点双连通分量。为了去重方便,这里直接使用 s e t set set 来记录点双连通分量。
(3)模板代码
ll times = 0;//时间
ll dfn[maxn], low[maxn];
ll bcc_cnt = 0;//点双连通分量数
bool iscut[maxn];//记录是否是割点
set<ll> bcc[maxn];//记录点双连通分量
stack<node> S;//记录边集
void dfs(ll u, ll fa)
{
dfn[u] = low[u]= ++times;
ll child = 0;
for(ll i = p[u]; i != -1; i = e[i].next)
{
ll v = e[i].v;
if(dfn[v] == 0)
{
S.push(e[i]);
++child;
dfs(v, u);
low[u] = min(low[u], low[v]);
if(low[v] >= dfn[u])
{
iscut[u] = true;
++bcc_cnt;
while(1)
{
node x=S.top();
S.pop();
bcc[bcc_cnt].insert(x.u);
bcc[bcc_cnt].insert(x.v);
if(x.u == u && x.v == v)
break;
}
}
}
else if(dfn[v] < dfn[u] && v != fa)
{
S.push(e[i]);
low[u] = min(low[u], dfn[v]);
}
}
if(fa < 0 && child == 1)
iscut[u] = false;
}
2.桥与边双连通分量
根据定义不难发现,割点与桥相似度极高,所以求法基本一模一样。但需要注意,割点属于某个点双连通分量,但桥不属于某个边双连通分量。此外,对根节点的处理也有所不同,可以参照代码。
对于求桥,我们可以给出一个定理:
在无向连通图 G G G 的 DFS 树中,边 ( u , v ) (u,v) (u,v) 是桥当且仅当 v v v 及其所有后代都没有反向边连回 u u u 或其祖先。
ll times = 0;
ll dfn[maxn], low[maxn];
ll bcc_cnt = 0;
bool used[maxn];
ll is_cut[maxm * 2];
vector<ll> bcc[maxn];
void dfs1(ll u, ll fa)
{
dfn[u] = low[u] = ++times;
for(ll i = p[u]; i != -1; i = e[i].next)
{
ll v = e[i].v;
if(dfn[v] == 0)
{
dfs1(v, u);
low[u] = min(low[u], low[v]);
if(low[v] > dfn[u])
is_cut[i] = is_cut[i ^ 1] = 1;
}
else if(dfn[v] < dfn[u] && v != fa)
low[u] = min(low[u], dfn[v]);
else if(low[v] > dfn[u])
is_cut[i] = is_cut[i ^ 1] = 2;
}
}
void dfs2(ll u)
{
bcc[bcc_cnt].push_back(u);
used[u] = true;
for(ll i = p[u]; i != -1; i = e[i].next)
{
ll v = e[i].v;
if(!used[v] && is_cut[i] != 1)
dfs2(v);
}
}
3.强连通分量
由强连通分量的定义可知,如果我们找到了强联通分量的第一个被发现的点 u u u,这个强连通分量的其他结点一定是点 u u u 的后代(强连通分量中任意两个结点相互可达)。那么问题就在于如何找到每个强连通分量的第一个结点。
- 如果我们发现从 u u u 的子结点出发可以到达 u u u 的某个祖先 w w w,那么 u , v , w u,v,w u,v,w 必然在同一个强连通分量里面,因为它们三个构成了一个环,环上的结点相互可达。
- 又因为 w w w 比 u u u 更在被发现,所以 w w w 才是第一个被发现的点。另外,如果从 v v v 最多只能发现到 u u u,那么 u u u 是这个强连通第一个发现的点。所以 u u u 是第一个被发现的点的条件是 l o w ( u ) = d f n ( u ) low(u)= dfn(u) low(u)=dfn(u)。这里 l o w , d f n low,dfn low,dfn 的定义与前面两个 tarjan 算法的定义相同。
与前面的 tarjan 算法相同,现在利用栈来找强连通分量中剩下的点:
- 每次对一个点做 dfs 的时候,都把这个点先压入到一个栈中。当我们找到一个强连通分量的第一个点 u u u 的时候,栈中在这个点之后访问的点都和点 u u u 属于同一个强连通分量。
- 最后考虑一下这么操作的正确性。若栈中 u u u 的后代中有一个点 v v v 和 w w w 不属于一个强连通分量,那么 v v v 势必应该之前会被当做另一个强连通分量的第一个点,所以 v v v 不应该出现在栈中的。
ll times = 0;
ll dfn[maxn], low[maxn];
ll scc_cnt = 0;
ll sccno[maxn];
set<ll> scc[maxn];
stack<ll> S;
void dfs(ll u)
{
dfn[u] = low[u] = ++times;
S.push(u);
for(ll i = p[u]; i != -1; i = e[i].next)
{
ll v = e[i].v;
if(dfn[v] == 0)
{
dfs(v);
low[u] = min(low[u], low[v]);
}
else if(!sccno[v])
low[u] = min(low[u], dfn[v]);
}
if(low[u] == dfn[u])
{
++scc_cnt;
while(1)
{
ll x = S.top();
S.pop();
sccno[x] = scc_cnt;
scc[scc_cnt].insert(x);
if(x == u)
break;
}
}
}