likes
comments
collection
share

729. 我的日程安排表 I :「模拟」&「线段树(动态开点)」&「分块 + 位运算(分桶)」

作者站长头像
站长
· 阅读数 15

题目描述

这是 LeetCode 上的 729. 我的日程安排表 I ,难度为 中等

Tag : 「模拟」、「红黑树」、「线段树(动态开点)」、「线段树」、「分块」、「位运算」、「哈希表」

实现一个 MyCalendar 类来存放你的日程安排。如果要添加的日程安排不会造成 重复预订 ,则可以存储这个新的日程安排。

当两个日程安排有一些时间上的交叉时(例如两个日程安排都在同一时间内),就会产生 重复预订 。

日程可以用一对整数 startend 表示,这里的时间是半开区间,即 [start,end)[start, end)[start,end), 实数 xxx 的范围为,  start<=x<endstart <= x < endstart<=x<end

实现 MyCalendar 类:

  • MyCalendar() 初始化日历对象。
  • boolean book(int start, int end) 如果可以将日程安排成功添加到日历中而不会导致重复预订,返回 true。否则,返回 false 并且不要将该日程安排添加到日历中。

示例:

输入:
["MyCalendar", "book", "book", "book"]
[[], [10, 20], [15, 25], [20, 30]]

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

解释:
MyCalendar myCalendar = new MyCalendar();
myCalendar.book(10, 20); // return True
myCalendar.book(15, 25); // return False ,这个日程安排不能添加到日历中,因为时间 15 已经被另一个日程安排预订了。
myCalendar.book(20, 30); // return True ,这个日程安排可以添加到日历中,因为第一个日程安排预订的每个时间都小于 20 ,且不包含时间 20 。

提示:

  • 0<=start<end<=1090 <= start < end <= 10^90<=start<end<=109
  • 每个测试用例,调用 book 方法的次数最多不超过 100010001000 次。

模拟

利用 book 操作最多调用 100010001000 次,我们可以使用一个数组存储所有已被预定的日期 [start,end−1][start, end - 1][start,end1],对于每次 book 操作,检查当前传入的 [start,end)[start, end)[start,end) 是否会与已有的日期冲突,冲突返回 False,否则将 [start,end−1][start, end- 1][start,end1] 插入数组并返回 True

代码:

class MyCalendar {
    List<int[]> list = new ArrayList<>();
    public boolean book(int start, int end) {
        end--;
        for (int[] info : list) {
            int l = info[0], r = info[1];
            if (start > r || end < l) continue;
            return false;
        }
        list.add(new int[]{start, end});
        return true;
    }
}
  • 时间复杂度:令 nnnbook 的最大调用次数,复杂度为 O(n2)O(n^2)O(n2)
  • 空间复杂度:O(n)O(n)O(n)

线段树(动态开点)

线段树维护的节点信息包括:

  • ls/rs: 分别代表当前节点的左右子节点在线段树数组 tr 中的下标;
  • add: 懒标记;
  • val: 为当前区间的所包含的点的数量。

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

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

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

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

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

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

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

代码:

class MyCalendar {
    class Node {
        // ls 和 rs 分别代表当前节点的左右子节点在 tr 的下标
        // val 代表当前节点有多少数
        // add 为懒标记
        int ls, rs, add, val;
    }
    int N = (int)1e9, M = 120010, cnt = 1;
    Node[] tr = new Node[M];
    void update(int u, int lc, int rc, int l, int r, int v) {
        if (l <= lc && rc <= r) {
            tr[u].val += (rc - lc + 1) * v;
            tr[u].add += v;
            return ;
        }
        lazyCreate(u);
        pushdown(u, rc - lc + 1);
        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].val;
        lazyCreate(u);
        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 lazyCreate(int u) {
        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();
        }
    }
    void pushdown(int u, int len) {
        tr[tr[u].ls].add += tr[u].add; tr[tr[u].rs].add += tr[u].add;
        tr[tr[u].ls].val += (len - len / 2) * tr[u].add; tr[tr[u].rs].val += len / 2 * tr[u].add;
        tr[u].add = 0;
    }
    void pushup(int u) {
        tr[u].val = tr[tr[u].ls].val + tr[tr[u].rs].val;
    }
    public boolean book(int start, int end) {
        if (query(1, 1, N + 1, start + 1, end) > 0) return false;
        update(1, 1, N + 1, start + 1, end, 1);
        return true;
    }
}
  • 时间复杂度:令 nnn 为值域大小,本题固定为 1e91e91e9,线段树的查询和增加复杂度均为 O(log⁡n)O(\log{n})O(logn)
  • 空间复杂度:令询问数量为 mmm,复杂度为 O(mlog⁡n)O(m\log{n})O(mlogn)

分块 + 位运算(分桶)

另一个留有遗憾的算法是「分块」,朴素的分块做法是使用一个布尔数组 region 代表某个块是否有被占用,使用哈希表记录具体某个位置是否被占用,但可惜被奇怪的测评机制卡了。

729. 我的日程安排表 I :「模拟」&「线段树(动态开点)」&「分块 + 位运算(分桶)」

TLE 代码:

class MyCalendar {
    static Boolean T = Boolean.TRUE, F = Boolean.FALSE;
    static int n = (int)1e9, len = (int) Math.sqrt(n) + 30;
    static boolean[] region = new boolean[len];
    static Map<Integer, Boolean> map = new HashMap<>();
    int getIdx(int x) {
        return x / len;
    }
    void add(int l, int r) {
        if (getIdx(l) == getIdx(r)) {
            for (int k = l; k <= r; k++) map.put(k, T);
        } else {
            int j = l, i = r;
            while (getIdx(j) == getIdx(l)) map.put(j++, T);
            while (getIdx(i) == getIdx(r)) map.put(i--, T);
            for (int k = getIdx(j); k <= getIdx(i); k++) region[k] = true;
        }
    }
    boolean query(int l, int r) {
        if (getIdx(l) == getIdx(r)) {
            boolean cur = region[getIdx(l)];
            for (int k = l; k <= r; k++) {
                if (map.getOrDefault(k, F) || cur) return false;
            }
            return true;
        } else {
            int j = l, i = r;
            while (getIdx(j) == getIdx(l)) {
                if (map.getOrDefault(j, F) || region[getIdx(j)]) return false;
                j++;
            }
            while (getIdx(i) == getIdx(r)) {
                if (map.getOrDefault(i, F) || region[getIdx(i)]) return false;
                i--;
            }
            for (int k = getIdx(j); k <= getIdx(i); k++) {
                if (region[k]) return false;
            }
            return true;
        }
    }
    public MyCalendar() {
        Arrays.fill(region, false);
        map.clear();
    }
    public boolean book(int start, int end) {
        if (query(start, end - 1)) {
            add(start, end - 1);
            return true;
        }
        return false;
    }
}

但我们知道分块算法的复杂度并不糟糕,而哈希表可能是被卡常数的关键,因此我们可以使用 int 数组来充当哈希表,由于只需要记录「是否被占用」,因此我们可以使用 int 的每一位充当格子,通过这种「分桶 + 位运算」,有效降低常数。

AC 代码(2022-07-05 可过):

class MyCalendar {
    static int n = (int)1e9, len = (int) Math.sqrt(n) + 50, cnt = 32;
    static boolean[] region = new boolean[len];
    static int[] map = new int[n / cnt];
    int getIdx(int x) {
        return x / len;
    }
    boolean get(int x) {
        return ((map[x / cnt] >> (x % cnt)) & 1) == 1;
    }
    void set(int x) {
        map[x / cnt] |= (1 << (x % cnt));
    }
    void add(int l, int r) {
        if (getIdx(l) == getIdx(r)) {
            for (int k = l; k <= r; k++) set(k);
        } else {
            int j = l, i = r;
            while (getIdx(j) == getIdx(l)) set(j++);
            while (getIdx(i) == getIdx(r)) set(i--);
            for (int k = getIdx(j); k <= getIdx(i); k++) region[k] = true;
        }
    }
    boolean query(int l, int r) {
        if (getIdx(l) == getIdx(r)) {
            boolean cur = region[getIdx(l)];
            for (int k = l; k <= r; k++) {
                if (get(k) || cur) return false;
            }
            return true;
        } else {
            int j = l, i = r;
            while (getIdx(j) == getIdx(l)) {
                if (get(j) || region[getIdx(j)]) return false;
                j++;
            }
            while (getIdx(i) == getIdx(r)) {
                if (get(i) || region[getIdx(i)]) return false;
                i--;
            }
            for (int k = getIdx(j); k <= getIdx(i); k++) {
                if (region[k]) return false;
            }
            return true;
        }
    }
    public MyCalendar() {
        Arrays.fill(region, false);
        Arrays.fill(map, 0);
    }
    public boolean book(int start, int end) {
        if (query(start, end - 1)) {
            add(start, end - 1);
            return true;
        }
        return false;
    }
}
  • 时间复杂度:O(nm)O(n\sqrt{m})O(nm)
  • 空间复杂度:O(m+Mk)O(\sqrt{m} + \frac{M}{k})O(m+kM),其中 M=1e9M = 1e9M=1e9 为值域大小,k=32k = 32k=32 为单个数字的能够存储的格子数

最后

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

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

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

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