数据结构与算法A 第八章内排序
排序主要分为两类:内排序和外排序:
- 内排序:待排序的记录个数较少,整个排序过程中所有的记录都可以直接存放在内存中
- 外排序:待排序的记录数量太大,内存无法容纳所有记录,在排序过程中还需要访问外存
基于比较的排序方式,时间代价为O(nlogn)
排序问题的基本概念
排序码 (Sort Key):作为排序运算依据的一个或多个域
记录或元素 (Record):结点,进行排序的基本单位
序列 (Sequence):线性表,由记录组成
关键码 (Key):唯一确定记录的一个或多个域,有人翻译为“键”
排序码不一定是关键码,关键码是唯一确定记录的一个域或多个域
排序:将序列中的记录按照排序码顺序排列起来的过程。其中,排序码域的值具有不减(或不增)的顺序。如果排序码不重复,则递增或递减
排序问题:
- 给定一个序列 R = { r0, r1, …,rn-1 },其排序码分别为 k = { k0, k1, …,kn-1 }
- 排序的目的:将记录按排序码重排。形成新的有序序列 R’= { r’0, r’1, …,r’n-1 }。相应排序码为 k’= { k’0, k’1, …,k’n-1 }
- 其中 k’0 ≤ k’1 ≤ … ≤ k’n-1,称为不减序(不取等为升序); k’0 ≥ k’1 ≥ … ≥ k’n-1 ,称为不增序(不取等为降序)
正序 vs. 逆序
- “正序”序列 :待排序序列正好符合排序要求
- “逆序”序列 :把待排序序列逆转过来,正好符合排序要求
排序的稳定性:存在多个具有相同排序码的记录,排序后这些记录的相对次序保持不变
- 排序算法的衡量标准:
- 时间代价:记录的比较和移动次数
- 算法本身的繁杂程度
插入排序 ( Shell 排序)
- 插入排序的算法思想:对待排元素逐个处理,对每个新纪录与同组的那些已经排好的记录进行比较,然后插入到适当的位置。重点在于找到序列中插入的位置以及如何移动序列中那些已经排好的元素
直接插入排序
线性搜索确定待插入记录的位置
思想:按照从大到小(或从小到大)依次逐个与新纪录进行比较,直到找到第一个不大于新记录的值,这就是新记录插入的位置。依次把新记录插入到逐步扩大的已排序子序列中直到最后完全排序
1 | // 直接插入排序算法 |
直接插入排序算法是稳定的。如果前面有等于新记录的排序码值的记录,该记录仍然会在前面位置
算法分析:
- 空间代价:Θ(1)
- 时间代价:
- 最佳情况:n-1 次比较,2(n-1) 次移动,Θ(n)
- 最差情况: Θ(n²)
- 平均情况:Θ(n²)
Shell排序
直接插入排序的两个性质:
- 在最好情况(序列本身已是基本有序的)下时间代价为 Θ(n)
- 对于短序列,直接插入排序比较有效
Shell 排序有效地利用了直接插入排序的这两个性质
Shell排序算法思想:
- 先将序列转化为若干小序列,在这些小序列内进行插入排序
- 逐渐增加小序列的规模,而减少小序列个数,使得待排序序列逐渐处于更有序的状态
- 最后对整个序列进行扫尾直接插入排序,从而完成排序
例如n=8,增量每次除以2时具体的实现过程如下:
- 首先,将原始序列分为n/2=4个子序列,每个子序列包含两个记录,记录下标间距d=4(0和4,1和5……),分别对4个子序列进行插入排序
- 再将序列分为n/4=2个子序列,每个子序列包含4个记录,记录下标间距d=2(0246和1357),然后分别对这两个子序列进行插入排序
- 最后对整个序列进行插入排序
1 | // 增量每次除以2递减的Shell排序 |
增量每次除以2递减可以保证最后一次的间距为1,如果采用其他增量序列,一定注意人为的增加一个间距为1的增量元素
Shell排序子序列互相交错,而且跨度很大,是一种不稳定的排序算法
Shell 排序算法分析:
- 空间代价:Θ(1)
- 时间代价:
- 增量每次除以2递减,Θ(n²)
- 增量每次除以3递减 & Hibbard 增量序列,Θ(n ^(3/2))
- 甚至有的Shell排序的时间代价可以达到Θ(n ^(7/6)),接近Θ(nlogn)
选择排序
- 选择排序的算法思想:逐个找出第i小的元素,并将元素放到数组的第i个位置。重点在于如何在剩余的未排序的记录中找出最小(大)的记录
直接选择排序
- 算法思想:逐个找出第i小的记录,并将这个记录与数组的第i个位置的记录进行交换,第i小的记录一次交换到位
1 | // 直接选择排序 |
- 直接选择排序是不稳定的
- 直接选择排序算法分析:
- 空间代价:Θ(1)
- 时间代价:
- 比较次数:Θ(n²)
- 交换次数:n-1
- 总时间代价:Θ(n²)
堆排序
直接选择排序的局限性:没有充分利用前一轮查询所能得到的信息
堆排序利用堆数据结构保持剩余记录相对大小的信息,因而是更有效的选择排序
如果从小到大排序,那么需要建立最大堆;如果从大到小排序,则要建立最小堆
最大堆:父结点中的数据项大于等于子结点中的数据项,因此最大堆的堆顶记录整个堆中的最大记录。而次大的记录则存放在堆顶的左右子结点中
堆排序的两个主要步骤:
- 对所有记录建立最大堆
- 取出堆顶的最大记录与数组末端的记录交换,最大记录放在下标n-1的位置,原数组末端元素临时处于根结点;将根元素向下调整到合适的位置,即剩下的n-1个记录重新调整为堆,再取新堆顶的最大记录,与数组n-2位交换…
- 不断重复这一操作直到堆为空。数组正好从小到大排序
- 堆排序是不稳定的,因为再建堆过程中,二叉树的父子节点之间的移动不能保证两个重复记录一定能够保持原始输入输出顺序
1 | // 堆排序 |
- 堆排序算法分析:
- 空间代价:Θ(1)
- 时间代价:
- 建堆:Θ(n)
- 删除堆顶: Θ(log n )
- 一次建堆,n 次删除堆顶,总时间代价为 Θ(nlogn)
交换排序
- 交换排序的基本思想:两两比较待排序记录的关键码,发现记录逆置则进行交换,直到没有逆置为止
冒泡排序
冒泡排序的基本思想:不停地比较相邻的记录,如果不满足排序要求,就交换相邻记录,直到所有的记录都已经排好序。检查每轮冒泡过程中是否发生过交换,如果没有,则表明整个数组已经排好序了,排序结束
冒泡排序的流程:
- 从数组末端开始,不断比较相邻的元素,不满足排序要求的就交换。比较完一轮后最小的记录就已经被推到最左端r0的位置上
- 第二轮冒泡的过程只需要对rn-1到r1进行比较。第二轮冒泡完成后次小的记录就被推到r1
- 重复上述过程直到数组中所有的记录都已经排好位置
冒泡排序算法还可以按照不断找出第i大的记录来进行:即第一次冒泡找出最大的记录到数组最末端,第二次找出第二大的…
1 | template <class Record> |
- 冒泡排序是稳定的
- 冒泡排序算法分析:
- 空间代价:Θ(1)
- 时间代价:
- 最小时间代价为 Θ(n):最佳情况下只运行第一轮循环
- 最大,平均时间代价均为 Θ(n²)
快速排序
快速排序基于分治法的排序算法
分治法的关键是“分、治、合”:
- 分:将给定的问题分成若干个子问题
- 治:再对每个子问题求解
- 合:最后将所有子问题的解合并成一个综合的解
快速排序几乎是最快的排序算法,平均时间代价为Θ(nlog n)
快速排序的算法思想:
- 从待排序列中任意选择一个记录轴值(pivot)
- 将序列划分为两个子序列 L 和 R,使得 L 中所有记录都小于或等于轴值,R 中记录都大于轴值
- 对子序列 L 和 R 递归进行快速排序,直到子序列中只含有1个或0个元素,退出递归
下图为快速排序的例子,其中方框圈起来的为轴值
轴值的选择对快速排序的时间性能影响很大,轴值的选择应该尽量使得序列可以划分为均匀的两半
可以选择中间点(start + end) 作为轴值,这种轴值在输入数据位正序或逆序的时候可以平分序列,效果很好
分割过程:
- 选择轴值并存储轴值
- 最后一个元素放到轴值位置
- 初始化下标 l, r,分别指向头尾
- i 递增直到遇到比轴值大的元素,将此元素覆盖到 j 的位置;j 递减直到遇到比轴值小的元素,将此元素覆盖到 i 的位置
- 重复上一步直到 l==r,将轴值放到 i 的位置,完毕
改进方法:从序列两端交替检查空闲位置,将逆置记录移动到空闲位置上,而不是直接交换逆置记录。不同的分割方法分出来的子序列不同
- 分割完毕后分别对左边和右边的子序列进行快速排序
1 | // 快速排序 |
快速排序不稳定
快速排序的比较次数比移动次数要多得多,因此主要考虑比较次数。由于快速排序是递归排序,因此可以递归推理计算时间代价:其中cn为轴值选择的时间
快速排序算法代价分析:
- 空间代价:
- 平均空间代价为Θ(nlog n)
- 空间代价: O(n)
- 时间代价:
- 最佳情况时间代价:Θ(nlog n)
- 最差情况时间代价:Θ(n²)
- 平均情况时间代价:Θ(nlog n)
- 空间代价:
随机化的快速排序(RQS)的主要思想:随机的选择一个数作为轴值,平均复杂度仍为Θ(nlog n)
Median-of-3 partition:随机找出3个元素,并取其中间的元素作为轴值
改进:当快速排序的子数组小于某个长度时,不必继续递归。虽然子数组中的数值是无序的,但是整块看待这些子数组,可以发现一块块地有序,即左边的数组排序码都要小于右边数组的排序码。此时正适合使用插入排序
归并排序
归并排序简单的将原始的序列划分为两个子序列,然后分别对每个子序列递归排序,最后再将有序子序列合并
归并排序的主要步骤为:
- 划分为两个子序列
- 对每个子序列分别进行归并排序
- 有序子序列合并
归并排序也是一种基于分治法的排序。快速排序侧重于“分”,没有明显的“合”,而归并排序的“分”很简单,侧重于“治”和“合”
归并的过程:
- 每次比较序列头,取出较小的进入结果序列中
- 随后次小的记录顶上来,继续比较两个子序列头,取出较小的进入结果序列
- 重复上述过程直到其中一个子序列为空,此时剩余子序列中的记录可以直接进入结果序列
1 | // 两路归并排序 |
归并排序是稳定的
归并排序算法算法复杂度分析:
- 空间代价:Θ(n)
- 时间代价:最大、最小以及平均时间代价均为 Θ(nlog n)
优化归并排序方法:R.Sedgewick 优化:先把右子序列置逆,归并时从两端开始处理,向中间推进,简化边界判断;同优化的快速排序一样,对基本已排序序列直接插入排序
1 | template <class Record> |
优化后的归并排序依然是稳定的
优化后的算法时空代价相同
分配排序和索引排序
分配排序不需要进行关键码之间的比较,但是需要事先知道记录序列的一些具体情况
如果事先知道序列中记录的排序码都位于某个小区间内,就可以使用桶式排序
当排序码值域m很大时,可以拆分为多个部分进行比较,这就是基于桶式排序的基数排序
为减少记录的移动,可以采取基于静态联的基数排序,这本质上是一种索引排序(地址排序)
桶式排序
桶式排序是一种简单的分配排序。记录都位于某小区间段 [0, m) 内,分配到各桶,再收集
假如知道长度为n的序列中的所有记录的值都在0
m-1间,可以用一个长度为n的辅助数组,还需要m个计数器统计0m-1出现的次数,将具有想通知的记录都分配到一个桶中,然后依次按编号从桶中取出记录,责成有序序列桶式排序的流程:
- 扫描一遍序列,统计处0~m-1这m个数的出现次数,放到count数组中
- 根据count[i] += count[i-1]计算出元素i的结束位置,即元素i应该从array[count[i]-1]往前追溯,存放count[i] - count[i-1]个
- 最终,输出排序结果应该从初始序列的尾部开始往前扫描,以保证算法的稳定性
1 | // 桶式排序 |
桶式排序具有稳定性
桶式排序算法分析:数组长度为 n, 所有记录区间 [0, m) 上
- 空间复杂度:长度为 n 的临时数组,m 个计数器, Θ(n+m)
- 时间复杂度:总的时间代价为 Θ(n+m)
桶式排序适用于m很小的情况
基数排序
基数排序: 当 m 很大时,可以将一个记录的值即排序码拆分为多个部分来进行比较。基数排序是将排序码按照其进制的基数进行拆分的排序
假设长度为 n 的序列R = { r0,r1,…,rn-1}记录的排序码 K 包含 d 个子排序码K = ( kd-1,kd-2,…,k1,k0)
R 对排序码有序,即对于任意两个记录 R i,R j(0 ≤ i< j ≤ n-1),都满足(k i ,d-1,k i ,d-2, …,k i ,1,k i,0 ) ≤ (k j ,d-1,k j, d-2,…,k j,1,k j,0 )
按照排序码的先后顺序,分配排序可以分成两种:高位优先法和低位优先法
- 高位优先法(MSD):分、分、…、分、收 的过程
- 先处理最高位 kd-1 将序列分到若干桶中
- 然后再对每个桶处理次高位 kd-2 ,分成更小的桶
- 依次重复,直到对 k0 排序后,分成最小的桶,每个桶内含有相同排序码 (kd-1,…,k1,k0)
- 最后将所有的桶中的数据依次连接在一起,成为一个有序序列
- 低位优先法(LSD):分、收;分、收;…;分、收的过程
- 从最低位 k0 开始排序
- 对于排好的序列再用次低位 k1 排序
- 依次重复,直至对最高位 kd-1 排好序后,整个序列成为有序的
- 高位优先法(MSD):分、分、…、分、收 的过程
计算机常用LSD分配排序,因为速度快便于统一处理
具体实现基数排序的时候,数据项的存储方式可以分为:
- 顺序存储
- 链式存储
顺序存储
- 原始输入数组 R 的长度为 n,基数为 r,排序码个数为 d。排序可以通过调用d次桶式排序来实现
1 | // 基于数组的基数排序 |
- 顺序基数排序的算法分析:
- 空间代价:总空间代价 Θ(n+r)
- 时间代价:总时间代价 Θ(d·(n+r))
链式存储(静态链)
顺序存储的局限性:
- 由于桶中的记录个数未定,因此空间代价高
- 分配收集都需要移动所有记录,导致时间代价也较高
链式存储的思想:给每个记录设置整数next域,指向同一个桶中下一个元素的下标,r个桶组成r个静态队列:
- LSD分配第i位时,按照某记录的排序码i位的值分配到相应的队列,实际上只需要修改该记录的next域
- 收集时,只需要利用next域依次把各队首尾相连
1 | // 静态队列定义 |
- 链式存储基数排序的算法分析:
- 空间复杂度:总空间代价 Θ(n + r)
- 时间复杂度:总时间代价 Θ(d·(n+r))
索引排序
让数组中的每一个元素存储指向该元素记录的指针,在需要移动记录 时,只移动指针值(索引地址)而不一定记录本身。这种排序称为“索引排序”或“地址排序”
可以看到下图中原始待排数组尚未整理,记录的序被保持在索引数组中。排序后需要根据辅助的索引数组来调整原始的待排数组,使其按照下标有序的存放记录,可以在O(n)时间内完成
可以观察到下标0应该放入目前下标5的排序码12,(5,12) -> (0,12)
索引排序算法的核心排序算法是直接插入排序。从0~n-1扫描数组,如果索引值与当前位置不符合,就顺着索引链进行循环调整,直到找到等于当前下标的索引位置为止
1 | // 插入排序的索引地址排序版本 |
- 索引排序的时间代价:Θ(𝒏)
排序算法的时间代价
简单排序算法的时间代价
- 直接插入、直接选择和冒泡这三种排序方法思想简单而容易实现。但是时间代价都很大,时间代价为Θ(𝒏²)
关键原因:共同缺点为只对相邻的两个记录进行比较和移动,记录只能一步步的向目标位置移动,效率低下。而直接选择排序则是逐个进行线性比较,效率同样不高
一个长度为 n 的序列平均有n(n-1)/4 个逆置对。任何一种基于相邻记录比较的排序算法的平均时间代价都是 Θ(n²)
n 很小或基本有序时插入排序比较有效
排序算法的理论和实验时间
- 实验:利用随机函数生成一个长度为n的整形数据。用若干个随机数据来测试,取平均时间
1 | // 随机生成待排序数组 |
排序问题的下限
排序问题的下限:解决排序问题所能达到的最佳可能效率
任何算法的时间都不可能小于I/O时间,因此没有哪种算法能够将时间下限降到Ω(n)一下,排序问题的时间代价在 Ω(n) (单趟扫描) 到O(n log n) (平均, 最差情况) 之间
二叉判定树:
- 每个结点列举当前状态下可能得排列集合
- 边表示记录之间的比较,即一个判断
- 每个叶结点为一种排列情况,因此一共有n!个结点
对 n 个记录,共有 n! 个叶结点,树的层数至少为 Ω (log n!)。Ω (log(n!)) 在 Ω(n·logn) 中:在最差情况下任何基于比较的排序算法都至少需要 Ω(nlog n) 次比较而排序问题的上限是 O(nlogn)
因此可以推导出排序问题的时间代价为Θ (n·log n)