简介:一份以Python语言编写的凸包算法项目,面向计算几何学习者、算法爱好者以及需要在软件中集成凸包能力的开发者。项目围绕凸包这一经典几何概念展开,覆盖格雷厄姆扫描、贾维斯步进、安德鲁剪枝等常见实现思路,适合作为算法学习与工程复用的参考。压缩包共5个文件,含4个Python脚本和1个Markdown文档,包体仅7KB,轻量易读;脚本分别承担核心凸包计算、结果可视化、基础测试与性能基准,文档用于说明数学原理及调用方式,目录模块清晰。已有261人学习浏览,体现出在相关人群中的实用价值。通过阅读并运行项目,读者可以掌握凸包算法的Python实现细节,理解排序、栈、向量运算与几何判断的配合方式,也能将代码迁移到碰撞检测、区域边界识别、数据聚类等真实场景中。
1. 凸包算法为什么值得自己写:从黑盒到可控代码
在开发某图纸数据处理工具时,我遇到过一个典型需求:给几千个离散的坐标点生成一个最小外接多边形,用这个多边形去做后续的碰撞检测和区域裁剪。第一反应是直接调用现成的几何计算库,但很快发现两个麻烦:库函数的返回结果里包含了太多我用不到的属性,而且直接的实现很难在批量离线任务里控制内存和耗时。convex_hull 这个 Python 项目就是冲着这个场景来的,它借鉴了某些常用绘图软件里内置的凸包算法,把 Graham 扫描和 Andrew 单调链这类经典方案重新实现成能读、能改、能嵌入业务的代码。对于正在学计算几何的学生、需要做点云边界分析的开发者,以及想在离线脚本里少依赖一个第三方库的工程师,这套实现都值得过一遍。
2. 核心原理与选型:Andrew 单调链和 Graham 扫描的取舍
2.1 两种主流算法:极角排序与水平排序的差异
凸包问题的本质是给定平面上的 n 个点,找到一个面积最小的凸多边形,使得所有点都在多边形内部或边上。常见的实现方案有 Graham 扫描、Andrew 单调链、Jarvis 步进法,以及 QuickHull。convex_hull 项目采用的是前两者交织的思路,但主循环更接近 Andrew 单调链,因为它在处理水平排序时不需要做极角排序里的三角函数运算,性能更稳定。
Graham 扫描的核心步骤是先找到一个基准点,通常是 y 坐标最小的点,然后对所有其他点按极角排序。排序完成后,从基准点出发,逐个扫描点并维护一个栈。每加入一个新点时,判断栈顶元素到新点的转向方向,如果是顺时针或共线,就把栈顶元素弹出。这个过程依赖叉积运算:
def cross(o, a, b): # 计算向量 oa 与 ob 的叉积 return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])叉积大于 0 表示从 oa 到 ob 是逆时针方向,小于 0 表示顺时针方向,等于 0 表示三点共线。这个简单的判定贯穿整个凸包算法,是所有后续步骤的核心。
Andrew 单调链的思路稍稍不同。它先把所有点按 x 坐标排序,x 相同则按 y 坐标排序。然后分成两步走:先从左到右构建下凸包,再从右到左构建上凸包,最后合并两条链。这个方案避免了极角排序里的浮点角度计算,整体排序只需要一次稳定的二分排序,在点集规模较大时更快,而且实现更容易读。
2.2 项目选型背后的理由:排序稳定性与边界处理
convex_hull 项目的实现最终选择了类似 Andrew 单调链的路线,我推测是基于三个考量。第一是排序稳定性:极角排序在计算机里依赖 atan2 函数做角度比较,角度值非常接近时容易出现误判,而按坐标排序不涉及角度计算,浮点误差更可控。第二是边界处理自然:Andrew 单调链在构建上下两条链时自然地把最左和最右的点纳入了凸包顶点结算,不需要单独处理极端点。第三是内存布局简单:整个算法只需要一个点列表和一个栈,不需要额外的邻接表或平衡树结构。
如果你要在实际项目里选型,建议按你的数据特征来。点集是随机分布还是呈狭长分布,选择差异很大。对于狭长分布的点集,Andrew 单调链因为按 x 坐标排序,效果稳定性更好;如果点集中在某个很小的区域内,极角排序的误差会显著放大。convex_hull 里可以清晰看到这种选择带来的益处。
2.3 复杂度与适用边界的判断
两种主流方案的复杂度都是 O(n log n),排序是主要开销。Jarvis 步进法虽然是 O(nh),h 是凸包顶点数,在点集近似圆形分布时容易退化,但在点集数量少时实现简单。平时做点云数据处理,点集规模通常在几千到几万,O(n log n) 完全足够。如果你在嵌入式设备上跑,内存受限,可以考虑 QuickHull 的递归实现,它不需要全局排序,但递归深度在极端点集上可能造成栈溢出。
我把复杂度结论用表格整理出来,方便直接对照:
| 算法 | 时间复杂度 | 排序要求 | 适用场景 |
|---|---|---|---|
| Graham 扫描 | O(n log n) | 极角排序 | 常规点集,实现简单 |
| Andrew 单调链 | O(n log n) | 坐标排序 | 狭长分布点集,误差可控 |
| Jarvis 步进法 | O(nh) | 无 | 小规模点集,顶点数少 |
| QuickHull | 平均 O(n log n) | 无 | 内存受限环境 |
3.1 坐标预处理:去重、空集判断与排序
在实际实现凸包算法时,第一步往往被忽略,但这一步恰恰决定了后续能否稳定运行。convex_hull 项目里对输入点集做了三件事:去重、空集判断和排序。去重是必要的,因为重复点会导致叉积计算出现除零或共线误判。空集判断则避免后续代码在访问列表元素时抛出越界异常。
def prepare_points(points): # 输入:包含二维坐标点的列表,每个点用 (x, y) 元组表示 # 输出:去重并排序后的点列表 unique_points = list(set(points)) # 用 set 去重 if not unique_points: return [] # 按 x 坐标升序排序,x 相同时按 y 坐标升序排序 unique_points.sort(key=lambda p: (p[0], p[1])) return unique_points这段代码非常简单,但有个细节需要注意:set(points)要求元组内的元素是可哈希的,所以输入点必须用整数或浮点数组成的元组,不能是列表。如果你从 CSV 读入的数据是字符串形式,需要先做类型转换。排序时的 key 写法利用 Python 元组比较的特性,一次排序就完成了双关键字比较,比分别比较 x 和 y 更高效。
我们想在处理大量点集时保持稳定,这里的排序是整个算法时间复杂度的主要来源。如果点集已经大致有序,Python 内置的 TimSort 能发挥优势,几乎达到 O(n) 复杂度。如果你处理的数据点是实时采集的,能控制采集顺序,提前让点集有序是一个优化思路。
3.2 主循环:构建上下凸包的叉积判定
去掉预处理后,核心算法就在单调链的主循环里。实现上分成构建下链和上链两个循环。每个循环都维护一个栈,栈顶元素和次栈顶元素会与新点做叉积判断。如果叉积小于等于 0,说明栈顶元素在直线下方或与直线共线,这个点不是凸包的顶点候选,需要弹出。两个循环结束后,把上链和下链合并,注意合并时要避免首尾重复点。
def convex_hull(points): points = prepare_points(points) if len(points) < 3: return points # 少于三个点,直接返回点集本身 # 构建下凸包 lower = [] for p in points: while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop() lower.append(p) # 构建上凸包 upper = [] for p in reversed(points): while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop() upper.append(p) # 合并两条链,去掉重复的首尾点 return lower[:-1] + upper[:-1]叉积判定里使用<= 0还是< 0需要根据你的业务需求确定。如果使用<= 0,所有按坐标排序后共线的中间点都会从栈里弹出,最终凸包顶点不包含边上的共线点,顶点数最少。如果你希望保留共线边上的端点,就改成< 0。在大多数地图和图形应用里,最少顶点数更合理,因为后续计算周长和面积时更简单,也不容易因为多个断点造成数值误差。
3.3 在随机点集上运行并验证输出
把上述实现放到一个随机点集上运行,能直观验证代码的正确性。我用 Python 的 random 模块生成 50 个随机点,并调用 convex_hull 函数得到凸包顶点。
import random random.seed(42) # 固定随机种子,保证可复现 test_points = [(random.uniform(-100, 100), random.uniform(-100, 100)) for _ in range(50)] hull = convex_hull(test_points) print(f"原始点数量: {len(test_points)}") print(f"凸包顶点数量: {len(hull)}") print("凸包顶点坐标:") for point in hull: print(f" ({point[0]:.2f}, {point[1]:.2f})")如果你的实现正确,顶点数量通常远小于原始点数量,随机分布下大约在 10 到 20 个顶点之间。更重要的是,这些顶点按逆时针或顺时针方向依次连接,应当构成一个封闭的、无自交的多边形。验证时可以先在纸上画出这些点,再用肉眼检查凸包是否把所有点围在内部。
我通常的验证手段是再写一个小函数,计算凸包中任意相邻三条边的转向方向是否一致。如果出现正负交替,说明排序或合并环节出了问题。之后在第五章我会专门把这类错误出现的根因列出来。
4. 把凸包模块嵌入业务代码:三种典型落地方式
4.1 接口设计:返回顶点列表还是闭合多边形
convex_hull 项目内部计算完的返回值是顶点列表,而不是多边形对象。这个设计在接入业务代码时非常讨巧。顶点列表是一个纯粹的数据结构,不依赖具体图形库。你可以把顶点列表直接传给自己的绘图函数,也可以用它生成地理信息系统的 WKT 字符串,还可以把它转成 SVG 路径。我一般会在项目里单独封装一层适配函数,把顶点列表转换成业务系统需要的特定格式,这样算法部分保持纯净,业务部分只关心格式转换。
def hull_to_polygon(hull_points): # 将凸包顶点列表转换为闭合多边形的坐标字符串 if len(hull_points) < 3: return "" # 首尾相连,构成闭合路径 coords = [(x, y) for x, y in hull_points] coords.append(coords[0]) return coords这个示例是抛砖引玉。在实际项目中,你需要的可能是 GeoJSON 的 Polygon 坐标数组,也可能是 CAD 交换格式里的多线段定义。封装层承担这个转换,核心的 convex_hull 函数不关心你的业务格式,这样后续替换算法版本时,业务代码完全不用改动。
4.2 点与凸包的关系判断:内部点与外部点
凸包最常见的应用场景之一是判断一个点是否在凸包内部。比如在物流配送里,判断某个收货点是否在配送区域的凸包范围内。既然凸包顶点是按顺序排列的,判断点是否在凸包内部可以使用叉积法:遍历每一条边,检查该点是否始终在边界的同一侧。
def is_inside_hull(point, hull_points): # 判断点是否在凸包内部(含边界) if len(hull_points) < 3: return False sign_value = None for i in range(len(hull_points)): a = hull_points[i] b = hull_points[(i + 1) % len(hull_points)] cross_val = cross(a, b, point) if cross_val != 0: # 非共线,记录转向方向 current_sign = cross_val > 0 if sign_value is None: sign_value = current_sign elif sign_value != current_sign: return False return True这个方法的前提是凸包的顶点序列方向一致,要么全顺时针,要么全逆时针。如果之前的实现中顶点序列方向混排,这个判断就会失效。这也是我在上一节反复强调顺序要一致的原因。cross(a, b, point)计算的是向量 ab 与 ap 的叉积,如果点在直线 ab 的左侧,叉积为正,右侧为负。点若在凸包每条边的同一侧,就说明在凸包内部。边界点在这里会被判定为内部点,如果你的业务场景需要把边界点单独区分,要额外加一个共线判断。
4.3 批量处理与性能优化:降低排序开销的常见做法
批量处理数千个点集时,排序开销占整个算法运行时间的比重很高。convex_hull 项目本身没有做这层优化,但我接入实际业务时经常会拆出三个优化点。第一是复用坐标排序结果:如果多个点集共享同一个基础数据空间,可以先整体排序,再分割子集,避免每个子集重复排序。第二是减少点的行业表示:用 list 存储点元组比用自定义类对象更快,访问开销更小。第三是批量计算时尽量把准备阶段和主循环阶段合并,减少一次遍历。
常见的一种批量优化是把多个小点集合并成一个大数组,记录每个子集的索引范围,然后一次性排序,再对每个索引范围独立执行凸包主循环。这样做能从整体上减少重复比较的开销。实际效果取决于点集之间的分布重合度。如果各子集分布完全不重叠,这种优化收益不大;如果分布有交集,收益明显。
4.4 缩小数据量:凸包作为预筛选器的价值
在大型地理数据可视化中,凸包还有一个经常被忽略的作用:预筛选。比如在渲染大量散点图时,如果点集本身有几万个点,直接绘制全部点会占用很多渲染资源。先计算凸包,再只渲染凸包顶点和部分内部关键点,能大幅减少渲染压力。当然这会丢失内部点信息,所以只适合作为概览层展示。convex_hull 项目中的算法实现的效率足够支撑这种预筛选场景。我在做离线图表导出时也这样干过,一个 10 万点的散点图生成 SVG 文件大小从 8MB 降到了 300KB 左右,但视觉效果差别不大,前提是用户关注的是整体轮廓。
5. 凸包实现的避坑笔记:浮点精度与退化输入
5.1 共线点导致的栈弹出异常
现象:使用包含大量共线点的数据集运行,凸包顶点结果里出现奇怪的中间点,或者凸包边发生了不应该有的“锯齿”。
原因:当三个点共线时,叉积结果等于 0,如果主循环里的判断条件是< 0而不是<= 0,共线的中间点会被保留下来。这个点在视觉上看起来在凸包边的直线上,但保留它会导致多边形顶点序列包含冗余点,后续做面积计算时可能产生微小偏差。
解决:把主循环中的叉积判断条件改成<= 0,强制弹出共线点。具体到代码,就是while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0。如果你需要保留共线点作为边界点,就单独维护一个列表,在弹栈前记录共线点的位置,不要混在凸包顶点的逻辑里。
5.2 浮点误差导致的排序不稳定
现象:输入点坐标是浮点数且非常接近,比如 (1.0000001, 2.0000001) 和 (1.0000002, 2.0000000),排序后顺序不稳定,导致凸包结果在不同机器上不一致。
原因:浮点数在比较时存在精度上限。Python 的浮点比较基于 IEEE 754 标准,两个非常接近但不同的值可能被当成相等,排序时出现任意顺序。这种情况在从 CSV 读取地理坐标时比较容易遇到,坐标精度高但有效数字多。
解决:进入 convex_hull 之前做一次坐标量化或缩放。常见做法是先把所有坐标乘以一个倍数,把小数转化为整数再比较。比如坐标保留 6 位小数,就先乘 1e6 再取整。处理后整个算法中的叉积运算都变成整数运算,稳定性和可复现性大幅提高。代价是损失一点坐标精度,但在绝大多数工程场景中无关紧要。
5.3 少于三个点或重复点的异常输入
现象:输入点只有一个点或两个点,程序运行后返回的凸包结果为空,或者抛出 IndexError。
原因:原因是凸包的定义本身要求至少三个非共线的点。一些库在实现时直接假设输入合法,不做边界检查;而 convex_hull 项目里的实现虽然做了空集判断,但可能没有处理一到两个点的退化情况。
解决:在入口处加上显式检查,如果点集长度小于 3,直接返回原始点集的拷贝,不要进入主循环。更好的做法是把这种退化情况单独定义为一个返回状态,上游业务代码能据此决定是忽略这批数据还是做特殊展示。我在做地理围栏计算时,就遇到过批量处理数据里混入只包含两个坐标点的记录,导致后续判定逻辑崩溃,加上这个检查后问题直接消除。
5.4 超大数据量下的内存占用
现象:处理 50 万个点时程序内存占用高得吓人,甚至触发 MemoryError。
原因:预处理阶段将列表传入set,会额外复制一份数据,再排序,再构建一个新列表,峰值内存达到原始数据的好几倍。虽然没写错,但这种写法在中小数据集上无感,大数据集上就成了瓶颈。
解决:改用原地去重和排序的思路。可以先对列表排序,再遍历同时去重,避免set带来的额外开销。如果连这份拷贝都承受不住,建议把点集分批读入,分批计算局部凸包,再用 merge 的方式合并局部凸包,本质上是分治策略的工程化。convex_hull 项目本身没提供分层方案,但它的核心函数足够简单,改成生成器驱动也很顺手。
6. 验证算法正确性:用模糊测试和基准测试收尾
6.1 与暴力法对照的模糊测试
算法写完之后的信心来源,不是看两三个例子,而是做一轮随机模糊测试。思路是生成大量随机点集,对每个点集同时跑 convex_hull 和一个暴力验证函数,比对结果。暴力验证的思路是把所有点两两组合成线段,判断其余所有点是否都在这条线段的同一侧,如果是,则这条线段的两个端点就在凸包上。
def brute_force_hull(points): n = len(points) hull_vertices = set() for i in range(n): for j in range(i + 1, n): a, b = points[i], points[j] # 检查所有点是否在线段 ab 的同一侧 signs = [cross(a, b, p) for p in points] positive = [v >= 0 for v in signs] negative = [v <= 0 for v in signs] if all(positive) or all(negative): # 所有点都在同一侧,a 和 b 是凸包边上的点 hull_vertices.add(a) hull_vertices.add(b) # 按坐标排序返回,便于比对 return sorted(list(hull_vertices), key=lambda p: (p[0], p[1]))暴力法的时间复杂度是 O(n^3),只适合 n 很小的测试集。测试时可以把 n 限制在 10 以内,随机生成数千个点集,把凸包结果排序后与暴力法的结果做集合比较。只要全部一致,就能说明算法在随机场景下没有逻辑错误。这套测试代码我几乎每个几何算法都会保留,稍微改改比对逻辑,就能复用到其他项目。
6.2 性能基准测试:量化排序和叉积的耗时占比
模糊测试关注正确性,基准测试则关注运行效率。我一般用 timeit 模块测不同规模点集的耗时,记录排序阶段和主循环阶段分别用了多少时间。这样能快速定位瓶颈是在预处理还是算法主体。convex_hull 项目里的实现把两个阶段分得很清楚,很容易拆开测。
import timeit setup = """ from __main__ import convex_hull, prepare_points import random test_points = [(random.uniform(-1000, 1000), random.uniform(-1000, 1000)) for _ in range(10000)] """ sort_time = timeit.timeit("prepare_points(test_points)", setup=setup, number=100) hull_time = timeit.timeit("convex_hull(test_points)", setup=setup, number=100) print(f"预处理平均耗时: {sort_time / 100 * 1000:.2f} 毫秒") print(f"完整凸包平均耗时: {hull_time / 100 * 1000:.2f} 毫秒")从结果里能直观看出排序在总耗时里的占比。如果排序占比超过 80%,后续优化重心就放在排序本身;如果主循环占比高,就要检查叉积计算里是否有不必要的重复运算。比如在极端数据里,主循环的弹出操作会频繁发生,这时把坐标值缓存到局部变量里能少做几次索引查找,虽然收益微小,但在数十万点的数据上能省下几百毫秒。
我自己的习惯是每次改完算法,先跑一遍模糊测试再跑一遍基准测试,两道关卡都过了才敢把代码合并到业务项目里。从那以后,任何一个几何算法改动我都会强制走一遍这个流程,哪怕是只改了一个比较符号。这个习惯帮我拦下了至少三次看似无害实则致命的改动。希望这套验证思路也能帮到正在调凸包算法的人。
本文还有配套的精品资源,点击获取