UOJ Logo computerkiller的博客

博客

浅谈树上的两个小trick

2020-09-25 14:35:22 By computerkiller

这份Blog将会包括两部分 长链剖分和dsu on tree

我们先讲一些前置知识 $\color{red}{轻重链剖分}$吧

我们一般认为的树链剖分默认为轻重链剖分 主要的思想在于让重链上的dfn序连续 从而将树上的问题转化成几个区间的问题

考虑如何做到这件事情:

在第一次dfs的时候 我们便要去处理出来重儿子 方便第二次去进行剖分 这件事情很$naive$ 就是在dfs的过程中去记录下重儿子的序号

void dfs1(int u,int f)
{
    dep[u] = dep[f] + 1;
    fa[u] = f;
    son[u] = -1;
    siz[u] = 1;
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == f) continue;
        dfs1(v);
        siz[u] += siz[v];
        if (son[u] == -1 || siz[v] > siz[son[u]]) son[u] = v;
    }
}

在第二次dfs的时候 优先遍历重儿子 再遍历轻儿子 这样就可以保证重链上的dfn序连续

void dfs2(int u,int tp)
{
    top[u] = tp;
    dfn[u] = ++cnt;
    rnk[cnt] = u;
    if (son[u] == -1) return ;
    dfs2(son[u],tp);
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == fa[u] || v == son[u]) continue;
        dfs2(v,v);
    }
}

很明显这样剖分完了之后 我们做到了重链上dfn序连续 并且注意到 我们这样剖分完会产生$logn$条链 那么我们来看一下树剖之后的小常数求lca

int query(int x,int y)
{
    while (top[x] != top[y])
    {
        if (dep[top[x]] < dep[top[y]]) swap(x,y);
        x = fa[top[x]];
    }
    if (dep[x] > dep[y]) swap(x,y);
    return x;
}

这样子寻找lca的常数很小倍增留下了泪水

dsu on tree

到这里 我们花了60+行解释了树剖 终于引出第一个主题 dsu on tree

dsu on tree基于的是树剖的思想 让重儿子的贡献直接继承到父亲上 而轻儿子的考虑暴力合并

无论你是logn - logs的推式子大师 还是每次操作后规模减半的毛估估大师 都能发现这个的复杂度是nlogn的 在处理子树问题时 吊打了树上莫队

我们看一下这样一个代码 我们以CF600E为例题 分析一下算法的流程:

void dfs(int u)
{
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == fa[u] || v == son[u]) continue;
        dfs(v);
    }
    if (son[u] != -1) dfs(son[u]);
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == fa[u] || v == son[u]) continue;
        dsu(v,1);
    }
    tmp[t[col[u]]] -= col[u];
    t[col[u]]++;
    tmp[t[col[u]]] += col[u];
    if (tmp[maxx + 1]) maxx++;
    if (!tmp[maxx]) maxx--;
    ans[u] += tmp[maxx];
    if (u == top[u]) dsu(u,-1);
}

这个是在树链剖分之后的一个计算答案的dfs 其中的top fa son这些数组和在树链剖分中的同义

在这个过程中 不难发现的是 我们后遍历了重儿子 这是和树剖不同的地方

原因很简单 我们遍历轻儿子是为了计算轻儿子的答案 而我们的重儿子是直接继承的 所以先遍历的话会影响到轻儿子的答案的计算

于是我们有了这样的一个算法流程:

1.计算轻儿子的答案

2.遍历重儿子继承重儿子的答案

3.将轻儿子的贡献暴力计算

4.加上自己的贡献 计算自己的答案

5.如果自己是某条重链的头 那么自己必然是某个结点的轻儿子了 此时我们需要消除我们这棵子树的贡献

上面代码中的dsu函数是对于子树遍历的 我给出我的计算方式:

void dsu(int u,int k)
{
    tmp[t[col[u]]] -= col[u];
    t[col[u]] += k;
    tmp[t[col[u]]] += col[u];
    if (tmp[maxx + 1]) maxx++;
    if (!tmp[maxx]) maxx--;
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == fa[u]) continue;
        dsu(v,k);
    }
}

这样dsu on tree的算法部分就讲完了

dsu on tree是一个对于子树问题统计的小trick 很少会作为标算使用 但是也有 我这里列举几道可以使用的题目 按照难度排序

CF600E

CF375D

CF570D

CF741D

天天爱跑步

强烈推荐用dsu on tree写一下天天爱跑步 写出来的基本都熟练了(

长链剖分

长链剖分相对而言较冷门 但是在优化有深度信息的dp时 是一个可以考虑的trick

长链剖分是将轻重链剖分的siz计算换成了len的计算 及最长的树链长度 看dfs1的代码可能会清晰一点:

void dfs1(int u,int f)
{
    son[u] = -1;
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == f) continue;
        fa[v] = u;
        dfs1(v,u);
        len[u] = max(len[u],len[v] + 1);
        if (son[u] == -1 || len[v] > len[son[u]]) son[u] = v;
    }
}

此处的len表示的是某结点的子树中最深的结点到达该结点的距离 长链剖分这个trick真的很冷门 这里推荐一道模板题吧CF1009F

这题中很明显存在一个很trivial的dp方程:

我们用dp[i][j]表示i的子树中距离i为j的结点个数 那么显然有这样的转移:

$$dp[i][j]=\sum\limits_{k\in son_i}dp[k][j-1]$$

有这个转移方程 我们有了一个$O(n^2)$的做法 但是看到数据范围 只能绝望地思考优化

考虑用长链剖分来进行转移 大致的思想是直接继承长儿子的状态 然后暴力合并短儿子的状态 这点上和dsu on tree十分相似 但是dsu on tree基于的是重链剖分

在继承长儿子的状态的时候 我们必须要做到$O(1)$ 不然复杂度依然爆炸 总体上有2种方法:

1.使用指针

2.使用vector的O(1)swap

由于我很不喜欢指针 所以一直用的是vector 我这里给出这题的dfs2作参考:

void dfs2(int u,int tp)
{
    top[u] = tp;
    dfn[u] = ++cnt;
    rnk[cnt] = u;
    if (son[u] == -1)
    {
        ans[u] = 0;
        dp[u].push_back(1);
        return ;
    }
    dfs2(son[u],tp);
    swap(dp[u],dp[son[u]]);
    dp[u].push_back(1);
    ans[u] = ans[son[u]];
    if (dp[u][ans[u]] == 1) ans[u] = len[u];
    for (int i = head[u]; i != -1; i = nxt[i])
    {
        int v = pnt[i];
        if (v == fa[u] || v == son[u]) continue;
        dfs2(v,v);
        for (int k = len[v]; k >= 0; k--)
        {
            int tmp = k + len[u] - len[v] - 1;
            dp[u][tmp] += dp[v][k];
            if (dp[u][tmp] > dp[u][ans[u]] || (dp[u][tmp] == dp[u][ans[u]] && tmp > ans[u]))
            {
                ans[u] = tmp;
            }
        }
    }
}

做完了这题之后 建议去做一下湖南集训 谈笑风生 也算是为数不多的可以找到的长链剖分的练习题了吧

结语

无论是长链剖分还是dsu on tree 本质上是直接继承某个儿子的信息然后暴力合并其他的 仍然是一种十分优雅的暴力trick

dsu on tree十分好写 基本上变化的只有一个dsu函数 而长链剖分的实现其实是存在一定的难度的 湖南集训那题我写了超久/kk

考虑树上的深度 或者子树问题时 没有思路的时候大可思考一下这两个trick 会有很多启发的

完结撒花

评论

OldDriverTree
哪里“吊打了树上莫队”,普遍认为的树上莫队是处理链的,又不是子树
immortalCO
这个算法再多加一点就是动态 DP 了嘿嘿嘿……(https://immortalco.blog.uoj.ac/blog/2625

发表评论

可以用@mike来提到mike这个用户,mike会被高亮显示。如果你真的想打“@”这个字符,请用“@@”。