前言
參考翻譯自Dan Sunday的文章The Convex hull of a point set
凸包計算是計算集合中的一個經典問題。這一問題有許多變種,其中最普遍的形式是計算平面離散點集的最小凸集(稱爲“凸包”)。這一算法也可以用於多邊形或者一組線段集的凸包求解。在許多領域中都需要用到凸包算法,例如:碰撞檢測、消隱、形狀分析等。
最流行的凸包算法是Graham掃描(Graham scan)算法和Preparata & Hong提出的“分而治之”(Divide-and-Conquer)算法。這兩者的時間複雜度都是O(nlogn),但是Graham算法的常數因子更小,實際運行要快得多。不過“分而治之”算法的優點在於可以方便自然地擴展到3D,乃至更多維度,而Graham算法則不能。
下面是一些著名的2D凸包算法的對比情況(其中,n是點集中點的個數,h是輸出凸包的點數):
算法 | 時間複雜度 | 發明者 |
---|---|---|
禮物包裹算法(Gift Wrapping) | O(nh) | Chand & Kapur, 1970 |
Graham Scan算法 | O(nlogn) | Graham, 1972 |
Jarvis March算法 | O(nh) | Jarvis, 1973 |
QuickHull | O(nh) | Eddy, 1977 |
Divide-and-Conquer | O(nlogn) | Preparata & Hong, 1977 |
單調鏈算法Monotone Chain | O(nlogn) | Andrew, 1979 |
增量算法 | O(nlogn) | Kallay, 1984 |
Marriage-before-Conquest | O(nlogh) | Kirkpatrick & Seidel, 1986 |
凸包的定義
對於一個幾何對象(一個點集或者多邊形),我們將包含所有對象的最小凸集稱爲凸包。但是這種定義太抽象,對於平面點集的凸包,我們可以給出下面幾種等價定義:
定義1:對於S內部任意兩點P和Q,如果線段PQ完全在S內部,那麼S是凸包(凸多邊形)。
定義2:S是凸包,如果它等價於包含它的所有半平面的交集。
這個定義可以這樣理解:逆時針沿着S的邊界走一圈,在每條邊左手側的半平面稱爲“左半平面”,所有邊的左半平面求交集,就得到了一個多邊形區域V,如果V = S,那麼S必是凸多邊形。
定義3: 點集{P}的凸包是所有包含點集{P}的凸多邊形中的面積最小者。
2D凸包算法詳解
下面詳細說明兩個簡單高效的凸包算法:Graham scan算法和Monotone Chain算法。兩者都用到了棧操作(stack)。在實踐中,兩者都非常高效,Monotone Chain算法略快,因爲它事先進行了排序且它的拒絕測試比較高效。
Graham Scan算法
在許多計算幾何的書籍中,都將Graham Scan算法作爲第一個計算幾何經典算法進行講解。因爲隨着問題規模的增長,該算法達到最優的漸近時間效率(O(nlogn))。該算法首次由O’Rourke在1998給出詳細的實現Computational Geometry in C。O(nlogn)的時間上界主要由初始化時,進行的角度排序決定;後續該算法基於棧操作,進行了最多2n次出棧和入棧,時間複雜度是O(n)。因此,算法運行效率很高。
記S={P}是輸入的點集。
- 從S中挑出一個可以確定是凸包中的點,這很容易做到,只需選取最下最右點(y最小,x最大)即可,時間複雜度是O(n),將該點記爲P0;
- 將其他所有點以P0P與x軸的夾角(逆時針方向)爲基準進行排序(從小到大),如果兩個點有相同的夾角則刪除離P0較近的點;爲了高效起見,進行排序比較時,不必計算P1、P2的角度值。事實上,角度的計算需要不精確且比較慢的三角函數計算,會引入數值誤差。如下圖,我們只需觀察P2是否在P0P1的左側,即可判斷P2P0的角度與P1P0的關係。這個計算僅需要一次叉乘操作(參考下面的isLeft函數),非常快速。
排序之後, 點集S={P0,P1,P2…Pn-1}如下圖所示:
3. 按次序遍歷S中的每個點,檢查其是否屬於凸包頂點。檢查算法是一個利用棧操作的歸納增量過程。
下面對第3步進行詳細講解:
首先將P0入棧,始終確保棧中的點構成凸包。P1直接入棧。
在第k步時,計算當Pk加入後,當前凸包是否改變。由於S是排過序的,因此Pk一定是當前凸包(P0~Pk-1的凸包)之外的一個點,因此Pk作爲一個新的頂點加入到棧中。
但是Pk的加入可能使得之前棧中的頂點不再是凸包頂點,就需要將非凸包頂點從棧中彈出。通過檢查Pk與棧頂兩個點組成的直線的關係(是否在左側,使用isLeft函數)就可做到。有兩種情況:
- 如果Pk是在左側,那麼先前的凸包是準確的,Pk直接加入到棧中。
- 如果Pk在棧頂兩個點組成的直線的右側,那麼將棧頂元素彈出,再次進行上述檢查,直到Pk在棧頂兩點組成直線的左側。最後將Pk入棧。
下圖清晰地說明了第二種情況:
接下來,算法處理下一個點Pk+1.
當k = n-1,所有點處理完,保留在棧中的點就是最後的結果。對於S中的每個點,上述算法最多有一次入棧和一次出棧操作,因此總共有最多2n次棧操作。
算法僞代碼歸納如下:
Graham Scan算法僞代碼如下:
Input: a set of points S = {P = (P.x,P.y)}
在S中選擇最下最右點P0,作爲起始點;
以P0爲基準點,將S中的點沿着逆時針方向排序 {
使用 isLeft() 做比較函數,
拋棄同一角度上的距離P0較近的點
}
記 P[N] 爲排序後的點集,其中 P[0]=P0.
Push P[0] and P[1] onto a stack
while i < N
{
Let PT1 = the top point on OMEGA
If (PT1 == P[0]) {
Push P[i] onto ;
i++ // increment i
}
Let PT2 = the second top point on
If (P[i] is strictly left of the line PT2 to PT1) {
Push P[i] onto ;
i++ // increment i
}
else
Pop the top point PT1 off the stack
}
Output: = the convex hull of S.
**************************************************************
// isLeft(): tests if a point is Left|On|Right of an infinite line.
// Input: three points P0, P1, and P2
// Return: >0 for P2 left of the line through P0 and P1
// =0 for P2 on the line
// <0 for P2 right of the line
isLeft( Point P0, Point P1, Point P2 )
{
return (P1.x - P0.x)*(P2.y - P0.y) - (P2.x - P0.x)*(P1.y - P0.y);
}
Monotone Chain算法
Andrew在1979年,提出了Graham scan的一個改進版本,直接通過點的x、y座標值進行線性的排序。由於其比較函數更加簡單,因此比Graham scan中的周向排序速度更快。Monotone Chain算法將點集分爲上下兩個單調鏈,分別計算其凸包,算法由此得名。與Graham scan一樣,其時間複雜度受限於排序算法,也是O(nlogn)。算法的主要步驟如下:
首先,將S={P0,P1,P2…Pn-1}進行按照先x後y的規則(從小到大)進行排序;將x的最小最大值分別記爲xmin、xmax。
記點Pminmin爲x=xmin且y爲x座標相同的點中的最小值,Pminmax爲x=xmin且y爲x座標相同的點中的最大值。當只有一個點的x座標=xmin時,Pminmax=Pminmin。
同樣地,我們定義Pmaxmin爲x=xmax且y爲x座標相同的點中的最小值,Pmaxmax同理。
連接Pminmin與Pmaxmin,我們得到Lmin;連接Pminmax與Pmaxmax,得到Lmax,如下圖所示:
接下來,算法構造一個下部的凸包(Lmin之下),以及一個上部的凸包(Lmax之上),然後將兩者合併起來就得到了最終的完整凸包.
下部和上部的凸包都使用類似與Graham scan中的棧方法來構造。
以下部爲例,首先將Pminmin入棧,然後依次處理minmin到maxmin之間的點。對於只需要考慮在Lmin之下的點。假設在第k步,Pk在Lmin之下,如果棧中只有一個點Pminmin,那麼直接將Pk入棧。否則,需要判斷Pk是否改變了當前的凸包。判斷方法與Graham掃描類似,檢查Pk是否在棧頂兩個元素組成的向量的左側。如果是,直接入棧;如果不是,彈出棧頂元素,繼續檢查直到Pk入棧。所有點都處理後,將Pmaxmin入棧即可,於是就得到了完整的。
對於上半部分,以相同的方式處理。不過,需要從Pmaxmax開始,按照降序依次處理S中的點,並且只考慮在Lmax之上的點。當與都計算出來後,很容易將兩者合併起來(不過需要注意端點的重複)。
Andrew’s Monotone Chain的僞代碼如下:
Input: a set S = {P = (P.x,P.y)} of N points
按照先x後y,從小到大對點集進行排序,
記P[]爲N個點排序之後的數組。
計算4個一定在凸包上的極值點Pminmin、Pminmax、Pmaxmin、Pmaxmax。
按以下步驟計算下部的凸包:
(1)Lmin爲P[minmin]-P[maxmin]的連線
(2)P[minmin]入棧
(3)對於下標在minmax到maxmin之間的點P[i](minmax+1 <= i <=maxmin-1):
if P[i]在Lmin下方 then
while (至少有兩個點在棧中) do
{
PT1爲棧頂元素,PT2爲次頂元素;
if P[i]在向量PT2-PT1的左側 then
Break;
彈出 PT1;
}
P[i]入棧。
(4)P[maxmin]入棧。
同樣地,計算上半部的凸包。
合併上下半部的結果,得到最終的凸包。
Andraw‘s Monotone Chain算法的C++實現
下面是單調鏈算法的一個C++實現。isLeft函數,在上一節中已經給出,這裏不再重複。Point是平面二維點的定義{float x, y;}。
// chainHull_2D(): Andrew's monotone chain 2D convex hull algorithm
// Input: P[] = an array of 2D points
// presorted by increasing x and y-coordinates
// n = the number of points in P[]
// Output: H[] = an array of the convex hull vertices (max is n)
// Return: the number of points in H[]
int chainHull_2D( Point* P, int n, Point* H )
{
// the output array H[] will be used as the stack
int bot=0, top=(-1); // indices for bottom and top of the stack
int i; // array scan index
// Get the indices of points with min x-coord and min|max y-coord
int minmin = 0, minmax;
float xmin = P[0].x;
for (i=1; i<n; i++)
if (P[i].x != xmin) break;
minmax = i-1;
if (minmax == n-1) { // degenerate case: all x-coords == xmin
H[++top] = P[minmin];
if (P[minmax].y != P[minmin].y) // a nontrivial segment
H[++top] = P[minmax];
H[++top] = P[minmin]; // add polygon endpoint
return top+1;
}
// Get the indices of points with max x-coord and min|max y-coord
int maxmin, maxmax = n-1;
float xmax = P[n-1].x;
for (i=n-2; i>=0; i--)
if (P[i].x != xmax) break;
maxmin = i+1;
// Compute the lower hull on the stack H
H[++top] = P[minmin]; // push minmin point onto stack
i = minmax;
while (++i <= maxmin)
{
// the lower line joins P[minmin] with P[maxmin]
if (isLeft( P[minmin], P[maxmin], P[i]) >= 0 && i < maxmin)
continue; // ignore P[i] above or on the lower line
while (top > 0) // there are at least 2 points on the stack
{
// test if P[i] is left of the line at the stack top
if (isLeft( H[top-1], H[top], P[i]) > 0)
break; // P[i] is a new hull vertex
else
top--; // pop top point off stack
}
H[++top] = P[i]; // push P[i] onto stack
}
// Next, compute the upper hull on the stack H above the bottom hull
if (maxmax != maxmin) // if distinct xmax points
H[++top] = P[maxmax]; // push maxmax point onto stack
bot = top; // the bottom point of the upper hull stack
i = maxmin;
while (--i >= minmax)
{
// the upper line joins P[maxmax] with P[minmax]
if (isLeft( P[maxmax], P[minmax], P[i]) >= 0 && i > minmax)
continue; // ignore P[i] below or on the upper line
while (top > bot) // at least 2 points on the upper stack
{
// test if P[i] is left of the line at the stack top
if (isLeft( H[top-1], H[top], P[i]) > 0)
break; // P[i] is a new hull vertex
else
top--; // pop
}
H[++top] = P[i]; // push P[i] onto stack
}
if (minmax != minmin)
H[++top] = P[minmin]; // push joining endpoint onto stack
return top+1;
}