嗒哒!今天我要介绍的是一个叫树状数组的数据结构。LeetCode1505中有用到它。(但是那道题太惨烈了我想缓一缓...所以就只介绍树状数组吧!)
如果我要给各种算法和数据结构的思想排一个序,最左端是“这个我感觉我早生五十年也能想到”,最右端是“想出这种结构/算法的真的是人类吗?”,树状数组大概就在正中间(i.e 冒泡排序在很左端,红黑树在很右端),就是那种,“我应该会想不出来,但是这样巧妙的想法真的很漂亮呀!真是参透了位运算的本质!”的感觉。那就来看看树状数组有多奇妙吧!
树状数组要解决的是求数组前缀和的问题,也就是:数组的第i位到第j位之间的元素和是多少?这个问题是不是听起来直接用累加就可以了?但是在计算机的领域,我们要追求的是速度(和节省空间)!如果你的任务里要执行很多次求前缀和的任务,每一次都用累加,既重复计算,又浪费时间资源。所以我们希望尽可能地减少重复的计算。
所以可不可以直接用一个数组D,它的第i位储存从0到i位的和呢?听起来是个好办法,这样的话,如果我们想知道i-j的前缀和的话,就可以直接算D[j]-D[i],时间复杂度为O(1)。但是这样省出来的时间其实是花在了写入数组里了。想想看,如果你要把数组的第i位加x,那么从第i位往后的所有元素都需要加x,一次写入的时间复杂度为O(n),其实就是和普通数组的读取/写入时间复杂度交换了。要是你的任务只会用到少量写入而用到大量查询,那么可以用前缀和数组,反过来的话可以用普通数组。但是在一般的情况下,有没有可能将写入和存储都优化呢?
树状数组是这样思考的,对任何一个数,我们可以将它拆分为二进制不同位数之和。例如,11转换为二进制是1011,因此它可以拆分为 。假设要对11求和,就可以通过将不同的二进制位数加起来完成。而如果要对index为11的数组求前缀和,也可以将同样的二进制位数范围累加起来。也就是说,如果事先储存好了0-7的和,8-9的和与10-10的和,求11的前缀和就化为这三段范围和的累加。看起来这个存储有些奇怪,要怎么实现这个存储呢?请看图~
在这个图中,1-14的数组index被表示为了树状。数组index的起始点是1(这也是构建树状数组需要注意的一点,与通常的起始为0的约定不同),0是一个dummy根节点。这个表示的操作是这样的:第一个index ,二进制展开位数为一,因此它直接接在dummy根节点下面;接着是,与1一样,也接在0下面;接下来是,它的父节点是,因此3接在2下面。再看一个例子,,因此它的父节点是,而10的父节点是,8没有父节点了,追溯到头。可以看到,第一排只存储了二进制展开为一位的数,第二排只存储了二进制展开为两位的数,并且它的根节点就是减去自己最小的二进制展开位之后的数,第三排...按照同样的规律排列。
这个构建树状数组的过程也可以用来构建前缀和的存储。道理是一样的:再以11为例,它的第一个根节点是8,再往上没有别的数了,所以8就用来存储0-7位的区间和。第二个根节点是10,对应的是第二个二进制展开位,所以10存储的是8-9的区间和。最后到了11自己这里,它的最后一位是,所以它只存储10-10的值。树状数组就是这样构建啦,然后,当我们需要求某个位置的前缀和,只要找到那个位置在树状数组的节点,然后一路回溯至根节点,将路径上的节点存储的区间和加起来就可以了!
到这里,关于树状数组的原理希望我已经讲清楚了。但是该怎么实现呢?怎么快速找到一个数的二进制位数?求然后取整?其实有更简单的方法。为了说明这个方法,我们来回顾一下副整数的二进制表示是怎么构造的。还是以11为例:
正整数的负数的二进制表示,就是将它对应的正整数的二进制按位取反之后加一。
因此,一个整数与它的负数进行按位与运算后的结果,就等于这个整数的最低二进制位数了。利用这个特性,就可以很好的构建树状数组了。下面是一个代码实现,一共定义了三个函数:getpar用于找到父节点;update用于更新数组,当更新一个位置时,需要同时更新的是排在它右边的所有同级节点;getsum就是通过追溯根节点来求和啦。
class BIT:
def __init__(self, arr):
self.bit = [0]+arr
def getpar(self, x):
return x-(x&(-x))
def update(self, x, k):
n = len(self.bit)-1
while (x <= n):
self.bit[x]+=k;
x += (x&(-x));
def getsum(self, x):
ans = 0
while x > 0:
ans += self.bit[x]
x = self.getpar(x)
return ans
从我们的分析可以看到,树状数组的输入和求和都是O(logN)的时间复杂度。