排序算法

排序算法
#

参考:http://blog.csdn.net/hguisu/article/details/7776068#t5

  排序算法,从大的分类可以分为两种:内排序和外排序。在排序过程中,全部记录存放在内存,则称为内排序。如果排序过程中需要使用外存,则称为外排序。下面讲的排序都是属于内排序。
  内排序又可以分为以下几类:

  • 插入排序:直接插入排序、希尔排序。
  • 选择排序:简单选择排序、堆排序。
  • 交换排序:冒泡排序、快速排序。
  • 归并排序
  • 基数排序

各种排序的稳定性,时间复杂度和空间复杂度总结:

稳定排序:所有相等的数经过某种排序方法后,仍能保持它们在排序之前的相对次序,就称这种排序方法是稳定的,反之,就是非稳定的。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;

一、八大排序算法

1.插入排序—直接插入排序(Straight Insertion Sort)

基本思想:
  将一个记录插入到已排序好的有序表中,从而得到一个新记录数增1的有序表。即:先将序列的第1个记录看成是一个有序的子序列,然后从第2个记录逐个进行插入,直至整个序列有序为止。
要点:设立哨兵,作为临时存储和判断数组边界之用。

直接插入排序的示例:

算法的实现:

	public int[] insertionSort(int[] a) {//其实可以不要返回值,因为共享对象a的内容已经被改变了;可以对a是否为null或者长度简单判断
		int length = a.length;
		int temp = 0;
		int j = 0;
		for (int i = 1; i < length; i++) {   //初始化i=0或i=1均可,因为i=0内循环并不执行
			temp = a[i];   //复制为哨兵,即存储待排序元素  
			for (j = i; j > 0 && temp < a[j - 1]; j--) {  // 假如temp比前面的值小,则将前面的值后移
				a[j] = a[j - 1];
			}
			a[j] = temp;
		}
		return a;
	}

如果碰见一个和插入元素相等的,那么把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,插入排序是稳定的。
复杂度分析:
最坏情况
在内循环中,元素的比较次数:对于位置i,最多进行i次比较,因为它只可能和位置i-1,i-2..0的元素进行比较,对所有的i求和得到:
1+2+3...+N-1=N*(N-1)/2=O(N^2)
最好情况
输入数据已预先排序,则内层的for循环立即判定不成立而终止:O(N)
平均情况
假设数组所有的排列都是等可能的,则在内循环中,元素的比较次数:对于位置i,平均进行i/2次比较,对所有的i求和得到:
1/2+2/2+3/2...+(N-1)/2=N*(N-1)/4=O(N^2)

等差数列求和公式:

等比数列求和公式:

2. 插入排序—希尔排序(Shell Sort)

基本思想:
  先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
算法描述:
1、先取一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组。
2、所有距离为d1的倍数的记录放在同一个组中,在各组内进行直接插入排序。
3、取第二个增量d2<d1重复上述的分组和排序,
4、直至所取的增量dt=1(dt<dt-l<…<d2<d1),即所有记录放在同一组中进行直接插入排序为止。
  希尔排序的时间复杂度与增量序列的选取有关,例如希尔增量()时间复杂度为O(n^2),而Hibbard增量(1, 3, ..., 2k-1,即不用向下取整,每一项都取奇数)的希尔排序的时间复杂度为O(N(5/4)),但是现今仍然没有人能找出希尔排序的精确下界。

希尔排序的示例:



算法的实现:

	public int[] shellSort(int[] a) {
		int i = 0;
		int j = 0;
		int temp = 0;
		int length = a.length;
		for (int increment = length / 2; increment > 0; increment /= 2) {   // 每次将步长缩短为原来的一半,increment仅会定义一次,故不像i,j,temp那样定义
			for (i = increment; i < length; i++) {
				temp = a[i];
				for (j = i; j >= increment && temp < a[j - increment]; j -= increment) {
					a[j] = a[j - increment];
				}
				a[j] = temp;
			}
		}
		return a;
	}

我们知道一次插入排序是稳定的,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。

希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于增量因子序列d的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。

3.选择排序—简单选择排序(Simple Selection Sort)

基本思想:
  在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数当中再找最小(或者最大)的与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。

简单选择排序的示例:

算法的实现:

	public int[] selectSort(int[] a) {
		int length = a.length; // 数组长度
		int temp = 0; // 中间变量
		for (int i = 0; i < length; i++) {
			int k = i; // 待确定的位置
			for (int j = length - 1; j > i; j--) {   // 选择出应该在第i个位置的数
				if (a[j] < a[k]) {
					k = j;
				}
			}
			temp = a[i];
			a[i] = a[k];
			a[k] = temp;
		}
		return a;
	}

假设有三个数依次为:a,b,c,并且c<a=b,如果c是某轮循环的最小值,那么a会和c交换,b就在a前面了,所以,简单选择排序不稳定。

复杂度分析:
最坏情况
每次循环需要访问N-i个元素得到最小值,故其总的比较次数为(n-1)+(n-2)+...+1=N*(N+1)/2,故复杂度为O(N^2)
最好情况
与最坏情况一致
平均情况
与最坏情况一致

4.选择排序—堆排序(Heap Sort)

  堆排序是一种树形选择排序,是对直接选择排序的有效改进。
基本思想:
满二叉树:从形象上来说满二叉树是一个绝对的三角形,也就是说它的最后一层全部是叶子节点,其余各层全部是非叶子节点,如果用数学公式表示那么其节点数n=2^k-1其中k表示深度层数(注意层数比深度多1)。
完全二叉树:从形式上来说他是一个可能有缺失的三角形,但所缺部分肯定是右下角的某个连续部分。和满二叉树的区别是,他的最后一行可能不是完整的,但绝对是右方的连续部分缺失。用数学公式讲,对于k层的完全二叉树,其节点数的范围是2(k-1)-1<N<=2k-1;

  堆的定义如下:具有n个元素的序列(k1,k2,...,kn),当且仅当满足

时称之为堆。由堆的定义可以看出,堆顶元素(即第一个元素)必为最小项(小顶堆)。
若以一维数组存储一个堆,则堆对应一棵完全二叉树,且所有非叶结点的值均不大于(或不小于)其子女的值,根结点(堆顶元素)的值是最小(或最大)的。如:
(a)大顶堆序列:(96, 83,27,38,11,09)
(b) 小顶堆序列:(12,36,24,85,47,30,53,91)

  初始时把要排序的n个数的序列看作是一棵顺序存储的完全二叉树(一维数组存储二叉树),调整它们的存储序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(或最大)的元素,这时堆的根节点的数最小(或者最大)。然后将根节点与堆的最后一个节点交换。然后对前面(n-1)个数重新调整使之成为堆。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。从算法描述来看,堆排序需要两个过程,一是建立堆或者调整堆,二是堆顶与堆的最后一个元素交换位置。所以堆排序有两个函数组成。一是建堆的渗透函数,二是反复调用渗透函数实现排序的函数。
  其实构造初始堆与调整堆的过程相同,只不过构造初始堆是对所有的非叶节点都进行调整。

堆排序的示例:
给定一个整形数组a[]={16,7,3,20,17,8},对其进行堆排序。 首先根据该数组元素构建一个完全二叉树,得到

然后需要构造初始堆,则从最后一个非叶节点开始调整,调整过程如下:

20和16交换后导致16不满足堆的性质,因此需重新调整

这样就得到了初始堆。
即每次调整都是从父节点、左孩子节点、右孩子节点三者中选择最大者跟父节点进行交换(交换之后可能造成被交换的孩子节点不满足堆的性质,因此每次交换之后要重新对被交换的孩子节点进行调整)。有了初始堆之后就可以进行排序了。

此时3位于堆顶不满堆的性质,则需继续调整

这样整个区间便已经有序了。
从上述过程可知,堆排序其实也是一种选择排序,是一种树形选择排序。只不过直接选择排序中,为了从R[1...n]中选择最大记录,需比较n-1次,然后从R[1...n-1]中选择最大记录需比较n-2次。事实上这n-2次比较中有很多已经在前面的n-1次比较中做过,而树形选择排序恰好利用树形的特点保存了前面的部分比较结果,因此可以减少比较次数。注意建堆是自底向上

大顶堆(从小到大排序)算法的实现:

    public int[] heapSort(int[] a) {
        // 首先构造大顶堆,从最后一个节点a.length-1的父节点(a.length-1-1)/2开始,
        // 直到根节点0,反复调整堆,比如0的两个儿子1和2,则i从3/2-1==0开始
        int len = a.length;
        for (int i = len / 2 - 1; i >= 0; i--) {
            adjustHeap(a, i, len - 1);
        }

        for (int i = len - 1; i > 0; i--) {// 此时不用考虑i=0,因为只剩一个节点肯定是有序的
            swap(a, 0, i); // 将堆顶元素和堆低元素交换,即得到当前最大元素正确的排序位置
            adjustHeap(a, 0, i - 1); // 将剩余的元素调整成堆
        }
        return a;
    }
    
    // 将元素a[k]放到合适的位置
    private static void adjustHeap(int[] a, int k, int lastIndex) {
        int temp = a[k];
        for (int i = 2 * k + 1; i <= lastIndex; i = 2 * i + 1) { // i初始化为节点k的左孩子;i<=lastIndex表示左孩子存在
            if (i < lastIndex && a[i] < a[i + 1]) { // 先判断右孩子存在,然后再判断左右孩子的大小
                i++;
            }
            if (temp < a[i]) { // 根节点小于左右孩子中较大者
                a[k] = a[i];
                k = i; // 【关键】修改k值,以便继续向下调整
            } else {
                break;
            }
        }
        a[k] = temp; // 被调整的结点放入最终位置
    }

    private static void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }

由于记录的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法。

复杂度分析:
最坏情况
时间复杂度:
建堆:O(N),每次调整O(logN),故最好、最坏、平均情况下:O(N*logN);
最好情况
与最坏情况一致
平均情况
与最坏情况一致

参考:从数组中选出前k小
下面给出堆的插入和删除最大元素的代码和分析:

	// 插入操作:向大顶堆arr的index下标位置插入数据x,假设数组index位置之前都有元素
	public static void heapInsert(int[] arr, int x, int index) {
		arr[index] = x;
		while (index != 0) {
			int parent = (index - 1) / 2;
			if (arr[parent] < arr[index]) {
				swap(arr, parent, index);
				index = parent;
			} else {
				break;
			}
		}
	}

  为将一个元素X插入到堆中,我们在下一个可用位置创建一个空穴,否则该堆将不是完全树。如果X可以放在该空穴中而并不破坏堆的序,那么插入完成。否则,我们把空穴的父节点上的元素移入该空穴中,这样,空穴就朝着根的方向上冒一步。继续该过程直到X能被放入空穴中为止。如下图所示,为了插入14,我们在堆的下一个可用位罝建立一个空穴。由于将14 插入空穴破坏了堆序性质,因此将31移入该空穴。然后继续这种策略,直到找出置入14 的正确位置。这种一般的策略叫做上滤(percolate up);新元素在堆中上滤直到找出正确的位置。

	// 删除堆顶元素操作
	public int[] deleteMax(int[] array) {
		int len = array.length;   // 将堆的最后一个元素与堆顶元素交换,堆底元素值设为-99999
		array[0] = array[len - 1];
		array[len - 1] = -99999;
		adjustHeap(array, 0, len - 1); // 对此时的根节点进行调整
		return array;
	}

  当删除一个最小元时,要在根节点建立一个空穴。由于现在堆少了一个元素,因此堆中最后一个元素X 必须移动到该堆的某个地方。将X放到空穴中,然后重新调整堆即可。

5.交换排序—冒泡排序(Bubble Sort)

基本思想:
  它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有相邻元素需要交换,也就是说该数列已经排序完成。
冒泡排序的示例:

  对冒泡排序常见的改进方法是加入一标志性变量flag,用于标志某一趟排序过程中是否有数据交换,如果进行某一趟排序时并没有进行数据交换,则说明数据已经按要求排列好,可立即结束排序,避免不必要的比较过程。

算法的实现:

	public int[] bubbleSort(int[] a) {
		int temp;
		int flag;
		int length = a.length;
		for (int i = 0; i < length - 1; i++) {//外层循环只是用于控制排序轮数,排序数组长度-1,内层循环用于比较相邻元素大小
			flag = 0;
			for (int j = 0; j < length - 1 - i; j++) {
				if (a[j] > a[j + 1]) {
					temp = a[j];
					a[j] = a[j + 1];
					a[j + 1] = temp;
					flag = 1;
				}
			}
			if(flag == 0)
                break;
		}
		return a;
	}

当a=b,由于只有大于才做交换,故a,b的位置没有机会交换,所以,冒泡排序是稳定的。

复杂度分析:
最坏情况
当输入序列是逆序的时候,在内循环中,元素的比较次数:对于位置i,进行length-1-i次比较,对所有的i求和得到:
1+2+3...+N-1=N*(N-1)/2=O(N^2)
最好情况
当输入序列已经排序好的时候,一轮冒泡下来没有交换过位置,直接退出程序,复杂度为:O(N)
平均情况
N-1轮循环,每一轮循环平均比较次数为(N-1)/2,故总的复杂度为:O(N^2)

6.交换排序—快速排序(Quick Sort)

重点掌握
基本思想:
  通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

算法描述:
1)选择一个基准元素,通常选择第一个元素或者最后一个元素;
2)通过一趟排序将待排序的记录分割成独立的两部分,其中一部分记录的元素值均比基准元素值小,另一部分记录的元素值均比基准值大。
3)此时基准元素在其排好序后的正确位置
4)然后分别对这两部分记录用同样的方法继续进行排序,直到整个序列有序。

快速排序的示例:
(a)一趟排序的过程:

(b)排序的全过程

算法的实现:

	public int[] quickSort(int[] a) {
		if (a.length > 0) {
			a = quickSort(a, 0, a.length - 1);
		}
		return a;
	}

	private static int[] quickSort(int[] a, int low, int high) {
		if (low < high) {
			int middle = getMiddle(a, low, high); // 将a数组进行一分为二
			quickSort(a, low, middle - 1); // 对低字段表进行递归排序
			quickSort(a, middle + 1, high); // 对高字段表进行递归排序
		}
		return a;
	}

	private static int getMiddle(int[] a, int low, int high) {
		int temp = a[low];                   // 数组的第一个作为基准
		while (low < high) {                //从数组的两端交替地向中间扫描  
			while (low < high && a[high] >= temp) { // 不考虑=的话会陷入死循环
				high--;
			}
			a[low] = a[high];            // 比基准小的记录移到低端
			while (low < high && a[low] <= temp) {
				low++;
			}
			a[high] = a[low];           // 比基准大的记录移到高端
		}
		a[low] = temp;                     // 将基准的记录放到此时low=high的位置
		return low;                          // 返回此时基准的位置
	}

  快速排序是通常被认为在同数量级(O(N*logN))的排序方法中平均性能最好的。但若初始序列有序或基本有序时,快速排序反而蜕化为冒泡排序。为改进之,通常以“三者取中法”来选取基准记录,将排序区间的两个端点与中点三个记录居中的设为基准记录,即:
令int center = (low+high)/2;
首先对a[low] ,a[center] ,a[high] 进行排序,将三者中的最小值放到a[low]位置,三者中的最大值a[high] 位置,然后将三者中的中间值和a[low+1]进行交换并作为基准,注意这时候a[low]比a[low+1]小,a[high]比a[low+1]大,都在正确的位置,我们只需要从low+2和high-1交替地向中间扫描即可。

当a=b>pivot且a在b前面的时候:由于从后面开始遍历,故b会先于a被替换到pivot的前面,这样,b就变成了在a的前面,也就是说,ab位置对调,故该排序算法不稳定。

空间复杂度分析:
首先快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的是递归调用,因为每次递归都要保持一些数据;
即每一次函数调用,除了需要空间作为被调函数的栈空间外,还需要额外的地址用来保存当前调用者的基址。函数若一直不返回,继续调用新的函数,则所需要的空间越来越大。空间复杂度分析时,我们将每次递归抽象为一次内存分配,所以递归的数量级决定了空间复杂度。
最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况,即递归树的深度为logn
最差的情况下空间复杂度为:O( n ) ;每次都刚好选到了最小的元素作为基准,导致极度的不平衡,退化为冒泡排序的情况,即需要进行n‐1次递归调用
平均情况:O(logn)

时间复杂度分析:
最坏情况
每次都刚好选到了最小的元素作为基准,导致极度的不平衡,退化为冒泡排序的最坏情况了(每一次都排好一个元素的顺序),复杂度为O(N^2)

最好情况
每次都刚好选到了一个区间段的中位数
首先,**递归算法的时间复杂度公式:T[n] = aT[n/b] + f(n) **
此时的时间复杂度公式则为:T[n] = 2T[n/2] + f(n);T[n/2]为平分后的子数组的时间复杂度,f[n] 为平分这个数组时所花的时间;
下面来推算下,在最优的情况下快速排序时间复杂度的计算(用迭代法):
T[n] = 2T[n/2] + n ----------------第一次递归
= 2 { 2 T[n/4] + (n/2) } + n ----------------第二次递归
= 2^2 T[ n/ (2^2) ] + 2n
= 2^2 { 2 T[n/ (2^3) ] + n/(2^2)} + 2n ----------------第三次递归
= 2^3 T[ n/ (2^3) ] + 3n
= 2^m T[1] + mn ----------------第m次递归(m次后结束),令:n = n/( 2^(m-1) )
即T[n/ (2^m) ] = T[1] ===>> n = 2^m ====>> m = logn;
那么T[n] =2^(logn) T[1] + nlogn = n T[1] + nlogn = n + nlogn ;其中n为元素个数
最优的情况下时间复杂度为O(N*logN)

平均情况
平均复杂度为O(NlogN)

7.归并排序(Merge Sort)

基本思想:
  归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的,然后再把有序子序列合并为整体有序序列。采用分治法的一个非常典型的应用。

算法描述:
1、Divide: 把长度为n的输入序列分成两个长度为n/2的子序列。
2、Conquer: 对这两个子序列分别采用归并排序。
3、Combine: 将两个排序好的子序列合并成一个最终的排序序列。

归并排序示例:

算法的实现:

	public int[] mergeSort(int[] a) {
		if (a.length > 0) {
			int[] temp =  new int[a.length];
			a = mergeSort(a, temp, 0, a.length - 1);
		}
		return a;
	}

	private static int[] mergeSort(int[] a, int[] temp, int low, int high) {
		if (low < high) {
			int mid = (low + high) / 2;
			mergeSort(a, temp, low, mid);          // 左边有序
			mergeSort(a, temp, mid + 1, high);     // 右边有序 
			merge(a, temp, low, mid, high);        // 将两个有序数列合并
		}
		return a;
	}

	private static void merge(int[] a, int[] temp, int low, int mid, int high) {
		int i = low;                         // 左指针
		int j = mid + 1;                     // 右指针
		int k = 0;                           // 临时数组末尾坐标 
		while (i <= mid && j <= high) {      // 把较小的数先移到临时数组中
			if (a[i] < a[j]) {
				temp[k++] = a[i++];
			} else {
				temp[k++] = a[j++];
			}
		}
		while (i <= mid) {                   // 把左边剩余的数移入临时数组
			temp[k++] = a[i++];
		}
		while (j <= high) {                  // 把右边边剩余的数移入临时数组,这种情况和上面的情况只有一个成立
			temp[k++] = a[j++];
		}
		for (i = 0; i < k; i++) {             // 将临时数组中的元素写回到原数组当中去
			a[i + low] = temp[i];
		}
	}

  算法中要注意一点:如果对merge的每个递归调用均局部声明一个临时数组temp,那么在任一时刻就可能有logN个临时数组被放到栈中,但任一时刻却只有一个临时数组在活动。因此我们在public的mergeSort中建立临时数组。

由于没有发生数据交换,所有当a=b的时候,a一开始如果在b前面,则其每一次合并后仍然在b前面,故该排序算法是稳定的

空间复杂度分析:
归并排序中,由于每次都是将序列均分,递归造成的空间复杂度为O(logN),辅助用来归并的临时数组需要O(N)的空间,所以总空间复杂度为O(N+logN) 约为 O(N) 。

时间复杂度分析:
最坏情况
对于各种输入序列,归并排序的处理过程都是一样的,T(1) = 1, T(N) = 2T(N/2) + N,不难求得最后的复杂度T(N)为O(N*logN)
最好情况
与最坏情况一致
平均情况
与最坏情况一致

8.桶排序/基数排序(Radix Sort)

说基数排序之前,我们先说桶排序
  桶排序 (Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶里。每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。 桶排序是稳定的,且在大多数情况下比快排还要快,缺点是非常耗空间,基本上是最耗空间的一种排序算法,而且只能在某些情形下使用。桶排序并不是比较排序,他不受到 O(n log n) 下限的影响。
  简单来说,就是把数据分组,放在一个个的桶中,然后对每个桶里面的数字再进行排序。
  例如要对大小为[1..1000]范围内的n个整数A[1..n]排序
  首先,可以把桶设为大小为10的范围,具体而言,设集合B[1]存储[1..10]的整数,集合B[2]存储 (10..20]的整数,……集合B[i]存储( (i-1) * 10, i * 10]的整数,i = 1,2,..100。总共有 100个桶。 然后,对A[1..n]从头到尾扫描一遍,把每个A[i]放入对应的桶B[j]中。 再对这100个桶中每个桶里的数字排序,这时可用冒泡,选择,乃至快排,一般来说任何排序法都可以。
  最后,依次输出每个桶里面的数字,且每个桶中的数字从小到大输出,这样就得到所有数字排好序的一个序列了。
  假设有n个数字,有m个桶,如果数字是平均分布的,则每个桶里面平均有n/m个数字。如果
  对每个桶中的数字采用快速排序,那么整个算法的复杂度是
  O(n + m * n/m*log(n/m)) = O(n + nlogn - nlogm)
从上式看出,当m接近n的时候,桶排序复杂度接近O(n),当然,以上复杂度的计算是基于输入的n个数字是平均分布这个假设的。这个假设是很强的,实际应用中效果并没有这么好。如果所有的数字都落在同一个桶中,那就退化成一般的排序了。
典型应用:位图,即桶的大小为1
  但桶排序的缺点是:
1)首先是空间复杂度比较高,需要的额外开销大。排序有两个数组的空间开销,一个存放待排序数组,一个就是所谓的桶,比如待排序值是从0到m-1,那就需要m个桶,这个桶数组就要至少m个空间。
2)其次待排序的元素都要在一定的范围内。

例子:
一年的全国高考考生人数为500 万,分数使用标准分,最低100 ,最高900 ,没有小数,你把这500 万元素的数组排个序。
对500W数据排序,如果基于比较的先进排序,平均比较次数为O(5000000 * log5000000)≈1.112亿。但是我们发现,这些数据都有特殊的条件: 100=<score<=900。那么我们就可以考虑桶排序这样一个“投机取巧”的办法、让其在毫秒级别就完成500万排序。
创建801(900-100)个桶。将每个考生的分数丢进f(score)=score-100的桶中。这个过程从头到尾遍历一遍数据只需要500W次。然后根据桶号大小依次将桶中数值输出,即可以得到一个有序的序列。而且可以很容易的得到100分有多少人,501分有多少人。
实际上,桶排序对数据的条件有特殊要求,如果上面的分数不是从100-900,而是从0-2亿,那么分配2亿个桶显然是不可能的。所以桶排序有其局限性,适合元素值集合范围并不大的情况。

基本思想:
基数排序的基本思想:多次的桶式排序。基数排序过程无须比较关键字,而是通过“分配”和“收集”过程来实现排序,即将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期),所以基数排序也不是只能使用于整数。

算法描述:
1、将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。
2、从最低位开始,依次进行一次排序。
3、这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

基数排序示例:

注意:每次排序都是在上次排序的基础上进行排序的,在某个位上两个数相同,则不进行移动,所以基数排序是稳定的。

算法的实现:

	public int[] radixSort(int[] a) {
        int max = 0;                       //找到最大数,确定要排序几趟
        for (int i = 0; i < a.length; i++) {
            if(max<a[i]){
                max = a[i];
            }
        }
        int d = 0;                         //判断位数
        while(max>0){
            max = max/10;
            d++;
        }
        List<LinkedList> queue = new ArrayList<>();         //建立十个队列
        for (int i = 0; i < 10; i++) {
            queue.add(new LinkedList());
        }
        int m=1;
        for (int i = 0; i < d; i++) {                 //进行d次分配和收集
        	m = m*10;
            for (int j = 0; j < a.length; j++) {      //分配
                int x = (a[j]% m)/(m/10) ;           //这里注意,比如2546,我们要确定百位的5,则2546%1000得到546,然后除以100,得到5
                queue.get(x).add(a[j]);
            }
            int count = 0;                     //收集
            for (int j = 0; j < 10; j++) {
                while(queue.get(j).size()>0){
                    a[count] = (int) queue.get(j).getFirst();
                    queue.get(j).removeFirst();
                    count++;
                }
            }
        }
        return a;
    }

空间复杂度分析:
由于需要10个链表保存各位上的数据,所有链表的总数据量为N,故总空间复杂度为 O(N) 。

时间复杂度分析:
最坏情况
每一次关键字的桶分配都需要O(N)的时间复杂度,分配之后从r(即基数)个桶中取出数据放回到原数组,即收集,需要O(r)(假设从链表中连续顺序取出数据不消耗时间)或者O(N)(从r个链表中取出了N个数据),"分配-收集"的总趟数为d,即最大数的位数,那么时间复杂度O(d(n+r))或者O(d(n+n))=2O(dn)=O(dn)
最好情况
与最坏情况一致
平均情况
与最坏情况一致

二、总结

时间复杂度

(1)平方阶(O(n^2))排序
  各类简单排序:直接插入、简单选择和冒泡排序;
(2)线性对数阶(O(nlogn))排序
  快速排序、堆排序和归并排序;
(3)\(O(n^{1+§}))\)排序,§是介于0和1之间的常数。
希尔排序
(4)线性阶(O(n))排序
  基数排序。

说明:
  当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
  而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为O(n^2);
  原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。

空间复杂度

(1)空间复杂度为O(1)排序
  直接插入排序、希尔排序、简单选择排序、堆排序、冒泡排序。
(2)空间复杂度为O(logn)排序
  快速排序;
(3)空间复杂度为O(n)排序
  归并排序和基数排序;

稳定性

  排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对次序发生了改变,则称该算法是不稳定的。
  稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,当低位相同的元素在高位也相同时,其顺序是不会改变的。另外,如果排序算法稳定,可以避免多余的比较;
  比如:一个班的学生已经按照学号大小排好序了,我现在要求按照年龄从小到大再排个序,如果年龄相同的,必须按照学号从小到大的顺序排列。
那么问题来了,你选择的年龄排序方法如果是不稳定的,是不是排序完了后年龄相同的一组学生学号就乱了,你就得把这组年龄相同的学生再按照学号排一遍。如果是稳定的排序算法,我就只需要按照年龄排一遍就好了。

稳定的排序算法:冒泡排序、直接插入排序、归并排序和基数排序

不稳定的排序算法:希尔排序、简单选择排序、堆排序、快速排序

选择排序算法准则

  影响排序的因素有很多,平均时间复杂度低的算法并不一定就是最优的。相反,有时平均时间复杂度高的算法可能更适合某些特殊情况。
  设待排序元素的个数为n.
1)当n较大,则应采用时间复杂度为O(nlogn)的排序方法:快速排序、堆排序或归并排序。
  快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
  堆排序 : 如果内存空间允许或者求topK。
  归并排序:对于要求稳定的可以选择。由于其空间复杂度较高,多用于与外部排序结合多路归并使用。

2)当n较小,可采用直接插入或简单选择排序。
  直接插入排序:当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数。
  简单选择排序 :当元素分布无序,如果不要求稳定性,可简单选择排序

posted @ 2017-06-08 10:14  何必等明天  阅读(669)  评论(0编辑  收藏  举报