【算法】堆排序学习笔记
参考资料
《算法(第4版)》 — — Robert Sedgewick, Kevin Wayne
什么是二叉堆
在了解堆排序之前, 最重要的当然是理解二叉堆的概念。 如果我们从零开始探究这个问题:什么是二叉堆呢? 那么这就变成了一个有趣而冗长的一个问题了:
二叉堆:一个堆有序的完全二叉树, 叫做二叉堆。
在了解二叉堆前, 先让我们理解下“堆有序” 和“完全二叉树”的概念
完全二叉树
让我们先从二叉树谈起
二叉树
从一个根节点开始, 每个节点连接一到两个子节点,依次向下展开的树形结构,叫做二叉树。
满二叉树
在二叉树的基础上, 除了最后一层节点没有任何子节点外,每一层的节点都有两个子节点,且每一层都完全填满的二叉树,叫做满二叉树。在外形上看,就像是一个完整的金字塔的形状。(从深度和节点数的关系上看,一颗深度为k且有2^k-1个节点的二叉树称为满二叉树)
完全二叉树
对满二叉树进行从上至下,从左至右的编号(例如上图所示的从1到7)。如果一个深度为k,有n个节点的二叉树,其每个节点都和深度同为k的满二叉树的编号1到n的节点在位置上一一对应的话,这个二叉树,就是完全二叉树。
作为对比, 看看下图中左下角和右下角的两颗树, 因为按照满二叉树的编号排定方式,它们相比起同深度的满二叉树而言, 分别在6和3的位置没有对应的节点,所以不是完全二叉树
堆有序
对于一个二叉树,如果它满足:
1. 任意一个父节点都小于等于它相邻的所有子节点
2. 任意一个父节点都大于等于它相邻的所有子节点
这两种情况都可称之为堆有序(和二叉搜索树不同的是,二叉堆的左儿子和右儿子间没有大小关系)
而堆有序的完全二叉树, 就叫做二叉堆。
【注意】 在接下来的代码示例中,我们都将默认采取父节点都大于等于它的所有子节点的二叉堆作为展示
二叉堆的表示方法
二叉堆可以用一个普通的一维数组来表示
按照从上至下,从左至右的编号,把二叉堆节点从1编号到N,以此作为数组下标,放入一个长度为N+1的数组中。那么就形成了一个用数组表示的二叉堆。例如下面的数组a
要注意的是, a[0]没有存放任何值! 数组是从a[1]开始存放的。
二叉堆节点间的位置关系
见上图图示, 二叉堆的节点位置(对应数组元素下标)间是有数字上的关系的:
在一个堆中,位置k的节点的父节点的位置是k/2, 而两个子节点的位置则分别是2k和2k+1。这样的话,在该二叉堆对应的数组中,我们就可以通过计算数组的下标在堆中上下移动: 从a[k]向上一层就令k等于k/2,向下一层则令k等于2k或者2k+1
也就是说,通过这层数字关系, 我们可以找到任意一个堆中节点的父节点和子节点(如果有的话),并进行比较, 在这层基础上,我们就可以来设计我们的堆有序化的算法了。
堆有序化的基本算法:上浮和下沉
堆排序的核心,就是堆有序化的算法
而堆有序化的基础,就是针对单个堆节点的有序化
单个堆节点的有序化有两种情况:
- 当某个节点变得比它的父节点更大而被打破(或是在堆底加入一个新元素时候),我们需要由下至上恢复堆的顺序
- 当某个节点的优先级下降(例如,将根节点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序
实现这两种有序化的操作,分别叫做“上浮”(swim)和“下沉” (sink)
基于上面所述,下面我们讨论的原始场景是这样的: 只有一个堆节点是非有序的,而其他堆节点都是有序的。
在这种前提下, 该节点对总体堆的顺序破环性只有两种情况:
- 该节点比它的父节点大
- 该节点比它的其中一个儿子节点小
(不可能既比父节点大又比儿子节点小,因为我们假设了前提: 其他节点都是堆有序的)
上浮实现堆有序
对第一种情况:某个节点比它的父节点大,如下图中位置5上的节点
(在图中,节点为从A到Z的字母,顺序上A最小Z最大)
这个时候我们要做的就是:
1.交换它和父节点(位置2)的值, 即P和T互换(这时P将处在5的位置,因为我们假设了前提“其他堆节点都是有序的”,所以互换位置后P肯定是有序的,不用考虑P的顺序问题)
2. 基于1, 我们需要判断交换后T和新的父节点(S)的大小关系, 如果还是大于父节点,那么再次交换。 不断循环,直到小于父节点或者该节点已经位于根节点为止(最终T到达了根节点的位置)
下面是我们的上浮方法(和图示的字母不同,我们的节点优先级是用整型数值的大小表示的)
/** * @param a 表示堆的数组 * @param k 堆中的节点位置 */ private void swim (int [] a, int k) { while(k>1&&a[k]>a[k/2]){ // 当该结点存在父节点,且大于父节点的时候 exchange(a, k, k/2); // 交换它和它的父节点值 k = k/2; // 取得父节点的位置 } }
下沉实现堆有序
对第二种情况:某个节点比它的至少一个儿子节点小,如下图中位置2上的节点
(在图中,节点为从A到Z的字母,顺序上A最小Z最大)
这个时候我们要做的就是:
1. 比较该节点(H)的两个子节点的大小, 取得那个比较大的子节点的值(图中S),和该节点交换。 成为父节点的那个较大的子节点, 同时大于原子节点(P)和新的子节点(原父节点H),三个节点间是堆有序的
2. 和1一样,再次判断交换后的该节点和新的子节点(N和G)的关系, 不断循环, 直到大于两个子节点或者到达了底部的叶子节点为止。
下面是我们的下沉方法:
/** * @param a 表示堆的数组 * @param k 堆中的节点位置 * @param N 堆中节点总数 */ private void sink (int [] a, int k, int N) { while(2*k<=N) { // 当该节点存在至少一个子节点的时候 int j = 2*k; // 取得左儿子的位置 if(j<N&&a[j]<a[j+1]) { j++; } // 取得左儿子和右儿子中的较大者 if(a[k]<a[j]) { // 当该节点的值小于较大的左儿子的时候 exchange(a, k, j); // 交换它和该儿子节点的值 k = j; // 取得该儿子节点的位置 } else { break; } } }
二叉堆的基本操作:插入元素和删除最大元素
为了更好地理解堆排序的过程,让我们利用刚才的上浮和下沉方法,实现一下对二叉堆的插入和删除最大元素这两个操作。
在这之前要提一下的是,我们把上面的两个方法:sink (int [] a, int k, int N) 和swim (int [] a, int k)中的N和k提取出来变成Heap类的全局变量,这样的话, 这两个方法就简化成了sink (int k) 和swim (int k)
向堆中插入元素
我们将新元素加到数组末尾, 增加堆的大小并让这个新元素上浮到合适的位置
如图所示
代码:
/** * 向堆中插入值 * @param v 要插入的值 */ public void insert (int v) { a[N+1]= v; // 元素被放入堆的最末端成为新节点 N++; // 增加堆的大小 swim(N); // 对末端节点进行上浮操作使其有序 }
向堆中删除最大元素
我们从数组顶端删除最大的元素并将数组的最后一个元素放到顶端, 减小堆的大小并让这个元素下沉到合适的位置
如图所示:
代码如下:
/** * 删除堆中的最大值, 并且将其返回 * @return 堆节点最大值 */ public int delMax () { if(isEmpty()) return 0; // 当堆为空时, 返回 int max = a[1]; // 取得堆中根节点(最大值) exchange(a, 1, N); // 交换根节点和末端节点(最后一个元素)的值 N--; // 减少堆的大小 (“删除”完毕) sink(1); // 下沉操作,让刚放上根节点的新元素下沉到合适的位置 return max; }
Heap类的全部代码:
public class Heap { /** * N: 记录二叉堆中的节点总数 * a: 容纳二叉堆节点的数组,从a[0]开始存放 */ private int N = 0; private int [] a; /** * @param maxN 创建堆中节点的总数 */ public Heap (int maxN) { a = new int [maxN+1]; } private static void exchange(int [] a , int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } /** * 上浮操作 * @param k 堆中的节点位置 */ private void swim (int k) { while(k>1&&a[k]>a[k/2]){ // 当该结点存在父节点,且大于父节点的时候 exchange(a, k, k/2); // 交换它和它的父节点值 k = k/2; // 取得父节点的位置 } } /** * 下沉操作 * @param k 堆中的节点位置 */ private void sink ( int k ) { while(2*k<=N) { // 当该节点存在至少一个子节点的时候 int j = 2*k; // 取得左儿子的位置 if(j<N&&a[j]<a[j+1]) { j++; } // 取得左儿子和右儿子中的较大者 if(a[k]<a[j]) { // 当该节点的值小于较大的左儿子的时候 exchange(a, k, j); // 交换它和该儿子节点的值 k = j; // 取得该儿子节点的位置 } else { break; } } } /** * 向堆中插入值 * @param v 要插入的值 */ public void insert (int v) { a[N+1]= v; // 元素被放入堆的最末端成为新节点 N++; // 增加堆的大小 swim(N); // 对末端节点进行上浮操作使其有序 } /** * 删除堆中的最大值, 并且将其返回 * @return 堆节点最大值 */ public int delMax () { if(isEmpty()) return 0; // 当堆为空时, 返回 int max = a[1]; // 取得堆中根节点(最大值) exchange(a, 1, N); // 交换根节点和末端节点(最后一个元素)的值 N--; // 减少堆的大小 (“删除”完毕) sink(1); // 下沉操作,让刚放上根节点的新元素下沉到合适的位置 return max; } /** * 判断堆数组是否为空 */ public boolean isEmpty() { return N == 0; } }
测试代码:
public class Test { public static void main (String [] args) {
// 创建一个能容纳10个元素的堆 Heap heap = new Heap(10); int [] array = {2,6,3,9,1,5,4,3,0,2}; // 将数组元素依次放入堆中 for(int i =0; i<array.length;i++) { heap.insert(array[i]); } // 依次删除堆中的最大元素并输出 for(int i =0; i<array.length;i++) { System.out.println(heap.delMax()); } } }
输出:
9 6 5 4 3 3 2 2 1 0
诶.仔细看一下我们有序的输出,是不是找到了些“排序”的影子呢?
我们上面所学习的堆的基本操作之一——删除最大元素,使用的Heap.delMax方法的有趣之处在于:每次调用它,都会
1.删除最大元素
2.返回该最大元素
3.使得剩下的堆中元素重新恢复有序
这样一来,依次删除返回的就是最大值,第二大值,第三大值...这样一来不就得到了有序的数组了吗? 下面就让我们来看看堆排序
堆排序
堆排序分为两个阶段:1.堆的构造 2.下沉排序
在堆的构造阶段,我们把原始数组重新组织安排进一个堆中,也就是让这个数组实现堆有序(注意“数组堆有序” ≠ “数组有序”!!)
而在下沉排序阶段,我们从堆中按递减顺序取得所有元素并得到排序结果。 (类似Heap.delMax)
堆的构造阶段
根据前面所讲的知识,我们可以猜测, 将堆无序的数组变成堆有序的数组可以有由两种不同的操作完成: 第一种是使用“上浮”实现堆有序, 第二种是使用“下沉”实现堆有序。
而这两种方式的效率是不一样的: 因为“下沉”需要遍历的节点数比“上浮”需要遍历的节点数少了一半
1.对上浮排序,我们需要对数组从左向右(或者说对堆从上到下)进行遍历, 依次保证前两个元素堆有序, 前三个元素堆有序...... 直到最后一个元素,如图:
2.而“下沉”实现排序的话,我们需要对数组从右向左(或者说对堆从下到上,但不是最下而是中间)进行遍历
但是! 因为最下方的叶子节点没有子节点,所以不需要排序! 而需要排序的节点(有子节点的节点)占N/2 (假设N为节点总数的话), 所以如下图所示:
所以说,下沉什么的最棒了!!!
接下来我们的堆排序是以下沉(sink)为基础进行的, 有兴趣的话大家也可以用上浮实现一下哦
堆的构造代码:
int N = a.length; // 取得节点总数 for(int i=N/2;i>0; i--) { sink(a, i, N); // 对所有父节点,从最后一个父节点到根节点,依次下沉排序 }
【注意】不要写成int N = a.length-1; 因为虽然我们之前写的二叉堆是忽略了a[0]的(堆节点数N = a.length - 1 ),但这是待排序的数组,当然不能忽略a[0]; (堆节点数N = a.length)
图示如下:
把构造完毕(实现堆有序)之后,我们就要将“堆有序”的数组转化为“有序”的数组,这一阶段被称为下沉排序
下沉排序阶段
依次把最大的数组元素移到数组的最右端, 依次填充a[N-1], a[N-2]...直到a[0]
while(N>1){ exchange(a, 1, N); // 将数组中最大的元素放到数组后端 N--; // 将最大的节点元素移出堆 sink(a, 1, N); // 下沉操作,再次实现堆有序 }
如图所示:
两个阶段的总代码:
/** * 堆排序方法 * @param a 待排序数组 */ public static void sort(int [] a) { // 堆的构造阶段 int N = a.length; // 取得节点总数 for(int i=N/2;i>0; i--) { sink(a, i, N); // 对所有父节点,从最后一个父节点到根节点,依次下沉排序 } // 到这里数组已经完全堆有序
// 下沉排序阶段 while(N>1){ exchange(a, 1, N); // 将数组中最大的元素放到数组后端 N--; // 将最大的节点元素移出堆 sink(a, 1, N); // 下沉操作,再次实现堆有序 } }
最后要说明一下,在堆排序中,我们的exchange方法和less方法要减1, 因为相比起二叉堆的使用中忽略了a[0], 而实际需要排序的数组当然是有a[0]的呀:
/** * 交换两个数组元素的值 * 注意! 不同于一般的exchange, 这里的i和j要减1! */ private static void exchange(int [] a , int i, int j) { int temp = a[i-1]; a[i-1] = a[j-1]; a[j-1] = temp; } /** * 比较i和j下标的数组元素的大小 * 注意! 不同于一般的less, 这里的i和j要减1! */ private static boolean less (int [] a, int i, int j) { return a[i-1]-a[j-1]<0 ? true : false; }
堆排序类HeapSort的全部代码:
public class HeapSort { /** * 交换两个数组元素的值 * 注意! 不同于一般的exchange, 这里的i和j要减1! */ private static void exchange(int [] a , int i, int j) { int temp = a[i-1]; a[i-1] = a[j-1]; a[j-1] = temp; } /** * 比较i和j下标的数组元素的大小 * 注意! 不同于一般的less, 这里的i和j要减1! */ private static boolean less (int [] a, int i, int j) { return a[i-1]-a[j-1]<0 ? true : false; } /** * 下沉操作 * @param a 待排序数组 * @param k 堆中的节点位置 * @param N 堆中的节点总数 */ private static void sink (int [] a, int k, int N) { while(2*k<=N) { // 当该节点存在至少一个子节点的时候 int j = 2*k; // 取得左儿子的位置 if(j<N&&less(a, j, j+1)) { j++; } // 取得左儿子和右儿子中的较大者 if(less(a, k, j)) { // 当该节点的值小于较大的左儿子的时候 exchange(a, k, j); // 交换它和该儿子节点的值 k = j; // 取得该儿子节点的位置 } else { break; } } } /** * 堆排序方法 * @param a 待排序数组 */ public static void sort(int [] a) { // 堆的构造阶段 int N = a.length; // 取得节点总数 for(int i=N/2;i>0; i--) { sink(a, i, N); // 对所有父节点,从最后一个父节点到根节点,依次下沉排序 } // 到这里数组已经完全堆有序 // 下沉排序阶段 while(N>1){ exchange(a, 1, N); // 将数组中最大的元素放到数组后端 N--; // 将最大的节点元素移出堆 sink(a, 1, N); // 下沉操作,再次实现堆有序 } } }
测试代码:
public class Test { public static void main (String [] args) { int [] array = {3,0,8,9,1,5,4,2,7,1,2}; HeapSort.sort(array); for(int i=0;i<array.length;i++) { System.out.println(array[i]); } } }
输出:
0 1 1 2 2 3 4 5 7 8 9
我叫彭湖湾,请叫我胖湾