区间存最低价、最高价与区间内最优交易,合并时取左段最优、右段最优、右段最高减左段最低三者最大;线段树与倍增两种实现,单次询问 O(log n)。
OJ: roj
题目 ID: 1820
难度:普及+/提高-
形式化题目
给定长度为
其中
下标约束
以样例的第三次询问
暴力解法
思路
照题面写:每次询问拿到
用样例的第二次询问
| 买入 |
卖出 |
|
|---|---|---|
| 1(价 5) | 2 | |
| 1(价 5) | 3 | |
| 1(价 5) | 4 | |
| 2(价 3) | 3 | |
| 2(价 3) | 4 | |
| 3(价 1) | 4 |
最大值
这个写法的正确性不需要额外论证:它枚举了区间内的全部合法数对,一个不漏。
代码
/**
* 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-29 20:18
* update_at: 2026-10-29 20:18
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 直接照题面写:每次询问把区间内所有 i < j 数对枚举一遍,取 A[j] - A[i] 的最大值。
// 单次询问 O(len^2),q 次询问共 O(n^2 q),只适合 n <= 300 的小数据。
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 305; // 暴力只处理 n <= 300 的小规模数据
const ll NEG_INF = -2000000000000000000LL;
ll n, q;
ll a[MAXN]; // a[i]:第 i 天的价格,下标从 1 开始
int main() {
if (scanf("%lld %lld", &n, &q) != 2) return 0;
if (n > MAXN - 1) { // 超出暴力适用范围,直接退出,避免越界
fprintf(stderr, "brute.cpp 仅支持 n <= %d\n", MAXN - 1);
return 0;
}
for (ll i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
for (ll t = 0; t < q; t++) {
ll l, r;
scanf("%lld %lld", &l, &r);
ll best = NEG_INF; // 区间内最好的利润,可能为负
// 枚举区间内所有先买后卖的组合
for (ll i = l; i <= r; i++) {
for (ll j = i + 1; j <= r; j++) {
ll profit = a[j] - a[i];
if (profit > best) best = profit;
}
}
printf("%lld\n", best);
}
return 0;
}复杂度
- 时间:单次询问
, 次询问共 。 - 空间:
,只需要存原数组。
瓶颈
瓶颈是同一批数对被反复枚举,而且重复发生在两个层次上。
第一层在询问之间:不同询问的区间大量重叠,比如
第二层在区间内部:固定卖出时刻
但只靠前缀最小值解决不了本题:它只能回答「从头到某处」这类询问,
而本题的
所以需要一个能把任意区间拼出来的结构:让一段区间的答案能从若干小段的答案合成。
解法总览
暴力层的瓶颈是「同一批数对被反复枚举」。要消掉它,必须让一段区间的答案能从若干小段的答案拼出来。 两种解法用的是同一个合并运算,区别只在「怎么把询问区间拆成若干段、段信息怎么预存」。
| 解法 | 核心观察 | 预处理 | 单次询问 | 空间 | 对应代码 |
|---|---|---|---|---|---|
| 解法一:线段树 | 三元组合并,询问时把区间拆成 |
main.cpp |
|||
| 解法二:倍增 | 同一三元组,预存所有长度为 |
main2.cpp |
两者是替代关系,不是逐步优化:线段树预处理更快、空间更省,适合本题 128 MB 的限制;
倍增的预处理多一个
正式主解是解法一(线段树),即 main.cpp。
合并运算:两种解法共用的基础
把一段区间切成前后相邻的两段
- 买入、卖出都在
内; - 买入、卖出都在
内; - 在
内买入、在 内卖出。
前两种是原问题的子问题,第三种跨过了两段的交界。第三种情形里,买入时刻一定落在
式子的左项是
至于这两段怎么切——按中点对半分,还是按
于是只要每段区间存下三个数,就能把两段合成一段:
| 存的量 | 含义 | 合并方式 |
|---|---|---|
minv |
区间内最低价 | |
maxv |
区间内最高价 | |
best |
区间内最优交易 |
best 的第三项就是上面的
光有半群还不够,本题的合并还有两个必须记住的性质:
- 不满足交换律:
, 所以合并参数不能颠倒,左段必须在前。 - 不满足幂等律:
。这一条决定了重叠查询不能做,详见解法二里那一节。
用样例
| 段 | minv |
maxv |
best |
|---|---|---|---|
| 1 | 5 | ||
| 3 | 4 | ||
| 合并 |
1 | 5 |
合并结果 best 分别是
解法一:线段树
思路
把上面的三元组放进线段树。每个结点存自己管辖区间的
有三处细节决定正确性:
第一,叶子结点的 best 要设成负无穷。
叶子只有一天,构不成先买后卖。若设成 0,单天会被当作「利润为 0 的交易」,
那么一路递减的区间就会错答成 0 而不是负数。
第二,合并时左段必须在前。
跨段项是
第三,询问的三个分支要保持顺序。 完全被覆盖时直接返回该结点;只与左儿子相交就只查左边;只与右儿子相交就只查右边; 跨过中点时先查左边、再查右边,然后合并。这个顺序保证了拼出来的片段与真实时间顺序一致。
正确性
引理:设
记等式右边为
- 三项各自都对应一个
内的合法交易: 对应左半段内的某个 ; 对应右半段内的某个 ;这两对下标都落在 内。 - 第三项里,
在某时刻 取到, 在某时刻 取到, 于是 是一对合法下标,且 是真实利润。 - 三项都是真实利润,都不超过
,取最大后 。
- 设最优交易为
, 。 - 若
,交易全在 内, 。 - 若
,交易全在 内, 。 - 若
,则 且 , 所以 。 - 三种情形穷尽了所有可能,每种都有
。
两个方向合起来得
两个方向都需要:只证
查询过程:线段树查询把
代码
/**
* 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-29 20:18
* update_at: 2026-10-29 20:18
*/
// main.cpp:ROJ 1820 区间最大利润(线段树)
// 询问 max(A[j] - A[i]),l <= i < j <= r。对每个区间只需要记住三件事:
// minv:区间内最低价(最适合买入)
// maxv:区间内最高价(最适合卖出)
// best:区间内 max(A[j] - A[i]),i < j
// 合并左右两段 L、R 时,最优交易要么全在 L 里、要么全在 R 里、
// 要么在 L 里买在 R 里卖,第三种的利润是 R.maxv - L.minv。
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
const ll NEG_INF = -2000000000000000000LL; // 答案可能为负,初值取得足够小
// 一段区间的交易信息
struct Node {
ll minv; // 区间内最低价
ll maxv; // 区间内最高价
ll best; // 区间内 max(A[j] - A[i]),要求买入时刻早于卖出时刻
};
ll n, q;
ll a[MAXN]; // a[i]:第 i 天的价格,下标从 1 开始
Node tree[MAXN * 4]; // tree[p]:结点 p 覆盖的那段区间的交易信息
// p 的左儿子与右儿子编号(模板约定的 1 下标完全二叉树编号)
ll ls(ll p) { return p << 1; }
ll rs(ll p) { return p << 1 | 1; }
// 把左右相邻的两段合并成一段
Node merge_node(Node left_seg, Node right_seg) {
Node res;
res.minv = min(left_seg.minv, right_seg.minv);
res.maxv = max(left_seg.maxv, right_seg.maxv);
// 三种来源:全在左段、全在右段、左段买入右段卖出
res.best = max(max(left_seg.best, right_seg.best), right_seg.maxv - left_seg.minv);
return res;
}
// 建树:叶子就是一天的价格
void build(ll p, ll l, ll r) {
if (l == r) {
tree[p].minv = a[l];
tree[p].maxv = a[l];
tree[p].best = NEG_INF; // 只有一天,构不成先买后卖
return;
}
ll mid = (l + r) >> 1;
build(ls(p), l, mid);
build(rs(p), mid + 1, r);
tree[p] = merge_node(tree[ls(p)], tree[rs(p)]);
}
// 查询区间 [ql, qr] 的交易信息
Node query(ll p, ll l, ll r, ll ql, ll qr) {
if (ql <= l && r <= qr) return tree[p]; // 当前结点被完全覆盖
ll mid = (l + r) >> 1;
if (qr <= mid) return query(ls(p), l, mid, ql, qr); // 只查左儿子
if (ql > mid) return query(rs(p), mid + 1, r, ql, qr); // 只查右儿子
// 跨过中点:左右都要查,再按顺序合并(左段在前,右段在后)
Node left_seg = query(ls(p), l, mid, ql, qr);
Node right_seg = query(rs(p), mid + 1, r, ql, qr);
return merge_node(left_seg, right_seg);
}
void solve() {
scanf("%lld %lld", &n, &q);
for (ll i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
build(1, 1, n);
for (ll i = 0; i < q; i++) {
ll l, r;
scanf("%lld %lld", &l, &r);
Node res = query(1, 1, n, l, r);
printf("%lld\n", res.best);
}
}
int main() {
solve();
return 0;
}复杂度
- 时间:建树
,单次询问 ,总 。 - 空间:
, tree开个结点、每个结点三个 ll,时约 10 MB,实测峰值 8.5–10 MB。
对比暴力层的
解法二:倍增
思路
倍增的思路是:把所有长度为
预处理的递推很直接。jump_table[k][i] 表示区间
式子的意思是:一个长度
查询时从大到小枚举
从 k = max_log 递减到 0:
若 [l, l + 2^k - 1] 还没超出 r:
把 jump_table[k][l] 并入答案
l += 2^k这就是倍增文章的模板里那个「从大到小枚举
为什么这里不能直接用常见的 ST 表
常见的 ST 表查询取两个长度为
这种写法能成立,前提是合并运算满足可重复贡献性: 两个重叠的块合起来,结果必须等于它们并集那段区间的真实信息。 取最大值、最小值、最大公约数都满足这一条(重叠部分的元素被算两次也不改变结果), 所以那几类问题用重叠 ST 表没有问题。
本题的合并运算不满足这一条。它的第三项是
拿样例的询问
| 块 | minv |
maxv |
best |
|---|---|---|---|
| 左块 |
1 | 5 | |
| 右块 |
1 | 5 | |
| 重叠合并 | — | — |
合并结果
这不是个别数据的问题:把这种写法拿去跑本题的 12 组公开数据,有 9 组答案偏大。
重叠不只发生在两块完全相同时。取询问
| 块 | 覆盖 | minv |
maxv |
best |
|---|---|---|---|---|
| 左块 | 3 | 5 | ||
| 右块 | 1 | 3 | ||
| 重叠合并 | — | — | — |
算出
所以本题的倍增必须用互不重叠的切分,也就是上面「从大到小能放就放」的写法: 每一段只被取一次、只合并一次,跨段项永远对应「先取走的段买入、后取走的段卖出」, 方向天然正确,不需要可重复贡献性。
与 rbook《ST 表》一文的对照。 那篇文章讲的是标准重叠查询,适用前提是合并运算可重复贡献, 处理区间最大值、最小值、最大公约数这类问题时照它的写法没有问题。本题的合并运算带方向, 恰好落在它的适用范围之外,所以不能直接套。本文的写法对应的是 rbook《倍增跳跃》那篇的 「能跳就跳」模板;两篇文章本来就是前后关系(ST 表以倍增为前置), 读完倍增再看 ST 表,正好能看清「什么时候两个块可以重叠、什么时候不能」这条分界线。
代码
/**
* 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-29 20:22
* update_at: 2026-10-29 20:22
*/
// main2.cpp:ROJ 1820 区间最大利润(倍增)
// 预处理 jump[k][i] = 区间 [i, i + 2^k - 1] 的交易信息,由 k-1 层的相邻两段合并得到。
// 查询 [l, r] 时从大到小枚举 2^k,能放下就取走这一段并让 l 前移,直到区间被取完。
//
// 注意:这里不能写成常见的「重叠 ST 表」查询。那种写法取两块长度为 2^k 的区间让它们
// 重叠,正确性依赖合并运算幂等(op(x, x) = x)。本题的 best 含跨段项 R.maxv - L.minv,
// 两块重叠时会把同一段既当左半又当右半,配出「先卖后买」的非法对,答案会偏大。
// 下面的写法每段只合并一次、互不重叠,所以不依赖幂等性。
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 100005;
const int MAXLOG = 18; // 2^17 = 131072 > 10^5,够用
const ll NEG_INF = -2000000000000000000LL; // 答案可能为负,初值取得足够小
// 一段区间的交易信息,含义与 main.cpp 完全一致
struct Node {
ll minv; // 区间内最低价
ll maxv; // 区间内最高价
ll best; // 区间内 max(A[j] - A[i]),要求买入时刻早于卖出时刻
};
ll n, q;
ll a[MAXN]; // a[i]:第 i 天的价格,下标从 1 开始
Node jump_table[MAXLOG][MAXN]; // jump_table[k][i]:区间 [i, i + 2^k - 1] 的交易信息
int max_log; // 满足 2^max_log <= n 的最大 max_log
// 把左右相邻的两段合并成一段
Node merge_node(Node left_seg, Node right_seg) {
Node res;
res.minv = min(left_seg.minv, right_seg.minv);
res.maxv = max(left_seg.maxv, right_seg.maxv);
res.best = max(max(left_seg.best, right_seg.best), right_seg.maxv - left_seg.minv);
return res;
}
// 预处理:第 0 层是每个单点,之后每层由上一层相邻两段合并
void build_table() {
for (ll i = 1; i <= n; i++) {
jump_table[0][i].minv = a[i];
jump_table[0][i].maxv = a[i];
jump_table[0][i].best = NEG_INF; // 只有一天,构不成先买后卖
}
max_log = 0;
while ((1 << (max_log + 1)) <= n) max_log++;
for (int k = 1; k <= max_log; k++) {
int half = 1 << (k - 1); // 半段长度 2^(k-1)
for (ll i = 1; i + (1 << k) - 1 <= n; i++) {
jump_table[k][i] = merge_node(jump_table[k - 1][i],
jump_table[k - 1][i + half]);
}
}
}
// 查询 [l, r]:从大到小尝试 2^k,放得下就取走这一段,等价于倍增文章里「能跳就跳」
Node query(ll l, ll r) {
Node res;
bool has_piece = false; // 是否已经取到第一段
for (int k = max_log; k >= 0; k--) {
if (l + (1 << k) - 1 > r) continue; // 这一段放不进剩余区间,跳过
if (!has_piece) {
res = jump_table[k][l];
has_piece = true;
} else {
res = merge_node(res, jump_table[k][l]);
}
l += 1 << k; // 这一段已经取走,左端点前移
}
return res;
}
void solve() {
scanf("%lld %lld", &n, &q);
for (ll i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
build_table();
for (ll i = 0; i < q; i++) {
ll l, r;
scanf("%lld %lld", &l, &r);
Node res = query(l, r);
printf("%lld\n", res.best);
}
}
int main() {
solve();
return 0;
}复杂度
- 时间:预处理
,单次询问 (从大到小枚举各层),总 。 - 空间:
。表里共 个三元组, 时约 38 MB,实测峰值 38.5–40 MB。
与解法一相比,倍增多花了
复杂度对比
| 解法一:线段树 | 解法二:倍增 | |
|---|---|---|
| 预处理 | ||
| 单次询问 | ||
| 空间 | ||
| 实测峰值内存 | 8.5–10 MB | 38.5–40 MB |
| 实测最大单点 | 109–170 ms | 114–143 ms |
| 适用场景 | 本题首选,空间充裕、可能带修改时更合适 | 纯静态查询,且希望代码贴近倍增模板时 |
两者询问复杂度相同。线段树在预处理和空间上都更优,所以本题选它作主解; 倍增的价值在于思路统一、代码短,且它是理解 ST 类结构的入口。
总结
本题的关键不在数据结构,而在找到能支撑合并的区间状态。
区间状态只需要三个数,这个结论来自交易位置的三分:
- 交易全在左段,用左段的最优值;
- 交易全在右段,用右段的最优值;
- 交易跨过中点,此时买入必在左、卖出必在右,只需左段最低价与右段最高价。
三种情形互斥且穷尽,所以合并式不会漏解,也不会引入非法解。 这也是判断能不能用线段树或倍增的通用思路:先问「一段区间的答案能否由若干小段的答案拼出来」, 能拼,才有资格上树或上表。
确定了合并运算之后,两种解法的分歧只在「怎么预存、怎么拆区间」:
线段树按中点递归切分,倍增按
实现上最容易写错的有三处。叶子 best 若不设成负无穷,递减区间会错答成 0;
合并参数若左右颠倒,跨段项会算成「先卖后买」;用倍增时若套用常见的重叠 ST 表,
同一段会被当成左右两半,同样配出非法交易。这三个错误都不会让程序崩溃,
只能靠能暴露负答案和跨段最优的样例测出来。