⭐️前面的话⭐️
本篇文章带大家认识排序算法——堆排序与归并排序,堆排序在讲解堆和优先队列的时候已经将原理介绍了一点点,并使用优先级队列实现了堆排序,本文将使用建堆和向下调整的方式实现堆排序,每种排序算法都有一个核心,向下调整是堆排序的核心,而归并排序的核心就是合并两个有序的数组,实现代码语言为java。
📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创,CSDN首发!
📆首发时间:🌴2022年2月26日🌴
✉️坚持和努力一定能换来诗与远方!
💭参考书籍:📚《Java核心技术》,📚《Java编程思想》,📚《数据结构》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🙏作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!
📌导航小助手📌
题外话: 本文所有的排序算法设计都基于升序排列,降序排列的思路是一样的,只需将思路调换一下即可。
1.堆排序
1.1堆排序
堆排序就是利用堆来对数组进行排序,堆的底层结构是一个数组,逻辑结构是一棵完全二叉树,按照层序遍历的顺序储存在数组中,其中堆顶元素为数组的最大或最小值(大堆堆顶元素为最大值,小堆相反),堆排序思路就是将堆顶元素与end
下标(end初始为待排序序列最后一个元素所对应的下标)的元素进行交换,然后调整堆顶元素使end
下标之前的元素所在堆还原为大根堆或小根堆,最后end--
,因为end
下标处的元素已经确定了排序位置。
所以如果要对序列进行升序排列,需要使用大根堆,因为大根堆堆顶是最大值,与序列最后一个元素交换后,最大值就位于序列的末端了,此时该元素排序位置也就确定了,反之如果需要降序排列,需要使用小根堆。
1.2建堆与向下调整
1.2.1向下调整
堆的向下调整就是调整结点的位置,使调整的路径上满足大根堆或小根堆的条件,以调整大根堆为例,对某一结点为根结点的堆进行向下调整,首先找到两个子结点中最大的那一个结点,然后与根结点进行比较,如果比根结点大,则交换,交换后使新的根结点为被交换结点的位置,对此位置所在结点继续进行相同的比较与交换,直到调整的结点不在堆的合法范围内或子结点没有比根结点大为止,此时这样的一个过程就叫做向下调整,因为它调整的方向是向下的。经过一次向下调整之后,就大根堆而言,被调整路径上的结点都会满足父结点的值大于子结点的值。
举一个栗子吧,请出我们的老朋友,[18, 16, 12, 23, 48, 24, 2, 32, 6, 1],还不赶快欢迎一下?
它所对应堆结构如图所示:
对1下标所对应的结点进行一次向下调整(基于大根堆),也就是值为16的结点。
绿色结点所对应的路径就是被调整的路径,我们发现该路径上所有的结点都满足父结点大于子结点。
像这样的一个调整过程就叫做向下调整。
假设需要调整堆的元素个数为len
,调整结点的下标为parent
,则左子结点下标为child=parent*2+1
,右子结点下标为child+1
,要注意调整的过程中parent
,child
均不能大于或等于len
,目地是防止越界,也是向下调整结束标志之一。
向下调整实现代码:
/**
* 交换数组两个元素
* @param arr 目标数组
* @param index1 交换下标1
* @param index2 交换下标2
*/
public void swap(int[] arr, int index1, int index2) {
int tmp = arr[index1];
arr[index1] = arr[index2];
arr[index2] = tmp;
}
/**
* 向下调整
* @param array 目标数组
* @param parent 父亲结点下标
* @param len 调整堆的结点数目(前len个不能超过数组长度)
*/
private void shiftDown(int[] array, int parent, int len) {
int child = 2 * parent + 1;
while (child < len) {
if (child + 1 < len && array[child] < array[child+1]) child++;
if (array[child] > array[parent]) {
swap(array, parent, child);
parent = child;
child = 2 * parent + 1;
} else {
break;
}
}
}
1.2.2建堆
因为我们需要对数组进行升序排序,所以我们进行排序的第一步就是将数组转换成大根堆,我们知道对某一结点进行向下调整,则被调整路径上的所有结点都满足大根堆的条件,所以要将一个数组建成大根堆需要按从下往上从右至左的顺序对堆进行向下调整,这样就能满足所有的结点都满足父结点大于子结点的值,一个大根堆也就建好了。
那么建堆的第一步就是找最后一个子堆,且该堆的元素个数要大于等于2个,即找到最后一个带有子结点的根结点,从这个结点开始以从右至左从下至上的顺序依次对该结点以及前面的结点进行向下调整,调整完堆顶元素这个大堆就建好了。
如,现在我们需要将[18, 16, 12, 23, 48, 24, 2, 32, 6, 1]这个数组改造成大根堆,最后一个带有子结点的子树根结点为48
,需要对如图红色的结点进行向下调整,且调整顺序为从右至左从下至上,具体顺序为48,23,12,16,18
。
大根堆建造过程:
知道了建堆该怎么建,下面就是来使用代码实现了,不妨设父结点下标为parent
,子结点下标为child
,数组序列长度为len
,一开始子结点下标指向最后一个结点,即child=len-1
,则父结点下标为parent=(child-1)/2
(此时父结点为最后一个含有子结点的子树),只使用len
来表示,最后一个含子结点的子树根结点下标为e=(len-2)/2
,按照从右至左总下到上对下标e
及其e
前面所以元素进行向下调整,调整完成后,大根堆也大功告成了!
建堆实现代码:
/**
* 根据数组创建大根堆
* @param array 目标数组
*/
private void creatBigHeap(int[] array) {
int child = array.length - 1;
int parent = (child - 1) / 2;
for (int i = parent; i >= 0 ; i--) {
shiftDown(array, i, array.length);
}
}
1.3堆排序的实现
前面已经简单介绍了堆排序的原理,这里来梳理一下堆排序(升序)的步骤:
- 将数组建成大根堆。
- 使用
end
标记数组(堆)中最后一个待排序的元素下标,将堆顶元素与end
下标的元素交换。 - 对堆顶元素进行向下调整,调整堆的范围为
0~end-1
下标,调整长度为end
。 end--
,即将原来end
下标元素标记为已排序。
堆排序图示过程:
堆排序实现代码:
/**
* 堆排序
* @param array 待排序数组
*/
public void heapSort(int[] array) {
//1.升序排列,需要建造大堆,将待排序数组转换成大根堆
creatBigHeap(array);
int end = array.length - 1;
//2.堆顶元素0~end下标对应数组中的最大值,将堆顶元素与end出元素交换,则该元素已经排好序了
while (end > 0) {
//3.堆顶元素与end出元素交换
swap(array, 0, end);
//4.堆顶元素处大根堆被破坏,其他树依然是大根堆,所以需要对堆顶元素进行向下调整,但要将已经排好序的元素排除在外
shiftDown(array, 0, end);//除去上一次排好序的元素,长度为end
end--;
}
}
1.4性能分析
时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|
O ( N l o g N ) O(NlogN) O(NlogN) | O ( 1 ) O(1) O(1) | 不稳定 |
2.归并排序
2.1合并两个有序的数组
在认识归并排序前,我们先来看一道题,就是将两个有序的数组合并成一个有序的数组。
这道题不难,假设第一个有序数组array1
的长度为len1
,第二个数组array2
的长度为len2
,申请一个大小为len1+len2
用来存放合并后的数组,然后比较两数组的元素按从小到大的顺序将数据拷入新建的数组中,直到其中有数组遍历完,最后将未遍历完数组中剩下的元素拷入新申请的数组即可。
实现代码:
/**
* 合并两个有序的数组
* @param array1 有序数组1
* @param array2 有序数组2
* @return 合并后的有序数组
*/
public int[] mergeArray(int[] array1, int[] array2) {
int[] tmp = new int[array1.length+array2.length];//临时数组,用来存放合并后的数组
int start1 = 0;
int start2 = 0;
int index = 0;
//较小的元素存在前
while (start1 < array1.length && start2 < array2.length) {
if (array1[start1] <= array2[start2]) tmp[index++] = array1[start1++];
else tmp[index++] = array2[start2++];
}
//将剩余的元素依次存入tmp
while (start1 < array1.length) tmp[index++] = array1[start1++];
while (start2 < array2.length) tmp[index++] = array2[start2++];
return tmp;
}
而归并排序的核心思想就是合并有序的数组,首先将数组分组,初始时每组为一个元素,然后按照合并有序数组的思路将每组元素按照两两一组合并,然后继续将已经合并的数组作为一组,继续与其他组进行合并,知道所有元素合并到一组为止,此时数组也有序了。
在线练习:
88. 合并两个有序数组
参考代码(java):
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int[] tmp = new int[m+n];//临时数组,用来存放合并后的数组
int start1 = 0;
int start2 = 0;
int index = 0;
//较小的元素存在前
while (start1 < m && start2 < n) {
if (nums1[start1] <= nums2[start2]) tmp[index++] = nums1[start1++];
else tmp[index++] = nums2[start2++];
}
//将剩余的元素依次存入tmp
while (start1 < m) tmp[index++] = nums1[start1++];
while (start2 < n) tmp[index++] = nums2[start2++];
for (int i = 0; i < m+n; i++) {
nums1[i] = tmp[i];
}
}
}
2.2归并排序
归并排序核心思想是合并有序数组,那么首先需要将数组分解成一个一个的元素,并进行分组,每组一个元素,此时每组的元素全部是有序(因为只有一个元素),然后两两一组进行有序合并,得到的合并数组也是有序数组,最后将合并的数组继续与其他合并的数组合并,直到完全合并为止。
对于数组的分解再合并,最常见的思路就是递归,递入的时候分解,归出的时候合并,完美地解决问题。
当然也可以非递归,对数组进行分组,每组元素为gap
,初始时为1
,按照2
组为一个单位进行有序合并,合并后每组元素为gap=2*gap
(不是所有组是这样,因为不一定能均分,但也不影响),直到每组元素个数大于或等于排序对象元素个数len
为止。
假设第一组序列左边界为left
,第一组序列右边界为mid
, 第二组序列的右边界为right
,则合并后数组的长度为right-left+1
,如果只有left
和每组组数gap
已知,则mid=ledt+gap-1
,right=mid+gap
。
归并合并有序序列代码:
/**
* 合并有序序列
* @param arrray 目标序列
* @param left 第一组序列左边界
* @param mid 第一组序列右边界(记为中点)
* @param right 第二组序列右边界
*/
private void merge(int[] arrray, int left, int mid, int right){
int[] tmp = new int[right - left + 1];//临时数组,用来存放合并后的数组
int start1 = left;
int start2 = mid + 1;
int index = 0;
while (start1 <= mid && start2 <= right) {
//有序向临时数组中传入数据
if (arrray[start1] <= arrray[start2]) {
tmp[index++] = arrray[start1++];
} else {
tmp[index++] = arrray[start2++];
}
}
//将剩下的元素放入临时数组中
while (start1 <= mid) tmp[index++] = arrray[start1++];
while (start2 <= right) tmp[index++] = arrray[start2++];
//将临时数组中的元素对应拷贝到array中
for (int i = 0; i < index; i++) {
arrray[i + left] = tmp[i];
}
}
2.3归并排序的实现
2.3.1递归实现归并排序
递归实现归并排序时,递过程分解数组,归过程合并数组,分解数组时需要将每组都分成单个元素为止,不妨设左边界下标:left 右边界下标:right 中点下标:mid=(left+right)/2,则此时left=mid=right
。所以当left=right
时就应该开始归,以开始合并数组,所以递归条件是left
不小于right
,满足条件就返回,归回合并。
以数组[18, 16, 12, 23, 48, 24, 2, 32, 6, 1]为例,图解如下,但是递归分解时不是左右两边的序列数组同时分解合并,而是先分解左再分解右(先分解右后分解左也是一样的,但是不是同时进行),合并也是一样的。
归并排序动图演示:
归并排序递归实现:
/**
* 归并排序
* @param array 待排序数组对象
*/
public void mergeSort(int[] array) {
mergeSortFunc(array, 0, array.length-1);
}
private void mergeSortFunc(int[] array, int start, int end) {
if (start >= end) return;
int mid = start +((end - start) >>> 1);
//对数组进行分解
mergeSortFunc(array, start, mid);
mergeSortFunc(array, mid + 1, end);
//对数组进行有序合并
merge(array, start, mid, end);
}
2.3.2非递归实现归并排序
非递归实现归并排序就不需要像递归那样先进行数组分解,首先因为我们所实现的合并数组方法是在同一个数组上进行改动的,需要第一组左边界下标left
作为参数,第一组右边界(也就是中点)mid
,第二组右边界right
作为参数,所以本质上就是解决这三个参数应该传什么,我们先回到归并排序的原理上来,归并排序的基本步骤如下:
- 对数组进行分组,初始时每组元素个数为
1
。 - 以两组为一个单位对数组进行合并,如果存在“落单”的数组等待下一次合并。
- 合并后的数组作为全新的一组,继续与其他组进行两两合并,“落单”数组下一次合并时再合并,直到组数大于或等于待排序序列长度为止,此时数组也已经排好序了。
- 分组合并的时候需要控制
left
,mid
,right
下标不要越界,如果越界调整为待排序序列最后一个元素的下标。
假设知道第一组左边界left
和每组组数gap
,则mid=ledt+gap-1
,right=mid+gap
。
我们可以使用一个循环对数组遍历,遍历下标为i
,那么left=i
,按照上面的公式mid
与right
均可以求出来,每组组数为gap
(初始为1
组),合并后,除了最后一组的元素个数不能保证为2*gap
,其他组的元素个数均为2*gap
,所以每对所有序列完成完一组合并后(一趟合并),gap=2*gap
。因为每次是以两组为一个单元进行合并,每遍历一次就对一个单元进行合并,所以i
的增量为2*gap
,当然合并过程中需要保证调整后的mid
与right
不能越界,如果越界需要调整为待排序序列的最后一个元素下标。每次合并数组前需要验证每组元素个数gap
是否小于待排序序列长度,如果满足则继续合并,不满足则表示数组已经排列完成。
非递归实现图解:
非递归归并排序实现:
/**
* 归并排序非递归
* @param array 待排序数组
*/
public void mergeSortIter(int[] array) {
//对待排序数组进行分组,每组元素个数从1开始,进行两组为一单位的有序合并,以合并完成后的数组为一组,继续进行有序数组合并,直到整个数组有序为止
int gap = 1;//每组元素个数
while (gap < array.length){
for (int i = 0; i < array.length; i += gap * 2) {
//合并在同一个数组上相邻两段序列需要以下3个参数
int left = i; //第一组第一个元素下标
int mid = left + gap - 1; //第一组最后一个元素下标
int right = mid + gap; //第二组最后一个元素下标
//合并时两段序列元素个数不一定相等,合并前需要验证mid和right是否越界,如果越界则改为数组最后一个元素的下标
if (mid >= array.length) mid = array.length - 1;
if (right >= array.length) right = array.length - 1;
//开始合并两段序列
merge(array, left, mid, right);
}
gap *= 2;//数组合并后,每组元素个数为上一次一组元素个数的2倍
}
}
2.4性能分析
时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|
O ( N l o g N ) O(NlogN) O(NlogN) | O ( N ) O(N) O(N) | 稳定 |
到这里,常见的七种基于比较的排序就已经全部介绍完毕了,分别是冒泡,选择,插入,希尔,快速,堆,归并排序,对于基于非比较的排序后面博主也会安排上的,常见基于非比较的排序算法有三种:计数,基数,桶排序。
⭐️排序算法博文回放⭐️