数据结构(八)高级排序算法——堆排序、桶排序

一、堆排序

1、排序原理

     堆,是一种类似于二叉树的结构。也就是说,在堆中,每一个待排序序列的元素都可以看做是一个堆的节点,而堆的每一个节点,又有两个子节点。

    我们称图中任何一个节点下方的左右两个分支节点为其左右孩子节点,这个节点本身称之为其左右孩子节点的父节点,我们通过不断调整堆的结构,能够得到大根堆(用于升序排序)或者小根堆(用于降序排序)也就是说:大根堆:堆顶元素是整个堆中取值最大的元素;小根堆:堆顶元素是整个堆中取值最小的元素。然后我们通过将堆顶元素与堆中最后的元素进行互换,完成对整个堆的排序操作。

  下面我们将堆排序算法的步骤进行分解,来演示一组堆排序的操作流程:
  步骤1:构建堆。也就是将待排序序列中的元素,逐一加入堆结构中,这个过程相当于广度优先创建一个二叉树的过程

  步骤2:从最后一个根节点开始,从这个根节点的左右两个孩子节点中找到一个比较大的,然后和父节点进行比较。如果这个孩子比父节点取值更大,那么这个孩子和父节点进行互换,否则不动;重复上述过程,直到最后一个根节点为止。实际上堆顶元素就是最后一个根节点,当对最后一个根节点(堆顶元素)完成上述操作之后,堆顶元素就是整个堆结构中取值最大的元素,此时我们称这个堆成为了一个大根堆。

  步骤3:将堆顶元素和堆中的最后一个元素互换,并断开互换后最后一个元素和堆的链接,此时我们就完成了对待排序序列中最大元素的排序,此时,一个元素就这样有序了。

       然后不断重复步骤2与步骤3,直到堆中仅剩余一个元素为止,堆排序完成。上面我们演示了一轮堆排序算法的流程,下面我们通过这个流程,总结出一些堆排序的规律:
尤其重要的是,我们要通过上面演示的流程,总结出在序列(比如数组结构)中,用来描述堆结构的两个公式:
1.在堆排序过程中,创建堆结构的流程我们仅执行一次,如果我们使用一个线性存储结构用来描述堆结构,那么这个过程实际上       是不需要通过代码实现的,也就是说,这个流程本身只是存在于思想结构和理论过程中的;
2.构建大根堆的流程,每执行一次,都能够将当前堆结构中的最大元素升至堆顶,也就是说,每构建一次大根堆,就能够使得一      个元素有序。所以,如果一个序列中存在n个元素,那么我们只要执行n-­1次构建大根堆的过程就可以了;
3.在堆结构中,一个中间节点,要么同时存在左右两个孩子,要么只有一个左孩子,不可能左右孩子同时不存在。因为如果一个      节点左右孩子都不存在,那么这个节点就不能够称之为中间节点了,只能够称之为叶子结点;
4.我们在创建堆的过程中,实际上就相当于遍历了整个序列,然后将序列中的元素逐个放入堆结构中,所以实际上堆结构中的元      素,按照从上到下,从左到右的顺序,就是序列中元素的顺序;
5.根据上面一条规律,我们能够直接推断出来,在堆结构中,具有所有中间节点(即具有左右孩子的节点,包括堆的根节点)所      在下标的规律:中间节点在长度为n的序列中下标的最大值是:n / 2 ­-1,并且对这个值向下取整;
6.因为在堆中,每一个中间节点最多能够具有两个孩子节点,所以如果某一个中间节点如果在序列中的下标为k,那么其左右孩子    节点与这个父节点的下标关系为:
        左孩子节点下标 = 2k + 1
        右孩子节点下标 = 2k + 2

7.通过上面总结的中间节点最大下标和父节点与两个孩子节点的下标关系,我们可以推理出如何判断一个节点是否具有右孩子,      即只要右孩子下标:2k + 2 <= n / 2 + 1,那么说明这个下标为k的父节点是具有右孩子的
8.根据规律4我们可以知道,在堆结构中,按照从上到下、从左到右的顺序,堆结构中的节点的顺序就是序列中节点的顺序。所      以,在构建一个大根堆之后,我们在将堆顶元素和堆中最后一个元素进行互换的时候,就是将序列中下标为0的元素和待排序序    列中的最后一个元素的互换过程,即序列中下标为0的元素和下标为n / 2 ­-1的元素之间的互换过程;
9.当序列中的一个元素有序之后,我们只要将待排序序列的最大下标,即n / 2 ­-1向前提一位,即­1操作,就可以保证在这个序列      中最后的元素,不会再参与到对排序之中。

2、代码实现

public class HeapSort {
	
	/**
	 * 堆排序中用来构建大根堆的方法
	 * @param array 待排序数组
	 * @param end 排序范围的终点
	 */
	
	private void maxHeap(int[] array, int end) {
		
		//[1]根据数组的排序范围,计算出最后一个根节点的下标,计算公式:lastFather = (start + end) / 2 - 1,并且(start + end) / 2向下取整
		int lastFather = (0 + end) % 2 == 0 ? (0 + end) / 2 - 1 : (0 + end) / 2;
		
		//[3]创建一个循环,对数组中所有的根节点都进行如下操作
		for(int father = lastFather; father >= 0; father--) {
			//[2]使用每一个父节点的两个子节点先比较大小,然后用两个子节点中比较大的一个,和根节点比较大小,如果这个子节点比根节点还要大,则互换
			/*
			 * 左右孩子节点下标和根节点下标之间的关系公式:
			 * leftChild = father * 2 + 1;
			 * rightChild = father * 2 + 2;
			 */
			int leftChild = father * 2 + 1;
			int rightChild = father * 2 + 2;
			
			//如果右孩子存在并且右孩子比父节点大,那么由右孩子替换父节点
			if(rightChild <= end && array[rightChild] > array[father]) {
				int tmp = array[rightChild];
				array[rightChild] = array[father];
				array[father] = tmp;
			}
			
			//如果左孩子比父节点大,那么由左孩子替换父节点,等价于左孩子比右孩子大,用右孩子替换原有的父节点
			if(array[leftChild] > array[father]) {
				int tmp = array[leftChild];
				array[leftChild] = array[father];
				array[father] = tmp;
			}
			
		}
		
	}
	
	/**
	 * 堆排序算法
	 * @param array 待排序数组
	 */
	public void heapSort(int[] array) {
		
		//[3]创建一个循环,控制数组的待排序部分的最后下标位
		for(int end = array.length-1; end > 0; end--) {
			//[1]每次都是自顶向下构建大根堆
			maxHeap(array, end);
			
			//[2]将大根堆的堆顶元素和数组待排序范围内的最后一个元素进行互换
			int tmp = array[0];
			array[0] = array[end];
			array[end] = tmp;
		}
		
	}
	
	public static void main(String[] args) {
		
		int[] array = new int[] {7,0,1,9,2,6,3,8,5,4};
		
		HeapSort hs = new HeapSort();
		hs.heapSort(array);
		
		System.out.println(Arrays.toString(array));
		
	}
	
}

3、时间复杂度、空间复杂度、算法稳定性分析

  1、时间复杂度

    如果一个序列中存在n个元素,那么我们需要构建n­-1次大根堆才能够将堆序列中所有的元素进行排序,所以堆序列整体进行排序的时间复杂度是logn*(n­1) = nlogn -­ logn。在省略表达式中幂次较低的项logn之后,最终得到堆排序的时间复杂度是O(nlogn)
注意:堆排序的时间复杂度在理论上来讲是O(nlogn),但是在时机编程过程中,因为我们需要在堆排序过程中实现更多的比较的代码,所以堆排序的实际表现并不是很好,根据实际操作推断,通过Java代码实现的堆排序算法的实际时间复杂度接近O(n^2 )

  2、空间复杂度

  在进行堆排序的过程中,只需要使用一个临时变量,在子节点和父级节点之间进行互换的时候使用即可。所以:堆排序算法的空间复杂度是O(1)。

  3、算法稳定性分析

   堆排序算法因为在子节点和父节点进行互换的时候,可能造成不相邻的两个等值节点之间的顺序会发生层级变化,最终导致这两个等值节点在序列中的相对位置发生变化。所以:堆排序是一种不稳定的排序算法。

二、桶排序

 1、排序原理

桶排序算法是一种具有一些特殊条件约束的排序算法。但是在这些特殊条件的约束之下,桶排序算法的时间复杂度非常低,甚至可以说,在我们目前接触的8种排序算法中,桶排序算法的时间复杂度是最低的。桶排序算法的排序流程可以做如下总结:
   步骤1:遍历整个待排序序列,并找到这个序列中的最大值;
   步骤2:使用待排序序列的最大值,作为一个辅助序列下标的最大值,创建一个辅助序列,我们称这个序列为桶序列(或者说                   是桶数组);
   步骤3:再次遍历整个待排序序列,在遍历过程中,如果遇见取值为m的元素,那么就在桶序列下标为m的位置上+1;
   步骤4:遍历整个桶序列,在遍历过程中,如果下标为m的位置上的取值是k,那么就将这个下标m输出k次到原始数组中,排                    序完成;

实际上通序列的排序思路非常简单:在通序列当中,桶序列的下标就是天然有序的,比如数组的下标,永远是从0开始,然后每隔一位,下标加1。我们就是利用了有序序列下标的这个特征,将待排序序列中的元素,作为桶排序的下标,然后使用桶排序中对应下标位上的取值,表示这个值在元素序列中出现了多少次,最后在遍历整个桶序列的时候,因为是按照下标遍历的,所以通序列中对应下标位的取值是几,我们就将这个下标输出多少次,然后进完成了排序。

但是通过上面的描述和数组的一些特性进行对比,我们不难发现桶排序算法的一些限制:
   1.在桶排序过程中,原始序列中绝对不能出现比0小的数字,因为有序序列的最小下标就是0,桶序列不能存储比0更小的值;
   2.原始序列中不能出现浮点值,因为有序序列的下标都是整数值,所以不能够使用桶序列保存浮点值;
   3.假设在待排序序列中存在的取值只有两种:0和999,并且都重复n次,那么我们就不得不创建一个长度为1000,下标取值范         围为0­-999的通序列作为辅助空间,但是在这个桶序列中,只有下标为0的位置和下标为999的位置上的取值是大于0的,其余       位置上的取值全都是0,这样就造成了极大的空间浪费。
所以我们认为桶排序非常适合原始序列中元素取值范围比较小,而且大量重复的情况。如果原始序列中的元素取值范围非常广泛而分散,重复率不高,在使用桶排序的时候,就会造成大量的空间浪费。

2、代码实现

public class BucketSort {
	
	public void bucketSort(int[] array) {
		
		//[1]首先遍历整个数组,找到数组中元素的最大值
		int max = array[0];
		for(int i = 0; i < array.length; i++) {
			if(array[i] > max) {
				max = array[i];
			}
		}
		
		//[2]根据数组的最大值,创建一个辅助数组,辅助数组的最大下标等于原始数组的最大值
		//辅助数组的每一位可以看做一个桶,这个桶中最终的取值,就是这个桶对应下标的取值,在原始数组中出现的次数
		int[] buckets = new int[max+1];
		
		//[3]再次遍历整个原始数组,将原始数组中所有的元素按照取值,加入对应下标的桶中
		for(int i = 0; i < array.length; i++) {
			buckets[array[i]]++;
		}
		
		//[4]遍历桶数组,每一个桶中的取值是几,说明这个元素就在原始数组中出现了多少次
		int index = 0;  //用来控制原始数组下标的变量
		for(int i = 0; i < buckets.length; i++) {
			for(int j = buckets[i]; j > 0; j--) {
				array[index++] = i;
			}
		}
		
	}
	
	public static void main(String[] args) {
		
		int[] array = new int[] {7,0,1,9,2,6,3,8,5,4};
		
		BucketSort bs = new BucketSort();
		bs.bucketSort(array);
		
		System.out.println(Arrays.toString(array));
		
	}
	
}

3、时间复杂度、空间复杂度、算法稳定性分析

  1、时间复杂度

    实际上我们在实现桶排序的过程中,总共进行了3次序列遍历:
    第1次:遍历长度为n的待排序序列,找到序列中的最大值m,并构建长度为m的桶序列
    第2次:遍历长度为n的待排序序列,将待排序序列中的元素全部存放到桶序列的对应取值下标位中
    第3次:遍历长度为m的桶序列,根据每一个桶当中的取值,输出对应次数的下标取值。不难看出,前两次遍历的长度都是n,                  所以加和是2n,第3次遍历的长度是m,那么3次遍历的结果相加,就得到2n+m
    在省略系数之后,我们得到:桶排序的时间复杂度是O(n+m),其中n表示待排序序列的长度,m表示待排序序列最大值的取值

  2、空间复杂度

   桶排序中,我们需要创建一个长度为m的桶序列作为辅助空间。所以:桶排序的空间复杂度是O(m)

  3、稳定性分析

    在桶排序过程中,我们主要考虑的是待排序元素出现的次数,所以我们并不能很好的讨论桶排序算法的稳定性。但是实际上桶排序还有一种实现方式,是稳定排序。

三、桶排序的另外一种实现:分桶操作

   上面所述的原生的桶排序算法,是一种使用“空间换时间”的操作,也就是使用大量的空间消耗,甚至不惜浪费存储空间,达到提升排序速度的目的,但是如果我们将将空间和时间进行一些均衡,就能够得到桶排序的另一种实现方式。下面我们通过一个具体的案例,来说明这种排序方式。

通过上述操作我们不难看出:这种操作方式使用的桶的数量不在取决于序列内最大值的取值而是人为规定,并且桶内存储元素的方式使用链表挂载,这大大节省了存储空间,但是同时也多出来一些诸如元素所在桶下标计算、有序数组合并等额外的操作,这会引起一定程度上的排序效率下降,但是影响并不明显。同样桶内元素因为使用插值插入的方式实现,所以桶排序的这种操作方
式,也是一种稳定的排序方式。

发布了98 篇原创文章 · 获赞 165 · 访问量 21万+

猜你喜欢

转载自blog.csdn.net/cyl101816/article/details/95492116
今日推荐