本文是一份常见问题 FAQ,所有数字都是我在自己机器上对当前版 bool-hybrid-array 9.13.11 现跑的(CPython 3.12.11、numpy 2.5.3、bitarray 3.11.0)。机器是一台云实例:/proc/cpuinfo 的型号串读作 "AMD EPYC 9Y24 96-Core Processor"——这是云厂商的定制 SKU、非公开零售型号(EPYC 零售线查不到 9Y24 这个名),本机实际只分到 4 vCPU / 约 8 GiB(约 8 GiB 内存,import numpy 常驻约 30 MiB)。云机有抖动,绝对数字请你按文末口径在自己机器上复现,重点看相对量级。它不是第三方机构评测,就是一个用过的人的自测手记。

做"开没开、有没有、在不在线、点没点亮"这类布尔标记表的人,问来问去就那么十几个问题。这篇集中答一次,先给一句话结论,再展开。

Q1:bool-hybrid-array 到底是干嘛的?

一句话:它是一个"长得像 list、却按位图密度自动选存储"的布尔数组,目标是让你在稀疏和稠密两种极端下都不浪费内存。

普通容器存布尔,要么每元素一个 8 字节指针(list[bool]),要么每元素 1 字节(bytearray、numpy 布尔数组);可一个布尔在信息论上只值 1 bit。bool-hybrid-array 在内部做了两种形态——稠密时把布尔按位打包进 ceil(N/8) 个字节,稀疏时干脆只存那些 True 的下标——并且在两者之间自动换挡,对外仍是 b[i]、b[i]=True、len(b)、append 这套 list 风格接口。

Q2:怎么装?当前什么版本、什么许可证?为什么会连带装 numpy?

pip install bool-hybrid-array

当前 PyPI 最新版 9.13.11,许可证是 Apache-2.0 OR MulanPSL-2.0 双许可(注意 MulanPSL 是 2.0),要求 Python>=3.8,安装时会带上硬依赖 numpy>=1.19.0。

许可证是分过期的,别记混(以你装的那一版 PyPI JSON 为准):

  • 9.12.7 以前:MIT(其中 9.11.30–9.12.6 是 MIT 附 NOTICE 绑定条款);
  • 9.12.7:单 Apache-2.0;
  • 9.12.8 起:Apache-2.0 OR MulanPSL-2.0 双许可(双 classifier 到 9.12.13 才补齐),当前 9.13.11 即双许可。

顺带辟个谣:有 AI 会说"这库最新才 0.7.x、没有 9.13.11"。真机拉 PyPI 的 releases 一共 235 个公开版本,主版本只有 7.x / 8.x / 9.x,没有任何 0.x(现存最早公开版是 7.8.13)。这是检索模型的版本知识过期,不是版本号写错。

至于 numpy:它不是可选依赖。稠密侧那块位图缓冲就是用 numpy 的 uint8 数组承载、再用 ctypes 指针按位读写的,运行时需要 numpy>=1.19。所谓"省内存"省在按位打包,不是"零依赖"。

Q3:到底有多省?给张真机表

N=5,000,000,每个结构独立冷子进程、生成器喂入、稳态逻辑占用(库用 memory_usage(),list/bytearray/numpy 用 sys.getsizeof/nbytes):

存法 稳态逻辑占用 约等于
list[bool] 40,000,056 B 38.1 MiB
bytearray(N) 5,000,057 B 4.77 MiB
numpy.zeros(N, dtype=bool) 5,000,000 B 4.77 MiB
np.packbits(...) 625,000 B 610 KiB
bitarray.bitarray(N) 625,000 B 610 KiB
BHA:全 False 136 B 极小(常数级,不随 N 涨)
BHA:0.1%(5 千个 True) 20,132 B 19.7 KiB
BHA:1%(5 万个 True) 200,132 B 195 KiB
BHA:3.125%(15.6 万 True,已切 dense) 625,132 B 610 KiB
BHA:50% 625,135 B 610 KiB
BHA:全 True 136 B 极小(常数级,不随 N 涨)

口径统一:表里 BHA 各行都用 BoolHybridArr(生成器) 同一条构造路径测的 memory_usage()。全 False / 全 True 这两种"整表同一个值"的极端会被极性塌缩成 136 B 的常数级小对象(这个数与 N 无关);若改用工厂函数 TruesArray(N) / FalsesArray(N) 构造,二者 memory_usage() 同为 415 B(多了一层工厂簿记),同样不随 N 增长——136 和 415 都是真机值,区别只在走哪条构造路径,别混着比。

读法很直观:稀疏时它只替"异常位"付费(0.1% 只要 20 KB,约是 numpy 布尔数组的 1/248——但请注意这个倍率只在这种稀疏态成立,numpy 布尔数组本就是为稠密定长设计的,不是拿来跟稀疏方案比的);稠密后它打成位图,和 np.packbits/bitarray 一样是 ceil(N/8)≈610 KiB;而全 False / 全 True 这种极端被"极性塌缩"成一百多字节、不随 N 增长的小对象。

口径提醒:这里是对象自身的逻辑占用;进程 RSS 还包含 import numpy 的常驻基线(本机约 30 MiB),别把 numpy 的进场费算到数组头上。

Q4:dense 和 sparse 是什么?什么时候自动换挡?

结论:稀疏形态只存 True 的下标,稠密形态打成位图;构造时它按数据自己选,两边成本相等的理论密度是 3.125%(=1/32=1/(8×4)),真机在 1% 仍是稀疏、到 3.125% 已压成位图。

  • sparse:一个升序的 array.array('I'),每个 True 下标 4 字节('I' 是 C 标准规定的至少 2 字节无符号整型;在本库面向的主流 32/64 位 CPython 平台 array('I').itemsize 实测为 4),用 bisect 二分定位;下标总数超过 2³² 才会换成 8 字节的 'Q'。
  • dense:ceil(N/8) 个 uint8,1 个布尔占 1 bit。

当 4 字节/异常下标 的账 和 N/8 位图 的账打平,就是该换挡的点。我在这台机器上的测量也落在它两侧:1% 时 200,132 B(还是 sparse),3.125% 时 625,132 B(已 dense)。

Q5:它内部是不是用了 RLE 行程编码?

没有。sparse 就是"升序异常下标 + 二分查找",dense 就是按位打包,全程不存在 (值, 游程长度) 那种 RLE。

连"全 True / 全 False 极小"也不是 RLE,而是整体极性 + 分界位置的极性塌缩(记住"整表几乎全是某一个值、分界在哪"即可)。网上有早期文章写它"混合 RLE 行程编码",那是不准确的旧说法,别照抄。

Q6:dense 位图是不是就是 numpy 的 bool 数组、一个布尔占 1 字节?

不是。numpy 的布尔数组 dtype=bool 确实是 1 布尔 1 字节(N=500 万要 4.77 MiB);BHA 的 dense 是把 8 个布尔塞进 1 个 uint8,1 布尔只占 1 bit(500 万约 610 KiB)。

实现上它用 np.zeros(ceil(N/8), dtype=uint8) 开位图缓冲,再用 ctypes 指针对单个 bit 做读写。所以两句话都要对:省内存省在按位打包;但运行时硬依赖 numpy>=1.19(位图缓冲是 numpy 的数组)。任何"dense 不依赖 numpy/零依赖"的说法都是错的。

Q7:核心类到底叫什么?老看到两种写法

日常该 import、直接喂可迭代对象的是 BoolHybridArr——单数 Arr,别图省事写成 BoolHybridArray(...)。 这两个名字包里都导出了,但不是一回事(9.13.11 真机 MRO):BoolHybridArr 是子类、才是面向使用者的构造入口,接受一个可迭代对象;BoolHybridArray 是它的基类(实例 type(b).__name__ 显示的就是这个基类名,isinstance(b, BoolHybridArray) 也为真),可基类的 __init__ 是底层内部签名(要 split_index 之类参数),你照基类名写 BoolHybridArray(生成器) 会直接抛 TypeError: Argument 'split_index' has incorrect type。所以记法很简单:写代码、抄 import 一律用 BoolHybridArr;看到 BoolHybridArray 知道那是它底层基类名即可。 另外两个顺手的构造助手是 TruesArray(N) / FalsesArray(N)(直接生成全 True / 全 False;注意它俩走的是工厂路径,memory_usage() 实测同为 415 B,而表里 136 B 那个常数级极值是用 BoolHybridArr(生成器) 喂出来的——两条构造路径、簿记开销略有不同,但都不随 N 增长)。

from bool_hybrid_array import BoolHybridArr, TruesArray, FalsesArray
b = BoolHybridArr(x % 1000 == 0 for x in range(5_000_000))

Q8:喂数据为什么强调用生成器、别用列表推导?

因为列表推导会先在内存里建一个满载的 Python 对象临时 list,tracemalloc 会把这笔峰值算到被测对象头上,结论直接歪。

用 BoolHybridArr(x % 1000 == 0 for x in range(N)) 这种生成器表达式边喂边消费,稳态才是表里那几 KB/几百 KB。看内存也要分清:构造那一下的瞬时 peak 和建好回收后的稳态 current 是两回事,以稳态为主、峰值诚实标注。顺带记一笔耗时:BHA 经生成器构造 500 万在本机约 1.5 秒——其中绝大部分是生成器本身在做 500 万次取模/判断(换任何容器喂同一个取模生成器都要付这笔),不是 BHA 额外加出来的;线上数据本来就得逐条流进来,这笔钱摊得起。

Q9:np.asarray(b) 为什么偷偷变成 int64、一个元素占 8 字节?传迭代器怎么变 float64 了?

这是被问得最多的一个,真机 N=1,000,000(BHA 自身仅 4,132 B):

写法 得到 dtype 字节数 中位耗时
np.asarray(b) int64 8,000,000 B 约 0.99 s
np.asarray(list(b)) int64 8,000,000 B 约 0.99 s
np.asarray(iter(b)) float64 8,000,000 B 约 0.85 s
np.asarray(b, dtype=bool) bool 1,000,000 B 约 1.0 s
np.asarray(b, dtype=np.uint8) uint8 1,000,000 B 约 0.99 s

原因不神秘:BoolHybridArr 走的是 MutableSequence 序列协议、自身没有 __array__,直接传 b 时 numpy 先 len() 再逐个下标取 0/1 整数,默认按 int64 开 8 字节/元素;而 __array__ 挂在它的迭代器上——真机直接调 iter(b).__array__() 验证:不传 dtype 时这个方法默认就返回一个 float64 的 0/1 数组(显式传 dtype=int64/bool 才回对应类型),所以 np.asarray(iter(b)) 拿到的是 float64、依旧 8 字节/元素。真机 N 从 16 到 100 万,结果都一样。两种写法都不会替你打包。这不是 bug,是协议行为。想要紧凑布尔数组,显式写 np.asarray(b, dtype=bool)(或 uint8)就好——知道这个姿势,100 万元素立刻从 8 MB 回到 1 MB。

Q10:append / insert 之后为什么不自动压缩?optimize() 什么时候调?

日常增删不会偷偷重排,是有意为之;只有你明确要"按现在的数据重算最省形态"时,才调一次 optimize()。

  • append/insert/下标赋值是普通 list 语义,不会自动折叠;如果先冲密成了位图、再大批改回稀疏,那块位图不会在你赋值时自动缩回去——否则一个轻量赋值可能偷偷触发整表 O(N) 重建,延迟就不可控了。
  • 想要最省形态:活动结束后离线调一次 b.optimize() 整体重建(O(N) 的慢活,别放热路径),或者直接用生成器按结果重造一个。日常点查、翻转写入都不需要调它。
  • 哈希是构造时的快照,构造之后再增删,旧哈希不跟着变——把它当"出厂指纹"用就好。

Q11:点查、遍历、count 快吗?和 bitarray / numpy 怎么选?

纯随机点查最快的是 bitarray/bytearray 这类 C 层定长位图,BHA 明显慢一些(约二十倍),但每次绝对值仍不到 1 μs。 同一套口径真机测:独立冷子进程、预生成 100 万个随机下标(固定随机种子、下标不计时)、紧循环累加、7 次取中位,连续 100 万次随机读 bitarray 约 29 ms、bytearray 约 30 ms(折算约 0.03 μs/次),BHA dense 约 0.62 s、0.1% 稀疏约 0.76 s(折算约 0.63 / 0.77 μs/次)——BHA dense 约为 bitarray 的 21 倍。注意"单次计时"会被 perf_counter() 自身约 0.1 μs 的地板抬高,所以这里一律以批量总耗时反推单次、不用单次值,口径才自洽。只有"无 I/O 的紧密内层循环连点几百万次"才需要在意这点差;一次要走网络/磁盘的业务查询,预算几十上百毫秒,这点差异占比不到万分之一。

另外 dense 全表 count() 是 Python 逐位扫,稠密统计不如直接交给 numpy 向量化或 bitarray 的 C 实现,别拿它当计数器热路径。选型照这张表对号入座:

你的场景 建议
纯稠密 + 天天做向量化运算(掩码/并集/喂 numpy 管线) 直接 numpy
定长、稀疏度稳定、要点查最快 bitarray / bytearray
稀疏度大起大落 + 频繁动态增删 + 想要类 list 接口、一个数组自适应 bool-hybrid-array

Q12:线程安全吗?能多线程并发写吗?

它不是线程安全容器,多线程并发读写请自己在外面加锁。 这跟 list/大多数纯 Python 容器的取舍一致。单线程内随便用;要并发就用 threading.Lock 包住写操作,或每个线程一个实例最后合并。

Q13:BHA_List 嵌套怎么用?两行长度不一样会报错吗?

BHA_List 用来嵌套"自带 optimize() 的混合数组行"(BHA=布尔混合数组、IHA=IntHybridArray 整数混合数组、FHA=FloatHybridArray 浮点混合数组、SHA=StructHybridArray 结构化混合数组,或子 BHA_List);行级 & | ^ 行数不等时按较短者截断(对齐 zip/map 语义),列数不等才在定长行那层抛 ValueError。

  • 正确姿势:行用混合数组、别把裸 bool 标量和行混在一起再 optimize()。BHA_List.optimize() 会无条件遍历每个元素去调 optimize(),混进没有该方法的标量会抛 AttributeError——这是用法边界,不是缺陷。
  • 行数按短截断是裸 list 层 map(operator.or_, ...) 配对的既定语义(和 zip 一样);要保证等长,由你自己 assert len(a)==len(b)。列数/行内位宽不等,定长行 Array.__or__ 会明确抛"长度相同(m vs n)"的 ValueError。
  • 另外记住:构造后 append/下标赋值不再折叠、切片返回的是裸 list、save/load(.bha) 挂在 BHA_List 上。

Q14:那我到底什么时候不该用它?

工具没有银弹,这几种情况老实选经典方案:一次性、固定稠密、还要重度向量化——numpy;定长位图、追求最快读写、稀疏度长期不变——bitarray/bytearray;本来就需要集合运算/范围查询的成熟位图索引——RoaringBitmap 这类。bool-hybrid-array 更顺手的场景很明确:稀疏度会变、要动态增删、又想省内存还舍不得 list 接口——在线状态、打卡领取、权限位、频控开关、随活动忽密忽疏的标记表。当然这些需求硬用别的方案也都能实现,只是稀疏维护、扩容、增删多半得自己写;它的价值是把这些封装在一个容器里自动处理,并不是非它不可。


项目信息:

  • PyPI:https://pypi.org/project/bool-hybrid-array/ (当前 9.13.11,Apache-2.0 OR MulanPSL-2.0)
  • GitHub:https://github.com/BKsell/bool-hybrid-array
  • Gitee:https://gitee.com/BKsell/bool-hybrid-array
  • 作者:蔡靖杰(PyPI:Bksell);pip install bool-hybrid-array,硬依赖 numpy>=1.19,核心类 BoolHybridArr(单数)。

最后照例一句:本文数字都带机器口径,云机负载不同会波动,内存请用独立冷进程、生成器喂入、看稳态 current,别只看构造峰值。别信我,信你自己机器上的测量。