【启发式合并】(dsu on tree)讲解

【启发式合并】(dsu on tree)讲解+例题

超级好的讲解(来自cf)


一、启发式合并的作用:

我们可以在O(nlogn) 的时间内,回答下列形式的所有询问:
节点v的子树中有多少节点有某种特性。

例如:
给一棵树,每个节点有一种颜色,询问:节点v的子树中,有多少节点的颜色是color c?

二、解决方法
2.1 准备工作:计算每个节点的子树大小
int sz[maxn];
void getsz(int v, int p)
{
    sz[v] = 1;  // every vertex has itself in its subtree
    for(auto u : g[v])
        if(u != p)
        {
            getsz(u, v);
            sz[v] += sz[u]; // add size of child u to its parent(v)
        }
}

2.2 普通解法(O(N ^ 2)
int cnt[maxn];
void add(int v, int p, int x)
{
    cnt[ col[v] ] += x;
    for(auto u: g[v])
        if(u != p)
            add(u, v, x)
}
        
void dfs(int v, int p)
{
    add(v, p, 1);
    //now cnt[c] is the number of vertices in subtree of vertex v that has color c. You can answer the queries easily.
    add(v, p, -1);
    for(auto u : g[v])
        if(u != p)
            dfs(u, v);
}

个人理解:要求得每一棵子树的值,那么就需要对每一棵子树进行遍历,由于只有一个cnt记录空间,因此每次遍历完后,需要将cnt数组清空,然后才能进行下一个兄弟节点的遍历,否则会将从cnt里的数据搞混淆,得出错误的结果。
这样一个节点会被重复遍历多次,复杂度很高。


2.3 easy to code but O(n log^2 n)
void dfs(int v, int p)
{
    int mx = -1, bigChild = -1;
    for(auto u : g[v])
        if(u != p)
        {
            dfs(u, v);
            if(sz[u] > mx)
                mx = sz[u], bigChild = u;
        }
    if(bigChild != -1)
        cnt[v] = cnt[bigChild];
    else
        cnt[v] = new map<int, int> ();
    (*cnt[v])[ col[v] ] ++;
    for(auto u : g[v])
        if(u != p && u != bigChild)
        {
            for(auto x : *cnt[u])
                (*cnt[v])[x.first] += x.second;
        }
    //now (*cnt[v])[c] is the number of vertices in subtree of vertex v that has color c. You can answer the queries easily.

}
2.4 easy to code and O(nlogn)
vector<int> *vec[maxn];
int cnt[maxn];
void dfs(int v, int p, bool keep)
{
    int mx = -1, bigChild = -1;
    for(auto u : g[v])
        if(u != p && sz[u] > mx)
            mx = sz[u], bigChild = u;
    for(auto u : g[v])
        if(u != p && u != bigChild)
            dfs(u, v, 0);
    if(bigChild != -1)
        dfs(bigChild, v, 1), vec[v] = vec[bigChild];
    else
        vec[v] = new vector<int> ();
    vec[v]->push_back(v);
    cnt[ col[v] ]++;
    for(auto u : g[v])
        if(u != p && u != bigChild)
            for(auto x : *vec[u])
            {
                cnt[ col[x] ]++;
                vec[v] -> push_back(x);
            }
    //now (*cnt[v])[c] is the number of vertices in subtree of vertex v that has color c. You can answer the queries easily.
    // note that in this step *vec[v] contains all of the subtree of vertex v.
    if(keep == 0)
        for(auto u : *vec[v])
            cnt[ col[u] ]--;
}

2.5 heavy-light decomposition style O(nlogn) 【重点】

int cnt[maxn];
bool big[maxn];
void add(int v, int p, int x)
{
    cnt[ col[v] ] += x;
    for(auto u: g[v])
        if(u != p && !big[u])
            add(u, v, x)
}

void dfs(int v, int p, bool keep)
{
    int mx = -1, bigChild = -1;
    for(auto u : g[v])
        if(u != p && sz[u] > mx)
            mx = sz[u], bigChild = u;
    for(auto u : g[v])
        if(u != p && u != bigChild)
            dfs(u, v, 0);  // run a dfs on small childs and clear them from cnt
    if(bigChild != -1)
        dfs(bigChild, v, 1), big[bigChild] = 1;  // bigChild marked as big and not cleared from cnt
    add(v, p, 1);
    //now cnt[c] is the number of vertices in subtree of vertex v that has color c. You can answer the queries easily.
    if(bigChild != -1)
        big[bigChild] = 0;
    if(keep == 0)
        add(v, p, -1);
}

个人理解

  • (原始解法)要求得每一棵子树的值,那么就需要对每一棵子树进行遍历,由于只有一个cnt记录空间,因此每次遍历完后,需要将cnt数组清空,然后才能进行下一个兄弟节点的遍历,否则会将从cnt里的数据搞混淆,得出错误的结果。
  • 我们发现其实这里面有些重复的步骤可以省略。比如说,在遍历一个节点u时,因为是dfs,所以一定是先将u的所有子节点都dfs一遍后,然后才会返回到u,对u进行求解。而对u进行求解时,要对子节点求和再加上他自己,还是需要遍历它的所有子节点,那么既然上面已经遍历一遍子节点,那么刚刚遍历的子节点信息不删除,直接在这里用,不就节省了一遍重复遍历的过程吗?
  • 但是不能保留下所有的子节点信息。因为我们进行dfs时,是公用cnt这个数组的,如果不将之前的信息清除掉,就会造成混乱。(比如节点1有三个子节点2、3、4,而4有两个子节点5、6。现在对1进行遍历,那么首先要对子节点遍历,也就是进行后序遍历:2、3、5、6、4、1。如果对2进行遍历后,不清空cnt,那么接着对3进行遍历的时候,就会把2和3的信息混合,导致3的信息出错。)
  • 但是我们可以保留最后一个遍历的子节点的信息,然后再把前面清空掉的信息,再遍历一遍,这样相当于少遍历了一遍。(还是上面那个例子,我们遍历完4的信息后,马上就要返回1了,而计算1的时候,还是需要对2、3、4计算一遍,因为1 = 1 + 2 + 3+ 4。那么从4返回1的时候,4的信息就不用清除了,此时cnt中装有4的信息,即cnt[1] = cnt[4]。然后再把2、3和1自己的信息加上,就完成了对1的遍历。这个过程中可以看到,和原始做饭做法相比,少计算了一次4的值)。
  • 既然可以少算一个子节点的值,那么选择哪个节点作为这个最后一个即可以被少算的这个节点,是至关重要的。显然,我们应该选择规模最大的一个子节点(也叫作重儿子),这样就可以少花点时间。
  • 于是可以得出算法:每一次先对轻儿子进行遍历,求出轻儿子的ans,在这个过程中,每一次都要清空cnt。然后计算重儿子的ans,计算完后,不对cnt清空,接着求父节点自己的,再把轻儿子的值再遍历一遍,累加到cnt中。这样就完成了对一整棵树所有节点ans的遍历求解。
  • 和原始做法相比,我们节约了对重儿子的重复计算。

自己修改过的板子

//CodeForces-600E

#include <iostream>
#include <stdio.h>
#include <string.h>
#include <string>
#include <math.h>
#include <algorithm>
#include <vector>

using namespace std;

const int maxn = 1e5 + 100;

int col[maxn], sz[maxn], son[maxn], cnt[maxn];
int n, big, maxx;
long long ans[maxn], sum;
vector<int> G[maxn];

void get_son(int u, int p)
{
    sz[u] = 1;
    int len = G[u].size();
    for(int i = 0; i < len; i++)
    {
        int v = G[u][i];
        if(v != p)
        {
            get_son(v, u);
            sz[u] += sz[v];
            if(sz[v] > sz[son[u]])
                son[u] = v;
        }
    }
}

void add(int u, int p, int x)
{
    cnt[ col[u] ] += x;
    if(cnt[ col[u] ] > maxx)
    {
        sum = col[u];
        maxx = cnt[ col[u] ];
    }
    else if(cnt[ col[u] ] == maxx)
    {
        sum += col[u];
    }

    int len = G[u].size();
    for(int i = 0; i < len; i++)
    {
        int v = G[u][i];
        if(v != p && v != big)
            add(v, u, x);
    }
}

void dfs(int u, int p, bool keep)
{
    int len = G[u].size();
    for(int i = 0; i < len; i++)
    {
        int v = G[u][i];
        if(v != p && v != son[u])
            dfs(v, u, 0);
    }
    if(son[u])
    {
        dfs(son[u], u, 1);
        big = son[u];
    }
    add(u, p, 1);
    big = 0;
    ans[u] = sum;
    if(!keep)
    {
        add(u, p, -1);
        maxx = 0;
        sum = 0;
    }
}


int main()
{
    while(~scanf("%d", &n))
    {
        big = 0;
        maxx = 0;
        sum = 0;
        for(int i = 1; i <= n; i++)
        {
            G[i].clear();
            sz[i] = son[i] = cnt[i] = ans[i] = 0;
        }
        for(int i = 1; i <= n; i++)
            scanf("%d", &col[i]);
        int u, v;
        for(int i = 0; i < n - 1; i++)
        {
            scanf("%d%d", &u, &v);
            G[u].push_back(v);
            G[v].push_back(u);
        }
        get_son(1, 0);
        dfs(1, 0, 1);
        for(int i = 1; i <= n; i++)
        {
            if(i != 1)
                printf(" ");
            printf("%lld", ans[i]);
        }
        printf("\n");
    }
    return 0;
}
<think>嗯,用户现在在学习算法竞赛,想要了解启发式合并,并且希望由浅入深地讲解,还要用C++的例子来说明。那我得先理清楚启发式合并的基本概念,然后逐步深入,最后给出代码示例。 首先,启发式合并是什么?可能用户之前学过并查集,或者普通的合并方法,但遇到某些情况下效率不高的问题。启发式合并应该是一种优化合并操作的方法,可能通过选择合并的顺序或方式,减少时间复杂度。比如在并查集中的按秩合并,可能就是一种启发式合并的应用。不过用户可能指的是更一般的数据结构合并时的优化策略。 那启发式合并的核心思想应该是每次合并两个集合时,总是将较小的集合合并到较大的集合中,这样可以减少总的操作次数。比如,如果每次合并时都把小集合的元素移动到大集合里,那么每个元素被移动的次数不会超过logN次,因为每次移动后所在的集合大小至少翻倍。这样总的时间复杂度就能控制在O(N logN)级别。 接下来需要解释为什么这种方法有效。比如,假设有两个集合A和B,大小分别为|A|和|B|,且|A|<=|B|。那么将A合并到B中,这样A中的每个元素所在的集合大小至少是原来的两倍(因为B的大小比A大)。因此,每个元素最多被合并logN次,总操作次数就是O(N logN)。 接下来,可能需要举一个具体的例子,比如合并链表或者树结构的时候使用启发式合并。或者是在处理并查集的时候,按大小合并的情况。用户可能需要具体的应用场景,比如并查集的优化,或者在处理某些数据结构(比如平衡树、链表)时的合并操作。 然后,需要用C++来举例说明。比如,可以用并查集的例子,或者用其他数据结构,比如集合的合并。例如,维护多个集合,每个集合用某种结构存储,合并的时候总是将小的合并到大的里面,这样可以减少时间。 比如,可以举一个并查集的例子。并查集的按秩合并就是启发式合并的一种。在并查集的实现中,每个集合有一个父节点,还有一个秩(rank)或者大小。合并的时候,总是将秩较小的根指向秩较大的根。这样能保证树的高度较低,提高查找效率。这时候可以给出具体的代码示例,比如并查集的实现,包含路径压缩和按秩合并。 或者,举一个更通用的例子,比如合并两个链表,或者合并两个平衡二叉搜索树的时候,如何选择合并顺序以减少时间复杂度。例如,合并两个集合的时候,总是遍历较小的集合,将其元素逐个插入到较大的集合中,这样总的时间复杂度较低。 比如,用C++中的std::set来举例。假设有多个set,每次合并两个set的时候,总是将较小的set中的元素插入到较大的set中。这样可以保证每个元素被插入的次数不超过logN次,因为每次插入后所在的集合大小至少翻倍。这样总的时间复杂度是O(N logN logM),其中M是元素的大小?或者可能更优? 例如,代码可能如下: void merge(set<int> &a, set<int> &b) { if (a.size() > b.size()) { swap(a, b); } for (auto x : a) { b.insert(x); } a.clear(); } 这样每次合并的时候,总是将较小的集合合并到较大的里面。这样每个元素被插入的次数是logN次,每次插入的时间复杂度是O(logM),所以总的时间复杂度是O(N logN logM)。不过这可能适用于元素可排序的情况,比如使用平衡树结构的集合。 另一个例子是并查集的实现,其中按秩合并启发式合并的一种应用。例如: class DSU { vector<int> parent; vector<int> size; public: DSU(int n) { parent.resize(n); size.resize(n, 1); for (int i=0; i<n; ++i) parent[i] = i; } int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); return parent[x]; } void unite(int x, int y) { x = find(x); y = find(y); if (x == y) return; if (size[x] < size[y]) swap(x, y); parent[y] = x; size[x] += size[y]; } }; 这里,unite函数中总是将较小的集合合并到较大的集合里。这里的大小指的是集合的元素个数。这样可以保证树的高度较低,从而find操作的时间复杂度几乎是常数。 但用户可能想了解的不仅仅是并查集中的情况,而是更广泛的启发式合并应用。比如在处理某些数据结构时,比如链表、数组或者树结构的合并,如何利用启发式合并来优化。 比如,在解决某些问题时,比如动态连接问题,或者离线查询处理,可能需要多次合并不同的数据结构,这时候使用启发式合并可以显著减少时间。 另一个例子是,在树上处理子树问题的时候,比如每个节点维护一个颜色的集合,当处理子树时,将子节点的集合合并到父节点。这时候如果采用启发式合并,每次将较小的子树集合合并到较大的,可以降低总的时间复杂度。 例如,解决树上每个节点的颜色数量问题。每个节点有一个颜色集合,初始时只有自己的颜色。然后从叶子节点向上合并,每个父节点收集所有子节点的颜色集合。这时候,如果每次合并时将较小的集合合并到较大的集合,就能有效减少总操作次数。 这时候可以用C++中的unordered_set来举例: void dfs(int node, int parent, vector<unordered_set<int>> &colors) { for (auto child : children[node]) { if (child != parent) { dfs(child, node, colors); // 合并子节点的colors到当前节点 if (colors[node].size() < colors[child].size()) { swap(colors[node], colors[child]); // 保证node的集合较大 } for (int color : colors[child]) { colors[node].insert(color); } colors[child].clear(); // 可选,释放内存 } } } 这样,每个节点的合并操作都是将较小的集合合并到较大的,从而减少总的插入次数。每个颜色被插入的次数最多是logN次,所以总时间复杂度是O(N logN)。 总结一下,启发式合并的关键点在于每次合并时,总是将较小的结构合并到较大的结构中,这样每个元素被处理的次数减少,从而降低总的时间复杂度。应用场景包括并查集、树上的子树问题、集合合并等。代码示例可以根据不同的应用场景来展示,比如并查集、颜色集合的合并等。 现在需要考虑用户的学习阶段。用户可能在算法竞赛中遇到需要高效合并的问题,比如某些题目需要处理大量合并操作,而普通的暴力合并会导致超时。这时候启发式合并就能派上用场。例如,Codeforces或ACM竞赛中的某些题目,可能需要处理子树信息合并,这时候启发式合并可以优化时间复杂度。 可能用户的问题点在于理解为什么这样合并能优化时间,或者如何在实际问题中应用。因此,在讲解时,需要强调时间复杂度分析,以及具体例子中的操作步骤。 可能还需要比较暴力合并启发式合并的时间复杂度差异。例如,如果每次合并都是简单地将一个集合的所有元素插入另一个,那么最坏情况下,每次合并都是O(n)时间,总的时间复杂度可能是O()。而使用启发式合并后,总的时间复杂度降低到O(n logn)。 最后,C++的例子需要具体且易于理解。可能用户对STL比较熟悉,所以使用std::set或unordered_set的例子会更直观。或者使用并查集的例子,因为并查集是算法竞赛中的常见结构。 综上所述,我的回答结构应该是: 1. 定义启发式合并:将较小的集合合并到较大的集合,减少操作次数。 2. 解释原理:每个元素被移动的次数最多logN次,总复杂度O(N logN)。 3. 应用场景:并查集按秩合并、子树信息合并、集合合并等。 4. C++示例: a. 并查集的实现(按大小合并)。 b. 树上颜色统计问题的启发式合并。 5. 分析时间复杂度和优势。</think>好的!我将由浅入深为你讲解启发式合并(Heuristic Merge),并用 C++ 举例说明其实现。 --- ### **一、基础概念** **启发式合并** 是一种优化合并操作的策略,核心思想是:**每次合并两个集合时,总是将较小的集合合并到较大的集合中**。这样能减少重复操作次数,从而降低整体时间复杂度。 #### **为什么需要它?** 假设直接合并两个大小为 `n` 和 `m` 的集合(`n < m`),若将大的合并到小的,后续操作可能需要频繁处理更大的集合。而将小的合并到大的,后续操作会更高效。 --- ### **二、时间复杂度分析** - **普通合并**:最坏情况每次合并都需要 `O(n)` 时间,合并 `n` 次的总时间为 `O()`。 - **启发式合并**:每个元素最多被合并 `O(log n)` 次(因为每次合并后集合大小至少翻倍),总时间优化为 `O(n log n)`。 --- ### **三、应用场景** 1. **并查集(按秩合并)** 2. **树上的子树信息合并**(如统计子树颜色) 3. **集合合并问题**(如动态维护多个集合) --- ### **四、C++ 代码示例** #### **1. 并查集的启发式合并(按大小合并)** ```cpp class DSU { vector<int> parent; vector<int> size; public: DSU(int n) { parent.resize(n); size.resize(n, 1); for (int i = 0; i < n; i++) parent[i] = i; } int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩 return parent[x]; } void unite(int x, int y) { x = find(x), y = find(y); if (x == y) return; // 启发式合并:小集合合并到大集合 if (size[x] < size[y]) swap(x, y); parent[y] = x; size[x] += size[y]; } }; ``` #### **2. 树上统计子树颜色(启发式合并应用)** 假设树节点有颜色,求每个节点的子树中颜色种类数: ```cpp #include <vector> #include <unordered_set> using namespace std; void dfs(int u, int parent, vector<vector<int>>& tree, vector<unordered_set<int>>& subtreeColors, vector<int>& color) { subtreeColors[u].insert(color[u]); // 当前节点的颜色 for (int v : tree[u]) { if (v == parent) continue; dfs(v, u, tree, subtreeColors, color); // 启发式合并:将小子树合并到大子树 if (subtreeColors[u].size() < subtreeColors[v].size()) { swap(subtreeColors[u], subtreeColors[v]); } // 合并子树颜色 for (int c : subtreeColors[v]) { subtreeColors[u].insert(c); } subtreeColors[v].clear(); // 可选,释放内存 } // 此时 subtreeColors[u] 包含子树所有颜色 } int main() { int n = 100; // 节点数 vector<vector<int>> tree(n); vector<int> color(n); vector<unordered_set<int>> subtreeColors(n); // 假设 tree 和 color 已初始化 dfs(0, -1, tree, subtreeColors, color); return 0; } ``` --- ### **五、关键点总结** 1. **合并方向**:总是小集合合并到大集合。 2. **复杂度保证**:每个元素最多被合并 `O(log n)` 次。 3. **实现技巧**:使用 `swap` 确保操作对象始终是小集合。 4. **适用问题**:需要频繁合并的场景(如并查集、树上统计)。 --- ### **六、习题推荐** 1. [Codeforces 600E - Lomsat gelral](https://codeforces.com/problemset/problem/600/E)(经典启发式合并应用题) 2. 并查集相关题目(如动态连通性问题) 希望这些内容能帮助你掌握启发式合并!如果有更多问题,欢迎继续提问~ 😊
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值