实战:线段树(区间查询+区间更新)

线段树,说实话,是很多C++选手的「噩梦」。

我第一次接触它是在大学算法课上,当时觉得这玩意儿不就是个二叉树吗?有什么难的。结果自己手写的时候,边界条件、懒标记、递归深度……各种问题接踵而至。后来在项目中做区间统计功能,才真正体会到它的威力。

今天我们就来彻底搞懂它。

什么是线段树?

线段树是一种二叉树结构,专门用来处理数组的区间问题。比如你有一个长度为N的数组,需要频繁做两件事:

  • 区间查询:求某个区间 [L, R] 的和、最大值、最小值等
  • 区间更新:把某个区间 [L, R] 的所有元素都加上一个值

如果用普通数组,更新是O(1),但查询是O(N)。如果数据量一大,比如10万次操作,直接卡死。

线段树能把这两个操作都优化到 O(log N)。怎么做到的?说白了就是「分而治之」——把大区间不断对半切,直到每个叶子节点代表一个元素。

核心思想:每个节点存储一个区间的信息,左右孩子分别存储左右子区间的信息。更新和查询时,只遍历必要的路径。

线段树的结构

我习惯用数组来模拟二叉树。根节点下标为1,左孩子是2*i,右孩子是2*i+1。这样不需要指针,内存连续,访问速度快。

假设数组长度为N,线段树需要开多大?

  • 最坏情况:N是2的幂,树高为 log₂N,节点数约 2N
  • 一般情况:开 4*N 绝对安全

嗯,这里要注意:一定要开4倍空间。我曾经因为只开了2倍,在边界测试时数组越界,排查了半天才发现是空间不够。

懒标记(Lazy Propagation)

区间更新如果不做优化,每次都要更新到叶子节点,那复杂度又回到O(N)了。

懒标记的思路很简单:先欠着,等查询的时候再还

具体来说:

  • 更新一个区间时,如果当前节点完全被覆盖,就只更新这个节点的值,并打上一个「懒标记」
  • 下次访问这个节点时,先把懒标记下传给子节点,再继续操作

这样做的好处是:更新操作只影响O(log N)个节点,而不是全部叶子。

我的经验:懒标记是线段树的灵魂。很多初学者觉得它复杂,其实你只要记住一句话——「用的时候再下传」。就像你欠了债,债主没来要,你就先欠着;债主来了,你再还。

代码实现

下面我给出一个完整的线段树实现,支持区间加法和区间求和。代码里我加了详细的注释,方便你理解。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int a[MAXN];          // 原始数组
long long tree[4 * MAXN];  // 线段树
long long lazy[4 * MAXN];  // 懒标记

// 建树
void build(int node, int l, int r) {
    if (l == r) {
        tree[node] = a[l];
        return;
    }
    int mid = (l + r) / 2;
    build(node * 2, l, mid);
    build(node * 2 + 1, mid + 1, r);
    tree[node] = tree[node * 2] + tree[node * 2 + 1];
}

// 下传懒标记
void push_down(int node, int l, int r) {
    if (lazy[node] != 0) {
        int mid = (l + r) / 2;
        // 更新左孩子
        tree[node * 2] += lazy[node] * (mid - l + 1);
        lazy[node * 2] += lazy[node];
        // 更新右孩子
        tree[node * 2 + 1] += lazy[node] * (r - mid);
        lazy[node * 2 + 1] += lazy[node];
        // 清除当前节点懒标记
        lazy[node] = 0;
    }
}

// 区间更新:[L, R] 加上 val
void update(int node, int l, int r, int L, int R, long long val) {
    if (L <= l && r <= R) {
        tree[node] += val * (r - l + 1);
        lazy[node] += val;
        return;
    }
    push_down(node, l, r);
    int mid = (l + r) / 2;
    if (L <= mid) update(node * 2, l, mid, L, R, val);
    if (R > mid) update(node * 2 + 1, mid + 1, r, L, R, val);
    tree[node] = tree[node * 2] + tree[node * 2 + 1];
}

// 区间查询:[L, R] 的和
long long query(int node, int l, int r, int L, int R) {
    if (L <= l && r <= R) {
        return tree[node];
    }
    push_down(node, l, r);
    int mid = (l + r) / 2;
    long long res = 0;
    if (L <= mid) res += query(node * 2, l, mid, L, R);
    if (R > mid) res += query(node * 2 + 1, mid + 1, r, L, R);
    return res;
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (m--) {
        int op, L, R;
        cin >> op >> L >> R;
        if (op == 1) {
            long long val;
            cin >> val;
            update(1, 1, n, L, R, val);
        } else {
            cout << query(1, 1, n, L, R) << endl;
        }
    }
    return 0;
}

核心逻辑流程图

下面我用一张SVG图来展示线段树区间更新的核心流程。你仔细看,懒标记的传递路径非常清晰。

线段树区间更新流程(懒标记) 根节点 [1,8] 左孩子 [1,4] 右孩子 [5,8] [1,2] [3,4] [5,6] [7,8] [1,1] [2,2] [3,3] [4,4] [5,5] [6,6] [7,7] [8,8] 懒标记传递规则 更新区间 [3,6] 时:先找到 [3,4] 和 [5,6] 两个节点,打上懒标记,不继续下传 查询时如果访问到带懒标记的节点,先下传再继续

避坑指南

我曾经在写线段树时踩过不少坑,这里列几个最常见的:

坑1:递归边界写错

我曾经在update函数里把 if (L <= l && r <= R) 写成了 if (L <= l || r <= R),结果整个树都乱了。记住:完全覆盖才直接返回

坑2:忘记下传懒标记

在query函数里,如果当前节点有懒标记但没有下传,查询结果就是错的。我调试了整整一个下午才发现是push_down没调用。所以:每次递归前都要push_down

坑3:数组越界

线段树数组大小一定要开4倍。我见过有人开2倍,结果在N=100000时直接段错误。别问我怎么知道的……

复杂度分析

操作 时间复杂度 空间复杂度
建树 O(N) O(4N)
区间查询 O(log N) O(1) 额外
区间更新 O(log N) O(1) 额外

你想想看,如果N=100000,普通数组做10万次区间更新+查询,复杂度是O(N²),根本跑不动。而线段树只需要O(N log N),差距是数量级的。

总结

线段树的核心就两件事:分治 + 懒标记

  • 分治让查询和更新都只走一条路径,复杂度降到log级别
  • 懒标记让区间更新不用动所有叶子,效率大幅提升

我个人建议你先把上面的代码手敲一遍,跑几个测试用例。然后试着改成求最大值、最小值,或者支持区间赋值操作。等你把代码改熟了,线段树就不再是噩梦,而是你的利器。

一句话记住:线段树就是「用空间换时间,用懒标记换效率」。

公众号:蓝海资料掘金营,微信deep3321