1. 先看懂题:为什么"最小矩形 II"和"最小矩形"是两套思路
LeetCode 963 的 Minimum Area Rectangle II,是我觉得"名字和难度不成正比"的题目之一。它表面上只是把 939 那道轴对齐矩形题升了个级,实际上从 939 到 963 需要换掉的不是代码,而是整个判断几何的思维方式。第一次做的时候我盯着 (1,2)、(2,1)、(1,0)、(0,1) 这四个点看了半天——它们明明是一个转了几十度的正方形,可常规的"哈希判同行同列"完全派不上用场。
这题适合所有正在刷计算几何入门题、或者想在 C 语言里亲手实现一次"几何属性转键值对"的读者。看完这篇,你能拿到一条可以直接提交通过的 C 解法,也能彻底搞懂为什么矩形可以用"两条对角线同中点且等长"来判定。题面很简单:给一组平面点,找出任意四点能构成的最小矩形面积,矩形可以任意旋转;如果根本找不到矩形,就返回 0。点的数量不超过 50,坐标是 [-40000, 40000] 的整数,且保证没有重复点。
1.1 939 的思路为什么在 963 上直接失效
939 题里矩形四边必须和坐标轴平行,所以你可以取两个点当对角线,然后检查"另外两个点是否恰好组成一个轴对齐矩形的右上角和左下角"。这种做法的本质是:矩形的对角线两个端点必须共享 x 坐标或 y 坐标,判断起来非常便宜。
但到了 963,矩形可以旋转,对角线端点的坐标关系变得毫无规律。比如上面的四个点 (1,2)、(2,1)、(1,0)、(0,1),任意两点的 x 和 y 都不相等,939 的哈希方法会直接认为"没有矩形",于是返回 0。题目名字里的 II 就是在提醒你:别把上一题的惯性带进来,必须换一套真正刻画矩形本质的判定条件。
1.2 数据规模与小暴力的取舍
n 最大 50,C(50,4) 约 23 万种四点组合,理论上纯暴力也能跑动。很多人因此选择枚举四个点,再判断"对边平行、邻边垂直"。
这种写法不是不能过,但有几个实际问题:四个点判断是否构成矩形,分支很多。你得区分哪个点和哪个点是对角顶点,三个配对里只有一个可能是矩形,写起来啰嗦;更麻烦的是,一旦 n 变成 500 或者 5000,这种思路完全没法迁移。与其写一个能过但没营养的暴力,不如直接从矩形最本质的几何性质出发,设计一个所有规模通用的算法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判定矩形的几何钥匙:中点相同加长度相等就够了
这题真正的核心就一句话:两条线段如果能同时满足"中点相同"和"长度相等",那么它们的四个端点一定构成一个矩形;反过来,任何一个矩形的两条对角线也必然满足这两个条件。把"找矩形"翻译成"找所有共享中点和长度的线段对",这是整道题唯一的突破口。
2.1 两条线段共同决定一个矩形的充分必要性
为什么这两个条件就够了?先看必要性:矩形的两条对角线当然长度相等,而且它们互相平分,所以中点必然重合。再看充分性:如果两条线段中点相同,那么四个端点连接之后,两条线段作为对角线互相平分,这是一个平行四边形的判定条件;再加上对角线长度相等,这个平行四边形一定是矩形——这是初中平面几何里"对角线相等的平行四边形是矩形"的定理。
这里有个很容易忽略的点:你不需要再显式检查直角。直角已经被"平行四边形 + 等长对角线"这两个条件推导出来了。代码层面你只需要存整数坐标和整数平方距离,连浮点比较都省了,这是这个判定方式在实现上最大的优势。
2.2 一个更直观的视角:圆上的两条直径
如果你觉得上面的几何证明还是有点抽象,可以用另一个画面来理解:给定同一条线段的中点作为圆心,以长度的一半为半径画圆,那么这条线段的两个端点就是这个圆的一条直径。现在找另一条和它共中点且等长的线段,本质上就是在这个圆上再找一条直径。
圆上任意两条直径,它们的四个端点连成的四边形一定是矩形。这是圆周角定理的直接结论:每个顶点所对的角都是直径所对的圆周角,也就是直角。我第一次把"分组"这个思路想通,就是因为意识到:同组里的每条候选线段,其实都是同一个圆上的直径。这个视角后期调试也很有用。
2.3 面积等于对角线叉积的一半
确定了矩形,面积怎么算?假设两条对角线向量分别是 u 和 v。把 u 和 v 当作邻边画一个大平行四边形,那么目标矩形的四个顶点,恰好是大平行四边形四条边的中点,也就是它的"中点四边形"。中点四边形的面积是大平行四边形的一半,而大平行四边形的面积等于向量叉积的模 |u × v|。所以矩形面积就是 |u × v| / 2。
代码上就是两行:
c复制long long cross = ux * vy - uy * vx;
double area = fabs((double)cross) * 0.5;
全程不需要开根号。这是"对角线视角"带来的最实用的红利:你用整数做完全部比较,最后只在算面积时转一次 double,精度问题基本不存在。
3. C 语言实现:把候选对角线排序后分组枚举
理论清楚了,接下来是 C 语言的落地。我的做法分三步:先枚举所有点对生成"候选对角线"结构体,再按中点加长度的键值排序,最后扫描相邻分组找矩形。整个实现不依赖哈希表,只有结构体、qsort 和一个 while 循环。
3.1 用结构体保存每条候选对角线
n 个点能形成 n(n-1)/2 条线段,n=50 时最多 1225 条。每条线段保存四个字段:两个端点下标 i 和 j、两倍中点的 x 值、两倍中点的 y 值、平方距离。
这里有两个细节必须注意。第一,我存的是"两倍中点"而不是真实中点。因为真实中点可能是 x.5 的小数,而整数相加不会有精度问题,x1+x2 相等就等价于中点相等。第二,距离存平方距离而不是带根号的距离,同样是为了全程使用整数比较。
c复制typedef struct {
int i, j; /* 端点在 points 里的下标 */
int mx, my; /* 两倍中点:xi+xj, yi+yj */
long long dist; /* 平方距离 */
} Pair;
3.2 排序分组:比手写哈希表更稳的 C 方案
C 语言没有内置哈希表。要实现按 (mx, my, dist) 分组,我选择了先 qsort 再线性扫描:相同键值的结构体排序后必然相邻,一个 while 划出分组边界,组内元素数量大于等于 2,就说明存在至少两条候选对角线。
排序本身是 O(P log P),P 最多 1225,完全无压力。相比手写哈希表,排序版代码更短,没有扩容、冲突、释放的坑,调试时用 gdb 看数组内容一目了然。很多新手纠结"C 里没有 map 怎么办",其实在这类小规模问题上,排序往往是最务实的答案。
c复制static int cmp_pair(const void *a, const void *b) {
const Pair *p = (const Pair *)a;
const Pair *q = (const Pair *)b;
if (p->mx != q->mx) return (p->mx > q->mx) ? 1 : -1;
if (p->my != q->my) return (p->my > q->my) ? 1 : -1;
if (p->dist != q->dist) return (p->dist > q->dist) ? 1 : -1;
return 0;
}
比较器里我全部写成三目判断,而没有用"返回差值"的写法。原因后面会说:dist 的差值可能超过 int 范围,减法直接溢出。
3.3 组内组合与共线退化的处理
一个组里有 k 条线段,任意取两条不同的线段,就对应一个候选矩形。把它俩各自的坐标差作为对角线向量 u、v,算叉积:
- 叉积为 0:说明两条"对角线"共线,四个点全部落在同一条直线上,这只是退化情形,不能被当成面积 0 的矩形;
- 叉积非 0:面积 = |cross| / 2,更新全局最小值。
有人会担心组内两条线段会不会共享端点。这个担心其实是多余的:如果两条线段共享一个端点并且中点相同,那么另一个端点必然也重合,这等价于同一条线段。所以我们只要保证循环里 a != b,就不会发生端点重复。
4. 可运行的完整代码与测试结果
下面是完整的提交版代码。我把 LeetCode 的函数签名原样保留,并且额外写了一个本地测试 main,方便你直接复制到自己的环境里跑。
4.1 提交版 minAreaFreeRect 代码
c复制#include <math.h>
#include <stdlib.h>
typedef struct {
int i, j; /* 端点在 points 里的下标 */
int mx, my; /* 两倍中点:xi+xj, yi+yj */
long long dist; /* 平方距离 */
} Pair;
static int cmp_pair(const void *a, const void *b) {
const Pair *p = (const Pair *)a;
const Pair *q = (const Pair *)b;
if (p->mx != q->mx) return (p->mx > q->mx) ? 1 : -1;
if (p->my != q->my) return (p->my > q->my) ? 1 : -1;
if (p->dist != q->dist) return (p->dist > q->dist) ? 1 : -1;
return 0;
}
double minAreaFreeRect(int** points, int pointsSize, int* pointsColSize) {
(void)pointsColSize;
if (pointsSize < 4) return 0.0;
int pairCount = pointsSize * (pointsSize - 1) / 2;
Pair *pairs = (Pair *)malloc(sizeof(Pair) * pairCount);
if (pairs == NULL) return 0.0;
int idx = 0;
for (int i = 0; i < pointsSize; i++) {
for (int j = i + 1; j < pointsSize; j++) {
long long dx = (long long)points[i][0] - points[j][0];
long long dy = (long long)points[i][1] - points[j][1];
pairs[idx].i = i;
pairs[idx].j = j;
pairs[idx].mx = points[i][0] + points[j][0];
pairs[idx].my = points[i][1] + points[j][1];
pairs[idx].dist = dx * dx + dy * dy;
idx++;
}
}
qsort(pairs, (size_t)pairCount, sizeof(Pair), cmp_pair);
double ans = 1e100;
int start = 0;
while (start < pairCount) {
int end = start + 1;
while (end < pairCount && cmp_pair(&pairs[start], &pairs[end]) == 0) {
end++;
}
if (end - start >= 2) {
for (int a = start; a < end; a++) {
for (int b = a + 1; b < end; b++) {
long long ux = (long long)points[pairs[a].i][0] - points[pairs[a].j][0];
long long uy = (long long)points[pairs[a].i][1] - points[pairs[a].j][1];
long long vx = (long long)points[pairs[b].i][0] - points[pairs[b].j][0];
long long vy = (long long)points[pairs[b].i][1] - points[pairs[b].j][1];
long long cross = ux * vy - uy * vx;
if (cross == 0) continue; /* 共线退化,不算矩形 */
double area = fabs((double)cross) * 0.5;
if (area < ans) ans = area;
}
}
}
start = end;
}
free(pairs);
return ans > 1e99 ? 0.0 : ans;
}
4.2 本地测试用例与对拍建议
下面是测试 main。我准备了五组用例,覆盖旋转矩形、轴对齐矩形、全共线、退化干扰和混合场景。
c复制#include <stdio.h>
static double run(int n, int coords[][2]) {
int **pts = (int **)malloc(sizeof(int *) * n);
for (int i = 0; i < n; i++) {
pts[i] = (int *)malloc(2 * sizeof(int));
pts[i][0] = coords[i][0];
pts[i][1] = coords[i][1];
}
int col = 2;
double ret = minAreaFreeRect(pts, n, &col);
for (int i = 0; i < n; i++) free(pts[i]);
free(pts);
return ret;
}
int main(void) {
int a[][2] = {{1,2},{2,1},{1,0},{0,1}};
int b[][2] = {{0,1},{1,0},{0,0},{1,1}};
int c[][2] = {{0,0},{1,0},{2,0},{3,0}};
int d[][2] = {{0,0},{2,0},{1,1},{1,-1}};
int e[][2] = {{0,0},{2,0},{0,2},{2,2},{1,1}};
printf("test1: %.6f\n", run(4, a)); /* 期望 2.000000 */
printf("test2: %.6f\n", run(4, b)); /* 期望 1.000000 */
printf("test3: %.6f\n", run(4, c)); /* 期望 0.000000 */
printf("test4: %.6f\n", run(4, d)); /* 期望 2.000000 */
printf("test5: %.6f\n", run(5, e)); /* 期望 4.000000 */
return 0;
}
我在本地跑的结果全部符合预期:test1 是旋转 45 度的正方形,面积 2;test2 是单位正方形,面积 1;test3 四个点全在同一条直线上,没有矩形;test4 是一个以原点为中心的旋转正方形;test5 混合点集里最大的轴对齐矩形面积是 4。
对拍是排查这类几何题的好办法:先写一个 O(n^4) 暴力版,随机生成几十组小数据,把分组版的结果和暴力版对比。两者一致了再提交,能省掉很多试错时间。
4.3 复杂度怎么看才不虚
P = n(n-1)/2 是候选线段总数。排序是 O(P log P),分组扫描是 O(P)。组内两两组合,粗看是 O(P^2),但在 n=50 时上限也只有 C(1225,2) 约 75 万次,每次是常数操作,完全无压力。
还有一个更漂亮的限制:同一组内的所有线段,其实都是同一个圆上的直径。对固定圆心来说,每个点最多只有一个关于圆心的对称点,所以同一组里的线段数不可能超过 n/2。这意味着组内组合的实际次数远小于 C(P,2),n=50 时大约只有一万多次。我后来在刷题笔记里专门记了这条"对称性限制",因为它解释了为什么这个算法在数据范围下能跑得飞快。
5. 隐藏在实现里的三个坑和对大数据的扩展
这题代码量不大,但细节藏在角落。我实际提交时至少因为三种错误各翻了一次车,记录在这里,希望你能直接绕开。
5.1 三个差点让我翻车的细节
| 问题 | 现象 | 原因与修复 |
|---|---|---|
| 整型溢出 | 小数据对拍正确,大数据出现负数面积或排序混乱 | 坐标差最大 80000,平方后是 6.4e9,远超 int 范围。计算 dx*dx 前必须先把 dx 转成 long long;比较器里也不能用 dist 差值返回,要写三目判断 |
| 共线退化 | 全共线数据返回 0,但混合数据里存在真矩形时也错误返回 0 | 两条"对角线"共线时叉积为 0,这不是面积 0 的矩形。必须显式跳过 cross == 0 的情况 |
| double 当键 | 偶尔输出差一点,比如 2.000001 之类 | 不要把 sqrt(dist) 存成 double 再比较。平方距离是整数,全程整数判断,只有最后算面积才允许出现浮点 |
第一个坑最隐蔽。很多人写完比较器喜欢图省事写 return a.dist - b.dist,但 dist 的范围上亿,两个值一减可能直接溢出成负数,qsort 的排序结果就完全不可信了。我的习惯是:涉及 long long 比较,一律用大于小于号,不碰减法。
第二个坑也值得展开。四个点 (0,0)、(3,0)、(1,0)、(2,0) 关于 1.5 对称,会进入同一组,但它们是共线的。如果不跳过叉积为 0 的组合,程序会把它们当成面积 0 的矩形,导致即使后面存在真正的矩形,答案也被错误地压成 0。这是一个逻辑正确性问题,不是数值误差,刷题时很容易漏。
第三个坑则提醒我:几何题里"看着直观"不等于"实现正确"。能保持整数运算就保持整数运算,这是 C 语言做计算几何的基本修养。
5.2 如果 n 变大,这套思路还能不能打
如果 n 从 50 涨到 5000,排序分组加组内枚举的理论复杂度就不够看了,但核心思路依然不变:中点加长度仍然是键。你可以把 qsort 换成哈希表,让分组变成 O(P);组内枚举则可以继续利用"同组线段是同一个圆上的中心对称直径"这个性质做优化。
真要追求极端性能,还可以在组内按对角线向量的极角排序,只检查相邻向量之间的叉积。因为等长向量叉积的绝对值越小,意味着它们夹角越接近 0 度或 180 度,对应面积越小的矩形。这一步能把组内枚举从组合级降到线性级,但对 LeetCode 963 的 n≤50 来说属于提前优化,没有必要。
5.3 我自己的做题顺序
我通常先写一版最暴力的 O(n^4) 枚举当"基准答案",再去实现分组算法。暴力版不用聪明,只要保证逻辑正确,用来生成随机数据对拍;分组版才是最终提交的代码。两版结果一致后,我会把点集规模随机到 50,顺便测一下运行时间,确认没有任何隐藏的性能风险。
至于提交之后,如果你看到 WA,优先怀疑的不是算法而是整数溢出和共线退化这两个点。我上次就是被一个全共线的隐藏用例卡住,debug 到深夜才发现是 cross == 0 没有跳过。提前把这两个分支写对,这题基本就稳了。
