省流:本文将介绍用指针实现线段树,并介绍如何在题目中运用线段树解决问题。
线段树 の 实现
线段树一般用于维护区间信息。
题目示例
要求维护一个序列 $\{a_n\}$,实现两种操作:
- 给定 $l,r$ 和 $x$,让 $a$ 中 $[l,r]$ 区间内的每一个数加上 $x$(区间加)。
- 给定 $l,r$,求 $\sum_{i=l}^{r}a_i$(区间求和)。
朴素实现 / $\textbf{Bruteforce}$
- 前缀和:发现有区间求和,容易想到用前缀和 $O(1)$ 维护。但是对于区间加,最坏情况下要修改整个前缀和序列,时间复杂度为 $O(n)$。
- 差分:发现有区间加,容易想到用差分 $O(1)$ 维护。但是对于区间求和,每次需要计算一次差分序列的前缀和,时间复杂度为 $O(n)$。
线段树 / $\textbf{Segment Tree}$
有没有一种可以均衡两个操作复杂度,每次操作是 $O(\log n)$ 的数据结构呢?
线段树是将序列分治的过程,并且将分治的过程记录下来。
线段树维护的信息需要满足可合并性,即可以从儿子的信息得到父亲的信息。
存储方式
线段树将序列中若干个区间在树上用结点来表示,其中 $[1,n]$(在下文的讨论中 $n$ 均表示序列长度)为树的根。
对于区间 $[l,r]$,可以将其均分为两段,设 $\text{mid}=\left\lfloor\dfrac{l+r}{2}\right\rfloor$,那么可以将 $[l,\text{mid}]$ 和 $[r,\text{mid}]$ 分别作为当前结点的左右儿子。
对于上面的题目,可以这样设计结点:
1 | struct TreeNode { |
下面是一颗维护 $[1,7]$ 的线段树形态:

性质
对于线段树上的任一结点,儿子数量为 $0$ 或 $1$,不存在只有一个儿子的情况。
证明
非常显然。
一个长度为 $n$ 的序列建立的线段树只有 $(2n-1)$ 个结点。
证明
首先线段树上必然有 $n$ 个叶子结点。
每两个叶子结点会合并,添加一个结点,此时没有父结点的点数减一。
最后没有父结点的点数为 $1$,即根节点,所以添加了 $(n-1)$ 个结点。
故总共有 $(2n-1)$ 个结点。
一个长度为 $n$ 的序列建立的线段树的高度为 $O(\log n)$($O(f(n))$ 的意思是与 $f(n)$ 同阶)。
证明
首先对于一个结点 $[l,r]$,设其长度 $t=r-l+1$。
则其长度最大的儿子的长度为 $\left\lfloor\dfrac{t+1}{2}\right\rfloor$。
那么设树高为 $h$,有
$$ \dfrac{\dfrac{\dfrac{n+1}{2}+1}{2}+1}{2}\dots $$
这个式子迭代 $h$ 次之后等于 $1$。因为上式 $\le \dfrac{n+h}{2^h}$,那么有
$$ \dfrac{n+h}{2^h}=O(1) $$
那么容易解得 $h=O(\log n)$,那么一般认为 $h=\left\lceil\log n\right\rceil$。
空间复杂度
线段树是一个类似完全二叉树的结构,考虑从上至下的每层结点数量之和,得到
$$ 1+2+4+\cdots+2^{\lceil\log n\rceil}\le2^{\lceil\log n\rceil+1} $$
一般结点数量接近 $4n$。
建线段树
树是递归定义的,所以也可以递归建树。
线段树需要从儿子的信息合并得到父亲的信息,考虑设计一个辅助函数:
1 | void pushup(TreeNode *u) { |
这样就可以从儿子的区间和得到父亲的区间和了。
每次修改操作结束后都需要一次 pushup!
建树只需要将当前区间分为两部分,然后分别递归下去即可,详见代码。
1 | // 假设根节点的 l,r 都已经设置好 |
时间复杂度为 $\Theta(n)$。
时间复杂度证明 1
容易发现每次调用 build 就新建了一个线段树结点,而结点数量为 $\Theta(n)$,那么时间复杂度也就是 $\Theta(n)$ 了。
时间复杂度证明 2
递归函数的时间函数为 $T(n)=2\times T\left(\dfrac n2\right)$。
那么在主定理中 $a=b=2$,$\log_ba=\log_22=1$,$\epsilon$ 可以取 $(0,1]$ 范围内的数字,满足主定理的第一条情况,所以 $T(n)=\Theta(n)$。
区间修改
由于单点修改相当于修改一个区间 $[p,p]$,所以仅讨论区间修改。
暴力修改 / $\textbf{Bruteforce}$
遍历区间内的每一个位置,然后在线段树上定位修改。
这样做的时间复杂度是 $O(n\log n)$ 的,甚至不如用数组维护。
懒标记 / $\textbf{Lazy Tag}$
考虑引入懒标记($\textbf{Lazy Tag}$),在结点处记录修改信息。
设当前递归到的区间为 $[l,r]$,需要修改的区间为 $[L,R]$,需要区间加上 $x$。
如果 $[l,r]$ 被 $[L,R]$ 完全包含,那么可以在 $[l,r]$ 所对应的结点上标记这颗子树需要 $+x$,并且更新当前结点的 w,然后直接返回。
每次访问到一个结点时,需要先将懒标记下放到子结点上,然后递归。
正确性比较显然。
举个例子
假设初始序列为 $\{1,5,4,2,3\}$,要对 $[1,4]$ 的每一个数加上 $5$。
线段树初始形态:
区间 $[1,4]$ 在线段树上一路递归下去发现可以划分为 $[1,3]$ 和 $[4,4]$ 两个序列。
此时将 $[1,3]$ 和 $[4,4]$ 对应的结点的 $\text{lzy}\gets5$,并且更新两个结点的区间和,同时向上回溯
pushup。修改后的线段树:
区间查询
由于单点查询相当于查询一个区间 $[p,p]$,所以仅讨论区间查询。
这里采用懒标记的实现方法。
