[学习笔记]换根 DP 的常用处理方式

[学习笔记]换根 DP 的常用处理方式

换根 DP,又称作二次扫描法,通常用于“对每个根求出树形 DP 的答案”。以每个点作为根节点进行一次树形 DP 的代价通常无法承受,因此我们会使用两次 DFS:

  • 第一次 DFS 指定一个点为根节点,运行一次常规的树形 DP。
  • 第二遍 DFS 进行换根 DP,得到将根转移到每个节点的答案。

换根 DP 常用的例题是一些具有特别良好性质的问题,例如求所有节点的深度总和。当根节点从 \(u\) 转移到 \(v\) 时,所有在 \(v\) 子树中的节点(以 \(0\) 为根/以 \(u\) 为根)深度减一,而其余节点深度加一。在第一遍 DFS 中处理出子树大小,在第二遍 DFS 中即可直接用 \(ans_v = ans_u + n - sz_v - sz_v = ans_u + n - 2 sz_v\) 更新答案,甚至没有用到第一遍树形 DP 的“子树节点深度和”信息。

但对于一般的问题,我们并不总能如此简单地更新答案。例如求最深节点的深度,不另外使用数据结构,无法快速地完成子树深度加减、全局深度最大值这两个操作(这个例子举的并不好,但总之我想说加减法是性质很优的操作)。

算法步骤

一般地,在第二次 DFS 搜索到 \(u\) 时,首先更新以 \(u\) 为根的答案;之后枚举 \(u\) 的子节点 \(v\),要将根从 \(u\) 转移到 \(v\),我们分为以下几步:

  1. \(v\) 的贡献从 \(dp_u\) 移除;
  2. \(u\) 作为 \(v\) 的子节点,将贡献加入到 \(dp_v\)
  3. \(v\) 为新的根节点继续进行第二次扫描。
  4. 回滚 \(dp_u\) 的状态。

注意第三步结束后,我们需要把 \(dp_u\) 的状态恢复到第一步之前,因为还要对其它的儿子重复这一个过程。而在 \(v\) 的(以 \(0\) 为根/以 \(u\) 为根)子树上的节点均已完成所有计算,它们的状态并不重要。说到回滚,就自然有多种方式。最简单地,若 Copy 的代价可以承担(例如要维护状态所占空间为 \(O(1)\) 时),我们可以每次为 \(dp_u\) 创建一个新的副本,在其上执行前两步操作。

让我们回到所有节点深度总和的问题,有 \(dp_u = 1 +\sum_{v \in sons(u)} (sz_v + dp_v), sz_u = 1 +\sum_{v \in sons(u)} sz_v\)。换根时我们依次执行上面的步骤,并使用“创建副本”的方法而避免回滚。

  1. 创建新的副本,在其中去掉 \(v\) 的贡献:
    • \(dp_u' = dp_u - sz_v - dp_v, sz_u' = sz_u - sz_v = n - sz_v\)
  2. \(u\) 作为 \(v\) 的子节点,将贡献加入到 \(dp_v\)
    • \(dp_v \leftarrow dp_v + sz_u' + dp_u' = dp_u + n - 2sz_v, sz_v \leftarrow sz_v + sz_u' = n\)

虽然推导步骤不同,我们得到了相同的式子。

但复制的代价并不总是能够承担。例如有的时候我们被迫(或为了方便)需要使用一个数据结构(如 STL set/ multiset)维护所有子树的信息以进行 DP 转移,此时就需要采用另外的回滚方法。例如:

  • 可以记录所有操作,并逐个进行逆操作。它可以是各种层面上的逆操作。
    • 如果所有宏观上的操作都是可逆的,可以倒序执行一遍所有操作的逆操作。例如 multisetinsertextract;以及更高层面的,题目中具体使用的操作。
      • 甚至更暴力地,对每一次内存写入,记录位置以及写入前的值;回滚时逆序撤销。
    • 尽管 \(dp_v\) 已经不再需要用到,但出于正确回滚 \(dp_u\) 的需要,\(dp_v\) 很可能会被一并回滚。
  • 还可以采用可持久化等方式。

例如 CF1796E 题,为了方便使用 multiset 维护最小值、次小值。建立宏观上互为逆操作的的添加/删除贡献的函数 adddel,以恰当的顺序和参数调用,以确保贡献的加入/移除、状态的回滚都能够正确完成。参考 这份题解 的相关代码:

void add(int u, int val) {
    if (f[u].size() >= 2) se.erase(se.find(*next(f[u].begin())));
    f[u].insert(val);
    if (f[u].size() >= 2) se.insert(*next(f[u].begin()));
}

void del(int u, int val) {
    if (f[u].size() >= 2) se.erase(se.find(*next(f[u].begin())));
    f[u].erase(f[u].find(val));
    if (f[u].size() >= 2) se.insert(*next(f[u].begin()));
}

void dfs2(int u, int fa) {
    ans = max(ans, min(getlen(u), se.empty() ? INF : *se.begin()));
    for (auto v : G[u]) {
        if (v == fa) continue;
        del(u, getlen(v));
        add(v, getlen(u));
        dfs2(v, u);
        del(v, getlen(u));
        add(u, getlen(v));
    }
}

有关贡献的移除

第一步操作“把 \(v\) 的贡献从 \(dp_u\) 中移除”需要单独拿出来讲讲。一般情况下贡献的移除不像回滚这么简单。这是因为我们相当于要计算如下问题:

  • \(u\) 的每个儿子 \(v\),计算 \(fa_u\)(若存在)以及 \(u\)\(v\) 外的所有儿子的(以 \(u\) 为根)子树贡献的聚合。

对加减乘除等存在逆元的聚合操作,移除贡献特别容易。但一般情形下,移除一个特定节点的贡献比回滚最后一次操作要难得多。

回到最深节点深度问题,有 \(dp_u = \max\limits_{v \in sons(u)} \{1 +dp_v\}\)。第一眼看上去 \(\max\) 操作是不可逆的,因为它始终只保存当前最大的贡献,会丢失信息。但注意到我们只需要移除一个节点,这是个很重要的性质:我们只要在更新 \(dp_u\) 的同时记录次大值 \(sdp_u\),按照 \(dp_v\) 是否是最大值选择 \(dp_u'\)。按照上面的步骤,算法如下:

  • \(dp_u = dp_v + 1\)\(dp_u' = sdp_u\);否则 \(dp_u' = dp_u\)
  • 根据 \(dp_u' + 1\) 更新 \(dp_v\)\(sdp_v\)

同样我们可以使用复制副本以快速回滚。类似的,我们可以 \(O(1)\) 处理最小值、次小值等操作的贡献移除。

对更特殊的聚合操作,如 \(\gcd\)\({\rm lcm}\)、对非质数的模乘法,这个性质也帮不上大忙。所幸我们有熟悉的小技巧:前后缀分解(我们总认为聚合操作是满足交换律的。若不满足,也不代表一定不能使用这一技巧),在状态的大小、聚合的时间均为 \(O(T)\) 时,总能保证换根 DP 的总时间复杂度为 \(O(nT)\)

使用前后缀分解的换根 DP 的框架如下(并不是很好写):

struct Info {
    int cnt = 0;
    i64 dep_sum = 0;
    void setRoot(int u) { cnt++; }
    void trans(i64 w) { dep_sum += cnt * w; }
};

Info setRoot(int u, Info a) {
    a.setRoot(u);
    return a;
}

Info trans(Info a, i64 w) {
    a.trans(w);
    return a;
}

Info merge(Info a, Info b) {
    Info res;
    res.cnt = a.cnt + b.cnt;
    res.dep_sum = a.dep_sum + b.dep_sum;
    return res;
}

int main() {
	vector<Info> dp(n);
    vector<vector<pair<int, i64>>> sons(n);
    i64 ans = 0; int ansu = -1;
    function<void(int, int)> dfs0 = [&](int u, int f) {
        for (auto [v, w] : adj[u]) {
            if (v == f) continue;
            sons[u].emplace_back(v, w);
            dfs0(v, u);
            dp[u] = merge(dp[u], trans(dp[v], w));
        }
        dp[u] = setRoot(u, dp[u]);
    };
    dfs0(0, -1);
    // update ans using dp[0]
	updateAns(dp[0]);
    function<void(int, Info)> reroot = [&](int u, Info from_fa) {
        int m = sons[u].size();
        vector<Info> pre(m), suf(m);
        for (int i = 0; i < m; i++) {
            auto [v, w] = sons[u][i];
            pre[i] = suf[i] = trans(dp[v], w);
            if (i > 0) pre[i] = merge(pre[i], pre[i - 1]);
        }
        for (int i = m - 2; i >= 0; i--) suf[i] = merge(suf[i], suf[i + 1]);
        if (u != 0) {
            Info res = m > 0 ? suf[0] : Info();
            res = merge(res, from_fa);
            res.setRoot(u);
            // update ans using merged info
			updateAns(res);
        }
        for (int i = 0; i < m; i++) {
            auto [v, w] = sons[u][i];
            Info from_u = from_fa;
            if (i > 0) from_u = merge(from_u, pre[i - 1]);
            if (i + 1 < m) from_u = merge(from_u, suf[i + 1]);
            from_u.setRoot(u);
            reroot(v, trans(from_u, w));
        }
    };
    reroot(0, Info());
}

这个技术在成都帮了我大忙,所以过来更新一下这个 blog。

posted @ 2024-02-15 22:34  cccpchenpi  阅读(294)  评论(0编辑  收藏  举报