[线段树进阶〇]“抽象”化的线段树
[线段树进阶〇] “抽象”化的线段树
线段树是算法竞赛中最基础的数据结构,本系列文章将介绍线段树的一些进阶技巧。
在这之前,我们有必要深入理解线段树的本质,把线段树的结构抽象出来。这对我们理解算法的实质是有很大帮助的。
区间修改区间查询问题
线段树解决的问题一般具有以下形式:给定序列 \(a\),集合 \(S\)(可以理解为序列元素的类型),\(S\) 中的元素可以相加,支持两种操作:
- 区间修改:给定区间 \([l, r]\),\(\forall l \le i \le r\),令 \(a_{i} \gets f(a_i)\),其中 \(f: S \mapsto S\) 是给定的修改函数。
- 区间查询:给定区间 \([l, r]\),求 \(\sum_{i = l}^{r} a_i\),这里的求和是 \(S\) 中元素的求和。
有时我们会把某种整数权值作为序列下标,习惯上使用这种技巧的线段树称为“权值线段树”。例如,给定一个多重集 \(S\),\(a_i\) 表示元素 \(i\) 出现的次数。这种技巧的经典应用是使用可持久化权值线段树维护静态区间第 \(k\) 小。
序列信息和懒标记
我们熟知,线段树的每个节点维护序列的一个区间的信息。我们把一个节点维护的信息抽象成两种:一种是序列信息,一种是修改信息(即懒标记)。把这两种信息的类型分别记为 \(D\) 和 \(T\),或者说两种信息分别属于集合 \(D\) 和集合 \(M\)。
接下来我们研究线段树维护信息的过程中需要什么函数:
-
区间合并(
update
):把两个相邻区间 \([l_1, r_1]\) 和 \([l_2, r_2]\) 的序列信息合并起来,作为大区间 \([l_1, r_2]\) 的序列信息,即序列信息的合并。 -
懒标记下放(
pushdown
):把当前区间的懒标记作用到它的两个子区间上,而这又分为两个过程,即当前区间懒标记对子区间懒标记的作用,以及当前区间懒标记对子区间序列信息的作用。即懒标记之间的复合和懒标记对序列信息的复合。 -
查询(
query
)和修改(change
)函数会调用以上函数,但其它部分与维护的信息具体是什么完全无关。
也就是说,设计一棵线段树,本质上只用设计三个操作:
- 序列信息合并,记为函数 \(\operatorname{apply}(D, D) \to D\);
- 懒标记对懒标记的复合,记为函数 \(\operatorname{compose}(T, T) \to T\);
- 懒标记对序列信息的复合,记为函数 \(\operatorname{compose}(T, D) \to D\)。
我们不妨以基础问题:区间加区间求和为例。在此问题中,序列信息是二元组 \((l, s)\),分别表示区间长度和区间和,修改信息是 \(x\),表示这个区间中的每个元素需要加上 \(x\)。那么三个操作可以这样设计:
-
序列信息合并:区间长度以及区间和都可以直接相加,即
\[\operatorname{apply}((l_1, s_1), (l_2, s_2)) = (l_1 + l_2, s_1 + s_2) \] -
懒标记对懒标记的复合:一个区间加上 \(x\) 再加上 \(y\),等价于同时加上 \(x + y\),即
\[\operatorname{compose}(y, x) = y + x \](注意:习惯上复合操作的变量从右往左书写,即 \(\operatorname{compose}(y, x)\) 表示先作用 \(x\) 再作用 \(y\),虽然在此例中顺序不重要)
-
懒标记对序列信息的复合:一个区间整体加上 \(x\),对区间长度没有影响,而区间和加上 \(l \cdot x\),即
\[\operatorname{compose}(x, (l, s)) = (l, s + l \cdot s) \]
为了方便,我们也可以使用加号 \(+\) 表示信息合并,用乘号 \(\times\) 表示信息复合。需要注意的是,这两个符号并不表示实数的加法和乘法。
双半群模型
上一节中我们把线段树维护的信息抽象了出来,现在我们进一步探讨这些信息应该满足什么性质。
序列信息结合律
在线段树合并信息的过程中,我们可能要把一个查询的区间拆成两个区间,然后合并两个子区间的信息。也就是说,设查询区间为 \([l, r]\),区间中点为 \(m\),则以下式子需要满足:
也就是序列信息需要满足结合律。
以下是几个例子:
- 给定一个方阵序列,我们可以用线段树维护区间矩阵积,因为矩阵乘法满足结合律。(注意区分结合律和交换律:序列信息并不需要满足交换律。)
- 不满足结合律的一个例子是与非(\(\operatorname{nand}\))运算:\(a \operatorname{nand} (b \operatorname{nand} c)\) 和 \((a \operatorname{nand} b) \operatorname{nand} c\) 不一定相等。例题是 BZOJ2908 又是 nand:看到这题很容易想到树剖以后用线段树维护区间 \(\operatorname{nand}\),但由于 \(\operatorname{nand}\) 不满足结合律,所以不能直接用线段树维护,需要别的办法。
懒标记结合律
设有懒标记 \(t_1\),\(t_2\) 和 \(t_3\),应有 \(t_3(t_2t_1) = (t_3t_2)t_1\)。
设有信息 \(d\),应有 \((t_2t_1)d = t_2(t_1d)\)。
分配律
下放懒标记之后,当前区间的序列信息不变,而子区间序列信息复合上了当前区间的懒标记。从逻辑上讲,子区间序列信息复合懒标记以后再合并,应该和子区间序列信息合并后再复合懒标记相等,即
也就是复合对合并有分配律。
幺元
没有进行任何修改操作时,所有节点的懒标记应该为空,因此我们需要一个元素 \(\varepsilon \in T\) 表示“什么都不做”,称为幺元。根据定义,此元素应该满足 \(\varepsilon t = t \varepsilon = t\)。
在有的线段树实现中,需要查询空区间。我们同样使用幺元 \(\varepsilon \in D\) 表示空区间的序列信息,满足 \(\varepsilon + d = d + \varepsilon = d\)。
例如,在区间加区间求和问题中,懒标记的和区间信息的幺元都是 \(0\)。如果序列信息是区间最大值,则幺元为 \(-\infty\)。
在抽象代数中,如果一个集合 \(S\) 和其上的二元运算 \(+: S + S \to S\) 满足以下条件,则集合和运算构成的二元组 \((S, +)\) 称为半群:
- 封闭性:\(\forall a, b \in S\),\(a + b \in S\)。
- 结合律:\((a + b) + c = a + (b + c)\)。
如果存在元素 \(\varepsilon \in S\) 满足 \(a + \varepsilon = \varepsilon + a = a\),则这个半群也被称为幺半群,\(\varepsilon\) 被称为幺元。
我们发现,序列信息和懒标记分别都满足幺半群的定义,所以我们可以用两个半群 \((D, +)\) 和 \((T, \times)\) 刻画序列信息和懒标记。这两个半群之间还需要满足上文所述的一些条件,也就是有联动性。我们把这样用两个半群刻画线段树信息的结构称为双半群模型。设计一棵线段树就本质上就是设计双半群模型。
如果你对用抽象代数的语言描述数据结构感到困惑,可以阅读本文的例题部分,结合实际的例子来理解。
代码实现
上文提到,把维护的信息抽象出来之后,线段树的实现和信息的具体内容完全无关。因此我们自然想要写出这样一份代码,把信息的设计和线段树的实现分离开,其中线段树的实现不依赖信息的设计。
这里先规范代码实现中的一些细节问题:
- 所有区间都是闭区间。(另一种常用的方式是左闭右开区间。)
- 序列下标从 \(1\) 开始。
- 使用
vector
而不是 C 风格的静态数组。
需要指出,本节的线段树模板很大程度上参考了 jiangly 的代码。
线段树主体的实现
首先,我们使用类模板参数传入序列信息 Info
和懒标记 Tag
,以达到分离线段树主体和信息设计的目的。然后考虑一棵线段树需要存储什么东西:我们要记录序列长度 n
,以及维护的信息。由于信息已经被抽象出来,所以只要用两个 vector
分别记录序列信息和懒标记:
template<class Info, class Tag>
struct SGT {
#define lson (id << 1)
#define rson (id << 1 | 1)
const int n;
vector<Info> info;
vector<Tag> tag;
// 成员函数
#undef lson
#undef rson
}
接下来分别考虑所有成员函数:
信息合并(update)
把当前区间的序列信息更新为两个子区间序列信息合并的值。
void update(int id) {
info[id] = info[lson] + info[rson];
}
懒标记复合(apply)
把懒标记 v
作用到节点 id
的序列信息和懒标记上。代码中 Info::apply(const Tag &v)
和 Tag::apply(const Tag &v)
分别表示懒标记对序列信息和懒标记的复合。
void apply(int id, const Tag &v) {
info[id].apply(v);
tag[id].apply(v);
}
下传标记(pushdown)
把当前节点的懒标记下放到子区间中,并清空当前节点的懒标记(把懒标记设为幺元)。代码中 Tag
的空构造函数 Tag()
表示幺元。
void pushdown(int id) {
apply(lson, tag[id]), apply(rson, tag[id]);
tag[id] = Tag();
}
区间修改(change)
此部分为线段树基础知识,不再展开。
为了便于调用,重载了此函数。
void change(int id, int l, int r, int L, int R, const Tag &v) {
if(l == L && r == R) {
apply(id, v);
return;
}
pushdown(id);
int mid = (l + r) >> 1;
if(R <= mid) change(lson, l, mid, L, R, v);
else if(L > mid) change(rson, mid + 1, r, L, R, v);
else change(lson, l, mid, L, mid, v), change(rson, mid + 1, r, mid + 1, R, v);
update(id);
}
void change(int L, int R, const Tag &v) { // 重载
change(1, 1, n, L, R, v);
}
区间查询(query)
和区间修改很相似。注意返回值类型是 Info
。
Info query(int id, int l, int r, int L, int R) {
if(l == L && r == R) {
return info[id];
}
pushdown(id);
int mid = (l + r) >> 1;
if(R <= mid) return query(lson, l, mid, L, R);
else if (L > mid) return query(rson, mid + 1, r, L, R);
else return query(lson, l, mid, L, mid) + query(rson, mid + 1, r, mid + 1, R);
}
Info query(int L, int R) {
return query(1, 1, n, L, R);
}
构造函数(build)
即建树,同样是基础内容。
为了便于实现,使用了 lambda
函数。
构造函数传入的 vector
类型 T
和 T
到 Info
的转换根据具体问题而定。在区间加区间求和问题中,传入的 vector
类型为 int
,代表初始序列。
template<class T>
SGT(int _n, const vector<T> &a): n(_n), info(n << 2), tag(n << 2) {
function<void(int, int, int)> build = [&](int id, int l, int r) {
if(l == r) {
info[id] = a[l]; // 应该实现 T -> Info 的构造函数
return;
}
int mid = (l + r) >> 1;
build(lson, l, mid), build(rson, mid + 1, r);
update(id);
};
build(1, 1, n);
}
懒标记的实现
懒标记需要实现成员函数 apply()
,即懒标记之间的复合。除此之外还要实现空构造函数,用于清空懒标记。(如果构造函数有其它用处,不方便用空构造函数代表幺元,也可以实现一个 static
修饰的函数用来返回幺元。)
以下是区间加区间求和问题的懒标记设计:
struct Tag {
i64 x;
Tag(i64 _x = 0): x(_x) {}
void apply(const Tag &v) {
x += v.x;
}
};
序列信息的实现
序列信息需要实现两个成员函数:懒标记的复合 apply
以及区间信息的合并(用重载 +
运算符实现)。
以下是区间加区间求和问题的序列信息设计:
struct Info {
int l;
i64 s;
Info(int _l = 0, i64 _s = 0): l(_l), s(_s) {}
void apply(const Tag &v) {
s += l * v.x;
}
friend Info operator + (const Info &A, const Info &B) {
return Info(A.l + B.l, A.s + B.s);
}
};
Code
#include<bits/stdc++.h>
using namespace std;
using i64 = long long;
template<class Info, class Tag>
struct SGT {
#define lson (id << 1)
#define rson (id << 1 | 1)
const int n;
vector<Info> info;
vector<Tag> tag;
void update(int id) {
info[id] = info[lson] + info[rson];
}
template<class T>
SGT(int _n, const vector<T> &a): n(_n), info(n << 2), tag(n << 2) {
function<void(int, int, int)> build = [&](int id, int l, int r) {
if(l == r) {
info[id] = a[l];
return;
}
int mid = (l + r) >> 1;
build(lson, l, mid), build(rson, mid + 1, r);
update(id);
};
build(1, 1, n);
}
void apply(int id, const Tag &v) {
info[id].apply(v);
tag[id].apply(v);
}
void pushdown(int id) {
apply(lson, tag[id]), apply(rson, tag[id]);
tag[id] = Tag();
}
void change(int id, int l, int r, int L, int R, const Tag &v) {
if(l == L && r == R) {
apply(id, v);
return;
}
pushdown(id);
int mid = (l + r) >> 1;
if(R <= mid) change(lson, l, mid, L, R, v);
else if(L > mid) change(rson, mid + 1, r, L, R, v);
else change(lson, l, mid, L, mid, v), change(rson, mid + 1, r, mid + 1, R, v);
update(id);
}
void change(int L, int R, const Tag &v) {
change(1, 1, n, L, R, v);
}
Info query(int id, int l, int r, int L, int R) {
if(l == L && r == R) {
return info[id];
}
pushdown(id);
int mid = (l + r) >> 1;
if(R <= mid) return query(lson, l, mid, L, R);
else if (L > mid) return query(rson, mid + 1, r, L, R);
else return query(lson, l, mid, L, mid) + query(rson, mid + 1, r, mid + 1, R);
}
Info query(int L, int R) {
return query(1, 1, n, L, R);
}
#undef lson
#undef rson
};
struct Tag {
i64 x;
Tag(i64 _x = 0): x(_x) {}
void apply(const Tag &v) {
x += v.x;
}
};
struct Info {
int l;
i64 s;
Info() {}
Info(i64 _s): l(1), s(_s) {}
Info(int _l, i64 _s): l(_l), s(_s) {}
void apply(const Tag &v) {
s += l * v.x;
}
friend Info operator + (const Info &A, const Info &B) {
return Info(A.l + B.l, A.s + B.s);
}
};
int main() {
cin.tie(nullptr) -> sync_with_stdio(false);
int n, q;
cin >> n >> q;
vector<int> a(n + 1);
for(int i = 1; i <= n; i++) {
cin >> a[i];
}
SGT<Info, Tag> tr(n, a);
for(int i = 1, op, l, r, x; i <= q; i++) {
cin >> op >> l >> r;
if(op == 1) {
cin >> x;
tr.change(l, r, x);
} else {
cout << tr.query(l, r).s << '\n';
}
}
return 0;
}
高度封装的板子的一大用途是参加网络比赛,因为可以直接赋值模板,但这种写法在 OI 中也有一定实用性(例如我在 NOI2025 D2T1 中就使用了这个线段树板子)。在 OI 比赛中,由于不能直接复制板子,当 Info
和 Tag
比较简单时,这种写法比较冗长。但信息比较复杂时,把线段树主体的实现和信息的实现分离开的写法,有助于编写和调试代码。
真正使用时,为了方便,可以对此模板作适当修改,例如当序列信息和懒标记比较简单时,可以不封装懒标记。如果不存在修改操作,或只有单点修改,可以简化一部分代码。此外,这个板子的线段树建树是用构造函数实现的,这样就不方便定义全局变量的线段树。如果要使用全局线段树,需要把构造函数修改成一般的成员函数。
时空复杂度分析
空间复杂度
一般情况下,线段树中每个节点的空间占用都是 \(O(1)\),那么整棵线段树的空间占用只和节点个数线性相关。
在初学者中常见的误区是线段树的空间复杂度是 \(O(n \log n)\),而实际上是 \(O(n)\)。最简单的分析方法是列出递归式:设 \(T(n)\) 表示长度为 \(n\) 的序列建出的线段树的节点个数,则
根据主定理,\(T(n) = O(n)\),因此线段树的空间复杂度为 \(O(n)\)。
在实际使用中,我们还需要关心空间复杂度中的常数因子,这部分的分析相对来说繁琐一些。
堆式存储
习惯上,我们通常使用堆式存储,即对于线段树中编号为 \(x\) 的非叶子节点,其左子编号为 \(2x\),右子编号为 \(2x + 1\)。使用堆式存储建出的线段树,叶子节点的深度之差不超过 \(1\)。这是因为,叶子节点对应的是长度为 \(1\) 的区间,其兄弟节点对应的区间长度不超过 \(2\)。如果兄弟节点对应区间长度为 \(2\),那么兄弟节点的子节点必然都是叶子节点。(不过线段树不一定是完全二叉树,因为叶子节点不一定在左侧。)为了方便计算,把线段树看成满二叉树,相当于在最深层加入一些无用的叶子节点,容易看成无用节点的数量不超过有用节点的数量。
定义一棵有根树的高度为,最深的叶子节点到根节点链上的边数,现在计算线段树的高度。如果一个节点对应的区间长度为 \(m\),则其左子对应的区间长度为 \(\lceil \frac{n}{2} \rceil\),右子对应的区间长度为 \(\lfloor \frac{n}{2} \rfloor\),因此左子区间长度不小于右子区间。由此推知,线段树的高度 \(h\) 为下值:
- 从根节点开始,不断跳向当前节点的左子节点,到叶子节点时停止,整个过程中经过的边数。
这等价于:
- 令 \(n \gets \lceil \frac{n}{2} \rceil\) 直到 \(n = 1\) 的迭代次数。
猜测这个值为 \(\lceil \log_2 n \rceil\),容易通过数学归纳法证明。
综上所述,树高 \(h = \lceil \log_2 n \rceil\),线段树节点数量为
分析上界:
也即线段树节点数量不超过 \(4n\),这就是线段树实现时通常开 \(4\) 倍空间的原因。
动态开点(理论最优)
堆式存储中存在很多无用节点,造成了空间的浪费。而线段树本质上是一棵有 \(n\) 个叶子节点的二叉树,根据经典结论,\(n\) 个叶子节点的二叉树总共有 \((2n - 1)\) 个节点。证明是容易的:把每个叶子节点看作一棵大小为 \(1\) 的子树,每次选择两棵子树,新建一个节点,作为两棵子树根节点的父节点,直到子树的数量变为 \(1\),总共需要操作 \((n - 1)\) 次。每次新增一个节点,因此节点总数为 \((2n - 1)\)。
代码实现上可以使用动态开点的技巧,即每个节点记录子节点的编号(或记录指向子节点的指针)。但实际上很少这样节省空间,因为最好情况下动态开点也只省下了一半的节点数量,而当 \(n\) 略小于 \(2\) 的非负整数次幂时二者节点数差别更小。此外,记录子节点的编号或指针也有额外的空间占用,因此这个技巧对空间的优化是有限的。
时间复杂度
通常情况下,序列信息合并、懒标记对懒标记的复合、懒标记对序列信息的复合都可以视作常数时间复杂度,我们基于这种假设进行讨论。
显然,区间查询和区间修改的时间复杂度相同,因此我们只用关心后者。而区间修改访问到某一节点时,进行的操作(pushdown
等)时间复杂度为 \(O(1)\),因此区间修改的总时间复杂度和访问的节点数线性相关。
设查询的区间为 \([L, R]\),节点对应的区间为 \([l, r]\)。如果 \([l, r] \subseteq [L, R]\),则称此节点为“完整节点”,否则称为“部分节点”。容易看出线段树的一层中至多存在两个部分节点(分别在查询区间的两端),而只有访问到部分节点时才会向下递归,一个部分节点最多递归到两个子节点。在第一层时只会访问一个节点,因此每一层访问的节点数量不超过 \(4\)。由于线段树的层高为 \(O(n \log n)\),所以区间查询的时间复杂度为 \(O(n \log n)\),区间修改同理。
线段树建树的时间复杂度和线段树节点数线性相关,为 \(O(n)\)。
例题
P2572 [SCOI2010] 序列操作
先考虑要维护什么序列信息。根据询问的要求,我们要维护一个区间中 \(1\) 的个数,以及最长 \(1\) 连续段的长度。由于存在取反操作,还要对应地维护区间中 \(0\) 的个数和最长 \(0\) 连续段的长度。
接下来套路性地考虑序列信息合并、懒标记复合及懒标记对序列信息的复合。
一个区间中 \(0\) 和 \(1\) 的个数显然分别就是两个子区间 \(0\) 和 \(1\) 的个数之和,关键在于连续段长度。把两个子区间拼到一起,大区间的最长连续段有三种情况:
- 完全在左子区间中;
- 完全在右子区间中;
- 跨越了两个区间。
前两种情况是容易维护的。对于第 3 种情况,现有的序列信息无法处理,所以必须增加维护的信息数量。如果一个最长 \(1\) 连续段(\(0\) 连续段同理)跨越了两个区间,那么它一定是由左区间的极长连续 \(1\) 后缀和右区间的极长连续 \(1\) 前缀拼接起来。
因此,我们还要维护一个区间极长的 \(0\) 和 \(1\) 前后缀长度。新增了维护的信息后,还要考虑新增信息如何合并。我们只需研究极长 \(1\) 前缀信息怎么合并,后缀是对称的。只有两种情况:
- 如果左子区间种所有元素都是 \(1\),那么大区间的极长 \(1\) 前缀是整个左子区间和右子区间的极长 \(1\) 前缀拼起来。
- 否则大区间的极长 \(1\) 前缀就是左子区间的极长 \(1\) 前缀。
我们发现前后缀信息的合并不需要新增别的信息,所以序列信息的设计就结束了。
接下来考虑懒标记的设计。由于有取反和赋值两种操作,自然想到用二元组 \((x, y)\) 表示懒标记,其中 \(x \in \{0, 1\}\) 表示是否取反,\(y \in \{-1, 0, 1\}\) 表示赋值为什么(\(y = -1\) 表示不赋值)。通常情况下,存在赋值操作时,赋值操作的优先级是最高的。即如果赋值标记不为 \(-1\),则取反标记失效,要把 \(x\) 设为 \(0\)。
设计懒标记之间的复合时,遵循赋值操作优先级最高的原则。令 \((a, b) = (x', y') \times (x, y)\)。
- 如果 \(y' \neq -1\),则直接令 \(a = 0\),\(b = y'\)。
- 否则如果 \(x = 1\)(即需要取反):
- 如果 \(y \neq -1\),令 \(b = 1 - y\),即 \(y\) 取反。
- 否则令 \(a = 1 - x\),即 \(x\) 取反。
- 否则什么都不做。(即 \((0, -1)\) 是懒标记的幺元。)
懒标记对序列信息的复合是简单的:
- 如果把区间赋值为 \(o\),则有关 \(o\) 的量全部设为区间长度,有关 \(1 - o\) 的量全部设为 \(0\)。
- 否则如果存在取反标记,则有关 \(0\) 的量和有关 \(1\) 的量对应交换。
struct Tag {
int flip, cov;
Tag(): flip(0), cov(-1) {}
Tag(int _flip, int _cov): flip(_flip), cov(_cov) {}
void apply(const Tag &v) {
if(v.cov != -1) {
flip = 0, cov = v.cov;
} else if(v.flip) {
if(cov != -1) cov ^= 1;
else flip ^= 1;
}
}
};
struct Info {
int sum; // 区间长度
array<int, 2> cnt{}, len{}, pre{}, suf{};
// 0 或 1 的个数,最长连续段长度,极长前/后缀长度
Info() = default;
Info(int x) {
sum = 1;
cnt[x] = len[x] = pre[x] = suf[x] = 1;
}
friend Info operator + (const Info &A, const Info &B) {
Info C;
C.sum = A.sum + B.sum;
for(int x: {0, 1}) {
C.cnt[x] = A.cnt[x] + B.cnt[x];
C.len[x] = max({A.len[x], B.len[x], A.suf[x] + B.pre[x]});
C.pre[x] = A.pre[x];
C.suf[x] = B.suf[x];
if(A.cnt[x] == A.sum) C.pre[x] = A.sum + B.pre[x];
if(B.cnt[x] == B.sum) C.suf[x] = B.sum + A.suf[x];
}
return C;
}
void apply(const Tag &v) {
if(v.cov != -1) {
int x = v.cov, y = x ^ 1;
cnt[x] = len[x] = pre[x] = suf[x] = sum;
cnt[y] = len[y] = pre[y] = suf[y] = 0;
} else if(v.flip) {
swap(cnt[0], cnt[1]);
swap(len[0], len[1]);
swap(suf[0], suf[1]);
swap(pre[0], pre[1]);
}
}
};
P4247 [清华集训 2012] 序列操作
设 \(w\) 为 \(c\) 的值域,即 \(20\)。对一个区间,记 \(f_k\) 表示从区间中选择 \(k\) 个数相乘的所有方案的和。由于 \(w\) 很小,我们可以在序列信息中对 \(1 \le k \le w\) 记录所有的 \(f_k\),称这个长为 \(k\) 的序列为答案序列。
设一个区间左子区间的答案序列为 \(a\),右子区间的答案序列为 \(b\)。现在要从大区间中选择 \(k\) 个数相乘,其中 \(i\) 个数来自左子区间,\((k - i)\) 个数来自右子区间。根据乘法分配律,这种情况的所有方案之和为 \(a_ib_{k - i}\)。枚举所有可能的 \(i\) 即可计算出 \(f_{k}\):
接下来考虑懒标记的设计。自然想到用两个变量 \((x, y)\) 分别表示是否要取反整个区间,以及区间加的值。区间取反相当于区间乘上 \(-1\),所以这个懒标记和区间乘区间加的线段树是相同的。
现在只需考虑懒标记对序列信息的复合。取反标记的影响是简单的:对任意选出若干个数相乘的方案,如果选择了偶数个数,则乘积不变,否则乘积变为原来的相反数。所以对所有的奇数 \(k\),令 \(f_k \gets -f_k\) 即可。
难点在于加法标记。设区间长度为 \(n\)(注意:不是整个序列的长度),假设从中选择 \(m\) 个数相乘。不失一般性地,设选择的数为 \(a_1, a_2, \cdots, a_m\)。把区间中的所有数加上 \(x\) 之后,乘积变为
为了展开这个式子,记 \(g_k\) 表示从 \(a_1, a_2, \cdots, a_m\) 中选择 \(k\) 个数相乘的所有方案乘积之和。展开这种二项式的积的常见想法是,把每个括号 \((a_i + x)\) 看成两种选择,要么选择 \(a_i\),要么选择 \(x\)。对每个括号分配一种选择,把所有括号的选择乘起来,就得到了展开式中的一项。枚举所有 \(2^m\) 中选择的方式,就得到展开式的所有项。
假设 \(m\) 个括号中有 \(i\) 个选择了非常数项,则剩下 \((m - i)\) 个括号选择了常数项 \(x\)。根据乘法分配律,所有选择了 \(i\) 个非常数项的方案乘积之和为 \(g_{i}x^{m - i}\)。枚举 \(i\) 即可计算这个展开式的值:
这个式子后仍然不够,因为我们不能枚举所有选择 \(m\) 个数的方案。我们转换一下视角。考虑一个大小为 \(i\) 的下标集合 \(S\),以及一个大小为 \(m \ge i\) 的下标集合 \(T\),且 \(S \subseteq T\)。在统计选择 \(T\) 中的元素相乘的贡献时,恰好枚举到 \(S\) 中所有元素的乘积一次,系数为 \(x^{m - i}\)。对于 \(S\),共有 \(\binom{n - i}{m - i}\) 个大小为 \(m\) 的集合包含它。设复合懒标记后新的答案序列为 \(f'\),则 \(S\) 对 \(f'_{m}\) 的贡献为
已知选择 \(i\) 个数相乘的所有方案乘积之和为 \(f_{i}\),所以大小为 \(i\) 的集合对 \(f'_{m}\) 的贡献之和为
综上所述,枚举 \(0 \le i \le n\),\(i \le m \le n\),即可计算 \(f'\),时间复杂度 \(O(w^2)\)。
struct Tag {
bool flip;
i64 add;
Tag() = default;
Tag(bool _flip, i64 _add): flip(_flip), add((_add + MOD) % MOD) {}
void apply(const Tag &v) {
if(v.flip) {
flip ^= 1;
add = MOD - add;
}
(add += v.add) %= MOD;
}
};
struct Info {
int len;
array<i64, c + 1> f{1};
Info(i64 x = 0): len(1) {
f[1] = (x + MOD) % MOD;
}
i64& operator [] (int x) { return f[x]; }
const i64& operator [] (int x) const { return f[x]; }
friend Info operator + (const Info &A, const Info &B) {
Info C;
C.len = A.len + B.len;
C[0] = 0;
for(int i = 0; i <= c; i++) {
for(int j = 0; i + j <= c; j++) {
(C[i + j] += A[i] * B[j]) %= MOD;
}
}
return C;
}
void apply(const Tag &v) {
if(v.flip) {
for(int i = 1; i <= c; i += 2) {
f[i] = MOD - f[i];
}
}
i64 x = v.add;
if(!x) return;
array<i64, c + 1> g{}, p{1};
for(int i = 1; i <= c; i++) {
p[i] = p[i - 1] * x % MOD;
}
for(int i = 0; i <= c; i++) {
for(int j = i; j <= min(len, c); j++) {
i64 add = bin[len - i][j - i] * p[j - i] % MOD * f[i] % MOD;
(g[j] += add) %= MOD;
}
}
f = g;
}
};
[ABC265G] 012 Inversion
本题中逆序对只有三种: \((1, 0)\),\((2, 0)\) 和 \((2, 1)\),因此一个初始的想法是序列信息中维护这三种数对的数量。但这是不够的,因为无法处理修改操作。于是修改成维护所有 \((x, y)\) 数对的数量(\(x, y \in \{0, 1, 2\}\))。
信息合并是简单的:数对 \((x, y)\) 存在三种情况:两个数都在左子区间;两个数都在右子区间;或一个数在左子区间,一个数在右子区间。我们发现现有的信息无法处理第三种情况的合并,还需要维护区间中 \(0, 1, 2\) 的个数,则第三种情况的数对数量就是左子区间中 \(x\) 的个数乘右子区间中 \(y\) 的个数。而这个新加的信息显然是容易合并的。
懒标记为三元组 \((p_0, p_1, p_2)\),表示区间中的 \(i\) 要变成 \(p_i\)(\(0 \le i < 3\))。懒标记之间的复合类似排列的复合(尽管 \(p\) 不一定是排列)。懒标记对序列信息的复合只需要暴力枚举二元组 \((x, y)\) 统计答案即可。
constexpr int w = 3;
struct Tag {
array<int, w> p{0, 1, 2};
Tag() = default;
Tag(int x, int y, int z) {
p[0] = x, p[1] = y, p[2] = z;
}
int& operator [] (int x) { return p[x]; }
const int& operator [] (int x) const { return p[x]; }
void apply(const Tag &v) {
array<int, w> q;
for(int i: {0, 1, 2}) {
q[i] = v[p[i]];
}
p = q;
}
};
struct Info {
array<int, w> cnt{};
array<array<i64, w>, w> cnt2{};
Info() = default;
Info(int x) {
cnt[x] = 1;
}
friend Info operator + (const Info &A, const Info &B) {
Info C;
for(int x: {0, 1, 2}) {
C.cnt[x] = A.cnt[x] + B.cnt[x];
for(int y: {0, 1, 2}) {
C.cnt2[x][y] = A.cnt2[x][y] + B.cnt2[x][y] + 1LL * A.cnt[x] * B.cnt[y];
}
}
return C;
}
void apply(const Tag &v) {
Info res;
for(int x: {0, 1, 2}) {
int nx = v[x];
res.cnt[nx] += cnt[x];
for(int y: {0, 1, 2}) {
int ny = v[y];
res.cnt2[nx][ny] += cnt2[x][y];
}
}
(*this) = res;
}
i64 getans() { // 用于统计答案
return cnt2[1][0] + cnt2[2][0] + cnt2[2][1];
}
};
练习题:
- SP1716 GSS3 - Can you answer these queries III 经典题目,单点修改区间最大子段和。