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

归并排序和快速排序,是两种时间复杂度为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 的中间位置 qmerge_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实现了并归算法,如有错误,请指正。)

 @Testpublic 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  1414  15  17  17  18  18  21  21  22  2324  27  27  27  28  28  30  30  30  3335  35  36  38  41  41  42  44  44  4545  46  47  48  48  48  49  51  51  5151  51  54  54  55  56  57  57  61  6262  63  66  66  68  68  73  73  75  7577  79  80  81  81  81  82  82  84  8485  88  89  89  90  91  92  92  93  9494  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实现了并归算法,如有错误,请指正。)

 @Testpublic 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  6999  102  124  159  164  167  189  191  195  202221  250  251  267  289  292  295  300  311  315317  323  326  328  329  353  361  363  366  385388  407  412  419  426  435  440  477  480  489493  499  509  513  516  519  525  527  542  542600  604  631  649  655  656  658  664  672  672687  688  706  708  714  719  719  723  762  781808  811  846  853  854  856  861  861  864  866882  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来避免这种情况。

排序(下):归并排序和快速排序相关推荐

  1. 十大排序算法:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序

    冒泡排序.选择排序.插入排序.希尔排序.归并排序.快速排序.堆排序.计数排序.桶排序.基数排序的动图与源代码. 目录 关于时间复杂度 冒泡排序 选择排序 插入排序 希尔排序 归并排序 快速排序 堆排序 ...

  2. 希尔排序和归并排序以及快速排序

    高级排序 高级排序 高级排序 1.希尔排序 2.归并排序 3.快速排序 4.快速排序和归并排序的区别 5.几种快速排序的测试 6.排序的稳定性 所谓高级排序就是在比时间复杂度上比简单排序更快一点,尤其 ...

  3. 【数据结构与算法】高级排序(希尔排序、归并排序、快速排序)完整思路,并用代码封装排序函数

    本系列文章[数据结构与算法]所有完整代码已上传 github,想要完整代码的小伙伴可以直接去那获取,可以的话欢迎点个Star哦~下面放上跳转链接 https://github.com/Lpyexplo ...

  4. 排序算法--(冒泡排序,插入排序,选择排序,归并排序,快速排序,桶排序,计数排序,基数排序)

    一.时间复杂度分析 - **时间复杂度**:对排序数据的总的操作次数.反应当n变化时,操作次数呈现什么规律 - **空间复杂度**:算法在计算机内执行时所需要的存储空间的容量,它也是数据规模n的函数. ...

  5. 适用于大规模数据排序(归并排序、快速排序)

    一.归并排序 1.1.分析 先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了. 1.2.使用递推思路实现 ①先写出递推公式:[mergeSo ...

  6. 数据结构与算法-基础算法篇-排序(归并排序、快速排序)

    3. 归并排序.快速排序 1. 分治思想 分治,顾明思意,就是分而治之,将一个大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了. 分治与递归的区别:分治算法一般都用递归来实现的.分治是 ...

  7. 排序下---(冒泡排序,快速排序,快速排序优化,快速排序非递归,归并排序,计数排序)

    排序上 排序上 交换类排序 基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动. ...

  8. 2.C++-选择排序、冒泡排序、插入排序、希尔排序、归并排序、快速排序

    1.常用排序算法介绍 一个排序算法的好坏需要针对它在某个场景下的时间复杂度和空间复杂度来进行判断.并且排序都需要求其稳定性,比如排序之前a在b前面,且a=b,排序之后也要保持a在b前面. 常用排序算法 ...

  9. 十大经典排序算法(图解与代码)——冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序(Python and Java)

    排序 重新排列表中的元素,使表中的元素按照关键字递增或者递减 内部排序: 指在排序期间,元素全部存放在内存中的排序 外部排序: 指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断 ...

  10. 【完整可运行源码+GIF动画演示】十大经典排序算法系列——冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序

    以前也零零碎碎发过一些排序算法,但总是不系统, 这次彻底的对排序系列做了一个整体的规划, 小伙伴们快快mark哦~ [GIF动画+完整可运行源代码]C++实现 冒泡排序--十大经典排序算法之一 [GI ...

最新文章

  1. 设计模式五:外观模式
  2. jQuery技术内幕电子版4
  3. koa --- seesion实现登录鉴权
  4. LeetCode 第 23 场双周赛(970/2044,前47.5%)
  5. 最通俗易懂的 Java 10 新特性讲解 | 原力计划
  6. ASA防火墙基本操作
  7. MYS-6ULX-IOT 开发板测评——使用 Yocto 添加软件包
  8. 美国纽约摄影学院摄影教材 学习笔记1
  9. 图形编辑器:对齐功能的实现
  10. [.NET源码] asp.net中手机版和PC版识别
  11. 前端初级学习阶段(3)
  12. APP上架各大应用市场教程:所需材料与注意事项
  13. 栈和队列的基本操作(栈和队列的区别)
  14. C++小课堂:STL中的栈容器(stack)
  15. 一些有用的在线工具(二)
  16. paessler公司PRTG 可以监控您的整个 IT 基础设施官方免费下载试用
  17. UDP 分片 与 丢包,UDP 真的比 TCP 高效吗?
  18. android webview问题汇总
  19. docker privileged参数解释
  20. 2018互联网企业最新面试大纲180+道Java面试题目!含答案解析!

热门文章

  1. 09- 京东客户购买意向预测 (机器学习集成算法) (项目九) *
  2. Ceph-ansible 安装 ceph (rbd + rgw)
  3. 离线数仓建设及技术选型
  4. MFC之CFile读取和写入文件
  5. 模拟退火算法——仿真篇
  6. Java中的枚举类是什么?enum关键字怎么使用?
  7. Unity3D状态机运行状态不显示解决方案哈哈哈
  8. python matplotlib plt bins histogram 直方图
  9. SSM SpringBoot vue限房摇号系统
  10. 解耦、削峰、异步的理解