华为OD机试的算法题里,几何类题目不算多,但每次出现都能劝退一批人。比如这道“构成正方形的数量”,题目本身一句话就能说清——给你 N 个平面坐标点,数一数其中能组成多少个正方形。但真上手写,很多人第一反应是四层循环暴力枚举四个点,然后直接卡死。我第一次刷这道题的时候也一样,直到把“枚举对角线 + 哈希判点”这套思路想明白,才觉得这题其实是个纸老虎。
这篇文章不打算只贴答案,我会把三种语言的完整实现、去重计数的坑、整数溢出的隐患、以及调试时怎么验证正确性都讲一遍。无论你是在为华为OD机试做准备,还是单纯想把点集几何题的套路吃透,都可以参考。
1. 题目解读与考点分析
1.1 这题在OD机试中的定位
华为OD机试的算法题通常有多个难度梯度,字符串处理、数组、排序这类基础题占大头,几何题属于“低频但高频翻车”的类型。“构成正方形的数量”一般归在中等难度,但它考查的点其实很组合:坐标系知识、向量旋转、哈希表设计、去重逻辑。只要其中一个环节没想清楚,代码很容易写成“自己看着对,一测试就错”的状态。
我在不少交流群里看到有人反馈,说这题读题容易,样例也简单,但提交后就是超时或者答案不对。超时多半是用了三层或四层循环;答案不对多半是去重没搞对,或者使用了浮点数导致精度丢失。两种坑我都踩过。
1.2 题目输入输出形态
根据常见的题目版本,输入格式大致如下:
- 第一行是整数 N,表示点的个数;
- 接下来 N 行,每行两个整数 x y,表示一个点的坐标;
- 输出一个整数,表示这些点能构成的不同正方形数量。
坐标是整数,但正方形不一定平行于坐标轴,也就是说斜着的正方形也要算。比如 (0,0), (0,1), (1,0), (1,1) 是正方形,(0,1), (1,0), (2,1), (1,2) 也是正方形。题目如果没特殊说明,点之间理论上是不重复的,但实际机试可能有边界数据,稳妥起见最好自己做一次去重。
1.3 核心考点拆解
这道题表面上考“数正方形”,实际上层层拆开是这样的:
- 几何建模:已知两个点作为对角线,如何推出另外两个顶点;
- 数据结构:用哈希集合快速判断某个坐标是否存在;
- 去重计数:同一个正方形会被枚举到几次,最终结果怎么处理;
- 数值处理:坐标可能很大,中间运算会不会溢出;
- 性能控制:N 到 2000 左右时,复杂度必须是 O(N²),不能再高。
接下来我按这个顺序逐步拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法思路:为什么对角线法是最优解
2.1 暴力思路和它的天花板
最容易想到的解法是枚举四个点,判断它们能不能组成正方形。四个点的组合数是 C(N,4),N=1000 时大约是 400 亿,完全不可行。就算 N=100,也有 390 万组,每组还要做距离计算,大概率超时。
稍微优化一点的做法是枚举三个点,通过三角形三边关系推断第四个点。这样复杂度是 O(N³),N=1000 时仍然有 10 亿量级,一样扛不住。
所以这道题必须把复杂度压到 O(N²)。能压到 O(N²) 的原因是:正方形的几何性质非常强,只要确定一条对角线,另外两个顶点就被唯一确定了,不需要枚举四个点。
2.2 正方形对角线性质的数学推导
正方形两条对角线互相平分且相等,并且互相垂直。利用这个性质,已知一条对角线的两个端点 A(x1,y1)、C(x2,y2),就能算出另外两个顶点 B、D。
具体推导过程是这样的:
-
中点 M 的坐标是:
M = ((x1 + x2) / 2, (y1 + y2) / 2) -
从 A 到 C 的向量是:
v = (x2 - x1, y2 - y1) -
对角线 AC 的一半就是向量 v/2,即 ((x2 - x1)/2, (y2 - y1)/2)。
-
将 v/2 逆时针旋转 90 度,得到向量 (-(y2 - y1)/2, (x2 - x1)/2)。
-
B 点和 D 点分别是:
B = M + 旋转后的向量
D = M - 旋转后的向量
展开后就是:
- Bx = (x1 + x2 - (y2 - y1)) / 2
- By = (y1 + y2 + (x2 - x1)) / 2
- Dx = (x1 + x2 + (y2 - y1)) / 2
- Dy = (y1 + y2 - (x2 - x1)) / 2
这个公式很重要,推荐自己推导一遍,比死记硬背有用。
2.3 浮点数陷阱与整数放大法
看上面的公式,每个坐标都要除以 2。如果直接用浮点数存储,比如 0.5、1.5 这种值,在 HashMap/HashSet 里做精度比较是非常危险的。比如 0.1 + 0.2 == 0.3 这种经典问题,在哈希查找里会直接导致应该命中的点查不到。
解决办法是“放大两倍”:把每个点的坐标都乘以 2 存入哈希集合,这样公式里的分子直接就是某一顶点的两倍坐标,不需要再除以 2,天然规避浮点问题。
假设集合 pts 里存的是 (2x, 2y),那么对于原始点 A(x1,y1) 和 C(x2,y2),我们需要判断的两个两倍坐标是:
- B2 = (x1 + x2 - (y2 - y1), y1 + y2 + (x2 - x1))
- D2 = (x1 + x2 + (y2 - y1), y1 + y2 - (x2 - x1))
如果 B2 和 D2 都在 pts 里,说明存在一个以 A、C 为对角线的正方形。
这个技巧是整道题的灵魂。用整数运算替代浮点运算,既保证精度,又提高查找速度。我写的三种语言版本全部基于这个思路。
2.4 去重与计数:除以 2 还是除以 4
这是另一个高发翻车点。
假设我们用 for i in range(n): for j in range(i+1, n): 这样枚举点对,那么每个无序点对只会被枚举一次。一个正方形有两条对角线,所以同一个正方形会被两个不同的点对分别验证一次。也就是说,统计结果会是真实数量的两倍,最后必须除以 2。
如果写成枚举所有 i 和 j 的任意组合,也就是 i 可以从 0 到 n,j 也可以从 0 到 n,那么每条对角线会被正反两个方向各枚举一次,一个正方形总共会被统计 4 次,这时候才除以 4。
我习惯统一用 i+1 起步的写法,最后除以 2,不容易乱。
3. 多语言实现与细节对比
3.1 Python 实现:简洁但要注意性能
Python 的集合直接支持元组,写起来最直观。
python复制def count_squares(points):
# 去重,顺便去掉重复点
unique_points = list(set(points))
n = len(unique_points)
# 存两倍坐标,避免浮点
pts = set()
for x, y in unique_points:
pts.add((x * 2, y * 2))
ans = 0
for i in range(n):
x1, y1 = unique_points[i]
for j in range(i + 1, n):
x2, y2 = unique_points[j]
sx = x1 + x2
sy = y1 + y2
dx = x2 - x1
dy = y2 - y1
# B2 和 D2 分别是另外两个顶点的两倍坐标
if (sx - dy, sy + dx) in pts and (sx + dy, sy - dx) in pts:
ans += 1
return ans // 2
def main():
import sys
data = sys.stdin.read().strip().split()
if not data:
return
n = int(data[0])
points = []
idx = 1
for _ in range(n):
x = int(data[idx])
y = int(data[idx + 1])
idx += 2
points.append((x, y))
print(count_squares(points))
if __name__ == "__main__":
main()
这里有一个小细节:先做 set(points) 去重。如果题目本来就不重复,这步不影响结果,但如果有重复点,这步能避免把退化图形算进去。
Python 版在小数据量下足够快。如果 N 到 2000,O(N²) 大概是 200 万次枚举,每次做两次元组哈希查找,实测完全在可接受范围内。如果你追求极致速度,可以把坐标编码成整数再放进 set,但可读性会差一些,我建议笔试时先保证正确性,再考虑优化。
3.2 Java 实现:注意包装类型和 HashSet
Java 没有现成的二元组,我习惯自己写一个 Point 类,重写 hashCode 和 equals。也可以用两个 HashSet<Long> 分别存 x 和 y,但那样判断时容易乱,不推荐。
java复制import java.util.*;
public class Main {
static class Point {
long x, y;
Point(long x, long y) {
this.x = x;
this.y = y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
Point[] points = new Point[n];
Set<Point> doubledSet = new HashSet<>();
for (int i = 0; i < n; i++) {
long x = sc.nextLong();
long y = sc.nextLong();
points[i] = new Point(x, y);
doubledSet.add(new Point(x * 2, y * 2));
}
long ans = 0;
for (int i = 0; i < n; i++) {
Point p1 = points[i];
long x1 = p1.x, y1 = p1.y;
for (int j = i + 1; j < n; j++) {
Point p2 = points[j];
long x2 = p2.x, y2 = p2.y;
long sx = x1 + x2;
long sy = y1 + y2;
long dx = x2 - x1;
long dy = y2 - y1;
if (doubledSet.contains(new Point(sx - dy, sy + dx))
&& doubledSet.contains(new Point(sx + dy, sy - dx))) {
ans++;
}
}
}
System.out.println(ans / 2);
}
}
Java 版有几个点容易踩:
- 坐标类型用
long,不要用int。虽然很多测试数据坐标范围不大,但中间值x1 + x2可能达到 2×10^9,接近 int 上限,一旦坐标范围再大一点就会溢出。 hashCode用Objects.hash(x, y)简洁可靠。不要自己乱拼字符串,性能差而且容易冲突。- 每次判断都
new Point会产生大量对象,但 N 在 2000 以内时总共约 400 万个对象,性能是可以接受的。如果特别在意内存,可以把 Point 换成Long编码,下面 C++ 部分会讲思路。
3.3 C++ 实现:用 Long 编码提高查找效率
C++ 里 unordered_set<pair<int, int>> 默认没有哈希函数,直接用会编译报错。最简单的方式是把坐标编码进一个 long long,这样既能用 unordered_set<long long>,也省掉 Pair 哈希的麻烦。
cpp复制#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll encode(ll x, ll y) {
return (x << 32) ^ (y & 0xffffffffLL);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<pair<ll, ll>> points(n);
unordered_set<ll> st;
st.reserve(n * 2);
for (int i = 0; i < n; i++) {
ll x, y;
cin >> x >> y;
points[i] = {x, y};
st.insert(encode(x * 2, y * 2));
}
ll ans = 0;
for (int i = 0; i < n; i++) {
auto [x1, y1] = points[i];
for (int j = i + 1; j < n; j++) {
auto [x2, y2] = points[j];
ll sx = x1 + x2;
ll sy = y1 + y2;
ll dx = x2 - x1;
ll dy = y2 - y1;
if (st.count(encode(sx - dy, sy + dx)) &&
st.count(encode(sx + dy, sy - dx))) {
ans++;
}
}
}
cout << ans / 2 << "\n";
return 0;
}
编码函数 (x << 32) ^ (y & 0xffffffffLL) 的原理是:把 x 放在高 32 位,y 放在低 32 位。前提是 x、y 的绝对值不超过 2^31,两点放大两倍后一般在 int 范围内,所以安全。这样每个坐标对对应唯一一个 long long,哈希查找很快。
如果不想用位运算,也可以定义一个自定义结构体,给 unordered_set 提供哈希函数,那样代码长一些但更好懂。笔试时间紧张的话,编码方案更省事。
3.4 三版实现的横向对比
| 语言 | 坐标存储方式 | 查找方式 | 主要优势 | 主要注意点 |
|---|---|---|---|---|
| Python | 元组 (2x, 2y) | set 判重 | 代码最简洁,适合快速验证思路 | 元组哈希有开销,超大 N 时稍慢 |
| Java | Point 对象 | HashSet | 类型清晰,维护方便 | 对象创建多,需要重写 hashCode/equals |
| C++ | long long 编码 | unordered_set | 性能最好,内存省 | 编码函数要保证唯一,不能只按低 32 位 |
以 N=2000 的规模来看,三种语言的提交都不会有大问题。如果你是刷题练习,建议三种都写一遍,尤其对比一下 C++ 的编码方式和 Java 的 Point 写法,会加深对哈希表的理解。
4. 常见错误与调试心得
4.1 计数到底除以几?先想清楚枚举方式
我见过好几个同学在这个问题上反复改,一会儿除以 2,一会儿除以 4,最后干脆写除法但心里发虚。其实不用背结论,只需要在提交前自己想一遍:
- 枚举对角线时,一个正方形有几条对角线?两条。
- 你的循环枚举到几条?如果
j从i+1开始,每条无向对角线只枚举一次,那么一个正方形被数 2 次,除以 2。 - 如果你的循环枚举了所有
i,j组合,那么每条对角线正反各一次,一个正方形被数 4 次,除以 4。
所以关键不是记住除以几,而是检查循环写法。我推荐一律用 i+1 起步,统一除以 2。
4.2 放大两倍后,集合里存的到底是什么
有些同学会混淆:集合里存的是两倍坐标,但枚举时用的是原始坐标。判断候选点时,(sx - dy, sy + dx) 已经是对应顶点的两倍坐标了,不要再去乘 2 或者除以 2。
举个例子:A(0,0)、C(1,1) 是对角线,另外两个顶点是 B(0,1)、D(1,0)。计算得到:
- sx = 1, sy = 1, dx = 1, dy = 1
- B2 = (1 - 1, 1 + 1) = (0, 2),这个就是 (2×0, 2×1)
- D2 = (1 + 1, 1 - 1) = (2, 0),这个就是 (2×1, 2×0)
集合里如果存了 (0,2) 和 (2,0),就能正确命中。如果此时再去乘 2,就会得到错误结果。建议在代码注释里写清楚“这里已经是两倍坐标”。
4.3 重复点导致的多算
如果输入数据里出现重复点,不处理会让结果变大。比如有两个点完全重合,再加上另外两个点,理论上不能构成正方形,但枚举时会把重复点当作不同点,从而通过公式算出另外两个点正好存在,产生一个非法正方形。
解决办法很简单:在读入后做一次去重,比如 Python 的 set(points),C++ 可以先用 set<pair<ll,ll>> 去重,再把结果转成 vector。这个操作不影响正确性,但能挡住一个隐藏边界。
4.4 浮点精度是怎么毁掉这道题的
如果你用 sqrt 或 HashMap<Double> 来算距离、存中点,大概率会在某些数据上失败。坐标是整数时看起来还好,但经过平方根、除法后,浮点误差会累积。比如 Math.sqrt(2) 乘以 Math.sqrt(2) 可能得到 2.0000000000000004,这种值一旦用来比较相等,结果就不可控了。
所以这道题最稳的方案就是全程整数:两倍坐标存哈希,中间计算也全是加减法。不需要 sqrt,不需要长度比较,只需要验证四个顶点都在集合里。
4.5 用暴力法做交叉验证
写完正式解法后,我习惯写一个暴力版本,在小数据量下对拍。暴力枚举四个点,判断这四个点里任意三点构成的三角形是否满足“两条短边相等且勾股定理成立”。
python复制def is_square(p1, p2, p3, p4):
pts = [p1, p2, p3, p4]
dists = []
for i in range(4):
for j in range(i + 1, 4):
d = (pts[i][0] - pts[j][0]) ** 2 + (pts[i][1] - pts[j][1]) ** 2
dists.append(d)
dists.sort()
return dists[0] > 0 and dists[0] == dists[1] == dists[2] == dists[3] and dists[4] == dists[5]
暴力写法比较慢,只适合 N 很小的情况。随机生成 N 在 10 到 30 的数据,把暴力结果和正式解法对比,基本上跑几十组就能发现去重错误、坐标放大错误等所有常见坑。这个方法强烈推荐。
5. 从这题延伸出去的思考
5.1 同类题型与变化
“构成正方形的数量”有一套通用化的思路:在点集里判断或统计某种特殊图形。往旁边延伸一下,就有很多变体:
- 判断四个点是否为正方形:LeetCode 593“有效的正方形”,同样是利用对角线性质和整数距离;
- 数矩形的数量:可以用“中点+长度”作为键,两个点如果中点相同、距离相等,就能补成矩形;
- 数正三角形:固定一条边,旋转 60 度,再用哈希判断第三个点是否存在。
这些题有个共同点:都是先固定两个点作为“骨架”,然后通过几何变换算出剩余点,再用哈希集合做存在性判断。所谓“点集几何题”,套路其实很有限。
5.2 通用套路:固定-旋转-哈希
我总结出一套三步走的思路,适用于大部分点集图形计数题:
- 固定:枚举能唯一确定图形的一对点,比如正方形的对角线、矩形的对角线、正三角形的一条边;
- 旋转:利用向量旋转公式,计算出其他顶点坐标;
- 哈希:把所有点放入哈希集合,直接查计算出的顶点是否存在。
这道题之所以枚举对角线,是因为正方形对角线“平分且垂直”这个性质能唯一确定另外两个点。如果枚举边,则每条边对应两套旋转结果,去重逻辑更复杂。以后遇到类似题,优先想清楚哪两个点是最省事的“固定骨架”。
5.3 个人刷题建议
我在准备机试的时候,遇到这种带几何背景的题,会专门建一个分类,把这题的推导过程和代码都记下来。后面刷到“有效的正方形”时,直接把放大两倍的思想迁移过去,几分钟就能写完。
还有一个小习惯:每道题写完,我都会问自己三个问题。
- 这个解法的时间复杂度和空间复杂度是多少?
- 如果 N 翻十倍,还过不过?
- 如果坐标出现负数或重复值,代码会不会崩?
这三个问题问完,基本就能把边界情况照顾齐了。回到这道题,如果你能完整讲清楚“为什么用对角线”“为什么乘以 2”“为什么除以 2”,那这道题就算真正吃透了。
