手撕排序算法2:堆排序和直接选择排序
一.堆排序
1.前言,前置知识点
1.学会堆排序必须掌握的有关堆的知识点:
补充:堆的知识点
堆的逻辑结构是一颗完全二叉树
堆的物理结构是一个数组
也就是说,给我们是一个数组,可是我们要把它想象成一个完全二叉树来做
通过下标父子结点关系
leftchild = parent * 2 + 1;
rightchild = parent * 2 + 2;
parent = (child - 1) / 2;(child可以是左孩子,也可以是右孩子)
下面我们通过一张图片来更加深刻地理解堆
堆的两个特性
1.结构性:用数组表示的完全二叉树
2.有序性:任意节点的关键字是其子树所有结点的最大值
3.堆的两种分类
最大堆(MaxHeap):也称为大顶堆(最大值)
最小堆(MinHeap):也称为小顶堆(最小值)
3.大堆:要求树中所有的父亲都大于等于孩子
小堆:要求所有的父亲都小于等于孩子
堆只有两种:大堆,小堆,其余的都不是堆,注意有些选择题常考堆的判别
大堆:堆顶数据是最大的
小堆:堆顶数据是最小的
2.前置算法剖析
首先我们要知道
> 堆排序(Heapsort)
> 是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种。
> 它是通过堆来进行选择数据。
> 需要注意的是排升序要建大堆,排降序建小堆。这一点我们在后面后给大家进行解释
> 而给我们的数组又不一定是堆
> 所以我们要先把该数组转变为堆
> 然后再利用堆的特性来进行堆排序
接下来介绍一个著名的建堆方法:(在这里我们按照建小堆的方式来介绍建堆算法)
向下调整算法:最多调整高度次,也就是O(log(2)N)
2^h-1-x=N(x:最后一层缺的节点,相比N,它是一个可以忽略不计的值)
不过这个算法需要满足一个前提:
左右子树都是小堆!!!
方法:
1.从根节点开始,选出左右孩子中小的那一个
2.将它跟父亲比较,如果比父亲小就交换,否则就终止
3.如果进行了交换,然后继续往下调,调到叶子节点就终止
4.记住一句话:物理上我们操纵的是数组,脑子里我们操纵的是二叉树
例如:下面这个图片
//建小堆
//记住一句话:物理上我们操纵的是数组,脑子里我们操纵的是二叉树
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustDown(int* a,int n,int root)//root传入0,即根节点的下标
{
int parent = root;
int child = root * 2 + 1;//假设左孩子小,右孩子大
while (child < n)//child>=n时数组越界
{
//1.选出左右孩子中小的那一个
if (child + 1 < n && a[child] > a[child + 1])//左孩子大于右孩子,
//child+1 < n:用来防止因为某个结点下方因为没有右孩子只有左孩子而产生数组越界
{
child++;//假设不成立,则将小孩子更新为右孩子
}
if (a[child] < a[parent])//孩子小于父亲,需要交换
{
Swap(&a[child], &a[parent]);//交换孩子和父亲
parent = child;//更新父亲
child = parent * 2 + 1;//更新孩子
}
else
{
break;//孩子大于等于父亲,无法交换,直接break
}
}
}
那么建大堆该如何建呢?
只需要修改我在下面代码中注释的地方即可,Swap函数见上面
void AdjustUp(int* a,int n,int root)//root传入0,即根节点的下标
{
int parent = root;
int child = root * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1])//这里我们选出左右孩子中大的那一个
{
child++;
}
if (a[child] > a[parent])//这里只有当父亲小于孩子的时候才会交换
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
3.代码实现
1.那么如果左右子树都不是小堆,也就是不能使用向下调整算法了,那我们该怎么办呢?
答案是:
倒着从最后一棵子树开始调整
从最后一个非叶子结点的子树开始调整
> 因为堆的物理结构是一个数组,数组最后一个元素的下标是n-1
> 也就是说最后一个叶子节点是的下标是n-1,而且不难发现最后一个非叶子结点就是最后一个叶子节点的parent
> 又因为我们在上面提到过
> parent=(child-1)/2
> 所以最后一个非叶子结点的下标值为(n-1-1)/2
> 而且最后一个非叶子结点之前的所有结点都是非叶子结点
> 下面我们来看一下代码
void HeapSort(int* a,int n)
{
int i = 0;
for (i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);//你没看错,将一个普通数组构造为一个小堆就是这么简洁,也就是从最后一个非叶子节点开始调整一直调整到根节点为止
}
//到现在为止我们堆排序的建堆工作就完成了
}
2.那么排升序我们要建什么堆呢?
答案是:大堆,这个答案确实挺出人意料的,但是为什么呢?
下面我们来详细解释一下这个原因
1.建小堆为什么不可以(不建议):
2.建大堆为什么可以(建议):
因为:
堆排序是属于选择排序的一种.
如果是建小堆,最小数在堆顶,已经被选出来了,
那么在剩下的数中再去选数,但是剩下的树结构都乱了,需要重新建堆才能选出下一个数,
建堆的时间复杂度是O(N),我们在讲解堆排序的最后会给大家证明这个建堆的时间复杂度.
那么这样不是不可以,但是堆排序就没有效率优势了并且建堆选数还不如直接遍历选数
其次,如何选次小的数呢?
第二个数去做根了,剩下的树关系全乱了,再重新建堆,
建堆的时间复杂度:O(N),而建堆选数排序,时间复杂度:O(N^2)
并且建堆选数还不如直接遍历选数
下面我给大家画图来演示一下:
2.建大堆:
步骤:
1.第一个和最后一个交换,然后把交换后的那个较大的数(即位于数组末尾的那个数)不看做堆里面
2.前n-1和数进行向下调整算法,选出次大的数放到根节点,再跟倒数第二个位置交换
代码如下:
void HeapSort(int* a,int n)
{
int i = 0;
for (i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustUp(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustUp(a, end, 0);
--end;
}
}
下面给大家画图演示一下,能帮助大家有更好的理解
4.堆排序的时间复杂度和空间复杂度
1.建堆的时间复杂度
2.整体的时间复杂度
void HeapSort(int* a,int n)
{
int i = 0;
for (i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustUp(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustUp(a, end, 0);
--end;
}
}
向下调整算法最坏的情况:进行高度次,即log(2)N次,所以while循环最坏进行了(n-1)log(2)N次,而for循环的建堆的时间复杂度为O(N)
所以整体的时间复杂度为O(Nlog(2)N)
整个效率相对来说是很高的
下面我们进行一下性能测试
// 测试排序的性能对比
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();//生成100000个随机数
a2[i] = a1[i];//保证所有的数组中的元素均相等
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
}
int begin1 = clock();//clock()获取到系统运行到这里的毫秒数
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
我们可以看出,堆排序14ms,已经非常快了,
我们再看一下直接选择排序,它比直接插入排序还要慢将近3000ms,可见它是效率非常低的排序
3.堆排序的空间复杂度:
因为堆排序只在自身数组当中进行运算,并未形成其他数组,所以空间复杂度为O(1)
5.堆排序的稳定性
堆排序是不稳定的
因为建堆的时候有可能发生以下的类似情况
下面,我们来剖析一下直接选择排序
二.直接选择排序
1.算法剖析
基本思想: 每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完.
代码实现
void SelectSort(int* a, int n)
{
int i = 0;
for (i = 0; i < n; i++)//i:排好序的元素的最大下标
{
int j = 0;
int min = i;//从i下标位置开始选出最小值
for (j = i; j < n; j++)
{
if (a[min] > a[j])//更新最小值
{
min = j;
}
}
Swap(&a[min], &a[i]);//把最小值放在i下标位置处
}
}
可见,时间复杂度是O(N^2),
而且,需要注意的是直接选择排序的双层for循环中并没有出现break
也就是说无论待排序的序列是有序还是无序还是部分有序
直接选择排序并不考虑这些,它是找最大值(或者最小值)依次放到数组的起始位置
2.优化版本算法剖析
上面代码不难理解,下面我们来给大家看一个优化版本的直接选择排序,这个优化版本比上一个版本快一倍,不过时间复杂度也是O(N^2)
void Swap(int* n1, int* n2)
{
int tmp = *n1;
*n1 = *n2;
*n2 = tmp;
}
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int min_i = begin;
int max_i = begin;
for (int i = begin; i <= end; i++)
{
if (a[i] < a[min_i])
{
min_i = i;
}
if (a[i] > a[max_i])
{
max_i = i;
}
}
Swap(&a[begin], &a[min_i]);
//如果begin跟max_i重叠,需要修正一下max_i的位置
if (begin == max_i)
{
max_i = min_i;
}
Swap(&a[max_i], &a[end]);
begin++;
end--;
}
}
这个优化版本的思想是:
1.通过begin和end逐步缩小待排序的范围,当begin>=end时,排序完成
2.通过min_i和max_i选择出[begin,end]内的最大值和最小值,并把最小值与begin位置处的数据交换,把最大值与end位置处的数据交换.
下面是不修正max_i的代码
void Swap(int* n1, int* n2)
{
int tmp = *n1;
*n1 = *n2;
*n2 = tmp;
}
void SelectSortUpper(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int min_i = begin;
int max_i = begin;
int i = 0;
for (i = begin; i <= end; i++)
{
if (a[min_i] > a[i])
{
min_i = i;
}
if (a[max_i] < a[i])
{
max_i = i;
}
}
Swap(&a[min_i], &a[begin]);
Swap(&a[max_i], &a[end]);
begin++;
end--;
}
}
下面给大家画图解释一下为什么要修正max_i的位置
3.时间复杂度和稳定性
1.时间复杂度
直接选择排序的时间复杂度是O(N^2)
都是等差数列
未优化版本:
N+(N-1)+(N-2)+… -> O(N^2)
优化版本
N+(N-2)+(N-4)+… -> O(N^2)
2.对直接选择排序的评价
在这里我们可以看出:插入排序为1698ms,直接选择排序为4533ms,同样时间复杂度都是O(N^2),但是却相差这么大,
所以也就是说直接选择排序效率非常差,是效率最差的排序算法,
后面讲到冒泡排序(时间复杂度也是O(N^2))的时候我们还要再对比一下这三者的时间复杂度.
3.稳定性
直接选择排序不具有稳定性,下面给大家举一个例子
以上就是堆排序和直接选择排序的讲解,希望能给大家带来帮助