目录
前言
今天我们将学习排序算法中的 直接选择排序 和 堆排序,它们的基本思想都是 每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。 所以这两个可以统称为 选择排序。
1. 直接选择排序
基本思想
选择排序(Selection-Sort)是一种简单直观的排序算法。
它的基本思想:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
生活中从矮到高排队就是就是运用这种思想。
举个例子:
大家上体育课时,老师都会让大家从矮到高排队,假设有5名同学.
首先老师找到了个子最矮的 5 号同学,然后老师说:5 号同学,你是最矮的,跟 1 号交换一下位置!
这时候,老师又说:4 号同学,你是第二矮的,跟 2 号交换一下位置!
老师又说:2 号同学,你是第三矮的,你和 3 号交换一下位置!
最后,老师说:1 号同学,你是第四矮的,你和 3 号交换一下位置吧!
如此一来,每一轮选出最小者直接交换到左侧的思路,就是选择排序的思路。这种排序的最大优势就是省去了多余的元素交换。
具体步骤:
(1)在元素集合 array[i] 到 array[n-1] 中选择关键码最大(小)的数据元素。
(2)若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换。
(3)在剩余的 array[i] 到 array[n-2](array[i+1] 到 array[n-1])集合中,重复上述步骤,直到集合剩余 1 个元素。
动图演示
我们来看看选择排序的动图演示吧。
代码实现
实际上,我们可以一趟选出两个值,一个最大值和一个最小值,然后将其放在序列开头和末尾,这样可以使选择排序的效率快一倍。
//交换函数
void Swap(int* pa, int* pb) {
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
//直接选择排序(优化版本)
void SelectSort(int* a, int n) {
int left = 0;
int right = n - 1;
while (left < right) {
int mini = left;
int maxi = left;
//遍历区间:[left+1, right]
//选出最小的和最大的,然后交换
for (int i = left + 1; i <= right; ++i) {
//选出最小的数
if (a[i] < a[mini]) {
mini = i;
}
//选出最大的数
if (a[i] > a[maxi]) {
maxi = i;
}
}
Swap(&a[left], &a[mini]); //把最小的数放在最左边
//如果left和maxi重叠,修正一下maxi即可
if (left == maxi) {
maxi = mini;
}
Swap(&a[right], &a[maxi]); //把最大的数放在最右边
left++;
right--;
}
}
直接选择特性总结:
-
直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用.
-
时间复杂度:O(N^2)
-
空间复杂度:O(1)
-
稳定性:不稳定
2. 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
回顾一下:
堆是具有以下性质的完全二叉树:
(1)每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;
(2)每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
如下图所示,就是两种堆的类型:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子:
向下调整算法
既然要进行堆排序,那么就要建堆,而建堆的方式有两种:
1)使用向上调整,插入数据的思想建堆,但是时间复杂度为:O ( N ∗ l o g N )
2)使用向下调整,插入数据的思想建堆,时间复杂度为:O ( N )
所以我们这里推荐使用 堆的向下调整算法,那么建堆也是有 2 个前提的:
(1)如果需要从⼤到⼩排序,就要将其调整为小堆,那么根结点的左右子树必须都为小堆。
(2)如果需要从⼩到⼤排序,就要将其调整为大堆,那么根结点的左右子树必须都为大堆。
但是我们先了解一下什么叫做大堆,如下图:
注意:有一个概念别搞错了,调整大堆并不是把元素从大到小排列,而是每个根节点都比它的叶子节点大
向下调整建大堆算法的基本思想:
(1)从根结点处开始,选出左右孩子中值较大的孩子,让大的孩子与其父亲进行比较。
(2)若大的孩子比父亲还大,则该孩子与其父亲的位置进行交换。并将原来大的孩子的位置当成父亲继续向下进行调整,直到调整到叶子结点为止。
(3)若大的孩子比父亲小,则不需处理了,调整完成,整个树已经是大堆了。
如下图所示:
堆的向下调整算法代码
//向下调整大堆
void AdjustDownBig(HPDataType* a, size_t size, size_t root) {
size_t parent = root;
size_t child = 2 * parent + 1; //默认左孩子最大
while (child < size)
{
//1.找出左右孩子中大的那个
//如果右孩子存在,且右孩子小于size(元素个数),那么就把默认小的左孩子修改为右孩子
if (child + 1 < size && a[child + 1] > a[child]) {
++child;
}
//2.把大的孩子去和父亲比较,如果比父亲大,就交换
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else {
break; //如果孩子小于等于父亲,那么直接跳出循环
}
}
}
使用堆的向下调整算法,最坏的情况下(即一直需要交换结点),需要循环的次数为:h − 1 次( h 为树的高度)。
而 h = log2(N+1)( N 为树的总结点数)。
所以堆的向下调整算法的时间复杂度为:O(logN) 。
任意树调整为堆的思想
上面说到,使用堆的向下调整算法需要满足其根结点的左右子树均为大堆或是小堆才行,那么如何才能将一个任意树调整为堆呢?
答案很简单,我们只需要从 倒数第一个非叶子结点 开始,从后往前,按下标,依次作为根去向下调整即可。
注意:倒数第一个非叶子结点,即为最后一个节点的父亲,也被叫做根。
如下图所示:
建堆代码:
//从倒数第一个非叶子节点开始(最后一个节点的父亲)
//n-1是最后一个节点的下标,(n-1-1)/2最后一个节点的父亲的下标
for (int i = (n - 1 - 1) / 2; i >= 0; --i) {
AdjustDownBig(a, n, i);
}
那么建堆的时间复杂度又是多少呢?
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果):
因此:建堆的时间复杂度为O(N)。
总结一下:
堆的向下调整算法的时间复杂度:O ( l o g N )
建堆的时间复杂度:O ( N )
堆排序
堆排序的基本思想:
1)将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
2)将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3)重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
动图演示
我们来看堆排序的动图过程:
void HeapSort(int* a, int n)
{
//升序 --> 建大堆
//建堆:使用向下调整 --> O(N)
//从倒数第一个非叶子节点开始(最后一个节点的父亲)
//n-1是最后一个节点的下标,(n-1-1)/2最后一个节点的父亲的下标
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDownBig(a, n, i);
}
size_t end = n - 1; //最后一个元素的下标
while (end > 0)
{
Swap(&a[0], &a[end]); //交换第一个元素和最后一个元素
AdjustDownBig(a, end, 0);
--end;
}
}
选择排序的特性总结:
堆排序是一种选择排序,整体主要由 构建初始堆 + 交换堆顶元素和末尾元素并重建堆 两部分组成。
1.堆排序使用堆来选数,效率就高了很多。
2.时间复杂度:O(N*logN)
3.空间复杂度:O(1)
4.稳定性:不稳定