平面点集的凸包算法

前言

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