什么是树状数组

树状数组是一种高效的数据结构,主要用来解决以下两种常见问题:

  • 单点更新:修改数组中某个位置的值(比如把第 3 个元素加 2)。
  • 前缀和查询:计算数组从开头到某个位置的和(比如第 1 到第 5 个元素的和)。

在普通数组中,这两个操作的时间复杂度通常是“此消彼长”的:

  • 如果直接用原始数组,单点更新是 O(1),但前缀和查询需要遍历数组,时间复杂度是 O(n)。
  • 如果用一个前缀和数组(提前计算每段的和),前缀和查询是 O(1),但单点更新需要修改后面所有的前缀和,时间复杂度是 O(n)。

树状数组的厉害之处在于,它能让这两种操作都达到 O(log n) 的时间复杂度,既高效又平衡,非常适合动态维护数组数据的场景。


树状数组的原理

树状数组的核心是通过一个辅助数组(我们叫它 tree),把原始数组的元素组织成一种“树形结构”。但它并不是真正的树,而是利用数字的二进制特性来管理区间和。

Lowbit 函数

要理解树状数组,首先要搞清楚一个关键函数:lowbit(x)。它返回的是一个数的二进制表示中“最低位的 1”所对应的值。怎么理解呢?我们看几个例子:

  • 对于数字 6,二进制是 110,最低位的 1 在第 2 位(从右往左数),对应值是 2(二进制 10),所以 lowbit(6) = 2。
  • 对于数字 4,二进制是 100,最低位的 1 在第 3 位,对应值是 4(二进制 100),所以 lowbit(4) = 4。
  • 对于数字 3,二进制是 11,最低位的 1 在第 1 位,对应值是 1(二进制 1),所以 lowbit(3) = 1。

计算方法:在计算机中,lowbit(x) 可以用 x & -x 来快速计算。

Lowbit 的作用:它决定了树状数组中每个节点负责的区间范围,是更新和查询时的“步长”。

树状数组的结构

假设我们有一个原始数组 arr = [a1, a2, a3, ..., an](为了方便,下标从 1 开始,而不是 0),树状数组用一个数组 tree[1..n] 来存储信息。关键点在于:

  • tree[i] 负责存储原始数组中一段区间的和,具体是从位置 i - lowbit(i) + 1 到 i 的和。
  • 例如:
    • tree[1] 负责 [1, 1](因为 lowbit(1) = 1,区间长度是 1)。
    • tree[2] 负责 [1, 2](因为 lowbit(2) = 2,区间长度是 2)。
    • tree[4] 负责 [1, 4](因为 lowbit(4) = 4,区间长度是 4)。

这种结构让每个 tree[i] 管理一个长度为 lowbit(i) 的区间,而这些区间通过二进制的跳跃关系巧妙地覆盖整个数组。


树状数组的操作

单点更新(Update)

假如我们想把原始数组中位置 i 的值增加一个数 delta(比如把 arr[2] 加 2),需要更新树状数组中所有包含 i 的节点。具体步骤是:

  1. 从位置 i 开始。
  2. 把 tree[i] 增加 delta。
  3. 计算下一步:i = i + lowbit(i),跳到下一个需要更新的节点。
  4. 重复步骤 2 和 3,直到 i 超过数组长度 n。
为什么这样更新?
  • 每次 i += lowbit(i) 会跳到包含当前区间的一个更大的区间。
  • 比如更新 i = 1,会影响:
    • tree[1](负责 [1, 1]),
    • 然后 i = 1 + lowbit(1) = 2,更新 tree[2](负责 [1, 2]),
    • 再 i = 2 + lowbit(2) = 4,更新 tree[4](负责 [1, 4]),依此类推。

这样,所有的“上级节点”都会感知到这个变化。

前缀和查询(Query)

假如我们想计算从 1 到位置 i 的前缀和,步骤是:

  1. 初始化一个变量 sum = 0。
  2. 从位置 i 开始。
  3. 把 tree[i] 加到 sum 上。
  4. 计算下一步:i = i - lowbit(i),跳到前一个区间。
  5. 重复步骤 3 和 4,直到 i 变成 0。
为什么这样查询?
  • 每次 i -= lowbit(i) 会跳到覆盖更小区间的节点,最终把所有覆盖 [1, i] 的区间和加起来。
  • 比如查询 i = 3 的前缀和:
    • tree[3](负责 [3, 3]),
    • i = 3 - lowbit(3) = 2,tree[2](负责 [1, 2]),
    • 加起来就是 [1, 3] 的和。

Java 实现

下面是一个完整的 Java 代码示例,展示了如何构建树状数组、执行单点更新和前缀和查询。

class FenwickTree {
    private int[] tree; // 树状数组
    private int n;      // 数组大小

    // 构造函数:初始化树状数组
    public FenwickTree(int size) {
        n = size;
        tree = new int[n + 1]; // 下标从 1 开始
    }

    // 单点更新:在位置 i 增加 delta
    public void update(int i, int delta) {
        while (i <= n) {
            tree[i] += delta;
            i += lowbit(i); // 跳转到下一个需要更新的位置
        }
    }

    // 前缀和查询:计算从 1 到 i 的和
    public int query(int i) {
        int sum = 0;
        while (i > 0) {
            sum += tree[i];
            i -= lowbit(i); // 跳转到前一个区间
        }
        return sum;
    }

    // 计算 lowbit(x)
    private int lowbit(int x) {
        return x & -x;
    }
}

// 测试代码
public class Main {
    public static void main(String[] args) {
        int[] arr = {0, 1, 3, 5, 7, 9, 11}; // 原始数组,下标从 1 开始
        FenwickTree fenwick = new FenwickTree(arr.length - 1);

        // 构建树状数组
        for (int i = 1; i < arr.length; i++) {
            fenwick.update(i, arr[i]);
        }

        // 查询前缀和 [1, 3]
        System.out.println(fenwick.query(3)); // 输出 9 (1+3+5)

        // 更新 arr[2] += 2,使 arr[2] 从 3 变为 5
        fenwick.update(2, 2);

        // 再次查询前缀和 [1, 3]
        System.out.println(fenwick.query(3)); // 输出 11 (1+5+5)
    }
}

具体例子

假设我们有一个原始数组 arr = [1, 3, 5, 7, 9, 11](下标从 1 到 6)。

构建树状数组

我们根据规则计算 tree 数组:

  • tree[1] = arr[1] = 1(负责 [1, 1])。
  • tree[2] = arr[1] + arr[2] = 1 + 3 = 4(负责 [1, 2])。
  • tree[3] = arr[3] = 5(负责 [3, 3])。
  • tree[4] = arr[1] + arr[2] + arr[3] + arr[4] = 1 + 3 + 5 + 7 = 16(负责 [1, 4])。
  • tree[5] = arr[5] = 9(负责 [5, 5])。
  • tree[6] = arr[5] + arr[6] = 9 + 11 = 20(负责 [5, 6])。

初始状态:

arr  = [1, 3, 5, 7, 9, 11]
tree = [1, 4, 5, 16, 9, 20]

查询前缀和

  • 查询 [1, 3] 的和
    • 从 i = 3 开始:
      • tree[3] = 5(负责 [3, 3]),
      • i = 3 - lowbit(3) = 3 - 1 = 2,tree[2] = 4(负责 [1, 2]),
      • i = 2 - lowbit(2) = 2 - 2 = 0,停止。
    • sum = 5 + 4 = 9,正确(1 + 3 + 5 = 9)。
  • 查询 [1, 6] 的和
    • 从 i = 6 开始:
      • tree[6] = 20(负责 [5, 6]),
      • i = 6 - lowbit(6) = 6 - 2 = 4,tree[4] = 16(负责 [1, 4]),
      • i = 4 - lowbit(4) = 4 - 4 = 0,停止。
    • sum = 20 + 16 = 36,正确(1 + 3 + 5 + 7 + 9 + 11 = 36)。

更新元素

  • 把 arr[2] 增加 2(从 3 变成 5)
    • 从 i = 2 开始:
      • tree[2] = 4 + 2 = 6,
      • i = 2 + lowbit(2) = 2 + 2 = 4,tree[4] = 16 + 2 = 18,
      • i = 4 + lowbit(4) = 4 + 4 = 8,超出范围,停止。
    • 更新后:
arr  = [1, 5, 5, 7, 9, 11]
tree = [1, 6, 5, 18, 9, 20]

再次查询 [1, 3]

  • i = 3:
    • tree[3] = 5,
    • i = 3 - 1 = 2,tree[2] = 6,
    • i = 2 - 2 = 0,停止。
  • sum = 5 + 6 = 11,正确(1 + 5 + 5 = 11)。

图解:直观理解结构

以 n = 8 为例,树状数组的结构可以用下面这张简化的图表示(括号里是负责的区间):

       tree[8] (1-8)
      /         \
tree[4] (1-4)   tree[6] (5-6)
 /    \         /    \
tree[2] (1-2) tree[3] (3-3) tree[5] (5-5) tree[7] (7-7)
 /  \
tree[1] (1-1) tree[2] (1-2)
  • 每个节点负责一段区间,区间长度由 lowbit 决定。
  • 查询和更新时,通过 lowbit 的加减操作在树中跳跃。

为什么高效?

树状数组的更新和查询都是 O(log n),因为:

  • 每次跳跃(i += lowbit(i) 或 i -= lowbit(i)),相当于把 i 的二进制最低位清零或改变,最多跳 log n 次。
  • 比如 n = 8 时,最多跳 3 次(因为 8 = 2^3)。

相比之下,线段树也能做到 O(log n),但树状数组更简单、占内存更少,非常适合前缀和相关问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值