欢迎进入博客浏览
>>我的博客<<
更好的排版,更好的阅读体验
树状数组初见与理解
我在第一次接触树状数组的时候,学习使用的博客是: Xenny-树状数组详解
博客本身已经将树状数组讲解的很好了,不过在二叉树抽象成树状数组的过程部分没有说的特别详细,我结合了一些自己的理解,认为这步抽象的过程还是需要仔细考虑一下的,毕竟是树状数组的原理,方便日后理解时候,便写了这篇博客。
树状数组初见,如有任何理解或文章方面的错误,希望大佬指正交流
a. 什么是树状数组?
树状数组,简单的说就是用数组的形式模拟数的结构。树状数组代码写起来简单,用起来方便,在不少问题上都可以用树状数组代替线段树,简直是居家必备小能手(误)
b. 树状数组可以解决的问题?
在这里,我们讨论的问题主要有,树状数组的原理与实现,树状数组的应用,包括:
- 区间查询
- 区间更新,单点查询
- 区间更新,区间查询
- 利用树状数组求逆元
当我们对树状数组有一个大概的认知之后,我们来详细了解一下树状数组。
1. 树状数组是什么样子?
我们已经说了,树状数组是用来模拟树的一种方法,那么模拟的是什么样子的数呢?答案很显然是二叉树,在我们学习二叉树的时候,也经常使用数组来实现二叉树,我们现在所提到的树状数组有什么不同呢?他的树结构是这样的:
这是一种非常常见的二叉树,每个节点都可以存储他对应子节点的和,但可以很直观的看到,他并不能满足我们要实现的数组形式,那么我们就需要对这棵树进行一点点修改:
对于每个子节点(1~8),我们摸出了对应竖轴上重复的节点,只保留了最高节点,这样就将刚刚那样的一棵树,从x轴的方向上看,变成了一维模型。现在我们只要找出保留节点与之前节点的关系,就可以完全的用树状数组来实现树形结构了。
观察图形,我们发现,每个节点仍然存储他所有子节点(图中最底层灰色节点)的和,但表示成数列的1~8号节点不再与子节点一一对应,而是包括了许多原二叉树结点的值,通过图中信息,我们肯可以得到:
Node[1] = a[1] | Node[5] = a[1] |
---|---|
Node[2] = a[1] + a[2] | Node[6] = a[5] + a[6] |
Node[3] = a[3] | Node[7] = a[7] |
Node[4] = a[1] + a[2] + a[3] + a[4] | Node[8] = a[1] + a[2] + … + a[8] |
可以发现节点和的表示形式与二进制的表示形式息息相关,观察发现:
-
2的二进制表示为:0010
-
4的二进制表示为:0100
-
5的二进制表示为:0101
-
6的二进制表示为:0110
观察每个数字末尾的0的个数
,即可发现末尾0的个数就表示这对应数字的节点在树上的高度(从最底层子节点为0开始计算)
而每个节点表示的数字和,可以发现:
-
2的二进制表示为:0010,表示2个数字的和,1~2。
-
4的二进制表示为:0100,表示4个数字的和,1~4。
-
5的二进制表示为:0101,表示1个数字的和,5。
-
6的二进制表示为:0110,表示2个数字的和,5~6。
很明显的发现,表示数字的和也与末尾连续的0的个数有关。原因很简单,因为这棵树本身是一颗二叉树,当你忽略了组成树的部分节点时,二叉树逢二进一的原则还是不会改变的,所以不论是高度还是宽度(表示数字的和)都是可以计算的。
N
o
d
e
[
x
]
=
a
[
x
]
+
a
[
x
−
1
]
+
⋯
+
a
[
x
−
2
k
−
1
]
共
2
k
项
,
其
中
k
为
数
字
x
末
尾
0
的
数
量
Node[x] = a[x]+a[x-1]+\cdots+a[x-2^k-1] \\共2^k项,其中k为数字x末尾0的数量
Node[x]=a[x]+a[x−1]+⋯+a[x−2k−1]共2k项,其中k为数字x末尾0的数量
2. Lowbit
当我们发现可每个节点的表示规律之后,下一个重要的点就是,如何快速的找到末尾零串?各路神仙发明了一种方法,叫做lowbit
。
lowbit的实现很简单:
int lowbit(int x) { return x & (-x); }
简单的一行函数就可以求得特定数字x最后的数字串。为什么呢?lowbit巧妙地利用了补码的性质,可以发现的是,在大多数编程语言中,数字以补码的形式存储,一个数字对应的相反数,可以通过二进制各位取反后加1得到。
那么对于奇数,末尾为1,取反为0,加1为1,除了末尾外的所有位置均未因为加1发生改变,补码与原码仍是互为相反数,那么结果就是1。
对于偶数来书,末尾为长度不等的0串,以10010100为例,末尾的100取反后变成011,加一后变回100,剩余位置与奇数一样,并没有因为加1发生改变。
所以,通过补码与原码的一次且操作,我们就可以的到数字末尾的串,既节点x表示的数字范围。
3. 计算求和
当我们知道了每个点表示的范围之后,怎么利用刚刚的一些列规律求特定的一个范围的数字和呢?
我们以26为例,26的二进制表示为11010,我们将11010按步骤变换成以下几个过程:
11010 --> 11000 --> 10000
再写出这几部分对应节点的数字标识和:
- 11010:十进制26,表示两个数字,25,26的和。
- 11000:十进制24,表示8个数字,17~24的和。
- 10000:十进制16,表示16个数字,1~16的和。
很显然,这三个数字加到一起就可以表示126所有数字的和。那么规律就出现了,当我们求124的和时,既可以写成:
// 得到1~i的所有值,其中a[i]为树状数组。
int getSum(int i) {
int sum = 0;
while(i>0) {
sum += a[i];
i -= lowbit(i);
}
return sum;
}
4. 数组的构建与单点更新。
离我们构造出完整的树状数组只差一步了,那就是如何对a[i]的值进行修改。
由于在树状数组中,a[i]的值是简介的存储在Node节点中的,观察图片可发现,图中a[3]的值不仅仅存在与Node[3]中:
因此,当我们想要修改a[3]的值时,我们只要顺藤摸瓜,从3开始,找到所有包括a[3]的节点,并将a[3]增加的值加进去就好了。跟二进制仅为的方法一样,我们同样可以推出,只要从指定节点i出发,一步一步的加入末尾串表示的值,就可以找到所有包括i的节点:
// 在a[i]的位置上,加上数值x,其中a[i]为树状数组。
int update(int i, int x) {
while(i<=n) {
a[i] += x;
i += lowbit(i);
}
}
5. 树状数组模板题:
题目描述就是树状数组,直接贴代码:
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define ull unsigned long long
#define db double
#define REP(i, lim) for(int i=0;i<lim;++i)
#define REPP(i, lim) for(int i=1;i<=lim;++i)
#define DEC(i, lim) for(int i=lim;i>=1;--i)
#define FOR(i,l,r) for(int i=l;i<r;++i)
#define deBug cout<<"==================================="<<endl;
#define clr(s) memset(s, 0, sizeof(s))
#define lowclr(s) memset(s, -1, sizeof(s))
const int MAXN = 1000055;
const int inf = 0x3f3f3f3f;
const double eps = 1e-8;
int n, m;
ll a[MAXN];
int lowbit(int x) { return x & -x; }
int update(int i, ll x) {
while(i<=n) {
a[i] += x;
i += lowbit(i);
}
}
ll getSum(int i) {
ll sum = 0;
while(i>0) {
sum += a[i];
i -= lowbit(i);
}
return sum;
}
int main()
{
// freopen("in.txt", "r", stdin);
// freopen("out.txt", "w", stdout);
scanf("%d%d", &n, &m);
int val;
REPP(i, n) {
scanf("%d", &val);
update(i, val);
}
int t, x, y;
REPP(i, m) {
scanf("%d%d%d", &t, &x, &y);
if(t==1) update(x, y);
else{
ll ans = getSum(y) - getSum(x-1);
printf("%lld\n", ans);
}
}
return 0;
}