【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²)

分治法

Jarvis步进法

参考

https://en.wikipedia.org/wiki/Gift_wrapping_algorithm

posted @ 2024-07-15 20:06  JimmyZou  阅读(26)  评论(0编辑  收藏  举报