如果只看几何定义,“正方形的判断”大概是我能想到的最简单的题:四边相等、四角直角,一句话的事。可这句话一旦搬进笔试和面试的编辑器里,就开始长出各种犄角——输入的点有没有顺序?坐标是整数还是浮点?四个点里有没有重复?如果给你十万个点,让你数一数里面能组成多少个正方形,又该怎么枚举?我见过太多人栽在这道题上,不是不认识正方形,而是代码里少了一个边界判断,或者多了一次没法比较的sqrt。
这篇文章我想把这道题从最简单的版本拆到最刁钻的版本,讲清楚题目变体、正确建模、现场作答节奏,以及不同语言里容易踩的坑。不管是准备笔试面试的工程师,还是想提升建模能力的学生,应该都能拿走点东西。
1. 这题凭什么能一路考到终面:面试官真正在看什么
先说一个反直觉的观察:越像“送分题”的题目,现场淘汰率往往越高。因为面试官不怕你不会,就怕你把一个看似简单的问题写得很随便。判断正方形这道题,表面上在考几何,实际上在考你面对一个需求时能不能把“直觉”翻译成“逻辑”,再把“逻辑”翻译成“不会跑偏的代码”。
我帮朋友做模拟面试时见过几种典型的挂法。有人听到题就开心,觉得四边相等就完事了,结果菱形也被他判成正方形。有人老老实实算了六条边,但最后一比对用了开根号,浮点精度一抖,差不多的数就是不相等,然后开始怀疑人生。还有候选人写对了核心算法,却因为没有先问“输入是四个点还是一堆点”“坐标是不是整数”,把简单版和进阶版搞混了,白费半天功夫。
这些表现背后是同一类问题:缺乏边界意识和复杂度迁移能力。面试官真正想看的东西,我总结成四件事。
第一,需求澄清。输入到底长什么样,是固定四个点,还是一个点集?四个点是无序排列,还是已经按四边形顺序给出?坐标范围是多少,有没有重复点?这些问题在真实工作中就是需求沟通,面试官很在意你会不会先问清楚再动手。
第二,逻辑完备性。正方形判定有很多等价的几何条件,但每个条件的适用场景不同。只看四边相等会误收菱形;只看对角线垂直会误收某些风筝形;必须把条件组合好,并且排除四点重合、三点共线这些退化情况。
第三,数值稳定性。能用整数运算的地方就不用浮点,能用平方距离的地方就不开根号。这不是洁癖,是工程上的自我保护。
第四,复杂度迁移。固定四个点时,怎么暴力都无所谓,反正就六条边。但一旦变成“点集里数正方形”,四重循环在n = 10^5时直接爆炸。面试官想看你能不能在几秒内意识到规模变了、模型也得跟着变。
这道题真正的难度不是那条几何定理,而是同一句话在不同输入规模下有完全不同的解法。这也是为什么它能在笔试里反复出现——它像一把刻度尺,量得出你写代码的工程素养,而不只是数学记忆。
2. 固定四个点的判断:六条边排序法为什么是标准答案
先处理最简单也最常见的版本:给你四个点,无序,判断它们能不能组成正方形。很多人的第一反应是算四边相等,这不够。反例太好找了,菱形就是四边都相等的四边形,但邻边夹角不是九十度。
那算四个边相等、再算一个角是直角行不行?理论上可以,但代码写起来容易难看。要选一个点当顶点,判断另外两个点构成的向量是否垂直,再判断两条邻边长度是否相等,还要处理“选哪个点当顶点”的分支。如果输入顺序不固定,你得先找个确定能当直角顶点的点,逻辑多一点就多一处出错的机会。
所以我自己在面试里最推荐的是“六条边排序法”。四个点两两之间一共可以连出六条线段,把它们的长度都算出来。正方形有个很漂亮的签名:这六条距离排序之后,前四个小值相等,后两个大值相等,而且大值刚好是小值的两倍。后一句话等价于对角线边长是边长的根号二倍,而平方之后就是二倍关系,用整数运算就能精确判断。
from itertools import combinations def is_square(points): distances = [] for (x1, y1), (x2, y2) in combinations(points, 2): dx, dy = x1 - x2, y1 - y2 distances.append(dx * dx + dy * dy) distances.sort() side, diag = distances[0], distances[4] return side > 0 and \ distances[0] == distances[1] == distances[2] == distances[3] and \ distances[4] == distances[5] and \ diag == 2 * side这里有一个关键的设计决策:我们比较的是距离平方,不是距离。坐标是整数时,距离平方是整数,比较起来毫无误差;而一旦开根号,哪怕边长真的是根号二,浮点表示也可能差一点点。笔试环境千奇百怪,尽量把问题变成整数比较是永远不会错的原则。
这个方法的充分性也值得想清楚。表面上它只检查了“四个短边相等、两个长边相等、长边是短边的两倍”,为什么就足以推出正方形?可以从四边形的不等式角度理解:四个点里最远的两个点一定构成某条对角线,剩下四个距离是四条边的长度。四边相等说明这是一个菱形,再加上对角线平方等于两倍边长平方,说明对角线长度等于边长的根号二倍。菱形的对角线互相垂直且平分,当两条对角线长度相等时,它只能是正方形。画个图验证一遍就踏实了。
还有个防御判断差点被我漏掉:side > 0。如果四个点里有重复点,六个距离里会出现零,整个签名可能被意外满足,所以先排除。这是我写这类题养成的习惯——任何几何判断,先问自己“退化输入会不会误判”。
如果题目明确告诉你四个点是按顺序给的,也就是已经构成了一个四边形,那确实可以更快:算四条边都相等,再算任意一条对角线长度的平方等于两倍边长平方就行。但我很反对一上来就默认输入有序。无序才是最常见的描述,你要么先问,要么就直接上六条边法。在面试里,多写两行代码换取“绝不出错”是非常划算的交易。
3. 变体二:一堆点里数正方形,核心是把“正方形”变成公式
固定四个点的版本通常只算热身题,真正的分水岭是这个进阶版:给你n个点,坐标都是整数,统计其中能组成正方形的四元组有多少个。第一反应是C(n, 4)枚举所有四元组,然后套六条边法?可惜n = 10^4时组合数已经到了十的十四次方量级,笔试环境直接超时,面试官也会摇头。
这个版本的破局点在于:不要枚举四元组,枚举一条边。给定两个点作为正方形的一条边,另外两个顶点是可以被唯一算出来的,不需要搜索。
怎么算?设这条边从点A = (ax, ay)指向点B = (bx, by),向量是(dx, dy) = (bx - ax, by - ay)。把这个向量逆时针旋转九十度,得到(-dy, dx),然后分别加到B和A上,就得到另外两个候选顶点。
def count_squares(points): point_set = set(points) ans = 0 for ax, ay in points: for bx, by in points: if (ax, ay) == (bx, by): continue dx, dy = bx - ax, by - ay x3, y3 = bx - dy, by + dx x4, y4 = ax - dy, ay + dx if (x3, y3) in point_set and (x4, y4) in point_set: ans += 1 return ans // 4旋转公式怎么记才不容易忘?我习惯从复数角度理解:一个向量(x, y)对应复数x + yi,乘一个i得到(-y, x),就是逆时针旋转九十度。如果你更习惯几何直觉,验证也很简单:点乘(dx, dy) · (-dy, dx) = -dx·dy + dy·dx = 0,说明垂直;模长分别是dx² + dy²,说明长度不变,确实是旋转而不是缩放。
为什么选“边模型”而不是“对角线模型”?两个点作为对角线,也能推出另外两个顶点:先找中心点,再把对角向量旋转九十度,加减到中心点上。但问题在于,整数坐标的对角线可能给出半整数顶点,比如中心点是(0.5, 0.5)。一旦出现半整数,哈希键的存储和比较就开始恶心了。边模型里全是整数加减法,没有半点精度问题,这是它最大的工程优势。
计数的时候有个非常隐蔽的细节:我上面的代码枚举的是有序点对,也就是每个A, B都会和B, A各算一次。一个正方形有四条边,每条边恰有一个方向能旋转出这个正方形本身,所以同一个正方形会被命中四次,最后要除以四。你可能会想,那我改成只枚举i < j的无序点对,最后不就不用担心了吗?事实恰恰相反,无序点对只取每条边的一个方向,同一个正方形可能被命中两次,也可能一次都没命中,完全取决于点的索引顺序。我试过用i < j加ans // 4,本地样例过了,换一组顺序就少了一半。所以要么老老实实枚举有序点对最后除以四,要么用排序后的四元组去重。
去重的写法也顺便给出来,复杂度多一个log,但思路更直白,适合当场写代码容易慌的人。
def count_squares(points): point_set = set(points) seen = set() ans = 0 n = len(points) for i in range(n): ax, ay = points[i] for j in range(i + 1, n): bx, by = points[j] dx, dy = bx - ax, by - ay x3, y3 = bx - dy, by + dx x4, y4 = ax - dy, ay + dx if (x3, y3) in point_set and (x4, y4) in point_set: quad = tuple(sorted([(ax, ay), (bx, by), (x3, y3), (x4, y4)])) if quad not in seen: seen.add(quad) ans += 1 return ans这两种写法面试官都能接受,关键是你要说清楚自己为什么选这一种。如果追求最优复杂度,用有序点对版,时间复杂度是O(n²),空间是O(n);去重版会多一个O(log n)的排序因子,但逻辑上容错率高。
4. 那些年我见过的翻车现场:浮点、菱形、半整数与去重
这一节我想专门聊坑。因为这道题思路说穿了都不难,但现场写起来,坑是一个接一个。
第一个坑是菱形冒充正方形。只看四条边相等,菱形会骗过你。我自己见过最典型的错误代码是四行写完,信心满满,结果测试里塞一个(0, 0), (1, 0), (2, 0), (1, √3)就露馅了。所以六条边排序法里的长边判据不是可有可无,它对菱形的“对角线不等于两倍边长平方”做了精确拦截。
第二个坑是sqrt精度。坐标是浮点数时,开根号后的比较更是玄学,同一个数值在不同编译器、不同优化级别下可能得到不同的表示。如果你非要比较浮点长度,得写fabs(a - b) < eps,而eps取多少又是一个新问题。所以整数坐标下用平方距离是这个领域的基本功,我甚至会在面试里主动说一句“为了避免精度问题,这里全部用平方距离比较”,这句话本身就是加分项。
第三个坑是重复计数。点集计数版里,那个除以四特别容易出错。我前面就说过,如果枚举无序对再除以四,结果会随索引顺序变化,同一个正方形可能只被统计两次。实话实说,这个坑我第一次刷题就踩过,后来才意识到应该枚举有序点对。现在看到别人代码里写i < j然后除以四,我都会下意识警惕。应对方法无非两种:除以四前确认自己枚举了所有有序点对;或者用排序去重,彻底绕开这个数学问题。
第四个坑是半整数哈希键。如果采用对角线模型,中心点可能是(0.5, 0.5),旋转后的顶点也可能是半整数。你没法直接把(0.5, 0.5)塞进以坐标为键的哈希表里,因为浮点键在比较时会让你怀疑人生。有人会说“那我判断分子是不是偶数不就行了”——可以,但到处是if ... % 2 == 0的分支,代码可读性直接下降。用边模型就永远遇不到这种情况,这也是我强烈推荐它在面试中当主方案的原因。
第五个坑是退化输入。四个点里有重复、三点共线、甚至全部重合,这些情况不能进入正方形判定。六条边排序法里的side > 0就是干这个用的。点集计数版通常假设输入没有重复点,但如果题目没说,你要么先问,要么在开头point_set = set(points)时意识到这一点。
这些坑没有一个需要高等数学,全是工程细节,但它们才是面试的真正筛选器。能把细节处理干净的人,往往就是能让人放心把模块交给他的人。
5. 面试现场怎么把这题演成加分项
技术准备是一回事,现场表达是另一回事。这道题在面试里能不能变成加分项,很大程度上取决于你怎样组织说话顺序。
我建议拿到题之后,前三十秒不要急着写。先确认三件事:输入规模是多少,是固定四个点还是一个点集;点是有序还是无序;坐标是不是整数、有没有重复。这个提问本身就在向面试官传递信号:这人会先厘清需求,而不是上来就干。如果面试官说“先按最简单的处理”,那你就从固定四点版开始;如果他说“要考虑大规模”,那就直接讲点集计数版。
真正写之前,先用一句话说明思路。固定四点版就说“四个点两两之间六条距离,排序后检查长度签名”。点集计数版就说“枚举一条边,旋转九十度生成另外两个点,用哈希集合查存在性”。话不用多,但要让面试官跟得上你。
写代码的时候,我建议按“主逻辑→防御判断→测试用例”的顺序来。不要一上来就纠结边界,先把中间那段核心逻辑写出来,比如旋转公式和哈希查询,然后再补side > 0这类判断。这样即使时间不够,面试官也能看到你的核心思路是完整的。
跑测试用例这里很多人会跳过,非常可惜。固定四条点这个题,我会主动报出几个边界用例:一个普通正方形,一个旋转了任意角度的正方形,一个菱形,一个四点重合,一个只有三个不同点。点集计数版再补一个“两个正方形共用一条边”的用例,既能验证除以四的逻辑,又能展示你对重复计数有意识。主动跑用例这个动作,在面试里的价值甚至比代码本身还高。
最后,如果面试官追问,最常见的几个问题要能接上。问“为什么平方距离不用开根号”,答整数精度问题。问“为什么是充分条件”,答四边相等推出菱形,对角线平方两倍推出对角线是边长的根号二倍,合起来就是正方形。问“如果不能用哈希表额外空间怎么办”,可以先答排序点集后用二分查找验证候选点,或者用邻接矩阵,把空间复杂度换成时间,再说明这种做法的适用条件。能把这些问题接住,这道题你就真正立于不败之地了。
6. 换一种语言就换一种活法:主流语言实现的细节差异
同一个算法,换个笔试语言,坑的位置就完全不一样了。基本解法大家都懂,但语言层面的细节往往是临场翻车的重灾区。
| 语言 | 哈希观感 | 常见坑 |
|---|---|---|
| Python | tuple天然可哈希,写起来最顺 | 别用浮点当 key;组合数别写成四重循环 |
| Java | 没有内置Pair类,要用自定义类或字符串拼接 | hashCode重写可别漏;字符串拼接注意分隔符 |
| C++ | unordered_set<pair<int,int>>需要自定义哈希 | 或者用long long压位,但别把负号弄丢 |
| JavaScript | 对象 key 会被强制转字符串,推荐用Set<String> | 数组做 key 会退化成引用比较,永远查不到 |
| Kotlin | data class自带结构相等与哈希,很省心 | 用listOf当 key 前要排序,否则同一四边形可能有不同表示 |
先说 Python。这篇里的代码全是 Python 风格,核心原因是tuple直接可以放进set和dict,不需要写任何哈希辅助逻辑。要注意的是,如果你非要把坐标拼成字符串"x,y",其实也能工作,但每次都要split,既慢又容易错。Python 里更自然的写法是直接用(x, y)。
Java 会麻烦一点。老的笔试环境里没有现成的Pair,很多人的第一反应是HashMap<String, Integer>,用x + "," + y当 key。这个能用,但要小心分隔符:坐标里不会出现逗号,所以,是安全的,不要把 key 拼成x+y这种会冲突的格式。更好的方案是用long压位:((long) x << 32) ^ (y & 0xffffffffL),一个long就能唯一表示一对整数坐标。这个技巧不复杂,但能让你的代码去掉大量字符串操作。
C++ 的坑在负数和自定义哈希。unordered_set<pair<int,int>>需要你提供一个哈希函数,直接塞没法编译。实战里我有两个偏好:一个是定义struct Key { int x, y; bool operator==(const Key&) const; }然后写哈希;另一个就是转long long,把x和y都先转成无符号整数再移位拼接,处理负数时要小心符号位。更省心的做法是用set<pair<int,int>>,查询变O(log n),但总复杂度从O(n²)变成O(n² log n),如果面试官较真复杂度,你得提前想好怎么解释。
JavaScript 环境最容易被对象哈希坑到。很多人写const set = new Set(),然后把点存成{x, y},结果两个结构相同但对象引用不同的点永远被认为不相等,查存在性直接失败。老老实实用Set<String>,key 就拼"x,y",简单可靠。Kotlin 因为有data class Point(val x: Int, val y: Int),结构相等和哈希都是自动的,是这几个语言里最省心的。
语言差异这部分,看似是细节,其实在笔试里能救命。很多题目你不是不会,是哈希键没写好导致超时或查不到。记住了这些坑,换什么环境都能稳一点。
最后聊一点个人体会。我一度觉得这种题太基础,不值得专门准备。后来有一次围观别人现场写,一个候选人洋洋洒洒写了四十多行,把旋转公式、哈希去重全都搞定了,很漂亮;另一个候选人五秒写出超出预期的简单解法,最后却挂在浮点比较上,提交瞬间才发现不对。那一刻我才明白,判断正方形真正考的不是“知不知道为什么”,而是“能不能在高压下写出绝不越界的代码”。从那以后,凡是能用整数算的几何,我绝不开根号;凡是能用平方距离的地方,我绝不去比较长度。这个小习惯帮我躲过了很多莫名其妙的测试用例,也希望对你有点用。