线段树上二分

线段树上二分

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 的模版化,但是好写思路简单就对了)。

如果你理解不了这些的话,直接跳过就好,直接按照思路写即可。以后再深入思考。

posted @ 2024-11-23 22:06  blind5883  阅读(1184)  评论(3)    收藏  举报