
同一批数据、同一台机器,只换去重那一行,五种写法的耗时差到七十倍:最快的 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,而且默认会就地改原数组。我不用它,省下的那点时间抵不过一次顺序被换掉之后的排查。