在生活中,我们离不开排序。例如上体育课时,同学们会按照身高顺序进行排队;又如每一场考试后,老师会按照考试成绩排名次。在编程的世界中,应用到排序的场景也比比皆是。例如当开发一个学生管理系统时,需要按照学号从小到大进行排序;当开发一个电商平台时,需要把同类商品按价格从低到高进行排序。
排序算法的分类
根据时间复杂度可以将排序算法分为三类
排序算法 | 时间复杂度 | |
---|---|---|
第一类 | 冒泡、插入、选择 | O(n^2) |
第二类 | 快排、归并 | O(nlogn) |
第三类 | 桶、计数、基数 | O(n) |
根据排序算法的稳定性还可以将排序算法的分为两类
如果值相同的元素在排序后仍然保持着排序前的顺序,则这样的排序算法是稳定排序;如果值相同的元素在排序后打乱了排序前的顺序,则这样的排序算法是不稳定排序。
稳定性 | |
---|---|
第一类 | 稳定排序算法 |
第二类 | 不稳定排序算法 |
复杂度为O(n^2 )的排序算法
第一类:时间复杂度为O(n^2 )的排序算法,因为时间复杂度比较高所以适合小规模数据的排序。
冒泡排序
冒泡排序之所以叫冒泡排序,正是因为这种排序算法的每一个元素都可以像小气泡
一样,根据自身大小,一点一点地向着数组的一侧移动。
冒泡排序是一种稳定排序,值相等的元素并不会打乱原本的顺序。由于该排序算法
的每一轮都要遍历所有元素,总共遍历(元素数量-1)轮,所以平均时间复杂度
是O(n^2 )。
冒泡排序的代码实现
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length; i++) {
// 提前退出冒泡循环的标志位
for (int j = 0; j < array.length - i - 1; j++) {
int tmp = 0;
if (array[j] > array[j+1]) { // 交换
tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
}
}
}
}
public static void main(String[] args) {
int[] array = new int[]{5,8,6,3,9,2,1,7};
bubbleSort(array);
System.out.println(Arrays.toString(array));
}
冒泡排序代码优化
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length; i++) {
// 记录最后一次数组元素交换的位置
int lastExchangeIndex = 0;
// 无序数列的边界,每次比较只要比较这里为止
int sortBorder = array.length - 1;
// 有序标记,每一轮的初始值都是true
boolean isSorted = true;
// 提前退出冒泡循环的标志位
for (int j = 0; j < array.length - i - 1; j++) {
int tmp = 0;
if (array[j] > array[j + 1]) { // 交换
tmp = array[j];
array[j] = array[j + 1];
array[j + 1] = tmp;
// 因为有元素进行交换,所以不是有序的,标记变为false
isSorted = false;
// 更新为最后一次交换的位置
lastExchangeIndex = j;
}
}
// 将最后一次交换的地方赋值给数组边界值
sortBorder = lastExchangeIndex;
if (isSorted) {
break;
}
}
}
插入排序
初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。
插入排序是一个是一个原地排序算法,是一个稳定的排序算法,平均时间复杂度为 O(n^2)。
插入排序的代码实现
public static void insertionSort(int[] a) {
for(int i=1; i<a.length; i++) {
// value是要插入的数组元素
int value = a[i];
int j = i - 1;
// 查找插入的位置
for(; j>=0; --j) {
if (a[j]>value) {
// 数据移动
a[j+1] = a[j];
} else {
break;
}
}
// 插入数据
a[j+1] = value;
}
}
选择排序
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n^2)。
复杂度为O(nlogn)的排序算法
第二类:时间复杂度为O(nlogn)的排序算法,这类排序算法更适合大规模的数据排序。
归并排序
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
归并排序是一个稳定的排序算法,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法,因为归并排序在每一次合并时都会申请额外的存储空间,其空间复杂度是O(n)。
快速排序
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达
到排序的目的。快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成两个部分。 这种思路便是上面讲到的分治思想。
每一轮的比较和交换,需要把数组全部元素都遍历一遍,时间复杂度是O(n)。这样的遍历一共需要多少轮呢?假如元素个数是n,那么平均情况下需要logn轮,因此快速排序算法总体的平均时间复杂度是O(nlogn)。
基准元素的选择
基准元素,英文是pivot,在分治过程中,以基准元素为中心,把其他元素移动到它
的左右两边。最简单的方式就是选择数列的第一个元素。不过为了避免数列完全逆序的情况,可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素进行位置交换。
元素的交换
选定了基准元素以后,我们要做的就是把其他元素中小于基准元素的都交换到基准元素一边,大于基准元素的都交换到基准元素另一边。一般有以下两种方法
1:双边循环法
从right指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动;如果小于pivot,则right指针停止移动,切换到left指针。轮到left指针行动,让指针所指向的元素和基准元素做比较。如果小于或等于pivot,则指针向右移动;如果大于pivot,则left指针停止移动。这时,让left和right指针所指向的元素进行交换,元素交换之后进入下一次循环。
代码实现:
public static void quickSort(int[] arr,int startIndex, int endIndex) {
// 递归结束条件
if (startIndex > endIndex) {
return;
}
// 得到基准元素位置
int pivoIndex = partition(arr, startIndex, endIndex);
// 根据基准元素,分为两部分进行递归排序
quickSort(arr, startIndex, pivoIndex - 1);
quickSort(arr, pivoIndex + 1, endIndex);
}
/**
* 分治(双边循环法)
* @param arr 待交换数组
* @param startIndex 起始下标
* @param endIndex 结束下标
*/
private static int partition(int[] arr, int startIndex, int endIndex) {
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
int left = startIndex;
int right = endIndex;
while(left != right) {
// 控制right指针比较并左移
while(left<right && arr[right]>pivot) {
right--;
}
// 控制left指针比较并右移
while(left<right && arr[left]<=pivot) {
left++;
}
//交换left和right 指针所指向的元素
if(left<right) {
int p = arr[left];
arr[left] = arr[right];
arr[right] = p;
}
}
// pivot和指针重合点交换
arr[startIndex] = arr[left];
arr[left] = pivot;
return left;
}
2:单边循环法
开始和双边循环法相似,首先选定基准元素pivot。同时,设置一个mark指针指向数
列起始位置,这个mark指针代表小于基准元素的区域边界。从基准元素的下一个位置开始遍历数组。如果遍历到的元素大于基准元素,就继续往后遍历。如果遍历到的元素小于基准元素,则需要做两件事:第一,把mark指针右移1位,因为小于pivot的区域边界增大了1;第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属于小于pivot的区域。
代码实现:
/**
* 分治 (单边循环法)
* @param arr 数组
* @param startIndex 起始下标
* @param endIndex 结束下标
*/
public static int partition1(int[] arr, int startIndex, int endIndex) {
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
// 设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。
int mark = startIndex;
for(int i=startIndex+1; i<=endIndex; i++) {
if (arr[i]<pivot) {
mark++;
int p = arr[mark];
arr[mark] = arr[i];
arr[i] = p;
}
}
arr[startIndex] = arr[mark];
arr[mark] = pivot;
return mark;
}
复杂度为O(n)的排序算法
第三类:时间复杂度为O(n)的排序算法,这类排序算法之所以能做到线性的时间复杂度,主要原因是,这类算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
计数排序
假设数组中有20个随机整数,取值范围为0~10,要求用最快的速度把这20个整数从小到大进行排序。如何给这些无序的随机整数进行排序呢?考虑到这些整数只能够在0、1、2、3、4、5、6、7、8、9、10这11个数中取值,取值范围有限。所以,可以根据这有限的范围,建立一个长度为11的数组。数组下标从0到10,元素初始值全为0。遍历这个无序的随机数列,每一个整数按照其值对号入座,同时,对应数组下标的元素进行加1操作。计数排序其实是桶排序的一种特殊情况。
代码实现:
public static int[] countSort(int[] array) {
//1.得到数列的最大值
int max = array[0];
for(int i=1; i<array.length; i++) {
if (array[i] > max) {
max = array[i];
}
}
//2.根据数列最大值确定统计数组的长度
int[] countArray = new int[max+1];
//3.遍历数列,填充统计数组
for(int i=0; i<array.length; i++) {
countArray[array[i]]++;
}
//4.遍历统计数组,输出结果
int index = 0;
int[] sortedArray = new int[array.length];
// 下面的双重循环中,i为添加至sortedArray中的元素,j为相同元素的个数
for(int i=0; i<countArray.length; i++) {
for(int j=0; j<countArray[i]; j++){
sortedArray[index++] = i;
}
}
return sortedArray;
}
public static void main(String[] args) {
int[] array = new int[] {4,4,6,5,3,2,8,1,7,5,6,0,10};
int[] sortedArray = countSort(array);
System.out.println(Arrays.toString(sortedArray));
}
计数排序的局限性
1: 当数列最大和最小值差距过大时,并不适合用计数排序。
2:当数列元素不是整数时,也不适合用计数排序。
桶排序
桶排序同样是一种线性时间的排序算法。类似于计数排序所创建的统计数组,桶排序需要创建若干个桶来协助排序。
代码实现:
public static double[] bucketSort(double[] array) {
// 1.得到数列的最大值和最小值,并算出差值d
double max = array[0];
double min = array[0];
for (int i = 1; i < array.length; i++) {
if (array[i] > max) {
max = array[i];
}
if (array[i] < min) {
min = array[i];
}
}
double d = max - min;
// 2.初始化桶
int bucketNum = array.length;
ArrayList<LinkedList<Double>> bucketList = new ArrayList<LinkedList<Double>>(bucketNum);
for (int i = 0; i < bucketNum; i++) {
bucketList.add(new LinkedList<Double>());
}
// 3.遍历原始数组,将每个元素放入桶中
for (int i = 0; i < array.length; i++) {
int num = (int) ((array[i] - min) * (bucketNum - 1) / d);
bucketList.get(num).add(array[i]);
}
// 4.对每个桶内部进行排序
for (int i = 0; i < bucketList.size(); i++) {
// JDK 底层采用了归并排序或归并的优化版本
Collections.sort(bucketList.get(i));
}
// 5.输出全部元素
double[] sortedArray = new double[array.length];
int index = 0;
for (LinkedList<Double> list : bucketList) {
for (double element : list) {
sortedArray[index] = element;
index++;
}
}
return sortedArray;
}
桶排序的总体时间复杂度为O(n),至于空间复杂度就很容易得到了,同样是O(n)。