Skip to content

Flower Dance - 题解

标签与难度

标签: 计算几何, 极角排序, 双指针, 叉积, 组合数学, 思维转换 难度: 2100

题目大意喵~

各位Master,晚上好喵~ 今天我们遇到的这道题,是在一个二维平面上进行的“花之舞”呐。

墙上有 NN 个不同的点,每个点都有自己的坐标 (x,y)(x, y)。LZR小哥发现了一个秘密:只要四个不同的点中,存在一个点严格位于另外三个点组成的非退化三角形内部,这四个点就能组成一朵美丽的“花”。

我们的任务就是,帮Gromah和LZR算出,这 NN 个点中,一共能组成多少朵这样的“花”呢?也就是找出满足条件的四元组 {i,j,k,l}\{i, j, k, l\} 的数量,喵~

解题思路分析

这道题的核心是识别一种特殊的四点共面几何构型,然后计数。一只我的直觉告诉我,直接去枚举所有四个点的组合肯定是不行的说!

1. 初步分析与思维转换

最朴素的想法是,枚举所有四个点的组合。从 NN 个点中选出4个,有 (N4)\binom{N}{4} 种方式。对于每种组合 {A,B,C,D}\{A, B, C, D\},我们再依次检查:D是否在 ABC\triangle ABC 内?C是否在 ABD\triangle ABD 内?... 这样做的时间复杂度是 O(N4)O(N^4),对于 N=1005N=1005 来说,肯定是会超时的,喵呜~

所以,我们需要换个角度来思考这个问题!

一个“花”的定义是“一个点在另外三个点组成的三角形内部”。这其实给了我们一个非常重要的几何直观:这四个点的凸包是一个三角形,而不是一个四边形!

我们可以不直接去数“花”的数量,而是换一个等价的计数对象。一朵“花” {A,B,C,D}\{A, B, C, D\},假设是点 DDABC\triangle ABC 内部,那么这个组合可以唯一地表示为 (点D, 三角形ABC) 这个配对。

于是,问题就巧妙地转化为了: 求所有“点P在三角形T内部”的配对 (P, T) 的总数量。

我们可以遍历每一个点,让它作为那个“内部点”,然后计算有多少个由其他点组成的三角形包含了它。把所有点作为“内部点”时的情况加起来,就是最终的答案啦!

2. “我”在三角形里面吗?—— 子问题的解决

现在,我们的问题变成了:固定一个点 OO (我们称之为原点),在剩下的 N1N-1 个点中,可以组成多少个三角形,能够把点 OO 包含在内部呢?

直接计算“包含”的三角形数量似乎有点困难。不过,我的智慧告诉我们,正难则反!我们可以计算有多少个三角形不包含OO,然后用总的三角形数量减去它。

剩下的 N1N-1 个点,总共可以组成 (N13)\binom{N-1}{3} 个三角形。

那么,一个三角形 ABC\triangle ABC 在什么情况下不包含原点 OO 呢? 为了方便分析,我们进行一个坐标变换,将点 OO 平移到坐标系的原点 (0,0)(0,0),其他所有点 PiP_i 也相应地平移为向量 OPi\vec{OP_i}

此时,如果 ABC\triangle ABC 不包含原点,当且仅当向量 OA,OB,OC\vec{OA}, \vec{OB}, \vec{OC} 都位于同一个以原点为顶点的、张角小于 180180^\circ 的角(也就是同一个半平面)内。

上图中,左边的 ABC\triangle A'B'C' 就不包含原点,因为三个顶点都在一个半平面里。而右边的 ABC\triangle ABC 就包含了原点。

3. 极角排序与双指针

如何高效地统计出所有顶点都在同一个半平面的“坏三角形”数量呢?这就要用到计算几何中的经典武器——极角排序双指针啦!

对于固定的原点 OO,我们把剩下的 N1N-1 个点(现在是向量了)按照绕 OO 点的极角进行逆时针排序。排好序的向量我们记为 v0,v1,,vm1v_0, v_1, \dots, v_{m-1} (其中 m=N1m = N-1)。

现在,我们来数“坏三角形”的数量。我们可以枚举其中一个顶点 viv_i,然后看有多少个三角形 (vi,vj,vk)(v_i, v_j, v_k) 是“坏”的。为了不重复计数,我们只考虑 j,kj, kviv_i 之后的情况(按逆时针顺序)。

一个三角形 (vi,vj,vk)(v_i, v_j, v_k) 是坏的,意味着 vi,vj,vkv_i, v_j, v_k 在同一个半平面。由于我们已经排好序了,如果我们固定 viv_i,那么所有和 viv_i 构成“坏三角形”的另外两个顶点 vj,vkv_j, v_k 一定都在从 viv_i 开始逆时针旋转 180180^\circ 的范围内。

我们可以用双指针来高效地完成这个统计:

  1. 为了处理环状结构(比如 vm1v_{m-1}v0,v1v_0, v_1 也能组成三角形),我们把排好序的向量数组复制一份接在后面。
  2. 我们用一个指针 i 遍历 v0v_0vm1v_{m-1},作为“坏三角形”的第一个顶点。
  3. 另一个指针 ji+1 开始向后移动。只要 vjv_j 还在以 viv_i 为起始边的半平面内,j 就继续前进。
  4. 如何判断 vjv_j 是否在 viv_i 的逆时针 180180^\circ 范围内呢?我们可以用叉积!只要 vi×vj>0\vec{v_i} \times \vec{v_j} > 0,就说明 vj\vec{v_j}vi\vec{v_i} 的逆时针方向,并且夹角小于 180180^\circ。(叉积等于0表示共线,也要特殊处理)。
  5. j 移动到不能再移动时,从 i 的下一个点到 j 的前一个点,这些点(假设有 count 个)都和 i 位于同一个半平面。从这 count 个点中任意选择两个,和 i 组合,都能形成一个“坏三角形”。数量就是 (count2)\binom{\text{count}}{2}
  6. 我们将 i 遍历一遍,把所有计算出的“坏三角形”数量加起来,就得到了以 OO 为原点时所有“坏三角形”的总数。

因为指针 j 在整个过程中只会单调向后移动,所以对于每个 i,我们不需要重置 j,这使得内层循环的总时间复杂度是 O(m)O(m) 的。

4. 算法总览

总结一下,我们的完整算法是这样的喵~

  1. 初始化总花朵数 total_flowers = 0
  2. 外层循环:遍历 NN 个点中的每一个点 P_k,让它当一次假想的“内部点” OO
  3. 内层计算: a. 创建一个包含 N1N-1 个向量的列表,每个向量是 PkPi\vec{P_k P_i}(对于所有 iki \neq k)。 b. 对这 N1N-1 个向量进行极角排序。 c. 使用双指针法,计算出有多少个“坏三角形”(顶点都在同一个半平面)。记为 bad_triangles。 d. 总的三角形数是 (N13)\binom{N-1}{3}。那么包含原点 OO 的“好三角形”数量就是 (N13)bad_triangles\binom{N-1}{3} - \text{bad\_triangles}。 e. 将这个数量累加到 total_flowers
  4. 循环结束后,total_flowers 就是我们要求的答案啦!

这个算法的复杂度是多少呢?外层循环 NN 次,内层排序是 O(NlogN)O(N \log N),双指针是 O(N)O(N)。所以总时间复杂度是 O(N2logN)O(N^2 \log N),对于 N=1005N=1005 来说是完全可以接受的!

代码实现

这是我根据上面的思路,精心重构的一份代码,希望能帮助Master更好地理解呐!

cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>

// 定义点的结构体,喵~
struct Point {
    long long x, y;

    // 重载减法运算符,方便计算向量
    Point operator-(const Point& other) const {
        return {x - other.x, y - other.y};
    }
};

// 计算两个向量的叉积,用来判断方向和角度
long long cross_product(Point a, Point b) {
    return a.x * b.y - a.y * b.x;
}

// 组合数 C(n, k) 的计算,这里我们只需要 C(n, 3)
long long combinations(int n, int k) {
    if (k < 0 || k > n) {
        return 0;
    }
    if (k == 0 || k == n) {
        return 1;
    }
    if (k > n / 2) {
        k = n - k;
    }
    long long res = 1;
    for (int i = 1; i <= k; ++i) {
        res = res * (n - i + 1) / i;
    }
    return res;
}

int main() {
    // 使用 stdio 可以更快地读写,喵~
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n;
    std::cin >> n;
    std::vector<Point> points(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> points[i].x >> points[i].y;
    }

    if (n < 4) {
        std::cout << 0 << std::endl;
        return 0;
    }

    long long total_flowers = 0;

    // 1. 外层循环:枚举每个点作为可能的“内部点” O
    for (int i = 0; i < n; ++i) {
        Point origin = points[i];
        std::vector<Point> others;
        // 2. 创建相对于原点 O 的向量
        for (int j = 0; j < n; ++j) {
            if (i == j) continue;
            others.push_back(points[j] - origin);
        }

        // 3. 极角排序
        // 排序规则:
        // a. 先按半平面分,上半平面(y>0 或 y=0,x>0)的点在前
        // b. 同一半平面内的点,按叉积(逆时针)排序
        std::sort(others.begin(), others.end(), [](Point a, Point b) {
            bool a_is_upper = (a.y > 0 || (a.y == 0 && a.x > 0));
            bool b_is_upper = (b.y > 0 || (b.y == 0 && b.x > 0));
            if (a_is_upper != b_is_upper) {
                return a_is_upper;
            }
            return cross_product(a, b) > 0;
        });

        // 4. 双指针计算“坏三角形”数量
        long long bad_triangles = 0;
        int m = others.size();
        if (m < 3) continue;

        int p2 = 1;
        for (int p1 = 0; p1 < m; ++p1) {
            // p2 指针从 p1 的下一个开始,如果它在 p1 的半平面内就前进
            // 为了处理循环,p2 可能小于 p1
            if (p1 == p2) p2 = (p2 + 1) % m;
            while (p1 != p2 && cross_product(others[p1], others[p2]) > 0) {
                p2 = (p2 + 1) % m;
            }
            
            // 从 p1 到 p2 之间的点(不含 p1, p2)有 count 个
            // 它们与 p1 都在同一个半平面
            int count = (p2 - p1 - 1 + m) % m;
            bad_triangles += combinations(count, 2);
        }
        
        // 5. 计算“好三角形”的数量并累加
        long long total_possible_triangles = combinations(m, 3);
        long long good_triangles = total_possible_triangles - bad_triangles;
        total_flowers += good_triangles;
    }

    std::cout << total_flowers << std::endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(N2logN)O(N^2 \log N)

    • 我们有一个外层循环,迭代 NN 次,每次选择一个点作为原点。
    • 在循环内部,最耗时的部分是极角排序,对 N1N-1 个点进行排序,时间复杂度为 O(NlogN)O(N \log N)
    • 随后的双指针扫描部分,两个指针 p1p2 在整个内层循环中都只绕一圈,所以是线性的 O(N)O(N)
    • 因此,总的时间复杂度是 N×(O(NlogN)+O(N))=O(N2logN)N \times (O(N \log N) + O(N)) = O(N^2 \log N),这对于题目给定的数据范围是足够快的,喵~
  • 空间复杂度: O(N)O(N)

    • 在每次外层循环中,我们需要一个额外的 std::vector 来存储 N1N-1 个向量。所以,我们使用的额外空间与 NN 成正比。

知识点总结

这道题是计算几何领域一道很好的练习题,它融合了多种思想和技巧,值得好好回味一下呐!

  1. 思维转换: 这是解题的关键!将一个看似困难的直接计数问题(数四元组),转化为一个更容易处理的等价问题(数“点-三角形”配对)。
  2. 补集思想: 当直接计算“好”的组合很困难时,尝试计算“坏”的组合,然后用总量去减。在这里就是用总三角形数减去不包含原点的三角形数。
  3. 极角排序: 计算几何中的基本操作。通过将点按角度排序,可以将几何问题转化为序列问题,从而使用更多序列上的算法(如双指针)。要注意使用基于叉积的整数比较,避免 atan2 带来的精度和速度问题。
  4. 叉积的应用: 叉积是二维几何的灵魂!它可以判断三个点的转向(共线、顺时针、逆时针),从而判断点与线的相对位置,以及向量间的夹角关系。
  5. 双指针: 在排好序的(环形)数组上,使用双指针可以高效地统计满足特定条件的区间或配对数量,将 O(N2)O(N^2) 的暴力枚举优化到 O(N)O(N)

希望这篇题解能帮到你,Master!如果还有不明白的地方,随时可以再来问这只我哦,喵~