多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++ std::bitset详解:位运算优化与图算法加速

C++ std::bitset详解:位运算优化与图算法加速 我第一次对std::bitset改观是在一次图算法的压测现场。N 到了 2000用 bool 矩阵做传递闭包三层循环直接卡到无法接受换成std::vectorstd::bitset2000之后耗时就掉到了肉眼可见的秒级。那一刻我才反应过来std::bitset不是什么冷门工具类它是一旦用对就能改变算法量级的东西。它本质上是一个编译期定长的位容器N 个二进制位连续存储支持整组、|、^、~、、运算自带count、any、none、all这些统计接口。它的价值不只是把 bool 数组省几字节而是把“一堆位”当成一个整体参与运算同时让编译器按机器字批量处理。这篇文章适合三类人刷算法题和参加竞赛的人搞底层系统或中间件的人以及任何需要状态压缩、位图集合、布隆过滤器雏形的人。我会把std::bitset和 bool 数组、std::vectorbool的区别讲清楚把接口按使用场景完整过一遍然后给出三个可以直接抄的实战套路最后集中讲性能和工程选型上的坑。所有性能结论和经验都来自我自己的实测和项目踩坑不是背文档。1. 先搞清楚bitset 到底解决了什么痛点1.1 从 bool 数组的尴尬说起很多人做状态压缩时第一反应是int mask因为 32 位的 int 可以表达 32 个布尔状态配合mask (1 i)、mask | (1 i)很方便。可一旦状态数量超过 64int 和uint64_t都不够用了最常见的做法就是开一个 bool 数组。但 bool 数组有几个问题。第一内存浪费bool在大多数平台上占 1 字节你只存 0/1 却付出 8 倍空间。第二想对整个集合做交集、并集、差集只能写循环逐个元素处理代码又长又容易错。第三如果你用std::vectorbool它虽然每个元素只占 1 位但它是标准库里的一个特殊特化operator[]返回的是代理对象而不是真正的bool很多通用代码里踩它都会出问题。std::bitsetN把这三件事一次性解决了按位存储、支持整组位运算、不用自己去管理代理语义。你只需要告诉编译器最大长度是 N剩下的内存排布、字块切分、位掩码计算全部由标准库完成。1.2 bitset 和其他“位容器”的定位差异从定位上看std::bitset最接近“定长的位图集合”和std::vectorbool、普通 bool 数组的差异非常大。我用过一个很简单的对比。维度bool 数组std::vectorboolstd::bitsetN长度动态动态编译期常量内存占用约 N 字节约 N/8 字节约 N/8 字节整体位运算需要手动循环不支持原生支持元素访问返回 bool返回代理对象返回代理对象取元素地址可以不行不行典型用途通用逻辑动态位图定长状态压缩这里最容易被忽略的是“整体位运算”。如果你用 bool 数组表示两个集合想求交集只能这样写for (int i 0; i n; i) { result[i] a[i] b[i]; }换成std::bitset一行result a b;就结束了。而且std::bitset底层通常按机器的字word存储一次 64 位数据的按位与在硬件上就是一条指令。你写的是一行代码编译器帮你生成的是一段极紧凑的字块循环这比用户手写逐位循环要快很多。1.3 100 万位到底多大内存账本很多人听到“100 万个位”会被吓到其实算一下就知道非常小。std::bitset1000000占用约 1000000 / 8 125000 字节也就是 125KB。如果是std::bitset10000000也就是 1000 万位占用约 1.25MB。相比之下一个bool flags[1000000]通常是 1MB如果状态数量变成 1000 万就是 10MB。差距在数据量大的时候非常明显。但这个内存账本里有个工程细节std::bitsetN的对象大小在编译期就确定了所以如果你在函数内部直接定义一个大的局部变量它占用的是栈空间。std::bitset1000000的 125KB 在大多数默认 8MB 栈上没问题但std::bitset10000000的 1.25MB 在 Windows 默认 1MB 栈上就可能直接爆栈。我的习惯是超过 1MB 的 bitset 一律用static修饰或者放到堆上比如std::make_uniquestd::bitsetN()。另外一个容易忽略的点是默认构造一个std::bitsetN会把所有位清零这个清零操作本身也需要按字块把内存写一遍。也就是说创建一个几百万位的 bitset 不是零开销的它会有一次 O(N/word_len) 的内存写入。在性能敏感循环里尽量不要反复构造大 bitset能复用就复用。2. 全接口梳理构造、查询、位运算与转换一次讲清2.1 构造与初始化std::bitsetN的构造方式有几种最常见的我直接写在一起#include bitset #include string std::bitset8 a; // 默认全 000000000 std::bitset8 b(0b10100101); // 从整数初始化需要 C14 的 0b 字面量 std::bitset8 c(170); // 效果同上10100101 std::bitset8 d(std::string(1010)); // 字符串初始化00001010 std::bitset8 e(std::string(11010110), 2, 4); // 从下标2取4个字符 010100000101这里最关键的是位序问题。字符串构造时字符串最左边的字符对应最高位d[3]才是 1d[1]也是 1最终打印出来是00001010。很多新手以为字符串第一位对应d[0]结果代码一跑就乱。而整数构造和下标访问则是低位在右b[0]是最低位。三个参数的那个构造函数很容易被忽略但处理子串很方便。它取出字符串中[pos, pos count)范围内的字符作为二进制位串来构造 bitset。如果取出的子串长度小于 N左边自动补 0如果大于 N只取最左边的 N 个字符。必须注意字符串里只能出现字符0和1否则会抛std::invalid_argumentpos超出字符串长度会抛std::out_of_range。C11 之后还支持直接传const char*字符串构造例如std::bitset8 f(1010);规则和std::string版本一样。这个语法在解析配置、处理二进制表示时很常用。2.2 单点位的读写与修改单个位的读取有两种方式operator[]和test(pos)。std::bitset8 bs; bs[0] 1; // 最低位置 1 bs.set(7); // 最高位置 1 bs.flip(0); // 最低位取反 bs.reset(7); // 最高位清零 bs.set(); // 全部置 1 bs.flip(); // 全部取反 bs.reset(); // 全部清零operator[]不检查越界性能更好test(pos)会检查越界越界时抛std::out_of_range。我写算法题时用[]写业务代码或解析外部输入时用test因为后者能在边界 bug 出现时立刻告诉你位置不对。这里有个很多人踩过的坑bs[0]返回的不是真正的bool而是一个代理对象。它可以隐式转换成bool用来读也可以被赋值但不能像普通 bool 引用一样到处传递。你写出bool ref bs[0];是编译不过的需要想清楚再设计接口。set(pos)、reset(pos)、flip(pos)都从 0 开始计数pos0是最低位posN-1是最高位。判断最高位有没有置 1应该用bs.test(N - 1)不要凭感觉用bs[0]。2.3 整体状态查询std::bitsetN有四个很实用的整体查询接口std::bitset8 bs(0b10100101); bool hasOne bs.any(); // 是否存在至少一个 1 bool noOne bs.none(); // 是否全部为 0 bool allOne bs.all(); // 是否全部为 1C11 起可用 size_t ones bs.count(); // 有多少个 1any()和none()正好相反!bs.any()等价于bs.none()。all()是一个整体判断如果已经知道 bitset 长度 N也可以写成bs.count() bs.size()但all()在实现上通常更高效因为它遇到 0 就能提前退出不需要数完整个数。count()是使用频率极高的接口它统计置 1 的位数。在主流编译器的标准库实现里它底层会利用 CPU 的 popcount 相关指令速度远快于自己写循环遍历每一位。统计一个 100 万位的 bitset 里有多少个 1用count()通常只需要几万次字块级操作而手写逐位统计要多跑 100 万次判断。2.4 位运算与移位std::bitset最值钱的就是支持整组位运算。std::bitset8 a(0b10100101); std::bitset8 b(0b11110000); auto c a b; // 10100000 auto d a | b; // 11110101 auto e a ^ b; // 01010101 auto f ~a; // 01011010 a b; // 按位与后写回 a | b; // 按位或后写回 a ^ b; // 按位异或后写回 a 2; // 左移 2 位低位补 0 a 1; // 右移 1 位高位补 0这些运算要求两边的 bitset 类型完全一致也就是模板参数 N 相同。N 不同编译期类型就不同不能直接参与运算。这一点看着显然但实际代码里踩到过不少次尤其是从某个函数返回std::bitset8另一个函数接收std::bitset16想直接比较或合并就会报错。位移操作要特别注意超出的位会直接丢弃移入的位置补 0。a 2之后原来高位的两个 1 如果被移出边界就没了不会循环回来。这和循环移位是两码事。如果要做循环移位得自己把移出的位先保存下来再接到低位。2.5 字符串和整数互转std::bitset和外部世界的沟通靠两个接口to_string()和to_ulong()/to_ullong()。std::bitset8 bs(0b00001010); std::string s bs.to_string(); // 00001010高位在前 unsigned long v bs.to_ulong(); // 10 unsigned long long vv bs.to_ullong(); // 10to_string()返回的字符串第一位对应最高位和构造时的方向一致。这个接口用来调试非常方便直接把所有位打出来看。要注意的是它每次调用都会创建一个新的std::string在循环或性能敏感路径里频繁调用会产生大量分配调试完最好去掉。to_ulong()和to_ullong()把位模式解释成无符号整数。如果 N 超出了目标整型的位数或者位模式对应的数值过大会抛std::overflow_error而不是默默截断。比如在 32 位平台上unsigned long只有 32 位一个std::bitset64即使只有高 40 位有 1to_ulong()也会抛异常。需要截断而不是报错时要先手动屏蔽高位再转换。2.6 接口速查表接口作用常见坑set(pos)把第 pos 位置 1pos 从 0 开始越界抛异常reset(pos)把第 pos 位清零同上flip(pos)把第 pos 位取反同上test(pos)读取第 pos 位越界抛异常operator[]读取/修改第 pos 位不检查越界count()统计 1 的个数返回 size_tany/none/all是否存在 1/是否全 0/是否全 1常用于集合判断to_string()转字符串高位在前有分配开销to_ulong/to_ullong转整数溢出抛异常,,^,~按位运算,移位移出位丢弃补 03. 三个能直接抄的实战套路3.1 图传递闭包把三层循环改成位运算有向图求传递闭包最经典的写法是 Floyd-Warshall 式三层循环复杂度 O(n^3)。如果 n2000直接是 80 亿次操作在普通机器上很难接受。但很多图问题里我们并不需要知道路径具体长什么样只需要知道“从 i 能不能到 j”这时候std::bitset可以整行整行地合并可达集合。做法是这样的用reach[i]表示从 i 出发能到达的所有点是一个std::bitsetNreach[i][j]1表示 i 能到 j。先初始化直接边再跑类似 Floyd 的传递闭包constexpr int MAXN 2000; std::vectorstd::bitsetMAXN reach(n); for (int i 0; i n; i) { reach[i].set(i); // 自己可达自己按需保留 } // 初始化直接边reach[u].set(v); for (int k 0; k n; k) { for (int i 0; i n; i) { if (reach[i].test(k)) { reach[i] | reach[k]; } } }理解这个算法的关键在reach[i] | reach[k]。当 i 能到 k 时那么 k 能到的所有点i 也一定都能到把reach[k]整一行合并到reach[i]上就完成了这层传递。这个合并操作对 bitset 来说是一次按字块的或运算不是逐元素循环。总复杂度大约是 O(n^3 / word_len)n2000 时只需要约 1.25 亿次 64 位字操作比 80 亿次 bool 判断快了一个量级不止。这里有一个性能小技巧if (reach[i].test(k))这个分支很重要它可以跳过大量不满足条件的 i、k 组合减少无意义的或运算。如果图比较稀疏这个分支能省下一大半时间。3.2 集合交并差用 bitset 当高性能位图集合当元素范围固定且不大时std::bitset是一个非常好的集合实现。比如系统里有 10000 个可能的标签 ID每个用户拥有其中一部分标签判断两个用户是否有共同标签用传统做法要遍历其中一个集合。用 bitset 就很直接constexpr int MAX_TAG 10000; std::bitsetMAX_TAG userA; std::bitsetMAX_TAG userB; bool hasCommon (userA userB).any(); // 判断是否有交集 bool bIsSubsetOfA (userA userB) userB; // 判断 B 是否是 A 的子集 userA | userB; // 并集结果写回 userA userA ~userB; // 差集结果写回 userA这个写法的好处是核心判断都变成 O(MAX_TAG / word_len) 的字块操作。MAX_TAG10000 时一次交集只需要约 157 个 64 位字的与运算和一次提前退出检查速度非常稳定不依赖集合里有多少元素。而如果用std::set或std::unordered_set求交集通常要遍历较小的集合集合越大越慢。但这里我要提醒一个反面如果元素取值范围极大但集合很稀疏bitset 就不合适。比如在 0 到 2^32 范围内存 100 个元素bitset 需要 512MB 内存完全不可行。这时候老老实实用哈希集合。bitset 适合的是“取值范围小、密度可能高、需要频繁做集合运算”的场景。如果需要在集合里取出任意一个元素bitset 的标准接口并不直接支持。你可以用any()判断有没有但想拿到具体下标就得循环。所以我的经验是集合判断、集合合并用 bitset真正要逐个输出元素时再考虑其他结构。3.3 Shift-And 位并行字符串匹配用std::bitset做字符串匹配是位并行算法的经典应用很多人可能没接触过。模式串长度 M 不大时可以用 M 个位记录“当前文本位置是否匹配模式串的前缀”。先对模式串里的每个字符建一个掩码constexpr int M 32; std::string pattern your_pattern_here; std::bitsetM masks[256]; for (int i 0; i M; i) { masks[(unsigned char)pattern[i]].set(i); }然后遍历文本维护一个状态 Dstd::bitsetM D; for (int i 0; i (int)text.size(); i) { D 1; D.set(0); D masks[(unsigned char)text[i]]; if (D.test(M - 1)) { // text 中以 i 结尾的位置形成了完整匹配 // 匹配起点是 i - M 1 } }原理可以这么理解D 的第 j 位为 1表示文本当前位置结束的长度为 j1 的后缀正好等于模式串的前 j1 个字符。每次读入新字符先让所有状态左移一位表示把之前已经匹配到的前缀加长一位同时把第 0 位置 1表示空字符串总是匹配的然后和当前字符的掩码做与只有那些下一个字符确实等于当前文本字符的匹配状态才保留下来。如果最高位变成 1说明整个模式串匹配成功了。这个算法的时间复杂度是 O(text_len * M / word_len)因为每次左移和与运算都是按字块处理的。M 只有几十时优势不明显但 M 到几百甚至几千时比朴素逐位遍历快很多。它还能比较容易地扩展支持通配符和模糊匹配属于那种“学会一次能复用好几年”的位运算套路。用std::bitset做这个算法的前提是 M 是编译期常量。M 如果是运行时变量无法直接构造对应长度的std::bitsetM。解决方案要么是给一个上限MAX_PATTERN_LEN要么换boost::dynamic_bitset或者干脆把 M 小于上限时的掩码限制在低 M 位。3.4 顺手的例子埃氏筛统计质数std::bitset另一个非常自然的使用场景是埃氏筛。筛法需要标记“合数”和“非合数”本质就是一个布尔数组而且筛完之后要统计到底有多少个质数。constexpr int MAXN 1000000; std::bitsetMAXN isPrime; isPrime.set(); isPrime[0] isPrime[1] false; for (int i 2; 1LL * i * i MAXN; i) { if (isPrime[i]) { for (long long j 1LL * i * i; j MAXN; j i) { isPrime[j] false; } } } size_t primeCount isPrime.count();这段代码里isPrime.set()先把所有位初始化为 1然后筛掉合数。最终count()直接给出质数个数不用再写一个循环统计。内存方面bool isPrime[1000000]要 1MBstd::bitset1000000只要 125KB而且在缓存友好性上也更好。如果要把所有质数打印出来那就需要遍历for (int i 2; i MAXN; i) { if (isPrime[i]) { // 输出 i } }这个遍历的成本和 bool 数组差不多因为operator[]对单个位的访问是 O(1)。但如果你需要的只是数量、交集、并集这些整体操作bitset 会比逐个遍历快很多。4. 性能边界与工程选型这些坑我基本都踩过4.1 位运算不是 O(1)但也不是 O(N) 逐位std::bitset的整体位运算确实快但它在复杂度上并非常数。a b、a | b、a k这些操作内部是按字块循环的时间复杂度大约是 O(N / word_len)。N 越大耗时也会线性增长只是常数非常小。这个特点在写代码时要时刻记住。比如一个std::bitset1000000每次|要做大约 15625 次 64 位字操作一百次就是 150 万次字操作看起来不多但如果在三重循环内部频繁做性能压力就会很快累积。它不是万能的银弹只是把原本逐位的开销压缩到了字块级。相比之下单个位的test(pos)、set(pos)是 O(1) 的因为它可以直接定位到对应字块再读写特定位。很多人在大 bitset 上遍历所有位时以为for (int i 0; i N; i) if (b.test(i))也可以接受但这就是 O(N) 的逐位访问在 N 很大时会明显慢于那些能按字块统计和合并的操作。能用整体运算完成的事尽量不要拆成单点处理。另外我建议性能对比一定开优化。std::bitset大量依赖编译器的内联和位运算优化Debug 模式下 STL 可能不会完全展开速度会比 Release 差非常多。我在 Debug 下测试一个大 bitset 的count()曾经比 Release 慢了几十倍但这不代表 bitset 本身慢只是没开优化。4.2 编译期长度的连锁反应std::bitsetN的 N 必须是编译期常量这意味着你不能根据用户输入直接写std::bitsetn。很多人第一次用 bitset 时都会在这里卡一下。如果你确实需要在运行时确定长度有几种替代方案使用boost::dynamic_bitset它支持运行时长度功能接近 bitset。自己封装std::vectorstd::uint64_t手动实现置位、清零、按位与。如果长度有明确上限就给一个足够大的编译期常量实际使用时只用低 K 位。这里要注意的是给上限的方案看起来很省事但所有整体运算仍然按最大长度执行。比如你只在std::bitset1000000里用了低 10 位但每次a | b还是要完整处理 1000000 位而不是 10 位。上限开得越大浪费越多。大 bitset 的模板实例化也有一点影响。不同 N 的std::bitset是不同的类型各自会实例化一套成员函数。如果一个程序里同时大量使用std::bitset64、std::bitset1000、std::bitset1000000编译时间和代码体积都会增加但因为大多是内联展开运行时性能反而是优势。另一个实际工程中的问题是栈空间我在 1.3 里说过大型局部 bitset 有爆栈风险超过 1MB 就用堆或 static。4.3 转换、比较和混合运算里的隐蔽坑to_string()是非常舒服的调试工具但也是有代价的。它每次都会创建一个完整字符串2000 位的 bitset 转一次就是 2000 字节的分配如果放在日志或循环里频繁调用程序会莫名其妙变慢。我的经验是调试时打一次确认没问题就删掉或者用条件编译包起来。to_ulong()/to_ullong()的溢出行为也容易让人困惑。它不是截断而是抛std::overflow_error。如果只是想取低 8 位可以先构造掩码std::bitset64 value ...; std::bitset64 mask(0xFF); unsigned long low8 (value mask).to_ulong();bitset 和整数之间没有隐式的混合位运算。a 0xFF这种写法是编译不过的因为0xFF会被当作整数而std::bitset没有定义和整数直接按位与的运算符。你得先把掩码包装成 bitsetstd::bitset64 mask(0xFF); auto result a mask;运算符优先级也是个容易翻车的地方。~的优先级比高但为了可读性我建议所有组合运算都加括号比如(a ~b).none()不要省。关于字符串方向我再强调一次构造时字符串左侧是最高位to_string()输出时左侧也是最高位。但op[]的下标 0 是最低位。这三者混在一起非常容易出错。我每次写完相关代码都会先用一个小 bitset 打印验证一下位序比如std::bitset8 b(0b00000001);然后确认b[0]为 1、b.to_string()为00000001。4.4 需要遍历所有置位位时bitset 不是好选择std::bitset没有标准接口去获取“第一个为 1 的位置”或“下一个为 1 的位置”这是它最不方便的地方。MSVC 的 STL 提供了一些非标准的_Find_first/_Find_next扩展但不可移植GCC/Clang 上并没有一套公开的等价接口。如果 N 较小比如不超过 64可以这样快速遍历所有置 1 的位std::bitset64 bs ...; unsigned long long v bs.to_ullong(); while (v) { int idx __builtin_ctzll(v); // GCC/Clang 返回最低位 1 的位置 // 使用 idx v v - 1; // 清除最低位的 1 }__builtin_ctzll在 GCC 和 Clang 上可用MSVC 对应的是_BitScanForward64。这个方法效率极高每次迭代都能直接拿到一个置位下标而不是从头扫描。但 N 很大时这个方案就行不通了因为to_ullong()会溢出。虽然可以先把 bitset 右移 i 位再转整数但每次右移都是 O(N/word_len)总体会退化成 O(N^2/word_len)非常不划算。标准库也没有开放底层字数组的访问接口。所以如果核心需求是“快速遍历所有置 1 位”我建议不要用std::bitset直接自己维护std::vectorstd::uint64_t然后对每个字用ctz查找。这样内存布局完全可控遍历也高效。这是我踩过最大的一个坑。之前为了图方便把所有状态都塞进std::bitset100000结果别的操作都很流畅一到“把所有 1 的下标取出来”就卡得不行。后来改成自研的 64 位字块位图问题立刻消失。选型时一定要提前判断你是更依赖整体运算还是更依赖逐个取元素。这两者对应完全不同的实现。4.5 我现在的选型标准经过这么多次实战我现在基本用一套标准来判断该不该用std::bitset。满足下面任意两条我会优先用std::bitset位长在编译期就能确定或者有一个可以接受的上限。主要操作是整体位运算比如交集、并集、差集、掩码、闭包合并。需要频繁统计 1 的个数count()比手写遍历快很多。不想引入 boost也不想自己维护底层字块。满足下面任意一条我会放弃std::bitset改用自研字块位图或boost::dynamic_bitset长度必须在运行时确定且上限很大。核心操作是快速遍历所有置 1 位。需要把底层字数组直接暴露给其他模块做序列化或零拷贝。位长极大比如上亿位且每次整体运算都成为热点。我个人目前的做法是快速原型和算法竞赛优先用std::bitset因为它写起来最快、最不容易错工程系统里如果需要动态长度或频繁枚举置位位再换成自研字块。这个转换成本其实不高因为你一旦理解了位图背后的按字块操作自定义实现也就是几十行的事。最重要的是先按需求做选型而不是等性能问题出现后再来骂 bitset 不好用。大部分时候用不好不是容器的问题是场景没选对。
返回列表