c++树形数据结构——树状数组,算法必看哟!!!
目录
注:本文题目均来自蓝桥杯官网公开题目,仅用于算法学习和技术讨论。
一,简介
树状数组(Fenwick Tree)是一种高效的数据结构,主要用于解决区间查询和单点更新问题,时间复杂度均为 O(logn),适用于数组长度固定且需要频繁进行这两种操作的场景。是一种可以“动态求区间和”的树形数据结构,但并没有真正的构造出边来,所以是“树状的”。
以下是其核心知识点:
1. 基本原理
(1)结构特点:树状数组通过将数组元素按照二进制低位的 “1” 进行分层存储,形成一个树形结构(非传统二叉树),每个节点负责存储特定区间的前缀和。
2,核心操作:
(1)单点更新:修改某个元素的值,并更新其所有相关父节点。
(2)前缀和查询:计算从数组第一个元素到指定位置的前缀和,进而推导出区间和。
二,区分与前缀和的区别和联系
1. 时间复杂度
操作 前缀和 树状数组
初始化 O(n) O(nlogn)或 O(n)
单点修改O(n) O(logn)
区间查询O(1) O(logn)
区间修改O(n)(需配合差分) O(logn)
2. 空间复杂度
前缀和:
O(n) (需要额外存储前缀和数组)。
树状数组:
O(n) (直接在原数组基础上维护树状结构)。
3. 适用场景
前缀和:适合静态数组(不涉及修改),频繁查询区间和。
例如:多次查询数组中任意子数组的和。
树状数组:适合动态数组(需频繁修改和查询)。
例如:单点修改后快速查询前缀和、区间修改与查询(需配合差分)。
4. 实现灵活性
前缀和:仅支持区间和查询,难以扩展到其他操作(如区间最大值)。
树状数组:可扩展支持多种操作,如区间最大值 / 最小值(需调整更新策略)。
三,基本步骤演示
1,lowbit操作
是一种位运算操作,用于计算出数字的二进制表达中的最低位的1以及后面所有的0
int lowbit(int x){return x&-x}; o(1)
int lowbit(int x)//用于快速计算一个整数在二进制表示下最低位的 1 所对应的值 { return x & -x; } /*x = 6 → 二进制: 0000 0110 ~x → 二进制: 1111 1001 ~x + 1 = -x → 二进制: 1111 1010 x & (-x) → 二进制: 0000 0010 → 十进制: 2*/ //lowbit(x) 的值等于 2^k,其中 k 是 x 的二进制末尾连续 0 的个数2,lowbit和树状数组t[]的联系
int t[](tree),大小和我们需要维护的数组大小一样即可
树状数组的结构:t[i]存储a[i]中一段区间和:
定义t[i]存储以i结尾,且区间大小为lowbit(i)的区间和
t[j]=sigma(j=(i-lowbit(i)+1)~~~i)a[j],可以将[i-lowbit(i)+1,i]称为“管辖区间”
例子:a1 a2 a3 a4.....................an(分别为1 2 3 4 ..........n)
t1-->1 t2-->[1,2] t3-->3 t4-->[1,4] t5-->5 t6-->[5,6] t7-->7 t8-->[1,8]
如何进行单点修改?举个例子:假设要修改a[3],包含a[3]的区间有t3 t4 t8,所以应该修改这3个节点
只需进行+lowbit操作即可(二进制性质)
3+lowbit(3)=4 4+lowbit(4)=8......就是原来的编号加上他所管辖的区域等于后面与之相连的编号!!!
怎么进行区间查询?
第一步:将其拆为两个区间的差,例子:假设要查询[3,7]的和,就要拆分为sum[1,7]-sum[1,2]
由上图,我们要求sum[1,7],结果为t[7]+t[6]+t[4],通过lowbit即可得到:
7-lowbit(7)=6 6-lowbit(6)=4 就是原来的编号减去他所管辖的区域等于前面与之相连的编号!!!
于是可以直接得到树状数组最重要的两个函数update()和getpre()!
1,update函数
//给a[k]增加x void update(int k, int x) { for (int i = k; i <= n; i += lowbit(i))t[i] += x; //解释:把a[k]增加x,相当于把与a[k]相关的几个区间和都要加上x。所以t[i]要加上x }2,getprefix函数
//返回区间[1,k]的和 int getprefix(int k) { int res = 0; for (int i = k; i >=1; i -= lowbit(i))res += t[i]; //以管辖区间为单位依次向前加上t[i]!!!就返回的是区间[1,k]的和! }四,例题详解
例题1:蓝桥杯官网——殷老师排队
问题描述
体育课上,殷老师正在给学生们排队,排完队后他发现这些学生有些不同。每个学生都有一个魅力值 ai 和愉悦值 bi,愉悦值的计算公式如下:

作为一名优秀的教师他一眼就可以看出学生们的魅力值,但随着时间的推移魅力值可能会发生变化,所以愉悦值也会发生变化。在排队的过程中会有 n 个事件,这 n 个事件共分为两种情况:
- 第 x 个学生的魅力值变为 y。
- 殷老师想查看第 x 个学生的愉悦值。
殷老师太忙了,所以你可以帮他计算某个学生的愉悦值吗?
输入格式
第一行包含两个整数 n,m,表示学生的个数和事件的个数。
第二行包含 n 个数表示每个学生的魅力值。
之后每一个操作的开头都输入一个数字 op 表示事件的类型,如果 op=1 则输入两个整数 x,z 表示将第 x 个学生的魅力值修改为 z;若 op=2 则输入一个整数 x 表示询问从第 x 个学生的愉悦值。
输出格式
对于每个操作 2 输出一个数字 ans 表示学生的愉悦值。
样例输入
10 10 74 56 25 77 55 36 50 89 42 15 1 2 38 2 8 2 1 2 1 1 3 11 2 1 2 6 2 8 1 5 47 2 3 样例输出
147 -239 -239 -253 -23 161 189 数据规模
对于所有评测数据,1≤n,m≤10^5,1≤op≤2,1≤x≤n,0≤z≤10^9。
代码详解!
方法一:正确方法,树状数组
#include <iostream> using namespace std; using ll = long long; const int N = 1e5 + 9; ll a[N], t[N], n, m; //首先,可以将式子转换: //bi=(i-1-1+1)ai-sigma(j=1 j=i-1)aj+sigma(j=i+1 j=n)aj-(n-i-1+1)ai //bi=(2i-n-1)ai-sigma(j=1 j=i-1)aj+sigma(j=i+1 j=n)aj int lowbit(int x) { return x & -x; } void update(int k, ll x)//单点修改,将a[k]加上x { a[k] += x; for (int i = k; i <= n; i += lowbit(i))t[i] += x; } ll getprefix(int k)//求区间[1,k]的和 { ll res = 0; for (int i = k; i >= 1; i -= lowbit(i))res += t[i]; return res; } ll getsum(int l, int r)//求区间[l,r]的和 { return getprefix(r) - getprefix(l - 1); } ll b(int i)//求愉悦值 { return (2 * i - n - 1) * a[i] - getsum(1, i - 1) + getsum(i + 1, n); } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); cin >> n >> m; for (int i = 1; i <= n; i++) { int x; cin >> x; update(i, x); //cin>>a[i];update(i,a[i]); } while (m--) { int op; cin >> op; if (op == 1) { ll x, y; cin >> x >> y; update(x, -a[x] + y);//将a[x]加上-a[x]+y,即将a[x]变成y } else { int x; cin >> x; cout << b(x) << '\n'; } } return 0; } ///若改用直接输入数组的形式,则太麻烦了,还不如前者方便 #include <iostream> using namespace std; using ll = long long; const int N = 1e5 + 9; ll a[N], t[N], n, m, g[N]; //首先,可以将式子转换: //bi=(i-1-1+1)ai-sigma(j=1 j=i-1)aj+sigma(j=i+1 j=n)aj-(n-i-1+1)ai //bi=(2i-n-1)ai-sigma(j=1 j=i-1)aj+sigma(j=i+1 j=n)aj int lowbit(int x) { return x & -x; } void update(int k, ll x) // 单点修改,将a[k]设为x { g[k] += x; // 更新a[k] for (int i = k; i <= n; i += lowbit(i)) t[i] += x; // 更新树状数组 } ll getprefix(int k)//求区间[1,k]的和 { ll res = 0; for (int i = k; i >= 1; i -= lowbit(i))res += t[i]; return res; } ll getsum(int l, int r)//求区间[l,r]的和 { return getprefix(r) - getprefix(l - 1); } ll b(int i)//求愉悦值 { return (2 * i - n - 1) * g[i] - getsum(1, i - 1) + getsum(i + 1, n); } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); cin >> n >> m; for (int i = 1; i <= n; i++) { //int x;cin>>x; //update(i,x); cin >> a[i]; update(i, a[i]); } while (m--) { int op; cin >> op; if (op == 1) { ll x, y; cin >> x >> y; update(x, y - g[x]);//将a[x]加上-a[x]+y,即将a[x]变成y } else { int x; cin >> x; cout << b(x) << '\n'; } } return 0; }大家可能会好奇,树状数组的相关操作和前缀和差分具有相似之处。那么这题可以用普通的前缀和和差分方法做吗?其实是可以的。只是时间复杂度较大,只能通过80%的案例。
请看代码:
方法二,普通前缀和差分方法,时间复杂度高
#include <iostream> #include<cstring> using namespace std; using ll = long long; const int N = 1e5 + 9; ll a[N], diff[N], prefix[N], n; //求前缀和 void pre_fix() { memset(prefix, 0, sizeof(prefix)); for (int i = 1; i <= n; i++) prefix[i] = prefix[i - 1] + a[i]; } //update(i,k)将a[i]加上k void update(int i, int k) { diff[i] += k; diff[i + 1] -= k; //前缀和还原 for (int i = 1; i <= n; i++) a[i] = a[i - 1] + diff[i]; pre_fix(); } //前缀和:求[1,k]的和 ll getprefix(int k) { return prefix[k]; } ll getsum(int l, int r) { return getprefix(r) - getprefix(l - 1); } ll b(int i) { return (2 * i - n - 1) * a[i] - getsum(1, i - 1) + getsum(i + 1, n); } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); int m; cin >> n >> m; for (int i = 1; i <= n; i++) cin >> a[i]; //先初始差分数组: for (int i = 1; i <= n; i++) diff[i] = a[i] - a[i - 1]; //初始前缀和数组 for (int i = 1; i <= n; i++) prefix[i] = prefix[i - 1] + a[i]; while (m--) { int op; cin >> op; if (op == 1) { int x, y; cin >> x >> y; update(x, y - a[x]); } else if (op == 2) { int x; cin >> x; cout << b(x) << '\n'; } } return 0; }例题2:23年蓝桥杯真题——异或和
问题描述
给一棵含有 n 个结点的有根树,根结点为 1,编号为 i 的点有点权 ai(i∈[1,n])。现在有两种操作,格式如下:
- 1 x y:该操作表示将点 x 的点权改为 y。
- 2 x:该操作表示查询以结点 x 为根的子树内的所有点的点权的异或和。
现有长度为 m 的操作序列,请对于每个第二类操作给出正确的结果。
输入格式
输入的第一行包含两个正整数 n,m,用一个空格分隔。
第二行包含 n 个整数 a1,a2,…,an,相邻整数之间使用一个空格分隔。
接下来 n−1 行,每行包含两个正整数 ui,vi,表示结点 ui 和 vi 之间有一条边。
接下来 m 行,每行包含一个操作。
输出格式
输出若干行,每行对应一个查询操作的答案。
样例输入
4 4 1 2 3 4 1 2 1 3 2 4 2 1 1 1 0 2 1 2 2 样例输出
4 5 6 评测用例规模与约定
对于 30% 的评测用例,n,m≤1000;
对于所有评测用例,1≤n,m≤100000,0≤ai,y≤100000,1≤ui,vi,x≤n。
代码详解!
方法一:树状数组
#include <iostream> #include <vector> using ll=long long; using namespace std; const int N=1e5+9; ll a[N],t[N],idx[N],dfn[N],sz[N]; vector<int>g[N]; int n,m,tot=0; int lowbit(int x) { return x & -x; } void update(int k,int x) { for(int i=k;i<=n;i+=lowbit(i)) t[i]^=x; } int query(int k) { int res=0; for(int i=k;i>0;i-=lowbit(i)) res^=t[i]; return res; } void dfs(int x,int fa) { dfn[x]=++tot; idx[dfn[x]]=x; sz[x]=1; for(const auto&y:g[x]) { if(y==fa) continue; dfs(y,x); sz[x]+=sz[y]; } } int main() { int q; cin >> n >> q; for (int i = 1; i <= n; i++)cin >> a[i]; for (int i = 1; i < n; i++) { int u, v; cin >> u >> v; g[u].push_back(v), g[v].push_back(u); } dfs(1, 0); for (int i = 1; i <= n; i++)update(dfn[i], a[i]); while (q--) { int op; cin >> op; if (op == 1)//更新x为y { int x, y; cin >> x >> y; int val = query(dfn[x]) ^ query(dfn[x] - 1); //在初始状态(未进行任何更新操作时),query(dfn[x]) ^ query(dfn[x] - 1) 等于 a[x]; // 但在经过更新操作后,它等于节点 x 当前的最新值(而非初始的 a[x])!!!!!! //val 表示的是 DFS 序中某一点的当前点权值 /*query(dfn[x]) 返回区间 [1, dfn[x]] 的异或和。 query(dfn[x]-1) 返回区间 [1, dfn[x]-1] 的异或和。 两者异或后,得到的 val 就是位置 dfn[x] 对应的点权(即节点 x 的当前值)。*/ update(dfn[x], val ^ y);//这一步的意思就是将dfn[x]所代表的点权(就是val)在和val^y进行异或运算 //val^vla^y=0^y=y,至此,val被替换成y!!! } else//求异或和 { int x; cin >> x; int l = dfn[x], r = dfn[x] + sz[x] - 1; int ans = query(r) ^ query(l - 1);//类似prefix //ans表示l到r的异或和 /*异或(XOR,符号为 ^)具有以下关键性质: 交换律:a ^ b = b ^ a 结合律:(a ^ b) ^ c = a ^ (b ^ c) 自反性:a ^ a = 0(任何数异或自身为 0) 单位元:a ^ 0 = a(任何数异或 0 等于自身)*/ //定义 prefix[i] 为前 i 个元素的异或和: //prefix[i] = a[1] ^ a[2] ^ ... ^ a[i] //则区间 [l, r] 的异或和可表示为: //a[l] ^ a[l+1] ^ ... ^ a[r] = prefix[r] ^ prefix[l-1] //前面的1到l-1部分由交换律a[i]^a[i]=0,都抵消了 cout << ans << '\n'; } } return 0; }方法2:更加简单直观的方法
//////方法2:更加直观 //需要设置两个数组:in_[N]和out[N],in_[i]表示第i个结点的入序,out[i]表示第i个结点的出序 //对树进行一次dfs,在进入函数时更新入序,在退出函数时更新出序 //对于以结点x为根结点的子树,其所有子结点的dfs序都将位于[ in_[x],out[x] ]间 #include <bits/stdc++.h> using namespace std; const int N = 1e5 + 10; vector<int>edge[N];//edge[i]存储第i个结点的邻接点 int in_[N], out[N];//in_[i]存储第i个结点的入序,out[i]存储第i个结点的出序 int weight[N];//weight[i]存储的第i个结点的值(原始数据) int tot = 0;//记录dfs序的变量 int seq[N];//seq[i]为dfs序中第i个结点的权值 int n, m; void dfs(int u, int father)//对结点u进行dfs,其父结点为father { in_[u] = ++tot;//进入函数,计算入序,tot先自加 seq[tot] = weight[u];//第tot个结点的权值是weight[u] for (const auto& v : edge[u])//遍历u的邻接点v { if (v == father)continue; dfs(v, u); } out[u] = tot;//退出函数,更新u的出序,tot无需自加 } /*in_[u]:表示节点u在 DFS 遍历中首次被访问的时间戳(即进入节点u的时间)。 这个时间戳是唯一的,并且在整个 DFS 过程中严格递增。 out[u]:表示节点u的所有子树都被遍历完成后,退出节点u的时间戳。 由于 DFS 会递归遍历当前节点的所有子节点, 因此out[u]实际上等于当前节点及其所有子树的节点总数(从in_[u]开始的连续区间)。*/ /*入序(in_):在递归进入节点u时立即记录,确保每个节点的入序唯一且递增。 出序(out):在遍历完所有子节点后记录,此时的tot值恰好等于当前子树的最后一个节点的入序, 因此out[u]表示子树区间的右端点。*/ int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++)scanf("%d", &weight[i]);//输入n个结点的原始值 for (int i = 1; i < n; i++)//输入n-1条无向边 { int u, v; scanf("%d%d", &u, &v); edge[u].push_back(v); edge[v].push_back(u); } dfs(1, 0);//从1号结点开始进行一次dfs,求出所有结点的dfs序,填写in_数组和out数组 //此后我们只按照dfs序处理结点,原始的weight不再使用 while (m--) { int op, x, y; scanf("%d", &op); if (op == 1)//把第x个结点的权值改为y { scanf("%d%d", &x, &y); seq[in_[x]] = y;//转化为将第in_[x]个结点(dfs序下)的权值改为y } else if (op == 2) { scanf("%d", &x);//查询以x为根的子树的异或和 int ans = 0; for (int i = in_[x]; i <= out[x]; i++)//转化为求区间[ in_[x],out[x] ]的异或和 { ans = ans ^ seq[i]; } printf("%d\n", ans); //注意这里使用的是暴力求前缀和的方式而不是前缀和数组 //这是因为本题是一边修改一边查询,前缀和数组无法实现动态修改和查询 //如果要提高效率可以使用线段树,可在O(logn)时间复杂度内完成查询 //但对于本题,暴力求异或和即可通过全部测试点 } } return 0; }