平面點集的凸包算法

前言

參考翻譯自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是凸包(凸多邊形)。
Def 1

定義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}是輸入的點集。

  1. 從S中挑出一個可以確定是凸包中的點,這很容易做到,只需選取最下最右點(y最小,x最大)即可,時間複雜度是O(n),將該點記爲P0;
  2. 將其他所有點以P0P與x軸的夾角(逆時針方向)爲基準進行排序(從小到大),如果兩個點有相同的夾角則刪除離P0較近的點;爲了高效起見,進行排序比較時,不必計算P1、P2的角度值。事實上,角度的計算需要不精確且比較慢的三角函數計算,會引入數值誤差。如下圖,我們只需觀察P2是否在P0P1的左側,即可判斷P2P0的角度與P1P0的關係。這個計算僅需要一次叉乘操作(參考下面的isLeft函數),非常快速。
    isLeft
    排序之後, 點集S={P0,P1,P2…Pn-1}如下圖所示:
    sorted
    3. 按次序遍歷S中的每個點,檢查其是否屬於凸包頂點。檢查算法是一個利用棧操作的歸納增量過程。

下面對第3步進行詳細講解:

首先將P0入棧,始終確保棧中的點構成凸包。P1直接入棧。

在第k步時,計算當Pk加入後,當前凸包是否改變。由於S是排過序的,因此Pk一定是當前凸包(P0~Pk-1的凸包)之外的一個點,因此Pk作爲一個新的頂點加入到棧中。

但是Pk的加入可能使得之前棧中的頂點不再是凸包頂點,就需要將非凸包頂點從棧中彈出。通過檢查Pk與棧頂兩個點組成的直線的關係(是否在左側,使用isLeft函數)就可做到。有兩種情況:

  • 如果Pk是在左側,那麼先前的凸包是準確的,Pk直接加入到棧中。
  • 如果Pk在棧頂兩個點組成的直線的右側,那麼將棧頂元素彈出,再次進行上述檢查,直到Pk在棧頂兩點組成直線的左側。最後將Pk入棧。
    下圖清晰地說明了第二種情況:
    testPk

接下來,算法處理下一個點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 Φ\Phi
while i < N
{
      Let PT1 = the top point on OMEGA
      If (PT1 == P[0]) {
            Push P[i] onto Φ\Phi
            i++ // increment i
      }
      Let PT2 = the second top point on Φ\Phi
      If (P[i] is strictly left of the line PT2 to PT1) {
            Push P[i] onto Φ\Phi
            i++ // increment i
      }
      else
            Pop the top point PT1 off the stack
}
Output: Φ\Phi = 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,如下圖所示:
chain
接下來,算法構造一個下部的凸包(Lmin之下)Φmin\Phi_{min},以及一個上部的凸包Φmax\Phi_{max}(Lmax之上),然後將兩者合併起來就得到了最終的完整凸包Φ\Phi.

下部和上部的凸包都使用類似與Graham scan中的棧方法來構造。

以下部爲例,首先將Pminmin入棧,然後依次處理minmin到maxmin之間的點。對於Φmin\Phi_{min}只需要考慮在Lmin之下的點。假設在第k步,Pk在Lmin之下,如果棧中只有一個點Pminmin,那麼直接將Pk入棧。否則,需要判斷Pk是否改變了當前的凸包。判斷方法與Graham掃描類似,檢查Pk是否在棧頂兩個元素組成的向量的左側。如果是,直接入棧;如果不是,彈出棧頂元素,繼續檢查直到Pk入棧。所有點都處理後,將Pmaxmin入棧即可,於是就得到了完整的Φmin\Phi_{min}

對於上半部分,以相同的方式處理。不過,需要從Pmaxmax開始,按照降序依次處理S中的點,並且只考慮在Lmax之上的點。當Φmin\Phi_{min}Φmax\Phi_{max}都計算出來後,很容易將兩者合併起來(不過需要注意端點的重複)。

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;
}
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章