- 从一个简单的问题谈起
考虑一个简单的问题,给定一串有序数字A1...An并不断进行插入数的操作且保证始终有序,在修改过程中陆续给定若干数字N1,N2...Nm,求解A1到ANi的数的和的大小。
我们试想,如果维护一个简单的array A来存储这串数的话,插入不是问题,时间是O(1),但是问题是如果询问m次,有n个数的话,查询区间大小的时间是O(n * m),显然如果在n和m较大的情况下是十分耗时的。那么换种思路,如果我们维护一个array B存储的是从1到i这段区间的数的和。那么会使查询时间优化至O(1),但面临的问题是每次修改都会需要变化从修改位置pos到n的所有数字的数值大小,显然修改操作是O(n * m)的时间复杂度。也是十分耗时的。
那么有没有一种数据结构可以解决这种问题呢。或者说,使插入和查找时间复杂度都折中的一种结构?那么下面介绍的就是插入和查找都是O(mlogn),那么他怎么实现的呢? - 写在树状数组之前
再次考虑一个问题,给定一个数N,如何将其操作后变成二进制只保留其最低位的1呢。举个例子,如10110(十进制24),经过操作以后变成00010。
这个问题的其实还是很简单的,无非是找到一个方法使得保留最低位的1的情况下,使得别的位取反或置0。那么可以想到一个方法就是将原数取反后加1,这样在最低位的1高位的数仍然是与原数对应位置的数是相反的,而最低位1低位的数则全部由1进位为0,取反后成为0的最低位1经过进位后重新成为1.得到的新的数与原数进行与操作,即可得到题意求解的答案。
拿举的例子操作一下,00010110 取反后 -> 11101001 加1-> 11101010 与原式子00010110与 -> 00000010,得解
如果写成代码如下:x & -x
这就是树状数组的灵魂操作——lowbit,它的做什么的呢,下面做介绍。
- 先浅谈下树状数组的表象
特别强调了,虽然它的名字后缀是数组,但是归根到底,这就是棵树。先看看他的样子:
其中array A代表的是原串数组,而C便是树状数组。我们先通过直观感受找找规律。
C[1] = A[1]
C[2] = C[1] + A[2] = A[1] + A[2]
C[3] = A[3]
C[4] = C[2] + C[3] + A[4] = A[1] + A[2] + A[3] + A[4]
C[5] = A[5]
C[6] = C[5] + A[6] = A[5] + A[6]
C[7] = A[7]
C[8] = C[4] + C[6] + C[7] + A[8] = (C[2] + C[3] + A[4]) + (A[6] + C[5]) + A[7] + A[8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8]
通过上面的八个式子,我们可以感受到树状数组mlogn的原理——二分
C[2]是分为1 (A[1])和 2 [C[1]]两边,C[4]是分为1,2 (C[2])一边,3,4 (C[3]A[4])一边。C[8]是分为1~4 (C[4])一边,5~8 (C[6],C[7],A[8])一边。经过二分,在查找的时候,时间复杂度就降到了O(n),举个例子,如果我们要计算1~5这段区间的值的话,就是C[4] + C[5],同样,如果要是计算1~7的值,其实就是C[4] + C[6] + A[7],说到这里可能很多不知道为什么要这么计算,只是从表面上看是这样算出来的,其实它的计算过程是这样的,拿到一个数n我们先看它满足的最高位1是多少,如5和7都是100也就是4,那么加上C[4],然后进一步求次高位的1,发现5不满足110而7满足,所以7再加上C[6],发现5满足101所以加上C[5],而7已经包含110了,所以继续往下找是111,也满足,再加上C[7]。得解。
当然,或许有人说,当添加7的时候,对C[7]还是未知的,那么其实也很简单,只需要利用递归的思想,求解1~6的和后再加上A[7]. - array C与array A的关系
现在的当务之急或许应该是求得怎么通过array A求array C,这就用上了前面提到的lowbit操作。我们还是通过观察找规律先,下面我们统一用二进制描述数字,这样比较方便。
C[0001] = A[0001] (0001对应的是从0001开始递减1的1个数)
C[0010] = A[0001] + A[0010] (0010对应的是从0010开始递减1的2个数)
C[0011] = A[0011] (0011对应的是从0011开始递减1的1个数)
C[0100] = A[0001] + A[0010] + A[0011] + A[0100] (0100对应的是从0100开始递减1的4个数)
C[0101] = A[0101] (0101对应的是从0101开始递减1的1个数)
C[0110] = A[0101] + A[0110] (0110对应的是从0110开始递减1的2个数)
C[0111] = A[0111] (0111对应的是从0111开始递减1的1个数)
C[1000] = A[0001] + A[0010] + A[0011] + A[0100] + A[0101] + A[0110] + A[0111] + A[1000] (1000对应的是从1000开始递减1的8个数)
通过上面的式子,我们是不是可以清晰的发现,其实C[i]就是从i开始的递减1的lowbit(i)个数?这样我们就得到了array A和array C的关系,下面就剩两个解决问题的操作,即如何进行求和和插入修改?
- 两个关键操作
对于添加操作,我们发现,从二进制考虑其实是改变包含与它的所有高位1,在8这个范围里,我们举个例子,这样可以参考上面的图。如果添加3的话,需要改变哪些数字?显然可以得到需要改变C[4],C[8],为什么不需要改变剩下的数字呢,因为它们完全不包含3这个数字。由上面我们得到的结论,每个数字i只包含从该数开始的递减1的lowbit(i)个数,所以反之添加的时候我们需要改变的其实就是从该数字i开始的x个lowbit(i)个数,且这是一种包含操作,即每次需要更新i的值。
按照3我们进行计算,0011,lowbit(0011) = 0001, 0011+0001 = 0100,此时改变C[4]的值。i = 0100,lowbit(0100) = 0100,0100 + 0100 = 1000,改变C[8]的值,下次操作肯定超出数值范围8,操作结束。
如果用代码表示的话,则如下:void add(elemtype x, val){ tree[x] += val; x += x & -x; }
解决了插入操作后,求和操作就非常简单了,其实就是添加的反操作,就是将从数字i开始递减lowbit(i)的数对应的树状数组的值进行叠加。当然也是包含操作,代码如下:
elemtype check(elemtype x){ elemtype sum = 0; while(x > 0){ sum += tree[x]; x -= x & -x; } }
- 树状数组小结
树状数组可以解决很多问题,最经典的莫过于多有真子集问题,当模型和模型之间存在着包含关系的时候,求解每个集合真子集的个数等问题可以用树状数组轻松解决。我的另外几篇文章就是介绍了有关几道树状数组的题目,有兴趣可以加以研究。树状数组能让在维护数组的基础上求和的时间复杂度在O(mlogn)级别,在面对大量的n的时候尤为有效。而且它的思想很简单,代码十分简洁,是为广大算法爱好者所喜欢和欣赏的一个数据结构。
查看原文:http://chilumanxi.org/2016/02/05/%e6%a0%91%e7%8a%b6%e6%95%b0%e7%bb%84/