1、冒泡排序
实现
public class BubbleSort implements Sort {
/**
* 1.比较相邻的前后二个数据,如果前面数据大于后面的数据,就将二个数据交换。
* 2.这样对数组的第0个数据到N-1个数据进行一次遍历后,最大的一个数据就“沉”到数组第N-1个位置。
* 3.N=N-1,如果N不为0就重复前面二步,否则排序完成。
*/
@Override
public void sort(int[] arr) {
/**
* 如果有100个数的数组,仅前面10个无序,后面90个都已排好序且都大于前面10个数字,那么在第一趟遍历后,
* 最后发生交换的位置必定小于10,且这个位置之后的数据必定已经有序了,
* 记录下这位置,第二次只要从数组头部遍历到这个位置就可以了
*/
int flag = arr.length;
while (flag > 0) {
int n = flag;
flag = 0;
for (int j = 1; j < n; j++) {
if (arr[j - 1] > arr[j]) {
swap(arr,j-1,j)
flag = j;
}
}
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
交换排序 | 冒泡排序 | O(N2) | O(N2) | O(N) | O(1) | 稳定 | 简单 |
时间复杂度
若文件的初始状态是正序的,一趟扫描即可完成排序。所需的关键字比较次数C和记录移动次数M均达到最小值:Cmin = N - 1, Mmin = 0。所以,冒泡排序最好时间复杂度为O(N)。
若初始文件是反序的,需要进行 N -1 趟排序。每趟排序要进行 N - i 次关键字的比较(1 ≤ i ≤ N - 1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
Cmax = N(N-1)/2 = O(N2)
Mmax = 3N(N-1)/2 = O(N2)
冒泡排序的最坏时间复杂度为O(N2)。
因此,冒泡排序的平均时间复杂度为O(N2)。
总结起来,其实就是一句话:当数据越接近正序时,冒泡排序性能越好。
算法稳定性
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。
所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。
2、插入排序
实现
public class InsertSort implements Sort {
/**
* 每次将一个待排序的记录,按其关键字大小插入到前面已经排好序的子序列中的适当位置,直到全部记录插入完成为止
*/
@Override
public void sort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
swap(arr,j,j+1);
}
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
插入排序 | 直接插入排序 | O(N2) | O(N2) | O(N) | O(1) | 稳定 | 简单 |
时间复杂度
当数据正序时,执行效率最好,每次插入都不用移动前面的元素,时间复杂度为O(N)。
当数据反序时,执行效率最差,每次插入都要前面的元素后移,时间复杂度为O(N2)。
所以,数据越接近正序,直接插入排序的算法性能越好。
空间复杂度
由直接插入排序算法可知,我们在排序过程中,需要一个临时变量存储要插入的值,所以空间复杂度为 1 。
算法稳定性
直接插入排序的过程中,不需要改变相等数值元素的位置,所以它是稳定的算法。
3、希尔排序
实现
public class ShellSort implements Sort {
/**
* 先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,
* 然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,
* 再对全体元素进行一次直接插入排序。
* 因为直接插入排序在元素基本有序的情况下(接近最好情况),
* 效率是很高的,因此希尔排序在时间效率上比前两种方法有较大提高。
*/
@Override
public void sort(int[] arr) {
for(int gap=arr.length/2;gap>0;gap/=2){
for(int i=gap;i<arr.length;i++){
for(int j=i-gap;j>=0&&arr[j]>arr[j+gap];j-=gap){
swap(arr,j,j+gap);
}
}
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
插入排序 | 希尔排序 | O(Nlog2N) | O(N1.5) |
| O(1) | 不稳定 | 较复杂 |
时间复杂度
步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。
算法最开始以一定的步长进行排序。然后会继续以一定步长进行排序,最终算法以步长为1进行排序。当步长为1时,算法变为插入排序,这就保证了数据一定会被排序。
Donald Shell 最初建议步长选择为N/2并且对步长取半直到步长达到1。虽然这样取可以比O(N2)类的算法(插入排序)更好,但这样仍然有减少平均时间和最差时间的余地。可能希尔排序最重要的地方在于当用较小步长排序后,以前用的较大步长仍然是有序的。比如,如果一个数列以步长5进行了排序然后再以步长3进行排序,那么该数列不仅是以步长3有序,而且是以步长5有序。如果不是这样,那么算法在迭代过程中会打乱以前的顺序,那就
不会以如此短的时间完成排序了。
步长序列 | 最坏情况下复杂度 |
| |
| |
| |
已知的最好步长序列是由Sedgewick提出的(1, 5, 19, 41, 109,...),该序列的项来自
这两个算式。
这项研究也表明“比较在希尔排序中是最主要的操作,而不是交换。”用这样步长序列的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢。
算法稳定性
希尔排序中相等数据可能会交换位置,所以希尔排序是不稳定的算法。
直接插入排序和希尔排序的比较
直接插入排序是稳定的;而希尔排序是不稳定的。
直接插入排序更适合于原始记录基本有序的集合。
希尔排序的比较次数和移动次数都要比直接插入排序少,当N越大时,效果越明显。
在希尔排序中,增量序列gap的取法必须满足:最后一个步长必须是 1 。
直接插入排序也适用于链式存储结构;希尔排序不适用于链式结构。
4、简单选择排序
实现
public class SelectSort implements Sort {
/**
* 设数组为a[0…n-1]。
* 1.初始时,数组全为无序区为a[0..n-1]。令i=0
* 2.在无序区a[i…n-1]中选取一个最小的元素,将其与a[i]交换。交换之后a[0…i]就形成了一个有序区。
* 3.i++并重复第二步直到i==n-1。排序完成。
*/
@Override
public void sort(int[] arr) {
for(int i=0;i<arr.length-1;i++){
int index=i;
for(int j=i+1;j<arr.length;j++){
if(arr[index]>arr[j]){
index=j;
}
}
swap(arr,index,i);
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
选择排序 | 简单选择排序 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 | 简单 |
时间复杂度
简单选择排序的比较次数与序列的初始排序无关。 假设待排序的序列有 N 个元素,则比较次数总是N (N - 1) / 2。
而移动次数与序列的初始排序有关。当序列正序时,移动次数最少,为 0.
当序列反序时,移动次数最多,为3N (N - 1) / 2。
所以,综合以上,简单排序的时间复杂度为 O(N2)。
空间复杂度
简单选择排序需要占用 1 个临时空间,在交换数值时使用。
5、快速排序
实现
public class QuickSort implements Sort {
/**
* 该方法的基本思想是:
* 1.先从数列中取出一个数作为基准数。
* 2.分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
* 3.再对左右区间重复第二步,直到各区间只有一个数。
*/
@Override
public void sort(int[] arr) {
sort(arr,0,arr.length-1);
}
private void sort(int[] arr,int first,int last){
if(first>=last){
return;
}
int left=first;
int right=last;
int x=arr[left];
while(left<right){
while(left<right&&arr[right]>=x){
right--;
}
if(left<right){
arr[left++]=arr[right];
}
while (left<right&&arr[left]<x){
left++;
}
if(left<right){
arr[right--]=arr[left];
}
}
arr[left]=x;
sort(arr,first,left-1);
sort(arr,left+1,last);
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
交换排序 | 快速排序 | O(Nlog2N) | O(N2) | O(Nlog2N) | O(Nlog2N) | 不稳定 | 较复杂 |
时间复杂度
当数据有序时,以第一个关键字为基准分为两个子序列,前一个子序列为空,此时执行效率最差。
而当数据随机分布时,以第一个关键字为基准分为两个子序列,两个子序列的元素个数接近相等,此时执行效率最好。
所以,数据越随机分布时,快速排序性能越好;数据越接近有序,快速排序性能越差。
空间复杂度
快速排序在每次分割的过程中,需要 1 个空间存储基准值。而快速排序的大概需要 Nlog2N次的分割处理,所以占用空间也是 Nlog2N 个。
算法稳定性
在快速排序中,相等元素可能会因为分区而交换顺序,所以它是不稳定的算法。
6、堆排序
实现
/**
* 二叉堆是完全二叉树或者是近似完全二叉树。
* 二叉堆满足二个特性:
* 1.父结点的键值总是大于或等于(小于或等于)任何一个子节点的键值。
* 2.每个结点的左子树和右子树都是一个二叉堆(都是最大堆或最小堆)。
*
* 当父结点的键值总是大于或等于任何一个子节点的键值时为最大堆。
* 当父结点的键值总是小于或等于任何一个子节点的键值时为最小堆。
*/
public class HeapSort implements Sort {
@Override
public void sort(int[] arr) {
//建堆
buildHeap(arr);
// 下面,开始排序逻辑
for (int j = arr.length - 1; j > 0; j--) {
// 元素交换
// 说是交换,其实质就是把大顶堆的根元素,放到数组的最后;
// 换句话说,就是每一次的堆调整之后,都会有一个元素到达自己的最终位置
swap(arr, 0, j);
// 元素交换之后,毫无疑问,最后一个元素无需再考虑排序问题了。
// 接下来我们需要排序的,就是已经去掉了部分元素的堆了,这也是为什么此方法放在循环里的原因
// 而这里,实质上是自上而下,自左向右进行调整的
minHeapFixdown(arr, 0, j);
}
}
/**
* 构建堆
*/
public void buildHeap(int[] arr) {
// 按照完全二叉树的特点,从最后一个非叶子节点开始,对于整棵树进行大根堆的调整
// 也就是说,是按照自下而上,每一层都是自右向左来进行调整的
// 注意,这里元素的索引是从0开始的
// 另一件需要注意的事情,这里的建堆,是用堆调整的方式来做的
// 堆调整的逻辑在建堆和后续排序过程中复用的
for (int i = arr.length / 2 - 1; i >= 0; i--) {
minHeapFixdown(arr, i, arr.length);
}
}
/**
* 从i节点开始调整,n为节点总数 从0开始计算 i节点的子节点为 2*i+1, 2*i+2
* 按定义,堆中每次都只能删除第0个数据。
* 为了便于重建堆,实际的操作是将最后一个数据的值赋给根结点,然后再从根结点开始进行一次从上向下的调整。
* 调整时先在左右儿子结点中找最小的,如果父结点比这个最小的子结点还小说明不需要调整了,
* 反之将父结点和它交换后再考虑后面的结点。相当于从根结点将一个数据的“下沉”过程。
*/
private void minHeapFixdown(int[] a, int i, int n) {
int temp = a[i];
int j = 2 * i + 1;
while (j < n) {
if (j + 1 < n && a[j + 1] < a[j]) {
j++;
}
if (a[j] >= temp) {
break;
}
a[i] = a[j];
i = j;
j = 2 * i + 1;
}
a[i] = temp;
}
/**
* 堆删除
* 在最小堆中删除数
*/
public void minHeapDeleteNumber(int arr[], int n) {
swap(arr, 0, n - 1);
minHeapFixdown(arr, 0, n - 1);
}
/**
* 新加入i结点 其父结点为(i - 1) / 2
* 每次插入都是将新数据放在数组最后。可以发现从这个新数据的父结点到根结点必然为一个有序的数列,
* 现在的任务是将这个新数据插入到这个有序数据中——这就类似于直接插入排序中将一个数据并入到有序区间中
*/
private void minHeapFixup(int arr[], int i) {
for (int j = (i - 1) / 2; (j >= 0 && i != 0) && arr[i] > arr[j]; i = j, j = (i - 1) / 2){
swap(arr,i,j);
}
}
/**
* 在最小堆中加入新的数据nNum
*/
public void MinHeapAddNumber(int arr[], int n, int nNum){
arr[n] = nNum;
minHeapFixup(arr, n);
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
选择排序 | 堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 | 较复杂 |
时间复杂度
堆的存储表示是顺序的。因为堆所对应的二叉树为完全二叉树,而完全二叉树通常采用顺序存储方式。
当想得到一个序列中第k个最小的元素之前的部分排序序列,最好采用堆排序。
因为堆排序的时间复杂度是O(n+klog2n),若k≤n/log2n,则可得到的时间复杂度为O(n)。
算法稳定性
堆排序是一种不稳定的排序方法。
因为在堆的调整过程中,关键字进行比较和交换所走的是该结点到叶子结点的一条路径,
因此对于相同的关键字就可能出现排在后面的关键字被交换到前面来的情况。
7、归并排序
/**
* 将待排序序列R[0...n-1]看成是n个长度为1的有序序列,将相邻的有序表成对归并,得到n/2个长度为2的有序表;
* 将这些有序序列再次归并,得到n/4个长度为4的有序序列;如此反复进行下去,最后得到一个长度为n的有序序列。
*/
public class MergeSort implements Sort {
/**
* 归并排序其实要做两件事:
* (1)“分解”——将序列每次折半划分。
* (2)“合并”——将划分后的序列段两两合并后排序。
* 我们先来考虑第二步,如何合并?
* 在每次合并过程中,都是对两个有序的序列段进行合并,然后排序。
* 这两个有序序列段分别为 R[low, mid] 和 R[mid+1, high]。
* 先将他们合并到一个局部的暂存数组R2中,带合并完成后再将R2复制回R中。
* 为了方便描述,我们称 R[low, mid] 第一段,R[mid+1, high] 为第二段。
* 每次从两个段中取出一个记录进行关键字的比较,将较小者放入R2中。最后将各段中余下的部分直接复制到R2中。
* 经过这样的过程,R2已经是一个有序的序列,再将其复制回R中,一次合并排序就完成了。
*/
@Override
public void sort(int[] arr) {
sort(arr,0,arr.length-1,new int[arr.length]);
}
private void sort(int[] arr,int first,int last,int[] tmp){
if(first<last){
int mid=(first+last)/2;
//左边有序
sort(arr,first,mid,tmp);
//右边有序
sort(arr,mid+1,last,tmp);
//再将二个有序数列合并
mergeSort(arr,first,mid,last,tmp);
}
}
private void mergeSort(int[] arr, int first, int mid, int last, int[] tmp) {
int left=first;
int right=mid+1;
int k=0;
while (left<=mid&&right<=last){
if(arr[left]<=arr[right]){
tmp[k++]=arr[left++];
}else {
tmp[k++]=arr[right++];
}
}
while (left<=mid){
tmp[k++]=arr[left++];
}
while (right<=last){
tmp[k++]=arr[right++];
}
for(int i=0;i<k;i++){
arr[first+i]=tmp[i];
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
归并排序 | 归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 | 较复杂 |
时间复杂度
归并排序的形式就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的可以得出它的时间复杂度是O(n*log2n)。
空间复杂度
由前面的算法说明可知,算法处理过程中,需要一个大小为n的临时存储空间用以保存合并序列。
算法稳定性
在归并排序中,相等的元素的顺序不会改变,所以它是稳定的算法。
归并排序和堆排序、快速排序的比较
若从空间复杂度来考虑:首选堆排序,其次是快速排序,最后是归并排序。
若从稳定性来考虑,应选取归并排序,因为堆排序和快速排序都是不稳定的。
若从平均情况下的排序速度考虑,应该选择快速排序
8、基数排序
要点
基数排序与本系列前面讲解的七种排序方法都不同,它不需要比较关键字的大小。
它是根据关键字中各位的值,通过对排序的N个元素进行若干趟“分配”与“收集”来实现排序的。
不妨通过一个具体的实例来展示一下,基数排序是如何进行的。
设有一个初始序列为: R {50, 123, 543, 187, 49, 30, 0, 2, 11, 100}。
我们知道,任何一个阿拉伯数,它的各个位数上的基数都是以0~9来表示的。
所以我们不妨把0~9视为10个桶。
我们先根据序列的个位数的数字来进行分类,将其分到指定的桶中。例如:R[0] = 50,个位数上是0,将这个数存入编号为0的桶中。
分类后,我们在从各个桶中,将这些数按照从编号0到编号9的顺序依次将所有数取出来。
这时,得到的序列就是个位数上呈递增趋势的序列。
按照个位数排序: {50, 30, 0, 100, 11, 2, 123, 543, 187, 49}。
接下来,可以对十位数、百位数也按照这种方法进行排序,最后就能得到排序完成的序列。
实现
public class RadixSort implements Sort{
@Override
public void sort(int[] arr) {
radixSort(arr, 0, arr.length - 1, 4);
}
/**
* 获取x这个数的d位数上的数字
* 比如获取123的1位数,结果返回3
*/
public int getDigit(int x, int d) {
// 本实例中的最大数是百位数,所以只要到1000就可以了
int[] a = {1, 1, 10, 100, 1000};
return ((x / a[d]) % 10);
}
private void radixSort(int[] list, int begin, int end, int digit) {
// 基数
final int radix = 10;
int i ,j ;
// 存放各个桶的数据统计个数
int[] count = new int[radix];
int[] bucket = new int[end - begin + 1];
// 按照从低位到高位的顺序执行排序过程
for (int d = 1; d <= digit; d++) {
// 置空各个桶的数据统计
for (i = 0; i < radix; i++) {
count[i] = 0;
}
// 统计各个桶将要装入的数据个数
for (i = begin; i <= end; i++) {
j = getDigit(list[i], d);
count[j]++;
}
// count[i]表示第i个桶的右边界索引
for (i = 1; i < radix; i++) {
count[i] = count[i] + count[i - 1];
}
// 将数据依次装入桶中
// 这里要从右向左扫描,保证排序稳定性
for (i = end; i >= begin; i--) {
// 求出关键码的第k位的数字, 例如:576的第3位是5
j = getDigit(list[i], d);
// 放入对应的桶中,count[j]-1是第j个桶的右边界索引
bucket[count[j] - 1] = list[i];
// 对应桶的装入数据索引减一
count[j]--;
}
// 将已分配好的桶中数据再倒出来,此时已是对应当前位数有序的表
for (i = begin, j = 0; i <= end; i++, j++) {
list[i] = bucket[j];
}
}
}
}
算法分析
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
基数排序 | 基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(n+r) | 稳定 | 较复杂 |
时间复杂度
通过上文可知,假设在基数排序中,r为基数,d为位数。则基数排序的时间复杂度为O(d(n+r))。
我们可以看出,基数排序的效率和初始序列是否有序没有关联。
空间复杂度
在基数排序过程中,对于任何位数上的基数进行“装桶”操作时,都需要n+r个临时空间。
算法稳定性
在基数排序过程中,每次都是将当前位数上相同数值的元素统一“装桶”,并不需要交换位置。所以基数排序是稳定的算法。
9、总结
测试程序
接口类Sort:
public interface Sort {
void sort(int[] arr);
/**
* 交换数组中两个元素位置
*/
default void swap(int[] arr, int i, int j) {
arr[i] ^= arr[j];
arr[j] ^= arr[i];
arr[i] ^= arr[j];
}
}
测试程序:
public class Main {
public static void main(String[] args) {
//测试数组的大小
int size=10000;
int[] arr=random(size);
System.out.println(Arrays.toString(arr));
test(new BubbleSort(),Arrays.copyOf(arr,size));
test(new InsertSort(),Arrays.copyOf(arr,size));
test(new SelectSort(),Arrays.copyOf(arr,size));
test(new ShellSort(),Arrays.copyOf(arr,size));
test(new MergeSort(),Arrays.copyOf(arr,size));
test(new QuickSort(),Arrays.copyOf(arr,size));
test(new HeapSort(),Arrays.copyOf(arr,size));
test(new RadixSort(),Arrays.copyOf(arr,size));
}
private static void test(Sort sort,int[] arr){
long start=System.currentTimeMillis();
sort.sort(arr);
long end=System.currentTimeMillis();
System.out.println(Arrays.toString(arr));
System.out.println(sort.getClass().getSimpleName()+":"+(end-start));
}
private static int[] random(int size){
final Random r=new Random();
int[] arr=new int[size];
for(int i=0;i<size;i++){
arr[i]=r.nextInt(size);
}
return arr;
}
}
运行结果:
BubbleSort:260
InsertSort:68
SelectSort:69
ShellSort:6
MergeSort:3
QuickSort:3
HeapSort:6
RadixSort:8
各种排序的基本性能
排序类别 | 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | ||
平均情况 | 最坏情况 | 最好情况 | |||||
交换排序 | 冒泡排序 | O(n2) | O(n2) | O(n) | O(1) | 稳定 | 简单 |
快速排序 | O(nlog2n) | O(n2) | O(nlog2n) | O(nlog2n) | 不稳定 | 较复杂 | |
插入排序 | 直接插入排序 | O(n2) | O(n2) | O(n) | O(1) | 稳定 | 简单 |
希尔排序 | O(nlog2n) | O(n1.5) | O(1) | 不稳定 | 较复杂 | ||
选择排序 | 简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 | 简单 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 | 较复杂 | |
归并排序 | 归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 | 较复杂 |
基数排序 | 基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(n+r) | 稳定 | 较复杂 |