1 引言
计算几何计算机科学的一个重要分支,因此在算法竞赛中也是常考的一类题,难度从签到题到防AK题不等。本文是作者对计算几何在算法竞赛中的解题学应用的一点心得,主要介绍计算几何专题内比较经典的思想,算法和个人对此的一点心得。本文从逻辑上分为三个部分,第一部分是阐释解决有关计算几何算法问题时的设计思想,第二部分从点,向量,圆,三角,简单多边形等计算几何中主要处理的二维图像的角度出发,用面向对象的思想介绍类成员函数和成员变量(但是出于程序实现的方便,在设计程序时依然以面向过程为主),主要采用的手段仍然以解析几何为主。最后介绍非解析方法的数值计算技巧,用以解决一类其他的问题。本文将围绕问题转化,分类讨论等算法设计中常用的思想对上述内容进行阐释。
1.1 精度
在以解析几何为理论背景的计算几何问题中,精度对程序正确性的影响非常大,其中尤其以开根操作和三角函数操作影响恶劣。此外,受限于计算机存储空间有限性,在逻辑上无法直接存储无尽小数(不考虑分数类等间接表示的方法),所以在经过一系列操作后==
操作符可能无法判断逻辑上等价而数值上不等价的表达式。
关于精度,主要就是要解决上述两个问题:精度降低和因此带来的等价判断处理。
第一个问题目前还没有很好的办法解决,在部分不需要非线性运算的问题中,可以用分数类来实现逻辑上的精确表述,在输出结果前不会产生精度损失。但是当遇到开根,三角函数等精度杀手时,分数类就显得力不从心了。一般而言,当精度要求为 long double
可以略微提升精度,但是效果不明显。
在等价判断上,一般设一个所需精度级别的误差量,当两个数之差小于该误差量时,可以认为这两个数相等。因为这最多带来小于所需精度级别的误差,基本可以认为他是安全的。当该操作后还要套很多操作时,可以适当减小这个误差量。使用一个cmp
函数来实现比较功能,返回值类似java
中的 compareTo
函数。
1 | const double EPS=1e-6; |
另一个常用的手段是用long long
存储数据。在处理不涉及距离,面积的问题时(或者只在最后一步求),如凸包(只需要处理叉积),可以判断性的操作都在整数范围下完成,只在计算距离面积数值时才转换为浮点型计算。这样可以有效避免各种浮点误差。
1.2 剖分
在非算法竞赛中说的三角剖分,常指在一个简单多边形的顶点间连若干条互不相交的线段,将之分解成若干个三角形,从而对于多边形的面积,重心,面积交等问题时可以通过这些三角形间接求出来。这本质上是一种转换的思想,将不好处理的多边形,转换为熟悉的三角形,在三角形上进行分类讨论来解决各类实际问题。
但是这种做法在实现时非常复杂,先要用扫描线法进行单调多边形的划分,然后再在单调多边形上用扫描线法求出三角剖分,编程复杂度巨大。
考虑一个更加简便的做法,在求面积的情况下,本质上是对三角形面积的加和。当三角剖分没有相交时,出现的所有三角形都对结果贡献了正面积。在这里我们考虑负面积,对于一个枢轴点
1.3 层次化设计
在设计程序时,建议对处理的对象进行逐级的定义和初始化,因为计算几何问题往往有着很强的层次性,如多边形在进行三角剖分后处理时,往往需要调用线段之间的操作,而此操作又依赖于点和向量的操作。从简单的几何结构及其操作开始定义,逐步搭建更高级的结构,可以有效降低编程过程中的复杂性。
2 点,向量和线
二维平面上的点和向量都可以用一个二元组来表示,事实上点坐标可以视为一个原点上引出的向量,所以可以将点和向量设计为同一种结构。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct point {
LL x,y;
point operator+(const point &obj)const{
return {x+obj.x,y+obj.y};
}
point operator-(const point &obj)const{
return {x-obj.x,y-obj.y};
}
double norm(){
return sqrt(x*x+y*y+0.0);
}
LL norm2(){
return x*x+y*y;
}
};
double dis(const point &a,const point &b){
return (a-b).norm();
}
2.1 点积和叉积
在计算几何中,向量的点积和叉积是有效的判断方向的手段,在只需要定性而不需要定量的向量朝向分析时,点积和叉积可以胜任绝大多数求角度/求斜率操作能求解的问题。
使用叉积可以判断给定向量在原向量基础上左偏还是右偏,使用点积可以判断给定向量和原向量正向还是反向。不难发现,点积满足交换率,而叉积不满足交换律。
规定
1 | double det(const point &a,const point &b){ |
此外,不难发现叉积的绝对值同时是两向量构成的四边形的面积,所以可以通过叉积快速求出三角形面积。
1 | double areaOfTriangle(const point &a,const point &b,const point &c){ |
2.2 线段(直线)
线段主要有四种储存方式,两点式,点向式,一般式,斜率式。
一般都以两点式存储,因为其不受斜率限制,可以表示任意一条直线,并且可以表示线段的范围,优势比较明显,此外还可以表示方向。
1 | struct segment{ |
但是在计算方程时,斜率式和一般式更为常用,尤其联立解一次以上方程时,常用斜率式,此时需要注意确认是否是铅直线,以防出现除0
的RE
。
对于两点式
如此便可以从两点式转换为斜率式,反过来处理只需代入端点计算即可。至于两点式和点向式的转换,斜率式和一般式的转换,都较为简单,此处不表。一般而言,我们所说的直线均默认以两点式存储。
2.2.1 点在线段上判定
点在线段上等价于
- 点在对应直线上
- 点的横纵坐标在对应范围内
一般而言,在处理问题时,如图形交点,常用直线先求出所有交点,再判断是否在所求线段上,所以第一条条件一般总是满足。大多数情况下只要快速判断第二条即可:
1 | bool isPointOnSegment(const point &o,const segment &l){ |
2.2.2 线线交求交点
如果是直线,直接转为一般式求解即可。如果是线段,只要在此基础上加上点在线段上判定即可。需要说明的是有必要特判斜率不存在的特殊情况。一个玄学的处理方法是开始对所有点旋转一个特定角度以卡掉铅直线,避免讨论。
2.2.3 线线交判定
此处判定特指线段交,因为在欧氏二维空间中,直线不平行必定相交。可以用上述方法进行大讨论,也可以采用快速排斥+跨立实验的方法。
快速排斥实验指:判断两线段所在平行于坐标轴的矩形是否相交。
跨立实验指是指:判断对任意一条线段,另一线段两端点是否在其两侧。
需要说明的是,如果不能通过跨立实验,说明那么必定不可能相交;如果通过夸跨立实验而不通过快速排斥实验,则说明两条直线共线且有交点。
该判定方法有较多文字资料,可以自行查阅。
2.2.4 点线距
点到直线距离有一般式公式:
但是这种写法需要对两点式进行变形,较为麻烦,一般采用面积除以底的形式,利用叉积的性质可以直接得到点
1 | double dis(const point &o,const segment &l){ |
3 圆和三角函数
圆往往和角度有关,所以在本节中,将圆和三角函数放在一起进行讨论。
圆心和半径可以唯一确定一个圆,因此给出圆的定义。1
2
3
4struct circle{
point cn;
double r;
};
为了方便起见,在本节中,如无特殊说明,所有的角度均为弧度制。
3.1 正弦定理和余弦定理
在诸如X点共圆的题目中,求圆心角是一个常见操作。圆心角的一个更一般的表述是,对于给定一点引出的两条向量,求他们之间的夹角(即三角形内角)。
用余弦定理可以容易的得到三角函数值:
如果已知各点均在圆上,在等腰三角形的情况下,可以用正弦定理解三角方程:
当然,直接通过叉积和点积求三角函数也可以,精度相差不大。
3.2 反三角函数求角度
求角度一般而言都绕不过反三角函数,所以角度和图形的转换势必会有较大的精度损失,建议尽量减少求角度的操作。在使用反三角函数时,一般使用acos
而不是用asin
,因为acos
的值域为
,而asin
的值域为
1 | double angle(const point &o,const point &a,const point &b){ |
为了减少求三角函数过程中求向量模长带来的精度损失,也可以使用atan2
减少一步开根操作,提高精度。
1 | double angle(const point &o,const point &a,const point &b){ |
后一种做法的好处不止在于精度,而且去掉fabs
后还可以得到有向的角度,适用性更广泛。劣势在于不能同时得到三角函数值。
3.3 扇形面积
以下两节是圆操作中比较基础的内容,多见于多边形和圆面积交的前置操作。
扇形面积比较简单,有类似三角形的面积公式
其中半径为
这样直接调用上一节的角度公式即可求。1
2
3double areaOfSector(const circle &c,const point &a,const point &b){
return angle(c.cn,a,b)*c.r*c.r/2;
}
3.4 圆和线段交
圆和线段很不好交,因为线段有长度限制。比较好的处理方法是先和直线交,再判断是否在线段上,调用2.2.1节的判定函数。
判断关于直线解个数可以直接用圆心距判断,求交点则可以联立解方程(解方程时的delta
值也可以直接用来判断解个数)。
作者尝试过使用向量做法解交点,但是因为在求解过程中大量使用开根操作(向量模)对向量进行缩放,导致巨大精度损失,所以建议还是使用丑陋的斜率式方程求解,特判垂直情况。在上交的算法书里,介绍了一种点向式带入圆方程的解法,也不失为一种巧妙的解法,用向量规避了无意义的斜率不存在的讨论,同时又利用解析方法避免纯向量方法频繁求模长带来的开根精度损失。
3.4.1 点斜式解法
因为这里涉及到求交点问题,所以在上面point类中应该将成员函数定义为double。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32polygon circleIntersectSegment(const circle &c,const segment &l){
polygon ret;
point a=l.s,b=l.t;
if(a.x==b.x){
double d=fabs(c.cn.x-a.x);
if(d<c.r){
double dy=sqrt(c.r*c.r-d*d);
point p1={a.x,c.cn.y+dy};
point p2={a.x,c.cn.y-dy};
if(isPointOnSegment(p1,l))ret.push_back(p1);
if(isPointOnSegment(p2,l))ret.push_back(p2);
}
}else{
double k=(b.y-a.y)/(b.x-a.x);
double bb=a.y-k*a.x;
double x0=c.cn.x;
double y0=c.cn.y;
double A=k*k+1;
double B=2*(k*(bb-y0)-x0);
double C=x0*x0+(bb-y0)*(bb-y0)-c.r*c.r;
double delta=B*B-4*A*C;
if(delta>0){
double t1=(-B+sqrt(delta))/2/A;
double t2=(-B-sqrt(delta))/2/A;
point p1={t1,k*t1+bb};
point p2={t2,k*t2+bb};
if(isPointOnSegment(p1,l))ret.push_back(p1);
if(isPointOnSegment(p2,l))ret.push_back(p2);
}
}
return ret;
}
在文中polygon
就是vector<point>
,该定义会在第四节中给出。方便起见,相切情况,我们默认产生了两个重合的交点,这样可以避免一些容易产生精度误差的讨论。
3.4.2 点向式解法
设线段一个端点为
带入圆方程
直接求解即可。
4 简单多边形
任何一个简单多边形都可以被认为是一连串点分别连向他们的前驱和后继,所以在存储时,我们只要存储一个有序的点的序列即可。需要说明的是,因为简单,所以要保证没有任何两条线段相交。
1 | typedef vector<point> polygon; |
此外,我们默认头节点和尾节点在多边形存储中是同一节点,因为凸包自成环,不存在逻辑意义上的首尾,所以经常要另行处理头节点和尾节点,方便起见将其存储在两端,就可以避免许多讨论。
4.1 凸包
凸包是绝大多是计算几何问题的核心,在没有圆的情况下尤其如此。对二维凸包的一个感性认识是:在一个平面上有一堆钉子,用一个橡皮筋把他们套起来,橡皮筋的形状结就是这些点构成的凸包。
对于凸包更深入的了解建议进一步观看邓俊辉老师的《计算几何》课程凸包章节,在课程中对凸包能够处理的绝大多数问题都进行了解答和证明。在本文中,本文只会提供一些粗浅的结论,方便读者快速掌握所需知识。
需要说明的是,求凸包的理论下界是
4.1.1 判断点在凸包内
要求凸包,其实就是要把所有在凸包内的点删去,所以先观察凸包内点的性质。
凸包有如下性质:凸包内所有点都在凸包上有向线段的同一侧。用叉积的说法来说,叉出来的结果正负性是一致的。这点不难从观察中发现。于是我们定义toLeft
测试,判断点和有向线段的位置关系。
1 | bool isToLeft(const point &o,const segment &l){ |
返回值为1
说明点在有向线段左侧。如果对所有凸包上所有线段返回值都一致,那么说明点在凸包内。因为不知道凸包旋转方向,所以要做两轮测试。
1 | bool isPointInConvexHull(const point &o,const polygon &p){ |
如果要允许点在凸包上,将toLeft
测试中>
替换为>=
即可。
4.1.2 求凸包
从上一节中,得到一个点在凸包内部的充要条件。现在我们讨论,如何求解凸包。假设已知一条有向边,那么考虑这条有向两侧的点,显然如果两侧都有点,那么这条边必定不在凸包上;反之必定在凸包上。如果能将所有点的角度排序,就可以在线性时间内得到凸壳。排序法+单调栈的凸包算法就是基于该原理,将所有点按照某种顺序排序,然后根据旋转角来决定进栈出栈,算法完成时即得到最终结果。而旋转角可以通过叉积来代替,以避免弱智的三角函数操作。
常见的排序法有graham法和andrew法,他们分别通过极角排序和水平序排序来实现。
Graham先选取一个在凸包上的枢轴点(如横坐标最小的点),按照极角排序,然后单调栈跑一圈即可。
Andrew则是按照水平序排序,然后分上下凸壳跑两次单调栈。两种做法其实并没有太大区别,但是个人比较喜欢水平序排序,因为看起来比较优美(因为不需要另外找一个枢轴点,排水平序的时候自然产生了)。事实上,两者的原理是完全一样的,水平序可以认为是关于一个无穷高的点的极角序。
此处只给出水平序的代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20bool cmp(point a,point b){
return a.x<b.x||(a.x==b.x&&a.y<b.y);
}
polygon convexHull(polygon p){
sort(p.begin(),p.end(),cmp);
polygon ret;
for(int i=0;i<p.size();++i){
while(ret.size()>1&&det(*ret.rbegin(),*++ret.rbegin(),p[i])<=0)ret.pop_back();
ret.push_back(p[i]);
}
int m=ret.size();
for(int i=p.size()-2;~i;--i){
while(ret.size()>m&&det(*ret.rbegin(),*++ret.rbegin(),p[i])<=0)ret.pop_back();
ret.push_back(p[i]);
}
//此段代码中求出凸包,初始点会在末尾位置出现
// ret.pop_back();
return ret;
}
如果要求三点共线的情况下,在凸包上点的个数,则将det
后面的比较操作符进行修改即可。
4.1.3 旋转卡壳
众所周知旋转卡壳有16种读法。但是其用法相对单一:在
从平行线的定义不难看出,该算法可以在
不难发现,对于一个点而言,所有有序的其他点和他的距离是一个单峰函数,一次查询可以使用三分法实现。对于全部点对,则可以采用双指针法,这就是旋转卡壳的基本思想。
以下给出一个最远点线距的实现。
1 | double rotateCalipers(polygon P){ |
如果要计算对踵点或者最远点对的话,只要修改while
内的比较函数为点和点之间的距离即可。
4.2 三角剖分
当所求与面积有关时,可以用带正负面积的三角剖分来实现问题的简化。任意选取一点作为基点
4.2.1 面积
求简单多边形面积只要直接暴力模拟上述过程求解即可。枢轴点可以直接选取原点,注意点类要设置为double
类型。1
2
3
4
5
6
7
8const point o={0.0,0.0};
double areaOfPolygon(const polygon &P){
double ret=0;
for(int i=0;i<P.size()-1;++i){
ret+=det(o,P[i],P[i+1]);
}
return fabs(ret/2);
}
4.2.2 重心
简单多边形的重心不易求解,但是三角形重心就是三点坐标均值。进行三角剖分后,对各三角形按照面积求加权平均值即可得到简单多边形重心。枢轴点也可以直接选取原点,注意点类要设置为double
类型。
1 | const point o={0.0,0.0}; |
4.2.3 与圆面积交
选取圆心为枢轴点进行三角剖分后,可以计算一点在圆心的三角形和原的面积交。接下来只要进行分类讨论即可。
可以简单分为四类:全在里面,一个角在外面,全在外面(不相交,相交)。对于全在外面且相切的情况吗其实和不相交的结果一致,所以可以直接忽略,视为不相交即可。
我们采用的处理手段是把他们分解成扇形和三角形进行处理。
如此便是一堆大讨论的操作。
看起来很复杂的讨论,其实在实现的时候可以通过一些技术手段来规避多数的讨论,从而降低编程的负担。
作者采用的方案是:对两点排序,将离圆心近的视为内点,另一个视为外点。再判断交点个数,如果没有交点,那么要么全内,要么全外,判断任意一点位置即可;如果有一个交点,那么一定是内点到交点为三角,交点到外点为扇面;如果有两个交点,那么一定是两侧扇面,中间三角,即两个交点构成三角,对于原线段上每一个点,找两个交点中较近的一个,构成扇面(如果将相切视为一种情况,则讨论会复杂很多)。
1 | double areaOfCircleIntersectSegment(const circle &c,point a,point b){ |
代码中areaOfTriangle
,areaOfSector
,circleIntersectSegment
,dis
均为上文提及前置函数。
5 数值计算
众所周知,数形结合是解决几何问题的一大法宝。当遇到一坨扭曲的丑陋的图形的时候,解析计算实在太费事了,这个时候数值计算就可以充分利用计算机的算力优势,来暴力解决本该积分/解方程解决的问题。
5.1 插值法
插值法解决这样一类问题:给定一系列的二维离散点,再未定义的位置补差连续函数,使得其通过所有给定离散点。换言之,建立一条关于给定点的拟合函数。
以下介绍的拉格朗日插值和牛顿插值都属于代数插值,在不考虑精度的情况下得到的结果是一致的。因为
5.1.1 拉格朗日插值
拉格朗日插值的核心思想是,对于 1
;当 0
。
对于给定的
举个例子,如果有两项,那么插值多项式为
如此达到了离线的
5.1.2 牛顿插值
拉格朗日插值主要的劣势在于,当插入一个新点时,整个函数需要重新进行计算,影响程序性能;进行了大量的除法运算,引入了浮点误差。
牛顿插值法在逻辑上可以得到和拉格朗日插值法一样的结果,但是在精度上更有优势,并且有着更强的拓展性。
要介绍牛顿插值法的基本原理,要先从低维情形说起,当只有一个点
函数总是通过这个点。
接下来加入一个点
代入
再加入点
解出
更一般地表述是,令
有牛顿插值多项式
一般牛顿迭代法使用差商的方式实现。定义差商
且
经过一通乱算(这里相关证明可以自行查询相关文献,如《自然哲学的数学原理》),可以发现
则有牛顿插值多项式最终表达式
在计算时,用二维数组记录各次差商即可,最终得到的三角矩阵最下一排即是上述系数。可以在
5.1.3 分段线性插值
分段线性插值是一种比较朴素的插值方案,即对每个横坐标相邻的两点见连线得到一条过所有所求点的折线。但是这种做法得到的函数不连续,且因为形式过于简单,拟合效果无法得到保证。
5.2 数值积分
数值积分用来通过暴力手段计算积分。如果一个积分可以通过计算得到,那么就不需要暴力,这里我们讨论的都是难以(或者懒得)通过计算积分得到的情况。
一个比较简单的策略是取一些等距的点,使用上一节中介绍的插值法构造拟合函数,然后对构造出来的函数积分。因为保证是一元高次多项式,所以积分相对容易且可积。但是在高次情形下该方法有着较大的局限性。
另一种数值积分的方案是使用定积分的定义,分割成条,对每个长条用梯形面积公式计算(类似分段线性插值后分段积分)。但是当遇到一些不那么友好的函数时,这种做法很容易发生精度爆炸的惨案。
5.2.1 牛顿-柯斯特公式
牛顿-柯斯特公式对于上述切条分别求面积再求和的算法的拓展。若要对区间
令步长 Cotes
系数
且有数值积分公式
当取
5.2.2 辛普森积分
当
5.2.3 自适应辛普森积分
辛普森积分是最常用的数值积分公式,他只比梯形公式多了一项,却把代数精度提高到了3,可以精确拟合三次及以下的函数。但是如果被积函数只有三次,大可以直接手算积分,当遇到更高次甚至是非多项式函数时,我们需要更加有力的武器。
根据类似积分定义的思路,如果我们将原函数分割的越细密,精度自然就越高。自适应辛普森积分通过对原问题进行分治来取得更高的精度:如果一个辛普森积分精度不够,那就两个。
不妨设被积函数
令中点
那么直接返回当前结果,否则分治处理两个子问题。
1 | double f(double x){ |
6 结语
本文主要介绍了关于计算几何的一些基本知识和思想,并以此为基础简单介绍一些算法。本文的点,向量,圆部分的模板部分借鉴了上交俞勇老师主编的《ACM国际大学生程序设计竞赛算法与实践》,一些计算几何相关但是不太常用的算法,诸如求多边形的核等算法,本文并未涉及,感兴趣的读者可以自行查阅。在凸包部分的证明,作者主要参考了邓俊辉老师的《计算几何》。本文最后数值计算部分则主要从《数值计算》和部分博客中归纳总结而来,因为有些内容年代比较久远,故无法确定来源,如有兴趣可自行查找相关资料。