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  阅读(1022)  评论(4)    收藏  举报