校招 OA 复盘:区间重排的秘密
Contents
最近刚完成某 N 厂的 OA 题目,难度比想象中高了很多,不过里面的问题非常有意思。正好趁求职这段时间复盘一下题目,顺带挖掘一下隐含的一些问题。
题目背景
有一排机器人分三组,按 排列,现在需要重排成 ,其中每一组内相对位置不变。现有一个 cart 和一个moveRobot(pos)接口用来移动机器人,规则如下:
- 若 cart 为空,将
pos上的机器人放进 cart,该位置变成空位; - 若 cart 非空,考虑两种情况:
- 如果
pos有机器人,将其移动到空位上,该位置变成空位; - 如果
pos是空位,把 cart 上的机器人移动到pos上。
- 如果
要求:给定 ,其中 都是正整数,模拟重排过程并返回调用moveRobot的次数,越少越好。SPJ 会检查重排后的结果。
unsigned long long robotCount(int n, int m, int k)
{
// Solution code
}
初步思路
如果没有仅一个 cart 这个限制,调memcpy就够了。
令 ,观察到这个重排可以理解成对 这个整数区间做一次置换(permutation)。由于任何置换都可以分解为若干个不相交的循环(cycle)1,我们可以利用这个性质,遍历所有循环完成重排。
unsigned long long robotCount(int n, int m, int k)
{
auto map2prev = [&](unsigned long long x) {
if (x < k) {
return x + n + m;
} else if (x - k < m) {
return x - k + n;
} else {
return x - k - m;
}
};
unsigned long long H = static_cast<unsigned long long>(n) + m;
bool* moved = new bool[H];
memset(moved, 0, H * sizeof(bool));
unsigned long long pos = 0;
unsigned long long count = 0;
while (pos < H) {
if (moved[pos]) {
pos++;
continue;
}
// 找到当前第一个没被移动的机器人
moveRobot(pos);
count++;
// 处理整个循环
auto move_dest = pos;
auto to_move = map2prev(pos);
while (to_move != pos) {
moveRobot(to_move);
count++;
if (to_move < H) {
moved[to_move] = true;
}
move_dest = to_move;
to_move = map2prev(to_move);
}
moveRobot(move_dest);
count++;
moved[pos] = true;
pos++;
}
delete[] moved;
return count;
}
如上所示,我们很自然地可以想到用一个moved数组来记录哪些位置的机器人已经被移动过。由于 会超过 int 的表示范围,下标需要使用unsigned long long类型;同时申请大小为 的数组会导致std::bad_alloc异常。
不难发现,每次找到的pos都代表一个置换循环的起点,我们自然会问:能不能仅扫描前 个位置()从而保证所有置换循环都能被遍历到?经验证,(见上述代码)能通过所有测试点,不过这会是最优解吗?
置换循环的分布
直觉上,一定存在某个 满足 使得所有置换循环都和 区间相交。我们接下来探究这个 的最小值。
沿用map2prev函数定义置换 表示位置 对应的机器人应该从哪里来,即目标位置到来源位置的映射:
等式两边同时减去 得到:
观察到 ,令 ,不难得出 ,即 。也就是说,一个循环不会离开自己的模 剩余类。我们甚至可以证明,每个模 剩余类恰好代表一个置换循环。
例如,取 ,则 。置换 恰好分成两个循环,分别由偶数位置和奇数位置组成:
引理
对一个长度为 的序列做步长为 的循环移位(rotation),得到的每个置换循环是模 的剩余类。
证明
令 表示该循环移位,对每个起始位置 ,移位 次后的位置为 。因此,移动回原来位置的充要条件是 ,即 。因此,置换循环的长度是满足 的最小正整数 ,自然就有 ,即置换循环长度和 无关。
由于 ,不难证明 ,因此从 出发的移位轨迹一定在和 同余的模 剩余类中。又因为剩余类的大小也是 ,所以每个模 剩余类恰好代表一个置换循环。
现在我们回到原题。我们构造一个长度为 的新序列
其中 表示 处机器人的副本。将该序列向右循环移动 个位置,得到的结果为
不难发现,循环移动后的前 个位置即为原题中的区间重排,循环移位后的 恰好可以替代 ,因而可以把循环移动前后的 和 视作等价的。若合并循环中重复的 与 ,剩余轨迹恰好对应 的循环。由上述引理可以直接得到,每个置换循环正好就是模 的剩余类。
至此,我们得出结论: 最小取 ,上述算法依然有效;甚至由于前 个位置恰好对应所有循环,我们可以去掉moved数组,把空间复杂度压缩到 。若 ,则 区间没有模 余 对应的循环,答案会出错。一个稍弱的结论是,在 时,可以令 。这版思路在 OA 中相对容易想到,代价是 的空间复杂度。模拟过程总时间复杂度 :当 时,只需交换 和 部分,调用次数只需 ;否则调用总次数为 。最终代码如下:
unsigned long long robotCount(int n, int m, int k)
{
auto map2prev = [&](unsigned long long x) {
if (x < k) {
return x + n + m;
} else if (x - k < m) {
return x - k + n;
} else {
return x - k - m;
}
};
unsigned long long H = std::gcd(
static_cast<unsigned long long>(n) + m,
static_cast<unsigned long long>(k) + m);
unsigned long long count = 0;
for (unsigned long long pos = 0; pos < H; pos++) {
auto to_move = map2prev(pos);
if (to_move == pos) {
// 特殊情况:当前位置的机器人不需要移动
continue;
}
moveRobot(pos);
count++;
auto move_dest = pos;
while (to_move != pos) {
moveRobot(to_move);
count++;
move_dest = to_move;
to_move = map2prev(to_move);
}
moveRobot(move_dest);
count++;
}
return count;
}
后记
数学问题最引人入胜的一个点在于,它们往往隐藏着一些巧妙的不变量,可以以“四两拨千斤”的方式把一个复杂的结构拆解成简单的组件。从矩阵对角化到线性空间分解,从齐次递推到特征方程,其核心都是找到并分析这些“正交”的不变性。在这道题中,我们首先注意到了置换变换和循环,循环的性质自然成为了分析问题的关键。变换过程中反复的加减特定的整数,能引导我们联想整除或同余,进而引入数论和 ,在这个有限环上探究和剩余类的关系。此外,很多巧妙的证明其实蕴含了数学家常用的 lifting 的思想,即把当前问题放在一个“更高维度”的空间中;此时一些原来看似离散的点便成了某个连续轨迹在一个截面上的交点,一个棘手的 permutation 瞬间变成了一个很简洁漂亮的 rotation。在理论研究和系统设计中,想要提出一些巧妙的 trick 来达成奇效,这些思想功不可没。