深蓝黑背景品牌封面:居中大标题两行「5000 万个 URL 和 500 个,如何快速找出交集」,副标题「哈希集合 vs 布隆过滤器」;下方装饰——一个紫色大圆标注「5000 万」与一个青色小圆标注「500」相交,重叠的月牙区域高亮为青色并标注「交集」;顶部标注「历史题目复盘 · 数据处理」与「Day 08 · 09-26」 图 1:本文封面——一大一小两个集合的交集区域。

先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。海量数据求交集是数据处理面试的经典题型,面试官想看的不是某个数据结构,而是你「先问规模、再选方案」的决策路径。

想象面试现场,面试官给出两个集合:一个 5000 万个 URL,一个 500 个 URL,如何快速找出交集?

第一步,审题:先问两个问题。

问题一:较小的那个集合装得下内存吗?问题二:业务上能不能容忍小概率误判?这两个问题的答案决定方案,而不是反过来先想一个方案再往题目上套。这也是所有海量数据题的通用开场。

第二步,基础回答:装得下,就用哈希集合。

本题里 500 个 URL 毫无内存压力。做法是把小集合建成哈希集合,然后流式扫大集合——逐个 URL 查「在不在集合里」,在就是交集成员。哈希查询近似 O(1),全程一遍扫描,时间 O(n),结果精确,零误判。大集合甚至不需要装进内存,按行流式读取即可,这才是「5000 万」这个数字该被处理的方式。

第三步,进阶回答:两边都海量怎么办。

把题目改一改:如果是 5000 万对 5000 万,小集合也装不下了呢?两条路。

方案 A,布隆过滤器。它是一个位数组加 k 个哈希函数:加入元素时把 k 个哈希位置的比特置 1;查询时若 k 个位置有任何一个为 0,则元素一定不在;全为 1 则可能在——有小概率误判(把不在的报成在),但绝不漏判。内存占用极小,代价是误判率与不支持标准删除。判为「可能在」的候选,再回源确认一次,就得到了精确结果。

方案 B,哈希切分。两个集合按同一规则 hash(url) mod N 切成 N 个小文件,同一个 URL 在两个集合里的哈希值相同,必然落进同一编号的小文件。然后对应编号两两求交——每个小文件都装得下内存了,问题退化回第二步。精确、无误判,代价是多轮磁盘读写。

决策路径图:起点问「小集合装得下内存吗」。装得下(本题 500 个毫无压力)→ 把它建成哈希集合,流式扫大集合逐个查在不在,时间 O(n),结果精确。装不下(两边都是海量)→ 两条路:方案 A 布隆过滤器——位数组加 k 个哈希函数,内存极小但有小概率误判,判「可能存在」的再回源确认;方案 B 哈希切分——两个集合按同一规则切成小份,对应份两两求交,精确但要多轮磁盘读写。底部对比表:哈希集合精确但费内存;布隆过滤器省内存、有误判、标准版不支持删除。口诀:先问装不装得下,再问能不能容忍误判 图 2:按「装不装得下、容不容误判」两问展开的决策路径。

第四步,用代码把两种方案都跑一遍。

下面这份代码同时实现哈希集合精确求交与布隆过滤器粗筛,用 5 万个 URL 对 500 个 URL 实测,Node.js 22.15.0 直接运行(node url-intersection.js):

'use strict';

// 方案一:小集合装得下内存 → 哈希集合精确求交
function intersectBySet(big, small) {
  const set = new Set(small);
  return big.filter((url) => set.has(url));
}

// 方案二:两边都海量 → 布隆过滤器粗筛,判「可能在」的回源确认
class BloomFilter {
  constructor(bitCount, hashCount) {
    this.bits = new Uint8Array(bitCount);
    this.bitCount = bitCount;
    this.hashCount = hashCount;
  }
  #hash(str, seed) {
    // FNV-1a 变体:不同 seed 充当 k 个独立哈希函数
    let h = (0x811c9dc5 ^ seed) >>> 0;
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i);
      h = Math.imul(h, 0x01000193) >>> 0;
    }
    return h % this.bitCount;
  }
  add(str) {
    for (let k = 0; k < this.hashCount; k++) {
      this.bits[this.#hash(str, (k + 1) * 0x9e3779b1)] = 1;
    }
  }
  mightContain(str) {
    for (let k = 0; k < this.hashCount; k++) {
      if (!this.bits[this.#hash(str, (k + 1) * 0x9e3779b1)]) return false; // 有位为 0 → 一定不在
    }
    return true; // 所有位都是 1 → 可能在(可能误判)
  }
}

// 造数据:大集合 5 万个,小集合 500 个,其中 120 个与大集合重叠
const big = [];
for (let i = 0; i < 50000; i++) big.push(`https://example.com/page/${i}`);
const small = [];
for (let i = 0; i < 120; i++) small.push(`https://example.com/page/${i * 100}`); // 真交集
for (let i = 0; i < 380; i++) small.push(`https://other.com/item/${i}`); // 不重叠

const exact = intersectBySet(big, small);
console.log(`方案一 哈希集合:交集 ${exact.length} 个,零误判`);

const bloom = new BloomFilter(200000, 3); // 约 2.5 万字节装 5 万元素(故意压紧,让误判显现)
for (const url of big) bloom.add(url);
const candidates = small.filter((url) => bloom.mightContain(url));
const falsePositives = candidates.filter((url) => !big.includes(url));
const truth = new Set(exact);
const missed = small.filter((url) => !bloom.mightContain(url) && truth.has(url));
console.log(
  `方案二 布隆过滤器:粗筛候选 ${candidates.length} 个,其中误判 ${falsePositives.length} 个,漏判 ${missed.length} 个`
);
console.log('误判需回源确认,漏判恒为 0——布隆过滤器只会把「不在」误报成「可能在」,绝不漏掉真正在的');
if (missed.length !== 0) throw new Error('布隆过滤器不允许漏判');

实测输出如下:

方案一 哈希集合:交集 120 个,零误判
方案二 布隆过滤器:粗筛候选 149 个,其中误判 29 个,漏判 0 个
误判需回源确认,漏判恒为 0——布隆过滤器只会把「不在」误报成「可能在」,绝不漏掉真正在的

2.5 万字节装下 5 万元素,120 个真交集一个不漏,代价是 29 个误判候选需要回源剔除。加大位数组或调整哈希个数,误判率还能继续往下压——内存与误判率的交换旋钮就在你手里。

易错点清单。

一,不看规模就答布隆过滤器——500 个元素的集合用哈希集合又准又简单。二,以为布隆过滤器给出精确答案——它只给「一定不在」和「可能在」,后者必须回源。三,哈希切分时两个集合用了不同切分规则,同一 URL 落进不同编号,交集直接算错。四,把大集合整个读进内存再处理——流式扫描才是海量数据的默认姿势。

回到面试现场,建议的答题节奏:先问规模与误判容忍度两个问题,再给哈希集合的基础解,然后主动把题目推向「两边都海量」并给出布隆过滤器与哈希切分两条路。训练语料去重、黑名单过滤、已见样本判定,用的都是这套决策路径。

你的业务里有用到布隆过滤器的场景吗?误判率是怎么定下来的?欢迎分享。

参考资料

  • Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,Set、Uint8Array、Math.imul 等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/