跳转至

第 39 章 树状数组

前置:序列与数组、前缀和与差分

树状数组是区间数据结构里性价比最高的一个:几十行代码,常数极小, 支持单点改 + 区间查、区间改 + 单点查、区间改 + 区间查三种形态。

本章最重要的一条判断,请先记住:

在 Python 里,树状数组的常数比线段树小 5–10 倍。 能用树状数组解决的问题,绝对不要写线段树。

它的代价写在最后一节:信息必须可减。求和、异或可以,最值不行——那是 线段树 的地盘。选型决策表(哪种情况该用哪种结构)在 数据结构目录页。


1 从前缀和说起

静态区间和用前缀和就够了,\(O(n)\) 预处理、\(O(1)\) 查询:

from itertools import accumulate

pre = [0] + list(accumulate(a))          # C 层循环
s = pre[r + 1] - pre[l]                  # a[l..r] 的和

但前缀和不支持修改:改一个 \(a_i\),后面所有 pre 都要重算,是 \(O(n)\)。

需求 工具 单次复杂度
静态区间和 前缀和 \(O(1)\)
静态区间加、最后统一查 差分数组 \(O(1)\)
静态区间最值 ST 表 \(O(1)\)(预处理 \(O(n\log n)\))
动态:单点改 + 区间和 树状数组 \(O(\log n)\)
动态:区间改 + 区间和 双树状数组 \(O(\log n)\)
动态:区间改 + 区间最值 / 复杂信息 线段树 \(O(\log n)\)
动态:奇奇怪怪的操作 分块 \(O(\sqrt n)\)

选型顺序永远是:前缀和 → 树状数组 → 分块 → 线段树。 越往后常数越大,代码越长,出错概率越高。


2 树状数组:lowbit 树

lowbit

「lowbit」即最低位,是整型二进制表示中最后一个 1 的位置。 获得 lowbit 的方法是 x & -x。因为负数存的是补码,而补码是反码加 1。 x 末尾的 0 变成 1,lowbit 上的 1 变成 0,然后加 1,末尾的 0 进位后在 lowbit 上填 1, 而 lowbit 之前的位置 x 和 -x 恰好相反。

x & -x          # lowbit:只保留最低位的 1

Python 特别提醒:Python 的整数是无限位宽的补码,x & -x 同样成立, 而且不会有 C++ 里 int / long long 位宽不同的问题。见 运算符与位运算。

结构

树状数组 t[i] 管辖区间 \((i - \text{lowbit}(i),\ i]\),长度恰好是 \(\text{lowbit}(i)\)。

下标:      1    2    3    4    5    6    7    8
管辖:     [1]  [1,2] [3] [1..4] [5] [5,6] [7] [1..8]
lowbit:    1    2     1    4     1    2    1    8

一个形象的说法:没必要真的把树建出来,直接在原序列上做。 两条移动路线:

路线 走法 用途
向前(减 lowbit) i -= i & -i 查询前缀和
向后(加 lowbit) i += i & -i 更新祖先

每条路线最多走 \(\lfloor \log n \rfloor + 1\) 步。精确结论是:

树状数组:区间操作最多访问 \(2(\lfloor \log n \rfloor + 1)\) 个节点。 线段树:区间操作最多访问 \(4\lceil \log n \rceil\) 个节点。

同样是 \(O(\log n)\),树状数组的节点访问数只有线段树的一半, 而且每个节点只做一次加法(线段树要做 push down、区间判断、递归调用)。 这就是「常数小 5–10 倍」的来源。



3 模板:三种形态

形态一:单点修改 + 区间和查询

最基础的形态。

class BIT:
    """树状数组:单点加、前缀和查询。下标 1..n。

    单次操作 O(log n),常数极小(每步只有一次加法和一次 lowbit)。
    兼容 Python 3.9。
    """

    __slots__ = ("n", "t")                   # 固定属性表:省掉实例字典,取属性更快

    def __init__(self, n):
        self.n = n
        self.t = [0] * (n + 1)               # 下标从 1 用起;t[0] 空置,lowbit 公式才成立

    def add(self, i, v):
        """a[i] += v,O(log n)。"""
        t, n = self.t, self.n                # 提成局部变量,循环里省去每次的属性查找
        while i <= n:                        # 超过 n 就没有祖先要更新了
            t[i] += v
            i += i & -i                      # 向后:跳到下一个把 i 包含在内的更大区间
        return

    def pre(self, i):
        """返回 a[1] + ... + a[i],O(log n)。"""
        t = self.t
        s = 0
        while i > 0:                         # 走到 0 表示前缀已被完整切分完毕
            s += t[i]                        # t[i] 覆盖 (i-lowbit(i), i],各段互不重叠
            i -= i & -i                      # 向前:剥掉最低位的 1,跳到左邻那一段
        return s

    def query(self, l, r):
        """返回 a[l] + ... + a[r],O(log n)。"""
        return self.pre(r) - self.pre(l - 1)  # 靠相减取区间,故信息必须可减(见 39.6)

    def build(self, a):
        """从数组 a(1-indexed,a[0] 忽略)O(n) 建树。

        比逐个 add 的 O(n log n) 快,n 大时值得用。
        """
        t = self.t
        n = self.n
        for i in range(1, n + 1):
            t[i] += a[i]                     # 顺序遍历,走到 i 时 t[i] 已收齐全部子段
            j = i + (i & -i)                 # i 的父亲:唯一一个把 t[i] 整段包住的节点
            if j <= n:
                t[j] += t[i]                 # 整段一次性并给父亲,每个节点只被访问一次

\(O(n)\) 建树:先把 t[i] = a[i],再把每个 t[i] 累加到父亲 t[i + lowbit(i)]。 一趟循环就完成,比 \(n\) 次 add 快 \(\log n\) 倍。

形态二:区间修改 + 单点查询(差分)

在差分数组上建树状数组:\(d_i = a_i - a_{i-1}\),则 \(a_i = \sum_{j \le i} d_j\)。

class BITDiff(BIT):
    """区间加 + 单点查询。在差分数组上做树状数组。"""

    def range_add(self, l, r, v):
        """a[l..r] 全部 += v,O(log n)。"""
        self.add(l, v)                       # 差分数组从 l 起整体抬高 v
        self.add(r + 1, -v)                  # r+1 处抵消,抬高只保留在 [l, r]
                                             # r = n 时这里访问 n+1,数组必须开到 n+1

    def point(self, i):
        """返回 a[i],O(log n)。"""
        return self.pre(i)                   # 差分的前缀和就是原值,无需再减

开数组要开到 \(n+1\),否则 add(r + 1, -v) 在 \(r = n\) 时会越界(或被静默丢弃导致 WA)。

形态三:区间修改 + 区间和查询(双树状数组)

现在有个尴尬的地方:树状数组可以支持区间查询或者区间修改,但两者不能兼得。 需要一点小技巧,树状数组也可以同时支持两种操作……相当于使用了两个树状数组。

推导:设 \(d\) 是差分数组,则

\[\sum_{i=1}^{k} a_i = \sum_{i=1}^{k}\sum_{j=1}^{i} d_j = \sum_{j=1}^{k} (k - j + 1) d_j = k \sum_{j=1}^{k} d_j - \sum_{j=1}^{k} (j-1) d_j\]

所以维护两个树状数组:\(B_1\) 存 \(d_j\),\(B_2\) 存 \((j-1) d_j\)。

class BITRange:
    """区间加 + 区间和查询。双树状数组,O(log n)。

    这是 Python 里做「区间改 + 区间查」的**首选**:
    常数远小于线段树,代码只有 30 行。
    """

    __slots__ = ("n", "t1", "t2")

    def __init__(self, n):
        self.n = n + 1                       # 多留一格给 r+1
        self.t1 = [0] * (self.n + 1)         # 第一棵树:存差分 d[j]
        self.t2 = [0] * (self.n + 1)         # 第二棵树:存加权差分 (j-1) * d[j]

    def _add(self, t, i, v):
        n = self.n
        while i <= n:
            t[i] += v
            i += i & -i                      # 与形态一同一条向后路线,只是换棵树走

    def range_add(self, l, r, v):
        """a[l..r] 全部 += v,O(log n)。"""
        self._add(self.t1, l, v)             # d[l] += v
        self._add(self.t1, r + 1, -v)        # d[r+1] -= v,把影响截断在 r
        self._add(self.t2, l, v * (l - 1))   # 权重取 j-1,与下面 _pre 的推导式配套
        self._add(self.t2, r + 1, -v * r)    # 此处 j = r+1,故权重是 (r+1)-1 = r

    def _pre(self, i):
        t1, t2 = self.t1, self.t2
        s1 = s2 = 0
        j = i
        while j > 0:                         # 两棵树共用同一条向前路线,循环次数减半
            s1 += t1[j]                      # s1 累加出 sum(d[1..i])
            s2 += t2[j]                      # s2 累加出 sum((j-1) * d[j])
            j -= j & -j
        return s1 * i - s2                   # 即 i * sum(d) - sum((j-1)*d),见上面推导

    def query(self, l, r):
        """返回 a[l] + ... + a[r],O(log n)。"""
        return self._pre(r) - self._pre(l - 1)

性能技巧:_pre 里把两个树状数组放在同一个 while 循环里走, 而不是调两次 pre。循环次数减半,这在 \(n = 5\times10^5\) 时是几秒的差别。

三种形态对照

形态 修改 查询 底层存什么
形态一 单点 区间和 原数组
形态二 区间 单点 差分数组
形态三 区间 区间和 差分 + 加权差分(两棵树)


4 树状数组上的二分(倍增)

树状数组还能 \(O(\log n)\) 回答「第 \(k\) 小」「前缀和首次超过 \(x\) 的位置」。

不要写「二分答案 + 每次查前缀和」——那是 \(O(\log^2 n)\)。 正确做法是在树状数组上从高位往低位倍增试探:

def kth(bit, k):
    """在计数型树状数组上找第 k 小(k 从 1 开始),O(log n)。

    bit.t[i] 存的是「值为 (i-lowbit(i), i] 的元素个数」。
    """
    n = bit.n
    t = bit.t
    pos = 0                                  # 循环不变量:前缀 [1, pos] 的计数恒 < 原始 k
    log = n.bit_length()
    for j in range(log, -1, -1):             # 从高位到低位,逐位拼出答案的二进制
        nxt = pos + (1 << j)                 # pos 是 2^(j+1) 的倍数,故 lowbit(nxt) 恰为 1<<j
        if nxt <= n and t[nxt] < k:          # t[nxt] 正好是 (pos, nxt] 这一整段的计数
            pos = nxt                        # 整段跨过去,前缀计数仍不足 k
            k -= t[pos]                      # 扣掉跨过的部分,k 变成剩余排名
    return pos + 1                           # 退出时 pos 是最后一个前缀计数 < k 的位置

用途:动态第 \(k\) 小、有序集合的前驱后继(集合与多重集合)、 约瑟夫问题的 \(O(n\log n)\) 解法(链表)。

\(O(\log n)\) 而不是 \(O(\log^2 n)\),在 Python 里是「能过」和「TLE」的区别。 \(n = 10^5\) 时前者是 \(1.7\times10^6\) 次迭代,后者是 \(2.9\times10^7\) 次。



5 可减性:树状数组能做什么、不能做什么

树状数组靠「前缀和相减」得到区间信息,所以要求运算满足可减性(存在逆元):

信息 可减? 树状数组
求和 ✅ ✅
异或和 ✅(自身是逆元) ✅
乘积(模素数) ✅(逆元存在) ✅
最大值 / 最小值 ❌ ❌ 只能维护前缀最值,不支持任意区间
最大公约数 ❌ ❌
区间赋值 ❌ ❌

这就是线段树不可替代的地方。


6 本章速查

要点 结论
选型第一原则 能用树状数组就绝不写线段树
树状数组常数 比线段树小 5–10 倍
lowbit x & -x
向前 i -= i & -i 查前缀和
向后 i += i & -i 更新祖先
\(O(n)\) 建树 t[i] += a[i] 后累加到 t[i + lowbit(i)]
区间改 + 单点查 维护差分
区间改 + 区间和 双树状数组(\(d_j\) 和 \((j-1)d_j\))
求第 \(k\) 小 倍增,\(O(\log n)\),不是二分套查询的 \(O(\log^2 n)\)
限制 信息必须可减(求和 ✅,最值 ❌)
卡不过去时 先问能不能离线;再问是不是为了常数放弃了复杂度
数据规模 → Python 现实性
\(n, q \le 10^5\)
\(n, q \le 5\times10^5\)