【Unity】凸包算法对比及实现
背景
在做闵可夫斯基差的可视化的时候,获得了很多个点,想要知道其是否包含原点,需要连接一个包裹这些点的最小凸多边形。因此就单独研究了这个部分,实现了功能并进行分析对比。凸包算法可以在多个散落的点中找到最小能包裹它的外壳,像套上一个橡皮筋一样。这里主要采用Graham算法进行代码实现,其余算法进行分析比对。
凸多边形判定
定义/判定方法
定义1:凸多边形是指如果一个多边形的所有边中,任意一条边向两方无限延长成为一直线时,其他各边都在此直线的同旁,那么这个多边形就叫做凸多边形。
定义2:所有的内角都是小于等于180°的角的多边形就是凸多边形。
另外,也可以从凹多边形的角度去进行判定。只要不是凹多边形,就一定是凸多边形。
★ 凹多边形定理: 凹多边形必然同时拥有大于180°和小于180°的内角。
实现
这里采用凹多边形定理进行实现,因为只需要找到两个不相同的内角,一个大于180°,一个小于180°就可以了,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace JimDevPack.Geometry
{
public enum AngleOrientation
{
Collinear, // 共线
Clockwise, // 顺时针
Counterclockwise // 逆时针
}
public static class ConvexHullHelper
{
public static bool IsConvex(List<Vector2> points)
{
// 点的数量必须大于等于3
if (points.Count < 3)
{
return false;
}
int n = points.Count;
AngleOrientation prevOrientation = AngleOrientation.Collinear;
for (int i = 0; i < n; i++)
{
// 计算当前点、下一个点和下下个点构成的角的旋转方向
AngleOrientation orientation = GetOrientation(points[i], points[(i + 1) % n], points[(i + 2) % n]);
// 如果三个点共线,跳过当前循环,不用记录到prevOrientation中
if (orientation == AngleOrientation.Collinear)
continue;
// 如果之前的方向是共线,更新之前的方向为当前方向
if (prevOrientation == AngleOrientation.Collinear)
prevOrientation = orientation;
// 如果之前的方向和当前方向不一致,返回false
else if (prevOrientation != orientation)
return false;
}
return true;
}
/// <summary>
/// 计算三个点形成的夹角的类型
/// a-->b-->c</summary>
private static AngleOrientation GetOrientation(Vector2 a, Vector2 b, Vector2 c)
{
Vector2 ab = b - a;
Vector2 bc = c - b;
float val = ab.x * bc.y - ab.y * bc.x;
if (val == 0)
return AngleOrientation.Collinear; // 共线
else if (val > 0)
return AngleOrientation.Counterclockwise; // 逆时针
else
return AngleOrientation.Clockwise; // 瞬时针时针
}
}
}
效果
红色情况表示该多边形被检测为凹多边形,绿色表示为凸多边形,效果符合预期。
凸包算法分类
Graham扫描法
思路
(1)选择基点:从点集中找到一个边界点(例如最下方)作为基点,这个点一定是凸包上的点,它将作为后续计算中的基准。
(2)极角排序:然后,对点集中的其他点根据它们相对于基点的极角进行排序(可通过计算点与基点连线的斜率),如果两点的极角相等,则按距离从近到远排序。
(3)构建凸包:使用一个栈来维护这个凸包。遍历排序后的点,对于即将准备加入的点,判断加入后是否会让前一个已经入栈的点出现凹陷的形状,如果会的话,则从凸包中删除这个点,往前再判断,直到不构成凹陷的形状,就把准备加入的点加进去。
整个过程可以参考这张图:
代码实现
/// <summary>
/// 基于Graham的凸包计算方法
/// </summary>
/// <param name="points">分散的点集</param>
/// <returns>包围points的最小凸包的点集</returns>
public static List<Vector2> CalculateConvexHullByGraham(List<Vector2> points)
{
if (points.Count < 3)
{
Debug.LogError("点的数量必须大于等于3");
return null;
}
// 找到最下面的点作为初始基点,如果有多个点具有相同的最小 y 坐标,则选择最左边的点
Vector2 startPoint = points[0];
for (int i = 1; i < points.Count; i++)
{
if (points[i].y < startPoint.y || (points[i].y == startPoint.y && points[i].x < startPoint.x))
{
startPoint = points[i];
}
}
// 将起始点移动到点集的第一个位置
points.Remove(startPoint);
points.Insert(0, startPoint);
List<Vector2> sortedPoints = new List<Vector2>(points);
// 根据起始点到其他点的极角进行排序
sortedPoints.Sort((a, b) =>
{
float angleA = GetPolarAngle(startPoint, a);
float angleB = GetPolarAngle(startPoint, b);
// 比较极角
if (angleA < angleB)
return -1;
if (angleA > angleB)
return 1;
// 极角相同则按距离近的来
if (angleA == angleB)
{
float distA = Vector2.Distance(a, startPoint);
float distB = Vector2.Distance(b, startPoint);
if (distA < distB)
return -1;
if (distA > distB)
return 1;
}
return 0;
});
// 创建一个栈,用来存储凸包上的点
Stack<Vector2> hull = new Stack<Vector2>();
hull.Push(sortedPoints[0]);
hull.Push(sortedPoints[1]);
// Graham扫描算法
for (int i = 2; i < sortedPoints.Count; i++)
{
Vector2 top = hull.Pop();
// 如果当前点不在上一个点和栈顶点的左侧(逆时针侧),那么上一个点存在凹陷或是共线,将其从栈中移除
while (hull.Count != 0 && GetOrientation(hull.Peek(), top, sortedPoints[i]) != AngleOrientation.Counterclockwise)
{
// 删除凹陷点,继续进入下个循环往前判断
top = hull.Pop();
}
// 将上一个点和当前点都添加到栈中
hull.Push(top);
hull.Push(sortedPoints[i]);
}
// 返回包含凸包上所有点的列表
return hull.ToList();
}
/// <summary>
/// 计算point相对于origin的极角
/// </summary>
private static float GetPolarAngle(Vector2 origin, Vector2 point)
{
return Mathf.Atan2(point.y - origin.y, point.x - origin.x);
}
GetOrientation函数在上面的凸包判定部分给出了,这里就不再放出了。
效果
随机给定一些点,进行凸包构建测试,得到预期效果。
时间复杂度分析
Graham算法中的时间开销主要来源于:
(1)极角排序:Graham 方法需要对点集进行极角排序,以确保点按照逆时针方向排列。一般来说,目前最快的排序就是快速排序,它的速度是O(nlogn)。
(2)栈操作:主循环中,使用一个栈来保存凸包的点。对于每个点,我们可能需要弹出栈中的多个点,直到满足逆时针的条件。在最坏的情况下,每个点都可能被弹出和压入栈一次。因此,栈操作的时间复杂度为 O(n)。
结论:所以Graham算法的开销实际来源于排序过程,总体的时间复杂度也就是O(nlogn)。
Gift-Wrapping包裹算法
时间复杂度:O(n²)