排序算法
简单介绍
排序算法是学完数据结构后最开始接触的也是非常常用的算法。下面将常用排序算法进行介绍并代码实现以下
时间复杂度总结
先把结论摆出来吧。这样比较好记忆。
1.冒泡排序
冒泡排序是最开始学的排序算法。时间复杂度为O(n²)
步骤:依次比较前后2个数字的大小,如果前面大于后面,则交换位置。(按升序来排的话).
public void bubbleSort(int[] arr) { //外层循环控制了排序的轮数 for(int i=0;i<arr.length-1;i++) { //内层循环控制每轮排序比较几次 for(int j=0;j<arr.length-1-i;j++) { if(arr[j]>arr[j+1]) { int temp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = temp; } } } }
2.选择排序:时间复杂度:O(n²)
选择排序原理:在未排序的序列宗找到最小元素(按升序来算的话),放在已排序的起始位置。然后在剩余的未排序的序列中找到最小元素放在已排序的末尾。直接全部都变成已排序序列。
public void selectionSort(int[] arr) { for(int i=0;i<arr.length;i++) { //i到n是未排序序列,找出[i,n)区间的最小值 //定义min为未排序序列的最小值,初始化为已排序序列的末尾值 int min = i; for(int j = i+1;j<arr.length;j++) { if(arr[j]<arr[min]) min = j; } //交换 int temp = arr[i]; arr[i] = arr[min]; arr[min] = temp; } }
3.插入排序:时间复杂度O(n²)
插入排序实现原来:类似要日常的打牌方法。把下一个元素拿出来往后面依次比较,如果拿出来的元素小于已排序的值,则交换位置。直到最终排序完成。
public void insertSort(int[] arr) { //从第二个元素开始,因为插入排序第一个元素自己本来就默认有序了。 for(int i=1;i<arr.length;i++) { //选择arr[i]需要插入的位置,从后往前 for(int j=i;j>0;j--) { if(arr[j]<arr[j-1]) { int temp = arr[j]; arr[j] = arr[j-1]; arr[j-1] = temp; }else //如果j已经大于前一个元素,说明j已经是合适的位置了。因为前面的序列已经排序了的。 break; } } }
插入排序的改进
虽然插入排序在新元素大于已排序的值时候可以直接break跳出循环,但是由于新元素从后往前扫描的过程中,每次的比较判断成功都会进行一次交换操作,交换操作是比赋值操作更加耗时的。所以需要进行改进一下
定义一个元素的值来存储新元素的值,定义一个值来存储新元素应该插入的位置。
在新元素从后往前扫描的过程中,一开始便存储新元素的值。如果新元素的值小于前一个元素,则直接将前一个的值赋给新元素位置的值(此时有j-1值有2份).
然后依次往后判断,当新元素的值小于前一个元素或者已经是0位置的时候。直接赋值在这个位置就好了。
插入排序的改进方法在近乎有序的数据上,会具有非常高的效率。会有接近O(n)的时间复杂度
public void insertSort(int[] arr) { for(int i=1;i<arr.length;i++) { int e = arr[i];//定义一个元素e来存储新元素的值 int j;//j为元素e应该插入的位置 for(j=i;j>0;j--) { if(e<arr[j-1]) //如果新元素小于前一个元素,则前一个元素的值就赋值给新元素位置(相当于往后挪一位) arr[j] = arr[j-1]; } arr[j] =e; } }
4.归并排序
归并排序的是第一个学习的O(nlogn)的排序算法,我的理解是需要分治的过程是一个O(logn),而在子过程的排序的过程是一个O(n)的排序。
整个就是将子序列进行排序,然后合并有序的子序列得到一个完全有序的序列
步骤:
1.将数据分为1/2的数量级分别进行归并排序 mergeSort(arr,left,mid); mergeSort(arr, mid+1, right);
2.将2个已经排好序的部分进行归并操作。
具体看代码
public void mergeSort(int[] arr) { mergeSort(arr,0,arr.length-1); } //递归使用递归排序,对arr[left....righy]的范围进行排序 private void mergeSort(int[] arr,int left,int right) { //递归到底的情况 if(left>=right) return; int mid = left+(right-left)/2; //对分开的左右2部分进行分别归并排序 mergeSort(arr,left,mid); mergeSort(arr, mid+1, right); //if(arr[mid]>arr[mid+1])//如果可能会面对近乎有序的情况的话。可以进行这步优化 merge(arr,left,mid,right); } //归并排序的2部分arr[left...mid]和arr[mid+1...right]进行合并 private void merge(int[] arr,int left,int mid,int right) { //开辟临时空间,大小为left-right的元素大小.right-left+1 int [] num = new int[right-left+1]; //临时空间复制的时候注意偏移量。 for(int i=left;i<=right;i++) { num[i-left] = arr[i]; } int i =left; int j =mid+1; for(int k=left;k<=right;k++) { //2部分数组如果有一边到末尾了要进行判断 if(i>mid) { arr[k] = num[j-left]; j++; }else if(j>right) { arr[k] = num[i-left]; i++; } //这样2部分的数组指针就都不会越界了。进行2边的比较判断 else if(num[i-left]<num[j-left]) { arr[k]=num[i-left]; i++; }else { //aux[i-left]>=aux[j-left] arr[k] = num[j-left]; j++; } } }
由于我都是以数组为参数的。所以重载了一个多参数的mergeSort。
2个有序的子序列进行归并的时候,准备了3个指针i,j,(2部分的子序列的指针)k(最终完全排序的数组的指针),所以需要考虑2部分子序列的指针已经走完的情况。代码中进行了判断。
并且辅助空间的大小和原数组的大小的下标并不一样。要注意数组下标偏移量。
归并排序的改进
这种基本的归并排序在面对近乎有序甚至到最差就是有序的情况会有不太好的情况。究其原因就是在将2个子序列都排序好以后进行归并的时候,并没有考虑子序列之间的关系而直接merge()了。
所以在面对近乎有序的情况时候,会有许多额外的merge操作。
改进的方法比较简单,只要在merge前进行判断一下,如果Left这边序列的最大值小于或者等于right这边序列的最小值,就可以不进行归并操作。
if(arr[mid]>arr[mid+1]) merge(arr,left,mid,right);
但是还是会比较改进后的插入排序慢一点点,因为if操作也是需要时间的。
5.快速排序:经典快排
快速排序的思路:通过一个基准值来将整个待排序序列分为小于和大于基准值的2部分,然后分别对2部分的记录进行同样的分开操作。直到最后全部有序。
快排主要分为2个部分
1.将一个序列利用基准值进行分成2部分的过程。这个过程一般称之为partition
2.递归使得2部分分别进行一样的过程。
代码看看:
public void quickSort(int[] arr) { quickSort(arr,0,arr.length-1); } private void quickSort(int[] arr,int left,int right) { //递归终止条件 if(left >= right) return; //获取一个下标值,arr[left...p-1]<arr[p];arr[p+1...r]>arr[p] int p = partition(arr,left,right); quickSort(arr,left,p-1); quickSort(arr,p+1,right); } //对arr[left....right]部分进行partition操作。 //返回一个p下标,使得arr[left...p-1]<arr[p];arr[p+1...r]>arr[p]的2部分 private int partition(int[] arr,int left,int right) { //v为基准值 int v = arr[left]; //遍历整个数组,对每一个元素都进行判断 //当前元素为i,<v和>v的分界下标为j //arr[l+1...j]<v; arr[j+1...i-1]>v //初始的时候2部分都为空 int j=left; for(int i=left+1;i<=right;i++) { //如果当前元素小于设置的判断值V,将当前的小于V的元素和第一个大于V的元素交换位置,然后j++,这样<V的数组就扩充了 if(arr[i]<v) { swap(arr, i, j+1); j++; } //如果大于V的话,就可以不用管了。因为大于V的范围就是到i-1,循环自动i++就好了 }
//比较完了以后要将left的值和j位置的值进行交换一下
swap(arr,left,j); return j; }
private void swap(int[] arr,int i,int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快排的改进:随机化快排
在面对近乎有序的序列时,快排会有非常非常差的效率,归其原因就是在取基准值的问题。
因为经典的快排取序列的最前面的值作为基准值。patition的过程是O(n),
但是以基准值为分治下标进行继续递归的过程2个子序列并不是平均大小的,在最差的有序序列就会变成了O(N)的过程。
整体就会退化成了O(n²)的时间复杂度。(反正我的电脑在10000个顺序数的时候就栈溢出了。。。)
快排的改进可以用随机的方式来改进。既然每次都是取最左边的值,那取值之前将arr[left] 和arr[(new Random().nextInt(right-left+1))+left](从left...right取一个随机下标)
//从[left-right]中随机取一个值为交换值的下标
int randomIndex=(new Random().nextInt(right-left+1))+left;
//交换 swap(arr,left,randomIndex);
//后面的不改变
int v = arr[left];
虽然这样的随机化取值交换也是有可能将序列更加有序,但是这样的几率是非常低的,随机化快排在随机取测试模板的话,大部分情况都是非常快的。
快排的改进:双路快排
在拥有大量重复值(比如100W个从0到10)的情况下,快速排序的效率又会变得非常非常的查。究其原因就是partition的过程,
patition只是对小于v的数进行了交换操作。而这也隐含着如果有多个等于基准值的数的话,就会归类到>v的部分也就是>v的部分其实是>=v的序列。
所以如果在拥有大量重复数的时候,2个子序列就会非常的不平衡,最差情况都是一样的情况下,分治的过程又变成了O(n)的时间复杂度了。
所以可以用另外一种partion的方法来避免这样的不平衡。
双路快排的partition:
主要是重新定义 i 和j 2个索引来进行处理:arr[left+1...i]<=v,而arr[j+1...r]>=v
i从左往右扫描,遇到>=v的时候停下来,而j从右往左扫描,遇到<=v的时候停下来。 while(while(i<=right && arr[i]<v) ) i++; while(j>=left+1 && arr[j]>v) j++
然后i和j位置的值交换。i和j都移动。
跳出循环的条件为i>j. 结束
这种partition的方式最大的区别就是将等于基准值的值分散到了2个子序列里面。就算i和j的值都是等于基准值的,都需要进行交换,这样就避免了相等的值集中在同一边的情况。
好了。写了一堆,看代码吧
public class QuickSort2Ways { public void quickSort(int[] arr) { quickSort(arr,0,arr.length-1); } private void quickSort(int[] arr,int left,int right) { //递归终止条件 if(left >= right) { return; } //获取一个下标值,arr[left...p-1]<arr[p];arr[p+1...r]>arr[p] int p = partition(arr,left,right); quickSort(arr,left,p-1); quickSort(arr,p+1,right); } //对arr[left....right]部分进行partition操作。 //返回一个p下标,使得arr[left...p-1]<arr[p];arr[p+1...r]>arr[p]的2部分 private int partition(int[] arr,int left,int right) { //v为基准值 //随机取一个作为值和最前面的值交换,作为v int randomIndex=(new Random().nextInt(right-left+1))+left; swap(arr,left,randomIndex); int v = arr[left]; // 取i,j指针,表示arr[left+1...i-1]<=v; arr[j...right]>=v int i = left+1; int j = right; while(true) { while(i<=right && arr[i]<v) i++; while(j>=left+1 && arr[j]>v) j--; if(i>j) break; //交换 i j的值,这样即使有重复的值都可以保证会相对平均 swap(arr,i,j); i++; j--; }
//记得将基准的值与j进行交换 来保证基准值的最终位置准确 swap(arr,left,j); return j; } private void swap(int[] arr,int i,int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }
快排的改进:三路快排
快速排序面对大量重复元素的时候还有可以改进partition的方式,这个方式成为3路快排。
3路快排的思路:
将序列分为3个部分,小于基准值,等于基准值,大于基准值。
用3个指针来控制范围。对当前考察元素的情况当然也是3种
处理方式具体看代码注释。思路还是非常清晰点的。
范围参考图:(我的和图中在规范小于基准值的索引不一样。图中的小于基准值的范围是[left...lt)左闭右开的区间。我代码的是左闭右闭的。所以在具体边界 处理会有所不同。)
public class QuickSort3Ways { public void quickSort(int[] arr) { quickSort(arr,0,arr.length-1); } private void quickSort(int[] arr,int left,int right) { //递归终止条件 if(left >= right) { return; } //v为基准值 //随机取一个作为值和最前面的值交换,作为v int randomIndex=(new Random().nextInt(right-left+1))+left; swap(arr,left,randomIndex); int v = arr[left]; //取3个索引来控制3个部分的范围 int i = left+1;//i为当前要考察的值,等于基准范围为:arr[less+1...i-1] == v // 取less,greater指针 int less = left; //小于基准值的范围:arr[left+1...less]<v int greater = right+1; //大于基准值的范围:arr[greater...right]>v
//终止条件为:当前考察元素i没有 碰到 大于基准值的范围控制索引greater while(i<greater) { if(arr[i]==v) { i++; }else if(arr[i]<v) { swap(arr,i,less+1); less++; i++; }else { //arr[i]>v swap(arr,i,greater-1); greater--;//这里不需要进行i++,因为从右边交换过来的数组依然是需要处理的元素。 } }
//要记得将基准值和less进行交换,保持基准值位置正确 swap(arr,left,less); less--; //这里的less退一位是小于基准值的范围是[left+1...less]这里取的是左右闭合区间 //再递归的向小于基准值和大于基准值的部分进行partition quickSort(arr,left,less); quickSort(arr,greater,right); } private void swap(int[] arr,int i,int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }
归并排序和快速排序
归并排序和快速排序都是分治法的经典例子。
但是区别在于,归并排序的重点并没有在分上,它直接2分的将数据分开递归。重点在于2个有序子序列的比较归并的过程。
而快速排序重点便是如何分,因为快排的递归就是按基准值位置来分开递归,如何找到基准值该在的位置是这个算法的重点,两路快排和三路快排都是对partition的过程进行的修改以满足不一样的需求。
最后
排序算法还有希尔排序,堆排序等还没有写,排序算法先暂时总结到这里。堆排序准备在堆结构的时候写。
原来并没有去很认真去理解排序算法具体的思路过程。只要大概的理解。所以在面对最差情况之类的时间复杂度提问时候并不能记住。但是重新去一点点理解过程并完整实现。还是有非常大的进步的。加油。
图片参考来源:https://www.cnblogs.com/onepixel/articles/7674659.html