树状数组
前言:这是令我印象最深的数据结构,曾经和自己的队友们一起苦苦地攻克这块的内容,一遍又一遍地敲板子去理解。怀念那时的时光......
1.树状数组概述
什么是树状数组:树状数是一种轻量级的数据结构,可以用来解决区间的动态查询修改问题。
从功能上来讲,树状数组有三个类型的功能
1. 单点修改区间查询
2. 区间修改区间查询
3. 区间修改区间查询
操作 | 描述 | 复杂度 | 数组复杂度 |
建树 | 初始化线段树 | O(n) | 木有 |
单点修改 | 修改某一个点的值 | O(logn) | O(1) |
单点查询 | 查询某一个点的值 | O(logn) | O(1) |
区间修改 | 对一段连续区间做相同的修改 | O(logn) | O(n) |
区间查询 | 查询一段连续区间数值的和 | O(logn) | O(n) |
单点修改区间查询
首先咱们从简单的开始说起。
问题就是:给出一段长度为n的序列:a1,a2,...,an ,对其进行了m次操作,操作共有两种类型:
按照惯例,我们还是先考虑一下朴素(暴力)的解法👇
暴力就非常地直接,说啥做啥就可以,那么对于每个add操作就直接a[x] + =d即可,对于每个query操作就枚举[l,r]这段区间里所有a的和即可
这样的时间复杂度是O(nm),代码也比较好写👇
void add(int x,int d){
a[x]+=d;
}
int query(int l,int r){
int ret=0;
for(int i=l;i<=r;i++)
ret+=a[i];
return ret;
}
暴力之后向大家介绍一种方法
前缀和优化
前缀和这种想法自然就是考虑到了add这种操作比较少(好些),但query操作很多还很复杂。具体做法就是维护一个数组s,要满足
s[i] = j=1 = a[j] (存前缀)那么对于query查询操作,s[r] - s[l - 1]就是答案 。但是如果在add操作时会改动许多数,对这个s数组的影响也是非常大的,也因此这种方法的困难就在这。
void add(int x, int d) {
a[x] += d;
for (int i = x; i <= n; i++)
s[x] += d;
}
long long query(int l, int r) {
return s[r] - s[l - 1];
}
进入正题👇
树状数组的做法
本质:二进制拆分
刚才舍弃了前缀和做法的原因就是add有点多还复杂。而树状数组则运用了一种巧妙的,高效的,动态的实现了前缀和的查询和修改。
当然要说树状数组是个啥?那肯定是个数组啊!一般来讲我们设这个数组为C。
首先介绍一下树状数组中重要的lowbit 操作下
Lowbit
lowbit(i) 为i的二进制表示中,最低位的1对应2的幂,举几个栗子👇
数字 | 二进制 | 最低为的1 | lowbit |
7 | 111 | 1 | lowbit(7)=1 |
14 | 1110 | 10 | lowbit(14)=2 |
996 | 11 1110 0100 | 100 | lowbit(996)=4 |
1024 | 1000000000 | 1000000000 | lowbit(1024)=1024 |
求Lowbit
至于怎么求出这个lowbit?先亮代码👇
int lowbit(int x){
return x & (-x);
}
lowbit 表示的是这个数末尾的1在哪里,
负数的补码是对正数的原码对它每一位按位取反之后加1得到的,比如:
二进制1100 12
补码 0011 + 1 = 0100
1100 和 0100 与一下得到 0100
那么最终得到的0100 就是2²,我们也就得到了这个数(12)的二进制末尾的1是多少了(详情请学习计算机组成原理)
那么接下来(12)1100再减去0100得到1000 就是这个树状数组下一次要查询的那个节点。这个过程比较巧妙,当然上述给出的代码比较好记,同样lowbit的操作还有如下写法
int lowbit(int x){
return x - (x & (x - 1));
}
其实也是和补码的思想很相近的,就不赘述了。
假设树状数组用来维护序列a的前缀和,那么
即a[i]之前长为lowbit(i)的区间的数字之和。那么,前缀和 s[i] = s[i−lowbit(i)] + C[i] ,继续递归下去可得:
s[i] = C[i] + C[i−lowbit(i)] + C[i−lowbit(i)−lowbit(i−lowbit(i))]..........
思考:一共会被划分多少个C的和?
i - lowbit(i) 相比于i来说,二进制上少了一1,所以其实减lowbit就是在减二进制当中的1,i 的二进制表示中有O(log2)个1,所以s[i]会被划分为O(log2)个C的和,这样求解前缀和s的效率就是O(log2)。
虽然树状数组查询s的复杂度确实不如前缀和的O(1),但它的有点就在于,对序列a中的元素进行修改后,对C数组更新的效率也提高了许多,这正是前缀和无法完成的。
并且,我们给 ii 和 i+lowbit(i)i+lowbit(i) 之间连了一条虚线,从图中我们可以看出,当我们对 a[x]a[x] 进行修改时,C[x],C[x+lowbit(x)],C[x+lowbit(x)+lowbit(x+lowbit(x))],...
也会被修改,因此修改的数量也是 O(log(n)) 个。
(图片来自百度百科~)
查询a序列前缀和的代码和修改a序列某个数的代码👇
long long ask(int x) {
long long ret = 0;
for (int i = x; i; i -= lowbit(i))
ret += C[i];
return ret;
}
void add(int x, int d) {
for (int i = x; i <= n; i += lowbit(i))
C[i] += d;
}
不难分析出 add 操作的复杂度同 ask 操作一样,都是 O(log2n) 。
因此,在给出的题目中,如果我们要查询 aa 序列中一段区间 [l,r] 的和,只要输出 ask(r)−ask(l−1) 即可。
区间修改单点查询
问题描述:
给出长度为 n 的序列 a , m 次操作,操作有两种:
-
给区间 [l,r] 中的数加上 x
-
询问 a[x] 的值
其实吧这里我们可以用差分来考虑考虑把本体转化为单点修改,区间查询问题,从而使用树状数组。
具体来说,我们用树状数组维护 a 的差分数组 b , b[i]=a[i]−a[i−1]
根据差分的知识,我们知道,给 a[l],a[l+1],...,a[r] 加上 x ,相当于给 b[l] += x ,b[r+1] −= x ;而询问 a[x] 的值,则等价于询问b[j]和的值
因此,只要用树状数组对差分数组 b 做单点修改、区间查询操作就好了。
区间修改区间查询
事实上前两种问题本质上是相同的,可以相互转化。而区间修改区间查询就高级多了。
问题描述:
给出长度为 n 的序列 a , m 次操作,操作有两种类型:
-
给序列 a 区间 [l,r] 中的数字加上 dd
-
询问序列 a 区间 [l,r] 中的数字和
我们从询问操作入手来进行分析,再反过来决定修改操作的实现方式。
本题要询问 al + al+1 +... + ar−1 + ar的值,即:
们记 a 的差分数组为 b , b[i]=a[i]−a[i−1] ,那么有:
右侧式子中, b1 被加了 i 次、 b2 被加了i−1 次、...、 bp 被加了 i−p+1 次,于是有:
所以我们可以用树状数组去维护两个前缀和,一个是t1[i]=b[i] 的前缀和,另一个是t2[i]=b[i]×i 的前缀和。
结语
不太好理解,但也得啃下来!祝大家顺利~
以下是我当年写过的完整的树状数组代码,你看看是那种类型?
#include<bits/stdc++.h>
using namespace std;
#define N 200020
long long a[N], c[N];
long long n, m;
int lowbit(int x){
return x & (-x);
}
void update(int x, int k){
for(int i = x; i <= n; i += lowbit(i)){
c[i] += k;
}
}
long long getsum(int x){
long long ans = 0;
for(int i = x; i; i -= lowbit(i)){
ans += c[i];
}
return ans;
}
int main(){
cin >> n >> m;
for(int i = 1; i <= n; i++){
cin >> a[i];
update(i, a[i] - a[i - 1]);
}
for(int i = 1; i <= m; i++){
long long cnt, x, y, z;
cin >> cnt;
if(cnt == 1){
cin >> x >> y >> z;
update(x, z);
update(y + 1, -z);
}
else{
cin >> x >> y;
long long ans = 0;
for(int j = x; j <= y; j++){
ans += getsum(j);
}
cout << ans << endl;
}
}
return 0;
}