【知识点】浅入线段树与区间最值问题

前言:这又是一篇关于数据结构的文章。

今天来讲一下线段树和线段树的基本应用。线段树 (Segment Tree),是一种非常高效且高级的数据结构,其主要用于区间查询和与区间更新相关的问题,例如进行多次查询区间最大值、最小值、更新区间等操作。

区间最值问题引入

常见的线段树题型就是 区间最值问题 (Range Maximum/Minimum Query, RMQ) 。通常来说,区间最值问题会给定用户一个长度为 \(n\) 的数组,对这个数组进行多次区间查询(最值)和区间批量修改的操作。

常见的区间最值算法(数据结构)有很多,但线段树在某些情况下一定是最优解。以下是不同 RMQ 算法的优势和劣势:

  1. 暴力枚举 Brute Force:实现难度非常简单,适合数据量较小的情况。但查询效率极其低下。
  2. 树状数组 Binary Indexed Tree:实现相对简单,查询和更新效率较高。只能处理前缀区间的问题,对于任意区间查询(例如,区间最值)需要进行一些变形和额外处理,不如线段树灵活。
  3. 稀疏表 Sparse Table:适合处理静态数据,即数据在预处理之后不再发生改变。如果要频繁实现在线区间修改/单点修改的操作,ST表就非常的耗时。
  4. 线段树 Segment Tree:查询和更新效率高,适合动态数据,支持快速更新。但缺点是实现较为复杂,代码量较大。

综上所述,每种算法(数据结构)都有自己的优势和劣势,我们应该根据实际情况选用最合适的方案。

线段树的底层是基于 二叉树 (Binary Tree) 来实现的,因此在线段树相关的操作中,大多数操作的时间复杂度可以被优化到 \(O(\log_2n)\) 级别。相比 \(O(n)\) 级别的暴力算法而言,线段树有显著的优势(据我所知,线段树唯一的劣势就是其码量对于初学者而言会比较多,因此在写线段树的过程中由于粗心导致失误的可能性会增加许多)。

线段树的基本结构

线段树是一棵二叉树,因此对于每一个节点而言至多只有两个子节点。与此同时,线段树的每一个节点都存储了一个区间的信息,通常是这个区间的某种 统计量(如最大值、最小值、总和等)。每个节点的区间是它的两个子节点区间的并集,根节点表示我们需要维护的整一个区间。

举一个形象的例子。例如我们想要构造一棵线段树来维护一个区间 \(1, 6\) 的某些状态,我们所构造出来的线段树的结构会呈现下图所示的样子。其中根节点负责维护的 \(1, 6\) 区间,其左儿子负责维护 \(1, 3\) 区间,其右儿子负责维护 \(4, 6\) 区间。可以看出,如果将一个根结点的左儿子和右儿子所维护的区间合并,那么这个新的区间就是该根节点所维护的区间。一般来说,一个节点的两个子节点维护的区间大小的差应该尽可能的小。以此类推,每一个节点都维护一个区间,直到这个区间不能再分为止,也就是说这棵树所有的叶子结点的区间长度都应该为 \(1\)。

知道了线段树的基本结构,那么维护每一个节点所记录的状态也会变得特别简单。以维护区间最大值为例子,如果区间 \(1, 3\) 所记录的最大值是 \(7\),区间 \(4, 6\) 所记录的最大值是 \(2\),那么我们就可以很容易的推导出区间 \(1, 6\) 的最大值应该是 \(\max(7, 2) = 7\)。

因此,线段树的一个局限性就是维护的数据必须具有可传递性,说白了,就是必须可以通过两个小区间所记录的值来推导出某一个大区间所记录的值。

以下代码将以维护区间的总和为例子来展开:

线段树的存储

arr 数组是我们需要维护区间每个位置的原始数值。

我们通过 tree 数组来存储整一棵线段树,由于线段树属于一种平衡二叉树,在最坏的情况下,线段树的大小将会是 \(n\) 的四倍,因此数组至少需要开 \(4n\) 的大小。对于这个数组,我们规定对于任何一个索引为 \(i\) 的节点,其左子节点的索引为 \(i \times 2\),右子节点的索引为 \(i \times 2 + 1\)。

为了加速计算,我们可以使用位运算的方式来实现(本文将不详细阐述位运算的过程,有需要的人可以自行上网查阅):

  1. 将一个数 x 乘上 \(2^n\),可以写为 x << n。因此 \(n \times 4\) 可以写成 n << 2
  2. 将一个数偶数加上 \(1\),可以通过 x | 1 或运算来实现。
cpp 复制代码
struct node{
   int sum;
} tree[(n << 2) + 5];
int arr[n + 5]

线段树的构建

线段树的构建过程跟普通的二叉树构建过程类似,都是通过递归的方式来实现的。如果我们要构建一个长度为 \(n\) 的区间,在每一层递归的时候我们将区间对半分成两个部分,并分别构建其左子树和右子树。直到区间长度为 \(1\) 时停止。当一个节点的左子树和右子树都被初始化完成后,我们应该通过合并其子节点所维护的值来更新当前节点所记录的值,这个操作也被称之为 \(\mathtt{Push \space up}\)。

\(\mathtt{Push \space up}\) 的代码也非常简单,就是单纯合并两个子节点的信息到它们的父节点当中,这里就是把父节点所维护区间的总和赋值为其两个子节点所维护区间总和的和:

cpp 复制代码
void push_up(int root){
    tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
    return ;
}

线段树的初始化(构建)代码如下。其中 root 变量表示当前节点在 tree 数组中的索引。变量 lr 分别表示所维护区间的左边界和右边界。对于每一层递归来说,我们要维护一个长度为 \(r - l + 1\) 的闭区间 \(l, r\)。当 l == r 时,则证明区间的长度正好为 \(1\),因此终止递归,将该叶子结点初始化为数组中对应的值:

cpp 复制代码
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 ;
}

通过代码可以看出,初始化一棵线段树的时间复杂度为 \(\Theta(n \log_2 n)\)。

线段树的区间查询

与线段树的构建相同,查询线段树也是通过 递归+二分 的方式来实现的。给定一个查询的区间 \(L, R\)。我们从根节点开始,如果当前节点表示的区间与查询区间完全匹配,则直接返回当前节点所存储的信息。否则,将查询区间分成左右两部分,递归查询左右子树,并将结果合并。相较于初始化操作,查询某一个区间的时间复杂度约为 \(O(\log_2 n)\)。

例如,如果我们要查询区间 \(3, 6\) 所维护的数据,递归到根节点的时候,根节点的左儿子的区间为 \(1, 3\),右儿子的区间为 \(4, 6\),我们发现我们所要查询的区间同时在左儿子和右儿子中,因此我们同时递归两个子区间,在 \(1, 3\) 区间内查找 \(3, 3\),在 \(4, 6\) 区间内查找 \(4, 6\)。这样子我们只需要合并 \(3, 3\) 和 \(4, 6\) 区间就可以计算出 \(3, 6\) 区间所需要维护的值。等递归到了 \(1, 3\) 区间,这个节点左儿子所维护的值为 \(1, 2\),其右儿子维护的值为 \(3, 3\)。我们发现所需要的值只存在于右儿子中,因此只递归搜索右儿子的值,即递归 \(3, 3\) 区间。当递归到 \(3, 3\) 区间时,我们发现这个区间正是答案所在的区间,因此直接将 \(3, 3\) 区间内所存放的值加入累加器。同理,当递归到 \(4, 6\) 区间时,查找区间也正好覆盖掉该区间,因此把 \(4, 6\) 所维护的值也加入累加器。至此,线段树的区间搜索就完成了。

实现线段树上区间查询的代码如下,其中变量 lr 表示当前所查询的区间边界,root 为当前的根节点索引。

cpp 复制代码
int interval_query(int l, int r, int L, int R, int root){
    int sum = 0;
    if (L <= l && r <= R) 
        // 与区间完全匹配,因此可以直接返回结果。
        return tree[root].sum;
    int mid = (l + r) >> 1;
    int llen = mid - l + 1;
    int rlen = r - mid;
    // 如果所查询的部分/所有结果存在于左半边,那么就递归计算左半边的结果。
    if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
    // 如果所查询的部分/所有结果存在于右半边,那么就再递归计算右半边的结果。
    if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
    return sum;
}

当然,如果要实现单点查询的话,只需要令所查询的左边界等于右边界,即令 \(L = R\) 即可。

线段树的区间更新

更新线段树同样是一个递归的过程。给定一个需要更新的位置和新的值,从根节点开始,如果当前节点表示的区间包含需要更新的位置,则递归更新左右子树,并将结果合并。区间更新的操作与区间查询的操作几乎类似。区间更新的时间复杂度也约为 \(O(\log_2 n)\)。

同时,在更新区间后应该再次使用 push_up() 函数来保证父节点的数据被正确更新了。

线段树区间更新的代码如下,变量 v 表示该区间所有元素要新增的值,其余变量的意义与上述保持不变:

cpp 复制代码
void interval_update(int l, int r, int L, int R, int v, int root) {
    if (l == r) {
        tree[root].sum += v;
        return;
    }
    int mid = (l + r) >> 1;
    if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
    if (R > mid) interval_update(mid + 1, r, L, R, v, root << 1 | 1);
    // 更新当前节点的值
    push_up();
    return ; 
}

当然,如果要实现单点更新的话,只需要令所更新的左边界等于右边界,即令 \(L = R\) 即可。

线段树的进一步优化 - 懒标记 (Lazy Tag)

在实际应用中,线段树常常使用 懒标记 (Lazy Tag) 来优化某些操作。懒标记技术可以延迟对某些节点的更新,直到必须访问这些节点时才进行更新,从而提高效率。

懒标记的概念:懒标记是一种延迟更新的技巧,用于处理区间更新问题。基本思想是对于一个更新操作,不立即更新所有受影响的节点,而是将更新信息记录下来,等到需要查询这些节点的值时再执行更新。

例如,假设我们要更新区间 \(1, n\) 所维护的信息,其实不需要更新区间内每一个叶子结点所记录的值。我们可以先只更新 \(1, n\) 区间的值,待到后续要查询 \(1, n\) 区间内的子区间时,我们再更新受影响的节点。我们将此操作命名为 懒标记下放

在下放懒标记的时候,我们每次只向下下放一次即可,也并不必需要更新到叶子结点。因此,在进行区间查询和区间更新的时候,需要调用 push_down() 函数。

懒标记下放 \(\mathtt{Push\space down}\) 的代码如下:

cpp 复制代码
void push_down(int root, int inten, int rlen){
    // 如果存在懒标记就更新,否则就不更新。
    if (tree[root].lazy_tag){
        // 将父节点的懒标记遗传给子节点。
        tree[root << 1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1].sum += inten * tree[root].lazy_tag;
        tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
        // 清除父节点的懒标记。
        tree[root].lazy_tag = 0;
    }
    return ;
}

线段树完整代码

以下是洛谷题目 P3372 【模板】线段树 1 的完整代码,改代码包含本文所阐述的所有代码且应用了懒标记的思想:

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;
#define int long long

const int MAXN = 2e5 + 5;

int n, m, t;
int x, y, k;
struct node{
    int sum;
    int lazy_tag;
} tree[MAXN << 2];
int arr[MAXN];

// 更新父节点
void push_up(int root){
    tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
    return ;
}

// 懒标记下放
void push_down(int root, int inten, int rlen){
    if (tree[root].lazy_tag){
        tree[root << 1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
        tree[root << 1].sum += inten * tree[root].lazy_tag;
        tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
        tree[root].lazy_tag = 0;
    }
    return ;
}

// 构造线段树
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 ;
}

// 单点修改
void single_update(int l, int r, int k, int v, int root){
    if (l == r){
        tree[l].sum += v;
        return ;
    }
    int mid = (l + r) >> 1;
    if (k <= mid) single_update(l, mid, k, v, root << 1);
    else single_update(mid + 1, r, k, v, root << 1|1);
    push_up(root);
    return ;
}

// 区间修改
void interval_update(int l, int r, int L, int R, int v, int root){
    if (L <= l && r <= R){
        tree[root].lazy_tag += v;
        tree[root].sum += (r - l + 1) * v;
        return ;
    }
    int mid = (l + r) >> 1;
    int inten = mid - l + 1;
    int rlen = r - mid;
    // 下放懒标记
    push_down(root, inten, rlen);
    if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
    if (R > mid) interval_update(mid+1, r, L, R, v, root << 1|1);
    push_up(root);
    return ;
}

// 区间查询
int interval_query(int l, int r, int L, int R, int root){
    int sum = 0;
    if (L <= l && r <= R) return tree[root].sum;
    int mid = (l + r) >> 1;
    int inten = mid - l + 1;
    int rlen = r - mid;
    // 下放懒标记
    push_down(root, inten, rlen);
    if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
    if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
    return sum;
}

signed main(){
    scanf("%lld %lld", &n, &m);
    for (int i=1; i<=n; i++) scanf("%lld", &arr[i]);
    build_tree(1, n, 1);
    while(m--){
        scanf("%lld", &t);
        if (t == 1){
            scanf("%lld %lld %lld", &x, &y, &k);
            interval_update(1, n, x, y, k, 1);
        } else{
            scanf("%lld %lld", &x, &y);
            int ans = interval_query(1, n, x, y, 1);
            printf("%lld\n", ans);
        }
    }
    return 0;
}

小结

线段树是一个相对复杂的数据结构,因此必然存在许多线段树的变形题目,需要我们在做题时随机应变。我们应该通过多刷题来提升对线段树的熟练程度。