您当前的位置:首页 > 算法研究

C++线段树为什么这么难写?带你手撕代码模板

时间:2026-05-03 16:42:17  来源:  作者:

如果你也有类似的经历,恭喜你,你不是一个人。线段树被誉为“数据结构的分水岭”——迈过去,区间问题对你来说将如履平地;迈不过去,你可能会在“能看懂但写不出来”的泥潭里挣扎很久。

本文用 C++ 作为实现语言,从零开始,手撕一份“拿来就能改,改了就能用”的线段树代码模板。全文约2000字,包含基础版、懒标记版、以及 C++ 特有的封装技巧。读完这篇,希望你能自信地说出:“线段树,不过如此。”

一、线段树到底难在哪儿?

在动笔之前,我们先复盘一下线段树让初学者自闭的几个核心痛点:

1. 递归的“递”与“归”容易乱

线段树基于完全二叉树,建树、更新、查询都采用递归写法。很多人能理解“递下去”,但回溯时的合并(pushUp)和标记下推(pushDown)的顺序一多,大脑就栈溢出了。

2. 边界条件极其敏感

以区间 [l, r] 为例,mid = (l + r) / 2 后,左孩子区间是 [l, mid],右孩子是 [mid+1, r]。如果写成 [l, mid-1][mid, r],要么漏掉元素,要么死递归。这种错误肉眼很难发现。

3. 懒标记的“借贷”逻辑

区间更新如果不加懒标记,复杂度会退化为 O(n)。懒标记的本质是“先欠着,等需要时再还”。但“什么时候下传、下传给谁、下传后如何清除”这三个问题,至少能让初学者错三次。

4. C++ 特有的内存和语法陷阱

  • 数组开多大?4 * N 是经验值,有人开小了导致越界 RE。

  • 递归深度:极端情况下树高约 log N,一般不会爆栈,但如果你在递归里写了死循环,那就另说了。

  • 全局数组 vs 动态分配:用 vector 动态扩容更安全,但很多人习惯 int tree[maxn<<2]

正因为这些难点,线段树才需要一套稳定的代码模板。下面我们就从最基础的版本开始,逐步构建一个健壮的 C++ 线段树。

二、基础框架:不带懒标记的单点修改 + 区间查询

这个版本是线段树的“Hello World”。我们用 C++ 数组实现,支持:

  • 建树(build)

  • 单点更新(update)

  • 区间查询(query,例如区间求和)

2.1 数组存储与命名约定

cpp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e5 + 5;
int a[MAXN];          // 原数组(1-indexed 方便处理)
int tree[4 * MAXN];   // 线段树数组

// 合并操作:对于区间求和,就是左右孩子相加
void pushUp(int p) {
    tree[p] = tree[p << 1] + tree[p << 1 | 1];
}

这里 p << 1 等价于 p * 2p << 1 | 1 等价于 p * 2 + 1,位运算写法在竞赛代码中很常见。

2.2 建树(build)

cpp
void build(int p, int l, int r) {
    if (l == r) {
        tree[p] = a[l];
        return;
    }
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);
    build(p << 1 | 1, mid + 1, r);
    pushUp(p);
}

关键点:只有叶子节点(l == r)才直接赋值,内部节点通过 pushUp 由孩子算出。

2.3 单点更新

cpp
void update(int p, int l, int r, int pos, int val) {
    if (l == r) {
        tree[p] = val;   // 如果是“加上val”则写 tree[p] += val
        return;
    }
    int mid = (l + r) >> 1;
    if (pos <= mid) update(p << 1, l, mid, pos, val);
    else update(p << 1 | 1, mid + 1, r, pos, val);
    pushUp(p);
}

2.4 区间查询

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

这个基础版本已经能解决不少题目(例如 HDU 1166 敌兵布阵)。但一旦要求区间修改(比如把 [L,R] 内每个数都加上 v),这个版本就会超时,因为每次更新都要递归到所有叶子。于是,懒标记(Lazy Propagation)登场了。

三、进阶核心:区间修改 + 懒标记(最难啃的骨头)

懒标记的精髓:当要修改的区间完全覆盖当前节点区间时,不递归到孩子,而是:

  1. 更新当前节点的 tree 值(注意要乘以区间长度)

  2. 在当前节点上打一个标记,表示“我的孩子还没有更新,以后用到时再下传”

3.1 增加懒标记数组

cpp
int lazy[4 * MAXN];   // 懒标记,0 表示没有未下传的更新

3.2 下推操作(pushDown)

这是最容易写错的地方。以下是一个标准的“区间加”的 pushDown 实现:

cpp
void pushDown(int p, int l, int r) {
    if (lazy[p] != 0) {
        int mid = (l + r) >> 1;
        int left = p << 1, right = p << 1 | 1;
        
        // 左孩子接收标记
        lazy[left] += lazy[p];
        tree[left] += lazy[p] * (mid - l + 1);
        
        // 右孩子接收标记
        lazy[right] += lazy[p];
        tree[right] += lazy[p] * (r - mid);
        
        // 清除当前节点标记
        lazy[p] = 0;
    }
}

三处易错点

  • 孩子节点的 tree 增加的值必须乘以孩子区间长度,而不是父节点长度。

  • 标记是累加+=),不是赋值,因为可能之前已经有未下传的标记。

  • 下传后一定要把当前标记清零。

3.3 区间更新(updateRange)

cpp
void updateRange(int p, int l, int r, int ul, int ur, int val) {
    if (ul <= l && r <= ur) {
        tree[p] += val * (r - l + 1);
        lazy[p] += val;
        return;
    }
    pushDown(p, l, r);   // 关键!进入孩子前先下传
    int mid = (l + r) >> 1;
    if (ul <= mid) updateRange(p << 1, l, mid, ul, ur, val);
    if (ur > mid)  updateRange(p << 1 | 1, mid + 1, r, ul, ur, val);
    pushUp(p);
}

3.4 带懒标记的区间查询

查询时同样需要先 pushDown,确保访问到的孩子数据是最新的。

cpp
int queryRange(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) {
        return tree[p];
    }
    pushDown(p, l, r);
    int mid = (l + r) >> 1;
    int res = 0;
    if (ql <= mid) res += queryRange(p << 1, l, mid, ql, qr);
    if (qr > mid)  res += queryRange(p << 1 | 1, mid + 1, r, ql, qr);
    return res;
}

至此,一个支持区间加、区间求和的线段树就完成了。总代码大约 80~100 行。如果你能熟练默写以上结构,你已经击败了 70% 的初学者。

四、C++ 封装:用结构体让你告别全局变量

上述代码中,treelazya 都是全局数组,在单题中没问题,但如果一道题有多个线段树实例(例如对不同的数组分别维护),全局方式就显得笨拙。C++ 的 classstruct 可以完美封装线段树,而且能利用构造函数自动分配内存。

下面给出一个使用 vector 的封装版,支持任意长度的数组,并且模板参数可以指定“合并操作”。

cpp
template<typename T>
class SegTree {
private:
    int n;
    vector<T> tree, lazy;
    vector<T> *arr;   // 指向原数组的指针(可选)
    
    void pushUp(int p) {
        tree[p] = tree[p<<1] + tree[p<<1|1];
    }
    
    void pushDown(int p, int l, int r) {
        if (lazy[p] != 0) {
            int mid = (l + r) >> 1;
            int left = p<<1, right = p<<1|1;
            lazy[left] += lazy[p];
            tree[left] += lazy[p] * (mid - l + 1);
            lazy[right] += lazy[p];
            tree[right] += lazy[p] * (r - mid);
            lazy[p] = 0;
        }
    }
    
    void build(int p, int l, int r) {
        if (l == r) {
            tree[p] = (*arr)[l];   // 注意原数组索引从1开始
            return;
        }
        int mid = (l + r) >> 1;
        build(p<<1, l, mid);
        build(p<<1|1, mid+1, r);
        pushUp(p);
    }
    
    void update(int p, int l, int r, int ul, int ur, T val) {
        if (ul <= l && r <= ur) {
            tree[p] += val * (r - l + 1);
            lazy[p] += val;
            return;
        }
        pushDown(p, l, r);
        int mid = (l + r) >> 1;
        if (ul <= mid) update(p<<1, l, mid, ul, ur, val);
        if (ur > mid) update(p<<1|1, mid+1, r, ul, ur, val);
        pushUp(p);
    }
    
    T query(int p, int l, int r, int ql, int qr) {
        if (ql <= l && r <= qr) return tree[p];
        pushDown(p, l, r);
        int mid = (l + r) >> 1;
        T res = 0;
        if (ql <= mid) res += query(p<<1, l, mid, ql, qr);
        if (qr > mid) res += query(p<<1|1, mid+1, r, ql, qr);
        return res;
    }
    
public:
    // 构造函数:传入原数组(下标从1开始有效)和大小
    SegTree(vector<T>& _arr, int _n) {
        n = _n;
        arr = &_arr;
        tree.resize(4 * n + 5, 0);
        lazy.resize(4 * n + 5, 0);
        build(1, 1, n);
    }
    
    void rangeAdd(int l, int r, T val) {
        update(1, 1, n, l, r, val);
    }
    
    T rangeSum(int l, int r) {
        return query(1, 1, n, l, r);
    }
};

使用示例:

cpp
vector<int> v = {0, 1, 2, 3, 4, 5}; // 1-indexed,v[0]占位
SegTree<int> st(v, 5);
st.rangeAdd(2, 4, 10);
cout << st.rangeSum(1, 5) << endl;

封装的好处:代码复用性高,且可以通过模板支持 intlong long 等类型,未来升级成区间最大值、区间赋值等也只需修改 pushUppushDown 内部的运算。

五、常见错误与调试心法(血的教训)

  1. 数组越界
    线段树开 4*N 是底线,但如果你使用了 vector 动态扩容,记得 resize(4*n+5)。如果开小了,可能在递归很深时访问越界,导致 RE 或莫名 WA。

  2. pushDown 位置错误
    黄金法则:任何需要进入孩子分支的地方,必须先 pushDown(除非你能绝对保证当前节点没有待下传的标记)。查询和更新都需要。

  3. 区间长度计算错误
    tree[left] += lazy[p] * (mid - l + 1) 这里的 (mid - l + 1) 是左儿子区间的长度,有人误写成 (r - l + 1)(r - mid),导致数值错误。

  4. 递归爆栈
    一般不会,因为树高只有约 20(N=1e5)。但如果写错了递归终止条件导致无限递归,就会爆栈。解决:在递归函数开头加 if (l > r) return; 作为防御。

  5. 多组数据时没有清空 lazy
    如果使用全局数组,每组新数据前要 memset(tree, 0, sizeof tree)memset(lazy, 0, sizeof lazy)。用 vector 封装则没有这个问题。

调试技巧:

  • 对极端小数据(n=1,2,3)手动模拟递归过程,打印每个节点的 (l,r) 和值。

  • 写一个暴力函数(直接循环计算区间和)与线段树结果对拍。

  • pushUppushDown 里用 static int cnt 计数,观察调用次数是否合理。

六、从模板到实战:下一步学什么?

掌握了“区间加、区间求和”模版后,你已经可以解决掉大部分难度在“提高-”级别的线段树题。但真正的挑战还在后面:

  • 区间修改类型变化:区间赋值、区间乘、区间开方——每种操作对应的 pushDown 逻辑都不同。对于“区间加乘混合”,需要两个标记并规定顺序(一般是先乘后加)。

  • 维护更多信息:区间最大子段和、区间最大公约数、区间最值 + 最值个数。你需要定义 struct Node 并在 pushUp 中合并。

  • 可持久化线段树(主席树):在静态数组上支持查询历史版本,是线段树的又一进阶。

无论多么复杂,只要你把本文的基础骨架刻进脑子里,所有的扩展都只是往骨架里填充不同的业务逻辑。

七、结语:手撕线段树,从这一刻开始

C++ 线段树之所以难写,是因为它同时对递归思维、边界控制、懒标记逻辑和 C++ 语法提出了不低的要求。但它也是回报率极高的数据结构——一旦你亲手写出了第一个 AC 的线段树,你会发现以前觉得头疼的区间问题,现在都能用一套统一的框架优雅地解决。

最后,送你一份“默写检查清单”:

  • build 函数:递归终止 l==r,记得 pushUp

  • pushDown 函数:非空标记时更新孩子 tree 和 lazy,乘以对应长度,最后清空当前标记

  • updateRange 函数:完全覆盖则打标记并返回;否则 pushDown,递归左右,最后 pushUp

  • queryRange 函数:完全覆盖则返回;否则 pushDown,递归左右,合并结果

拿起你的键盘,照着这个清单敲一遍,然后去洛谷做 P3372 【模板】线段树 1。当你看到绿色的 AC 时,你会明白:所有的手撕,都是为了最后的喜悦

淮安信息学奥赛CSP J/S 培训网, 一个专注于信息学奥赛学习的网站,专注于信息学奥赛培优,助力于孩子在NOIP/NOI/CSP/IOI中取得好成绩。