讲义:树上问题选讲

1. \(\footnotesize\bf{Kruskal}\) 重构树

\(\text{Kruskal}\) 重构树可以用来解决限制边权/点权的在线多次查询问题。

1.1. \(\footnotesize\bf{Kruskal}\) 重构树

1.1.1 概述与性质

回忆 \(\text{Kruskal}\) 算法求最小生成树的过程:先将所有边按边权排序,贪心地将合法的边加入边集,使用并查集维护连通性以判断边的合法性。

若当前我们已经加入了第 \(i\) 条边,其权值为 \(w_i\),如果下一条边的边权 \(w_{i+1}\gt w_i\),那么此时并查集维护的是什么?是整个图所有边权小于 \(w_{i+1}\)(或小于等于 \(w_i\))的边连通时,整个图的连通情况。

这启发我们:如果能用某种结构描述每条边被连接的先后顺序,因为越往后加入的边权越大,就可以快速刻画边权有限制时整个图的连通情况。

\(\text{Kruskal}\) 重构树应运而生。具体的,对于边 \((u,v)\),我们在并查集上找到 \(u,v\) 的代表元 \(U,V\),用并查集判断 \(U,V\) 是否已经连通,若不是,新建一个虚节点 \(W\),将 \(U,V,W\) 在并查集内的代表元设为 \(W\),同时在重构树 \(T\) 上令 \(W\) 的两个儿子为 \(U,V\)。注意,\(U,V\) 可能不是原图上的点。

这棵重构树 \(T\) 即为原图\(\text{Kruskal}\) 重构树,通常我们会设 \(W\) 的点权 \(w_W\)\(w_{(u,v)}\),方便做题。

写成代码:

typedef tuple<int, int, int> tiii;
int tot, fa[N << 1], wgt[N << 1];
vector<tiii> edge; // {u, v, w}
vector<int> T[N << 1];
void build(int n) { for(mem(fa, 0), tot = 1; tot <= n; tot++) fa[tot] = tot; }
int find(int u)  {return u == fa[u] ? u : fa[u] = find(fa[u]); }
void kruskal() {
    build(n);
    sort(all(edge), [](tiii x, tiii y) { return get<2>(x) > get<2>(y); });
    for(auto [u, v, w] : edge) {
        // if(tot == (n << 1)) continue;
        if((u = find(u)) == (v = find(v))) continue;
        fa[tot] = fa[u] = fa[v] = tot, wgt[tot] = w;
        T[tot].eb(u), T[tot].eb(v);
        tot++;
    }
}

这棵重构树 \(T\) 即为原图的 \(\text{Kruskal}\) 重构树,它有如下性质:

  • 若原图有 \(n\) 个节点,则其 \(T\)\(2n-1\) 个节点,\(T\) 的根节点为 \(2n-1\)
  • \(T\) 是一棵二叉树,且没有度数为 \(1\) 的节点
  • \(T\) 的叶子节点集合是原图的所有节点
  • 若设所有原图节点(叶子节点)的点权为 \(-\infin\),则任意一条叶子到根的路径上,点权递增

证明都很显然。

1.1.2. 应用:限制边权

那这棵 \(\text{Kruskal}\) 重构树有什么用处呢?

  • 考虑一个虚点 \(W\),显然它的子树内所有叶子节点一定在之前建树的某一时刻被 \(W\) 所代表的边加入了一个连通块内
  • 而又由于边权从小到大,所以在加入这条边之前,所有边权小于 \(w_W\) 的边已经连好,那么 \(W\) 的子树内的所有叶子节点即为在原图的最小生成树中,连接边权小于等于 \(w_W\) 的边时的一个连通块
  • 而对于原图不在最小生成树内的边权小于等于 \(w_W\) 的边,一定对连通性没有贡献,所以 \(W\) 的子树内的所有叶子节点即为在原图中,连接边权小于等于 \(w_W\) 的边时的一个连通块

换成可达性的表述:对于 \(W\) 的子树内的任一叶子节点 \(u\),它在原图中在经过边权不超过 \(w_W\) 的边情况下,可以到达的点的集合为 \(W\) 的子树中的叶子节点。

思考:这样会不会有问题?有多个权值为 \(w_W\) 的点时?

(答案:所以这里的 \(W\) 要保证 \(w_W\lt w_{fa_W}\)

这是 \(\text{Kruskal}\) 重构树的核心:那如果题目现在询问限制边权上界为 \(w\) 时,\(u\) 所在连通块内的相关信息时,我们可以先找到 \(u\),我们可以先找到 \(\text{Kruskal}\) 重构树中 \(u\) 最靠上的、满足 \(w_W\le w\) 的祖先 \(W\),那么 \(W\) 子树内的叶子节点组成了询问所需的这个连通块,转换成这个子树内的问题即可,提前预处理即可。

至于怎么找到这个 \(W\),树上倍增即可。

看一个例题:P4768 [NOI2018] 归程

lyc 已经讲了可持久化并查集的做法,但是:如图

谁愿意去写一个费力不讨好的东西呢。。。

所以我们今天来讲这道题的 \(\text{Kruskal}\) 重构树做法。

显然我们即需多次求在整棵树限制边权下界时,给定点所在连通块中离 \(1\) 最近的一个节点到 \(1\) 的距离。那我们建出 \(\text{Kruskal}\) 重构树,注意这时变成限制下界,所以边按边权从大到小排序。

每个点到 \(1\) 的距离可以一次 \(\text{dijkstra}\) 跑出,用前面说的方法树上倍增 + 树形 \(\text{dp}\) 即可,时间复杂度 \(O((n+m)\log n+m\log m+q\log n)\),视 \(n,m,q\) 同阶,复杂度 \(O(n\log n)\)

1.2. 最小瓶颈路

1.2.1. 瓶颈生成树与最小瓶颈路

\(\text{Def.}\) (最小)瓶颈生成树:无向图 \(G\) 的瓶颈生成树是这样的一个生成树,它的最大的边权值在 \(G\) 的所有生成树中最小。

\(\text{Thm.}\) 最小生成树一定是瓶颈生成树。

\(\text{Prf.}\) 反证法。设瓶颈生成树的最大边权为 \(w\),那么若最小生成树的最大边权 \(w'\) 满足 \(w'\gt w\),那么考虑删除最小生成树中边权为 \(w'\) 的一条边,此时原生成树被拆成两棵树,用瓶颈生成树之中的一条边(边权小于 \(w\))连接着两棵小树,那么得到的新生成树的边权和必然小于原来的边权和,与原树是最小生成树矛盾。得证。


\(\text{Def.}\) 最小瓶颈路:无向图 \(G\)\(u\)\(v\) 的最小瓶颈路是这样的一条简单路径,它之上的最大的边权在所有 \(u\)\(v\) 的简单路径中是最小的。

\(\text{Thm.}\) 最小生成树上 \(u\)\(v\) 的路径必然是最小瓶颈路。

\(\text{Prf.}\) 反证法。设最小生成树上 \(u\)\(v\) 路径上的最大边权为 \(w\),假设存在更优路径使得其上的最大边权 \(w'\) 满足 \(w'\lt w\),将这段路径放到最小生成树中,此时出现一个环,环上边权最大的边为 \(w\),这与最小生成树的性质矛盾。得证。

运用这个定理,我们可以将最小瓶颈路问题转化为在最小生成树上求 \(u\)\(v\) 的路径上的最大边权,树上倍增/树剖都可以解决。

1.2.2. 应用:求最小瓶颈路最大边权

一个重要性质:原图中 \(u\)\(v\) 的最小瓶颈路的最大边权等于 \(w_{\operatorname{lca}(u,v)}\)

这是很显然的:首先由前面的定理,我们只需考虑原图的最小生成树中的边即可。显然在最小生成树中,\(u\)\(v\) 的路径必然经过 \(\operatorname{lca}(u,v)\) 对应的边,因为正是这条边将 \(u\)\(v\) 所在连通块连起来的。同时,这条路径必然只会在 \(\operatorname{lca}(u,v)\) 的子树内活动,因为它是简单路径,而 \(\operatorname{lca}(u,v)\) 的子树内的边权都小于等于 \(w_{\operatorname{lca}(u,v)}\),所以在这条路径上,最大的边权必然是 \(\operatorname{lca}(u,v)\) 的边权。

有什么用呢?对于某些题目,\(\text{MST}\) 上倍增/树剖做不了,这时就可以考虑 \(\text{Kruskal}\) 重构树,稍后我们会看到。

板子题是 P1967 [NOIP 2013 提高组] 货车运输loj#137. 最小瓶颈路(加强版),两种方法都行。

1.3. 点权多叉重构树

对于限制点权的问题,容易想到将点权转为边权,比方说如果限制点权上界的话,对于边 \((u,v)\),可以令 \(w_{(u,v)}=max(w_u,w_v)\),这样就转化成了限制边权上界的问题,\(\text{Kruskal}\) 重构树即可。

但还有另一种做法:点权多叉重构树。我们按点权从小到大排序,枚举每一个节点 \(u\),再枚举每一条边 \((u,v)\),如果 \(v\) 已经遍历过且 \(u,v\) 在重构树上不连通,那么在重构树上令 \(v\) 的代表元的父亲为 \(u\)

我们研究一下这棵树的性质?首先类似地也有:任意一条叶子到根的路径上,点权递增,于是限制点权下的一个连通块只需考虑树上对应的一棵子树。况且这种做法不需建虚点,常数小一半!

1.4. 例题

I. P4768 [NOI2018] 归程

见上文。

// godmoo's code
const int N=2e5+5;
const int LG=20;
int t,n,m,q,k,s;
vector<pii> G[N]; // {v, l}
int vis[N],dis[N];
struct Node{
    int u,dis;
    Node(int _u=0,int _dis=0):u(_u),dis(_dis){}
    bool operator <(const Node& _) const{return dis>_.dis;}
};
void dijkstra(){
    mem(dis,0x7f),dis[1]=0,mem(vis,0);
    priority_queue<Node> q;q.ep(1,0);
    while(!q.empty()){
        int u=q.top().u;q.pop();
        if(vis[u]) continue;vis[u]=1;
        for(auto [v,l]:G[u]){
            if(dis[v]>dis[u]+l) q.ep(v,dis[v]=dis[u]+l);
        }
    }
}
int tot,fa[N<<1],w[N<<1];
vector<tiii> edge; // {v, l, a}
vector<int> T[N<<1]; // {v}
void build(int n){for(mem(fa,0),tot=1;tot<=n;tot++) fa[tot]=tot;}
int find(int u){return u==fa[u]?u:fa[u]=find(fa[u]);}
void kruskal(){
    sort(all(edge),[](tiii x,tiii y){return get<2>(x)>get<2>(y);});
    build(n);
    // 此时 tot 是 n + 1
    for(auto [u,v,a]:edge){
        if((u=find(u))==(v=find(v))) continue;
        fa[tot]=fa[u]=fa[v]=tot,w[tot]=a;
        T[tot].eb(u),T[tot].eb(v);
        tot++;
    }
}
int mu[LG][N<<1],mn[N<<1];
void dfs(int u,int fa){
    mu[0][u]=fa;
    for(int i=1;i<LG;i++) mu[i][u]=mu[i-1][mu[i-1][u]];
    mn[u]=u<=n?dis[u]:0x7f7f7f7f;
    for(int v:T[u]) dfs(v,u),ckmn(mn[u],mn[v]);
}
void init(){
    for(int i=1;i<=n;i++) G[i].resize(0);
    for(int i=1;i<(n<<1);i++) T[i].resize(0);
    edge.resize(0);
    mem(w,0);
}
int main() {
    ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
    cin>>t;
    while(t--){
        init();
        cin>>n>>m;
        for(int i=1,u,v,l,a;i<=m;i++){
            cin>>u>>v>>l>>a;
            G[u].eb(v,l),G[v].eb(u,l);
            edge.eb(u,v,a);
        }
        dijkstra();
        kruskal();
        dfs(2*n-1,0);
        cin>>q>>k>>s;
        for(int i=1,v,p,lst=0;i<=q;i++){
            cin>>v>>p,v=(v+k*lst-1)%n+1,p=(p+k*lst)%(s+1);
            for(int j=LG-1;~j;j--) if(w[mu[j][v]]>p) v=mu[j][v];
            cout<<(lst=mn[v])<<"\n";
        }
    }
    cout<<flush;
    return 0;
}

II. loj#6493. graph

本题数据范围不对,颜色的值域为 \([0,10^5]\)

首先扔到 \(\text{Kruskal}\) 重构树上,那么如果有一个合法点对 \((u,v)\),那么它们的贡献是 \(w_{\operatorname{lca}(u,v)}\)

考虑在 \(\text{lca}\) 处统计答案,\(\text{dsu~on~tree}\) 即可,值域树状数组随便维护。

// godmoo's code
// 此题范围有误,c_i\le10^5
const int N=2e5+5;
const int MX=1e5+5;
int n,m,l,col[N];
ll ans;
vector<t3> edge;
int tot,fa[N<<1],wgt[N<<1];
vector<int> T[N<<1];
void build(){for(tot=1;tot<=n;tot++) fa[tot]=tot;}
int find(int u){return u==fa[u]?u:fa[u]=find(fa[u]);}
void kruskal(){
    build();
    sort(all(edge),[](t3 x,t3 y){return get<2>(x)<get<2>(y);});
    for(auto [u,v,w]:edge){
        if(tot==(n<<1)) continue;
        if((u=find(u))==(v=find(v))) continue;
        fa[tot]=fa[u]=fa[v]=tot,wgt[tot]=w;
        T[tot].eb(u),T[tot].eb(v);
        tot++;
    }
}
int siz[N<<1],son[N<<1];
void dfs1(int u){
    siz[u]=1;
    for(int v:T[u]){
        dfs1(v);
        siz[u]+=siz[v];
        if(!son[u]||siz[v]>siz[son[u]]) son[u]=v;
    }
}
class BIT{
    int n,s,tr[MX];
    #define lb(x) ((x)&(-(x)))
    int query(int u){int res=0;for(;u;u-=lb(u))res+=tr[u];return res;}
    int query(int l,int r){return query(r)-query(l-1);}
public:
    // 区分好对内对外接口,只在对外接口中处理移位问题,注意不要因为内部调用而多次移位
    void add(int u,int x){u++;for(;u<=n;u+=lb(u))tr[u]+=x;s+=x;}
    int queryC(int l,int r){l++,r++;ckmx(l,1),ckmn(r,n);return s-query(l,r);} // 查询补集
    BIT(int _n=0){n=_n,s=0,mem(tr,0);}
};
BIT bit(MX-4);
vector<int> C;
void getinfo(int u){if(T[u].empty()) C.eb(col[u]);for(int v:T[u]) getinfo(v);}
void dfs2(int u,int flag){
    for(int v:T[u]) if(v!=son[u]) dfs2(v,0);
    if(son[u]) dfs2(son[u],1);
    else bit.add(col[u],1);
    ll cnt=0;
    for(int v:T[u]) if(v!=son[u]){
        C.resize(0),getinfo(v);
        for(int c:C) cnt+=bit.queryC(c-l+1,c+l-1);
        for(int c:C) bit.add(c,1);
    }
    ans+=cnt*wgt[u];
    if(!flag){
        C.resize(0),getinfo(u);
        for(int c:C) bit.add(c,-1);
    }
}
int main() {
    ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
    cin>>n>>m>>l;
    for(int i=1;i<=n;i++) cin>>col[i];
    for(int i=1,u,v,w;i<=m;i++) cin>>u>>v>>w,edge.eb(u,v,w);
    kruskal();
    dfs1(2*n-1),dfs2(2*n-1,0);
    cout<<ans<<endl;
    return 0;
}

III. CF1628E Groceries in Meteor Town

节点 \(x\) 出发到达任意一个白色节点的简单路径上经过的边最大可能的权值,即为 \(x\) 和所有白色点的最小瓶颈路最大值取 \(\max\)。建出 \(\text{Kruskal}\) 重构树,那么也就是 \(\max\limits_{u\in S}w_{\operatorname{lca}(x,u)}\)

这些 \(\operatorname{lca}(x,u)\) 都是 \(x\) 的祖先,由于取 \(\max\),我们只关心最高的那个,所以实际上着等价于 \(w_{\operatorname{lca}(S\cup\{u\})}\)。问题转化为点集的 \(\operatorname{lca}\),我们考虑怎么动态维护 \(\operatorname{lca}(S)\)

我们知道一个经典结论:\(\operatorname{lca}(S)=\operatorname{lca}(\max_{\text{dfn}}(S),\min_{\text{dfn}}(S))\)

所以对于白点,线段树处理 \(1,2\) 推平操作,\(3\) 操作维护出 \(\max_{\text{dfn}}(S)\)\(\min_{\text{dfn}}(S)\),答案即为 \(\operatorname{lca}({x,\max_{\text{dfn}}(S),\min_{\text{dfn}}(S)})\)

时间复杂度 \(O((n+q)\log n)\)

IV. CF1253F Cheap Robot

没那么裸了,先挖掘一下性质:考虑在确定一个 \(c\) 的时候,先尝试思考哪些边一定不能走。

\(d_u\)\(u\) 到其最近的充电中心的距离,可以通过一次 \(\text{dijkstra}\) 求出。显然对于一条边 \((u,v)\)\(d_u+w_{(u,v)}+d_v>c\) 的边是不能走的。

这变相限制了 \(c\) 的范围:如果考虑从 \(a\) 走到 \(b\) 的一条路径,那么这条路径上所有的边都需满足 \(d_u+w_{(u,v)}+d_v\le c\)。这启发我们令 \(w_{(u,v)}\gets d_u+w_{(u,v)}+d_v\),对于这条路径就须有 \(c\ge\max{w_{(u,v)}}\),那么 \(c\) 的最小值就是 \(\max{w_{(u,v)}}\),即找 \(a\)\(b\) 的最小瓶颈路最大边权。

注意这只是必要条件,下面考虑它的充分性:由于 \(a\)\(b\) 都是充电中心,从 \(a\) 开始走,对于路径上其它点,我们只需按照顺序依次走到离它们最近的充电中心即可,最后到达 \(b\) 即可。充分性得证。

V. CF1416D Graph and Queries

有点意思的题。

操作 \(2\) 删边,正着做不好做,试着离线下来倒序加边。

但是注意到操作 \(1\) 虽然依赖图的连通性,但是却强依赖于正序处理,肯定是没法倒序处理的,这启发我们:在倒序处理时,用一个数据结构维护出不同时刻下图的连通性,再正着处理操作 \(1\)

容易想到 \(\text{Kruskal}\) 重构树,将边的存在时间设为边权,操作 \(1\) 变成一个限制边权下界时依赖连通性的问题,搬到 \(\text{Kruskal}\) 重构树上就变成了子树问题。\(\text{dfs}\) 序拍平后线段树维护即可。

VI. CF1706E Qpwoeirut and Vertices

区间连通性?可以先维护出 \(i\)\(i+1\) 连通的答案 \(ans(i,i+1)\),那么 \(ans(\{x\mid x\in\mathbb{Z}~\land~l\le x\le r\})=\max\limits_{i=l}^{r-1}ans(i,i+1)\),线段树 / \(\text{ST}\) 维护。注意特判 \(l=r\) 的情况。

\(ans(i,i+1)\) 即为最小瓶颈路最大边权,\(\text{Kruskal}\) 重构树即可。

VII. P4899 [IOI 2018] werewolf 狼人

相当于一开始在限制下界的连通块内走,后来在一个限制上界的连通块内走,通过两个连通块的一共同点转换阶段。

分别建出限制上界和下界的点权多叉重构树,找到两个子树/连通块,即询问有没有交,\(\text{dfs}\) 序拍平 + 二维数点即可。

VIII. [提高组集训 2021] 超级加倍(来源不明)

定义树上简单路径 \(x\rightarrow y\) 是好的当且仅当路径上编号最小的点为 \(x\),编号最大的点为 \(y\)。给定大小为 \(n\) 的树 \(T\),求 \(T\) 的好路径数量。\(n\le2\times10^6\)

分别建出限制上界和下界的点权多叉重构树 \(T_u\)\(T_d\)\(x\rightarrow y\) 合法当且仅当 \(x\in\operatorname{subtree}_{T_u}(y)\land y\in\operatorname{subtree}_{T_d}(x)\)\(\text{dfs}\) 序拍平处理一个,\(\text{dfs}\) + 值域树状数组算完。

IX. CF1578L Labyrinth

好题。

肯定是在最大生成树上走最优。转换成一个树上问题。

那么随着宽度的增大,必然有一些边会断掉,这对一棵树来说就相当于把整个树分开,所以在某条边断掉之前,我们必须吃完其中一边的糖果同时到达另外一边。

考虑树上边权最小的边,它一定是最早断掉的,令它为 \((u,v)\),,断开后得到的两棵树为 \(T_u,T_v\)。若先吃完了 \(T_u\) 的糖果,这时感性理解一下:一开始先吃完 \(T_u\) 的所有糖果再去考虑 \(T_v\) 一定不劣。那么按照这个策略,此时有:\(ans(T)=\min\{w_{(u,v)}-\sum\limits_{i\in T_u}a_i,ans(T_v)-\sum\limits_{i\in T_u}a_i\}\),那么先吃完 \(T_v\) 的情况同理,最后有:

\[ans(T)=\max\bigg\{\!\min\{w_{(u,v)}-\sum_{i\in T_u}a_i,ans(T_v)-\sum_{i\in T_u}a_i\},\min\{w_{(u,v)}-\sum_{i\in T_v}a_i,ans(T_u)-\sum_{i\in T_v}a_i\}\!\bigg\} \]

\(\text{Kruskal}\) 重构树过程中计算即可,边界为 \(ans({u})=\infin\)

习题:

posted @ 2025-04-21 13:02  godmoo  阅读(89)  评论(0)    收藏  举报