线段树合并优化 DP 简介
推歌
《迂回步》
作者:雨狸,星辉 p
前置知识
既然是线段树合并优化 DP 的简介,那就一定要会线段树合并。
什么是线段树合并呢?我们先对权值线段树进行考虑。权值线段树维护的是这样一个数组 \(c_i\),这个 \(c_i\) 可以唯一的确定一个有限可重集 \(S\subset\N\),使得 \(\forall i\in[0,n]\),有 \(c_i\) 个 \(i\) 在 \(S\) 中(\(n\) 为值域上界)。下文中,我们称这个可重集为该权值线段树的对应集合。假设我们有两棵权值线段树 \(A\) 和 \(B\),它们维护的数组分别是 \(\{b_i|i\in[0,n]\cap\N\}\) 和 \(\{c_i|i\in[0,n]\cap\N\}\),对应集合分别为 \(S\) 和 \(T\)。那么显然,我们将 \(S\) 和 \(T\) 合并之后得到的结果记作 \(s\),记对应集合为 \(s\) 的权值线段树维护的数组为 \(\{d_i|i\in[0,n]\cap\N\}\),则有:\(\forall i\in[0,n]\cap\N,d_i=b_i+c_i\)。
那么在此基础上,我们推出权值线段树的合并:两棵权值线段树 \(A,B\) 的合并,得到的结果就是对应集合为它们的对应集合的合并的权值线段树。显然,\(A,B\) 合并的结果上的每个节点的区间和都是 \(A,B\) 对应节点的区间和的和。并且我们可以将其这么推广到不是权值线段树的线段树上。
显然,对于两棵线段树,如果它们很复杂(各自维护的数组内没有相同元素),那么求它们的合并的唯一方法就是暴力加一遍。但是对于一些特定的线段树——如动态开点线段树,情况就不一样了。这种线段树的很多节点对应的区间内全是 \(0\),所以我们根本就不需要加一遍,直接复制另一棵该子树的所有信息就行了。但是复制一遍的时间和加一遍也没有区别啊!我们可以学习可持久化线段树的思想,直接从结果线段树那连一条边到原来的线段树上就行。
如何实现?有两种实现方法:
1.将线段树 \(B\) 直接合并在 \(A\) 上。优点是空间小,不会申请新的节点;缺点则是会改变 \(A\)。也就是说这种做法合并后(多数情况下)就不能直接访问 \(A\) 了。
2.新建一棵动态开点线段树作为 \(A\) 和 \(B\) 合并的结果。优点是不会改变 \(A\) 的信息,缺点显然就是占的空间更大了。
下面给出我的第一种写法:
//把 v 合并到 u 上,u 和 v 对应的区间是 [l,r],返回值为合并后的线段树的根节点
//ls[u] 为线段树上 u 的左儿子,rs[u] 为线段树上 u 的右儿子
//pushdown(u) 是把 u 上的懒标记下传,pushup(u) 是更新 u 的信息
//nv[u] 表示 u 的信息
int tmer(int u, int v, int l, int r) {
if (!u || !v)
return u + v;
/*
这个写法等价于:
if(!u) return v;
if(!v) return u;
如果两者之一为 0,那就说明该节点维护的区间也都是 0
*/
if (l == r) {//这里不用再管懒标记,因为已经到了叶子结点
nv[u] += nv[v];
return u;
}
int mid = (l + r) >> 1;
pushdown(u);
pushdown(v);
ls[u] = tmer(ls[u], ls[v], l, mid);//递归处理左儿子
rs[u] = tmer(rs[u], rs[v], mid + 1, r);//递归处理右儿子
pushup(u);
return u;
}
那么,这个玩意可以干嘛呢?
有些时候,我们需要求多个数组按位相加后得到的结果。如果暴力做的话一定超时,但这些数组有个共同特点就是它们中很多元素都是 \(0\)。此时就可以对数组建动态开点线段树,使用线段树合并来避免时间浪费在为 \(0\) 的元素上。假设需要合并的这些数组中非 \(0\) 点不超过 \(n\) 个,数组大小是 \(m\),那么时间复杂度就近似于 \(\Theta(n\log m)\)。
那么,我们来试图解决一下这个题吧!
P4556 雨天的尾巴
题意
一棵有 \(n\) 个节点的树,每个节点上有一个初始为空集的可重集。进行 \(m\) 次操作,每次操作给出 \(u,v,z\),表示在节点 \(u\) 到节点 \(v\) 的路径上的每个节点上都插入一个元素 \(z\)。求最终每个节点上最多的元素。如果这个节点上的可重集为空集,输出 \(0\)。如果有多个最多元素,输出其中值最小的那个元素。
思路
多次操作求一次答案,容易想到差分。既然这是在树上,那自然就是树上差分了(如果不会树上差分,请先去学)。我们将可重集转化为权值数组 \(\{c_i\}\),题目就变成了给出 \(u,v,z\),将 \(u\) 至 \(v\) 路径上所有点的 \(c_z\) 修改为 \(c_z+1\)。在我们做了差分后,这个操作就变成了将 \(u,v\) 点处的 \(c_z\) 修改为 \(c_z+1\),将 \(lca(u,v),fa(lca(u,v))\) 点处的 \(c_z\) 修改为 \(c_z-1\)(\(lca(u,v)\) 为 \(u\) 和 \(v\) 的最近公共祖先,\(fa(u)\) 为 \(u\) 的父亲)。
我们知道,要把树上差分还原,需要对其做子树和。这里的子树和对应的自然是差分后子树内所有节点的数组按位相加得到的结果。和一般的树上差分一样,如果我们已经还原了一个节点的所有儿子,那么还原这个节点只需要把所有儿子的数组按位相加再按位相加上这个节点的差分数组就行。把数组按位相加是个很熟悉的条件,我们还需要所有的数组中不为 \(0\) 的元素较少这个条件。容易发现一次修改最多只增加 \(4\) 个非 \(0\) 元素,那么总共就最多增加 \(4m\) 个非 \(0\) 元素。所以所有数组中最多只有 \(4m\) 个非 \(0\) 元素,求按位相加可以使用线段树合并。与此同时,求得的答案并不是单单给出权值数组就行,还要求出权值数组中最大值的最小下标。这个很容易用线段树维护。
代码
/*********************************************************************
程序名:
版权:
作者: TM_Sharweek
日期: 2025-02-17 10:57
说明:
*********************************************************************/
#include <bits/stdc++.h>
#define p_b push_back
#define m_p make_pair
#define sec second
#define fst first
#define p_q priority_queue
#define u_m unordered_map
using namespace std;
using ll = long long;
using ull = unsigned long long;
using i128 = __int128;
const int N = 1e5 + 50;
vector<int> g[N];
int fa[N], dep[N], siz[N], son[N], top[N];
void dfs1(int u, int f) {
fa[u] = f, dep[u] = dep[f] + 1, siz[u] = 1;
int t = -1;
for (int v : g[u]) {
if (v == f)
continue;
dfs1(v, u);
siz[u] += siz[v];
if (siz[v] > t)
son[u] = v, t = siz[v];
}
}
void dfs2(int u, int tp) {
top[u] = tp;
if (!son[u])
return;
dfs2(son[u], tp);
for (int v : g[u]) {
if (v == fa[u] || v == son[u])
continue;
dfs2(v, v);
}
}
int lca(int u, int v) {
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]])
swap(u, v);
u = fa[top[u]];
}
return dep[u] > dep[v] ? v : u;
}
//以上内容为树剖 lca
int ls[N << 6], rs[N << 6], cnt;
pair<int, int> nv[N << 6];
int root[N];
//只有单点修改,不需要懒标记,也就不需要 pushdown
void pushup(int u) {
nv[u] = max(nv[ls[u]], nv[rs[u]]);
}
int tadd(int u, int l, int r, int pos, int k) {
if (!u) {
u = ++cnt;
nv[u].sec = -pos;
}
if (l == r && l == pos) {
nv[u].fst += k;
return u;
}
int mid = (l + r) >> 1;
if (pos <= mid)
ls[u] = tadd(ls[u], l, mid, pos, k);
else
rs[u] = tadd(rs[u], mid + 1, r, pos, k);
pushup(u);
return u;
}
int tmer(int u, int v, int l, int r) {
if (!u || !v)
return u + v;
if (l == r) {
nv[u].fst += nv[v].fst;
return u;
}
int mid = (l + r) >> 1;
ls[u] = tmer(ls[u], ls[v], l, mid);
rs[u] = tmer(rs[u], rs[v], mid + 1, r);
pushup(u);
return u;
}
int ans[N];
void sol(int u) {
for (int v : g[u]) {
if (v == fa[u])
continue;
sol(v);
root[u] = tmer(root[u], root[v], 1, 1e5);
}
ans[u] = nv[root[u]].fst ? -nv[root[u]].sec : 0;//特判最大值为 0 的情况
//需要注意的是,因为值为 0 的节点可能通过线段树合并由互为相反数的节点得到,所以合并后并不是不可能存在为 0 的节点
}
int main() {
// freopen(".in","r",stdin);
// freopen(".out","w",stdout);
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n, m;
cin >> n >> m;
for (int i = 1, u, v; i < n; i++) {
cin >> u >> v;
g[u].p_b(v), g[v].p_b(u);
}
dfs1(1, 0);
dfs2(1, 1);
while (m--) {
int u, v, z;
cin >> u >> v >> z;
int lc = lca(u, v);
root[u] = tadd(root[u], 1, 1e5, z, 1);
root[v] = tadd(root[v], 1, 1e5, z, 1);
root[lc] = tadd(root[lc], 1, 1e5, z, -1);
root[fa[lc]] = tadd(root[fa[lc]], 1, 1e5, z, -1);
}
sol(1);
for (int i = 1; i <= n; i++) {
cout << ans[i] << '\n';
}
return 0;
}
再度推歌
《宿命论》
作者:JUSF 周存
正式内容
学习了线段树合并是个啥之后,我们就可以学习线段树合并优化 DP 了。
线段树合并优化 DP 并不像绝大多数数据结构优化 DP 一样优化方式那么显然。毕竟只是做数组按位相加而已,拓展一下也就数组按位相乘之类的东西,如果只是这样感觉也不是很能优化什么东西。
下面我将从一道经典的线段树合并优化 DP 题目讲解怎么用线段树合并优化 DP。
P6773 [NOI2020D1T2]命运
题意
给出一棵 \(n\) 个节点的边可被染为黑色或不染的有根树和 \(m\) 条限制。每个限制包括两个数 \(u\) 和 \(v\),表示要求 \(u\) 到 \(v\) 的路径中有至少一条黑色边。保证 \(u\) 一定为 \(v\) 的祖先。求合法染色方案数,对 \(998244353\) 取模。
\(1\le n,m\le 5\times 10^5\)
思路
这么数数,先考虑 DP。
容易找到一个关键条件:保证 \(u\) 一定为 \(v\) 的祖先。这个保证可能是问题有解的保证。还可以从部分分中看到一个特殊性质分:树为完全二叉树。通常这说明有做法可以在完全二叉树上做到很好却无法在普通树上做得很好(当然也有例外,如 NOIP2024T2)。
我们从这两点出发去思考。\(u\) 一定为 \(v\) 的祖先有什么好的性质?题目中已经讲了,\(u\) 到 \(v\) 中有一个染为黑色边就行。为了更好的将不同的情况合并,我们考虑 \(u\) 一定为 \(v\) 的祖先而非 \(v\) 一定为 \(u\) 的后代。因为一个节点的两个后代之间不一定有“后代与祖先”的关系,而一个节点的两个祖先之间一定有“后代与祖先的关系”。
那么所有后一个数为 \(v\) 的限制可以列为 \(\langle u_1,v\rangle,\langle u_2,v\rangle,\cdots,\langle u_k,v\rangle\),其中 \(\forall i<j\),\(u_i\) 都是 \(u_j\) 的后代。令它们分别对应集合 \(S_1,S_2,\cdots,S_k\),使得 \(S_i\subseteq E\),且从 \(S_i\) 任取一条边染成黑色 \(\langle u_i,v\rangle\) 这条限制都会被满足(\(E\) 为这棵树的边集)。容易发现 \(S_i\) 就是 \(u_i\) 到 \(v\) 的路径上的所有边的集合(废话)。在此基础上就很容易发现 \(S_1\subseteq S_2\subseteq\cdots\subseteq S_k\)(题面有说可以取等)。从这一点上我们可以发现,如果 \(S_1\) 中有黑色边则 \(S_1,S_2,\cdots,S_k\) 中都有黑色边。也就是说,这 \(k\) 条限制全部被满足了。
这是第一个重要性质,它告诉我们一个事实:对于所有后一个数为 \(v\) 的限制,它们中只有一个是有效的。且这个有效的限制 \(\langle u_i,v\rangle\) 满足 \(u_i\) 是 \(u_1\) 到 \(u_k\) 中离 \(v\) 最近的那个。容易发现这也是深度最深的那个。
完全二叉树告诉了我们什么?完全二叉树告诉我们,所有点的深度不会超过 \(\Theta(\log n)\)。这启发我们复杂度可能会与深度有关。具体的,我们的 DP 方程中可能有与深度有关的状态。我们考虑第二个数在 \(u\) 的子树中的限制,显然,这些限制不在子树内完成就在子树外完成(废话)。而我们又知道子树内所有节点都是 \(u\) 的后代(还是废话),那么在子树外完成的限制 \(\langle u',v\rangle\) 一定满足 \(u'\) 是 \(u\) 的祖先,并且在子树内没有完成(废话)。那么我们就可以把它看作一个限制 \(\langle u',u\rangle\),因为不会在 \(u\) 的子树里面令它满足。而这些新的限制又会与 \(u\) 的限制合并,只留下其中第一个数最深的限制。
下文中,我们称第二个数是一个子树内的节点的限制为该子树中的限制。
所以除了 \(u\) 和以 \(u\) 为根的子树中未完成的限制中第一个数最深的深度以外,子树内的其他信息对外界没有影响,这两个数据相同的子问题可以合并在一起且保证无后效性。所以我们可以基于此来做 DP。
我们设计状态为 \(f_{u,i,j}\) 表示只考虑以 \(u\) 和以 \(u\) 的前 \(i\) 个儿子为根的子树时还没满足的限制中第一个数深度最深为 \(j\) 的方案数(根节点深度为 \(1\))。若 \(j=0\),则表明子树内限制已全部满足。当我们将一个儿子和它的子树加入时,我们有连黑边和连无色边两种选择。连黑边的话,这棵子树内的第一个数深度小于等于 \(u\) 的深度的限制会全被满足,\(j\) 就由前面几个子树决定了。这部分的贡献是 \(\sum_{k=0}^{dep(u)}f_{u,i-1,j}\times f_{v,son(v),k}\),其中 \(dep(u)\) 是 \(u\) 的深度,\(son(u)\) 是 \(u\) 的儿子个数。
连无色边时,这棵子树内没有新的限制被满足,所以前面没被满足的深度和该子树内没被满足的深度的最大值应该是 \(j\)。分类讨论:当前面没被满足的深度更深时,总贡献为 \(\sum_{k=0}^{j-1}f_{u,i-1,j}\times f_{v,son(v),k}\);当一样深时,总贡献为 \(f_{u,i-1,j}f_{v,son(v),j}\);当该子树内没被满足的深度更深时,总贡献为 \(\sum_{k=0}^{j-1}f_{u,i-1,k}\times f_{v,son(v),j}\)。加在一起就是 \(\sum_{k=0}^jf_{u,i-1,j}\times f_{v,son(v),k}+\sum_{k=0}^{j-1}f_{u,i-1,k}\times f_{v,son(v),j}\)。
将两种情况再加在一起,就得到了转移式:
做前缀和优化 \(g_{u,i,j}=\sum_{k=0}^j f_{u,i,k}\),则:
再非常套路的用滚动数组滚掉第二维,我们得到了这么个转移式:
注意先更新完所有的 \(f_{u,i}\) 再去更新 \(g_{u,i}\)。边界条件为 \(f_{u,dep(xz(u))}=1,f_{u,i}=0(i\neq dep(xz(u)))\),其中 \(xz(u)\) 表示所有第二个数是 \(u\) 的限制中第一个数最深的深度。这个做法时间复杂度为 \(\Theta(nD)\),其中 \(D\) 是最深节点的深度。当给出的树为链时,复杂度被卡至 \(\Theta(n^2)\)。所以我们需要进行优化。
注意到一个重要性质:对于一个节点,初始时绝大多数状态的答案都为 \(0\),只有一个状态为 \(1\)。这启示我们使用之前在线段树合并中使用的思路,即跳过没必要的运算。
对于一段区间 \([l,r]\),如果 \(\forall i\in [l,r]\cap\N,f_{u,i}=0\),那么就有 \(\forall i\in [l,r]\cap\N,g_{u,i}=g_{u,i-1}\),我们就没必要再关心前面这个式子,直接把 \(f_{v,i}\) 上的这个区间复制过来,再区间乘一个 \(g_{u,l-1}\) 就行。如果 \(\forall i\in [l,r]\cap\N,f_{v,i}=0\),那么就有 \(\forall i\in [l,r]\cap\N,g_{v,i}=g_{v,i-1}\),后面这个式子就不需要考虑了,也可以直接把 \(f_{u,i}\) 上的区间继承后做区间乘。如果两个条件都满足,就更简单了,答案就是 \(0\)。于是我们可以用一种和线段树合并基本一样的算法来完成这个问题。但我们还需要在做这个合并的同时维护 \(f_u\) 和 \(f_v\) 的前缀和。怎么做呢?我们可以设立两个全局变量 \(su\) 和 \(sv\),做合并时先合并左子树再合并右子树。每合并一棵子树前,就把 \(sv\) 加上这个子树对应的区间和(因为 \(g_{v,i}\) 第二维是 \(i\),所以在计算 \(i\) 处答案前就要加上);合并好之后,就把 \(su\) 加上这个子树对应的区间和(因为第二维是 \(i-1\))。这样计算时 \(su\) 和 \(sv\) 就是 \(g_{u,i-1}\) 和 \(g_{v,dep(v)}+g_{v,i}\) 了。因为 \(sv\) 除了前缀和以外还要加上 \(g_{v,dep(v)}\) 这个定值,所以初值赋为它即可。注意 \(su\) 应该加上的是修改之前的子树区间和,所以如果区间和有改变,就需要保存原来的区间和的值来改变 \(su\)。
现在,我们只需要实现这样一棵线段树:支持单点加、区间乘、单点赋值、区间和、奇怪合并,然后在每个节点上都开一棵就可以啦。因为一个节点不可能有多个父亲,所以我们不需要考虑破坏原来信息的问题。
时间复杂度有保证吗?有的。每个节点上 DP 数组初始时只有 \(1\) 个非零点,后面进行的也都是合并而没添加新的点,所以总共最多 \(n\) 个非零点,所以时间复杂度是 \(\Theta(n\log n)\)。
代码
/*********************************************************************
程序名:
版权:
作者: TM_Sharweek
日期: 2025-02-18 11:04
说明: 炸死你
*********************************************************************/
#include <bits/stdc++.h>
#define p_b push_back
#define m_p make_pair
#define sec second
#define fst first
#define p_q priority_queue
#define u_m unordered_map
using namespace std;
using ll = long long;
using ull = unsigned long long;
using i128 = __int128;
const int N = 5e5 + 50;
const ll P = 998244353;
int n, m;
vector<int> g[N];
int xz[N];
int dep[N], fa[N];
void dfs1(int u, int fat) {
fa[u] = fat, dep[u] = dep[fat] + 1;
for (int v : g[u]) {
if (v == fat)
continue;
dfs1(v, u);
}
}//求深度
ll sm[N << 6], ls[N << 6], rs[N << 6], tg[N << 6], cnt;
int rt[N];
//sm 维护区间和,ls 为左儿子,rs 为右儿子,tg 为乘法懒标记,rt 为该节点上线段树的根节点
void pushdown(int u) {
if (tg[u] == 1)
return;
tg[ls[u]] = (tg[ls[u]] * tg[u]) % P;
tg[rs[u]] = (tg[rs[u]] * tg[u]) % P;
sm[ls[u]] = (sm[ls[u]] * tg[u]) % P;
sm[rs[u]] = (sm[rs[u]] * tg[u]) % P;
tg[u] = 1;
}//下传懒标记
void pushup(int u) {
sm[u] = (sm[ls[u]] + sm[rs[u]]) % P;
}//更新信息
int tadd(int u, int l, int r, int pos, ll k) {
if (!u) {
u = ++cnt;
tg[u] = 1;
}
if (l == r && l == pos) {
sm[u] = (sm[u] + k) % P;
return u;
}
int mid = (l + r) >> 1;
pushdown(u);
if (pos <= mid) {
ls[u] = tadd(ls[u], l, mid, pos, k);
} else {
rs[u] = tadd(rs[u], mid + 1, r, pos, k);
}
pushup(u);
return u;
}//普通的动态开点线段树单点加
ll tque(int u, int l, int r, int ql, int qr) {
if (!u)
return 0;
if (ql <= l && qr >= r) {
return sm[u];
}
int mid = (l + r) >> 1;
pushdown(u);
ll ans = 0;
if (ql <= mid) {
ans = (ans + tque(ls[u], l, mid, ql, qr)) % P;
}
if (qr > mid) {
ans = (ans + tque(rs[u], mid + 1, r, ql, qr)) % P;
}
return ans;
}//普通的区间查询
//其实并不需要额外的区间乘法,直接在合并时做即可
//单点赋值也一样
ll su, sv;
int tmer(int u, int v, int l, int r) {
if (!u && !v)//如果都是 0 结果就是 0,返回空节点
return 0;
if (!u) {//如果 f[u][i] 在这棵子树对应的区间上全是 0,就只要把 v 对应子树合并过来区间乘
sv = (sv + sm[v]) % P;//更新 sv
tg[v] = (tg[v] * su) % P;
sm[v] = (sm[v] * su) % P;//直接区间乘
return v;
}
if (!v) {
su = (su + sm[u]) % P;//由于下面不要用 su,所以可以在前面更新
tg[u] = (tg[u] * sv) % P;
sm[u] = (sm[u] * sv) % P;//区间乘
return u;
}
if (l == r) {//如果走到叶子上就暴力做
ll tu = sm[u];//先存下来修改之前 u 的区间和
sv = (sv + sm[v]) % P;//更新 sv
sm[u] = (sm[u] * sv % P + sm[v] * su % P) % P;//暴力单点赋值
su = (su + tu) % P;//更新 su
return u;
}
int mid = (l + r) >> 1;
pushdown(u);
pushdown(v);//记得下传标记
ls[u] = tmer(ls[u], ls[v], l, mid);
rs[u] = tmer(rs[u], rs[v], mid + 1, r);//记得先左再右顺序
pushup(u);//记得更新信息
return u;
}
void dfs2(int u) {
rt[u] = tadd(rt[u], 0, n, xz[u], 1);//深度不可能超过 n
//初始时只有 f[u][xz[u]] 为 1
for (int v : g[u]) {
if (v == fa[u])
continue;
dfs2(v);
su = 0, sv = tque(rt[v], 0, n, 0, dep[u]);//su 初始为 0,sv 初始为 [0,dep[u]] 区间和
rt[u] = tmer(rt[u], rt[v], 0, n);//合并
}
}
int main() {
// freopen(".in","r",stdin);
// freopen(".out","w",stdout);
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin >> n;
for (int i = 1, u, v; i < n; i++) {
cin >> u >> v;
g[u].p_b(v), g[v].p_b(u);
}
dfs1(1, 0);
cin >> m;
for (int i = 1, u, v; i <= m; i++) {
cin >> u >> v;
xz[v] = max(xz[v], dep[u]);//合并限制
}
dfs2(1);
cout << tque(rt[1], 0, n, 0, 0) << endl;//答案为 f[1][0]
return 0;
}
这里,我们并没有写常规意义上的线段树合并,而是写了一个形式相似的。线段树合并优化 DP 实际上利用了线段树合并的思想:跳过与全是幺元或全是零元的区间,因为我们并不需要进行计算就能得到答案:如果是幺元,那就直接把区间复制过来;如果是零元,那就放个都是零元的区间。为了方便跳过这些运算区间,我们使用了线段树来维护。为了方便区间复制,我们使用了类似主席树的思想。这种做法显然幺元和零元越多越快。如果没有幺元和零元,会被卡至 \(\Theta(n^2\log n)\)。此外,如果合并时只有一个运算,并且这个运算可以在线段树上做区间操作,那么可以所有元素都相等的区间也可以通过直接区间修改来合并以跳过重复运算。

浙公网安备 33010602011771号