快速构造支配树的Lengauer-Tarjan算法

本文深入讲解支配树的概念、性质及Lengauer-Tarjan算法实现,解析支配树在信息传递网络中的关键作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本篇口胡写给我自己这样的老是证错东西的口胡选手 以及那些想学支配树,又不想啃论文原文的人…

  大概会讲的东西是求支配树时需要用到的一些性质,以及构造支配树的算法实现…

  最后讲一下把只有路径压缩的并查集卡到O(mlogn)O(mlog⁡n)上界的办法作为小彩蛋…

 

1、基本介绍 支配树 DominatorTree

  对于一个流程图(单源有向图)上的每个点w,都存在点d满足去掉d之后起点无法到达w,我们称作d支配wdw的一个支配点。

  

  支配w的点可以有多个,但是至少会有一个。显然,对于起点以外的点,它们都有两个平凡的支配点,一个是自己,一个是起点。

  在支配w的点中,如果一个支配点iw满足iw剩下的所有支配点支配,则这个i称作w的最近支配点(immediate dominator),记作
idom(w)

  定理1:我们把图的起点称作r,除r以外每个点均存在唯一的idom

  这个的证明很简单:如果a支配bb支配c,则a一定支配c,因为到达c的路径都经过了b所以必须经过a;如果b支配ca支配c,则a支配b(或者b支配a),否则存在从rb再到c的路径绕过a,与a支配c矛盾。这就意味着支配定义了点w的支配点集合上的一个全序关系,所以一定可以找到一个“最小”的元素使得所有元素都支配它。

  于是,连上所有r以外的idom(w)w的边,就能得到一棵树,其中每个点支配它子树中的所有点,它就是支配树。

  

  支配树有很多食用…哦不…是实际用途。比如它展示了一个信息传递网络的关键点,如果一个点支配了很多点,那么这个点的传递效率和稳定性要求都会很高。比如Java的内存分析工具(Memory Analyzer Tool)里面就可以查看对象间引用关系的支配树…很多分析上支配树都是一个重要的参考。

  为了能够求出支配树,我们下面来介绍一下需要用到的基本性质。

 

2、支配树相关性质

  首先,我们会使用一棵DFS树来帮助我们计算。从起点出发进行DFS就可以得到一棵DFS树。

  

  观察上面这幅图,我们可以注意到原图中的边被分为了几类。在DFS树上出现的边称作树边,剩下的边称为非树边。非树边也可以分为几类,从祖先指向后代(前向边),从后代指向祖先(后向边),从一棵子树內指向另一棵子树内(横叉边)。树边是我们非常熟悉的,所以着重考虑一下非树边。

  我们按照DFS到的先后顺序给点从小到大编号(在下面的内容中我们通过这个比较两个节点),那么前向边总是由编号小的指向编号大的,后向边总是由大指向小,横叉边也总是由大指向小。现在在DFS树上我们要证明一些重要的引理:

 


 

  引理1(路径引理):

    如果两个点v,w,满足vw,那么任意vw的路径经过v,w的公共祖先。(注意这里不是说LCA)

  证明:

    如果v,w其中一个是另一个的祖先显然成立。否则删掉起点到LCA路径上的所有点(这些点是v,w的公共祖先),那么vw在两棵子树内,并且因为公共祖先被删去,无法通过后向边到达子树外面,前向边也无法跨越子树,而横叉边只能从大到小,所以从v出发不能离开这颗子树到达w。所以如果本来v能够到达w,就说明这些路径必须经过v,w的公共祖先。

 


 

  在继续之前,我们先约定一些记号:

  V代表图的点集,E代表图的边集。

  ab代表从点a直接经过一条边到达点b

  ab代表从点a经过某条路径到达点b

  a˙b代表从点a经过DFS树上的树边到达点bab在DFS树上的祖先),

  a+b代表a˙bab

  


 

  定义 半支配点(semi-dominator):

    对于wr,它的半支配点定义为sdom(w)=min{v|(v0,v1,,vk1,vk),v0=v,vk=w,1ik1,vi>w}

  对于这个定义的理解其实就是从v出发,绕过w之前的所有点到达w。(只能以它之后的点作为落脚点)

  注意这只是个辅助定义,并不是真正的支配点。甚至在只保留ww以前的点时它都不一定是支配点。例子:V={1,2,3,4},E={(1,2),(2,3),(3,4),(1,3),(2,4)},r=1,sdom(4)=2              ,但是2不支配4。不过它代表了有潜力成为支配点的点,在后面我们可以看到,所有的idom都来自自己或者另一个点的sdom

 


 

  引理2

    对于任意wr,有idom(w)+w

  证明很显然,如果不是这样的话就可以直接通过树边不经过idom(w)就到达w了,与idom定义矛盾。

 


 

  引理3

    对于任意wr,有sdom(w)+w

  证明:

    对于w在DFS树上的父亲fawfaww这条路径只有两个点,所以满足sdom定义中的条件,于是它是sdom(w)的一个候选。所以sdom(w)faw在这里我们就可以使用路径引理证明sdom(w)不可能在另一棵子树,因为如果是那样的话就会经过sdom(w)w的一个公共祖先,公共祖先的编号一定小于w,所以不可行。于是sdom(w)就是w的真祖先。

 


 

  引理4

    对于任意wr,有idom(w)˙sdom(w)

  证明:

    如果不是这样的话,按照sdom的定义,就会有一条路径是r˙sdom(w)w不经过idom(w)了,与idom定义矛盾。

 


 

  引理5

    对于满足v˙w的点v,wv˙idom(w)idom(w)˙idom(v)

  (不严谨地说就是idom(w)w的路径不相交或者被完全包含,其实idom(w)这个位置是可能相交的)

  证明:

    如果不是这样的话,就是idom(v)+idom(w)+v+w,那么存在路径r˙idom(v)v+w不经过idom(w)到达了w(因为idom(w)idom(v)的真后代,一定不支配v,所以存在绕过idom(w)到达v的路径),矛盾。

 


 

  上面这5条引理都比较简单,不过是非常重要的性质。接下来我们要证明几个定理,它们揭示了idomsdom的关系。证明可能会比上面的复杂一点。

 


 

  定理2

    对于任意wr,如果所有满足sdom(w)+u˙wu也满足sdom(u)sdom(w),那么idom(w)=sdom(w)

  

sdom(w)˙sdom(u)+u˙w

  证明:

    由上面的引理4知道idom(w)˙sdom(w),所以只要证明sdom(w)支配w就可以保证是最近支配点了。对任意rw的路径,取上面最后一个编号小于sdom(w)x(如果sdom就是r的话显然定理成立),它必然有个后继y满足sdom(w)˙y˙w(否则x会变成sdom(w),我们取最小的那个y。同时,如果y不是sdom(w),根据条件,sdom(y)sdom(w),所以x不可能是sdom(y),这就意味着xy的路径上一定有一个v满足x+v+y,因为xx是小于sdom(w)的最后一个,所以v也满足sdom(w)˙v˙w,但是我们取的y已经是最小的一个了,矛盾。于是y只能是sdom(w),那么我们就证明了对于任意路径都要经过sdom(w),所以sdom(w)就是idom(w)

 


 

  定理3

    对于任意wr,令u为所有满足sdom(w)+u˙wusdom(u)最小的一个,那么sdom(u)sdom(w)idom(w)=idom(u)

  

sdom(u)˙sdom(w)+u˙w

  证明:

    由引理5,有idom(w)˙idom(u)u˙idom(w),由引理4排除后面这种。所以只要证明idom(u)支配w即可。类似定理2的证明,我们取任意rw路径上最后一个小于idom(u)x(如果idom(u)r的话显然定理成立),路径上必然有个后继y满足idom(u)˙y˙w(否则x会变成sdom(w)),我们取最小的一个y。类似上面的证明,我们知道xy的路径上不能有点v满足idom(u)˙v+y,于是x成为sdom(y)的候选,所以sdom(y)x。那么根据条件我们也知道了y不能是sdom(w)的真后代,于是y满足idom(u)˙y˙sdom(w)  。但是我们注意到因为sdom(y)x,存在一条路径r˙sdom(y)y˙u ,如果y不是idom(u) 的话这就是一条绕过idom(u) 的到u的路径,矛盾,所以y必定是idom(u) 。所以任意到w的路径都经过idom(u),所以idom(w)=idom(u) 。

 


 

  幸苦地完成了上面两个定理的证明,我们就能够通过sdom求出idom了:


 

  推论1 

    对于wr,令u为所有满足sdom(w)+u˙wusdom(u) 最小的一个,有

    

idom(w)={sdom(w)idom(u)(sdom(u)=sdom(w))(sdom(u)<sdom(w))

  通过定理2和定理3可以直接得到。这里一定有sdom(u)sdom(w),因为w也是u的候选。

 


 

  接下来我们的问题是,直接通过定义计算sdom很低效,我们需要更加高效的方法,所以我们证明下面这个定理:


 

  定理4

    对于任意wrsdom(w)=min({v|(v,w)E,v<w}{sdom(u)|u>w,(v,w)E,u˙v})

  证明:

    令等号右侧为x,显然右侧的点集中都存在路径绕过w之前的点,所以sdom(w)x 。然后我们考虑sdom(w) w 的绕过w 之前的点的路径,如果只有一条边,那么必定满足(sdom(w),w)E  sdom(w)<w,所以此时xsdom(w);如果多于一条边,令路径上w的上一个点为last  ,我们取路径上除两端外满足p˙last 的最小的p(一定能取得这样的p,因为last  是p 的候选)。因为这个p是最小的,所以sdom(w)p的路径必定绕过了p之前的所有点,于是sdom(w)sdom(p)的候选,所以sdom(p)sdom(w)。同时,sdom(p)还满足右侧的条件(p在绕过w之前的点的路径上,于是p>w,并且p˙last,同时last  直接连到了w),所以sdom(p)x的候选,xsdom(p)。所以xsdom(p)sdom(w, xsdom(w)。综上,sdom(w)xxsdom(w),所以x=sdom(w)

 


 

  好啦,最困难的步骤已经完成了,我们得到了sdom的一个替代定义,而且这个定义里面的形式要简单得多。这种基本的树上操作我们是非常熟悉的,所以没有什么好担心的了。接下来就可以给出我们需要的算法了。

 

3、Lengauer-Tarjan算法

算法流程:

  1、初始化、跑一遍DFS得到DFS树和标号
  2、按标号从大到小求出sdom(利用定理4)
  3、通过推论1求出所有能确定的idom,剩下的点记录下和哪个点的idom 是相同的
  4、按照标号从小到大再跑一次,得到所有点的idom

  很简单对不对~有了理论基础后算法就很显然了。

 

具体实现:

  大致要维护的东西:
  vertex(x) 标号为x的点u
  pred(u) 有边直接连到u的点集
  parent(u) u在DFS树上的父亲fau
  bucket(u) sdom 为点u的点集
  以及idomsdom数组

  第1步没什么特别的,规规矩矩地DFS一次即可,同时初始化sdom为自己(这是为了实现方便)。

  第2、3步可以一起做。通过一个辅助数据结构维护一个森林,支持加入一条边(link(u,v))和查询点到根路径上的点的sdom的最小值对应的点(eval(u))。那么我们求每个点的sdom只需要对它的所有直接前驱eval一次,求得前驱中的sdom最小值即可。因为定理4中的第一类点编号比它小,它们还没有处理过,所以自己就是根,eval就能取得它们的值;对于第二类点,eval 查询的就是满足u˙vusdom(u)的最小值。所以这么做和定理4是一致的。

  然后把该点加入它的sdomsbucket 里,连上它与父亲的边。现在它父亲到它的这棵子树中已经处理完了,所以可以对父亲的bucket 里的每个点求一次sdom并且清空bucket。对于bucket 里的每个点vv,求出eval(v),此时parent(w)+eval(v)˙v,于是直接按照推论1,如果sdom(eval(v))=sdom(v),则idom(v)=sdom(v)=parent(w);否则可以记下idom(v)=idom(eval(v)),实现时我们可以写成idom(v)=eval(v),留到第4步处理。
  最后从小到大扫一遍完成第4步,对于每个u,如果idom(u)=sdom(u)的话,就已经是第3步求出的正确的idom了,否则就证明这是第3步留下的待处理点,令idom(u)=idom(idom(u))即可。

  对于这个辅助数据结构,我们可以选择并查集。不过因为我们需要查询到根路径上的信息,所以不方便写按秩合并,但是我们仍然可以路径压缩,压缩时保留路径上的最值就可以了,所以并查集操作的复杂度是O(logn)。这样做的话,最终的复杂度是O(nlogn)。(各种常见方法优化的并查集只要没有按秩合并就是做不到αα的复杂度的,最下面我会提到如何卡路径压缩)

  原论文还提到了一个比较奥妙的实现方法,能够把这个并查集优化到αα的复杂度,不过看上去比较迷,我觉得我会写错,所以就先放着了,如果有兴趣的话可以找原论文A Fast Algorithm for Finding Dominators in a Flowgraph,里面的参考文献14是Tarjan的另一篇东西Applications of Path Compression on Balanced Trees,原论文说用的是这里面的方法…等什么时候无聊想要真正地学习并查集的各种东西的时候再看吧…(我又挖了个大坑)

 

代码实现


















#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<string>
#include<algorithm>
#include<cmath>
#include<vector>
using namespace std;
inline int read()
{
	char ch=getchar();int i=0,f=1;
	while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
	while(ch>='0'&&ch<='9'){i=(i<<3)+(i<<1)+ch-'0';ch=getchar();}
	return i*f;
}
const int N = 200010;
struct eg{ int to,before; }edge[N];
int n,m,tot,ecnt;
int last[N],pos[N],idx[N],fa[N],sdom[N],idom[N];
int father[N],val[N];
vector<int> pre[N],bkt[N];
int findf(int p)
{
	if(father[p]==p) return p;
	int r=findf(father[p]);
	if(sdom[val[father[p]]]<sdom[val[p]]) val[p] = val[father[p]];
	return father[p] = r;
}
inline int eval(int p)
{
	findf(p);
	return val[p];
}
void dfs(int p)
{
	idx[pos[p]=++tot]=p,sdom[p]=pos[p];
	for(int pt=last[p];pt;pt=edge[pt].before) if(!pos[edge[pt].to])
	dfs(edge[pt].to),fa[edge[pt].to]=p;
}
void work()
{
	int i,p;
	dfs(1);
	for(i=tot;i>=2;i--)
	{
		p=idx[i];
		for(int k:pre[p])
			if(pos[k])sdom[p]=min(sdom[p],sdom[eval(k)]);
		bkt[idx[sdom[p]]].push_back(p);
		int fp=fa[p];father[p]=fa[p];
		for(int v:bkt[fp])
		{
			int u = eval(v);
			idom[v] = sdom[u]==sdom[v]?fp:u;
		}
		bkt[fp].clear();
	}
	for(i=2;i<=tot;i++) p=idx[i],idom[p]=(idom[p]==idx[sdom[p]])?idom[p]:idom[idom[p]];
	for(i=2;i<=tot;i++) p=idx[i],sdom[p]=idx[sdom[p]];
}
inline void link(int a,int b)
{
	edge[++ecnt].to=b,edge[ecnt].before=last[a],last[a]=ecnt;
	pre[b].push_back(a);
}
int main()
{
	int i;
	n=read(),m=read();
	tot=ecnt=0;
	for(i=1;i<=n;i++)last[i]=pos[i]=0,father[i]=val[i]=i,pre[i].clear(),bkt[i].clear();
	for(i=1;i<=m;i++)
	{
		int a=read(); 
		link(a,read()); 
	}
	work();
	return 0;
}

  我的变量名都很迷…不要在意…(它们可是经过了长时间的结合中文+英文+象形+脑洞的演变得出的结果)

  稍微需要注意一下的就是实现时点的真实编号和DFS序中的编号的区别,DFS序的编号是用来比较的那个。以及尽量要保持一致性(要么都用真实编号,要么都用DFS序编号),否则很容易写错…我的这段代码里idom用的是真实编号,sdom用的是DFS序编号,最后再跑一次把sdom转成真实编号的。

 

4、欢快的彩蛋 卡并查集!

  是不是听到周围有人说:“我的并查集只写了路径压缩,它是单次操作α的”。这时你要坚定你的信念,你要相信这是O(logn)的。如果他告诉你这个卡不了的话…你或许会觉得确实很难卡…我也觉得很难卡…但是Tarjan总知道怎么卡。

  现在确认一下纯路径压缩并查集的实现方法:每次基本操作find(v)后都把v到根路径上的所有点直接接在根的下面,每次合并操作对需要合并的两个点执行find找到它们的根。

  看起来挺优的。(其实真的挺优的,只是没有α那么优)

  Tarjan的卡法基于一种特殊定义的二项树(和一般的二项树的定义不同)。

  定义这种特殊的二项树TkTk为一类多叉树,其中T1,T2,,Tj都是一个单独的点,对于Tk,k>jTk就是Tk1再接上一个Tkj作为它的儿子。

  

  就像这样。这种定义有一个有趣的特性,如果我们把它继续展开,可以得到各种有趣的结果。比如我们把上面图中的Tkj继续展开,就会变成Tkj1接着Tk2j,以此类推可以展开出一串。而如果对Tk1继续展开,父节点就会变成Tk2Tk−2,子节点多出一个Tkj1,以此类推可以展开成一层树。下面的图展示了展开Tk的不同方式。

  

  让我们好好考虑一下这意味着什么。从图4到图5…除了这些树的编号没有对应上以外,会不会有一种感觉,图5像是图4路径压缩后的结果。

  图4的展开方式中编号的间隔都是j,图5的展开方式中间隔都是1…那么如果我们用图5的方式展开出j棵子树,再按图4展开会怎么样呢?(假设j整除k

  

  变成了这个样子,就确实和路径压缩扯上关系了。如果在最顶上再加一个点,然后jj次访问底层的T1,T2,,Tj就可以把树压成图5的样子了,不过会多一个单点的儿子出来,因为图6中其实有两个Tj(因为图4展开到最后一层没有了1,所以会和上一层出现一次重复)。这么一来,我们又可以做一次这一系列操作了,非常神奇!(原论文里把这个叫做self-reproduction)至于TkTk的实际点数,通过归纳法可以得到点数不超过(j+1)kj1。(我们只对能被jj整除的k进行计算,每次j次展开父节点进行归纳)

 有了这个我们就有信心卡纯路径压缩并查集了。令m代表询问操作数,n代表合并操作数,不妨设mn,我们取j=mn,i=logj+1n2+1,k=ij那么Tk的大小不超过(j+1)i1n2。接下来我们做n2组操作,每组在最顶上加入一个点,然后对底层的jj个节点逐一查询,每次查询的路径长度都是i+1i+1。同时总共的查询次数还是不超过mm。于是总共的复杂度是n2j(i+1)=Ω(mlog1+m/nn)

  Boom~爆炸了,所以它确实是log级的。

  彩蛋到这里就结束啦…如果想知道更多并查集优化方法怎么卡,可以去看这一部分参考的原论文Worst-Case Analysis of Set Union Algorithms,里面还附带了一个表,有写各种并查集实现不带按秩合并和带按秩合并的复杂度,嗯,卡并查集还是挺有趣的(只是一般人想不到呀…Tarjan太强辣)…

 

  (题外话:这次我画了好多图,感觉自己好良心呀w 其实都是对着论文上的例子画的)



from:http://www.cnblogs.com/meowww/p/6475952.html


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值