之前我们给大家介绍过了SG的概念和引入了RMQ,这一讲我们就来深入看看RMQ的算法。
1. RMQ的结构
RMQ的结构我们之前也已经见过了,简单来说,就是存储该区间最小值的SG。但和教材上面不同的是,我们这里维护一个同时支持两种RMQ类型的SG,即Range minimum and maximum query,所以每个节点需要同时保存最小值(min)和最大值(max),比如之前题目的RMQ结构为:
那SG的初始化也非常简单了,我们从上往下进行初始化,每次计算当前区间的[min, max],并创建当前的节点,之后递归创建左右两个子节点。这里需要多讲一下代码实现的技巧,因为SG和Heap一样,基本结构都是基于完美二叉树,所以我们在初始化树的时候,不用真正地去创建一颗树,而是用一个数组就能表示一颗完美二叉树,这个给我们的实现带来很多便利。而且父亲下标和子下标有如下的规律,对于下标为k的节点:
父亲的下标:( k - 1 ) / 2
左孩子的下标:k * 2 + 1
右孩子的下标:k * 2 + 2
对这个实现思路不是很清楚的童鞋,可以看看下面的图例进行理解哦:
2. RMQ的查询
现在我们有了SG的数据结构,那给定某个区间,我们该如何进行查询呢?其实很简单,我们以查询最小值为例,则有下面的查询伪代码1:
如果所查询的区间和当前节点对应的区间完全没有交集,那么就返回null。
如果所查询的区间完全包含了当前节点对应的区间,则返回当前节点的值。
以上两者情况都不满足,就对左右节点递归处理,返回两个结果中的较小者。
比如之前题目,查询区间[0, 7)的流程图如下:
绿点表示我们经过的节点,红点表示我们返回并且采用的节点,黄点表示我们返回但未采用的节点,其余节点均被舍弃,并未被查询过。
3. RMQ的更新
我们的SG除了支持查询(Query)操作之外,还需要支持更新(Update)操作,因为有时我们会更改某个原始元素,但我们也希望维护原本SG的性质,这样更新之后,我们依然能快速进行查询。我们先来看一个更新操作的例子,还是之前的SG,我们需要把原数组中下标为0的元素更改为2,也就是5 -> 2:
同样,绿色点表示我们更新需要经过的节点。所以对于RMQ的更新,我们只需要从某个叶节点开始,不断向上更新max或min,直到我们抵达Root。而且这个更新操作和Heap删除一个节点,之后向上Bubble-up是非常类似的,至于Heap插入和删除操作,有兴趣的童鞋可以网上自己查查相关资料,或者以后有机会再给大家讲解啦哒~
4. 复杂度分析
到此,我们已经把SG和RMQ基本给大家讲解结束了。最后,我们还剩复杂度的分析,现在我们就来看看。对于基于SG的RMQ的时间复杂度,这个非常简单,之前的图例已经给大家说明了,已经忘了的童鞋可以再看看这个图例:
对于n个元素组成的SG,它的高度必定是logn,而且SG是完美二叉树,所以高度不会像BST出现极端情况,而退化成n,所以SG的查询和更新时间复杂度都是O(logn)。
那空间复杂度呢?我们看到最底层的元素个数有n个,而且树的高度是logn,那空间复杂度岂不是O(nlogn)?其实不是哦,这点有点误导人,SG的空间复杂是O(n)。大家可以注意一下,图例把每层的元素个数标记出来的,如果我们把每层的个数都加起来:
n
+
n
2
+
n
4
+
.
.
.
+
4
+
2
+
1
=
n
∗
1
−
(
1
2
)
l
o
g
n
1
−
1
2
=
2
n
∗
(
1
−
(
1
2
)
l
o
g
n
)
<
=
2
n
n + \frac{n}{2} + \frac{n}{4} + ... + 4 + 2 + 1 = n * \frac{1-(\frac{1}{2})^{logn}}{1-\frac{1}{2}} = 2n * ( 1 - (\frac{1}{2})^{logn}) <= 2n
n+2n+4n+...+4+2+1=n∗1−211−(21)logn=2n∗(1−(21)logn)<=2n
这里需要注意一下,从上往下,节点总数从1每层递增两倍,但从下往上,节点总数从n每层递减两倍,所以整个求和公式是以1/2为公倍数的等比数列,所以SG的空间复杂度不是O(nlog),而是O(n),非常的nice,时间复杂度是O(log),空间复杂度是O(n),和我们对一维数组进行离散二分查找没有任何区别,性能一致。
可是,是不是到这里就完事大吉了呢?No!可能有聪明的童鞋可能已经发现了,SG的空间复杂度是O(n),那就意味着初始化SG的时间复杂度为O(n),加上查询时间复杂度O(logn),O(n) + O(logn) = O(n),嗯?最后整体的时间复杂度岂不和之前暴力解的时间复杂度一样嘛?而且暴力解不需要额外空间,所以不会有额外初始化的时间消耗。Wait,也就是说我们讲了半天,SG的效率和暴力解并没有多少优势,至少从渐进的意义上,两者都是O(n)。
其实这个分析是没有问题,但这只针对查询次数为1的情况,如果我们假设我们查询了k次,那么两个算法的整体时间复杂度为:
Brute Force: O( k * n );
Segement Tree: O( n ) + O( k * logn ) = O( n + k * logn );
如果k = n,则:
Brute Force: O( k * n ) = O( n * n ) = O( n ^ 2 );
Segement Tree: O( n + k * logn ) = O( n + n * logn ) = O( nlogn );
也就是说在大量查询的情况下,SG的性能就会优于暴力解,而且即使不是大量查询,SG也不会比暴力解逊色,所以SG它不香么?这种思路在实际应用的非常普遍,比如我们有一组数据,但每天都会有大量用户对这个数据进行查询,我们初始化查询用的数据结构只需要一次,但用户的查询次数是成千上万,或者百万千万,所以有时候我们需要把这些因素考虑到我们的算法设计当中,这也是Offline Algorithm和Online Algorithm的重要区别哦。
到这里,我们讲解完了RMQ的算法思路,包括查询和更新,接下来我们将进入到代码实现的解析当中。
上一节:线段树(一):基本概念
下一节:线段树(三):代码解析
系列汇总:超详细!线段树讲解文章汇总(含代码)
5. 参考资料
- 《挑战程序设计竞赛(第2版)》,(日)秋叶拓哉 等著,人民邮电出版社;
6. 免责声明
※ 本文之中如有错误和不准确的地方,欢迎大家指正哒~
※ 此项目仅用于学习交流,请不要用于任何形式的商用用途,谢谢呢;
《挑战程序设计竞赛(第2版)》,(日)秋叶拓哉 等著,人民邮电出版社 ↩︎