线段树和树状数组
一、线段树
线段树是什么
首先线段树是一棵平衡二叉树,平常我们所指的线段树都是指一维线段树。故名思义, 线段树能解决的是线段上的问题, 这个线段也可指区间。
线段树支持三个操作:
build(start,end,vals) -> O(n)
update(index,value) -> O(logn)
query(start,end) -> O(logn)
线段树的结构
一颗线段树的构造就是根据区间的性质的来构造的, 如下是一棵区间[0, 4]
的线段树,每个[start, end]
都是一个二叉树中的节点。
[0,4]
/ \
[0,2] [3,4]
/ \ / \
[0,1] [2,2] [3,3] [4,4]
/ \
[0,0] [1,1]
区间划分大概就是上述的区间划分。可以看出每次都将区间的长度一分为二,数列长度为n,所以线段树的高度是log(n)
,这是很多高效操作的基础。
上述的区间存储的只是区间的左右边界。我们可以将区间的最大值加入进来,也就是树中的Node需要存储left,right左右子节点外,还需要存储start, end, val
区间的范围和区间内表示的值。
[0,3]
(val=4)
/ \
[0,1] [2,3]
(val=4) (val=3)
/ \ / \
[0,0] [1,1] [2,2] [3,3]
(val=1)(val=4) (val=2)(val=3)
区间的第三维就是区间的最大值。加这一维的时候只需要在建完了左右区间之后,根据左右区间的最大值来更新当前区间的最大值即可,即当前子树的最大值是左子树的最大和右子树的最大值里面选出来的最大值。
// 节点区间定义
// [start, end] 代表节点的区间范围
// max 是节点在(start,end)区间上的最大值
// left, right 是当前节点区间划分之后的左右节点区间
class SegmentTreeNode {
public:
int start, end, max;
SegmentTreeNode *left, *right;
SegmentTreeNode(int start, int end, int max, SegmentTreeNode *left, SegmentTreeNode *right) {
this->start = start;
this->end = end;
this->max = max;
this->left = left;
this->right = right;
}
};
因为每次将区间的长度一分为二,所有创造的节点个数,即底层有n个节点,那么倒数第二次约n/2个节点,倒数第三次约n/4个节点,依次类推:
n + 1/2 * n + 1/4 * n + 1/8 * n + ...
= (1 + 1/2 + 1/4 + 1/8 + ...) * n
= 2n
所以构造线段树的时间复杂度和空间复杂度都为O(n)
线段树的构建
给定一个区间,我们要维护线段树中存在的区间中最大的值。这将有利于我们高效的查询任何区间的最大值。给出A数组,基于A数组在线性时间内构建一棵维护最大值的线段树。
segmentTreeNode* buildTree(int start, int end, vector<int> nums){
if(start == end){
return new segmentTreeNode(start,end,nums[start],NULL,NULL);
}
int mid = start + (end-start)/2;
segmentTreeNode* left = buildTree(start,mid,nums);
segmentTreeNode* right = buildTree(mid+1,end,nums);
return new segmentTreeNode(start,end, max(left->sum,right->sum), left, right);
}
线段树的区间查询
1. 如何更好的查询Query
构造线段树的目的就是为了更快的查询。
给定一个区间,要求区间中最大的值。线段树的区间查询操作
就是将当前区间分解为较小的子区间
,然后由子区间的最大值就可以快速得到需要查询区间的最大值。
[0,3]
(val=4)
/ \
[0,1] [2,3]
(val=4) (val=3)
/ \ / \
[0,0] [1,1] [2,2] [3,3]
(val=1)(val=4) (val=2)(val=3)
query(1,3) = max(query(1,1),query(2,3)) = max(4,3) = 4
上述例子将[1, 3]
区间分为了[1, 1]
和[2, 3]
两个区间,因为[1, 1]和[2, 3]存在于线段树上,所以区间的最大值已经记录好了,所以直接拿来用就可以了。所以拆分区间的目的是划分成为线段树上已经存在的小线段
。
2. 如何拆分区间变成线段树上有的小区间:
在线段树的层数上考虑查询 考虑长度为8的序列构造成的线段树区间[1, 8], 现在我们查询区间[1, 7]。
第一层会查询试图查询[1, 7], 发现区间不存在,然后根据mid位置拆分[1, 4]和[5, 7]
第二层会查询[1, 4],[5, 7], 发现[1, 4]已经存在,返回即可,[5, 7]仍旧需要继续拆分
第三层会查询[5, 6],[7, 7], 发现[5, 6]已经存在,返回即可,[7, 7]仍旧需要继续拆分
第四层会查询[7, 7]
任意长度的线段,最多被拆分成logn条线段树上存在的线段,所以查询的时间复杂度为O(log(n))
// 区间查询的代码及注释
int query(TreeNode *root, int start, int end) {
if (start == root->start && root->end == end) {
return root->max;
}
int mid = (root->start + root->end) / 2;
if (end <= mid) {
return query(root->left,start, end);
}else if (start >= mid + 1) {
return query(root->right,start, end);
}else{
return max(query(root->right,start, mid),query(root->right,mid+1, end));
}
}
线段树的单点更新
更新序列中的一个点
[0,3]
(val=4)
/ \
[0,1] [2,3]
(val=4) (val=3)
/ \ / \
[0,0] [1,1] [2,2] [3,3]
(val=1)(val=4) (val=2)(val=3)
更新序列中的一个节点,如何把这种变化体现到线段树中去,例如,将序列中的第4个点A[3]更新为5, 要变动3个区间中的值,分别为[3,3],[2,3],[0,3]
提问:为什么需要更新这三个区间?
:因为只有这三个在线段树中的区间,覆盖了3这个点。
[0,3]
(val=5)
/ \
[0,1] [2,3]
(val=4) (val=5)
/ \ / \
[0,0] [1,1] [2,2] [3,3]
(val=1)(val=4) (val=2)(val=5)
可以这样想,改动一个节点,与这个节点对应的叶子节点需要变动。因为叶子节点的值的改变可能影响到父亲节点,然后叶子节点的父亲节点也可能需要变动。
更新所以需要从叶子节点一路走到根节点
, 去更新线段树上的值。因为线段树的高度为log(n),所以更新序列中一个节点的复杂度为log(n)。
因为每次从父节点走到子节点的时候,区间都是一分为二,那么我们要修改index的时候,我们从root出发,判断index会落在左边还是右边,然后继续递归,这样就可以很容易从根节点走到叶子节点,然后更新叶子节点的值,递归返回前
不断更新每个节点的最大值即可。具体代码实现如下:
// 单点更新的代码及注释
void update(SegmentTreeNode *root, int index, int value) {
// write your code here
if(root->start == root->end && root->start == index) { // 找到被改动的叶子节点
root->max = value; // 改变value值
return;
}
int mid = (root->start + root->end) / 2; // 将当前节点区间分割为2个区间的分割线
if(index <= mid){ // 如果index在当前节点的左边
modify(root->left, index, value); // 递归操作
}else{ // 如果index在当前节点的右边
modify(root->right, index, value); // 递归操作
}
root->max = max(root->right->max, root->left->max); // 可能对当前节点的影响
return;
}
如果需要区间的最小值或者区间的和,构造的时候同理。
总结 - 线段树问题解决的框架
通过前面问题的分析,我们对线段树问题可以做如下总结:
- 如果问题带有区间操作,或者可以转化成区间操作,可以尝试往线段树方向考虑
- 从面试官给的题目中抽象问题,将问题转化成一列区间操作,注意这步很关键
当我们分析出问题是一些列区间操作的时候
- 对区间的一个点的值进行修改
- 对区间的一段值进行统一的修改
- 询问区间的和
- 询问区间的最大值、最小值
什么情况下,无法使用线段树?
- 如果我们删除或者增加区间中的元素,那么区间的大小将发生变化,此时是无法使用线段树解决这种问题的。
二、树状数组
数组在物理空间上是连续的,而树是通过父子关系关联起来的,而树状数组正是这两种关系的结合,首先在存储空间上它是以数组的形式存储的,即下标连续;其次,对于两个数组下标x,y(x < y),如果x + 2^k = y (k等于x的二进制表示中末尾0的个数),那么定义(y, x)为一组树上的父子关系,其中y为父结点,x为子结点。
我们定义C[i]的值为它的所有叶子结点的权值的总和。根据上述逻辑结构和思想,可以写出C[i]的表达式,C[i]=A[i]+A[i-1]+....+A[i-2^k+1]。k代表i的二进制的最后连续0的个数。
其实C[i]还有一种更加普适的定义,它表示的其实是一段原数组A的连续区间和。区间的左端点为i - 2^k + 1,右端点为i。
构建树状数组,实则就是初始化C数组。对于C数组,我们知道,下标为i的Ci,在树形逻辑结构中,它的父亲是i + 2^k = y,而它父亲的父亲则是y + 2^ k' = m 一直到超出数据范围为止。也就是说,原本的Ai,只会影响Ci及Ci的祖先。
基于“前缀和”信息来实现:
- Log(n)修改任意位置值
- Log(n)查询任意区间和
功能特性:
对于一个有N个数的数组,支持如下功能:
update(index, delta) -> O(logN)
query(k) -> O(logK)
实现特性:
虽然名字叫做Tree,但是是用数组(Array)存储的
BIT是一棵多叉树,父子关系代表包含关系
BIT的第0位空出来,没有用上
如何求前缀和?
由前面可知,C[i] = A[i - 2^k + 1] + A[i - 2^k + 2] +... + A[i],所以
sum(i) = A[1] + A[2] + ... + A[i]
= A[1] + A[2] + ... + A[i - 2^k] + A[i - 2^k + 1] + ... + A[i]
= A[1] + A[2] + ... + A[i - 2^k] + C[i]
= sum(i - 2^k) + C[i]
= sum(i - lowbit(i)) + C[i]
lowbit operation:
// lowbit opeation returns the first 1 bit from the right in binary representation
// which is equivalent to doing 2^k where k is number of tailing 0s
lowbit(x) = x & (-x);
如何更新?
当A[i]被更新了,那些BIT中的数会受到影响?
BIT的两个操作总结
update(x,delta)
- delta = val - A[x] 也就是增量
- 从x开始,不断的将 BIT[x] += delta,然后 x = x + lowbit(x) while x <= n
query(x)
- 不断的做 x = x - lowbit(x) while x > 0
// binary index tree array index start from 1
// the index passed from the outside function call should +1
class BIT{
private:
vector<int> sums;
int lowbit(int x)
return x & (-x);
public:
BIT(n):sums(n+1,0){}
void update(int index, int delta){
for(int i = index; i <= sums.size(); i += lowbit(i))
sums[i] += delta;
}
int query(int index){
int sum = 0;
for(int i = index; i > 0; i -= lowbit(i))
sum += sums[i];
return sum;
}
}
树状数组总结
Binary Indexed Tree 事实上就是一个有部分区段累加和数组
首先我们必须明确的事情是,树状数组只能维护前缀(前缀和,前缀积,前缀最大最小),而线段树可以维护区间。我们求区间和,是用两个前缀和相减得到的,而区间最大最小值是无法用树状数组求得的。(经过一些修改处理,也可以处理)
所以树状数组可以针对的题目是:
1.如果问题带有区间和,或者可以转化成区间和问题,可以尝试往树状数组方向考虑
- 从面试官给的题目中抽象问题,将问题转化成一列区间操作,注意这步很关键
2.当我们分析出问题是一个区间和的问题时,如果有以下特征:
- 对区间的单个元素进行修改操作
- 对区间进行求和操作
3.我们就可以套用经典的树状数组模型进行求解
树状数组劣势
- 线段树无法处理的问题树状数组也无法处理。
- 对于区间最大值这类无法通过两个区间相减操作得到解答的问题,树状数组一般也无法处理。(即经过一些修改处理,也可以处理)
树状数组优势
- 更快(树状数组运用位运算、不递归)
- 更少空间(树状数组只有O(n)的空间消耗,线段树根据写法和实现不同空间消耗也有所不同,但都大于树状数组的消耗)
树状数组 | 线段树 | |
区间和 | O(logN) | O(logN) |
区间最大值/最小值 | N/A | O(logN) |
所有数最大值/最小值 | O(logN) | 取值O(1) 更新O(logN) |
比某个数大的最小值 | N/A | O(logN) |
比某个数小的最大值 | N/A | O(logN) |