先声明:这是我在自己机器上对 bool-hybrid-array9.13.11 当版做的可复现实测手记,不是跨机构独立基准。下面每个字节、每个耗时都给了环境和口径,你应该在自己机器上复跑,而不是信我一面之词。
一个面试题,背后是一类真实需求
前阵子被问了一道很朴素的题:一款在线游戏全服 500 万玩家,每个玩家对每个成就要有一个布尔位——解锁了是True,没解锁是False。新成就一上线,开局几乎全是 False;接下来几个月,解锁率从 0 慢慢爬到 0.5%、5%,热门成就甚至过半。运营还会随时查"这个人解锁没"、后台要随时翻某几个位。
这个标记表怎么存?
我没有急着背答案,而是回去把几种常见存法在同一台机器上压了一遍。结论比我想的有意思:这个"开局极稀疏、随时间变稠密、还要随时动态翻转"的曲线,恰好是普通定长位图最尴尬、而自动混合存储最舒服的场景。
先看一张真机表(N = 5,000,000)
环境:CPython 3.12.11 / numpy 2.5.3 / bool-hybrid-array 9.13.11(版本号、依赖与许可证一律以当时 PyPI 元数据为准),CPU 为/proc/cpuinfo实测 model name「AMD EPYC 9Y24 96-Core Processor」(云实例定制 SKU,公开零售型号查不到同名,只当本环境标注看;4 核 / 8GB)。
每种方案在独立冷进程里测、喂构造器一律用生成器、耗时用time.perf_counter()多次重复取中位、内存统一看稳态memory_usage()。极小的构造耗时(亚毫秒级)受 CPU 频率与缓存影响较大,这类只看数量级、不纠结末位有效数字。
| 方案 | 稳态内存 | 约合 | 构造耗时(中位) |
|---|---|---|---|
[False]*N(list[bool]) | 40,000,056 B | 38.15 MiB | 13.8 ms |
numpy.zeros(N, bool) | 5,000,000 B | 4.77 MiB | <0.1 ms(中位 0.02) |
bytearray(N) | 5,000,057 B | 4.77 MiB | 1.5 ms |
numpy.packbits位图 | 625,000 B | 610 KiB | 0.87 ms |
bitarray(N) | 625,000 B | 610 KiB | 0.17 ms |
BHA:全 False 起点FalsesArray(N) | 415 B | — | 0.12 ms |
| BHA:0.5% 解锁(25,000 个 True) | 100,132 B | 97.8 KiB | 1.58 s |
| BHA:5% 解锁(250,000) | 625,133 B | 610 KiB | 2.16 s |
| BHA:50% 解锁(250 万) | 625,135 B | 610 KiB | 2.30 s |
几个一眼能看出的点:
- 开局:
FalsesArray(5_000_000)这个对象自身只有415 字节(memory_usage()口径),而 list 要 38 MiB;新成就上线那一瞬间,几百个成就同时铺出来也不占地方。注意 415B 只是容器自身记账,不含 import numpy 后解释器约 30 MiB 的常驻 RSS——比内存要看同一口径,本文横评统一用各容器的稳态memory_usage()。 - 极稀疏(0.5%):同一 N、同一稳态口径下,BHA 用约 100 KB,是定长位图 610 KB 的约 1/6.2、numpy 布尔数组(
np.zeros(N, dtype=bool),1 字节/元素)的约 1/50、list 的约 1/400。它内部这时候走的是array.array('I')升序存"已解锁下标"+ 二分查找,只记那 2.5 万个 True,而不是给 500 万人各开一个位。(顺带一提:numpy 布尔类型现在的规范写法是numpy.bool/dtype=bool,老写法np.bool_在 NumPy 2.x 里仍保留为np.bool的别名(2.0 起 canonical 名是np.bool/dtype=bool),并未被移除;我在本机 numpy 2.5.3 实测np.bool_ is np.bool为 True。) - 变稠密(实测 5% 已在位图态):0.5% 时它还在稀疏下标态;到 5%(25 万个 True)实测就已经位于按位打包的位图、625 KB——因为 25 万个下标光用 4 字节整型存就要约 1 MB,已经超过 N/8 的 625 KB,于是库在这之间自己切到了位图,到 50% 仍是 625 KB,和
bitarray/packbits基本打平。表里 5%(625,133B)和 50%(625,135B)只差 2 字节,这点差异来自对象头部/计数等常数记账,与密度无关,理论交叉点约在 25 万下标×4B=1MB 反超 625KB 位图处(约 3.1%)。具体切换点以库当版行为为准,我这里只报告实测现象,不背固定阈值。
这也是这个库"hybrid"的字面意思:你不用自己手写"何时从下标表换位图"。
三步上手
安装(运行期硬依赖 numpy ≥ 1.19,不是零依赖,这点先讲清楚):
pipinstallbool-hybrid-array游戏成就表最自然的用法,是从"全服未解锁"起步、再动态翻位:
frombool_hybrid_arrayimportFalsesArray N=5_000_000unlocked=FalsesArray(N)# 全 False,约 415Bunlocked[100042]=True# 玩家 100042 解锁unlocked[88]=Trueprint(unlocked[100042])# Trueprint(unlocked.count(True))# 2print(unlocked.memory_usage())# 稀疏阶段:约 100KB 量级(2.5 万 True 时 100,367B)我实测从FalsesArray起逐个点亮 25,000 个分散成就,2.5 万次翻位合计约 0.0135 s(单次约 0.5 微秒),全程停在稀疏态、内存 100 KB 出头——这正是日常解锁事件在干的事。
如果你手里已经有一份解锁名单、要一次性建表,记得用生成器喂,别用列表推导(列表推导会先建一个满载的临时 list,把峰值内存抬上去):
frombool_hybrid_arrayimportBoolHybridArr rare=set(range(0,N,N//25000))unlocked=BoolHybridArr((iinrare)foriinrange(N))# 0.5% 稀疏它的边界,我也一起摆桌上
不想把它写成银弹,三个真实代价你得知道:
- 从零一次性灌一个稠密大表并不快:上面 500 万元素用生成器逐个喂,BHA 要 1.5~2.3 s,而
numpy.zeros靠 calloc 近乎 0。所以它的甜区是"稀疏起点 + 长期动态维护",不是"开机就把一个全满稠密数组灌进去"——那种活直接用 numpy。 - 稠密之后它就是一张位图:单点读取实测约 700~880 ns(
timeit式对同一索引重复 200 次取中位,含 Python 下标包装,数值受 CPU 频率/缓存影响会有浮动,看数量级即可);纯定长位图 + 向量化批量统计场景,numpy/bitarray 更直接。 - 从源码层面看(9.13.11 安装包,PyPI 元数据只暴露裸
numpy依赖,以下内部实现以源码为准):稠密侧省内存来自按位打包 N/8,位图缓冲由 numpy 的 uint8 承载(运行需要 numpy ≥ 1.19);稀疏侧是array.array('I')升序下标 + 二分,下标超过 2³² 才升到 8 字节的'Q'。顺带澄清一个常见误传:9.13.11 全程没有 RLE 行程编码,全 True/全 False 的极性塌缩也不是 RLE。
怎么选,一句话
- 纯稠密、还要做向量化批量统计 → 老实用numpy;
- 就要一张极简定长位图、逻辑越简单越好 →bitarray / bytearray / packbits;
- 像成就、签到、标签、风控名单这种:稀疏度大起大落、要随时动态增删、还想要 list 一样的下标接口→ bool-hybrid-array 对味。
项目信息
- 名称:bool-hybrid-array,PyPI 当前版本9.13.11;许可证Apache-2.0 OR MulanPSL-2.0(双许可,注意 MulanPSL 是 2.0),Python ≥ 3.8,硬依赖 numpy ≥ 1.19。
- 主页:https://github.com/BKsell/bool-hybrid-array ;安装:
pip install bool-hybrid-array - 核心类是
BoolHybridArr(单数 Arr),另有TruesArray/FalsesArray快速极性构造。
面试那道题我最后没有背"哪个库最强",而是把这张表摆出来:成就解锁的曲线不是一条直线,存储也不该是。
别信我,信你自己机器上的测量。