【图论】Tarjan 入门

· · 算法·理论

update 2025.8.21:发现几个锅,改一改。

这篇文章是题单 【图论】Tarjan 入门 的配套文章。

注意到 Tarjan 是一个算法,而不是一类问题。事实上有向图用 Tarjan 解决的问题和无向图用 Tarjan 解决的问题是两类不同的问题。我一般称之为“强连通分量问题”和“双连通分量问题”。

有向图的 Tarjan(强连通分量问题)

相比双连通分量问题,强连通分量问题应用更广泛一些。

强连通分量的定义

有向图 G 强连通是指,G 中任意两个结点连通。

例如下面的图就是强连通的:

强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图。

例如:

这张图中,\{7,8,9\} 构成一个强连通分量。另外的两个强连通分量分别是 \{2\}\{1,3,4,5,6\}。虽然 \{1,3,4,5\} 两两之间也可达,但它不是最大的。

我们要运用到 Tarjan 算法。在 OI 中,Tarjan 算法一般是指求强连通分量或双连通分量的算法。

有向图的 dfs 树

这是一个有向图。

我们 dfs 遍历整张图。所经过的边应该是红色的边:

这些红边组成了一颗 dfs 树,这些边统称为树边。

但是还有一些边没有遍历到。

所以我们再定义两类边:

返祖边:顾名思义,从一个点连向其祖先的边叫做返祖边。

这张图中,蓝色的边是返租边:

但还有一些边没有被覆盖到。这些边我们叫做横叉边(下图中的紫色边)。

看图理解:横叉边就是在两个不同的子树里面乱插的边。

辅助数组:dfn 和 low

我们定义两个辅助数组和一个辅助栈。

注意:有向图和无向图中 low 的定义不同。

强连通分量的求解

文字描述

我们先来文字版叙述一遍过程:

  1. 当前节点 u 入栈,初始化 dfn_ulow_ulow_u\leftarrow dfn_u),标记节点入栈
  2. 遍历 u 的邻居 v

    • 如果 v 还没有被遍历过,那么 v 就是 dfs 树中 u 的儿子。遍历一遍,然后根据定义,令 low_u\leftarrow \min(low_u,low_v)
    • 否则,考虑 v 是否还在栈中。如果还在,则 low_u\leftarrow \min(low_u,dfn_v)
  3. 遍历完邻居后,如果 low_u=dfn_u,说明子树内无法回到 u 以上的点,就将 u 还在栈中的子树分离出来成为一个新的强连通分量(注意标记该节点已不在栈中)。所以用栈——因为子树后遍历先出去,符合栈的特点。

图解

举个例子(点左边蓝色的权值为 dfn,右边红色的权值为 low):

先遍历前四个点,这都不在话下。这四个点依次入栈。

8 的时候,你会发现返租边所连的 1 还在栈中,所以 low_8=1

向上更新 43low。这都不在话下。

遍历 99 号点再遍历横叉边,得到 low_9=4

向上回溯到 1,更新 low。这都不在话下。

然后遍历 77 连着一条到 5 的横叉边,于是 low_7=2

继续往下遍历。(实际上 26 的祖先,但是这里没画好)

现在 low_6=8。向上回溯时发现,low_2=dfn_2(也就是说 2 的子树只能被困在 2 以下了。

于是把 \{2,6,10\} 三个点分出来,变成一个新的强连通分量(出栈)。

最后,把还在栈里面的点合成一个强连通分量。原因是 $low_1=dfn_1$。 得出结果:$\{2,6,10\}$ 和 $\{1,3,4,5,7,8,9\}$ 共两个强连通分量。 #### 代码实现 按照上面所说模拟即可。 ``` //ins 指点是否在栈中 void dfs(int now){ dfn[now]=++idx; low[now]=idx; ins[now]=true; st.push(now); for(int i=head[now];i;i=e[i].nxt){ int v=e[i].v; if(!dfn[v]){ dfs(v); low[now]=min(low[now],low[v]); } else if(ins[v])low[now]=min(low[now],dfn[v]); } if(dfn[now]==low[now]){ ++tot; while(1){ int v=st.top(); st.pop(); g[tot].push_back(v); ins[v]=false; bel[v]=tot; if(v==now)break; } } } ``` #### 题目练习 - [B3609 [图论与代数结构 701] 强连通分量](https://www.luogu.com.cn/problem/B3609) - [P2863 [USACO06JAN] The Cow Prom S](https://www.luogu.com.cn/problem/P2863) - [P1726 上白泽慧音](https://www.luogu.com.cn/problem/P1726) - [P1407 [国家集训队] 稳定婚姻](https://www.luogu.com.cn/problem/P1407) 这些题目全部在题单中有收录。 ### 缩点及 dp 缩点就是把在同一强连通分量中的点当成一个点来看待。 缩完点之后的图是一个 DAG。原因是:假如还有环,那么这个环上的点就是一个新的强连通分量。 给一些例题(例题全部在题单中有收录): - [P3387 【模板】缩点](https://www.luogu.com.cn/problem/P3387) - [P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G](https://www.luogu.com.cn/problem/P2341) - [SP14887 GOODA - Good Travels](https://www.luogu.com.cn/problem/SP14887) - [P3627 [APIO2009] 抢掠计划](https://www.luogu.com.cn/problem/P3627) - [P1073 [NOIP 2009 提高组] 最优贸易](https://www.luogu.com.cn/problem/P1073) - [P2515 [HAOI2010] 软件安装](https://www.luogu.com.cn/problem/P2515) - [P2272 [ZJOI2007] 最大半连通子图](https://www.luogu.com.cn/problem/P2272) ## 无向图的 Tarjan(双连通分量问题) ### 双连通分量、割点、割边的定义 对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的**割点**(又称割顶)。 ![](https://cdn.luogu.com.cn/upload/image_hosting/45530d73.png) 例如在上面这个图中,点 $2$ 就是一个割点。 如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者**割边**。 ![](https://cdn.luogu.com.cn/upload/image_hosting/bdjcf0ob.png) 例如在上面这个图中,边 $(2,8)$ 就是一条割边。 对于无向图中的点对 $(u,v)$,若 $(u,v)$ 之间去掉任何一个点(不包括 $u$ 和 $v$ 自己)都无法使得它们不连通,那他们是**点双连通**的。换句话说,$(u,v)$ 之间至少存在两条路径(设它们经过的点的集合分别为 $A$ 和 $B$,其中 $A$ 和 $B$ 均不包含 $u$ 和 $v$),使得 $A\cap B=\varnothing$。也就是说,存在两条 $(u,v)$ 之间的路径除了起终点一样之外,中间不共同经过任何一个点。注意:“换句话说”后面的话**并不严谨**(仅仅是为了方便理解“双”的含义),因为它并未考虑到一张图只有 $(u,v)$ 一条边的情况,你会发现按照定义它们依然是点双连通的。 对于无向图中的点对 $(u,v)$,若 $(u,v)$ 之间去掉任何一条边都无法使得它们不连通,那他们是**边双连通**的。换句话说,$(u,v)$ 之间至少存在两条路径(设它们经过的边的集合分别为 $A$ 和 $B$),使得 $A\cap B=\varnothing$。也就是说,存在两条 $(u,v)$ 之间的路径中间不共同经过任何一条边。 在一个无向图 $G$ 中,一个**点双连通分量** $A$ 应满足: - $A$ 是**极大的**。换句话说,不存在 $u\in G$ 且 $u\notin A$ 使得 $A\cup\{u\}$ 依然是一个点双连通分量。 - $\forall u,v\in A$ 且 $u\ne v$,$u$ 和 $v$ 是**点双连通**的。换句话说,在一个点双连通分量中随便抓两个不同的点,它们是点双连通的。 在一个无向图 $G$ 中,一个**边双连通分量** $A$ 应满足: - $A$ 是**极大的**。换句话说,不存在 $u\in G$ 且 $u\notin A$ 使得 $A\cup\{u\}$ 依然是一个边双连通分量。 - $\forall u,v\in A$ 且 $u\ne v$,$u$ 和 $v$ 是**边双连通**的。换句话说,在一个点双连通分量中随便抓两个不同的点,它们是边双连通的。 ### 无向图的 dfs 树 ![](https://cdn.luogu.com.cn/upload/image_hosting/iuj7t71j.png) 这是一张无向图。 ![](https://cdn.luogu.com.cn/upload/image_hosting/140zvqcq.png) 红色的边是 dfs 树上的边,而蓝色的边是返祖边。 什么?你说,为什么没有横叉边? 因为如果存在一条紫色的横叉边: ![](https://cdn.luogu.com.cn/upload/image_hosting/u1y9gte7.png) 那么这张图在 dfs 到 $4$ 的时候,就会直接 dfs 到 $6$,这条紫色边就变成树边了。 ![](https://cdn.luogu.com.cn/upload/image_hosting/4iml797p.png) ### 无向图中 low 的定义 注意到前文中,我说过:有向图和无向图中 $low$ 的定义不同。 事实上,无向图中,$low$ 的定义是:$low_u$ 是节点 $u$ 在 dfn 树中,自身或后代能通过树边和返祖边触及到的 $dfn$ 值的最小值。 ### 割边和边双连通分量的求法 #### 文字说明 边双连通分量相对简单的原因是:一个点不会出现在多个边双连通分量中。 我们简单描述一下用 Tarjan 算法解决割边和边双连通分量问题的过程: 1. 初始化,同有向图。 2. 枚举 $u$ 的邻居 $v$。 - 显然对 $u$ 的儿子 $v$ 有 $low_u\leftarrow\min(low_u,low_v)$。 - 如果 $v$ 是 $u$ 的邻居但不是 $u$ 的儿子且不是 $u$ 的父亲,那么 $(u,v)$ 就是返祖边。根据定义,$low_u\leftarrow\min(low_u,dfn_v)$。 3. 如果发现 $low_u=dfn_u$,那么连接 $u$ 和其祖先的边就是割边,$u$ 及其还在栈中的子树就构成一个边双连通分量。 #### 图解 举个例子(点左侧的红色数字为 $dfn$,右侧的蓝色数字为 $low$): ![](https://cdn.luogu.com.cn/upload/image_hosting/iuj7t71j.png) 首先遍历 $1,2,3$,求 dfs 序。 ![](https://cdn.luogu.com.cn/upload/image_hosting/nt2hxz0k.png) 到 $3$ 时,发现 $low_3=dfn_3$,于是 $3$ 与其祖先的这一条边就是割边,$3$ 自己就是一个边双连通分量。 ![](https://cdn.luogu.com.cn/upload/image_hosting/ovebllbo.png) 然后回溯到 $2$,再 dfs 到 $4$,记录 $low_4=1$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/quic0mi8.png) 回溯到 $2$,记录 $low_2=1$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/uhvqgh1o.png) 回溯到 $1$,然后遍历 $5,6,7$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/qtsk0ykg.png) 发现 $low_7=dfn_7$。于是 $7$ 出栈,自己成为一个边双连通分量。$7$ 与其祖先的这一条边就是割边。 ![](https://cdn.luogu.com.cn/upload/image_hosting/sxfzrhnq.png) 回溯到 $6$,遍历 $8$,记录 $low_8=5$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/rxgi6w4g.png) 继续回溯,发现 $low_5=dfn_5$,于是 $\{5,6,8\}$ 一起出栈。$5$ 与其祖先的边成为割边。 ![](https://cdn.luogu.com.cn/upload/image_hosting/5vw5e133.png) 这样最后得到 $low_1=1$,然后把 $\{1,2,4\}$ 出栈。 ![](https://cdn.luogu.com.cn/upload/image_hosting/z3wmsv3w.png) 这样得到四个边双连通分量:$\{3\}$、$\{7\}$、$\{1,2,4\}$、$\{5,6,8\}$。 割边有三条,即下图中红色的边。 ![](https://cdn.luogu.com.cn/upload/image_hosting/6njg8dy9.png) 完成! #### 代码实现 ``` void dfs(int now,int fa){ dfn[now]=++idx;low[now]=idx; st.push(now); for(int i=head[now];i;i=e[i].nxt){ if(!dfn[e[i].v]){ dfs(e[i].v,e[i].id); low[now]=min(low[now],low[e[i].v]); } else{ if(e[i].id!=fa)low[now]=min(low[now],dfn[e[i].v]); } } if(low[now]==dfn[now]){ c.clear(); tot++; while(!st.empty()){ int v=st.top();st.pop(); c.push_back(v); if(v==now)break; } sort(c.begin(),c.end()); for(int i=0;i<c.size();i++){ g[tot].push_back(c[i]); } //如果你要求割边,这里直接记录 fa 是割边即可 } } ``` #### 题目练习 - [P8436 【模板】边双连通分量](https://www.luogu.com.cn/problem/P8436) - [P2860 [USACO06JAN] Redundant Paths G](https://www.luogu.com.cn/problem/P2860) - [P2783 有机化学之神偶尔会做作弊](https://www.luogu.com.cn/problem/P2783) 这些题也在配套题单中有收录。 割边在主题库没有模板,这里提供一道私题:[U582665](https://www.luogu.com.cn/problem/U582665)。 ### 割点和点双连通分量的求法 这一部分比较复杂,尤其是点双连通分量。因为一个点可以出现在多个点双连通分量中。 #### 点双连通分量的性质 - 两个点双最多只有一个公共点,且一定是割点。 - 对于一个点双,它在 dfs 搜索树中 $dfn$ 值最小的点一定是割点或者树根。 在接下来的讲解中,你可以验证这两个性质。 #### 文字叙述 我们利用 Tarjan 算法求出一张图的割点(点双连通分量)的过程如下: 1. 遍历 $u$,初始化。 2. 遍历 $u$ 的邻居 $v$。 - 如果 $v$ 是 $u$ 的儿子,那么遍历 $v$ 来求出 $low_v$。如果 $low_v\ge dfn_u$,说明 $u$ 是一个割点。然后弹空 $u$ 还残存在栈中的子树(注意!不要把 $u$ 弹出去),这些点构成一个点双连通分量。 - 如果不是,那么就按无向图的 $low$ 定义处理 $low_u\leftarrow\min(low_u,dfn_v)$。 3. 这一步割点和点双略有差异。 - 点双:这个简单。只要判断 $u$ 这个点是不是单独一个连通块就可以了(如果是那 $u$ 自己就是一个点双)。 - 割点:稍微难办一点:我们在第二步记录一个儿子的数量。如果 $u$ 是根节点,那么它必须有 $2$ 个儿子才是一个割点;否则因为都有父亲,所以有 $1$ 个儿子就行。 #### 图解 文字叙述还是太过于抽象了。我们用图来描述一下这个过程(点左侧的红色数字为 $dfn$,右侧的蓝色数字为 $low$)。 ![](https://cdn.luogu.com.cn/upload/image_hosting/uts5zamd.png) 首先,遍历点 $1,2,3,4$,得到 $low_4=2$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/k17w20rn.png) 回溯到 $2$。发现 $low_2=2$,$2$ 是割点,于是把 $3,4$ 弹出栈,$\{2,3,4\}$ 构成一个点双连通分量。 ![](https://cdn.luogu.com.cn/upload/image_hosting/a4dio1vf.png) 然后回溯到 $1$。发现 $low_2\ge dfn_1$,于是把 $2$ 弹出栈,$\{1,2\}$ 构成一个点双连通分量。 然后继续遍历 $5,6,7,8,9$,处理出 $low_9=7$。 ![](https://cdn.luogu.com.cn/upload/image_hosting/mrho1k94.png) 回溯发现 $low_8\ge dfn_7$,所以 $8,9$ 出栈,$7$ 是割点,$\{7,8,9\}$ 构成一个点双连通分量。 ![](https://cdn.luogu.com.cn/upload/image_hosting/u8mqf249.png) 继续回溯,发现 $low_7\ge dfn_6$,于是 $7$ 出栈,$\{6,7\}$ 为一个点双连通分量($6$ 是割点)。 ![](https://cdn.luogu.com.cn/upload/image_hosting/g268havi.png) 遍历点 $10$,处理 $low_{10}=5$。 然后回溯($6$ 到 $10$ 这一块图比较抽象,见谅)。 ![](https://cdn.luogu.com.cn/upload/image_hosting/ifmvfilm.png) 注意到 $low_6\ge dfn_5$,于是 $6,10$ 出栈,$\{5,6,10\}$ 是一个新的点双连通分量($5$ 是割点)。 继续回溯,发现 $low_5\ge dfn_1$,于是 $5$ 出栈,$\{1,5\}$ 是一个新的点双连通分量。由于 $1$ 有多于 $2$ 个儿子,所以 $1$ 是割点。 ![](https://cdn.luogu.com.cn/upload/image_hosting/sjc052p2.png) 完成! 得到 $6$ 个点双连通分量: - $\{2,3,4\}$; - $\{1,2\}$; - $\{1,5\}$; - $\{5,6,10\}$; - $\{6,7\}$; - $\{7,8,9\}$。 #### 代码实现 ##### 割点 ``` void tarjan(int u,int fa){ low[u]=dfn[u]=++num; int sum=0; for(int i=head[u];i;i=nxt[i]){ int v=to[i]; if(v==fa)continue; if(!dfn[v]){ tarjan(v,u); if(low[v]>=dfn[u]){ sum++; } low[u]=min(low[u],low[v]); } else low[u]=min(low[u],dfn[v]); } if(u==root)sum--; if(sum>0)fl[u]=1,ans++; return; } ``` ##### 点双 ``` void dfs(int now,int fa){ int u=now,son=0; low[u]=++idx;dfn[u]=idx; st.push(u); for(int i=head[now],v;i;i=e[i].nxt){ v=e[i].v; if(!dfn[v]){ dfs(v,now); son++; low[u]=min(low[u],low[v]); if(dfn[u]<=low[v]){ tot++; while(!st.empty()){ g[tot].push_back(st.top()); if(st.top()==v){ st.pop(); break; } st.pop(); } g[tot].push_back(u); } } else{ if(v!=fa)low[u]=min(low[u],dfn[v]); } } if(fa==0&&son==0){ g[++tot].push_back(u); } } ``` #### 例题 割点: - [P3388 【模板】割点(割顶)](https://www.luogu.com.cn/problem/P3388) - [UVA315 Network](https://www.luogu.com.cn/problem/UVA315) - [P3469 [POI 2008] BLO-Blockade](https://www.luogu.com.cn/problem/P3469) - [P3225 [HNOI2012] 矿场搭建](https://www.luogu.com.cn/problem/P3225) 点双连通分量: - [P8435 【模板】点双连通分量](https://www.luogu.com.cn/problem/P8435) - [B3610 [图论与代数结构 801] 无向图的块](https://www.luogu.com.cn/problem/B3610) 这些题目也是收录在配套题单中。 ## 写在后面 本文章耗时两个晚上,共 $11150$ 字符,$471$ 行。 这篇文章不只是为了 [【图论】Tarjan 入门](https://www.luogu.com.cn/training/576466) 而写,更是为了在 CSP/NOIP 等比赛中赛前快速理解 Tarjan。 ~~如果你觉得有收获,那就请留个赞再走吧。~~