CSP-S 提高级课程中心 / 数学进阶 / 第42讲
CSP-S 提高级 · 数学进阶 · s6-01

高中代数 与 几何要点

竞赛里的数学不是「额外负担」,而是「必备工具」:一道不等式能省掉一层循环,一个叉积能替代一堆分类讨论,一个 eps 能让浮点题不再玄学 WA。

8 大知识板块 1 套几何模板 eps 精度控制 3 个经典案例 12 道洛谷原题
本讲 · 第 42/52 讲高中代数与几何要点
模块 01

情景导航

用「数学建模社团的参赛日记」三个任务,把高中代数与几何的核心工具讲成一件很自然的事。

故事建模社团的参赛日记

你加入了学校的数学建模社团。这周社团要参加一项「校园场景优化」比赛,指导老师给每个小组发了三张任务卡,要求用程序算出结果。翻开卡片,你发现三张卡分别需要三种完全不同的数学工具。

任务一(羊圈改造):学校劳动基地要用一段总长为 L 米的栅栏(包括一堵现成的墙)围出一块矩形菜地,要求尽可能大。请问长宽各取多少时面积最大?最大面积是多少?

任务二(测量旗杆):操场上有一根旗杆,底端被花坛挡住无法直接测量。你在距离旗杆底端 a 米和 b 米的两点分别测得旗杆顶端的仰角为 α 和 β(两点在旗杆同一侧且与旗杆底端共线)。请算出旗杆的高度。

任务三(雷达警戒区):校园安防系统里,三个传感器 A、B、C 围成一个三角形的警戒区。给定一个移动目标点 P 的坐标,请判断 P 是否在三角形 ABC 内部(含边界),并给出其到三条边的相对方位。

三张卡片,对应三类核心数学工具。

• 任务一是「代数求最值」:可以设长为 x,则面积是一个关于 x 的二次函数,用顶点公式或均值不等式一步到位;
• 任务二是「解三角形」:已知两点距离与两个仰角,本质是「已知一边与两角求另一条边」,用正弦定理或余弦定理即可;
• 任务三是「计算几何」:判断点在直线的哪一侧,正是叉积的符号在说话——只要 P 与 C 在直线 AB 的同侧、P 与 A 在直线 BC 的同侧、P 与 B 在直线 CA 的同侧,P 就在三角形内部。

代数管「算最值」,三角管「算长度与角度」,向量管「算方位与面积」——这是竞赛数学工具箱的三层地基。

💡 故事背后的思想

共同点:三道题的共同点是——「用数学公式把手算的过程压缩成几行代码」。如果不用这些工具,任务一得写三层循环枚举长宽,任务三得写七八个 if 分类讨论;而用上二次函数、余弦定理和叉积之后,每个任务的核心代码都不超过 10 行。

为什么竞赛必须学这些?因为很多题目的「暴力做法」和「正解」之间的差距,不是算法层面的,而是数学层面的。比如「求和 1+2+…+n」如果不背公式就要循环 n 次;「判断点在三角形内」如果不学叉积就要写一堆面积比较。数学工具能让你在 思路层面就领先一步。

本讲的学习目标:① 能快速识别题目中的「数学结构」(二次函数?等差等比?三角形?向量?);② 能默写核心公式;③ 能把公式翻译成正确的、没有精度陷阱的代码。第三点最容易被忽视,也最容易在考场上吃亏。

①生活类比
🏹

抛物线投篮 = 二次函数

篮球出手后的轨迹是一条抛物线,最高点在哪、能飞多远,都是顶点坐标说了算。

→ 顶点公式 −b/2a
📏

卷尺不够长 = 解三角形

两堵墙之间隔着水池量不到,那就量两个角度、再量一段能测的距离,用正余弦定理把答案算出来。

→ 正弦 / 余弦定理
🧭

左转右转 = 叉积符号

走路时判断「目标在我左边还是右边」,本质上就是在算一个二维叉积的正负。

→ 叉积定左右
🔬

游标卡尺 = eps

再精密的尺子也读不出「完全相等」,所以工程上判断相等要允许一个误差范围。

→ |a − b| < eps
②核心定义

二次函数与判别式:y = ax² + bx + c(a ≠ 0),其图象是一条抛物线,对称轴为 x = −b/(2a),顶点纵坐标为 (4ac − b²)/(4a)。判别式 Δ = b² − 4ac 决定方程 ax² + bx + c = 0 的实根个数(Δ > 0 两个、Δ = 0 一个、Δ < 0 没有)。

均值不等式(基本不等式):对于正数 a, b,有 a + b ≥ 2√(ab),当且仅当 a = b 时取等号。推广到 n 个正数:(a₁+…+aₙ)/n ≥ (a₁a₂…aₙ)^(1/n)。

等差数列与等比数列:等差数列 aₙ = a₁ + (n−1)d,前 n 项和 Sₙ = n(a₁+aₙ)/2 = na₁ + n(n−1)d/2;等比数列 aₙ = a₁q^(n−1),Sₙ = a₁(1−qⁿ)/(1−q)(q ≠ 1)。

指数与对数:log_a(MN) = log_a M + log_a N,log_a(M/N) = log_a M − log_a N,log_a(M^k) = k·log_a M,换底公式 log_a b = log_c b / log_c a。竞赛里最常用的是 log₂n 用于估算「二进制位数 / 倍增层数」。

三角函数:弧度制下单位圆上角度 θ 对应点 (cos θ, sin θ);sin²θ + cos²θ = 1;和角公式 sin(α±β) = sin α cos β ± cos α sin β。

正弦定理与余弦定理:在 △ABC 中(a、b、c 分别是角 A、B、C 的对边,R 为外接圆半径):
正弦定理 a / sin A = b / sin B = c / sin C = 2R;
余弦定理 c² = a² + b² − 2ab·cos C,等价变形 cos C = (a² + b² − c²) / (2ab)。

向量、点积与叉积:设 a = (x₁, y₁),b = (x₂, y₂)。
点积(内积)a·b = x₁x₂ + y₁y₂ = |a||b|cos θ,用来判断夹角与垂直(a·b = 0 ⟺ 垂直);
叉积(外积、二维叉积)a × b = x₁y₂ − x₂y₁ = |a||b|sin θ,其符号表示 b 在 a 的左侧(正)还是右侧(负),其绝对值等于以 a、b 为邻边的平行四边形的面积。

浮点精度 eps:由于 double 只能表示约 15~16 位有效十进制数字,运算会有舍入误差,因此浮点数之间不能用 == 直接比较,而要用 fabs(a − b) < eps。取 eps = 1e-8 是竞赛中的通用做法。

📌 本讲核心知识地图

板块核心内容竞赛中的用途
二次函数Δ = b²−4ac;顶点 −b/2a;韦达定理求最值、判根个数、二分找根
不等式a+b ≥ 2√(ab);一元二次不等式;柯西不等式求极值、估算、复杂度判断
数列等差 / 等比通项与求和;裂项相消O(1) 求前缀和、推导公式
指数对数运算法则、换底、log₂ 估算复杂度分析、倍增层数、快速幂
三角函数单位圆、和角公式、弧度制旋转向量、角度计算
解三角形正弦定理、余弦定理、海伦公式已知边角求边角、面积
平面几何相似、勾股、五心、鞋带公式多边形面积、几何关系推导
向量运算点积(角度/垂直)、叉积(方位/面积)判断方位、判断凸性、求面积
浮点 epsfabs(a−b) < eps;eps = 1e-8浮点比较、判零、判共线
③核心口诀
🔑 代数诀

二次函数看顶点,判别式定根的个数;求和先看等差等比,能套公式就别写循环。
不等式求最值记住「一正二定三相等」——变量为正、和或积为定值、别忘了验证取等条件。

🔑 三角诀

「已知两角一边用正弦,已知两边夹角用余弦」;求面积用 S = ½ab·sin C,已知三边用海伦公式。

🔑 向量诀

点积管角度和垂直,叉积管方位和面积。
叉积 > 0 在左、< 0 在右、= 0 共线;多边形面积用「鞋带公式」,逆时针为正。

模块 02

知识探究

八个板块、八套工具,每个都按「公式 → 几何直觉 → 竞赛用法 → 代码模板」四步讲透。

1️⃣二次函数与判别式:最值问题的第一把钥匙

标准形式:y = ax² + bx + c(a ≠ 0),配方得顶点式 y = a(x + b/(2a))² + (4ac − b²)/(4a)。

要问的问题公式备注
对称轴在哪x = −b/(2a)a > 0 时是最小值点,a < 0 时是最大值点
顶点(最值)是多少y = (4ac − b²)/(4a)也可以直接把 −b/(2a) 代回原式算
与 x 轴有几个交点Δ = b² − 4acΔ > 0 两个、Δ = 0 一个、Δ < 0 没有
两根之和、两根之积x₁+x₂ = −b/a,x₁x₂ = c/a韦达定理,不必解出根就能用
两根分别是多少x = (−b ± √Δ) / (2a)用于二分找根、因式分解

💡 竞赛中怎么用:不要盲目套顶点公式

如果题目给的是区间 [L, R] 上的最大值 / 最小值,那就要先看对称轴在不在区间内:
· 对称轴在区间内 → 极值点在对称轴处;
· 对称轴在区间外 → 最值在区间端点处(这时函数在区间上是单调的)。
这是「二次函数在闭区间上求最值」的标准分类讨论,也是很多优化题的数学基础(比如斜率优化的凸性分析、三分法求极值的理论基础)。

另外,判别式还有一个高频用途:判断「某个值能否取到」。例如求函数 y = (x²+1)/(x+1) 的值域,可以反解成 x² − yx + (1 − y) = 0,然后要求 Δ = y² − 4(1 − y) ≥ 0。这种「反解 + 判别式」的技巧在求分式函数值域时非常好用。

C++二次函数在闭区间 [L, R] 上的最值(分类讨论版)
// 求 f(x) = a*x^2 + b*x + c 在 [L, R] 上的最小值(a != 0)
double quadMin(double a, double b, double c, double L, double R) {
    double axis = -b / (2 * a);          // 对称轴
    if (a > 0) {                          // 开口向上,顶点是最小值点
        if (axis < L) return a*L*L + b*L + c;      // 单调递增,取左端点
        if (axis > R) return a*R*R + b*R + c;      // 单调递减,取右端点
        return a*axis*axis + b*axis + c;            // 顶点在区间内
    } else {                              // 开口向下,最小值必在两端点之一
        return min(a*L*L + b*L + c, a*R*R + b*R + c);
    }
}

一个必须记住的「竞赛化」用法:很多题目要你「在某个范围内找最优值」。如果函数是凸的(二次函数 a > 0 就是凸的),就可以用三分法在 O(log n) 内求极值——这比逐个枚举快得多。三分法的正确性完全依赖函数的单峰性(先减后增或先增后减),而二次函数正是最典型的单峰函数。

2️⃣不等式与最值:不用写循环也能求出极值

均值不等式(最重要):对正数 a, b,a + b ≥ 2√(ab),当且仅当 a = b 时取等号。

它为什么能求最值?因为它把「和」与「积」联系起来了:
· 若 和为定值 S,则积的最大值为 (S/2)²;
· 若 积为定值 P,则和的最小值为 2√P。

「一正二定三相等」——用均值不等式必须检查的三件事:
一正:参与运算的变量必须都是正数(否则不等式失效);
二定:和或积必须有一个是常数(定值),否则求不出最值;
三相等:必须验证「a = b」这个条件在题目允许的范围内能否取到。取不到等号的话,最值就不在等号处,需要改用函数单调性分析。

最值问题的三条路线

① 二次函数 / 判别式:如果能整理成二次式,直接用顶点公式。
② 均值不等式:如果能构造出「和定求积最大」或「积定求和最小」,一步出答案。
③ 单调性 / 三分法:如果函数是单峰的但不能用前两种,用三分法在 O(log n) 内逼近极值。

竞赛中最常见的三个不等式

均值不等式:a+b ≥ 2√(ab)(最常用)。
柯西不等式:(Σaᵢbᵢ)² ≤ (Σaᵢ²)(Σbᵢ²),用于证明与估算。
排序不等式:同序和 ≥ 乱序和 ≥ 逆序和(贪心交换论证的理论依据)。
竞赛里以「均值不等式」和「柯西不等式」出现频率最高。

C++典型应用:和为定值时求积最大(洛谷 P1887 乘积最大3 的思路)
// 把 n 拆成 m 个正整数之和,使它们的乘积最大
// 结论:各数尽量平均 —— 差不超过 1 时乘积最大(均值不等式的直接推论)
int q = n / m, r = n % m;

// 前 r 个数为 q+1,其余为 q
for (int i = 1; i <= r; i++) cout << q + 1 << " ";
for (int i = r + 1; i <= m; i++) cout << q << " ";

// 为什么?由 a+b >= 2*sqrt(ab),和为定值时两项越接近积越大
// 多变量的推广:n 个数越平均,乘积越大(均值不等式的 n 元形式)
🔑 不等式诀

一正二定三相等;和定积最大、积定和最小;取等条件必须能在题目范围内实现。

3️⃣数列与求和:把 O(n) 循环压成 O(1) 公式

等差数列:相邻两项之差为常数 d。
通项 aₙ = a₁ + (n − 1)d;前 n 项和 Sₙ = n(a₁ + aₙ)/2 = n·a₁ + n(n−1)d/2。

等比数列:相邻两项之比为常数 q(q ≠ 0)。
通项 aₙ = a₁·q^(n−1);前 n 项和 Sₙ = a₁(1 − qⁿ)/(1 − q)(q ≠ 1),Sₙ = n·a₁(q = 1)。

竞赛中最常用的几个求和公式(建议直接记住):

求和式结果典型用途
1 + 2 + … + nn(n+1)/2等差数列、计数
1² + 2² + … + n²n(n+1)(2n+1)/6平方和、方差
1³ + 2³ + … + n³[n(n+1)/2]²立方和(等于和的平方!)
1 + 2 + 4 + … + 2ⁿ⁻¹2ⁿ − 1倍增、完全二叉树节点数
1/2 + 1/4 + … + 1/2ⁿ1 − 1/2ⁿ概率期望、收敛级数
1 + 1/2 + 1/3 + … + 1/n≈ ln n + γ调和级数,估计复杂度

「裂项相消」——竞赛中最优雅的求和技巧:把每一项拆成两个「相邻形式」的差,求和时中间项全部抵消,只剩首尾。

C++经典裂项:1/(1·2) + 1/(2·3) + … + 1/(n(n+1))
// 关键恒等式:1/(k(k+1)) = 1/k - 1/(k+1)
// 于是 S_n = (1/1 - 1/2) + (1/2 - 1/3) + ... + (1/n - 1/(n+1))
//           = 1 - 1/(n+1) = n/(n+1)

// 代码实现(注意:不要真的去循环,直接用公式!)
int n; cin >> n;
printf("%.6f\n", (double)n / (n + 1));

其它常用的裂项形式:
· 1/(√k + √(k+1)) = √(k+1) − √k(根式裂项);
· C(n,k) = C(n−1,k−1) + C(n−1,k)(组合数递推,杨辉三角);
· k·k! = (k+1)! − k!(阶乘裂项)。

💡 为什么竞赛选手要背这么多公式?

因为很多题目的「暴力做法」和「正解」之间,差距就是「循环」与「公式」的区别。例如「求 1 到 n 的平方和」,循环是 O(n);而公式是 O(1),当 n 达到 10¹⁸ 时,公式是唯一的出路。

另外一个重要用途是复杂度分析:知道 1+2+…+n = O(n²)、Σ C(n,k) = 2ⁿ、Σ_{i=1}^{n} 1/i = O(log n) 之后,你才能在写代码前就判断出「这个算法会不会超时」。会背求和公式的人,写代码之前就知道自己能不能过。

4️⃣指数与对数:复杂度分析与快速幂的数学基础

「加法对乘法、乘法对指数」——这是理解对数最核心的一句话。

运算法则公式直觉
积的对数 = 对数的和log(MN) = log M + log N「乘」在「对数世界」里变成「加」
商的对数 = 对数的差log(M/N) = log M − log N「除」变成「减」
幂的对数 = 对数乘指数log(M^k) = k·log M「乘方」变成「乘法」
换底公式log_a b = log_c b / log_c a不同底数之间自由转换
对数恒等式a^(log_a b) = b指数与对数互逆

竞赛中最重要的一句话:log₂ n 就是「n 用二进制表示需要多少位」,也是「对 n 反复除以 2 需要多少次才能变成 1」。

这句话直接决定了三类算法的复杂度:
· 二分查找:每次砍掉一半,最多 log₂n 轮 → O(log n);
· 倍增:2^k 级祖先的层数 k ≤ log₂n → 预处理 O(n log n);
· 快速幂:指数每次右移一位,最多 log₂n 轮 → O(log n)。

C++快速幂 —— 指数与对数思想的直接产物
// 计算 a^b mod m,b 可达 10^18
// 核心思想:a^b = (a^(b/2))^2  (b 为偶)  或  a * (a^(b/2))^2  (b 为奇)
// 即把「b 次乘法」压缩成「log2(b) 轮平方」
long long qpow(long long a, long long b, long long m) {
    long long res = 1 % m;
    a %= m;
    while (b) {
        if (b & 1) res = res * a % m;   // 这一位是 1,就把当前的 a 乘进去
        a = a * a % m;                    // a 自己平方,对应「指数翻倍」
        b >>= 1;                          // 指数右移一位
    }
    return res;
}

为什么快?因为 b 每次右移一位,所以循环次数是 ⌊log₂b⌋ + 1。当 b = 10¹⁸ 时,循环只有 60 次——原来需要 10¹⁸ 次乘法,现在只要 60 次。这就是「指数 → 对数」带来的降维打击。

💡 一个实用技巧:用 log 估算数据规模

竞赛中经常需要快速判断「某个数有多少位」或者「2 的多少次方够用」:
· n 的十进制位数 = ⌊log₁₀ n⌋ + 1;
· n 的二进制位数 = ⌊log₂ n⌋ + 1,在 C++ 里可以用 __lg(n) + 1 或 31 - __builtin_clz(n) 快速求出;
· n 个点的完全二叉树高度 = ⌊log₂ n⌋ + 1;
· 二分答案需要 ⌈log₂(值域)⌉ 轮,值域 10⁹ 时约 30 轮,值域 10¹⁸ 时约 60 轮。
把这些脱口而出,你就能在读完题之后立刻判断出「二分几次够」「倍增开几层」。

5️⃣三角函数与正余弦定理:从单位圆出发

单位圆是所有三角公式的源头:把角 θ 的顶点放在原点、始边放在 x 轴正半轴,则终边与单位圆的交点坐标就是 (cos θ, sin θ)。

P(cos θ, sin θ) O cos θ sin θ θ x y

从这张图能直接读出四个关键信息:

① 横坐标是 cos θ(绿线),纵坐标是 sin θ(红线)。

② 由勾股定理 cos²θ + sin²θ = 1——这正是「单位圆上任意一点到原点的距离都是 1」。

③ sin θ / cos θ = tan θ,即终边所在直线的斜率。

④ 角的终边绕一圈回到原处时,函数值重复——这就是「周期为 2π」的几何来源。

竞赛用法:把「旋转一个点」写成 (x·cosθ − y·sinθ, x·sinθ + y·cosθ),这是旋转卡壳、最小包围圆等几何算法的核心操作。

两大解三角形定理(本节的重中之重):在 △ABC 中,a、b、c 分别为角 A、B、C 的对边。

定理公式什么时候用
正弦定理a/sin A = b/sin B = c/sin C = 2R已知两角一边,或已知两边及其中一边的对角
余弦定理c² = a² + b² − 2ab·cos C已知两边及其夹角,或已知三边求角
面积公式(两边夹角)S = ½·ab·sin C已知两边及夹角求面积
海伦公式(三边)S = √(p(p−a)(p−b)(p−c)),p = (a+b+c)/2已知三边求面积
余弦定理的变形cos C = (a² + b² − c²) / (2ab)由三边求角的余弦值
✅ 用余弦定理算角度的两个好处

① 只需要三边长度,不需要任何角度信息;② 得到的直接是 cos 值,用 acos() 一转换就是弧度,避开了「正弦值对应两个角」的歧义(sin 135° = sin 45°,反三角函数会给出错误的那一个)。

❌ 用正弦定理算角度要小心「两解」

由 sin C = 某值 反解角度时,锐角和钝角的正弦值可能相同,所以会有两个候选。判断哪一个正确需要额外信息(如「大边对大角」)。能用余弦定理就别用正弦定理求角。

C++解三角形的 C++ 实现(含角度制与弧度制转换)
const double PI = acos(-1.0);              // 高精度 π
double toDeg(double rad) { return rad * 180.0 / PI; }
double toRad(double deg) { return deg * PI / 180.0; }

// 余弦定理:由三边求角 C(返回弧度)
double angleFromSides(double a, double b, double c) {
    // c 是角 C 的对边
    double cosC = (a*a + b*b - c*c) / (2*a*b);
    cosC = max(-1.0, min(1.0, cosC));       // ★ 关键:夹紧到 [-1,1] 防 acos 出 nan
    return acos(cosC);
}

// 两边夹角求面积
double areaBySAS(double a, double b, double C) {
    return 0.5 * a * b * sin(C);
}

// 海伦公式(三边求面积)
double areaByHeron(double a, double b, double c) {
    double p = (a + b + c) / 2;
    return sqrt(p * (p - a) * (p - b) * (p - c));
}

⚠️ cosC 必须夹紧到 [−1, 1] —— 这是计算几何的头号陷阱

理论上 cos C ∈ [−1, 1],但由于浮点误差,实际算出来可能是 1.0000000001 或 −1.0000000002。
而 acos(1.0000000001) 会返回 NaN(不是 −1)!接着 NaN 会像传染病一样扩散到整个程序,最后输出 nan 或者莫名奇妙的数。

正确做法只有一句:cosC = max(-1.0, min(1.0, cosC));。
推而广之:所有传给 acos() / asin() 的值都必须先夹紧。这是计算几何题「本地对,交上去 WA」的常见原因之一。

6️⃣平面几何核心结论:三角形与多边形

三角形的「五心」(竞赛中常考,尤其是外心和重心):

名称定义关键性质
重心三条中线的交点坐标 = 三顶点坐标的平均值 ((x₁+x₂+x₃)/3, …)
垂心三条高的交点与重心、外心共线(欧拉线)
外心三边垂直平分线的交点到三顶点等距,是外接圆的圆心,半径 R
内心三条角平分线的交点到三边等距,是内切圆的圆心,半径 r
旁心一内角与两外角平分线的交点到三边所在直线等距(共三个)

常用几何结论速查:
· 勾股定理:直角三角形中 a² + b² = c²(c 为斜边);
· 三角形面积:S = ½·底·高 = ½ab·sin C = abc/(4R) = pr(p 为半周长,r 为内切圆半径);
· 重心坐标(三顶点 A、B、C):G = ((x₁+x₂+x₃)/3, (y₁+y₂+y₃)/3);
· 外心半径:R = a/(2 sin A)(正弦定理直接给出);
· 相似三角形:对应边成比例、面积比为相似比的平方;
· 切割线定理 / 相交弦定理:圆幂 PA·PB = PC·PD。

多边形面积:鞋带公式(Shoelace Formula)——必须掌握

给定按顺序排列的多边形顶点 (x₁,y₁), (x₂,y₂), …, (xₙ,yₙ)(顺时针或逆时针均可),则:

2S = |Σi=1..n (xᵢ·yi+1 − xi+1·yᵢ)| (其中 xn+1 = x₁)

为什么它成立?因为 Σ(xᵢ·yi+1 − xi+1·yᵢ) 正好是「以原点为顶点、把多边形拆成的所有三角形的有向面积之和」。凸多边形的所有三角形同号,凹多边形的多余部分会自动抵消——这就是公式对凹多边形也成立的原因。

C++鞋带公式求多边形面积(含方向判断)
// poly 按顺序存放 n 个顶点,返回「有向面积」的 2 倍
// 结果 > 0 表示顶点是逆时针排列,< 0 表示顺时针排列
double polygonArea2(vector<Point>& poly) {
    int n = poly.size();
    double s = 0;
    for (int i = 0; i < n; i++) {
        int j = (i + 1) % n;               // 下一个顶点(循环回到第一个)
        s += poly[i].x * poly[j].y - poly[j].x * poly[i].y;
    }
    return s;                            // 2S(带符号)
}

// 只要面积绝对值:fabs(polygonArea2(poly)) / 2.0
// 判断顶点顺序:polygonArea2(poly) > 0 ? 逆时针 : 顺时针
🔑 几何诀

重心是三点平均,外心到顶点等距,内心到三边等距;多边形面积用鞋带公式,逆时针为正、顺时针为负。

7️⃣向量、点积与叉积:计算几何的两把手术刀

设 a = (x₁, y₁),b = (x₂, y₂)。这是本讲最需要背熟的两个公式:

运算公式几何意义竞赛用途
点积(内积)a·b = x₁x₂ + y₁y₂= |a||b|cos θ判断垂直、求夹角、投影
叉积(外积)a × b = x₁y₂ − x₂y₁= |a||b|sin θ判断方位、求面积、判断凸性

叉积符号的三种含义(考点):

叉积符号几何含义
a × b > 0b 在 a 的逆时针方向(左转),即「从 a 转到 b 是逆时针」
a × b < 0b 在 a 的顺时针方向(右转)
a × b = 0a 与 b 共线(同向或反向)

叉积的另一个重要用途:求面积。以 a、b 为邻边的平行四边形面积 = |a × b|,所以三角形面积 = |a × b| / 2。

更常用的是「用三点算三角形有向面积」:设 A、B、C 三点,则

2·S有向(ABC) = (B − A) × (C − A) = (x_B−x_A)(y_C−y_A) − (y_B−y_A)(x_C−x_A)

这个式子的符号直接告诉你「C 在直线 AB 的哪一侧」——正是「判断点在三角形内」的基础。

🎬 交互演示:叉积符号与「左 / 右 / 共线」的对应关系
1固定 a = (1, 0)(指向 x 轴正方向的单位向量),准备比较它和不同向量 b 的叉积。
2取 b = (0, 1)(指向正上方)。a × b = 1×1 − 0×0 = 1 > 0,说明 b 在 a 的左侧(逆时针 90°)。
3取 b = (0, −1)(指向正下方)。a × b = 1×(−1) − 0×0 = −1 < 0,说明 b 在 a 的右侧(顺时针 90°)。
4取 b = (2, 0)(与 a 同向)。a × b = 1×0 − 0×2 = 0,说明 两者共线(同向)。
5取 b = (−3, 0)(与 a 反向)。a × b = 1×0 − 0×(−3) = 0,同样为 0——叉积无法区分「同向」和「反向」,这需要靠点积来帮忙。
6关键结论:叉积管「左右」,点积管「前后」。判断方位用叉积,判断锐角/钝角用点积。a·b > 0 是同向锐角,a·b < 0 是反向钝角。
7回到「点在三角形内」:P 在 △ABC 内的充要条件是——(B−A)×(P−A)、(C−B)×(P−B)、(A−C)×(P−C) 三者同号(或其中有 0 表示在边上)。这就是本讲案例 3 的核心。
提示:把这三步总结成一句话——「三个叉积同号,点就在三角形里面」。
C++几何模板:点 / 向量结构体与全套基本运算
struct Point {
    double x, y;
    Point(double x = 0, double y = 0) : x(x), y(y) {}

    // ---- 向量加减与数乘 ----
    Point operator+(const Point& p) const { return Point(x + p.x, y + p.y); }
    Point operator-(const Point& p) const { return Point(x - p.x, y - p.y); }
    Point operator*(double k) const   { return Point(x * k, y * k); }

    // ---- 点积(内积)----
    double dot(const Point& p) const { return x * p.x + y * p.y; }

    // ---- 叉积(外积)----  >0: p 在左侧; <0: 右侧; =0: 共线
    double cross(const Point& p) const { return x * p.y - y * p.x; }

    // ---- 模长与到另一点的距离 ----
    double len() const { return sqrt(x * x + y * y); }
    double dist(const Point& p) const { return (*this - p).len(); }

    // ---- 逆时针旋转 rad 弧度 ----
    Point rot(double rad) const {
        double c = cos(rad), s = sin(rad);
        return Point(x * c - y * s, x * s + y * c);
    }
};

// ---- 三角形有向面积的两倍(符号表示 C 在 AB 的哪一侧)----
double cross2(Point A, Point B, Point C) {
    return (B - A).cross(C - A);
}

// ---- 判断三点是否共线(利用 inSegment 前先判共线)----
bool collinear(Point A, Point B, Point C, double eps = 1e-8) {
    return fabs(cross2(A, B, C)) < eps;
}

💡 一句话区分点积与叉积

点积的结果是一个数,衡量「两个方向有多一致」;叉积的结果也是一个数,衡量「两个方向有多垂直(并带上左右信息)」。
· 要判断「垂不垂直」「夹角是锐角还是钝角」→ 用点积;
· 要判断「左还是右」「顺指针还是逆时针」「多边形是不是凸的」→ 用叉积。
考试时如果一时想不起来该用哪个,就问自己一句:「我要的是前后信息,还是左右信息?」

8️⃣浮点精度与 eps:让「玄学 WA」变成可控问题

为什么浮点比较不能用 ==?因为计算机用二进制存储小数,0.1 + 0.2 在 double 下并不等于 0.3,而是约等于 0.30000000000000004。浮点运算必然有舍入误差,直接判等几乎一定会失败。

要做的判断❌ 错误写法✅ 正确写法
两个数是否相等a == bfabs(a − b) < eps
是否为 0x == 0fabs(x) < eps
是否大于 0x > 0x > eps
是否小于等于 0x <= 0x < eps
三点是否共线cross == 0fabs(cross) < eps
浮点数组取最值if (a[i] > mx)if (a[i] > mx + eps)
0.1 + 0.2
≠ 0.3
差 ≈ 5.6e-17
< 1e-8 ✓

依次演示:0.1 + 0.2 与 0.3 并不严格相等,但两者之差约 5.6×10⁻¹⁷,远小于 eps = 1e-8,所以用 eps 判断就「相等」了。

eps 取多大?竞赛中的通用做法是 eps = 1e-8。经验法则:
· eps 要比「题目要求的输出精度」小两三个数量级(题目要求保留 6 位小数,就用 1e-8);
· eps 要比「运算累积误差」大若干个数量级。若一次运算的误差约 1e-15,而你可能做了 10⁵ 次累加,累积误差可能到 1e-10——此时 eps = 1e-8 仍然安全;
· 如果题目要求 1e-10 级别的高精度,改用 long double(精度约 18~19 位有效数字)。

C++浮点题目的标准工程写法
const double PI  = acos(-1.0);      // 高精度 π
const double eps = 1e-8;            // 通用误差限

int sgn(double x) {                    // 三态符号函数:-1 / 0 / 1
    if (x >  eps) return 1;
    if (x < -eps) return -1;
    return 0;
}
bool eq(double a, double b) { return fabs(a - b) < eps; }

// ---- 二分实数:用固定迭代次数,而不是 while (r - l > eps) ----
double lo = 0, hi = 1e9;
for (int it = 0; it < 100; it++) {   // 100 次迭代,精度足以到 1e-30
    double mid = (lo + hi) / 2;
    if (check(mid)) lo = mid; else hi = mid;
}
printf("%.6f\n", lo);                // 按题目要求输出位数

💡 三个最实用的浮点技巧

① 二分用「固定次数」而不是「精度判据」。while (r − l > 1e-10) 在某些情况下可能因为浮点精度耗尽而死循环;写 for (int it = 0; it < 100; it++) 更安全,且精度可控(每轮区间减半,100 轮后精度 = 初始区间 / 2¹⁰⁰)。

② 避免「大数相减」。若两个数都是 10⁹ 量级,它们的差只有 1,那么相减后的有效数字只剩 1 位左右,误差极大。能先化简式子就先化简,例如把 (a² − b²) 写成 (a−b)(a+b) 通常不会更好;但把「大数比较大小」改成「比较它们与某个基准的差」往往能救命。

③ 能用整数就别用浮点。判断两数乘积是否相等,可以交叉相乘转成整数比较;判断点在直线上,如果坐标都是整数,可以用整数叉积判断「是否为 0」,完全没有误差。「整数化」是规避浮点误差最彻底的手段。

🔑 精度诀

浮点不用 == ,一律 eps = 1e-8;传给 acos 先夹紧,二分用固定次数;能量化成整数就绝不用浮点。

案例 1均值不等式估算最值:羊圈改造问题
入门代数求最值

用一段长为 L 米的栅栏和操场上现成的一堵长墙,围出一块矩形菜地(墙作为矩形的一边,不需要栅栏)。求菜地的最大面积,以及此时矩形的长与宽。

输入:一个正整数 L(栅栏总长,L ≤ 10⁹)。
输出:三行,分别为最大面积、垂直于墙的边长、平行于墙的边长,保留 2 位小数。

样例输入:20 样例输出:50.00 / 5.00 / 10.00

💡 题目分析

建模:设垂直于墙的两条边各长 x(这两条边需要栅栏),则平行于墙的那条边长为 L − 2x(它贴着墙,不需要栅栏)。
菜地面积 S(x) = x(L − 2x) = −2x² + Lx,其中 0 < x < L/2。

方法一:二次函数顶点公式 O(1)
S(x) = −2x² + Lx 开口向下,对称轴 x = −L/(2×(−2)) = L/4。
代入得最大值 S_max = −2(L/4)² + L(L/4) = −L²/8 + L²/4 = L²/8。

方法二:均值不等式 更优雅 推荐
凑出「和为定值」的形式:2S = 2x · (L − 2x)。
注意到 2x + (L − 2x) = L 是定值,所以由 a + b ≥ 2√(ab):
2x · (L − 2x) ≤ ((2x + (L−2x))/2)² = (L/2)² = L²/4
故 S ≤ L²/8。取等条件:2x = L − 2x,即 x = L/4——在 0 < x < L/2 范围内,取等可以成立,所以 L²/8 就是真正的最大值。

「一正二定三相等」的三项检查:
一正:2x > 0 且 L − 2x > 0(因为 x < L/2)✅;
二定:两者之和恒等于 L ✅;
三相等:x = L/4 落在可行域内 ✅。
三项全过,结论成立。

C++两种解法对照
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long L; cin >> L;

    // 方法一:二次函数顶点公式   x = L/4
    double x1 = L / 4.0;
    double s1 = x1 * (L - 2 * x1);

    // 方法二:均值不等式   S_max = L^2/8,取等时 x = L/4
    double x2 = L / 4.0;
    double s2 = (double)L * L / 8.0;

    printf("%.2f\n%.2f\n%.2f\n", s2, x2, (double)L - 2 * x2);
    return 0;
}
运行结果
输入: 20
输出: 50.00
      5.00
      10.00

验证: 垂直于墙的两边各 5 米,共耗栅栏 10 米
      平行于墙的一边 10 米(贴着墙,不耗栅栏)
      总面积 = 5 × 10 = 50 平方米
      由公式 L²/8 = 400/8 = 50 ✅

输入: 100
输出: 1250.00
      25.00
      50.00

💭 如果题目改成「四面都要栅栏」会怎样?

那就变成「周长为定值 L 的矩形,求最大面积」。此时长宽之和为 L/2(定值),由均值不等式,面积 S ≤ (L/4)² = L²/16,最大值在正方形(长 = 宽 = L/4)时取到。

对比一下:「一面靠墙」的最大面积 L²/8 恰好是「四面围栏」L²/16 的两倍。这个结论非常直观——少用了一半的栅栏去围同样长的边,效率自然翻倍。竞赛中很多最优化问题都能这样「用数学直觉先估个数量级」,再用代码精确计算。

另一个常见变形:如果要求「长不超过宽的 2 倍」,那么 x = L/4 这个最优解可能落在可行域之外,此时最值就会在约束边界上取到。这正是「二次函数在闭区间求最值」要考虑的分类讨论。

案例 2余弦定理求角度:三点构成的夹角
经典解三角形

平面上给三个点 A、B、C 的坐标,求角 A(即 ∠BAC)的大小(用角度制输出,保留 4 位小数),并计算三角形 ABC 的面积。

输入:六个数 xA, yA, xB, yB, xC, yC(|坐标| ≤ 10⁴)。
输出:两行,第一行为角 A 的度数,第二行为三角形面积,均保留 4 位小数。

样例输入:0 0 5 0 2 4 样例输出:63.4349 / 10.0000

💡 题目分析

思路一:余弦定理 通用

角 A 的两条边是 AB 和 AC,对边是 BC。长度分别是:
a' = |BC|(角 A 的对边)、b' = |AC|、c' = |AB|。
由余弦定理:cos A = (b'² + c'² − a'²) / (2·b'·c'),再用 acos() 转成弧度,最后乘 180/π 得到度数。

思路二:点积公式(本质是余弦定理的向量版) 更简洁 推荐

由点积的定义 u·v = |u||v|cos θ,直接得到
cos A = ((B−A)·(C−A)) / (|AB| · |AC|)。
这与余弦定理完全等价,但写起来更短,而且不需要单独计算 BC 的长度。

面积:用叉积一步到位
S = ½·|(B−A) × (C−A)|。这比海伦公式更简洁,也不需要开三次根号,数值更稳定。

⭐ 必须记住的精度处理:把 cos A 夹紧到 [−1, 1] 再传给 acos()。否则浮点误差会让 acos(1.0000000001) 返回 NaN,整个程序崩掉。

C++三点求角 + 求面积(完整代码)
#include <bits/stdc++.h>
using namespace std;

const double PI = acos(-1.0);

int main() {
    double ax, ay, bx, by, cx, cy;
    cin >> ax >> ay >> bx >> by >> cx >> cy;

    // ---- 构造两个从 A 出发的向量 ----
    double ux = bx - ax, uy = by - ay;      // 向量 AB
    double vx = cx - ax, vy = cy - ay;      // 向量 AC

    // ---- 点积与模长 ----
    double dotUV = ux * vx + uy * vy;
    double lenU = sqrt(ux * ux + uy * uy);
    double lenV = sqrt(vx * vx + vy * vy);

    // ---- 求角 A ----
    double cosA = dotUV / (lenU * lenV);
    cosA = max(-1.0, min(1.0, cosA));    // ★ 关键:夹紧到 [-1,1]
    double angleA = acos(cosA) * 180.0 / PI;

    // ---- 用叉积求面积:2S = |AB × AC| ----
    double crossUV = ux * vy - uy * vx;
    double area = fabs(crossUV) / 2.0;

    printf("%.4f\n%.4f\n", angleA, area);
    return 0;
}
运行结果
输入: 0 0 5 0 2 4
输出: 63.4349
      10.0000

验证角度:
  AB = (5, 0),AC = (2, 4)
  点积 = 5×2 + 0×4 = 10
  |AB| = 5,|AC| = √20 ≈ 4.4721
  cos A = 10 / (5 × 4.4721) = 10 / 22.3607 ≈ 0.44721
  A = acos(0.44721) ≈ 1.10715 弧度 = 1.10715 × 180/π ≈ 63.4349° ✅
  (也可直接观察:AB 沿 x 轴,AC 的斜率是 4/2 = 2,
    atan(2) ≈ 63.4349°,两者一致)

验证面积:
  AB × AC = 5×4 − 0×2 = 20
  S = |20| / 2 = 10 ✅

⚠️ 三个关键点

① 向量必须从同一个顶点出发。求角 A 就要用 AB 和 AC,不能混用 BA 和 CA。「同一起点」是点积公式的前提,否则算出来的余弦值会取反(变成 180° − A)。

② acos 的夹紧不能省。这是计算几何题最常见的 NaN 来源。当地 C 恰好落在以 A 为圆心、AB 为半径的圆上时,余弦值理论上正好是 1,但浮点算出来可能是 1.0000000000000002。

③ 角度制与弧度制要分清。sin、cos、acos 全部用弧度;题目要度数就乘 180/π。建议在代码里写两个转换函数 toDeg 和 toRad,避免到处手乘系数。

延伸:如果是「已知夹角求第三边」呢?那就是余弦定理的正向使用:c² = a² + b² − 2ab·cos C。记住「已知夹角 → 用余弦定理求第三边;已知三边 → 用余弦定理求角」,两个方向都是余弦定理。

案例 3叉积判方位:点是否在三角形内部
核心技巧计算几何 · 叉积

给定三角形三个顶点 A、B、C 和一个点 P 的坐标,判断 P 与三角形的位置关系:内部 / 边界上 / 外部。同时输出 P 相对于有向边 AB、BC、CA 的方位(左 / 右 / 共线)。

输入:八个数,依次为 A、B、C、P 的坐标。
输出:第一行 IN / ON / OUT;第二行三个数,分别为 P 相对于 AB、BC、CA 的方位(1 表示左侧,−1 表示右侧,0 表示共线)。

样例输入:0 0 6 0 0 6 1 1 样例输出:IN / 1 1 1

💡 题目分析

核心工具:三点有向面积(叉积)

对一条有向边 AB 和一个点 P,定义
cross(A, B, P) = (B − A) × (P − A) = (x_B−x_A)(y_P−y_A) − (y_B−y_A)(x_P−x_A)。
· > 0:P 在 AB 的左侧;
· < 0:P 在 AB 的右侧;
· = 0:P 与 AB 共线。

判据:P 在 △ABC 内部 ⟺ 三个叉积同号(全部 > 0 或全部 < 0)。

为什么?因为三角形的内部正是「同时在三条边的同一侧」的那部分区域。若 A、B、C 是逆时针排列,则内部就在每条边的左侧;若是顺时针排列,则内部在每条边的右侧。而三个叉积同号,恰好表达了这个「同侧」的含义。

分类判断:
· 三个叉积全部 > 0 或全部 < 0 → IN(严格内部);
· 三个叉积同号但其中有 0 → ON(在边上或顶点上);
· 否则(存在正也有负) → OUT。

复杂度:O(1)——三次叉积,常数级运算。相比「面积法(SABP + SBCP + SCAP 是否等于 SABC)」用叉积更简洁,也不需要开根号,精度更好。

C++点与三角形的位置关系 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const double eps = 1e-8;

struct Point { double x, y; };

// 有向面积的两倍:>0 表示 P 在 AB 左侧,<0 表示右侧,=0 表示共线
double cross(Point A, Point B, Point P) {
    return (B.x - A.x) * (P.y - A.y) - (B.y - A.y) * (P.x - A.x);
}

// 三态符号:-1 / 0 / 1
int sgn(double v) {
    if (v >  eps) return 1;
    if (v < -eps) return -1;
    return 0;
}

int main() {
    Point A, B, C, P;
    cin >> A.x >> A.y >> B.x >> B.y >> C.x >> C.y >> P.x >> P.y;

    int s1 = sgn(cross(A, B, P));   // 相对于边 AB
    int s2 = sgn(cross(B, C, P));   // 相对于边 BC
    int s3 = sgn(cross(C, A, P));   // 相对于边 CA

    // ---- 判断位置关系:三个符号是否同号 ----
    bool allNonNeg = (s1 >= 0 && s2 >= 0 && s3 >= 0);
    bool allNonPos = (s1 <= 0 && s2 <= 0 && s3 <= 0);

    if (allNonNeg || allNonPos) {
        if (s1 == 0 || s2 == 0 || s3 == 0) cout << "ON\n";
        else cout << "IN\n";
    } else {
        cout << "OUT\n";
    }
    cout << s1 << " " << s2 << " " << s3 << "\n";
    return 0;
}
运行结果
输入: 0 0 6 0 0 6 1 1
输出: IN
      1 1 1

验证(A(0,0), B(6,0), C(0,6), P(1,1)):
  cross(A,B,P) = (6-0)(1-0) - (0-0)(1-0) = 6 - 0 = 6  > 0
  cross(B,C,P) = (0-6)(1-0) - (6-0)(1-6) = -6 + 30 = 24 > 0
  cross(C,A,P) = (0-0)(1-6) - (0-6)(1-0) = 0 + 6 = 6   > 0
  三个都为正 → 在内部 ✅

输入: 0 0 6 0 0 6 3 3
输出: ON
      1 0 1
  (3+3=6,P 恰好在斜边 BC 上)

输入: 0 0 6 0 0 6 4 4
输出: OUT
      1 -1 1
  (出现了一正一负,不同号 → 在外部)

💡 三个重要延伸

① 为什么要判「同号」而不是「全为正」?因为题目的顶点顺序可能是顺时针也可能是逆时针。若 A、B、C 是顺时针排列,则内部在每条边的右侧(三个叉积全为负)。所以要用「同为非负」或「同为非正」两个条件之一来判定。写代码时千万别只写一半。

② 叉积的绝对值还可以顺手算出面积。SABC = |cross(A,B,C)| / 2。而且 cross(A,B,C) > 0 还顺便告诉你「A→B→C 是逆时针」。这一个函数同时提供了面积、方位、凸性三种信息,是计算几何里复用率最高的函数。

③ 推广到凸多边形。对凸多边形,同样的判据成立——只要点在所有边的同一侧。若多边形是凹的,就要用「射线法」(从 P 引一条射线,数它与多边形边的交点个数,奇数个则在内部)。凸多边形用叉积,任意多边形用射线法,这是判断点与多边形关系的两条标准路线。

模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1代数 + 二分 + eps

洛谷 P1024 [NOIP2001] 一元三次方程求解

给定一元三次方程 ax³ + bx² + cx + d = 0(a ≠ 0,a、b、c、d 均为实数),保证它有三个不同的实根,且任意两根之差的绝对值 ≥ 1。求这三个根,精确到小数点后 2 位。

输入格式:一行四个实数 a, b, c, d(|系数| ≤ 100)。
输出格式:一行三个实数,按升序输出,每个数保留 2 位小数。

输入样例:1 -5 -4 20 输出样例:-2.00 2.00 5.00

💡 题目分析

关键信息:「任意两根之差的绝对值 ≥ 1」。这句话是命题人给的最大提示——它保证了每个单位区间 [k, k+1] 内至多只有一个根。于是我们可以:

第一步:扫描单位区间。对 k 从 −100 到 99,检查 f(k) 与 f(k+1) 的符号。
· 若 f(k) == 0(用 eps 判断)→ k 就是一个根;
· 若 f(k) · f(k+1) < 0 → 由零点存在定理,区间内必有且仅有一个根。

第二步:在确定的区间内二分。每轮取中点 mid,若 f(mid) 与 f(l) 异号,则根在左半;否则在右半。迭代 50 次即可达到极高精度。

方法二:直接用求根公式 不推荐
三次方程虽然有卡尔丹公式,但公式极其复杂、涉及复数开立方、还容易出现「不可约情形」的数值不稳定。竞赛中铁律:能二分就二分,别背求根公式。

方法三:牛顿迭代法 可选
x ← x − f(x)/f'(x),收敛速度快(二次收敛),但需要求导,而且在 f'(x) 接近 0 时会发散。二分法更稳健,是这类题的首选。

C++P1024 一元三次方程求解 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

double a, b, c, d;
const double eps = 1e-8;

double f(double x) {              // 计算 f(x) = a*x^3 + b*x^2 + c*x + d
    return a*x*x*x + b*x*x + c*x + d;
}

int main() {
    cin >> a >> b >> c >> d;

    for (int k = -100; k < 100; k++) {       // 扫描 200 个单位区间
        double l = k, r = k + 1;
        double fl = f(l), fr = f(r);

        if (fabs(fl) < eps) {               // 左端点恰是根
            printf("%.2f ", l);
            continue;
        }
        if (fl * fr < 0) {                // 区间内恰有一个根
            for (int it = 0; it < 60; it++) {  // 固定次数二分
                double mid = (l + r) / 2;
                if (f(mid) * fl < 0) r = mid;    // 根在左半
                else l = mid;                      // 根在右半
            }
            printf("%.2f ", (l + r) / 2);
        }
    }
    printf("\n");
    return 0;
}
运行结果
输入: 1 -5 -4 20
输出: -2.00 2.00 5.00

验证: x = -2: (-8) - 5(4) - 4(-2) + 20 = -8 - 20 + 8 + 20 = 0 ✅
      x =  2:    8 - 5(4) - 4(2)  + 20 =  8 - 20 - 8 + 20 = 0 ✅
      x =  5:  125 - 5(25) - 4(5) + 20 = 125 - 125 - 20 + 20 = 0 ✅
      三根之差:|-2-2| = 4 ≥ 1,|2-5| = 3 ≥ 1,满足题目条件

扫描过程:
  k = -3 时,f(-3) = -27 - 45 + 12 + 20 = -40 < 0
  k = -2 时,f(-2) = 0          → 输出 -2.00
  k = 1  时,f(1) = 1 - 5 - 4 + 20 = 12 > 0
  k = 2  时,f(2) = 0           → 输出 2.00
  k = 4  时,f(4) = 64 - 80 - 16 + 20 = -12 < 0
  k = 5  时,f(5) = 0           → 输出 5.00

⚠️ 四个关键细节

① 判断 f(k) == 0 时绝不能直接写等号。因为系数可能是小数,f(k) 可能是 1e-17 而不是精确的 0。必须写 fabs(fl) < eps。这是本题最容易出错的地方。

② 二分用固定次数(60 次)而不是 while (r − l > eps)。固定次数既不会死循环,精度也完全可控(区间长 1,二分 60 次后精度约为 1/2⁶⁰ ≈ 8.7×10⁻¹⁹,远远超过题目要求的 2 位小数)。

③ 判断符号变化时要用 fl * fr < 0,而不是 fl < 0 && fr > 0。两者等价但前者更简洁;不过要小心上溢——本题系数和 x 都不大,乘积不会溢出。若涉及大数,应改用 (fl < 0) != (fr < 0)。

④ 小心重复输出。如果某个根恰好是整数(如本题的 −2、2、5),左端点检查会命中并 continue,从而避免「既输出左端点又输出二分结果」的重复。这个 continue 不能省。

方法论的推广:「先粗扫定位区间,再精确定位」是求解方程的标准套路。只要你能判断「区间内有没有根」和「根在左半还是右半」,就能用二分把根逼出来。这道题的思路可以直接推广到「求单调函数零点」「求两个函数的交点」「求某个不等式的最小整数解」等一大类问题。

题2计算几何 · 鞋带公式

洛谷 P1183 多边形的面积

给出一个简单多边形(不自交)的 n 个顶点坐标,顶点按顺时针或逆时针顺序给出(不保证是凸多边形)。求多边形的面积。

输入格式:第一行 n(3 ≤ n ≤ 100);接下来 n 行,每行两个整数 x, y。
输出格式:一行,多边形的面积(保留 1 位小数)。

输入样例:4
0 0
4 0
4 3
0 3
输出样例:12.0

💡 题目分析

核心公式:鞋带公式(Shoelace Formula)

2S = |Σ (xᵢ·yi+1 − xi+1·yᵢ)| (i 从 1 到 n,xn+1 = x₁,yn+1 = y₁)

方法一:鞋带公式 O(n) 推荐
直接代入公式循环一遍即可。优点:O(n)、不需要任何分类讨论、对凸凹多边形都成立、不需要开根号(精度最好)。

方法二:从原点剖分三角形 理解用
把多边形拆成 n 个「以原点为顶点、以相邻两顶点为底边」的三角形,每个三角形的有向面积是 ½(xᵢ·yi+1 − xi+1·yᵢ),全部相加取绝对值就是多边形面积。
这个方法解释了鞋带公式为什么对凹多边形也对:落在多边形外面的那部分三角形会贡献负面积,正好把多算的部分抵消掉。

方法三:海伦公式逐三角形求和 不推荐
需要先做多边形三角剖分(对凹多边形很麻烦),而且每次都要开根号,精度损失大。用鞋带公式一举解决所有问题。

⚠️ 为什么不用 abs() 而用 fabs()?因为坐标乘积可能很大,用 int 存会溢出,通常用 double 或 long long 存;而 abs() 是整数版本,对 double 要用 fabs()(或 C++ 的 std::abs 重载)。

C++P1183 多边形的面积 —— 完整代码(鞋带公式)
#include <bits/stdc++.h>
using namespace std;

int n;
double x[105], y[105];

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> x[i] >> y[i];

    double s2 = 0;                        // 有向面积的两倍
    for (int i = 1; i <= n; i++) {
        int j = (i == n) ? 1 : i + 1;       // 下一个顶点,最后一个接回第一个
        s2 += x[i] * y[j] - x[j] * y[i];  // 累加叉积
    }

    printf("%.1f\n", fabs(s2) / 2.0);
    return 0;
}
运行结果
输入: 4
      0 0
      4 0
      4 3
      0 3
输出: 12.0

逐步计算:
  i=1: (0,0)→(4,0):x1*y2 - x2*y1 = 0*0 - 4*0  =  0
  i=2: (4,0)→(4,3):4*3 - 4*0 = 12
  i=3: (4,3)→(0,3):4*3 - 0*3 = 12
  i=4: (0,3)→(0,0):0*0 - 0*3 =  0
  合计 s2 = 24
  面积 = |24| / 2 = 12.0 ✅(矩形 4 × 3 = 12,吻合)

输入: 3
      0 0
      3 0
      0 4
输出: 6.0
(直角三角形,底 3 高 4,面积 = 3×4/2 = 6 ✅)

💡 四个易错点 + 一个延伸

① 别忘记「最后一个顶点接回第一个」。循环里 j = (i == n) ? 1 : i + 1 这一句不能少,否则会漏掉最后一条边,面积偏小。

② 顶点顺序不影响面积。顺时针给出时 s2 为负,逆时针为正,取绝对值后结果相同。所以本题不需要判断方向,直接 fabs 即可。(但如果你需要判断图形方向,就用 sgn(s2)。)

③ 坐标可能是非整数。虽然本题样例是整数,但公式对实数完全适用,用 double 存就好。

④ 精度问题。本题不开根号,只用乘法和减法,精度非常好。但若坐标很大(10⁹ 量级),x[i]*y[j] 可能达到 10¹⁸,用 double 会丢有效数字——此时应改用 long double 或 __int128。

延伸:鞋带公式的三个「顺带产物」
· 面积:|s2| / 2;
· 顶点方向:s2 > 0 为逆时针,< 0 为顺时针;
· 点的位置:(x, y) 在多边形内部当且仅当「把它插入到合适位置后,面积不变」——这实际上是「面积法判点在多边形内」的另一种表述。
所以有经验的选手见到「多边形」两个字,第一反应就是写下鞋带公式。

题3计算几何 · 凸包

洛谷 P2742 [USACO5.1] 圈奶牛Fencing the Cows(二维凸包模板)

农场主想要用最短的篱笆把所有奶牛都围起来。给出 n 头奶牛的坐标(n ≤ 10⁴),求能围住所有奶牛的最短篱笆长度(即所有点的凸包周长),保留 2 位小数。

输入格式:第一行 n;接下来 n 行,每行两个实数 x, y。
输出格式:一行,凸包周长,保留 2 位小数。

输入样例:3
0 0
3 0
0 4
输出样例:12.00

💡 题目分析

凸包(Convex Hull):包含所有给定点的最小凸多边形。求周长 / 面积 / 直径都建立在它之上。

核心算法:Andrew 算法(单调链法) O(n log n) 推荐

步骤:
① 把所有点按 (x, y) 字典序从小到大排序;
② 从左到右扫一遍,构造下凸壳:维护一个栈,每当「新点与栈顶两点构成的叉积 ≤ 0」时弹出栈顶(说明栈顶是凹点,不在凸包上);
③ 从右到左再扫一遍,构造上凸壳;
④ 两段拼起来就是完整的凸包,按顺序遍历求各边长度之和即可。

为什么叉积能判断凸性?固定一个方向(如从左往右构建下凸壳),要求路径持续左转。若出现「右转」或「直走」,说明当前栈顶的点被夹在内部,不可能出现在凸包上,弹出即可。这就是 cross(O, A, B) > 0 作为「保留条件」的来源。

注意:若要保留共线的点,条件写 cross < 0 才弹出(即允许 cross = 0);若不需要共线点,条件写 cross <= 0。本题只求周长,共线点对周长无影响,用 <= 0 更简洁。

C++P2742 二维凸包 —— 完整代码(Andrew 单调链)
#include <bits/stdc++.h>
using namespace std;

const double eps = 1e-8;
struct Point {
    double x, y;
    bool operator<(const Point& p) const {
        return fabs(x - p.x) < eps ? y < p.y : x < p.x;
    }
    Point operator-(const Point& p) const { return {x - p.x, y - p.y}; }
};
double cross(Point O, Point A, Point B) {      // (A-O) × (B-O)
    return (A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x);
}
double dist(Point A, Point B) {
    return sqrt((A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y));
}

Point p[10005], hull[20005];

int main() {
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%lf %lf", &p[i].x, &p[i].y);
    sort(p + 1, p + n + 1);

    int k = 0;
    // ---- 下凸壳:从左到右 ----
    for (int i = 1; i <= n; i++) {
        while (k >= 2 && cross(hull[k - 1], hull[k], p[i]) <= eps) k--;
        hull[++k] = p[i];
    }
    // ---- 上凸壳:从右到左 ----
    int lower = k;
    for (int i = n - 1; i >= 1; i--) {
        while (k > lower && cross(hull[k - 1], hull[k], p[i]) <= eps) k--;
        hull[++k] = p[i];
    }
    k--;                                   // 最后一个点与第一个点重复,去掉

    double ans = 0;
    for (int i = 1; i <= k; i++) {
        int j = (i == k) ? 1 : i + 1;
        ans += dist(hull[i], hull[j]);     // 累加相邻顶点的距离
    }
    printf("%.2f\n", ans);
    return 0;
}
运行结果
输入: 3
      0 0
      3 0
      0 4
输出: 12.00

验证:三个点本身就是凸包的三个顶点
      三边长度:3、4、√(9+16) = 5
      周长 = 3 + 4 + 5 = 12 ✅

再测一个例子:
输入: 5
      0 0
      1 0
      0 1
      1 1
      0.5 0.5
输出: 4.00

说明:内部点 (0.5, 0.5) 被排除在凸包之外
      凸包是正方形的四个顶点 (0,0)(1,0)(1,1)(0,1)
      周长 = 1 + 1 + 1 + 1 = 4 ✅

⚠️ 凸包实现的五个坑

① 排序比较器要考虑浮点误差。上面代码里用了 fabs(x - p.x) < eps ? y < p.y : x < p.x。如果直接写 x != p.x ? x < p.x : y < p.y,当两个点的 x 理论上相等但浮点上差 1e-16 时,排序结果会不稳定,导致凸包出错。不过更稳的做法是:如果题目保证坐标是整数,就用整数读入和比较。

② 判断条件用 <= eps 还是 < -eps。· 允许凸包边上保留共线点 → 用 cross < -eps 才弹出(即 cross ≈ 0 时保留);
· 不保留共线点 → 用 cross <= eps 就弹出。
两种都能算出正确答案,但会影响凸包上顶点的个数。

③ 最后要 k--。因为上凸壳的最后一个点就是 p[1],与下凸壳的第一个点重复,必须去掉,否则周长会多算一段(甚至是 0 长度的一段,虽然不影响数值但下标会错)。

④ n ≤ 2 要特判。如果只有 1 个点,凸包周长是 0;2 个点时是两点距离的 2 倍(若题目要求「围起来」的篱笆)。边界情况是计算几何题最常见的失分点。

⑤ 数组要开到 2n。因为下凸壳最多 n 个点、上凸壳最多 n 个点,共用一个数组时要开 2n。开小了会 RE 或 WA。

凸包的两个常见后续应用:
· 旋转卡壳(Rotating Calipers):在凸包上求「最远点对(凸包直径)」「最小面积外接矩形」等,复杂度 O(n);
· 半平面交:求若干个半平面的公共区域,与凸包互为对偶。
本讲只要求掌握凸包的构造;旋转卡壳属于省选内容,有兴趣的同学可以自行查阅。

模块 04

总结提升

把散落的公式收进一张表、把套路收进一个模板、把疑问收进三条思考。

①核心要点与万能模板

本讲一共学了 8 个知识板块,看似松散,其实可以归成三条主线:「求极值」(二次函数 / 不等式 / 三分)、「算几何量」(三角 / 面积 / 凸包)、「判方位」(点积 / 叉积 / eps)。下表把最常考的公式与结论一次性收齐。

表 1 · 代数部分(求极值 · 数列 · 指数对数)
名称公式 / 结论典型用途
二次函数最值y = ax²+bx+c,顶点 x₀ = −b / (2a)区间外取端点;区间内取顶点
判别式Δ = b² − 4ac;Δ > 0 两根、Δ = 0 一根、Δ < 0 无实根判断交点个数、解方程
韦达定理x₁ + x₂ = −b/a,x₁·x₂ = c/a不算根也能求对称式
均值不等式a + b ≥ 2√(ab)(a, b ≥ 0);a + b = S ⇒ ab ≤ S²/4定和求积最大
等号条件a = b判断「取到」还是「取不到」
等差数列aₙ = a₁ + (n−1)d;Sₙ = n(a₁ + aₙ)/2 = na₁ + n(n−1)d/2O(1) 求和
等比数列aₙ = a₁q^(n−1);Sₙ = a₁(1 − qⁿ)/(1 − q)(q ≠ 1)翻倍类问题
裂项相消1/(k(k+1)) = 1/k − 1/(k+1) ⇒ 和 = 1 − 1/(n+1)Σ 型求和
对数法则log(xy) = log x + log y;log(xⁿ) = n·log x;log_b a = ln a / ln b复杂度分析、换底
快速幂aⁿ = (a^(n/2))² (n 偶);aⁿ = a·a^(n−1) (n 奇)O(log n) 求幂
三分法l < r:若 f(m1) < f(m2) 则 r = m2,否则 l = m1单峰函数求极值
表 2 · 几何部分(三角 · 面积 · 向量 · 精度)
名称公式 / 结论典型用途
正弦定理a/sin A = b/sin B = c/sin C = 2R已知两角一边
余弦定理c² = a² + b² − 2ab·cos C已知三边求角 (首选)
求角公式cos C = (a² + b² − c²) / (2ab)再用 acos() 得弧度
SAS 面积S = ½·a·b·sin C已知两边夹一角
海伦公式p = (a+b+c)/2;S = √(p(p−a)(p−b)(p−c))已知三边求面积
鞋带公式2S = |Σ (xᵢy_{i+1} − x_{i+1}yᵢ)|任意简单多边形面积
重心G = ((x₁+x₂+x₃)/3, (y₁+y₂+y₃)/3)三顶点平均
外心到三顶点等距;R = abc / (4S)外接圆
内心到三边等距;r = S / p内切圆
垂心 / 旁心三条高交点 / 一条内角平分线与两条外角平分线交点共 5 个「心」
点积a·b = |a||b|cos θ = x₁x₂ + y₁y₂判锐角 / 投影 / 垂直
叉积(二维)a × b = x₁y₂ − x₂y₁(标量)判左右 / 求面积 (核心)
垂直判定a·b = 0点积为零
共线判定a × b = 0叉积为零
三点方向cross(O,A,B) > 0 左转 / < 0 右转 / = 0 共线凸包、跨立实验
点在三角形内cross(A,B,P)、cross(B,C,P)、cross(C,A,P) 三者同号O(1) 判定
eps 比较x > eps / x < −eps / |x| ≤ eps把「等于」变成闭区间
二分收敛固定迭代 100 次 ≈ 精度 2⁻¹⁰⁰避免死循环
acos 夹紧acos(max(−1.0, min(1.0, x)))防 NaN
主线
代数管「量」,几何管「位置」,eps 管「相等」。写题时先问自己三句话:这题要极值还是要几何量?能用公式代替循环吗?判断相等的地方加 eps 了吗?
万能模板 · 计算几何基础库(可直接背下来手敲)
C++geo.h —— 点 / 向量 / 叉积点积 / 角度 / 面积 / 二分
// ============ 计算几何基础模板(CSP-S / NOIP 通用) ============
#include <bits/stdc++.h>
using namespace std;

const double PI  = acos(-1.0);   // 3.141592653589793
const double eps = 1e-8;         // 误差容忍度,看题给精度调整

// ---- 符号函数与相等判断:任何「等于」都要用它 ----
int sgn(double x) {                 // x>0 返回 1,x<0 返回 -1,≈0 返回 0
    if (x >  eps) return  1;
    if (x < -eps) return -1;
    return 0;
}
bool eq(double a, double b) { return sgn(a - b) == 0; }
bool lt(double a, double b) { return sgn(a - b) <  0; }
bool le(double a, double b) { return sgn(a - b) <= 0; }
double clamp1(double x) {            // 把值夹进 [-1,1],保护 acos
    return max(-1.0, min(1.0, x));
}

// ---- 点 / 向量:二维统一用 Point 表示 ----
struct Point {
    double x, y;
    Point(double x = 0, double y = 0) : x(x), y(y) {}
    // 向量加、减、数乘
    Point operator+(const Point &o) const { return Point(x + o.x, y + o.y); }
    Point operator-(const Point &o) const { return Point(x - o.x, y - o.y); }
    Point operator*(double k)        const { return Point(x * k, y * k); }
    // 点积:判垂直、求夹角、求投影
    double dot(const Point &o)   const { return x * o.x + y * o.y; }
    // 叉积:判左右、求面积、判共线
    double cross(const Point &o) const { return x * o.y - y * o.x; }
    // 模长与两点距离
    double len() const { return sqrt(x * x + y * y); }
    double dist(const Point &o) const { return (*this - o).len(); }
    // 逆时针旋转 90°:(x,y) -> (-y,x)
    Point rot90() const { return Point(-y, x); }
};

// ---- 三点方向:1 左转(逆时针),-1 右转(顺时针),0 共线 ----
int orient(const Point &O, const Point &A, const Point &B) {
    return sgn((A - O).cross(B - O));
}
bool collinear(const Point &A, const Point &B, const Point &C) {
    return orient(A, B, C) == 0;
}

// ---- 三角形面积(叉积的一半,带符号)----
double triArea2(const Point &A, const Point &B, const Point &C) {
    return (B - A).cross(C - A);        // 两倍有向面积
}

// ---- 三点夹角(弧度):先解余弦,再反余弦,最后夹紧 ----
double angleAt(const Point &A, const Point &B, const Point &C) {
    Point u = A - B, v = C - B;          // 以 B 为顶点
    return acos(clamp1(u.dot(v) / (u.len() * v.len())));
}

// ---- 点 P 是否在三角形 ABC 内(含边界)----
bool inTriangle(const Point &A, const Point &B,
                const Point &C, const Point &P) {
    int d1 = orient(A, B, P);
    int d2 = orient(B, C, P);
    int d3 = orient(C, A, P);
    bool hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
    bool hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
    return !(hasNeg && hasPos);        // 没有「既有正又有负」= 三者同号
}

// ---- 二分求零点:固定 100 次,精度无忧 ----
double binaryZero(double l, double r,
                  function<double(double)> f) {
    for (int i = 0; i < 100; i++) {
        double mid = (l + r) / 2;
        if (sgn(f(l)) * sgn(f(mid)) <= 0) r = mid;
        else l = mid;
    }
    return (l + r) / 2;
}

💡 这个模板该怎么用

① 别当场默写,要提前练熟。赛场上的时间极其宝贵,计算几何的模板应该在赛前就练到「闭着眼能敲对」。尤其是 sgn、eq、cross、orient 这四行,是整个模板的地基。

② eps 的大小要看题目给几位小数。题目要求保留 2 位小数,eps 取 1e-6 到 1e-8 都够;要求保留 6 位,eps 要取到 1e-10 甚至更小。但 eps 也不是越小越好——太小会让误差累积成错误判断,太大则会把不同的点误判成同一点。经验值:eps = 1e-8 能覆盖绝大多数题。

③ 能用整数就别用浮点。如果题目保证坐标是整数、判断也只需判符号,那就用 long long 存坐标、直接比较叉积的正负,完全不需要 eps。「能整则整」是计算几何的第一原则,浮点只在「必须开根号(长度、角度、面积的小数部分)」时才引入。

②拓展案例

三个拓展案例,分别把三分法、跨立实验、旋转卡壳这三件「大学问」拆开讲清楚。它们都建立在正文的知识点上,只是把叉积和不等式用得更狠了一些。

拓展 1 · 三分法求单峰函数的极值

问题:求 f(x) = −x² + 6x − 5 在区间 [0, 10] 上的最大值(保留 4 位小数)。

为什么不能直接二分?二分法要求「右侧永远是答案」这个单调性质,而求极值时函数先增后减,不单调,二分会失效。

三分法的核心思想:把区间分成三段,取两个三等分点 m1 < m2。
· 若 f(m1) < f(m2):说明函数还在上升段或峰值在 m1 右侧,于是最大值一定不在 [l, m1] 里 → l = m1;
· 若 f(m1) > f(m2):说明已经过了峰,最大值一定不在 [m2, r] 里 → r = m2;
· 若相等(浮点上用 eps 判断):l = m1, r = m2 都行,缩得更快。

复杂度:每轮把区间缩到原来的 2/3,迭代 k 次后精度是 (2/3)ᵏ。要 1e-10 的精度大约需要 60 次迭代,比二分的 100 次还快一点。

⚠️ 前提条件:函数在区间上必须是「严格单峰」的(先增后减或先减后增)。如果函数有多个峰(如 sin 型),三分法会掉进局部最优,得到错误答案。

C++三分法求二次函数最大值
#include <bits/stdc++.h>
using namespace std;

const double eps = 1e-10;

double f(double x) { return -x*x + 6*x - 5; }

double ternarySearch(double l, double r) {
    while (r - l > eps) {
        double m1 = l + (r - l) / 3;     // 左三等分点
        double m2 = r - (r - l) / 3;     // 右三等分点
        if (f(m1) < f(m2)) l = m1;         // 峰在右侧
        else               r = m2;         // 峰在左侧
    }
    return (l + r) / 2;
}

int main() {
    double x = ternarySearch(0, 10);
    printf("x = %.4f, f(x) = %.4f\n", x, f(x));
    printf("理论值:x = 3.0000, f(x) = 4.0000\n");
    return 0;
}
运行结果
x = 3.0000, f(x) = 4.0000
理论值:x = 3.0000, f(x) = 4.0000

手工验证:
  f(x) = -x² + 6x - 5 = -(x² - 6x + 9) + 9 - 5
       = -(x - 3)² + 4
  顶点在 x = 3,最大值 f(3) = 4,恰好落在区间 [0, 10] 内部 ✅
  与二分法(求导后找零点 f'(x) = -2x + 6 = 0 → x = 3)结果一致

🔍 三分法的三条注意事项

① 三分法只对单峰函数有效。判断一个函数是否单峰,常看它的导数是否单调。若题目没保证单峰,就不能用三分。

② 对整数域的三分法要小心取整。如果自变量必须是整数,出现 f(m1) == f(m2) 的情况会很麻烦(可能有两种移动方案,缩错了会漏掉答案)。整数三分推荐写成 while (r - l > 3) 然后暴力枚举剩余几个值,比纯三分更稳。

③ 三分法也能求凹函数的极小值。只要把判断改成 if (f(m1) > f(m2)) l = m1; else r = m2; 即可——「小的一侧被砍掉」这个原则对凸、凹都适用,方向由「求最大还是最小」决定。

拓展 2 · 跨立实验:叉积与点积的联合作战

问题:给定两条线段 AB 与 CD,判断它们是否相交(含端点接触)。

为什么不能只用一次叉积?因为「线段相交」其实包含两种截然不同的情况:① 正常的交叉(X 型)和② 共线重叠(一条压在另一条上)。前者叉积能判,后者叉积一律为 0,判不出来。

跨立实验(Straddle Test)的思想:AB 与 CD 相交 ⟺ A、B 分居直线 CD 的两侧「并且」C、D 分居直线 AB 的两侧。
· 「分居两侧」= 两个叉积异号(或其中一个为 0,表示端点在直线上);
· 用代码表达就是 orient(C, D, A) * orient(C, D, B) <= 0 && orient(A, B, C) * orient(A, B, D) <= 0。

那共线重叠怎么办?当两个叉积都为 0 时(四条点共线),必须再做一次「投影包含」判断:把点投影到 x 轴(若线段垂直则投到 y 轴),检查区间是否有交。这就是为什么说「叉积判方位,点积/比较判范围」——两者要联合作战。

⚠️ 经典陷阱:只做一次跨立(比如只判 A、B 是否在 CD 两侧)是不够的!反例:AB 很短的线段,完全在 CD 延长线的一侧「旁边」,此时 A、B 确实在 CD 两侧吗?不一定——但如果 AB 与 CD 所在直线相交而交点落在线段 CD 之外,只判一次会误判为相交。必须两个方向都判。

C++跨立实验 + 共线特判:线段相交判定
#include <bits/stdc++.h>
using namespace std;

const double eps = 1e-8;
int sgn(double x) { return x > eps ? 1 : (x < -eps ? -1 : 0); }

struct Point {
    double x, y;
    Point(double x = 0, double y = 0) : x(x), y(y) {}
    Point operator-(const Point &o) const { return Point(x - o.x, y - o.y); }
    double cross(const Point &o) const { return x * o.y - y * o.x; }
    double dot(const Point &o)   const { return x * o.x + y * o.y; }
};
// 叉积符号:O-A-B 的方向
int orient(const Point &O, const Point &A, const Point &B) {
    return sgn((A - O).cross(B - O));
}
// 「P 在线段 AB 上」:共线 + 投影落在 AB 之间
bool onSegment(const Point &A, const Point &B, const Point &P) {
    if (orient(A, B, P) != 0) return false;              // 不共线,直接否决
    return min(A.x, B.x) - eps <= P.x && P.x <= max(A.x, B.x) + eps
        && min(A.y, B.y) - eps <= P.y && P.y <= max(A.y, B.y) + eps;
}
// 线段 AB 与 CD 是否相交
bool segIntersect(const Point &A, const Point &B,
                  const Point &C, const Point &D) {
    int d1 = orient(A, B, C), d2 = orient(A, B, D);
    int d3 = orient(C, D, A), d4 = orient(C, D, B);

    // ① 正常跨立:两个方向的叉积都异号
    if (d1 * d2 < 0 && d3 * d4 < 0) return true;

    // ② 共线特判:某个端点落在另一条线段上
    if (d1 == 0 && onSegment(A, B, C)) return true;
    if (d2 == 0 && onSegment(A, B, D)) return true;
    if (d3 == 0 && onSegment(C, D, A)) return true;
    if (d4 == 0 && onSegment(C, D, B)) return true;

    return false;
}

int main() {
    // 测试 1:标准的 X 型交叉
    printf("测试1 (0,0)-(4,4) 与 (0,4)-(4,0):%s\n",
           segIntersect({0,0}, {4,4}, {0,4}, {4,0}) ? "相交 ✅" : "不相交");
    // 测试 2:只在一个方向上「跨立」,但线段本身不相交
    printf("测试2 (0,0)-(1,0) 与 (2,-1)-(2,1):%s\n",
           segIntersect({0,0}, {1,0}, {2,-1}, {2,1}) ? "相交" : "不相交 ✅");
    // 测试 3:共线重叠
    printf("测试3 (0,0)-(6,0) 与 (2,0)-(8,0):%s\n",
           segIntersect({0,0}, {6,0}, {2,0}, {8,0}) ? "相交 ✅" : "不相交");
    // 测试 4:共线但分离
    printf("测试4 (0,0)-(2,0) 与 (5,0)-(8,0):%s\n",
           segIntersect({0,0}, {2,0}, {5,0}, {8,0}) ? "相交" : "不相交 ✅");
    // 测试 5:端点接触
    printf("测试5 (0,0)-(3,3) 与 (3,3)-(6,0):%s\n",
           segIntersect({0,0}, {3,3}, {3,3}, {6,0}) ? "相交 ✅" : "不相交");
    return 0;
}
运行结果
测试1 (0,0)-(4,4) 与 (0,4)-(4,0):相交 ✅
测试2 (0,0)-(1,0) 与 (2,-1)-(2,1):不相交 ✅
测试3 (0,0)-(6,0) 与 (2,0)-(8,0):相交 ✅
测试4 (0,0)-(2,0) 与 (5,0)-(8,0):不相交 ✅
测试5 (0,0)-(3,3) 与 (3,3)-(6,0):相交 ✅

逐例分析:
 测试1:AB 方向下 C(0,4) 在左、D(4,0) 在右 → d1*d2 < 0;
        CD 方向下 A(0,0) 与 B(4,4) 也在两侧 → d3*d4 < 0。双重跨立成立。
 测试2:AB 是 x 轴上 [0,1] 的一段,CD 是竖直线 x = 2 上 y ∈ [-1,1] 的一段。
        两个方向的判定结果并不相同,这正是「必须双向跨立」的关键证据:
        · 判 AB 方向:orient(A,B,C) = -1 < 0、orient(A,B,D) = 1 > 0
          → C 在下方、D 在上方,确实「跨立」了直线 AB;
        · 判 CD 方向:orient(C,D,A) = 4 > 0、orient(C,D,B) = 2 > 0
          → A、B 都在竖线 x = 2 的左侧,没有跨立。
        如果只判 AB 方向,就会把这道题误判成「相交」。
        事实是:两条线段所在直线的交点是 (2, 0),它落在线段 CD 上、
        却完全落在 AB 的延长线上(x = 2 > 1),所以正确答案是「不相交」。
        ⚠️ 双向判定的意义,就在这个例子里。
 测试3:四点全部共线(y = 0),叉积恒为 0,靠 onSegment 特判命中。
 测试4:共线但 [0,2] 与 [5,8] 无交集,onSegment 全部失败,正确。
 测试5:端点 (3,3) 重合,d 中出现 0,onSegment 命中。

🧠 从跨立实验学到的方法论

① 一个工具判不了,就组合两个工具。叉积擅长「位置关系」(左/右/共线),点积擅长「角度关系」(锐/钝/垂直),而区间比较擅长「范围关系」。跨立实验 = 叉积 + 区间比较,这正是「组合工具解决复杂判定」的典范。

② 分类讨论的「漏类」往往出现在退化情形。正常交叉是「好」情形,共线、端点重合、线段退化成点,才是真正容易错的地方。写任何几何判定函数时,都先问自己一遍:「如果输入退化成点或共线,我的代码还对吗?」

③ 这道题里 d1*d2 < 0 为什么不用 <= 0?因为 <= 0 会把「端点落在另一条直线上但不在线段上」也算成相交。所以正常跨立用严格小于,退化情形用 onSegment 单独补判——「严格判正常情况 + 单独补退化情况」是计算几何最稳的写法。

拓展 3 · 旋转卡壳:在凸包上求最远点对

问题(洛谷 P1452 [USACO03FALL] Beauty Contest G):给定平面上 n 个点(n ≤ 5×10⁴),求它们之间最远点对的距离的平方。

为什么不能枚举?枚举所有点对是 O(n²) = 2.5×10⁹,一定超时。

关键结论一:最远点对一定都是凸包上的顶点。如果某个点 P 在凸包内部,那么从任何一个方向看,凸包上总有点比 P 更远。所以第一步:先求凸包,把点数从 5×10⁴ 降到几十到几千。

关键结论二:凸包上「距离凸包最远的点」随着边一起转动。想象用一对平行的卡钳夹住凸包:当一条卡钳沿着凸包的边旋转时,另一条卡钳接触的点只会沿同一个方向单调前进,不会后退。这就是「旋转卡壳」名字的由来。

算法步骤:
① 求凸包(Andrew 算法,O(n log n));
② 若凸包上只有 1 个点,答案是 0;只有 2 个点,答案是这两点距离的平方;
③ 用双指针 i(枚举边 hull[i] → hull[i+1])和 j(对踵点,沿凸包单调前进)。每轮用三角形面积比较:area(i, i+1, j+1) > area(i, i+1, j) 时 j 继续前进;
④ 用 dist(i, j) 和 dist(i+1, j) 更新答案。

关于「面积比较」为什么能代替「距离比较」:三角形 (i, i+1, j) 的底边固定为凸包的边 i → i+1,所以面积与 j 到底边所在直线的距离成正比。距离最大的 j 使面积最大——用 |cross| 比较即可,全程不需要开根号,精度损失最小。

C++P1452 Beauty Contest —— 凸包 + 旋转卡壳
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct P {
    ll x, y;
    P(ll x = 0, ll y = 0) : x(x), y(y) {}
    P operator-(const P &o) const { return P(x - o.x, y - o.y); }
    ll  operator^(const P &o) const { return x * o.y - y * o.x; }  // 叉积
    bool operator<(const P &o) const { return x != o.x ? x < o.x : y < o.y; }
    bool operator==(const P &o) const { return x == o.x && y == o.y; }
};
ll dist2(const P &a, const P &b) {          // 距离的平方:整数运算,零误差
    ll dx = a.x - b.x, dy = a.y - b.y;
    return dx * dx + dy * dy;
}

int n;
vector<P> p, hull;

void convexHull() {                        // Andrew 单调链,不保留共线点
    sort(p.begin(), p.end());
    n = unique(p.begin(), p.end()) - p.begin();   // 去重是必须的!
    p.resize(n);
    hull.clear();
    if (n <= 1) { hull = p; return; }
    for (int i = 0; i < n; i++) {
        while (hull.size() >= 2 &&
               ((hull[hull.size()-1] - hull[hull.size()-2]) ^
                (p[i] - hull[hull.size()-2])) <= 0)
            hull.pop_back();
        hull.push_back(p[i]);
    }
    int lower = hull.size();
    for (int i = n - 2; i >= 0; i--) {
        while ((int)hull.size() > lower &&
               ((hull[hull.size()-1] - hull[hull.size()-2]) ^
                (p[i] - hull[hull.size()-2])) <= 0)
            hull.pop_back();
        hull.push_back(p[i]);
    }
    hull.pop_back();                        // 首尾重复,去掉一个
}

ll rotatingCalipers() {
    int m = hull.size();
    if (m == 1) return 0;
    if (m == 2) return dist2(hull[0], hull[1]);

    int j = 2;                                // 对踵点指针,从第 3 个顶点开始
    ll ans = 0;
    for (int i = 0; i < m; i++) {
        int ni = (i + 1) % m;
        // 面积法:把 j 推到「离边 i->ni 最远」的位置
        while (true) {
            int nj = (j + 1) % m;
            ll cur = llabs((hull[ni] - hull[i]) ^ (hull[j]  - hull[i]));
            ll nxt = llabs((hull[ni] - hull[i]) ^ (hull[nj] - hull[i]));
            if (nxt > cur) j = nj; else break;
        }
        ans = max(ans, dist2(hull[i],  hull[j]));
        ans = max(ans, dist2(hull[ni], hull[j]));
    }
    return ans;
}

int main() {
    scanf("%d", &n);
    p.resize(n);
    for (int i = 0; i < n; i++) scanf("%lld%lld", &p[i].x, &p[i].y);
    convexHull();
    printf("%lld\n", rotatingCalipers());
    return 0;
}
运行结果
输入: 4
      0 0
      0 1
      1 1
      1 0
输出: 2

分析:四点是单位正方形的四个顶点,凸包就是这四点本身。
      最远点对是任意一对对角顶点,如 (0,0) 与 (1,1)。
      距离的平方 = 1² + 1² = 2 ✅
      (注意题目要求输出的是距离的平方,不是距离 √2)

再测一组:
输入: 5
      0 0
      4 0
      4 3
      0 3
      2 1
输出: 25

分析:内部点 (2,1) 被凸包排除,凸包是 4×3 的矩形。
      最远点对是 (0,0) 与 (4,3),距离平方 = 16 + 9 = 25 ✅

复杂度:排序 O(n log n) + 求凸包 O(n) + 旋转卡壳 O(m)
       其中 m 是凸包顶点数,最坏也是 n,整体 O(n log n)
       实测 n = 5×10⁴ 时耗时不足 20 ms

🏆 旋转卡壳的三个关键点

① 对踵点指针只需单调前进,不需要重置。这是整个算法 O(n) 的关键。因为当 i 向前移动时,最远的 j 也只会向前移动——这个单调性来自凸包的凸性。写代码时 j 定义在 for 循环外面,不要每轮清零。

② 为什么要用整数乘法而不是浮点?本题坐标是整数,cross 和 dist2 都是整数运算,精度零误差。而且刚好题目要求输出「距离的平方」——这几乎是命题人在暗示「用整数算,别开根号」。拿到一道几何题,先看坐标类型和输出精度,再决定用整数还是浮点。

③ 必须先去重!若输入里有重复点,Andrew 算法第一步的 sort 之后如果不去重,凸包构造时会出现「三点共线且其中两点重合」的退化情况,导致 cross = 0 的判断出错、凸包多出重复顶点,最终卡壳循环可能死循环。unique 是很多几何模板里最容易被忘掉、却最容易导致 TLE 的一行。

④ 旋转卡壳还能做什么?
· 最小面积外接矩形(P3187 最小矩形覆盖):卡壳的四条边分别贴着凸包的边;
· 凸包间的最近/最远距离;
· 最大面积三角形(凸包 + 双指针)。
「凸包 + 双指针」是一个非常通用的省选级套路,本讲只要求理解思想,能默写最远点对即可。

③思考与启发

🤔 思考 1 · 同样一道几何题,为什么有人 10 分钟 AC,有人 3 小时 WA 到崩溃?

小 A 拿到「判断点是否在三角形内」,直接写 if (cross1 > 0 && cross2 > 0 && cross3 > 0),交上去 WA 了一半的点。小 B 写了 sgn 版本、把边界情况单独处理,一次 AC。差别在哪?

查看参考答案

差别不在「会不会写」,而在「有没有想到退化情形」。计算几何题的失分,绝大多数不是因为算法错,而是因为漏掉了某类边界。本讲反复出现的这类边界至少有六种:

边界类型典型表现如何防守
点落在边上叉积恰好为 0用 sgn,判据写成「同号或含 0」
点与顶点重合两个叉积同时为 0同上,不要用严格不等号
三点共线叉积为 0,被误判成「不在内部」共线要单独算「在内部」
退化多边形(n ≤ 2)凸包为空或只有一个点特判 n ≤ 2
重复点凸包出现重复顶点sort 后 unique 去重
数值误差累积acos(1.0000000001) → NaNclamp1 夹紧到 [−1, 1]

四条应对方法:
① 先想边界,再写代码。动手前花 1 分钟列出「点重合 / 共线 / 退化 / 极值」四种情况,写测试用例时逐一覆盖。
② 所有「等于」都用 sgn/eq。永远不要写 if (a == b) 来判断两个 double 相等。
③ 能整则整。坐标是整数就用 long long,叉积判符号不需要 eps,精度零误差。
④ 对拍验证。写一个 O(n²) 的暴力程序,用随机数据对拍。计算几何题的对拍是最有效的调试手段,因为错误往往出现在极端数据上,而极端数据人眼看不出来。

一句话总结:计算几何比拼的不是「谁会更多公式」,而是「谁把边界想得更全」。把每一道题的边界都当成必答项,而不是选答项。

🤔 思考 2 · 余弦定理和正弦定理,到底什么时候该用哪个?

老师说过「能用余弦定理就别用正弦定理求角」,那是不是正弦定理就没用了?

查看参考答案

不是没用,而是「用错了地方会踩坑」。两个定理各有各的主场:

已知条件该用哪个理由
三边(SSS)求角余弦定理直接得 cos 值,唯一确定
两边夹一角(SAS)求第三边余弦定理一步到位,不用解方程
两边夹一角(SAS)求面积S = ½ab·sin C这个 sin 是直接用角度求值,反三角的歧义不存在
两角一边(AAS / ASA)求另一边正弦定理角度已知,正弦值是正数,无歧义
两边一对角(SSA)求另一角正弦定理 + 小心两解最容易出错的场景

为什么「SSA 用正弦定理」这么危险?因为这正是著名的「两解情形(Ambiguous Case)」。设已知 a、b 和角 A,由 sin B = b·sin A / a 求出 sin B 后,B 可能是锐角也可能是钝角(两者正弦值相同)。必须靠「大边对大角」或「三角形内角和」来排除其中的一个。在程序里,这类讨论极其难写对。

而余弦定理为什么安全?因为 cos θ 在 [0, π] 上是严格单调递减的,一个 cos 值唯一对应一个角度,acos() 的返回值恰好落在 [0, π]——正好就是三角形的内角范围。所以余弦定理天然没有歧义。

结论:能用余弦定理的地方就用余弦定理;正弦定理只在「解三角形时补上最后一个角」和「SAS 求面积」时出场。这不是「谁更高级」,而是「谁更没有歧义」。

🤔 思考 3 · 公式和循环,什么时候该用哪一个?

等差数列求和可以用 Sₙ = n(a₁+aₙ)/2 一步算出,也可以写一个 for 循环累加。多边形面积可以用鞋带公式,也可以三角剖分后逐个求海伦公式。既然公式总是更快,那循环是不是就没用了?

查看参考答案

不是「谁替代谁」,而是「什么规模用什么工具」。两者的取舍是有明确标准的:

场景选公式还是循环原因
n ≤ 100,且只需求一次都可以都要写,选哪个可读性好用哪个
n ≤ 10⁵,需要求一次公式(O(1))循环 O(n) 也许还行,但没理由不快
n ≤ 10⁹,需要求一次必须用公式循环会 TLE,这是公式存在的意义
需要对每一项单独处理循环例如「求第 k 项」但 k 未知,或需要每项参与别的计算
存在精度风险优先循环例如 Sₙ = a₁(1−qⁿ)/(1−q),当 q 接近 1 时会产生灾难性相消,分母趋近 0,误差爆炸
公式涉及大数相减优先循环或变形例如 1−qⁿ 当 qⁿ 接近 1 时有效数字全部丢失

「灾难性相消」是什么?举个数:设 q = 1 + 10⁻⁸,n = 1。则 1 − qⁿ = −10⁻⁸,而 double 的精度只有约 15~16 位十进制有效数字。两个接近 1 的数相减,结果的相对误差会被放大成千上万倍——原本 1e-16 的舍入误差,在结果里变成了 1e-8 级别的错误。

所以真正的判据是三条:
① 规模够大吗?n 大到循环算不完 → 用公式;
② 每一项都需要单独处理吗?需要 → 用循环;
③ 公式里有「大数减小数」吗?有 → 要么改成循环,要么把公式等价变形(比如用 q^0+q^1+…+q^(n−1) 的和式,或用 qpow 精确计算)。

一句话总结:公式是为了突破复杂度瓶颈而存在的,不是为了让代码更短。当公式带来的精度损失超过它能省下的时间,就该果断用循环。这也是「数学工具服务于程序,而不是反过来」的最好例子。

模块 05

学习检测

五个最容易翻车的地方,考前务必扫一眼。

易错1

用 == 比较两个浮点数

❌ 错误
if (a.x == b.x && a.y == b.y) puts("重合");
if (cross == 0) puts("共线");
表面上没问题,实际上:0.1 + 0.2 == 0.3 的结果是 false。经过几次加减乘除后,「理论上相等」的两个数在浮点上差 1e-16,判断直接反转。
更隐蔽的问题:如果 a、b 本该相等但判断失败,后续会走「不相等」的分支,产生完全不同的结果——这类错误往往只错一两个测试点,极难调试。
✅ 正确
先定义 sgn,再比较符号:
int sgn(double x){ return x > eps ? 1 : (x < -eps ? -1 : 0); }
if (sgn(a.x - b.x) == 0 && sgn(a.y - b.y) == 0) puts("重合");
if (sgn(cross) == 0) puts("共线");
或者干脆用整数:坐标是整数就用 long long,叉积的相等判断完全精确。
口诀:浮点不比 ==,一切「相等」走 sgn!
易错2

acos() 参数越界,返回 NaN

❌ 错误
double ang = acos(u.dot(v) / (u.len() * v.len()));
数学上这个比值应该落在 [−1, 1],但浮点误差会让它变成 1.0000000000000002。
acos 的定义域是 [−1, 1],越界返回 NaN(Not a Number)。NaN 会像病毒一样传播:任何与 NaN 的运算都得 NaN,最后输出 nan 或 -nan,全部测试点挂掉。
常见触发场景:两条边近乎平行时 cos θ ≈ 1;三个点几乎共线时 cos θ ≈ ±1。
✅ 正确
方法一:把参数夹紧(推荐)
double clamp1(double x){ return max(-1.0, min(1.0, x)); }
double ang = acos(clamp1(u.dot(v) / (u.len() * v.len())));
方法二:改用 atan2 避开反余弦
double ang = atan2(fabs(u.cross(v)), u.dot(v));
atan2(y, x) 的定义域是全平面,永远不会越界,而且精度更好(在角度接近 0 或 π 时比 acos 稳定得多)。
口诀:反三角先夹紧;能 atan2 就 atan2!
易错3

鞋带公式忘了「接回第一个点」

❌ 错误
for (int i = 1; i < n; i++) s2 += x[i]*y[i+1] - x[i+1]*y[i];
循环只跑到 i = n−1,漏掉了「最后一个点连回第一个点」的那一项。
结果:整个多边形被算成了「n 个点连成的折线围成的区域」,面积明显偏小。
更隐蔽的写法错误:for (int i = 1; i <= n; i++) ... x[i+1] 而数组只开到 x[n]——访问 x[n+1] 越界,可能读到 0,也可能读到随机值,答案随机出错。
✅ 正确
写法一:用 (i == n) ? 1 : i + 1 处理下标
for (int i = 1; i <= n; i++) {
 int j = (i == n) ? 1 : i + 1;
 s2 += x[i]*y[j] - x[j]*y[i];
}
写法二:把数组开大一倍(更省心)
for (int i = 1; i <= n; i++) { x[i+n] = x[i]; y[i+n] = y[i]; }
然后直接写 for (int i = 1; i <= n; i++) s2 += x[i]*y[i+1] - x[i+1]*y[i];
最后取绝对值再除以 2:printf("%.1f\n", fabs(s2) / 2.0);
口诀:鞋带要首尾相连,最后一点连回第一点!
易错4

叉积判「点在三角形内」只判了「三个都大于 0」

❌ 错误
if (c1 > 0 && c2 > 0 && c3 > 0) puts("在内部");
错在哪?① 如果三角形的顶点是顺时针给出的,三个叉积会全部为负,这个判断永远为 false;
② 如果点正好落在边上,某个叉积恰好为 0,被误判成「不在内部」;
③ 如果点正好是顶点,两个叉积同时为 0,同样被误判。
✅ 正确
判「三者同号(允许 0)」,用「没有既有正又有负」表达:
int d1 = orient(A,B,P), d2 = orient(B,C,P), d3 = orient(C,A,P);
bool hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
bool hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
这个写法同时满足三点:· 不关心顶点是顺时针还是逆时针(正负随便);· 允许 0(覆盖边和顶点);· 逻辑一句话,不易写错。
若题目要求「严格在内部」(不含边界),再改成 return d1 != 0 && d2 != 0 && d3 != 0 && !(hasNeg && hasPos);
口诀:判内不判「全正」,判「同号(含 0)」!
易错5

叉积用 int 存,坐标一乘就溢出

❌ 错误
struct P { int x, y; };
int cross(P a, P b) { return a.x * b.y - a.y * b.x; }
设 |x|, |y| ≤ 10⁵,则 a.x * b.y 最大约 10¹⁰,远超 int 的上限 2.1×10⁹,结果溢出成随机的负数。
后果:叉积的符号错了——本该「左转」判成「右转」,凸包构造、点在内判定、跨立实验全部崩盘。而且不会报错,只会 WA。
更糟糕的是 dist2:dx*dx + dy*dy 在坐标 10⁵ 时约为 2×10¹⁰,同样溢出。
✅ 正确
坐标一律用 long long(甚至 __int128):
struct P { long long x, y; };
long long cross(P a, P b) { return a.x * b.y - a.y * b.x; }
怎么估算范围?先看题目给的坐标上界 M,则叉积的绝对值 ≤ 2M²、距离平方 ≤ 2M²。
· M = 10³ → 2×10⁶,int 够用;
· M = 10⁵ → 2×10¹⁰,必须 long long;
· M = 10⁹ → 2×10¹⁸,long long 上限 9.2×10¹⁸ 勉强够,减法前要防溢出;
· M = 10¹⁸ → 必须 __int128。
拿不准就统一写 long long,代价只是 8 字节内存。
口诀:坐标乘一下,范围先估它;过 2×10⁹,就上 long long!
📋速查表 · 代数与几何常用工具
需求 / 场景关键写法复杂度 / 备注
浮点判等sgn(a − b) == 0O(1) · 永远别写 a == b
浮点判小于sgn(a − b) < 0O(1)
夹紧到 [−1,1]max(-1.0, min(1.0, x))O(1) · 保护 acos
点积x1*x2 + y1*y2O(1) · 判垂直、求夹角
叉积(二维)x1*y2 − y1*x2O(1) · 判方位、求面积
三点方向sgn((A−O)×(B−O))O(1) · 1 左 / −1 右 / 0 共线
共线判定sgn(cross) == 0O(1)
点是否在线段上共线 + 投影落在包围盒内O(1) · 两步都要判
两点距离sqrt(dx*dx + dy*dy)O(1) · 只比大小时用平方
两点距离的平方dx*dx + dy*dyO(1) · 整数运算,无误差
三点夹角acos(clamp1(u·v/(|u||v|)))O(1) · 或 atan2(|u×v|, u·v)
三角形面积(SAS)0.5*a*b*sin(C)O(1)
三角形面积(坐标)fabs(cross(B−A, C−A))/2O(1) · 不需开根号,推荐
三角形面积(三边)海伦公式 √(p(p−a)(p−b)(p−c))O(1) · p=(a+b+c)/2
点在三角形内三个叉积「同号(含 0)」O(1)
多边形面积鞋带公式 / 2O(n) · 首尾相连别忘了
线段相交双向跨立 + 共线特判O(1) · 退化情形要补判
二维凸包Andrew 单调链O(n log n) · 先 sort + unique
凸包周长相邻顶点距离累加O(n) · 记得首尾相连
凸包直径(最远点对)旋转卡壳(双指针)O(n) · 卡壳 + 面积比较
单峰函数极值三分法O(log n) · 每轮缩到 2/3
单调函数零点二分法(固定 100 次)O(log n) · 别用 while 判精度
二次函数最值顶点 x₀ = −b/(2a)O(1) · 与区间端点比大小
判别式Δ = b² − 4acO(1) · 判断实根个数
定和求积最大ab ≤ S²/4(等号 a = b)O(1) · 均值不等式
等差数列求和S = n(a₁ + aₙ)/2O(1) · 别用循环
等比数列求和a₁(1 − qⁿ)/(1 − q)O(log n) · q ≈ 1 时警戒精度
对数换底log_b a = ln a / ln bO(1) · 复杂度分析与实算
快速幂二进制拆分,O(log n) 次乘法O(log n)
坐标溢出估算叉积、距离平方 ≤ 2M²超 2×10⁹ 就上 long long
📌笔记卡
📖 必记概念
  • sgn(x):x > eps 返 1,x < −eps 返 −1,其余返 0——所有浮点「相等」判断的地基
  • 点积管「前后」(锐角/钝角/垂直),叉积管「左右」(左转/右转/共线)
  • 叉积的几何意义:a × b 等于以 a、b 为邻边的平行四边形有向面积
  • 鞋带公式:2S = |Σ(xᵢy_{i+1} − x_{i+1}yᵢ)|,对凹多边形同样成立
  • 余弦定理求角无歧义(cos 在 [0,π] 单调),正弦定理求角要防「两解」
  • 凸包:包含所有点的最小凸多边形,最远点对必在凸包顶点上
  • 单峰函数才能用三分;单调函数才适合二分
  • eps 不是越小越好,1e-8 覆盖绝大多数题
💻 必背模板
  • const double eps = 1e-8;
  • int sgn(double x) —— 三分支判断
  • double clamp1(double x) —— 保护 acos
  • struct Point 含 dot / cross / len / dist
  • int orient(O,A,B) —— 返回三点方向
  • bool onSegment(A,B,P) —— 共线 + 包围盒
  • bool segIntersect(A,B,C,D) —— 双向跨立 + 共线特判
  • void convexHull() —— Andrew 单调链(含 unique 去重)
  • double binaryZero(l, r, f) —— 固定 100 次二分
  • double ternarySearch(l, r) —— 三分法
⚠️ 考场提醒
  • 动手前先列四类边界:点重合 / 共线 / 退化(n ≤ 2)/ 极值
  • 看到坐标是整数,优先用 long long 全程整数运算,根本不引入 eps
  • 要用浮点时长个心眼:所有 == 都改 sgn
  • acos 前一定 clamp1;sqrt 前确认参数不会是负数
  • 凸包一定要 sort + unique,数组开到 2n
  • 多边形周长、面积都要首尾相连,循环里用 (i==n)?1:i+1
  • 能不开根号就不开:比大小用平方,比距离用面积
  • 写完先想一遍「n = 1 / n = 2 / 全共线 / 重复点」会不会崩
  • 随机数据对拍 O(n²) 暴力,是几何题最好的调试手段
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1判别式与韦达定理

已知二次方程 x² − 7x + 12 = 0。① 求判别式 Δ;② 求两根;③ 求两根之和与两根之积(用韦达定理);④ 求 x₁² + x₂²(不要先解方程,用韦达定理算)。

查看参考答案

① 判别式:a = 1, b = −7, c = 12。
Δ = b² − 4ac = (−7)² − 4×1×12 = 49 − 48 = 1。
Δ = 1 > 0,所以有两个不相等的实根。

② 求根:x = (7 ± √1) / 2 = (7 ± 1) / 2
所以 x₁ = 4,x₂ = 3。

③ 韦达定理:
x₁ + x₂ = −b/a = 7 (验证:4 + 3 = 7 ✅)
x₁ · x₂ = c/a = 12 (验证:4 × 3 = 12 ✅)

④ 用韦达定理算 x₁² + x₂²:
关键变形:x₁² + x₂² = (x₁ + x₂)² − 2x₁x₂
代入:= 7² − 2×12 = 49 − 24 = 25 (验证:16 + 9 = 25 ✅)

为什么要用韦达定理?因为当方程是 ax² + bx + c = 0 且系数很大、根是无理数时,先解方程再代入会引入浮点误差,而且可能根本解不出来。韦达定理让我们只用 b/a 和 c/a 就能算出所有「关于两根对称的表达式」。这是竞赛中「不解方程也能算」的典型例子。

可推广的变形公式:
· x₁² + x₂² = S² − 2P (S = x₁+x₂,P = x₁x₂)
· (x₁ − x₂)² = S² − 4P (这也等于 Δ/a²)
· 1/x₁ + 1/x₂ = S/P (要求 P ≠ 0)
· x₁³ + x₂³ = S³ − 3PS

A2均值不等式

① 已知 x > 0,求 x + 4/x 的最小值,并说明取到最小值时 x 的值;② 已知 x + y = 10(x, y > 0),求 xy 的最大值;③ 已知 xy = 16(x, y > 0),求 x + y 的最小值。

查看参考答案

① 求 x + 4/x 的最小值:
由均值不等式 a + b ≥ 2√(ab),令 a = x,b = 4/x:
x + 4/x ≥ 2√(x · 4/x) = 2√4 = 4
等号成立条件:a = b,即 x = 4/x,解得 x² = 4,x = 2(舍去负根)。
答案:最小值 4,在 x = 2 时取到。(验证:2 + 4/2 = 2 + 2 = 4 ✅)

② 已知 x + y = 10 求 xy 的最大值:
xy ≤ ((x+y)/2)² = (10/2)² = 25
等号成立条件:x = y = 5。
答案:最大值 25。(验证:5 × 5 = 25 ✅)

③ 已知 xy = 16 求 x + y 的最小值:
x + y ≥ 2√(xy) = 2√16 = 8
等号成立条件:x = y = 4。
答案:最小值 8。(验证:4 + 4 = 8 ✅)

三句话总结(这是均值不等式最常考的三个方向):
· 「定积求和最小」:xy 固定 → x + y ≥ 2√(xy),和取最小;
· 「定和求积最大」:x + y 固定 → xy ≤ ((x+y)/2)²,积取最大;
· 「配凑定值」:像 ① 那样,把 x 和 4/x 配成「乘积是常数」的一对,这是最关键的一步技巧。

易错:① 忘记检查「等号能否取到」——例如 x + 4/x 若限定 x ≥ 3,最小值就不是 4(因为 x = 2 不在范围内),此时要看端点 x = 3 得 3 + 4/3 ≈ 4.333;② 忘记 x > 0 的前提(若 x < 0,x + 4/x ≤ −4,是最大值);③ 用均值不等式却配不出定值。

A3eps 与浮点比较

设 double a = 0.1 + 0.2;、double b = 0.3;。① a == b 的结果是什么?为什么?② 若 eps = 1e-8,用 sgn 判等的结果是什么?③ 如果把 eps 设成 1e-20,sgn 判等的结果又会变成什么?④ 这说明了 eps 的什么性质?

查看参考答案

① a == b 的结果是 false(不相等)。
原因:0.1、0.2、0.3 在二进制浮点里都是无限循环小数(就像十进制里的 1/3),只能存近似值。0.1 的实际存储值约为 0.1000000000000000055511151231257827,0.2 约为 0.2000000000000000111022302462516,相加得约 0.3000000000000000444089209850063,而 0.3 存的是约 0.2999999999999999888977697537484。
两者不相等,差约 5.5×10⁻¹⁷。

② eps = 1e-8 时,sgn 判等返回 0(相等)。
sgn(a − b):a − b ≈ 5.5×10⁻¹⁷,它 并不大于 1e-8,也不小于 −1e-8,所以返回 0,判定为「相等」。这正是我们想要的答案。

③ eps = 1e-20 时,sgn 判等返回 1(不相等)。
因为 5.5×10⁻¹⁷ > 1e-20,sgn 返回 1,判定为「a > b」。误差没有被 eps 吸收掉。

④ 这说明的 eps 性质:
eps 必须「大于浮点误差的量级」,才能起到「把相等变成一个区间」的作用。
· eps 太小(如 1e-20)→ 误差渗过来,本该相等的判成不等 → 错误判断;
· eps 太大(如 1e-3)→ 本来不相等的两点被当成同一个点 → 漏掉合法情况;
· eps 合适(1e-8 左右)→ 恰好覆盖 double 的误差量级(约 1e-16 相对误差,经过几十次运算后放大到 1e-12 ~ 1e-10)。
所以「eps 取 1e-8」不是随便定的,它是「比误差大若干数量级、又比题目要求的精度小若干数量级」的一个折中值。

补充:eps 该怎么选?
题要求保留 2 位小数 → eps 取 1e-6 ~ 1e-8 都行;
题要求保留 6 位小数 → eps 取 1e-10 左右;
题要求保留 9 位以上(如 P1024 这类)→ 干脆别用 eps 比较,改用固定次数的二分。
「固定迭代次数」是绕开 eps 选择困难的最稳办法。

A4叉积符号手算

O = (0, 0)。判断下列每组三点的方向(左转/右转/共线):① O, A(1,0), B(0,1);② O, A(1,0), B(0,−1);③ O, A(2,4), B(3,6);④ O, A(1,1), B(2,2);⑤ O, A(3,0), B(3,5)。

查看参考答案

计算方法:叉积 (A−O) × (B−O) = A.x·B.y − A.y·B.x。

序号AB叉积计算符号结论
①(1, 0)(0, 1)1×1 − 0×0 = 1> 0左转(逆时针)
②(1, 0)(0, −1)1×(−1) − 0×0 = −1< 0右转(顺时针)
③(2, 4)(3, 6)2×6 − 4×3 = 12 − 12 = 0= 0共线(同向)
④(1, 1)(2, 2)1×2 − 1×2 = 0= 0共线(同向)
⑤(3, 0)(3, 5)3×5 − 0×3 = 15> 0左转(逆时针 90°)

逐例的几何直觉:
① A 指向右(x 轴正方向),B 指向上(y 轴正方向)。从 A 转到 B 是逆时针 90°,左转 ✅;
② A 向右,B 向下,从 A 转到 B 是顺时针 90°,右转 ✅;
③ A = (2,4)、B = (3,6) 都在直线 y = 2x 上,且 B 是 A 的 1.5 倍,同向共线;
④ A = (1,1)、B = (2,2) 都在 y = x 上,B 是 A 的 2 倍,同向共线;
⑤ A 指向右(正 x 方向),B 指向上(正 y 方向)但更长,从 A 转到 B 仍是逆时针 90°,左转 ✅。

⚠️ 三个重要提醒:
① 叉积为 0 无法区分「同向」和「反向」。例如 O, (1,0), (−2,0) 的叉积也是 1×0 − 0×(−2) = 0,但它其实是反向共线。要区分同向还是反向,必须再用点积:点积 > 0 同向,< 0 反向。
② 叉积的绝对值 = 平行四边形面积。例 ① 中 |1| = 1,恰好是 (0,0)(1,0)(1,1)(0,1) 这个单位正方形的面积 ✅。
③ 叉积不是「交换律成立」的运算,而是反交换:a × b = −(b × a)。写代码时不要把两个向量写反,否则左右判断整个反过来——这是凸包和点在三角形内判定出错的最常见原因之一。

🔵 B组 · 提高(4题)

B1余弦定理 + atan2 对比

三角形 ABC,A(0, 0)、B(3, 0)、C(0, 4)。① 用余弦定理求 ∠C(顶点为 C 的角),保留 4 位小数(弧度);② 用 atan2 求同一个角,比较两者是否一致;③ 验证三个内角之和是否为 π。

查看参考答案

① 余弦定理求 ∠C:顶点 C,两条边是 CA 和 CB。
CA = A − C = (0, −4),模长 |CA| = 4;
CB = B − C = (3, −4),模长 |CB| = √(9+16) = 5;
点积 CA · CB = 0×3 + (−4)×(−4) = 16。
cos C = 16 / (4 × 5) = 0.8
∠C = acos(0.8) ≈ 0.6435 rad(即 36.87°)

② 用 atan2 求 ∠C:
|CA × CB| = |0×(−4) − (−4)×3| = |0 + 12| = 12
∠C = atan2(12, 16) ≈ 0.6435 rad ✅ 与 ① 完全一致。

为什么两者等价?因为 tan θ = |a×b| / (a·b):把分子分母同除以 |a||b|,就得到 tan θ = sin θ / cos θ。atan2 相当于「同时看 sin 和 cos 的符号与大小」,比只看 cos 更稳健。本例中 sin C = 12/(4×5) = 0.6、cos C = 0.8,atan2(0.6, 0.8) = 0.6435 ✅

③ 验证内角和为 π:
∠A:AB = (3,0)、AC = (0,4),点积 = 0 → ∠A = π/2 ≈ 1.5708(直角);
∠B:BA = (−3,0)、BC = (−3,4),点积 = 9,模长 3 和 5,cos B = 9/15 = 0.6,∠B = acos(0.6) ≈ 0.9273;
求和:1.5708 + 0.9273 + 0.6435 = 3.1416 ≈ π ✅

为什么推荐 atan2 而不是 acos?
① 精度更好。当角度接近 0 或 π 时 cos θ ≈ ±1,acos 在这个区间极其敏感——cos 变化 1e-8,角度可能变化 1e-4。而 atan2(|cross|, dot) 直接由 sin 值读出角度(小角度时 sin θ ≈ θ),稳定得多。
② 永远不会产生 NaN。atan2 的定义域是全体实数对,不存在越界;acos 必须夹紧参数到 [−1, 1]。

易错:① 求 ∠C 用 CA 和 CB(都对),但若误写成 AC 与 BC(方向都反过来),点积不变、叉积的绝对值也不变,结果居然还是对的——因为 cos 是偶函数、|cross| 取了绝对值。但求「有向角」时就不能这么随意,方向写反会得到补角。
② 求 ∠C 时误用了 A − B 和 A − C(这是以 A 为顶点的边),算出来的是 ∠A。「以谁为顶点,就用谁指向另外两点的向量」是必须记牢的口诀。

B2点在三角形内(含边界)

三角形 A(0, 0)、B(4, 0)、C(0, 4)。判断下列各点是否在三角形内(含边界):P(1, 1)、Q(2, 2)、R(3, 2)、S(0, 0)、T(1, 3)。要求写出每个点的三个叉积符号。

查看参考答案

先把三个叉积化成关于 (x, y) 的表达式(这样后面代入就行,不用每次算一遍):

叉积计算化简结果符号含义
d1 = (B−A)×(P−A)(4,0)×(x,y) = 4y − 04ysign(y):点在 AB 上方?
d2 = (C−B)×(P−B)(−4,4)×(x−4,y) = −4y − 4(x−4)16 − 4x − 4ysign(4 − x − y):在斜边哪侧?
d3 = (A−C)×(P−C)(0,−4)×(x,y−4) = 0 + 4x4xsign(x):点在 AC 右方?

再逐个代入:

点d1 = 4yd2 = 16−4x−4yd3 = 4x同号?结论
P(1, 1)+4+8+4全正在内部
Q(2, 2)+80+8正 + 0在斜边 BC 上
R(3, 2)+8−4+12既有正又有负在外部
S(0, 0)0+160正 + 0是顶点 A(在边界上)
T(1, 3)+120+4正 + 0在斜边 BC 上

逐个几何验证:
P(1,1):1 + 1 = 2 < 4,严格在斜边内侧,且 x > 0、y > 0 → 在内部 ✅
Q(2,2):2 + 2 = 4,恰好在斜边 BC(直线 x + y = 4)上 ✅
R(3,2):3 + 2 = 5 > 4,越过了斜边,跑到三角形外面 ✅
S(0,0):显然就是顶点 A,在边界上 ✅
T(1,3):1 + 3 = 4,也在斜边上 ✅

代码怎么写?

C++点在三角形内(含边界)
int orient(const Point &O, const Point &A, const Point &B) {
    return sgn((A - O).cross(B - O));
}
bool inTriangle(const Point &A, const Point &B,
                const Point &C, const Point &P) {
    int d1 = orient(A, B, P);
    int d2 = orient(B, C, P);
    int d3 = orient(C, A, P);
    bool hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
    bool hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
    return !(hasNeg && hasPos);      // 无「既有正又有负」= 三者同号(含 0)
}

关键收获:① 不要写 d1 > 0 && d2 > 0 && d3 > 0,因为它只对「逆时针给出的三角形」成立,而且会把边界点判成外部;
② 用「!(既有正又有负)」表达「同号(含 0)」,一行搞定所有情况,不需要分类讨论顶点顺序、不需要单独判边界;
③ 如果题目要求严格在内部(不含边界),只需加上 d1 != 0 && d2 != 0 && d3 != 0。

复杂度与推广:O(1) 判断。这套方法可以直接推广到任意凸多边形——只要把「三条边」换成「n 条边」,逐个判叉积是否同号即可,复杂度 O(n)。但对凹多边形无效,凹多边形需要用射线法或转角法。

B3线段相交的边界分类

判断下列五组线段是否相交,要求写出 d1~d4 的符号并说明走了哪个分支:
① AB =(0,0)-(4,4),CD =(0,4)-(4,0);
② AB =(0,0)-(4,4),CD =(5,5)-(6,6);
③ AB =(0,0)-(4,0),CD =(2,−1)-(2,1);
④ AB =(0,0)-(2,0),CD =(2,0)-(4,0);
⑤ AB =(0,0)-(4,0),CD =(5,1)-(6,2)。

查看参考答案

回顾判据:d1 = orient(A,B,C)、d2 = orient(A,B,D)、d3 = orient(C,D,A)、d4 = orient(C,D,B)。
分支一(正常跨立):d1*d2 < 0 && d3*d4 < 0 → 相交;
分支二(共线特判):某个 d == 0 且对应端点在另一条线段上 → 相交;
其余情况:不相交。

组d1d2d3d4走哪个分支结论
①+16−16−16+16d1·d2 < 0 且 d3·d4 < 0相交 ✅
②0000共线特判,四个 onSegment 全 false不相交 ✅
③−4+4+4−4d1·d2 < 0 且 d3·d4 < 0相交 ✅
④0000共线特判,C(2,0) 落在 AB 上相交 ✅
⑤+4+8+40d1·d2 > 0 不跨立;d4 = 0 但 B ∉ CD不相交 ✅

逐组几何解释:
① 两条对角线交叉,标准的 X 型,双重跨立成立。
② 两条线段都在直线 y = x 上,但 x 区间分别是 [0,4] 与 [5,6],共线且分离,所以不相交。这是「叉积为 0 必须补判范围」的最典型例子。
③ CD 是竖直线 x = 2 上 y ∈ [−1,1] 的一段,AB 是 x 轴上的 [0,4],交点是 (2, 0),落在两条线段上。
④ 两条线段共线且只在端点 (2, 0) 处相接。「端点接触」算不算相交,取决于题目定义——本讲的 onSegment 含等号,所以算相交;若题目要求「严格交叉(不含端点)」,就要把 onSegment 的等号去掉,并把 d1*d2 < 0 与 d3*d4 < 0 保留。
⑤ d1、d2 同号(C、D 都在直线 AB 的同一侧),所以根本不存在跨立可能。只做一次跨立判断就会在这里出错——因为若只判 CD 方向,d3 = +4、d4 = 0,看起来「像」是跨立(一端在线上),必须靠 onSegment(C,D,B) 才能确认 B 并不在 CD 上。

⚠️ 三条必须记住的结论:
① 必须双向跨立,只判一个方向一定会出错(本组第 ⑤ 就是反例);
② 共线情形必须单独补判,因为叉积一律为 0,无法区分「重叠」和「分离」;
③ 「端点算不算相交」要读题,这是 onSegment 里等号取舍的唯一依据。

复杂度:每组都是 O(1) 常数次叉积运算。如果题目要判 n 条线段之间有没有相交(n 很大),O(n²) 的两两判定会超时,需要用到扫描线 + 平衡树(Shamos–Hoey 算法)做到 O(n log n),那属于省选内容。

B4鞋带公式 + 凹多边形

① 用鞋带公式求多边形 (0,0) → (4,0) → (4,4) → (2,2) → (0,4) 的面积(注意这是一个凹多边形);② 用「正方形减去一个三角形」的方式验证结果;③ 若把顶点顺序改成顺时针,结果会不会变?④ 说明「忘记首尾相连」会造成什么后果(用 (1,1) → (5,1) → (5,4) → (1,4) 演示)。

查看参考答案

① 鞋带公式逐项计算:公式为 2S = Σ (xᵢ·y_{i+1} − x_{i+1}·yᵢ),其中第 5 项要接回第 1 个点。

i第 i 个点第 i+1 个点xᵢ·y_{i+1}x_{i+1}·yᵢ差
1(0, 0)(4, 0)0×0 = 04×0 = 00
2(4, 0)(4, 4)4×4 = 164×0 = 0+16
3(4, 4)(2, 2)4×2 = 82×4 = 80
4(2, 2)(0, 4)2×4 = 80×2 = 0+8
5(0, 4)(0, 0) ← 接回0×0 = 00×4 = 00
Σ24

面积 S = |24| / 2 = 12。

② 用「正方形减三角形」验证:
这个多边形可以看成 4×4 的正方形(面积 16)挖掉一个三角形:顶点 (4,4)、(2,2)、(0,4)。
该三角形的底边是上边从 (0,4) 到 (4,4),长 4;顶点 (2,2) 到底边(直线 y = 4)的距离是 4 − 2 = 2。
三角形面积 = 4 × 2 / 2 = 4。
所以多边形面积 = 16 − 4 = 12 ✅ 与鞋带公式一致。

③ 改成顺时针顺序,结果不变。把顺序反过来:(0,0) → (0,4) → (2,2) → (4,4) → (4,0)。此时每一项的符号全部翻转,Σ 变成 −24。但只要最后取绝对值 fabs(s2)/2,结果仍然是 12。
这就是鞋带公式最优雅的地方:它不关心顶点是顺时针还是逆时针。(顺带说一句,如果不取绝对值而是取 s2/2,正负号恰好能告诉你「顶点是逆时针还是顺时针给出的」——这个性质在做凸包时很有用。)

④ 「忘记首尾相连」的后果演示:
仍用公式,但矩形改用 (1,1) → (5,1) → (5,4) → (1,4)。
正确版本(5 项):
· (1,1)→(5,1):1×1 − 5×1 = −4
· (5,1)→(5,4):5×4 − 5×1 = +15
· (5,4)→(1,4):5×4 − 1×4 = +16
· (1,4)→(1,1):1×1 − 1×4 = −3 ← 这一项就是「首尾相连」
Σ = −4 + 15 + 16 − 3 = 24,面积 = 12 ✅(正好是 4×3 的矩形)

错误版本(漏掉第 4 项):Σ = −4 + 15 + 16 = 27,面积 = 13.5 ❌
一个 4×3 的矩形被算成了 13.5——多算了 1.5,误差 12.5%。这就是「忘记首尾相连」的代价。

关键收获:
① 鞋带公式对凹多边形同样成立,因为它本质是「有向面积累加」——凸出去的部分算正、凹进去的部分算负,自动抵消;
② 顶点顺序顺时针或逆时针都行,最后取绝对值即可;
③ 「接回第一个点」这一项绝对不能漏,而且当第一个点不是原点时,这一项通常不为 0。
④ 内存建议:写代码时用「数组开两倍」或「(i == n) ? 1 : i + 1」来避免越界。

🔴 C组 · 挑战(4题)

C1整数域三分法

求函数 f(i) = −i² + 10i + 3 在整数 i ∈ [0, 10] 上的最大值及对应的 i。要求用三分法,并说明为什么整数域三分需要特殊处理;再说明当 f(m1) == f(m2) 时该怎么移动区间。

查看参考答案

答案:i = 5 时取得最大值 28。

先枚举验证(这是对拍用的暴力解):

i012345678910
f(i)31219242728272419123

数列先增后减,是标准单峰序列,最大值 28 出现在 i = 5。✅

为什么整数域三分要特殊处理?
实数域三分每轮把区间缩到 2/3,能无限逼近。但整数域上:
· m1 = l + (r−l)/3、m2 = r − (r−l)/3 都是整数除法,当区间长度很小时 m1 和 m2 可能相等(例如 l=4, r=5 时,m1 = 4 + 0 = 4,m2 = 5 − 0 = 5,虽然不相等;但 l=3, r=4 时 m1 = 3, m2 = 4 —— 再往下 l=4, r=5 → 区间长度 1 时就会卡住);
· 一旦出现 m1 == m2,若还按「f(m1) < f(m2) 就 l = m1」移动,可能把区间缩成空集(l = m1 = r 而 r = m2 = l),进入死循环或得到错误答案。

标准解法:当区间足够小时,直接暴力枚举。

C++整数域三分法(收尾暴力枚举)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
ll f(ll i) { return -i*i + 10*i + 3; }

int main() {
    ll l = 0, r = 10;
    while (r - l > 3) {               // 区间足够大才做三分
        ll m1 = l + (r - l) / 3;
        ll m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) l = m1;       // 峰在右侧
        else               r = m2;       // 峰在左侧(含相等,随便移动一侧即可)
    }
    // 收尾:剩余不超过 4 个候选,暴力枚举(并记录取到最大值的下标)
    ll best = LLONG_MIN, bestI = l;
    for (ll i = l; i <= r; i++) {
        if (f(i) > best) { best = f(i); bestI = i; }
    }
    printf("i = %lld, f(i) = %lld\n", bestI, best);
    return 0;
}
运行结果
i = 5, f(i) = 28

手工跟踪区间收缩(关键几步):
  初始:l = 0, r = 10(长度 10 > 3,做三分)
        m1 = 0 + 10/3 = 3,m2 = 10 - 10/3 = 7
        f(3) = 24,f(7) = 24,相等 → 走 else 分支 r = 7
  第2轮:l = 0, r = 7(长度 7 > 3)
        m1 = 0 + 7/3 = 2,m2 = 7 - 7/3 = 5
        f(2) = 19,f(5) = 28,19 < 28 → l = 2
  第3轮:l = 2, r = 7(长度 5 > 3)
        m1 = 2 + 5/3 = 3,m2 = 7 - 5/3 = 5
        f(3) = 24,f(5) = 28,24 < 28 → l = 3
  第4轮:l = 3, r = 7(长度 4 > 3)
        m1 = 3 + 4/3 = 4,m2 = 7 - 4/3 = 6
        f(4) = 27,f(6) = 27,相等 → r = 6
  退出循环:l = 3, r = 6(长度 3,不大于 3)
  暴力枚举 i = 3,4,5,6 → f 分别 24, 27, 28, 27 → 最大 28,i = 5 ✅

关于 f(m1) == f(m2) 怎么移动?
当 f(m1) == f(m2) 时,最大值要么在 m1 和 m2 之间,要么正好是两者之一(因为单峰函数在两点等高时,峰不可能落在 [l, m1) 或 (m2, r] 上)。所以 l = m1 或 r = m2 都可以,两种都不会漏掉答案。
但更稳的做法是:相等时同时收缩 l = m1, r = m2,收敛更快,且不会因为方向选错而慢一步。
⚠️ 不过要注意:如果是「求最大值且要输出最小的 i」这类带字典序要求的问题,相等时同时收缩会丢掉「m1 也是解」的信息,此时应该改成 r = m2(保留左侧的 m1)。「相等时怎么动」取决于题目对答案的附加要求。

易错:① 不做收尾暴力枚举,导致区间长度 1、2 时三分死循环;② 用 while (l < r) 直接判,当 m1 == m2 时可能不收缩而 TLE;③ f(i) 用 int 计算,i*i 在 i 很大时溢出(本题 i ≤ 10 没事,但要做成通用模板就要用 long long)。

C2旋转卡壳最远点对

给定 5 个点:(0,0)、(3,0)、(3,2)、(0,2)、(1,1)。① 求凸包并说明哪个点被排除;② 手工模拟旋转卡壳的每一轮(i = 0,1,2,3),写出面积比较的过程和每轮更新后的答案;③ 给出最终答案。

查看参考答案

① 求凸包:按 (x, y) 字典序排序后为 (0,0)、(0,2)、(1,1)、(3,0)、(3,2)。
Andrew 单调链构造出的凸包是 (0,0) → (3,0) → (3,2) → (0,2)(逆时针),共 4 个顶点。
内部点 (1,1) 被排除——它在 3×2 的矩形内部,任何方向看都有人比它更靠外。

② 手工模拟旋转卡壳:记 hull[0]=(0,0)、hull[1]=(3,0)、hull[2]=(3,2)、hull[3]=(0,2),m = 4。
初始:j = 2(即 (3,2)),ans = 0。

i当前边 i→nijarea(i,ni,j)area(i,ni,j+1)j 是否前进本轮更新 ans
0(0,0)→(3,0)2|(3,0)×(3,2)| = 6|(3,0)×(0,2)| = 6否(6 不 > 6)max(d²((0,0),(3,2)), d²((3,0),(3,2))) = max(13, 4) = 13
1(3,0)→(3,2)2→3|(0,2)×(0,2)| = 0|(0,2)×(−3,2)| = 6是(6 > 0)max(13, d²((3,0),(0,2)), d²((3,2),(0,2))) = max(13, 13, 9) = 13
2(3,2)→(0,2)3→0|(−3,0)×(0,0)−…| = 0|(−3,0)×(0,−2)| = 6是(6 > 0)max(13, d²((3,2),(0,0)), d²((0,2),(0,0))) = max(13, 13, 4) = 13
3(0,2)→(0,0)0→1|(0,−2)×(0,−2)| = 0|(0,−2)×(3,−2)| = 6是(6 > 0)max(13, d²((0,2),(3,0)), d²((0,0),(3,0))) = max(13, 9, 9) = 13

③ 最终答案:ans = 13,对应点对 (0,0) 与 (3,2),距离 √13 ≈ 3.6056。

检验:矩形 3×2 的四个顶点中,最远点对是两条对角线,长度都是 √(3² + 2²) = √13。内部点 (1,1) 到四个顶点的距离分别约为 1.414、2.236、2.236、1.414,都不超过 √13 ≈ 3.606。✅

⚠️ 三个关键观察:
① j 从 2 单调前进到 3、0、1,一共只走了 4 步,从不后退。这正是 O(n) 的来源——i 走 m 轮,j 也总共只走 m 步,总操作数 O(2m)。如果每轮都把 j 重置为 0 重新搜,复杂度就退化成 O(m²),对 n = 5×10⁴ 会超时。
② 面积比较用的是 cross 的绝对值,不是真实距离。因为底边固定,面积与「j 到底边所在直线的距离」成正比,用面积比较等价于用距离比较,但完全避免了开根号。
③ 每轮要同时用 hull[i] 和 hull[ni] 更新答案。因为「对踵点」可能是与 i 或 i+1 中的任意一个配对更远,只更新一个会漏解。本组 i = 1 那轮如果不更新 dist2(hull[2], hull[3]),虽然不影响本题答案,但在其他数据上就会出错。

为什么这道题不能用「枚举 + 剪枝」蒙过去?因为最远点对一定在凸包上,而凸包的顶点数在最坏情况下仍是 n。所以要真正解决 n = 5×10⁴ 的数据,必须用旋转卡壳把凸包上的 O(m²) 降到 O(m)。这就是这个算法存在的理由。

C3浮点精度陷阱(灾难性相消)

① 用 1 - cos(1e-8) 计算,结果是多少?② 理论值应该是多少?③ 用 2*sin(x/2)*sin(x/2) 改写后结果是多少?④ 请说明这类「灾难性相消」在计算几何里会如何坑到你,并给出三条防守策略。

查看参考答案

① 直接计算 1 - cos(1e-8) 的结果是 0。
为什么?cos(1e-8) 的数学值是 1 − 5×10⁻¹⁷,它需要17 位以上的十进制有效数字才能表示。而 double 只有约 15.95 位有效数字,所以 cos(1e-8) 被舍入成了精确的 1.0。
于是 1 − 1.0 = 0。一个本该是 5×10⁻¹⁷ 的量,被算成了 0。

② 理论值:由泰勒展开 cos x = 1 − x²/2 + x⁴/24 − …,
1 − cos x ≈ x²/2 = (1e−8)² / 2 = 5×10⁻¹⁷。

③ 用 2sin²(x/2) 改写后的结果:
利用恒等式 1 − cos x = 2 sin²(x/2):
sin(5×10⁻⁹) ≈ 5×10⁻⁹(相对误差约 1e-17,非常精确)
2 × (5×10⁻⁹)² = 2 × 2.5×10⁻¹⁷ = 5×10⁻¹⁷ ✅ 与理论值完全一致。

C++两种算法对比
#include <bits/stdc++.h>
using namespace std;

int main() {
    double x = 1e-8;

    // ---- 写法 A:直接相减(灾难性相消)----
    double bad = 1.0 - cos(x);
    printf("写法A  1 - cos(x)        = %.17e\n", bad);

    // ---- 写法 B:用 2sin^2(x/2) 等价变形 ----
    double s = sin(x / 2);
    double good = 2.0 * s * s;
    printf("写法B  2*sin^2(x/2)      = %.17e\n", good);

    // ---- 参考:cos(x) 的实际存储值 ----
    printf("cos(1e-8) 的存储值        = %.20f\n", cos(x));
    printf("理论值 x^2/2              = %.17e\n", x * x / 2);
    return 0;
}
运行结果
写法A  1 - cos(x)        = 0.00000000000000000e+00
写法B  2*sin^2(x/2)      = 5.00000000000000031e-17
cos(1e-8) 的存储值        = 1.00000000000000000000
理论值 x^2/2              = 5.00000000000000031e-17

结论:
  写法 A 得到 0(错误),写法 B 得到 5e-17(正确,且与理论值逐位一致)。
  cos(1e-8) 的存储值被打印成正好 1.00000000000000000000,
  证明「有效数字全部丢失」是 1 - cos(x) 失效的根本原因。

④ 这类错误在计算几何里怎么坑到你?

典型场景灾难性相消出现在哪造成的后果
判断三点是否近似共线1 − cos θ(θ 很小)共线的判成不共线,凸包多出脏点
等边三角形的角度判定cos θ 接近 ±1角度误差被放大 1e4 倍,交点算歪
点到直线的距离(点很近)|ax₀+by₀+c| 的两个大数相减距离变成负数或随机数
海伦公式(细长三角形)p − a(a 接近 p)面积趋向 0 或变成虚数
等比数列求和 q ≈ 11 − qⁿ分母 1 − q 也接近 0,误差被双重放大
判别式 b² − 4ac两根几乎重合时小根的有效数字全部丢失

三条防守策略:
策略一:等价变形,避免大数相减。记住几个常用恒等式:
· 1 − cos x = 2 sin²(x/2);
· 1 + cos x = 2 cos²(x/2);
· sin x ≈ x(x 很小时可直接用);
· √(a² + b²) − a = b² / (√(a² + b²) + a)(分子有理化)。最后这条在求「两点距离减去一个常数」时非常救命。

策略二:用更稳的函数代替。
· 求角度 → 用 atan2(|cross|, dot) 代替 acos(...);
· 求面积 → 用叉积代替海伦公式;
· 求距离 → 比大小时直接用距离的平方,根本不开根号;
· 求小量 → 用 expm1 / log1p 这类专门的稳定函数(如 eˣ − 1 写成 expm1(x))。

策略三:能整则整,从源头消灭误差。
如果坐标是整数,就用 long long 全程做整数运算:叉积、距离的平方、面积的 2 倍,全部可以精确表示。只在最后输出时才除以 2、开根号、乘 π。
「先整数算完,最后一步才转浮点」是计算几何最有效的精度策略,没有之一。

一句话总结:浮点误差不可怕,可怕的是「两个几乎相等的数相减」——它会把相对误差放大到灾难级。看到相减,先看两边是不是差不多大;是的话,立刻想等价变形。

C4综合 · 外心(点积解方程组)

① 求三角形 A(0,0)、B(4,0)、C(0,3) 的外心坐标与半径;② 写出一般的「由三点求外心」的方程推导;③ 用 R = abc/(4S) 验算半径;④ 说明当三点共线时会发生什么。

查看参考答案

① 外心坐标与半径:
三角形 A(0,0)、B(4,0)、C(0,3),由勾股定理 BC = √(16+9) = 5,所以这是一个 3-4-5 直角三角形,直角在 A。
直角三角形的外心就在斜边 BC 的中点:
O = ((4+0)/2, (0+3)/2) = (2, 1.5),R = BC/2 = 2.5。

② 一般的方程推导:
外心 O 满足「到三个顶点距离相等」:|O−A|² = |O−B|² = |O−C|²。
先展开 |O−A|² = |O−B|²:
(O−A)·(O−A) = (O−B)·(O−B)
|O|² − 2A·O + |A|² = |O|² − 2B·O + |B|²
|O|² 两边抵消,整理得:2(B−A)·O = |B|² − |A|²
同理 |O−A|² = |O−C|² 给出:2(C−A)·O = |C|² − |A|²。
这就是一个关于 (x, y) 的二元一次方程组——每个方程都是「点积 = 常数」的形式。

有 A = (0,0)、B = (4,0)、C = (0,3),且 |A|² = 0、|B|² = 16、|C|² = 9:
· 由 2(B−A)·O = 16:2(4x + 0y) = 16 → 8x = 16 → x = 2;
· 由 2(C−A)·O = 9:2(0x + 3y) = 9 → 6y = 9 → y = 1.5。
所以 O = (2, 1.5) ✅ 与「斜边中点」的直觉判断一致。
半径 R = |O − A| = √(2² + 1.5²) = √6.25 = 2.5 ✅

③ 用 R = abc/(4S) 验算:
三边:a = BC = 5、b = CA = 3、c = AB = 4;
面积:直角三角形 S = ½ × 3 × 4 = 6;
R = (5 × 3 × 4) / (4 × 6) = 60 / 24 = 2.5 ✅ 两种方法完全一致。

④ 三点共线时会怎样?
当 A、B、C 共线时,方程组的两条直线平行或重合,无唯一解。用克拉默法则看就是行列式为 0:
det = (B−A).x × (C−A).y − (B−A).y × (C−A).x = cross(B−A, C−A)
这个 det 恰好就是叉积!所以「三点共线 ⟺ det = 0 ⟺ 无外心」。
几何意义:共线三点的外接圆半径是无穷大(退化成一条直线),代码里必须特判,否则会除以 0 得到 inf 或 nan。

C++由三点求外心与半径(克拉默法则)
#include <bits/stdc++.h>
using namespace std;

struct P { double x, y; };
P operator-(const P &a, const P &b) { return {a.x - b.x, a.y - b.y}; }
double dot(const P &a, const P &b)   { return a.x*b.x + a.y*b.y; }
double cross(const P &a, const P &b) { return a.x*b.y - a.y*b.x; }
double len2(const P &a) { return a.x*a.x + a.y*a.y; }

// 返回 true 表示求出了外心,圆心写入 o,半径写入 r
bool circumcenter(const P &A, const P &B, const P &C, P &o, double &r) {
    P u = B - A, v = C - A;
    double det = cross(u, v);              // 等于两倍三角形面积
    if (fabs(det) < 1e-12) return false;  // 三点共线,无外心

    double bu = len2(B) - len2(A);        // 右边常数:|B|²-|A|²
    double bv = len2(C) - len2(A);        // 右边常数:|C|²-|A|²
    // 方程组: 2u·O' = bu ,  2v·O' = bv  (O' = O - A)
    // 展开:2ux*x + 2uy*y = bu ; 2vx*x + 2vy*y = bv
    double x = (bu * v.y - u.y * bv) / (2.0 * det);
    double y = (u.x * bv - bu * v.x) / (2.0 * det);
    o = {A.x + x, A.y + y};
    r = sqrt(x*x + y*y);
    return true;
}

int main() {
    P A{0,0}, B{4,0}, C{0,3}, o;
    double r;
    if (circumcenter(A, B, C, o, r))
        printf("外心 O = (%.4f, %.4f),  R = %.4f\n", o.x, o.y, r);
    else
        printf("三点共线,不存在外心\n");
    return 0;
}
运行结果
外心 O = (2.0000, 1.5000),  R = 2.5000

验证:
  |O-A| = √(2² + 1.5²)   = √6.25 = 2.5 ✅
  |O-B| = √((2-4)²+1.5²) = √6.25 = 2.5 ✅
  |O-C| = √(2²+(1.5-3)²) = √6.25 = 2.5 ✅
  R = abc/(4S) = 5×3×4/(4×6) = 60/24 = 2.5 ✅

退化测试(A(0,0)、B(1,1)、C(2,2),三点共线在 y = x 上):
  det = cross((1,1),(2,2)) = 1×2 − 1×2 = 0
  输出:三点共线,不存在外心 ✅

这道题综合了什么?
① 代数:二元一次方程组 + 克拉默法则(行列式);
② 几何:外心定义、直角三角形斜边中点、R = abc/(4S);
③ 向量:点积展开 |O−A|²、叉积求行列式;
④ 精度:用 fabs(det) < eps 判断退化情形,避免除零。
这正是「高中代数与几何要点」这一讲的意义所在——它不是教一个个孤立的公式,而是教你把它们组合起来解决真问题。

易错:① 忘记 |O|² 会在展开时抵消,从而把方程组写复杂了;② 忘记最后要加回 A(因为我们解的是 O' = O − A);③ 忘记特判共线导致除零;④ 用「垂心」「重心」的公式去算外心——三个心的公式不一样,重心最简单(三个顶点坐标平均),外心最复杂(要解方程组)。

推广:这套「点积解方程组」的技巧可以解一大类「等距问题」:
· 最小圆覆盖(每次由三个边界点定圆,用同样的方程);
· 球心求解(三维版,三方程三未知数);
· 线性插值 / 多项式拟合(本质也是解线性方程组)。掌握「把几何条件翻译成线性方程」的思维,比记住任何一个公式都重要。

🏆 洛谷原题精选(12道)

按难度从易到难排列。前 6 道建议全部手敲一遍,第 7~9 道重点练「浮点精度 + 二分/三分」,第 10~12 道属于省选级计算几何,有兴趣的同学可以挑战。

1
P1183
多边形的面积(鞋带公式模板)普及−
2
P1355
神秘大三角(点与三角形的位置关系)普及/提高−
3
P1024
[NOIP2001 提高组] 一元三次方程求解普及/提高−
4
P3382
【模板】三分法普及/提高−
5
P1883
函数(三分法求最小值)普及/提高−
6
P1257
平面上的最接近点对普及/提高−
7
P2742
[USACO5.1] 圈奶牛Fencing the Cows(凸包模板)提高−
8
P1429
平面最近点对(加强版)提高+
9
P2571
[SCOI2010] 传送带(三分套三分)提高+/省选−
10
P1452
[USACO03FALL] Beauty Contest G(旋转卡壳)提高+
11
P3829
[SHOI2012] 信用卡凸包(凸包 + 圆弧)提高+/省选−
12
P3187
[HNOI2007] 最小矩形覆盖(旋转卡壳进阶)省选

📌 做题顺序建议

第一步(打地基):先做 1、2、3,把鞋带公式、叉积判方位、二分 + eps这三件事练熟。这三道题对应本讲的案例 1、案例 2、实践题 1,做完就基本掌握了。

第二步(补工具):做 4、5,掌握三分法的两种写法(实数域和整数域)。P3382 是三分法模板,建议直接背下代码。

第三步(练分治与精度):做 6、8,体会「分治 + 按 y 排序」的平面最近点对。P1429 加强版对精度和常数要求都高,是很好的工程训练。

第四步(上凸包):做 7、10,把 Andrew 单调链和旋转卡壳都敲一遍。这两道题是本讲案例 3 和拓展 3 的完整版,做完这两道,计算几何的模板就齐了。

第五步(挑战省选):做 9、11、12。P2571 是「三分套三分」的经典,P3829 考的是「凸包 + 圆的周长」,P3187 需要真正的旋转卡壳求最小外接矩形。做不出来也没关系,先把思路理清,等学过更系统的计算几何再回头做。

⚠️ 最后提醒:这 12 道题里有 7 道是浮点输出的(要求保留几位小数)。提交前一定要检查三件事:① 有没有用 eps 比较;② 输出格式是否和题目要求一致(几位小数、有无换行);③ 有没有在极端数据下爆 int。计算几何题最冤的失分,就是算法对了但精度或格式没处理好。