首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

1.6 三角分割实现
Triangulation: Implementation

1.6.1 对角线,内部或者外部(Diagnoals, Internal or External)

完成了判定线段相交的实现之后,我们距离实现多边形的三角分割就不远了。我们的第一个目标是要找到多边形的一条对角线。

引理 1.5.1 刻画了对角线的两个条件,不与多边形的边相交、在多边形的内部。 如果我们忽略内部对角线和外部对角线的差异,那么寻找对角线的过程就是简单地重复调用 Intersect 函数。 对于多边形中每条边 \(e\),如果它不恰好与对角线 \(s\) 的两个端点之一重合,就检查 \(e\) 是否与 \(s\) 相交。一旦检测到交点,即可确定 \(s\) 不是对角线。 如果没有这样的边与 \(s\) 相交,那么 \(s\) 可能就是对角线。我们不能完全下结论的原因是,与 \(s\) 的某个端点相连的边可能与 \(s\) 共线,而这种情况是不会被检测到的。 我们稍后会讨论这种可能性。Code 1.10 给出了用于检查相交的代码实现。

        bool Diagnalie(tVertex a, tVertex b)
        {
            tVertex c, c1;

            c = vertices;
            do {
                c1 = c->next;
                if ((c != a) && (c1 != a) && (c != b) && (c1 != b) && Intersect(a->v, b->v, c->v, c1->v))
                    return FALSE;
                c = c->next;
            } while (c != vertices);
            retrun TRUE;
        }

1.6.2 InCone

现在我们来看引理 1.5.1 的第二个条件。我们需要区分出对角线实在多边形的内部还是外部。 同时我们还需要考虑与对角线共用端点的边。我们使用一个布尔函数 InCone 来解决这个两个问题。该函数可以确定一个向量 \(B\) 是否严格位于另外两个向量 \(A\) 和 \(C\) 之间的逆时针方向的开锥里(open cone counterclockwise)。如果向量 \(A\) 和 \(C\) 是多边形上相邻的两条边,那么 \(B\) 就在对角线上。这个方法足以确定对角线了,我们会在后面介绍细节。 现在我们来设计函数 InCone。

如果圆锥的顶点是凸角(convex angle, ≤ 180° 的角),这将是一个简单的任务。但如果它是凹角,就需要分情况处理或者需要一些巧妙的方法。这里我们先分类处理,而将巧妙的方法留给练习 1.6.4[5]。

图 1.25(a) 展示了凸角的情况。当且仅当 \(s\) 的一个端点位于顶点 \(a\) 处并且在与 \(a-, a+\) 构成的圆锥内部,\(s\) 才在 \(P\) 的内部。 我们可以通过函数 Left 轻松搞定: \(a-\) 必须在 \(ab\) 的左侧,\(a+\) 必须在 \(ba\) 的左侧。 这两个左侧的判定都必须严格的,以避免 \(ab\) 与圆锥边界共线重叠的情况。

如图 1.25(b) 所示,当 a 为凹角时,刚才的两个条件就不充分了。\(a-\) 和 \(a+\) 可能都在内部对角线的左侧,也可能都在右侧,或者一个在左侧而另一个在右侧。 值得注意的是,\(a\) 邻域的外部是凸角锥体,如果将凹角顶点的内部和外部互换,就变成了凸顶点。因此在这种情况下,判定 \(s\) 是否在内部的最简单方法就是,判定它不在椎体的外部。 \(a+\) 不能在 \(ab\) 的左侧或 \(ab\) 上,\(a-\) 不能同时在 \(ba\) 的左侧或 \(ba\) 上。

最后,区分一个角是凸角还是凹角很简单,只需要调用一次 Left 函数就可以确定。当且仅当 \(a-\) 在 \(aa+\) 的左侧或 \(aa+\) 上时,\(a\) 就是一个凸角。 注意,如果 \(a-, a, a+\) 共线,那么 \(a\) 处的内角就是 \(\pi\),我们仍将其定义为凸角(1.1.2 节)。 Code 1.11 就是上述思想的直接实现。

        bool InCone(tVertex a, tVertex b)
        {
            tVertex a0 = a->prev;
            tVertex a1 = a->next;
            if (LeftOn(a->v, a1->v, a0->v)) {
                // 若 a 是凸顶点
                return Left(a->v, b->v, a0->v) && Left(b->v, a->v, a1->v);
            } else {
                // 否则 a 是一个凹顶点
                return !(LeftOn(a->v, b->v, a1->v) && LeftOn(b->v, a->v, a0->v));
            }
        }

虽然这个 InCone 函数很简单,但很容易出错。整个函数由五个有符号面积计算组成,这恰恰说明了该计算的实用性。按:实在没看出实用性的因果关系。

1.6.3 对角(Diagnoal)

现在,我们已经有了判断 ab 是否为对角线的代码,当且仅当 Diagonalie(a, b)、InCone(a, b) 和 InCone(b, a) 都为真时,ab 才是对角线。 InCone 函数的调用既可以确保 ab 在多边形内部,也可以覆盖 Diagonalie 函数未检查的与端点相连的边。 此外,函数的调用是有顺序的。InCone 函数的调用应该放在最前面,因为它们都是常数时间,只涉及顶点 \(a,b\) 的邻域,无需考虑多边形的其余部分。 而 Diagonalie 函数有一个遍历所有 n 条边的循环。如果任何一个 InCone 函数的调用返回 FALSE,就不会执行可能耗时较长的 Diagonalie 检查。参见Code 1.12。

        bool Diagnal(tVertex a, tVertex b)
        {
            return InCone(a, b) && InCone(b, a) && Diagonalie(a, b);
        }

1.6.4 练习

  1. 整数溢出integer overflow。如果一台机器上的 int 是 32 位的,\(a,b,c\) 的坐标有多大不会导致 Area2 计算溢出? Code 1.5
  2. 函数 IntersectProp。详细分析函数 IntersectProp(Code 1.7), 如果删除它的 if 语句的判定,该函数计算的是什么。探讨一下,删除之后,函数 Intersect(Code 1.9)还能不能正常工作?
  3. 函数 Intersect 的低效率。手动追踪一下函数 Intersect(Code 1.9), 看看它最多调用了几次函数 Area2(Code 1.5),是否存在冗余的调用,设计一个新的函数避免这些冗余的调用。
  4. 保存交叉信息Saving intersection information。设计一个新算法,避免对同两条线段进行两次交点测试。分析新算法的时间复杂度和空间复杂度。
  5. 改进 InCone (Andy Mirzian)。证明当且仅当 Left(a, a+, b), Left(a,b,a-), Left(a, a-, a+) 这三个 Left 中有一个返回 false 时,\(ab\) 在顶点 \(a\) 的椎体内。
  6. 改进 Diagonal。证明 Diagonal 函数中对 InCome 的两次调用,移除任何一次都不会改变结果。

1.6.5 通过移除耳朵实现的三角分割(Triangulation by Ear Removal)

现在我们实现多边形的三角分割。一种方法是参考三角分割定理(定理 1.2.3)的证明,先找到一条对角线,将多边形分割成两部分,然后递归每一个部分。 这种方法的效率很低,最终我们基于 Meister 的双耳定理(定理 1.2.7)实现。但首先,我们还是分析一下递归方法的运行速度。我们假设读者对 \(O\) 表示法很熟悉。

基于对角线的算法(Diagonal-Based Algorithm)

基于定理 1.2.3 的算法的复杂度是 \(O(n^4)\)。其中有 \(\binom{n}{2} = O(n^2)\) 个候选对角线,而每个对角线的判定也需要 \(O(n)\)。 对 \(n - 3\) 个对角线重复上述 \(O(n^3)\) 的计算,就得到了一个 \(O(n^4)\) 的算法。

我们可以利用双耳定理将速度提高 \(n\) 倍。一定存在一条内部对角线将一个耳朵分隔开。而这种"耳朵对角线ear diagonal"只有 \(O(n)\) 的候选对象,\((v_i, v_{i+2}), i = 0, \cdots, n-1\)。 这样递归过程也简单,因为一次只需要处理其中一个部分。另一个部分就是一个三角形耳朵,显然已经是三角分割。因此这种方法最坏情况下的复杂度是 \(O(n^3)\)。

移除耳朵(Ear Removal)

现在我们将上述算法改进到 \(O(n²)\)。因为每次调用 Diagonal 函数的复杂度为 \(O(n)\),为了达到 \(O(n²)\),Diagonal 函数最多只能被调用 \(O(n)\)。 改进的关键在于,移除一只耳朵并不会显著改变多边形的形状,尤其是潜在的耳尖。所以我们首先要判断每个顶点 \(v_i\) 是否是潜在的耳尖,即 \(v_{i-1}\) 到 \(v_{i+1}\) 是否构成对角线。 虽然这一步骤已经是 \(O(n²)\) 的复杂度了,但无需重复执行这个耗时的步骤。

设 \((v_0, v_1, v_2, v_3, v_4)\) 为 \(P\) 的五个连续顶点,其中 \(v_2\) 是耳尖,并且已经删除了 \(E_2 = \Delta(v_1, v_2, v_3)\),如图 1.26 所示。 哪些顶点的耳尖状态可能会改变?只有 \(v_1\) 和 \(v_3\)。

比如说 \(v_4\)。它是不是耳尖取决于 \(v_3 v_5\) 是不是对角线。移除 \(E_2\) 后 \(v_3 v_5\) 的端点保持不变。显然,如果这两个端点本来能够互相看到, 那么移除 \(E_2\) 也不会阻挡它们之间的视线。如果它们之前就无法互相看到,那么移除 \(E_2\) 后它们仍然无法互相看到,这一点可能不太明显。

正如引理 1.2.2 的证明所述,只有两种情况需要考虑。如果 \(v_3, v_5\) 是外部线段, 那么显然删除 \(E_2\) 不会使其变为内部线段。否则,\(\Delta(v_3, v_4, v_5)\) 必须包含一个凹顶点(图 1.12 中的 x)。 但是移除 \(E_2\) 只会移除一个凸顶点。因此,移除 \(E_2\) 不会改变 \(v_4\) 的状态,除了 \(v_1\) 和 \(v_3\) 之外的所有顶点的状态也不会改变, 因为 \(v_1\) 和 \(v_3\) 的耳朵对角线(ear diagnals)都与被移除的顶点 V2 相连。

这意味着,在耗时的初始化步骤之后,每次迭代只需调用两次 Diagonal 函数即可更新耳尖状态信息。由此可得到Algorithm 1.1 中所示的用于构建三角剖分的伪代码。

我们将任务"多边形的三角分割"换个说过——"以任意顺序输出构成三角分割的对角线"。这主要是为了便于表述。通常,特定应用需要更结构化的输出。 例如,对偶图中需要三角形邻接信息。虽然获得更结构化的输出在渐近时间复杂度上并不更难,但通常会显著增加代码的复杂性。我们将不再深入探讨这些替代的三角剖分输出。

三角分割代码(Triangulation Code)

首先需要初始化布尔标志 v->ear,它是顶点数据结构的一部分(Code 1.2)。 对每个顶点调用一次函数 Diagonal 即可。参见 Earlnit,Code 1.13。

        void EarInit(void)
        {
            tVertex v0, v1, v2;
            v1 = vertices;
            do {
                v2 = v1->next;
                v0 = v1->prev;
                v1->ear = Diagnal(v0, v2);
                v1 = v1->next;
            } while (v1 != vertices);
        }

三角分割代码的主题是一个两层循环。外循环每次迭代移除一个耳朵,直到 \(n = 3\) 时停止。内循环通过检查预先计算的 v2->ear 标志来寻找耳朵, 其中 \(v_2\) 是潜在的耳朵尖。一旦找到耳朵尖,就通过调用函数 Diagonal 更新 \(v_1\) 和 \(v_3\) 的耳朵状态,打印代表耳朵根的对角线,并将耳朵从多边形中移除。 移除操作通过重新连接 \(v_1\) 和 \(v_3\) 的 next 和 prev 指针来实现。此时,如果 \(v_2\) 所指的顶点对象未被其他应用程序使用,则可以释放它。 必须注意,要避免 \(v_2\) 成为顶点列表头,即访问循环列表的入口点。因此需要把表头的指针 vertices 指向 v_3。参见代码 1.14。 在分析时间复杂度之前,我们先逐步推演一个示例。

        void Triangulate(void)
        {
            tVertex v0, v1, v2, v3, v4; /* 五个连续的顶点 */
            int n = nvertices;
            EarInit();
            while (n > 3) {  // 外层循环,每次移除一个耳朵
                v2 = vertices;
                do {         // 内层循环,寻找耳朵尖
                    if (v2->ear) {
                        v3 = v2->next; v4 = v3->next;
                        v1 = v2->prev; v0 = v1->prev;
                        PrintDiagonal(v1, v3) // (v1, v3) 是一个对角线

                        // 更新对角线端点的状态
                        v1->ear = Diagonal(v0, v3);
                        v3->ear = Diagonal(v1, v4);

                        // 移除耳朵 v2
                        v1->next = v3;
                        v3->prev = v1;
                        vertices = v3; // 防止 v2 成为链表头
                        n--;
                        break;
                    }
                    v2 = v2->next;
                } while (v2 != vertices);
        }   }

1.6.6 例子

图 1.27 中显示了一个多边形以及由简单的 main 函数 Code 1.15 生成的三角分割。读取和打印的代码很简单,这里不再赘述。

        main()
        {
            ReadVertices();
            PrintVertices();
            Triangulate();
        }

Table 1.1. Figure1.27 中多边形各个顶点坐标

i(x,y) i(x,y) i(x,y) i(x,y)
0(0, 0) 5(10, 12) 10(10, 15) 15(5, 8)
1(10, 7) 6(12, 14) 11(7, 17) 16(-2, 9)
2(12, 3) 7(14, 9) 12(0, 16) 17(5, 5)
3(20, 8) 8(8, 10) 13(1, 13)
4(13, 17) 9(6, 14) 14(3, 15)

第 11 的点坐标原文是 (7, 10) 不构成一个多边形,这里改成了 (7,17)。

Table 1.2. 依次输出的对角线顶点对

order对角线 order对角线 order对角线 order对角线
1(17, 1) 5(9, 11) 9(15, 3) 13(15, 8)
2(1, 3) 6(12, 14) 10(3, 7) 14(15, 9)
3(4, 6) 7(15, 17) 11(11, 14) 15(9, 14)
4(4, 7) 8(15, 1) 12(15, 7)

现在来看一下,本例中对角线的输出结果,如表 1.2 所示。\(v_0\) 是耳尖,因此第一个对角线输出为 (17, 1)。 \(v_1\) 不是耳尖,所以指针 v2 移动到 \(v_2\)。\(v_2\) 是一个耳尖,输出对角线 (1, 3)。 \(v_3\) 和 \(v_4\) 都不是耳尖,直到 \(v_5\) 时才输出下一个对角线 (4, 6)。线段 \(v_3v_8\) 与 \(v_7\) 共线,因此下一个耳朵要到 \(v_{10}\) 才能找到。 图 1.27 中深色阴影的子多边形显示了输出第 9 条对角线 (15, 3) 后剩余的多边形。另外,\(v_9\) 与 \(v_{11}v_{15}\) 共线,因此在移除对角线 (15, 9) 后,\(v_9\) 不再是耳朵。

1.6.7 分析

现在我们分析算法的时间复杂度。如前所述,EarInit 的复杂度为 O(n²)。Triangulate 函数的外层循环迭代次数等于对角线的数量,即 \(n - 3 = O(n)\)。 内层循环搜索耳朵的复杂度也为 \(O(n)\),因为可能需要检查每个顶点。内层循环内部的工作量为 \(O(n)\)。 在最坏情况下,可能需要需要对整个多边形调用两次函数 Diagonal 检查对角线是否被阻塞。因此,我们得到的算法时间复杂度为 \(O(n³)\),不足预期的 \(O(n²)\)。 更深入的分析表明,\(O(n²)\) 才是 Triangulate 函数的正确时间复杂度界限。

如图 1.28 所示。删除 \(v_0\) 后,内循环需要搜索 \(v_1, \cdots, v_6\) 才能找到下一个耳尖 \(v_7\)。然后,它必须搜索 \(v_8, \cdots, v_{12}\) 才能找到耳尖 \(v_{13}\)。 这个例子表明,内循环确实可能需要迭代 \(n\) 次才能找到一只耳朵。但请注意,循环内的两个 \(O(n)\) 的函数 Diagnal 仅在找到一只耳朵后才需要调用——它们并非在每次迭代中都调用。 因此,尽管表面上其复杂度为 \(n × n × n = O(n³)\),但实际上它是 \(n × (n + n) = O(n²)\) 的。

还可以更进一步(练习 1.6.8(4)),将渐近时间复杂度降低到二次方以下,需要采用完全不同的方法,这将在下一章中讨论。

1.6.8 练习

  1. [Programming] 重复相交测试Repeated intersection tests。函数 Triangulate (Code 1.14) 经常需要检查线段与线段之间是否相交。 修改代码,以便确定进行了多少次不必要的相交检查。在图 1.27 上进行测试。
  2. [easy] 凸多边形Convex polygons。分析函数 Triangulate 在凸多边形上的运行性能。
  3. 螺旋Spiral。继续分析图 1.28,函数 Triangulate 是否会继续遍历边界寻找耳朵尖?更具体地说,如果多边形有 \(n\) 个顶点, 在完成之前,指针 v2 会沿着边界完整地循环多少次?
  4. [Programming] 耳朵列表Ear list。通过将耳尖链接到一个单独的(循环)列表中,可以避免函数 Triangulate 的内循环搜索。 该列表将满足 v->ear == TRUE 的顶点 v 与顶点结构中的指针 nextear 和 prevear 连接起来。 迭代的过程中,可以通过该列表找到下一次迭代的目标耳尖。实现此改进,并验证其速度提升是否明显。
  5. 重心center of gravity。设计一个算法来计算多边形的重心,假设该多边形由均匀密度的材料切割而成。重心是一个点,可以用一个向量表示。 三角形的重心位于其质心(centroid)处,其坐标恰好是三角形各顶点坐标的平均值。两个不相交的集合 \(A,B\) 的并集 \(S\),其重心 \(\gamma(S)\) 是 \(A\) 和 \(B\) 两个部分重心的加权和。 记 \(S\) 的重量为 \(\omega(S) = \omega(A) + \omega(B)\),那么 $$ \gamma(S) = \frac{\omega(A) \gamma(A) + \omega(B) \gamma(B)}{\omega(S)} $$ 这里,每个三角形的权重是其在均匀密度假设下的面积。



Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1