数据结构(四):堆排序与归并排序

发布时间:2026/6/28 23:29:13
数据结构(四):堆排序与归并排序 引言在算法的世界里高效排序是数据处理的基石而堆排序与归并排序作为时间复杂度稳定在O(n log n)的经典排序算法凭借其稳定的性能、清晰的逻辑成为面试高频考点与工程实践的重要工具。堆排序以“原地排序”的空间优势脱颖而出归并排序则以“稳定排序”的特性备受青睐。本文将围绕堆排序的大顶堆构建、数组下标映射、核心维护方法以及归并排序的分治思想、递归合并、内存模型两大核心方向拆解算法底层逻辑辅以代码示例、复杂度推导与内存流程详解帮你从“原理理解”到“代码实现”全面掌握这两种经典排序算法。一、堆排序基于大顶堆的高效原地排序堆排序是一种基于完全二叉树结构的原地排序算法核心是将数组构建为大顶堆通过不断交换堆顶与堆底元素逐步筛选出最大值最终实现数组升序排序。它兼顾了O(n log n)的时间复杂度与O(1)的空间复杂度是工程中常用的高效排序方案。1. 核心排序思路与构建逻辑堆排序的核心逻辑可概括为“建堆、交换、调整”的循环过程而这一切的前提是理解“大顶堆”的定义与完全二叉树的数组存储规则。1大顶堆构建与交换机制堆排序的核心是利用大顶堆父节点值≥子节点值实现数据筛选整个流程分为两大阶段初始建堆阶段将无序数组调整为大顶堆此时堆顶元素是整个数组的最大值位于索引0。交换与调整阶段将堆顶最大值与堆底元素交换使堆底元素成为数组最后一个元素不再参与后续堆调整然后将剩余元素重新调整为大顶堆重复这一过程直到堆中只剩一个元素此时数组完成升序排序。举个直观例子假设数组为[3,1,4,1,5,9,2,6]初始建堆后堆顶是9交换堆顶9与堆底6数组变为[6,1,4,1,5,9,2,3]剩余[6,1,4,1,5,2]重新调整为大顶堆继续交换堆顶6与新堆底2直至所有元素排序完成。2数组下标映射关系无需建树的巧妙实现堆排序无需显式构建二叉树节点而是通过数组下标的映射规则直接在原数组上模拟完全二叉树的结构这是堆排序原地排序的关键。完全二叉树与数组的映射规则如下假设当前节点的下标为i从0开始计数左子节点的下标2*i 1右子节点的下标2*i 2父节点的下标(i-1)//2整数除法向下取整这种映射规则让我们仅通过下标就能快速定位父子节点实现大顶堆的调整。例如父节点下标0的左子节点是2*011右子节点是2*022下标1的左子节点是2*113右子节点是2*124下标2的父节点是(2-1)//20。通过这种映射我们完全可以直接在原数组上操作无需额外空间存储树结构。3维护方法Heapify的统一从根到叶的通用调整无论是初始建堆还是交换堆顶后的堆调整本质都是对“以某个节点为根的子树”进行大顶堆维护这一核心逻辑可统一为Heapify方法也叫堆化方法该方法的核心是将指定节点为根的子树调整为符合大顶堆规则的结构。初始建堆的调用逻辑完全二叉树的最后一个非叶子节点的下标是(n-1)//2n为数组长度因为叶子节点没有子节点无需调整。初始建堆时从最后一个非叶子节点开始向前遍历到根节点下标0对每个节点调用Heapify逐步构建完整的大顶堆。交换后的调用逻辑交换堆顶与堆底后堆的大小减1堆底元素不再参与堆调整此时只需对根节点下标0调用Heapify将剩余元素重新调整为大顶堆无需从头遍历。这种统一设计让代码逻辑更简洁实现了“一次维护方法贯穿整个堆排序流程”。2. 代码实现与时间复杂度分析理解了核心思路后我们将通过代码实现堆排序并深入推导其时间复杂度掌握算法的性能本质。1堆排序核心代码实现堆排序的代码围绕两个核心方法展开Heapify子树维护方法和heapSort主排序方法以下是完整实现def heapify(arr, heap_size, root_index): 维护以root_index为根的子树为大顶堆 :param arr: 待调整的数组 :param heap_size: 当前堆的有效长度即参与堆调整的元素个数 :param root_index: 需要调整的根节点下标 # 记录当前根节点下标用于后续向下调整 largest root_index # 根据下标映射计算左右子节点下标 left_child 2 * root_index 1 right_child 2 * root_index 2 # 判断左子节点是否存在且大于根节点 if left_child heap_size and arr[left_child] arr[largest]: largest left_child # 判断右子节点是否存在且大于当前最大值可能是根节点或左子节点 if right_child heap_size and arr[right_child] arr[largest]: largest right_child # 如果最大值不是根节点说明需要交换并继续向下调整 if largest ! root_index: # 交换根节点与最大值节点 arr[root_index], arr[largest] arr[largest], arr[root_index] # 递归调整交换后的子树以largest为新的根节点 heapify(arr, heap_size, largest) def heap_sort(arr): 堆排序主方法 n len(arr) if n 1: return arr # 阶段1初始构建大顶堆 # 从最后一个非叶子节点开始向前遍历到根节点下标0调用heapify for i in range((n - 1) // 2, -1, -1): heapify(arr, n, i) # 阶段2交换堆顶与堆底逐步排序 # 堆的大小从n逐渐减到1heap_size表示当前堆的有效长度 for heap_size in range(n - 1, 0, -1): # 交换堆顶arr[0]与堆底arr[heap_size-1] arr[0], arr[heap_size - 1] arr[heap_size - 1], arr[0] # 堆大小减1调整剩余元素为大顶堆仅调整根节点 heapify(arr, heap_size, 0) return arr # 测试示例 if __name__ __main__: arr [3, 1, 4, 1, 5, 9, 2, 6] print(排序前, arr) heap_sort(arr) print(排序后, arr) # 输出排序后 [1, 1, 2, 3, 4, 5, 6, 9]代码关键逻辑说明heapify方法通过循环这里用递归实现也可改为循环避免栈溢出风险找到当前子树中的最大值若根节点不是最大值则交换继续调整子树确保子树符合大顶堆规则。初始建堆时从最后一个非叶子节点向前遍历是因为叶子节点无需调整从后往前可保证每个子树都先调整为大顶堆最终全局形成大顶堆。交换堆顶与堆底后堆大小减1此时仅调整根节点就能快速恢复剩余元素的大顶堆属性逐步筛选出所有最大值。2时间复杂度推导为何是O(n log n)堆排序的时间复杂度由“初始建堆”和“交换与调整”两个阶段的时间开销共同决定且两个阶段都围绕Heapify方法展开核心是分析Heapify的时间复杂度。Heapify的时间复杂度Heapify方法的核心是从根节点向下遍历调整子树结构调整的次数等于树的高度。完全二叉树的高度与元素个数n的关系是log₂n以2为底n的对数向上取整因此单次Heapify的时间复杂度为O(log n)。初始建堆的时间复杂度初始建堆需要对从最后一个非叶子节点到根节点的所有节点各调用一次Heapify共n个节点。但注意大部分节点位于树的底层叶子节点这些节点的子树高度仅为1无需深度调整只有少数高层节点的子树高度较高。综合计算初始建堆的总时间复杂度为O(n)可通过等比数列求和推导底层n/2个节点各调整1次倒数第二层n/4个节点各调整2次总次数约为2n属于O(n)。交换与调整阶段的时间复杂度这个阶段需要执行n-1次交换每次交换后调用一次Heapify调整根节点共n-1次Heapify每次时间复杂度为O(log n)因此该阶段的总时间复杂度为O(n log n)。综上堆排序的总时间复杂度为初始建堆的O(n)加上交换调整的O(n log n)核心由后者主导因此最终时间复杂度为O(n log n)且在最坏、最好、平均情况下时间复杂度都稳定为O(n log n)同时空间复杂度为O(1)仅通过下标操作无额外存储是高效原地排序的典范。二、归并排序稳定分治的有序合并算法归并排序的核心是分治思想它将复杂问题拆分为简单的子问题解决子问题后再合并结果天然适合处理大规模数据排序。同时归并排序是稳定排序相等元素的相对位置不变且时间复杂度稳定为O(n log n)广泛应用于需要稳定排序的场景。1. 分治思想与合并逻辑归并排序的逻辑清晰直观先拆后合通过不断拆分让子数组变得有序再通过合并让子数组恢复为更大的有序数组最终完成整体排序。1合并有序序列双指针的高效合并策略合并是归并排序的核心操作目标是将两个已排序的连续子数组合并为一个有序数组。实现的核心是双指针遍历具体步骤如下假设要合并的两个有序子数组为左子数组arr[left...mid]右子数组arr[mid1...right]创建临时数组大小为right - left 1用于存储合并后的有序结果。定义两个指针i和j分别指向左子数组的起始位置left和右子数组的起始位置mid1。循环比较arr[i]和arr[j]将较小的元素放入临时数组同时移动对应指针若某子数组遍历完毕将另一个子数组的剩余元素全部追加到临时数组。将临时数组中的元素按顺序写回原数组的left到right区间完成合并。合并示例左子数组[2,5,7]右子数组[3,6,9]合并过程如下i0指向2j0指向323临时数组加入2ii1指向5j0指向335临时数组加入3ji1指向5j1指向656临时数组加入5ii2指向7j1指向667临时数组加入6ji2指向7j2指向979临时数组加入7i左子数组遍历完将右子数组剩余的9加入临时数组最终合并为[2,3,5,6,7,9]。2递归拆分策略从无序到有序的天然递归归并排序的递归拆分遵循“大问题拆小问题”的思路具体步骤如下拆分规则将当前待排序的数组从中间位置mid (left right) // 2拆分为左右两个子数组左子数组范围是left...mid右子数组范围是mid1...right。递归终止条件当子数组的长度为1时即left right子数组天然有序无需再拆分直接触发合并操作。递归流程先递归拆分左子数组再递归拆分右子数组待左右子数组都拆分到长度为1后逐层向上返回对每层的左右子数组进行合并最终合并为完整的有序数组。这种“先递归到底再逐层合并”的过程类似于“先拆包裹再逐一还原包裹”让无序数组逐步有序。2. 递归实现与内存模型归并排序的递归实现涉及参数传递、临时空间创建、数据写回等环节而内存模型的解析能帮我们理解递归调用的执行顺序和空间占用情况。1递归实现核心代码归并排序的递归实现包含两个核心函数merge合并函数和merge_sort递归排序函数以下是完整代码def merge(arr, left, mid, right): 合并两个有序子数组arr[left...mid] 和 arr[mid1...right] :param arr: 原数组 :param left: 左边界下标 :param mid: 中间位置下标 :param right: 右边界下标 # 1. 创建临时数组存储合并结果 temp [0] * (right - left 1) i left # 左子数组指针初始指向左子数组起点 j mid 1 # 右子数组指针初始指向右子数组起点 k 0 # 临时数组指针初始指向临时数组起点 # 2. 双指针遍历合并两个有序子数组 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 # 3. 处理剩余元素若左子数组有剩余全部追加到临时数组 while i mid: temp[k] arr[i] i 1 k 1 # 4. 处理剩余元素若右子数组有剩余全部追加到临时数组 while j right: temp[k] arr[j] j 1 k 1 # 5. 将临时数组的结果写回原数组的对应区间 for index in range(len(temp)): arr[left index] temp[index] def merge_sort(arr, left, right): 递归实现归并排序 :param arr: 待排序数组 :param left: 当前处理区间的左边界 :param right: 当前处理区间的右边界 # 递归出口区间长度1无需排序 if left right: return # 计算中间位置拆分区间 mid (left right) // 2 # 递归拆分左子数组 merge_sort(arr, left, mid) # 递归拆分右子数组 merge_sort(arr, mid 1, right) # 合并左右有序子数组 merge(arr, left, mid, right) # 对外暴露的归并排序入口函数 def merge_sort_main(arr): 归并排序主入口封装参数简化调用 if len(arr) 1: return arr merge_sort(arr, 0, len(arr) - 1) return arr # 测试示例 if __name__ __main__: arr [3, 1, 4, 1, 5, 9, 2, 6] print(排序前, arr) merge_sort_main(arr) print(排序后, arr) # 输出排序后 [1, 1, 2, 3, 4, 5, 6, 9]代码关键逻辑说明递归出口left right时区间长度为1或0无需排序直接返回这是递归的终止条件。参数传递通过传递left和right下标界定当前处理的区间无需创建新的子数组逻辑上划分区间物理上复用原数组空间节省了存储成本。临时空间与写回合并时必须借助临时数组因为如果直接在原数组合并会导致元素覆盖例如左子数组的元素会被右子数组的元素覆盖丢失原数据。合并完成后必须将临时数组的元素写回原数组的对应位置才能将排序结果固定到原数组。2内存执行流程递归调用与空间管理的直观解析归并排序的递归调用涉及栈Stack和方法调用、堆Heap和临时数组分配理解其内存执行流程能帮我们更清晰地掌握递归的执行顺序。我们以数组[3,1,4,2]为例详细解析归并排序的内存执行流程1. 栈内存递归调用的入栈与出栈递归调用的过程是方法栈帧不断入栈、出栈的过程遵循“后进先出”的规则阶段1递归拆分栈帧入栈主调用merge_sort_main(arr)调用merge_sort(arr,0,3)第一个栈帧入栈记录参数left0、right3计算mid1然后递归调用merge_sort(arr,0,1)第二个栈帧入栈。第二个栈帧记录left0、right1计算mid0递归调用merge_sort(arr,0,0)第三个栈帧入栈。第三个栈帧中left0 right0触发递归出口栈帧出栈返回上层第二个栈帧。第二个栈帧继续执行递归调用merge_sort(arr,1,1)第四个栈帧入栈同样触发出口出栈返回第二个栈帧。第二个栈帧执行merge(arr,0,0,1)完成合并后栈帧出栈返回第一个栈帧。第一个栈帧继续执行递归调用merge_sort(arr,2,3)第五个栈帧入栈后续重复拆分逻辑直至所有子数组拆分完成。阶段2逐层合并栈帧出栈当所有子数组都拆分到长度为1后开始逐层合并每完成一次合并当前栈帧出栈返回上层调用直到第一个栈帧完成合并整个排序结束。2. 堆内存临时数组的创建与回收堆内存用于存储临时数组临时数组的生命周期仅与当前合并操作绑定临时数组的创建当执行merge函数时会创建对应大小的临时数组大小为right - left 1例如合并[3]和[1]时临时数组大小为2。临时数组的写回合并完成后将临时数组的元素写回原数组此时临时数组不再被引用JVM会回收这部分内存避免内存泄漏。空间特性归并排序的空间复杂度为O(n)因为每次合并最多需要一个长度为n的临时数组当合并整个数组时且临时数组可以复用在不同层级的合并中临时数组可被重复创建和回收实际占用的额外空间约为n。3递归实现的优缺点与适用场景优点时间复杂度稳定为O(n log n)无论最好、最坏还是平均情况性能一致是稳定排序相等元素的相对位置不会改变适合对元素顺序有要求的场景如排序包含重复元素的结构化数据逻辑清晰拆分与合并的思路易于理解和实现适合处理大规模数据。缺点空间复杂度为O(n)需要额外的临时数组空间开销比堆排序大递归调用会占用栈空间当数据量极大时可能出现栈溢出可通过非递归实现解决非递归归并用迭代代替递归避免栈溢出。适用场景需要稳定排序的场景如电商订单排序、学生成绩排序大规模数据排序尤其是需要和外部存储结合的场景如磁盘排序归并排序天然适合外排序。三、堆排序与归并排序的核心对比为了更清晰地理解两种算法的差异我们从时间复杂度、空间复杂度、稳定性、适用场景等维度进行对比帮助大家在实际应用中选择合适的算法对比维度堆排序归并排序时间复杂度O(n log n)稳定O(n log n)稳定空间复杂度O(1)原地排序O(n)需要临时数组稳定性不稳定交换可能改变相等元素顺序稳定合并保持元素相对顺序核心思想大顶堆筛选、数组映射分治拆分、有序合并适用场景内存受限的大规模排序、无需稳定排序的场景需要稳定排序、大规模数据排序代码复杂度中等需理解堆结构中等递归逻辑清晰四、总结与学习建议堆排序与归并排序作为O(n log n)的经典排序算法是算法学习的核心里程碑它们的核心思想堆的结构调整、分治的拆分合并不仅适用于排序更广泛应用于树结构、分治算法等更复杂的技术场景。1. 核心知识点总结堆排序基于大顶堆通过数组下标映射实现完全二叉树操作核心是Heapify方法的两次应用初始建堆、交换后调整时间复杂度O(n log n)空间复杂度O(1)是高效的原地排序但排序不稳定关键在于掌握数组与完全二叉树的下标映射关系以及Heapify的向下调整逻辑。归并排序基于分治思想核心是“拆分到有序再合并为有序”关键是双指针合并有序序列时间复杂度O(n log n)空间复杂度O(n)是稳定的排序算法关键在于理解递归的拆分与合并顺序以及临时数组的创建与写回机制同时掌握递归调用的栈内存流程。2. 学习与实践建议动手手写代码堆排序的Heapify和归并排序的merge方法是核心建议先不看代码尝试凭理解手写再对比标准实现查漏补缺重点关注边界条件如下标越界、递归出口的处理。可视化调试借助代码调试工具或可视化工具如pythontutor可视化Python代码执行过程观察堆排序的数组下标变化和归并排序的递归调用栈、临时数组变化直观理解执行逻辑。拓展非递归实现在掌握递归实现后尝试实现非递归版本的堆排序和归并排序尤其是非递归归并排序用迭代代替递归避免栈溢出这对理解算法的本质和空间优化有很大帮助。结合实际场景选型在实际开发中根据需求选择算法需要稳定排序优先选归并排序内存受限且不需要稳定排序优先选堆排序小规模数据可考虑简单排序如插入排序提升效率。关联其他算法对比学习其他排序算法如快速排序、插入排序理解不同算法的时间复杂度、空间复杂度、稳定性差异形成完整的排序算法知识体系。算法学习的核心是理解思想而非死记硬背堆排序的“堆结构思维”和归并排序的“分治思维”不仅能解决排序问题更能培养拆分复杂问题、设计高效解决方案的能力。希望本文能帮助你彻底掌握这两种算法将核心逻辑融入自己的编程思维在算法学习和工程实践中游刃有余