算法学习——dsu on tree

简介

dsu on tree也叫树上启发式合并,和启发式合并类似,其时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),核心思想也是小的集合并到大的集合中去。在学习dsu on tree之前,我们先简单回顾一下启发式合并是什么

启发式合并

启发式合并其实是很暴力的一个思想。

先只看看合并,合并就是将两个集合 A , B A,B A,B合并成一个集合 C C C

  1. 首先有一个操作是新建一个空集合,然后将集合 A , B A,B A,B的每一个元素都放进去,这个时间复杂度是遍历集合 A , B A,B A,B的每一个元素,也就是 s i z e ( A + B ) size(A+B) size(A+B)

  2. 另一个操作就是我们在集合 A , B A,B A,B中选一个集合,然后把这个集合的所有元素全部都放进另一个集合,这样另一个集合就成了集合 C C C,这个的时间复杂度就是两个集合间的其中一个的集合大小。

显然后者的时间复杂度是一定小于等于前一个操作的时间复杂度的。

那么启发式合并是什么?

在第二个操作中,时间复杂度是两个集合间的其中一个的集合大小,为了尽可能的节省时间,我们一定是选集合大小比较小的那个集合(设为集合 X X X, 另一个设为集合 Y Y Y),然后遍历集合 X X X,把每一个元素都扔进集合 Y Y Y中,这样集合 Y Y Y就成为了我们的目标——集合 C C C。而这个时间复杂度,是 O ( n l o g n ) O(nlogn) O(nlogn)

虽然是个很暴力的思想,但是时间复杂度却是 O ( n l o g n ) O(nlogn) O(nlogn),这里看起来有点反直觉,所以我们可以证明一下:

  • 由于每次合并集合都是小的集合合并到大的集合中去,所以每次合并,合并后集合的大小至少是原本小的集合的两倍,所以对于某一个集合中的某一个元素,最多被合并 l o g 2 n log_2n log2n次,那么 n n n个元素被合并的时间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn)

E. XOR Tree

题意

给你一棵由 n n n个顶点组成的树。每个顶点上都写有一个数字;顶点 i i i上的数等于 a i a_i ai

回想一下,简单路径是最多访问每个顶点一次的路径。令路径的权重为其所包含的顶点上写入的值的按位异或。假设如果没有简单路径具有权重,那么这棵树就是好树 0 0 0

您可以多次应用以下操作(可能为零):选择树的一个顶点,并将其上写入的值替换为任意正整数。为了使树变得良好,您必须应用此操作的最少次数是多少?

n ≤ 2 e 5 n \le 2e^5 n2e5

思路

之前也写过一篇题解:https://zhuanlan.zhihu.com/p/645890524

这里主要讲怎么想到使用启发式合并,详细题解请见上文链接。

由于我们可以使得修改一个子树的根使得整个子树的任何一个节点为端点,在整棵树中的一条简单路径异或值为0,所以我们可以将一颗子树看作一个集合。

u u u是树上的某一个节点, u u u v , w v,w v,w两个儿子,显然集合 u u u就是节点 u u u并上以 v v v为根的子树集合再并上以 w w w为根的子树集合。我们可以在合并的过程中判断是否需要修改 u u u。这里涉及到了集合的合并操作,所以我们可以联想到启发式合并的时间复杂度是合法的,所以选用启发式合并解决这道题。

代码
int n, c[N], ans, d[N];
std::set<int> s[N];
std::vector<int> go[N];

void dfs(int u, int fa) {
   
   
    bool ok = false;
    s[u].insert(d[u]);
    for (auto v : go[u]) {
   
   
        if (v == fa) continue;
        d[v] = c[v] ^ d[u];
        dfs(v, u);
        if (s[u].size() < s[v].size()) std::swap(s[u], s[v]);
        for (auto w : s[v]) if (s[u].count(w ^ c[u])) ok = true;
        for (auto w : s[v]) s[u].insert(w);
    }
    if (ok) ans ++, s[u].clear();
}

void solve() {
   
   
    std::cin >> n;
    for (int i = 1; i <= n; i ++) std::cin >> c[i];
    for (int i = 1; i < n; i ++) {
   
   
        int u, v;
        std::cin >> u >> v;
        go[u].emplace_back(v);
        go[v].emplace_back(u);
    }

    d[1] = c[1];
    dfs(1, 0);

    std::cout << ans << "\n";
}

dsu on tree能做什么题

和启发式合并类似,也是要解决集合的合并问题,不过主要是解决树上集合合并问题。

dsu on tree可以解决能够离线查询的题目,关键是和有根树的子树相关的查询。类似于查询某一深度的特征,某一条简单路径的特征等等。

下面我会给出六道例题来帮助理解并掌握dsu on tree。

dsu on tree的步骤

当然时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),证明可以参考一下启发式合并的证明(其实差不多),这里懒得写了(

我们需要进行两遍dfs

第一遍dfs需要预处理出各种在第二遍dfs中要用到的信息——dfs序,子树大小,每个节点的重儿子是谁,dfs序的编号是属于哪个节点等。

第二遍dfs的时候就是要开始计算答案了,而这个dfs的算法是下述的三步骤。

  1. 对于一个根节点,先遍历轻儿子,计算出轻儿子的答案,但是不保留影响
  2. 然后遍历其重儿子,计算出答案,保留影响
  3. 再次遍历轻儿子,将这些点逐个加入到重儿子的集合中,然后再将根节点也加入到集合

解释一下概念:

  • 轻儿子/重儿子:在一颗树中,与一个点 u u u相连的点,除了父亲就是儿子(其中根节点没有父亲)。儿子节点为根节点的子树,以子树中的节点数量成为子树的重量,显然子树中节点数越多,子树的重量就越大,那么 u u u的儿子中,子树重量最大的儿子就成为重儿子,其余的成为轻儿子。分轻重儿子的目的也和启发式合并的原理一样,让轻儿子合并到重儿子中,这样可以保证时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)
  • 为什么先遍历轻儿子,并且第一遍遍历轻儿子时不保留影响:我们会维护一些表示重儿子集合信息的变量,然后使用这些记录答案。如果我们先遍历重儿子再遍历轻儿子的话,会将重儿子的信息也算进轻儿子中,换句话说,重儿子是一个以 u u u为节点的子树,轻儿子是一个以 v v v为节点的子树,如果先遍历重儿子的话,由于我们是询问的一颗子树上的信息,再遍历轻儿子时计算的就是轻儿子 v v v子树的信息+重儿子 u u u子树上的信息。这显然并符合我们想要的询问,所以我们在第一次遍历时不能保留影响,并且为了保证时间复杂度,所以要先遍历轻儿子。

我们可以结合dsu on tree的板子来看

void dfs(int u, int fa) {
    l[u] = ++ tot; // dfs序
    hs[u] = -1; // 重儿子是谁
    sz[u] = 1; // 子树大小
    id[tot] = u; // 根据dfs序找到节点
    for (auto v : go[u]) {
        if (v == fa) continue;
        dfs(v, u);
        sz[u] += sz[v]; 
        if (hs[u] == -1 || sz[v] > sz[hs[u]]) hs[u] = v;
        // 如果v是u的第一个儿子,或者子树v的重量大于之前u的儿子子树的重量,那么就将u的重儿子更新成v
    }
    r[u] = tot;
}

void dfs(int u, int fa, bool ok) {// 第三个参数代表是否保留对全局变量的影响
    for (auto v : go[u]) { // 先遍历轻儿子
        if (v == fa || v == hs[u]) continue;
        // 如果是父亲或者重儿子就跳过
        dfs(v, u, false);// 轻儿子不保留影响
    }

    if (hs[u] != -1) // 如果有重儿子就遍历
        dfs(hs[u], u, true);

    auto add = [&](int x) { // 填空题,根据题目需求进行填空
    };

    auto del = [&](int x) { // 同上
    };

    for (auto v : go[u]) { // 第二遍遍历轻儿子
        if (v == fa || v == hs[u]) continue;
        // 利用dfs序遍历轻儿子的所有节点,这样常数会比dfs遍历要小
        for (int w = l[v]; w <= r[v]; w ++)
            add(id[w]);// 将轻儿子中的节点加入到重儿子集合中去
    }
    add(u); // 把根节点加入到集合中

    if (!ok) { // 如果是轻儿子,就删除对全局变量的影响
        for (int w = l[u]; w <= r[u]; w ++)
            del(id[w]);
    }
}

另外值得一提的是,我们这里预处理了dfs序,在第二遍遍历轻儿子和删除影响时,我们都通过遍历dfs序的方式来遍历子树中的每个节点,这是由于可以不用再写一个dfs,而且常数会更小

1. E. Lomsat gelral

题意

给你一棵有根树,根在顶点1。每个顶点都涂有某种颜色。

如果在顶点v的子树中没有其他颜色出现的次数多于颜色,我们称颜色c在顶点v的子树中占主导地位c.因此,某个顶点的子树中可能有两种或多种颜色占主导地位。

顶点v的子树是顶点v,并且每个顶点中包含顶点v的所有其他顶点到根的路径。

对于每个顶点v,找到顶点v的子树中所有主颜色的总和。

思路

典中典的板子题,考虑合并时将每个节点加入到重儿子集合中的影响

首先要判断主导颜色有哪些,显然是颜色数量最大的一些颜色,和数量有关那么就维护一个表示数量的数组 c n t i cnt_i cnti在重儿子集合中,为颜色为 i i i的数量是多少。同时,我们还要知道当数量为 k k k是该子树中颜色的最大数量时,有哪些颜色的数量是 k k k,所以我们再维护一个数组 t o t j tot_j totj表示数量为 j j j的所有颜色的总和(当然也可以只用一个变量维护最大的数量的颜色总和也可以)。

假设现在加入了一个颜色为 k k k,并且集合中现在有 i i i个颜色为 k k k的节点,那么对于加入一些点对 c n t i cnt_i cnti t o t j tot_j totj的影响分别是
t o t c n t k − = 1 c n t k + = 1 t o t c n t k + = 1 tot_{cnt_k}-=1\\ cnt_k +=1\\ tot_{cnt_k}+=1 totcntk=1cntk+=1totcntk+=1
然后用一个变量 m x mx mx维护出现次数最大的颜色即可 m x = max ⁡ ( m x , c n t k ) mx = \max{(mx, cnt_k)} mx=max(mx,cntk)

最后合并完一颗子树后,维护该子树的答案 a n s u = t o t m x ans_u = tot_{mx} ansu=totmx

代码

int n, c[N];
i64 ans[N], cnt[N], mx, sum;
std::vector<int> go[N];
int l[N], r[N], tot, id[N], hs[N], sz[N];
 
void dfs(int u, int fa) {
   
   
    l[u] = ++ tot;
    hs[u] = -1;
    sz[u] = 1;
    id[tot] =
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值