前言
参考翻译自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;
}