数据结构——树状数组

1.树状数组的基础

树状数组 是一个查询和修改复杂度都在 O ( l o g n ) O(logn) O(logn) 的数据结构,主要用于数组的单点修改区间求和

一般,我们常见的二叉树是这样画的:
普通二叉树
叶子节点代表A数组,A[1]~A[8]

然后,我们稍微变行一下,就形成了树状数组的画法:
树状数组
从上图,我们可以看出来,最后的根结点其实可以看成所有叶子结点求和的结果,而内部结点可以看成是叶子结点分段求和的结果,如下图所示:
区间求和示意图
从上图我们可以看出来,A数组是原始数组,代表叶子结点C数组是求和后的数组,代表内部结点
C [ i ] C[i] C[i] 代表子树叶子结点的权值之和,即

C[1] = A[1]
C[2] = A[1] + A[2]
C[3] = A[3]
C[4] = A[1] + A[2] + A[3] + A[4]
C[5] = A[5]
C[6] = A[5] + A[6]
C[7] = A[7]
C[8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8]

再将其转成二进制形式有

C[1] = C[0001] = C[1-0,1] = A[1];                   
C[2] = C[0010] = C[2-1,1] = A[1] + A[2]
C[3] = C[0011] = c[3-0,3] = A[3]
C[4] = C[0100] = C[4-3,4] = A[1] + A[2] + A[3] + A[4]
C[5] = C[0101] = C[5-0,5] = A[5]
C[6] = C[0110] = C[6-1,6] = A[5] + A[6]
C[7] = C[0111] = C[7-0,7] = A[7]
C[8] = C[1000] = C[8-7,8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8]

对照式子可以发现 C [ i ] = A [ i − ( 2 k − 1 ) ] + . . . + A [ i ] C[i] = A[i-(2^k-1)]+...+A[i] C[i]=A[i(2k1)]+...+A[i]
其中 k k k i i i 的二进制中从低位开始到高位连续的0,例如 i = 8 ( 1000 ) i=8(1000) i=8(1000) 时, k = 3 k = 3 k=3,则
C [ 8 ] = A [ 8 − 2 3 + 1 ] + A [ 8 − 2 3 + 2 ] + . . . + A [ 8 ] C[8] = A[8 - 2^3+1]+A[8-2^3+2]+...+A[8] C[8]=A[823+1]+A[823+2]+...+A[8] 就是上面列出的树状数组。
树状数组示例
则我们也有一个明显的结果,二进制低位有k个连续的0,则该结点就是子树 2 k 2^k 2k个叶子结点的和。

下面我们介绍一种简单粗暴的技术来求解低位有几个连续的0。
lowbit(x) 表示求x最低位的1以及在其后补0,则容易看出lowbit(i)就是上面 2 k 2^k 2k,因为 2 k 2^k 2k 后面有k个0才是第一个1。例如 2 3 = 1000 2^3=1000 23=1000 正好是 i 的最低位的1加上后缀0所得到的值。则我们有lowbit(i)的计算方法

int lowbit(x){
	return x & (-x);
}

解释一下:
对于一个数(计算机中存储的是二进制数),求其负数就是对其取反+1,也就是取补的过程,例如
6(00110)->取反11001->加1得11010(-6)-> 相与有00010恰好表示有1个连续的0,
即反码和原码的0/1是相反的,则原码低位连续的0取反后全是1,加上1后,全变0,而进位的1恰好是原码第一个1的位置,此时高位是相反的,所以取与恰好能把这个位置的1取出来。

2.树状数组的基本操作
2.1.单点更新

在这里插入图片描述
若此时我们要更改A[1],则需要进行下面的同步更新:

1(0001)
C[1] += A[1]
lowbit(1)=001 1+lowbit(1)=2(010) C[2]+=A[1]
lowbit(2)=010 2+lowbit(2)=4(100) C[4]+=A[1]
lowbit(4)=100 4+lowbit(4)=8(1000) C[8]+=A[1]

若更新A[3]则有一下同步更新:

3(0011)
C[3] += A[3]
lowbit(3) =001 3+lowbit(3)=4(100) C[4]+=A[3]
lowbit(4)=100 4+lowbit(4)=8(1000) C[8]+=A[3]

此时会发现这是一个递推的过程,则有代码:

void update(int x,int y,int n){
	// x 为更新的位置,y为更新后的数,n为数组的上界
	for(int i=x;i<=n;i+=lowbit(i)) c[i]+=y;
}

我们可以看到这个更新是按照树结构往上传的,所以复杂度在 O ( l o g n ) O(logn) O(logn)

2.2 区间查询

举个例子,求sum(5)
则有

C[4] = C[0100] = A[1] + A[2] + A[3] + A[4]
C[5] = C[0101] = A[5]
sum(i == 5) = C[4] + C[5],sum(101) = C[100] + C[101] = C[101-lowbit(101)] + C[101]

也就是单点更新的逆操作,即减lowbit(),复杂度在 O ( l o g n ) O(logn) O(logn),代码如下:

int getsum(int x){
	int sum = 0;
	for(int i=x;i;i-=lowbit(i)) sum += c[i];
	return sum;
}
2.3 例题练习

HDU1166 敌兵布阵 单点更新+区间查询

/* HDU 1166 敌兵布阵*/
#include <iostream>
#include <string>

#define lowbit(x) ((x)&(-x))

using namespace std;

const int MAXN = 5 * 1e4 + 5;
int c[MAXN];
void update(int x, int y, int n) {
	for (int i = x; i <= n; i += lowbit(i)) c[i] += y;
}
int getsum(int x) {
	int sum = 0;
	for (int i = x; i; i -= lowbit(i)) sum += c[i];
	return sum;
}
int main() {
	int t, n;
	int x, y, z;
	scanf("%d", &t);
	for (int i = 1; i <= t; i++) {
		scanf("%d", &n);
		// 初始化数组中前n+1个数字为0
		fill(c, c + n + 1, 0);
		for (int j = 1; j <= n; j++) {
			scanf("%d", &z);
			update(j, z, n); // 表明开始建树
		}
		printf("Case %d:\n", i);
		while (1) { // 开始查询
			string s;
			cin >> s;
			if (s[0] == 'E') break;
			scanf("%d%d", &x, &y);
			if (s[0] == 'Q') printf("%d\n", getsum(y) - getsum(x - 1));
			else if (s[0] == 'A') update(x, y, n);
			else update(x, -y, n);
		}
	}

	return 0;
}

即初始插入建树状数组操作时使用基本的更新操作update来建立。

以上部分引用自bestsort 的博客

3.高级应用
3.1 求逆序对

数组离散化
对于输入的数组A,由于求逆序数只需要知道元素间的相对大小,所以我们可以使用下列方式对输入进行离散化:

for(int i=1;i<=n;i++){
	a[i].data = read();
	a[i].id = i;
}
sort(a+1,a+n+1); // 按照data升序排列
for(int i=1;i<=n;i++) b[a[i].id] = i;

举个栗子
离散化举例
所以最后我们得到原始输入的相对顺序,[5,3,4,2,1] 排名越靠后表明数字越大,这样的预处理也能够避免输入的数据太大,因为我们只需要相对顺序就行。

逆序数

逆序对即在一个序列中,如果存在 i < j 且 a [ i ] > a [ j ] i < j 且 a[i] > a[j] i<ja[i]>a[j],那么就表明有一个逆序对。即对于每一个位置的数,我么考虑出现在它之前的数有多少比它大,就构成一个逆序,然后我们考虑完所有位置后,就可求出总的逆序数。

我们从 1 到 n 1到n 1n开始枚举,对于位置 i i i的数,我们在数组中将其标识为1代表其出现过,然后考虑比它先出现的数(小于它)有多少个,即树状数组中的区间求和操作getsum[i](小于等于的数),此时我们已经插入i个数,所以比它大的就有i-getsum[i]个,遍历n遍即可求出总的逆序数,复杂度在 O ( n l o g n ) O(nlogn) O(nlogn)

// -- 使用树状数组求逆序对 -- 
// 复杂度为nlogn, 包含离散化和建树查询
#include<vector>
#include<algorithm>
#include<string>
#include<iostream>

#define lowbit(x) (x)&(-x)

using namespace std;

typedef struct {
	int d, id; // 输入的数据和原始顺序
}node;

vector<node> a; // 原始的数据顺序
vector<int> b;  // 离散化后的数据,小的在前面,存储原始数据在全部数据中排第几
vector<int> c;  // 为最后的求和数组
int n;          // 数组长度
int DEBUG = 1;

int cmp(node p, node q) {
	return p.d < q.d;
}
void update(int x, int y) {
	while (x <= n) {
		c[x] += y;
		x += lowbit(x);
	}
}
int getsum(int x) {
	int sum = 0;
	while (x) {
		sum += c[x];
		x -= lowbit(x);
	}
	return sum;
}

int main() {
	cin >> n;
	a.resize(n + 1); b.resize(n + 1); c.resize(n + 1);
	int i, j, e;
	for (i = 1; i <= n; i++) {
		cin >> e;
		a[i] = { e,i };
	}
	// 排序,从1开始
	sort(a.begin() + 1, a.end(), cmp);
	// 开始离散化,b[]表明该位置的元素在原始位置排第几,越大越排在后面
	for (i = 1; i <= n; i++) b[a[i].id] = i;
	if (DEBUG) {
		printf("排序完成后的相对位置:");
		for (i = 1; i <= n; i++) printf("%d ", b[i]);
		printf("\n");
	}
	// 开始建立树和求逆序对
	fill(&c[0], &c[n], 0);
	int cnt = 0;
	for (i = 1; i <= n; i++) {
		update(b[i], 1); // 表明排名第b[i]的数据出现过了
		cnt += (i - getsum(b[i]));
		// getsum(b[i])表明小于等于b[i]的元素个数
		// i 表明当前插入的元素个数,想减表示大于等于b[i]的元素
		if (DEBUG) printf("第%d位时候的逆序数%d\n", i, cnt);
	}
	system("pause");
	return 0;
}

我们以之前的例子说明,同时直接以离散化后的顺序说明:
上面表示数据数组,下面表示标记数组
在这里插入图片描述
第一次插入,把5的位置标记成1,此时getsum[5]=1,表明小于等于它的只有它自己,大于它的为1 - 1 = 0
在这里插入图片描述
第二次插入,把3的位置标记成,这是getsum[3] = 1,表面大于它的有2 - 1 = 1
在这里插入图片描述
第三次插入,把4的位置标记成,这是getsum[4] = 2,表面大于它的有3 - 2 = 1
在这里插入图片描述
第四次插入,把2的位置标记成,这是getsum[2] = 1,表面大于它的有4 - 1 = 3
在这里插入图片描述
第五次插入,把1的位置标记成,这是getsum[1] = 1,表面大于它的有5 - 1 = 4
所以总的逆序数为9。

图源SSimpLe_Y的博客

3.2 稍进阶操作

1. 区间修改 + 单点查询
通过差分,将此问题转换为基本的单点修改+区间查询。

– 查询
在这里插入图片描述
– 修改
在这里插入图片描述

void update(int p,int x){
		for(int i=p;i<=n;i += lowbit(i)) a[i] += x;
}
void range_update(int l,int r,int x){
	update(l,x); // 给[l,]加上x
	update(r + 1,-x); // 给[r+1,]减去x
	// 即给[l,r]加上x
}
void getsum(int p){ // 单点查询
	int sum = 0;
	for(int i=p;i;i -= lowbit(i)) sum += a[i];
	return sum;
}

2.区间修改+区间查询

基于上面1的的“差分”,我们可以在树状数组中求数组的前缀和,即
在这里插入图片描述
–查询

位置p的前缀和即: ( p + 1 ) ∗ s u m 1 (p+1) * sum1 (p+1)sum1数组中p的前缀和(区间查询) - s u m 2 sum2 sum2数组中p的前缀和
区间 [ l , r ] [l,r] [l,r]的和即:位置 r r r的前缀和-位置 l − 1 l-1 l1的前缀和

–修改

对sum1数组的修改和之前的一样。
对sum2的修改,我们给 s u m 2 [ l ] 加 上 l ∗ x sum2[l] 加上 l * x sum2[l]lx

void update(int p,int x){
		for(int i=p;i<=n;i += lowbit(i)){
			sum1[i] += x, sum2[i] += p * x;
		}
}
void update(int l,int r,int x){
	update(l,x); update(r+1,-x);
}
void getsum(int p){ // 单点查询
	int sum = 0;
	for(int i=p;i;i -= lowbit(i)) sum += (p+1)*sum1[i] - sum2[i];
	return sum;
}

以上部分内容来自胡小兔的Blog

4.例题收录
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
逻辑结构:描述数据元素之间的逻辑关系,如线性结构(如数组、链表)、形结构(如二叉、堆、B)、图结构(有向图、无向图等)以及集合和队列等抽象数据类型。 存储结构(物理结构):描述数据在计算机中如何具体存储。例如,数组的连续存储,链表的动态分配节点,和图的邻接矩阵或邻接表表示等。 基本操作:针对每种数据结构,定义了一系列基本的操作,包括但不限于插入、删除、查找、更新、遍历等,并分析这些操作的时间复杂度和空间复杂度。 算法: 算法设计:研究如何将解决问题的步骤形式化为一系列指令,使得计算机可以执行以求解问题。 算法特性:包括输入、输出、有穷性、确定性和可行性。即一个有效的算法必须能在有限步骤内结束,并且对于给定的输入产生唯一的确定输出。 算法分类:排序算法(如冒泡排序、快速排序、归并排序),查找算法(如顺序查找、二分查找、哈希查找),图论算法(如Dijkstra最短路径算法、Floyd-Warshall算法、Prim最小生成算法),动态规划,贪心算法,回溯法,分支限界法等。 算法分析:通过数学方法分析算法的时间复杂度(运行时间随数据规模增长的速度)和空间复杂度(所需内存大小)来评估其效率。 学习算法与数据结构不仅有助于理解程序的内部工作原理,更能帮助开发人员编写出高效、稳定和易于维护的软件系统。
树状数组(Fenwick Tree)是一种用于快速维护数组前缀和的数据结构。它可以在 $O(\log n)$ 的时间内完成单点修改和前缀查询操作,比线段更加简洁高效。 下面是 Java 实现的树状数组详解: 首先,在 Java 中我们需要使用数组来表示树状数组,如下: ``` int[] tree; ``` 接着,我们需要实现两个基本操作:单点修改和前缀查询。 单点修改的实现如下: ``` void update(int index, int value) { while (index < tree.length) { tree[index] += value; index += index & -index; } } ``` 该函数的参数 `index` 表示要修改的位置,`value` 表示修改的值。在函数内部,我们使用了一个 `while` 循环不断向上更新树状数组中相应的节点,直到到达根节点为止。具体来说,我们首先将 `tree[index]` 加上 `value`,然后将 `index` 加上其最后一位为 1 的二进制数,这样就可以更新其父节点了。例如,当 `index` 为 6 时,其二进制表示为 110,最后一位为 2^1,加上后变为 111,即 7,这样就可以更新节点 7 了。 前缀查询的实现如下: ``` int query(int index) { int sum = 0; while (index > 0) { sum += tree[index]; index -= index & -index; } return sum; } ``` 该函数的参数 `index` 表示要查询的前缀的结束位置,即查询 $[1, index]$ 的和。在函数内部,我们同样使用了一个 `while` 循环不断向前查询树状数组中相应的节点,直到到达 0 为止。具体来说,我们首先将 `sum` 加上 `tree[index]`,然后将 `index` 减去其最后一位为 1 的二进制数,这样就可以查询其前一个节点了。例如,当 `index` 为 6 时,其二进制表示为 110,最后一位为 2^1,减去后变为 100,即 4,这样就可以查询节点 4 的值了。 最后,我们还需要初始化树状数组,将其全部置为 0。初始化的实现如下: ``` void init(int[] nums) { tree = new int[nums.length + 1]; for (int i = 1; i <= nums.length; i++) { update(i, nums[i - 1]); } } ``` 该函数的参数 `nums` 表示初始数组的值。在函数内部,我们首先创建一个长度为 `nums.length + 1` 的数组 `tree`,然后逐个将 `nums` 中的元素插入到树状数组中。具体来说,我们调用 `update(i, nums[i - 1])` 来将 `nums[i - 1]` 插入到树状数组的第 `i` 个位置。 到此为止,我们就完成了树状数组的实现。可以看到,树状数组的代码比线段要简洁很多,而且效率也更高。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值