数据结构 排序算法——选择排序与堆排序_直接选择排序和堆序的区别

103 阅读13分钟

img img img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上大数据知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

需要这份系统化资料的朋友,可以戳这里获取

都【堆】在一起,【选】哪个呀😵

🌳选择排序

📕简单选择排序

首先我们来说一说直接选择排序,首先来看一种我们经常见到的

void Select\_Sort2(int\* a, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		int k = i;
		for (int j = i + 1; j < n; ++j) {
			if (a[j] < a[k])
				k = j;
		}
		if (k != i) {
			int t = a[k];
			a[k] = a[i];
			a[i] = t;
		}
	}
}

  • 首先记录下数组中的第一个数,然后从i + 1个数开始向后遍历,若是找到一个比它小的数,则记录下这个数的位置,最后当这个数与记录的第一个数字不相同时,就进行一个交换
  • 这是我们很早就学会了的简单选择排序算法,很直观,在遍历N个数的同时又去层内向后遍历,时间复杂度为O(N2)

给大家看一下动画,方便理解
在这里插入图片描述

📕直接选择排序

然后我们来讲一种略微高效一点的选择排序

  • 其原理就是利用双指针,一个begin前指针,一个end后指针,记录下首尾两个位置
  • 然后设置一个最小值,一个最大值,一开始最小值为begin,最大值为end,接着通过一个内层的for循环从begin到end去做一个遍历,若是找到比这个最小值还要小的,就更新最小值;若是找到比这个最大值还要大的,就更新最大值
  • 很简单又直观,我们来看一下代码
/\*直接选择排序\*/
void Select\_Sort(int\* a, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int mini = begin;
		int maxi = end;
		for (int i = begin; i <= end; ++i)
		{
			/\*更新最大最小值\*/
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}

		}
		//将最小值放在最前面,将最大值放在最后面
		swap(a[begin], a[mini]);
		swap(a[end], a[maxi]);
		begin++;
		end--;
	}
}

但是这种写法有一个缺陷,若是最大值max与前指针begin重合了,就会发生错误

  • 因为当你执行这段语句时,此时的最大值就会被最小值覆盖,最小值会放到最前面的位置
  • swap(a[begin], a[mini]);
  • 此时再去执行这一个语句,便会找不着最大值了
  • swap(a[end], a[maxi]);

我们去VS里测试一下看看

在这里插入图片描述

  • 可以看到,我将最大值9放在首位,后面在排序的时候,首位便发生了错乱,一直不会更新,末尾的位置也是一样,就只是中间进行了排序
  • 那我们要怎么去修正它呢?其实只要在第一次换位的时候做一个判断就可以了,若是这个max最大值与begin重合了,那么此时在更新max只需要更新一下max值就可以了
swap(a[begin], a[mini]);
if (begin == maxi)
{
	maxi = mini;
}
swap(a[end], a[maxi]);
begin++;
end--;

📕选择排序时间复杂度分析

  • 从上面这两种选择排序的方式来看,它们都属于O(N2),而且选择排序无论是在随机排序还是顺序排序下,其时间复杂度都是O(N2),所有说这种排序的效率并不是很高,反倒是我们昨天介绍的直接插入排序来得好,因为若数组是呈有序序列排列的,那直接插入排序的时间复杂度是可以达到O(N)的
  • 所以我们在选择排序算法的时候如果会使用其他的排序算法尽量就不要使用选择排序,看这张表就知道了。这里也只是给大家简单地介绍一下。

在这里插入图片描述

接下来我们来看一种复杂但是很高效的排序算法——堆排序

🌳堆排序

⏳什么是堆?

堆,它得物理结构是一个数组,是一个完全二叉树,而堆排序是根据堆的这种数据结构设计的一种排序,其中堆分为大根堆和小根堆

大根堆

  • 对于大根堆得要求,所有的父亲结点都大于等于其孩子结点

在这里插入图片描述

小根堆

  • 对于小根堆得要求,所有的父亲结点都小于等于其孩子结点

在这里插入图片描述

  • 刚才说到孩子结点与父亲结点,那它们之间的关系是怎样的呢,我们来看一下

【LeftChild = Parent * 2 + 1】
【RightChild = Parent * 2 + 2】
【Parent = (Child - 1)/2】

  • 以上就是它们之间的关系,对于任何的地方都适用

⏳向下调整算法

什么是向下调整算法呢?我们也是分别通过小根堆和大根堆来看一下

小根堆调整

  • 也就是从根节点开始,作为父亲结点,选出其左右孩子中较小的那一个,若是比父亲结点来的小,则交换这两个结点,然后更新交换后的孩子结点为新的父亲结点,继续向下调整,直到叶子结点就终止

大根堆调整

  • 大根堆与小根堆刚好相反。不过一样是从根节点开始,作为父亲结点,选出其左右孩子中较大的那一个,若是比父亲结点来的大,则交换这两个结点,然后更新交换后的孩子结点为新的父亲结点,继续向下调整,直到叶子结点就终止

尤其要注意的一点是如果你要使用这个算法,那根结点左子树和右子树必须都是小堆或者大堆

🐀算法图示

  • 以下就是向下调整算法的原理,首先看到27,为根结点,接着我们看起左子树和右子树是不是均为小顶堆
  • 然后现在它的左右孩子为15和19,经过比较是15来的小,选出小的那个孩子后,就将其与根节点进行一个比较,若是比根节点小的,则将这个小的孩子换上来,若是比根节点大的,就原本就是小顶锥,就不需要换了

在这里插入图片描述

  • 然后在换完一次后,上一次的较小孩子结点就成了下一次得的父亲结点,它也会有自己的孩子,看到小的那个为28,所以将27与18进行一个交换
  • 最后一次的交换便是父亲结点25与27的交换

那这个交换的次数最多是几次呢?

  • 根据我们前面二叉树的知识,这是呈一个等比数列。若一棵满二叉树树的高度为h,那个结点的个数就为2h - 1 = N,那这里是完全二叉树,后面肯定缺了一些结点,那就是2h - 1 - X= N,减1后在减缺的结点X,但是相比N,它是可忽略的值
  • 所以我们可以得到h = log2N + 1 + X,根据这个大O渐进法的规则,算法的常数操作可忽略不计
  • 所以可以得出这个向下调整算法要调整的高度为O(logN)也就是其时间复杂度

但若是这个左右子树不满足是小顶堆或者大顶堆的条件,就不可以去使用这个算法,这时我们就需要进行【建堆】做调整,我们后面再说

🐀代码实现与分析

先来看一下向下调整算法的代码的实现🎒

void swap(int& x, int& y)
{
	int t = x;
	x = y;
	y = t;
}

void Adjust\_Down(int\* a, int n, int root)
{
	int parent = root;
	int child = parent \* 2 + 1;
	while (child < n)
	{
		//选出左右孩子中小的那一个
		if (child + 1 < n && a[child + 1] < a[child])
		{	//考虑到右孩子越界的情况
			child += 1;
				//若右孩子来的小,则更新孩子结点为小的那个
		}
		//交换父亲节点和小的那个孩子结点
		if (a[child] < a[parent]){
			swap(a[child], a[parent]);
			//重置父亲节点和孩子结点
			parent = child;
			child = parent \* 2 + 1;
		}
		else {		//若已是小根堆,则不交换
			break;
		}
	}
}

  • 外层的while循环控制的就是调整到叶子结点时便不再向下做调整,内部的第一个if分支判断是选出左右孩子中小的那一个,因为我们默认左孩子较小,若时右孩子小一些的话,将其值+1便可,注意这里的child + 1 < n,因为a[child + 1]去算它的右孩子时,就有可能会越界了。因此要加一个条件就是child + 1 < n,当最后的这个叶子结点只落在左孩子上时,我们才去进行一个判断
  • 第二个分支判断就是交换父亲结点和小的那个孩子结点,然后进行一个更替,但若是孩子结点这时已经比父亲结点要来的大了,就不需要一直往下调整,已经是OK了,因此直接break就行

⏳建堆

上面我们有说到,当这个左右子树不是大堆或者小堆的时候,就不能去直接使用向下调整算法了,那这个时候怎么办呢?我们就需要去进行一个调整

  • 那我们应该从哪里开始调整呢,是去调整这个叶子6或者1吗,既然它是叶子,我们就没有必要去进行一个调整,只需要调整子树即可,也就是图中的这个8
  • 那我们要怎么去找到8这个结点呢,这就要看它的叶子结点0了,我们上面有说到过一组公式,就是知道一个父亲结点的孩子结点,怎么去求这个父亲结点Parent = (Child - 1)/2
  • 而最后一个结点的值为n - 1,所以我们将其定义为一个循环,从下往上依次去进行一个换位寻找,直到原来的根节点为止,然后内部通过我们上面推导出的【向下调整算法】进行一个改进,变为【向“上”调整算法】
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	Adjust\_Down(a, n, i);

  • 这个叫做自底向上的建堆方式,从倒数第二层的结点(叶子结点得上一层)开始,从右向左,从下到上得向下进行调整
    在这里插入图片描述

⏳排升序,建大堆or小堆?

知道了如何去建一个堆,那这个地基就算搭好了,接下去我们就要使用这个堆积树去进行一个堆排序

  • 一般我们都会去实现升序排序,那对于这个升序排序的话是要建大堆呢还是小堆呢?,许多同学可能都会认为是建小堆,因为小堆的话是根结点来的小,也就是开头来的小,那后面大,也就构成了升序排序,真的是这样吗,我们来探究一番?

在这里插入图片描述

  • 从图示中我们可以看出,若是建小堆的话,那最小的那个数就在堆顶,这个歌时候再去剩下的数里面选数的话,这个根节点就需要重新选择,但是这样的话原本的左孩子结点就会变成新的根结点,而右孩子结点就会变成新的左孩子结点
  • 这就会导致剩下的树结构都乱了,那便需要重新去建堆才能选出下一个数,对于建堆,其时间复杂度我们可以看出来,一层for循环,需要O(n),这就会导致堆排序的时间复杂度上升,那也就没了它的优势
  • 所以我们在是实现升序排序的时候不要用大根堆,效率过低,并且建堆选数,还不如直接遍历选数,遍历的话也是可以找出最小数

从上面的分析可以看出,要实现一个升序排序,不可以去建立一个小顶堆,那我们就应该去建立一个大顶堆,那大顶堆要怎么建立呢?其实只需要稍微改一下我们上面那个【向下调整算法】即可,如下👇

//选出左右孩子中大的那一个
if (child + 1 < n && a[child + 1] > a[child])
{	//考虑到右孩子越界的情况
	child += 1;
		//若右孩子来的小,则更新孩子结点为小的那个
}
//交换父亲节点和大的那个孩子结点
if (a[child] > a[parent]){
	swap(a[child], a[parent]);
	//重置父亲节点和孩子结点
	parent = child;
	child = parent \* 2 + 1;
}
else {		//若已是小根堆,则不交换
	break;
}

  • 也就是修改两个if分支的条件,去找出大的那个孩子结点,找出后若比父亲结点小,则进行一个交换即可
  • 下面就是调整出来的大根堆,这个时候我们只需要将根结点的第一个数与最后一个叶子结点进行一个交换即可

在这里插入图片描述

  • 那接下来应该怎么办呢?我们将刚刚从堆顶换下来的最大的那个数排除在堆外,接下去看我用绿色笔圈起来的两个子树,依旧是呈现一个大顶堆,这时看到可以看到我们两个紫色三角形做的两个标记数,这就是下一次要交换的两个数,也就是每次都将上层的大的数字与最后层的叶子结点进行一个交换,接着在交换之后将最低端的那个数排除在堆外即可,因为即使排除了这个数字,也不会像上面那样改变了整棵树的整体层次结构,左右子树依旧是呈现一个大顶堆的现状

在这里插入图片描述

  • 我们再来分析一下其时间复杂度,向下调整的话我们上面说到过是logN,又因为有N个数需要调整,因此建堆的时间复杂度为O(n),所以它的总体时间复杂度为O(NlogN)

我们来看一下其代码实现

img img img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上大数据知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

需要这份系统化资料的朋友,可以戳这里获取