《数据结构》(C++)之第八章:排序技术
作者:互联网
8.1 概述
8.1.1 排序的基本概念
-
记录:在排序问题中,通常将数据元素称为记录(record)
-
排序:将一个记录的任意序列重新排列成一个 按关键码有序 的序列
-
正序、逆序:
正序 待排序序列中的记录已按关键码排好序 逆序/反序 待排序序列中记录的排列顺序与排好序的顺序正好相反 -
趟:在排序过程中,将待排序的记录序列扫描一遍称为一趟(pass)
-
排序算法的稳定性:假定在待排序的记录序列中,存在多个具有 相同关键码 的记录
稳定性 判定 例子 稳定 若经过排序,这些记录的 相对次序保持不变 ,则称这种排序算法稳定 即在原序列中,ki = kj,且 ri 在 rj 之前,在排序后的序列中,ri 仍在 rj 之前 不稳定 否则称为不稳定 / -
排序的分类
-
(1)内排序、外排序:根据在排序过程中待排序的所有记录是否全部被放置在内存中
类别 定义 内排序 指在排序的整个过程中,待排序的所有记录全部被放置在内存中 外排序 指由于待排序的记录个数太多,不能同时放置在内存,而需要将一部分记录放置在内存,另一部分记录放置在外存,整个排序过程需要在内外存之间多次交换数据才能得到排序的结果 -
(2)基于比较的排序、不基于比较的排序:根据排序方法是否建立在关键码比较的基础上
-
基于比较的排序:主要通过 关键码之间的比较 和 记录的移动 这两种操作实现,时间下界为
Ω(n * log以2为底的n)
类别 一般实现 优化实现 插入排序 直接插入排序 希尔排序 交换排序 起泡排序 快速排序 选择排序 简单选择排序 堆排序 归并排序 二路归并排序的非递归实现 / 递归实现 / -
不基于比较的排序:根据待排序数据的特点所采取其他方法,通常 没有大量的关键码之间的比较和记录的移动操作
类别 一般实现 优化实现 分配排序 桶式排序 基数排序
-
-
8.1.2 排序算法的性能(衡量标准)
-
(1)时间开销(最重要):对于基于比较的内排序,在待排序的记录个数一定的条件下,算法的执行之间主要消耗在 关键码的比较 和 记录的移动 上
基本操作 定义 优化 比较 关键码之间的比较 尽可能少的关键码比较次数 移动 记录从一个位置移动到另一个位置 尽可能少的记录移动次数 -
(2)执行算法所需要的辅助存储空间:指在待排序的记录个数一定的条件下,除了存放待排序记录占用的存储空间之外,执行算法所需要的其他存储空间
-
(3)算法本身的复杂度
8.2 插入排序
- 主要思想:每次将一个待排序的记录按其关键码的大小插入到一个已经排好序的有序序列中
- 前面有序,后面无序
8.2.1 直接插入排序
-
基本思想:依次将待排序序列中的每一个记录插入到一个已排好序的序列中,直到全部记录都排好序
-
关键问题:
序号 问题 解决 1 如何构造初始的有序序列? 初始时有序区为待排序记录序列中的 第一个记录,无序区包括所有剩余待排序的记录 2 如何查找待插入记录的插入位置? 在记录的有序区 r[i] ~ r[i-1]
中插入记录r[i]
,采用顺序查找,在r[0]
处设置哨兵(避免数组越界),在自i-1
起往前查找的过程中,同时后移记录(用于插入) -
算法描述:
-
伪代码:
1、将整个待排序的记录序列划分成有序区和无序区,初始时有序区为待排序记录序列中的第一个记录,无序区包括所有剩余待排序记录 2、将无序区的第一个记录插入到有序区的合适位置中,从而使无序区减少一个记录,有序区增加一个记录 3、重复执行(2),直到无序区中没有记录为止
-
代码描述:
void InsertSort(int r[], int n) //0号单元用作暂存单元和监视哨 { for (i = 2; i <= n; i++) //第一个记录为初始有序区,从第二个记录(即初始无序区的第一个记录)开始逐渐插入 { r[0] = r[i]; //0号位置暂存待插入元素用于交换,同时作为哨兵避免数组越界 for (j = i - 1; r[0] < r[j]; j--) //从后往前寻找插入位置,只要r[j]大于哨兵的值(即非有序),说明该值要比它后一个值(即哨兵更大),则后移 r[j + 1] = r[j]; //记录后移 r[j + 1] = r[0]; //插入 } }
特性 表现 备注 稳定性 稳定 / 辅助空间 需要一个记录的辅助空间(即 r[0]
)作为待插入记录的暂存单元和查找记录的插入位置过程中的哨兵 优点 算法简单、容易实现 / 适用场景 当序列中的记录基本有序(已接近正序)或待排序记录较少时,是最佳的排序方法 但是,当待排序的记录个数较多时,大量的比较和移动操作使其效率降低 -
时间复杂度:
O(n的平方)
情况 说明 时间复杂度 最好的情况下,待排序序列为正序 每趟只需与有序序列的最后一个记录的关键码比较一次,移动两次记录 O(n)
最坏的情况下,待排序序列为逆序 在第i趟插入时,第i个记录必须与 前面i-1个记录的关键码 及 哨兵 做比较,并且每比较依次就要做一次记录的移动 O(n的平方)
平均情况下 待排序序列中各种可能排列的概率相同,在插入第i个记录时平均需要比较有序区中全部记录的一半 O(n的平方)
-
8.2.2 希尔排序
-
基本思想:先将整个待排序记录序列分割成若干个子序列,在子序列内分别进行直接插入排序(使局部有序);待整个序列基本有序时,再对全体记录进行一次直接插入排序
- 对直接插入排序改进的着眼点:
序号 改进 1 若待排序记录按关键码基本有序,直接插入排序的效率很高 2 由于直接插入排序算法简单,则在待排序记录个数较少时效率也很高
- 对直接插入排序改进的着眼点:
-
关键问题:
序号 问题 解决 备注 1 应如何分割待排序的记录,才能保证整个序列逐步向基本有序发展? 不能是简单的逐段分割,而是将 相距某个“增量”的记录 组成一个子序列,增量可取 d = n/2
的递归(最后一个增量一定等于1)“增量分割子序列”能有效的保证在子序列内分别进行直接插入排序后得到的结果是 基本有序 而不是 局部有序 2 子序列内如何进行直接插入排序? 在每个子序列中,待插入记录和同一子序列中的前一个记录比较,在插入记录 r[i]
时,自r[i-d]
起往前跳跃式(跳跃幅度为d)查找待插入位置(在查找过程中,记录后移也是跳跃d个位置)1⃣ r[0]
只是暂存单元,不是哨兵 2⃣ 当搜索位置j =< 0
或r[0] >= r[j]
,表示插入位置已找到 3⃣ 在整个序列中,前d个记录分别是d个子序列中的第一个记录 -
算法描述:
-
伪代码:
1、假设待排序的记录为n个,先取整数 d<n (一般取 d=⌊n/2⌋), 将所有相距为d的记录构成一组,从而将整个待排序记录序列分割成多个子序列 (取n/2时有d个子序列) 2、对每个子序列分别进行直接插入排序 3、重复缩小间隔d (取 d=⌊d/2⌋) 并对每次分割得到的新的子序列分别进行直接插入排序 直到最后取 d=1 ,即将所有记录放在一组进行一次插入排序,最终将所有记录重新排列成按关键码有序的序列
-
代码描述:
void shellSort(int r[], int n) { for (d = n/2 ; d >= 1; d = d/2) //以增量为d进行分割,并不断缩小增量 { for (i = d + 1; i <= n ; i++) //将r[i]插入到所属的子序列中 { r[0] = r[i]; //暂存被插入记录 for (j = i - d; j > 0 && r[0] < r[j]; j = j - d) //j的初始值是每个子序列的第一个元素,遍历每个子序列的第一个元素(即除了每个子序列的第一个元素,该序列的其他元素都视为逐一直接插入排序) r[j + d] = r[j]; //记录后移d个位置,保证仍在同一个子序列 r[j + d] = r[0]; //插入元素 } } }
特性 表现 备注 稳定性 不稳定 / 辅助空间 需要一个记录的辅助空间(即 r[0]
)仅用于暂存当前插入记录,不用做哨兵 -
时间复杂度:
O(n的平方)~O(n * log以2为底的n)
-
8.3 交换排序
- 主要思想:在待排序序列中选两个记录,将它们的关键码进行比较,如果反序则交换它们的位置
8.3.1 起泡排序
-
基本思想:两两比较相邻记录的关键码,如果反序则交换,直到没有反序的记录为止
- 前面无序,后面有序
-
关键问题:
序号 问题 解决 备注 1 在一趟起泡排序中,若有多个记录位于最终位置,应如何记载? 设 变量 exchange
记载每次记录交换的位置,则一趟排序后,exchange
记载的一定是这趟排序中记录的 最后一次交换的位置 ,从此位置之后的所有记录 均已经有序即多个相同的最大值(或最小值)如何避免重复比较 2 如何确定起泡排序的范围,使得已经位于最终位置的记录不参与下一趟排序? 设 变量 bound
位置的记录是无序区的最后一个记录,则每趟起泡排序的范围是r[1]~r[bound]
在一趟排序后, exchange
位置之后的记录一定是有序的,所以下一趟起泡排序中无序区的最后一个记录的位置是exchange
,即bound = exchange
3 如何判别起泡排序的结束? 在每趟起泡排序开始之前,设 exchange
的初值为0,在该趟排序的过程中,只要有记录的交换,exchange
的值就会大于0,因此当exchange = 0
时起泡排序结束结束条件:在一趟排序过程中没有进行交换的记录 -
算法描述:
-
伪代码:
1、将整个待排序的记录序列划分成有序区和无序区, 初始时有序区为空,无序区包括所有待排序的记录 2、对无序区从前向后依次将相邻记录的关键码进行比较, 若反序则交换,从而使得关键码小的记录向前移,关键码大的记录向后移(像水中的气泡,体积大的先浮上来) 3、重复执行(2),直到无序区中没有反序的记录
-
代码描述:
void BubbleSort(int r[], int n) //0号单元用作交换操作的暂存单元 { exchange = n; //初始时有序区记录个数为0、无序区记录个数为n //第一趟起泡排序的区间为[1, n] while (exchange != 0) //当上一趟排序有记录交换时 { bound = exchange; //有序区的边界更新 exchange = 0; //本趟的exchange值重新初始化为0 for (j = 1; j < bound; j++) //遍历无序区中所有的元素 if (r[j] > r[j+1]) { r[j] <--> r[j+1]; exchange = j; //记载每次记录交换的位置 } } }
特性 表现 稳定性 稳定 辅助空间 需要一个记录的辅助空间(即 r[0]
) -
时间复杂度:
O(n的平方)
情况 说明 时间复杂度 最好情况下,待排序序列为正序 算法只执行一趟,进行了n-1次关键码的比较,不需要移动记录 O(n)
最坏情况下,待排序序列为逆序 算法执行 n-1 趟,第 i(1 =< i < n)趟排序执行了 n-i 次关键码的比较和 n-i 次记录的交换 O(n的平方)
平均情况下 时间复杂度与最坏情况同数量级 O(n的平方)
-
8.3.2 快速排序(分区交换排序)
-
基本思想:首先选一个 轴值(provit,即比较的基准) ,将待排序记录划分成独立的两部分,左侧记录的关键码均小于或等于轴值,右侧记录的关键码均大于或等于轴值,然后分别对这两部分重复上述过程,直到整个序列有序
-
对起泡排序的改进:
排序方法 记录的交换方向 记录的移动距离 比较和交换次数 起泡排序 记录的比较和移动是在 相邻位置 进行的 记录每次交换 只能后移一个位置 较多 快速排序 记录的比较和移动是 从两端向中间 进行的 关键码较大的记录一次就能从前面移动到后面,关键码较小的记录一次就能从后面移动到前面,记录的移动距离较远 减少了总的比较次数和移动次数
-
-
关键问题
序号 问题 解决 备注 1 如何选择轴值? 选取第一个或中间记录的关键码 无硬性要求,一般选择第一个 2 在待排序序列中如何进行划分(通常叫做“一次划分”)? 1⃣ 初始化轴值 2⃣ 右侧扫描 j-- 3⃣ 左侧扫描 i++ 4⃣ i与j 指向同一个位置时,一次划分结束 详见下文“一次划分”过程 3 如何处理划分得到的两个待排序子序列? 递归对左右两个子序列继续进行快排,直到每个分区都只有一个记录为止 / 4 如何判别快速排序的结束? 待排序序列中只有一个记录,结束递归,快速排序结束 / -
一次划分:
-
过程:
序号 操作 过程 重复 1 初始化 取第一个记录作为轴值 ,设置 两个参数 i和j
分别用来指示将要与轴值记录进行比较的左侧记录位置和右侧记录位置,即 本次划分的区间/ 2 右侧扫描过程 将轴值记录与 j 指向的记录进行比较,如果 j 指向记录的关键码大,则 j 前移一个记录位置(即 j--
)重复右侧扫描过程,直到右侧的记录小(即反序),若存在划分区间,则 将轴值记录与 j 指向的记录交换(只是更新轴值在数组中的位置,即下标,并不是轴值的值变了) 3 左侧扫描过程 将轴值记录与 i 指向的记录进行比较,如果 i 指向记录的关键码小,则 i 后移一个记录位置(即 i++
)重复左侧扫描过程,直到左侧的记录大(即反序),若存在划分区间,则 将轴值记录与 i 指向的记录交换 4 i < j
时循环直到 i 与 j 指向同一位置,即轴值记录的最终位置,一次划分结束 / -
伪代码:
1、将 i 和 j 分别指向待划分区间的 最左侧记录 和 最右侧记录; 2、重复下述过程,直到 i = j 2.1 右侧扫描,直到记录 j 的关键码小于轴值记录的关键码 2.2 如果存在划分区间,则将 r[j] 与 r[i] 交换,并执行 i++ 2.3 左侧扫描,直到记录 i 的关键码大于轴值记录的关键码 2.4 如果存在划分区间,则将 r[i] 与 r[j] 交换,并执行 j-- 3、退出循环,说明 i 和 j 指向了轴值记录的所在位置,返回该位置
-
代码描述(一次划分):
int partition(int r[], int first, int end) { i = first; //i初始化为最左端 j = end; //j初始化为最右端 while (i < j) //划分区间仍存在时 { while (i < j && r[i] =< r[j]) j--; //扫描轴值右侧区间,正序则j循环自减 if (i < j) { //划分区间仍存在时 r[i] <--> r[j]; //逆序则交换(即更新轴值的下标) i++; //更新到下一个值,避免进入死循环 } while (i < j && r[i] =< r[j]) i++; //扫描轴值左侧区间,正序则i循环自增 if (i < j) { //划分区间仍存在时 r[j] <--> r[i]; j--; } } return i; //i作为轴值记录的最终位置(返回j也可,此时i=j) }
-
-
快速排序算法代码描述(递归):初始调用为
QuickSort(r, 1, n)
void QuickSort(int r[], int first, int end) { if (first < end) { //区间长度大于1,执行一次划分,否则递归结束 pivot = Partition(r, first, end); //一次划分 QuickSort(r, first, pivot - 1); //递归的对左侧子序列进行快速排序 QuickSort(r, pivot + 1, end); //递归的对右侧子序列进行快速排序 } }
特性 表现 稳定性 不稳定 优点 平均性能是迄今为止所有内排序算法中最好的一种 适用场景 适用于待排序记录个数很大且原始记录随机排列的情况 -
时间复杂度:
O(n * log以2为底的n)
, 快排的趟数取决于递归调用树的深度情况 说明 时间复杂度 在最好情况下,每次划分对一个记录定位后,该记录的左侧子序列与右侧子序列的长度相同 ??? O(n * log以2为底的n)
在最坏情况下,待排序记录序列正序或逆序,每次划分只得到比上一次划分少一个记录的子序列,另一个子序列为空 此时必须经过 n-1 次递归调用才能把所有记录定位;而且第 i 趟划分需要经过 n-i 次关键码的比较才能找到第 i 个记录的轴值位置。记录的移动次数小于等于比较次数 O(n的平方)
平均情况下 设轴值记录的关键码第k小,归纳法证明 O(n * log以2为底的n)
-
空间复杂度:由于快速排序是递归的,需要一个栈来存放每一层递归调用的必要信息,其 最大容量应与递归调用树的深度一致 ,即
O(log以2为底的n)
情况 说明 空间复杂度 最好情况下 递归调用树为满二叉树 O(log以2为底的n)
最坏情况下 要进行 n-1 次递归调用 O(n)
平均情况下 / O(log以2为底的n)
-
8.4 选择排序
-
主要思想:每趟排序在当前待排序序列中选出 关键码最小 的记录,添加到有序序列中
-
特点:记录移动的次数较少
8.4.1 简单选择排序
-
基本思想:第 i 趟排序在待排序序列
r[i]~r[n] (1 =< i =< n-1)
中选取关键码最小的记录,并和第 i 个记录交换作为有序序列的第 i 个记录- 有序区在前,无序区在后
-
关键问题:
序号 问题 解决 备注 1 如何在待排序序列中选出关键码最小的记录? 设置一个整型变量 index
,用于记录在一趟比较过程中关键码最小的记录的位置(即下标),从本趟无序区的第一个位置开始遍历,如果遇到更小的就更新index/ 2 如何确定待排序序列中关键码最小的记录在有序序列中的位置? 第 i 趟简单选择排序的区间是 r[i]~r[n] (1 =< i =< n-1)
,则r[i]
是无序区的第一个记录,所以,将index所指向的关键码最小的记录与r[i]
交换即可第 i 趟排序从无序区找出的最小值一定大于有序区的任何一个记录 -
算法描述:
-
伪代码:
1、将整个记录序列划分为有序区和无序区, 初始时有序区为空,无序区含有待排序的所有记录 2、在无序区中选取关键码最小的记录,将它与无序区中的第一个记录交换, 使得有序区扩展了一个记录,同时无序区减少了一个记录 3、不断重复(2),直到无序区只剩下一个记录为止 此时所有的记录已经按关键码从小到大的顺序排列
-
代码描述:
void SelectSort (int r[], int n) //0号单元用作交换操作的暂存单元 { for (i = 1; i < n; i++) //对 n 个记录进行 n-1 趟简单选择排序 { index = i; //index每趟的初始赋值为该趟无序区的第一个元素的位置 for (j = i + 1; j < n; j++) //j为该趟无序区第二个元素,遍历整个无序区开始比较 if (r[j] < r[index]) index = j; if (index != i) r[i] <--> r[index]; //i始终指向该趟无序区第一个元素,如果不是最小则与index指向的交换到最小 } }
特性 表现 稳定性 不稳定 辅助空间 需要一个记录的辅助空间(即 r[0]
)优点 记录的移动次数较少 缺点 记录的比较次数较多 -
时间复杂度:
O(n的平方)
,无论记录的初始排列如何:- 1⃣ 关键码的比较次数相同
- 2⃣ 第 i 趟排序需要进行
n-i
次关键码的比较 - 3⃣ 简单选择排序需要进行
n-1
趟排序
情况 说明 最好情况下,待排序序列为正序时 记录的移动次数最少,为 0 次 最坏情况下,待排序序列为逆序时 记录的移动次数最多,为 3(n-1) 次
-
8.4.2 堆排序
- 对简单选择排序改进的着眼点:如何减少关键码的比较次数?
改进 原理 记录的比较次数 改进前 简单选择排序在一趟排序中仅选出最小关键码,没有把一趟比较结果保存下来 因而记录的比较次数较多 改进后 堆排序在选出最小关键码的同时,也找出较小关键码 减少了在后面的选择中的比较次数,从而提高了整个排序的效率
1、堆的定义
-
堆:指具有下列性质的 完全二叉树:(若按层序编号)
- (1)每个结点的值 都小于或等于 其左右孩子结点的值,称为 小根堆
ki =< k2i ki =< k(2i+1) (1 =< i =< ⌊n/2⌋)
- (2)每个结点的值 都大于或等于 其左右孩子结点的值,称为 大根堆
ki >= k2i ki >= k(2i+1) (1 =< i =< ⌊n/2⌋)
- (1)每个结点的值 都小于或等于 其左右孩子结点的值,称为 小根堆
-
存储结构:
表示方式 情景 备注 线性表 实际存储方式 最大值(大根堆)或最小值(小根堆)一般放在第一个位置 完全二叉树(按层序编号) 抽象存储方式(理解) 最大值(大根堆)或最小值(小根堆)为完全二叉树的根结点 -
筛选:调整堆的过程(除根结点外其左右子树均满足堆条件的完全二叉树 -> 堆)
-
定义:总是将根结点与左右孩子的较大者(大根堆)或较小者(小根堆)进行交换 ,直到 所有子树均为堆 或 将被调整结点交换到叶子为止
-
伪代码:
1、设置 i 和 j ,分别指向当前要筛选的结点和要筛选结点的左孩子; 2、若结点 i 已是叶子,则筛选完毕,算法结束; 否则,执行下述操作: 2.1 将 j 指向结点 i 的左右孩子中的较大者; //以大根堆为例 2.2 如果r[i]大于r[j],则筛选完毕,算法结束; //以大根堆为例 2.3 如果r[i]小于r[j],则将r[i]与r[j]交换; 令i=j,转到步骤(2)继续筛选;
-
代码描述:
- (1)k:当前要筛选结点的编号
- (2)m:堆中最后一个结点的编号(线性表的最后一个元素)
- (3)结点k的左右子树均是堆(即
r[k+1]~r[m]
满足堆的条件)
//以大根堆为例 void Sift(int r[], int k, int m) { i = k; //i指向被筛选结点 j = 2 * i; //j指向被筛选结点的左孩子 while (j <= m) //筛选还没有进行到叶子 { if (j < m && r[j] < r[j+1]) j++; //比较i的左右孩子(j+1 是根结点的右孩子),令j指向较大者 if (r[i] > r[j]) break; //若根节点已经大于左右孩子中的较大者,算法结束(以大根堆为例) else { r[i] <--> r[j]; //交换元素的值 i = j; //被筛结点位于原来结点j的位置 j = 2 * i; //继续向下一层检查交换后是否满足堆的条件 } } }
-
2、堆排序
-
基本思想:线性表中 前面无序(堆),后面有序
- 首先将待排序的记录序列构造成一个堆,此时,选出了堆中所有记录的最大者(即堆顶记录)
- 然后将堆顶记录移走,并将剩余的记录再调整成堆,这样又找出了次大的记录
- 依次类推,直到堆中只有一个记录为止
-
关键问题:
序号 问题 解决 备注 1 如何将一个无序序列构造成一个堆?(即 初始建堆) 将无序序列看成一个 完全二叉树的顺序存储 ,则所有叶子结点都已经是堆,从 第 ⌊n/2⌋
个记录(即最后一个分支结点) 开始,向前执行上述筛选过程,直至根结点是一个 反复筛选 的过程 2 如何处理堆顶记录? 将堆顶( r[1]
)与堆中最后一个记录(r[n-i+1]
)交换一般情况下,第 i 趟堆排序的堆中有 n-i+1
个记录(未排序时),即堆中最后一个记录是r[n-i+1]
3 如何调整剩余记录,成为一个新的堆?(即 重建堆) 筛选根结点(此根结点为处理完堆顶记录后换上去的新的根结点) 第 i 趟排序后,无序区有 n-i
个记录,对无序区对应的完全二叉树筛选根结点即可 -
堆排序算法描述
void HeapSort(int r[], int n) //0号单元用作交换操作的暂存单元 { for (i = n/2, i >= 1; i--) //初始建堆,从最后一个分支结点(第⌊n/2⌋个记录)向前到根结点 Sift(r, i, n); //反复筛选建堆 for (i = 1; i < n; i++) //重复执行移走堆顶及重建堆的操作,i代表趟数,共要移走n-1趟 { r[1] <--> r[n-i+1]; //取走根结点:根结点与本趟堆中最后一个结点交换(这个“最后一个结点”在下一趟中就是有序区的第一个结点了) Sift(r, 1, n-i); //重建堆:n-i代表r[n-i+1]~r[n]已经是有序的了 } }
特性 表现 备注 稳定性 不稳定 与简单选择排序一样,存在跳跃交换 辅助空间 需要一个记录的辅助空间(即 r[0]
)/ 优点 对原始记录的排列状态不敏感 相对于快速排序,这是堆排序最大的优点 - 时间复杂度:
O(n * log以2为底的n)
-
主要消耗在初始建堆和重建堆时进行的反复筛选上
操作 时间消耗 初始建堆 O(n)
第i次取堆顶记录重建堆 O(log以2为底的i)
取堆顶记录 n-1 次
-
- 时间复杂度:
8.5 归并排序
-
归并:是指将两个或两个以上的有序序列归并成一个有序序列的过程
-
主要思想:将 若干有序序列 逐步归并,最终归并为 一个有序序列
- 二路归并排序(2-way merge sort):将若干个有序序列进行 两两归并 ,直至所有待排序记录都在一个有序序列中为止
8.5.1 二路归并排序的非递归实现
-
关键问题:
序号 问题 解决 备注 1 如何构造初始有序序列? 将具有n个待排序记录的序列看作 n个长度为1的有序序列 2 如何将 两个相邻的有序序列 归并成 一个有序序列?(称为 “一次归并” ) 设 i、j、k 三个变量,将i、j指向的两个待排序序列中的元素比较大小 依次 放入k指向的最终有序序列中 有序序列中的第一个元素总是该序列中的最小值 3 怎样完成 “一趟归并” ? 分三种情况讨论 4 如何控制二路归并的结束? 通过 有序序列中记录的个数(即序列长度) 来控制 开始时,有序序列的长度为 1;结束时,有序序列的长度为 n -
一次归并:将两个相邻的有序序列归并为一个有序序列的过程(二路归并排序的核心操作)
-
引出:归并过程中,可能会破坏原来的有序序列 —> 所以,将归并的结果存入一个 新的数组 中
-
伪代码:
- 设两个相邻的有序序列为
r[s]~r[m]
和r[m+1]~r[t]
- 将这两个有序序列归并成一个有序序列
r1[s]~r1[t]
1、设三个参数 i、j 和 k 分别指向两个待归并有序序列和最终有序序列的当前记录,初始时: i 指向第一个待归并有序序列的第一个元素,即 i = s j 指向第二个待归并有序序列的第一个元素,即 j = m + 1 k 指向存放归并结果的为止,即 k = s; 2、比较 i 和 j 所指记录的关键码,取出较小者作为归并结果存入 k 所指位置 直至两个有序序列之一的所有记录都取完 3、再将另一个有序序列的剩余记录顺序送到归并后的有序序列中
- 设两个相邻的有序序列为
-
“一次归并”的代码描述:
void Merge (int r[], int r1[], int s, int m, int t) { i = s; //s为第一个待排序序列的第一个元素的下标 j = m + 1; //m为第一个待排序序列的最后一个元素的下标 k = s; //s也是最终有序序列的第一个元素的下标 while (i =< m && j =< t) //两个待排序序列都还有元素没有排完 { //t是第二个待排序序列的最后一个元素的下标,也是最终有序序列的最后一个元素的下标 if (r[i] =< r[j]) r1[k++] = r[i++]; else r1[k++] = r[j++]; } if (i =< m) //第一个没排完,第二个排完了 while (i =< m) r1[k++] = r[i++]; //第一个剩余没排完的“依次”放入最终有序序列中 //(这些剩下的按从小到大是排好的,剩下的最小值一定 >= 已经排好的有序序列中的最大值,即最后一个) else //第二个没排完,第一个排完了 while (j =< t) r1[k++] = r[j++]; //第二个剩余没排完的“依次”放入最终有序序列中 }
-
-
一趟归并:把若干个相邻的长度为 h 的有序序列和最后一个长度可能小于 h 的有序序列进行两两归并,将结果存放在
r1[1]~r1[n]
中(要点在于将待排序的 n个记录都归并,且只 归并一次)-
引出:在一趟归并中,除最后一个有序序列外,其他有序序列中记录的个数(称为 “序列长度”)相同,用 h 表示
- 最后一个有序序列的序列长度不一定是h
-
参数 i(指向待归并序列的第一个记录)取值的三种情况:
- 初始时:
i = 1
,h = 1
- 下一趟归并的步长应为 2h
序号 范围 临界值的含义 意义 操作 本趟是否还有下一次归并 情况一 i =< n-2h+1
最后一对成对有序序列(序列长度均为2h)的第二个序列的最后一个记录 待归并两个相邻有序序列的长度均为h 执行一次归并,完成后执行 i + 2h
准备进行下一次归并 情况二 i < n-h+1
开始不成对的第一个序列的最后一个记录 仍有两个相邻的有序序列,一个长度为h,另一个长度小于h 执行这两个有序序列的归并 完成后退出一趟归并 情况三 i >= n-h+1
开始不成对的第一个序列的最后一个记录 只剩下一个序列长度小于等于h的有序序列 直接将该有序序列送到r1的对应位置 完成后退出一趟归并 - 初始时:
-
“一趟归并”的代码描述:“一次归并” —> “一趟归并”
参数 含义 r[] 待排序数组 r1[] 本趟归并完成后的有序数组 n 待排序序列中记录个数 h 本趟排序时有序序列(除最后一个外)的序列长度 void MergePass (int r[], int r1[], int n, int h) //从下标1开始存放待排序序列 { i = 1; //初始时令i指向待归并序列的第一个记录 while (i =< n - 2h + 1) //情况一 { Merge (r, r1, i, i+h-1, i+2*h-1); i += 2 * h; } if (i < n - h + 1) //情况二 Merge (r, r1, i, i+h-1, n); else //情况三 for (k = i; k <= n; k++) r1[k] = r[k]; }
-
-
归并排序的非递归算法:“一次排序” --> “一趟排序” --> “归并排序的非递归算法”
- 此处的 r1 仅仅是辅助数组,待排序和输出排好序的结果都在r中存放
void MergeSortNonRecursion (int r[], int r1[], int n) { h = 1; //初始时子序列的长度为1 while (h < n) { MergePass (r, r1, n, h); //待排序序列从数组r中传到r1中排好 h = 2 * h; MergePass (r1, r, n, h); //排序趟数为奇数时,将待排序序列从数组r1中传入r中排好(最后一次只会执行“一趟排序”中的else部分) h = 2 * h; //while循环的一次执行过程中进行两趟二路归并排序,确保最终排好序的数组始终都是在r中 } }
特性 表现 备注 稳定性 稳定 辅助空间 O(n)
即 r1[]
的大小- 时间复杂度:
O(n * log以2为底的n)
- 一趟归并排序需要将待排序序列扫描一遍,其时间性能为
O(n)
- 整个归并排序需要进行
⌈log以2为底的n趟⌉
- 一趟归并排序需要将待排序序列扫描一遍,其时间性能为
8.5.2 二路归并排序的递归实现
-
基本思想:一次拆就有一次合
- 首先将待排序的记录序列分为两个相等的子序列
- 并分别将这两个子序列用归并方法进行排序
- 然后调用一次归并算法
Merge
,将这两个有序子序列合并成一个含有全部记录的有序数列
-
代码描述
void MergeSortRecursion (int r[], int r1[], int s, int t) { if (s == t) r1[s] = r[s]; //待排序序列只有一个记录,递归结束 else { m = (s + t) / 2; //均分为两个相等的子序列,m为第一个子序列的最后一个元素 MergeSortRecursion (r, r1, s, m); //归并排序前半个子序列 MergeSortRecursion (r, r1, m+1, t); //归并排序后半个子序列 Merge (r1, r, s, m, t); //将两个已排序的子序列归并 } }
-
递归实现于非递归实现的比较:
方式 方向 优点 缺点 非递归实现 自底向上 算法效率较高 可读性较差 递归实现 自顶向下(分治法) 形式更为简洁 效率相对较差
8.6 分配排序
8.6.1 桶式排序
8.6.2 基数排序
8.7 各种排序方法的比较
排序方法 | 时间平均情况 | 时间最好情况 | 时间最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n的平方) |
O(n) |
O(n的平方) |
O(1) |
稳定 |
希尔排序 | O(n * log以2为底的n)~O(n的平方) |
O(n的1.3次方) |
O(n的平方) |
O(1) |
不稳定 |
起泡排序 | O(n的平方) |
O(n) |
O(n的平方) |
O(1) |
稳定 |
快速排序 | O(n * log以2为底的n) |
O(n * log以2为底的n) |
O(n的平方) |
O(log以2为底的n)~O(n) |
不稳定 |
简单选择排序 | O(n的平方) |
O(n的平方) |
O(n的平方) |
O(1) |
不稳定 |
堆排序 | O(n * log以2为底的n) |
O(n * log以2为底的n) |
O(n * log以2为底的n) |
O(1) |
不稳定 |
归并排序 | O(n * log以2为底的n) |
O(n * log以2为底的n) |
O(n * log以2为底的n) |
O(n) |
稳定 |
1、时间复杂度
-
平均、最好、最坏三种情况分析
-
从平均情况看:
分类 排序方式 时间复杂度 备注 1 直接插入排序、直接选择排序、起泡排序 O(n的平方)
其中 直接插入排序 最常用,特别是对于已按关键码基本有序的记录序列 2 堆排序、快速排序、归并排序 O(n * log以2为底的n次方)
1⃣ 其中快速排序目前(平均情况下)是最快的排序方法 2⃣ 在待排序记录个数较多的情况下,归并排序较堆排序更快 3 希尔排序 O(n * log以2为底的n)~O(n的平方)
/ -
从最好情况看:
- 直接插入排序、起泡排序 的时间复杂度最好,为
O(n)
- 其他排序算法的最好情况与平均情况相同
- 直接插入排序、起泡排序 的时间复杂度最好,为
-
从最坏情况看:
排序方式 时间复杂度 快速排序 O(n的平方)
直接插入排序、起泡排序 与平均情况相同,但系数大约增加一倍,所以运行速度将降低一半 直接选择排序、堆排序、归并排序、基数排序 影响不大
-
-
总结:
情况 该情况下最快的排序方式 最好情况 直接插入排序、起泡排序 平均情况 快速排序 最坏情况 堆排序、归并排序
2、空间复杂度
分类 | 排序方式 | 空间复杂度 |
---|---|---|
1 | 归并排序 | O(n) |
2 | 快速排序 | O(log以2为底的n)~O(n) |
3 | 基数排序 | O(m) |
4 | 其他排序方式 | O(1) |
3、稳定性
稳定性 | 排序方式 |
---|---|
稳定 | 直接插入排序、起泡排序、归并排序、基数排序 |
不稳定 | 希尔排序、快速排序、简单排序、堆排序 |
4、算法简单性
简单算法 | 改进算法 |
---|---|
直接插入排序 | 希尔排序 |
简单选择排序 | 堆排序 |
起泡排序 | 快速排序 |
桶式排序 | 基数排序 |
/ | 归并排序 |
5、待排序的记录个数n的大小(–>记录的比较次数)
- 规律:n 越小,采用简单排序方法越合适;n 越大,采用改进排序方式越合适
- 原因:n 越小,
O(n的平方)
与O(n * log以2为底的n)
的差距越小,也更好调试
- 原因:n 越小,
6、记录本身信息量的大小(–>记录的移动次数)
-
引出:记录本身信息量越大,表明占用的存储空间就越多,移动记录所花费的时间就越多,所以 对记录移动次数较多的算法 不利
-
三种简单排序算法中记录移动次数的比较
排序方法 最好情况 最坏情况 平均情况 直接插入排序 O(n)
O(n的平方)
O(n的平方)
起泡排序 0 O(n的平方)
O(n的平方)
简单选择排序 0 O(n)
O(n)
- 当记录本身的信息量较大时,对 简单选择排序 有利
- 记录本身的信息量大小对改进算法的影响不大
-
-
降低各种排序算法用于移动记录所用的时间(尤其当记录很多的时候):新增辅助数组,使辅助数组中的每一个元素存储指向对应原数组下标相应记录的指针
- 优点:移动只需要对指针进行操作,拥有更高的排序效率
- 缺点:需要增加辅助数组的空间来存放指针
7、关键码的分布情况
- 当待排序记录为正序时,直接插入排序和起泡排序 能达到
O(n)
的时间复杂度 - 简单选择排序、堆排序、归并排序、基数排序 的时间性能不随记录序列中的关键码的分布而改变
总结
序号 | 排序方式 | 应用场景 |
---|---|---|
1 | 快速排序 | 待排序记录个数n较大,关键码分布随机,且对稳定性不作要求时 |
2 | 归并排序 | 待排序记录个数n较大,内存空间允许,且要求排序稳定时 |
3 | 堆排序、归并排序 | 待排序记录个数n较大,关键码分布可能出现正序或逆序的情况,且对稳定性不做要求时 |
4 | 堆排序、简单选择排序 | 待排序记录个数n较大,只需找出最大/最小的前几个记录 |
5 | 直接插入排序 | 当待排序记录个数n较小(如小于100)时,记录基本有序,且要求稳定时 |
6 | 简单选择排序 | 待排序记录个数n较小,记录所含数据项较多,所占存储空间较大时 |
7 | 快速排序/归并排序 与 直接插入排序 混合使用 | 1⃣ 在快速排序中划分的子序列长度小于某个值时,转而调用直接插入排序 2⃣ 对待排序记录序列先逐段进行直接插入排序,然后再利用“归并”操作进行两两归并直至整个序列有序 |
标签:归并,数据结构,记录,关键码,第八章,C++,有序,序列,排序 来源: https://blog.csdn.net/chileme/article/details/96101626