算法之美7 - 排序

排序

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


11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?

一、几种经典排序算法及其时间复杂度级别

冒泡、插入、选择 O(n^2) 基于比较

快排、归并 O(nlogn) 基于比较

计数、基数、桶 O(n) 不基于比较

二、如何分析一个排序算法?

1.学习排序算法的思路?明确原理、掌握实现以及分析性能。

2.如何分析排序算法性能?从执行效率、内存消耗以及稳定性3个方面分析排序算法的性能。

3.执行效率:从以下3个方面来衡量

1)最好情况、最坏情况、平均情况时间复杂度

2)时间复杂度的系数、常数、低阶:排序的数据量比较小时考虑

3)比较次数和交换(或移动)次数

4.内存消耗:通过空间复杂度来衡量。针对排序算法的空间复杂度,引入原地排序的概念,原地排序算法就是指空间复杂度为O(1)的排序算法。

5.稳定性:如果待排序的序列中存在值等的元素,经过排序之后,相等元素之间原有的先后顺序不变,就说明这个排序算法时稳定的。

三、冒泡排序

1.排序原理

1)冒泡排序只会操作相邻的两个数据。

2)对相邻两个数据进行比较,看是否满足大小关系要求,若不满足让它俩互换。

3)一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。

4)优化:若某次冒泡不存在数据交换,则说明已经达到完全有序,所以终止冒泡。

2.代码实现


def bubble_sort1(a):
    # 冒泡排序,大数下沉
    # O(n**2) | O(1) | 原地 | 稳定

    has_changed = False  # 提前退出冒泡循环的标记位
    n = len(a)
    for i in range(n):
        for j in range(n-i-1):
            if a[j] > a[j+1]:
                a[j], a[j+1] = a[j+1], a[j]
                has_changed = True  # 数据有交换
        if not has_changed:  # 没有数据交换,提前退出
            break
    return a

3.性能分析

1)执行效率:最小时间复杂度、最大时间复杂度、平均时间复杂度

最小时间复杂度:数据完全有序时,只需进行一次冒泡操作即可,时间复杂度是O(n)。

最大时间复杂度:数据倒序排序时,需要n次冒泡操作,时间复杂度是O(n^2)。

平均时间复杂度:通过有序度和逆序度来分析。

什么是有序度?

有序度是数组中具有有序关系的元素对的个数,比如[2,4,3,1,5,6]这组数据的有序度就是11,分别是[2,4][2,3][2,5][2,6][4,5][4,6][3,5][3,6][1,5][1,6][5,6]。同理,对于一个倒序数组,比如[6,5,4,3,2,1],有序度是0;对于一个完全有序的数组,比如[1,2,3,4,5,6],有序度为n*(n-1)/2,也就是15,完全有序的情况称为满有序度。

什么是逆序度?逆序度的定义正好和有序度相反。核心公式:逆序度=满有序度-有序度。

排序过程,就是有序度增加,逆序度减少的过程,最后达到满有序度,就说明排序完成了。

冒泡排序包含两个操作原子,即比较和交换,每交换一次,有序度加1。不管算法如何改进,交换的次数总是确定的,即逆序度。

对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏的情况初始有序度为0,所以要进行n*(n-1)/2交换。最好情况下,初始状态有序度是n*(n-1)/2,就不需要进行交互。我们可以取个中间值n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。

换句话说,平均情况下,需要n*(n-1)/4次交换操作,比较操作可定比交换操作多,而复杂度的上限是O(n2),所以平均情况时间复杂度就是O(n2)。

以上的分析并不严格,但很实用,这就够了。

2)空间复杂度:每次交换仅需1个临时变量,故空间复杂度为O(1),是原地排序算法。

3)算法稳定性:如果两个值相等,就不会交换位置,故是稳定排序算法。

四、插入排序

1.算法原理

首先,我们将数组中的数据分为2个区间,即已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想就是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间中的元素一直有序。重复这个过程,直到未排序中元素为空,算法结束。

2.代码实现


def insert_sort(a):
    # 插入排序,已排序区间j,未排序区间i
    # O(n**2) | O(1) | 原地 | 稳定

    n = len(a)
    for i in range(1, n):
        val = a[i]  # i指向带插元素
        j = i - 1  # j指向有序元素
        while j >= 0:
            if a[j] > val:
                a[j + 1] = a[j]  # j指向的有序元素后移
            else:
                break
            j -= 1
        a[j+1] = val  # 插入位置
    return a

3.性能分析

1)时间复杂度:最好、最坏、平均情况

如果要排序的数组已经是有序的,我们并不需要搬移任何数据。只需要遍历一遍数组即可,所以时间复杂度是O(n)。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,因此时间复杂度是O(n2)。而在一个数组中插入一个元素的平均时间复杂都是O(n),插入排序需要n次插入,所以平均时间复杂度是O(n2)。

2)空间复杂度:从上面的代码可以看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),是原地排序算法。

3)算法稳定性:在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现的元素的后面,这样就保持原有的顺序不变,所以是稳定的。

五、选择排序

1.算法原理

选择排序算法也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,并将其放置到已排序区间的末尾。

2.代码实现


def select_sort(a):
    # 已排序区间i, 未排序区间j
    # O(n**2) | O(1) | 原地 | 不稳定
    n = len(a)
    for i in range(0, n):
        min = i
        for j in range(i + 1, n):  # 从待排记录中选出最小的记录
            if a[min] > a[j]:
                min = j
        a[min], a[i] = a[i], a[min]  # 将最小的记录与待排序记录中的第一个记录进行交换
    return a

3.性能分析

1)时间复杂度:最好、最坏、平均情况

选择排序的最好、最坏、平均情况时间复杂度都是O(n^2)。为什么?因为无论是否有序,每个循环都会完整执行,没得商量。

2)空间复杂度:

选择排序算法空间复杂度是O(1),是一种原地排序算法。

3)算法稳定性:

选择排序算法不是一种稳定排序算法,比如[5,8,5,2,9]这个数组,使用选择排序算法第一次找到的最小元素就是2,与第一个位置的元素5交换位置,那第一个5和中间的5的顺序就变量,所以就不稳定了。正因如此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

六、希尔排序(shell sort)

希尔排序

七、小结

八、思考

1.冒泡排序和插入排序的时间复杂度都是 O(n^2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?

冒泡排序移动数据有3条赋值语句,而选择排序的交换位置的只有1条赋值语句,因此在有序度相同的情况下,冒泡排序时间复杂度是选择排序的3倍,所以,选择排序性能更好。

2.如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?


12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?

一、分治 & 递归

  • 分治思想:分治,顾明思意,就是分而治之,将一个大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了。
  • 分治与递归的区别:分治算法一般都用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧。

二、归并排序

  • Divde & Conquer

  • 合并函数

  • 算法原理

先把数组从中间分成前后两部分,然后对前后两部分分别进行排序,再将排序好的两部分合并到一起,这样整个数组就有序了。这就是归并排序的核心思想。如何用递归实现归并排序呢?写递归代码的技巧就是分写得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。递推公式怎么写?如下

递推公式:merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(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]) {
  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()合并函数如果借助哨兵代码就会简洁很多

  • 性能分析

1)算法稳定性:

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

2)时间复杂度:分析归并排序的时间复杂度就是分析递归代码的时间复杂度

如何分析递归代码的时间复杂度?

递归的适用场景是一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决之后,我们再把b、c的结果合并成a的结果。若定义求解问题a的时间是T(a),则求解问题b、c的时间分别是T(b)和T©,那就可以得到这样的递推公式:T(a) = T(b) + T© + K,其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。这里有一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。套用这个公式,那么归并排序的时间复杂度就可以表示为:

T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。

T(n) = 2*T(n/2) + n; n>1,其中n就是merge()函数合并两个子数组的的时间复杂度O(n)。

T(n) = 2*T(n/2) + n

= 2*(2T(n/4) + n/2) + n = 4T(n/4) + 2*n

= 4*(2T(n/8) + n/4) + 2n = 8T(n/8) + 3n

= 8*(2T(n/16) + n/8) + 3n = 16T(n/16) + 4n

= 2^k * T(n/2^k) + k * n

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

注:终止条件是T(1)。我们要把这个公式最终化简成T(1)的函数。当n/2^k=1时 那就化简成T(1)的函数了

3)空间复杂度:归并排序算法不是原地排序算法,空间复杂度是O(n)

为什么?因为归并排序的合并函数,在合并两个数组为一个有序数组时,需要借助额外的存储空间。为什么空间复杂度是O(n)而不是O(nlogn)呢?如果我们按照分析递归的时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是O(nlogn),但这种分析思路是有问题的!因为,在实际上,递归代码的空间复杂度并不是像时间复杂度那样累加,而是这样的过程,即在每次合并过程中都需要申请额外的内存空间,但是合并完成后,临时开辟的内存空间就被释放掉了,在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时空间再大也不会超过n个数据的大小,所以空间复杂度是O(n)。

三、快速排序

快排核心思想就是分区分治

  • 算法原理

快排的思想是这样的:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。

然后遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将povit放到中间。经过这一步之后,数组p到r之间的数据就分成了3部分,前面p到q-1之间都是小于povit的,中间是povit,后面的q+1到r之间是大于povit的。

根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都有序了。

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

终止条件:p >= r
  • 代码实现

快排的思想是这样的:如果要排序数组中下标从 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,就说明所有的数据都有序了。


// 快速排序,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(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]分成2部分,A[p...i-1]的元素都是小于pivot的,我们暂且叫它“已处理区间”,A[i+1...r-1]是“未处理区间”。我们每次都从未处理区间取出一个元素A[j],与poivt相比,如果小于pivot,则将其加入到已处理区间的尾部,也就是A[i]位置。


def quick_sort2(lists, left, right):
    """
    通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,
    然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

    :param lists: 
    :param left: 
    :param right: 
    :return:
    """
    if left >= right:
        return lists
    pivot = lists[left]
    low = left
    high = right
    while left != right:
        while left < right and lists[right] >= pivot:
            right -= 1
        lists[left] = lists[right]
        while left < right and lists[left] <= pivot:
            left += 1
        lists[right] = lists[left]
    lists[right] = pivot
    quick_sort2(lists, low, left - 1)
    quick_sort2(lists, left + 1, high)
    return lists
  • 性能分析

1)算法稳定性:

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

2)时间复杂度:最好、最坏、平均情况

快排也是用递归实现的,所以时间复杂度也可以用递推公式表示。

如果每次分区操作都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并的相同。

T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。

T(n) = 2*T(n/2) + n; n>1

所以,快排的时间复杂度也是O(nlogn)

如果数组中的元素原来已经有序了,比如1,3,5,6,8,若每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的,需要进行大约n次的分区,才能完成整个快排过程,而每次分区我们平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就是O(n^2)。

前面两种情况,一个是分区及其均衡,一个是分区极不均衡,它们分别对应了快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均时间复杂度是多少呢?T(n)大部分情况下是O(nlogn),只有在极端情况下才是退化到O(n^2),而且我们也有很多方法将这个概率降低。

3)空间复杂度:快排是一种原地排序算法,空间复杂度是O(1)

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

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

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

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

思考

  • O(n)时间复杂度内求无序数组中第K大元素,比如4,2,5,12,3这样一组数据,第3大元素是4。

快排核心思想就是分治分区

我们选择数组区间A[0…n-1]的最后一个元素作为pivot,对数组A[0…n-1]进行原地分区,这样数组就分成了3部分,A[0…p-1]、A[p]、A[p+1…n-1]。

如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1…n-1]区间,我们按照上面的思路递归地在A[p+1…n-1]这个区间查找。同理,如果K<p+1,那我们就在A[0…p-1]区间查找。

时间复杂度分析?

第一次分区查找,我们需要对大小为n的数组进行分区操作,需要遍历n个元素。第二次分区查找,我们需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。依次类推,分区遍历元素的个数分别为n、n/2、n/4、n/8、n/16…直到区间缩小为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)。

  • 有10个访问日志文件,每个日志文件大小约为300MB,每个文件里的日志都是按照时间戳从小到大排序的。现在需要将这10个较小的日志文件合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述任务的机器内存只有1GB,你有什么好的解决思路能快速地将这10个日志文件合并?

先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1)


13 | 线性排序:如何根据年龄给100万用户数据排序?

一、线性排序算法介绍

1.线性排序算法包括桶排序、计数排序、基数排序。

2.线性排序算法的时间复杂度为O(n)。

3.此3种排序算法都不涉及元素之间的比较操作,是非基于比较的排序算法。

4.对排序数据的要求很苛刻,重点掌握此3种排序算法的适用场景。

二、桶排序(Bucket sort)

1.算法原理:

1)将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序

2)桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

2.使用条件

1)要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。

2)数据在各个桶之间分布是均匀的。

3.适用场景

1)桶排序比较适合用在外部排序中。

2)外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中。

4.应用案例

1)需求描述:

10GB的订单数据,需按订单金额(假设金额都是正整数)进行排序,但内存有限,仅几百MB

2)解决思路:

扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。

第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。

每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。

将100个小文件依次放入内存并用快排排序。

所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。

3)注意点:若单个文件无法全部载入内存,则针对该文件继续按照前面的思路进行处理即可。

三、计数排序(Counting sort)

1.算法原理

1)计数其实就是桶排序的一种特殊情况。

2)当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶

3)每个桶内的数据值都是相同的,就省掉了桶内排序的时间。

2.代码实现


// 计数排序,a 是数组,n 是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;


  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }


  int[] c = new int[max + 1]; // 申请一个计数数组 c,下标大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }


  // 计算每个元素的个数,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }


  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }


  // 临时数组 r,存储排序之后的结果
  int[] r = new int[n];
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }


  // 将结果拷贝给 a 数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

案例分析:

我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?

考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。

假设只有8个考生分数在0-5分之间,成绩存于数组A[8] = [2,5,3,0,2,3,0,3]。

使用大小为6的数组C[6]表示桶,下标对应分数,即0,1,2,3,4,5。

C[6]存储的是考生人数,只需遍历一边考生分数,就可以得到C[6] = [2,0,2,3,0,1]。

对C[6]数组顺序求和则C[6]=[2,2,4,7,7,8],c[k]存储的是小于等于分数k的考生个数。

数组R[8] = [0,0,2,2,3,3,3,5]存储考生名次。那么如何得到R[8]的呢?

从后到前依次扫描数组A,比如扫描到3时,可以从数组C中取出下标为3的值7,也就是说,到目前为止,包括自己在内,分数小于等于3的考生有7个,也就是说3是数组R的第7个元素(也就是数组R中下标为6的位置)。当3放入数组R后,小于等于3的元素就剩下6个了,相应的C[3]要减1变成6。

以此类推,当扫描到第二个分数为3的考生时,就会把它放入数组R中第6个元素的位置(也就是下标为5的位置)。当扫描完数组A后,数组R内的数据就是按照分数从小到大排列的了。

3.使用条件

1)只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序;

2)计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数;

3)比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。

四、基数排序(Radix sort)

1.算法原理(以排序10万个手机号为例来说明)

1)比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。

2)借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推,最后按照第一个位重新排序。

3)经过11次排序后,手机号码就变为有序的了。

4)每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。

手机号码稍微有点长,画图比较不容易看清楚,我用字符串排序的例子,画了一张基数排序的过程分解图,你可以看下。

2.使用条件

1)要求数据可以分割独立的“”来比较;

2)位之间由递进关系,如果a数据的高位比b数据大,那么剩下的地位就不用比较了;

3)每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。

五、思考

1.如何根据年龄给100万用户数据排序?

答:实际上,根据年龄给 100 万用户排序,就类似按照成绩给 50 万考生排序。我们假设年龄的范围最小 1 岁,最大不超过 120 岁。我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素。这样就得到了按照年龄排序的 100 万用户数据。

2.对D,a,F,B,c,A,z这几个字符串进行排序,要求将其中所有小写字母都排在大写字母前面,但是小写字母内部和大写字母内部不要求有序。比如经过排序后为a,c,z,D,F,B,A,这个如何实现呢?如果字符串中处理大小写,还有数字,将数字放在最前面,又该如何解决呢?

答:利用桶排序思想,弄小写,大写,数字三个桶,遍历一遍,都放进去,然后再从桶中取出来就行了。相当于遍历了两遍,复杂度O(n)


14 | 排序优化:如何实现一个通用的、高性能的排序函数?

一、如何选择合适的排序算法?

1.排序算法一览表

时间复杂度 是稳定排序? 是原地排序?

冒泡排序 O(n^2) 是 是

插入排序 O(n^2) 是 是

选择排序 O(n^2) 否 是

快速排序 O(nlogn) 否 是

归并排序 O(nlogn) 是 否

桶排序 O(n) 是 否

计数排序 O(n+k),k是数据范围 是 否

基数排序 O(dn),d是纬度 是 否

2.为什选择快速排序?

1)线性排序时间复杂度很低但使用场景特殊,如果要写一个通用排序函数,不能选择线性排序。

2)为了兼顾任意规模数据的排序,一般会首选时间复杂度为O(nlogn)的排序算法来实现排序函数。

3)同为O(nlogn)的快排和归并排序相比,归并排序不是原地排序算法,所以最优的选择是快排。

二、如何优化快速排序?

导致快排时间复杂度降为O(n^2)的原因是分区点选择不合理,最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。如何优化分区点的选择?有2种常用方法,如下:

1.三数取中法

①从区间的首、中、尾分别取一个数,然后比较大小,取中间值作为分区点。

②如果要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。

2.随机法:每次从要排序的区间中,随机选择一个元素作为分区点。

3.警惕快排的递归发生堆栈溢出,有2中解决方法,如下:

①限制递归深度,一旦递归超过了设置的阈值就停止递归。

②在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。

三、通用排序函数实现技巧

1.数据量不大时,可以采取用空间换时间的思路

2.数据量大时,优化快排分区点的选择

3.防止堆栈溢出,可以选择在堆上手动模拟调用栈解决

4.在排序区间中,当元素个数小于某个常数是,可以考虑使用O(n^2)级别的插入排序

5.用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致

C 语言中 qsort() 的底层实现原理

qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗?这就是一个典型的应用。

但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。

那 qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是**“三数取中法”**。是不是也并不复杂?

还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。我们之前在讲递归那一节也讲过,不知道你还有没有印象?

实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,O(n^2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。我们现在就来分析下这个说法。

所以,对于小规模数据的排序,O(n^2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。

还记得我们之前讲到的哨兵来简化代码,提高执行效率吗?在 qsort() 插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致。


28 | 堆和堆排序:为什么说堆排序没有快速排序快?

堆是一种特殊的树

  • 堆是一个完全二叉树
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值

堆化

堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化

往堆中插入一个元素

  • 插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化
  • 时间复杂度都是 O(logn)

删除堆顶元素

假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。

删除时出现的问题是:可能出现数组空洞

解决:

实际上,我们稍微改变一下思路,就可以解决这个问题。你看我画的下面这幅图。我们把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。

因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。

结论:

  • 删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化
  • 时间复杂度都是 O(logn)

堆排序

  • 堆排序包含两个过程,建堆和排序。我们将下标从 n/2 到 1 的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。
  • 整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法
  • 堆排序包括建堆和排序两个操作。堆排序的建堆过程的时间复杂度是 O(n)。排序过程的时间复杂度是 O(nlogn)。堆排序整体的时间复杂度是 O(nlogn)
  • 堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序

解答开篇

  • 第一点,堆排序数据访问的方式没有快速排序友好。
    • 快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问
    • 所以,这样对 CPU 缓存是不友好的。
  • 第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序
    • 快速排序数据交换的次数不会比逆序度多。
    • 堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低

课后思考

1.在讲堆排序建堆的时候,我说到,对于完全二叉树来说,下标从 n/2 + 1 到 n 的都是叶子节点,这个结论是怎么推导出来的呢?

2.我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?

  • 优先级队列
  • 求 Top K
  • 和求中位数

29 | 堆的应用:如何快速获取到Top 10最热门的搜索关键词?

堆的应用一:优先级队列

1.合并有序小文件

方案一:这里我们用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组。

假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。

整体思路有点像归并排序中的合并函数。我们从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。

假设,这个最小的字符串来自于 13.txt 这个小文件,我们就再从这个小文件取下一个字符串,并且放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,并且将它从数组中删除。依次类推,直到所有的文件中的数据都放入到大文件为止。

方案二:这里就可以用到优先级队列,也可以说是堆。我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。

我们知道,删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。

2.高性能定时器

假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。

但是,这样每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。

针对这些问题,我们就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。

这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。

当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。这样,定时器既不用间隔 1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。

堆的应用二:利用堆求 Top K

静态数据集合 | 动态数据集合

针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,所以时间复杂度就是 O(nlogK)。

针对动态数据求得 Top K 就是实时 Top K。怎么理解呢?我举一个例子。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。

如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以里立刻返回给他。

堆的应用三:利用堆求中位数

  • 静态数据

    • 中位数是固定的,我们可以先排序,n / 2 个数据就是中位数
  • 动态数据

    • 我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
    • 约定:如果 n 是偶数,两个堆中的数据个数都是 n/2;如果 n 是奇数,大顶堆有 n/2+1 个数据,小顶堆有 n/2 个数据。
    • 这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。
    • 于是,我们就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。

如何快速求接口的 99% 响应时间?

我们维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是 n,大顶堆中保存 n99% 个数据,小顶堆中保存 n1% 个数据。大顶堆堆顶的数据就是我们要找的 99% 响应时间。

每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。

但是,为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,直到满足这个比例。

通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn)。每次求 99% 响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。

解答开篇

如果我们将处理的场景限定为单机,可以使用的内存为 1GB。那这个问题该如何解决呢?

  • 散列表:来记录关键词及其出现的次数
  • 用堆求 Top K 的方法:建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中。

上面的解决思路其实存在漏洞:消耗的内存空间就更多。

解决:相同数据经过哈希算法得到的哈希值是一样的。我们可以哈希算法的这个特点,将 10 亿条搜索关键词先通过哈希算法分片到 10 个文件中。具体可以这样做:我们创建 10 个空文件 00,01,02,……,09。我们遍历这 10 亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。

对这 10 亿个关键词分片之后,每个文件都只有 1 亿的关键词,去除掉重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500MB。1GB 的内存完全可以放得下。

我们针对每个包含 1 亿条搜索关键词的文件,利用散列表和堆,分别求出 Top 10,然后把这个 10 个 Top 10 放在一块,然后取这 100 个关键词中,出现次数最多的 10 个关键词,这就是这 10 亿数据中的 Top 10 最频繁的搜索关键词了。

内容小结

我们今天主要讲了堆的几个重要的应用,它们分别是:优先级队列、求 Top K 问题和求中位数问题。

优先级队列是一种特殊的队列,优先级高的数据先出队,而不再像普通的队列那样,先进先出。实际上,堆就可以看作优先级队列,只是称谓不一样罢了。

求 Top K 问题又可以分为针对静态数据和针对动态数据,只需要利用一个堆,就可以做到非常高效率的查询 Top K 的数据。

求中位数实际上还有很多变形,比如求 99 百分位数据、90 百分位数据等,处理的思路都是一样的,即利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆的堆顶元素就是要求的数据。

课后思考

有一个访问量非常大的新闻网站,我们希望将点击量排名 Top 10 的新闻摘要,滚动显示在网站首页 banner 上,并且每隔 1 小时更新一次。如果你是负责开发这个功能的工程师,你会如何来实现呢?

  • 对每篇新闻摘要计算一个hashcode,并建立摘要与hashcode的关联关系,使用map存储,以hashCode为key,新闻摘要为值
  • 按每小时一个文件的方式记录下被点击的摘要的hashCode
  • 当一个小时结束后,上一个小时的文件被关闭,开始计算上一个小时的点击top10
  • 将hashcode分片到多个文件中,通过对hashCode取模运算,即可将相同的hashCode分片到相同的文件中
  • 针对每个文件取top10的hashCode,使用Map<hashCode,int>的方式,统计出所有的摘要点击次数,然后再使用小顶堆(大小为10)计算top10
  • 再针对所有分片计算一个总的top10,最后合并的逻辑也是使用小顶堆,计算top10
  • 如果仅展示前一个小时的top10,计算结束
  • 如果需要展示全天,需要与上一次的计算按hashCode进行合并,然后在这合并的数据中取top10
  • 在展示时,将计算得到的top10的hashcode,转化为新闻摘要显示即可
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页