树和树上算法


前置知识:

一、树的存储和遍历

树是一张由n个点(n-1)条边组成的图

树的存储

我们用vector结构来存储树的边,建立一个二维动态数组,其中v[a]里存储了所有和a相连的边。

vector<int>v[100005];
int main()
{
    
    
    int n,m;
    scanf("%d",&n);
    for(int i=1;i<=n-1;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        v[tempb].push_back(tempa);
        v[tempa].push_back(tempb);
    }
}

树的遍历

遍历函数dfs拥有两个参数:pre和x,x表示当前节点,pre表示父亲节点。为了防止重复遍历当v[x][i]=pre时continue。

vector<int>v[100005];
ll dep[10000],fa[10000];
void dfs(int x,int pre)
{
    
    
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
    }
}

dfs序

就是dfs遍历树的时候节点出现的顺序,在这里插入图片描述

代码实现非常简单,只需要打一个数组即可

int cnt=0,dfss[10000],rnk[10000];
dfss[i]表示欧拉序为i的节点下标,rnk[i]表示节点i的dfs序
void dfs(int x,int pre)
{
    
    
    dfss[++cnt]=x;
    rnk[x]=cnt;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
    }  
}

欧拉序

图是Styx-ferryman画的,欧拉序就是按照图中红线顺序遍历时点出现的次序。
A->B->D->D->B->E->G->G->E->B->A->C->F->H->H->F->C->A
在这里插入图片描述
可以发现一个点在欧拉序列中不只出现一次。
在欧拉序中,lca节点的序号一定介于两个“子孙”节点的序号之间。
实现方法也非常简单,在dfs序的基础上进行一些小小的修改就可以了

int cnt=0,dfss[10000];
void dfs(int x,int pre)
{
    
    
    dfss[++cnt]=x;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
        dfss[++cnt]=x;
    }
    dfss[++cnt]=x;
}

二、树的基本概念

深度

结点i的深度为根到i的路径长,我们可以dp求出结点的深度

int dep[10000];
void dfs(int x,int pre)
{
    
    
    dep[x]=dep[pre]+1;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x); 
    }
}

子树大小

子树大小指该节点所在子树结点个数,同样可以dp求出

int dep[10000];
void dfs(int x,int pre)
{
    
    
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x); 
        siz[x]+=siz[v[x][i]];
    }
}
int main()
{
    
    
	for(int i=1;i<=n;i++)siz[i]=1;
	bfs();
}

红和蓝
这题观察出规律之后只需要处理一下子树大小就可以dp解决,是难得一见的练手题目。

树的直径

树形dp
树的最长链等于,最深节点深度最大子树A和最深节点深度次大子树B,的最深节点深度和+2。

#include<bits/stdc++.h>
#define ll long long
using namespace std;
vector<int>v[100000];
int d[10000],dep[10000];
void dfs(int x,int pre)
{
    
    
    int dp=0;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
        d[x]=max(d[x],dp+dep[v[x][i]]+1);
        dp=max(dp,dep[v[x][i]]+1);
    }
    dep[pre]=max(dep[pre],dep[x]+1);
}
int main()
{
    
    
    int n;
    scanf("%d",&n);
    for(int i=1;i<=n-1;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        v[tempa].push_back(tempb);
        v[tempb].push_back(tempa);
    }
    dfs(1,0);
    for(int i=1;i<=n;i++)printf("%d",dep[i]);
    cout<<endl;
    cout<<d[1];
    return 0;
}

两次dfs
先找出深度最大的点,然后从深度最大的点开始搜索找深度最大的点。

树的重心

树的重心也叫树的质心。对于一棵树n个节点的无根树,找到一个点,使得把树变成以该点为根的有根树时,最大子树的结点数最小。换句话说,删除这个点后最大连通块(一定是树)的结点数最小。
方法也是简单的,只需要处理结点的所有子树和树的根所在子树siz的最大值即可。

例题

void dfs(int x,int pre)
{
    
    
    int temp=0,maxx=0;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
        siz[x]+=siz[v[x][i]];
        temp+=siz[v[x][i]];
        maxx=max(maxx,siz[v[x][i]]);
    }
    maxx=max(maxx,n-temp-1);
    p[x]=maxx;这边用p记录一下去掉该点后最大子树大小
}

三、LCA

LCA(Least Common Ancestors)
即最近公共祖先,是指这样一个问题:在有根树中,找出某两个结点u和v最近的公共祖先(另一种说法,离树根最远的公共祖先)。

例题

1、暴力

找到点A和点B,先让比较深的点往上“跳”,当他们一样深度的时候一起往上“跳”,直到他们的父亲相同。

while(dep[a]<dep[b])a=fa[a];
while(dep[b]<dep[a])b=fa[b];
while(tempb!=tempa)b=fa[b],a=fa[a];

2、欧拉序+RMQ

因为我们知道:
两个节点的欧拉序之间深度比他们低的节点,一定是他们的公共祖先节点。
所以我们只需要查找他们欧拉序之间深度最大的公共祖先就可以找到他们的lca。
问题转化成了[a,b]之间的最值问题,即区间求最值问题。
我们可以使用倍增算法解决 R M Q RMQ RMQ问题。倍增算法
当然,如果会线段树自然也可以线段树维护一下,只是稍微慢一点。线段树查区间最值的复杂度是 l o g n logn logn

vector<int>v[100000];
int dfss=0,dep[100000],dp[10000][100],a[1000000],b[100000];
void makee(int l,int r)
{
    
    
    for(int i=l;i<=r;i++)dp[i][0]=a[i];
    for(int i=1;(1<<i)<=(r-l);i++)
    {
    
    
        for(int j=l;j+(1<<i)-1<=r;j++)
        {
    
    
            if(dep[dp[j][i-1]]<dep[dp[j+(1<<(i-1))][i-1]])dp[j][i]=dp[j][i-1];
            else dp[j][i]=dp[j+(1<<(i-1))][i-1];          
        }
    }
}
void dfs(int x,int pre)
{
    
    
    a[++dfss]=x;
    b[x]=dfss;
    dep[x]=dep[pre]+1;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
        a[++dfss]=x;
    }
    a[++dfss]=x;
}
int findd(int l,int r)
{
    
    
    int k=log(r-l)/log(2);
    if(dep[dp[l][k]]<dep[dp[l+(1<<k)][k]])return dp[l][k];
    else return dp[l+(1<<k)][k];
}
int main()
{
    
    
    int n;
    scanf("%d",&n);
    for(int i=1;i<=n-1;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        v[tempa].push_back(tempb);
        v[tempb].push_back(tempa);
    }
    dfs(1,0);
    makee(1,dfss);
    int m;
    scanf("%d",&m);
    for(int i=1;i<=m;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        cout<<findd(min(b[tempa],b[tempb]),max(b[tempb],b[tempa]))<<endl;
    }

}

3、tarjan算法

一种新的实现方式
TARJAN算法解决树上LCA问题

struct que{
    
    
    int a,b;
};
vector<int>v[100000],q[100000],ans[100000];
vector<que>lis;
int vis[100000],fa[100000],an[100000];
int findd(int x)
{
    
    
    if(fa[x]!=x)fa[x]=findd(fa[x]);
    return fa[x];
}
void make(int son,int father)
{
    
    
    if(findd(son)!=findd(father))fa[son]=findd(father);
}
void bfs(int x,int pre)
{
    
    
    an[x]=pre;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        bfs(v[x][i],x);
        make(v[x][i],x);
    }
    vis[x]=1;
    for(int i=0;i<q[x].size();i++)
    {
    
    
        if(x==q[x][i])
        {
    
    
            ans[x][i]=x;continue;
        }
        if(vis[q[x][i]]==0)continue;
        ans[x][i]=findd(q[x][i]);
    }
}
int main()
{
    
    
    int n;
    scanf("%d",&n);
    for(int i=1;i<n;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        v[tempa].push_back(tempb);
        v[tempb].push_back(tempa);
    }
    int m;
    scanf("%d",&m);

    for(int i=1;i<=m;i++)
    {
    
    
        int tempa,tempb;
        scanf("%d%d",&tempa,&tempb);
        q[tempa].push_back(tempb);
        q[tempb].push_back(tempa);
        ans[tempa].push_back(-1);
        ans[tempb].push_back(-1);
        lis.push_back({
    
    tempa,q[tempa].size()});
        if(tempa!=tempb)lis.push_back({
    
    tempb,q[tempb].size()});
    }
    for(int i=1;i<=n;i++)fa[i]=i;
    bfs(1,0);

    for(int i=0;i<lis.size();i++)
    {
    
    
        if(ans[lis[i].a][lis[i].b-1]!=-1)cout<<ans[lis[i].a][lis[i].b-1]<<endl;
    }
}

四、维护树的点权边权

1、维护子树点权边权

dfs序具有一些很优美的性质,其中之一就是子树里面所有的结点的dfs序列都是连续的,用这种方式我们可以维护一颗树的点权值。

例题

下面代码中l[x]到r[x]所在的连续区间就是该子树结点所在的dfs序区间,我们可以把对子树的结点权值修改转化成连续区间的修改,用线段树维护就可以了。

void dfs(int x,int pre)
{
    
    
    cnt=cnt+1;
    l[x]=cnt;
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        dfs(v[x][i],x);
    }
    r[x]=cnt;
}


相关习题:
ICPC Maratona de Programa ̧c ̃ao da SBC 2019-Denouncing Mafia
这道题总体来讲思路是极为简单的,我们设选取一个点之后增加的值为这个点的贡献,在刚开始每个点的贡献是他的深度加一。
当我们选取一个点之后, 观察操作对其他点贡献的影响:这个点与根的简单路径上的点,他们的子树节点贡献分别减一。
所以我们可以用子树dfs序连续的性质用线段树维护树上节点的点权(贡献)……
这是核心代码

for(int i=1;i<=K;i++)
    {
    
    
        ll temp=maxd[1];
        maxd[1]指的是整颗线段树上贡献最大的节点序号
        
        sum+=query(1,1,N,rnk[temp],rnk[temp]);
        sum加上这个节点的贡献
        
        while(temp!=-1&&vis[temp]==1)
        {
    
    
            vis[temp]=0;
            把这个点设为已经访问,不重复赋值,这样保证了整个遍历的复杂度最高为n
            update(1,1,N,l[temp],r[temp],-1);
            修改子树所在区间
            temp=dad[temp];
            修改整条链(从该点到根)
        }
    }

其他的部分就是上面内容的一个运用吧

2、维护树链点权边权

一、树链剖分

例题
树链就是树上的一条简单路径,我们如果用遍历对这条路径经过的点权进行修改,复杂度是非常高的,树链剖分可以把这条路径转化成若干连续区间段从而降低复杂度。
具体的实现方法网上博客有很多,这里就讲一些细节。
推荐博客:树链剖分
第一遍dfs处理出重儿子结点

void dfs_son(int x,int pre)
{
    
    
    dep[x]=dep[pre]+1;
    fa[x]=pre;
    int maxx=0;
    for(auto to:v[x])if(to!=pre)
    {
    
    
        siz[x]+=siz[to];
        dfs_son(to,x);
        if(siz[to]>maxx)
        {
    
    
            son[x]=to;
            maxx=siz[to];
        }
    }
    if(maxx==0)son[x]=0;
}

第二遍dfs处理出dfs,rnk,top数组

void dfs2(ll x,ll pre)
{
    
    
    if(son[pre]==x)top[x]=top[pre];
    else top[x]=x;
    rnk[x]=++cnt;
    dfs[cnt]=x;
    if(son[x]!=0)dfs2(son[x],x);
    for(int i=0;i<v[x].size();i++)
    {
    
    
        if(v[x][i]==pre)continue;
        if(v[x][i]==son[x])continue;
        dfs2(v[x][i],x);
    }
}

还想讲一下这个函数,要注意比较的dep是top[x]的而不是自己的dep,这样做的目的是为了“走过头”,也就是越过lca。

void update_chain(ll x,ll y,ll z)
{
    
    
    while(top[x]!=top[y])
    {
    
    
        if(dep[top[x]]>dep[top[y]])
        {
    
    
            update(1,1,n,rnk[top[x]],rnk[x],z);
            x=fa[top[x]];
        }
        else
        {
    
    
            update(1,1,n,rnk[top[y]],rnk[y],z);
            y=fa[top[y]];
        }
    }
    update(1,1,n,min(rnk[x],rnk[y]),max(rnk[x],rnk[y]),z);
}

最后注意一下,在多组样例的时候son[x]一定要清空,因为在dfs的时候如果x节点没有儿子并不会更新son[x](或者在dfs的时候注意son[x]的初始化)。

二、树上差分

在树链起点和重点+w,在起点和重点的lca上-w,查找某一点的点权和边权只需要求出该点所在子树的权值和。

五、树上启发式合并

考虑下面的问题:
给出一棵树,每个节点有颜色,询问所有节点子树的颜色数量(颜色可重复)。

如果因为加入和删去一个节点的颜色复杂度为 O ( 1 ) O(1) O(1),那么暴力遍历子树的复杂度是 ∑ i = 1 n s i z [ i ] \sum_{i=1}^{n} siz[i] i=1nsiz[i],是 n 2 n^2 n2级别的。

由于这个复杂度主要花费于清空容器,所以就产生了以下优化:对于某个节点,先得到轻儿子及其子树的答案,清空容器,然后统计重儿子及其子树的答案,保留数据,再遍历其他轻儿子及其子树,把它们的数据与重儿子合并,最后添加自己到容器。
因为每个节点最多当 l o g n logn logn次轻儿子,所以总的复杂度不会大于 2 n l o g n 2nlogn 2nlogn

例题1:月出皎兮,佼人僚兮

由于我们可以贪心的把最少的颜色和最大的颜色匹配,可以知道当 m a x < t o t / 2 max<tot/2 max<tot/2时是可以在相差1的意义下全匹配的,所以只需要多花费一个logn维护一个map就可以。 2 n l o g n 2 2nlogn^2 2nlogn2

void dfs(int x,int pre)
{
    
    
    for(int i=0;i<v[x].size();i++)if(v[x][i]!=pre&&v[x][i]!=son[x])
    先遍历轻儿子
    {
    
    
        dfs(v[x][i],x);
        dfs_del(v[x][i],x);
        删去轻儿子上的节点颜色
    }
    if(son[x]!=0)dfs(son[x],x);
    遍历重儿子
    for(int i=0;i<v[x].size();i++)if(v[x][i]!=pre&&v[x][i]!=son[x])
    {
    
    
        dfs_add(v[x][i],x);
    }
    add(x);
    加上自己
    ans[x]=getans();
}

例题2:Strange Memory
对于某个节点,先得到轻儿子及其子树的答案,清空容器。
然后开始统计以它作为lca的答案,先统计重儿子及其子树的答案,保留数据,再遍历其他轻儿子及其子树(即统计子树间贡献),最后添加自己到容器。
注:异或和可用二进制拆位解决

void dfs(int x,int pre)
{
    
    
    for(auto to:v[x])if(to!=pre&&to!=son[x])
    {
    
    
        dfs(to,x);
        dfs_del(to,x);
    }
    if(son[x]!=0)
    {
    
    
        dfs(son[x],x);
    }
    for(auto to:v[x])if(to!=pre&&to!=son[x])
    {
    
    
        getans(to,x,x);
        得到以x为lca的答案
        dfs_add(to,x);
    }
    add(x);
}

六、树分治

1、点分治

把答案分为包含根的和不包含根的,计算包含根的答案(如果可以 O ( n ) O(n) O(n)求得),然后去掉根使得整棵树变成若干棵子树,重复以上的步骤。如果我们每次选取的根都是重心,可以知道每个点被遍历的次数小于 l o g 2 n log_2n log2n,总体复杂度约为 n l o g 2 n nlog_2n nlog2n

void divide(int tr)
{
    
    
    getans(tr);获得以当前节点为根节点的答案
    vis[tr]=1;标记当前节点为已访问
    for(auto to:v[tr])if(vis[to]!=1)
    {
    
    
        getroot(to);获得子树重心
        divide(root);求得以子树重心为根的答案
    }
}

P3806 【模板】点分治1
P4178 Tree
这两道题都是关于树上距离,而树上距离可以看成是位于不同子树的点到根的距离之和。

void getans(int x,int pre,int dis)
{
    
    
    for(auto to:v[x])if(to.to!=pre&&vis[to.to]!=1)
    {
    
    
        getans2(to.to,x,dis+to.dis);求出这棵子树内的点和之前子树中的点的距离和符合条件的点对
        addans(to.to,x,dis+to.dis);将该子树中点的距离加入答案集合
    }
}
void divide(int tr)
{
    
    
	统计以tr为根的答案
    getans(tr,0,0);
    清空答案区间
    delans(tr,0,0);
	标记tr为已访问
    vis[tr]=1;
    for(auto to:v[tr])if(vis[to.to]!=1)
    {
    
    
        tot=siz[to.to];
        maxroot=10000000;
        root=0;
        求重心前的初始化
        
        getroot(to.to,0);
        divide(root);
    }
}

猜你喜欢

转载自blog.csdn.net/solemntee/article/details/111051966
今日推荐