文章目录
- 树状数组是什么
- 树状数组完整原理推导
-
- 一.树状数组核心思想
- 二.前缀和查询原理
- 三.单点更新原理
-
- 重点注释:
- 四.多语言代码展示
-
- 引用牛客PEEK73 【模板】动态区间和Ⅰ ‖ 单点修改 + 区间查询
- Java的代码如下:
- C++代码如下:
- C语言代码如下:
- Python代码如下:
- 树状数组代码时间+空间复杂度汇总表
- 树状数组的更多应用:
-
- 1.区间增量与单点求值
-
- 引用:牛客PEEK74 区间增量与单点求值
- 2.区间增值和区间求和
-
- 引用洛谷P3372 【模板】线段树 1
-
- 解法一.树状数组暴力解法
- 解法二.双树状数组模拟解法
- 一、基础定义
- 二、步骤1:证明差分数组前缀和
c
[
k
]
=
a
k
c[k] = a_k
c[k]=ak - 三、步骤2:推导原数组前
k
k
k 项总和S
(
k
)
=
a
1
+
a
2
+
⋯
+
a
k
S(k) = a_1+a_2+\\dots+a_k
S(k)=a1+a2+⋯+ak -
- 交换二重求和顺序
- 拆分求和项
- 四、最终结论
- 解法三.线段树经典解法
- 3.二进制区间取反与单点求值
-
- 引用:牛客PEEK75 区间取反与单点求值
-
- 思考
- 4.二维单点增加和范围查询
- 5.二维范围增加和范围查询
-
- 引用洛谷P4514
-
- 二维区间加、区间求和完整数学推导
-
- 1. 基础定义
- 2. 定义全局前缀和
S
(
x
,
y
)
S(x,y)
S(x,y) - 3. 交换求和顺序
- 4. 展开多项式
(
x
−
p
+
1
)
(
y
−
q
+
1
)
(x-p+1)(y-q+1)
(x−p+1)(y−q+1) - 5. 代入拆分四项求和
- 6. 定义四个二维前缀和(对应代码4棵树)
- 总结一下
树状数组是什么
试想一下:在上世纪在上世纪 90 年代,无损压缩领域广泛使用算术编码(一种可以逼近理论压缩极限的无损压缩算法,核心原理是将一整段文本映射到区间[0,1)内唯一小数完成压缩,编码、解码全程需要频繁查询符号累积出现总次数,也就是累积频率表,因此要解决自适应算术编码中累积频率表动态维护的问题),所以这个时候前缀和,暴力遍历求解,平衡二叉树就挑起了担子,我们知道前缀和在求加和的时候需要O(n) 的时间复杂度(即求l,r的下标数组和需要构建前缀和数组,然后前缀和数组的第r项减去第l-1项即可),而暴力求解即为O(n),在多次查询加和的时候就会更耗时间,而虽然平衡二叉树可以做 O(logn) 的时间复杂度,但逻辑复杂,代码量大,且依赖指针节点内存开销大、内存访问不连续缓存效率差,实际运行常数就会很高,所以这个时候还是会想到计算机底层的0和1,如果我能把数字插为2的幂(即以二进制的形式)用于计算加和,且利用 lowbit 拆分下标分段存储局部区间和的思想,树状数组就诞生了。
树状数组完整原理推导
我们用一段推导来表达它的原理:
一.树状数组核心思想
我们在这里对任意数组下标
x
x
x,
t
r
e
e
[
x
]
tree[x]
tree[x]专门维护一段连续区间: 区间右端点固定为
x
x
x,区间长度等于
l
o
w
b
i
t
(
x
)
lowbit(x)
lowbit(x),这样的区域为下标x所管辖的区域。 假设有一个下标1~16的数组,我们每次通过对半开的形式,然后计算各下标单独管辖范围:
-
x
=
1
x=1
x=1,l
o
w
b
i
t
(
1
)
=
1
lowbit(1)=1
lowbit(1)=1,管辖区间[
1
]
[1]
[1] -
x
=
2
x=2
x=2,l
o
w
b
i
t
(
2
)
=
2
lowbit(2)=2
lowbit(2)=2,管辖区间[
1
,
2
]
[1,2]
[1,2] -
x
=
3
x=3
x=3,l
o
w
b
i
t
(
3
)
=
1
lowbit(3)=1
lowbit(3)=1,管辖区间[
3
]
[3]
[3] -
x
=
4
x=4
x=4,l
o
w
b
i
t
(
4
)
=
4
lowbit(4)=4
lowbit(4)=4,管辖区间[
1
,
2
,
3
,
4
]
[1,2,3,4]
[1,2,3,4] -
x
=
5
x=5
x=5,l
o
w
b
i
t
(
5
)
=
1
lowbit(5)=1
lowbit(5)=1,管辖区间[
5
]
[5]
[5] -
x
=
6
x=6
x=6,l
o
w
b
i
t
(
6
)
=
2
lowbit(6)=2
lowbit(6)=2,管辖区间[
5
,
6
]
[5,6]
[5,6] -
x
=
7
x=7
x=7,l
o
w
b
i
t
(
7
)
=
1
lowbit(7)=1
lowbit(7)=1,管辖区间[
7
]
[7]
[7] -
x
=
8
x=8
x=8,l
o
w
b
i
t
(
8
)
=
8
lowbit(8)=8
lowbit(8)=8,管辖区间[
1
∼
8
]
[1\\sim8]
[1∼8] -
x
=
9
x=9
x=9,管辖[
9
]
[9]
[9]; -
x
=
10
x=10
x=10,管辖[
9
,
10
]
[9,10]
[9,10]; -
x
=
11
x=11
x=11,管辖[
11
]
[11]
[11]; -
x
=
12
x=12
x=12,管辖[
9
,
10
,
11
,
12
]
[9,10,11,12]
[9,10,11,12] -
x
=
13
x=13
x=13,管辖[
13
]
[13]
[13]; -
x
=
14
x=14
x=14,管辖[
13
,
14
]
[13,14]
[13,14]; -
x
=
15
x=15
x=15,管辖[
15
]
[15]
[15]; -
x
=
16
x=16
x=16,管全部。 也就是说在1
∼
n
1\\sim n
1∼n范围内,任意下标x
x
x,以x
x
x作为子数组右边界、区间长度为l
o
w
b
i
t
(
x
)
lowbit(x)
lowbit(x)的连续区间,就是树状数组t
r
e
e
[
x
]
tree[x]
tree[x]独立管理的区域;所有以x
x
x为右边界的前缀区间,都可以拆分为多段上述标准区间累加,也就是前缀和查询的底层逻辑。 左神课上的原图:
二.前缀和查询原理
求前缀和
s
u
m
(
1
∼
x
)
sum(1\\sim x)
sum(1∼x)流程:
x
x
x对应的管辖区间和
t
r
e
e
[
x
]
tree[x]
tree[x];
x
=
x
−
l
o
w
b
i
t
(
x
)
x = x – lowbit(x)
x=x−lowbit(x),跳到下一段靠左区间的右边界;
x
=
0
x=0
x=0。 举例:求
s
u
m
(
1
∼
6
)
sum(1\\sim6)
sum(1∼6)
x
=
6
x=6
x=6,
l
o
w
b
i
t
=
2
lowbit=2
lowbit=2,管辖
[
5
,
6
]
[5,6]
[5,6],累加
t
r
e
e
[
6
]
tree[6]
tree[6],
x
=
6
−
2
=
4
x=6-2=4
x=6−2=4
x
=
4
x=4
x=4,
l
o
w
b
i
t
=
4
lowbit=4
lowbit=4,管辖
[
1
,
4
]
[1,4]
[1,4],累加
t
r
e
e
[
4
]
tree[4]
tree[4],
x
=
4
−
4
=
0
x=4-4=0
x=4−4=0 最终总和:
t
r
e
e
[
4
]
+
t
r
e
e
[
6
]
tree[4]+tree[6]
tree[4]+tree[6],完整覆盖
1
∼
6
1\\sim6
1∼6所有元素。 通俗点来说就是比如说我们要求1~12的累加和,就先看12的二进制(1100),对应到图表中12管9 ~12下标的元素,然后我们找到它二进制最右边1,然后把它消去,变为(1000)就是8,然后在对应上面,它管1 ~8,那么我们按这样的变换把所有元素加起来就是1 ~12的和了
三.单点更新原理
给下标
x
x
x数值增加
v
v
v流程:
t
r
e
e
[
x
]
+
=
v
tree[x] += v
tree[x]+=v,更新当前
x
x
x管辖区间;
x
=
x
+
l
o
w
b
i
t
(
x
)
x = x + lowbit(x)
x=x+lowbit(x),跳到所有覆盖
x
x
x的更大右边界;
x
>
n
x>n
x>n。
原理:所有右端点大于
x
x
x、且管辖区间包含下标
x
x
x的树状数组节点,都需要同步更新数值。 这里也是一样,对一个值进行更行,比如给第3个元素加一个v,我们要看它的树状数组每个下标,看有哪几个管辖这下标3,它们的值都要加上一个v,那应该怎么做呢?其实还是位运算,3的二进制是0011,那我们减去最右边的1,然后再加上原二进制加上它最右侧的1,就是0010+0010=0100(就是4的二进制),和上面的推导相符,然后接着这样(直到越界)就可找到每一个管辖了3的下标,把它们都加上一个v,这样我们就修改成功了。
重点注释:
树状数组的核心是要取一个数字二进制的最右边的1,那为啥x&(-x)能实现呢,推导过程如下:
11的二进制是00001011,对它进形结论中的操作:
+11原码:00001011
-11原码:10001011
-11反码:11110100
-11补码:11110101
0 0 0 0 1 0 1 1
1 1 1 1 0 1 0 1
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
0 0 0 0 0 0 0 1
可知11的二进制最低位的1恰好是原数最低位的1
其实就是负数用补码(按位取反 + 1),使x与-x仅在最低位 1 处同为 1,按位与便只取出该位权值,得到它二进制最低位1的位置
即证明
四.多语言代码展示
基于以上原理:
引用牛客PEEK73 【模板】动态区间和Ⅰ ‖ 单点修改 + 区间查询
题目链接如下: 牛客树状数组模版题 同样的牛客这个题对应洛谷的P3374,链接如下: 洛谷P3374树状数组模版题
Java的代码如下:
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br=new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out=new PrintWriter(System.out);
static StringTokenizer st;
static int N=500010;//按题目开出一个合适的空间
static long []Tree;
static int op;
static int n;
static int m;
public static int lowbit(int x){//核心方法:取出二进制最右边的1
return x&(–x);
}
public static void add(int x,int v){
while(x<=N){//和上面的推导一样
Tree[x]+=v;//把这些管辖了x的下标元素都加v
x+=lowbit(x);//找到每位管辖x的下标
}
}
public static long query(int x){
long res=0;
while(x>0){
res+=Tree[x];//再把每个下标的元素相加
x-=lowbit(x);//我们不断抹去x的二进制最右边的1
}
return res;
}
public static void main(String[] args) throws IOException{
Tree = new long[N + 1];
st=new StringTokenizer(br.readLine());
n=Integer.parseInt(st.nextToken());
m=Integer.parseInt(st.nextToken());
st=new StringTokenizer(br.readLine());
for(int i=1;i<=n;i++){
int num=Integer.parseInt(st.nextToken());
add(i,num);//这个一定不要忘记了,就是要初始化
}
while(m—>0){
st=new StringTokenizer(br.readLine());
op=Integer.parseInt(st.nextToken());
if(op==1){
int i=Integer.parseInt(st.nextToken());
int x=Integer.parseInt(st.nextToken());
add(i,x);
}else{
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
out.println(query(r)–query(l–1));
}
}
out.flush();
br.close();
out.close();
}
}
C++代码如下:
//思路和前面的一样
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N = 10000010;
static ll Tree[N];
static ll n, m,op;
static ll lowbit(ll x) {
return x & (–x);
}
static void add(ll x, ll v) {
while (x <= N) {
Tree[x] += v;
x += lowbit(x);
}
}
static ll query(ll x) {
ll res = 0;
while (x > 0) {
res += Tree[x];
x -= lowbit(x);
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int num;
cin >> num;
add(i, num);
}
while (m—) {
cin>>op;
if(op==1){
int i,x;
cin>>i>>x;
add(i,x);
}else{
int l,r;
cin>>l>>r;
cout<<query(r)–query(l–1)<<"\\n";
}
}
return 0;
}
C语言代码如下:
//和前面的思路也一样
#include <stdio.h>
typedef long long ll;
const int N = 500010;
ll Tree[N];
static int lowbit(int x) {
return x & (–x);
}
static void add(int x, ll v) {
while (x <= N) {
Tree[x] += v;
x += lowbit(x);
}
}
static ll query(int x) {
ll res = 0;
while (x > 0) {
res += Tree[x];
x -= lowbit(x);
}
return res;
}
int main() {
int n, m;
scanf("%d %d", &n, &m);
for (int i = 1; i <= n; i++) {
ll num;
scanf("%lld", &num);
add(i, num);
}
while (m—) {
int op;
scanf("%d", &op);
if (op == 1) {
int i;
ll x;
scanf("%d %lld", &i, &x);
add(i, x);
} else {
int l, r;
scanf("%d %d", &l, &r);
printf("%lld\\n", query(r) – query(l – 1));
}
}
return 0;
}
Python代码如下:
# 和前面的思路也相同
import sys
def main():
data = list(map(int, sys.stdin.read().split()))
ptr = 0
N = 500010
Tree = [0] * (N + 1)
def lowbit(x):
return x & –x
def add(x, v):
while x <= N:
Tree[x] += v
x += lowbit(x)
def query(x):
res = 0
while x > 0:
res += Tree[x]
x -= lowbit(x)
return res
n = data[ptr]
ptr += 1
m = data[ptr]
ptr += 1
for i in range(1, n + 1):
num = data[ptr]
ptr += 1
add(i, num)
output = []
for _ in range(m):
op = data[ptr]
ptr += 1
if op == 1:
i = data[ptr]
ptr += 1
val = data[ptr]
ptr += 1
add(i, val)
else:
l = data[ptr]
ptr += 1
r = data[ptr]
ptr += 1
res = query(r) – query(l – 1)
output.append(str(res))
print('\\n'.join(output))
if __name__ == "__main__":
main()
它的时间复杂度如下:
树状数组代码时间+空间复杂度汇总表
| lowbit(x) 函数 | 提取数字二进制最右侧1对应数值 |
O ( 1 ) O(1) O(1) |
O ( 1 ) O(1) O(1) |
纯位运算,无额外空间 |
| add(x, v) 单点更新函数 | 下标x位置增加v,更新树状数组 |
O ( log n ) O(\\log n) O(logn) |
O ( 1 ) O(1) O(1) |
仅局部变量,不新开数组 |
| query(x) 前缀和查询函数 | 计算
[ 1 , x ] [1,x] [1,x] 累加和 |
O ( log n ) O(\\log n) O(logn) |
O ( 1 ) O(1) O(1) |
仅临时存储结果res |
| 区间和查询 query®-query(l-1) | 计算
[ l , r ] [l,r] [l,r] 区间和 |
O ( log n ) O(\\log n) O(logn) |
O ( 1 ) O(1) O(1) |
两次前缀查询,无额外空间 |
| 数组初始化循环 | 读入原始数组初始化树状数组 |
O ( n log n ) O(n\\log n) O(nlogn) |
– | 不新增空间,仅调用add |
| m次操作主循环 | 处理m次修改/查询 |
O ( m log n ) O(m\\log n) O(mlogn) |
O ( 1 ) O(1) O(1) |
每次操作临时变量,空间常数 |
| 全局数组 Tree[N] | 树状数组存储容器 | – |
O ( N ) O(N) O(N) |
定义常量 N=10000010,静态全局数组占用N个long long空间 |
| 整体程序总复杂度 | 完整程序 | 时间:
O ( ( n + m ) log n ) O((n+m)\\log n) O((n+m)logn) |
空间:
O ( N ) O(N) O(N) |
N为预设最大数据范围,与输入n无关 |
树状数组的更多应用:
1.区间增量与单点求值
引用:牛客PEEK74 区间增量与单点求值
题目链接如下: 牛客PEEK74区间增量与单点求值 同样的牛客这道题对应洛谷P3368,题目链接如下: 洛谷P3368区间增量与单点求值 其实在这里这个题唯一的改变就是从只改单个值,变为了区间改值,那我们在区间改值下能想到啥?就是差分,差分顾名思义,就是数组的前后两项相减,即:b[i]=a[i]-a[i-1],经典的模版如下:
//实现一维差分
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static int n,m;
public static void main(String[] args) throws IOException{
st=new StringTokenizer(br.readLine());
n=Integer.parseInt(st.nextToken());
m=Integer.parseInt(st.nextToken());
int[] a=new int[n+1];
st=new StringTokenizer(br.readLine());
for(int i=1;i<=n;i++){
a[i]=Integer.parseInt(st.nextToken());
}
int []b=new int[n+2];
b[1]=a[1];//差分数组的第一项就是原数组的第一项
for(int i=2;i<=n;i++){
b[i]=a[i]–a[i–1];//构建一个差分数组
}
while(m— > 0){
st=new StringTokenizer(br.readLine());
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
int k=Integer.parseInt(st.nextToken());
//对于修改l~r范围的值都加个k,为啥可以这样呢?
//原理:差分数组求前缀和还原原数组
//1.b[l]+=k:从l位置往后所有前缀和全部增加k
//2.b[r+1]-=k:从r+1位置往后抵消增加的k,保证r之后数值不变
//最终仅区间[l,r]内所有元素统一加上k,单次修改仅2次操作O(1)
b[l]+=k;
b[r+1]-=k;
}
for(int i=1;i<=n;i++){
a[i]=a[i–1]+b[i];//这里记得要加和回去进行前缀和变为原数组
}
for(int i=1;i<=n;i++){
out.print(a[i]);//格式化输出
if(i!=n) out.print(" ");
}
out.flush();
out.close();
br.close();
}
}
所以基于这个原理,我们在创建树状数组的时候就应该初始化原数组的差分形式,在单点求值求值的时候就直接进行一个前缀和操作(对应树状数组的query方法)就可以了,而区间增加一个量就按上面这个差分的操作(对应树状数组的add方法),这道题就可以解了:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N = 500010;
static ll Tree[N];
static ll n, m;
static ll lowbit(ll x) {
return x & (–x);
}
static void add(ll x, ll v) {
while (x <= n) {
Tree[x] += v;
x += lowbit(x);
}
}
static ll query(ll x) {
ll res = 0;
while (x > 0) {
res += Tree[x];
x -= lowbit(x);
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
vector<ll> a(n + 1);
for (int i = 1; i <= n; i++) {
cin >> a[i];
add(i, a[i] – a[i – 1]);//初始化的是原数组的差分形式
}
while (m—) {
int op;
cin >> op;
if (op == 1) {
int x, y, k;
cin >> x >> y >> k;
//和上面代码一样,以差分的方式对区间求值
add(x, k);
add(y + 1, –k);
} else {
int x;
cin >> x;
cout << query(x) << "\\n";//差分求和就是原数组
}
}
return 0;
}
2.区间增值和区间求和
其实这个问题对于树状数组来说有难度,引用洛谷P3372线段数模版,在这里我会有三种不同的解法,但不是每一种都合适,也不是每一种都能解这个题:
引用洛谷P3372 【模板】线段树 1
题目链接如下: 洛谷P3372 在这里我们先不去想线段树的解法,在看到这个题目的要求,要我们实现区间增值和区间求和,能想到啥,前缀和和差分的结合不就能实现了吗,那怎么加在一起?以上面的树状数组为载体就可以了
解法一.树状数组暴力解法
代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MAX=1000010;
static ll Tree[MAX];
static ll sum[MAX];
static int n,m;
static int lowbit(int x) {
return x&(–x);
}
static void add1(int x,int v) {
while (x<=n) {
Tree[x]+=v;
x+=lowbit(x);
}
}
static ll query(int x) {
ll res=0;
while (x>0) {
res+=Tree[x];
x-=lowbit(x);
}
return res;
}
ll get_sum(int x){//这里实现了一个对差分和再求和的过程,即求前缀和
sum[0] = 0;
for(int i=1;i<=x;i++){
sum[i] = sum[i–1] + query(i);
}
return sum[x];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin>>n>>m;
vector<int> a(n+1, 0);
for (int i = 1; i <= n; i++) {
cin>>a[i];
add1(i, a[i] – a[i–1]);//也是传入它的差分形式
}
while (m—) {
int op;
cin >> op;
if (op == 1) {
int L, R, k;
cin >> L >> R >> k;
//这里和前面差分的逻辑是一样的
add1(L, k);
add1(R+1, –k);
}else {
int L,R;
cin >> L >> R;
ll total = get_sum(R) – get_sum(L–1);//前缀和的标准操作
cout << total << '\\n';
}
}
return 0;
}
理论上,这种解法是对的,但是,在考虑时间复杂度的时候就不行了,它的时间复杂度如下:
| lowbit(int x) | 获取二进制最低位1 |
O ( 1 ) O(1) O(1) |
位运算,常数时间 |
| add1(int x, int v) | 树状数组单点加值 |
O ( log n ) O(\\log n) O(logn) |
循环最多
log 2 n \\log_2 n log2n次 |
| query(int x) | 求差分数组
[ 1 , x ] [1,x] [1,x]前缀和 |
O ( log n ) O(\\log n) O(logn) |
循环最多
log 2 n \\log_2 n log2n次 |
| get_sum(int x) | 求原数组
[ 1 , x ] [1,x] [1,x]前缀和 |
O ( n log n ) O(n\\log n) O(nlogn) |
循环1~x,每次调用一次
O ( log n ) O(\\log n) O(logn)的query |
| 初始化读入数组循环 | 构建差分树状数组 |
O ( n log n ) O(n\\log n) O(nlogn) |
n次add1,每次
O ( log n ) O(\\log n) O(logn) |
| op==1 区间加
[ L , R ] + k [L,R]+k [L,R]+k |
区间批量增加k |
O ( log n ) O(\\log n) O(logn) |
两次add1操作 |
| op==2 查询
[ L , R ] [L,R] [L,R]总和 |
区间求和 |
O ( n log n ) O(n\\log n) O(nlogn) |
get_sum®、get_sum(L-1)各
O ( n log n ) O(n\\log n) O(nlogn) |
| 整体程序总时间复杂度 | 完整程序 |
O ( n log n + m ⋅ n log n ) O(n\\log n + m\\cdot n\\log n) O(nlogn+m⋅nlogn) |
m次查询操作会造成极高时间开销 |
所以可想而知,我们这个代码的时间复杂度不是我们想要的O(logn),且它过不了这个题的,如果提交就是如下结果:
很显然是超时了的,时间复杂度太大了。
所以很显然我们要对它进行优化:
解法二.双树状数组模拟解法
在使用这个解法前,我们先思考一下:对于树状数组来说,要解这个题,还是前缀和和差分的结合,但我们要优化,推导过程如下:
一、基础定义
设原数组
a
[
1
…
n
]
a[1\\dots n]
a[1…n],规定
a
0
=
0
a_0=0
a0=0 差分数组
b
[
1
…
n
]
b[1\\dots n]
b[1…n]:
b
i
=
a
i
−
a
i
−
1
b_i = a_i – a_{i-1}
bi=ai−ai−1
设
c
[
k
]
c[k]
c[k] 为差分数组
b
b
b 的前缀和:
c
[
k
]
=
∑
i
=
1
k
b
i
=
b
1
+
b
2
+
⋯
+
b
k
c[k] = \\sum_{i=1}^k b_i = b_1 + b_2 + \\dots + b_k
c[k]=i=1∑kbi=b1+b2+⋯+bk
二、步骤1:证明差分数组前缀和
c
[
k
]
=
a
k
c[k] = a_k
c[k]=ak
将
b
i
=
a
i
−
a
i
−
1
b_i = a_i – a_{i-1}
bi=ai−ai−1 代入展开:
c
[
k
]
=
b
1
+
b
2
+
b
3
+
⋯
+
b
k
=
(
a
1
−
a
0
)
+
(
a
2
−
a
1
)
+
(
a
3
−
a
2
)
+
⋯
+
(
a
k
−
a
k
−
1
)
\\begin{aligned} c[k] &= b_1 + b_2 + b_3 + \\dots + b_k \\\\ &= (a_1-a_0) + (a_2-a_1) + (a_3-a_2) + \\dots + (a_k – a_{k-1}) \\end{aligned}
c[k]=b1+b2+b3+⋯+bk=(a1−a0)+(a2−a1)+(a3−a2)+⋯+(ak−ak−1) 裂项全部抵消,
a
0
=
0
a_0=0
a0=0:
c
[
k
]
=
a
k
c[k] = a_k
c[k]=ak 等价关系:
∑
i
=
1
k
b
i
=
a
k
\\sum_{i=1}^k b_i = a_k
i=1∑kbi=ak
三、步骤2:推导原数组前
k
k
k 项总和
S
(
k
)
=
a
1
+
a
2
+
⋯
+
a
k
S(k) = a_1+a_2+\\dots+a_k
S(k)=a1+a2+⋯+ak
把
a
i
=
∑
t
=
1
i
b
t
a_i = \\sum_{t=1}^i b_t
ai=∑t=1ibt 代入总和公式:
S
(
k
)
=
∑
i
=
1
k
a
i
=
∑
i
=
1
k
(
∑
t
=
1
i
b
t
)
S(k) = \\sum_{i=1}^k a_i = \\sum_{i=1}^k \\left( \\sum_{t=1}^i b_t \\right)
S(k)=i=1∑kai=i=1∑k(t=1∑ibt)
交换二重求和顺序
b
t
b_t
bt 在
i
=
t
,
i
=
t
+
1
,
…
,
i
=
k
i=t,i=t+1,\\dots,i=k
i=t,i=t+1,…,i=k 中一共出现
k
−
t
+
1
k-t+1
k−t+1 次:
S
(
k
)
=
∑
t
=
1
k
b
t
⋅
(
k
−
t
+
1
)
=
∑
t
=
1
k
b
t
⋅
[
k
−
(
t
−
1
)
]
=
∑
t
=
1
k
[
k
⋅
b
t
−
(
t
−
1
)
⋅
b
t
]
\\begin{aligned} S(k) &= \\sum_{t=1}^k b_t \\cdot (k – t + 1) \\\\ &= \\sum_{t=1}^k b_t \\cdot \\big[ k – (t-1) \\big] \\\\ &= \\sum_{t=1}^k \\Big[ k\\cdot b_t – (t-1)\\cdot b_t \\Big] \\end{aligned}
S(k)=t=1∑kbt⋅(k−t+1)=t=1∑kbt⋅[k−(t−1)]=t=1∑k[k⋅bt−(t−1)⋅bt]
拆分求和项
S
(
k
)
=
k
⋅
∑
t
=
1
k
b
t
−
∑
t
=
1
k
(
t
−
1
)
⋅
b
t
S(k) = k\\cdot \\sum_{t=1}^k b_t \\;-\\; \\sum_{t=1}^k (t-1)\\cdot b_t
S(k)=k⋅t=1∑kbt−t=1∑k(t−1)⋅bt
四、最终结论
原数组前
k
k
k 项累加和公式:
a
1
+
a
2
+
⋯
+
a
k
=
k
⋅
∑
i
=
1
k
b
i
−
∑
i
=
1
k
(
i
−
1
)
⋅
b
i
\\boldsymbol{a_1+a_2+\\dots+a_k = k \\cdot \\sum_{i=1}^k b_i \\;-\\; \\sum_{i=1}^k (i-1)\\cdot b_i}
a1+a2+⋯+ak=k⋅i=1∑kbi−i=1∑k(i−1)⋅bi
∑
i
=
1
k
b
i
\\displaystyle\\sum_{i=1}^k b_i
i=1∑kbi:Tree1 维护,存储差分数组
b
i
b_i
bi
∑
i
=
1
k
(
i
−
1
)
b
i
\\displaystyle\\sum_{i=1}^k (i-1)b_i
i=1∑k(i−1)bi:Tree2 维护,存储
(
i
−
1
)
⋅
b
i
(i-1)\\cdot b_i
(i−1)⋅bi
所以基于以上推导过程,我们要添加几个方法: 以下为左神上课原图(上面的sum方法其实就是模版里的query方法):
通俗一点来说我们要求l~r范围内的累加和就是先去维护一个方法query(求1 ~k的累加和),l ~r的累加和就是query(1-r)-query(1-(l-1)),,就是图中的range方法,而要区间增值,其实也是和差分是一样的,照着公式来说:
a
1
+
a
2
+
⋯
+
a
k
=
k
⋅
∑
i
=
1
k
b
i
−
∑
i
=
1
k
(
i
−
1
)
⋅
b
i
\\boldsymbol{a_1+a_2+\\dots+a_k = k \\cdot \\sum_{i=1}^k b_i \\;-\\; \\sum_{i=1}^k (i-1)\\cdot b_i}
a1+a2+⋯+ak=k⋅i=1∑kbi−i=1∑k(i−1)⋅bi
∑
i
=
1
k
b
i
\\displaystyle\\sum_{i=1}^k b_i
i=1∑kbi:Tree1 维护,存储差分数组
b
i
b_i
bi
∑
i
=
1
k
(
i
−
1
)
b
i
\\displaystyle\\sum_{i=1}^k (i-1)b_i
i=1∑k(i−1)bi:Tree2 维护,存储
(
i
−
1
)
⋅
b
i
(i-1)\\cdot b_i
(i−1)⋅bi
差分我修改的话就要分两个部分来修改(就和上图一样),正好对应了我们设出的两个树状数组,所以代码如下: java代码:
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
final static long MAX=1000010;
// Tree1:维护差分数组b[i]的前缀和 Σb[i]
static long []Tree1;
// Tree2:维护 (i-1)*b[i] 的前缀和 Σ(i-1)*b[i]
static long []Tree2;
static int n,m;
public static int lowbit(int x){
return x&(–x);
}
// 单点更新两棵树:差分数组x位置增加v
public static void update(int x,long v){
// 循环向上更新所有包含x的区间节点
for(int i=x;i<MAX;i+=lowbit(i)){
Tree1[i]+=v;
// Tree2对应存入 (x-1)*v,和前面的推导公式相同
Tree2[i]+=(x–1)*v;
}
}
// 区间 [l,r] 整体加上数值v,进行一个差分的操作
public static void RangeAdd(int l,int r,long v){
update(l,v); // l位置增量v,l之后全部+ v
update(r+1,–v); // r+1位置减v,这样就可以抵消增量,而且仅l~r生效
}
// 查询原数组前x项总和,套用前面推出来的公式 S(x) = x*Σb[i] – Σ(i-1)*b[i]
public static long getSum(int x){
long res1=0; // 存储Tree1前缀和 Σb[i]
long res2=0; // 存储Tree2前缀和 Σ(i-1)*b[i]
// 从x往左拆分区间累加
for(int i=x;i>0;i-=lowbit(i)){
res1+=Tree1[i];
res2+=Tree2[i];
}
return res1*x–res2;
}
// 查询任意区间 [l,r] 的累加和
// 区间和 = 前r项总和 – 前l-1项总和
public static long RangeQuery(int l,int r){
return getSum(r)–getSum(l–1);
}
public static void main(String[] args) throws IOException{
st=new StringTokenizer(br.readLine());
n=Integer.parseInt(st.nextToken());
m=Integer.parseInt(st.nextToken());
//Math.toIntExact方法是用于将Long转为int,其实也可以(int)来强转的
Tree1=new long[Math.toIntExact(MAX)];
Tree2=new long[Math.toIntExact(MAX)];
st=new StringTokenizer(br.readLine());
for(int i=1;i<=n;i++){
long num=Long.parseLong(st.nextToken());
// 单点赋值就是相当于在区间[i,i]都增加num
RangeAdd(i,i,num);
}
while(m— > 0){
st=new StringTokenizer(br.readLine());
int op=Integer.parseInt(st.nextToken());
if(op==1){
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
long k=Long.parseLong(st.nextToken());
RangeAdd(l,r,k);
}else{
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
out.println(RangeQuery(l,r));
}
}
out.flush();
out.close();
br.close();
}
}
C++代码:
//和Java的逻辑相同
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MAX=1000010;
ll Tree1[MAX];
ll Tree2[MAX];
static int n,m,op;
static int lowbit(int x) {
return x&(–x);
}
static void update(int x,ll v) {
for (int i = x; i <= n; i+=lowbit(i)) {
Tree1[i]+=v;
Tree2[i]+=v*(x–1);
}
}
static void RangeAdd(int l,int r,ll v) {
update(l,v);
update(r+1,–v);
}
static ll getSum(int x) {
ll res1=0;
ll res2=0;
for (int i = x; i > 0; i-=lowbit(i)) {
res1+=Tree1[i];
res2+=Tree2[i];
}
return res1*x–res2;
}
static ll RangeQuery(int l,int r) {
return getSum(r)–getSum(l–1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin>>n>>m;
for (int i = 1; i <= n; i++) {
ll num;
cin>>num;
RangeAdd(i,i,num);
}
while (m—) {
cin>>op;
if (op==1) {
int l,r;
ll k;
cin>>l>>r>>k;
RangeAdd(l,r,k);
}else {
int l,r;
cin>>l>>r;
cout<<RangeQuery(l,r)<<"\\n";
}
}
return 0;
}
所以对于我们双树状数组的解法来说它的时间复杂度为:
| lowbit(int x) | 取二进制最低位1 |
O ( 1 ) O(1) O(1) |
| update(int x, long v) | 单点更新两棵树状数组 |
O ( log n ) O(\\log n) O(logn) |
| RangeAdd(int l, int r, long v) | 区间
[ l , r ] [l,r] [l,r]整体加值 |
O ( log n ) O(\\log n) O(logn) |
| getSum(int x) | 求原数组前
x x x项总和 |
O ( log n ) O(\\log n) O(logn) |
| RangeQuery(int l, int r) | 查询区间
[ l , r ] [l,r] [l,r]累加和 |
O ( log n ) O(\\log n) O(logn) |
| 数组初始化循环 | 读入n个数字构建树 |
O ( n log n ) O(n\\log n) O(nlogn) |
| m次操作主循环 | 处理m次修改/查询 |
O ( m log n ) O(m\\log n) O(mlogn) |
| 程序整体总复杂度 | 完整程序运行 |
O ( ( n + m ) log n ) O((n+m)\\log n) O((n+m)logn) |
对比这个和上面那个写法来说,它的时间就大大缩小了。
解法三.线段树经典解法
在这里不仔细说明线段树的原理了,其实线段树就是一种二叉树,对于树状数组在解题时遇到不可查分信息时,树状数组往往思路复杂,这个时候线段树就挺身而出了,它的原理是基于分治递归来的,就是说我维护一个二叉树,对于叶子节点就存单个元素,而非叶子节点就存这段区间的总和以及最值等信息,而子节点数值更新后,和大多数二叉树一样,父节点的值 = 左子节点 + 右子节点,在区间修改的时候,我们就会用到懒标记,其实就是能不往下更新就先不更新,延迟下放的一个操作,所以代码如下: java代码如下:
//线段树代码的实现
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
final static int MAX=1000010;
// 表示线段树数组:存储每个区间的总和,开4倍空间防止越界
static long[] tree;
// 表示懒标记数组:保存待下传的区间增量,延迟更新子节点
static long[] lazy;
static int n,m;
public static void pushUp(int node){//这个方法其实是用于向上合并节点的
tree[node] = tree[node*2] + tree[node*2+1];
}
public static void pushDown(int node,int l,int r){
// 没有待下放增量的操作的话,就直接返回无需操作
if(lazy[node]==0) return;
int mid = (l+r)/2;
int left = node*2; // 左孩子节点编号
int rightNode = node*2+1; // 右孩子节点编号
// 更新左子树总和与懒标记
tree[left] += lazy[node] * (mid – l + 1);
lazy[left] += lazy[node];
// 更新右子树总和与懒标记
tree[rightNode] += lazy[node] * (r – mid);
lazy[rightNode] += lazy[node];
// 当前节点标记已下放,清空懒标记
lazy[node] = 0;
}
public static void build(int node,int l,int r,long[] a){
// 叶子节点,对应原数组单个元素,直接赋值
if(l == r){
tree[node] = a[l];
return;
}
int mid = (l+r)/2;
// 递归构造左子树 [l,mid]
build(node*2, l, mid, a);
// 递归构造右子树 [mid+1,r]
build(node*2+1, mid+1, r, a);
// 左右子树构造完成,更新当前节点区间和
pushUp(node);
}
public static void RangeAdd(int node,int l,int r,int L,int R,long v){
// 当前区间完全被修改区间包含,直接打懒标记返回
if(L<=l && r<=R){
tree[node] += v * (r – l + 1);
lazy[node] += v;
return;
}
// 不完全包含,先下放旧标记再递归子节点
pushDown(node, l, r);
int mid = (l+r)/2;
// 左区间有重叠,递归修改左子节点
if(L <= mid) RangeAdd(node*2, l, mid, L, R, v);
// 右区间有重叠,递归修改右子节点
if(R > mid) RangeAdd(node*2+1, mid+1, r, L, R, v);
// 子节点修改完成,更新当前节点总和
pushUp(node);
}
public static long RangeQuery(int node,int l,int r,int L,int R){
// 当前区间完全在查询范围内,直接返回区间和
if(L<=l && r<=R){
return tree[node];
}
// 访问子节点前下放懒标记,保证数据正确
pushDown(node, l, r);
int mid = (l+r)/2;
long res = 0;
// 左区间存在查询范围,累加左子树结果
if(L <= mid) res += RangeQuery(node*2, l, mid, L, R);
// 右区间存在查询范围,累加右子树结果
if(R > mid) res += RangeQuery(node*2+1, mid+1, r, L, R);
return res;
}
public static void main(String[] args) throws IOException{
st=new StringTokenizer(br.readLine());
n=Integer.parseInt(st.nextToken());
m=Integer.parseInt(st.nextToken());
// 初始化线段树与懒标记数组,4倍空间
tree = new long[MAX * 4];
lazy = new long[MAX * 4];
// 存储原始输入数组,下标从1开始
long[] a = new long[n+1];
st=new StringTokenizer(br.readLine());
for(int i=1;i<=n;i++){
a[i]=Long.parseLong(st.nextToken());
}
// 根节点编号1,构建1~n的线段树
build(1, 1, n, a);
while(m— > 0){
st=new StringTokenizer(br.readLine());
int op=Integer.parseInt(st.nextToken());
if(op==1){
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
long k=Long.parseLong(st.nextToken());
RangeAdd(1,1,n,l,r,k);
}else{
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
out.println(RangeQuery(1,1,n,l,r));
}
}
out.flush();
out.close();
br.close();
}
}
C++代码如下:
//逻辑和Java的相同
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAX = 1000010;
ll tree[MAX * 4];
ll lazy[MAX * 4];
int n, m;
void pushUp(int node) {
tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
void pushDown(int node, int l, int r) {
if (lazy[node] == 0) return;
int mid = (l + r) / 2;
int left = node * 2;
int rightNode = node * 2 + 1;
tree[left] += lazy[node] * (mid – l + 1);
lazy[left] += lazy[node];
tree[rightNode] += lazy[node] * (r – mid);
lazy[rightNode] += lazy[node];
lazy[node] = 0;
}
void build(int node, int l, int r, ll a[]) {
if (l == r) {
tree[node] = a[l];
return;
}
int mid = (l + r) / 2;
build(node * 2, l, mid, a);
build(node * 2 + 1, mid + 1, r, a);
pushUp(node);
}
void RangeAdd(int node, int l, int r, int L, int R, ll v) {
if (L <= l && r <= R) {
tree[node] += v * (r – l + 1);
lazy[node] += v;
return;
}
pushDown(node, l, r);
int mid = (l + r) / 2;
if (L <= mid) RangeAdd(node * 2, l, mid, L, R, v);
if (R > mid) RangeAdd(node * 2 + 1, mid + 1, r, L, R, v);
pushUp(node);
}
ll RangeQuery(int node, int l, int r, int L, int R) {
if (L <= l && r <= R) {
return tree[node];
}
pushDown(node, l, r);
int mid = (l + r) / 2;
ll res = 0;
if (L <= mid) res += RangeQuery(node * 2, l, mid, L, R);
if (R > mid) res += RangeQuery(node * 2 + 1, mid + 1, r, L, R);
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
ll *a = new ll[n + 1];
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
build(1, 1, n, a);
delete[] a;
while (m— > 0) {
int op;
cin >> op;
if (op == 1) {
int l, r;
ll k;
cin >> l >> r >> k;
RangeAdd(1, 1, n, l, r, k);
} else {
int l, r;
cin >> l >> r;
cout << RangeQuery(1, 1, n, l, r) << '\\n';
}
}
return 0;
}
所以从这里来看,线段树的代码量更为巨大,而它的时间复杂度为:
| pushUp | 合并左右子节点和 |
O ( 1 ) O(1) O(1) |
| pushDown | 下放懒标记 |
O ( 1 ) O(1) O(1) |
| build | 线段树建树 |
O ( n ) O(n) O(n) |
| RangeAdd | 区间[l,r]加k |
O ( log n ) O(\\log n) O(logn) |
| RangeQuery | 查询[l,r]区间和 |
O ( log n ) O(\\log n) O(logn) |
| m次操作循环 | m次修改/查询 |
O ( m log n ) O(m\\log n) O(mlogn) |
| 整体总复杂度 | 完整程序 |
O ( n + m log n ) O(n + m\\log n) O(n+mlogn) |
因此和树状数组的时间复杂度大致相同,但它们有啥区别呢,为啥说树状数组不适合用于解不可差分信息呢?其实很好理解,树状数组其实本质上是和前缀和,差分有一样的思想,就是说: 树状数组的所有操作,建立在两条特性: 修改是靠单点增量; 查询是前缀合并**,且合并运算满足: f(1∼x)=f(1∼mid)+f(mid+1∼x) 加法、求和满足这个,属于可差分信息: 原数组区间和可以拆差分,区间修改也能用差分两点更新实现。 因此像区间最大值、区间最小、区间 GCD这些不可差分信息,他就无法简单的实现了。
3.二进制区间取反与单点求值
引用:牛客PEEK75 区间取反与单点求值
题目链接如下: 牛客PEEK区间取反和单点求值
我一开始看到这个题目还以为是区间正数变为负数,负数变为正数,看错了题目,其实它是给我们一个长度为n的字符串(只包括0和1),然后要我们去查询那个位置是0还是1,翻转的话就是把范围l~r范围内的字符由0改为1,由1改为0,那翻转其实就是对它进行一个异或操作就行了,在这里也还是要用到差分的思想,就对原区间进行异或,就对差分后的两头分别异或就行了,所以代码如下: java代码如下:
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static int n,m;
static int []Tree;
static int []a;
public static int lowbit(int x){//和树状数组一样是取最右位的1
return x&(–x);
}
public static void add(int x,int v) {//这里是一个异或的思想
for(int i=x;i<=n;i+=lowbit(i)){//和树状树组模板一样,去找它管的下标
Tree[i]^=v;//不断异或就能实现它的翻转
}
}
public static int query(int x){//这里则为向下遍历,累加前缀所有翻转标记,就是一个异或叠加的过程
int res=0;
for(int i=x;i>0;i-=lowbit(i)){
res^=Tree[i];
}
return res;
}
public static void main(String[] args) throws Exception {
st=new StringTokenizer(br.readLine());
n=Integer.parseInt(st.nextToken());
m=Integer.parseInt(st.nextToken());
String s=br.readLine().trim();
a=new int[n+1];
Tree=new int[n+1];
// 将下标1~n的输入的01字符串存入数组a
for(int i=1;i<=n;i++){
a[i]=s.charAt(i–1)–'0';
}
while(m— > 0){
st=new StringTokenizer(br.readLine());
int op=Integer.parseInt(st.nextToken());
if(op==1){
int l=Integer.parseInt(st.nextToken());
int r=Integer.parseInt(st.nextToken());
//类似于差分的异或差分:l位置打翻转标记,r+1位置抵消后面的翻转就可以了
add(l,1);
add(r+1,1);
}else{
int x=Integer.parseInt(st.nextToken());
//原始数字 ^ 总翻转次数,就可以得到翻转后的结果了
out.println(a[x]^query(x));
}
}
out.flush();
out.close();
br.close();
}
}
C++代码如下:
//和Java代码的逻辑相同
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> Tree;
vector<int> a;
static int lowbit(int x) {
return x & –x;
}
static void add(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) {
Tree[i] ^= k;
}
}
static int query(int x) {
int res = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
res ^= Tree[i];
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
int m;
cin >> n >> m;
string s;
cin >> s;
a.resize(n + 1);
Tree.resize(n + 1, 0);
for (int i = 0; i < n; ++i) {
a[i + 1] = s[i] – '0';
}
while (m—) {
int op;
cin >> op;
if (op == 1) {
int l, r;
cin >> l >> r;
add(l, 1);
add(r + 1, 1);
} else {
int x;
cin >> x;
cout << (a[x] ^ query(x)) << "\\n";
}
}
return 0;
}
思考
那思考:如果是给一个数组,要对区间的数字进行取反(即正数变为负数,负数变为正数)和单点查询,又该如何写呢? 其实这样就是一个树状数组乘法的模板了,还是一样的差分思想,就是我对区间l~r要实现数组符号改变就是差分形式下把r+1和l都乘个-1,在单点查询的时候则是去看他的符号就可以了,因为这个时候我们的query方法并不是要记录累加和,而是要进行一个乘法的操作,即原值 × 累积正负标记就可以了,所以代码如下: java代码如下:
import java.io.*;
import java.util.*;
public class Main{
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static int n,m;
static int[] Tree; // 记录树状数组,存乘法标记,初始全1
static int[] a; // 记录原始数组,存正负整数
static int lowbit(int x){
return x & –x;
}
// 单点更新:x位置所有父节点 *= v(v固定为-1)
static void add(int x, int v){
for(int i = x; i <= n; i += lowbit(i)){
Tree[i] *= v;
}
}
// 查询1~x标记的累积乘积,返回1 / -1
static int query(int x){
int res = 1;
for(int i = x; i > 0; i -= lowbit(i)){
res *= Tree[i];
}
return res;
}
public static void main(String[] args) throws Exception{
st = new StringTokenizer(br.readLine());
n = Integer.parseInt(st.nextToken());
m = Integer.parseInt(st.nextToken());
a = new int[n + 1];
Tree = new int[n + 1];
// 乘法树状数组初始值全部为1
Arrays.fill(Tree, 1);
// 读入原始数组
st = new StringTokenizer(br.readLine());
for(int i = 1; i <= n; i++){
a[i] = Integer.parseInt(st.nextToken());
}
while(m— > 0){
st = new StringTokenizer(br.readLine());
int op = Integer.parseInt(st.nextToken());
if(op == 1){
// 进行操作1:区间[l,r]全部正负取反 *= -1
int l = Integer.parseInt(st.nextToken());
int r = Integer.parseInt(st.nextToken());
add(l, –1);
if(r + 1 <= n) add(r + 1, –1);
}else{
//进行操作2:单点查询x当前值
int x = Integer.parseInt(st.nextToken());
// 原值 × 累积正负标记
out.println(a[x] * query(x));
}
}
out.flush();
out.close();
br.close();
}
}
C++代码如下:
//和Java代码逻辑相同
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000010;
int n, m;
int Tree[MAXN]; // 乘法树状数组,存符号标记,初始全1
int a[MAXN]; // 原始数组
// lowbit 取出二进制最低位的1
int lowbit(int x) {
return x & –x;
}
// 单点更新:x位置所有管辖节点乘v,本题v固定为-1
void add(int x, int v) {
for (int i = x; i <= n; i += lowbit(i)) {
Tree[i] *= v;
}
}
// 查询1~x前缀标记累乘,返回总符号 1 / -1
int query(int x) {
int res = 1; // 乘法单位元是1
for (int i = x; i > 0; i -= lowbit(i)) {
res *= Tree[i];
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
// 树状数组初始全部置1
fill(Tree, Tree + MAXN, 1);
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
while (m—) {
int op;
cin >> op;
if (op == 1) {
// 区间[l,r]全部数字正负取反(* -1)
int l, r;
cin >> l >> r;
add(l, –1);
add(r + 1, –1);
} else {
// 单点查询x当前数值
int x;
cin >> x;
cout << a[x] * query(x) << '\\n';
}
}
return 0;
}
4.二维单点增加和范围查询
二维单点增加和范围查询,顾名思义,其实就是对一个二维矩阵单点进行加上一个值,和查询子矩阵的和,其实就是二维前缀和的思想了,所以在看解答之前,我们先看看二维前缀和,先看代码:
import java.io.*;
import java.util.*;
public class Main {
static int[][] matrix;
static long[][] sum;
public static void NumMatrix() {
int n = matrix.length – 1;
int m = matrix[0].length – 1;
// 初始化前缀和数组,开大一点防止越界
sum = new long[n + 2][m + 2];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
// 二维前缀和核心公式
sum[i][j] = sum[i – 1][j] // 上方区域和
+ sum[i][j – 1] // 左方区域和
– sum[i – 1][j – 1] // 重复区域和(减重复)
+ matrix[i][j]; // 当前位置的值
}
}
}
public static long getSum(int a, int b, int c, int d) {
// 子矩阵和公式(容斥原理)
return sum[c][d] // 大矩形总和
– sum[a – 1][d] // 减去上部分
– sum[c][b – 1] // 减去左部分
+ sum[a – 1][b – 1]; // 加回多减的重复部分
}
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
PrintWriter out = new PrintWriter(System.out);
StringTokenizer st;
st = new StringTokenizer(br.readLine());
int n = Integer.parseInt(st.nextToken());
int m = Integer.parseInt(st.nextToken());
int q = Integer.parseInt(st.nextToken());
matrix = new int[n + 2][m + 2];
for (int i = 1; i <= n; i++) {
st = new StringTokenizer(br.readLine());
for (int j = 1; j <= m; j++) {
matrix[i][j] = Integer.parseInt(st.nextToken());
}
}
// 构建前缀和数组
NumMatrix();
// 处理 q 次查询
while (q— > 0) {
st = new StringTokenizer(br.readLine());
// 读取查询的子矩阵坐标
int x1 = Integer.parseInt(st.nextToken());
int y1 = Integer.parseInt(st.nextToken());
int x2 = Integer.parseInt(st.nextToken());
int y2 = Integer.parseInt(st.nextToken());
// 查询并输出结果
out.println(getSum(x1, y1, x2, y2));
}
out.flush();
br.close();
out.close();
}
}
所以在这里我们基于以上的思想我们一样可以进行,而范围查询其实就是二维下的query范围相减即可,所以代码如下: java代码如下:
import java.io.*;
import java.util.*;
public class Main {
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static int n, m;
final static int N = 2010;
static int[][] Tree = new int[N][N];
static int[][] matrix = new int[N][N];
public static int lowbit(int x) {
return x & (–x);
}
// 单点修改:在 (x, y) 位置加上 v
public static void update(int x, int y, int v) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
Tree[i][j] += v;
}
}
}
// 查询二维前缀和:从 (1, 1) 到 (x, y) 的和
public static int query(int x, int y) {
int res = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
res += Tree[i][j];
}
}
return res;
}
// 利用二维前缀和的容斥原理,求子矩阵的和
public static long getSum(int x1, int y1, int x2, int y2) {
return (long) (query(x2, y2) – query(x1 – 1, y2) – query(x2, y1 – 1) + query(x1 – 1, y1 – 1));
}
public static void main(String[] args) throws Exception {
st = new StringTokenizer(br.readLine());
n = Integer.parseInt(st.nextToken());
m = Integer.parseInt(st.nextToken());
// 1. 读入初始矩阵,并构建二维树状数组
for (int i = 1; i <= n; i++) {
st = new StringTokenizer(br.readLine());
for (int j = 1; j <= m; j++) {
matrix[i][j] = Integer.parseInt(st.nextToken());
// 将原矩阵的值作为增量,加入到树状数组中
update(i, j, matrix[i][j]);
}
}
int q = Integer.parseInt(br.readLine().trim());
while (q— > 0) {
st = new StringTokenizer(br.readLine());
int op = Integer.parseInt(st.nextToken());
if (op == 1) {
//单点修改,将 (x, y) 的值加上 v
int x = Integer.parseInt(st.nextToken());
int y = Integer.parseInt(st.nextToken());
int v = Integer.parseInt(st.nextToken());
update(x, y, v);
matrix[x][y] += v; // 需要同步更新原矩阵
} else {
//查询左上角 (x1, y1) 到 右下角 (x2, y2) 的子矩阵和
int x1 = Integer.parseInt(st.nextToken());
int y1 = Integer.parseInt(st.nextToken());
int x2 = Integer.parseInt(st.nextToken());
int y2 = Integer.parseInt(st.nextToken());
out.println(getSum(x1, y1, x2, y2));//这样就可以得到我们想要的矩阵和了
}
}
out.flush();
out.close();
br.close();
}
}
C++代码如下:
//逻辑和Java代码其实还是一样的
#include <bits/stdc++.h>
using namespace std;
const int N = 2010;
typedef long long ll;
int n, m;
int Tree[N][N];
int matrix[N][N];
static int lowbit(int x) {
return x & (–x);
}
static void update(int x, int y, int v) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
Tree[i][j] += v;
}
}
}
static int query(int x, int y) {
int res = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
res += Tree[i][j];
}
}
return res;
}
ll getSum(int x1, int y1, int x2, int y2) {
return 1LL * query(x2, y2) – query(x1 – 1, y2) – query(x2, y1 – 1) + query(x1 – 1, y1 – 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> matrix[i][j];
update(i, j, matrix[i][j]);
}
}
int q;
cin >> q;
while (q—) {
int op;
cin >> op;
if (op == 1) {
int x, y, v;
cin >> x >> y >> v;
update(x, y, v);
matrix[x][y] += v;
} else {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
cout << getSum(x1, y1, x2, y2) << '\\n';
}
}
return 0;
}
5.二维范围增加和范围查询
引用洛谷P4514
题目链接如下: 洛谷P4514二维树状数组模版题
其实对于一维的范围增值来说,我们使用了一维差分,所以其实很自然的就可以想到能不能用二维差分来解决这个题呢?其实我们可以验证一下,先看一段二维差分模板:
#include <bits/stdc++.h>
using namespace std;
vector<vector<long>> matrix;
vector<vector<long>> diff;
static int n, m;
// 根据原矩阵 matrix,初始化二维差分数组 diff
// 对应一维:crr[i] = arr[i] – arr[i-1]
void getDiff() {
for (int i = 1; i <= n; i++) { // 遍历每一行
for (int j = 1; j <= m; j++) { // 遍历每一列
// 二维差分核心公式:当前值 – 左边 – 上边 + 左上角(抵消重复减)
diff[i][j] = matrix[i][j] – matrix[i][j–1] – matrix[i–1][j] + matrix[i–1][j–1];
}
}
}
// 根据公式对矩形区域 (a,b) → (c,d) 内所有数统一加 k
// 二维差分标准操作:只修改 4 个点,O(1) 完成区间修改
void getNum(int a,int b,int c,int d,int k) {
diff[a][b] += k; // 矩形左上角 +k
diff[a][d+1] -= k; // 矩形右上角右边 -k
diff[c+1][b] -= k; // 矩形左下角下边 -k
diff[c+1][d+1] += k; // 矩形右下角右下角 +k(抵消前面的减法)
}
// 其实对差分数组 diff 求二维前缀和,还原成最终的答案矩阵
// 对应一维:arr[i] = arr[i-1] + crr[i]
void getSum() {
for (int i = 1; i <= n; i++) { // 遍历每一行
for (int j = 1; j <= m; j++) { // 遍历每一列
// 二维前缀和公式:当前值 + 左边 + 上边 – 左上角(去重)
diff[i][j] = diff[i][j] + diff[i][j–1] + diff[i–1][j] – diff[i–1][j–1];
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
matrix.resize(n+1, vector<long>(m+1));
diff.resize(n+2, vector<long>(m+2));
int q;
cin >> q;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> matrix[i][j];
}
}
getDiff();
while (q—>0) {
int x1, y1, x2, y2, k;
cin >> x1 >> y1 >> x2 >> y2 >> k;
getNum(x1, y1, x2, y2, k);
}
getSum();
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cout << diff[i][j] << " ";
}
cout << "\\n";
}
return 0;
}
其实基于暴力的思想,直接用二位差分来是可以解的,和前面那个一维的是一样的,要二维区间加一个值和查询其实就是二维差分来加值,对二维差分直接前缀和一次就是原矩阵,再前缀和一次就是我们要的子矩阵的和了,代码如下:
//暴力解法
import java.io.*;
import java.util.*;
public class Main {
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static int n, m;
static long[][] diff;//储存差分信息
static long[][] a;//原矩阵
static long[][] pre;//储存前缀和信息
public static void main(String[] args) throws IOException {
String line = br.readLine();
while (line != null && line.trim().isEmpty()) {//读第一行
line = br.readLine();
}
st = new StringTokenizer(line);
st.nextToken();
n = Integer.parseInt(st.nextToken());
m = Integer.parseInt(st.nextToken());
diff = new long[n + 2][m + 2];
a = new long[n + 1][m + 1];
pre = new long[n + 1][m + 1];
String op;
while ((op = br.readLine()) != null) {//按题目的要求,从输入数据的第二行开始到文件尾,只要有就一直输入。
if (op.trim().isEmpty()) continue;
st = new StringTokenizer(op);
char type = st.nextToken().charAt(0);
if (type == 'L') {//如果是L就进行一个二维差分的操作
int a0 = Integer.parseInt(st.nextToken());
int b0 = Integer.parseInt(st.nextToken());
int c0 = Integer.parseInt(st.nextToken());
int d0 = Integer.parseInt(st.nextToken());
long delta = Long.parseLong(st.nextToken());
int x1 = Math.min(a0, c0), x2 = Math.max(a0, c0);
int y1 = Math.min(b0, d0), y2 = Math.max(b0, d0);
diff[x1][y1] += delta;
diff[x2 + 1][y1] -= delta;
diff[x1][y2 + 1] -= delta;
diff[x2 + 1][y2 + 1] += delta;
}
else if (type == 'k') {//如果是k就进行二维前缀和的操作
int a0 = Integer.parseInt(st.nextToken());
int b0 = Integer.parseInt(st.nextToken());
int c0 = Integer.parseInt(st.nextToken());
int d0 = Integer.parseInt(st.nextToken());
int x1 = Math.min(a0, c0), x2 = Math.max(a0, c0);
int y1 = Math.min(b0, d0), y2 = Math.max(b0, d0);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
a[i][j] = diff[i][j] + a[i–1][j] + a[i][j–1] – a[i–1][j–1];
pre[i][j] = pre[i–1][j] + pre[i][j–1] – pre[i–1][j–1] + a[i][j];
}
}
long ans = pre[x2][y2] – pre[x1–1][y2] – pre[x2][y1–1] + pre[x1–1][y1–1];
out.println(ans);
}
}
out.flush();
out.close();
br.close();
}
}
显而易见,以上代码当 n = m = 2048 时,单次查询约 4×10⁶ 次运算;若查询次数接近 2×10⁵,总运算量将超过 8×10¹¹,就远远超出时间限制了,它的时间复杂度如下:
| L矩形加操作 | 二维差分四点修改 |
O ( 1 ) O(1) O(1) |
仅4次数组加减,无循环;自动处理坐标大小互换 |
| k查询操作内部步骤 | 遍历全矩阵还原原矩阵a、构建前缀和pre |
O ( n m ) O(nm) O(nm) |
双重循环i∈[1,n],j∈[1,m],每次查询完整重建整个矩阵与前缀和 |
| k查询操作内部步骤 | 容斥计算矩形答案 |
O ( 1 ) O(1) O(1) |
仅四则运算 |
| 单次k查询整体 | 重建矩阵+前缀和+区间求和 |
O ( n m ) O(nm) O(nm) |
核心耗时为全矩阵双重循环 |
| 全局总复杂度 | 整个程序所有操作 |
O ( Q L + Q k ⋅ n m ) O(Q_L + Q_k \\cdot nm) O(QL+Qk⋅nm) |
Q L Q_L QL:L操作总数; Q k Q_k Qk:k查询总数;瓶颈为每次查询都完整遍历n×m矩阵 |
所以提交的结果就可想而知的超时了:
所以我们我们要对原来的思路进行一次优化,推导过程如下:
二维区间加、区间求和完整数学推导
1. 基础定义
设:
a
i
,
j
a_{i,j}
ai,j
d
i
,
j
d_{i,j}
di,j
二维差分性质:对矩形
[
a
,
b
]
∼
[
c
,
d
]
[a,b] \\sim [c,d]
[a,b]∼[c,d] 整体加
v
v
v,仅需4个单点修改
d
d
d:
{
d
a
,
b
←
d
a
,
b
+
v
d
a
,
d
+
1
←
d
a
,
d
+
1
−
v
d
c
+
1
,
b
←
d
c
+
1
,
b
−
v
d
c
+
1
,
d
+
1
←
d
c
+
1
,
d
+
1
+
v
\\begin{cases} d_{a,b} \\gets d_{a,b} + v \\\\ d_{a,d+1} \\gets d_{a,d+1} – v \\\\ d_{c+1,b} \\gets d_{c+1,b} – v \\\\ d_{c+1,d+1} \\gets d_{c+1,d+1} + v \\end{cases}
⎩
⎨
⎧da,b←da,b+vda,d+1←da,d+1−vdc+1,b←dc+1,b−vdc+1,d+1←dc+1,d+1+v
还原原矩阵:
a
i
,
j
a_{i,j}
ai,j 是
d
d
d 的二维前缀和
a
i
,
j
=
∑
p
=
1
i
∑
q
=
1
j
d
p
,
q
a_{i,j} = \\sum_{p=1}^i \\sum_{q=1}^j d_{p,q}
ai,j=p=1∑iq=1∑jdp,q
2. 定义全局前缀和
S
(
x
,
y
)
S(x,y)
S(x,y)
S
(
x
,
y
)
S(x,y)
S(x,y) 代表左上角
(
1
,
1
)
(1,1)
(1,1) 到
(
x
,
y
)
(x,y)
(x,y) 整个矩形所有数字之和:
S
(
x
,
y
)
=
∑
i
=
1
x
∑
j
=
1
y
a
i
,
j
S(x,y) = \\sum_{i=1}^x \\sum_{j=1}^y a_{i,j}
S(x,y)=i=1∑xj=1∑yai,j
把
a
i
,
j
=
∑
p
=
1
i
∑
q
=
1
j
d
p
,
q
a_{i,j} = \\sum_{p=1}^i\\sum_{q=1}^j d_{p,q}
ai,j=∑p=1i∑q=1jdp,q 代入:
S
(
x
,
y
)
=
∑
i
=
1
x
∑
j
=
1
y
(
∑
p
=
1
i
∑
q
=
1
j
d
p
,
q
)
S(x,y) = \\sum_{i=1}^x \\sum_{j=1}^y \\left( \\sum_{p=1}^i \\sum_{q=1}^j d_{p,q} \\right)
S(x,y)=i=1∑xj=1∑y(p=1∑iq=1∑jdp,q)
3. 交换求和顺序
固定差分点
(
p
,
q
)
(p,q)
(p,q),统计
d
p
,
q
d_{p,q}
dp,q 会被累加多少次:
i
i
i 取值范围
[
p
,
x
]
[p,\\ x]
[p, x],
j
j
j 取值范围
[
q
,
y
]
[q,\\ y]
[q, y] 总累加次数 = 行数 × 列数 =
(
x
−
p
+
1
)
(
y
−
q
+
1
)
(x-p+1)(y-q+1)
(x−p+1)(y−q+1)
因此改写求和式:
S
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
d
p
,
q
⋅
(
x
−
p
+
1
)
(
y
−
q
+
1
)
S(x,y) = \\sum_{p=1}^x \\sum_{q=1}^y d_{p,q} \\cdot (x-p+1)(y-q+1)
S(x,y)=p=1∑xq=1∑ydp,q⋅(x−p+1)(y−q+1)
4. 展开多项式
(
x
−
p
+
1
)
(
y
−
q
+
1
)
(x-p+1)(y-q+1)
(x−p+1)(y−q+1)
(
x
−
p
+
1
)
(
y
−
q
+
1
)
=
[
(
x
+
1
)
−
p
]
⋅
[
(
y
+
1
)
−
q
]
=
(
x
+
1
)
(
y
+
1
)
−
(
x
+
1
)
q
−
(
y
+
1
)
p
+
p
q
\\begin{align*} (x-p+1)(y-q+1) &= \\big[(x+1)-p\\big] \\cdot \\big[(y+1)-q\\big] \\\\ &= (x+1)(y+1) – (x+1)q – (y+1)p + pq \\end{align*}
(x−p+1)(y−q+1)=[(x+1)−p]⋅[(y+1)−q]=(x+1)(y+1)−(x+1)q−(y+1)p+pq
5. 代入拆分四项求和
将展开式代入
S
(
x
,
y
)
S(x,y)
S(x,y):
S
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
d
p
,
q
⋅
[
(
x
+
1
)
(
y
+
1
)
−
(
y
+
1
)
p
−
(
x
+
1
)
q
+
p
q
]
=
(
x
+
1
)
(
y
+
1
)
∑
p
=
1
x
∑
q
=
1
y
d
p
,
q
−
(
y
+
1
)
∑
p
=
1
x
∑
q
=
1
y
p
⋅
d
p
,
q
−
(
x
+
1
)
∑
p
=
1
x
∑
q
=
1
y
q
⋅
d
p
,
q
+
∑
p
=
1
x
∑
q
=
1
y
p
q
⋅
d
p
,
q
\\begin{align*} S(x,y) &= \\sum_{p=1}^x\\sum_{q=1}^y d_{p,q} \\cdot \\Big[(x+1)(y+1) – (y+1)p – (x+1)q + pq\\Big] \\\\ &= (x+1)(y+1)\\sum_{p=1}^x\\sum_{q=1}^y d_{p,q} \\\\ &\\quad – (y+1)\\sum_{p=1}^x\\sum_{q=1}^y p\\cdot d_{p,q} \\\\ &\\quad – (x+1)\\sum_{p=1}^x\\sum_{q=1}^y q\\cdot d_{p,q} \\\\ &\\quad + \\sum_{p=1}^x\\sum_{q=1}^y pq\\cdot d_{p,q} \\end{align*}
S(x,y)=p=1∑xq=1∑ydp,q⋅[(x+1)(y+1)−(y+1)p−(x+1)q+pq]=(x+1)(y+1)p=1∑xq=1∑ydp,q−(y+1)p=1∑xq=1∑yp⋅dp,q−(x+1)p=1∑xq=1∑yq⋅dp,q+p=1∑xq=1∑ypq⋅dp,q
6. 定义四个二维前缀和(对应代码4棵树)
令4个二维前缀和:
{
T
1
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
d
p
,
q
T
2
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
p
⋅
d
p
,
q
T
3
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
q
⋅
d
p
,
q
T
4
(
x
,
y
)
=
∑
p
=
1
x
∑
q
=
1
y
p
q
⋅
d
p
,
q
\\begin{cases} T_1(x,y) = \\displaystyle\\sum_{p=1}^x\\sum_{q=1}^y d_{p,q} \\\\[6pt] T_2(x,y) = \\displaystyle\\sum_{p=1}^x\\sum_{q=1}^y p\\cdot d_{p,q} \\\\[6pt] T_3(x,y) = \\displaystyle\\sum_{p=1}^x\\sum_{q=1}^y q\\cdot d_{p,q} \\\\[6pt] T_4(x,y) = \\displaystyle\\sum_{p=1}^x\\sum_{q=1}^y pq\\cdot d_{p,q} \\end{cases}
⎩
⎨
⎧T1(x,y)=p=1∑xq=1∑ydp,qT2(x,y)=p=1∑xq=1∑yp⋅dp,qT3(x,y)=p=1∑xq=1∑yq⋅dp,qT4(x,y)=p=1∑xq=1∑ypq⋅dp,q
则
S
(
x
,
y
)
S(x,y)
S(x,y) 最简公式:
S
(
x
,
y
)
=
(
x
+
1
)
(
y
+
1
)
T
1
−
(
y
+
1
)
T
2
−
(
x
+
1
)
T
3
+
T
4
\\boldsymbol{S(x,y) = (x+1)(y+1)T_1 – (y+1)T_2 – (x+1)T_3 + T_4}
S(x,y)=(x+1)(y+1)T1−(y+1)T2−(x+1)T3+T4
和代码 sum(x,y) 内部计算公式完全对应:
ans += (x + 1) * (y + 1) * Tree1[i][j] – (y + 1) * Tree2[i][j] – (x + 1) * Tree3[i][j] + Tree4[i][j];
根据上面的公式,就可以写出代码如下: java代码如下:
import java.io.*;
import java.util.*;
public class Main {
public static int MAXN = 2050;
public static int MAXM = 2050;
// 维护信息 : d[i][j]
public static int[][] Tree1 = new int[MAXN][MAXM];
// 维护信息 : d[i][j] * i
public static int[][] Tree2 = new int[MAXN][MAXM];
// 维护信息 : d[i][j] * j
public static int[][] Tree3 = new int[MAXN][MAXM];
// 维护信息 : d[i][j] * i * j
public static int[][] Tree4 = new int[MAXN][MAXM];
public static int n, m;
public static int lowbit(int i) {
return i & –i;
}
// 单点更新 (x,y) 增加v
public static void add(int x, int y, int v) {
int v1 = v;
int v2 = x * v;
int v3 = y * v;
int v4 = x * y * v;
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
Tree1[i][j] += v1;
Tree2[i][j] += v2;
Tree3[i][j] += v3;
Tree4[i][j] += v4;
}
}
}
// 查询(1,1) ~ (x,y) 前缀和
public static int sum(int x, int y) {
int ans = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
// 套用数学推导拆分公式 S=(x+1)(y+1)T1 – (y+1)T2 – (x+1)T3 + T4
ans += (x + 1) * (y + 1) * Tree1[i][j] – (y + 1) * Tree2[i][j] – (x + 1) * Tree3[i][j] + Tree4[i][j];
}
}
return ans;
}
// 矩形区间 [a,b] ~ [c,d] 整体加v
public static void add(int a, int b, int c, int d, int v) {
add(a, b, v);
add(a, d + 1, –v);
add(c + 1, b, –v);
add(c + 1, d + 1, v);
}
// 查询矩形 [a,b] ~ [c,d] 的总和
public static int range(int a, int b, int c, int d) {
return sum(c, d) – sum(a – 1, d) – sum(c, b – 1) + sum(a – 1, b – 1);
}
public static void main(String[] args) throws Exception {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
String op;
int a, b, c, d, v;
//按照题目要求:循环读取所有输入,直到文件末尾
while (in.nextToken() != StreamTokenizer.TT_EOF) {
op = in.sval;
if (op.equals("X")) {
in.nextToken();
n = (int) in.nval;
in.nextToken();
m = (int) in.nval;
} else if (op.equals("L")) {
in.nextToken();
a = (int) in.nval;
in.nextToken();
b = (int) in.nval;
in.nextToken();
c = (int) in.nval;
in.nextToken();
d = (int) in.nval;
in.nextToken();
v = (int) in.nval;
add(a, b, c, d, v);
} else {
in.nextToken();
a = (int) in.nval;
in.nextToken();
b = (int) in.nval;
in.nextToken();
c = (int) in.nval;
in.nextToken();
d = (int) in.nval;
out.println(range(a, b, c, d));
}
}
out.flush();
out.close();
br.close();
}
}
C++代码如下:
//逻辑和Java大致相同
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAX = 2050;
static int n, m;
// 创建二维树状数组
ll Tree1[MAX][MAX], Tree2[MAX][MAX], Tree3[MAX][MAX], Tree4[MAX][MAX];
inline int lowbit(int x) {
return x & –x;
}
// 创建同步单点更新
void update(int x, int y, ll val) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
Tree1[i][j] += val;
Tree2[i][j] += val * x;
Tree3[i][j] += val * y;
Tree4[i][j] += val * x * y;
}
}
}
// 二维差分区间加 [a,b]~[c,d] +val
void range_add(int a, int b, int c, int d, ll val) {
update(a, b, val);
update(a, d + 1, –val);
update(c + 1, b, –val);
update(c + 1, d + 1, val);
}
// 查询 (1,1)~(x,y) 前缀总和
ll query(int x, int y) {
ll s1 = 0, s2 = 0, s3 = 0, s4 = 0;
for (int i = x; i; i -= lowbit(i)) {
for (int j = y; j; j -= lowbit(j)) {
s1 += Tree1[i][j];
s2 += Tree2[i][j];
s3 += Tree3[i][j];
s4 += Tree4[i][j];
}
}
ll res = (x + 1) * (y + 1) * s1 – (y + 1) * s2 – (x + 1) * s3 + s4;
return res;
}
// 容斥求矩形 [x1,y1]~[x2,y2] 和
ll get_sum(int x1, int y1, int x2, int y2) {
return query(x2, y2) – query(x1 – 1, y2) – query(x2, y1 – 1) + query(x1 – 1, y1 – 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
char op;
cin >> op >> n >> m;
int a, b, c, d;
ll delta;
while (cin >> op) {
if (op == 'L') {
// 区间加
cin >> a >> b >> c >> d >> delta;
range_add(a, b, c, d, delta);
} else if (op == 'k') {
// 区间查询
cin >> a >> b >> c >> d;
cout << get_sum(a, b, c, d) << '\\n';
}
}
return 0;
}
结果如下: 
其实对应一维的树状数组他的总体时间复杂度为O(logn),所以类比到二维的时间复杂度其实就是O(logn)*O(logm),分析如下:
| lowbit | lowbit | 取数字二进制最低位1 |
O ( 1 ) O(1) O(1) |
单纯位运算,常数时间无循环 |
| update | add(int x,int y,int v) | 四棵树同步单点更新 |
O ( log n ⋅ log m ) O(\\log n \\cdot \\log m) O(logn⋅logm) |
二维树状数组两层循环,行循环跳
log n \\log n logn次,列循环跳 log m \\log m logm次,内部仅常数次数组赋值 |
| query | sum(int x,int y) | 查询
( 1 , 1 ) ∼ ( x , y ) (1,1) \\sim (x,y) (1,1)∼(x,y)前缀总和 |
O ( log n ⋅ log m ) O(\\log n \\cdot \\log m) O(logn⋅logm) |
两层倒序遍历树节点,内部仅四则运算,常数忽略 |
| range_add | add(int a,b,c,d,v) | 整块矩形区间加值 |
O ( log n ⋅ log m ) O(\\log n \\cdot \\log m) O(logn⋅logm) |
内部调用4次单点update,常数4不影响复杂度阶 |
| get_sum | range(int a,b,c,d) | 求任意矩形区间和 |
O ( log n ⋅ log m ) O(\\log n \\cdot \\log m) O(logn⋅logm) |
内部调用4次前缀查询query,常数4不影响复杂度阶 |
所以对比暴力解法,这种算法就快多了,但是却学要思考和数学上的推导,所以代码搞我千百遍,我带代码还要依旧如初恋!
总结一下
其实对于树状数组而言,写出他的模版并不是一件难事,但是算法算法,本质上还是难在应用上了,有的时候学习它就觉得很懂了,但一用起来却就百般地抓耳挠腮,不知所措,其实还是用的不够多,因为算法,本质上还是为现实服务的,学习它不可能是死的,这种算法有啥优点,有啥缺点,啥时候使用,都是要搞懂的地方,所以人生万事须自为,硅步江山即辽阔!亲身经历的bug才会让人记忆犹新,所以还是加油吧!
