数据结构——堆排序
堆排序
简单选择排序虽然思路清晰,但每次选择最小元素时都需要遍历未排序部分的所有元素,导致大量的比较操作。能否利用某种数据结构来优化选择最值的过程呢?答案是肯定的。堆这种特殊的完全二叉树结构,能够在对数时间内完成插入和删除最值操作,正好可以用来改进选择排序的效率。堆排序正是基于堆这种数据结构设计的一种高效排序算法,它巧妙地利用堆的性质,将选择排序的时间复杂度从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大于其子节点60和55,节点80大于其子节点70和65。这个堆对应的数组表示为[90, 75, 80, 60, 55, 70, 65, 40, 50],可以看到,数组的顺序存储方式完整地保留了堆的结构信息。
2. 堆的调整操作
堆排序的核心操作是堆的调整,包括向下调整和建堆过程。当堆的某个节点违反了堆的性质时,需要通过调整操作来恢复堆的性质。
向下调整是指从某个节点开始,将该节点与其子节点进行比较和交换,使得以该节点为根的子树满足堆的性质。具体过程是,将当前节点与其左右子节点中较大的一个进行比较(对于大根堆),如果当前节点小于该子节点,则交换两者的位置,然后继续对交换后的位置进行向下调整,直到当前节点大于等于其所有子节点或到达叶子节点为止。
上图展示了对一个违反堆性质的节点进行向下调整的过程。初始时,根节点30小于其子节点75和80,违反了大根堆的性质。第一次调整时,将30与较大的子节点80交换位置。交换后,节点30来到了右子树的根位置,仍然小于其子节点70,需要继续调整。第二次调整时,将30与70交换,此时30已经是叶子节点,调整完成,整个树恢复了大根堆的性质。
建堆操作是指将一个无序数组调整为一个堆。建堆的过程是从最后一个非叶子节点开始,依次对每个非叶子节点进行向下调整。由于完全二叉树的性质,最后一个非叶子节点的下标为⌊n/2⌋−1\lfloor n/2 \rfloor - 1⌊n/2⌋−1(假设数组下标从0开始,长度为nnn)。从后向前对每个非叶子节点进行调整,可以保证调整某个节点时,其子树已经是堆,从而保证调整的正确性。
上图展示了将数组[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是最大值。第一次交换将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)。此外,堆这种数据结构本身在优先队列、任务调度等领域也有广泛应用,掌握堆排序有助于理解和使用这些数据结构。
