方差

线段树节点同时维护区间和与平方和,用懒标记支持区间加,方差由二阶矩公式 O(log n) 求出。

OJ: luogu

题目 ID: P1471

难度:提高

标签:线段树懒标记区间加方差浮点数

日期: 2026-07-16 23:59

形式化题目

有一个长度为 nn 的实数数列 aa,初始值给定。给出 mm 次操作:

  1. 对区间 [l,r][l,r] 内的每个位置加上实数 kk
  2. 询问区间 [l,r][l,r] 的平均数 A=1lenai\overline A = \frac{1}{len}\sum a_i
  3. 询问区间 [l,r][l,r] 的方差 s2=1len(aiA)2s^2 = \frac{1}{len}\sum(a_i - \overline A)^2

要求按顺序处理全部操作,每次询问输出一行实数,保留 4 位小数。

思路

先看一个可以直接验证想法的朴素解:

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-12 22:10
 * update_at: 2026-08-12 22:10
 */
// brute.cpp:小数据暴力解,直接按题意逐项区间加、暴力求平均数和方差,
// 用来理解题意并辅助对拍,复杂度 O(nm),只适合小数据。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;

int n, m;
double a[MAXN]; // a[i] 表示当前位置的当前值

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    cout << fixed << setprecision(4);

    while (m--) {
        int opt, l, r;
        cin >> opt >> l >> r;
        if (opt == 1) {
            double k;
            cin >> k;
            // 区间加:暴力逐项加。
            for (int i = l; i <= r; i++) {
                a[i] += k;
            }
        } else {
            // 查询:暴力扫描区间,同时累加和与平方和。
            double s = 0, s2 = 0;
            for (int i = l; i <= r; i++) {
                s += a[i];
                s2 += a[i] * a[i];
            }
            double len = r - l + 1;
            double avg = s / len;
            if (opt == 2) {
                cout << avg << '\n';
            } else {
                // 方差 = E(x^2) - (E(x))^2
                cout << s2 / len - avg * avg << '\n';
            }
        }
    }

    return 0;
}

brute.cpp 直接维护数组:操作 1 逐项加,操作 2、3 暴力扫描区间求和与平方和,单次操作 O(n)O(n),总复杂度 O(nm)O(nm),无法通过 10510^5 的数据。

关键观察是把方差改写成一阶矩与二阶矩的形式。设 xx 为在区间 [l,r][l,r] 内均匀随机取值的变量,则 E(x)=A=ailenE(x) = \overline A = \frac{\sum a_i}{len}E(x2)=ai2lenE(x^2) = \frac{\sum a_i^2}{len},由 Var(x)=E(x2)(E(x))2\mathrm{Var}(x) = E(x^2) - (E(x))^2

s2=1len(aiA)2=ai2len(ailen)2=E(x2)(E(x))2s^2 = \frac{1}{len}\sum(a_i - \overline A)^2 = \frac{\sum a_i^2}{len} - \left(\frac{\sum a_i}{len}\right)^2 = E(x^2) - (E(x))^2

这样查询只依赖区间的两个可合并量:和 S=aiS = \sum a_i 与平方和 Q=ai2Q = \sum a_i^2。其中 (E(x))2=(Slen)2(E(x))^2 = \left(\frac{S}{len}\right)^2SS 直接得到,好求;真正的难点在 E(x2)E(x^2) 里的平方和 QQ——区间加 kk 后每个元素都变了,Q=ai2Q = \sum a_i^2 该怎么更新?难道要逐项重算平方吗?

答案是不需要。对区间整段加 kk 时(区间长度 lenlen),把 QQ' 直接展开:

S=S+lenk,Q=(ai+k)2=(ai2+2kai+k2)=Q+2kS+lenk2S' = S + len \cdot k,\qquad Q' = \sum(a_i + k)^2 = \sum(a_i^2 + 2ka_i + k^2) = Q + 2k \cdot S + len \cdot k^2

QQ' 只由旧的 SSQQ 算出,不需要任何逐项信息,所以整段加法可以被节点懒标记完整吸收——这就是区间加线段树的封闭性:节点只维护 (S,Q,lazy)(S, Q, lazy),完全覆盖时整段更新,递归进入孩子前下传标记。

另一个必须满足的是合并:push_up 时父节点要由左右子树拼出,S=SL+SRS = S_L + S_RQ=QL+QRQ = Q_L + Q_R,两者都是普通加法,结合律显然成立,合并结果与区间如何分割无关,所以线段树任何一层把区间拆成两半再合并都不影响正确性。

以样例为例,三次询问对应的矩如下:

步骤 操作 区间 SS 平方和 QQ 输出
1 2 1 4 [1,4][1,4] 1212 4646 3.0000
2 3 1 5 [1,5][1,5] 1515 5555 2.0000
5 3 1 5 [1,5][1,5] 1515 4949 0.8000

观察第一行:平均数是 12/4=312/4 = 3;第二行方差 =55/5(15/5)2=119=2= 55/5 - (15/5)^2 = 11 - 9 = 2,正是 E(x2)(E(x))2E(x^2) - (E(x))^2。步骤 3、4 两次区间加后,平方和由 5555 变为 4949(每一步都由 Q=Q+2kS+lenk2Q' = Q + 2kS + len\cdot k^2 逐节点更新),第三行方差 =49/59=0.8= 49/5 - 9 = 0.8,与样例输出一致。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-12 22:10
 * update_at: 2026-08-15 22:00
 */
// main.cpp:P1471 正式解,线段树懒标记同时维护区间和与平方和,
// 支持区间加实数、查询区间平均值与方差,单次操作 O(log n)。
#include <bits/stdc++.h>
using namespace std;

// 区间加 + 区间和与平方和线段树(懒标记)
struct SegmentTreeRangeAdd {
    using T = double;

    // 线段树节点:s 为区间和,q 为平方和,lazy 为待下传的加法值
    struct Node {
        T s = 0;        // 当前区间的真实区间和 Σa[i]
        T q = 0;        // 当前区间的真实平方和 Σa[i]^2
        T lazy = 0;     // 待下传的加法值

        // 合并两个孩子:和相加、平方和相加,合并结果不携带懒标记
        Node operator+(const Node &other) const {
            return Node{s + other.s, q + other.q};
        }
    };

    // 左儿子 / 右儿子的节点编号
    static int lson(int p) { return p << 1; }
    static int rson(int p) { return p << 1 | 1; }

    // 区间 [l, r] 的中点
    static int mid(int l, int r) { return (l + r) >> 1; }

    int n = 0;              // 区间大小
    vector<Node> tree;      // 线段树数组

    SegmentTreeRangeAdd(int n = 0) {
        init(n);
    }

    void init(int size) {
        n = size;
        tree.assign(n * 4 + 5, Node{});
    }

    // 上推:用两个孩子合并出当前节点
    void push_up(int p) {
        tree[p] = tree[lson(p)] + tree[rson(p)];
    }

    // 把节点 p 的整个区间 [l, r] 的每个元素都加上 val。
    // 平方和:Σ(a[i]+val)^2 = Σa[i]^2 + 2*val*Σa[i] + len*val^2,
    // 注意先使用旧的 s 更新 q,再更新 s。
    void apply(int p, int l, int r, T val) {
        int len = r - l + 1;
        tree[p].q += 2 * val * tree[p].s + val * val * len;
        tree[p].s += val * len;
        tree[p].lazy += val;
    }

    // 下推:把节点 p 的加法懒标记传给两个孩子
    void push_down(int p, int l, int r) {
        if (tree[p].lazy == 0 || l == r) return;

        int m = mid(l, r);
        apply(lson(p), l, m, tree[p].lazy);
        apply(rson(p), m + 1, r, tree[p].lazy);
        tree[p].lazy = 0;
    }

    // 用数组 a 建树(下标从 1 开始)
    void build(const vector<T> &a, int l, int r, int p = 1) {
        if (l == r) {
            tree[p].s = a[l];
            tree[p].q = a[l] * a[l];
            return;
        }
        int m = mid(l, r);
        build(a, l, m, lson(p));
        build(a, m + 1, r, rson(p));
        push_up(p);
    }

    // 区间加:把 [ql, qr] 的每个元素都加上 val
    void add_range(int ql, int qr, T val, int l, int r, int p = 1) {
        if (ql <= l && r <= qr) {
            apply(p, l, r, val);
            return;
        }

        push_down(p, l, r);
        int m = mid(l, r);
        if (ql <= m) add_range(ql, qr, val, l, m, lson(p));
        if (qr > m) add_range(ql, qr, val, m + 1, r, rson(p));
        push_up(p);
    }

    // 区间查询:返回 [ql, qr] 的和与平方和
    Node query(int ql, int qr, int l, int r, int p = 1) {
        if (ql <= l && r <= qr) return tree[p];

        push_down(p, l, r);
        int m = mid(l, r);
        Node answer;
        if (ql <= m) answer = answer + query(ql, qr, l, m, lson(p));
        if (qr > m) answer = answer + query(ql, qr, m + 1, r, rson(p));
        return answer;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<double> a(n + 1);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    SegmentTreeRangeAdd seg(n);
    seg.build(a, 1, n);

    cout << fixed << setprecision(4); // 输出统一保留 4 位小数

    while (m--) {
        int opt, l, r;
        cin >> opt >> l >> r;
        if (opt == 1) {
            double k;
            cin >> k;
            seg.add_range(l, r, k, 1, n);
        } else {
            auto res = seg.query(l, r, 1, n);
            double len = r - l + 1;
            double avg = res.s / len;   // 平均值 = Σa / len
            if (opt == 2) {
                cout << avg << '\n';
            } else {
                // 方差 = E(x^2) - (E(x))^2 = Σa^2/len - (Σa/len)^2
                cout << res.q / len - avg * avg << '\n';
            }
        }
    }

    return 0;
}

复杂度

  • 时间:建树 O(n)O(n),区间加与两种查询各 O(logn)O(\log n),总 O(n+mlogn)O(n + m \log n)
  • 空间:sumsum2lazy 三个 4n4n 数组,O(n)O(n)

总结

遇到"区间修改 + 方差查询",套路是先把方差改写成一阶矩与二阶矩的组合,让查询只依赖两个满足合并律的量;再看区间加在这两个量上的更新是否封闭(QQ' 由旧 SSQQ 直接算出),封闭就能套懒标记线段树。rbook 的《线段树:区间赋值与区间查询》讲解了本解使用的懒标记骨架(apply / push / pull),本题把"赋值"语义换成"加法"并多维护一个平方和字段即可。

图示解析

这张 ASCII 图展示整道题的解题路线:

text
朴素模拟(brute.cpp)
  区间加逐项 a[i] += k,查询暴力扫描区间求平均/方差   O(n) 每次操作
        |
        | 瓶颈:逐项访问区间,m 次操作 O(n*m) 太大
        v
关键观察(改写方差)
  s^2 = E(x^2) - (E(x))^2
  查询只依赖两个可合并量:和 S = Σa_i,平方和 Q = Σa_i^2
        |
        | 整段加 k 是否封闭?
        v
封闭性验证
  S' = S + len*k
  Q' = Q + 2*k*S + len*k^2     只依赖旧 (S, Q),不需要逐项信息
        |
        v
懒标记线段树(main.cpp)
  节点维护 (sum, sum2, lazy)
  完全覆盖:apply 整段更新并叠加标记
  进入孩子前:push 下传标记,回溯 pull 重新合并
  查询:平均数 = S/len,方差 = Q/len - (S/len)^2
        |
        v
复杂度 O((n + m) log n),空间 O(n)

图中四层对应"暴力慢在哪"“方差如何退化成一阶、二阶矩”“整段加为何能被节点吸收”“线段树如何把修改压缩到 O(logn)O(\log n) 个节点”。核心是把"逐项加 + 逐项求平方差"的困难,转换成"两个可合并矩的封闭更新",难度就从逐项模拟降为懒标记区间维护。