排序介绍
排序对任何程序员来说都不模式,在很多编程语音中提供了很多编程函数,如java中Arrays.sort()等。排序算法有很多,本章节着重讲解集中常见的排序算法,如冒泡排序、插入排序、选择排序、快速排序。其他还有很多排序算法,有兴趣也可以自行了解。
分析排序算法
排序算法有很多,那么哪种算法比较好,什么情况下该用哪种算法,该怎么界定呢?我们可以从算法的执行效率、内存消耗两个维度来分析。由于现在计算机内存都很大,不会很在意算法执行的额外使用的内存空间,更多在意算法的时间复杂度。
执行效率
执行效率一般从时间复杂度和算法比较交换移动的次数来观察。
一般分析算法的复杂度,需要计算算法平均的时间复杂度,即最理想到最不理想情况下时间复杂度的平均。
上诉常见的集中算法,一般都是通过比较后交换或移动元素起到排序的作用,所以交换移动的次数也是洞察算法执行效率必不可少的。
内存消耗
算法的内存消耗可以通过空间复杂度来衡量,即算法排序中所需要额外使用的内存空间。
常见算法排序
冒泡排序
原理介绍
说到数组的排序,很多程序员想起的第一种一定是冒泡排序。做为最基础的排序算法,特点是简单易懂。
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
假设需排序的数组为{4,5,3,2,1},第一次比较相邻的两个元素如下图:
从上图看过,一次冒泡操作后,5这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只要进行5次这样的冒泡操作就行了。
冒泡代码
附上冒泡排序的代码:
public static void bubblingSort(int[] array) {
for(int i = 0;i<array.length;i++){
for(int j = 0;j<array.length-1-i;j++){
if(array[j] > array[j+1]){
int temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
}
}
}
}
那么上面代码能否有优化空间呢?答案是有的,但内部的循环没有元素移动时,表示当前数组已完成排序,则不需要继续后续操作了。
改进后的代码:
public static void bubblingSort(int[] array) {
for(int i = 0;i<array.length;i++){
//可提前推出标志
int exitFlag = false;
for(int j = 0;j<array.length-1-i;j++){
if(array[j] > array[j+1]){
int temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
exitFlag = true;
}
}
if(!exitFlag){
break;
}
}
}
复杂度
冒泡排序的时间复杂度比较简单,n*n,为O(n2)。
空间复杂度因为不使用额外的空间,为O(1)。
插入排序
原理介绍
顾名思义,插入排序的核心是插入,即每个位置的元素插入到合适的位置中。
我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
如下图,左侧是已排序区间,右侧为未排序区间。
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据插入到已排序区间时,需要拿此数据与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素插入。
插入代码
public static void insetSort(int[] array) {
if(null == array || array.length < 2){
return;
}
for(int i = 1 ; i < array.length ; i++){
int j = i - 1;
int val = array[i];
//寻找插入位置&移动元素
for(;j >=0 ;j--){
if(array[j] > val){
array[j+1] = array[j];
}else {
break;
}
}
array[j+1] = val;
}
复杂度
插入排序的时间复杂度,为O(n2)。
空间复杂度因为不使用额外的空间,为O(1)。
选择排序
原理介绍
选择排序和插入排序原理类似,都分为已排序区间和未排序区间。每次从未排序区间内找最小值,与已排序区间的后一位交换位置,循环至数组最后。
选择排序代码
public static void selectionSort(int[] array) {
for(int i = 0;i < array.length-1;i++){
//找出未排序中的最小值
int minIndex = i;
int tempValue = array[i];
for(int j = i+1; j < array.length;j++){
if(array[j] < tempValue){
tempValue = array[j];
minIndex = j;
}
}
//当前值与最小值替换
if(i != minIndex){
int temp = array[i];
array[i] = array[minIndex];
array[minIndex] = temp;
}
}
}
复杂度
选择排序的时间复杂度,为O(n2)。
空间复杂度因为不使用额外的空间,为O(1)。
快速排序
原理介绍
快速排序也叫快排,常年混迹于面试算法环节中,非常考验面试者的基础,是我们必须掌握的一张算法。其核心思想是分而治之。
如果要排序数组中下标从left到right之间的一组数据,我们选择left到right之间的任意一个数据作为pivot(分区点),一般是left或者right位。
遍历left到right之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组left到right之间的数据就被分成了三个部分,前面left到pivot-1之间都是小于pivot的,中间是pivot,后面的pivot+1到right之间是大于pivot的。
根据分治、递归的处理思想,我们可以用递归排序下标从left到pivot-1之间的数据和下标从pivot+1到right之间的数据,直到区间缩小为1,就说明所有的数据都有序了。
上诉思想可以用代码下面表示:
private void quickSort(int[] arr, int left, int right) {
if(left < right){
//寻找分区点,递归调用
int parIndex = partition(arr,left,right);
quickSort(arr,left,parIndex-1);
quickSort(arr,parIndex+1,right);
}
}
通过上面的代码,可以看出还缺少个重要的partition方法。partition()就是随机选择一个元素作为pivot,然后对A[left…right]分区,函数返回pivot的下标。
如果不考虑额外使用空间,可以选择left位置做为pivot,申明两个临时数组A、B,循环数组,将小于pivot的元素放至A,大于pivot的元素放至B,最后将A、B数组拷贝至原数组。
那么有没有不需要使用额外内存空间的方式呢?答案是有的。
我们可以通过对比、交换的方式,来达到这种效果。假设pivot为left的值,这边定义两个游标,i表示循环下表,swap表示交换位,初始时swap = i = left+1。通过i把A[left+1…r]分成两部分。A[left+1…i-1]的元素都是小于pivot的,叫它“已处理区间”,A[i…right]是“未处理区间”。每次都从未处理的区间A[i…right]中取一个元素A[j],与pivot对比,如果小于pivot,则与swap位置的元素交换位置,随后swap+1。最后只需要将A[swap-1]与A[left]交换,则达到partition()想要的效果。
快排代码
private static void quickSort(int[] arr, int left, int right) {
if(left < right){
int parIndex = partitionRight(arr,left,right);
quickSort(arr,left,parIndex-1);
quickSort(arr,parIndex+1,right);
}
}
//以lfet位置为基准分区
private int partitionLeft(int[] array,int left,int right){
int referVal = array[left];
int swapIndex = left+1;
for(int i = left+1;i <= right;i++){
if(array[i] < referVal){
swap(array,i,swapIndex);
swapIndex++;
}
}
swap(array,left,swapIndex-1);
return swapIndex-1;
}
//交换位置
private void swap(int[] array,int i,int j){
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
复杂度
假设每次分区操作,正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。则快排的时间复杂度也是O(nlogn)。但也存在比较极端的例子。如果数组中的数据原来已经是有序的了,比如2,4,6,8。如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约n/2个元素,这种情况下,快排的时间复杂度就从O(nlogn)退化成了O(n2)。
针对上诉极端情况,选择pivot可以选择left right和两者中间位3个数进行对比,选择大小中间的元素做为pivot,可避免这种极端情况。
最终平均下来,快排的时间复杂度还是O(nlogn)。
空间复杂度因为不使用额外的空间,为O(1)。
总结
冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是O(n2),比较高,适合小规模数据的排序。
快速排序是稍微复杂的排序算法,用的是分治的思想,代码都通过递归来实现。快速排序算法虽然最坏情况下的时间复杂度是O(n2),但是平均情况下时间复杂度都是O(nlogn)。不仅如此,快速排序算法时间复杂度退化到O(n2)的概率非常小,我们可以通过合理地选择pivot来避免这种情况。