树状数组简单易懂的详解

树状数组确实是个好东西啊,以前搞比赛的时候了解过它,会套用模版,但确没有深入理解这个东西,先学会用轮子,然后再学造轮子嘛,这段时间再回头研究了一下,发现二进制在算法中真的是的好东西,它可以使算法的时间复杂度降到n的二进制表示中的1相关,大家都知道,求一个二进制中的1的个数,这个时间复杂度为 O ( l o g n ) O(logn) O(logn)

有时候觉得树状数组难以理解,我觉得根本原因是:你还在用十进制的视角来看待树状数组,下面的讲解我会时刻提醒你转换到二进制的视角,而且我也不会先给你上图,因为你的视角在二进制,你就会发现树状数组就是一个普通的东西,不需要图你就能理解。

树状数组的问题模型

首先我们搞明白树状数组是用来干嘛的,现在有一个这样的问题:有一个数组a,下标从0n-1,现在给你w次修改,q次查询,修改的话是修改数组中某一个元素的值;查询的话是查询数组中任意一个区间的和,w + q < 500000

这个问题很常见,首先分析下朴素做法的时间复杂度,修改是 O ( 1 ) O(1) O(1)的时间复杂度,而查询的话是 O ( n ) O(n) O(n)的复杂度,总体时间复杂度为 O ( q n ) O(qn) O(qn);可能你会想到前缀和来优化这个查询,我们也来分析下,查询的话是 O ( 1 ) O(1) O(1)的复杂度,而修改的时候修改一个点,那么在之后的所有前缀和都要更新,所以修改的时间复杂度是 O ( n ) O(n) O(n),总体时间复杂度还是 O ( q n ) O(qn) O(qn)

可以发现,两种做法中,要么查询是 O ( 1 ) O(1) O(1),修改是 O ( n ) O(n) O(n);要么修改是 O ( 1 ) O(1) O(1),查询是 O ( n ) O(n) O(n)。那么就有没有一种做法可以综合一下这两种朴素做法,然后整体时间复杂度可以降一个数量级呢?有的,对,就是树状数组。

lowbit函数

这里我们先不管树状数组这种数据结构到底是什么,先来了解下lowbit这个函数,你也先不要问这个函数到底在树状数组中有什么用;

顾名思义,lowbit这个函数的功能就是求某一个数的二进制表示中最低的一位1,举个例子,x = 6,它的二进制为110,那么lowbit(x)就返回2,因为最后一位1表示2

那么怎么求lowbit呢?

  • 还记得 剑指Offer66题之每日6题 - 第二天中的第五题中讲过的如何消掉最后一位1吗?我们就是先消掉最后一位1,然后再用原数减去消掉最后一位1后的数,答案就是lowbit(x)的结果;

  • 第二种方法就是计算机组成原理课上老师教过我们求负数的补码的简便方法:把这个数的二进制写出来,然后从右向左找到第一个1(这个1就是我们要求的结果,但是现在表示不出来,后来的操作就是让这个1能表示出来),这个1不要动和这个1右边的二进制不变,左边的二进制依次取反,这样就求出的一个数的补码,说这个方法主要是让我们理解一个负数的补码在二进制上的特征,然后我们把这个负数对应的正数与该负数与运算一下,由于这个1的左边的二进制与正数的原码对应的部分是相反的,所以相与一定都为0,;由于这个1和这个1右边的二进制都是不变的,因此,相与后还是原来的样子,故,这样搞出来的结果就是lowbit(x)的结果。

两种方法对应的代码依次如下:

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

树状数组的思想

在树状数组的问题模型中已经有所提及了,就是那两种不同做法的一个综合;

先定义一些东西:arr是原数组,c是新开的一个数组,这个数组代表后缀和(问题模型中是用的前缀和,这里要用后缀和,具体原因马上就知道了);

二进制的视角:一个数n,假设n = 6,它的二进制为110,我们把它表示成累加的形式110 = 100 + 10,这样是可以的,那么我们要求前6(110)项的和是不是可以这样求: ∑ i = 1 6 = ( a r r 1 + a r r 2 + a r r 3 + a r r 4 ) + ( a r r 5 + a r r 6 ) \sum_{i=1}^{6}=(arr_1+arr_2+arr_3+arr_4) + (arr_5 + arr_6) i=16=(arr1+arr2+arr3+arr4)+(arr5+arr6)

注意括号中的元素个数,是不是4(100)个加2(10)个,和110 = 100 + 10是不是很像,不知你们发现了吗,10就是lowbit(110)的结果,100lowbit(100)的结果。求和的时候我们总是把 ∑ i = 1 n \sum_{i=1}^{n} i=1n拆分成这样的几段区间和来计算,而如何去确定这些区间的起点和长度呢?就是根据n的二进制来的(不懂的可以再看下上面举的例子),二进制怎么拆的,你就怎么拆分,而拆分二进制就要用到上面说的lowbit函数了。这里也可以顺理成章得给出c数组的表示了。

这里也可以顺理成章得给出c数组的表示了,c[i]表示从第i个元素向前数lowbit(i)个元素,这一段的和,这就是上面说的区间和,只不过这个区间是靠右端点的;你可能又会想,不是说区间是靠右端点的吗,是后缀和啊,那中间的这些区间怎么定义?其实递归定义就好了,比如说 ∑ i = 1 6 = ( a r r 1 + a r r 2 + a r r 3 + a r r 4 ) + ( a r r 5 + a r r 6 ) = ∑ i = 1 6 = ( a r r 1 + a r r 2 + a r r 3 + a r r 4 ) + c [ 6 ] \sum_{i=1}^{6}=(arr_1+arr_2+arr_3+arr_4) + (arr_5 + arr_6)=\sum_{i=1}^{6}=(arr_1+arr_2+arr_3+arr_4) + c[6] i=16=(arr1+arr2+arr3+arr4)+(arr5+arr6)=i=16=(arr1+arr2+arr3+arr4)+c[6],你把c[6]去掉,不就是 ∑ i = 1 4 = ( a r r 1 + a r r 2 + a r r 3 + a r r 4 ) \sum_{i=1}^{4}=(arr_1+arr_2+arr_3+arr_4) i=14=(arr1+arr2+arr3+arr4),这个区间不就靠右端点了吗, ∑ i = 1 4 = c [ 4 ] = c [ 6 − l o w b i t ( 6 ) ] \sum_{i=1}^{4}=c[4] = c[6 - lowbit(6)] i=14=c[4]=c[6lowbit(6)]

其实你把所有的数字都看成二进制,很好理解的。

树状数组的实现

设计一种数据结构,需要的操作无非就是”增删改查“,这里只讨论查询和修改操作具体是怎么实现的;

查询

这里说的查询是查询任一区间的和,由于区间和具有可加减性,故转化为求前缀和;

查询前缀和刚刚在树状数组的思想中已经说过了,就是把大区间分成几段长度不等的小区间,然后求和。区间的个数为 O ( l o g n ) O(logn) O(logn),所以查询的时间复杂度为 O ( l o g n ) O(logn) O(logn)

修改

修改某一位置上的元素的时间复杂度为 O ( 1 ) O(1) O(1),但是要更新c数组,不然查询的时间复杂度就会变高。更新的方法就要提一下树状数组的性质了和树状数组那张经典的图片了。

这里写图片描述

这张图片中已经把c数组的后缀和这个含义已经表达得很清楚了。这个时候你再把查询操作对应到这张图上,然后看着二进制来操作,是不是就可以很直白地理解上面所说的查询操作了!

我们从这张图中可以得到树状数组的如下性质:

  • 后缀和的长度是2的幂;
  • 上一层后缀和的长度是下一层后缀和长度的两倍;
  • 下一层后缀和只要补上自己后缀和的长度就可以得到上面层的后缀和(图中的虚框框),注意,是上面的后缀和,而不是上一层的后缀和,这个性质就是更新操作的依据;
  • 最后一位1右边有多少个0(可以用 l o g 2 ( l o w b i t ( x ) ) log_2(lowbit(x)) log2(lowbit(x))表示)就表示这一层有多少个直系子层(子层的意思就是这一层的和包含下面某一层的和)。

我暂时就写这么多吧,这个时候我们再来说更新操作;

更新的时候只要更新修改这个点会影响到的那些后缀和(c数组),假设现在修改6(110)这个点,依据树状数组的性质三,它影响的直系父层就是c[6(110) + lowbit(6(110))] = c[8(1000)],但是它肯定不是只影响直系父层,上面所有包含这一层和的层都要更新,但是我们把这个更新传递给直系父层c[8],8这个点的直系父层是c[16],依次类推地更新就行了。

这里我留一个问题给大家,如何寻找某一层的所有直系子层,大家可以看这个图思考一下,想一想。

树状数组的代码

查询前缀和

int sum(int x, ArrayInt c, int n)
{
    int ret = 0;
    for ( ; x > 0; ret += c[x], x -= lowbit(x));
    return ret;
}

更新后缀和

void update(int x, int val, ArrayInt c, int n)
{
    for ( ; x <= n; c[x] += val, x += lowbit(x));
}

完整代码

先给一个题目背景,然后运用树状数组来高效解决这个问题。

题目:

输入一个数组,然后给你一些操作,操作有查询和修改两种。具体见输入格式。

输入格式:

第一行输入n,表示数组长度;
第二行输入n个整数;
第三行输入m,表示操作的次数;
接下来m行,每行输入三个东西,字符chxych='F'表示查询xy这段区间和;ch='S'表示修改第x个元素为y

输出格式:

对于每个查询,输出结果。

Code:

之前好多同学和我说看不懂C++的代码,主要还是STL那块,所以我用C来写了这个代码。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define lowbit(x) ((x) & -(x))

typedef int *ArrayInt;

int sum(int x, ArrayInt c, int n)
{
    int ret = 0;
    for ( ; x > 0; ret += c[x], x -= lowbit(x));
    return ret;
}

void update(int x, int val, ArrayInt c, int n)
{
    for ( ; x <= n; c[x] += val, x += lowbit(x));
}

int main()
{
    int n, i, m;
    ArrayInt arr = NULL, c = NULL;
    char ope[5];

    scanf("%d", &n);
    arr = (ArrayInt)malloc((n + 1) * sizeof(int));
    c = (ArrayInt)malloc((n + 1) * sizeof(int));
    memset(c, 0, (n + 1) * sizeof(int));
    for (i = 1; i <= n; scanf("%d", arr + i), update(i, arr[i], c, n), ++i);

    for (scanf("%d", &m) ; m--; ) {
        int L, R;
        scanf("%s%d%d", ope, &L, &R);

        switch (ope[0]) {
        case 'F' :
            printf("%d\n", sum(R, c, n) - sum(L - 1, c, n));
            break;
        default :
            update(L, R - arr[L], c, n);
            arr[L] = R;
            break;
        }
    }
    return 0;
}

C++代码

看很多人想看C++如何实现,其实把上面的代码用G++编译,一点问题。

用现代C++写代码方面会很简洁,尤其是引入了很多机制,例如 l a m b d a lambda lambda表达式,模板的增强等等。随着C++20的到来,代码会更优雅,但是思想永远是最重要的,有时候代码好看与简洁并不代表好用易懂,下面的代码我是为了Modern C++ 而 ModernC++的,看起来很晦涩,实际的开发中是绝对不会这么用的。

#include <algorithm>
#include <iostream>
#include <vector>
#include <array>
#include <iterator>
#include <functional>

int main(int argc, char** argv)
{
    int n, m = 0;
    std::cin >> n;

    std::vector<int> arr(1, 0), c(n + 1, 0);

    auto lowbit = [](int x) -> int
    {
        return x & -x;
    };

    auto update = [&c, n, &lowbit](int x, int value) -> void
    {
        for (; x <= n; c[x] += value, x += lowbit(x)) {}
    };

    auto sum = [&c, n, &lowbit](int x) -> int
    {
        int ans = 0;
        for (; x > 0; ans += c[x], x -= lowbit(x)) {}
        return ans;
    };

    std::copy_n(std::istream_iterator<int>(std::cin), n, std::back_inserter(arr));
    std::for_each(std::next(std::begin(arr)), std::end(arr)
        , [&arr, i = 1, &update](auto & value) mutable -> void
    {
        update(i++, value);
    });

    for (std::cin >> m; m--; ) {
        std::string ope;
        int L = 0, R = 0;

        std::cin >> ope >> L >> R;

        switch (ope.front())
        {
        case 'F':
            std::cout << std::minus<int>()(sum(R), sum(L - 1)) << std::endl;
            break;
        default:
            update(L, R - arr[L]);
            arr[L] = R;
            break;
        }
    }

    return 0;
}


总结

查询区间和以前的做法要么就是查询很慢,修改很快,那怎么办呢,那就存储前缀和来提高查询速度,但这样一来修改了之后要更新这些前缀和,更新又很慢;

数组数组就完美地综合了这两种做法,存储后缀和,更新后缀和,通过lowbit来限定后缀和的长度,利用二进制使得查询、更新的时间复杂度都在 O ( l o g n ) O(logn) O(logn)

我已经把我所理解的树状数组尽可能详细地写出来了,之中可能有些表述不清楚的地方,大家可以在评论区留言。

  • 249
    点赞
  • 644
    收藏
    觉得还不错? 一键收藏
  • 60
    评论
树状数组(Fenwick Tree)是一种用于快速维护数组前缀和的数据结构。它可以在 $O(\log n)$ 的时间内完成单点修改和前缀查询操作,比线段树更加简洁高效。 下面是 Java 实现的树状数组详解: 首先,在 Java 中我们需要使用数组来表示树状数组,如下: ``` int[] tree; ``` 接着,我们需要实现两个基本操作:单点修改和前缀查询。 单点修改的实现如下: ``` void update(int index, int value) { while (index < tree.length) { tree[index] += value; index += index & -index; } } ``` 该函数的参数 `index` 表示要修改的位置,`value` 表示修改的值。在函数内部,我们使用了一个 `while` 循环不断向上更新树状数组中相应的节点,直到到达根节点为止。具体来说,我们首先将 `tree[index]` 加上 `value`,然后将 `index` 加上其最后一位为 1 的二进制数,这样就可以更新其父节点了。例如,当 `index` 为 6 时,其二进制表示为 110,最后一位为 2^1,加上后变为 111,即 7,这样就可以更新节点 7 了。 前缀查询的实现如下: ``` int query(int index) { int sum = 0; while (index > 0) { sum += tree[index]; index -= index & -index; } return sum; } ``` 该函数的参数 `index` 表示要查询的前缀的结束位置,即查询 $[1, index]$ 的和。在函数内部,我们同样使用了一个 `while` 循环不断向前查询树状数组中相应的节点,直到到达 0 为止。具体来说,我们首先将 `sum` 加上 `tree[index]`,然后将 `index` 减去其最后一位为 1 的二进制数,这样就可以查询其前一个节点了。例如,当 `index` 为 6 时,其二进制表示为 110,最后一位为 2^1,减去后变为 100,即 4,这样就可以查询节点 4 的值了。 最后,我们还需要初始化树状数组,将其全部置为 0。初始化的实现如下: ``` void init(int[] nums) { tree = new int[nums.length + 1]; for (int i = 1; i <= nums.length; i++) { update(i, nums[i - 1]); } } ``` 该函数的参数 `nums` 表示初始数组的值。在函数内部,我们首先创建一个长度为 `nums.length + 1` 的数组 `tree`,然后逐个将 `nums` 中的元素插入到树状数组中。具体来说,我们调用 `update(i, nums[i - 1])` 来将 `nums[i - 1]` 插入到树状数组的第 `i` 个位置。 到此为止,我们就完成了树状数组的实现。可以看到,树状数组的代码比线段树要简洁很多,而且效率也更高。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 60
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值