RMQ类问题利器:线段树

Posted 白龙码~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了RMQ类问题利器:线段树相关的知识,希望对你有一定的参考价值。

文章目录

线段树

一、问题引入

对于一般的区间问题,比如RMQ(区间的最值)、区间的和,如果使用朴素算法,即通过遍历的方式求取,则时间复杂度为O(N),在常数次查询的情况下可以接受,但是当区间长度为N,查询次数为M时,查询复杂度就变成O(M*N)。在M和N较大时,这样的复杂度无法满足要求。

对于这类问题,有一个神奇的数据结构,能够在O(M*logN)的时间内解决问题——线段树。


二、线段树的构建

线段树的每个节点可以根据需要存储一个区间的最大/最小值/和等内容。它的构建方式与堆的构建方式类似,即线段树是基于数组实现的树。

以构建区间和的线段树为例:对于给定数组nums,设大小为n,则区间范围为[0, n-1]。

  • 规定线段树的根节点,即SegmentTree[0]存储[0, n)的和。

  • 根据堆的构建方法,父节点的左孩子为2*parent+1,右孩子为2*parent+2。

  • 假设父节点存储[start, end]的和,mid=start+(end - start>>1),则左孩子存储[start, mid]的和,右孩子存储[mid+1, end]的和

    注:mid=start+(end - start>>1)是一种避免整形溢出的写法,等价于mid=(start+end)/2

  • 由于父节点的值依赖于两个子节点,因此线段树的构建是一种后序遍历

// nums是给定大小为n的数组,par表示当前正在构建的线段树节点下标,start和end是当前需要计算的区间。
void build(vector<int>& nums, int par, int start, int end)

    if (start == end) // 区间大小为1,即单个点,因此当前节点的区间和就是单点的值
    
        _segmentTree[par] = nums[start];
        return;
    
    // 如果区间大于1,则先求当前节点的左孩子和右孩子
    int mid = start + (end - start >> 1);
    int lchild = 2 * par + 1;
    int rchild = 2 * par + 2;
    build(nums, lchild, start, mid);   // 递归求左节点的区间和
    build(nums, rchild, mid + 1, end); // 递归求右孩子的区间和
    // 当前节点的值就是左孩子的值+右孩子的值
    _segmentTree[par] = _segmentTree[lchild] + _segmentTree[rchild];

注:在极端情况下,最后一层有n个结点,此时线段树是一棵完全二叉树,树的高度h=log2N向上取整+1≤log2N+2。

因此,树的节点数量为2h-1≤2logN+2-1=4N-1。

所以,线段树数组的大小一般为4*n

此外,如果想要避免因为n过大而导致MLE,则可以选择map/unordered_map来存储线段树,不过这会增加时间成本。一般来说直接开辟4*n的线段树数组是最方便书写的。


三、线段树的单点修改与查询

1、修改

单点修改要求:修改原数组下标index处的值。此时我们需要对线段树进行更新:

  • 依然是从根节点开始进行修改。
  • 根据修改的下标index,判断应当修改当前节点的左子树还是右子树。
  • 在递归修改左右孩子节点以后,再根据左右孩子的值重新对父节点进行赋值(pushUp())。
void update(int index, int val, int par, int start, int end)

    if (start == end) // 递归结束条件依然是当前区间为单点
    
        segtree[par] = val;
        return;
    
    int mid = start + (end - start >> 1);
    // 递归修改左孩子或右孩子
    if (index <= mid)
        update(index, val, 2 * par + 1, start, mid);
    else
        update(index, val, 2 * par + 2, mid + 1, end);
    // 修改完成后重新对父节点赋值
    pushUp(par);


// pushUp负责利用左右孩子的值更新父节点
void pushUp(int par)

    segtree[par] = segtree[2 * par + 1] + segtree[2 * par + 2];

2、查询

由于每个节点可以存储最值和区间和,因此求最值与求和的过程几乎相同,这里以求和为例:

  • 假设当前节点的区间为[start, end],中点为mid。
  • 对于给定区间[left, right],它有三种分布情况:
    1. right<=mid,即给定区间全部在左节点中,因此只需要递归左子树计算区间和即可。
    2. left>mid,即给定区间全部在右节点中,因此只需要递归右子树计算区间和即可。
    3. 给定区间有一部分在左子树,一部分在右子树,因此需要分成两部分,一部分是[left, mid],这部分到左子树中递归求取。另一部分是[mid+1,right],这部分到右子树中递归求取。
// [left, right]是目标求和区间,par是当前节点编号,当前节点存储区间[start, end]的和
int query(int left, int right, int par, int start, int end)

    // 目标求和区间与当前节点的区间吻合,直接返回当前节点的值即可
    if (left == start && right == end)
        return segtree[par];
    int mid = start + (end - start >> 1);
    if (right <= mid) // 目标求和区间全部在左子树
        return query(left, right, 2 * par + 1, start, mid);
    else if (left > mid) // 目标求和区间全部在右子树
        return query(left, right, 2 * par + 2, mid + 1, end);
    else  // 目标求和区间分布在左右子树上
        return query(left, mid, 2 * par + 1, start, mid) +
               query(mid + 1, right, 2 * par + 2, mid + 1, end);

四、线段树的区间修改与查询

1、修改

区间修改要求:修改原数组[left, right]处的值,将它们全部加/减value,或者全部改为value。此时我们需要对线段树进行更新。

我们可以选择将[left, right]看成一个个点,然后进行单点修改,但是一个点的修改消耗为log2N,修改整个区间就是C*log2N了,M次修改就是M*C*log2N,这比暴力法的M*C还要差。

我们使用懒标记法,引入一个lazy变量:

  • 依然从根节点开始修改。

  • 如果节点对应的区间[start, end]完全包含在[left, right]中时,即left≤start≤end≤right,此时将这个节点的值进行修改,并按要求修改lazy,比如:对给定区间整体加4,则lazy加4,整体减3,则lazy减3。

    修改完lazy数组后,我们不再需要修改它的子节点,因此lazy的意义在于减少向下更新的次数,从而降低时间复杂度**「懒的体现」**。

  • 如果节点对应的区间[start, end]不完全包含在[left, right]中时,则递归修改左右节点,直至对应节点的区间与待修改的区间没有交集**「递归的结束条件」**。子树修改完成后,再利用子节点的值更新父节点(pushUp())。

    注意:由于lazy变量的存在,使用子节点的值更新父节点时,需要加上父节点的lazy值,因为该值是由于"偷懒"而没有添加在子节点上的。

// 以「将给定区间内的数加x,查询每个节点存储对应区间的和」为例:
void update(int left, int right, int x, int node, int start, int end)

    // 区间没有交集,无需修改
    if (end < left || right < start)
        return;
    
    // 当前节点对应的区间被需要修改的区间完全包含
    if (left <= start && right >= end)
    
        segtree[node].val += x * (end - start + 1);
        segtree[node].lazy += x;
        return;
    
    
    // 不被[left, right]完全包含,则说明本轮只会更新[start, end]的一部分,因此不能再"偷懒"直接将x加在lazy上了
    // 而是先根据lazy的值修改左右子节点,然后再递归修改左右子树
    int mid = start + ((end - start) >> 1);
    
    // 先利用lazy修改孩子节点
    pushDown(node, mid - start + 1, end - mid);
    
    // 递归修改孩子节点
    update(left, right, 2 * node + 1, start, mid);
    update(left, right, 2 * node + 2, mid + 1, end);
    
    // 利用左右子树的区间最大值确定父节点的区间最大值
    pushUp(par);


void pushUp(int par)

	segtree[par].val = segtree[2 * par + 1] + segtree[2 * par + 2] + segtree[par].lazy;


// par表示父节点,ln表示左孩子的区间长度,rn表示右孩子的区间长度
void pushDown(int par, int ln, int rn)

    if (segtree[par].lazy != 0)
    
        segtree[2 * par + 1].val += segtree[par].lazy * ln; // 修改左孩子的值
        segtree[2 * par + 1].lazy += segtree[par].lazy; // 偷懒,不再往下继续修改,因此左孩子继承父节点的lazy值
        segtree[2 * par + 2].lazy += segtree[par].lazy * rn;
        segtree[2 * par + 2].lazy += segtree[par].lazy;
        segtree[par].lazy = 0; // 父节点的lazy已经分配到子节点了,因此父节点lazy清零
    

2、查询

查询的过程与修改几乎相同:

  • 依然从根节点开始查询。
  • 如果当前节点有懒标记,此时返回节点的值,无需向下遍历。
  • 当某个节点对应的区间[start, end]完全包含在[left, right]中时,即left≤start≤end≤right,则该节点的值是我们最终结果的子集,直接返回节点值即可。
  • 如果不完全包含,则递归查询左右子树,直至对应节点的区间与待修改的区间没有交集「递归的结束条件」。利用子树的查询结果作为最终的返回结果。
// 以「将给定区间内的数加x,查询每个节点存储对应区间的和」为例:
bool query(int left, int right, int node, int start, int end)

    // 区间没有交集,无需查询
    if (end < left || right < start)
        return false;
    
    // 有懒标记,则无需查询左右孩子,而是直接返回节点值,外加懒标记
    // 或者当前节点对应的区间被需要查询的区间完全包含,则直接返回节点值
    if (segtree[node].lazy || left <= start && right >= end)
        return segtree[node].val;
    
    int mid = start + ((end - start) >> 1);
    // 不完全包含,则先根据lazy修改子节点,再递归查询左右子树的和
    pushDown(node, mid - start + 1, end - mid);  
    
    return query(left, right, 2 * node + 1, start, mid) +
           query(left, right, 2 * node + 2, mid + 1, end);


// par表示父节点,ln表示左孩子的区间长度,rn表示右孩子的区间长度
void pushDown(int par, int ln, int rn)

    if (segtree[par].lazy != 0)
    
        segtree[2 * par + 1].val += segtree[par].lazy * ln; // 修改左孩子的值
        segtree[2 * par + 1].lazy += segtree[par].lazy; // 偷懒,不再往下继续修改,因此左孩子继承父节点的lazy值
        segtree[2 * par + 2].lazy += segtree[par].lazy * rn;
        segtree[2 * par + 2].lazy += segtree[par].lazy;
        segtree[par].lazy = 0; // 父节点的lazy已经分配到子节点了,因此父节点lazy清零
    


五、离散化线段树

对于在线查询,是指:事先不知道每次查询的区间。那么这种情况下线段树必须能够承载给定数据范围内的所有数据。

但是,在离线查询的情况下,我们预先知道每次查询的区间。因此,可以使用离散化使这些区间更加紧凑,从而降低线段树的空间成本。比如:

而离散化的算法非常简单:将区间的左右端点进行排序,用排序后的"排名",即数组下标代替原数字。比如,上图中端点4在排序后的排名是2,因此被离散化为2。

通过离散化,原本分散的区间变得非常紧凑。由于降低了n,因此4*n大小的线段树数组可以更加小巧!


六、算法练习

LeetCode 307 单点修改问题

LeetCode 732 区间修改问题

以上是关于RMQ类问题利器:线段树的主要内容,如果未能解决你的问题,请参考以下文章

wenbao与RMP(线段树)

线段树+RMQ问题第二弹

线段树

2022-07-05:给定一个数组,想随时查询任何范围上的最大值。 如果只是根据初始数组建立并且以后没有修改, 那么RMQ方法比线段树方法好实现,时间复杂度O(N*logN),额外空间复杂度O(N*

nyoj 119士兵杀敌(线段树区间最值查询,RMQ算法)

提高篇:RMQ问题与ST表