深蓝黑背景品牌封面:左侧大标题「有序数组平方后」,副标题「有多少种不同取值:双指针从两端收敛」;右侧原创装饰——一条 V 形抛物线,横轴上标出负数到正数的刻度点,曲线两端高中间低,左右两端各有一个指针箭头同时向中间移动;左上标注「经典题解析 · 算法」,右上标注「Day 05 · 10-13」 图 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 方案快了一个量级还多,而且省下了哈希表的全部额外内存——「有序性即信息」兑现成了真金白银。

解法结构图:上部是关键观察——含负数的有序数组平方后不再是单调序列,而是两端大、中间最小的 V 形,因此当前未处理的最大平方值一定在数组两端。中部是双指针流程——左右指针各指一端,比较两端平方值,谁大取谁并计数,若与上一个已取值相同则跳过不计,取值一侧的指针向中间移动一格,直到两指针相遇;每一步都在缩小问题。下部是正确性与复杂度说明——因为最大值总在两端,所以贪心选择不会漏掉任何取值;每个元素只被访问一次,O(n) 时间、O(1) 额外空间,不需要哈希集合。底部点题:有序性即信息,输入里已有的顺序是最容易被浪费的免费线索 图 2:关键观察、双指针流程与正确性说明。

可能的追问与易错点。

「为什么不去重后再统计?」平方后用 Set 去重当然对,但 O(n) 空间;面试官紧接着就会问能不能省掉。「去重为什么只跟上一个值比?」因为取值顺序是从大到小单调的,相同取值必然相邻出现,这是本解法成立的暗桩,讲不出这一层等于没真正懂。易错点有三:一是没看出 V 形,从左往右扫导致漏掉右端的重复;二是两端相等时只挪一个指针,另一个等下一轮处理,虽不致命但代码绕;三是忘记空数组与全同值的边界。还有一点值得主动提:如果元素可能很大,平方有溢出风险,可以比较绝对值代替比较平方。

回到面试现场,建议的答题节奏:先给 Set 去重的朴素解并主动指出空间代价,再画 V 形图推出「最大值必在两端」,然后写双指针,最后补一句单调去重的正确性论证。这个「输入里已有的顺序是免费线索」的直觉,在工程里一再出现:特征值变换后的去重统计、单调序列上的范围查询,用的都是同一份信息。

你在面试里有没有过「发现题目白给了一个条件」的瞬间?欢迎分享。