dsu的意思是并查集,但是dsu on tree这个算法和并查集的关系也就剩一个启发式合并了。
学习这个算法的切入点还真不在启发式合并,关于启发式合并的问题实际上是最后再考虑它为什么叫启发式合并。
我们选什么作为切入点呢,选莫队算法,如果你不懂莫队也没关系,这里只是说这两个算法在应用的场景上面有共通点,只不过一个应用于子树,一个应用于数组区间。

莫队


我们从最简单的区间问题“子区间的静态求和问题”开始讨论好了。
假定现在有一个数组a,数组里面有若干个元素,然后你已知这段区间的区间和,假设区间与区间有大面积重合,重合率达到90%。问你如何在已知区间和的情况下计算的区间和?
那只要把l_1向后面移动,然后令每次减少便利到的数组元素,再把r_1向后移动,每次加入新的元素
这样利用区间计算区间的结果复杂度为,在区间重合率较高的情况下可以优化很多重复计算。
假设共有m个查询问题,我每一次的查询都基于上一次查询的结果基础上,那么我的总复杂度就可以表示为
(其中)
如果我想要让这个复杂度最小化,也就是“如何安排一个合理的查询顺序,使得复杂度最小化”。
那我这片博客不是讲莫队,所以不解答“合理的查询顺序”是个怎样的顺序。
考虑这个算法和传统的暴力算法相比,优势在什么地方?,自然就是“如果相邻查询之间有大面积重合,那么就算的飞快”这个优势。
查询之间有大面积重合”换个说法就是有大量的重复子问题。
与动态规划的思路不同,动态规划的思想是既然有重复子问题,那我就不停地划分子问题,然后分阶段求解不同子问题,最终求的答案,这样最高效率的利用了重复子问题。
而莫队是如何利用重复子问题的呢,显然它没有保存,它只是因为当前的查询与下一个查询之间有重复部分,所以不用重复统计仅此而已。

总之就是,我们从莫队上面得到了启示:对于静态的查询类问题,如果查询之间有大面积的重合,那么我只要安排一个合理的顺序暴力求解就能够降低复杂度

为什么不用区间DP/倍增跳表或者线段树维护

就是因为它们做不到,所以才需要莫队的啊,要么是时空不允许,要么就干脆不满足区间合并的性质。
比如“区间元素种类数”,“区间出现次数>k的元素”等。
这个问题的答案也揭示了后面“为什么需要dsu on tree,而非直接树形DP”这个问题。

dsu on tree


先给你这么一棵树,根节点是1号节点有n-1条有向边链接,假设每个点有点权,第i个点的权值为。我进行若干次查询,每次查询以某点为根所表示的子树权值和。
我统计权值和所用的代码如下:
int ans;
bool vis[MAXN];
void init()
{
    ans=0;
    memsest(vis,false,sizeof(vis));
}
void dfs(int x)
{
    if(vis[x])return;
    vis[x]=true;
    ++ans;
    for(auto &i:G[x])
    {
        dfs(i);
    }
}
假设我要查询节点{1,4}的权值和,在不改变算法的情况下,如何降低复杂度?
那无非就是两种方法:

先查1,再查4

dfs(1);
init();
dfs(4);

先查4,再查1

dfs(4);
dfs(1);
先不讨论init的时间消耗,假设init函数不消耗时间,在这个情况下谁更优?
显然是第二个嘛,因为第一种方案的复杂度为,第二种方案复杂度只剩下了。
这个优化很明显,太过分了,因为相当于dfs(4)白嫖了变成,甚至不用计算复杂度。
为什么会出现这个现象呢,因为dfs(4)就是dfs(1)的子问题,那就相当于“重合率100%”,我可以节约100%的时间。

ok,我们现在来切入正题:假设我全查了,如何安排顺序使得复杂度最小化(同样假设init函数不消耗时间)?
dfs(5);
dfs(3);
init();
dfs(7);
init();
dfs(6);
dfs(4);
dfs(2);
dfs(1); 
那这个查询顺序是怎么来的呢?

情景一:单链

那这个是最简单的,只要按照dfs(n),dfs(n-1)....dfs(1)的顺序执行即可。

情景二:分叉

由情景一,我们已经知道了,在最优顺序里面,一定是先dfs,x,y,z这三个子节点之后再进行dfs(rt)。
换句话说rt对于x,y,z的顺序已经定了一定是rt在后,只需要对xyz进行排序即可。
我们发现,dfs(x),dfs(y),dfs(z)这三个函数没有任何交集,换句话说就是根本没有重复子问题。
那么无论先处理谁,只要后面需要调用dfs(x),dfs(y),dfs(z)中的任意一个,都必须推倒重来,执行一次init()。
因为接下来会调用dfs(rt),反倒是谁最后被执行,谁就可以被保留不计算复杂度。
那也就是说
dfs(y);init();dfs(z)init;dfs(x);dfs(rt);
dfs(z);init();dfs(y)init;dfs(x);dfs(rt);
这两个求解顺序都是复杂度最低的求解顺序。

总结一下

1、优先处理子树查询,再处理根节点
2、当出现多个子树的时候,优先处理子树尺寸较小的子树,然后使用init清空,最后处理尺寸最大的子树,然后保留信息的情况下处理根节点。

看到这里的话你肯定发现了,所谓“尺寸最大的子树”的子树,它也就是轻重树链剖分中“重儿子”的概念,而“子树尺寸较小”则是轻儿子。

再次总结一下

1、优先处理子树查询,再处理根节点。
2、当出现多个子树的时候,优先处理轻儿子,处理完每个轻儿子之后使用init清空,最后处理重儿子并保留信息的情况下处理根节点。

现在再反过来看一开始提出的例题


最优求解顺序:
dfs(5);
dfs(3);
init();
dfs(7); 
init();
dfs(6);
dfs(4);
dfs(2);
dfs(1); 
//init();
1~2~4~6是一条重链,链首是1
3~5是一条重链,链首是3
7是一条重链,链首是7
“最优求解序列”就是轻重树链剖分的dfs序列的倒置,并且在每条重链的链首处进行了一次init()
那么“最优求解序列”究竟效率如何呢?
为了解决这个问题,还是要借助树链剖分。
定理:树上任意节点到根节点的简单路径中,经过轻边的数目不多于log_2n
我们知道,对于任意一个节点x,我们需要统计它的信息仅当调用dfs(y),y是x的祖先时。
那么只要求出所有的dfs(y)中遍历x的次数,把它们加起来也就是这个算法的最终复杂度。
因为y是x的祖先,所以dfs(y)时只要之前调用过init函数,x就必须要重新统计。
换句话说只要统计dfs(y)时init的调用次数即可。
因为只有在链首处才会调用init,所以这个问题的答案就等于从x到根节点的简单路径中,经过轻边的数目。
那对于任意节点x,它对总时间复杂度的贡献不多于log_2n,那么这个算法的最终复杂度就不多于O(nlog_2n)

代码讲解

例题:输入一个正整数n表示有n个节点的树,根节点为1号节点,然后输入n个正整数表示点权,请输出n个数,以每个点为根的子树权值和。

首先是“如何求出最优求解序列”,前面也说了,这个东西就是“树链剖分dfs序的倒序”,翻转一个序列最简单的方法就是压到栈里面再输出,我们知道递归算法是一个天然的堆栈。所以我们只要利用dfs按照树链剖分的规则再来一遍,然后在return之前求解即可。
那因为我们需要用到树链剖分的dfs序嘛,那肯定是要真的树剖一下,我们做一下树剖然后记录一些信息。
int sz[MAXN],deep[MAXN],fa[MAXN],son[MAXN],L[MAXN],R[MAXN],Index[MAXN],dfn;
void dfs(int x,int dep,int father)
{
    sz[x]=1;
    deep[x]=dep;
    fa[x]=father;
    son[x]=0;
    L[x]=++dfn;
    Index[dfn]=x;
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to!=father)
        {
            dfs(e[i].to,dep+1,x);
            sz[x]+=sz[e[i].to];
            if(!son[x]||sz[son[x]]<sz[e[i].to])son[x]=e[i].to;
        }
    }
    R[x]=dfn;
    return;
}
表示子树尺寸,表示当前节点深度,表示父节点,表示重儿子
,表示x节点所表示子树中dfn的最小值与最大值。并且x节点所表示子树中dfn的最小值就是x节点的dfn,即
的逆数组。
表示“节点x的dfn是多少”(由于在数值上等于,所以代码中用代替了
表示“ dfn为x的节点是哪个节点”
这里其实是做了树链剖分的一半工作(即只有树链剖分中dfs1的部分),当然你也可以做一个完整的链剖把(表示重链链首)也给求出来。

有了链剖预处理出来的各种dfs序,接下来是代码的主体部分
先递归轻儿子,每次递归完轻儿子(当前节点是重链的链首)时全部清0。然后递归重儿子并且不清空,最后dfs(x)并且跳过重儿子。
void dsu(int x,bool del)
{
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to==fa[x]||e[i].to==son[x])continue;
        dsu(e[i].to,true);
    }
    ///skip the heavy son
    if(son[x])
    {
        dsu(son[x],false);
        skp=son[x];
    }
    ///get data from light son and itself
    get_data(x);
    ///solve problem with subtree x
    ans[x]=sum;
    ///if delete clear all
    if(del)
    {
        skp=0;
        sum=0;
    }
    return;
}
这里额外提一句,如果你之前做了完整的树剖,那么del这个参数可以不传,只需要把if(del)改写为if(x==top[x])这个条件即可。(重链的链首处进行init)
这个get_data()函数,就是我一开始举例时的"dfs暴力统计子树权值和,并且碰到vis就return"的那个函数
void get_data(int x)
{
    if(x==skp)return;   //if(vis[x])return;
    sum+=val[x];
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to==fa[x])continue;
        get_data(e[i].to);
    }
}
当然,因为有了dfs序列,我们预处理了,,这里可以使用非递归的for循环写法
void get_data(int x)
{
    for(int i=L[x];i<=R[x];++i)
    {
        if(Index[i]==skp)
        {
            i=R[Index[i]];
            continue;
        }
        sum+=val[Index[i]];
    }
}
这样这道题就写完了

完整代码

#include<bits/stdc++.h>
using namespace std;
const int MAXN=100005;
int n;
int g[MAXN],tot,dfn,u,v;
int sz[MAXN],deep[MAXN],fa[MAXN],son[MAXN],L[MAXN],R[MAXN],Index[MAXN],skp;
long long sum,ans[MAXN],val[MAXN];
struct edges
{
    int to,next;
}e[2*MAXN];
void add_edge(int from,int to)
{
    e[++tot].to=to;
    e[tot].next=g[from];
    g[from]=tot;
    return;
}
void dfs(int x,int dep,int father)
{
    sz[x]=1;
    deep[x]=dep;
    fa[x]=father;
    son[x]=0;
    L[x]=++dfn;
    Index[dfn]=x;
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to!=father)
        {
            dfs(e[i].to,dep+1,x);
            sz[x]+=sz[e[i].to];
            if(!son[x]||sz[son[x]]<sz[e[i].to])son[x]=e[i].to;
        }
    }
    R[x]=dfn;
    return;
}
void get_data(int x)
{
    for(int i=L[x];i<=R[x];++i)
    {
        if(Index[i]==skp)
        {
            i=R[Index[i]];
            continue;
        }
        sum+=val[Index[i]];
    }
}
/*
void get_data(int x)
{
    if(x==skp)return;
    sum+=val[x];
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to==fa[x])continue;
        get_data(e[i].to);
    }
}
*/
void dsu(int x,bool del)
{
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to==fa[x]||e[i].to==son[x])continue;
        dsu(e[i].to,true);
    }
    ///skip the heavy son
    if(son[x])
    {
        dsu(son[x],false);
        skp=son[x];
    }
    ///get data from light son and itself
    get_data(x);
    ///solve problem with subtree x
    ans[x]=sum;
    ///if delete clear all
    if(del)
    {
        skp=0;
        sum=0;
    }
    return;
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;++i)
    {
        scanf("%lld",&val[i]);
    }
    for(int i=1;i<n;++i)
    {
        scanf("%d %d",&u,&v);
        add_edge(u,v);
        add_edge(v,u);
    }
    dfs(1,1,-1);
    dsu(1,0);
    for(int i=1;i<=n;++i)
    {
        printf("%lld%c",ans[i],i==n?'\n':' ');
    }
    return 0;
}

为什么需要dsu on tree

这个问题其实就类似使用莫队的困境一样,有的树形dp,它压根就不支持子树之间的合并问题。就是说虽然根节点可以快速在单链时快速转移,但是碰到两个以上的子树时,两子树之间的信息不能快速合并。
例如“子树元素种类数”,“子树元素众数”

例题:输入一个正整数n表示有n个节点的树,根节点为1号节点,然后输入n个正整数表示点权,请输出n个数,以每个点为根的子树元素种类数。

只用改写get_ans中的统计方法,以及del中每次初始化的方式即可。

统计方法

void get_data(int x,int op)
{
    for(int i=L[x];i<=R[x];++i)
    {
        if(Index[i]==skp)
        {
            i=R[Index[i]];
            continue;
        }
        cnt[col[Index[i]]]+=op;
        if(cnt[col[Index[i]]]==1)++nowans;
        if(cnt[col[Index[i]]]==0)--nowans;
    }
}

del

if(del)
{
    skp=0;
    get_data(x,-1);
}
注意del这个东西,虽然我们要的效果是将cnt数组和nowans清零,但是实际上我们不能使用memset,否则复杂度会退化为

完整代码

#include<bits/stdc++.h>
using namespace std;
const int MAXN=100005;
int n;
int g[MAXN],tot,dfn,u,v;
int sz[MAXN],deep[MAXN],fa[MAXN],son[MAXN],L[MAXN],R[MAXN],Index[MAXN],skp,col[MAXN],nowans,ans[MAXN],cnt[MAXN];
struct edges
{
    int to,next;
}e[2*MAXN];
void add_edge(int from,int to)
{
    e[++tot].to=to;
    e[tot].next=g[from];
    g[from]=tot;
    return;
}
void dfs(int x,int dep,int father)
{
    sz[x]=1;
    deep[x]=dep;
    fa[x]=father;
    son[x]=0;
    L[x]=++dfn;
    Index[dfn]=x;
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to!=father)
        {
            dfs(e[i].to,dep+1,x);
            sz[x]+=sz[e[i].to];
            if(!son[x]||sz[son[x]]<sz[e[i].to])son[x]=e[i].to;
        }
    }
    R[x]=dfn;
    return;
}
void get_data(int x,int op)
{
    for(int i=L[x];i<=R[x];++i)
    {
        if(Index[i]==skp)
        {
            i=R[Index[i]];
            continue;
        }
        cnt[col[Index[i]]]+=op;
        if(cnt[col[Index[i]]]==1)++nowans;
        if(cnt[col[Index[i]]]==0)--nowans;
    }
}

void dsu(int x,bool del)
{
    for(int i=g[x];i;i=e[i].next)
    {
        if(e[i].to==fa[x]||e[i].to==son[x])continue;
        dsu(e[i].to,true);
    }
    ///skip the heavy son
    if(son[x])
    {
        dsu(son[x],false);
        skp=son[x];
    }
    ///get data from light son and itself
    get_data(x,1);
    ///solve problem with subtree x
    ans[x]=nowans;
    ///if delete clear all
    if(del)
    {
        skp=0;
        get_data(x,-1);
    }
    return;
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;++i)
    {
        scanf("%d",&col[i]);
    }
    for(int i=1;i<n;++i)
    {
        scanf("%d %d",&u,&v);
        add_edge(u,v);
        add_edge(v,u);
    }
    dfs(1,1,-1);
    dsu(1,0);
    for(int i=1;i<=n;++i)
    {
        printf("%d%c",ans[i],i==n?'\n':' ');
    }
    return 0;
}

dsu on tree与启发式合并

最后解答一下为什么dsu on tree是树上的启发式合并。
启发式合并用于解决这个问题,假设有某种数据结构,支持O(1)的 pop与insert操作,但是没有合并的操作,然后现在让你写一个合并的函数合并这若干个数据结构,如何实现均摊log的代价?
那这个大家都知道是采取“小并大”的策略,也就是对两个数据结构中size较小的那个把所有元素全部pop出来insert到大的当中。这样子操作每次都至少会使得较小数据结构的尺寸翻倍,由于每次暴力合并的复杂度等于较小数据结构的尺寸,那么每个数据结构作为较小的一堆至多合并log_2n次(当它作为较大数据结构的一堆参与合并时是不影响复杂度的)。也就是每个元素至多被pop和insertlog_2n次,所以总复杂度为nlog_2n

那其实dsu on tree中有一步也是这样考虑的。
就是在于当出现多个子树进行合并的时候,我们选择最后dfs统计子树x的信息,然后保留这个信息再dfs(rt)
这个过程,就相当于把x,y,z三个子树视为某种数据结构,保留子树x的信息,暴力遍历子树y与子树z的子树信息,往x这个数据结构里面并
这里的某种数据结构,就是树结构本身,从本质上来讲,dsu on tree就是树上的启发式合并算法。
那现在我们利用启发式合并的体系,再次证明dsu on tree的时间复杂度。
在每一层中出现若干个子树,然后对于每一层,合并他们的时间复杂度为,也就是轻儿子的尺寸之和。
那么同样的,每向上一层,轻儿子的尺寸大小至少翻倍一次,那么每个节点至多参与合并log_2n(当它作为重儿子参与合并时是不影响复杂度的)
所以总复杂度为nlog_2n,从启发式合并的角度再次证明了其时间复杂度

与深度有关的静态查询类问题