算法的故事 / 第一季第二季目录0 / 30 章已读

第二季 · 第 41 章

用一根橡皮筋包住世界

《算法的故事》第二季 · 第三篇:让机器理解空间——罗纳德·格雷厄姆与计算几何的第一次大转身1972年,美国新泽西州,默里山。贝尔实验室的一位数学家正在研究平面上的点。这些点没有名字。它们不是城市,也不是铁路车站。没有人告诉计算机哪些点属于边界,哪些点藏在内部。它们只是纸面上或者计算机存储器里的一组坐标。假设有人给出五万个点。现在,我们希望找出能够包住全部点的最小凸区域。如果把这些点想象成钉在木板上的五万颗钉子,问题似乎很容易。只需要拿起一根足够大的橡皮筋,套在全部钉子外围。然后松开。橡皮筋会慢慢收缩。它越过那些位于内部的钉子,最终被最外围的钉子挡住。于是,一条紧绷的多边形边界出现了。但在1972年的电子计算机里,没有木板。没有钉子。更没有一根能够自动收缩的橡皮筋。只有数字。每个点都需要被表示成两个坐标:(xi,yi)(x_i,y_i)那么,怎样让计算机判断哪些点应该留在橡皮筋形成的边界上?更进一步:能不能不用检查所有可能的多边形,就在可以接受的时间内找出真正的最外围边界?提出重要答案的数学家,叫作罗纳德·刘易斯·格雷厄姆(Ronald Lewis Graham)。1972年,他在《Information Processing Letters》发表了一篇仅有两页的论文。文章标题是:An Efficient Algorithm for Determining the Convex Hull of a Finite Planar Set《确定平面有限点集凸包的一种高效算法》。这篇短短的论文,展示了一种新的思路:先将点按环绕中心的方向排序。然后沿着排序后的点逐一前进。每遇到一个新点,只需要判断最近形成的拐角是否合理。如果不合理,就删除那个不该留下的点。不断重复。最终,我们便能得到包住所有点的凸多边形。后来,这项算法以他的名字命名。Graham Scan——格雷厄姆扫描法。它教会计算机一项非常重要的空间能力:不需要真正看见橡皮筋,也能通过数字判断边界。

第一幕:钉子、橡皮筋与一个看似不需要计算的问题

凸包的经典实物演示:橡皮筋收缩后由外围的钉子支撑,内部钉子不会决定边界形状。这是一种教学类比,不是格雷厄姆1972年实验的现场照片。我们先想象一块木板。在上面随机钉入十颗钉子。有些靠近左边。有些靠近右边。有些挤在中间。现在,把一根橡皮筋套在所有钉子外面。随着橡皮筋收缩,它会贴住最外围的钉子。这条边界有一个重要特征。不管你在内部再钉多少颗钉子,只要没有超出原来的边界,橡皮筋的形状就不会改变。因此,虽然木板上可能有一万颗钉子,但真正决定边界的,也许只有十几颗。

什么叫作凸?

这是整章最重要的数学定义之一。假设平面上存在一个区域 CC。我们在区域中任意选择两个点:A,B∈CA,B\in C如果连接A和B的整条线段,也完全位于区域之内,那么这个区域就叫作凸集(Convex Set)。用数学公式表示:λA+(1−λ)B∈C,0≤λ≤1\boxed{ \lambda A+(1-\lambda)B\in C,\quad 0\le\lambda\le1 }这里的表达式:λA+(1−λ)B\lambda A+(1-\lambda)B代表A与B之间线段上的任意一点。对于一个正方形,不管怎样选取内部的两个点,它们之间的线段都不会跑到正方形外面。所以正方形是凸的。对于一个具有凹陷的星形区域,某些内部点之间的连线可能穿过区域外部。所以它不是凸的。
图41-1:凸与非凸的区别
凸区域两点间的线段始终留在区域内部
非凸区域有些连接内部点的线段会经过区域外部
现在,我们把橡皮筋的形状转化为数学语言。给定点集:P={p1,p2,…,pn}P=\{p_1,p_2,\ldots,p_n\}包含这些点的最小凸集,叫作它们的凸包(Convex Hull)。记为:CH⁡(P)\boxed{\operatorname{CH}(P)}在二维平面中,当点集有限且不全部共线时,凸包形成一个凸多边形。它的顶点,是原始点集中一些真正位于最外围的点。

凸包不是“外围任意画一个圈”

假设有人给出一个很大的长方形,能够包含所有点。这个长方形当然也可能是凸的。但它不一定是最小的凸区域。我们可以继续缩小长方形。也可以改变边的方向。直到再也不能在保持凸性的情况下,舍弃某些内部区域。真正的凸包,是所有包含原始点集的凸集的交集。这意味着它不是“某种看起来包得比较紧的轮廓”。而是一个由数学定义唯一确定的对象。

哪些点才是真正的边界?

考虑五个点:A=(0,0)A=(0,0)B=(4,0)B=(4,0)C=(4,4)C=(4,4)D=(0,4)D=(0,4)E=(2,2)E=(2,2)前四个点位于正方形的四角。E在中心。它们的凸包由:A→B→C→D→AA\rightarrow B\rightarrow C\rightarrow D\rightarrow A构成。中心点E不会出现在凸包顶点序列里。更有意思的是,如果我们再加入一千个全部位于正方形内部的点,凸包仍然不会改变。这就是凸包算法为什么有价值。它可以将大量空间数据,压缩成真正决定外部形状的少数点。但我们还需要面对一种边界情况。如果有三个点恰好位于凸包的同一条边上,那么中间那个点虽然在边界上,却不是这条多边形边的必要端点。本章默认输出的是凸包的极端顶点,不保留位于同一直边中间的冗余共线点。这一约定将直接影响后面的算法实现。

一根橡皮筋,其实藏着数学家的问题

对于十颗钉子,人类可以用肉眼轻易找到外围轮廓。对于一万颗点,计算机也可以把它们画在屏幕上。但如果这些点被存放在数据库中,程序需要自动决定哪些点是边界顶点。它不能依靠人类盯着图片猜测。它必须使用一种可重复、可证明正确的数学规则。而且,如果点的数量从一万增加到一百万,算法还必须足够高效。于是,问题从一项直观的几何实验,变成一项真正的计算机科学挑战。

第二幕:贝尔实验室里,那个会杂耍的数学家

罗纳德·格雷厄姆(1935—2020)。他不仅在离散数学与算法领域作出重要贡献,也以杂耍、魔术和独轮车表演而闻名。图片呈现的是他后来的生活,并非1972年的研究现场。[today.ucsd.edu][雅各布工程学院]在数学史中,罗纳德·格雷厄姆是一位十分特别的人物。他的研究横跨组合数学、数论、图论、调度理论与计算机科学。他曾担任美国数学会和美国数学协会的主席。也曾担任国际杂耍协会主席。在他看来,数学与身体技巧之间,存在一种有趣的共同语言。杂耍需要精确控制时间与空间。球从手中飞出。沿着轨迹运动。又必须在恰当的时间回到手中。稍有偏差,整个动作就可能失败。格雷厄姆曾把杂耍称为数学的一种身体表现。不过,我们不能因此编造“他在抛球时突然想到凸包算法”的故事。没有可靠史料证明这种具体灵感场景发生过。真正有文献记录的,是他长期从事离散数学研究的经历,以及1972年的那篇凸包论文。

从阿拉斯加到贝尔实验室

格雷厄姆1935年出生于加利福尼亚州。青年时期,他曾在美国空军服役。驻扎阿拉斯加期间,他同时继续接受高等教育,并于1959年获得物理学学士学位。随后,他进入加州大学伯克利分校,1962年取得数学博士学位。同年,他进入贝尔实验室。此后,他在贝尔实验室及其后继机构工作了三十多年。[MacTutor History of Mathematics][雅各布工程学院]贝尔实验室默里山研究中心的历史环境。1972年论文署名单位为 Bell Telephone Laboratories, Incorporated,Murray Hill, New Jersey。贝尔实验室并不是一所普通大学。那里聚集了来自数学、物理、电子工程和计算机科学等领域的研究人员。他们既研究基础理论,也研究具有工程背景的问题。电话网络的通信。数据传输。信息编码。计算设备与软件。以及大规模系统的可靠运行。在这样的环境里,数学家很自然地会追问:当实际数据变得越来越多时,哪些问题需要更快的算法?怎样用数学结构减少不必要的计算?能不能找到不仅可行,而且具有复杂度保证的处理方法?格雷厄姆正是在这样的研究环境中,成为离散数学和算法理论的重要推动者。

1972年1月25日,一篇论文送交期刊

现在,我们终于来到有明确史料记录的时刻。格雷厄姆的凸包论文,标注的收稿日期是:1972年1月25日。它发表于同年《Information Processing Letters》第1卷第4期,第132—133页。全文只有两页。今天,我们仍然能够找到这篇论文的扫描版本。格雷厄姆1972年论文的原始扫描资料。论文首页标注了贝尔实验室、1972年1月25日收稿,以及算法的主要步骤。[计算机与信息科学与工程系][dblp]这篇论文没有用大量篇幅讲述数学家的灵感故事。也没有长篇介绍应用领域。它直接说明了需要解决的问题:给定平面上的有限点集,找出其中真正决定凸包的极端点。然后,作者提出了一种具有:O(nlog⁡n)O(n\log n)时间复杂度的算法。论文末尾还有一句非常有时代感的说明。作者指出,计算机实现这种方法,已经足以处理约五万个点的例子。[计算机与信息科学与工程系][计算机与信息科学与工程系]五万个点。对今天的普通计算机而言,这个规模可能只是一个很小的任务。但在1972年,它展示了算法改进带来的巨大可能。如果方法只能进行大量两两比较,点数增长会迅速增加计算成本。而如果能够利用排序之后的几何结构,就可以避免许多重复工作。这便是 Graham Scan 的核心。但在继续讲解算法之前,我们需要澄清一项很重要的历史细节。

我们今天学习的版本,与1972年论文并不完全相同

现代教材通常这样介绍 Graham Scan:首先选择纵坐标最低、必要时横坐标最小的点。把它作为起点。然后根据其他点围绕该点的极角排序。最后使用栈,依次判断相邻三点的转向。这是一种非常清晰、便于编程的实现方式。然而,1972年原始论文的第一步,并不是直接寻找最低的点。格雷厄姆先寻找一个位于点集凸包内部的参考点。例如,选择三个不共线的点,取它们构成三角形的重心。接下来,才围绕这个内部参考点进行极角排序,并通过连续点的几何关系删除非极端点。原文还特别处理了同方向的点。[计算机与信息科学与工程系][计算机与信息科学与工程系]这两种描述的核心思想相通,但细节不同。为了让读者真正理解现代算法并能够亲手实现,我们将在后文采用教材中常见的“最低点+极角排序+栈”的版本。同时保留这一历史区别。因为真正有价值的算法史,不应该把后来的教学简化,误写成发明者原论文逐字使用的程序。

第三幕:寻找最外围的点,为什么不能一个一个猜?

散点构成的平面几何问题。点的视觉分布可以很复杂,但凸包仅由一部分极端点决定。假设计算机里存放着十万个坐标。我们想知道哪些点位于凸包上。最直接的做法,当然是逐个检查。但怎样判断一个点是否属于外围呢?

第一种想法:找出最左、最右、最高、最低的点

这似乎是个不错的开端。最左边的点,一定属于凸包边界。最右边的点也是。最高与最低的点同样可能确定某些凸包顶点。但是,仅仅找出四个方向的极端点,显然不够。如果点集构成近似八边形,就可能拥有八个凸包顶点。如果它构成一个拥有上百个边界顶点的形状,四个方向就更加不够用了。因此,我们需要找到所有外围顶点,而不是仅仅找到几个最极端的坐标。

第二种想法:检查每两个点是否构成凸包的一条边

任意选择两个不同的点:A,BA,\quad B它们确定一条直线。如果其他所有点都位于这条直线的同一侧,或者恰好在线上,那么线段AB就有可能位于凸包边界。这是一个非常有用的几何判据。但如果我们把全部点两两配对,就有:(n2)=n(n−1)2\binom n2=\frac{n(n-1)}2组候选。如果每一组还要检查其他全部 nn 个点,总计算量便可能达到:O(n3)O(n^3)这只是一个朴素判定思路,并不是现代凸包算法的最佳复杂度。对于十万个点:100000×999992\frac{100000\times99999}{2}大约有五十亿组点对。仅仅枚举候选直线,就已经十分昂贵。

第三种想法:让边界像橡皮筋一样向前寻找

现在换一个角度。既然凸包边界是一条连续的多边形轮廓,为什么不沿着它逐渐向前移动?假设我们已经找到最左边的点。从这个点出发,旋转一条射线。找到第一个能够成为外围边界的下一个点。然后继续向前。如此反复。这项思想后来与**礼品包装算法(Gift Wrapping)**密切相关,也常被称为 Jarvis March。1973年,R. A. Jarvis 在《Information Processing Letters》发表了对应论文,研究利用逐步选择边界点的方式求凸包。[ScienceDirect][PDF]如果最终凸包只有 hh 个顶点,使用基本的 Jarvis March 方法,每次可能需要检查全部 nn 个候选点。因此时间复杂度为:O(nh)\boxed{O(nh)}当绝大多数点位于内部、凸包顶点数量很少时,这可能很有吸引力。但如果所有点都分布在一个圆周附近,几乎每个点都属于凸包,则:h≈nh\approx n复杂度可能退化为:O(n2)O(n^2)这个问题提出了一个非常重要的算法设计选择。我们是应该:每次重新搜索下一个边界点?还是先将全部点组织成某种有序结构,让后续寻找更加高效?格雷厄姆选择了第二条道路。

真正的突破:先排序,再扫描

想象我们把一组杂乱的点,按照围绕某个参考点的方向排成顺序。原来,点的顺序是随机的。现在,它们已经按照逆时针方向排列。那么,算法就不需要再反复从所有点里寻找下一个候选。它只要沿着排序后的序列向前走即可。这看起来不过是加了一次排序。但这次排序,改变了问题本身的结构。原来我们面对的是无序点云。现在我们面对的是一条沿着方向逐渐旋转的候选链。只需要判断每一步是否仍然符合凸多边形的转向规则,就能逐渐剔除错误的内部点。这就是 Graham Scan 的基本思想。不过,在正式运行算法之前,我们还需要一项小小的数学工具。它简单到只需要乘法和减法。却能够帮助计算机判断三个点究竟向左转,还是向右转。

第四幕:一道乘法题,教会计算机识别左转和右转

计算几何中的方向判定:通过三个点的坐标,可以判断从第一段向量转向第二段向量时发生左转、右转,还是三个点共线。现在,把橡皮筋暂时放在一边。我们只研究三个点。A,B,CA,\quad B,\quad C想象有一个小机器人。它从A出发,沿直线走到B。然后准备从B走向C。这时,我们要问:机器人需要向左转,还是向右转?对于人类,这种判断几乎不需要思考。但计算机并不能直接理解“左边”或“右边”这样的空间概念。我们必须把它转化为数字。

将三个点写成坐标

设:A=(x1,y1)A=(x_1,y_1)B=(x2,y2)B=(x_2,y_2)C=(x3,y3)C=(x_3,y_3)从A指向B的向量是:AB→=(x2−x1, y2−y1)\overrightarrow{AB} = (x_2-x_1,\ y_2-y_1)从A指向C的向量是:AC→=(x3−x1, y3−y1)\overrightarrow{AC} = (x_3-x_1,\ y_3-y_1)在二维平面里,我们可以计算一个有符号的行列式:cross⁡(A,B,C)=(x2−x1)(y3−y1)−(y2−y1)(x3−x1)\boxed{ \operatorname{cross}(A,B,C)= (x_2-x_1)(y_3-y_1) -(y_2-y_1)(x_3-x_1) }这个数,就是我们判断转向的关键。

为什么一个数字能判断方向?

从几何上看,它等于由A、B、C组成的有向平行四边形的面积。更准确地说,它的绝对值是相应平行四边形的面积,也等于三角形ABC面积的两倍。而它的正负号,表示方向。如果:cross⁡(A,B,C)>0\operatorname{cross}(A,B,C)>0说明三个点按逆时针方向排列。也就是从AB转向BC时,发生左转。如果:cross⁡(A,B,C)<0\operatorname{cross}(A,B,C)<0则表示右转。如果:cross⁡(A,B,C)=0\operatorname{cross}(A,B,C)=0说明三个点共线。
图41-2:叉积的三个符号
ABC正数:左转
ABC负数:右转
ABC零:共线

亲手算一次左转

假设:A=(1,1)A=(1,1)B=(5,1)B=(5,1)C=(4,4)C=(4,4)代入公式:cross⁡(A,B,C)=(5−1)(4−1)−(1−1)(4−1)\operatorname{cross}(A,B,C) =(5-1)(4-1)-(1-1)(4-1)得到:=4×3−0×3=4\times3-0\times3所以:cross⁡(A,B,C)=12\boxed{\operatorname{cross}(A,B,C)=12}结果为正。因此,A、B、C形成左转。再将C换成:D=(4,−1)D=(4,-1)计算:cross⁡(A,B,D)=(5−1)(−1−1)−(1−1)(4−1)\operatorname{cross}(A,B,D) =(5-1)(-1-1)-(1-1)(4-1)得到:−8\boxed{-8}因此,这一次是右转。整个判断只需要少量加减法和乘法。没有三角函数。没有开平方。甚至不需要计算真正的角度。

这为什么是计算几何的关键?

因为很多空间问题,并不需要精确知道一个角有多少度。我们只需要知道:它是左转还是右转?两个线段是否相交?一个点位于某条有向直线的哪一侧?一个多边形的顶点顺序是顺时针还是逆时针?这些问题都可以依靠类似的几何谓词解决。专业计算几何库 CGAL,将此类方向判定称为 Orientation Predicate,也就是方向谓词。方向谓词是构造二维凸包算法的重要基础。[斯坦福图形实验室][CGAL]这件事具有特别深刻的意义。人类原本依靠视觉理解空间。计算机却可以将空间关系转化为代数判断。于是:左转。右转。共线。这些看起来需要“看见”的事情,变成了可重复的数学运算。有了这项能力,计算机就终于可以开始模仿橡皮筋寻找边界的过程。

第五幕:1972年的真正技巧——沿着极角前进,遇到右转就后退

现代 Graham Scan 的基本思想:先按极角组织候选点,再使用栈维护凸包边界。图为现代算法教学示意,与1972年论文使用内部参考点的原始步骤有所区别。现在,我们拥有了两项工具。排序。叉积。接下来,我们要把它们组合起来。这就是现代教材里最常见的 Graham Scan。

第一步:选择一个绝不可能藏在内部的点

我们先找出纵坐标最低的点。如果存在多个点拥有相同的最低纵坐标,就选择其中横坐标最小的点。记作:P0P_0为什么选择它?因为它位于所有点的最下方。不可能被其他点包围成一个包含其下方区域的凸多边形。因此,它必然是凸包的一个极端顶点。这个点会成为我们扫描的起点。

第二步:让其他点按极角排队

想象从 P0P_0 向其他每个点画出一条射线。每条射线,都有一个相对于水平向右方向的角度。我们把其他点按照这个角度从小到大排序。就像一根射线从右向左逆时针旋转,依次扫过所有点。
图41-3:从最低点开始的极角排序
P₀P₁P₂P₃P₄逆时针
查看 SVG 配图原件
示意图原件 41-015-704685204e9c.svg
41-015-704685204e9c.svg · 点击查看大图
极角排序使候选点按照围绕起点的方向排列。示意角度为说明概念而标注,不对应实测数值。
这里需要处理一种特殊情况。如果两个点位于从起点出发的同一条射线上,那么它们拥有相同极角。为了保证边界正确,我们可以按距离起点的远近进一步排序。较近的点放在前面。较远的点放在后面。扫描时遇到共线情况,就把位于中间的冗余点从栈里移除。这样,最终能够保留最远的端点。

第三步:使用栈保存当前边界

栈是一种后进先出的数据结构。我们可以把它想象成一摞盘子。最后放上去的盘子,最先能够拿下来。Graham Scan 使用栈,保存目前仍然可能属于凸包的点。先将起点放入栈。然后按照极角顺序,逐个检查其他点。每次准备加入新点 CC 时,我们观察栈顶的两个点:A,BA,\quad B计算:cross⁡(A,B,C)\operatorname{cross}(A,B,C)如果结果大于0,说明构成左转。这符合逆时针凸包边界应该维持的方向。于是把C加入栈。如果结果小于0,说明构成右转。意味着中间的B不是当前正确凸边界的一部分。因此,B应当从栈里删除。然后再次使用新的栈顶两点,重新判断C。直到形成左转,或者栈中只剩下不足两个点。最后,再把C加入栈。对于本章保留极端顶点的约定,叉积等于0时也会弹出中间共线点。所以实际判断条件是:cross⁡(A,B,C)≤0⇒弹出B\boxed{\operatorname{cross}(A,B,C)\le0\Rightarrow\text{弹出B}}

一个真正的右转陷阱

现在来看一个具体的算术例子。假设起点:P=(0,0)P=(0,0)按极角排序后,三个候选点依次是:Q=(5,0)Q=(5,0)R=(4,2)R=(4,2)S=(6,5)S=(6,5)其中,Q的极角为0度。R的极角约为26.6度。S的极角约为39.8度。因此排序顺序是:Q, R, SQ,\ R,\ S算法开始:栈=[P,Q,R]\text{栈}=[P,Q,R]现在准备加入S。检查:cross⁡(Q,R,S)\operatorname{cross}(Q,R,S)代入:=(4−5)(5−0)−(2−0)(6−5)=(4-5)(5-0)-(2-0)(6-5)得到:=−1×5−2×1=-1\times5-2\times1所以:cross⁡(Q,R,S)=−7\boxed{\operatorname{cross}(Q,R,S)=-7}出现右转!这说明R不能继续留在当前外侧凸边界上。于是将R弹出。现在栈中只剩:[P,Q][P,Q]再检查:cross⁡(P,Q,S)\operatorname{cross}(P,Q,S)结果是:5×5=255\times5=25为正。说明从P到Q,再到S,是一次合法左转。于是将S压入栈。最终:[P,Q,S][P,Q,S]
图41-4:R看起来在外围,但它必须被弹出
PQRSP → QR导致右转弹出R后边界恢复左转
查看 SVG 配图原件
示意图原件 41-016-d0bae17642be.svg
41-016-d0bae17642be.svg · 点击查看大图
橙色虚线代表加入S之前的错误候选链;绿色线表示删除R之后的正确候选转向。
这是 Graham Scan 最值得理解的一个动作。新点不是简单地追加在边界末尾。它可能迫使算法删除之前认为合理的候选点。如果你记得第37章的残量网络,应该对这种思想十分熟悉。最大流算法允许撤销部分旧流量,以便寻找更好的整体分配。Graham Scan 则允许从栈里弹出旧候选点,以便维护正确的凸包边界。两者看起来解决完全不同的问题。但都利用了一个非常有价值的算法机制:当新信息证明原来的局部决定不再合理时,允许安全地撤销它。

栈为什么能够让算法高效?

假设一个点先被压入栈。后来又因为出现右转而被弹出。这个点不会再次被压回。每个候选点至多入栈一次、出栈一次。所以,尽管程序中存在一个 while 循环,看起来可能反复弹栈,但全部扫描过程中的总弹栈次数至多与点数成正比。因此,在极角排序完成之后,扫描阶段仅需要:O(n)\boxed{O(n)}时间。排序阶段需要:O(nlog⁡n)O(n\log n)所以总时间复杂度是:O(nlog⁡n)+O(n)=O(nlog⁡n)\boxed{O(n\log n)+O(n)=O(n\log n)}这就是格雷厄姆1972年研究中的重要复杂度结果。[计算机与信息科学与工程系][Algorithms 4]而它也揭示了一个非常美丽的算法设计结构:先花较多时间建立秩序。再利用这种秩序,以线性时间完成真正的筛选。

第六幕:格雷厄姆真的是第一个发现凸包算法的人吗?

20世纪六七十年代,计算机图形学、模式识别与算法理论开始大量研究如何从数字坐标中计算几何结构。凸包问题是其中极具代表性的一项基础任务。我们已经看到,格雷厄姆1972年的方法能够在:O(nlog⁡n)O(n\log n)时间内构造平面点集的凸包。这一复杂度相当优秀。但科学史还有一项值得追问的事情。他是第一个研究凸包算法的人吗?并不是。在1972年之前,数学家已经研究过凸集、凸多面体以及相关计算方法。例如,1970年,Donald Chand 和 Sham Kapur 在《Journal of the ACM》发表了关于凸多面体计算的论文。这些早期研究说明,几何对象的计算问题已经开始受到理论计算机科学界的重视。[SIGMOD][ScienceDirect]然而,格雷厄姆的工作仍然拥有十分特殊的地位。因为它提供了一套清晰的平面点集凸包算法,并且给出了明确的:O(nlog⁡n)O(n\log n)最坏情况时间复杂度保证。

1985年,一篇历史论文重新审查了“第一”的归属

1985年,计算几何研究者 Godfried Toussaint 发表了一篇专门的历史考证:A Historical Note on Convex Hull Finding Algorithms《凸包求解算法的一则历史注记》。他发现,早在1967年,Bass 和 Schubert 的研究中,就已经包含了许多后来凸包算法的重要思想。但问题是:他们当时对方法的描述不够清晰。按原文方式直接执行,算法存在正确性问题。不过,Toussaint 进一步分析表明,如果对其方法作适当解释与修正,就可以得到一套正确的 O(nlog⁡n)O(n\log n) 算法。这意味着,若以思想的历史先后为标准,1967年的工作不能被忽略。但若要求原始论文明确给出正确算法和完整的效率论证,格雷厄姆1972年的贡献仍然具有重要地位。[ScienceDirect][ScienceDirect]这与我们在前几章遇到的历史非常相似。第38章的匈牙利方法,在库恩1955年发表之后,后来又发现了雅可比十九世纪的相关算法。第40章的 Christofides 算法,则拥有谢尔久科夫1978年独立发现的历史。算法的历史很少是一条只有一个人的简单直线。在某些问题上,不同研究者会独立发现相似的结构。有些方法先有直觉,后有严格证明。有些方法先在特定问题中出现,后来才被整理为通用算法。因此,我们可以准确地说:Graham Scan 是以格雷厄姆1972年的重要研究命名的经典凸包算法,但平面凸包的计算思想与早期方法,并不全部始于1972年。

1973年:Jarvis 走另一条路

礼品包装算法从一个极端点出发,反复寻找凸包上的下一个顶点。它与 Graham Scan 的预排序和线性扫描思路有所不同。1973年,R. A. Jarvis 发表了另一项凸包算法研究。他强调的方法更接近实际拿一根线,沿外部边界逐点包裹。每次确定一个新的凸包顶点。然后继续寻找下一个。这项方法后来通常称为 Jarvis March。它的典型复杂度为:O(nh)O(nh)其中 hh 是最终凸包顶点数量。这意味着,如果十万个输入点中,真正位于凸包上的只有八个,基本 Jarvis March 只需要进行大约八轮全体候选检查。其总工作量与 nhnh 成正比。而 Graham Scan 不管最终有多少凸包顶点,通常都需要先进行一次 O(nlog⁡n)O(n\log n) 排序。因此,在某些凸包顶点很少的输入中,Jarvis March 可能成为有竞争力的方案。但如果几乎所有点都在凸包上,它就可能退化到平方复杂度。

1979年:Andrew 把扫描变成上下两条链

1979年,A. M. Andrew 提出另一种高效的二维凸包算法。它后来被称为:Andrew Monotone Chain——安德鲁单调链算法。它不按照极角排序。而是先按照:(x,y)(x,y)的字典序排列全部点。然后分别构建下凸壳与上凸壳。两个方向都利用叉积判断左转和右转。最终将上下两条链合并,就得到完整凸包。这项方法同样具有:O(nlog⁡n)O(n\log n)时间复杂度,并且在编程中非常实用。[CGAL][dgtal.org]
图41-5:三种经典凸包算法
算法主要策略时间复杂度
Graham Scan(1972)极角排序、栈扫描O(nlog⁡n)O(n\log n)
Jarvis March(1973)逐个寻找下一个外部顶点O(nh)O(nh)
Andrew Monotone Chain(1979)字典序排序、上下链扫描O(nlog⁡n)O(n\log n)
nn 为输入点数,hh 为凸包顶点数。复杂度假设基本几何谓词和坐标算术操作按相应标准模型计费。

Graham Scan 的复杂度,已经不能再改进了吗?

这是一个值得深入研究的理论问题。格雷厄姆的方法需要:O(nlog⁡n)O(n\log n)时间。我们是否能够设计一项对任意无序平面点集都只需要:O(n)O(n)时间的通用凸包算法?在经典的代数决策树等相关计算模型下,答案是否定的。一般平面凸包问题存在:Ω(nlog⁡n)\boxed{\Omega(n\log n)}的最坏情况复杂度下界。这并不是因为数学家尚未足够聪明。而是因为它与另一个著名问题存在深刻联系。那就是排序。

把排序问题藏进一条抛物线

假设有人交给我们一组互不相同的实数:x1,x2,…,xnx_1,x_2,\ldots,x_n我们想将它们从小到大排序。现在,暂时不使用任何排序算法。而是把每个数字变成平面上的一个点:pi=(xi,xi2)\boxed{p_i=(x_i,x_i^2)}这些点全部落在抛物线:y=x2y=x^2上。
图41-6:把数字排序转化为凸包计算
-3-2-10123沿凸包下侧边界,点的横坐标自然有序
查看 SVG 配图原件
示意图原件 41-019-bcad7da0f78c.svg
41-019-bcad7da0f78c.svg · 点击查看大图
示意图采用抛物线上的若干点。由于抛物线严格凸,这些点按横坐标顺序出现在相应凸包链上。
因为抛物线是严格凸曲线,这些点的相邻连线构成凸包的一条链。沿着这条链读取点的横坐标,就能恢复:x1,x2,…,xnx_1,x_2,\ldots,x_n的有序排列。因此,如果我们能够在一般计算模型里,以明显低于 O(nlog⁡n)O(n\log n) 的代价求出能够按边界顺序输出的凸包,就能够进一步构造更快的排序算法。而经典比较排序的最坏情况复杂度下界是:Ω(nlog⁡n)\Omega(n\log n)这说明,一般凸包计算也不可能在这些模型下普遍突破相应下界。[弗吉尼亚大学计算机科学系][arXiv]这里有两个限定。第一,这种结论依赖计算模型及输入表示方式。如果输入点已经按合适的顺序排列,后续凸包扫描可能只需要线性时间。第二,在特殊坐标范围、特定数据分布或额外输入保证下,也可能设计不同复杂度的算法。因此,不能将 Ω(nlog⁡n)\Omega(n\log n) 简化为“任何计算机、任何数据、任何特殊条件下都绝不可能更快”。但对于通常的无序平面实数点集,Graham Scan 的:O(nlog⁡n)O(n\log n)已经达到了经典计算模型下的渐近最优阶。这也解释了为什么一篇只有两页的论文,能够在算法史中占据如此重要的位置。它不仅找到了一种有效方法。还达到了这个一般问题所能期待的最优复杂度量级。

第七幕:当三个点几乎在同一直线上,算法也可能犯错

近乎共线的点,是计算几何中的典型数值精度挑战。数学上为正、负或零的方向判定,在有限精度计算中必须谨慎处理。到现在为止,我们一直假设计算机能够准确判断:cross⁡>0\operatorname{cross}>0cross⁡<0\operatorname{cross}<0或者:cross⁡=0\operatorname{cross}=0但是,真实程序还会遇到一个非常重要的问题。坐标并不一定都是小整数。它们可能来自:GPS测量。数字地图。摄像头中的像素坐标。机器人定位。科学仪器中的浮点计算。而这些数据,经常拥有有限的测量精度。

一个几乎无法用肉眼分辨的转弯

考虑三个点:A=(0,0)A=(0,0)B=(109,109)B=(10^9,10^9)C=(2×109, 2×109+1)C=(2\times10^9,\ 2\times10^9+1)从图像上看,这三个点几乎位于同一条直线上。但代入叉积公式:cross⁡(A,B,C)=109(2×109+1)−109(2×109)\operatorname{cross}(A,B,C) = 10^9(2\times10^9+1)-10^9(2\times10^9)结果为:109\boxed{10^9}它是正数。因此,从数学上说,三个点形成左转。而如果第三个点变成:C′=(2×109, 2×109−1)C'=(2\times10^9,\ 2\times10^9-1)叉积则为:−109\boxed{-10^9}变成右转。请注意,虽然这里叉积的绝对值达到十亿,但它仍然是由两个约为 2×10182\times10^{18} 的巨大乘积相减得到的。这就是数值计算中令人警惕的情况。两个非常接近的大数相减,可能使浮点舍入误差变得非常重要。如果程序把一个实际上为正的叉积错误算成零,或者甚至算成负数,那么 Graham Scan 就可能做出错误决定。它可能弹出一个真正的凸包顶点。或者错误地保留一个内部点。

一个更大的整数陷阱

如果坐标都是整数,我们可以使用整数运算避免浮点舍入问题。但仍然不能掉以轻心。在C++里,常见的 long long 通常是64位有符号整数。它可以表示的正数范围大约是:9.22×10189.22\times10^{18}如果坐标数值和坐标差足够大,叉积中的乘法可能溢出。而且,乘法溢出并不是单纯产生一个较大的误差。在C++有符号整数算术中,溢出会导致未定义行为。所以,一份几何程序是否正确,不能只看算法思想。还必须检查数值表示是否足够安全。

现代计算几何为什么强调精确谓词?

专业计算几何库 CGAL 特别强调:几何算法中的方向测试、相交判断等操作,不只是普通算术。它们是决定算法控制流程的几何谓词(Geometric Predicates)。一旦几何谓词错误,后续的拓扑关系就可能全部出错。因此,工程中往往需要采用合适的数值类型,或者能够提供精确方向判断的几何内核。例如,CGAL 提供了精确谓词与非精确构造相结合的计算内核。对于凸包这类输出顶点直接来自输入点的算法,精确方向谓词尤其具有价值。[cgal.org][Manual: Hello World]这里还需要避免一种过度简化的做法:遇到浮点数,就随手规定:
python
if abs(cross) < 1e-9:
    cross = 0
这种固定阈值,在某些特定尺度的数据里可能有用。但对于坐标规模相差巨大的通用输入,不能保证它总是正确。因为叉积的量纲是坐标单位的平方。其数值大小还受到坐标尺度影响。一个合理的数值容差,需要结合数据单位、误差来源和实际几何要求设计。如果必须对所有输入给出严格的几何正确性保证,就应该考虑精确谓词或经过充分验证的稳健算法。

三种退化情况,必须明确处理

图41-7:凸包程序容易遇到的特殊输入
重复点
全部共线
边上共线点
对于本章使用的凸包顶点约定:如果只有一个不同的点,凸包就是这个点。如果只有两个不同的点,凸包就是连接它们的线段。如果所有不同的点都共线,凸包退化为两个端点之间的线段。如果某个点位于凸包一条边的内部,那么它属于凸包边界,但不需要作为输出的极端顶点。这些情况看似琐碎。实际上,它们正是决定一份算法实现是否能够用于真实项目的关键。很多程序在随机生成的普通样本上完全正确。却会因为重复点、共线点或整数溢出,在实际数据中失败。因此,一位成熟的算法工程师不仅要理解主流程。还必须认真思考:算法的输入条件是什么?退化情况如何定义?数值类型会不会溢出?输出应该保留哪些边界点?这也是计算几何比纸上画图更加困难的地方。

第八幕:互动实验室——亲眼看见凸包是怎样长出来的

凸包扫描的可视化:候选点按极角排序,算法不断检查最近三个点的转向,并通过栈的弹出与加入维护候选边界。现在,我们将刚才的数学推理,变成一场真正的几何实验。你可以选择三组点。第一组包含十个分散在平面上的点。第二组故意安排了多个共线点。第三组则包含一圈外围点和少量内部点。程序会自动选择最低点。按照极角排序。然后逐次判断:左转。右转。共线。并显示每次压栈与弹栈的结果。

实验一:亲手推动 Graham Scan

图41-8:Graham Scan 动态实验室依次查看算法对每一个候选点的判断。遇到非左转时,橙色点会从当前栈中被弹出。
0 / 25
ABCDEFGHIJ坐标采用数学坐标系,向上为正 y 方向
已在栈中
当前关注
尚未保留
当前动作选取起点
起点为 G:它的纵坐标最低。
当前栈(从栈底到栈顶)
G
极角排序结果G(起点) → F → E → I → D → H → B → J → C → A

请先尝试经典十点

在第一组点集中,最低点是G。算法按照极角,逐渐将其他点加入候选边界。如果只不断压栈而不弹栈,某些内部点也会留在结果里。例如,I一开始会进入栈。但当后续的D加入时,I会导致不正确的转向。因此,算法必须将I弹出。后面还会出现类似情形。J与C也会在后续检查中被淘汰。最后真正留下的,是构成外围凸多边形的点。

再试试共线挑战

这组数据里,有多个点位于一条直线上。如果全部保留,最终输出就可能包含许多不必要的中间点。而我们本章希望得到的,是凸包的极端顶点。因此,当叉积为0时,也会弹出位于中间的冗余点。请注意,它不是认为共线点不属于凸包边界。而是因为这些点不是描述凸包形状所必需的极端顶点。这两个概念必须分清。

最后切换到六边形

这一次,外围形状更接近普通的凸六边形。内部还藏有几个点。算法会逐渐剔除这些内部候选。你会看到,最终的结果并不依赖点最初输入时的顺序。因为我们先进行了极角排序。这一步让几何结构获得了可以扫描的顺序。排序负责建立秩序,叉积负责识别错误,栈负责保存仍然有效的边界。这就是 Graham Scan 的全部核心。

第九幕:把几何思想变成一段可靠的程序

凸包算法的实际实现不需要真实地绘制橡皮筋,只需要坐标排序、方向判定和栈结构。正确处理共线点、重复点与数值精度,是工程实现的重要部分。现在,我们将这项算法完整地写成 Python。为了避免浮点角度计算引入不必要的误差,这一次,我们不直接调用:
python
math.atan2()
来对角度进行排序。而是继续使用叉积判断两个向量的相对方向。这能够使整数坐标的计算保持精确。

第一步:判断三个点的方向

先定义最核心的函数:
python

def cross(a, b, c):
    """
    计算向量 AB 与 AC 的二维叉积。

    > 0:逆时针方向(左转)
    < 0:顺时针方向(右转)
    = 0:共线
    """
    return (
        (b[0] - a[0]) * (c[1] - a[1])
        - (b[1] - a[1]) * (c[0] - a[0])
    )


print(cross((1, 1), (5, 1), (4, 4)))   # 12
print(cross((1, 1), (5, 1), (4, -1)))  # -8
print(cross((0, 0), (2, 2), (4, 4)))   # 0
对于整数坐标,Python 使用任意精度整数。所以,只要内存允许,这种计算不会因为普通固定宽度整数溢出而得到错误结果。当然,这不意味着所有使用 Python 的几何程序都天然精确。如果输入坐标是浮点数,它们依然可能存在舍入误差。

第二步:按照极角排序

在最低点作为起点的前提下,其他所有点都不会位于该点下方。而我们又规定,当多个点拥有相同最低纵坐标时,选择横坐标最小的点。因此,所有其他点相对于起点的极角,都落在一个不超过180度的范围内。在这个范围里,可以使用叉积比较不同方向的先后。如果:cross⁡(P0,A,B)>0\operatorname{cross}(P_0,A,B)>0说明向量 P0AP_0A 的极角小于向量 P0BP_0B 的极角。在极角相同时,再按距离平方排序。这样,我们不需要计算任何反三角函数。

第三步:完整 Graham Scan

python

from functools import cmp_to_key


def cross(a, b, c):
    return (
        (b[0] - a[0]) * (c[1] - a[1])
        - (b[1] - a[1]) * (c[0] - a[0])
    )


def graham_scan(points):
    """
    返回逆时针排列的凸包极端顶点。

    - 自动删除重复点
    - 不保留凸包直边上的中间共线点
    - 全部共线时返回两个端点
    - 使用整数坐标时方向判断为精确整数运算
    """

    pts = list(set(map(tuple, points)))

    if len(pts) <= 1:
        return pts

    # 选择最低点,纵坐标相同则选择最左点
    pivot = min(pts, key=lambda p: (p[1], p[0]))

    others = [p for p in pts if p != pivot]

    def distance2(p):
        return (
            (p[0] - pivot[0]) ** 2
            + (p[1] - pivot[1]) ** 2
        )

    def angle_compare(a, b):
        turn = cross(pivot, a, b)

        if turn > 0:
            return -1
        if turn < 0:
            return 1

        # 共线时,较近的点在前
        da, db = distance2(a), distance2(b)

        if da < db:
            return -1
        if da > db:
            return 1

        return 0

    others.sort(key=cmp_to_key(angle_compare))

    stack = [pivot]

    for p in others:
        while (
            len(stack) >= 2
            and cross(stack[-2], stack[-1], p) <= 0
        ):
            stack.pop()

        stack.append(p)

    return stack


points = [
    (10, 15),
    (26, 78),
    (38, 20),
    (53, 90),
    (86, 76),
    (94, 24),
    (66, 8),
    (45, 49),
    (73, 48),
    (22, 43),
]

hull = graham_scan(points)

print("凸包顶点数量:", len(hull))
print("凸包顶点:", hull)
这份代码看起来并不长。其中只有三项核心运算。选择起点。极角排序。使用栈扫描。但这几步背后,包含一份完整的数学正确性保证。

算法的不变量

我们在第31章已经学习过循环不变量。它是一种重要的算法证明工具。对于 Graham Scan,可以把关键的不变量理解为:在完成当前候选点的处理之后,栈中的点按照极角顺序排列,并形成当前已处理点集的正确凸边界链。如果某个新点造成右转或不需要保留的共线拐点,算法就从栈中移除原来的中间点。这样,扫描过程中不会在保留的边界链里留下违反凸性要求的转向。由于输入点已经按极角排序,任何被弹出的点都不会在后来重新成为完整凸包所必需的极端顶点。因此,当所有点处理完毕,栈中剩下的便是完整点集的凸包极端顶点,按逆时针顺序排列。这里省略了关于极角排序、退化情况和闭合边界的完整形式化证明,但这就是其核心不变量思路。

为什么扫描的 while 不会造成平方复杂度?

很多初学者看到:
python
for p in others:
    while ...:
        stack.pop()
便会立刻判断:外层一个循环。内层又一个循环。所以时间复杂度一定是:O(n2)O(n^2)这是一个常见错误。因为这里的 while 并不会对每个点都执行 nn 次。每个点只能压栈一次。每个点也最多弹栈一次。所以,整个算法过程中:入栈总次数不超过 nn。弹栈总次数也不超过 nn。全部扫描操作总数仍然是:O(n)O(n)这种分析方法叫作摊还分析(Amortized Analysis)。它不只关心某一次循环最多执行多少步。而是关心整段程序所有操作加起来的总成本。因此,Graham Scan 也是学习摊还分析的一个非常好的实例。

对算法进行边界测试

请试试下面四组输入。
python

tests = [
    # 重复点
    [(0, 0), (0, 0), (2, 0), (1, 1), (2, 0)],

    # 全部共线
    [(0, 0), (1, 1), (2, 2), (3, 3)],

    # 只有一个点
    [(7, 4)],

    # 正方形加中心点
    [
        (0, 0), (4, 0),
        (4, 4), (0, 4),
        (2, 2)
    ]
]

for points in tests:
    print(graham_scan(points))
你应该看到:重复点不会重复进入最终结果。全部共线时只留下两个端点。只有一个点时,返回该点。正方形中心的点,则不会进入凸包顶点列表。这些都是我们在第七幕讨论过的必要边界条件。

C++中最应该警惕的地方

对于习惯使用 C++ 的读者,方向判定还要特别注意整数溢出。如果输入坐标的范围足够大,下面这种写法可能不安全:
cpp
long long cross = dx1 * dy2 - dy1 * dx2;
即使每个 dx 和 dy 能够放入64位整数,乘积也未必能放入64位。对于 GNU C++ 等支持 __int128 的编译环境,可以在已知坐标范围足够安全的前提下,采用更宽的中间类型:
cpp

#include <cstdint>

struct Point {
    long long x, y;
};

__int128 orient(
    const Point& a,
    const Point& b,
    const Point& c
) {
    __int128 abx = (__int128)b.x - a.x;
    __int128 aby = (__int128)b.y - a.y;

    __int128 acx = (__int128)c.x - a.x;
    __int128 acy = (__int128)c.y - a.y;

    return abx * acy - aby * acx;
}
这里把坐标差放在128位类型中计算,可以避免许多普通64位中间乘法溢出问题。但 __int128 不是标准C++的可移植任意精度整数类型,而且当64位坐标取到极端值时,128位中间乘积及减法仍然可能超出范围。因此,工业程序仍需根据坐标最大范围进行严格验证,必要时采用大整数或稳健的几何内核。这一点十分重要。数学公式本身正确,不代表使用任意数值类型实现它都正确。

如果只需要调用现成的凸包算法

真实项目中,我们不必为了每次求凸包都重新实现 Graham Scan。Python 的 Shapely 提供了凸包计算功能。例如:
python

from shapely.geometry import MultiPoint

points = MultiPoint([
    (0, 0),
    (4, 0),
    (4, 4),
    (0, 4),
    (2, 2)
])

hull = points.convex_hull

print(hull)
print("凸包面积:", hull.area)
print("凸包周长:", hull.length)
这里会得到由正方形四角构成的凸包。中心点不影响其外边界。Shapely 官方文档也指出,当输入只有一个点或两个不同的点时,凸包会退化为点或线段,而不是一个正常的多边形。[GitHub][Shapely 2.2.0 documentation]使用现成库能够减少大量工程工作。但理解底层算法,依然很有价值。因为只有理解叉积、排序和边界条件,我们才能判断输入数据出了什么问题,或者为什么某个输出结果与肉眼预期不同。

第十幕:从电话实验室到机器人——凸包如何进入真实世界?

机器人和激光雷达系统可以产生大量空间采样点。在某些任务中,凸包能够为这些点提供一个简单、保守的外部边界。格雷厄姆1972年的论文,研究的是平面有限点集。但它所揭示的思想,并没有停留在数学课本里。随着计算机图形学、地理信息系统、机器人和计算机视觉的发展,凸包逐渐成为许多空间计算任务的基础组件。它的应用并不总是直接把一根橡皮筋套住所有点。更常见的情况,是某个复杂系统需要快速获得一个对象的外部范围、极端位置或几何性质。这时,凸包便能够提供一种简洁的数学表达。

应用一:地图上,一组位置的外部范围

地理信息系统中的凸包示意。对一组位置坐标求凸包,可以得到包含全部采样点的最小凸区域。假设某个研究项目在一座城市中记录了数千个采样点。这些点可能来自:气象观测。野外生态调查。移动设备位置记录。环境监测。研究人员希望知道,这些点大致覆盖了怎样的空间范围。最简单的方法之一,就是求出它们的凸包。它能够将复杂点集,转化成一个外部凸多边形。但这有一个重要局限。如果采样点沿着一条弯曲河流分布,凸包可能把河流拐弯之间大片并未采样的陆地区域也包进去。所以,凸包不是采样区域真实形状的精确恢复。它是一种凸性约束下的外部包络。如果希望得到能够反映凹陷结构的边界,就可能需要凹包(Concave Hull)、Alpha Shape 或其他更加复杂的空间重建方法。

应用二:计算机视觉中的物体边界

假设摄像头已经检测出一组属于同一个物体的轮廓点。研究人员希望快速得到这些点的外围几何范围。凸包可以帮助计算:包围点集的凸多边形。凸包面积。凸包周长。某些物体形状的方向和极端位置。在对轮廓进行预处理时,凸包也可以作为一种简单的几何参照。但它不能恢复物体的所有细节。例如,一只张开手掌的手,其手指之间具有明显的凹陷区域。凸包会跨过这些凹陷。因此,它更适合表示整体外围范围,而不是精确描述手指之间的边界。
真实轮廓手指之间存在明显的凹陷。
凸包边界凸包连接外围点,填平了部分凹陷。
这也解释了为什么现代计算机视觉系统经常同时使用多种几何表示。有时候需要完整轮廓。有时候需要旋转矩形框。有时候只需要凸包。不同表示适合不同任务。

应用三:机器人与碰撞检测

想象一台机器人在仓库里移动。它必须避免撞到障碍物。一种常见做法,是先使用简单的包围形状,对潜在碰撞进行快速筛选。例如,某个复杂的物体拥有大量顶点。我们可以先为它构造一个凸包。然后判断机器人对应的包围区域与该凸包是否分离。如果两个包含真实对象的外包区域完全分离,那么真实对象也一定分离。这可以帮助排除一部分不可能发生的碰撞。但是,如果两个凸包重叠,并不能立刻证明它们内部的真实物体一定碰撞。因为凸包可能包含大量原始物体并未实际占据的区域。所以,凸包常常适合作为一种保守的几何包围形状,而不是所有场景中的最终碰撞判定。

应用四:寻找最远的两个点

假设给出一百万个平面点。我们希望找到其中距离最远的两个点。最直接的方法,是检查全部点对。需要:O(n2)O(n^2)量级的距离计算。但是,可以证明:平面点集中的最远点对,一定可以在凸包顶点中找到。这就意味着,如果大量点藏在凸包内部,它们不必全部参与最后的最远点对搜索。研究人员可以先求出凸包。然后利用**旋转卡壳(Rotating Calipers)**等几何技术,在凸包上进一步处理最远点对与某些包围形状问题。这种思想在计算几何中非常重要。先将复杂数据化简为保留关键极端性质的结构。再在这个简化结构上完成下一步计算。格雷厄姆1983年与 F. Frances Yao 合作研究简单多边形的凸包时,也特别指出,凸包能够帮助简化图形操作,并用于包围形状相关问题。[CiteSeerX]

一项空间算法,不只是画出一个多边形

到了这里,我们应该能够理解凸包的真正价值。它不是一个独立的绘图技巧。而是一种空间数据压缩与几何结构提取。给定:nn个点。经过计算,我们得到:hh个凸包极端顶点。其中:h≤nh\le n如果绝大多数点位于内部,那么:h≪nh\ll n我们就用更少的数据,保存了点集最外层的凸性结构。这是一种非常有价值的信息提取能力。但它同时提醒我们:每次压缩都有代价。内部细节会丢失。凹陷会被填平。某些真实边界无法恢复。因此,算法工程师必须清楚知道:这项几何表示保留了什么,又舍弃了什么。

第十一幕:从二维橡皮筋,到我们无法直接看见的高维空间

三维凸包通常表现为一个凸多面体。它是二维凸包概念的自然推广,但计算结构和复杂度分析会发生变化。到现在为止,我们始终生活在二维平面里。一个点只需要两个坐标:(x,y)(x,y)我们可以把它画在纸上。也可以用橡皮筋包围。但是,真实世界不只有两个维度。

如果把钉子放进三维空间呢?

假设有一组空间点:(xi,yi,zi)(x_i,y_i,z_i)它们可能来自三维激光扫描。也可能来自一个物体的三维模型。我们希望找到包含全部点的最小凸集合。这时候,凸包不再是一个平面多边形。而是一个凸多面体。它拥有:顶点。边。面。如果点集具有充分的三维结构,凸包可以由若干多边形面共同构成。为了便于计算机图形学处理,这些面还可以被三角化。但原来的二维 Graham Scan,并不能不加修改地直接解决一般三维凸包问题。因为三维空间中,三个点的左转或右转,不再能够完整表达所有需要处理的空间关系。我们可能需要判断:一个点位于某个平面的哪一侧?哪些三角面是外表面?新增点会使哪些面失效?如何维护多面体的拓扑结构?这些问题需要更加复杂的几何算法。例如,三维增量式凸包算法和 Quickhull 等方法。

如果维度继续增加呢?

在机器学习、优化和统计学中,数据点可能不是二维或三维坐标。一个样本可能拥有:十个特征。一百个特征。甚至上千个特征。这些数据也可以被视为高维空间里的点。从数学定义上说,凸包依然存在。我们仍然可以考虑所有点的凸组合:x=∑i=1nλipi\boxed{ x=\sum_{i=1}^{n}\lambda_i p_i }其中:λi≥0\lambda_i\ge0并且:∑i=1nλi=1\sum_{i=1}^{n}\lambda_i=1所有这样的凸组合,构成这些点的凸包。这个公式可以看成二维橡皮筋思想在高维空间中的抽象推广。但随着维度增加,凸包的顶点、面以及其他组合结构可能变得异常复杂。因此,高维凸包问题不能简单套用二维点集:O(nlog⁡n)O(n\log n)的复杂度结论。这也揭示了计算几何的一项重要特点。同一个数学概念,随着维数变化,算法难度可能发生根本性改变。在二维平面里,一次左转判断可能就足以决定当前栈顶是否保留。在三维空间里,我们需要维护多面体的面与邻接关系。在更高维空间里,组合复杂度还可能迅速增长。几何概念可以自然推广,但算法往往必须重新设计。

尾声:一根橡皮筋,怎样变成一门科学?

让我们再次回到1972年。一位在贝尔实验室工作的数学家,正在研究平面点集。他希望找到其中最外围的点。从人类的角度来看,这项任务似乎不需要太多思考。只要把一根橡皮筋套在全部点的外面,让它慢慢收缩,就能够看到结果。但计算机没有橡皮筋。它不能直接依靠视觉直觉理解什么叫作外边界。它必须通过明确的操作规则完成判断。格雷厄姆的研究,正是在这种人类直觉与机器计算之间建立了一座桥梁。他发现,可以先利用排序,将杂乱的点组织成有序的候选序列。然后通过简单的方向判断,逐步剔除不可能构成凸包的点。每一次计算,都只涉及少量坐标运算。每个点,也只需要有限次进入和离开扫描结构。于是,一个原本看起来需要反复检查大量几何关系的问题,被转化成:O(nlog⁡n)O(n\log n)时间内即可完成的算法。这项研究背后,有一段持续演变的科学历史。1972年的论文,为平面凸包计算提供了重要的高效方法。1973年,Jarvis 从另一种方向发展了边界包裹算法。1979年,Andrew 又利用字典序排序与上下链扫描,构造了另一套广泛使用的实现。1985年的历史考证,让人们重新认识1967年更早的相关研究。此后,凸包算法进入更多应用领域。地图数据。图像处理。机器人。空间几何。以及高维计算。在这条发展道路上,格雷厄姆的名字,始终与计算几何早期的重要突破紧密相连。但回顾整章,我们应该记住的,并不只是某个人的名字,或者某一段代码。真正深刻的思想,是下面这三步。第一步:把视觉问题转化成数学结构。橡皮筋包围点集的直觉,被定义为最小凸集合。第二步:把空间关系转化成可以精确判断的运算。左转、右转和共线,通过叉积得到明确答案。第三步:利用数据的顺序,避免重复搜索。通过极角排序与栈,我们无需逐一检查所有可能的多边形边界,就能构造出真正的凸包。这是一种极具代表性的算法设计过程。我们不是让计算机模仿人的眼睛。而是先找到视觉直觉背后的数学规律。再让计算机执行这些规律。最终,机器获得了一项原本属于人类空间直觉的能力。这一章还是本系列第一次将计算几何作为主要研究对象。在第二篇《寻找世界的最优解》中,我们研究了资源如何分配、路径如何选择、网络如何寻找瓶颈。而现在,我们开始研究空间本身。点与线。方向与距离。边界与区域。几何位置之间的关系。这将为后面的章节打开一个全新的世界。下一章,我们会看到一项更加奇妙的几何结构。如果不再使用一根橡皮筋包围全部点,而是要求平面上的每一个位置,都归属于距离它最近的那个点,会发生什么?城市会被分割成不同的服务区域。医院可以拥有各自的最近服务范围。机器人可以根据空间距离,划分自己应该前往的区域。一张由距离关系形成的几何图案,将逐渐出现。它叫作:沃罗诺伊图(Voronoi Diagram)。而那,将是另一段跨越数学、自然科学与计算机技术的历史。

历史档案:史实来源与教学边界

本章对“第一种凸包算法”的历史归属进行了特别核查,区分早期相关思想、正确且有复杂度分析的算法,以及后来的教材实现。
年代史实本章采用的表述
1967年Bass 与 Schubert 研究中包含早期凸包算法思想原始描述存在正确性问题,经后人解释可形成有效算法
1970年Chand 与 Kapur 发表凸多面体算法研究表明凸包相关计算早于1972年
1972年Ronald Graham 发表平面凸包算法经典 O(nlog⁡n)O(n\log n) Graham Scan 的重要来源
1973年R. A. Jarvis 发表平面凸包算法后来广泛称为 Jarvis March
1979年A. M. Andrew 发表二维凸包算法现代常用的上下单调链算法
1985年Godfried Toussaint 重新考证早期历史指出1967年工作的意义与局限
主要参考资料:本章开篇的贝尔实验室场景属于以真实发表记录为依据的文学性问题导入,并不声称存在某次具体会议或研究者独白的逐字记录。钉子与橡皮筋、坐标样例、交互图解及 Python/C++ 程序均为现代教学内容。尤其需要说明:本章互动实验及代码采用现代常用的最低点起始版本;1972年 Graham 原始论文使用凸包内部参考点进行极角排序,二者在基本算法思想上相通,但不是完全相同的逐行实现。
下一章预告 · 第42章

《谁离你最近:沃罗诺伊图与城市的分割》

1908年,一位俄国数学家发表了一项关于空间几何的重要研究。他所研究的结构,如今被广泛用于计算机科学、地理分析、材料研究和空间规划。假设地图上有十家医院。现在,我们希望知道:每一个位置,究竟距离哪一家医院最近?如果所有位置都按照最近医院进行归属,城市就会被切分成许多区域。这些区域拥有奇妙的几何边界。而它们所形成的整体结构,就是沃罗诺伊图。下一章,我们将从狄利克雷与沃罗诺伊的历史研究出发,理解垂直平分线如何逐渐构造出复杂的空间划分,并进一步认识它与德劳内三角剖分之间的对偶关系。从寻找世界的外边界,到重新分割世界的内部空间,计算几何的故事正在展开。
——《算法的故事》第二季 · 第41章 完

本章配图