常见排序算法总结(三)
常见排序算法总结(三)
排序就是将一组对象按照某种逻辑顺序重新排列的过程
优先队列
很多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次就将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。
在这种情况下,一个合适的数据结构应该支持两种操作:
- 删除最大元素
- 插入元素
这种数据结构叫做优先队列。
优先队列的API
方法名 | 描述 |
---|---|
PQ() | 创建一个优先队列 |
PQ(int max) | 创建一个初始容量为max的优先队列 |
PQ(int[] a) | 使用a[]中的元素创建一个优先队列 |
void insert(int v) | 向优先队列中插入一个元素 |
int max() | 返回最大元素 |
int delMax() | 删除并返回最大元素 |
boolean isEmpty() | 返回队列是否为空 |
int size() | 返回优先队列中的元素个数 |
一个优先队列的用例
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int m = in.nextInt();
PQ pq = new PQ(m);
while (in.hasNextInt()) {
pq.insert(in.nextInt());
if (pq.size() > m) {
pq.delMax();
// 如果优先队列中存在m+1个元素则删除其中最大的元素
}
}//最小的M个元素都在优先队列中
in.close();
Stack<Integer> stack = new Stack<>();
while (!pq.isEmpty()) {
stack.push(pq.delMax());
}
for (Integer integer : stack) {
System.out.println(integer);
}
}
初级实现
学习过的几种基础数据结构使我们实现优先队列的起点,我们可以使用有序或无序的数组或链表。
1.数组实现(无序)
insert()方法的代码和栈的push()方法完全一样。要是实现删除最大元素,我们可以添加一端类似于选择排序的内循环的代码,将最大元素和边界元素交换然后删除它。
2.数组实现(有序)
另一种方法就是在insert()方法中添加代码,将所有较大的元素向右移动一格以使数组保持有序(和插入排序一样)。这样,最大的元素总会在数组的一边,优先队列的删除最大元素操作就和栈的pop()操作一样了。
3.链表表示法
和上述方法类似,可以使用基于链表的下压栈的代码作为基础,而后可以选择修改pop()来找到并返回最大元素,或是修改push()来保证所有元素为逆序并用pop()来删除并返回链表的首元素(也就是最大的元素)
使用无序序列是解决这个问题的惰性方法,我们仅在必要的时候才会采取行动(找出最大元素),使用有序序列则是解决问题的积极方法,因为我们会尽可能未雨绸缪(在插入元素时就保持列表有序),使后续操作更有效.
实现栈或是队列与实现优先队列的最大不同在于对性能的要求.对于栈和队列,我们的实现能够在常数时间内完成所有操作;而对于优先队列,插入元素和删除最大元素这两个操作之一在最坏的情况下需要线性时间完成.接下来要讨论的基于数据结构堆的实现能够保证这两种操作都能更快的执行.
优先队列的各种实现在最坏情况下运行时间的增长数量级
数据结构 | 插入元素 | 删除最大元素 |
---|---|---|
有序数组 | N | 1 |
无序数组 | 1 | N |
堆 | logN | logN |
理想情况 | 1 | 1 |
堆的定义
数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。如果我们将所有元素画成一颗二叉树,将每个较大元素和两个较小的元素用边链接即可以很容易看出这种结构。
当一颗二叉树的每个节点都大于等于它的两个子节点时,它被成为堆有序
相应的,在堆有序的二叉树中,每个节点都小于等于它的父节点(如果有的话);从任意节点向上,我们都能得到一列非递减的元素;从任意节点向下,我们都能得到一列非递增的元素;
- 二叉堆表示法
如果我们用指针来表示堆有序的二叉树,那么每个元素都需要三个指针来找到它的上下节点(父节点和两个子节点各需要一个);如果我们使用完全二叉树,表达就会变得特别方便;要画出这样一颗完全二叉树,可以先定下根节点,然后一层一层地由上向下,从左至右,在每个节点的下方链接两个更小的节点,直至将N个节点全部连接完毕;完全二叉树只用数组而不需要指针就可以表示;具体方法就是将二叉树的节点按照层级顺序放入数组,根节点在位置1,它的子节点在位置2和3,而子节点的子节点则分别在位置4,5,6和7,以此类推;
二叉堆是一组能够堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置)
在一个堆中,位置k的节点的父节点的位置为[k/2],而它的两个子节点为位置则分别为2k和2k+1;这样在不使用指针的情况下我们也可以通过计算数组的索引在数中上下移动:从a[k]向上一层就令k等于k/2,向下一层则令k等于2k或2k+1。
用数组(堆)实现的完全二叉树的结构是很严格的,但它的灵活性已经足以让我们高效地实现优先队列。用它们我们将能实现对数级别的插入元素和删除最大元素的操作。利用在数组中无需指针即可沿树上下移动的便利和以下性质,算法保证了对数复杂度的性能。
堆的算法
我们用长度为N+1的私有数组pq[]来表示一个大小为N的堆,我们不会使用pq[0],堆元素放在pq[1]至pq[N]中。在排序算法中,我们只通过私有辅助函数less()和exch()来访问元素,但因为所有的元素都在数组pq[]中。堆的操作会首先进行一些简单的改动,打破堆的状态,然后再便利堆并按照要求将堆的状态恢复。我们称这个过程叫做堆的有序化。
堆实现的比较和交换方法如下代码所示:
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {
int t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
在有序化的过程中我们会遇到两种情况。当某个节点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个节点的优先级下降(例如,将各节点替换为一个较小的元素时),我们需要由上至下恢复堆的顺序。首先我们会学习如何实现这两种辅助操作,然后再用它们实现插入元素和删除最大元素的操作。
由下至上的堆有序化(上浮)
如果堆的有序状态因为某个节点变得比它的父节点更大而被打破,那么我们就需要通过交换它和它的父节点来修复堆。交换后,这个节点就比它的两个子节点都大(一个是曾经的父节点,另一个比它更小,因为它是曾经父节点的子节点),但这个节点仍然可能比它现在的父节点更大。我们可以一遍遍的用同样的方法恢复秩序,将这个节点不断向上移动直到我们遇到一个更大的父节点。只要记住位置k的节点的父节点的位置是[k/2],这个过程实现起来很简单。swim()方法中的循环可以保证只有位置k上的节点大于它的父节点时堆的有序状态才会被打破。因此只要该节点不再大于它的父节点,堆的有序状态就被恢复了。
当一个节点太大的时候它需要浮(swim)到堆的最高层
private void swim(int k) {
while (k > 1 && less(k/2, k)) {
exch(k/2, k);
k /= 2;
}
}
由上至下的堆有序化(下沉)
如果堆的有序状态因为某个节点变得比它的两个子节点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子节点中的较大者交换来恢复堆。交换可能会在子节点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将节点向下移动直到它的子节点都比它更小或是到达了堆的底部。由位置k的节点的子节点位于2k和2k+1可以直到得到对应的代码。
当一个节点太小的时候他需要沉(sink)到堆的最底层。
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j+1)) {
j++;
}
if (!less(k, j)) {
break;
}
exch(k, j);
k = j;
}
}
插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
删除最大元素。我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
下方算法解决了一开始提出的一个基本问题:它对优先队列API的实现能够保证插入元素和删除最大元素这两个操作的用时和队列的大小仅成对数关系。
public class PQ {
private Integer[] pq;
private int N = 0;
public PQ(int maxN) {
pq = new Integer[maxN + 1];
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
public void insert(int v) {
pq[++N] = v;
swim(N);
}
public int delMax() {
int max = pq[1];
exch(1, N--);
pq[N+1] = null;
sink(1);
return max;
}
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
private void exch(int i, int j) {
int t = pq[i];
pq[i] = pq[j];
pq[j] = t;
}
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j+1)) {
j++;
}
if (!less(k, j)) {
break;
}
exch(k, j);
k = j;
}
}
private void swim(int k) {
while (k > 1 && less(k/2, k)) {
exch(k/2, k);
k /= 2;
}
}
}
优先队列由一个基于堆的完全二叉树表示,存储与数组pq[1..N]中,pq[0]没有使用。在insert()中,我们将N加一并把新元素添加到数组的最后,然后用swim()恢复堆的秩序。在delMax()中,我们从pq[1]中得到需要返回的元素,然后将pq[N]移动到pq[1],将N减一并用sink()恢复堆的秩序。同时我们还将不再使用的pq[N+1]设为null,以便系统回收它所占用的空间。
调整数组大小
我们可以添加一个没有参数的构造函数,在insert()中添加将数组长度加倍的代码,在delete()中添加将数组长度减半的代码。这样,算法的用例就无需关注各种队列代销的限制。
堆排序
我们可以把任意优先队列变成一种排序方法。将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将他们按顺序删去。用无序数组实现的优先队列这么做相当于进行一次选择排序。下面我们就用堆来实现一种经典而优雅的排序算法——堆排序。
堆排序可以分为两个阶段。在堆的构造阶段中,我们将元是数组重新组织安排进一个堆中;然后在下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果。为了和我们已经学习过的代码保持一致,我们将使用一个面向最大元素的优先队列并重复删除最大元素。
堆的构造
从右至左用sink()方法构造子堆
数组的每个位置都已经是一个子堆的根节点了,sink()对于这些子堆也适用。如果一个节点的两个子节点都已经是堆了,那么在该节点上调用sink()可以将它们变成一个堆。这个过程会递归地建立起堆的秩序。开始时我们只需要扫描数组中的一半元素,因为我们可以跳过大小为1的子堆。最后我们在位置1上调用sink()方法,扫描结束。在排序的第一阶段,堆的构造方法和我们的想象有所不同,因为我们的目标是构造一个堆有序的数组并使最大元素位于数组的开头(次大的元素在附件)而非构造函数结束的末尾。
public static void sort(Integer[] a) {
int N = a.length - 1;
for (int k = N / 2; k >= 0; k--) {
sink(a, k, N);
}
while (N >= 0) {
exch(a, 0, N--);
sink(a, 0, N);
}
}
这段代码用sink()方法将a[1]到a[N]的元素排序(sink()方法被修改过,以a[]和N作为参数)。for循环构造了堆,然后while循环将最大的元素a[1]和a[N]交换并修复了堆,如此重复直到堆变空(这里和之前有一些不同就是这里实现的是a[0]至a[N-1]排序)。
堆排序的主要工作都是在第二阶段完成的。这里我们将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置。这个过程和选择排序有些类似(按照降序而非升序取出所有元素),但所需的比较要少得多,因为堆提供了一种从未排序部分找到最大元素的有效方法。
测试:
public static void main(String[] args) {
var a = new Integer[]{3, 1, 4, 0, 2, 5, 11, 9};
sort(a);
System.out.println(Arrays.toString(a));
}
输出结果:
[0, 1, 2, 3, 4, 5, 9, 11]