排序算法的好坏对于效率的影响十分显著。好的排序算法排序100万个整数可能只需要一秒(不考虑硬件因素),不好的排序算法可能需要一个小时甚至几个小时。
常见的排序算法有冒泡排序、插入排序、堆排序、快速排序等,这些排序都属于基于比较的排序,因此这些算法的时间复杂度都不能突破 O(NlogN)。
还有另一类非基于比较的排序,包括基数排序、桶排序、计数排序。
冒泡排序一般情况下效率比较低,在此不再赘述。
插入排序就像大多数玩家打牌时 排列手中纸牌的情景。从第二张牌开始,根据之前的牌的大小,我们把牌插到合适的位置。这样在抓下一张牌之前,手中的牌就已经是排好序的了。
如果输入已经是升序序列,这时时间复杂度为 O(n)。平均来说插入排序时间复杂度为 O(n2)。
注意:虽然冒泡排序和插入排序时间复杂度相同,但一般情况下,插入排序仍然比冒泡排序效率高的多。O(n2) 只是一个理论上的界限,它们的常量部分并不相同,就好像是 n2 和 1000n2 对应的时间复杂度都是 O(n2)。
Java 代码实现插入排序的思路是将新元素取出,从右到左依次与已排序的元素比较,如果该元素大于新元素,那么将其移动到新元素的位置,接着继续比较,直到已排序的元素小于等于新元素,这时将新元素插入至此元素后面的位置,所以此插入排序是稳定的。
需要注意的是,排序算法是否稳定是由具体算法决定的,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法,比如比较的边界的选择(> 还是 >=)。
public class InsertSort {
private static int[] randomArray;
public static int[] factory(int length) {
Random random = new Random();
randomArray = new int[length];
for (int i = 0; i < length; i++) {
randomArray[i] = random.nextInt(10000);
}
return randomArray;
}
public static void insertSort(int[] array) {
//Instant为Java8新增,用来获取时间
Instant begin = Instant.now();
for (int i = 1; i < array.length; i++) {
int t = array[i];
int j;
for (j = i; j > 0 && array[j - 1] > t; j--)
array[j] = array[j - 1];
array[j] = t;
}
Instant end = Instant.now();
System.out.println(Duration.between(begin, end));
}
public static void main(String[] args) {
int[] array = factory(100000);
insertSort(array);
}
}
输出结果:
// ISO 日期时间表示格式
PT4.191S
这里只进行了100000个整数的排序,只需要4.191s。对于不是很多数据的情况来说,这已经够用了。但当数据增加到一百万的时候,在我的机器上用了五分多钟。在数据量大的时候排序的速度还是不尽如人意。
折半插入排序是对插入排序的改进,又称二分插入排序,折半插入排序也是稳定的。
在传统插入排序中,在将一个数插入到已排序数组中时,要逐一比较。我们可以采用折半查找的方法寻找要插入的位置,这样可以减少比较次数。示例如下:
public static void binaryInsertSort(int[] array) {
Instant begin = Instant.now();
for (int i = 1; i < array.length; i++) {
int t = array[i];
int low = 0, high = i - 1;
while (low <= high) {
int pivot = (low + high) >> 1;
if (t < array[pivot])
high = pivot - 1;
else
low = pivot + 1;
}
for (int j = i; j > low; j--) {
array[j] = array[j - 1];
}
array[low] = t;
}
Instant end = Instant.now();
System.out.println(Duration.between(begin, end));
}
快速排序运用了分治思想:要解决规模为n的问题,可以递归地解决两个规模近似为 n/2 的子问题,然后对他们的答案进行合并以得到整个问题的答案。分治法是很多高效算法的基础,如排序算法(快速排序、归并排序)、傅立叶变换(快速傅立叶变换)。
快速排序于1962年被提出,快速排序的运行时间取决于划分是否平衡。平均情况下快速排序的时间复杂度为 O(NlogN)。它的排序过程如下:
现有数组array如下所示:
5 | 8 | 3 | 7 | 4 | 1 | 6 | 2 |
对数组进行排序(升序),首先要选择一个基准数,小于等于基准数的放到左边,大于基准数的放到右边。一般选数组的第一个数作为基准数。在这里将 m = array[0] 作为基准数。
剩下的七个数要进行"站边"。这里有两种处理方式,一种是从左往右扫描数组(单向划分),一种是两边依次进行扫描(双向划分)。双向划分的性能更好,因此这里只讨论双向划分。
那应该从左边开始扫描还是从右边开始扫描呢?答案是最好从右边开始。
因为我们将 array[0] 的元素拿出来赋给了一个变量,相当于第一个元素的位置空了下来,这时候需要从右半部分选一个小于等于基准数的放到这个位置。如果从左边开始的话也能实现,但没有从右边开始直观、易于实现。
示意图如下:
5 | |||||||
2 | 8 | 3 | 7 | 4 | 1 | 6 |
接下来从左边开始扫描,将大于基准数的放到右边的空位上。示意图如下:
5 | |||||||
2 | 3 | 7 | 4 | 1 | 6 | 8 |
一直循环进行,直到左右两边循环“相遇”。循环结束后应该是这个样子:
5 | |||||||
2 | 1 | 3 | 4 | 7 | 6 | 8 |
下面将基准数放到空位即完成了本轮循环,接下来对m的左边和右边递归进行这个过程,直到只有一个元素,完成排序。
完整的 Java 代码如下:
public static void quickSort(int[] array, int left, int right) {
if (left < right) {
int m = array[left];
int i = left, j = right;
while (i < j) {
while (i < j) {
if (array[j] < m) {
array[i++] = array[j];
break;
}
j--;
}
while (i < j) {
if (array[i] > m) {
array[j--] = array[i];
break;
}
i++;
}
}
array[i] = m;
quickSort(array, left, i - 1);
quickSort(array, i + 1, right);
}
}
经过测试,对一个长度为一百万的随机int数组排序,需要0.2s左右,相比插入排序效率提升很多。
注意,当快速排序的输入是一个有序序列的时候,快速排序会退化成冒泡排序,它的时间复杂度为 O(n2)。每次划分实际上只减少了一个元素,此时的最大递归深度为N,也就是数组的长度。此时如果对较大型的数组进行排序,可能会出现 StackOverflowError(取决于JVM栈的大小)。
解决方法是在每次选择基准数的时候采用随机选择的方法而不是固定选择第一个。
示例如下:
public static void quickSort(int[] array, int left, int right) {
if (left < right) {
Random random = new Random();
int n = random.nextInt(right - left + 1) + left;
int m = array[n];
array[n] = array[left];
array[left] = m;
int i = left, j = right;
while (i < j) {
while (i < j) {
if (array[j] < m) {
array[i++] = array[j];
break;
}
j--;
}
while (i < j) {
if (array[i] > m) {
array[j--] = array[i];
break;
}
i++;
}
}
array[i] = m;
quickSort(array, left, i - 1);
quickSort(array, i + 1, right);
}
}
这样当输入是一个有序序列时,仍能保持较快的排序速度,而不会抛出StackOverflowError。
从实现过程可知,快速排序不是稳定的。
归并排序与快速排序一样都采用了分治思想,它将待排序序列分为若干个子序列,每个子序列是有序的,然后再把有序子序列合并为整体有序序列。
若将两个有序表合并成一个有序表,称为二路归并。
归并排序的时间复杂度与快速排序一样都为 O(NlogN),但归并排序是稳定的,这在有些情况下是很重要的。归并排序的空间复杂度是 O(n),可以看到归并排序相比其它排序算法所需的空间更多,但带来的是时间效率的提升。
假设现在有一个数组a,其中有10个元素。
实现归并排序有两种方式,递归法实现与迭代法实现。
递归法实现如下:
public static void mergeSort(int[] arr) {
int[] temp = new int[arr.length];
mergeSortByRecursive(arr, temp, 0, arr.length - 1);
}
private static void mergeSortByRecursive(int[] arr, int[] temp, int start, int end) {
if (start >= end)
return;
int middle = ((end - start) >> 1) + start;
int start1 = start, start2 = middle + 1;
//左半部分排序
mergeSortByRecursive(arr, temp, start1, middle);
//右半部分排序
mergeSortByRecursive(arr, temp, start2, end);
//左右两部分合并
int k = start;
while (start1 <= middle && start2 <= end)
temp[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= middle)
temp[k++] = arr[start1++];
while (start2 <= end)
temp[k++] = arr[start2++];
for (k = start; k <= end; k++)
arr[k] = temp[k];
}
虽然网上有很多现成的例子,还是建议大家自己动手敲一下,加深理解。只有自己动手去做过才会发现许多意想不到的错误,比如移位运算:
int middle = 2 >> 1 + 3;
结果是0而不是4,因为 >> 的优先级低于 +。
迭代法的实现可以参考维基百科,相比递归,迭代法显得不是很直观。
注:维基百科 Java 迭代版说原版代码的迭代次数少了一次,没有考虑到奇数列数组的情况,因此 block 的上限是数组长度的两倍,但 block 的上限是数组的长度就可以了。
Arrays.sort():
Collections.sort():调用 Arrays.sort() 的 TimSort 排序