李超线段树是一种用于维护动态插入线段(或直线),并支持 在指定点查询所有线段在该点的最大值(或最小值) 的数据结构。
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*n
tree.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 的位置
// 但查询仍然有效,因为树的结构支持任意 x
return 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 = x
lct.insert(Line(0, 5)); // y = 5
lct.insert(Line(-1, 10)); // y = -x + 10
lct.insert(Line(2, -5)); // y = 2x - 5
// 进行查询
cout << "Query at x=1: " << lct.query(1) << endl; // 应该是 max(1, 5, 9, -3) = 9
cout << "Query at x=3: " << lct.query(3) << endl; // 应该是 max(3, 5, 7, 1) = 7
cout << "Query at x=5: " << lct.query(5) << endl; // 应该是 max(5, 5, 5, 5) = 5
cout << "Query at x=7: " << lct.query(7) << endl; // 应该是 max(7, 5, 3, 9) = 9
return 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. 总结
李超线段树是一种巧妙的数据结构,利用“中点优势”和直线相交性质,高效地解决了动态插入线段并单点查询最值的问题。通过离散化,它可以处理大范围的坐标。理解其核心思想——“在每个区间维护中点最优的线段”——是掌握它的关键。