算法学习笔记——数据结构:树状数组BIT

问题

  1. 给出一个数组,多次求下标区间[i,j]的元素和
    解决:前缀和
  2. 给出一个数组,对其进行多次单点修改,期间穿插求下标区间[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,每次修改最多有logNC[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)个元素的总和
  • 注意,使用树状数组,numsC下标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]元素和的查询
  1. 我们仍然需要用前缀和来实现:
    定义前缀和:preSum[i]表示[1,i]区间内的元素和,且preSum[0]=0(和普通前缀和定义不同)
    那么,[l,r]区间元素和 = preSum[r] - preSum[l-1]
  2. 我们实际维护的是树状数组C,因此要利用CpreSum[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],然后不断往“左上角”走,收集总和
如何实现元素的单点修改
  1. 我们维护树状数组C,因此改动一个元素i,可能会对多个C[idx]造成影响
    例如,在图中让nums[6]增加val,则C[6]C[8]C[16]都要同步增加val
  2. 如何完整找出所有受到影响的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. 区域和检索 - 数组可修改
注意两点:

  1. 通常使用下标从0开始,在树状数组中需要转化为从1开始;
  2. 这题的单点修改定义为[重新赋值],而树状数组中的修改是[增加一个增量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]
因此,我们要做的就是把具体数值映射到排名,从而回到上面的 [正整数序列]的问题

具体实现:

  1. 方法一:类似于“对于考试分数排名,分数相同则排名相同”的问题
  • 预处理:对于(val,pos)排序,根据排序结果计算各元素排名,用排名替换原数组中的具体数值val(位置在pos处)
  • 排名rank的逻辑:遍历排序结果,
    如果当前元素!=上一元素,则rank=当前已遍历元素数(遍历下标+1);
    如果当前元素==上一元素,则rank不变
  1. 方法二:直接去重后排序,元素的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. 数组中的逆序对
在统计逆序对的时候,只需要统计每个位置右侧小于当前元素的个数,再对它们求和,就可以得到逆序对的总数

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值