树状数组导论

关于这篇文章

这篇文章是 《树状数组模板》 的详细篇(萌新我的学习笔记),主要是介绍树状数组的原理

以及介绍这种超强数据结构的一些强大用法

但是可能会与 《树状数组模板》 的内容有些重复

关于树状数组,你需要了解的

树状数组的定义

一种支持单点修改,区间查询的数据结构。

这是比较基础的定义。

树状数组不止于“单点修改,区间查询”,更有其他美妙的用法。

最关键的是比线段树,

写得快,调得快,运行得快!!!

(但区间最值就不比线段树运行得快了)

树状数组,顾名思义,就是可以被数组容下的树

因为树状数组的空间复杂度是 \(O(n)\) ,也就是一个数组的长度,因此得名

也因为树状数组的树节点的编号可以组成一个数组

lowbit是啥,能吃吗

lowbit(x) 是树状数组最重要的一个函数

lowbit(x) 返回 \(x\) 只留下最低位的 \(1\) 后的结果。

例如对于 lowbit(5) ,其结果就是 \((110)_2\) 种只留下最低位的 \(1\) ,所以 lowbit(5)=2

代码如下:

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

lowbit 的代码的原理如下:

首先,要了解补码的概念:

在常见的计算机中,数据都是以补码的形式表示的。

一个数 \(x\) 的补码就是将 \(x\) 按位取反后再 \(+1\) 后的数

那如何只留下最低位的 \(1\) 呢?

由于末尾的 \(1\) 都是以 \(1\) 后跟着一串 \(0\) 的形式出现的,所以取反后就变成了 \(0\) 后跟着一串 \(1\)
这时再 \(+1\) 的话,就会将末尾跟着的一串 \(1\) 又变成 \(0\),那个 \(0\) 又被进位为 \(1\) ,这时再进行 按位与 ,由于有前面的部分是被取反的,所以与后不会留下 ,但是最末尾的 \(1\) 却与原来的数保持一致,所以就只留下来末尾的 \(1\) 了。

简单模拟:

$ s_1 = (a_1a_2a_3a_4 \cdots 10\cdots 0)_2 $

\(s_1\) 按位取反得到 \(s_2\)

\(s_2=(b_1b_2b_3b_4\cdots01\cdots1)\)

其中 \(b_i\)\(a_i\) 取反后的结果

这时,再在末尾加 \(1\)

\(s_2+1=(b_1b_2b_3b_4\cdots10\cdots0)\)

这时,\(s_1 \& (s_2+1)\) 的结果就是 \((00000\cdots10\cdots0)\),也就是最低位的 \(1\)

另外,关于 \(lowbit(x)\) 的值,在 \(1\leq x\leq 10^9\) 时,\(lowbit(x)\) 的期望为 \(15.0888\),如果范围更小的话,应该会更小一些

树状数组的思想

这里要用到之前所说的 lowbit 了,有没有一些激动?!

首先先考虑树状数组的经典用法,维护区间和

首先运用前缀和的思想,只要能够维护数组前缀和,就可以维护区间和了。

于是设前 \(n\) 个数的前缀和为 \(pre(n)\) ,即 \(pre(n)=\sum\limits_{i=1}^na_i\)

\(n=6\) 的情况举例,\(n\) 的二进制拆分为 \(n=(110)_2=(100)_2+(10)_2=4+2\)

\(pre(n)\) 可以拆分为 \(pre(n)=\sum\limits_{i=1}^4a_i+\sum\limits_{i=5}^6a_i\)

可以发现,我们将 \(pre(n)\) 拆分出的两个区间的长度与 \(n\) 的二进制拆分是相互对应的

于是就想到了,让树状数组维护一下这些长度为 \((100\cdots0)_2\) 的区间不就行了吗?

没错,通过维护这些区间,就可以维护树状数组的前缀和了

但这与 lowbit 有何联系?

如果考虑要查找 \(pre(n)\) ,就需要二进制拆分 \(n\) 来获取各个拆分出的区间的和

但直接拆分是需要 \(O(\log n)\) ,但其实我们只需要那些拆分后为 \(1\) 的位的值,

所以我们每次将 \(n\)\(lowbit(n)\) ,获取这个区间的和,然后 \(n-=lowbit(n)\),直到 \(n=0\)

这时所访问过的所有区间之和就是 \(pre(n)\),这样的话最坏复杂度 \(O(\log n)\) ,实际上大概率达不到


说了这么多,也该讲讲树状数组的实现了:

树状数组的实现

这一部分中的代码有很多都是从 《树状数组模板》 中摘过来的

凡事总要从基础做起

所以先讲解基础树状数组的实现,即单点修改,区间询问模板

区间求和

这个太简单了

就像《树状数组的思想》一章所言,实现 \(get(x)\) (有些人把这个函数称作 \(getsum(x)\)),来求出 \(pre(x)\) 的值。

为了方便获取值,我们将以 \(a_i\) 结尾的长度为 \(lowbit(i)\) 的区间之和储存于 c[i]

那就统计一个 ret

\(x\) 不等于 \(0\) 时,也就是没有拆分完时,

ret+=c[x],加上以 \(x\) 结尾的 \(lowbit(x)\) 长度的区间和

x-=lowbit(x) ,减去当前区间长,去查找下一个区间

单点修改

单点修改较为麻烦

我们要实现一个 \(add(x,k)\) ,将 \(a_x\) 加上 \(k\)

如果要增加 \(a_x\) 的值,那所有包含 \(a_x\) 的区间的值都会加上 \(k\)

首先要说明,编号最小(也就是 \(i\) 最小)的包含 \(a_x\)c[i]c[x]

这一点很显然,所有小于 \(x\)c[i] ,是以 \(a_i\) 结尾的,因为 \(i<x\) ,所以 c[i] 必然是不包含 \(a_x\)

那下一个包含 \(a_x\) 的区间编号为多少呢?

这里将要讲解树状数组中父节点的设计!!!

事实上编号是 \(x+lowbit(x)\) ,为什么呢?

首先要证明, \(x+lowbit(x)\) 的区间包含 \(a_x\)

这个很好想,只需要比较一下 \(lowbit(x+lowbit(x))\)\(lowbit(x)\) 的大小即可,

因为 c[x+lowbit(x)] 代表的区间长度为 \(lowbit(x+lowbit(x))\)

如果 \(x+lowbit(x)-lowbit(x+lowbit(x)) < x\) ,就包含 \(a_x\)

可以通过不等式移项得到,如果 \(lowbit(x)<lowbit(x+lowbit(x))\) ,就包含 \(a_x\)

因为 \(x \geq lowbit(x)\) ,所以 \(2 \times lowbit(x) \leq x+lowbit(x)\)

所以 \(lowbit(2 \times lowbit(x)) \leq lowbit(x+lowbit(x))\)

又因为 \(lowbit(lowbit(x))=lowbit(x)\)

所以 \(lowbit(2\times lowbit(x))=2\times lowbit(x)\)

所以 \(2 \times lowbit(x) \leq lowbit(x+lowbit(x))\)

所以 \(lowbit(x)<lowbit(x+lowbit(x))\)

因此,根据上文,\(x+lowbit(x)\) 的区间一定包含 \(a_x\)

证明有亿点点长,但接下来还要证明对于 任意 \(x+w(0 < w < lowbit(x))\),不包含 \(a_x\)

这里就给出一个比较简略的证明吧(主要是作者太懒了):

因为 \(w<x+lowbit(x)\) ,所以 \(lowbit(w)<lowbit(x)\)

与上文的不等式同理,如果 \(w<lowbit(x+w)\) ,那么区间包含 \(a_x\),反之则不然

因为 \(w<lowbit(x)\) ,所以 \(lowbit(w)=lowbit(x+w)\)这里简略了,读者自证

\(lowbit(w) \leq w\) ,不满足 \(w < lowbit(x+w)\),所以区间不包含 \(a_x\)

好吧,证明有点多了,至此可以证明 \(x+lowbit(x)\) 是下一个包含 \(a_x\) 的节点

这里再次定义一个函数吧,有助于下文书写,\(next(i)=i+lowbit(i)\)

但在改完 \(next(x)\) 后,怎么找到下一个包含 \(a_x\) 的区间呢?

我们有一种预感, \(next(next(x))\) 应该就是要找的区间

所以我们需要证明树状数组中不存在没有包含关系,只是相交关系的两个区间

如果证明成功的话,我们只需要找到下一个包含当前区间的区间即可,那就是 \(next(next(x))\)应该不难理解

如果存在有区间 \(i\) 和区间 \(j\)\(i<j\)),使得区间 \(i,j\) 不包含却相交

那么由于 \(j\geq i+lowbit(i)\) ,是一定会包含 \(i\) 区间(由上文可知)

所以 \(i<j<i+lowbit(i)\)

与上文中关于 \(w\) 的证明条件相同,因此由那段证明可知,\(j\) 区间不包含 \(a_i\) 元素,所以不存在满足条件的 \(i,j\)

由此可知 \(next(next(x))\) 就是我们要找的区间


既然证明了以上这些,如果需要执行 \(add(x,k)\),只需要先将 c[x]+=k ,再 x+=lowbit(x)

但必须保证 \(x\leq n\) 才行

参考代码如下所示:

void add(int x,int k)
{
    while(x<=n)
    {
        c[x]+=k;
        x+=lowbit(x);
    }
}

树状数组的结构

你可能会问,为何这时再说树状数组的结构,先讲实现再将结构?

确实树状数组的结构与其实现没有必然联系,但是讲了实现却更容易理解结构

如下图所示:

可以发现,图中,对于节点 \(i\) 的父亲就是 \(next(i)\)

所以 \(add(x,k)\) ,就是将 \(x\) 节点本身及其祖先更新了一下

\(get(x)\) 在这张图中稍微难看一点,不过如果靠看每个节点覆盖的区间应该也可以看出的

这个结构用处在于扩展树状数组(尤其是可持久化树状数组

对于普通的 \(add(x,k)\)\(get(x)\) 并没有太大用处

接下来,将一些比较难的东西——树状数组的扩展

树状数组的扩展

O(n) 建树

标准模板:

void init()
{
    int x;
    for(int i=1;i<=n;i++)
    {
        c[i]+=a[i];
        x=i+lowbit(i);
        if(x<=n) c[x]+=c[i];
    }
}

说实话,这个模板没啥可说的,就是将当前值加到父亲身上,当父亲时,父亲的值就已经全了(肉眼可见的O(n)

但是,下面的模板就有些不好辨认了:

for(int i=1;i<=n;i++)
{
    c[i]+=a[i];
    for(int j=1;j<lowbit(i);j*=2){
        c[i]+=a[i-j];
    }
}

但其实这种方法也是 O(n),但证明比较复杂(萌新我不会啦

差分(区间修改+单点查询)

直接用树状数组维护差分数组即可

int c[MAXN];

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

void repreadd(int x,int k)
{
    while(x<=n)
    {
        c[x]+=k;
        x+=lowbit(x);
    }
}

void add(int l,int r,int x)
{
    repreadd(l,x),repreadd(r+1,-x);
}

int get(int x)
{
    int ret=0;
    while(x)
    {
        ret+=c[x];
        x-=lowbit(x);
    }
    return ret;
}

void init()
{
    int x;
    for(int i=1;i<=n;i++)
    {
        c[i]+=a[i]-a[i-1];
        x=i+lowbit(i);
        if(x<=n) c[x]+=c[i];
    }
}

其中 repreadd 是标准的树状数组单点加,而 add 则是应用于维护差分的树状数组的区间加

两个树状数组的妙用(区间修改+区间查询)

这里稍微有些麻烦,但也比较好理解,这里引用一下我写过的《树状数组模板里的内容》:

说实话,这种用法算不上模板,但确实很强,比用线段树快很多,空间少用了很多,最重要的是:

代码量极其之短,而且特别好调(只要公式没写错)

\(a_i\) 为原数组, \(b_i\)\(a_i\) 的差分数组

可得:

\[a_i=\sum\limits_{j=1}^ib_j \]

因此,如果我们想要求出前 \(x\) 个数之和,可得:

\[\sum\limits_{i=1}^xa_i\\ =\sum\limits_{i=1}^x\sum\limits_{j=1}^ib_j\\ =b_1\times x+b_2\times(x-1)+b_3\times(x-2)+\cdots+b_x\times1\\ =\sum\limits_{i=1}^x(x-i+1)b_i\\ =(x+1)\sum\limits_{i=1}^xb_i-\sum\limits_{i=1}^xi \times b_i \]

这时候就可以发现,要想求出区间之和,需要维护两个树状数组,一个统计 \(b_i\) 的前缀和,另一个统计 \(i \times b_i\) 的前缀和。

int n;
ll c1[MAXN],c2[MAXN],a[MAXN];

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

void stdadd(int x,ll k)
{
	ll w=x*k;
	while(x<=n)
	{
		c1[x]+=k,c2[x]+=w;
		x+=lowbit(x);
	}
}

ll stdgetsum(ll *c,int x)
{
	ll ret=0;
	while(x)
	{
		ret+=c[x];
		x-=lowbit(x);
	}
	return ret;
}

void add(int l,int r,int x)
{
	stdadd(l,x);
	stdadd(r+1,-x);
}

ll getsum(int l,int r)
{
	return (r+1)*stdgetsum(c1,r)-l*stdgetsum(c1,l-1)-
	stdgetsum(c2,r)+stdgetsum(c2,l-1);
}

void init()
{
    for(int i=1;i<=n;i++)
    {
        c1[i]+=a[i]-a[i-1];
        c2[i]+=i*(a[i]-a[i-1]);
        int x=i+lowbit(i);
        if(x<=n)
        {
            c1[x]+=c1[i];
            c2[x]+=c2[i];
        }
    }
}

其中 c1 是正常的差分用法,c2 则是维护 \(i\times b_i\)

详情请看代码,挺清楚了


这里为什么有条分界线呢?

因为后面的扩展的可用性较小

但树状数组党还是值得一看

模拟平衡树

很简单的扩展

用树状数组维护值域,在值域较大时空间复杂度遭到严重打击,会出现 MLE建议还是老老实实的写平衡树

而且其实在值域比较小的情况下,插入删除排名第K小以及前驱后继的时间复杂度为 \(O(\log u)\)

比平衡树的 \(O(\log n)\) 还是要慢不少的

int n;
int c[MAXN],a[MAXN];

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

void add(int x,int k)
{
    while(x<=maxa)
    {
        c[x]+=k;
        k+=lowbit(k);
    }
}

void build()
{
    for(int i=1; i<=n; i++) add(a[i],1);
}

int rank(int x)
{
    int res=1;
    x--;
    while(x) res+=c[x],x-=lowbit(x);
    return res;
}

int findKth(int k)
{
    int ans=0,cnt=0;
    for(int i=30; i>=0; i--)
    {
        ans+=(1<<i);
        if(ans>maxa||cnt+c[ans]>=k) ans-=(1<<i);
        else cnt+=c[ans];
    }
    return ++ans;
}

int pre(int x){return findKth(rank(x)-1);}
int nxt(int x){return findKth(rank(x)+1);}

挺好写的,但也不比平衡树好写多少,总体还是推荐平衡树的

另外 maxa\(a\) 数组里最大的数的值

至于为什么 build 函数不使用上面的 init 函数的方法初始化,因为对于当前状况,build\(O(n\log u)\) 的,而 init\(O(u+n)\) (为什么不是 \(O(u)\) 的,因为还要扫描 \(a\) 数组将其装入桶里)的,所以 init 应该比 build 还慢,当然具体视情况而定

话说,这种扩展也可以维护第k小问题,所以下面的第k小扩展其实有亿点点多余

时间戳优化

其实这个优化是不太想说了,因为过于常见

引用一下 OI-wiki 的内容:

对付多组数据很常见的技巧。如果每次输入新数据时,都暴力清空树状数组,就可能会造成超时。因此使用 \(tag\) 标记,存储当前节点上次使用时间(即最近一次是被第几组数据使用)。每次操作时判断这个位置 \(tag\) 中的时间和当前时间是否相同,就可以判断这个位置应该是 0 还是数组内的值。

就是维护 \(tag\) 数组以及 \(temp\) 变量,\(tag_i\) 表示 \(c_i\) 上一次被使用是什么时候,\(temp\) 表示现在是什么时候

当访问 \(c_i\) 时,如果 \(tag_i \neq temp\) ,那么 \(c_i\) 存储的不是这一轮的值,否则 \(c_i\) 里的值就是这一轮的值

当要换到下一轮(清空树状数组)时,直接将 \(temp\)\(1\)

这里给出树状数组基础实现时间戳优化后的代码:

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

void clr(){++temp;}

void add(int x,int k)
{
    while(x<=n)
    {
        if(tag[x]!=temp)
        {
            c[x]=k;
            tag[x]=temp;
        }
        else c[x]+=k;
        x+=lowbit(x);
    }
}

int get(int x)
{
    int ret=0;
    while(x)
    {
        if(tag[x]==temp) ret+=c[x];
        x-=lowbit(x);
    }
    return ret;
}

void init()
{
    int x;
    for(int i=1;i<=n;i++)
    {
        c[i]+=a[i];
        x=i+lowbit(i);
        if(x<=n) c[x]+=c[i];
    }
}

其中 clr() 就是清空函数,负责清空树状数组

离散化树状数组

其实这算不上一种扩展,只能说是一种省时间省空间的方法

以后会写一篇《竞赛中的各类离散化》的

首先考虑,树状数组最大的缺点是什么,就是维护区间占用空间太大

如果区间长度为 \(len\) ,那树状数组的空间复杂度为 \(O(len)\)

看起来占用的不大,但如果要维护 \([1,10^{10}]\) ,就有些大了( 当场 MLE

这时就需要分析题目了,这类题目的区间较大,但由于时间限制,所以询问和插入不会太多

所以可以将区间范围缩小,即离散化

请注意:以下离散化的方法仅能用于非强制在线的题目

将所有要操作(这里的操作包含查询和修改)的区间列出来,如:\([1,10^5],[10^3,10^7],[10^5,10^7]\)

然后将所有区间的左右端点提取到一个数组里,进行排序,如:\(1,10^3,10^5,10^5,10^7,10^7\)

再进行去重,如:\(1,10^3,10^5,10^7\)

将数组里的每个元素与其在数组里的下标一一对应,

如(这里的数组下标从 \(1\) 开始):\((1,1),(10^3,2),(10^5,3),(10^7,4)\)

最后将要操作的区间的端点都替换成其对应的数:\([1,3],[2,4],[3,4]\)

这时再在这些区间里操作,就能省下大量的空间和时间了

二维树状数组

我已经找到了一个绝妙的二维数据结构,但是这里太窄了,写不下。

请转到 二维树状数组导论

第k小

啥?树状数组还能写第k小,当然了

毕竟第k小是史上最经典问题之一(雾,不应该是区间第k小吗?),解决方案有:

  • 万能的可持久化家族,如主席树

  • 各类分治的排序,如快排

    \(\cdots \cdots \cdots\)

既然有这么多算法,树状数组当然可以解决这个问题(树状数组万岁!!!

那说了这么多,第k小是什么呢?

就是给你一个数组 \(a\) ,让你维护一种操作:

给出一个数 \(k\),让你回答数组 \(a\) 中第 \(k\) 小的数是多少

题意很简单,做起来也很简单

既然要用树状数组做,那树状数组最擅长维护区间和,于是就要尝试与区间和联系

建立一个数组 \(b\)\(b_{i}\) 就表示 \(a\) 数组中等于 \(i\) 的元素有多少个

可以认为 \(b\) 数组就是一个桶

那第 \(k\) 小就是找到最小的 \(x\) 使得 \(pre(x) \leq k\) (在这里, \(pre(x)=\sum\limits_{i=1}^xb_i\)

这不就是树状数组最擅长的区间和了吗?

考虑到 \(pre(i)\) 是个上升函数,所以可以使用二分来查找 \(x\) 的值,

时间复杂度应该是 \(O((\log u)^2)\) (这里的 \(u\) 是值域)

那能不能再优化一点呢?

可以考虑倍增优化:

\(dep=\lfloor \log u \rfloor\)

\(x\) 代表当前来到 \(a_x\)\(sum\) 负责维护 \(pre(x)\)

计算 \(t=\sum\limits_{i=x+1}^{x+2^{dep}}a_i\)

如果 \(sum+t \leq k\)\(sum\) 加上 \(t\)\(x\) 加上 \(2^{dep}\) 次方

\(dep\)\(1\)

只要 \(dep \geq 0\) ,就一直循环

这时就会发现,这种倍增的方法不还是 \(O((\log u)^2)\) 吗?

其实并不然,因为 \(t=c_{x+2^{dep}}\) (这里的 \(c\) 数组就是树状数组)

所以可以 \(O(1)\)\(t\) ,那第 \(k\) 小也就 \(O(\log u)\) 回答了

代码(自己看吧,我就不解释了):

int kth(int k)
{
    int cnt=0,ret=0;
    for (int dep=log2(n); dep>=0; dep--)
    {
        ret+=1<<dep;
        if(ret>=n||cnt+t[ret]>=k) ret-=1<<dep;
        else cnt+=c[ret];
    }
    return ret+1;
}
posted @ 2021-12-23 18:18  yhang323  阅读(47)  评论(0编辑  收藏  举报