超详细树状数组讲解(原理+代码实现+拓展)

写在前面

话说挑个阴间时间发就没人看了罢(

这篇文章真的废了我很多心血,接下来几个月上学,可能没有很多时间写博客,所以难免会出现一些错误和讲得不够详细的地方,欢迎大家评论指出!

1 为什么要用树状数组

1.1 树状数组的用处

在做题的时候,经常会遇到“单点修改+区间查询”“区间修改+单点查询”“求逆序对个数”之类的问题。

如果只用一般的思路去写,很容易写出 O(nq) 的做法——对于每个询问,遍历询问区间进行修改/查询。

但是当数据量加大的时候,就只能用树状数组优越的小常数以及 O(q\log_2n) 的时间复杂度进行优化。

1.2 树状数组?线段树?

另一种数据结构“线段树”与树状数组的时间复杂度大体相似,但我不会线段树

线段树的常数比树状数组略大一些,而且它的代码量更大,空间复杂度更大(树状数组O(n)线段树O(n\log_2n)

不过它能解决的问题比树状数组多一点,但是树状数组也可以解决大部分线段树问题才怪

2 树状数组原理

2.1 思想

树状数组本质其实就是在维护一个前缀和/差分。

在“单点修改+区间查询”和求逆序对的时候用的就是前缀和的思想,“区间修改+单点查询”也要用到差分思想,所以不会前缀和和差分的点这里

2.2 树?数组?

为什么把“数组”和“树”两个相反的词放在一起呢?

其实树状数组从实现上看是一个数组,它的定义是:

1) int lowbit(int x) 返回x转为二进制后最后一个1的位置所代表的数值.

e.g. (34)_{10} = (100010)_2 所以返回 (10)_2 = (2)_{10}

2) 定义tree数组为a数组的树状数组 tree[i]=a[i-lowbit(i)+1]+a[i-lowbit(i)+2]+...+a[i]

tree_i=\sum_{j=i-lowbit(i)+1}^{i}a_j

为什么说这是个树呢?请看VCR:

图中t[i]就是前面的tree[i],tree[i]对应绿框表示tree[i]是哪些a[j]的和

不难发现如下事实

tree_i=\sum_{j+lowbit(j)=i}tree[j]  (唯二难点)

2.3 更新:修改

由上面的式子,我们可以得出以下结论:

当更新a[i]时,需且仅需更新tree[i],tree[i+lowbit(i)],tree[i+lowbit(i)+lowbit(i+lowbit(i))],...

这个很明显可以用循环来写,单点修改就是这么实现的;

当问题变为区间修改时,我们更新[L,R]区间,就是更新差分数组b的第L项和第(R+1)项,所以

此时将tree数组用来维护原数组的差分数组b就可以只更新b[L]和b[R+1]

方法同上个粗体字

2.4 前缀:查询

大家还记得树状数组的定义吗?

定义tree数组为a数组的树状数组 tree[i]=a[i-lowbit(i)+1]+a[i-lowbit(i)+2]+...+a[i]

那么这个定义如何帮助我们呢?

(真)不难发现

tree[i]+tree[i-lowbit(i)]+tree[i-lowbit(i)-lowbit(i-lowbit(i))]+... = a[1]+a[2]+...+a[i] (唯二难点)

也可以用循环来写,这就是前缀和;

查询[L,R]的区间和就是a[1~R]前缀减去a[1~L-1]前缀

而“区间修改+单点查询”呢?由于维护的是差分数组,所以a[i]就是b[1~i]前缀

3 代码实现

知识点:“单点查询+区间修改”“单点修改+区间查询”

3.1 lowbit函数

3.1.1 前置知识

位与原码、反码、补码

3.1.2 具体实现

考虑一个数 x 在内存空间中被表示为以下二进制数

(0.1.0.100..0)_2

其中的1就是lowbit函数应该返回的数值,最前面的0是符号位,表示正数

那么它的反码就是以下负数

(1.0.1.011..1)_2

其中前面省略部分与原码完全相反

补码(反码-1)

(1.0.1.100..0)_2

前面省略部分也与原码完全相反

考虑此时原码位与补码的结果:

\,\,\,\,\,(0.1.0.100..0)_2\newline \&\,(1.0.1.100..0)_2\newline =(0.0.0.100..0)_2

这就是lowbit函数的底层实现

代码如下:

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

3.2 更新

3.2.1 单点更新

上面原理部分已经讲过了,比较简单

代码如下:(下面几个代码都有点微抽象,敬请谅解)

void upd(int x, int num) { // 给x这个位置的数加上num
    for(; x <= n; tree[x] += num, x += lowbit(x));
}

3.2.2 区间更新(配单点查询)

就是差分数组单点更新(

代码如下:

​
​
void upd(int l, int r, int num) { // 给[l,r]区间的数加上num
    for(; l <= n; tree[l] += num, l += lowbit(l));
    for(r ++; r <= n; tree[r] -= num, r += lowbit(r));
}

3.3 查询

3.2.1 区间查询(配单点更新)

查询[L,R]区间和就是R前缀减去L-1前缀

代码如下:

​
int ask(int x, int ret = 0) { // a[1]到a[x]的前缀(我可以好好声明ret的,但我拒绝(笑
    for(; x; ret += tree[x], x -= lowbit(x));
    return ret;
} 
int query(int l, int r) { // 查询[l,r]区间和
    return ask(r) - ask(l - 1);
}

3.2.2 单点查询

就是上面的ask(x),我不重复写了

4 一些树状数组的小拓展

4.1 二维树状数组

4.1.1 单点修改+区间查询

2.1 思想那节,我说过树状数组本质上就是去维护前缀和/差分。当问题变为二维数组时,我们其实就是在维护二维前缀和/差分。此时:

tree[i][j]表示a[i-lowbit(i)+1][j-lowbit(j)+1]到a[i][j]共(i-lowbit(i))(j-lowbit(j))个元素的和

 那么更新和查询代码都要有略微修改:

void upd(int x, int y, int num) { // (x,y)元素增加num
    for(; x <= n; x += lowbit(x))
        for(; y <= n; tree[x][y] += num, y += lowbit(y));
}
int ask(int x, int y, int ret = 0) { // 二维前缀和
    for(; x; x -= lowbit(x))
        for(; y; ret += tree[x][y], y -= lowbit(y));
    return ret;
}
int query(int lux, int luy, int rbx, int rby) { // 二维前缀和典型套路(容斥原理) 
	return (lux --, luy --, ask(rbx, rby) - ask(rbx, luy) - ask(lux, rby) + ask(lux, luy));
}

4.1.2 区间修改+单点查询

这里涉及到二维差分

区间修改只需要修改四至点,单点查询仍然是求差分数组前缀和

​
void add(int x, int y, int num) {
    for(; x <= n; x += lowbit(x))
        for(; y <= n; tree[x][y] += num, y += lowbit(y));
}
void upd(int lux, int luy, int rbx, int rby, int num) {  
    add(lux, luy, num);
    add(lux, ++ rby, -num);
    add(++ rbx, luy, -num);
    add(rbx, rby, num);
}

4.2 求逆序对

这里tree[i]表示当前已考虑范围中有多少个在(i-lowbit(i), i]区间的值,保持了树状数组的模样

但是如何不重复地求出逆序对个数呢?考虑以下推导:

对于a[i],a[j]<=a[i]且j<=i的节点个数为ask(a[i]);

对于a[i],a[j]>a[i]且j<=i的节点个数为i-ask(a[i]),即以a[i]为第二项的逆序对数量

那么总的逆序对数量即为\sum_{i=1}^{n}{i-ask(a[i])}

 代码如下:

// . . .
void upd(int x) {
    for(; x <= n; tree[x] ++, x += lowbit(x));
}
int ask(int x, int ret = 0) {
    for(; x; ret += tree[x], x -= lowbit(x));
    return ret;
}

int main() {
    // . . .
    for(int i = 1; i <= n; i ++) {
        cin >> a[i];
        upd(a[i]); // 思考为什么要在更新答案之前把a[i]加入考虑范围内
        ans += i - ask(a[i]);
    }
    cout << ans;
}

4.3 区间修改+区间查询

4.3.1 一维

这部分用公式来讲比较显而易见

首先考虑区间修改,肯定要用tree数组来维护差分数组:

设d[]表示原数组的差分数组,tree[i]表示d的树状数组

那么区间修改就和上面3.2.2 区间更新(配单点查询)的代码无异了

区间查询比较难想,我们用公式来推:

\sum_{i=1}^k a[i]=\sum_{i=1}^k \sum_{j=1}^i d[i]\newline =\sum_{i=1}^k d[i]\times(k-i+1) \newline =(k+1)\times\sum_{i=1}^kd[i] -\sum_{i=1}^ki*d[i]

观察到所用的前缀和一个是d[i],一个是i*d[i],用两个树状数组维护这两个量即可

代码如下:

​
​int tree1[N], tree2[N]; // tree1 存储 d[i]; tree2 存储i*d[i]
void upd_single(int x, int num) { // 单点进行更新
    for(int i = x; i <= n; tree1[i] += num, tree2[i] += x * num, i += lowbit(i));
}
void upd(int l, int r, int num) { // 区间更新
    upd_single(l, num);
    upd_single(++ r, -num);
}
int ask(int x, int ret = 0) { // 查询前缀和
    for(int i = x; i; ret += (x + 1) * tree1[i] - tree2[i], i -= lowbit(i));
    return ret;
}
int query(int l, int r) { // 查询[l,r]区间和
    return ask(r) - ask(l - 1);
}

4.3.2 二维

偶不会

模仿上面一维的思路,先考虑区间修改(同4.1.2 区间修改+单点查询一致)

再考虑前缀和:

\sum_{i=1}^x\sum_{j=1}^y\sum_{k=1}^i\sum_{l=1}^jd[k][l]\newline =\sum_{i=1}^x\sum_{j=1}^y[d[i][j]\times(x-i+1)(y-j+1)]\newline =\sum_{i=1}^x\sum_{j=1}^y[xy\times d[i][j]-(x+1)\times (j\times d[i][j])-(y+1)\times(i\times d[i][j])+(ij\times d[i][j])+(x+y)\times d[i][j]+1]

所!以!需要维护j*d[i][j],i*d[i][j],i*j*d[i][j]和d[i][j]的树状数组

代码如下:(有待填坑)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值