前言❤️❤️
hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
这篇博客会解析3个进阶的排序算法:堆排序、快速排序、归并排序,这3种排序的效率是要优于上篇博客中解析的4个基础排序算法的,都是很有实用价值的,很多标准库中的排序算法,都是基于这几个算法来实现的,Java中的Arrays.sort()方法就是快速排序的改进版本,Collections.sort()方法就是归并排序中的改进版本
这个专栏的数据结构是代码都是用Java来写的,JavaSE专栏现在已经全部更新完成,铁汁们复习基础知识时非常推荐使用,可以试一下💪💪💪
🎇个人主页:洋不写bug的博客
🎇所属专栏:数据结构专栏
🎇复习Java基础知识:Java学习之旅,从入门到进阶
🎇铁汁们对于数据结构基础的各种核心知识(不太常用的也有😆),都可以在上面的数据结构专栏学习,专栏正在持续更新中🐵🐵,有问题可以写在评论区或者私信我哦~
1,堆排序
注:学习堆排序需要的前提是掌握堆相关的知识,要掌握向下调整、建堆操作的原理,并且会写代码,这些知识在数据结构专栏的堆博客中都有详细的解析,想复习下的铁汁可以看下(链接如下)
堆博客链接
要对数组进行升序排序,利用堆就可以这样做:
- 对数组的元素进行建堆操作
- 循环的删除堆顶元素,删除的时候,不是直接删除,而是和最后一个元素交换顺序,这样当前堆的最大值,就来到了数组的最后一个位置,heapSize- -,堆的元素个数减去1,数组的长度仍然不变
- 换到堆顶的那个元素从[0]位置开始向下调整,调整后仍然满足堆的性质,接着重复第2步和第3步的过程,直到heapSize的值减小为1
画图来模拟下这个过程,下图中是一个大堆,交换堆顶元素9和堆的最后一个元素2,heapSize–,再对堆顶元素进行向下调整,如下图,用红色框圈住的就是已经排好序的元素,不用再管了

数组最后一个元素就是2,交换堆顶元素8和元素2的位置,交换后再进行向下调整

以此类推,最后heapSize等于1的时候,交换堆顶元素和堆的最后一个元素,交换上来后,这时候堆中已经没有元素了,堆顶元素也就不需要向下调整,就排好序了

堆排序方法需要先写个shiftDown和createHeap方法,下面只会简单的解析下代码,感觉这部分比较抽象的铁汁,推荐先看上篇堆博客复习一下,里面有这两个方法的超详细解析🐵👍
代码逻辑如下:
- shiftDown方法传入3个参数,数组arr,堆的长度size(注意是堆的长度,不是堆的最大下标),要进行向下调整的元素的下标index
- 当前要进行向下调整的元素就是父结点parent,用child = parent + 1,计算出左子结点的下标,这里我们要建的是大堆,就找到parent左右子结点中的最大值,跟arr[parent]比较,如果arr[parent]更小,就交换父结点和子结点的位置
- 交换parent和child的位置后,新的parent就变成了原来的child,计算出新的child,继续重复这一步骤,直到arr[parent] >= arr[child];或者parent已经没有左右子结点了(也就是parent变成了叶子结点),也就是child >= size了
public static void shiftDown(int[] arr,int size,int index){
int parent = index;
int child = 2 * parent + 1;
while(child < size){
if(child + 1 < size&& arr[child] < arr[child + 1]){
child = child + 1;
}
if(arr[parent] >= arr[child]){
break;
}else{
int tmp = arr[parent];
arr[parent] = arr[child];
arr[child] = tmp;
parent = child;
child = 2 * parent + 1;
}
}
}
在createHeap方法中,先找到二叉树中的最后一个非叶子结点,在数组中从后往前,对这些结点进行向下调整,就能得到一个堆
public static void createHeap(int[] arr){
int lastLeaf = arr.length - 1;
int lastParent = (lastLeaf - 1) / 2;
for(int i = lastParent;i >= 0;i--){
shiftDown(arr,arr.length - 1,i);
}
}
- 在heapSort方法中,先调用createHeap方法去构建出一个堆,初始时[0,bound]就是未排序区间,bound的值为arr.length - 1,写个for循环,每次排序后bound的值减1,直到bound的值减为0,就排好序了
- shiftDown的第二参数传入的是堆的长度,而bound表示的是下标,下标是比长度要小1的,这里删除堆顶元素后,直接传入bound,刚好就相当于堆的长度减去1
- 最后一轮,只需要交换位置,已经不需要向下调整了,传入bound值为0,进入shiftDown方法中,就不会进入while循环
public static void heapSort(int[] arr){
createHeap(arr);
for(int bound = arr.length - 1;bound >= 0;bound--){
int temp = arr[0];
arr[0] = arr[bound];
arr[bound] = temp;
shiftDown(arr,bound,0);
}
}
如果数组需要升序排序,就使用大堆;需要降序排序,就使用小堆,因为每次都是把堆顶的极值放到最后,作为已排序的区间
计算下堆排序的时间复杂度,堆顶元素和堆的最后一个元素交换,新的堆顶元素再向下调整,每次调整时间复杂度为O(logN),总共交换了N次元素,时间复杂度就是O(NlogN),建堆操作的时间复杂度是O(N),合起来就是NlogN + N,因为计算时间复杂度只看最高项,所以堆排序的时间复杂度就是O(NlogN)
堆排序操作都是在数组上进行的,空间复杂度就是O(1)
堆排序属于不稳定排序,因为建堆和向下调整操作,会使值相同的元素,不知道被安排到哪里,最终出堆的顺序是无法预测的
O(NlogN)这个时间复杂度是非常优秀的,像冒泡排序、插入排序、直接选择排序的时间复杂度都是O(N ^ 2),希尔排序进行了优化,也只是优化到了O(N ^ 1.5),随着N的增大,logN的变化是非常非常小的,堆排序算是一个非常高效的算法
2,快速排序
快速排序属于实际开发中,最主要的排序算法,也是面试中最常考的排序算法,快速排序采用的思想是分治,假如要对数组进行升序排序,步骤如下:
-
给定一个待排序的数组,从数组中选择一个“基准值”,拿着这个数组的每个元素,和基准值进行比较,把数组整理成三个部分:
左侧:比基准值小的元素
中间:基准值
右侧:比基准值大的元素 -
递归的进行这个过程,在左侧区间中再选择一个基准值,把左侧区间也整理成左侧 + 中间 + 右侧三个部分(右侧区间也是如此)
-
当递归到一定程度,也就是区间足够小的时候,有序性就可以保证了,例如区间中剩下三个元素了,选择基准值进行整理后,发现一个在左侧一个在右侧;或者区间中剩下两个元素,选择基准值进行整理,另一个元素在区间的左侧或者右侧
-
当所有小的区间都有序了,那整个数组元素就也有序了
那具体在代码细节上如何处理,有三种方法,这三种方法的时间、空间复杂度是完全相同的,区别只在分区过程
- Hoare法,Hoare是发明快速排序的大佬的名字,这个是最经典、使用最多的写法,快速排序的朴素写法就是Hoare法
- 挖坑法
- 前后指针法,不推荐使用,理解成本比较高,甚至面试中写出这个版本,面试官都可能看不懂是啥意思
首先解析下Hoare法,如下图,要把数组进行升序排序,选择数组最右侧的元素作为基准值(基准值就是6),再搞个left引用和right引用
- left引用从前往后走,找到第一个比基准值大的元素
- right引用从后往前走,找到第一个比基准值小的元素

如果两个引用都找到了符合要求的元素,那就交换两个元素的位置,如下图

接着left和right继续匹配符合要求的元素,如果都能匹配到符合要求的元素,那就继续交换;也可能两个引用会重合,如下图

如果两个引用位置重合,就说明找完了,就把重合位置的元素和后面基准值位置的元素进行交换,这时候以6为基准值,6的左区间的元素都小于6,6的右区间的元素都大于6

再对基准值6的左右区间递归进行这个过程,先看左区间,用最右侧元素2作为基准值,right是匹配不到小于2的元素的,就会在元素3下方和left重合

交换重合位置和基准值位置的元素,这时候就满足2左区间的元素小于2,右区间的元素大于2,再递归的对2的左右区间的元素进行这个过程,2的左区间为空,就直接跳过
在2的右区间中,以3作为基准值,left和right重合在元素5的位置

交换3和5的位置,这个区间就符合升序要求了

对6的右区间也递归进行该过程,整个数组就是有序的了

对于一些特殊情况,使用这种方法也是可以的,就比如基准值恰好是数组中的最大值,这时候left移动时,就会一直移动到基准值的位置(区间边界位置),也没有找到比基准值大的元素,接着right从基准值的位置开始准备向前移动,发现left和right重合了,riight就不移动了,交换基准值和重合位置元素的位置,相当于还是没有交换

选用最左侧元素和最右侧元素作为基准值的区别如下:
-
这里我们选择用最右侧元素作为基准值,就是因为在找的时候,先让left从前往后找大于基准值的元素,再让right从后往前找小于基准值的元素,一旦left和right重合,那重合位置的元素值是大于基准值的(在特殊情况下会等于),交换后重合元素的位置交换到了基准值的右侧,是符合升序规则的
-
那如果选用最左侧元素作为基准值,就需要让right先从后往前移动,匹配到比基准值小的元素,再让left从前往后移动,匹配比基准值大的元素,这样left和right重合位置的元素是小于基准值的(特殊情况也会等于),交换后重合元素的位置交换到了基准值的左侧,是符合升序规则的
还要根据降序和升序排序,判断下是让left先扫描,还是让right先扫描:
- 如果是降序排序,那就要left从前往后找小于基准值的元素,right从后往前找大于基准值的元素,都匹配到就交换位置
如果选择最右侧元素作为基准值,那就要确保left和right重合的位置是小于基准值元素的,就让left先走,这个规则搞懂了,不管是升序还是降序,选择最前面还是最后面的元素作为基准值,都能够分析清楚代码怎么写
代码逻辑如下:
-
quickSort方法搞两个重载方法,一个里面传入的是数组arr,用于在main方法中调用使用,另一个里面传入arr,left,right,因为要递归对子区间进行快速排序,这里比较推荐使用左闭右闭的方式来写代码,也就是要排序的区间的范围是[left,right],铁汁们这里如果写成左闭右开的话,就会稍微麻烦一些
-
在quickSort三个参数的方法中,先判定一下,如果left小于等于right,就说明当前区间为空或者只有一个元素,已经有序了,就直接return
-
再写个partition方法,移动left和right,交换元素位置的步骤就在这个方法中,方法的返回值是int,返回最终交换位置后,基准值的下标,还要写个swap方法,负责交换数组中元素的位置,在partition方法中调用swap方法,交换数组元素
public static void quickSort(int[] arr){
quickSort(arr,0,arr.length - 1);
}
public static void quickSort(int[] arr,int left,int right){
if(left >= right){
return;
}
int index = partition(arr,left,right);
quickSort(arr,left,index - 1);
quickSort(arr,index + 1,right);
}
public static int partition(int[] arr,int left,int right){
int value = arr[right];
int l = left;
int r = right;
while(l < r){
while(l < r && arr[l] <= value){
l++;
}
while(l < r && arr[r] >= value){
r--;
}
swap(arr,l,r);
}
swap(arr,l,right);
return l;
}
public static void swap(int[] arr,int l,int r){
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
分析下快速排序的时间复杂度,快速排序的平均时间复杂度是O(NlogN),但最坏情况下时间复杂度为O(N ^ 2)
当要排序的数组刚好是反序的时候,就是最坏情况,如下图,要对数组中的元素进行升序排序
- 选择1作为基准值,left和right移动,left定位到9的位置,right移动到9处,跟left重合,9和1交换位置,这时候1的左区间为空
- 接着开始对1的右区间进行排序,以9作为基准值,left会一直移动到9处,和right重合,9的位置就不需要动,9的右区间为空
- 对9的左区间进行排序,选择2作为基准值,left定位到8的位置,right一直往前移动,直到跟left重合,交换2和8的位置,这时候left的左区间为空

写几步就能发现规律:这种情况下每次递归时,区间的长度只减小了1,因此整体的时间复杂度就是O(N ^ 2),这种情况下,每次排序后,基准值左区间或者右区间就会为空
接着分析下空间复杂度,可能有的铁汁会觉得代码中没有额外创建数组什么的,因此空间复杂度就是O(1),但快速排序是通过递归来实现的,递归中每次方法的调用,都会产生出一系列的栈帧,这都是占用空间的
那就要分析递归的深度是多少,平均情况下,每次递归的区间会缩小一半,空间复杂度就是O(logN),最坏情况下,也就是刚好数组是反序时,每次递归,区间的长度只会减去1,空间复杂度就是O(N)
快速排序可以从三个方面来进行优化:
- 极端情况出现的原因就是取基准值取到了当前区间中的最值,导致操作后区间并没有缩小很多,可能有的铁汁会想到算中位数或者平均值作为基准值,但是计算本身也是有开销的
就可以使用三数取中法,代码简单,效率高,对于待排序区间,取出最左侧元素,中间位置元素,最右侧元素,用这三个元素的中间值作为基准值,这个中间值元素再放到区间最右侧 - 当快速排序递归到一定程度时,每个子问题已经比较小了,继续递归虽然可以解决问题,但可能仍然要递归很多次,如果待排序区间比较小的话,就可以通过插入排序来解决问题,速度特别快(例如要走1000KM,肯定飞机快,但是走1KM,就是汽车快,因为这么短的距离,飞机在天上微调1KM,效率也不高)
- 如果针对特别大的数组进行快速排序,递归深度就可能很高,如果发现递归深度到了一定值以后,就可以使用堆排序,针对分出来的每个区间,直接进行堆排序,一步到位,C++标注库中的sort方法就是这样搞的,根据具体情况切换排序方法
在平均情况下,快速排序的效率是更高的,因为堆排序需要更多次比较和移动元素,但是堆排序的时间复杂度比较稳定
快速排序是通过递归来实现的,面试时为了考察代码能力,可能会考如何非递归的实现快速排序
非递归就是用栈来模拟递归过程,递归的过程,就是在倒腾待排序区间的下标,创建一个Range类表示区间的范围,在栈中存储Range对象,元素出栈,子区间入栈,就可以模拟递归过程
public static void quickSortByLoop(int[] arr){
Stack<Range> stack = new Stack<>();
stack.push(new Range(0,arr.length - 1));
while(!stack.isEmpty()){
Range range = stack.pop();
if(range.left >= range.right){
continue;
}
int index = partition(arr,range.left,range.right);
stack.push(new Range(range.left,index - 1));
stack.push(new Range(index + 1,range.right));
}
}
public class Range {
public int left;
public int right;
public Range(int left,int right) {
this.left = left;
this.right = right;
}
}
3,归并排序
在前面的链表博客中,解析过这样一个题目,就是给出两个有序链表,将两者合并成一个有序链表,归并排序也是这样,只是把两个有序的数组,合并成了一个有序的数组

归并排序使用了分治思想,把待排序的数组分成两份,称为A和B,此时A和B大概率是无序的,就继续拆分,把A拆分为C和D,把B拆分为E和F,以此类推,当拆出的两个数组长度为1时,这两个数组就是有序数组了,就可以直接合并,得到了长度为2的有序数组
简单来说,就是在分治过程中,把长度为N的数组,拆分为N个长度为1的数组
- 把这些长度为1的数组两两合并,就得到了N / 2个长度为2的数组
- 把这些长度为2的数组两两合并,就得到了N / 4个长度为4的数组
- 把这些长度为4的数组两两合并,就得到了N / 8个长度为8的数组
- 当子数组的长度为1时,就开始合并
代码逻辑如下:
-
mergeSort方法搞两个版本,一个参数是arr,用于main方法中直接调用,另一个参数是arr,left,right,表示要进行归并排序的区间,在有三个参数的mergeSort方法中,把当前区间分成两份,再对左右区间继续递归的进行排序,左右区间排序完成后,调用merge方法,来合并两个有序区间
-
merge就是将两个有序数组合并为一个有序数组,传入四个参数,arr,left,mid,right(当然,这个mid也可以不传,根据传入的left和right计算一下即可),再创建一个数组result,用于将两个数组进行合并
-
merge方法中搞两个引用,cur1和cur2,分别指向两个有序数组第一个元素的位置,初始时,cur1就等于left,cur2就等于mid + 1,接着对比arr[cur1]和arr[cur2]的大小,将较小的存储到result中,接着cur1++或者cur2++;写在while循环中,当两个有序数组引用走到末尾,就结束while循环
-
结束while循环后,两个已排序数组,一定会有一个数组的引用没有走到末尾,这个数组后面的元素值比较大,直接把这些元素加到result数组的末尾,result数组这时候就已经完成两个有序数组的合并了,还要再赋值给arr数组
public static void mergeSort(int[] arr){
mergeSort(arr,0,arr.length - 1);
}
public static void mergeSort(int[] arr,int left,int right){
if(left >= right){
return;
}
int mid = (left + right) / 2;
mergeSort(arr,left,mid);
mergeSort(arr,mid + 1,right);
merge(arr,left,mid,right);
}
public static void merge(int[] arr,int left,int mid,int right){
int[] result = new int[right - left + 1];
int resultSize = 0;
int cur1 = left;
int cur2 = mid + 1;
while(cur1 <= mid && cur2 <= right){
if(arr[cur1] <= arr[cur2]){
result[resultSize] = arr[cur1];
resultSize++;
cur1++;
}else{
result[resultSize] = arr[cur2];
resultSize++;
cur2++;
}
}
while(cur1 <= mid){
result[resultSize] = arr[cur1];
resultSize++;
cur1++;
}
while(cur2 <= right){
result[resultSize] = arr[cur2];
resultSize++;
cur2++;
}
for(int i = 0;i < result.length;i++){
arr[left + i] = result[i];
}
}
接着分析下时间复杂度,归并排序就不存在什么最坏情况了,每次都是把区间分成两份,递归的次数是和logN相关的,时间复杂度就是O(NlogN),发挥是比快速排序稳定的
因为在合并区间时,创建一个result数组,所以时间复杂度就是O(N)
归并排序属于是稳定排序,因为不涉及大范围的元素交换,在合并时,如果arr[cur1]和arr[cur2]的值相同,就把arr[cur1]的值先加入到result中
if(arr[cur1] <= arr[cur2]){
result[resultSize] = arr[cur1];
resultSize++;
cur1++;
}
非递归版本的归并排序,就不需要使用栈来模拟递归过程了,本质上就是把长度为1的空间进行两两合并,长度为2的空间进行两两合并,以此类推,就可以直接用两层for循环来实现,代码逻辑如下:
- 外层循环中的size表示要合并的数组的大小,size的初始值是1,接着每次size都乘以2,也就是先合并长度为1的数组,再去合并长度为2的数组,以此类推
- 内存循环就是完成合并操作,i的初始值是0,要合并的区间就是[i,i + size - 1]和[i + size,i + 2 * size - 1],每次i的值加上size * 2
- left或者right的值是有可能超过arr.length - 1的,超过的话,就直接值等于arr.length - 1即可,后面传入到merge方法中,是可以正确的排序的
public static void mergeSortByLoop(int[] arr){
for(int size = 1;size < arr.length;size *= 2){
for(int i = 0;i < arr.length;i += size * 2){
int left = i;
int mid = i + size - 1;
int right = i + 2 * size - 1;
if(left > arr.length - 1){
left = arr.length - 1;
}
if(right > arr.length - 1){
right = arr.length - 1;
}
merge(arr,left,mid,right);
}
}
}
归并排序有两大优点:
- 归并排序是可以针对链表进行高效排序的,堆排序/快速排序只能针对数组,不能针对链表进行排序,因为堆排序中的向上和向下调整是非常依赖数组下标的,快速排序中right和left引用移动匹配符合要求的值,在单链表中是不支持从右往左扫描的
- 归并排序对于海量数据,也是能够处理的,例如有1000GB的数据需要排序,没办法都加载到内存中进行排序,就可以先取出两个1GB文件,合并成一个2GB的文件,再存到硬盘中,同时就可以把之前两个1GB的文件删掉,以此类推(外部排序效率是很低的,最后合并两个500G的时候,每次只能读取一小块来合并,要读取很多次)
结语
这三种排序总结如下:
-
堆排序:先建堆,再循环的删除堆顶元素,也就堆顶元素和堆的最后一个元素交换位置,堆的长度减去1
时间复杂度:O(NlogN)
空间复杂度:O(1)
稳定性:不稳定,建堆和向下调整的过程中,值相同的元素的先后顺序无法预测 -
快速排序:选定基准值,分为左区间,基准值,右区间三个部分,当递归到区间足够下的时候,数组整体就有序了
时间复杂度:平均为O(NlogN),最坏为O(N ^ 2)
空间复杂度:平均为O(logN),最坏为O(N)
稳定性:不稳定,partition方法远距离交换元素位置时,可能会打乱值相同元素的先后顺序 -
归并排序:一直拆分数组,直到子树组的长度为1,接着把子树组进行两两合并,合并为有序数组
时间复杂度:O(NlogN)
空间复杂度:O(N)
稳定性:稳定
- 这篇博客解析了3种进阶排序,算上排序(一)中解析的4种基础排序,一共是7种:冒泡、插入、直接选择、希尔、堆、快速、归并排序;无论是408还是就业面试,都是高频考点,所以初学的铁汁一定要搞清楚原理,多写几次代码🐵💪
.以上就是今天的所有内容啦~完结撒花~🥳🎉🎉


2560



被折叠的 条评论
为什么被折叠?



