【笔记】树状数组原理

254 阅读8分钟

这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战.


前言

介绍树状数组的原理以及一些应用。 代码请参考最后的简化版代码。

预备知识

前缀和数组,差分数组
树状数组基础

问题:对长为1e5的数组,执行1e5次操作,包括单点增加与区间求和。 lowbit:x&-x,截取数字x的最后一个二进制1及之后的部分。 add(p,v):对p以及递归p+lowbit(p)处的值加v,O(logn) sum(1,p):取p以及递归p-lowbit(p)处的值之和,O(logn) sum(l,r):sum(1,r)-sum(1,l-1),同前缀和。

线段树基础
群论

半群:若非空集合SS上有二元运算oo满足封闭性、结合律,则称<S,o><S,o>为半群。 群:若半群<S,o><S,o>上的运算oo满足有幺元且每个元素都有逆元,则称<S,o><S,o>为群。

树状数组原理

这里写图片描述 树状数组一般用于维护一个群上的运算,以较小的时空代价执行单点修改区间求和操作。

树状数组又被称作二进制索引树(Binary Index Tree,BIT),核心思想是将原序列的信息构建为一棵树,然后通过修改和查询这棵树来完成目的。树的节点数目与原序列长度恰好相同,且节点之间存在很强的逻辑关系,所以仍以数组的方式存储。

树上每一个节点都对应一个原位置,设原数组为a,树状数组为c,下标均为从1到n。 每一个树节点的值,都等于它所有子节点的值+原数组中对应位置的值。反过来看的话,每一个树节点的值,都会传递给它的父节点即ci+lowbit(i)+=cic_{i+lowbit(i)} += c_i,使用这种方法即可O(n)地从原数组构建树状数组。

观察树状数组的存储方式,发现ci=Σj=ilowbit(i)+1icjc_i = \Sigma_{j=i-lowbit(i)+1}^ic_j,当需要求aapp处的前缀和时,只需要求cp+cplowbit(p)+cplowbit(p)lowbit(plowbit(p))+...c_p+c_{p-lowbit(p)}+c_{p-lowbit(p)-lowbit(p-lowbit(p))}+...,即递归plowbit(p)p-lowbit(p)直到p为0.

当需要修改apa_p时,在树上需要修改cp,cp+lowbit(p),cp+lowbit(p)+lowbit(p+lowbit(p)),...c_p,c_{p+lowbit(p)},c_{p+lowbit(p)+lowbit(p+lowbit(p))},...直到p大于n。 单点修改和区间求和操作都可以在O(logn)内完成。

再次注意:树状数组优化求取前缀和的方法,再根据前缀和之差来获得任意区间和。

树状数组与群

对长为1e5的数组,执行1e5次操作,包括单点乘一个数与区间求积。

同上,只不过将加换为了乘,最后将前缀和之积取商。

对长为1e5的数组,执行1e5次操作,包括单点乘一个数与区间求积,输出结果模1e9+7。

同上,所有乘的部分取模,最后取逆元相乘。

对长为1e5的数组,执行1e5次操作,包括两种。 1 p x 表示将p位置的数与x求最大值再放到p位置处。 2 r 表示求区间[1,r]内的最大值

修改时:对p及递归p+lowbit(p)进行max的判断。 查询时:输出r及递归r-lowbit(r)的max值。

此时维护的二元运算是max,它仍有半群的性质(封闭,结合律),但没有群的性质(逆元),所以无法求两个区间之差得到任意区间[l,r]内的最大值。由此例可见,树状数组上“单点修改”需要满足半群性质,“求前缀和”需要满足半群性质,“区间求和”需要满足群性质。

注意:在这里,单点修改操作区间求和操作中的运算是同一种运算。当两者不是同一种运算时,树状数组维护的运算以区间求和操作中的运算为准

对长为1e5的数组,执行1e5次操作,包括两种。 1 p x 表示将p位置的数加上x,x可以为负 2 r 表示求区间[1,r]内的最大值

查询时:输出r及递归r-lowbit(r)的max值 修改时:若x为正,将p更新,对递归p+lowbit(p)进行max的判断。若x为负,更新p,递归p+lowbit(p)全部进行重新求值。 单个值重新求值的复杂度是O(logn)O(logn),如对c8c_8进行重新求值需要c4,c6,c7c_4,c_6,c_7,所以此次操作的复杂度是O(log2n)O(log^2n)

注意:当单点修改操作中的运算与区间求和操作中的运算不匹配时,需要将单点修改中的运算转化为区间求和操作中的运算,如果转换失败,则O(logn)失效,需要用O(log2n)O(log^2n)来进行重新求值。

对长为1e5的数组,执行1e5次操作,包括两种 1 p x 表示将p位置的数【经过一系列奇奇怪怪的变换之后】变成x 2 l r 表示求区间[l,r]的和,模1e9+7

修改:对p及递归p+lowbit(p)加上xapx-a_p再取模。 求和:求前缀和,相减,取模。

可以证明,这【一系列奇奇怪怪的变换】总能转换为所需要的运算(模意义加),因为变换后的值可以算得,所以这个运算就转化为差量的加法。 模意义加是群上的运算,其他群上的运算也具有这个特点。

综上所述,树状数组最适合于维护群上的运算。当用树状数组维护半群上的运算时,修改操作可能退化到O(log2n)O(log^2n),且只能求前缀和而无法求任意区间和。 9.10upd:树状数组实际上可以O(log2n)O(log^2n)求任意区间最大值,但不推荐这种做法。

树状数组与线段树

两者的联系:线段树把所有右子节点都删掉后,就是一个树状数组,这也是lyf曾讲到的“左线段树”理论。 线段树 树状数组 两者的差别:

  1. 线段树是二叉树,而树状数组不是。
  2. 由1. 导致了 线段树节点数比树状数组多.
  3. 由1. 导致了 无逆元运算的修改操作需要对节点重新求值,树状数组是多叉树,一个节点需要O(logn),线段树是二叉树,只需要O(1)
  4. 由2. 导致了 树状数组只能求前缀和,再用大区间减去小区间来获得任意区间和。而线段树通过求若干个小区间的和来求任意区间和,不会受到逆元的限制。
  5. 由2. 导致了 线段树递推步数更多,时空常数更大。
  6. 由3. 4. 导致了线段树对半群上的运算仍有很好的支持。

树状数组应用

代码

class BinIdTree
{
    int n;
    vector<ll> save;
public:
    explicit BinIdTree(int sz = 0) : n(sz) //建立一个空BIT
    {
        save.assign(n + 1, 0);
    }
    explicit BinIdTree(const vector<ll> &src) : n(src.size() - 1) //由已知数组O(n)建立
    {
        save.assign(src.begin(), src.end());
        for(int i = 1; i <= n; i++) if(i + (i & -i) <= n)
            save[i + (i & -i)] += save[i];
    }
    inline void add(int p, ll x) //单点修改
    {
        for(; p <= n; p += p & -p) save[p] += x;
    }
    inline ll sum(int l, int r) //区间求和
    {
        return sum(r) - sum(l - 1);
    }
    inline ll sum(int p)
    {
        ll res = 0;
        for(; p; p -= p & -p) res += save[p];
        return res;
    }
};

有两个数据成员n表示长度,save表示数组本身。 五个函数,第一个构造函数表示建立一个空bit,第二个接收一个数组参数,然后O(n)建立。 剩下的三个函数分别为单点修改,求前缀和,区间求和。

区间修改,单点求和

对原数组求差分数组,再用树状数组维护差分数组

区间修改,区间求和

对长为1e5的数组,执行1e5次操作,包括两种 1 l r x表示对区间[l,r]内所有值加上x 2 l r表示求区间[l,r]内值的和

记原数组为a,原数组的差分数组为b,则b[i]=a[i]a[i1]b[i] = a[i] - a[i-1] 记区间求和的结果为ans,首先考虑求[1,r]的和

ans=Σi=1rΣj=1ib[i]=Σi=1r(ri+1)b[i]=rΣi=1rb[i]Σi=1r(i1)b[i]\begin{aligned} ans&=\Sigma_{i=1}^r\Sigma_{j=1}^ib[i]\\ &=\Sigma_{i=1}^r(r-i+1)*b[i]\\ &=r*\Sigma_{i=1}^rb[i]-\Sigma_{i=1}^r(i-1)*b[i] \\ \end{aligned}

观察上方的公式,出现了两个前缀和函数的形式,且这两个函数都只和ii有关。

c[i]=(i1)b[i]c[i] = (i-1)*b[i],通过维护bb数组和cc数组求解这道题: 操作1:b[l]+=xb[r+1]=xc[l]+=x(l1)c[r+1]=xrb[l]+=x,b[r+1]-=x,c[l]+=x*(l-1),c[r+1]-=x*r 操作2(1到r):sum(r)=rΣi=1rb[i]+Σi=1rc[i]sum(r) = r*\Sigma_{i=1}^rb[i] + \Sigma_{i=1}^rc[i] 操作2(l到r):sum(r)sum(l1)sum(r)-sum(l-1)

现在每次操作1就转变成了多个单点修改,操作2就转变成了多个区间求和,使用两个树状数组分别维护bbcc即可。

代码

class ExBinIdTree
{
    int n;
    BinIdTree bt_b, bt_c;
public:
    explicit ExBinIdTree(const vector<ll> &src) : n(src.size() - 1) //由已知数组建立
    {
        vector<ll> b(n + 1), c(n + 1);
        for(int i = 1; i <= n; i++)
        {
            b[i] = src[i] - src[i - 1];
            c[i] = b[i] * (i - 1);
        }
        bt_b = BinIdTree(b), bt_c = BinIdTree(c);
    }
    inline void add(int l, int r, int x) //区间修改
    {
        bt_b.add(l, x);
        bt_b.add(r + 1, -x);
        bt_c.add(l, 1LL * (l - 1)*x);
        bt_c.add(r + 1, -1LL * r * x);
    }
    inline ll sum(int l, int r) //区间求和
    {
        return sum(r) - sum(l - 1);
    }
    inline ll sum(int p)
    {
        return p * bt_b.sum(p) - bt_c.sum(p);
    }
};

9月29日补:这种多树状数组组合起来求解问题的方式,非常玄学。参见BZOJ 4034

树状数组简化版代码 不需要O(n)构造,因为这个在多数情况下更慢。

int bit[M];
inline void modify(int p, int x)
{
	for(;p<=n;p+=p&-p) bit[p]+=x;
}
inline int sum(int p)
{
	int res = 0;
	for(;p;p-=p&-p) res += bit[p];
	return res;
}
inline int sum(int l, int r)
{
	return sum(r) - sum(l-1);
}

区间修改版树状数组简化版代码

ll bit1[M],bit2[M];
inline void modify(int l, int r, int x)
{
	ll x1 = 1ll*(l-1)*x, x2 = 1ll*r*x;
	for(; l<=n; l+=l&-l)
		bit1[l]+=x, bit2[l]+=x1;
	for(++r; r<=n; r+=r&-r)
		bit1[r]-=x, bit2[r]-=x2;
}
inline ll sum(int p)
{
	ll res = 0, xp = p;
	for(; p; p-=p&-p)
		res += xp*bit1[p] - bit2[p];
	return res;
}
inline ll sum(int l, int r)
{
	return sum(r) - sum(l-1);
}