Skip to content

SortingtheArray - 题解

标签与难度

标签: 构造, 组合数学, 排列, 逆向思维, 滑动窗口, 喵~ 难度: 2300

题目大意喵~

一位名叫 f 的函数会对一个数组 a 进行操作,喵~ 函数的逻辑是这样的:它有一个大小为 m 的滑动窗口,从左到右滑过数组。每当窗口滑动到一个新的位置,它就会对窗口内的元素进行排序。这个过程会一直持续到窗口到达数组的末尾。

现在,我们知道了函数 f 处理一个长度为 n 的初始数组 b 后,得到的最终数组 ret。我们的任务是,找出所有可能的初始数组 b 中,字典序第 k 小的那一个,然后把它交出来,喵!

题目保证了至少存在 k 个这样的初始数组 b,所以不用担心找不到哦。

解题思路分析

这真是一道有趣的谜题呢,喵!要把一个乱七八糟的过程倒过来想,就像把打结的毛线球解开一样,需要耐心和智慧,的说!

模拟正向过程

首先,我们来仔细研究一下函数 f 到底做了什么。它从 i = m-1n-1 遍历,每次都对子数组 a[i-m+1 ... i] 进行排序。

我们来模拟一下这个过程,看看能不能发现什么规律。想象我们有一个大小为 m 的魔法口袋(用优先队列或者 std::multiset 来想最方便了!)。

  1. 一开始,我们把初始数组 b 的前 m-1 个元素 b[0], ..., b[m-2] 都丢进魔法口袋里。
  2. 然后,从 i = m-1n-1,我们进行 n-m+1 次操作: a. 把 b[i] 这个新元素也丢进魔法口袋。现在口袋里有 m 个元素啦。 b. 从口袋里找出最小的那个元素,这个元素就是最终数组 reti-m+1 位置上的值,也就是 ret[i-m+1]。 c. 把这个最小的元素从口袋里拿出来,它光荣地完成了任务,不会再参与后续的排序了。

所以,ret 数组其实就是我们每次从魔法口袋里取出的最小值的序列!ret[0] 是第一次取出的最小值,ret[1] 是第二次,以此类推。

逆向推理找线索

知道了 ret 是怎么来的,我们就可以开始逆向推理,寻找 b 的蛛丝马迹了,喵~

Pool_j 为决定 ret[j] 时,魔法口袋里的元素集合。

  • ret[0] = min(Pool_0),其中 Pool_0 = {b[0], ..., b[m-1]}
  • ret[1] = min(Pool_1),其中 Pool_1 = (Pool_0 \setminus \{ret[0]\}) \cup \{b[m]\}
  • ...
  • ret[j] = min(Pool_j),其中 Pool_j = (Pool_{j-1} \setminus \{ret[j-1]\}) \cup \{b[j+m-1]\}

这里的 Pool_{j-1} \setminus \{ret[j-1]\} 就是上一步操作后,口袋里剩下的 m-1 个“幸存者”。我们知道,ret[j-1]Pool_{j-1} 中最小的,所以所有幸存者的值都大于或等于 ret[j-1]

现在,关键的推理来了! ret[j] = min( (幸存者们) \cup \{b[j+m-1]\} ) 如果 ret[j] 比所有幸存者都小,那它只能是 b[j+m-1]!什么时候会发生这种情况呢?一个很强的信号是,如果 ret[j] 比它之前的一些 ret 值还要小。

更准确地说,我们维护一个 ret 值的单调不减栈。当 ret[i] 出现时:

  • 如果 ret[i] 大于等于栈顶元素,说明它可能是从幸存者中选出来的,我们无法确定它来自哪里。我们将它压入栈中。
  • 如果 ret[i] 小于栈顶元素,这说明 ret[i] 不可能是任何一个幸存者(因为幸存者们都比之前的 ret 值大,自然也比栈顶元素大)。因此,ret[i] 只能是那个新加入的元素!在决定 ret[i] 的那一步,新加入的元素是 b[i+m-1]。所以,我们就确定了 b[i+m-1] = ret[i]

通过这个方法,我们可以把 ret 数组中的元素分成两类:

  1. 自由值 (Free Values): 那些被压入单调栈的 ret 元素。我们暂时不知道它们在 b 数组中的原始位置。
  2. 固定值 (Fixed Values): 那些因为小于栈顶而被识别出来的 ret 元素。它们在 b 数组中的位置是固定的。

相应的,b 数组的 n 个位置也被分成了 自由位置固定位置

组合计数的魔法

现在问题转化成了:把所有“自由值”填入所有“自由位置”,使得最终的 b 数组是所有合法方案中字典序第 k 小的。

这就像一个经典的排列组合问题,喵!我们要从左到右,一个一个地确定自由位置上的值。假设有 L 个自由位置 p_1, p_2, ..., p_L (已排序) 和 L 个自由值 v_1, v_2, ..., v_L (已排序)。

为了得到字典序最小的 b,我们应该在最小的自由位置 p_1 上放尽可能小的值。 那么,对于第 i 个自由位置 p_i,我们可以从当前还没用过的自由值中选择哪些呢? 经过一番复杂的推导(这里的小我也挠了挠头呢~),可以得出一个美妙的结论: 对于第 i 个自由位置 p_ii 从 1 开始数),我们可以从当前可用的自由值中,选择min(i, m)的任意一个!

为什么是 min(i, m) 呢?

  • i: 代表这是我们遇到的第 i 个自由位置。在 f 的过程中,b[p_1], ..., b[p_i]i 个值会先后进入魔法口袋。
  • m: 魔法口袋的大小。口袋最多只能同时关注 m 个元素。

i <= m 时,前 i 个自由值 {b[p_1], ..., b[p_i]} 会全部进入口袋。为了保证最终生成的 ret 中的自由值部分是从小到大 v_1, v_2, ... 这样出现的,{b[p_1], ..., b[p_i]} 的集合必须是 {v_1, ..., v_i}。所以,在决定 b[p_i] 时,它可以是 {v_1, ..., v_i} 中任何一个尚未被 b[p_1], ..., b[p_{i-1}] 使用的值。这给了我们 i 个选择。 当 i > m 时,魔法口袋的大小为 m,它只能“记住” m 个元素。这给了我们 m 个选择的自由度。

所以,在填充第 i 个自由位置时,我们有 min(i, m) 个选择。

寻找第 k 小排列

现在,我们可以用康托展开(或者叫 factoradic)的思想来构造第 k 小的排列了。 我们从 k (0-indexed) 开始,从左到右(i from 1 to L)填充自由位置 p_i

  1. 计算将剩下的 L-i 个自由值填入 p_{i+1}, ..., p_L 的总方案数。这个方案数等于 min(i+1, m) * min(i+2, m) * ... * min(L, m)
  2. 对于 p_i,我们按从小到大的顺序尝试可用的 min(i, m) 个自由值。
  3. 假设当前尝试第 j 个可用值(j from 1 to min(i,m))。如果 k 小于后面的总方案数,说明 p_i 就应该填这个值。我们把它填上,然后继续处理下一个位置 p_{i+1}
  4. 如果 k 大于等于总方案数,我们就从 k 中减去这个方案数,然后尝试下一个可用值。

优化小技巧: k 可能很大,但乘积增长得非常快。通常,只有最后几十个自由位置的排列是需要我们精心计算的。对于前面的自由位置,k 都会远远大于它们的后续方案数之积,所以它们会直接取当前可用的最小值。我们可以先找到一个分界点 split_point,使得 split_point 之后的位置的排列方案数首次大于 k。那么在 split_point 之前的所有自由位置,我们都可以直接按 b[p_i] = v_i 来填充,大大加快了计算速度,喵~

代码实现

这是小我根据上面的思路,精心重构的一份代码~ 希望能帮到你,喵!

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

// 使用一个更快的 IO 模板,喵~
void fast_io() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(nullptr);
}

// 处理单个测试用例的函数
void solve() {
    int n;
    long long m, k;
    std::cin >> n >> m >> k;
    std::vector<int> ret(n);
    std::vector<bool> is_val_used(n + 1, false);
    for (int i = 0; i < n; ++i) {
        std::cin >> ret[i];
    }

    // b_final 是我们最终要构造的答案数组
    std::vector<int> b_final(n, 0);
    // free_values 存储所有自由值
    std::vector<int> free_values;
    // mono_stack 是用来识别自由值的单调栈
    std::vector<int> mono_stack;

    // 步骤1: 识别固定值和自由值
    for (int i = 0; i < n; ++i) {
        // 如果当前 ret[i] 破坏了单调性,它就是一个固定值
        if (!mono_stack.empty() && mono_stack.back() > ret[i]) {
            // 固定值 ret[i] 必须来自 b[i + m - 1]
            // 注意题目给的伪代码是 1-based,这里我们用 0-based
            if (i + m - 1 < n) {
                b_final[i + m - 1] = ret[i];
                is_val_used[ret[i]] = true;
            }
        } else {
            // 否则,ret[i] 是一个潜在的自由值,先放入单调栈
            mono_stack.push_back(ret[i]);
        }
    }
    
    // mono_stack 中剩下的就是所有的自由值
    free_values = mono_stack;
    for(int val : free_values) {
        is_val_used[val] = true;
    }

    // 步骤2: 找出所有自由位置
    std::vector<int> free_positions;
    for (int i = 0; i < n; ++i) {
        if (b_final[i] == 0) {
            free_positions.push_back(i);
        }
    }
    
    int num_free = free_positions.size();
    
    // k 是 1-based, 我们转成 0-based 方便计算
    k--; 

    // 步骤3: 构造第 k 小的排列
    
    // 优化:找到一个分割点,使得此点之前的自由位可以直接填最小可用值
    int split_idx = 0;
    // 使用 __int128 防止计算方案数时溢出
    __int128 permutations_count = 1;
    // 从后往前计算后缀的排列方案数
    for (int i = 1; i <= num_free; ++i) {
        // 如果再乘就会超过 k,就找到了分割点
        if (permutations_count > k / std::min((long long)i, m)) {
            split_idx = num_free - i;
            break;
        }
        permutations_count *= std::min((long long)i, m);
    }

    // 填充分割点之前的位置
    for (int i = 0; i < split_idx; ++i) {
        b_final[free_positions[i]] = free_values[i];
    }

    // 填充分割点之后的位置,这里需要精细计算
    std::vector<int> remaining_free_values;
    for (int i = split_idx; i < num_free; ++i) {
        remaining_free_values.push_back(free_values[i]);
    }

    for (int i = split_idx; i < num_free; ++i) {
        // 计算填充完当前位置后,剩下位置的排列方案数
        permutations_count = 1;
        int remaining_count = num_free - 1 - i;
        for (int j = 1; j <= remaining_count; ++j) {
            permutations_count *= std::min((long long)(j + i - split_idx + 1), m);
        }

        // 确定当前位置 free_positions[i] 的值
        long long choices = std::min((long long)(i - split_idx + 1), m);
        for (int j = 0; j < choices; ++j) {
            if (k < permutations_count) {
                b_final[free_positions[i]] = remaining_free_values[j];
                remaining_free_values.erase(remaining_free_values.begin() + j);
                break;
            }
            k -= permutations_count;
        }
    }

    // 打印最终答案
    for (int i = 0; i < n; ++i) {
        std::cout << b_final[i] << (i == n - 1 ? "" : " ");
    }
    std::cout << "\n";
}

int main() {
    fast_io();
    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}

复杂度分析

  • 时间复杂度: O(N+L02)O(N + L_0^2),其中 NN 是数组大小, L0L_0 是需要进行组合计数部分的自由变量数量。

    • 识别固定值和自由值需要一次遍历,复杂度为 O(N)O(N)
    • 寻找自由位置也是 O(N)O(N)
    • 寻找分割点的优化,最多计算约 60 次乘法(因为 long long 的范围),所以是 O(logmk)O(\log_m k),非常快。
    • 构造排列的部分,我们只对分割点后的 L0L_0 个元素进行操作。外层循环 L0L_0 次,内层循环最多 mm 次,内部的 erase 操作是 O(L0)O(L_0)。总共是 O(L02)O(L_0^2)。由于 L0L_0 很小(通常小于 60),这部分也非常快。
    • 所以总时间复杂度由 O(N)O(N) 主导。
  • 空间复杂度: O(N)O(N)

    • 我们需要存储 ret 数组、b_final 数组、free_valuesfree_positions 和单调栈,它们的大小都不会超过 NN

知识点总结

这道题是多种思想的美妙结合,喵~

  1. 逆向思维: 解题的关键是从最终结果 ret 反推初始数组 b 的性质,而不是模拟正向过程。
  2. 单调栈: 一个经典的数据结构,在这里巧妙地用于区分“稳定”可预测的元素(自由值)和“突变”的元素(固定值)。
  3. 组合数学与康托展开: 当问题归结为“求第 k 小排列”时,康托展开(或其变体)是标准的解决方案。理解每个位置有多少种选择是核心。
  4. 大数处理: 在计算排列数时,乘积可能非常大,会超出 long long 的范围。使用 __int128 或者通过比较来避免直接计算大数是一种常见的处理技巧。
  5. 构造性算法: 这类算法要求我们不是去寻找一个答案,而是根据规则直接构造出满足条件的答案。

希望这篇题解能帮助你理解这道题的奥秘!继续加油哦,喵~