位图索引做到一定规模,就绕不开一个动作:给一个 bit 位置,问它前面(含自己)有多少个 1。这个操作在文献里叫 rank,落到 C++ 代码里,我绝大多数时候用的是sdsl::rank_support_v<1>。它来自 sdsl-lite 这套 succinct data structure 工具库,做的事情很朴素——把一个静态的sdsl::bit_vector包一层,让你用 O(1) 的时间回答任意位置的 rank 查询,代价是要额外拿出一部分内存存辅助表。这篇文章我不讲教科书推导,只讲我在位图索引、序列检索、波形树这几类活里怎么用它:怎么编译、怎么构造、rank()的闭区间语义怎么算、模板参数<1>到底在调什么、序列化和生命周期上有哪些能把人坑到半夜的细节。适合已经能写基本 C++、手里有百万级以上位向量、正在纠结要不要上辅助索引结构的人看;完全没接触过 succinct 结构的读者也能顺着看下来,因为我会把 rank 这件事本身先拆开讲一遍。
1. rank 这件事本身:为什么值得单独搞一个模板类
1.1 从"数一数有几个 1"说起
rank(i)的定义是:位向量下标[0, i]这个闭区间里,值为 1 的位一共有多少个。注意是闭区间,i本身是算进去的,这一点跟很多人的第一直觉(以为是开区间)不一样,我在 review 别人代码时至少见过五次因为这个差一位导致的 off-by-one bug。
这个操作听起来没什么技术含量,但它在压缩数据结构里是地基级别的存在。举个具体例子:你用位向量表示一批文档 ID 是否命中某个查询词,位向量第 1000 位是 1,那"第 1000 号文档"和"命中的第几个文档"是两个完全不同的概念。前者叫位置,后者叫秩。查询结果要按顺序取前 20 条,你需要的是"第 20 个命中在哪",而中间那步换算,往往就是 rank 加 select 组合出来的。再比如波形树(wavelet tree)里,从根节点往叶子走,每一步都要靠 rank 算出左右子树的偏移量,一次access或者rank查询里可能连续调好几次。
所以 rank 不是"顺便实现一下"的辅助函数,它是整个结构的时间复杂度里最关键的一环。你把它写成扫一遍数组,上层所有漂亮的 O(log σ) 就会退化成一坨。
1.2 逐位扫和全量前缀表之间的死结
最笨的实现是每次查询从头数一遍,复杂度 O(n),百万位的向量上单次查询就要几毫秒,整个索引基本报废。稍微聪明一点的做法是预处理一个前缀数组pre[i] = rank(i),查询直接 O(1) 查表。但代价是每个位置都要存一个整数,1 亿位的位向量就要 4 亿字节起步,辅助结构比原始数据大三十多倍,这跟"压缩"两个字完全背道而驰。
真正的解法是把两者折中:把位向量切成固定大小的块,只在块的边界上存累计值,块内部临时用硬件指令现算。x86 和 ARM 上都有单指令算 64 位整数里 1 的个数的能力,编译器内置函数是__builtin_popcountll,一般在 -O2 以上会直接编译成一条指令。这样一次 rank 查询就是"读一个块的累计值 + 对块内最多几个 64 位字做 popcount 再相加",是一个与向量总长度无关的常数时间操作。
sdsl::rank_support_v<1>走的就是这条路,只不过它做了两级:粗粒度一级定住大区间,细粒度一级把块内偏移压缩进很小的空间。丢掉的精度换回来了内存,硬件指令又把精度补了回来,这就是整个 succinct 结构的核心套路。
1.3 sdsl-lite 里 rank_support 家族的分工
这套库里跟 rank 相关的类不止一个,名字长得还挺像,容易选错:
| 类型 | 辅助空间(经验值) | 查询复杂度 | 我用它的场合 |
|---|---|---|---|
rank_support_v<1> | 约为位向量大小的 25% | O(1) | 默认选择,查询量大时首选 |
rank_support_v<k>(k 增大) | 随 k 增大而下降 | O(1),常数略大 | 内存吃紧、查询频次中等 |
rank_support_v5 | 约 5% | O(1),多级查表 | 超长位向量、冷数据、内存受限环境 |
| 自己写前缀数组 | 与粒度强相关 | O(1) 或 O(块长) | 语义特殊、需要和其它结构共用内存 |
表里的 25% 和 5% 是官方文档给出的量级,不是精确公式。我这几年在不同机器上跑size_in_bytes(),得到的数字在这个量级上下浮动,跟位向量长度、版本实现都有关系。所以我一般不建议背数字,直接在目标数据上打印一次实测值,比什么估算都靠谱。
真正需要我做选择的分水岭只有一条:这个位向量的 rank 查询是不是在热路径上。是在热路径上,25% 的空间完全不心疼,一次查询省下的几纳秒乘以每秒千万次调用,就是几百毫秒的差距;是冷数据或者离线批处理,v5 甚至自己写个稀疏采样都够用。
2. 拆开 rank_support_v<1>:模板参数到底在调什么
2.1 结构长什么样:超级块加块内前缀
sdsl::rank_support_v<1>是模板类rank_support_v的一个特化实例,模板参数是个uint8_t类型的 k,默认值就是 1。这意味着你写sdsl::rank_support_v<>和写sdsl::rank_support_v<1>是同一个东西,前者只是省了几个字符。我在代码里通常还是显式写<1>,因为读代码的人一眼就能看出这里用的是哪一档,不需要再去查默认值。
内部结构可以想象成一座两层楼:上层是超级块的累计值,每隔一段距离存一个 64 位整数,记录"从向量开头到这个超级块起点,一共有多少个 1";下层是块内更细的前缀信息,让查询不用在块内从头数。查询时先定位到所属超级块,拿到基准值,然后块内把跨越的完整 64 位字用 popcount 累加,最后一个不完整的字用掩码掐掉尾部再数。
这套结构有个硬性前提:位向量必须是静态的。所有预处理值在构造那一刻定死,之后位向量里的任何一个 bit 被改动,辅助表就和真实数据对不上了,而库不会替你检查这件事。这一点后面我会专门开一节讲,因为它是这个类最容易出事的地方。
2.2 k 值怎么影响空间和局部性
模板参数 k 控制的是抽样粒度:k 越大,超级块覆盖的位数越多(也就是把更多的基本块合并到一个采样点上),辅助数组越短,整体空间占用越低。反过来说,块内要扫的位数变长,单次查询的常数变大。sdsl 的这一族都是 O(1) 复杂度,区别只在常系数和空间占比。
所以我选 k 的思路很直接:默认 1,除非内存真的紧张。我做过一个粗略的对比,在 1 亿位、密度 10% 的数据上,rank_support_v<1>和rank_support_v<4>的查询耗时差距在百分之十几到二十这个区间,而空间差距是实打实的。如果你的索引节点数量有几百万个,每个节点都挂一个位向量,那省下来的空间能直接决定整个索引能不能塞进一台机器的内存,这时候选大 k 值就非常值。
提示:不同版本的库对块长的定义细节可能有出入,头文件里的注释是最权威的。想搞清楚自己的版本具体怎么切块,直接翻
rank_support_v的源码,比看任何二手资料都快。
2.3 什么时候该换 v5 或者别的方案
我的判断标准比较粗暴,分三档:
第一档,位向量规模在千万位以内,查询频繁,直接rank_support_v<1>,别想太多。
第二档,位向量规模上亿,节点数多,总内存吃紧,先看rank_support_v5。它的辅助空间大概只有 5% 这个量级,查询是常数时间里的多级查找,比 v 家族慢一些但仍然是 O(1)。
第三档,查询本身就不密集,一天跑一次的离线统计,或者 rank 只是用来做一次性的位置换算,那就干脆别建辅助结构,构造的时候顺手把前缀和算出来存成std::vector<uint64_t>都行,代码还更好懂。
还有一类特殊情况:你需要的是"第 k 个 1 的位置"而不是"前 i 位有几个 1",那就是 select 的活,对应select_support_mcl之类的类。实际项目里这两个经常一起出现,rank 用来做范围计数,select 用来做定点取回,配合起来才是完整的位图索引。
3. 从零跑通第一个示例
3.1 编译环境与链接参数
先把环境搞定,不然下面所有代码都白搭。我习惯从源码编译这套库,装到本地目录,头文件和库文件都在同一个前缀下,后面写编译命令时路径清楚。
# 假设库已经编译安装到 /opt/local/sdsl 下 # 目录结构大致是 /opt/local/sdsl/include/sdsl、/opt/local/sdsl/lib g++ -std=c++17 -O3 -march=native \ -I/opt/local/sdsl/include \ demo.cpp -o demo \ -L/opt/local/sdsl/lib \ -lsdsl -ldivsufsort -ldivsufsort64-ldivsufsort和-ldivsufsort64这两个后缀库是构造后缀数组相关结构时用的,即使你这次只用 rank,链接阶段也可能因为静态库里有符号依赖而报未定义引用。我第一次编译时就卡在这里,只写了-lsdsl,报了一堆divsufsort相关的错,折腾了半小时才反应过来。
用 CMake 的话更省事,直接指定头文件目录和库目录即可:
cmake_minimum_required(VERSION 3.16) project(rank_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_FLAGS_RELEASE "-O3 -march=native") set(SDSL_ROOT "/opt/local/sdsl") add_executable(demo demo.cpp) target_include_directories(demo PRIVATE ${SDSL_ROOT}/include) target_link_directories(demo PRIVATE ${SDSL_ROOT}/lib) target_link_libraries(demo PRIVATE sdsl divsufsort divsufsort64)-march=native这个参数值得单独说一句:它让编译器按本机 CPU 生成指令,popcount 这类操作能直接对上硬件指令,性能差距在 rank 这种密集小操作上很明显。代价是编译产物不能在别的机器上跑,做 benchmark 时无所谓,发布给别人的二进制就别开了。
3.2 最小示例:构造、绑定与闭区间语义
下面这段是我给新人看的第一段代码,短到不能再短,但把核心语义全包含了:
#include <sdsl/bit_vectors.hpp> #include <iostream> int main() { // 100 位的位向量,初始全 0 sdsl::bit_vector bv(100, 0); bv[3] = 1; bv[7] = 1; bv[64] = 1; // 绑定位向量,构造辅助结构 sdsl::rank_support_v<1> rank_bv(&bv); std::cout << "rank(2) = " << rank_bv.rank(2) << "\n"; // 0,[0,2] 里没有 1 std::cout << "rank(3) = " << rank_bv.rank(3) << "\n"; // 1,刚好吃到下标 3 std::cout << "rank(7) = " << rank_bv.rank(7) << "\n"; // 2 std::cout << "rank(64) = " << rank_bv.rank(64) << "\n"; // 3 std::cout << "rank(99) = " << rank_bv.rank(99) << "\n"; // 3 return 0; }这里有几个细节值得抠:
第一,构造函数接收的是const sdsl::bit_vector*,也就是一个指针,不是引用,也不是拷贝。这意味着bv必须比rank_bv活得久。如果你把bv写在某个作用域里,然后把这个 rank 对象传出去,后面再调用rank(),读到的就是已经释放的内存。这类 bug 在小数据上往往还"能跑",一到线上数据量变了就随机崩,极难定位。
第二,rank(3)返回 1 而不是 0,这就验证了闭区间语义。我建议所有人在刚接手这套 API 时都跑一遍这个最小例子,把边界值打出来看,比读文档快。
第三,注意rank(99)是合法的,因为99 < bv.size()。但rank(100)就是越界了,库内部一般不会替你检查(release 版本没有断言),行为不可预期。要统计整个向量的 1 的个数,用rank_bv.rank(bv.size() - 1),或者干脆在位向量上做一次全量计数。
3.3 必须记住的 API 清单与成员含义
用过几次之后,常用的接口就那几个,我列成表方便查阅:
| 接口 | 语义 | 我踩过的坑 |
|---|---|---|
rank_support_v<1> rs(&bv) | 构造并建辅助结构 | 存的是指针,bv 生命周期必须更长 |
rs.init(&bv) | 在已存在的对象上重建 | 重建会重新分配,原有序列化数据作废 |
rs.set_vector(&bv) | 只替换绑定的指针 | 不重建表,换到内容不同的向量上必错 |
rs.rank(i) | 返回[0, i]内 1 的个数 | 闭区间,越界不检查 |
rs.size() | 绑定位向量的位数 | 不是辅助结构占的字节数,容易混 |
sdsl::size_in_bytes(rs) | 序列化后的字节数 | 用它来量辅助结构开销最准 |
rs.serialize(out) | 写出到流 | 流必须用std::ios::binary打开 |
rs.load(in, &bv) | 从流读回 | 第二个参数必须指向已经加载好的位向量 |
关于set_vector和init的区别,我单独强调一遍:init是"重新算一遍表",set_vector是"把指针换个地方指"。后者在 sdsl 的 support 类里是有意设计的,方便你复用同一块内存做多个对象,但它不会重算任何东西。如果你拿它去绑一个内容完全不同的位向量,程序不会崩,只会给你一堆看起来正常但全是错的数字,这种错误最难查。
还有个小的实践建议:辅助结构的空间别用sizeof去估,那只能量到对象本身那几个成员(主要就是一个int_vector的脑袋),量不到堆上真正占用的部分。用sdsl::size_in_bytes(rs),它会通过序列化统计出实际字节数,误差可以忽略。
4. 放进真实场景:位图索引与波形树
4.1 场景一:稀疏文档集上的排名定位
我做过的一个日志检索场景,文档总量在几亿这个量级,某个词命中的文档是多寡不一的,用一个位向量记录"哪些文档命中"。查询要回答两类问题:某个 ID 之前的命中数是多少(用来做分页的 offset 定位),以及第 k 个命中是哪个文档。
#include <sdsl/bit_vectors.hpp> #include <sdsl/select_support_mcl.hpp> #include <algorithm> #include <iostream> #include <vector> int main() { // 模拟:文档 ID 升序,部分命中 std::vector<uint64_t> hits = {5, 17, 42, 1000, 2001, 999999}; uint64_t max_id = hits.back(); sdsl::bit_vector bv(max_id + 1, 0); for (uint64_t id : hits) { bv[id] = 1; } sdsl::rank_support_v<1> rank_bv(&bv); sdsl::select_support_mcl<> sel_bv(&bv); // 问:ID 1000 之前(含)命中了几条? std::cout << rank_bv.rank(1000) << "\n"; // 4 // 问:第 3 个命中(0-based)对应哪个文档? std::cout << sel_bv.select(3) << "\n"; // 1000 // 问:ID 区间 [100, 2000] 里命中了几条?用两次 rank 相减 uint64_t lo = 100, hi = 2000; uint64_t cnt = rank_bv.rank(hi) - (lo == 0 ? 0 : rank_bv.rank(lo - 1)); std::cout << cnt << "\n"; // 1 return 0; }区间计数那行是重点,rank(hi) - rank(lo - 1),注意lo == 0时要单独处理,否则lo - 1是无符号整数回绕成一个天文数字,rank()收到这个越界下标,行为不可预期。这是我自己写过、也在别人的代码里见过不止一次的低级错误,加一行判断就能规避。
这个模式的效率相当可观。位向量大小是最大文档 ID,就算 ID 稀疏到只有万分之一的命中率,几十兆的位向量配合 25% 的辅助空间,总内存也就几十兆,一次区间计数是常数时间。如果要查"某个时间窗口内命中了多少条",那就是多个词的位向量做交集再 rank,也就是经典的位图检索思路,比逐条扫描倒排列表快得多。
4.2 场景二:波形树内部是怎么用它的
sdsl::rank_support_v<1>在日常代码里更常见的出现方式其实是被别人封装好——波形树内部就大量使用 rank。你构造一棵wt_huff,它内部每一层的节点都是一个位向量,每个节点上都挂着 rank 结构。你调用wt.rank(i, c)的时候,实际执行的是沿着字符的编码路径逐层做 rank 换算。
#include <sdsl/wavelet_trees.hpp> #include <iostream> int main() { sdsl::wt_huff<> wt; sdsl::construct_im(wt, "abracadabra", 1); // [0, 10] 内 'a' 出现了几次 std::cout << wt.rank(10, 'a') << "\n"; // 5 // 第 2 个 'a'(0-based)的位置 std::cout << wt.select(2, 'a') << "\n"; // 5 // 字符集大小、序列长度 std::cout << wt.size() << "\n"; // 11 std::cout << wt.sigma << "\n"; // 5 return 0; }这段代码我拿它当"冒烟测试"用,任何时候升级库版本,先跑一遍这个小例子,输出对了再动业务代码。因为波形树这类结构内部依赖链很长,一层错了,最后结果可能只是"看起来稍微有点不对",不做小例子很难察觉。
需要注意的是,wt.rank(i, c)里的i同样是闭区间,和rank_support_v保持一致。这种一致性其实是好事,整个库的语义统一,学一次就够。但要注意construct_im的第三个参数是每个字符占几个字节,纯 ASCII 传 1,处理 UTF-8 或者宽字符序列时得按实际字节数传,传错了不会报错,只是构造出一个看起来正常但没法用的树。
4.3 持久化:序列化、加载与内存复用的正确顺序
位向量和辅助结构建好之后,通常是要落盘的,不然每次启动重建一遍,几亿位的数据光构造就可能要几秒。序列化的顺序有讲究,必须"先位向量,后 rank 结构",加载的时候顺序反过来是错的。
#include <sdsl/bit_vectors.hpp> #include <fstream> #include <iostream> int main() { sdsl::bit_vector bv(1000, 0); for (size_t i = 0; i < 1000; i += 7) bv[i] = 1; sdsl::rank_support_v<1> rs(&bv); // 写:先位向量,再辅助结构 { std::ofstream out_bv("bv.sdsl", std::ios::binary); bv.serialize(out_bv); } { std::ofstream out_rs("rank.sdsl", std::ios::binary); rs.serialize(out_rs); } // 读:先加载位向量,再把指针交给 rank 结构 sdsl::bit_vector bv2; { std::ifstream in_bv("bv.sdsl", std::ios::binary); bv2.load(in_bv); } sdsl::rank_support_v<1> rs2; { std::ifstream in_rs("rank.sdsl", std::ios::binary); rs2.load(in_rs, &bv2); // 指针必须指向已加载完成的 bv2 } std::cout << rs2.rank(999) << "\n"; return 0; }两个坑点:一是文件流必须用二进制模式打开,文本模式在 Windows 上会把换行符做转换,读回来的数据全是乱的;二是load的第二个参数必须指向一个"已经加载完成的"位向量对象。如果你先读 rank 再读位向量,或者传了个空指针进去,得到的对象内部指针是悬空的,之后任意一次rank()都是未定义行为。
这套序列化还有一个衍生玩法:把位向量和辅助结构分别写进文件,再用内存映射(mmap)的方式加载。sdsl 里有int_vector_mapper之类的工具类支持直接映射,加载时间几乎为零,多个进程还能共享同一份物理内存。做检索服务时这个特性非常实用,启动速度和内存占用都能压下来一大截。
5. 踩过的坑:几个高频故障与排查路径
5.1 位向量生命周期与悬空指针
这是头号杀手,我几乎每次带新人都要讲一遍。rank_support_v<1>的构造函数签名决定了它只存指针,不拷贝数据,也不延长位向量的生命周期。
// 错误示范 sdsl::rank_support_v<1> make_rank() { sdsl::bit_vector bv(1000, 0); bv[10] = 1; return sdsl::rank_support_v<1>(&bv); // bv 出了作用域就没了 } int main() { auto rs = make_rank(); std::cout << rs.rank(500) << "\n"; // 悬空指针,行为不可预期 }函数返回之后,bv的析构函数已经把数据还给了堆,rs里那个指针指向的是别人的内存。这种代码在数据量小的时候经常"看起来能跑",因为那块内存还没被覆盖,等到程序负载一上来,内存被别的东西占用,结果就开始飘。
正确的做法是把位向量和 rank 结构放在同一个生命周期里管理。我一般把它们包成一个小类,成员顺序是位向量在前,rank 结构在后,析构顺序天然正确(C++ 按声明逆序析构,rank 先析构,它不会去碰位向量,所以安全)。
5.2 索引越界与闭区间误算
第二个高频错误是边界。有三处容易出错:
一是把闭区间当开区间用,rank(i)和rank(i-1)差了整整一个 bit 的贡献。写区间计数的时候一定要想清楚两端的开闭。
二是i == 0时的减法。rank(lo - 1)在lo == 0时会因为无符号回绕变成一个极大的数,传入越界下标。这个我前面提过一次,这里再强调一遍,因为它真的很容易在半夜被人漏掉。
三是i == bv.size()。有的人习惯把size()当成"末尾之后的位置",用rank(bv.size())来求总量。在这套 API 里这是越界的,正确写法是rank(bv.size() - 1)。空向量(size() == 0)还要单独判断,size() - 1会回绕成一个巨大值。
调试这类问题的技巧:写个小的断言工具,把边界值都跑一遍,用朴素实现(直接循环数)做对照。几行代码的对照测试,能挡掉大部分语义错误。
5.3 改了位向量忘了重新构造
这个坑最隐蔽,因为程序不崩,只是结果错。辅助表是在构造那一刻根据位向量内容算出来的,之后你把bv[i] = 1改一下,rank()的结果就跟真实数据脱节了。
我在一个增量更新的场景里就中过招:来了新文档就把对应位置置 1,然后继续用老的 rank 结构查分页 offset,前端展示出来的条数总是比实际少一两条。查了半天以为是分页逻辑写错,最后发现是位向量改了没重建。修起来很简单,改完位向量之后重新init(&bv)一次;但重建是 O(n) 的,如果更新非常频繁,就得考虑批量重建,或者换成支持动态更新的结构——sdsl 里也有动态位向量,只是空间代价高不少。
我的经验是:只要类里有init这个方法,就说明数据是可能被换掉的,代码里每次改数据的地方都要在注释里明确写"改完必须 init",让后来人一眼看到。
5.4 性能不达预期时的排查顺序
有时候代码跑通了,但 benchmark 一比,比预期慢一个数量级。我现在的排查顺序基本固定:
第一步,确认编译优化开了没有。忘了写-O2或者-O3,popcount 会变成软件循环,慢几十倍很正常。这一步我吃过亏,一个下午都在怀疑库有问题。
第二步,确认查询用的下标是不是随机分布的。顺序访问和随机访问对缓存的影响巨大,如果基准测试里用顺序下标跑出来的数字很漂亮,切到真实数据上就崩,那问题出在数据局部性上,不在结构本身。可以试试把大位向量切成多个小块,各自建 rank,让热点块尽量待在缓存里。
第三步,确认没有在循环里反复构造 rank 对象。构造是 O(n) 的,放进内层循环就是灾难。
第四步,对比不同 k 值和 v5 的实测数据,选一个适合当前数据规模的档位。这一步花不了多少时间,写个小 benchmark 循环跑几组就出来了。
5.5 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 结果随机漂移,小数据正常大数据崩 | 位向量已析构,指针悬空 | 保证位向量生命周期覆盖 rank 对象 |
| 结果稳定但整体偏小/偏大 | 改了位向量未重建辅助表 | 改完立即init(&bv) |
| 区间计数差一 | 闭区间语义理解错误 | 用最小例子打印边界值核对 |
| 计数结果异常巨大 | lo - 1无符号回绕 | 对lo == 0单独分支 |
| 加载后崩溃或数据错乱 | 加载顺序反了,或流不是二进制模式 | 先加载位向量,流加std::ios::binary |
| 查询比预期慢几十倍 | 编译没开优化 | 加-O3 -march=native重编 |
| 内存占用远超预估 | 用了默认 k 且节点数极多 | 换大 k 值或rank_support_v5 |
| 多线程下结果偶尔错 | 共享了同一个 rank 对象且有人init | 每线程独立对象,或构造后只读 |
6. 实测数据以及几条调参经验
6.1 一组可复现的基准
想自己量一遍,下面这段代码可以直接拿去改,我用它对比过好几个版本:
#include <sdsl/bit_vectors.hpp> #include <chrono> #include <iostream> #include <random> #include <cstdint> template <class RankSupport> void bench(const char* tag, RankSupport& rs, size_t n, size_t step) { volatile uint64_t sink = 0; auto t0 = std::chrono::steady_clock::now(); size_t queries = 0; for (size_t i = 0; i < n; i += step) { sink ^= rs.rank(i); ++queries; } auto t1 = std::chrono::steady_clock::now(); double ns = std::chrono::duration<double, std::nano>(t1 - t0).count(); std::cout << tag << " 查询次数=" << queries << " 单次约=" << (ns / queries) << " ns\n"; (void)sink; } int main() { const size_t n = 1u << 27; // 约 1.34 亿位,原始数据约 16 MB sdsl::bit_vector bv(n, 0); std::mt19937 rng(20240101u); for (size_t i = 0; i < n; ++i) { bv[i] = (rng() % 10 == 0) ? 1 : 0; // 约 10% 密度 } sdsl::rank_support_v<1> r1(&bv); sdsl::rank_support_v<4> r4(&bv); std::cout << "位向量字节数: " << sdsl::size_in_bytes(bv) << "\n"; std::cout << "v<1> 字节数 : " << sdsl::size_in_bytes(r1) << "\n"; std::cout << "v<4> 字节数 : " << sdsl::size_in_bytes(r4) << "\n"; bench("v<1> 顺序步长1 ", r1, n, 1); bench("v<1> 跳步 997 ", r1, n, 997); bench("v<4> 顺序步长1 ", r4, n, 1); bench("v<4> 跳步 997 ", r4, n, 997); return 0; }volatile那个sink不能省。我就试过不写它,编译器把整个循环优化掉了,测出来零点几纳秒一次,看着像神迹,其实是空跑。加上volatile保证每次都真的执行,顺便把结果"用掉",避免被优化器判定为死代码。
size_in_bytes打印出来的辅助结构大小,除以位向量大小,就是我前面说的那个比例。我这边的实测结果在四分之一这个量级上下,和文档给的 25% 吻合得不错,但不同数据密度下会有小幅波动,密度特别极端(全 0 或者全 1)时结构里的累计值分布会变,统计出来的数字也略有不同。
6.2 空间换时间的临界点在哪
顺序访问和随机访问跑出来的数字能差好几倍,这不是 rank 结构的问题,是缓存的问题。顺序访问时,辅助表基本都在 CPU 缓存里,一次查询几纳秒;随机跳着查,每查一次可能都要去主存拉一次缓存行,时间线就上去了。
所以我在设计索引的时候会尽量把 rank 查询的访问模式往"局部"上靠。具体做法是分块:把一个巨大的位向量切成若干段,每段独立建一个 rank 结构,查询时先算段号,再在段内查。这样热点能集中在少数段上,缓存命中率明显提升。代价是边界处需要加上前面所有段的累计偏移,多一次数组读取,但整体收益通常是正的。
另一个经验是:不要为了省内存把 k 调得太大。我见过有人为了省几兆内存把 k 设到很夸张的值,结果查询常数上去了,整体查询吞吐反而下降。判断标准还是那句:跑一遍基准,用数字说话。内存省下来的部分如果换不来可感知的收益,就别动。
6.3 还能往哪些方向扩展
这套结构是只读的,一旦建好就只能查。如果你的场景里有大量增删,单靠它是不够的。常见的折中是把数据分片,每片一个静态结构,更新落到新分片上,后台做合并(类似日志结构合并的思路),这样查询时对每个分片各查一次再汇总,代价随分片数线性增长,但工程上很好实现。
另一个方向是把 rank 和 select 组合成更上层的结构,比如用来做位图与倒排列表之间的转换层、做去重后的集合运算、做范围计数加 Top-K 取回。这类组合在我手上的几个检索系统里都是核心模块,而底层那个把sdsl::bit_vector包一层的rank_support_v<1>,基本上从头到尾没换过。它足够快,内存开销可控,语义清晰,API 也稳定,一个类能同时满足这几条,在我用过的工具库里已经算很难得了。
我在实际使用中的体会是:真正容易出问题的从来不是 rank 的算法本身,而是生命周期、边界语义和"数据改了忘了重建"这三件事。把这三件事用断言和注释焊死,剩下的就是纯粹的工程问题。