常见排序算法,一篇就够!

常见排序算法

这一篇我们来讲排序算法。其中,用处最广,最经典的排序,在这篇文章中也已经整理好了。其中将要讲到的有希尔,选择,快排,堆排等排序,帮助你一篇搞懂常见排序算法。
在这里插入图片描述
首先我们来讲插入排序大类。实际上,希尔排序就是直接插入排序的升级版,当我们了解直接插入排序的原理,希尔排序也就不难了。
想要了解直接插入的原理,我们可以用打牌来作为比较。比如说,当我们手中有一手牌时,我们有很多种方法将牌从大到小排序。而插入排序,实际上就是你先将第一,二张牌按照你想要的顺序排号,然后将第三张牌从后往前依次与前面的两张牌作比较,直至找到合适的位置。接着,依次插入第四张,第五张…并且,待插入的牌前面的牌已经成有序状态。
让我们来看看插入排序的代码:
在这里插入图片描述
从代码中我们可以看出,我们用 i 来保存待插入的数字的下标,之所以要用end来拷贝 i ,是因为接下来我们需要从后向前遍历前面的有序数组,从而找到待插入的合适位置。假设我们要将数组排成升序,我们就用num来记录待排的数字,然后依次与前面的数字比较。如果比代排的数字大,那么就将前面的数字拷贝到后面,从而空出插入的空位,如果比待排的数字小,那么就停止遍历,将代排的数插入空位。当 i 遍历完整个数组时,意味着数组已经排序完成。
说实话,想要了解希尔排序的代码怎么写并不难,难的是希尔排序背后的思想。而想要了解它,我们首先要了解的是插入排序的特性。从插入排序的代码中,我们不难看出,如果一个数组越趋近于有序,那么在一趟插入排序中,待排数字与前面数组比较的次数就越小,插入排序的效率也就越高。所以,希尔排序的思想,就是通过多次小的的插入排序提前对待排数组进行整理,使其接近于有序,从而减少最后一次整体插入排序的消耗。
在这里插入图片描述
假设这个数组里有十二个数字,我们将其分为四个大组,每组三个数字,且同一组内的数字两两相邻三个数字。然后,我们利用for循环依次遍历每个大组,并将组内的数字排成有序。这样,我们就得到了一个大致有序的数组。
不过,到了这里,有些同学可能会疑惑:一次插入排序才一次遍历循环,而我们虽然每次遍历的数组大小是之前的n分之一,却要遍历n次,难道消耗不大吗?让我们来算一笔账。假设这是最坏的情况,每次插入都得遍历到头才能结束。假设这个数组有n个数字,那么一次遍历,需要(1+n-1)n/2次,即nn/2次调整。(由此我们可以看出,插入排序的时间复杂度是O(n* n))。我们将其分为k组,每组需要如果比代排的数字大,那么就将前面的数字拷贝到后面,从而空出插入的空位,如果比待排的数字小,那么就停止遍历,将代排的数插入空位。(n/k)*(n/k)/2次调整,总共需要n *(n/k)/2次。所以我们可以看出,for循环的次数多,不代表消耗大
当我们将待排序的数组进行如上处理后,数组本身已经很接近有序了。这时我们再对整体用一次插入排序,这时,插入排序的时间复杂度就很接近于O(n)。因为需要调整的次数很少,时间复杂度就接近于将数组从头遍历一遍。
我们知道,分组越少,同一组内相邻数字间隔的数字越少,排序后整体就越接近于有序。当同组内相邻数字的间隔数字为0时,整体有序。所以,实际上的希尔排序,是需要将数组进行多次处理的,并且每次处理的分组组数都大于上一次分组,于是数组也就越接近于有序。代码如下:
在这里插入图片描述
注:这里的gap既是分组的组数,又是同一组内相邻数字的下标之差。当gap为1时,实际上就是整个数组为一组。排完后,整个数组有序。

接下来我们要讲选择排序。选择排序又分为普通选择排序与堆排序。普通选择排序较为简单,而堆排序则需要读者本身对堆有一定的了解。下面我们先来讲普通选择排序。
下面我们将普通选择排序简称为选择排序。选择排序实际上就是遍历一遍数组,选出其最大的数放最后面,然后遍历除最后一个数的数组,选出次大的数,以此类推。当然,这样的效率其实并不高,因为一次遍历只能排好一个数字。在我们的代码中,一次遍历,我们将从其中找出最大和最小的数,保存到相应的位置,然后再选出次大的和次小的数,以此类推。它的代码实现也不难,不过要记住,当最值已经被保存在相应位置后,下一次的遍历取值要将这些位置排除在外。由于我们同时找到了两个最值,因此后面的下标要减1,而前面的要加1。
代码如下:
在这里插入图片描述
注:其中SwapNum函数的作用是交换对应位置的数值。

接下来我们来讲堆排序。堆排序在逻辑结构上是树状图,但实际上却使用数组存储。该数组没有固定顺序。因而,对于一个堆,我们只能确定大堆的根节点是最大的,小堆的是最小的。假设我们要排升序数组,我们便用其建大堆,取其最大值与数组末尾交换,然后在用数组除最后一个数建大堆取次大值,依次下来获得升序数组。对于堆排序,需要读者对于堆的结构有较为清晰的理解,我们直接上代码:
在这里插入图片描述
在这里插入图片描述

虽然堆排序的理解对于读者有较高的门槛,因为它需要读者了解二叉树,但我们接下来讲的交换排序非常友好。交换排序其中的快速排序,是几乎所有排序里面效率最高,应用最广的排序。在讲快速排序之前,我们先来看看交换排序里另外一个经典排序,就是我们的冒泡排序。
冒泡排序的原理,实际上就是从前到后将数字依次两两比对,如果要建升序,则将大的数换到后面。第一趟冒泡排序,可以将最大的数排至最后,第二,三趟,则可以将次大,次次大的数排在相应的位置。让我们上代码:
在这里插入图片描述
可以看出,冒泡排序的大体框架是两层for循环,而其中Judge变量的作用是优化我们的冒泡排序。如果在一次遍历中没有发生交换,意味着接下来的数字已经有序,不需要我们再遍历。如果数组本身有序,则只需要遍历一次,从而大大减少工作量。

接下来是许多同学期待的环节,也就是我们的快排啦!
要弄懂快排的代码,首先我们得了解它的思想。首先我们知道,一个数组排好序后,每一个数字都在它该待的位置上。大多数排序通常都是我们遍历一遍数组,找到最大的数,再将其排在最后面。但快排的每一次遍历,都是将数组中大小尽量居中的数字排到它该排的位置上,这个数字可以是随机的,也可以是经过一定筛选选出的较为居中的数字。我们将该数其称为“key"。为了使key在之后的排序中不再变动位置,我们应该使得这个数左边的数都比他小,右边的都比它大。(假设前提是升序)。然后,我们再以这个数左边的数组和右边的数组分为两个子数组,再对其重复之前的操作,然后再分子数组…当子数组无法再分时,我们的数组便排好了。
现在,我们已经知道了快排对于数组操作的基本流程。实际上,快排有一个很关键的问题,就是对数字key的选择。如果我们每次选择的key,都是这个数组的中位数,那么我们每次就可以尽可能平均地将数组分为两个子数组,操作递归的次数也会越少,快排的消耗也就越少。

注:无论我们选取什么数字做”key",我们都将其与下标为0的数字交换,即数字“key"应该放在数组首位。)
那么,我们应该如何尽可能使我们找到的key大小居中呢?针对不同的情况,我们可以采取不同的方法。如果这个数组本身是乱序的,那么我们可以直接将数组的第一个数字作为那个先排序的”key",因为这个数字也是随机的。
但是,要是我们待排的数本身便接近有序呢?那么我们每次选出的key,便接近其对应数组的最值,这很明显不行。为了解决这一个问题,我们可以随机生成一个下标,然后将这个下标所对应的数字与数组的第一个位置对应的数字交换,再将第一个位置对应的数字设为key。这样,我们得到的key便不会受到上述极端情况的影响。
当然,即使我们得到的key是随机的,我们也杜绝不了刚好选中最值的可能性。所以,又有一种方法被提出,就是所谓的 ”三数取中法“。这个方法很好理解,就是找到数组最前面,最后面,最中间位置的三个数字,再将这三个数字相比较,找出其大小居中的数字,再将其与标为0的数字位置交换。这样不仅杜绝了数组原本有序的极端情况影响,还使得我们得到的key绝对不是最值。

选好key后,我们面临的问题是如何将key排在它该排的位置上。(例如,最大的数字应该排在最后,每一个数字都有它该去的位置)首先,我们定义两个下标 front 与 end,front指向首位,en 指向末尾。然后我们让end先走,如果指针对应的数字大于等于 key ,就让 end 减 1,直到停在比 key 小的数字的下标。再让front走,如果对应的数字小于等于key,就让front加 1,直到停在比key大的数字的下标。然后再让 end与front对应的数字交换,这样,比key小的数字便到了前面,比key大的数字到了后面。当end与front重合时,它们重合位置的前面数字全部小于等于 key,后面的数字大于等于 key

如果让end指针先走,end与front重合位置的对应数字一定小于key。下面来证明这个观点。首先,它们重合的情形只可能有两种情况,一种是front撞上end,还有一种是end撞上front 。如果是前者,当front开始走时,end停留的位置在小于key的位置上。如果是后者,由代码知,当end走时,前一轮end与front对应的数字已经交换,所以front里的数字也小于key。所以,当end与key相遇后,它们对应位置的数字一定小于key。于是我们将相遇位置对应数字与下标为0的数字key交换,key的位置便排好了。然后,我们又将key左边与右边分为两个子区间,再进行相同的操作,直到子区间不可再分。让我们上代码。

三数取中代码:
在这里插入图片描述
快排代码:(注:keyi 是数字key的下标)
在这里插入图片描述
在这个版本中,为了让读者们理解为什么让end先走,则end与front相遇的位置一定小于Key,我们花了大量的篇幅来讲解。而我们下面讲的另外一种方法,其本质与前者并无差别,但更好理解。我们也通俗的称其为:挖坑法。
首先,我们创建一个int变量来保存我们的key值,然后将front设为坑。是坑就不能走,于是我们让end先走,直到找到比key小的数,将其赋值给坑位,再将end设为坑。然后front开始走,直到找到比key大的值,将其赋值给坑位,再将front设为坑,以此类推。当end与front相遇后,再将key赋值给它们相遇的坑位。可以看出,挖坑法的底层思想与前者类似,但理解起来简单的多。让我们看看挖坑法的代码:
在这里插入图片描述
为了将key排在它应该的位置,我们要做的是将大于等于key的值放在一起,再把小于等于key的放在一起。这里还有别的方法可以得到我们想要的结果。这种方法难以理解,但是代码非常简单,我们称其为前后指针法。首先,我们先使slow指针指向首位,再使quick指针指向下标为1的位置。如果quick指向的位置对应的数字小于等于key,则slow的位置加1,slow与quick的位置交换,quick的位置再加1。(当然,如果slow加1等于quick,则交换不必要发生。)如果quick指向的位置大于key,则只有quick加1,slow停在原地。这样遍历下去,slow与quick两个指针的距离一定会越来越大,因为随着两个指针的遍历,所有比key大的值全部都被管理到了它们之间,随着quick走到了数组的末尾,所有比key大的数字也被放到了数组的末尾。最后,我们再将slow对应的数字与下标为0的数字key交换。

代码很简洁:
在这里插入图片描述
到这里,我们便结束了对于交换排序的讲解。

最后,我们只剩下了归并排序。归并排序比较简单。假如我们有一个数组,并且要排升序,我们就先将0号与1号排好,再将2,3号排好…假如数组是单数,最后一个不用管。两两遍历整个数组排好后,我们再将排好的相邻两组合并排好,一组四个数。(最后一组不一定)。接下来再依次类推,每一轮合并的数组大小都为前一次的两倍,直到一个组的数字个数大于等于数组本身的个数,数组便完成了排序。让我们看看代码:
在这里插入图片描述
在这里插入图片描述

(小tip:对于二叉树熟悉的朋友们一定了解递归,并且知道前序,中序与后序的区别。从代码中我们可以看出,在归并排序中,我们使用的实际上是后序的思想。而在常规快排中,我们使用的是前序的思想。实际上,快排和归并,都可以使用非递归的方法。由于其本身原来递归顺序的不同,它们由递归改为非递归的方式也有所不同。)
看到这里的朋友们辛苦了,希望大家也能从中收获一些知识。
让我们一起加油叭!

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值