本篇口胡写给我自己这样的老是证错东西的口胡选手 以及那些想学支配树,又不想啃论文原文的人…
大概会讲的东西是求支配树时需要用到的一些性质,以及构造支配树的算法实现…
最后讲一下把只有路径压缩的并查集卡到O(mlogn)上界的办法作为小彩蛋…
1、基本介绍 支配树 DominatorTree
对于一个流程图(单源有向图)上的每个点w,都存在点d满足去掉d之后起点无法到达w,我们称作d支配w,d是w的一个支配点。
支配w的点可以有多个,但是至少会有一个。显然,对于起点以外的点,它们都有两个平凡的支配点,一个是自己,一个是起点。
在支配w的点中,如果一个支配点i≠w满足i被w剩下的所有支配点支配,则这个i称作w的最近支配点(immediate
dominator),记作
。
定理1:我们把图的起点称作r,除r以外每个点均存在唯一的idom。
这个的证明很简单:如果a支配b且b支配c,则a一定支配c,因为到达c的路径都经过了b所以必须经过a;如果b支配c且a支配c,则a支配b(或者b支配a),否则存在从r到b再到c的路径绕过a,与a支配c矛盾。这就意味着支配定义了点w的支配点集合上的一个全序关系,所以一定可以找到一个“最小”的元素使得所有元素都支配它。
于是,连上所有r以外的idom(w)→w的边,就能得到一棵树,其中每个点支配它子树中的所有点,它就是支配树。
支配树有很多食用…哦不…是实际用途。比如它展示了一个信息传递网络的关键点,如果一个点支配了很多点,那么这个点的传递效率和稳定性要求都会很高。比如Java的内存分析工具(Memory Analyzer Tool)里面就可以查看对象间引用关系的支配树…很多分析上支配树都是一个重要的参考。
为了能够求出支配树,我们下面来介绍一下需要用到的基本性质。
2、支配树相关性质
首先,我们会使用一棵DFS树来帮助我们计算。从起点出发进行DFS就可以得到一棵DFS树。
观察上面这幅图,我们可以注意到原图中的边被分为了几类。在DFS树上出现的边称作树边,剩下的边称为非树边。非树边也可以分为几类,从祖先指向后代(前向边),从后代指向祖先(后向边),从一棵子树內指向另一棵子树内(横叉边)。树边是我们非常熟悉的,所以着重考虑一下非树边。
我们按照DFS到的先后顺序给点从小到大编号(在下面的内容中我们通过这个比较两个节点),那么前向边总是由编号小的指向编号大的,后向边总是由大指向小,横叉边也总是由大指向小。现在在DFS树上我们要证明一些重要的引理:
引理1(路径引理):
如果两个点v,w满足v≤w,那么任意v到w的路径经过v,w的公共祖先。(注意这里不是说LCA)
证明:
如果v,w其中一个是另一个的祖先显然成立。否则删掉起点到LCA路径上的所有点(这些点是v,w的公共祖先),那么v和w在两棵子树内,并且因为公共祖先被删去,无法通过后向边到达子树外面,前向边也无法跨越子树,而横叉边只能从大到小,所以从v出发不能离开这颗子树到达w。所以如果本来v能够到达w,就说明这些路径必须经过v,w的公共祖先。
在继续之前,我们先约定一些记号:
V代表图的点集,E代表图的边集。
a→b代表从点a直接经过一条边到达点b,
a⇝b代表从点a经过某条路径到达点b,
a→˙b代表从点a经过DFS树上的树边到达点b(a是b在DFS树上的祖先),
a→+b代表a→˙b且a≠b。
定义 半支配点(semi-dominator):
对于w≠r,它的半支配点定义为sdom(w)=min{v|∃(v0,v1,⋯,vk−1,vk),v0=v,vk=w,∀1≤i≤k−1,vi>w}
对于这个定义的理解其实就是从v出发,绕过w之前的所有点到达w。(只能以它之后的点作为落脚点)
注意这只是个辅助定义,并不是真正的支配点。甚至在只保留w和w以前的点时它都不一定是支配点。例子: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
对于任意w≠r,有idom(w)→+w。
证明很显然,如果不是这样的话就可以直接通过树边不经过idom(w)就到达w了,与idom定义矛盾。
引理3
对于任意w≠r,有sdom(w)→+w。
证明:
对于w在DFS树上的父亲faw,faw→w这条路径只有两个点,所以满足sdom定义中的条件,于是它是sdom(w)的一个候选。所以sdom(w)≤faw在这里我们就可以使用路径引理证明sdom(w)不可能在另一棵子树,因为如果是那样的话就会经过sdom(w)和w的一个公共祖先,公共祖先的编号一定小于w,所以不可行。于是sdom(w)就是w的真祖先。
引理4
对于任意w≠r,有idom(w)→˙sdom(w)。
证明:
如果不是这样的话,按照sdom的定义,就会有一条路径是r→˙sdom(w)⇝w不经过idom(w)了,与idom定义矛盾。
引理5
对于满足v→˙w的点v,w,v→˙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条引理都比较简单,不过是非常重要的性质。接下来我们要证明几个定理,它们揭示了idom与sdom的关系。证明可能会比上面的复杂一点。
定理2
对于任意w≠r,如果所有满足sdom(w)→+u→˙w的u也满足sdom(u)≥sdom(w),那么idom(w)=sdom(w)。
证明:
由上面的引理4知道idom(w)→˙sdom(w),所以只要证明sdom(w)支配w就可以保证是最近支配点了。对任意r到w的路径,取上面最后一个编号小于sdom(w)的x(如果sdom就是r的话显然定理成立),它必然有个后继y满足sdom(w)→˙y→˙w(否则x会变成sdom(w),我们取最小的那个y。同时,如果y不是sdom(w),根据条件,sdom(y)≥sdom(w),所以x不可能是sdom(y),这就意味着x到y的路径上一定有一个v满足x→+v→+y,因为x是小于sdom(w)的最后一个,所以v也满足sdom(w)→˙v→˙w,但是我们取的y已经是最小的一个了,矛盾。于是y只能是sdom(w),那么我们就证明了对于任意路径都要经过sdom(w),所以sdom(w)就是idom(w)。
定理3
对于任意w≠r,令u为所有满足sdom(w)→+u→˙w的u中sdom(u)最小的一个,那么sdom(u)≤sdom(w)⇒idom(w)=idom(u)。
证明:
由引理5,有idom(w)→˙idom(u)或u→˙idom(w),由引理4排除后面这种。所以只要证明idom(u)支配w即可。类似定理2的证明,我们取任意r到w路径上最后一个小于idom(u)的x(如果idom(u)是r的话显然定理成立),路径上必然有个后继y满足idom(u)→˙y→˙w(否则x会变成sdom(w)),我们取最小的一个y。类似上面的证明,我们知道x到y的路径上不能有点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
对于w≠r,令u为所有满足sdom(w)→+u→˙w的u中sdom(u) 最小的一个,有
通过定理2和定理3可以直接得到。这里一定有sdom(u)≤sdom(w),因为w也是u的候选。
接下来我们的问题是,直接通过定义计算sdom很低效,我们需要更加高效的方法,所以我们证明下面这个定理:
定理4
对于任意w≠r,sdom(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,所以此时x≤sdom(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的候选,x≤sdom(p)。所以x≤sdom(p)≤sdom(w) , x≤sdom(w)。综上,sdom(w)≤x且x≤sdom(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的点集
以及idom和sdom数组
第1步没什么特别的,规规矩矩地DFS一次即可,同时初始化sdom为自己(这是为了实现方便)。
第2、3步可以一起做。通过一个辅助数据结构维护一个森林,支持加入一条边(link(u,v))和查询点到根路径上的点的sdom的最小值对应的点(eval(u))。那么我们求每个点的sdom只需要对它的所有直接前驱eval一次,求得前驱中的sdom最小值即可。因为定理4中的第一类点编号比它小,它们还没有处理过,所以自己就是根,eval就能取得它们的值;对于第二类点,eval 查询的就是满足u→˙v的u的sdom(u)的最小值。所以这么做和定理4是一致的。
然后把该点加入它的sdom的bucket 里,连上它与父亲的边。现在它父亲到它的这棵子树中已经处理完了,所以可以对父亲的bucket 里的每个点求一次sdom并且清空bucket。对于bucket 里的每个点v,求出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的卡法基于一种特殊定义的二项树(和一般的二项树的定义不同)。
定义这种特殊的二项树Tk为一类多叉树,其中T1,T2,⋯,Tj都是一个单独的点,对于Tk,k>j,Tk就是Tk−1再接上一个Tk−j作为它的儿子。
就像这样。这种定义有一个有趣的特性,如果我们把它继续展开,可以得到各种有趣的结果。比如我们把上面图中的Tk−j继续展开,就会变成Tk−j−1接着Tk−2j,以此类推可以展开出一串。而如果对Tk−1继续展开,父节点就会变成Tk−2,子节点多出一个Tk−j−1,以此类推可以展开成一层树。下面的图展示了展开Tk的不同方式。
让我们好好考虑一下这意味着什么。从图4到图5…除了这些树的编号没有对应上以外,会不会有一种感觉,图5像是图4路径压缩后的结果。
图4的展开方式中编号的间隔都是j,图5的展开方式中间隔都是1…那么如果我们用图5的方式展开出j棵子树,再按图4展开会怎么样呢?(假设j整除k)
变成了这个样子,就确实和路径压缩扯上关系了。如果在最顶上再加一个点,然后j次访问底层的T1,T2,⋯,Tj就可以把树压成图5的样子了,不过会多一个单点的儿子出来,因为图6中其实有两个Tj(因为图4展开到最后一层没有了−1,所以会和上一层出现一次重复)。这么一来,我们又可以做一次这一系列操作了,非常神奇!(原论文里把这个叫做self-reproduction)至于Tk的实际点数,通过归纳法可以得到点数不超过(j+1)kj−1。(我们只对能被j整除的k进行计算,每次j次展开父节点进行归纳)
有了这个我们就有信心卡纯路径压缩并查集了。令m代表询问操作数,n代表合并操作数,不妨设m≥n,我们取j=⌊mn⌋,i=⌊logj+1n2⌋+1,k=ij那么Tk的大小不超过(j+1)i−1即n2。接下来我们做n2组操作,每组在最顶上加入一个点,然后对底层的j个节点逐一查询,每次查询的路径长度都是i+1。同时总共的查询次数还是不超过m。于是总共的复杂度是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