《程序设计解题策略》——1.5 利用动态树维护森林的连通性

1.5 利用动态树维护森林的连通性

本节将介绍一种特殊的数据结构——动态树(dynamic tree)。在正式介绍动态树之前,我们先来分析一个带有普遍性的问题。
1.5.1 动态树的问题背景
给出一棵共有n(n≤10000)个节点的树,每条边都有一个权值,要求维护一个数据结构,支持如下操作:
操作1——修改某条边的权值。
操作2——询问某两个节点之间唯一通路上的最大边权。
其中操作的总次数为q。
对于操作2,我们很容易想到一种算法:先求出被询问的两节点的最近公共祖先,然后分别求出这两个节点至最近公共祖先的两条路径上的最大边权,两者的最大值即为解。显然,这种直译的算法会超时。要提高解题的时效,就必须探索一种新的数据结构,用来维护从某个点往根方向路径的权值。这就提出了动态树的问题背景。
动态树能够维护一个带权的森林、支持边的插入与删除、支持树的合并与分离及支持寻找路径上费用最小的边。所有操作的均摊复杂度为O(logN)。由于动态树中所有节点或者部分节点是不固定的,可随需要而动态变动,具备一般静态树无法企及的功能,因此在网络优化等领域得到广泛应用。 

这里,我们仅考虑一个简化的动态树问题,如图1.5-1所示,它只包含对树的形态的操作和对某个点到根的路径的操作,不包含对子树的操作。为此将动态树中的边分成实边、虚边两种,从每个节点出发,最多有1条实边连向它的子节点。一条路径包括一些自底向上连通的实边,剩下的边都是虚边。
维护这样一个数据结构,须支持以下典型的操作:
1) MAKE TREE():新建一棵仅有一个节点的树。
2) CUT(v):删除v与它的父亲节点parent(v)的边,相当于将点v的子树分离了出来。
3) LINK(v,w):让v成为w的新的儿子,其中v是一棵树的根节点,并且v和w是不同的两棵树中的节点。
4) FIND_ROOT(v):返回v所在的树的根节点。
搞清了这些问题,就容易扩充这个数据结构,维护每个点到它所属树的根节点的路径的一些信息,例如权和、边权的最大值、路径长度等。
Link-Cut Tree是由Sleator和Tarjan发明的解决这类动态树问题的一种数据结构,这个数据结构可以在均摊O(logN)的时间内实现上述动态树问题的每个操作。
下面我们先介绍Link-Cut Tree的概念,然后介绍它支持的各种操作,最后对它的复杂度进行分析。
1.5.2 Link-Cut Tree的定义
称一个点u被访问过当且仅当对u刚执行了Access(u)操作(即u到根的路径打通成为实边,并且u的孩子节点的实边变成虚边)。如果节点v的子树中,最后被访问的节点在子树w中,这里w是v的儿子,那么就称w是v的Pre_ferred Child。如果最后被访问过的节点就是v本身,那么它没有Pre_ferred Child。每个点到它的Pre_ferred Child的边称作Preferred_Edge。由Preferred_Edge连接成的不可再延伸的路径称为Preferred_Path。
这样,整棵树就被划分成了若干条Preferred_Path。对每条Preferred_Path,用这条路上的点的深度作为关键字,用一棵平衡树来维护它(在这棵平衡树中,每个点的左子树中的点都在Preferred_Path中这个点的上方;右子树中的点都在Preferred_Path中这个点的下方)。需要注意的是,这种平衡树必须支持分离与合并。这里,我们选择伸展树作为其数据结构,这棵平衡树称为Auxiliary Tree(图1.5-2)。

知道了树T分解成的这若干条Preferred_Path,只需要再知道这些路径之间的连接关系,就可以表示出这棵树T。用Path_Parent来记录每棵Auxiliary Tree对应的Preferred_Path中的最高点的父亲节点,如果这个Preferred_Path的最高点就是根节点,那么令这棵Auxiliary Tree的Path_Parent为null。
Link-Cut Tree就是将被维护森林中的每棵树T表示为Auxiliary Tree,并通过Path_Parent将这些Auxiliary Tree连接起来的数据结构。
1.5.3 Link-Cut Tree的基本操作和时间复杂度分析
下面介绍Link-Cut Tree的9种基本操作。
(1) Splay(p)
把节点p旋到Splay的根部。

void Splay(node *p){        // 把节点p旋到Splay的根部
while(p->f && (p->f->l==p || p->f->r==p)){
// 若p的父亲存在且p为其父的左右儿子之一,则调整
node q=p->f,y=q->f;// 记q为p的父亲,y为p的祖父
if (y && y->l==q){// 在q是y的左儿子的情况下,若p是q的左儿子,则q右旋后p右旋;
// 若p是q的右儿子,则p左旋右旋
if (q->l==p)zig(q),zig(p);
else zag(p),zig(p);
}else if (y && y->r==q){// 在q是y的左儿子的情况下,若p是q的右儿子,则q左旋后p左旋;
// 若p是q的左儿子,则p右旋左旋
if (q->r==p)zag(q),zag(p);
else zig(p),zag(p);
}else// 在q是树根的情况下,若p是q的左儿子,则p右旋;若p是q的
// 右儿子,则p左旋
{
if (q->l==p)zig(p);
else zag(p);
}
}
p->update();// 调整p子树的信息
}
 ```
<div style="text-align: center">
 <img src=" https://yqfile.alicdn.com/8d47bf84772c21ec6c4cd106c7f4ffa66bbd9009.png" >
</div>
(2) Access(u)
这个操作是核心操作,保证经过Access(u)之后u到根的路径打通成为实边,并且u的孩子节点的实边变成虚边。也就是这条Preferred_Path一端是根,一端是u。图1.5-3为一棵Link-Cut Tree经过一次Access操作后的变化。
```javascript
Node access(Node u)
{
Node* v = EMPTY;         // 初始时v=EMPTY是为了清除u原来的孩子。因为不是每次Access
// 都需要把最后节点旋到Splay的根,所以不须最后Splay(v)
for (;u!=EMPTY;u=u->parent)// 向上追溯u至根的路径
{
splay(u);// 把节点u旋到Splay的根
u->child[1] = v;// 把上一次的Splay的根节点v当作u的右孩子
update(v = u);// 将u记为v并调整其子树信息
}
return v;// 返回最后访问的节点v,即原树根
}

(3) MakeRoot(x)
把x当作原树的根(因为是无向图,所以随便指定树根)。

inline void makeroot(Node* const x)
{
access(x)->rev ^= true;// 打通x到原根的路径,并打翻转标记(交换左右子树,也就是在原树中
// 交换上下关系)
splay(x);// 清除标记
}

(4) Find_Root(x)
返回x所属的Preferred_Path在原树的最上方节点,常用来判断两个节点是否同属一棵树。

Node Find_Root(Node x)// 构建一条一端是根、一端是x的Preferred_Path,然后不断往左走
// (往原树的根方向走),边走边清除标记,最终使得x变为所属Auxiliary Tree的最小节点
{
for (x=access(x); clear(x),x->child[0]!=EMPTY;x=x->child[0]);
return x;// 返回根节点
}

(5) Cut(x,y)
以x为树根,把y到x的路径分离出来。

inline void cut(Node const x, Node const y)
{
makeroot(x);// 把x当作原树的根
access(y);// 构建一条一端是根、一端是y的Preferred_Path
splay(y);// 将y旋转至根
y->child[0]->parent=EMPTY;// 在y所属 Auxiliary Tree 中断开y与它的左子树的连接
y->child[0] = EMPTY;
update(y);// 更新y所在子树的信息
}

(6) Link(x,y)
把x和y所在的子树连接起来(森林中树的连接)。

inline voidLink(Node const x, Node const y)
{
makeroot(x);// 把x当作原树的根
x->parent = y;// 修改 x所属的Auxiliary Tree的Path_Parent为y
access(x);// 构建一条一端是根、一端是x的Preferred_Path
}

(7) Query(x,y)
以询问x到y路径上的最大值为例:

inline int query(Node x, Node y)
{
makeroot(x);// 把x当作原树的根
access(y), splay(y);// 构建一条一端是根、一端是y的Preferred_Path,将y旋转至根
return y->max;// 返回y子树的最大值
}

(8) Modify(x,y)
在x到y路径上做修改,以加上某一个值w为例:

inline void modify(Node x, Node y, const int w)
{
makeroot(x);// 把x当作原树的根
access(y), splay(y);// 构建一条一端是根、一端是y的Preferred_ Path,将y旋转至根
_inc(y, w);// 同Splay中的清除标记
}

(9) splay_parent(x,y)
看x的父亲y是否为x所在Splay的父亲。这个条件可成为Splay过程中的终止条件。

inline bool _splay_parent(Node x, Node (&y))
{ return (y=x->parent)!=EMPTY &&(y->child[0]==x ||y->child[1]==x); }

从上述9种操作的伪代码中可以看出,除Access操作之外的其他操作,其均摊时间复杂度至多为O(logN),而每一个动态树操作都需要用到1次Access(),所以只用分析Access()的时间复杂度。
对于动态树中的边(v,w),如果v的子孙>w的子孙/2,称为A类边,否则称为B类边。显然每个点最多连出一条A类边,树中每条路径上最多有O(log2N)条B类边。令p为B类边中的虚边数。执行一次Access(),可能执行许多次Splay()操作,每一次Splay()操作分为以下两种情况:
若添加一条B类虚边进入路径,则p+1,这种情况最多发生O(log2N)次;
若添加一条A类虚边进入路径,则p-1,可能发生许多次,但前提保证p是非负数。
由此可以看出平均每次Access()操作要执行O(log2N)次路径操作。
根据定理1.4-1,在每一次Splay操作中,调整树的结构与保持伸展树不变量的总花费不超过3log2s」+1(s表示伸展树s的节点个数),也就是说,Splay操作中多次旋转的复杂度叠加最多为3log2s」+1。Access(v)操作是把从v到动态树树根路径上的所有虚边消除,并合并路径上的伸展树,因此路径的合并操作均摊复杂度不会超过3log2s」+1,叠加后可得到Access(v)的复杂度不超过3log2s」+k(k是从v到树根路径上的虚边个数)。由平均每次Access(v)操作要执行O(log2N)次路径操作,可知平均k是对数级别的,而log2s」也是对数级别的,所以Access(v)均摊复杂度为O(log2N),这也就是所有动态树操作的时间界。
1.5.4 应用动态树解题
由于动态树能够高效地维护带权森林,有效地支持边的插入与删除、支持树的合并与分离、支持寻找路径上费用最小的边,因此在解题中大有用武之地。下面就通过两个范例说明动态树的应用价值。
【1.5.1 洞穴勘测】
【问题描述】
辉辉热衷于洞穴勘测。某天,他按照地图来到了一片被标记为JSZX的洞穴群地区。经过初步勘测,辉辉发现这片区域由n个洞穴(分别编号为1到n)以及若干通道组成,并且每条通道连接了恰好两个洞穴。假如两个洞穴可以通过一条或者多条通道按一定顺序连接起来,那么这两个洞穴就是连通的,按顺序连接在一起的这些通道则被称为这两个洞穴之间的一条路径。洞穴都十分坚固无法破坏,然而通道不太稳定,时常因为外界影响而发生改变。比如,根据有关仪器的监测结果,123号洞穴和127号洞穴之间有时会出现一条通道,有时这条通道又会因为某种稀奇古怪的原因被毁。辉辉有一台监测仪器可以实时将通道的每一次改变状况在他手边的终端机上显示:如果监测到洞穴u和洞穴v之间出现了一条通道,终端机上会显示一条指令Connect u v;如果监测到洞穴u和洞穴v之间的通道被毁,终端机上会显示一条指令Destroy u v。经过长期艰苦卓绝的手工推算,辉辉发现一个奇怪的现象:无论通道怎么改变,任意时刻任意两个洞穴之间至多只有一条路径。因而,他坚信这是由于某种本质规律的支配导致的,所以更加夜以继日地坚守在终端机之前,试图通过通道的改变情况来研究这条本质规律。然而,终于有一天,辉辉在堆积成山的演算纸中崩溃了……他把终端机往地面一砸(终端机也足够坚固无法破坏),转而求助于你,说道:“老兄你把这程序写写吧。”辉辉希望能随时通过终端机发出指令Query u v,向监测仪询问此时洞穴u和洞穴v是否连通。现在你要为他编写程序回答每一次询问。已知在第一条指令显示之前,JSZX洞穴群中没有任何通道存在。
输入:
第一行为两个正整数n和m,分别表示洞穴的个数和终端机上出现过的指令的个数。以下m行,依次表示终端机上出现的各条指令。每行开头是一个表示指令种类的字符串s(“Connect”、“Destroy”或者“Query”,区分大小写),之后有两个整数u和v(1≤u,v≤n且u≠v)分别表示两个洞穴的编号。
输出:
对每个Query指令,输出洞穴u和洞穴v是否互相连通:是输出“Yes”,否则输出“No”(不含双引号)。

数据说明:

10%的数据满足n≤1000,m≤20000
20%的数据满足n≤2000,m≤40000
30%的数据满足n≤3000,m≤60000
40%的数据满足n≤4000,m≤80000
50%的数据满足n≤5000,m≤100000
60%的数据满足n≤6000,m≤120000
70%的数据满足n≤7000,m≤140000
80%的数据满足n≤8000,m≤160000
90%的数据满足n≤9000,m≤180000
100%的数据满足n≤10000,m≤200000

保证所有Destroy指令将摧毁的是一条存在的通道。本题输入、输出规模比较大,建议C\C++选手使用scanf和printf进行I\O操作以免超时
试题来源:SDOI 2008
测试地址:BZOJ2049 http://www.lydsy.com/JudgeOnline/problem.php?id=2049
试题解析
简述题意:在一幅图上进行三类操作:断开u、v之间的边,在u、v之间连一条边,询问u、v是否连通。任何操作须保证不会出现环,即维护这幅图的森林性质。
此题其实是动态树的模板题。三类命令分别与动态树的三种典型操作完全对应:
“Destroy u v”命令需要将两个节点u、v间的边删除,即将一棵树切割为两棵树(对应cut操作)。我们首先要选出两个节点中深度较大的点。计算一个节点v深度的简单方法是先access(v),然后splay(v),v的左子树就是根到v这一条链上的节点。如果v不是根节点,v的左子树大小就是v的深度。设u的深度较浅。我们需要先access(v)。由于u一定是v的parent节点,因此我们splay(u)后直接切断两点的边,即断开u在它的所属Auxiliary Tree中与它的左子树的连接即可。
“Connect u v”命令需要将两个属于不同树的节点连边,即将两棵树合并(对应link操作)。操作时,若需要将节点u挂在节点v上,可以先access(u),然后将u旋转至splay的根部,再将u和v连一条虚边。这时,splay中是u的根到u这一条链。由于动态树维护的是有根树,我们可以将u这一整棵树的根换成v所属的树的根。这样,由于splay的关键字是深度,而splay中原本深度最大的点u合并后离新根的距离最近,u的深度变成了最小,原来的根深度却变成最大的—整条链翻转了过来。因此,我们需要给u打上翻转标记。
“Query u v”命令就是通过执行Find_Root(u)和Find_Root(v),找出含u的Prefer Path在原树最上方的节点rb和含v的Prefer Path在原树最上方的节点rb。若ra==rb,则说明节点ra和rb在同一棵树中,u和v间有通道;否则u和v间无通道。计算find_root()其实很简单,只要access后找到splay中最小的点即可。
程序清单

#include <cstdio>
#include <algorithm>
const int MAXN = 10002;           // 节点数上限
struct Node// 节点的结构定义
{
bool rev;// 翻转标记
Node parent, child[2];// 父指针和左右儿子指针
} _memory[MAXN], *EMPTY=_memory;// 动态树

inline void clear(Node* const x)// 节点x翻转
{
if (x == EMPTY) return;// 若x节点空,则退出
if (x->rev)// 若x翻转,则左右儿子的翻转标志取反
{
x->child[0]->rev ^= true;x->child[1]->rev ^= true;
std::swap(x->child[0], x->child[1]);// 交换x的左右儿子
x->rev = false;// 撤去x的翻转标志
}
}

void rotate(Node* const x, const int c)// 节点x按c方向旋转
{
Node* const y = x->parent;
y->child[c^1] = x->child[c];
if (x->child[c] != EMPTY) x->child[c]->parent = y;
x->parent = y->parent;
if (y->parent->child[0] == y) x->parent->child[0] = x; else
if (y->parent->child[1] == y) x->parent->child[1] = x;
x->child[c] = y;
y->parent = x;
}

inline bool _splay_parent(Node const x, Node (&y))
// 看x的父亲y是否为x所在splay的父亲
{ return (y = x->parent) != EMPTY && (y->child[0] == x || y->child[1] == x); }

void splay(Node* const x)// 把节点x旋到splay的根部
{
clear(x);// 节点x翻转
for (Node y, z; _splay_parent(x, y); )
if (_splay_parent(y, z))// 若y的父亲z为y所在splay的父亲
{
clear(z), clear(y), clear(x);// x、y和z翻转
const int c = y == z->child[0];// 根据y在z的儿子方向,确定旋转方向
// 若x是y的c方向的儿子,则x先按c的相反方向旋转,
// 再按c方向旋转;若x是y的c的相反方向的儿子,则y和x先后按c方向旋转
if (x == y->child[c]) rotate(x, c^1), rotate(x, c);
else rotate(y, c), rotate(x, c);
}
Else// y的父亲z非y所在splay的父亲
{
clear(y), clear(x);// y和x翻转
rotate(x, x == y->child[0]);// 若x非y的左儿子,则x左旋;否则x右旋
}
}

Node access(Node u)// 将u到根的路径打通成为实边,且u的孩子节点的实边变
// 成虚边,使得这条Prefer Path一端是根,一端是u
{
Node* v = EMPTY;// 初始时v=EMPTY是为了翻转u原来的孩子。因为不是每
// 次access都需要把最后节点旋到splay的根,所以不须最后splay(v)
for (; u != EMPTY; u = u->parent)// 向上追溯u至根的路径
{
splay(u);// 把节点u旋到splay的根部
u->child[1] = v;// 把上一次splay的根节点v当作u的右孩子
v = u;// 记下本次的splay根节点
}
return v;// 返回最后访问到的节点v,即原树根
}

inline Node getroot(Node x)// 计算x所属的Prefer Path在原树最上方的节点
{
for (x = access(x);clear(x), x->child[0] != EMPTY; x = x->child[0]);
// 构建一条一端是根、一端是x的Prefer Path,然后不断往左走(往原树的根方向走), 边走边清除标记,最终使
// 得x变为所属Auxiliary Tree的最小节点
return x;// 返回x所属的Prefer Path在原树最上方的节点
}

inline void makeroot(Node* const x)// 把x当作原树的根
{
access(x)->rev ^= true;// 打通到原根的路径,并翻转标记取反(交换左右子树,也就
// 是在原树中交换上下关系)
splay(x);// 将x旋转到根,即翻转
}

void link(Node const x, Node const y)// 把x和y所在的子树连接起来

{
makeroot(x);// 把x当作原树的根
x->parent=y;// 修改x所属的Auxiliary Tree的Path Parent为y
access(x);// 构建一条一端是根、一端是x的Prefer Path
}

void cut(Node const x, Node const y)// 以x为树根,把y到x的路径分离出来
{
makeroot(x);// 把x当作原树的根
access(y), splay(y);// 构建一条一端是根、一端是y的Prefer Path, 将y旋转至根
y->child[0]->parent=EMPTY;y->child[0]=EMPTY;
// 断开y在它的所属 Auxiliary Tree 中与它的左子树的连接
}

int n, m;// 洞穴数和指令数
int main()
{
scanf("%d%d", &n, &m);// 输入洞穴数和指令数
for (int i = 0; i <= n; ++i)// 动态树初始化为空
{
Node* const node = _memory+i;
node->child[0] = node->child[1] = node->parent = EMPTY;
}
for (int i = 0, x, y; i < m; ++i)// 处理每条命令
{
static char buf[10];
scanf("\n%s%d%d", buf, &x, &y);// 输入命令buf和参数x、y
Node ra, rb;
switch (buf[0])
{
case 'Q':// 若询问命令
ra=getroot(_memory+x), rb=getroot(_memory+y);
// 计算含x的Prefer Path在原树的最上方节点ra和含y的Prefer Path在原树的最上方节点ra
printf(ra == rb && ra != EMPTY ? "Yes\n" : "No\n"); break;
// 若ra和rb在同一棵树,则洞穴x和洞穴y可互相连通;否则不连通
case 'D': cut(_memory+x, _memory+y); break;
// 若洞穴x和洞穴y之间的通道被毁,则以_memory[x]为树根,把_memory[y]到_memory[x]的路径分离出来
case 'C': link(_memory+x, _memory+y); break;
// 若洞穴x和洞穴y之间出现通道,则把_memory[x]和_memory[y]所在的子树连接起来
}
}
}

如果说[1.5.1 Cave洞穴勘测]是计算无权森林中树的合并、分离和询问节点间的连通关系,那么,我们再通过一个范例将问题拓展至有权森林中的路径计算。
【1.5.2 Tree】
【问题描述】
给出一棵树的N个节点,树的节点编号从1到N,树的边的编号从1到N-1,每条边和一个权值相关联。请你对树执行一系列的指令。指令如表1.5-1所示。

输入:
输入包含多个测试用例。输入的第一行给出一个整数t(t≤20),表示测试用例的数目。然后给出多个测试用例。
每个测试用例之前有一个空行。第一个非空行给出N(N≤10000)。接下来的N-1行每行给出3个整数a、b和c,表示连接a和b的权值为c的一条边。在输入中边按出现的次序编号。然后给出指令,每条指令形式如上。最后一行给出单词“DONE”结束测试用例。
输出:
对每条“QUERY”指令,在单独的一行中输出结果。

试题解析
简述题意:给你一棵树,要求支持下列3个操作。
1) 询问两点之间路径的边中边权最大的。
2) 把两点之间的路径的边权全部取反。
3) 修改某条边的边权。
根据试题要求,我们给伸展树节点定义如下域:
父指针pre、左儿子指针ls、右儿子指针rs、取反标志neg、入边的权key、子树的最大值maxkey和最小值minkey及root(当前节点是否为它所在的splay tree的根标志root)。
所谓取反,就是将当前节点的key取负,maxkey=-minkey、minkey=-makeyx。取反过程打懒惰标记neg,取反标志下传就是将左右儿子的neg标志和节点的key、max和min信息取反。
我们使用Link-Cut Tree数据结构来维护这棵树,仅需要对Auxiliary Tree做一些扩充。在Auxiliary Tree中,记录每个节点到它的父亲节点的边权key和以它为根的子树中所有节点的key的最大值maxkey。由于求最大值的运算是满足结合律的,所以maxkey可以在Auxiliary Tree旋转的过程中用O(1)的时间维护。
对于修改边权的操作,我们只需要更新相应节点在它所属的Auxiliary Tree中的key,并对这个节点进行splay操作来维护这棵Auxiliary Tree中相关节点的maxkey。显然,这个操作的时间复杂度是O(logn)。
对于询问u到v的路径的最大边权的操作,我们先access(u),然后在access(v)的过程中,一旦走到了包含根节点(也就是包含u)的Auxiliary Tree,这时我们走到的节点恰好就是u和v的最近公共祖先,不妨称这个节点为d。这时d所在的Auxiliary Tree中d的右子树的maxkey即为u到d的路径的最大边权,v所在的Auxiliary Tree的maxkey则是v到d的路径的最大边权。于是我们所求的答案就是这两个maxkey中的最大值。因为Access操作的均摊复杂度为O(logn),所以回答这个询问所花时间也是O(logn)的。
对于把x和y之间的路径的边权全部取反的操作亦是一样:先access(x),将节点x到根上的所有点按深度用一棵splay维护,左边比根深度小,右边比根深度大。然后依次枚举y至根路径上的每个节点fy,每次通过splay操作将fy旋转至根,然后将fy右儿子至y的路径取反,将其后继节点p至y的路径取反,使得(fy右儿子,p)的边取反,而p至y的路径上的边保持不变。依次类推,直至fy=0为止。
程序清单

#include<stdio.h>
#include<string.h>
#define MAXD 100010// 节点数上限
#define MAXM 200010// 边数上限
#define INF 0x7fffffff// 定义无穷大
int N,q[MAXD],first[MAXD],e,next[MAXM],v[MAXM],w[MAXM],dep[MAXD];
// 节点数n,队列q[],节点x在邻接表的首条边序号为first[x],该边的端点v[e],权w[e],后继边next[e];
// 节点x的度dep[x]
struct Edge// 边的结构定义
{
int x, y, z;// 权为z的边表(x,y)
}edge[MAXD];// 边表
struct Splay// 伸展树节点的结构定义
{
int pre, ls, rs, neg, key, max, min;// 父指针、左右儿子指针、取反标志,边权、子树的最大值和最小值
bool root;// 当前节点是否为它所在的Splay tree的根标志
void update(); void pushdown(); void zig(int ); void zag(int ); void splay(int );
// 伸展树操作:调整子树的最大值和最小值update(),取反操作pushdown()、右旋操作zig(int )、左旋操作
// zag,通过一系列旋转将边调整至伸展树的根部splay(int )
void renew()// 构造单个节点的sply树
{
root = true; pre = ls = rs = 0; neg = 0;
}
}sp[MAXD];// 伸展树sp[]

int Max(int x, int y)// 计算x和y的较大值
{
return x > y ? x : y;
}

int Min(int x, int y)// 计算x和y的较小值
{
return x < y ? x : y;
}

void Splay::update()// 伸展树操作:调整子树的最大值和最小值
{
max = Max(Max(sp[ls].max, sp[rs].max), key);
min = Min(Min(sp[ls].min, sp[rs].min), key);
}

void makeneg(int cur)// 节点cur取反
{
if(cur!=0)// 节点cur的取反标志取反。该边的关键字取负,max域取
// min域的负值,min域取max域的负值
{
sp[cur].neg ^= 1, sp[cur].key = -sp[cur].key;
int t = sp[cur].max;sp[cur].max = -sp[cur].min, sp[cur].min = -t;
}
}

void Splay::pushdown()// 伸展树操作:下传取反的懒惰标志
{
if(neg)// 若取反的懒惰标志存在,则下传给左右儿子,撤去其懒惰标志
{
makeneg(ls), makeneg(rs); neg = 0;
}
}

void Splay::zig(int x)// 伸展树操作:节点x右旋操作
{
int y = rs, fa = pre;
pushdown(), sp[y].pushdown();
rs = sp[y].ls, sp[rs].pre = x;
sp[y].ls = x, pre = y; sp[y].pre = fa;
if(root) root = false, sp[y].root = true;
else sp[fa].rs == x ? sp[fa].rs = y : sp[fa].ls = y;
update();
}
void Splay::zag(int x)// 伸展树操作:节点x左旋操作
{
int y = ls, fa = pre;
pushdown(), sp[y].pushdown();
ls = sp[y].rs, sp[ls].pre = x;
sp[y].rs = x, pre = y; sp[y].pre = fa;
if(root) root = false, sp[y].root = true;
else sp[fa].rs == x ? sp[fa].rs = y : sp[fa].ls = y;
update();
}
void Splay::splay(int x)// 保持伸展树有序性的前提下,通过一系列旋转将伸展树中
// 的元素x调整至树的根部,维护x子树的最大值和最小值
{
int y, z;
for(pushdown(); !root;)
{
y = pre;
if(sp[y].root) sp[y].rs == x ? sp[y].zig(y) : sp[y].zag(y);
else
{
z = sp[y].pre;
if(sp[z].rs == y)
{
if(sp[y].rs == x) sp[z].zig(z), sp[y].zig(y);
else sp[y].zag(y), sp[z].zig(z);
}
else
{
if(sp[y].ls == x)
sp[z].zag(z), sp[y].zag(y);
else
sp[y].zig(y), sp[z].zag(z);
}
}
}
update();
}

void add(int x, int y, int z)// 将权为z的边(x,y)加入邻接表
{
v[e] = y, w[e] = z;
next[e] = first[x], first[x] = e ++;
}

void prepare()// 计算树中每个节点的父指针、层次、key、max和min域初始化
{
int i, j, x, rear = 0;// 队尾指针初始化
sp[0].max = -INF, sp[0].min = INF;
q[rear ++] = 1;// 节点1入队
sp[1].renew(), dep[1] = 1;// 为节点1申请内存,层次为1
for(i = 0; i < rear; i ++)// 队列中的节点依次出队,直至队列空
{
x = q[i];
for(j = first[x]; j != -1; j = next[j])
// 搜索x的每条出边,设定儿子节点的父指针、层次、key、max和min域设为边权,儿子入队
if(v[j] != sp[x].pre)
{
sp[v[j]].renew(), sp[v[j]].pre = x, dep[v[j]] = dep[x]+1;
sp[v[j]].key = sp[v[j]].max = sp[v[j]].min=w[j];
q[rear ++] = v[j];
}
}
}

void Swap(int &x, int &y)// 交换x和y
{
int t;
t = x, x = y, y = t;
}

void init()// 输入信息,构造相邻矩阵
{
int i;
memset(first, -1, sizeof(first));
e = 0;
scanf("%d", &N);// 输入节点数
for(i = 1; i < N; i ++)// 输入每n-1条边信息,构建邻接表
{
scanf("%d%d%d", &edge[i].x, &edge[i].y, &edge[i].z);
add(edge[i].x,edge[i].y,edge[i].z),add(edge[i].y,edge[i].x,edge[i].z);
}
prepare();// 计算树中每个节点的父指针、层次、key、max和min域初始化
for(i = 1; i < N; i ++)// 每条边x端节点的深度大
if(dep[edge[i].x]>dep[edge[i].y])Swap(edge[i].x, edge[i].y);
}

void access(int x)// 从节点x到根上的所有点按深度用一棵splay维护,左边
// 比根深度小,右边比根深度大
{
int fx;
for(fx=x,x=0;fx!=0;x=fx,fx=sp[x].pre)
// 依次枚举x至根路径上的每个节点fx
{
sp[fx].splay(fx);sp[sp[fx].rs].root=true;
// 将fx旋转至根,fx的右儿子设旋转标志
sp[fx].rs=x,sp[x].root=false;sp[fx].update();
// fx的右儿子设为x,x不再作为splay的根,维护fx子树的最大值和最小值
}
}

void Change(int x, int y)// 将节点x的出边权值改为y
{
sp[x].splay(x); sp[x].key = y; sp[x].update();
// 将节点x调整到树的根部,将出边权改为y,维护其子树的最大值和最小值
}

void Negate(int x, int y)// 消除x到y路上每条边的权值
{
int fy;
access(x);// 从节点x到根上的所有点按深度用一棵splay维护,左边
// 比根深度小,右边比根深度大
for(fy=y, y=0;fy!=0;y=fy,fy=sp[y].pre)
// 依次枚举y至根路径上的每个节点fy
{
sp[fy].splay(fy);// 将fy旋转至根
if(sp[fy].pre == 0) makeneg(sp[fy].rs), makeneg(y);
// 若fy为根,其右儿子取反,后继节点y取反
sp[sp[fy].rs].root=true;sp[fy].rs=y,sp[y].root=false;
// 设fy的右儿子为splay的根标志,fy的右儿子更新为y,取消y的根标志
sp[fy].update();// 维护fy子树的最大值和最小值
}
}

void Query(int x, int y)// 计算和输出x到y路上的最大权值
{
int fy;
access(x);// 从节点x到根上的所有点按深度用一棵splay维护,左边
// 比根深度小,右边比根深度大
for(fy = y, y = 0; fy != 0; y = fy, fy = sp[y].pre)
// 依次枚举y至根路径上的每个节点fy(其后继节点为y)
{
sp[fy].splay(fy);// 将fy旋转至根
if(sp[fy].pre == 0)// 若fy为根,则x到y路上的最大权值为max{fy右子树的
// 最大值,y子树的最大值}
printf("%d\n", Max(sp[sp[fy].rs].max, sp[y].max));
sp[sp[fy].rs].root = true; sp[fy].rs = y, sp[y].root = false;
// 设splay的根为fy的右儿子,y调整为fy的右儿子,取消y的根标志
sp[fy].update();// 维护fy子树的最大值和最小值
}
}

void solve()// 依次处理当前测试用例的每条命令
{
int x, y;// 命令参数
char op[10];// 命令字
for(;;)
{
scanf("%s", op);// 输入命令字
if(op[0] == 'D') break;// 若为结束命令,则退出
scanf("%d%d", &x, &y);// 输入命令参数
if(op[0] == 'C') Change(edge[x].y, y);
// 将第x条边的权值改为y
else if(op[0] == 'N') Negate(x, y);
// 消除x到y路上每条边的权值
else Query(x, y);// 计算和输出x到y路上的最大权值
}
}

int main()
{
int t;
scanf("%d", &t);// 输入测试用例数
while(t --)// 依次处理每个测试用例
{
init();// 输入信息
solve();// 依次处理当前测试用例的每条命令
}
return 0;
}
时间: 2024-11-02 10:19:17

《程序设计解题策略》——1.5 利用动态树维护森林的连通性的相关文章

《程序设计解题策略》——第1章 利用树型数据关系的解题策略 1.1 利用划分树求解整数区间内第k大的值

第1章 利用树型数据关系的解题策略 树是一个具有层次结构的集合,一种限制前件数且没有回路的连通图.在现实生活和程序设计的竞赛试题中,许多问题的数据关系呈树型结构,因此有关树的概念.原理.操作方法和一些由树的数据结构支持的算法,一直受到编程者的重视,被广泛应用于解题过程.在本章里,我们将介绍利用树型数据关系解题的七种策略: 1) 利用划分树求解整数区间内第k大值. 2) 利用最小生成树及其扩展形式(最优比率生成树.最小k度生成树.次小生成树)计算有权连通无向图中边权和满足限制条件的最优生成树. 3

《程序设计解题策略》—— 导读

前言 策略即计策和谋略,指一种总体的行为方针和行事方法,即一种可以实现目标的方案集合,而非纠缠于细枝末节的雕虫小技.程序设计的解题策略指的是编程解题过程中所采取的一种基本方法,是对解题方法的综合性.智能性和个性化的认识.尤其是当面对非标准.非模式化的问题时,就更需要发挥创造性思维,求索应对策略和解题艺术.正如古人所言"术谋之人以思谟为度,故能成策畧之奇,而不识遵法之良". 对于程序设计竞赛选手的培养,教师应注重在两个方面系统地训练选手:①程序设计竞赛的知识体系:②程序设计的解题策略.

《程序设计解题策略》——1.3 利用线段树解决区间计算问题

1.3 利用线段树解决区间计算问题 在现实生活中,我们经常遇到与区间有关的问题,例如,记录一个区间的最值(最大或最小值)和总量,并在区间的插入.删除.修改中维护这些最值和总量:再如,统计若干矩形并的面积.线段树拥有良好的树形二分特征,我们可以从其定义和结构中发现,在线段树的基础上完成这些问题的计算操作,是十分简捷和高效的.1.3.1 线段树的基本概念 一棵二叉树,记为T(a,b),参数a.b表示该节点表示区间[a,b].区间的长度b-a记为L.递归定义T[a,b]: 若区间长度L>1:区间[a,

《程序设计解题策略》——1.2 利用最小生成树及其扩展形式解题

1.2 利用最小生成树及其扩展形式解题 设G=(V,E,ω)是连通的有权无向图(节点集为V,边集为E,边权集为ω),连通图G中包含所有节点,且只有V-1条边的连通子图即为G的生成树.边权值和最小的生成树称为最小生成树.在现实生活中,最小生成树的原理和算法有着广泛的应用.程序设计竞赛中与最小生成树有关的试题一般有两类: 1) 构建最小生成树. 2) 应用最小生成树原理优化算法. 本节除了深入研讨最小生成树的性质和求解方法外,还给出了三种特殊类型生成树: 1) 最优比率生成树. 2) 最小k度限制生

《程序设计解题策略》——1.7 本章小结

1.7 本章小结 树是一个具有层次结构的集合,一种没有回路的连通图,在程序设计竞赛中,许多试题的数据关系呈现树型结构.为了拓宽读者对树的认识.拓展树的应用范围.掌握更多利用树型结构解题的处理方法,我们在本章中介绍了以往竞赛曾涉及的一些前沿知识: 1) 探讨了如何利用划分树求解整数区间内第k大的值:即先离线构建出整个查询区间的划分树,然后在划分树中查找某子区间中第k大值.这个算法保证能在O(logn)的时间复杂度内快速求出问题解. 2) 通过分析一些蕴涵最小生成树性质的试题,给出了应用最小生成树原

《程序设计解题策略》——1.4 利用改进型的二叉查找树优化动态集合的操作

1.4 利用改进型的二叉查找树优化动态集合的操作 我们知道,二叉查找树(binary search tree)能够支持多种动态集合操作,因此在程序设计竞赛中,二叉查找树起着非常重要的作用,它可以用来表示有序集合.建立索引或优先队列等.作用于二叉查找树上的基本操作时间是与树的高度成正比的:对于一棵含n个节点的二叉查找树,如果呈完全二叉树结构,则这些操作的最坏情况运行时间为O(log2n):但如果呈线性链结构,则这些操作的最坏情况运行时间退化为O(n).针对二叉查找树这种不平衡.不稳定的弊病,人们做

《程序设计解题策略》——1.6 利用左偏树实现优先队列的合并

1.6 利用左偏树实现优先队列的合并 优先队列在程序设计竞赛中十分常见,在统计问题.最值问题.模拟问题和贪心问题等类型的题目中,优先队列都有着广泛的应用.二叉堆是一种常用的优先队列,它编程简单.效率高,但如果问题需要对两个优先队列进行合并,二叉堆的效率就无法令人满意了.为了解决这个问题,我们引入了左偏树.1.6.1 左偏树的定义和性质在介绍左偏树之前,我们先来回顾一下优先队列和可并堆的概念.优先队列(priority queue):一种抽象数据类型(ADT).它是一种容器,里面有一些元素,这些元

利用Ext Js生成动态树实例代码_javascript技巧

一. 需求 要求生成一颗部门树,初始只列出根部门 当点击一个部门节点时,动态载入该部门下的直属子部门,并展开该部门节点 部门节点要求支持右键单击事件,当点击右键时,列出相关操作菜单 二. 关键类 这里主要涉及Ext JS的两个类: Ext.tree.TreeNode Ext.menu.Menu 相关API可以参考:http://extjs.com/deploy/ext/docs/ 三. 代码示例 1. 先看一下测试页面 复制代码 代码如下: <html> <head> <me

AJAX实现动态树型结构

ajax|动态|树型结构 树型结构是一类应用非常广泛的数据结构.人类社会中宗族的族谱和现代企业的组织形式都是树型结构.在计算机领域中,文件系统中文件的管理结构.存储器管理中的页表.数据库中的索引等也都是树型结构.随着Internet的飞速发展,树型结构在浏览器/服务器(Browser/Server,简称B/S)应用系统的应用也越来越广泛. 目前,在互联网上广泛存在.应用的树型结构一般分为两种:静态和动态结构.静态结构存在最多.实现简单,但是静态导致不能改变树的结构和内容,无法反映树的节点信息的变