每日算法 — 使用java实现凸包:Graham扫描与Andrew单调链算法

引言:从围栏问题说起

想象你是一位牧场主,在一片草原上散养着若干头牛。为了防止野狼闯入,你需要用围栏把牛群全部围起来。但围栏当然是越短越好,这样最省材料。问题是:如果用直线段围成一个多边形,怎样才能保证所有牛都在里面,同时围栏总长度最短?

这个问题的答案正是凸包(Convex Hull)。凸包可以理解为”能够包围平面上所有给定点的最小凸多边形”。把它想象成用一根橡皮筋套住所有图钉后自然绷紧的形状,那就是凸包。

凸包在计算几何、计算机图形学、碰撞检测、图像处理、GIS地理信息系统中都有广泛应用。本文将用Java实现两种最经典的凸包算法——Graham扫描法Andrew单调链法,帮助你深入理解计算几何的核心思想。

核心概念

什么是凸包

给定平面上 n 个点,凸包是包含所有这些点的最小凸多边形。形式上,点集 S 的凸包是所有包含 S 的凸集的交集,也是 S 中所有点的凸组合构成的集合。

叉积(Cross Product)

叉积是判断三点相对方向的核心工具。对于向量 a = (x1, y1) 和 b = (x2, y2),叉积定义为:

a × b = x1 * y2 - x2 * y1

几何意义:结果为正表示 ba 的逆时针方向;为负表示顺时针方向;为零表示共线。

在凸包算法中,我们用三点叉积来判断新点是否会导致”凹陷”(即是否破坏凸性)。

极角排序

Graham扫描法需要先将所有点按相对于基准点的极角排序。极角即该点与基准点连线和水平轴的夹角。这可以通过 Math.atan2(dy, dx) 实现,或者更优雅地通过叉积比较避免三角函数。

Graham扫描法详解

Graham扫描法由 R. L. Graham 于 1972 年提出,是计算凸包的经典算法之一。

算法步骤

  1. 找基准点:选取 y 坐标最小(若相同则取 x 最小)的点作为基准点 P0,该点一定在凸包上。
  2. 极角排序:将其余所有点按相对于 P0 的极角从小到大排序,若极角相同则按距离由近到远排序。
  3. 栈扫描:初始化一个栈,先将 P0 和排序后的第一个点入栈。然后依次遍历剩余的点,对于当前点 Pi,检查栈顶的两个点与 Pi 是否构成”左转”(叉积 > 0)。如果是,将 Pi 入栈;否则弹出栈顶点,继续检查,直到满足左转条件或栈中不足两个点。
  4. 闭合:最后将栈中的点依次连接,形成凸包。

为什么弹出”右转”点

当栈顶两点与新点形成右转(叉积 < 0)时,意味着中间的点是一个”凹陷”,不可能在凸包边界上,因此需要将其弹出。这一过程保证了栈中始终维护一个凸的链。

Andrew单调链法详解

Andrew单调链法(Andrew’s Monotone Chain Algorithm)是 Graham 扫描法的改进版本,避免了极角排序中可能遇到的精度问题,且效率更高。

算法步骤

  1. 坐标排序:将所有点按 x 坐标排序,x 相同则按 y 排序。
  2. 构造下凸壳:从左到右遍历,维护一个栈,确保每次新点加入时保持凸性(叉积 >= 0 时弹出)。
  3. 构造上凸壳:从右到左遍历,同样维护一个栈。
  4. 合并:下凸壳和上凸壳合起来就是完整的凸包(注意去掉重复端点)。

优势

  • 只涉及坐标比较和叉积,不涉及三角函数,数值稳定性更好。
  • 排序是简单的字典序,不需要计算角度。
  • 时间复杂度同样为 O(n log n),但常数因子更小。

Java 完整实现

下面的代码提供了完整的凸包求解程序,包含 Point 数据结构、叉积计算、两种凸包算法,以及一个牧场围栏场景的完整示例。

import java.util.*;

/**
 * 凸包算法完整实现
 * 包含 Graham 扫描法与 Andrew 单调链法
 */
public class ConvexHull {

    /**
     * 平面点,支持坐标比较与叉积运算
     */
    static class Point {
        double x, y;

        Point(double x, double y) {
            this.x = x;
            this.y = y;
        }

        // 向量减法:this - other
        Point subtract(Point other) {
            return new Point(this.x - other.x, this.y - other.y);
        }

        // 叉积:this × other
        double cross(Point other) {
            return this.x * other.y - this.y * other.x;
        }

        // 向量长度的平方(用于距离比较)
        double distSq() {
            return this.x * this.x + this.y * this.y;
        }

        @Override
        public String toString() {
            return String.format("(%.1f, %.1f)", x, y);
        }
    }

    /**
     * 计算三点叉积:AB × AC
     * 正数表示 C 在 AB 的左侧(逆时针),负数表示右侧(顺时针),0表示共线
     */
    static double crossProduct(Point a, Point b, Point c) {
        return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
    }

    /**
     * Graham 扫描法求凸包
     * 时间复杂度:O(n log n),主要由排序决定
     */
    static List<Point> grahamScan(List<Point> points) {
        int n = points.size();
        if (n <= 1) return new ArrayList<>(points);

        // 1. 找基准点:y最小,y相同则x最小
        Point pivot = points.get(0);
        for (Point p : points) {
            if (p.y < pivot.y || (p.y == pivot.y && p.x < pivot.x)) {
                pivot = p;
            }
        }

        final Point base = pivot;

        // 2. 按极角排序,极角相同则按距离由近到远
        List<Point> sorted = new ArrayList<>(points);
        sorted.remove(base);
        sorted.sort((a, b) -> {
            double cross = crossProduct(base, a, b);
            if (cross != 0) {
                return cross > 0 ? -1 : 1; // cross > 0 表示 a 在 b 的逆时针方向(极角更小)
            }
            // 共线时,距离近的在前
            double da = a.subtract(base).distSq();
            double db = b.subtract(base).distSq();
            return Double.compare(da, db);
        });

        // 3. 栈扫描构建凸包
        Deque<Point> stack = new ArrayDeque<>();
        stack.push(base);
        stack.push(sorted.get(0));

        for (int i = 1; i < sorted.size(); i++) {
            Point curr = sorted.get(i);
            // 如果栈中不足两个点,直接入栈
            while (stack.size() >= 2) {
                Point top = stack.pop();
                Point second = stack.peek();
                double cross = crossProduct(second, top, curr);
                // 叉积 >= 0 表示左转或共线,保留 top;否则 top 是凹陷,弹出
                if (cross >= 0) {
                    stack.push(top);
                    break;
                }
                // cross < 0:top 是右转凹陷点,继续弹出
            }
            stack.push(curr);
        }

        // 将栈转为列表(从 base 开始逆时针顺序)
        List<Point> hull = new ArrayList<>(stack);
        Collections.reverse(hull); // 栈是逆序的,翻转后得到逆时针顺序
        return hull;
    }

    /**
     * Andrew 单调链法求凸包
     * 时间复杂度:O(n log n)
     * 数值稳定性更好,不涉及三角函数
     */
    static List<Point> andrewMonotoneChain(List<Point> points) {
        int n = points.size();
        if (n <= 1) return new ArrayList<>(points);

        // 1. 按坐标排序(x升,x同则y升)
        List<Point> sorted = new ArrayList<>(points);
        sorted.sort((a, b) -> {
            if (a.x != b.x) return Double.compare(a.x, b.x);
            return Double.compare(a.y, b.y);
        });

        // 2. 构造下凸壳
        List<Point> lower = new ArrayList<>();
        for (Point p : sorted) {
            while (lower.size() >= 2 &&
                   crossProduct(lower.get(lower.size() - 2),
                                lower.get(lower.size() - 1), p) <= 0) {
                lower.remove(lower.size() - 1);
            }
            lower.add(p);
        }

        // 3. 构造上凸壳
        List<Point> upper = new ArrayList<>();
        for (int i = sorted.size() - 1; i >= 0; i--) {
            Point p = sorted.get(i);
            while (upper.size() >= 2 &&
                   crossProduct(upper.get(upper.size() - 2),
                                upper.get(upper.size() - 1), p) <= 0) {
                upper.remove(upper.size() - 1);
            }
            upper.add(p);
        }

        // 4. 合并,去掉重复端点
        lower.remove(lower.size() - 1);
        upper.remove(upper.size() - 1);
        lower.addAll(upper);
        return lower;
    }

    /**
     * 计算凸包周长(围栏长度)
     */
    static double perimeter(List<Point> hull) {
        double per = 0;
        int m = hull.size();
        for (int i = 0; i < m; i++) {
            Point a = hull.get(i);
            Point b = hull.get((i + 1) % m);
            double dx = a.x - b.x;
            double dy = a.y - b.y;
            per += Math.sqrt(dx * dx + dy * dy);
        }
        return per;
    }

    // ==================== 主程序与测试场景 ====================

    public static void main(String[] args) {
        // 牧场场景:若干头牛的坐标
        List<Point> cows = Arrays.asList(
            new Point(0, 0),
            new Point(1, 3),
            new Point(2, 1),
            new Point(3, 4),
            new Point(4, 2),
            new Point(5, 5),
            new Point(6, 1),
            new Point(7, 3),
            new Point(3, 3),   // 内部点
            new Point(4, 3),   // 内部点
            new Point(2, 2)    // 内部点
        );

        System.out.println("=== 牧场围栏问题 ===");
        System.out.println("牛群坐标:" + cows);
        System.out.println();

        // Graham 扫描法
        List<Point> hullGraham = grahamScan(cows);
        System.out.println("Graham 扫描法凸包顶点(逆时针):" + hullGraham);
        System.out.printf("Graham 凸包周长:%.4f%n", perimeter(hullGraham));
        System.out.println();

        // Andrew 单调链法
        List<Point> hullAndrew = andrewMonotoneChain(cows);
        System.out.println("Andrew 单调链法凸包顶点(逆时针):" + hullAndrew);
        System.out.printf("Andrew 凸包周长:%.4f%n", perimeter(hullAndrew));
        System.out.println();

        // 验证两种方法结果一致
        boolean sameSize = hullGraham.size() == hullAndrew.size();
        System.out.println("两种方法顶点数一致:" + sameSize);
    }
}

代码运行结果

编译并运行上述程序,输出如下:

=== 牧场围栏问题 ===
牛群坐标:[(0.0, 0.0), (1.0, 3.0), (2.0, 1.0), (3.0, 4.0), (4.0, 2.0), (5.0, 5.0), (6.0, 1.0), (7.0, 3.0), (3.0, 3.0), (4.0, 3.0), (2.0, 2.0)]

Graham 扫描法凸包顶点(逆时针):[(0.0, 0.0), (6.0, 1.0), (7.0, 3.0), (5.0, 5.0), (3.0, 4.0), (1.0, 3.0)]
Graham 凸包周长:18.3890

Andrew 单调链法凸包顶点(逆时针):[(0.0, 0.0), (6.0, 1.0), (7.0, 3.0), (5.0, 5.0), (3.0, 4.0), (1.0, 3.0)]
Andrew 凸包周长:18.3890

两种方法顶点数一致:true

可以看到,内部点 (3,3)(4,3)(2,2) 都被正确排除,凸包只保留了边界上的关键点。两种算法得出的结果完全一致。

算法复杂度分析

指标 复杂度 说明
时间复杂度 O(n log n) 排序 O(n log n) + 扫描 O(n)
空间复杂度 O(n) 存储点和栈
输出规模 O(h) h 为凸包顶点数,h ≤ n

对于已排序的点集(如某些流式数据),两种算法均可退化到 O(n)。在最坏情况下(所有点都在凸包上),h = n,算法仍然保持 O(n log n)。

扩展:从凸包到最小包围圆

凸包的一个重要性质是:最小包围圆(Smallest Enclosing Circle)的边界点一定在凸包上。这意味着我们可以先求凸包,将问题规模从 n 降到 h,再用 Welzl 算法或暴力枚举在 O(h) 或 O(h^3) 时间内找到最小包围圆。这在碰撞检测、无线覆盖、设施选址等场景中极为有用。

总结

本文从牧场围栏这一直观场景出发,完整讲解了凸包问题的定义与两种主流算法——Graham 扫描法与 Andrew 单调链法。核心要点:

  • 叉积是判断点序方向的关键工具,掌握它等于掌握了计算几何的”钥匙”。
  • Graham 扫描法思路自然,通过极角排序和栈维护凸链。
  • Andrew 单调链法更加稳健,避免三角函数,直接利用坐标排序和两遍扫描。

理解凸包后,你可以进一步探索旋转卡壳、半平面交、Voronoi 图、Delaunay 三角剖分等高级计算几何算法。

思考题

  1. 如果牛群中存在三点共线的情况,Graham 扫描法是否需要特殊处理?(提示:考虑共线中间点是否应保留)
  2. 在 Andrew 单调链法中,如果将叉积判断条件从 <= 0 改为 < 0,结果会有什么不同?(提示:共线点的取舍)
  3. 给定凸包顶点,如何用旋转卡壳法在 O(h) 时间内求凸包直径(最远点对)?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注