【数据结构】【学习笔记】线段树
笔者又偷懒了,该文大部分使用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)\)。
🤓实现
核心函数:
- build(node, l, r):自底向上分治建树。
- query(node, l, r, ql, qr):将查询区间拆分为若干个树上节点。
- updatePoint(node, l, r, idx, val):单点更新并沿路回溯更新父节点。
- updateRange(node, l, r, ql, qr, val):Lazy 懒标记延迟下传(线段树最重要的精髓,解决 \(O(\log N)\) 区间修改)。
❔题目
1. 区域和检索 - 数组可修改
解题思路
这道题是学习线段树单点修改与区间查询的最标准模板题。
它的核心逻辑如下:
- 构建(NumArray):开辟 4 * N 大小的数组 tree。根节点存 \([0, n-1]\) 的和,随后二分拆分为左右子树(左 \([0, mid]\),右 \([mid+1, n-1]\)),自底向上累加求和,\(O(N)\) 时间建树。
- 单点更新(update):从根节点出发,根据 \(index\) 与 \(mid\) 的比较一路二分下探到目标叶子节点,更新叶子节点的值,然后在递归回溯时更新沿线所有父节点的值。复杂度仅为 \(O(\log N)\)。
- 区间查询(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)\):
- 直接相遇(无须往后找):
- 若 \(i == j\):两人本来就在同一栋建筑,直接返回 \(j\)。
- 若 \(heights[i] < heights[j]\):因为 \(i < j\),Alice(在 \(i\))可以直接跳到 Bob 所在的 \(j\),答案也是 \(j\)。
- 必须向右寻找新建筑:
- 若 \(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)\)。
- 时间复杂度:
- 建树 build:\(O(N)\)
- 一棵满二叉树有 \(N\) 个叶子节点和 \(N - 1\) 个非叶子节点,总结点数大约为 \(2N - 1\)。
- 建树过程会且仅会访问每个节点一次,每个节点的计算量为 \(O(1)\),因此总时间复杂度为 \(O(2N - 1) = O(N)\)。
- 单点修改 update:\(O(\log N)\)
- 修改某个元素时,算法根据 idx 与中点 mid 的关系,沿着根节点二分下探。
- 在二叉树的每一层中,只深入访问 1 个子节点;到达叶子节点后回溯,也只更新沿途的 1 个父节点。
- 访问路径长度等于树的高度,因此时间复杂度为 \(O(\log N)\)。
- 区间查询 query:\(O(\log N)\)
- 查询区间 \([L, R]\) 时,根据分治逻辑,同一层级上最多只可能访问 4 个节点。
原理:如果在同一层访问了超过 4 个节点,说明其中位于中间的节点必然包含了完整的子区间,这些中间节点在上一层就已经被 L <= start && end <= R 拦截并直接返回了,不会继续向下拆分。
- 每一层最多访问 \(4\) 个节点,树的总高度为 \(\log_2 N\),因此访问的总节点数上限为 \(4 \log_2 N\),时间复杂度为 \(O(\log N)\)。
- 查询区间 \([L, R]\) 时,根据分治逻辑,同一层级上最多只可能访问 4 个节点。
- 建树 build:\(O(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 辅助整理。

浙公网安备 33010602011771号