☰
简单多边形O(n)凸包算法:Melkman原理与C++实现
2026/10/12 22:13:57 网站建设 项目流程

做计算几何的人应该都有这种经历:拿到一个多边形轮廓,第一反应就是用Graham扫描或者Andrew单调链去求凸包,写起来不复杂,但总觉得排序那一步有点浪费。直到我认真去翻Melkman's Algorithm,才意识到在“顶点按顺序给出的简单多边形”这个特定前提下,我们完全可以丢掉排序,用O(n)的时间直接扫一遍出结果。

这个算法不算冷门,但在很多工程代码里反而见得少,可能是因为教材里普遍只讲Graham和Andrew,提到Melkman时也常常一笔带过。这篇文章我会从算法思路、几何原理到完整C++实现讲透,再把我实际调试中踩过的坑和排查经验一起整理出来。适合正在写几何算法、游戏碰撞体生成、图像轮廓分析或者地理围栏相关功能的人,看完可以直接照着用。

1. 为什么需要“多边形快速凸包算法”

1.1 凸包到底在解决什么问题

凸包在图形学、游戏、路径规划、图像处理里都太常见了。说人话就是:给你一堆点或者一个多边形,用一根无形的“橡皮筋”把所有点包住,绷紧之后得到的那个凸多边形就是凸包。

实际工程里我遇到最多的是两类需求。一类是碰撞检测,引擎里如果用原始多边形做精确碰撞,顶点多、计算量大;把复杂凹多边形先转成凸包,再套用凸多边形的分离轴定理,速度会快很多。另一类是轮廓分析,比如拿到图像分割得到的区域轮廓,想获取它的外边界大致形状,凸包就是最稳的第一步。

还有一些场景,比如障碍物膨胀、电子围栏生成、活动范围分析,本质都是求一组有序顶点围成区域的凸包。这些场景有一个共同特征:输入不是乱序散点,而是多边形顶点按顺序给好的轮廓。

1.2 已有凸包算法的主要瓶颈

常见的凸包算法里,Graham扫描和Andrew单调链都把排序作为前置步骤,时间复杂度O(n log n)。排序对于“任意点集”来说无法避免,因为点之间本来没有顺序关系。Jarvis步进法虽然不用排序,但复杂度是O(n·h),h是凸包顶点个数,最坏情况退化成O(n²)。

这就在实际工程里造成一个奇怪的现象:明明轮廓数据已经是按顺序给出来的多边形顶点,却还要额外进行一次极角排序或坐标排序,白白浪费了输入里已经包含的顺序信息。

1.3 什么输入才适合Melkman

Melkman算法有一个明确的前提:输入必须是简单多边形的顶点序列,按顶点顺序(顺时针或逆时针均可)给出。简单多边形就是边与边不会自交的多边形。

只要满足这个前提,算法就能达到O(n),不需要排序,也不依赖凸包顶点个数。如果拿到的输入是乱序散点,那Melkman就不适用,老老实实去排序做Andrew。这是选型时最重要的判断点。

2. 算法为什么能O(n):从双端队列说起

2.1 巧妙的“在线处理”思路

Melkman处理顶点的方式是流式的:从多边形的第一个顶点开始,逐个读入,每一步都维护“当前已读顶点集合”的凸包。你不需要等到所有点都读进来,每处理一个点,手里就已经有一个正确的凸包。

这一点很像在线算法。你在一条折线上往前走,手里的凸包是动态更新的。因为这个特性,它甚至可以用在流式轮廓输入的场景里,比如传感器一个点一个点往外吐数据的情况。

它不需要排序的根本原因,就是顶点顺序已经固定了。对于任意点集,极角排序能帮我们确定谁先谁后;但在多边形轮廓里,先后关系天然存在,算法只需要沿着这条顺序走下去即可。

2.2 双端队列里到底存了什么

实现的核心结构是双端队列(deque)。deque里维护的是当前凸包的一条闭合链,front元素和back元素是同一个点,相当于一个环形链首尾相接。

举个例子,初始化完成之后deque可能是这样:

d = [p2, p0, p1, p2]

这里的d.front()和d.back()都是p2,从front到back遍历得到的顶点序列p2 → p0 → p1 → p2,构成了一个闭合的凸多边形边界。

每次来一个新顶点,先把deque两端不合适的旧顶点弹出,再把新顶点同时插入两端。插入之后front和back又变成同一个点(都是新顶点),闭合链继续成立。

2.3 弹出规则背后的几何直觉

处理每个新顶点时,有两段循环判断。

第一段看deque头部方向,判断当前新顶点相对于边d[0] → d[1]的位置。如果新顶点在这条边的右侧,或者与这条边共线,就把d[0]从头部弹出,一直弹到新顶点位于头部边的左侧为止。

第二段看deque尾部方向,判断新顶点相对于d[size-2] → d[size-1]的位置。同样,如果新顶点在右侧或共线,就从尾部弹出。

理解这个规则最直观的类比是橡皮筋:已有顶点被一根绷紧的橡皮筋包住,新顶点从某个方向挤进来,把皮筋顶出去,原本支撑皮筋的旧顶点失去作用,就被释放掉。deque两端的弹出,就是在把失去支撑的顶点从边界上拿掉。

弹出不是随意进行的。因为deque里维护的始终是一个凸链,新顶点只可能影响与两端相连的那两条边。如果新顶点在最外层,它会让某一段老边界失效;我们把失效边界上的顶点从两头弹出,直到新顶点与两端连成的边重新构成凸多边形,此时插入就完成了。这个循环保证了每个顶点最多被弹出一次,所以总复杂度是O(n)。

3. 完整实现:C++版Melkman

3.1 先搞定叉积和方向判断

几何算法里,方向判断是最基础的工具。对于三个点a、b、c,叉积可以判断c在ab连线左侧、右侧还是线上:

long long cross(const Point& a, const Point& b, const Point& c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); }

如果cross大于0,说明c在ab的左侧;小于0,说明c在右侧;等于0,说明三点共线。这里的“左侧”基于右手坐标系,x轴向右、y轴向上。

初始化时我们希望构成一个逆时针方向的三角形。判断前三个点的方向时,如果cross小于0,就交换第二个和第三个点,保证最终三角形方向统一为逆时针。这个统一方向在后面输出结果时很有用。

3.2 初始化:前几个点共线怎么办

很多教材里的Melkman实现默认前三个点不共线。但真实工程数据里,多边形从某条直边中间开始的情况太常见了,不处理前段共线点直接跑,结果会丢掉凸包的极端端点。

我的做法是:找到第一个与起点不重合的点作为p1,然后继续向后扫描,凡是与p0、p1共线的点都跳过,但在跳过过程中,始终保留离p0最远的那个点作为p1。这样即使前几个点都在同一条边上,也不会漏掉这条边的端点。

如果整个多边形所有点共线,直接返回p0和离p0最远的点作为结果——这就是一条线段的凸包。

完整初始化代码如下:

int i = 1; while (i < n && poly[i] == poly[0]) ++i; if (i == n) return {poly[0]}; Point p0 = poly[0], p1 = poly[i]; int j = i + 1; while (j < n && cross(p0, p1, poly[j]) == 0) { if (dist2(p0, poly[j]) > dist2(p0, p1)) p1 = poly[j]; ++j; } if (j == n) return {p0, p1}; Point p2 = poly[j]; if (cross(p0, p1, p2) < 0) swap(p1, p2);

这里dist2计算的是两点距离的平方,用整数运算避免精度问题。

初始化deque时,注意构建方式:

deque<Point> d; d.push_back(p0); d.push_back(p1); d.push_back(p2); d.push_front(p2);

得到的结果就是d = [p2, p0, p1, p2],front和back重合,中间按逆时针方向排列。

3.3 主循环:五个点的代码解决全部问题

主循环非常短,核心逻辑只有几行:

for (int k = j + 1; k < n; ++k) { while (cross(d[0], d[1], poly[k]) <= 0) { d.pop_front(); } while (cross(d[d.size() - 2], d[d.size() - 1], poly[k]) <= 0) { d.pop_back(); } d.push_front(poly[k]); d.push_back(poly[k]); }

第一个while处理头部弹出,第二个while处理尾部弹出。两个循环都弹出之后,再把新顶点同时插入到两端。注意这里用的是<= 0,表示新顶点在边的右侧或共线时都会弹出旧顶点,这样最终凸包只保留真正的角点,共线的中间顶点会被去掉。

如果你希望保留凸包边上的所有共线点(即包含边上的中间点),可以把<= 0改成< 0。两种需求都有人遇到,我建议默认用<= 0,因为绝大多数碰撞检测和轮廓分析只需要角点。

最后输出时,由于deque的front和back是同一个点,直接删除末尾元素:

vector<Point> hull(d.begin(), d.end()); hull.pop_back(); return hull;

3.4 完整可运行代码示例

把上面几段合在一起,就是一个可以直接跑通的完整函数:

#include <iostream> #include <vector> #include <deque> #include <algorithm> using namespace std; struct Point { long long x, y; bool operator==(const Point& other) const { return x == other.x && y == other.y; } }; long long cross(const Point& a, const Point& b, const Point& c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); } long long dist2(const Point& a, const Point& b) { long long dx = a.x - b.x; long long dy = a.y - b.y; return dx * dx + dy * dy; } vector<Point> melkman(const vector<Point>& poly) { int n = poly.size(); if (n <= 3) return poly; int i = 1; while (i < n && poly[i] == poly[0]) ++i; if (i == n) return {poly[0]}; Point p0 = poly[0], p1 = poly[i]; int j = i + 1; while (j < n && cross(p0, p1, poly[j]) == 0) { if (dist2(p0, poly[j]) > dist2(p0, p1)) p1 = poly[j]; ++j; } if (j == n) return {p0, p1}; Point p2 = poly[j]; if (cross(p0, p1, p2) < 0) swap(p1, p2); deque<Point> d; d.push_back(p0); d.push_back(p1); d.push_back(p2); d.push_front(p2); for (int k = j + 1; k < n; ++k) { while (cross(d[0], d[1], poly[k]) <= 0) { d.pop_front(); } while (cross(d[d.size() - 2], d[d.size() - 1], poly[k]) <= 0) { d.pop_back(); } d.push_front(poly[k]); d.push_back(poly[k]); } vector<Point> hull(d.begin(), d.end()); hull.pop_back(); return hull; } int main() { vector<Point> poly = {{0,0}, {4,0}, {2,2}, {4,4}, {0,4}}; vector<Point> hull = melkman(poly); for (auto& p : hull) { cout << p.x << " " << p.y << "\n"; } return 0; }

用这个凹多边形测试:顶点依次是(0,0)、(4,0)、(2,2)、(4,4)、(0,4),输出结果是(0,4)、(0,0)、(4,0)、(4,4),即整个多边形的最小凸包,内部凹点(2,2)被正确剔除。

4. 避坑经验:这些细节不注意一定会翻车

4.1 共线点处理的两难

共线点是Melkman实现里最容易出问题的地方。第一段共线如果直接跳过不处理,可能会丢凸包端点;但如果过度处理,又可能引入非必要的顶点。

我的建议是只在初始化阶段做共线预处理,主循环中不用额外关心共线点,因为<= 0的判断会自然把凸包边中间的点去掉。需要特别注意的是,<= 0会把所有共线点全部弹出,也就是说如果多边形有一条很长的直边,中间那些顶点不会出现在结果里。这也是正常的,除非你的交付要求里明确写着“保留边上顶点”。

4.2 循环弹出的顺序和“保底”判断

有的教程代码会先处理尾部再处理头部,理论上都能得到正确结果,但我在调试时发现,先处理头部再处理尾部的顺序更直观,因为人可以顺着deque的链方向理解:头部是链的起始,尾部是链的终结。

还有一个细节:主循环中弹出可能让deque暂时失去“首尾重合”的状态。比如d从[p2, p0, p1, p2]开始,头部弹出p2后变成[p0, p1, p2],front和back不重合。这没有问题,因为紧接着的尾部判断和最后的双端插入会把闭合状态恢复。很多初学者在这里犹豫,以为deque状态已经坏了,其实这是算法正常工作的中间过程。

4.3 整数溢出与坐标范围

叉积计算里两个坐标差相乘,如果坐标范围是1e9,叉积结果可能到1e18。用int必炸,用long long也要注意上限。坐标范围再大(比如1e18级别)就需要上高精度或改成浮点数,但那已经是另一个话题了。

实际项目中如果坐标来自经纬度或者屏幕像素,long long完全够用。我习惯在结构体定义里直接写long long,杜绝后续改类型带来的连锁修改。

4.4 输入不是简单多边形时结果会怎样

Melkman算法对输入多边形的自交非常敏感。如果顶点序列对应的多边形有自交边,算法的凸性维护逻辑会被破坏,输出结果可能是错误的,而且不会报错。你只会得到一份看似正常的凸包,但某些顶点明显说不过去。

所以接入这个算法前,务必确认你的输入来源。图像轮廓一般是简单多边形,但某些GIS数据或者手工标注的围栏数据可能会出现自交,排查方法可以单独写一篇文章,我目前的做法是在调试期加一个自交检测开关,跑出异常结果时提前暴露问题。

4.5 输出方向和起始点

由于初始化时强制改成逆时针,最终凸包也是逆时针方向。但起始点不是固定的,它取决于最后一个被处理的顶点。如果你要求凸包从某个指定顶点开始输出,可以对结果做一次旋转。这种情况在GIS里比较常见,比如要求首点等于原始轮廓的第一个点。

5. 常见问题速查表

现象原因解决办法
结果里出现凹点初始化方向不对检查前三点是否统一为逆时针
凸包端点丢失前段共线点被直接跳过按3.2节的逻辑保留最远点
所有点共线时程序崩溃没有处理退化情况返回线段两端点
凸包边上多个实心点<= 0改成< 0确认需求再选择保留行为
输出首点不一致起始顶点由最后处理点决定按需旋转结果数组
自交多边形结果异常输入不是简单多边形预处理检测自交或换算法
运行到一半deque只剩两个点一般是退化输入检查是否存在重复点或全共线区域
坐标范围大导致WA叉积溢出改用long long或高精度

这些坑我几乎都踩过一遍。第一次用的时候,输出结果里多了一个内部点,排查了很久,最后发现是初始三角形方向没有统一,头部边和尾部边的判断标准不一致导致的。后来我形成一个习惯:所有几何模板里,叉积函数和方向判断永远单独封装,并且在初始化阶段显式统一逆时针方向,这样无论输入是顺时针还是逆时针都不会出错。

结语与个人体会

这个小众算法最打动我的一点,是它把“输入有序”这个信息利用到了极致。很多通用算法为了适配最差情况,牺牲了输入中本来就存在的顺序结构,而Melkman恰恰是那种“认清问题边界,再把优势榨干”的典型。

我个人的建议是:如果你的轮廓来源稳定、确定是简单多边形,直接优先考虑Melkman,代码量不比其他算法多,运行效率却实打实高一个档次。如果只是偶尔处理散点集,那就继续用Andrew单调链。最怕的情况是输入类型不稳定,混合场景下强行用Melkman,反而要处理各种退化分支。

最后补一个小技巧:在主循环里临时打印deque的当前状态,每处理一个点输出一次链表内容,你会非常直观地看到“橡皮筋崩紧、旧点弹出、新点插入”的整个过程。我第一次调通之后把这个可视化循环跑了一遍,很多疑问瞬间就解开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询