线段树 Segment Tree
- 前言
线段树是一类平衡二叉树,它实质上是对递归过程中处理数据的记忆,也就是说它把递归的步骤都进行及时记忆,及时递归结束,仍然可以查找或更新不同的递归区间。
线段树的结构与堆(二叉堆)结构比较类似,它的叶子结点储存的是原始数组中的元素,终结点储存对叶子结点的逐层处理的基本信息。
线段树的结点的储存方式可以采用两种基本类型,第一类结构和Heap结构比较类似,定义根节点的序列号v,那么其左子树的序列号定义为2*v,右子树的序列号定义为2*v +1,如果需要这类方式进行定义,那么需要我们从v=1开始构造线段树。
既然线段树属于平衡二叉树,构造线段树的信息就可以采用前序遍历,后续遍历或中序遍历的任何一种,一般情况下,采用约定成俗的后序遍历信息处理的方法。也就是,需要处理完成叶子节点后,最后处理父节点的信息,父节点的信息来自对叶子结点信息的基本处理。父节点的处理方式可以选择求和,求最大值或求最小值,甚至可以求解前缀和后缀的值。
- 线段树的实现和操作
我们从简单的问题开始,给定数组a[]={1,3,-2,8,7},需要查询任意区间段的和a[l…r],要求采用线段树的数据结构实现。如果数组元素比较少,可以采用每次递加的方式进行sum计算,但是当数据量很大的时候,时间复杂度和空间复杂度都非常高,不利于程序的执行。
那么我们就需要考虑采用线段树进行操作,之前提到线段树可以有效进行递归的分段记忆,在构造线段树之前,需要确定线段树元素的个数。
定义数组a[]元素个数为n,那么求和线段树得深度就为[log2n],如果当前深度为i,那么当前层的元素数量就为
2
i
2^i
2i
基于上述结论,那么线段树的元素总数描述为,
为了方便计算,我们需要把线段树的元素数量设定为4n,由于线段树并非满二叉树,所以4n元素当中有些元素并未充分填充,但是由于我们需要采用2*i和2*i+1的模式,必须预留4n的空间对线段树进行操作。
由于构造的线段树为相应的区间元素求和,所以每个元素中包含的基本信息为a[l…r]的元素和,对于叶子节点,由于l=r,所以叶子节点中储存的是数组元素的单个元素值;对于非叶子节点,我们需要对非叶子节点的左右孩子进行求和计算。涉及代码如下。
#define MAXN 10
int t[4*MAXN]; //segment tree data structure
void build(int *nums, int v, int tl, int tr)
{
int tm;
if (tl == tr) // int nums[]={1,3,-2,8,-7};
{
t[v]=nums[tl];
}
else
{
tm=(tl+tr)/2;
build(nums,2*v,tl,tm);
build(nums,2*v+1,tm+1,tr);
t[v]=combine(t[2*v],t[2*v+1]);
}
return;:
}
int combine(int sum_1, int sum_2)
{
return (sum_1+sum_2);
}
观察代码结构,可以明显看到线段树的构造过程为二叉树后续操作的过程, 分支语句build(nums,2*v,tl,tm)计算出左分支的值,分支语句build(nums,2*v+1,tm+1,tr)计算出右分支的具体值。两个分支的值计算出来之后,调用求和函数计算出父节点的值,不断重复,直至后序遍历 回到根节点为止。
线段树的另外一个重要的操作为查询操作(query),如果要查询某一区间段的求和,就需要对线段树再进行递归调用,整个过程中分为三类情况:
-
查询区间位于左子树的某个区间(1)
-
查询区间位于右子树的某个区间(2)
-
查询区间横跨左右子树(3)
情形(1)
情形(2)
情形(3)
查询过程(query)可以理解为对线段树的递归遍历,类型属于后序遍历,在过程中不断对比tll && trr这两个条件是否成立,如果成立,则表明为情形(1)或情形(2)二者之一。对于情形(3),则需要左右两边的递归都返回后,然后再对结果进行合并。
int query(int v, int tl, int tr, int l, int r)
{
int sum_leftmost;
int sum_rightmost;
int tm;
if(l>r)
{
return 0;
}
else if(tl==l && tr==r)
{
return t[v];
}
else
{
tm=(tl+tr)/2;
sum_leftmost=query(2*v,tl,tm,l,min(r,tm)); //essence of this program
sum_rightmost=query(2*v+1,tm+1,tr,max(l,tm+1),r);
return combine(sum_leftmost,sum_rightmost);
}
}
如果l>r,则表明查询区间不包含l…r之间的计算结果,对于这个结点,我们不需要返回其本身的值,只要替代值0即可。
我们需要计算左半部分的查询结果和右半部分的查询结构,最后通过combine求解返回即可。下图表示计算区间[2,4]范围内的值的求和过程。
递归过程,当到结点4的时候由于l>r,所以直接返回0即可,接着遍历结点5,结点5满足tll && tr=r,直接返回t[4]即可,然后进行后续归并,0+(-2)=-2,整个过程相当于对结点5直接进行剔除,但是递归的过程保持不变。接着遍历结点3,由于结点3的tll3, trr4,我们直接返回其储存的值即可。最后再调用combine函数对其求和,返回最终的结果。
过程当中,我们使用 sum_leftmost和sum_rightmost来记录左右子树的具体值,从而不改变原有的线段树的值,从而达到搜索的目的。
最后一个操作是更新,在线段树的更新操作中,分为两类基本类型:
-
对单点数据进行更新
-
对区间段进行更新,对某个区间段内的所有元素进行相同的更新操作(区间内的每个元素的值增加x,或者对区间的计算结果进行重新赋值),还有一种情况是对区间段内储存的值进行相应更新
由于对区间的更新涉及到延迟数组或lazy[]数组的标记,本文暂不表述。我们仅针对单点的数据进行相应的更新,在函数中,我们需要指定待更新的位置上的具体数据,然后利用递归对所有的收到影响的结点进行相应的更新。
单点更新的过程实际是上剪枝遍历的过程,整个遍历过程中,需要比较中值位置和Pos的相对位置,以便决定递归是前往左子树还是右子树。
例如需要更新pos=2位置上的值,遍历的过程 根->左->右的具体过程,然后利用后序递归更新结点2的值为7,接着更新结点1的值为8.
实现代码也比较简单,
void update(int v, int tl, int tr, int pos, int new_value)
{
int tm;
if(tl==tr) // In this case, tl=tr=pos; condition will be met
{
t[v]=new_value;
}
else
{
tm=(tl+tr)/2;
if(pos<=tm)
{
update(2*v,tl,tm,pos,new_value);
}
else
{
update(2*v+1,tm+1,tr,pos,new_value);
}
t[v]=combine(t[2 * v], t[2 * v + 1]);
}
}
- 总结
线段树本质为递归过程中不同分割区间的信息的记忆,也可以是递归过程中信息处理的记忆,它把递归的区间信息映射到树上的索引编号,方便后续的查询(避免再次递归)。
对递归树的查询本质上是,对线段树的里面的信息再处理,处理的信息可能位于左子树,也可能位于右子树上,或者介于两者之间,但最终还是对这些信息的再组合以及再处理。
参考资料:
Segment Tree - Algorithms for Competitive Programming (cp-algorithms.com)