树状数组:从基础到极致

by hukk

树状数组是一种树形结构的数据结构,可以支持 O(log2n) 级别的区间操作。

最原始的树状数组仅支持单点修改与区间查询,配合上差分使用可以做到区间修改与单点查询。树状数组在加上各种扩展后可以支持很多其他操作,能够部分赶上并超过线段树的表现,而且其时空复杂度常数显著优于线段树。由于它好写好调,一直以来被许多 OIer 所喜爱。

前置知识

  • 阅读本文,您至少需要了解:

    • 常用数学符号如 等;
    • 前缀和与差分;
    • 补码表示法与位运算。
  • 如果您还要阅读本文的进阶部分,您还需要掌握:

    • 二维前缀和、差分
    • 线段树
    • 平衡树
    • 可持久化数据结构基础

树状数组基础

概念

引入

考虑以下问题(即 洛谷 P3374 【模板】树状数组 1):

  • 给定长度为 n 的数列 {an}m 次操作。
  • 操作 1:给定 x,k,将 ax 加上 k
  • 操作 2:给定 l,r,求 i=lraial+al+1++ar

可以发现,无论使用朴素算法或是前缀和都会有一个操作时间复杂度为单次 O(n),总复杂度为 O(nm)

树状数组可以做到两个操作均为单次 O(log2n),接下来介绍它的思想。

数组 d

我们构造一个数组 d,使它与 a 有如下对应关系:

d a
d1 a1
d2 a1+a2
d3 a3
d4 a1+a2+a3+a4
d5 a5
d6 a5+a6
d7 a7
d8 a1+a2+a3+a4+a5+a6+a7+a8
di j=ilowbit(i)+1iaj

把上表画成图就是下面这样的(很经典的图):

示意图

可以发现,在 d 中:

  • 奇数编号的位置只记录了本身的信息。
  • 2 的整数次幂的位置记录了完整的前缀和。

那其他规律呢?还有表中最后一行的 lowbit 是什么意思?

基本操作

lowbit

定义

如果一个正整数 x 的二进制表示为

x=(110000)2n0

即末尾有 n0,则满足

lowbit(x)=(10000)2n0

也就是说,lowbit(x) 就是只保留 x 在二进制表示下最后一个 1 及其后的 0,或者说找到 x 二进制下最低位的 1 的位置

举例:

  • 5=(101)2,则有 lowbit(5)=(1)2=1
  • 6=(110)2,则有 lowbit(6)=(10)2=2
  • 12=(1100)2,则有 lowbit(12)=(100)2=4
  • 20=(10100)2,则有 lowbit(20)=(100)2=4

我们再回过头去看图,就能看到:在 d 中的每个位置都记录了长度为 lowbit(编号)、以自身结尾的一段的和。

计算

利用 C++ 中的位运算与补码表示法 ,可以研究出多种计算 lowbit 的方法。以下介绍最常用的一种。

在 C++ 中,有 lowbit(x)=x&-x

为什么呢?请看下面的表格。

x -x(~x)+1 x&-x
01...10...00 10...10...0010...01...11+1 10...00

显然,lowbit(x)=x&-x 是成立的。常定义以下函数:

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

查询与修改

我们已经知道了数组 d (其实就是树状数组)中记录的信息,怎样才能修改这些信息呢?

为了方便,这里把上面的图搬下来。

示意图

查询

上面讲的树状数组可以 O(log2n) 查询前缀和,具体是这样的:

  1. 先访问将要查询的前缀和的末尾;
  2. 向上爬一层,往前走,累加答案;
  3. 重复直到已经累加完毕。

例如:查询前 7 项的和。

  1. 访问 d7
  2. 向上一层,累加 d6
  3. 再次向上,累加 d4
  4. 查询完毕,结果为 d7+d6+d4

像这样,就可以不重不漏地统计每个位置的信息(看看图,d7,d6,d4 是不是恰好覆盖了 a1,a2,a7?)。

如何确定下次加哪个位置呢?很简单:如果你这次累加了 dx,那么下一个就是 dxlowbit(x)。也就是循环减 lowbit。(不信试试看?)

修改

类似查询,也是一层层往上爬,但是方向变了。

  1. 先修改原位置;
  2. 向上爬,修改包含原位置信息的后续位置。

例如:修改位置 3,就会改变 d3,d4,d8

如何确定下次改哪个位置呢?类似地,如果你这次修改到了 dx,那么下一个就是 dx+lowbit(x)。也就是循环加 lowbit。(同样,自己验证一下?)


至此,你已经学会了树状数组的基本操作了,接下来我们看看它的使用。

使用及代码

建树

在使用之前,一般需要通过原数据构造树状数组,多数人把这个过程称为建树或预处理。

建树有两种方法:

  1. 逐个位置修改,复杂度 O(nlog2n)
  2. 利用树状数组的性质,即上面讲的修改的性质递推,复杂度 O(n)

详见代码。

//本文中所有代码均设原序列长度为 n,并默认已定义 lowbit()

for(int i=1;i<=n;i++){ //方法 1
    add(i,a[i]); //add 函数,用于修改,参见修改部分代码。
}

for(int i=1;i<=n;i++){ //方法 2
    d[i]+=a[i];
    if(i+lowbit(i)<=n) //注意边界
        d[i+lowbit(i)]+=d[i];
}

查询与修改

前面讲过了,只是要注意边界问题。循环加减 lowbit 即可。

代码:

void add(int x,int k){ //在 x 位置上加上 k
    while(x<=n){
        d[x]+=k; //修改
        x+=lowbit(x); //往上爬
    }
}

int query(int x){ //查询前 x 个数的和
    int ans=0;
    while(x>0){
        ans+=d[x]; //累加
        x-=lowbit(x); //往上爬
    }
}

时空复杂度分析

可以看出,树状数组单次操作的时间复杂度与其层数有关。

容易证明,若原序列长度为 n,则树状数组的层数为 log2n+1,故树状数组单次操作时间复杂度为 O(log2n)

同时,树状数组拥有比线段树小得多的时间复杂度常数。(树状数组时间复杂度常数约 12,而线段树约为 4。)

树状数组的空间复杂度为 O(n)

应用与变式

区间修改、单点查询

如果使用上述树状数组维护原数组的差分数组,就可以实现区间修改、单点查询。

求逆序对

我们在值域上开树状数组,表示某数是否在序列中出现过(0 为没出现过,1 为出现过)。

那么 add 函数就是把数依次加入到序列中,query 函数就是统计序列中值小于等于某数的个数。

我们依次将给定的序列输入,每次输入一个数时,就将当前序列中大于这个数的元素的个数计算出来,并累加到答案,最后的答案就是这个序列的逆序数个数。

若是值域很大,离散化即可。

例题

树状数组进阶

除了最基本的树状数组及其变式,它还有各种高级扩展用法。

二维树状数组

本质上就是用树状数组维护二维前缀和信息,两层循环查询与修改即可。

修改与查询函数变为以下代码:

//设原二维数组有 n 行 m 列

void add(int x,int y,int k){
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
    	    d[i][j]+=k;
}

int query(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))
            ans+=d[i][j];
    return ans;
}

树状数组与线段树

区间加法

定义:

  • a 为原数组。
  • sum 为前缀和数组,记 sum[x]=i=1xai
  • delta 为差分数组,记 Δax=i=1xdelta[i]

有:

sum[x]=i=1xai+i=1x[delta[i]×(xi+1)]=i=1xai+x×i=1xdelta[i]i=1x[delta[i]×(i1)]

那么可以把 sum 拆成三部分维护。具体而言,使用 d1 维护 delta 数组的和,d2 维护 delta[i]×(i1) 的和。详见代码。

void __add(int *arr,int x,int k){
    while(x<=n){
        arr[x]+=k;
        x+=lowbit(x);
    }
}
int __query(int *arr,int x){
    int ans=0;
    while(x>0){
        ans+=arr[x];
        x-=lowbit(x);
    }
    return ans;
}
void add(int l,int r,int x){
    __add(d1,l,x);
    __add(d1,r+1,-x);
    __add(d2,l,x*(l-1));
    __add(d2,r+1,-x*r);
}
int query(int l,int r){
    return (r*__query(d1,r)-(l-1)*__query(d1,l-1))-(__query(d2,r)-__query(d2,l-1));
}

区间最值

普通树状数组的实现要求所维护的数据满足区间加法与区间减法(如区间和),要能由前缀信息得到任意区间信息。

但是区间最值满足区间加法而不满足区间减法,因此需要在原来树状数组上做一些修改。

为了方便对照理解,再次放上这张图:

示意图

以下的代码均以区间最大值为例。

建树
void build(){
     for(int i=1;i<=n;i++){
         int t=lowbit(i);
          d[i]=a[i];
          for(int j=1;j<t;j*=2) //访问该位置能够覆盖的所有位置
              d[i]=max(d[i],d[i-j]);
    }
}

有不理解的,可以对照图片与下面的例子好好体会一下过程。

  1. 更新到位置 6
  2. 更新位置 6
  3. 访问位置 5,结束。
  4. 更新到位置 7
  5. 更新位置 7
  6. 没有需要访问的,结束。
  7. 更新到位置 8
  8. 更新位置 8
  9. 访问位置 820=7
  10. 访问位置 821=6
  11. 访问位置 822=4
  12. 23=8,不小于 8,结束。

以上建树过程可以保证正确性与效率兼顾。

修改

换一种建树的方式维护了正确性,修改同样如此。

那么在更新时,我们需要查询更新位置覆盖的所有位置。

void add(int x,int k){
    a[x]=k;
    while(x<=n){
        int t=lowbit(i);
        d[x]=a[x];
        for(int j=1;j<t;j*=2)
            d[i]=max(d[i],d[i-j]);
        x+=lowbit(x);
    }
}
查询

设查询的区间为 [L,R]

我们从 RL 进行判断。di 控制的 a 数组的元素是 [ilowbit(i)+1,i]。设 l=ilowbit(i)+1,r=i。如果 LlR 就将 di 加入最值的判断中,接着 i(ilowbit(i)),否则的话就只判 ai,然后 i(i1)

代码中的 i 直接用 r 来代替,也就是将右端点不断左移。

int query(int l,int r){
    int ans=a[r];
    while(1){
        ans=max(ans,a[r]);
        if(r==l) break;
        r--;
        while(r-l>=lowbit(r))
            ans=max(ans,d[r]),r-=lowbit(r);
        if(r<=l) break;
    }
    return ans;
}
时空复杂度分析

时间复杂度:

  • 建树显然是 O(nlog2n)
  • 修改和查询均为单次 O(log22n)

空间复杂度:O(n)

例题

树状数组与平衡树

可持久化树状数组


参考资料

  1. 从0到inf,超详细的树状数组详解
  2. 浅谈树状数组的优化及扩展
  3. 可以代替线段树的树状数组?——树状数组进阶(1)
  4. 可以代替平衡树的树状数组?——树状数组进阶(2)

作者:hukk

本文的全部内容与源代码在 CC BY-SA 4.0SATA 协议之条款下提供,转载请标明原作者。

posted @   hukk  阅读(94)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话
点击右上角即可分享
微信分享提示