排序算法是算法的入门知识,其经典思想可以用于很多算法当中。因为其实现代码较短,应用较常见。所以在面试中经常会问到排序算法及其相关的问题。但万变不离其宗,只要熟悉了思想,灵活运用也不是难事。一般在面试中最常考的是快速排序和归并排序,并且经常有面试官要求现场写出这两种排序的代码。对这两种排序的代码一定要信手拈来才行。还有插入排序、冒泡排序、堆排序、选择排序、基数排序、桶排序等。面试官对于这些排序可能会要求比较各自的优劣、各种算法的思想及其使用场景。还有要会分析算法的时间和空间复杂度。通常查找和排序算法的考察是面试的开始,如果这些问题回答不好,估计面试官都没有继续面试下去的兴趣都没了。所以想开个好头就要把常见的排序算法思想及其特点要熟练掌握,有必要熟练写出代码[1]。
对排序算法的分类方式也有很多种[2]:
1、计算的时间复杂度(最差、平均、和最好性能),依据列表(list)的大小(n)。一般而言,好的性能是O(n log n),坏的性能是O(n2)。对于一个排序理想的性能是O(n),但平均而言不可能达到。基于比较的排序算法对大多数输入而言至少需要O(n log n)。
2、空间复杂度。
3、稳定性:稳定排序算法会让原本有相等键值的纪录维持相对次序。也就是如果一个排序算法是稳定的,当有两个相等键值的纪录R和S,且在原本的列表中R出现在S之前,在排序过的列表中R也将会是在S之前。
4、排序的方法:交换、选择、插入、合并等等。
首先给出一个对比的表格,以便从整体上理解排序算法:
接下来我们按照
交换排序:冒泡排序、快速排序
选择排序:选择排序、堆排序
插入排序:插入排序
归并排序:归并排序
的顺序、分析一下这六种常见的排序算法及其使用场景。限于篇幅,某些算法的详细演示和图示请在算法导论中寻找详细的参考。值得一提的是,本文中的代码思想都源自算法导论,如果有不明白的代码请翻阅算法导论一书。本文专为面试前突击而作,套路和思想和算法导论一模一样,即以方便记忆为主。
交换排序
交换排序的基本方法是在待排序的元素中选择两个元素,将他们的值进行比较,如果反序则交换他们的位置,直到没有反序的记录为止。交换排序中常见的是冒泡排序和快速排序。
冒泡排序
冒泡排序算法的伪代码如下:
function bubble_sort (array, length) {
var i, j;
for(i from 0 to length-1){
for(j from 0 to length-1-i){
if (array[j] > array[j+1])
swap(array[j], array[j+1])
}
}
}
参考伪代码不难写出代码:
/**
* @param arr
* 1、冒泡排序
* 冒泡排序时间复杂度O(n^2),比较次数多,交换次数多。因此是效率极低的算法。
* 冒泡排序是一种稳定的算法。
*/
public static void bubbleSort(int[] arr){
int len = arr.length;
for(int i = 0; i < len - 1; i++){
for(int j = 0; j < len - 1 - i; j++){
if(arr[j] > arr[j + 1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
冒泡排序对n个项目需要O(n^2)的比较次数,且可以原地排序。尽管这个算法是最简单了解和实现的排序算法之一,但冒泡排序的实现通常会对已经排序好的数列拙劣地运行O(n^2),它对于包含大量的元素的数列排序是很没有效率的。
快速排序
快速排序是冒泡排序的一种改进,冒泡排序排完一趟是最大值冒出来了,那么可不可以先选定一个值,然后扫描待排序序列,把小于该值的记录和大于该值的记录分成两个单独的序列,然后分别对这两个序列进行上述操作。这就是快速排序,我们把选定的那个值称为枢纽值,如果枢纽值为序列中的最大值,那么一趟快速排序就变成了一趟冒泡排序。
快速排序是基于分治模式的[3]:
分解:数组A【p..r】被划分成两个(可能空)子数组A【p..q-1】和A【q+1..r】,使得A【p..q-1】中的每个元素都小于等于A(q),而且,小于等于A【q+1..r】中的元素。下 标q 也在返个划分过程中迕行计算。
解决:通过递归调用快速排序,对子数组A【p..q-1】和A【q+1..r】排序。
合并:因为两个子数组使就地排序的,将它们的合并不需要操作:整个数组A【p..r】已排序。
/**
* @param arr
* @param p
* @param r
* 2、快排
* 快排最坏时间复杂度O(n^2),平均时间复杂度O(nlgn)。空间复杂度为O(nlgn)。
* 快排是一种不稳定的算法。
*/
public static void quickSort(int[] arr, int p, int r){
if(p < r){
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
return;
}
public static int partition(int[] arr, int p, int r){
int x = arr[r];
int i = p - 1;
for(int j = p; j < r; j++){
if(arr[j] < x){
swap(arr, ++i, j);
}
}
swap(arr, ++i, r);
return i;
}
private static void swap(int[] arr, int i,int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序是最常用的一种排序算法,包括C的qsort,C++和Java的sort,都采用了快排(C++和Java的sort经过了优化,还混合了其他排序算法)。
快排最坏情况O( n^2 ),但平均效率O(n lg n),而且这个O(n lg n)几号中隐含的常数因子很小,快排可以说是最快的排序算法,并非浪得虚名。另外它还是就地排序。
举一个例子,java中arrays.sort()方法:
1)当待排序的数组中的元素个数较少时,源码中的阀值为7,采用的是插入排序。尽管插入排序的时间复杂度为0(n^2),但是当数组元素较少时,插入排序优于快速排序,因为这时快速排序的递归操作影响性能。
2)较好的选择了划分元(基准元素)。能够将数组分成大致两个相等的部分,避免出现最坏的情况。例如当数组有序的的情况下,选择第一个元素作为划分元,将使得算法的时间复杂度达到O(n^2).
源码中选择划分元的方法:
当数组大小为 size=7 时 ,取数组中间元素作为划分元。int n=m>>1;(此方法值得借鉴)
当数组大小 7
选择排序
选择排序的基本思想是,每趟排序在待排序序列中,选择值较小的元素,顺序添加到有序序列的最后,直到全部记录排序完毕。常用的有简单选择排序和堆排序。
简单选择排序
简单选择排序是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
/**
* @param arr
* 3、选择排序
* 选择排序时间复杂度O(n^2)。比较次数多,交换次数少。
* 选择排序是一种不稳定的排序算法。例如:(7) 2 5 9 3 4 [7] 1...
* 当我们利用直接选择排序算法进行排序时,(7)和1调换,(7)就在[7]的后面了,原来的次序改变,这样就不稳定.
*/
public static void selectSort(int[] arr){
int len = arr.length;
for(int i = 0; i < len - 1; i++){
int min = i;
for(int j = i + 1; j < len; j ++){
if(arr[min] > arr[j]){
min = j;
}
}
swap(arr, i, min);
}
}
简单选择排序是移动次数最少的算法。原始序列为正序时,比较次数O( n^2 ),移动次数为0;逆序时,比较次数O( n^2 ),移动次数O( n )。平均情况下时间复杂度O( n^2 ),空间复杂度O( 1 )。
另外简单选择排序是不稳定的;简单选择排序是原地排序。
堆排序
在堆的数据结构中,堆中的最大值总是位于根节点(在优先队列中使用堆的话堆中的最小值位于根节点)。堆排序需要用到堆中定义以下三个种操作:
- 最大堆调整(Max_Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点。
- 创建最大堆(Build_Max_Heap):将堆所有数据重新排序。
- 堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算。
/**
* @param arr
* 4、堆排序
* 堆排序有三个操作:1、维护堆性质;2、建堆;3、堆排序
* 1、维护堆性质,时间复杂度为O(lgn)
* 2、建堆,时间复杂度O(n)
* 3、堆排序,n-1次调用维护堆性质函数,每次时间复杂度为O(lgn),因此总体时间复杂度为O(nlgn)。
* 空间复杂度为O(lgn)
* 堆排序是一种不稳定的排序。
*/
public static void heapSort(int[] arr){
/*
* 第一步:将数组堆化
* beginIndex = 第一个非叶子节点。
* 从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。
* 叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。
*/
int len = arr.length - 1;
int beginIndex = (len - 1) >> 1;
for(int i = beginIndex; i >= 0; i--){
maxHeapify(arr, i, len);
}
/*
* 第二步:对堆化数据排序
* 每次都是移出最顶层的根节点A[0],与最尾部节点位置调换,同时遍历长度 - 1。
* 然后从新整理被换到根节点的末尾元素,使其符合堆的特性。
* 直至未排序的堆长度为 0。
*/
for(int i = len; i > 0; i--){
swap(arr, 0, i);
maxHeapify(arr, 0, i - 1);
}
}
/**
* 调整索引为 index 处的数据,使其符合堆的特性。
*
* @param index 需要堆化处理的数据的索引
* @param len 未排序的堆(数组)的长度
*/
public static void maxHeapify(int[] arr, int i, int len){
int l = 2 * i + 1; // 左子节点索引
int r = l + 1; // 右子节点索引
int largest = i; // 默认父节点索引为最大值索引
if(l <= len && arr[l] > arr[i]){ //判断左子节点是否比父节点大
largest = l;
}
if(r <= len && arr[r] > arr[largest]){ //判断右子节点是否比父节点大
largest = r;
}
if(largest != i){
swap(arr, i, largest); // 如果父节点被子节点调换,
maxHeapify(arr, largest, len); // 则需要继续判断换下后的父节点是否符合堆的特性。
}
}
插入排序
插入排序不是通过交换位置,而是通过比较找到合适的位置插入元素。类似于打扑克牌,整牌的时候就是拿到一张牌,找到一个合适的位置插入。这个原理其实和插入排序是一样的。
直接插入排序
/**
* @param arr
* 5、插入排序
* 插入排序平均时间复杂度为O(n^2),空间复杂度为O(1)。
* 直接插入排序是一种稳定的算法,当数组长度较小时,效果要比快排好。
*/
public static void insertSort(int[] arr){
int len = arr.length;
for(int i = 1; i < len; i++){
int temp = arr[i];
int j = i - 1;
// 找到合适的位置j来插入arr[i]
while(j >= 0 && arr[j] > temp){
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = temp;
}
}
合并排序
合并排序的基本方法是,将两个或两个以上的有序序列归并成一个有序序列。常见的算法有归并排序。
归并排序
/**
* @param arr
* 6、归并排序
* 归并排序平均时间复杂度为O(nlgn),空间复杂度为O(n)。
* 归并排序是一种稳定的算法。
*
*/
public static void mergeSort(int[] arr, int p, int r){
if (p < r){
int q = (p + r) / 2;
mergeSort(arr, p, q);
mergeSort(arr, q + 1, r);
merge(arr, p, q, r);
}
}
public static void merge(int[] arr, int p, int q, int r){
int len1 = q - p + 1;
int len2 = r - q;
// 创建长度为len1和len2的新数组。
int[] arr1 = new int[len1 + 1];
int[] arr2 = new int[len2 + 1];
// 赋值,尾部值为无穷。
for(int i = 0; i < len1; i++){
arr1[i] = arr[p + i];
}
for(int i = 0; i < len2; i++){
arr2[i] = arr[q + 1 + i];
}
arr1[len1] = Integer.MAX_VALUE;
arr2[len2] = Integer.MAX_VALUE;
// 比较两个新数组的元素大小,将小的元素添加的arr,进行排序。
for(int k = 0, i = 0, j = 0; k < len1 + len2; k++){
if(arr1[i] < arr2[j]){
arr[p + k] = arr1[i++];
}else{
arr[p + k] = arr2[j++];
}
}
}
参考文献
[1] 面试中的 10 大排序算法总结
[2] 排序算法wiki
[3] 算法导论