八大排序算法Java实现
什么是排序?
-
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。
-
排序分为内部排序和外部排序。
-
若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。
-
反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。
排序的分类
算法分析
1、冒泡排序
基本思想
- 冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
算法描述
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
动态效果如下
Java代码实现
/*
* 冒泡排序
* 相邻元素比较,大的元素往后调
*/
public static void bubbleSort(int array[]){
for(int i = array.length - 1 ; i >= 0 ; i--){
boolean flag = false; //设置一趟排序是否有交换的标识
for(int j = 0 ; j < i ; j++){ //一趟冒泡排序
if(array[j] > array[j+1]){
swap(array, j, j+1);
flag = true; //标识发生了交换
}
}
if(!flag)
break;
}
}
比较与总结
- 由于冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法。
2、选择排序
基本思想
-
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
-
选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对 n个元素的表进行排序总共进行至多 n-1 次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
算法描述
- 从未排序序列中,找到关键字最小的元素
- 如果最小元素不是未排序序列的第一个元素,将其和未排序序列第一个元素互换
- 重复1、2步,直到排序结束。
动图效果如下
Java代码实现
/*
* 选择排序
* 每个位置选择当前元素最小的
*/
public static void selectSort(int array[]){
for(int i = 0 ; i < array.length-1 ; i++){
int minPosition = i;
int min = array[i];
for(int j = i+1 ; j <array.length ; j++){
if(array[j] < min){
min = array[j];
minPosition = j;
}
}
//若i不是当前元素最小的,则和找到的那个元素交换
if(i != minPosition){
array[minPosition] = array[i];
array[i] = min;
}
}
}
比较与总结
- 选择排序的简单和直观名副其实,这也造就了它”出了名的慢性子”,无论是哪种情况,哪怕原数组已排序完成,它也将花费将近n²/2次遍历来确认一遍。即便是这样,它的排序结果也还是不稳定的。 唯一值得高兴的是,它并不耗费额外的内存空间。
3、插入排序
基本思想
- 通常人们整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了要给插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。
算法描述
- 一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后
- 重复步骤2~5
动态效果如下
Java代码实现
/*
* 插入排序
* 已经有序的小序列的基础上,一次插入一个元素
*/
public static void insertSort(int array[]){
for(int i = 1 ; i < array.length ; i++){
int current = array[i]; //待排元素
int j = i;
for(; j > 0 && array[j - 1] > current ; j--){
//向前扫描,只要发现待排元素比较小,就插入
array[j] = array[j - 1]; //移出空位
}
array[j] = current; //元素插入
}
}
比较与总结
- 插入排序所需的时间取决于输入元素的初始顺序。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比随机顺序的数组或是逆序数组进行排序要快得多。
4、快速排序
基本思想
-
快速排序的基本思想:挖坑填数+分治法。
-
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
-
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
-
快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好。
算法描述
快速排序使用分治策略来把一个序列(list)分为两个子序列(sub-lists)。步骤为:
- 从数列中挑出一个元素,称为"基准"(pivot)。
- 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
动态效果如下
Java代码实现
/*
* 快速排序
* 两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],
* 其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]
* 如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j
* 交换a[j]和a[center_index],完成一趟快速排序
* 枢轴采用三数取中法可以优化
*/
//递归快速排序
public static void quickSort(int a[]){
qSort(a, 0, a.length - 1);
}
//非递归快速排序,手动利用栈来存储每次分块快排的起始点,栈非空时循环获取中轴入栈
public static void quickSortNonRecursion(int array[]){
if (array == null || array.length == 1) return;
//存放开始与结束索引
Stack<Integer> s = new Stack<Integer>();
//压栈
s.push(0);
s.push(array.length - 1);
//利用循环里实现
while (!s.empty()) {
int right = s.pop();
int left = s.pop();
//如果最大索引小于等于左边索引,说明结束了
if (right <= left) continue;
int i = partition(array, left, right);
if (left < i - 1) {
s.push(left);
s.push(i - 1);
}
if (i + 1 < right) {
s.push(i+1);
s.push(right);
}
}
}
//递归排序,利用两路划分
public static void qSort(int a[],int low,int high){
int pivot = 0;
if(low < high){
//将数组一分为二
pivot = partition(a,low,high);
//对第一部分进行递归排序
qSort(a,low,pivot);
//对第二部分进行递归排序
qSort(a,pivot + 1,high);
}
}
//partition函数
public static int partition(int a[],int low,int high){
int pivotkey = a[low]; //选取第一个元素为枢轴记录
while(low < high){
//将比枢轴记录小的交换到低端
while(low < high && a[high] >= pivotkey){
high--;
}
//采用替换而不是交换的方式操作
a[low] = a[high];
//将比枢轴记录大的交换到高端
while(low < high && a[low] <= pivotkey){
low++;
}
a[high] = a[low];
}
//枢纽所在位置赋值
a[low] = pivotkey;
//返回枢纽所在的位置
return low;
}
比较与总结
5、归并排序
基本思想
- 归并排序算法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
算法描述
归并排序可通过两种方式实现:
- 自上而下的递归
- 自下而上的迭代
递归法(假设序列共有n个元素):
- 将序列每相邻两个数字进行归并操作,形成 floor(n/2)个序列,排序后每个序列包含两个元素;
- 将上述序列再次归并,形成 floor(n/4)个序列,每个序列包含四个元素;
- 重复步骤2,直到所有元素排序完毕。
迭代法
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
- 重复步骤3直到某一指针到达序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
动态效果如下
Java代码实现
/*
* 归并排序
* 把序列递归地分成短序列
* 递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),
* 然后把各个有序的短序列合并成一个有序的长序列,不断合并直到原序列全部排好序
*/
//将有二个有序数列a[first...mid]和a[mid+1...last]合并。
public static void merge(int a[], int first, int mid, int last, int temp[]){
int i = first,j = mid+1;
int k = 0;
while(i <= mid && j<= last){
if(a[i]<a[j])
temp[k++] = a[i++];
else
temp[k++] = a[j++];
}
while(i <= mid)
temp[k++] = a[i++];
while(j <= last)
temp[k++] = a[j++];
for(i = 0 ; i < k ; i++)
a[first+i] = temp[i];
}
//递归合并排序
public static void mSort(int a[], int first,int last, int temp[]){
if(first < last){
int mid = (first + last) / 2;
mSort(a, first, mid, temp);
mSort(a, mid+1, last, temp);
merge(a, first, mid, last, temp);
}
}
//提供通用归并排序接口
public static void mergeSort(int a[]){
int[] temp = new int[a.length];
mSort(a, 0, a.length-1, temp);
}
比较与总结
- 归并排序是建立在归并操作上的一种有效的排序算法,1945年由约翰·冯·诺伊曼首次提出。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
6、希尔排序
基本思想
-
将待排序数组按照步长gap进行分组,然后将每组的元素利用直接插入排序的方法进行排序;每次再将gap折半减小,循环上述操作;当gap=1时,利用直接插入,完成排序。
-
可以看到步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。一般来说最简单的步长取值是初次取数组长度的一半为增量,之后每次再减半,直到增量为1。
算法描述
- 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
- 按增量序列个数 k,对序列进行 k 趟排序;
- 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
效果图如下
Java代码实现
/*
* 希尔排序
* 按照不同步长对元素进行插入排序
* 插入排序的一种
*/
public static void shellSort(int a[]){
if(a == null || a.length == 0){
return;
}
int len = a.length;
//初始化增量
int inc = len;
do{
//增量变化规则
inc = inc / 3 + 1;
for(int i = inc; i < len; i++){
//待排元素
int cur = a[i];
int j = i;
//向前扫描,只要发现待排元素比较小,就插入
for(; j >= inc && a[j - inc] > cur; j -= inc){
//移除空位
a[j] = a[j - inc];
}
//元素插入
a[j] = cur;
}
}while(inc > 1);
}
比较与总结
- 希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
7、堆排序
1991年的计算机先驱奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同发明了著名的堆排序算法(Heap Sort)
基本思想
- 此处以大顶堆为例,堆排序的过程就是将待排序的序列构造成一个堆,选出堆中最大的移走,再把剩余的元素调整成堆,找出最大的再移走,重复直至有序。
算法描述
- 先将初始序列K[1…n]K[1…n]建成一个大顶堆, 那么此时第一个元素K1K1最大, 此堆为初始的无序区.
- 再将关键字最大的记录K1K1 (即堆顶, 第一个元素)和无序区的最后一个记录 KnKn 交换, 由此得到新的无序区K[1…n−1]K[1…n−1]和有序区K[n]K[n], 且满足K[1…n−1].keys⩽K[n].keyK[1…n−1].keys⩽K[n].key
- 交换K1K1 和 KnKn 后, 堆顶可能违反堆性质, 因此需将K[1…n−1]K[1…n−1]调整为堆. 然后重复步骤2, 直到无序区只有一个元素时停止。
动图效果如下
Java代码实现
/*
* 堆排序
* 调整最大堆,交换根元素和最后一个元素。
* 参数说明:
* a -- 待排序的数组
*/
public static void heapSort(int[] a) {
if(a == null || a.length == 0){
return;
}
int len = a.length;
//从尾部开始,调整成最大堆
for(int i = len / 2 - 1; i >= 0; i--){
maxHeapDown(a, i, len - 1);
}
//从最后一个元素开始对序列进行调整,不断缩小调整的范围直到第一个元素
for(int i = len - 1; i >= 0; i--){
//交换a[0]和a[i]。交换后,a[i]是a[0..i]中最大
int tmp = a[0];
a[0] = a[i];
a[i] = tmp;
//调整a[0..i - 1],使得a[0..i - 1]仍然是一个最大堆
maxHeapDown(a, 0, i - 1);
}
}
/*
* 注:数组实现的堆中,第N个节点的左孩子的索引值是(2N+1),右孩子的索引是(2N+2)。
* 其中,N为数组下标索引值,如数组中第1个数对应的N为0。
*
* 参数说明:
* a -- 待排序的数组
* lo -- 被下调节点的起始位置(一般为0,表示从第1个开始)
* hi -- 截至范围(一般为数组中最后一个元素的索引)
*/
private static void maxHeapDown(int[] a, int lo, int hi){
//记录当前结点位置
int curIndex = lo;
//记录左孩子结点
int left = 2 * curIndex + 1;
//记录当前结点的值
int curVal = a[curIndex];
//保证curIndex,leftIndex,rightIndex中,curIndex对应的值最大
for(; left <= hi; curIndex = left, left = 2 * left + 1){
//左右孩子中选择较大者
if(left < hi && a[left] < a[left + 1]){
left++;
}
if(curVal >= a[left]){
break;
}else{
a[curIndex] = a[left];
a[left] = curVal;
}
}
}
比较与总结
- 由于堆排序中初始化堆的过程比较次数较多, 因此它不太适用于小序列。 同时由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了, 因此, 它是不稳定的排序。
8、基数排序
基本思想
- 它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序按照优先从高位或低位来排序有两种实现方案:
- MSD(Most significant digital) 从最左侧高位开始进行排序。先按k1排序分组, 同一组中记录, 关键码k1相等, 再对各组按k2排序分成子组, 之后, 对后面的关键码继续这样的排序分组, 直到按最次位关键码kd对各子组排序后. 再将各组连接起来, 便得到一个有序序列。MSD方式适用于位数多的序列。
- LSD (Least significant digital)从最右侧低位开始进行排序。先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便得到一个有序序列。LSD方式适用于位数少的序列。
算法描述
我们以LSD为例,从最低位开始,具体算法描述如下:
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
动态效果图如下
Java代码实现
基数排序:通过序列中各个元素的值,对排序的N个元素进行若干趟的“分配”与“收集”来实现排序。
- 分配:我们将L[i]中的元素取出,首先确定其个位上的数字,根据该数字分配到与之序号相同的桶中
- 收集:当序列中所有的元素都分配到对应的桶中,再按照顺序依次将桶中的元素收集形成新的一个待排序列L[]。对新形成的序列L[]重复执行分配和收集元素中的十位、百位…直到分配完该序列中的最高位,则排序结束
/*
* 基数排序
* 按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位
*/
public static void radixSort(int[] array,int d)
{
int n=1; //代表位数对应的数:1,10,100...
int k=0; //保存每一位排序后的结果用于下一位的排序输入
int length=array.length;
int[][] bucket=new int[10][length]; //排序桶用于保存每次排序后的结果,这一位上排序结果相同的数字放在同一个桶里
int[] order=new int[length]; //用于保存每个桶里有多少个数字
while(n<d)
{
for(int num:array) //将数组array里的每个数字放在相应的桶里
{
int digit=(num/n)%10;
bucket[digit][order[digit]]=num;
order[digit]++;
}
for(int i=0;i<length;i++) //将前一个循环生成的桶里的数据覆盖到原数组中用于保存这一位的排序结果
{
if(order[i]!=0) //这个桶里有数据,从上到下遍历这个桶并将数据保存到原数组中
{
for(int j=0;j<order[i];j++)
{
array[k]=bucket[i][j];
k++;
}
}
order[i]=0; //将桶里计数器置0,用于下一次位排序
}
n*=10;
k=0; //将k置0,用于下一轮保存位排序结果
}
}
比较与总结
-
基数排序更适合用于对时间, 字符串等这些 整体权值未知的数据 进行排序。
-
基数排序不改变相同元素之间的相对顺序,因此它是稳定的排序算法。
-
基数排序 vs 计数排序 vs 桶排序
-
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
-
基数排序:根据键值的每位数字来分配桶
-
计数排序:每个桶只存储单一键值
-
桶排序:每个桶存储一定范围的数值
-
八大排序算法总结
各种排序性能对比如下:
排序类型 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
直接插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
折半插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
希尔排序 | O(n^1.3) | O(nlogn) | O(n²) | O(1) | 不稳定 |
归并排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(n) | 稳定 |
快速排序 | O(nlog₂n) | O(nlog₂n) | O(n²) | O(nlog₂n) | 不稳定 |
堆排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(1) | 不稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
桶排序 | O(n+k) | O(n+k) | O(n²) | O(n+k) | (不)稳定 |
基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+kd)) | O(n+kd) | 稳定 |
从时间复杂度来说:
- 平方阶O(n²)排序:各类简单排序:直接插入、直接选择和冒泡排序
- 线性对数阶O(nlog₂n)排序:快速排序、堆排序和归并排序
- O(n1+§))排序,§是介于0和1之间的常数:希尔排序
- 线性阶O(n)排序:基数排序,此外还有桶、箱排序
论是否有序的影响:
- 当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
- 而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为O(n2);
- 原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。