【题目描述】
给定n个数列,规定有两种操作,一是修改某个元素,二是求子数列[a,b]的连续和。数列元素个数最多10万个,询问操作最多10万次。
【输入】
第一行2个整数n,m(n表示输入n个数,m表示m操作)
第二行n个整数
接下来m行,每行三个数k,a,b(k=0,表示求子数列[a,b]的连续和;k=1,表示第a个数加b)。
【输出】
若干行,表示k=0时,对应子数列[a,b]连续和。
【输入样例】
10 5
1 2 3 4 5 6 7 8 9 10
1 1 5
0 1 3
0 4 8
1 7 5
0 4 8
【输出样例】
11
30
35
一、 题目分析
【核心需求】 维护一个最多包含 105 个元素的序列,支持两种交替进行的操作(总操作数高达 105 次):
-
单点修改: 将序列中第x个数加上val。
-
区间查询: 求序列中第a个数到第b个数的连续和。
【性能瓶颈】 对于10^5级别的数据,任何单次操作复杂度为O(n)的算法都会在1s的时限内遭遇 超时。我们必须寻找一种能在O(logn)时间内同时兼顾"修改"与"查询"的数据结构。
二、 思考过程
面对这类动态查询问题,初学者通常会经历三次思维迭代:
-
纯暴力数组: 用普通数组存数据。单点修改只需O(1),但求区间和需要
for循环遍历,时间复杂度为O(n)。10^5 次操作必超时。 -
静态前缀和: 预处理出前缀和数组。此时区间查询降到了极速的 O(1),但一旦发生"单点修改",该位置之后所有的前缀和都要重新计算,修改复杂度退化为 O(n)。依然超时。
-
树状数组: 既然绝对的O(1)无法兼顾,我们就选择折中,树状数组通过二进制拆分,将一段连续的区间和"打包"交给特定的节点管理。无论是"向上更新"还是"向下求和",都能在极其稳定的O(logn)时间内完成。
三、 解题思路
树状数组的核心,全部浓缩在一个只有一行的函数里:lowbit(x)。
lowbit(x)=x&(−x)
利用计算机底层的补码机制(按位取反,末位加一),lowbit(x)能精准提取出 x 二进制下最右边的1所代表的整数。在树状数组的物理模型中,lowbit(x)代表了第x个"片区老大"所管辖的原数组元素的个数。
基于此,我们建立起一套严密的上下级体系:
-
当原数组第x个数发生改变时(单点修改): 它不仅要更新自己,还要向上级汇报。通过不断执行
x+=lowbit(x),程序会精准地跳跃到所有包含了该元素的上级节点,并同步更新它们的值。 -
当我们需要前 x 个数的和时(前缀查询): 我们只需要通过
x-=lowbit(x),不断向左上方寻找上一个相邻的"片区老大",把他们手里的管辖总和拼凑起来,就能完美拼出一个完整的前缀区间。
四、 算法设计
-
建树: 抛弃物理上的原数组。在读取初始值时,我们直接将其视作对一个全0树状数组的n 次单点修改(
update),代码优雅。 -
操作流转:
-
若指令为修改操作:直接调用
update(a,b)。 -
若指令为查询操作:利用容斥原理,求区间 [a,b] 的和,等价于求出从 1 至 b 的数列和,减去从1至a−1的数列和,即
sum(b)-sum(a-1)。
-
五、 时空复杂度分析
-
时间复杂度: 建树阶段相当于执行n次单点修改,耗时O(nlogn)。
-
联机操作阶段共m次,每次操作耗时O(logn)。
-
总体时间复杂度:O((n+m)logn)。对于10^5的数据规模,运算量在 2×10^6 级别,安全稳定。
-
-
空间复杂度: 仅需开辟一个长度为n+1的
long long数组,总体空间复杂度为O(n)。
六、 易错点总结
在信奥实战中,能写出树状数组只是及格线,能避开以下三个学生易错的点,才能确保AC:
-
IO 竞速陷阱(易 TLE): 操作数达到 10 万级别,建议在主函数开头加上
ios::sync_with_stdio(false); cin.tie(0);解除绑定,并且输出换行必须用\n,严禁用endl强刷缓冲区。 -
数据类型溢出(极易 WA): 题目中随时可能出现高频次的极大数值累加。凡是涉及到树状数组本体、更新的值(
val)、查询的返回值,请一律使用long long声明,彻底杜绝隐形溢出。 -
下标从0开始的惨剧(极易死循环): 树状数组的下标绝对不能传入 0。因为
lowbit(0)=0,会导致update和sum函数中的while循环陷入死循环跳不出来。
七、完整代码
cpp
//单点修改 区间查询
#include <iostream>
using namespace std;
typedef long long ll;
int n,m;
ll c[100010];//树状数组
//求x在二进制表示下最右边的最低位的1所代表的整数
int lowbit(int x){
return x&(-x);
}
//单点修改 x代表修改原数列第x个数
//到树状数组中则是要修改所有包含原数列第x项的位置
//val代表原数列第x项增加val
void update(int x,ll val){
for(int i=x;i<=n;i+=lowbit(i)){
c[i]+=val;
}
}
//求原数列从第一项到第x项的和
ll sum(int x){
ll ret=0;//记录和
while(x>0){
ret+=c[x];//树状数组c[x]一定包含原数列第x项
//重点:减去c[x]所管辖的原数列项数
//意义:精准地往回跳跃到一个不重叠的、相邻的上一级区块
x-=lowbit(x);//要减去c[x]包含的原数列的项数
}
return ret;
}
int main(){
//io加速
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
//不需要给原数组赋值,直接把每次赋值变为对树状数组
//所有包含a[i](原数组)的项的更新
for(int i=1;i<=n;i++){
ll x;
cin>>x;
update(i,x);
}
//接下来执行m次操作
while(m--){
int k,a;
ll b;
cin>>k>>a>>b;
if(k==0){//求子数列[a,b]的连续和
//即算出从1至b的数列和减去1至(a-1)的数列和
cout<<sum(b)-sum(a-1)<<"\n";
}
//第a个数加b
else update(a,b);
}
return 0;
}