归并和快速排序算法--数据结构和算法之美--CH12

文章目录

  • 1. 概述
  • 2. 归并排序
    • 2.1 原理分析
    • 2.2 递推公式和终止条件
    • 2.3 伪代码
    • 2.3 性能分析
      • 2.3.1 算法稳定性
      • 2.3.2 时间复杂度
      • 2.3.3 空间复杂度
  • 3. 快速排序
    • 3.1 原理分析
    • 3.2 递推公式和终止条件
    • 3.3 伪代码
    • 3.3 性能分析
      • 3.3.1 算法稳定性
      • 3.3.2 时间复杂度
      • 3.3.3 空间复杂度
  • 4. 归并排序与快速排序的区别
  • 5. 解开篇答
  • 6. 课后思考

1. 概述

  本节介绍两种时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)的排序算法,归并排序和快速排序。这两种排序都是利用了分治思想,使用递归方式来实现的。通过分治思想,将待排序数组进行分区,大事化小来降低时间复杂度,这种思想可以会在编程中经常使用到。

2. 归并排序

2.1 原理分析

  归并排序的核心思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
过程如下图所示:
归并和快速排序算法--数据结构和算法之美--CH12_第1张图片

2.2 递推公式和终止条件

递推公式:merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:p >= r 不用再继续分解

2.3 伪代码

merge_sort伪代码如下:

// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return

  // 取 p 到 r 之间的中间位置 q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(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])
}

merge函数伪代码如下:

merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化变量 i, j, k
  var tmp := new array[0...r-p] // 申请一个大小跟 A[p...r] 一样的临时数组
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++ 等于 i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
  
  // 判断哪个子数组中有剩余的数据
  var start := i,end := q
  if j<=r then start := j, end:=r
  
  // 将剩余的数据拷贝到临时数组 tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  
  // 将 tmp 中的数组拷贝回 A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

2.3 性能分析

2.3.1 算法稳定性

  归并排序稳不稳定关键要看merge()函数,也就是两个子数组合并成一个有序数组的那部分代码。在合并的过程中,如果 A[p…q] 和 A[q+1…r] 之间有值相同的元素,那我们就可以像伪代码中那样,先把 A[p…q] 中的元素放入tmp数组,这样 就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一种稳定排序算法。

2.3.2 时间复杂度

如何分析递归代码的时间复杂度公式?
  递归的适用场景是一个问题a可以分解为子问题b、c,若定义求解问题a的时间是 T ( a ) T(a) T(a),则求解问题b、c的时间分别是 T ( b ) T(b) T(b) T ( c ) T(c) T(c),那就可以得到这样的递推公式:
T ( a ) = T ( b ) + T ( c ) + K T(a) = T(b) + T(c) + K T(a)=T(b)+T(c)+K
  其中,K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。

  套用这个公式,那么归并排序的时间复杂度就可以表示为:

T ( n ) = 2 ∗ T ( n / 2 ) + n T(n) = 2*T(n/2) + n T(n)=2T(n/2)+n
= 2 ∗ ( 2 ∗ T ( n / 4 ) + n / 2 ) + n = 4 ∗ T ( n / 4 ) + 2 ∗ n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n =2(2T(n/4)+n/2)+n=4T(n/4)+2n
= 4 ∗ ( 2 ∗ T ( n / 8 ) + n / 4 ) + 2 ∗ n = 8 ∗ T ( n / 8 ) + 3 ∗ n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n =4(2T(n/8)+n/4)+2n=8T(n/8)+3n
. . . . . . ...... ......
= 2 k ∗ T ( n / 2 k ) + k ∗ n = 2^k * T(n/2^k) + k * n =2kT(n/2k)+kn
. . . . . . ...... ......

  当$T(n/2^k)=T(1) $时,也就是 n / 2 k = 1 n/2^k=1 n/2k=1,得到 k = l o g 2 n k=log2n k=log2n。将k带入上面的公式就得到
T ( n ) = C n + n l o g 2 n T(n)=Cn+nlog2n T(n)=Cn+nlog2n
  如用大O表示法, T ( n ) T(n) T(n)就等于 O ( n l o g n ) O(nlogn) O(nlogn),所以归并排序的是复杂度时间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn)

2.3.3 空间复杂度

  归并排序算法不是原地排序算法,空间复杂度是 O ( n ) O(n) O(n)
  因为归并排序的合并函数,在合并两个数组为一个有序数组时,需要借助额外的存储空间。

3. 快速排序

3.1 原理分析

  如果要排序数组中从 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,就说明所有的数据都有序了。
如下图所示:
归并和快速排序算法--数据结构和算法之美--CH12_第2张图片

3.2 递推公式和终止条件

递推公式
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)

终止条件
p >= r

3.3 伪代码

quick_sort()伪代码

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

partition()伪代码
  partition函数用来完成上述分区过程,但是如果简单采用临时数组方式保存小于pivot和大于pivot数据,然后合并入数组,这样虽然简单,但是会占用额外内存空间。为了保证快速排序为原地排序算法,采用分区函数方式如下:

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i

  这里的处理有点类似选择排序,通过游标 i 把 A[p…r-1] 分成两部分:A[p…i-1] 的元素都是小于 pivot 的,叫它“已处理区间”,A[i…r-1] 是“未处理区间”。
  每次都从未处理的区间 A[i…r-1] 中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i] 的位置,通过这种交换方式能够在 O ( 1 ) O(1) O(1)的时间完成插入操作。
  过程如下图:

归并和快速排序算法--数据结构和算法之美--CH12_第3张图片

3.3 性能分析

3.3.1 算法稳定性

  因为分区过程中涉及交换操作,如果数组中有两个8,其中一个是pivot,经过分区处理后顺序就颠倒了,所以快速排序是不稳定的排序算法。比如数组[1,2,3,9,8,11,8],取后面的8作为pivot,那么分区后就会将后面的8与9进行交换。

3.3.2 时间复杂度

最好时间复杂度: 如果每次分区操作都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并的相同。因此时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
最坏时间复杂度: 如果分区极不均衡,每次pivot都是最大或者最小值,则导致算法退化为时间复杂度 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 ( n l o g n ) O(nlogn) O(nlogn)

3.3.3 空间复杂度

  由于partition()函数的特殊分区思路,快排是一种原地排序算法,空间复杂度是O(1)。

4. 归并排序与快速排序的区别

  归并和快排都是分治思想,递推公式和递归代码也非常相似,区别在于:

  1. 归并排序,是先递归调用,再进行合并,合并的时候进行数据的交换。所以它是自下而上的排序方式。何为自下而上?就是先解决子问题,再解决父问题。
  2. 快速排序,是先分区,在递归调用,分区的时候进行数据的交换。所以它是自上而下的排序方式。何为自上而下?就是先解决父问题,再解决子问题。

如图所示:
归并和快速排序算法--数据结构和算法之美--CH12_第4张图片

5. 解开篇答

  O(n)时间复杂度内求无序数组中第K大元素? 比如[4,2,5,12,3],第3大元素是4。
step1:选择数组区间A[0…n-1]的最后一个元素作为pivot,对数组A[0…n-1]进行原地分区,分成了3部分,A[0…p-1]、A[p]、A[p+1…n-1]。
step2: 如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1…n-1]区间。如果K step3:递归上述操作,直到找到第k大元素。

  第一次分区查找,需要对大小为n的数组进行分区操作,需要遍历n个元素。
  第二次分区查找,我们需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。
  依次类推,分区遍历元素的个数分别为n、n/2、n/4、n/8、n/16…直到区间缩小为1。
  如果把每次分区遍历的元素个数累加起来,就是等比数列求和,结果为2n-1。所以时间复杂度为O(n)。

6. 课后思考

  有10个访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。现在需要将这10个较小的日志文件合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述任务的机器内存只有1GB,你有什么好的解决思路能快速地将这10个日志文件合并?
  解题思路:先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据。
  以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存。

你可能感兴趣的:(数据结构和算法)