不定期补充、修正、更新;欢迎大家讨论和指正
本文以数据结构(C语言版)第三版 李云清 杨庆红编著为主要参考资料,用Java来实现
数据结构与算法Java(一)——线性表
数据结构与算法Java(二)——字符串、矩阵压缩、递归
数据结构与算法Java(四)——检索算法
数据结构与算法Java(五)——图
数据结构与算法Java(六)——排序算法
排序是数据处理过程中经常使用的一个重要的运算,它往往是一个系统的核心部分。排序算法的优劣对于一个系统是至关重要的。
假设一个文件由n个记录组成,所谓排序就是以记录中某个(或几个)字段值递增或递减的次序将这n个记录重新排序,则这个字段为排序码。能唯一标识一个记录的字段称为关键码。关键码可以作为排序码,但排序码不一定是关键码。
对于排序算法的优劣,主要关心的是该算法是否稳定和复杂度。在待排序文件中,当存在排序码相同的记录,若经过排序后,这些记录仍保持原来相对的次序不变,则称这个算法是稳定的。例如
5 2 7 1 5 9 8
假如按照升序排序,如果排完序后第一个5的下标仍然小于第二个5,即排序在第二个5前,那么这个排序算法的就是稳定的,反之亦然。
对于算法的复杂度,首要关心的是算法的时间复杂度,这主要是用执行过程中的比较次数和移动次数来度量。
常用的排序算法总的可以分为几大类:交换排序、插入排序、选择排序、归并排序、基数排序、计数排序。
本文实现的排序均为升序排序
交换排序的思路是对待排序的记录两两进行排序码的比较,若不满足排序顺序,则交换这对记录,直到任何两个记录的排序码都满足排序要求为止。这里介绍两种交换排序:冒泡和快速排序。
类结构
为了方便,数组的内容也直接在静态代码块初始化了,方法也要static修饰方便调用
public class Sort {
private static int[] list = new int[20];
private static int size = 0;
static {
list[0] = 3;
list[1] = 44;
list[2] = 38;
list[3] = 5;
list[4] = 47;
list[5] = 15;
list[6] = 36;
list[7] = 26;
list[8] = 27;
list[9] = 2;
list[10] = 46;
list[11] = 4;
list[12] = 19;
list[13] = 50;
list[14] = 18;
size = 15;
}
}
冒泡排序(Bubble Sort)是大多数人学习的第一种排序算法,其具体做法是:第一趟,对所有记录从左到右每两两相邻的排序码做比较,如果不符合排序要求,则进行交换,这样一趟做完,排序码最大或最小的将会放在最后一个位置。第二趟,将剩下的n-1个待排记录重复上述过程,又将一个记录放在最终位置,即倒数第二个位置。这样反复进行n-1次,将n-1个记录排序好(最后一个记录可以不用排序,最终都会在首位)
public static void bubble(){
int tmp;
for (int i=size-1; i>0; i--){
for (int j=0; j<i; j++){
if(list[j]>list[j+1]){
tmp = list[j];
list[j] = list[j+1];
list[j+1] = tmp;
}
}
}
}
或者是这种形式
public static void bubble(){
int tmp;
for (int i=1; i<size; i++)
for (int j=0; j<size-i; j++){
if(list[j]>list[j+1]){
tmp = list[j];
list[j] = list[j+1];
list[j+1] = tmp;
}
}
}
有时记录中是比较有序,甚至本来就是完全按照排序规则排好序的,这时就完全没必要继续进行排序了,我们可以加个标识,如果不用排序就可以退出了
public static void bubble(){
int tmp;
boolean flag;
for (int i=size-1; i>0; i--){
flag = false;
for (int j=0; j<i; j++){
if(list[j]>list[j+1]){
tmp = list[j];
list[j] = list[j+1];
list[j+1] = tmp;
flag = true;
}
}
if(!flag)//如果此次排序中,flag没有变过,说明这些待排序的记录已经有序,没有必要继续排序下去了
return;
}
}
冒泡排序是一种稳定的算法,最好情况下,即所有元素都排好序,时间复杂度为O(n)(前提是要有flag标识避免进行无用的排序);最坏情况下为O(n²);平均情况下为O(n²)。
快速排序(Quick Sort)是对冒泡排序的一种改进,由 C.A.R.Hoare(Charles Antony Richard Hoare)在 1962 年提出。
其思路是从n个待排序的记录中任取一个记录(一般取两端或者中间)作为基准,接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边。这时,左右两个分区的元素就相对有序了;接着把两个分区的元素分别按照上面两种方法继续对每个分区找出基准数,然后移动,直到各个分区只有一个数时为止。
以 47、29、71、99、78、19、24、47 的待排序的数列为例进行排序,为了方便区分两个 47,对后面的 47 增加一个下画线.
随后选择首位数字47作为基准,将比47小的数字移到左边,比47大的数字移到右边,最终47会存放再它排好序后的位置,此时它前面的数字都比它小,后面的数字都比它大。
随后就可以进行移动操作了:首先从数列的右边开始往左边找(视基准位置而定,现在选的是左端,所以肯定要从右边开始找),我们设这个下标为 i,也就是进行减减操作(i–),找到第 1 个比基准数小的值,让它与基准值交换;接着从左边开始往右边找,设这个下标为 j,然后执行加加操作(j++),找到第 1 个比基准数大的值,让它与基准值交换;然后继续寻找,直到 i 与 j 相遇时结束,最后基准值所在的位置即 k 的位置,也就是说 k 左边的值均比 k 上的值小,而 k 右边的值都比 k 上的值大。
所以对于上面的数列 47、29、71、99、78、19、24、47,进行第 1 趟第 1 个交换的排序情况如下,第 1 次的操作情况如图 1 所示。
交换之后,i 移动到了下标为 6 的位置,对 j 继续扫描,如图 2 所示。
此时交换后的数列变为 24、29、47、99、78、19、71、47。接下来我们继续对 i、j 进行操作,如图 3 所示,继续进行 i-- 及 j++ 的比较操作。
进行了这两次 i、j 的移动、比较、交换之后,我们最终得到的数列是 24、29、19、47、78、99、71、47。接下来我们继续进行 i-- 的操作,发现在 i 为 4 时比 47 大不用交换,在 i 为 3 时与 j 相遇,这时就不需要继续移动、比较了,已经找到 k 了,并且 k 的值为 3。我们可以确认一下当前的数列是不是 k 左边的值都比 47 小,而 k 右边的值都比 47 大(由于要保持相对位置不变,所以 47 同样在基准值 47 的右边)。
47 这个值已经落到了它该在的位置,第 1 趟排序完成了。接下来就是以 k 为基准,分为两部分,然后在左右两部分分别执行上述排序操作,最后数据会分为 4 部分;接着对每部分进行操作,直到每部分都只有一个值为止。
原文:快速排序算法详解(原理、实现和时间复杂度)
public static void quickSort(int left, int right){
int tmp = list[left];//按照个人喜好,这里总以左端作为基准
int i = right;
int j = left;
while (i!=j){
while (tmp>list[i])//从右边寻找比基准小的记录
i--;
if(j<i){
list[j] = list[i];//将找到的记录置于左边
j++;
}
while (tmp<list[j])//从左边寻找比基准大的记录
j++;
if(j<i){
list[i] = list[j];//将找到的记录置于右边
i--;
}
}
list[i] = tmp;//将基准放到最终位置
quickSort(i+1,right);//进行后半部分快速排序
quickSort(left,i-1);//进行前半部分快速排序
}
插入排序(Insertion Sort)的基本思路是:将待排序文件中的记录,逐个地按排序码值得大小插入到目前已经排好序的若干个记录组成的文件中的适当位置,并保持新文件有序。
这里介绍四种插入排序算法:直接插入、二分法插入、表插入、shell插入
直接插入的思路是,初始可认为所有记录中第1个记录已经排好序,然后将第2个到第n个记录依次插入到已排好序的记录组成的文件中。就像打扑克时一种常用的排序方法,当抓到新牌时,就将新牌插入前面已经排好序的牌堆里。例如现有
3 5 7 13
下一个记录为6,依次与前面的记录排序。比13小,13后移;比7小,7后移;比5大,停止后续的比较,这个位置就是6所在的位置。
直接插入一种实现方式是先找到新纪录所在的位置,然后利用后移操作空出该位置,将新纪录存放到该位置
public static void directInsert() {
int tmp;
for (int i = 1; i < size; i++){
tmp = list[i];
int j = i-1;
while (j>=0 && list[j]>tmp){//后移,空出插入位置
list[j+1] = list[j];
j--;
}
list[j+1] = tmp;
}
}
另一种方式是在与前面排好序的记录进行比较时就顺便作交换
public static void directInsert() {
int tmp;
for (int i = 1; i < size; i++){
for (int j=i-1; j>=0; j--){
if(list[j]>list[j+1]){
tmp = list[j];
list[j] = list[j+1];
list[j+1] = tmp;
}else
break;//当已经比剩下的已排序的记录都大时,就可以结束内层循环了
}
}
}
经观察发现直接插入和冒泡排序交换元素的次数相同 这是因为两个算法都是通过交换相邻两元素消除逆序对来完成排序 复杂度可用O(N+I)表示 (I为逆序对对数)若全为逆序则有n-1+n-2……+1 ,即为最差情况复杂度O(n²), 同理最好情况复杂度为O(n) 当基本有序时,插入排序和冒泡效率较高
因此有以下定理
1.任意N个不同元素组成的序列平均具有N/(N-1)/4个逆序对
2. 任何仅以交换相邻两元素来排序的算法,其平均事件复杂度为O(n²),这意味可以每次消除多个逆序对来提高算法效率 ,Shell排序即为一个例子
如果采用的是直接插入的后移方式,可以看出其关键是找到新记录所应该在的位置,对此可以利用一些算法提高查找位置的效率,比如二分法查找。
public static void dichotomyInsert(){
int left, right, mid,tmp;
for (int i=1; i<size; i++){
left = 0;
right = i-1;
tmp = list[i];
while (left<=right){//二分法查找位置
mid = (left+right)/2;
if(list[mid]>tmp)
right = mid-1;
else if(list[mid]<tmp)
left = mid+1;
}
for (int j=i-1; j>=left; j--){//后移,空出插入位置
list[j+1] = list[j];
}
list[left] = tmp;//注意这里只有left或right+1所在的位置才是插入的正确位置,可以自行验证
}
}
总体上讲,当n比较大时,二分法插入次数远少于直接插入的平均比较次数,但两者需要移动的次数相等,所以二分法插入的时间复杂度也为O(n²)。
二分法插入比较次数通常情况比直接插入的次数少,但移动次数相等,这里介绍的表插入排序将在不进行记录移动的情况下,利用存储结构有关信息的改变来达到排序的目的。
Shell(希尔)插入排序,是D.L.Shell在1959年提出的算法,又称为缩小增量排序。
直接插入排序对于较少记录组成的文件十分实用,在待排序文件已基本有序的情况下,其时间复杂度为O(n),比平均时间复杂度O(n²)有很大改进。因此在记录多的文件中,可以先将所有记录按照某种规律分为若干组,这样每组的记录相对减少,可以进行直接插入排序,接下来将这若干组已经有序的记录再重组(分组的数目应比前一次分组数目少),然后再对重新分组的各组记录进行直接插入排序,尽管重新分组后每组中的记录多了起来,但是因为经过前一次排序,这时新分组的记录已经有相当一部分是有序的了。这样逐渐减少分组数,直到分组数为1,此时所有记录的排序就做好了。再对多记录的文件,Shell插入显然是比直接插入效率高的。
因此Shell排序有以下性质:Dk间隔有序的序列,在执行Dk-1间隔排序后,仍然使Dk间隔有序的,比如第二次排序完,然后用第一次的增量提取相应的元素,发现他们的序列仍然是有序的,没有打乱之前的有序性
Shell排序的实现和直接插入交换方式十分相似,只不过两两之间做比较和交换的记录中间隔了增量n的距离。
public static void shellInsert(int n){
int tmp;
while (n>=1){
for (int i=1+n; i<size; i++)
for (int j=i-n; j>=0; j--){
if(list[j]>list[j+n]){
tmp = list[j];
list[j] = list[j+n];
list[j+n] = tmp;
}else
break;
}
n = n/2;
}
}
需要注意Shell排序中如果增量元素如果不是互为质数,很可能不会起作用,白白浪费资源
如{1,9,2,10,3,11,4,12,5,13,6,14,7,15,8,16}
若增量依次为 8、4、2、1,会发现元素前三次排序中,每个间隔的记录其实已经有序,因此不会进行排序,而到执行增量为1时才真正起作用,前三次浪费资源。
容易看出Shell排序是不稳定的排序算法;Shell排序一般而言比直接插入排序快,时间复杂度与增量的选取有很大关系,因此给出时间复杂度的分析十分困难。
关于增量的选取,至今也没有找到一个最好的选择方法,
除了Shell增量序列,还有几种流传较广的增量序列,例如Hibbard 增量序列、Knuth 增量序列等,可以看看这篇文章:希尔排序增量序列简介
选择排序(Selection Sort)的思路为每次从待排序的文件中选择出排序码最小的记录,将记录放于已排序文件的最后一个位置,直到已排序文件记录个数等于初始待排序文件的记录为止。选择排序这里介绍:直接选择排序、树型选择排序、堆排序三种算法。
直接选择是一种很简单的算法,其思路是从n个待排序中遍历找出排序码最小的记录,将该记录通过交换放置在第一位,再从剩下的n-1中找出最小的记录,放置再第二位。重复这样的操作直到剩下两个记录,再从中选择排序码最小的记录和第n-1个记录交换,剩下的那个记录肯定是排序码最大的那个记录,排序完成。
public static void directSwap(){
int min,tmp;
for (int i=0; i<size; i++){
min = list[i];
for (int j=i+1; j<size; j++){
if(list[j]<min){
min = list[j];
tmp = list[j];
list[j] = list[i];
list[i] = tmp;
}
}
}
}
选择排序是不稳定的算法,比如有一段记录为50(1) 50(2) ,现插入10,50(1)先与10进行比较,通过交换为10 50(2) 50(1) ,两个50间的次序就乱了。
选择排序无论是否大致有序,都需要进行两次完整的循环,只不过移动的次数有区别,所以最坏、最好、平均时间复杂度都为O(n²)。
归并排序(Merge Sort)的思路是:一个待排序记录构成的文件,可以看作是由多个有序子文件组成的,对有序子文件通过若干使用归并的方法,得到一个有序的文件;归并是指将两个以上的有序子表合并成一个有序表的过程。
当归并到只有两个有序文件时,归并成一个有序文件只要将两个有序子文件中的当前记录的排序码进行比较,较小者放入目标——有序文件,重复这个过程直到两个有序子文件的记录都放入同一个有序文件为止。这种每次对两个有序子文件进行归并,使有序子文件中的记录个数不断增加,反复进行归并,直到所有待排序记录都在一个有序文件中,排序即完成的方法称为二路归并排序算法。
仔细看两张动图的合并操作,可以发现有些细微差别,因为两者实现的方式有些差异,第一张图是非递归实现,第二张图是递归实现。
下图为递归方式的合并操作
而采用非递归方式合并的思路应为:对于有n个记录的待排序文件,初始可以将其看作由n个长度为1(即由一个记录构成)的子文件组成。每两个相邻的有序子文件分为一组,共有[n/2]组,对每一组进行归并,这样就可以得到长度为2的[n/2]个有序文件。重复这一过程直到得到长度为n的一个有序文件,排序完成。如果记录个数为奇数,可以将最后一个有序子文件直接放入目标文件,或者认为是与空的文件进行归并。
仍然以上图 14 12 15 13 11 16为例,非递归合并的具体步骤应该是这样的
第一步: [14] [12] [15] [13] [11] [16]
第二步: [12,14] [13,15] [11,16]
第三步:[12,13,14,15] [11,16]
第四步:[11,12,13,14,15,16]
简单来说非递归合并操作是先两两之间合并,再四个相邻合并,以此类推,直到完成;而递归实现是把序列分为两部分,递归完成前半部分的合并后,才进行后半部分的合并。这里只用递归方式实现(非递归找下标找到头疼)
public static void mergeSort(){
int[] tmp = new int[size];
divide(tmp,0,size-1);
}
public static void divide(int[] tmp, int left, int right){//分割
if(left<right){
int mid = (left+right)/2;
divide(tmp, left, mid);
divide(tmp,mid+1, right);
merge(tmp, left, right, mid);
for (int i=left; i<=right; i++){//每次合并完需要向原数组进行更新
list[i] = tmp[i];
}
}
}
public static void merge(int[] tmp, int left, int right, int mid){
int i = left;//前半部分起始位置指针
int j = mid+1;//后半部分起始位置指针
int v = mid;//前半部分结束位置
int w = right;//后半部分结束位置
int k =left;//合并后放入临时数组的起始位置
while (i<=v && j<=w){
if(list[i]<=list[j]){
tmp[k++] = list[i];
i++;
}else {
tmp[k++] = list[j];
j++;
}
}
//将剩余元素添加到临时数组
//例如合并3 5 和 1 2
//因为后半部分都小于前半部分,所以存放完1 2后,后半部分的指针就会越界,结束循环
//3 5要另外添加到临时数组
if(i<=v)
for (int n=i; n<=v; n++)
tmp[k++] = list[i++];
if(j<=w)
for (int n=j; n<=w; n++)
tmp[k++] = list[j++];
}
二路归并排序是稳定的算法。其分割和合并的时间复杂度都为O(log2n),每次合并都需进行至多n-1次比较,因此总的时间复杂度为O(n*log2n),除此外还需要大小为n的临时数组进行辅助,所以空间复杂度为O(n)。
基数排序(Radix Sort),也叫分配排序,是一种和前述各种算法都不相同的排序算法。前面的算法是通过对排序码的比较以及记录的移动来实现排序,而基数排序没有这两种操作,它不对排序码进行比较、而是借助于多排序码排序的思想进行单排序码排序的算法。
例如一副扑克除了大小王共有52张牌,分为四种花色,每种花色13张牌。现根据花色优先(梅花<方块<红心<黑桃)排序,再根据扑克面值大小来排序。因此可以对其排序可以先将52张牌根据花色分为四堆,再对每一堆同花色的牌按照面值大小整理有序。也可以分为13堆,将这13堆排从小到大叠在一起,再从13堆中提取同一花色的牌,不同花色分为4堆,最后将这4堆牌按从小到大的次序合在一起即可。
这就是多排序码的排序过程,其中分为若干堆的过程称为分配,从若干堆中从小到大排序的过程称为收集,扑克牌排序用到了2次分配和2次收集操作。
将扑克牌排序的第二种方法推广,就可以得到对多排序码排序的算法。若每个记录有b个排序码,从最低位排序码kb开始进行排序,再对高一位的排序码kb-1进行排序,重复这一过程,直到对最高位k1进行排序后便得到一个有序序列,这种以最低位优先排序的称为LSD(Least significant digital,最低有效数字)法,反之最高位优先排序的称为MSD(Most significant digital,最重要数字)法。
对于普通的整数序列,我们也可以通过基数排序来处理,可以把整数的个位数看作是最低位排序码,十位数是次低位排序码,依此类推,这样就可以通过分配和收集来排序了。
基数排序数据结构的选择可以使用单链表(静态链式基数排序)来实现:先用单链表存储待排序文件中的n个记录,表中每个结点对应一个记录,同时创建用于分配的数组,数组的下标范围应该为0~9。第一趟基数排序,对最低位排序码,即个位数进行分配到数组中,分配好后,依次从分配数组中顺序取出记录(数组[0]中的记录全部取出才获取数组[1]的记录)整合成一个新的整数序列。然后依次对十位数、百位数进行这样的分配和收集操作,直到排序完成。
类结构
public class Radix {
private static int[] list = new int[20];
private static int size = 0;
private static Node head = new Node();//主链表的头结点,个人喜欢带头链表
private static Node[] distribution = new Node[10];//分配数组
private static class Node{
int data;
Node next;
public Node(){};
public Node(int data){
this.data = data;
}
}
static {//初始化
list[0] = 3;
list[1] = 44;
list[2] = 38;
list[3] = 5;
list[4] = 47;
list[5] = 15;
list[6] = 36;
list[7] = 26;
list[8] = 27;
list[9] = 2;
list[10] = 46;
list[11] = 4;
list[12] = 19;
list[13] = 50;
list[14] = 18;
size = 15;
for (int i=0; i<10; i++){
distribution[i] = new Node();
}
}
为了方便操作,编写辅助方法
public static void tailAppend(Node h,int data){
Node point = h;
while (point.next!=null){
point = point.next;
}
Node newNode = new Node(data);
point.next = newNode;
}
public static void display(){
Node point = head.next;
while (point!=null){
System.out.print(point.data + " ");
point = point.next;
}
System.out.println();
}
基数排序
public static void radixSort(){
int max = 0;//获取整数序列的最大值
for (int i=0; i<size; i++){//初始化主链表
tailAppend(head,list[i]);
if(list[i]>max)
max = list[i];
}
int b = 0;//排序码个数
for (int i=1; max/i!=0; i*=10){
b++;
}
System.out.print("初始状态: ");
display();//打印主链表信息
Node point;
int digits = 1, index;//digits为位数,初始为个位;index为每个记录应该存放在分配数组distribution[]的位置的下标
for (int i=0; i<b; i++){
point = head.next;
//根据排序码将记录收集到收集数组
while (point!=null){
index = (point.data % (digits*10)) / digits;
//获取某个位数上数组的计算方法如下:
//个位数:(num%10)/1
//十位数:(num%100)/10
//百位数:(num%1000)/100
//以此类推
tailAppend(distribution[index], point.data );//将该记录分配到分配数组正确的位置
point = point.next;
}
//将分配好的记录进行收集整合
head.next = null;//主链表需要清空,用于接收通过基数排序后的整数序列
for (int j=0; j<10; j++){
point = distribution[j].next;
while (point!=null){
tailAppend(head, point.data);
point = point.next;
}
}
for (int j=0; j<10; j++){//分配数组也需要清空,用于下一次的分配
distribution[j].next =null;
}
System.out.print("第" + (int)(i+1) +"次基数排序: ");
display();
digits *= 10;//位数提升
}
}
简单分析,分配过程与记录的个数有关,时间复杂度为O(n);收集过程与排序码取值范围中值得个数rd(十进制为10)有关,时间复杂度为O(rd);一次基数排序需要一次分配和收集,而整个序列进行的基数排序次数取决于排序码的数量b,因此要进行b次分配和b次收集,时间复杂度为O(b(n+rd),并且无论待排记录是否大致有序和无序,都需要进行这样的操作,所以最坏情况和最好情况时间复杂度都为O(b(n+rd),除此之外,基数排序还至少需要n个辅助空间,所以空间复杂度为O(n);基数排序是稳定的算法
计数排序(Counting Sort)也是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存,因此在数据稀疏但范围大的情况下是很不实用的,相反如果是范围较小的数组,那么计数排序的效率远高于其他比较方式的排序算法的。例如,计数排序是用来排序0到100之间的数字的最好的算法。同时计数排序也可以用在基数排序中来排序数据范围很大的数组。
算法的实现步骤如下:
原文:1.8 计数排序|菜鸟教程
public static void countingSort(){
int max = 0;
for (int i=0; i<size; i++){//获取最大值
if (list[i]>max)
max = list[i];
}
int[] C = new int[max+1];//构造计数数组,这里只是简单演示功能,实际上计数数组的元素应该要由链表组成,类似基数排序的分配数组一样
for (int i=0; i<size; i++){
C[list[i]]++;//从待排序记录读取并计数
}
int p = 0;
for (int i=0; i<max+1; i++){
if(C[i]!=0){
for (int j=0; j<C[i]; j++){
list[p++] = i;
}
}
}
}
找最大值和进行计数的时间复杂度都为O(n),从计数数组重新排序的时间复杂度为O(n+k),其中k为排序码的范围,因此计数排序的时间复杂度为O(n+k),(最坏最好平均都是)。显然当k范围很小时,时间复杂度接近线性,效率十分高;但当k远大于n时,可能就比冒泡排序效率还低。另外计数排序需要大小k的辅助空间,所以空间复杂度为O(k)。计数排序是稳定的算法。
容易发现计数排序很大的缺点是只能排序整数类型和非负整数的记录,因为这是根据数组的特性决定的。
桶排序(Bucket Sort)是计数排序的升级算法,其思想类似统计中的直方图,根据待排序序列的范围划分合适的区间,也就是“桶”,然后跟计数排序(计数排序可以视为桶范围为1时的特殊情况)类似的操作将所有记录放入桶里,随后需要分别对每个桶里的记录进行排序,最后顺序地将桶中的记录取出所有记录完成排序。
为了使桶排序更加高效,我们需要尽量做到这两点:在额外空间充足的情况下,尽量增大桶的数量;尽量使所有记录平均地分配到各个桶中(当对桶中记录排序时,一般用比较交换的排序方式,所以如果所有记录都在一个桶中时,就完全相当于比较交换排序了,这时桶排序就形同虚设,当所有记录平均地分配到各个桶,这时桶排序的效率才是最高的)
public static void bucketSort(int bucketSize){//桶的大小
int min=99999, max=0,i;
for (i=0; i<size; i++){//寻找最大最小值
if(list[i]>max)
max = list[i];
if(list[i]<min)
min = list[i];
}
int k = (max-min)/bucketSize + 1;//构建桶
bucket = new Node[k];
for (i=0; i<k; i++){
bucket[i] = new Node();//初始化桶
}
Node point;
int index;
for (i=0; i<size; i++){//将记录分配到各个桶中
index = list[i]/bucketSize;
point = bucket[index];
while (point.next!=null){//尾插法
point = point.next;
}
Node newNode = new Node(list[i]);
point.next = newNode;
}
Node point2;
int tmp;
for (i=0; i<k; i++){//对各个桶内记录进行排序
point = bucket[i].next;
while (point!=null){
tmp = point.data;
point2 = point.next;
while (point2!=null){
if(point2.data<tmp){
point.data = point2.data;
point2.data = tmp;
}
point2 = point2.next;
}
point = point.next;
}
}
int n = 0;
for (i=0; i<k; i++){//将桶内的记录顺序取出
point = bucket[i].next;
while(point!=null){
list[n++] = point.data;
point = point.next;
}
}
}
桶排序中,寻找最大最小值时间复杂度为O(n);初始化桶时间复杂度为桶的数量O(k);将记录分配到各个桶O(n+k);各个桶内记录排序,当记录分布较均匀时时间复杂度为O(n+k),当都在一个桶内时为O(n²);桶内记录重新整合O(n+k)。因此平均和最好情况下为O(n+k),最差情况下O(n²),空间复杂度需要k大小的数组,数组内元素总和为n,因此空间复杂度为O(n+k)。桶排序是稳定的算法。
下图为十大排序算法的分析结果
先从冒泡、直接选择、直接插入三者最简单也相似的算法比较。
选择排序理解容易,但无论待排序记录是否已经大致有序,都需要O(n²)的时间复杂度,另外选择排序是不稳定的算法。
而冒泡排序和插入排序的效率取决于逆序对的数量,也就是基本有序的情况下这两种算法效率较高,实际上因为冒泡排序需要两两之间进行比较和交换,而插入排序的操作由寻找插入位置(可以用二分法优化)和移动两部分组成,实际效率会比冒泡高。两者都是稳定的算法。
经网上对三种算法的效率分析,插入排序的效率都是比其他两种算法要高得多的,因此多数情况下使用插入排序就行,当然不考虑稳定性的话采用希尔排序和快速排序的效率会更高。
对于非比较排序算法:计数排序、桶排序、基数排序。这些算法通过牺牲空间来获取时间上的效率,复杂度取决于记录的数量和记录值的所在的范围,在记录范围比较小,记录数量较多时,其效率是远高于比较排序算法的。
基数排序相比于其他算法最大的特点是可以多排序码排序,相比于计数排序进行整数序列排序时,基数排序只要排序码数量少(即位数少),进行高效排序的范围比计数排序广,计数排序更适用于范围小,但是重复数据多的场合。
桶排序是基于计数排序的改进,相比于计数排序只能排序整数序列,桶排序还可以排序小数序列。当记录分配到各个桶时,还需要对各个桶内的记录进行排序,因此桶排序适合于记录均匀分布的场合,否则最差情况下所有记录都在一个桶内时,就近似比较排序了。