图 1:本文封面,V 形曲线就是这道题的全部秘密。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。它看起来是道去重统计题,实际考察的是你能不能发现「输入有序」这份免费信息。
想象面试现场:「给一个按升序排好的整数数组,有正有负有零。把每个元素平方之后,一共有多少种不同的取值?能不能不开哈希表?」
第一步,关键观察:平方后是个 V 形。
如果数组里没有负数,平方后还是升序,从左往右扫一遍去重就行。麻烦在于负数:负数平方后变大,正数平方后也变大,平方值序列是先降后升的 V 形——最小值在数组里绝对值最小的位置,最大值一定在两端之一。由此得到本题的核心洞察:当前未处理的最大平方值,只能在左端或右端。既然最大值总在两端,双指针从两端向中间收敛,每一步都在缩小问题。
第二步,双指针流程。
左指针指头,右指针指尾。每一步比较两端的平方值:谁大取谁,取值一侧的指针向中间挪一格;两端相等时一次取掉两个(同值,只算一种)。取到的值如果和上一个已计入的值相同,说明是重复取值,跳过不计;否则计数加一并记下来。两指针相遇时收尾。因为取值顺序天然是从大到小,相邻比较就足以去重,不需要任何辅助容器。全程 O(n) 时间、O(1) 额外空间。
第三步,写出可运行代码并实测。
下面这份实现包含双指针主解法、Set 去重朴素对照、固定用例自验、一万组随机对拍和百万级规模实测。用 Node.js 22.15.0 直接运行(node squares.js)即可:
'use strict';
// 双指针:两端向中间收敛,每次取当前两端中较大的平方值,重复取值只计一次
function countDistinctSquares(nums) {
let left = 0, right = nums.length - 1;
let count = 0, last = null; // last 是上一个已计入的平方值
while (left <= right) {
const lv = nums[left] * nums[left];
const rv = nums[right] * nums[right];
let picked;
if (lv > rv) { picked = lv; left++; }
else if (rv > lv) { picked = rv; right--; }
else { picked = lv; left++; right--; } // 两端平方相等,同值一次取掉
if (picked !== last) { count++; last = picked; }
}
return count;
}
// 朴素对照:平方后丢进 Set 去重
function countBySet(nums) {
return new Set(nums.map((x) => x * x)).size;
}
// 用例自验
const cases = [
{ input: [-4, -1, 0, 3, 10], expect: 5 },
{ input: [-3, -2, -1, 0, 1, 2, 3], expect: 4 }, // 平方后大量对称重复
{ input: [1, 1, 2], expect: 2 },
{ input: [], expect: 0 },
{ input: [-7, -7], expect: 1 },
];
for (const { input, expect } of cases) {
const got = countDistinctSquares(input);
console.log(`输入 [${input}] 双指针=${got} Set=${countBySet(input)} 期望=${expect} ${got === expect ? 'OK' : 'FAIL'}`);
}
// 随机对拍:1 万组随机有序数组,双指针结果必须与 Set 一致
let mismatch = 0;
for (let t = 0; t < 10000; t++) {
const n = Math.floor(Math.random() * 30);
const arr = Array.from({ length: n }, () => Math.floor(Math.random() * 41) - 20).sort((a, b) => a - b);
if (countDistinctSquares(arr) !== countBySet(arr)) mismatch++;
}
console.log('随机对拍 10000 组,不一致组数:', mismatch);
// 规模实测:100 万个元素的有序数组
const big = Array.from({ length: 1000000 }, (_, i) => i - 500000);
console.time('双指针(100 万元素)');
const r1 = countDistinctSquares(big);
console.timeEnd('双指针(100 万元素)');
console.time('Set 去重(100 万元素)');
const r2 = countBySet(big);
console.timeEnd('Set 去重(100 万元素)');
console.log('两法结果:', r1, r2);
实测输出如下(耗时随机器波动,量级稳定):
输入 [-4,-1,0,3,10] 双指针=5 Set=5 期望=5 OK
输入 [-3,-2,-1,0,1,2,3] 双指针=4 Set=4 期望=4 OK
输入 [1,1,2] 双指针=2 Set=2 期望=2 OK
输入 [] 双指针=0 Set=0 期望=0 OK
输入 [-7,-7] 双指针=1 Set=1 期望=1 OK
随机对拍 10000 组,不一致组数: 0
双指针(100 万元素): 2.933ms
Set 去重(100 万元素): 70.392ms
两法结果: 500001 500001
一万组随机对拍零失误,百万规模下双指针比 Set 方案快了一个量级还多,而且省下了哈希表的全部额外内存——「有序性即信息」兑现成了真金白银。
图 2:关键观察、双指针流程与正确性说明。
可能的追问与易错点。
「为什么不去重后再统计?」平方后用 Set 去重当然对,但 O(n) 空间;面试官紧接着就会问能不能省掉。「去重为什么只跟上一个值比?」因为取值顺序是从大到小单调的,相同取值必然相邻出现,这是本解法成立的暗桩,讲不出这一层等于没真正懂。易错点有三:一是没看出 V 形,从左往右扫导致漏掉右端的重复;二是两端相等时只挪一个指针,另一个等下一轮处理,虽不致命但代码绕;三是忘记空数组与全同值的边界。还有一点值得主动提:如果元素可能很大,平方有溢出风险,可以比较绝对值代替比较平方。
回到面试现场,建议的答题节奏:先给 Set 去重的朴素解并主动指出空间代价,再画 V 形图推出「最大值必在两端」,然后写双指针,最后补一句单调去重的正确性论证。这个「输入里已有的顺序是免费线索」的直觉,在工程里一再出现:特征值变换后的去重统计、单调序列上的范围查询,用的都是同一份信息。
你在面试里有没有过「发现题目白给了一个条件」的瞬间?欢迎分享。