从位运算角度重新理解树状数组
从位运算角度重新理解树状数组:为什么传统解释不够深刻?
引言:问题的提出
当我们学习树状数组(Binary Indexed Tree,也称 Fenwick Tree)时,大多数教程都会告诉我们:
- 树状数组是一种"树形结构"
- 通过
i += lowbit(i)
可以"向上找父节点" - 通过
i -= lowbit(i)
可以"向下找子节点" - 每个节点管理一个特定长度的区间
这些解释看起来合理,但却没有回答一个核心问题:
为什么只能跳到这些特定的节点?为什么不是其他的跳跃方式?
更深入地思考,既然树状数组的核心操作都是基于 lowbit(x) = x & (-x)
这个位运算,那我们是否应该从位运算的角度来理解它的本质?
预备知识:lowbit 的位运算含义
在深入树状数组之前,我们需要先理解一个关键的位运算概念:lowbit。
什么是 lowbit?
lowbit(x) 表示 x 的二进制表示中最右边的 1 及其右边所有 0 组成的数值。
更准确地说:
- lowbit(x) = x & (-x)
- 它提取出 x 二进制表示中最低位的 1 对应的 2 的幂次
lowbit 的直观理解
x = 12 = 1100 (二进制)
最右边的1在位置2 (从右往左,从0开始计数)
lowbit(12) = 2² = 4x = 6 = 0110 (二进制)
最右边的1在位置1
lowbit(6) = 2¹ = 2x = 8 = 1000 (二进制)
最右边的1在位置3
lowbit(8) = 2³ = 8x = 5 = 0101 (二进制)
最右边的1在位置0
lowbit(5) = 2⁰ = 1
lowbit 的位运算实现原理
为什么 x & (-x)
能得到 lowbit?
这涉及到负数的补码表示:
计算 -x 的步骤:
1. 取 x 的二进制表示
2. 按位取反(得到反码)
3. 加 1(得到补码)例如 x = 6:
x = 6: 00000110
按位取反: 11111001
加1得-x: 11111010x & (-x): 00000110& 11111010= 00000010 = 2
关键洞察:在补码运算中,从右往左找到第一个 1,这个 1 及其右边的 0 在取反加1后保持不变,而左边的位全部被翻转。因此 x & (-x)
只保留了最右边的 1。
树状数组的基本实现
tree[i] 的含义
现在我们可以准确定义树状数组中 tree[i]
的含义:
tree[i]
管理从位置 i - lowbit(i) + 1
到位置 i
的区间和
换句话说,tree[i]
管理的是:从 i 开始,向前 lowbit(i) 这么长的区间。
例如:
tree[1]: 管理 [1, 1],长度 = lowbit(1) = 1
tree[2]: 管理 [1, 2],长度 = lowbit(2) = 2
tree[3]: 管理 [3, 3],长度 = lowbit(3) = 1
tree[4]: 管理 [1, 4],长度 = lowbit(4) = 4
tree[5]: 管理 [5, 5],长度 = lowbit(5) = 1
tree[6]: 管理 [5, 6],长度 = lowbit(6) = 2
tree[7]: 管理 [7, 7],长度 = lowbit(7) = 1
tree[8]: 管理 [1, 8],长度 = lowbit(8) = 8
Query 操作的区间分解
现在我们来看 Query 操作是如何工作的:
func (bit *BIT) Query(i int) int {sum := 0for i > 0 {sum += bit.tree[i]i -= lowbit(i)}return sum
}
Query 的本质是将 [1, i] 区间按照 tree 的管理区间进行分解。
让我们看一个具体例子,Query(6) 的分解过程:
Query(6) 要计算 [1, 6] 的区间和第一步:i = 6
- tree[6] 管理 [5, 6]
- 剩余区间:[1, 4] = [1, 6-lowbit(6)] = [1, 6-2]
- i = 6 - 2 = 4第二步:i = 4
- tree[4] 管理 [1, 4]
- 剩余区间:[1, 0] = 空
- i = 4 - 4 = 0,结束完整分解:[1, 6] = [5, 6] ∪ [1, 4]
对应代码:sum = tree[6] + tree[4]
再看一个例子,Query(7) 的分解过程:
Query(7) 要计算 [1, 7] 的区间和第一步:i = 7
- tree[7] 管理 [7, 7]
- 剩余区间:[1, 6]
- i = 7 - 1 = 6第二步:i = 6
- tree[6] 管理 [5, 6]
- 剩余区间:[1, 4]
- i = 6 - 2 = 4第三步:i = 4
- tree[4] 管理 [1, 4]
- 剩余区间:空
- i = 4 - 4 = 0,结束完整分解:[1, 7] = [7, 7] ∪ [5, 6] ∪ [1, 4]
对应代码:sum = tree[7] + tree[6] + tree[4]
关键观察:i -= lowbit(i)
恰好实现了最优的区间分解,每次分解都是无重叠、无遗漏的。
核心理论:贡献关系的位运算构造
核心问题:当更新位置 b 时,哪些 tree[a] 需要被更新?
贡献关系的位运算定义
从位运算角度看,a 包含 b 的条件可以这样理解:
tree[a] 管理区间 (a-lowbit(a), a],从正向构造的角度分析:
核心思想:将 a 的 lowbit(a) 位清零,然后在 lowbit(a) 位之后的位置进行任意组合,但不能全为0。
位运算构造过程:
- 从 a 开始,保持 lowbit(a) 位之前的所有位不变
- 将 lowbit(a) 位清零
- lowbit(a) 位之后的位可以任意变化,但至少要有一个位为1(这样构造出区间 (a-lowbit(a), a))
- 最后加上 a 本身,得到完整的管理区间 (a-lowbit(a), a]
具体例子:
-
a = 4 (100₂),lowbit(4) = 4 (100₂)
-
tree[4] 管理区间 [1,4],即 (000₂, 100₂]
-
构造过程:从 100₂ 开始,将 lowbit 位(2²位)清零得到 000₂,然后在 lowbit 位之后的位(2⁰位和2¹位)进行变化
-
在 000₂ 基础上,2⁰位和2¹位可以任意组合,但不能是 000₂ 本身
-
因此构造出 (0, 4) 中的数:001₂(1), 010₂(2), 011₂(3),再加上 a 本身 100₂(4)
-
a = 6 (110₂),lowbit(6) = 2 (010₂)
-
tree[6] 管理区间 [5,6],6-lowbit(6) = 6-2 = 4,所以区间是 (4, 6]
-
构造过程:从 110₂ 开始,将 lowbit 位(第2位)清零得到 100₂,然后最右边的位可以变化
-
在 100₂ 基础上,最右位可以是0或1,但不能是 100₂ 本身
-
因此构造出 (4, 6) 中的数:101₂(5),再加上 a 本身 110₂(6)
判断规律:b 属于 tree[a] 管理范围,当且仅当:
(b & ~(lowbit(a)-1)) == (a & ~(lowbit(a)-1))
(高位部分相同)b & (lowbit(a)-1) != 0
或b == a
(低位部分不全为0,或者就是a本身)
从 b 构造贡献目标 a 的方法
逆向构造:从 b 找到它能贡献的 a
现在反过来,对于给定的 b,我们要找到所有它能贡献的 a。
关键观察:如果 b 对 a 有贡献,那么:
- 前缀一致性:lowbit(a) 之前的前缀,a 和 b 必须完全一致
- 位置关系:lowbit(a) 必须 > lowbit(b)(因为 a > b 且 a 包含 b)
- lowbit(a) 位的状态:这一位在 b 中必须是 0(否则构造的 a 会 < b)
构造过程:
-
枚举候选位置:从 lowbit(b) 位开始向左枚举,寻找 b 中为 0 的位作为 lowbit(a) 的候选位置
-
验证可行性:对于每个候选位置 k:
- 如果 b 的第 k 位是 0 ✓ 可以构造
- 如果 b 的第 k 位是 1 ✗ 不能构造(会导致 a < b)
-
构造 a:
- 保留 b 在第 k 位之前的所有前缀
- 将第 k 位设置为 1(作为 a 的 lowbit)
- 第 k 位之后全部设为 0
为什么第 k 位是 1 时不能构造?
如果 b 的第 k 位是 1,构造出的 a 会是:
- a 的前缀 = b 的前缀(第 k 位之前)
- a 的第 k 位 = 1
- a 的第 k 位之后 = 全 0
但 b 除了第 k 位是 1,还有 lowbit(b) 位也是 1,所以 b > a,违反了贡献关系的要求。
简化的构造方法:
- 从 b 的 lowbit 位开始,向左枚举 0 位
- 将找到的 0 位设置成 1,作为 a 的 lowbit
- 保留 b 在 a 的 lowbit 位之前的前缀
- lowbit 之后的位全部为 0
具体例子:b = 3 的贡献构造
b = 3 = 0011, lowbit(b) = 1 (位置0)从位置0向左枚举0位:
- 位置1: 是1,跳过
- 位置2: 是0 ✓ 可以设置成1构造a₁:
- a的lowbit在位置2,值为2² = 4
- 保留位置2之前的前缀: "00"
- 构造: a₁ = 00100 = 4验证包含关系:
- tree[4]管理[1,4],位置3在其中 ✓
- 4 - 4 < 3 ≤ 4 → 0 < 3 ≤ 4 ✓继续枚举:
- 位置3: 是0 ✓ 可以设置成1构造a₂:
- a的lowbit在位置3,值为2³ = 8
- 保留位置3之前的前缀: "001"
- 构造: a₂ = 1000 = 8验证包含关系:
- tree[8]管理[1,8],位置3在其中 ✓
- 8 - 8 < 3 ≤ 8 → 0 < 3 ≤ 8 ✓贡献路径: 3 → 4 → 8 → 16 → 32 → ...
再看例子:b = 6 的贡献构造
b = 6 = 0110, lowbit(b) = 2 (位置1)从位置1向左枚举0位:
- 位置2: 是1,跳过
- 位置3: 是0 ✓ 可以设置成1构造a₁:
- a的lowbit在位置3,值为2³ = 8
- 保留位置3之前的前缀: "011"
- 构造: a₁ = 1000 = 8验证包含关系:
- tree[8]管理[1,8],位置6在其中 ✓
- 8 - 8 < 6 ≤ 8 → 0 < 6 ≤ 8 ✓继续枚举:
- 位置4: 是0 ✓ 可以设置成1构造a₂:
- a的lowbit在位置4,值为2⁴ = 16
- 保留位置4之前的前缀: "0110"
- 构造: a₂ = 10000 = 16贡献路径: 6 → 8 → 16 → 32 → ...
理论与代码的完美契合
现在我们来看最精彩的部分:我们的位运算理论如何完美地对应到树状数组的代码实现。
Update 操作的位运算本质
func (bit *BIT) Update(i, delta int) {for i <= bit.n {bit.tree[i] += deltai += lowbit(i) // 关键操作}
}
i += lowbit(i)
恰好枚举了我们理论中的贡献路径!
让我们分析 i += lowbit(i)
的位运算过程:
设 i = ...abc10...0 (最后有k个连续的0)
lowbit(i) = 2^k
i + lowbit(i) = ...ab(c+1)00...0操作效果:
1. 找到i的最右边的1 (当前lowbit位)
2. 将其进位到下一个0位
3. 进位过程自动保留了前缀
4. 进位后自动清零了新lowbit之后的位
这恰好是我们理论中的构造过程!
验证 b = 3 的例子:
i = 3 = 0011, lowbit(3) = 1
i + lowbit(i) = 3 + 1 = 4 = 0100位运算分析:
- 最右边的1在位置0
- 进位到下一个0位(位置2)
- 自动保留前缀"00"
- 自动清零位置2之后的位
- 结果恰好是我们构造的a₁ = 4 ✓
验证 b = 6 的例子:
i = 6 = 0110, lowbit(6) = 2
i + lowbit(i) = 6 + 2 = 8 = 1000位运算分析:
- 最右边的1在位置1
- 进位到下一个0位(位置3)
- 自动保留前缀"011"
- 自动清零位置3之后的位
- 结果恰好是我们构造的a₁ = 8 ✓
Query 操作的位运算验证
基于前面的区间分解理论,i -= lowbit(i)
的位运算过程可以这样理解:
位运算分析:
设 i = ...abc1000...0 (末尾有k个0)
lowbit(i) = 2^k
i - lowbit(i) = ...ab(c-1)111...1 (将最右边的1变为0,其右边全变为1)这个操作恰好去掉了tree[i]管理的区间,留下了需要继续分解的部分。
Update 操作中 x += lowbit(x) 的构造原理
基于我们的贡献关系理论,x + lowbit(x)
能实现构造的原理:
3 = 0011 + 0001 = 0100 = 4位运算分析:
- 最右边的1在位置0
- 加上lowbit相当于进位到下一个0位(位置2)
- 自动清零位置2之后的位 → 00
- 保留位置2之前的前缀 → "00"
- 结果: 0100 = 4
神奇之处:x + lowbit(x)
自动找到了第一个可以"设置成1"的0位,并完成了我们理论中的构造过程!
对比验证代码
package mainimport "fmt"// ========== 位运算枚举实现(基于我们的理论) ==========
type BitEnumBIT struct {tree []intn int
}func NewBitEnumBIT(n int) *BitEnumBIT {return &BitEnumBIT{tree: make([]int, n+1),n: n,}
}func (bit *BitEnumBIT) lowbit(x int) int {return x & (-x)
}// 基于位运算枚举理论的Update实现
func (bit *BitEnumBIT) Update(pos, delta int) {// 从pos开始,枚举所有它能贡献的位置for pos <= bit.n {bit.tree[pos] += delta// 使用我们的理论:找到下一个可以设置为1的0位pos = bit.constructNextContributor(pos)if pos > bit.n {break}}
}// 根据我们的位运算理论构造下一个贡献目标
func (bit *BitEnumBIT) constructNextContributor(b int) int {// 从b的lowbit位开始向左枚举0位lowbitB := bit.lowbit(b)// 寻找b中第一个为0的位(从lowbit位开始向左)for k := lowbitB << 1; k <= (1 << 20); k <<= 1 {if (b & k) == 0 { // 找到0位// 构造a:保留k位之前的前缀,设置k位为1,k位之后清零prefix := b & (^(k - 1)) // 保留k位之前的前缀a := prefix | k // 设置k位为1return a}}return bit.n + 1 // 超出范围
}// 基于位运算区间分解理论的Query实现
func (bit *BitEnumBIT) Query(i int) int {sum := 0for i > 0 {sum += bit.tree[i]i -= bit.lowbit(i) // 位运算区间分解}return sum
}// ========== 传统暴力实现(用于对比验证) ==========
type BruteForce struct {arr []intn int
}func NewBruteForce(n int) *BruteForce {return &BruteForce{arr: make([]int, n+1),n: n,}
}func (bf *BruteForce) Update(i, delta int) {bf.arr[i] += delta
}func (bf *BruteForce) Query(i int) int {sum := 0for j := 1; j <= i; j++ {sum += bf.arr[j]}return sum
}// ========== 验证函数 ==========
func main() {fmt.Println("=== 位运算枚举实现 vs 传统暴力方法对比验证 ===\n")bit := NewBitEnumBIT(10)bf := NewBruteForce(10)// 测试操作序列operations := []struct {op stringpos intvalue int}{{"update", 1, 5},{"update", 3, 7},{"query", 3, 0}, // 查询前3个位置的和{"update", 5, 2},{"query", 5, 0}, // 查询前5个位置的和{"update", 2, 3},{"query", 4, 0}, // 查询前4个位置的和}for i, op := range operations {if op.op == "update" {fmt.Printf("操作%d: Update(%d, %d)\n", i+1, op.pos, op.value)bit.Update(op.pos, op.value)bf.Update(op.pos, op.value)} else {result1 := bit.Query(op.pos)result2 := bf.Query(op.pos)fmt.Printf("操作%d: Query(%d) -> 位运算枚举: %d, 暴力: %d",i+1, op.pos, result1, result2)if result1 == result2 {fmt.Printf(" ✓\n")} else {fmt.Printf(" ✗ 不一致!\n")}}}fmt.Println("\n验证完成:位运算枚举实现与传统方法结果完全一致,证明我们的位运算理论正确!")
}