Can you answer these queries II 做题随笔/题解
文章目录
- Can you answer these queries II 做题随笔/题解
-
- 题意简述
- 正解
-
- 示例:数组 `[5, -10, 6]`
- 结论
- 变故&解决
- 梳理一下
- 实现流程
- 代码
题意简述
洛谷传送门 vjudge传送门 给你一个长度为
n
(
1
≤
n
≤
1
e
5
)
n(1 \\le n \\le 1e5)
n(1≤n≤1e5)的序列
a
a
a,每个数
a
i
∈
[
−
1
e
5
,
1
e
5
]
a_i ∈[-1e5,1e5]
ai∈[−1e5,1e5]。现在有
Q
(
1
≤
Q
≤
1
e
5
)
Q(1 \\le Q \\le 1e5)
Q(1≤Q≤1e5)次提问,包含两个整数
X
,
Y
(
1
≤
X
,
Y
≤
n
)
X,Y(1 \\le X,Y \\le n)
X,Y(1≤X,Y≤n),求出区间最大子段和的值。特别地,对于任何一个权值仅能被计数一次。比如这个样例:
// 输入
9
4 –2 –2 3 –1 –4 2 2 –6
3
1 2
1 5
4 9
// 输出
4
5
3
当第二次询问
X
=
1
,
Y
=
5
X=1,Y=5
X=1,Y=5时,最大子段和为[1,4],权值和
s
u
m
=
4
+
(
−
2
)
+
3
=
5
sum=4+(-2)+3=5
sum=4+(−2)+3=5,
−
2
-2
−2只会被算一次,后面遇到就直接跳过。
正解
既然要维护任意区间内的最大子段和并且多次提问,那一定就是用线段树无疑了。不带修的的最大子段和我们肯定信手拈来,现在问题是“对于任何一个权值仅能被计数一次”,如果用正常的模板代码去做的话可能会漏掉最优解。像是刚才说过的第二次询问,模板输出就是
4
4
4,这肯定不行。
那怎么办呢?
使用瞪眼法——“对于任何一个权值仅能被计数一次”,发现其实就是做“种类区分”。还记得洛谷 P1972 [SDOI2009] HH 的项链吗?虽然我还没A过。那一道题也是统计种类。做法是这样:
将所有询问按右端点 r 排序,从左到右依次将元素加入数据结构,并在加入后回答所有 r == i 的询问。
所以这道题是否也能仿照HH的项链一样求解呢?让我们试一试:
- 预处理
p
r
e
pre
pre数组,pre[i]表示:上一个a[i]出现的位置; - 由于题目没有强制在线,所以对所有询问的右端点从小到大排序;
- 至于线段树……并不能照搬HH的思路,真是个难点
貌似卡住了。
问题源于:我们统计的可不是什么区间内的数字的种数,而是最大子段和。如果按照HH的思路:循环遍历for(int i=1;i<=n;i++),每次在线段树中区间adda[i],即add(1,pre[i]+1,i,a[i])1。但是这样更新完,就只能访问到
i
i
i的最大后缀了,这肯定是不行滴~
又不能把线段树给抛弃了,于是乎就只能在它身上做文章了:
现在结构体
t
r
e
e
tree
tree也就是节点k的类型中有这几个成员变量:
l
,
r
,
s
u
m
,
s
u
m
t
a
g
l,r,sum,sumtag
l,r,sum,sumtag。我们再加一个:
h
i
s
m
a
x
hismax
hismax,干什么用的呢?
- 当节点
k
k
k是线段树的叶子节点时,hismax存:该节点历史sum的最大值(至少为0); - 当节点
k
k
k是线段树的内部节点时,hismax存:左右儿子hismax的最大值;
这样定义了能干嘛呀?我们先要明确:为啥“更新完
k
k
k就只能访问到
i
i
i的最大后缀了”? 看D老师讲解:
假设我们只有 sum(当前最大值),没有 hismax(历史最大值)。那么,当我们扫描到某个右端点 R 时,线段树的每个叶子节点存储的是以该叶子为左端点、以当前 R 为右端点的去重和。查询区间 [l, R] 返回的是 max_{pos ∈ [l, R]} sum[pos],这恰好是以 R 为右端点的最大子段和(也就是最大后缀)。但你无法回答一个询问区间 [l, r],其中 r < R,因为当 R 继续增大后,sum 的值已经变为了以新右端点结尾的和,旧的历史峰值丢失了。
示例:数组 [5, -10, 6]
我们逐步执行扫描(假设去重不影响,因为所有值不同)。
初始化: 所有 sum[pos] = 0。
i = 1,加入 5:
- 区间加 [1,1] +5 → sum[1] = 5。
- 此时(右端点=1),查询 [1,1] 返回 sum[1] = 5(正确,子段 [1,1] 的和为5)。
i = 2,加入 -10:
- 区间加 [1,2] -10 → sum[1] = -5,sum[2] = -10。
- 此时(右端点=2),查询 [1,2] 返回 max(sum[1], sum[2]) = -5。但历史上,在 i=1 时,sum[1]=5,而 i=2 时变为 -5。查询 [1,2] 实际应该考察所有子段(可以是 [1,1] 和 [1,2] 等),最大是 5(子段 [1,1])。但我们现在只能得到 -5,因为 sum[1] 被降低了。
i = 3,加入 6:
- 区间加 [1,3] +6 → sum[1] = 1,sum[2] = -4,sum[3] = 6。
- 现在(右端点=3),查询 [1,3] 返回 max(1, -4, 6) = 6。这个 6 是子段 [3,3] 的和,并非历史上最大的 5(虽然 6 更大,但在其他例子中历史峰值可能更大)。但如果查询 [1,2](右端点为2,但我们只能回答当前右端点=3时的查询),我们无法直接回答,因为我们已经错过了 i=2 时的状态。
关键点: 当我们在处理询问 [l, r] 时,必须在扫描到右端点恰好为 r 时才能回答。如果在扫描到 r 时,某些左端点的 sum 值已经被后来的修改(比如负数的加入)拉低了,而这些左端点历史上曾经有更高的值,那么我们若只存储当前 sum,就会丢失这些历史峰值,导致答案偏小。
结论
没有 hismax,我们只能回答“以当前右端点 i 为结尾的最大子段和”(即最大后缀),因为 max(sum[pos]) 仅代表以 i 为右端点的最大和。而要回答任意区间 [l, r] 的任意子段(右端点可以是 r 之前的任何位置),我们必须记录每个左端点在不同右端点下的历史峰值。这就是 hismax 存在的根本原因。
OK那现在我们理解“为什么需要hismax”了。
也就是说现在——可以爽爽求代码了?
很抱歉,还是不行。
变故&解决
凭啥啊!?
在我们的线段树区间修改中有一个很棘手的问题:信息丢失!
怎么说?
假设父节点 P 在两次不同的时刻,对子节点 C 所在的整个区间进行了区间加操作:
- 此时 C 的当前值从 X 变成了 X+5,历史峰值更新为 X+5。
- 此时 C 的当前值从 X+5 变成了 X-5。虽然当前值降低了,但历史峰值仍然保持在 X+5(因为历史记录不会因为数值下降而抹去)。
现在问题来了:在这个过程中,我们并没有立即将每次修改都下传给 C,而是只更新了父节点 P 上的懒标记。P 记录的信息是:
- sumtag = -5(两次净变化:+5-10)
- 但历史最大净变化:+5
接下来:
当某个操作(如查询或后续修改)触发 push_down 时,父节点 P 只能把 sumtag = -5 传给子节点 C。
那么 C 会做这样的更新:
结果:子节点 C 的历史最大值丢失了 +5 的峰值,本应是 X+5,却错误地变成了 X-5。它完全不知道自己在某个时刻曾经达到过 X+5 这个高度。如此一来子节点的hismax在push_up()时就会偏小,最终答案就错误了。
综上所述:如果不能解决信息丢失的问题,我们的算法——就废啦!

我就是说,谁让你不写主席树的?那现在怎么办?扯了这么久不会告诉我们要推倒重来写主席树吧?
NO NO NO ,大可不必。
那怎么办呢?
且听我娓娓道来:
再定义一个成员变量:
h
i
s
m
a
x
t
a
g
hismaxtag
hismaxtag变量名最长的一集,不知道的以为是AI
ta表示:在当前节点 k 上,所有尚未下传给子节点的、对 sum 的增量中,历史上曾经达到过的最大值。
只要有了ta,当父节点下传标记时,子节点就知道:“虽然现在我只净增加了 sumtag,但我在历史上曾经净增加过 hismaxtag。我的历史最大值应该基于这个更大的增量来更新。”
这下总行了吧?
没~错!到了现在,我们的线段树已经能够完美胜任这道题了!
梳理一下
我们要维护一棵线段树,每个节点包含以下
6
6
6个成员变量:
-
l:节点维护的左端点
-
r:节点维护的右端点
-
sum:
- 当k为叶子时:表示以该位置为左端点、当前扫描到的右端点 i 为结尾的去重子段和。
- 当k为内部节点时:sum为左右儿子sum的最大值
-
hismax:
- k为叶子时:hismax为历史sum的最大值(最小为0)
- 当k为内部节点时:hismax为左右儿子hismax的最大值
-
sumtag:区间修改懒标记
-
hismaxtag:sumtag的历史最大值
实现流程
1
1
1~
n
n
n。在这个过程中区间添加
a
[
i
]
a[i]
a[i],处理所有右边界为
i
i
i的查询(就是找hismax最大值)
终于
代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define ls (k<<1) // 左孩子索引
#define rs (k<<1|1) // 右孩子索引
const int o=1e6+22;
int n,m;
int a[o]={0};
int pre[o]={0}; // pre[i]:a[i]上一次出现的位置
unordered_map<int,int> lst; // 辅助数组,用于计算pre[i]
// 询问结构体
struct node
{
int tid; // 询问编号
int l,r; // 查询区间 [l, r]
} q[o];
// 按右端点排序
bool cmp(node x,node y)
{
if(x.r != y.r) return x.r < y.r;
return x.l < y.l;
}
// 线段树节点
struct tree
{
int l,r; // 节点管辖的区间范围 [l, r] (物理下标)
int sum; // 当前最大值:
// 叶子:以该位置为左端点、当前扫描右端点下的去重子段和
// 内部:左右儿子 sum 的最大值
int hismax; // 历史最大值:
// 叶子:sum 从开始到当前时刻曾经达到的最大值(至少为0)
// 内部:左右儿子 hismax 的最大值
// 懒标记
int sumtag; // 当前增量懒标记:待下传的净增加值
int hismaxtag; // 历史增量懒标记:sumtag 在历史上达到过的最大值
} t[o<<2];
// 向上更新:用左右孩子更新父节点
void push_up(int k)
{
t[k].sum = max(t[ls].sum, t[rs].sum);
t[k].hismax = max(t[ls].hismax, t[rs].hismax);
}
// 下传懒标记
void push_down(int k) {
// 如果没有任何标记,直接返回
if (t[k].sumtag == 0 && t[k].hismaxtag == 0) return;
// 先用旧的 sum 更新 hismax,再更新 sum,否则历史峰值会被当前值覆盖。标记同理。
// 1. 更新左孩子的历史最大值:用孩子旧的 sum + 父节点的历史峰值增量
t[ls].hismax = max(t[ls].hismax, t[ls].sum + t[k].hismaxtag);
t[rs].hismax = max(t[rs].hismax, t[rs].sum + t[k].hismaxtag);
// 2. 更新孩子的当前值
t[ls].sum += t[k].sumtag;
t[rs].sum += t[k].sumtag;
// 3. 更新孩子的历史懒标记:用孩子旧的 sumtag + 父节点的历史峰值增量
t[ls].hismaxtag = max(t[ls].hismaxtag, t[ls].sumtag + t[k].hismaxtag);
t[rs].hismaxtag = max(t[rs].hismaxtag, t[rs].sumtag + t[k].hismaxtag);
// 4. 更新孩子的当前懒标记
t[ls].sumtag += t[k].sumtag;
t[rs].sumtag += t[k].sumtag;
// 5. 清空父节点标记
t[k].sumtag = t[k].hismaxtag = 0;
}
// 建树:初始化所有值为0
void build(int k,int l,int r)
{
t[k] = {l, r, 0, 0, 0, 0};
if(l == r) return;
int mid = (l + r) >> 1;
build(ls, l, mid);
build(rs, mid+1, r);
push_up(k);
}
// 区间加:对 [l, r] 增加 val
void add(int k,int l,int r,int val)
{
// 完全覆盖当前节点
if(l <= t[k].l && t[k].r <= r)
{
// 1. 更新当前增量标记
t[k].sumtag += val;
// 2. 更新当前最大值(所有值同时增加 val,最大值也增加 val)
t[k].sum += val;
// 3. 用新的 sum 更新历史最大值
t[k].hismax = max(t[k].hismax, t[k].sum);
// 4. 用新的 sumtag 更新历史增量标记
t[k].hismaxtag = max(t[k].hismaxtag, t[k].sumtag);
return;
}
// 部分覆盖:下传标记,递归更新孩子
int mid = (t[k].l + t[k].r) >> 1;
push_down(k);
if(l <= mid) add(ls, l, r, val);
if(r > mid) add(rs, l, r, val);
// 回溯更新当前节点
push_up(k);
}
// 区间查询:返回 [l, r] 的历史最大值
int query(int k,int l,int r)
{
// 完全覆盖:直接返回该节点的历史最大值
if(l <= t[k].l && t[k].r <= r)
return t[k].hismax;
int ans = 0; // 因为答案至少为0(空子段)
int mid = (t[k].l + t[k].r) >> 1;
push_down(k); // 查询前必须下传标记,保证孩子值最新
if(l <= mid) ans = max(ans, query(ls, l, r));
if(r > mid) ans = max(ans, query(rs, l, r));
return ans;
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
// 读入数组并预处理 pre[i]
cin >> n;
for(int i=1; i<=n; i++)
{
cin >> a[i];
pre[i] = lst[a[i]]; // 记录上次出现位置(默认0)
lst[a[i]] = i; // 更新为当前位置
}
// 读入询问
cin >> m;
for(int i=1; i<=m; i++)
{
int l, r;
cin >> l >> r;
q[i] = {i, l, r};
}
// 按右端点排序(离线处理)
sort(q+1, q+1+m, cmp);
// 建树(所有节点初始为0)
build(1, 1, n);
vector<int> ans(m+1, 0);
int idx = 1; // 当前待处理的询问下标
// 扫描右端点 i 从 1 到 n
for(int i=1; i<=n; i++)
{
// 加入 a[i]:区间 [pre[i]+1, i] 增加 a[i]
// 这表示所有以 pos (pre[i]+1 <= pos <= i) 为左端点的子段,
// 其去重和都增加 a[i]
if(pre[i] + 1 <= i)
add(1, pre[i]+1, i, a[i]);
// 处理所有右端点恰好为 i 的询问
while(idx <= m && q[idx].r == i)
{
ans[q[idx].tid] = query(1, q[idx].l, i);
idx++;
}
}
// 按原顺序输出答案
for(int i=1; i<=m; i++)
cout << ans[i] << "\\n";
return 0;
}
结束!
这里做个补充说明:本题线段树中,由于在动态扩大上界,所以节点
k
k
k的含义/实际存的东西为:维护从当前节点
k
k
k的左端点到达已知上界
i
i
i的数据。当然,如果当前的
i
i
i已经大于等于建树时给节点
k
k
k分配的右端点时,就只更新到
k
k
k的右端点就可以了。更新范围就是从上次a[i]出现位置更新到
i
i
i。 ↩︎


