先说一个我真实踩过的坑。前几年做广告平台的数据服务每天要接收几千万条设备ID的去重和状态判断一开始直接用 Redis Set 存储内存眼看着往上飙不到两周就触发容量预警。后来有同事提醒了一句“布隆过滤器可以看看”我当时也听说过位图这个数据结构心想布隆过滤器不就是位图加几个哈希函数能有多大区别。结果真动手去改造、去压测、去调参数之后才意识到这个“位图加哈希”的小东西背后牵扯的原理推导、参数权衡和工程坑比想象中多得多。这篇文章不聊虚的就认认真真拆一下布隆过滤器Bloom Filter和位图Bitmap这两个数据结构。它们到底能解决什么问题一句话概括在数据量大、内存吃紧、又允许一定概率误差的场景下用极低的内存代价去判断“某个元素是否大概率出现过”。典型应用包括缓存穿透防护、黑名单过滤、爬虫 URL 去重、数据库层的快速存在性判定。适合谁看一是面试前想彻底搞懂布隆过滤器原理的人二是后端开发时打算真正落地这个方案的人三是被 Redis 内存逼疯、想找一个省内存替代方案的人。我尽量把原理讲明白把公式推导过程给你把可复现的 Java 和 Redis 实操代码贴出来最后再把线上踩过的坑和排查思路整理成速查表。1. 位图用比特位做标记的高效数据结构1.1 位图的底层原理位图的全称叫 Bitmap核心思想极其朴素用一个 bit位来标记某个元素是否存在。8 个 bit 组成一个字节32 个 bit 组成一个 int。如果我们要存储“某个数字是否出现过”传统做法是往 Set 或 Map 里塞数据一个 int 占 4 字节一亿个 int 就是 400MB。但位图的思路是把“数值本身”当作数组下标把该下标对应的 bit 置为 1。一亿个数字只需要一亿个 bit换算下来约 12.5MB差距是几十倍。你可以把位图想象成一栋宿舍楼的电子门牌系统。每个房间号对应一个开关开关只有“亮/灭”两种状态。你要标记 10086 号房间有人入住就把 10086 号开关打开要查 10086 是否入住就看那个开关有没有亮。这里的关键是房间号本身就是数据不需要额外存一份数据副本。所以位图天然适合做“是否存在”这种判断题而且是精确判断不是概率判断。实现层面Java 里最直接的位图是java.util.BitSet它内部用long[]存储一个 long 是 64 位。手动实现也很简单核心就三件事找到目标 bit 在数组中的下标用位运算把对应位置置 1用位运算读回对应位置。位运算无外乎|置 1、判断和/移位。1.2 手写一个简单位图我不建议你把BitSet当成黑盒用一遍就完事自己写一次更能理解底层逻辑。下面这个实现只保留 set、get、clear 三个核心方法足够覆盖大多数使用场景。public class SimpleBitmap { private final long[] words; private final int bitCount; public SimpleBitmap(int bitCount) { this.bitCount bitCount; // 每个 long 有 64 位需要多少个 long 才能覆盖 bitCount 个位 this.words new long[(bitCount 63) / 64]; } public void set(int index) { checkIndex(index); // index / 64 定位到哪个 longindex % 64 定位到 long 里的哪个位 words[index / 64] | (1L (index % 64)); } public boolean get(int index) { checkIndex(index); return (words[index / 64] (1L (index % 64))) ! 0; } public void clear(int index) { checkIndex(index); words[index / 64] ~(1L (index % 64)); } private void checkIndex(int index) { if (index 0 || index bitCount) { throw new IndexOutOfBoundsException(index: index); } } }这段代码有几个细节值得注意。第一(bitCount 63) / 64是向上取整保证空间足够多出来的位不会访问到。第二1L (index % 64)必须用1L而不是1否则在移位超过 31 位时 int 会溢出导致标记错位。第三clear方法用的是 ~(1L ...)先取反再与原理是“把目标位变 0其他位保持不变”这个模式在嵌入式编程、操作系统页表管理里也很常见。写完之后可以做个内存估算练习。假设要标记 10 亿个 int直接HashSetInteger大概要 4GB 以上还要算上对象头和扩容开销换成位图只需要(10^9 / 8) / 1024 / 1024 ≈ 119MB。如果把范围缩小到 1 亿就是约 12.5MB。这个差距面试官问“海量数据如何去重”时位图就是标准答案之一。1.3 位图的经典应用场景位图不只是教科书概念它藏在很多基础软件里。最常见的是操作系统内存管理里的页分配器物理内存被划分成固定大小的页帧内核用一张位图记录每个页帧是空闲还是已被占用分配页时扫描位图找空闲位释放页时把对应位清 0。热搜词里的“页分配器与位图安装”说的大体就是这个机制。这种场景对空间极度敏感位图带来的节省是实打实的。另一个典型场景是 Redis 的 Bitmap 操作。Redis 的 String 类型底层是字节数组可以用SETBIT和GETBIT按位操作相当于一个可共享的分布式位图。比如统计一整年用户的签到状态一年 365 天一个用户只占 365 个 bit一万个用户也就 50KB 不到。用BITCOUNT还能直接算出有多少天签到比传统的关系表省太多。我自己的经验是位图适合“元素范围可预估、分布相对紧凑”的场景。如果数据范围极大且极度稀疏比如在 32 位整数空间里只存几百个随机数位图反而浪费——这时应该用哈希表或其他索引结构。做技术选型时不要只盯着空间优势数据分布特征必须一起看。2. 布隆过滤器位图之上的概率型进阶2.1 位图到布隆过滤器的跳跃位图有一个天然局限它把“数值本身”当作下标所以只能处理整数而且要求数值范围不能太大。当我们要判断“某个 URL 是否已经抓取过”“某个用户 ID 是否在黑名单里”这类字符串场景时位图直接失灵。怎么办最简单的想法是用哈希函数把字符串映射成一个整数下标然后去位图里查。但哈希函数存在碰撞不同字符串可能映射到同一个 bit 位光靠一个 bit 无法区分它们。布隆过滤器解决这个问题的思路很直白一个哈希函数会碰撞那就用多个哈希函数把每个元素映射到多个 bit 位上。比如用 3 个哈希函数算出一个字符串的 3 个下标插入时把这 3 个位置都置 1查询时看这 3 个位置是否都为 1只要有一个位置是 0就说明这个字符串肯定不在集合里。这里的关键逻辑是所有位置都是 1不代表元素一定存在但只要有任意一个位置是 0元素一定不存在。这就是布隆过滤器的“概率性”来源。这句“有 0 必不存在全 1 未必存在”是整个数据结构最核心的结论。它决定了布隆过滤器的几个特点支持“可能存在”的判断支持“一定不存在”的判断没有假阴性False Negative但会有假阳性False Positive。用大白话说就是它会漏报“不存在”吗不会。它会误报“存在”吗会而且这就是“布隆过滤器误判”这个热搜词的真正含义。2.2 误判率的直观理解很多人第一次碰到布隆过滤器误判时会觉得不靠谱其实误判是概率性的而且可以通过参数控制。我们来构建一个直觉模型。假设位数组长度为 m当前已经插入了 n 个元素每个元素使用 k 个哈希函数。哈希函数输出范围很大近似认为每次映射到任意一个位置的概率均匀。那么在某一次插入时某个特定的位没有被某个哈希函数选中的概率是1 - 1/m这个元素一共做 k 次映射所以特定一位在插入该元素后仍为 0 的概率是(1 - 1/m)^k。等 n 个元素都插入完某个位仍然为 0 的概率近似为(1 - 1/m)^(k*n)。查询一个“从未插入过”的元素时它的 k 个哈希位置如果碰巧都已经被其他元素置为 1就会产生误判。所以误判率大约是[1 - (1 - 1/m)^(k*n)]^k。当 m 足够大时(1 - 1/m)^(k*n)可以近似为e^(-k*n/m)于是误判率公式化简为(1 - e^(-k*n/m))^k。这个公式是布隆过滤器参数设计的基石。我第一次推导时花了很长时间才理解“假阳性率取决于位数组被填充的密度”。如果 m 相对于 n 太小位数组几乎全被填成 1那么随便查一个不存在的元素k 个位置大概率都命中误判率接近 100%布隆过滤器就退化成“什么都可能存在”完全失去意义。2.3 参数推导与最佳实践公式实际工程中我们不会去盲猜参数而是根据两个输入来反推预估元素数量 n 和可接受的最大误判率 p。需要求的是位数组长度 m 和哈希函数个数 k。布隆过滤器论文给出了两个经典公式最优位数组长度m - n * ln(p) / (ln 2)^2最优哈希函数个数k (m / n) * ln 2从数学上当k (m/n) * ln2时误判率达到最小。近似计算时k ≈ 0.7 * (m / n)这个“0.7”很好记用来快速估算很有效。我举一个具体例子。假设预估元素 n100 万要求误判率 p1%即 0.01。先算 mln(0.01) -4.605(ln 2)^2 0.4805所以m -1000000 * (-4.605) / 0.4805 ≈ 9583105个 bit约 1.15MB。再看 kk (m/n) * ln2 9.58 * 0.693 ≈ 6.64向上取整为 7。也就是用 7 个哈希函数在 1.15MB 的位数组上处理 100 万个元素理论误判率不到 1%。如果把 p 改成 0.1%m 会变成约 1.72MBk 仍接近 7。这说明在误判率要求不是极端苛刻时内存开销其实相当可控。这也是为什么布隆过滤器能在大数据领域活下来几 MB 就能支撑百万级数据的存在性判断换成哈希集合是几十 MB 甚至上 GB。下表是几个常用参数组合可以直接参考预估元素量 n期望误判率 p位数组大小 m内存占用哈希函数个数 k10 万1%约 96 万 bit0.12 MB7100 万1%约 958 万 bit1.15 MB7100 万0.1%约 1437 万 bit1.72 MB101000 万1%约 9583 万 bit11.4 MB71 亿0.01%约 19.2 亿 bit229 MB13注意一个问题k 算出来往往不是整数实际使用要取整。取整后真实误判率会略高于理论最优值但只要别差太远工程上都可以接受。我的建议是 k 向上取整位数组长度 m 也可以适当往大取因为多分配一点内存能显著压低误判率而少了位后重建代价更高。3. 实战Java 与 Redis 完整落地布隆过滤器3.1 用 Guava 三分钟接入布隆过滤器生产环境最快的落地方式是用 Google Guava 的BloomFilter类。Guava 内部已经实现好了最优参数计算、位数组管理和哈希函数分配我们只需要告诉它预期元素量和想要的误判率。dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.0.0-jre/version /dependency核心代码如下import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import java.util.UUID; public class BloomFilterDemo { public static void main(String[] args) { int expectedInsertions 100_0000; // 预估插入 100 万条 double fpp 0.01; // 期望误判率 1% BloomFilterString filter BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), expectedInsertions, fpp); // 插入 100 万条模拟数据 ListString samples new ArrayList(); for (int i 0; i expectedInsertions; i) { String value user- UUID.randomUUID(); samples.add(value); filter.put(value); } // 全部插入完成后再判断统计误判率 int falsePositiveCount 0; for (String value : samples) { // 这里故意再插一次来判断不对应该换一批不存在的值 } // 正确测法用一批从未插入过的值测试 int testCount 10_0000; int hitCount 0; for (int i 0; i testCount; i) { String notExistValue fake- UUID.randomUUID(); if (filter.mightContain(notExistValue)) { hitCount; } } System.out.println(误判率: (hitCount * 1.0 / testCount)); } }上面代码里注释标出了我第一次写时的错误为了测误判率我又把已插入的值拿去查了一遍当然全部命中毫无意义。正确做法是用另一批从未插入过的随机字符串去查看有多少被误判成“存在”。实测结果通常在 1% 左右徘徊符合参数预期。Guava 的BloomFilter有一个值得注意的底层设计它内部不是用HashMap或BitSet存数据而是用了LockFreeBitArray底层是一个AtomicLongArray。这意味着 Guava 版布隆过滤器是线程安全的多线程并发put和mightContain不需要额外加锁这对高并发场景非常友好。3.2 Redis 实现分布式布隆过滤器Guava 的布隆过滤器是进程内对象如果应用部署了多个实例每个实例的位数组是独立的判断结果就各自为政。比如用户请求负载均衡到 A 实例A 的布隆过滤器说“不存在”但用户数据在 B 实例里被插入过于是发生漏判。要解决这个问题要么引入外部存储统一维护位数组要么做内存同步。我推荐前者直接把位图放到 Redis 里。Redis 的 String 底层是字节数组天然支持按位操作。核心命令就三个SETBIT key offset value把 key 对应的位图第 offset 位设为 0 或 1GETBIT key offset读取第 offset 位BITCOUNT key统计位图中有多少位是 1我们的任务是把“一个元素的 k 个哈希位置”转换成多个 offset逐个SETBIT。这里不再依赖 Guava而是自己实现哈希映射和位数组逻辑。import redis.clients.jedis.Jedis; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public class RedisBloomFilter { private static final String KEY bloom:url:filter; private static final int BIT_SIZE 10_000_000; // 1000万位约1.2MB private static final int HASH_COUNT 7; private final Jedis jedis; public RedisBloomFilter(Jedis jedis) { this.jedis jedis; } public void add(String value) { int[] offsets hashOffsets(value); for (int offset : offsets) { jedis.setbit(KEY, offset, true); } } public boolean mightContain(String value) { int[] offsets hashOffsets(value); for (int offset : offsets) { if (!jedis.getbit(KEY, offset)) { return false; } } return true; } private int[] hashOffsets(String value) { int[] offsets new int[HASH_COUNT]; try { MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(value.getBytes(StandardCharsets.UTF_8)); // 用一个 128 位的 MD5 拆成多个位置 for (int i 0; i HASH_COUNT; i) { int h ((digest[2 * i] 0xFF) 8) | (digest[2 * i 1] 0xFF); offsets[i] Math.abs(h % BIT_SIZE); } } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } return offsets; } }这里我用了 MD5 拆位来生成多个哈希位置简单但不完美。MD5 只能算一个哈希函数把它拆成多段并不能真正生成 k 个独立哈希只是工程上够用。更严谨的做法是采用双重哈希或使用murmurhash配合不同种子生成 k 个独立哈希。Guava 内部实际就是基于murmur3_128拆高位和低位来生成线性独立的哈希函数效果比 MD5 拆位好。生产环境中我建议用 Lua 脚本把“一个元素的 k 次 setbit”打包成原子操作避免并发时中间状态被读到性能也会好很多。大体的 Lua 逻辑是先用redis.call(GETBIT, ...)判断所有位置如果都命中则直接返回 1否则逐位SETBIT最后返回 0 或 1。3.3 布隆过滤器不能删除元素的坑与 Counting Bloom Filter布隆过滤器最大的痛点之一是不支持删除元素。原因想想就明白一个 bit 位可能同时被多个元素共享如果我们删除某个元素时把它对应的 k 个 bit 清 0很可能把其他元素的位置也清了导致其他元素变成“有时不存在”。这是布隆过滤器的固有缺陷不是实现 bug。面试里经常考这个点标准回答是常规布隆过滤器可以 insert 和 query但不能 delete如果业务必须支持删除就要用变种结构比如 Counting Bloom Filter计数布隆过滤器。Counting Bloom Filter 的思路是把位数组里的每一个 bit 扩展成一个计数器插入时给 k 个位置的计数器加 1删除时减 1查询时看计数器是否都大于 0。计数器一般用 4 位能表示 0~15支持大约 15 次重复插入。但它的缺点是空间开销比普通布隆过滤器大得多因为每个位置从 1 bit 变成了 4 bit需要的内存直接翻 4 倍。工程上我会先问业务真的要支持删除吗如果只是偶尔需要“删除”可以定期重建布隆过滤器成本往往低于引入 Counting Bloom Filter 的复杂度。我还见过一个更工程化的补偿方案主布隆过滤器不删除额外维护一个“精确删除集合”也就是用 Redis Set 或数据库把待删除的元素精确记录下来。判断时先查布隆过滤器如果布隆过滤器说“不存在”直接返回如果说“可能存在”再去删除集合里二次确认。这样布隆过滤器本身不用变也能保证删除语义。缺点是精确集合不能太大否则内存优势就没了。4. 真实业务场景盘点缓存穿透、黑名单与爬虫去重4.1 缓存穿透防护缓存穿透是后端高频问题。用户疯狂请求一个 redis 里不存在、数据库里也不存在的 key请求每次都绕过缓存直达数据库轻则拖慢接口重则把数据库打挂。布隆过滤器的做法是系统启动或数据写入时把所有合法 key 都预先把 hash 位置置 1请求进来先过布隆过滤器如果它判定 key 不存在直接返回空根本不去查 Redis 和数据库。这里要特别说清楚一个细节布隆过滤器说“可能存在”时我们才去查缓存和 DB说“不存在”时就直接挡掉。如果是缓存里有但布隆过滤器没数据就会出现“本来存在却被误杀”的情况。所以布隆过滤器必须在数据写入真正的存储之前就一起更新顺序不能反。比如新增一个用户时先filter.put(userId)再写数据库或缓存这样查询路径上布隆过滤器的判断才是完整的。我之前在线上遇到过一个数据不一致的坑历史存量数据导入时只写了 Redis 缓存忘记同步布隆过滤器导致大量存量用户被误判为“不存在”接口直接返回空数据。排查半天最后是逐个对比布隆过滤器和数据库才发现的。所以如果要从零引入布隆过滤器务必设计离线全量重建流程重建逻辑就是循环存量数据重新put比如在凌晨低峰期跑批处理跑完再切换读取路径。4.2 黑名单与敏感信息过滤黑名单场景很经典。比如封禁手机号、拉黑恶意 IP、过滤垃圾邮件地址本质上都是“某个值在不在名单里”的判断题。布隆过滤器可以先把黑名单值全部放入查询时快速过滤。它的误判方向是“把白名单误判成黑名单”也就是宁可错杀、不可放过。这对部分风控业务可以接受但对用户体验要求高的场景要斟酌。我的建议是采用两层过滤第一层布隆过滤器粗筛命中后进入第二层精确名单Redis Set 或数据库索引二次确认。这样既享受了布隆过滤器的低内存优点又避免误杀真实用户。这里要额外提醒一点不要把过于严格的黑名单直接只靠布隆过滤器承载因为它一旦误判用户要申诉、解封操作成本远高于那点内存节省。4.3 爬虫与 URL 去重分布式爬虫的 URL 去重是布隆过滤器最舒服的战场。原因在于爬虫 URL 去重对误判的容忍度很高误判最多导致少爬几个网页不影响整体抓取质量但 URL 数量能达到几千万甚至几十亿用哈希表存会撑爆内存用数据库查询又太慢。布隆过滤器往中间一放内存占用小单次判断是 O(k) 的位运算速度极快。这个场景我做过一次对比测试5000 万 URL 放在 Guava 布隆过滤器里预期误判率 1%内存只占约 60MB同样的数据放 Redis Set光 key 就占了不到一点value 内存却要 1GB 以上。差别摆在那里没有悬念。4.4 数据库与分库分表场景分库分表之后跨库查询很昂贵。布隆过滤器可以作为分片路由的辅助结构每个分片维护一个布隆过滤器记录本分片有哪些主键。查询时先快速判断“目标主键可能在这个分片吗”如果所有分片的布隆过滤器都判定不存在就直接返回空避免把所有分片都查一遍。这个做法在数据分布均匀、主键命中率低的时候收益很高。还有一个和索引相关的点在 LSM-Tree 结构的存储引擎里布隆过滤器被用来加速点查。比如 RocksDB 每个 SSTable 都带一个内置布隆过滤器查询时先判断 key 是否可能在某个 SSTable 里不可能就跳过该文件减少无效磁盘 IO。这就是为什么把布隆过滤器称为“数据库隐藏加速器”它不直接存数据却能大幅降低存储层的随机访问成本。5. 参数调优、常见问题与排查实录5.1 参数选择时要避免的三类错误参数选错是布隆过滤器上线后翻车的最常见原因我总结成三条。第一预估元素量 n 太乐观。很多人设计时按当时的数据量选 n结果半年后数据翻倍误判率跟着飙涨。布隆过滤器不像哈希表可以自动扩容初始化后位数组大小就固定了只能重建。所以预估 n 时我一般会乘以 2 到 3 倍的冗余系数宁多勿少。多出来的内存通常只有几 MB 到几十 MB换来的却是长时间稳定运行。第二期望误判率 p 选得太小。理论上看 p 越小越好但 m 和 p 是对数关系把 p 从 1% 压到 0.01%位数组长度大约增加一倍。如果业务其实能容忍 5% 的误判率却非要按 0.1% 设计纯粹是浪费内存。我自己有个经验值缓存穿透场景一般取 1% 到 5%因为即使误判也会落到缓存层成本可控爬虫去重取 5% 都行风控黑名单因为有二次精确校验可以取 1%。第三哈希函数选得不够均匀。有的实现随便用hashCode()取模这在数据分布不均匀时会让位数组局部过热误判率远超理论值。稳妥做法是用 MurmurHash、MD5 等公认的散列算法并检查哈希函数数量 k 和位数组长度 m 的组合是否与公式计算一致。5.2 高频问题排查速查表我整理了一份布隆过滤器线上排查速查表都是踩过坑后固化下来的判断路径。现象可能原因排查与解决误判率远超预期位数组长度 m 不足或哈希函数取值相关用公式按当前实际 n 反算理论误判率确认是否接近考虑重建并扩大 m部分数据查不到假阴性元素可能未插入或插入时位数组已满检查插入路径有没有全量执行布隆过滤器本身不存在假阴性出现假阴性一定是你漏插或重建时丢数据内存占用超预期误用了 Counting Bloom Filter 或哈希表替代确认底层使用的是位数组不是 Set 或 MapRedis 用MEMORY USAGE key检查实际占用多实例结果不一致每个实例各持有一个独立布隆过滤器改用 Redis 统一位数组或在应用层做数据同步重建并发插入时查询到中间状态插入不是原子的多个位写入不连贯用 Lua 脚本包装多个 setbit保证原子性删除元素后报错或异常普通布隆过滤器不支持删除改用 Counting Bloom Filter或增加精确删除集合二次确认redis key 太大阻塞请求位数组很大且单 key 频繁读写考虑分段存储把一个大 bitmap 拆成多个 key按哈希前缀路由表格里的“假阴性”我特意强调一下理论上布隆过滤器不会误判“存在”为“不存在”一旦出现通常不是因为布隆过滤器本身而是你插入逻辑没有覆盖全部数据源或者位数组被重建但没同步全部数据。我在多个项目里发现这个认知能省很多排查时间。5.3 线上压测与灾备的额外建议布隆过滤器上线前我习惯先做一轮“误判率实测”准备 100 万个已插入元素和 100 万个从未插入元素分别统计mightContain结果算出真实误判率。如果实测和理论差太多多半是哈希函数质量问题或位数组长度设置错误。实测脚本很简单代码本身可以作为自动化测试的一部分长期执行防止后续改动导致回归。灾备方面Redis 版布隆过滤器最怕的是 Redis 宕机或数据丢失。位数组一旦丢失很多元素会被误判为不存在缓存穿透问题立即暴露。建议定期把位数组 dump 到磁盘或者干脆用 AOF 持久化。如果是 Guava 进程内版本应用重启意味着布隆过滤器清空此时最好有一个从数据库全量重建的兜底任务在启动后异步执行避免服务一开就被穿透打垮。还有一点个人经验布隆过滤器尽量不要做成公共依赖服务后让业务方无脑调用。它带了“概率误判”这个属性业务方如果不理解会把“可能存在”当成“一定存在”导致线上事故。我现在的做法是在 API 命名上直接暴露语义比如mightContain()而不是contains()再在文档和注释里反复强调这个方法的语义是“可能”。这个看起来是个小细节但对规避事故很有用。6. 结尾再聊几句实在的最后分享一个我自己的体会。做技术选型时布隆过滤器看起来是个“老古董”数据结构但它解决的问题恰恰是很多新方案绕不过去的用空间换时间的反面是用极小的空间成本支撑海量数据的存在性判断。我踩过预估值不准的坑也踩过搞错插入顺序导致缓存穿透的坑但把参数、业务语义和兜底流程想清楚之后它就是一套非常稳的基础设施。如果你现在正面临内存告急、查询太慢或者缓存穿透的困扰建议先从“能不能接受误判”这个问题入手。答案是可以的话布隆过滤器就有资格进入候选答案是不可以的话那就用两层方案让布隆过滤器做粗筛精确集合做兜底。数据结构的价值不在于它有多高级而在于它在合适的场景里能不能用最小的成本解决最扎手的问题。