凸包

1 概念

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

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

2 维护方法

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

2.1 静态构建

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

而根据凸包概念我们知道,上凸壳从左到右的轨迹总是右拐,而下凸壳从左到右的轨迹总是左拐,于是我们便可以用叉积简单判断出当前点插入是否合法。具体的,我们先对所有点排序,然后用一个单调栈去维护当前的凸包,如果待插入点 P 与当前栈顶的两个节点 S1,S2 构成的两个向量 S1P,S2S1 不满足凸壳对应的关系,则弹出栈顶,直到合法后插入 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,首先判断 PA,B 能否构成上凸壳,不能的话则不用插入;否则,将凸包从这里分成两半,然后找出 P 和左右两半凸包的切点并连接,中间的点全部删除即可。

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

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

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

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(nlog2n)
  • 利用分块维护每个块内的凸包,当有一些点会动态改变的时候,对整块记录偏移量,对散块进行暴力重构即可。取块长为 n 即可得到一个 O(nlogn) 的复杂度。

3 应用

3.1 应用场景

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

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

3.2 例题

例 1 旅行规划

Link

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

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

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

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

例 2 [NOI2014] 购票

Link

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

dp(i)=mindisidisjli(dp(j)+(disidisj)×pi+qi)

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

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

例 3 [CTSC2016] 时空旅行

Link

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

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

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

例 4 [NOI2017] 分身术

Link

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

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

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

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

posted @   UKE_Automation  阅读(25)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示