06:排序
1、冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序
2、排序算法的执行效率==》
最好情况、最坏情况、平均情况时间复杂度
时间复杂度的系数、常数、低阶:对同一阶时间复杂度的排序算法性能对比的时候,就要把系数、常数、低阶也考虑进来
比较次数和交换(或移动)次数
3、排序算法的内存消耗==》
原地排序O(1)
4、排序算法的稳定性==》如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变
应用:
比如说,现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。
先按照下单时间给订单排序。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。
5、冒泡排序==》冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
优化==》当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作
原地排序、稳定排序
元素操作==》比较与交换,交换次数总数为逆序度=满有序度n*(n-1)/2-初始有序度 O(n**2)
最好情况O(n)、最坏情况\均摊均为O(n**2)
6、插入排序==》核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
元素操作==》比较与移动 需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。
原地排序、稳定排序、最好情况O(n)、最坏情况\均摊均为O(n**2)
如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)
7、选择排序==》选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
原地排序、最好情况最坏情况时间复杂度都为O(n**2) 不是稳定排序
8、为什么插入排序比冒泡排序受欢迎?==》
冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个
9、冒泡排序、插入排序、选择排序这三种排序算法,时间复杂度都是O(n**2),比较高,适合小规模数据的排序
归并排序和快速排序,适合大规模的数据排序
10、如何在O(n)的时间复杂度内查找一个无序数组中的第K大元素==》快排核心思想是分治和分区,可以利用分区的思想,O(n)时间复杂度内求无序数组中的第k大元素
11、归并排序==》核心思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
//递推公式: merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r)) //终止条件: p >= r 不用再继续分解
稳定排序、最好情况最坏情况平均情况时间复杂度都为O(nlogn) 空间复杂度O(n)
12、快速排序==》如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
partition==》通过游标 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 的位置。
原地排序、不稳定
最好情况、均摊O(logn) 最坏情况O(n**2)
13、归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法
14、题目:有 10 个接口访问日志文件,每个日志文件大小约 300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有 1GB,你有什么好的解决思路,能“快速”地将这 10 个日志文件合并吗?
15、如何根据年龄给100万用户数据排序==》桶排序
16、线性排序==》桶排序、计数排序、基数排序 O(n)==》三个算法都是非基于比较的排序算法,都不涉及元素之间的比较操作
17、桶排序==》核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序对要排序数据的要求是非常苛刻的。首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
应用:有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
18、计数排序==》计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
//计数排序,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 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
19、基数排序==》有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
可以借助稳定排序的处理思路,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。
基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
20、实现一个通用的、高性能的排序函数==》优化快速排序
21、qsort==》qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。qsort() 选择分区点的方法就是“三数取中法”。qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决递归太深会导致堆栈溢出的问题
qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长