线段树(Segment Tree)

文章目录

  • 【概述】
  • 【基本操作】
    • 1.建树
    • 2.单点修改
    • 3.区间查询
    • 4.区间修改(延迟标记)
  • 【例题】
    • 1.区间最值
    • 2.区间求和
    • 3.子段乘积
    • 4.最大连续子段和
    • 5.区间最大公约数
    • 6.扫描线

【概述】

线段树(Segment Tree)是一种基于分治思想的二叉树结构,用于在区间上进行信息统计。

线段树维护的信息,需要满足可加性,即能以可以接受的速度合并信息和修改信息,包括在使用懒惰标记时,标记也要满足可加性。(例如取模就不满足可加性,对 4 4 4 取模然后对 3 3 3 取模,两个操作就不能合并在一起做)

与按照二进制位(2 的次幂)进行区间划分的树状数组相比,线段树是一种更加通用的结构:

  1. 线段树的每个节点都代表一个区间。
  2. 线段树具有唯一的根节点,代表的区间是整个统计范围,如 [ 1 , N ] [1,N] [1,N]
  3. 线段树的每个叶节点都代表一个长度为 1 1 1 的元区间 [ x , x ] [x,x] [x,x]
  4. 对于每个内部节点 [ l , r ] [l,r] [l,r],它的左子节点是 [ l , m i d ] [l,mid] [l,mid],右子节点是 [ m i d + 1 , r ] [mid+ 1,r] [mid+1,r],其中 m i d = ( l + r ) / 2 mid=(l + r)/2 mid=(l+r)/2(向下取整)
线段树(Segment Tree)_第1张图片 线段树(Segment Tree)_第2张图片

上图展示了一棵线段树。可以发现,除去树的最后一层,整棵线段树一定是一棵完全二叉树,树的深度为 O ( l o g N ) O(logN) O(logN)。 因此,我们可以按照与二叉堆类似的“父子2倍”节点编号方法:

  1. 根节点编号为 1 1 1
  2. 编号为 x x x 的节点的左子节点编号为 x ∗ 2 x*2 x2,右子节点编号为 x ∗ 2 + 1 x*2+ 1 x2+1

这样一来,我们就能简单地使用一个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=2N1 个节点。因为在上述存储方式下,最后还有一层产生了空余,所以保存线段树的数组长度要不小于 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)。.

1.建树

给定一个长度为 N N N 的序列 A A A,我们可以在区间 [ 1 , N ] [1,N] [1,N] 上建立一 棵线段树,每个叶节点 [ i , i ] [i,i] [i,i] 保存 A [ i ] A[i] A[i] 的值。线段树的二叉树结构可以很方便地从下往上传递信息。

线段树(Segment Tree)_第3张图片
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);                                 // 调用入口

2.单点修改

单点修改是一条形如 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)

线段树(Segment Tree)_第4张图片
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);                                            // 调用入口

3.区间查询

区间查询是一条形如 Q   l   r Q\ l\ r Q l r 的指令,表示查询序列 A A A 在区间 [ l , r ] [l,r] [l,r] 上的最大值。

我们只需要从根节点开始,递归执行以下过程:

  1. [ l , r ] [l,r] [l,r] 完全覆盖了当前节点代表的区间,则立即回溯,并且该节点的dat值为候选答案。
  2. 若左子节点与 [ l , r ] [l,r] [l,r] 有重叠部分,则递归访问左子节点。
  3. 若右子节点与 [ l , r ] [l,r] [l,r] 有重叠部分,则递归访问右子节点。
线段树(Segment Tree)_第5张图片
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(向下取整),可能会出现以下几种情况:

  1. l ≤ p l ≤ p r ≤ r l≤p_l≤p_r≤r lplprr,即完全覆盖了当前节点,直接返回。
  2. p l ≤ l ≤ p r ≤ r p_l≤l≤pr≤r pllprr,即只有 l l l 处于节点之内。
    (1) l > m i d l>mid l>mid,只会递归右子树。
    (2) l ≤ m i d l≤mid lmid,虽然递归两棵子树,但是右子节点会在递归后直接返回。
  3. l ≤ p l ≤ r ≤ p r l≤p_l≤r≤p_r lplrpr,即只有 r r r 处于节点之内,与情况 2 2 2 类似。.
  4. p l ≤ l ≤ r ≤ p r p_l≤l≤r≤p_r pllrpr,即 l l l r r r 都位于节点之内。
    (1) l , r l,r l,r 都位于 m i d mid mid 的一侧,只会递归一棵子树。
    (2) l , r l,r l,r 分别位于 m i d mid mid 的两侧,递归左右两棵子树。

也就是说,只有情况 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) 在两条路径在从下往上的第一次交会处产生。

4.区间修改(延迟标记)

在线段树的区间查询中,每当遇到被询问区间 [ 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 lplprr 的情况下立即返回,只不过在回溯之前向节点 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)。 这些标记就称为“延迟标记”。延迟标记提供了线段树中从上往下传递信息的方式。这种“延迟”也是设计算法与解决问题的一个重要思路。

【例题】

1.区间最值

  • 洛谷 P2880 平衡的阵容(区间查询):点击这里
  • AcWing 1275. 最大数(单点修改+区间查询):点击这里
  • HDU 2795 Billboard:点击这里
  • HDU 1754 I Hate It:点击这里

2.区间求和

  • POJ 3468 A Simple Problem with Integers(区间修改+区间查询):点击这里
  • HDU 1698 Just a Hook:点击这里
  • 洛谷 P3372 【模板】线段树 1:点击这里

3.子段乘积

  • 牛客 C. 子段乘积:点击这里

4.最大连续子段和

  • AcWing 245. 你能回答这些问题吗(单点修改+区间查询):点击这里

5.区间最大公约数

6.扫描线

你可能感兴趣的:(线段树)