最近点对与凸包问题(BF)

最近点对问题

这一节内容,我们来看两个经典的几何学问题。首先是最近点对问题closest-pair problem)。问题的描述是在一个二维平面上有一些随机分布的散点,要求我们在这些散点里找到两个点,使得它们的距离最小。

最近点对

用暴力求解的方式解决这个问题很简单,只需要把所有的情况都列举出来,即计算出每一个点与其他所有节点的距离,然后取最小的值。如果平面上有 n 个点,那么要考虑的点对数就为 n(n-1)/2。比如下面的图中一共有 16 个点,我们就要考虑 120 种情况。

最近点对

要实现这个过程也很容易,几行代码就可以搞定。

import numpy as np

def closest_pair_bf(X, Y):
    d = np.inf  # 初始化 d 为无穷
    for i in range(len(X)-1):
        for j in range(i+1, len(X)):
            d = min((X[i] - X[j])**2 + (Y[i] - Y[j])**2)
    return d

其中参数 XY 分别表示所有散点的横纵坐标的集合。在主程序中,我们用 numpy.random.normal 方法生成随机数,并且用np.column_stack方法将它们打包。

np.random.seed(13)  # 为了代码的可复现性
X = np.random.normal(2, 0.5, size=[16, 1])
Y = np.random.normal(2, 0.5, size=[16, 1])
XY_pair = np.column_stack((X, Y))

最后将得到的最近点对在图中标注出来。

最近点对

由于有两重循环,所以我们得到算法的时间复杂度为 Θ(n2)。

凸包问题

凸包问题convex-hull problem)是这里我们要讨论的第二个问题。首先要弄明白什么是凸包,在生活中,你一定见过在一个扎满钉子的木头上套上一圈橡皮筋的场景,像下面这张图那样。哈哈,实际上橡皮筋围成的多边形就是一个凸包了。

橡皮筋

从严格意义上来讲,凸包指的是由多个点构成的凸多边形(所有内角都小于180度),并且凸包还有一点性质是平面上所有的点都位于该多边形的内部。就像上面那张图中橡皮筋把所有钉子都围住一样。所以,我们要怎么来解决这个问题呢?答案当然还是采用最“暴力”的手段,穷举所有的情况,找到我们所需要的多边形。

从构成凸包的边中我们可以观察到,除了构成边本身的两个点以外,其他所有的点都位于边的同一侧。于是我们可以根据这个性质写出代码:

def convex_hull_bf(XY_pair):
    pairs = []  # 用于存储满足条件的点
    for i in range(len(XY_pair) - 1):
        for j in range(i+1, len(XY_pair)):
            a = XY_pair[j][1] - XY_pair[i][1]
            b = XY_pair[i][0] - XY_pair[j][0]
            c = XY_pair[i][0] * XY_pair[j][1] - XY_pair[i][1] * XY_pair[j][0]
            k = 0; pos = 0; neg = 0  # pos 和 neg 用于统计直线左右两侧点的个数          
            while k < len(XY_pair):
                if k != i and k != j:
                    if a * XY_pair[k][0] + b * XY_pair[k][1] - c > 0:
                        pos += 1
                    else:
                        neg += 1
                    if pos != 0 and neg != 0:
                        break
                k += 1
            else:
                pairs.append((XY_pair[i], XY_pair[j]))
    return pairs

对于判断一个点是在一条直线的左侧还是右侧,我们会用到了下面的公式:

公式

其中 a = y2 - y1, b = x1 - x2, c = x1y2 - y1x2,构成边的两个点的坐标分别为 (x1, y1), (x2, y2),而 x 和 y 分别待判定的点。如果值大于 0,说明该点在直线的左侧;如果小于 0,说明该点在直线的右侧。

最后我们将满足条件的点通过收尾相连的方式显示出来,这样我们就能画出多边形了。

凸包

作图的代码如下:

def plotting(xy_pair, polygon):
    plt.scatter(xy_pair[:, 0], xy_pair[:, 1], s=15)
    for i in range(len(polygon)):
        np_point_stack = np.stack(polygon[i], axis=1)  # 将每一个点对打包成 (xi, yi) 的形式
        plt.plot(np_point_stack[0], np_point_stack[1], c='r')
    plt.show()

最后来分析一下复杂度。我们知道,如果平面上有 n 个点,那么一共就有 n(n-1) / 2 个点对,也就是直线的条数。对于每一条直线,又要从剩下 n - 2 个点中判断是否在直线的同一侧。这样一来,只要我们列出求和公式,再经过推导就能够得到复杂度为 Θ(n3),具体推导过程如下:

derivation

总结

我们可以看到,用暴力求解的方式解决这两个几何问题非常简单,但效率却是低,都是平方级和立方级的复杂度,所以这两种算法也只能解决小规模的问题。面对 n > 106 这种大规模的问题时,我们就要求助其他算法了。


本节全部代码