【题解】P8338 [AHOI2022] 排列
一道好的思维题!
题目链接
题意概述
对于一个长度为 \(n\) 的排列 \(P = (p_1, p_2, \ldots, p_n)\) 和整数 \(k \ge 0\),定义 \(P\) 的 \(k\) 次幂
该排列的第 \(i\) 项为
容易证明任意排列的任意次幂都是一个排列。
定义排列 \(P\) 的循环值 \(v(P)\) 为最小的正整数 \(k\) 使得 \(P^{(k + 1)} = P\)。
给出一个长度为 \(n\) 的排列 \(A = (a_1, a_2, \ldots, a_n)\),对于整数 \(1 \le i, j \le n\),定义 \(f(i, j)\):若存在 \(k \ge 0\) 使得 \(a^{(k)}_i = j\),则 \(f(i, j) = 0\),否则设排列 \(A_{i j}\) 为将排列 \(A\) 的第 \(i\) 项 \(a_i\) 和第 \(j\) 项 \(a_j\) 交换后得到的排列,则 \(f(i, j) = v(A_{i j})\)。
求 \(\sum_{i = 1}^{n} \sum_{j = 1}^{n} f(i, j)\) 的值。答案可能很大,你只需要输出其对 \(({10}^9 + 7)\) 取模的结果。
思路分析
首先我们可以考虑一个 OI 中比较常见的技巧——化数为图,在这道题里的具体体现就是,对于输入的 \(a_i\),我们连一条 \(i\) 到 \(a_i\) 的有向边。
那么其实可以发现,整张图是若干个环构成的。
根据题意我们可以知道,\({a_i}^{(k)}=j\) 其实就是相当于在图上从 \(i\) 出发走了 \(k\) 步走到了 \(j\)。
由于题目中要求的是 \(\sum \limits_{i=1}^n\sum \limits_{j=1}^n f(i,j)\) 那么我们为了求这个式子,可以先考虑把 \(f(i,j)\) 单独拿出来,看它怎么求。
根据题意可以分类讨论:
-
当 \(i,j\) 在同一个环中时:
说明 \(i\) 可以在图上到达 \(j\),即 \({a_i}^{(k)}=j\),此时 \(f(i,j)=0\);
这里可以利用并查集直接判环。
-
当 \(i,j\) 不在同一个环上时:
根据题意,我们需要交换 \(i\) 和 \(j\) 并求出交换后的序列的循环值。
那么我们应该如何求出一个序列的循环值呢?
由于我们已经将这个序列转化为图,所以我们可以考虑直接在图上求解。
假如图上有 \(k\) 个环,大小分别是 \(sz_1,sz_2,sz_3,\cdots,sz_k\)。
根据循环值的定义,是序列经过变换之后回到原序列所用的步数。
对应在图上其实就是 \(\operatorname{lcm}(sz_1,sz_2,sz_3,\cdots,sz_k)\)。
这个很好理解,其实就相当于让每一个环都同时回到最初的起点所用的最短时间嘛。
那么我们就解决了在图上求序列循环值的问题。
现在我们在回过头来看刚刚要解决的问题,发现我们要求的是原图上交换 \(i\) 和 \(j\) 之后的循环值,而非原图循环值。
那么我们就要弄清楚:交换 \(i\) 和 \(j\) 之后,新图和原图发生了什么变化?
别急,我们先画个图看看!
假如这是原图,我们现在要交换 \(2\) 和 \(5\),那么实际上就是将原图中的 \(1 \to 2\) 和 \(5 \to 6\) 改成
\(1 \to 5\) 和 \(2 \to 6\),如下:
我们发现,以前的两个环合并成了一个环长为以前两个环之和的大环!
那么是不是所有的交换都满足这样的规律呢?
实际上是的,证明如下:
假设 \(i\) 的前驱是 \(pre_i\),后继是 \(nxt_i\),原来的两个环是 \(pre_i\to i \to nxt_i \to \cdots \to pre_i\) 和 \(pre_j \to j \to nxt_j \to \cdots pre_i\),那么交换 \(i\) 和 \(j\) 之后,相当于把 \(i \to nxt_i\) 和 \(j \to nxt_j\) 变成了 \(i \to nxt_j\) 和 \(j \to nxt_i\) 那么变换之后的新环是 \(pre_i\to i \to nxt_j \to \cdots \to pre_j \to j \to nxt_i \to \cdots pre_i\)。
那么我们直接在变换后的新环上求新的循环值即可。
我们现在已经知道了如何求单个 \(f(i,j)\),那么如何求出整个 \(\sum \limits_{i=1}^n \sum \limits_{j=1}^n f(i,j)\) 呢。
如果直接暴力,总复杂度是 \(O(n^3 \log n)\) 的,期望得分 40 分。
考虑优化。
首先考虑,两个 \(f(i,j)\) 在什么情况下相同。
不难看出,对于两个 \(f(a,b)\) 和 \(f(x,y)\),当 \(a\) 和 \(x\) 处在同一个环上,且 \(x\) 和 \(y\) 处在同一个环上时,\(f(a,b)=f(x,y)\)。
那么我们可以直接对于环考虑,假设图中一共有 \(k\) 个环,答案就可以转化为 \(\sum \limits_{i=1}^k \sum \limits_{j=1}^k f(i,j)\) (其中 \(i,j\) 表示的都是具体的哪个环)。
但是这样做在最坏情况下还可能会退化成 \(n^2\) 级别的复杂度。
这时候就要用到一个常见的技巧:
若存在 \(\sum a_i=n\),则本质不同的 \(a_i\) 最多有 \(\sqrt{n}\) 种。
证明很简单,当 \(\sum a_i=n\) 时,如果把序列 \(A\) 按照从小到大排序,最坏情况下 \(a_1=1,a_2=2,a_3=3,\cdots\) 这种情况下 \(a_n\) 最大是 \(\sqrt{n}\) 级别的。
那么我们只需要枚举本质不同的环长,假设本质不同的环长有 \(m\) 个,问题就转化为 \(\sum \limits_{i=1}^m \sum \limits_{j=1}^m f(i,j)\)(其中 \(i,j\) 表示本质不同的环长)。
此时枚举的复杂度由 \(n^2\) 变成了线性。
设 \(w(i,j)\) 表示合并了环长 \(i\) 和环长 \(j\) 之后排列的循环值,我们考虑它对答案产生了多少贡献。
设 \(cnt_i\) 表示整张图中有多少个环长为 \(i\) 的环,分类讨论:
-
当 \(i=j\) 时:
首先要满足 \(cnt_i \ge 2\),只有大于等于 \(2\) 个环才能交换。根据乘法原理,此时对答案的贡献是:\(i\times cnt_i \times i \times (cnt_i-1)\)。
-
当 \(i \ne j\) 时:
对答案产生的贡献为:\(i\times cnt_i \times j \times cnt_j\)。
然后我们暴力枚举 \(i,j\) 求解 \(w(i,j)\),一个优化常数的小技巧是,枚举一个 \(i\) 时,可以直接从 \(j+1\) 开始枚举,最后结果乘 \(2\) 即可。
此时,时间复杂度为 \(O(n\times m \log n)=O(n\sqrt n \log n)\)。期望得分 80 分。
梳理一下目前的思路会发现,现在时间复杂度的瓶颈就在于我们每次求 \(w(i,j)\) 的时候,要重新求一遍得到的新的图上所有环长的 \(\operatorname{lcm}\),非常麻烦。
那么如何优化呢?
思考一下便可以发现,我们这一步实际上要维护一个原来的 \(\operatorname{lcm}\) 删除其中两个数,增加一个新的数之后的新的 \(\operatorname{lcm}\) 的操作。
乍一看似乎很难维护,那么这时候我们就需要抛开问题的表面,然后去探求 \(\operatorname{lcm}\) 的本质。
我们知道,对于一个序列 \(a\) 中的一个元素 \(a_i\),假如它的因式分解形式是:
那么这个序列中所有的数的 \(\operatorname{lcm}\) 就为:
这给我们一个启发:可以去考虑处理出来序列中所有数的因式分解形式,然后利用这个来维护 \(\operatorname{lcm}\)。
这里就用到一个常用的小技巧:
要处理出来 \(1-n\) 内的所有数的质因子,可以先用欧拉筛预处理出 \(1-n\) 范围内的所有数的最小质因子。
然后对于每一个数 \(num\),我们先给它除以它本身的最小质因子 \(p1\),得到它除自身外的最大因子 \(pr1\),然后再除以 \(pr1\) 的最小质因子 \(p2\),得到 \(num\) 除自身外第二大因子 \(pr2\),以此类推……把处理出来的所有质因子都直接存到 \(num\) 的质因子集合中。
枚举 \(1-n\) 的每一个数,都这样做一遍就可以处理出来所有的质因子集合。
时间复杂度 \(O(n \log n)\)。
到这里,问题就转化成了:
已知一些数的质因子分解形式,然后要从中删掉两个数,增加一个数之后, 最后求得的 \(\operatorname{lcm}\)(所有剩下的数的最大质因子幂之积)是多少。
有一个比较易懂的做法就是:我们只需要维护原始集合下所有数中每个质因子最大的 \(3\) 个幂,因为你每次只会最多对三个数进行更改,后面的不会影响到新的 \(\operatorname{lcm}\)。
然后对于每次的操作,假如你要删掉的是 \(a\) 和 \(b\),增加的是 \(c\),那么你首先就在原先的质因子集合中,首先判断 \(a\) 和 \(b\) 对应的每一个质因子,是否在前三大幂的质因子集合中。如果在,则删掉;如果不在,则直接不用做任何操作。接着,再将对于 \(c\) 的每一个质因子,判断它对应的幂是否能加入到新的前三大幂的集合中,如果可以,则加入,如果不行,同样不进行任何操作。
这样暴力的加加减减之后,形成的新的质因子集合中的每个数的最大幂的积就是最后的本次询问的 \(\operatorname{lcm}\)。
注意:每次求完新的 \(\operatorname{lcm}\) 之后,还要把质因子集合还原为以前的质因子集合,因为每次操作都是对于最原始的那张图而言的。
总时间复杂度 \(O( n \log n)\)。期望得分 100 分。
最后这里的代码实现难度很大,所以我专门在这里放了一下这部分的代码:
#define int long long
#define mk make_pair
using namespace std;
const int maxn=5e5+10;
const int mod=1e9+7;
int a[maxn],fa[maxn];//a 表示输入序列,fa 表示并查集上每个点的祖先。
int vis[maxn],p[maxn];//vis 存储的是每个数的最小质因子,p 存储的是 5e5 内的质因子。
int pcnt[maxn];//pcnt 用来存储每个数有多少个质因子。
int sz[maxn],c[maxn];//sz 用来存储每一个环的大小,c 用来记录每一个环长有多少个。
int huan[maxn];//环长集合。
int inv[maxn],cntp[maxn];//计数器
int cnt,ans,lcm;
vector<pair<int,int> >pri[maxn],g[maxn];
//pri 存储每个数的具体质因子(first)以及幂(second),g 是个临时动态数组,存储的是当前这个数被删/加了多少次。
vector<int>f[maxn];//f 表示的是每个质因子有哪些幂。
void insert(int x)//插入新加入的数的 lcm
{
for(auto y:pri[x])
{
int p=y.first,q=y.second;
f[p].push_back(q);
sort(f[p].begin(),f[p].end(),cmp);
if(f[p].size()>3)f[p].pop_back();
}
return ;
}
int get(int x)//得到新的最大幂。
{
int ret=1;
for(int y:f[x])cntp[y]++;
for(auto y:g[x])cntp[y.first]+=y.second;
for(int y:f[x])
{
if(cntp[y])ret=max(ret,y),cntp[y]=0;
}
for(auto y:g[x])
{
if(cntp[y.first])ret=max(ret,y.first),cntp[y.first]=0;
}
return ret;
}
void upd(int x,int num)//更新 lcm
{
for(auto y:pri[x])
{
int p=y.first,q=y.second;
(lcm*=inv[get(p)])%=mod;
g[p].push_back(mk(q,num));
(lcm*=get(p))%=mod;
}
}
void clearg(int x)//最后做完之后要清零。
{
for(auto y:pri[x])g[y.first].clear();
}
//主函数内
//初始化 lcm
lcm=1;
for(int i=1;i<=n;i++)
{
if(f[i].empty())continue;
(lcm*=f[i][0])%=mod;
}
//i=j 的情况。
for(int i=1;i<=cnt;i++)
{
if(c[huan[i]]<=1)continue;
int tt=lcm;
upd(huan[i]+huan[i],1);
upd(huan[i],-2);
(ans+=lcm*huan[i]%mod*c[huan[i]]%mod*(c[huan[i]]-1)%mod*(huan[i])%mod)%=mod;
clearg(huan[i]+huan[i]);
lcm=tt;
}
//i!=j 的情况。
for(int i=1;i<=cnt;i++)
{
for(int j=i+1;j<=cnt;j++)
{
int tt=lcm;
upd(huan[i]+huan[j],1);
upd(huan[i],-1);
upd(huan[j],-1);
(ans+=2*lcm%mod*huan[i]%mod*c[huan[i]]%mod*c[huan[j]]%mod*huan[j]%mod)%=mod;
clearg(huan[i]+huan[j]);
clearg(huan[i]);clearg(huan[j]);
lcm=tt;
}
}
总的梳理一下,我们的思考过程大概就是:
化数为图 \(\to\) 转化为所有环的 \(\operatorname{lcm}\) \(\to\) 发现交换两个数之后会把两个小的环合并为一个新的大环 \(\to\) 发现本质不同的环长最多只有 \(\sqrt n\) 种 \(\to\) 刨开 \(\operatorname{lcm}\) 的本质利用质因子分解的办法优化求 \(\operatorname{lcm}\) 的过程。
实现步骤
-
预处理出来 \(1-n\) 以内所有的质因子集合。
-
读入,并利用并查集化数为图;
-
预处理出来原 \(\operatorname{lcm}\);
-
处理出来初始的质因子最大的 \(3\) 个幂集合;
-
分类讨论,枚举环长,分别对于 \(i=j\) 和 \(i \ne j\) 分别求解并乘上对应贡献。
代码实现
//luoguP8338
//remake.
#include<cstdio>
#include<iostream>
#include<vector>
#include<algorithm>
#include<cstring>
#define int long long
#define mk make_pair
using namespace std;
const int maxn=5e5+10;
const int mod=1e9+7;
int a[maxn],fa[maxn];//a 表示输入序列,fa 表示并查集上每个点的祖先。
int vis[maxn],p[maxn];//vis 存储的是每个数的最小质因子,p 存储的是 5e5 内的质因子。
int pcnt[maxn];//pcnt 用来存储每个数有多少个质因子。
int sz[maxn],c[maxn];//sz 用来存储每一个环的大小,c 用来记录每一个环长有多少个。
int huan[maxn];//环长集合。
int inv[maxn],cntp[maxn];//计数器
int cnt,ans,lcm;
vector<pair<int,int> >pri[maxn],g[maxn];
//pri 存储每个数的具体质因子(first)以及幂(second),g 是个临时动态数组,存储的是当前这个数被删/加了多少次。
vector<int>f[maxn];//f 表示的是每个质因子有哪些幂。
inline int read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
int cmp(int a,int b){return a>b;}
void init()
{
inv[0]=inv[1]=1;
for(int i=2;i<=500000;i++)inv[i]=(mod-mod/i)*inv[mod%i]%mod;
int cnt=0;
for(int i=2;i<=500000;i++)
{
if(!vis[i]){vis[i]=i;p[++cnt]=i;}
for(int j=1;j<=cnt&&i*p[j]<=500000;j++)
{
if(p[j]>vis[i])break;
vis[i*p[j]]=p[j];
}
}
for(int i=2;i<=500000;i++)
{
int t=i;
while(t>1)
{
int now=vis[t];
int val=1;
while(!(t%now))
{
val*=now;
t/=now;
}
pri[i].push_back(mk(now,val));
}
}
}
int find(int x)
{
if(x!=fa[x])fa[x]=find(fa[x]);
return fa[x];
}
void add(int x,int y)
{
x=find(x);y=find(y);
if(x==y)return ;
if(sz[x]>sz[y])swap(x,y);//启发式合并。
fa[x]=y;
sz[y]+=sz[x];
}
void insert(int x)
{
for(auto y:pri[x])
{
int p=y.first,q=y.second;
f[p].push_back(q);
sort(f[p].begin(),f[p].end(),cmp);
if(f[p].size()>3)f[p].pop_back();
}
return ;
}
int get(int x)
{
int ret=1;
for(int y:f[x])cntp[y]++;
for(auto y:g[x])cntp[y.first]+=y.second;
for(int y:f[x])
{
if(cntp[y])ret=max(ret,y),cntp[y]=0;
}
for(auto y:g[x])
{
if(cntp[y.first])ret=max(ret,y.first),cntp[y.first]=0;
}
return ret;
}
void upd(int x,int num)
{
for(auto y:pri[x])
{
int p=y.first,q=y.second;
(lcm*=inv[get(p)])%=mod;
g[p].push_back(mk(q,num));
(lcm*=get(p))%=mod;
}
}
void clearg(int x)
{
for(auto y:pri[x])g[y.first].clear();
}
signed main()
{
// freopen("data.in","r",stdin);
// freopen("data.out","w",stdout);
init();//预处理 1-n 每个数的质因子。(nlogn 分解质因数)
int T=read();
while(T--)
{
ans=0;
int n;
n=read();
memset(c,0,sizeof(c));
memset(huan,0,sizeof(huan));
for(int i=1;i<=n;i++)f[i].clear();
for(int i=1;i<=n;i++)fa[i]=i,sz[i]=1;//初始化 fa 数组。
for(int i=1;i<=n;i++)
{
a[i]=read();
add(i,a[i]);//每次将 i 与 a[i] 连边(合并)
}
// for(int i=1;i<=n;i++)
// {
// cout<<a[i]<<endl;
// for(auto y:pri[a[i]])
// {
// cout<<y.second<<" ";
// }
// cout<<endl;
// }
cnt=0;//cnt 表示的是有多少种不同的环长。
for(int i=1;i<=n;i++)
{
if(fa[i]==i)//如果它本身就是祖先。
{
if(!c[sz[i]])//原先环长集合中没有这个数,则将其加入环长集合。
{
huan[++cnt]=sz[i];
}
c[sz[i]]++;
insert(sz[i]);
}
}
//初始化 lcm
lcm=1;
for(int i=1;i<=n;i++)
{
if(f[i].empty())continue;
(lcm*=f[i][0])%=mod;
}
//i=j 的情况。
for(int i=1;i<=cnt;i++)
{
if(c[huan[i]]<=1)continue;
int tt=lcm;
upd(huan[i]+huan[i],1);
upd(huan[i],-2);
(ans+=lcm*huan[i]%mod*c[huan[i]]%mod*(c[huan[i]]-1)%mod*(huan[i])%mod)%=mod;
clearg(huan[i]+huan[i]);
lcm=tt;
}
for(int i=1;i<=cnt;i++)
{
for(int j=i+1;j<=cnt;j++)
{
int tt=lcm;
upd(huan[i]+huan[j],1);
upd(huan[i],-1);
upd(huan[j],-1);
(ans+=2*lcm%mod*huan[i]%mod*c[huan[i]]%mod*c[huan[j]]%mod*huan[j]%mod)%=mod;
clearg(huan[i]+huan[j]);
clearg(huan[i]);clearg(huan[j]);
lcm=tt;
}
}
cout<<ans<<endl;
}
}
/*重新梳理一下写题思路:
- 首先,预处理出来 1-5e5 范围内所有的数的质因子,具体做法:
- 先用欧拉筛跑一遍把 1-5e5 范围内的所有质因子预处理出来;
- 再枚举 1-5e5,然后每次除以这个数的最小质因子然后处理出来这个数所有的质因子。
- 其次,处理每一组数据,对于每一组输入的数据,让 i 与 a[i] 连边,
实际上只需要在并查集中直接将它们俩直接合并,并维护一下合并后的集合的大小,
这里为了更加优雅也降低时限,可以采用启发式合并处理集合大小。
- 然后处理出来所有的环长。
- 然后枚举每一个环长,直接把每一次得到的答案并入总答案中,具体处理方法如下:
- 假设每一个环长的答案是 tt。那么对于每一次枚举的环长 i 和 j:
- 若 i=j:则答案累加 sz[i]*c[i]*(c[i]-1)*sz[i]*tt;
- 若 i!=j:则答案累加 sz[i]*c[i]*sz[j]*c[j]*tt;
- 现在考虑每一个 tt 如何求:
首先要维护一个原来的所有环长的 lcm 集合,对于一个质因子 i 维护它的幂 j,可以用 pair 来实现。
并且只需要维护对于每一个质因子 i 而言它的前 3 大幂即可。
假设每次删除的是 i 和 j,那么每次从 lcm 集合中删除 i 和 j 一个 i 和 j 对应质因数的幂,
如果这个幂不在质因子对应的前 3 大集合里面可以直接将它忽略。
然后再插入一个 i+j,同理将 i+j 对应质因子的幂加入集合里。
*/
总结
这道题让我意识到了两点:
-
自己码力有点太弱了,,这种复杂的题要写好久。
-
我写了那么多题并不是没有用。因为这道题里面大多技巧都是我在其它各个地方见过的。
同时也让我探索到了一些适合自己的学习方法:
我并不是那种思维能力强的人,相反我可能是机房里面最笨的思维能力最差的人,但是我做了不少题目,见过很多套路和技巧,这使得我在赛场上不是完全无从下手。
也就是说,我需要在赛场上把题目转化为我做过的某一个或者某几个题目,通过记忆和联想从而在做过的旧题中得到启发,从而做出新题。
这也告诉我在剩下两个月的时间里,应该多见新题,多了解套路和技巧,而不要做太难太高深的题目,而应该回归基础算法,掌握更多,我原本就该掌握的东西。