位打包两行代码的完全解剖:(N+7)/8 与 1<<(7-i%8)
位图(bitmap)是计算机里最古老的压缩术:一个 bool 占 1 字节,一个 bit 只占 1 位,把 []bool 压成 []byte,空间直接省 8 倍。布隆过滤器、槽位占用表、权限位、 BitSet——底层全是它。
剥掉外壳,位打包的核心就两行:
两行各自藏着一个惯用法:除法配 +7 模拟向上取整,取模配移位造掩码。这篇文章把这两行拆到分子级别。
1. 容量计算:+7 的把戏
N 个 bit 需要多少字节?数学答案是 ceil(N/8)——9 位要 2 字节,1 位也要 1 字节。麻烦在于 Go 的整数除法只向 下 取整:9/8 == 1,第 9 位没地方放。
标准解法是恒等式:
+7 的作用是当 N 不是 8 的倍数时,把商"顶"过整数边界。逐个验证:
| N(位数) | 直接 N/8 ❌ | (N+7)/8 ✅ | 说明 |
|---|---|---|---|
| 1 | 0 字节 | 1 字节 | 0 字节放不下 1 位,越界 |
| 8 | 1 字节 | 1 字节 | 整除时余数 7 被除法吸收 |
| 9 | 1 字节 | 2 字节 | 第 9 位需要第 2 个字节 |
| 12 | 1 字节 | 2 字节 | 12 位需要 16 位空间 |
| 16 | 2 字节 | 2 字节 | 整除,无副作用 |
关键观察:N 是 8 的倍数时,+7 产生的余数恰好被整数除法吸收,零副作用;非倍数时恰好多分 1 字节。一个公式对所有 N 都正确,不需要 if-else。
通用形式:
同一惯用法的其他出场:分页算总页数 (total + pageSize - 1) / pageSize、分桶算桶数。凡是"N 个东西按每份 K 个分组、最后一份可能不满"的场景,都是它。
Tip
一个公式覆盖所有 N,本质是利用整数除法的截断特性:+b-1 只在非整除时改变结果。这是整数编程里复用率最高的惯用法之一。
2. 位寻址:i/8 找字节,7-i%8 找格子
数据放哪个字节、字节的哪一位?第二个惯用法用除法和取模回答:
以 i=13 为例:
| 表达式 | 含义 | 结果 |
|---|---|---|
i / 8 | 第几个字节 | 13/8 = 1 → out[1] |
i % 8 | 字节内第几格 | 13%8 = 5 |
1 << (7 - i%8) | 单点掩码 | 1<<2 = 0b00000100 |
把一个字节看成 8 个格子,编号就是 2 的幂次:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ ← bit 编号
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
bit 7 = 最高位(最左)
1 << k 生成一个"只有编号 k 那格是 1"的掩码;|= 把它或进去——只点亮目标格,其他位原样保留:
Tip
掩码三件套背下来到处能用:置位 x |= mask,清位 x &^= mask,测试 x & mask != 0。
3. 位序之争:为什么是 7-i%8
7-i%8 和 i%8 都能让编解码正确(只要两边对称),区别只在字节内的排列方向:
高位在前(7-i%8) 低位在前(i%8)
bit0 占最高位 bit0 占最低位
┌───────────────┐ ┌───────────────┐
│0 1 2 3 4 5 6 7 │ │7 6 5 4 3 2 1 0 │
└───────────────┘ └───────────────┘
out[0] = 0b10100000 out[0] = 0b00000101
= 0xA0 = 0x05
选高位在前纯粹为了 可读性:打印二进制时,字面左→右的顺序与位图下标顺序一致,排查"第 0 位在哪儿"一眼可见。这是工程约定,不是协议要求——但如果编码用 7-i%8、解码用 i%8,数据直接错乱。
4. 完整走一遍:12 位 → 2 字节
func encodeBits(bits []bool) []byte {
out := make([]byte, (len(bits)+7)/8) // 12 位 → 2 字节
for i, b := range bits {
if b {
out[i/8] |= 1 << (7 - uint(i%8))
}
}
return out
}
func decodeBits(data []byte, wantBits int) []bool {
bits := make([]bool, wantBits)
for i := range bits {
bits[i] = data[i/8]&(1<<(7-uint(i%8))) != 0
}
return bits
}
代入 bits = [T,F,T,F,F,F,F,F,F,T,F,F]:
0 1 2 3 4 5 6 7 8 9 10 11 ← 下标 i
i=0: out[0] |= 1<<7 → 0b10000000
i=2: out[0] |= 1<<5 → 0b10100000
i=9: out[1] |= 1<<6 → 0b01000000
out[0] = 0b10100000 ← 下标 0~7
out[1] = 0b01000000 ← 下标 8~11,末 4 格闲置补 0
末尾不足 8 位的部分补 0 闲置,解码侧只读前 wantBits 位,多余的 0 自动忽略——"补齐"只发生在容量计算,不影响语义。
5. 注意事项
- ⚠️ 编码解码必须对称:位序公式两边必须一致,混用 MSB/LSB 得到错乱数据
- ⚠️ 负数不适用:Go 的
/对负数向零截断,(a+b-1)/b假设 a ≥ 0 - ⚠️ 溢出边界:a 接近类型最大值时
a+b-1会溢出,改用(a-1)/b + 1(a > 0) - ✅
uint(i%8)在 Go 1.13+ 已非必需(有符号移位数合法),保留是兼容习惯 - ✅ 语言无关:C/C++/Java/Rust 的整数除法同样截断,同一惯用法直接搬
小结
一句话概括:i/8 找到字节,7-i%8 找到格子,1<< 造出只点亮那个格子的掩码,|= 点亮它而不碰别的格子;而 (N+7)/8 事先算好要几个字节。
这两行代码浓缩了整数编程的三个基本功:用 +b-1 模拟向上取整、用除法取模对做二维寻址、用移位造掩码配合位或置位。它们在分页、分桶、标志位管理里反复出现——认出它们,读源码时就少一半的"这段在干嘛"。
最后更新:2026-09-04