715. Range 模块 : 线段树(动态开点)的两种方式

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第28天,点击查看活动详情

题目描述

这是 LeetCode 上的 715. Range 模块 ,难度为 困难

Tag : 「线段树」、「线段树(动态开点)」

Range 模块是跟踪数字范围的模块。设计一个数据结构来跟踪表示为 半开区间 的范围并查询它们。

半开区间  [ l e f t , r i g h t ) [left, right)  表示所有  l e f t < = x < r i g h t left <= x < right  的实数 x

实现 RangeModule 类:

  • RangeModule() 初始化数据结构的对象。
  • void addRange(int left, int right) 添加 半开区间 [left, right),跟踪该区间中的每个实数。添加与当前跟踪的数字部分重叠的区间时,应当添加在区间 [left, right) 中尚未跟踪的任何数字到该区间中。
  • boolean queryRange(int left, int right) 只有在当前正在跟踪区间 [left, right) 中的每一个实数时,才返回 true ,否则返回 false
  • void removeRange(int left, int right) 停止跟踪 半开区间 [left, right) 中当前正在跟踪的每个实数。

示例 1:

输入
["RangeModule", "addRange", "removeRange", "queryRange", "queryRange", "queryRange"]
[[], [10, 20], [14, 16], [10, 14], [13, 15], [16, 17]]

输出
[null, null, null, true, false, true]

解释
RangeModule rangeModule = new RangeModule();
rangeModule.addRange(10, 20);
rangeModule.removeRange(14, 16);
rangeModule.queryRange(10, 14); 返回 true (区间 [10, 14) 中的每个数都正在被跟踪)
rangeModule.queryRange(13, 15); 返回 false(未跟踪区间 [13, 15) 中像 14, 14.03, 14.17 这样的数字)
rangeModule.queryRange(16, 17); 返回 true (尽管执行了删除操作,区间 [16, 17) 中的数字 16 仍然会被跟踪)
复制代码

提示:

  • 1 < = l e f t < r i g h t < = 1 0 9 1 <= left < right <= 10^9
  • 在单个测试用例中,对 addRange、 queryRange 和 removeRange 的调用总数不超过  1 0 4 10^4  次

基本分析

m m addRangequeryRangeremoveRange 的调用总数, n = 1 e 9 n = 1e9 为值域大小。

由于值域过大,我们无法直接使用空间大小固定为 4 × n 4 \times n 的常规线段树,而要采用「动态开点」的方式,其中动态开点的方式有两种 :「需要进行估点的数组实现」和「无须估点的动态指针」。

设计 Node 节点维护什么信息:

  • lsrs 分别指向左右区间子节点(当采用「估点数组」方式时,记录的是左右区间子节点在线段树数组中的下标;在「动态指针」方式时,记录的是左右区间子节点对象);
  • sum 为记录当前区间有多少个整数被追踪;
  • add 为懒标记,当 add = -1 代表 removeRange 懒标记,当 add = 1 则代表 addRange 懒标记。

线段树(动态开点)- 数组估点

对于常规的线段树实现来说,都是一开始就调用 build 操作创建空树,而线段树一般以「满二叉树」的形式用数组存储,因此需要 4 × n 4 \times n 的空间,并且这些空间在起始 build 空树的时候已经锁死。

如果一道题仅仅是「值域很大」的离线题(提前知晓所有的询问),我们还能通过「离散化」来进行处理,将值域映射到一个小空间去,从而解决 MLE 问题。

但对于本题而言,由于「强制在线」的原因,我们无法进行「离散化」,同时值域大小达到 1 e 9 1e9 级别,因此如果我们想要使用「线段树」进行求解,只能采取「动态开点」的方式进行。

动态开点的优势在于,不需要事前构造空树,而是在插入操作 add 和查询操作 query 时根据访问需要进行「开点」操作。由于我们不保证查询和插入都是连续的,因此对于父节点 u u 而言,我们不能通过 u << 1u << 1 | 1 的固定方式进行访问,而要将节点 t r [ u ] tr[u] 的左右节点所在 tr 数组的下标进行存储,分别记为 lsrs 属性。对于 t r [ u ] . l s = 0 tr[u].ls = 0 t r [ u ] . r s = 0 tr[u].rs = 0 则是代表子节点尚未被创建,当需要访问到它们,而又尚未创建的时候,则将其进行创建。

由于存在「懒标记」,线段树的插入和查询都是 log n \log{n} 的,因此我们在单次操作的时候,最多会创建数量级为 log n \log{n} 的点,因此空间复杂度为 O ( m log n ) O(m\log{n}) ,而不是 O ( 4 × n ) O(4 \times n) ,而开点数的预估需不能仅仅根据 log n \log{n} 来进行,还要对常熟进行分析,才能得到准确的点数上界。

动态开点相比于原始的线段树实现,本质仍是使用「满二叉树」的形式进行存储,只不过是按需创建区间,如果我们是按照连续段进行查询或插入,最坏情况下仍然会占到 4 × n 4 \times n 的空间,因此盲猜 log n \log{n} 的常数在 4 4 左右,保守一点可以直接估算到 6 6 ,因此我们可以估算点数为 6 × m × log n 6 \times m \times \log{n} ,其中 n = 1 e 9 n = 1e9 m = 1 e 4 m = 1e4 分别代表值域大小和查询次数。

当然一个比较实用的估点方式可以「尽可能的多开点数」,利用题目给定的空间上界和我们创建的自定义类(结构体)的大小,尽可能的多开( Java128M 可以开到 5 × 1 0 6 5 \times 10^6 以上)。

代码:

class RangeModule {
    class Node {
        int ls, rs, sum, add;
    }
    int N = (int)1e9 + 10, M = 500010, cnt = 1;
    Node[] tr = new Node[M];
    void update(int u, int lc, int rc, int l, int r, int v) {
        int len = rc - lc + 1;
        if (l <= lc && rc <= r) {
            tr[u].sum = v == 1 ? len : 0;
            tr[u].add = v;
            return ;
        }
        pushdown(u, len);
        int mid = lc + rc >> 1;
        if (l <= mid) update(tr[u].ls, lc, mid, l, r, v);
        if (r > mid) update(tr[u].rs, mid + 1, rc, l, r, v);
        pushup(u);
    }
    int query(int u, int lc, int rc, int l, int r) {
        if (l <= lc && rc <= r) return tr[u].sum;
        pushdown(u, rc - lc + 1);
        int mid = lc + rc >> 1, ans = 0;
        if (l <= mid) ans = query(tr[u].ls, lc, mid, l, r);
        if (r > mid) ans += query(tr[u].rs, mid + 1, rc, l, r);
        return ans;
    }
    void pushdown(int u, int len) {
        if (tr[u] == null) tr[u] = new Node();
        if (tr[u].ls == 0) {
            tr[u].ls = ++cnt;
            tr[tr[u].ls] = new Node();
        }
        if (tr[u].rs == 0) {
            tr[u].rs = ++cnt;
            tr[tr[u].rs] = new Node();
        }
        if (tr[u].add == 0) return;
        if (tr[u].add == -1) {
            tr[tr[u].ls].sum = tr[tr[u].rs].sum = 0;
        } else {
            tr[tr[u].ls].sum = len - len / 2;
            tr[tr[u].rs].sum = len / 2;
        }
        tr[tr[u].ls].add = tr[tr[u].rs].add = tr[u].add;
        tr[u].add = 0;
    }
    void pushup(int u) {
        tr[u].sum = tr[tr[u].ls].sum + tr[tr[u].rs].sum;
    }
    public void addRange(int left, int right) {
        update(1, 1, N - 1, left, right - 1, 1);
    }
    public boolean queryRange(int left, int right) {
        return query(1, 1, N - 1, left, right - 1) == right - left;
    }
    public void removeRange(int left, int right) {
        update(1, 1, N - 1, left, right - 1, -1);
    }
}
复制代码
  • 时间复杂度:addRangequeryRangeremoveRange 操作复杂度均为 O ( log n ) O(\log{n})
  • 空间复杂度: O ( m log n ) O(m\log{n})

线段树(动态开点)- 动态指针

利用「动态指针」实现的「动态开点」可以有效避免数组估点问题,更重要的是可以有效避免 new 大数组的初始化开销,对于 LC 这种还跟你算所有样例总时长的 OJ 来说,在不考虑 static 优化/全局数组优化 的情况下,动态指针的方式要比估点的方式来得好。

代码:

class RangeModule {
    class Node {
        Node ls, rs;
        int sum, add;
    }
    int N = (int)1e9 + 10;
    Node root = new Node();
    void update(Node node, int lc, int rc, int l, int r, int v) {
        int len = rc - lc + 1;
        if (l <= lc && rc <= r) {
            node.sum = v == 1 ? len : 0;
            node.add = v;
            return ;
        }
        pushdown(node, len);
        int mid = lc + rc >> 1;
        if (l <= mid) update(node.ls, lc, mid, l, r, v);
        if (r > mid) update(node.rs, mid + 1, rc, l, r, v);
        pushup(node);
    }
    int query(Node node, int lc, int rc, int l, int r) {
        if (l <= lc && rc <= r) return node.sum;
        pushdown(node, rc - lc + 1);
        int mid = lc + rc >> 1, ans = 0;
        if (l <= mid) ans = query(node.ls, lc, mid, l, r);
        if (r > mid) ans += query(node.rs, mid + 1, rc, l, r);
        return ans;
    }
    void pushdown(Node node, int len) {
        if (node.ls == null) node.ls = new Node();
        if (node.rs == null) node.rs = new Node();
        if (node.add == 0) return ;
        int add = node.add;
        if (add == -1) {
            node.ls.sum = node.rs.sum = 0;
        } else {
            node.ls.sum = len - len / 2;
            node.rs.sum = len / 2;
        }
        node.ls.add = node.rs.add = add;
        node.add = 0;
    }
    void pushup(Node node) {
        node.sum = node.ls.sum + node.rs.sum;
    }
    public void addRange(int left, int right) {
        update(root, 1, N - 1, left, right - 1, 1);
    }
    public boolean queryRange(int left, int right) {
        return query(root, 1, N - 1, left, right - 1) == right - left;
    }
    public void removeRange(int left, int right) {
        update(root, 1, N - 1, left, right - 1, -1);
    }
}
复制代码
  • 时间复杂度:addRangequeryRangeremoveRange 操作复杂度均为 O ( log n ) O(\log{n})
  • 空间复杂度: O ( m log n ) O(m\log{n})

最后

这是我们「刷穿 LeetCode」系列文章的第 No.715 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。

在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。

为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…

在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

猜你喜欢

转载自juejin.im/post/7111154936200560677
今日推荐