1 平面点集的凸包
1.1 凸包的定义
将平面有限点集 P 的凸包定义为:顶点取自于 P 且包含 P 中所有点的那个唯一的凸多边形(convex polygon)。
正如我们已经定义的,P 的凸包是一个凸多边形。表示多边形的一种自然的方法,就是从任一顶点开始,沿顺时针方向依次列出所有顶点。因此,我们所要求解的问题就变成:
1.2 凸包的简单实现
1.2.1 实现思路及算法
根据凸包的定义,很容易得出,相对于凸包边界上任一边所在的直线,P中所有点均居于同侧。
如下图:
凸包某条边的端点p和q都来自于P;另外,只要适当地定义由p和q所确定直线的方向,使得CH§总
是位于其右侧,那么P中的所有点也都将落在该直线的右侧(如 图1所示)。反之亦然:如果相对
于由p和q确定的直线,P中的所有点都位于右侧,那么pq就是构成凸包CH§的一条边。由定义可以得出简单的凸包计算方法:
1.2.2 算法分析
SLOW CONVEX HULL 算法的复杂度分析:
总共要检查nx(n-1)直线,也就是要检查 对点。对每一对点,要检查其它的 n – 2 个点,看看它们是否都位于(该点对所确定有向线段的)右侧。这总共需要运行 O( )时间。最后一步需要 O( )时间,故总体的时间复杂度为O(
在实际应用中,这样一个需要运行三次方时间的算法,除非是处理小规模的输入集,否则都会由于太慢而毫无用处。听此没有做实现,只是了解就好。感兴趣的读者可以自己去实现一下。
1.3 凸包的实现
下面来介绍一个比较好的计算凸包的算法——递增式或者增量式算法。
1.3.1 实现思路及算法
设计一个递增式算法incremental algorithm),顾名思义,我们将逐一引入 P 中各点;每增加一个点,都要相应地更新目前的解。这个递增式方法将沿用几何上的习惯,按照由左到右的次序加入各点。
于是,首先需要根据 x-坐标对所有点进行排序,产生一个有序的序列:p 1 , …, p n 。
接下来,我们将按照这一顺序,将它们逐一引入。本来,既然是自左而右地进行处理,所以要是凸包上的顶点也能按照它们在边界上出现的次序自左向右地排列,将会更加方便。
(然而,情况并没有这样好。)因此,我们将首先计算出构成上凸包(upper hull)的那些顶点。
如 图 1-11 所示,所谓的上凸包,就是从最左端顶点p1 出发,沿着凸包顺时针行进到最右端顶
点pn 之间的那段。换而言之,组成上凸包的,就是从上方界定凸包的那些边。此后,再自右向左进
行一次扫描,计算出凸包的剩余部分⎯⎯下凸包(lower hull)。
该递增式算法的基本步骤,就是在每次新引入一个点 p i 之后,对上凸包做相应的更新。也就是
说,已知点 p 1 , …, p i-1 所对应的上凸包,计算出 p 1 , …, p i 所对应的上凸包。
在新引入 p i 之后,可以进行如下处理。令 L upper 为从左向右存放上凸包各顶点的一个列表。
首先,将p i 接在 L upper 的最后⎯⎯既然在目前已经加入的所有点中,p i 是最靠右的,则它必然是(当前)上凸
包的一个顶点,所以这样做无可厚非。
然后,再检查 L upper 中最末尾的三个点,看看它们是否构成一个右拐(right-turn)。若构成右拐,则大功告成,此时(更新后的)L upper 记录了组成上凸包的各个顶点 p 1 , …, p i ,
接下来,就可以继续处理下一个点⎯⎯p i+1 。然而,若最后的三个点构成一个左拐(left-turn),就必须将中间的(即倒数第二个)顶点从上凸包中剔除出去。
若出现这种情况,需要做的可能还远不止这些⎯⎯因为,此时的最后三个点可能仍然构成一个左拐(如 图 1-12 所示)。果真如此,就必须再次将中间的顶点剔除掉。这一过程需要反复进行,直到位于最后的三个点构成一个右拐,或者仅剩下两个点。
下面将给出该算法的伪代码。这段代码既计算上凸包,也计算下凸包。
1.3.2 算法分析
给定包含 n 个点的任意一个平面点集,该方法计算凸包的时间复杂度为 O(nlogn)。
因为在其中用到了排序算法,而按照字典序对各点进行排序,需要 O(nlogn)时间。
接下来,考虑上凸包的计算。for-循环要执行的趟数是线性的。这样,只需要考虑其中 while-循环的执行趟数。在每一趟 for-循环中,while-循环至少要执行一趟。而如果还要额外地执行 while-循环,则每趟都会将某个点从凸包中剔除出去。在构造上凸包的整个过程中,每个点至多只能被删除一次,因此,在所有 for-循环中(while-循环)额外的执行趟数加起来不会超过 n。可以类似地证明,下凸包的计算也至多消耗 O(n)时间。因此,整个计算凸包算法的时间复杂度取决于排序那一步,即 O(nlogn)。
1.3.3 实现源代码
function ptSortFunc(pt1: Vector2, pt2: Vector2) {
const dx = pt1.x - pt2.x;
if (Math.abs(dx) > 1e-12) {
return dx;
}
return pt1.y - pt2.y;
}
export class Convex{
/**
* 凸包上的点,按顺时针排序
* @param pts 给定要计算凸包的点
*/
public static calculateConvex(pts: Vector2[]): Vector2[] {
if (pts.length < 3) {
return pts;
}
// 采用增量方法,先排序,然后递增的加入点
pts.sort((a, b) => ptSortFunc(a, b));
const isAddPtOnRight = (thePt: Vector2, theConvexPts: Vector2[]) => {
const endIndex = theConvexPts.length - 1;
const prevDir = theConvexPts[endIndex].subed(theConvexPts[endIndex - 1]);
const norm = new Vector2(-prevDir.y, prevDir.x);
const tmpDir = thePt.subed(theConvexPts[endIndex]);
return norm.dot(tmpDir) < 0;
};
const upperConvexPts: Vector2[] = [pts[0], pts[1]];
for (let i = 2; i < pts.length; i++) {
const tmpPt = pts[i];
while(!isAddPtOnRight(tmpPt, upperConvexPts)) {
upperConvexPts.pop();
if (upperConvexPts.length < 2) {
break;
}
}
upperConvexPts.push(tmpPt);
}
const lowerConvexPts: Vector2[] = [pts[pts.length - 1], pts[pts.length - 2]];
for (let j = pts.length - 3; j >= 0; j--) {
const tmpPt = pts[j];
while(!isAddPtOnRight(tmpPt, lowerConvexPts)) {
lowerConvexPts.pop();
if (lowerConvexPts.length < 2) {
break;
}
}
lowerConvexPts.push(tmpPt);
}
// 去掉中间两个重复点,然后合并在一起。尾部的不能去,因为会导致不封闭
lowerConvexPts.splice(0, 1);
const resConvex = upperConvexPts.concat(lowerConvexPts)
return resConvex;
}
}
注意:伪代码中是先将当前点加入上/下凸包数组中,如果发现不是构成右转,就将倒数第二个点从凸包数组中移出。由于移出倒数第二个点不如移除最后一个点方便高效(直接pop就行),因此实现时是在移除点后再将当前点加入。
1.4 思考改进
后续思考:
如果计算完上凸包后,就上凸包中的点从原数组中移出(首末端点保留)。这样的话,可以排除已经用掉的点被用来重复判断是不是下边界。这样可以减少下边界计算时若干次已用点的判断。
但是由于从数组中删除点的操作,也会耗费时间。如果有一个快速删除点的方法,那么是可以考虑用此优化的;但是如果删点效率不高,不适合用此改进思路。
2022.5.4于上海