浅谈 Tarjan 算法

简述

对于初学 Tarjan 的你来说,肯定和我一开始学 Tarjan 一样无比迷茫。

网上大框大框的定义就足以让一个萌新从入门到入土自闭。

所以本文决定不在这里对于 Tarjan 的定义和原理做过多介绍。(当然如果还是无法理解可以尝试直接理解代码)

注意&特别鸣谢:这篇文章,本文也有多处讲解与图片转自此文

作用

其实这都是后话了...。(毕竟你不会这个东西知道它的作用也没什么用)

下面是一段令人窒息的专业定义,萌新慎入:

  1. 在有向图G中,如果两个顶点间至少存在一条路径,称两个顶点强连通(strongly connected)。
  2. 如果有向图G的每两个顶点都强连通,称G是一个强连通图
  3. 非强连通图有向图的极大强连通子图,称为强连通分量(strongly connected components)。

所以 Tarjan 的基础用处就是:求一个图的强连通分量

举个栗子:

下图中,子图 \(\{1,2,3,4\}\) 为一个强连通分量,因为顶点 \(1,2,3,4\) 两两可达。\(\{5\},\{6\}\)也分别是两个强连通分量。

让我们带着疑惑和不解去探索 Tarjan 的奥秘吧。

Tarjan 算法

原理

Tarjan 算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。

搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。

当然,主要还是靠下面两个标记。

出场人物

其实就是用到了哪几个变量。

  1. \(Dfn(u)\):为节点u搜索的次序编号(时间戳)。
  2. \(Low(u)\):为 \(u\)\(u\) 的子树能够追溯到的最早的栈中节点的次序号(人话:\(u\) 的子树以及其连向的节点中 \(dfn[]\) 的最小值)。

由定义可以得出:

Low(u)=Min
{
    DFN(u),
    Low(v),(u,v)为树枝边,u为v的父节点
    DFN(v),(u,v)为指向栈中节点的后向边(非横叉边)
}

\(Dfn(u)=Low(u)\) 时,以 \(u\) 为根的搜索子树上所有节点是一个强连通分量。

感性理解一下,当一个集合 \(S\) 构成强连通分量时,它们的祖先节点肯定是 \(dfn[]\) 最小的。(遍历时间最早)

那么除了这个祖先节点之外,其他所有节点的 \(low[]\) 肯定都会被刷新。

所以唯一不变的就是祖先节点,找到祖先节点也就找到了整个强连通分量,而栈中的残留数字都属于 \(S\) 集合。

图示

从节点 \(1\) 开始 DFS,把遍历到的节点加入栈中。

搜索到节点 \(u=6\) 时,\(Dfn[6]=Low[6]\),找到了一个强连通分量。

退栈到 \(u=v\) 为止,\(\{6\}\)为一个强连通分量。

返回节点 \(5\),发现 \(Dfn[5]=Low[5]\),退栈后 \(\{5\}\) 为一个强连通分量。

返回节点 \(3\),继续搜索到节点 \(4\),把 \(4\) 加入堆栈。

发现节点 \(4\) 向节点 \(1\) 有后向边,节点 \(1\) 还在栈中,所以 \(Low[4]=1\)

节点 \(6\) 已经出栈,\((4,6)\) 是横叉边,返回 \(3\)\((3,4)\) 为树枝边,所以 \(Low[3]=Low[4]=1\)

继续回到节点 \(1\),最后访问节点 \(2\)。访问边 \((2,4)\)\(4\) 还在栈中,所以 \(Low[2]=Dfn[4]=5\)

返回 \(1\) 后,发现 \(Dfn[1]=Low[1]\),把栈中节点全部取出,组成一个连通分量 \(\{1,3,4,2\}\)

至此,算法结束。经过该算法,求出了图中全部的三个强连通分量 \(\{1,3,4,2\},\{5\},\{6\}\)

可以发现,运行 Tarjan 算法的过程中,每个顶点都被访问了一次,且只进出了一次堆栈,每条边也只被访问了一次,所以该算法的时间复杂度为 \(O(N+M)\)

代码实现

代码实现很简单(前提是你看懂了上面的讲解,也不排除很多同学可以直接通过代码加深理解)。

//co[] 表示这个强联通分量的“颜色”,sum[] 表示这个“颜色”的强连通分量的节点个数。
//num 是时间戳,tot 是栈顶,col 是“颜色”总数。
void tarjan(int u){
	dfn[u]=low[u]=++num;//打上时间戳,并同时给 low[u] 赋初值。
	s[++tot]=u;//进栈。
	for(int i=head[u];i;i=ed[i].nxt){
		int v=ed[i].to;
		if(!dfn[v])//如果没有遍历过(即这是一条树枝边)。
			tarjan(v),low[u]=min(low[u],low[v]);
		else if(!co[v])//遍历过(即这是一条返祖变或横向边)。
			low[u]=min(low[u],dfn[v]);
	}
	if(dfn[u]==low[u]){//找到啦。
		co[u]=++col,sum[col]=a[u];
		while(u!=s[tot])
			sum[col]+=a[s[tot]],co[s[tot]]=col,--tot;
		--tot;
	}
	return;
}

for(int i=1;i<=n;i++)
    if(!dfn[i]) tarjan(i);

例题

多做例题通常是了解新算法的重要过程,建议读者完全消化上面的模板后在食用。

题目都不难,而且十分适合初学者。

例题一

受欢迎的牛

简化题意:

有一个包括 \(N\) 个点,\(M\) 条边的有向图,求所有节点都能到达的节点的个数。如果没有,则输出 0。

分析题目,这样的节点只可能出现在唯一一个出度为 0 的强联通分量内。

若出现两个以上出度为 0 的强连通分量则不存在这样的节点,因为那几个出度为零的分量无法传递出去。

而分量内的节点一定是可以相互到达的。

那么题目就变为求:图中出度为 0 的强联通分量的节点总数,如果有多个这样的强连通分量则输出 0

标准的 Tarjan 模板题。

#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<iostream>
#define N 10010
#define M 50010
using namespace std;

int n,m,head[N],cnt=0;
int col=0,num=0;
int co[N],dfn[N],low[N],s[N],de[N],sum[N];
struct Edge{
	int nxt,to;
}ed[M];

int read(){
	int x=0,f=1;char c=getchar();
	while(c<'0' || c>'9') f=(c=='-')?-1:1,c=getchar();
	while(c>='0' && c<='9') x=x*10+c-48,c=getchar();
	return x*f;
}

void add(int u,int v){
	cnt++;
	ed[cnt].nxt=head[u];
	ed[cnt].to=v;
	head[u]=cnt;
	return;
}

int tot=0;

void tarjan(int u){
	dfn[u]=low[u]=++num;
	tot++;
	s[tot]=u;
	for(int i=head[u];i;i=ed[i].nxt){
		int v=ed[i].to;
		if(!dfn[v])
			tarjan(v),low[u]=min(low[u],low[v]);
		else if(!co[v])
			low[u]=min(low[u],dfn[v]);
	}
	if(low[u]==dfn[u]){
		co[u]=++col;
		++sum[col];
		while(s[tot]!=u)
			++sum[col],co[s[tot]]=col,--tot;
		--tot;
	}
	return;
}

int main(){
	n=read(),m=read();
	int u,v;
	for(int i=1;i<=m;i++)
		u=read(),v=read(),add(u,v);
	for(int i=1;i<=n;i++)
		if(!dfn[i]) tarjan(i);
	int ans,k=0;
	for(int i=1;i<=n;i++)
		for(int j=head[i];j;j=ed[j].nxt)
			if(co[i]!=co[ed[j].to])
				de[co[i]]++;
	for(int i=1;i<=col;i++)
		if(!de[i])
			ans=sum[i],k++;
	if(k==1) printf("%d\n",ans);
	else puts("0");
	return 0;
}

例题二

最大半连通子图

对于初学者来说可能有一定的难度,码量也是 100+ 行。

简单来说,这道题是有向图中常用的套路:Tarjan + 拓扑排序 + DP

定义挺长的,理解起来比较困难,但是读懂后就发现是水题了:缩点后求有向图中的最长链

我们先用模板式的 Tarjan 把图进行缩点。

然后我们对这个缩过点的图重新进行建边(注意:重新建图需要判重边,否则最后最长链的数量会受到影响)。

最后我们拓扑加 DP 把最长链的节点的数量和最长链的数量求出。

#include <cstdio>
#include <cmath>
#include <cstring>
#include <algorithm>
#include <iostream>
#include <queue>
#define N 100010
#define M 1000010
using namespace std;

int n, m, MOD, head[N], cnt = 0;
int low[N], dfn[N], s[N], co[N], sum[N];
int tot = 0, numm = 0, col = 0;
struct Edge {
    int nxt, to;
} ed[M];

int read() {
    int x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
    while (c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
    return x * f;
}

void add(int u, int v) {
    ++cnt;
    ed[cnt].nxt = head[u];
    ed[cnt].to = v;
    head[u] = cnt;
    return;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++numm;
    s[++tot] = u;
    for (int i = head[u]; i; i = ed[i].nxt) {
        int v = ed[i].to;
        if (!dfn[v])
            tarjan(v), low[u] = min(low[u], low[v]);
        else if (!co[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (low[u] == dfn[u]) {
        co[u] = ++col;
        ++sum[col];
        while (s[tot] != u) ++sum[col], co[s[tot]] = col, --tot;
        --tot;
    }
    return;
}

int nu[M], x[M], y[M], ru[N];

bool cmp(int a, int b) {
    if (x[a] != x[b])
        return x[a] < x[b];
    return y[a] < y[b];
}

void remove() {
    for (int i = 1; i <= m; i++) {
        nu[i] = i;
        x[i] = co[x[i]];
        y[i] = co[y[i]];
    }
    sort(nu + 1, nu + m + 1, cmp);
    cnt = 0;
    memset(head, 0, sizeof(head));
    memset(ed, 0, sizeof(ed));
    for (int i = 1; i <= m; i++) {
        int z = nu[i];
        if (x[z] != y[z] && (x[z] != x[nu[i - 1]] || y[z] != y[nu[i - 1]])) {
            add(x[z], y[z]);
            ++ru[y[z]];
        }
    }
    return;
}

int dis[N], num[N], ans;

void topo() {
    queue<int> q;
    for (int i = 1; i <= col; i++) {
        if (!ru[i]) {
            q.push(i), dis[i] = sum[i], num[i] = 1;
            if (dis[ans] < dis[i])
                ans = i;
        }
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = head[u]; i; i = ed[i].nxt) {
            int v = ed[i].to;
            if (dis[v] < dis[u] + sum[v]) {
                dis[v] = dis[u] + sum[v];
                num[v] = 0;
                if (dis[ans] < dis[v])
                    ans = v;
            }
            if (dis[v] == dis[u] + sum[v])
                num[v] = (num[v] + num[u]) % MOD;
            if (!--ru[v])
                q.push(v);
        }
    }
    return;
}

int anss;

void ask() {
    for (int i = 1; i <= n; i++)
        if (dis[i] == dis[ans])
            anss = (anss + num[i]) % MOD;
}

int main() {
    n = read(), m = read(), MOD = read();
    int u, v;
    for (int i = 1; i <= m; i++) {
        x[i] = read(), y[i] = read();
        add(x[i], y[i]);
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i);
    remove();
    topo();
    ask();
    printf("%d\n%d\n", dis[ans], anss);
    return 0;
}

例题三

网络协议

Tarjan 和 DAG 是有不解之缘的,所以经常通过 DAG 的某些性质来做题。

其实如果没有第二问的话,这道题就十分的简单了。

  1. 第一问:Tarjan 缩点后寻找入度为 0 的强连通分量的个数。
  2. 第二问:\(max(sum_{入度为 0 的强连通分量},sum_{出度为 0 的强连通分量})\)

那么第二问的为什么可以这么求呢?

定理:将 DAG 变为有向连通图的最小方案数为 \(max(sum_{入度为 0 的强连通分量},sum_{出度为 0 的强连通分量})\)

构造:连接 \(min(sum_{入度为 0 的强连通分量},sum_{出度为 0 的强连通分量})\) 个入度为 0 与出度为 0 的强连通分量构成环,

然后再随意连接剩余的入度为 0 或出度为 0 的强连通分量到环上任意节点。

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <iostream>
#include <queue>
#define N 110
using namespace std;

int n, head[N], cnt = 0;
int low[N], dfn[N], s[N], co[N], sum[N], col = 0, tot = 0, num = 0;
struct Edge {
    int nxt, to;
} ed[N * N];

int read() {
    int x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
    while (c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
    return x * f;
}

void add(int u, int v) {
    ed[++cnt].nxt = head[u];
    ed[cnt].to = v;
    head[u] = cnt;
    return;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++num;
    s[++tot] = u;
    for (int i = head[u]; i; i = ed[i].nxt) {
        int v = ed[i].to;
        if (!dfn[v])
            tarjan(v), low[u] = min(low[u], low[v]);
        else if (!co[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (low[u] == dfn[u]) {
        co[u] = ++col;
        ++sum[col];
        while (s[tot] != u) ++sum[col], co[s[tot]] = col, --tot;
        --tot;
    }
    return;
}

int ru[N], cu[N];

int main() {
    n = read();
    int v;
    for (int u = 1; u <= n; u++) {
        v = read();
        while (v) add(u, v), v = read();
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i);
    if (col == 1) {
        printf("1\n0\n");
        return 0;
    }
    for (int u = 1; u <= n; u++)
        for (int i = head[u]; i; i = ed[i].nxt) {
            int v = ed[i].to;
            if (co[u] != co[v])
                ++cu[co[u]], ++ru[co[v]];
        }
    int a = 0, b = 0;
    for (int i = 1; i <= col; i++) {
        if (!ru[i])
            ++a;
        if (!cu[i])
            ++b;
    }
    printf("%d\n%d\n", a, max(a, b));
    return 0;
}

例题四

间谍网络

这道题没有看上去那么简单,与普通缩点题唯一的不同是它加上了限制条件。

那怎么办?其实我们在循环是也可以加上限制条件,同时通过 \(dfn[]\) 判重(具体见代码)。

#include <cstdio>
#include <algorithm>
#include <cstring>
#include <iostream>
#include <cmath>
#define N 3010
#define M 8010
using namespace std;

int n, m1, m2, head[N], cnt = 0, cost[N];
int low[N], dfn[N], co[N], s[N], ru[N], sum[N];
int col = 0, tot = 0, num = 0;
struct Edge {
    int nxt, to;
} ed[M];

int read() {
    int x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
    while (c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
    return x * f;
}

void add(int u, int v) {
    ed[++cnt].nxt = head[u];
    ed[cnt].to = v;
    head[u] = cnt;
    return;
}

void init() {
    n = read(), m1 = read();
    int u, v;
    memset(cost, 0x3f, sizeof(cost));
    memset(sum, 0x3f, sizeof(sum));
    for (int i = 1; i <= m1; i++) u = read(), v = read(), cost[u] = v;
    m2 = read();
    for (int i = 1; i <= m2; i++) u = read(), v = read(), add(u, v);
    return;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++num;
    s[++tot] = u;
    for (int i = head[u]; i; i = ed[i].nxt) {
        int v = ed[i].to;
        if (!dfn[v])
            tarjan(v), low[u] = min(low[u], low[v]);
        else if (!co[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (dfn[u] == low[u]) {
        co[u] = ++col, sum[col] = cost[u];
        while (s[tot] != u) sum[col] = min(sum[col], cost[s[tot]]), co[s[tot]] = col, --tot;
        --tot;
    }
    return;
}

int main() {
    init();
    for (int i = 1; i <= n; i++)
        if (!dfn[i] && cost[i] != 0x3f3f3f3f)//限制条件。
            tarjan(i);
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) {//判断无解。
            printf("NO\n%d\n", i);
            return 0;
        }
    for (int u = 1; u <= n; u++)
        for (int i = head[u]; i; i = ed[i].nxt)
            if (co[ed[i].to] != co[u])
                ++ru[co[ed[i].to]];
    int ans = 0;
    for (int i = 1; i <= col; i++)
        if (!ru[i])
            ans += sum[i];
    printf("YES\n%d\n", ans);
    return 0;
}

例题五

抢掠计划

首先日常缩点,重新建立有向图,然后...,有一个神仙做法:

  1. 化点权为边权:举个栗子,假设有边 \(u\to v\)\(v\) 的点权为 \(w\),那么令 \(edge(u,v)=-w\)
  2. 因为置了负边权,所以将最长路变为熟悉的最短路,跑 SPFA 即可。
  3. 找到有酒吧的点中 \(-dis[]\) 最大的即可。
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <iostream>
#include <cmath>
#include <queue>
#define N 500010
using namespace std;

int n, m, S, P, x[N], y[N], a[N], head[N], cnt = 0;
int low[N], dfn[N], co[N], s[N], sum[N];
int col = 0, tot = 0, num = 0;
bool bar[N];
struct Edge {
    int nxt, to, val;
} ed[N];

int read() {
    int x = 0, f = 1;
    char c = getchar();
    while (c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
    while (c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
    return x * f;
}

void add(int u, int v) {
    ed[++cnt].nxt = head[u];
    ed[cnt].to = v;
    head[u] = cnt;
    return;
}

void tarjan(int u) {
    dfn[u] = low[u] = ++num;
    s[++tot] = u;
    for (int i = head[u]; i; i = ed[i].nxt) {
        int v = ed[i].to;
        if (!dfn[v])
            tarjan(v), low[u] = min(low[u], low[v]);
        else if (!co[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (dfn[u] == low[u]) {
        co[u] = ++col, sum[col] = a[u];
        while (u != s[tot]) sum[col] += a[s[tot]], co[s[tot]] = col, --tot;
        --tot;
    }
    return;
}

void addedge(int u, int v, int w) {
    ed[++cnt].nxt = head[u];
    ed[cnt].to = v;
    ed[cnt].val = w;
    head[u] = cnt;
    return;
}

void Remove() {
    cnt = 0;
    memset(head, 0, sizeof(head));
    memset(ed, 0, sizeof(ed));
    for (int i = 1; i <= m; i++) {
        if (co[x[i]] != co[y[i]])
            addedge(co[x[i]], co[y[i]], -sum[co[y[i]]]);
    }
    return;
}

int dis[N];
bool vis[N];
void SPFA(int s) {
    queue<int> q;
    memset(dis, 0x3f, sizeof(dis));
    memset(vis, false, sizeof(vis));
    s = co[s];
    dis[s] = -sum[s];
    vis[s] = true;
    q.push(s);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        vis[u] = false;
        for (int i = head[u]; i; i = ed[i].nxt) {
            int v = ed[i].to, w = ed[i].val;
            if (dis[v] > dis[u] + w) {
                dis[v] = dis[u] + w;
                if (!vis[v])
                    q.push(v), vis[v] = true;
            }
        }
    }
    return;
}

int main() {
    n = read(), m = read();
    int u, v;
    for (int i = 1; i <= m; i++) x[i] = read(), y[i] = read(), add(x[i], y[i]);
    for (int i = 1; i <= n; i++) a[i] = read();
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i);
    Remove();
    S = read();
    SPFA(S);
    P = read();
    int ans = 0;
    for (int i = 1; i <= P; i++) u = read(), ans = max(ans, -dis[co[u]]);
    printf("%d\n", ans);
    return 0;
}

总结

图论中常用的算法之一就是 Tarjan 了,所以也是很重要的啦。

希望这篇文章对每一位读者都有帮助。

再次注意&特别鸣谢:这篇文章,本文也有多处讲解与图片转自此文

完结撒花。

猜你喜欢

转载自www.cnblogs.com/lpf-666/p/12702584.html