zoukankan      html  css  js  c++  java
  • 树链剖分 --算法竞赛专题解析(30)

    本系列文章将于2021年整理出版。前驱教材:《算法竞赛入门到进阶》 清华大学出版社
    网购:京东 当当   作者签名书:点我
    有建议请加QQ 群:567554289

    @

       树链剖分是对树的一种巧妙分割。它按一定规则把树“剖分”成一条条线性的不相交的链,并通过DFS序对这些链上的结点重新编号,这些编号具有美妙的特征,能够使用线段树来处理,从而高效地解决一些树上的修改和查询问题。

    1. 树链剖分求LCA

       首先通过求LCA介绍树链剖分的基本概念。树链剖分的题目也需要用到LCA。
       求LCA的各种算法,都是快速往上“跳”到祖先结点。回顾上一节求LCA的两种方法,其思想可以概况为:(1)倍增法,用二进制递增直接往祖先“跳”;(2)Tarjan算法,用并查集合并子树,子树内的结点都指向子树的根,查LCA时,可以从结点直接跳到它所在的子树的根,从而实现了快速跳的目的。
       树链剖分也是“跳”到祖先结点,它的跳法比较巧妙。它把树“剖”为从根到叶子的一条条链路,链路之间不相交;每条链上的任意两个相邻结点都是父子关系;每条链路内的结点可以看成一个集合,并以“链头”为集;链路上的结点找LCA时,都指向链头,从而实现快速跳的目的。特别关键的是,从根到叶子只需要经过O(logn)个链,那么从一个结点跳到它的LCA,只需要跳O(logn)个链。
       如何把树剖成链,使得从根到叶子经过的链更少?注意每个结点只能属于一个链。很自然的思路是观察树上结点的分布情况,如果选择那些有更多结点的分支建链,链会更长一些,从而使得链的数量更少。

    图1 (1)选择有更多结点的分支建链         (2)随意建链

       图1(1)从根结点a开始,每次选择有更多子结点的分支建链,最后形成了粗线条所示的3条链。从叶子结点到根,最多经过2条链。例如从h到a,先经过d为头的链,然后就到了a为头的链。
       图(2)随意建链,最后得到4条链。从叶子节点到根,最多经过了4条链。例如从q到a,经过了j链、e链、b链、a链。
       下面详细解释剖链的过程。图(1)是剖好的链的例子。首先定义以下概念。
       重儿子:对一个非叶子结点,它最大的儿子是重儿子。所谓“最大”,是指以这个儿子为根的子树上的结点数量最多(包括这个儿子)。例如,a的重儿子是b,因为以b为根的子树有8个结点,比另一个儿子c大,以c为根的子树只有3个结点。又例如,e的重儿子是j。
       轻儿子:除了重儿子以外的儿子。例如a的轻儿子是c,b的轻儿子是d。
       重边:连接两个重儿子的边,例如边(a, b)、(b, e)等。定义重边的目的是得到重链。
       重链:连续的重边连接而成的链,或者说连续的重儿子形成的链。重链上的任意两个相邻结点都是父子关系。例如a、b、e、j、q形成了一条重链。每一条重链以轻儿子为起点。可以把单独的叶子结点看成一条重链,例如h。
       轻边:除重边以外的边。任意两个重链之间由一条轻边连接。
       链头:一条重链上深度最小的点。链头是一个轻儿子。如果把一条重链看成一个集合,链头就是这个集合的集。设top[x]是结点x所在重链的链头,图(1)中例如:top[e] = top[j] = a,top[f] = top[c] = c。
       利用以上定义剖好的链,最关键的一个性质是:从任意一个点出发,到根结点的路径上经过的重链不会超过 logn条。由于每两条重链之间是一个轻边,也可以这样说:经过的轻边不会超过logn条。
       下面证明经过的轻边不会超过logn条。以二叉树为例,x的一个轻儿子y,y 的子树大小必然小于x的子树大小的一半。从根结点往任意一个结点走,设size为当前结点的子树大小,那么每经过一条轻边,size至少除以2。所以最后到达叶子结点时,最多经过了 logn 条轻边。如果是多叉树,每经过一条轻边,size减少得更快,经过的轻边也少于logn个。
       经过剖分得到重链之后,如何求两个结点x、y的LCA(x, y)?分析两种情况:
       (1)x、y位于同一条重链上。重链上的结点都是祖先和后代的关系,设y的深度比x浅,那么LCA(x, y) = y。
       (2)x、y位于不同的重链上。让x和y沿着重链往上跳,直到位于同一条重链为止。重链的定义可以保证x、y最后能到达同一条重链。
       例如图(1)中,求p、q的LCA(p, q)。先从p开始跳,跳到链头top[p] = d,然后穿过轻边(b, d)到达上一个重链的结点b,此时发现top[b] = top[q] = a,说明b、q在同一跳重链上,由于b的深度比q浅,得LCA[b, q] = b。注意不能先从q开始跳,请分析原因。
       仍然以上一篇的模板题“洛谷P3379”为例给出树链剖分求LCA的代码,代码的主体是三个函数。
    (1)dfs1()。在树上做一次DFS求以下数组。
       deep[]:deep[x]是结点x的深度。
       fa[]:fa[x]是结点x的父结点。当需要穿过一条轻边时,跳到链头的父结点即可。
       siz[]:siz[x]是结点x为根的的子树上结点的数量;
       son[]:son[x]是非叶子结点x的重儿子。
    (2)dfs2()。在树上做一次DFS计算top[],top[x]是结点x所在重链的链头
    (3)LCA()
       复杂度:dfs1()和dfs2()都只遍历树的每个结点1次,是O(n)的;LCA()查询一次是O(logn)的,m次查询为O(mlogn)。树链剖分的复杂度和倍增法的复杂度差不多,略好一点。

    //洛谷P3379 的树链剖分代码
    #include<bits/stdc++.h>
    using namespace std;
    const int maxn=500005;
    
    struct Edge{int to, next;}edge[2*maxn];  //链式前向星
    int head[2*maxn], cnt;
    void init(){                             //链式前向星:初始化
        for(int i=0;i<2*maxn;++i){ edge[i].next = -1;  head[i] = -1; }
        cnt = 0;
    }
    void addedge(int u,int v){               //链式前向星:加边
    	edge[cnt].to = v;  edge[cnt].next = head[u];  head[u] = cnt++;
    }//以上是链式前向星
    
    int deep[maxn],siz[maxn],son[maxn],top[maxn],fa[maxn];
    void dfs1(int x, int father){  
        deep[x]=deep[father]+1;   //深度:比父结点深度多1
    	fa[x]=father;             //标记x的父亲
        siz[x]=1;                 //标记每个结点的子树大小(包括自己)
        for(int i=head[x];~i;i=edge[i].next){
            int y=edge[i].to;
            if(y!=father){   //邻居:除了父亲,都是孩子
            	fa[y]=x;
            	dfs1(y,x);
            	siz[x] += siz[y];     //回溯后,把x的儿子数加到x身上
            	if(!son[x] || siz[son[x]]<siz[y])   //标记每个非叶子结点的重儿子
                	son[x]=y;                       //x的重儿子是y
    		}
        }
    }
    void dfs2(int x,int topx){  
                                //id[x] = ++num;    //对每个结点新编号,在下一小节用到
        top[x]=topx;            //x所在链的链头
        if(!son[x]) return;     //x是叶子,没有儿子,返回
    	dfs2(son[x],topx);      //先dfs重儿子,所有重儿子的链头都是topx
        for(int i=head[x];~i;i=edge[i].next){    //再dfs轻儿子
            int y=edge[i].to;
            if(y!=fa[x] && y!=son[x])  
                dfs2(y,y);      //每一个轻儿子都有一条以它为链头的重链
        }
    }
    int LCA(int x, int y){
    	while(top[x]!=top[y]){   //持续往上跳,直到若x和y属于同一条重链
       		if(deep[top[x]] < deep[top[y]])	
    		   swap(x,y);                    //让x是链头更深的重链
    		x = fa[top[x]];   //x穿过轻边,跳到上一条重链     	
       	}
        return deep[x]<deep[y]?x:y;  
    }
    int main(){
    	init();
        int n,m,root;  scanf("%d%d%d",&n,&m,&root);
        for(int i=1;i<n;i++){
            int u,v;          scanf("%d%d",&u,&v);
            addedge(u,v);     addedge(v,u);
        }
        dfs1(root,0);
        dfs2(root,root);
        while(m--){
            int a,b;   scanf("%d%d",&a,&b); 
            printf("%d
    ", LCA(a,b));
        }
    }
    

    2. 树链剖分的典型应用

       上面介绍了树链剖分的概念和简单应用。
       关于重链,还有一个重要特征没有提到:一条重链内部结点的DFS序是连续的。这个特征使得可以用数据结构(一般是线段树)来维护重链,从而高效率地解决一些树上的问题,例如以下问题:
       (1)修改点x到点y的路径上各点的权值。
       (2)查询点x到点y的路径上结点权值之和。
       (3)修改点x子树上各点的权值。
       (4)查询点x子树上所有结点的权值之和。
       其中的(1)是“树上差分”问题,见前一节的“倍增+差分”的解法。树上差分只能解决简单的修改问题,对(3)这样的修改整棵子树问题,树上差分就行不通了。
    1、重链的DFS序
       前面给出的函数dfs2(),是先DFS重儿子,再DFS轻儿子。如果在dfs2()的第一句用编号id[x]记录结点x的DFS序:
          id[x] = ++num;
       对每个结点重新编号的结果,例如下图:

    图2 重链的DFS序

       容易观察到:
       (1)每一条重链内部结点的编号是有序的。重链{a, b, e, j, q}的DFS序是{1, 2, 3, 4, 5};重链{d, p}的DFS序是{7, 8};重链{c, f}的DFS序是{10, 11}。
       (2)每棵子树上的所有结点的DFS序也是连续的。例如以e为根的子树{e, i, j, q},它们的DFS序是{3, 4, 5, 6}。
       下面是关键内容:用线段树处理重链。由于每条重链内部的结点是有序的,可以按DFS序,把它们安排在一个线段树上。把每条重链看成一个连续的区间,对一条重链内部的修改和查询,用线段树来处理;若x到y的路径跨越了多个重链,简单地跳过即可。
       概况地说:“重链内部用线段树,重链之间跳过”。

    图3 用线段树重建树链

      上图把树链的结点重新安排在一个线段树上。同一个重链的结点,在线段树上是连续的。

    2、修改从结点x到y的最短路径上结点权值
      x、y的最短路径经过LCA(x, y),这实际上是一个查找LCA(x, y)的过程。借助重链来修改路径上的结点的权值:
      (1)令x的链头的深度更深,即top[x] ≥ top[y]。从x开始往上走,先沿着x所在的重链往上走,修改这一段的结点;
      (2)到达x的链头后,跳过1个轻边,到达上一个重链;
      (3)继续执行(1)、(2),直到x和y位于同一条重链上,再修改此时两个点之间的结点权值。结束。
      例如修改从p到q的路径上所有结点权值之和:
      (1)从p走到它的链头top[p] = d,修改p和d的权值;
      (2)跳到b;
      (3)b和q在同一条重链上,修改从b到q的权值;结束。
      用线段树处理上述过程,仍以修改从p到q的路径上结点之和为例:
      (1)从p跳到链头d,p和d属于同一条重链,用线段树修改对应的[7, 8]区间;
      (2)从d穿过轻边(b, d),到达b所在的重链;
      (3)查b到q,它们属于同一个重链,用线段树修改对应区间[2, 5],结束。

    3、查询从x到y的路径上所有结点权值之和
      查询与修改的过程几乎是一样的,以查询从p到q的路径上结点之和为例:
      (1)从p跳到链头d,p和d属于同一条重链,用线段树查询对应的[7, 8]区间;
      (2)从d穿过轻边(b, d),到达b所在的重链;
      (3)查b到q,它们属于同一个重链,用线段树查询对应区间[2, 5],结束。

    4、修改结点x的子树上各点的权值、查询结点x的子树上结点权值之和
      每棵子树上的所有结点的DFS序是连续的,也就是说,每棵子树对应了一个连续的区间。那么修改和查询子树,和线段树对区间的修改和查询操作完全一样。
      下面用一个模板题给出代码。


    轻重链剖分 洛谷 P3384
    题目描述:已知一棵包含n个结点的树(连通且无环),每个结点上包含一个数值,有以下4种操作:
    1 x y z 修改:将树从x到y结点最短路径上所有结点的值都加上z。
    2 x y 查询:求树从x到 y 结点最短路径上所有结点的值之和。
    3 x z 修改:将以x为根节点的子树内所有结点值都加上z。
    4 x 查询:求以x为根节点的子树内所有结点值之和。
    输入格式
    第一行包含4个正整数n、m、r、p,分别表示树的结点个数、操作个数、根结点点序号和取模数(即所有的输出结果均对此取模)。
    接下来一行包含n个非负整数,分别依次表示各个结点上初始的数值。
    接下来n−1行每行包含两个整数x,y,表示点x和点y之间连有一条边(保证无环且连通)。
    接下来m行每行包含若干个正整数,每行表示一个操作。
    输出格式:输出包含若干行,分别依次表示每个操作2或操作4所得的结果(对P取模)。
    数据规模:1≤n≤105,1≤m≤105,1≤r≤N,1≤p≤231-1


      首先用链式前向星存树,然后用dfs1()、dfs2()剖链。这部分内容和前一小节“树链剖分求LCA”的内容几乎一样。唯一不同的地方在dfs2()中,加了id[x],对结点重新编号,这些编号是重链的DFS序,准备用线段树处理它们。
      接下来是线段树。建线段树build()、打lazy标记addtag()、上传标记push_up()、下传标记push_down()、更新线段树update()、查询线段树query(),这些代码直接套用了第4章的“线段树”这一节的模板,内容几乎一样,只是把线段树内的结点看成重链的DFS序。
      最后是本题的4个操作:
      (1)update_range(),操作1,把从x到y的最短路径上的所有结点值加z。与求LCA(x, y)的过程差不多:让x和y沿着各自的重链往上跳,直到最后x和y处于同一个重链上。当x或者y在重链内部时,把这条重链看成线段树的一个区间,用线段树的update()处理;在重链之间的轻边上,简单地穿过轻边即可。
      (2)query_range(),操作2,查询从x到 y 结点最短路径上所有结点的值之和。与操作1的步骤差不多,不同的地方是用线段树的查询函数query()。
      (3)update_tree(),操作3,把以x为根节点的子树内所有结点值都加上z。就是线段树的update()。
      (4)query_tree(),操作4,查询以x为根节点的子树内所有结点值之和。就是线段树的query()。
      下面给出代码,基本上是“链式前向星+线段树+树剖”的简单组合,编码虽然有点长,但是不难。

    #include<bits/stdc++.h>
    using namespace std;
    const int maxn=100000+10;
    int n,m,r,mod;
    //以下是链式前向星
    struct Edge{int to, next;}edge[2*maxn];   
    int head[2*maxn], cnt;
    void init();                   //与前一小节“洛谷P3379树链剖分”的init()一样
    void addedge(int u,int v);     //与前一小节“洛谷P3379树链剖分”的addedge()一样
    //以下是线段树
    int ls(int x){ return x<<1;  }     //定位左儿子:x*2
    int rs(int x){ return x<<1|1;}     //定位右儿子:x*2 + 1
    int w[maxn],w_new[maxn];           //w[]、w_new[]初始点权
    int tree[maxn<<2], tag[maxn<<2];   //线段树数组、lazy-tag操作
    void addtag(int p,int pl,int pr,int d){        //给结点p打tag标记,并更新tree
        tag[p]  += d;                              //打上tag标记
        tree[p] += d*(pr-pl+1); tree[p] %= mod;    //计算新的tree
    }
    void push_up(int p){                    //从下往上传递区间值
        tree[p] = tree[ls(p)] + tree[rs(p)];  tree[p] %= mod;
    }
    void push_down(int p,int pl, int pr){
        if(tag[p]){
            int mid = (pl+pr)>>1;
            addtag(ls(p),pl,mid,tag[p]);    //把tag标记传给左子树
            addtag(rs(p),mid+1,pr,tag[p]);  //把tag标记传给右子树
            tag[p] = 0;
        }
    }
    void build(int p,int pl,int pr){        //建线段树
        tag[p] = 0;
        if(pl==pr){
            tree[p] = w_new[pl];    tree[p] %= mod;
            return;
        }
        int mid = (pl+pr) >> 1;
        build(ls(p),pl,mid);
        build(rs(p),mid+1,pr);
        push_up(p);
    }
    void update(int L,int R,int p,int pl,int pr,int d){
        if(L<=pl && pr<=R){     
            addtag(p, pl, pr,d);
            return;
        }
        push_down(p,pl,pr);    
        int mid = (pl+pr) >> 1;
        if(L<=mid)   update(L,R,ls(p),pl,mid,d);
        if(R> mid)   update(L,R,rs(p),mid+1,pr,d);
        push_up(p);
    }
    int query(int L,int R,int p,int pl,int pr){
        if(pl>=L && R >= pr)   
            return tree[p] %= mod;
        push_down(p,pl,pr);
        int res =0;
        int mid = (pl+pr) >> 1;
        if(L<=mid)   res += query(L,R,ls(p),pl,mid);
        if(R> mid)   res += query(L,R,rs(p),mid+1,pr);
        return res;
    }
    //以下是树链剖分
    int son[maxn],id[maxn],fa[maxn],deep[maxn],siz[maxn],top[maxn];
    void dfs1(int x, int father); //与前一小节“洛谷P3379树链剖分”dfs1()一样
    int num = 0;
    void dfs2(int x,int topx){    //x当前节点,topx当前链的最顶端的节点
        id[x] = ++num;            //对每个结点新编号
        w_new[num] = w[x];        //把每个点的初始值赋给新编号
        top[x]=topx;              //记录x的链头
        if(!son[x])   return;     //x是叶子,没有儿子,返回
        dfs2(son[x],topx);                     //先dfs重儿子
        for(int i=head[x];~i;i=edge[i].next){  //再dfs轻儿子
            int y=edge[i].to;
            if(y!=fa[x] && y!=son[x])
                dfs2(y,y);         //每一个轻儿子都有一条从它自己开始的链
        }
    }
    void update_range(int x,int y,int z){       //和求LCA(x, y)的过程差不多
        while(top[x]!=top[y]){
            if(deep[top[x]]<deep[top[y]])
                swap(x,y);
            update(id[top[x]],id[x],1,1,n,z);    //修改一条重链的内部
            x = fa[top[x]];
        }
        if(deep[x]>deep[y])    swap(x,y);
        update(id[x],id[y],1,1,n,z);             //修改一条重链的内部
    }
    int query_range(int x,int y){               //和求LCA(x,y)的过程差不多
        int ans=0;
        while(top[x]!=top[y]){              //持续往上跳,直到若x和y属于同一条重链
            if(deep[top[x]]<deep[top[y]])
                swap(x,y);                  //让x是链头更深的重链
            ans += query(id[top[x]],id[x],1,1,n);     //加上x到x的链头这一段区间
            ans %= mod;
            x = fa[top[x]];                           //x穿过轻边,跳到上一条重链
        }
        if(deep[x]>deep[y])                 //若LCA(x, y) = y,交换x, y
            swap(x,y);                      //让x更浅,使得id[x] <= id[y]
        ans += query(id[x],id[y],1,1,n);    //再加上x, y的区间和
        return ans % mod;
    }
    void update_tree(int x,int k){  update(id[x],id[x]+siz[x]-1,1,1,n,k); }
    int query_tree(int x){   return query(id[x],id[x]+siz[x]-1,1,1,n) % mod; }
    int main(){
        init();  //链式前向星初始化
        scanf("%d%d%d%d",&n,&m,&r,&mod);
        for(int i=1;i<=n;i++)   scanf("%d",&w[i]);
        for(int i=1;i<n;i++){
            int u,v;            scanf("%d%d",&u,&v);
            addedge(u,v);       addedge(v,u);
        }
        dfs1(r,0);
        dfs2(r,r);
        build(1,1,n);         //建线段树
        while(m--){
            int k,x,y,z;   scanf("%d",&k);
            switch(k){
                case 1: scanf("%d%d%d",&x,&y,&z);update_range(x,y,z);            break;
                case 2: scanf("%d%d",&x,&y);     printf("%d
    ",query_range(x,y));break;
                case 3: scanf("%d%d",&x,&y);     update_tree(x,y);               break;
                case 4: scanf("%d",&x);          printf("%d
    ",query_tree(x));   break;
            }
        }
    }
    

    5、把边权转为点权
      上面的例题处理的是结点权值问题,有的树是边权问题,例如:一棵树有n个结点,由n-1条边连接,给出边的权值,做两种操作,(1)查询两个结点之间的路径长度;(2)修改第i条路径的权值。
      如果把边权转为点权,就能按前面给出的“树链剖分 + 线段树”来解决。
      例如下图(1),若把边权转为点权,显然只能把每条边上的边权赋给这条边下层的结点,得到图(2)。编程操作是:比较边(u, v)的两点的deep[u]、deep[v],把边权赋给更深的那个结点。

    图4 图(1)边权树        (2)转化为点权树

      转换为点权后,树剖的操作基本上一样。但是,区间求和和区间更新操作都有一点问题。
      (1)区间求和。例如图(1)求从d到e的路径,d-b-e的长度是7 + 3 = 10;但是图(2)变成了7 + 2 + 3 = 12,多算了b点的权值。
      (2)区间修改。例如图(1)中把从d到e的路径上的边d-b、b-e都减1,此时边b-a并没有被影响到;但是在图(2)中,把d、b、e三个结点的值都减了1,而b点的值是不该被减的。
      观察到b = LCA(d, e),所以解决方法是不要处理LCA:
      (1)区间[L, R]求和时,不计算LCA(L, R)的值;
      (2)区间[L, R]更新时,不更新LCA(L, R)的值。

    【树链剖分习题】

    洛谷:P2486、P2146、P2590、P3178、P3038
    Hdu: 3966、4897、5029、6547
    Poj: 2763、3237

  • 相关阅读:
    嵌入式软件设计第7次实验报告
    自我介绍
    软工 需求分析
    微软小娜app的使用
    嵌入式软件设计第12次实验报告
    嵌入式软件设计第11次实验报告
    嵌入式软件设计第十次
    嵌入式软件设计第九次
    软件工程需求分析
    嵌入式软件设计第8次实验
  • 原文地址:https://www.cnblogs.com/luoyj/p/14002975.html
Copyright © 2011-2022 走看看