《数据结构》-第八章 排序

引言:

排序作为各类数据结构的相应的运算的一种,在很多领域中都有广泛的应用。主要的排序方法有插入排序、交换排序、选择排序、二路归并排序、基数排序、外部排序等各类排序方法。堆排序、快速排序和归并排序是本章的重难点,应深入掌握各种排序算法的思想、排序过程(能动手模拟)和特征(初态的影响、复杂度、稳定性、适用性等)。

本章同样作为考察重点章节,通常以选择题的形式考查不同算法之间的对比。此外,对于一些常用排序算法的关键代码,要达到熟练编写的程度:看到某特定序列,读者应具有选择最优排序算法的能力。

1. 排序的概念及运用

排序的概念:

排序:指将一组记录按照特定关键字的大小进行升序或降序排列的操作。

排序稳定性:在待排序序列中,若存在多个相同关键字的记录,且排序后这些记录的相对顺序与原序列保持一致,则称该排序算法具有稳定性;反之则为不稳定排序。

**内部排序:**所有待排序数据完全存储在内存中进行的排序操作。

**外部排序:**当数据量过大无法全部存入内存时,需要借助外部存储设备完成的排序过程。

排序运用:

排序在生活中随处可见,比如,商品的价格排序

2. 常见排序算法的实现

2.1 插入排序

插入排序的基本思想为每步将一个待排序的对象,按其关键码大小,插入到前面已经排好序的一组对象的适当位置上,直到对象全部插入为止。

即边插入边排序,保证子序列中随时都是排好序的。

2.1.1 直接插入排序(基于顺序查找)

**【算法思想】**整个排序过程为n-1趟插入,即先将序列中第1个记录看成是一个有序子序列,然后从第2个记录开始,逐个进行插入,直至整个序列有序。

算法描述:

bash 复制代码
void InsertSort(SqList &L)
 
 {int i,j;
 
   for(i=2;i<=L.length;++i)
 
     if( L.r[i].key<L.r[i-1].key)//将L.r[i]插入有序子表
 
       { L.r[0]=L.r[i]; // 复制为哨兵
 
          L.r[i]=L.r[i-1];
 
          for(j=i-2; L.r[0].key<L.r[j].key;--j)
 
                   L.r[j+1]=L.r[j]; // 记录后移
 
         L.r[j+1]=L.r[0]; //插入到正确位置
 
       }
 
 }
2.1.2 冒泡排序的算法分析

先明确:这是 "冒泡排序" 的性能分析

冒泡排序的逻辑是:把 n 个要排序的元素,一趟趟 "冒泡"------ 每趟比较相邻元素,把大的(或小的)往末尾挪;n 个元素只需要排 n-1 趟(最后 1 个元素会自动归位)。

分情况解释

① 最好情况(运气超好)

如果要排序的元素已经是 "从小到大排好序" 的

  • 每一趟只需要比较 1 次(确认相邻元素是有序的),不用移动元素;

  • 总共要排 n-1 趟,所以总比较次数就是 "n-1 次"。

② 最坏情况(运气超差)

如果要排序的元素是已经是"从大到小逆序" 的 **:

  • 第 i 趟需要比较 i 次(比如第 2 趟比 2 次、第 3 趟比 3 次... 直到第 n 趟比 n 次),还要额外移动 i+1 次元素;

  • 把每趟的比较次数加起来(从 i=2 到 i=n 求和),结果是 【2(n+2)(n−1)​】/2;

  • 把每趟的移动次数加起来(从 i=2 到 i=n 求和),结果是 【2(n+4)(n−1)​】/2。

③ 平均情况 & 排序特性

如果元素的排列是随机的(各种顺序概率差不多):

  • 时间复杂度 O (n²):排序花费的时间和 "元素个数 n 的平方" 成正比(n 越大,时间会明显变长);

  • 空间复杂度 O (1):排序过程中只需要临时存 1-2 个元素,不用额外占大内存;

  • 稳定的排序:如果有两个数值相同的元素,排序后它们的相对位置不会变。

2.1.2.1 冒泡排序动图理解
2.1.3 冒泡排序和直接插入排序的直接区别

冒泡排序和直接插入排序的直接区别核心在 "操作逻辑",我拆成 3 个最直观的点讲:

  1. 核心操作逻辑不同(最关键)
  • 冒泡排序 :盯着 "未排序部分" 的相邻元素,两两比较,顺序错了就交换;像气泡往上冒一样,每趟把未排序部分里最大(或最小)的元素 "推" 到末尾,每趟确定 1 个元素的最终位置。

  • 直接插入排序 :从第 2 个元素开始,逐个取出当前元素,然后把它插到 "前面已经排好序的部分" 里的对应位置;每趟让 "前面的已排序区域" 变长一点。

  1. 已排序部分的位置不同
  • 冒泡排序:已排好序的元素在序列末尾(越排,末尾的有序区域越大)。

  • 直接插入排序:已排好序的元素在序列开头(越排,开头的有序区域越大)。

  1. 元素移动的方式不同
  • 冒泡排序:靠多次相邻元素交换来移动元素(比如把一个大元素从开头推到末尾,要交换很多次)。

  • 直接插入排序:靠移动已排序区域的元素腾出位置,再把当前元素插入(只需要移动一段元素,插入一次)。

2.2 折半插入排序(基于折半查找)

其实说白了:折半插入是直接插入排序的优化版

**ps:**核心就是 "分治":把有序的查找范围不断对半切分,快速缩小目标位置的范围,这也是它比 "挨个找(顺序查找)" 效率高得多的原因。但有两个关键细节要纠正,避免理解偏差。

我们用数组 [3, 1, 4, 2] 完整演示折半插入排序 的过程(以 "从小到大排序" 为例,数组索引从0开始),每一步都拆解细节:

  1. 初始时,数组第 1 个元素(索引0)是 "有序序列";

  2. 依次处理后面的元素(索引123):

    • 折半查找(二分法) ,在 "已排序序列" 中快速找到当前元素的插入位置

    • 把 "已排序序列中,插入位置之后的元素" 集体后移,腾出空位;

    • 将当前元素插入空位,扩大有序序列。

步骤 1:处理第 2 个元素(1,索引1

  • 初始状态 :有序序列是 [3](索引0),待插入元素是 1(索引1)。

  • 折半查找插入位置 :设定有序序列的边界:low=0(有序序列起始索引),high=0(有序序列结束索引)。

    1. 计算中间索引:mid = (low + high) // 2 = (0+0)//2 = 0

    2. 比较待插入元素1和有序序列中mid位置的元素31 < 3,说明插入位置在mid左侧,因此更新 high = mid - 1 = -1

    3. low > high时,查找结束,插入位置是low=0

  • 移动元素 + 插入 :把有序序列中 "插入位置及之后" 的元素(这里是3,索引0)向后移 1 位,空出索引0;将1插入到索引0

  • 插入后数组[1, 3, 4, 2]

步骤 2:处理第 3 个元素(4,索引2

  • 当前状态 :有序序列是 [1, 3](索引0-1),待插入元素是 4(索引2)。

  • 折半查找插入位置 :边界:low=0high=1

    1. mid = (0+1)//2 = 0,比较414 > 1,说明插入位置在mid右侧,更新 low = mid + 1 = 1

    2. 现在low=1high=1mid = (1+1)//2 = 1,比较434 > 3,更新 low = mid + 1 = 2

    3. low > high时,查找结束,插入位置是low=2

  • 移动元素 + 插入 :插入位置2刚好是待插入元素的原位置,无需移动元素,直接将4留在原位。

  • 插入后数组[1, 3, 4, 2](无变化)。

步骤 3:处理第 4 个元素(2,索引3

  • 当前状态 :有序序列是 [1, 3, 4](索引0-2),待插入元素是 2(索引3)。

  • 折半查找插入位置 :边界:low=0high=2

    1. mid = (0+2)//2 = 1,比较232 < 3,说明插入位置在mid左侧,更新 high = mid - 1 = 0

    2. 现在low=0high=0mid = (0+0)//2 = 0,比较212 > 1,说明插入位置在mid右侧,更新 low = mid + 1 = 1

    3. low > high时,查找结束,插入位置是low=1

  • 移动元素 + 插入 :把有序序列中 "插入位置1及之后" 的元素(34,索引1-2)向后移 1 位,空出索引1;将2插入到索引1

  • 插入后数组[1, 2, 3, 4](排序完成)。

2.2.1 图示理解

【图解】数据结构代码领背-折半插入排序 - 知乎 它用图示讲解的不错!!!!!

2.2.2 算法代码分析

【算法分析】

①折半插入排序的时间复杂度仍为 O(n2);折半插入排序所需附加存储空间和直接插入排序相同,只需要一个记录的辅助空间,所以空间复杂度为O(1);

②折半插入算法是一种稳定的排序算法。

【算法特点】

①因为要进行折半查找, 所以只能用于顺序结构,不能用于链式结构;

②适合初始记录无序、n较大时的情况。

2.3 希尔排序(基于逐趟缩小增量)

希尔排序的核心思想是通过将原始列表分割成多个子序列,先对每个子序列进行插入排序,然后逐步缩小子序列的间隔,最终对整个列表进行一次标准的插入排序

算法步骤:

  1. 选择增量序列:确定一个由大到小的整数序列,最后一个增量必须是 1。最常用的初始增量是数组长度的一半,然后逐次减半。

  2. 按增量分组 :对于当前的增量 gap,将整个列表分成 gap 个子序列。每个子序列由所有间隔为 gap 的元素组成。

  3. 对子序列进行插入排序:分别对每个子序列进行插入排序。

  4. 减小增量:得到一个新的、更小的增量,重复步骤 2 和 3。

  5. 最终排序:当增量减小到 1 时,对整个列表进行一次标准的插入排序,此时列表已基本有序,排序很快完成。

2.3.1 过程图示

希尔排序目的为了加快速度改进了插入排序,交换不相邻的元素对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。

在此我们选择增量 gap=length/2 ,缩小增量以gap = gap/2 的方式,用序列 {n/2,(n/2)/2...1} 来表示。

如图示例:

(1)初始增量第一趟 gap = length/2 = 4

(2)第二趟,增量缩小为 2

(3)第三趟,增量缩小为 1,得到最终排序结果

2.3.2 算法代码:
cs 复制代码
// C 希尔排序
void shellSort(int arr[], int n) {
    for (int gap = n / 2; gap > 0; gap /= 2) {
        for (int i = gap; i < n; i++) {
            int temp = arr[i];
            int j = i;
            while (j >= gap && arr[j - gap] > temp) {
                arr[j] = arr[j - gap];
                j -= gap;
            }
            arr[j] = temp;
        }
    }
}

【算法优点】

①小元素跳跃式前移,且最后一趟增量为1时,序列已基本有序;

②平均性能优于直接插入排序。

3.0 交换排序

所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置

交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

3.1 冒泡排序(和2.1.2一样)

【算法思想】

每趟不断将记录两两比较,并按"前小后大" 规则交换

【算法优点】

①每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素;

②一旦某趟没有交换,提前结束排序。

3.2 快速排序

快速排序需满足以下几点:

①任取一个元素 (如第一个) 为中心;

②所有比它小的元素一律前放,比它大的元素一律后放,形成左右两个子表;

③对各子表重新选择中心元素并依此规则调整,直到每个子表的元素只剩一个。

3.2.1 快速排序算法图解

因为不确定通过哪种方法选取基准元素效果最好,在此选取第一个元素作为基准元素。假设当前待排序序列为 a [ left :right ],其中 left ≤ right:

  • 选取数组的第一个元素作为基准元素,pivot=a[left],i=left,j=right;
  • 从右向左扫描,找小于或等于 pivot 的数,令 a[i]=a[j],i++;
  • 从左向右扫描,找大于或等于 pivot 的数,令 a[j]=a[i],j--;
  • 重复第 2~3 步,直到 i 和 j 重合,将 pivot 放到中间,即 a[i]=pivot,返回 mid=i。

至此完成一趟排序。此时以 mid 为界,将原序列分解为两个子序列,左侧的子序列都小于或等于 pivot,右侧的子序列都大于或等于 pivot。接着对这两个子序列分别进行快速排序。

下面以序列(30,24,5,58,18,36,12,42,39)为例,演示快速排序的过程。

  1. 初始化。i=left,j=right,pivot=r[left]=30。
  1. 从右向左扫描,找小于或等于 pivot 的数,找到 a[j]=12。

a[i]=a[j],i++,如下图所示:

  1. 从左向右扫描,找大于或等于 pivot 的数,找到 a[i]=58。

a[j]=a[i],j--,如下图所示:

  1. 从右向左扫描,找小于或等于 pivot 的数,找到 a[j]=18。

a[i]=a[j],i++,如下图所示:

  1. 此时 i=j,第一趟排序结束,将 pivot 放到中间,即 a[i]=pivot,返回 i 的位置,mid=i,如下图所示:

此时以 mid 为界,将原序列分解为两个子序列,左侧的子序列都比 pivot 小,右侧的子序列都比 pivot 大。接着分别对两个子序列(12,24,5,18)、(36,58,42,39)进行快速排序。

3.2.2 代码分析
bash 复制代码
int partition(int left, int right) { // 划分函数
    int i = left, j = right, pivot = a[left]; // 选取第一个元素作为基准元素
    while (i < j) {
        while (a[j] > pivot && i < j) j--; // 找右侧小于或等于 pivot 的数
        if (i < j)
            a[i++] = a[j]; // 覆盖
        while (a[i] < pivot && i < j) i++; // 找左侧大于或等于 pivot 的数
        if (i < j)
            a[j--] = a[i]; // 覆盖
    }
    a[i] = pivot; // 把 pivot 放到中间
    return i;
}

【算法特点】

①每一趟的子表的形成是采用从两头向中间交替式逼近法;

②由于每趟中对各子表的操作都相似,可采用递归算法;

③快速排序算法可以证明,平均计算时间是O(nlog2n)。平均计算时间而言,快速排序是我们所讨论的所有内排序方法中最好的一个;

④快速排序是递归的,需要有一个栈存放每层递归调用时参数(新的low和high);

⑤最大递归调用层次数与递归树的深度一致,因此,要求存储开销为 O(log2n) 。

4.0 选择排序

选择排序的基本思想为第i 趟中就是在后面 n-i +1个记录中选出关键码最小的对象, 作为有序序列的第 i 个记录。

4.1 直接选择排序

直接选择排序的时间复杂度是O(N^2),它的思考非常好理解,但是效率不是很好。所以实际中很少使用。

代码实现:
bash 复制代码
void SelectSort(int* a, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int maxi = begin;
		int mini = begin;
		for (int i = begin+1; i <= end; i++)
		{
			if (a[i] < a[mini])
			{
				mini = i;
			}
			if (a[i] > a[maxi])
			{
				maxi = i;
			}
		}
		Swap(&a[mini], &a[begin]);
		if (begin == maxi)
			 maxi = mini;
		Swap(&a[maxi], &a[end]);
		begin++;
		end--;
	}
}
4.2 堆排序

一、概念及其介绍

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。

堆是一个近似 完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

二、适用说明

我们之前构造堆的过程是一个个数据调用 insert 方法使用 shift up 逐个插入到堆中,这个算法的时候时间复杂度是 O(nlogn),本小节介绍的一种构造堆排序的过程,称为 Heapify ,算法时间复杂度为 O(n)

三、过程图示

完全二叉树有个重要性质,对于第一个非叶子节点的索引是 n/2 取整数得到的索引值,其中 n 是元素个数(前提是数组索引从 1 开始计算)。

索引 5 位置是第一个非叶子节点,我们从它开始逐一向前分别把每个元素作为根节点进行 shift down 操作满足最大堆的性质。

索引 5 位置进行 shift down 操作后,22 和 62 交换位置。

对索引 4 元素进行 shift down 操作

对索引 3 元素进行 shift down 操作

对索引 2 元素进行 shift down 操作

最后对根节点进行 shift down 操作,整个堆排序过程就完成了。

4.2.1 算法效率分析

5.0 归并排序

5.1 归并的相关概念

**【归并】**将两个或两个以上的有序表组合成一个新有序表

归并排序的核心思想是将一个大问题分解成若干个小问题,分别解决这些小问题,然后将结果合并起来,最终得到整个问题的解。具体到排序问题,归并排序的步骤如下:

  1. 分解(Divide):将待排序的数组分成两个子数组,每个子数组包含大约一半的元素。
  2. 解决(Conquer):递归地对每个子数组进行排序。
  3. 合并(Combine):将两个已排序的子数组合并成一个有序的数组。

通过不断地分解和合并,最终整个数组将被排序。

算法步骤

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;

  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;

  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;

  4. 重复步骤 3 直到某一指针达到序列尾;

  5. 将另一序列剩下的所有元素直接复制到合并序列尾。

图示演示:

假设有一个待排序的列表以 [8, 3, 5, 1] 为例的完整过程

第一步:分解(拆分)

拆分规则:每次把列表从中间分成左右两半,直到所有子列表都只有 1 个元素。

  1. 初始列表:[8, 3, 5, 1]

  2. 第一次拆分(从中间分):[8, 3](左半) 和 [5, 1](右半)

  3. 第二次拆分(继续拆分两个子列表):

    • [8, 3] 拆成 → [8][3]

    • [5, 1] 拆成 → [5][1]

  4. 拆分结束:所有子列表都是单个元素([8][3][5][1]

第二步:合并(有序合并)

合并规则:每次取两个有序子列表的第一个元素比较,把更小的那个先放入新列表;直到其中一个子列表为空,再把剩下的元素直接追加进去。

  1. 第一次合并(合并单个元素的子列表):

    • 合并 [8][3]:比较 8 和 3 → 先放 3,再放 8 → 得到 [3, 8]

    • 合并 [5][1]:比较 5 和 1 → 先放 1,再放 5 → 得到 [1, 5]

  2. 第二次合并(合并两个有序子列表):合并 [3, 8][1, 5],步骤拆解:

    • 比较 3(第一个列表首元素)和 1(第二个列表首元素)→ 1 更小,放入新列表 → 新列表:[1]

    • 第二个列表剩 [5],比较 3 和 5 → 3 更小,放入 → 新列表:[1, 3]

    • 第一个列表剩 [8],比较 8 和 5 → 5 更小,放入 → 新列表:[1, 3, 5]

    • 第二个列表空了,把第一个列表剩下的 8 追加进去 → 最终有序列表:[1, 3, 5, 8]

6.0 基数排序

基数排序(Radix Sort)是一种非比较型的排序算法,它通过逐位比较元素的每一位(从最低位到最高位)来实现排序。基数排序的核心思想是将整数按位数切割成不同的数字,然后按每个位数分别进行排序。基数排序的时间复杂度为 O(n * k),其中 n 是列表长度,k 是最大数字的位数。

算法步骤:

  1. 确定最大位数:找到列表中最大数字的位数,确定需要排序的轮数。

  2. 按位排序:从最低位开始,依次对每一位进行排序(通常使用计数排序或桶排序作为子排序算法)。

  3. 合并结果:每一轮排序后,更新列表的顺序,直到所有位数排序完成。

6.1 LSD 基数排序动图演示

6.2 例子解释

假设有一个待排序列表:[12, 4, 31, 18, 7, 25],下面完整演示基数排序的每一步(核心逻辑和原例子一致,仅数字更简单)。

第一步:确定最大位数

列表中最大数字是 31,是两位数 ,因此需要进行 2 轮 排序(先按个位排,再按十位排)。

第二轮:第一轮排序(按个位排序)

步骤 1:提取每个数字的个位

  • 12 的个位:2

  • 4 的个位:4(补位后)

  • 31 的个位:1

  • 18 的个位:8

  • 7 的个位:7

  • 25 的个位:5

步骤 2:统计个位数字出现的次数(计数排序的核心)

个位数字范围是 0-9,统计结果:[0, 1, 1, 0, 1, 1, 0, 1, 1, 0](索引对应个位数字,值对应出现次数:比如索引 1 的值是 1,表示个位为 1 的数有 1 个;索引 2 的值是 1,表示个位为 2 的数有 1 个,以此类推)

步骤 3:按个位从小到大排序

按个位 0→9 的顺序,把数字归位:

  • 个位 1:31

  • 个位 2:12

  • 个位 4:4

  • 个位 5:25

  • 个位 7:7

  • 个位 8:18

第一轮排序后列表[31, 12, 4, 25, 7, 18]

第三步:第二轮排序(按十位排序)

步骤 1:提取第一轮排序后每个数字的十位

(注意:个位数的十位补 0)

  • 31 的十位:3

  • 12 的十位:1

  • 4 的十位:0

  • 25 的十位:2

  • 7 的十位:0

  • 18 的十位:1

步骤 2:统计十位数字出现的次数

十位数字范围是 0-9,统计结果:[2, 2, 1, 1, 0, 0, 0, 0, 0, 0](索引 0 的值是 2:十位为 0 的数有 2 个(4、7);索引 1 的值是 2:十位为 1 的数有 2 个(12、18);索引 2 的值是 1:十位为 2 的数有 1 个(25);索引 3 的值是 1:十位为 3 的数有 1 个(31))

步骤 3:按十位从小到大排序

按十位 0→9 的顺序,把数字归位:

  • 十位 0:4、7

  • 十位 1:12、18

  • 十位 2:25

  • 十位 3:31

第二轮排序后列表[4, 7, 12, 18, 25, 31]

最终结果

列表已完全有序:[4, 7, 12, 18, 25, 31]

7.0 外部排序

介绍了很多排序算法,插入排序、选择排序、归并排序等等,这些算法都属于内部排序算法,即排序的整个过程只是在内存中完成。而当待排序的文件比内存的可使用容量还大时,文件无法一次性放到内存中进行排序,需要借助于外部存储器(例如硬盘、U盘、光盘),这时就需要用到本章介绍的外部排序算法来解决。

外部排序算法由两个阶段构成:

  1. 按照内存大小,将大文件分成若干长度为 l 的子文件(l 应小于内存的可使用容量),然后将各个子文件依次读入内存,使用适当的内部排序算法对其进行排序(排好序的子文件统称为"归并段"或者"顺段"),将排好序的归并段重新写入外存,为下一个子文件排序腾出内存空间;

  2. 对得到的顺段进行合并,直至得到整个有序的文件为止。

例如,有一个含有 10000 个记录的文件,但是内存的可使用容量仅为 1000 个记录,毫无疑问需要使用外部排序算法,具体分为两步:

  • 将整个文件其等分为 10 个临时文件(每个文件中含有 1000 个记录),然后将这 10 个文件依次进入内存,采取适当的内存排序算法对其中的记录进行排序,将得到的有序文件(初始归并段)移至外存。

  • 对得到的 10 个初始归并段进行如图 1 的两两归并,直至得到一个完整的有序文件。

注意:此例中采用了将文件进行等分的操作,还有不等分的算法

图 1 2-路平衡归并

如图 1 所示有 10 个初始归并段到一个有序文件,共进行了 4 次归并,每次都由 m 个归并段得到 ⌈m/2⌉ 个归并段,这种归并方式被称为 2-路平衡归并。

注意:在实际归并的过程中,由于内存容量的限制不能满足同时将 2 个归并段全部完整的读入内存进行归并,只能不断地取 2 个归并段中的每一小部分进行归并,通过不断地读数据和向外存写数据,直至 2 个归并段完成归并变为 1 个大的有序文件。

对于外部排序算法来说,影响整体排序效率的因素主要取决于读写外存的次数,即访问外存的次数越多,算法花费的时间就越多,效率就越低。

计算机中处理数据的为中央处理器(CPU),如若需要访问外存中的数据,只能通过将数据从外存导入内存,然后从内存中获取。同时由于内存读写速度快,外存读写速度慢的差异,更加影响了外部排序的效率。

对于同一个文件来说,对其进行外部排序时访问外存的次数同归并的次数成正比,即归并操作的次数越多,访问外存的次数就越多。图 1 中使用的是 2-路平衡归并的方式,举一反三,还可以使用 3-路归并、4-路归并甚至是 10-路归并的方式,图 2 为 5-路归并的方式:

图 2 5-路平衡归并

对比 图 1 和图 2可以看出,对于 k-路平衡归并中 k 值得选择,增加 k 可以减少归并的次数,从而减少外存读写的次数,最终达到提高算法效率的目的。除此之外,一般情况下对于具有 m 个初始归并段进行 k-路平衡归并时,归并的次数为:s=⌊logk⁡m ⌋(其中 s 表示归并次数)。

从公式上可以判断出,想要达到减少归并次数从而提高算法效率的目的,可以从两个角度实现:

  • 增加 k-路平衡归并中的 k 值;

  • 尽量减少初始归并段的数量 m,即增加每个归并段的容量;

其增加 k 值的想法引申出了一种外部排序算法:多路平衡归并算法;增加数量 m 的想法引申出了另一种外部排序算法:置换-选择排序算法。

7.1 胜者树、败者树

7.1.1 败者树实现内部归并

败者树是树形选择排序的一种变形,本身是一棵完全二叉树

在树形选择排序一节中,对于无序表{49,38,65,97,76,13,27,49}创建的完全二叉树如图 1 所示,构建此树的目的是选出无序表中的最小值。

这棵树与败者树正好相反,是一棵"胜者树"。

因为树中每个非终端结点(除叶子结点之外的其它结点)中的值都表示的是左右孩子相比较后的较小值(谁最小即为胜者)。

例如叶子结点 49 和 38 相对比,由于 38 更小,所以其双亲结点中的值保留的是胜者 38。然后用 38 去继续同上层去比较,一直比较到树的根结点。

图 1 胜者树

而败者树恰好相反,其双亲结点存储的是左右孩子比较之后的失败者,而胜利者则继续同其它的胜者去比较。

例如还是图 1 中,叶子结点 49 和 38 比较,38 更小,所以 38 是胜利者,49 为失败者,但由于是败者树,所以其双亲结点存储的应该是 49;同样,叶子结点 65 和 97 比较,其双亲结点中存储的是 97 ,而 65 则用来同 38 进行比较,65 会存储到 97 和 49 的双亲结点的位置,38 继续做后续的胜者比较,依次类推。

胜者树和败者树的区别就是:胜者树中的非终端结点中存储的是胜利的一方;而败者树中的非终端结点存储的是失败的一方。而在比较过程中,都是拿胜者去比较。

图 2 败者树

如图 2 所示为一棵 5-路归并的败者树,其中 b0---b4 为树的叶子结点,分别为 5 个归并段中存储的记录的关键字。 ls 为一维数组,表示的是非终端结点,其中存储的数值表示第几归并段(例如 b0 为第 0 个归并段)。ls[0] 中存储的为最终的胜者,表示当前第 3 归并段中的关键字最小。

当最终胜者判断完成后,只需要更新叶子结点 b3 的值,即导入关键字 15,然后让该结点不断同其双亲结点所表示的关键字进行比较,败者留在双亲结点中,胜者继续向上比较。

例如,叶子结点 15 先同其双亲结点 ls[4] 中表示的 b4 中的 12 进行比较,12 为胜利者,则 ls[4] 改为 15,然后 12 继续同 ls[2] 中表示的 10 做比较,10 为胜者,然后 10 继续同其双亲结点 ls[1] 表示的 b1(关键字 9)作比较,最终 9 为胜者。整个过程如下图所示:

注意:为了防止在归并过程中某个归并段变为空,处理的办法为:可以在每个归并段最后附加一个关键字为最大值的记录。这样当某一时刻选出的冠军为最大值时,表明 5 个归并段已全部归并完成。(因为只要还有记录,最终的胜者就不可能是附加的最大值)

7.2 置换选择排序算法

例如已知初始文件中总共有 24 个记录,假设内存工作区最多可容纳 6 个记录,按照之前的选择排序算法最少也只能分为 4 个初始归并段。而如果使用置换---选择排序,可以实现将 24 个记录分为 3 个初始归并段,如图 1 所示:

图 1 选择排序算法的比较

置换---选择排序算法的具体操作过程为:

  1. 首先从初始文件中输入 6 个记录到内存工作区中;
  2. 从内存工作区中选出关键字最小的记录,将其记为 MINIMAX 记录;
  3. 然后将 MINIMAX 记录输出到归并段文件中;
  4. 此时内存工作区中还剩余 5 个记录,若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中;
  5. 从内存工作区中的所有比 MINIMAX 值大的记录中选出值最小的关键字的记录,作为新的 MINIMAX 记录;
  6. 重复过程 3---5,直至在内存工作区中选不出新的 MINIMAX 记录为止,由此就得到了一个初始归并段;
  7. 重复 2---6,直至内存工作为空,由此就可以得到全部的初始归并段。

拿图 1 中的初始文件为例,首先输入前 6 个记录到内存工作区,其中关键字最小的为 29,所以选其为 MINIMAX 记录,同时将其输出到归并段文件中,如下图所示:

此时初始文件不为空,所以从中输入下一个记录 14 到内存工作区中,然后从内存工作区中的比 29 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到 归并段文件中,如下图所示:

初始文件还不为空,所以继续输入 61 到内存工作区中,从内存工作区中的所有关键字比 38 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到归并段文件中,如下图所示:

如此重复性进行,直至选不出 MINIMAX 值为止,如下图所示:

当选不出 MINIMAX 值时,表示一个归并段已经生成,则开始下一个归并段的创建,创建过程同第一个归并段一样,这里不再赘述。

在上述创建初始段文件的过程中,需要不断地在内存工作区中选择新的 MINIMAX 记录,即选择不小于旧的 MINIMAX 记录的最小值,此过程需要利用"败者树"来实现。

同上一节所用到的败者树不同的是,在不断选择新的 MINIMAX 记录时,为了防止新加入的关键字值小的的影响,每个叶子结点附加一个序号位,当进行关键字的比较时,先比较序号,序号小的为胜者;序号相同的关键字值小的为胜者。

在初期创建败者树时也可以通过不断调整败者树的方式,其中所有记录的序号均设为 0 ,然后从初始文件中逐个输入记录到内存工作区中,自下而上调整败者树。过程如下:

  1. 首先创建一个空的败者树,如下图所示:

    提示:败者树根结点上方的方框内表示的为最终的胜者所处的位置。

  2. 从初始文件中读入关键字为 51 的记录,自下往上调整败者树,如下图所示:

    提示:序号 1 默认为比 0 小,为败者。

  3. 从初始文件中读入关键字为 49 的记录,调整败者树如下图所示:

  4. 从初始文件依次读入关键字为 39、46、38、29 的记录,调整败者树如下图所示:

由败者树得知,其最终胜者为 29,设为 MINIMAX 值,将其输出到初始归并文件中,同时再读入下一个记录 14,调整败者树,如下图所示:

注意:当读入新的记录时,如果其值比 MINIMAX 大,其序号则仍为 1;反之则为 2 ,比较时序号 1 比序号 2的记录大。

通过不断地向败者树中读入记录,会产生多个 MINIMAX,直到最终所有叶子结点中的序号都为 2,此时产生的新的 MINIMAX 值的序号 2,表明此归并段生成完成,而此新的 MINIMAX 值就是下一个归并段中的第一个记录。

7.3 最佳归并树

本节带领大家思考一个问题:无论是通过等分还是置换-选择排序得到的归并段,如何设置它们的归并顺序,可以使得对外存的访问次数降到最低?

例如,现有通过置换选择排序算法所得到的 9 个初始归并段,其长度分别为:9,30,12,18,3,17,2,6,24。在对其采用 3-路平衡归并的方式时可能出现如图 1 所示的情况:

图 1 3-路平衡归并

提示:图 1 中的叶子结点表示初始归并段,各自包含记录的长度用结点的权重来表示;非终端结点表示归并后的临时文件。

假设在进行平衡归并时,操作每个记录都需要单独进行一次对外存的读写,那么图 1 中的归并过程需要对外存进行读或者写的次数为:

(9+30+12+18+3+17+2+6+24)*2*2=484(图 1 中涉及到了两次归并,对外存的读和写各进行 2 次)

从计算结果上看,对于图 1 中的 3 叉树来讲,其操作外存的次数恰好是树的带权路径长度的 2 倍。所以,对于如何减少访问外存的次数的问题,就等同于考虑如何使 k-路归并所构成的 k 叉树的带权路径长度最短。

若想使树的带权路径长度最短,就是构造赫夫曼树。

在学习赫夫曼树时,只是涉及到了带权路径长度最短的二叉树为赫夫曼树,其实扩展到一般情况,对于 k 叉树,只要其带权路径长度最短,亦可以称为赫夫曼树。

若对上述 9 个初始归并段构造一棵赫夫曼树作为归并树,如图 2 所示:

图 2 赫夫曼树作为3-路归并树

依照图 2 所示,其对外存的读写次数为:(2*3+3*3+6*3+9*2+12*2+17*2+18*2+24*2+30)*2=446

通过以构建赫夫曼树的方式构建归并树,使其对读写外存的次数降至最低(k-路平衡归并,需要选取合适的 k 值,构建赫夫曼树作为归并树)。所以称此归并树为最佳归并树。

7.3.1 附加"虚段"的归并树

上述图 2 中所构建的为一颗真正的 3叉树(树中各结点的度不是 3 就是 0),而若 9 个初始归并段改为 8 个,在做 3-路平衡归并的时候就需要有一个结点的度为 2。

对于具体设置哪个结点的度为 2,为了使总的带权路径长度最短,正确的选择方法是:附加一个权值为 0 的结点(称为"虚段"),然后再构建赫夫曼树。例如图 2 中若去掉权值为 30 的结点,其附加虚段的最佳归并树如图 3 所示:

图 3 附加虚段的最佳归并树

注意:虚段的设置只是为了方便构建赫夫曼树,在构建完成后虚段自动去掉即可。

对于如何判断是否需要增加虚段,以及增加多少虚段的问题,有以下结论直接套用即可:

在一般情况下,对于 k--路平衡归并来说,若 (m-1)MOD(k-1)=0,则不需要增加虚段;否则需附加 k-(m-1)MOD(k-1)-1 个虚段。

相关推荐
CoovallyAIHub2 小时前
为AI装上“纠偏”思维链,开源框架Robust-R1显著提升多模态大模型抗退化能力
深度学习·算法·计算机视觉
小棠师姐2 小时前
随机森林原理与实战:如何解决过拟合问题?
算法·机器学习·随机森林算法·python实战·过拟合解决
范纹杉想快点毕业2 小时前
欧几里得算法与扩展欧几里得算法,C语言编程实现(零基础全解析)
运维·c语言·单片机·嵌入式硬件·算法
f***24112 小时前
Bug悬案:技术侦探的破案指南
算法·bug
Swift社区2 小时前
LeetCode 472 连接词
算法·leetcode·职场和发展
CoovallyAIHub3 小时前
YOLO-Maste开源:首个MoE加速加速实时检测,推理提速17.8%!
深度学习·算法·计算机视觉
清铎3 小时前
leetcode_day13_普通数组_《绝境求生》
数据结构·算法
hetao17338373 小时前
2026-01-09~12 hetao1733837 的刷题笔记
c++·笔记·算法
过河卒_zh15667663 小时前
情感型AI被“立规矩”,AI陪伴时代进入下半场
人工智能·算法·aigc·生成式人工智能·算法备案