斜率优化的变种 - 凸包优化

斜率优化的变种 - 凸包优化

李超线段树

李超线段树是一个用于维护凸包的数据结构。

作为一棵线段树,它可以在 \(O(\log n)\) 的时间内筛选出修改的区间。

同时,它可以在 \(O(\log n)\) 的时间内完成对标记的处理。

斜率优化

斜率优化是数形结合的经典应用,一般的斜率优化是计算斜率,而我在初学斜率优化时,苦于无法理解斜率优化的本质,因而独立想出了一种另类的“斜率优化”,也许称之为“凸包”优化更加贴切。

“凸包”优化

对于一个一般的 \(\text{DP}\) 转移方程:

\[f_i = \max_j f_j + A(i) + B(j) + C(i) \times D(j) \]

先提出每一次迭代的转移:

\[f_i = f_j + A(i) + B(j) + C(i) \times D(j) \]

简单变换得:

\[f_i - A(i) = D(j) \times C(i) + f_j + B(j) \]

\(y = f_i - A(i), k = D(j), b = f_j + B(j)\),则原式被重构为了 \(y = kx + b\) 的形式。对于固定的 \(i\)\(x\) 为常量,因此原问题被转化为:

已知由 \(n\) 个直线组成的直线集合,给定 \(x = x_0\),求直线集合中所截得的 \(y\) 的最值。

不难想到对直线集合维护其上/下凸包,查询时在凸包上截取即可。

对比传统的斜率优化,凸包优化理解简单,方便结合李超线段树,无需大量计算浮点数,因此常数小且没有舍入误差,同时实现码量与斜率优化差不多,可谓是斜率优化的上位替代。

一些 \(\text{trick}\):

  • 如果插入的直线 \(k\),查询 \(x\) 满足单调性,则可以使用单调栈/队列优化,时间复杂度 \(O(n)\)
  • 如果只有插入的直线 \(k\) 满足单调性,则查询时需要二分凸包顶点,时间复杂度 \(O(n \log n)\)
  • 如果 \(k, x\) 均无单调性则需要使用李超树/平衡树维护凸包,使用李超树码量略大但不容易写挂,时间复杂度 \(O(n \log n)\)

一些坑:

  • 如果是维护单调栈/队列,则交点横坐标一定要 + eps。

例题

[JSOI2011] 柠檬

题意:给定序列 \(s_{1 \dots n}\),将序列分为任意段,对每一段选择一个数值 \(s_0\),其出现此时为 \(t\),则获得该段的贡献 \(s_0 t ^ 2\),求最大化的总贡献。

首先,设 \(c_i\)\(s_i\)\(i\) 处出现过的次数。

首先考虑朴素的 \(\text{DP}\),定义 \(f_i\) 为在 \(i\) 处为一段的结束分界点的最值,可以推出转移:

\[f_i = \max_{s_i = s_j} f_{j - 1} + (c_i - c_j + 1) ^ 2 s_i \]

如何记录满足 \(s_i = s_j\) 的所有的 \(j\)?可以使用邻接表来储存 \(s_i\) 出现的每一个位置,对于每一个 \(i\),遍历所有在 \(i\) 之前被插入同一行的 \(j\) 即可。

考虑优化算法。我们对原转移做转化,可得:

\[\begin{aligned} f_i = \end{aligned} \]

从而转化为 \(y = kx + b\) 的形式。

注意到 \(k\)\(x\) 单调性相反,因此对于每一个 \(s_i\),我们都用单调栈维护一个下凸包,总共维护值域个直线集合。

[NOI2007] 货币兑换

题意:TODO

洛谷的题面给出了提示:证明

必然存在一种最优的买卖方案满足:
每次买进操作使用完所有的人民币,每次卖出操作卖出所有的金券。

因此容易推出转移:

\[f_i = \max_j \begin{cases} f_j \\ a_i f_j A_j + b_i f_j B_j \end{cases} \]

第二个转移包含了两个形如 \(D(j) \times C(i)\) 的式子,看似无法直接套用斜率优化,但只需要对式子左右同时除以 \(a_i\),则原式可化为:

\[\dfrac{f_i}{a_i} = f_j B_j \dfrac{b_i}{a_i} + f_j A_j \]

从而转化为 \(y = kx + b\) 的形式。

注意到 \(x = \dfrac{b_i}{a_i}\) 可能不是整数,可以将所有的的 \(\dfrac{b_i}{a_i}\) 离散化,然后再套用李超线段树处理。

[NOI2019] 回家路线

题意:TODO

这道题朴素的 \(\text{DP}\) 都使我卡了半天,主要是我卡在了 若小猫最终在时刻 z 到达 n 号站点,则烦躁值将再增加 z。 这条限制。

实际上,只需要优化状态设计,把 \(f_i\) 设计为通过第 \(i\) 条边之后的开销最小值(不包含到达时间产生的开销),则可以推出转移:

\[\begin{aligned} \Delta &\leftarrow p_i - q_j \\ f_i &= f_j + A \Delta ^ 2 + B \Delta + C \end{aligned} \]

统计答案时再处理总到达时间产生的开销:

\[\text{ans} = \min_{y_i = n} f_i + q_i \]

这样我们就推导出了朴素的 \(\text{DP}\) 转移。

进一步优化 \(\text{DP}\) 可以考虑使用斜率优化。类似 [JSOI2011] 柠檬,我们对每个结点开一个邻接表,维护 \(n\) 个上凸包。

维护上凸包看似没有单调性,不方便直接套用单调队列,实际上可以把一条边拆为始末两个时间点的操作:

  • 开始时,查询 \(x = x_0\) 与凸包的交点。
  • 结束时,向凸包中插入一条直线。

然后原问题就可以直接对时间排序然后用单调队列维护。

[USACO08MAR] Land Acquisition G

题意:TODO

本题是 P6047 丝之割 的弱化版,\(\text{DP}\) 的推导过程大相径庭,如果对理解 P6047 丝之割 有困难可以先做做这道题目。

P6047 丝之割

题意:有 \(m\) 条弦,一条弦可以抽象为一个二元组 \((u,v)\),你可以进行任意次切割操作,一次切割操作你将选择两个下标 \(i\)\(j\) 满足 \(i,j \in [1,n]\),然后所有满足 \(i < u, v < j\) 的弦 \((u, v)\) 都将被破坏,同时你将付出 \(a_i \times b_j\) 的代价。求破坏所有弦的最小代价和。

练思维的好题,所以丝之歌什么时候出(

Team Cherry,我的 Team Cherry,我只要 Team Cherry 完美的 silksong~


首先有一个重要的性质:对于两根弦 \((u_1, v_1), (u_2, v_2)\),如果满足 \(u_1 < u_2 \wedge v_2 < v_1\),也就是说 \((u_1, v_1)\) 可以切割 \((u_2, v_2)\),则 \((u_2, v_2)\) 无意义,可以忽略。

证明其实是显然的(但是其他题解写的不显然):

不要考虑乱七八糟的几何意义,只考虑代数意义。

对于一条能够切割 \((u_1, v_1)\) 的边 \((u, v)\),由定义可知 \(u < u_1 \wedge v_1 < v\),因此 \(u < u_1 < u_2 \wedge v_2 < v_1 < v\)

这意味着能切割 \((u_1, v_1)\) 的边 \((u, v)\) 一定可以切割 \((u_2, v_2)\),所以 \((u_2, v_2)\) 可以直接忽略,只需考虑切割 \((u_1, v_1)\) 即可。换而言之,切割具有 传递性

根据性质,我们可以先对所以弦排序去掉无意义的弦。

定义 \(f_i\) 为切割完前 \(i\) 条边的最小开销,则容易推出转移:

\[\begin{aligned} p_i &= \min_{k = 1} ^ i a_i \\ q_i &= \min_{k = i} ^ n b_i \\ f_i &= \min_{j = 0} ^ {i - 1} f_j + p_{u_{j + 1} - 1} q_{v_i + 1} \end{aligned} \]

考虑优化。我们可以重定义 \(u_i, v_i\)

\[\begin{aligned} u_i &\leftarrow p_{u_{i + 1} - 1} \\ v_i &\leftarrow q_{v_i + 1} \end{aligned} \]

然后原转移可以被简化为:

\[f_i = u_j v_i + f_j \]

原式是一个一眼的 \(y = kx + b\),同时考虑性质:

  • 原始的 \(u_i,v_i\) 关于 \(i\) 单调增。
  • \(p_i\) 关于 \(i\) 单调减。
  • \(q_i\) 关于 \(i\) 单调增。

因此重定义后,\(k = u_j\) 单调减,\(x = v_i\) 单调增,单调队列维护即可,注意判定斜率相同。

[NOI2014] 购票

给定一棵 \(n\) 个节点的树,我懒得写了。

首先考虑 \(t = 0\) 的部分分,则原问题转化为了一个简单的序列上问题。

定义 \(f_i\) 为走到节点 \(i\) 的最小开销,\(d_i\) 为节点 \(i\)\(1\) 的路径长,则容易推出朴素的转移:

\[f_i = \min_{d_i - d_j \leq l} p_i (d_i - d_j) + q_i \]

简单转化可得:

\[f_i - p_i d_i - q_i = f_j - d_j p_i \quad (d_i - d_j \leq l) \]

则原式被转化为了 \(y = kx + b\) 的形式,注意此题 \(k = d_j\) 具有单调性,但 \(x = p_i\) 没有。

接下来有以下几种做法:

做法 可通过数据点 时间 - 空间复杂度
李超树维护凸包 \(t = 0\) /
可持久化李超树 \(t = 0, 1\) /
树链剖分 + 李超树 \(t = 0, 1\) /
线段树套可持久化李超树 \(t = 0, 1, 2, 3\) \(O(n \log ^ 2 n) - O(n \log ^ 2 n)\)
树链剖分 + 线段树套李超树 \(t = 0, 1, 2, 3\) \(O(n \log ^ 3 n) - O(n \log n)\)
出栈序 + 线段树套李超树 \(t = 0, 1, 2, 3\) \(O(n \log ^ 3 n) - O(n \log n)\)
二分单调队列 \(t = 0, 1\) /
二分可持久化单调队列 \(t = 0, 1, 2, 3\) \(O(n \log ^ 2 n) - O(n \log n)\)

虽然我信仰树剖神教,但我选择 出栈序 + 线段树套李超树 或者 二分可持久化单调队列

(本人曾在 [SDOI2017] 切树游戏 这道题被卡树剖,硬控三天。)

简单补充一下出栈序的性质:

定义 \(u\)出栈序 \(e_u\) 为每个节点结束 \(\text{DFS}\) 的顺序。对于 \(u\)\(u\) 的祖先 \(v\),满足性质:

在新的一轮 \(\text{DFS}\) 第一次搜索到 \(u\) 时,出栈序 \([e_u, e_v]\) 区间所包含的节点有且仅有 \(u\) 到祖先 \(v\) 路径上的节点被访问到,区间内其他的节点仍未被访问到,其他被访问到的节点也不在区间内。

本题细节超级多,建议亲自上手写写。

#include <bits/extc++.h>

#define inline __always_inline
#define lson(u) (son[(u)][0])
#define rson(u) (son[(u)][1])
template <typename T> inline void chkmin(T &x, const T &y) { if (x > y) x = y; }
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar()) x = x * 10 + ch - '0';
}
using pli = std::pair<int64_t, int>;
const int MaxN = 2e5 + 5, MaxP = 1e6 + 5, MaxL = 25, MaxIGT = MaxP * MaxL, MaxOGT = MaxN * 2;
const int64_t inf = 1e18;

int n, t;
int fa[MaxN], p[MaxN];
int64_t d[MaxN], q[MaxN], l[MaxN], f[MaxN];
std::vector<int> graph[MaxN];
struct line_t
{
	int64_t k, b;
	inline int64_t operator()(int x) { return k * x + b; }
};
struct inner_tree_t
{
	int son[MaxIGT][2], top = 1;
	line_t line[MaxIGT];
	void insert(int u, int l, int r, line_t f)
	{
		int mid = l + r >> 1;
		if (line[u](mid) > f(mid)) std::swap(line[u], f);
		if (r - l == 1) return;
		if (line[u](l) > f(l)) 
			lson(u) ? insert(lson(u), l, mid, f) : (line[lson(u) = top++] = f, void());
		else if (line[u](r - 1) > f(r - 1)) 
			rson(u) ? insert(rson(u), mid, r, f) : (line[rson(u) = top++] = f, void());
	}
	int64_t min(int u, int l, int r, int x)
	{
		if (!u) return inf;
		int64_t ans = line[u](x);
		if (r - l == 1) return ans;
		int mid = l + r >> 1; 
		if (x < mid) chkmin(ans, min(lson(u), l, mid, x));
		else chkmin(ans, min(rson(u), mid, r, x));
		return ans;
	}
};
struct outer_tree_t
{
	int son[MaxOGT][2], top = 2, rt[MaxOGT];
	inner_tree_t tree;
	void build(int u, int l, int r)
	{
		tree.line[rt[u] = tree.top++] = {0, inf};
		if (r - l == 1) return;
		build(lson(u) = top++, l, l + r >> 1), build(rson(u) = top++, l + r >> 1, r);
	}
	void insert(int u, int l, int r, int x, const line_t &f)
	{
		tree.insert(rt[u], 0, MaxP, f); 
		if (r - l == 1) return;
		int mid = l + r >> 1;
		if (x < mid) insert(lson(u), l, mid, x, f);
		else insert(rson(u), mid, r, x, f);
	}
	int64_t min(int u, int l, int r, int pl, int pr, int x)
	{
		if (pl <= l && r <= pr) return tree.min(rt[u], 0, MaxP, x);
		int mid = l + r >> 1; int64_t ans = inf;
		if (pl < mid) chkmin(ans, min(lson(u), l, mid, pl, pr, x));
		if (mid < pr) chkmin(ans, min(rson(u), mid, r, pl, pr, x));
		return ans;
	}
} tree;

int end[MaxN], cur = 1;
void init(int u)
{
	for (auto &&v : graph[u]) init(v);
	end[u] = cur++;
}
pli stack[MaxN], *top = stack;
void dfs(int u)
{
	*top++ = {d[u] += d[fa[u]], u};
	int v = std::lower_bound(stack, top, (pli){d[u] - l[u], 0})->second;
	assert(d[u] - d[v] <= l[u]);
	f[u] = tree.min(1, 1, n + 1, end[u], end[v] + 1, p[u]) + d[u] * p[u] + q[u];
	line_t l = {-d[u], f[u]};
	tree.insert(1, 1, n + 1, end[u], l);
	for (auto &&v : graph[u]) dfs(v);
	top--;
}
int main()
{
	read(n), read(t);
	for (int i = 2; i <= n; i++)
		read(fa[i]), read(d[i]), read(p[i]), read(q[i]), read(l[i]), graph[fa[i]].push_back(i);
	init(1);
	tree.build(1, 1, n + 1);
	tree.insert(1, 1, n + 1, end[1], {0, 0});
	dfs(1);
	for (int i = 2; i <= n; i++) printf("%ld\n", f[i]);
	return 0;
}

[NOI2016] 国王饮水记

题意:TODO

这题好像只能用正统的斜率优化了,凸包优化无能为力。

posted @ 2024-09-29 13:29  yiming564  阅读(168)  评论(0)    收藏  举报