前言:算法竞赛中常见的树问题
- (二叉)树的遍历
- 树的重心
- 树的直径
- 最近公共祖先(LCA)
- 哈夫曼树
- 树链剖分
一、(二叉)树的遍历
二叉树的遍历(Traversing binary tree)是指从根节点出发,按照某种次序一次访问二叉树中所有的节点,是的每个节点被依次访问且仅被访问一次。
我们规定一种遍历顺序为先访问中间的节点,在访问左边子树,当左面都访问完成以后再回来访问右面的子树。这样的话,对于下面这一棵树,我们访问节点的顺序应该是A->B->D->F->G->H->I->E->C。
这种遍历顺序和DFS入栈的顺序很像,这种二叉树遍历方式称为先序遍历。除了先序遍历外,还有另外两种遍历,中序遍历和后序遍历。
这三种遍历方式的特点归结如下:
- 先序遍历:访问根节点,遍历左子树,遍历右子树;
- 中序遍历:遍历左子树,访问根节点,遍历右子树;
- 后序遍历:遍历左子树,遍历右子树,访问根节点。
对于上面的那棵树,给出三种遍历方式是:
- 先序遍历:A->B->D->F->G->H->I->E->C;
- 中序遍历:F->D->H->G->I->B->E->A->C;
- 后序遍历:F->H->I->G->D->E->B->C->A。
说了这么多,二叉树的遍历有什么用呢?答案是,没太大用。一般情况下是用来当做题目中的信息。对于OIer来说这些是常识,初赛会考的。
二、树的重心
树的重心,也叫树的质心。对于一棵树来说,删去该树的重心后,所有的子树的大小不会超过原树大小的二分之一。树的重心还有一个性质,是相对于树上的其他点而言的,就是删去重心后形成的所有子树中最大的一棵节点数最少。换句话说,就是删去重心后生成的多棵子树是最平衡的。一棵树的重心至多有两个。
下面考虑重心的求法。我们考虑用第一个性质来求,这样比较简单。我们可以很容易的在一次DFS过程中求出所有节点的siz,即子树大小。我们每搜索完一个节点u的儿子v,就判断siz[v]是否大于n/2,然后在搜索完所有儿子后计算出本节点的siz,再判断n-siz[u]是否大于n/2(n-siz[u]是节点u上面的连通块大小)即可求出重心,时间复杂度O(n)。
比如对于下面这棵树。
我们任意选取一个节点作为根,将其转为有根树,假设我们了选取节点1。这棵树就会转成下面的样子。假设我们正在节点4处。我们需要判断删去节点4后,这棵树的任一子树大小是否会超过n/2,就是图中标出的三块。我们分别判断4的所有儿子(siz[5]和siz[6])是否大于n/2,再判断节点4上面的部分(n-siz[4])是否大于n/2,对于节点4,上述两个条件均被满足,那么节点4是这棵树的一个重心。同样地,我们可以求出节点2也是这棵树的重心。
下面是代码实现(实际上是POJ1655Balancing Act的代码,DFS过程即可求出重心)。感谢Anonymous366提供代码,我在其基础上进行了修改,并增加了注释。
#include<cstdio>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=20100;
int n,father;
int siz[maxn];//siz保存每个节点的子树大小。
bool vist[maxn];
int CenterOfGravity=0x3f3f3f3f,minsum=-1;//minsum表示切掉重心后最大连通块的大小。
vector<int>G[maxn];
void DFS(int u,int x){//遍历到节点x,x的父亲是u。
siz[x]=1;
bool flag=true;
for(int i=0;i<G[x].size();i++){
int v=G[x][i];
if(!vist[v]){
vist[v]=true;
DFS(x,v);//访问子节点。
siz[x]+=siz[v];//回溯计算本节点的siz
if(siz[v]>n/2) flag=false;//判断节点x是不是重心。
}
}
if(n-siz[x]>n/2) flag=false;//判断节点x是不是重心。
if(flag && x<CenterOfGravity) CenterOfGravity=x,father=u;//这里写x<CenterOfGravity是因为本题中要求节点编号最小的重心。
}
void init(){
memset(vist,false,sizeof(vist));
memset(siz,0,sizeof(siz));
minsum=-1;
CenterOfGravity=0x3f3f3f3f;
for(int i=0;i<maxn;i++) G[i].clear();
}
int main(){
int T;
scanf("%d",&T);
while(T--){
scanf("%d",&n);
init();
for(int i=1;i<n;i++){
int u,v;
scanf("%d%d",&u,&v);
G[u].push_back(v);
G[v].push_back(u);
}
vist[1]=1;
DFS(-1,1);//任意选取节点作为根,根节点的父亲是-1。
for(int i=0;i<G[CenterOfGravity].size();i++)
if(G[CenterOfGravity][i]==father) minsum=max(minsum,n-siz[CenterOfGravity]);
else minsum=max(minsum,siz[G[CenterOfGravity][i]]);
printf("%d %d\n",CenterOfGravity,minsum);
}
return 0;
}
三、树的直径
树的直径,即树上的最长路径,显然,树的直径可以有很多条(考虑一棵菊花)。
接下来我们考虑如何求出一棵树的直径。有很多种O(n)的算法。
算法1:我们任取树中的一个节点x,找出距离它最远的点y,那么点y就是这棵树中一条直径的一个端点。我们再从y出发,找出距离y最远的点就找到了一条直径。这个算法依赖于一个性质:对于树中的任一个点,距离它最远的点一定是树上一条直径的一个端点。
下面给出证明。
考虑这样一棵树,我们假设AB是树的直径,C的最远点为D,那么有AC<CD,a+c<d,因为c>0,所以a<d+c,故有a+b<b+c+d,AB<BD,与假设AB是直径矛盾,故性质得证。
算法2:首先,先将无根树转成有根树,定义F[i]表示从i出发向远离根节点的方向走的最长路径的长度,G[i]表示从i向远离根节点的方向走的次长路径的长度。注意F[i]和G[i]不能沿着i的同一个儿子走。特别地,如果i只有一个儿子,那么G[i]=0。答案为max(F[i]+G[i])。
下面是代码实现。再次感谢Anonymous366提供代码,我也进行了修改,并加了注释。这份代码可以求出带权树中的直径,如果只是一棵普通的树,那么val赋为1即可。
#include<cstdio>
#include<vector>
#include<cstring>
using namespace std;
const int maxn=10100;
int n,ans;
int f[maxn],g[maxn];//f表示最长路,g表示次长路。
bool vist[maxn];
struct Node{
int to,val;
Node(int to=0,int val=0):to(to),val(val){}
};
vector <Node> G[maxn];
void DFS(int x){
f[x]=g[x]=0;
for(int i=0;i<G[x].size();i++){
Node v=G[x][i];
if (!vist[v.to]){
vist[v.to]=true;
DFS(v.to);//访问子节点。
vist[v.to]=false;
if (f[x]<f[v.to]+v.val){//如果发现了一条更长的路径,那么更新f[x]和g[x]。
g[x]=f[x];//原来的f[x]变为次长路,新发现的记为最长路。
f[x]=f[v.to]+v.val;
}
else if (g[x]<f[v.to]+v.val) g[x]=f[v.to]+v.val;//如果找到了一条比次长路更长的路径,那么更新g[x]。
}
}
ans=max(ans,f[x]+g[x]);
}
int main(){
scanf("%d",&n);
for(int i=1;i<n;i++){
int u,v,val;
scanf("%d%d%d",&u,&v,&val);
G[u].push_back(Node(v,val));
G[v].push_back(Node(u,val));
}
vist[1]=true;
DFS(1);
printf("%d\n",ans);
return 0;
}
这次的讲解就到这里,其它的问题会在后续博客中更新。