前言
- 本文要求读者对线段树较为熟悉,并能理解左闭右开的区间。
- 动态开点线段树的写法非常多,读者应选择自己喜欢的方式去实现。
理解动态开点
动态开点的含义就是,不使用就不开空间,一使用就开空间。
为什么会存在不使用的情况呢?
因为线段树的一次操作只会使用到很小一部分结点——
具体地,假设整棵线段树的区间长度为
,那么单次操作只需要
个结点的信息。
下面以修改操作为例来分析:
单点修改
单点修改就是修改某一个叶子结点的值,而后更新它所有祖先结点的信息(因为都包含这个单点),例:
修改 [0, 8) 线段树中的单点 3
因此需要更新信息的结点为:从根结点到该叶子结点这条路径上所有的结点,最多
个结点。
区间修改
区间修改需要更新的结点数量可以逐层来分析,这里给出一个实例便于理解:
修改 [0, 8) 线段树中的 [1, 7) 区间
在线段树每一层中需要更新的结点:
- 要么包含左端点,这种每层最多 1 个,如
- 要么包含右端点,这种每层最多 1 个,如
- 要么在左右端点之间,如
- ,这种每层最多 2 个,否则必能合并,如
因此每层最多更新四个结点,而线段树的层数为
,因此区间修改需要更新的结点数量最多为。
小结
因为单次操作只需要操作
个结点,所以在操作的同时,将要用的结点的空间开出来就好了,从而对于单次操作,其最坏空间复杂度与时间复杂度相同。
可见动态开点线段树本质上依然是一棵完整的线段树,只是没使用过的结点不占空间而已。
如何动态开点
其实只要会写普通的线段树,稍作修改就是动态开点的线段树啦。
这里引入力扣上一道典范模板题:掉落的方块 (Falling Squares) - 力扣 (LeetCode)
简要题意:
从高空依次丢若干个正方形的方块,每个方块底边与轴平行竖直下落。
当方块碰到地面或者碰到其他方块时停下(左右边缘的触碰不能算),停下之后才会丢出下一个方块。
每次丢下一个方块并停下来后,你需要求出目前方块堆叠出的最高高度。样例示意图(原题图片)
样例输入:
样例输出:
样例解释:
共有 3 个方块,如第一个方块表示左边缘坐标
left = 1
,边长side_length = 2
的方块。
第 1 个方块掉落后,最高的堆叠由方块 1 组成,堆叠的最高高度为 2 。
第 2 个方块掉落后,最高的堆叠由方块 1 和 2 组成,堆叠的最高高度为 5 。
第 3 个方块掉落后,最高的堆叠仍然由方块 1 和 2 组成,堆叠的最高高度为 5 。
因此,返回 [2, 5, 5] 作为答案。怎么做呢?
每个方块都有个区间
[left, left + side_length)
,那么该区间最大高度h
就是停止时下边缘的高度。
那么当这个方块落下来之后,这个区间的高度就会变成h + side_length
。每次丢下方块后的最高高度就是整个区间的最大值。
也就是区间赋值+区间最值啦。
首先定义结点:
动态开点的左、右孩子下标无法根据当前下标计算,所以用变量lc, rc
分别记录左、右孩子的下标。
此外跟普通线段树类似,最高高度用height
来记录,区间赋值需要懒标记lazy
。using int64 = long long; struct node { int lc = -1; int rc = -1; int64 height = 0; int64 lazy = 0; } tr[TREE_SIZE];
这里用
-1
表示不存在(还没有为其分配空间)。那么线段树的空间大小
TREE_SIZE
应该开多大呢?由前面的分析可知,每次区间操作最坏情况需要开的空间是
,其中是区间长度。而又因每个方块只操作一个区间,因此最坏情况下需要开的空间的一个上界是:方块数量乘以单次操作最坏情况下需要开的空间。
constexpr static int N = (int) 1e8 + (int) 1e6 + 1; // 区间长度 constexpr static int logN = 27; // log2(N)上取整 constexpr static int M = 1000; // 方块个数(也是区间操作的次数) constexpr static int TREE_SIZE = M * 4 * logN + 10; // 树的大小
由于在结点要用时才会给它分配空间,因此定义函数
check(int &u)
来完成这一操作:void check(int &u) { if (u == -1) { // 如果还没有为结点 u 分配空间 u = ++ tot; // 为之分配一个下标 tot tr[u].lc = tr[u].rc = -1; // 左右孩子仍待分配 tr[u].height = tr[u].lazy = 0; // 初始化 } }
注意到
check
函数传的是引用类型int &
,这意味着函数内可以修改传过来的变量,这也意味着如果某个函数需要在其内部调用check
,那就也要传引用。其他操作与普通的线段树十分类似,只要在有需要的时候调用
check
函数分配空间即可。有哪些地方需要呢,这里给出一个参考答案(可能并不完整):
在递归调用子树、下传懒标记、为结点赋值时需要调用
check
函数。// down(u, h) : 将结点 u 所表示区间的高度赋值为 h void down(int &u, int64 h) { check(u); tr[u].height = h; tr[u].lazy = h; } // 将区间 [lo, hi) 赋值为 val void range_assign(int &u, int l, int r, int lo, int hi, int64 val) { check(u); // ...和普通线段树完全一样,故省略... } // 求区间 [lo, hi) 的最大值 int64 range_max(int &u, int l, int r, int lo, int hi) { check(u); // ...和普通线段树完全一样,故省略... }
最后,
push_up
时需要判断孩子结点是否存在:void push_up(int u) { if (tr[u].lc != -1) { tr[u].height = std::max(tr[u].height, tr[tr[u].lc].height); } if (tr[u].rc != -1) { tr[u].height = std::max(tr[u].height, tr[tr[u].rc].height); } }
最后的建议是,根结点的下标也定义一个变量,初始化为
-1
表示未分配空间。然后就像普通线段树那样直接调用就好啦——
int root = -1; // 注:调用递归函数需要使用变量 root,不能直接用常量,因为常量不能作为 int& 引用传入 std::vector<int> ans; for (auto &square : positions) { int left = square[0]; int side_length = square[1]; int right = left + side_length; int64 h = range_max(root, 0, N, left, right); range_assign(root, 0, N, left, right, h + side_length); ans.push_back(tr[root].height); }
完整代码
class Solution { public: constexpr static int N = (int) 1e8 + (int) 1e6 + 1; constexpr static int logN = 27; constexpr static int M = 1000; constexpr static int TREE_SIZE = M * 4 * logN + 10; using int64 = long long; struct node { int lc = -1; int rc = -1; int64 height = 0; int64 lazy = 0; } tr[TREE_SIZE]; int tot = 0; void check(int &u) { if (u == -1) { u = ++ tot; tr[u].lc = tr[u].rc = -1; tr[u].height = tr[u].lazy = 0; } } void down(int &u, int64 h) { check(u); tr[u].height = h; tr[u].lazy = h; } void push_down(int u) { if (tr[u].lazy != 0) { int h = tr[u].lazy; tr[u].lazy = 0; down(tr[u].lc, h); down(tr[u].rc, h); } } void push_up(int u) { if (tr[u].lc != -1) { tr[u].height = std::max(tr[u].height, tr[tr[u].lc].height); } if (tr[u].rc != -1) { tr[u].height = std::max(tr[u].height, tr[tr[u].rc].height); } } void range_assign(int &u, int l, int r, int lo, int hi, int64 val) { check(u); if (lo <= l and r <= hi) { down(u, val); } else if (r <= lo or hi <= l) { // pass } else { push_down(u); int mid = l + (r - l) / 2; range_assign(tr[u].lc, l, mid, lo, hi, val); range_assign(tr[u].rc, mid, r, lo, hi, val); push_up(u); } } int64 range_max(int &u, int l, int r, int lo, int hi) { check(u); if (lo <= l and r <= hi) { return tr[u].height; } else if (r <= lo or hi <= l) { return 0ll; } else { push_down(u); int mid = l + (r - l) / 2; int64 res = std::max(range_max(tr[u].lc, l, mid, lo, hi), range_max(tr[u].rc, mid, r, lo, hi)); push_up(u); return res; } } vector<int> fallingSquares(vector<vector<int>>& positions) { vector<int> ans; int root = -1; // 注:调用递归函数需要使用 root,不能直接用 0,因为 0 不能作为 int& 引用传入 for (auto &square : positions) { int left = square[0]; int side_length = square[1]; int right = left + side_length; int64 h = range_max(root, 0, N, left, right); range_assign(root, 0, N, left, right, h + side_length); ans.push_back(tr[root].height); } return ans; } };