研磨算法:排序之堆排序
标签(空格分隔): 研磨算法
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。
堆
什么是堆
堆在Java中也称做优先级队列,使用过二叉堆的数据结构来实现的,用数组保存并按照一定条件排序,但实现对数级别的删除最大元素和插入元素操作。
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。则堆的含义表明,完全二叉树中所有非终端结点的值均不大于(或不小于)其左、右孩子结点的值。
由此,若序列{k1,k2,…,kn}是堆,则堆顶元素(或完全二叉树的根)必为序列中n个元素的最小值(或最大值)。
对于大顶堆和小顶堆,如果我们要取前最大的几个,我们要使用小顶堆;如果要取前最小的几个,要使用大顶堆。举一个例子:比如A与参加某个选秀节目,选秀节目要取前10名,那么A需要和前10名中最差的一个比,所以要找出最小的。
堆的操作
堆的操作涉及到上浮和下沉,我们把堆想象成一个严密的黑社会组织。每一个子节点表示一个下属,父节点表示直接上级。
当一个很有能力的新人加入组织的时候,就会逐渐提升,将能力不够的上级踩到脚下,直到遇到一个更强的领导。这就是上浮swim。当一个领导被外来者取代后,如果他的下属比他厉害,那么他们的角色就会互换,直到他比他的下属们强为止。这就是下沉sink。
重要声明
使用一个数组a[]表示一个堆,但是不会使用a[0],堆元素存放在a[1]到a[n]中。 这样才能使用父子节点之间的位置关系。
但是在写代码的时候,我们没法将a[0]置为空,因此我们选择在less方法和swap方法时,将数组索引参数向前移动一个。
private static boolean less(int[] a, int i, int j) {
if (a[i-1] < a[j-1]) return true;
else return false;
}
public static void swap(int[] a,int i,int j) {
int temp = a[i-1];
a[i-1] = a[j-1];
a[j-1] = temp;
}
上浮 swim
上浮是由下至上的堆有序化
如果堆的有序状态因为某个节点变得比它的父节点更大而打破,那么我们就需要通过交换它和它的父节点来修复堆。交换后这个节点还有可能比父节点大,那么就要不断地修复,直到我们遇到更大的父节点。
只要记住:位置k的节点的父节点的位置是k/2,那么这个过程实现起来很简单:
// k元素上浮
private static void swim(int[] a,int k) {
// 当k>1且父节点(k/2位置)比k位置的元素要小,上浮
while (k > 1 && less(a,k/2,k)) {
swap(a,k/2,k);
k = k/2;
}
}
保证只有位置k上的节点大于它的父节点时,有序状态才会被打破
下沉 sink
下沉是由上至下的堆有序化
如果堆的有序状态因为某个节点变得比它的两个子节点或其中一个更小而打破,那么我们就需要通过交换它和它的子节点来修复堆。交换后这个节点还有可能比子节点小,那么就要不断地修复,直到它的子节点都比它小,或到达底部。
// k元素下沉
private static void sink(int[] a,int k,int n) {
while (2*k <= n) {
// j 为左孩子节点 (2*k)
int j = 2 * k;
// 如果左孩子没越界,并且左孩子a[j]比右孩子a[j+1]小,j++,即选择孩子中较大的一个
if (j < n && less(a,j,j+1)) {
j++;
}
// 如果a[k]比最大的孩子节点a[j]还要大,则退出
if (!(less(a,k,j))) break;
// 如果比子节点都小或比其中一个小,则交换
swap(a,k,j);
// 重新获取 k 的位置 比较
k = j;
}
}
插入元素
将新元素加入到数组的末尾,增加堆的大小并让这个元素上浮到合适的位置
删除堆顶元素
从数组顶端删除最大元素并将驻足的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。
堆中节点的关系
在一个堆中,位置k的节点的父节点的位置为k/2
(向下取整),而它的来年改革子节点的位置分别为2k
和2k+1
。
这样在不使用指针的情况下,可以通过计算数组的索引在树中上下移动:从a[k]向上一层,就令k=k/2,向下一层,就令k=2k或2k+1。
堆排序
通过堆的结构,我们只能够找出最大或最小的元素,但是两个子节点之间的顺序是不能保证的。但是堆排序呢?
堆排序的基本思想
堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。
步骤如下:
构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
- 对于无序序列,从最后一个非叶子结点开始(第一个非叶子结点 arr.length/2-1),将该节点为父节点的小三角进行调整。其子节点为arr[2i+1]、arr[2i+2]
- 再找到第二个非叶子节点,进行调整,如导致下面的小三角结构混乱,则继续调整。
将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
- 将堆顶元素和末尾元素进行交换,就是数组的首尾,然后去掉数组的末尾,该元素为最大的元素
- 堆顶元素变了,重新调整结构,使其继续满足堆定义,再进行首尾替换,得到第二大元素
- 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
详细的图解,见:https://www.cnblogs.com/chengxiao/p/6129630.html
代码演示
简单总结下堆排序的基本思路:
将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
将堆顶元素与末尾元素交换,将最大元素”沉”到数组末端;
重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
堆的构造
我们可以在与NlogN成正比的时间内完成这项任务:
只需从左至右遍历数组,用swim()保证扫描指针左侧的所有元素已经是一棵堆有序的完全树即可,就像连续向优先队列中插入元素一样。
但这需要遍历数组中的全部数字。
一个更聪明更高效的办法是:采用数组从右向左遍历并利用sink()就可以仅仅遍历一般的数字,因为叶子节点不需要进行sink,时间复杂度是O(2N)。
从右至左用sink()函数构造子堆。数组的每个位置都已经是一个子堆的根结点了,sink()对于这些子堆也适用。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用sink()可以将它们变成一个堆。这个过程会递归地建立起堆的秩序。开始时我们只需要扫描数组中的一半元素,因为我们可以跳过大小为1的子堆。最后我们在位置1上调用sink()方法,扫描结束。
主体代码分析
private static void heapSort(int[] a) {
int n = a.length;
for (int i = n/2 ; i >= 1 ; i--) {
sink(a,i,n);
}
while (n > 1) {
swap(a,1,n--);
sink(a,1,n);
}
}
对于构建堆的代码:
for (int i = n/2 ; i >= 1 ; i--) {
sink(a,i,n);
}
i=n/2的位置是最后的一个父节点,然后将其下沉sink(),将其放到合适的位置。然后不断地从i=n/2的位置向前查找,不断地将节点下沉到合适的位置。一直到i=1的位置,堆就构建成了。
因为在堆中,叶子节点是不需要下沉的,我们只操作子堆的父节点,那么在下沉的过程中会自动地调整好。
在构建好堆之后,我们就要进行堆排序:
while (n > 1) {
swap(a,1,n--);
sink(a,1,n);
}
将最大元素a[1]和最末位的元素a[n]交换了位置。然后n–,即前移成一个子堆,然后再使用sink(),将被交换到a[0]但不是最大的元素下沉,调整为a[0]为最大元素…直到堆只剩一个元素位置,最后一个也不用排了。
整体代码
package sort;
import java.util.Arrays;
/**
* Created by japson on 4/19/2018.
*/
public class HeapSort {
public static void main(String []args){
int []a = {9,8,7,6,5,4,3,2,1};
heapSort(a);
System.out.println(Arrays.toString(a));
}
private static void heapSort(int[] a) {
int n = a.length;
for (int i = n/2 ; i >= 1 ; i--) {
sink(a,i,n);
}
while (n > 1) {
swap(a,1,n--);
sink(a,1,n);
}
}
// k元素上浮
private static void swim(int[] a,int k) {
// 当k>1且父节点(k/2位置)比k位置的元素要小,上浮
while (k > 1 && less(a,k/2,k)) {
swap(a,k/2,k);
k = k/2;
}
}
// k元素下沉
private static void sink(int[] a,int k,int n) {
while (2*k <= n) {
// j 为左孩子节点 (2*k)
int j = 2 * k;
// 如果左孩子没越界,并且左孩子a[j]比右孩子a[j+1]小,j++,即选择孩子中较大的一个
if (j < n && less(a,j,j+1)) {
j++;
}
// 如果a[k]比最大的孩子节点a[j]还要大,则退出
if (!(less(a,k,j))) break;
// 如果比子节点都小或比其中一个小,则交换
swap(a,k,j);
// 重新获取 k 的位置 比较
k = j;
}
}
private static boolean less(int[] a, int i, int j) {
if (a[i-1] < a[j-1]) return true;
else return false;
}
public static void swap(int[] a,int i,int j) {
int temp = a[i-1];
a[i-1] = a[j-1];
a[j-1] = temp;
}
}
改进
先下沉后上浮
我们可以这样来思考,处在数组尾部的元素一般都是比较小的元素,这样我们在把它放到堆顶后进行sink,一般它还是会下来的,那么我们为什么不直接将这个元素放到堆尾然后在swim上去呢?这样会减少很多次的比较,具体做法如下:
- 当我们将堆顶的元素放在数组尾部后,我们利用辅助空间暂时存储之前的堆尾元素temp,并不是将它放在堆顶;
- 现在堆顶是空的,我们不将temp放在上面,而是直接将堆顶对应的子节点中较大的那个节点bigger直接放上去(这样我们只需要比较一次哪个子节点大就可以了),然后这个bigger的位置就产生了空缺;
- 这个空缺的左右子节点继续比较产生较大的节点bigger`放在父节点空缺的位置上;
- 依次类推直到空缺的节点没有子节点;
- 将空缺的节点放上temp;
- swim上浮
这个方法需要一个辅助空间,如果条件允许的话便可以用;而这种方法产生的比较次数几乎就成为了N