每日算法 — 使用java实现树状数组:动态前缀和与区间频率统计

在算法竞赛和工程实践中,频繁遇到一个经典问题:给定一个长度为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] += deltad[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 < jarr[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)解决。