
今日复习任务:实现数据从小到大的排序方式,分别有冒泡、选择、插入、快排、归并
冒泡排序
思想:大的数往往上冒,每遍历一次,后面的几位是排好顺序的。第一次遍历得到最大的数,在末尾,第二次遍历,次大的数在倒数第二位,需要遍历n-1
次,所有的才能排好顺序。
1 | /** |
优化
1 | /** |
由于有大量的重复比较,在每次比较的时候,用一个变量标识表示这轮遍历是否都已经排好顺序了,如果都排好顺序了,就直接跳出循环。可想而知,当排好序的情况下,是最好的情况,时间复杂度为O(n)
。最坏情况是O(n^2)
,对于同样大小的数是不需要移动的,可以保证之前的顺序,所以是稳定的。
选择排序
思想:有点类似冒泡,每次也是得到一个排好顺序的位置。选择一个数,逐个跟其他的比较大小。比如,取第一个,如果有比它小的数,则交换位置。
1 | /** |
选择排序不管是有序无序都会进行选择比较,因此时间复杂度都是O(n^2)
,为什么不是稳定的?
因为每次比较都可能会交换问题,比如3,2,2,1,1,相邻的两个2在第一轮遍历的时候顺序就打乱了。
插入排序
思想:把元素分为两部分,前部分是排好队的部分,后部分是还没有排好队的,当第一次遍历时,第一个元素是排好队的,第二个及之后的元素是需要插入到前面的,所以每次遍历的时候,拿第i+1个元素与前面的元素逐个比较,如果小于,交换元素。指针j继续往前移动,直到arr[i]大于arr[j]或者j<=0
1 | /** |
优化
1 | /** |
原理是一样的,更加容易理解一些,并且性能更好,因为每次比较的时候可以利用局部性缓存原理。
优化
1 | /** |
跟前一种思想一样,只是没有使用swap方法了,前面遍历,移动位置,最后找到key应该插入的位置,赋值就OK。
优化
1 | /** |
避免不必要的重复比较,如果一开始比较比前面的数小,那直接跳出本次内部循环。
快速排序
思想:选择一个哨兵,以这个为基准,对剩余的元素进行比较,小的排左边,大的排右边。当进行完一次遍历后,找到了当前哨兵所应该在的位置。然后对左右两部分递归,选择各自的哨兵,进行排序,当排完之后,就是有序的了。
1 | /** |
程序有两处容易犯错的地方,第一处是quick_Sort()方法
的递归调用,调用外部递归是因为,每次要使的左右两边有序。第二处是partition()方法
有两处while循环,当内部循环之后,两处的交换赋值,需要注意。
快排的平均时间复杂度为O(nlogn),对于排好序(升序或者倒序)的数组,表现的性能最差,因为每次遍历,选择到的哨兵是最大的或者最小的,导致每次遍历完,左侧的要么全是最大的或是最小的,导致没有二分的效果,时间复杂度接近于O(n^2)。
为了避免这种情况,重点在于选择哨兵的方法,我们可以选择数组的中间的那个数做哨兵,也能使用随机数选择,避免出现最快的情况。
三位数取中
1 | int index = (min + max) / 2; |
随机选择哨兵
1 | Random random = new Random(47); |
归并排序
思想:使用分而治之的思想,对一个大的任务分解成多个子任务,进行处理,将子任务处理完得到的解合并,就是所求问题的解。在归并排序中,将带排序的元素分成大小大致相同的两个子集合,分别对子集合进行排序(其实对子集合排序很简单,再对子集合进行分,分,分,直到分成单个元素,然后将单个元素进行从小到大合并,得到的就是有序的子集合),最终将排好序的子集合合并成所要排好序的集合。
其分而治之思想实现为:
1 | /** |
上面一步很容易理解,也有点类似快排。其重要的实现在于将单个元素进行从小到大合并,其中间的过程会产生多个有序的子集合,因为涉及到递归,必会开辟多个栈空间进行方法的运算和存储,所以相比于非递归,递归算法的运算效率较低,在计算时间上和占用空间上都差于非递归实现。当然,可以想一想如何消除递归,用非递归方法实现归并排序?
将单个元素进行从小到大合并实现,可以简单的抽象为有两个排好序的长度为n/2
数组,现在要按照从小到大组合成长度为n
的数组,这组合的过程中要考虑多个不同的情况,共以下三 种:
左边的数全部比右边小
右边的数全部比左边的小
左边的数有比右边的小,也有比右边的大的(这个时候先取小的,再遍历比较)
具体 实现如下:
1 | private static void merge(int[] arr, int low, int middle, int high) { |
总结
以上几种排序是我们最熟悉的排序,也是面试时候经常会问到的排序,理解每种排序的思想远远比记住代码更重要。
最后我们总结归纳:
稳定的排序有三种:冒泡、插入、归并
在已经排好序的情况下 ,应该选择哪几种排序:优化的冒泡、插入