

线段树 (区间树)
source link: https://lotabout.me/2018/segment-tree/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

线段树 (区间树)
不查不知道,一查吓一跳,“线段树”这个名字的定义真是混乱到一定程度了。
维基百科 Segment Tree 说它是一种数据结构,用来存储区间或线段,用来在 O(log n)
的时间内查找包含某个点的所有区间。一般线段树是静态的结构,不需要修改的,但一些教程又很强调线段树的修改,比如说“lazy 节点是线段树的精髓“。与此同时,另一种结构区间树(Interval
Tree) 在结构和功能上和线段树又十分接近。看来看去,线段树,区间树在维基百科、教材、博客里的介绍经常大不相同。
本文里,我们以解决区间最小值(RMQ)问题中使用的数据结构为基准,讲讲“线段树”的基本思想。
开始前先申明博主是不搞 ACM 的,因此平时也接触不到这些高级的数据结构。但在学习时看网上的教程也是一头雾水,也因此才想记录自己的学习所得。如有不当之处,请批评指正。
首先我们考虑一个数组 A,我们要求 Q(i,j)=min(Ai,Ai+1,…,Aj)Q(i,j)=min(Ai,Ai+1,…,Aj)。即
A[i], ... A[j]
中的最小值。如果只求一次,那自然是遍历一次,花费 O(n)O(n) 时间。如果这个查询很频繁,那很自然地想到先做个索引。
最简单直观的方式是建一个表 index
,有 index[i][j] = Q(i,j)
,例如
A = [1, 3, 2, 3]
j0 1 2 3
i┌──────────
0│1 1 1 1
1│ 3 2 2
2│ 2 2
3│ 3
很 Naive 的建表需要 O(n3)O(n3) 时间。如果注意到 dp[i][j] = min(dp[i+1][j], A[i])
则只需要 O(n2)O(n2) 的时间。现在考虑 A[i]
元素发生变化,则需要 O(n2)O(n2)
时间更新。
现在考虑更新和查询操作各占 50%,这种建立索引的方式就变得不太合适,于是我们想到了树结构。
RMQ 问题代表的是一类“区间查询”的问题,即我们需要对 A[i], ... A[j]
作一些统计,上面的例子里我们想求最小值,常见的还有求最大值,求和,求积,等等。而线段树给我们提供了一种比较通用的索引方式,来加快查询的速度。
我们说“线段树”是索引,也就是说它的作用是对某些数据进行预处理。这一点很重要,我认为理解线段树的重点就在于理解它是如何对原数据进行索引的。
有数组: A = [0,5,2,5,4,3,1,6,3]
,也就是说原数据是 9 个离散的点(实际可以扩展成连续的类型)。现在我们为这个数组构建“线段树”。树的每个节点包含两种数据,一是区间 [i, j]
,另一个是该区间里问题的解,这里存放的是 Q(i, j)
值。于是创建线段树如下:
(1)
[0,15]
0
┌───────────────┴───────────────┐
[0,7] [8,15]
0 3
┌───────┴───────┐ ┌───────┴───────┐
[0,3] [4,7] [8,11]
0 1 3
┌───┴───┐ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐
[0,1] [2,3] [4,5] [6,7] [8,9]
0 2 3 1 3
┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐
0 5 2 5 4 3 1 6 3
┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
上面的树是自底向上创建的,我们添加了许多空节点来让树达到满二叉树,这种树的好处是节点不需要真正存放 [i,j]
,如果我们对所有节点编号,那么每个节点对应的区间其实可以直接由它的编号得到,当然具体的对应关系和编号的方法有关。同时这种树存放在数组里特别方便。另一种自上而下的方法是:
(2)
[0,8]
0
┌───────────────┴───────────────┐
[0,4] [5,8]
0 1
┌───────┴───────┐ ┌───────┴───────┐
[0,2] [3,4] [5,6] [7,8]
0 4 1 3
┌───┴───┐ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐
[0,1] [2,2] [3,3] [4,4] [5,5] [6,6] [7,7] [8,8]
0 2 5 4 3 1 6 3
┌─┴─┐
[0,0] [1,1]
0 5
有了上面的树,我们要回答 Q(i,j)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):
if root.left == i and root.rigth == j:
return root.value
mid = (root.left + root.right) // 2
if j <= mid:
return search(root.left, i, j)
elif i >= mid + 1:
return search(root.right, i, j)
else:
return min(search(root.left_child, i, mid), search(root.right_child, mid+1, j))
这个算法的时间复杂度是 O(logn)O(logn)。虽然代码看上去需要访问整棵树的所有节点,但实际上,在线段树的每一层,至多只有两个节点需要继续向下求值。这里不严格证明,直观上,如果在某一层有三个节点 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]
│ 0
│ ┌───────────────┴───────────────┐
1│ [0,4] [5,8]
│ 0 1
│ ┌───────┴───────┐ ┌───────┴───────┐
2│ [0,2] [3,4] [5,6] [7,8]
│ 0 4 1 3
│ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐
3│ [0,1] [2,2] [3,3] [4,4] [5,5] [6,6] [7,7] [8,8]
│ 0 2 5 4 3 1 6 3
│ ┌─┴─┐
4│[0,0] [1,1]
│ 0 5
这里更新不讲那些有的没的,复杂的操作。只说“显而易见”,一个底部节点的修改只影响该节点及所有父/祖父节点,因此是 O(logn)O(logn)。
构建的算法其实也没啥说的,例如自底向上一个个创建父节点,每个都需要 O(1)
,而共有 O(n)
个父节点,所以也是 O(n)
的时间。
我认为线段树的本质,就是用这种“二进制”(二分)的方式去组织(统计)信息。我们可以看到:
- 线段树的每个节点保存了部分信息,即某个子区间的解
- 对任意输入,我们通过 O(logn)O(logn) 个结点就可以组合得到最终的结果
- 而理论上组合 O(logn)O(logn) 个节点的过程需要是 O(n)O(n) 的时间,比如上面提到的
min, max, sum, ...
操作,都是 O(n)O(n) 复杂度的。
这又让我想起了以前见过的一道面试题,1000 个瓶子里只有一瓶是毒药,毒要一星期才发作,要在一星期内找出哪瓶有毒,需要多少只老鼠。其本质也是利用二进制的方式去统计信息。(当然并不是二叉树)
知道了这一点之后,相信即使之后看见更复杂的树结构,也能更好地去理解了吧。
Recommend
-
25
前一阵子朋友换工作,去了新公司的一个基础服务的部门。对数据结构和算法的要求着实不低,不是平常的CRUD,而是通过各种巧妙的数据结构去完成对应的业务需求。我发现是时候夯实一下基础了,这次来看...
-
33
目录 为什么要使用线段树? 最经典的线段树问题:区间染色 有一面墙 ,长度为n,每次选择一段儿墙进行染色,m次操作后,我们可以看见多少种颜色?
-
44
点击关注上方“ 五分钟学算法 ”, 设为“置顶或星标”,第一时间送达干货。 来源...
-
37
学习了一周的线段树和树状数组,深深地体会到了这每种操作几乎都是 \(O(logN)\) 级别的数据结构的美,但是做起题来还是相当痛苦的(特别是一开始只会模板的时候,很难灵活运用线段树的性质)。还好有雨巨大神带入门,视频讲...
-
33
题目描述 “南山之首日鹊山。其首日招摇之山,临于西海之上,多桂,多金玉。有草焉,其状如韭而青华,其名日祝余,食之不饥……又东三百里,日堂庭之山,多棪木,多白猿,多水玉,多黄金。 又东三百八十里,日猨翼之山,其...
-
23
大家好,欢迎阅读周三 算法数据结构 专题,今天我们来聊聊一个新的数据结构,叫做线段树。 线段树这个数据结构很多人可能会有点蒙,觉得没有听说过,但是它非常非常有名,尤其是在竞赛圈,可以说是 竞赛圈的必备技能
-
10
两个线段相交测试 两个线段相交测试 [TOC] 因为需要用到线段相交测试,找了一个不错的实现,改成了c++的 把参考[1]中的代码用c++翻译了一下,几乎都不用改,放在github上了[5]...
-
10
HDU1698 Just a Hook(线段树 区间更新)March 05, 2016题目链接 1(数据数) 10(1 个数初始都是 1) 2(2 次操作) 1 5 2(将[1, 5]区间内所有...
-
10
Codeforces620E New Year Tree(dfs序+线段树 区间更新)March 07, 2016题目链接 题意:一棵树,每个节点有一个颜色,现在有两种操作,一种是将一棵子树所有节点置为一种...
-
10
给一个两个数组,其中一个数组是 A [1,2,3,4],另外一个数组是 B [5,6,7,8]。让你求两个数组合并后的大数组的: 这题是不是很简单?我们直接可以很轻松地在 O(m+n)O(m+n) 的时间解决,其中 m 和 n 分别为数组 A 和 B 的大小。 那如果我可以
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK