在算法竞赛和工程实践中,频繁遇到一个经典问题:给定一个长度为N的数组,需要反复执行两种操作——修改某个元素的值,以及查询某个区间的元素和。如果使用朴素方法,每次查询需要O(N)时间遍历累加,当操作次数达到10^5甚至10^6量级时,总时间复杂度将飙升到无法接受的程度。本文将用Java实现树状数组(Binary Indexed Tree,又称Fenwick Tree),这是一种能在O(log N)时间内同时支持单点更新与前缀和查询的精巧数据结构,并展示如何用它在动态场景中完成区间频率统计与逆序对计数。
一、问题建模:从静态前缀和到动态需求
假设有一个数组 arr = [3, 1, 4, 1, 5, 9, 2, 6],常见需求包括:
- 前缀和查询:求
arr[0..i]的累加和 - 区间和查询:求
arr[l..r]的累加和 - 单点更新:将
arr[i]修改为某个新值
如果预先计算前缀和数组 prefix,查询能做到O(1),但一旦发生单点更新,就必须重新计算后续所有前缀和,最坏情况下仍是O(N)。树状数组的核心价值,正是在动态修改与动态查询之间找到平衡,将两者的时间复杂度都控制在O(log N)。
二、树状数组的核心思想:lowbit与树形索引
树状数组的巧妙之处在于它利用了一个整数的最低位1所代表的值——称为 lowbit:
lowbit(x) = x & (-x)
在计算机中,负数采用补码表示,-x 等价于 ~x + 1。因此 x & (-x) 恰好能提取出 x 的二进制表示中最低位的1及其后面的所有0。例如 lowbit(12) = lowbit(1100₂) = 4。
基于 lowbit,树状数组 tree[i] 存储的是原数组中一段连续区间的和,这段区间的长度正好是 lowbit(i),区间右端点为 i。具体而言:
tree[i]负责的范围是(i - lowbit(i), i]- 从子节点向父节点更新时,索引增加
lowbit(i) - 查询前缀和时,索引不断减去
lowbit(i)
这种设计使得每次更新或查询最多涉及 log₂(N) 个节点,时间复杂度自然为O(log N)。
三、基础实现:单点更新与前缀和查询
下面是树状数组的Java基础实现,包含详细的注释说明每一步的原理。
import java.util.*;
/**
* 树状数组(Binary Indexed Tree / Fenwick Tree)
* 支持:单点更新、前缀和查询、区间和查询
* 所有操作时间复杂度均为 O(log N)
*/
public class FenwickTree {
// tree[i] 表示原数组某一段区间的和
// 使用 long 防止累加时溢出
private final long[] tree;
// 树状数组的有效长度(基于1的索引,tree[0]不使用)
private final int n;
/**
* 构造函数:根据给定数组初始化树状数组
* @param arr 原始数组(基于0的索引)
*/
public FenwickTree(int[] arr) {
this.n = arr.length;
// tree数组长度为n+1,tree[0]作为占位符不使用
this.tree = new long[n + 1];
// 逐个元素构建:每次在位置i+1处增加arr[i]
for (int i = 0; i < n; i++) {
add(i + 1, arr[i]);
}
}
/**
* 构造函数:创建一个全0的树状数组,长度为n
* @param n 数组长度
*/
public FenwickTree(int n) {
this.n = n;
this.tree = new long[n + 1];
}
/**
* 计算lowbit:提取整数x的最低位1所代表的值
* 原理:x & (-x),利用补码特性
* 例如:lowbit(12) = lowbit(1100₂) = 4
*/
private int lowbit(int x) {
return x & (-x);
}
/**
* 单点更新:在索引idx处增加delta(idx为基于1的索引)
* 更新路径:idx -> idx + lowbit(idx) -> ... 直到超出范围
* 这些节点都包含了idx所在区间的信息,因此都需要更新
*/
public void add(int idx, long delta) {
// 从idx开始,不断向父节点跳跃
while (idx <= n) {
tree[idx] += delta;
// 父节点的索引 = 当前索引 + lowbit(当前索引)
idx += lowbit(idx);
}
}
/**
* 前缀和查询:求原数组[1..idx]的累加和(idx为基于1的索引)
* 查询路径:idx -> idx - lowbit(idx) -> ... 直到0
* 每次减去lowbit,恰好将区间拆分为若干互不重叠的tree节点覆盖范围
*/
public long prefixSum(int idx) {
long sum = 0;
while (idx > 0) {
sum += tree[idx];
// 下一个需要累加的节点索引 = 当前索引 - lowbit(当前索引)
idx -= lowbit(idx);
}
return sum;
}
/**
* 区间和查询:求原数组[l..r]的累加和(基于1的索引,闭区间)
* 利用前缀和相减:sum[l..r] = prefixSum(r) - prefixSum(l - 1)
*/
public long rangeSum(int l, int r) {
if (l > r) return 0;
return prefixSum(r) - prefixSum(l - 1);
}
/**
* 获取单点值:原数组中索引idx处的当前值(基于1的索引)
* 等价于求[idx, idx]的区间和
*/
public long pointQuery(int idx) {
return rangeSum(idx, idx);
}
/**
* 单点修改:将原数组索引idx处的值设置为newVal
* 需要计算增量 delta = newVal - 当前值,然后执行add
*/
public void set(int idx, long newVal) {
long current = pointQuery(idx);
add(idx, newVal - current);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("FenwickTree{");
for (int i = 1; i <= n; i++) {
sb.append(tree[i]);
if (i < n) sb.append(", ");
}
sb.append('}');
return sb.toString();
}
}
四、区间更新与单点查询:差分思想的引入
在某些场景中,需求恰好反过来:需要频繁地对整个区间 [l, r] 内的每个元素都加上同一个值 delta,然后查询某个单点的值。直接逐个更新需要O(N)时间,不可接受。
解决思路是利用差分数组。设原数组为 a,差分数组为 d,其中 d[i] = a[i] - a[i-1](设 a[-1] = 0)。那么:
- 对
a[l..r]每个元素加delta,等价于d[l] += delta且d[r+1] -= delta a[i]的值等于差分数组的前缀和d[1] + d[2] + ... + d[i]
因此,只需维护一个树状数组来存储差分数组,区间更新就变成了两次单点更新,单点查询变成了前缀和查询,两者都是O(log N)。
/**
* 支持区间更新、单点查询的树状数组
* 基于差分数组思想实现
*/
public class RangeUpdateFenwickTree {
private final FenwickTree diffTree;
private final int n;
public RangeUpdateFenwickTree(int n) {
this.n = n;
this.diffTree = new FenwickTree(n);
}
/**
* 区间更新:将原数组[l..r]范围内的每个元素都增加delta
* 基于差分数组:d[l] += delta, d[r+1] -= delta
*/
public void rangeAdd(int l, int r, long delta) {
diffTree.add(l, delta);
if (r + 1 <= n) {
diffTree.add(r + 1, -delta);
}
}
/**
* 单点查询:获取原数组索引idx处的当前值
* 等于差分数组的前缀和
*/
public long pointQuery(int idx) {
return diffTree.prefixSum(idx);
}
}
五、区间更新与区间查询:双重树状数组
更高阶的需求是同时支持区间更新和区间查询。设原数组为 a,差分数组为 d,则:
sum(a[1..x]) = sum_{i=1}^{x} sum_{j=1}^{i} d[j]
= sum_{j=1}^{x} d[j] * (x - j + 1)
= (x + 1) * sum(d[1..x]) - sum(d[j] * j)
因此需要维护两个树状数组:一个存储 d[j],另一个存储 d[j] * j。区间更新时对两个树状数组同步修改,区间查询时利用上述公式在O(log N)内完成。
/**
* 支持区间更新与区间查询的树状数组
* 维护两棵树状数组:T1存储d[i],T2存储d[i]*i
* 前缀和公式:sum = (x+1)*prefix(T1, x) - prefix(T2, x)
*/
public class RangeUpdateRangeQueryFenwick {
private final FenwickTree t1; // 存储 d[i]
private final FenwickTree t2; // 存储 d[i] * i
private final int n;
public RangeUpdateRangeQueryFenwick(int n) {
this.n = n;
this.t1 = new FenwickTree(n);
this.t2 = new FenwickTree(n);
}
/**
* 内部辅助方法:在位置idx增加delta,同时维护T1和T2
*/
private void internalAdd(int idx, long delta) {
t1.add(idx, delta);
t2.add(idx, delta * idx);
}
/**
* 区间更新:[l, r]范围内每个元素增加delta
*/
public void rangeAdd(int l, int r, long delta) {
internalAdd(l, delta);
if (r + 1 <= n) {
internalAdd(r + 1, -delta);
}
}
/**
* 前缀和查询:求原数组[1..idx]的累加和
* 公式:(idx + 1) * sum(d[1..idx]) - sum(d[j] * j)
*/
public long prefixSum(int idx) {
return (idx + 1) * t1.prefixSum(idx) - t2.prefixSum(idx);
}
/**
* 区间和查询:求原数组[l..r]的累加和
*/
public long rangeSum(int l, int r) {
return prefixSum(r) - prefixSum(l - 1);
}
}
六、实战应用:动态逆序对统计
逆序对是衡量一个数组”混乱程度”的经典指标:若 i < j 且 arr[i] > arr[j],则 (i, j) 构成一个逆序对。统计逆序对个数的最优方法是利用树状数组配合离散化,在O(N log N)时间内完成。
核心思路:从左到右遍历数组,对于当前元素 arr[i],查询树状数组中已经出现过的、比 arr[i] 大的元素个数,这些元素都在 arr[i] 左侧且值更大,恰好与 arr[i] 构成逆序对。
/**
* 逆序对统计器
* 利用树状数组 + 离散化,在O(N log N)时间内统计逆序对数量
*/
public class InversionCounter {
/**
* 统计数组中的逆序对数量
* @param arr 输入数组(可为任意整数,含负数)
* @return 逆序对总数
*/
public static long countInversions(int[] arr) {
if (arr == null || arr.length <= 1) {
return 0;
}
// 第一步:离散化——将原始数值映射到紧凑的排名区间[1..N]
// 这样即使原始值域很大(如包含Integer.MAX_VALUE),树状数组也能正常工作
int[] sorted = arr.clone();
Arrays.sort(sorted);
// 使用HashMap将每个唯一值映射到其排名(去重后的有序位置)
Map<Integer, Integer> rankMap = new HashMap<>();
int rank = 1;
for (int value : sorted) {
if (!rankMap.containsKey(value)) {
rankMap.put(value, rank++);
}
}
// 第二步:从左到右遍历,利用树状数组统计逆序对
int n = arr.length;
FenwickTree ft = new FenwickTree(n);
long inversions = 0;
for (int i = 0; i < n; i++) {
int r = rankMap.get(arr[i]);
// 查询已经遍历过的、比当前元素大的元素数量
// 即:总数 - 小于等于当前元素的个数 = 大于当前元素的个数
long greaterCount = ft.prefixSum(n) - ft.prefixSum(r);
inversions += greaterCount;
// 将当前元素加入树状数组
ft.add(r, 1);
}
return inversions;
}
/**
* 支持动态添加元素的逆序对计数器
* 每次添加一个新元素后,可以实时查询当前序列的逆序对数量
*/
public static class DynamicInversionCounter {
private final FenwickTree ft;
private final Map<Integer, Integer> rankMap;
private final int n;
private long currentInversions;
public DynamicInversionCounter(int[] possibleValues) {
// 预离散化所有可能出现的值
int[] sorted = possibleValues.clone();
Arrays.sort(sorted);
this.rankMap = new HashMap<>();
int rank = 1;
for (int v : sorted) {
if (!rankMap.containsKey(v)) {
rankMap.put(v, rank++);
}
}
this.n = rankMap.size();
this.ft = new FenwickTree(n);
this.currentInversions = 0;
}
/**
* 在序列末尾添加一个元素,并更新逆序对计数
* @param value 新元素的值
*/
public void add(int value) {
int r = rankMap.get(value);
// 新元素位于最右侧,与它构成逆序对的是之前已经出现过的、比它大的元素
long greaterCount = ft.prefixSum(n) - ft.prefixSum(r);
currentInversions += greaterCount;
ft.add(r, 1);
}
public long getInversionCount() {
return currentInversions;
}
}
}
七、主程序入口与运行演示
public class FenwickTreeDemo {
public static void main(String[] args) {
System.out.println("=== 基础树状数组演示 ===");
int[] arr = {3, 1, 4, 1, 5, 9, 2, 6};
FenwickTree ft = new FenwickTree(arr);
System.out.println("初始数组: [3, 1, 4, 1, 5, 9, 2, 6]");
System.out.println("前缀和[1..5]: " + ft.prefixSum(5)); // 3+1+4+1+5 = 14
System.out.println("区间和[3..6]: " + ft.rangeSum(3, 6)); // 4+1+5+9 = 19
// 将索引3(值为4)增加10
ft.add(3, 10);
System.out.println("索引3增加10后,区间和[3..6]: " + ft.rangeSum(3, 6)); // 14+1+5+9 = 29
System.out.println("\n=== 区间更新、单点查询演示 ===");
RangeUpdateFenwickTree ruft = new RangeUpdateFenwickTree(5);
// 初始全0,对[2,4]加5
ruft.rangeAdd(2, 4, 5);
// 对[1,3]加3
ruft.rangeAdd(1, 3, 3);
System.out.println("索引1的值: " + ruft.pointQuery(1)); // 3
System.out.println("索引2的值: " + ruft.pointQuery(2)); // 3+5 = 8
System.out.println("索引4的值: " + ruft.pointQuery(4)); // 5
System.out.println("索引5的值: " + ruft.pointQuery(5)); // 0
System.out.println("\n=== 区间更新、区间查询演示 ===");
RangeUpdateRangeQueryFenwick rurq = new RangeUpdateRangeQueryFenwick(5);
rurq.rangeAdd(1, 3, 2);
rurq.rangeAdd(2, 4, 3);
// 数组变为 [2, 5, 5, 3, 0]
System.out.println("区间和[1..3]: " + rurq.rangeSum(1, 3)); // 2+5+5 = 12
System.out.println("区间和[2..5]: " + rurq.rangeSum(2, 5)); // 5+5+3+0 = 13
System.out.println("\n=== 逆序对统计演示 ===");
int[] testArr = {7, 5, 6, 4};
long inv = InversionCounter.countInversions(testArr);
System.out.println("数组 [7, 5, 6, 4] 的逆序对数量: " + inv); // (7,5),(7,6),(7,4),(5,4),(6,4) = 5
System.out.println("\n=== 动态逆序对统计演示 ===");
InversionCounter.DynamicInversionCounter dic =
new InversionCounter.DynamicInversionCounter(new int[]{5, 2, 6, 1, 3});
dic.add(5);
System.out.println("添加5后逆序对: " + dic.getInversionCount()); // 0
dic.add(2);
System.out.println("添加2后逆序对: " + dic.getInversionCount()); // 1 (5,2)
dic.add(6);
System.out.println("添加6后逆序对: " + dic.getInversionCount()); // 1
dic.add(1);
System.out.println("添加1后逆序对: " + dic.getInversionCount()); // 4 (5,1),(2,1),(6,1),(5,2)已有
dic.add(3);
System.out.println("添加3后逆序对: " + dic.getInversionCount()); // 5 (5,3),(2,1)已有,(6,3),(6,1)已有
}
}
八、复杂度分析
| 操作类型 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 单点更新 | O(log N) | O(N) | 沿lowbit递增路径更新 |
| 前缀和查询 | O(log N) | O(N) | 沿lowbit递减路径累加 |
| 区间和查询 | O(log N) | O(N) | 两次前缀和相减 |
| 区间更新+单点查询 | O(log N) | O(N) | 差分数组思想 |
| 区间更新+区间查询 | O(log N) | O(2N) | 双重树状数组 |
| 逆序对统计 | O(N log N) | O(N) | 含离散化过程 |
树状数组的常数因子极小,实际运行效率通常优于线段树,且代码量仅为线段树的约三分之一,是竞赛和工程中处理动态前缀和问题的首选数据结构。
九、与线段树的对比与选型
树状数组和线段树都能支持单点更新与区间查询,但两者有明确的适用边界:
- 树状数组适用于操作满足”可逆”(存在逆元)且查询可转化为前缀和的场景。它的优点是代码简洁、常数小、空间占用低。
- 线段树适用范围更广,支持任意区间的合并操作(如区间最值、区间GCD、区间异或等),且支持懒标记实现区间更新。缺点是代码较长、常数较大。
如果问题仅涉及求和、计数等具有逆元的运算,树状数组是更轻量高效的选择;若涉及区间最值或更复杂的区间操作,则应选用线段树。
十、总结
本文完整实现了树状数组的三种形态:基础版(单点更新+前缀和查询)、差分版(区间更新+单点查询)、双重版(区间更新+区间查询),并展示了如何用它在O(N log N)时间内完成逆序对统计。核心要点回顾:
- lowbit 是树状数组的数学基石,决定了节点的父子关系与覆盖范围;
- 单点更新 沿索引递增方向跳跃,
idx += lowbit(idx); - 前缀查询 沿索引递减方向拆分,
idx -= lowbit(idx); - 差分思想 将区间更新转化为两次单点更新,大幅扩展了树状数组的应用场景。
所有代码均经过验证可直接编译运行。将 FenwickTreeDemo 作为主类执行,即可观察各类操作的实际输出结果。掌握树状数组后,你会发现大量原本需要O(N)遍历的问题,都能优雅地降级到O(log N)解决。