排序算法之:归并排序的递归实现与空间复杂度优化
字数 2337 2025-12-15 21:49:10
排序算法之:归并排序的递归实现与空间复杂度优化
题目描述
实现一个标准的归并排序(Merge Sort)算法。归并排序是一种基于分治策略的稳定排序算法。其核心思想是:分解 与 合并。算法的步骤如下:
- 分解:将待排序的数组递归地分成两半,直到每个子数组只剩下一个或零个元素(此时天然有序)。
- 合并:将两个已经排序的子数组合并成一个大的有序数组。
你的任务是:
- 清晰地阐述分治过程和合并操作的细节。
- 实现该算法,并分析其时间复杂度和空间复杂度。
- 针对空间复杂度,探讨一种优化策略,以减少在递归过程中临时数组的创建次数。
解题过程详解
我们将整个过程分解为三个核心部分:分治递归、合并操作、空间优化。
步骤一:理解分治递归框架
归并排序的递归过程非常直观,其伪代码逻辑如下:
函数 mergeSort(arr, left, right):
if left >= right:
return // 递归基线条件:子数组长度为1或0
mid = left + (right - left) / 2 // 计算中点,防止溢出
mergeSort(arr, left, mid) // 递归排序左半部分
mergeSort(arr, mid+1, right) // 递归排序右半部分
merge(arr, left, mid, right) // 合并两个有序子数组
关键点:
left和right定义了当前正在处理的子数组在原数组中的闭区间索引[left, right]。- 当
left >= right时,区间内最多只有一个元素,无需再分。 - 递归调用确保左右两部分在调用
merge之前已经各自有序。
步骤二:实现核心的合并(Merge)操作
合并操作是将两个有序数组合并为一个有序数组的过程。由于我们是在原数组 arr 上操作,合并需要借助一个临时数组。
假设我们要合并 arr[left...mid] 和 arr[mid+1...right],这两个区间内部已经有序。
- 创建临时空间:创建一个大小为
right - left + 1的临时数组temp。 - 双指针遍历:初始化两个指针
i = left和j = mid + 1,分别指向两个子数组的起始位置。同时初始化一个指针k = 0,指向临时数组temp的起始位置。 - 比较与填充:
- 比较
arr[i]和arr[j]。 - 将较小的元素复制到
temp[k]。 - 移动较小元素所在子数组的指针(
i++或j++)和temp的指针k++。
- 比较
- 处理剩余元素:当其中一个子数组被完全复制后,将另一个子数组的剩余元素直接按顺序复制到
temp中。 - 写回原数组:将临时数组
temp中的数据拷贝回原数组的arr[left...right]区间。
合并操作代码示例(关键部分):
def merge(arr, left, mid, right):
# 创建临时数组
temp = [0] * (right - left + 1)
i, j, k = left, mid + 1, 0
# 双指针比较,填充temp
while i <= mid and j <= right:
if arr[i] <= arr[j]: # 注意这里使用 <= 保证了排序的稳定性
temp[k] = arr[i]
i += 1
else:
temp[k] = arr[j]
j += 1
k += 1
# 复制左半部分剩余元素
while i <= mid:
temp[k] = arr[i]
i += 1
k += 1
# 复制右半部分剩余元素
while j <= right:
temp[k] = arr[j]
j += 1
k += 1
# 将temp中的数据拷贝回原数组
for idx in range(len(temp)):
arr[left + idx] = temp[idx]
步骤三:初始实现与复杂度分析
将步骤一的递归框架和步骤二的合并函数组合起来,就得到了完整的归并排序。
时间复杂度分析:
- 分解过程:递归树的高度为
log₂ n(以2为底)。 - 合并过程:每一层递归都需要遍历所有
n个元素进行合并。 - 因此,总时间复杂度为 O(n log n),并且最好、最坏、平均情况都是如此。
空间复杂度分析:
- 在上面的实现中,每次合并操作都需要创建一个与当前待合并区间大小相同的临时数组
temp。 - 递归调用栈的深度为
O(log n)。 - 但是,主要的空间开销来自临时数组。考虑最顶层的那次合并,它需要一个大小为
n的临时数组。由于递归调用是深度优先的,在某一时刻,递归栈上可能同时存在多个未完成的merge调用,但它们所需的临时数组空间总和不会超过n(可以想象为递归过程是“用完即释放”的)。 - 因此,总的空间复杂度为 O(n)(用于临时数组) + O(log n)(用于递归栈) ≈ O(n)。
步骤四:空间复杂度优化策略
上面的标准实现有一个可以优化的地方:避免在每次合并时都创建新的临时数组。
优化思路:
在整个排序过程中,我们只使用一个全局的、大小与原数组 arr 相同的临时数组 temp_arr。
- 在排序入口函数中,一次性分配好这个临时数组。
- 在递归函数中,将需要排序的数据从原数组
arr归并到临时数组temp_arr的对应区间。 - 然后,再将排好序的数据从
temp_arr的该区间拷贝回原数组arr。 - 或者,更巧妙的方法是,交替使用原数组和临时数组作为“源”和“目标”。在一次合并中,从“源”读取数据,向“目标”写入排序结果。下一次合并时,交换“源”和“目标”的角色。这样,最终有序的数据总是存储在原始输入数组中。
优化后的合并逻辑(交替角色):
假设我们有一个辅助函数 merge_sort_helper(src, dst, left, right),它的含义是:将 src 中 [left, right] 区间的数据排序后,放入 dst 的 [left, right] 区间。
def merge_sort_helper(src, dst, left, right):
if left >= right:
# 如果区间内只有一个元素,直接拷贝(如果src和dst不同的话)
if src is not dst: # 或者更精确地判断索引,这里为概念说明
dst[left] = src[left]
return
mid = (left + right) // 2
# 注意这里递归调用时,交换了src和dst的角色!
# 目的是让递归子过程向src中写入有序结果,然后当前过程从src读取并合并到dst
merge_sort_helper(dst, src, left, mid) # 排序左半部分,结果存到src
merge_sort_helper(dst, src, mid+1, right) # 排序右半部分,结果存到src
# 现在src的[left, mid]和[mid+1, right]都是有序的
# 将它们合并,存入dst的[left, right]
i, j, k = left, mid+1, left
while i <= mid and j <= right:
if src[i] <= src[j]:
dst[k] = src[i]
i += 1
else:
dst[k] = src[j]
j += 1
k += 1
while i <= mid:
dst[k] = src[i]
i += 1
k += 1
while j <= right:
dst[k] = src[j]
j += 1
k += 1
def optimized_merge_sort(arr):
n = len(arr)
temp_arr = arr.copy() # 创建一个初始副本作为辅助空间
merge_sort_helper(temp_arr, arr, 0, n-1) # 从temp_arr到arr
优化后的空间复杂度:
- 我们只使用了一个额外的、大小恒为
n的数组temp_arr。 - 递归栈深度依然是
O(log n)。 - 因此,总的空间复杂度从 O(n) 优化到了 O(n)?等一下,看起来还是
O(n)。
关键区别:
标准实现中,虽然分析起来是 O(n),但频繁的分配和释放小数组会带来内存分配开销。优化后:
- 内存分配开销降低:只需要一次
O(n)的内存分配。 - 常数因子优化:避免了多次分配和垃圾回收(在支持GC的语言中)的开销。
- 精确的空间占用:在任何时刻,额外占用的空间严格等于
n个元素(加上栈开销),而标准实现在递归树不同分支上可能因临时数组未及时释放而存在理论上的峰值略高的可能(尽管分析仍为O(n))。
所以,这个优化主要提升了算法的实际运行效率(减少了动态内存操作)并明确了空间占用上限,而复杂度类 O(n) 本身没有改变。这是归并排序一种经典的空间优化技巧。