树状数组

·   ·   ·   ·

  ·   ·


树状数组,也称二叉索引树(Binary Index Tree, BIT),是很多OIer心中最简洁优美的数据结构之一

最简单的树状数组支持两种操作,时间复杂度均为 $O(logn)$:

  • 单点修改:更改数组中一个元素的值
  • 区间查询:查询一个区间内所有元素的和

引入

回顾一下,我们说,我们要实现两种操作:单点修改区间求和。对于普通数组而言,单点修改的时间复杂度是 $O(1)$ ,但区间求和的时间复杂度是 $O(n)$ 

当然,我们也可以用前缀和的方式去维护这个数组,这样的话区间求和的时间复杂度降到了 $O(1)$,但单点修改会影响到后面的所有元素,时间复杂度上升到了 $O(n)$

程序最后跑多长时间,是由最慢的一环决定的,我们希望找到一种折中的方法,使得无论是单点查询还是区间求和,它都能不那么慢的完成

注意到我们对 $[a, b]$ 进行区间查询只需查询 $[1, b]$ 和 $[1, a)$ 然后相减即可(具体可看前缀和),所以我们可以把区间查询问题转化为求前$n$项和的问题

关于数组的维护,有个很自然的想法:可以用一个数组 C 维护若干个小区间,单点修改时,只更新包含这一元素的区间;求前n项和时,通过将区间进行组合,得到从 1 到 n 的区间,然后对所有用到的区间求和。实际上,设原数组是 $A$,如果 $C_{i}$ 维护的区间是 $[A_{i},A_{i}]$,此结构就相当于普通数组(还浪费了一倍内存);如果 $C_{i}$ 维护的区间是 $[1,A_{i}]$,此结构就相当于前缀和。

树状数组很好的平衡了两种操作的时间复杂度:单点修改区间求和

树状数组

树状数组巧妙的利用了二进制(实际上,树状数组的英文名: Binary Index Tree,直译过来就是二进制下标树)。例如 11,转化为二进制就是 $(1011)_{2}$ ,如果我们要求前11项和,可以分别查询 $( (0000)_{2} , (1000)_{2} ]$ 、
$( (1000)_{2} , (1010)_{2} ]$ 以及 $( (1010)_{2} , (1011)_{2} ]$ 的和,也就是 $( 0, 8 ]、( 8, 10 ]、( 10, 11 ]$ 再相加。这三个区间其实就是不断去掉二进制数 11 最右边的一个 1 的过程(如下图)

让我们定义,二进制数最右边的一个 1,连带着它之后的 0 为 lowbit(Ai)。那么我们用 Ci 维护区间
$(A_{i} - lowbit_{i},A_{i}]$。显然,在这个情况下,查询前n项和时需要合并的区间数是少于 $log_{2}n$ 的

那么,在树状数组中,更新就像是一个向上爬的过程。一路向上更新,直到MAXN(树状数组的容量)

我们举个例子来看看这树是这么爬的。现有二进制数 $(100110)_{2}$ ,包含它的最小区间就是 $( (100100)_{2},(100110)_{2} ]$
然后,它肯定也位于区间$( (100000)_{2},(101000)_{2} ]$内。然后是$( (100000)_{2},(110000)_{2} ]$,最后是$( 0,(1000000)_{2} ]$

如上图,每一步都把从右边起一系列连续的1变为0,再把这一系列1的前一位0变为1。这看起来像是一个进位的过程对吧?实际上,每一次加的正是 lowbit(x)lowbit(x)的值每次会是 x 的二进制最后一个 1 所在的位置对应的十进制的值,比如lowbit(5) = 1, lowbit(6) = 2 ( $5 = (101)_{2}\quad6 = (110)_{2}$ ) )

这样,我们更新的区间数不会超过 $log_{2}n$。一个能以 $O(log\ n)$ 时间复杂度进行单点修改和区间查询的数据结构就诞生了。

树状数组的实现

前面已经详细讲了树状数组的计算过程,实现倒是件简单的事情了。不过我们需要先解决一个重要的问题:lowbit() 函数怎么写?如果一位一位验证的话,会形成额外的时间开销。然而,我们有这样神奇的一个公式:

$$
lowbit(x)=x \ & \ (-x)
$$

为什么可以这样?我们需要知道,计算机里有符号数一般是以补码的形式存储的。$-x$相当于$x$按位取反再加1,会把结尾处原来1000…的形式,变成0111…,再变成1000…;而前面每一位都与原来相反。这时我们再把它和$x$按位与,得到的结果便是lowbit(x)。下面的图中举了两个例子:

现在我们可以愉快地实现树状数组了:

单点修改

int tree[MAXN];
inline void add(int i, int x)
{
    for (int pos = i; pos < MAXN; pos += lowbit(pos))
        tree[pos] += x;
}

求前n项和

inline int query(int n)
{
    int ans = 0;
    for (int pos = n; pos; pos -= lowbit(pos))
        ans += tree[pos];
    return ans;
}

区间查询

inline int query(int a, int b)
{
    return query(b) - query(a - 1);
}

树状数组的应用

求逆序对

在数组中,存在 $i < j\ ,\ a[i] > a[j]$ 时,我们称 $(a[i],\ a[j])$ 为数组中的一对逆序对

求逆序对主要有两种方法,一种是归并排序,另一种就是我们这里介绍的树状数组求法

不过使用树状数组求逆序对时,数组中存在负数时,需要对数组进行一定的处理(毕竟下标没有负数)

流程

使用树状数组求逆序对其实很简单:

  1. 找出数组中 最大 的数 $maxn$,创建一个大小为 $maxn + 1$ 的树状数组
  2. 将数组中的数 顺序 加入树状数组中
  3. 在加入一个数时,计算树状数组中 大于 它的数的总数
  4. 将这个数加入树状数组中

从前文可以知道,树状数组的强项就是求前缀和。所以,我们使用 tree[i] 来记录数组中 $i$ 出现的次数,每当我们求一个数存在的逆序对的个数时,我们只需要求树状数组中比这个数大的区间内有多少个数即可(也就是 query(MAXN) - query(i - 1)

代码实现
const int MAXN = 7; // 偷懒提前定好了
int tree[MAXN];

inline int lowbit(int x)
{
    return x & -x;
}

inline void add(int i, int x)
{
    for (int pos = i; pos < MAXN; pos += lowbit(pos))
        tree[pos] += x;
}

inline int query(int n) // 单点查询
{
    int ans = 0;
    for (int pos = n; pos; pos -= lowbit(pos))
        ans += tree[pos];
    return ans;
}

int main()
{
    int arr[5] = {5, 6, 3, 1, 2};
    int ans = 0, arrmax = 6; // 偷懒没写求数组最大值的代码
    for (int i = 0; i < 5; ++i)
    {
        ans += query(arrmax) - query(arr[i] - 1); // 计算这个数的逆序对个数
        add(arr[i], 1); // 将这个数添加进树状数组中
    }
    cout << ans << endl;
    return 0;
}

最后输出结果为 $8$,读者可自行使用纸笔验算

由于下标 没有负数,所以这种方法只能用于数组中的数均为 正整数 的情况

优化

在上面版本中,我们每次计算都要进行两次 query() 的计算,有些浪费时间

我们只需要一个很小的改动就可以计算的次数减半,将之前的 顺序 改为 倒序

由于逆序对的数量其实是 数组中这个数的后面有多少个数是小于它的数。那么,我们 倒序 输入数组中的数,就只用进行一次 query() 计算了(因为只用计算一次前缀和即可)

对于空间来说,如果我们遇到类似 a[] = {1, 5, 3, 999} 这样的数组,如果开一个 $999$ 的数组就造成了大部分空间的浪费

对此我们可以使用 离散化 的手段,将原数组变为 {4, 2, 3, 1}(也就是第 $i$ 大的数在原数组中的位置,例子中原数组最大的数是 $999$,在第四位,所以新数组第一位为 $4$)

但是需要注意,离散化后所求的为新数组的 顺序对