树状数组自学笔记
树状数组和线段树都是查询\(O(logn)\)的数据结构。
但是为什么很多人宁愿用树状数组而不是用线段树呢?
因为树状数组写起来比线段树在一定程度上简单多了。(Author:理解看了这篇文章也OK)
但是!树状数组维护的数据局限性要比线段树要大——这验证了一句话:
越复杂的数据结构时间复杂度越小,但是越暴力的算法全能性越大。
真理......
Author:好我们先不扯淡了
先来上一个例子:
如果给你一个数列(n个数),然后让你求出区间\([l,r]\)的和。
分类讨论一下n:
若\(n\leq1000\),很明显,暴力。
若\(n\leq 10000\),继续吸氧暴力......
若\(n \leq 10^6\)......线段树?
但是,作为蒟蒻,我表示线段树RE好几次了......
咋整啊......
这时候,一种新的算法腾空出世:前缀和!
一、树状数组基础1——前缀和
前缀和,采取动态规划思想,通项公式:
\(n[i]=n[i-1]+a[i]\)
然后有点数学常识就可以看得出来\(O(1)\)访问区间和(??详见下注)。
Tip:第n项+之前的和就是$a[n]$,询问第n项即$a[n]-a[n-1]$
以上只不过是思想基础1,下面才是真正扯的树状数组。
二、树状数组原理
1.我们先来观察这样一个图:
蓝的是左儿子,红的是右儿子(废话)
然后看看线段树,我们发现线段树的节点是有重叠部分的——父节点各种重叠,导致空间存储各种爆炸。
如果我们去掉这个烦人的重叠,发现好像空间复杂度降低了,时间复杂度不变。
空间到底是减少了多少呢?
手算一下:
最上面是减少了\(1\over 2\)
然后是\(1 \over 4\)
\(1\over 8\)......
空间竟然一步步变成了O(n)......
2.但是怎么访问呢???
神奇的地方来了:
再看一个图——
我们发现凡是\(2^n\)都是一群单个1的,然后是,然后是......
恍然大悟——
原来我们只需要前缀和一样的存储方式
前缀和一样的访问方式
(戴望舒:OI一样的凄婉迷茫)
然后我们要加上或者减去一个数的二进制中所在的位2^二进制中1所在的位
——这时候再引入一个东西
3.lowbit
求最低位权。
num的位权就是num最低位的'1'和左边的'0'(如果有的话)组成的数字
(Tip:位权不一定是二进制)
根据计算机补码(位运算玄学操作),lowbit(n)=n & -n
int lowbit(n){return n & -n;}
于是我们得到如下操作:
很明显:\(C_n = A_{(n – 2^k + 1)} + ... + A_n\)
相对于维护,这样的计算省下多少力气...
我们使用一个while
循环来实现根节点到子节点的循环。
使用树状数组只需要反复进行某些步骤就好了:
- 1.初始化
sum=0
; - 2.如果\(n\leq 0\),停止;否则就\(sum+=c[n]\);
- 3.\(n-=lowbit(n);\)然后是第二步。
int ask_(int k)//前k个数的和
{
int ans=0;//初始化ans=0
while(k>0)//不超过左界
{
ans+=t[k];//加上左边所有节点的数字
k-=lowbit(k);
}
return ans;
}
int ask_seg(int l,int r)
{
return ask_(r)-ask_(l);
}
以上代码是求区间[1,k]
的方式。
Tip:\(\sum_{i=x}^y[x,y]\)等同于求$(\sum_{i=1}^yarray[i],i\in[1,y])-(\sum_{j=1}^x array[j],j\in[1,x]) $。
如果是单点修改呢?
void change_p(int pos,int num)
{
while(i<=n)//0.在数组内部
{
c[i]=c[i]+x;//1.修改
i+=lowbit(i);//2.跳转到下一个与其有关的值
}
}
现在又面临一个严峻的问题:
区间修改咋整啊?
我们还是引进新的概念——
三、树状数组基础2——数列差分
差分简直是一个神奇的东西。。。
首先我们介绍这个还得引入一个问题:
如何快速地改变一个数列的值?朴素算法
常数大了呢?
差分!
差分数组定义:
我们求出一个类似dp求差分数组的公式:
\[ dp[i]=c[i]-c[i-1] \]
然后动动脑子(Author:没有nz),如果我们从dp[1]~dp[n]
反向还原加起来就得到了原数组。
更有意义的是如果我们仅仅对dp数组进行修改,仅仅需要修改两个数——
假设修改区间\([x,y]\),就改dp[x]
和dp[y+1]
。
为啥呢?如果我们dp[x]+=num
,相当于对后面所有的元素都产生了+num
的影响,然后再通过dp[y+1]
改回来就完了。
这跟树状数组有什么关系?
类比一下:因为我们树状数组修改父亲节点会对子节点产生一个影响...
对!修改前面的节点和后面的节点+1的地方!
但是注意我们修改不是向左(根)修改,是向右(叶子)修改。
如果想要单点修改和区间修改同时出现咋整?
单点p
不就是假的区间[p,p]
嘛!
我们开两种树状数组,一个是原序列的前缀和,另外一个就是前面说的差分数组。
然后操作一下差分的树状数组pos1=p,pos2=p+1
,以及前缀和树状数组就OK了。
区间修改操作:
void modify(int pos,int num)
{//实现向右改数组的基本操作
while (p<=n)//向右
{
sum[pos]+=num;
pos+=lowbit(pos);
}
}
void change_seg(int l,int r,int num)
{
modify(l,num);//差分左侧
modify(r,-num);//差分右侧
}
然后好像还有最后一个问题——
单点查询
由于我们差分了树状数组,单点查询就变成了区间查询的操作。
为啥?不是刚说了吗,前面所有的加起来不就是这个点的值嘛!
操作:
void ask_p(int pos)
{
int res=0;
while (pos>0)//限定左边
{
res+=a[pos];//还原现场
pos-=lowbit(pos);//向左循环
}
return res;
}
所以我们得到了如下基本操作:
单点修改、单点查询、区间修改、区间查询。
总结一下,整个学习过程分为两个大的板块:
- 前缀和思想:单点修改+区间查询
- 差分数列思想:区间修改+单点查询。
然后我们甚至可以拿这个毒瘤数据结构解逆序对问题。
Tip:
离散化原先数组,然后食用本数据结构