线段树维护区间最大值,查询时用「这段最大值都不大于 k 就整块剪掉」的判据单路下降定位第一个大于 k 的位置,把二分的 O(log^2 n) 降到 O(log n)。
OJ: luogu
题目 ID: U502676
难度:提高
标签:线段树二分数据结构输入输出
创建: 2026-10-27 15:34
更新: 2026-10-27 20:25
RBook 文章:
线段树:单点修改与区间查询
值域线段树:查找第一个小于阈值的位置
二分查找
形式化题目
给定长度为
若该集合为空则回答
解法路线
题目给了三档测试点: 第
| 层次 | 适用约束 | 做法 | 复杂度 | 相对前层的推进 |
|---|---|---|---|---|
| 暴力解法 | 任意, 但只有 |
从 |
基线 | |
| 中间层: 位置二分 | 第 |
线段树维护区间最大值, 在位置上二分第一个使 |
复用"段最大值就能回答段内有无大于 |
|
| 正解: 带剪枝下降 | 全部测试点 | 线段树维护区间最大值, 从根往下走, 遇到 |
把二分的判定搬进下降过程, 消掉外层那个 |
关键跳跃是第二步: 不能直接对
正式主解是最后一步, 对应 main.cpp. 中间层用 main2.cpp 单独给出, 因为它正是题面第
暴力解法
适用范围
定义直接给出的做法, 适用范围没有限制, 但每次询问都要扫一整段, 只有
思路
从
正确性由定义直接得到, 不需要额外论证.
代码
/**
* 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-27 17:04
* update_at: 2026-10-27 17:04
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 每次询问直接从 l 往右逐个数检查,遇到第一个大于 k 的就停下,单次 O(r-l+1)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2000000 + 5;
int n, m;
int a[MAXN]; // a[i] 只有 2e6 以内且只做大小比较,用 int 省内存
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) {
int l, r, k;
cin >> l >> r >> k;
int answer = -1; // 找不到答案时输出 -1
for (int j = l; j <= r; j++) {
if (a[j] > k) {
answer = j; // 从左往右第一个满足条件的下标
break;
}
}
cout << answer << '\n';
}
return 0;
}复杂度与瓶颈
单次询问
瓶颈很清楚: 对每个询问都从零开始"现场看", 区间里已经看过的信息一点也没留下来. 要想快, 必须先把数列预处理成某种能"快速回答一段里有没有大于
中间层: 区间最大值 + 位置二分
适用范围
思路
一段里有没有大于
定位靠一句话: 前缀最大值具有单调性. 在位置上二分, 每次判定问"
一个容易想错的地方: 不能直接对
t : 1 2 3 4 5
a_t : 1 5 2 1 1
a_t>k : 假 真 假 假 假 <- 不是一段假接一段真
max(a_1..a_t) >k : 假 真 真 真 真 <- 换成最大值就单调了若按 “第一个为真” 去二分
而且这个判据转回题面正好对得上: “
于是每次询问: 在 query_max(l, t); 二分结束记下的最后一个"真"位置就是答案, 全程无真则输出
下图把这个过程画在数组上, 不用想线段树怎么存. 例子
看第二行就够了. 前三项的区间最大值都是 query_max(2,4)=3 不大于 lo 推到 query_max(2,6)=5>3, 记下答案并把 hi 收到 query_max(2,5)=5>3, 答案收紧到
代码
/**
* 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-27 17:04
* update_at: 2026-10-27 19:52
*/
// main2.cpp:中间层对照解。线段树求区间最大值,再在位置上二分答案,每次询问 O(log^2 n)。
// 正确性没问题,但每次询问要多付一个 log,大数据下明显更慢。
// 这里故意用 cin/cout,不写快读:这一层本来就要 TLE,读者该看的是二分逻辑本身。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2000000 + 5;
const int MAXNODE = MAXN * 4;
int n, m;
int a[MAXN]; // a[i] 只有 2e6 以内且只做大小比较,用 int 省内存
int mx[MAXNODE]; // mx[p]:结点 p 覆盖区间里的最大值
void push_up(int p) {
mx[p] = max(mx[p << 1], mx[p << 1 | 1]);
}
void build(int p, int l, int r) {
if (l == r) {
mx[p] = a[l];
return;
}
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
push_up(p);
}
// 区间最大值查询:[ql, qr] 内最大的 a[i],区间封闭在 [1, n] 内
int query_max(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return mx[p];
int mid = (l + r) >> 1;
int answer = 0; // a[i] >= 1,用 0 当"还没有值"
if (ql <= mid) answer = max(answer, query_max(p << 1, l, mid, ql, qr));
if (qr > mid) answer = max(answer, query_max(p << 1 | 1, mid + 1, r, ql, qr));
return answer;
}
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);
for (int i = 1; i <= m; i++) {
int l, r, k;
cin >> l >> r >> k;
// prefix_max(t) = max(a[l..t]) 随 t 增大单调不降,
// 所以"prefix_max(t) > k"在位置上单调,可以二分第一个成立的位置
int lo = l;
int hi = r;
int answer = -1;
while (lo <= hi) {
int mid = (lo + hi) >> 1;
if (query_max(1, 1, n, l, mid) > k) {
answer = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
cout << answer << '\n';
}
return 0;
}复杂度与瓶颈
二分有
实测这层在真实限制 (1000ms / 512MB) 下的成绩: 第
瓶颈在于二分不知道树的结构. 每次判定 query_max(l, mid) 都要从根重新走一趟树, 而相邻两次二分的区间大量重叠. 这个重复的下行正是下一步要消掉的东西.
这层的代码用 cin/cout, 没有写快读. 这是个有意的选择: 它本来就是超时的, 快读也救不回来 (实测换成快读后判定结果不变), 所以把五十行 IO 代码去掉, 让注意力留在二分逻辑上. 具体数据见文末「总结」里的对比表.
正解: 带可行性剪枝的单路下降
关键观察
二分的每次判定问的是"
二分没法利用这点, 因为它只会"从根查一个区间". 但如果让下降过程自己带着这个判据, 就不需要外层二分了.
思路
让叶子按原下标排布, 每个结点存它覆盖区间的最大值 query_first(p, l, r, ql, qr, k) 处理结点
与 不相交, 返回 ; , 这一整块都不可能有答案, 返回 ; , 已经到叶子, 而规则 2 没拦住它, 说明 , 返回 ; - 否则先递归左儿子, 左儿子返回非
就直接用它; 否则递归右儿子.
规则 4 先左后右, 这正是"最靠左"的来源: 左边只要还有答案, 右边就不用看.
规则 2 是整个算法的关键. 少了它, 程序在"整段都不可能有答案"的区间上仍然会一路走到底, 退化得比二分还慢. 题面第
下图用
沿着蓝色路径走: 从根
为什么是
关键是把"每次询问访问多少结点"算清楚. 按与询问区间
- 被端点穿过: 区间内部含着
或 . 每个端点在一层里只落在 个结点内, 所以这类每层至多 个. - 与询问区间不相交: 整段落在
之外. 这种结点只会作为"被端点穿过"结点的孩子被进入 (若父亲就不相交, 规则 已经返回, 不会再生孩子), 所以每层至多 个. - 完全落在
内: 这种结点同样只能是被端点穿过结点的孩子 (或就在下降链上), 每层至多 个.
三类相加, 每层被访问的结点数是常数 (上界
这里不需要区分结点是被规则
规则
题面第
两个结论都做了机械核对:
- 每层结点数: 在
上穷举全部 组合, 配 种 形态与 种 , 共 组, 实测每层被访问结点数最大为 (低于上述上界 ). - 单次询问总量: 在
上取随机 / 递增 / 递减 / 全相等 / 单尖峰共 种形态 × 组询问, 最多访问 个结点, 而 .
实测值比推导出的上界小是正常的: 上界只用了"每层常数个", 没利用左右两端链在树上的具体位置.
代码
/**
* 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-27 17:04
* update_at: 2026-10-27 17:04
*/
// main.cpp:正解。线段树维护区间最大值,查询时带着"子树里还有没有大于 k 的数"这个判据单路下降,每次 O(log n)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2000000 + 5;
const int MAXNODE = MAXN * 4;
int n, m;
int a[MAXN]; // a[i] 只有 2e6 以内且只做大小比较,用 int 省内存
int mx[MAXNODE]; // mx[p]:结点 p 覆盖区间里的最大值
// 输入输出可达 30MB、约 5e6 个整数,cin/cout 在这道题的时限下不够用,改为手写读写
const int BUFSIZE = 1 << 16;
char in_buf[BUFSIZE];
int in_len = 0;
int in_pos = 0;
inline char read_char() {
if (in_pos == in_len) {
in_len = fread(in_buf, 1, BUFSIZE, stdin);
in_pos = 0;
if (in_len <= 0) return 0;
}
return in_buf[in_pos++];
}
inline int read_int() {
char c = read_char();
while (c != 0 && (c < '0' || c > '9')) c = read_char();
int x = 0;
while (c >= '0' && c <= '9') {
x = x * 10 + (c - '0');
c = read_char();
}
return x;
}
char out_buf[BUFSIZE];
int out_len = 0;
inline void write_int(int x) {
if (out_len + 16 > BUFSIZE) {
fwrite(out_buf, 1, out_len, stdout);
out_len = 0;
}
if (x < 0) {
out_buf[out_len++] = '-';
x = -x;
}
char tmp[16];
int len = 0;
do {
tmp[len++] = char('0' + x % 10);
x /= 10;
} while (x > 0);
while (len > 0) out_buf[out_len++] = tmp[--len];
out_buf[out_len++] = '\n';
}
// 上推:父结点的最大值取两个儿子的较大者
void push_up(int p) {
mx[p] = max(mx[p << 1], mx[p << 1 | 1]);
}
void build(int p, int l, int r) {
if (l == r) {
mx[p] = a[l];
return;
}
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
push_up(p);
}
// 在位置区间 [ql, qr] 中找第一个 a[i] > k 的下标,找不到返回 -1
// 关键判据:mx[p] <= k 说明整段都不可能有答案,直接剪掉,不再往下走
int query_first(int p, int l, int r, int ql, int qr, int k) {
if (qr < l || r < ql) return -1; // 这一段和询问区间不相交
if (mx[p] <= k) return -1; // 这一段的最大值都不大于 k,整段无解
if (l == r) return l; // 走到叶子,且它一定大于 k
int mid = (l + r) >> 1;
int answer = query_first(p << 1, l, mid, ql, qr, k); // 左边优先,保证取到最靠左的答案
if (answer != -1) return answer;
return query_first(p << 1 | 1, mid + 1, r, ql, qr, k);
}
int main() {
n = read_int();
m = read_int();
for (int i = 1; i <= n; i++) a[i] = read_int();
build(1, 1, n);
for (int i = 1; i <= m; i++) {
int l = read_int();
int r = read_int();
int k = read_int();
write_int(query_first(1, 1, n, l, r, k));
}
if (out_len > 0) fwrite(out_buf, 1, out_len, stdout);
return 0;
}复杂度
预处理建树
空间是 int 的线段树, 另加原数组 int. int, 约
实测峰值出现在官方 data3 上 (int / 元素"的量级吻合.
总结
这道题的教学价值在于同一个判据的两种用法.
"
题面第 [2, 1048575] k=1048574 的数据上, 有剪枝时单次询问访问
最后是题面点名的工程问题: 输入输出总量约
| 读入方式 | 只读数、不做算法 |
|---|---|
cin 关同步 |
|
fread 手写缓冲 |
差了约
main.cpp是正式主解, 必须保住余量, 用手写fread/fwrite. 快读版在 data2 上稳定在ms 左右, 换成 cin后升到ms 左右, 虽然还能过但余量从三倍多压缩到不到两倍, 评测机抖一下就危险了. main2.cpp用cin/cout. 它本来就是因为多一个而 TLE, 快读只能把 s 降到 s 这种量级, 判定结果一模一样 (两版都是 TLE 1/3). 与其贴五十行 IO 代码分散注意力, 不如让读者直接看二分逻辑. brute.cpp也是关同步的cin/cout, 它只用在小数据对拍.
这也说明一个常被误判的事: 快读不能救复杂度. main2.cpp 不能用快读变快, main.cpp 也不是靠快读才过的.