凸包

1 概念

凸包指的是在平面上能包含所有给定点的最小凸多边形,可以理解为用一根绳子圈住所有点构成的一个多边形。而一个凸包又可以看做是上下两个部分组成,也就是常说的上凸壳和下凸壳。

实际操作中,我们很难直接维护出一整个凸包,所以一般分为上下凸壳进行维护。

2 维护方法

凸包的维护方法有很多种,在静态、动态插入、动态删除时均有不同的做法,下面简单介绍两种基本场景下的解决方式。

2.1 静态构建

静态构建凸包的算法是 Andrew 算法,其重点在于向量叉积这个运算。我们知道,对于两个向量 \(\vec{a},\vec{b}\),如果 \(\vec{a}\times \vec b >0\) 则说明 \(\vec{b}\)\(\vec{a}\) 的左侧,否则说明 \(\vec{b}\)\(\vec{a}\) 的右侧。

而根据凸包概念我们知道,上凸壳从左到右的轨迹总是右拐,而下凸壳从左到右的轨迹总是左拐,于是我们便可以用叉积简单判断出当前点插入是否合法。具体的,我们先对所有点排序,然后用一个单调栈去维护当前的凸包,如果待插入点 \(P\) 与当前栈顶的两个节点 \(S_1,S_2\) 构成的两个向量 \(\overrightarrow{S_1P},\overrightarrow{S_2S_1}\) 不满足凸壳对应的关系,则弹出栈顶,直到合法后插入 \(P\) 即可。

代码如下:

struct Point {
	int x, y;
	bool operator < (Point b) {//排序比较函数
		if(x == b.x) return y < b.y;
		return x < b.x;
	}
	Point operator - (Point b) {return {x - b.x, y - b.y};}//求出两点之间的向量
	int operator * (Point b) {return x * b.y - y * b.x;}//向量叉积
}a[Maxn];

signed main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i].x >> a[i].y;
	sort(a + 1, a + n + 1);
    //以构建上凸壳为例
	for(int i = 1; i <= n; i++) {
		while(top > 1 && (a[i] - a[top]) * (a[top] - a[top - 1]) <= 0) top--;
        //叉积小于 0 说明向左拐,不合法,弹栈
		a[++top] = a[i];
	}
	return 0;
}

2.2 动态加点

如果每次要动态加入一个点的话,我们就需要动态调整凸包的形态。以上凸壳为例,假设当前待插入点为 \(P\),首先在当前凸包上找出 \(P\) 的前驱 \(A\) 和后继 \(B\),首先判断 \(P\)\(A,B\) 能否构成上凸壳,不能的话则不用插入;否则,将凸包从这里分成两半,然后找出 \(P\) 和左右两半凸包的切点并连接,中间的点全部删除即可。

找切点可以直接二分查找,不过直接暴力判断也是可以的,因为每个点如果判断完不合法后就会被踢出凸包,并且再也不可能加入进来,所以每个点只会进出凸包一次,复杂度有保障。

实际中常用 set 或平衡树去维护凸包,于是复杂度就是 \(O(n\log n)\) 的了。

采用暴力删点的代码如下:

set <Point> s;
void insert(Point p) {
	auto it = s.lower_bound(p);
	auto itr = it, itl = (--it);//找前驱后继 
	Point nxt = *itr, pre = *itl;
	if((nxt - p) * (p - pre) <= 0) return ;//先判断能否构成凸包
	auto tmp = itr++;
	while(1) {//向右暴力删点
		if(itr == s.end()) break;
		Point a = *tmp, b = *itr;
		if((b - a) * (a - p) <= 0) {//不合法删去
			s.erase(tmp);
		}
		else break;//合法就跳出
		tmp = itr++;
	}
	tmp = itl--;
	while(1) {//向左暴力删点
		if(tmp == s.begin()) break;
		Point a = *tmp, b = *itl;
		if((p - a) * (a - b) <= 0) {
			s.erase(tmp);
		}
		else break;
		tmp = itl--;
	}
	s.insert(p);
}

上面两种就是常见的维护凸包的方式,剩下的就是利用各种数据结构去维护凸包,不过这些方法都或多或少会丢失凸包的具体信息,所以只适用于利用凸包求最优解的问题,下面再举几例:

  • 利用线段树维护每个区间上的凸包,通过暴力合并即可得出任意区间上的凸包信息。复杂度会因为线段树变为 \(O(n\log^2 n)\)
  • 利用分块维护每个块内的凸包,当有一些点会动态改变的时候,对整块记录偏移量,对散块进行暴力重构即可。取块长为 \(\sqrt n\) 即可得到一个 \(O(\sqrt n \log n)\) 的复杂度。

3 应用

3.1 应用场景

凸包的常见应用场景实际上无非两种,即实际维护凸包信息以及最优解问题。

  • 维护凸包信息:题目中明确说了要求凸包周长、面积,或者可以转化为求凸包周长、面积等需要知道凸包上每一个点的信息时使用。此时上面提到的一些维护方式便不再适用。
  • 最优解问题:这个相信大家更熟悉,实际上类似于斜率优化 dp。在某些问题中我们会有若干决策点,而我们可以得出当某个点更优时其斜率满足某个关系,然后可以知道最优解一定在所有决策点构成的上凸壳 / 下凸壳上,然后通过二分就可以找到最优决策点。

3.2 例题

例 1 旅行规划

\(\text{Link}\)

显然我们需要维护的就是每一个点的前缀和信息,这样第二问就是求一个区间最大值。而第一个操作实际上可以转化为区间 \([l,r]\) 加上一个公差为 \(k\) 的等差数列,以及区间 \([r+1,n]\) 加上同一个数 \((r-l+1)k\)

考虑困难的实际上是前一个操作,也就是加等差数列,这个很难用线段树直接维护。那么考虑用万能的分块去解决,对于整块而言,加上若干等差数列后每个位置的增加值依然相当于一个等差数列,并且每一个位置最终的前缀和很容易写成 \(s_i+k_p\times i\) 的形式,其中 \(s_i\) 表示每个位置原始的前缀和,\(k_p\) 表示当前块内加上的等差数列的公差和。自然这样算会有一些多余的部分,给每一个块打一个 \(tag\) 即可。

现在每一个块内的元素贡献就可以写成一个一次函数的形式了,我们要求最后的答案 \(ans_i=s_i+k_p\times i\) 的最大值,移项后得 \(s_i=-k_p\times i+ans_i\),即给定若干点 \((i,s_i)\),求过当前点斜率 \(-k_p\) 的直线的截距最大值,显然答案一定在上凸壳上,所以对每个块内维护一个凸包即可。

剩下的细节例如散块的处理等不再赘述,留给读者自行思考。

例 2 [NOI2014] 购票

\(\text{Link}\)

显然此题需要考虑 dp,那么设 \(dp(i)\) 表示 \(i\to 1\) 的最小花费,同时定义 \(dis(i)\) 表示 \(i\to 1\) 的距离。那么不难发现转移方程为:

\[dp(i)=\min_{dis_i-dis_j\le l_i}(dp(j)+(dis_i-dis_j)\times p_i +q_i) \]

这个式子的转化就太过套路了,我们要求 \(dp(i)=dp(j)+(dis_i-dis_j)\times p_i+q_i\) 的最小值,也就是 \(dp(i)-dis_ip_i-q_i=dp(j)-dis_j\times p_i\) 的最小值。移项可得 \(dp(j)=p_i\times dis_j+(dp(i)-dis_ip_i-q_i)\)。显然点的坐标就是 \((dis_j,dp(j))\),斜率是 \(p_i\),要求截距最小值,显然答案都在下凸壳上。维护凸包做斜优 dp 即可。

不过下面还有一个限制 \(dis_i-dis_j\le l_i\),移项后得 \(dis_j\ge dis_i-l_i\)。这种限制条件如果放在序列上我们一般会采用 CDQ 分治去操作,那么放在树上自然想到用点分治去操作。由于树是有根的,所以写一个有根树点分治即可。

例 3 [CTSC2016] 时空旅行

\(\text{Link}\)

发现给出的 \(y,z\) 根本没用,所以只需要考虑 \(x\)。假如某个时空内固定在了 \(x_0\) 处,那么对于任意一个点,其贡献就是 \((x_i-x_0)^2+c_i\)。展开后可得 \(x_i^2-2x_ix_0+x_0^2+c_i\)。我们要求 \(ans=x_i^2-2x_ix_0+x_0^2+c_i\) 的最小值,移项可得 \(x_i^2+c_i=2x_0\times x_i+(ans-x_0^2)\),也就是点 \((x_i,x_i^2+c_i)\),斜率 \(2x_0\),求截距最小值。显然答案在下凸壳上。

不过此题的难点在于维护每一个版本的凸壳信息。首先可以发现不同版本的依赖关系构成一棵树,考虑利用 DFS 序进行操作,然后可以进一步发现每一个星球在 DFS 序上出现的区间恰好只有 \(O(n)\) 段,于是我们就可以在 DFS 序上建线段树,然后利用线段树维护凸包信息即可。当然了,由于凸包信息肯定无法下放,所以需要利用标记永久化的思路,查询时查找叶子节点根链上所有凸包的最优解即可。复杂度是 \(O(n\log^2 n)\) 的。

但是这样还不够,实际上我们还可以省掉一个 \(\log\)。这也是线段树维护区间凸包的一个经典技巧。首先在插入时我们显然可以离线然后按 \(x\) 升序插入,这样插入可以直接利用单调栈维护了。然后在查询时我们同样按 \(x_0\) 升序查询,因为我们查询的斜率是 \(2x_0\),所以随着斜率增长最优决策点一定更靠后,维护一个单调不减的指针即可。实际上这就是单调队列的思路。这样做的复杂度就是 \(O(n\log n)\) 了。

例 4 [NOI2017] 分身术

\(\text{Link}\)

这道题就是一个单纯维护凸包信息的题了,不过依然很难做。首先将凸包拆成上下凸壳分别维护面积,然后考虑删掉 \(k\) 个点后凸包的变化。

首先考虑 \(k=1\),如果这个点不在凸包上那么不会影响答案,而如果在凸包上,则我们只需要删去这个点,求新的凸包即可。而这个新的凸包实际上只可能有两种来源,一种是原来的凸包,另一种是去掉原来的凸包后剩下的点的凸包(也就是第二层凸包)。

根据这个结论我们不难想到,我们可以维护出 \(k+1\) 层凸包,根据抽屉原理这样做一定可以得到删掉 \(k\) 个点后的凸包。接下来我们从内向外依次处理,对于更靠外的凸包,除了其上被删去的点之外会剩下一些点,我们只需要用这些点去覆盖掉当前凸包上的一部分即可。具体的,假如当前更靠外一层的凸包有一段没有被删除的点,其左端点为 \(L\),右端点为 \(R\),我们只需要找到 \(L\) 左边的切点和 \(R\) 右边的切点,将这中间的部分删除并连上 \([L,R]\) 这一段凸包即可得出合并后的新凸包。

这个操作的实现只需要用线段树即可。对每个凸包维护一个权值线段树表示其包含哪些给定点(注意要先排序)。对于找切点采用线段树二分,对于删除采用线段树分裂,对于合并采用线段树合并即可。在 pushup 时利用向量叉积算出合并时多出来的面积,然后就可以得出最终的答案了。另一个值得注意的点时我们不能破坏原有凸包的信息,所以合并分裂时需要采用新建节点的方式处理。

posted @ 2025-02-16 16:18  UKE_Automation  阅读(562)  评论(0)    收藏  举报