我最近看到zjoi2011的一道题:
http://www.zybbs.org/JudgeOnline/problem.php?id=2325
之后一惊:这不是传说中的动态树吗,怎么都出到省选里了?
我又看到了某神牛的博文:
http://hi.baidu.com/wjbzbmr/blog/item/83f31646fd360554500ffecd.html
“不过我权衡了一下,觉得树链剖分我几乎写过10多次了。。应该还是写的出来的。。”
我被震撼了:真是人在北京好似坐井观天,人家都写了10遍的东西我竟然还认为OI中不会考呢!
于是,我痛下决心:疯狂练习,攻克动态树。
动态树除了上面的那题外,还有
http://www.zybbs.org/JudgeOnline/problem.php?id=1036
也是zjoi的题。
以及spoj上的QTREE。
http://www.spoj.pl/problems/QTREE/
我决定就把这三道题刷了好了。
动态树的实现主要有5种:
link-cut tree *
Euler-Tour tree
全局平衡二叉树 *
树链剖分
树块剖分 *
我们重点关注带星号的实现
link-cut tree的思想是把树剖分成若干个链,不过这些链是用splay动态维护的,每次查询的时候把一个点到根的路径连成一个链。几篇入门文章可以到这里下载:
http://cid-354ed8646264d3c4.office.live.com/self.aspx/.Public/DynamicTree/QTREE^_YangZhe.pdf
http://cid-354ed8646264d3c4.office.live.com/view.aspx/.Public/DynamicTree/CollectionOfAlgorithms^_DynamicTree.doc
一些实现上的技巧可以参考杨哲的文章,我综合以上两篇文章得出了我自己的写法:
http://cid-354ed8646264d3c4.office.live.com/self.aspx/.Public/DynamicTree/2325^_2.cpp
总体来说,是融合了朴素与飘逸。程序100多行,4K,还行。最核心的连接操作非常经典:
node *Expose(node *p){
node *q;
for (q=NULL;p;p=p->f){
Splay(p);
p->r=q;
(q=p)->update();
}
return q;
}
Splay操作则是唐文斌教给我的写法(融合了杨哲的改进):
void Splay(node *p){
while (p->f && (p->f->l==p || p->f->r==p)){
node *q=p->f,*y=q->f;
if (y && y->l==q){
if (q->l==p)zig(q),zig(p);
else zag(p),zig(p);
}else if (y && y->r==q){
if (q->r==p)zag(q),zag(p);
else zig(p),zag(p);
}else{
if (q->l==p)zig(p);
else zag(p);
}
}
p->update();
}
这些代码其实非常优雅、流畅,默写一遍20分钟足矣,我写到第2遍的时候就已经不用调试直接正确了。
SPOJ上QTREE那题用这个模板AC没有阻碍:
http://cid-354ed8646264d3c4.office.live.com/self.aspx/.Public/DynamicTree/375.cpp
可以说,以后考试的时候遇到动态树我就写Link-Cut Tree了。
Link-Cut Tree的常数其实很糟糕,这主要是splay导致的。
改进常数的方法是建立一棵”全局平衡二叉树“,也是树链剖分,不过把整个树看做一体,修改每个链选择根节点的规则,使得任何一个节点的深度不超过2logN。具体见杨哲的文章。
我很纠结地写出来了代码。
http://cid-354ed8646264d3c4.office.live.com/self.aspx/.Public/DynamicTree/2325^_3.cpp
说它纠结,其实倒不是建树的过程有多麻烦,相反,非常容易。恶心的是查询的写法。
我写的第一个版本跑得比link-cut tree还慢。经过参考各种代码之后,我终于找到了正确、高效的写法:
int Ask(int x,int y){
rec left=rec::empty(),right=rec::empty();
while (head[x]!=head[y]){
if (depth[head[x]]>depth[head[y]]){
for (int b=rc[x],i=x;i!=-1;i=tf[i]){
if (b==rc[i]){
left=left+R[i];
if (lc[i]!=-1)left=left+S[lc[i]];
}
b=i;
}
x=fa[head[x]];
}else{
for (int b=rc[y],i=y;i!=-1;i=tf[i]){
if (b==rc[i]){
right=right+R[i];
if (lc[i]!=-1)right=right+S[lc[i]];
}
b=i;
}
y=fa[head[y]];
}
}
int bx,by,flg=depth[x]<depth[y];
if (flg)bx=lc[x],by=rc[y];
else bx=rc[x],by=lc[y];
while (x!=y){
if (dep2[x]>dep2[y]){
if (flg && bx==lc[x]){
left=left+R[x];
if (rc[x]!=-1)left=left+S[rc[x]].reverse();
}else if (!flg && bx==rc[x]){
left=left+R[x];
if (lc[x]!=-1)left=left+S[lc[x]];
}
bx=x;
x=tf[x];
}else{
if (flg && by==rc[y]){
right=right+R[y];
if (lc[y]!=-1)right=right+S[lc[y]];
}else if (!flg && by==lc[y]){
right=right+R[y];
if (rc[y]!=-1)right=right+S[rc[y]].reverse();
}
by=y;
y=tf[y];
}
}
rec ret=left+R[x]+right.reverse();
return max(ret.b[0][0],ret.b[0][1]);
}
50多行,占代码中动态树部分的一半。
这里有特别多的细节,必须把所有东西都想明白才能AC。
效果是让人欣慰的:zjoi2011道馆之战那题
4 121844(5) fanhqme 5648 KB 1910 MS C++ 4821 B 2011-06-18 22:29:08
排名刷到了第4,非常有成就感。
我造了一组数据,通过gprof的统计,全局平衡二叉树的实现调用合并统计信息的函数的次数比link-cut tree的实现少60%
67.24 0.39 0.39 1601129 0.00 0.00 rec::operator+(rec const&) const
78.43 0.80 0.80 4291053 0.00 0.00 rec::operator+(rec const&) const
这就是为什么它的常数小;实际上,程序运行的大部分时间都花费在计算统计信息的和上了。
不过,正如唐文斌曾经问过的一个经典问题,”这东西你写过不重要,重要的是你还想写第2遍吗?”
我斩钉截铁地回答:不!
那么,动态树的高性价比的实现是什么呢?
树块剖分!
http://cid-354ed8646264d3c4.office.live.com/self.aspx/.Public/DynamicTree/2325^_1.cpp
动态树的部分就50多行(差不多跟GBT(Global Balanced Tree)的Ask部分一样长),可以先写朴素形式,之后改动20行变成树块剖分形式。
树块剖分的思想是什么呢?
我们依然把树剖分,不过,这回我们是把树剖分成若干个联通块。每个联通块里,我们维护每个点到整个联通块的根(连通块中深度最小的点)的信息和。
统计两个点之间的路径的信息的时候,我们把这个路径拆分到各个树块中,这样,除了这两个点的LCA所在的树块以外,其他的树块中的路径都是已经计算好的,直接累加即可。而LCA所在的树块中的信息可以暴力统计。
把树块的大小设定为sqrt(N),那么算法的时间复杂度就是sqrt(N)。虽然比logN大很多,不过,实际效果非常好:
下面是我造的一组道馆之战的数据的gprof的结果,可以看出,它的常数比link-cut tree仅仅大一点。
73.91 0.85 0.85 4408485 0.00 0.00 rec::operator+(rec const&) const(树块剖分)
78.43 0.80 0.80 4291053 0.00 0.00 rec::operator+(rec const&) const(link-cut tree)
67.24 0.39 0.39 1601129 0.00 0.00 rec::operator+(rec const&) const(GBT)
实践中效果也很好,zjoi的两个题AC毫无压力,甚至跑的比一些写的不是很好的link-cut tree还快。
有一个问题:如何让每个联通块的大小都是sqrt(N)呢?
当然,这是一个不可能的任务。
不过,我们可以把要求降低一点:
每个点到根的路径上的树块数为O(sqrt(N)),每个树块大小<=sqrt(N)。
这样,就有了一个简单的思路:尝试合并dfs入栈序相邻的两个节点,直到块的大小满了。这个可以用另一种语言来描述:
def dfs(a,color):
a.belong=color
color.size+=1
for i in a.childs:
if color.size<L:
dfs(i,color)
else:
dfs(i,i)
嗯,这个python程序表达的就是那个意思吧(换一种语言。。。)。
为什么这么做是对的呢?一句话证明:路径上相邻两个树块的大小的和肯定超过L。
划分完联通块之后,更新和查询都很好写。更新就是从这个节点往下dfs,修改同一个树块内的统计信息。查询就是两个节点比赛往上爬,直到找到LCA。
O(sqrt(N))是一个理论上的复杂度,实际中,比较弱的数据(例如随机数据)上根本到不了这个复杂度。所以,AC起来就非常愉快。
如何卡掉树块剖分呢?好像菊花形数据应该可以(一条链+一个星)让它达到理论复杂度
动态树除了维护形态静止的树上的信息外,还要处理动态的问题。
例如:
把一个子树砍下来
把另一棵树接上去
改变一个树的根
link-cut tree生下来就是为了处理这些问题的,通过强大的splay可以很容易来处理树的形态改变的问题。
那么其他的呢?
GBT可以见阎王了。当然,也可以有一些补救办法,例如修改的时候忽略2logN的约束,当树的形态太不像样了就重新建一遍(类似暴力懒惰删除)
树块剖分呢?
其实是可以在一定程度上动态起来的。
我们用如下的方法来维护树块:
对每次要访问的节点x,沿x走到根,把路径上相邻两个能合并的树块合并。
这样,理论复杂度不变,均摊下来都是O(sqrt(N))。(N是整个森林的节点数)
这么做得好处是,我们可以在树的形态改变的时候比较“奔放”地处理树块,在查询的时候再让它们规整起来就行了。
砍树和接树都能快速完成,唯独修改根不行。
总结一下吧。
目前的动态树问题都是静态的树,动态的信息。
追求代码短可以使用树块剖分,简洁高效易调错。
追求稳定可以用link-cut tree,复杂度有保证。
如果你的时间真的很充裕,如果你真的很牛,可以写GBT,效率高,常数小,时间复杂度低。
未来的发展方向呢?
1.重量级统计信息(例如6*N最短路之类的)
2.动态的形态(例题:动态最小生成树,正在研发中。。。)
3.和dp优化、网络流、图论等结合(例题:无向图动态连通性,正在找思路中。。。)