[线段树进阶〇]“抽象”化的线段树

[线段树进阶〇] “抽象”化的线段树

线段树是算法竞赛中最基础的数据结构,本系列文章将介绍线段树的一些进阶技巧。

在这之前,我们有必要深入理解线段树的本质,把线段树的结构抽象出来。这对我们理解算法的实质是有很大帮助的。

区间修改区间查询问题

线段树解决的问题一般具有以下形式:给定序列 \(a\),集合 \(S\)(可以理解为序列元素的类型),\(S\) 中的元素可以相加,支持两种操作:

  1. 区间修改:给定区间 \([l, r]\)\(\forall l \le i \le r\),令 \(a_{i} \gets f(a_i)\),其中 \(f: S \mapsto S\) 是给定的修改函数。
  2. 区间查询:给定区间 \([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)函数会调用以上函数,但其它部分与维护的信息具体是什么完全无关

也就是说,设计一棵线段树,本质上只用设计三个操作:

  1. 序列信息合并,记为函数 \(\operatorname{apply}(D, D) \to D\)
  2. 懒标记对懒标记的复合,记为函数 \(\operatorname{compose}(T, T) \to T\)
  3. 懒标记对序列信息的复合,记为函数 \(\operatorname{compose}(T, D) \to D\)

我们不妨以基础问题:区间加区间求和为例。在此问题中,序列信息是二元组 \((l, s)\),分别表示区间长度和区间和,修改信息是 \(x\),表示这个区间中的每个元素需要加上 \(x\)。那么三个操作可以这样设计:

  1. 序列信息合并:区间长度以及区间和都可以直接相加,即

    \[\operatorname{apply}((l_1, s_1), (l_2, s_2)) = (l_1 + l_2, s_1 + s_2) \]

  2. 懒标记对懒标记的复合:一个区间加上 \(x\) 再加上 \(y\),等价于同时加上 \(x + y\),即

    \[\operatorname{compose}(y, x) = y + x \]

    (注意:习惯上复合操作的变量从右往左书写,即 \(\operatorname{compose}(y, x)\) 表示先作用 \(x\) 再作用 \(y\),虽然在此例中顺序不重要)

  3. 懒标记对序列信息的复合:一个区间整体加上 \(x\),对区间长度没有影响,而区间和加上 \(l \cdot x\),即

    \[\operatorname{compose}(x, (l, s)) = (l, s + l \cdot s) \]

为了方便,我们也可以使用加号 \(+\) 表示信息合并,用乘号 \(\times\) 表示信息复合。需要注意的是,这两个符号并不表示实数的加法和乘法。

双半群模型

上一节中我们把线段树维护的信息抽象了出来,现在我们进一步探讨这些信息应该满足什么性质。

序列信息结合律

在线段树合并信息的过程中,我们可能要把一个查询的区间拆成两个区间,然后合并两个子区间的信息。也就是说,设查询区间为 \([l, r]\),区间中点为 \(m\),则以下式子需要满足:

\[a_l + a_{l + 1} + \cdots + a_{r} = (a_{l} + a_{l + 1} + \cdots + a_{m}) + (a_{m + 1} + a_{m + 2} + \cdots + a_{r}) \]

也就是序列信息需要满足结合律。

以下是几个例子:

  1. 给定一个方阵序列,我们可以用线段树维护区间矩阵积,因为矩阵乘法满足结合律。(注意区分结合律和交换律:序列信息并不需要满足交换律。)
  2. 不满足结合律的一个例子是与非(\(\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)\)

分配律

下放懒标记之后,当前区间的序列信息不变,而子区间序列信息复合上了当前区间的懒标记。从逻辑上讲,子区间序列信息复合懒标记以后再合并,应该和子区间序列信息合并后再复合懒标记相等,即

\[td_1 + td_2 = t(d_1 + d_2) \]

也就是复合对合并有分配律。

幺元

没有进行任何修改操作时,所有节点的懒标记应该为空,因此我们需要一个元素 \(\varepsilon \in T\) 表示“什么都不做”,称为幺元。根据定义,此元素应该满足 \(\varepsilon t = t \varepsilon = t\)

在有的线段树实现中,需要查询空区间。我们同样使用幺元 \(\varepsilon \in D\) 表示空区间的序列信息,满足 \(\varepsilon + d = d + \varepsilon = d\)

例如,在区间加区间求和问题中,懒标记的和区间信息的幺元都是 \(0\)。如果序列信息是区间最大值,则幺元为 \(-\infty\)


在抽象代数中,如果一个集合 \(S\) 和其上的二元运算 \(+: S + S \to S\) 满足以下条件,则集合和运算构成的二元组 \((S, +)\) 称为半群

  1. 封闭性:\(\forall a, b \in S\)\(a + b \in S\)
  2. 结合律:\((a + b) + c = a + (b + c)\)

如果存在元素 \(\varepsilon \in S\) 满足 \(a + \varepsilon = \varepsilon + a = a\),则这个半群也被称为幺半群\(\varepsilon\) 被称为幺元

我们发现,序列信息和懒标记分别都满足幺半群的定义,所以我们可以用两个半群 \((D, +)\)\((T, \times)\) 刻画序列信息和懒标记。这两个半群之间还需要满足上文所述的一些条件,也就是有联动性。我们把这样用两个半群刻画线段树信息的结构称为双半群模型。设计一棵线段树就本质上就是设计双半群模型。

如果你对用抽象代数的语言描述数据结构感到困惑,可以阅读本文的例题部分,结合实际的例子来理解。

代码实现

上文提到,把维护的信息抽象出来之后,线段树的实现和信息的具体内容完全无关。因此我们自然想要写出这样一份代码,把信息的设计和线段树的实现分离开,其中线段树的实现不依赖信息的设计。

这里先规范代码实现中的一些细节问题:

  1. 所有区间都是闭区间。(另一种常用的方式是左闭右开区间。)
  2. 序列下标从 \(1\) 开始。
  3. 使用 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 类型 TTInfo 的转换根据具体问题而定。在区间加区间求和问题中,传入的 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);
    }
};

以下是区间加区间求和模板题 LOJ#132参考代码

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 比赛中,由于不能直接复制板子,当 InfoTag 比较简单时,这种写法比较冗长。但信息比较复杂时,把线段树主体的实现和信息的实现分离开的写法,有助于编写和调试代码。

真正使用时,为了方便,可以对此模板作适当修改,例如当序列信息和懒标记比较简单时,可以不封装懒标记。如果不存在修改操作,或只有单点修改,可以简化一部分代码。此外,这个板子的线段树建树是用构造函数实现的,这样就不方便定义全局变量的线段树。如果要使用全局线段树,需要把构造函数修改成一般的成员函数。

时空复杂度分析

空间复杂度

一般情况下,线段树中每个节点的空间占用都是 \(O(1)\),那么整棵线段树的空间占用只和节点个数线性相关。

在初学者中常见的误区是线段树的空间复杂度是 \(O(n \log n)\),而实际上是 \(O(n)\)。最简单的分析方法是列出递归式:设 \(T(n)\) 表示长度为 \(n\) 的序列建出的线段树的节点个数,则

\[T(n) = 2T\left(\frac{n}{2}\right) + O(1) \]

根据主定理,\(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\),线段树节点数量为

\[s = 2^{h + 1} - 1 = 2^{\lceil \log_2 n \rceil + 1} - 1 \]

分析上界:

\[\begin{aligned} s &= 2 \times 2^{\lceil \log_2 n \rceil} - 1 \\ &\le 4 \times 2^{\lfloor \log_2 n \rfloor} - 1 \\ &< 4n \end{aligned} \]

也即线段树节点数量不超过 \(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\) 的个数之和,关键在于连续段长度。把两个子区间拼到一起,大区间的最长连续段有三种情况:

  1. 完全在左子区间中;
  2. 完全在右子区间中;
  3. 跨越了两个区间。

前两种情况是容易维护的。对于第 3 种情况,现有的序列信息无法处理,所以必须增加维护的信息数量。如果一个最长 \(1\) 连续段(\(0\) 连续段同理)跨越了两个区间,那么它一定是由左区间的极长连续 \(1\) 后缀和右区间的极长连续 \(1\) 前缀拼接起来。

因此,我们还要维护一个区间极长的 \(0\)\(1\) 前后缀长度。新增了维护的信息后,还要考虑新增信息如何合并。我们只需研究极长 \(1\) 前缀信息怎么合并,后缀是对称的。只有两种情况:

  1. 如果左子区间种所有元素都是 \(1\),那么大区间的极长 \(1\) 前缀是整个左子区间和右子区间的极长 \(1\) 前缀拼起来。
  2. 否则大区间的极长 \(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}\)

\[f_{k} = \sum_{i = 0}^{k} a_{i}b_{k - i} \]

接下来考虑懒标记的设计。自然想到用两个变量 \((x, y)\) 分别表示是否要取反整个区间,以及区间加的值。区间取反相当于区间乘上 \(-1\),所以这个懒标记和区间乘区间加的线段树是相同的。

现在只需考虑懒标记对序列信息的复合。取反标记的影响是简单的:对任意选出若干个数相乘的方案,如果选择了偶数个数,则乘积不变,否则乘积变为原来的相反数。所以对所有的奇数 \(k\),令 \(f_k \gets -f_k\) 即可。

难点在于加法标记。设区间长度为 \(n\)(注意:不是整个序列的长度),假设从中选择 \(m\) 个数相乘。不失一般性地,设选择的数为 \(a_1, a_2, \cdots, a_m\)。把区间中的所有数加上 \(x\) 之后,乘积变为

\[(a_1 + x)(a_2 + x)\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\) 即可计算这个展开式的值:

\[\sum_{i = 0}^{m} g_{i}x^{m - 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}\) 的贡献为

\[\binom{n - i}{m - i} x^{m - i} \prod_{p \in S} a_{p} \]

已知选择 \(i\) 个数相乘的所有方案乘积之和为 \(f_{i}\),所以大小为 \(i\) 的集合对 \(f'_{m}\) 的贡献之和为

\[\binom{n - i}{m - i} x^{m - i} f_{i} \]

综上所述,枚举 \(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];
    }
};

完整代码


练习题:

参考资料

posted @ 2025-07-24 22:11  DengStar  阅读(152)  评论(0)    收藏  举报