算法学习笔记(19): 树上启发式合并(DSU on tree)

算法,学习,笔记,树上,启发式,合并,dsu,on,tree · 浏览次数 : 139

小编点评

**生成内容时需要带简单的排版** **1. 排版优化** * 使用 **优先队列** 或 **堆** 来优化 **路径搜索**。 * **排序** 存储的 **信息**,如 **最大异或对** 和 **路径长度**。 * 使用 ****排版** 技术来优化 **字符串** 的搜索。 **2. 排版技巧** * ****压缩**** 数据,如 **字符串** 或 **数组**。 * 使用 ****缓存**** 技术来优化 **搜索**和 **路径搜索**。 * ****优化**** 数据结构的 **访问路径**。 **3. 排版工具** * ****优先队列****: **```cpp** priority_queue **``` * ****堆****: **```cpp** heap **``` * ****排序**: **```cpp** sort(int, int, int) **``` * ****排版**: **```cpp** **sort** (string, string, string) **``` **4. 排版技巧** * 使用 ****排序** 和 ****压缩**** 技术来优化 **字符串** 的搜索。 * 使用 ****优先队列** 或 ****堆**** 来优化 **路径搜索**。 * ****优化**** 数据结构的 **访问路径**。

正文

树上启发式合并

DSU on tree,我也不知道DSU是啥意思

这是一种看似特别玄学的优化

可以把树上部分问题由 \(O(n^2)\) 优化到 \(O(n \log n)\)

例如 CodeForces 600E

又例如一道神奇的题:

适用情况

可以离线部分树上问题。

需要子树上的所有信息,但是信息无法快速合并的情况。

或者说可以使用树上莫队的题目一般都可以使用启发式合并?(至少OI-Wiki是这么说的)

树上启发式合并并不是很常用

合并思路

首先定义一点概念:

  • 重子节点:一个结点的子结点中度最大的结点(孩子个数最多的点)
  • 轻子节点:非重子节点的节点
  • 重边:重子结点(如果有)与其父结点相连的边
  • 轻边:非重边
  • 重链:相邻重边链接形成的链(这里好像用不到)

树上启发式合并的思路如下:

  • 处理 x 为根的情况

  • 递归处理 轻子节点 的答案,并舍弃其信息

  • 处理 重子节点 的答案,并保留其信息

  • 如果 x 为(其父亲的)重子节点,则加上所有 轻子节点 的信息。否则舍弃其信息

很明显,我们需要预处理出重子节点。同时可能需要用到 dfn 序来优化信息的加入

不妨我们以 vector 存图(非常方便)

void workSon(int x, int f) {
    siz[x] = 1, fa[x] = f;
    dfn[x] = ++cdfn, rdfn[cdfn] = x;
    for (int y : G[x]) {
        if (dfn[y]) continue;
        workSon(y, x);
        siz[x] += siz[y];
        if (siz[son[x]] < siz[y]) son[x] = y;
    }
    edfn[x] = cdfn;
}

最终 son[x] 就是 x 的重子节点,同时我们处理出了 dfn 序以及以 x 为子树的 dfn 序范围:[dfn[x], edfn[x]] 注意为闭区间

那么伪代码大致如下:

// remain 用于获知需不需要保留数据,默认不保留
int currentAnswer;
void work(int x, bool remain = false) {
    for (int y : G[x]) {
        if (y != fa[x] && y != son[x]) work(y);
    }

    if (son[x]) work(son[x], true);

    for (int y : G[x]) {
        if (y == fa[x] || y == son[x]) continue;
        addSubTreeInfoAndUpdateAnswer(y);
    }

    answerOf[x] = currentAnswer;
    if (!remain) {
        clearAnswer();
        for (int y : G[x]) {
            if (y != fa[x]) removeSubTreeInfo(y);
        }
    }
}

每个部分的作用在函数名里面应该很清晰了。这里不再赘述。

复杂度证明

首先,根节点到树上任意节点的轻边数不超过 \(\log n\) 条。

只有当祖先节点为轻子节点时其信息会被删除。也就是加入 \(O(\log n)\) 次,删除 \(O(\log n)\) 次,故而每一个点的复杂度为 \(O(\log n)\),整体的复杂度为 \(O(n \log n)\)

当然,考虑如果每一个节点加入信息或者删除信息的复杂度为 \(O(k)\),则整体复杂度为 \(O(n k \log n)\)

非常玄学……但是就是能够优化

例题分析

就以开始为例子的两道题为例吧。

CodeForces 600E

这道题也就是树上数颜色的问题。

题目大意是:

对于每一个节点,做出贡献的颜色需要满足出现的次数是最多的之一。一个颜色的贡献即是这个颜色的编号。

最终输出每一个节点被贡献的结果

样例输入
4
1 2 3 4
1 2
2 3
2 4
样例输出
10 9 3 4

最主要也是最重要的就是颜色的统计。

加入点(颜色)的核心代码如下:

int colorBut[N];
long long maxExi = 0, cnt = 0;
void add(int c) {
    if (++colorBut[c] == maxExi) {
        cnt += c;
    } else if (colorBut[c] > maxExi) {
        maxExi = colorBut[c], cnt = c;
    }
}

在合并部分也很清晰了

long long res[N];
void dsu(int x, int f, bool remain = false) {
    for (int y : G[x]) {
        if (y != f && y != son[x]) dsu(y, x);
    }

    if (son[x]) dsu(son[x], x, true);

    // 记得把根节点的信息也加进去!
    add(col[x]);
    for (int y : G[x]) {
        if (y == f || y == son[x]) continue;
        // 添加信息
        for (int i = dfn[y]; i <= edfn[y]; ++i) add(col[rdfn[i]]); 
    }

    res[x] = cnt;
    if (!remain) {
        maxExi = cnt = 0; // 重置答案
        for (int i = dfn[x]; i <= edfn[x]; ++i) // 删除影响信息
            colorBut[col[rdfn[i]]] = 0;
    }
}

不正常国家

这道题稍微复杂一点。

考虑每对点对其 LCA 的贡献。或者换个思路,考虑每一个根节点能够被那些点贡献。

不难发现,有两种情况:

  • LCA 和其子节点之间的路径

  • LCA 的两个子节点之间的路径。这里要保证两个子节点在不同的子树里面

如果我们已经预处理出了树上异或前缀和 path。那么任意两个点对其 LCA 的贡献为 path[x] ^ path[y] ^ val[LCA]。我们不妨对于每一个 LCA,枚举所有 path[y] ^ val[LCA],同时在已知的 path[x] 中匹配最大的异或对。

最大异或对可以看此题:AcWing 143.最大异或对

利用了01Trie树和二进制贪心。

此处不展开。

同时,由于我们需要保证 xyu 的不同子树中,所以我们先查询完一颗子树再加入这棵子树的信息。

核心代码如下:

// 树上启发式合并 
void work(int x, int f, bool remain = false) {
    // 首先搞定所有非重子节点
    for (int y : G[x]) {
        if (y == f || y == son[x]) continue;
        work(y, x);
    }

    // 搞定重子节点,并保留数据 
    if (son[x]) work(son[x], x, true);
    // path[fa[x]] 也就是 path[x] ^ val[x]
    int ans = max(val[x], trie.pairMax(path[fa[x]]));
    trie.insert(path[x]);

    // 加入其他节点,并搜索
    for (int y : G[x]) {
        if (y == f || y == son[x]) continue;
        for (int j = dfn[y]; j <= edfn[y]; ++j) {
            int pa = path[rdfn[j]] ^ val[x];
            ans = max(ans, trie.pairMax(pa));
        }

        for (int j = dfn[y]; j <= edfn[y]; ++j) {
            trie.insert(path[rdfn[j]]);
        }
    }

    res[x] = ans;
    if (!remain) trie.clear();
}

至于 01Trie 树代码如下:

const int LOG = 30; // 31位!下标为 [0, 30]

#define bit(x, i) ((x >> i) & 1)
class Trie01 {
private:
    int ch[N << 4][2];
    int usage;
public:
    Trie01() : usage(1) {
    }

    inline int newNode() {
        ++usage;
        ch[usage][0] = ch[usage][1] = 0;
        return usage; 
    }

    void insert(int x) {
        int p = 1;
        for (int k = LOG; k >= 0; --k) {
            int s = bit(x, k);
            if (!ch[p][s]) ch[p][s] = newNode();
            p = ch[p][s];
        }
    }

    // 这是通过树的形状贪心寻找最大异或对
    int pairMax(int x) {
        int p = 1;
        int r = 0;
        for (int k = LOG; k >= 0; --k) {
            int s = bit(x, k) ^ 1;
            if (ch[p][s]) r = (r << 1) | 1, p = ch[p][s];
            else if (ch[p][s ^ 1]) r <<= 1, p = ch[p][s ^ 1];
            else p = 0, r = x; // 避免空树的情况
        }
        return r;
    }

    void clear() {
        usage = 1;
        ch[1][0] = ch[1][1] = 0;
    }
} trie;

那么这道 水题 也就这么水过去了。

忘了说,其复杂度为 \(O(n \log n L)\),其中 \(L\) 是位长,也就是代码中的 LOG = 30。所以复杂度也可以写为 \(O(n \log^2 n)\)


树上启发式合并的潜力不止于此,还望诸君发掘。

与算法学习笔记(19): 树上启发式合并(DSU on tree)相似的内容:

算法学习笔记(19): 树上启发式合并(DSU on tree)

树上启发式合并详解。 例题:CodeForces 600E 和一道我们考试的题

算法学习笔记(6): 树链剖分

树链剖分 树链剖分是一个很神奇,但是在树上可以完成一些区间操作问题 简单来说,就是把一棵树分成一条条的链,通过维护链上的信息来维护整棵树的信息 基础知识可以参考我的另外一篇博客:算法学习笔记(5): 最近公共祖先(LCA) 这里假设你已经掌握了上述博客中的所有相关知识,并清晰了其背后的原理 性质?发

算法学习笔记(11): 原根

原根 此文相对困难,请读者酌情食用 在定义原根之前,我们先定义其他的一点东西 阶 通俗一点来说,对于 $a$ 在模 $p$ 意义下的阶就是 $a^x \equiv 1 \pmod p$ 的最小正整数解 $x$ 或者说,$a$ 在模 $p$ 意义下生成子群的阶(群的大小) 再或者说,是 $a$ 在模

算法学习笔记(30):Kruskal 重构树

Kruskal 重构树 这是一种用于处理与最大/最小边权相关的一个数据结构。 其与 kruskal 做最小生成树的过程是类似的,我们考虑其过程: 按边权排序,利用并查集维护连通性,进行合并。 如果我们在合并时,新建一个节点,其权值为当前处理的边的权值,并将合并的两个节点都连向新建的节点,那么就可以得

算法学习笔记(3.1): ST算法

ST表 在RMQ(区间最值)问题中,著名的ST算法就是倍增的产物。ST算法可以在 \(O(n \log n)\) 的时间复杂度能预处理后,以 \(O(1)\) 的复杂度在线回答区间 [l, r] 内的最值。 当然,ST表不支持动态修改,如果需要动态修改,线段树是一种良好的解决方案,是 \(O(n)\

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

C++算法之旅、08 基础篇 | 质数、约数

算法学习笔记,记录容易忘记的知识点和难题。试除法、分解质因数、筛质数、约数个数、约数之和、最大公约数

算法学习笔记(1): 欧几里得算法及其扩展

扩展欧几里得算法详解 在了解扩欧之前我们应该先了解欧几里得算法 欧几里得算法 这是一个递归求最大公约数(greatest common divisor)的方法 $$ gcd(a, b) = gcd(b, a % b) $$ 可以通过一个类似的简单公式推导而来 好像叫做辗转相减法来着? $$ gcd(

算法学习笔记(3): 倍增与ST算法

倍增 目录倍增查找 洛谷P2249重点变式练习快速幂ST表扩展 - 运算扩展 - 区间变式答案倍增更多的用法优化矩形查询优化建图优化 DP作者有话说 倍增,字面意思即”成倍增长“ 他与二分十分类似,都是基于”2“的划分思想 那么具体是怎么样,我们以一个例子来看 查找 洛谷P2249 依据题面,我们知

算法学习笔记(4): 并查集及其优化

# 并查集 并查集,Disjoint-Set,或者通俗一点,叫做MergeFind-Set,是一种可以动态维护若干个不重叠的集合,并支持集合之间的合并与查询的数据结构。 集体来说,并查集支持下列两个操作: - Find,查询元素所属集合 - Merge,将两个元素所属集合合并 一般来说,为了具体实现