问题
- 给出一个数组,多次求下标区间
[i,j]
的元素和
解决:前缀和 - 给出一个数组,对其进行多次单点修改,期间穿插求下标区间
[i,j]
的元素和
解决:树状数组
问题2的分析:
- 有单点修改,若仍用前缀和,则修改
i
处的值,需要更新i
后面所有前缀和
单次修改复杂度O(N)
,单次查询复杂度O(1)
,最坏时K次操作都是修改,总复杂度O(KN)
- 使用普通数组存储和修改
单次修改复杂度O(1)
,单次查询复杂度O(N)
,最坏时K次操作都是查询,总复杂度O(KN)
- 更高效的解决方式是使用树状数组,单次查询复杂度
O(logN)
,单次修改复杂度O(logN)
,有K次不同操作,总复杂度O(KlogN)
树状数组可以理解为升级版的前缀和数组,两者都是预处理某段区间的和,不同点在于:
- 前缀和数组
preSum[i]
求和覆盖范围过大([0,i)
区间的元素和)- 树状数组每一个
C[i]
覆盖的求和范围仅仅为[i-lowbit(i)+1,i]
当我们需要穿插单点修改操作时,每次修改会引起很大部分
preSum[i]
随之受影响,而树状数组通过二进制位合理划分求和覆盖范围,使得每次受影响的C[i]
很少,若元素总数为N,每次修改最多有logN
个C[i]
受影响,复杂度O(logN)
。
树状数组/二进制索引树(Binary Index Tree, BIT)
树状数组是一种动态维护前缀和(支持单点修改+区间查询)的数据结构
首先明确,树状数组的本质思想,是二进制规律的应用
树状数组中,需要用到lowbit(n)=n&-n
,函数取出二进制数最右侧的1和其更右边部分
记原数组为nums[i]
,其前缀和数组preSum[i]
表示[0,i)
区间元素的总和
树状数组定义
- 树状数组
C
也是保存了nums
数组某个区间内的元素总和,但其定义为:
C[i]
保存:nums[i]
(包含本身)及其之前的lobit(i)
个元素的总和 - 注意,使用树状数组,
nums
和C
下标i
一定要从1
开始,否则下面所有结论出错
例如,对于
i=4或12
,其lowbit(i)=100
,
故树状数组C[4]
和C[12]
都保存了其本身及之前的长度为4的区间和结果
即C[4]=nums[1]+nums[2]+nums[3]+nums[4]
,C[12]=nums[9]+nums[10]+nums[11]+nums[12]
如何实现区间[l,r]
元素和的查询
- 我们仍然需要用前缀和来实现:
定义前缀和:preSum[i]
表示[1,i]
区间内的元素和,且preSum[0]=0
(和普通前缀和定义不同)
那么,[l,r]
区间元素和 =preSum[r] - preSum[l-1]
- 我们实际维护的是树状数组
C
,因此要利用C
求preSum[i]
由图可见,preSum[i]
希望求解[1,i]
区间的元素和
当前的C[i]
覆盖了lowbit(i)
范围的区间和,还剩下[1, i-lowbit(i)]
区间的元素和没有解决
[1, i-lowbit(i)]
区间的元素和,又转化为小规模的相同问题preSum[ i-lowbit(i)]
总结:preSum[i]
= C[i]
+ preSum[i-lowbit(i)]
,可以不断递归求解,直到i=0
def preSum(i):
"""求解nums[1...i]区间的元素和
preSum[0]=0"""
ans = 0
while i != 0:
ans += C[i]
# C[i]覆盖了lowbit(i)个元素之和,接下来要求解[1,i-lowbit(i)]子问题,直到右边界为0
i -= lowbit(i)
return ans
理解:
- 首先,
i = i - lowbit(i)
实际上就是不断把数字i
的最右边的1
置为0
的过程,故循环次数=i
的二进制表示中1
的个数,时间复杂度:O(logN)
- 其次,这里的原理可以从图上理解:随便给一个
i
,求preSum[i]
,然后不断往“左上角”走,收集总和
如何实现元素的单点修改
- 我们维护树状数组
C
,因此改动一个元素i
,可能会对多个C[idx]
造成影响
例如,在图中让nums[6]
增加val
,则C[6]
、C[8]
、C[16]
都要同步增加val
- 如何完整找出所有受到影响的
C[idx]
当前的C[i]
覆盖了i
及其之前的lowbit(i)
范围的区间和,我们修改C[i]
对于更小的idx
,显然C[idx]
不会涉及当前的nums[i]
因此,我们要找下一个更大的idx=i+delta
,并且delta
的值尽量小,这样才能找出下一个受到影响的C[idx]
可以证明,最小的delta = lowbit(i)
,故下一个要更新C[i+loiwbit(i)]
,不断递归更多的C[idx]
…
总结:更新C[i]
后,下一个受影响并需要更新的是C[i+loiwbit(i)]
,可以不断递归求解,直到i>=len(C)
def update(i, val):
"""nums[i]元素的值增加val,
更新树状数组中,与之有关的所有C[idx]"""
L = len(C)
while i < L:
# 更新C[i]
C[i] += val
# 下一个受影响,需要更新的是C[i+loiwbit(i)]
i += lowbit(i)
return
理解:
- 首先,
i = i + lowbit(i)
实际上就是不断在数字i
的最右边的1
位置上产生进位(由此定位到最右边1
的左侧第一个0
),时间复杂度:O(logN)
- 其次,这里的原理可以从图上理解:随便给一个
i
,更新C[i]
,然后不断往“右上角”走,更新所有受影响的C[idx]
树状数组的应用
模板题
LeetCode 307. 区域和检索 - 数组可修改
注意两点:
- 通常使用下标从0开始,在树状数组中需要转化为从1开始;
- 这题的单点修改定义为[重新赋值],而树状数组中的修改是[增加一个增量v],我们可以转化:
nums[i]
改为val
,等价于增加val-nums[i]
经典运用:统计序列中各元素左侧小于它的元素的个数
给出正整数序列,对于每个元素,求其左侧有多少个元素比它小
- 基本思路:从左到右遍历元素,哈希表
hash[num]
实时维护元素num的出现次数,则对于当前的元素n
,答案为hash[0]+hash[1]+...+hash[n-1]
,并且维护hash[n]+=1
- 上面的操作完全可以用树状数组的“单点修改”和“前缀和”功能实现,使得每次求和快速完成
- 进阶变式问题:
改为 [求各元素右侧小于它的元素的个数]:改为从右到左遍历即可
改为 [求各元素左侧大于它的元素的个数]:改为求区间和hash[n+1]+...+hash[n_max]
,即preSum[n_max]-preSum[n]
元素取值改为 [可能为负/可能非常大]:使用离散化技巧
离散化
思路:
实际上我们只关注元素之间的大小关系,因此在这里[-9999,111,1,11111111]
等价于[1,3,2,4]
因此,我们要做的就是把具体数值映射到排名,从而回到上面的 [正整数序列]的问题
具体实现:
- 方法一:类似于“对于考试分数排名,分数相同则排名相同”的问题
- 预处理:对于
(val,pos)
排序,根据排序结果计算各元素排名,用排名替换原数组中的具体数值val(位置在pos处) - 排名rank的逻辑:遍历排序结果,
如果当前元素!=上一元素,则rank=当前已遍历元素数(遍历下标+1);
如果当前元素==上一元素,则rank不变
- 方法二:直接去重后排序,元素的rank=元素在排序后的数组中的下标
可见,离散化的优点:将不在合适范围内的整数/非整数映射为正整数,或者将分布稀疏的元素聚集到一起,总之就是将元素值映射到一个连续的整数区间,且保持其大小相对关系;
离散化的缺点:仅用于离线查询,因为获取排名的前提是已知所有元素的值。
例题:
LeetCode 315. 计算右侧小于当前元素的个数
问题中元素取值范围-10^4 <= nums[i] <= 10^4
,使用上面的离散化树状数组即可
class Solution:
def countSmaller(self, nums: List[int]) -> List[int]:
class BIT:
def __init__(self, len):
"""下标1~len的数组"""
self.L = len + 1
self.C = [0] * (len + 1)
def lowbit(self, x):
return x & (-x)
def update(self, idx, val):
while idx < self.L:
self.C[idx] += val
idx += self.lowbit(idx)
def preSum(self, idx):
"""1~idx的和"""
res = 0
while idx:
res += self.C[idx]
idx -= self.lowbit(idx)
return res
# 从左到右遍历元素,树状数组`hash[num]`实时维护元素num的出现次数,
# 则对于当前的元素`n`,答案为`hash[0]+hash[1]+...+hash[n-1]`,并且维护`hash[n]+=1`
# 离散化,`[-9999,111,1,11111111]`等价于`[1,3,2,4]`
rank = {}
s = sorted(set(nums)) # 去重后得到唯一的排名
for i, n in enumerate(s):
rank[n] = i + 1
bit = BIT(len(s))
ans = [0] * len(nums)
for i in range(len(nums) - 1, -1, -1):
# 处理时不是处理数字,而是处理其离散化的排名
r = rank[nums[i]]
ans[i] = bit.preSum(r - 1) # 答案为`hash[0]+hash[1]+...+hash[n-1]`
bit.update(r, 1) # 维护`hash[n]+=1`
return ans
变式:
剑指 Offer 51. 数组中的逆序对
在统计逆序对的时候,只需要统计每个位置右侧小于当前元素的个数,再对它们求和,就可以得到逆序对的总数