成爲計算幾何master之路——記算法競賽中常用的計幾算法及思想

成爲計算幾何MASTER(FAKE)之路

1 引言

計算幾何計算機科學的一個重要分支,因此在算法競賽中也是常考的一類題,難度從簽到題到防AK題不等。本文是作者對計算幾何在算法競賽中的解題學應用的一點心得,主要介紹計算幾何專題內比較經典的思想,算法和個人對此的一點心得。本文從邏輯上分爲三個部分,第一部分是闡釋解決有關計算幾何算法問題時的設計思想,第二部分從點,向量,圓,三角,簡單多邊形等計算幾何中主要處理的二維圖像的角度出發,用面向對象的思想介紹類成員函數和成員變量(但是出於程序實現的方便,在設計程序時依然以面向過程爲主),主要採用的手段仍然以解析幾何爲主。最後介紹非解析方法的數值計算技巧,用以解決一類其他的問題。本文將圍繞問題轉化,分類討論等算法設計中常用的思想對上述內容進行闡釋。

1.1 精度

在以解析幾何爲理論背景的計算幾何問題中,精度對程序正確性的影響非常大,其中尤其以開根操作和三角函數操作影響惡劣。此外,受限於計算機存儲空間有限性,在邏輯上無法直接存儲無盡小數(不考慮分數類等間接表示的方法),所以在經過一系列操作後==操作符可能無法判斷邏輯上等價而數值上不等價的表達式。

關於精度,主要就是要解決上述兩個問題:精度降低和因此帶來的等價判斷處理。

第一個問題目前還沒有很好的辦法解決,在部分不需要非線性運算的問題中,可以用分數類來實現邏輯上的精確表述,在輸出結果前不會產生精度損失。但是當遇到開根,三角函數等精度殺手時,分數類就顯得力不從心了。一般而言,當精度要求爲 10610^{-6} 時,可以容忍一次到二次開根操作或者一次(反)三角函數運算。使用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 剖分

在非算法競賽中說的三角剖分,常指在一個簡單多邊形的頂點間連若干條互不相交的線段,將之分解成若干個三角形,從而對於多邊形的面積,重心,面積交等問題時可以通過這些三角形間接求出來。這本質上是一種轉換的思想,將不好處理的多邊形,轉換爲熟悉的三角形,在三角形上進行分類討論來解決各類實際問題。

但是這種做法在實現時非常複雜,先要用掃描線法進行單調多邊形的劃分,然後再在單調多邊形上用掃描線法求出三角剖分,編程複雜度巨大。

考慮一個更加簡便的做法,在求面積的情況下,本質上是對三角形面積的加和。當三角剖分沒有相交時,出現的所有三角形都對結果貢獻了正面積。在這裏我們考慮負面積,對於一個樞軸點 OO , 有多邊形 PP 面積爲 iPopi×opi+1\sum_{i\in P}\vec{op_i}\times\vec{op_{i+1}} 。當叉乘結果爲負時,則對結果貢獻負面積,最終結果和不相交的三角剖分一致。這樣,在處理和麪積相關的問題時,對正負面積分別累計,就可以得到一個更加高效的三角剖分的解法。

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 點積和叉積

在計算幾何中,向量的點積和叉積是有效的判斷方向的手段,在只需要定性而不需要定量的向量朝向分析時,點積和叉積可以勝任絕大多數求角度/求斜率操作能求解的問題。
圖2.1.1
使用叉積可以判斷給定向量在原向量基礎上左偏還是右偏,使用點積可以判斷給定向量和原向量正向還是反向。不難發現,點積滿足交換率,而叉積不滿足交換律。

規定 ×(det)\times (det) 表示叉積 (dot)· (dot) 表示點積,有二維語境下的定義如下:

a×b=a.xb.ya.yb.x=absin<a,b>\vec{a}\times\vec{b} = a.x * b.y - a.y *b.x = |a|*|b|*sin<\vec{a},\vec{b}>

ab=a.xb.x+a.yb.y=abcos<a,b>\vec{a} · \vec{b} = a.x * b.x + a.y *b.y = |a|*|b|*cos<\vec{a},\vec{b}>

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;
};

但是在計算方程時,斜率式和一般式更爲常用,尤其聯立解一次以上方程時,常用斜率式,此時需要注意確認是否是鉛直線,以防出現除0RE

對於兩點式 l(s,t)l(s,t),若 s.x==t.xs.x==t.x 則爲鉛直線,需要另行討論;否則有:

k=(s.yt.y)(s.xt.x)k=\frac{(s.y-t.y)}{(s.x-t.x)}

b=s.yks.xb=s.y-k*s.x

如此便可以從兩點式轉換爲斜率式,反過來處理只需代入端點計算即可。至於兩點式和點向式的轉換,斜率式和一般式的轉換,都較爲簡單,此處不表。一般而言,我們所說的直線均默認以兩點式存儲。

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 點線距

點到直線距離有一般式公式:
d=Ax+By+Cx2+y2d=\frac{|Ax+By+C|}{\sqrt{x^2+y^2}}

但是這種寫法需要對兩點式進行變形,較爲麻煩,一般採用面積除以底的形式,利用叉積的性質可以直接得到點 OO 到線段 A,BA,B 的距離:
d=OA×OBABd=\frac{\vec{OA}\times\vec{OB}}{|\vec{AB}|}

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.1.1
用餘弦定理可以容易的得到三角函數值:
cosθ=a2+b2c22ab\cos\theta=\frac{a^2+b^2-c^2}{2ab}
如果已知各點均在圓上,在等腰三角形的情況下,可以用正弦定理解三角方程:
sinθc=sin(πθ2)r\frac{\sin\theta}{c}=\frac{\sin(\frac{\pi-\theta}{2})}{r}
當然,直接通過叉積和點積求三角函數也可以,精度相差不大。

3.2 反三角函數求角度

求角度一般而言都繞不過反三角函數,所以角度和圖形的轉換勢必會有較大的精度損失,建議儘量減少求角度的操作。在使用反三角函數時,一般使用acos而不是用asin,因爲acos的值域爲 [0,π][0,\pi]
,而asin的值域爲 [π2,π2][-\frac{\pi}{2},\frac{\pi}{2}]

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 扇形面積

以下兩節是圓操作中比較基礎的內容,多見於多邊形和圓面積交的前置操作。

扇形面積比較簡單,有類似三角形的面積公式
S=RL2=L2α2S=\frac{RL}{2}=\frac{L^2\alpha}{2}
其中半徑爲 RR 弧長爲 LL,有對應弧度爲 α=LR\alpha=\frac{L}{R}

這樣直接調用上一節的角度公式即可求。

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 點向式解法

設線段一個端點爲 A(xa,ya)A(x_a,y_a) ,到另一個端點的向量 AB(dx,dy)\vec{AB}(dx,dy),得到向量式 P(xa+tdx,ya+tdy)CircleP(x_a+t*dx,y_a+t*dy)\in Circle

帶入圓方程 (xx0)2+(yy0)2=r2(x-x_0)^2+(y-y_0)^2=r^2 可以得到一個形如 At2Bt+C=0At^2Bt+C=0 的一元二次方程,有
A=dx2+dy2A=dx^2+dy^2

B=2(dx(xax0)+dy(yay0))B=2*(dx*(x_a-x_0)+dy*(y_a-y_0))

C=(xax0)2+(yay0)2r2C=(x_a-x_0)^2+(y_a-y_0)^2-r^2

直接求解即可。

4 簡單多邊形

任何一個簡單多邊形都可以被認爲是一連串點分別連向他們的前驅和後繼,所以在存儲時,我們只要存儲一個有序的點的序列即可。需要說明的是,因爲簡單,所以要保證沒有任何兩條線段相交。

typedef vector<point> polygon;

此外,我們默認頭節點和尾節點在多邊形存儲中是同一節點,因爲凸包自成環,不存在邏輯意義上的首尾,所以經常要另行處理頭節點和尾節點,方便起見將其存儲在兩端,就可以避免許多討論。

4.1 凸包

凸包是絕大多是計算幾何問題的核心,在沒有圓的情況下尤其如此。對二維凸包的一個感性認識是:在一個平面上有一堆釘子,用一個橡皮筋把他們套起來,橡皮筋的形狀結就是這些點構成的凸包。

對於凸包更深入的瞭解建議進一步觀看鄧俊輝老師的《計算幾何》課程凸包章節,在課程中對凸包能夠處理的絕大多數問題都進行了解答和證明。在本文中,本文只會提供一些粗淺的結論,方便讀者快速掌握所需知識。

需要說明的是,求凸包的理論下界是 O(log2n)O(log_2n) 的,可以規約到排序問題來證明。對於任意一個排序問題,可以將他們的值在線性時間內映射到二維空間中的一根輔助線(如拋物線)上,調用凸包算法,並且在線性時間內遍歷輸出得到排序結果。如此就通過規約方法證明了凸包求解算法的理論複雜度下界。

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先選取一個在凸包上的樞軸點(如橫座標最小的點),按照極角排序,然後單調棧跑一圈即可。
圖4.1.2.1
Andrew則是按照水平序排序,然後分上下凸殼跑兩次單調棧。兩種做法其實並沒有太大區別,但是個人比較喜歡水平序排序,因爲看起來比較優美(因爲不需要另外找一個樞軸點,排水平序的時候自然產生了)。事實上,兩者的原理是完全一樣的,水平序可以認爲是關於一個無窮高的點的極角序。
4.1.2.2

此處只給出水平序的代碼。

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種讀法。但是其用法相對單一:在 O(n)O(n) 時間內計算給定凸上的所有對踵點。感性認識對踵點,就是從某個點出發,能走到的最遠點,這兩個點構成一組對踵點。更科學的表述是,如果能用一組平行線將凸包包裹,那麼在平行線上的點對都是對踵點。在凸包上,該關係具有對稱性。

從平行線的定義不難看出,該算法可以在 O(n)O(n) 時間內快速求出凸包最遠點對/最遠點線距。

不難發現,對於一個點而言,所有有序的其他點和他的距離是一個單峯函數,一次查詢可以使用三分法實現。對於全部點對,則可以採用雙指針法,這就是旋轉卡殼的基本思想。

以下給出一個最遠點線距的實現。

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 三角剖分

當所求與面積有關時,可以用帶正負面積的三角剖分來實現問題的簡化。任意選取一點作爲基點 oo,和簡單多邊形上有向相鄰的兩點 pi,pi+1p_i,p_{i+1} 組成三角形,根據叉積 det(o,pi,pi+1)det(o,p_i,p_{i+1}) 判斷正負性即可。

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 與圓面積交

選取圓心爲樞軸點進行三角剖分後,可以計算一點在圓心的三角形和原的面積交。接下來只要進行分類討論即可。

可以簡單分爲四類:全在裏面,一個角在外面,全在外面(不相交,相交)。對於全在外面且相切的情況嗎其實和不相交的結果一致,所以可以直接忽略,視爲不相交即可。
4.2.3.1
我們採用的處理手段是把他們分解成扇形和三角形進行處理。
4.2.3.2
如此便是一堆大討論的操作。

看起來很複雜的討論,其實在實現的時候可以通過一些技術手段來規避多數的討論,從而降低編程的負擔。

作者採用的方案是:對兩點排序,將離圓心近的視爲內點,另一個視爲外點。再判斷交點個數,如果沒有交點,那麼要麼全內,要麼全外,判斷以下兩點位置;如果有一個交點,那麼一定時內點到交點爲三角,交點到外點爲扇面;如果有兩個交點,那麼一定是兩側扇面,中間三角,即兩個交點構成三角,對於原線段上每一個點,找兩個交點中較近的一個,構成扇面(如果將相切視爲一種情況,則討論會複雜很多)。

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;
}

代碼中areaOfTriangleareaOfSectorcircleIntersectSegmentdis均爲上文提及前置函數。

5 數值計算

衆所周知,數形結合是解決幾何問題的一大法寶。當遇到一坨扭曲的醜陋的圖形的時候,解析計算實在太費事了,這個時候數值計算就可以充分利用計算機的算力優勢,來暴力解決本該積分/解方程解決的問題。

5.1 插值法

插值法解決這樣一類問題:給定一系列的二維離散點,再未定義的位置補差連續函數,使得其通過所有給定離散點。換言之,建立一條關於給定點的擬合函數。

以下介紹的兩種插值方法都屬於代數插值,在不考慮精度的情況下得到的結果是一致的。因爲 nn 個點構造的一元 n1n-1 次方程是唯一的。用待定係數法,列方程,克拉默法則求解易證結果的唯一性。

5.1.1 拉格朗日插值

拉格朗日插值的核心思想是,對於 nn 個點,構造一個 nn 項的多項式,當 x=xix=x_i 時,使第 ii 項爲 1;當 xxix\not=x_i 時,使第 ii 項爲 0

對於給定的 nn 個點 (x0,y0),(x1,y1)(xn1,yn1)(x_0,y_0),(x_1,y_1)\dots(x_{n-1},y_{n-1}),可以構造如下插值多項式

P(x)=i<nij,j<nxxjxixjyiP(x)=\sum_{i<n}\prod_{i\not=j,j<n}\frac{x-x_j}{x_i-x_j}y_i

舉個例子,如果有兩項,那麼插值多項式爲

f(x)=xx1x0x1y0+xx0x1x0y1f(x)=\frac{x-x_1}{x_0-x_1}y_0+\frac{x-x_0}{x_1-x_0}y_1

如此達到了離線的 O(n2)O(n^2) 複雜度的單次構造,查詢。

5.1.2 牛頓插值

拉格朗日插值主要的劣勢在於,當插入一個新點時,整個函數需要重新進行計算,影響程序性能;進行了大量的除法運算,引入了浮點誤差。

牛頓插值法在邏輯上可以得到和拉格朗日插值法一樣的結果,但是在精度上更有優勢,並且有着更強的拓展性。

要介紹牛頓插值法的基本原理,要先從低維情形說起,當只有一個點 (x0,y0)(x_0,y_0) 時,直接構造
f0(x)=y0f_0(x)=y_0
函數總是通過這個點。

接下來加入一個點 (x1,y1)(x_1,y_1),我們希望不影響之前經過的點,並且在此基礎上經過新點,於是構造一個 b1(xx0)b_1(x-x_0) 的累加項使得新函數不影響函數在 x=x0x=x_0 處的函數值,即
f1(x)=f0(x)+b1(xx0)f_1(x)=f_0(x)+b_1(x-x_0)
代入 (x1,y1)(x_1,y_1) 可以求出 b1b_1,從而也能通到新點上。

再加入點 (x2,y2)(x_2,y_2),類似地,構造
f2(x)=f1(x)+b2(xx0)(xx1)f_2(x)=f_1(x)+b_2(x-x_0)(x-x_1)
解出 b2b_2 即可。迭代展開即可得到牛頓插值多項式,當插入新點時,按照上述操作繼續拓展即可,每次解 bb 都是求一個一元一次函數的過程。

更一般地表述是,令
ϕi(x)=j<i(xxj)\phi_i(x)=\prod_{j<i}(x-x_j)
有牛頓插值多項式
f(x)=i<nbiϕi(x)f(x)=\sum_{i<n}b_i\phi_i(x)

一般牛頓迭代法使用差商的方式實現。定義差商
f[xn1,xn2x0]=f[xn1x1]f[xn2x0]xn1x0f[x_{n-1},x_{n-2}\cdots x_0]=\frac{f[x_{n-1}\cdots x_1]-f[x_{n-2}\cdots x_0]}{x_{n-1}-x_0}


f[xi]=f(xi)f[x_i]=f(x_i)

經過一通亂算(這裏相關證明可以自行查詢相關文獻,如《自然哲學的數學原理》),可以發現
f[xn,xn1x0]=bnf[x_{n},x_{n-1}\cdots x_0]=b_n

則有牛頓插值多項式最終表達式
f(x)=f[x0]+f[x1,x0](xx0)++f[xn1,xn2x0](xxn2)(xx0)f(x) = f[x_0]+f[x_1,x_0](x-x_0)+\dots+f[x_{n-1},x_{n-2}\dots x_0](x-x_{n-2})\cdots(x-x_0)

在計算時,用二維數組記錄各次差商即可,最終得到的三角矩陣最下一排即是上述係數。可以在 O(n2)O(n^2) 時間內在線計算,求解。

5.1.3 分段線性插值

分段線性插值是一種比較樸素的插值方案,即對每個橫座標相鄰的兩點見連線得到一條過所有所求點的折現。但是這種做法得到的函數不連續,且因爲形式過於簡單,擬合效果無法得到保證。

5.2 數值積分

數值積分用來通過暴力手段計算積分。如果一個積分可以通過計算得到,那麼就不需要暴力,這裏我們討論的都是難以(或者懶得)通過計算積分得到的情況。

一個比較簡單的策略是取一些等距的點,使用上一節中介紹的插值法構造擬合函數,然後對構造出來的函數積分。因爲保證是一元高次多項式,所以積分相對容易且可積。但是在高次情形下該方法有着較大的侷限性。

另一種數值積分的方案是使用定積分的定義,分割成條,對每個長條用梯形面積公式計算(類似分段線性插值後分段積分)。但是當遇到一些不那麼友好的函數時,這種做法很容易發生精度爆炸的慘案。

5.2.1 牛頓-柯斯特公式

牛頓-柯斯特公式是一種插值形的公式。假設區間 I=[a,b]I=[a,b] 被等分,取 xk=a+kbanx_k=a+k·\frac{b-a}{n} ,可以寫成:

5.2.2 辛普森積分

5.2.3 自適應辛普森積分

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章