LGP4556 [Vani's Date] 雨天的尾巴 // [LG TPLT] 线段树合并 学习笔记

LGP4556 [Vani's Date] 雨天的尾巴 // [LG TPLT] 线段树合并 学习笔记

题意简述

给定一棵 \(n\) 个结点的树。每个结点有一个初始为空的可重集合。\(m\) 次操作,将 \(u\to v\) 路径上所有点的集合里添加一个元素 \(x\)。最后问每个结点的集合中出现次数最多的元素是哪种(如果有多种出现次数最多的元素,输出编号最小的)。

做法解析

区间添加一种元素,我们显然考虑差分,每次给树打上若干标记,最后把标签全部合并。反正查询只有一次,所以这么做是自然的。

问题在于我们怎么打差分。有一种想法是树剖,把每次修改拆出 \(O(n\log n)\) 段重链,在链顶打加一的标签,链底打减一的标签(对 \(\text{dfn}\) 而言就是对若干个区间打差分) ,最后把整棵树按dfs序遍历一次,维护一个能查 \(\max\) 的权值线段树,就可以统计答案了。可以看出时间复杂度 \(O(n\log^2 n)\)(设 \(n,m\) 同阶)。

主播主播,你的 \(\log^2\) 做法虽然好写常熟小,但是纸面复杂度还是有点不好看,有没有单 \(\log\) 的做法呢?有的兄弟有的。接下来进入本篇主题:线段树合并。

首先我们不需要对每次修改剖出 \(\log n\) 个区间,而是直接用树上差分的搞法,\(u,v\) 各打一个加一的标签,\(\text{lca}(u,v),\text{fa}_{\text{lca}(u,v)}\) 各打一个减一的标签。最后做一遍dfs,我们对每个点搞一棵动态开点的权值线段树,让儿子的动态开点线段树合并到自己的线段树上即可。时间复杂度 \(O(n\log n)\)

怎么合并两个动态开点线段树?从根开始dfs,如果 \(T_1,T_2\) 都有某个儿子就递归后合并,如果只有 \(T_2\) 有某个儿子就直接把 \(T_2\) 的这个儿子接到 \(T_1\) 的这个位置上面。

过程很好懂,讲一下时间复杂度证明。我们做的事情是把 \(n\) 个只有 \(O(1)\) 个元素的线段树合并成一个有 \(O(n)\) 个元素的线段树。我们考虑一件事:我们每次dfs下去最深会dfs到叶子节点,每次dfs到最深处都意味着有两个本来没被合并在一起的叶子点点集“连通”了,这个过程显然最多 \(O(n)\) 次,又因为一次dfs的深度最多 \(O(\log n)\),所以总时间复杂度 \(O(n\log n)\)。程序其它部分时间复杂度也不高于此。

代码实现

显然,倍增是用来找 \(\text{lca}\) 的。

#include <bits/stdc++.h>
using namespace std;
using namespace obasic;
const int MaxN=1e5+5,MaxNb=20;
int N,M,X,Y,Z,V=1e5,ans[MaxN];
vector<int> Tr[MaxN];
void addudge(int u,int v){
    Tr[u].push_back(v);
    Tr[v].push_back(u);
}
struct adat{
    int c,x;
    friend bool operator<(adat a,adat b){return a.x==b.x?a.c>b.c:a.x<b.x;}
    friend adat operator+=(adat &a,adat b){a.c=b.c,a.x+=b.x;return a;}
};
vector<adat> vec[MaxN];
int srt[MaxN];
struct SegTrees{
    adat val[MaxN<<5];
    int ls[MaxN<<5],rs[MaxN<<5],tot;
    void init(int n){for(int i=1;i<=n;i++)srt[i]=++tot;}
    void pushup(int u){val[u]=max(val[ls[u]],val[rs[u]]);}
    void modify(int &u,int cl,int cr,adat x){
        if(!u)u=++tot;
        if(cl==cr){val[u]+=x;return;}
        int cmid=(cl+cr)>>1;
        if(x.c<=cmid)modify(ls[u],cl,cmid,x);
        else modify(rs[u],cmid+1,cr,x);
        pushup(u);
    }
    int merge(int u,int v,int cl,int cr){
        if(!u||!v)return u|v;
        if(cl==cr){val[u]+=val[v];return u;}
        int cmid=(cl+cr)>>1;
        ls[u]=merge(ls[u],ls[v],cl,cmid);
        rs[u]=merge(rs[u],rs[v],cmid+1,cr);
        pushup(u);return u;
    }
}SgS;
int tfa[MaxN][MaxNb],dep[MaxN];
void dfs1(int u,int f){
    tfa[u][0]=f,dep[u]=dep[f]+1;
    for(int i=0;tfa[u][i];i++)tfa[u][i+1]=tfa[tfa[u][i]][i];
    for(int v : Tr[u])if(v!=f)dfs1(v,u);
}
int getlca(int u,int v){
    if(dep[u]<dep[v])swap(u,v);
    for(int i=log2(dep[u]-dep[v]);i>=0;i--)if(dep[tfa[u][i]]>=dep[v])u=tfa[u][i];
    if(u==v)return u;
    for(int i=log2(dep[u]);i>=0;i--)if(tfa[u][i]!=tfa[v][i])u=tfa[u][i],v=tfa[v][i];
    return tfa[u][0];
}
void dfs2(int u){
    for(int v : Tr[u])if(v!=tfa[u][0])dfs2(v),srt[u]=SgS.merge(u,v,1,V);
    for(adat x : vec[u])SgS.modify(srt[u],1,V,x);
    ans[u]=SgS.val[u].c;
}
int main(){
    readis(N,M);SgS.init(N);
    for(int i=1;i<N;i++)readis(X,Y),addudge(X,Y);
    dfs1(1,0);for(int i=1,anc;i<=M;i++){
        readis(X,Y,Z);anc=getlca(X,Y);
        vec[X].push_back({Z,1}),vec[Y].push_back({Z,1});
        vec[anc].push_back({Z,-1}),vec[tfa[anc][0]].push_back({Z,-1});
    }
    dfs2(1);for(int i=1;i<=N;i++)writil(ans[i]);
    return 0;
}```

### 反思总结
同样都是树上差分,树剖做法和线段树合并做法的时间复杂度的差距在哪呢?

首先,这是因为用来维护的数据结构都是单 $\log$ 的,但是树剖对于每个询问都拆出了 $O(\log n)$ 组标签,而线段树合并拆了 $O(1)$ 组标签。

进一步地,我们发现:树剖做法在链顶加一链底减一,所以不拆出 $\log$ 组标签时减一就不好处理。但是如果要在链底加一,那我们一棵线段树就不够用了,就得每个点开一棵动态开点树……看到没,我们就自然而然引出了线段树合并做法!
posted @ 2025-05-14 14:37  矞龙OrinLoong  阅读(10)  评论(0)    收藏  举报