树状数组原理

树状数组及代码详解

树状数组作为一种较为"高级"的数据结构,可以同时实现log(n)级别的修改和求和操作,之前看了很多遍原理没能坚持下去,这次总算通过leetcode每日一题了解了,记录一下自己的感悟。

1.前缀和问题

很多时候解决数组问题时,都要考虑使用前缀和,即一个顺序储存结构的前N项之和。

int[] arr = {1,3,4,5,2,3,7};
int[] sumArr = new int[arr.length];
sumArr[0] = arr[0];
for(int i = 1;i< arr.length;i++){
	sumArr[i] = sumArr[i - 1] + arr[i];
}

如上图中代码所示,sumArr中储存的就是arr的前缀和,这个结构有什么优势呢?
很明显,如果我们要求区间**(l ,r]**的和,我们只需要用sumArr[r] - sumArr[l]就可以了。此时这个查询操作的时间复杂度是o(1),很符合高效的要求。
但是,如果我们此时更改了arr数组中任意一个值,sumArr应该怎么更新呢?

假设我们执行了操作arr[k] += num; 
则sumArr更新:
for(int i = k;i<arr.length;i++){
	sumArr[i] += num;
}

如上图,因为k位置的更新会对k之后的前缀和都产生影响,此时更新sumArr操作的复杂度就变成了o(n)级别的了,这对于大规模的数据来说是不可以承受的,所以我们需要一种新的数据结构来储存现有的信息,以供查询和修改。

2.构建新的数组

信息量的问题

针对sumArr,我们知道当更新arr[k]时,k之后的所有位置的值都会受到影响。把这个影响画成树的话就是下图:
在这里插入图片描述
由上图可知4,8,13这几个值是级联的,前面的数修改了,后面的值也必须修改,这样的的结构sum数组每个位置都携带了前面所有位置的信息。那我们是否可以构建一种数据结构,让每个位置携带的信息多一点,求区间和时不必依赖前面所有位置呢?
确实是可以的,我们一步一步来,首先既然一个位置储存一个元素的和太少了,那我隔一个位置就储存两个元素的和是不是就更快了呢?
在这里插入图片描述
设新数组为C,我们更改下标为 i 元素的值,那么下标为i + 1元素的值也需要更改(i为偶数则只要改自己)。
而求[i, j]的和也只要隔两位加就行(比如求sum(3,6),只要求C[4] + C[6]就行)。这样求和时间降为O(n/2),这样虽然更快了 但是还不够快。
那我们继续想 既然一个位置可以储存2个数的和那么是不是一个位置也可以储存4个数的和 8个数的和,16个数的和呢我们试试看4:
在这里插入图片描述
再试试8:
在这里插入图片描述

  其实到这里 我们的思路就已经逐渐明确了,我们一直往上递增可以储存的数的大小,直到2 ^ k < 数组的大小n。求和时间会一直降低直到 log(n),而修改时间每次+1也是log(n)

构建树状数组

在这里插入图片描述
  如上图所示 设红色数组为C,蓝色数组为A
C[1] = A[1]
C[2] = C[1] + A[2] = A[1] + A[2]
C[3] = A[3]
C[4] = A[4] + C[3] + C[2] = A[4] + A[3] + A[2] + A[1]
C[5] = A[5]
C[6] = A[6] + C[5] = A[6] + A[5]
C[7] = A[7]
C[8] = A[8] + C[7] + C[6] + C[4] = A[8] + A[7] + A[6] + A[5] + A[4] + A[3] + A[2] + A[1]

从上面的递推式不难看出,C数组每一个元素包含A数组数据个数为
1 2 1 4 1 2 1 8
如果长度继续增加,包含数据的和的个数将会变成
1 2 1 4 1 2 1 8 1 2 1 4 1 2 1 16
那么究竟改变A数组某个值到底会影响哪几个元素呢?
说通俗点的规律是这样的:
  我们从下标为0开始 包含数据量位1的元素是普通群众,他们只能管自己。每2个普通群众就有一个1级管理员,他们要管理自己和前面一个群众的值。每2个1级管理员就要有2级管理员,不但要管理自己的群众,还要管理前方比他权限低的1级管理员,每2个二级管理员又要诞生一个3级管理员负责管理隶属自己的群众,2级管理员,1级管理员。以此类推。
  那么怎么判断一个数到底是群众还是管理员,是第几级的管理员呢,这个就要用到二进制的概念了。我们将最低一位1的位数作为其权限标准。比如0001 和 1001 他们最低一位1是第4位,所以他们就是群众,只能管理自己。而1010 1110 最低位的1是第三位 他们就是1级管理员。

  判断出了身份之后我们也就可以通过身份去改变数组了。我们假设现在数组长度为8,也就是二进制一共4位。
  当群众的值发生改变时首先影响自己。然后影响管理他的1级管理员。因为群众的最低位的1为0001,所以他影响的1级管理员为群众编号 + 0001(只要走1步就可以找到他的1级管理员)
  当1级管理员的值发生改变时首先影响自己。然后影响管理他的2级管理员。因为1级管理员的最低位的1为0010,所以他影响的2级管理员为群众编号 + 0010(要走2步才可以到达)
  当2级管理员的值发生改变时首先影响自己。然后影响管理他的3级管理员。因为2级管理员的最低位的1为0100,所以他影响的2级管理员为群众编号 + 0100(要走4步才可以到达)
以次类推,我们可以总结:
  当A数组的下标为i的数据发生变化时,所影响的数据为 i 和 i + 2^k ,k为当前数的二进制中1出现的最低位。

下标为1举例,当A[1] 发生变化,则 0001 + 0001 = 0010 = C[2]也要变化
因为C[2] 发生变化,则0010 + 0010 = 0100 = C[4]也要变化,
因为C[4] 发生变化,则0100 + 0100 = 1000 = C[8]也要变化。

3.由新构建的数组获取前缀和

   依靠上面所说的规律,我们预处理出了一个数组C,每当我们更改A数组的值时都可以在log(n)的时间复杂度之内更改C的值;那么如何通过C得到我们想要的前缀和数组呢?
   还是以群众和管理员作为例子,我们的出的结论是:下标为i的A数组前缀和 = C数组中i本身的值 + i 之前所有的高于自己的管理员的值。
为什么这么说呢?我们可以看到,每一个数都可以分解成2^k1 + 2^k2+…个数之和。
   比如7 就可以分解为4 + 2 + 1。所以我们求前7个数的和,只要知道前4个数的和+第5个和第6个数的和+第7个数 。
  6分解为4 + 2,也就是前四个数 + 第5和第6个数的和。
看到这个分解,再联想我们的管理员机制,有没有一点悟了?
  没错,7在我们的机制中,是群众,他前面的1级管理员正好是6,6管理着5和6,再前面的2级管理员正好是4而4管理了1,2,3,4的元素
  所以我们只要找到第i个数之前所有等级大于他的管理员,加上去就可以得到前缀和sum(i);

怎么找呢?和找i之后的管理员类似,先判断自己本身的等级,也就是二进制中1的最低位的位数k。i - 2^k就是i之前首个管理员的位置。再依次向前就可以。
举例:
i = 10 ,二进制为1010. 最低位的1是0010,所以i之前的管理员就是1010 - 0010 = 1000 = 8。也就是第3级的管理员。
1000 的最低位的1为1000,1000 - 1000 = 0,结束寻找。
所以求前10项和 = C[10] + C[8]

4.代码解析

看完上面的解析,我们能够知道最重要的一步就是求当前的等级,也就是2^k。其中k为当前二进制中1的最低位,这个操作大神们早已想好了

    public int lowBit(int i){
        return i & (-i);
    }

然后就是模拟了

    public int[] c;
    public int[] a;
    int n;
    public int lowBit(int i){
        return i & (-i);
    }

    public void add(int i, int k){
        while (i <= n){
            c[i] += k;
            i += lowBit(i);
        }
    }
    public int getSum(int i){
        int ans = 0;
        while (i > 0){
            ans += c[i];
            i -= lowBit(i);
        }
        return ans;
    }
    @Test
    public void test() {
        a = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9};
        c = new int[a.length + 1];
        n = a.length;
        for(int i = 0;i<a.length;i++){
            add(i + 1, a[i]); // 初始化C数组
        }
        add(1,3);
        System.out.println(getSum(3));
        add(3,4);
        System.out.println(getSum(5));
    }

执行结果
第一项加 3 之后前 3项和为9
第三项加 4 之后 前5项和 = 15 + 3 + 4 = 22 符合前缀和
在这里插入图片描述
至此 我们就可以实现在log(n)的时间内获得某个数组的前缀和

其他更多的应用下次再写

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值