307. 区域和检索 | 树状数组

21 阅读4分钟

题目描述:

给你一个数组 nums ,请你完成两类查询。

  1. 其中一类查询要求 更新 数组 nums 下标对应的值
  2. 另一类查询要求返回数组 nums 中索引 left 和索引 right 之间( 包含 )的nums元素的  ,其中 left <= right

实现 NumArray 类:

  • NumArray(int[] nums) 用整数数组 nums 初始化对象
  • void update(int index, int val) 将 nums[index] 的值 更新 为 val
  • int sumRange(int left, int right) 返回数组 nums 中索引 left 和索引 right 之间( 包含 )的nums元素的  (即,nums[left] + nums[left + 1], ..., nums[right]

根据题目的意思,总共有两个方法要实现:单点更新和区间求和,这正好符合树状数组的基本功能,因此这是一道树状数组的裸题,那我们正好可以在这复习一下树状数组的知识。

显然,对于普通数组来说,单点更新的时间复杂度是 O(1)O(1),而区间求和的时间复杂度是 O(n)O(n)。如果我们想要加快对区间求和的速度,那一个很传统的做法就是求前缀和 sumsum,此时区间 [i,j][i, j] 的和就是 sumjsumi1sum_j-sum_{i-1}。这样子区间求和的时间复杂度确实降到了 O(1)O(1),但由于单点更新时要同步更新所有该点之和的前缀和,因此单点更新的时间复杂度又变成了 O(n)O(n)

这时候,想必聪明的你已经在考虑,是否有一种折中的方案,能够让单点更新和区间求和都不要太慢,比如都在 O(logn)O(logn) 的样子呢?

按照这样的思路,我们可以把数组分成一个个块,假设每块的大小为 sizesize,那么长度为 nn 的数组就可以分成 nsize\lfloor {n \over size} \rfloor个大小相同的块。之后,我们在单点更新时,只需要更新对应的数组块,而区间查询,也最多遍历两个块即可,确实达到了我们的预期。经过计算推导可以得出,当 size=nsize=\sqrt n 时,复杂度最优,此时单点更新时间复杂度是 O(1)O(1),区间查询的时间复杂度是 O(n)O(\sqrt n)

这样的解法显然有些 “naive” 了,是否有更优的解法呢?答案是肯定的,这时就轮到大名鼎鼎的树状数组登场了!具体思路是用一个数组 CC 维护若干个小区间,单点修改时,只更新包含这一元素的区间;求前n项和时,通过将区间进行组合,得到从 1 到 n 的区间,然后对所有用到的区间求和

如何确定 CiC_i 的范围呢?树状数组巧妙利用了二进制,用 CiC_i 维护区间 (Ailowbit(Ai),Ai](A_i-lowbit(A_i),A_i]。例如 C11C_{11} 维护 (A10,A11](A_{10},A_{11}]C10C_{10} 维护 (A8,A10](A_8,A_{10}]。这样子划分后,查询前 n 项和时需要合并的区间数是少于 lognlogn 的。

image.png

那如何进行更新操作呢? 其实这是一个从叶子结点爬到根节点的过程。按照一开始的想法,当更新 AiA_i 时,只需要更新包含了 AiA_iCjC_j 即可。以 A3A_3 为例,如图所示,包含了 A3A_3 的区间和包括 C3C_3C4C_4C8C_8,因此只需要更新这三个即可。

爬树的过程是如何计算的? 对于当前所在位置 ii,只需要不断加上 lowbit(i)lowbit(i) 即可。例如初始 i=3i=3,然后是 3+lowbit(3)=43+lowbit(3)=4,然后是 4+lowbit(4)=84+lowbit(4)=8 ......

image.png

至此我们就可以简单实现一个树状数组了。

class NumArray {
    private int[] nums;
    private int[] tree;

    public NumArray(int[] nums) {
        int n = nums.length;
        this.nums = new int[n];
        tree = new int[n + 1];
        // 初始化树状数组
        for(int i = 0; i < n; i++) {
            update(i, nums[i]);
        }
    }
    
    public void update(int index, int val) {
        int delta = val - nums[index];
        nums[index] = val;
        // 从index一路往上更新
        for(int i = index + 1; i < tree.length; i += i & -i) {
            tree[i] += delta;
        }
    }
    
    private int prefixSum(int index) {
        int sum = 0;
        // 从index一路往下求和
        for(int i = index; i > 0; i -= i & -i) {
            sum += tree[i];
        }
        return sum;
    }

    public int sumRange(int left, int right) {
        return prefixSum(right + 1) - prefixSum(left);
    }
}

/**
 * Your NumArray object will be instantiated and called as such:
 * NumArray obj = new NumArray(nums);
 * obj.update(index,val);
 * int param_2 = obj.sumRange(left,right);
 */

lowbitlowbit的作用

从正文中可以看出,lowbitlowbit 是树状数组实现的一个关键,那 lowbitlowbit 是什么,为啥这么有用呢?

lowbit(x)lowbit(x) 计算得到的是 xx 的二进制表示中,最后一个 11 的位置,例如 lowbit(1011)=1lowbit(1011)=1lowbit(1100)=100lowbit(1100)=100 等。

lowbitlowbit 的计算公式很简单:lowbit(x)=x&xlowbit(x)=x\&-x。原理是在计算机中,负数用补码表示,即原码取反加一。因此 x 和 -x 仅从右往左第一个 ‘1’ 的位置是相同的。如下图:

image.png


参考:算法学习笔记(2) : 树状数组 - 知乎 (zhihu.com)