把一束钉子钉在一块木板上,然后用橡皮筋把它们围起来,让橡皮筋绷紧(taut),就像这样:
橡皮筋已经把钉子集合的凸包画出来了。这是计算机图形学、机器人运动规划、地理信息系统、动物行为学等领域应用中的一个重要问题。更正式地说,我们说:
给定一个有限集, *P, 对于平面上的所有点, **P 的凸包是多边形 H, 满足:
- P 中的每一点都在 H内或 H上.
- H 上的每个顶点都是 P 中的一个点.
- H 是凸的: 连接H的任意两个顶点的线段 ,要么是H的边,要么位于H内部.
在此notebook中,我们开发了一个算法来寻找凸包(并展示了如何使用matplotlib绘图的例子)。
首先,我们要弄清楚如何表示我们感兴趣的对象:
- 点
- 点集
- 多边形:我们用顶点的有序列表来表示多边形
第一步:完成必要的import:
1 | from __future__ import division, print_function |
点和点集
我将类Point
定义为x和y坐标的named tuple,Points(n)
定义为创建一组n个随机点的函数。
对于函数Points(n)
有两个难点:
1.第二个可选参数用来生成随机数种子
2.由于matplotlib默认绘制在3×2矩形上,因此将从3×2矩形中均匀采样点(每条边上有一个0.05的小边框,以防止点碰到矩形的边)。
1 | Point = collections.namedtuple('Point', 'x, y') |
可视化点和线段
现在来看看怎么可视化点;定义一个函数plot_points.我们想看到的是:
- 点自身
- 可选的,点之间的线段。一个可选式的参数允许你确定是不是要这个线,以及要什么颜色的。该参数使用由matplotlib定义的标准样式格式。例如”r”。表示没有线的红色圆点,“bs-”表示有线条的蓝色正方形,“go”表示点之间有虚线的绿色圆圈。线从点到点依次排列;如果要使线从最后一点向后一点闭合(以形成一个完整的多边形),请指定closed = True。 (为此,点的集合必须是一个列表; closed = False时,该集合可以是任何集合。)
可选地,在这些点上的标签可以使我们彼此区分。 如果指定labels = True,则会得到标签(从0到n的整数)。
1 | def plot_points(points, style='r.', labels=False, closed=False): |
凸性(Convexity)
我们想构造一个凸包,所以最好有一些确定多边形是否凸的方法。 让我们检查一个是:
1 | octagon = [Point(-10, 0), Point(-7, -7), Point(0, -10), Point(+7, -7), |
如果你从左侧的点0开始,并沿八角形逆时针依次移动,从点到点跟随边缘,则可以看到在每个顶点处都在向左转。
现在让我们考虑一个非凸多边形:
吃豆人(pacman)多边形是非凸的; 你会看到一条从点3到点5的线经过了多边形。 你还可以看到,当你将逆时针方向从3移到4到5时,你将在4处右转。这导致了这个想法:如果我们在逆时针旋转多边形时没有向右转,则该多边形为凸形。
转向
现在我们如何确定从点A到B到C的转弯在B处左转还是右转(或直行)? 考虑下图:
如果角度β大于角度α,则在B处向左转; 换句话说,如果β的tan值大于α:
$(C.y - B.y) / (C.x - B.x) > (B.y - A.y) / (B.x - A.x)$
但是,如果执行该计算,则每个分母为零时将需要特殊情况。 因此,将每边乘以分母:
$(B.x - A.x) * (C.y - B.y) > (B.y - A.y) * (C.x - B.x) $
(注意:这一步会让你非常紧张!通常,将不等式的两边都乘以负数会逆转不等式,在这里分母可能是负数。在这种情况下,它是可行的;基本上是因为我们正在做两次乘法 因此负数可以抵消,但是数学证明是棘手的,涉及向量代数中的一些概念,因此我在这里不再重复;取而代之,我将在下面提供良好的测试范围。)
1 | def turn(A, B, C): |
凸包算法概述
现在 我们有了寻找凸包策略的第一部分:
沿着点以某种顺序走一条路径(还不确定以什么顺序) 这条路上的任何没有标记为左转的点都不是凸包的一部分
什么是好的顺序?让我们看看,如果我们从最左边开始,一直走到最右边会发生什么。我们可以通过调用在点上排序的内置函数来实现这种排序(因为点是元组,排序按字典顺序排序:首先由它们的第一个X坐标,如果有关系,下一个是它们的Y坐标)。我们从11个随机点开始,我将定义一个函数来帮助绘制部分外壳(hull):
1 | def plot_partial_hull(points, hull_indexes=()): |
1 | plot_partial_hull(sorted(Points(11))) |
现在,我将按照从点0到1到2到3的顺序开始构建外壳:
1 | plot_partial_hull(sorted(Points(11)), [0, 1, 9, 10]) |
1 | plot_partial_hull(sorted(Points(11)), [10, 8, 4, 0]) |
1 | plot_partial_hull(sorted(Points(11)), [0, 1, 9, 10] + [10, 8, 4, 0]) |
算法的基本思想就是这样,但有一些边界情况需要担心:
退化多边形:如果只有1/2(甚至0)个点会发生什么?这样的一组点应该被认为是凸的,因为没有办法画出这些线段外的点。
共线(colinear)点:如果三个或更多的点是共线的,我们应该只保留两个“外部”点。不保留它们的理由是我们希望凸包是最小可能的点集。我们需要保留外面的,因为它们在外壳上标出了真正的角。当它是一个“straight”转向和当它是一个“right”转向时,我们可以通过拒绝一个点来实现。
第一个和最后一个点:聪明(astute)的读者可能已经注意到,在A->B->C的转弯中,我们的算法只拒绝中间点B。这意味着,排序顺序中的第一个和最后一个点永远不会成为拒绝的候选点,因此总是会在外壳上。是这样吗?是的。第一个点是最左边的点,x值最低的点(如果有一样的,它是最左边的最低点)。这是一个极端的角落,所以它应该一直在外壳上。最后一个点同理。
凸包算法的实现
1 | def convex_hull(points): |
可视化结果
1 | def plot_convex_hull(points): |
n == 10,000的时候会不会很慢?
1 | P10K = Points(10000) |
104 ms ± 3.69 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
1 | plot_convex_hull(P10K) |
26 of 10000 points on hull
还有一些有意思的例子再[这里][[https://github.com/Qasak/pytudes/blob/master/ipynb/Convex%20Hull.ipynb][https://github.com/Qasak/pytudes/blob/master/ipynb/Convex%Hull.ipynb]
测试
到目前为止,一切看起来都很好!但如果我们能通过一套测试,我会更加自信:
1 | def tests(): |
外壳上有多少点?
Points(N)在外壳上的点数似乎随着N的增加而缓慢增加。有多慢?让我们试试看。我们将对60次随机试验中的Points(N)取外壳上的点的平均数:
1 | def average_hull_size(N, trials=60): |
我们将对N的几个值进行此操作,取2的幂(We’ll do this for several values of N, taken as powers of 2:):
1 | hull_sizes = [average_hull_size(2**e) |
1 | N Hull Size |
1 | def plot_hull_sizes(hull_sizes): |
这意味着我们可以通过计算直线的斜率(slope)和截距(intercept)来定义estimated_hull_size。(我不需要做线性回归;我只需要画一条从hull_sizes的第一点到最后一点的直线。)
1 | def estimated_hull_size(N): |
结束语和引伸阅读
凸壳问题是算法设计中的一个有趣的问题。这里的算法叫做安德鲁单调链 Andrew’s Monotone Chain. 它是格雷姆扫描的变种 Graham Scan.在这里阅读更多 Tamassia or Wikipedia.