快排查找数组中的第K个最大元素

x33g5p2x  于2021-11-13 转载在 其他  
字(5.7k)|赞(0)|评价(0)|浏览(345)

冒泡排序、插入排序、选择排序时间复杂度都是O(n2),适合小规模数据排序。

两种时间复杂度为O(nlogn)的排序算法,归并排序和快速排序。这两种排序算法适合大规模数据排序,更常用。

归并排序和快速排序都用到了分治思想。

归并排序

要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并,整个数组就有序了。

使用的分治思想,跟递归思想很像。
因为分治算法一般都是用递归实现:

  • 分治是一种解决问题的处理思想
  • 递归是一种编程技巧

二者不冲突。

写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。

想写出归并排序代码,先写出归并排序递推公式。

递推公式:
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到q和从q+1到r这两个子数组都排好序之后,再将两个有序子数组合并,这样下标p~r的数据就都排好序了。

有了递推公式,转化成代码:

// 归并排序算法, 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(A[p…r], A[p…q], A[q+1…r]):将有序的A[p…q]、A[q+1…r]合并成一个有序数组,并放入A[p…r]。
如下,申请一个临时数组tmp,大小与A[p…r]相同。
两个游标i、j,分别指向A[p…q]、A[q+1…r]的第一个元素。
比较这两个元素A[i],A[j]:

  • A[i]<=A[j],则将A[i]放入临时数组tmp,且i后移一位
  • 否则将A[j]放入到数组tmp,j后移一位

继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组,再把另一数组中的数据依次加到临时数组的末尾,这时,临时数组中存储的就是两个子数组合并后结果。最后再把临时数组tmp中的数据拷贝到原数组A[p…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]
  }
}

merge()合并函数如果借助哨兵,代码就会简洁很多。

性能分析

分析排序算法的三个问题:

稳定性

关键看merge():两个有序子数组合并成一个有序数组时。

合并过程中,若A[p…q]和A[q+1…r]之间有值相同的元素,则可像伪代码中那样,先把A[p…q]中的元素放入tmp数组。这就保证值相同的元素,在合并前后的先后顺序不变。所以,归并排序是个稳定排序算法。

时间复杂度

归并排序涉及递归,分析稍有点复杂。

  • 递归的适用场景
    一个问题A可分解为多个子问题B、C,则求解问题A即可分解为求解问题B、C。问题BC解决后,再把BC的结果合并成A的结果。

若定义求解问题A的时间是T(A),可得递推关系式:

T(A) = T(B) + T(C) + P

P = 子问题BC的结果合并成问题A的结果所消耗时间

可见递归求解的问题可写成递推公式,递归代码的时间复杂度也可写成递推公式

假设对n个元素归排需时间T(n),分解成两个子数组排序的时间都是T(n/2)。
merge()合并两个有序子数组的时间复杂度是O(n)。
所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
n = 1 : n=1:n=1:
T ( 1 ) = C ; T(1) = C;T(1)=C;

n > 1 : n>1: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
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......

可得
T ( n ) = 2 k ∗ T ( n / 2 k ) + k n T(n) = 2^k * T(n/2^k)+knT(n)=2k∗T(n/2k)+kn

当T ( n / 2 k ) = T ( 1 ) T(n/2^k) = T(1)T(n/2k)=T(1) =》n / 2 k = 1 n/2^k=1n/2k=1 =》k = l o g 2 n k=log2nk=log2n

将k值代入上面公式=》

T ( n ) = C n + n l o g 2 n T(n)=Cn+nlog2nT(n)=Cn+nlog2n

用大O标记法表示:

T ( n ) = O ( n l o g n ) T(n)=O(nlogn)T(n)=O(nlogn)

所以归并排序的时间复杂度是O ( n l o g n ) O(nlogn)O(nlogn)。

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

空间复杂度

归并排序的时间复杂度任何情况下都是O(nlogn),真是个秀儿。
但归并排序并未像快排应用广泛,why?
它有个致命点:不是原地排序算法。

归并排序的合并函数,在合并两个有序数组为一个有序数组时,需借助额外存储空间。

递归代码的空间复杂度不能像时间复杂度那样累加。
尽管每次合并操作都需申请额外内存空间,但合并完成后,临时开辟的内存空间就被释放了。任意时刻,CPU只会有一个函数在执行,也就只会有一个临时内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度O(n)。

快速排序算法(Quicksort)

快排也是分治思想。乍看有点像归并排序,但思路完全不同。

思想

排序数组中下标从p到r之间的一组数据,选择p到r间任意一数据为pivot(分区点)。

遍历p~r数据:

  • 小于pivot的放到左边
  • 大于pivot的放到右边
  • pivot放到中间

经过这步后,p~r的数据就被分成三部分:

根据分治、递归,可用递归排序下标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)
}
// 快速排序递归函数,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)
}

归并排序有个merge()函数,这有个partition()函数:随机选择一个元素作为pivot(一般可选择p~r区间的最后一个元素),然后对A[p…r]分区,函数返回pivot下标。

若不考虑空间消耗,partition()可写得很简单。申请两个临时数组X、Y,遍历A[p…r]:

  • 将<pivot的元素拷贝到X
  • >pivot的元素都拷贝到Y
  • 最后将X、Y中数据顺序拷贝到A[p…r]

但若按照此思路,partition()需很多额外内存空间,那就不是原地排序算法。若希望快排是原地排序算法,则其空间复杂度得O(1),partition()就不能占用太多额外内存空间,需在A[p…r]原地完成分区操作。
原地分区函数:

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[j],与pivot对比:
  • <pivot,则将其加入已处理区间尾部,即A[i]位置

在数组某个位置插入元素,需搬移数据,很耗时。但通过交换,在O(1)时间复杂度内完成插入操作。
这里也是借助这个思想,只需交换A[i]、A[j],即可在O(1)时间复杂度内将A[j]放到下标为i的位置。
分区的整个过程。

分区过程涉及交换操作,如果数组中有两个相同的元素,比如序列
6,8,7,6,3,5,9,4
在经过第一次分区操作之后,两个6的相对先后顺序就会改变。所以,快排不是稳定排序算法。

快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?

归排的处理过程由下到上,先处理子问题,再合并。
快排正好相反,处理过程由上到下,先分区,再处理子问题。
归排虽稳定、时间复杂度为O(nlogn),但非原地排序算法。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归排占用太多内存问题。

性能分析

快排也是用递归来实现的。递归代码的时间复杂度,使用之前总结的公式也适用。
每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是O(nlogn)。

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

但公式成立前提是每次分区操作,pivot都很合适,正好能将大区间对等地一分为二。但实际很难满足。
极端的:数组数据原已有序,如1,3,5,6,8。如每次选择最后一个元素作为pivot,那每次分区得到的两个区间都不均等。需要进行约n次分区操作,才能完成。每次分区平均要扫描n/2元素,这时快排时间复杂度从O ( n l o g n ) O(nlogn)O(nlogn)退化成O ( n 2 ) O(n2)O(n2)。

分区极其均衡,分区极其不均衡分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。

平均情况时间复杂度

假设每次分区操作都将区间分成大小9:1的两个小区间。
套用递归时间复杂度的递推公式:

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

该公式的递推求解的过程很复杂,不推荐使用。
递归的时间复杂度求解除了递推公式之外,还有递归树:
T(n)在大部分情况下的时间复杂度都能做到O ( n l o g n ) O(nlogn)O(nlogn),只在极端情况下,才会退化到O ( n 2 ) O(n2)O(n2)。有很多方法将这个概率降到很低。

解答

快排核心思想就是分治和分区,可利用分区思想: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]:

  • K<p+1
    在A[0…p-1]区间查找
  • p+1=K,则A[p]就是目标
  • K>p+1, 则第K大元素在A[p+1…n-1]
    再继续同样思路递归查找A[p+1…n-1]

时间复杂度分析

第一次分区查找,需对大小为n的数组执行分区操作,遍历n个元素。
第二次分区查找,只需对n/2数组分区,遍历n/2个元素
类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间为1。

每次分区遍历的元素个数相加:

  • n + n / 2 + n / 4 + n / 8 + … + 1 = 2 n − 1 n+n/2+n/4+n/8+…+1=2n-1n+n/2+n/4+n/8+…+1=2n−1

所以,上述解决思路的时间复杂度就为O(n)。

那我每次取数组中的最小值,将其移动到数组最前,然后在剩下的数组中继续找最小值,以此类推,执行K次,找到的数据不就是第K大元素了吗?
这时间复杂度就不是O(n),而是O(K * n)
那时间复杂度前面的系数不是可以忽略吗?O(K * n)不就等于O(n)?
切记!可不能简单划等。当K是比较小的常量时,比如1、2,那最好时间复杂度确实是O(n);但当K等于n/2或者n时,这种最坏情况下的时间复杂度就是O ( n 2 ) O(n2)O(n2)!

相关文章