开一贴,把之前学过的线段树重新总结一下
引例
来看一个例子
对于一个长度为1000000的数组,现在有两种操作:
- 查询数组中某个区间的最值
- 更新数组中某个区间的值
这样的操作要是有1000000次,如果我们用朴素的算法,即每次以O(n)的效率去处理操作,无疑是非常耗时的。
线段树对于每个查询 和 更新 的操作,都是O(logn)的时间复杂度,因此大大提高了效率。
线段树基本概念
如上图所示,是一颗完整的线段树,接下来有关的操作都通过这张图来说明,我们可以从图中得出一些结论:
- 线段树是一颗二叉搜索树
- 每个结点存储着区间和数据信息
首先看第一个结论。线段树是一颗二叉搜索树说明每个结点最多有两个子结点,由于线段树将区间不断二分的特点,因此结点如果有子结点则必定是两个子结点,否则是叶子节点。设左边界为left, 右边界为right, 如果left != right,则左子树区间为[left, (left+right)/2], 右子树是[(left+right)/2+1, right],否则 left = right, 是叶子节点,不必继续划分。在上图的例子中,区间[1,8]可以划分,(1 + 8) / 2 = 4, 则左子树的区间为[1,4], 右子树的区间为[5,8];继续划分左子树,可以发现区间[1, 1]已经不能划分,因此停止递归。
有了这样的数据结构,那么我们就可以在其中存储相关的信息。
例如上图中的线段树就是存储每个区间中的最大值。显然我们只需要在每个叶子结点中存储相对应的数据,之后的整棵树经过构造之后,就会记录所有区间的最大值信息。图中的[1,2]区间的最大值显然是通过比较左右子树的最大值得到的,左子树的值为1,右子树的值为2,因此区间[1,2]上的最大值为2,同理得出其他所有子树的最大值,最后可以算出根节点的最大值为8。
接下来我们讨论线段树的存储问题,线段树可以用指针的方式来存储,但在实际的做题过程中,采用结构体数组的方式比较方便。下图中的 l 表示左边界,r 表示右边界,Max用来存储区间的最大值。
#define maxn 200010
struct node{
int l;
int r;
int Max;
}T[maxn*4];
使用上图的数组存储中,我们可以很方便的访问一个非叶子结点的左右子树,假设一个非叶子结点的下标为i,则左子树为2*i,右子树为2*i+1。
此外,一般在实际应用中,数组的大小为所要求的结点的4倍即可。
线段树基本操作
有了线段树的基本概念后,我们来讨论线段树的四种操作:构造(build), 查询(query),更新(update), 查找(search)。
构造
int val[maxn]; // 原始数组
void build(int left, int right, int num)
{
T[num].l = left;
T[num].r = right;
if(left == right)
{
T[num].Max = val[left];
return;
}
int mid = (left+right)/2;
build(left, mid, 2*num);
build(mid+1, right, 2*num+1);
T[num].Max = max(T[2*num].Max, T[2*num+1].Max);
}
上图是求最大值的线段树构造过程,其他有关的线段树算法都大同小异,基本的框架是一样的,只有细节有变化。
构造是从根节点开始,不断深度优先搜索的过程,如果left == right,就递归到头,返回;否则继续搜索该结点的左右子树,在递归的过程中,自底向上更新最大值。
查询
void query(int x, int y, int num)
{
if(T[num].l == x && T[num].r == y)
{
ans = max(ans, T[num].Max);
return ;
}
int mid = (T[num].l + T[num].r)/2;
if(x > mid) query(x, y, 2*num+1);
else if(y <= mid) query(x, y, 2*num);
else
{
query(x, mid, 2*num);
query(mid+1, y, 2*num+1);
}
}
上图是线段树的查询过程,其中[x,y]为待查询的区间,每次查询总是和区间中点的值进行比较,分为以下三种情况:
- x > mid,说明查询的区间在线段树右边,直接递归线段树的右子树
- y <= mid, 说明查询的区间在线段树左边,直接递归线段树的左子树
- x <= mid < y, 说明查询的区间同时包含线段树的左右的一部分,所以两边都需要递归
这里必须注意:因为 mid = (T[num].l + T[num].r)/2, int 类型总是向下取整的,因此每次算出来的mid实际上是在左子树上,因此只有整个区间 > mid(而不是>=)才能递归右子树。
以上面的例子来说明,整个区间为[1,8],mid = 4
- 如果查询[1,3] ,x = 1,y = 3, y <= mid,直接递归线段树的左子树
- 如果查询[5,7] ,x = 5,y = 7, x > mid,直接递归线段树的右子树
- 如果查询[3,6] ,x = 3,y = 6,x <= mid < y,两边的子树都需要递归
这里的查询操作是用了全局变量,因此不返回值,如果用局部变量的话,则需要最后的返回。
更新
void update(int x, int y, int num)
{
if(T[num].l == T[num].r && T[num].l == x)
{
T[num].Max = y;
return ;
}
int mid = (T[num].l+T[num].r)/2;
if(x > mid) update(x, y, 2*num+1);
else if(x <= mid) update(x, y, 2*num);
T[num].Max = max(T[2*num].Max, T[2*num+1].Max);
}
上图是单点更新的线段树操作,x为要更新的数组下标,y为要更新的值,只要递归到此区间更新值即可,同时注意最后需要更新区间的最大值。
查找
void Search(int num)
{
printf("l=%d r=%d Max=%d\n",T[num].l,T[num].r,T[num].Max);
if(T[num].l == T[num].r) return ;
Search(2*num);
Search(2*num+1);
}
查找是用来debug,可以输出整棵线段树的信息。