[计算几何] 2 二维凸包/笨蛋(我)也能看懂的二维凸包算法
二维凸包,这篇博客已经说得够好了,介绍了斜率逼近法、Jarvis算法,Graham算法,还有Andrew算法。我这篇博客只会非常详细的介绍Andrew算法。
数论小白都能看懂的平面凸包详解 - ShineEternal的笔记小屋 - 洛谷博客 (luogu.com.cn)
我相信凭借着我6个粉丝其中5个都是老熟人的传播量,应该不会因为乱贴别人链接导致啥问题()。也许会有朋友要问了,人家都写的这么好了,你博客的创新点在哪里呢?(谁问你了)我主要解决的是这三个问题:
- 关于三点共线等等的特殊情况,网上的有些代码会被hack掉,我在这里给出一个相对比较靠谱的代码。
- 每个人对Andrew算法的理解可能都有点点不一样,也许我的博客会更适合你。
- 我会尽量探讨,在不同的情况中,板子应该怎么做一些小改动。
那我们开始吧。
Andrew算法
算法思想
要对一个点集求凸包,就是用一个凸多边形把这些点集全部围起来,这些点最多只能在凸多边形上,不能超出这个凸多边形,并且这个凸多边形是所有可以满足上述条件的凸多边形中,面积、周长均是最小的那一个。并且,这样的一个凸多边形是唯一的。我们从感性上就可以感觉到,这个凸多边形的顶点一定是从点集中选取的。

假如黑色的点是原本的点集,点P是形成的凸包中的某一个顶点。P不属于初始,并且凸包把所有的点围了起来。那么,一定存在初始点集中两个点,把它们相连之后,围成的凸多边形仍然是凸包,并且面积比原来的凸包更小。有了这样一个认知,我们要做的事情就变成了:从点集中选出若干点构成凸包。也可以说,这些点肯定是最“外面”的凸包框住的,对吧?
首先,我们把所有点从左到右的排个序,找到点集的最左边的点A和最右边的点B,点A和点B肯定是最“外面”的点,也就是说,肯定是构成凸包的点。我们想象现在有一块柔软的布从上到下垂下来,盖住了这些点,形成了凸包的上边界:

这个上边界很明显,起始于A,终于B。我们先不想一次找完整个凸包,我们先找上边界。
把点按照这个方法排序,可以得到
bool operator < (Point& B) {
if (!sgn(x - B.x)) return y < B.y;
return x < B.x;
}

初始点集,然后,A先试探性的和相连,然后把插入点集U。嗯,很显然就是的上边界。再看,如果把相连,那就是这三个点的上边界。

现在压力来到,把相连后,情况开始不对了,为了维护美丽的上边界形状,我们删除,然后把加进来,现在。

重复上述过程,最终我们得到了





纵观整个寻找上边界的过程,其实可以描述为:按照排序从小到大依次遍历节点,当遍历到第个节点时,看节点是否当前的上边界的末尾两点构成的向量的右侧,如果是,则将点加入,否则,从中不断弹出最后一个节点,直到满足条件或者弹到没点可弹为止。
维护下边界,就是反着再来一遍:按照排序从大到小依次遍历节点,当遍历到第个节点时,……(不要指望着我再告诉你怎么做了!自己推一下)就像这样:








嗯嗯,最后我们可以得到,
合起来就是凸包:

代码思路
问题已经解决了,现在我们只要把我们的思想用代码表示出来即可。

回过头来看凸包的形成过程,我们为什么能判断是新的上边界,而把弹出了呢?通过仔细的观察,我们发现是因为这个点在向量的左侧。如果在维护上边界的时候,点在向量左侧,那就说明在“更上面”,不是吗?维护下边界也是一样:如果当前的点在下边界末尾两点构成向量的右侧,就把当前点加入下边界;如果当前的点在向量左侧,就把下边界末尾的点弹出,直到没点可弹或满足点在向量右侧为止。

判断点和向量(其实这里是有方向的直线)的位置关系,如果你已经看过我的[计算几何] 1 二维基础运算与概念 - 跳岩 - 博客园 (cnblogs.com)的话,应该可以已经非常轻松的写出来代码了罢()
// 判断直线a和点b的关系
// 1: a在直线左边; 0: a在直线上; -1: a在直线右边
int relation(Line a, Point b){
return sgn(cross(a.v, b - a.p));
}
代码实现
[P2742 USACO5.1] 圈奶牛Fencing the Cows /【模板】二维凸包 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

现在我们就以这道题为例,看看怎么做一个二维凸包。
andrewScan的伪代码如下,真的代码其实就多了一点点边界的判断。注意看,我们给点排序的时间是,找上下边界的时间是,故总的时间复杂度为。
Polygon andrewScan(Polygon p){
sort(p.begin(), p.end()); // 给点排序
Polygon u, d; // u: 上边界; d: 下边界
u.push_back(p[0], p[1]); // 把最左边的两个点push到上边界
d.push_back(p[end], p[end - 1]); // 把最右边的两个点push到下边界
// 找上边界
for (int i = 2; i < p.size(); ++i){
while (u.size() >= 2 && p[i]不在u末尾向量的右边) 弹出u末尾元素;
u.push_back(p[i]);
}
// 找下边界
for (int i = end - 2; i >= 0; --i){
while (d.size() >= 2 && p[i]不在u末尾向量的右边) 弹出d末尾元素;
d.push_back(p[i]);
}
// 现在找到了上下边界, 我们把结果合并起来
// 本人喜欢逆时针存储
result = merge(d, u, anticlockwise);
return result;
}
AC的代码如下:
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define ZERO 1e-8
#define xx first
#define yy second
#define RIGHT -1
#define ON 0
#define LEFT 1
int sgn(double x){
if (fabs(x) < ZERO) return 0;
return x > 0 ? 1 : -1;
}
struct Point{
double x, y;
Point (double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator + (Point& B) { return Point(x + B.x, y + B.y); }
Point operator - (Point& B) { return Point(x - B.x, y - B.y); }
bool operator < (Point& B) {
if (!sgn(x - B.x)) return y < B.y;
return x < B.x;
}
friend ostream& operator <<(ostream& out, Point& p) { out << "(" << p.x << ", " << p.y << ")"; return out; }
friend void operator >>(istream& in, Point& p) { in >> p.x >> p.y; }
};
typedef Point Vector;
typedef pair<Point, Point> Line;
typedef vector<Point> Polygon;
double cross(Vector v1, Vector v2){
return v1.x * v2.y - v1.y * v2.x;
}
int relation(Line l, Point p){
return sgn(cross(l.yy - l.xx, p - l.xx));
}
double get_dist(Point p1, Point p2){
return sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));
}
Polygon andrewScan(Polygon p){
Polygon u, d;
// 如果p的点少于3, 可以直接返回了
if (p.size() < 3) return p;
sort(p.begin(), p.end());
u.push_back(p[0]), u.push_back(p[1]);
d.push_back(p.back()), d.push_back(p[p.size() - 2]);
for (int i = 2; i < p.size(); ++i){
int k = u.size();
while (k >= 2 && relation(Line(u[k - 2], u[k - 1]), p[i]) != RIGHT){
k--; u.pop_back();
}
u.push_back(p[i]);
}
for (int i = p.size() - 3; i >= 0; --i){
int k = d.size();
while (k >= 2 && relation(Line(d[k - 2], d[k - 1]), p[i]) != RIGHT){
k--; d.pop_back();
}
d.push_back(p[i]);
}
// 这里就是做了逆时针合并, 记得u.st=A, u.ed=B, d.st=B, d.ed=A
// 所以合并的时候还要注意不要重复存储两遍A和B
reverse(d.begin(), d.end());
for (int i = u.size() - 2; i > 0; --i){
d.push_back(u[i]);
}
return d;
}
int n;
const int N = 1e5 + 5;
int main(void){
cin >> n;
Polygon pg;
Point p;
for (int i = 0; i < n; ++i){
cin >> p.x >> p.y;
pg.push_back(p);
}
Polygon res = andrewScan(pg);
double dist = 0;
for (int i = 0; i < res.size(); ++i){
dist += get_dist(res[i], res[(i + 1) % res.size()]);
}
cout << fixed << setprecision(2) << dist << endl;
return 0;
}
小小的改动
- 注意这段代码:
for (int i = 2; i < p.size(); ++i) {
int k = u.size();
while (k >= 2 && relation(Line(u[k - 2], u[k - 1]), p[i]) != RIGHT) {
k--; u.pop_back();
}
u.push_back(p[i]);
}
我们的代码中,认为如果当前点不在向量的右边,就要把上边界末尾的点弹出。也就是说,如果三点共线,我们最终会把弹出,就像下面这样:

如果我们遇到需要保留的情况,那只要把!=RIGHT
改为== LEFT
就好啦。
for (int i = 2; i < p.size(); ++i) {
int k = u.size();
while (k >= 2 && relation(Line(u[k - 2], u[k - 1]), p[i]) == LEFT) {
k--; u.pop_back();
}
u.push_back(p[i]);
}
- 而且,我们的代码也是经得起一些hack的:
if (p.size() < 3) : 返回的是p;
if (p中所有点都是三点共线): 返回p的左端点和右端点;
好!水完了!有什么表达不清的地方请联系我哦!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通