侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 184 篇文章
  • 累计收到 7 条评论

100 万条数组去重,indexOf 版删光了所有 NaN

2026-10-13 / 0 评论 / 4 阅读

100 万条数组去重,indexOf 版删光了所有 NaN

同一批数据、同一台机器,只换去重那一行,五种写法的耗时差到七十倍:最快的 Set 二十多毫秒跑完,最慢的 filter 加 indexOf 花了一点六秒。

数据是一百万个整数,固定种子从两万个候选值里随机取,去重后剩一万四千九百四十五个,重复项超过九成八。另有两组小样本卡边界:一组混进三个 NaN,一组装重复出现的对象引用。

五种写法跑同一批百万级数组

五种写法都按最常见的样子写,各自返回一个新数组,跑之前不预热,每种只跑一次,脚本跑在 node v26.8.1 上。

== 0. 运行环境 ==
node 版本: v26.8.1
平台: darwin arm64
== 2. 五种写法的耗时与结果长度 ==
Set 展开 耗时: 22.8 ms 结果长度: 14945
filter 加 indexOf 耗时: 1604.0 ms 结果长度: 14945
reduce 加 includes 耗时: 1103.1 ms 结果长度: 14945
先 sort 再比相邻 耗时: 359.2 ms 结果长度: 14945
Map 键去重 耗时: 28.6 ms 结果长度: 14945
写法 耗时 (ms) 结果长度
[...new Set(a)] 22.8 14945
a.filter((x, i) => a.indexOf(x) === i) 1604.0 14945
a.reduce(...) 配 includes 1103.1 14945
先 sort 再比相邻 359.2 14945
Map 键去重 28.6 14945

Set 展开与 Map 键去重都在三十毫秒以内,接着是 sort 版的 359 毫秒,再往外两个数量级才是 filter 和 reduce 那两版。五份结果长度都是 14945,不多也不少。耗时是单次运行,长度是定的。

一点六秒花在哪

两个慢版本干的是同一件事:对每个元素重新扫一遍已有数据。filter 版条件写成 a.indexOf(x) === i,reduce 版每一步调 acc.includes(x)。

indexOf 从下标 0 开始找,撞上第一次出现就停。重复项的首次出现大多落在数组前段,单次扫描常常没跑满,才落在 1.6 秒。

reduce 那版略快,1.1 秒对 1.6 秒,差距还不足以让平方级写法翻盘。

五份结果的集合完全一样

== 3. 结果集合是否与 Set 版一致 ==
filter 加 indexOf 与 Set 版一致: true
reduce 加 includes 与 Set 版一致: true
先 sort 再比相邻 与 Set 版一致: true
Map 键去重 与 Set 版一致: true

四份结果逐一与 Set 比对,集合和长度都对得上。这批数据上挑哪种写法都能拿到正确结果,速度是唯一的差别。前提是数组装基本类型,里面也没有 NaN。

sort 版顺手改了原数组

== 4. sort 版把顺序改掉了 ==
原数组前 6 项: 18949, 5916, 1626, 17852, 14833, 7646
sort 版结果前 6 项: 0, 1, 10, 10000, 10001, 10002
小样本原地 sort 前: 5,1,4,2,3
小样本原地 sort 后: 1,2,3,4,5
原地 sort 改动了原数组: true

原地 sort 会把原数组重排,五元素小样本 5,1,4,2,3 进、1,2,3,4,5 出。百万级样本里,原数组开头那串也被换成了排好序的 0, 1, 10, 10000。去重后如果还要用原来的顺序,比如那串数字记的是事件先后,sort 版就不能上,除非先复制一份再排。

NaN 与对象引用上的分叉

== 5. NaN 边界 ==
样本长度: 8
样本里 NaN 个数: 3
Set 展开 长度: 4 其中 NaN 个数: 1
filter 加 indexOf 长度: 3 其中 NaN 个数: 0
reduce 加 includes 长度: 4 其中 NaN 个数: 1
先 sort 再比相邻 长度: 6 其中 NaN 个数: 3
Map 键去重 长度: 4 其中 NaN 个数: 1
indexOf 对 NaN 的判定: -1
includes 对 NaN 的判定: true
NaN 严格等于 NaN: false

NaN 那组样本长 8,里面三个 NaN,五种写法给出三种不同的长度。

写法 结果长度 其中 NaN
[...new Set(a)] 4 1
a.filter((x, i) => a.indexOf(x) === i) 3 0
a.reduce(...) 配 includes 4 1
先 sort 再比相邻 6 3
Map 键去重 4 1

Set 与 Map 留一个 NaN,两套键判定都走 SameValueZero,所有 NaN 视作同一个值。reduce 版也留一个,关键在 includes 同样走 SameValueZero,[NaN].includes(NaN) 为 true。

filter 版最意外:结果只剩 3 个,NaN 一个不剩。indexOf 走严格相等,a.indexOf(NaN) 永远是 -1,等不上当前下标 i,于是每个 NaN 连第一次出现都被判掉,整组被删。这不是去不掉重复,是悄悄丢数据,长度从 4 掉到 3 才会显形。

sort 版把三个 NaN 全留下。排序后三个 NaN 挨在一起,可 NaN !== NaN 成立,相邻比较认不出它们相等。

== 6. 对象引用边界 ==
o1 与 o2 的值相等: true
o1 与 o2 是同一个引用: false
样本长度: 5
Set 展开 长度: 2
filter 加 indexOf 长度: 2
reduce 加 includes 长度: 2
先 sort 再比相邻 长度: 3
Map 键去重 长度: 2
两个值相等的不同对象被合并成一个: false
同一个引用出现三次被去成一次: true

对象引用那组样本长 5,o1 出现三次,o2 出现两次,两者值相等而非同一引用。除 sort 版外都给 2:同一个引用合掉,值相等的两个不同对象留下两个。sort 版给 3:默认排序把对象统一当成 [object Object],两个对象被视作相等,顺序又保持不动,得到 o1,o1,o2,o2,o1,末尾那个 o1 的前一个是 o2,引用不同就漏了。要按值合并对象,五种写法都不代劳,得自己先算出一个可比较的键。

== 7. 大小写与类型混排样本 ==
样本长度: 11
Set 展开 结果: 1, 1, true, null, undefined, 0
Set 展开 长度: 6
0 与 -0 在 Set 里是否同一个: true
数字 1 与字符串 1 是否同一个: false

最后一组混排类型,字符串 '1' 和数字 1 都印成 1,Set 却给两个,长度 6 而不是 5;0 与 -0 反过来被算成同一个。两条都由 SameValueZero 决定:-0 与 0 相等,数字 1 与字符串 1 不等。

落在代码上的取舍

常规场景直接用 Set,一行就够,复杂度线性,判定走 SameValueZero,也不动原数组。要顺便带上计数时,Map 版等价,代价是几毫秒。

数据里可能冒出 NaN,filter 加 indexOf 这版不能上,它会把 NaN 整组删掉,而且只有长度对不上时才会被发现。reduce 加 includes 在 NaN 上判断正确,用 includes 而不是 indexOf 是分水岭,但平方级耗时让它只适合小数组。

sort 版能用的场景很窄:顺序无所谓,元素是基本类型且样本里不含 NaN,而且默认会就地改原数组。我不用它,省下的那点时间抵不过一次顺序被换掉之后的排查。

    🤞 分享