本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用
文章目录
排序算法
大部分编程语言,都会提供排序函数,平常项目,也会经常使用排序
最经典,最常用的排序:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序和桶排序
排序算法 | 时间复杂度 | 是否基于比较 |
---|---|---|
冒泡、插入、选择 | O(n²) | 是 |
快排、归并 | O(nlogn) | 是 |
桶、计数、基数 | O(n) | 否 |
思考题:插入排序和冒泡排序的时间复杂度相同,都是O(n2),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?
1. 如何分析一个“排序算法”?
学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。分析一个排序算法,要从哪几个方面入手呢?
排序算法的执行效率
一般会从这几个方面来衡量:
1. 最好情况、最坏情况、平均情况时间复杂度
要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
为什么要区分这三种时间复杂度呢?
第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。
第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
2. 时间复杂度的系数、常数 、低阶
时间复杂度反应的是数据规模n很大的时候的一个增长趋势,实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
3. 比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。
排序算法的内存消耗
算法的内存消耗可以通过空间复杂度来衡量
针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。我们今天讲的三种排序算法,都是原地排序算法。
排序算法的稳定性
针对排序算法,我们还有一个重要的度量指标,稳定性。如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
- 为什么要考察排序算法的稳定性呢?
真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个key来排序。
比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有10万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?
最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂
借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。
稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
2 冒泡排序
1. 概念
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
2. 代码实现
public class BubbleSort {
// 冒泡排序,a表示数组,n表示数组大小
public static void bubbleSort(int[] a,int n){
if(n<=1) return;
for(int i=0;i<n-1;i++){
// 设立退出循环的标志位
boolean flag = false;
for(int j=0;j<n-i-1;j++){
if(a[j]>a[j+1]){
// 交换
int temp = a[j];
a[j] = a[j+1];
a[j+1] = temp;
// 标志位
flag = true;
}
}
if(!flag) break;//没有数据交换,提前退出
}
}
public static void main(String[] args) {
int[] a = {1,2,7,5,9,3};
bubbleSort(a,a.length);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
}
3. 问题描述
1. 冒泡排序是原地排序算法吗?
只涉及相邻数据的交换操作,只需要长良机的临时空间,空间复杂度为O(1),原地排序
2. 冒泡排序是稳定的排序算法吗?
只有交换才改变两个元素的前后顺序,为保证冒泡算法稳定性,相邻两个元素大小相等时,不做交换,相同大小的数据在排序前后顺序不变,稳定
3. 冒泡排序的时间复杂度
最好情况,有序,只需要进行一次冒泡,就可以结束,时间复杂度为O(n);最坏,倒序排列,n次冒泡,最坏时间复杂度为O(n²)
平均时间复杂度,对于包含n个数据的数组,n个数据有n!种排列方式。不同排列方式,执行时间不同。有一种思路,通过“有序度”和“逆序度”两个概念分析
有序度是数组中具有有序关系的元素对的个数。数学表达式:有序元素对:a[i]<=a[j],如果i<j
对于倒序排列的数组,有序度为0;完全有序的,有序度n*(n-1)/2
,完全有序的有序度叫²
逆序度相反,默认从小到大为有序。逆序元素对:a[i]>a[j],如果i<j
逆序度=满有序度-有序度,排序过程就是增加有序度,减少逆序度,最后达到满有序度
冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度加1,交换次数确定,也就是逆序度,为n*(n-1)/2-初始有序度
对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况,初始有序度0,进行n*(n-1)/2
次交换。最好情况,初始有序度为n*(n-1)/2
,不需要交换,取中间值n*(n-1)/4
,表示初始有序度的平均情况
也就是说,平均情况,需要n*(n-1)/4
次交换,比较操作肯定比交换操作多,而复杂度的上限为O(n²),因此平均时间复杂度就是O(n²)
3. 插入排序(Insertion Sort)
一个有序的数组,往里面添加一个新的数据,如何继续保持数据有序呢?只要遍历数组,找到数据应该插入的位置将其插入。
这是个动态过程,动态的往有序集合添加数据,通过该方法保持集合中的数据一直有序。而对于静态数据,也可以借鉴上面的过程。
插入排序具体是如何借助上面的思想实现排序呢?
首先,将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间的元素,在已排序区间找合适的插入位置将其插入,并保证已排序区间数据一直有序。重复该过程,直到未排序区间中元素为空,算法结束。
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据a插入到已排序区间,需要拿a与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点后,需要将插入点之后的元素顺序往后移动一位,腾出位置给a插入。
对于不同的查找插入点方法(从头到尾,从尾到头),元素比较次数有区别。但对于一个给定的初始序列,移动操作的次数固定,等于逆序度。
看马士兵老师的视频,非常形象,比喻为打扑克,斗地主,接牌后怎么给手里的牌排序?就是插入排序!
代码实现
public class InsertionSort {
// 插入排序,a表示数组,n表示数组大小
public static void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; i++) {
int value = a[i];
// 指针,从i-1开始,每次都和后边的值比大小,挪位置,直到确定位置
int j = i - 1;
// 查找插入的位置
for (; j >= 0; j--) {
if (a[j] > value) {
a[j + 1] = a[j]; //数据移动
} else {
break;
}
}
a[j + 1] = value; // 插入数据
}
}
public static void main(String[] args) {
int[] a = {1,3,7,9,2,4};
insertionSort(a,a.length);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
}
3. 三个问题描述
1. 插入排序是原地排序吗
插入排序的运行不需要额外的存储空间,空间复杂度为O(1),原地排序
2. 插入排序稳定吗
在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后边,保持原有的前后顺序不变,稳定。
3. 插入排序的时间复杂度
如果已经有序,从尾到头查找插入位置,每次只需要比较一个数据就能确定插入的位置,最好时间复杂度为O(n),注意:这是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,需要移动大量的数据,最坏时间复杂度为O(n²)
我们在数组中插入一个数据的平均时间复杂度为O(n),对于插入排序,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,平均时间复杂度为O(n²)
4. 选择排序(Selection Sort)
实现思路类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找最小的元素,将其放到已排序区间的末尾。
快排的时间复杂度为O(1),是一种原地排序算法。选择排序的最好时间复杂度、最坏和平均时间复杂度都是O(n²)
选择排序不稳定,每次都要找剩余未排序元素的最小值,并和前面的元素交换位置,破坏了稳定性。
比如5,8,5,2,9,使用选择排序,第一次找到最小元素2,与第一个5交换位置,第一个5和中间的5的顺序就变了,不稳定。
5. 解答开篇
冒泡排序和插入排序的时间复杂度都是O(n²),都是原地排序算法,为什么插入排序比冒泡排序更受欢迎
二者的元素交换的次数不管怎么优化都是固定值,原始数据的逆序度。
但是,从代码实现上,冒泡排序的数据交换比插入排序的数据移动更复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个。
// 冒泡排序
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
// 插入排序
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
我们把执行一个赋值语句的时间粗略统计为单位时间(unit_time),分别用冒泡排序和插入排序对同一个逆序度为k的数组进行排序。用冒泡排序,需要K次交换,每次需要3个赋值语句,交换总耗时3*K单位时间,而插入排序只需要K个单位时间。
插入排序的优化,希尔排序。
6. 归并排序(Merge Sort)
1. 概念
归并排序的核心思想:如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并起来,这样整个数组就有序了。
归并排序使用的是分治思想。分治,分而治之,将一个大问题分解成若干个小问题,小问题解决,大问题也就解决了。
分治思想一般都是用递归实现。分治是解决问题的处理思想,递归是一种编程技巧。
2. 实现
如何用递归代码实现归并排序?
归并的递推公式
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的中间位置,也就是(p+r)/2。当两个子数组都排好序,合并到一起,下标从p到r之间的数据就排好序了。
合并的具体过程:
申请一个临时数组temp,大小和a相同,用两个指针i和j,分别指向a[p…q]和a[q+1…r]的第一个元素,比较两个元素a[i]和a[j],如果a[i]<=a[j],就把a[i]放入到临时数组temp,并且i后移一位,否则将a[j]放入数组temp,j后移一位。
继续上述过程,直到其中一个子数组的所有数据都放入到临时数组,再把另一个数组的数据依次加入临时数组的末尾,最后,将临时数组拷贝到原数组。
public class MergeSort {
// 归并排序算法,a为数组,n为数组大小
public void mergerSort(int[] a,int n){
merge2sort(a,0,n-1);
}
private void merge2sort(int[] a, int p, int r) {
// 递归终止条件
if(p>=r) return;
// 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
int q = p+ (r-p)/2;
// 分治递归
merge2sort(a,p,q);
merge2sort(a,q+1,r);
// 将a[p...q]和a[q+1...r]合并为a[p...r]
merge(a,p,q,r);
}
// 合并逻辑
private void merge(int[] a, int p, int q, int r) {
int i = p;
int j = q+1;
int k = 0;//初始化变量i,j,k
int[] temp = new int[r-p+1];//申请一个大小跟a[p..r]大小一样的临时数组
while(i<=q && j<=r){
// 如果a[i]<=a[j] 就把a[i]放入临时数组temp,并且i后移一位,否则将a[j]放入数组temp,j后移一位
if(a[i]<=a[j]){
temp[k++] = a[i++];
}else{
temp[k++] = a[j++];
}
}
while (i<=q){
temp[k++] = nums[i++];
}
while (j<=r){
temp[k++] = nums[j++];
}
// 将temp中的数组拷贝回a[p...r]
for(i =0;i<=r-p;++i){
a[p+i] = temp[i];
}
}
public static void main(String[] args) {
int[] a = {1,3,5,7,9,2,6,4};
new MergeSort().mergerSort(a,a.length);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
}
3. 性能分析
1. 是否稳定排序算法
归并排序是否稳定关键看merge()函数,也就是两个有序字数组合并为一个有序数组的那部分代码
合并过程中,如果两个子数组存在值相同的元素,可以先把a[p…q]中的元素放入temp数组,保证值相同的元素,合并前后先后顺序不变,稳定排序
2. 归并排序的时间复杂度
涉及递归,如果问题a可以分解为多个子问题b、c,求解问题a就可以分解为求解b、c,解决后,再把b、c的结果合并成a的结果。
如果求解a的时间为T(a),求解b、c的时间为T(b) 、T©,T(a)=T(b)+T(c)+K
K为将两个子问题b、c的结果合并为a的结果消耗的时间。
不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
分析归并排序的时间复杂度:
假设对n个元素进行归并排序需要时间T(n),那么分解成两个子数组排序的时间都是T(n/2),merge()函数合并两个有序子数组的时间复杂度为O(n),套用前面的公式,时间复杂度计算公式为
T(1) = C; n=1时,只需要常量级的执行时间,表示为C
T(n) = 2*T(n/2) +n; n>1
如何求解T(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)=2k*T(n/2k)+kn。当T(n/2k)=T(1)时,也就是n/2k=1,得到k=log₂n,将k值代入公式,得到T(n)=Cn+nlog₂n。用大O标记法,T(n)等于O(nlogn),也就是归并排序的时间复杂度为O(nlogn)
因此,归并排序的执行效率与要排序的原始数组的有序程度无关,时间复杂度非常稳定,最好最坏和平均都是O(nlogn)
3. 归并排序的空间复杂度
归并排序的时间复杂度任何情况都是O(nlogn),但是致命弱点是:不是原地排序算法
因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间,那么空间复杂度如何求解?
合并完成后,临时开辟的内存空间被释放掉,任意时刻,CPU只有一个函数在执行,只有一个临时的内存空间在使用。空间复杂度为O(n)
7. 快速排序(quicksort)
1. 概念
快排,利用的也是分治思想。快排的核心思想是:如果要排序数组中下标从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,说明所有的数据都有序了。
2. 代码实现
递推公式
quick_sort(p...r)=quick_sort(p...q-1)+quick_sort(q+1,r)
终止条件
p>=r
将递推公式转化成递推代码,见下边代码实现。
归并排序有个merge()合并函数,快排则是partition()分区函数。该分区函数就是随机选择一个元素为pivot(一般情况,选择p到r区间的最后一个元素),然后对a[p…r]分区,函数返回pivot的下标。
如果希望快排是原地排序算法,空间复杂度为O(1),那么partition()分区函数不能占用太多的内存空间,需要在a[p…r]的原地完成分区操作。原地分区函数的实现见下边代码
处理和选择排序优点像。通过指针i将a[p…r-1]分为两部分。a[p…i-1]的元素都是小于pivot,叫“已处理区间”,a[i…r-1]是“未处理区间”。每次都从未处理区间a[i…r-1]中取出一个元素a[j],和pivot比较,如果小于pivot,加入到已处理区间的尾部,也就是a[i]的位置。
如何插入?采用交换,将a[i]和a[j]交换,实现O(1)的时间复杂度内将a[j]放到下标为i的位置。
public class QuickSort {
// 快速排序算法,a为数组,n为数组大小
public void quickSort(int[] a,int n){
quickSortInternally(a,0,n-1);
}
// 快排递归函数,p,r为下标
private void quickSortInternally(int[] a, int p, int r) {
if(p>=r) return;
int q = partition(a,p,r); // 获取分区点
quickSortInternally(a,p,q-1);
quickSortInternally(a,q+1,r);
}
private int partition(int[] a, int p, int r) {
int pivot = a[r];
int i = p;
for(int j=p;j<r;j++){
if(a[j]<pivot){
if(i==j){
i++;
}else{
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
}
int temp = a[i];
a[i] = a[r];
a[r] = temp;
return i;
}
public static void main(String[] args) {
int[] a = {1,3,5,7,9,2,6,4};
new QuickSort().quickSort(a,a.length);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
}
分区的过程涉及到交换的操作,如果数组中有两个相同的元素,如6,8,7,6,3,5,9,在第一次分区后,两个6的相对先后顺序会改变,所以快排不稳定
3. 快排和归并的区分
- 归并的处理过程是由下到上,先处理子问题,再合并;快排相反,处理过程由上到下,先分区,再处理子问题。
- 归并稳定,但不是原地排序算法;快排通过设计原地分区函数,可以实现原地排序,解决占用内存过多的问题。
4. 快排的性能分析
快排也是利用递归实现。如果每次分区,都能将数组正好氛围大小接近相等的两个小区间,时间复杂度和归并相同,是O(nlogn)
但实际情况很难实现,假设极端例子,数组中的数据原来已经有序,如1,3,5,6,8。如果每次选择最后一个元素作为pivot,每次分区得到两个区间不均等。需要进行大约n此分区完成快排。每次分区平均扫描n/2个元素,快排时间复杂度从O(nlogn)退化为O(n²)
结论:大部分情况下时间复杂度可以做到O(nlogn),极端情况才会退化到O(n²),而且也有很多方法将这个概率降到很低。
如何优化?
选取合适的分区点 ,最理想的分区点:被分区点分开的两个分区,数据的数量差不多。
- 三数取中法
- 随机法
8. 问题
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 …直到区间缩小为1
如果把每次分区遍历的元素个数加起来,就是n+n/2+n/4+…+1,等比数列求和2n-1,时间复杂度为O(n)
9. 桶排序(Bucket sort)
1. 概念
桶排序,用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是O(n)?
如果要排序的数据为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)。
2. 优缺点
桶排序对要排序的数据的要求:
- 要排序的数据要很容易就能划分为m个桶,并且,桶与桶之间天然有大小顺序,这样,每个桶内的数据都排序好之后,桶与桶之间的数据不需要再排序。
- 数据在各个桶之间的分布比较均匀。如果经过桶划分后,有些桶的数据非常多,有些非常少,很不均匀,桶内数据排序的时间复杂度就不是常量级了。极端情况下退化为O(nlogn)
桶排序比较适合用在外部排序。所谓外部排序,就是数据存储在外部磁盘,数据量较大,内存有限,无法将数据全部加载进内存。
案例:有10GB的订单数据,希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,没办法一次性加载10GB数据到内存,怎么办?
借助桶排序的处理思想解决
先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描后得到,订单金额最小1元,最大10万,将所有订单根据金额划分到100个桶,第一桶存储1-1000元以内订单,第二桶存储1001-2000元订单,以此类推。每个桶对应一个文件。并且按照金额范围的大小编号命名(00,01,02…99)
理想情况,如果订单金额在1到10万之间均匀分布,订单被均匀划分到100个文件。每个小文件存储100MB的订单数据。可以将这100个小文件依次放到内存,用快排排序。所有文件排序完成,只需要按照文件编号,从小到大依次读取每个小文件的订单数据,并将其写入到一个文件,这个文件中存储的就是按照金额从小到大排序的订单数据。
如果某个金额区间的数据特别多,划分后对应的文件会很大,没法一次读取,可以继续划分,如,订单金额在1到1000元之间的比较多,将这个区间继续划分为10个小区间,如果不够,继续划分。
10. 计数排序(Counting sort)
其实,计数排序是桶排序的特殊情况。当要排序的n个数据,所处的范围并不大时,如最大值是K,可以把数据划分成K个桶,每个桶内的数据值都是相同的,省掉了桶内排序的时间。
案例:高考查分,如果所在省有50万考生,如何通过成绩快速排序得出名次?
考生满分为900分,最小0分,数据范围很小,可以分为901个桶,对应分数从0分到900分。根据成绩,将50万考生划分到901个桶中,桶内的数据都是分数相同的考生,不需要再排序,只需要依次扫描每个桶,将桶内的考生依次输出到一个数组,实现50万个考生的排序,因为只涉及扫描遍历操作,时间复杂度为O(n)
为什么叫计数排序?计数的含义是如何来的?
需要看其实现原理。对模型简化,假设只有8个考生,分数0-5分之间,成绩放到数组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[6]存储的数据,存储小于等于分数k的考生个数。
数据准备后,从后往前依次扫描数组A,比如,当扫描到3时,从数组C中取出下标为3的值7,也就是说,到目前为止,包括该元素,分数小于等于3的考生有7个,也就是说3是数组R中的第7个元素(也就是R中下标为6的位置)。当3放入数组R中小于等于3的元素只剩下6个,对应的C[3]要减一,变为6。
以此类推,扫描到第2个分数为3的考生时,把它放到R中第6个元素的位置(也就是下标为5的位置)。当扫描完整个数组A后,R内的数据就是按照分数从小到大有序排列了。
代码如下
public class CountingSort {
// 计数排序,数组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];
}
}
// 申请一个计数数组c,下标大小[0,max]
int[] c = new int[max+1];
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];
}
}
public static void main(String[] args) {
int[] a = {2,5,3,0,2,3,0,3};
new CountingSort().countingSort(a,a.length);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
}
这样其实就是利用另外一个数组来计数,所以叫计数排序
计数排序只能用在数据范围不大的场景中,如果数据范围K比要排序的数据n大很多,就不适合计数排序。而且只能给非负整数排序。
11. 基数排序(Radix sort)
假设有10万个手机号码,希望将这10万个手机号码从小到大排序,有什么比较快速的排序方法?
手机号码有11位,范围太大,不适合桶排序、计数排序,针对这个排序问题,有没有时间复杂度为O(n)的算法?基数排序
这个问题有这样的规律:假设比较两个手机号码a,b的大小,如果前几位,a已经比b大了,其他位数就不用看了。
先按照最后一位来排序手机号码,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序,经过11次排序,手机号码就有序了。
简化为字符串排序,要确保稳定排序,根据每一位来排序,可以用桶排序或者计数排序,时间复杂度O(n),如果要排序的数据有K位,需要K次桶排序或者计数排序,总的时间复杂度为O(k*n),当k不大,基数排序时间复杂度近似于O(n)
有时候要排序的数据不是等长的,可以采用把所有的单词补齐到相同长度,位数不够的在后面补“0”,因为根据ASCII码,所有字母都大于“0”,不会影响原有排序,可以继续使用基数排序。
基数排序对要排序的数据有要求,需要可以分割出独立的“位”比较,且位之间有递进的关系,如果a的高位比b大,低位就不用比较。此外,每一位的数据范围不能太大,要可以用线性排序算法排序,否则,基数排序时间复杂度无法做到O(n)。
12. 排序优化
如何选择合适的排序算法
时间复杂度 | 是否稳定 | 是否原地排序 | |
---|---|---|---|
冒泡排序 | O(n²) | √ | √ |
插入排序 | O(n²) | √ | √ |
选择排序 | O(n²) | × | √ |
快速排序 | O(nlogn) | × | √ |
归并排序 | O(nlogn) | √ | × |
计数排序 | O(n+k)k是数据范围 | √ | × |
桶排序 | O(n) | √ | × |
基数排序 | O(dn) d是维度 | √ | × |
线性排序算法应用场景较为特殊
对小规模数据,可选择时间复杂度O(n²)的算法;对大规模排序,时间复杂度 O(nlogn),为了兼容,一般首选时间复杂度 O(nlogn)的算法。