排序(下):归并排序和快速排序

本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程

归并排序和快速排序,是两种时间复杂度为O(nlogn)的排序,适合大规模的排序,比上节所说的三种排序(冒泡、插入、选择)更常用

归并排序和快速排序都用到了分治思想,非常巧妙,我们可以借鉴这个思想,来解决非排序问题,比如,如何在O(n)的时间复杂度内查找一个无序数组中第k大元素?,这就要用到今天所讲的内容。

归并排序的原理

归并排序(Merge Sort ) 的核心思想还是蛮满意的。如果要排序一个数组,我们先把数组从中间分成前后两个部分,然后对前后两个部分分别排序,再将两个部分合并在一直,这个整个数组就是一个有序的数组了。在这里插入图片描述
这里使用了分治思想。就是将一个大的问题,分解成小的子问题来解决。小的问题解决了,大问题也就解决了。

是不是觉得这和咱们前面讲的递归挺像的。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

是否还记得,讲递归的时候说的,写递归代码的技巧呢? 就是分析出递推公式,然后找出终止条件,最后将此翻译为代码。

//递推公式:
merge_sort( p...r ) = merge( merge_sort( p...q ),merge_sort( q+1...r ));
// 终止条件
p >= r;

简单解释一下这个递推公式。
merge_sort( p…r ):表示给下标从p到r之间的元素排序。
将此问题转化为两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中q=( p + r )/2,也就是 p 和 r 的中间位置,当这两部分排序好了之后,再合并到一个数组中。
有了递推公式和终止条件,转化成代码就容易多了。这里给出伪代码,你可以翻译成你熟悉的语言

merge_sort(A, n){
	merge_sort_c(A, 0, n-1) // A指数组,n 表示数组大小
}
merge_sort_c(A, p, r){
	if ( p >= r ){ // 递归的终止条件
		return;
	}
	q = (p+r)/2 // 取 p 到 r 的中间位置 q
	merge_sort_c(A, p, q)
	merge_sort_c(A, q+1, r)
	merge(A[ p...r ],A[ p...q ],A[ q+1...r ] ) // 将A[ p...q ],A[ q+1...r ] 合并为A[ p...r ]
}

merge(A[ p…r ],A[ p…q ],A[ q+1…r ] ),这个函数的思想,就是将已有序的A[ p…q ],A[ q+1…r ]合并成一个有序数组,并且放入A[ p…r ]中,具体这个过程要怎么做呢?

申请一个临时数组,大小与A[ p…r ]一样,游标 i 和 j 分别指向A[ p…q ] 和 A[ q+1…r ] 的第一个元素。如果 A[i] <= A[j] ,我们就把A[i] 放到临时数组temp中,并且 i 后移一位,否则A[j] 放到临时数组temp中,并且 j 后移一位。当A[ p…q ] 和 A[ q+1…r ]有一个为空时,就不用比较了,把非空的剩余元素放入temp中在这里插入图片描述
java实现如下(课程中没有给出具体的代码,我在学习时,用java实现了并归算法,如有错误,请指正。)

	@Test
	public void testMergeSort(){
		Random rand = new Random();
		int length = 100;
		int[] a = new int[length];
		for (int i = 0; i < length; i++) {
			a[i] = rand.nextInt(100);
		}
		int[] temp = new int[length];
		
		long begin = System.currentTimeMillis();
		recursion(a, 0, length - 1, temp);
		long end = System.currentTimeMillis();
		
		System.out.println("    归并排序,数组长度:" + length + ", 耗时:" + (end - begin) + "毫秒。");
		
		for (int i = 0; i < length; i++) {
			System.out.print("  " + a[i]);
			if (i % 10 == 9) {
				System.out.println();
			}
		}
	}	
	/**
	 * 将数组a中下标p到r排序
	 * 
	 * @param a 原数组
	 * @param p 开始下标
	 * @param r 结束下标
	 * @param temp
	 */
	private void recursion(int[] a, int p, int r, int[] temp) {
		if (p >= r) {
			return;
		}
		int q = getAverage(p, r);
		recursion(a, p, q, temp);
		recursion(a, q + 1, r, temp);
		merge(a, p, q, r, temp);
	}

	/**
	 * 合并两部分的数组,从小到大排列
	 * 
	 * @param a 目标数组
	 * @param p 开始下标
	 * @param q 中间值
	 * @param r 结束下标
	 * @param temp 临时数组
	 */
	private void merge(int[] a, int p, int q, int r, int[] temp) {
		int before = p;
		int after = q + 1;
		int index = p;
		while (before <= q && after <= r) {
			if (a[before] <= a[after]) {
				temp[index] = a[before];
				before++; // 前半段下标后移
			} else {
				temp[index] = a[after];
				after++; // 后半段下标后移
			}
			index++; // 临时数组的下标后移
		}
		if (before == q + 1) {
			System.arraycopy(a, after, temp, index, r - after + 1); // 前半段没有元素时,把后半段的剩余元素放入临时数组的对应位置
		} else {
			System.arraycopy(a, before, temp, index, q - before + 1);
		}
		System.arraycopy(temp, p, a, p, r - p + 1); // 把排好序的元素从临时数组复制到原数组的对应位置
	}

	/**
	 * 返回数组的中间位置
	 * @param a
	 * @param b
	 * @return
	 */
	private int getAverage(int a, int b) {
		if ((a + b) % 2 == 0) {
			return (a + b) / 2;
		}
		return (a + b - 1) / 2;
	}


// 运行结果如下
    归并排序,数组长度:100, 耗时:0毫秒。
  0  2  5  6  8  8  13  13  13  14
  14  15  17  17  18  18  21  21  22  23
  24  27  27  27  28  28  30  30  30  33
  35  35  36  38  41  41  42  44  44  45
  45  46  47  48  48  48  49  51  51  51
  51  51  54  54  55  56  57  57  61  62
  62  63  66  66  68  68  73  73  75  75
  77  79  80  81  81  81  82  82  84  84
  85  88  89  89  90  91  92  92  93  94
  94  96  96  96  96  97  98  98  98  99


// 调数组大小,对比结果如下
	归并排序,数组长度:800, 耗时:1毫秒。
	归并排序,数组长度:8000, 耗时:3毫秒。
	归并排序,数组长度:80000, 耗时:18毫秒。

与上一节讲的排序算法结果对比下
在这里插入图片描述
并归排序的效率优势很明显的。

归并排序性能分析

第一、归并排序是稳定的排序算法吗?

结合代码,在merge()函数中,决断条件是a[before] <= a[after],当等值时,原本在前面的元素合并后,还是在前面,所以它是稳定的排序算法

第二、归并排序的时间复杂度是多少?

如果我们定义求解问题 a 的时间是T(a),求解问题b、c 的时间分别是T(b)和T©,那可以得到这样的递推关系式;
T(a) = T(b) + T(c) + k
其中k等于将问题 b、c 合并成问题a 的结果所消耗的时间。
(不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式)
套用前面的公式,归并排序时间复杂度的计算公式就是:

T(1) = C;  n = 1 时,只需要常量级的执行时间,所以表示为C
T(n) = 2*T(n/2) + n;    n>1

// 写的直观一点
T(n) = 2*T(n/2) + n
	= 2*( 2*T(n/4) + n/2 ) + n = 4*T(n/4) +2*n
	= 4*( 2*T(n/8)+ n/4) +2*n = 8*T(n/8) + 3*n
	......
	=2^k *T(n/2^k) + k*n
	......

T(n/2^k)=T(1)
得 n/2^k = 1,代入可得 T(n) = T(n)=Cn+nlog2n。即用大O标记法表示,T(n) 是 O(nlogn)。所以归并排序的时间复杂度是O(nlogn)。

归并排序与原始数据的有序度无关,其时间复杂度很稳定,最好、最坏、平均情况复杂度都是O(nlogn)。

第三、归并排序的空间复杂度是多少?
归并排序不是原地算法,它有一个临时数组,需要额外申请空间,临时数组长度与原数组长度一样,所以空间复杂度是O(n)。

快速排序的原理

快速排序(Quicksort)算法也是利用分治思想,有点像归并排序,但思路不一样。待会我们再讲两者的区别。它的思想是这样的:如果要排序的数组中下标从p到r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为pivot(分区点)。

我们遍历 p 到 r 之间的数据,将小于 pivot 的放在左边,将大于 pivot 的放到右边,将pivot放到中间。经过这一步骤,数组 p 到 r 之间数据被分成了三个部分,前面 p 到 q-1 之间的都是小于pivot的,中间是pivot,后面的 q+1到 r 之间的是大于pivot的。在这里插入图片描述

我们用递归排序下标从p 到 q-1 之间的数据和下标从q+1到 r 之间的数据,直到分区缩小为1,这时所有的数据就都有序了。递推公式如下

// 递推公式:
quick_sort( p...r ) = quick_sort( p...q-1 )  + quick_sort( q+1...r ) ;
// 终止条件
p >= r ;

将递推公式转化为递归代码,这里给出伪代码,你可以翻译成你熟悉的语言。

// 快速排序,A是数组,n 表示数组的大小
quick_sort( A, n){
	quick_sort_c(A, 0, n-1)
}
quick_sort_c(A, p, r){
	if ( p >= r ){ // 递归的终止条件
		return;
	}
	q = partition(A, p, r) // 获取分区点
	quick_sort_c(A, p, q-1);
	quick_sort_c(A, q+1, r);
}

快速排序里有一个分区函数partition(),就是随机选择一个元素作为pivot(一般情况下,可以选择p 到 r 区间的最后一个元素),然后对A[p…r]分区,并返回pivot的下标。

如果不考虑空间消耗的话,分区函数partition()可以写得非常简单,分别申请两个数组,其中一个存储小于分区点的元素,另外一个存储大于分区点的元素,最后再合并到一起,如图在这里插入图片描述
如果这样实现的话,快排就不是原地算法了。如果我们希望快排是原地算法,那就需要在空间复杂度O(1)的情况下,实现分区函数。如图所示在这里插入图片描述
用最后一个元素作为分区点,遍历所有元素,找到首个比分区点大的元素X,之后遇到比分区点小的就与x互换位置,最后将分区点元素与x互换位置,返回分区点下标。java实现如下(课程中没有给出具体的代码,我在学习时,用java实现了并归算法,如有错误,请指正。)

	@Test
	public void testQuickSort() {
		Random rand = new Random();
		int length = 800;
		int[] a = new int[length];
		for (int i = 0; i < length; i++) {
			a[i] = rand.nextInt(1000);
		}
		
		long begin = System.currentTimeMillis();
		quickSort(a, 0, length - 1);
		long end = System.currentTimeMillis();
		
		System.out.println("    快速排序,数组长度:" + length + ", 耗时:" + (end - begin) + "毫秒。");
		
		for (int i = 0; i < length; i++) {
			System.out.print("  " + a[i]);
			if (i % 10 == 9) {
				System.out.println();
			}
		}
	}
	
	private void quickSort(int[]a, int p, int r) {
		if(p >= r) {
			return;
		}
		int q = partition(a, p, r);
		quickSort(a,p,q-1);
		quickSort(a,q+1,r);
	}
	
	/**
	 * 将数组a 分区,并返回分区点的下标
	 * @param a
	 * @param p
	 * @param r
	 * @return
	 */
	private int partition(int[]a, int p, int r) {
		int big = r; // 第一个比分区点大的元素的下标,
		boolean flag = false; // 是否找到首个比分区点大的元素
		for(int i = p; i < r; i++) {
			if(a[i] > a[r] && !flag) {
				big = i;  // 通过flag,首次找到后,这段代码就不再执行
				flag = true;
			}
			if(flag && a[i] < a[r]) {
				int temp = a[i];
				a[i] = a[big];
				a[big] = temp;
				big ++;
			}
		}
		if( big != r) { 
			// 首个比分区点大的值,与分区点的值互换
			int item = a[big]; 
			a[big] = a[r];
			a[r] = item;
		}
		return big;
	}
	// 结果如下:
	 快速排序,数组长度:100, 耗时:0毫秒。
  16  20  21  31  35  36  51  60  64  69
  99  102  124  159  164  167  189  191  195  202
  221  250  251  267  289  292  295  300  311  315
  317  323  326  328  329  353  361  363  366  385
  388  407  412  419  426  435  440  477  480  489
  493  499  509  513  516  519  525  527  542  542
  600  604  631  649  655  656  658  664  672  672
  687  688  706  708  714  719  719  723  762  781
  808  811  846  853  854  856  861  861  864  866
  882  887  898  930  966  973  990  992  994  997


    快速排序,数组长度:800, 耗时:1毫秒。
    快速排序,数组长度:8000, 耗时:3毫秒。
    快速排序,数组长度:80000, 耗时:20毫秒。

接下来,我们来看看归并排序和快速排序,都是分治思想,递推公式也很相似,那区别在哪里呢?
在这里插入图片描述
可以看出,归并排序是自下而上的,而快速排序是自上而下的,快速排序通过巧妙的原地分区,实现了原地排序,尽管它不是稳定算法。

快速排序性能分析

快排也是用递归来实现的,如果每次分区操作,都能正好把数组分成大小接近的两个小区,那快排的时间复杂度和归并排序相同,也是O(nlogn)。

如果在极端情况下,每次分区,两个小区的大小差别都很悬殊,那么它的时间复杂度会退化为O(n^2),具体怎么算,可以用递归树的相关知识来解答,等讲树的好一节再讲。

解答开篇

快排的核心思想就是分治和分区,我们可以利用分区思想,来解答开篇的问题:O(n)时间复杂度内未无序数组中的第k大元素。比如,4,2,5,12,3这样一组数据,第3大元素就是4.

我们选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,这样数组就分成三部分,A[0…p-1],A[p],A[p+1…n-1]。
如果 p+1 = K,那么A[p]就是求解的元素;如果 K > p+1,就按上面的思路在A[p+1…n-1]这个区间里查找,否则在A[0…p-1]区间里查找。
在这里插入图片描述
我们再来看看,为什么上述的思路时间复杂度是O(n)?

第一次分区查找,我们需要对大小为n 的数组执行分区操作,需要遍历n个元素。第二交分区查找,只需要对大小为n/2的数组执行分区操作,需要遍历 n/2个元素。依次类推,分区遍历元素的个数分别是 n、 n/2、 n/4、 n/8、 n/16……直到区间缩小为1.求和得 2n -1。所以上述解决问题思路的时间复杂度是O(n)。

你可能会说,可以来个笨方法,每次取数组中的最大值,将其移动到数组的最前面,然后在剩下的数组元素中继续找最大值,以此类推,执行K次,找到的数据就是第K大元素了吗?

不过,时间复杂度就是O(K*n),系数可忽略,不就是O(n)了吗?

当K值较小时,比如1、2,那最好情况时间复杂度确实是O(n),但当K = n/2,或者n 时,最坏情况下的时间复杂度就是O(n^2)了。

小结

归并排序和快速排序,是两种稍复杂的排序算法,它们用的都是分治的思想。代码都通过递归来实现,过程非常相似。重点是是理解merge()合并函数和partition()分区函数。

归并排序算法,任何情况下时间复杂度都是O(nlogn),但它不是原地排序算法,空间复杂度高O(n),这是它的致命缺点。正因为如此,它没有快速排序应用广泛。

快速排序算法虽然在最坏情况下时间复杂度是O(n^2)。但最好情况,平均情况时间复杂度都是O(nlogn)。

而且,快速排序算法时间复杂度退化为O(n^2) 的概率非常小,我们可以通过合理地选择pivot来避免这种情况。

  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值