树状数组

走远了吗. 提交于 2019-12-02 12:18:50

树状数组

为了表述方便,下面所有的数字,都是二进制形式下的。

拆分成特殊区间------C[i]的定义

树状数组通过特定将区间\(\left[1,i \right]\)通过一个特殊地规则,将区间拆分成\(k(k \leq \log_{2}{i+1})\)个区间\(\left(i_{k-1},i_{k} \right], \left(i_{k-2},i_{k-1} \right],\dots,\left(0,i_1\right]]\),其中\(i_k=i\).

而所谓特殊地规则,是这样的,首先将\(\left(l,r\right]\),首先将\(r\)用二进制表示,然后将最后一个1变成0即获得了左端点\(l\).
之后把\(l\)作为下一个区间的右端点,重复进行,直到整个区间分完了。

举个例子。假设\(r=100011001001\).划分的区间如下

l | r
-------------- --------------
100011001000 100011001001
100011000000 100011001000
100010000000 100011000000
100000000000 100010000000
000000000000 100000000000

我们将数\(i\)二进制表示形式下最后一个1的位权(即那个1及其后面的一连串的0表示的数)定义为\(lowbit(i)\).那么上面拆分成的特殊区间\(\left(l,r\right]\)拆分规则就可以用\(l=r-lowbit(r)=r^lowbit(r)\)表示了。

由位运算的知识不难推导出\(lowbit(x)=x\&(-x)\).

可以看到,区间的右端点每次变化的时候,都是去掉一个1.因此,拆分成的子区间数就是i二进制表示下1的个数。假设\(i\)含有\(k\)个1,而\(k\)个1表示的最小数是\(11\ldots1\),即\(2^k-1\),故\(i \geq 2^k-1\),因此\(k \leq log_{2}{i+1}\).

而树状数组的\(C[i]\)的定义就是\([1..i]\)区间拆分出来的第一个区间的部分和。

假设所有的C[i]都已知,那么,对于\(a[1..i]\)求前缀和,我们只需要对\(O(log_{2}{n})\)\(C[j]\)进行求和即可。单次前缀和查询复杂度\(O(log_{2}{n})\).

C[i]的求取

现在有个问题,如何求\(C[i]\)?显然直接枚举一个个元素求和是低效的、不明智的。

我们可以充分利用已经前面已经求得的\(C[j]\)的值。例如\(C[100011001000]\)表示的区间是\(\left( 100011000000, 100011001000\right]\).我们将前面两者相同的前缀10001100简记为A.我们可以拆分成:

l | r
------- -------
A0111 A1000
A0110 A0111
A0100 A0110
A0000 A0100

其中表格第一行所表示的区间只有一个元素\(a[A1000]\),即\(a[i]\).剩下的每一行,不难发现表示的区间恰好是\(C[A0111],C[A0110],C[A0100]\).表格的构造也不难,第一行只有一个元素,所以第一行的\(l\)\(r-1\).之后每一行的\(r\)都是上一行的\(l\),而这一行的\(l\)就是\(r-lowbit(r)\).

事实上,也不能说是恰好。因为这是特意要这样构造的。这样拆分,可以充分利用已经求出的\(C[j]\)并且保证拆分出的\(C[j]\)不超过\(i\)的二进制表示下0的个数。\(C[i]\)表示的区间\(\left( l, r\right]\)必然是
\(\left( A00..0, A10..0\right]\)的形式。右端点最后有\(k\)个0,k可以取0.那么就可以将求\(C[i]\)变成求\(a[i]\)\(k\)个已经求出来的\(C[j]\)的和的问题.显然\(k\)\(O(log_{2}{n})\).总体C数组的求取是\(O(nlog_{2}{n})\).

为什么说是树状数组呢?去百度百科里看一下图就知道了。就是把\(C[i]\)表示的区间往求取\(C[i]\)所要用到各个\(C[j]\)连一条边,并且往\(a[j]\)连一条边,就变成一棵树了。最后就变成树一样的形式了。而\(C[i]\)的高度,显然取决于\(i\)的二进制表示下\(1\)的个数(因为每一层都去掉最后一个1),即至多\(log_{2}{i+1}\)。因此最高点的高度是\(O(log_{2}{n})\)

单点修改a[i]快速更新C数组

到现在为止,我们通过把区间分解成一个个特殊的区间,并且用一个数组C记录这一个个小区间。如果仅仅是求前缀和,那么现在这种做法没有任何优势。因为它多维护了很多不必要的部分区间和。预处理时间复杂度\(O(nlog_2(n))\)比前缀和\(O(n)\)差,单次查询前缀和\(O(log_{2}{n})\)比前缀和\(O(1)\)差。

但是,我们的确维护了更多的信息,只是这些多维护的信息暂时还没有得到充分的利用,所以显得多余。

当我们就行元素\(a[i]\)的单点修改的时候,树状数组多维护的部分区间和信息就开始显示出优势了。对于朴素的数组前缀和,当我们修改一个元素\(a[i]\)只是,所有\(sum[j](j \geq i)\)都要更新。而对于树状数组而言,由于只是维护了部分区间和,所以修改\(a[i]\)之时,当且仅当\(C[j]\)所管辖的区间\((j-lowbit(j),j]\)包含\(i\)之时才需要更新C[j].

那么更新\(a[i]\)时,如何找到需要更新的所有\(C[j]\)呢?

只需要解决一个问题即可。刚才是我们求\(C[i]\)时,是直接找到了直接影响它的所有\(C[j]\)。即由父亲\(C[i]\)直接找到了所有的儿子\(C[j]\)及儿子\(a[i]\).那么如何由某一个儿子找到父亲呢?

如果是\(a[i]\),那么父亲显然是\(c[i]\);如果是c[j],观察上面的例子,不难发现是父亲\(C[i]\)中的\(i=j+lowbit(j)\),而通过之前构造出\(c[j]\)的过程也不难验证这个猜想正确。

因此,更新\(a[i]\)的时候,只需要一直往上寻找父亲并更新父亲的C值,直到发现到顶了。由于每次都是加自己的lowbit往上走,顶再往上走就超过n了。

BTW,其实C[i]的预处理求取也可以直接通过更新操作来求取,a视作全0数组,那么C数组不论是哪个区间的部分和,和都是0.然后将\(a[1],a[2],a[3],\ldots,a[n]\)逐一更新即可。复杂度是一样的。

小结

树状数组C,通过线性的空间复杂度维护一类特殊区间(\(\left( i-lowbit(i), i\right]\))的部分和信息,以对数的时间复杂度实现了单次自顶向下查询原数组a前缀和的操作,以对数的时间复杂度实现了单次自底向上的修改a[i]并更新C数组的操作。

Code

树状数组的代码非常简单,简直令人发指。相对于线段树简单很多,而且常数比线段树小不少。当然,线段树可能多支持一些操作。

inline int lowbit(int x) {return x&(-x);}
inline int lft(int x) {return x&(-x)^x;}
inline int upf(int x) {return x+lowbit(x);}
// 原始数组a下标从1开始
// 树状数组 c[i]是初始数组a的部分区间和
// i所掌管的区间是(lft(i),i]
ll a[maxn];
int n;
ll c[maxn];

void add(int i,ll val) {
    while (i <= n) c[i] += val, i = upf(i);
}

ll sum(int i) {
    ll ans = 0;
    while (i) ans += c[i],i = lft(i);
    return ans;
}

一些发散的思考

C[i]的更广泛的定义

前面我们将C[i]定义成了特定区间的区间部分和。但是C[i]不一定要是定义成区间的部分和,只要是满足结合律的运算的函数应该都可以,例如定义成区间内所有元素的异或值、最值之类的。

但是考虑到单点修改a[i]的时候,我们是把包含\(a[i]\)\(C[j]\)进行更新的。

假设直接指定了修改操作是"+val"(这里的+不是普通意义的加法,只是代表一种运算)。理论上更新后的\(C^{'}[j]\)的值应该是\(\ldots+a[i]+val+\ldots\),而我们修改操作的算法是\(C^{'}[i]=C[i]+val\),即计算的是\(\ldots+a[i]+\ldots+val\).理论上的值要和我们实际计算出来的值恒相等就必须使得我们定义的+运算必须满足交换性。

如果单点修改操作不是直接给出如何修改,而是给出修改后的结果,即直接给出修改后的\(a^{'}[i]\),则定义的+运算则还需要加上存在逆元(逆操作)\(-a\)这一条。即\(\ldots+a[i]+\ldots\)要变成\(\ldots+a^{'}[i]+\ldots\),如果可交换并且有逆元,那么就可以\(\ldots+a[i]+\ldots-a[i]+a^{'}[i]\)计算出目标值。即可以消去原本\(a[i]\)的影响。例如异或可以,但是求区间最大值却不可(因为无逆元)。

当然,如果不要求保持单次修改操作的时间复杂度是\(O(log_{2}{n})\),那么只需要满足结合性即可。显然,我们需要修改我们的修改操作的算法。我们依旧是自底向上更新。对于\(C[j]\)的更新,我们不能利用原本的\(C[j]\)的信息直接计算,而是用\(C[j]\)的所有儿子按照其所代表的区间顺序(如果不满足交换性)重新计算一番。另外,对于区间\(a[l,r]\)的问题,不能变成\(a[1..r]\)\(a[1..l-1]\)的差的问题,因为无法消除\(a[1..l-1]\)的影响。于是只能使用逐步分解为儿子的一个个区间后进行的合并的方法。单点修改复杂度将变成\(O({log^2_{2}{n}})\)并且代码没那么优美了.因此,最值问题也可以使用树状数组做,只是复杂度多乘以了一个对数。

BTW,事实上,线段树之所以能处理最值问题就是因为线段树采取的是重新用儿子计算。只是,线段树的一个节点的儿子就只有两个!

与差分的结合

小结中已经可以看到,树状数组支持单点修改,前缀和查询(之后容易推出区间和)查询。

那么区间集体增加一个数,查询单点值呢?这个引入差分数组即可。区间集体加将等价于差分数组的两个点的单点修改。查询单点值将等价于查询差分数组的前缀和。

如果是区间集体加,查询区间和呢?依旧引进差分数组b,则前缀和\(\sum\limits_{i=1}^{l}{a[i]}=\sum\limits_{i=1}^{l}\sum\limits_{j=1}^{i}b[i]=\sum\limits_{i=1}^{l}\left[ b[i] \times (n-i+1) \right]\).所以只需要引入数组\(c[i]=b[i] \times (n-i+1)\).如此,区间集体加将是变成c数组的2次单点修改,区间求和将变成c数组的两次前缀和查询。再次变成树状数组的题目。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!