【题目描述】
这是一道模板题。
给出一个 n×m 的零矩阵 A,你需要完成如下操作:
1xyk:表示元素 Ax,y自增 k;
2abcd:表示询问左上角为 (a,b),右下角为 (c,d) 的子矩阵内所有数的和。
【输入】
输入的第一行有两个正整数 n,m;
接下来若干行,每行一个操作,直到文件结束。
【输出】
对于每个 2 操作,输出一个整数,表示对于这个操作的回答。
【输入样例】
2 2
1 1 1 3
1 2 2 4
2 1 1 2 2
【输出样例】
7
【提示】
数据范围与提示:
对于 10% 的数据,n=1;
对于另 10% 的数据,m=1;
对于全部数据,1≤n,m≤2^12,1≤x,a,c≤n,1≤y,b,d≤m,∣k∣≤10^5 ,保证操作数目不超过 3×10^5 ,且询问的子矩阵存在。
一、 题目分析
【题目大意】 维护一个n×m的初始全为0的矩阵 A,要求高效支持两种操作:
-
单点修改:将矩阵中坐标为(x,y)的元素自增 k。
-
子矩阵求和:查询左上角为(a,b),右下角为 (c,d) 的子矩阵内所有元素的和。
【数据范围与核心矛盾】 1≤n,m≤4096,操作总数 ≤3×10^5。 如果每次修改是 O(1),查询用两层 for循环暴力遍历,查询的时间复杂度将高达O(N×M),在 30 万次操作下绝对会超时。我们必须将单次操作的复杂度降到对数级别,即O(logN×logM)。
二、 思考过程
-
一维到二维的推演 : 一维树状数组
tree[x]维护的是一段长度为lowbit(x)的线状区间。因为二维平面的横纵坐标是绝对正交、互不干扰的,所以当我们升维到二维时,tree[x][y]维护的就是一个矩形面积 :宽度为lowbit(x),高度为lowbit(y)。 -
修改操作(单点波及面) : 当 (x,y) 发生改变时,我们需要更新所有"管辖领地"包含 (x,y) 的大节点。在一维中是用
for循环不断i+=lowbit(i)往后跳;在二维中,只需要将x轴的跳跃和y轴的跳跃进行双层嵌套组合即可。 -
查询操作(前缀矩阵和) : 同样利用双层嵌套,
x-=lowbit(x)配合y-=lowbit(y),我们可以极其高效地拼凑出从左上角 (1,1) 到右下角 (x,y) 的整个前缀矩阵的面积和。
三、 解题思路
树状数组原生的 query(x,y) 只能求出"紧贴左上角"的前缀矩阵和。但题目要求的是任意一个悬空的子矩阵 (a,b) 到 (c,d) 的和。
这时候必须引入二维前缀和的核心------容斥原理: 要计算悬空子矩阵的面积,我们可以用大矩形挖去多余的部分:
-
先拿到大矩形:
query(c,d)。 -
挖掉它上方多余的矩形:
-query(a-1,d)。 -
挖掉它左边多余的矩形:
-query(c,b-1)。 -
因为左上角的矩形被挖了两次,必须补偿回来:
+query(a-1,b-1)。
最终公式:Ans=query(c,d)-query(c,b-1)-query(a-1,d)+query(a-1,b-1)。
四、 算法设计
-
数据结构 :二维数组
c[5000][5000]。因为矩阵求和极易突破21亿的整型极限,该数组及查询函数的返回值必须使用long long。 -
lowbit函数 :
x&(-x),提取二进制最低位的1。 -
add函数 :双层
for循环,控制变量均使用+=lowbit()向上攀升更新。 -
query 函数 :双层
for循环,控制变量均使用-=lowbit()向下聚拢求和。 -
IO 优化 :使用
ios::sync_with_stdio(false); cin.tie(0);应对 30 万次的庞大输入流。
五、 时空复杂度分析
-
时间复杂度:
-
单次
add和query的时间复杂度均为O(logN×logM)。 -
总时间复杂度为O(Q×logN×logM),在N=4096时,logN≈12。单次操作最多只需循环144 次。30 万次操作的计算量在千万级别,1 秒内毫无压力。
-
-
空间复杂度:需要开辟一个5000×5000的64位整数数组,空间复杂度O(N×M),约占用 200MB内存,符合常规竞赛的空间限制。
六、 易错点总结
-
容斥边界切勿漏减1 :在容斥原理公式中,必须是
a-1和b-1。如果写成a和b,会把子矩阵边界上的那一行/那一列也给错误地挖掉。 -
全局变量与局部变量撞车 :我们以往的风格是将树状数组命名为
c。而在处理询问时,局部变量又定义了int a,b,c,d;。虽然C++允许局部变量遮蔽全局变量,使得query(c, d)传递的是局部变量,但这在工程上是极度危险的做法。强烈建议将全局树状数组命名为cc,避免 Bug。 -
数据溢出 :二维求和极其庞大,一定要将
ans和cc数组开成long long。
七、 题解
本题是二维树状数组最纯粹的模板题。通过两层嵌套循环将一维的线段管辖扩展至二维的矩形管辖,再辅以二维前缀和的容斥原理,用极其轻量级的代码和极小的常数,完美解决了二维动态矩阵的维护问题。相比于二维线段树(树套树),二维树状数组是考场上兼顾速度与代码稳定性的最优解。
八、 完整代码
cpp
#include <iostream>
using namespace std;
long long cc[5000][5000];//树状数组
int n,m;
//返回x二进制表示下最低位1所代表的整数
int lowbit(int x){
return x&(-x);
}
//树状数组更新操作
void add(int x,int y,int k){
//双层循环,x轴和y轴均向上攀升,更新所有包含(x,y)的大管辖矩阵
for(int i=x;i<=n;i+=lowbit(i)){
for(int j=y;j<=m;j+=lowbit(j)){
cc[i][j]+=k;
}
}
}
//树状数组查询操作
//查询左上角为(1,1),右下角为(x,y)的前缀矩阵和
long long query(int x,int y){
long long ret=0ll;//记录前缀和
//双层循环,x轴和y轴均向下聚拢,无缝拼凑出前缀面积
for(int i=x;i>0;i-=lowbit(i)){
for(int j=y;j>0;j-=lowbit(j)){
ret+=cc[i][j];
}
}
return ret;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
int t;
while(cin>>t){
if(t==1){//自增操作
int x,y,k;
cin>>x>>y>>k;
add(x,y,k);
}
else{//查询操作
int a,b,c,d;
cin>>a>>b>>c>>d;
//套用二维前缀和容斥原理公式
//务必注意是a-1和b-1,否则会多挖掉一行一列
cout<<query(c,d)-query(c,b-1)-query(a-1,d)+query(a-1,b-1)<<"\n";
}
}
}