← 目录 第 47 / 60 节 全部 60 节

47 · 线段树

把区间二分到底,每个节点维护自己那一段的信息:build 自底向上,query 命中整段就返回,区间修改靠懒标记停在整段上、要进儿子时才 pushdown。一份代码,换掉 pushup 和合并方式就能做和、最值、最大子段和。
提高预计 80 分钟线段树懒标记区间合并
本节出现的新函数 / 新语法(先看懂再用)

第 46 节的树状数组很快,但它只吃“能相减”的信息:和、异或、计数。一旦题目要区间加并且区间求和,或者要区间最值,那套 12 行就不够了。这一节写一个通用得多的结构:把区间二分到底,每个节点维护自己那一段的整体信息。四十行代码,覆盖比赛里大半的区间问题。

线段树的所有细节都能由一句话推出来:每个节点负责一个区间,节点上的值由两个儿子合并而来。哪个函数不会写,就回到这句话,问自己“我要的是哪一段、它的儿子是谁”。
问题树状数组线段树
单点改 + 前缀和O(log n),12 行O(log n),40 行
区间加 + 单点查O(log n),差分O(log n),懒标记
区间加 + 区间和要两个树状数组推式子直接做,只改 pushup
区间最值 / gcd不支持O(log n)
区间最大子段和不支持O(log n),多维护 3 个量
常数极小,没有递归约 3~5 倍
数组空间n4n

先把区间怎么分钉死。这是线段树唯一不随题目变化的部分,也是 90% 的错位 bug 的来源。根节点管 [1, n]。对一个管 [l, r] 的节点,令 mid = (l + r) / 2(向下取整),把它劈成左儿子 [l, mid] 与右儿子 [mid+1, r],两段长度差不超过 1。一直劈到 l == r,那就是叶子,管一个位置。

n = 5 时的区间划分与节点编号
                      [1,5]                    节点编号 p = 1
                    /       \
              [1,3]           [4,5]                 p = 2, 3
             /     \         /     \
         [1,2]   [3,3]   [4,4]   [5,5]              p = 4, 5, 6, 7
         /   \
      [1,1] [2,2]                                   p = 8, 9

  左儿子 2p,右儿子 2p+1:堆式编号,不用存指针,也不用存区间(l、r 现场推)

编号带来一个副作用:会跳号。n = 5 时最大编号是 9,n = 6 时直接跳到 13。所以数组必须开 4n——按 2n 开会在 n = 6 这种小数据上就踩出去。这个 4 是怎么来的,数学补给里算清楚。

一个询问区间 [ql, qr] 与当前节点 [l, r] 的关系只有三种:完全包含(整段都要,直接返回,不再往下)、完全不相交(根本不会递归进来)、部分相交(两边各自递归)。query 里那几个 if,全部只是在区分这三件事。

接下来四张卡对应线段树的四块骨头:建树、合并、查询、懒标记。前两张是“从下往上”,第三张是“从上往下”,第四张解决“整段修改怎么不往下走”。

新函数拆解 · build(递归建树)
void build(int p, int l, int r);
组成含义 / 格式要求
p节点编号。根固定是 1,左儿子 2p,右儿子 2p+1。
l, r这个节点负责的闭区间。它们跟着递归参数走,不存进数组,省掉 8n 的空间。
if (l == r)叶子:这一段只有一个位置,把 a[l] 直接抄进 s[p],然后 return。
mid = (l + r) >> 1从中间劈开。(l+r)/2 向下取整,所以左段比右段多一个或一样长,两段都不可能为空。
两次递归先建左子树再建右子树,顺序无所谓,反正都在 pushup 之前。
pushup(p)两个儿子都有值了,用它们推出 s[p]。必须写在两次递归之后,写前面就用到 0。
只调一次 build(1, 1, n)每个节点访问一次,总复杂度 O(n),不是 O(n log n)。
数组开 4nn 个位置的树有 2n−1 个真实节点,但堆式编号会跳号,4n 才一定够(数学补给给证明)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
long long a[N], s[N << 2];             // s[p]:节点 p 管辖区间内所有数的和

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

void build(int p, int l, int r) {
    if (l == r) { s[p] = a[l]; return; }         // 叶子:直接抄原数
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);                       // 左子树 [l, mid]
    build(p << 1 | 1, mid + 1, r);               // 右子树 [mid+1, r]
    pushup(p);                                   // 儿子都好了,才能推自己
}

int main() {
    int n = 5;
    long long b[6] = {0, 1, 5, 4, 2, 3};
    for (int i = 1; i <= n; ++i) a[i] = b[i];
    build(1, 1, n);
    cout << s[1] << "\n";                        // 1+5+4+2+3 = 15
    cout << s[2] << " " << s[3] << "\n";         // a[1..3]=10, a[4..5]=5
    return 0;
}
注意

build 只在“初始有值”时需要。很多时候(比如从空数列开始追加)可以直接把 s 全清 0,跳过 build。

递归深度是 ⌈log₂n⌉+1,n = 10⁵ 时 18 层,离爆栈差得远。

新函数拆解 · pushup(用两个儿子更新自己)
void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }
组成含义 / 格式要求
p << 1左儿子 = 2p。位运算比乘法快,竞赛习惯写法。
p << 1 | 1右儿子 = 2p+1。| 的优先级低于 <<,所以它等于 (p<<1)|1,正确;写成 p << 1 + 1 会被算成 p << 2,直接跑去看错的节点。
s[p] = ...只写“合并”这一步。求和用 +,求最值用 max,求 gcd 用 __gcd。这是整份代码里唯一随题目变化的一行。
调用时机build 的最后一行、update 的最后一行。query 只读不写,不调用。
为什么不需要 l、r合并只用到两个儿子的值,用不上区间信息,所以只有一个参数。
叶子不调用叶子在 build / modify 里直接赋值就 return 了,pushup 只作用于有儿子的节点。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long s[100];                        // 假设树已建好,2 是左儿子、3 是右儿子

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

int main() {
    s[2] = 10; s[3] = 5;                 // 左儿子 10,右儿子 5
    pushup(1);
    cout << s[1] << "\n";                // 15
    // 换成区间最大值,只改这一句:mx[p] = max(mx[p << 1], mx[p << 1 | 1]);
    return 0;
}
注意

pushup 是“儿子 → 自己”的方向,pushdown 是“自己 → 儿子”。两个搞反,答案要么偏小要么停在上一次的状态。

维护和的时候不需要考虑 tag:apply 已经把 s[p] 改对了,pushup 照样成立。

新函数拆解 · query(区间查询)
long long query(int p, int l, int r, int ql, int qr);
组成含义 / 格式要求
ql, qr询问区间,全程不变,递归时不用减,只在判断里用。
ql <= l && r <= qr当前节点整段被包住 → 直接返回 s[p],不再往下。这是 O(log n) 的唯一来源。
if (ql <= mid)左儿子 [l, mid] 与询问区间有交集才进。判断是 ql <= mid,不是 qr <= mid。
if (qr > mid)右儿子同理。这两个 if 互相独立,因为询问区间可以同时伸进两边;写成 else 就会漏掉跨中线的那一半。
res 怎么合并求和用 res += ;求最值用 res = max(res, ...),且 res 要初始化成 −∞。
为什么不会死循环每次递归区间长度至少减半,走到 l == r 时一定被“完全包含”拦住并返回。
复杂度每层最多 2 个节点是“部分相交”,一共 log n 层,所以访问 O(log n) 个节点。
和 tag 的关系query 只读不写,所以不调 pushup;但如果树上有 tag,进入儿子前必须 pushdown。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 5;
long long a[6] = {0, 1, 5, 4, 2, 3}, s[24];

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

long long query(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return s[p];          // 整段被包住
    int mid = (l + r) >> 1;
    long long 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;
}

int main() {
    build(1, 1, n);
    cout << query(1, 1, n, 2, 4) << "\n";         // 5+4+2 = 11
    cout << query(1, 1, n, 1, 5) << "\n";         // 15
    return 0;
}
注意

线段树查询的区间不会像树状数组那样“相减”,因为每个节点只认自己那一段,不存在前缀和的概念。

只有两个 if 都落空才是 bug:那种情况意味着进入了一个根本不相交的节点,说明上层的 if 写错了。

新函数拆解 · 懒标记 tag 与 pushdown(整段修改怎么不往下走)
void apply(int p, int l, int r, long long v); void pushdown(int p, int l, int r);
组成含义 / 格式要求
tag[p]节点 p 那一段每个数还欠着的加量。有了它,区间加可以停在“整段被覆盖”的节点上,不改到叶子。
apply(p, l, r, v)整段加 v:s[p] += v * (r - l + 1)(和增加 v 乘元素个数),同时 tag[p] += v。这两句必须一起做。
pushdown(p, l, r)把 tag[p] 分给两个儿子(对儿子各调一次 apply),然后把 tag[p] = 0。
什么时候调只要准备往儿子走,就必须先 pushdown:update 的递归前、query 的递归前,一处都不能漏。
if (!tag[p]) return;没有欠账立刻返回。这一行决定了常数大小,不能省。
为什么只推一层就够懒标记是“按需结算”:只结算真正走过的那条路径,一条路径 O(log n) 层,总复杂度不变。
为什么 s[p] 总是对的apply 同时改了 s[p] 和 tag[p],所以节点自己的和永远准确;错的只是“儿子还没结账”。
有乘法标记时像 P3373 那种“区间乘 + 区间加”,pushdown 必须先乘后加,顺序反了全错。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

long long s[100], tag[100];              // 设 1 号管 [1,5],2、3 号是它的儿子

void apply(int p, int l, int r, long long v) {
    s[p] += v * (r - l + 1);             // 和增加 v 乘元素个数
    tag[p] += v;                         // 欠账记上
}

void pushdown(int p, int l, int r) {
    if (!tag[p]) return;                 // 没欠账,走人
    int mid = (l + r) >> 1;
    apply(p << 1, l, mid, tag[p]);       // 左儿子 [l, mid] 结账
    apply(p << 1 | 1, mid + 1, r, tag[p]);   // 右儿子 [mid+1, r] 结账
    tag[p] = 0;                          // 账分完了,自己清零
}

int main() {
    s[1] = 15; s[2] = 10; s[3] = 5;      // 和分别是 15、10、5
    apply(1, 1, 5, 2);                   // [1,5] 整段加 2
    cout << s[1] << " " << s[2] << "\n"; // 25 10:儿子还没结账
    pushdown(1, 1, 5);
    cout << s[2] << " " << s[3] << "\n"; // 16 9:左段 3 个数各加 2,右段 2 个数各加 2
    return 0;
}
注意

pushup 与 pushdown 是一对反向操作:前者“儿子 → 自己”,后者“自己 → 儿子”。写反了不报错,答案会停在旧值上。

apply 里 v * (r - l + 1) 的乘号不能漏:漏了就是把“整段”当成“一个数”,n 越大错得越离谱,而样例常常只有很小的数,容易看不出来。

模板题把上面四块骨头一次串起来:区间加、区间求和。这是线段树最常见的形态,其他变体基本只是换 pushup 那一行和 query 的合并方式。

例题 · 【模板】线段树 1洛谷 P3372普及/提高-去洛谷做这题 ↗

给定一个长度为 n 的数列,需要进行 m 次操作,操作有两种:

1 x y k:把区间 [x, y] 内每个数加上 k;2 x y:输出区间 [x, y] 内每个数的和。

数据范围:1 ≤ n, m ≤ 10⁵,保证任意时刻数列中的数与答案在 64 位整数范围内。

输入格式:第一行两个整数 n、m;第二行 n 个整数;接下来 m 行,每行为上述两种操作之一。
输出格式:对每个操作 2,输出一行一个整数。

输入: 5 5 1 5 4 2 3 2 2 4 1 2 3 2 2 3 4 1 1 5 1 2 1 4 输出: 11 8 20
三件事:区间划分、懒标记、结算时机

① 朴素写法为什么死。区间加如果老老实实改到每个叶子,一次操作 O(n),m = 10⁵ 时 10¹⁰ 次加法。树状数组呢?区间加 + 区间和要用两个树状数组推公式 Σdᵢ·(x−i+1),容易推错,而且换个询问(最值)就完全失效。

② 懒标记是“记账”。当某个节点的区间整段被修改范围包住时,就没有必要往下走了:把这笔加量记在 tag[p] 上,同时把 s[p] 改对。一次区间加只会停在 O(log n) 个这样的节点上,剩下的祖先用 pushup 重新算一遍。

③ 什么时候必须结算。只有一件事会让“欠账”变成错误:要往儿子走。update 递归前、query 递归前,都必须 pushdown,把父亲的 tag 分给两个儿子。如果只在 update 里 pushdown 而 query 忘了——这题的样例就会从 20 变成 16(见下面的复现)。

④ 区间查询怎么动手。询问 [2,4] 从根 [1,5] 开始:整段没被包住,切两半;左边 [1,3] 与 [2,4] 相交,进去继续切;右边 [4,5] 也相交,进去。最后整段被命中的节点是 [2,2]、[3,3]、[4,4],三块拼起来正好是 [2,4]。任何区间都能被切成 O(log n) 个这样的整段,这就是它快的原因。

⑤ 规模估算。n = 10⁵ 时树高 18 层。实测:单点查询访问 17 个节点,随机区间平均 43 个、最多 56 个。m = 10⁵ 次操作约 4×10⁶ 到 6×10⁶ 次节点访问,1 秒内轻松完成。

⑥ 数值。用 long long:n = 10⁵、单个数到 10⁹ 时,一整个区间的和能到 10¹⁴,int 的上限 2.1×10⁹ 连零头都不够。s、tag、加法参数、返回值全部 long long,一个都别漏。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100005;
int n, m;
ll a[N], s[N << 2], tag[N << 2];       // s:区间和;tag:还没下推的加量

void pushup(int p) { s[p] = s[p << 1] + s[p << 1 | 1]; }

void build(int p, int l, int r) {
    if (l == r) { s[p] = a[l]; return; }        // 叶子
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);
    build(p << 1 | 1, mid + 1, r);
    pushup(p);
}

void apply(int p, int l, int r, ll v) {         // 给整段 [l,r] 加 v
    s[p] += v * (r - l + 1);                    // 和增加 v 乘元素个数
    tag[p] += v;                                // 记账,等以后要用儿子时再结
}

void pushdown(int p, int l, int r) {            // 把欠账分给两个儿子
    if (!tag[p]) return;                        // 没欠账,省掉一切
    int mid = (l + r) >> 1;
    apply(p << 1, l, mid, tag[p]);
    apply(p << 1 | 1, mid + 1, r, tag[p]);
    tag[p] = 0;                                 // 账已分完,自己清零
}

void update(int p, int l, int r, int ql, int qr, ll v) {
    if (ql <= l && r <= qr) { apply(p, l, r, v); return; }   // 整段被包住就停手
    pushdown(p, l, r);                          // 要进儿子了,先结账
    int mid = (l + r) >> 1;
    if (ql <= mid) update(p << 1, l, mid, ql, qr, v);
    if (qr > mid)  update(p << 1 | 1, mid + 1, r, ql, qr, v);
    pushup(p);                                  // 儿子变了,自己重算
}

ll query(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return s[p];        // 整段都要,直接返回
    pushdown(p, l, r);                          // 漏掉这一行,样例就会错
    int mid = (l + r) >> 1;
    ll 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;
}

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

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

    while (m--) {
        int op, x, y;
        ll k;
        cin >> op >> x >> y;
        if (op == 1) { cin >> k; update(1, 1, n, x, y, k); }
        else cout << query(1, 1, n, x, y) << "\n";
    }
    return 0;
}
逐行拆解
代码片段含义与理由
s[N << 2]N 左移 2 位 = N×4,就是 4n。按 2n 开在 n = 6 时就已越界(最大编号 13 > 12)。
if (l == r) { s[p] = a[l]; return; }唯一的递归出口。p 与 l、r 一起才唯一确定一个节点,缺一个都不对。
apply 里的 v * (r - l + 1)整段加 v,和增加 v 乘元素个数。漏掉乘号,n 越大错越多,小样例看不出来。
tag[p] += v为什么是 += 而不是 =:同一个节点可能被多次整段加,欠账要累加。
if (!tag[p]) return;pushdown 的第一行。绝大多数调用时 tag 是 0,这一行省掉大部分无谓操作。
pushdown 在 update 里的位置必须在两次递归之前。写在之后,儿子已经算完了才结账,等于没结。
if (ql <= mid) / if (qr > mid)两个独立判断。询问区间跨中线时两边都要进,写成 else if 直接漏一半。
pushup(p) 在 update 末尾递归回来,儿子们的 s 都变了,必须重算 s[p]。漏掉它,祖先全部停在旧值。
query 里的 pushdown最容易漏的一行:只在 update 写了 pushdown,查询时儿子少算了父亲那笔账 → WA。

模板题只用了“和”。真实的题目往往是别的信息,这时要改的其实只有三处:pushup 的合并方式、query 里 res 的合并方式与初值、tag 的含义与 apply 的写法。下面换一个最常见的:区间最大值,配单点修改。

换成区间最大值:单点修改 + 区间查询(不需要 tag)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100005;
const ll NEG = -(1LL << 60);           // 最值查询的中性元:比任何答案都小
ll mx[N << 2];

void pushup(int p) { mx[p] = max(mx[p << 1], mx[p << 1 | 1]); }   // 只换这一句

void modify(int p, int l, int r, int pos, ll v) {                 // 单点赋值
    if (l == r) { mx[p] = v; return; }
    int mid = (l + r) >> 1;
    if (pos <= mid) modify(p << 1, l, mid, pos, v);
    else            modify(p << 1 | 1, mid + 1, r, pos, v);
    pushup(p);                        // 儿子变了,自己跟着变
}

ll queryMax(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return mx[p];
    int mid = (l + r) >> 1;
    ll res = NEG;
    if (ql <= mid) res = max(res, queryMax(p << 1, l, mid, ql, qr));
    if (qr > mid)  res = max(res, queryMax(p << 1 | 1, mid + 1, r, ql, qr));
    return res;
}

int main() {
    int n = 6;
    ll a[7] = {0, 3, 1, 4, 1, 5, 9};
    for (int i = 1; i <= n; ++i) modify(1, 1, n, i, a[i]);         // 逐个位置建树
    cout << queryMax(1, 1, n, 2, 4) << "\n";                       // max(1,4,1) = 4
    modify(1, 1, n, 3, 8);                                        // a[3] 改成 8
    cout << queryMax(1, 1, n, 2, 4) << "\n";                       // max(1,8,1) = 8
    return 0;
}

注意这份代码里没有 tag、没有 pushdown。因为修改是单点的,一路走到叶子,不存在“整段还没结算”的情况。单点修改的题,懒标记整套都可以删掉。反过来,只要支持区间修改,就必须有 tag;而 tag 的含义决定了 apply 怎么写:区间加是 s[p] += v*(len)、区间赋值是 s[p] = v*(len),区间取反是 s[p] = len − s[p]。

什么时候该写线段树

三个信号同时出现就该写它:① 有修改也有询问,且修改和询问都作用在区间上;② 信息可以“合并”(和、最值、gcd、最大子段和、出现次数);③ n、m 到 10⁵,暴力撑不住。

常见变体只要换三处:

题目要什么pushup 写法query 的合并tag 含义
区间加 + 区间和s = L + Rres += ...加量 v
区间加 + 区间最值mx = max(L, R)res = max(...)加量 v
区间赋值 + 区间和s = L + Rres += ...赋值成 v
区间取反 + 区间和s = L + Rres += ...翻转次数
区间最大子段和sum / pre / suf / best 四量合并四量合并加量 v(best 也加 len·v)

还有几个名字要认得。把下标当成“值域”建的树叫权值线段树,能在 O(log n) 找第 k 小;只建用到的那几个节点叫动态开点;两棵树按节点合并叫线段树合并。这三样都是本节代码加一两句话就变形出来的,不必重新学。

常数提示。递归线段树比树状数组慢 3~5 倍。n、m 都是 10⁵ 时完全够用;到了 10⁶ 或时限 0.5 秒,才需要考虑非递归(zkw)写法。

这一节的 7 个坑,按症状记

数组按 n 或 2n 开:n = 6 时最大编号已是 13,n = 10⁵ 时是 262141 → RE(越界写坏别的变量) 或随机 WA。一律开 4n,别算。

只在 update 里 pushdown,query 里忘了 → WA。症状:先加后查的数据全错,而这题样例最后一个答案会从 20 变成 16。

update 里把 if (qr > mid) 写成 else:询问区间跨中线时只进了一边 → WA,小数据可能蒙对。

apply 里写成 s[p] += v,漏了乘 (r−l+1):把“整段”当成“一个数” → WA,区间越长错得越多。

把 p << 1 | 1 写成 p << 1 + 1:因为 + 优先级高于 <<,它等于 p<<2,跑到完全不相干的节点 → WA 或 RE。

s 用 int:区间和到 10¹⁴ → 溢出成负数 → WA,样例全过(数太小)。

多组数据不清 tag 和 s → 第二组开始全错 → WA。memset 两个数组,或者把 4n 个位置清 0。

自己动手复现

关掉页面,把 P3372 的六个函数(pushup / build / apply / pushdown / update / query)默写一遍,提交确认样例输出 11、8、20。

改一改①:删掉 query 里那一行 pushdown(p, l, r),再跑样例。前两个答案仍是 11 和 8,第三个会变成 16(正确是 20)——少掉的 4 正是节点 1 欠下的账。

改一改②:把 s 改成 int,自己造一组“n = 10、每个数 10⁹、全区间求和”的数据,看输出为什么是负数。

改一改③:把 query 里第二个 if (qr > mid) 改成 else,查询 [2, 4] 会返回 5(正确是 11):跨中线的右半边 [3,3]、[4,4] 全被跳过了。

数学补给 · 4n 是怎么来的,O(log n) 又是怎么来的

先数节点。n 个叶子,每个内部节点恰好有两个儿子,所以内部节点 n−1 个,整棵树 2n−1 个节点。如果按“节点个数”开数组,2n 就够。

但堆式编号会跳号。编号是 2p、2p+1,当 n 不是 2 的幂时最后一层不满,空出来的位置仍然占着编号。实测最大编号:

n最大编号2n4n
61312(不够)24
102520(不够)40
100253200(不够)400
100000262141200000(不够)400000

上界证明。设 2^(k−1) < n ≤ 2^k,则树高最多 k+1 层,编号最大不超过 2^(k+1)−1。又因为 n > 2^(k−1),所以 2^(k+1) = 4·2^(k−1) < 4n。即最大编号一定小于 4n,这就是所有人写 N << 2 的原因。

再算高度。每递归一层,区间长度至少减半,所以层数 = ⌈log₂n⌉ + 1。n = 10⁵ 时 ⌈log₂10⁵⌉ = 17,共 18 层。递归深度 18,爆栈是不可能的事。

最后算查询为什么是 O(log n)。关键观察:同一层里,最多只有 2 个节点是“部分相交”的。因为部分相交意味着节点区间里有询问区间的端点 ql 或 qr;而同一层的节点区间互不相交,所以含 ql 的最多一个,含 qr 的最多一个,合起来最多两个。每层 2 个、共 log n 层,于是访问节点数不超过 4⌈log₂n⌉。

实测对得上:n = 10⁵ 时单点查询访问 17 个节点(正好等于高度),随机区间平均 43 个、最坏 56 个,而理论上界 4×17 = 68。m = 10⁵ 次操作因此只有几百万次节点访问,1 秒的时限绰绰有余。

举一反三 · 想清楚这些再往下走
  1. 把区间和换成区间异或和,tag 还能记成“加 v”吗?如果操作是“区间取反”,apply 该怎么写?
  2. 如果同时有“区间加”和“区间赋值”两种标记,一个 tag 够不够?pushdown 里的顺序该怎么定?(提示:赋值会把之前的加量全部作废)
  3. 求区间最大子段和时,一个节点要维护几个量?为什么只存 best 不够,还必须存 sum、前缀最大值、后缀最大值?
  4. n = 10⁶ 时 4n 个 long long 要多少内存?如果内存限制 256 MB,还能开几棵树?这时该怎么压空间?
  5. 单点修改的题为什么可以把 tag 和 pushdown 整套删掉?如果保留它们,会多出多少无用的操作?
  6. 树状数组和线段树都能做“单点改 + 区间和”。给你一道题,你用哪两条依据决定选谁?
自测:能全部回答再进下一节
挑战题 · 最大数
洛谷 P1198普及/提高-去洛谷做这题 ↗

维护一个数列,初始为空,需要支持 m 次操作:

A n:把 n 加上 t 之后对固定常数 D 取模(t 是最近一次询问的答案,若还没问过则 t = 0),把结果追加到数列末尾。Q L:询问当前数列中最后 L 个数的最大值,输出它,并把它作为新的 t。

输入格式:第一行两个整数 m、D;接下来 m 行,每行一个字符(A 或 Q)和一个整数。
输出格式:对每个 Q 操作,输出一行一个整数。

数据范围:1 ≤ m ≤ 2×10⁵,1 ≤ D ≤ 2×10⁹,保证 L 不超过当前数列长度;每次给出的整数在 64 位整数范围内,可能为负。

引导性提示(先只看这里)

插入只会发生在末尾,位置一旦确定就再也不会变——所以“最后 L 个”永远是数组里连续的一段。

既然最多插入 m 次,那位置最多只有 m 个。能不能先开一棵长度为 m 的空树,边插边改?

这题只需要两个操作:单点赋值、区间最大值。想想为什么一个 tag 都不需要。

想看思路了?点开完整思路

离线看出上界。共 m 次操作,每次最多插入一个数,所以数列长度永远 ≤ m。于是可以直接建一棵管 [1, m] 的线段树,初始全是 0,等着被填。

在线维护。用 tail 记当前长度。A n:++tail,把 ((n + t) % D + D) % D 单点赋值到 tail 位置;Q L:查询 [tail − L + 1, tail] 的最大值,更新 t 并输出。

为什么正确。插入位置的顺序就是数列顺序;已经插入的部分之后不再改动,而询问的区间一定落在 [1, tail] 内,全是已插入的有效值,树里的 0 初值永远不会被问到。

为什么不需要 tag。修改是单点的,一路递归到叶子,中间没有任何“整段还没结算”的状态。把 pushdown 和 tag 全删掉,代码更短也更快。

易错的数值细节。n + t 可能超过 int(D 到 2×10⁹),全部用 long long;取模后可能为负,要 ((n + t) % D + D) % D 才能落回 [0, D)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 200005;
int cap;                              // 树的长度 = 操作总数(最多插入这么多次)
ll D, mx[N << 2];                     // 插入值都落在 [0, D),所以 0 是安全的初值

void modify(int p, int l, int r, int pos, ll v) {        // 单点赋值
    if (l == r) { mx[p] = v; return; }
    int mid = (l + r) >> 1;
    if (pos <= mid) modify(p << 1, l, mid, pos, v);
    else            modify(p << 1 | 1, mid + 1, r, pos, v);
    mx[p] = max(mx[p << 1], mx[p << 1 | 1]);             // 儿子变了,自己跟着变
}

ll queryMax(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) return mx[p];                // 整段被包住
    int mid = (l + r) >> 1;
    ll res = 0;                                          // 非负数,用 0 当"没有"
    if (ql <= mid) res = max(res, queryMax(p << 1, l, mid, ql, qr));
    if (qr > mid)  res = max(res, queryMax(p << 1 | 1, mid + 1, r, ql, qr));
    return res;
}

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

    int m;
    cin >> m >> D;
    cap = m;                                         // 最多插 m 次,树不用更长
    int tail = 0;                                    // 当前数列长度
    ll t = 0;

    while (m--) {
        char op;
        ll n;
        cin >> op >> n;
        if (op == 'A') {
            ll v = ((n + t) % D + D) % D;             // 先加 t,再取模,负数也落进 [0,D)
            modify(1, 1, cap, ++tail, v);             // 追加到最后一个位置
        } else {
            int L = (int)n;
            t = queryMax(1, 1, cap, tail - L + 1, tail);   // 最后 L 个
            cout << t << "\n";
        }
    }
    return 0;
}
关键片段拆解
片段作用
cap = m把操作总数存下来当树的长度。后面 while (m--) 会把 m 用掉,所以必须提前保存。
mx[N << 2]N = 2×10⁵,4N = 8×10⁵ 个 long long,约 6.4 MB,安全。
((n + t) % D + D) % DC++ 的 % 对负数取模结果仍为负,加一个 D 再取模才能保证落在 [0, D)。
modify(1, 1, cap, ++tail, v)先 ++tail 再传,参数求值顺序在这里无歧义(只有一个自增)。
mx[p] = max(mx[p << 1], mx[p << 1 | 1])pushup 直接写在 modify 末尾,省一个函数调用。换信息只改这一句。
queryMax(1, 1, cap, tail - L + 1, tail)末尾 L 个 = 区间 [tail−L+1, tail]。保证 L ≤ tail,所以不会越界。
ll res = 0所有插入值都 ≥ 0,用 0 当“没有”是安全的;若允许负数,必须用 −∞ 起头。
这题最容易错的地方
  • 漏掉 cap = m,直接拿被 while (m--) 消耗掉的 m 当右端点 → 树的范围变成 0,行为完全错乱。
  • 取模写成 (n + t) % D:n 可能为负,结果也为负,插进去的值破坏“非负”前提 → WA。
  • Q 操作只 cout 忘了把答案写回 t:之后的每个 A 都少加了上一次的答案 → 从第二个 A 起全错。
  • 把查询区间写成 [tail − L, tail]:多算了前一个数,答案偏大。
  • 数组开成 mx[N] 而不是 mx[N << 2]:堆式编号最大到 4m 附近 → 越界 → RE。
延伸阅读 / 参考