排序算法之:基于最小移动次数将数组排序的图论模型与环分解优化
题目描述
给定一个包含 n 个互不相同的整数的数组 arr,我们可以执行一种操作:从数组中选择任意一个元素,并将其“移动”(或理解为“插入”)到数组中的任意其他位置。我们的目标是使用最少的“移动”操作次数,将数组 arr 排序为升序序列。每次“移动”操作的成本为 1。我们需要找出并证明完成排序所需的最小移动次数。
请注意,这里的“移动”定义是:选择一个元素,将其从当前位置移除,然后插入到任意一个新位置(可以在当前元素位置之前或之后)。这与“交换”(swap)两个元素位置的操作不同。
例如,对于数组 arr = [2, 4, 3, 1]。其排序后的结果应为 [1, 2, 3, 4]。
一种可行的移动序列是:
- 将元素
1移动到最前面 ->[1, 2, 4, 3](移动次数: 1) - 将元素
3移动到4前面 ->[1, 2, 3, 4](移动次数: 2)
总移动次数为 2。
但我们需要证明 2 是最小值,并找到适用于任何输入的一般性解法。
我们将逐步揭示,这个问题可以巧妙地转化为一个图论问题,并通过对“环”的分析得出最优解。
解题过程
第一步:问题转化与建模
我们需要理解“移动”操作的本质,以及它与最终有序状态的关系。
-
确定目标位置:由于数组元素互不相同,最终的升序序列是唯一确定的。我们可以先得到排序后的数组
sorted_arr。对于原数组arr中的每一个元素,我们都能在sorted_arr中找到其唯一的、正确的位置。 -
定义元素映射:我们不关心元素的具体值,而关心其“最终排名”。我们可以将原数组
arr的每个元素,映射为其在sorted_arr中的索引(从 0 开始)。这个过程通常称为“离散化”。- 例如,
arr = [2, 4, 3, 1],排序后为[1, 2, 3, 4]。 - 映射关系:值 1 -> 索引 0, 值 2 -> 索引 1, 值 3 -> 索引 2, 值 4 -> 索引 3。
- 将原数组用其“目标索引”替换:
[2, 4, 3, 1]变为[1, 3, 2, 0]。 - 现在,我们的问题转化为:将一个排列
[1, 3, 2, 0],通过最少的“移动”操作,变成标准的单位排列[0, 1, 2, 3]。单位排列的意思是,索引 i 处的元素值恰好是 i。
- 例如,
-
构建置换图:现在,我们有一个排列
P。我们可以将这个排列视为一个函数:P[i]表示“当前在位置 i 的元素,它的目标位置是P[i]”。对于单位排列,有P[i] = i。我们可以构建一个有向图
G,它有 n 个节点(0 到 n-1)。对于每个索引 i,我们添加一条从节点 i 指向节点P[i]的有向边。因为P是一个排列(每个数 0 到 n-1 恰好出现一次),所以每个节点的出度和入度都是 1。这样的图必然由若干个不相交的有向环构成。这种图称为置换的循环分解表示。- 继续我们的例子
P = [1, 3, 2, 0]:- i=0: P[0]=1,边 0 -> 1
- i=1: P[1]=3,边 1 -> 3
- i=2: P[2]=2,边 2 -> 2 (这是一个自环,即自己指向自己)
- i=3: P[3]=0,边 3 -> 0
- 这个图包含两个环:
- 环 C1: 0 -> 1 -> 3 -> 0 (一个长度为 3 的环)
- 环 C2: 2 -> 2 (一个长度为 1 的自环,表示元素 2 已经在它的目标位置上了)
- 继续我们的例子
第二步:分析“移动”操作在图上的影响
这是最关键的一步。一次“移动”操作,对应在排列 P 上做了什么?
假设我们将位于位置 x 的元素移动到位置 y(y 可以小于或大于 x)。在移动之后,位置 x 到 y 之间的其他元素会相应地向前或向后移动一个位置来填补空隙或腾出空间。在排列 P 的视角下,这相当于从排列中删除 P[x] 这个值,然后将它插入到索引 y 所代表的新位置上。更重要的是,我们可以证明,这样一次“移动”操作,在对应的置换图 G 上,最多只能将一个环分解成两个更小的环,或者改变环的结构但不增加环的数目。
让我们直观地理解一下:在环中,每个节点都指向它的“目标”。如果我们把一个元素(节点)从它当前所在的环中“取出”并放到它最终应该在的地方(即让它变成一个自环),那么这个操作可能会打破原有的环。
一个重要的结论是:通过一次“移动”操作,我们最多可以使得图中的环的数量增加 1 个。更具体地说,如果我们移动一个属于某个长度 L > 1 的环中的元素,并且将其放置到它的最终正确位置(即让它变成一个自环),那么这个操作会将原来的一个长度为 L 的环,拆分为一个长度为 (L-1) 的环和一个长度为 1 的自环。环的总数增加了 1。
第三步:推导最小移动次数的公式
我们的最终目标是将排列 P 变为单位排列 [0, 1, ..., n-1]。在单位排列对应的图中,每个节点都是一个自环,总共有 n 个环。
假设初始的排列 P 对应的置换图被分解成了 c 个环(包括长度为 1 的自环)。我们的目标是从 c 个环变成 n 个环。
每次“移动”操作最多能增加 1 个环。那么,至少需要 n - c 次操作,才能达到 n 个环的状态。
这个下界 n - c 是可以达到的。策略如下:
- 忽略所有已经是自环的节点(长度为 1 的环),它们已经就位。
- 对于每一个长度大于 1 的环,我们总可以找到一个策略,通过 (环的长度 - 1) 次移动,将这个环中的所有元素归位。一个简单的策略是:对于一个长度为 L 的环
(a1 -> a2 -> ... -> aL -> a1),我们选择环中的一个元素(例如 a1),我们知道 a1 的目标位置是它自己(即它应该成为一个自环)。但直接将 a1 移动到它自己的目标位置不一定能拆开环。一个有效的方法是:总是将环中任意一个元素的“前驱”在排列中的元素,移动到它的“最终正确位置”。这可以通过移动这个元素来实现,每次移动都能将被移动的元素变成自环,并将原环的长度减 1。重复 L-1 次,整个环的所有元素就都变成了自环。在这个长度为 L 的环上,我们恰好执行了 L-1 次移动,增加了 L-1 个环(从 1 个环变成 L 个自环,净增 L-1 个环)。
因此,对于整个排列,总的最小移动次数就是所有环的 (长度 - 1) 之和,也就是 n - c。
第四步:算法步骤总结与示例验证
- 输入:一个包含 n 个互不相同整数的数组
arr。 - 排序与映射:
- 创建数组
sorted_arr= 对arr进行升序排序。 - 创建一个哈希表
value_to_index,记录每个值在sorted_arr中的索引。 - 创建一个新的数组
permutation,长度 n,其中permutation[i] = value_to_index[arr[i]]。现在permutation是 0 到 n-1 的一个排列。
- 创建数组
- 寻找环的数量 (c):
- 初始化一个布尔数组
visited记录节点是否被访问过,初始全为false。c = 0。 - 对于 i 从 0 到 n-1:
- 如果
visited[i]为false并且permutation[i] != i(也可以包括自环,但自环对移动次数贡献为0):c += 1(发现一个新环)- 从节点 i 开始,沿着边
j -> permutation[j]遍历,直到回到 i,并将路径上所有节点标记为visited = true。
- 如果
- 初始化一个布尔数组
- 计算最小移动次数:
- 最小移动次数 =
n - c。
- 最小移动次数 =
验证示例:arr = [2, 4, 3, 1]
sorted_arr = [1, 2, 3, 4]value_to_index = {1:0, 2:1, 3:2, 4:3}permutation = [value_to_index[2], value_to_index[4], value_to_index[3], value_to_index[1]] = [1, 3, 2, 0]- 寻找环:
- 从 i=0 开始,路径 0->1->3->0,标记 0,1,3 为 visited,发现 1 个环 (c=1)。
- i=2,
permutation[2]=2,这是一个自环。根据我们遍历的逻辑(通常从permutation[i] != i开始找非自环),我们可能不会把它计为一个需要“打破”的环。但更严格地说,总环数 c_total 应该是 2(一个长度为3的环 + 一个长度为1的自环)。在我们的公式最小移动次数 = n - c中,这里的c应该是总环数,包括自环。因为 n 是所有节点数,c 是所有环数,n-c 就是“不在自己位置上的节点形成的环需要纠正的总步数”。对于自环,它已经在目标位置,不需要移动,所以对移动次数的贡献是 0 (1-1=0)。 - 总环数 c_total = 2。
- 最小移动次数 = n - c_total = 4 - 2 = 2。这与我们手动操作的次数一致。
第五步:算法复杂度分析
- 时间复杂度:O(n log n),主要由排序步骤决定。创建映射和环分解的过程只需要 O(n) 的遍历时间。
- 空间复杂度:O(n),用于存储排序后的数组、映射哈希表和 visited 数组。
总结
本题的核心洞察是将“最小移动次数排序”问题转化为排列的环分解问题。通过将数组元素映射到其目标索引,我们得到一个新的排列。这个排列的置换图由若干个有向环构成。排序的最终状态(单位排列)对应所有元素都是自环。每次“移动”操作最多能增加一个环。因此,最小移动次数就等于元素总数 n 减去初始排列中的总环数(包括自环)。算法通过一次环分解即可找到最优解,高效且优雅。