树上启发式合并(DSU-on-Tree)

前言

树上启发式合并,用来解决子树查询问题,是一种离线的、“暴力的”算法。从某种角度而言,与莫队算法有些类似,即:按照我们指定的顺序对数据做一个遍历,在遍历过程中记录询问的答案。CF上有一个专门的blog,讲了很多,本文只涉及到有关轻重链剖分的部分。oi-wiki上有一个更短的文章

简单而言,就是对每一个节点,分两步:

  1. 递归统计子节点的数据
    • 先统计轻儿子(统计完的数据即刻清空)
    • 最后统计重儿子(统计完的数据保留下来)
  2. 统计自己的数据(就是再统计一遍轻儿子的数据,加上本节点的数据)

在统计本节点的数据时,因为重儿子的数据已经保存,因此只需要再统计一遍轻儿子的数据,再加上本节点的数据,就有了本节点子树的所有数据,于是可以回答有关本节点子树的相关询问。

可以证明(并不会证明),这样重复统计以及清空操作,数量级是 O ( N log ⁡ N ) O(N\log{N}) O(NlogN)的。

当然首先要会轻重链剖分,其实并不需要,因为这里其实并不需要实际剖出树链,只需要求出重儿子即可。因此实际上比剖分要简单的多。出于个人习惯,还是称之为剖分。

入门题

例1

为了简单起见,我们考虑一个不需要DSUonTree的问题,问子树的节点数量。这个显然 O ( N ) O(N) O(N)就能完成。我们考虑一下启发式合并。

#include <bits/stdc++.h>
using namespace std;

struct dsu_on_tree_t{
   
   

using vi = vector<int>;

int N;
vector<vi> G; // 树, 1-index

struct node_t{
   
    // 树链剖分的结构
    int size; 
    int hson; // 重儿子,这里是原树编号    
    int nid;  // 在树链剖分中的新编号
    int mdes; // 本子树全部在[nid, mdes]之间, 这是剖分编号
};
vector<node_t> Nodes;
vi New2Old;   // 剖分的编号为i,则原树节点编号为New2Old[i], 显然有Nodes[New2Old[i]].nid == i
int TimeStamp;
int Root;

vector<int> Ans;
int Cnt; // 全局变量用于记录即时数据

void init(int n){
   
   
    N = n;
    G.assign(N + 1, {
   
   });
    Nodes.assign(N + 1, {
   
   0, 0, 0, 0});
    New2Old.assign(N + 1, 0);
    TimeStamp = 0;

    Ans.assign(N + 1, 0);
}

void mkDiEdge(int a, int b){
   
   
    G[a].push_back(b);
}

void mkBiEdge(int a, int b){
   
   
    mkDiEdge(a, b);
    mkDiEdge(b, a);
}

void dfsHeavy(int u, int p){
   
    // 递归重儿子
    auto & n = Nodes[u];
    n.size = 1;
    New2Old[n.nid = ++TimeStamp] = u;

    for(auto v : G[u]){
   
   
        if(v == p) continue;

        dfsHeavy(v, u);
        n.size += Nodes[v].size;
        if(Nodes[n.hson].size < Nodes[v].size) n.hson = v;
    }

    n.mdes = TimeStamp;
    return;
}


void dfs(int u, int p, bool keep){
   
    // 递归
    const auto & n = Nodes[u];
    for(auto v : G[u]){
   
   
        if(v == p or v == n.hson) continue;
        dfs(v, u, false);
    }
    /// 最后递归重儿子
    if(n.hson) dfs(n.hson, u, true);

    /// 以下为统计u节点及其轻儿子
    if(n.hson){
   
   
        for(int i=n.nid;i<Nodes[n.hson].nid;++i) ++Cnt;
        /// 刚好把重儿子忽略掉
        for(int i=Nodes[n.hson].mdes+1;i<=n.mdes;++i) ++Cnt;
    }else{
   
    // 只有一个节点
        ++Cnt;
    }

    /// 此时可以回答问题
    Ans[u] = Cnt;

    /// 是否清空u子树对即时数据的影响
    if(not keep){
   
   
        for(int i=n.nid;i<=n.mdes;++i) --Cnt;
    }
    return;
}


}Tree;

int main(){
   
   
#ifndef ONLINE_JUDGE
    freopen("z.txt", "r", stdin);
#endif
    ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
    int n; cin >> n;
    Tree.init(n);
    for(int a,i=2;i<=n;++i){
   
   
        cin >> a;
        Tree.mkDiEdge(a, i);
    }
    Tree.dfsHeavy(1, 0);
    Tree.dfs(1, 0, true);
    for(int i=1;i<=n;++i) cout << Tree.Ans[i] << "\n";
    return 0;
}

首先关于树链剖分部分,加了一个数据域mdes,其作用主要是为了明确本子树的范围。这样方便后续将代码写成迭代实现。树链剖分的实现这里就略过了。

然后就是本文的核心实现函数dfs
这一部分就是递归,并且把重儿子放在最后递归。

    for(auto v : G[u]){
   
   
        if(v == p or v == n.hson) continue;
        dfs(v, u, false);
    }
    /// 最后递归重儿子
    if(n.hson) dfs(n.hson, u, true);

接下来就是统计即时数据,即统计u子树中除了u的重儿子之外的节点对即时数据造成的影响。因为记录了mdes,这一段可以写成迭代。迭代实现对于初次实现本算法的程序员,在调试方面比较友好。当然,也可以写成递归实现。

    /// 以下为统计u节点及其轻儿子
    if(n.hson){
   
   
        for(int i=n.nid;i<Nodes[n.hson].nid;++i) ++Cnt;
        /// 刚好把重儿子忽略掉
        for(int i=Nodes[n.hson].mdes+1;i<=n.mdes;++i) ++Cnt;
    }else{
   
    // 只有一个节点
        ++Cnt;
    }

接下来是根据当前的即时数据,回答跟u有关的问题。这里的话比较简单。

    /// 此时可以回答问题
    Ans[u] = Cnt;

最后是根据参数,决定是否要清空u子树对即时数据的影响。这里写成迭代就非常自然了。当然,递归也是极好的。

    /// 是否清空u子树对即时数据的影响
    if(not keep){
   
   
        for(int i=n.nid;i<=n.mdes;++i) --Cnt;
    }

对于DSU-on-Tree而言,这个流程是固定的。只需要考虑每个节点对即时数据造成的影响,以及如何根据即时数据回答问题即可。当然这两个动作需要能够“很快的”完成。

例2

再来看标准的例子,每个节点有一个颜色,问子树的不同颜色的种类的数量。只需要有一个计数器记录各种颜色的数量,并且记录计数器中不同种类的数量即可,都可以在 O ( 1 ) O(1) O(1)完成。因此加上DSU的流程,在 O ( N log ⁡ N ) O(N\log{N}) O(NlogN)可以完成。

#include <bits/stdc++.h>
using namespace std;

struct dsu_on_tree_t{
   
   

using vi = vector<int>;

int N;
vector<vi> G; // 树, 1-index

struct node_t{
   
    // 树链剖分的结构
    int size; 
    int hson; // 重儿子,这里是原树编号    
    int nid;  // 在树链剖分中的新编号
    int mdes; // 本子树全部在[nid, mdes]之间, 这是剖分编号
};
vector<node_t> Nodes;
vi New2Old;   // 剖分的编号为i,则原树节点编号为New2Old[i], 显然有Nodes[New2Old[i]].nid == i
int TimeStamp;
int Root;

vector<int> Color;

vector<int> Flag;
int Cnt; // 全局变量用于记录即时数据

vector<int> Ans;
vector<vi> Questions;

void init(int n){
   
   
    N = n;
    G.assign(N + 1, {
   
   });
    Nodes.assign(N + 1, {
   
   0, 0, 0, 0});
    New2Old.assign(N + 1, 0);
    TimeStamp = 0;
    Color.assign(N + 1, 0);

    Flag.assign(N + 1, Cnt = 0);
}

void mkDiEdge(int a, int b){
   
   
    G[a].push_back(b);
}

void mkBiEdge(int a, int b){
   
   
    mkDiEdge(a, b);
    mkDiEdge(b, a);
}

void dfsHeavy(int u, int p){
   
    // 递归重儿子
    auto & n = Nodes[u];
    n.size = 1;
    New2Old[n.nid = ++TimeStamp] = u;

    for(auto v : G[u]){
   
   
        if(v == p) continue;

        dfsHeavy(v, u);
        n.size += Nodes[v].size;
        if(Nodes[n.hson].size < Nodes[v].size) n.hson = v;
    }

    n.mdes = TimeStamp;
    return;
}


void dfs(int u, int p, bool keep){
   
    // 递归
    const auto & n = Nodes[u];
    for(auto v : G[u]){
   
   
        if(v == p or v == n.hson) continue;
        dfs(v, u, false);
    }
    /// 最后递归重儿子
    if(n.hson) dfs(n.hson, u, true);

    /// 以下为统计u节点及其轻儿子
    if(n.hson){
   
   
        for(int i=n.nid;i<Nodes[n.hson].nid;++i){
   
   
            if(1 == ++Flag[Color[New2Old[i]]]){
   
   
                ++Cnt;
            }
        }
        for(int i=Nodes[n.hson].mdes+1;i<=n.mdes;++i){
   
   
            if(1 == ++Flag[Color[New2Old[i]]]){
   
   
                ++Cnt;
            }
        }
    }else{
   
    // 只有一个节点
        if(1 == ++Flag[Color[u]]){
   
   
            ++Cnt;
        }
    }

    /// 此时可以回答问题
    for(auto i : Questions[u]){
   
   
        Ans[i] = Cnt;
    }

    /// 是否清空u子树对即时数据的影响
    if(not keep){
   
   
        for(int i=n.nid;i<=n.mdes;++i) {
   
   
            if(0 == --Flag[Color[New2Old[i]]]){
   
   
                --Cnt;
            }
        }
    }
    return;
}


}Tree;

int main(){
   
   
#ifndef ONLINE_JUDGE
    freopen("z.txt", "r", stdin);
#endif
    ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
    int n; cin >> n;
    Tree.init(n);
    for(int a,b,i=2;i<=n;++i){
   
   
        cin >> a >> b;
        Tree.mkBiEdge(a, b);
    }
    for(int i=1;i<=n;++i) cin >>Tree.Color[i];

    int q; cin >> q;
    Tree.Questions.assign(n + 1, {
   
   });
    Tree.Ans.assign(q, {
   
   });
    for(int a,i=0;i<q;++i){
   
   
        cin >> a;
        Tree.Questions[a].push_back(i);
    } 

    Tree.dfsHeavy(1, 0);
    Tree.dfs(1, 0, true);
    for(auto i : Tree.Ans) cout << i << "\n";
    return 0;
}

NC23807的题目意思与本题是一模一样的,这里就不再赘述。

例3CF600E

这也是一个入门的例子,可以更好的认识清空操作的含义。给定树,每个节点有颜色。询问每一个子树的颜色的众数,即求出出现颜色最多的颜色编号;如果有多个颜色均出现最多次,则对编号求和。

本题显然也要弄一个计数器,然后每过一个节点,相应的颜色数量加加即可,对于众数或者众数求和都很简单。可能存在的一个问题在于如何清空?如果要考虑移除一个节点,对于答案的影响,那就很难办了。因为最值不支持快速的减法操作。但是注意到,本质上我们进行的是一个清空操作。考虑到递归顺序的安排,每次需要清空时,我们必然处在轻儿子处,且相应的重儿子必然在其之后。因此当本次清空完成时,实际上所有即时数据全部都清零了。之所以有些数据需要逐个节点、逐个节点的操作,是为了控制时间,只清除非零的数据。因为如果不管三七二十一,直接fill所有数据,显然是要超时的。

#include <bits/stdc++.h>
using namespace std;

struct dsu_on_tree_t{
   
   

using vi = vector<int>;

int N;
vector<vi> G; // 树, 1-index

struct node_t{
   
    // 树链剖分的结构
    int size; 
    int hson; // 重儿子,这里是原树编号    
    int nid;  // 在树链剖分中的新编号
    int mdes; // 本子树全部在[nid, mdes]之间, 这是剖分编号
};
vector<node_t> Nodes;
vi New2Old;   // 剖分的编号为i,则原树节点编号为New2Old[i], 显然有Nodes[New2Old[i]].nid == i
int TimeStamp;
int R
<think>嗯...用户这次问的是"启发式合并"和"树上启发式合并"的优劣区别,看来ta对算法优化领域很感兴趣啊。从ta精准引用站内文献的能力看,可能是计算机专业的研究生或从业者。 回顾上次对话,用户系统要求很严格呢,必须确保所有数学公式都正确用$包裹,中文回答,还要生成相关问题。不过这次问题本身倒很聚焦,就是两个概念的对比分析。 让我先理清核心概念:普通启发式合并更通用,适用于集合合并问题;而树上启发式合并(dsu on tree)是专门处理树形结构的变种。有意思的是,引用[1]提到dsu on tree用于"统计子树中具有某种特征的节点数",这正好解释了它的专精领域。 注意到引用[2][3][4]其实更侧重"启发式搜索",与当前问题关联度不高。重点应该放在引用[1]和引用[3]的对比上——前者描述树上版本,后者定义通用版本。用户特意区分"优劣区别",说明ta需要实践指导,可能是要解决具体问题。 技术层面要强调三点本质差异:一是应用场景(普通集合vs树结构),二是复杂度(树上版本优化到O(n log n)),三是实现难度(树上需要dfs序处理)。用户既然能提出这么专业的问题,应该能理解heavy-light分解这样的术语。 最后生成的问题既要延续深度(比如询问替代算法),也要考虑实用性(比如具体代码实现)。毕竟从ta上次关注快速排序的行为看,是理论结合实践的类型。 </think>以下是启发式合并树上启发式合并的对比分析: --- ### **一、核心概念** 1. **启发式合并(Heuristic Merge)** - **本质**:通过特定策略优化集合合并过程的算法 - **核心思想**:始终将**较小集合的元素合并到较大集合**中 - **时间复杂度**:$O(n \log n)$($n$为总元素数) - **适用场景**:并查集合并、离线查询处理等[^3] ```python # 伪代码示例 def merge(set_a, set_b): if len(set_a) < len(set_b): # 选择较小集合合并到较大集合 for x in set_a: set_b.add(x) return set_b else: for x in set_b: set_a.add(x) return set_a ``` 2. **树上启发式合并DSU on Tree)** - **本质**:在树结构上对启发式合并的扩展 - **核心思想**: - 递归处理子树时**保留重儿子的计算结果** - 轻儿子子树结果**合并到重子树**(基于子树大小) - **时间复杂度**:$O(n \log n)$($n$为节点数) - **适用场景**:子树统计问题(如颜色计数、众数查询等)[^1] --- ### **二、关键区别** | **特性** | 启发式合并 | 树上启发式合并 | |------------------|----------------------|-----------------------| | **数据结构** | 集合/并查集 | 树结构(DFS遍历) | | **操作对象** | 集合元素 | 树节点及其子树 | | **优化核心** | 集合大小平衡 | 重链剖分(子树大小) | | **典型问题** | 动态连通性 | 子树统计查询 | | **空间复杂度** | $O(n)$ | $O(n \log n)$ | --- ### **三、算法优劣对比** #### **启发式合并优势** - **实现简单**:仅需维护集合大小 - **通用性强**:适用于线性数据结构 - **内存效率**:空间占用恒定 $O(n)$ #### **劣势** - **不支持树结构**:无法直接处理子树关联问题 #### **树上启发式合并优势** - **高效子树处理**:利用树的重链特性避免重复计算 - **解决特定问题**:如子树颜色统计(例题:CF 600E) - **查询离线化**:支持批量子树查询 #### **劣势** - **实现复杂**:需预处理重儿子、DFS序等[^1] - **空间开销大**:需存储中间结果 - **灵活性受限**:仅适用于静态树 --- ### **四、应用场景对比** 1. **启发式合并适用**: - 动态图连通性维护 - 离线区间合并问题(如MEX查询) 2. **树上启发式合并适用**: ```mermaid graph LR A[子树统计问题] --> B[颜色计数] A --> C[众数查询] A --> D[子树权值分布] ``` --- ### **五、性能关键点** - **树上启发式合并的 $\log n$ 来源**: 每个节点被合并的次数 $\leq \log n$(轻边子树大小至少减半) 推导:$T(n) = T(n/2) + O(n) \Rightarrow T(n) = O(n \log n)$ - **极端场景注意**: 链式树可能退化为 $O(n^2)$(需结合重链剖分优化) ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值