关于十大排序的文章也有不少了,但感觉大部分在各个排序算法的适用场景、如何实现外排等细节方面没怎么讲,故总结了这篇文章,欢迎浏览
一、前言
内部排序是指排序时将待排序数据全部加载到内存的算法。
外部排序是指在处理海量数据排序时,无法将外部的数据(磁盘文件)一次全部加载进内存,可分批加载到内存进行排序,分次将结果写入到磁盘,最后在磁盘获得完整结果。如下归并、计数排序等排序算法就可以进行外部排序。比较排序是指排序时元素次序依赖于他们之间的比较。每个数都必须和其他数比较,才能确定自己的位置。如下1-7都是比较排序算法
非比较排序是指排序时元素次序不完全依赖于元素之间的直接比较。如下8-10都是非比较排序算法
下面算法的完整实现和测试案例已放到了我的码云仓库中,地址:https://gitee.com/syuchen/algorithm/tree/master/src/Algorithm/sort 可克隆下来直接运行调试
🏆二、十大排序算法详解
1. 选择排序
算法思想:遍历整个序列选出最小的元素与第一个元素交换位置,这样就确定了一个元素的位置,然后继续从剩余元素选出最小元素,以此类推,最后选出来的序列就是有序序列了
优化:在寻找最小元素时顺便检查数组是否已经按序排列,及时终止排序。寻找最小元素要从表尾对比到表头,寻找最大元素则从表头对比到表尾,若这个过程中每次对比当前最小(大)元素的位置都发生了改变,则说明数组已经有序
简单选择排序性能:时间复杂度平均O(n2),最好O(n2),最坏O(n2)。空间复杂度O(1)。不稳定
//简单选择排序
void selectSort(int[] A) {
int n = A.length;
for(int i = 0;i < n;i++) {
int min = i; //记录最小元素位置
for(int j = i+1;j < n;j++) {
if(A[j] < A[min])
min = j; //更新最小元素位置
}
if(min != i)
swap(A[i], A[min]); //交换
}
}
//优化选择排序
void selectSort2(int A[]) {
int n = A.length;
for(int i = 0;i < n;i++) {
int min = n-1; //记录最小元素位置
boolean sorted = true;
for(int j = n-2;j >= i;j--) {
//这里如果相等也要更新位置,便于数组包含重复元素但已有序时及时终止排序
if(A[j] <= A[min]) {
min = j; //更新最小元素位置
} else {
sorted = false;
}
}
//若从尾至头每一次比较都更新了最小元素的位置(即sorted没有被赋值false)
//则数组已经有序
if (sorted) return;
if(min != i)
swap(A[i], A[min]); //交换
}
}
2. 插入排序
算法思想:从第二个元素开始从前往后扫描,每扫描到一个元素,就将其插入到前面的序列使其构成有序序列,扫描完最后一个元素则整个序列有序
优化:插入时采用折半查找寻找插入位置,比较关键字的次数减少了,但是移动元素的次数没变,所以时间复杂度依然是O(n^2)
性能:时间复杂度平均O(n2),最好O(n),最坏O(n2)。空间复杂度O(1)。稳定
适用场景:适用于数据量不大,对稳定性有要求的情况,尤其当数据局部或者整体有序时,采用插入排序可以明显减少数据移动次数,进而提升排序效率。(例如在jdk Arrays.sort()源码中当数组长度小于47时会优先使用插排)
//直接插入排序
void insertSort(int[] A) {
for(int i = 1;i < A.length;i++) {
//最好的情况:数组严格递增,则不会进入到if代码块中,时间复杂度O(n)
if(A[i] < A[i-1]) {
int temp = A[i];
//所有大于temp的元素向后挪位
int j = i-1;
for(;j >= 0 && A[j] > temp;j--) {
A[j + 1] = A[j];
}
A[j + 1] = temp; //将A[i]复制到插入位置
}
}
}
//优化插入排序
void insertSort2(int[] A) {
int i, j, low, high, mid;
for (i = 1;i < A.length;i++) {
low = 0;high = i-1;
while(low <= high) {
mid = (low + high)/2; //向下取整
if(A[mid] > A[i]) {
high = mid - 1; //查找左半子表
} else
low = mid + 1; //查找右半子表
}
int temp = A[i];
for(j = i-1;j >= low;j--)
A[j+1] = A[j];
A[low] = temp;
}
}
3. 冒泡排序
算法思想:从前往后依次对比两个相邻元素,若反序,则交换两个元素的位置,完成这样一趟冒泡后,此趟交换中最大的元素就“冒”到了表尾,这样就确定了一个元素的位置,然后继续对剩余的元素执行冒泡,直到确定所有元素位置。
优化:若在一趟冒泡中没有发生交换,则说明数组已经有序,可提前终止排序
性能:时间复杂度平均O(n2),最好O(n),最坏O(n2)。空间复杂度O(1)。稳定
void bubbleSort(int[] A) {
boolean flag = true;
for (int i=0;flag && i < A.length-1;i++) {
flag = false;//标记在一趟冒泡中是否发生过交换
for (int j = 0;j < A.length-1-i;j++) {
if (A[j] > A[j+1]) {
swap(A[j], A[j+1]);
flag = true;
}
}
}
}
简评:在确定一个元素的位置时,选择排序是选出元素后直接与对应位置的元素交换,改变了剩余元素的相对次序,故选择排序是不稳定的,而插入排序和冒泡排序是整体向后挪动空出位置,因此不会改变剩余元素的相对次序,故插入排序和冒泡排序是稳定的。
上述三种算法时间复杂度都是O(n2)级别的,实现起来比较简单,在数据量很小对性能没要求的场景可以用下,在实际中很少直接用到,更多的是作为算法学习以及为后续的算法抛砖引玉之用,数据量较大时的高性能算法还得看下面的
✨4. 希尔排序
算法思想:设置一个初始距离,称为希尔增量(最原始的即设为数组长度的一半),将相同距离的元素分为一组,然后对各个分组进行插入排序,然后将增量减半,重复上述过程,直到增量为1,即分组只有一组,此时数组已经大致有序了,再进行插入排序,得有序数组。
优化:将各个分组的插入排序混合同时进行,使数组访问更紧凑,故速度更快(局部性原理)。如写法2所示
性能:希尔排序的时间复杂度为O(n1.3)~O(n2),与数组的规模和增量的选取有关。例如,当增量为1时,希尔排序退化成了直接插入排序,此时的时间复杂度为O(n2),而Hibbard增量的希尔排序的时间复杂度为O(n3/2)。空间复杂度O(1)。不稳定
适用场景:数据规模中等,数据大体有序的场景
如下代码看起来好像时间复杂度不低,但实际上相比上面三种方法大量减少了移动元素次数,可以手动实验看一下
//写法1 易理解
void shellSort(int[] A) {
int length = A.length;
//d为希尔排序的增量
for (int d = length/2;d >= 1;d = d/2) {
//对每个分组进行直接插入排序
for (int i=0;i < d;i++) {
for(int j=i+d;j < length;j=j+d) {
int temp = A[j];
while (j-d>=0 && A[j-d] > temp) {
A[j] = A[j-d];
j = j-d;
}
A[j] = temp;
}
}
}
}
//写法2 更快
void shellSort2(int[] A) {
int length = A.length;
//d为希尔排序的增量
for (int d = length/2;d >= 1;d = d/2) {
//在每层循环中使第i%d+1个分组的前i/d + 1个元素有序,即每次对一个不同的分组进行一次插排
for(int i=d;i<length;i++) {
int temp=A[i];
int j=i-d;
while(j>=0 && A[j]>temp)
{
A[j+d]=A[j];
j=j-d;
}
A[j+d] = temp;
}
}
}
简评:为什么希尔能突破O(n2)的界,可以用逆序数来理解,假设我们要从小到大排序,一个数组中取两个元素如果前面比后面大,则为一个逆序,容易看出排序的本质就是消除逆序数,可以证明对于随机数组,逆序数是O(n2)的,而如果采用“交换相邻元素”的办法来消除逆序,每次正好只消除一个,因此必须执行O(n2)的交换次数,这就是为什么冒泡、插入等算法只能到平方级别的原因,反过来,基于交换元素的排序要想突破这个下界,必须执行一些比较,交换相隔比较远的元素,使得一次交换能消除多个逆序,希尔、快排、堆排等等算法都是交换比较远的元素,只不过规则各不同罢了。
最原始的那种增量,即从length逐步减半,其实还不算最快的希尔,有几个增量在实践中表现更出色,具体可以看weiss的数据结构书,同时里面有希尔排序复杂度的证明,但是涉及组合数学和数论,希尔排序是实现简单但是分析极其困难的一个算法的例子
参考: https://www.zhihu.com/question/24637339/answer/84079774
✨5. 堆排序
算法思想:将要排序的n个元素初始化为一个大(小)根堆,逐次从堆中提取堆顶元素,每次提取后将堆底元素放到堆顶,再重构剩下的元素为大(小)根堆,提取出的序列即为有序序列。
大(小)根堆即为树中每个节点都大于等于(小于等于)其子节点的完全二叉树 (概念不清楚可参考: 文章)
完全二叉树的性质:将下标从0开始长度为n的数组视为一棵完全二叉树,则下标 n/2 -1 的元素为树的最后一个分支节点,节点i若存在左孩子,则其下标为2i+1,若存在右孩子,则其下标为2i+2
性能:初始化大根堆时间复杂度为O(n),之后每次调整大根堆只需要将堆顶元素向下“冒泡”,时间复杂度为O(logn),共需调整n次。故时间复杂度平均O(nlogn),最好O(nlogn),最坏O(nlogn) 参考证明。空间复杂度O(1)。不稳定
适用场景:Top-K问题
void heapSort(int[] A) {
int length = A.length;
//将数组初始化为大根堆
buildMaxHeap(A, length);
while (length > 1) {
//将堆顶元素即堆的最大值与最后一个元素交换,然后将剩余元素调整为大根堆
swap(A[0], A[length-1]);
maxHeadAdjust(A, 0, --length);
}
}
/**
* 构建大根堆:
* ①首先将长为length的数组视作一棵完全二叉树
* ②从最后一个分支结点(下标为n/2 - 1 向下取整)开始检查,如果以该元素为根的子树是大根堆,则不操作,进行下一步,否则将此子树调整为大根堆
* ③继续逐个检查下标为i-1,i-2等分支结点为根的子树,进行第二步的判断和调整,直到检查到根节点i=0为止
*/
void buildMaxHeap(int[] A, int length) {
for (int i = length/2 - 1;i >= 0;i--) {
headAdjust(A, i, length);
}
}
//此时i节点的子树必定已为调整好的堆,故只需将i节点往下“冒泡”,直到找到一个合适的位置
void headAdjust(int[] A, int i, int length) {
int temp = A[i];
//A[2*i+1]即为A[i]的左孩子
for (int j = 2*i+1;j < length;j=2*i+1) {
//取当前结点的孩子结点中较大的结点
if (j+1 < length && A[j] < A[j+1]) {
j++;
}
//如果当前节点小于较大孩子节点,则交换。否则temp已到达合适位置 退出循环
if (temp < A[j]) {
A[i] = A[j];
i=j;
} else {
break;
}
}
A[i] = temp;
}
如何外排:在对大型的磁盘文件内的数据进行排序时,内存可能不够将数据一次全部加载到内存进行排序,此时可进行外部排序。堆排序的外部排序步骤如下:
- 根据内存大小将磁盘文件划为k份,逐份加载进内存进行内部排序(自选合适的内排算法)然后写回磁盘,这样就在磁盘中得到了k份有序的数据。
- 根据内存大小从每份文件中从头读取一个文件块到内存,共k个文件块,每块文件都是一个由小到大排列的有序队列。
- 读入每块文件的队列头,建立一个最小堆
- 弹出堆顶元素,如果元素来自第i块,则从第i个文件块中补充一个元素到堆顶,再调整为最小堆。如果文件块i读取完毕,则从第i份文件中再读取一个文件块到内存补充。弹出的元素暂存至临时数组(显然每次弹出的元素都是整个文件中剩余元素中的最小元素)。
- 当临时数组存满时,将数组按顺序写至磁盘的结果文件的尾部,再清空数组内容。
- 重复过程(4)、(5),直至所有文件读取完毕,此时磁盘上的结果文件内就是最终的有序序列。
这其实也是多路归并排序,只不过在3-4步中使用了堆排序,而在实际中我们使用多路归并排序做外排时在3-4步通常会使用另一种更优秀的方法,在后面讲归并排序时会继续讲。
参考文章:海量数据排序:外部排序、位图排序、基数排序、桶排序
简评:堆排序最适用于在较多记录数中取排序靠前的部分记录的场景(Top-K问题),例如从一亿个记录中选取100个最大值,首先使用一个大小为100的数组,读入前100个数建立小根堆, 然后依次读入剩下的数,若小于堆顶则舍弃,否则用该数取代堆顶并重新调整根堆,待数据全部读取完毕,堆中100个数即为所求。实现如下,总的时间复杂度O(nlogK),如果采用插入排序时间复杂度就是O(n*K)了。显然这种情况下不需要一次将所有数据全部读取内存,可以进行外部排序。
/**
* 使用堆排序得出nums数组的前n个最大值
*/
static int[] getMaxNums(int[] nums, int n) {
int heapLength = Math.min(nums.length, n);
int[] heapNums = Arrays.copyOf(nums, heapLength);
buildMinHeap(heapNums, heapLength);
for (int i=heapLength;i < nums.length;i++) {
if (nums[i] > heapNums[0]) {
heapNums[0] = nums[i];
minHeadAdjust(heapNums, 0, heapLength);
}
}
return heapNums;
}
/**
* 构建最小堆
*/
static void buildMinHeap(int[] A, int length) {
for (int i = length/2 - 1;i >= 0;i--) {
minHeadAdjust(A, i, length);
}
}
//此时i节点的子树必定已为调整好的堆,故只需将i节点往下“冒泡”,直到找到一个合适的位置
static void minHeadAdjust(int[] A, int i, int length) {
int temp = A[i];
//A[2*i+1]即为A[i]的左孩子
for (int j = 2*i+1;j < length;j=2*i+1) {
//取当前结点的孩子结点中较小的结点
if (j+1 < length && A[j] > A[j+1]) {
j++;
}
//如果大于较小孩子节点,则交换。否则temp已到达合适位置 退出循环
if (temp > A[j]) {
A[i] = A[j];
i=j;
} else {
break;
}
}
A[i] = temp;
}
✨6. 快速排序
算法思想:在一趟快速排序,选择一个元素作为基准将待排序记录分割成两部分,左半部分元素均小于等于基准元素,右半部分元素均大于等于基准元素,这样就确定了这个基准元素在序列中的位置,再分别对这两部分元素进行快速排序,这样递归执行下去直到无法再分割。每一趟快排都能确定其中一个元素的位置。
时间复杂度:快排的运行时间与划分是否对称有关,平均O(nlogn),最好O(nlogn),最坏情况是每次都划分出n-1个元素和0个元素,即对应初始排序表基本有序或基本逆序时,时间复杂度为O(n2)。
空间复杂度:使用了递归工作栈,其容量与递归调用的最大深度一致,空间复杂度平均情况O(logn),最好情况O(logn),最坏情况进行n-1次递归调用,所以栈的深度为O(n)。
稳定性:在划分中,若右端有两个关键字相同且均小于基准,则它们被放到左端后相对位置会发生变化,因此快速排序算法不稳定
适用场景:数据量大,数据较为无序的场景
public class QuickSort {
public static void main(String[] args) {
int[] A = new int[]{9, 8,7,6,5,4};
quickSort(A, 0, A.length - 1);
System.out.println(Arrays.toString(A));
}
static void quickSort(int[] A, int low, int high) {
if(low<high) {
int pivotPos=partition(A,low,high);//划分
quickSort(A,low,pivotPos-1);//依次对两个子表进行递归
quickSort(A,pivotPos+1,high);
}
}
//一趟划分 初始时将low指向的元素pivot设为基准元素 最后返回基准元素最终存放位置
//low指针与high指针交替扫描向中间移动,当high指针扫到比pivot小的元素时,将其移动到low指针指向的空位置,轮到low指针向右扫描
//low指针扫到比pivot大的元素时,将其移动high指针指向的空位置,又轮到high指针向左扫描。直至两指针相遇
static int partition(int[] A, int low, int high) {
int pivot = A[low];//设置基准元素
//初始low指向的视为空位置
while(low < high) {
while(low<high && A[high]>=pivot)
--high;
A[low]=A[high];//high指针向左扫描,扫描到比基准小的元素移到左端low指向的空位置,移动后high指向的视为空位置
while(low<high && A[low]<=pivot)
++low;
A[high]=A[low];//low指针向右扫描,扫描到比基准大的元素移到右端high指向的空位置,移动后low指向的视为空位置
}
A[low]=pivot;//无论是low遇到high 还是high遇到low,相遇位置一定是一个空位,即基准元素最终存放位置
return low;//返回存放基准元素的位置
}
}
简评:快排可以说是最常用的排序算法,代码简洁易实现,且其综合性能在内部排序算法中十分优异,与堆排序的跳跃式数组访问方式相比,快排的局部顺序访问显然对cpu缓存更加友好,另外堆排序在每次建堆的过程中会打乱数据原有的相对先后顺序,导致数据的有序度降低,比如对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。而快速排序数据交换的次数不会比逆序度多,因此快排相比堆排序交换数据的次数更少。实际场合中,快排对于数据量大,数据分布随机的平均效率高于堆排序,但如果数据大部分有序发现快排明显退化的时候会切换到堆排,快排递归到比较小的数据量的时候为了节约递归产生的空间,也会切换成插入排序。
参考:堆排序与快速排序比较
✨7. 归并排序
算法思想(二路归并):
①初始时,将每个记录看成一个单独的有序序列,则n个待排序记录就是n个长度为 1的有序子序列
②对所有相邻的有序子序列进行两两合并。在第一趟归并中会得到n/2个长度为2或1 的有序子序列
③重复②,直到得到长度为n的有序序列为止
性能:每趟归并的时间复杂度为O(n),共需趟归并logn趟,所以时间复杂度最好最坏都为O(nlogn)。递归栈深度为logn,数组排序时需要长度为n的辅助数组,故总的空间复杂度为O(n)。稳定
适用场景:链表排序,外部排序(多路归并)
//对数组进行二路归并排序
public class MergeSort {
//辅助数组
private static int[] B;
public static void main(String[] args) {
int[] A = new int[]{5,6,1,2,3,6,9};
B = new int[A.length];
mergeSort(A, 0, A.length-1);
System.out.println(Arrays.toString(A));
}
//利用递归实现
static void mergeSort(int[] A, int low, int high) {
if(low<high) {
int mid=(low+high)/2;//从中间划分两个子序列
mergeSort(A,low,mid);//对左侧子序列进行递归排序
mergeSort(A,mid+1,high);//对右侧子序列进行递归排序
merge(A,low,mid,high);//归并
}
}
//merge函数的功能是将相邻的两段有序子序列A[low,mid]和A[mid+1,high]归并为一个有序序列
static void merge(int[] A, int low, int mid, int high) {
//将A中两个有序子序表元素复制到B的对应位置中
for (int i = low; i <= high; i++) {
B[i] = A[i];
}
int i,j,k;
for(i=low, j=mid+1, k=i;i<=mid&&j<=high;k++) {
//比较两个子序表中的元素,将较小值复制到A中
//相等时左边的优先复制,可确保稳定性
if(B[i]<=B[j])
A[k] = B[i++];
else
A[k] = B[j++];
}
//未检测完的表直接复制到有序表的后面
while(i<=mid) A[k++]=B[i++];
while(j<=high) A[k++]=B[j++];
}
}
多路归并排序:上述为二路归并排序,即每次归并都是将两个有序序列归并为一个有序序列。多路归并排序就是每次将多个有序序列归并为一个有序序列,归并方式有堆、胜者树和败者树三种。
如何外排:用多路归并排序做外排的思想前面已经讲过了,就是将文件划分为k份进行内部排序写入到磁盘,从这k个文件中加载k个已有序的文件块到内存,然后对这个k有序序列进行k路归并,归并过程中临时结果数组满了就写到磁盘,某个序列读取完了就再从对应的文件中再读取一块进来,直到所有文件读取完毕。关于如何做归并,前面采用的是构建堆的方法,但在堆调整中,新来的元素向下寻找位置时,在每一层中都需要和左右孩子分别进行比较,即需要两次比较,因此一次堆调整需要的比较次数大约为2(logk - 1)(k为归并的路数即堆中的元素数),那能不能再优化下呢?
于是就有了胜者树。胜者树就和晋级赛的结构一样,首先所有选手(每个序列的队列头)都处于叶子节点,不断两两比较向上晋级,每个非终端节点都是其两孩子节点中胜出者(只需要记录其来自的队列标识即可),最后根节点就是胜者。将胜者取出然后从其来自的序列中再取一个元素参与对决,而此时在每一层中只需要和兄弟节点比较就行了,因此总比较次数为logk(胜者树k个元素都在叶子节点故高度比堆高1)。
补充新元素后在调整胜者树时,每次两两比较后都要更新其父节点为新的优胜者(原存储值必定是已输出过的元素)。那能不能再优化呢?
于是就有了败者树。败者树中非终端节点存储的是比较中的败者,而胜者则继续向上参与比较,如下图,ls[1]中存储的是最后一次比较中的败者,ls[0]中则是最终的胜者。将胜者取出然后从其来自的序列中再取一个元素参与对决,那么此时就不需要和兄弟节点比较,而是和父节点进行比较了 ,若胜,则无需更新父节点,而是作为胜者直接继续进行上一层的比较,总比较次数也为logk。
总体来说,堆调整时是自上而下调整,每下一层都要和两个孩子节点进行比较,但不一定要下到最后一层。胜者树和败者树在补充值后是自底向上调整,每上升一层都需要一次比较,胜者树是和兄弟的一次比较,败者树是和父节点的一次比较,在比较的内存访问次数上二者没有太大的差别。不同的是胜者树每次必然需要更新胜者(因为这条路径就是以原来的最终胜者为外部节点的路径,而原来的最终胜者已经被输出了),但败者树每次不一定需要更新,这就代表它在每次上升时可能会少一次向内存的写入,减少了访存的时间,而现在程序的主要瓶颈在于访存了,计算倒几乎可以忽略不计了。因此更优。故在多路归并的外排算法中,通常会采用败者树做序列归并。
参考:
多路归并排序的时候,为什么要采用败者树?
多路平衡归并排序(胜者树、败者树)算法详解
多路归并排序
大数据中的归并排序(Merge Sort)
简评:内部排序中,在进行数组的排序时,快排不需要额外辅助空间,故性能优于归并排序,而在进行链表的排序时,归并排序不需要数组作为辅助空间,并且归并排序能够更均匀的划分列表,且归并排序具有稳定性,故此时总体优于快排。
参考文章:
快速排序和归并排序
快速排序、归并排序、堆排序的理解及各自应用场景
🔥8. 计数排序
算法思想:扫描统计出待排序数组中每个元素出现的次数,存储在辅助数组对应下标位置(下标+固定偏置=元素值)中,最后扫描这个辅助数组,从每个位置取出对应次数的元素,即构成有序序列。
性能:时间复杂度为O(n+k),k为辅助数组的长度,即待排序数组最大元素值减去最小元素值再加1。空间复杂度为O(k)。可稳定,关于稳定性的探讨可参考 计数排序和稳定的计数排序
适用场景:待排序数据为整数数组且数据范围较小的场景,外排
static void countSort(int[] A) {
int min=A[0], max=A[0];
//寻找最小元素和最大元素
for (int i=1;i < A.length;i++) {
if (min > A[i])
min = A[i];
if (max < A[i])
max = A[i];
}
int[] B = new int[max-min+1]; //辅助数组
for (int i=0;i < A.length;i++) {
int index = A[i] - min; //元素A[i]在统计数组中对应的下标
B[index] = B[index] + 1; //元素A[i]出现次数增1
}
//扫描辅助数组
for (int k=0,i=0;k < B.length;k++) {
for (int j=1;j <= B[k];j++) {
A[i++] = k+min;
}
}
}
如何外排:先逐批读入数据求出最小最大值,然后在内存中设立一个辅助数组,再逐批读入数据求每个元素出现的次数,最后再扫描辅助数组将结果写入磁盘。
简评:当待排序数据的数据范围较小时,计数排序速度非常之快,但对于数据范围很大时,就需要大量时间和内存了。
🔥9. 桶排序
算法思想:根据待排序的数据范围划分出若干个区间(即桶),遍历数组将每个元素放到对应区间的桶中,然后对每个桶进行内部排序,最后将各个桶中的元素依次取出,即构成有序序列
时间复杂度:时间复杂度为遍历数组的复杂度和所有桶内部排序复杂度的总和O(n+nlog(n/m)),m为桶的个数,平均记为O(n+c)。当桶的个数与元素个数接近时且元素均匀分布在各个桶时,有最好时间复杂度O(n),当n-1个元素在一个桶 另一个元素在另一个桶时,时间复杂度向比较排序退化 比如快排O(nlogn)
空间复杂度:额外需要m个桶,存储n个数据,故空间复杂度为O(n+m)
稳定性:桶排序是否稳定取决于桶内排序使用的算法,如果基于快排实现就不稳定,如果是基于归并排序实现的则稳定。
适用场景:数据分布较为均匀的场景,外部排序
public class BucketSort {
public static void main(String[] args) {
int[] A = new int[]{5,6,1,2,3,6,9};
bucketSort(A, 3);
System.out.println(Arrays.toString(A));
}
static void bucketSort(int[] A, int bucketSize) {
int min = A[0], max = A[0];
//寻找最小元素和最大元素
for (int i = 1; i < A.length; i++) {
if (min > A[i])
min = A[i];
if (max < A[i])
max = A[i];
}
//根据数据范围和桶的大小确定桶的数量
int bucketCount = (max-min)/bucketSize + 1;
ArrayList[] buckets = new ArrayList[bucketCount];
for (int i=0;i < bucketCount;i++) {
buckets[i] = new ArrayList<Integer>();
}
//扫描数组将元素放到对应的桶中
for (int i=0;i < A.length;i++) {
//A[i]所属桶的编号
int k = (A[i]-min)/bucketSize;
buckets[k].add(A[i]);
}
//对每个桶进行内部排序然后依次取出,构成有序序列
for (int k=0, i=0;k < bucketCount;k++) {
buckets[k].sort(null);
for (int j=0;j < buckets[k].size();j++) {
A[i++] = (Integer)buckets[k].get(j);
}
}
}
}
如何外排:先计算出待排序文件的数据范围,划分为n个范围,根据内存大小在内存中对应准备n个限容的桶,在磁盘中准备n个对应的空文件,然后依次读取待排序文件数据到对应的桶中,桶满后就写入到对应的桶文件中,读取完毕后待排序文件就被划分到了n个桶文件中,再对这n个文件分别加载到内存中进行内部排序,最后将结果依次写入到结果文件中。可能有人会想要是数据划分不均匀导致一个桶文件中的数据还是超过了内存大小怎么办?那就继续针对这个桶文件划分子范围进行桶排序,直到子文件大小能够读入内存为止。当然最好还是事先能够观察出数据的规律划分出合适的桶。
简评:计数排序不适用于数据跨度很大或者浮点数的情况,桶排序是对计数排序的改进,计数排序申请的额外空间跨度从最小元素值到最大元素值,若待排序集合中元素不是依次递增的,则必然有空间浪费,而桶排序使用链表弱化了这种浪费情况。桶排序的关键点在于如何划分桶,也就是元素到桶的映射规则。映射规则应根据待排序集合的元素分布特性进行选择,若规则设计的过于模糊、宽泛,则可能导致待排序集合中所有元素全部映射到一个桶上,则桶排序会向比较排序算法演变。若映射规则设计的过于具体、严苛,则可能导致待排序集合中每一个元素值映射到一个桶上,则桶排序向计数排序演化。
参考文章:再谈桶排排序算法—从计数排序到桶排序
🔥10. 基数排序
算法思想:先获取数组中最大值的位数,然后从个位开始按每个数的个位数排序调整数组,使数组只看每个数的最后一位时是有序的,再按每个数的十位数排序调整数组,使数组只看每个数的最后两位时是有序的,一直到最大位数,此时数组就是有序的了。
性能:时间复杂度为O(n * d),d为待排序数组中最大值的位数。空间复杂度为O(n+m),m为桶的数量。稳定
适用场景:一般来说在数据量较为巨大而数据的最大值位数不大的场景表现会比较优秀,可以进行外排
public class RadixSort {
public static void main(String[] args) {
int[] A = new int[]{56,622,1,21,3333,69,9};
//注意,radixSort()并没有修改上面A的引用指向的数组,如果直接在这里打印A则还是原来的数组
System.out.println(Arrays.toString(radixSort(A)));
}
static int[] radixSort(int[] A) {
int max=A[0];
for (int i=1;i < A.length;i++) {
if (max < A[i])
max = A[i];
}
//求最大值的位数
int temp = max, maxDigit=1;
while (temp%10 != temp) {
temp = temp/10;
maxDigit++;
}
//初始化0~9号共10个桶
ArrayList<ArrayList<Integer>> bucketList = new ArrayList<>(10);
for (int i=0;i < 10;i++)
bucketList.add(new ArrayList<>());
//从个位到最高位依次进行排序
for(int i=1;i <= maxDigit;i++) {
//根据每个数的i位数进行桶排序
for (int j=0;j < A.length;j++) {
//第i位数即数除以10^i的余数 再除以10^(i-1)得到的商
int iNum = (A[j]%(int)Math.pow(10, i))/(int)Math.pow(10, i-1);
//i位数值为多少就放到几号桶中
bucketList.get(iNum).add(A[j]);
}
//将桶中的数依次取出
A = bucketList.stream().flatMap(ArrayList::stream).mapToInt(Integer::valueOf).toArray();
bucketList.forEach(ArrayList::clear);
}
return A;
}
}
如何外排:思想和上面的代码思想是一样的,只不过外排时基数桶是在磁盘上。若最大值的位数较大,导致磁盘IO次数过多,效率就很低了,因此不常用。
简评:基数排序也是一种非常优秀的排序算法,但是它没那么容易实现简洁通用的接口,特别是对于复杂的结构体元素来说,还会需要更大的额外空间,因此基数排序远没有快排那么流行。但是我们在遇到适合它的特定场景还是可以使用的
参考(见仁见智,争论问题更能加深理解):
【漫画】为什么说O(n)复杂度的基数排序没有快速排序快?
为什么时效上具有明显优势的基数排序(radix sort)没有快速排序流行?
另外,上述这这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 计数排序: 每个桶只存储单一键值
- 桶排序: 每个桶存储一定范围的数值
- 基数排序: 根据键值的每位数字来分配桶
这种桶的思想还是非常值得注意的,多多体会一下