浅析李超线段树
大家好啊,我是 YAOhc2012,今天给大家来点想看的东西啊。
前置知识:线段树。
1.李超线段树的原理
李超线段树,可以解决在二维平面上插入形如 \(kx+b\) 的直线并查询横 / 纵坐标为 \(x\) 时的最大值等问题,这也使得它能够很好地解决大部分斜率优化的 dp 题目。
先从一道例题开始:
::::info[例题 P4097]{open}
要求在平面直角坐标系下维护两个操作:
- 在平面上加入一条线段。记第 \(i\) 条被插入的线段的标号为 \(i\)。
- 给定一个数 \(k\),询问与直线 \(x = k\) 相交的线段中,交点纵坐标最大的线段的编号。
对于 \(100\%\) 的数据,保证 \(1 \leq n \leq 10^5\)。
::::
这道题用普通的线段树解决十分困难,考虑用李超线段树解决。
插入
在李超线段树中,对于区间 \([l,r]\),维护的是 \(mid\) 即上最大的值对应的线段编号。
举个例子,假设平面中有一条线段,区间的答案为 \(1\):

如果我们加入一条线段:

此时的区间答案变成 \(2\),但是,并不是这个区间内所有位置的 \(2\) 线更优,所以我们要递归下去更新。不难想出这样一种方法:
- 判断位置为 \(l\) 的线谁更优,如果插入的线更优,那么就跳到左儿子去更新。
- 判断位置为 \(r\) 的线谁更优,如果插入的线更优,那么就跳到右儿子去更新。
- 否则,两个儿子都更新一次。
这个做法看着很美好,实则不然。考虑如下一个例子

插入 \(2\) 时,此时线段树中只有 \([l,r]\) 这个区间与 \([r,mid]\) 里面的区间的答案更新成了线 \(2\)。
如果再插入这样的一条线段:

此时由于 \([mid,r]\) 区间内线 \(3\) 更优,会导致线 \(2\) 先前更新的所有答案都被覆盖了,而实际上,线 \(2\) 在 \([k,mid-1]\) 中的答案也是最优的,这样做答案就出错了。
于是,我们可以使用交换大法。如果新插入的线段比原答案更优,我们直接把插入的线段换成原答案,则答案则变成新插入的线。
为什么这样是对的呢,还是之前那个例子:

更新线 \(3\) 的时候,虽然线 \(2\) 的答案被覆盖掉了,但是,由于 \(l\) 处线 \(2\) 更优,所以 \([l,mid]\) 区间内要更新线 \(2\)

这样就保证了插入的正确性。
于是,插入的步骤如下:
- 判断 \(mid\) 位置哪个线更优,若新插入的线更优,则交换新线和答案,此时新线变成了两者之间的较劣者。
- 判断 \(l\) 位置哪个线更优,若新插入的线更优,说明 \([l,mid]\) 区间内一定有一部分答案为新线,则递归去左儿子。
- 判断 \(r\) 位置哪个线更优,若新插入的线更优,说明 \([mid+1,r]\) 区间内一定有一部分答案为新线,则递归去右儿子。
- 若新线两个都不满足,直接结束。
插入的复杂度为 \(O(\log n)\)。
查询
因为插入只改了大区间,所以查询时需要 pushdown,可是直接 pushdown 是十分困难的,因为并不是大区间的答案更优,当然也不一定是小区间的答案更优。
于是,李超线段树就有了这样一个性质:没有 pushdown。
虽然 pushdown 的问题解决了,但是查询答案却变难了。查询时需要在每一层上都取一遍答案,才能保证结果的正确性,其它的和普通的线段树就没区别了。
所以说,李超线段树是懒标记永久化的线段树。
查询的复杂度也为 \(O(\log n)\)。
代码
例题要求的是插入线段,所以需要将插入拆成 \(\log n\) 个区间,即在代码中的 update2 函数,但同时,复杂度会退化到 \(O(n\log^2n)\)。
由于这道题需要询问编号,所以查询时要同时存编号,所以代码写的很史,见谅:
#include<bits/stdc++.h>
#define pdi pair<double,int>
#define pdd pair<double,double>
#define f first
#define s second
#define mkp(x,y) make_pair(x,y)
using namespace std;
const int P=39989,P2=1e9;
const double eps=1e-9;//防止精度误差
pdd a[100010];
int cmp(double x,double y){
if(x-y>eps)return 1;
if(y-x>eps)return -1;
return 0;
}
pdi cmp2(pdi x,pdi y){
if(cmp(x.f,y.f)==1)return x;
else if(cmp(x.f,y.f)==-1)return y;
return (x.s<y.s?x:y);
}
double calc(int x,int y){//计算a[x]函数在y处的取值
return a[x].s+a[x].f*y;
}
struct LCtree{
#define ls(x) x<<1
#define rs(x) x<<1|1
int t[200010];
void update(int l,int r,int fa,int x){
int mid=(l+r)>>1,mid2=cmp(calc(x,mid),calc(t[fa],mid));
if(mid2==1||(!mid2&&x<t[fa]))swap(t[fa],x);//比较mid
int tl=cmp(calc(x,l),calc(t[fa],l)),tr=cmp(calc(x,r),calc(t[fa],r));
if(tl==1||(!tl&&x<t[fa]))update(l,mid,ls(fa),x);//比较l
if(tr==1||(!tr&&x<t[fa]))update(mid+1,r,rs(fa),x);//比较r
return;
}
void update2(int l,int r,int L,int R,int fa,int x){
if(l<=L&&R<=r){
update(L,R,fa,x);
return;
}
int mid=(L+R)>>1;
if(l<=mid)update2(l,r,L,mid,ls(fa),x);
if(r>mid)update2(l,r,mid+1,R,rs(fa),x);
return;
}
pdi query(int p,int l,int r,int fa){
if(p<l||p>r)return {0,-1};
double sum=calc(t[fa],p);//每层都要更新
if(l==r)return {sum,t[fa]};
int mid=(l+r)>>1;
if(p<=mid)return cmp2({sum,t[fa]},query(p,l,mid,ls(fa)));
return cmp2({sum,t[fa]},query(p,mid+1,r,rs(fa)));
}
#undef ls
#undef rs
}T;
int main(){
int n,lst=0,cnt=0;
cin>>n;
for(int i=1;i<=n;i++){
int op,x,y,x2,y2;
cin>>op>>x;
if(op==1){
cin>>y>>x2>>y2;
x=(x+lst-1+P)%P+1,x2=(x2+lst-1+P)%P+1,y=(y+lst-1+P2)%P2+1,y2=(y2+lst-1+P2)%P2+1;
if(x>x2)swap(x,x2),swap(y,y2);
if(x==x2)a[++cnt]={0,max(y,y2)};
else a[++cnt]={1.0*(y2-y)/(x2-x),y-1.0*(y2-y)/(x2-x)*x};//计算斜率和截距
T.update2(x,x2,1,P,1,cnt);
}else{
x=(x+lst-1+P)%P+1;
cout<<(lst=T.query(x,1,P,1).s)<<endl;
}
}
return 0;
}
2. 斜率优化
李超线段树可以用在大部分斜率优化 dp 的题目上。
考虑一个形如下面这样的转移方程:
此时单调队列优化 dp 是很难的,但可以用斜率优化来解决。
但是,什么决策单调性维护凸包我都看不懂怎么办。
我们设 \(k=dp_j\),\(b=a_j\),则转移方程则变为
显然这是一个李超线段树的板子,只要每次推 \(dp_i\) 时查询和插入一下即可。
这样的时间复杂度为 \(O(n\log n)\),只要不卡 \( \log\) 这题就能过。
所以,直接上实战,练起来!
2.1 例题 1:P4655 [CEOI 2017] Building Bridges
::::info[题面]{open}
有 \(n\) 根柱子依次排列,每根柱子都有一个高度。第 \(i\) 根柱子的高度为 \(h_i\)。
现在想要建造若干座桥,如果一座桥架在第 \(i\) 根柱子和第 \(j\) 根柱子之间,那么需要 \((h_i-h_j)^2+\sum_{k=i+1}^{j-1}w_k\) 的代价。
现在需要求出通过桥梁把第 \(1\) 根柱子和第 \(n\) 根柱子连接的最小代价。注意桥梁不能在端点以外的任何地方相交。
\(2\le n\le 10^5\),\(0\le h_i,\vert w_i\vert\le 10^6\)。
::::
首先把暴力 dp 的式子推出来:
记 \(s_i\) 为 \(\sum_{j=1}^{i}w_i\),再拆个括号得:
把与 \(j\) 无关的移出去:
设 \(k=-2h_j\),\(b=dp_j+{h_j}^2-s_j\)。
于是式子就变成:
于是这道题就变成了李超线段树的板子,时间复杂度 \(O(n\log V)\),空间复杂度 \(O(V)\)。
因为 \(k\) 和 \(b\),以及查询的 \(h_i\) 都是整数,而且不用查询编号,所以代码会简洁很多:
::::info[代码]
#include<bits/stdc++.h>
#define int long long
#define pii pair<int,int>
#define f first
#define s second
#define mkp(x,y) make_pair(x,y)
#define qry(x,y) (line[x].f*y+line[x].s)
using namespace std;
const int P=39989,P2=1e9;
int a[100010],b[100010],dp[100010];
pii line[100010];
struct LCtree{
#define ls(x) x<<1
#define rs(x) x<<1|1
int t[20000010];
void update(int l,int r,int fa,int x){
int mid=(l+r)>>1,flag=(qry(x,mid)<qry(t[fa],mid));
if(flag)swap(t[fa],x);
int fl=(qry(x,l)<qry(t[fa],l)),fr=(qry(x,r)<qry(t[fa],r));
if(fl)update(l,mid,ls(fa),x);
if(fr)update(mid+1,r,rs(fa),x);
return;
}
int query(int p,int l,int r,int fa){
int sum=qry(t[fa],p);
if(l==r)return sum;
int mid=(l+r)>>1;
if(p<=mid)return min(sum,query(p,l,mid,ls(fa)));
return min(sum,query(p,mid+1,r,rs(fa)));
}
#undef ls
#undef rs
}T;
signed main(){
int n;
cin>>n;
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=1;i<=n;i++)cin>>b[i],b[i]+=b[i-1];
dp[1]=0,line[0]={1e12,1e12},line[1]={-2*a[1],a[1]*a[1]-b[1]};
T.update(0,4e6,1,1);
for(int i=2;i<=n;i++){
dp[i]=b[i-1]+a[i]*a[i]+T.query(a[i],0,4e6,1);
line[i]={-2*a[i],dp[i]+a[i]*a[i]-b[i]};
T.update(0,4e6,1,i);
}
cout<<dp[n]<<endl;
return 0;
}
:::::
2.2 例题 2:P5785 [SDOI2012] 任务安排
::::info[题面]{open}
机器上有 \(n\) 个需要处理的任务 \(1 , 2 , 3 \cdots n\)。这 \(n\) 个任务被分成若干批,每批包含相邻的若干任务。从时刻 \(0\) 开始,这些任务被分批加工,第 \(i\) 个任务单独完成所需的时间是 \(T_i\)。在每批任务开始前,机器需要启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和。
注意,同一批任务将在同一时刻完成。每个任务的费用是它的完成时刻乘以一个费用系数 \(C_i\)。
请确定一个分组方案,使得总费用最小。
\(1 \le n \le 3 \times 10^5\),\(1 \le s \le 2^8\),$ \left| T_i \right| \le 2^8$,\(0 \le C_i \le 2^8\)。
::::
依旧先想暴力 dp 的式子,设 \(S_i\) 为 \(\sum_{j=1}^{i}T_i\),\(S2_i=\sum_{j=1}^{i}C_i\),则可以得出:
拆括号:
把与 \(j\) 无关的移出去:
设 \(k=-S2_{j-1}\),\(b=dp_j-sS2_{j-1}\),得:
于是就能用李超线段树了。
但是,这道题,\(T_i\) 有可能是负数,这无疑是令人愤怒的。所以这道题需要用离散化将查询的 \(S_i\) 转成下标。
::::info[代码]
#include<bits/stdc++.h>
#define int long long
#define pii pair<int,int>
#define f first
#define s second
#define qry(x,y)(line[x].s+line[x].f*y)
using namespace std;
const int inf=1e18;
int a[300010],b[300010],p[300010],t[300010],dp[300010];
pii line[300010];
struct LCtree{
#define ls(x) x<<1
#define rs(x) x<<1|1
int t[5000010];
void update(int l,int r,int fa,int k){
int mid=(l+r)>>1;
bool f=(qry(k,p[mid])<qry(t[fa],p[mid]));
if(f)swap(t[fa],k);
bool tl=(qry(k,p[l])<qry(t[fa],p[l])),tr=(qry(k,p[r])<qry(t[fa],p[r]));
if(tl)update(l,mid,ls(fa),k);
if(tr)update(mid+1,r,rs(fa),k);
}
int query(int pos,int l,int r,int fa){
if(!fa)return inf;
if(l==r)return qry(t[fa],p[pos]);
int mid=(l+r)>>1;
if(pos<=mid)return min(qry(t[fa],p[pos]),query(pos,l,mid,ls(fa)));
return min(qry(t[fa],p[pos]),query(pos,mid+1,r,rs(fa)));
}
#undef ls
#undef rs
}T;
signed main(){
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i]>>b[i];
a[i]+=a[i-1],b[i]+=b[i-1],p[i]=a[i];
}
sort(p+1,p+n+1);
int tot=unique(p+1,p+n+1)-p-1;
for(int i=1;i<=n;i++)t[i]=lower_bound(p+1,p+tot+1,a[i])-p;
line[0]={0,0};
T.update(1,tot,1,0);
for(int i=1;i<=n;i++){
dp[i]=a[i]*b[i]+m*b[n]+T.query(t[i],1,tot,1);
line[i]={-b[i],dp[i]-m*b[i]};
T.update(1,tot,1,i);
}
cout<<dp[n]<<'\n';
return 0;
}
::::
时间复杂度 \(O(n\log n)\),空间复杂度 \(O(n)\)。
2.3 例题 3:P3195 [HNOI2008] 玩具装箱
::::info[题面]{open}
有编号为 \(1 \cdots n\) 的 \(n\) 件玩具,第 \(i\) 件玩具的长度为 \(C_i\)。
现在要将所有的玩具分为若干个连续的区间。区间 \([l,r]\) 的长度为 \(len=r-l+\sum_{i=k}^{r}C_i\)。分出这个区间的代价为 \((len-L)^2\),其中 \(L\) 是给定的值。
现在需要求将 \(n\) 件玩具分成若干个区间的最小代价。
\(n\le5\times10^4\),\(1\le C_i,L\le10^7\)。
::::
首先还是列出暴力 dp 的式子:
然后再设前缀和 \(s_i=\sum_{j=1}^{i}C_i\),于是式子可以变成:
直接拆平方比较繁琐,于是可以设 \(X_i=s_i+i\),\(Y_i=s_i+i+1+L\),于是式子变成:
再把括号拆掉:
把 \({X_i}^2\) 提出来:
再设 \(k=-2Y_j\),\(b=dp_j+{Y_j}^2\),得:
于是这道也就变成了李超线段树的板子。
但是,这道题的 \(C_i\) 达到了 \(10^7\),\(X_i\) 则达到了 \(5\times10^{11}\) 的级别。
这时就要用到动态开点了,所以这道题的时间复杂度 \(O(n\log nV)\),空间复杂度 \(O(n\log nV)\),因为这道题的 \(n\) 很小,小到 \(O(n^2)\) 的暴力都可以过,不存在爆空间的问题。
::::info[代码]
#include<bits/stdc++.h>
#define int __int128
#define ll long long
#define pii pair<int,int>
#define f first
#define s second
#define mkp(x,y) make_pair(x,y)
#define qry(x,y) (line[x].s+line[x].f*y)
using namespace std;
const int inf=1e18;
ll a[50010],dp[50010];
int b[50010],c[50010];
pii line[50010];
struct LCtree{
#define ls(x) x<<1
#define rs(x) x<<1|1
int cnt=1;
struct node{
int ls,rs,x;
}t[5000010];
void update(int l,int r,int fa,int k){
int mid=(l+r)>>1;
bool f=(qry(k,mid)<qry(t[fa].x,mid));
if(f)swap(t[fa].x,k);
bool tl=(qry(k,l)<qry(t[fa].x,l)),tr=(qry(k,r)<qry(t[fa].x,r));
if(tl)t[fa].ls=(!t[fa].ls?++cnt:t[fa].ls),update(l,mid,t[fa].ls,k);
if(tr)t[fa].rs=(!t[fa].rs?++cnt:t[fa].rs),update(mid+1,r,t[fa].rs,k);
return;
}
int query(int p,int l,int r,int fa){
if(!fa)return inf;
if(l==p&&r==p)return qry(t[fa].x,p);
int mid=(l+r)>>1;
if(p<=mid)return min(qry(t[fa].x,p),query(p,l,mid,t[fa].ls));
return min(qry(t[fa].x,p),query(p,mid+1,r,t[fa].rs));
}
#undef ls
#undef rs
}T;
signed main(){
ll n,L;
cin>>n>>L;
L++;
for(int i=1;i<=n;i++)cin>>a[i],a[i]+=a[i-1],b[i]=a[i]+i,c[i]=a[i]+i+L;
line[0]={-2*L,L*L};
T.update(0,1e12,1,0);
for(int i=1;i<=n;i++){
dp[i]=b[i]*b[i]+T.query(b[i],0,1e12,1);
line[i]={-2*c[i],dp[i]+c[i]*c[i]};
T.update(0,1e12,1,i);
}
cout<<dp[n]<<endl;
return 0;
}
:::::
3. 作业
4. 闲话
一百万年没有写文章了。
如果文章有任何错误,麻烦在评论区指正,谢谢。

浙公网安备 33010602011771号