成为计算几何MASTER(FAKE)之路
1 引言
计算几何计算机科学的一个重要分支,因此在算法竞赛中也是常考的一类题,难度从签到题到防AK题不等。本文是作者对计算几何在算法竞赛中的解题学应用的一点心得,主要介绍计算几何专题内比较经典的思想,算法和个人对此的一点心得。本文从逻辑上分为三个部分,第一部分是阐释解决有关计算几何算法问题时的设计思想,第二部分从点,向量,圆,三角,简单多边形等计算几何中主要处理的二维图像的角度出发,用面向对象的思想介绍类成员函数和成员变量(但是出于程序实现的方便,在设计程序时依然以面向过程为主),主要采用的手段仍然以解析几何为主。最后介绍非解析方法的数值计算技巧,用以解决一类其他的问题。本文将围绕问题转化,分类讨论等算法设计中常用的思想对上述内容进行阐释。
1.1 精度
在以解析几何为理论背景的计算几何问题中,精度对程序正确性的影响非常大,其中尤其以开根操作和三角函数操作影响恶劣。此外,受限于计算机存储空间有限性,在逻辑上无法直接存储无尽小数(不考虑分数类等间接表示的方法),所以在经过一系列操作后==
操作符可能无法判断逻辑上等价而数值上不等价的表达式。
关于精度,主要就是要解决上述两个问题:精度降低和因此带来的等价判断处理。
第一个问题目前还没有很好的办法解决,在部分不需要非线性运算的问题中,可以用分数类来实现逻辑上的精确表述,在输出结果前不会产生精度损失。但是当遇到开根,三角函数等精度杀手时,分数类就显得力不从心了。一般而言,当精度要求为
时,可以容忍一次到二次开根操作或者一次(反)三角函数运算。使用long double
可以略微提升精度,但是效果不明显。
在等价判断上,一般设一个所需精度级别的误差量,当两个数之差小于该误差量时,可以认为这两个数相等。因为这最多带来小于所需精度级别的误差,基本可以认为他是安全的。当该操作后还要套很多操作时,可以适当减小这个误差量。使用一个cmp
函数来实现比较功能,返回值类似java
中的 compareTo
函数。
const double EPS=1e-6;
int cmp(const double &a,const double &b){
if(fabs(a-b)<EPS)return 0;
return a>b?1:-1;
}
另一个常用的手段是用long long
存储数据。在处理不涉及距离,面积的问题时(或者只在最后一步求),如凸包(只需要处理叉积),可以判断性的操作都在整数范围下完成,只在计算距离面积数值时才转换为浮点型计算。这样可以有效避免各种浮点误差。
1.2 剖分
在非算法竞赛中说的三角剖分,常指在一个简单多边形的顶点间连若干条互不相交的线段,将之分解成若干个三角形,从而对于多边形的面积,重心,面积交等问题时可以通过这些三角形间接求出来。这本质上是一种转换的思想,将不好处理的多边形,转换为熟悉的三角形,在三角形上进行分类讨论来解决各类实际问题。
但是这种做法在实现时非常复杂,先要用扫描线法进行单调多边形的划分,然后再在单调多边形上用扫描线法求出三角剖分,编程复杂度巨大。
考虑一个更加简便的做法,在求面积的情况下,本质上是对三角形面积的加和。当三角剖分没有相交时,出现的所有三角形都对结果贡献了正面积。在这里我们考虑负面积,对于一个枢轴点 , 有多边形 面积为 。当叉乘结果为负时,则对结果贡献负面积,最终结果和不相交的三角剖分一致。这样,在处理和面积相关的问题时,对正负面积分别累计,就可以得到一个更加高效的三角剖分的解法。
1.3 层次化设计
在设计程序时,建议对处理的对象进行逐级的定义和初始化,因为计算几何问题往往有着很强的层次性,如多边形在进行三角剖分后处理时,往往需要调用线段之间的操作,而此操作又依赖于点和向量的操作。从简单的几何结构及其操作开始定义,逐步搭建更高级的结构,可以有效降低编程过程中的复杂性。
2 点,向量和线
二维平面上的点和向量都可以用一个二元组来表示,事实上点坐标可以视为一个原点上引出的向量,所以可以将点和向量设计为同一种结构。
#define LL long long
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 点积和叉积
在计算几何中,向量的点积和叉积是有效的判断方向的手段,在只需要定性而不需要定量的向量朝向分析时,点积和叉积可以胜任绝大多数求角度/求斜率操作能求解的问题。
使用叉积可以判断给定向量在原向量基础上左偏还是右偏,使用点积可以判断给定向量和原向量正向还是反向。不难发现,点积满足交换率,而叉积不满足交换律。
规定 表示叉积 表示点积,有二维语境下的定义如下:
double det(const point &a,const point &b){
return a.x*b.y-a.y*b.x;
}
double det(const point &o,const point &a,const point &b){
return det(a-o,b-o);
}
double dot(const point &a,const point &b){
return a.x*b.x+a.y*b.y;
}
double dot(const point &o,const point &a,const point &b){
return dot(a-o,b-o);
}
此外,不难发现叉积的绝对值同时是两向量构成的四边形的面积,所以可以通过叉积快速求出三角形面积。
double areaOfTriangle(const point &a,const point &b,const point &c){
return fabs(det(a,b,c)/2);
}
2.2 线段(直线)
线段主要有四种储存方式,两点式,点向式,一般式,斜率式。
一般都以两点式存储,因为其不受斜率限制,可以表示任意一条直线,并且可以表示线段的范围,优势比较明显,此外还可以表示方向。
struct segment{
point s,t;
};
但是在计算方程时,斜率式和一般式更为常用,尤其联立解一次以上方程时,常用斜率式,此时需要注意确认是否是铅直线,以防出现除0
的RE
。
对于两点式 ,若 则为铅直线,需要另行讨论;否则有:
如此便可以从两点式转换为斜率式,反过来处理只需代入端点计算即可。至于两点式和点向式的转换,斜率式和一般式的转换,都较为简单,此处不表。一般而言,我们所说的直线均默认以两点式存储。
2.2.1 点在线段上判定
点在线段上等价于
- 点在对应直线上
- 点的横纵坐标在对应范围内
一般而言,在处理问题时,如图形交点,常用直线先求出所有交点,再判断是否在所求线段上,所以第一条条件一般总是满足。大多数情况下只要快速判断第二条即可:
bool isPointOnSegment(const point &o,const segment &l){
double mix=min(l.s.x,l.s.x);
double mxx=max(l.s.x,l.s.x);
double miy=min(l.s.y,l.s.y);
double mxy=max(l.s.y,l.s.y);
return mix<=o.x&&o.x<=mxx&&miy<=o.y&&o.y<=mxy;
}
2.2.2 线线交求交点
如果是直线,直接转为一般式求解即可。如果是线段,只要在此基础上加上点在线段上判定即可。需要说明的是有必要特判斜率不存在的特殊情况。一个玄学的处理方法是开始对所有点旋转一个特定角度以卡掉铅直线,避免讨论。
2.2.3 线线交判定
此处判定特指线段交,因为在欧氏二维空间中,直线不平行必定相交。可以用上述方法进行大讨论,也可以采用快速排斥+跨立实验的方法。
快速排斥实验指:判断两线段所在平行于坐标轴的矩形是否相交。
跨立实验指是指:判断对任意一条线段,另一线段两端点是否在其两侧。
需要说明的是,如果不能通过跨立实验,说明那么必定不可能相交;如果通过夸跨立实验而不通过快速排斥实验,则说明两条直线共线且有交点。
该判定方法有较多文字资料,可以自行查阅。
2.2.4 点线距
点到直线距离有一般式公式:
但是这种写法需要对两点式进行变形,较为麻烦,一般采用面积除以底的形式,利用叉积的性质可以直接得到点
到线段
的距离:
double dis(const point &o,const segment &l){
return fabs(det(o,l.s,l.t)/dis(l.s,l.t));
}
3 圆和三角函数
圆往往和角度有关,所以在本节中,将圆和三角函数放在一起进行讨论。
圆心和半径可以唯一确定一个圆,因此给出圆的定义。
struct circle{
point cn;
double r;
};
为了方便起见,在本节中,如无特殊说明,所有的角度均为弧度制。
3.1 正弦定理和余弦定理
在诸如X点共圆的题目中,求圆心角是一个常见操作。圆心角的一个更一般的表述是,对于给定一点引出的两条向量,求他们之间的夹角(即三角形内角)。
用余弦定理可以容易的得到三角函数值:
如果已知各点均在圆上,在等腰三角形的情况下,可以用正弦定理解三角方程:
当然,直接通过叉积和点积求三角函数也可以,精度相差不大。
3.2 反三角函数求角度
求角度一般而言都绕不过反三角函数,所以角度和图形的转换势必会有较大的精度损失,建议尽量减少求角度的操作。在使用反三角函数时,一般使用acos
而不是用asin
,因为acos
的值域为
,而asin
的值域为
。
double angle(const point &o,const point &a,const point &b){
return acos(1.0*dot(o,a,b)/dis(a,o)/dis(b,o));
}
为了减少求三角函数过程中求向量模长带来的精度损失,也可以使用atan2
减少一步开根操作,提高精度。
double angle(const point &o,const point &a,const point &b){
point da=a-o;
point db=b-o;
return fabs(atan2(1.0*da.y,1.0*da.x)-atan2(1.0*db.y,1.0*db.x));
}
后一种做法的好处不止在于精度,而且去掉fabs
后还可以得到有向的角度,适用性更广泛。劣势在于不能同时得到三角函数值。
3.3 扇形面积
以下两节是圆操作中比较基础的内容,多见于多边形和圆面积交的前置操作。
扇形面积比较简单,有类似三角形的面积公式
其中半径为
弧长为
,有对应弧度为
。
这样直接调用上一节的角度公式即可求。
double 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。
polygon 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 简单多边形
任何一个简单多边形都可以被认为是一连串点分别连向他们的前驱和后继,所以在存储时,我们只要存储一个有序的点的序列即可。需要说明的是,因为简单,所以要保证没有任何两条线段相交。
typedef vector<point> polygon;
此外,我们默认头节点和尾节点在多边形存储中是同一节点,因为凸包自成环,不存在逻辑意义上的首尾,所以经常要另行处理头节点和尾节点,方便起见将其存储在两端,就可以避免许多讨论。
4.1 凸包
凸包是绝大多是计算几何问题的核心,在没有圆的情况下尤其如此。对二维凸包的一个感性认识是:在一个平面上有一堆钉子,用一个橡皮筋把他们套起来,橡皮筋的形状结就是这些点构成的凸包。
对于凸包更深入的了解建议进一步观看邓俊辉老师的《计算几何》课程凸包章节,在课程中对凸包能够处理的绝大多数问题都进行了解答和证明。在本文中,本文只会提供一些粗浅的结论,方便读者快速掌握所需知识。
需要说明的是,求凸包的理论下界是 的,可以规约到排序问题来证明。对于任意一个排序问题,可以将他们的值在线性时间内映射到二维空间中的一根辅助线(如抛物线)上,调用凸包算法,并且在线性时间内遍历输出得到排序结果。如此就通过规约方法证明了凸包求解算法的理论复杂度下界。
4.1.1 判断点在凸包内
要求凸包,其实就是要把所有在凸包内的点删去,所以先观察凸包内点的性质。
凸包有如下性质:凸包内所有点都在凸包上有向线段的同一侧。用叉积的说法来说,叉出来的结果正负性是一致的。这点不难从观察中发现。于是我们定义toLeft
测试,判断点和有向线段的位置关系。
bool isToLeft(const point &o,const segment &l){
return det(l.s,l.t,o)>0;
}
返回值为1
说明点在有向线段左侧。如果对所有凸包上所有线段返回值都一致,那么说明点在凸包内。因为不知道凸包旋转方向,所以要做两轮测试。
bool isPointInConvexHull(const point &o,const polygon &p){
bool tmp=1;
for(int i=0;i<p.size()-1;++i){
if(!isToLeft(o,(segment)(p[i],p[i+1])))tmp=0;
}
if(tmp)return 1;
bool tmp=1;
for(int i=0;i<p.size()-1;++i){
if(!isToLeft(o,(segment)(p[i+1],p[i])))tmp=0;
}
if(tmp)return 1;
return 0;
}
如果要允许点在凸包上,将toLeft
测试中>
替换为>=
即可。
4.1.2 求凸包
从上一节中,得到一个点在凸包内部的充要条件。现在我们讨论,如何求解凸包。假设已知一条有向边,那么考虑这条有向两侧的点,显然如果两侧都有点,那么这条边必定不在凸包上;反之必定在凸包上。如果能将所有点的角度排序,就可以在线性时间内得到凸壳。排序法+单调栈的凸包算法就是基于该原理,将所有点按照某种顺序排序,然后根据旋转角来决定进栈出栈,算法完成时即得到最终结果。而旋转角可以通过叉积来代替,以避免弱智的三角函数操作。
常见的排序法有graham法和andrew法,他们分别通过极角排序和水平序排序来实现。
Graham先选取一个在凸包上的枢轴点(如横坐标最小的点),按照极角排序,然后单调栈跑一圈即可。
Andrew则是按照水平序排序,然后分上下凸壳跑两次单调栈。两种做法其实并没有太大区别,但是个人比较喜欢水平序排序,因为看起来比较优美(因为不需要另外找一个枢轴点,排水平序的时候自然产生了)。事实上,两者的原理是完全一样的,水平序可以认为是关于一个无穷高的点的极角序。
此处只给出水平序的代码。
bool 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种读法。但是其用法相对单一:在 时间内计算给定凸上的所有对踵点。感性认识对踵点,就是从某个点出发,能走到的最远点,这两个点构成一组对踵点。更科学的表述是,如果能用一组平行线将凸包包裹,那么在平行线上的点对都是对踵点。在凸包上,该关系具有对称性。
从平行线的定义不难看出,该算法可以在 时间内快速求出凸包最远点对/最远点线距。
不难发现,对于一个点而言,所有有序的其他点和他的距离是一个单峰函数,一次查询可以使用三分法实现。对于全部点对,则可以采用双指针法,这就是旋转卡壳的基本思想。
以下给出一个最远点线距的实现。
double rotateCalipers(polygon P){
double ret=1e15+7;
int p=1;
for(int i=0;i<P.size()-1;++i){
while(abs(det(P[i],P[i+1],P[p]))<abs(det(P[i],P[i+1],P[p+1]))){
++p;
p%=P.size()-1;
}
double tmp=abs(det(P[i],P[i+1],P[p]));
tmp/=dis(P[i],P[i+1]);
ret=min(tmp,ret);
}
return ret;
}
如果要计算对踵点或者最远点对的话,只要修改while
内的比较函数为点和点之间的距离即可。
4.2 三角剖分
当所求与面积有关时,可以用带正负面积的三角剖分来实现问题的简化。任意选取一点作为基点 ,和简单多边形上有向相邻的两点 组成三角形,根据叉积 判断正负性即可。
4.2.1 面积
求简单多边形面积只要直接暴力模拟上述过程求解即可。枢轴点可以直接选取原点,注意点类要设置为double
类型。
const 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
类型。
const point o={0.0,0.0};
point centroidOfPolygon(const polygon &P){
point ret={0.0,0.0};
double sum=0.0;
for(int i=0;i<P.size()-1;++i){
double tmp=det(o,P[i],P[i+1]);
ret=ret+(point){(P[i].x+P[i+1].x)*tmp,(P[i].y+P[i+1].y)*tmp};
sum+=tmp;
}
return {ret.x/sum/3,ret.y/sum/3};
}
4.2.3 与圆面积交
选取圆心为枢轴点进行三角剖分后,可以计算一点在圆心的三角形和原的面积交。接下来只要进行分类讨论即可。
可以简单分为四类:全在里面,一个角在外面,全在外面(不相交,相交)。对于全在外面且相切的情况吗其实和不相交的结果一致,所以可以直接忽略,视为不相交即可。
我们采用的处理手段是把他们分解成扇形和三角形进行处理。
如此便是一堆大讨论的操作。
看起来很复杂的讨论,其实在实现的时候可以通过一些技术手段来规避多数的讨论,从而降低编程的负担。
作者采用的方案是:对两点排序,将离圆心近的视为内点,另一个视为外点。再判断交点个数,如果没有交点,那么要么全内,要么全外,判断以下两点位置;如果有一个交点,那么一定时内点到交点为三角,交点到外点为扇面;如果有两个交点,那么一定是两侧扇面,中间三角,即两个交点构成三角,对于原线段上每一个点,找两个交点中较近的一个,构成扇面(如果将相切视为一种情况,则讨论会复杂很多)。
double areaOfCircleIntersectSegment(const circle &c,point a,point b){
double ret=0.0;
if(dis(c.cn,a)>dis(c.cn,b))swap(a,b);
if(dis(c.cn,b)<=c.r){
ret=areaOfTriangle(c.cn,a,b);
}else{
polygon p=circleIntersectSegment(c,a,b);
switch (p.size())
{
case 0:
ret=areaOfSector(c,a,b);
break;
case 1:
ret=areaOfTriangle(c.cn,a,p[0])+areaOfSector(c,p[0],b);
break;
case 2:
if(dis(p[0],a)>dis(p[1],a))swap(p[0],p[1]);
ret=areaOfTriangle(c.cn,p[0],p[1])+areaOfSector(c,a,p[0])+areaOfSector(c,b,p[1]);
break;
}
}
return ret;
}
代码中areaOfTriangle
,areaOfSector
,circleIntersectSegment
,dis
均为上文提及前置函数。
数值计算
插值法
拉格朗日插值
牛顿插值
数值积分
牛顿-柯斯特公式
辛普森积分
自适应辛普森积分
转载:https://blog.csdn.net/qq_42778110/article/details/105976088