线段树上二分
线段树上二分
update 2025.8.21 发现了一处错误,在这里深感抱歉。
欢迎指出错误,或提供更优的代码方案。
线段树的奇幻科技——线段树上二分 - Mercury_City - 博客园 (cnblogs.com) 这篇博客讲的好,但仍不详细。
不是二分+线段树,是直接利用线段树去二分查找。从而把 \(O(\log^2 n)\) 变为 \(O(\log n)\)。
基本原理是直接利用当前的左子树和右子树,去判断目的解是在左还是右,然后直接进入有解的子树。而对于限制区间,只需要按正常查询的思维,只去与目标区间相交(包括在之内的情况)的区间。因为如果这个区间不完全包含在目标区间内,那么提供的信息就不准确,所以要到完全包含的区间内提供信息。看了代码应该就理解这句话了。
我研究了得一天,下面给出我测试所用的代码包,和这个优化的效果。
一个基本的例题是查找区间 \([1,n]\) 内第一个大于 \(x\) 的数的下标。按照意思可以写出以下代码:
int query(int u, int x)
{
if (tr[u].l == tr[u].r) return tr[u].l;
else
{
int mid = l + r >> 1; // 实际上是不用写的,因为我们直接去左右儿子
if (tr[u << 1].maxv > x) return query(u << 1, l, r, x); // 如果左边有就直接去左边
else return query(u << 1 | 1, l, r, x); // 否则一定在右边
}
}
但是这会导致一个问题,即如果不存在这样的数,我们没法表示出,所以要表示无解情况,一般以 \(-1\) 作为无解情况。一般,我们直接判断当前区间是否可行(即最大值是否大于目标值)就行,即:
int query(int u, int x)
{
if (tr[u].l == tr[u].r)
{
if (tr[u].maxv <= x) return -1; // 判断无解情况
return tr[u].l;
}
else
{
if (tr[u].maxv <= x) return -1; // 判断无解情况
int mid = l + r >> 1; // 多余了
if (tr[u << 1].maxv > x) return query(u << 1, l, r, x);
else return query(u << 1 | 1, l, r, x);
}
}
我们简化一下,把两个地方的判断都提出来,就变为:
int query(int u, int x)
{
if (tr[u].maxv <= x) return -1; // 判断无解情况
if (tr[u].l == tr[u].r) return tr[u].l;
else
{
int mid = l + r >> 1; // 多余了
if (tr[u << 1].maxv > x) return query(u << 1, l, r, x);
else return query(u << 1 | 1, l, r, x);
}
}
每次要么去左边要么去右边,所以此时的时间复杂度为 \(O(\log n)\)。
如果限制区间为 \([l, r]\) 呢?按照上面说的,如果有限制区间,那么当前区间和限制区间的关系就分为相交,被包含和不相交。对于不相交,我们就不进入子树,而对于相交,只要相交就进入对应子区间,因为要利用相交的那部分的信息。实际上这部分和正常 query 一样。
对于包含则进行上面那样的二分。如果到了对应节点就返回信息。实际上对于不相交的情况,我们不用管他,只进行相交的进入子树即可。
于是可以写出以下代码:
int query(int u, int l, int r, int x)
{
if (tr[u].maxv <= x) return -1; // 如果无效就直接返回-1,为什么这么写下方有解释
if (tr[u].l == tr[u].r) return tr[u].l; // 找到一个解
else if (l <= tr[u].l && tr[u].r <= r) // 被目标区间包含,可以提供信息
{
if (tr[u << 1].maxv > x) return query(u << 1, l, r, x);
else return query(u << 1 | 1, l, r, x);
}
else // 仅相交,要继续划分区间,直到被包含
{
int mid = tr[u].l + tr[u].r >> 1;
int res = -1;
if (l <= mid) res = query(u << 1, l, r, x); // 去含有的区间内, 和正常query一样
if (res == -1 && r > mid) res = query(u << 1 | 1, l, r, x);
return res;
}
}
整体思路就是,正常 query 进限制区间,在限制区间内二分,直到有解返回。
二分是可以封边界的,上面代码就封左边界,即找尽量靠左的符合条件的点。对于封左/右边界,你只需要让它趋于进入左/右子树即可。给出对应封右的代码。
int query(int u, int l, int r, int x)
{
if (tr[u].maxv <= x) return -1; // 去除无效解
if (tr[u].l == tr[u].r) return tr[u].l;
else if (l <= tr[u].l && tr[u].r <= r)
{
if (tr[u << 1 | 1].maxv > x) return query(u << 1 | 1, l, r, x); // 尽量先进右子树
else return query(u << 1, l, r, x);
}
else
{
int mid = tr[u].l + tr[u].r >> 1;
int res = -1;
if (r > mid) res = query(u << 1 | 1, l, r, x); // 同理
if (res == -1 && l <= mid) res = query(u << 1, l, r, x);
return res;
}
}
最后提醒一下,这是查询,如果有懒标记的话,记得 pushdown,第二个 if 里面也要。
例题
来做做例题把,我费劲自己搞得 U502676 线段树上二分模版 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
还有一个例题 P11217 【MX-S4-T1】「yyOI R2」youyou 的垃圾桶 - blind5883 - 博客园 (cnblogs.com)
一些讨论(下面的内容可不看)
这里需要说明一点,对于去除无效解这句话,是必须的,不只是为了保证解的正确,也是为了保证时间复杂度为 \(O(\log n)\)。
如果把这句话只放到第一个 if 里面(即找到解时判断),会导致时间复杂度在特殊情况下退化为 \(O(\log^2n)\),比如这种情况:对封左边界的代码,考虑当 \(n\) 为 \(2\) 的次幂,查询下标为 \([2,n-1]\) 内的答案,并且答案为下标为 \(n - 1\) 的时候。(详见下面一楼评论)
因为在进入限制区间的时候,我们可能会进入很多包含在限制区间之内的区间,这些区间可能是不含我们需要的解,如果我们依旧进入这些区间,就会在这个区间内跑一个彻彻底底的二分。而在最坏情况下,这样的区间最坏有 \(\log n\) 个,每次二分是 \(O(\log n)\) 的,就会使得时间复杂度降为 \(O(\log^2 n)\)。
如果这句话你只放到第二个 if 里面(对区间二分),那么当在 \(l=r\) 时你会直接跳过第二个 if,无法判断解是否可行,从而会使答案错误。
因此两个 if 里面必须都含这句话,而实际上,对于第三个 if 里面(即进入限制区间),这个判断可有可无,因为它只是进入限制区间,而不求出结果。因此为了方便,我们直接把判断写到函数的最前面,让无论进入哪个 if 都判断是否有解,由此来规避这些情况,同时减少码量(确实会打破一点线段树 if 的模版化,但是好写思路简单就对了)。
如果你理解不了这些的话,直接跳过就好,直接按照思路写即可。以后再深入思考。

浙公网安备 33010602011771号