NeFut Logo NeFut
EN 管理员登录

深入理解线段树:原理、实现与常见陷阱

发布于:2026-05-29 00:44 最后更新:2026-06-06 13:04
#algorithm #C++ #Segment Tree

核心逻辑与数学原理

线段树(Segment Tree)本质上是一种基于分治(Divide and Conquer)思想的二叉搜索树,用于维护区间信息。它将一个长度为 $N$ 的线性区间划分成若干个对数级别的子区间,每个节点代表一个闭区间 $[L, R]$。

空间拓扑结构

对于当前节点编号为 $u$,其左子节点编号为 $u \ll 1$(即 $2u$),右子节点编号为 $u \ll 1 | 1$(即 $2u + 1$)。 叶子节点满足 $L = R$,代表单点。非叶子节点的划分点为 $M = (L + R) \gg 1$,左子树维护 $[L, M]$,右子树维护 $[M + 1, R]$。

为了保证完全二叉树的索引不越界,数组空间必须严格开辟到 $4N$。

渐进复杂度证明


状态设计与算法推导

1. 分治建树

定义递归函数 build(u, l, r)。 若 $l = r$,说明到达叶子节点,直接读取原数组数据赋值:

$$tree[u] = a[l]$$

否则,令 $m = (l + r) \gg 1$,递归构建左右子树,最后执行 pushup(u)

$$tree[u] = tree[u \ll 1] + tree[u \ll 1 | 1]$$

2. 单点更新

定义递归函数 update(u, l, r, pos, val)。 通过二分位置 pos 锁定分支。若 $pos \le m$,递归左子树;否则递归右子树。 触及叶子节点(即 $l = r = pos$)时执行修改:

$$tree[u] = val$$

回溯时,必须沿着更新路径逐层调用 pushup(u) 更新祖先节点的值。

3. 区间二分求和

定义递归函数 query(u, l, r, ql, qr)。 当前节点区间 $[l, r]$ 与查询区间 $[ql, qr]$ 存在三种关系:

  1. $[l, r] \subseteq [ql, qr]$:当前区间被完全包含,直接返回 $tree[u]$。
  2. $[l, r] \cap [ql, qr] = \emptyset$:当前区间与查询区间无交集,返回 $0$。
  3. 部分交集:令 $m = (l + r) \gg 1$。 若 $ql \le m$,累加左子树贡献:query(u << 1, l, m, ql, qr)。 若 $qr > m$,累加右子树贡献:query(u << 1 | 1, m + 1, r, ql, qr)

C++ 标准源码(NOIP风格)

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// 严格遵循 NOIP/Linux 环境规范,禁用万能头,大数据量下必须使用快读
inline long long read() {
    long long x = 0, f = 1;
    char ch = getchar();
    while (ch < '0' || ch > '9') {
        if (ch == '-') f = -1;
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9') {
        x = (x << 3) + (x << 1) + (ch ^ 48);
        ch = getchar();
    }
    return x * f;
}

const int MAXN = 100005;
long long tree[MAXN << 2]; // 致命踩坑点:线段树数组大小必须开到原数组的 4 倍
long long a[MAXN];

inline void pushup(int u) {
    tree[u] = tree[u << 1] + tree[u << 1 | 1];
}

void build(int u, int l, int r) {
    if (l == r) {
        tree[u] = a[l];
        return;
    }
    int m = (l + r) >> 1;
    build(u << 1, l, m);
    build(u << 1 | 1, m + 1, r);
    pushup(u);
}

void update(int u, int l, int r, int pos, long long val) {
    if (l == r) {
        tree[u] = val; // 找到目标单点,直接覆盖或累加
        return;
    }
    int m = (l + r) >> 1;
    if (pos <= m) {
        update(u << 1, l, m, pos, val);
    } else {
        update(u << 1 | 1, m + 1, r, pos, val);
    }
    pushup(u); // 回溯时必须更新祖先节点,否则查询数据断层
}

long long query(int u, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) {
        return tree[u]; // 当前区间被完全包含,直接返回
    }
    int m = (l + r) >> 1;
    long long res = 0;
    if (ql <= m) {
        res += query(u << 1, l, m, ql, qr);
    }
    if (qr > m) {
        res += query(u << 1 | 1, m + 1, r, ql, qr);
    }
    return res;
}

int main() {
    // 优化标准流性能
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

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

    build(1, 1, n);

    for (int i = 0; i < m; ++i) {
        int opt = read();
        if (opt == 1) {
            int pos = read();
            long long val = read();
            update(1, 1, n, pos, val);
        } else if (opt == 2) {
            int ql = read();
            int qr = read();
            printf("%lld\n", query(1, 1, n, ql, qr));
        }
    }
    return 0;
}

NOIP 实战避坑指南

1. 数组越界与物理空间不足

线段树是满二叉树的退化形式。在线性区间长度为 $N$ 时,最坏情况下末端叶子节点的下标会逼近 $4N$。 若仅开辟 $2N$ 或 $3N$ 空间,代码在处理 $N$ 为非 $2$ 的幂次且数据偏向右侧子树时,执行 u << 1 | 1 会直接触发 Segmentation Fault 或死锁。必须严格声明 `MAXN << 2

2. 运算符优先级灾难

写线段树常使用位运算提升效率。 注意:+- 的优先级高于 <<>>。 错误示范:int m = l + r >> 1;(等价于 (l + r) >> 1,此行无误)。 错误示范:build(u << 1, l, m + 1); 中若误写为 u << 1 + 1,由于 + 优先,实际执行的是 u << (1 + 1)u << 2涉及位运算时,必须强制加括号,如 (u << 1) | 1

3. 数据溢出

区间求和题目中,即使原数组都在 int 范围内,累加后的答案、单点修改的增量极易突破 $2^{31}-1$。线段树数组 tree[] 以及查询函数的返回值必须全部声明为 `long long`。


经典 NOIP/洛谷 真题

1. 洛谷 P1303 / 基础改编版:单点修改与区间查询基准

2. 洛谷 P7075 [CSP-S 2020] 儒略日 / 树形数据检索化点

原文链接: local://6.1

[h] 返回首页