第45讲 计算几何基础:向量运算与凸包问题

各位同学,今天我们来聊聊计算几何。说实话,很多搞C语言开发的朋友,一听到“几何”两个字就头疼。我当年刚接触这部分内容时,也觉得这玩意儿离实际开发太远。直到有一次做地图导航项目,需要判断车辆行驶轨迹是否偏离路线——嗯,那时候我才发现,向量运算简直就是救命稻草。

这一讲,我会带着大家从最基础的向量运算开始,一步步走到凸包问题。你想想看,从点积叉积到多边形面积,再到Graham扫描法和Andrew算法,其实是一条很清晰的逻辑链。咱们不搞花架子,直接上干货。

核心知识点一览:

  • 向量的点积与叉积——几何计算的基石
  • 判断线段相交——实际项目中最常用的功能
  • 多边形面积计算——海伦公式的向量版
  • 凸包问题——Graham扫描法与Andrew算法
计算几何基础 向量点积与叉积 判断线段相交 多边形面积 凸包问题 Graham扫描法 Andrew算法 从基础运算到凸包算法,层层递进 点积→叉积→线段相交→多边形面积→凸包

1. 向量的点积与叉积

先说说向量。在C语言里,我们通常用一个结构体来表示二维向量:

typedef struct {
    double x;
    double y;
} Vector2D;

点积(Dot Product)的计算公式很简单:a·b = a.x * b.x + a.y * b.y。它有什么用?我个人最常用的场景是判断两个向量的方向关系。如果点积大于0,说明夹角小于90度;等于0就是垂直;小于0就是钝角。

叉积(Cross Product)稍微复杂一点。在二维空间中,叉积的结果是一个标量:a × b = a.x * b.y - a.y * b.x。这个值的正负号,直接告诉你b在a的左侧还是右侧。我习惯用这个来判断多边形的顶点顺序——顺时针还是逆时针。

我的小技巧: 写代码时,我通常把点积和叉积写成内联函数。因为这两个操作太频繁了,函数调用开销能省则省。用宏定义也行,但注意参数不要有副作用。

// 点积
static inline double dot(Vector2D a, Vector2D b) {
    return a.x * b.x + a.y * b.y;
}

// 叉积
static inline double cross(Vector2D a, Vector2D b) {
    return a.x * b.y - a.y * b.x;
}

2. 判断线段相交

这个功能,我在做路径规划项目时几乎天天用。判断两条线段是否相交,最经典的方法就是“跨立实验”。说白了,就是看一条线段的两个端点,是否分别位于另一条线段的两侧。

具体做法:对线段AB和CD,计算叉积 (B-A) × (C-A)(B-A) × (D-A)。如果这两个叉积异号,说明C和D在AB的两侧。同理再检查A和B相对于CD的位置。如果两组都异号,那就相交了。

注意: 这里有个坑——共线情况。如果叉积为0,说明点在线段所在的直线上。这时候需要判断点是否在线段范围内。我曾经在这个问题上吃过亏,调试了半天才发现是边界条件没处理好。

// 判断线段AB和CD是否相交
int segments_intersect(Vector2D A, Vector2D B, Vector2D C, Vector2D D) {
    Vector2D AB = {B.x - A.x, B.y - A.y};
    Vector2D AC = {C.x - A.x, C.y - A.y};
    Vector2D AD = {D.x - A.x, D.y - A.y};
    
    double cross1 = cross(AB, AC);
    double cross2 = cross(AB, AD);
    
    // 如果C和D在AB两侧
    if (cross1 * cross2 < 0) {
        Vector2D CD = {D.x - C.x, D.y - C.y};
        Vector2D CA = {A.x - C.x, A.y - C.y};
        Vector2D CB = {B.x - C.x, B.y - C.y};
        double cross3 = cross(CD, CA);
        double cross4 = cross(CD, CB);
        if (cross3 * cross4 < 0) return 1;
    }
    
    // 处理共线情况(略)
    return 0;
}

3. 多边形面积

计算多边形面积,我推荐用“鞋带公式”(Shoelace Formula)。名字挺有意思,其实就是把顶点坐标按顺序排好,交叉相乘再求和。公式如下:

Area = 0.5 * |Σ(x_i * y_{i+1} - x_{i+1} * y_i)|

注意顶点要按顺序给出(顺时针或逆时针都行),最后一个顶点要连回第一个。这个公式的好处是,不需要把多边形分割成三角形,直接一次遍历就能算出来。

double polygon_area(Vector2D *points, int n) {
    double area = 0.0;
    for (int i = 0; i < n; i++) {
        int j = (i + 1) % n;
        area += points[i].x * points[j].y;
        area -= points[j].x * points[i].y;
    }
    return fabs(area) / 2.0;
}

经验之谈: 如果你发现算出来的面积是负数,别慌。这表示你的顶点顺序是顺时针。取绝对值就好。我一般会在代码里保留符号,因为有时候需要知道顶点顺序。

4. 凸包问题

凸包,说白了就是用一个最小的凸多边形,把一堆点全部包在里面。想象一下用橡皮筋圈住一堆钉子,橡皮筋的形状就是凸包。

4.1 Graham扫描法

Graham扫描法的思路很直观:先找到最左下角的点作为起点,然后按极角排序,最后用一个栈来维护凸包。我当年学这个算法时,觉得“极角排序”这一步最绕。其实用叉积就能比较角度,不用真的去算角度值。

// 比较极角(用叉积)
int cmp_angle(const void *a, const void *b) {
    Vector2D *p1 = (Vector2D *)a;
    Vector2D *p2 = (Vector2D *)b;
    double c = cross(*p1, *p2);
    if (c != 0) return c > 0 ? -1 : 1;
    // 如果共线,距离近的优先
    double d1 = p1->x * p1->x + p1->y * p1->y;
    double d2 = p2->x * p2->x + p2->y * p2->y;
    return d1 < d2 ? -1 : 1;
}

排序之后,依次把点压入栈。每次压入前,检查栈顶两个点和新点是否构成“左转”。如果是左转就压入,否则弹出栈顶。最终栈里的点就是凸包。

4.2 Andrew算法

Andrew算法是Graham的改进版。它不需要计算极角,直接按x坐标排序。然后分别构造上凸包和下凸包。我个人更喜欢Andrew算法,因为实现起来更简洁,而且数值稳定性更好。

// Andrew算法求凸包
int convex_hull(Vector2D *points, int n, Vector2D *hull) {
    // 先按x排序,x相同按y排序
    qsort(points, n, sizeof(Vector2D), cmp_x);
    
    int m = 0;
    // 构造下凸包
    for (int i = 0; i < n; i++) {
        while (m >= 2 && cross(hull[m-1] - hull[m-2], points[i] - hull[m-2]) <= 0)
            m--;
        hull[m++] = points[i];
    }
    
    // 构造上凸包
    int t = m + 1;
    for (int i = n - 2; i >= 0; i--) {
        while (m >= t && cross(hull[m-1] - hull[m-2], points[i] - hull[m-2]) <= 0)
            m--;
        hull[m++] = points[i];
    }
    
    return m - 1;  // 最后一个点重复了起点,去掉
}

避坑指南: 我曾经在Andrew算法里忘记处理重复点的情况,结果凸包上出现了两个相同的点。后来我加了一步去重,问题就解决了。另外,如果所有点共线,凸包就是一条线段,这时候算法也能正确处理。

总结

这一讲的内容,从向量运算到凸包算法,其实是一条很清晰的脉络。点积和叉积是基础中的基础,线段相交是实际应用最多的功能,多边形面积计算是很多几何问题的起点,而凸包则是这些知识的综合运用。

我个人建议,初学者先把点积和叉积的代码写熟练,然后试着实现线段相交判断。等这些基础打牢了,再挑战凸包算法。别一上来就啃凸包,容易消化不良。

最后说一句,计算几何的代码,调试起来确实有点麻烦。因为很多错误是数值精度导致的,不是逻辑问题。我的习惯是,所有比较都用eps(比如1e-9)来做容差,不要直接用==。这个小习惯,能帮你省下不少调试时间。

好了,这一讲就到这里。代码示例我都放在了配套资源里,大家可以去下载练习。记住,光看不练是学不会的,动手写一遍比看十遍都管用。


专注资料整理