位图和布隆过滤器,是我在海量数据判重场景里最常用的两板斧
先说一个我经常拿来开场的问题,也是当年我面试时被问到的:如果给你40亿个不重复的无符号整数,内存限制在1GB以内,如何快速判断某个数是否在这40亿个数当中?
第一反应可能是排序后二分,或者丢进哈希表。但40亿个int排序后在内存里至少占16GB,哈希表的开销更大。这时候你就需要一个“记住状态但不需要记住值本身”的数据结构——位图(Bitmap)出现了。再往后,如果数据量更大、数据本身不是连续整数,而是URL、用户名这种字符串,位图也撑不住,于是布隆过滤器(Bloom Filter)成了标准答案。
这篇文章不讲八股,就把我实际写C++代码时怎么设计位图、怎么写布隆过滤器、怎么估算参数、以及线上真实踩过的坑都摊开聊一遍。无论你是准备面试还是真的要在项目里处理海量判重,应该都能找到直接能抄的代码和思路。
1. 位图的核心价值:把“状态”而不是“数据”存下来
1.1 一个二进制位回答一个“存在吗”
位图的思路极其朴素:给每个可能的值分配一个bit,bit为1表示“这个值出现过”,bit为0表示“没出现过”。查询的时候只要看对应的bit是0还是1,就能在O(1)时间内得到答案。
很多人第一次接触位图时觉得这不过是个“节省内存的数组”,但它的本质其实是把数据存储问题转化成了状态记录问题。你不需要保存原始值本身,你只需要一个能根据值计算出唯一位置的方法。对于无符号整数来说,这个值本身就是下标,所以位图在整数判重场景里是天然的完美适配。
这么说可能太抽象,我们算一笔账。假设要处理的是32位无符号整数,取值范围0到2^32 - 1。如果用位图,只需要2^32个bit,也就是512MB。如果直接存这些整数的集合,最坏情况下要存2^32个int,按4字节算就是16GB。在只关心“在不在”这个问题的前提下,位图把空间压缩到了原来的1/32。
注意这里的本质是:用“提前分配整个值域空间”去换“查询时O(1)的时间”。哈希表是空间随数据量增长,位图是空间随值域范围增长。选哪个,取决于你面对的是什么样的数据分布。
1.2 为什么哈希表在某些场景下不是最优解
哈希表的查找也是O(1),但它的内存开销比位图大很多。以unordered_set为例,一个元素除了要存4字节的int本身,还要存哈希表节点指针、负载因子预留空间,平均下来一个元素的实际开销少说16字节起步。数据量到亿级时,这个差距就很明显了。
另一个更关键的问题:哈希表在插入过程中会发生内存分配和rehash,在大数据量下性能抖动明显。而位图在初始化时一次性把内存分配好,后续所有操作都是纯内存位运算,没有分配、没有rehash、没有链地址冲突问题。
我并不是说位图可以替代哈希表。哈希表能存key-value、能枚举所有元素、支持删除操作,这些都是位图做不到的。但在“只需要判断是否存在”这种特定问题上,位图的简洁和性能确实是哈希表比不了的。做技术选型最忌讳的就是拿着一把锤子看什么都是钉子,明白每个数据结构的边界,比背一百个API管用。
1.3 位图的内存模型与换算关系
实现位图时,C/C++里没有bit级别的数组类型,最小可寻址单位是byte。所以位图通常用vector<uint64_t>或vector<unsigned char>来承载,靠位运算定位到具体的bit。
换算关系其实就两个公式:
- 第几个
uint64_t:index = pos / 64 - 这个
uint64_t里的第几位:offset = pos % 64
为什么要用uint64_t而不是vector<bool>或者vector<char>?vector<bool>在C++标准库里有专门的bit压缩实现,但它的代理对象设计导致在很多场景下性能不佳,而且在多线程环境下操作起来很别扭。vector<char>的问题是一个字节只用一个bit太浪费。vector<uint64_t>在64位机器上一个指令就能处理64个bit,无论从空间还是从CPU指令效率上都是更好的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个可用的C++位图:接口、位运算与边界处理
2.1 接口设计:set、reset、test,少即是多
先给出一个我自己在项目里反复使用的位图实现,代码不长,但涉及到的细节都值得抠一抠:
cpp复制#include <cstdint>
#include <stdexcept>
#include <vector>
class Bitmap {
public:
explicit Bitmap(size_t bit_count)
: bit_count_(bit_count),
words_((bit_count + 63) / 64) {}
void set(size_t pos) {
check_range(pos);
words_[pos / 64] |= (uint64_t{1} << (pos % 64));
}
void reset(size_t pos) {
check_range(pos);
words_[pos / 64] &= ~(uint64_t{1} << (pos % 64));
}
bool test(size_t pos) const {
check_range(pos);
return (words_[pos / 64] >> (pos % 64)) & 1;
}
void clear_all() {
std::fill(words_.begin(), words_.end(), 0);
}
size_t size() const {
return bit_count_;
}
private:
void check_range(size_t pos) const {
if (pos >= bit_count_) {
throw std::out_of_range("bitmap position out of range");
}
}
size_t bit_count_;
std::vector<uint64_t> words_;
};
接口只提供set、reset、test三个核心操作,够用就好。不要在一开始就给位图堆上各种遍历、统计之类的接口,单一职责在这里特别重要——位图就该是个纯粹的状态记录器,复杂的业务逻辑放在上层。
2.2 位运算的细节:mask的生成是关键
三个核心操作里,最值得琢磨的是mask的生成。
set操作先定位到对应的uint64_t,然后用按位或置1:
cpp复制words_[pos / 64] |= (uint64_t{1} << (pos % 64));
(uint64_t{1} << (pos % 64))生成了一个只在目标位置为1的掩码。这里有一个新手容易踩的坑:必须用uint64_t{1}而不是直接写1 << (pos % 64)。因为1默认是32位int,当pos % 64大于等于32时,移位操作的行为是未定义的。用uint64_t{1}确保移位操作发生在64位整数上。
reset操作是set的逆操作:
cpp复制words_[pos / 64] &= ~(uint64_t{1} << (pos % 64));
先取反掩码,再按位与,目标位置被清零,其他位置不受影响。这里同样要注意取反操作要发生在64位整数上,否则高32位会被意外置1。
test操作只需要读取对应的bit:
cpp复制return (words_[pos / 64] >> (pos % 64)) & 1;
右移后按位与1,得到0或1。这个过程不修改任何数据,所以用const修饰。
这三个操作的共同点在于:定位到uint64_t的代价是O(1),定位到bit的代价也是O(1),整个查询和修改过程完全不依赖已有数据量。这就是位图的核心性能特征。
2.3 边界情况处理:越界检查与扩容
位图最常见的bug来自越界访问。比如初始化时bit_count不是64的倍数,最后一个uint64_t的高位没有被使用,如果上层逻辑越界访问会读到脏数据。我的做法是在所有公开接口里做check_range,宁可多一次比较,也不要因为位运算天然支持取模而导致静默错误。
再看扩容的问题。位图在初始化时就要明确知道自己要覆盖多大的值域。如果值域是动态增长的,位图就非常被动。我早期做的一个抓包分析工具就遇到过这种情况:一开始按IPv4地址的规模分配位图,后来要统计IPv6地址,直接崩了。解决方式有两种:
- 初始化时按最大值域分配,空间换安全
- 封装一个
ensure(size_t new_size)方法,在越界时重新分配并拷贝原数据
cpp复制void ensure(size_t new_size) {
if (new_size <= bit_count_) return;
size_t old_words = words_.size();
size_t new_words = (new_size + 63) / 64;
words_.resize(new_words, 0);
bit_count_ = new_size;
}
注意resize的第二个参数传0,否则新扩展的部分默认值是不确定的。这个ensure方法虽然简单,但在实际项目里救过我很多次。
2.4 用位图给40亿整数排序
位图不仅能判重,还能做排序,这个应用可能很多人没想过。
原理很简单:把所有整数插入位图,然后从头到尾扫描位图,遇到bit为1就把对应的数字输出。整个过程时间复杂度O(n),空间复杂度O(值域/8)。
cpp复制std::vector<uint32_t> sort_unique(const std::vector<uint32_t>& data) {
Bitmap bitmap(1ull << 32);
for (uint32_t v : data) {
bitmap.set(v);
}
std::vector<uint32_t> result;
result.reserve(data.size());
for (size_t i = 0; i < (1ull << 32); ++i) {
if (bitmap.test(i)) {
result.push_back(static_cast<uint32_t>(i));
}
}
return result;
}
这个排序能成立的前提是:输入的整数不重复,且值域范围可接受。如果数据里存在大量重复,这种排序反而会丢失信息——因为位图天然是去重的,它不知道一个数出现了多少次。所以这个技巧适合“去重后排序”这一特定场景,不适合通用排序。
3. 位图的局限:什么场景下它不适用
3.1 负数、大偏移量与稀疏数据的问题
位图的下标映射要求值是天然非负整数。如果数据里有负数,你得先做偏移转换;如果数据范围极大但实际有效数据很少,比如在大整数上做稀疏记录,位图会浪费大量内存。
举个例子:假设要记录某个系统里所有用户ID的登录状态,用户ID范围是1到100亿,但实际上只有100万活跃用户。此时位图需要分配100亿个bit,约1.25GB内存,只为记录100万个状态位。这种场景下,用unordered_set或roaring bitmap这类压缩位图会更合适。
3.2 位图无法回答的“第三个问题”:删除与计数
位图只能回答“有没有出现过”。如果你问“某个值出现了几次”,位图给不了答案。有人可能会说:我可以用多个bit来表示一个值的计数,比如用4个bit记录0-15的频次。这个思路确实可行,但内存消耗会随之成倍增长,而且当计数溢出时你需要额外的处理逻辑。
更要命的是删除问题。一个普通位图,如果把某个位置的bit从1改成0,你无法区分两种语义:这个值“被删除过”还是“从未出现过”。在纯位图模型里没有历史信息可以区分这两者。这在很多实际场景下是无法接受的。
3.3 字符串、对象等非整数类型无法直接映射
位图的价值建立在“值到下标”映射的唯一性和连续性之上。整数天然满足这个条件,但字符串不行,自定义对象更不行。你当然可以用哈希函数把字符串映射到某个bit位置,但这就引入了哈希冲突——两个不同的字符串可能映射到同一个位置。
这个矛盾恰恰是布隆过滤器出现的原因。布隆过滤器本质上就是“位图+多个哈希函数”,它通过多次独立映射来降低冲突概率,把“一定准确”的位图变成了“高概率准确”的过滤器。
4. 布隆过滤器:把哈希冲突的概率压到可控范围
4.1 从单哈希到位数映射:为什么一个哈希函数不够
先看一个朴素的想法:给一个字符串算一个哈希值,取模后定位到位图的某个bit。插入时置1,查询时看bit是否为1。这个方案的问题非常明显——哈希冲突。不同字符串算出相同的哈希值取模后落到同一个bit,后插入的字符串会把先插入的“顶掉”,导致查询结果失真。直接一点说:这种单哈希位图在数据量上去之后,误判率高到几乎不可用。
布隆过滤器的改进是把一个哈希变为k个相互独立的哈希,插入时把k个位置全部置1,查询时检查k个位置是否全部为1。只要有一个位置是0,元素肯定不存在;只有k个位置全部为1,才认为元素可能存在。
注意“可能”这两个字。布隆过滤器的核心特征就是存在假阳性而不存在假阴性:它不会漏掉真正存在的元素,但有一定概率把不存在的元素误判为存在。这个特性决定了它的典型应用场景——“可以接受少量误判,但绝不能漏判”,比如网络爬虫的URL去重:一个URL被重复抓一次没关系,但漏抓一个页面可能就丢了重要信息。
4.2 误判率公式与参数推导:m、n、k如何选
这里直接把结论摆出来,推导过程放在后面。
设位数组长度为m(bit数),预期插入元素数量为n,哈希函数数量为k。在最优条件下,误判率近似为:
[
p \approx \left(1 - e^{-kn/m}\right)^k
]
给定m和n,最优的哈希函数数量k为:
[
k = \frac{m}{n} \ln 2 \approx 0.7 \frac{m}{n}
]
也就是说,每个元素大约需要0.7个bit的位数组空间,配1个哈希函数时误判率最优。如果每个元素分配10个bit,最优k大约是7,此时理论误判率在1%左右。
来看一个具体例子。假设要处理1亿个URL,要求误判率不超过1%。查表或者套公式:
- 令k = 7,n = 10^8
- 根据 ( m = - \frac{n \ln p}{(\ln 2)^2} ) 或直接用 ( m = \frac{n k}{\ln 2} ),得到 ( m \approx 10^9 ),也就是约125MB
相比用unordered_set存储1亿个URL(按平均64字节算至少6.4GB),布隆过滤器的空间优势是两个数量级的。
这里我强烈建议任何要实际用布隆过滤器的人,在写代码之前先把数据量n和可接受误判率p估算清楚,然后再反推m和k。不要随手拍脑袋定一个数组大小,后面数据量一涨,误判率会以指数级别恶化。
4.3 完整的C++ BloomFilter实现
直接给出一个经过实战验证的C++实现。这个实现的重点在于:用双重哈希生成k个哈希值,避免计算k次完整哈希的开销。
cpp复制#include <cstdint>
#include <functional>
#include <string>
#include <vector>
class BloomFilter {
public:
BloomFilter(size_t bit_size, size_t hash_count)
: bit_size_(bit_size),
hash_count_(hash_count),
words_((bit_size + 63) / 64, 0) {}
// 生成hash_count_个互相独立的下标
std::vector<size_t> get_positions(const std::string& key) const {
uint64_t h1 = fnv1a_hash(key);
uint64_t h2 = murmur3_hash(key);
std::vector<size_t> positions;
positions.reserve(hash_count_);
for (size_t i = 0; i < hash_count_; ++i) {
// 使用双重哈希组合生成第i个位置
uint64_t combined = h1 + i * h2;
positions.push_back(combined % bit_size_);
}
return positions;
}
void add(const std::string& key) {
for (size_t pos : get_positions(key)) {
words_[pos / 64] |= (uint64_t{1} << (pos % 64));
}
}
bool contains(const std::string& key) const {
for (size_t pos : get_positions(key)) {
if (((words_[pos / 64] >> (pos % 64)) & 1) == 0) {
return false;
}
}
return true;
}
private:
uint64_t fnv1a_hash(const std::string& key) const {
uint64_t hash = 1469598103934665603ULL;
for (char c : key) {
hash ^= static_cast<uint8_t>(c);
hash *= 1099511628211ULL;
}
return hash;
}
uint64_t murmur3_hash(const std::string& key) const {
uint64_t h = 0x9e3779b97f4a7c15ULL;
for (char c : key) {
h ^= static_cast<uint8_t>(c);
h *= 0x100000001b3ULL;
}
return h;
}
size_t bit_size_;
size_t hash_count_;
std::vector<uint64_t> words_;
};
这里有一个实现细节值得解释:为什么用h1 + i * h2来生成第i个位置,而不是真的调k次哈希函数?
哈希计算是有成本的,尤其是对长字符串,每多算一次哈希就多一次完整扫描。用双重哈希组合的方式,先算两个基础哈希值h1和h2,然后用线性组合生成k个位置。这种方式在数学上能够得到足够分散的哈希序列,同时把计算成本从O(k * len)降到了O(2 * len)。
fnv1a_hash和murmur3_hash是两个结构差异很大的哈希函数,组合起来能有效避免两个哈希同时冲突的情况。如果你只是随便找两个哈希函数拼凑,可能组合后整体的分布质量还不如一个高质量的哈希函数,这里务必选用经过验证的算法。
4.4 计数布隆过滤器:让删除成为可能
前面说了普通布隆过滤器无法删除元素。但在很多场景下,比如缓存系统,元素是有生命周期需要删除的。解决办法是把每个bit扩展成一个小计数器,删除时递减对应位置的计数器,只有计数器归零时才真正清空bit。
cpp复制class CountingBloomFilter {
public:
CountingBloomFilter(size_t bit_size, size_t hash_count, size_t counter_bits = 4)
: bit_size_(bit_size),
hash_count_(hash_count),
counters_((bit_size + counter_bits - 1) / counter_bits, 0) {}
void add(const std::string& key) {
for (size_t pos : get_positions(key)) {
counters_[pos] = std::min(counters_[pos] + 1, 15u); // 4bit计数器最大15
}
}
void remove(const std::string& key) {
for (size_t pos : get_positions(key)) {
if (counters_[pos] > 0) {
counters_[pos]--;
}
}
}
bool contains(const std::string& key) const {
for (size_t pos : get_positions(key)) {
if (counters_[pos] == 0) {
return false;
}
}
return true;
}
private:
size_t bit_size_;
size_t hash_count_;
std::vector<uint8_t> counters_;
};
但这种方案有一个很微妙的问题:计数器溢出。如果某个位置被插入的次数超过了计数器的最大值,计数会回绕到0,导致原本存在的元素被误判为不存在。这是假阴性,和普通布隆过滤器只能有假阳性的特性正好相反,一旦出现非常致命。所以用计数布隆过滤器时,必须根据插入次数精确设计计数器位宽,并在极端情况下做溢出保护。
5. 实战选型:位图、布隆过滤器还是其他方案
5.1 三个方案的适用边界对比
| 场景维度 | 位图 | 布隆过滤器 | 哈希表 |
|---|---|---|---|
| 数据类型 | 非负整数 | 任意可哈希类型 | 任意可哈希类型 |
| 内存特征 | 随值域范围 | 预先固定,随n和p决定 | 随实际元素数增长 |
| 误判 | 无 | 有假阳性无假阴性 | 无 |
| 删除 | 不推荐 | 普通版不支持,计数版支持 | 支持 |
| 枚举元素 | 支持(扫描bit) | 不支持 | 支持 |
| 典型场景 | 整数判重、排序 | 字符串去重、缓存穿透防护 | 通用存储 |
5.2 布隆过滤器在分布式系统和缓存场景的经典用法
布隆过滤器在分布式系统里最常见的定位是“前置过滤器”。数据库查询时,如果某个key在布隆过滤器中判断为不存在,就直接返回空,不再查询底层存储。这样可以把无效查询挡在最前面,大幅降低数据库压力。缓存穿透的防护思路是一样的:缓存里没有,数据库里也没有的数据,如果每次都打到数据库,数据库就扛不住了;而布隆过滤器可以快速回答“这个key肯定不存在”,从源头拦截掉大部分穿透请求。
另一个经典场景是内容平台做“已读/已推荐”过滤。用户刷信息流时,为了不重复推荐已经看过的内容,需要记录大量内容ID。一个内容ID如果用一个bit记录,10亿条内容就需要125MB。配合布隆过滤器的概率特性,可以在内存占用极小的情况下完成过滤,偶尔漏掉一条已经看过的内容推荐出来,用户划走就是,影响微乎其微。
5.3 参数估算模板:拿到需求先算这三件事
我每次在项目里引入布隆过滤器,都会先完成三件事,有人觉得啰嗦,但我从不跳步:
- 确认n——这个系统在可预见的未来最多会有多少元素
- 确认p——业务能容忍的误判率上限是多少
- 算m和k——套公式算出位数组大小和哈希函数个数
给你一组已经算好的常用参数,直接抄:
| 预期元素数n | 可接受误判率p | 位数组大小m | 哈希函数数k |
|---|---|---|---|
| 100万 | 1% | 约9.6MB | 6-7 |
| 1000万 | 1% | 约96MB | 6-7 |
| 1亿 | 0.1% | 约192MB | 9-10 |
| 1亿 | 1% | 约144MB | 6-7 |
这里有个特别容易踩的坑:m定好了,但如果数据量超过了预期,误判率会迅速恶化。比如m/n从10降到5,误判率可能从1%飙升到10%以上。所以给服务预留容量时,建议按峰值预估量的1.5到2倍来定m,防止业务增长后被动重构。
5.4 我的踩坑记录:从误判率失控到哈希函数退化
最后分享几个真实项目里踩过的坑,都是文档里不会写的。
第一个坑是哈希函数的“退化”。我曾经图省事,直接用std::hash<string>当基础哈希。在libstdc++的某些版本里,std::hash<string>底层是MurmurHash的某种变体,单独用它做单哈希其实还行。但当我用双重哈希组合时,第二个哈希我随手写了个简单多项式,结果两个哈希在短字符串上高度相关,组合出的k个位置分布极不均匀,误判率比理论值高出好几倍。排查了很久才发现是第二个哈希函数太弱。后来改成fnv1a和murmur的组合,分布质量才恢复正常。
第二个坑是关于m和n的估算。早期做黑名单过滤时,我低估了要拦截的URL数量,按照每月100万条来分配位数组。结果两个月后数据量涨到500万条,误判率从1%飙升到接近10%,大量正常URL被“误杀”,直接导致线上投诉暴增。最后紧急扩容位数组并重建过滤器才解决。这个教训让我养成了一个习惯:凡是设计布隆过滤器,一定要在代码注释里把m、n、k的估算过程和假设前提写清楚,否则半年后你自己都会忘记当初为什么选这套参数。
第三个坑比较隐蔽,和C++语言本身有关。vector<uint64_t>在64位系统上天然对齐,但如果用vector<bool>的位压缩实现来做布隆过滤器,性能会折损得很厉害——因为标准库实现为了压缩bit,把每个bit的读写都变成了复杂的位运算序列。更麻烦的是vector<bool>的reference类型不支持取地址,很多泛型算法没法直接用。所以我后来的所有位图、布隆过滤器实现都统一用vector<uint64_t>,宁可自己管理位运算,也不去碰vector<bool>这个坑。
这些坑看起来很细,但每一个都让我付出了真实的时间代价。数据结构这个东西,光懂原理是不够的,工程落地时的细节才是决定方案能不能长期稳定运行的关键。你在自己的项目里用到布隆过滤器时,哪怕只避开其中一个坑,我这篇文章就没白写。
