维护非负序列 A 的区间和,用线段树上带余量下降(或前缀和的区间加配极值剪枝)在 O(log N) 内定位和第一次达到 t 的位置
OJ: atcoder
题目 ID: aalc001_c
难度:提高
标签:线段树二分前缀和懒标记
创建: 2026-10-27 18:22
更新: 2026-10-28 13:37
目录
形式化题目
给定长度为
- 单点赋值:把
改为 。 - 向右定位:给定
,求满足 且 的最小 。 - 向左定位:给定
,求满足 且 的最大 。
两种定位在无解时分别返回
题面中有两个条件在后续分析中会反复用到:一是
暴力解法
思路
直接照题意模拟。查询
单次定位最多扫描
代码
#include <bits/stdc++.h>
using namespace std;
// 暴力:每次查询直接向两侧扫前缀和,复杂度 O(nq)。
// 仅用于对拍小数据,不满足题目限制。
int n, q;
vector<long long> a;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
a.assign(n + 2, 0);
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
while (q--) {
int op;
cin >> op;
if (op == 1) {
int i;
long long x;
cin >> i >> x;
a[i] = x;
} else if (op == 2) {
int l;
long long t;
cin >> l >> t;
// 从 l 往右累加,第一个使和 >= t 的位置就是答案
long long sum = 0;
int answer = n + 1;
for (int r = l; r <= n; r++) {
sum += a[r];
if (sum >= t) {
answer = r;
break;
}
}
cout << answer << '\n';
} else {
int r;
long long t;
cin >> r >> t;
// 从 r 往左累加,遇到的第一个使和 >= t 的位置就是最大的 l
long long sum = 0;
int answer = 0;
for (int l = r; l >= 1; l--) {
sum += a[l];
if (sum >= t) {
answer = l;
break;
}
}
cout << answer << '\n';
}
}
return 0;
}复杂度
单次查询时间复杂度为
瓶颈
将同一次查询内的计算拆解来看,本质是在重复累加一段并未变化的前缀。
以查询
加法单次开销极小,开销在于重复累加:相同的位置会被不同的查询反复计入。若能将区间和预先维护起来,单次获取便能远快于
解法总览
三种解法都是完整可提交的解法,时间复杂度都在
- 解法一(
main2.cpp):线段树只维护的区间和,定位直接套 rbook 的 first_true二分模板。改动最小,不需要懒标记,也不需要设计树上检索逻辑,写起来最快;代价是单次查询需要。 - 解法二(
main.cpp,正式主解):沿用相同的数据结构,将二分内化到树上遍历过程中——带着“还差多少”这一剩余量沿树下降,整段不足则直接跳过整棵子树。单次查询降至,代码反而比解法一更精简。 - 解法三(
main3.cpp):更换维护对象,以前缀和序列代替原序列 。单点修改转化为 上的后缀区间加,定位转化为在前缀和的值域上检索下标。同样达到单次 ,但需要引入懒标记下传,并处理下标 的边界。
解法一与解法二维护同一个对象(
| 解法 | 核心观察 | 复杂度 | 适用场景 | 对应代码 |
|---|---|---|---|---|
解法一:单点赋值 + 区间和,树外 first_true |
每次 |
追求最快编码完成,或时限宽松;无需懒标记 | main2.cpp(C++) |
|
| 解法二:单点赋值 + 树上按和下降 | 将二分搬进树里,用余量 rem 沿树走,整段不够就整段跳过 |
每次 |
正式提交推荐;常数小,代码精简 | main.cpp(C++) |
| 解法三:前缀和 + 区间加 + 极值下降 | 单点改值在前缀和上是后缀区间加;结点维护极值即可剪枝定位 | 每次 |
需要兼顾前缀和区间加等扩展操作时 | main3.cpp(C++) |
同目录下的 brute.cpp 为暴力扫描实现,仅作为对拍基准,不作为正式提交解法。
解法一:单点赋值 + 区间和,树外 first_true
思路
线段树结点维护区间和,直接提供单点赋值与区间和查询两种基础操作,单次时间复杂度均为 seg[p] = x,返回途中用 push_up 把父结点的和重算一遍,不必再另存一份原序列。定位操作要判断区间和是否达到
解决求和后,关键在于如何定位端点。利用 check 成立的位置。这正是 rbook 二分模板 first_true(l, r) 的形状——左闭右闭,要求 check 单调为 false … false true … true,并要求传入的
- 查询
:固定 时 关于 单调不减,令 check(pos)为,第一个成立的位置就是最小的 。 - 查询
:固定 时 关于 单调不增,要让模板派上用场,把谓词取反:令 check(pos)为,第一个成立的位置减一,就是最后一个凑够的 。
两个方向的谓词由同一个 check 函数按 to_left 分派,端点与门槛 need 存全局,这样二分主体完全保持模板原样,不必为两个查询各写一遍二分。
无解不用单独判,交给哨兵位置。 手写二分时通常要先查一遍
- 查询
传 当虚拟位置。 时它必然成立,二分落在这里,直接输出 。 - 查询
传 当虚拟位置,它表示空区间、和为 ,必然 。若连 都不够,二分就停在这个虚拟位置上,减一恰为 。
因此 main2.cpp 里没有前置的区间和校验,两个查询都只有一次 first_true 调用加上一次减法。
代码
/**
* 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-10-28 12:26
* update_at: 2026-10-28 13:10
*/
// 解法一:单点赋值 + 区间和,定位靠线段树外面的二分。
//
// 不引入前缀和,也不出现区间加:线段树只维护 A 本身,支持
// 1. 单点赋值(op 1 直接把 A[i] 置为 x)
// 2. 区间和查询
// 于是不需要懒标记,代码比解法三短不少。
//
// 定位交给 rbook 的 first_true 二分模板。因为 A_i >= 0,区间和关于端点单调,
// 两个查询都能写成"在某个区间里找第一个满足 check 的位置":
// op 2:check(pos) 是 sum(l..pos) >= t,答案就是第一个 true 的位置;
// op 3:check(pos) 是 sum(pos..r) < t,第一个 true 的位置减一就是答案。
// 无解不用单独判:往右的虚拟位置 n+1 恒为 true,落空就直接返回 N+1;
// 往左若第一个就 true,减一正好是 0。
// 代价是每次查询 O(log^2 n):二分 O(log n) 步,每步一次区间和 O(log n)。
#include <bits/stdc++.h>
using namespace std;
int n, q;
vector<long long> seg; // 区间和
bool to_left; // 本次定位方向:false 从 l 往右找,true 从 r 往左找
int bound; // 固定的那个端点:往右时是 l,往左时是 r
long long need; // 本次要凑够的和 t
void push_up(int p) { seg[p] = seg[p << 1] + seg[p << 1 | 1]; }
// 单点赋值:把 A[pos] 置为 v,沿途重算区间和
void modify(int pos, long long v, int l, int r, int p = 1) {
if (l == r) {
seg[p] = v;
return;
}
int m = (l + r) >> 1;
if (pos <= m) modify(pos, v, l, m, p << 1);
else modify(pos, v, m + 1, r, p << 1 | 1);
push_up(p);
}
// 区间和:sum(A[ql..qr])
long long query(int ql, int qr, int l, int r, int p = 1) {
if (ql <= l && r <= qr) return seg[p];
int m = (l + r) >> 1;
long long s = 0;
if (ql <= m) s += query(ql, qr, l, m, p << 1);
if (qr > m) s += query(ql, qr, m + 1, r, p << 1 | 1);
return s;
}
// 二分的单调谓词,两个方向共用一个函数:
// 往右:sum(l..pos) >= t 时已经凑够,返回 true;
// 往左:sum(pos..r) < t 时已经不够,返回 true。
bool check(int pos) {
if (!to_left) {
if (pos > n) return true; // 虚拟可行位置:加到末尾仍不够,答案就是 N+1
return query(bound, pos, 1, n) >= need;
}
if (pos > bound) return true; // 空区间,和为 0,必然不够
return query(pos, bound, 1, n) < need;
}
// rbook 的 first_true 模板:在 [l, r] 中查找第一个满足 check(pos) 的位置。
// 要求 check 单调:false false ... false true true ... true。
// 调用时要保证 r 是一个真实或虚拟的可行位置。
int first_true(int l, int r) {
while (l < r) {
int mid = l + (r - l) / 2;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
seg.assign(n * 4 + 5, 0);
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
modify(i, x, 1, n);
}
while (q--) {
int op;
cin >> op;
if (op == 1) {
int i;
long long x;
cin >> i >> x;
modify(i, x, 1, n);
} else if (op == 2) {
int l;
long long t;
cin >> l >> t;
to_left = false;
bound = l;
need = t;
// 第一个 sum(l..pos) >= t 的位置;不存在时落在虚拟位置 n+1
cout << first_true(l, n + 1) << '\n';
} else {
int r;
long long t;
cin >> r >> t;
to_left = true;
bound = r;
need = t;
// 第一个 sum(pos..r) < t 的位置,减一就是最后一个凑够的 l
cout << first_true(1, r + 1) - 1 << '\n';
}
}
return 0;
}复杂度
建树时间复杂度为 first_true,走
取
解法二:单点赋值 + 线段树上按和下降
思路
解法一的性能瓶颈在于树外的二分:每向内逼近一步都要从树根重新查询一次区间和,沿途结点被重复访问。实际上,这层二分完全可以在树上单次自顶向下完成。
核心在于显式维护“当前还差多少”。以查询 rem,初值为
- 若当前子树的区间和
:说明目标位置必在该子树内,递归深入查找; - 若当前子树的区间和
:说明该整段累加后仍不足,直接扣减剩余量( )并跳过该子树。
判断只需使用结点上现成的区间和信息,既不需要前缀和,也无需维护极值。遇到不足则减去并跳过、遇到充足则深入检索,整套流程本身便是一次二分定位。
两个扫描方向仅在遍历顺序上对称:查询
无解判定在此机制下自然达成:剩余量仅在整段跳过时被扣减,若合法范围内的所有结点访问完毕后剩余量仍大于
单次定位时间复杂度为
值得注意的是,该算法严格依赖序列非负(
树上按和下降图示
下图以序列
图中直观展示了算法单次定位仅需
- 节点
的右界 ,完全在查询范围左侧,直接以 剪枝排除; - 节点
完全在查询范围内,但区间和 ,说明整段累加仍不足,直接整段跳过,无需遍历叶子,同时将余量扣减为 ; - 节点
的区间和 ,说明答案必在其中,优先深入左子树 ; - 最终在叶子
处由于 命中答案,直接返回 。整棵树只有通往目标叶子的一条路径向下展开,其余分支均被 跳过或剪枝。
代码
#include <bits/stdc++.h>
using namespace std;
// 解法二(正式主解):单点赋值 + 线段树上按和下降。
//
// 不转前缀和,也没有懒标记:线段树只维护 A 本身,结点存区间和。
// 定位时把"还差多少"当作余量 rem,沿树按目标方向走:
// op 2(找最小的 r):从左往右扫结点,整段和 < rem 就整段跳过并 rem -= 和,
// 否则往下钻,走到叶子就是答案。
// op 3(找最大的 l):同样,从右往左扫。
// 一次定位 O(log n):与边界部分相交的结点 O(log n) 个,整段跳过各花 O(1),
// 真正向下钻的只有通往答案的一条路径。
// 余量只在整段跳过时被扣掉;范围内所有下标都跳完仍不够,就是无解,返回 -1。
//
// 这版依赖 A_i >= 0:余量 rem 始终为正,"整段和 < rem"就说明段内凑不够。
int n, q;
vector<long long> seg; // 区间和
void push_up(int p) { seg[p] = seg[p << 1] + seg[p << 1 | 1]; }
// 单点赋值:把 A[pos] 置为 v,沿途重算区间和
void modify(int pos, long long v, int l, int r, int p = 1) {
if (l == r) {
seg[p] = v;
return;
}
int m = (l + r) >> 1;
if (pos <= m) modify(pos, v, l, m, p << 1);
else modify(pos, v, m + 1, r, p << 1 | 1);
push_up(p);
}
// 最小的 i >= ql 使从 ql 累到 i 的和达到原 rem;无解返回 -1。
int first_reach(int ql, long long &rem, int l, int r, int p = 1) {
if (r < ql) return -1;
if (ql <= l && seg[p] < rem) { // 整段都不够,整段跳过
rem -= seg[p];
return -1;
}
if (l == r) return l; // 能走到叶子说明这一段够,叶子就是答案
int m = (l + r) >> 1;
int res = first_reach(ql, rem, l, m, p << 1);
if (res != -1) return res;
return first_reach(ql, rem, m + 1, r, p << 1 | 1);
}
// 最大的 i <= qr 使从 qr 累到 i 的和达到原 rem;无解返回 -1。
int last_reach(int qr, long long &rem, int l, int r, int p = 1) {
if (l > qr) return -1;
if (r <= qr && seg[p] < rem) { // 整段都不够,整段跳过
rem -= seg[p];
return -1;
}
if (l == r) return l;
int m = (l + r) >> 1;
int res = last_reach(qr, rem, m + 1, r, p << 1 | 1);
if (res != -1) return res;
return last_reach(qr, rem, l, m, p << 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
seg.assign(n * 4 + 5, 0);
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
modify(i, x, 1, n);
}
while (q--) {
int op;
cin >> op;
if (op == 1) {
int i;
long long x;
cin >> i >> x;
modify(i, x, 1, n);
} else if (op == 2) {
int l;
long long t;
cin >> l >> t;
long long rem = t;
int r = first_reach(l, rem, 1, n);
cout << (r == -1 ? n + 1 : r) << '\n';
} else {
int r;
long long t;
cin >> r >> t;
long long rem = t;
int l = last_reach(r, rem, 1, n);
cout << (l == -1 ? 0 : l) << '\n';
}
}
return 0;
}复杂度
建树时间复杂度为
解法三:前缀和 + 区间加,按区间极值下降
思路
记
任意区间的求和转化为常数次前缀和差分。
代价在于定位条件转化到了前缀和的值域上:查询
相应地,单点修改在前缀和上的开销有所增加:将
为了支持上述查询与修改,线段树需要提供四项操作:
| 需求 | 对应操作 | 实现手段 |
|---|---|---|
| 区间加 | 操作 |
懒标记延迟更新 |
| 单点查询 | 获取 |
树上单点检索 |
| 查找首个 |
操作 |
线段树上二分 |
| 查找末个 |
操作 |
线段树上二分 |
下标范围需从
定位通过区间极值剪枝完成。每个结点维护区间最大值
- 当前结点区间完全位于
左侧,与查询范围无交集,直接剪枝; - 当前区间最大值
,说明整段内所有值均无法达到阈值,无解剪枝; - 否则答案可能存在于当前子树中,优先递归左子树;若左子树未找到解,再递归右子树;
- 抵达叶子结点时直接返回对应下标,该下标即为范围内第一个达标的位置。
先左后右的搜索顺序保证了找到的是满足条件的最小下标。查找最后一个
该解法在检索时不依赖
在实现上有两处关键的下标边界:
- 查询
的候选答案 至少为 ,递归时下界传入 ;若未找到返回 ,主调输出 。 - 查询
寻找的是前缀和下标 ,由 可得 ,因此递归时上界应传入 ;找到 后答案为 ,若未找到返回 ,主调输出 。
这里传入
代码
#include <bits/stdc++.h>
using namespace std;
// 题意:维护非负序列 A,支持单点改值,以及「从 l 往右/从 r 往左的前缀和
// 第一次达到 t 的位置」。
//
// 关键转化:记前缀和 P[i] = A[1] + ... + A[i],则
// 查询 2:求最小的 r >= l 使 P[r] >= P[l-1] + t;
// 查询 3:求最大的 l <= r 使 P[l-1] <= P[r] - t,输出 l = 那个下标 + 1。
// 单点改 A[i] 等价于把 P[i..n] 整体加上 d,是区间加。于是问题变成
// 「区间加 + 线段树上二分找第一个 >= x 的位置 / 最后一个 <= x 的位置」。
//
// 因为 A 非负,P 单调不减,第二个二分等价于找「第一个 > P[r]-t」的位置再减一,
// 但这里直接在 P 上维护区间最大值和最小值做二分,不依赖单调性也能写对。
int n, q;
// 区间加 + 区间最值线段树
struct SegmentTree {
using T = long long;
struct Node {
T mx = 0; // 当前区间最大值
T mn = 0; // 当前区间最小值
};
static int lson(int p) { return p << 1; }
static int rson(int p) { return p << 1 | 1; }
static int mid(int l, int r) { return (l + r) >> 1; }
int n = 0;
vector<Node> tree;
vector<T> lazy; // 整段加法的懒标记
SegmentTree(int n = 0) { init(n); }
void init(int size) {
n = size;
tree.assign(n * 4 + 5, Node{});
lazy.assign(n * 4 + 5, 0);
}
void apply(int p, T v) {
tree[p].mx += v;
tree[p].mn += v;
lazy[p] += v;
}
void push_down(int p) {
if (lazy[p] != 0) {
apply(lson(p), lazy[p]);
apply(rson(p), lazy[p]);
lazy[p] = 0;
}
}
void push_up(int p) {
tree[p].mx = max(tree[lson(p)].mx, tree[rson(p)].mx);
tree[p].mn = min(tree[lson(p)].mn, tree[rson(p)].mn);
}
void build(const vector<T> &a, int l, int r, int p = 1) {
if (l == r) {
tree[p].mx = tree[p].mn = 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] 每个元素加上 v
void add(int ql, int qr, T v, int l, int r, int p = 1) {
if (ql <= l && r <= qr) {
apply(p, v);
return;
}
push_down(p);
int m = mid(l, r);
if (ql <= m) add(ql, qr, v, l, m, lson(p));
if (qr > m) add(ql, qr, v, m + 1, r, rson(p));
push_up(p);
}
// 单点查询
T point(int pos, int l, int r, int p = 1) {
if (l == r) return tree[p].mx;
push_down(p);
int m = mid(l, r);
if (pos <= m) return point(pos, l, m, lson(p));
return point(pos, m + 1, r, rson(p));
}
// 最小的下标 i >= ql 且 P[i] >= target;不存在返回 -1。
// 超出 ql 的区间最大值都够不到 target 时整段剪掉。
int first_ge(int ql, T target, int l, int r, int p = 1) {
if (r < ql || tree[p].mx < target) return -1;
if (l == r) return l;
push_down(p);
int m = mid(l, r);
int res = first_ge(ql, target, l, m, lson(p));
if (res != -1) return res;
return first_ge(ql, target, m + 1, r, rson(p));
}
// 最大的下标 i <= qr 且 P[i] <= target;不存在返回 -1。
int last_le(int qr, T target, int l, int r, int p = 1) {
if (l > qr || tree[p].mn > target) return -1;
if (l == r) return l;
push_down(p);
int m = mid(l, r);
int res = last_le(qr, target, m + 1, r, rson(p));
if (res != -1) return res;
return last_le(qr, target, l, m, lson(p));
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
vector<long long> a(n + 1, 0);
vector<long long> pre(n + 1, 0); // pre[i] = A[1..i] 的和
for (int i = 1; i <= n; i++) {
cin >> a[i];
pre[i] = pre[i - 1] + a[i];
}
// 下标范围 0..n,其中 0 表示空前缀。
SegmentTree seg(n + 1);
seg.build(pre, 0, n);
while (q--) {
int op;
cin >> op;
if (op == 1) {
int i;
long long x;
cin >> i >> x;
long long delta = x - a[i];
a[i] = x;
// A[i] 变了,前缀和 pre[i..n] 整体平移 delta。
if (delta != 0) seg.add(i, n, delta, 0, n);
} else if (op == 2) {
int l;
long long t;
cin >> l >> t;
// 要求 sum(A[l..r]) >= t,即 pre[r] >= pre[l-1] + t。
long long base = seg.point(l - 1, 0, n);
int r = seg.first_ge(l, base + t, 0, n);
cout << (r == -1 ? n + 1 : r) << '\n';
} else {
int r;
long long t;
cin >> r >> t;
// 要求 sum(A[l..r]) >= t,即 pre[l-1] <= pre[r] - t。
long long target = seg.point(r, 0, n) - t;
int j = seg.last_le(r - 1, target, 0, n);
cout << (j == -1 ? 0 : j + 1) << '\n';
}
}
return 0;
}复杂度
建树时间复杂度为
定位复杂度分析:与解法二类似,整棵树上仅有通往目标解的单条链会递归至叶子,沿途被极值剪枝跳过的子树单次判定仅耗时
复杂度对比
| 解法 | 维护对象 | 懒标记 | 单次操作 | 空间复杂度 | 是否依赖 |
|---|---|---|---|---|---|
解法一:树外 first_true |
原序列 |
不需要 | 是,依赖单调性进行二分 | ||
| 解法二:树上按和下降 | 原序列 |
不需要 | 是,整段剪枝跳过依赖非负性 | ||
| 解法三:前缀和 + 极值下降 | 前缀和序列 |
需要 | 否,极值剪枝适用于任意序列 |
常数从小到大依次为解法二、解法三、解法一;代码编写量从小到大依次为解法二、解法一、解法三。
总结
三种解法本质上是对“求和”与“定位”两个阶段的不同设计分工:
求和阶段,既可以借助线段树的区间和维护(解法一、解法二),也可以通过前缀和差分实现(解法三)。解法三为此付出的代价是修改操作变成了区间加,必须使用懒标记。定位阶段,则分别采用了树外二分(解法一)、树上带余量向下贪心(解法二)以及前缀和值域上的极值剪枝(解法三)。
从实战和工程角度看,解法二是全题最优的折中方案:树外二分之所以偏慢,是因为每步都从树根重新进入;将“还差多少”显式维护为剩余量后,整轮二分被自然融合在单次树上自顶向下的遍历中,复杂度由
三处细节容易翻车。
- 非负约束:解法二的整段跳过严格依赖
;若存在负数,子树和不足并不代表内部无解,而解法三通过极值剪枝则不受此限制。 - 区间更新:解法三对
的单点修改,映射到前缀和上是后缀区间加,必须使用懒标记正确下传。 - 二分上界:解法三在处理查询
时,二分右边界必须传入 而不是 ;虽然在 时传 碰巧不会选错,但严格按定义域传 逻辑才严密。
最后可以通过“去掉操作 lower_bound / upper_bound),无需任何高级数据结构,时间复杂度仅为