交换排序之(冒泡排序和快速排序)
文章目录
前言
交换的意思是根据两个关键字值的比较结果,不满足次序要求时交换位置。冒泡排序和快速排序是典型的交换排序算法,其中快速排序是目前最快的排序算法。
冒泡排序
冒泡排序是一种最简单的交换排序算法,通过两两比较关键字,如果逆序就交换,使关键字大的记录像泡泡一样冒出来放在尾部。重复执行若干次冒泡排序,最终得到有序序列
算法步骤
1)设待排序的记录存储在数组r[1…n]中,首先第一个记录和第二个记录关键字比较,若逆序则交换;然后第一个记录和第二个记录关键字比较……依次类推,直到第n-1个记录和第n个记录关键字比较完毕为止。第一趟排序结束,关键字最大的记录在最后一个位置。
2)第二趟排序,对前n-1个元素进行冒泡排序,关键字次大的记录在n-1位置。
3)重复上述过程,直到某一趟排序中没有进行交换记录为止,说明序列已经有序。
图解
例如,利用冒泡排序算法对序列{12, 2, 16, 30, 28, 10, 16, 6, 20, 18}进行非递减排序。
1)第一趟排序,两两比较,如果逆序则交换,如图9-27所示。
经过第一趟排序后,最大的记录已经冒泡到最后一个位置,第二趟排序不需要再参加。
2)第二趟排序,两两比较,如果逆序则交换,如图9-28所示。
3)继续进行冒泡排序,当某一趟排序无交换时停止,全部冒泡排序结果如图9-29所示。
代码实现
public class Bubble {
/**
* 对数组元素进行排序
* @param a
*/
public static void sort(Comparable[] a){
for (int i = a.length - 1; i > 0; i--) {
for (int j = 0; j <i ; j++) {
// 最坏情况 {6,5,4,3,2,1}
// 比较索引 j 和 j+1 处的值
if (greater(a[j],a[j+1])){
exch(a,j,j+1);
}
}
}
}
/**
* 比较 v 元素是否大于 W 元素
* @param v
* @param w
* @return
*/
private static boolean greater(Comparable v,Comparable w){
return v.compareTo(w)>0;
}
/**
* 对数组的 i 和 j 交换位置
* @param a
* @param i
* @param j
*/
private static void exch(Comparable[] a,int i,int j){
Comparable temp;
temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
测试
import java.util.Arrays;
public class BubbleTest {
public static void main(String[] args) {
Integer[] arr = {12, 2, 16, 30, 28, 10, 16, 6, 20, 18};
Bubble.sort(arr);
System.out.println(Arrays.toString(arr));
}
}
===========================
结果:[2, 6, 10, 12, 16, 16, 18, 20, 28, 30]
复杂度
(1)时间复杂度
冒泡排序的时间复杂度和初始序列有关,可分为最好情况、最坏情况和平均情况。
在最好情况下,待排序序列本身是正序的(如待排序序列是非递减的,题目要求也是非递减排序),只需要一趟排序,n-1次比较,无交换记录。在最好情况下,冒泡排序时间复杂度为O(n)。
在最坏情况下,待排序序列本身是逆序的(如待排序序列是非递增的,题目要求是非递减排序),需要n-1趟排序,每趟排序i-1次比较,总的比较次数为:
在最坏情况下,冒泡排序的时间复杂度为O(n2)。· 在平均情况下,若待排序序列出现各种情况的概率均等,则可取最好情况和最坏情况的平均值。在平均情况下,冒泡排序的时间复杂度也为O(n2)。
(2)空间复杂度冒泡排序使用了一些辅助空间,即i、j、temp,空间复杂度为O(1)。
(3)稳定性冒泡排序是稳定的排序方法。
稳定性:意思就是说大小相同的两个值在排序之前和排序之后的先后顺序不变,这就是稳定的。冒泡排序并不改变排序前后的顺序,所以是稳定的。
快速排序
冒泡排序的缺点是移动记录次数较多,因此算法性能较差。有人做过实验,如果对105个数据进行排序,冒泡排序需要8174ms,而快速排序只需要3.634ms!
快速排序(Quicksort)是比较快速的排序方法。快速排序由C. A. R. Hoare在1962年提出。它的基本思想是通过一组排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此使所有数据变成有序序列。
快速排序算法是基于分治策略的,其算法思想如下。
1)分解:先从数列中取出一个元素作为基准元素。以基准元素为标准,将序列分解为两个子序列,使小于或等于基准元素的子序列在左侧,使大于基准元素的子序列在右侧。
2)治理:对两个子序列进行快速排序。
3)合并:将排好序的两个子序列合并在一起,得到原问题的解。设当前待排序的序列为R[low:high],其中low≤high,如果序列的规模足够小(只有一个元素),则完成排序,否则分3步处理,其处理过程如下。
1)分解:在R[low: high]中选定一个元素R[pivot],以此为标准将要排序的序列划分为两个序列:R[low:pivot-1]和R[pivot+1:high],并使序列R[low:pivot-1]中所有元素小于等于R[pivot],序列R[pivot+1:high]中所有元素均大于R[pivot]。此时基准元素已经位于正确的位置,它无须参加后面的排序,如图9-30所示。
2)治理:对于两个子序列R[low:pivot-1]和R[pivot+1:high],分别通过递归调用进行快速排序。
3)合并:由于对R[low:pivot-1]和R[pivot+1:high]的排序是原地进行的,所以在R[low:pivot-1]和R[pivot+1:high]都已经排好序后,合并步骤无须做什么,序列R[low:high]就已经排好序了。
如何分解是一个难题,因为如果基准元素选取不当,有可能分解成规模为0和n-1的两个子序列,这样快速排序就退化为冒泡排序了。
算法步骤
1)首先取数组的第一个元素作为基准元素pivot=R[low], i=low, j=high。
2)从右向左扫描,找小于等于pivot的数,如果找到,则R[i]和R[j]交换,i++。
3)从左向右扫描,找大于pivot的数,如果找到,则R[i]和R[j]交换,j–。
4)重复第2步和第3步,直到i和j重合,返回该位置mid=i,该位置的数正好是pivot元素。
5)至此完成一趟排序。此时以mid为界,将原序列分为两个子序列,左侧子序列元素小于等于pivot,右侧子序列元素大于pivot,再分别对这两个子序列进行快速排序。
图解方法一(每次和基准元素交换)
假设当前待排序的序列为R[low:high],其中low≤high。以序列(30, 24, 5, 58, 18, 36, 12, 42, 39)为例,演示快速排序过程。
1)初始化。i=low, j=high, pivot=R[low]=30,如图9-33所示。
2)向左走。从数组的右边位置向左找,一直找小于等于pivot的数,找到R[j]=12,如图9-34所示。
R[i]和R[j]交换,i++,如图9-35所示。
3)向右走。从数组的左边位置向右找,一直找比pivot大的数,找到R[i]=58,如图9-36所示。
R[i]和R[j]交换,j–,如图9-37所示。
4)向左走。从数组的右边位置向左找,一直找小于等于pivot的数,找到R[j]=18,如图9-38所示。
R[i]和R[j]交换,i++,如图9-39所示。
5)向右走。从数组的左边位置向右找,一直找比pivot大的数,这时i=j,第一轮排序结束,返回i的位置,mid=i,如图9-40所示。
至此完成一趟排序。此时以mid为界,将原序列分为两个子序列,左侧子序列小于等于pivot,右侧子序列大于pivot。再分别对这两个子序列(12, 24, 5, 18)和(36, 58, 42, 39)进行快速排序。
图解方法二(算法改进)
从上述算法可以看出,每次交换都是在和基准元素进行交换,实际上没必要这样做,我们的目的就是想把原序列分成以基准元素为界的两个子序列,左侧子序列小于等于基准元素,右侧子序列大于基准元素。有很多方法可以实现,可以从右向左扫描,找小于等于pivot的数R[j];然后从左向右扫描,找大于pivot的数R[i],让R[i]和R[j]交换,一直交替进行,直到i和j碰头为止,这时将基准元素与R[i]交换即可。这样就完成了一次划分过程,但交换元素的个数少了很多。
假设当前待排序的序列为R[low: high],其中low≤high。
1)首先取数组的第一个元素作为基准元素pivot=R[low], i=low, j=high。
2)从右向左扫描,找小于等于pivot的数R[i]。
3)从左向右扫描,找大于pivot的数R[j]。
4)R[i]和R[j]交换,i+ +, j- -。
5)重复第2步~第4步,直到i和j相等。如果R[i]大于pivot,则R[i-1]和基准元素R[low]交换,返回该位置mid=i-1;否则,R[i]和基准元素R[low]交换,返回该位置mid=i,该位置的数正好是基准元素。
至此完成一趟排序。此时以mid为界,将原数据分为两个子序列,左侧子序列元素小于等于pivot,右侧子序列元素大于pivot。
然后分别对这两个子序列进行快速排序。
以序列(30, 24, 5, 58, 18, 36, 12, 42, 39)为例。
1)初始化。i=low, j=high, pivot=R[low]=30,如图9-46所示。
2)向左走。从数组的右边位置向左找,一直找小于等于pivot的数,找到R[j]=12,如图9-47所示。
3)向右走。从数组的左边位置向右找,一直找比pivot大的数,找到R[i]=58,如图9-48所示。
4)R[i]和R[j]交换,i+ +, j- -,如图9-49所示。
5)向左走。从数组的右边位置向左找,一直找小于等于pivot的数,找到R[j]=18,如图9-50所示。
6)向右走。从数组的左边位置向右找,一直找比pivo t大的数,这时i=j,停止,如图9-51所示。
7)R[i]和R[low]交换,返回i的位置,mid=i,第一轮排序结束,如图9-52所示。
至此完成一轮排序。此时以mid为界,将原数据分为两个子序列,左侧子序列都比pivot小,右侧子序列都比pivot大,如图9-53所示。
8)再分别对这两个子序列(18, 24, 5, 12)和(36, 58, 42, 39)进行快速排序。相比之下,上述的方法比传统的每次和基准元素交换的方法更加快速高效!
改进算法代码实现
/**
* 数组元素 i 和 j 交换位置
* @param a
* @param i
* @param j
*/
private static void exch(Comparable[] a,int i,int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 比较v元素是否小于w元素
* @param v
* @param w
* @return
*/
public static boolean less(Comparable v,Comparable w){
return v.compareTo(w)<0;
}
/**
* 对数组内的元素进行排序
* @param a
*/
public static void sort(Comparable[] a){
int low = 0;
int high = a.length - 1;
// 进行分治
sort(a,low,high);
}
/**
* 对数组a中从索引lo到索引hi之间的元素进行排序
* @param a
* @param lo
* @param hi
*/
private static void sort(Comparable[] a, int lo, int hi) {
if (hi<=lo){ return; }
//对a数组中,从lo到hi的元素进行切分 i
int partition = partition(a, lo, hi);
// 对左边分组中的元素进行排序
sort(a,lo,partition-1);
// 对右边分组中的元素进行排序
sort(a,partition+1,hi);
}
public static int i = 1;
/**
* 对数组a中,从索引 lo到索引 hi之间的元素进行分组,并返回分组界限对应的索引
* @param a
* @param lo
* @param hi
* @return
*/
public static int partition(Comparable[] a, int lo, int hi) {
//把最左边的元素当做基准值
Comparable key = a[lo];
//定义一个左侧指针,初始指向最左边的元素
int left=lo;
//定义一个右侧指针,初始指向左右侧的元素下一个位置
int right=hi+1;
//进行切分
while(true){
//先从右往左扫描,找到一个比基准值小的元素
while(less(key,a[--right])){
//循环停止,证明找到了一个比基准值小的元素
if (right==lo){
break;//已经扫描到最左边了,无需继续扫描
}
}
//再从左往右扫描,找一个比基准值大的元素
while(less(a[++left],key)){
//循环停止,证明找到了一个比基准值大的元素
if (left==hi){
break;//已经扫描到了最右边了,无需继续扫描
}
}
if (left>=right){
//扫描完了所有元素,结束循环
break;
}else{
//交换left和right索引处的元素
exch(a,left,right);
System.out.println("第"+ (i++) + "轮交换后:"+Arrays.toString(a));
}
}
//交换最后 right 索引处和基准值所在的索引处的值
exch(a,lo,right);
return right;//right就是切分的界限
}
测试
public class QuickTest {
public static void main(String[] args) throws Exception {
Integer[] arr = {30, 24, 5, 58, 18, 36, 12, 42, 39};
System.out.println("初始化数据:"+Arrays.toString(arr));
Test.sort(arr);
System.out.println("排序后数据:"+Arrays.toString(arr));
}
}
===================
结果:[5, 12, 18, 24, 30, 36, 39, 42, 58]
复杂度
(1)最好情况
时间复杂度
1)分解:划分函数Partition需要扫描每个元素,每次扫描的元素个数不超过n,因此时间复杂度为O(n)。
1)分解:划分函数Partition需要扫描每个元素,每次扫描的元素个数不超过n,因此时间复杂度为O(n)。
3)合并:因为是原地排序,合并操作不需要时间,如图9-42所示。
所以总运行时间为:
当n>1时,可以递推求解:
空间复杂度
程序中变量占用了一些辅助空间,这些辅助空间都是常数阶的,递归调用所使用的栈空间为递归树的深度logn,空间复杂度为O(logn)。
(2)最坏情况
时间复杂度
1)分解:划分函数Partition需要扫描每个元素,每次扫描的元素个数不超过n,因此时间复杂度为O(n)。
2)治理:在最坏的情况下,每次划分将问题分解后,基准元素的左侧(或者右侧)没有元素,基准元素的另一侧为1个规模为n-1的子问题,递归求解这个规模为n-1的子问题,所需时间为T(n-1),如图9-43所示。
3)合并:因为是原地排序,合并操作不需要时间复杂度,如图9-44所示。
所以总运行时间为
当n>1时,可以递推求解:
空间复杂度
程序中变量占用了一些辅助空间,这些辅助空间都是常数阶的,递归调用所使用的栈空间为递归树的深度n,空间复杂度为O(n)。
(3)平均情况
时间复杂度
假设我们划分后基准元素的位置在第k(k=1,2, …, n)个,如图9-45所示。
由归纳法可以得出,T(n)的数量级也为O(nlogn)。快速排序算法平均情况下,时间复杂度为O(nlogn)。
空间复杂度
程序中变量占用了一些辅助空间,这些辅助空间都是常数阶的,递归调用所使用的栈空间是O(logn),空间复杂度为O(logn)。
(4)稳定性
因为前后两个方向扫描并交换,相等的两个元素有可能出现排序前后位置不一致的情况,所以快速排序是不稳定的排序方法。
总结
交换排序中冒泡排序是十大排序里面相对较简单的排序,个人而言也是最容易理解的排序算法,面试过程中也有考的。快速排序则相对要难一些,但性能也要好很多,对于刚开始学的同学可以打断点追踪查看,多看几遍就能明白它的原理了,如有错误请指教。
博客资料来源【趣学数据结构】