前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >树状数组-从入门到拓展(转载非原创)

树状数组-从入门到拓展(转载非原创)

作者头像
xlj
修改2021-09-22 11:06:50
3820
修改2021-09-22 11:06:50
举报

转载来源:https://www.cnblogs.com/AKing-/p/15311440.html

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

树状数组是一个可以在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函数

cpp

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]

cpp

// 单点修改
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];

cpp

// 区间查询
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代码如下

cpp

#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代码如下

cpp

#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;
}

区间最值说明

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

cpp

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,那么求的就是有几个数比我小,然后剩下的,就是比我大的了,就可以做为贡献加入结果中

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

cpp

#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;
}

递归版本代码

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

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

cpp

#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函数

cpp

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代码如下

cpp

#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;
}

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

cpp

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

以此类推

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

cpp

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

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

cpp

#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;
}

本文系转载,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文系转载前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 单点修改、区间查询
  • 区间修改、区间查询
  • 区间最值说明
  • 拓展一:逆序数问题
  • 拓展二:上升子序列问题
  • 拓展三:第k小数
  • 拓展四:离散化
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档