时间复杂度为O(nlogn)的算法

柔情只为你懂 2021-10-01 09:34 504阅读 0赞

mergeSort

口诀:

左拆分,左合并,右拆分,右合并,最后合并左右。

归并排序的逻辑

归并排序的战略(宏观)逻辑

  1. 先将原数组拆分为arr.length个小数组,每个数组长度为1
  2. 小数组之间合并,进行第一次合并
  3. 第一次合并后,此时大数组分为了arr.length/2个小数组,每个小数组有两个元素,再次合并
  4. 每次合并后,将新的最小数组合并。

拆分的逻辑是递归,需要先推导出递归的公式和退出低轨的条件:

  1. 忽略常数项 参数l,r代表数组左右索引,数组允许左右索引想等
  2. mergeSort(l,r) = merge(mergeSort(l,p),merge_sort(p+1,r))
  3. 退出条件为:
  4. l>=r

归并排序合并的逻辑

本质:最小数组(左右数组)元素之间的比较,构造一个有序数组,完成合并

  1. while循环,左右数组均从个字其实索引位开始比较,并将本次比较的小值放到临时数组中。直到一侧数组中所有元素都放入了临时数组(即一侧数组元素清空)
  2. 判断余下了那测数组,将该测数组遍历并放入临时数组中(此时是一测数组内部循坏,没测数组都是有序的,所以直接遍历赋值即可)
  3. 将新数组遍历赋值给原数组

18721752-c6cc0f90f07596a7.png

image.png

代码实现

  1. //递归拆分,直到拆分到最小数组长度为1
  2. public static void mergeSort(int[] arr,int left,int right,int[] temp){
  3. int mid = (left+right)/2;
  4. if (left<right){
  5. mergeSort(arr,left,mid,temp);
  6. mergeSort(arr,mid+1,right,temp);
  7. merge(arr,left,mid,right,temp);
  8. }
  9. }
  10. //合并
  11. private static void merge(int[] arr, int left,int mid, int right, int[] temp) {
  12. //左侧数组的索引,赋予初始值为左侧数组的起始索引
  13. int leftIndex = left;
  14. //右侧数组的索引,赋予初始值为右侧数组的起始索引
  15. int rightIndex = mid+1;
  16. int tIndex = 0;
  17. //todo 最小数组之间的元素比较并给临时数组赋值 eg:数组中四个元素,左右各两个元素,左面数组和右侧数组均从起始索引遍历,放入临时数组
  18. while(leftIndex<=mid && rightIndex<=right){
  19. //起始判定:左侧数组最小值,和右侧数组最小值比较, 左右数组都从各自的起始元素开始比较,放入临时数组,跳出循环的条件为,当一侧数组先比较完,即出现一侧数组清空的情况,跳出循环。
  20. //注意此处一定要是<= 为了应对值相同的场景,保证其的稳定性
  21. if (arr[leftIndex]<=arr[rightIndex]){
  22. temp[tIndex] = arr[leftIndex];
  23. leftIndex++;
  24. tIndex++;
  25. }else {
  26. temp[tIndex] = arr[rightIndex];
  27. rightIndex++;
  28. tIndex++;
  29. }
  30. }
  31. //todo 将剩下的值都放到临时数组中
  32. //判断哪测数组清空了,将未清空的一侧数组按照其索引位开始遍历,放到临时数组中(没测数组均是有序的)
  33. while (leftIndex <= mid){
  34. temp[tIndex] = arr[leftIndex];
  35. tIndex++;
  36. leftIndex++;
  37. }
  38. while (rightIndex<=right){
  39. temp[tIndex] = arr[rightIndex];
  40. tIndex++;
  41. rightIndex++;
  42. }
  43. //todo 将临时数组复制给arr 可以直接遍历赋值,因为原数组的这部分元素都在临时数组中
  44. int arrIndex = left;
  45. tIndex = 0;
  46. while (arrIndex<=right){
  47. // System.out.println(tIndex+"-----------"+arrIndex);
  48. arr[arrIndex] = temp[tIndex];
  49. arrIndex++;
  50. tIndex++;
  51. }
  52. }

归并思想:

将一组数据拆分至最小单元,在将最小单元合并,合并的过程中进行排序,合并直到最小单元长度等于元数据长度。

性能分析

内存消耗

O(n)
实际上,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。

稳定性

是否稳定决定于代码21行
if (arr[leftIndex]<=arr[rightIndex])
如果判断条件是<=那就是稳定的,如果判断条件是<而不是<=就是不稳定的。

执行效率

最好,最坏,平均都是O(nlogn)

这里分析时间复杂度,和之前有点不同,因为通过递归实现的,所以这里的时间复杂度即分析递归的时间复杂度。

递归代码时间复杂度推导过程

首先先看一下递归的使用场景
一个问题 a 可以分解为多个子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,我们再把 b、c 的结果合并成 a 的结果。

递归代码的时间复杂度:

如果我们定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那我们就可以得到这样的递推关系式:
T(a) = T(b) + T(c) + K
其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。

mergeSort的时间复杂度:

我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

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

2*T(n/2)为每个子数组排序的时间
n为合并两个子数组的时间,merge() 函数合并两个有序子数组的时间复杂度是 O(n)
以上只是一次merge的时间函数,如果在以上的基础上再次拆分:

  1. T(n) = 2*T(n/2) + n
  2. = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n //进行两次合并
  3. = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
  4. = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
  5. ......
  6. = 2^k * T(n/2^k) + k * n
  7. ......

当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。
大O分析,则得出时间复杂度为:

  1. O(nlogn)

归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn),这种排序算法,和原数组是否有序无关。

快速排序

排序逻辑

通过不断递归二分,不断将数组分成两组,左侧组所有元素都比右侧组所有元素小,在和中间值比较的过程中,先到达中间位置的一侧负责控制另一侧的索引,让另一组合中间值比较与交换中间值,也就是另一组和中间值排序。

  • 从数列中挑出一个元素,称为 “基准”(pivot);
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

递归公式

  1. quick_sort(pr) = quick_sort(pq-1) + quick_sort(q+1 r)
  2. 终止条件:
  3. p >= r

code

  1. public static void quickSort(int[] arr,int left,int right){
  2. //todo 首次分组通过中间值,制造两组,左面小于中间值,右面大于中间值,后面每次都在各自组中再次分组,直到中间值等于最小值或者等于最大值。
  3. int leftIndex = left;
  4. int rightIndex = right;
  5. int middleValue = arr[(leftIndex + rightIndex)/2];
  6. //左少 -1,1,2,0,3,4,6 l1 r3 l2 r2 l3 r1 -1,0,2,1,3,4,6 右少 -1,-2,-3,0,-4,-5,6 -1,1,2,0,-3,4,5 -1,-3,2,0,1,4,5 -1,-3,0,2,1,4,5
  7. while (leftIndex < rightIndex){
  8. int temp = 0;
  9. while (arr[leftIndex] < middleValue){
  10. leftIndex++;
  11. }
  12. while (arr[rightIndex] > middleValue){
  13. rightIndex--;
  14. }
  15. if (leftIndex == rightIndex){
  16. break;
  17. }
  18. temp = arr[leftIndex];
  19. arr[leftIndex] = arr[rightIndex];
  20. arr[rightIndex] = temp;
  21. //每次中轴两侧比较,左右互补越过中轴,左先到,等右,右先到,等左
  22. if (arr[rightIndex] == middleValue){
  23. leftIndex++;
  24. }
  25. if (arr[leftIndex] == middleValue){
  26. rightIndex--;
  27. }
  28. }
  29. //每次出循环,必然leftIndex = rightIndex
  30. if (leftIndex == rightIndex){
  31. leftIndex++;
  32. rightIndex--;
  33. }
  34. if (leftIndex<right ){
  35. quickSort(arr,leftIndex,right);
  36. }
  37. if (rightIndex>left){
  38. quickSort(arr,left,rightIndex);
  39. }
  40. }

性能分析

内存消耗

O(1)

稳定性

不稳定

执行效率

最佳情况:T(n) = O(nlogn) 最差情况:T(n) = O(n2) 平均情况:T(n) = O(nlogn)

总结

归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。
快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。

发表评论

表情:
评论列表 (有 0 条评论,504人围观)

还没有评论,来说两句吧...

相关阅读

    相关 算法时间复杂

    在 [算法基础][Link 1] 中,我们简单介绍了什么是算法、对算法的要求,以及说了评断算法效率的两大类方法。今天我们将重点介绍衡量算法效率的一个概念——时间复杂度。 --

    相关 算法时间复杂

    算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,比如排序就有前面的十大经典排序和几种奇葩排序,虽

    相关 算法 - 时间复杂

    注:本文仅为笔记 [原文][Link 1] [极客时间 - 数据结构与算法之美 - 03 | 复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?][- _ - 03

    相关 算法时间复杂

    时间复杂度就是通常我们简称的复杂度,O(f(n))表示。 常见的算法时间复杂度由小到大依次为: Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n\2)<