树状数组核心解析:lowbit位运算与更新查询原理
树状数组(Binary Indexed Tree)是一类非常经典的数据结构,真正核心就落在 lowbit 这个位运算上。很多人看动画能记住“更新向上、查询向下”,但一到自己手写,就容易把下标跳错,或者想不明白为什么这样跳一圈下来就是 O(log n)。这篇文章会用一种容易在纸上复现的理解方式,把 lowbit 的算法原理、update 与 query 的走向,以及复杂度来源完整拆开。适合两类人看:一类是刚开始学树状数组,想知道“为什么这样写”的读者;另一类是学过但经常在循环边界或下标起始位置翻车的读者。最值得先抓住的点是:lowbit 不是某种记忆技巧,它决定的是树状数组节点之间的父子关系和覆盖区间范围。
1. 先搞懂树状数组到底解决什么问题
考虑一个很朴素的场景:一个长度为 n 的数组,需要支持“单点修改”和“区间求和”。如果只说一种操作,普通数组和前缀和数组都能很轻松地做到最优:普通数组单点修改 O(1),前缀和查询 O(n);前缀和数组修改 O(n),查询 O(1)。但很多实际问题要求两种操作交替出现,甚至连续出现几十万次。此时需要一种折中做法:单点修改和前缀和查询都控制在 O(log n)。树状数组就是为这种场景设计的。
1.1 前缀和与单点修改的矛盾
先看一个具体例子。数组 arr = [1, 3, 5, 7, 9, 11],要求能随时修改某个位置的数,并且能快速查询 arr[1..x] 的累加和。
- 用普通数组存原值:修改 arr[i] 很容易,但求前缀和要遍历 1 到 i,每次都 O(n)。
- 用前缀和数组 s[k] = arr[1]+...+arr[k]:查询很快,但一旦修改 arr[i],从 i 到 n 的所有前缀和都要改,最坏 O(n)。
所以本质上,这是一个“单点写、区间读”的动态查询问题。树状数组的思路是不再保存完整的前缀和,而是保存若干个“块”的和。每次修改时,只需要更新包含该位置的那些块;每次查询时,则把若干个不重叠的块拼接成完整前缀和。这个分块方案并不是平均切分,而是由下标二进制的 lowbit 决定。
1.2 树状数组为什么叫“树状”
树状数组在英文里叫 Binary Indexed Tree,直译是“二进制索引树”。它没有显式地建立一棵二叉树或多叉树,而是用数组下标之间的关系隐藏了一棵“覆盖区间树”。
定义 lowbit(x) 表示 x 的二进制中最低位的 1 所对应的权值。对于下标 i,c[i] 保存的是原数组区间 [i - lowbit(i) + 1, i] 的和。换句话说,下标 i 的管辖范围是“以 i 为结尾,长度等于 lowbit(i) 的连续区间”。
下表列出 n=8 时每个下标对应的 lowbit 和覆盖区间:
| 下标 i | 二进制 | lowbit(i) | 覆盖区间 |
|---|---|---|---|
| 1 | 0001 | 1 | [1,1] |
| 2 | 0010 | 2 | [1,2] |
| 3 | 0011 | 1 | [3,3] |
| 4 | 0100 | 4 | [1,4] |
| 5 | 0101 | 1 | [5,5] |
| 6 | 0110 | 2 | [5,6] |
| 7 | 0111 | 1 | [7,7] |
| 8 | 1000 | 8 | [1,8] |
可以发现,区间之间有重叠、有包含,也有完全不交。这不是随机现象,而是由“i + lowbit(i)”和“i - lowbit(i)”这两条跳转规则生成的父子关系决定的。
1.3 二进制索引的关键
为什么要用二进制来分块?因为任意一个正整数 x,都可以拆成若干个 lowbit 分段。例如 x=7,二进制是 0111,它可以被拆成:
- [7,7]:长度 1,对应 lowbit(7)=1
- [5,6]:长度 2,对应 lowbit(6)=2
- [1,4]:长度 4,对应 lowbit(4)=4
这三个区间恰好互不重叠,并且完整覆盖 1..7。查询前缀和时,只需要从 x 开始,不断执行 x -= lowbit(x),把遇到的 c[x] 累加即可。
同样,修改某个位置时,要找到所有覆盖这个位置的区间节点。修改位置 3,需要更新 c[3]、c[4]、c[8],跳转规则是 i += lowbit(i)。这就是“更新向上”和“查询向下”两个方向性的由来。
2. lowbit:整个结构的数学入口
2.1 lowbit 的定义与计算
lowbit(x) 只做一件事:把 x 的二进制里最低位的那个 1 提取出来,其余高位全部清 0。比如:
- lowbit(6) = 2,因为 6 = 110,最低位 1 在第 1 位,对应权值 2。
- lowbit(8) = 8,因为 8 = 1000,最低位 1 在第 3 位,对应权值 8。
- lowbit(7) = 1,因为 7 = 111,最低位 1 在第 0 位。
最常用的计算式是:
2.2 为什么是 x & -x
这个式子看起来有点“魔术”,其实只是利用了补码。x 的相反数 -x 在计算机里表示为 ~x + 1,也就是把 x 的所有位取反,再加 1。
举一个例子,x = 6,二进制是 0110:
从效果上看,取反之后最低位 1 变成了 0,低位 0 变成了 1;再加 1 后,这些低位变回 0,并向高位进位。最终 x 与 -x 的按位与,只有最低位那个 1 的位置上两边都是 1,其余位置至少有一边是 0。所以 x & -x 能精确提取最低位 1。
也可以用另一种等价写法:
不过 x & -x 更简洁,也是大多数树状数组模板采用的方式。需要注意,在 Python 中整数没有固定位数,但 x & -x 依然成立;在 C++ 中要把变量类型保持为有符号整数,避免无符号类型取负时出现意外。
2.3 从二进制视角看管辖范围
每个下标 i 的管辖区间长度是 lowbit(i),这一点直接来自 Binary Indexed Tree 的构造。管辖范围的右端点是 i,左端点是 i - lowbit(i) + 1。
比如 i=12,二进制是 1100,最低位 1 在第 2 位,lowbit(12)=4,所以 c[12] 保存的是 [12-4+1, 12] = [9,12] 的和。这意味着,只要修改下标 9、10、11、12 中的任意一个位置,c[12] 都要更新。
这里有一个容易忽略的点:管辖范围长度不一定是 1、2、4、8 这种连续幂次。比如 i=10,二进制 1010,lowbit(10)=2,管辖 [9,10];i=9,二进制 1001,lowbit(9)=1,管辖 [9,9]。下标 9 同时被 c[9] 和 c[10] 覆盖,但 c[8] 却只覆盖 [1,8],所以修改 9 不会影响 c[8]。这个“谁覆盖谁”的关系,正是通过 i += lowbit(i) 访问到的“祖先节点”确定的。
3. 更新向上:单点修改为什么要往上走
3.1 一次修改的完整路径
假设要对原数组下标 pos 增加一个增量 delta。那么所有覆盖 pos 的树状数组节点都要同步加 delta。
从任意 pos 出发,往父节点走的规则是:
以 n=8、pos=3 为例:
| 当前 i | 二进制 | lowbit(i) | 更新 c[i] 后跳到的 i |
|---|---|---|---|
| 3 | 011 | 1 | 4 |
| 4 | 100 | 4 | 8 |
| 8 | 1000 | 8 | 16(超过 n,停止) |
所以修改原数组位置 3 时,需要更新的是 c[3]、c[4]、c[8]。
为什么是这三个节点?因为:
- c[3] 覆盖 [3,3];
- c[4] 覆盖 [1,4];
- c[8] 覆盖 [1,8]。
这三个区间都包含位置 3。再换一个位置,比如 pos=6,路径是 6 -> 8,也就是更新 c[6] 和 c[8]。c[6] 覆盖 [5,6],c[8] 覆盖 [1,8],都包含 6,但 c[4] 覆盖 [1,4],不包含 6,所以不需要更新。
3.2 为什么不能只改 c[pos]
如果把树状数组理解成“分块前缀和”,c[pos] 只负责以 pos 结尾的那一小段。查询前缀和 sum(pos) 时,最终会累加多个 c 节点。如果只更新 c[pos],那么查询更大的前缀和时,覆盖 pos 的祖先节点仍然是旧值,结果就会漏掉这次修改。
所以 update 的本质是:沿着“覆盖区间包含 pos 的祖先链”逐级同步。跳转规则 i += lowbit(i) 恰好给出了这条祖先链。
3.3 update 的代码与边界理解
一个常见模板如下:
这里有两个边界需要理解:
- 循环条件 i <= n,而不是无条件跳下去。因为树状数组只维护下标 1 到 n 的节点,超过 n 的节点不存在,查询范围也不会走到 n 之外。
- delta 可以是正数,也可以是负数。做“单点修改为某个值 v”时,需要先求原值 old,再 update(i, v - old)。
3.4 为什么更新复杂度是 O(log n)
这是很多初学者最想弄清楚的部分。观察 update 的跳转序列:每一步都执行 i += lowbit(i),最低位的 1 会不断向更高位移动。
比如从 3 开始:
从 6 开始:
从 9 开始:
每一次操作,i 的二进制中“最低位 1 所在的位置”都会至少向左移动一位。由于 n 的二进制位数不超过 O(log n),从任意位置出发,最多经过 O(log n) 步就会超过 n。
所以 update 的最坏时间复杂度是 O(log n)。这不是平均分析,而是严格上界。
注意:这里的 O(log n) 是基于“二进制位数”的上界。实际执行次数通常在 1 到 log2(n) 之间,远远小于遍历整个数组。这也是树状数组适合大量动态修改查询的关键原因。
4. 查询向下:前缀和为什么要往下走
4.1 查询前缀和的合并逻辑
查询前缀和 sum(x) 需要把 1..x 拆成若干个互不重叠、且都来自 c 数组的覆盖区间。拆法正好和 update 相反:
以 x=7 为例:
| 当前 i | 二进制 | lowbit(i) | 累加的 c[i] 覆盖区间 |
|---|---|---|---|
| 7 | 0111 | 1 | [7,7] |
| 6 | 0110 | 2 | [5,6] |
| 4 | 0100 | 4 | [1,4] |
| 0 | 0000 | 停止 | 无 |
结果是 c[7] + c[6] + c[4],正好等于 arr[1] + ... + arr[7]。
这个拆法的关键性质是:c 数组的覆盖区间之间存在包含和并列关系,但它们不会出现“同一个原数组位置被加两次”或“某个位置漏掉”的情况。因为每次减去 lowbit,实际上就是把当前二进制最低位的 1 去掉,剩下的部分对应尚未覆盖的前缀。
4.2 “向下”到底是怎么个向下法
这里需要澄清一个容易混淆的方向问题。如果按照数组下标递增的方向画,query 是“递减”的;但按照覆盖区间的层级画,query 是从覆盖大区间的节点走向覆盖更小且互不重叠的节点。
比如查询 sum(7) 时,第一个访问的节点是 c[7],它只覆盖 [7,7];接着访问 c[6],覆盖 [5,6];最后访问 c[4],覆盖 [1,4]。从覆盖范围看,c[4] 反而比 c[7] 大,所以“向下”不是表示下标递减,而是表示“从当前 index 的二进制最低位 1 开始,逐步向低位拆分前缀”。
更直观的理解是:把 1..x 这个范围看成一个不断缩小的二进制分段过程,每次从右端剥掉一个 lowbit 段落。所以“查询向下”这个说法,适合配合动画理解,写代码时不要只记方向,要记住“每次去掉最后一段”。
4.3 查询复杂度为什么也是 O(log n)
query 的循环条件是 i > 0,每一步都执行 i -= lowbit(i)。lowbit(i) 的作用是去掉 i 二进制中最低位的 1。例如:
每执行一次,i 的二进制表示中 1 的个数就会减少 1。一个正整数 x 的二进制最多有 O(log x) 个 1,因此查询步数最多也是 O(log n)。
区间查询 [l, r] 的和,只需要两次前缀和:
这里也要注意边界:如果 l 从 1 开始,l - 1 可能是 0,query(0) 返回 0。如果下标一开始就是 0,就会遇到死循环问题,后面会专门说明。
4.4 查询的常见边界:i=0
如果在循环条件里写 while i >= 0,那么 i 减到 0 之后,lowbit(0) 等于 0,i 会一直停留在 0,进入死循环。更稳妥的写法是 while i > 0。
| 操作 | 循环条件 | 下标变化 |
|---|---|---|
| update | while i <= n | i += lowbit(i) |
| query | while i > 0 | i -= lowbit(i) |
这张表是调试树状数组时最常用的对照表。写错方向或写错边界,往往都是因为这四条规则没有对齐。
5. 用动画思维辅助理解与最小实现
5.1 如果要做动画,重点看哪三样东西
题目里提到了“算法动画”,这个思路其实很适合辅助 debug。如果要把树状数组做成动画,我建议不要只画“下标在跳”,而是每一帧