整体二分
感谢 Sentoayaka
姐姐的帮助,没有她就没有这篇文章。我爱神里凌华❥
引入
这是一道主席树板子:https://www.luogu.com.cn/problem/P3834
给你一个长为 \(n\) 数组 \(a\) 和多次询问,每次询问包含一个区间,你要寻找这个区间的第 \(k\) 小
如果只有一次询问,那么直接扫一遍是 \(O(n)\) 的。
现在我们有了多次询问,所以我们抛开预处理复杂度,尝试优化询问。
发现我们如果将序列排好序,那么只需要 \(O(\log n)\) 就可以二分查找答案。
但是排序也是需要时间的。如果每次询问都排序,那么还是复杂度超标。
思考这样一件事情,我们其实是排了很多重复的序的。如果只排一遍序就能二分解决所有问题,那么时间复杂度就是正确的。
因此我们初步得到了一个策略:用一次二分+内部的排序将所有答案处理出来。预计复杂度 \(O(n\log n\log n)\)
这种二分方法叫做整体二分。
以下内容可结合 CDQ 分治理解。因为整体二分表面上叫二分,实际更像分治算法。作者之后也会分析CDQ 分治与整体二分的异同。
算法框架
我们从普通的二分想起,普通二分的思路即枚举答案区间 \(l\) 与 \(r\),得出 \(mid\) 以后统计 \(<=mid\) 的数有多少个,\(<=k\) 则答案在右区间,否则答案在左区间。
很容易得出优化:如果答案在右区间,我们就将 \(k\) 减去 \(<=mid\) 的数个数,并且删去原数组中 \(<=mid\) 的数。
肯定有同学会说:你这优化不跟没优化一样吗?在普通二分中,确实差不多。但你先别急。
整体二分,实际上就是把很多次这样的二分一起做了。所以二分的东西是一样的,就是第 k 大的数的大小。在这里表示为答案区间 \(l\) 到 \(r\),最终二分到 \(l=r\) 时可以确定此时的这个地方的询问答案都是 \(l\)。
看得出来,我们需要统计 \(l=r\) 时有哪些询问。所以我们还需要两个参数:\(ql\) 和 \(qr\) ,表示答案区间在 \(l\) 到 \(r\) 之间的数组下标范围。
除了二分答案,我们还需要做的事是对权值像对询问一样分组。对权值分组的作用即为上面的优化,这时其作用就凸显出来了:我们可以缩减每次二分处理的权值个数,使其与答案区间同阶。
另一个需要注意的点是,可能 \(l \neq r\) 时,就没有询问的答案在此之中。此时显然充要条件为 \(ql > qr\)。遇到这种情况要直接返回
现在我们得到了一个solve函数。它大概长这样:
void solve(int ql, int qr, int l, int r)
{
if(ql > qr) return;
if(l == r)
{
// 对答案数组赋值
}
int mid = l + r >> 1;
//神秘的操作,其中会处理出新的 ql 与 qr。
solve(l, mid);
solve(mid + 1, r);//前面两个参数为新的 ql 和 qr
}
神秘的操作
在神秘的操作中,我们需要处理出新的 \(ql\) 和 \(qr\) ,本质上就是将原有的值和询问组成的序列分成左右两部分。不妨思考什么询问与值需要放在左半边。根据 \(mid\) 的定义,放在左半边的询问的答案要 \(<=mid\),而放在左半边的权值要 \(<= mid\)。
如何判定?我们把所有 \(<=mid\) 的权值全丢全部转化为权值为 1,下标为原数组下标的点丢进树状数组里面,对于每一个询问 \(l,r,k\) 查询树状数组中对应的\([l,r]\)区间和,如果区间和 \(>=k\) 则表示答案 \(<=mid\)。因为此时\(<=mid\) 的数已经超过 \(k\) 个,所以第 \(k\) 大的数必在其中。
再次查看上面的函数,被我们分到同一组的询问与权值会在之后进行处理,所以我们要处理的只有跨组的权值对询问的影响。之后我们把 \(<=mid\) 的段称作左段, \(>mid\) 的称作右段。
由于左段询问答案必须小于 \(mid\)。所以右段的权值对左段完全无影响,我们只需统计左段权值对右段询问的影响即可。统计方法与普通二分的优化相同,查询询问对应的区间和,将右段的询问中的 \(k\) 减去这个区间和,这样之后就只需处理段内的权值即可。
发现没有,CDQ 分治是将两个段的区间处理好,再处理左区间对右区间的贡献。而整体二分是反过来。这是因为 CDQ 分治的最终目的是求出大区间的答案,而整体二分的目的是将答案范围进行进一步的划分。
void solve(int ql, int qr, int L, int R)
{
if(ql > qr) {return ;}
if(L == R)
{
rep(i, ql, qr)
{
if(a[i].type == 2) ans[a[i].id] = L;
}
return ;
}
int mid = L + R >> 1;
int s1 = 0, s2 = 0;
rep(i, ql, qr)
{
if(a[i].type == 1)
{
if(a[i].x <= mid)
{
T.add(a[i].id, 1);
q[++ s1] = a[i];
}
else p[++ s2] = a[i];
}
else
{
int k = T.query(a[i].r) - T.query(a[i].l - 1);
if(k >= a[i].x) q[++ s1] = a[i];
else
{
a[i].x -= k;
p[++ s2] = a[i];
}
}
}
rep(i, 1, s1) if(q[i].type == 1) T.add(q[i].id, -1);
rep(i, 1, s1) a[i + ql - 1] = q[i];
rep(i, 1, s2) a[i + ql + s1 - 1] = p[i];
solve(ql, ql + s1 - 1, L, mid);
solve(ql + s1, qr, mid + 1, R);
}
例题
P3527 MET-Meteors
首先套路地断环为链,然后进行整体二分,直接二分答案,用树状数组维护每个空间站的陨石个数
// 思考一件事情,如果我把每个题的代码都发进来,这篇文章就不知道会长到哪里去了
P4602 混合果汁
首先我们尝试二分美味值,然后我们发现我们不好在二分内处理美味值高的点对前半段(答案为美味值低的点)的贡献。因此我们建立一棵可持久化线段树,先遍历后半段,这样前半段也能统计到后半段的美味值贡献。
P1527 [国家集训队]矩阵乘法
实际上就是一个二维树状数组
P5163 WD与地图
第一个套路,将删边倒过来转化为加边,这个很套路
然后这道题可以很明显的被分为两部分:无向图连通和如何转化为无向图联通
无向图联通的前 b 大可以用并查集判联通+权值线段树求前 k 大解决
发现有向图的边并不会在加入后立即联通两个连通块,会和煌小姐的链锯一样在一段时间后才产生作用
这给我们以启示,求出每条边产生作用的时间,然后转化为无向图的问题
思考整体二分的模型。设询问范围为ql与qr,答案范围为L和R。
我们将 <= mid 的边建张图,在里面跑强连通分量(>mid的边的起效时间肯定>mid)
然后对于所有 <= mid 的边,查询其两端点是否在同一强连通分量中。若在,说明已经起效,在前半段,否则在后半段
最后的问题在于tarjan复杂度会超时。
首先,显然的优化是,我们并不要对一条边也没有连的点跑tarjan,记录一个vector表示当前加入的边的端点的集合,复杂度会降低成与 n 同阶。但还是无法通过
思考这样一件事:已经在tarjan中纳入强连通分量的右边的点会受到来自左边点的影响,并且这些影响可以看做永久影响。
所以我们完全可以把右边属于同一强连通分量的点重新赋下标为一个代表点,这样tarjan复杂度会不断减小。事实上,此时复杂度与加入的边数同阶。
最后参照无向图联通的做法就行。
P2617 Dynamic Rankings
如果我们把一开始的序列也改成一个一个的修改,那么就可以二分修改与查询这整个序列。
每一次修改,相当于减去前面的那个数,再加上现在的这个数。所以我们要额外记录一个 tag,表示当前这个修改是加还是减
我们把二分的序列保持原来输入的顺序,这样可以避免后面的修改对前面的询问造成影响这一错误