AIGC标识 【数据结构】【学习笔记】线段树

笔者又偷懒了,该文大部分使用ai。不过线段树只要明白了它的核心点:二分分治法+根据需求对区间进行统计。就好理解和书写很多了。

线段树,本质上是一种用数组存储的平衡二叉树,专门用于解决高效的区间查询与区间修改问题。

在理解时,可以参考二分查找的思想——线段树是以 \(4N\) 数组存储的分治二叉树,通过预存 \(O(N)\)二分区间的统计值(统计什么内容可以按需处理),实现任意区间在 \(O(\log N)\) 时间内的高效拼接与修改。

⚙️定义

线段树(Segment Tree)是一种基于分治思想的平衡二叉树,每个节点代表原数组中的一段区间 \([l, r]\),结构例子如下

		[0, 3] (Sum: 11)
                 /                \
         [0, 1] (Sum: 3)     [2, 3] (Sum: 8)
          /        \          /        \
      [0,0](2)   [1,1](1)  [2,2](5)   [3,3](3)
  • 数组表示法:采用类似于堆(Heap)的顺序存储。根节点下标为 \(0\),对于节点 \(i\)

    • 左孩子下标:\(2i + 1\)
    • 右孩子下标:\(2i + 2\)
  • \(4N\)空间坑点:若数组长度 \(N\) 不是 \(2\) 的幂次,线段树树高为 \(\lceil \log_2 N \rceil + 1\)。最后一层可能会出现空缺,用一维数组顺序存储时,最大节点索引会逼近 \(4N\)。因此开辟空间必须设为 4 * N,否则访问子节点必越界。

🔎特性

  • 自底向上建树 (build):利用后序遍历,先递归构建左右子树,再通过 pushUp 将子节点的结果汇总给父节点。
  • 区间拆分查询 (query):如果要查询的区间完全覆盖了当前节点区间,直接返回当前节点值;否则将查询区间拆分,分别去左右子树寻找并拼合。
  • 单点更新 (updatePoint):沿着树根一路找到目标叶子节点修改值,回溯时顺便更新沿途所有父节点。
  • 懒标记延迟下传 (updateRange & Lazy Tag):
    • 精髓思想:“能懒就懒,用时再传”。
    • 当需要修改区间 \([L, R]\) 时,只要当前节点被 \([L, R]\) 完全覆盖,就直接在此节点打上一个 lazy 标签(记录“待处理的修改”),并立即返回,不再向下递归。
    • 只有当下次查询或修改需要用到该节点的子节点时,才通过 pushDown 函数把 lazy 标签向下传递一级。这保证了区间修改的时间复杂度依然是 \(O(\log N)\)

🤓实现

核心函数:

  1. build(node, l, r):自底向上分治建树。
  2. query(node, l, r, ql, qr):将查询区间拆分为若干个树上节点。
  3. updatePoint(node, l, r, idx, val):单点更新并沿路回溯更新父节点。
  4. updateRange(node, l, r, ql, qr, val):Lazy 懒标记延迟下传(线段树最重要的精髓,解决 \(O(\log N)\) 区间修改)。

❔题目

1. 区域和检索 - 数组可修改

解题思路

这道题是学习线段树单点修改与区间查询的最标准模板题。
它的核心逻辑如下:

  1. 构建(NumArray):开辟 4 * N 大小的数组 tree。根节点存 \([0, n-1]\) 的和,随后二分拆分为左右子树(左 \([0, mid]\),右 \([mid+1, n-1]\)),自底向上累加求和,\(O(N)\) 时间建树。
  2. 单点更新(update):从根节点出发,根据 \(index\)\(mid\) 的比较一路二分下探到目标叶子节点,更新叶子节点的值,然后在递归回溯时更新沿线所有父节点的值。复杂度仅为 \(O(\log N)\)
  3. 区间查询(sumRange):查询 \([left, right]\) 时,如果当前节点代表的区间完全落在查询范围内则直接返回,否则将查询区间拆分传递给左右子树去拼凑,复杂度同样为 \(O(\log N)\)

实现

class NumArray {
    private tree: number[];  // 线段树数组,用于存储区间和
    private n: number;       // 原数组的元素数量

    constructor(nums: number[]) {
        this.n = nums.length;
        this.tree = new Array(4 * this.n).fill(0);
        if (this.n > 0) {
            // 从根节点 0 开始构建线段树,管辖原数组范围 [0, n - 1]
            this.build(nums, 0, 0, this.n - 1);
        }
    }

    private build(nums: number[], node: number, start: number, end: number): void {
        if (start === end) {
            // 到达叶子节点(区间只有一个元素),节点值等于原数组该位置的值
            this.tree[node] = nums[start];
            return;
        }
        
        // 二分划分为左右子区间
        const mid = Math.floor((start + end) / 2);
        
        // 递归构建左子树 (管辖 [start, mid])
        this.build(nums, 2 * node + 1, start, mid);
        // 递归构建右子树 (管辖 [mid + 1, end])
        this.build(nums, 2 * node + 2, mid + 1, end);
        
        // 当前节点的区间和 = 左子树的和 + 右子树的和
        this.tree[node] = this.tree[2 * node + 1] + this.tree[2 * node + 2];
    }

    update(index: number, val: number): void {
        this.updateTree(0, 0, this.n - 1, index, val);
    }

    private updateTree(node: number, start: number, end: number, idx: number, val: number): void {
        if (start === end) {
            // 定位到目标索引对应的叶子节点,更新其数值,中断递归
            this.tree[node] = val;
            return;
        }
        
        const mid = Math.floor((start + end) / 2);
        if (idx <= mid) {
            // 目标下标在左侧区间
            this.updateTree(2 * node + 1, start, mid, idx, val);
        } else {
            // 目标下标在右侧区间
            this.updateTree(2 * node + 2, mid + 1, end, idx, val);
        }
        
        // 沿途父节点重新计算区间和(回溯更新 PushUp)
        this.tree[node] = this.tree[2 * node + 1] + this.tree[2 * node + 2];
    }

    sumRange(left: number, right: number): number {
        return this.query(0, 0, this.n - 1, left, right);
    }

    private query(node: number, start: number, end: number, L: number, R: number): number {
        // 1. 完全覆盖:当前节点管辖区间 [start, end] 被目标区间 [L, R] 完全包含,直接返回节点预存的和
        if (L <= start && end <= R) return this.tree[node];
        
        // 2. 无交集:当前节点管辖区间与目标区间完全不重叠,返回 0 不影响求和
        if (end < L || start > R) return 0;
        
        // 3. 部分重叠:二分拆分,分别向左右子树查询并累加拼接结果
        const mid = Math.floor((start + end) / 2);
        return this.query(2 * node + 1, start, mid, L, R) + this.query(2 * node + 2, mid + 1, end, L, R);
    }
}
public class NumArray {
    private int[] tree; // 线段树存储数组
    private int n;      // 原数组长度

    public NumArray(int[] nums) {
        n = nums.Length;
        if (n == 0) return;
        tree = new int[4 * n]; // 开辟 4 * N 的安全空间
        // 从根节点 0 开始建树,管辖区间 [0, n-1]
        Build(nums, 0, 0, n - 1);
    }

    // 1. 建树函数
    private void Build(int[] nums, int node, int start, int end) {
        // 【递归基】:到达叶子节点
        if (start == end) {
            tree[node] = nums[start];
            return;
        }

        int mid = start + (end - start) / 2;
        int leftChild = 2 * node + 1;
        int rightChild = 2 * node + 2;

        // 分治建树
        Build(nums, leftChild, start, mid);
        Build(nums, rightChild, mid + 1, end);

        // 回溯汇总父节点
        tree[node] = tree[leftChild] + tree[rightChild];
    }

    // 2. 单点修改函数
    public void Update(int index, int val) {
        UpdateTree(0, 0, n - 1, index, val);
    }

    private void UpdateTree(int node, int start, int end, int idx, int val) {
        // 【递归基】:定位到目标叶子
        if (start == end) {
            tree[node] = val;
            return;
        }

        int mid = start + (end - start) / 2;
        int leftChild = 2 * node + 1;
        int rightChild = 2 * node + 2;

        // 判断走向
        if (idx <= mid) {
            UpdateTree(leftChild, start, mid, idx, val);
        } else {
            UpdateTree(rightChild, mid + 1, end, idx, val);
        }

        // 回溯更新父节点
        tree[node] = tree[leftChild] + tree[rightChild];
    }

    // 3. 区间查询函数
    public int SumRange(int left, int right) {
        return Query(0, 0, n - 1, left, right);
    }

    private int Query(int node, int start, int end, int L, int R) {
        // 完全被覆盖:直接返回当前节点预存结果
        if (L <= start && end <= R) return tree[node];
        // 完全无交集:返回 0
        if (end < L || start > R) return 0;

        // 部分重叠:左右子树拆分查询
        int mid = start + (end - start) / 2;
        int leftSum = Query(2 * node + 1, start, mid, L, R);
        int rightSum = Query(2 * node + 2, mid + 1, end, L, R);

        return leftSum + rightSum;
    }
}
class NumArray {
private:
    vector<int> tree; // 存储线段树的数组,大小为 4 * N
    int n;            // 原数组的长度

    // 1. 建树函数:递归将区间一分为二,自底向上求和
    void build(vector<int>& nums, int node, int start, int end) {
        // 【递归基】:当区间缩小到单个元素时,说明到达了叶子节点
        if (start == end) {
            tree[node] = nums[start]; // 直接将原数组的值填入叶子节点
            return;
        }

        int mid = start + (end - start) / 2; // 计算当前管辖区间的二分中点
        int leftChild = 2 * node + 1;        // 左孩子节点在 tree 中的下标
        int rightChild = 2 * node + 2;       // 右孩子节点在 tree 中的下标

        // 分治:递归构建左半区间 [start, mid]
        build(nums, leftChild, start, mid);
        // 分治:递归构建右半区间 [mid + 1, end]
        build(nums, rightChild, mid + 1, end);

        // 【回溯汇总 PushUp】:当前节点的值等于左右两个子节点的值之和
        tree[node] = tree[leftChild] + tree[rightChild];
    }

    // 2. 单点修改函数:沿着树二分下探找到目标叶子,然后沿路回溯更新父节点
    void updateTree(int node, int start, int end, int idx, int val) {
        // 【递归基】:一路找到了对应 idx 的那个叶子节点
        if (start == end) {
            tree[node] = val; // 更新叶子节点的值
            return;
        }

        int mid = start + (end - start) / 2;
        int leftChild = 2 * node + 1;
        int rightChild = 2 * node + 2;

        // 二分下探:判断目标索引 idx 在左半边还是右半边
        if (idx <= mid) {
            // idx 在左半区间,往左子树深入
            updateTree(leftChild, start, mid, idx, val);
        } else {
            // idx 在右半区间,往右子树深入
            updateTree(rightChild, mid + 1, end, idx, val);
        }

        // 【回溯更新 PushUp】:底层的值变了,沿途所有包含该位置的父节点都要重新求和
        tree[node] = tree[leftChild] + tree[rightChild];
    }

    // 3. 区间查询函数:拼凑目标区间 [L, R] 的和
    int query(int node, int start, int end, int L, int R) {
        // 情况 A【完全覆盖】:当前节点管辖的区间 [start, end] 完全落在目标查询区间 [L, R] 内
        // 直接返回预存好的当前节点值,无需再往下找(这就是 log N 的关键!)
        if (L <= start && end <= R) {
            return tree[node];
        }

        // 情况 B【完全无交集】:当前节点管辖区间与查询区间毫无瓜葛,返回 0 也不影响求和
        if (end < L || start > R) {
            return 0;
        }

        // 情况 C【部分重叠】:目标区间跨越了左右两半,拆分给左右子树共同去查
        int mid = start + (end - start) / 2;
        int leftSum = query(2 * node + 1, start, mid, L, R);     // 左子树凑出的和
        int rightSum = query(2 * node + 2, mid + 1, end, L, R);   // 右子树凑出的和

        return leftSum + rightSum; // 将左右子树凑出的部分和拼接起来返回
    }

public:
    NumArray(vector<int>& nums) {
        n = nums.size();
        if (n == 0) return;
        tree.resize(4 * n); // 开辟 4 * N 的安全空间,防止底层的数组越界
        // 从根节点 node=0 开始建树,管辖原数组整个区间 [0, n-1]
        build(nums, 0, 0, n - 1);
    }

    void update(int index, int val) {
        // 从根节点 0 开始向下寻路更新
        updateTree(0, 0, n - 1, index, val);
    }

    int sumRange(int left, int right) {
        // 从根节点 0 开始查找目标区间 [left, right] 的和
        return query(0, 0, n - 1, left, right);
    }
};

2. 找到 Alice 和 Bob 可以相遇的建筑

解题思路

核心思路

对于每一个查询 queries[k] = [a, b],令 \(i = \min(a, b)\)\(j = \max(a, b)\)

  1. 直接相遇(无须往后找):
    • \(i == j\):两人本来就在同一栋建筑,直接返回 \(j\)
    • \(heights[i] < heights[j]\):因为 \(i < j\),Alice(在 \(i\))可以直接跳到 Bob 所在的 \(j\),答案也是 \(j\)
  2. 必须向右寻找新建筑:
    • \(heights[i] \ge heights[j]\):Alice 无法直接跳到 \(j\)。两人必须寻找位于 \(j\) 右侧的某个建筑 \(k\)(满足 \(k > j\)),使得 \(heights[k] > heights[i]\)\(heights[k] > heights[j]\),又因为\(heights[i] \ge heights[j]\),所以只用判断\(heights[k] > heights[i]\)即可。
    • 转换后的目标:在区间 \([j + 1, n - 1]\) 中,寻找第一个高度严格大于 \(heights[i]\) 的建筑下标 \(k\)

在数组中查找“区间内第一个大于 \(V\) 的元素下标”,正是线段树上二分(Segment Tree Binary Search)的经典应用场景。

为何使用线段树

  • 线段树节点存什么:每个节点保存其管辖区间内的最大建筑高度 tree[node] = max(left_child, right_child)。
  • 如何查找目标 \(k\):从根节点下探查询区间 \([j + 1, n - 1]\)
    • 若当前节点的 tree[node] <= heights[i],说明该区间内所有建筑都太矮,直接剪枝返回 -1;
    • 若当前节点属于目标范围且其最大高度 \(> heights[i]\)
      • 优先递归左子树(因为要找最左边/最小的下标 \(k\));
      • 如果左子树没找到,再递归右子树。

实现

function leftmostBuildingQueries(heights: number[], queries: number[][]): number[] {
    const n = heights.length;
    const tree: number[] = new Array(4 * n).fill(0);

    // 1. 建树:维护区间最大高度
    function build(node: number, start: number, end: number): void {
        if (start === end) {
            tree[node] = heights[start];
            return;
        }
        const mid = Math.floor((start + end) / 2);
        build(2 * node + 1, start, mid);
        build(2 * node + 2, mid + 1, end);
        tree[node] = Math.max(tree[2 * node + 1], tree[2 * node + 2]);
    }

    // 2. 线段树上二分查找最左的高建筑
    function query(node: number, start: number, end: number, L: number, R: number, val: number): number {
        if (start > R || end < L || tree[node] <= val) return -1;
        if (start === end) return start;

        const mid = Math.floor((start + end) / 2);
        const res = query(2 * node + 1, start, mid, L, R, val);
        if (res !== -1) return res;
        return query(2 * node + 2, mid + 1, end, L, R, val);
    }

    build(0, 0, n - 1);

    const ans: number[] = [];
    for (const [a, b] of queries) {
        const i = Math.min(a, b);
        const j = Math.max(a, b);

        if (i === j || heights[i] < heights[j]) {
            ans.push(j);
        } else {
            ans.push(query(0, 0, n - 1, j + 1, n - 1, heights[i]));
        }
    }
    return ans;
}
public class Solution {
    private int[] tree;

    private void Build(int[] heights, int node, int start, int end) {
        if (start == end) {
            tree[node] = heights[start];
            return;
        }
        int mid = start + (end - start) / 2;
        Build(heights, 2 * node + 1, start, mid);
        Build(heights, 2 * node + 2, mid + 1, end);
        tree[node] = Math.Max(tree[2 * node + 1], tree[2 * node + 2]);
    }

    private int Query(int node, int start, int end, int L, int R, int val) {
        if (start > R || end < L || tree[node] <= val) return -1;
        if (start == end) return start;

        int mid = start + (end - start) / 2;
        int res = Query(2 * node + 1, start, mid, L, R, val);
        if (res != -1) return res;
        return Query(2 * node + 2, mid + 1, end, L, R, val);
    }

    public int[] LeftmostBuildingQueries(int[] heights, int[][] queries) {
        int n = heights.Length;
        tree = new int[4 * n];
        Build(heights, 0, 0, n - 1);

        int[] ans = new int[queries.Length];
        for (int k = 0; k < queries.Length; k++) {
            int i = Math.Min(queries[k][0], queries[k][1]);
            int j = Math.Max(queries[k][0], queries[k][1]);

            if (i == j || heights[i] < heights[j]) {
                ans[k] = j;
            } else {
                ans[k] = Query(0, 0, n - 1, j + 1, n - 1, heights[i]);
            }
        }
        return ans;
    }
}
class Solution {
    vector<int> tree;

    // 建树:维护区间内的最大高度
    void build(vector<int>& heights, int node, int start, int end) {
        if (start == end) {
            tree[node] = heights[start];
            return;
        }
        int mid = start + (end - start) / 2;
        build(heights, 2 * node + 1, start, mid);
        build(heights, 2 * node + 2, mid + 1, end);
        tree[node] = max(tree[2 * node + 1], tree[2 * node + 2]);
    }

    // 线段树上二分:查找区间 [L, R] 中第一个高度 > val 的建筑下标
    int query(int node, int start, int end, int L, int R, int val) {
        // 无交集 或 当前区间最大高度不大于 val,直接剪枝返回 -1
        if (start > R || end < L || tree[node] <= val) return -1;
        // 到达叶子节点,找到了第一个满足条件的建筑
        if (start == end) return start;

        int mid = start + (end - start) / 2;
        // 优先在左子树找(确保是最左侧的建筑)
        int res = query(2 * node + 1, start, mid, L, R, val);
        if (res != -1) return res;
        // 左子树没找到,再去右子树找
        return query(2 * node + 2, mid + 1, end, L, R, val);
    }

public:
    vector<int> leftmostBuildingQueries(vector<int>& heights, vector<vector<int>>& queries) {
        int n = heights.size();
        tree.resize(4 * n);
        build(heights, 0, 0, n - 1);

        vector<int> ans;
        for (auto& q : queries) {
            int i = min(q[0], q[1]);
            int j = max(q[0], q[1]);

            // 情况 1:两者重合 或 较左侧的建筑比较右侧的矮,直接在 j 相遇
            if (i == j || heights[i] < heights[j]) {
                ans.push_back(j);
            } else {
                // 情况 2:在 [j + 1, n - 1] 区间查找第一个高度 > heights[i] 的下标
                int res = query(0, 0, n - 1, j + 1, n - 1, heights[i]);
                ans.push_back(res);
            }
        }
        return ans;
    }
};

复杂度分析

  • 空间复杂度:$O(N)
    $开辟 \(4N\) 空间的推导:
    • 对于 \(N\) 个元素的原数组,线段树有 \(N\) 个叶节点。
    • \(N\) 不是 2 的整数次幂(最坏情况如 \(N = 2^k + 1\)),树的高度会达到 \(\lceil \log_2 N \rceil + 1\) 层。
    • 采用完全二叉树的顺序数组存储时,最后一层即便很多位置为空,底层的索引也会延伸到接近 \(4N - 5\)
    • 渐进复杂度中忽略常数系数 \(4\),因此空间复杂度记为 \(O(N)\)
  • 时间复杂度
    1. 建树 build:\(O(N)\)
      • 一棵满二叉树有 \(N\) 个叶子节点和 \(N - 1\) 个非叶子节点,总结点数大约为 \(2N - 1\)
      • 建树过程会且仅会访问每个节点一次,每个节点的计算量为 \(O(1)\),因此总时间复杂度为 \(O(2N - 1) = O(N)\)
    2. 单点修改 update:\(O(\log N)\)
      • 修改某个元素时,算法根据 idx 与中点 mid 的关系,沿着根节点二分下探。
      • 在二叉树的每一层中,只深入访问 1 个子节点;到达叶子节点后回溯,也只更新沿途的 1 个父节点。
      • 访问路径长度等于树的高度,因此时间复杂度为 \(O(\log N)\)
    3. 区间查询 query:\(O(\log N)\)
      • 查询区间 \([L, R]\) 时,根据分治逻辑,同一层级上最多只可能访问 4 个节点。

        原理:如果在同一层访问了超过 4 个节点,说明其中位于中间的节点必然包含了完整的子区间,这些中间节点在上一层就已经被 L <= start && end <= R 拦截并直接返回了,不会继续向下拆分。

      • 每一层最多访问 \(4\) 个节点,树的总高度为 \(\log_2 N\),因此访问的总节点数上限为 \(4 \log_2 N\),时间复杂度为 \(O(\log N)\)

3. 我的日程安排表 II

解题思路

原始思路:
用线段树记录每个区间的课数量,然后直接获取当前区间是否超过3即可。

但在该题,不能用普通的静态线段树,应该需要——维护区间最大值 + 动态开点(结合懒标记)。

核心解题思路

  • 区间映射:题目给出的日程是半开区间 [startTime, endTime)。在线段树中操作时,需要将其转换为闭区间 [startTime, endTime - 1],避免相邻区间(如 [1, 2)[2, 3))在边界点 2 发生误判重叠。
  • 节点维护信息:线段树节点记录的不是简单的“课程总数”,而是该区间内的最大重叠次数(max,同时搭配延迟更新标记(lazy)。
  • 判断阈值不能在超过 3 后再处理。在插入新的日程之前,必须先查询 [startTime, endTime - 1] 区间内的最大重叠数:
    • queryMax >= 2:说明该区间内已有至少一个时间点被预订了 2 次,如果加入新日程,该点重叠数将达到 3(三重预订)。因此直接拒绝,返回 false
    • queryMax < 2:说明该区间最多只重叠了 1 次,加入新日程后最多达到 2 次,满足要求。执行区间 +1 更新并返回 true
  • 动态开点:由于时间范围为 \(0 \le \text{startTime} < \text{endTime} \le 10^9\),使用固定数组建立线段树会超出内存限制,必须采用动态开点线段树

实现

// 动态开点的线段树节点类
class SegmentTreeNode {
    left: SegmentTreeNode | null = null;   // 左子节点
    right: SegmentTreeNode | null = null;  // 右子节点
    maxVal: number = 0;                    // 当前区间内的最大重叠次数
    lazy: number = 0;                      // 懒标记
}

class MyCalendarTwo {
    private root: SegmentTreeNode = new SegmentTreeNode();
    private readonly MAX_TIME: number = 1e9; // 边界 10^9

    // 1. 懒标记下传(PushDown):延迟更新并按需创建子节点
    private pushDown(node: SegmentTreeNode): void {
        if (!node.left) node.left = new SegmentTreeNode();
        if (!node.right) node.right = new SegmentTreeNode();

        if (node.lazy !== 0) {
            const val = node.lazy;

            // 累加左子节点的值与懒标记
            node.left.maxVal += val;
            node.left.lazy += val;

            // 累加右子节点的值与懒标记
            node.right.maxVal += val;
            node.right.lazy += val;

            // 重置当前节点的懒标记
            node.lazy = 0;
        }
    }

    // 2. 区间修改(Update):将 [L, R] 范围内的重叠次数 +val
    private update(node: SegmentTreeNode, start: number, end: number, L: number, R: number, val: number): void {
        // 当前节点区间被目标区间完全覆盖,直接更新并置懒标记
        if (L <= start && end <= R) {
            node.maxVal += val;
            node.lazy += val;
            return;
        }

        this.pushDown(node); // 下传标记
        const mid = Math.floor((start + end) / 2);

        if (L <= mid) this.update(node.left!, start, mid, L, R, val);
        if (R > mid) this.update(node.right!, mid + 1, end, L, R, val);

        // 回溯更新(PushUp):当前最大值由左右子树最大值决定
        node.maxVal = Math.max(node.left!.maxVal, node.right!.maxVal);
    }

    // 3. 区间查询(Query):查询 [L, R] 范围内的最大重叠数
    private query(node: SegmentTreeNode, start: number, end: number, L: number, R: number): number {
        // 区间完全覆盖,直接返回预存的最大值
        if (L <= start && end <= R) {
            return node.maxVal;
        }

        this.pushDown(node); // 下传标记
        const mid = Math.floor((start + end) / 2);
        let maxVal = 0;

        if (L <= mid) maxVal = Math.max(maxVal, this.query(node.left!, start, mid, L, R));
        if (R > mid) maxVal = Math.max(maxVal, this.query(node.right!, mid + 1, end, L, R));

        return maxVal;
    }

    book(startTime: number, endTime: number): boolean {
        // 半开区间 [startTime, endTime) 转闭区间 [startTime, endTime - 1]
        const L = startTime;
        const R = endTime - 1;

        // 步骤 1:查询 [L, R] 的最大重叠次数
        if (this.query(this.root, 0, this.MAX_TIME, L, R) >= 2) {
            return false; // 再加就会触发三重预订
        }

        // 步骤 2:允许预订,区间内所有点重叠数 +1
        this.update(this.root, 0, this.MAX_TIME, L, R, 1);
        return true;
    }
}
public class MyCalendarTwo {
    // 动态开点的线段树节点类
    private class Node {
        public Node Left;   // 左子节点(管辖左半区间)
        public Node Right;  // 右子节点(管辖右半区间)
        public int MaxVal;  // 当前区间内包含的最大重叠预订数
        public int Lazy;    // 延迟更新标记(懒标记)
    }

    private readonly Node root = new Node();
    private const int MAX_TIME = 1000000000; // 时间上限 10^9

    // 1. 懒标记下传:在需要下探子节点时,按需新建节点并将更新值下传
    private void PushDown(Node node) {
        // 动态开点:若子节点不存在则即时创建
        if (node.Left == null) node.Left = new Node();
        if (node.Right == null) node.Right = new Node();

        // 存在未下传的更新动作
        if (node.Lazy != 0) {
            int val = node.Lazy;

            // 将更新值应用给左孩子,并累加左孩子的懒标记
            node.Left.MaxVal += val;
            node.Left.Lazy += val;

            // 将更新值应用给右孩子,并累加右孩子的懒标记
            node.Right.MaxVal += val;
            node.Right.Lazy += val;

            // 清空当前节点的懒标记,防止重复累加
            node.Lazy = 0;
        }
    }

    // 2. 区间修改:将时间范围 [L, R] 内所有点的重叠次数 +val
    private void Update(Node node, int start, int end, int L, int R, int val) {
        // 【完全覆盖】当前节点代表的区间 [start, end] 被目标区间 [L, R] 完全包含
        if (L <= start && end <= R) {
            node.MaxVal += val; // 直接更新当前节点的最大值
            node.Lazy += val;   // 打上懒标记,暂不下传给子节点
            return;
        }

        // 【部分重叠】需要继续深入子节点,先下传当前节点的懒标记
        PushDown(node);
        int mid = start + (end - start) / 2;

        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.MaxVal = Math.Max(node.Left.MaxVal, node.Right.MaxVal);
    }

    // 3. 区间查询:获取时间范围 [L, R] 内已有的最大重叠数
    private int Query(Node node, int start, int end, int L, int R) {
        // 【完全覆盖】直接返回该节点维护的最大值,无须继续下探
        if (L <= start && end <= R) {
            return node.MaxVal;
        }

        // 【部分重叠】下传懒标记并拆分查询左右子树
        PushDown(node);
        int mid = start + (end - start) / 2;
        int maxVal = 0;

        if (L <= mid) maxVal = Math.Max(maxVal, Query(node.Left, start, mid, L, R));
        if (R > mid) maxVal = Math.Max(maxVal, Query(node.Right, mid + 1, end, L, R));

        return maxVal;
    }

    public bool Book(int startTime, int endTime) {
        // 半开区间 [startTime, endTime) 转为闭区间 [startTime, endTime - 1]
        int L = startTime;
        int R = endTime - 1;

        // 步骤 1:先检查 [L, R] 范围内是否已经有时间段达到了 2 次重叠
        if (Query(root, 0, MAX_TIME, L, R) >= 2) {
            return false; // 如果再加一次就会达到 3(三重预订),直接拒绝
        }

        // 步骤 2:允许预订,将 [L, R] 区间内每个时间点的重叠数全部 +1
        Update(root, 0, MAX_TIME, L, R, 1);
        return true;
    }
}
class MyCalendarTwo {
    struct Node {
        int left = 0, right = 0; // 左右子节点在 tree 数组中的下标
        int maxVal = 0;          // 当前区间的最大预订数
        int lazy = 0;            // 懒标记
    };

    vector<Node> tree;
    int cnt = 1; // 节点计数器,根节点为 1
    const int MAX_R = 1e9;

    void pushDown(int node) {
        if (tree[node].left == 0) tree[node].left = ++cnt, tree.emplace_back();
        if (tree[node].right == 0) tree[node].right = ++cnt, tree.emplace_back();

        if (tree[node].lazy != 0) {
            int l = tree[node].left, r = tree[node].right;
            int lazy = tree[node].lazy;

            tree[l].maxVal += lazy;
            tree[l].lazy += lazy;
            tree[r].maxVal += lazy;
            tree[r].lazy += lazy;

            tree[node].lazy = 0;
        }
    }

    void update(int node, int start, int end, int L, int R, int val) {
        if (L <= start && end <= R) {
            tree[node].maxVal += val;
            tree[node].lazy += val;
            return;
        }
        pushDown(node);
        int mid = start + (end - start) / 2;
        if (L <= mid) update(tree[node].left, start, mid, L, R, val);
        if (R > mid) update(tree[node].right, mid + 1, end, L, R, val);
        tree[node].maxVal = max(tree[tree[node].left].maxVal, tree[tree[node].right].maxVal);
    }

    int query(int node, int start, int end, int L, int R) {
        if (L <= start && end <= R) return tree[node].maxVal;
        pushDown(node);
        int mid = start + (end - start) / 2;
        int res = 0;
        if (L <= mid) res = max(res, query(tree[node].left, start, mid, L, R));
        if (R > mid) res = max(res, query(tree[node].right, mid + 1, end, L, R));
        return res;
    }

public:
    MyCalendarTwo() {
        tree.resize(2); // 初始化根节点空间
    }

    bool book(int startTime, int endTime) {
        // 1. 查询 [startTime, endTime - 1] 的最大预订数
        if (query(1, 0, MAX_R, startTime, endTime - 1) >= 2) {
            return false; // 已有区间达到 2 次,再加就触发三重预订
        }
        // 2. 如果满足条件,将 [startTime, endTime - 1] 范围内所有点 +1
        update(1, 0, MAX_R, startTime, endTime - 1, 1);
        return true;
    }
};

复杂度分析

  • 时间复杂度\(O(Q \log C)\)
    单次 book 操作:\(O(\log C)\)
    每次执行 book 会分别调用一次 Query(查询最大重叠数)和一次 Update(区间加 1)。
    由于时间轴的最大范围 \(C = 10^9\),线段树的最大深度为 \(H = \lceil \log_2(10^9) \rceil \approx 30\)
    在最坏情况下,每次区间查询或更新在树的每一层最多访问 4 个节点,因此单次 book 操作访问的节点数上限约为 \(4 \times 30 = 120\) 个。
    由于节点内的 PushDown 计算是 \(O(1)\) 的,单次耗时非常极小。
    处理完所有 \(Q\) 次预订请求的总耗时与 \(Q \log_2 C\) 成正比。
  • 空间复杂度\(O(Q \log C)\)
    初始时线段树只有一个根节点。
    只有在执行 Update 或 PushDown 递归下探到从未访问过的子区间时,才会动态新建节点。每次 Update 操作沿路径最多新建 \(O(\log C)\) 个节点。
    因此,经过 \(Q\) 次预订后,线段树中占用的总节点数上限为 \(O(Q \log C)\)
    这种按需创建节点的机制避开了传统静态线段树需要预先开辟 \(4C\)(几十亿级)长度数组的内存爆仓问题。

引用

[1] 力扣探索模式


注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

posted @ 2026-08-30 14:07  SEHOD  阅读(5)  评论(0)    收藏  举报