先记住这个答案
数组的 includes 从第一个元素开始逐个比较,找到才停,没找到要比完整个数组,所以是 O(n)。Set.has 在主流引擎里用哈希表实现,平均接近 O(1),不随数据量变慢。所以在 m 次查询、n 个元素的场景里,全用 includes 是 O(m × n),先建 Set 再查是 O(n + m)。但建 Set 本身要遍历一遍数组,还要多占内存,只查一两次、或者每次查询都重新建 Set,就得不偿失了。
- 查数组是
O(n),查Set接近O(1) - 建
Set本身是O(n),要被多次查询复用才划算 - 别在循环里每次都
new Set - 小数组、只查一次,直接
includes就好
一次建好,多次查询
典型场景:有一个允许的 ID 列表,要从一批数据里过滤出在列表中的项。
const allowed = ['a', 'c', 'e']
const queries = ['e', 'x', 'a', 'x']
// 每次都扫描 allowed 数组
const scanned = queries.filter(value => allowed.includes(value))
// 先建一次 Set,之后每次查询都是哈希查找
const index = new Set(allowed)
const indexed = queries.filter(value => index.has(value))
console.log(JSON.stringify(scanned))
console.log(JSON.stringify(indexed))查看输出与解释
["e","a"]
["e","a"]两种写法结果完全一样,区别在成本。scanned 那种写法,queries 里每一项都要把 allowed 扫一遍,两边都是几万条数据时就是几亿次比较。indexed 先花一次遍历建好 index,之后每次查询都很快。
new Set(allowed) 一定要放在 filter 外面。放进回调里,每次查询都会重建一次,比直接 includes 还慢。
不是所有场景都该换成 Set
数组很小,比如只有十几个元素时,includes 的线性扫描本身就很快,而建 Set 要分配哈希表,常数开销反而占了大头,差别基本感觉不到。只查一次的时候更是没必要,建 Set 已经遍历了一遍,不如直接 includes 遍历一遍。
- 数据会变时要考虑同步
- 如果
allowed会经常增删,就要决定是每次变化后重建Set,还是同步调用add和delete维护它。两份数据如果不同步,查询结果就会出错,这比性能差一点严重得多。能直接用Set作为唯一的数据来源最好,需要数组时再[...set]转出来。 真要比较性能,要用接近真实的数据规模和查询分布去测,并且把建Set的时间也算进去。只测has那一步,结论会偏向Set。
容易答错的地方
- 说规范保证
Set.has每次都是O(1) - 规范只要求平均访问时间低于线性,没有规定必须是哈希表,也没有保证最坏情况。可以说「主流引擎里平均接近
O(1)」,不要说成规范承诺。 - 只比较查询,忽略建
Set的成本 new Set(arr)要遍历整个数组并建哈希表。每次查询前都要重新建的话,总成本就是建Set的成本,查询再快也没用。
面试官还会怎么问?
求两个数组的交集怎么写更快?
把其中一个转成 Set,再过滤另一个:const set = new Set(b); a.filter(x => set.has(x)),总共 O(m + n)。直接 a.filter(x => b.includes(x)) 是 O(m × n)。
Set.has 和 includes 对 NaN 的判断一样吗?
一样,两者都用 SameValueZero,都能找到 NaN,另外 +0 和 -0 也都算相等,对象都按引用比较。换写法时不用担心相等规则变了。
用对象的键做查找和 Set 比呢?
键都是字符串时,obj[key] !== undefined 或 key in obj 也很快,但要处理原型链上的属性冲突。Set 不仅没有这个问题,还能存任意类型的值,语义也更清楚,一般优先用 Set。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。