首页 > 其他分享 >【知识点】浅入线段树与区间最值问题

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

时间:2024-05-25 11:30:53浏览次数:28  
标签:知识点 浅入 线段 更新 int 区间 root 节点 最值

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

今天来讲一下线段树和线段树的基本应用。线段树 (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\)。

image

知道了线段树的基本结构,那么维护每一个节点所记录的状态也会变得特别简单。以维护区间最大值为例子,如果区间 \([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 或运算来实现。
struct node{
   int sum;
} tree[(n << 2) + 5];
int arr[n + 5]

线段树的构建

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

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

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\),因此终止递归,将该叶子结点初始化为数组中对应的值:

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 为当前的根节点索引。

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 表示该区间所有元素要新增的值,其余变量的意义与上述保持不变:

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}\) 的代码如下:

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 的完整代码,改代码包含本文所阐述的所有代码且应用了懒标记的思想:

#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;
}

小结

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

标签:知识点,浅入,线段,更新,int,区间,root,节点,最值
From: https://www.cnblogs.com/Macw07/p/18212206

相关文章

  • 大数据技术原理与应用——第1章(知识点+课后题)
    参考:大数据技术原理与应用(第3版)林子雨编著基本概念大数据:指无法在可承受的时间范围内用常规软件工具进行捕捉、管理和处理的数据集合,是需要新处理模式才能具有更强的决策力、洞察发现力和流程优化能力的海量、高增长率和多样化的信息资产。流数据/数据流:指在时间分布和数......
  • uniapp快速分享知识点,请求简单封装 登陆 ,支付 , 分享 , 短信,
    第一部份requrety请求封装 备注:关于环境配置ui选择插件安装在我的另一个帐号中前几天也经写了,这个博客就不用在写一遍了另一博客地址:https://www.cnblogs.com/ZzwWan/p/18202502module.exports=(vm)=>{//初始化请求配置uni.$u.http.setConfig((config)=>{......
  • Java面试进阶指南:高级知识点问答精粹(二)
    Java面试问题及答案1.什么是Java内存模型(JMM)?它在并发编程中扮演什么角色?答案:Java内存模型(JMM)是一个抽象的模型,它定义了Java程序中各种变量(线程共享变量)的访问规则,以及在并发环境下这些变量如何被不同线程所看到。JMM规定了主内存和工作内存的概念,以及它们之间的交互规......
  • Java面试进阶指南:高级知识点问答精粹(一)
    Java面试问题及答案1.什么是Java中的集合框架?它包含哪些主要接口?答案:Java集合框架是一个设计用来存储和操作大量数据的统一的架构。它提供了一套标准的接口和类,使得我们可以以一种统一的方式来处理数据集合。集合框架主要包含以下接口:Collection:最基本的集合接口,它是......
  • vue知识点: v-if和v-for为何不能同时使用?
    在vue2和vue3的官方文档里都写到不推荐 v-if和v-for同时使用,如下代码所示:<liv-for="todointodos"v-if="!todo.isComplete">{{todo.text}}</li>一、vue3文档:列表渲染|Vue.js在vue3中,是因为当它们同时存在于一个节点上时,v-if比v-for的优先级更高。这意味着......
  • 【学习整理】编程知识点总结
    编程知识点总结查询接口实现查询接口示例查询排序@SortDefault查询参数配置@ApiIgnore@Param消息发送常量定义......
  • 【知识点】集合与并查集
    在前几篇文章当中,我们已经讨论了许多有关数论的知识点了,因此Macw决定写几篇数据结构的文章缓一缓。(整天写数论相关的内容容易自闭(bushi))。今天我们将会围绕一个新的数据结构,并查集(DisjointSetUnion)来展开。集合与集合的常见操作在谈论到并查集的时候,首先讨论一个前置知识......
  • 【知识点】拓展欧几里得与中国剩余定理
    在上一篇文章中,我们已经熟知了有关公约数和欧几里得算法的相关事宜。详情参见:欧几里得算法求最大公约数。本文将作为上篇文章内容的一个延续,简要阐述拓展欧几里得算法和中国剩余定理。拓展欧几里得算法拓展欧几里得算法(ExtendedEuclidianAlgorithm),是欧几里得算法的扩展版本,用......
  • 软考知识点整理-程序设计语言
    语义分析阶段:程序编译过程中,执行类型分析和检查语用分析阶段:表示构成语言的各个记号和使用者的关系程序设计语言的基本成分包括数据、运算、控制和传输枚举属于用户定义类型符号表是存储程序源代码中每个标识符和声明的信息动态查找表是查找key的值,若存在则返回位置,不存在就......
  • salesforce零基础学习(一百三十八)零碎知识点小总结(十)
    本篇参考: https://help.salesforce.com/s/articleView?id=release-notes.rn_apex_5level_SOQLqueries.htm&release=250&type=5https://developer.salesforce.com/tools/vscode/en/einstein/einstein-overviewhttps://developer.salesforce.com/tools/vscode/en/user-g......