线段树算法(Segment Tree)

线段树(Segment Tree)是一种高级数据结构,主要用于高效解决数组的区间查询和区间修改问题。它支持快速的区间查询(如区间最小值、区间最大值、区间和等)和区间更新。线段树的构建和查询操作的时间复杂度均为 O ( l o g n ) O(logn) O(logn)

一、线段树基础知识

构建线段树: 从数组构建线段树。
区间查询: 查询某个区间的值。
更新操作: 更新数组中的某个元素或区间。
线段树的设计
线段树通常用数组表示,其中根节点在索引 1 处。对于一个节点 i,其左子节点在 2i 处,右子节点在 2i+1 处。

1. 图文解释

二叉树的区间表示
在这里插入图片描述
以区间 [ 0 , 5 ] [0, 5] [0,5]为例
在这里插入图片描述
数据存储: 一般选择指针结构进行存储,由于具有完全二叉树性质,是一种平衡二叉树,所以采用数组存储。左右孩子节点都是遍历的过程中计算。
在这里插入图片描述
在这里插入图片描述

数组下标的计算
在这里插入图片描述

2. 线段树的代码实现

以下是一个用 C++ 实现的线段树,支持区间和查询和单点更新:

#include <iostream>
#include <vector>

using namespace std;

class SegmentTree {
private:
    vector<int> tree;
    vector<int> arr;
    int n;

    // 构建线段树
    void build(int node, int start, int end) {
        if (start == end) {
            tree[node] = arr[start];
        } else {
            int mid = (start + end) / 2;
            int leftChild = 2 * node + 1;
            int rightChild = 2 * node + 2;
            build(leftChild, start, mid);
            build(rightChild, mid + 1, end);
            tree[node] = tree[leftChild] + tree[rightChild];
        }
    }

    // 区间查询
    int query(int node, int start, int end, int L, int R) {
        if (R < start || end < L) {
            return 0; // 超出范围
        }
        if (L <= start && end <= R) {
            return tree[node]; // 完全在范围内
        }
        int mid = (start + end) / 2;
        int leftChild = 2 * node + 1;
        int rightChild = 2 * node + 2;
        int sumLeft = query(leftChild, start, mid, L, R);
        int sumRight = query(rightChild, mid + 1, end, L, R);
        return sumLeft + sumRight;
    }

    // 单点更新
    void update(int node, int start, int end, int idx, int value) {
        if (start == end) {
            arr[idx] = value;
            tree[node] = value;
        } else {
            int mid = (start + end) / 2;
            int leftChild = 2 * node + 1;
            int rightChild = 2 * node + 2;
            if (start <= idx && idx <= mid) {
                update(leftChild, start, mid, idx, value);
            } else {
                update(rightChild, mid + 1, end, idx, value);
            }
            tree[node] = tree[leftChild] + tree[rightChild];
        }
    }

public:
    SegmentTree(const vector<int>& input) {
        arr = input;
        n = input.size();
        tree.resize(4 * n);
        build(0, 0, n - 1);
    }

    int query(int L, int R) {
        return query(0, 0, n - 1, L, R);
    }

    void update(int idx, int value) {
        update(0, 0, n - 1, idx, value);
    }
};

int main() {
    vector<int> input = {1, 2, 3, 4, 5};
    SegmentTree segTree(input);

    // 区间查询
    cout << "Sum of range [1, 3]: " << segTree.query(1, 3) << endl;

    // 单点更新
    segTree.update(2, 10);

    // 更新后再次查询
    cout << "Sum of range [1, 3]: " << segTree.query(1, 3) << endl;

    return 0;
}

代码解释
1.构建线段树:

  • 通过递归构建线段树。在叶节点处,直接存储数组元素;在内部节点处,存储子节点之和。

2.区间查询:

  • 如果查询区间完全包含在节点区间内,直接返回节点值。
  • 如果查询区间与节点区间没有交集,返回 0。
  • 如果查询区间部分覆盖节点区间,递归查询子节点,并将结果合并。

3.单点更新:

  • 通过递归找到叶节点,并更新其值。
  • 更新过程中,更新所有受影响的父节点。

这个实现可以用于处理常见的区间和查询和单点更新问题。对于更复杂的需求(如区间更新),线段树可以通过懒惰标记(Lazy Propagation)进行扩展。

3. 线段树一般开辟多大数组空间?

线段树通常用数组表示,其空间复杂度为 O ( 2 n ) O(2n) O(2n) O ( 4 n ) O(4n) O(4n)。这是因为为了便于操作,线段树通常使用一个比原数组大一些的数组来存储树节点。

空间复杂度分析
对于一个大小为 n n n 的数组,线段树的高度为 ⌈ l o g 2 n ⌉ \lceil log_2n \rceil log2n。为了构建一棵完全二叉树并存储其节点,我们需要考虑以下几点:

1.完全二叉树在每一层的节点数是指数增长的,总节点数与层数成比例。
2.完全二叉树节点总数近似为 2 n − 1 2n−1 2n1,实际使用中为了避免复杂计算,通常分配一个接近 4 n 4n 4n 大小的数组。

为什么选择 4 n 4n 4n?

1.结构特点: 线段树在递归构建和查询过程中需要分配和处理多个子节点,为了简化边界条件和递归操作,通常预留 4 n 4n 4n 大小的空间。
2.实现方便: 使用 4 n 4n 4n 大小的数组可以避免复杂的边界检查,提供充足的空间保证所有操作的正常进行。

二、线段树延伸(动态开点)

1. 线段树引入

遇到过好多次线段树的题目,要么就是用其他的方法去解决,要么就是不会写!!今天痛定思痛,决定好好归纳整理一下线段树

线段树解决的是「区间和」的问题,且该「区间」会被修改

什么意思呢?举个简单的例子,对于 nums = [1, 2, 3, 4, 5]

如果我们需要多次求某些区间的和,是不是首先想到了利用「前缀和」。关于前缀和的详细介绍可见 [前缀和数组](https://lfool.github.io/LFool-Notes/algorithm/%E5%89%8D%E7%BC%80%E5%92%8C%E6%95%B0%E7%BB%84.html)

但是如果 nums 会被修改呢?比如:

  • 把第 i 个元素修改成 x
  • 把第 i 个元素增加 x
  • 把区间 [i, j] 内的元素都增加 x
    此时,如果我们再使用「前缀和」,就没那么高效了。因为每一次更新,前缀和数组必须也随之更新,时间复杂度为 O(n)

既然「前缀和」在这种场景下没那么高效了,所以就有了今天要介绍的「线段树」

线段树原理及实现
上面提到过:线段树解决的是「区间和」的问题,且该「区间」会被修改

所以线段树主要实现两个方法:「求区间和」&&「修改区间」,且时间复杂度均为 O(logn)

始终记住一句话:线段树的每个节点代表一个区间

nums = [1, 2, 3, 4, 5] 对应的线段树如下所示:
在这里插入图片描述
从图中可以看到,每个节点代表一个区间,而节点的值就是该区间的和 (其实还可以根据题目问题,改变表示的含义!!)

  • 数字之和「总数字之和 = 左区间数字之和 + 右区间数字之和」
  • 最大公因数 (GCD)「总 GCD = gcd(左区间 GCD, 右区间 GCD)」
  • 最大值「总最大值 = max(左区间最大值,右区间最大值)」

不符合区间加法的例子:

  • 众数「只知道左右区间的众数,没法求总区间的众数」
  • 01 序列的最长连续零「只知道左右区间的最长连续零,没法知道总的最长连续零」

根节点代表的区间是问题的总区间,如这个例子,问题的总区间就是数组的长度 [0, 4]

其实线段树是一棵近似的完全二叉树,该例子就是一棵完全二叉树,但是有些情况不是完全二叉树

所以对于给定的一个问题,如果该问题的范围是确定的,那么该问题的线段树也是确定的,因为建立线段树的过程就是不断把区间「平分」的过程,直到区间长度为 1

注意:下面的所有实现均基于求「区间和」以及对区间进行「加减」的更新操作
线段树的数据结构

我们可以使用数组来表示一棵线段树,假如根节点为 i,那么左孩子的节点就为 2 * i,右孩子的节点就为 2 * i + 1 (前提:i 从 1 开始,如果i从0开始,左节点是2*i+1,右孩子节点2*i+2)

我们可以使用链表来表示一棵线段树,其节点的数据结构如下:

class Node {
    // 左右孩子节点
    Node left, right;
    // 当前节点值
    int val;
}

个人比较倾向使用链表,因为比较节约内存,下面的实现均基于链表

2. 线段树的建立

如果题目中给了具体的区间范围,我们根据该范围建立线段树。见题目 区域和检索 - 数组可修改

public void buildTree(Node node, int start, int end) {
    // 到达叶子节点
    if (start == end) {
        node.val = arr[start];
        return ;
    }
    int mid = (start + end) >> 1;
    buildTree(node.left, start, mid);
    buildTree(node.right, mid + 1, end);
    // 向上更新
    pushUp(node);
}
// 向上更新
private void pushUp(Node node) {
    node.val = node.left.val + node.right.val;
}

但是很多时候,题目中都没有给出很具体的范围,只有数据的取值范围,一般都很大,所以我们更常用的是「动态开点」

下面我们手动模拟一下「动态开点」的过程。同样的,也是基于上面的例子 nums = [1, 2, 3, 4, 5]

假设一种情况,最开始只知道数组的长度 5,而不知道数组内每个元素的大小,元素都是后面添加进去的。所以线段树的初始状态如下图所示:(只有一个节点,很孤独!!)
在这里插入图片描述
假设此时,我们添加了一个元素 [2, 2]; val = 3。现在线段树的结构如下图所示:
在这里插入图片描述这里需要解释一下,如果一个节点没有左右孩子,会一下子把左右孩子节点都给创建出来,如上图橙色节点所示,具体代码可见方法 pushDown()

两个橙色的叶子节点仅仅只是被创建出来了,并无实际的值,均为 0;而另外一个橙色的非叶子节点,值为 3 的原因是下面的孩子节点的值向上更新得到的

下面给出依次添加剩余节点的过程:(注意观察值的变化!!)
在这里插入图片描述
「动态开点」一般是在「更新」或「查询」的时候动态的建立节点,具体可见下面的更新查询操作

3. 线段树的更新

我看大多数教程都是把更新分为两种:「点更新」和「区间更新」。其实这两种可以合并成一种,「点更新」不就是更新长度为 1 的区间嘛!!

更新区间的前提是找到需要更新的区间,所以和查询的思路很相似

如果我们要把区间 [2, 4]内的元素都「➕1」
在这里插入图片描述
我们会发现一个很有意思的现象,我们只把 [2,2][3,4] 这两个区间对应的节点更新了,而区间 [3, 3][4,4] 并没有更新

按道理来说,[3, 3][4,4] 也是需要更新的,不然当我们查询区间 [3, 3][4,4] 的值,就会出现错误!!

这是因为我们使用了「懒惰标记」的方法,我们只需要更新到满足条件的区间即可,然后再给该区间对应的节点加一个懒惰标记,表示该节点所有对应的孩子节点都应该有此更新

当我们向孩子节点遍历的时候会把「懒惰标记」下推给孩子节点

我们需要稍微修改一下 Node 的数据结构

class Node {
    // 左右孩子节点
    Node left, right;
    // 当前节点值
    int val;
    // 懒惰标记
    int add;
}

基于「动态开点」的前提,我们下推懒惰标记的时候,如果节点不存在左右孩子节点,那么我们就创建左右孩子节点

先来实现下推懒惰标记的函数:

// leftNum 和 rightNum 表示左右孩子区间的叶子节点数量
// 因为如果是「加减」更新操作的话,需要用懒惰标记的值✖️叶子节点的数量
private void pushDown(Node node, int leftNum, int rightNum) {
    // 动态开点
    if (node.left == null) node.left = new Node();
    if (node.right == null) node.right = new Node();
    // 如果 add 为 0,表示没有标记
    if (node.add == 0) return ;
    // 注意:当前节点加上标记值✖️该子树所有叶子节点的数量
    node.left.val += node.add * leftNum;
    node.right.val += node.add * rightNum;
    // 把标记下推给孩子节点
    // 对区间进行「加减」的更新操作,下推懒惰标记时需要累加起来,不能直接覆盖
    node.left.add += node.add;
    node.right.add += node.add;
    // 取消当前节点标记
    node.add = 0;
}

下面来实现更新的函数:

// 在区间 [start, end] 中更新区间 [l, r] 的值,将区间 [l, r] ➕ val
// 对于上面的例子,应该这样调用该函数:update(root, 0, 4, 2, 4, 1)
public void update(Node node, int start, int end, int l, int r, int val) {
    // 找到满足要求的区间
    if (l <= start && end <= r) {
        // 区间节点加上更新值
        // 注意:需要✖️该子树所有叶子节点
        node.val += (end - start + 1) * val;
        // 添加懒惰标记
        // 对区间进行「加减」的更新操作,懒惰标记需要累加,不能直接覆盖
        node.add += val;
        return ;
    }
    int mid = (start + end) >> 1;
    // 下推标记
    // mid - start + 1:表示左孩子区间叶子节点数量
    // end - mid:表示右孩子区间叶子节点数量
    pushDown(node, mid - start + 1, end - mid);
    // [start, mid] 和 [l, r] 可能有交集,遍历左孩子区间
    if (l <= mid) update(node.left, start, mid, l, r, val);
    // [mid + 1, end] 和 [l, r] 可能有交集,遍历右孩子区间
    if (r > mid) update(node.right, mid + 1, end, l, r, val);
    // 向上更新
    pushUp(node);
}

4. 线段树的查询

如果我们要查询区间 [2, 4] 的结果,如下图红色标记所示:
在这里插入图片描述
下面给出代码实现:

// 在区间 [start, end] 中查询区间 [l, r] 的结果,即 [l ,r] 保持不变
// 对于上面的例子,应该这样调用该函数:query(root, 0, 4, 2, 4)
public int query(Node node, int start, int end, int l, int r) {
    // 区间 [l ,r] 完全包含区间 [start, end]
    // 例如:[2, 4] = [2, 2] + [3, 4],当 [start, end] = [2, 2] 或者 [start, end] = [3, 4],直接返回
    // ***[l, r]是请求区别,它不会做调整,会进行比较***
    if (l <= start && end <= r) return node.val;
    // 把当前区间 [start, end] 均分得到左右孩子的区间范围
    // node 左孩子区间 [start, mid]
    // node 左孩子区间 [mid + 1, end]
    int mid = (start + end) >> 1, ans = 0;
    // 下推标记
    pushDown(node, mid - start + 1, end - mid);
    // [start, mid] 和 [l, r] 可能有交集,遍历左孩子区间,
    // ***[l, r]是请求区别,它不会做调整,会进行比较***
    if (l <= mid) ans += query(node.left, start, mid, l, r);
    // [mid + 1, end] 和 [l, r] 可能有交集,遍历右孩子区间
    if (r > mid) ans += query(node.right, mid + 1, end, l, r);
    // ans 把左右子树的结果都累加起来了,与树的后续遍历同理
    return ans;
}

5. 线段树完整模版

注意:下面模版基于求「区间和」以及对区间进行「加减」的更新操作,且为「动态开点」

/**
 * @Description: 线段树(动态开点)
 * @Author: LFool
 * @Date 2022/6/7 09:15
 **/
public class SegmentTreeDynamic {
    class Node {
        Node left, right;
        int val, add;
    }
    private int N = (int) 1e9;
    private Node root = new Node();
    public void update(Node node, int start, int end, int l, int r, int val) {
        if (l <= start && end <= r) {
            node.val += (end - start + 1) * val;
            node.add += val;
            return ;
        }
        int mid = (start + end) >> 1;
        pushDown(node, mid - start + 1, end - mid);
        if (l <= mid) update(node.left, start, mid, l, r, val);
        if (r > mid) update(node.right, mid + 1, end, l, r, val);
        pushUp(node);
    }
    public int query(Node node, int start, int end, int l, int r) {
        if (l <= start && end <= r) return node.val;
        int mid = (start + end) >> 1, ans = 0;
        pushDown(node, mid - start + 1, end - mid);
        if (l <= mid) ans += query(node.left, start, mid, l, r);
        if (r > mid) ans += query(node.right, mid + 1, end, l, r);
        return ans;
    }
    private void pushUp(Node node) {
        node.val = node.left.val + node.right.val;
    }
    private void pushDown(Node node, int leftNum, int rightNum) {
        if (node.left == null) node.left = new Node();
        if (node.right == null) node.right = new Node();
        if (node.add == 0) return ;
        node.left.val += node.add * leftNum;
        node.right.val += node.add * rightNum;
        // 对区间进行「加减」的更新操作,下推懒惰标记时需要累加起来,不能直接覆盖
        node.left.add += node.add;
        node.right.add += node.add;
        node.add = 0;
    }
}

三、其他变形

1、我的日程安排表 I

class MyCalendar {
    unordered_set<int> tree, lazy;

public:
    bool query(int start, int end, int l, int r, int idx) {
        if (r < start || end < l) {
            return false;
        }
        /* 如果该区间已被预订,则直接返回 */
        if (lazy.count(idx)) {
            return true;
        }
        if (start <= l && r <= end) {
            return tree.count(idx);
        }
        int mid = (l + r) >> 1;
        return query(start, end, l, mid, 2 * idx) ||
               query(start, end, mid + 1, r, 2 * idx + 1);
    }

    void update(int start, int end, int l, int r, int idx) {
        if (r < start || end < l) {
            return;
        }
        if (start <= l && r <= end) {
            tree.emplace(idx);
            lazy.emplace(idx);
        } else {
            int mid = (l + r) >> 1;
            update(start, end, l, mid, 2 * idx);
            update(start, end, mid + 1, r, 2 * idx + 1);
            tree.emplace(idx);
            if (lazy.count(2 * idx) && lazy.count(2 * idx + 1)) {
                lazy.emplace(idx);
            }
        }
    }

    bool book(int start, int end) {
        if (query(start, end - 1, 0, 1e9, 1)) {
            return false;
        }
        update(start, end - 1, 0, 1e9, 1);
        return true;
    }
};

2、我的日程安排表 II

class MyCalendarTwo {
public:
    MyCalendarTwo() {

    }

    void update(int start, int end, int val, int l, int r, int idx) {
        if (r < start || end < l) {
            return;
        } 
        if (start <= l && r <= end) {
            tree[idx].first += val;
            tree[idx].second += val;
        } else {
            int mid = (l + r) >> 1;
            update(start, end, val, l, mid, 2 * idx);
            update(start, end, val, mid + 1, r, 2 * idx + 1);
            tree[idx].first = tree[idx].second + max(tree[2 * idx].first, tree[2 * idx + 1].first);
        }
    }

    bool book(int start, int end) {            
        update(start, end - 1, 1, 0, 1e9, 1);
        if (tree[1].first > 2) {
            update(start, end - 1, -1, 0, 1e9, 1);
            return false;
        }
        return true;
    }
private:
    unordered_map<int, pair<int, int>> tree;
};

注意点:

1. 注意点一

updatequery函数中,**线段树延伸(动态开点)**中采用以下方式是否存在空间交集

        if (l <= mid) update(node.left, start, mid, l, r, val);
        if (r > mid) update(node.right, mid + 1, end, l, r, val);

在其他变形中采用if else的形式

        if (start <= l && r <= end) {
            tree[idx].first += val;
            tree[idx].second += val;
        } else {
            int mid = (l + r) >> 1;
            update(start, end, val, l, mid, 2 * idx);
            update(start, end, val, mid + 1, r, 2 * idx + 1);
            tree[idx].first = tree[idx].second + max(tree[2 * idx].first, tree[2 * idx + 1].first);
        }

2.注意点二

两者存在进行共性

  • query只进行区间查询,不会存在更新数组或者节点的情况,update会更新节点。
  • queryupdate都会进行类似区间二分递归。

参考资料:
https://leetcode.cn/problems/range-module/solutions/1612955/by-lfool-eo50/
https://leetcode.cn/circle/discuss/H4aMOn/
https://leetcode.cn/problems/my-calendar-ii/solutions/1681245/by-lfool-nodi/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值