SortingtheArray - 题解
标签与难度
标签: 构造, 组合数学, 排列, 逆向思维, 滑动窗口, 喵~ 难度: 2300
题目大意喵~
一位名叫 f 的函数会对一个数组 a 进行操作,喵~ 函数的逻辑是这样的:它有一个大小为 m 的滑动窗口,从左到右滑过数组。每当窗口滑动到一个新的位置,它就会对窗口内的元素进行排序。这个过程会一直持续到窗口到达数组的末尾。
现在,我们知道了函数 f 处理一个长度为 n 的初始数组 b 后,得到的最终数组 ret。我们的任务是,找出所有可能的初始数组 b 中,字典序第 k 小的那一个,然后把它交出来,喵!
题目保证了至少存在 k 个这样的初始数组 b,所以不用担心找不到哦。
解题思路分析
这真是一道有趣的谜题呢,喵!要把一个乱七八糟的过程倒过来想,就像把打结的毛线球解开一样,需要耐心和智慧,的说!
模拟正向过程
首先,我们来仔细研究一下函数 f 到底做了什么。它从 i = m-1 到 n-1 遍历,每次都对子数组 a[i-m+1 ... i] 进行排序。
我们来模拟一下这个过程,看看能不能发现什么规律。想象我们有一个大小为 m 的魔法口袋(用优先队列或者 std::multiset 来想最方便了!)。
- 一开始,我们把初始数组
b的前m-1个元素b[0], ..., b[m-2]都丢进魔法口袋里。 - 然后,从
i = m-1到n-1,我们进行n-m+1次操作: a. 把b[i]这个新元素也丢进魔法口袋。现在口袋里有m个元素啦。 b. 从口袋里找出最小的那个元素,这个元素就是最终数组ret在i-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 数组中的元素分成两类:
- 自由值 (Free Values): 那些被压入单调栈的
ret元素。我们暂时不知道它们在b数组中的原始位置。 - 固定值 (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_i(i 从 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:
- 计算将剩下的
L-i个自由值填入p_{i+1}, ..., p_L的总方案数。这个方案数等于min(i+1, m) * min(i+2, m) * ... * min(L, m)。 - 对于
p_i,我们按从小到大的顺序尝试可用的min(i, m)个自由值。 - 假设当前尝试第
j个可用值(jfrom 1 tomin(i,m))。如果k小于后面的总方案数,说明p_i就应该填这个值。我们把它填上,然后继续处理下一个位置p_{i+1}。 - 如果
k大于等于总方案数,我们就从k中减去这个方案数,然后尝试下一个可用值。
优化小技巧: k 可能很大,但乘积增长得非常快。通常,只有最后几十个自由位置的排列是需要我们精心计算的。对于前面的自由位置,k 都会远远大于它们的后续方案数之积,所以它们会直接取当前可用的最小值。我们可以先找到一个分界点 split_point,使得 split_point 之后的位置的排列方案数首次大于 k。那么在 split_point 之前的所有自由位置,我们都可以直接按 b[p_i] = v_i 来填充,大大加快了计算速度,喵~
代码实现
这是小我根据上面的思路,精心重构的一份代码~ 希望能帮到你,喵!
#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;
}复杂度分析
时间复杂度: ,其中 是数组大小, 是需要进行组合计数部分的自由变量数量。
- 识别固定值和自由值需要一次遍历,复杂度为 。
- 寻找自由位置也是 。
- 寻找分割点的优化,最多计算约 60 次乘法(因为
long long的范围),所以是 ,非常快。 - 构造排列的部分,我们只对分割点后的 个元素进行操作。外层循环 次,内层循环最多 次,内部的
erase操作是 。总共是 。由于 很小(通常小于 60),这部分也非常快。 - 所以总时间复杂度由 主导。
空间复杂度: 。
- 我们需要存储
ret数组、b_final数组、free_values、free_positions和单调栈,它们的大小都不会超过 。
- 我们需要存储
知识点总结
这道题是多种思想的美妙结合,喵~
- 逆向思维: 解题的关键是从最终结果
ret反推初始数组b的性质,而不是模拟正向过程。 - 单调栈: 一个经典的数据结构,在这里巧妙地用于区分“稳定”可预测的元素(自由值)和“突变”的元素(固定值)。
- 组合数学与康托展开: 当问题归结为“求第 k 小排列”时,康托展开(或其变体)是标准的解决方案。理解每个位置有多少种选择是核心。
- 大数处理: 在计算排列数时,乘积可能非常大,会超出
long long的范围。使用__int128或者通过比较来避免直接计算大数是一种常见的处理技巧。 - 构造性算法: 这类算法要求我们不是去寻找一个答案,而是根据规则直接构造出满足条件的答案。
希望这篇题解能帮助你理解这道题的奥秘!继续加油哦,喵~