乱谈斜率优化 & 李超线段树

上课讲的时候都听不懂,下课之后还花了若干个小时。我怎么这么菜?我怎么这么菜?我怎么这么菜?

斜率优化

引入

我们在处理各种东西是,难免会遇到这个问题:

问题:我们要计算 \(\max_{1\le j<i \le n}(A(i) \times B(j)+C(i)+D(j)+E)\) 这个式子或是 \(\min_{1\le j<i \le n}(A(i) \times B(j)+C(i)+D(j)+E)\) 这个式子,其中 \(A,B,C,D\) 都是函数,而且互相都是独立的\(E\) 是常数。

容易发现我们的数据结构维护或者是其他方法都很难处理,即使能处理也只能处理简单的情况。但是,这个问题就可以被斜率优化解决。

注意,前面这个要计算的式子只能是外面套一个最值,然后里面是两个东西相乘再加上两个东西相加再加上一个常数。如果不是这个形式,就不能用斜率优化。

不妨设 \(A,B,C,D,E\) 的计算都是 \(O(1)\) 的,显然这个式子可以平方算法来解决。而斜率优化可以更快的解决。(具体时间复杂度要看单调性而定,后面会讲)

思路

斜率优化的第一步还是先枚举 \(i\)然后我们就可以计算 \(A(i)\)\(C(i)\) 了,剩下还剩 \(B(i)\)\(D(i)\)

然后我们就需要想,如何快速计算 \(i\) 对应的最优的 \(j\)。显然 \(E+C(i)\) 可以在最终要计算出答案时再加上,所以就相当于求出 \(B(i) \times A(i)+D(i)\) 的最值。

注意到这个式子很像一个一次函数,所以考虑数形结合,令 \(k=-A(i),x(j)=B(j),y(j)=D(j)\)。然后改个名字,我们要计算的就变成了 \(y(j)-k \times x(j)\) 的最值。(其中 \(1 \le j< i \le n\)

然后再想回一次函数的 \(y-kx\),你能发现什么?

学过函数的应该都知道,这就是 \(b=y-kx\)一次函数的斜截式。

所以这就变成了,有一堆二维平面的点 \((x_j,y_j)(1 \le j < i)\),然后你需要在其中选一个 \(j\),使得 \(y(j)-k \times x(j)\) 最小,也就是 \(y(j)=k \times x(j)+b\) 的截距最小,也就是说,经过 \((x_j,y_j)\) 这个点的斜率为 \(k\) 的直线的截距最小。

然后我们知道 \(k\) 不变,也就是一次函数的斜率不变,也就是说,对于 \(i\) 固定的所有 \(j\),所有的不同只有截距不同。

(斜率优化,之所以是因为发现了对于 \(i\) 所有的 \(j\) 对应的直线的斜率都一样,才叫做“斜率优化”。)


在之后,我们只考虑 \(\min\)。容易发现 \(\max\) 的情况和 \(\min\) 是差不多的,也就是反过来一下而已。

考虑数形结合。虽然我们目前不太知道机器该怎么解决,但是人为判断还是可以的。

那我们先画出来 \(1 \sim i-1\) 的位置中所有的点。

那我们怎么对于一个 \(i\),计算最小截距呢?

这时候假设我们有一条直线,我们只需要把这条直线放在最下方,然后一直往上挪挪挪,一直平移直到碰到点为止。这时候的点就一定是答案的点。(也就是截距最小)

我们不难发现,无论是什么斜率的直线,从最底下开始平移,最终可能第一个碰到的点的集合,就是下凸包的点集。

同理,如果我们要取得是 \(\max\),就是上凸包。


于是我们就发现,对于 \(1 \sim i-1\) 的点,只有下凸包上面的点,才有可能成为最终的决策点。

而我们还可以发现,如果一个点不再属于下凸包的点集,之后也不会属于。所以这是一个类似单调栈的情况。

我们将凸包中相邻的点两两连线。于是我们就会发现这些直线的斜率单调上升。

单调上升你就发现,可以直接二分最佳决策点。

怎么个二分法呢?我们考虑一条直线。这条直线如果第一个碰到了某一个点,则这个点和左边的点的直线的斜率小于它,而和右边的点的直线的斜率大于它。所以我们还需要一个 \(O(\log n)\)

于是,求解答案的部分就解决了。


怎么维护下凸包呢?我们一开始有一个比较 naive 的方法:只需要使用单调栈 \(O(n)\) 维护即可。

具体怎么维护,就是在加入一个点之前,先比较一下和单调栈顶上的两个点的斜率的大小关系,如果就发现可以被替换掉就把栈弹出即可。

就比如:

这样就要弹出栈顶。

而且还有一个细节:如果 \(x\) 相等的话,就需要取最下面的点作为凸包上的点。


但是,这并不对。

这种方法只能维护在左边或者是右边插入的情况,如果在中间插入呢?

因此,我们需要分类讨论一下单调性。

(查询)斜率单调,横坐标单调

因为查询的直线斜率单调,所以,每一个最优的决策点一定是从左往右的。\(O(n)\)

而因为横坐标单调,我们还是可以直接使用单调栈的方法维护下凸包。\(O(n)\)

总复杂度线性。

(查询)斜率不单调,横坐标单调

查询就可以使用二分了。而横坐标还是直接线性即可。

总复杂度 \(O(n \log n)\)

(查询)斜率单调,横坐标不单调

李超线段树显然是一种做法,但是如果存在更加优秀的做法,会在后文提到。

(查询)斜率不单调,横坐标不单调

也可以李超线段树,会在后文提到。

斜率优化 DP

顾名思义就是使用斜率优化来优化 DP。

实际上,斜率优化 DP 就是将

\[\min_{1\le j<i \le n}(A(i) \times B(j)+C(i)+D(j)+E) \]

变成了

\[dp_i=\min_{1\le j<i \le n}(dp_j+A(i) \times B(j)+C(i)+D(j)+E) \]

(这里我们仍然只考虑最小值的情况,最大值和最小值是差不多的。)

又因为 \(i\) 是定值:

\[dp_i=\min_{1\le j<i \le n}(dp_j+A(i) \times B(j)+D(j))+C(i)+E \]

但是 \(D(j)\) 这时候还有点累赘。所以考虑 \(\forall 1 \le i \le n,D(i)+dp_i \to D_i\)

得到

\[dp_i=\min_{1\le j<i \le n}(D(j)+A(i) \times B(j))+(C(i)+E) \]

与此同时,最终得到答案的时候也不要忘记 \(-D(i)\)!!

于是我们的目标还是求解 \(\min_{1\le j<i \le n}(D(j)+A(i) \times B(j))\),直接使用前面讲过的方法即可。


但是还会有几种奇怪的式子,但是也就是加上一点点变数,改改也可以做。

例如这个式子:

\[dp_i=\min_{1\le j<i \le n}(dp_j\times A(i)+A(i) \times B(j)+C(i)+D(j)+E) \]

也可以通过乘法分配律变成:

\[dp_i=\min_{1\le j<i \le n}(A(i) \times (B(j)+dp_j)+C(i)+D(j)+E) \]

于是也可以斜率优化。


但是在这里还是介绍一个小 tips,如何判断一个转移方程可以使用斜率优化:

把右边和 \(j\) 无关的项丢到外面,把只和 \(j\) 有关的数放在一起(\(D(j)\)),\(i,j\) 同时有关的项放一起得到 \(U(i,j)\)
如果 \(U(i,j)\) 可以表示成 \(A(i) \times B(j)\) 的形式,那么可以斜率优化。

斜率优化 DP 板子

声明:该板子只能用于横坐标单调的情况,横坐标不单调的情况会在后面说。

#include <bits/stdc++.h>
using namespace std;
#define int long long
#define N 100010
int n, s, t, c, sumt[N], sumc[N], que[N], dp[N];

int Y(int p) {
	return ???;
}

int X(int p) {
	return ???;
}

int K(int p) {
	return ???;
}

bool cmp1(int a, int b, int k) {
	return (Y(b) - Y(a)) ??? k * (X(b) - X(a));
}

bool cmp2(int a, int b, int c) {
	return (Y(b) - Y(a)) * (X(c) - X(b)) ??? (Y(c) - Y(b)) * (X(b) - X(a));
}

void cal(int x, int y) {
	dp[x] = dp[y] + ???;
}

int binary_search(int left, int right, int k) {
	int res = right ;
	while (left <= right) {
		int mid = (left + right) >> 1;
		if (cmp1(que[mid], que[mid + 1], k) == ??? )
			left = mid + 1;
		else
			right = mid - 1, res = mid;
	}
	return que[res];
}

int work() {
	que[1] = dp[0] = 0;
	for (int i = 1, head = 1, tail = 1; i <= n; ++i) {
		cal(i, binary_search(head, tail, K(i)));
		while (head < tail && cmp2(que[tail - 1], que[tail], i) == true)
			--tail;
		que[++tail] = i;
	}
	return dp[n];
}

我将分段解析。

int Y(int p) {
	return ???;
}

int X(int p) {
	return ???;
}

int K(int p) {
	return ???;
}

这里就是三个式子。分些用来计算纵坐标、横坐标和斜率。

bool cmp1(int a, int b, int k) {
	return (Y(b) - Y(a)) ??? k * (X(b) - X(a));
}

bool cmp2(int a, int b, int c) {
	return (Y(b) - Y(a)) * (X(c) - X(b)) ??? (Y(c) - Y(b)) * (X(b) - X(a));
}

void cal(int x, int y) {
	dp[x] = dp[y] + ???;
}

cmp1 是斜率和直线斜率的比较。

cmp2 是直线之间斜率的比较。

里面的式子可以通过数学推导得到,但是注意要使用 int128

cal 函数就是用来 DP 转移的。

这里就还有一个小细节:cmp1cmp2 中最好写 <= 或是 >=,为的是防止一些奇怪的情况,比如说 x 相等。

int work() {
	que[1] = dp[0] = 0;
	for (int i = 1, head = 1, tail = 1; i <= n; ++i) {
		cal(i, binary_search(head, tail, K(i)));
		while (head < tail && cmp2(que[tail - 1], que[tail], i) == true)
			--tail;
		que[++tail] = i;
	}
	return dp[n];
}

que, head, tail 都是用来模拟单调队列的。

先循环 \(i\)。然后就是三步:

  • 对于 \(i\) 二分找到最优决策点 \(j\)。并完成转移。
  • 尝试将 \(i\) 加入半凸包(这里是上凸包还是下凸包我也不清楚,需要按照题目来定)。弹出所有不合法的点。
  • 加入半凸包。
int binary_search(int left, int right, int k) {
	int res = right ;
	while (left <= right) {
		int mid = (left + right) >> 1;
		if (cmp1(que[mid], que[mid + 1], k) == ??? )
			left = mid + 1;
		else
			right = mid - 1, res = mid;
	}
	return que[res];
}

很多人这里二分查找会出错,这里的细节真的应该好好看看。

首先是 res = right。这是说我们默认了 right 是当前的最优值。为啥呢?因为你每次遇到一个合法点都会 res = mid,最后 res 保存的就是最小的合法点。而我们又知道 right 肯定是一个合法点(因为不存在 right + 1 的位置,也不会存在 rightright + 1 之间的边),所以一开始就可以设 res = right

还有一个细节,就是 que[mid]que[mid + 1]。因为 mid 是一个单调队列中的位置,需要在单调队列中找到对应的数。

细节

一共有好几个细节。

  • \(x(j)\) 不是严格单调的,需要考虑 \(x(j_1) = x(j_2)\) 的特殊情况。
  • 注意使用 __int128,无论是否爆了 long long,都会返回合法的 \(j\)
  • 建议用 <=>=
  • 如果斜率用浮点表示,记得用 long double
  • 初始决策点的加入根据具体问题考虑。

斜率优化本来就稍微偏板子,所以例题不多讲了。

李超线段树

因为是笔者自己记录的东西,所以就直接挂个地址吧。

https://www.cnblogs.com/Shinomiya/p/15560042.html

自已一定要多看!

实际上,李超线段树实际上就是一个分类讨论。

posted @ 2025-08-21 11:32  wusixuan  阅读(21)  评论(0)    收藏  举报