树状数组(下)

树状数组(下)


树状数组(上)中我提到了树状数组的基本操作与变式,现在来看看它的实际应用和一些题目。

应用

逆序对

\(a\)为一个有\(n\)个数字的有序集(\(n>1\)),其中所有数字各不相同。
如果存在正整数\(i\)\(j\)使得\(1\leqslant i<j\leqslant n\)\(a[i]>a[j]\)
则有序对\((a[i],a[j])\)称为\(a\)的一个逆序对。

用树状数组的方法可以\(O(n\log_2 n)\)求一段正整数序列中逆序对的数目。
思路很简单,先离散化(否则\(\text{MLE}\)),然后按照序列顺序从左到右地用树状数组记录每个\(a_i\)出现了几次,遍历到\(a_i\)时就\(\operatorname{add}(a_i)\),同时\(ans\!\gets\!ans\!+\!\operatorname{ask}(a_i)\)。由于下标小于\(i\)且能与\(a_i\)构成逆序对的数一定会在\(a_i\)之前加入,因此\(\operatorname{ask}(a_i)\)返回值是\(a_i\)与其之前的数构成的逆序对数量。
当然,需要考虑重复元素的情况,处理这个问题只要在离散化排序的时候以下表作为第二关键字排序就可以了(或者用\(\text{algorithm}\)库的\(\operatorname{stable\_sort}()\))。
LG1908 逆序对
模板题:

#include<iostream>
#include<algorithm>
#include<cmath>
#include<cstdio>
#include<cstdlib>
#include<cstring>
using namespace std;
int tree[500005];
struct Tmp{
	int d,r;
}a[500005];
int n;
long long ans;
inline bool cmp(Tmp a,Tmp b)
{
	if(a.d==b.d)
		return a.r>b.r;
	return a.d>b.d;
}
inline void add(int x,int k)
{
	for(;x<=n;x+=x&-x)
		tree[x]+=k;
}
inline int ask(int x)
{
	int res=0;
	for(;x;x-=x&-x)
		res+=tree[x];
	return res;
}
int main()
{
	ios::sync_with_stdio(false);
	cin>>n;
	for(int i=1;i<=n;i++)	a[i].r=i,cin>>a[i].d;
	sort(a+1,a+n+1,cmp);
	for(int i=1;i<=n;i++)
		ans+=ask(a[i].r),add(a[i].r,1);
	printf("%lld\n",ans);
	return 0;
}

康托展开

康托展开是一个全排列到一个自然数的双射,常用于建立哈希表时的空间压缩。

具体来说,设\(X\)表示一个排列前有几个字典序小于它的排列(元素相同),则有下式:

\[X=\sum_{i=1}^{n}a_i\times{(n-i)!} \]

其中的\(a_i\)代表\(\sum_{j=i}^n(a_j<a_i)\)(即与它后面的数构成的逆序对数),以排列\(A\{2,3,1,4,5\}\)为例,\(a_1=1\)\(a_2=1\)\(a_3\)\(a_4\)\(a_5\)都为\(0\)
(当然,对于一个长度为\(n\)的排列,\(a_n\)始终为\(0\)
解释一下这个式子:
首先考虑第一位,\(A_1=2\)\(a_1=1\),因此前一位与\(A_1\)相同,且序号更前的排列有\(1\times{(5-1)!}=24\)种。
接着考虑第二位,\(A_1=3\)\(a_2=1\),因此前两位与\(A_2\)相同,且序号更前的排列有\(1\times{(5-2)!}=6\)种。
依此类推,直到第五位为止。
所以排列\(A\{2,3,1,4,5\}\)前有\(30\)个排列,它在全排列中的序号为\(31\)
现在需要解决如何计算\(a_i\)的问题。
可以用一个树状数组维护\(a_i\),使得\(\operatorname{ask}(i)\)表示“\(A_i\)在区间\([1,i]\)中出现了几次”。
先从\(1\)\(n\)\(\operatorname{add}(i,1)\),每次读入\(A_i\)\(\operatorname{add}(A_i,-1)\),再\(\operatorname{ask}(a_i)\),加到答案中。
LG5367【模板】康托展开
模板题:

#include<iostream>
#include<cstdio>
#define MOD 998244353
using namespace std;
int tr[1000005],a[1000001],n;
long long fac[1000005]={1},ans;//fac是预处理出的阶乘,fac[0]=1
inline void add(int x,int k){for(;x<=n;x+=x&-x)	tr[x]+=k;}
inline int ask(int x)
{	int res=0;
	for(;x;x-=x&-x)
		res=(res+tr[x])%MOD;
	return res;
}
int main()
{
	ios::sync_with_stdio(false);
	cin>>n;
	for(int i=1;i<=n;i++)
		fac[i]=fac[i-1]*i%MOD,add(i,1);
	for(int i=1;i<=n;i++)
		cin>>a[i],add(a[i],-1),ans=(ans+(ask(a[i]))*fac[n-i])%MOD;
	printf("%lld",ans+1);
	return 0;
}
//没开long long见祖宗

另外,康托展开主要用于排列的哈希。

逆康托展开

康托展开是一个双射,因此在已知元素及其先后顺序的情况下可以通过一个排列的康托展开值还原出这个排列,也就是“逆康托展开”。

看个例子:
\((1,2,3,4,5)\)\(ans=31\)

  • 首先\(ans-1\)得到康托展开值\(30\)
  • \(30/4!=1\),因此\(a_1=1\),第一个元素为\(2\)
  • \(30\bmod(4!)=6\)\(6/3!=1\),因此\(a_2=1\),第二个元素为\(3\)
  • \(6\bmod(3!)=0\)\(0/2!=0\),因此\(a_3=0\),第三个元素为\(1\)
  • \(0\bmod(2!)=0\)\(0/1!=0\),因此\(a_4=0\),第四个元素为\(4\)
  • \(0\bmod(1!)=0\)\(0/0!=0\),因此\(a_5=0\),第五个元素为\(5\)
    原排列为\(A\{2,3,1,4,5\}\)

写上\(O(N^2)\)做法:

//略去了预处理的过程
int ans,vis[N],a[N];
int num=ans-1;
for(int i=1;i<=n;i++)
{
	int now=num/fac[n-i];
	num%=fac[n-i];
	for(int j=1;j<=n;j++)
		if(!vis[j] && !(now--))
		{
			vis[j]=1,a[i]=j;
			break;
		}
}

RMQ问题

RMQ问题:在一段连续区间查找最大最小值的问题

使用ST算法可以实现\(O(n)\)预处理,\(O(1)\)查找。
不过,在这里我们主要谈该问题的树状数组解法(\(O(\log_2^2 n)\)的查找)。
建立很简单,模板改一下就可以了

#define lowbit(x) x&-x
int n,a[N],trmin[N],trmax[N];
inline void add(int x,int k)
{
	for(;x<=n;x+=lowbit(x))
	{
		trmax[x]=max(trmax[x],k);
		trmin[x]=min(trmin[x],k);		
	}
}

查询则难一些,因为RMQ问题不满足区间减法性质,你不能用两区间相减得出某一区间的最值。这时需要另一种思路。
根据树状数组的性质(1):\(C[i]=\sum_{j=i-\operatorname{lowbit}(i)+1}^{n}\!{A[j]}\),可知\(trmax[i]\)的值为闭区间\([i-\operatorname{lowbit}(i)+1,i]\)中的最大值。
因此对于闭区间\([l,r]\)

  • 如果\(l\leqslant r-\operatorname{lowbit}(r)+1\),说明\(trmax[r]\)的值对\([l,r]\)有效,所以答案为\(\max({trmax[r],\operatorname{askmax}(l,r-\operatorname{lowbit}(r)))}\)
  • 如果\(l>r-\operatorname{lowbit}(r)\),说明\(trmax[r]\)的值对\([l,r]\)无效(因为我们并不能确定这个值是否在\([l,r]\)中出现过),所以答案为\(\max({a[r],\operatorname{askmax}(l,r-1)})\)(可以证明这里的\(\operatorname{askmax}()\)一定会变成第一种情况)。
  • 如果\(l\geqslant r\),返回\(a[l]\)即可(思考一下上面两种情况的\(\operatorname{askmax}\)函数的参数)
    看到这里就可以尝试打代码了。
//查询最大值,最小值同理
int askmax(int l,int r)
{ 
    if(l<r)
        if(r-lowbit(r)>l) return max(trmax[r],askmax(l,r-lowbit(r)));
        else return max(a[r],askmax(l,r-1));
    return a[l];
}

用循环来实现也可以。
给出模板题:
[USACO07JAN]Balanced Lineup G

#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
int n,m;
int a[50005],trmax[50005],trmin[50005];
inline int lowbit(int x)//如果写成宏定义,一定要加括号(写成(x&-x))
{
    return x&-x;
}
inline void add(int x,int k)
{
    for(;x<=n;x+=lowbit(x))
    {
        trmax[x]=max(trmax[x],k);
        trmin[x]=min(trmin[x],k);
    }
}
inline int askmax(int l,int r)
{ 
    if(l<r)
        if(r-lowbit(r)>l)    return max(trmax[r],askmax(l,r-lowbit(r)));
        else    return max(a[r],askmax(l,r-1));
    return a[l];
}

inline int askmin(int l,int r)
{ 
    if(l<r)
        if(r-lowbit(r)>l)    return min(trmin[r],askmin(l,r-lowbit(r)));
        else    return min(a[r],askmin(l,r-1));
    return a[l];
}
int main()
{
    ios::sync_with_stdio(false);
    memset(trmin, 0x3f3f3f3f, sizeof(trmin));
    cin>>n>>m;
    for(int i=1;i<=n;i++) 
        cin>>a[i],add(i,a[i]);
    for(int i=1,l,r;i<=m;i++)
        cin>>l>>r,cout<<askmax(l,r)-askmin(l,r)<<endl;
    return 0;
}

RMQ问题的树状数组解法“用时间换空间”,总体上来说是一种不错的算法

查询第k小

这个问题的树状数组解法时间复杂度也是\(O(\log_2^2 n)\)

习题

Preprefix sum

这题是一个叫前前缀和的东西(???)
思路
首先,不能用树状数组维护树状数组来求“前前缀和”,因此需要化简式子:
前前缀和的式子是

\[SS_i=\sum_{j=1}^i\sum_{k=1}^j{a_k} \]

\[=\sum_{j=1}^i(i-j+1){a_j} \]

\[=(i+1)\times\sum_{j=1}^{i}a_j-\sum_{j=1}^{i}(j\times a_j) \]

是不是很熟悉?
所以你应该知道怎么办了[滑稽.jpg]

#include<iostream>
#include<cstdio>
#define N 100005
using namespace std;
long long tree[N],tree2[N],a[N],n,m;
inline void add(long long x,long long k)
{
    for(long long x0=x;x<=n;x+=x&-x)
        tree[x]+=k,tree2[x]+=k*x0;
}
inline long long ask(long long x)
{
    long long ans=0;
    for(long long x1=x+1;x;x-=x&-x)
        ans+=x1*tree[x]-tree2[x];
    return ans;
}
int main()
{
    ios::sync_with_stdio(false);
    cin>>n>>m;
    string c;
    for(long long i=1;i<=n;i++)
        cin>>a[i],add(i,a[i]);
    for(long long i=1,I,X;i<=m;i++)
    {
        cin>>c>>I;
        if(c[0]=='M')  cin>>X,add(I,X-a[I]),a[I]=X;
        if(c[0]=='Q')  printf("%lld\n",ask(I));
    }
    return 0;
}

[USACO20OPEN]Haircut G

最近出的USACO赛题。
思路
发现顺推不好做,考虑逆推:
首先,第\(x\)轮的答案=第\(x-1\)轮的答案+第\(x\)轮新增的逆序对数(这不废话么)
然后,第\(x\)轮新增的逆序对数=最后一次更新为\(x-1\)轮的每一个数与其前面的数构成的逆序对数之和
所以,用树状数组维护\(a_1\)~\(a_n\)与其前面的数构成的逆序对数即可。
这里还有几个细节要注意:

  1. 树状数组维护的值必须为正整数,因此读入时要+1
  2. 维护的是add(n-a[i]+2),查询的是ask(n-a[i]+1)
  3. 记得开long long
#include<iostream>
#include<cstdio>
#include<cstring>
#define N 100010
#define lowbit(x) x&-x
#define int long long
using namespace std;
int n,s[N],a[N],tr[N],ans;
inline void add(int x)
{
	for(;x<=n;x+=lowbit(x))
		tr[x]++;
}
inline int ask(int x)
{
	int res=0;
	for(;x;x-=lowbit(x))
		res+=tr[x];
	return res;
}
signed main()
{
//	freopen("haircut.in","r",stdin);
//	freopen("haircut.out","w",stdout);
	scanf("%lld",&n);
	for(int i=1;i<=n;i++)
		scanf("%lld",&a[i]),a[i]++;
	for(int i=1;i<=n;i++)
	{
		s[a[i]]+=ask(n-a[i]+1);
		add(n-a[i]+2);
	}
	for(int i=1;i<=n;i++)
		printf("%lld\n",ans),ans+=s[i];
	return 0;
}

[eJOI2019]异或橙子

题意
给一个长为\(n\)的数列,要求支持两种操作

  1. \(a_i\)修改为\(j\)
  2. 查询闭区间\([u,l]\)的所有子区间的异或和的异或和

(如果没看懂看原题吧)
思路
异或(记作\(\oplus\),编程中写作a=a^b或a^=b的形式),又被称为不进位加法。它有三个重要的性质可助我们切掉解决此题

  • \(x\oplus 0=x\)
  • \(x\oplus x=0\)
  • \(x\oplus y\oplus y=x\)
    根据性质1,2,分类讨论第二种操作后可得结论:
  • \(l\)\(u\)奇偶性相同时,答案为\(a_l\oplus a_{l+2}\cdots\oplus a_{u-2}\oplus{a_u}\)
  • \(l\)\(u\)奇偶性不同时,答案为\(0\)

根据性质3可推出异或和问题满足区间减法性质
因此用两个树状数组维护原数列的奇数位和偶数位的异或和即可。

#include<iostream>
#include<cstdio>
#include<cstring>
#define N 200010
#define lowbit(x) x&-x
using namespace std;
int n,q,a[N];
int op,l,r;
struct BIT
{
	int tr[N];
	void xoradd(int x,int k)
	{
		for(;x<=n;x+=lowbit(x))
			tr[x]^=k;
	}
	int xorask(int x)
	{
		int res=0;
		for(;x;x-=lowbit(x))
			res^=tr[x];
		return res;
	}
}t[2];//t[0]维护偶数位,t[1]维护奇数位
int main()
{
	ios::sync_with_stdio(0);
	cin>>n>>q;
	for(int i=1;i<=n;i++)
		cin>>a[i],t[i&1].xoradd(i,a[i]);//利用位运算快速判断一个数是否为奇数
	while(q--)
	{
		cin>>op>>l>>r;
		if(op==1)
		{
			t[l&1].xoradd(l,a[l]^r),a[l]=r;
		}
		else
		{
			if((l+r)&1)//利用位运算快速判断两数和是否为奇数
				cout<<"0"<<endl;
			else
				cout<<(t[l&1].xorask(r)^t[l&1].xorask(l-1))<<endl;
		}
	}
	return 0;
}

[USACO04OPEN]MooFest

对于\(i\)(\(1\leqslant i\leqslant n\)),用两个树状数组num和sum分别维护x小于i的个数,坐标和,再用all更新\(\sum_{j=1}^{i}a[i].x\)
具体操作看代码:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define N 50010
#define int long long
using namespace std;
int n,all,ans;
class BIT
{
	private:
		#define lowbit(x) x&-x
		int tr[N];
	public:
		void add(int x,int k)
		{
			for(;x<=N;x+=lowbit(x))//注意是最大范围N不是n,想想为什么
				tr[x]+=k;
		}
		int ask(int x)
		{
			int res=0;
			for(;x;x-=lowbit(x))
				res+=tr[x];
			return res;
		}
}num,sum;
struct cow
{
	int v,x;
        //重载"<"使得sort()以v为关键字排序
	bool operator<(const cow &t)const
	{
		return v<t.v;
	}
}a[N];
signed main()
{
	ios::sync_with_stdio(0);
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i].v>>a[i].x;
	sort(a+1,a+n+1);//排序
	for(int i=1,sumi,numi;i<=n;i++)
	{
		numi=num.ask(a[i].x);//计算小于a[i].x的数有几个
		sumi=sum.ask(a[i].x);//计算小于a[i].x的每个数的坐标之和
		ans+=a[i].v*(a[i].x*numi-sumi);//这里加上x小于a[i].x的情况的答案
		ans+=a[i].v*(all-sumi-a[i].x*(i-1-numi));//这里加上x大于a[i].x的情况的答案
		num.add(a[i].x,1);//更新num
		sum.add(a[i].x,a[i].x);//更新sum
		all+=a[i].x;//更新all
	}
	cout<<ans<<endl;
	return 0;
}
posted @ 2020-04-05 21:53  LZShuing  阅读(346)  评论(1编辑  收藏  举报