堆排序
简单选择排序虽然思路清晰,但每次选择最小元素时都需要遍历未排序部分的所有元素,导致大量的比较操作。能否利用某种数据结构来优化选择最值的过程呢?答案是肯定的。堆这种特殊的完全二叉树结构,能够在对数时间内完成插入和删除最值操作,正好可以用来改进选择排序的效率。堆排序正是基于堆这种数据结构设计的一种高效排序算法,它巧妙地利用堆的性质,将选择排序的时间复杂度从O(n2)O(n^2)O(n2)降低到O(nlogn)O(n\log n)O(nlogn)。
1. 堆的基本概念
在理解堆排序之前,需要先掌握堆的定义和性质。堆是一种特殊的完全二叉树,它满足堆的性质:每个节点的值都大于或等于(或小于或等于)其子节点的值。根据节点值与子节点值的关系,堆可以分为两种类型。
如果每个节点的值都大于或等于其子节点的值,这样的堆称为大根堆或最大堆。在大根堆中,根节点的值是整个堆中的最大值,并且任意子树也都是一个大根堆。与之相对,如果每个节点的值都小于或等于其子节点的值,这样的堆称为小根堆或最小堆。在小根堆中,根节点的值是整个堆中的最小值。
堆虽然是一种树形结构,但通常采用顺序存储方式,即使用数组来存储。这是因为堆是完全二叉树,使用数组存储不会浪费空间,并且可以方便地通过下标计算父节点和子节点的位置。对于数组中下标为iii的节点,其左子节点的下标为2i+12i+12i+1,右子节点的下标为2i+22i+22i+2,父节点的下标为⌊(i−1)/2⌋\lfloor(i-1)/2\rfloor⌊(i−1)/2⌋(假设数组下标从0开始)。
下面通过一个具体的例子来说明大根堆的结构。
90 75 80 60 55 70 65 40 50
上图展示了一个大根堆的树形结构,其中根节点90是最大值,每个父节点的值都大于或等于其子节点的值。例如,节点75大于其子节点60和55,节点80大于其子节点70和65。这个堆对应的数组表示为[90, 75, 80, 60, 55, 70, 65, 40, 50],可以看到,数组的顺序存储方式完整地保留了堆的结构信息。
2. 堆的调整操作
堆排序的核心操作是堆的调整,包括向下调整和建堆过程。当堆的某个节点违反了堆的性质时,需要通过调整操作来恢复堆的性质。
向下调整是指从某个节点开始,将该节点与其子节点进行比较和交换,使得以该节点为根的子树满足堆的性质。具体过程是,将当前节点与其左右子节点中较大的一个进行比较(对于大根堆),如果当前节点小于该子节点,则交换两者的位置,然后继续对交换后的位置进行向下调整,直到当前节点大于等于其所有子节点或到达叶子节点为止。
第二次调整 第一次调整 调整前 75 80 70 60 55 30 65 75 80 30 60 55 70 65 75 30 80 60 55 70 65
上图展示了对一个违反堆性质的节点进行向下调整的过程。初始时,根节点30小于其子节点75和80,违反了大根堆的性质。第一次调整时,将30与较大的子节点80交换位置。交换后,节点30来到了右子树的根位置,仍然小于其子节点70,需要继续调整。第二次调整时,将30与70交换,此时30已经是叶子节点,调整完成,整个树恢复了大根堆的性质。
建堆操作是指将一个无序数组调整为一个堆。建堆的过程是从最后一个非叶子节点开始,依次对每个非叶子节点进行向下调整。由于完全二叉树的性质,最后一个非叶子节点的下标为⌊n/2⌋−1\lfloor n/2 \rfloor - 1⌊n/2⌋−1(假设数组下标从0开始,长度为nnn)。从后向前对每个非叶子节点进行调整,可以保证调整某个节点时,其子树已经是堆,从而保证调整的正确性。
调整节点50形成堆 调整节点80 调整节点30 原始数组 60 80 70 30 55 50 65 60 50 80 30 55 70 65 60 50 80 30 55 70 65 30 50 80 60 55 70 65
上图展示了将数组[50, 30, 80, 60, 55, 70, 65]建堆的过程。首先从最后一个非叶子节点30开始调整,将30与其较大子节点60交换。然后调整节点80,发现80已经满足堆性质,无需交换。最后调整根节点50,将50与较大子节点80交换,交换后50来到右子树,继续与70交换,最终形成大根堆[80, 60, 70, 30, 55, 50, 65]。
3. 堆排序的基本过程
堆排序利用堆的性质进行排序,其基本思想是先将待排序数组建成一个大根堆,然后将堆顶元素(最大值)与堆的最后一个元素交换,使最大值到达最终位置,接着对剩余元素重新调整为堆,重复这个过程直到所有元素都排好序。
堆排序的完整过程可以分为两个主要阶段进行理解。第一阶段是建堆阶段,将无序数组调整为一个大根堆,这个过程保证了堆顶元素是当前所有元素中的最大值。第二阶段是排序阶段,反复执行"交换堆顶与末尾元素,然后调整剩余元素为堆"的操作,每次操作都能将当前最大值放到正确的位置。
(1)建堆阶段
建堆阶段从最后一个非叶子节点开始,依次向前对每个非叶子节点进行向下调整。这个过程的时间复杂度为O(n)O(n)O(n),虽然每次调整的时间复杂度为O(logn)O(\log n)O(logn),但由于大部分节点的高度较小,总的时间复杂度可以证明为O(n)O(n)O(n)。
(2)排序阶段
排序阶段重复执行以下步骤:将堆顶元素(当前最大值)与堆的最后一个元素交换,此时最大值到达数组末尾的正确位置;然后将堆的大小减1,对新的堆顶元素进行向下调整,使剩余元素重新形成堆。这个过程需要进行n−1n-1n−1次,每次调整的时间复杂度为O(logn)O(\log n)O(logn),因此排序阶段的总时间复杂度为O(nlogn)O(n\log n)O(nlogn)。
下面通过一个完整的例子来展示堆排序的过程。
继续交换和调整 调整后 交换90和65 初始大根堆 60 75 70 65 55 80 90 75 80 70 60 55 65 90 75 65 80 60 55 70 90已排序 75 90 80 60 55 70 65
上图展示了堆排序的部分过程。初始时已经建好大根堆,堆顶90是最大值。第一次交换将90与末尾元素65交换,90到达最终位置。然后对65进行向下调整,与80交换,再与70交换,形成新的大根堆。接着将新的堆顶80与末尾元素交换,继续调整,如此反复,直到所有元素都排好序。每次交换和调整后,数组末尾已排序的部分逐渐增长,未排序部分逐渐缩小。
4. 堆排序算法实现
堆排序的实现需要两个核心函数:向下调整函数和堆排序主函数。向下调整函数负责维护堆的性质,堆排序主函数负责整体的建堆和排序流程。
向下调整函数的实现思路是,从当前节点开始,找出其左右子节点中较大的一个,如果该子节点大于当前节点,则交换两者,然后继续对交换后的位置进行调整。这个过程使用循环实现,循环条件是当前节点有子节点且需要调整。具体来说,设当前调整节点的下标为iii,左子节点下标为2i+12i+12i+1,右子节点下标为2i+22i+22i+2。首先假设最大值在当前节点,然后依次与左右子节点比较,如果子节点更大,则更新最大值的位置。如果最大值不在当前节点,则交换当前节点与最大值节点,并继续对最大值节点的新位置进行调整。
堆排序主函数的实现包括两个阶段。建堆阶段从最后一个非叶子节点开始,下标为⌊n/2⌋−1\lfloor n/2 \rfloor - 1⌊n/2⌋−1,依次向前对每个非叶子节点调用向下调整函数。排序阶段从最后一个元素开始,依次将堆顶元素与当前未排序部分的最后一个元素交换,然后对堆顶元素进行向下调整。这个过程重复n−1n-1n−1次,每次未排序部分的长度减1。
需要注意的是,堆排序是一种不稳定的排序算法。这是因为在交换堆顶与末尾元素时,可能会改变相等元素的相对位置。例如,两个值相等的元素,一个在堆顶,另一个在末尾,交换后它们的相对顺序就发生了变化。
5. 堆排序的性能分析
堆排序的性能特点使其在某些场景下具有独特的优势。从时间复杂度来看,堆排序的建堆阶段时间复杂度为O(n)O(n)O(n),排序阶段需要进行n−1n-1n−1次调整,每次调整的时间复杂度为O(logn)O(\log n)O(logn),因此总的时间复杂度为O(nlogn)O(n\log n)O(nlogn)。这个时间复杂度在最好、最坏和平均情况下都是O(nlogn)O(n\log n)O(nlogn),具有良好的稳定性,不会像快速排序那样在最坏情况下退化为O(n2)O(n^2)O(n2)。
从空间复杂度来看,堆排序只需要常数个额外的辅助空间用于元素交换,因此空间复杂度为O(1)O(1)O(1),是一种原地排序算法。这使得堆排序在内存受限的环境中具有优势。
堆排序的比较次数和移动次数都比较稳定。在建堆阶段,比较次数约为2n2n2n次,移动次数约为nnn次。在排序阶段,每次调整最多需要logn\log nlogn次比较和移动,共需要(n−1)logn(n-1)\log n(n−1)logn次比较和移动。因此总的比较次数约为2n+(n−1)logn2n + (n-1)\log n2n+(n−1)logn,移动次数约为n+3(n−1)lognn + 3(n-1)\log nn+3(n−1)logn。虽然堆排序的比较次数少于简单选择排序的n(n−1)/2n(n-1)/2n(n−1)/2次,但由于堆排序需要频繁地进行元素移动,实际运行时间在某些情况下可能不如快速排序。
堆排序适用于数据量较大且对时间复杂度有稳定要求的场景。它不像快速排序那样可能退化,也不像归并排序那样需要额外的O(n)O(n)O(n)空间。在需要找出前kkk个最大(或最小)元素的问题中,堆排序也有很好的应用,只需要建堆后取出kkk次堆顶元素即可,时间复杂度为O(n+klogn)O(n + k\log n)O(n+klogn)。此外,堆这种数据结构本身在优先队列、任务调度等领域也有广泛应用,掌握堆排序有助于理解和使用这些数据结构。