大家好,我是洪爵,今天想给大家讲一个算法:线段树。
线段树是啥,它能干什么呢?
我们先来看下这样一个场景:现在有一个长度为5的数组a。
现在对它有如下几个操作:一个是求区间和,即给一个下标的范围[left, right]然后求数组a[left] ~ a[right]的和,我们把这个操作叫做query。
第二个是对数组a某个位置进行数值更新,比如a[1] = 3,现在把a[1]更新为5,我们把这个操作称为update。
我们可以先来看下常规操作的时间复杂度和空间复杂度是多少,query是求区间[left, right]和,时间复杂度是o(n),然后我们只需要使用一个变量去存储这个答案,所以空间复杂度是o(1)。
update是通过下标直接更新,所以它的时间复杂度o(1)。
总结一下,这种做法query的时间复杂度是o(n),update的时间复杂度是o(1),我们有没有更好的方法呢?
我们来看一下前缀和解法是否适用于这种场景,我们新建一个数组b,长度和a数组一样,b[0] = a[0],b[1] = b[0] + a[1],b[2] = b[1] + a[2]……b[n] = b[n - 1] + a[n]。当我们进行query操作的时候,比如求区间[left, right],那么我们只需要使用b[right] - b[left - 1](left等于0的时候需要处理下,避免数组越界)就能够得到query的答案,所以时间复杂度为o(1)。
但如果要进行update操作,比如更新了a[3]的值,b[3] ~ b[n]的值都需要进行更新,所以它的时间复杂度是o(n)。
使用前缀和算法的话,我们做一个总结,query的操作是o(1),update的操作是o(n),然后因为开了一个长度为n的数组,所以它的空间复杂度为o(n)。
所以不管是比较常规的做法或者是使用前缀和,update和query总有一个的时间复杂度是o(n),那么有没有时间复杂度更低的解决方法呢?我们可不可以把时间复杂度降到**o(logn)**呢?
我们可以先来看下o(n)和o(logn)的曲线:
o(n)曲线
o(logn)曲线
从图中我们可以看出o(n)的时间随着n增大是呈线性增长的,o(logn)时间复杂度随着n增大,增加的趋势不明显,并且随着n越来越大,o(logn)体现的效率会越来越明显,所以我们能得出:如果能把时间复杂度优化到o(logn),将会大大提高运行的效率。
线段树是一个query和update的**时间复杂度都为o(logn)**的数据结构。它是怎么实现的呢?我们还是看这样一个原数组。
我们把这个数组构建成一棵树,但是这棵树和平常的二叉树不同,它的每一个节点保存的信息不是数值,而是left,right的区间范围,还有保存区间值,可能这样说还是比较抽象,我们来看下每个节点是长啥样的:
struct Node {
int left;
int right;
int sum;
};
因为使用的是线段树,我们除了query和update操作,还需要添加一个建树的操作,我们称为build,这个build的操作只需要在一开始的时候初始化一次就ok,后面就不需要在build了。
那么问题来了,这棵树要怎么建才能使得时间复杂度为o(logn)呢,我们可以采用二分的思想,先来看下示意图:
再来看下代码的实现:
//调用build(0, 4, 0);
void build(int left, int right, int root) {
p[root].left = left;
p[root].right = right;
if(left == right) {
p[root].sum = num[left];
return;
}
int mid = (right+left) / 2;
build(left, mid, root * 2);
build(mid + 1, right, root * 2 + 1);
p[root].sum = p[root * 2].sum + p[root * 2 + 1].sum;
}
这样一颗线段树就构建好了,如果说我们要查询[1,1]这个区间的和,只需要遍历下面这几个节点就可以:
如果说我们要更新a[1]也一样,也是上面这几个节点需要更新,这样就能保证不论是query还是update操作都是**o(logn)**的时间复杂度,我们来看下代码实现吧。
友情提示:
root * 2 = root << 1
root * 2 + 1 = root << 1 | 1
#include <bits/stdc++.h>
#define MAXN 50005
using namespace std;
struct Node{
int left;
int right;
int sum;
}p[MAXN * 4]; // 为什么是4倍呢, 大家也可以思考下
int num[MAXN];
void Pushup(int root) {
p[root].sum = p[root << 1].sum + p[root << 1 | 1].sum;
}
// 建树操作
void build(int left, int right, int root) {
p[root].left = left;
p[root].right = right;
if(left == right) {
p[root].sum = num[left];
return;
}
int mid = (right + left) >> 1;
build(left, mid, root << 1);
build(mid + 1, right, root << 1 | 1);
Pushup(root);
}
// 更新a[i]为a[i] + j
void Update(int root, int i, int j) {
p[root].sum += j;
if(p[root].left == i && p[root].right == i) {
return;
}
int mid = (p[root].left + p[root].right) >> 1;
if(i <= mid) {
Update(root << 1, i, j);
}else{
Update(root << 1 | 1, i, j);
}
}
// 查询区间[left, right]的和
int Query(int root, int left, int right) {
if(p[root].left == left && p[root].right == right) {
return p[root].sum;
}
int mid = (p[root].right + p[root].left) >> 1;
if(right <= mid) {
return Query(root << 1, left, right);
}else if(mid < left){
return Query(root << 1 | 1, left, right);
}else{
return Query(root << 1, left, mid) + Query(root << 1| 1, mid + 1, right);
}
}
这里给到一个例题:acm.hdu.edu.cn/showproblem…
思考:
大家也许会很疑惑**为什么要开4倍呢?**在做题的时候,你可能试过开2倍或者3倍,但是都不能过题,但是4倍的话就可以,为啥呢?
我们可以思考下,二叉树和线段树的一个区别,线段树采用二分去分区间,而二叉树是存储每个节点的值,当遇到不能完全平分的时候,就会出现叶子节点不在同一层的情况,而第n层的节点数是1~n-1层节点数的两倍(近似),所以正常是2n - 1个节点,但是需要乘以2,所以大约是4n个节点。
好的。线段树就说到这里啦,希望本篇文章对你有用!
愿每个人都能带着怀疑的态度去阅读文章并探究其中原理。
道阻且长,往事作序,来日为章。
期待我们下一次相遇!