分治与根号算法

1. 根号分治与分块

1.1. 根号分治

根号分治,就是在预处理与询问的复杂度之间寻找平衡的一个算法。通常以根号作为问题规模的分界线,规模小于根号的询问可以 \(n\sqrt n\) 预处理求出,而回答一次规模为 \(B\geq n\) 的询问的时间只需要 \(\dfrac n B\leq \sqrt n\),那么整个题目就可以做到 \(n\sqrt n\)。

根号平衡的思想非常重要,它是几乎所有根号算法的核心思想,例如第三部分的莫队。学会根号平衡对 OI 水平有很大帮助。

1.2. 分块

与根号分治有异曲同工之妙,可以用来维护一些 \(\log\) 数据结构无法维护的东西,比如用分块凸包替换不支持修改的李超树。

本质是一种暴力:序列分块就是把序列分成 \(\sqrt n\) 块,修改时遇到整块可以区间打标记,遇到散点直接暴力重构,由于最多 \(\sqrt n\) 重构两个块,打根号个区间的标记,所以单次修改时间复杂度 \(k\sqrt n\),一般 \(k\) 是常数。

别看分块时间复杂度没有 \(\rm polylog\) 的数据结构好,但是它仍是重要算法。它可以灵活地维护一些数据结构无法维护的信息,并且可以用来平衡复杂度。例如在莫队二次离线算法中,我们有 \(\mathcal{O}(n)\) 次修改,\(\mathcal{O}(n\sqrt n)\) 次查询,此时使用 \(\mathcal{O}(\sqrt n)\) 修改,\(\mathcal{O}(1)\) 查询的维护前缀和的分块即可做到 \(\mathcal{O}(n\sqrt n)\) 的优秀复杂度,比 \(\mathcal{O}(n\sqrt n\times \mathrm{polylog})\) 不知道快到哪去了。

1.3. 例题

I. CF797E Array Queries

题意简述:给出 \(\{a_i\}\),多次询问给出 \(p,k\),求至少执行多少次 \(p\gets p+a_p+k\) 才能使 \(p>n\)。

注意到如果 \(k>\sqrt n\) 那么答案必定不大于 \(\sqrt n\),那么对于所有位置预处理出所有 \(k\leq \sqrt n\) 的答案,若 \(k>\sqrt n\) 直接暴力查询即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=1e5+5;
const int B=333;

int n,m,b,a[N],s[N][B];

int main(){
	cin>>n,b=sqrt(n);
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=b;i++)for(int j=n;j;j--)
		s[j][i]=j+a[j]+i>n?1:(s[j+a[j]+i][i]+1);
	cin>>m;
	for(int i=1;i<=m;i++){
		int p,k; cin>>p>>k;
		if(k<=b)cout<<s[p][k]<<endl;
		else{
			int ans=0;
			while(p<=n)ans++,p+=a[p]+k;
			cout<<ans<<endl;
		}
	}
	return 0;
}

II. *CF1039D You Are Given a Tree

题意简述:给出一棵树,对每个 \(k\in[1,n]\),求出最多能找出多少条没有公共点的至少经过 \(k\) 个点的链。

注意到若 \(k>\sqrt n\) 则答案一定不大于 \(\sqrt n\)(怎么和上一题那么像,笑)。那么对于 \(1\leq k\leq \sqrt n\),直接暴力树形 DP。然后再枚举 \(1\leq ans\leq \sqrt n\),不过这次枚举的是链的条数,即答案。显然答案单调不升,于是二分出答案为 \(ans\) 的 \(k\) 的区间即可(实际上不需要右端点,只需要左端点)。

树形 DP 求链上经过的点 \(k\) 的答案:该部分比较类似 赛道修建,不过也有一些区别:因为一个点只能被一条链经过(而不是赛道修建中的一条边),于是分两种情况讨论:记 \(mx_1,mx_2\) 为 \(i\) 的儿子所传入的最长的两条链(所经过点的个数),若 \(mx_1+mx_2+1\geq k\),那么显然是将 \(i\) 与它的两个儿子配成一条链,答案加 \(1\);否则将 \(mx+1\) 传上去到 \(fa_i\) 即可。这样一次 DP 就是 \(\mathcal{O}(n)\) 的。

因此,总时间复杂度为 \(\mathcal{O}(n\sqrt n\log n)\)。

卡常技巧:将每个节点的父亲预处理出来,然后按照 dfs 序排序,可以直接循环树形 DP,不需要 dfs。

#include <bits/stdc++.h>
using namespace std;
#define mem(x,v) memset(x,v,sizeof(x))
const int N=1e5+5;
int ed,ed2,hd[N],nxt[N<<1],to[N<<1];
pii nw[N];
void add(int u,int v){
	nxt[++ed]=hd[u],hd[u]=ed,to[ed]=v;
}
int n,p,cnt,ans[N];
void dfs0(int id,int f){
	for(int i=hd[id];i;i=nxt[i]){
		if(to[i]==f)continue;
		nw[++ed2]={id,to[i]},dfs0(to[i],id);
	}
}
int dfs(int id){
	int mx=0,mx2=0;
	for(int i=hd[id];i;i=nxt[i]){
		int v=dfs(to[i]);
		if(v>=p){cnt++; return 0;}
		if(v>=mx)mx2=mx,mx=v;
		else if(v>=mx2)mx2=v;
	} if(mx+mx2+1>=p){cnt++; return 0;}
	return mx+1;
} int run(int x){
	cnt=0,p=x,dfs(1);
	return cnt;
}
int main(){
	cin>>n;
	for(int i=1;i<n;i++){
		int a=read(),b=read();
		add(a,b),add(b,a);
	} int m=sqrt(n*log2(n));
	dfs0(1,0),mem(hd,0),mem(nxt,0),ed=0;
	for(int i=1;i<n;i++)add(nw[i].fi,nw[i].se);
	for(int i=1;i<=m;i++)ans[i]=run(i);
	for(int i=1,pre=n+1;i<=n/m+1;i++){
		int l=1,r=pre;
		while(l<r){
			int m=(l+r>>1)+1;
			if(run(m)>=i)l=m;
			else r=m-1;
		} for(int j=l+1;j<pre;j++)ans[j]=i-1; pre=l+1;
	} for(int i=1;i<=n;i++)cout<<ans[i]<<endl;
	return flush(),0;
}

III. *2019 五校联考 - 镇海 T3 小 ω 的树

见计算几何初步凸包部分例题。

IV. CF1580C Train Maintenance

一个非常显然的根号重构题目。若 \(x+y\leq B\) 我们可以用桶记录其对 \(i\bmod (x+y)=d\) 的每个天数 \(i\) 的贡献。若 \(x+y>B\) 直接差分即可。注意取消差分贡献时下标对 \(i\) 取 \(\max\),因为作用在 \(i\) 以前的位置 \(j\) 的差分需要在 \(i\) 处更新而不是 \(j\):你对差分数组位置 \(j\ (j+1<i)\) 的更新是不会在位置 \(i\) 中体现的,\(j\) 已经过时了。

时间复杂度 \(\mathcal{O}(\dfrac{nm}B+mB)\),取 \(B=\sqrt m\) 有最优复杂度 \(n\sqrt m\)。荣登 cf 最短代码榜首。

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,m,x[N],y[N],a[N],p[N],buc[555][555];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)scanf("%d%d",&x[i],&y[i]);
	for(int i=1;i<=m;i++){
		int op,k,c,ans=0;
		scanf("%d%d",&op,&k),c=x[k]+y[k];
		if(op==1){
			a[k]=i;
			if(c<555)for(int j=0;j<c;j++)buc[c][(i+j)%c]+=j>=x[k];
			else for(int j=i+x[k];j<=m;j+=c)p[j]++,p[min(m+1,j+y[k])]--;
		} else{
			if(c<555)for(int j=0;j<c;j++)buc[c][(a[k]+j)%c]-=j>=x[k];
			else for(int j=a[k]+x[k];j<=m;j+=c)p[max(i,j)]--,p[max(i,min(m+1,j+y[k]))]++;
		}
		for(int j=2;j<555;j++)ans+=buc[j][i%j];
		printf("%d\n",ans+(p[i]+=p[i-1]));
	}
	return 0;
}

Trick:根号分治进入较大的分支调不出来时,试试将块大小设为 \(0\)

2. cdq 分治

人类智慧算法。

在一个序列中,需要计算满足某些限制的点对 \((i,j)\)(这里 \(i,j\) 都表示位置)对答案的贡献,通常这些点对都有 \(\mathcal{O}(n^2)\) 个。cdq 分治的核心思想就是将所有需要计算贡献的点对 \((i,j)\) 分成三类:第一类 \(i,j\in[1,mid]\);第二类 \(i,j\in(mid,n]\);第三类 \(i\in[1,mid],j\in(mid,n]\)。这样一来就可以先递归处理第一、二类点对的答案,再运用一些方法快速求第三类的答案。

2.1. 例题

I. P3810 【模板】三维偏序(陌上花开)

题意简述:对每个 \(d\),求使 \(a_j\leq a_i,b_j\leq b_i,c_j\leq c_i,i\neq j\) 的 \(j\) 的个数有 \(d\) 个的 \(i\) 的个数。

首先去重,cdq 一般解决不了有重复元素的问题,除非重复元素之间不算贡献。接着将所有点按 \(a_i,b_i,c_i\) 分别为第一、二、三关键字从小到大排序。

这样做,排除了 \(a_i\) 对答案的限制。因为右区间的任何一个点都不会对左区间中的任何一个点有贡献。这样一来,需要求的就变成了对右区间的每个点 \(i\),求左区间的所有点 \(j\) 中,满足 \(b_j\leq b_i,c_j\leq c_i\) 的 \(j\) 有多少个。

先将区间内部的点按照 \(b_i,c_i\) 分别为第一、二关键字从小到大排序,那么对于每个 \(i\),可能符合条件(\(b_j\leq b_i\))的 \(j\) 一定是左区间的一段随着 \(i\) 的增大单调不缩的前缀。对于一段前缀,求有多少个 \(j\) 满足 \(c_j\leq c_i\) 就是树状数组的拿手好戏了。

视值域与序列大小同阶(离散化一下即可),则时间复杂度为 \(\mathcal{O}(n\log^2 n)\)。

一些注意点:

  • 树状数组在添加 / 删除时权值为点的个数而不是 \(1\)。
  • 别忘了考虑重复元素之间的贡献,即最终答案还要加上该重复元素个数 \(-1\)。
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int n,m,k,f[N];
struct pt{
	int a,b,c,ans,cnt;
}d[N],u[N];
bool cmp1(pt a,pt b){return a.a!=b.a?a.a<b.a:a.b!=b.b?a.b<b.b:a.c<b.c;}
bool cmp2(pt a,pt b){return a.b!=b.b?a.b<b.b:a.c<b.c;}
int c[N<<1];
void add(int x,int v){while(x<=k)c[x]+=v,x+=x&-x;}
int query(int x){int s=0; while(x)s+=c[x],x-=x&-x; return s;}
void solve(int l,int r){
	if(l==r)return;
	int m=l+r>>1,le=l;
	solve(l,m),solve(m+1,r);
	sort(u+l,u+m+1,cmp2),sort(u+m+1,u+r+1,cmp2);
	for(int i=m+1;i<=r;i++){
		while(le<=m&&u[le].b<=u[i].b)add(u[le].c,u[le].cnt),le++;
		u[i].ans+=query(u[i].c);
	} for(int i=l;i<le;i++)add(u[i].c,-u[i].cnt);
}

int main(){
	cin>>n>>k;
	for(int i=1;i<=n;i++)d[i].a=read(),d[i].b=read(),d[i].c=read();
	sort(d+1,d+n+1,cmp1);
	for(int i=1;i<=n;i++){
		if(d[i].a!=d[i-1].a||d[i].b!=d[i-1].b||d[i].c!=d[i-1].c)u[++m]=d[i];
		u[m].cnt++;
	} solve(1,m);
	for(int i=1;i<=m;i++)f[u[i].ans+u[i].cnt-1]+=u[i].cnt;
	for(int i=1;i<=n;i++)print(f[i-1]),pc('\n');
	return flush(),0;
}

II. P4755 Beautiful Pair

首先对其进行 cdq 分治,设当前区间为 \([l,r]\),\(m=\frac{l+r}{2}\)。

对于每个位置 \(i\),若 \(i\in[l,m]\) 则记 \(suf_i=\max_{j=i}^m a_j\),若 \(i\in[m+1,r]\) 则记 \(pre_i=\max_{j=m+1}^i a_j\)。

分别考虑最大值在 \([l,m]\) 之间与在 \([m+1,r]\) 之间的情况:若最大值在左侧,则枚举 \(i\in[l,m]\),找到右侧的分界点 \(p\) 使得对于 \(j\in[m+1,p]\) 都有 \(pre_j\leq suf_i\),那么查询 \([m+1,p]\) 有多少个 \(j\) 使得 $a_j\leq \frac{suf_i}{a_i} $(不等号右边是定值),这个可以用主席树或者 BIT 做到。反之同理。

别忘了离散化。注意最大值在右边时要找分界点 \(p\) 使得对于 \(j\in[p,m]\) 都有 \(suf_j<pre_i\),而不是 \(\leq\),因为后者会多加上最大值在两边都出现的情况,而这种情况在考虑最大值在左边时已经计算过。

#include<bits/stdc++.h>
using namespace std;
#define ll long long
const int N=1e5+5;
ll n,ans,c,a[N],b[N],d[N],suf[N],pre[N];
ll node,R[N],ls[N<<5],rs[N<<5],val[N<<5];
void modify(int pre,ll &x,int l,int r,int p){
	val[x=++node]=val[pre]+1,ls[x]=ls[pre],rs[x]=rs[pre];
	if(l==r)return;
	int m=l+r>>1;
	if(p<=m)modify(ls[pre],ls[x],l,m,p);
	else modify(rs[pre],rs[x],m+1,r,p);
} ll query(int l,int r,int p,int x,int y){
	if(l==r)return val[y]-val[x];
	int m=l+r>>1;
	if(p<=m)return query(l,m,p,ls[x],ls[y]);
	return val[ls[y]]-val[ls[x]]+query(m+1,r,p,rs[x],rs[y]);
}
ll solve(int l,int r){
	if(l==r)return b[a[l]]==1;
	ll m=l+r>>1,ans=solve(l,m)+solve(m+1,r);
	for(int i=m+1;i<=r;i++)pre[i]=max(pre[i-1],a[i]);
	for(int i=m;i>=l;i--)d[m-i+1]=suf[i]=max(suf[i+1],a[i]);
	for(int i=l;i<=m;i++){
		int p=upper_bound(pre+m+1,pre+r+1,suf[i])-pre-1;
		if(p>m){
			int nd=upper_bound(b+1,b+c+1,b[suf[i]]/b[a[i]])-b-1;
			if(nd)ans+=query(1,c,nd,R[m],R[p]);
		}
	} for(int i=m+1;i<=r;i++){
		int p=m+1-(lower_bound(d+1,d+m-l+2,pre[i])-d-1);
		if(p<=m){
			int nd=upper_bound(b+1,b+c+1,b[pre[i]]/b[a[i]])-b-1;
			if(nd)ans+=query(1,c,nd,R[p-1],R[m]);
		}
	}
	for(int i=l;i<=r;i++)pre[i]=suf[i]=0;
	return ans;
}
int main(){
	cin>>n;
	for(int i=1;i<=n;i++)cin>>a[i],b[i]=a[i];
	sort(b+1,b+n+1),c=unique(b+1,b+n+1)-b-1;
	for(int i=1;i<=n;i++){
		a[i]=lower_bound(b+1,b+c+1,a[i])-b;
		modify(R[i-1],R[i],1,c,a[i]);
	}
	cout<<solve(1,n)<<endl;
	return 0;
}

*3. 莫队

莫队是优雅的暴力。

3.1. 普通莫队

3.1.1. 算法介绍

莫队常用来离线处理形如 “\(q\) 次询问一段区间的答案,且区间 \([l,r]\) 扩展到 \([l\pm 1,r\pm 1]\) 的时间复杂度为 \(\mathcal{O}(\mathrm{polylog})\)” 的问题。莫队可以支持修改,这将在带修莫队部分提及。

它的核心思想十分简单:维护两个指针 \(l,r\) 表示当前区间,并按照一定顺序处理询问使得时间复杂度最小。如果按照询问次序伸缩区间,每次询问的最坏时间复杂度为 \(\mathcal{O}(n)\),无法接受。

一个基本的想法是为了让指针移动距离尽可能地少,可以将询问按照某个端点为关键字排序。尽管该端点的移动距离是 \(\mathcal{O}(n)\),但另一个端点的移动距离无法保证,是 \(\mathcal{O}(nq)\),仍无法接受。

但注意到两个端点的移动距离分别是 \(\mathcal{O}(n)\) 和 \(\mathcal{O}(nq)\),一个过优,一个过劣,都不是我们想要的。因此,为了平衡复杂度,自然想到使用根号平衡的思想。如果将左右端点的移动距离都控制在 \(n\sqrt n\) 以内,我们就得到了一个时间复杂度为 \(\mathcal{O}(n\sqrt n\times k)\) 的优秀算法(\(k\) 是扩展复杂度)。

接下来是一个非常神仙(但很好理解)的操作:我们分块!具体地,将所有询问离线下来,并按照左端点所在块编号为第一关键字,右端点为第二关键字排序,按照该顺序处理所有询问的时间复杂度为 \(\mathcal{O}((n+q)\sqrt n)\)。每个块内,左端点的移动距离不超过 \(\sqrt n\),而右端点的总移动距离不超过 \(n\)(因为它有序)。故左端点总移动距离为 \(q\sqrt n\),右端点总移动距离为 \(n\sqrt n\),因此得到上述优秀的时间复杂度。

3.1.2. 常数优化

关于莫队,有一个人尽皆知的常数优化:奇偶排序。具体地说,如果左端点在奇块,那么右端点从小到大排序,否则从大到小排序,这保证了我们右端点在左端点跨块的时候不会从很右边移到很左边导致常数增大,因为这样排序后右端点会类似波浪一样左右扫动。一般可以优化 \(25\%\)。

3.1.3. 与分块结合

注意到莫队的本质是 \(n\sqrt n\) 次修改 + \(n\) 次询问。因此对于一些需要使用数据结构维护的题目,如果使用 \(\mathcal{O}(1)\) 修改,\(\mathcal{O}(\sqrt n)\) 查询的分块可以去掉数据结构 \(\log\)。

3.2. 莫队二次离线

3.2.1. 算法简介

一般情况下,伸缩区间的时间复杂度为 \(\mathcal{O}(1)\)。但如果遇到无法在线性时间内伸缩区间的题目,但数据范围又很大(如 \(n\sqrt n\log n\) 跑 2e5)怎么办?GG,双手离开键盘并问候出题人的母亲

首先思考为什么有些题目无法线性时间伸缩区间:新增加的位置对整个区间的贡献和区间内每个数都有关,需要用数据结构维护,例如区间逆序对数。大部分情况下这样的信息是可减的,此时我们可以使用莫队二次离线方法做到更优的复杂度。

现在看一道例题:P4887 【模板】莫队二次离线

我们设 \(f(i,[l,r])\) 表示 \(i\) 对区间 \([l,r]\) 的贡献,即 \(\sum_{\\j=l}^r[\mathrm{popcount}(a_j\oplus a_i)=k]\)。我们再设 \(g([l_1,r_1],[l_2,r_2])\) 表示 \(\sum_{\\i=l_1}^{r_1}f(i,[l_2,r_2])\),即 \(\sum_{\\i=l_1}^{r_1}\sum_{\\j=l_2}^{r_2}[\mathrm{popcount}(a_i\oplus a_j)=k]\)。

考虑右端点向右移动时的贡献 \(f(r,[l,r-1])\)。由于信息可减性,我们可以将其写作 \(f(r,[1,r-1])-f(r,[1,l-1])\)。因此,假设右端点 \(r\to r+d\),那么新的贡献即为

\[\sum_{p=r+1}^{r+d}f(p,[1,p-1])-f(p,[1,l-1]) \]

前半部分很容易 \(\dbinom{14}k\) 预处理出来,因此考虑如何计算后面的 \(-g([r+1,r+d],[1,l-1])\)。注意到这其实是一段前缀对一段区间的贡献,而区间总长度是 \(n\sqrt n\) 的。如果我们将所有这样的 “贡献对” 按照前缀的位置从小到大排序(直接开个 vector 桶排,这就是二次离线:莫队离线后将产生的询问进行第二次离线),那么我们只需要支持 \(n\) 次修改,\(n\sqrt n\) 次查询:每次加入 \(a_i\) 时,将桶中所有位置 \(j\) 使得 \(\mathrm{popcount}(j\oplus a_i)=k\) 的值 \(+1\),查询时直接查桶中 \(a_c\ (r+1\leq c\leq r+d)\) 的值。时间复杂度 \(\mathcal{O}\left(n\left(\dbinom{14}k+\sqrt n\right)\right)\)。

需要注意上述只是 \(r\to r+k\) 的情况,剩下来三种情况如法炮制即可,请读者自行推导。

从上面的例子中,我们可以初步感受到莫队二次离线的威力:在运用莫队的根号平衡思想基础上,更进一步地运用了平衡复杂度的方法,是一个非常美妙且可爱(逐渐 ymx 化)的算法。

3.2.2. 注意点

  • 推导过程中注意 \(g([1,x],[l,r])\) 的符号。

  • 例题 \(k=0\) 时,若当前 \(c\leq i\ (r+1\leq c\leq r+d)\),这说明 \(b_{a_c}\) 受到了它自身的贡献(因为桶中存储的是前缀信息,而前缀右端点在当前点的右边,故当前点对该前缀信息有贡献),这是不可以的(题目中要求二元组 \(i,j\) 满足 \(i<j\)),故需减去 \(1\)。从中可以看出计算贡献需特别注意去掉不合法的贡献

3.3. 信息无法删除:回滚莫队

如果遇到维护如区间最大值这样的信息导致我们无法快速缩短区间时,考虑如何不删除地回答询问:运用撤销的思想

将所有询问按照左端点所在的块排序,然后依次处理所有左端点落在某个块内的询问 \((l_i,r_i)\),需要确保这些询问按照 \(r_i\) 从小到大有序。类似普通莫队,我们仍然维护两个指针,不同的是左指针 \(l\) 初始指向当前块下一个块的开头,右指针 \(r=l-1\) 表示当前区间为空。然后,对于右端点,由于其有序,我们可以直接扩展。右端点扩展完毕后,我们再扩展左端点直到我们想要的位置并记录答案。

接下来我们撤回扩展左端点时对信息的修改,这个可以在 \(\sqrt n\times\mathrm{polylog}\) 的时间内完成,因为左端点移动长度不超过 \(\sqrt n\)。撤回完毕后再处理下一个询问,这就是回滚莫队了。

注意点:

  • 左右端点在同一个块时,直接暴力即可。
  • 每做完一个块需要将信息清空。

3.4. 带修:多参数莫队

3.5. 例题

I. P5047 Yuno loves sqrt technology II

开幕 Ynoi,二次离线莫队裸题。

设 \(F/G([1,x],[l,r])\) 表示区间 \([1,x]\) 比 \(a_i\ (1\leq i\leq x)\) 大 / 小的数的个数,\(f_x=F([1,x-1],x)\),\(g_x=G([1,x-1],x)\)。

  • 右端点向右扩展:\(\sum f_i-F([1,l-1],i)\),第二项写为 \(-F([1,l-1],[r+1,r+d])\)。

  • 左端点向左扩展:\(\sum G([1,r],i)-g_i\),第一项写为 \(G([1,r],[l-d,l-1])\)。

  • 右端点向左扩展:\(\sum -f_i+F([1,l-1],i)\),第二项写为 \(F([1,l-1],[r-d+1,r])\)。

  • 左端点向右扩展:\(\sum -G([1,r],i)+g_i\),第一项写为 \(-G([1,r],[l,l+d-1])\)。

因此我们需要维护两个分块数组,一个加入 \(a_i\) 时将 \(1\sim a_i-1\) 加 \(1\) 为了查询 \(F\),另一个将 \(a_i+1\sim n\) 加 \(1\) 为了查询 \(G\)。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=1e5+5;
const int B=321;
ll n,m,a[N],d[N],f[N],g[N],ans[N];
int bl[N],lp[N],rp[N];
struct Block{
	int tag[N],sum[N];
	void add(int x,int v){
		if(x>n)return;
		for(int i=x;i<=rp[bl[x]];i++)sum[i]+=v;
		for(int i=bl[x]+1;i<=bl[n];i++)tag[i]+=v;
	}
	void add(int l,int r,int v){add(l,v),add(r+1,-v);}
	int query(int x){return x?tag[bl[x]]+sum[x]:0;}
	int query(int x,int y){return query(y)-query(x-1);}
}b,c,ept;
struct query{
	int x,y,b,id;
	bool operator < (const query &v) {
		return b!=v.b?b<v.b:b&1?y>v.y:y<v.y;
	}
}q[N];
struct data{
	int l,r,id,type;
	data(int x,int y,int z,int w){l=x,r=y,id=z,type=w;}
};
vector <data> buc[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)d[i]=a[i]=read(),bl[i]=i/B;
	for(int i=0;i<=bl[n];i++)lp[i]=max(1,i*B),rp[i]=min((int)n,i*B+B-1);
	for(int i=1;i<=m;i++)q[i].b=(q[i].x=read())/B,q[i].y=read(),q[i].id=i;
	sort(d+1,d+n+1),sort(q+1,q+m+1);
	for(int i=1;i<=n;i++)a[i]=lower_bound(d+1,d+n+1,a[i])-d;
	for(int i=1;i<=n;i++)
		f[i]=b.query(a[i]+1,n),g[i]=b.query(a[i]-1),b.add(a[i],1);
	for(int i=1,l=1,r=0;i<=m;i++){
		if(r<q[i].y){
			buc[l-1].pb(r+1,q[i].y,-q[i].id,0);
			while(r<q[i].y)ans[q[i].id]+=f[++r]; 
		}
		if(l>q[i].x){
			buc[r].pb(q[i].x,l-1,q[i].id,1);
			while(l>q[i].x)ans[q[i].id]-=g[--l];
		}
		if(r>q[i].y){
			buc[l-1].pb(q[i].y+1,r,q[i].id,0);
			while(r>q[i].y)ans[q[i].id]-=f[r--];
		}
		if(l<q[i].x){
			buc[r].pb(l,q[i].x-1,-q[i].id,1);
			while(l<q[i].x)ans[q[i].id]+=g[l++];
		}
	} b=ept;
	for(int i=1;i<=n;i++){
		b.add(a[i]+1,1),c.add(1,a[i]-1,1);
		for(auto it:buc[i]){
			int id=abs(it.id),tp=id/it.id;
			for(int j=it.l;j<=it.r;j++)
				ans[id]+=(it.type?b.query(a[j]):c.query(a[j]))*tp;
		}
	}
	for(int i=1;i<=m;i++)ans[q[i].id]+=ans[q[i-1].id];
	for(int i=1;i<=m;i++)printf("%lld\n",ans[i]);
	return 0;
}

II. P4887 【模板】莫队二次离线(第十四分块(前体))

莫队二离的例题,这里给出代码。

const int N = 1e5 + 5;
const int B = 320; 

int n, m, k, a[N], f[N], buc[1 << 14];
ll cnt, vec[1 << 12], ans[N];
struct query {
	int x, y, b, id;
	bool operator < (const query &v) const {
		return b != v.b ? b < v.b : b & 1 ? y > v.y : y < v.y;
	}
} q[N];
struct modify {
	int l, r, id;
	modify(int x, int y, int z) {l = x, r = y, id = z;}
};
vector <modify> c[N];
int main() {
	cin >> n >> m >> k;
	for(int i = 1; i <= n; i++) cin >> a[i];
	for(int i = 0; i < 1 << 14; i++)
		if(__builtin_popcount(i) == k) vec[++cnt] = i;
	for(int i = 1; i <= n; i++) {
		for(int j = 1; j <= cnt; j++) f[i] += buc[a[i] ^ vec[j]];
		buc[a[i]] += 1;
	} mem(buc, 0, 1 << 14);
	for(int i = 1; i <= m; i++)
		cin >> q[i].x >> q[i].y, q[i].b = q[i].x / B, q[i].id = i;
	sort(q + 1, q + m + 1);
	for(int i = 1, l = 1, r = 0; i <= m; i++) {
		if(r < q[i].y) {
			c[l - 1].pb(r + 1, q[i].y, -q[i].id);
			while(r < q[i].y) ans[q[i].id] += f[++r];
		}
		if(l > q[i].x) {
			c[r].pb(q[i].x, l - 1, q[i].id);
			while(l > q[i].x) ans[q[i].id] -= f[--l];
		}
		if(r > q[i].y) {
			c[l - 1].pb(q[i].y + 1, r, q[i].id);
			while(r > q[i].y) ans[q[i].id] -= f[r--];
		}
		if(l < q[i].x) {
			c[r].pb(l, q[i].x - 1, -q[i].id);
			while(l < q[i].x) ans[q[i].id] += f[l++];
		}
	}
	for(int i = 1; i <= n; i++) {
		for(int j = 1; j <= cnt; j++) buc[a[i] ^ vec[j]]++;
		for(modify it : c[i]) {
			int id = abs(it.id), tp = id / it.id;
			for(int j = it.l; j <= it.r; j++)
				ans[id] += tp * (buc[a[j]] - (j <= i && !k));
		}
	}
	for(int i = 1; i <= m; i++) ans[q[i].id] += ans[q[i - 1].id];
	for(int i = 1; i <= m; i++) printf("%lld\n", ans[i]);
	return 0;
}

*III. P5501 [LnOI2019]来者不拒,去者不追

考虑右端点右移时需要求哪些信息:\(a_{r+1}\) 的排名以及 \([l,r]\) 比 \(a_{r+1}\) 大的数的和,用数据结构维护是 \(n\sqrt n\log a\) 的,无法接受,那么使用莫队二离即可。

时间复杂度 \((n+m)(\sqrt n+\sqrt a)\)。还是比较卡常的。注意特殊考虑端点处 \(a_{l/r}\) 的贡献。

const int N=5e5+5;
const int V=1e5+5;
const int BV=333;
const int B=777;

int mx,lp[V+1],rp[V+1],bl[V+1];
struct Block{
	ll sum[V+1],tag[V+1];
	void add(int x,int v){
		if(x>V)return;
		for(int i=x;i<=rp[bl[x]];i++)sum[i]+=v;
		for(int i=bl[x]+1;i<=mx;i++)tag[i]+=v;
	}
	ll query(int x){return sum[x]+tag[bl[x]];}
	void add(int l,int r,int v){add(l,v),add(r+1,-v);}
	ll query(int x,int y){return query(y)-query(x-1);}
}num,sum,ept;

ll n,m,a[N],f[N],ans[N];
struct query{
	int x,y,b,id;
	bool operator < (const query &v) const{
		return b!=v.b?b<v.b:b&1?y>v.y:y<v.y;
	}
}q[N];
struct data{
	int l,r,id;
	data(int x,int y,int z){l=x,r=y,id=z;}
};
vector <data> buc[N];
int main(){
	for(int i=0;i<=V;i++)bl[i]=i/BV; mx=bl[V];
	for(int i=0;i<=mx;i++)lp[i]=i*BV,rp[i]=min(V,(i+1)*BV-1);
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		a[i]=read(),f[i]=num.query(1,a[i]-1)*a[i];
		f[i]+=sum.query(a[i]+1,V);
		num.add(a[i],1),sum.add(a[i],a[i]);
	}
	for(int i=1;i<=m;i++)q[i].b=(q[i].x=read())/B,q[i].y=read(),q[i].id=i;
	sort(q+1,q+m+1);
	for(int i=1,l=1,r=0;i<=m;i++){
		if(r<q[i].y){
			buc[l-1].pb(r+1,q[i].y,-q[i].id);
			while(r<q[i].y)r++,ans[q[i].id]+=f[r]+a[r];
		}
		if(l>q[i].x){
			buc[r].pb(q[i].x,l-1,q[i].id);
			while(l>q[i].x)l--,ans[q[i].id]-=f[l]-a[l];
		}
		if(r>q[i].y){
			buc[l-1].pb(q[i].y+1,r,q[i].id);
			while(r>q[i].y)ans[q[i].id]-=f[r]+a[r],r--;
		}
		if(l<q[i].x){
			buc[r].pb(l,q[i].x-1,-q[i].id);
			while(l<q[i].x)ans[q[i].id]+=f[l]-a[l],l++;
		}
	}
	sum=num=ept;
	for(int i=1;i<=n;i++){
		num.add(a[i]+1,V,1),sum.add(1,a[i]-1,a[i]);
		for(auto it:buc[i]){
			int id=abs(it.id),tp=it.id/id;
			for(int j=it.l;j<=it.r;j++)
				ans[id]+=tp*(sum.query(a[j])+num.query(a[j])*a[j]);
		}
	}
	for(int i=1;i<=m;i++)ans[q[i].id]+=ans[q[i-1].id];
	for(int i=1;i<=m;i++)printf("%lld\n",ans[i]);
	return 0;
}

IV. P4462 [CQOI2018]异或序列

一道莫队裸题,根据 \(a\oplus k=b\Leftrightarrow b\oplus k =a\),开个桶记录一下每个数的出现次数即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=1e5+5;
const int B=333;

int n,m,k,a[N],buc[N<<1];
struct query{
	int l,r,b,id;
	bool operator < (const query &v) const {
		return b!=v.b?b<v.b:b&1?r>v.r:r<v.r; 
	}
}q[N];
ll cur,ans[N];
int main(){
	cin>>n>>m>>k;
	for(int i=1;i<=n;i++)cin>>a[i],a[i]^=a[i-1];
	for(int i=1;i<=m;i++){
		cin>>q[i].l>>q[i].r,q[i].l--;
		q[i].b=q[i].l/B,q[i].id=i;
	}
	sort(q+1,q+m+1),buc[0]=1;
	for(int i=1,l=0,r=0;i<=m;i++){
		while(r<q[i].r)r++,cur+=buc[a[r]^k],buc[a[r]]++;
		while(l>q[i].l)l--,cur+=buc[a[l]^k],buc[a[l]]++;
		while(r>q[i].r)buc[a[r]]--,cur-=buc[a[r]^k],r--;
		while(l<q[i].l)buc[a[l]]--,cur-=buc[a[l]^k],l++;
		ans[q[i].id]=cur;
	}
	for(int i=1;i<=m;i++)printf("%lld\n",ans[i]);
	return 0;
}

V. CF617E XOR and Favorite Number

双倍经验,代码就不放了。

VI. P4396 [AHOI2013]作业

莫队 + 对值域分块即可。比较裸,时间复杂度 \(n\sqrt n\)。

const int N=1e5+5;

int B1,B2,lp[N],rp[N],bl[N],buc[N],sum[N],bsum[N];
void add(int x){if(!buc[x])sum[bl[x]]++; buc[x]++,bsum[bl[x]]++;}
void del(int x){buc[x]--,bsum[bl[x]]--; if(!buc[x])sum[bl[x]]--;}
int query1(int l,int r){
	int ans=0;
	for(int i=bl[l];i<=bl[r];i++)ans=ans+bsum[i];
	for(int i=lp[bl[l]];i<l;i++)ans=ans-buc[i];
	for(int i=r+1;i<=rp[bl[r]];i++)ans=ans-buc[i];
	return ans;
}
int query2(int l,int r){
	int ans=0;
	for(int i=bl[l];i<=bl[r];i++)ans=ans+sum[i];
	for(int i=lp[bl[l]];i<l;i++)ans=ans-(buc[i]>0);
	for(int i=r+1;i<=rp[bl[r]];i++)ans=ans-(buc[i]>0);
	return ans;
}

int n,m,a[N],ans1[N],ans2[N];
struct query{
	int l,r,a,b,bl,id;
	bool operator < (const query &v) const {
		return bl!=v.bl?bl<v.bl:bl&1?r>v.r:r<v.r; 
	}
}q[N];
int main(){
	cin>>n>>m,B1=sqrt(n),B2=250;
	for(int i=1;i<N;i++)bl[i]=i/B2;
	for(int i=0;i<=bl[N-1];i++)lp[i]=max(1,i*B2),rp[i]=min(N-1,(i+1)*B2-1);
	for(int i=1;i<=n;i++)a[i]=read();
	for(int i=1;i<=m;i++){
		q[i].l=read(),q[i].r=read(),q[i].a=read(),q[i].b=read();
		q[i].bl=q[i].l/B1,q[i].id=i;
	}
	sort(q+1,q+m+1);
	for(int i=1,l=1,r=0;i<=m;i++){
		while(r<q[i].r)add(a[++r]);
		while(l>q[i].l)add(a[--l]);
		while(r>q[i].r)del(a[r--]);
		while(l<q[i].l)del(a[l++]);
		ans1[q[i].id]=query1(q[i].a,q[i].b);
		ans2[q[i].id]=query2(q[i].a,q[i].b);
	}
	for(int i=1;i<=m;i++)printf("%d %d\n",ans1[i],ans2[i]);
	return 0;
}

VII. P5906 【模板】回滚莫队&不删除莫队

回滚莫队的裸题。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=2e5+5;
const int B=333;

int n,m,c,a[N],d[N],res[N];
struct query{
	int l,r,bl,id;
	bool operator < (const query &v) const{
		return bl!=v.bl?bl<v.bl:r<v.r;
	}
}q[N];

int pre[N],suf[N],ans;
int top,stc[N],pp[N],ps[N],pa[N];
void rollback(){
	while(top){
		pre[stc[top]]=pp[top];
		suf[stc[top]]=ps[top];
		ans=pa[top],top--;
	}
}
void add(int x,int tp){
	if(tp)stc[++top]=a[x],pp[top]=pre[a[x]],ps[top]=suf[a[x]],pa[top]=ans;
	pre[a[x]]=min(pre[a[x]],x),suf[a[x]]=max(suf[a[x]],x);
	ans=max(ans,suf[a[x]]-pre[a[x]]);
}
int main(){
	cin>>n,mem(pre,0x3f,N);
	for(int i=1;i<=n;i++)d[i]=a[i]=read();
	sort(d+1,d+n+1);
	for(int i=1;i<=n;i++)a[i]=lower_bound(d+1,d+n+1,a[i])-d;
	cin>>m;
	for(int i=1;i<=m;i++){
		int l=read(),r=read(),ans=0;
		if(l/B==r/B){
			for(int j=l;j<=r;j++){
				pre[a[j]]=min(pre[a[j]],j);
				suf[a[j]]=max(suf[a[j]],j); 
				ans=max(ans,suf[a[j]]-pre[a[j]]);
			} res[i]=ans; 
			for(int j=l;j<=r;j++)pre[a[j]]=1e9,suf[a[j]]=0;
			continue;
		}
		q[++c]={l,r,l/B,i};
	}
	sort(q+1,q+c+1);
	for(int i=1,l,r;i<=c;i++){
		if(i==1||q[i].bl!=q[i-1].bl){
			mem(pre,0x3f,N),mem(suf,0,N),top=ans=0;
			l=q[i].bl*B+B,r=l-1; 
		}
		while(r<q[i].r)add(++r,0);
		while(l>q[i].l)add(--l,1);
		res[q[i].id]=ans,rollback(),l=q[i].bl*B+B;
	}
	for(int i=1;i<=m;i++)printf("%d\n",res[i]);
	return 0;
}

VIII. P3709 大爷的字符串题

题意翻译过来就是求区间众数,那么只需莫队维护每个数的出现次数以及出现次数为 \(i\) 的数有多少个即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

代码使用了回滚莫队,信息维护更简单一些。

const int N=2e5+5;
const int B=300;

int n,m,ans,a[N],d[N],buc[N],res[N];
struct query{
	int l,r,b,id;
	bool operator < (const query &v) const {
		return b!=v.b?b<v.b:r<v.r; 
	}
}q[N];
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i],d[i]=a[i];
	sort(d+1,d+n+1);
	for(int i=1;i<=n;i++)a[i]=lower_bound(d+1,d+n+1,a[i])-d;
	for(int i=1;i<=m;i++)cin>>q[i].l>>q[i].r,q[i].b=q[i].l/B,q[i].id=i;
	sort(q+1,q+m+1);
	for(int i=1,r;i<=m;i++){
		int l=q[i].b*B+B;
		if(i==1||q[i].b!=q[i-1].b)mem(buc,0,N),r=l-1,ans=0;
		if(q[i].l/B==q[i].r/B){
			int tmp=0;
			for(int j=q[i].l;j<=q[i].r;j++)
				tmp=max(tmp,++buc[a[j]]);
			res[q[i].id]=tmp;
			for(int j=q[i].l;j<=q[i].r;j++)buc[a[j]]=0;
			continue;
		}
		while(r<q[i].r)ans=max(ans,++buc[a[++r]]);
		int tmp=ans;
		for(int j=q[i].l;j<l;j++)ans=max(ans,++buc[a[j]]);
		res[q[i].id]=ans,ans=tmp;
		for(int j=q[i].l;j<l;j++)buc[a[j]]--;
	}
	for(int i=1;i<=m;i++)printf("%d\n",-res[i]);
	return 0;
}

IX. P3730 曼哈顿交易

仍然是莫队裸题,求出现次数第 \(k\) 大可以用分块,将根号平衡的思想贯彻到底。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=1e5+5;
const int B=333;

int mx,bl[N],lp[N],rp[N],buc[N],pos[N],sum[N];
void add(int x,int v){pos[x]+=v,sum[bl[x]]+=v;}
int query(int k){
	int pre=0;
	for(int i=0;i<=mx;i++){
		if(pre+sum[i]<k)pre+=sum[i];
		else for(int j=lp[i];j<=rp[i];j++)
			if((pre+=pos[j])>=k)return j;
	}
}

int n,m,a[N],d[N],res[N],tot;
struct query{
	int l,r,k,b,id;
	bool operator < (const query &v) const {
		return b!=v.b?b<v.b:b&1?r>v.r:r<v.r;
	}
}q[N];
void add(int x){
	if(buc[x])add(buc[x],-1);
	else tot++;
	add(++buc[x],1);
}
void del(int x){
	add(buc[x],-1);
	if(--buc[x])add(buc[x],1);
	else tot--;
}
int main(){
	for(int i=1;i<N;i++)bl[i]=i/B; mx=bl[N-1];
	for(int i=0;i<=mx;i++)lp[i]=max(1,i*B),rp[i]=min(N-1,i*B+B-1);
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i],d[i]=a[i];
	sort(d+1,d+n+1);
	for(int i=1;i<=n;i++)a[i]=lower_bound(d+1,d+n+1,a[i])-d;
	for(int i=1,l,r,k;i<=m;i++)cin>>l>>r>>k,q[i]={l,r,k,bl[l],i};
	sort(q+1,q+m+1);
	for(int i=1,l=1,r=0;i<=m;i++){
		while(r<q[i].r)add(a[++r]);
		while(l>q[i].l)add(a[--l]);
		while(r>q[i].r)del(a[r--]);
		while(l<q[i].l)del(a[l++]);
		if(tot<q[i].k)res[q[i].id]=-1;
		else res[q[i].id]=query(q[i].k);
	}
	for(int i=1;i<=m;i++)printf("%d\n",res[i]);
	return 0;
}

*X. P7708「Wdsr-2.7」八云蓝自动机 Ⅰ

一道莫队好题。私以为本题最有价值的地方在于对单点修改的转化以及对交换两个数的处理:需要维护原来每个位置现在的位置,以及现在每个位置原来的位置。

注意到这个单点修改并不方便实现(如果是单点加就好做了),我们可以使用一个小技巧将其转化为交换两个数,即新建一个 \(a_c=k\),并将其看做 \(a_x\) 与 \(a_c\) 交换。这一步非常巧妙,因为它消灭了单点修改这一麻烦的操作。

对于多次询问一段区间的操作结果,我们通常需要使用莫队实现,因此考虑区间在伸缩时需要维护什么东西。为了支持在操作序列最前面加入交换两个数的操作,我们不难想到维护:

  • 序列 \(a\) 在操作后变成了什么样。

  • \(pos_i\) 表示现位置 \(i\) 是原来的哪个位置。

  • \(rev_i\) 表示原位置 \(i\) 现在在哪。

  • \(add_i\) 表示原位置 \(i\) 上的数被查询了多少次。

  • 当右端点右移 \(r-1\to r\) 时:

    • 若第 \(r\) 个操作是交换 \(x,y\),则交换 \(a_x,a_y\),\(pos_x,pos_y\),\(rev_{pos_x},rev_{pos_y}\)。
    • 若第 \(r\) 个操作是查询 \(x\),则令 \(ans\gets ans+a_x\),\(add_{pos_x}\gets add_{pos_x}+1\)。
  • 当左端点左移 \(l+1\to l\) 时:

    • 若第 \(l\) 个操作是交换 \(x,y\),注意我们相当于交换 “原位置” 的两个数,因此对答案有影响。令 \(del=a_{rev_y}-a_{rev_x}\),答案需加上 \(del\times (add_x-add_y)\),即计算原来的 \(a_x,a_y\) 即现在的 \(a_{rev_x},a_{rev_y}\) 在交换后的贡献变化量。此外,交换 \(a_{rev_x},a_{rev_y}\) \(add_x,add_y\),\(rev_x,rev_y\) 以及 \(pos_{rev_x},pos_{rev_y}\)。
    • 若第 \(l\) 个操作是查询 \(x\),则令 \(ans\gets ans+a_{rev_x}\),\(add_x\gets add_x+1\),意义显然。

右端点左移和左端点右移的情况分别与上述两种情况相似,不再赘述。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。

const int N=4e5+5;
const int B=666;
uint n,m,q,cnt,a[N],id[N],op[N],x[N],y[N];
struct query{
	int l,r,blk,id;
	bool operator < (const query &v) const {
		return blk!=v.blk?blk<v.blk:blk&1?r>v.r:r<v.r; 
	}
}c[N];

uint ans,res[N],pos[N],rev[N],add[N];
int main(){
	cin>>n>>m,cnt=n;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=m;i++){
		cin>>op[i]>>x[i]; if(op[i]!=3)cin>>y[i];
		if(op[i]==1)a[++cnt]=y[i],op[i]=2,y[i]=cnt;
	}
	for(int i=1;i<=cnt;i++)pos[i]=rev[i]=i;
	cin>>q;
	for(int i=1,l,r;i<=q;i++)cin>>l>>r,c[i]={l,r,l/B,i};
	sort(c+1,c+q+1);
	for(int i=1,l=1,r=0;i<=q;i++){
		while(r<c[i].r){
			r++;
			if(op[r]==2){
				swap(pos[x[r]],pos[y[r]]),swap(a[x[r]],a[y[r]]);
				swap(rev[pos[x[r]]],rev[pos[y[r]]]);
			}
			else ans+=a[x[r]],add[pos[x[r]]]++;
		}
		while(l>c[i].l){
			l--;
			if(op[l]==2){
				uint del=a[rev[y[l]]]-a[rev[x[l]]];
				swap(rev[x[l]],rev[y[l]]),ans+=(add[x[l]]-add[y[l]])*del;
				swap(a[rev[x[l]]],a[rev[y[l]]]);
				swap(pos[rev[x[l]]],pos[rev[y[l]]]),swap(add[x[l]],add[y[l]]);
			}
			else ans+=a[rev[x[l]]],add[x[l]]++;
		}
		while(r>c[i].r){
			if(op[r]==2){
				swap(pos[x[r]],pos[y[r]]),swap(a[x[r]],a[y[r]]);
				swap(rev[pos[x[r]]],rev[pos[y[r]]]);
			}
			else ans-=a[x[r]],add[pos[x[r]]]--;
			r--;
		}
		while(l<c[i].l){
			if(op[l]==2){
				uint del=a[rev[y[l]]]-a[rev[x[l]]];
				swap(rev[x[l]],rev[y[l]]),ans+=(add[x[l]]-add[y[l]])*del;
				swap(a[rev[x[l]]],a[rev[y[l]]]);
				swap(pos[rev[x[l]]],pos[rev[y[l]]]),swap(add[x[l]],add[y[l]]);
			}
			else ans-=a[rev[x[l]]],add[x[l]]--;
			l++;
		}
		res[c[i].id]=ans;
	}
	for(int i=1;i<=q;i++)cout<<res[i]<<"\n";
	return 0;
}
上一篇:loj6277~6285.分块入门九讲


下一篇:C++语言导学 第三章 模块化 - 3.1 引言