考虑一个简单的问题
给定一个数组
a
[
0...
n
−
1
]
a[0 . . . n-1]
a[0...n−1],我们要对数组执行这样的操作:
(1)计算从下标 l l l到 r r r的元素的最小值,其中 0 < = l < = r < = n − 1 0 <= l <= r <= n-1 0<=l<=r<=n−1
(2)修改数组指定元素的值 a [ i ] = x a[i] = x a[i]=x,其中 0 < = i < = n − 1 0 <= i <= n-1 0<=i<=n−1
一个简单的方案是从 l l l到 r r r执行循环,计算给定区间的元素之和。更新值的时候,简单地令 a [ i ] = x a[i] = x a[i]=x。第一个操作花费 O ( n ) O(n) O(n)的时间,第二个操作花费 O ( 1 ) O(1) O(1)的时间。
第二个方案是创建另外一个数组来存储从下标 i i i开始的元素的最小值。这样一来,给定区间之和可以用 O ( 1 ) O(1) O(1)的时间计算,但是更新需要花费 O ( n ) O(n) O(n)的时间。这种方法适用于需要大量查询而更新操作较少的场景。
有没有一种方法,能够同时高效的完成区间查询和元素修改两种操作呢,这就是线段树了。
线段树概念
线段树是擅长处理区间的,形如下图的数据结构。线段树是一棵完美二叉树(PerfectBinaiy Tree)(所有的叶子的深度都相同,并且每个节点要么是叶子要么有2个儿子的树 ),树上的每个节点都维护一个区间。根维护的是整个区间,每个节点维护的是父亲的区间二等分后的其中一个子区间。当有
n
n
n个元素时,对区间的操作可以在
O
(
log
n
)
O(\log n)
O(logn) 的时间内完成。
而线段树所提供的区间查询的功能取决于节点中存储的数据,如果节点中存储的是区间最小值,那么线段树就能查询区间最小值。如果节点存储区间和,那么线段树提供的就是区间和的查询。我们以求区间最小值(range minimum query RMQ)的线段树为例、
RMQ的构建–以叶子节点为2的整数幂为例
如下图,线段树的每个节点维护对应区间的最小值。在建树时,只需要按从下到上的顺序分别取左右儿子的值中的较小者就可以了。
代码如下,只需要将数组
a
a
a中的
n
n
n个元素依次
u
p
d
a
t
e
update
update即可。
//a[i]=x,同时修改与其有关的所有根节点的值
void update(ll k, ll x) {
k += n - 1;//线段树一共2n-1个节点,后n个节点为数组元素,叶子节点,将k转化为其在树中的位置。
tree[k] = x;//修改值
while (k > 0) {//0号节点是root节点
k = (k - 1) / 2;//对于节点i,2i+1,2i+2是左右孩子,(i-1)/2是父亲
//这一步决定了线段树的功能,是存储区间的最小值。
tree[k] = min(tree[2 * k + 1], tree[2 * k + 2]);
}
}
基于RMQ的更新操作
更新操作也就是上方的update代码,操作过程无异于构建树的过程。比如如果将
a
[
0
]
a[0]
a[0]赋值为2
基于RMQ的查询操作
如果要求
a
[
1
]
.
.
.
a
[
6
]
a[1]...a[6]
a[1]...a[6]的最小值,我们只需要求下图中的三个节点的值的最小值即可。
像这样,即使査询的是一个比较大的区间,由于较靠上的节点对应较大的区间,通过这些区间就可以知道大部分值的最小值,从而只需要访问很少的节点就可以求得最小值。而这个对树的查询操作,需要一个递归的程序不断的访问子层的节点,对子层的节点进行如下判断:
- 如果所查询的区间和当前节点对应的区间完全没有交集,那么就返回一个不影响答案的值。比如求区间最小值的话,就返回一个很大的值,求区间和的话,就返回0.
- 如果所查询的区间完全包含了当前节点对应的区间,那么就返回当前节点的值。
- 以上两种情况都不满足的话,就对两个儿子递归处理,返回两个结果中的较小者。
这里的一个比较重要的问题是,我们如何知道当前节点对应的区间是where to where?用一个struct存储所有中间结点的起始位置、终止位置and 节点值吗?不用如此麻烦,具体看注释
// 求[a, b)的最小值
// k是节点的编号,1, r表示这个节点对应的是[1, r) 区间。
// 在外部调用时,用query(a, b)即可
ll query(ll a, ll b, ll k, ll l = 0, ll r = n) {
//1st case: 如果所查询的区间和当前节点对应的区间完全没有交集。
//tips:为什么是大于等于或者小于等于,因为[a,b)和[l,r)都是半开区间b,r都是不能取到的index。
if (r <= a || l >= b) return inf;
//2ed case:如果所查询的区间完全包含了当前节点对应的区间,那么就返回当前节点的值。
if (l >= a && r <= b)return tree[k];
//3rd case:以上两种情况都不满足的话,就对两个儿子递归处理,返回两个结果中的较小者。
ll v1 = query(a, b, 2 * k + 1, l, (l + r) / 2);
ll v2 = query(a, b, 2 * k + 2, (l + r) / 2, r);
return min(v1, v2);
}
疑问?如果叶子节点不是2的整数幂呢
我们将叶子节点的数目补为2的整数幂,比如对于数组 [ 1 , 2 , 3 ] [1,2,3] [1,2,3],我们将其补位 [ 1 , 2 , 3 , 0 ] [1,2,3,0] [1,2,3,0],也就是多余位用0替代,然后 t r e e [ ] tree[] tree[]数组的大小为 2 n − 1 2n-1 2n−1,有一部分中间结点,也可能会变成0,这样做依然是正确的。
void init(ll n_) {
N = 1;
//如果叶子节点不是2的整数幂,那么数组不止是2*n-1,需要4N-1,这里方便起见将叶子节点的数目记为2的整数幂
//因此空节点都用0填补
while (N < n_) N *= 2;
for (ll i = 0; i < 2 * N - 1; i++) tree[i] = 0;
}
基于RMQ的复杂度分析
就本文开始描述的那样,无论是查询还是修改操作,都是比较高效的复杂度, 1 − − n 1--n 1−−n之间的 O ( log n ) O(\log n) O(logn)。对于一般二叉树而言。很有可能发生退化,使得复杂度回升到 O ( n ) O(n) O(n),但是线段树不会进行区间的合并,或者增加,删除元素,因此不会出现退化的情况发生。
此外 n n n个元素的线段树的初始化的总的空间复杂度为 O ( n ) O(n) O(n),因为一共只有 2 n − 1 2n-1 2n−1个节点。
拓展:基于稀疏表的RMQ
在 RMQ 的其他实现方法中,有一种叫做 Sparse Table 的方法较为常见。对于上述数列构建的 Sparse Table 如下表所示。
其中
t
i
,
j
t_{i,j}
ti,j表示的是
a
j
,
a
j
+
1
,
.
.
.
,
a
j
+
2
i
a_j,a_{j+1},...,a_{j+2^i}
aj,aj+1,...,aj+2i,具体操作不详述,只需要知道他单次查询的效率比基于线段树的RMQ高,但是预处理的时间复杂度和空间复杂度都达到了
O
(
n
log
n
)
O(n\log n)
O(nlogn),而且无法高效的对值进行更新。
Tips
1、 线段树是二叉树,且必定是平衡二叉树,但不一定是完全二叉树。
2、 对于区间[a,b],令mid=(a+b)/2,则其左子树为[a,mid],右子树为[mid+1,b],当a==b时,该区间为线段树的叶子,无需继续往下划分。
3、 线段树虽然不是完全二叉树,但是可以用完全二叉树的方式去构造并存储它,只是最后一层可能存在某些叶子与叶子之间出现“空叶子”,这个无需理会,同样给空叶子按顺序编号,在遍历线段树时当判断到a==b时就认为到了叶子,“空叶子”永远也不会遍历到。
4、 之所以要用完全二叉树的方式去存储线段树,是为了提高在插入线段和搜索时的效率。用p2,p2+1的索引方式检索p的左右子树要比指针快得多。
5、线段树的精髓是,能不往下搜索,就不要往下搜索,尽可能利用子树的根的信息去获取整棵子树的信息。如果在插入线段或检索特征值时,每次都非要搜索到叶子,还不如直接建一棵普通树更来得方便