这个问题在数学上有一个非常经典且优美的背景,它直接关联到抽象代数中的置换群(Permutation Group)以及置换的循环分解(Cycle Decomposition)。
下面将为您引出相关的数学概念、给出最少交换次数公式的数学证明,并提供一个基于 C++11 的高效算法实现。
一、 数学概念:置换与不相交循环
为了方便讨论,我们假设数组中的元素是互不相同的。如果存在重复元素,我们可以通过记录它们原本的相对顺序(如稳定排序)来将其转化为无重复元素的情况。
1. 置换(Permutation)
设 $S = {1, 2, \dots, n}$ 是一个有限集。从 $S$ 到 $S$ 的一个双射(一双一对应) $\sigma$ 称为 $S$ 上的一个置换。 在我们的问题中,将一个乱序数组通过调整位置变成有序数组,本质上就是寻找一个置换,将其恢复为恒等置换(Identity Permutation,即每个元素都在其正确位置上的状态)。
2. 循环(Cycle)与不相交循环分解
对于一个置换,如果我们从某个位置 $i$ 开始,查看它应该去的位置 $j$,再看 $j$ 应该去的位置……由于集合有限,最终一定会回到 $i$。这形成了一个封闭的环,称为循环(Cycle)。 例如,一个长度为 $k$ 的循环可以记为 $(a_1, a_2, \dots, a_k)$,表示 $a_1 \to a_2 \to \dots \to a_k \to a_1$。
重要定理:任何一个置换都可以唯一地分解为若干个互不相交的循环的乘积。
例子说明
假设数组为 [4, 3, 2, 1],排序后的目标数组为 [1, 2, 3, 4]。
我们将当前元素与它们应该在的目标位置进行映射(以 0 为起始索引):
* 元素 4(当前在索引 0)的目标索引是 3
* 元素 1(当前在索引 3)的目标索引是 0
这构成了一个循环:$(0, 3)$
* 元素 3(当前在索引 1)的目标索引是 2
* 元素 2(当前在索引 2)的目标索引是 1
这构成了另一个循环:$(1, 2)$
整个数组的置换可以分解为 2 个不相交的循环:$(0, 3)$ 和 $(1, 2)$。
二、 数学证明:最少交换次数
定理:对于一个长度为 $n$ 的数组,如果它对应的置换可以分解为 $c$ 个不相交的循环(包括长度为 1 的循环,即已经在正确位置上的元素),那么将其排好序所需的最少交换次数为: $$\text{Min Swaps} = n - c$$
证明过程:
-
目标状态: 当数组完全排好序时,每个元素都在其对应的位置上。此时有 $n$ 个长度为 1 的循环。因此,目标状态下的循环总数 $c_{\text{target}} = n$。
-
单次交换(对换)对循环个数的影响: 单次任意位置的交换在代数上称为一个对换(Transposition)。我们来看对换如何影响循环的个数:
- 情况 A:交换同一个循环内的两个元素。 假设有一个循环 $C = (x_1, \dots, x_i, \dots, x_j, \dots, x_k)$。如果我们交换位置 $x_i$ 和 $x_j$ 的元素,这个循环会被分裂成两个独立的不相交循环: $$(x_1, \dots, x_i, x_{j+1}, \dots, x_k) \quad \text{和} \quad (x_{i+1}, \dots, x_j)$$ 这使得总循环数 $c$ 增加 1。
-
情况 B:交换两个不同循环中的元素。 如果我们选择处于不同循环中的两个元素进行交换,根据上述过程的逆过程,这两个循环会被合并成一个大循环。 这使得总循环数 $c$ 减少 1。
-
最少步骤推导: 我们的目标是让循环个数从初始的 $c$ 增加到最终的 $n$。 为了以最快的速度达到目标,我们每一次交换都应该尽可能多地增加循环的个数。 由上述分析可知,单词交换最多只能让循环个数增加 1(即每次都只在同一个循环内部进行交换)。 因此,要使循环数从 $c$ 增长到 $n$,至少需要进行的交换次数为: $$\text{次数} \ge n - c$$
同时,通过每次选择同一个循环内的两个元素进行交换,我们确实可以保证每次交换都使循环数恰好加 1。因此,这个下界是完全可以达到的。 证毕。
三、 C++11 代码实现
在实际编程中,我们不需要显式地去构建复杂的置换群结构。我们可以通过以下步骤高效求解:
1. 将元素及其原始索引绑定,然后对数组进行排序,从而确定每个元素排好序后应该去的“目标索引”。
2. 使用一个 visited 数组来标记哪些位置已经被处理过。
3. 遍历数组,对于未访问的位置,沿着它的目标索引一直追踪下去,直到回到起点,从而完整找出该位置所在的循环,并记录该循环的长度 $L$。
4. 一个长度为 $L$ 的循环需要 $L - 1$ 次交换来解开(这与 $n-c$ 的公式是一致的,因为对于这个子系统,元素数是 $L$,循环数是 1,交换次数即 $L-1$)。将所有循环的贡献累加即可。
以下是具体的 C++11 实现代码:
#include <iostream>
#include <vector>
#include <algorithm>
// 函数用于计算最少交换次数
int minSwaps(const std::vector<int>& arr) {
int n = arr.size();
// 存储元素值及其原始索引
std::vector<std::pair<int, int>> arrPos(n);
for (int i = 0; i < n; ++i) {
arrPos[i] = {arr[i], i};
}
// 按照元素值进行升序排序,确定每个元素在有序状态下应该在的位置
std::sort(arrPos.begin(), arrPos.end(),
[](const std::pair<int, int>& a, const std::pair<int, int>& b) {
return a.first < b.first;
});
// 用于记录该位置的元素是否已被归入某个循环中
std::vector<bool> visited(n, false);
int total_swaps = 0;
for (int i = 0; i < n; ++i) {
// 如果该元素已经被访问过,或者它本来就在正确的位置上(即排序后的原索引仍为i)
if (visited[i] || arrPos[i].second == i) {
continue;
}
// 寻找一个新循环的长度
int cycle_size = 0;
int curr = i;
while (!visited[curr]) {
visited[curr] = true;
// 移向该元素原本应该去的位置(即它在排序数组中的正确位置)
curr = arrPos[curr].second;
cycle_size++;
}
// 一个长度为 L 的循环需要 L - 1 次交换
if (cycle_size > 1) {
total_swaps += (cycle_size - 1);
}
}
return total_swaps;
}
int main() {
// 示例 1: [4, 3, 2, 1] -> 理论上 c = 2, n = 4, 最少交换 4 - 2 = 2 次
std::vector<int> arr1 = {4, 3, 2, 1};
std::cout << "Array 1 min swaps: " << minSwaps(arr1) << " (Expected: 2)" << std::endl;
// 示例 2: [2, 3, 1, 5, 4] -> 理论上最少交换 3 次
std::vector<int> arr2 = {2, 3, 1, 5, 4};
std::cout << "Array 2 min swaps: " << minSwaps(arr2) << " (Expected: 3)" << std::endl;
return 0;
}
复杂度分析
- 时间复杂度:$O(N \log N)$。主要开销在对辅助数组进行排序以确定目标位置。后续寻找循环的遍历中,每个节点最多被访问两次(一次在
for循环,一次在while循环),因此寻找循环部分的时间复杂度为 $O(N)$。如果数组元素本身范围有限(例如是 $1$ 到 $N$ 的排列),我们可以用计数排序等方式将时间复杂度优化到 $O(N)$。 - 空间复杂度:$O(N)$。需要额外的空间存储带索引的数组
arrPos以及标记数组visited。
—— 本文来自火龙信奥(义乌睿码科技):义乌青少年信息学奥赛与编程教育平台,专注 CSP-J/S、NOIP、GESP 竞赛培训,线上线下融合教学,助力编程升学。网址:hlcoding.com