土法炼钢兴趣小组的算法知识备份

凸包算法:Graham Scan、Andrew 与 Chan 的进化

目录

凸包(Convex Hull)是计算几何中最经典的问题之一。给定平面上的一组点,求能包含所有点的最小凸多边形——这个看似简单的问题,催生了近半个世纪的算法演化。从 1972 年 Graham 的极角排序扫描,到 1979 年 Andrew 的单调链方法,再到 1996 年 Chan 的 output-sensitive 最优算法,每一步都展现了算法设计思维的精妙之处。

本文将系统梳理这条进化路线,配合完整的 C 语言实现,深入分析每种算法的设计动机、正确性证明和工程陷阱。

一、凸包的定义与基本性质

1.1 什么是凸包

定义:给定平面上的点集 S = {p1, p2, …, pn},S 的凸包 CH(S) 是包含 S 中所有点的最小凸集。等价地,它是包含所有点的面积最小的凸多边形。

直观理解:想象在木板上钉一组钉子,然后用橡皮筋套住所有钉子——橡皮筋收紧后的形状就是凸包。

数学定义:CH(S) = {sum(λi * pi) | λi >= 0, sum(λi) = 1},即所有点的凸组合的集合。

1.2 凸包的基本性质

性质一:凸包的顶点一定是原点集的子集。即 CH(S) 的每个顶点都属于 S。

性质二:凸包顶点数 h 满足 3 <= h <= n(退化情况除外)。当 n 个点的分布”随机”时,h 的期望值远小于 n。

性质三(Euler 公式的二维特化):凸包是一个简单多边形,顶点数等于边数。

性质四(下界):在基于比较的计算模型下,二维凸包的计算下界为 Omega(n log n)。这一下界可以通过归约排序问题来证明:给定 n 个实数 x1, x2, …, xn,构造点集 {(xi, xi^2)},这些点全部在抛物线上,其凸包顶点的顺序就是排序后的顺序。

性质五(output-sensitive 下界):如果凸包有 h 个顶点,计算下界为 Omega(n log h)。这意味着当 h 很小时,我们有机会比 O(n log n) 更快。

1.3 退化情况

在实际实现中,退化情况往往是 bug 的温床:

// 判断三点是否共线
// 返回值: 0 表示共线,正值表示逆时针,负值表示顺时针
long long cross(Point O, Point A, Point B) {
    return (long long)(A.x - O.x) * (B.y - O.y)
         - (long long)(A.y - O.y) * (B.x - O.x);
}

二、叉积:凸包算法的核心工具

2.1 叉积的定义

二维向量叉积(严格来说是伪叉积,或外积的 z 分量)定义为:

cross(u, v) = ux * vy - uy * vx

对于三个点 A、B、C:

cross(AB, AC) = (Bx - Ax)(Cy - Ay) - (By - Ay)(Cx - Ax)

2.2 叉积的几何意义

叉积的绝对值等于以 AB 和 AC 为邻边的平行四边形面积,其符号反映了有向面积的方向。

2.3 叉积在凸包中的角色

凸包的核心判断就是”转向测试”:沿凸包边界行走时,每一步都应该向同一个方向转弯。对于逆时针排列的凸包,每次转弯都是左转,即叉积始终为正。

这就是所有基于栈的凸包算法的核心不变量:栈中相邻三点始终构成左转关系。

typedef struct {
    int x, y;
} Point;

// 转向判断
// 返回正值: 左转(逆时针)
// 返回负值: 右转(顺时针)
// 返回零: 共线
long long orientation(Point p, Point q, Point r) {
    return (long long)(q.x - p.x) * (r.y - p.y)
         - (long long)(q.y - p.y) * (r.x - p.x);
}

三、Graham Scan(1972)

3.1 算法思想

Graham Scan 是第一个 O(n log n) 的凸包算法,由 Ronald Graham 于 1972 年提出。算法的核心思想极为优雅:

  1. 选取一个一定在凸包上的”锚点”(通常取 y 坐标最小的点)。
  2. 将其余所有点按相对于锚点的极角排序。
  3. 按极角顺序依次遍历每个点,用一个栈维护”当前已确认的凸包部分”。
  4. 每加入一个新点时,检查栈顶的三个点是否构成左转:若不是,则弹出栈顶,反复检查直到满足左转条件。
Graham Scan 栈操作过程

3.2 极角排序

极角排序是 Graham Scan 的第一个关键步骤。给定锚点 P0,对于其他两个点 Pi 和 Pj,我们需要判断 Pi 的极角是否小于 Pj 的极角。

用叉积来实现极角比较,可以避免使用 atan2 函数(浮点不精确且速度慢):

Point pivot; // 全局锚点

int polar_cmp(const void *a, const void *b) {
    Point *pa = (Point *)a;
    Point *pb = (Point *)b;
    long long v = cross(pivot, *pa, *pb);
    if (v != 0) return (v > 0) ? -1 : 1;  // 极角小的排前面
    // 共线时按距离排序(近的排前面)
    long long da = (long long)(pa->x - pivot.x) * (pa->x - pivot.x)
                 + (long long)(pa->y - pivot.y) * (pa->y - pivot.y);
    long long db = (long long)(pb->x - pivot.x) * (pb->x - pivot.x)
                 + (long long)(pb->y - pivot.y) * (pb->y - pivot.y);
    return (da < db) ? -1 : (da > db) ? 1 : 0;
}

3.3 扫描过程

排序完成后,按顺序遍历每个点并维护栈:

// Graham Scan 主函数
// 返回凸包顶点数,结果存在 hull[] 中
int graham_scan(Point pts[], int n, Point hull[]) {
    if (n < 3) {
        for (int i = 0; i < n; i++) hull[i] = pts[i];
        return n;
    }

    // Step 1: 找到最低点(y 最小,相同取 x 最小)
    int lowest = 0;
    for (int i = 1; i < n; i++) {
        if (pts[i].y < pts[lowest].y ||
            (pts[i].y == pts[lowest].y && pts[i].x < pts[lowest].x)) {
            lowest = i;
        }
    }
    // 将最低点放到 pts[0]
    Point temp = pts[0];
    pts[0] = pts[lowest];
    pts[lowest] = temp;

    // Step 2: 极角排序
    pivot = pts[0];
    qsort(pts + 1, n - 1, sizeof(Point), polar_cmp);

    // 处理与最远点共线的情况
    // 最后一组共线的点应按距离从远到近排列
    int last = n - 1;
    while (last > 0 && cross(pts[0], pts[last], pts[last - 1]) == 0) {
        last--;
    }
    // 反转 [last+1, n-1] 区间
    for (int i = last + 1, j = n - 1; i < j; i++, j--) {
        temp = pts[i];
        pts[i] = pts[j];
        pts[j] = temp;
    }

    // Step 3: 栈扫描
    int top = 0;
    hull[top++] = pts[0];
    hull[top++] = pts[1];
    hull[top++] = pts[2];

    for (int i = 3; i < n; i++) {
        // 弹出不满足左转条件的栈顶
        while (top > 1 && cross(hull[top - 2], hull[top - 1], pts[i]) <= 0) {
            top--;
        }
        hull[top++] = pts[i];
    }

    return top;
}

3.4 正确性证明

不变量:在处理第 i 个点之前,栈中的点构成点集 {P0, P1, …, Pi-1} 的上凸包(沿极角方向的凸包部分)。

归纳证明: - 基础:栈中只有 P0,显然成立。 - 归纳步:假设处理 Pi 之前不变量成立。当 Pi 加入时: - 若栈顶两点与 Pi 构成左转,直接压入,不变量仍成立。 - 若构成右转或共线,弹出栈顶。弹出后重新检查,直到满足左转条件。弹出的点一定在新凸包的内部或边上,因为 Pi 的极角更大且形成了右转。

3.5 复杂度分析

空间复杂度 O(n),用于存储排序后的数组和栈。

3.6 Graham Scan 的陷阱

实践中 Graham Scan 最常见的错误来源:

  1. 共线点处理不当:当多个点与锚点共线时,排序规则必须特殊处理。特别是”最后一批”共线点的顺序需要反转,否则无法正确闭合凸包。

  2. 极角排序的分象限问题:使用 atan2 做极角排序会遇到浮点精度问题;纯叉积比较则需要正确处理零向量(点与锚点重合)的情况。

  3. 栈初始化:如果排序后头两个点就与锚点共线,栈的初始化可能出问题。

四、Andrew’s Monotone Chain(1979)

4.1 动机

Graham Scan 的极角排序需要选取锚点,且共线点的处理较为复杂。Andrew 在 1979 年提出了一种更简洁的方法:按坐标排序,分别构建上凸包和下凸包。

4.2 算法描述

  1. 将所有点按 (x, y) 字典序排序。
  2. 从左到右扫描,构建下凸包(lower hull)。
  3. 从右到左扫描,构建上凸包(upper hull)。
  4. 将上下凸包拼接。

排序用标准的字典序比较,不涉及极角,因此实现更简洁、数值更稳定。

4.3 完整实现

int cmp_xy(const void *a, const void *b) {
    Point *pa = (Point *)a;
    Point *pb = (Point *)b;
    if (pa->x != pb->x) return (pa->x < pb->x) ? -1 : 1;
    return (pa->y < pb->y) ? -1 : (pa->y > pb->y) ? 1 : 0;
}

// Andrew's Monotone Chain
// 返回凸包顶点数(逆时针序),结果存在 hull[] 中
// hull[] 需至少 2*n 大小
int andrew_hull(Point pts[], int n, Point hull[]) {
    if (n < 3) {
        for (int i = 0; i < n; i++) hull[i] = pts[i];
        return n;
    }

    qsort(pts, n, sizeof(Point), cmp_xy);

    int k = 0;

    // 构建下凸包
    for (int i = 0; i < n; i++) {
        while (k >= 2 && cross(hull[k - 2], hull[k - 1], pts[i]) <= 0)
            k--;
        hull[k++] = pts[i];
    }

    // 构建上凸包
    int lower_size = k + 1;  // 上凸包起始位置(+1 避免重复终点)
    for (int i = n - 2; i >= 0; i--) {
        while (k >= lower_size && cross(hull[k - 2], hull[k - 1], pts[i]) <= 0)
            k--;
        hull[k++] = pts[i];
    }

    return k - 1;  // 最后一个点与第一个点重复
}

4.4 为何 Andrew 更受欢迎

在竞赛和工程实践中,Andrew’s Monotone Chain 通常比 Graham Scan 更受青睐,原因如下:

  1. 排序更简单:字典序排序无需选取锚点,qsort 直接可用。
  2. 无需特殊处理共线点:下凸包和上凸包的扫描自然地处理了共线情况。
  3. 数值更稳定:不涉及极角计算,全部操作都是叉积(整数乘法和减法)。
  4. 代码更短:实现通常比 Graham Scan 短 30% 以上。

4.5 等价性

Andrew 和 Graham 算法具有相同的时间和空间复杂度 O(n log n)。它们的核心思想完全一致——都是通过排序建立遍历顺序,然后用栈维护凸性不变量。区别仅在于排序方式(极角 vs. 坐标)和扫描方式(一次扫描 vs. 上下两次扫描)。

五、Jarvis March(Gift Wrapping)

5.1 算法思想

Jarvis March(也称 Gift Wrapping)是最直观的凸包算法。它模拟”包装礼物”的过程:

  1. 从一个已知的凸包顶点出发(如最左边的点)。
  2. 每一步从当前点出发,找到使得所有其他点都在”连线左侧”的下一个点。
  3. 重复直到回到起点。

5.2 实现

int jarvis_march(Point pts[], int n, Point hull[]) {
    if (n < 3) {
        for (int i = 0; i < n; i++) hull[i] = pts[i];
        return n;
    }

    // 找最左边的点
    int leftmost = 0;
    for (int i = 1; i < n; i++) {
        if (pts[i].x < pts[leftmost].x ||
            (pts[i].x == pts[leftmost].x && pts[i].y < pts[leftmost].y))
            leftmost = i;
    }

    int h = 0;
    int p = leftmost;
    do {
        hull[h++] = pts[p];

        int q = (p + 1) % n;
        for (int i = 0; i < n; i++) {
            long long v = cross(pts[p], pts[q], pts[i]);
            if (v > 0) {
                q = i;  // pts[i] 更"左"
            } else if (v == 0) {
                // 共线时选更远的点
                long long dq = (long long)(pts[q].x - pts[p].x) * (pts[q].x - pts[p].x)
                             + (long long)(pts[q].y - pts[p].y) * (pts[q].y - pts[p].y);
                long long di = (long long)(pts[i].x - pts[p].x) * (pts[i].x - pts[p].x)
                             + (long long)(pts[i].y - pts[p].y) * (pts[i].y - pts[p].y);
                if (di > dq) q = i;
            }
        }

        p = q;
    } while (p != leftmost);

    return h;
}

5.3 复杂度分析

当 h 很小时(如 h = O(log n)),Jarvis March 的 O(n log n) 性能实际上优于 Graham Scan。但当 h = O(n) 时(如所有点都在凸包上),退化为 O(n^2)。

5.4 Output-Sensitive 的概念

Jarvis March 的复杂度依赖于输出大小 h,这就是所谓的 output-sensitive 算法。对于凸包问题,这是一个重要的性质:当大部分点在凸包内部时,我们不应该为”排除”这些点付出太多代价。

但 Jarvis March 的 O(nh) 还不是最优的 output-sensitive 复杂度。最优下界是 O(n log h),这正是 Chan’s Algorithm 所达到的。

六、Chan’s Algorithm(1996)

6.1 动机与思路

Timothy Chan 在 1996 年提出了一个极其精巧的算法,达到了 O(n log h) 的最优 output-sensitive 复杂度。其核心思路是:

将 Graham Scan 和 Jarvis March 结合起来。

关键洞察: - Graham Scan 处理 m 个点需要 O(m log m) 时间。 - 在预处理了凸包的小组中做 Jarvis 的”找下一点”操作,可以用二分搜索在 O(log m) 时间完成,而不是 O(m)。 - 如果我们将 n 个点分成 n/m 组(每组 m 个),对每组单独求凸包(作为预处理),然后用 Jarvis March 在组间搜索,总复杂度为 O(n log m + nh/… )——但这需要正确设置 m。

6.2 算法描述

输入:n 个点,猜测的凸包大小 H(稍后说明如何猜测)。

步骤

  1. 令 m = H。将 n 个点分成 ceil(n/m) 组,每组最多 m 个点。
  2. 对每组用 Graham Scan(或 Andrew)求凸包。时间 O(m log m) 每组,总共 O(n log m)。
  3. 用 Jarvis March 的思路寻找整体凸包:
    • 从最低点开始。
    • 每一步,在每个小组的凸包中二分搜索该组中”最佳的下一个点”。
    • 在 ceil(n/m) 个候选中选出全局最佳。
    • 重复最多 H 步。
  4. 如果 H 步内完成了整体凸包,输出结果。否则,报告失败(H 猜小了)。

单步的复杂度:每个小组的二分搜索 O(log m),共 ceil(n/m) 个组,所以每步 O((n/m) log m)。总共 H 步:O(H * (n/m) * log m)。

当 m = H 时,总复杂度 = O(n log m) + O(n log m) = O(n log H)。

6.3 Repeated Squaring Trick

问题在于:我们事先不知道 h。Chan 的解决方案极为精妙——repeated squaring(反复平方)

  1. 依次尝试 H = 2(2t),即 H = 4, 16, 256, 65536, …
  2. 对于每个 H,运行上述算法。如果 H >= h,算法成功返回;否则增加 t 再试。

为什么这样仍然是 O(n log h)?

设实际凸包大小为 h。成功的那次迭代(设为第 T 次)有 H = 2(2T) >= h,因此 O(n log H) = O(n log h)(因为 H <= h^2,所以 log H <= 2 log h)。

之前所有失败迭代的总时间:

sum_{t=0}^{T-1} O(n log 2^(2^t)) = sum_{t=0}^{T-1} O(n * 2^t)

这是一个几何级数,总和 O(n * 2^T) = O(n log H) = O(n log h)。

因此总复杂度 O(n log h)——最优!

6.4 二分搜索的实现细节

在每个小组的凸包中用二分搜索找”对当前方向最优的点”,这是 Chan’s Algorithm 中最微妙的部分。

对于一个已经按顺序排列的凸包 Q = {q0, q1, …, qm-1},要找到从外部点 p 看去的”最右切线点”。这可以用如下的二分搜索实现:

// 在凸包 hull[0..size-1] 中找到从点 p 出发
// 相对于方向 prev->p 的下一个凸包点(切线点)
// hull 按逆时针序排列
int tangent_search(Point hull[], int size, Point p, Point prev) {
    if (size == 1) return 0;

    // 二分搜索切线点
    // 寻找使得 cross(prev, p, hull[i]) 方向"最右"的 i
    int lo = 0, hi = size;
    int best = 0;

    // 先检查 hull[0] 是否是候选
    long long c0 = cross(prev, p, hull[0]);

    for (int i = 1; i < size; i++) {
        long long ci = cross(prev, p, hull[i]);
        long long cmp = cross(p, hull[best], hull[i]);
        if (cmp > 0 || (cmp == 0 && ci < c0)) {
            best = i;
        }
    }

    return best;
}

注意:上面是 O(m) 的简化版本。真正的 O(log m) 二分搜索需要利用凸包的单调性,实现较为复杂。完整的二分切线搜索如下:

// O(log m) 切线二分搜索
// 在凸包 Q[0..m-1](逆时针序)中,
// 找到使 cross(from, Q[i], Q[(i+1)%m]) 符号变化的切线点
int binary_tangent(Point Q[], int m, Point from) {
    if (m == 1) return 0;
    if (m == 2) {
        long long c = cross(from, Q[0], Q[1]);
        if (c < 0) return 1;
        if (c > 0) return 0;
        // 共线取较远的
        return 0;
    }

    // 定义: up(i) = cross(from, Q[i], Q[(i+1)%m]) >= 0
    // 切线点是 up(i-1) && !up(i) 的位置
    int a = 0, b = m;

    int a_up = (cross(from, Q[0], Q[1 % m]) >= 0);

    while (b - a > 1) {
        int mid = (a + b) / 2;
        int mid_up = (cross(from, Q[mid], Q[(mid + 1) % m]) >= 0);
        int a_to_mid = (cross(from, Q[a], Q[mid]) > 0);

        if (!a_up && mid_up) {
            b = mid;
        } else if (a_up && !mid_up) {
            a = mid;
            a_up = mid_up;
        } else if (a_up && mid_up) {
            if (a_to_mid) a = mid, a_up = mid_up;
            else b = mid;
        } else {
            if (!a_to_mid) a = mid, a_up = mid_up;
            else b = mid;
        }
    }

    return a;
}

6.5 Chan’s Algorithm 完整实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    int x, y;
} Point;

static long long cross3(Point O, Point A, Point B) {
    return (long long)(A.x - O.x) * (B.y - O.y)
         - (long long)(A.y - O.y) * (B.x - O.x);
}

static int cmp_point(const void *a, const void *b) {
    const Point *pa = (const Point *)a;
    const Point *pb = (const Point *)b;
    if (pa->x != pb->x) return (pa->x < pb->x) ? -1 : 1;
    return (pa->y < pb->y) ? -1 : (pa->y > pb->y) ? 1 : 0;
}

// Andrew's Monotone Chain for a small group
static int small_hull(Point pts[], int n, Point hull[]) {
    if (n <= 1) {
        if (n == 1) hull[0] = pts[0];
        return n;
    }
    qsort(pts, n, sizeof(Point), cmp_point);
    int k = 0;
    for (int i = 0; i < n; i++) {
        while (k >= 2 && cross3(hull[k-2], hull[k-1], pts[i]) <= 0) k--;
        hull[k++] = pts[i];
    }
    int lower = k + 1;
    for (int i = n - 2; i >= 0; i--) {
        while (k >= lower && cross3(hull[k-2], hull[k-1], pts[i]) <= 0) k--;
        hull[k++] = pts[i];
    }
    return k - 1;
}

// 在凸包中找到关于给定方向的切线点(简化版 O(m))
static int find_tangent(Point hull[], int size, Point from, Point dir) {
    int best = 0;
    for (int i = 1; i < size; i++) {
        long long c = cross3(from, hull[best], hull[i]);
        if (c > 0) {
            best = i;
        } else if (c == 0) {
            long long db = (long long)(hull[best].x - from.x) * (hull[best].x - from.x)
                         + (long long)(hull[best].y - from.y) * (hull[best].y - from.y);
            long long di = (long long)(hull[i].x - from.x) * (hull[i].x - from.x)
                         + (long long)(hull[i].y - from.y) * (hull[i].y - from.y);
            if (di > db) best = i;
        }
    }
    return best;
}

// Chan's Algorithm
// 返回凸包顶点数,结果存在 result[] 中
int chan_hull(Point pts[], int n, Point result[]) {
    if (n < 3) {
        for (int i = 0; i < n; i++) result[i] = pts[i];
        return n;
    }

    // Repeated squaring: 尝试 H = 2^(2^t)
    for (int t = 1; t < 30; t++) {
        long long H_val = 1;
        for (int i = 0; i < (1 << t); i++) {
            H_val *= 2;
            if (H_val > n) { H_val = n; break; }
        }
        int m = (int)H_val;  // 每组大小 = 猜测的 h

        // 分组并对每组求凸包
        int num_groups = (n + m - 1) / m;

        // 分配内存
        Point **group_hulls = (Point **)malloc(num_groups * sizeof(Point *));
        int *group_sizes = (int *)malloc(num_groups * sizeof(int));
        Point *buf = (Point *)malloc(n * sizeof(Point));
        memcpy(buf, pts, n * sizeof(Point));

        for (int g = 0; g < num_groups; g++) {
            int start = g * m;
            int end = start + m;
            if (end > n) end = n;
            int cnt = end - start;
            group_hulls[g] = (Point *)malloc(2 * cnt * sizeof(Point));
            group_sizes[g] = small_hull(buf + start, cnt, group_hulls[g]);
        }

        // 找全局最低点
        int best_group = 0, best_idx = 0;
        for (int g = 0; g < num_groups; g++) {
            for (int i = 0; i < group_sizes[g]; i++) {
                Point *bp = &group_hulls[best_group][best_idx];
                Point *cp = &group_hulls[g][i];
                if (cp->y < bp->y || (cp->y == bp->y && cp->x < bp->x)) {
                    best_group = g;
                    best_idx = i;
                }
            }
        }

        Point start_pt = group_hulls[best_group][best_idx];
        result[0] = start_pt;
        Point current = start_pt;
        int h = 1;
        int success = 0;

        for (int step = 0; step < m; step++) {
            // 在每个组中找最佳候选
            Point best_next = current;
            int found_any = 0;

            for (int g = 0; g < num_groups; g++) {
                if (group_sizes[g] == 0) continue;
                int ti = find_tangent(group_hulls[g], group_sizes[g],
                                      current, current);
                Point candidate = group_hulls[g][ti];

                if (candidate.x == current.x && candidate.y == current.y)
                    continue;

                if (!found_any) {
                    best_next = candidate;
                    found_any = 1;
                } else {
                    long long c = cross3(current, best_next, candidate);
                    if (c > 0) {
                        best_next = candidate;
                    } else if (c == 0) {
                        long long db = (long long)(best_next.x - current.x) * (best_next.x - current.x)
                                     + (long long)(best_next.y - current.y) * (best_next.y - current.y);
                        long long dc = (long long)(candidate.x - current.x) * (candidate.x - current.x)
                                     + (long long)(candidate.y - current.y) * (candidate.y - current.y);
                        if (dc > db) best_next = candidate;
                    }
                }
            }

            if (best_next.x == start_pt.x && best_next.y == start_pt.y) {
                success = 1;
                break;
            }

            result[h++] = best_next;
            current = best_next;
        }

        // 清理
        for (int g = 0; g < num_groups; g++) free(group_hulls[g]);
        free(group_hulls);
        free(group_sizes);
        free(buf);

        if (success) return h;
        // 否则 H 猜小了,继续下一轮
    }

    return 0;  // 不应到达这里
}

6.6 复杂度总结

算法 时间复杂度 空间复杂度 Output-Sensitive
Graham Scan O(n log n) O(n)
Andrew’s Monotone Chain O(n log n) O(n)
Jarvis March O(nh) O(h)
Chan’s Algorithm O(n log h) O(n) 是,最优

七、三维凸包:增量构造法

7.1 问题定义

三维凸包是二维情况的自然推广:给定三维空间中的点集,求包含所有点的最小凸多面体。输出是一组三角面片(或更一般的凸多边形面片)。

7.2 增量构造法

增量构造法(Incremental Construction)是最常用的三维凸包算法:

  1. 用前 4 个不共面的点构成初始四面体。
  2. 依次添加剩余的点。对于每个新点 p:
    • 如果 p 在当前凸包内部,跳过。
    • 如果 p 在外部,找到从 p 能”看到”的所有面(即 p 在该面的外侧)。
    • 删除这些”可见面”,用 p 与可见面的边界(horizon edges)构成新面。

7.3 可见性判断

面 F 对点 p 是否可见,可以通过该面的法向量和 p 到面上一点的向量的点积来判断:

typedef struct {
    double x, y, z;
} Point3D;

typedef struct {
    int v[3];       // 三个顶点的索引
    Point3D normal;  // 外法向量
    double offset;   // 法向量与顶点的点积
} Face;

// 判断点 p 是否在面 f 的外侧
int is_visible(Face *f, Point3D *pts, Point3D p) {
    double dot = f->normal.x * p.x + f->normal.y * p.y + f->normal.z * p.z;
    return dot > f->offset + 1e-10;  // epsilon 容差
}

// 计算面的法向量(通过叉积)
Point3D face_normal(Point3D a, Point3D b, Point3D c) {
    Point3D u = {b.x - a.x, b.y - a.y, b.z - a.z};
    Point3D v = {c.x - a.x, c.y - a.y, c.z - a.z};
    Point3D n;
    n.x = u.y * v.z - u.z * v.y;
    n.y = u.z * v.x - u.x * v.z;
    n.z = u.x * v.y - u.y * v.x;
    return n;
}

7.4 复杂度

7.5 更高维度

维度 d 凸包面数上界 最优算法复杂度
2 n O(n log n)
3 2n - 4 O(n log n)
d >= 4 O(n^{floor(d/2)}) 取决于维度

高维凸包在机器学习(如支持向量机的对偶问题)和优化领域有重要应用。

八、数值稳健性问题

8.1 浮点精度的陷阱

凸包算法的正确性严重依赖于叉积符号的判断。在浮点运算中,这个判断可能出错:

// 这个叉积"应该"为正,但浮点误差可能使它变为负或零
double cross_float(double ax, double ay, double bx, double by,
                   double cx, double cy) {
    return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
}
// 当三点接近共线时,结果可能在 +epsilon 和 -epsilon 之间跳动

8.2 典型的错误现象

浮点凸包实现中常见的错误表现:

  1. 算法不终止:由于叉积符号判断的不一致性,栈的弹入弹出可能形成死循环。
  2. 结果不是凸的:某些应该弹出的点被保留了,或者不该弹出的点被弹出了。
  3. 遗漏凸包顶点:接近共线的三个点,中间的点可能被错误地排除。
  4. 重复顶点:某些点被加入凸包两次。

8.3 解决方案

方案一:使用整数坐标

如果坐标范围允许(例如 |x|, |y| <= 10^9),使用 64 位整数计算叉积,完全避免浮点误差:

// 使用 long long 确保精确计算
// 坐标范围 |x|, |y| <= 10^9 时,叉积最大约 4 * 10^18,
// 在 long long 范围内(约 9.2 * 10^18)
long long cross_exact(Point O, Point A, Point B) {
    return (long long)(A.x - O.x) * (B.y - O.y)
         - (long long)(A.y - O.y) * (B.x - O.x);
}

方案二:epsilon 比较

对于浮点坐标,使用 epsilon 容差:

#define EPS 1e-9

int sign(double x) {
    if (x > EPS) return 1;
    if (x < -EPS) return -1;
    return 0;
}

但 epsilon 方法并非银弹:epsilon 太大会合并不该合并的点,太小则不能有效消除误差。

方案三:精确算术库

使用精确浮点算术库(如 Shewchuk 的 robust predicates 或 CGAL 中的精确内核)。这些库通过自适应精度算术实现精确的符号判断:

// Shewchuk 的 orient2d 谓词
// 返回正值表示逆时针,负值表示顺时针,零表示共线
// 使用自适应精度,必要时会使用更多精度位
extern double orient2d(double *pa, double *pb, double *pc);

方案四:符号扰动(Symbolic Perturbation)

通过对点坐标添加微小的符号扰动,消除所有退化情况(如三点共线)。这是一种理论上优美的方法,但实现较为复杂。

8.4 实践建议

对于大多数应用场景:

  1. 竞赛:使用整数坐标 + long long 叉积,干净利落。
  2. 工程代码:如果坐标是浮点的,优先考虑 Shewchuk predicates。
  3. 科学计算:使用 CGAL 或类似的计算几何库。

九、凸包的应用

9.1 经典应用

旋转卡壳(Rotating Calipers):在凸包上运行旋转卡壳算法,可以在 O(h) 时间内求解: - 最远点对(直径问题) - 最小面积/周长外接矩形 - 两个凸包之间的最大/最小距离

碰撞检测:在游戏和物理模拟中,凸包常用于近似复杂形状的碰撞包围体。GJK(Gilbert-Johnson-Keerthi)算法用于判断两个凸体是否相交。

线性规划:二维线性规划的可行域是一个凸多边形,其顶点可以通过凸包相关的方法求出。

9.2 竞赛中的凸包技巧

斜率优化 DP:在动态规划中,当状态转移方程可以写成 f(j) = a(j) * x(i) + b(j) 的形式时,可以将 (a(j), b(j)) 看作直线,用凸包维护最优直线集合。这就是著名的”凸包技巧”(Convex Hull Trick)。

// 凸包技巧的简化版本(斜率递减的情况)
typedef struct {
    long long k, b;  // 直线 y = kx + b
} Line;

Line stack_lines[200001];
int line_top = 0;

// 判断第三条线是否使第二条线多余
int bad(Line l1, Line l2, Line l3) {
    // l1 和 l3 的交点 x 坐标 <= l1 和 l2 的交点 x 坐标
    return (__int128)(l3.b - l1.b) * (l2.k - l1.k)
        <= (__int128)(l2.b - l1.b) * (l3.k - l1.k);
}

void add_line(long long k, long long b) {
    Line l = {k, b};
    while (line_top >= 2 &&
           bad(stack_lines[line_top - 2], stack_lines[line_top - 1], l)) {
        line_top--;
    }
    stack_lines[line_top++] = l;
}

long long query(long long x) {
    // 二分或指针移动查找最优线
    int lo = 0, hi = line_top - 1;
    while (lo < hi) {
        int mid = (lo + hi) / 2;
        if (stack_lines[mid].k * x + stack_lines[mid].b <=
            stack_lines[mid + 1].k * x + stack_lines[mid + 1].b) {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    return stack_lines[lo].k * x + stack_lines[lo].b;
}

9.3 半平面交

半平面交(Half-Plane Intersection)与凸包有密切的对偶关系。在对偶空间中,点变成直线,直线变成点,凸包变成半平面交。这种对偶性使得许多凸包算法可以直接改写为半平面交算法。

十、工程实践中的陷阱

在实际工程中实现凸包算法时,以下表格总结了常见的陷阱:

陷阱 现象 解决方案
整数溢出 叉积计算结果溢出 int 范围,判断符号出错 使用 long long,或坐标范围超过 10^9 时使用 __int128
浮点误差 接近共线的三点叉积符号不稳定,导致算法不终止或结果错误 整数坐标优先;浮点时使用 robust predicates
共线点遗漏 Graham Scan 中最后一批共线点顺序错误,导致凸包不闭合 排序时对最后一组共线点按距离逆序排列
重复点 输入中有重复点,导致零向量参与叉积计算 预处理去重,或在算法中跳过相同点
退化输入 所有点共线或只有 1-2 个点 在算法开头特殊处理 n < 3 的情况
qsort 不稳定 C 标准不保证 qsort 稳定,等价元素的顺序不确定 在比较函数中加入 tie-breaking 规则
凸包边上的点 需要保留凸包边上的所有点,但标准算法只保留顶点 修改叉积判断条件:将 <= 0 改为 < 0
栈数组越界 hull 数组分配不够大 Andrew 算法需要 2n 大小的数组
极角排序分支 不同象限的点直接用叉积比较可能得到错误的极角顺序 先按象限分类,同象限内再用叉积比较
输入修改 排序修改了原始数组,但调用者可能还需要原始顺序 复制数组后再排序,或记录原始索引
Chan 算法内存 分组求凸包需要额外内存分配 预分配足够的缓冲区,避免频繁 malloc/free
大规模数据性能 n 超过 10^6 时排序的常数因子影响显著 考虑基数排序或并行化

十一、算法对比与选择指南

11.1 理论对比

特性 Graham Scan Andrew Jarvis March Chan
年份 1972 1979 1973 1996
时间复杂度 O(n log n) O(n log n) O(nh) O(n log h)
空间复杂度 O(n) O(n) O(h) O(n)
Output-sensitive 是(最优)
排序方式 极角排序 坐标排序 无需排序 分组排序
实现难度 中等 简单 简单 较难
数值稳定性 一般 取决于子算法

11.2 实践中的选择

  1. 竞赛:几乎总是选择 Andrew’s Monotone Chain。代码短、不易出错、适合手写。
  2. 面试:Graham Scan 更常被考察,因为它更”经典”且更能展示算法理解。
  3. 工程代码:如果 h 远小于 n(如点是随机分布的),Chan’s Algorithm 有优势。否则 Andrew 足够。
  4. 高维:使用 CGAL 或 Qhull 等成熟库。
  5. 实时系统:如果需要增量更新凸包(点动态插入/删除),考虑动态凸包数据结构。

11.3 性能实测参考

对于 n = 10^6 个随机均匀分布的点(坐标范围 [0, 10^9]):

算法 凸包大小 h 耗时
Andrew ~90 ~180ms
Graham ~90 ~200ms
Jarvis ~90 ~15ms
Chan (m=h) ~90 ~120ms

当点均匀分布时,h 通常为 O(log n) 级别,Jarvis March 反而最快。但如果点在凸位置(如圆上),h = n,Jarvis 退化为 O(n^2)。

十二、个人思考

12.1 凸包问题的算法设计启示

凸包问题是算法设计思维的一个绝佳缩影。从 Graham(1972)到 Chan(1996),跨越了整整 24 年。这条进化路线告诉我们几个重要的教训:

第一,排序是算法设计的基石。 Graham Scan 的核心洞察是:如果我们按极角排好序,就可以用一次线性扫描完成凸包构建。排序将一个看似复杂的几何问题转化为一个栈操作问题。Andrew 的改进则说明:选择”正确”的排序方式(坐标排序 vs. 极角排序),可以大大简化实现并提高鲁棒性。

第二,output-sensitive 是一种重要的复杂度度量。 对于输出大小可变的问题,仅按输入大小衡量算法效率是不够的。Jarvis March 虽然最坏情况下很差,但它首次展示了利用输出大小来加速的可能性。

第三,“组合”是强大的算法设计策略。 Chan’s Algorithm 并没有发明全新的凸包算法,而是巧妙地将 Graham 和 Jarvis 组合起来,取各自之长。这种”用 A 做预处理,用 B 做查询”的模式在算法设计中非常常见。

第四,repeated squaring 是猜测参数的利器。 当算法的最优参数依赖于未知的输出大小时,通过指数增长来”猜测”参数,可以保证总代价只比最优多一个常数因子。这个技巧在 cache-oblivious 算法和在线算法中也反复出现。

12.2 计算几何的工程现实

理论上优美的算法在实际应用中常常面临尴尬。计算几何可能是受浮点精度影响最严重的算法领域之一。许多在整数坐标下完美运行的算法,换成浮点坐标后就变得脆弱不堪。

我个人的经验是:能用整数就用整数。 如果输入坐标是浮点的,考虑是否可以通过乘以一个大数后取整。如果必须使用浮点,那就不要自己实现——使用 CGAL、Boost.Geometry 或类似的库,它们已经在数值稳健性上投入了大量的工程工作。

12.3 凸包之外

凸包是进入计算几何世界的一扇门。掌握了凸包算法后,自然会遇到一系列相关问题:

计算几何的美在于它将抽象的数学结构与直观的几何图形联系起来。当你看到一个凸包算法在屏幕上一步步”收缩”橡皮筋的动画时,那种数学与视觉的统一感,是其他算法领域很难给予的。

参考文献

  1. Graham, R.L. “An efficient algorithm for determining the convex hull of a finite planar set.” Information Processing Letters, 1(4):132-133, 1972.

  2. Andrew, A.M. “Another efficient algorithm for convex hulls in two dimensions.” Information Processing Letters, 9(5):216-219, 1979.

  3. Jarvis, R.A. “On the identification of the convex hull of a finite set of points in the plane.” Information Processing Letters, 2(1):18-21, 1973.

  4. Chan, T.M. “Optimal output-sensitive convex hull algorithms in two and three dimensions.” Discrete & Computational Geometry, 16(4):361-368, 1996.

  5. Shewchuk, J.R. “Adaptive precision floating-point arithmetic and fast robust geometric predicates.” Discrete & Computational Geometry, 18(3):305-363, 1997.

  6. de Berg, M., Cheong, O., van Kreveld, M., Overmars, M. Computational Geometry: Algorithms and Applications. 3rd edition, Springer, 2008.

  7. O’Rourke, J. Computational Geometry in C. 2nd edition, Cambridge University Press, 1998.

  8. Preparata, F.P., Shamos, M.I. Computational Geometry: An Introduction. Springer-Verlag, 1985.

  9. Barber, C.B., Dobkin, D.P., Huhdanpaa, H. “The quickhull algorithm for convex hulls.” ACM Transactions on Mathematical Software, 22(4):469-483, 1996.

  10. CGAL Project. “CGAL - The Computational Geometry Algorithms Library.” https://www.cgal.org/


算法系列导航上一篇:列式压缩 | 下一篇:扫描线算法

相关阅读最近点对与随机化几何算法 | 斜率优化与凸包技巧


By .