Tarjan 算法的应用

近期一直在刷这方面的题 由于无法学新知识 但又想写点什么 就水篇博文吧算法

关于 Tarjan算法

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

塔老爷子发明过不少算法,并且大可能是以他的名字命名的,因此 Tarjan算法 也分不少种code

这里主要讲 缩点,割点,割边 和 2-SATget

\[\]

引理一

什么是强连通份量?it

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

简单来讲,在一个有向图中,若全部点之间两两互相直接可达,则将这个图成为强连通份量计算机科学

求一个图中的强连通份量可使用 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序 的节点进行 Tarjan 操做,操做主要包括两个部分

第一部分

以 DFS 的形式,处理出当前点 \(x\)\(dfn[x]\)\(low[x]\)

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

设当前枚举点为 \(fr\)\(fr\) 连出去的点记为 \(to\)

  1. \(to\) 未被访问:继续对 \(to\) 进行深度搜索。在回溯过程当中,用 \(low[to]\) 更新 \(low[fr]\)。由于存在从 \(fr\)\(to\) 的直接路径,因此 \(to\) 可以回溯到的已经在栈中的结点, \(fr\) 也必定可以回溯到。
  2. \(to\) 被访问过,已经在栈中:即已经被访问过,根据 low值 的定义(可以回溯到的最先的已经在栈中的结点),则用 \(dfn[to]\) 更新 \(low[fr]\)
  3. \(to\) 被访问过,已不在在栈中:说明 \(to\) 已搜索完毕,其所在连通份量已被处理,因此不用对其作操做。

这一部分代码实现以下:

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]){
	++siz[++t];
        int pre=zhan[top--];
        vis[pre]=0;num[pre]=t;
	while(pre!=u){
	    ++siz[t];pre=zhan[top--]; 
	    vis[pre]=0;num[pre]=t;
	}
}

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

\[\]

无向图缩点

能够处理 割点与桥 以及 双联通份量 相关的一些题

由于是无向图,必须加两条边,而加两条边后跑 Tarjan 会很麻烦

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

咱们知道 \(0\oplus1=1\)\(1\oplus1=0\) ; \(2\oplus1=3\) , \(3\oplus1=2\) ; \(4\oplus1=3\) , \(3\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]){
		++siz[++t];int pre=zhan[top--];num[pre]=t;
		while(pre!=u){++siz[t];pre=zhan[top--];num[pre]=t;}
	}
}

\[\]

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值,而且是用 DFS 的形式处理

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

1.若是它有至少一个儿子的 low值 大于它自己的 DFS序,那么它就是割点
1.若是它自己被搜到且有很多于两个儿子,那么它就是割点

对于第一个依据的说明是:若一个儿子的 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)\) 是一条割边

\[\]

例题

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

写在后面

写了半上午加半下午,好累qaq
不过有一说一,我是真不理解有人只放个题目连接而后放个代码是怎么想的,连思路都不提,是给别人写 std 仍是只为了告诉别人本身作对了
比我还水