C++ 线段树原理与实现示例详解

 更新时间:2022年09月15日 15:45:08   作者:白龙码  
这篇文章主要为大家介绍了C++ 线段树原理与实现示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

一、问题引入

对于一般的区间问题,比如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。

因此,树的节点数量为2^h-1^≤2^logN+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],它有三种分布情况:

  • right<=mid,即给定区间全部在左节点中,因此只需要递归左子树计算区间和即可。
  • left>mid,即给定区间全部在右节点中,因此只需要递归右子树计算区间和即可。
  • 给定区间有一部分在左子树,一部分在右子树,因此需要分成两部分,一部分是[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清零
    }
}

以上就是C++ 线段树原理与实现示例详解的详细内容,更多关于C++ 线段树原理的资料请关注脚本之家其它相关文章!

相关文章

  • IOS 开发UITextView回收或关闭键盘

    IOS 开发UITextView回收或关闭键盘

    这篇文章主要介绍了IOS 开发UITextView回收或关闭键盘的相关资料,需要的朋友可以参考下
    2017-06-06
  • C语言实现扫雷小游戏详细代码

    C语言实现扫雷小游戏详细代码

    这篇文章主要为大家详细介绍了C语言实现扫雷小游戏的代码,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-06-06
  • 解析Linux下的时间函数:设置以及获取时间的方法

    解析Linux下的时间函数:设置以及获取时间的方法

    本篇文章是对Linux下的时间函数:设置以及获取时间的方法进行了详细的分析介绍,需要的朋友参考下
    2013-05-05
  • C语言编写简单拼图游戏

    C语言编写简单拼图游戏

    这篇文章主要为大家详细介绍了C语言编写简单拼图游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-03-03
  • C++中map容器的具体使用

    C++中map容器的具体使用

    本文主要介绍了C++中map容器的具体使用,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • Protocol Buffer技术深入理解(C++实例)

    Protocol Buffer技术深入理解(C++实例)

    C++实例Protocol Buffer技术详解,感兴趣的朋友可以了解下
    2013-01-01
  • C++中的函数你真的理解了吗

    C++中的函数你真的理解了吗

    这篇文章主要为大家详细介绍了C++中的函数,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下,希望能够给你带来帮助
    2022-02-02
  • C++哈希应用之位图,哈希切分与布隆过滤器详解

    C++哈希应用之位图,哈希切分与布隆过滤器详解

    这篇文章主要为大家详细介绍了C++哈希应用中的位图、哈希切分与布隆过滤器,文中的示例代码讲解详细,具有一定的学习价值,需要的可以参考一下
    2023-04-04
  • C++精要分析lambda表达式的使用

    C++精要分析lambda表达式的使用

    Lambda表达式是现代C++在C ++ 11和更高版本中的一个新的语法糖 ,在C++11、C++14、C++17和C++20中Lambda表达的内容还在不断更新。 lambda表达式(也称为lambda函数)是在调用或作为函数参数传递的位置处定义匿名函数对象的便捷方法
    2022-05-05
  • C++实现带头双向循环链表的示例详解

    C++实现带头双向循环链表的示例详解

    这篇文章主要介绍了如何利用C++实现带头双向循环链表,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-12-12

最新评论