文章目录
-
- 第一部分:各种排序方法的比较
- 第二部分:插入排序
-
- 第三部分:交换排序
-
- 第四部分:选择排序
-
- 1、简单选择排序
- 2、堆排序
-
- 2.1 堆的概念
- 2.2 堆的调整算法
- 2.3 堆的构造
- 2.4 堆排序的流程
- 2.5 插入和删除元素
- 第五部分:归并排序
- 第六部分:基数排序
- 第一到六部分小结
第一部分:各种排序方法的比较
在下面几种较常用的排序方法中,具有共同性质的可以用如下口诀记忆:(“些”就是希尔排序中“希”的谐音;“队”即“堆”的谐音)
- 不稳定性:(情绪不稳定的时候)快些选一堆好友来聊天
- 算法的稳定性是指当待排序表存在两个关键字相同的元素时,排序前后两者的位置没有发生变化,那么就说这个排序算法是稳定的。它是算法的性质之一,并不能衡量算法的优劣,如果题目里要求了待排序表不能出现重复元素,那么该性质就无关紧要。
- 快速排序、希尔排序、选择排序、堆排序都是不稳定的排序。
- 时间快:(速度快就)快些归队
- 快速排序、希尔排序、归并排序、堆排序都是平均时间复杂度较低的排序。
第二部分:插入排序
在第一部分的表中所列的插入排序是指直接插入排序,在插入排序的大类下有三种排序算法:直接插入排序、折半插入排序、希尔排序。
1、直接插入排序
直接插入排序方法,概括一下就是:待排序的元素一个一个往前移,插到已排序元素中。
- 上面是从小到大排序(从大到小把下面的大于小于条件互换一下),使用顺序表时要预留下标为0的位置用于存储哨兵;
- 对于还没有排序的元素,首先与它的前一个元素(属于已排序元素)进行比较:
- 如果小于前一个元素的关键字,就将其复制为哨兵;然后在已经排序的元素中,从后往前比较找出插入的位置,将插入位置往后的元素(已经排序的元素,即被比较元素的前一个元素)全部后移一位,空出插入位后将哨兵位置保存的元素插入,之后继续对下一个元素重复上一级的比较操作;
- 如果大于前一个元素的关键字,那么久让其保持在原位,继续对下一个元素重复上一级的比较操作。
直接插入排序适用于顺序表和链表,如果是使用链表时可以比较方便的执行插入操作,也不需要预留哨兵位置,直接修改指针即可。
2、折半插入排序
折半插入排序的步骤和直接插入排序是一样的,唯一的区别在于查找插入位置的过程采用折半查找,而不是按顺序逐个查找。
- 在直接插入排序中,查找与移动操作是合并在一起的(一个循环里),所以在折半插入排序中首先将两个操作分离,同时采用折半查找加快查找速度;
- 由于折半查找只适用于顺序表,所以折半插入排序也只适用于顺序表;
- 其平均时间复杂度和直接插入排序是一样的,同时也是稳定的排序方法,所以表格内并没有单独列举两种排序,而是合并称“插入排序”。
3、希尔排序
希尔排序和折半插入排序一样,都是对直接插入排序的改进。
我们在对直接插入排序的分析中发现,当待排序表基本有序且数据量不大时,时间复杂度将会显著下降。为此希尔排序的基本思想就是:
① 先有规律的取出待排序表中的部分数据进行直接插入排序,利用其适合数据量不大的待排序表的特性;
② 在多次执行部分排序后待排序表已经变得基本有序,又满足了另一个适用条件,就可以再对整个待排序表使用一次直接插入排序。
- 通常我们通过相隔某个“增量”来取数据组成子表,然后对子表进行直接插入排序后放回原表中;
- 随后减小这个“增量”再重复上面的步骤,直到这个“增量”变为 1,此时相当于对整个待排序表进行一次直接插入排序;
- 到目前为止,尚未求得一个最好的“增量序列”,常用的有: d i = { 5 , 3 , 1 } d_i=\{5,3,1\} di={5,3,1}。
① 我们在选取的时候只需要注意“初始增量”不能大于表长;
② 每趟排序都会操作到所有元素,且产生的子表个数等于“增量”值,在上图中“增量”为 5 时,{44}、{15}、{36}、{26}都单独构成一个子表;
③ 如果某个子表中只含有一个元素,那么本趟排序这个元素的位置将保持不动,因为没有其他元素可以和它换位置。
第三部分:交换排序
在第一部分的表中所列的冒泡排序和快速排序,其同属于交换排序这一大类。两种交换排序法均适用于顺序表和链表。
1、冒泡排序
冒泡排序方法,简单概况一下就是:相邻两个数逐一比较。
- 从第一个元素开始,相邻的两个元素比较大小,如果逆序就进行交换操作:
- 如果从小到大排序,那么前一个元素更大就是逆序;
- 如果从大到小排序,那么后一个元素更大就是逆序;
- 在每一趟排序结束后,当前序列的最后一个元素就是本趟的最大/最小元素,因此不再参与下一趟排序;
- 当待排序元素少于两个时,排序结束;换句话说,当待排序列中只剩最后两个逆序元素时,本趟排序是最后一次排序。
我们会发现,冒泡排序需要执行的趟数,在从小到大的排序中取决于最小元素在序列中的位置;在从大到小的排序中取决于最大元素在序列中的位置。这是因为通常这个元素是最后一个被交换到正确位置上的。
2、快速排序
快速排序基于分治法的思想,在待排序表中选出一个元素作为枢纽,将比它小/大的元素置于一侧,比它大/小的元素置于另一侧,这一过程就是在“分”;形成两个子表后,再分别对两个子表进行快速排序,这一过程就是在“治”。不难看出快速排序中包含了递归操作。
- 依据严蔚敏老师的《数据结构》,我们通常选择当前表的第一个元素作为枢纽;
- 每一次划分操作完成后,需要返回划分完成的待排序表以及枢纽的位置(顺序表的数组下标或链表指针),然后基于枢纽的位置可以知道左子表的终止位置(枢纽位置-1)和右子表的起始位置(枢纽位置+1),从而分别对两个子表执行递归操作;
- 直到子表内只包含一个元素时,视为排序结束。
不难看出划分操作才是快速排序的核心,通常划分函数需要获取待排序表、最低位置(low)、最高位置(high):
- 最低位置和最高位置指示了需要操作的元素范围,这样我们就可以只分配一个表的存储空间,仅通过改变这两个位置来指示对表的哪一部分进行划分(即明确子表的位置)。
- 以从小到大排序为例,划分主要包含过程:
- 假设两个指针 i 和 j,初始时分别指向 low 和 high;
- 此时指针 i 所指的元素是枢纽,然后让指针 j 从后往前找到第一个小于枢纽的元素,交换两者的位置;
- 此时指针 j 所指的元素是枢纽,我们再让指针 i 从前往后找到第一个大于枢纽的元素,交换两者的位置;
- 重复上面两个步骤,直到指针 i 和 j 指向同一个元素,即枢纽元素。
① 上面的步骤中如果是从大到小排序,则指针 j 负责找大于枢纽的元素交换;指针 i 负责找小于枢纽的元素交换。
② 在实际操作中如果采用数组,首先指针 i 和 j 就是指数组下标;其次通常会单独保存枢纽的关键字,然后把交换操作变为赋值操作,在确定了枢纽最终的位置后,再把之前记录的关键字存储到该位置。
第四部分:选择排序
第一部分表中的选择排序就是指简单选择排序,在选择排序这一大类下还包含堆排序,是选择排序的重点。
1、简单选择排序
简单选择排序的思想非常简单:在序列中找到最小元素,依次换到序列最前端。
- 简单选择排序的每一趟排序就包含两个步骤:查找(比较)和交换;
- 以第一趟为例:
- 从第一个元素开始,顺序比较待排序表中的每一个元素,找出最小元素;
- 将最小元素和第一个元素互换位置(如果最小元素不在第一个),然后将第一个元素从待排序表中去除。
- 之后的每一趟都查找当前待排序表中最小的元素,即初始待排序表中第 i 小的元素,将其和第 i 个元素交换位置(如果该元素不在第 i 个),然后将第 i 个元素从待排序表中去除;
- 直到第 n-1 趟做完,即待排序表元素只剩下一个。
第 i 趟开始时待排序表元素个数为:n-i+1
;
第 i 趟结束后待排序表元素个数为:n-i
;
每一趟查找最小元素都需要比较n(n-1)/2
次,与初始状态无关,所以简单选择排序很费时,不常用。
2、堆排序
堆排序比较复杂,在介绍具体排序过程之前需要先说几个概念。
2.1 堆的概念
在堆排序中,我们所说的“堆”简单来说就是完全二叉树,只不过这个完全二叉树有个特点:它的非叶子结点要么大于任意一个孩子结点,要么小于任意一个孩子结点。
- 如果其非叶子结点大于任意一个孩子结点,就称其为大顶堆;
- 反之如果其非叶子结点小于任意一个孩子结点,就称其为小顶堆。
对于大顶堆来说,其值最大的结点是根节点;
对于小顶堆来说,其值最小的结点也是根节点;
因此根节点也是堆排序算法实现的核心,所以堆排序也属于选择排序。
很多人可能会有一个问题:既然“堆”就是完全二叉树,那为什么还要叫“堆排序”而不是“完全二叉树排序”?
换句话说,堆不是动态分配空间的栈吗,怎么就变成完全二叉树了?
- 要解答这个疑惑,首先要知道堆排序是指使用堆结构对一个序列进行排序。也就是说我们在排序过程中使用到的是堆的两大特性:
- 和栈一样“出口”和“入口”在同一侧;
- 可以在执行过程中动态分配空间,即随时更新。
这就是为何在堆排序中涉及到了插入元素和删除元素,堆排序的一大优势就是能处理一个随时会有更新的序列;
同时也暗示了我们堆排序的插入元素和删除元素需要在同一侧进行。
- 然后我们可以思考一下为什么用完全二叉树表示堆:
- 完全二叉树不同于普通二叉树,它限制了结点必须按从上到下、从左到右的顺序逐层编号,对树的插入和删除操作只能从最后的序号开始,保障了增删过程不会影响其他结点的编号。这样仅能在“树尾”进行操作就类似于堆栈里仅能在栈顶进行操作。
- 同时完全二叉树又不像满二叉树一样要求叶子结点全在最下层,进而导致满二叉树的增删就必须操作一整层的结点;而完全二叉树却可以实现单个结点的增删。
- 并且我们都知道二叉树是可以用顺序表(数组)存储的,在存储结构上也满足堆栈的需求。
- 而逐层编号使完全二叉树上层元素与下层元素在数组中的位置存在固定的规律,方便根据孩子结点定位双亲结点(孩子是 i ,则双亲是 ⌊ i / 2 ⌋ \lfloor i/2 \rfloor ⌊i/2⌋,向上调整法中涉及)以及根据双亲结点定位孩子结点(结点是 i ,则左孩子是2i,右孩子是 2i+1,向下调整法中涉及)。
需要补充一点,堆并不一定都是完全二叉树,只是在这里我们讨论的堆都可以表示为完全二叉树。
例如:二项堆、斐波那契堆就不属于二叉树,这里就不展开说明。
2.2 堆的调整算法
堆的调整算法是堆排序的核心所在。通常在实际场景中,我们会被给予一个数组,该数组对应了一个完全二叉树,我们需要把它按实际需求进一步调整为“大顶堆”或“小顶堆”。
- 执行一次调整算法就相当于进行一次选择,如果实际需求是每次选出最小元素那么就调整为“小顶堆”,反之调整为“大顶堆”;
- 调整完成后的根节点就是我们要选取的元素。
具体的调整算法分为两种,下面以构建小顶堆为例介绍:
① 向下调整算法:
- 从根节点开始,先计算其左右孩子的下标,然后比较对应下标的元素大小,选出较小的一方记录下标。(注意第 i 个结点的下标为 i-1,其左孩子下标为 j=2i-1=2(i-1)+1,其右孩子下标为 j+1=2i。)
- 再将较小的一方与根结点比较,如果孩子结点小于根结点就交换两者的位置。
- 然后将此时位于该记录位置的结点作为下一个待比较的结点,重复与根节点相同的操作。直到计算出的孩子结点下标大于等于数组长度 n,说明当前待比较的结点已经是叶子结点。
不难发现,在进行一轮向下调整算法后,根节点处的元素似乎是完全二叉树第1、2层里的最小值,似乎并没有达到找出整个序列最小值,即构建小顶堆的效果;
这是因为对完全二叉树使用该算法进行小顶堆构造时,要求当前二叉树的左右子树已经是小顶堆,这是很难实现的一种理想情况。(解决办法将在堆构造里介绍)
② 向上调整算法:
- 与向下调整算法相反,用最后一个序号的结点,先计算其双亲结点的下标并记录。(注意第 i 个结点的下标为 i-1,其双亲下标为 j=[(i-1)-1]/2。)
- 再将双亲结点与该结点比较,如果双亲结点大于当前结点就交换两者位置。
- 然后将更新位置后的该结点重复上面的操作。直到计算出的双亲结点下标小于 0,说明插入结点已经来到根结点位置。
通常向上调整算法用于已经是小/大顶堆的完全二叉树,是其插入新元素后进行的调整。因为该方法不会与兄弟、堂兄弟结点进行比较,这就要求整个二叉树的每个非叶子结点都要小于/大于它的孩子结点,这样比较才有代表性。
所以当第二步发现插入元素结点不小于/不大于其双亲结点时,就不会发生交换,插入元素留在最后一个结点位置。
2.3 堆的构造
堆的构造即“小/大顶堆”的构造,以下简称为“堆”。我们在前面说了只有当完全二叉树的左右子树本身就是“堆”时,才可以通过一次向下调整算法实现。
这听起来似乎很难实现,但是有一个特例——叶子结点:单个结点本身就是一个二叉树,所以它也算一个“堆”。
- 我们可以从最后一个非叶结点开始(也可以从最后一个结点开始,只不过,如果从最后一个结点开始效率不高),按序号对每一个结点使用堆的向下调整,直到根节点,这样就能构造一个“堆”了。
- 如果一个完全二叉树有 n 个结点,那么它的最后一个非叶结点编号为 n 2 − 1 \frac{n}{2}-1 2n−1。
2.4 堆排序的流程
在完成堆的构造之后,虽然不能保障堆是完全有序的,但是却能保证根结点是最大的(大顶堆)或最小的(小顶堆)。于是:
我们可以每次选出一个根结点,将其划到有序序列里面,并对剩余结点进行调整,使剩余部分再次成为一个堆,然后对这个堆再进行上述操作,这也就是为什么堆排序属于选择排序的原因。
但是树是不能没有根节点的,也就是说我们并不能直接把根节点去除。因此:
- 选出根节点后,让根节点与此时无序序列中的最后一个结点(第一次选择的时候就和最后一个结点交换,第二次选择的时候和倒数第二个结点交换,以此类推)进行交换;
- 此时根节点到达下方成为有序序列中的一员,将它从待排序列中去除;
- 而原本最后的结点到达根节点位置,由于这个结点到来,堆的结构被破坏,由于仅仅是当前的根节点破坏了堆的结构,所以只对该结点使用向下调整算法,重新生成一个堆,然后重复上述操作。
以小顶堆为例,经过上述操作,每次根节点到达有序序列的前一个位置,于是整个数组成为了降序排列;
以大顶堆为例,经过上述操作,整个数组就成为了升序排列。
因此:需要升序排序,就建立大顶堆,需要降序排序,就建立小顶堆。B:堆排序演示
2.5 插入和删除元素
① 插入:
将新元素插入到最后一个结点的下一位置(尾插到序列最后),由于这个结点的到来可能再次破坏了堆的结构(仅破坏了一部分),因此使用堆的向上调整算法重新建堆。
② 删除:
堆的删除默认只能删除第一个元素,因为删除其他元素没有意义(堆排序过程中去除根节点就是用到了删除)。将最后一个元素与第一个元素交换,然后对新的根使用堆的向下调整算法重新建堆。
第五部分:归并排序
和希尔排序一样,归并排序采用的是分治法的思想。二者其实都可以说是一种逻辑方法,底层实现还是需要依靠其他的排序,例如希尔排序“分”之后常采用插入排序进行“治”,所以将其归类在插入排序下。
- 归并排序的逻辑就是对序列不断进行划分,直到划分后产生的子序列是有序的,或者子序列只包含一个元素,就可以开始对序列进行合并(即“归并”——回归合并成原长序列),并且在合并的过程中排序(找出两个待合并序列中最小的元素,把它先加入到合并序列中)。
- 通常归并过程中采取两两归并,这样的排序方法被称为2路归并排序。也有多路归并,但2路归并使用的较多。
虽然归并排序的逻辑非常好理解,但是其代码较为复杂,需要使用递归。且排序过程不能在原序列中进行,需要额外分配空间,其空间复杂度为O(n),是所有排序中最大的。
- 在排序函数中先递归调用自身进行拆分过程,直到拆分为单个元素,再开始继续运行递归语句后的合并语句,合并结束就完成一次递归。
- 合并过程中对两个序列(实际存储在一个数组中,通过两对指针区分)中的元素逐个比较,找出其中的最小/最大值复制到另一个序列(数组)中,所有元素都复制进去就完成了合并与排序。
第六部分:基数排序
基数排序是一种很特别的排序,它不基于对关键字整体的比较而移动排序,而是基于关键字“各位”的大小进行排序。如果把一个关键字的“各位”单独视为一个关键字,那么就可以说基数排序是借助多关键字排序的思想进行单逻辑关键字排序。
- 为了实现基数排序,我们需要用到 r 个队列,其中 r 的值取决于关键字各个位的取值范围大小(例如每一位是数字的话,取值范围是 0-9,则 r=10)。
- 按先后顺序依次对各个位进行大小比较,然后将关键字中当前位相同的元素按序列中的先后顺序,依次加入到对应的队列中,直到所有元素都入队。
- 然后按队列的顺序依次将各个队列首尾相接,得到一个完整的序列,完成一趟排序。
- 关键字有多少位就要进行多少趟排序,各个位的先后顺序可以按照最高位优先法(MSD)或最低位优先法(LSD)。
- 如果是三位数字,最高位优先法就按照“百十个”的顺序进行三趟入队再连接的操作,从而完成基数排序;最低位优先法则按照“个十百”的顺序进行三趟入队再连接的操作,从而完成基数排序。
r 个队列会被重复使用,所以空间复杂度为O(r);
因为要频繁分配(入队)和收集(首尾相接),所以常采用链式结构。
第一到六部分小结
各种排序算法的比较都在第一部分。上面我们提到的排序算法都属于内部排序算法:
① 内部排序:
内部排序是排序的基础,在排序的过程中,把所有元素调到内存中进行排序,称之为内部排序。
② 外部排序:
在数据量大的时候,只能分块排序,但是块和块排序不能保证有序,外部排序用读写次数来衡量其效率。