数据结构专题-学习笔记:李超线段树
1. 前言
本篇博文是博主学习李超线段树的学习笔记。
2020/12/21 的时候我在 数据结构专题-学习笔记:线段树 中说要写 5 篇线段树博客,第 5 篇是李超线段树,结果鸽子到现在才写( 众所周知这篇博文是线段树算法总结&专题训练5
李超线段树也是一种线段树,但是这个线段树主要做的是这样的一类问题:在二维平面上插入若干条线段,求 \(x=k\) 时候,这条线交到所有线段的 \(y\) 坐标的最大或最小值。
2. 详解
更加具体一点的,李超线段树可以解决这样的一类问题:
给出个二位平面,有如下两个操作:
- 插入一条线段 \(AB,A(x_1,y_1),B(x_2,y_2)\)。
- 查询 \(x=k\) 的时候这条直线与所有线段交点的 \(y\) 坐标最大值 / 最小值。
注意上述最大值和最小值一般情况下不会同时出现询问,一操作也可以改成插入直线然后给出斜率 \(k\) 和截距 \(b\),实际上李超线段树要用到的也就是这玩意。
下面认为插入的是一条直线,查询的是最大值。
我们设一个区间的优势线段是这个区间的大多数点有可能取到最大值(一般来讲是左右两个区间中有半个区间能取到)。
考虑现在老区间上有一个优势线段,现在我们新丢进去了一个线段,我们就需要讨论这个线段要不要换掉。
设老直线斜率截距是 \((k_{old},b_{old})\),新直线是 \((k_{new},b_{new})\),当前区间中点是 \(mid\),图片中老直线是黑色,新直线是红色,带入 \(x=mid\) 时老直线计算结果时 \(val_{old}\),新直线是 \(val_{new}\),绿色线是 \(x=mid\)。
没错李超线段树还是配合图比较好 强烈谴责没图的李超线段树博客
若 \(k_{old}<k_{new}\)
- \(val_{old}>val_{now}\)
观察上图后发现我们的旧线段实际上还是优势线段,因为有超过半数的点相对于新线段而言在旧线段上能够取到最大值。
然而对于右半区间而言,旧线段不一定就是优势线段(毕竟新线段有一部分是在老线段上面的),于是我们需要将新线段传到右半区间里面进行更新。
- 如果 \(val_{old}<val_{now}\)
发现此时新线段应当是优势线段,因为有超过半数的点相对旧线段而言能取到最大值,但是旧线段此时就有可能成为左半区间的优势线段,因此将旧线段下传到左半区间进行更新。
若 \(k_{old}>k_{new}\):
- \(val_{old}>val_{new}\)
发现旧线段还是优势线段,但是新线段可能是左半区间的优势线段,故将新线段下传到左半区间更新。
- \(val_{old}<val_{new}\)
发现新线段应当成为优势线段,但老线段可能会成为右半区间优势线段,所以将老线段下传到右半区间更新。
若 \(k_{old}=k_{now}\):
这个地方就没必要画图了,因为两条线平行,直接算下 \(val_{old},val_{now}\) 中哪条大,取大的那条线就可以了。
上面讨论了 \(k_{old},k_{now}\) 与 \(val_{old},val_{now}\) 相对大小关系时优势线段的更新,接下来有几个问题统一回答一下:
- 叶子节点如何更新?
直接将这个点带入两条直线,哪条大取哪条。 - 为什么当新线段成为优势线段的时候,老线段需要下传继续更新?
我们知道线段都是一条条插入进去的,所以当老线段插入到这个区间的时候,它在当时是新线段,既然它能成为优势线段,那么老老线段就被替代了。
而无论哪条线段被替代,总有半个区间是不会有线段下传的,假设是左半区间,现在有新线段来代替老线段,那么对于左半区间而言,由于之前我们不知道老线段是不是左半区间的优势线段,我们就需要将老线段传到左半区间更新。
实际上,这里也已经说明了对于叶子节点而言,不一定这个叶子节点的优势线段就是这个点取到最大值的地方,可能有更好的优势线段,只是上面没有传下来而已,所以在查询最大值的时候我们要将路上经过的每一条优势线段都计算一遍,取最大值才是答案。 - 上述所有图都只讨论了 \(k>0\) 的情况,对于 \(k \leq 0\) 的情况呢?
因为 \(k>0\) 的情况比较好理解所以我用了这个情况,实际上对于 \(k \leq 0\) 的情况上述结论都是成立的,这个可以通过大力分类讨论或者是结合旋转理解,旋转理解法可以参考参考资料 2 理解。
以上三个问题解决之后,整个过程就没有问题了。
正式写李超线段树的时候一般不大会有人直接背下代码因为容易弄错(滚瓜烂熟的除外),但是只要画出上面 4 张图我估计 10min 就打好板子了吧。
现在丢一道例题:P4254 [JSOI2008]Blue Mary开公司。
发现这道题就是板子题,但是注意一下这道题第 \(x\) 天计算是 \(k(x-1)+b\) 不是 \(kx+b\),这个地方看代码中的 Calc
函数即可。
GitHub:CodeBase-of-Plozia。
Code:
/*
========= Plozia =========
Author:Plozia
Problem:P4254 [JSOI2008]Blue Mary开公司
Date:2022/1/8
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
typedef double db;
const int MAXN = 1e5 + 5;
int q, cntn;
char str[20];
db k[MAXN], b[MAXN];
struct node
{
int l, r, tag;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define tag(p) tree[p].tag
}tree[MAXN << 2];
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = sum * 10 + (ch ^ 48);
return sum * fh;
}
db Max(db fir, db sec) { return (fir > sec) ? fir : sec; }
db Min(db fir, db sec) { return (fir < sec) ? fir : sec; }
db Calc(int p, db x) { return k[p] * (x - 1) + b[p]; }
void Build(int p, int l, int r)
{
l(p) = l, r(p) = r; if (l == r) return ;
int mid = (l + r) >> 1; Build(p << 1, l, mid); Build(p << 1 | 1, mid + 1, r);
return ;
} // 建树实际上可以不需要,直接在 Change / Ask 里面下传 l(p),r(p) 即可
void Change(int p, int Num)
{
if (tag(p) == 0) { tag(p) = Num; return ;}
if (l(p) == r(p))
{
if (Calc(tag(p), l(p)) < Calc(Num, l(p))) { tag(p) = Num; }
return ;
}
int mid = (l(p) + r(p)) >> 1;
double s1 = Calc(tag(p), mid), s2 = Calc(Num, mid); // 计算中点值
if (k[tag(p)] < k[Num]) // 按照斜率分类
{
if (s1 <= s2) { Change(p << 1, tag(p)); tag(p) = Num; }
else Change(p << 1 | 1, Num);
}
else if (k[tag(p)] > k[Num])
{
if (s1 <= s2) { Change(p << 1 | 1, tag(p)); tag(p) = Num; }
else Change(p << 1, Num);
}
else if (b[tag(p)] < b[Num]) tag(p) = Num; // 对 k = 0 单独讨论
}
db Ask(int p, int x)
{
if (l(p) == r(p)) return Calc(tag(p), x);
int mid = (l(p) + r(p)) >> 1;
if (x <= mid) return Max(Ask(p << 1, x), Calc(tag(p), x));
else return Max(Ask(p << 1 | 1, x), Calc(tag(p), x));
} // 注意每个点的优势线段都需要计算一遍
int main()
{
Build(1, 1, 50000); q = Read();
while (q--)
{
scanf("%s", str + 1);
if (str[1] == 'P')
{
++cntn; scanf("%lf %lf", &b[cntn], &k[cntn]);
Change(1, cntn);
}
else
{
int t = Read(); db ans = Ask(1, t);
if (cntn == 0) printf("0\n");
else printf("%d\n", (int)ans / 100);
}
}
return 0;
}
很好到这里你已经会了全局插入求最大值。
求最小值的操作是类似的,还是需要上面四张图,只是优势线段要改一下。
如果是区间插入呢?
我们需要考虑当前节点是否被插入区间完全覆盖,如果是就变成了全局插入,如果不是那么我们单独下压至左半区间或者右半区间即可。
可以发现,全局插入时李超线段树的复杂度是 \(O(n \log n)\) 的,但是区间插入时复杂度是 \(O(n \log^2 n)\) 的。
3. 应用
这玩意的主要应用是斜率优化,因为斜率优化的时候我们会将式子整理成 \(y=kx+b\) 的形式,这个时候如果我们整理式子时 \(k,b\) 与 \(j\) 有关,\(x,y\) 与 \(i\) 有关并且 \(f_i\) 在左边(这里认为左边的 \(y\) 除了 \(f_i\) 都是常数项),做完一个点的 DP 值后我们就可以将 \((k,b)\) 看成条全局的线段丢到李超线段树里面,然后查值即可,注意动态开点。
这么说有点不清楚,来道例题:P3628 [APIO2010]特别行动队。
这道题可以单调队列斜率优化,但是斜率优化更一般的做法是李超线段树 / 维护凸包,但是谁来写凸包,李超线段树不是好写得多(
先做暴力 DP,设 \(f_i\) 表示前 \(i\) 个士兵编队的最大战斗力,有转移方程:
上述中 \(s_i=\sum_{k=1}^{i}x_k\),含义就是将 \(j+1\) 和 \(i\) 的编做一段。
发现这个转移是 1D/1D 的,然后可以斜率优化,于是开始拆式子。
拆完之后我们通过整理,可以得到这样的一个用单调队列优化的式子:
因为这里 \(k=2as_i\) 是递增的,所以可以直接单调队列优化,但是这显然没法李超线段树维护,于是我们换下式子:
这个时候式子被我们整理成了 \(y=kx+b\) 的形式,其中 \(y=f_i-as_i^2-bs_i-c,k=-2as_j,x=s_i,b=f_j+as_j^2-bs_j\)。
注意到上面的式子中除了 \(f_i\) 其他都是定值,而我们要求的是 \(f_i\) 最大值,因此就要考虑让 \(y\) 最大。
于是我们发现这个方程就是给定直线 \(x=s_i\) 求这条线与当前所有 \(j<i\) 的线段相交,纵坐标的最大值,\((k,b)=(-2as_j,f_j+as_j^2-bs_j)\)。
这不就可以李超线段树维护了吗?对于每一个 \(f_i\) 做完之后我们都将这个点对应的 \((k,b)\) 全局插入,利用动态开点李超线段树查询即可,注意开 long long
,复杂度是 \(O(n \log n)\)。
实际上用李超线段树做斜率优化才是斜率优化题更加一般的做法,本质还是数据结构优化 DP。
当然注意一下有些出题人可能会卡你李超线段树斜率优化而放过单调队列斜率优化,这个时候还是要老老实实利用线性规划推结论,因为李超线段树多一个 \(\log\)。但是真的有这样的出题人吗?
4. 总结
李超线段树就是一种可以维护线段的线段树,如果是全局修改的话复杂度是 \(O(n \log n)\),区间修改是 \(O(n \log^2 n)\)。
实际上因为李超线段树是线段树,所以照样可以可持久化动态开点之类的,根据情况灵活选择。