树状数组-从入门到拓展详解

树状数组-从入门到拓展

树状数组入门

期间如有问题,欢迎评论区讨论

树状数组是一个可以在O(log2n)的时间复杂度下实现修改和查询的数据结构,因此对于我们在竞赛中起着重要作用

为了能够直观的认识这个时间复杂的意义,我们看下面这个问题

给定长度为n的序列

如果要求我们求出下标区间l-r内数的总和,我们可能会想到直接两个前缀和想减即可

如果我要求把第k个数修改一下,那我们直接修改即可

但是,重点来了,如果我给出m个询问,一共分为两种询问

第一种是需要你对第k个数进行修改

第二种是需要你对当前区间l-r求和,那么,还可以直接算吗?

很显然是不行的,例如我有八个数,我要求2-7之间的区间和,我可以sum[7] - sum[1],如果我下一步是让你第二个数的值加上x,那么后面的这些前缀和就都需要重新算一边,当我们询问次数高达十的五次方次的时候,显示这种暴力的方法是行不通的

好,带着问题我们开始接触树状数组,首先看一下树状数组

假设我们给出八个数(a[1]、a[2]、....a[8])、那么我们定义树状数组tr

tr[1] = a[1];

tr[2] = a[1] + a[2];

tr[3] = a[3];

tr[4] = a[1] + a[2] + a[3] + a[4];

tr[5] = a[5];

tr[6] = a[5] + a[6];

tr[7] = a[7];

tr[8] = a[1] + a[2] + a[3] + a[4] + a[5] + a[6] + a[7] + a[8];

我们使奇数项直接等于我们的原数组,偶数项则是一种和的形式,为了方便理解,下面放上树状数组的一个图形

树状数组-从入门到拓展详解

空白方格不需要管,我们只需要知道,箭头代表我当前方格管理的方格有哪些,例如tr[6]就可以管理a[5] + a[6]

为什么像这样定义呢

当我们需要求出前六项的前缀和的时候,我们想一下,我们只需要tr[6] + tr[4]就可以得到了

当我们需要求出前七项的前缀和的时候,我们只需要tr[7] + tr[6] + tr[4]即可

再者我们考虑修改,假设我需要修改a[2],其实我们发现,包含a[2]的只有tr[2]、tr[4]、tr[8]这三项

这样我们在边修改边查询的时候,时间复杂度就会降低很多

那么现在我们考虑,这6应该跟4、6关联起来了呢,我们看一下4、6的二进制

6 :110

4: 100

再看一下前七项前缀和需要用到的7、6、4的

7 : 111

6 : 110

4 : 100

规律就是,每次将二进制中最低位的1减去,直到减完即可

对于修改,我们考虑2和2、4、8的关系

2 : 10

4 : 100

8 : 1000

再考虑包括3的有哪些,3、4、8

3 : 11

4 : 100

8 : 1000

规律就是每次加上二进制中最低位的1,直到超过n

二进制最低位的1也有响应的算法,我们称之为lowbit函数

int lowbit(int x)// 返回x的最低位1
{
    return x & -x;
}

这里是利用了负数在计算机内存储形式为补码的特点,感兴趣的可以自己计算一下

单点修改、区间查询

了解了树状数组的内容,和lowbit函数,接下来就是如何实现单点修改和区间查询了

对于单点修改,我们上面提到过,从该点开始,每次加上lowbit,直到最大

这样我们就把可以管理到我们当前数的tr数组给初始化完成了

例如a[2] = 2;那么我就需要把tr[2]、tr[4]、tr[8]都加上这个a[2],因为他们都可以管理到a[2]

// 单点修改
void update(int x, int c)  // a[x] = c;
{
    for (int i = x;i <= n; i += lowbit(i)) tr[i] += c;// 如果你可以管理到我,那么你就加上我的值
}

代码很短,多琢磨琢磨就清楚了

考虑前x个数的和,根据我们上面分析的,每次减去最低位的1即可

例如我要求前七个数的和,就是res += tr[7] + tr[6] + tr[4];

// 区间查询
int getsum(int x)  // 返回前x个数的和
{
    int res = 0;// 前缀和计算结果,用于返回
    for (int i = x;i > 0; i -= lowbit(i)) res += tr[i];
    return res;
}

下面推荐一道LibreOJ上的例题,推荐这个OJ是因为在这上面提交后是可以看到任何一个测试点的,方便调试

#130. 树状数组 1 :单点修改,区间查询

AC代码如下

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;

typedef long long ll;
const int N = 1e6 + 10;

int a[N];// 原数组
ll tr[N];// 树状数组
int n, m;

int lowbit(int x)// lowbit函数
{
    return x & -x;
}

void update(int x, int c)  // a[x] = c
{
    for (int i = x;i <= n; i += lowbit(i)) tr[i] += c;
}

ll getsum(int x)  // 返回前x个数的和
{
    ll res = 0;
    for (int i = x;i > 0; i -= lowbit(i)) res += tr[i];
    return res;
}


int main() {
    ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
    // freopen("in.in", "r", stdin);freopen("out.out", "w", stdout);

    cin >> n >> m;
    for (int i = 1;i <= n;i ++) {
        cin >> a[i];
        update(i, a[i]);// 初始化tr数组
    }
    for (int i = 1;i <= m;i ++) {
        int id, l, r;
        cin >> id >> l >> r;
        if (id == 1) {// 修改
            update(l, r);// 在l的位置上加r,并更新后面可以管理到他的
        }else {// 查询
            ll ans = getsum(r) - getsum(l - 1);// 区间和
            cout << ans << endl;
        }
    }
    return 0;
}

区间修改、区间查询

说明:线段树挂懒标记可更简单的解决,如果实在不想看树状数组的,可以跳过这里

既然单独提出来了,那么一定是有特点的,不能简简单单考虑,我存的时候存差分不久可以了吗

仔细想想,如果存的时候存的是差分数组,那么你只能进行简单的单点查询,为了能够实现区间查询,我们就要好好考虑一下了

首先对于一个区间和a[1]+a[2]+...+a[n]

定义c为差分数组,那么我们可以得到a[1]+a[2]+...+a[n] = (c[1]) + (c[1]+c[2]) + ... + (c[1]+c[2]+...+c[n])

我们进一步将公式转换= n*c[1] + (n-1)*c[2] +... +c[n]

最终我们得到求和公式= n * (c[1]+c[2]+...+c[n]) - (0*c[1]+1*c[2]+...+(n-1)*c[n])

接下来就可以开始愉快的敲代码了

我们只需要维护两个树状数组c1、c2,其中c1存我们的差分数组,c2存我们的差分数组*系数

推荐题目依旧是LibreOJ上的模板题

#132. 树状数组 3 :区间修改,区间查询

AC代码如下

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;

typedef long long ll;
const int N = 1e6 + 10;
int n, q;
ll a[N], c1[N], c2[N];

void update(ll *h, int x, ll y){
	while(x <= n){
		h[x] += y;
		x += x & -x;
	}
}

ll getsum(ll *h, int x){
	ll res = 0;
	while(x){
		res += h[x];
		x -= x & -x;
	}
	return res;
}

int getsum(int x)//求差分数组c1的前缀和->修改后的原数组
{
    int ans = 0;
    while (x)
    {
        ans += c1[x];
        x -= lowbit(x);
    }
    return ans;
}

int main(int argc, char const *argv[])
{
	// freopen("in.txt", "r", stdin);
	// freopen("out.txt", "w", stdout);
	cin >> n >> q;
	for (int i = 1; i <= n; ++i)
	{
		cin >> a[i];
		update(c1, i, a[i] - a[i - 1]);// c1存 差分
		update(c2, i, (i - 1) * (a[i] - a[i - 1]));// c2存 差分*系数
	}
	for(int i = 1;i <= q;i++){
		ll id, l, r, x;
		cin >> id >> l >> r;
		if(id == 1){
			cin >> x;
			update(c1, l, x);
			update(c1, r + 1, -x);
			update(c2, l, x * (l - 1));
			update(c2, r + 1, -x * r);
		}else{
			ll sum1 = (l - 1) * getsum(c1, l - 1) - getsum(c2, l - 1);// 求和公式
			ll sum2 = r * getsum(c1, r) - getsum(c2, r);
			cout << sum2 - sum1 << endl;
		}
	}
//    for (int i = 1; i <= n; i++)//修改后的原数列、俗称单点查询qwq
//        cout << getsum(i) << ‘ ‘;
	return 0;
}

区间最值说明

不再对其进行讲解,自行理解即可,很简洁,最大值最小值思路一样

void update(int x, ll y)
{
    while (x <= n)
    {
        c[x] = max(c[x], y);// 谁可以管理到我,谁就对我取max,看我是否可以作为最大值
        x += x & -x;
    }
}

ll getsum(int x)
{
    ll ans = 0;
    while (x)
    {
        ans = max(ans, c[x]);// 对于每一个我可以管理的到,取max
        x -= x & -x;
    }
    return ans;
}

拓展一:逆序数问题

逆序数问题只做为兴趣,实际情况不一定有递归求的快,下面我也会给出递归版本的求逆序数

逆序数就是当前数前面有几个比他大的数

那么我们用树状数组就可以完美的解决这个问题,因为我们树状数组就可以求出了一个数前面的前缀和,如果我们每个数都贡献1,那么求的就是有几个数比我小,然后剩下的,就是比我大的了,就可以做为贡献加入结果中

当然大部分情况下,数会非常大,所以需要离散化一下

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1e6 + 10;
struct node
{
    int x, y;
} que[N];
int tree[N], dispers[N];
int n, cnt = 0, sum;

bool cmp(node a, node b) { return a.x < b.x; }

void update(int x)
{
    while (x <= n)
    {
        tree[x]++;// 默认贡献为1代表个数
        x += x & -x;
    }
}

int getsum(int x)
{
    int ans = 0;
    while (x)
    {
        ans += tree[x];
        x -= x & -x;
    }
    return ans;
}

int main()
{
    // freopen("in.txt", "r", stdin);
    while (cin >> n)
    {
        for (register int i = 1; i <= n; i++)
        {
            cin >> que[i].x;
            que[i].y = i;// 用于离散化
        }
        sort(que + 1, que + n + 1, cmp);
        for (register int i = 1; i <= n; i++)// 离散化
        {

            cnt++;
            // cout << cnt << endl;
            dispers[que[i].y] = cnt;
        }
        for (register int i = 1; i <= cnt; i++)
        {
            update(dispers[i]);// 默认贡献为1,代表个数
            sum += (i - getsum(dispers[i]));// 那么剩余的就是比当前数大的了
        }
        cout << sum << endl;
    }
    return 0;
}

递归版本代码

原理也比较简单,在递归排序中,我们知道,是通过一个数一个数往前挪的

那么,对于一个数,你在递归排序中,挪了几次,都加起来,就是这个序列的逆序数了

#include <iostream>
#define INF 0xFFFFF
using namespace std;
long long A[1000000];
long long number;
typedef long long ll;

void Merge(int left, int mid, int right)
{
    int len1 = mid - left + 1;
    int len2 = right - mid;
    int L[len1 + 2], R[len2 + 2];
    for (int i = 1; i <= len1; i++)
        L[i] = A[left + i - 1];
    for (int i = 1; i <= len2; i++)
        R[i] = A[mid + i];
    L[len1 + 1] = R[len2 + 1] = INF;
    int l = 1, r = 1;
    for (int i = left; i <= right; i++)
    {
        if (L[l] <= R[r])
            A[i] = L[l++];
        else
        {
            A[i] = R[r++];
            number += len1 - l + 1;// 如果需要往前挪,就让逆序数加上你挪了几位
        }
    }
}

void mergeSort(int left, int right)
{
    if (left < right)
    {
        int mid = (left + right) / 2;
        mergeSort(left, mid);
        mergeSort(mid + 1, right);
        Merge(left, mid, right);
    }
}

int main()
{
    // freopen("in.txt", "r", stdin);
    std::ios::sync_with_stdio(false);
    long long n;
    while (cin >> n)
    {
        number = 0;
        for (register int i = 1; i <= n; i++)
            cin >> A[i];
        mergeSort(1, n);
        cout << number << endl;
        // for(register int i=0;i<n;i++) cout << A[i];
        // cout << endl;
    }
}

拓展二:上升子序列问题

推荐例题AcWing上的3662. 最大上升子序列和

子序列问题大部分是需要dp来求解的

不过用树状数组也有奇效

通过树状数组的性质,我们知道,对于每个树状数组的含义是管理他前面是数,那么我们就可以不只用来求和,用来求最大值也是可以的

对于本题,首先我们修改一下update和getsum函数

void update(int x, ll y)
{
    while (x <= n)
    {
        c[x] = max(c[x], y);// 谁可以管理到我,谁就对我取max,看我是否可以作为最大值
        x += x & -x;
    }
}

ll getsum(int x)
{
    ll ans = 0;
    while (x)
    {
        ans = max(ans, c[x]);// 对于每一个我可以管理的到,取max
        x -= x & -x;
    }
    return ans;
}

我们定义sum数组表示以第i个数结尾的最大上升子序列和,对于我们的序列,我们一个一个判断

对于当前这个数我们考虑比他小的所有数里面,最大的子序列和,并实时更新树状数组

由于我们n只有十的五次方,但是每个数却可能非常大,所以需要离散化一下

AC代码如下

#include <iostream>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <cstdio>
#include <map>
#include <unordered_map>
#define INF 0xFFFFFF
using namespace std;

typedef long long ll;
const int N = 1e5 + 10;
ll n, tot = 0;
ll que[N], disperse[N], sum[N], amxsum[N], c[N];
unordered_map<int, ll> mp;

void update(int x, ll y)
{
    while (x <= n)
    {
        c[x] = max(c[x], y);
        x += x & -x;
    }
}


ll getsum(int x)
{
    ll ans = 0;
    while (x)
    {
        ans = max(ans, c[x]);
        x -= x & -x;
    }
    return ans;
}

int main()
{
//     freopen("in.txt", "r", stdin);
//     freopen("out.txt", "w", stdout);
    cin >> n;
    for (int i = 1; i <= n; i++)
        cin >> que[i];
    memcpy(disperse, que, sizeof que);// disperse数组用于存放原数组来离散化
    sort(disperse + 1, disperse + n + 1);
    
    for (int i = 1; i <= n; i++)
        if (!mp.count(disperse[i]))
            mp[disperse[i]] = ++tot;// 每个数是第几大的数
            
    for (int i = 1; i <= n; i++)// 从头往后遍历
    {
        // 对于在我前面出现并且比我小的数里面,找子序列和最大的,然后加上我的值
        // 由于我是第mp[que[i]]大的数,那么我的子序列和最小也得是mp[que[i]]
        sum[i] = max(mp[que[i]], getsum(mp[que[i]] - 1) + que[i]);
        update(mp[que[i]], sum[i]);//边记录还要变更新以我为结尾最大的子序列和,方便后面的数进行判断
    }
    
    ll ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, sum[i]);
    cout << ans << endl;
    
    return 0;
}

这里是求的最大上升子序列和问题,相应的还有最长上升子序列问题,只需要将这里代码改一下就行了

for (int i = 1; i <= n; i++)// 从头往后遍历
    {
        sum[i] = max(1ll, getsum(mp[que[i]] - 1) + 1);
        update(mp[que[i]], sum[i]);
    	// sum[i] = max(mp[que[i]], getsum(mp[que[i]] - 1) + que[i]);
        // update(mp[que[i]], sum[i]);
    }

如果要求非严格上升的话,也只需要把getsum(mp[que[i]] - 1)修改为getsum(mp[que[i]])即可

拓展三:第k小数

推荐题目AcWing244. 谜一样的牛

思路:二分+树状数组

对于中间某一头牛,我们只知道他前面比他高的,并不清楚他后面有几个

但是,对于最后一头牛,如果他前面有k个比他高的,那么他一定是第k + 1个高的牛

对于倒数第二头牛,如果他前面有k个比他高的,那么他一定是除了最后一头牛以外的,第k + 1个高的牛

图示

树状数组-从入门到拓展详解

对于第5头牛,我已经可以确定,他是第1高的,说明他已经占据了第一个位置,那么看第4头牛

树状数组-从入门到拓展详解

因为他前面有一个比它高的,所以我们从1-n进行二分,看那个数前面有1个还存在的高度,然后我们定位到第4头牛的高度为3

树状数组-从入门到拓展详解

看第3头牛,他前面有两个比它高的,从1-n进行二分,我们定位到5这个高度的前面还有两个存在的高度,所以我们定位到第三头牛高度为5

以此类推

所以我们就可以从后往前遍历,每求出一头牛是第几高,我们就将这个高度删去,然后去判断下一头牛

#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>
using namespace std;

const int N = 1e5 + 10;
int a[N], c[N];
int n;
vector<int> res;

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

inline void update(int x, int cc) {
    for(int i = x;i <= n;i += lowbit(i)) {
        c[i] += cc;
    }
}

inline int get(int x) {
    int res = 0;
    for(int i = x;i;i -= lowbit(i)) {
        res += c[i];
    }
    return res;
}

int main(){
    scanf("%d", &n);
    for(int i = 2;i <= n;i++) {
        scanf("%d", &a[i]);
    }
    
    for (int i = 1;i <= n;i ++) {
        update(i, 1);// 最开始,每一个高度都存在
    }
    
    for(int i = n;i;i--){
        int l = 1, r = n;
        while(l < r){
            int mid = l + r >> 1;
            if(get(mid) >= a[i] + 1) r = mid;
            else l = mid + 1;
        }
        update(r, -1);// 这个高度已经有牛了,将其删去
        res.push_back(r);
    }
    
    for(auto i = res.rbegin();i != res.rend();i++){// 由于是倒着求答案的,所以要倒着输出
        printf("%d\n", *i);
    }
    
    return 0;
}

拓展四:离散化

推荐题目

HDUTuring Tree

T组数据,给定长度为n的序列,m次询问,每次询问区间内不同数相加的和

为了能从左往右进行枚举询问,以每个区间右端点进行排序

然后遍历序列里的n个数

如果这个数在之前出现过,那么我将之前出现的删去,然后在这个位置加上该数,因为每个数只能贡献一次

然后用while循环询问的右端点,是否有右端点与我们遍历到的带你重合了,如果有,那么这个区间里的数我一定已经初始化好了,然后去求这个区间

能看到这里我就不啰嗦这个右端点合理性了,这是完全正确的

#include <iostream>
#include <cstring>
#include <algorithm>
// #include <bits/stdc++.h>
using namespace std;
#define inf 0x7f7f7f7f

typedef long long ll;

const int N = 3e4 + 10, M = 1e5 + 10, mod = 1331;

struct node {
    int l, r, k;
    bool operator < (const node &t) const {// 自定义以区间右端点排序
        return r < t.r;
    }
}q[M];

int a[N], b[N];
ll tr[N], res[M];//res存储第i个区间的答案
int vis[N];// 第i个数最后一次出现的位置
int n, m;

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

void update(int x, int c)  // 位置x加c
{
    for (int i = x;i <= n; i += lowbit(i)) tr[i] += c;
}

ll getsum(int x)  // 返回前x个数的和
{
    ll res = 0;
    for (int i = x;i > 0; i -= lowbit(i)) res += tr[i];
    return res;
}


signed main()
{
    ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
    // freopen("in.in", "r", stdin);freopen("out.out", "w", stdout);
    int T;
    cin >> T;
    while (T --) {
        memset(tr, 0, sizeof tr);
        memset(vis, 0, sizeof vis);
        
        cin >> n;
        for (int i = 1;i <= n;i ++) {
            cin >> a[i];
            b[i] = a[i];
        }
        sort(b + 1, b + n + 1);// 排序用于离散化
        for (int i = 1;i <= n;i ++) {// 二分函数离散化
            a[i] = lower_bound(b + 1, b + n + 1, a[i]) - b;
        }

        cin >> m;
        for (int i = 1;i <= m;i ++) {
            cin >> q[i].l >> q[i].r;
            q[i].k = i;
        }
        sort(q + 1, q + m + 1);

        int cnt = 1;

        for (int i = 1;i <= n;i ++) {
            if (vis[a[i]]) update(vis[a[i]], -b[a[i]]);// 如果你出现过,我先将你前面的贡献删去

            update(i, b[a[i]]);// 在当前位置上加上贡献
            vis[a[i]] = i;// 记录当前点,用于后面重复了以后进行删除
            
            // 如果右端点与我枚举的位置重合了,那么这个区间里的数我一定已经初始化好了,然后去求这个区间
            while (cnt <= m && q[cnt].r == i) {
                res[q[cnt].k] = getsum(q[cnt].r) - getsum(q[cnt].l - 1);
                cnt ++;
            }
        }
        // cout << m << "---" << endl;
        for (int i = 1;i <= m;i ++) {
            cout << res[i] << endl;
        }

    }
    return 0;
}

树状数组-从入门到拓展详解

上一篇:MySQL排序方案选择


下一篇:CorelDRAW X6和PhotoZoom在一起,会碰撞出什么样的火花?