Skip to content

AfricanSort - 题解

标签与难度

标签: 动态规划, 概率与期望, 数学, 置换群, 组合数学, 逆元 难度: 2200

题目大意喵~

主人你好呀,喵~ 这道题是说,我们有一个长度为 nn 的数字排列(也就是 11nn 不重复地出现一次)。我们的目标是把它变成 1, 2, 3, ..., n 这样的有序状态,喵。

为了实现这个目标,我们可以使用一种很看运气的“非洲式排序法”:

  1. 选择任意一些位置,构成一个集合 SS
  2. 花费 S|S|(也就是集合大小)的代价。
  3. 把这些位置上的数字进行一次完全随机的洗牌(均匀打乱)。

我们需要为每一个给定的排列,找出一种最优的策略,使得排序成功的期望总花费最小。最后的结果要对 998244353998244353 取模哦,呐。

简单来说,就是:怎么花最少的钱(期望上),把乱七八糟的数字排列整整齐齐呢?

解题思路分析

这道题看起来有点吓人,又是期望又是最优决策的,但别怕,让我带你一步步解开它的神秘面纱,喵~

第一步:看透排列的本质——环!

一个排列,最自然的分解方式就是把它看成若干个互不相交的置换环

什么意思呢?举个栗子!如果排列 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 的环!

第二步:化整为零,各个击破!

我们发现,这些置换环是互相独立的。对一个环里的元素进行操作,并不会影响到另一个环里的元素(只要我们选择的下标 SS 都来自同一个环)。如果选择的下标来自不同的环,反而可能会把它们合并成一个更大的环,这离我们的目标(全部分解成长度为1的环)就更远了,喵!

所以,一个聪明的策略是:一次只对一个环进行操作

这样问题就简化了!总的期望花费,就等于把每个环单独排序的期望花费之和

Etotal=环 CE(排序环 C)E_{total} = \sum_{\text{环 } C} E(\text{排序环 } C)

现在,我们的核心问题就变成了:对于一个长度为 kk 的环,把它完全排好序(分解成 kk 个长度为 1 的环)的最小期望花费是多少呢?我们把这个值记为 EkE_k

第三步:建立期望 DP 方程

我们来考虑一个长度为 kk 的环 (k>1k > 1)。为了打破这个环,我们至少要对它进行一次操作。

选择什么样的集合 SS 是最优的呢?一个非常直观且强大的猜想是:选择这个环中的全部 kk 个元素进行洗牌

为什么呢?因为这样可以用 kk 的代价,最大程度地“扰乱”这个环的结构,让它最有希望能分解成更多、更小的环。如果只选一部分,比如 s<ks < k 个元素,我们花了 ss 的代价,但还有 ksk-s 个元素的位置关系被固定了,感觉有点亏呀,喵~

我们就顺着这个“最优策略是操作整个环”的思路走下去!

  1. 花费:我们选择环上全部 kk 个元素,花费为 kk
  2. 结果:这 kk 个元素被随机打乱,形成了一个新的、长度为 kk 的随机排列。
  3. 未来期望:这个新的随机排列也由若干个环组成。我们需要继续排序这些新环,直到全部搞定。这部分的期望花费就是 E(排序一个随机k-排列)E(\text{排序一个随机k-排列})

于是,我们得到了 EkE_k 的递推关系:

Ek=k+E(排序一个随机k-排列的期望花费)E_k = k + E(\text{排序一个随机k-排列的期望花费})

根据我们之前的“化整为零”策略,排序一个排列的期望花费是其所有环的期望花费之和。利用期望的线性性,我们可以把求和和期望交换一下:

E(排序一个随机k-排列)=E[新环 CEC]=j=1kEj×(一个随机k-排列中,长度为j的环的期望数量)E(\text{排序一个随机k-排列}) = E\left[\sum_{\text{新环 } C'} E_{|C'|}\right] = \sum_{j=1}^{k} E_j \times (\text{一个随机k-排列中,长度为j的环的期望数量})

这里需要一个组合数学里的小知识点:在一个 kk 个元素的随机排列中,长度为 jj 的环的期望数量是 1/j1/j。是不是很神奇,喵~

所以,我们的递推式就变成了:

Ek=k+j=1k1jEjE_k = k + \sum_{j=1}^{k} \frac{1}{j} E_j

这个式子里,左右两边都有 EkE_k,我们需要把它解出来:

Ek1kEk=k+j=1k11jEjE_k - \frac{1}{k} E_k = k + \sum_{j=1}^{k-1} \frac{1}{j} E_j

Ek(11k)=k+j=1k11jEjE_k \left(1 - \frac{1}{k}\right) = k + \sum_{j=1}^{k-1} \frac{1}{j} E_j

Ek(k1k)=k+j=1k11jEjE_k \left(\frac{k-1}{k}\right) = k + \sum_{j=1}^{k-1} \frac{1}{j} E_j

Ek=kk1(k+j=1k11jEj)(对于 k2)E_k = \frac{k}{k-1} \left( k + \sum_{j=1}^{k-1} \frac{1}{j} E_j \right) \quad (\text{对于 } k \ge 2)

边界条件是 E1=0E_1 = 0,因为长度为 1 的环已经排好了。

太棒啦!我们得到了一个可以计算的递推式!我们可以预处理出所有 EkE_k 的值(从 k=2k=2nn)。 为了方便计算,我们可以维护一个前缀和 sum_val =j=1i1Ejj= \sum_{j=1}^{i-1} \frac{E_j}{j}。当计算 EiE_i 时,直接用 sum_val,算出 EiE_i 后,再把 Ei/iE_i/i 加到 sum_val 中,为下一次计算做准备。

最终算法流程

  1. 预处理:利用上面的递推式,计算出 E2,E3,,EnE_2, E_3, \dots, E_n 的值,并存起来。别忘了处理取模和求逆元哦。
  2. 处理查询:对于给定的 mm 个排列中的每一个: a. 初始化总花费 total_cost = 0,并用一个 visited 数组来标记访问过的位置。 b. 遍历 i 从 1 到 nn。 c. 如果 i 还没被访问过,说明我们发现了一个新的环。从 i 开始沿着 p[i], p[p[i]], ... 走,直到回到 i,同时计数环的长度 len,并把路径上的点都标记为 visited。 d. 找到了一个长度为 len 的环,就把预处理好的 E[len] 加到 total_cost 上。 e. 所有位置都访问完后,total_cost 就是这个排列的答案啦!

这样,我们就能高效地解决问题了,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码,注释超详细的,希望能帮到你哟!

cpp
#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;
}

复杂度分析

  • 时间复杂度: O(N+MN)O(N + M \cdot N)

    • 预处理 expected_cost 数组需要一个从 2 到 NN 的循环,每次循环内部都是常数次模运算和求逆元(如果线性预处理逆元就是 O(1)O(1),每次快速幂是 O(logMOD)O(\log MOD),但我们这里递推求逆元也是 O(1)O(1))。所以预处理部分是 O(N)O(N)
    • 之后有 MM 个查询。对于每个查询,我们需要遍历整个排列来寻找环。由于每个元素只会被访问一次,所以找环的复杂度是 O(N)O(N)
    • 因此,总时间复杂度是 O(N+MN)O(N + M \cdot N)
  • 空间复杂度: O(N)O(N)

    • 我们需要大小为 N+1N+1 的数组来存储排列 pvisited 状态和预处理的 expected_cost。所以空间复杂度是 O(N)O(N),喵~

知识点总结

这道题真是一次有趣的冒险,我们用到了不少好玩的工具呢!

  1. 置换环分解: 解决排列问题的基本操作,能将复杂问题分解成独立子问题。
  2. 期望的线性性: 一个超级强大的性质!E[X+Y]=E[X]+E[Y]E[X+Y] = E[X]+E[Y],它让我们能把一个复杂状态的期望分解成多个简单状态的期望之和,即使这些状态不独立!
  3. 期望DP: 建立关于期望值的递推关系。关键是想清楚当前一步的决策、花费,以及这一步之后会转移到哪些状态,以及这些状态的期望花费。
  4. 组合数学知识: “随机k-排列中长度为j的环的期望数量为 1/j1/j” 这个结论是解题的钥匙。
  5. 模块化编程: 先预处理,再查询,是一种常见的解题模式,可以避免重复计算。
  6. 模数运算: 快速幂求逆元是必备技能,喵~

希望这篇题解能帮到你,如果还有问题,随时可以再来问我哦!一起加油,喵~!