如何在Java中实现Heap Sort

116 阅读13分钟

简介

排序是用于解决问题的基本技术之一,特别是在那些与编写和实现高效算法有关的问题中。

通常情况下,排序是与搜索结合在一起的--这意味着我们首先对给定集合中的元素进行排序,然后在其中搜索一些东西,因为在一个已排序的集合中搜索一些东西通常比在一个未排序的集合中搜索更容易,因为我们可以做出有根据的猜测并对数据进行假设。

有许多算法可以有效地对元素进行排序,但在本指南中,我们将看看如何在Java中实现Heap Sort

为了理解堆排序的工作原理,我们首先需要理解它所基于的结构-- .在这篇文章中,我们将以.NET的形式来讨论这个问题。 二进制堆来谈,但只要稍作调整,同样的原理也可以推广到其他堆结构。

我们将做另一个没有堆的实现--而是PriorityQueues,它将算法简化为一行

堆作为一种数据结构

是一个专门的基于树的数据结构,它是一个完整的二叉树,满足堆的属性,也就是说,对于每个节点,它的所有子节点都与它有关系。在一个最大堆中,对于一个给定的父节点P和子节点C,P的值大于或等于子节点C的值。

类似地,在最小堆中,P的值小于或等于它的孩子C的值。位于堆的 "顶部 "的节点(即没有父母的节点)被称为

下面是一个最小堆(左)和最大堆(右)的例子。

heap representation

正如我们前面提到的,我们把堆看作是一个基于树的数据结构。然而,我们将用一个简单的数组来表示它,并且只定义每个节点(子节点)与它的父节点之间的关系。假设我们的数组从一个索引开始0 ,我们可以用下面的数组来表示上面插图中的最大堆。

53, 25, 41, 12, 6, 31, 18

我们也可以把这种表示方法解释为从左到右逐级读图。本质上,我们在父节点和子节点之间定义了某种关系。

对于数组中的k-th 元素,我们可以在2*k+12*k+2 的位置找到它的子节点,假设索引从0 开始。同样,我们可以在(k-1)/2 的位置找到k-th 元素的父节点。

前面我们提到,堆是一个 完整的二叉树.一个完整的二叉树是一个二叉树,其中每一层(可能最后一层除外)都被完全填满,所有节点都是左对齐的。

**注意:**A 完整的二进制树可以和A 完全二叉树,但它的核心是一个不同的概念,全二叉树代表一棵树,其中除叶子外的每个节点都正好有两个孩子。

为了进一步解释完整二叉树的概念,让我们看一下前面插图中的最大堆的一个例子。如果我们去掉节点126 ,我们会得到下面的二进制树。

full binary tree

这棵树将在一个数组中表示为。

53, 25, 41, -, -, 31, 18

我们可以看到,这并不是一棵完整的二叉树,因为2 (如果根节点在0 )上的节点并不是左对齐的。而另一方面,下面的二叉树将代表一个完整的二叉树。

complete binary tree

这棵树的数组将是。

53, 25, 41, 12, 6

从上面的简短例子中,我们可以看到,从直觉上讲,一棵完整的二叉树是用一个没有 "空隙 "的数组来表示的,也就是说,我们在上面的第一个数组中表示的位置是-

继续我们对堆的解释--从堆中插入和删除元素的过程是堆排序的一个关键步骤。

**注意:**我们将专注于最大堆,但请记住,适用于最大堆的一切也适用于最小堆。

在最大堆中插入一个元素

使用我们之前拥有的同一个最大堆,假设我们想添加元素60 。乍一看,很明显,60 将是我们堆中最大的元素,所以它应该成为根元素。但这又提出了另一个问题:我们如何同时保持完整二叉树的形式,并同时添加60

让我们先把元素放在我们堆数组的最后一个位置,得到这样的结果。

// 0   1   2   3  4   5   6   7
  53, 25, 41, 12, 6, 31, 18, 60

上面一行的数字代表数组的索引位置

如前所述,k-th 节点的子节点位于2*k+12*k+2 的位置,而每个节点的父节点位于(k-1)/2 。按照同样的模式,60 将是12 的一个子节点。

现在,这扰乱了我们的最大堆的形式,因为比较和检查60 是否小于或等于12 ,会得到一个否定的答案。我们要做的是交换这两个,因为我们确信在二叉树下没有比60 小的数字,因为60 是一个叶子。

调换之后,我们得到以下结果。

// 0   1   2   3  4   5   6   7
  53, 25, 41, 60, 6, 31, 18, 12

我们重复刚才的步骤,直到60 在它的正确位置。60 的父元素现在将是25 。我们交换这两个,之后60 的父元素是53 ,之后我们也交换它们,最后得到一个最大堆。

// 0   1   2   3  4   5   6   7
  60, 53, 41, 25, 6, 31, 18, 12

从最大堆中删除一个元素

现在,让我们讨论删除一个元素。我们将使用与先前相同的最大堆(没有添加60 )。当谈到从堆中删除一个元素时,标准的删除操作意味着我们应该只删除元素。在最大堆的情况下,这是最大的元素,而在最小堆的情况下是最小的元素。

从堆中删除一个元素就像从数组中删除它一样简单。然而,这又产生了一个新的问题,因为删除后会在我们的二叉树中产生一个 "缺口",使其不完整。

对我们来说,幸运的是,解决方案也很简单--我们将被删除的根元素替换为在 最远的-右边上的 最低层中的元素替换掉。这样做可以保证我们再次拥有一棵完整的二叉树,但又一次产生了一个新的潜在问题:虽然我们的二叉树现在是完整的,但它可能不是一个堆。那么我们该如何解决这个问题呢?

让我们讨论一下,在与先前相同的最大堆上删除一个元素(在添加60 之前)。在我们删除了我们的根,并将我们最右边的元素移到它的位置上之后,我们有以下情况。

// 0   1   2   3  4   5  6
  18, 25, 41, 12, 6, 31

**注意:**位置6的元素是故意留空的--这在后面会很重要。

像这样表示,我们的数组并不是一个最大堆。我们接下来应该做的是将18 与它的子代进行比较,特别是与两个子代中较大的那个进行比较,在本例中是41 。如果两个子代中较大的那个比父代大,我们就将两者交换。

这样做之后,我们得到以下数组。

// 0   1   2   3  4   5  6
  41, 25, 18, 12, 6, 31

由于18 现在的位置是2 ,它唯一的孩子是31 ,由于这个孩子又比父孩子大,我们就把它们交换。

// 0   1   2   3  4   5  6  41, 25, 31, 12, 6, 18

就这样,我们又有了一个最大的堆!

插入和删除的时间复杂度

在实现该算法之前,让我们看看从堆中插入和删除元素的时间复杂性。由于我们使用的是二叉树状结构,所以插入和删除的时间复杂度自然是O(logn) ,其中n 代表我们数组的大小。

这是因为对于高度为h 的二叉树来说,考虑到堆的二叉性质--当向下遍历该树时,你甚至只能在两个选项中做出选择,每一步的可能路径都减少了两个。在最坏的情况下,当向下遍历到树的底部时--树的高度,h ,将是logn

至此,我们结束了对堆作为一种数据结构的解释,并进入本文的主要话题--堆排序

Java中的堆排序

通过利用堆和它的属性,我们将它表示为一个数组。我们也可以很容易地对任何数组进行最大堆化最大堆化是一个将元素以正确的顺序排列的过程,以便它们遵循最大堆属性。同样地,你也可以对一个数组进行最小堆积。

对于每个元素,我们需要检查它的任何子元素是否比自己小。如果是,就把其中一个与父元素交换,然后递归地重复这个步骤(因为新的大元素可能仍然比它的其他子元素大)。叶子没有孩子,所以它们自己就已经是最大的堆了。

让我们来看看下面这个数组。

// 0   1  2   3   4   5   6  
   25, 12, 6, 41, 18, 31, 53

让我们快速运行heapify算法,手动将这个数组做成一个堆,然后用Java实现代码来为我们做这件事。我们从右边开始,一直走到左边。

25 12 *6* 41 18 **31** **53** 

由于31 > 653 > 6 ,我们取两者中较大的一个(在这里是53 ),并与它们的父本交换,我们得到以下结果。25 12 5341 18 31**6.

25 *12* 6 **41** **18** 31 6 

再一次,18 > 1241 > 12 ,由于41 > 18 ,我们交换了4212

*25*, **41**, **53** 12, 18, 31, 6 

在这最后一步中,我们看到41 > 2553 > 25 ,由于53 > 41 ,我们将5325 对调。之后,我们对25 递归堆积。

53, 41, *25*, 12, 18, **31**, **6** 

31 > 25,所以我们交换了它们。

53, 41, 31, 12, 18, 25, 6 

我们得到了一个最大的堆!这个过程可能看起来令人生畏,但当用代码实现时,它实际上是相当简单的。堆化的过程对堆排序至关重要,它遵循三个步骤。

**1.**使用输入数组建立一个最大堆数组
**。**由于最大堆将数组中最大的元素存储在顶部(也就是数组的开头),我们需要将其与数组内的最后一个元素交换,接着将数组(堆)的大小减少1 。之后,我们对根部进行堆积
**。**只要我们的堆的大小大于1,我们就重复步骤2。

有了对该算法工作原理的良好直觉,我们就可以开始实施它了。一般来说,由于我们会多次调用heapify() 方法--我们将其与heapsort() 方法分开实现,并在其中调用它。

这使得实现更简洁,更容易阅读。让我们从heapify() 方法开始。

public static void heapify(int[] array, int length, int i) {
    int left = 2 * i + 1;
    int right = 2 * i + 2;
    int largest = i;
    if (left < length && array[left] > array[largest]) {
        largest = left;
    }
    if (right < length && array[right] > array[largest]) {
        largest = right;
    }
    if (largest != i) {
        int tmp = array[i];
        array[i] = array[largest];
        array[largest] = tmp;
        heapify(array, length, largest);
    }
}

heapify() 方法是做大部分重活的,它只是由三个if 语句组成。堆排序算法本身的流程也相当简单,主要依靠heapify()

public static void heapSort(int[] array) {
    if (array.length == 0) {
        return;
    }
    
    int length = array.length;
    
    // Moving from the first element that isn't a leaf towards the root
    for (int i = length / 2 - 1; i >= 0; i--) {
        heapify(array, length, i);
    }
    
    for (int i = length - 1; i >= 0; i--) {
        int tmp = array[0];
        array[0] = array[i];
        array[i] = tmp;
        heapify(array, i, 0);
    }
}

就这样吧!我们现在可以向heapSort() 方法提供一个数组,该方法对其进行就地排序。

public static void main(String[] args){
    int[] array = {25, 12, 6, 41, 18, 31, 53};
    heapSort(array);
    System.out.println(Arrays.toString(array));
}

这就导致了。

[6, 12, 18, 25, 31, 41, 53]

用一个优先队列实现堆排序

A 优先级队列是一个数据结构,实际上是一个特定类型的队列,其中的元素以优先级逐一添加,因此得名。移除元素时,从具有最高优先级的元素开始。这个定义本身确实与堆的定义相似,所以你也可以用这个非常方便的数据结构来实现堆排序,这是很自然的。

Java有一个内置的PriorityQueue ,位于util 包中。

import java.util.PriorityQueue;

PriorityQueue 有很多它自己的和从Queue 接口继承的方法,但对于我们的目的,我们只需要使用其中的几个。

  • boolean add(E e) - 将元素 插入到优先级队列中。e
  • E poll() - 检索并删除优先级队列的头部,如果是空的,则返回 。null
  • int size() - 返回优先级队列中的元素数量。

有了这些,我们可以通过一个单一的while() 循环真正实现堆排序。

首先,我们将创建并添加元素到优先级队列中,之后,只要我们的优先级队列pq 中至少有1 元素,我们就简单地运行一个while 循环。在每一次的迭代中,我们使用poll() 方法来检索和删除队列的头部,之后我们将其打印出来,并产生与先前相同的输出。

Queue<Integer> pq = new PriorityQueue<>();
int[] array = new int[]{25, 12, 6, 41, 18, 31, 53};
Arrays.stream(array).forEach(element -> pq.add(element));

while(pq.size() > 0){
    System.out.print(pq.poll() + " ");
}

这就导致了

6 12 18 25 31 41 53 

Heapsort的时间复杂度

让我们来讨论一下我们所涉及的两种方法的时间复杂性。

我们在前面讨论过,从堆中添加和删除元素需要O(logn) ,由于我们的for循环运行了n 次,其中n 是数组中元素的数量,这样实现的Heapsort的总时间复杂性是O(nlogn) 。另一方面,从优先队列中添加和删除元素也需要O(logn) ,这样做n 次,也会产生O(nlogn) 的时间复杂性。

那么空间复杂度呢?嗯,因为在这两种方法中,我们只使用起始数组来排序,这意味着Heap Sort所需要的额外空间是O(1) ,这使得Heap Sort成为一种就地的算法。

结语

总之,这篇文章已经涵盖了Heap Sort算法背后的理论和实现。我们首先解释了它是如何工作的,有一个直观的手动迭代,接着是两个实现。

虽然与快速排序合并排序等算法相比,堆排序的速度并不快,但当数据被部分排序或需要一个稳定的算法时,经常会用到堆排序。堆排序的就地性也使我们能够更好地使用内存,当内存受到关注的时候。