极客时间-数据结构与算法之美(二)

10 | 递归:如何用三行代码找到“最终推荐人”?

递归需要满足的三个条件

1. 一个问题的解可以分解为几个子问题的解

2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样

3. 存在递归终止条件

把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。

如何编写递归代码?

写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了。

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤

递归代码要警惕堆栈溢出

函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,我们就不继续往下再递归了,直接返回报错。

但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。

递归代码要警惕重复计算

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

怎么将递归代码改写为非递归代码?

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。

编写递归代码的技巧

  1. 终止条件

  2. 递推公式

  3. 清理现场

编写递归的关键是思考终止条件,把问题抽象成一个递推公式,并信任它一定能完成任务,不用想一层层的调用关系,试图用人脑分解递归是反人类的,最多只能想两三层。

递归的缺点 递归会利用栈保存临时变量,如果递归过深,会造成栈溢出。解决方案是控制递归的深度。

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

如何分析一个“排序算法”?

排序算法的执行效率

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

2. 时间复杂度的系数、常数 、低阶

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

排序算法的内存消耗

原地排序算法,指空间复杂度是 O(1) 的排序算法。冒泡、插入、选择排序,都是原地排序算法。

排序算法的稳定性

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变

冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。

//冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int []a,int n){
if(n<=1)return;
for(int i=0;i<n;++i){
//提前退出冒泡循环的标志位
boolean flag=false;
for(int j=0;j<n-i-1;++j){
if(a[j]>a[j+1]){//交换
int tmp=a[j];
a[j]=a[j+1];
a[j+1]=tmp;
flag=true;//表示有数据交换
}
}
if(!flag)break;//没有数据交换,提前退出
}
}
 

第一,冒泡排序是原地排序算法吗?

冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。

第二,冒泡排序是稳定的排序算法吗?

在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

第三,冒泡排序的时间复杂度是多少?

对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n* (n-1)/2 次交换,时间复杂度为 O(n^2)。最好情况下,初始状态的有序度是 n* (n-1)/2,就不需要进行交换,需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n^2),所以平均情况下的时间复杂度就是 O(n^2)。

插入排序(Insertion Sort)

一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?只要遍历数组,找到数据应该插入的位置将其插入即可。

插入排序具体是如何借助上面的思想来实现排序的呢

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

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。

对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

//插入排序,a表示数组,n表示数组大小
public void insertionSort(int []a,int n){
if(n<=1) return;
​
for(int i=1;i<n;++i){
int value=a[i];
int j=i-1;
//查找插入的位置
for(;j>=0;--j){
if(a[j]>value){
a[j+1]=a[j];//数据移动
}else{
break;
}
}
a[j+1]=value;//插入数据
}
}

第一,插入排序是原地排序算法吗?

插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),这是一个原地排序算法。

第二,插入排序是稳定的排序算法吗?

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

第三,插入排序的时间复杂度是多少?

如果要排序的数据已经是有序的,我们并不需要搬移任何数据。最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据

如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n^2)。

在数组中插入一个数据的平均时间复杂度是多少?是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n^2)。

选择排序(Selection Sort)

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

首先,选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n^2)。

选择排序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。

虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O(n^2),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。

排序

一、排序方法与复杂度归类

(1)冒泡排序、插入排序、选择排序、快速排序、归并排序、计数排序、基数排序、桶排序。 (2)复杂度归类

冒泡排序、插入排序、选择排序 O(n^2)

快速排序、归并排序 O(nlogn)

计数排序、基数排序、桶排序 O(n)

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

<1>算法的执行效率

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

  2. 时间复杂度的系数、常数和低阶。

  3. 比较次数,交换(或移动)次数。

<2>排序算法的稳定性

  1. 稳定性概念:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

  2. 稳定性重要性:可针对对象的多种属性进行有优先级的排序。

  3. 举例:给电商交易系统中的“订单”排序,按照金额大小对订单数据排序,对于相同金额的订单以下单时间早晚排序。用稳定排序算法可简洁地解决。先按照下单时间给订单排序,排序完成后用稳定排序算法按照订单金额重新排序。

<3>排序算法的内存损耗 原地排序算法:特指空间复杂度是O(1)的排序算法。

三、冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让它俩互换。 稳定性:冒泡排序是稳定的排序算法。 空间复杂度:冒泡排序是原地排序算法。 时间复杂度:

最好情况(满有序度):O(n)。最坏情况(满逆序度):O(n^2)。 平均情况:最好情况下初始有序度为n* (n-1)/2,最坏情况下初始有序度为0,则平均初始有序度为n* (n-1)/4,即交换次数为n*(n-1)/4,因交换次数<比较次数<最坏情况时间复杂度,所以平均时间复杂度为O(n^2)。

四、插入排序

插入排序将数组数据分成已排序区间和未排序区间。初始已排序区间只有一个元素,即数组第一个元素。在未排序区间取出一个元素插入到已排序区间的合适位置,直到未排序区间为空。 空间复杂度:插入排序是原地排序算法。 时间复杂度:最好情况:O(n)。最坏情况:O(n^2)。平均情况:O(n^2)(往数组中插入一个数的平均时间复杂度是O(n),一共重复n次)。 稳定性:插入排序是稳定的排序算法。

五、选择排序

选择排序将数组分成已排序区间和未排序区间。初始已排序区间为空。每次从未排序区间中选出最小的元素插入已排序区间的末尾,直到未排序区间为空。 空间复杂度:选择排序是原地排序算法。 时间复杂度:(都是O(n^2)) 稳定性:选择排序不是稳定的排序算法。

思考

选择排序和插入排序的时间复杂度相同,都是O(n2),在实际的软件开发中,为什么更倾向于使用插入排序而不是冒泡排序算法呢?

答:从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个,所以在对相同数组进行排序时,冒泡排序的运行时间理论上要长于插入排序。

/**
 * 冒泡排序
 * @param a 待排序数组
 * @param n 数组长度
 */
public static void bubbleSort(int[] a, int n) {
        if(n<=0) return ;
    for (int i = 0; i < n; i++) {
        //标记一次冒泡是否存在数据交换,若存在,则改为true
        boolean tag = false;
        for (int j = 0; j < n-1-i; j++) {
            if(a[j] > a[j+1]){
                int temp = a[j];
                a[j] = a[j+1];
                a[j+1] = temp;
                tag = true;
            }
        }
        //若本次冒泡操作未发生数据交换,则终止冒泡操作
        if (tag == false) break;
    }
}
/**
 * 插入排序
 * @param a 待排序数组
 * @param n 表示数组大小
 */
public static void insertSort(int[] a, int n) {
       if(n<=1) return;
       for(int i=1;i<n;i++){
            int value=a[i];
            int j=i-1;
            //找到插入位置
            for(;j>0;j--){
            if(a[j]>value){
                  a[j+1]=a[j];//移动数据
            } else {
                  break;
            }
       }
       a[j+1]=value;//插入数据
       }
}
​
/**
 * 选择排序
 * @param a 待排序数组
 * @param n 数组长度
 */
public static void selectSort(int[] a, int n) {
    if(n<=0) return;
        for(int i=0;i<n;i++){
             int min=i;
             for(int j=i;j<n;j++){
                  if(a[j] < a[min]) min=j;
             }
             if(min != i){
                  int temp=a[i];
                  a[i]=a[min];
                  a[min]=temp;
             }
        }
}

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

归并排序的原理

归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

归并排序使用的就是分治思想分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

如何用递归代码来实现归并排序

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

我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (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(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() 函数,也就是两个有序子数组合并成一个有序数组的那部分。

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

第二,归并排序的时间复杂度是多少?

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

第三,归并排序的空间复杂度是多少?

归并排序的时间复杂度任何情况下都是 O(nlogn),但是归并排序不是原地排序算法。

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

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

快速排序的原理

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

如果我们用递推公式来将上面的过程写出来的话,就是这样:

递推公式:
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)
}

如果我们不考虑空间消耗的话,partition() 分区函数可以写得非常简单。我们申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]。

如果我们希望快排是原地排序算法,那它的空间复杂度得是 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[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(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下才会退化到 O(n^2)。

  • 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]。

如果 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] 区间查找。

为什么上述解决思路的时间复杂度是 O(n)?

第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。

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

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

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

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

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

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

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

桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序(Linear sort)。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。

桶排序(Bucket sort)

桶排序,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。

桶排序对要排序数据的要求是非常苛刻的。

首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。

其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

计数排序(Counting sort)

计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?

假设只有 8 个考生,考生的成绩我们放在一个数组 A[8] 中,它们是:2,5,3,0,2,3,0,3。

考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。C[6] 内存储的是对应的考生个数。我们只需要遍历一遍考生分数,就可以得到 C[6] 的值。

分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 R[8] 中,会保存下标 4,5,6 的位置。

那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?

思路是这样的:我们对 C[6] 数组顺序求和,C[k] 里存储小于等于分数 k 的考生个数。

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

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

// 计数排序,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];
}
}

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

基数排序(Radix sort)

假设有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,有什么比较快速的排序方法呢?

借助稳定排序算法,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。

注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。

根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。

实际上,我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n)

桶排序和计数排序的排序思想是非常相似的,都是针对范围不大的数据,将数据划分成不同的桶来实现排序。基数排序要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。而且每一位的数据范围不能太大,因为基数排序算法需要借助桶排序或者计数排序来完成每一个位的排序工作。

总结:桶排序、计数排序、基数排序

一、线性排序算法介绍

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

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

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

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

二、桶排序(Bucket sort)

1.算法原理: 1)将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序。 2)桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

2.使用条件 1)要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。 2)数据在各个桶之间分布是均匀的。

3.适用场景 1)桶排序比较适合用在外部排序中。 2)外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中。

三、计数排序(Counting sort)

1.算法原理 1)计数其实就是桶排序的一种特殊情况。 2)当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶 3)每个桶内的数据值都是相同的,就省掉了桶内排序的时间。

2.使用条件 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,这个如何实现呢?如果字符串中处理大小写,还有数字,将数字放在最前面,又该如何解决呢? 用两个指针a、b:a指针从头开始往后遍历,遇到大写字母就停下,b从后往前遍历,遇到小写字母就停下,交换a、b指针对应的元素;重复如上过程,直到a、b指针相交。

对于小写字母放前面,数字放中间,大写字母放后面,可以先将数据分为小写字母和非小写字母两大类,进行如上交换后再在非小写字母区间内分为数字和大写字母做同样处理

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

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

如何优化快速排序?

如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n^2)。主要原因还是因为我们分区点选的不够合理。

最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多

1. 三数取中法

从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”就不够了,可能要“五数取中”或者“十数取中”。

2. 随机法

就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法不能保证每次分区点都选的好,但是也不大可能会每次分区点都选的很差,所以平均情况下,这样选的分区点是比较好的。

快速排序是用递归来实现的。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。

举例分析排序函数

qsort() 会优先使用归并排序来排序输入数据要排序的数据量比较大的时候,qsort() **会改为用快速排序算法来排序**。

qsort() 选择分区点的方法就是“三数取中法”。

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

对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。

总结:如何实现一个通用的高性能的排序函数?

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

1.为什么选择快速排序?

1)线性排序时间复杂度很低但使用场景特殊,如果要写一个通用排序函数,不能选择线性排序。 2)为了兼顾任意规模数据的排序,一般会首选时间复杂度为O(nlogn)的排序算法来实现排序函数。 3)同为O(nlogn)的快排和归并排序相比,归并排序不是原地排序算法,所以最优的选择是快排。

二、如何优化快速排序?

导致快排时间复杂度降为O(n)的原因是分区点选择不合理,最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。

如何优化分区点的选择?有2种常用方法,如下: 1.三数取中法 ①从区间的首、中、尾分别取一个数,然后比较大小,取中间值作为分区点。 ②如果要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。 2.随机法:每次从要排序的区间中,随机选择一个元素作为分区点。 3.警惕快排的递归发生堆栈溢出,有2中解决方法,如下: ①限制递归深度,一旦递归超过了设置的阈值就停止递归。 ②在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。

三、通用排序函数实现技巧 1.数据量不大时,可以采取用时间换空间的思路 2.数据量大时,优化快排分区点的选择 3.防止堆栈溢出,可以选择在堆上手动模拟调用栈解决 4.在排序区间中,当元素个数小于某个常数是,可以考虑使用O(n^2)级别的插入排序 5.用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致

四、思考 1.Java中的排序函数都是用什么排序算法实现的?有哪些技巧?

  • 查看了下Arrays.sort的源码,主要采用TimSort算法, 大致思路是这样的:

    1 元素个数 < 32, 采用二分查找插入排序(Binary Sort)

    2 元素个数 >= 32, 采用归并排序,归并的核心是分区(Run)

    3 找连续升或降的序列作为分区,分区最终被调整为升序后压入栈

    4 如果分区长度太小,通过二分插入排序扩充分区长度到分区最小阙值

    5 每次压入栈,都要检查栈内已存在的分区是否满足合并条件,满足则进行合并

    6 最终栈内的分区被全部合并,得到一个排序好的数组

  • Timsort的合并算法非常巧妙:

1 找出左分区最后一个元素(最大)及在右分区的位置

2 找出右分区第一个元素(最小)及在左分区的位置

3 仅对这两个位置之间的元素进行合并,之外的元素本身就是有序的

  • java1.8中的排序,在元素小于47的时候用插入排序,大于47小于286用双轴快排,大于286用timsort归并排序,并在timesort中记录数据的连续的有序段的位置,若有序段太多,也就是说数据近乎乱序,则用双轴快排,当然快排的递归调用的过程中,若排序的子数组数据数量小,用插入排序

  • golang标准库中的Sort用的是快排+希尔排序+插排,数据量大于12时用快排,小于等于12时用6作为gap做一次希尔排序,然后走一遍普通的插排(插排对有序度高的序列效率高)。其中快排pivot的选择做了很多工作不是一两句话可以描述出来,是基于首中尾中值的很复杂的变种

  • .NET里面的Array排序实现: \1. 三个以内的,直接比较,交换进行实现 2.大于3个小于16个的,用的是插入排序进行的实现 3.对于大于16,并且深度限制是0的,用的是堆排序实现的 4.对于大于15,并且深度限制不是0的,使用的是快速排序;然后快速排序分区使用的也是三数取中法

  • Google v8中对QuickSort的实现是: 数据规模在10以内的话使用快排; 数据规模在10到1000之间时选择中点作为pivot进行快排; 数据规模在1000以上时,每隔200到215个数选一个数,将选出来的数排序,选择中间值作为pivot进行快排; 而且还有几个细节: 1是折半的时候用的是位运算; 2是每一次遍历都会分成小于pivot,等于pivot,大于pivot的三个区间; 3是小于pivot和大于pivot这两个区间中数据规模比较小的会递归执行QuickSort,数据规模大的会先通过while循环减小数据规模。 附上源码链接: https://github.com/v8/v8/blob/master/src/js/array.js

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值