Loading

【笔记】树状数组

动态维护前缀的数据结构。以下均以前缀和为例。

构造树状数组

对于长度为 \(n\) 的原始数组 \(A\),我们把元素两两合并,在此之上再把区间两两合并,直到合并到整个数组,把这些区间的和存下来构造辅助数组,即可以把每个区间拆成不超过 \(\log n\) 个区间,以达到快速修改单点和查询区间的目的。

然后我们注意到每一层的第 \(2k\) 个都是可以通过上一层 \(-\) 下一层差分出来的,也就是不必维护,因此就可以把他们删掉。删掉后还剩 \(n\) 个数,将其按区间右端点从左到右的顺序排成一排。

这就是树状数组。具有树结构关系的数组,小区间为大区间的子节点。

其性质为:

  1. 对于节点 \(x\),其父结点编号为 \(x+\operatorname{lowbit}(x)\)
  2. 对于前缀 \([1,x]\),其前缀和等于 \(tr[x]+tr[x-\operatorname{lowbit}(x)]+tr[(x-\operatorname{lowbit}(x))-\operatorname{lowbit}(x-\operatorname{lowbit}(x))]+\cdots\) 直到下标 \(=0\)(不含)。

其中 \(\operatorname{lowbit}(x)\) 定义为 \(x\) 最低位的 \(1\) 及其后面的 \(0\) 构成的二进制数。如 \(\operatorname{lowbit}((10110110)_2)=(10)_2\)

在 C++ 中,负数以补码存储,即原码取反 \(+1\),原码中低位的 \(0\) 取反后全为 \(1\),再 \(+1\) 又全变为了 \(0\);最低位 \(1\) 取反后为 \(0\)\(+1\) 因为进位又变为了 \(1\);高位全部取反不受 \(+1\) 影响,而 \(\operatorname{lowbit}\) 与原码相同。因此 \(\operatorname{lowbit}(x)\) 在 C++ 中等于 x&(-x)

利用这两条性质我们可以很简单地实现单点修改和查询前缀的代码。

单点修改:

void update(int x,int k){
    while(x<=n){
        tr[x]+=k;
        x+=lowbit(x);
    }
    return ;
}

(单点修改为特定值:同步修改原序列 \(a_i\),然后作差。)

update(x,k-a[x]);
a[x]=k; 

查询前缀:

int query(int x){
    int res=0;
    while(x){
        res+=tr[x];
        x-=lowbit(x);
    }
    return res;
}

查询一般区间 \([l,r]\) 需使用差分,query(r)-query(l-1)。因此普通树状数组仅能维护可差分信息的区间。

维护不可差分信息的树状数组,理论时间复杂度比线段树劣,故此处不涉及。

区间修、单点查树状数组

维护原始数据的树状数组是做不了区间修改的。如果只查询单点,则考虑更换维护对象。

注意到差分数组的前缀和是每个点的数据。在差分数组中仅需修改两个点即可做到原数组的区间加,而树状数组又正好维护前缀和。因此用树状数组维护差分数组即可。

for(int i=1;i<=n;i++) update(i,a[i]-a[i-1]);
for(int i=1;i<=m;i++){
    int op,x;
    cin>>op>>x;
    if(op==1){
        int y,k;
        cin>>y>>k;
        update(x,k),update(y+1,-k);
    }else cout<<query(x)<<'\n';
}

区间修改、区间查询树状数组较为科技且理论时间复杂度比如线段树劣,此处不涉及。

权值树状数组

很多时候我们要用树状数组维护值域解决问题。

Luogu P1908 逆序对

逆序对问题即对于每个元素求出他前面有多少个比他大的元素,并求和。想到从前往后加元素,找到元素加入后在序列中的位置。

考虑如何动态维护元素在当前序列中的相对位置。我们可以在值域上加权值,表示此值上有几个元素,所有比其小的元素数量即为值域上的标记前缀和,就可以拿树状数组维护这个东西了。

对于值域过大的情况,将其离散化即可。

for(int i=1;i<=n;i++){
    cin>>a[i];
    b[i]=a[i];
} 
sort(b+1,b+1+n);
int l=unique(b+1,b+1+n)-b-1;
for(int i=1;i<=n;i++){
    int x=lower_bound(b+1,b+1+l,a[i])-b;
    update(x,1);
    ans+=i-query(x);
}

树状数组二分

很多时候我们想知道前缀和正好等于 \(x\) 的点在哪里(保证序列非负),我们知道前缀和是单增的,可以在树状数组查询外面套一个二分查找。但这样做是 \(O(n\log^2n)\) 的。

我们也可以以单 \(\log\) 的复杂度在树状数组上二分。具体思想是从根节点往下搜,比较 子区间右端点处的前缀和 与目标,决定向哪一子节点走。

性质:对于树状数组上的节点 \(x\),其从左到右的子节点编号为 \(x\) 分别减 \(2^k\)\(k\)\(x\) 的二进制位数 \(-1\) 递减到 \(0\)

我们就可以从根节点开始,判断其子节点决定接下来往哪里走。最左侧子节点的值是左半的前缀和,右侧有若干个子节点。对于一个子节点,其 左边所有子节点的值之和 + 自身的值 就是此子节点右端点处的前缀和。若该前缀和 \(\ge x\),就往该子节点走,否则就去判根的下一个子节点。直到走到叶子。

需要注意的是,树状数组二分要从一个总的根节点开始,这个根一定要能访问到所有节点,而普通树状数组若元素个数不是 \(2^k\) 则是不连通的,我们要建到一个 \(>n\)\(2\) 的次幂才能连通整个树,这个次幂一定是 \(<2n\) 的,在单点修改时判到 \(2n\) 即可。

num=log(n)/log(2)+1;
root=(1<<num);
void update(int x,int k){
    while(x<=root){//或 x<=2*n
        tr[x]+=k;
        x+=lowbit(x);
    }
    return ;
}
int find(int x){
    int u=root,val=0;
    for(int i=num-1;i>=0;i--){
        int v=u-(1<<i);
        if(val+tr[v]<x) val+=tr[v];
        else u=v; 
    }
    return u;
}

Luogu P1168 中位数

从前往后加元素,权值树状数组维护,找前缀和等于当前元素个数一半的位置。需要离散化。

for(int i=1;i<=n;i++){
    update(a[i],1);
    if(i==1) cout<<b[a[i]]<<'\n';
    else if(i&1){
        int x=find((i+1)/2);
        cout<<b[x]<<'\n';
    }
}

树状数组应用 - 离线二维数点

二维数点(二维偏序):在二维平面上有若干个坐标点,求某个矩形范围内点的数量。

实际上,上面提到的逆序对就是一种形式的二维数点。

Luogu P10814 【模板】离线二维数点

若每次都询问整个区间,则可使用权值树状数组直接求解。

对于区间 \([1,r]\),可以发现答案为从前往后加点到 \(r\) 时值域树状数组的结果,即整个树状数组的历史版本。而区间 \([l,r]\) 就可以差分,用 \(r\) 时的结果减 \(l-1\) 时的结果。

允许离线,那么我们只需要对所有查询端点排序,从左到右边加点边求解,左端点要减去所以标记是 \(-1\),右端点要加上所以标记是 \(1\)。类似扫描线的思想。

实际上这个问题也可以抽象成二维数点。我们令 \(x\) 轴为下标,\(y\) 轴为元素值,建立平面直角坐标系。原数组的元素 \(a[i]\) 就对应坐标点 \((i,a[i])\)

查询 \(l,r,x\) 相当于询问左下角是 \((l,0)\) 右上角是 \((r,x)\) 的矩形中有多少个点,也就可以用接到 \(y\) 轴的大矩形相减得到。

实现方法同上,权值树状数组维护即可。时间复杂度 \(O((n+m)\log m)\)

若强制在线,则需要用到主席树等更高级的数据结构(维护历史版本)。

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+10;
int n,m;
int a[N],maxa;
struct Node{
	int id,val,vis;
};
vector<Node> q[N];
int tr[N],ans[N];
int lowbit(int x){
	return x&(-x);
}
void update(int x){
	while(x<=maxa){
		tr[x]++;
		x+=lowbit(x);
	}
}
int query(int x){
	int res=0;
	while(x){
		res+=tr[x];
		x-=lowbit(x);
	}
	return res;
}
int main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        cin>>a[i];
        maxa=max(maxa,a[i]);
    }
    for(int i=1;i<=m;i++){
        int l,r,x;
        cin>>l>>r>>x;
        maxa=max(maxa,x);
        q[l-1].push_back((Node){i,x,-1});
        q[r].push_back((Node){i,x,1});
    }
    for(int i=1;i<=n;i++){
        update(a[i]);
        for(int j=0;j<q[i].size();j++){
            int t=query(q[i][j].val);
            ans[q[i][j].id]+=t*q[i][j].vis;
        }
    }
    for(int i=1;i<=m;i++) cout<<ans[i]<<'\n';
    return 0;
}

对于更标准的离线二维数点(查询任意矩形),只需要在查询 \(l-1\)\(r\) 时分别减去他们下端点到 \(x\) 轴之间的那个矩形。也可以使用二维前缀和的思想,两者是等价的。

for(int i=1;i<=lenx;i++){
    while(p<=n&&e[p].a==i){
        update(e[p].b);
        p++;
    }
    for(int j=0;j<q[i].size();j++){
        int t=query(q[i][j].yb)-query(q[i][j].ya-1);
        ans[q[i][j].id]+=t*q[i][j].vis;
    }
}

Luogu P1972 [SDOI2009] HH 的项链

容易发现原问题等价于不重复加 \(y\) 坐标相同的点的二维数点,但这就意味着一个点需要在多个包含相同元素的查询区间里出现,此时有两种处理方法。

方法 1:只保留当前最后一个

显然,我们只需保留还可能出现在查询序列的那一个点。于是我们把查询区间按右端点从小到大求解,保证当前最右侧的点都 \(\le\) 当前查询区间的右端点,更靠左的点就自然不必保留了。遇到重复元素就把之前的点删掉再加上新点即可。

struct node{
    int id,l;
};
for(int i=1;i<=m;i++){
    cin>>l>>r;
    Q[r].push_back((node){i,l});
}
for(int i=1;i<=n;i++){
    if(vis[a[i]]==0){
        update(i,1);
        vis[a[i]]=i;
    }
    else{	
        update(vis[a[i]],-1); 
        update(i,1);
        vis[a[i]]=i; 
    }
    for(int j=0;j<Q[i].size();j++){
        node u=Q[i][j];
        Ans[u.id]=query(i)-query(u.l-1);
    }	 
}

方法 2:维护上一次出现位置

这也是更标准的区间数颜色的 trick。

定义 \(pre_i\):与 \(a_i\) 相同的元素上一次出现时的下标。

那么对于区间 \([l,r]\),所有不是在 \([l,r]\) 中第一个出现的元素的 \(pre_i\) 必然会指向 \(\ge l\) 的结果。反过来 \(pre_i<l\) 的元素都是 \([l,r]\) 中第一个出现的该元素,这些元素的数量就是问题的答案。即统计:

  • \(i\in[l,r]\)
  • \(pre_i\in[0,l)\)

的数量。

以横坐标为 \(x\)\(pre_i\)\(y\) 建立平面直角坐标系,每个区间端点询问 \(y<l\) 点的数量,直接跑离线二维数点即可。

然后注意在整个序列中第一次出现的元素的 \(pre\) 全部指向 \(0\),而树状数组只能从 \(1\) 开始,要在存储和查询时全部 \(+1\)

for(int i=1;i<=n;i++){
    cin>>a[i].val;
    if(!mp[a[i].val]) mp[a[i].val]=i,a[i].pre=1;
    else a[i].pre=mp[a[i].val]+1,mp[a[i].val]=i; 
}
for(int i=1;i<=n;i++){
    update(a[i].pre);
    for(int j=0;j<q[i].size();j++){
        int res=query(q[i][j].l);
        ans[q[i][j].id]+=res*q[i][j].vis;
    }
}

树状数组应用 - 整体二分

待补充。

练习题

普通树状数组

Luogu P4868 Preprefix sum

待补充。

Luogu P5094 [USACO04OPEN] MooFest G 加强版

待补充。

权值树状数组

Luogu P2184 贪婪大陆

问题转化为询问区间 \([l,r]\) 此前被多少个区间覆盖过,考虑维护区间端点,所有左端点 \(\le r\) 且右端点 \(\ge l\) 的区间可以计算在内,用两个权值树状数组维护即可。

if(op==1){
    ss1.update(l);//维护左端点
    ss2.update(r);//维护右端点
}else cout<<ss1.query(r)-ss2.query(l-1)<<'\n';//左端点小于等于r的区间数量减去右端点小于l的区间数量
posted @ 2026-03-27 23:57  Seqfrel  阅读(11)  评论(0)    收藏  举报