排序算法的分类
-
按照是否借助外部存储
- 内部排序
占用内存空间排序,适用于数据量比较小的情况。基于内存的排序能解决大部分的排序问题,大多数的排序算法也是基于内存层面的内部排序算法。 - 外部排序
适用于数据量很大的情况,借助外部存储和内存相结合来对相关记录进行排序。
- 内部排序
-
按照排序的思想(以下均为内部排序)
- 插入排序(直接插入排序,希尔排序)
- 交换排序(冒泡排序,快速排序)
- 选择排序(简单选择排序,堆排序)
- 归并排序(二路归并,多路归并)
- 线性时间排序(桶排序,基数排序,计数排序)
- …
算法的时间复杂度
- 一个算法中语句执行次数称为语句频度或时间频度,记作T(n)
- 常数项可以忽略/低阶可以忽略/系数可以忽略
- T(n) = O(f(n)) f(n)和T(n)是同数量级函数 ,O(f(n)) 称为渐进的时间复杂度
- 在排序算法中,我们接下来会分析他们各自的平均时间复杂度/最坏时间复杂度/最好时间复杂度。
各种排序算法的比较
交换排序
所谓交换排序,顾名思义,它的思想在于每次通过两个元素的交换,最终达到排序的目的。经典的交换排序分为 冒泡排序 和快速排序。
冒泡排序
-
算法思想:从左到右,将数组(或一组记录)的每两个数依次进行比较,如果前一个数大于后一个数,则将两数进行位置交换。这样,每一趟后,较大的数就慢慢往后移,较小的数慢慢往前移,就向冒水泡一样,所以称为冒泡排序。直观来看,通过每一次排序,都会将一个最大的数通过两两交换的方式移动到数组的尾部。
-
伪代码
BubbleSort(Arr)
1. for i ⬅1 to length(Arr)
2. do for int j⬅length(Arr) down to i+1
3. do if Arr[j]<Arr[j-1]
4. then exchange A[j] and A[j-1]
- 算法描述:
- 每一趟,比较相邻的元素,如果前一个比后一个大,交换之
- 第1趟,在数组第一个元素到第length(Arr)个元素依次两两比较,如果满足交换条件则交换。第1趟完毕,数组中最大的数就移动到了数组尾部。第2趟,在数组第一个元素到第length(Arr)-1个元素依次两两比较,如果满足交换条件则交换。第2趟完毕,数组中第2大的数就移动到了数组尾部第length(A)-1的位置,…,经过N-1趟交换,数组就变为了有序数组。
- 动态展示(来源于参考文献)
- Java代码
/**
* 冒泡排序
* @param arr 非有序数组
*/
public static void sort(int[] arr)
{
for(int i=0;i<arr.length-1;i++)
{
for(int j=0;j<arr.length-1-i;j++)
{
if(arr[j]>arr[j+1])
{
int temp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = temp;
}
}
}
}
这里,第二层for循环j<arr.length-1-i需要-1是由于在比较两两大小时,我们时用第j个数和第j+1个数比较。我们知道冒泡排序还有种优化。存在这么一种情况,就是经过某趟排序后,两两之间一次交换都没有发生,说明此时数组已经变得有序了,我们就可以直接break不用再进行下一趟了。但是,由于if判断也是需要时间消耗的,所以有时候这个优化并不能带来实质性的效率提升。实现起来,我们只需要加上一个判断条件,设置一个标志flag来判断每一趟是否发生交换即可,如下;
/**
* 优化的冒泡排序,如果某趟排序没有进行交换,则直接break
* @param arr 非有序数组
*/
public static void refinedSort(int[] arr)
{
boolean flag = true;
for(int i=0;i<arr.length-1;i++)
{
for(int j=0;j<arr.length-1-i;j++)
{
if(arr[j]>arr[j+1])
{
int temp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = temp;
flag = false;
}
}
if(flag)
{
break;
}else {
flag = true;
}
}
}
当然,为了算法的严谨性,我们也可以额外考虑数组为空的情况。在这里,可以,但没必要,哈哈。很容易看出来冒泡排序具有以下特征:
1. 时间复杂度:
最好: 数组已经有序 O(1) 【对于优化后的冒牌排序】
最坏: 数组逆序 O(n^2)
平均:O(n^2)
2. 空间复杂度:O(1) ,是基于原地排序的,没有辅助空间的消耗
3. 稳定排序:即对于原始数组中两个相同元素,他们的先后位置再排序前后不会发生改变。
快速排序
- 基本思想:每一趟,选择一个基准,将要排序的数据分为两部分,其中左边部分数据都比右边部分数据小。对这两部分继续进行递归,以达到排序的目的。
/**
* 快速排序的递归解法
* 每次以中轴的值作为基准来分成左右两堆
* @param arr 原始数组
* @param left 左下表索引
* @param right 右下标索引
*/
public static void sort(int[] arr, int left,int right)
{
int l = left;
int r = right;
int pivot = arr[(left + right)/2];
while(l<r)
{
while(arr[l]<pivot)
{
l ++;
}
while(arr[r]>pivot)
{
r --;
}
// 已经分成两组完毕
if(l >= r)
{
break;
}
// 交换操作
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
// 判断交换的两个值中是否有值和pivot指向的基准值相同 例如 5 5 5这种情况,会死循环
// 此时可以理解为改变了以下pivot为一个和他相同的值,相当于中间值的位置改变了
if(arr[l] >= pivot)
{
r--;
}
if(arr[r] == pivot)
{
l++;
}
}
// 开始递归操作
// 如果l==r,必须r++,l--,否则会出现栈溢出
if(l == r)
{
l++;
r--;
}
// 向左递归
if(left < r)
{
sort(arr,left,r);
}
// 向右递归
if(right > r)
{
sort(arr, l, right);
}
}
选择排序
选择排序的主要思想是每一趟选择一个最大/最小的值移动到数组的头部或尾部,经过length(arr)-1趟,整个数组就变为有序数组了。选择排序分为 简单选择排序 和 堆排序。
简单选择排序
- 基本思想
选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
第一次:从arr[0] - arr[n-1] 中选择最小数,与arr[0]交换
第二次: 从arr[1] - arr[n-1] 中选择最小数, 与arr【1】交换
....
第n-1次: 从arr[n-2] - arr[n-1] 中选择最小数, 与arr【n-2】交换.
算法结束。
冒泡排序和选择排序很像,选择排序性能更高,因为选择排序每次比较后不会立即进行交换,
要找到最小的那个才会进行交换操作。
- 动态展示
/**
* 选择排序,每次选择一个最小的元素与数组前面的元素交换
* @param arr 非有序数组
*/
public static void sort(int[] arr)
{
for(int i=0;i<arr.length-1;i++)
{
int minTemp = arr[i];
int minIndex = i;
for(int j=i;j<arr.length;j++)
{
if(arr[j] < minTemp)
{
minTemp = arr[j];
minIndex = j;
}
}
// 交换最小值和当前的i
if(minIndex !=i)
{
int temp = arr[i];
arr[i] = minTemp;
arr[minIndex] = temp;
}
}
}
堆排序
插入排序
插入排序的思想在于将数组看作一组有序记录和一组非有序记录,每次从非有序数组中取出一个插入到有序记录的合适位置。 插入排序分为 直接插入排序 和 希尔排序(缩小增量排序)。
直接插入排序
- 基本思想:把N个待排序的元素看作一个有序表和一个无序表,开始时有序表只包含一个元素,无序表包含剩下的n-1个元素。排序过程中每次从无序表中选择一个插入到有序表中,形成一个新的有序表。
- 思想很简单,就像打扑克牌每摸一张牌就像手中已经排好序的牌中找一个合适的位置插入,使手中的牌仍然是有序的。
初始状态:有序记录:arr[0] 无序记录:剩下的n-1个元素
每一次,从剩下的元素中取一个,再有序记录中找一个合适的位置插入该元素,就形成了一个新的有序记录。
1. 取出下一个元素,在已经排序的元素序列中从后向前扫描;
2. 如果该元素(已排序)大于新元素,将该元素移到下一位置;
3. 重复步骤2,直到找到已排序的元素小于或者等于新元素的位置;
4. 将新元素插入到该位置后;
5. 重复步骤1-4直到数组有序
- 动态展示
- 实现如下:
public class MyInsertSorter {
/**
* 插入排序 有序数组+无序数组结合
* @param arr 原始数组
*/
public static int[] sort(int[] arr)
{
int[] helper = new int[arr.length];
for(int i=0;i<arr.length;i++)
{
if(i == 0)
{
helper[0] = arr[0];
}else {
int j ;
// 查找插入位置
for(j=0;j<i;j++)
{
if(arr[i]<helper[j])
{
break;
}
}
// 从插入位置到i的所有元素后移
for(int m=j;m<i;m++)
{
helper[m+1] = helper[m];
}
// 插入当前元素
helper[j] = arr[i];
}
}
return helper;
}
}
插入排序其实不需要辅助空间,这里我用了辅助空间是为了便于理解。
更正宗的插入排序应该是下面这样的
/**
* 直接插入排序
* @param arr
*/
public static void insertSort(int[] arr)
{
for(int i=1;i<arr.length;i++)
{
int index = i-1;
int temp = arr[i];
while(index>0 && arr[index]>temp)
{
arr[index+1] = arr[index];
index --;
}
arr[index+1]= temp;
}
}
希尔排序
-
直接插入排序的缺点
对于数组 2 3 4 5 6 1 (最差情况: 逆序)。当插入的数是较小数比如1时,后移的次数会明显增多,影响了效率。因此,引入希尔排序(缩小增量排序)。 -
希尔排序思想
希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序,同时该算法是冲破O(n2)的第一批算法之一。 希尔排序把一组记录按下标的一定增量分组,对每组采用直接插入排序进行排序;随着增量逐渐减小,每组包含的关键词越来越多,当增量减小到1时,整个记录恰好被分为一组,算法终止。它与插入排序的不同之处在于,它会优先比较距离较远的元素。- 例子: 8 9 1 7 2 3 5 4 6 0
初始增量:gap = length /2 = 5 分为5组 【8 3】【9 5】【1 4】【7 6】【2 0】
直接插入排序:3 4 1 6 0 8 9 4 7 2
第二次增量: 5/2 = 2 分为两组【3 1 0 9 7】【4 6 8 4 2】
直接插入排序:0 2 1 4 3 5 7 6 9 8
第三次增量: 2/2 = 1 分为1组【0 2 1 4 3 5 7 6 9 8】
直接插入排序,即完成了整个记录的排序
- 例子: 8 9 1 7 2 3 5 4 6 0
-
动态展示
这里,颜色相同的为一组。
第一次:gap = 10/2 = 5 组,对每组分别进行直接插入排序
第二次:gap = 5/2 = 2组,对每组分别进行直接插入排序
第三次:gap = 2/2 = 1组,对每组分别进行直接插入排序
这样,经过三轮直接插入排序,算法终止。 我们可以看出希尔排序的优点在于 每经过一轮缩小增量的直接插入排序,整个数组会变得相对越来越有序,下一次直接插入排序真正进行的元素后移动作会不断减少。
希尔排序在对有序序列进行插入时,分为交换法和移动法。
- 交换法(每次都要进行交换,效率较低)
- 移动法(有优化,效率较高)
public class MyShellSorter {
/**
* 希尔排序,采用 依次交换法
* 反而会比简单插入排序慢,因为交换次数太多了
* @param arr
*/
public static void sort(int[] arr)
{
// 初始化增量为len/2
int gap = arr.length/2;
while(gap >=1 )
{
for(int i=gap;i<arr.length;i++)
{
for(int j=i-gap;j>=0;j-=gap)
{
if(arr[j] > arr[j+gap])
{
// 交换
int temp = arr[j];
arr[j] = arr[j+gap];
arr[j+gap] = temp;
}
}
}
gap /= 2;
}
}
/**
* 希尔排序 采用优化的移动法 效率更高
* @param arr
*/
public static void RefinedShellSort(int[] arr)
{
int gap = arr.length/2;
while(gap >=1)
{
for(int i=gap;i<arr.length;i++)
{
int temp = arr[i];
int index = i - gap;
while(index>=0 && temp<arr[index])
{
arr[index+gap] = arr[index];
index -= gap;
}
arr[index+gap] = temp;
}
gap /= 2;
}
}
}
归并排序
-
算法思想
归并排序是分治法的一种典型应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。相当于每次都是将两个有序序列合并成一个新的有序序列。 -
算法步骤
1)把长度为n的输入序列分成两个长度为n/2的子序列;
2)对这两个子序列分别采用归并排序;
3)将两个排序好的子序列合并成一个最终的排序序列。 -
动态展示
代码实现
根据两个有序数组合并成一个新的有序数组,
- 交换法
public class MyMergeSort {
public static void main(String[] args) {
int[] arr = {4,5,7,8,1,2,3,6};
int[] temp = new int[arr.length]; // 归并排序需要额外的空间开销
sort(arr,0, arr.length-1, temp);
}
public static void sort(int[] arr,int left,int right,int[] temp)
{
if(left<right)
{
int mid = (left+right)/2;
// 左半部分递归分解
sort(arr, left, mid, temp);
// 右半部分递归分解
sort(arr, mid+1, right, temp);
// 合并
merge(arr, left, right, mid, temp);
}
}
/**
*
* @param arr 原始数组
* @param left 左边部分的指针索引
* @param right 右边部分的指针索引
* @param mid 中间元素的索引
* @param temp 临时数组
*/
public static void merge(int[] arr,int left, int right, int mid,int[] temp)
{
// 左边部分数组的指针索引
int i = left;
// 右边部分数组的指针索引
int j = mid+1;
// 临时数组的填充下表索引
int t = 0;
// 1. 左右两边的数组依次按照大小顺序填充到temp数组中,使temp是一个有序数组
while(i <=mid && j<=right)
{
if(arr[i]<arr[j])
{
temp[t] = arr[i];
i++;
t++;
}else {
temp[t] = arr[j];
j++;
t++;
}
}
// 2. 对于左右两边,如果有一边还有剩余元素,直接依次填充到temp数组即可
while(i<=mid)
{
temp[t] = arr[i];
i++;
t++;
}
while(j<=right)
{
temp[t] = arr[j];
j++;
t++;
}
// 3. 将temp数组拷贝到原数组
// Note:并不是每次都拷贝所有元素到arr
t = 0;
int tempLeft = left;
// 第一次合并 TL=0,right = 1 // 第二次 TL=2 right=3 // TL = 0, right = 3// TL = 0,right=7
while(tempLeft<=right)
{
arr[tempLeft] = temp[t];
tempLeft ++;
t++;
}
}
}
- 移动法
public class MyMergeSort2 {
public static void main(String[] args) {
int[] arr = {4,5,7,8,1,2,3,6,10,11};
sort(arr, 0, arr.length-1);
System.out.println(Arrays.toString(arr));
}
public static void sort(int[] arr,int left,int right)
{
int mid = (left+right)/2;
if(left<right)
{
sort(arr, left, mid);
sort(arr, mid+1, right);
merge(arr, left, right);
}
}
public static void merge(int[] arr,int left,int right)
{
int i = left;
int[] temp = new int[right-left+1];
int t = 0;
int mid = (left+right)/2;
int j = mid+1;
while(i<=mid && j<=right)
{
if(arr[i]<arr[j])
{
temp[t++] = arr[i++];
}else {
temp[t++] = arr[j++];
}
}
while(i<=mid)
{
temp[t++] = arr[i++];
}
while(j<=right)
{
temp[t++] = arr[j++];
}
int tempLeft = left;
t = 0;
while(tempLeft<=right)
{
arr[tempLeft++] = temp[t++];
}
}
}
基数排序
-
算法思想
1)通过键值的各个位上的值,将要排序的元素分配至某些桶中,达到排序的作用。建立0-9共是10个桶,从个位开始每次把所有数放到对应的桶中,每一轮放置完毕后依次从桶中取出即可。
2)基数排序是一种较高效率的稳定性排序
3)基数排序是桶排序的扩展 -
算法步骤
取得数组中的最大数,并取得位数;
arr为原始数组,从最低位开始取每个位组成radix数组;
对radix进行计数排序(利用计数排序适用于小范围数的特点);
-
动态展示
-
java实现
package com.like.java.data_structure.sort;
import java.util.Arrays;
public class MyRadixSort {
public static void main(String[] args) {
int[] arr = {53,3,542,748,14,214};
sort(arr);
System.out.println(Arrays.toString(arr));
}
/**
* 获取最大数的位数
* @param arr
* @return
*/
private static int getMaxDigit(int[] arr)
{
int max = arr[0];
for(int i=0;i<arr.length;i++)
{
if(arr[i]>max)
{
max = arr[i];
}
}
return (max+"").length();
}
public static void sort(int[] arr)
{
int maxDigit = getMaxDigit(arr);
// 最外层:位数循环
for(int n=0;n<maxDigit;n++)
{
// 0-9共10个桶
int[][] bucket = new int[10][arr.length];
// 每个桶中存储的个数
int[] bucketsSize = new int[10];
// 值循环-对应位数值放入对应桶
for(int k=0;k<arr.length;k++)
{
int val = (n==0)? arr[k] % 10 : arr[k]/(10*n) % 10;
bucket[val][bucketsSize[val]] = arr[k];
bucketsSize[val] = bucketsSize[val] +1;
}
// 依次取出桶中元素
int t = 0;
for(int i = 0;i<10;i++)
{
if(bucketsSize[i] != 0)
{
for(int j=0;j<bucketsSize[i];j++)
{
arr[t++] = bucket[i][j];
}
}
}
System.out.println(Arrays.toString(arr));
}
}
}
计数排序
桶排序
参考文献
[1] https://www.toutiao.com/a6593273307280179715/?iid=6593273307280179715
[2] INTRODUCTION TO ALGORITHM