Skip to content

BogoSort - 题解

标签与难度

标签: 置换群, 循环分解, 最小公倍数(LCM), 高精度计算, 数论, 图论 难度: 2200

题目大意喵~

你好呀,指挥官!今天我们遇到了一个叫 Tonnnny 的可爱猴子,他“改进”了一下著名的 Bogo Sort(猴子排序)算法,喵~

原本的 Bogo Sort 是不断地随机打乱数组,直到数组有序为止。但 Tonnnny 不喜欢随机,所以他选了一个自己最喜欢的、长度为 NN 的固定排列 pp,用它来代替随机打乱。

Tonnnny 的 shuffle 操作是这样的:对于一个数组 a,新的数组 a_new 的第 i 个元素 a_new[i] 会被赋值为旧数组 a 的第 p[i] 个元素。他会重复这个操作,直到数组变得有序(即 a[0] <= a[1] <= ... <= a[N-1])为止。

我们作为 Tonnnny 的好朋友,想让他尽可能久地开心下去。所以,我们的任务是计算出,总共有多少个不同的初始数组,可以通过 Tonnnny 的排序算法最终变得有序。

因为答案可能会非常非常大,所以我们需要将结果对 10N10^N 取模。NN 最大可以到 1000 哦!

解题思路分析

这道题看起来有点绕,但别担心,跟着我的思路一步步来,很快就能理清啦,喵~

第一步:理解 Tonnnny 的排序过程

首先,我们来分析一下 Tonnnny 的 shuffle 操作。a_new[i] = a_old[p[i]]。这是一个确定性的操作。如果我们把初始数组叫做 a0a_0,经过一次 shuffle 后得到 a1a_1,再 shuffle 一次得到 a2a_2,以此类推。

一个初始数组 a0a_0 如果能被排序,就意味着在序列 a0,a1,a2,a_0, a_1, a_2, \dots 中,至少有一个数组 aka_k 是有序的。

aka_k 有序的条件是:对于所有的 i[1,N1]i \in [1, N-1],都有 ak[i1]ak[i]a_k[i-1] \le a_k[i]。 我们来追溯一下 aka_k 的元素和 a0a_0 的关系: a1[i]=a0[p[i]]a_1[i] = a_0[p[i]]a2[i]=a1[p[i]]=a0[p[p[i]]]a_2[i] = a_1[p[i]] = a_0[p[p[i]]] ... 经过 kk 次操作后,p 这个置换作用了 kk 次,我们记作 pkp^k。所以 ak[i]=a0[pk(i)]a_k[i] = a_0[p^k(i)]

于是,数组 a0a_0 能被排序,等价于存在一个非负整数 kk,使得对于所有 i[1,N1]i \in [1, N-1],都满足:

a0[pk(i1)]a0[pk(i)]a_0[p^k(i-1)] \le a_0[p^k(i)]

这个不等式链告诉我们,初始数组 a0a_0 的元素值,必须在按照 p^k 作用后的下标序列 (p^k(0), p^k(1), ..., p^k(N-1)) 上是单调不减的。

第二步:一个关键的简化!

题干中说“一个不同的数组”,但没有规定数组里的数必须是什么。如果我们可以从整数集合 Z\mathbb{Z} 中任意取值,那么满足 x <= y 的数组有无限多个,这样一来,可排序的数组也会有无限多个。这显然不是题目的本意,喵~

我们注意到,答案需要对 10N10^N 取模。这是一个非常强的暗示!它告诉我们,答案是一个具体的、非常大的整数。这引导我们去思考一个更受限制、但更典型的场景:如果初始数组是 {1, 2, ..., N} 的一个排列呢?

让我们来分析一下这个简化后的情况。 假设我们的数组包含的是 NN 个不同的值,不妨设为 v1<v2<<vNv_1 < v_2 < \dots < v_N。那么,一个有序的数组必须是 (v_1, v_2, ..., v_N)

如果一个初始排列 π (一个数组) 经过 k 次 shuffle 后变得有序,那就意味着:

ak[i]=vi+1(使用1-based索引)a_k[i] = v_{i+1} \quad (\text{使用1-based索引})

结合 ak[i]=a0[pk(i)]a_k[i] = a_0[p^k(i)],我们得到:

a0[pk(i)]=vi+1a_0[p^k(i)] = v_{i+1}

这说明,对于一个给定的 k,初始数组 a_0 是唯一确定的!它的第 j 个元素 a_0[j] 应该是哪个 vv 值呢?令 j=pk(i)j = p^k(i),则 i=(pk)1(j)i = (p^k)^{-1}(j),所以 a0[j]=v(pk)1(j)+1a_0[j] = v_{(p^k)^{-1}(j)+1}

这意味着,对于每个 k,都对应着一个可能的可排序的初始数组。这些数组会不会重复呢? 两个数组 a0(k1)a_0^{(k_1)}a0(k2)a_0^{(k_2)} 相同,当且仅当置换 (p1)k1(p^{-1})^{k_1}(p1)k2(p^{-1})^{k_2} 相同。这等价于 pk2k1p^{k_2-k_1} 是单位置换(即所有元素都映射到自身)。 这只有在 k2k1k_2-k_1 是置换 pp阶 (order) 的倍数时才会发生。

置换 pp,是指最小的正整数 m 使得 pmp^m 是单位置换。所以,在 k=0,1,2,,ord(p)1k = 0, 1, 2, \dots, \text{ord}(p)-1 的范围内,我们能得到 ord(p)\text{ord}(p)不同的可排序初始数组。

因此,问题的核心就转化为了求置换 p 的阶

第三步:计算置换的阶

一个置换的阶怎么求呢?这需要我们将置换分解成不相交的循环(disjoint cycles),喵~ 一个置huan的阶,等于它所有不相交循环的长度的最小公倍数 (LCM)

举个例子:如果 N=5N=5p=[2,3,1,5,4]p = [2, 3, 1, 5, 4] (1-indexed)。 我们来找循环:

  1. 从 1 开始:1p[1]=2p[2]=3p[3]=11 \to p[1]=2 \to p[2]=3 \to p[3]=1。这是一个长度为 3 的循环 (1 2 3)。
  2. 从 4 开始:4p[4]=5p[5]=44 \to p[4]=5 \to p[5]=4。这是一个长度为 2 的循环 (4 5)。

这个置换 pp 被分解成了两个循环,长度分别为 3 和 2。 那么 ord(p) = lcm(3, 2) = 6。 在这个例子里,就有 6 个不同的初始排列可以被 Tonnnny 排序。

第四步:处理大数计算

现在我们的任务清单很清晰了:

  1. 找到置换 p 的所有不相交循环的长度 L1,L2,,LmL_1, L_2, \dots, L_m
  2. 计算 lcm(L1,L2,,Lm)\text{lcm}(L_1, L_2, \dots, L_m)
  3. 将结果对 10N10^N 取模。

计算 LCM 的经典方法是利用质因数分解:

lcm(a,b,c,)=q is primeqmax(νq(a),νq(b),νq(c),)\text{lcm}(a, b, c, \dots) = \prod_{q \text{ is prime}} q^{\max(\nu_q(a), \nu_q(b), \nu_q(c), \dots)}

其中 νq(n)\nu_q(n)nn 的质因数分解中质数 qq 的指数。

所以,我们的算法是:

  1. 找出所有循环长度:用一个 visited 数组,遍历 0N-1,如果一个下标 i 没被访问过,就从它开始沿着 p 走,直到回到 i,记录下一个循环的长度。
  2. 质因数分解:对每个循环长度 LiL_i,进行质因数分解。
  3. 统计最大指数:用一个 map 或者哈希表 max_exponents 记录每个质数出现过的最大指数。例如,如果 L1=12=2231L_1=12=2^2 \cdot 3^1L2=18=2132L_2=18=2^1 \cdot 3^2,那么对于质数 2,最大指数是 2;对于质数 3,最大指数也是 2。
  4. 计算最终结果:最终的 LCM 就是所有 q^max_exponents[q] 的乘积。

由于 NN 高达 1000,模数是 10100010^{1000},这是一个非常大的数。我们需要用高精度计算(大数运算)来实现乘法。我们可以用 string 来存储大整数,然后手写一个大数乘法函数。每次乘法后,如果结果的位数超过 NN,我们只需要保留末尾的 NN 位即可,这正是对 10N10^N 取模的效果!

好啦,思路已经很清晰了,准备好和我一起实现它了吗?Let's go, meow!

代码实现

cpp
#include <iostream>
#include <vector>
#include <string>
#include <numeric>
#include <algorithm>
#include <map>

// 高精度乘法:大数(string) * 整数(int)
// 结果需要对 10^N 取模,通过只保留末尾N位实现
std::string big_num_multiply_int(const std::string& num_str, int factor, int N) {
    if (factor == 0) return "0";
    if (factor == 1) return num_str;

    std::string result = "";
    int carry = 0;
    // 从低位到高位(字符串末尾到开头)计算
    for (int i = num_str.length() - 1; i >= 0; --i) {
        int digit = num_str[i] - '0';
        int temp = digit * factor + carry;
        result += std::to_string(temp % 10);
        carry = temp / 10;
    }
    while (carry > 0) {
        result += std::to_string(carry % 10);
        carry /= 10;
    }
    
    // 翻转得到正常顺序的数
    std::reverse(result.begin(), result.end());

    // 对 10^N 取模,即截取末尾 N 位
    if (result.length() > N) {
        return result.substr(result.length() - N);
    }
    return result;
}

// 高精度乘法:大数(string) * 大数(string)
// 结果需要对 10^N 取模
std::string big_num_multiply_str(const std::string& num1, const std::string& num2, int N) {
    if (num1 == "0" || num2 == "0") return "0";

    int len1 = num1.length();
    int len2 = num2.length();
    // 结果最多 len1 + len2 位
    std::vector<int> res_vec(len1 + len2, 0);

    for (int i = len1 - 1; i >= 0; i--) {
        for (int j = len2 - 1; j >= 0; j--) {
            int mul = (num1[i] - '0') * (num2[j] - '0');
            // 乘积加到结果的对应位置
            int sum = res_vec[i + j + 1] + mul;
            res_vec[i + j + 1] = sum % 10;
            res_vec[i + j] += sum / 10;
        }
    }

    std::string result_str = "";
    for (int digit : res_vec) {
        // 去掉前导0
        if (!(result_str.empty() && digit == 0)) {
            result_str += std::to_string(digit);
        }
    }
    
    if (result_str.empty()) return "0";

    // 对 10^N 取模
    if (result_str.length() > N) {
        return result_str.substr(result_str.length() - N);
    }
    return result_str;
}


// 高精度快速幂:(base^exp) mod 10^N
std::string big_num_power(int base, int exp, int N) {
    std::string result = "1";
    std::string base_str = std::to_string(base);
    while (exp > 0) {
        if (exp % 2 == 1) {
            result = big_num_multiply_str(result, base_str, N);
        }
        base_str = big_num_multiply_str(base_str, base_str, N);
        exp /= 2;
    }
    return result;
}


int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int N;
    std::cin >> N;

    // 题目给的是1-indexed,我们转成0-indexed方便处理
    std::vector<int> p(N);
    for (int i = 0; i < N; ++i) {
        std::cin >> p[i];
        p[i]--; 
    }

    // 1. 找出所有循环长度
    std::vector<int> cycle_lengths;
    std::vector<bool> visited(N, false);
    for (int i = 0; i < N; ++i) {
        if (!visited[i]) {
            int current = i;
            int length = 0;
            while (!visited[current]) {
                visited[current] = true;
                current = p[current];
                length++;
            }
            cycle_lengths.push_back(length);
        }
    }

    // 2. 找出所有质数,并对每个循环长度进行质因数分解
    std::map<int, int> max_exponents;
    for (int len : cycle_lengths) {
        int temp_len = len;
        for (int i = 2; i * i <= temp_len; ++i) {
            if (temp_len % i == 0) {
                int count = 0;
                while (temp_len % i == 0) {
                    temp_len /= i;
                    count++;
                }
                max_exponents[i] = std::max(max_exponents[i], count);
            }
        }
        if (temp_len > 1) { // 剩余的是一个大质数
            max_exponents[temp_len] = std::max(max_exponents[temp_len], 1);
        }
    }

    // 3. 用高精度计算LCM
    std::string lcm_result = "1";
    for (auto const& [prime, exponent] : max_exponents) {
        std::string prime_power = big_num_power(prime, exponent, N);
        lcm_result = big_num_multiply_str(lcm_result, prime_power, N);
    }
    
    // 4. 格式化输出,补足前导0
    std::string final_answer = lcm_result;
    while (final_answer.length() < N) {
        final_answer = "0" + final_answer;
    }
    
    std::cout << final_answer << std::endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(NlogN+Li/logLi+P(N)N2)O(N \log N + \sum L_i / \log L_i + P(N) \cdot N^2)

    • 寻找循环: 我们遍历了每个元素一次,所以是 O(N)O(N)
    • 质因数分解: 对每个循环长度 LL 分解,最坏情况是 O(L)O(\sqrt{L})。所有循环长度之和为 NN,但这不是一个紧凑的上界。一个更好的方法是,我们可以先用 O(NloglogN)O(N \log \log N) 的筛法预处理出质数,然后对每个 LL 进行分解,这会快很多。总的分解时间大约在 O(NlogN)O(N \log N) 级别。
    • 高精度计算: 设小于等于 NN 的质数有 P(N)P(N) 个(P(N)N/lnNP(N) \approx N/\ln N)。对于每个质数 qq 和其最大指数 ee,我们计算 qeq^e 再乘入总结果。power 操作中的乘法是小数乘法,很快。主要开销是 lcm_result = multiply(lcm_result, prime_power)lcm_result 的长度最多为 NNprime_power 的长度相对较小。一次乘法最坏是 O(Nlog(lcm))O(N \cdot \log(\text{lcm}))。总的来说,高精度部分的时间复杂度近似为 O(P(N)NlogN)O(P(N) \cdot N \cdot \log N),或者更宽松地写为 O(NlogNNlogN)=O(N2)O(\frac{N}{\log N} \cdot N \log N) = O(N^2)
    • 总时间复杂度由高精度计算主导,约为 O(N2)O(N^2)
  • 空间复杂度: O(N)O(N)

    • 我们存储了置换 pvisited 数组,循环长度等,都需要 O(N)O(N) 的空间。
    • max_exponents 这个 map 最多存储 P(N)P(N) 个质数,也是 O(N/lnN)O(N/\ln N)
    • 高精度计算中,string 的长度最大为 NN
    • 所以总空间复杂度为 O(N)O(N)

知识点总结

这真是一次有趣的冒险,喵~ 我们从一个看似简单的排序问题,一路探索到了数论和高精度计算的深处!

  1. 问题转化: 关键在于将“可排序数组的数量”这个问题,通过分析和简化,转化为了计算一个置换的“阶”。这是解题的突破口!
  2. 置换群与阶: 理解了置换、循环分解和阶的概念。一个置换的阶等于其所有不相交循环长度的最小公倍数(LCM)。
  3. LCM的计算: 当数值很大时,计算LCM的最佳方式是通过质因数分解,找到每个质因子的最高次幂,然后相乘。
  4. 高精度计算: 当常规数据类型(如long long)无法存储结果或模数时,就需要自己实现大数运算。本题中对 10N10^N 取模,可以通过字符串操作,只保留末尾 NN 位来实现,这是一种常见的高精度取模技巧。

希望这篇题解能帮到你,指挥官!下次遇到难题,也请随时来找我哦,喵~