ACM算法总结 树上问题




简介

是一种联通无向非循环图,对于 n 个结点的树来说有 n-1 条边;如果不要求联通,我们称之为森林

在数据结构中有很多实用的树形结构,但是大多数都是基于二叉树的结构,这里更多地讨论一般树形结构。

关于树的一些名词定义:

  • :人为指定的一个结点;
  • 结点深度:从根结点到该结点的路径上的边数;
  • 树的高度:结点深度的最大值;
  • 叶结点:度数为 1 的非根结点;
  • 父亲、祖先、儿子、兄弟、后代:顾名思义即可

存储二叉树我们一般用数组存储,用 k<<1 和 k<<1|1 表示两个儿子,用 k>>1 表示父亲,对于一般树使用邻接表的存储方法(类似图),可以新开 f 数组记录父亲结点,或者在搜索时传递父亲以区分上下关系。




树的直径

从任意一个结点开始 bfs 找到最远结点,再从最远结点开始 bfs 找到最远结点,这个距离就是直径。




树的重心

定义:树上所有结点到重心的距离之和最小。 这等价于对于每一个点,取其所有子树中最大的结点数,这个数最小的就是重心。

求解方法就和定义类似,我们 dfs 的时候计算出每一个子树的结点数,然后取其中最大的那个,更新答案。

代码如下:

const int maxn=1e5+5;
vector<int> G[maxn];
int n,root,siz[maxn],max_siz=maxn;

void get_root(int u,int fa)
{
    int maxx=0; siz[u]=1;
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i];
        if(v==fa) continue;
        get_root(v,u);
        siz[u]+=siz[v];
        maxx=max(maxx,siz[v]);
    }
    maxx=max(maxx,n-siz[u]);
    if(maxx<max_siz || (maxx==max_siz && u<root)) max_siz=maxx,root=u;
}




树链剖分

下图为一棵树的树链剖分(重链剖分):

树链剖分把一棵树分为若干条不相交的重链,连接这些重链的树边称为轻链,一些名词定义如下:

  • 重儿子:所有儿子中子树最大的儿子结点(如有多个任取);
  • 轻儿子:除了重儿子之外的所有儿子;
  • 重边:父亲和重儿子的连边;
  • 轻边:父亲和轻儿子的连边;

注意单个结点我们也看成一条重链,这样整棵树就被划分为若干条不相交的重链,而每条重链有一个顶端结点(图中蓝色结点),表示这条重链的起始结点,也是这条重链中深度最小的结点。除此之外,重链的 dfn(dfs序,图中红色数字)一定是连续的。

树链剖分的过程就是两次 dfs 的过程,第一次处理 f、siz、d、son(父亲,子树大小,深度,重儿子),第二次处理 dfn、which、top(dfs序,dfn的逆(即dfs序为 i 的结点编号为which[i]),结点所在重链的起始结点)。注意第二次 dfs 要优先搜索重儿子。

代码如下:

const int maxn=1e6+5;
vector<int> G[maxn];
int n,m,root,cnt,a[maxn];
int siz[maxn],f[maxn],d[maxn],son[maxn],dfn[maxn],which[maxn],top[maxn];

void dfs1(int u,int fa,int depth)
{
    f[u]=fa; d[u]=depth; siz[u]=1; son[u]=0;
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i];
        if(v==fa) continue;
        dfs1(v,u,depth+1);
        siz[u]+=siz[v];
        if(siz[v]>siz[son[u]]) son[u]=v;
    }
}

void dfs2(int u,int tf)
{
    top[u]=tf; dfn[u]=++cnt; which[cnt]=u;
    if(!son[u]) return;
    dfs2(son[u],tf);
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i];
        if(v!=f[u] && v!=son[u]) dfs2(v,v);
    }
}

树链剖分本身只是一种对树形结构的操作,进行按重链划分,但它对于处理树上问题很有帮助。比如说对于 洛谷P3384重链剖分 ,要求维护结点 u 和 v 路径上的权值和以及某一结点的子树权值和。

由于同一条重链的 dfn 是连续的,所以我们可以用线段树维护 dfn 序列的权值和。对于两个结点路径权值和,我们将两个结点不停往上跳重链,每跳一次对线段树更新一次;对于某一结点子树权值和,发现子树的 dfn 也是连续的,我们通过子树根结点和其 siz 值计算出应该更新的 dfn 区间,然后更新线段树。

还有一种树链剖分后不维护点权,而是维护边权,这个时候我们可以把边变成一个点,然后与原来的两端的点相连,这样就转换为点权了。




LCA

最近公共祖先(Least Common Ancestor),即两个结点 u 和 v 的所有祖先中最深的那个,该结点必然在 u 和 v 的路径上。

  • d i s t ( u , v ) = d ( u ) + d ( v ) − 2 d ( L C A ( u , v ) ) dist(u,v)=d(u)+d(v)-2d(LCA(u,v)) dist(u,v)=d(u)+d(v)2d(LCA(u,v)),其中 d 为深度,或者是某个结点到根的距离;

将树进行树链剖分之后,求解LCA就转变为对两个结点不停地往上跳重链(u=f[top[u]]),直到两个结点在同一条重链为止,这时比较浅的那个就是LCA。这里要注意往上跳重链的时候,要优先跳起始结点比较深的那个。

代码如下:

int LCA(int x,int y)
{
    while(top[x]!=top[y])
        d[top[x]]>d[top[y]]?(x=f[top[x]]):(y=f[top[y]]);
    return d[x]<d[y]?x:y;
}

还有一种倍增的方法也可以求解LCA。

  • 可换根的LCA:结点 u 和结点 v 的以 w 为根的LCA就是 LCA(u,v)、LCA(u,w)、LCA(v,w) 中最深的那个。




点分治

点分治用来处理树上路径问题。

对于某一个有根树,树上的路径分为两种:经过根的和不经过根的。但是不经过根的路径一定是某一棵子树上经过根的路径。所以树上的路径可以都看作是经过根的路径。要处理某一路径信息时,我们可以对子树选取一个根,然后枚举其儿子,统计儿子的路径信息,然后合并到总路径信息列表中,对下一个儿子处理时同时根据已有的总路径信息判断某一条件是否成立,这样的话就可以处理当前子树经过根的所有路径信息。处理完当前子树后对所有儿子所在子树递归处理即可。

对于某一子树,我们选取其重心作为树根,这样保证复杂度为 O(nlogn) 级别。

对于 洛谷P3806 点分治1 ,要求判断树上是否存在长度为 k 的路径,代码如下:

const int maxn=1e5+5;
struct edge {int v,w;};
vector<edge> G[maxn];
int n,m,root,siz[maxn],max_siz=maxn;
int e[maxn],dis[maxn],all_dis[maxn],tot,all_tot;
bool is[maxn*100],vis[maxn],ans[maxn];

void get_root(int u,int fa)
{
    int maxx=0; siz[u]=1;
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i].v;
        if(v==fa || vis[v]) continue;
        get_root(v,u);
        siz[u]+=siz[v];
        maxx=max(maxx,siz[v]);
    }
    maxx=max(maxx,n-siz[u]);
    if(maxx<max_siz || (maxx==max_siz && u<root)) max_siz=maxx,root=u;
}

void dfs(int u,int fa,int depth)
{
    dis[tot++]=depth;
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i].v,w=G[u][i].w;
        if(v==fa || vis[v]) continue;
        dfs(v,u,depth+w);
    }
}

void solve(int s)
{
    vis[s]=1; is[0]=1;
    all_tot=0;
    REP(i,0,G[s].size()-1)
    {
        int v=G[s][i].v,w=G[s][i].w;
        tot=0;
        dfs(v,s,w);
        REP(j,0,tot-1) REP(k,1,m)
            if(dis[j]<=e[k] && is[e[k]-dis[j]]) ans[k]=1;
        REP(j,0,tot-1) all_dis[all_tot++]=dis[j],is[dis[j]]=1;
    }
    REP(i,0,all_tot-1) is[all_dis[i]]=0;
    REP(i,0,G[s].size()-1)
    {
        int v=G[s][i].v;
        if(vis[v]) continue;
        root=0; max_siz=maxn;
        get_root(v,s);
        solve(root);
    }
}

int main()
{
    //freopen("input.txt","r",stdin);
    n=read(),m=read();
    REP(i,1,n-1)
    {
        int u=read(),v=read(),w=read();
        G[u].push_back((edge){v,w});
        G[v].push_back((edge){u,w});
    }
    REP(i,1,m) e[i]=read();
    get_root(1,0);
    solve(root);
    REP(i,1,m) puts(ans[i]?"AYE":"NAY");

    return 0;
}

以及对于 洛谷P4178 Tree ,要求统计长度小于等于 K 的路径数目,在统计某一子树的根的儿子的总路径信息时,我们用线段树维护长度区间路径数目即可。




支配树

给定一个有向图 G,并且给定一个起始结点 s,对于某一个结点 x,如果从 s 到 x 的路径中一定经过某个结点 y,那么就说 y 是 x 的支配点。

支配树是一个这样的东西:根结点为 s,某个结点 x 的祖先都是它的支配点。

可以看出支配树有这样的性质:

  • 所有和 s 连通的结点(s 可以到达的结点)都会在支配树中,因为任意结点一定存在一个支配点 s;
  • 结点 x 的父亲为其最近支配点;

求解支配树的过程有点复杂,我还没有学完,不过板子是有的了。

代码:

struct dominant_tree
{
    #define maxn 500005
    VI a[maxn],b[maxn],c[maxn],t[maxn];
    int dfn[maxn],which[maxn],f[maxn],cnt,n,root;
    int sdom[maxn],idom[maxn],far[maxn],val[maxn];

    void dfs(int u,int fa)
    {
        dfn[u]=++cnt; which[cnt]=u; f[u]=fa;
        for(int v:a[u]) if(!dfn[v]) dfs(v,u);
    }

    int find(int x)
    {
        if(x==far[x]) return x;
        int t=find(far[x]);
        if(dfn[sdom[val[far[x]]]]<dfn[sdom[val[x]]]) val[x]=val[far[x]];
        return far[x]=t;
    }

    void tarjan()
    {
        REP_(i,cnt,2)
        {
            int u=which[i];
            for(int v:b[u]) if(dfn[v])
            {
                find(v);
                if(dfn[sdom[val[v]]]<dfn[sdom[u]]) sdom[u]=sdom[val[v]];
            }
            c[sdom[u]].pb(u);
            far[u]=f[u]; u=f[u];
            for(int v:c[u])
            {
                find(v);
                if(sdom[val[v]]==u) idom[v]=u;
                else idom[v]=val[v];
            }
        }
        REP(i,2,cnt)
        {
            int u=which[i];
            if(idom[u]!=sdom[u]) idom[u]=idom[idom[u]];
        }
    }

    void build(int n,int root,vector<P> E)
    {
        this->n=n; this->root=root; cnt=0;
        REP(i,0,n) a[i].clear(),b[i].clear(),c[i].clear(),t[i].clear();
        REP(i,0,n) sdom[i]=val[i]=far[i]=i;
        REP(i,0,n) idom[i]=dfn[i]=f[i]=which[i]=0;
        for(P e:E)
        {
            int u=e.first,v=e.second;
            a[u].pb(v); b[v].pb(u);
        }
        dfs(root,0);
        tarjan();
        REP(i,1,n) if(i!=root) t[idom[i]].pb(i);
    }
};

指定有向图结点个数 n、起始结点 root 以及边集 E,调用 build 就可以构建出支配树,里面的 t 存储支配树的树形结构。




树上最长路

这是一个这样的问题:给出一棵带边权的树,让你求出每个结点到其它结点的最长路。

我们可以随意指定一个根 root,然后第一次 dfs 可以算出每个结点的子树中的最长路 down[i] ,然后考虑如何在第二次 dfs 时计算出每个结点祖先的最长路 up[i] 。可以用一张图形象表示:

我们在 dfs 结点 u 时已经得到了 up[u],然后我们需要计算出它每一个儿子的 up 。对于每一个儿子,用 down[v]+w 去更新维护的一个最大值和次大值,计算完 down[v]+w 的最大值和次大值之后,对于每一个儿子,如果它不是最大值的那个儿子,那么有 up[v]=w+max(up[u], 最大值);如果它是最大值的那个儿子,那么有 up[v]=w+max(up[u], 次大值) 。这样对于每个结点,max(up[u], down[u]) 就是它到其它结点的最长路。

这道题 Computer 的代码:

#include <bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof(a))
#define REP(i,a,b) for(int i=(a);i<=(int)(b);i++)
#define REP_(i,a,b) for(int i=(a);i>=(b);i--)
#define pb push_back
using namespace std;
typedef long long LL;
typedef vector<int> VI;
typedef pair<int,int> P;
int read()
{
    int x=0,flag=1;
    char c=getchar();
    while((c>'9' || c<'0') && c!='-') c=getchar();
    if(c=='-') flag=0,c=getchar();
    while(c<='9' && c>='0') {x=(x<<3)+(x<<1)+c-'0';c=getchar();}
    return flag?x:-x;
}

const int maxn=1e4+5;
struct edge {int v,w;};
vector<edge> G[maxn];
int n,up[maxn],down[maxn];

void dfs_down(int u,int fa)
{
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i].v,w=G[u][i].w;
        if(v==fa) continue;
        dfs_down(v,u);
        down[u]=max(down[u],down[v]+w);
    }
}

void dfs_up(int u,int fa)
{
    if(!fa && G[u].size()==0) return;
    if((!fa && G[u].size()==1) || (fa && G[u].size()==2))
    {
        int v=G[u][0].v,w=G[u][0].w;
        if(v==fa) v=G[u][1].v,w=G[u][1].w;
        up[v]=up[u]+w;
        dfs_up(v,u);
    }
    int max1=0,max2=0,u1,u2;
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i].v,w=G[u][i].w;
        if(v==fa) continue;
        if(down[v]+w>max1) max2=max1,u2=u1,max1=down[v]+w,u1=v;
        else if(down[v]+w>max2) max2=down[v]+w,u2=v;
    }
    REP(i,0,G[u].size()-1)
    {
        int v=G[u][i].v,w=G[u][i].w;
        if(v==fa) continue;
        if(v==u1) up[v]=w+max(max2,up[u]);
        else up[v]=w+max(max1,up[u]);
        dfs_up(v,u);
    }
}

int main()
{
    while(~scanf("%d",&n))
    {
        REP(i,1,n) G[i].clear(),up[i]=down[i]=0;
        REP(i,2,n)
        {
            int v=read(),w=read();
            G[i].pb((edge){v,w});
            G[v].pb((edge){i,w});
        }
        dfs_down(1,0);
        dfs_up(1,0);
        REP(i,1,n) printf("%d\n",max(down[i],up[i]));
    }

    return 0;
}

这里计算最大值和次大值时,如果儿子个数少于2那就不用这么麻烦了,所以在 dfs_up 一开始有一个讨论的过程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值