← 目录 第 59 / 60 节 全部 60 节

59 · 计算几何入门:向量与凸包

把图形翻译成代数:点积判角度、叉积判方向与面积,再给自定义类型重载运算符,最后用一个单调栈把 n 个点的最外圈(凸包)在 O(n log n) 内扫出来,并顺手拿到面积、点内判定与极角排序。
提高预计 70 分钟向量叉积凸包
本节出现的新函数 / 新语法(先看懂再用)

计算几何的第一道坎不是算法,而是把图形翻译成代数。这件事只需要两个式子:点积管角度,叉积管方向。把这两个写好,「有没有拐弯」「在左边还是右边」「这块面积是多少」全都变成几次乘法和减法。

出发点是:把点当成向量。点 A、B 的差 A − B 是一条有向线段;两点的「位置」本身没有意义,只有「差」才有。于是整节代码里点与向量共用一个类型,减法得到向量,向量加减还得到向量。

叉积的符号就是「左拐还是右拐」,大小就是「面积的两倍」。凸包算法只是把这一句话重复 n 次。
运算定义几何意义典型用途
加法 / 减法(ax±bx, ay±by)平移、两点连线把点搬到原点看问题
点积 dotax·bx + ay·by|a||b|cosθ夹角、垂直判定、投影长度
叉积 crossax·by − ay·bx|a||b|sinθ(有符号)转向、共线、面积
长度 lensqrt(dot(a, a))|a|周长、两点距离
cross(b−a, c−a)—三点转向 / 三角形有向面积 ×2凸包的全部判断

本节要新学三样:结构体重载运算符(让自定义类型像 int 一样写)、点积与叉积(两个式子撑起全部几何)、sgn 与 EPS(浮点比较的唯一正确姿势)。

新函数拆解 · 结构体重载运算符
struct Vec { double x, y; Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; } bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; } };
组成含义 / 格式要求
struct Vec { double x, y; }把「点」和「向量」统一成同一个类型。两点之差是向量,向量还叫点——计算几何里不区分这两个概念,共用一个类型最省事。
Vec operator-(const Vec& b) const成员函数版重载:编译器把 a - b 翻译成 a.operator-(b)。返回类型写成 Vec,所以 b - a 还能继续参与运算。
const Vec& bconst 引用传参:不拷贝,并承诺不修改 b。漏掉 const,对 const 对象调用会编译失败。
结尾的 const承诺这个函数不修改 *this。漏了它,对 const Vec 调用会报错;写了没有坏处。
return {x - b.x, y - b.y};花括号初始化,等价于 Vec{x - b.x, y - b.y},C++11 起可用。
bool operator<(const Vec& b)给 sort 用:先比 x,x 相等再比 y。凸包的第一步就是按这个顺序排序,运算符写反整份代码都错。
重载与 cmp 只能选一个sort(v.begin(), v.end()) 用 operator<,sort(v.begin(), v.end(), cmp) 用 cmp。两处定义不一致 → 凸包结果随机。
为什么一定要 operator<Andrew 算法的正确性依赖「按 x 单调」的顺序。没有这个顺序,扫描时弹出的点可能是该留的。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

struct Vec {
    double x, y;
    Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; }
    bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; }
};

int main() {
    Vec a{3, 4}, b{1, 1};
    Vec c = a - b;                       // (2, 3)
    cout << c.x << " " << c.y << "\n";

    vector<Vec> v{{2, 1}, {1, 5}, {1, 2}};
    sort(v.begin(), v.end());            // 先按 x,再按 y
    for (auto p : v) cout << "(" << p.x << "," << p.y << ") ";
    // (1,2) (1,5) (2,1)
    return 0;
}
注意

比较器里不要塞 EPS。写成 fabs(x - b.x) > EPS ? ... : ... 会破坏严格弱序,libstdc++ 的 sort 在极端数据上可能越界 → RE。要判浮点相等,用 sgn 写在算法里,别放进比较器。

二元减法是 operator-;一元负号是 operator-()(无参)。两者能共存,别写混。

新函数拆解 · 点积 dot 与叉积 cross
double dot(Vec a, Vec b) { return a.x * b.x + a.y * b.y; } double cross(Vec a, Vec b) { return a.x * b.y - a.y * b.x; }
组成含义 / 格式要求
dot 的符号> 0 锐角,= 0 垂直,< 0 钝角。判垂直就用它,因为 dot = 0 恰好等价于垂直。
dot(a, a)等于 |a|²。开根号才得到长度:sqrt(dot(a, a))。
cross 的符号> 0:b 在 a 的逆时针一侧(左转);< 0:顺时针一侧(右转);= 0:共线(同向或反向)。凸包的全部判断都是这一句。
三点转向cross(b - a, c - a) 判断「沿 a→b 走到 c,是左拐还是右拐」。左拐为正、右拐为负、共线为 0。注意两个向量都从同一个点出发。
cross 的绝对值等于以 a、b 为邻边的平行四边形面积。三角形 abc 的面积 = fabs(cross(b - a, c - a)) / 2。
反对称cross(a, b) = -cross(b, a),所以「转向」带正负号;dot(a, b) = dot(b, a),所以「夹角」没有方向。
量级坐标到 10⁶ 时叉积到 10¹²,int 必炸。整数坐标用 long long,实数坐标用 double。
最小可运行示例
示例
Vec A{0, 0}, B{2, 0}, C{1, 1};
double s = cross(B - A, C - A);              // 2*1 - 0*1 = 2 > 0,A->B->C 是左转
cout << s << "\n";                           // 2
cout << fabs(s) / 2 << "\n";                 // 1:三角形面积
cout << dot(Vec{1, 0}, Vec{0, 1}) << "\n";   // 0:垂直
cout << dot(Vec{1, 0}, Vec{1, 1}) << "\n";   // 1 > 0:锐角
注意

两个式子都是「两次乘法、一次加减」,几乎不要钱。看到角度、旋转、面积、方向,先想这两个。

cross 的顺序不能换:ax·by − ay·bx。手滑写成 ax·bx − ay·by 就是点积,程序照跑,结果全错。

新函数拆解 · sgn 与浮点比较
const double EPS = 1e-9; int sgn(double x) { return x > EPS ? 1 : (x < -EPS ? -1 : 0); }
组成含义 / 格式要求
为什么不写 x > 0double 用二进制存十进制小数,0.1 存不准。叉积是「两个乘积相减」,两个 10¹² 量级的数相减会带上量级 10⁻⁴ 的误差。直接拿 0 当分界,会把共线判成左转。
EPS 取多大跟着数量级走。坐标 ≤ 10⁶ 时叉积 ≤ 10¹²,double 的 53 位尾数只给 15~16 位有效数字,绝对误差约 10⁻⁴。所以本题用 1e-9 其实等于「几乎没有容差」——够用,因为它只影响共线点的取舍。
什么时候必须放大做「凸包顶点个数」「点是否在多边形内」这类数量敏感的题,EPS 得按坐标量级放大,否则边界点会随机判错。
永远不写 a == b浮点判等一律写 sgn(a - b) == 0。
输出题目要求几位就输出几位:printf("%.2f\n", ans)。不要手写 round,四舍五入交给 printf。
能避开就避开本题坐标最多两位小数,把输入 ×100 存成 long long 后叉积完全精确。这是计算几何最省心的一招:能用整数就绝不用浮点。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

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

int main() {
    printf("%d\n", sgn(1e-11));          // 0:当作零
    printf("%d\n", sgn(-3.0));           // -1
    printf("%d\n", sgn(2.0));            // 1

    double a = 0.1 + 0.2;
    printf("%d\n", a == 0.3);            // 0:不相等
    printf("%d\n", sgn(a - 0.3) == 0);   // 1:容差内相等
    return 0;
}
注意

EPS 不是越小越好,也不是越大越好。经验值:坐标 ≤ 10³ 用 1e-9;坐标到 10⁶ 时 1e-9 已经小到没有容差,该按「判的是量级还是个数」来定。

本题有个好处:判错只会让一个近乎共线的点留下或去掉,周长几乎不变。换成「凸包上有几个点」,同样的错判就直接 WA。

凸包是最基础的几何结构:包住所有点的最小凸多边形。它只有一道模板题,但里面每一行都在复用上面三个工具。

例题 · 【模板】二维凸包 / [USACO5.1] 圈奶牛 Fencing the Cows洛谷 P2742普及/提高-去洛谷做这题 ↗

农夫约翰要造一圈围栏,把奶牛喜欢吃草的所有地点都围进去,并且要最短。给定这些地点的坐标,求围栏的长度——也就是这些点的凸包周长。

输入格式:第一行一个整数 n,表示放牧点数目;第 2 到第 n+1 行,每行两个实数 xᵢ、yᵢ,表示第 i 个点的坐标。

输出格式:一行一个实数,四舍五入保留两位小数,表示围栏长度。

数据范围:3 ≤ n ≤ 10⁵,−10⁶ ≤ xᵢ, yᵢ ≤ 10⁶,坐标小数点后最多两位。

自造样例(6 个点,其中两个在矩形内部): 输入: 6 0 0 4 0 4 3 0 3 2 1 1 2 输出: 14.00
凸包 = 上下两条单调链,各扫一遍

凸包是什么。包住所有点的最小凸多边形。所有点要么在它内部,要么在它边界上。求周长就是把这个多边形的边长加起来。

先排序。把所有点按 (x, y) 字典序排好。最左和最右的点一定在凸包上。于是凸包被这两个点切成上下两条链,每条链都是从最左走到最右的单调折线。

下链怎么走。从左往右依次加点,用单调栈维护当前的下链。新点 c 加入时,检查「栈顶 b、栈顶之前一个 a」组成的三点转向 cross(b−a, c−b):如果是右转或共线(≤ 0),说明 b 被包进 a、c 里了,永远当不了凸包顶点,弹掉;反复弹到出现左转,再压入 c。

上链同理。从右往左再扫一遍,沿路同样只保留左转。两段拼起来就是完整凸包。

弹出条件为什么是 ≤ 0 而不是 < 0。取 ≤ 0 会连共线点一起弹掉,得到的是「没有多余顶点」的凸包;取 < 0 会把共线点留在链上。两者在本题等价——多点共线时那些点在同一条边上,边长的和不变。做题要看清楚考的是周长(无害)还是顶点个数(有害)。

为什么弹掉不会错。设栈顶是 b、前一个是 a、新点是 c,且 cross(b−a, c−b) ≤ 0,则 b 在 a→c 这条有向直线的右侧或线上。三角形 a、b、c 里 b 是「凹进去」的那个角,任何包含 a、c 的凸集都不会把 b 当顶点。后面加入的点都在 c 右边,b 就更没机会了。

注意 m > k。下链扫完得到 k 个点,上链的弹出条件必须是 m > k 而不是 m >= 2——否则上链会把下链刚建好的点弹光,凸包退化成两个点。这是整个算法唯一容易写错的地方。

最后一步 --m。上链一路扫到最左点,它的值和起点重复,所以数组末尾多了一个起点。去掉它,剩下的 m 个点就是凸包顶点(逆时针)。

复杂度。排序 O(n log n),两次扫描各 O(n)(每个点最多进栈一次、出栈一次)。n = 10⁵ 时总操作约 10⁶。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const double EPS = 1e-9;

struct Vec {
    double x, y;
    Vec operator-(const Vec& b) const { return {x - b.x, y - b.y}; }
    bool operator<(const Vec& b) const { return x != b.x ? x < b.x : y < b.y; }
};

int n;
Vec p[N], h[N];

double cross(Vec a, Vec b) { return a.x * b.y - a.y * b.x; }
double dot(Vec a, Vec b)   { return a.x * b.x + a.y * b.y; }
double len(Vec a)          { return sqrt(dot(a, a)); }
int sgn(double x)          { return x > EPS ? 1 : (x < -EPS ? -1 : 0); }

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

    sort(p, p + n);                        // 凸包第一步:按 x 再按 y

    int m = 0;
    for (int i = 0; i < n; ++i) {          // 下链:从左到右,只留左转
        while (m >= 2 && sgn(cross(h[m - 1] - h[m - 2], p[i] - h[m - 1])) <= 0) --m;
        h[m++] = p[i];
    }
    int k = m;                             // 下链的点数,上链不能把它们弹掉
    for (int i = n - 2; i >= 0; --i) {     // 上链:从右到左
        while (m > k && sgn(cross(h[m - 1] - h[m - 2], p[i] - h[m - 1])) <= 0) --m;
        h[m++] = p[i];
    }
    --m;                                   // 末尾与起点重复,去掉

    double ans = 0;
    for (int i = 0; i < m; ++i)
        ans += len(h[(i + 1) % m] - h[i]); // 首尾相连,最后一条边要绕回去
    printf("%.2f\n", ans);
    return 0;
}
逐行拆解
代码片段含义与理由
struct Vec点和向量共用一个类型。后面所有 h[i] - h[j] 都是在求向量。
sort(p, p + n)依赖 operator<。排好序后「从左到右」才有意义,弹出逻辑才成立。
while (m >= 2 && ...)m ≥ 2 才够组成三点转向。不足两点时无条件压栈。
cross(h[m-1] - h[m-2], p[i] - h[m-1])三点的转向:从 h[m−2]→h[m−1] 转到 h[m−1]→p[i]。第一个向量是「上一段」,第二个是「新一段」。
sgn(...) <= 0右转或共线 → 栈顶是凹点,弹掉。注意是 ≤ 0 而不是 < 0,差一个等号决定共线点留不留。
h[m++] = p[i]当前点入栈。每个点都会被压一次,最多被弹一次,所以总操作 O(n)。
int k = m;记下下链长度。上链从这里往上长,弹出条件换成 m > k,保护下链成果。
for (int i = n - 2; i >= 0; --i)从倒数第二个点往回扫到第 0 个。n−1 已经在下链末尾,不用重复加。
--m;上链最后压进去的是最左点,与 h[0] 重复。去掉它,h[0..m−1] 才是凸包顶点。
h[(i + 1) % m]取模实现首尾相连:最后一条边从 h[m−1] 回到 h[0]。
len(h[(i+1)%m] - h[i])两点距离 = 差向量的模。整份代码只在这里出现一次根号。
凸包建好之后,能顺手做三件事

一、面积。顶点是逆时针给出的,把每个顶点当成从原点出发的向量,Σ cross(h[i], h[i+1]) 就是面积的 2 倍(鞋带公式)。这条式子和 P1183 的解法是同一个东西。

二、判断点在不在凸包里。逆时针凸包有个漂亮的等价条件:内部就是每条边的左侧交集。逐边判叉积,全部 ≥ 0 就在里面。O(m) 判一次;要更快可以按极角二分,O(log m)。

三、极角排序。Graham 扫描需要按极角排序。用 atan2 能排但慢且不准,标准做法是「先按半平面分组,组内用叉积定先后」,全部整数运算。

凸包的三个常见后续操作
double area(Vec* h, int m) {            // 顶点逆时针 → 结果为正
    double s = 0;
    for (int i = 0; i < m; ++i) s += cross(h[i], h[(i + 1) % m]);
    return s / 2;
}

bool inHull(Vec q, Vec* h, int m) {     // 点在凸包内(含边界),O(m)
    for (int i = 0; i < m; ++i)
        if (sgn(cross(h[(i + 1) % m] - h[i], q - h[i])) < 0) return false;
    return true;                        // 每条边都不在右侧 → 在里面
}

int half(Vec a) {                       // 极角排序:上半平面 0,下半平面 1
    return (a.y > 0 || (a.y == 0 && a.x > 0)) ? 0 : 1;
}
bool cmpPolar(Vec a, Vec b) {           // 极角从小到大
    if (half(a) != half(b)) return half(a) < half(b);
    return cross(a, b) > 0;             // 同一半平面内用叉积定先后
}
这三段为什么这样写
代码片段含义与理由
cross(h[i], h[(i+1)%m])两个位置向量直接做叉积 = 三角形 O、h[i]、h[i+1] 的有向面积 ×2。全部加起来,原点在外的部分正负抵消,剩下的正好是多边形面积 ×2。
return s / 2上式给的是 2 倍面积。逆时针时为正,顺时针为负,需要绝对值就加 fabs。
cross(h[(i+1)%m] - h[i], q - h[i])边向量 与 「边起点指向 q」的向量。叉积为正 = q 在这条边左侧。所有边都满足,q 就在凸包内。
< 0 就 return false只要有一条边把 q 判在右侧,q 一定在外面——凸包是凸的,方向性是整体一致的。
half(a)把平面按 x 轴正方向切成两半,先排上半、再排下半,避免 atan2 的精度与常数。
cross(a, b) > 0同一半平面内,叉积为正说明 a 的极角小于 b 的极角,a 排在前面。

注意 inHull 的 EPS。凸包边长可能很小,叉积量级跟着坐标走。用它判题之前,先按坐标范围把 EPS 调到合适大小,别照抄 1e-9。

这一节的 7 个坑,按症状记

上链的弹出条件写成 m >= 2(漏了 m > k)→ 上链会把下链的点全部弹回去,凸包退化成两点一线 → WA,而且样例有时还能过。

叉积写成 a.x * b.x - a.y * b.y(把 by 打成 bx)→ 那是点积的式子,程序照跑,结果完全不对 → WA。编译期间发现不了,只能靠手算一个三角形验证。

弹出条件写成 < 0 而不是 <= 0:共线点会留在凸包里。本题只求周长,留着不影响答案;换成「凸包面积」「凸包顶点数」就会出事。

数组开小。h 的长度会涨到 n+1(凸包最多 n 个顶点,末尾再加一个与起点重复的点),所以 p、h 都要开到 10⁵+5 以上。只开 10⁵ → 越界 RE。

在比较器里塞 EPS(fabs(x - b.x) > EPS)破坏严格弱序 → sort 越界 RE,或者顺序乱掉 WA。

用 int 读实数坐标:只读到整数部分,小数被丢掉 → WA,而样例恰好都是整数时完全看不出来。必须 double + scanf("%lf")。

所有点共线时 --m 之后 m = 2,周长 = 2 × 两端距离。看着像「算错了一倍」,其实是对的——退化凸包就是一条线段,来回各走一遍。想要别的约定就得特判。

自己动手复现

关掉页面,把 P2742 的 40 行从零敲一遍,提交确认样例输出 14.00。

改一改①:把上链的 m > k 改成 m >= 2,先预测再跑。结果是 12.00 而不是 14.00——上链把角点 (4,3) 弹掉了,凸包变成一个缺角的三角形。

改一改②:在 --m 后面加一句 cout << m << "\n";,对样例(6 个点,2 个在内部)应该看到 4。再把 <= 0 改成 < 0,喂 5 个共线的点 (0,0)(1,0)(2,0)(3,0)(4,0) 进去:m 从 2 变成 8(正着数一遍、反着再数一遍),而周长仍然是 8.00 —— 顶点数变了,答案没变。

改一改③:把 sort(p, p + n); 注释掉,用同一组数据多跑几次,观察结果乱跳——这是「顺序决定正确性」最直观的现场。

改一改④:把 scanf("%d", &n) 之后的读入改成 cin >> p[i].x,然后关掉 sync_with_stdio,对比一下 10⁵ 个点的运行时间。

数学补给 · 叉积是有向面积,鞋带公式是它的累加

把叉积写成行列式:cross(a, b) = ax·by − ay·bx = |ax ay; bx by|。行列式的绝对值等于以这两个向量为邻边的平行四边形面积,所以叉积的绝对值就是面积,而它的正负号由「b 在 a 的左边还是右边」决定。

由 a·b = |a||b|cosθ、a×b = |a||b|sinθ 两式之比可得 tanθ = cross/dot。这就是为什么「判角度」用点积、「判方向」用叉积:cos 是偶函数,分不清左右;sin 是奇函数,能分。

三点转向。取 u = b − a、v = c − a,则 cross(u, v) = |u||v|sin∠bac。sin 在 (0, π) 上为正、(π, 2π) 上为负,正好对应「c 在 a→b 左侧 / 右侧」。共线时 sin = 0。

鞋带公式怎么来。把多边形每条边 PᵢPᵢ₊₁ 和原点 O 连成三角形,有向面积 cross(Pᵢ, Pᵢ₊₁)/2 全部相加。原点在多边形外部时,有些三角形是「负」的,它们的重叠部分恰好正负抵消,总和仍然等于多边形面积。这就是为什么它对任意位置的坐标系都成立,不需要把原点搬进多边形。

叉积的双线性。cross(a, b + c) = cross(a, b) + cross(a, c),cross(ka, b) = k·cross(a, b)。鞋带公式能一项项拆开累加,靠的就是这个性质。点积同样双线性,所以内积空间那套线性代数在这里全部通用。

整数坐标的面积。坐标全是整数时,Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ) 是整数,所以多边形面积一定是整数或半整数。P1183 保证面积是整数,于是这个和一定是偶数,s / 2 用整数除法不会丢精度。这是「整数几何」的核心事实,也是为什么能用 long long 全程避开浮点。

精度从哪来。double 有 53 位尾数,约 15.9 位十进制有效数字。坐标 ≤ 10⁶ 时叉积 ≤ 2×10¹²,此时 1 ulp ≈ 2×10¹² / 9×10¹⁵ ≈ 2×10⁻⁴。所以在这种量级下,1e-9 的 EPS 实际上不起任何作用;真要容差就得取 10⁻³ 甚至更大,但那会让「确实很小的左转」被判成共线。想让判断可靠,正确做法是把坐标缩放到整数,而不是调 EPS。

举一反三 · 想清楚这些再往下走
  1. 如果要求输出「凸包上有几个顶点」而不是周长,代码要改哪一处?共线点应该留还是不留?两种弹法(< 0 与 <= 0)分别给出什么答案?
  2. Andrew 必须按 (x, y) 排序。Graham 扫描则要先选一个极点(一般取 y 最小、y 相同取 x 最小的点)再按极角排序。这个极点为什么一定要取「最下最左」的?换一个点当极点会错在哪?
  3. 如果所有点共线,--m 之后 m 等于几?周长输出多少?把这个值和「线段长度」比一比,解释为什么是两倍。
  4. 判断点是否在凸包内,逐边扫是 O(m)。能不能做到 O(log m)?提示:凸包顶点按极角有序,可以先二分出 q 落在哪个扇形里。
  5. 把坐标 ×100 存成 long long,叉积就完全精确。这个技巧的代价是什么?什么情况下用不了(提示:输入的小数位数没有上限时,×100 就不再精确)?
  6. 用叉积算多边形面积时,如果顶点是按顺时针给出的,结果会怎样?写一行代码让它永远为正,并说清为什么取绝对值是安全的。
自测:能全部回答再进下一节
挑战题 · 多边形的面积
洛谷 P1183普及-去洛谷做这题 ↗

给出一个没有缺口的简单多边形,它的每条边都平行于坐标轴之一。顶点按逆时针方向逐个给出,坐标都是整数,因此面积也是整数。注意可能存在连续三个顶点共线。

求这个多边形的面积。

输入格式:第一行一个整数 n,表示顶点数;接下来 n 行,每行两个整数 x、y,表示一个顶点。多边形最后由最后一个顶点连回第一个顶点封闭。

输出格式:一行一个整数,表示面积。数据范围:1 ≤ n ≤ 100,−200 ≤ x, y ≤ 200。

引导性提示(先只看这里)

顶点是逆时针给的,这个顺序本身就是信息。想想「每条边和原点组成的三角形,有向面积加起来」会得到什么。

三角形 OAB 的有向面积 = cross(A, B) / 2,其中 cross(A, B) = Ax·By − Ay·Bx。把它对每条边累加。

累加出来的和是面积的两倍,一定是偶数。所以最后再做一次除以 2,过程中不要提前除。

想看思路了?点开完整思路

这是鞋带公式(Shoelace Formula)最直接的用法:S = |Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)| / 2,下标 i+1 对 n 取模,保证最后一条边连回首点。

为什么对。对每条边 PᵢPᵢ₊₁,xᵢyᵢ₊₁ − xᵢ₊₁yᵢ 正好是 cross(Pᵢ, Pᵢ₊₁),等于三角形 O、Pᵢ、Pᵢ₊₁ 的有向面积的两倍。把 n 个三角形加起来,落在多边形外面的部分正负抵消,剩下的正好是多边形本身,所以总和 = 2S。这与原点在不在多边形内部无关。

方向的处理。逆时针给出时每一项的累加结果为正,直接取用即可。加一句 if (s < 0) s = -s; 更保险:万一题目给的顺序反了,也不会输出负数。

为什么最后才除以 2。过程中每一项都是整数,累加也是整数,只有最后一步可能是「半个」。本题保证面积是整数,所以总和一定是偶数,整数除法不会丢精度。若提前做除法,就会引入 .5 甚至浮点误差。

共线顶点怎么办。不用管。三个点共线时,对应的两个三角形有向面积恰好相互抵消(或其中一个为 0),总和不变。这也是为什么题目特意提示「可能存在连续三个顶点共线」——它在提醒你不要去做去重。

规模。n ≤ 100、坐标 ≤ 200,|s| ≤ 100 × 2 × 200 × 200 = 8×10⁶,int 就够;但养成习惯用 long long,坐标范围稍微大一点(10⁹)就会溢出。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<long long> x(n), y(n);
    for (int i = 0; i < n; ++i) cin >> x[i] >> y[i];

    long long s = 0;                        // 有向面积的两倍,一定是偶数
    for (int i = 0; i < n; ++i) {
        int j = (i + 1) % n;                // 最后一条边:末点连回首点
        s += x[i] * y[j] - x[j] * y[i];     // 就是 cross(P_i, P_j)
    }
    if (s < 0) s = -s;                      // 万一是顺时针给出,取绝对值
    cout << s / 2 << "\n";                  // 整数除法,答案必为整数
    return 0;
}
关键片段拆解
片段作用
vector<long long> x(n), y(n)把所有点先全部读进来。首尾成环,必须在算之前知道全部坐标。
int j = (i + 1) % n取模实现闭环。i = n−1 时 j = 0,最后一条边自动连回起点。
x[i] * y[j] - x[j] * y[i]叉积 cross(P_i, P_j) 的展开,也就是三角形 O、Pᵢ、Pⱼ 的有向面积的两倍。顺序不能反,反了整体变号。
s += ...把所有有向面积累加。多边形外侧的三角形贡献负值,抵消后剩下 2S。
if (s < 0) s = -s;逆时针时 s 本就为正;加这一句是为了防止题目顺序与预期不符。
cout << s / 2最后一步才除。本题保证面积为整数,所以 s 必为偶数,整数除法精确。
这题最容易错的地方
  • 提前做除法:写成 s += (x[i]*y[j] - x[j]*y[i]) / 2,每项都截断,答案差一大截。
  • 累加时漏掉最后一条边(循环写成 i < n-1)→ 少了「末点连回首点」那一块,WA。
  • 把叉积写成 x[i]*x[j] - y[i]*y[j](点积的形式)→ 完全不对,但照样能跑。
  • 用 int 且坐标范围很大时溢出。本题坐标只到 200 侥幸逃过,换一题就炸。
  • 以为「共线顶点」需要去重,多写一段特判 → 反而把合法的重复顶点处理错。这题不需要任何去重。
延伸阅读 / 参考