Loading

Tarjan 算法的应用

写在前面

近期一直在刷这方面的题,因为没法学新知识,但又想写点什么,就水篇博文吧。

Upd on 2021.6.27:修了下排版和部分错误,同时重写了下代码。

关于 Tarjan算法

发明者 Robert E.Tarjan 罗伯特·塔扬,美国计算机科学家。

塔老爷子发明过很多算法,而且大多是以他的名字命名的,所以 Tarjan算法 也分很多种。

这里主要讲 缩点,割点,割边,2-SAT 以及如何求 LCA。

\[\]

引理一

什么是强连通分量?

强连通分量的定义是:极大的强连通子图。又叫 SCC。

简单来说,在一个有向图中,若所有点之间两两互相直接可达,则将这个图成为强连通分量。

求一个图中的强连通分量可以使用 Tarjan,Kosaraju 或者 Garbow 算法。

引理二

什么是双联通分量?

双联通分为点双联通与边双连通两种。

在一张连通的无向图中,对于两个点 \(u\)\(v\),如果无论删去哪一条边都不能使它们不连通,我们就说 \(u\)\(v\) 边双连通。

在一张连通的无向图中,对于两个点 \(u\)\(v\) ,如果无论删去哪一个除自己之外的点都不能使它们不连通,我们就说 \(u\)\(v\) 点双连通。

这里有两个结论:

  • 边双连通具有传递性,即若 \(x\)\(y\) 边双连通, \(y\)\(z\) 边双连通,则 \(x\)\(z\) 边双连通。

  • 点双连通不具有传递性。

手玩几组样例即可证明,比较显然。

\[\]

有向图缩点

缩点,简单说就是把一个图中所有的强连通分量缩成一个点,使之形成一个 DAG。

缩完点后的图中每个点会有一个新的编号,同处一个强连通分量中的点编号相同。

想要完成这一操作,首先需要知道什么是 DFS 序。

一个结点 \(x\) 的 DFS 序是指深度优先搜索遍历时改结点被搜索的次序,简记为 \(dfn_x\)

然后,再维护另一个变量 \(low_x\)

\(low_x\) 表示以下节点的 DFS 序的最小值:以 \(x\) 为根的子树中的结点 和 从该子树通过一条不在搜索树上的边能到达的结点。

根据 DFS 的遍历原理可以发现:

  • 一个结点的子树内结点的 DFS 序都大于该结点的 DFS 序;

  • 从根开始的一条路径上的 DFS 序严格递增,low 值严格非降。

知道了这些,再来看 Tarjan 算法求强连通分量的具体内容。

我们一般只对还没有确定其 DFS 序的节点进行操作,操作主要包括两个部分。

第一部分

以 DFS 的形式,处理出当前点 \(x\)\(dfn_x\)\(low_x\)

对当前点打一个标记表示已经遍历过,在之后的 DFS 中根据是否遍历过来进行不同处理,具体方式如下:

设当前枚举点为 \(u\)\(u\) 连出去的点记为 \(v\)

  1. \(v\) 未被访问:继续对 \(v\) 进行深度搜索。在回溯过程中,用 \(low_v\) 更新 \(low_u\)。因为存在从 \(u\)\(v\) 的直接路径,所以 \(v\) 能够回溯到的已经在栈中的结点, \(u\) 也一定能够回溯到。

  2. \(v\) 被访问过,已经在栈中:即已经被访问过,根据 low 值的定义(能够回溯到的最早的已经在栈中的结点),则用 \(dfn_v\) 更新 \(low_u\)

  3. \(v\) 被访问过,已不在在栈中:说明 \(v\) 已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。

这一部分代码实现如下:

low[fr]=dfn[fr]=++cnt;vis[fr]=1;
for(int i=head[fr];i;i=e[i].nxt){
  int to=e[i].to;
  if(!dfn[to]) tarjan(to),low[fr]=min(low[fr],low[to]);
  else if(vis[to]) low[fr]=min(low[fr],dfn[to]);
}

第二部分

对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个 \(dfn_x=low_x\)

该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点,因为它的 DFS 序和 low 值最小,不会被该连通分量中的其他结点所影响。

我们可以维护一个栈,存储所有枚举到的点。

在回溯的过程中,判定 \(dfn_x=low_x\) 的条件是否成立,如果成立,则从栈中取出一个点,处理它所在的强连通分量的编号以及大小,也可以处理其他的一些操作,这样直到把所有点处理完为止。

这一部分的代码实现如下:

zhan[++top]=u;
if(dfn[u]==low[u]){
  ++t;
  int pre=zhan[top--];
  vis[pre]=0;
  ...//相应操作
  while(pre!=u){
    pre=zhan[top--]; 
    vis[pre]=0;
    ...//相应操作
  }
}

至此,便可以处理出一个点所在的强连通分量,时间复杂度为 \(O(n+m)\)

\[\]

无向图缩点

这里说的其实是求无向图的双联通分量。

可以处理割点与桥以及双联通分量相关的一些题。

边双连通

因为是无向图,必须加两条边,而加两条边后跑 Tarjan 会很麻烦。

这里有另一个处理方法:通过 异或 来一次选中两条边。

我们知道 \(0\oplus1=1\)\(1\oplus1=0\)\(2\oplus1=3\)\(3\oplus1=2\)\(4\oplus1=5\)\(5\oplus1=4\)

而建边的时候两条边的编号相差 \(1\),所以可以每次处理第 \(i\) 条边的时候处理第 \(i\oplus 1\) 条边,解决这个问题。

而有向图和无向图 Tarjan 的写法也差不多,low 值的更新方式和缩点的编号等都相同,只有标记的地方不一样。

代码实现如下:

void tarjan(int u){
  low[u]=dfn[u]=++cnt;zhan[++top]=u;
  for(int i=head[u];i;i=e[i].nxt){
    if(!vis[i]){
      vis[i]=vis[i^1]=1;
      int to=e[i].to;
      if(!dfn[to]) tarjan(to),low[u]=min(low[u],low[to]);
      else low[u]=min(low[u],dfn[to]); 	
    }
  }
  if(dfn[u]==low[u]){
    ++t;
    int pre=zhan[top--];
    ...//相应操作
    while(pre!=u){
      pre=zhan[top--];
      ...//相应操作
    }
  }
}

点双联通

舍去了对边的判断,也不需要处理双向边这种问题。

代码如下:

void tarjan(int u){
  zhan[++Top]=u;dfn[u]=low[u]=++cnt;
  for(int i=head[u];i;i=e[i].nxt){
    int to=e[i].to;
    if(!dfn[to]){
      tarjan(to);low[u]=min(low[u],low[to]);
      if(low[to]>=dfn[u]){
        ++t;int pre;
        do{
          pre=zhan[Top--];
          ...//相应操作
        }while(pre!=to);
        ...//相应操作
      }
    }
    else low[u]=min(low[u],dfn[to]);
  }
}

\[\]

2-SAT

SAT 是适定性(Satisfiability)问题的简称。一般形式为 k-适定性问题,简称 k-SAT。而当 \(k>2\) 时该问题为 NP 完全的。所以我们只研究 \(k=2\) 的情况。 —— OI Wiki

个人感觉,就是一个实际应用类的知识吧。

就是指定 \(n\) 个集合,每个集合包含两个元素,给出若干个限制条件,每个条件规定不同集合中的某两个元素不能同时出现,最后问在这些条件下能否选出 \(n\) 个不在同一集合中的元素。

这个问题一般用 Tarjan 来求解,也可以暴搜,可以参考 OI Wiki 上的说明,这里就只讲用 Tarjan 实现。

但这种问题的实现主要不是难在 Tarjan 怎么写,而是难在图怎么建。

我们可以考虑怎么通过图来构造其中的关系。

既然给出了条件 \(a\)\(b\),必须只满足其中之一,那么存在两种情况,一是选择 \(a\)\(\lnot b\),二是选择 \(b\)\(\lnot a\)

那我们就可以将 \(a\) 连向 \(\lnot b\)\(b\) 连向 \(\lnot a\),表示选了 \(a\) 必须选 \(\lnot b\),选了 \(b\) 必须选 \(\lnot a\)

举个例子,假设这里有两个集合 \(A=\{x_1,y_1\}\)\(B=\{x_2,y_2\}\),规定 \(x_1\)\(y_2\) 不可同时出现,那我们就建两条有向边 \((x_1,y_1)\)\((y_2,x_2)\),表示选了 \(x_1\) 必须选 \(y_1\),,选了 \(y_2\) 必须选 \(x_2\)

这样建完边之后只需要跑一边 Tarjan 缩点判断有无解,若有解就把几个不矛盾的强连通分量拼起来就好了。

这里注意,因为跑 Tarjan 用了栈,根据拓扑序的定义和栈的原理,可以得到跑出来的强连通分量编号是反拓扑序这一结论。

我们就可以利用这一结论,在输出方案时倒序得到拓扑序,然后确定变量取值即可。

具体形如这样:

//mem[i] 表示非 i
for(int i=1;i<=n;i++)if(num[i]==num[mem[i]]){printf("无解");return 0;}//若两条件必须同时选,则不成立
for(int i=1;i<=n*2;i++)if(num[i]<num[mem[i]]) printf("%d\n",i);return 0;//输出其中一种选择方案

时间复杂度为 \(O(n+m)\)

\[\]

求割点

什么是割点?

如果在一个无向图中,删去某个点可以使这个图的极大连通分量数增加,那么这个点被称为这个图的割点,也叫割顶。

求割点比较暴力的做法是,对于每个点尝试删除然后判断图的连通性,不过显然复杂度极高。

考虑用 Tarjan 做。

同缩点一样,用 Tarjan 求割点也需要处理出点的 DFS 序和 low 值。

每次枚举一个点,判断这个点是否为割点的依据是:

  1. 如果它有至少一个儿子的 low 值大于它本身的 DFS 序,那么它就是割点;
  2. 如果它本身被搜到且有不少于两个儿子,那么它就是割点。

对于第一个依据的说明是:若一个儿子的 low 值大于它本身的 DFS 序,说明删去它之后它的这个儿子无法回到祖先点,那么它肯定是割点。
对于第二个依据的说明是:若它的儿子小于两个,那么删去他不会造成任何影响,所以它不会是割点。

更新 low 值的方式与缩点相似,但是约束条件不同,放伪代码感性理解一下:

如果 v 是 u 的儿子 low[u] = min(low[u], low[v]);
否则 low[u] = min(low[u], num[v]);

其实割点 Tarjan 的全部代码实现有很多别的细节,原理很简单,代码实现如下:

void tarjan(int u,int fa){
  vis[u]=1;int chi=0;//统计孩子数量
  dfn[u]=low[u]=++cnt;
  for(int i=head[u];i;i=e[i].nxt){
    int to=e[i].to;
    if(!vis[to]){
      chi++;tarjan(to,u);
      low[u]=min(low[to],low[u]);
      if(fa!=u&&low[to]>=dfn[u]&&!flag[u]){//第一个依据
	flag[u]=1;
	res++;//割点数量
      }
    }
    else if(to!=fa)
    low[u]=min(low[u],dfn[to]);
  }
  if(fa==u&&chi>=2&&!flag[u]){//第二个依据
    flag[u]=1;
    res++;
  }
}

但是这样跑 Tarjan 针对的不是没有确定 DFS 序的点,而是没有访问过的点,并且每次初始父亲都是自己。
也就是这样:

for(int i=1;i<=n;i++) if(!vis[i]) cnt=0,tarjan(i,i);

这样跑一边 Tarjan 后,带有 \(flag\) 标记的点就是割点。

\[\]

求割边

按割点的理解方式,割边应该是删去后能使无向图极大连通分量数量增加的边。

没错,就是这样。

割边,也叫桥。严谨来说,假设有连通图 \(G=\{V,E\}\)\(e\) 是其中一条边(即 \(e\in E\)),如果 \(G-e\) 是不连通的,则边 \(e\) 是图 \(G\) 的一条割边(桥)。

原理和割点差不多,实现也差不多,只要改一处:\(low_v>dfn_u\) 就可以了,而且不需要考虑根节点的问题。

与判断割点的第一条依据类似,当一条边 \((u,v)\)\(low_v>dfn_u\) 时,删去这条边,\(v\) 就无法回到祖先节点,因此满足此条件的边就是图的割边。

代码实现如下:

void tarjan(int u,int fat){
  fa[u]=fat;
  low[u]=dfn[u]=++cnt;
  for(int i=head[u];i;i=e[i].nxt){
    int v=e[i].to;
    if(!dfn[v]){
      tarjan(v,u);
      low[u]=min(low[u],low[v]);
      if(low[v]>dfn[u]){vis[v]=true;++bri;}//bri 是割边的数量
    } 
    else if(dfn[v]<dfn[u]&&v!=fat) 
    low[u]=min(low[u],dfn[v]);
  }
}

其中,当 \(vis_x=1\) 时,\((fa_x,x)\) 是一条割边。

\[\]

求 LCA

用 Tarjan 来求 LCA,需要用到并查集来维护某个结点的祖先结点。

  1. 首先接受输入、查询。查询边其实是虚拟加上去的边,为了方便,每次输入查询边的时候,将这个边及其反向边都存上。

  2. 然后对其进行一次 DFS 遍历,同时记录某个结点是否被访问过以及当前结点的父亲结点。

  3. 其中涉及到了回溯思想,我们每次遍历到某个结点的时候,认为这个结点的根结点就是它本身。让以这个结点为根节点的 DFS 全部遍历完毕了以后,再将这个结点的根节点 设置为这个结点的父一级结点。

  4. 回溯的时候,如果以该节点为起点时,查询边的另一个结点也恰好访问过了,则直接更新查询边的 LCA 结果。

  5. 最后输出结果

Tarjan 算法需要初始化并查集,所以预处理的时间复杂度为 \(O(n)\) ,Tarjan 算法处理所有 \(m\) 次询问的时间复杂度为 \(O(n+m)\) 。但是 Tarjan 算法的常数比倍增算法大。

需要注意的是,Tarjan 算法中使用的并查集性质比较特殊,在仅使用路径压缩优化的情况下,单次调用 find() 函数的时间复杂度为均摊 \(O(1)\) ,而不是 \(O(\log n)\)

代码实现如下:

void tarjan(int u) {
  parent[u] = u;
  visited[u] = 1;

  for (int i = head[u]; i != -1; i = edge[i].next) {
    Edge& e = edge[i];
    if (!visited[e.toVertex]) {
      tarjan(e.toVertex);
      parent[e.toVertex] = u;
    }
  }

  for (int i = queryHead[u]; i != -1; i = queryEdge[i].next) {
    Edge& e = queryEdge[i];
    if (visited[e.toVertex]) {
      queryEdge[i ^ 1].LCA = e.LCA = find(e.toVertex);
    }
  }
}

注:此代码来自 OI Wiki。

总的来说,Tarjan 算法求 LCA,拥有更优秀的时间复杂度,但它的常数也更大。

倍增来求 LCA 更易理解,也更实用。

\[\]

例题

「一本通 3.6 例 1」分离的路径
「一本通 3.6 例 2」矿场搭建
[APIO2009]抢掠计划
[USACO5.3]校园网Network of Schools
[ZJOI2007]最大半连通子图
[POI2001]和平委员会

\[\]

写在后面

Tarjan 虽说为了实现不同的目的有不同的写法,但是却没有固定的模板,可以根据自己喜好来变通,也可以更改其中维护的变量。

还可以对同一个图跑多种 Tarjan 来求它的各种值等。

最主要的还是理解它的思想。

希望对大家有帮助。

posted @ 2021-01-31 10:12  KnightL  阅读(677)  评论(4编辑  收藏  举报