1
0
mirror of https://github.com/Didnelpsun/CS408.git synced 2026-02-09 05:44:32 +08:00
Files
CS408/Data-Structrue/10-sort.md
2021-05-31 23:35:44 +08:00

20 KiB
Raw Blame History

排序

基本概念

  • 排序:将一个数据元素的任意序列重新排列成一个按关键字有序的序列。
  • 内部排序:待排序的记录存放在计算机的内存中所进行的排序操作称为内部排序。
  • 外部排序:待排序的记录数量很大,以致内存一次不能容纳全部记录,在排序过程中需要访问外存的排序过程称为外部排序。
  • 稳定的排序比如一个序列是“1,4,3,3*,2”按从小到大排序后变成“1,2,3,3*,4”就叫做稳定排序即3和3*相对顺序不变。如果相同关键字的顺序发生了改变,则是不稳定的排序。稳定性的需要看具体的应用场景。
  • 内部排序的算法性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动次数决定的。外部排序除此之外还要考虑磁盘读写速度和次数。
  • 大部分排序算法都仅适用于顺序存储的线性表。

插入排序

插入排序就是将选定的目标值插入到对应的位置。

直接插入排序

直接插入排序的过程

每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成为止。

直接插入排序的性能

空间复杂度为$O(1)$。

时间复杂度主要来自对比关键字,移动元素,若有$n$个元素,则需要$n-1$趟处理。

最好情况是原本的序列就是有序的,需要$n-1$趟处理,每次只需要对比一次关键字,不用移动元素,时间复杂度为$O(n)$。

最坏情况是原本的序列是逆序的,需要$n-1$趟处理,第$i$趟处理需要对比关键字$i+1$次,移动元素$i+2$次,时间复杂度是$O(n^2)$。

所以平均时间复杂度是$O(n^2)$。

直接插入排序算法是稳定的。

如果使用链表实现直接插入排序,移动元素的次数变少了,但是关键字对比次数仍然时$O(n^2)$,从而整体时间复杂度依然是$O(n^2)$。

二分插入排序

二分插入排序的过程

也称为折半插入排序,是对直接插入排序的优化,在寻找插入位置时使用二分查找的方式。

当data[mid]==data[i]时为了保证算法的稳定性会继续在mid所指位置右边寻找插入位置。

当low>high时停止折半查找并将[low,i-1]内的元素全部右移并把元素值赋值到low所指的位置。

二分插入排序的性能

空间复杂度为$O(1)$。

二分插入排序是稳定的。

比起直接插入排序,比较关键字的次数减少,移动元素的次数没变,所以总体时间复杂度为$O(n^2)$。

希尔排序

希尔排序的过程

希尔排序也是对直接插入排序的优化。直接插入排序对于基本有序的序列排序效果较好,所以就希望序列能尽可能基本有序。从而希尔排序的思想就是先追求表中元素部分有序,然后逐渐逼近全局有序。

先将整个待排序元素序列分割成若干个子序列由相隔某个“增量”的元素组成的分别进行直接插入排序然后缩小增量重复上述过程直到增量为1。

增量序列的选择建议是第一趟选择元素个数的一半,后面不断缩小到原来的一半。

希尔排序的性能

空间复杂度为$O(1)$。

而时间复杂度和增量序列的选择有关,目前无法使用属性手段证明确切的时间复杂度。最坏时间复杂度为$O(n^2)$,在某个范围内可以达到$O(n^{1.3})$。

希尔排序是不稳定的。

希尔排序只适用于顺序表而不适合用于链表,无法快速进行增量的访问。

交换排序

交换排序即根据序列中两个元素关键的比较结构然后交换这两个记录在序列中的位置。

冒泡排序

冒泡排序的过程

从后往前或从前往后两两比较相邻元素的值,若逆序则交换这两个值,如果相等也不交换,直到序列比较完。这个过程是一趟冒泡排序,第$i$趟后第i个元素会已经排序完成。每一趟都会让关键字最小或最大的一个元素到未排序队列的第一个或最后一个。一共需要$n-1$趟排序。

冒泡排序的性能

空间复杂度为$O(1)$。

最好情况下即本身序列有序则比较次数是n-1交换次数是0从而时间复杂度是$O(n)$。

最坏情况是逆序情况,比较次数和交换次数都是$\dfrac{n(n-1)}{2}$,所以时间复杂度是$O(n^2)$。

从而平均时间复杂度是$O(n^2)$。

冒泡排序是稳定的。

冒泡排序可以用于链表。

快速排序

快速排序在内部排序中的表现确实是最好的。排序过程类似于构建二叉排序树。

快速排序的过程

取待排序序列中的某个元素pivot作为基准一般取第一个元素通过一趟排序将待排元素分为左右两个子序列左子序列元素的关键字均小于或等于基准元素的关键字右子序列的关键字则大于基准元素的关键字然后分别对两个子序列继续进行排序直至整个序列有序。

  1. 先选择个值做标杆把标杆放入pivot比标杆小的放左边大的放右边。
  2. 初始时令high指向序列最右边的值low指向序列最左边的值。
  3. 然后从high开始当遇到比标杆大的值时high--。
  4. 当遇到比标杆小的值就取出这个值放入当前low所指的位置。
  5. 然后high不动low++开始移动。
  6. 若low所指向的值比标杆小low++。
  7. 若low所指向的值比标杆大则放入当前high所指向的位置。
  8. 然后low不动high--开始移动。回到步骤三开始执行。
  9. 当low=high时表示low和high之前的元素都比基准小low和high之后的元素都比基准大完成了一次划分。然后把基准元素放入low和high指向的位置。
  10. 不断交替使用low和high指针进行对比。对左右子序列进行同样的递归操作即可从步骤三开始。若左右两个子序列的元素数量等于一则无需再划分。

快速排序的性能

由于快速排序使用了递归,所以需要递归工作栈,空间复杂度与递归层数相关,所以为$O(递归层数)$。

每一层划分只需要处理剩余的待排序元素,时间复杂度不超过$O(n)$,所以时间复杂度为$O(n\times\text{递归层数})$。

而快速排序会将所有元素组织成为二叉树,二叉树的层数就是递归调用的层数。所以对于$n$个结点的二叉树,最小高度为$\lfloor\log_2n\rfloor+1$,最大高度为$n$。

从而最好时间复杂度为$O(n\log_2n)$,最坏时间复杂度为$O(n^2)$,平均时间复杂度为$O(n\log_2n)$;最好空间复杂度为$O(\log_2n)$,最快空间复杂度为$O(n)$。

所以如果初始序列是有序的或逆序的,则快速排序的性能最差。若每一次选中的基准能均匀划分,则效率最高。

所以对于快速排序的性能优化是选择尽可能能中分的基准元素,入选头中尾三个位置的元素,选择中间值作为基准元素,或随机选择一个元素作为基准元素。

快速排序算法是不稳定的。

选择排序

选择排序就是每一趟在待排序元素中选取关键字最小或最大的元素加入有序子序列。

简单选择排序

简单选择排序的过程

即每一趟在待排序元素中选取关键字最小的元素加入有序序列。

简单选择排序的性能

空间复杂度为$O(1)$。

时间复杂度为$O(n^2)$。

简单选择排序是不稳定的。

简单选择排序也可以适用于链表。

堆排序

堆的定义

若$n$个关键字序列$L$满足下面某一条性质,则就是堆:

  1. 若满足$L(i)\geqslant L(2i)$且$L(i)\geqslant L(2i+1),(1\leqslant i\leqslant\dfrac{n}{2})$则是大根堆或大顶堆。
  2. 若满足$L(i)\leqslant L(2i)$且$L(i)\leqslant L(2i+1),(1\leqslant i\leqslant\dfrac{n}{2})$则是小根堆或小顶堆。

堆的建立

其实堆就是顺序存储的完全二叉树。其中:

  • $i\leqslant\lfloor\dfrac{n}{2}\rfloor$的结点都是非终端结点。
  • $i$的左孩子是$2i$。
  • $i$的右孩子是$2i+1$。
  • $i$的父结点是$\lfloor\dfrac{n}{2}\rfloor$。

所以建立根堆的过程是:

  1. 从$t\lfloor\dfrac{n}{2}\rfloor$的结点开始往前遍历。
  2. 检查当前结点$i$与左孩子和右孩子是否满足根堆条件,若不满足则交换。
    • 若是建立大根堆,检查是否满足根大于等于左、右结点,若不满足,则当前结点与更大的一个孩子互换。
    • 若是建立小根堆,检查是否满足根小于等于左、右结点,若不满足,则当前结点与更小的一个孩子互换。
  3. 若元素互换破坏了下一级的堆,则采用同样的方法继续向下调整。
    • 若是建立大根堆,则小的元素不断下坠。
    • 若是建立小根堆,则大的元素不断下坠。

堆排序的过程

  1. 每一趟将堆顶元素加入子序列(堆顶元素与待排序序列中的最后一个元素交换)。此时后面的这个元素就排序好了。
  2. 此时待排序序列已经不是堆了,需要将其再次调整为堆(小元素或大元素不断下坠)。
  3. 重复步骤一二。
  4. 直到n-1趟处理后得到有序序列。基于大根堆的堆排序会得到递增序列而基于小根堆的堆排序会得到递减序列。

堆排序的性能

堆排序的存储就是它本身,不需要额外的存储空间,要么只需要一个用于交换或临时存放元素的辅助空间。所以空间复杂度为$O(1)$。

若树高为$h$,某结点在第$i$层,则将这个结点向下调整最多只需要下坠$h-i$层,关键字对比次数不超过$2(h-i)$次。

第$i$层最多$2^{i-1}$个结点,而只有第$1\cdots(h-1)$层的结点才可能需要下坠调整。所以调整时关键字对比次数不超过$\sum_{i=h-1}^12^{i-1}2(h-i)=\sum_{j=1}^{h-1}2^{h-j}j\leqslant2n\sum_{j=1}^{h-1}\dfrac{j}{2^j}\leqslant4n$。

所以建堆的过程中,关键字对比次数不超过$4n$,建堆的时间复杂度为$O(n)$。

堆排序中处理时根结点最多下坠$h-1$层,而每下坠一层,最多对比关键字两次,所以每一趟排序的时间复杂度不超过$O(h)=O(\log_2n)$,一共$n-1$趟,所以时间复杂度为$O(n\log_2n)$。所以总的时间复杂度也是$O(n\log_2n)$。

堆排序是不稳定的。

堆的插入

新元素放到表尾,并与其$\lfloor\dfrac{i}{2}\rfloor$的父结点进行对比,若新元素比父元素更大(大根堆)或更小(小根堆),则二者互换,并保持上升,直到无法上升为止。

堆的删除

被删除的元素用堆底元素代替,然后让这个元素不断下坠,直到无法下坠为止。

归并排序

归并是指把两个(二路归并)或多个(多路归并)已经有序的序列合并为一个。

该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。

二路归并排序

二路归并排序比较常用,且基本上用于内部排序,多路排序多用于外部排序。

二路归并排序的过程

  1. 把长度为$n$的输入序列分成两个长度为$\dfrac{n}{2}$的子序列。
  2. 对这两个子序列分别采用归并排序。
  3. 将两个排序好的子序列合并成一个最终的排序序列。

二路归并排序的性能

二路归并排序是一棵倒立的二叉树。

空间复杂度主要来自辅助数组,所以为$O(n)$,而递归调用的调用栈的空间复杂度为$O(\log_2n)$,总的空间复杂度就是为$O(n)$。

$n$个元素二路归并排序,归并一共要$\log_2n$趟,每次归并时间复杂度为$O(n)$,则算法时间复杂度为O(n\log_2n)

归并排序是稳定的。

分配排序

分配排序过程无须比较关键字,而是通过用额外的空间来“分配”和“收集”来实现排序,它们的时间复杂度可达到线性阶$O(n)$。简言之就是:用空间换时间,所以性能与基于比较的排序才有数量级的提高。

基数排序

计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

基数的定义

假设长度为$n$的线性表中每个结点$a_j$的关键字由$d$元组$(k_j^{d-1},k_j^{d-2},\cdots,k_j^1,k_j^0)$组成,其中$0\geqslant k_j^i\geqslant r-1,(0\geqslant i\geqslant d-1)$,其中$r$就是基数。

基数排序的过程

若是要得到递减序列:

  1. 初始化:设置$r$个空辅助队列$Q_{r-1},Q_{r-2},\cdots,Q_0$。
  2. 按照每个关键字位权重递增的次序(个、十、百),对$d$个关键字位分别做分配和收集。
  3. 分配就是顺序扫描各个元素,若当前处理的关键字位为$x$,就将元素插入$Q_x$队尾。
  4. 收集就是把$Q_{r-1},Q_{r-2},\cdots,Q_0$各个队列的结点依次出队并链接在一起。

基数排序的性能

基数排序基本上使用链式存储而不是一般的顺序存储。

需要$r$个辅助队列,所以空间复杂度为$O(r)$。

一趟分配$O(n)$,一趟收集$O(r)$,一共有$d$趟分配收集,所以总的时间复杂度为$O(d(n+r))$。

基数排序是稳定的。

基数排序的应用

对于一般的整数排序是可以按位排序的也可以处理一些实际问题如根据人的年龄排序需要从年月日三个维度分别设置年份的队列、月份的队列1到12、日的队列1到31

所以基数排序擅长解决的问题:

  1. 数据元素的关键字可以方便地拆分为$d$组,且$d$较小。
  2. 每组关键字的取值范围不大,即$r$较小。
  3. 数据元素个数$n$较大。

计数排序

作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序的过程

  1. 找出待排序的数组中最大和最小的元素。
  2. 统计数组中每个值为i的元素出现的次数存入数组C的第i项。
  3. 对所有的计数累加从C中的第一个元素开始每一项和前一项相加
  4. 反向填充目标数组将每个元素i放在新数组的第C(i)项每放一个元素就将C(i)减去1。

当输入的元素是$n$个属于$[0,k]$的整数时,时间复杂度是$O(n+k)$,空间复杂度也是$O(n+k)$,其排序速度快于任何比较排序算法。

当$k$不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。

计数排序是稳定的。

桶排序

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。

桶排序的过程

  1. 设置一个定量的数组当作空桶。
  2. 遍历输入数据,并且把数据一个一个放到对应的桶里去。
  3. 对每个不是空的桶进行排序。
  4. 从不是空的桶里把排好序的数据拼接起来。

桶排序的性能

桶排序最好情况下使用线性时间$O(n)$,桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为$O(n)$。桶排序的平均时间复杂度为线性的$O(n+C)$,其中$C=n\times(\log n-\log m)$,其中$m$代表桶划分的数量。

很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

桶排序是稳定的。

内部排序

算法种类 最好时间复杂度 平均时间复杂度 最好时间复杂度 空间复杂度 是否稳定
直接插入排序 O(n) O(n^2) O(n^2) O(1)
冒泡排序 O(n) O(n^2) O(n^2) O(1)
简单选择排序 O(n^2) O(n^2) O(n^2) O(1)
希尔排序 ? ? ? O(1)
快速排序 O(n\log_2n) O(n\log_2n) O(n^2) O(\log_2n)
堆排序 O(n\log_2n) O(n\log_2n) O(n\log_2n) O(1)
二路归并排序 O(n\log_2n) O(n\log_2n) O(n\log_2n) O(n)
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+r)) O(r)

外部排序

外部排序的原理

外部排序的过程

磁盘的读写是以块为单位,数据读入内存后才能被修改,修改完成后还需要写回磁盘。

外部排序就是针对数据元素太多,无法一次性全部读入内存进行排序而进行处理的在外部磁盘进行的排序处理方式。

使用归并排序的方式,最少只用在内存分配三块大小的缓冲区即可堆任意一个大文件进行排序。然后对缓冲区里的数据进行内部排序。

外部排序的过程:

  1. 生成初始归并段,需要读写并进行内部排序
  2. 重复读写,进行内部归并。

外部排序时间开销=读写外存时间(最大的时间开销)+内部排序所需时间+内部归并所需时间。

外部排序的优化方法

优化方法就是使用更多路的多路归并,减少归并趟数。

$k$路平衡归并:最多只能有$k$个段归并为一个;每一趟归并中,若有$m$个归并段参与归并,则经过这一趟处理得到$\lceil\dfrac{m}{k}\rceil$个新的归并段。

对$r$个初始归并段,使用$k$路归并,则归并树可以使用$k$叉树表示,若树高为$h$,则归并趟数为$h-1=\lceil\log_kr\rceil$。

但是多路归并会带来负面影响:

  1. $k$路归并时,需要开辟$k$个输入缓冲区,内存开销增加。
  2. 每挑选一个关键字需要对比关键字$(k-1)$次,内部归并时间增加。

同时,若能增加初始归并段的长度,也可以减少初始归并段数量$r$从而进行优化。

败者树

用于通过过去归并的经历减少归并次数。败者树可以看作一棵多了一个单个的根的完全二叉树。$k$个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的失败者,而让胜者往上继续比较,一直到根结点。

传统方法从$k$个归并段选出一个最大或最小元素需要对比关键字$k-1$次,而使用$k$路归并的败者树只需要对比关键字$\lceil\log_2k\rceil$次。

置换选择排序

用于构建更长的初始归并段,从而减少归并次数。

假设初始始待排文件为FI初始归并段输出文件为FO内存工作区为WAFO和WA的初始状态为空WA可容纳$w$个记录。置换选择算法的步骤如下:

  1. 从FI输入$w$个记录到工作区WA。.
  2. 从WA中选出其中关键字取最小值的记录记为MINIMAX记录。
  3. 将MINIMAX记录输出到FO中去。
  4. 若FI不空则从FI输入下一个记录到WA中。
  5. 从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录作为新的MINIMAX记录。
  6. 重复步骤三到五直至在WA中选不出新的MINIMAX记录为止由此得到一个初始归并段输出一个归并段的结束标志到FO中去。准备输出新的归并段。
  7. 重复步骤二到六直至WA为空。由此得到全部初始归并段。

此时输出的初始归并段可以超过WA且初始归并段长度是不一定相等的。

最佳归并树

最佳归并树的衡量

每个初始归并段可以看作一个叶子结点归并树的长度作为结点权值则归并树的带权路径长度WPL等于读写磁盘的次数。从而归并过程中的磁盘IO次数=归并树的WPL×2。

最佳归并树的构造

所以就需要一棵类似哈夫曼树来成为最佳的归并树,不断选择最小的$k$段进行归并。

添加虚段

对于$k$叉归并来说,若初始归并段的数量无法构成严格的$k$叉归并树则需要补充几个长度为0的虚拟段再进行$k$叉哈夫曼树的构造。

那么添加多少虚段呢?

$k$叉的最佳归并树一定是一棵严格的$k$叉树,即树种只包含度为$k$和0的结点。

设度为$k$的结点有$n_k$个度为0的结点有$n_0$个,归并树的总结点树为$n$,则初始归并段数量+虚段数量=$n_0$。

所以$n=n_0+n_k$$kn_k=n-1$,所以$n_0=(k-1)n_k+1$,所以$n_k=\dfrac{(n_0-1)}{(k-1)}$一定是可以整除的。如果不整除就要添加虚段。