计算几何 学习笔记
计算几何学习笔记
起因是感觉这个东西很难,非常容易忘,因此趁教练没出模拟赛赶快写个笔记。
为了美观题目部分代码会转移到 https://wuyixiang.me/paste/XXX 中,如果链接炸了可以私信 @wuyixiang 更新。访问比较慢,如果访问不了可以尝试开代理。
由于作者也是计算几何新手,所以肯定会有说错或说不准确的地方,请一定要告诉作者错误并提醒他纠正。
计算几何一般都只会在省选内容中涉及。而且它一般离不开两个东西:
-
平面直角坐标系
-
向量
平面直角坐标系是什么大家应该都知道,因此讲一下向量。
向量
向量是什么?高中数学会学到,其实向量就是一条有方向的线段。因此,向量既有起点,又有终点。因此,向量可以用一个带有箭头的线段表示,如 \(\overrightarrow{OA}\)。有时候我们也会用一个字母来代替这个 \(OA\),即 \(\vec{a}\)。
在很多时候向量的位置其实是不重要的,只要起点坐标和终点坐标的相对位置保持一致就行了,因此我们可以对向量进行平移操作。
当然我们发现两个坐标来定义它实在太麻烦了。所以,如果起点(上面例子中即为点 \(O\))正好位于坐标系原点,则可以直接用一个数对 \((x,y)\) 来表示,即终点坐标。当然如果起点不在原点上也可以表示,你可以把这两个数理解为当起点平移到原点上时的终点坐标。
因此我们会对向量有和线段不一样的称呼:
-
向量的长度:即向量起点到终点的距离,也被称为向量的 模(模长)。通常用 \(|\vec{a}|\) 来表示。
-
反向量:即 \(- \vec{a}\)。这个向量的长度和 \(\vec{a}\) 的长度相同,但方向相反。
-
共线向量:两个向量平行。为什么定义是这个?这是因为向量是可以平移的。把其中一个向量移到另一个向量所在直线上,它们就共线了。
这时候会有一些特殊的向量,了解即可。
-
零向量:起点和终点相同的向量,长度为 \(0\)。该向量的方向可以是任意的,可以写成 \(\overrightarrow{0}\)。
-
单位向量:模长为 \(1\) 的向量。
所以这个东西有什么用呢?它对一些几何图形坐标及角度的计算有很大的帮助。在此之前我们先了解一下向量的运算。
向量加
\(\vec{a} + \vec{b}\) 被称之为向量的加法。我们可以这样理解:向量加是将 \(\vec{b}\) 接在 \(\vec{a}\) 的尾部,此时 \(\vec{a}\) 的头部指向 \(\vec{b}\) 的尾部即为 \(\vec{a} + \vec{b}\)。

向量减
\(\vec{a} - \vec{b}\) 被称之为向量的减法。这等于 \(\vec{a} + (-\vec{b})\),即 \(\vec{a}\) 加上 \(\vec{b}\) 的反向量。
向量的数乘
和乘法一样,\(\vec{a} + \vec{a} + \vec{a} + .\!.\!.\) 可以省略写成 \(k\vec{a}\),其中 \(k\) 为 \(\vec{a}\) 向量的个数。
这里或许有人会疑惑:向量乘去哪了?为什么只有数乘?
因为一般来说向量乘向量是比较难定义的。为此这里出现了两种分支:
点积
又叫数量积。点积名字的由来是它的运算符号像一个点:\(\vec{a} \cdot \vec{b}\)。
注意这个运算结果是一个数而不是向量,这个值等于 \(|\vec{a}||\vec{b}|\cos \theta\),其中 \(\theta\) 表示两个向量的夹角。
举个例子:

其中紫色向量就是 \(|\vec{a}|\cos \theta\),而紫色向量模长乘上橙色向量模长就是 \(\vec{a}\) 和 \(\vec{b}\) 的点积。
研究过 \(y = \cos x\) 的大佬们都知道,这个函数的变化规律如下:
-
\(0^{\circ} < \theta < 90^{\circ}\),\(\cos \theta > 0\),因此两个向量的点积大于 \(0\)。
-
\(\theta = 90^{\circ}\),\(\cos \theta = 0\),因此两个向量的点积等于 \(0\)。
-
\(90^{\circ} < \theta < 180^{\circ}\),\(\cos \theta < 0\),因此两个向量的点积小于 \(0\)。
当然,我们注意到有两个边界情况:
-
两个向量方向相同(\(\theta = 0^{\circ}\)),\(\cos \theta = 1\),因此它们的点积就等于 \(|\vec{a}||\vec{b}|\)。
-
两个向量方向相反(\(\theta = 180^{\circ}\)),\(\cos \theta = -1\),因此它们的点积就等于 \(-|\vec{a}||\vec{b}|\)。
所以点积定义这么复杂有什么作用呢?
有的,可以判断夹角大小。
好的其实这定义成 \(|\vec{a}||\vec{b}|\cos \theta\) 而不是 \(\cos \theta\) 的原因是,它可以通过一系列变换,得出这个值和下面式子相同:
其中,\((x_1,y_1)\) 为向量 \(\vec{a}\),\((x_2,y_2)\) 为向量 \(\vec{b}\)。
这样你就可以避免通过麻烦的计算夹角就能大致判断两个向量的夹角大小,还是非常方便的。
至于这个公式怎么来的,可以先记着,因为你并不需要用到这个证明。
叉积
点积是用来判断夹角大小的,但其实叉积却是比点积更强大的存在————它能直接判断两个向量的方向关系。
也就是说,它可以知道向量 \(\vec{b}\) 是在向量 \(\vec{a}\) 的顺时针方向,还是逆时针方向。
叉积又叫外积。叉积的名字同样来源于运算符号:\(\vec{a} \times \vec{b}\)。
注意,这个符号已经不表示普通的乘法。实际上 \(\vec{a} \times \vec{b}\) 的运算结果同样还是一个数:\(|\vec{a}||\vec{b}|\sin \theta\)。其中 \(\theta\) 代表向量 \(\vec{b}\) 需要顺时针旋转使得和向量 \(\vec{a}\) 同一方向的最小角度。
再来看一张图:

其中 \(\vec{a} \times \vec{b}\) 就等于紫色线段长度乘上橙色长度。(强调:\(\sin \theta\) 等于对边比斜边)
注意到这里我们还框了一个黄色的平行四边形。这是什么🤔?
实际上,根据我们小学学过的知识,紫色线段等于该平行四边形的高,橙色线段就等于该平行四边形的底,于是你就会发现,叉积的值刚好就等于平行四边形的面积。
当然这不是主要运用。
之前提过,叉积是可以判断两条线段的相对方向。上面那个图中,向量 \(\vec{b}\) 在向量 \(\vec{a}\) 的逆时针方向。如果是顺时针方向呢🤔?

好的,注意到此时向量 \(\vec{b}\) 已经来到了 \(\vec{a}\) 的顺时针方向。乍看之下值的大小还是正的啊,非常合理。
但注意到一点:\(\theta\) 角是可以大于 \(180^{\circ}\) 的!
根据 \(y = \sin \theta\) 的函数图像,可以大致知道:
-
\(0^{\circ} < \theta < 180^{\circ}\),\(\sin \theta > 0\),因此两个向量的叉积大于 \(0\)。
-
\(180^{\circ} < \theta < 360^{\circ}\),\(\sin \theta < 0\),因此两个向量的叉积小于 \(0\)。
所以,上面那幅图中,叉积的大小实际上为负数,而两条向量围成的图形实际上是负的平行四边形。
以防大家一头雾水所以就讲到这里了
此外,有两个边界:当 \(\theta = 0^{\circ}\) 或 \(180^{\circ}\),\(\sin \theta = 0\),因此两个向量的叉积等于 \(0\)。
所以现在知道为什么可以判断两条向量的相对方向了吧。当叉积为正时,\(\vec{b}\) 在 \(\vec{a}\) 的逆时针方向;当叉积为负时,\(\vec{b}\) 在 \(\vec{a}\) 的顺时针方向;当叉积为零时,两条向量共线。
叉积也有一个方便计算的公式:
注意到这个式子不是对称的,因此叉积不满足交换律。
运用
实际上没找到什么运用,因此我就给一个板题给大家了。
例 1-1
给定三个点的坐标,求出三角形的面积。
还记得上面那张图吗?

注意到围成的是平行四边形面积。如果将面积减去一半,就会得到:

然后你惊讶地发现这就是三角形的面积!
于是公式就出来了:\(S_{\vartriangle ABC}=\dfrac{1}{2}\Big|\overrightarrow{AB} \times \overrightarrow{AC}\Big|\)
-
注意叉积可能为负的,所以要取绝对值。
-
可以证明 \(A\) 点可以是三个顶点中的任意一个,不一定必须要选取最左下角的那个点。
例 1-2 P1183
给定 \(n\) 个顶点的坐标,保证每条边垂直或水平,请求出 \(n\) 个顶点依次连接形成的多边形的面积。
\(n \le 100\)
其实保证每条边垂直或水平这个性质是没啥用的。
没学过叉积的人肯定会直接模拟并将其分割成若干个矩形,依次相加。
还有一种做法是,把多边形分割成 \(n - 2\) 个三角形,分别计算面积,然后相加。
但你会发现当多边形没有凸性时会出现问题,并且这有个更巧妙的方法,这就是鞋带公式。
定义 \(P_i\) 为输入的 \(n\) 个点,\(O\) 为原点,则:
注意 \(-\) 号为向量减法,\(P_{n + 1} = P_1\)。
我们可以这样看:

我们将其分成了 \(n\) 个三角形。通过计算有向三角形的面积,最后相加,可以证明原来产生负贡献的三角形到最后都会被消去,最后只留下多边形的面积,因为多边形是闭合的。
把原点放在多边形内部会更好理解,这样 \(n\) 个三角形的贡献全部是正的,就变成普通相加了。这里是它的拓展版本,原点无论在什么位置,计算结果总是正确。
#include <iostream>
#include <cmath>
#define int long long
using namespace std;
struct node
{
double x,y;
}a[200005];
node jian(node b,node c)
{
b.x -= c.x;b.y -= c.y;
return b;
}
double chaji(node b,node c,node d)
{
c = jian(c,b);
d = jian(d,b);
return c.x * d.y - c.y * d.x;
}
int n;
int ans;
signed main()
{
cin >> n;
for(int i = 1;i <= n;i ++)cin >> a[i].x >> a[i].y;
a[n + 1] = a[1];
for(int i = 1;i <= n;i ++)
ans += chaji(a[0],a[i],a[i + 1]);
cout << ans / 2;
}
凸包
好的我们讲完了基础内容。
我们把在平面上能包含所有给定点的最小凸多边形叫做凸包。
如图所示,现在平面直角坐标系有 \(n\) 个点:

然后我们选一个最小凸多边形:

这个凸多边形就是凸包。
在 OI 中,我们一般只需要知道凸包的顶点即可,凸包里面的点不重要。
我们一般会把凸包上面的边(在凸包最左端结点和凸包最右端结点连线之上)叫做上凸壳,否则就叫下凸壳。
然后就是怎么构建凸包了。直接看例题。
例 2-1 P2742
给定 \(n(n \le 10^5)\) 个点,求凸包周长。
通常使用 Andrew 算法构建凸包,当然还有其它方法但是我不会,所以只讲这个。
我们上凸壳和下凸壳分别处理。
然后我们将 \(n\) 个点按 \(x\) 坐标为第一关键字,\(y\) 坐标为第二关键字从小到大排序。
于是开始维护上凸壳。首先第一个点一定在凸包边界上,于是直接加入上凸壳。然后开始依次遍历其余 \(n - 1\) 个点,如图所示。

这里我们发现一个问题:上凸壳要求必须是凸的,但这里连线是凹下去的,怎么办?所以第二个点我们就不能要了,将其弹出。

于是连接到这里:

我们发现这也凹得太诡异了,赶紧把最下面的点弹出来。

为什么还是凹的?很简单,再弹一次就行了。

所以遍历完 \(n\) 个点后我们就成功完成了上凸壳的构建。

所以上面的弹点方法是什么呢?答案是单调栈,因为每次你弹出的都是栈中最后一个点。一旦不满足要求,就把它弹出去,继续比较下一个点。注意:我们需要三个点才能比较,所以如果栈中只剩一个点了,就不要再弹栈了,否则你就在和滚木比较大小。
还有一个问题:怎么判断一个点是凹下去的呢?不难发现凸多边形的一个性质:第 \(i - 1\) 条边一定在第 \(i\) 条边的逆时针方向。
因此得到一个结论:边 \(i - 1\) 与边 \(i\) 的叉积必然大于 \(0\)(共线时也是要弹栈的)。所以弹栈条件即为相邻边的叉积不小于 \(0\)。
下凸壳是同理的,只不过遍历顺序是从 \(n - 1\) 到 \(1\)。另外还有几个细节:遍历完排序后的结点 \(1\) 后,这个点就在首和在尾各被加入了凸包一次,所以应该弹出这个点任意一个;上凸壳已经维护好了,所以弹栈时不要弹到上凸壳上的点。
代码如下。
#include <iostream>
#include <cmath>
#include <algorithm>
using namespace std;
int n,top;
double ans;
struct node
{
double x,y;
}a[200005],stk[200005];
bool cmp(node e,node f)
{
if(e.x != f.x)return e.x < f.x;
return e.y < f.y;
}
node jian(node b,node c)
{
b.x -= c.x;b.y -= c.y;
return b;
}
double chazhi(node b,node c,node d)
{
c = jian(c,b);
d = jian(d,b);
return c.x * d.y - c.y * d.x;
}
int main()
{
cin >> n;
for(int i = 1;i <= n;i ++)cin >> a[i].x >> a[i].y;
sort(a + 1,a + n + 1,cmp);
for(int i = 1;i <= n;i ++)
{
while(top > 1 && chazhi(stk[top - 1],stk[top],a[i]) <= 0)top --;//计算叉积
stk[++top] = a[i];
}
int kat = top;//上凸壳点数
for(int i = n - 1;i >= 1;i --)
{
while(top > kat && chazhi(stk[top - 1],stk[top],a[i]) <= 0)top --;
stk[++top] = a[i];
}
for(int i = 1;i < top;i ++)
ans += sqrt((stk[i].x - stk[i + 1].x) * (stk[i].x - stk[i + 1].x) + (stk[i].y - stk[i + 1].y) * (stk[i].y - stk[i + 1].y));//计算凸包周长
printf("%.2lf",ans);
}
投几道例题。
例 2-2 P14707
由于这题我不会简述题意所以我不写了。
细节放一道冷门题。
考虑枚举三角形的顶点 \(i\)。现在我们就要求出以它为起点向左和向右引出两条射线,射线与 \(x\) 轴的两个交点距离的最大值。
于是我们发现一个性质:
- 两条射线都至少会经过两个顶点(包括 \(i\) 号点,\(1\) 号点和 \(n\) 号点)。
证明显然,因为必须要有一个点来顶住射线的斜率。
那么我们的任务就是要求射线经过的那个点是什么。下面我们只讨论向左引出的射线,因为向右是对称的。
直接说结论:它一定是在下凸壳上,且是用结点 \(i\) 弹栈后栈的栈顶。因为这个结点一定是与 \(a_i\)(\(a\) 数组表示结点)连线中斜率最大的(因为弹栈条件是比较叉积大小)。
然后从左往右扫一遍,再从右往左扫一遍,就能求出每个点引出两条射线对应的切点是什么了。
然后是求面积。本质上就是求一条直线与 \(y = 0\) 的交点坐标,这个随便用一下斜率公式就做出来了。
代码链接:here
例 2-3 P3099
给定 \(2 \times n\) 个点,前 \(n\) 个点为红色,后 \(2 \times n\) 个点为蓝色,定义一个点被捕获当且仅当存在三个异色的点使得该点在以这三个异色的点为顶点的三角形内部。分别计算这两个问题:有多少个蓝点被捕获;有多少个红点被捕获。
\(n \le 10^5\)
这应该是很上位的青题了,应该评蓝的。
注意我不在这里放水题的原因是之后有个题也要用到这个做法,敬请期待。
这里我们只考虑第一问,因为第二问基本完全是一样的。
有一个显然的推论:存在一个三角形使得一个点 \(p\) 被这个三角形包含,等价于 \(p\) 在凸包内。因为所有点都在凸包里面,而凸包本质上可以看成 \(n - 2\) 个三角形拼接形成的(即凸包的三角剖分)。
于是你就得到了一个子问题:判断一个点是否在凸包内(含边上),要求 \(\mathcal{O}(\log n)\)。
直接看图。

我们先将凸包最左下角那个点移至原点,当然其他点也跟着平移。
先判一个边界情况:判断它是否在红线的外面。如果与红线共线,则判断它是否在凸包外。
这可以用叉积和模长来解决,如果这个点在左侧红线的逆时针方向或在右侧红线的顺时针方向则就在凸包外面,如果共线了比较模长即可。
然后我们就可以锁定其在红线内部了。但是注意了我们还有绿线的限制。怎么办呢?🤔🤔🤔
我们把凸包分成 \(n - 2\) 个区域,其中第 \(i\) 个区域是一个两边分别为射线 \((1,i + 1)\) 和射线 \((1,i + 2)\) 构成的区域(\((x,y)\) 表示经过的两个点的编号):

然后我们对边二分,找到它所属于的区域是哪块。具体而言,找到与前一条边叉积大于 \(0\),与后一条边叉积小于 \(0\) 的区域。
锁定区域了之后,我们发现每个区域都有一条边。怎么判断点在这条边里面还是外面呢?很简单,再判一次叉积即可。准确的来讲,你找到区域内那条边顺时针方向的那个点,并将查询点和这个点连边,计算这条边与区域内那条边的方向即可(见下图的蓝色边和紫色边),如果在区域内边的顺时针方向,就说明它在区域外。

于是我们被 #10 hack了😡,因此我们需要特判:当凸包退化成一条线段(只包含两个点时),上面实现会出问题,需要特判,由于第一个点坐标是 \((0,0)\) 所以很容易就能判掉(
代码展示,可能有点丑陋。here
受篇幅影响剩下几道题就作为练习题了,有些题很水让大家吃一吃小零食。P2116,P3829,P3442。
极角排序
在写 P3099 题解的时候偶然发现一个严重的事情就是这部分我好像没讲。
下一章节还会用到所以我赶紧讲一下。
极角
极角定义为一个向量与 \(x\) 轴的夹角。
如下图,这个向量经过 \(O(0,0)\) 和 \(A(1,\sqrt{3})\),则它的极角为 \(60^{\circ}\)。

极角的值域比较模糊,一般而言有两种:在 \((-\pi,\pi]\) 之间,或在 \([0^{\circ},2\pi)\) 之间。
负数极角代表什么呢?其实就是说,箭头方向是朝下的。这里就不配图了,感性理解一下。
如何计算极角呢?C++ math 库提供了一个函数:atan2(y,x),其中 \(x,y\) 为向量的起点和终点,可为任意实数。它能返回这个向量的极角。注意这个函数的值域是 \((-\pi,\pi]\)。
注意: 这个函数精度很低,如果想让精度高一点,可以使用 atan2l(y,x)。
排序部分
有时候你想要对若干个极角进行排序。
显然直接用 atan2(y,x) 进行极角排序是可行的,但是由于该函数是浮点数有精度误差。
我喜欢第二种方法:叉积。怎么又是你
因为不难发现 \(\vec{a}\) 的极角小于 \(\vec{b}\) 的极角条件就是叉积大于 \(0\),因此我们直接在排序比较器写一个类似 \(\vec{a} \times \vec{b} > 0\) 状物即可按极角从小到大排序。
但大多数情况下我们需要的是按极角从大到小排序,所以很多时候我们看见的比较器都是 \(\vec{a} \times \vec{b} < 0\)。
不过这里会有一个问题:当 \(\vec{a}\) 和 \(\vec{b}\) 是反向量时,叉积等于 \(0\),但我们不希望它们排在一起,但这时候我们发现其中一个向量肯定 \(x\) 坐标 \(\ge 0\)。如果 \(> 0\),则强制钦定该向量在排序后靠前;如果两个向量 \(x\) 坐标都等于 \(0\),则再比较 \(y\),这样就能确保万无一失。
闵可夫斯基和
不要畏惧其实这个东西很简单的。
它这个东西还是非常神奇。因为它定义了凸包的加法!!!
定义两个点集 \(A,B\) 的闵可夫斯基和 \(A + B\) 为:\(\{a + b | a \in A,b \in B\}\)。这相当于,\(A + B\) 上的每一个点,都是由 \(A\) 集合中的一个点加上 \(B\) 集合中的一个点得到的。
那么凸包呢?很简单,那 \(A + B\) 就不会有 \(n \times m\) 个点了,需要将这些点再求一遍凸包,凸包上的点构成的集合才是凸包的闵可夫斯基和。
话说大家都在借用 oi-wiki 的图片,那我也借一张吧
我们假设蓝色是凸包 \(A\),红色是凸包 \(B\)。
然后你会得到很多个点:
于是你再求一遍凸包,于是你就完成了凸包的加法。
于是你就学完了闵可夫斯基和,赶快去刷例题吧
好吧其实就是,如果直接暴力构建,复杂度达到了惊人的 \(\mathcal{O}(nm \log nm)\),无法接受。有一种方法可以在 \(\mathcal{O}(n+m)\) 的复杂度构建好新凸包。
性质 闵可夫斯基和 \(A+B\) 的边集是由凸集 \(A,B\) 的边按极角排序后连接的结果。
证明可以详见 oi-wiki。
于是我们先用差分数组把边集上的边取出来,然后极角排序一下,最后再还原(前缀和)就行了。
记得第一个点直接计算即可,不要也代入到前缀和数组里面了。
示例代码使用了 merge 函数,它可以将两个有序的数组合并成一个有序的数组。可以使用自定义比较器。
因为使用静态数组很麻烦所以代码统一用 vector 存储了。
bool cmp2(node b,node c)//极角排序,chazhi函数计算两个向量的叉积
{
return (chazhi(b,c,{0,0}) > 0 || (chazhi(b,c,{0,0}) == 0 && (b.x > c.x || (b.x == c.x && b.y > c.y))));
}
vector<node> minkefu()
{
vector<node>ze = a[1],ye = a[2];//a[1],a[2]为待合并的数组
ze.push_back(ze[0]),ye.push_back(ye[0]);
vector<node>res(ze.size() + ye.size() - 1);
res[0] = jia(ze[0],ye[0]);//计算出新凸包的第一个点
for(int i = 0;i + 1 < ze.size();i ++)ze[i] = jian(ze[i + 1],ze[i]);//计算边集
for(int i = 0;i + 1 < ye.size();i ++)ye[i] = jian(ye[i + 1],ye[i]);
ze.pop_back(),ye.pop_back();//注意,最后一项不属于边集,直接pop_back掉
merge(ze.begin(),ze.end(),ye.begin(),ye.end(),res.begin() + 1,cmp2);//O(n)合并
for(int i = 1;i < res.size();i ++)res[i] = jia(res[i - 1],res[i]);//还原凸包
return res;
}
然后我们直接看例题。
例 3-1 P8101
你有 \(n\) 组向量,你要在每一组内各选一个向量,使得 \(n\) 个向量加起来得到的向量长度最大。
\(n \le 10^5\),向量总数 \(\le 2 \times 10^5\),每组至少有两个向量
这应该是闵可夫斯基和最简单的例题了,因为我好像找不到更简单的。
我们把每一组向量都看成是一个凸包,然后做 \(n - 1\) 遍闵可夫斯基和即可得到一个新凸包,不难发现最远的那个点一定会在凸包边上,于是就做完了。
当然还有一个细节需要注意:如果直接顺序做闵可夫斯基和,那么复杂度会被卡到 \(\mathcal{O}(nm)\),然后就爆了。
于是考虑分治解决问题,我们设 \(s(l,r)\) 表示合并第 \(l\) 个凸包到第 \(r\) 个凸包后的凸包,那么可以考虑合并 \(s(l,mid)\) 和 \(s(mid + 1,r)\) 来保证复杂度的正确性。
复杂度 \(\mathcal{O}(n \log n)\)。
代码链接:here
例 3-2 P4557
你有一个大小为 \(n\) 的点集 \(A\) 和大小为 \(m\) 的点集 \(B\),有 \(q\) 次询问,第 \(i\) 次询问给定两个数 \(x,y\),询问将点集 \(B\) 的所有点横坐标加 \(x\),纵坐标加 \(y\) 后,此时对 \(A,B\) 分别建凸包,两个凸包是否有交集(面积交不为 \(0\) 或存在公共点都算)。
\(n,m,q \le 10^5\)
首先转化一下题意,就是把 \((x,y)\) 当成一个向量 \(C\),并对 \(A,B\) 分别求一下凸包,那么就需要判断 \(B + C\) 是否和 \(A\) 有交集。
然后我们对其移项,转化问题得到 \(C\) 是否和 \(A - B\) 有交集。
注意 \(A - B\) 凸包做减法可能有点抽象,但其实我们可以化为加法,变成 \(A + (-B)\)。
\((-B)\) 又代表什么?这里可以简单理解为对凸包 \(B\) 上的点的所有坐标对原点做对称,即 \((x,y)\) 变成 \((-x,-y)\)。
然后你对 \(A + (-B)\) 做闵可夫斯基和就行了,现在问题变成了 \((x,y)\) 是否在新凸包内部。
???这不就是 P3099 吗?而且不用特判凸包大小为 \(2\) 的情况。
于是你就秒了此题。
据说我切这道题的几个月前这题还是黑,因为实在没有黑的难度降紫了
代码:here。因为是两个题拼起来所以我就没写注释了。
拓展:闵可夫斯基和优化 dp
不想看的可以跳过,因为这里确实比较难。
这里就没有平面直角坐标系了(或者说你题面中看不到坐标系),但是如果你用 dp 来推一下式子,复杂度是会爆的,因此这时候闵可夫斯基和优化 dp 就派上用场了。
当然肯定不是随便一个 dp 状态都可以用闵可夫斯基和优化的,这还是和凸包有关系 ———— dp 数组必须有凸性。
什么是有凸性?就是把 \((i,dp_i)\) 当成一个点,然后从左到右依次连线,能构成凸包的一个上凸壳或下凸壳。这样,将 \((n,dp_n)\) 和 \((1,dp_1)\) 连边,那么 dp 数组就能形成一个凸包。(当然实际代码中就不要连边了,这里说到这个是为了方便理解)
这样两个 dp 数组的信息合并就能够做到 \(\mathcal{O}(n + m)\) 了,然后你考虑用分治来合并把所有 dp 信息都合并成一个整体,就能够做到 \(\mathcal{O}(n \log n)\) 了。
根据凸包加法的定义,\(dp_i\) 和 \(dp2_j\) 就会合并成 \(newdp_{i+j}\),支持的符号有取 \(\min\) 和取 \(\max\)。
典型的题就是朴素转移是 \(\mathcal{O}(n^2)\) 的,然后用闵可夫斯基和优化到 \(\mathcal{O}(K n \log n)\),其中 \(K\) 是合并个数的系数,一般来说这个数不会很大。
例 3-3 P11459
题目大意:给定一个只含
M或O的长度为 \(N\) 的字符串 \(S\),你将第 \(i\) 个字符改变的代价为 \(c_i\),定义字符串 \(T\) 为一个字符M接上 \(L - 1\) 个字符O,你需要对于每一个 \(1 \le k \le \lfloor{\frac{N}{L}}\rfloor\) 求出通过改变字符串 \(S\) 使其包含至少 \(k\) 个子串 \(T\) 所需要的最小代价。\(N \le 3 \times 10^5,1 \le L \le 3,1 \le c_i \le 10^8\)
本题暴力转移 dp 是 \(\mathcal{O}(n^2)\) 的,考虑怎么去优化。
然后你就需要发现一个性质:\(ans_{i + 1} - ans_{i}\) 不小于 \(ans_{i} - ans_{i - 1}\)。这里证明就不证了,实际上你观察样例大概就能猜到。所以,\(i,ans_i\) 的斜率只会越来越陡,显然就能构成凸壳。
于是考虑去设计 dp。在分治答案 \((l,r)\) 中,我们设 \(dp_{i,j,k}\) 表示强制不匹配区间前 \(i\) 个字符和后 \(j\) 个字符,包含 \(k\) 个子串 \(T\) 的最小代价。于是出现两种情况:
-
第一种:若 \(r - l + 1\) 即区间长度很小,直接暴力做就可以。推荐将这个阀值设为 \(2 \times L - 1\),这样就只用讨论包含一个子串的情况。
-
第二种:若 \(r - l + 1\) 的长度很大,那我们先得到 \(l\) 和 \(r\) 的中点 \(mid\),分治得到 \(\text{f}(l,mid)\) 和 \(\text{f}(mid + 1,r)\) 的答案,合并用闵可夫斯基和。
当然合并也分为两种:
-
连接处不匹配子串,那么直接 \(dp_{i,j}\) 由 \(l_{i,0} + r_{0,j}\) 得到即可,其中 \(l,r\) 分别为 \(\text{f}(l,mid)\) 和 \(\text{f}(mid + 1,r)\) 的答案。
-
连接处匹配子串。于是需要再枚举这个匹配位置,则 \(dp_{i,j}\) 需要对 \(l_{i,k} + r_{L - k,j} + w_{pos}\) 取 \(\min\),其中 \(w_{pos}\) 为在 \(pos\) 处来匹配字符串 \(T\) 需要的代价,可以预处理直接算出。
于是就做完了。
下面是小细节:
-
在暴力这一部分中,dp 完后可能存在四个整数 \(x \le l,y \le r\) 使得 \(dp_{x,y}\) 的答案反而比 \(dp_{l,r}\) 的答案小,于是需要枚举子集来完成取 \(\min\) 操作。你问我数组和数组怎么取 \(\min\)?直接拆位,每一位都取最小值就行了。
-
这里传给闵可夫斯基和就不是两个点集了,但其实是因为其中一维被固定成了 \(i\),所以本质上是一样的,只是最后面不要加入第一个数来形成闭环,这样反而会破坏差分数组的凸性。
代码链接:here
旋转卡壳
旋转卡壳是一种求凸包最远点对距离的 trick,拼音是 xuàn zhuàn qiǎ qiào,已知这四个拼音全是错误的,大家可以推一下它的正确读法。
其实本身这个 trick 并不难,所以有关这个的题目,一般都是水紫。
直接看例题。
例 4-1
给定平面上 \(n\) 个点,求凸包直径。
\(|x_i|,|y_i|,n \le 5 \times 10^4\)
这里凸包直径其实就是最远点对。
这里介绍的 trick 就是:我们枚举一条边 \((i - 1,i)\),设离这条边最远的点下标是 \(x\),此时我们再枚举另一条边 \((i,i + 1)\)(即这条边逆时针方向与它紧挨着的那条边),那么离这条边最远的点 \(y\) 一定在 \(x\) 的逆时针方向或重合。
举个例子:

我们枚举绿色的边,它的最远点是红色的点。
然后我们转到下一条边,而此时最远点就来到了:

然后我们就能看到规律:最远点和枚举边是同向移动的。
于是我们就另设一个指针 \(j\),看一下它的下一个点 \(j + 1\) 离边的距离是否比 \(j\) 离边的距离远,如果是,就将 \(j\) 指针赋值为 \(j + 1\)。
时间复杂度可以证明是 \(\mathcal{O}(n)\) 的。
还有一个小问题:你怎么比较一个点到一条边的距离?
很简单,比较三角形面积就行了呗,其中边是底边,顶点为比较对象。由于同底,那么肯定三角形面积更大的那个顶点离直线更远。于是就简单用叉积求面积的方法比较大小即可。
代码:here
题外话:
用边是不是太麻烦了,直接换成点不就行了吗?
很遗憾,点对距离不是单峰的,通过精心构造 hack 代码就会 WA。
但是,本题并不只有旋转卡壳做法!!!附链接。人类智慧永垂不朽
对了,此题还有个双倍经验,其实就是搬迁之后的模板:P16223。但本题有一些坑点注意不要踩坑:
-
多测要清空。
-
题目不保证任意两个点坐标不同,记得去重。
-
特判凸包退化成一个点的情况。
好在我提前看了讨论帖,于是两发就过了。
例 4-2 P14498
给定 \(n\) 个点,已知存在一种路径 \((q_1,q_2,.\!.\!.,q_n)\) 满足对于任意 \(i < j < k\) 使得 \(dis(q_i,q_k) > \max(dis(q_i,q_j),dis(q_j,q_k))\)。请输出 \(\sum_{i=1}^{n-1} dis(q_i,q_{i + 1})\) 的和,其中 \(dis(i,j)\) 指点 \(i\) 到点 \(j\) 的欧几里得距离。
\(n \le 2 \times 10^5,|x_i|,|y_i| \le 10^9\)
依然冷门题当例题
这里有两种做法:正常做法和爆标做法。
正常做法
首先推一下性质。
由题目不等式可知 \(\max(dis(q_1,q_i),dis(q_i,q_{i + 1})) < dis(q_1,q_{i + 1})\),于是可知 \(q_i\) 到 \(q_1\) 的距离单调递增,所以我们只需要求出 \(q_1\) 即可。
又可知 \(dis(q_1,q_n) > dis(q_x,q_n) > dis(q_x,q_y)\),所以对于任意 \(1 < x < y < n\),\(dis(q_x,q_y)\) 的大小都没有 \(dis(q_1,q_n)\) 大,所以 \(q_1,q_n\) 是平面内最远的两个点。
于是此题就变成了【模板】最远点对,在这个点对中选一个点当做 \(q_1\),然后其余点按照到 \(q_1\) 的距离升序排序,这道题就做完了。复杂度 \(\mathcal{O}(n \log n)\)。
代码:here。
爆标做法
观察到题解区没有的做法,不需要旋转卡壳,代码特别好写。
我们思考一个问题:\(q_1\) 真的必须要用旋转卡壳来求出吗?
这里给一个容易忽视的性质:对于任意一个点 \(x\),距离它最远的点一定是 \(q_1\) 或 \(q_n\)!
考虑证明这一件事,就是我们设 \(x\) 这个点在 \(q\) 中的位置是 \(q_{pos}\),那么假设与它最远的点 \(q_p\) 满足 \(p \neq 1\) 且 \(p \neq n\):
-
若 \(pos < p\),则 \(dis(q_{pos},q_p) > dis(q_1,q_p)\),与题目矛盾。
-
若 \(pos > p\),则 \(dis(q_p,q_{pos}) > dis(q_p,q_n)\),也与题目矛盾。
于是我们随便选一个点,记录与它距离最远的点当作 \(q_1\),其余点按照到 \(q_1\) 的距离升序排序,于是就做完了。here

浙公网安备 33010602011771号