树状数组的解题思路

时间:2021-03-29 09:30:04
•树状数组
•一类树状的解决区间统计问题的数据结构。
•一个简单的例子:
现有一个长度为n的数组,数组内存有数据。对于这些数据,有2类操作:
 1、修改数据第i个元素。
 2、查询数据一个区间(如p至q)内元素的和。


•方法一:
使用数组a[i]存储数据的第i个元素。那么对于要求的2种操作:
1、修改数据第i个元素:直接修改a[i]。时间复杂度为O(1)。
2、查询数据区间[p,q]的元素和:循环p至q累加。时间复杂度为O(n)。
小结:修改操作常数级复杂度,查询操作线性级复杂度。



方法二:
使用数组a[i]存储数据的第i个元素,并维护一个数组s,s[i]表示数组a前i项的和。那么对于要求的2种操作:
1、修改数据第i个元素:直接修改a[i],但需要维护s,对于s[i..n]进行相应的修改。时间复杂度为O(n)。
2、查询数据区间[p,q]的元素和:s[q]-s[p]即为所求。时间复杂度为O(1)。
小结:修改操作线性级复杂度,查询操作常数级复杂度。



由上可见,不同的数据结构之间没有绝对的优劣之分,这取决于你的(算法的)需求。
比如,如果你的算法操作一的需求较多,那么使用方法一更优;反之,如果对区间和的询问较多,而修改较少,那么方法二就更好一些了。
还是那句话,这取决于需求
下面介绍如何用树状数组解决这个问题。




方法三:(树状数组)
在方法二中,我们需要维护一个数组的前缀和S[i]=A[1]+A[2]+...+A[i]。但是不难发现,如果我们修改了任意一个A[i],S[i]、S[i+1]...S[n]都会发生变化。可以说,每次修改A[i]后,调整前缀和S在最坏情况下会需要O(n)的时间。
但方法二的思想已经给我们了启发。对于有关“区间”的问题,如果我们只在单个元素上做文章,可能不会有太大的收获。但是如果对于这些数据元素进行合理的划分(如方法二将其化为n个前缀),然后对于整体进行操作,往往会有神奇的功效。



为了使你对树状数组的逻辑结构有一个更为形象的认识,我们先看下面这张图:

树状数组的解题思路


如图所示,红色矩形表示的数组C就是树状数组。这里,C[i]表示A[i-2^k+1]到A[i]的和,而k则是i在二进制时末尾0的个数,或者说是i用2的幂方和表示时的最小指数。
这个概念对于初学者来说不容易理解,我展开讲一下。二进制的形式与一个数用2的幂方和表示的关系大家应该都清楚,比如:
23的二进制为10111,即
23 = 1*2^4+0*2^3+1*2^2+1*2^1+1*2^0
即23用2的幂方和表示为2^4+2^2+2^1+2^0。




即23用2的幂方和表示的2的最小指数为0,那么C[23]表示A[23-2^0+1]..A[23]的元素和,即A[23]。这个例子举得不好,我们换一个,比如6的二进制为110,则对于6的k为1(1为6的2的幂方和表示2的最小指数)。那么C[6]表示A[6-2*1+1]..A[6]的和,即C[6]=A[5]+A[6]。下面我们再看一下刚才的图,请尽力理解一下这个结构。
继续观察这个图,我们可以看到,所谓的k,也是该节点在树中的高度。下面,我们归回到最开始的问题

对于一开始要求的2种操作:
1、修改第i个元素:
从图示中我们可以看出,修改第i个元素,为了维护数组C的意义,需要修改C[i]以及C[i]的全部祖先,而非C[i]的祖先的节点则对于第i个元素的修改,不会发生改变。(想想看为什么)
那么修改C[i]的全部祖先有多少个?从图示中可以看出,C[i]的祖先共有“树的高度 - C[i]节点高度”个,而有n个元素的树状数组的高度为[Log2N](在以后的算法分析中,忽略对数函数的底数)。在元素修改过程中,修改C[i]并沿着树型结构一直向上回溯修改到树根,那么对于修改元素的操作需要修改树状数组中的LogN个节点,即时间复杂度为O(Log N)。


2、区间[p,q]元素和查询
刚才我们把数据进行了划分,其目的很显然,就是在对区间进行统计的时候可以对整体进行统计不是一个一个统计。对于其他的用于区间统计问题的数据结构大多也是这样的思路。
要求区间[p,q]元素和,可求[1,q]、[1,p]作差。则问题转化为如何查询一个区间[1,p]的元素和,即求s[p]。回顾一下我们是如何划分树状数组的区间的,对于求数列的前n项和,只需找到n以前的所有最大子树,把其根节点的C加起来即可。不难发现,这些子树的数目是n在二进制时1的个数,或者说是把n展开成2的幂方和时的项数。因此,求和操作的复杂度也是O(logn)。



举个例子说明,还是以23为例:
23的二进制为10111,即
10111(2) = 23 C[23] = A[23]
10110(2) = 22 C[22] = A[21] + A[22]
10100(2) = 20 C[20] = A[17] + ... + A[20]
10000(2) = 16 C[16] = A[1] + ... + A[16]
则S[23] = C[16] + C[20] + C[22] + C[23]
易见,对于任意p,求s[p]只需将若干树状数组上节点c进行加和即可,因为所加节点个数等于p的二进制形式中1的个数,因此统计过程的复杂度为O(Log N)。
综上所述,使用树状数组解决这个问题,修改操作对数级复杂度,查询操作也为对数级复杂度。也就是说,方法三相对于方法一和方法二,更适合解决修改和查询操作次数差不多的情况。


•树状数组的具体实现(代码以c语言为例):
通过上面的介绍,可以发现,实现树状数组的关键,在于求一个数p的二进制时末尾0的个数k(用2的幂方和表示时的最小指数)。而2^k就是修改(和统计)时指针滑动的距离,我们定义这个值为p的lowbit。
更具体的说,正整数p的lowbit为将p二进制中最后一个1按位抽取的结果。比如,23(10111)的lowbit为1(00001),20(10100)的lowbit为3(00100)。
如果可以求出lowbit,那么树状数组的实现就变得很显然了。那么如何求lowbit呢?



如何按位抽取一个数二进制的最后一个1呢?这里介绍如何用位运算来解决这个问题,并利用有符号整数补码的存储规则,将这个过程书写的更加简练。
对于一个二进制数p,以111010000为例。
将这个数减1,即p-1为111001111
将这两个数取异或,即p^(p-1),为000011111
将原数与这个数取和,即p&(p^(p-1)),为000010000
至此已经成功将p二进制的最后一个1按位抽取出来了。
lowbit(p) = p & ( p ^ ( p - 1 ) )
根据有符号整数的补码规则,我们可以发现(p^(p-1))恰好等于-p【1】,即lowbit的求取公式可以更为简练:
lowbit(p) = p & -p
记住这个结论,这是实现树状数组的核心内容。



•区间统计问题的具体实现:
1、修改第i个元素:修改第i个元素,需要更新C[i]以及C[i]的全部祖先。根据树状数组的逻辑结构,易见
C[i]的祖先为C[i + lowbit(i)]。
要对第i个位置进行修改,修改C[i]后再向上对其父亲进行递归修改,直至树根。下面给出一个对第i个元素进行加法操作(x位置加num)的例子。


void plus(int x, int num)
{
while ( x <= n)
{
c[ x ] += num;
x += lowbit( x );
}
}



2、查询区间[p,q]的元素和:要求[p,q]的元素和,可求s[q]、s[p]作差。要求s[p],只需将p反复减其lowbit并对其c进行加和即可。下面给出求s函数的范例:


int sum(int x)
{
int s = 0;
while ( x )
{
s += c[ x ];
x -= lowbit( x );
}
return s;
}



以上就是树状数组的具体实现,它解决的问题是:(1)改变某一个元素的值 (2)查询某一个区间内所有元素的和。
在此基础上,经过简单的变形可以变成支持另一组操作:(1)把一个区间内所有元素都加上一个值 (2)查询某一个元素的值。
如果你真正的理解了树状数组区间划分的思想,那么你会发现这种变形很显然,只需要将上面的2个基本操作反过来用就可以了,即把一个区间内所有的元素都加上一个值时,向前递归修改;查询某一个元素的值时,沿树向上递归累加,因为它的祖先记录了所有与它有关的区间整体修改操作。