【树形数据结构】李超线段树 (Li-Chao Tree)
李超线段树是一种用于维护动态插入线段(或直线),并支持 在指定点查询所有线段在该点的最大值(或最小值) 的数据结构。
1. 问题背景
想象一下,你有以下需求:
- 在线操作:不断地向一个集合中添加新的线段(或直线)。
- 单点查询:给定一个横坐标
x
,需要快速查询所有已插入的线段在x
处的函数值的最大值(或最小值)。
例如,在计算几何、动态规划优化(如斜率优化)中,我们常常需要处理这类问题。朴素的做法是每次查询都遍历所有线段,时间复杂度为 O(n),效率低下。
李超线段树可以在 O(log C) 的时间内完成单次插入和查询操作,其中 C 是坐标范围的大小(或离散化后的点数)。
2. 核心思想
李超线段树的核心思想是:在每个线段树节点上,只维护一个“优势”线段。
这里的“优势”指的是:在当前节点所代表的区间中点处,该线段的函数值是最大的(或最小的)。
- 为什么是中点? 选择中点是为了保证树的平衡性和操作的正确性。通过比较中点处的函数值,可以决定新插入的线段是否应该“覆盖”当前节点存储的线段,或者递归到子区间。
3. 数据结构定义
- 线段/直线:通常表示为
y = k * x + b
。我们用一个结构体Line
来存储k
和b
。 - 线段树:是一棵完全二叉树,通常建立在横坐标上。
- 叶子节点代表一个具体的横坐标点。
- 内部节点代表一个区间
[l, r]
。
- 节点信息:每个节点
u
存储一条线段tree[u]
,这条线段是在区间[l, r]
的中点mid = (l + r) / 2
处具有最大(或最小)函数值的线段。
4. 操作详解
4.1 插入操作 (Insert)
目标:将一条新线段 L
插入到线段树中。
过程(递归进行):
- 初始化:从根节点开始,当前处理的节点
u
对应区间[l, r]
。 - 计算中点:
mid = (l + r) / 2
。 - 比较中点函数值:
- 计算当前节点存储的线段
tree[u]
在mid
处的值:val_old = tree[u].f(mid)
- 计算新线段
L
在mid
处的值:val_new = L.f(mid)
- 计算当前节点存储的线段
- 决策:
- 如果
val_new > val_old
(假设求最大值),说明新线段L
在中点处更优,那么:- 将
tree[u]
替换为L
。 - 将原来的
tree[u]
作为新的待插入线段,递归地插入到左子树或右子树中。
- 将
- 否则(
val_new <= val_old
),说明当前节点的线段在中点处更优或相等,那么:- 将新线段
L
递归地插入到左子树或右子树中。
- 将新线段
- 如果
- 递归方向:
- 如何决定递归到左子树还是右子树?
- 关键在于比较两条线段在区间端点的函数值。
- 比较左端点
l
处的函数值:- 如果
L.f(l) > tree[u].f(l)
,说明新线段在左半区间可能更优,递归到左子树。 - 否则,递归到右子树。
- 如果
- (注意:也可以比较右端点
r
,但比较左端点更常见。选择哪个端点不影响正确性,但可能影响常数。)
为什么这样递归?
这个决策基于一个重要的几何性质:两条直线最多相交一次。
- 如果两条线在中点
mid
处,新线段更优,但在左端点l
处旧线段更优,说明两条线在区间[l, mid]
内相交。因此,旧线段在左半区间可能仍有优势,需要递归到左子树检查。 - 同理,如果在右端点
r
处旧线段更优,则递归到右子树。
4.2 查询操作 (Query)
目标:查询在横坐标 x
处所有线段的最大函数值。
过程:
- 从根节点开始,沿着线段树向下遍历。
- 对于经过的每一个节点
u
:- 计算该节点存储的线段
tree[u]
在x
处的函数值。 - 用这个值更新全局最大值
ans
。
- 计算该节点存储的线段
- 根据
x
的大小,决定进入左子树还是右子树:- 如果
x <= mid
,进入左子树。 - 如果
x > mid
,进入右子树。
- 如果
- 当到达叶子节点时,返回
ans
。
为什么这样查询?
因为任何一条线段 L
,只要它曾经在某个包含 x
的区间中被存储在某个节点上,那么在查询 x
时,我们一定会经过那个节点,并计算 L
在 x
处的值。由于我们维护的是“中点优势”,即使 L
不是最终在 x
处最优的线段,它也可能在某个祖先节点上被记录过。通过遍历所有包含 x
的区间对应的节点,我们保证了不会遗漏任何可能在 x
处取得最大值的线段。
5. 复杂度分析
- 时间复杂度:
- 插入:每次插入最多递归树的高度次。树的高度为 O(log C),其中 C 是坐标范围。因此,单次插入时间复杂度为 O(log C)。
- 查询:查询需要从根到叶子的路径,路径长度为树的高度。因此,单次查询时间复杂度为 O(log C)。
- 空间复杂度:线段树需要 O© 的空间来存储所有节点。如果坐标范围很大,通常需要离散化。
6. 坐标离散化
当横坐标范围 C
非常大(例如 10^9
)时,直接开 O© 的数组是不现实的。
解决方案:离散化
- 收集所有可能用到的横坐标值(包括插入线段的定义域和查询点)。
- 对这些值进行排序并去重。
- 建立一个映射,将原始坐标映射到
[1, M]
的整数,其中M
是去重后的坐标数量。 - 在离散化后的坐标上建立李超线段树。
离散化后,树的高度变为 O(log M),插入和查询的时间复杂度也变为 O(log M)。
7. C++ 实现
以下是一个完整的、可运行的 C++ 实现,支持离散化,求最大值。
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
using namespace std;// 定义线段/直线 y = k*x + b
struct Line {long long k, b;Line() : k(0), b(0) {}Line(long long k, long long b) : k(k), b(b) {}// 计算在 x 处的函数值long long f(long long x) const {return k * x + b;}
};// 李超线段树类
class LiChaoTree {
private:vector<Line> tree; // 线段树数组,每个节点存储一条线段vector<long long> xs; // 离散化后的横坐标数组int n; // 离散化后的坐标数量// 比较函数:判断 line1 在 x 处的值是否大于 line2// 用于维护最大值bool better(const Line& line1, const Line& line2, long long x) {long long val1 = line1.f(x);long long val2 = line2.f(x);// 如果值相等,优先选择新插入的线段(避免死循环)if (val1 != val2) return val1 > val2;return line1.k > line2.k; // 任意规则打破平局}// 递归插入线段// u: 当前节点编号// l, r: 当前节点代表的区间 [l, r] (在 xs 数组中的索引)// L: 要插入的线段void insert(int u, int l, int r, Line L) {if (l == r) {// 叶子节点,直接比较并更新if (better(L, tree[u], xs[l])) {tree[u] = L;}return;}int mid = (l + r) / 2;// 计算中点坐标long long x_mid = xs[mid];// 判断是否需要交换bool better_at_mid = better(L, tree[u], x_mid);if (better_at_mid) {swap(tree[u], L); // 将更优的线段留在当前节点}// 计算左端点坐标long long x_left = xs[l];// 判断递归方向:比较在左端点的函数值if (better(L, tree[u], x_left)) {// 新线段在左端点更优,递归到左子树insert(u * 2, l, mid, L);} else {// 否则递归到右子树insert(u * 2 + 1, mid + 1, r, L);}}// 查询在坐标 x 处的最大函数值// u: 当前节点编号// l, r: 当前节点区间// x: 查询的横坐标long long query(int u, int l, int r, long long x) {if (l == r) {return tree[u].f(x);}int mid = (l + r) / 2;long long ans = tree[u].f(x); // 当前节点线段的贡献if (x <= xs[mid]) {// 查询点在左半区间ans = max(ans, query(u * 2, l, mid, x));} else {// 查询点在右半区间ans = max(ans, query(u * 2 + 1, mid + 1, r, x));}return ans;}public:// 构造函数:接收离散化的坐标数组LiChaoTree(vector<long long> coordinates) {// 去重并排序sort(coordinates.begin(), coordinates.end());coordinates.erase(unique(coordinates.begin(), coordinates.end()), coordinates.end());xs = coordinates;n = xs.size();// 初始化线段树,大小为 4*ntree.resize(4 * n, Line(0, LLONG_MIN)); // 初始线段为负无穷,确保任何线段都更优}// 插入一条线段void insert(Line L) {insert(1, 0, n - 1, L);}// 查询在 x 处的最大值long long query(long long x) {// 找到 x 在离散化数组中的位置(二分查找)auto it = lower_bound(xs.begin(), xs.end(), x);// 如果 x 不在离散化数组中,找到第一个 >= x 的位置// 但查询仍然有效,因为树的结构支持任意 xreturn query(1, 0, n - 1, x);}
};// ===================== 使用示例 =====================int main() {// 收集所有可能的横坐标(包括查询点和线段定义域)vector<long long> coords = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 示例坐标范围 1 到 10// 实际应用中,coords 应包含所有查询点和线段端点// 创建李超线段树LiChaoTree lct(coords);// 插入几条线段lct.insert(Line(1, 0)); // y = xlct.insert(Line(0, 5)); // y = 5lct.insert(Line(-1, 10)); // y = -x + 10lct.insert(Line(2, -5)); // y = 2x - 5// 进行查询cout << "Query at x=1: " << lct.query(1) << endl; // 应该是 max(1, 5, 9, -3) = 9cout << "Query at x=3: " << lct.query(3) << endl; // 应该是 max(3, 5, 7, 1) = 7cout << "Query at x=5: " << lct.query(5) << endl; // 应该是 max(5, 5, 5, 5) = 5cout << "Query at x=7: " << lct.query(7) << endl; // 应该是 max(7, 5, 3, 9) = 9return 0;
}
8. 代码说明
Line
结构体:表示一条直线y = kx + b
,包含计算函数值的f
方法。better
函数:用于比较两条线段在某个x
处的函数值,决定哪条更优(求最大值)。这里加入了k
的比较来打破平局,防止无限递归。insert
函数:递归插入。核心是中点比较和交换,然后根据左端点比较决定递归方向。query
函数:递归查询,遍历所有包含查询点的节点,取最大值。- 构造函数:接收坐标数组,进行排序、去重,并初始化线段树数组。初始线段设置为
k=0, b=LLONG_MIN
,代表负无穷,确保任何有效线段插入时都能被接受。 query
公共接口:使用lower_bound
查找坐标,但实际查询时传入原始x
值,因为f(x)
计算不依赖于离散化索引。
9. 注意事项
- 精度问题:如果使用浮点数,需要注意精度误差。通常建议使用整数或高精度浮点数。
- 求最小值:只需将
better
函数的比较逻辑改为<
,并将初始线段的b
设为LLONG_MAX
。 - 线段 vs 直线:上述实现处理的是无限长的直线。如果需要处理有定义域的线段,可以在
f
函数中加入范围检查,或者使用更复杂的变体。 - 常数优化:可以尝试不同的递归方向判断(如比较右端点),有时能获得更好的性能。
10. 总结
李超线段树是一种巧妙的数据结构,利用“中点优势”和直线相交性质,高效地解决了动态插入线段并单点查询最值的问题。通过离散化,它可以处理大范围的坐标。理解其核心思想——“在每个区间维护中点最优的线段”——是掌握它的关键。