前言
平面點集的凸包算法一文介紹瞭如何計算平面點集或者任意多邊形的凸包。對於隨機的平面點集,Graham scan和Andraw's 單調鏈算法已經是最快的算法了。但是對於沒有自相交的封閉的簡單多邊形,存在線性複雜度的算法。下面介紹這一優雅高效的算法。
一般的2D凸包算法,首先將點進行排序(時間複雜度),然後利用棧操作在O(n)的時間複雜度內計算凸包。初始的排序決定了最終的時間複雜度。但是本文介紹的算法使用一個雙端隊列來進行操作,避免了排序。由於限定了多邊形的簡單性(平面單連通域),可以證明隊列中的點構成凸包。該算法是由Melkman在1987年提出的。
一些多邊形的特徵算法可以通過其凸包來高效地求解,其凸包的解就是原來多邊形的解。因此,對於簡單多邊形有一個快速凸包算法的話,可以加速相應算法的計算。例如多邊形的直徑、切線等算法。
背景
早在1972年,Sklansky就提出了一個O(n)時間複雜度的算法,並給出了實現。不幸的是,6年後Bykat證明他的算法是錯誤的。基於Sklansky的想法,Graham & Yao在1983年修正了這個算法,給出了一個使用棧操作的正確版本,但是算法的實現十分複雜。
最終,Melkman在1987年給出了一個簡潔漂亮的O(n)算法:
- 適用於簡單多段線(不自交);
- 不需要任何預處理,直接按頂點順序處理;
- 使用雙端隊列存儲凸包結果;
- 算法的邏輯非常簡單。
Melkman算法似乎不太可能被超越了。
簡單多邊形凸包算法
Melkman's Algorithm
Melkman, 1987年設計了一種巧妙的方法來實現計算簡單多段線的凸包,下面將對其進行詳細描述。
Melkman Algorithm的策略非常直接,按原始順序依次處理多段線上的每個點。假定輸入多段線爲S={P0,P1,...,Pn}。在每一步,算法將當前爲止處理過的所有頂點形成的凸包存儲在一個雙端隊列中。
接下來,考慮下一個頂點Pk。Pk有兩種情況:(1)Pk在當前凸包內;(2)Pk在當前凸包外,並且形成一個新的凸包頂點。在case (2)中,原來凸包中的點可能變爲在新凸包的內部,需要被丟棄,然後再將Pk加入隊列。
首先給雙端隊列兩個標籤:bot和top,在這中間的是當前凸包結果。在隊列兩端,都可以增加或刪除元素。在頂部(top之上),我們稱爲push / pop;在底部(bot之下),我們將增刪元素的操作稱爲insert / delete。不妨將隊列記爲,是原多段線中的點。當就形成了一個多邊形。在Melkman算法中,處理頂點Pk後,滿足:
- 是多段線的逆時針方向的凸包;
- ,是最近添加到中的點。
對於case(2),我們需要改變,更新隊列。在將Pk添加到隊列兩端之前,需要先將在新凸包內部的點刪除。在隊列的首尾,通過測試Pk是否在頂部的邊的左側,就可以判斷此時top\bot處的點是否需要刪除。繼續這個檢查,直到Pk在隊列兩端的邊的左側。最後,我們將Pk添加到隊列兩端。過程如下圖:
根據上述過程,很容易分析算法的時間複雜度。每個頂點最多添加到隊列中兩次(top和bot各一次),隊列中的點最多被移除一次,每添加/移除一個頂點,最多需要一次常數量級的isLeft判斷。Melkman algorithm最多需要3n次isLeft測試和3n次隊列操作。最佳性能是,2n次測試和4次隊列操作(當最初的3個點構成最終的凸包結果時)。
因此,Melkman算法非常高效,時間複雜度和空間複雜度都是O(n).
算法僞代碼如下:
Input: 有n個頂點的簡單多段線P[i]
將初始3個點加入隊列 D[] ,使得:
a) P[2] 在 D[]的top和bot處
b) 在D[]中,P0、P1、P2形成一個逆時針的三角形
依次處理i=2之後的每一個點,對於P[[i],檢查P[i]是否在D的內部:
if P[i] 在D[bot]D[bot+1] 和 D[top-1]D[top]的左側 then
跳過P[i],接着處理下一個點;
while P[i] is right of D[bot]D[bot+1] do
Delete D[bot] from the bottom of D[];
Insert P[i] at the bottom of D[];
while P[i] is right of D[top-1]D[top] do
Pop D[top] from the top of D[];
Push P[i] onto the top of D[].
Output: D[]就是最終的凸包結果。
C++實現
// Assume that a class is already given for the object:
// Point with coordinates {float x, y;}
//===================================================================
// isLeft(): test 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
// See: Algorithm 1 on Area of Triangles
inline float isLeft( Point P0, Point P1, Point P2 )
{
return (P1.x - P0.x)*(P2.y - P0.y) - (P2.x - P0.x)*(P1.y - P0.y);
}
// simpleHull_2D(): Melkman's 2D simple polyline O(n) convex hull algorithm
// Input: P[] = array of 2D vertex points for a simple polyline
// n = the number of points in V[]
// Output: H[] = output convex hull array of vertices (max is n)
// Return: h = the number of points in H[]
int simpleHull_2D( Point* P, int n, Point* H )
{
// initialize a deque D[] from bottom to top so that the
// 1st three vertices of P[] are a ccw triangle
Point* D = new Point[2*n+1];
int bot = n-2, top = bot+3; // initial bottom and top deque indices
D[bot] = D[top] = P[2]; // 3rd vertex is at both bot and top
if (isLeft(P[0], P[1], P[2]) > 0) {
D[bot+1] = P[0];
D[bot+2] = P[1]; // ccw vertices are: 2,0,1,2
}
else {
D[bot+1] = P[1];
D[bot+2] = P[0]; // ccw vertices are: 2,1,0,2
}
// compute the hull on the deque D[]
for (int i=3; i < n; i++) { // process the rest of vertices
// test if next vertex is inside the deque hull
if ((isLeft(D[bot], D[bot+1], P[i]) > 0) &&
(isLeft(D[top-1], D[top], P[i]) > 0) )
continue; // skip an interior vertex
// incrementally add an exterior vertex to the deque hull
// get the rightmost tangent at the deque bot
while (isLeft(D[bot], D[bot+1], P[i]) <= 0)
++bot; // remove bot of deque
D[--bot] = P[i]; // insert P[i] at bot of deque
// get the leftmost tangent at the deque top
while (isLeft(D[top-1], D[top], P[i]) <= 0)
--top; // pop top of deque
D[++top] = P[i]; // push P[i] onto top of deque
}
// transcribe deque D[] to the output hull array H[]
int h; // hull vertex counter
for (h=0; h <= (top-bot); h++)
H[h] = D[bot + h];
delete D;
return h-1;
}