全网最细【树状数组】

更多算法详解见:gitee

定义

树状数组是利用数的二进制特征进行检索的一种树状的结构。它用于维护前缀和的数据结构,支持单点修改、区间查询;区间修改、区间查询等一系列操作。
树状数组维护的元素要满足结合律可差分的性质。

树状数组

给定一个数组 { a 1 , a 2 … , a 8 } \{a_1,a_2\dots ,a_8 \} {a1,a2,a8},我们如何快速的求其前缀和呢?
一般我们求前缀和就是累加,时间复杂度是 O ( n ) O(n) O(n),一种容易想到的分治的优化策略是,对数组中的元素两两求和并且存到新的数组中,一直这样计算下去直到新数组中只有一个元素,如下:

这样就优化成线段树了,求前缀和、修改元素值的复杂度就都是 O ( log ⁡ n ) O(\log n) O(logn)了,但是对于求前缀和有很多元素是多余的,如:我们要求 a 1 a_1 a1 a 4 a_4 a4的前缀和,只需要用到 a 4 a_4 a4的父节点,并不需要 a 4 a_4 a4其本身。我们将类似的节点都删掉,就得到树状数组:

图中黑色节点都是被删除的节点,这样空间复杂度就优化为 O ( n ) O(n) O(n)了,我们可以直接将节点值存到一个数组中。但是问题来了,我们该怎么在数组中正确快速的求前缀和呢?这就要用到大名鼎鼎的 l o w b i t lowbit lowbit函数了

l o w b i t lowbit lowbit函数

我们将每个节点下标对应的二进制形式写出来,就能看出一些规律:

  • 每个节点覆盖的长度是其二进制表示下的最低位 1 1 1及其后面的 0 0 0构成的数值
  • 每个节点的父节点的下标就是在其二进制的最低位 1 1 1加上 1 1 1

那么实现树状数组的就归结到一个关键问题:如何快速找到一个数二进制表示下最低位 1 1 1及其后面的 0 0 0构成的数值。这也就是 l o w b i t lowbit lowbit函数的功能。
我们举个例子:求 l o w b i t ( 10 ) lowbit(10) lowbit(10)
我们先将 10 10 10的二进制位写出来 ( 1010 ) 2 (1010)_2 (1010)2,在对其按位取反得到 ( 0101 ) 2 (0101)_2 (0101)2,再加上 1 1 1,就得到 ( 0110 ) 2 (0110)_2 (0110)2,我们对比两个二进制数,发现除了最低位 1 1 1及其后面的 0 0 0,两个数字其他位上的数完全不同,我将两个数进行按位与(&)运算,就得到 l o w b i t lowbit lowbit值了。

我们再思考,按位取反再加 1 1 1,这不就是负数补码的运算过程吗,所以我们直接将该数取负再按位与即可得到 l o w b i t lowbit lowbit的值,下面是代码:

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

所以节点 x x x覆盖的长度就是其 l o w b i t ( x ) lowbit(x) lowbit(x)的值,其父节点下标就是 x + l o w b i t ( x ) x+lowbit(x) x+lowbit(x)

实现
单点修改,区间查询

【模板】树状数组 1
最简单的树状数组可以在 O ( log ⁡ n ) O(\log n) O(logn)的复杂度实现这两种操作

单点修改

我们修改某一位置的值,就将覆盖其的父节点的值都进行修改,将节点 x x x加上 d d d的实现如下:

inline void update(int x, int d) {
    for (; x <= n; x += lowbit(x))
        f[x] += d;
}
区间查询

我们要求区间 [ l , r ] [l,r] [l,r]的和,其实就是 s u m ( r ) − s u m ( l − 1 ) sum(r)-sum(l-1) sum(r)sum(l1) s u m ( x ) sum(x) sum(x)代表下标从 1 1 1 x x x的前缀和,查询某个点的前缀和就是树状数组擅长的,就是从这个节点开始,向坐上找到上一个节点,并加上其节点的值,可以发现向左上找上一个节点,只需要将下标减去当前下标的 l o w b i t lowbit lowbit值,下面是实现代码:

inline int ask(int x) {
    int sum = 0;
    for (; x >= 1; x -= lowbit(x))
        sum += f[x];
    return sum;
}
//求区间[l,r]的和
//ask(r)-ask(l-1);
区间修改,单点查询

【模板】树状数组 2

区间修改

我们只需一个简单而巧妙的操作,就能利用树状数组高效的实现区间修改,这个操作就是差分数组,我们用树状数组维护原数组的差分数组,这样我当我们要修改某个区间的值时,只需要修改两个端点即可。

void update(int x,int d){
    for(int i=x;i<=n;i+=lowbit(i))
        f[i]+=d;
}
void change(int x,int y,int k){
    update(x,k);
    update(y+1,-k);
}
单点查询

众所周知,差分的逆运算是求前缀和,树状数组计算的就是前缀和,用树状数组维护差分数组,那么树状数组计算得到就是单点的元素值,即 a s k ( x ) = a [ x ] ask(x)=a[x] ask(x)=a[x],所以单点查询和上面的查询实现相同:

int ask(int x){
    int sum=0;
    for(int i=x;i>=1;i-=lowbit(i))
        sum+=f[i];
    return sum;
}
区间修改,区间查询

【模板】线段树 1

区间查询

我们要利用树状数组求区间和,首先要求前缀和 s u m ( x ) sum(x) sum(x),这里我们定义差分数组 d d d,它和原数组的关系是 a [ k ] = d [ 1 ] + d [ 2 ] + ⋯ + d [ k ] a[k]=d[1]+d[2]+\cdots+d[k] a[k]=d[1]+d[2]++d[k] d [ k ] = a [ k ] − a [ k − 1 ] d[k]=a[k]-a[k-1] d[k]=a[k]a[k1],下面推导前缀和与差分数组的关系:
a 1 + a 2 + ⋯ + a k   = d 1 + ( d 1 + d 2 ) + ⋯ + ( d 1 + d 2 + ⋯ + d k )   = k d 1 + ( k − 1 ) d 2 + ⋯ + ( k − ( k − 1 ) ) d k   = k ( d 1 + d 2 + ⋯ + d l ) − ( d 2 + 2 d 3 + ⋯ + ( k − 1 ) d k )   = k ∑ i = 1 k d i − ∑ i = 1 k ( i − 1 ) d i \begin{align*} &a_1+a_2+\cdots + a_k\\ ~\\ =&d_1+(d_1+d_2)+\cdots +(d_1+d_2+\cdots +d_k)\\ ~\\ =&kd_1+(k-1)d_2+\cdots +(k-(k-1))d_k\\ ~\\ =&k(d_1+d_2+\cdots +d_l)-(d_2+2d_3+\cdots +(k-1)d_k)\\ ~\\ =&k\sum_{i=1}^{k} d_i-\sum_{i=1}^{k}(i-1)d_i \end{align*}  = = = =a1+a2++akd1+(d1+d2)++(d1+d2++dk)kd1+(k1)d2++(k(k1))dkk(d1+d2++dl)(d2+2d3++(k1)dk)ki=1kdii=1k(i1)di
公式最后一行是求两个前缀和,可以用两个树状数组分别来维护,这样就可计算出前缀和 s u m ( x ) sum(x) sum(x),区间和也就很好计算了,下面是实现:

int ask1(int x) { int sum = 0; for (; x > 0; x -= lowbit(x))sum += f1[x]; return sum; }
int ask2(int x) { int sum = 0; for (; x > 0; x -= lowbit(x))sum += f2[x]; return sum; }
int ask(int l, int r) {
    return r * ask1(r) - ask2(r) - (l - 1) * ask1(l - 1) + ask2(l - 1);
}
区间修改

区间修改时,两个数组要同时修改,实现方式和上面相同

void update1(int x, int d) { for (; x <= n; x += lowbit(x))f1[x] += d; }
void update2(int x, int d) { for (; x <= n; x += lowbit(x))f2[x] += d; }
void change(int l, int r, int d) {
    update1(l, d), update1(r + 1, -d);
    update2(l, (l - 1) * d), update2(r + 1, -r * d);
}

树状数组扩展

二维树状数组

二维树状数组,也被称作树状数组套树状数组,用来维护二维数组上的单点修改和前缀信息问题。其实就是一维树状数组上的每个节点变成了一个一维树状数组:

基本的二维树状数组可以实现单点修改,子矩阵查询

单点修改

修改一个矩阵的值后,我们要将其父节点都修改,例如我们要修改上图的 f [ 2 ] [ 2 ] f[2][2] f[2][2]元素,那么我们同时也要修改 f [ 2 ] [ 5 ] 、 f [ 5 ] [ 2 ] 、 f [ 5 ] [ 5 ] f[2][5]、f[5][2]、f[5][5] f[2][5]f[5][2]f[5][5],这些节点,实现如下:

void update(int x,int y,int d){
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
            f[i][j]+=d;
}

时间复杂度为 O ( log ⁡ n log ⁡ m ) O(\log n\log m) O(lognlogm)

子矩阵查询

现在我们可以求 ∑ i = 1 n ∑ j = 1 m a [ i ] [ j ] \sum_{i=1}^{n}\sum_{j=1}^{m}a[i][j] i=1nj=1ma[i][j]的值,要我们计算下图中黑色矩阵的值

很明显,计算黑色矩阵的面积有下面的公式:
∑ i = x 2 x 1 ∑ j = y 2 y 1 a [ i ] [ j ] = ∑ i = 1 x 1 ∑ j = 1 y 1 a [ i ] [ j ] − ∑ i = 1 x 2 − 1 ∑ j = 1 y 1 a [ i ] [ j ] − ∑ i = 1 x 1 ∑ j = 1 y 2 − 1 a [ i ] [ j ] + ∑ i = 1 x 2 ∑ j = 1 y 2 a [ i ] [ j ] \sum_{i=x_2}^{x_1}\sum_{j=y_2}^{y_1}a[i][j]=\sum_{i=1}^{x_1}\sum_{j=1}^{y_1}a[i][j]-\sum_{i=1}^{x_2-1}\sum_{j=1}^{y_1}a[i][j]-\sum_{i=1}^{x_1}\sum_{j=1}^{y_2-1}a[i][j]+\sum_{i=1}^{x_2}\sum_{j=1}^{y_2}a[i][j] i=x2x1j=y2y1a[i][j]=i=1x1j=1y1a[i][j]i=1x21j=1y1a[i][j]i=1x1j=1y21a[i][j]+i=1x2j=1y2a[i][j]
有了公式代码也就很容易写了:

int ask(int x,int y){
    int sum=0;
    for(int i=x;i>0;i-=lowbit(i))
        for(int j=y;j>0;j-=lowbit(j))
            sum+=f[i][j];
    return sum;
}
int ask(int x1,int y1,int x2,int y2){
    return ask(x1,y1)-ask(x1,y2-1)-ask(x2-1,y1)+ask(x2,y2);
}

时间复杂度为 O ( log ⁡ n log ⁡ m ) O(\log n\log m) O(lognlogm)

二维树状数组进阶

上帝造题的七分钟
该题要我们实现二维树状数组的子矩阵修改和子矩阵查询

子矩阵修改

和一维类似的,这里也要用到差分的思想,使用二维差分,我们定义一个二维差分数组 d [ i ] [ j ] d[i][j] d[i][j],它与原矩阵元素 a [ i ] [ j ] a[i][j] a[i][j]的关系如下:
d [ i ] [ j ] = a [ i ] [ j ] − a [ i ] [ j − 1 ] − a [ i − 1 ] [ j ] + a [ i − 1 ] [ j − 1 ]   a [ x ] [ y ] = ∑ i = 1 x ∑ j = 1 y d [ i ] [ j ] d[i][j]=a[i][j]-a[i][j-1]-a[i-1][j]+a[i-1][j-1]\\ ~\\ a[x][y]=\sum_{i=1}^{x}\sum_{j=1}^{y}d[i][j] d[i][j]=a[i][j]a[i][j1]a[i1][j]+a[i1][j1] a[x][y]=i=1xj=1yd[i][j]
现在,假设我们要在顶点为 ( a , b ) 、 ( c , d ) (a,b)、(c,d) (a,b)(c,d)的矩阵内的每个元素加上 k k k,如下图

因为我们维护的是差分数组,所以我分别修改四个顶点即可完成对区间的修改,实现如下:

void update(int x,int y,int d){
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
            f[i][j]+=d;
}
void update(int x1,int y1,int x2,int y2,int k){
    update(x1,y1,k),update(x1,y2+1,-k),update(x2+1,y1,-k),update(x2+1,y2+1,k);
}
子矩阵查询

我们已经直到求一个子矩阵的和有下面的式子:
∑ i = x 2 x 1 ∑ j = y 2 y 1 a [ i ] [ j ] = ∑ i = 1 x 1 ∑ j = 1 y 1 a [ i ] [ j ] − ∑ i = 1 x 2 − 1 ∑ j = 1 y 1 a [ i ] [ j ] − ∑ i = 1 x 1 ∑ j = 1 y 2 − 1 a [ i ] [ j ] + ∑ i = 1 x 2 ∑ j = 1 y 2 a [ i ] [ j ] \sum_{i=x_2}^{x_1}\sum_{j=y_2}^{y_1}a[i][j]=\sum_{i=1}^{x_1}\sum_{j=1}^{y_1}a[i][j]-\sum_{i=1}^{x_2-1}\sum_{j=1}^{y_1}a[i][j]-\sum_{i=1}^{x_1}\sum_{j=1}^{y_2-1}a[i][j]+\sum_{i=1}^{x_2}\sum_{j=1}^{y_2}a[i][j] i=x2x1j=y2y1a[i][j]=i=1x1j=1y1a[i][j]i=1x21j=1y1a[i][j]i=1x1j=1y21a[i][j]+i=1x2j=1y2a[i][j]
问题就转换为计算 ∑ i = 1 n ∑ j = 1 m a [ i ] [ j ] \sum_{i=1}^{n}\sum_{j=1}^{m}a[i][j] i=1nj=1ma[i][j],与推一维数组区间查询类似,我们利用原数组的与差分数组之间的关系进行变换,下面是推导过程:
∑ i = 1 n ∑ j = 1 m a [ i ] [ j ] = ∑ i = 1 n ∑ j = 1 m ∑ k = 1 i ∑ l = 1 j d [ k ] [ l ] = ∑ i = 1 n ∑ j = 1 m d [ i ] [ j ] × ( n − i + 1 ) × ( m − j + 1 ) = ( n + 1 ) ( m + 1 ) ∑ i = 1 n ∑ j = 1 m d [ i ] [ j ] − ( m + 1 ) ∑ i = 1 n ∑ j = 1 m d [ i ] [ j ] × i ( n + 1 ) ∑ i = 1 n ∑ j = 1 m d [ i ] [ j ] × j + ∑ i = 1 n ∑ j = 1 m d [ i ] [ j ] × i × j \begin{align*} &\sum_{i=1}^{n}\sum_{j=1}^{m}a[i][j]\\ = &\sum_{i=1}^{n}\sum_{j=1}^{m}\sum_{k=1}^{i}\sum_{l=1}^{j}d[k][l]\\ =&\sum_{i=1}^{n}\sum_{j=1}^{m}d[i][j]\times (n-i+1)\times (m-j+1)\\ =&(n+1)(m+1)\sum_{i=1}^{n}\sum_{j=1}^{m}d[i][j]-(m+1)\sum_{i=1}^{n}\sum_{j=1}^{m}d[i][j]\times i\\ &(n+1)\sum_{i=1}^{n}\sum_{j=1}^{m}d[i][j]\times j+\sum_{i=1}^{n}\sum_{j=1}^{m}d[i][j]\times i\times j \end{align*} ===i=1nj=1ma[i][j]i=1nj=1mk=1il=1jd[k][l]i=1nj=1md[i][j]×(ni+1)×(mj+1)(n+1)(m+1)i=1nj=1md[i][j](m+1)i=1nj=1md[i][j]×i(n+1)i=1nj=1md[i][j]×j+i=1nj=1md[i][j]×i×j
我们可以用四个二维树状数组来分别维护上面四个二维前缀和,下面是实现:

int ask(int x,int y){
    int sum=0;
    for(int i=x;i>0;i-=lowbit(i))
        for(int j=y;j>0;j-=lowbit(j))
            sum+=(x+1)*(y+1)*f[0][i][j]-(y+1)*f[1][i][j]-(x+1)*f[2][i][j]+f[3][i][j];
    return sum;
}

int ask(int x1,int y1,int x2,int y2){
    return ask(x2,y2)-ask(x1-1,y2)-ask(x2,y1-1)+ask(x1-1,y1-1);
}

下面是完整代码:

char op;
int f[4][MAX][MAX], n, m,a,b,c,d,delta;
inline int lowbit(int x){
    return x&-x;
}
//维护四个树状数组
void update(int x,int y,int d){
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
            f[0][i][j]+=d,f[1][i][j]+=d*x,f[2][i][j]+=y*d,f[3][i][j]+=x*y*d;
}
//维护子矩阵和
void update(int x1,int y1,int x2,int y2,int k){
    update(x1,y1,k),update(x1,y2+1,-k),update(x2+1,y1,-k),update(x2+1,y2+1,k);
}
//前缀和查询
int ask(int x,int y){
    int sum=0;
    for(int i=x;i>0;i-=lowbit(i))
        for(int j=y;j>0;j-=lowbit(j))
            sum+=(x+1)*(y+1)*f[0][i][j]-(y+1)*f[1][i][j]-(x+1)*f[2][i][j]+f[3][i][j];
    return sum;
}
//查询子矩阵和
int ask(int x1,int y1,int x2,int y2){
    return ask(x2,y2)-ask(x1-1,y2)-ask(x2,y1-1)+ask(x1-1,y1-1);
}

void solve() {
    cin>>op>>n>>m;
    while(cin>>op){
        cin>>a>>b>>c>>d;
        if(op=='L'){
            cin>>delta;
            update(a,b,c,d,delta);
        }
        else cout<<ask(a,b,c,d)<<'\n';
    }
}
偏序问题

关于偏序问题,以一维、二维、三维偏序问题为例,介绍如下:

  • 一维偏序(逆序对)。给定数列 a a a,求满足 i < j i<j i<j a i > a j a_i>a_j ai>aj,的二元组 ( i , j ) (i,j) (i,j)的个数
  • 二维偏序。给定 n n n个点的坐标,求满足 x i < x j 、 y i < y j x_i<x_j、y_i<y_j xi<xjyi<yj的二元组 ( i , j ) (i,j) (i,j)的个数
  • 三维偏序。给定 n n n个点的坐标,求满足 x i < x j 、 y i < y j 、 z i < z j x_i<x_j、y_i<y_j、z_i<z_j xi<xjyi<yjzi<zj的二元组 ( i , j ) (i,j) (i,j)的个数

逆序对
利用离散化+树状数组求逆序对是一种简单且效率极高的方法。具体思路读者可以思考一下,这里直接给出代码:

ll f[MAX], n, a[MAX], newa[MAX];
inline ll lowbit(ll x) {
    return x & -x;
}

void update(int x, ll d) {
    for (int i = x; i <= n; i += lowbit(i))
        f[i] += d;
}

ll ask(int x) {
    ll sum = 0;
    for (int i = x; i >= 1; i -= lowbit(i))
        sum += f[i];
    return sum;
}

void solve() {
    cin >> n;
    memset(f, 0, sizeof f);
    ll ans = 0;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        newa[i] = a[i];
    }
    sort(newa, newa + n + 1);
    //数据离散化
    for (int i = 1; i <= n; ++i)
        a[i] = lower_bound(newa + 1, newa + 1 + n, a[i]) - newa ;
    for (int i = 1; i <= n; ++i) {
        update(a[i], 1);
        ans += (i - ask(a[i]));
    }
    cout << ans << '\n';
}

求逆序对还有一种解法是用归并排序,有兴趣的可以看洛谷的题解。
处理多维偏序问题的一般解法是 C D Q CDQ CDQ分治,这里就不展开说明了(因为我不会QAQ)。

区间最值

求区间最值一般是用线段树或 S T ST ST表。这里给出树状数组的解法,加深理解。
例题:I Hate It
以前树状数组节点值是 [ x − l o w b i t ( x ) + 1 , x ] [x-lowbit(x)+1,x] [xlowbit(x)+1,x]区间的元素和,这里树状数组节点值就改为存放 [ x − l o w b i t ( x ) + 1 , x ] [x-lowbit(x)+1,x] [xlowbit(x)+1,x]区间的最大值。
在修改区间最值时,要更新树状数组上所有被其影响的节点,即所有与其直接相连的节点,其父节点和子节点。

在查询时分两种情况,如果当前节点覆盖的范围,超过了我们的查询范围,就用原数组对应下标的值去更新答案,并且向前递推;如果没有超过我们的查询范围,就直接用当前节点的值去更新答案。

下面是具体代码:

int f[MAX],a[MAX], n, m;
inline int lowbit(int x) {return x & -x;}

void update(int x, int d) { 
    while (x <= n) {
        f[x] = d;
        //通过子节点更新自身,看看有没有比自己大的子节点
        for (int i = 1; i < lowbit(x); i <<= 1)
            f[x] = max(f[x], f[x - i]);
        //到下一个父节点
        x += lowbit(x);
    }
}
int ask(int l,int r) {
    int res = 0;
    while (l <= r) {
        if (lowbit(r) > r - l + 1) {
            res = max(res, a[r]);
            --r;
        }
        else {
            res = max(res, f[r]);
            r -= lowbit(r);
        }
    }
    return res;
}


void solve() {
    while (cin >> n >> m) {
        for (int i = 1; i <= n; ++i) {
            cin >> a[i];
            update(i, a[i]);
        }
        for (int i = 0; i < m; ++i) {
            char op;
            int x, y;
            cin >> op >> x >> y;
            if (op == 'Q')cout << ask(x, y) << '\n';
            else a[x] = y, update(x, y);
        }
    }
}
离线操作

离线处理,即先读取所有查询,然后统一处理,计算结束后一起输出。
下面是离线操作的典型例题,留给读者思考了:
No Pain No Game

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IdlePerson.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值