跳转至

位打包两行代码的完全解剖:(N+7)/8 与 1<<(7-i%8)

位图(bitmap)是计算机里最古老的压缩术:一个 bool 占 1 字节,一个 bit 只占 1 位,把 []bool 压成 []byte,空间直接省 8 倍。布隆过滤器、槽位占用表、权限位、 BitSet——底层全是它。

剥掉外壳,位打包的核心就两行:

out := make([]byte, (len(bits)+7)/8)     // 要几个字节
out[i/8] |= 1 << (7 - uint(i%8))         // 放进哪个格子

两行各自藏着一个惯用法:除法配 +7 模拟向上取整,取模配移位造掩码。这篇文章把这两行拆到分子级别。

1. 容量计算:+7 的把戏

N 个 bit 需要多少字节?数学答案是 ceil(N/8)——9 位要 2 字节,1 位也要 1 字节。麻烦在于 Go 的整数除法只向 取整:9/8 == 1,第 9 位没地方放。

标准解法是恒等式:

ceil(N/8) = (N + 7) / 8

+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。

通用形式:

ceil(a / b) = (a + b - 1) / b    // b > 0

同一惯用法的其他出场:分页算总页数 (total + pageSize - 1) / pageSize、分桶算桶数。凡是"N 个东西按每份 K 个分组、最后一份可能不满"的场景,都是它。

Tip

一个公式覆盖所有 N,本质是利用整数除法的截断特性:+b-1 只在非整除时改变结果。这是整数编程里复用率最高的惯用法之一。

2. 位寻址:i/8 找字节,7-i%8 找格子

数据放哪个字节、字节的哪一位?第二个惯用法用除法和取模回答:

out[i/8] |= 1 << (7 - uint(i%8))
└───┬───┘     └──────┬──────┘
  哪个字节         哪个 bit

以 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"的掩码;|= 把它或进去——只点亮目标格,其他位原样保留:

   01000000    ← out[1] 原值
 | 00000100    ← 掩码 1<<2
 ──────────
   01000100    ← 只有编号 2 被点亮

Tip

掩码三件套背下来到处能用:置位 x |= mask,清位 x &^= mask,测试 x & mask != 0

3. 位序之争:为什么是 7-i%8

7-i%8i%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

评论