AfricanSort - 题解
标签与难度
标签: 动态规划, 概率与期望, 数学, 置换群, 组合数学, 逆元 难度: 2200
题目大意喵~
主人你好呀,喵~ 这道题是说,我们有一个长度为 的数字排列(也就是 到 不重复地出现一次)。我们的目标是把它变成 1, 2, 3, ..., n 这样的有序状态,喵。
为了实现这个目标,我们可以使用一种很看运气的“非洲式排序法”:
- 选择任意一些位置,构成一个集合 。
- 花费 (也就是集合大小)的代价。
- 把这些位置上的数字进行一次完全随机的洗牌(均匀打乱)。
我们需要为每一个给定的排列,找出一种最优的策略,使得排序成功的期望总花费最小。最后的结果要对 取模哦,呐。
简单来说,就是:怎么花最少的钱(期望上),把乱七八糟的数字排列整整齐齐呢?
解题思路分析
这道题看起来有点吓人,又是期望又是最优决策的,但别怕,让我带你一步步解开它的神秘面纱,喵~
第一步:看透排列的本质——环!
一个排列,最自然的分解方式就是把它看成若干个互不相交的置换环。
什么意思呢?举个栗子!如果排列 p 是 [2, 5, 1, 3, 4],我们可以画一个图:
- 从位置 1 出发,
p[1]=2,所以我们画一条1 -> 2的边。 - 从位置 2 出发,
p[2]=5,所以2 -> 5。 - 从位置 5 出发,
p[5]=4,所以5 -> 4。 - 从位置 4 出发,
p[4]=3,所以4 -> 3。 - 从位置 3 出发,
p[3]=1,所以3 -> 1。哎呀,回到起点了!这就形成了一个环:1 -> 2 -> 5 -> 4 -> 3 -> 1。
一个数字 i 如果在它应该在的位置上(即 p[i] = i),那它自己就构成一个长度为 1 的小环。我们的最终目标,就是把所有数字都变成长度为 1 的环!
第二步:化整为零,各个击破!
我们发现,这些置换环是互相独立的。对一个环里的元素进行操作,并不会影响到另一个环里的元素(只要我们选择的下标 都来自同一个环)。如果选择的下标来自不同的环,反而可能会把它们合并成一个更大的环,这离我们的目标(全部分解成长度为1的环)就更远了,喵!
所以,一个聪明的策略是:一次只对一个环进行操作。
这样问题就简化了!总的期望花费,就等于把每个环单独排序的期望花费之和。
现在,我们的核心问题就变成了:对于一个长度为 的环,把它完全排好序(分解成 个长度为 1 的环)的最小期望花费是多少呢?我们把这个值记为 。
第三步:建立期望 DP 方程
我们来考虑一个长度为 的环 ()。为了打破这个环,我们至少要对它进行一次操作。
选择什么样的集合 是最优的呢?一个非常直观且强大的猜想是:选择这个环中的全部 个元素进行洗牌。
为什么呢?因为这样可以用 的代价,最大程度地“扰乱”这个环的结构,让它最有希望能分解成更多、更小的环。如果只选一部分,比如 个元素,我们花了 的代价,但还有 个元素的位置关系被固定了,感觉有点亏呀,喵~
我们就顺着这个“最优策略是操作整个环”的思路走下去!
- 花费:我们选择环上全部 个元素,花费为 。
- 结果:这 个元素被随机打乱,形成了一个新的、长度为 的随机排列。
- 未来期望:这个新的随机排列也由若干个环组成。我们需要继续排序这些新环,直到全部搞定。这部分的期望花费就是 。
于是,我们得到了 的递推关系:
根据我们之前的“化整为零”策略,排序一个排列的期望花费是其所有环的期望花费之和。利用期望的线性性,我们可以把求和和期望交换一下:
这里需要一个组合数学里的小知识点:在一个 个元素的随机排列中,长度为 的环的期望数量是 。是不是很神奇,喵~
所以,我们的递推式就变成了:
这个式子里,左右两边都有 ,我们需要把它解出来:
边界条件是 ,因为长度为 1 的环已经排好了。
太棒啦!我们得到了一个可以计算的递推式!我们可以预处理出所有 的值(从 到 )。 为了方便计算,我们可以维护一个前缀和 sum_val 。当计算 时,直接用 sum_val,算出 后,再把 加到 sum_val 中,为下一次计算做准备。
最终算法流程
- 预处理:利用上面的递推式,计算出 的值,并存起来。别忘了处理取模和求逆元哦。
- 处理查询:对于给定的 个排列中的每一个: a. 初始化总花费
total_cost = 0,并用一个visited数组来标记访问过的位置。 b. 遍历i从 1 到 。 c. 如果i还没被访问过,说明我们发现了一个新的环。从i开始沿着p[i],p[p[i]], ... 走,直到回到i,同时计数环的长度len,并把路径上的点都标记为visited。 d. 找到了一个长度为len的环,就把预处理好的E[len]加到total_cost上。 e. 所有位置都访问完后,total_cost就是这个排列的答案啦!
这样,我们就能高效地解决问题了,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码,注释超详细的,希望能帮到你哟!
#include <iostream>
#include <vector>
#include <numeric>
// 定义一个很常用的模数,喵~
const int MOD = 998244353;
// 快速幂函数,用来求逆元,a^b % MOD
long long power(long long base, long long exp) {
long long res = 1;
base %= MOD;
while (exp > 0) {
if (exp % 2 == 1) res = (res * base) % MOD;
base = (base * base) % MOD;
exp /= 2;
}
return res;
}
// 求一个数的模逆元
long long modInverse(long long n) {
return power(n, MOD - 2);
}
// 存储每个长度的环的期望花费
std::vector<long long> expected_cost;
// 预处理 E_k 的值
void precompute_costs(int n) {
if (n < 2) return;
// E_k = k/(k-1) * (k + sum_{j=1}^{k-1} E_j/j)
// sum_val 存储 sum_{j=1}^{k-1} E_j/j
long long sum_val = 0; // E_1 = 0, so E_1/1 = 0
for (int k = 2; k <= n; ++k) {
long long k_inv = modInverse(k);
long long k_minus_1_inv = modInverse(k - 1);
// 计算 E_k
long long term1 = (k + sum_val + MOD) % MOD;
long long term2 = (k * k_minus_1_inv) % MOD;
long long E_k = (term1 * term2) % MOD;
expected_cost[k] = E_k;
// 更新 sum_val,为计算 E_{k+1} 做准备
long long E_k_div_k = (E_k * k_inv) % MOD;
sum_val = (sum_val + E_k_div_k) % MOD;
}
}
void solve() {
int n, m;
std::cin >> n >> m;
// 预处理期望花费数组
expected_cost.assign(n + 1, 0);
precompute_costs(n);
std::vector<int> p(n + 1);
std::vector<bool> visited(n + 1);
for (int i = 0; i < m; ++i) {
for (int j = 1; j <= n; ++j) {
std::cin >> p[j];
visited[j] = false;
}
long long total_expected_cost = 0;
// 寻找并处理每一个环
for (int j = 1; j <= n; ++j) {
if (!visited[j]) {
int current_node = j;
int cycle_len = 0;
// 遍历环,计算长度并标记
while (!visited[current_node]) {
visited[current_node] = true;
cycle_len++;
current_node = p[current_node];
}
// 长度为1的环花费为0,已经初始化了
if (cycle_len > 1) {
total_expected_cost = (total_expected_cost + expected_cost[cycle_len]) % MOD;
}
}
}
std::cout << total_expected_cost << "\n";
}
}
int main() {
// 加速输入输出,让我跑得更快!
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
solve();
return 0;
}复杂度分析
时间复杂度:
- 预处理
expected_cost数组需要一个从 2 到 的循环,每次循环内部都是常数次模运算和求逆元(如果线性预处理逆元就是 ,每次快速幂是 ,但我们这里递推求逆元也是 )。所以预处理部分是 。 - 之后有 个查询。对于每个查询,我们需要遍历整个排列来寻找环。由于每个元素只会被访问一次,所以找环的复杂度是 。
- 因此,总时间复杂度是 。
- 预处理
空间复杂度:
- 我们需要大小为 的数组来存储排列
p、visited状态和预处理的expected_cost。所以空间复杂度是 ,喵~
- 我们需要大小为 的数组来存储排列
知识点总结
这道题真是一次有趣的冒险,我们用到了不少好玩的工具呢!
- 置换环分解: 解决排列问题的基本操作,能将复杂问题分解成独立子问题。
- 期望的线性性: 一个超级强大的性质!,它让我们能把一个复杂状态的期望分解成多个简单状态的期望之和,即使这些状态不独立!
- 期望DP: 建立关于期望值的递推关系。关键是想清楚当前一步的决策、花费,以及这一步之后会转移到哪些状态,以及这些状态的期望花费。
- 组合数学知识: “随机k-排列中长度为j的环的期望数量为 ” 这个结论是解题的钥匙。
- 模块化编程: 先预处理,再查询,是一种常见的解题模式,可以避免重复计算。
- 模数运算: 快速幂求逆元是必备技能,喵~
希望这篇题解能帮到你,如果还有问题,随时可以再来问我哦!一起加油,喵~!