浅析李超线段树

大家好啊,我是 YAOhc2012,今天给大家来点想看的东西啊。

\[\Huge \sf 浅析李超线段树 \]

前置知识:线段树

1.李超线段树的原理

李超线段树,可以解决在二维平面上插入形如 \(kx+b\) 的直线并查询横 / 纵坐标为 \(x\) 时的最大值等问题,这也使得它能够很好地解决大部分斜率优化的 dp 题目。

先从一道例题开始:

::::info[例题 P4097]{open}
要求在平面直角坐标系下维护两个操作:

  1. 在平面上加入一条线段。记第 \(i\) 条被插入的线段的标号为 \(i\)
  2. 给定一个数 \(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_i=\min / \max\{dp_j+a_i a_j\} \]

此时单调队列优化 dp 是很难的,但可以用斜率优化来解决。

但是,什么决策单调性维护凸包我都看不懂怎么办。

我们设 \(k=dp_j\)\(b=a_j\),则转移方程则变为

\[dp_i=\min\{ka_i+b\} \]

显然这是一个李超线段树的板子,只要每次推 \(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 的式子推出来:

\[dp_i=\min\{dp_j+(h_i-h_j)^2+\sum_{k=i+1}^{j-1}w_k\} \]

\(s_i\)\(\sum_{j=1}^{i}w_i\),再拆个括号得:

\[dp_i=\min\{dp_j+{h_i}^2+{h_j}^2-2h_ih_j+s_{i-1}-s_j\} \]

把与 \(j\) 无关的移出去:

\[dp_i={h_i}^2+s_{i-1}+\min\{dp_j+{h_j}^2-2h_ih_j-s_j\} \]

\(k=-2h_j\)\(b=dp_j+{h_j}^2-s_j\)

于是式子就变成:

\[dp_i={h_i}^2+s_{i-1}+\min\{kh_i+b\} \]

于是这道题就变成了李超线段树的板子,时间复杂度 \(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\),则可以得出:

\[dp_i=\min\{dp_j+S_i(S2_i-S2_j)+s(S2_n-S2_{j-1})\} \]

拆括号:

\[dp_i=\min\{dp_j+S_iS2_i-S_iS2_j+sS2_n-sS2_{j-1}\} \]

把与 \(j\) 无关的移出去:

\[dp_i=S_iS2_i-sS2_n+\min\{dp_j-S_iS2_j-sS2_{j-1}\} \]

\(k=-S2_{j-1}\)\(b=dp_j-sS2_{j-1}\),得:

\[dp_i=S_iS2_i+sS2_n-min\{kS_i+b\} \]

于是就能用李超线段树了。

但是,这道题,\(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 的式子:

\[dp_i=\min\{dp_j+(\sum_{k=j}^iC_i+i-j-L)^2\} \]

然后再设前缀和 \(s_i=\sum_{j=1}^{i}C_i\),于是式子可以变成:

\[dp_i=\min\{dp_j+(s_i-s_{j-1}+i-j-L)^2\} \]

直接拆平方比较繁琐,于是可以设 \(X_i=s_i+i\)\(Y_i=s_i+i+1+L\),于是式子变成:

\[dp_i=\min\{dp_j+(X_i-Y_j)^2\} \]

再把括号拆掉:

\[dp_i=\min\{dp_j+{X_i}^2+{Y_j}^2-2X_iY_j\} \]

\({X_i}^2\) 提出来:

\[dp_i={X_i}^2+\min\{dp_j+{Y_j}^2-2X_iY_j\} \]

再设 \(k=-2Y_j\)\(b=dp_j+{Y_j}^2\),得:

\[dp_i={X_i}^2+\min\{kX_i+b\} \]

于是这道也就变成了李超线段树的板子。

但是,这道题的 \(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. 作业

  1. P4360 [CEOI 2004] 锯木厂选址
  2. P3628 [APIO2010] 特别行动队
  3. P6047 丝之割

4. 闲话

一百万年没有写文章了。

如果文章有任何错误,麻烦在评论区指正,谢谢。

posted @ 2026-08-13 11:13  doooge  阅读(5)  评论(0)    收藏  举报