RMQ
总结求各种 RMQ 的常用技巧和方法。
RMQ 真是流皮,每次深入思考都会有新的发现,所以有了新的发现会更新。
以下 n 表示数列的大小,q 表示询问的次数,均以最大值为例。
一般性普通做法
线段树当然是可以在线维护的,复杂度 \(O(n +
qlogn)\) 。
甚至还可以支持单点修改或区间修改。
但是如果只是静态询问的话,可以用 ST 表预处理后 \(O(1)\) 在线处理询问,
复杂度 \(O(nlogn + q)\) 。
这两个做法烂大街了,是基础中的基础,不是本文讨论重点。
区间定长特殊做法
即所有的询问区间的长度都为与询问无关的定值 \(len\) 。
直接当做任意区间做,可以做到 \(O(n +
qlogn)\) 或 \(O(nloglen + q)\)
。
这种情况下有更好理解的预处理方法,只需优先队列。
先把 \([1, l]\)
的数扔进优先队列里,之后不断把区间右移,
\([l, r]\) 右移的过程相当于加一个点
\(r + 1\) 并删掉点 \(l\) ,用优先队列维护即可。
复杂度 \(O(nlogn + q)\) 。
但这还不够,还有线性的预处理方法,用单调双端队列代替上面的优先队列。
同样是让区间不断右移,单调双端队列中的值是单调不增的,
那么最左边的值一定是最大值。
加点 \(r + 1\) 前维护单调性,删点 \(l\) 时判断是不是删的最大值,
如果是就删点最左边的点即可。
复杂度 \(O(n + q)\) 。
随机询问期望做法
这种情况下有个 \(O(n + q)\) 的在线做法。
考虑分块,设块的大小为 \(b\) , \(O(n)\) 预处理每个块的最大值。
那么对于询问 \([l, r]\) ,若该区间跨过了多个块,问题就分为两个部分:
- 求跨过的块区间的最大值。
- 求两端点所在零散的块的最大值。
第一个问题就是个子问题,并且数据规模减小到了 \(O(\frac{n}{b})\) ,
为了保证询问 \(O(1)\)
,可以用上面一般性的普通做法提到的 ST 表,
就可以 \(O(\frac{n}{b}
log\frac{n}{b})\) 进行预处理然后 \(O(1)\) 询问。
第二个问题端点所在零散的块是该块的一段前缀或者后缀,
只需 \(O(n)\)
对于每个块预处理前缀最大值和后缀最大值即可 \(O(1)\) 询问。
那么若询问区间在同一个块内呢?
自然是暴力扫,但是这样的复杂度是 \(O(b)\) 的。
但询问区间随机的情况下,不难得出两个端点在同一个块内的概率是 \(\frac{b}{n}\) 。
那么这种情况询问的期望复杂度是 \(O(\frac{b^2}{n})\) 的。
总复杂度 \(O(n + \frac{n}{b} log\frac{n}{b}
+ q + q \frac{b^2}{n})\) 。
当 \(b\) 至少为 \(O(logn)\) 时,预处理的 \(O(\frac{n}{b} log\frac{n}{b})\) 不超过
\(O(n)\) 。
当 \(b\) 至多为 \(O(\sqrt{n})\) 时,询问的 \(O(q \frac{b^2}{n})\) 不超过 \(O(q)\) 。
因此 \(b\) 的大小取 \(O(logn)\) 到 \(O(\sqrt{n})\) 之间即可。
另外,当 \(b = \sqrt{n}\)
时,块的个数也是 \(O(\sqrt{n})\)
的,
此时根本不需要 ST 表,直接 \(O(\sqrt{n}^2)\)
暴力预处理处理所有可能区间的最大值即可。
这样复杂度不变,常数可能还能小一点。
毒瘤活动
值得注意的是,虽然这个做法的复杂度仅适用于询问区间随机的情况,但是一般不会卡。
来点有意思的娱乐活动,考虑怎么卡掉它,以及怎么防止被出题人卡。
想要卡这个做法就要尽量让询问区间在一个块内,卡成 \(O(b)\) 的询问复杂度。
但在不知道块的大小的情况下,假设给一个区间长 \(len\) 的询问,实际块的大小为 \(b\) ,
那么两个端点在同一个块内的概率大概是 \(\frac{b-len+1}{b}\) ,期望复杂度就是 \(O(\frac{(b-len+1)len}{b})\) 。
现在出题人要在不知道 \(b\)
的情况下希望上面的复杂度尽量大,选手要在不知道 \(len\) 的情况下希望上面复杂度尽量小。
怎么感觉像博弈论
最坏的情况是 \(len = \frac{b}{2}\)
的时候,此时询问的期望复杂度为 \(O(\frac{b}{4})\) 。
选手希望预处理和询问的复杂度最大值最小,也就是让它们相等,此时 \(b\) 的最优取值大致为 \(2\sqrt{\frac{n}{q} logn}\) ,
这里说是“大致”,是因为为了方便计算将 \(O(log
\frac{n}{b})\) 看做了 \(O(logn)\) 。
将 \(n, q\) 看做同阶的话,上述取值为
\(2\sqrt{logn}\) ,此时复杂度为 \(O(\frac{n\sqrt{logn}}{2})\) ,
也就是 \(O(n\sqrt{logn})\)
,得出结论,在询问区间非随机的情况下,该算法最优可以做到严格 \(O(n\sqrt{logn})\) 。
关键这算法常数小,还好写,取 b 为 \(O(\sqrt{logn})\) 的话,复杂度 \(O((n + q)\sqrt{logn})\) 在绝大多数情况都足够了。
另外,此时没必要维护块内前缀后缀最大值,因为块足够小,询问的时候对零散的块暴力扫就好了,复杂度不变。
一般性较优做法
自己 yy 出来的,权当过渡吧。
上面提到的 \(O((n + q)\sqrt{logn})\)
算法中,通过分块将问题规模缩小到了 \(\frac{n}{b}\) ,
然后对于这个规模的问题使用 ST 表,预处理复杂度近似看做 \(O(\frac{n}{b}logn)\) 。
而既然这是个子问题,为什么还要用 ST 表?能不能继续分块直到 ST
表的预处理复杂度在 \(O(n)\) 以内?
当然是可以的,这个时候块的大小又要取多少呢?
取 \(b = 2\)
就够了,这个时候,相当于从底层向上建线段树,
第一层有 \(n\) 个节点,第二层有 \(\frac{n}{2}\) 个节点,第三层有 \(\frac{n}{4}\) 个节点,
直到某一层只有 \(\frac{n}{logn}\)
个节点时,不再向上建线段树,而是用 ST 表维护这 \(\frac{n}{logn}\) 个点,
这样 ST 表的预处理就是 \(O(n)\)
的,而此时这个线段树的树高是 \(O(loglogn)\) 的。
总时间复杂度 \(O(n + qloglogn)\)
,事实上层数可以再少点,使得 ST 表预处理不严格 \(O(n)\) 而是与询问复杂度相当,
但这样的话最优的层数难以计算,在此不讨论。
毒瘤活动
从下向上建线段树太麻烦了,怕不是要写 zkw ,考虑从上向下建。
由于最上面一层有 \(\frac{n}{logn}\)
个节点,每个节点管辖的区间大小为 \(logn\) ,
继续考虑分块,以 \(b = logn\)
为块大小分块,那么就是块内直接建满的线段树,块间维护 ST 表。
卧槽怎么又回到前面的做法了
那么这个算法事实上就是通过线段树保证了块间查询严格 \(logb\) ,也就是 \(loglogn\) 。
既然这样,为什么一定要用线段树呢?在每个块内依然维护 ST 表,复杂度就是 \(O(nloglogn + q)\) 。
通过上面的各种讨论,相信读者已经明白分块 RMQ 的强大,以及线段树和 ST
表(尤其是后者)在 RMQ 的重要性。
分块什么这么多东西目的基本就是去平衡 ST 表 /
线段树的询问和预处理的复杂度。
update: 后来知道这玩意叫四个俄罗斯人算法 (Four Russian) 。
一般性标准做法
标准的 \(O(n + q)\) RMQ 。
离线的话可以建笛卡尔树将 RMQ 转换为 LCA ,然后用 Tarjan 处理。
明显的缺点是离线,并且空间开销较大。
在线的话,有一个经典的常用做法,还是建笛卡尔树转 LCA
,然后求出树的欧拉序转为 +-1 RMQ 。
而 +-1 RMQ 也是通过分块实现 \(O(n +
q)\) 复杂度的,
利用的是 +-1 RMQ 的差分数组在每个块内只有 \(2^b\) 中可能,其中 \(b\) 是块大小,取 \(logn\) 。
整个过程较为复杂,在 OI 中实用性较低,具体做法留个坑,到时候再补。
这里重点介绍另一个同样能做到在线 + 线性的 RMQ 算法,并且相对于上面的做法更简单,常数也更优秀。
线性 RMQ
该算法改进自之前在随机数据下的分块 RMQ ,
注意到当块大小为 \(O(logn)\)
时,该算法唯一的瓶颈在于询问端点在同一个块内时需要暴力 \(O(logn)\) 扫。
单独考虑每个块,考虑优化询问端点在该块的情况。
其中块大小为 \(b = O(logn)\) 。
利用之前提到的单调队列做法,如果对于每个点暴力存下块的左端点到该点的单调递减队列,
那么对于询问 [l, r] ,拿出 r 上的单调队列,在单调队列上找到第一个不小于
l 的位置,该位置上的值即区间最大值。
由于单调队列的大小是 \(O(b)\) 即 \(O(logn)\) 的,该做法复杂度 \(O((n + q)logn)\) 。
但是该做法有很大优化空间,由于 \(b\)
足够小(一般认为它不超过字长 \(w\)
),完全可以将每个点的单调队列状压,
这样预处理的时空复杂度就降到了 \(O(n)\)
。
然后对于询问一个单调队列上第一个不小于 l 的位置,就是在一个二进制数
\(x\) 上询问第一个位数不小于 l 的 1
的位数。
这通过位运算和内置函数可以很好实现,只需将 \(x\) 右移 \(l mod
b\) 位来去掉单调队列上小于 l 的位置,
然后通过 __builtin_ctz
\(O(1)\) 找到第一个 1 的位置即可。
这样询问的复杂度就降到了 \(O(1)\)
,总复杂度 \(O(n + q)\)
,常数非常优秀。
完整实现
以 bzoj1699 为例,该题的 rank1 就是用了这个线性 RMQ 做法,本人常数大,但还是狗到了 rank6 。
1 |
|