数据结构与算法之美笔记——排序(下)

摘要:

本章节主要讲解「归并排序」( Merge Sort )和「快速排序」( Quick Sort ),这两种排序主要应用了分治的思想,时间复杂度都为 O ( n l o g n ) O(nlogn) O(nlogn),但是在实际生产中快速排序使用更加广泛。

归并排序

原理

归并排序将一组数据进行一分为二的分解操作,直到子数组中只有一个元素为止,此时将分解的子数组进行合并,在合并的过程中进行排序,合并后的数组就是一个有序的数组,按照这种操作方式循环合并拆分的数组,最后合并完成的数组就是已经完成排序的完整数组。

实现逻辑解析

通过上一节的归并排序的逻辑描述可以看出,拆分数组与合并数组就是一个递归的过程,在递归章节讲过如果要实现递归代码最重要是的找到递推公式和终止条件。

根据归并排序的逻辑可以得到如下的递推公式和终止条件。

递推公式 m e r g e _ s o r t ( p , r ) = m e r g e ( m e r g e _ s o r t ( p , q ) , m e r g e _ s o r t ( q + 1 , r ) ) merge\_sort(p,r)=merge(merge\_sort(p,q),merge\_sort(q+1,r)) merge_sort(p,r)=merge(merge_sort(p,q),merge_sort(q+1,r))
终止条件 p ≥ r p\geq r pr

主要说一下上面公式中参数,p 和 r 分别表示数组头、尾的元素下标,q 表示数组中间元素的下标,满足公式 q = p + r 2 q=\frac{p+r}2 q=2p+r,当 p ≥ r p\geq r pr 时就是数组被拆分到只有一个元素的时候,这个时候拆分终止。

代码实现
递归代码的实现
public void mergeSort(int[] array, int head, int tail) {
  if(head >= tail) {return;}

  mergeSort(array, head, (head + tail) / 2);
  mergeSort(array, (head + tail) / 2 + 1, tail);
  merge(array, head, tail);
}
merge 函数的实现

从代码中可以看到,归并排序的重点在于 merge 方法的实现,接下来我们分析一下 merge 函数如何实现。

我们申请一个临时数组用来保存合并后的数组,当合并完成后把临时数组中的元素迁移至原数组中相应位置。合并过程中需要对两个子数组的元素有序合并,根据归并排序的逻辑两个子数组已经是有序的,我们可以通过两个指针 i 和 j 分别指向两个子数据的头元素,遍历数组并且对比 i 和 j 指向的元素大小,较小的元素就放入临时数组中,并且相应的指针自增 1,指向相应数组的下一个元素,直到其中的一个元素遍历完。

其中的一个子数组的元素遍历结束,也就意味着这个子数组的元素已经有序合并完成,但另外一个子数组的元素依然没有完全合并完成,因为子数组本就有序,我们只需要将剩下的群组元素按顺序添加到临时数组的尾部,这样临时数组就是合并两个子数组完成的有序数组了,最后只需要临时数组中的元素迁移至原数组相应的位置即可。

private void merge(int[] array, int head, int tail) {
  int i = head, midPoint = (head + tail) / 2, j = midPoint + 1, k = 0;
  int[] temp = new int[tail - head + 1];

  // merge sub arrays to temp
  while(i <= midPoint && j <= tail) {
    if(array[i] <= array[j]) {
      temp[k++] = array[i++];
    } else {
      temp[k++] = array[j++];
    }
  }

  // merge extra elements of sub arrays to temp
  int start = i, end = midPoint;
  if(j <= tail) {
    start = j;
    end = tail;
  }

  while(start <= end) {
    temp[k++] = array[start++];
  }

  // move temp to array
  for(int s = 0; s < k; s++) {
    array[head + s] = temp[s];
  }
}
简化 merge 函数

上一节分析并且实现了 merge 函数, 其实可以通过哨兵对 merge 函数代码进行简化。

private void mergeWithGuard(int[] array, int head, int tail) {
  int i = head, midPoint = (head + tail) / 2, j = midPoint + 1, k = 0;
  int[] temp = new int[tail - head + 1];

  // make max as guard
  int max = array[midPoint];
  if(array[tail] > max) {
    max = array[tail];
  }
  boolean isGuard = false;

  // merge sub arrays to temp
  while(i <= midPoint && j <= tail) {
    if(array[i] <= array[j]) {
      temp[k++] = array[i++];
    } else {
      temp[k++] = array[j++];
    }

    // add guard
    if((i > midPoint || j > tail) && !isGuard) {
      if(i > midPoint) {
        i--;
        array[i] = max;
      } else {
        j--;
        array[j] = max;
      }
      isGuard = true;
    }
  }

  // move temp to array
  for(int s = 0; s < k; s++) {
    array[head + s] = temp[s];
  }
}

这里哨兵是两个子数组中的最大值,基于两个子数组有序的条件,比较两个子数组的最后一个元素就可以得到结果,当一个子数组被遍历结束后将哨兵插入这个子数组的末尾,并将子数组的相应指针指向尾部,此时循环就不会结束,直到将另一个子数组的所有元素也合并进临时数组才会结束。这里的哨兵就可以简化掉将未遍历完的子数组元素合并至临时数组的代码。

执行效率分析
时间复杂度

分析归并排序的时间复杂度其实就是在分析递归的时间复杂度。归并排序的递归方法时间复杂度主要由三部分构成,两个拆分后的子数组归并排序的时间复杂度和合并数组的时间复杂度,可以转换为如下公式。

T ( c ) = T ( a ) + T ( b ) + K T(c)=T(a)+T(b)+K T(c)=T(a)+T(b)+K
T 表示消耗的时间
c 表示归并排序未分解的数组
a 和 b 分别表示归并排序两个拆分后的子数组
K 表示合并数组消耗的时间
当归并排序元素个数为 1 的数组时消耗时间是个常数, T ( 1 ) = C T(1)=C T(1)=C

其实分析递归算法的时间复杂度也是按照递推公式。归并排序的合并操作时间复杂度是 O ( n ) O(n) O(n),所以时间复杂度的计算公式可以转换为 T ( n ) = T ( n 2 ) + T ( n 2 ) + n = 2 T ( n 2 ) + n T(n)=T(\frac n2)+T(\frac n2)+n=2T(\frac n2)+n T(n)=T(2n)+T(2n)+n=2T(2n)+n,假设将大小为 n 的数组进行拆分至大小为 1 的数组需要 k 次,根据公式进行递推。

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\times T(\frac n2)+n=2\times(2\times T(\frac n4)+\frac n2)+n=4\times T(\frac n4)+2n= 4\times(2\times T(\frac n8)+\frac n4)+2n=8\times T(\frac n8)+3n=...=2^k\times T(\frac n{2^k})+k\times n T(n)=2×T(2n)+n=2×(2×T(4n)+2n)+n=4×T(4n)+2n=4×(2×T(8n)+4n)+2n=8×T(8n)+3n=...=2k×T(2kn)+k×n

因为第 k 次时拆分后子数组大小为 1,所以有等式关系 n 2 k = 1 \frac{n}{2^k}=1 2kn=1,即可以得到 k = log ⁡ 2 n k=\log_2n k=log2n,将 k 代入第 k 次递推公式, T ( n ) = 2 log ⁡ 2 n × T ( 1 ) + n × log ⁡ 2 n = n × C + n × log ⁡ 2 n T(n)=2^{\log_2n}\times T(1)+n\times\log_2n=n\times C+n\times \log_2n T(n)=2log2n×T(1)+n×log2n=n×C+n×log2n,使用大 O 标记法表示时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

根据上面的分析归并排序不会受到数据有序度等的影响,所以归并排序的最好、最坏和平均时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)

空间复杂度

归并排序的递归方法中,只有 merge 函数申请了一个额外的存储空间作为临时数组,但不需要将每个递归的子问题的临时数组的额外空间都进行累积计算,因为临时数组的存储空间在使用完成后就会被释放,所以归并排序的空间复杂度是 O ( n ) O(n) O(n),也就是说归并排序并不是原地算法。

稳定性

归并排序拆分数组操作并不会导致相同数据的前后顺序发生改变,在合并子数组的操作中控制小于等于的数合并在前,也不会改变相同数据的前后顺序,所以归并排序是稳定的排序算法。

快速排序

原理

快速排序(以下简称「快排」)也是根据分治的思想而来,与归并排序相似,快排也会进行数组的拆分,但不同的是快排不会进行合并操作。快排的排序操作发生在分区,也就是子数组拆分的时候,快排进行分区时会选择数组中的一个元素作为「分区点」( pivot ),接着遍历数组中元素并把元素与分区点进行比较,比分区点小的数据放置在分区点的左端,比分区点大的数据放置在分区点的右端,子数组的拆分就以分区点的位置为中间点进行拆分,子数组再进行同样的操作直到子数组元素只有 1 个时终止。

实现逻辑解析

根据快排的原理,快排与归并排序类似,也是属于递归的方式实现,同样可以先分析快排的递推公式和终止条件。

递推公式 q u i c k _ s o r t ( p , r ) = p a r t i t i o n ( p , r ) + q u i c k _ s o r t ( p , q − 1 ) + q u i c k _ s o r t ( q + 1 , r ) quick\_sort(p,r)=partition(p,r)+quick\_sort(p,q-1)+quick\_sort(q+1,r) quick_sort(p,r)=partition(p,r)+quick_sort(p,q1)+quick_sort(q+1,r)
终止条件 p ≥ r p\geq r pr

递推公式中的 q 表示分区点的下标位置。

代码实现
private void quickSort(int[] array, int head, int tail) {
  if(head >= tail) {return;}

  int pivot = partition(array, head, tail);
  quickSort(array, head, pivot - 1);
  quickSort(array, pivot + 1, tail);
}
partition 函数的实现

上一节的代码实现中可以看出,partition 函数的实现是快排的实现重点。根据快排的原理介绍,最先想到的实现方法是先确定分区点(一般会选择当前排序数组的最后一个元素),再申请两个临时数组将比分区点大的数据放入一个数组,比分区点小的数据放入另一个数组,最后再将两个数组和分区点合并成一个数组。

虽然这样可以实现 partition 函数的功能,但因为会额外申请两个临时数组,会造成空间复杂度的增加,其实分区函数可以使用巧妙的方法实现,将空间复杂度降低到 O ( 1 ) O(1) O(1)

partition 函数的实现方法与选择排序的方式类似,会使用两个指针 i 和 j 同时指向数组的头元素,以 i 指向数组位置之前为比分区点小的区域,j 作为遍历数组的指针,当遍历到的数据比分区点小时与 i 指向的元素交换,并且 i 的位置向后移动一位,当循环遍历结束后将分区点元素与 i 指向的元素交换。

private int partition(int[] array, int head, int tail) {
  int i = 0;
  for(int j = 0; j <= tail; j++) {
    if(array[j] <= array[tail]) {
      int temp = array[i];
      array[i] = array[j];
      array[j] = temp;
      i++;
    }
  }

  return i - 1;
}
执行效率分析
时间复杂度

快排的时间复杂度会受到数据有序程度和分区点选择的影响,我们先假设分区点每次都能将数组均分,这种情况下快排的时间复杂度分析与归并排序的分析相似,快排的分区函数时间复杂度也为 O ( n ) O(n) O(n),所以这种情况下快排的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

分区点每次都能将数组分成两个平均的子数组这种情况比较理想,属于快排分区的最好情况,所以 O ( n l o g n ) O(nlogn) O(nlogn) 是快排的最好时间复杂度。

如果在数据完全有序的情况下,每次分区点都选择最后一个,需要进行 n 次排序,每次排序的时间复杂度为 O ( n ) O(n) O(n),所以这种情况下快排的时间复杂度会退化为 O ( n 2 ) O(n^2) O(n2),这种分区极不平衡的情况下时间复杂度是快排的最坏时间复杂度。

那快排的平均时间复杂度是多少?这里通过递推公式进行计算会比较复杂,在大多数情况下快排的时间复杂度都可以做到 O ( n l o g n ) O(nlogn) O(nlogn),只有极少数情况会退化为 O ( n 2 ) O(n^2) O(n2),而且也有方法将这种情况发生的概率降到很低,在以后的章节中会讲解。

空间复杂度

快排没有申请额外的存储空间,所以空间复杂度是 O ( 1 ) O(1) O(1),也就是说快排是原地算法。

稳定性

快排的分区函数会交换元素的位置,例如 6 , 4 , 6 , 1 , 3 6,4,6,1,3 6,4,6,1,3 这组数据在第一次分区交换时就会导致两个 6 的前后顺序发生变化,所以快排是一种不稳定的排序算法。

两种排序的比对

两种算法虽然都是根据分治思想,利用递归方法实现的,但是归并排序是先将问题分解为子问题,最后再将子问题归并及排序,这是种由下至上的解决思路,快排先进行了分区排序再将问题分解为子问题进行类似的解决,这是由上至下的解决思路。

归并算法虽然与快排的时间复杂度一样,都是 O ( n l o g n ) O(nlogn) O(nlogn),并且也稳定,但就是由于不是原地算法,导致在使用上没有快排广泛。


文章中如有问题欢迎留言指正
本章节代码已经上传GitHub,可点击跳转查看代码详情。
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值