线段树基础与应用
前言:这又是一篇关于数据结构的文章。
今天来讲一下线段树和线段树的基本应用。线段树 (Segment Tree),是一种非常高效且高级的数据结构,其主要用于区间查询和与区间更新相关的问题,例如进行多次查询区间最大值、最小值、更新区间等操作。
区间最值问题引入
常见的线段树题型就是 区间最值问题 (Range Maximum/Minimum Query, RMQ)。通常来说,区间最值问题会给定用户一个长度为 的数组,对这个数组进行多次区间查询(最值)和区间批量修改的操作。
常见的区间最值算法(数据结构)有很多,但线段树在某些情况下一定是最优解。以下是不同 RMQ 算法的优势和劣势:
- 暴力枚举 Brute Force:实现难度非常简单,适合数据量较小的情况。但查询效率极其低下。
- 树状数组 Binary Indexed Tree:实现相对简单,查询和更新效率较高。只能处理前缀区间的问题,对于任意区间查询(例如,区间最值)需要进行一些变形和额外处理,不如线段树灵活。
- 稀疏表 Sparse Table:适合处理静态数据,即数据在预处理之后不再发生改变。如果要频繁实现在线区间修改/单点修改的操作,ST表就非常的耗时。
- 线段树 Segment Tree:查询和更新效率高,适合动态数据,支持快速更新。但缺点是实现较为复杂,代码量较大。
综上所述,每种算法(数据结构)都有自己的优势和劣势,我们应该根据实际情况选用最合适的方案。
线段树的底层是基于 二叉树 (Binary Tree) 来实现的,因此在线段树相关的操作中,大多数操作的时间复杂度可以被优化到 级别。相比 级别的暴力算法而言,线段树有显著的优势(据我所知,线段树唯一的劣势就是其码量对于初学者而言会比较多,因此在写线段树的过程中由于粗心导致失误的可能性会增加许多)。
线段树的基本结构
线段树是一棵二叉树,因此对于每一个节点而言至多只有两个子节点。与此同时,线段树的每一个节点都存储了一个区间的信息,通常是这个区间的某种 统计量(如最大值、最小值、总和等)。每个节点的区间是它的两个子节点区间的并集,根节点表示我们需要维护的整一个区间。
举一个形象的例子。例如我们想要构造一棵线段树来维护一个区间 的某些状态,我们所构造出来的线段树的结构会呈现下图所示的样子。其中根节点负责维护的 区间,其左儿子负责维护 区间,其右儿子负责维护 区间。可以看出,如果将一个根结点的左儿子和右儿子所维护的区间合并,那么这个新的区间就是该根节点所维护的区间。一般来说,一个节点的两个子节点维护的区间大小的差应该尽可能的小。以此类推,每一个节点都维护一个区间,直到这个区间不能再分为止,也就是说这棵树所有的叶子结点的区间长度都应该为 。
知道了线段树的基本结构,那么维护每一个节点所记录的状态也会变得特别简单。以维护区间最大值为例子,如果区间 所记录的最大值是 ,区间 所记录的最大值是 ,那么我们就可以很容易的推导出区间 的最大值应该是 。
因此,线段树的一个局限性就是维护的数据必须具有可传递性,说白了,就是必须可以通过两个小区间所记录的值来推导出某一个大区间所记录的值。
以下代码将以维护区间的总和为例子来展开:
线段树的存储
arr
数组是我们需要维护区间每个位置的原始数值。
我们通过 tree
数组来存储整一棵线段树,由于线段树属于一种平衡二叉树,在最坏的情况下,线段树的大小将会是 的四倍,因此数组至少需要开 的大小。对于这个数组,我们规定对于任何一个索引为 的节点,其左子节点的索引为 ,右子节点的索引为 。
为了加速计算,我们可以使用位运算的方式来实现(本文将不详细阐述位运算的过程,有需要的人可以自行上网查阅):
- 将一个数
x
乘上 ,可以写为x << n
。因此 可以写成n << 2
。 - 将一个数偶数加上 ,可以通过
x | 1
或运算来实现。
struct node{
int sum;
} tree[(n << 2) + 5];
int arr[n + 5]
线段树的构建
线段树的构建过程跟普通的二叉树构建过程类似,都是通过递归的方式来实现的。如果我们要构建一个长度为 的区间,在每一层递归的时候我们将区间对半分成两个部分,并分别构建其左子树和右子树。直到区间长度为 时停止。当一个节点的左子树和右子树都被初始化完成后,我们应该通过合并其子节点所维护的值来更新当前节点所记录的值,这个操作也被称之为 。
的代码也非常简单,就是单纯合并两个子节点的信息到它们的父节点当中,这里就是把父节点所维护区间的总和赋值为其两个子节点所维护区间总和的和:
void push_up(int root){
tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
return ;
}
线段树的初始化(构建)代码如下。其中 root
变量表示当前节点在 tree
数组中的索引。变量 l
与 r
分别表示所维护区间的左边界和右边界。对于每一层递归来说,我们要维护一个长度为 的闭区间 。当 l == r
时,则证明区间的长度正好为 ,因此终止递归,将该叶子结点初始化为数组中对应的值:
void build_tree(int l, int r, int root){
if (l == r){
tree[root] = (node){arr[l], 0};
return ;
}
int mid = (l + r) >> 1;
build_tree(l, mid, root << 1);
build_tree(mid+1, r, root << 1|1);
push_up(root);
return ;
}
通过代码可以看出,初始化一棵线段树的时间复杂度为 。
线段树的区间查询
与线段树的构建相同,查询线段树也是通过 递归+二分 的方式来实现的。给定一个查询的区间 。我们从根节点开始,如果当前节点表示的区间与查询区间完全匹配,则直接返回当前节点所存储的信息。否则,将查询区间分成左右两部分,递归查询左右子树,并将结果合并。相较于初始化操作,查询某一个区间的时间复杂度约为 。
例如,如果我们要查询区间 所维护的数据,递归到根节点的时候,根节点的左儿子的区间为 ,右儿子的区间为 ,我们发现我们所要查询的区间同时在左儿子和右儿子中,因此我们同时递归两个子区间,在 区间内查找