不查不知道,一查吓一跳,“线段树”这个名字的定义真是混乱到一定程度了。
维基百科 Segment Tree 说它是一种数据结构,用来存储区间或线段,用来在 O(log n)
的时间内查找包含某个点的所有区间。一般线段树是静态的结构,不需要修改的,但一些教程又很强调线段树的修改,比如说“lazy 节点是线段树的精髓“。与此同时,另一种结构区间树(Interval
Tree) 在结构和功能上和线段树又十分接近。看来看去,线段树,区间树在维基百科、教材、博客里的介绍经常大不相同。
本文里,我们以解决区间最小值(RMQ)问题中使用的数据结构为基准,讲讲“线段树”的基本思想。
开始前先申明博主是不搞 ACM 的,因此平时也接触不到这些高级的数据结构。但在学习时看网上的教程也是一头雾水,也因此才想记录自己的学习所得。如有不当之处,请批评指正。
引子
首先我们考虑一个数组 A,我们要求 $Q(i, j) = min(A_i, A_{i+1}, …, A_j)$。即
A[i], ... A[j]
中的最小值。如果只求一次,那自然是遍历一次,花费 $O(n)$ 时间。如果这个查询很频繁,那很自然地想到先做个索引。
最简单直观的方式是建一个表 index
,有 index[i][j] = Q(i,j)
,例如
A = [1, 3, 2, 3] |
很 Naive 的建表需要 $O(n^3)$ 时间。如果注意到 dp[i][j] = min(dp[i+1][j], A[i])
则只需要 $O(n^2)$ 的时间。现在考虑 A[i]
元素发生变化,则需要 $O(n^2)$
时间更新。
现在考虑更新和查询操作各占 50%,这种建立索引的方式就变得不太合适,于是我们想到了树结构。
线段树
RMQ 问题代表的是一类“区间查询”的问题,即我们需要对 A[i], ... A[j]
作一些统计,上面的例子里我们想求最小值,常见的还有求最大值,求和,求积,等等。而线段树给我们提供了一种比较通用的索引方式,来加快查询的速度。
我们说“线段树”是索引,也就是说它的作用是对某些数据进行预处理。这一点很重要,我认为理解线段树的重点就在于理解它是如何对原数据进行索引的。
有数组: A = [0,5,2,5,4,3,1,6,3]
,也就是说原数据是 9 个离散的点(实际可以扩展成连续的类型)。现在我们为这个数组构建“线段树”。树的每个节点包含两种数据,一是区间 [i, j]
,另一个是该区间里问题的解,这里存放的是 Q(i, j)
值。于是创建线段树如下:
(1) |
上面的树是自底向上创建的,我们添加了许多空节点来让树达到满二叉树,这种树的好处是节点不需要真正存放 [i,j]
,如果我们对所有节点编号,那么每个节点对应的区间其实可以直接由它的编号得到,当然具体的对应关系和编号的方法有关。同时这种树存放在数组里特别方便。另一种自上而下的方法是:
(2) |
查询
有了上面的树,我们要回答 $Q(i,j)$ 是多少。例如我们问 Q(3,5)
,纵观全局,我们发现其实 [3,5]
在树中没有对应的节点,但我们可以一步步查询求得:
[0,8]
: 输入[3,5]
。发现[3,5]
被包含在[0,8]
之中,通过计算我们发现[3,5]
横跨[0,4]
与[5,8]
,因此我们递归求min(Q(3,4), Q(5,5))
[0,4]
:输入[3,4]
,发现它完全在[0,4]
的右子树,于是向右子树查询[3,4]
[3,4]
:输入[3,4]
与自己完全重合,于是返回保存的值4
[5,8]
:输入[5,5]
,向左子树查询[5,6]
:输入[5,5]
,向左子树查询[5,5]
:输入[5,5]
,与自己完全重合,返回保存的值3
[0,8]
:返回min(4, 3) = 3
上面的描述比较啰唆,主要是因为算法本身就是递归的过程。写成代码如下:
def search(root, i, j): |
这个算法的时间复杂度是 $O(\log n)$。虽然代码看上去需要访问整棵树的所有节点,但实际上,在线段树的每一层,至多只有两个节点需要继续向下求值。这里不严格证明,直观上,如果在某一层有三个节点 A, B, C 需要继续向下求值,设 B = [b, b']
是这三个节点的中间节点,而 B 的输入是 [x, x']
,我们可以推出 b == x and b' == x'
。否则最开始的输入不可能是一个区间。
例如我们求 Q(2,5)
,当访问节点 [0,2]
输入为 (2,2)
我们记为 [0,2]: (2,2)
。在第 2 层时需要访问 [0,2]: (2,2)
, [3,4]: (3,4)
, [5,6]: (5,5)
。可以看见中间节点 [3,4]: (3,4)
是可以直接返回的,不需要再继续向下求值,而
[0,2]: (2,2)
和 [5,6]: (5,5)
是需要继续向下求值的。
0│ [0,8] |
更新
这里更新不讲那些有的没的,复杂的操作。只说“显而易见”,一个底部节点的修改只影响该节点及所有父/祖父节点,因此是 $O(\log n)$。
构建
构建的算法其实也没啥说的,例如自底向上一个个创建父节点,每个都需要 O(1)
,而共有 O(n)
个父节点,所以也是 O(n)
的时间。
结语
我认为线段树的本质,就是用这种“二进制”(二分)的方式去组织(统计)信息。我们可以看到:
- 线段树的每个节点保存了部分信息,即某个子区间的解
- 对任意输入,我们通过 $O(\log n)$ 个结点就可以组合得到最终的结果
- 而理论上组合 $O(\log n)$ 个节点的过程需要是 $O(n)$ 的时间,比如上面提到的
min, max, sum, ...
操作,都是 $O(n)$ 复杂度的。
这又让我想起了以前见过的一道面试题,1000 个瓶子里只有一瓶是毒药,毒要一星期才发作,要在一星期内找出哪瓶有毒,需要多少只老鼠。其本质也是利用二进制的方式去统计信息。(当然并不是二叉树)
知道了这一点之后,相信即使之后看见更复杂的树结构,也能更好地去理解了吧。