线段树(Segment Tree)是一种基于分治思想的二叉树结构,用于在区间上进行信息统计。
线段树维护的信息,需要满足可加性,即能以可以接受的速度合并信息和修改信息,包括在使用懒惰标记时,标记也要满足可加性。(例如取模就不满足可加性,对 4 4 4 取模然后对 3 3 3 取模,两个操作就不能合并在一起做)
与按照二进制位(2 的次幂)进行区间划分的树状数组相比,线段树是一种更加通用的结构:
上图展示了一棵线段树。可以发现,除去树的最后一层,整棵线段树一定是一棵完全二叉树,树的深度为 O ( l o g N ) O(logN) O(logN)。 因此,我们可以按照与二叉堆类似的“父子2倍”节点编号方法:
这样一来,我们就能简单地使用一个struct数组来保存线段树。当然,树的最后一层节点在数组中保存的位置不是连续的,直接空出数组中多余的位置即可。
在理想情况下, N N N 个叶节点的满二叉树有 N + N / 2 + N / 4 + . . . + 2 + 1 = 2 N − 1 N + N/2 + N/4+...+2+1= 2N-1 N+N/2+N/4+...+2+1=2N−1 个节点。因为在上述存储方式下,最后还有一层产生了空余,所以保存线段树的数组长度要不小于 4 N 4N 4N 才能保证不会越界。
struct SegmentTree {
int l, r;
int dat;
}t[SIZE * 4]; // struct数组存储线段树
下面以区间最大值问题为例进行介绍,显然 d a t ( l , r ) = m a x ( d a t ( l , m i d ) , d a t ( m i d + 1 , r ) dat(l,r) = max(dat(l, mid), dat(mid + 1,r) dat(l,r)=max(dat(l,mid),dat(mid+1,r)。.
给定一个长度为 N N N 的序列 A A A,我们可以在区间 [ 1 , N ] [1,N] [1,N] 上建立一 棵线段树,每个叶节点 [ i , i ] [i,i] [i,i] 保存 A [ i ] A[i] A[i] 的值。线段树的二叉树结构可以很方便地从下往上传递信息。
void build(int p, int l, int r)
{
t[p].l = l, t[p].r = r; // 节点p代表区间[1,r]
if(l == r) { t[p].dat = a[l]; return; } // 叶节点
int mid = (l + r) / 2; // 折半
build(p*2, l, mid); // 左子节点[l,mid],编号p*2
build(p*2+1, mid+1, r); // 右子节点[mid+1,r],编号p*2+1
t[p].dat = max(t[p*2].dat, t[p*2+1].dat); // 从下往上传递信息
}
build(1, 1, n); // 调用入口
单点修改是一条形如 C x v C\ x\ v C x v 的指令,表示把 A [ x ] A[x] A[x] 的值修改为 v v v。
在线段树中,根节点(编号为 1 1 1 的节点)是执行各种指令的入口。我们需要从根节点出发,递归找到代表区间 [ x , x ] [x,x] [x,x] 的叶节点,然后从下往上更新 [ x , x ] [x,x] [x,x] 以及它的所有祖先节点上保存的信息,如下图所示。时间复杂度为 O ( l o g N ) O(logN) O(logN)。
void change(int p, int x, int v)
{
if(t[p].l == t[p].r) { t[p].dat = v; return; } // 找到叶节点
int mid = (t[p].l + t[p].r) / 2;
if(x <= mid) change(p*2, x, v); // x属于左半区间
else change(p*2+1, x, v); // x属于右半区间
t[p].dat = max(t[p*2].dat, t[p*2+1].dat); // 从下往上更新信息
}
change(1, x, v); // 调用入口
区间查询是一条形如 Q l r Q\ l\ r Q l r 的指令,表示查询序列 A A A 在区间 [ l , r ] [l,r] [l,r] 上的最大值。
我们只需要从根节点开始,递归执行以下过程:
int ask(int p, int l, int r)
{
if(l <= t[p].l && r >= t[p].r) return t[p].dat; // 完全包含
int mid = (t[p].l + t[p].r) / 2;
int val = -(1 << 30); // 负无穷大
if(l <= mid) val = max(val, ask(p*2, l, r)); // 左子节点有重叠
if(r > mid) val = max(val, ask(p*2+1, l, r)); // 右子节点有重叠
return val;
}
cout << ask(1, l, r) << endl; //调用入口
该查询过程会把询问区间 [ l , r ] [l,r] [l,r] 在线段树上分成 O ( l o g N ) O(logN) O(logN) 个节点,取它们的最大值作为答案。
为什么是 O ( l o g N ) O(logN) O(logN) 个呢?仔细分析上述过程,在每个节点 [ p l , p r ] [p_l,p_r] [pl,pr] 上,设 m i d = ( p l + p r ) / 2 mid = (p_l + p_r)/2 mid=(pl+pr)/2(向下取整),可能会出现以下几种情况:
也就是说,只有情况 4 ( 2 ) 4(2) 4(2) 会真正产生对左右两棵子树的递归。这种情况至多发生一次,之后在子节点上就会变成情况 2 2 2 或 3 3 3。因此,上述查询过程的时间复杂度为 O ( 2 l o g N ) = O ( l o g N ) O(2logN)=O(logN) O(2logN)=O(logN)。
从宏观上理解,相当于 l , r l,r l,r 两个端点分别在线段树上划分出一条递归访问路径,情况 4 ( 2 ) 4(2) 4(2) 在两条路径在从下往上的第一次交会处产生。
在线段树的区间查询中,每当遇到被询问区间 [ l , r ] [l,r] [l,r] 完全覆盖的节点时,可以立即把该节点上存储的信息作为候选答案返回。已经证明,被询问区间 [ l , r ] [l,r] [l,r] 在线段树上会被分成 O ( l o g N ) O(logN) O(logN) 个小区间(节点),从而在 O ( l o g N ) O(logN) O(logN) 的时间内求出答案。
不过,在区间修改中,如果某个节点被修改区间 [ l , r ] [l,r] [l,r] 完全覆盖,那么以该节点为根的整棵子树中的所有节点存储的信息都会发生变化,若逐一进行更新,将使得一次区间修改的时间复杂度增加 O ( N ) O(N) O(N),这是我们不能接受的。
试想,如果我们在一次修改指令中发现节点 p p p 代表的区间 [ p l , p r ] [p_l,p_r] [pl,pr] 被修改区间 [ l , r ] [l,r] [l,r] 完全覆盖,并且逐一更新了子树 p p p 中的所有节点,但是在之后的查询指令中却根本没有用到 [ l , r ] [l,r] [l,r] 的子区间作为候选答案,那么更新 p p p 的整棵子树就是徒劳的。
换言之,我们在执行修改指令时,同样可以在 l ≤ p l ≤ p r ≤ r l≤p_l≤p_r≤r l≤pl≤pr≤r 的情况下立即返回,只不过在回溯之前向节点 p p p 增加一个标记,标识“该节点曾经被修改,但其子节点尚未被更新”。
如果在后续的指令中,需要从节点 p p p 向下递归,我们再检查 p p p 是否具有标记。若有标记,就根据标记信息更新 p p p 的两个子节点,同时为 p p p 的两个子节点增加标记,然后清除 p p p 的标记。
也就是说,除了在修改指令中直接划分成的 O ( l o g N ) O(logN) O(logN) 个节点之外,对任意节点的修改都延迟到“在后续操作中递归进入它的父节点时”再执行。这样一来,每条查询或修改指令的时间复杂度都降低到了 O ( l o g N ) O(logN) O(logN)。 这些标记就称为“延迟标记”。延迟标记提供了线段树中从上往下传递信息的方式。这种“延迟”也是设计算法与解决问题的一个重要思路。