阅读 33

数据结构与算法<三>

其他更多java基础文章:
java基础学习(目录)


这系列是根据极客时间《数据结构与算法之美》这个课程做的笔记

本篇目录

  • 如何分析排序算法
  • 冒泡排序、插入排序、选择排序 O(n^2)
  • 快速排序、归并排序 O(nlogn)
  • 计数排序、基数排序、桶排序 O(n)
  • 排序优化

前言

排序方法与复杂度归类

  1. 几种最经典、最常用的排序方法:冒泡排序、插入排序、选择排序、快速排序、归并排序、计数排序、基数排序、桶排序。
  2. 复杂度归类
    • 冒泡排序、插入排序、选择排序 O(n^2)
    • 快速排序、归并排序 O(nlogn)
    • 计数排序、基数排序、桶排序 O(n)

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

1. 算法的执行效率

  • 最好、最坏、平均情况时间复杂度。
  • 时间复杂度的系数、常数和低阶。
  • 比较次数,交换(或移动)次数。

2. 排序算法的稳定性

稳定性概念:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
稳定性重要性:可针对对象的多种属性进行有优先级的排序。
举例:给电商交易系统中的“订单”排序,按照金额大小对订单数据排序,对于相同金额的订单以下单时间早晚排序。用稳定排序算法可简洁地解决。先按照下单时间给订单排序,排序完成后用稳定排序算法按照订单金额重新排序。

3. 排序算法的内存损耗

原地排序算法:特指空间复杂度是O(1)的排序算法。

O(n^2)级别排序

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让它俩互换。

  • 稳定性:冒泡排序是稳定的排序算法。
  • 空间复杂度:冒泡排序是原地排序算法。
  • 时间复杂度:
    • 最好情况(满有序度):O(n)。

    • 最坏情况(满逆序度):O(n^2)。

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

“有序度”和“逆序度”:对于一个不完全有序的数组,如4,5,6,3,2,1,有序元素对为3个(4,5),(4,6),(5,6),有序度为3,逆序度为12;

对于一个完全有序的数组,如1,2,3,4,5,6,有序度就是n*(n-1)/2,也就是15,称作满有序度;  
**逆序度=满有序度-有序度;冒泡排序、插入排序交换(或移动)次数=逆序度。**
复制代码

插入排序

插入排序将数组数据分成已排序区间和未排序区间。初始已排序区间只有一个元素,即数组第一个元素。在未排序区间取出一个元素插入到已排序区间的合适位置,直到未排序区间为空。

  • 稳定性:插入排序是稳定的排序算法。
  • 空间复杂度:插入排序是原地排序算法。
  • 时间复杂度:
    • 最好情况:O(n)。
    • 最坏情况:O(n^2)。
    • 平均情况:O(n^2)(往数组中插入一个数的平均时间复杂度是O(n),一共重复n次)。

选择排序

选择排序将数组分成已排序区间和未排序区间。初始已排序区间为空。每次从未排序区间中选出最小的元素插入已排序区间的末尾,直到未排序区间为空。

  • 稳定性:选择排序不是稳定的排序算法。
  • 空间复杂度:选择排序是原地排序算法。
  • 时间复杂度:(都是O(n^2))
    • 最好情况:O(n^2)。
    • 最坏情况:O(n^2)。
    • 平均情况:O(n^2)。

O(nlogn)级别排序

分治思想

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

归并排序

算法原理先把数组从中间分成前后两部分,然后对前后两部分分别进行排序,再将排序好的两部分合并到一起,这样整个数组就有序了。这就是归并排序的核心思想。如下递推公式:

merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:p >= r 不用再继续分解
复制代码

稳定性

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

时间复杂度

分析归并排序的时间复杂度就是分析递归代码的时间复杂度。递归的适用场景是:

  1. 一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。
  2. 问题b、c解决之后,我们再把b、c的结果合并成a的结果。
  3. 若定义求解问题a的时间是T(a),则求解问题b、c的时间分别是T(b)和T(c),那就可以得到这样的递推公式:T(a) = T(b) + T(c) + 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*(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(1) 时,也就是 n/2^k=1,我们得到k=log2n。将k带入上面的公式就得到T(n)=Cn+nlog2n。如用大O表示法,T(n)就等于O(nlogn)。
所以,归并排序的是复杂度时间复杂度就是O(nlogn)。

空间复杂度

归并排序算法不是原地排序算法,空间复杂度是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
复制代码

稳定性

因为分区过程中涉及交换操作,如果数组中有两个8,其中一个是pivot,经过分区处理后,后面的8就有可能放到了另一个8的前面,先后顺序就颠倒了,所以快速排序是不稳定的排序算法。

比如数组[1,2,3,9,8,11,8],取后面的8作为pivot,那么分区后就会将后面的8与9进行交换。

时间复杂度

最好、最坏、平均情况快排也是用递归实现的,所以时间复杂度也可以用递推公式表示。
如果每次分区操作都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并的相同。

T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2 * T(n/2) + n; n>1所以,快排的时间复杂度也是O(nlogn)。
复制代码

但是,公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。
我举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如1,3,5,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就从O(nlogn)退化成了O(n2)。
前面两种情况,一个是分区及其均衡,一个是分区极不均衡,它们分别对应了快排的最好情况时间复杂度和最坏情况时间复杂度。

那快排的平均时间复杂度是多少呢?
T(n)大部分情况下是O(nlogn),只有在极端情况下才是退化到O(n^2),而且我们也有很多方法将这个概率降低。

空间复杂度

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

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

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

O(n)级别排序

线性排序算法介绍

  1. 线性排序算法包括桶排序、计数排序、基数排序。
  2. 线性排序算法的时间复杂度为O(n)。
  3. 此3种排序算法都不涉及元素之间的比较操作,是非基于比较的排序算法。
  4. 对排序数据的要求很苛刻,重点掌握此3种排序算法的适用场景。

桶排序(Bucket sort)

算法原理:

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

使用条件

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

适用场景

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

应用案例

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

解决思路: 扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。 第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。 每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。 将100个小文件依次放入内存并用快排排序。 所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。

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

计数排序(Counting sort)

算法原理

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

使用条件

1. 只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序;
2. 计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数;
3. 比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。
复制代码

案例分析:

假设只有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内的数据就是按照分数从小到大排列的了。

基数排序(Radix sort)

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

算法原理

1. 比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。
2. 借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推,最后按照第一个位重新排序。
3. 经过11次排序后,手机号码就变为有序的了。
4. 每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。
复制代码

使用条件

1. 要求数据可以分割独立的“位”来比较;
2. 位之间由递进关系,如果a数据的高位比b数据大,那么剩下的地位就不用比较了;
3. 每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。
复制代码

排序优化

为什选择快速排序?

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

如何优化快速排序?

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

优化分区点的选择

如何优化分区点的选择?有2种常用方法,如下:

  1. 三数取中法
    • 从区间的首、中、尾分别取一个数,然后比较大小,取中间值作为分区点。
    • 如果要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。
  2. 随机法:每次从要排序的区间中,随机选择一个元素作为分区点。

警惕快排的递归发生堆栈溢出

还有要警惕快排的递归发生堆栈溢出,有2种解决方法,如下:

  1. 限制递归深度,一旦递归超过了设置的阈值就停止递归。
  2. 在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。

通用排序函数实现技巧

  1. 数据量不大时,O(n^2) 排序算法并不一定比 O(nlogn) 排序算法执行的时间长。
  2. 数据量大时,优化快排分区点的选择
  3. 防止堆栈溢出,可以选择在堆上手动模拟调用栈解决
  4. 在排序区间中,当元素个数小于某个常数是,可以考虑使用O(n^2)级别的插入排序
  5. 用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致