Tarjan 算法全家桶
Robert E. Tarjan(罗伯特·塔扬,1948~),生于美国加州波莫纳,计算机科学家。
Tarjan 发明了很多算法和数据结构。不少他发明的算法都以他的名字命名,以至于有时会让人混淆几种不同的算法。比如求各种连通分量的 Tarjan 算法,求 LCA(Lowest Common Ancestor,最近公共祖先)的 Tarjan 算法。并查集、Splay、Toptree 也是 Tarjan 发明的。
摘自 OI-Wiki
一、强连通分量
0、有向图的 DFS 生成树
(1) DFS 序
\(u\) 的 DFS 序即为从某节点(根)开始遍历,首次到达 \(u\) 的时间戳。
(2) 四条边
- 树边:DFS 树上的边。
- 返祖边:指向祖先节点的边。
- 前向边:指向子孙节点的边。
- 横叉边:指向的访问过的节点既不是自己的祖先节点也不是自己的子孙。
如下图,黑边为树边,绿边为返祖边,粉边为前向边,红边为横叉边。
1. 基本概念
- 强连通:在有向图 \(G\) 中,任意两点联通。
- 强连通分量(SCC):有向图 \(G\) 中,极大的强连通子图。对于“极大”,意思是这个子图无法再加入任意一点使其仍为强连通的。
- SCC 的根:遍历这个 SCC 的第一个点
1.5 约定
- \(dfn_u\):遍历到 \(u\) 的时间戳。
- \(low_u\):以 \(u\) 为根的子树可以回溯到最早已经在栈中的节点的时间戳。
2. 求法
Trick
一个 SCC 的所有节点必定在其根的子树中。
我们于 dfs 过程中维护一个栈,里面是未处理的节点。
对于 \(u\),我们枚举每一条 \(u\) 出边 \((u,v)\)。
- 若 \(v\) 未访问。我们向 \(v\) 遍历,并更新 \(low_u\gets\min(low_u,low_v)\)。
- 若 \(v\) 已访问,且在栈内。\(low_u\gets\min(low_u,dfn_v)\)。
- 若 \(v\) 已访问,不在栈内。那么 \(v\) 所在 SCC 已经处理完毕,不操作。
值得注意的是,有时在写强连通分量时,我们误将 \(low_u\gets\min(low_u,dfn_v)\) 写作 \(low_u\gets\min(low_u,low_v)\),虽然运行结果正确。然而这实际上不符合我们推导以及思考过程,在求双连通分量中这样是错误的。因此需要注意这个问题。
对于一个 SCC,有且仅有一个点使得 \(low_u=dfn_u\)。
简证
存在性:假设 SCC 根节点为 $R$,那么必然有 $dfn_R=low_R$。这是因为如若 $low_R < dfn_R$,那么说明存在点 $v$ 使 $v$ 加入当前 SCC 点集后仍然是一个 SCC,与定义“极大的”矛盾。
唯一性:除根节点外的所有子节点,必有 $low_u< dfn_u$。这是由于所有子节点都可以回溯到时间戳早于它的节点。
我们只需找到这样的点,将这个点以及栈内所有晚于这个点入栈的点弹出,所构成的即为一个 SCC。
void tarjan (int x) {
dfn[x] = low[x] = ++ tim;
inst[x] = true;
st.push (x);
for (auto to : e[x]) {
if (!dfn[to]) tarjan (to), low[x] = min (low[x], low[to]);
else if (inst[to]) low[x] = min (low[x], dfn[to]);
}
if (dfn[x] == low[x]) {
int tp;
ans.push_back ({});
do {
tp = st.top (); st.pop ();
ans.back ().push_back (tp);
id[tp] = ans.size ();
inst[tp] = false;
} while (tp != x);
}
}
这整个过程的时间复杂度非常优秀,为 \(\mathcal O(n+m)\)。
3. 例题
P3387 【模板】缩点
模板题。
我们可以发现,在经过缩点操作后的新图是一个有向无环图,故可以在新图上进行 DP 操作。
P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G
对原图缩点后,原图中属于出度为 \(0\) 的那个点的所有点即为答案。
P2812 校园网络【[USACO]Network of Schools加强版】
缩点,第一个答案为新图入度为 \(0\) 点的个数,第二个答案为入度为 \(0\) 点个数与出度为 \(0\) 点个数的最大值。
P4306 [JSOI2010] 连通数
缩点后建反图,拓扑 DP 即可。
另解:用 bitset 优化的传递闭包。
P2272 [ZJOI2007] 最大半连通子图
原图上是存在环的,所以我们先缩点,得到一张 DAG。根据半连通的定义,我们从入度为 \(0\) 的点开始 DP。设 \(c_i\) 表示 SCC \(i\) 所含节点数,\(f_i\) 表示到 \(i\) 的最大半连通子图节点数,\(g_i\) 表示方案数。那么对于边 \((u,v)\),\(f_v\gets \max(f_v,f_u+c_v)\),\(g_v\gets\begin{cases}g_u,& f_v<f_u+c_v\\g_u+g_v,&f_v=f_u+c_i\end{cases}\)。
二、双联通分量,割点与桥
0. 无向图的 DFS 生成树
这部分十分简单,图上边分为树边与非树边。顾名思义。
1. 基本概念
- 割点:无向连通图 \(G\) 中,若删除 \(u\) 使得 \(G\) 不再连通,那么 \(u\) 就是 \(G\) 的一个割点。
- 桥(割边):无向连通图 \(G\) 中,若删除边 \(e\) 使得 \(G\) 不再连通,那么 \(e\) 就是 \(G\) 的一个桥。
- 边双连通:若无向连通图 \(G\) 不存在桥,那么 \(G\) 就是边双连通的。
- 点双连通:若无向连通图 \(G\) 不存在割点,那么 \(G\) 就是点双连通的。
- 边双连通分量:若 \(G'\) 是无向图 \(G\) 的 极大 的边双联通子图,那么 \(G'\) 就是 \(G\) 的一个边双连通分量。下文简称为“边双”。
- 点双连通分量:若 \(G'\) 是无向图 \(G\) 的 极大 的点双联通子图,那么 \(G'\) 就是 \(G\) 的一个点双连通分量。下文简称为“点双”。
这里的“极大”与强连通分量中含义一致。
1.5 约定
- \(dfn_u\):遍历到 \(u\) 的时间戳。
- \(low_u\):以 \(u\) 为根的子树通过非树边可到达的最小时间戳。
- \(subtree_u\):以 \(u\) 为根的子树。
2. 割点 & 点双联通分量
(1) 求割点
类似的,我们考虑 \(dfn\) 与 \(low\) 的关系。
对于点 \(u\) ,若存在 \(v\in subtree_u\) 使得 \(low_v\ge dfn_u\),也就是说 \(v\) 无法通过非树边到达 \(u\) 的祖先,那么 \(u\) 即为一个割点。特别的,当 \(u\) 为搜索起始点时,若其仅有一个子节点,那么删除 \(u\) 并不会增加图的连通子图数,因此并不会成为割点;反之,若其多于一个子节点,那么其必然为割点。
简证
命题:$\exist v\in subtree_u,low_v\ge dfn_u\iff u$ 是割点。若 \(v\in subtree_u\),那么 \(v\) 是第一次被遍历,且由 \(u\) 而来。且对于其到达不在根到 \(u\) 路径上的节点的非树边,到达的时间戳必然是大于其本身的。因此当 \(low_v\ge dfn_u\) 时,\(v\) 必然无法通过非树边到达 \(u\) 的祖先。
如若存在这样的 \(v\),但 \(u\) 不是割点。由于 \(v\) 无法通过非树边到达 \(u\) 的祖先,故 \(v\) 必须通过 \(u\) 到达其余点,因此 \(u\) 必然为割点。故充分性成立。
如若 \(u\) 为割点,假设不存在这样的 \(v\):那么所有 \(v\) 必然经过 \(u\) 到达小于 \(low_u\) 的点。然而根据 dfs 的性质,我们发现连接 \(u\) 的非树边的 \(dfn\) 值是必然大于 \(u\) 的,同样的,向上遍历亦如此。故必要性成立。
void tarjan (int x, int root) {
dfn[x] = low[x] = ++ tim;
int son = 0;
for (auto to : g[x]) {
if (!dfn[to]) {
tarjan (to, root);
low[x] = min (low[x], low[to]);
if (x != root && low[to] >= dfn[x]) cut[x] = true;
if (x == root) ++ son;
} else low[x] = min (low[x], dfn[to]);
}
if (x == root && son >= 2) cut[x] = true;
ans += cut[x];
}
(2) 求点双连通分量
由于点双中不存在割点,因此我们类比求割点的做法,就可以推出点双的做法。
同样的,我们维护一个栈。对于 \(u\),我们如果枚举到了 \(v\) 使得 \(low_v\ge dfn_u\),那么 \(u\) 即为割点。并不断弹出栈内元素直至栈顶为 \(u\),这些部分构成一个点双。值得注意的,对于根节点,如若没有子树,则它就单独构成一个点双;如若有多个子树,那么就是一个割点;如若只有一个子树,那么他就是所在点双的根。
void tarjan (int x, int root) {
dfn[x] = low[x] = ++ tim;
st.push (x);
int son = 0;
for (auto to : e[x]) {
if (!dfn[to]) {
++ son;
tarjan (to, root);
low[x] = min (low[x], low[to]);
if (low[to] >= dfn[x] ) {
int tp;
ans.push_back ({});
do {
tp = st.top (); st.pop ();
ans.back ().push_back (tp);
} while (tp != to);
ans.back ().push_back (x);
}
} else low[x] = min (low[x], dfn[to]);
}
if (root == x && !son) {
ans.push_back ({});
ans.back ().push_back (x);
return ;
}
}
3. 桥 & 边双连通分量
(1) 桥
我们只需将割点的判定条件改为 \(low_v>dfn_u\) 即可。证明与上类似。值得注意的是,若 \((u,v)\) 仅有一条边时为桥,多于一条边时,需要特殊判定。若 \(v\) 多次枚举到 \(u\),那么不为桥。我们只需加上特判即可。
void tarjan (int u, int fa) {
dfn[u] = low[u] = ++ tim;
bool flag = false;
for (int i = head[u]; i; i = nxt[i]]) {
int v = to[i];
if (!dfn[v]) {
tarjan (v, u);
low[u] = min (low[v], low[u]);
if (low[v] > dfn[u]) isbridge[i] = isbridge[i ^ 1] = true;
} else {
if (flag || v != fa) low[u] = min (low[u], dfn[v]);
else flag = true;
}
}
}
(2) 边双连通分量
我们先把所有桥求出,后再次进行 dfs。具体的,我们枚举每个未被枚举的点 \(u\),从 \(u\) 开始搜索,将所有不经过桥所连点加入当前的边双集合。这样就得到了全体的边双集合。
void dfs (int x) {
E_BCC[id].push_back (x);
vis[x] = id;
for (int i = head[x]; i; i = nxt[i]) {
int v = to[i];
if (vis[v] || isbridge[i]) continue;
dfs (v);
}
}
实际上也有另一种简便的求法,与求强连通分量的过程十分相似(实际上一致)。
void tarjan (int u, int id) {
dfn[u] = low[u] = ++ tim;
st[++ tp] = u; inst[u] = true;
for (int i = head[u]; i; i = e[i].nxt) {
if (i == (id ^ 1)) continue;
int v = e[i].v;
if (!dfn[v]) tarjan (v, i), low[u] = min (low[u], low[v]);
else if (inst[v]) low[u] = min (low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
++ coll;
do {
col[ st[tp] ] = coll;
inst[ st[tp] ] = false;
} while (st[tp --] != u);
}
}
4. 例题
P8435 【模板】点双连通分量 & P8436 【模板】边双连通分量 & P3388 【模板】割点(割顶)
模板题。
P3225 [HNOI2012] 矿场搭建
我们对每一个点双讨论:
- 若当前点双不存在割点,则这个点双不与外界相连,因此只能从其本身逃出。那么至少需要两个出口(防止其中之一被炸了)。
- 若当前点双存在一个割点,割点可能会被炸,因此我们还需要再这个点双内放置一个出口。
- 若当前点双存在不少于两个割点,那么任意一个点爆炸都可以从其他割点逃出。因此无需放置。
那么做法就十分显然了。设出口数为 \(cnt\),方案数为 \(ans\),当前点双的点数为 \(n\)。对于度数为 \(0\) 的点双,\(cnt\gets cnt+2\),\(ans\gets ans\times n\times (n-1)\times\dfrac12\)。对于度数为 \(1\) 的点双,\(cnt\gets cnt+1\),\(ans\gets ans\times (n-1)\)(根据前面的讨论,割点是不能当作出口的)。对于其他度数的点双,我们无需统计。
P8867 [NOIP2022] 建造军营
首先进行边双缩点,后形成一个树结构,两点之间的连边为桥。考虑树形 DP。

浙公网安备 33010602011771号