这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

排序

本章在选择题中会考察,在大题中也可能会基于本章的排序算法思想出一道相关的代码题。需要熟练掌握各个排序算法的过程,并且能够手写快速排序的代码。
# 排序

## 排序的基本概念

## 内部排序

- 直接插入排序
- 折半插入排序
- 冒泡排序
- 简单选择排序
- 希尔排序
- 快速排序
- 堆排序
- 二路归并排序
- 基数排序

## 外部排序

## 排序算法的分析

1 - 内部排序

🔥 高优先级
每年选择题都必考一到两题,需要能够辨别 不同排序算法的特征 以及掌握 每种排序算法执行的具体流程

排序概念

稳定性

排序算法的 稳定性 是指:在排序过程中,如果两个元素的键值相等,排序后它们的 相对顺序保持不变,那么这个排序算法就是 稳定的排序算法

比如排序前的数组是这样:[... A ... B ...],并且 AB 的值相同。

如果排序后的数组是这样:[... B A ...],那么排序算法就是不稳定的,反之排序算法就是 稳定的

稳定排序排序前3A13B24[0][1][2][3][4]升序排序排序后123A3B4[0][1][2][3][4]3A仍在 3B之前,相对顺序不变 ✓不稳定排序排序前3A13B24[0][1][2][3][4]升序排序排序后123B3A4[0][1][2][3][4]3B跑到 3A之前,相对顺序改变 ✗
补充

可以通过以下方式对排序算法的 稳定性 进行记忆:

速度比较快的算法(时间复杂度 )一般都不稳定,除了 归并排序,因为它需要占额外的空间。

速度比较慢的算法(时间复杂度 )一般都稳定,除了 选择排序

除此之外,桶排序基数排序 都是 稳定的

元素移动次数

排序算法中的 元素移动次数,指的是在排序过程中对 数组元素进行位置变换的次数

下表列出了各排序算法的 元素移动次数

排序算法元素移动次数说明
冒泡排序最坏 每次交换都移动两个元素,次数较多
选择排序最多 每轮找到最小值,只交换一次,移动次数较少,但比较多
插入排序平均 可能需要把一个元素插入前面,移动一串元素
归并排序借助辅助数组,搬来搬去,数据复制较频繁
快速排序平均 ,最坏 每次划分区间时要交换元素
堆排序每次堆化需要交换元素,整体移动较快速排序少
希尔排序分段插入,元素跳跃移动,次数难以精确界定
桶排序元素在桶中“复制”而不是交换,适合不比较的排序场景
基数排序每轮根据某位进行 稳定排序,通常使用计数排序复制元素
O(n)O(n log n)O(n^1.5)O(n²)元素移动次数量级(从少到多)选择排序O(n)每轮仅交换一次,移动最少桶排序O(n)复制入桶,不做原地交换归并排序O(n log n)辅助数组复制堆排序O(n log n)堆化时原地交换快速排序均 O(n log n)最坏O(n²)基数排序O(n·d)按位多轮计数排序复制希尔排序O(n^1.3 ~ 2)难以精确界定插入排序O(n²)冒泡排序:最坏 O(n²),每次交换移动两个元素,次数最多;选择排序 & 桶排序移动次数最少最少较多

可以观察到,选择排序桶排序元素移动次数 是最少的,不涉及到大量的元素位置交换。

复杂度

排序算法时间复杂度层次图O(n + k) - 线性时间桶排序(理想情况)基数排序:O(d·(n+k))O(n log n) - 对数线性时间归并排序(稳定)堆排序快速排序(平均情况)稳定O(n²) - 二次时间冒泡排序(稳定)选择排序插入排序(稳定)希尔排序(最坏情况)快速排序(最坏情况)稳定稳定空间复杂度对比O(1) 常数空间:冒泡、选择、插入、希尔排序、堆排序O(log n) 对数空间:快速排序(递归栈)O(n) 线性空间:归并排序O(n + k) 线性空间:桶排序、基数排序绿色圆点表示稳定排序
排序方式时间复杂度空间复杂度是否稳定
冒泡排序\(O(n^2)\)\(O(1)\)稳定
选择排序\(O(n^2)\)\(O(1)\)不稳定
插入排序\(O(n^2)\)\(O(1)\)稳定
希尔排序取决于增量序列,最坏 \(O(n^2)\)\(O(1)\)不稳定
归并排序\(O(n \log n)\)\(O(n)\)稳定
快速排序平均 \(O(n \log n)\),最坏 \(O(n^2)\)\(O(\log n)\)不稳定
堆排序\(O(n \log n)\)\(O(1)\)不稳定
桶排序\(O(n + k)\)(k 为桶数)\(O(n + k)\)稳定(视内部排序方式而定)
基数排序\(O(d \cdot (n + k))\)(d 为位数,k 为基数)\(O(n + k)\)稳定
补充

快排的空间复杂度为什么是

快速排序是原地排序算法,唯一的空间开销来自递归调用栈,理想情况下:

  • 每次把数组分成近似相等的两半;
  • 整个递归树的深度是
  • 所以最多同时存在 层的递归调用;
  • 每一层调用中局部变量空间是常数( );

因此,空间复杂度是: (递归深度) × (每层开销) =

趟特征

在排序算法中,一趟(pass) 通常指 完成一次从头到尾(或部分范围)对数组进行处理的过程,这一过程中可能会比较、移动、插入或交换若干元素。
换句话说,一趟就是排序算法中最小的完整“循环工作单元”,通常对应于外层循环的一次执行。

排序算法一趟操作特征图解冒泡排序相邻比较,最大元素"冒"到末尾5281925189最大值到位选择排序找最小值放到起始位置5281915829最小值就位插入排序新元素插入已排序部分2518912589已排序未排序快速排序基准分区,左小右大3751931579< 基准基准> 基准归并排序合并相邻有序子数组1528912589子数组1子数组2合并有序堆排序堆顶元素移到末尾9582185129堆顶(最大)已排序基数排序按数位稳定排序123456321654按个位321123654456按个位数字排序图例说明未排序元素已排序/就位元素关键元素(基准/堆顶)当前处理元素大于基准的元素合并/处理后的元素一趟操作核心特征• 冒泡:最值逐渐"浮"到边界• 选择:直接定位最值到指定位置• 插入:维护前缀有序性• 快排:基准分区,确定相对位置• 归并:小有序段合并成大有序段• 堆排:利用堆性质找极值• 基数:按数位逐步细化排序

不同的排序算法在每趟排序后,数组中的元素会呈现不同特征,总结为下表:

排序算法一趟操作含义一趟后的数组特征
冒泡排序从头到尾依次比较相邻元素并交换最大元素被“冒”到最后
选择排序遍历剩余元素,找到最小值并放到当前位置最小元素被放到当前起始位
插入排序将下一个元素插入到前面已排序序列中前 \(i\) 个元素是有序的
希尔排序以某个 gap 为间隔,进行插入排序每个间隔为 gap 的子序列局部有序
归并排序合并相邻的两个有序子数组小规模区间逐步合并成更大的有序区间
快速排序选一个基准,划分左右小于/大于它的区间基准元素被放到最终位置,两侧被初步分区
基准元素左边的值都比它小,右边的值都比它大
堆排序构建/调整堆,取出堆顶并放到末尾当前堆顶(最大/最小)被移动到最终位置,堆被重构
桶排序元素被分配到不同桶中元素被归入对应桶,但桶内顺序未必有序
基数排序按某一数位进行 稳定排序(如个位)按当前数位局部有序,低位排序逐步推进整体有序

伪代码

BubbleSort(arr)
    n ← arr.length
    for i from 0 to n - 1
        for j from 0 to n - i - 2
            if arr[j] > arr[j + 1]
                swap arr[j], arr[j + 1]
InsertionSort(arr)
    n ← arr.length
    for i from 1 to n - 1
        key ← arr[i]
        j ← i - 1
        while j ≥ 0 and arr[j] > key
            arr[j + 1] ← arr[j]
            j ← j - 1
        arr[j + 1] ← key
SelectionSort(arr)
    n ← arr.length
    for i from 0 to n - 1
        minIndex ← i
        for j from i + 1 to n - 1
            if arr[j] < arr[minIndex]
                minIndex ← j
        swap arr[i], arr[minIndex]
RadixSort(arr)
    找到数组中最大值,确定最大位数 d
    for i from 1 to d
        对数组按第 i 位进行**稳定排序**
MergeSort(arr)
    if arr.length ≤ 1
        return arr
    mid ← arr.length / 2
    left ← MergeSort(arr[0 to mid - 1])
    right ← MergeSort(arr[mid to end])
    return Merge(left, right)

Merge(left, right)
    result ← []
    while left ≠ ∅ and right ≠ ∅
        if left[0] ≤ right[0]
            append left[0] to result
            remove left[0] from left
        else
            append right[0] to result
            remove right[0] from right
    append remaining elements of left and right to result
    return result
ShellSort(arr)
    n ← arr.length
    gap ← n / 2
    while gap > 0
        for i from gap to n - 1
            temp ← arr[i]
            j ← i
            while j ≥ gap and arr[j - gap] > temp
                arr[j] ← arr[j - gap]
                j ← j - gap
            arr[j] ← temp
        gap ← gap / 2
QuickSort(arr, low, high)
    if low < high
        pivotIndex ← Partition(arr, low, high)
        QuickSort(arr, low, pivotIndex - 1)
        QuickSort(arr, pivotIndex + 1, high)

Partition(arr, low, high)
    pivot ← arr[high]
    i ← low - 1
    for j from low to high - 1
        if arr[j] ≤ pivot
            i ← i + 1
            swap arr[i], arr[j]
    swap arr[i + 1], arr[high]
    return i + 1
HeapSort(arr)
    BuildMaxHeap(arr)
    for i from arr.length - 1 down to 1
        swap arr[0], arr[i]
        MaxHeapify(arr, 0, i)

BuildMaxHeap(arr)
    n ← arr.length
    for i from n / 2 - 1 down to 0
        MaxHeapify(arr, i, n)

MaxHeapify(arr, i, n)
    largest ← i
    left ← 2 * i + 1
    right ← 2 * i + 2
    if left < n and arr[left] > arr[largest]
        largest ← left
    if right < n and arr[right] > arr[largest]
        largest ← right
    if largest ≠ i
        swap arr[i], arr[largest]
        MaxHeapify(arr, largest, n)

冒泡排序

冒泡排序(Bubble Sort)是一种 简单的排序算法,通过反复遍历数组,比较相邻元素并交换位置,将较大的(或较小的)元素逐步“冒泡”到数组的一端。过程如下:

  1. 从数组开头开始,比较相邻的两个元素,如果顺序不对(例如前者大于后者,假设升序排序),则交换它们。
  2. 遍历一遍后,最大(或最小)的元素会被“冒泡”到数组末尾(或开头)
  3. 对剩余的未排序部分重复上述步骤,每次遍历的范围减少一个元素,直到数组完全排序。

在升序排序中,较大的元素像气泡一样逐渐“浮”到数组的末端(或较小的元素“沉”到开头),每次遍历都将一个元素推到正确的位置,形似气泡在水中上升的过程,因此得名 “冒泡排序”

比如,对于数组 5, 1, 4, 2, 8的前两次冒泡过程如下:

5
1
4
2
8
1
5
4
2
8
1
4
5
2
8
1
4
2
5
8
1
5
4
2
8
1
4
5
2
8
1
4
2
5
8
1
4
2
5
8
1
4
2
5
8
1
4
2
5
8
1
4
2
5
8
1
2
4
5
8
1
2
4
5
8
1
2
4
5
8
第一次冒泡:将最大的数放到最后一个位置
第二次冒泡:将第二大的数放到倒数第二个位置

插入排序

插入排序(Insertion Sort)将数组分为 未排序部分已排序部分,每次选取未排序部分的第一个元素作为插入元素,在已排序序列中 从后向前 扫描,找到相应位置并插入。

对于数组 4, 3, 2, 10, 12, 1, 5, 6 执行插入排序的过程:

4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6

插入排序可以分为 直接插入排序 折半插入排序 ,其不同点在于 寻找插入位置时 使用的是 从后向前顺序查找 还是 折半查找

直接插入排序与折半插入排序对比图展示两种插入排序在查找插入位置时的区别:直接插入排序使用从后向前的顺序查找,折半插入排序使用折半查找,最后两者都需要将元素后移再插入。已排序区间(示例)待插入元素258127直接插入排序顺序查找(从后向前)折半插入排序折半查找(二分查找)比较 1:7 与 1212>7,12 后移一位比较 2:7 与 88>7,8 后移一位比较 3:7 与 55<7,停止,位置确定mid=1,值为 55<7,插入点在右半mid=2,值为 88>7,插入点在左半low>high,查找结束插入位置为 low=2共同点:确定位置后都需将其后元素顺序后移,再插入该元素

插入排序适合 大部分元素有序 的场景,因为这种情况下进行插入比较的次数较少,排序算法执行更加高效。

例如,对于序列 1, 2, 3, 5, 4, 6, 7, 8,只有元素 4 的位置不正确,插入排序仅需将 4 向前移动一个位置即可完成排序。而对于 8, 7, 6, 5, 4, 3, 2, 1 这样的逆序序列,每个元素都需要移动到已排序部分的开头,需要进行大量比较和移动,因此效率最低。

因此,插入排序的时间复杂度与序列的 初始有序程度 密切相关:

  • 最好情况:序列已经有序,每个元素只需比较一次即可,时间复杂度为
  • 最坏情况:序列完全逆序,每个元素都需要移动到最前面,时间复杂度为
  • 平均情况:时间复杂度为

选择排序

选择排序(Selection Sort)将待排序序列划分为 已排序部分未排序部分。每一趟排序都 从未排序部分中选出关键字最小(或最大)的元素,并与未排序部分的第一个元素交换,使其进入已排序部分。

经过第 i 趟排序后,前 i+1 个元素均已处于最终位置,因此未排序部分不断缩小,已排序部分不断扩大,直到整个序列有序。

以排序数组 11, 25, 12, 22, 64 为例:

已排序部分未排序部分本趟选出的最小值
()(11, 25, 12, 22, 64)11
(11)(25, 12, 22, 64)12
(11, 12)(25, 22, 64)22
(11, 12, 22)(25, 64)25
(11, 12, 22, 25)(64)64
(11, 12, 22, 25, 64)()
11
25
12
22
64
11
25
12
22
64
11
12
25
22
64
11
12
22
25
64
11
12
22
25
64
11
12
22
25
64
注意

选择排序和插入排序都将序列划分为 已排序部分未排序部分,但两者维护的性质不同:

  • 选择排序:每一趟都会将当前最小(或最大)元素放到其 最终位置,因此已排序部分中的元素都已经归位,后续不会再发生变化。
  • 插入排序:每一趟只是将一个元素插入到已排序部分中的正确位置,虽然已排序部分内部始终有序,但其中元素的位置后续仍可能发生移动,因此并未全部归位。

归并排序

归并排序(Merge Sort)是一种典型的 分而治之(Divide and Conquer) 排序算法。它将待排序序列不断划分为规模更小的子序列,分别排序后,再将多个有序子序列逐步合并,最终得到一个完全有序的序列。

归并排序的核心操作称为 二路归并(Two-way Merge),即 将两个已经有序的序列合并成一个更大的有序序列。归并时,同时从两个有序序列的首元素开始比较,每次取出较小(或较大)的元素放入结果序列,直到两个序列全部合并完成。例如:

  • 1, 4, 72, 3, 8 ⟶ 二路归并后得到 1, 2, 3, 4, 7, 8
二路归并过程示意图展示两个有序序列 A 和 B 通过指针比较,将较小元素依次放入合并结果序列的过程有序序列 A147i有序序列 B238j比较 A[i] 与 B[j]取较小值放入结果12合并结果序列(依次填充)

上图展示了二路归并的一次典型比较过程:

  • 指针 ij 分别指向序列 A 和序列 B 的当前待比较元素(此时是 1 和 2);
  • 两者进入"比较"环节,取较小的一个(1)放入结果序列;
  • 指针 i 后移一位,继续与 j 指向的元素比较,如此重复;
  • 图中结果序列的前两格(1、2)已经填入,后面的虚线格子表示尚未归并的位置。

归并排序实际上就是不断重复 “分解 → 二路归并” 的过程:先将长度为 1 的有序子序列两两归并,再归并长度为 2 的有序子序列,接着归并长度为 4 的有序子序列……直到整个序列合并完成。

归并排序的流程可以总结为如下步骤:

  1. 分解(Divide):将待排序序列不断二分,直到每个子序列只包含一个元素(单个元素天然有序)。
  2. 递归(Conquer):递归地对子序列执行相同的操作。
  3. 归并(Merge):采用二路归并的方法,将两个相邻的有序子序列合并成一个更大的有序序列,直到整个序列有序。
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
27
38
3
43
9
82
10
3
27
38
43
9
10
82
3
9
10
27
38
43
82
注意

归并排序 过程中需要额外申请一个长度为 n辅助数组 来暂存归并结果,同时递归调用还需要 O(log n) 的栈空间,因此归并排序的额外空间复杂度为 O(n + log n),通常简记为 O(n)

由于归并时相等关键字能够保持原有的先后次序,因此归并排序是一种 稳定 的排序算法。

快速排序

快速排序(Quick Sort)的核心思想与 归并排序 类似,同样采用 分治法(Divide and Conquer),但两者的实现方式有所不同。

归并排序是 先递归划分,再合并;而快速排序则是 先分区,再递归。每次都会选择一个 基准元素(Pivot),将数组划分为左右两个部分,然后分别对左右子数组继续排序。

快速排序的基本步骤如下:

  1. 选择基准元素(Pivot):从当前区间中选择一个元素作为基准。
  2. 分区(Partition):重新排列数组,使所有小于基准的元素位于左侧,所有大于基准的元素位于右侧。分区结束后,基准元素已经处于最终排序位置
  3. 递归排序子数组:分别对基准左侧和右侧的子数组继续执行快速排序,直到区间长度为 1 或 0。

流程

快速排序通常由两个函数组成:

  • partition:完成一次分区操作,并返回基准元素最终所在的位置;
  • quickSort:递归调用 partition,分别排序左右两个子数组。

下面采用 双指针法 实现 partition,代码较为简洁,也是考研中最常见的实现方式。

// [low, high] 为分区的范围,返回值为分区后 pivot 在数组中的下标
int partition(int a[], int low, int high) {
    int pivot = a[low];     // 选择区间第一个元素作为基准
    int l = low, r = high;

    while (l < r) {
        while (l < r && a[r] >= pivot) r--; // 从右向左寻找小于 pivot 的元素
        while (l < r && a[l] <= pivot) l++; // 从左向右寻找大于 pivot 的元素
        if (l < r) swap(a[l], a[r]);        // 交换两个元素
    }

    swap(a[low], a[r]); // 将 pivot 放到最终位置
    return r;
}

void quickSort(int a[], int low, int high) {
    // 递归结束条件
    if (low < high) {
        int pivotIdx = partition(a, low, high);

        quickSort(a, low, pivotIdx - 1);
        quickSort(a, pivotIdx + 1, high);
    }
}

快速排序的代码实现需要熟练掌握(能够手写代码),作为算法设计题的兜底方案。

分区

下图以数组 [5, 3, 8, 1, 9, 4, 7, 2] 为例,展示 partitionlrpivot 的移动及交换过程。

快速排序 partition 双指针:以 [5, 3, 8, 1, 9, 4, 7, 2] 为例(pivot = 5)下标01234567Step 053819472lr取 a[low]=5 为 pivot,l=0, r=7Step 153819472lrr 自右向左找 < 5 的值:停在 a[7]=2Step 223819475lr交换 a[l] ↔ a[r]:2 放到左端,5 暂置右端Step 323819475lrl 自左向右找 > 5 的值:停在 a[2]=8Step 423519478lr交换 a[l] ↔ a[r]:8 放到右端,5 回到中部Step 523519478lrr 继续左移找 < 5:停在 a[5]=4Step 623419578lr交换后 l 继续右移,停在 a[4]=9最终23415978交换 9 和 5:pivot 落到下标 4,左侧 < 5,右侧 > 5pivot< pivot> pivot本步被移动

经过一次分区后,可以得到如下结论:

  • 基准元素左侧的元素都 不大于 基准;
  • 基准元素右侧的元素都 不小于 基准;
  • 基准元素已经位于最终排序位置,之后不会再移动。
双向递归

一次分区完成后,基准元素已经位于最终排序位置,数组被划分为两个互不重叠的子数组:

  • 左子数组:所有元素都 不大于 基准元素;
  • 右子数组:所有元素都 不小于 基准元素。

此时,左右两个子数组内部仍然可能是无序的,因此需要分别对它们继续执行快速排序,即对两个子数组分别递归调用 quickSort

随着递归不断进行,每个子数组都会继续被划分为更小的子数组。当子数组中 元素个数小于等于 1(即 low >= high)时,该子数组已经天然有序,此时递归结束,不再继续划分。

4
3
1
2
5
9
7
10
6
1
2
4
3
6
7
10
9
1
3
4
4
7
9
10
7
10
pivot
pivot
pivot
pivot
pivot
pivot
pivot
pivot
pivot

单向递归算法

快速排序每完成一次分区,都会确定 一个基准元素 的最终位置,因此如果我们并不需要整个数组有序,而只是希望找到某一个特定位置的元素,就没有必要继续递归两个方向。

例如,在寻找数组中 第 k 小(或第 k 大)元素 时:

  • 如果基准元素正好位于目标位置,则直接返回;
  • 如果目标位置位于左半部分,只需递归左侧;
  • 如果目标位置位于右半部分,只需递归右侧。

这种算法称为 Quick Select(快速选择),其思想与快速排序完全相同,只是每次分区后 只递归一个子区间,因此也称为 单向递归算法

快速排序 · 单向递归(Quickselect)寻找数组中第 k 小的元素 —— 每次只向包含目标的一侧递归① 核心思想经过一次 partition 后,基准元素 pivot 落在其最终有序位置(下标 = pivotIdx):• pivot 左侧元素 < pivot,右侧元素 > pivot• 因此只需比较 pivotIdx 与目标 k,就能判断第 k 小的元素在哪一侧 ——另一侧无需再排• 相比标准快排两侧都递归,单向递归把平均时间复杂度从 O(n log n) 降到O(n)② 一次 partition 后的三种情况左侧:都 < pivotpivot右侧:都 > pivotlow … pivotIdx-1pivotIdxpivotIdx+1 … high情况 A:pivotIdx == k基准正好落在第 k 个位置→ a[pivotIdx] 即为答案动作:直接返回,递归结束情况 B:pivotIdx < k目标在基准的右侧区间→ 只对 [pivotIdx+1, high] 递归动作:quickSelect(a, pivotIdx+1, high, k)情况 C:pivotIdx > k目标在基准的左侧区间→ 只对 [low, pivotIdx-1] 递归动作:quickSelect(a, low, pivotIdx-1, k)③ 示例:在 [3, 8, 1, 5, 9, 4, 7, 2] 中寻找第 k=3 小(下标 3,答案 4)初始区间 [0,7],取 pivot=3:1235947801234567pivotIdx=2 < k=3 → 递归右侧 [3,7]区间 [3,7],取 pivot=5:1234597801234567pivotIdx=4 > k=3 → 递归左侧 [3,3]区间 [3,3],仅一个元素 4:1234pivotIdx=3 == k=3 → 返回答案 4 ✓
// 分区算法与快速排序相同,这里不再赘述
int partition(int a[], int low, int high) { ... }

// 查找有序数组中下标为 k(从 0 开始)的元素
int quickSelect(int a[], int low, int high, int k) {
    if (low < high) {
        int pivotIdx = partition(a, low, high);

        // 找到目标元素
        if (pivotIdx == k) {
            return a[pivotIdx];
        }

        // 目标位于右半部分
        if (pivotIdx < k) {
            return quickSelect(a, pivotIdx + 1, high, k);
        }

        // 目标位于左半部分
        return quickSelect(a, low, pivotIdx - 1, k);
    }

    return a[low];
}

注意

上述代码中的 k 表示 有序数组中的下标(从 0 开始)。若题目要求寻找 第 k 大元素,通常需要先转换为对应的数组下标,再调用 quickSelect

堆排序

堆排序的核心在于利用 堆的性质 进行排序,在了解堆排序过程之前,需要了解堆的基本概念。

在二叉树一节中我们提到,二叉树的存储方式 包含 链接存储顺序存储 两种。
堆就是采用 顺序存储 的方式,在这种方式中,我们使用一个数组来模拟一颗二叉树,二叉树中的结点操作被转化为数组元素操作。

满足以下性质的树 被称为 (Heap):

  1. 结构性质:堆总是一颗 完全二叉树,即除了最后一层之外的其他每一层都被元素填满,且最后一层的元素都尽可能地靠左排列。
  2. 有序性质:树中的每个结点 相对于 子结点 都保证有序关系,要么父结点的值都 大于等于 子结点的值,要么都 小于等于 子结点的值,按照这种有序关系堆可以分为两种类型:
    • 大根堆 (Max Heap):堆中的任意结点,其值都 其子结点的值。这意味着 根结点是最大值
    • 小根堆 (Min Heap):堆中的任意结点,其值都 其子结点的值。这意味着 根结点是最小值
10
15
30
40
50
100
40
100
40
50
10
15
50
40
小根堆
大根堆

基于这些性质,堆常常被用于实现 优先队列。对于大根堆,我们总是可以在 O(1) 的时间内得到最大的元素(即根结点),而对于小根堆,我们总是可以在 O(1) 的时间内得到最小的元素。

实现
100
19
36
17
3
25
1
2
7
二叉树结构
堆的数组顺序表示
100
19
36
17
3
25
1
2
7
0
1
2
3
4
5
6
7
8

堆通常使用数组来实现,而不需要使用真正的树或链表结构。利用数组实现堆时,每个元素都有一个固定的位置,而该位置与元素在树中的位置有关。

假设数组的起始索引为 1,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i
  • 右孩子的索引为 2i + 1
  • 父结点的索引为 i/2 (整数除法)

如果数组的起始索引为 0,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i + 1
  • 右孩子的索引为 2i + 2
  • 父结点的索引为 (i-1)/2(整数除法)
操作

对于堆,重点掌握 堆化(heapify)构建初始堆删除插入 操作。

堆化

堆化是从某个结点开始,调整以该结点为根的子树,使其满足堆性质(以最大堆为例说明其过程):

  • 比较 当前结点左右子结点 的值。
  • 如果某个子结点 比当前结点大,交换当前结点与较大的子结点。
  • 交换后,继续对 被交换的子结点 递归地执行堆化,直到子树满足堆性质。

以大根堆 50,13,40,30,20,22,24,15,11 为例,若我们从结点 13 执行堆化,会发生如下图所示的过程:

50
13
40
22
24
30
20
15
11
假设从该非叶子结点执行 heapify
交换 13 和 30
(30 > 20,选择 30)
50
30
40
22
24
13
20
15
11
继续从叶子结点
递归地执行 heapify
50
30
40
22
24
15
20
13
11
交换 13 和 15

注意堆化的过程是递归的,当我们交换父结点和子结点后,需要从子结点进一步执行堆化操作。

构造初始堆

构造初始堆有两种方式:

  • 方法 1:向一个初始为空的堆中不断 插入 新的元素。
  • 方法 2:将无序数组转化为最大堆,核心是从 最后一个非叶子结点 开始,从后向前逐一对每个子树执行 堆化 操作。

以数组 1,3,5,4,6,13,10,9,8,15,17 为例,假设我们使用方法 2 将其初始化为大根堆,会发生如下图所示的过程:

1
3
5
4
6
13
10
9
8
15
17
swap
从结点 6 执行 heapify
1
3
5
4
17
13
10
9
8
15
6
从结点 4 执行 heapify
swap
1
3
5
9
17
13
10
4
8
15
6
从结点 5 执行 heapify
swap
1
3
13
9
17
5
10
4
8
15
6
swap
swap
1
17
13
9
15
5
10
4
8
3
6
从结点 3 执行 heapify
从根结点执行 heapify
swap
swap
swap
17
15
13
9
6
5
10
4
8
3
1
删除

堆中的删除一般都发生在 堆顶,其过程如下:

  • 将堆顶元素和堆中最后一个元素互换,然后删除最后一个结点
  • 对新的堆顶执行 堆化,调整堆使其重新满足最大堆性质
步骤 1:初始大根堆(假设要删除堆顶元素 50)503040152022241113• 堆顶(红黄底色):待删除元素 50• 堆底最后一个结点(红底色):13• 数组形式:[50, 30, 40, 15, 20, 22, 24, 11, 13]删除操作只发生在堆顶步骤 2:将堆顶 50 与最后一个结点 13 交换,删除末尾结点(50 被移出堆)交换133040152022241150已删除• 交换 heap[0] 和 heap[size-1]• 堆大小 size-- ,末尾 50 被移出堆• 当前堆顶 13 破坏了大根堆性质需要对新的堆顶执行「向下堆化 heapify」步骤 3:向下堆化 —— 13 与两个孩子(30, 40)中较大者 40 交换较大者1330401520222411交换4030131520222411继续向下比较步骤 4:13 继续与孩子(22, 24)中较大者 24 交换,此时 13 已成为叶子,堆化结束4030241520221311• 新数组:[40, 30, 24, 15, 20, 22, 13, 11]• 大根堆性质重新满足,删除完成图例:堆顶参与本步交换交换后位置已移出堆
插入

在堆中插入新元素后,需维护堆性质。

  • 将新元素添加到堆底(数组末尾)。
  • 从新元素开始,向上与父结点比较,若 大于父结点 则交换(上浮)。
  • 重复上浮直到满足堆性质或到达堆顶。
步骤 1:初始大根堆,准备插入新元素 354030241520221311• 待插入元素:35• 数组形式:[40, 30, 24, 15, 20, 22, 13, 11]• 当前根 40 是最大值,满足大根堆性质插入策略:先放到堆底(数组末尾),再向上「上浮」步骤 2:将 35 添加到堆底(数组末尾,作为 15 的右孩子)403024152022131135与父 15 比较• 数组:[40, 30, 24, 15, 20, 22, 13, 11,35]• 新结点下标 i = 8,父下标 = (i-1)/2 = 3(值 15)• 35 > 父结点 15 → 需要上浮步骤 3:35 与父结点 15 交换(上浮一层),继续与新的父 30 比较403024352022131115与父 30 比较• 数组:[40, 30, 24,35, 20, 22, 13, 11, 15]• 35 当前下标 i = 3,父下标 = (i-1)/2 = 1(值 30)• 35 > 父结点 30 → 继续上浮步骤 4:35 与父 30 交换,再与根 40 比较,35 < 40,停止上浮40352430202213111535 < 40,停止• 最终数组:[40, 35, 24, 30, 20, 22, 13, 11, 15]• 大根堆性质重新满足,插入完成图例:堆顶新插入结点参与本步交换交换后位置
// 调整 i 结点为根的子树,使其满足堆的性质
void heapify(int heap[], int size, int i) {
    int largest = i;       // 假设当前结点是最大的
    int left = 2 * i + 1;  // 左结点下标
    int right = 2 * i + 2; // 右结点下标
    if (left < size && heap[left] > heap[largest]) {
        largest = left;
    }
    if (right < size && heap[right] > heap[largest]) {
        largest = right;
    }
    // 如果最大值不是根结点
    if (largest != i) {
        swap(heap[i], heap[largest]);
        heapify(heap, size, largest);  // 递归堆化受影响的子树
    }
}
// 建立最大堆
void buildHeap(int heap[], int size) {
    // 从最后一个非叶结点开始,向前堆化每个结点
    for (int i = (size / 2) - 1; i >= 0; i--) {
        heapify(heap, size, i);
    }
}
// 向大小为 size 的堆中插入元素 value
void heapInsert(int heap[], int *size, int value) {
    heap[*size] = value;
    int current = *size;
    int parent = (current - 1) / 2;
    
    // 如果新插入的元素比父结点的元素大,需要将其向上移动
    while (current > 0 && heap[current] > heap[parent]) {
        swap(heap[current], heap[parent]); // 交换当前结点和父结点
        current = parent;
        parent = (current - 1) / 2; // 重新计算当前结点和其父结点的位置
    }
  
    (*size)++;  // 增加堆的大小
}
// 删除堆根结点
void heapDelete(int heap[], int *size) {
    if (*size <= 0) {
        return;
    }
    heap[0] = heap[*size - 1]; // 将堆顶元素替换为最后一个元素
    (*size)--;   // 将堆大小减 1
    heapify(heap, *size, 0);   // 调整堆
}

过程

堆排序(Heap Sort)其主要思想是将待排序的序列构造成一个 大顶堆小顶堆,然后交换堆顶和最后一个元素的位置,使最大或最小的元素放到序列的末尾。这样,就得到一个部分有序的序列。接下来,再对剩下的部分重新调整为大顶堆或小顶堆,并重复上述过程,直到整个序列有序。

堆排序流程图展示堆排序的主要步骤:构建初始大顶堆、交换堆顶与末尾元素、收缩未排序区间、对新堆顶下滑调整、判断未排序区间是否大于1,循环直到排序完成。待排序数组构建初始大顶堆从最后一个非叶结点开始下滑交换堆顶与末尾元素堆顶最大值归位到末尾未排序区间长度减一该元素视为已排序对新堆顶执行下滑调整恢复大顶堆性质未排序区间长度 > 1 ?排序完成,序列有序

堆排序的主要步骤:

  1. 构造初始堆:将给定无序序列构造成一个大顶堆(对于升序排序)或小顶堆(对于降序排序)。
  2. 交换数据:将堆顶元素与末尾元素交换,这样最大元素就位于序列的末尾。然后,将未排序的序列的长度减 1,因为最后一个元素已经排序好了。
  3. 重建堆:对于剩下的未排序的序列,重新调整为大顶堆。
  4. 重复步骤 2 和 3,直到整个序列有序。

调整堆过程:

  1. 选择一个结点 i,它的左子结点为 2i,右子结点为 2i+1
  2. 如果结点 i 的值小于其子结点的值,则找到 最大的子结点
  3. 将结点 i最大的子结点 交换。
  4. 交换后可能会破坏下一层的堆结构,所以需要对换到的子结点重复步骤 1‑3 的调整,直到整个子树满足堆的性质。
24
23
18
19
14
24
23
18
19
14
24
23
18
19
14
23
19
18
14
24
23
19
18
14
24
19
14
18
23
24
19
14
18
23
24
18
14
19
23
24
18
14
19
23
24
14
18
19
23
24
14
18
19
23
24
14
18
19
23
24
Original Array
After Heap Sort
14
18
19
23
24

堆排序的代码实现如下所示:

void heapSort(int heap[], int size) {
    // 构建最大堆 buildHeap
    for (int i = (size / 2) - 1; i >= 0; i--) {
        heapify(heap, size, i);
    }
    
    // 一个个从堆中取出元素
    for (int i = size - 1; i >= 0; i--) {
        swap(heap[0], heap[i]); // 将当前最大元素(堆顶)移到数组末尾
        heapify(heap, i, 0);    // 调整堆以维护最大堆性质
    }
}

希尔排序

希尔排序(Shell Sort)是一种 基于插入排序 的改进算法,通过分组和逐步减小步长来提高效率。

希尔排序流程图展示希尔排序算法的执行流程:确定初始步长、分组插入排序、缩小步长并循环,直至步长为1完成排序开始确定初始步长gap = n / 2分组插入排序对相距 gap 的元素分组排序gap == 1 ?缩小步长gap = gap / 2排序完成

希尔排序的过程如下所示:

  1. 确定初始步长(增量)
    • 选择一个初始步长(gap),通常可以 取数组长度的一半(例如 gap = n/2)。
  2. 分组插入排序
    • 将数组按步长 gap 分成若干组,每组内的元素相距 gap 个位置。
    • 对每组进行插入排序。例如,若 gap=4,比较和排序索引为 0,4,8... 的元素,1,5,9... 的元素,依此类推。
  3. 减小步长
    • 将步长缩小(通常除以 2 或按增量序列递减),例如 gap = gap/2
    • 重复步骤 2,对新的分组进行插入排序。
  4. 重复直到步长为 1
    • 当步长减小到 1 时,相当于对整个数组进行一次标准插入排序。此时数组已接近有序,插入排序的效率较高。
  5. 排序完成
    • 步长为 1 的插入排序完成后,数组完全有序。
注意

虽然希尔最初提出时建议初始 gap(增量序列)为 n/2 并逐步折半,但在实践中 gap 的选取是灵活的,并不强制要求必须为数组长度的一半。

以数组 32, 95, 16, 82, 24, 66, 35, 19, 75, 54, 40, 43, 93, 68 为例进行 希尔排序,数组长度为 14,初始步长为 7,然后选择步长 3、1,依次按照步长对所有子数组排序。

32
95
16
82
24
66
35
19
75
54
40
43
93
68
19
75
16
40
24
66
35
32
95
54
82
43
93
68
0
1
2
3
4
5
6
7
8
9
10
11
12
13
original
gap = 7
19
24
16
35
32
43
40
68
66
54
75
95
93
82
gap = 3
gap = 1
16
19
24
32
35
40
43
54
66
68
75
82
93
95
同色的元素表示该轮次 shell sort 根据 gap 选择的子数组

桶排序

桶排序(Bucket Sort) 的核心思想是根据关键字的取值范围,将数据分配到若干个 中,再分别对各个桶内的数据进行排序,最后按桶的顺序依次合并,从而得到有序序列。

桶排序适用于 关键字取值范围已知,并且数据分布较均匀的场景 的情况。当数据分布较均匀时,每个桶中的元素较少,因此整体排序效率较高。

桶排序的步骤如下:

  1. 根据数据范围划分若干个桶,并按照映射规则将元素放入对应的桶中;
  2. 对每个非空桶内部进行排序;
  3. 按桶的顺序依次取出各桶中的元素,得到最终有序序列。

例如,对 12, 9, 24, 4, 19, 21, 14, 6, 2, 16 进行桶排序的过程如下:

12
9
24
4
19
21
14
6
2
16
4, 2
9, 6
12, 14
19, 16
24, 21
2, 4
6, 9
12, 14
16, 19
21, 24
2
4
6
9
12
14
16
19
21
24
补充

桶排序和归并排序的区别

两者最终都是"分而治之",但分的方法完全不同。

更准确地说:

  • 归并排序:按元素个数划分(对半拆分),完全不关心元素的值。
  • 桶排序:按元素取值范围划分(映射到不同桶),充分利用元素值的信息。

所以这是两种类型的排序,其实我们将排序算法分成两大类:

  • 比较排序:只知道"谁比谁大",例如直接插入排序、冒泡排序、快速排序、堆排序、归并排序。它们不利用元素值的具体含义,因此都有比较排序的理论下界 (\Omega(n\log n))。
  • 分布排序(非比较排序):利用元素值本身的特性,例如计数排序、桶排序、基数排序。只要数据满足一定条件,就能突破比较排序的下界,达到线性时间。

基数排序

基数排序(Radix Sort)的核心思想是将整数分解为单独的数字,然后进行多轮排序,最终使数据有序。

基数排序分为 低位优先(LSD,Least Significant Digit first)和 高位优先(MSD,Most Significant Digit first)两种方案。这里重点掌握 LSD 的方案,MSD 的方式了解即可。

低位优先

  1. 先按照最低位进行 桶排序(或计数排序)。
  2. 然后按照次低位进行 桶排序,但保持上一轮的相对顺序(稳定排序)。
  3. 依次进行,直到最高位排序完成。
0
1
2
3
4
5
6
7
8
9
278
109
063
930
599
184
505
269
008
083
0
1
2
3
4
5
6
7
8
9
930
083
063
184
505
278
008
109
599
269
0
1
2
3
4
5
6
7
8
9
505
008
109
930
063
269
278
083
184
599
278, 109, 063, 930, 589, 184, 505, 269, 008, 083
第一次排序,按照个位将数字加入桶中
930, 063, 083, 184, 505, 278, 008, 109, 599, 269
505, 008, 109, 930, 063, 269, 278, 083, 184, 599
008, 063, 083, 109, 184, 269, 278, 505, 599, 930
第二次排序,按照十位将数字加入桶中
第三次排序,按照百位将数字加入桶中

以上排序的效果如下:

2
7
8
1
0
9
0
6
3
9
3
0
5
9
9
1
8
4
5
0
5
2
6
9
0
0
8
0
8
3
9
3
0
0
6
3
0
8
3
1
8
4
5
0
5
2
7
8
0
0
8
1
0
9
5
9
9
2
6
9
5
0
5
0
0
8
1
0
9
9
3
0
0
6
3
2
6
9
2
7
8
0
8
3
1
8
4
5
9
9
0
0
8
0
8
3
1
0
9
1
8
4
2
6
9
2
7
8
5
0
5
5
9
9
0
6
3
9
3
0

首先对最低位排序,然后对次低位排序,最后对最高位排序。通过三轮排序,可以保证结果序列是有序的。

高位优先

MSD(高位优先)基数排序的核心思想是从最高位开始,对数据进行递归分类,直到所有数字或字符串都排好序。

2
7
8
1
0
9
0
6
3
9
3
0
5
8
9
1
8
4
5
0
5
2
6
9
0
0
8
0
8
3
0
6
3
0
0
8
0
8
3
1
0
9
1
8
4
2
7
8
2
6
9
5
8
9
5
0
5
9
3
0
0
0
8
0
6
3
0
8
3
1
0
9
1
8
4
2
6
9
2
7
8
5
0
5
5
8
9

如上图所示,MSD 首先根据最高位对数据进行分组,然后再根据次高位对数据进行分组,以此类推,递归直到每组中只有一个元素。

MSD 基数排序适用于 字符串或变长数据,因为它先处理最高位,可以提前分组。适合 字典序排序,如 IP 地址、文件名、长整型数值等。

B
A
D
B
A
N
C
O
F
G
E
\0
N
E
R
\0
F
E
\0
C
O
M
P
A
R
I
S
O
N
\0
C
O
M
P
U
T
E
R
\0
M
I
D
N
I
G
H
T
\0
W
A
N
D
E
R
\0
M
A
R
D
R
O
B
E
\0
W
O
R
K
E
R
\0
使用 MSD 对字符串进行排序,绿色表示递归的范围

对比

方式低位优先(LSD)高位优先(MSD)
排序顺序从低位到高位从高位到低位
是否递归
适用数据等长数据(整数、固定长度字符串)变长数据(字符串、IP 地址)
适用场景大量数值排序变长字符串、字典序排序
实现难度较简单需要递归,较复杂

2 - 外部排序

中优先级
其实外部排序不是高频考点,但是遭不住 23 年考了一道置换选择排序的大题,所以还是得掌握下 外部排序的过程。这也给了我们一个启示:涉及到算法过程的知识点,都要留一个心眼,也许之前不考察但是今年就冷不丁地来一道大题。

外部排序流程

当待排序的数据量大到无法一次性全部装入内存时,就必须采用 外部排序。外部排序的基本思路是:先将整个数据集划分成若干能够装入内存的子块,对每个子块在内存中完成内部排序;随后再把这些已排序的子块逐步合并,最终得到整体有序的结果。这样既克服了内存容量的限制,又能高效地对海量数据完成排序。

外部排序的整体过程通常可以划分为两个关键阶段:

  1. 生成初始归并段
    先采用 置换选择排序 对原始的无序文件进行扫描。置换选择能够在一次扫描中尽可能长地生成有序子文件,这些子文件即称为 初始归并段。每个归并段都是内部有序的,且长度尽量大,以减少后续合并的轮数。

  2. 多路归并
    将所有 初始归并段 以多路归并的方式逐步合并。每一次归并都会把若干归并段合并成一个更长的有序段,重复此过程直至只剩下一个完整的有序文件,从而得到最终的排序结果。

通过上述两步,外部排序能够在 磁盘与内存之间 高效地完成大规模数据的排序。

置换归并排序:
生成初始归并段
多路归并排序

置换选择排序

置换选择排序(Replacement Selection Sort)是 外部排序 中的一个步骤,用于生成 初始归并段。它的目标是在 内存工作区有限 的情况下,尽可能生成更长的初始归并段,从而减少后续归并次数,提高外部排序效率。

置换选择排序的基本思想是:工作区中的记录并不会一次性全部输出,而是不断从输入文件读取新记录替换已经输出的记录,并判断新记录应该属于当前归并段还是下一归并段。

置换选择排序基本思路展示置换选择排序中输入文件、工作区、当前归并段与下一归并段之间的关系输入文件外存中待读入记录读入/替换记录工作区(内存缓冲区)容量固定,可容纳有限记录728选中173下一段11输出标记当前归并段已排好序,逐步增长81525下一归并段暂存记录,等待下一趟即将输出进入当前归并段的元素还在缓冲区的元素下一个会加入新归并段的元素

其算法过程如下:

  1. 初始化
    • 从待排序文件中读入前 M 个记录,放入工作区M 为工作区容量)。
  2. 生成归并段
    • 若当前还没有归并段,则创建第一个归并段,并输出工作区中最小的记录。
    • 此后,每次输出一个记录后,都将其与当前归并段最后一个记录(记为 MAXV)进行比较:
      • 如果工作区中仍存在键值大于 MAXV 的记录,则继续将其中键值最小的记录加入当前归并段,使归并段保持有序。
      • 如果工作区中的所有记录都不大于 MAXV,说明这些记录已经无法继续加入当前归并段,需要结束当前归并段,并创建一个新的归并段继续输出。
    • 每输出一个记录,就从输入文件中读取一个新的记录补充到工作区,直到所有记录都处理完成。
ReplacementSelectionstart开始init初始化:读取前M个记录到工作区start->initcheck_segment是否存在初始归并段?init->check_segmentcreate_new创建新的初始归并段添加工作区最小元素check_segment->create_newcompare_maxv工作区所有元素都 ≤ MAXV?check_segment->compare_maxvread_next从输入文件读取下一个值加入工作区create_new->read_nextcreate_new_segment创建新的初始归并段添加工作区最小元素compare_maxv->create_new_segmentfind_greater找到第一个 > MAXV 的元素添加到上一个归并段末尾compare_maxv->find_greatercreate_new_segment->read_nextfind_greater->read_nextcheck_data还有输入数据?read_next->check_datacheck_data->check_segmentend结束check_data->endlabel_heap工作区:最小堆大小为Mlabel_maxvMAXV:上一个归并段的最大值

置换选择排序的核心思想可以概括为:

  • 能够接入当前归并段的记录,就尽可能继续输出。 只要工作区中仍存在键值大于当前归并段最后一个记录(MAXV)的记录,就将其中键值最小的记录追加到当前归并段,使该归并段不断扩展。
  • 无法接入当前归并段的记录,则留到下一归并段。 当工作区中的所有记录都不大于 MAXV 时,说明这些记录都无法保持当前归并段的有序性,因此结束当前归并段,并以这些记录作为起点开始生成新的归并段。

与内部排序不同,置换选择排序并不是一次性对工作区中的记录进行排序,而是在 输出一个记录的同时,从输入文件中读入一个新的记录补充到工作区。因此,后续读入的数据只要满足有序性要求,就有机会继续加入当前归并段,而不是必须等待下一轮排序。

正是由于这种 边输出、边读入、边扩展归并段 的机制,一个归并段不仅包含最初读入工作区的记录,还可能包含后续读入的大量记录。因此,在随机输入的情况下,一个初始归并段的平均长度约为工作区容量的 2M,从而减少初始归并段的数量,降低后续多路归并的次数,提高整个外部排序的效率。

输入文件 IF
工作区
输出文件 OF1
输出文件 OF2
如果工作区中存在值
> OF1 最大值,添加进入 OF1
否则创建一个
新文件,添加工作区最小值进入 OF2

举一个实际的例子,假设一个输入文件 FI 的内容为 51, 94, 37, 92, 14, 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100。工作区能够容纳 4 个元素。

我们可以通过 置换选择排序 生成 3 个 初始归并段,分别为 {37, 51, 63, 92, 94, 99} ,{14, 15, 23, 31, 48, 56, 60, 90, 166} ,{8, 17, 43, 100} 。

算法执行的过程如下表所示:

输出文件 FO工作区 WA输入文件 FI
————51, 94, 37, 92, 14, 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
——51, 94, 37, 9214, 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
3751, 94, 14, 9263, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
37, 5163, 94, 14, 9215, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
37, 51, 6315, 94, 14, 9299, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
37, 51, 63, 9215, 94, 14, 9948, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100
37, 51, 63, 92, 9415, 48, 14, 9956, 23, 60, 31, 17, 43, 8, 90, 166, 100
37, 51, 63, 92, 94, 9915, 48, 14, 5623, 60, 31, 17, 43, 8, 90, 166, 100
37, 51, 63, 92, 94, 99#15, 48, 14, 5623, 60, 31, 17, 43, 8, 90, 166, 100
1415, 48, 23, 5660, 31, 17, 43, 8, 90, 166, 100
14, 1560, 48, 23, 5631, 17, 43, 8, 90, 166, 100
14, 15, 2360, 48, 31, 5617, 43, 8, 90, 166, 100
14, 15, 23, 3160, 48, 17, 5643, 8, 90, 166, 100
14, 15, 23, 31, 4860, 43, 17, 568, 90, 166, 100
14, 15, 23, 31, 48, 5660, 43, 17, 890, 166, 100
14, 15, 23, 31, 48, 56, 6090, 43, 17, 8166, 100
14, 15, 23, 31, 48, 56, 60, 90166, 43, 17, 8100
14, 15, 23, 31, 48, 56, 60, 90, 166100, 43, 17, 8——
14, 15, 23, 31, 48, 56, 60, 90, 166#100, 43, 17, 8——
8100, 43, 17——
8, 17100, 43——
8, 17, 43100——
8, 17, 43, 100————
8, 17, 43, 100#————

多路归并

多路归并(K-way Merge)是外部排序的第二个阶段,其目标是将多个已经有序的 初始归并段 逐步合并,最终生成一个完整的有序文件。

核心思想 是:

每个归并段始终保留一个当前待输出的元素,每次从这些元素中选出最小值写入输出文件,然后从该元素所属的归并段读取下一个元素进行补充。

由于每个归并段本身已经有序,因此只需要维护每个归并段当前的最小候选元素,即可保证输出文件始终保持有序。

实际实现中,通常使用 小根堆 维护各个归并段当前的候选元素,从而能够快速找到全局最小值。

37
51
63
92
•••
14
15
23
31
•••
8
17
43
100
•••
初始归并段0
8
14
37
输出文件
初始归并段1
初始归并段2
工作区
(小根堆)
选择 最小元素
加入输出文件

多路归并的具体流程如下:

  1. 初始化
    • 打开所有参与归并的初始归并段
    • 从每个归并段读取第一个元素,将这些元素加入工作区(通常采用小根堆维护)。
  2. 归并
    • 从工作区中取出最小元素,写入输出文件。
    • 找到该元素所属的归并段,从该归并段读取下一个元素。
    • 若该归并段还有剩余元素,则将新读取的元素加入工作区,并调整数据结构。
    • 重复上述过程,直到所有归并段均处理完成。
  3. 结束
    • 当所有归并段都已读完、工作区为空时,归并结束,得到一个完全有序的文件。

多路归并的关键在于:每个归并段始终只有一个元素参与比较,因此工作区中最多只需要维护 k 个元素(k 为归并路数),每输出一个元素,仅需从对应的归并段补充一个新的元素即可。借助小根堆,每次取出最小元素和插入新元素的时间复杂度均为 O(log k),因此整个多路归并能够高效地完成大规模外部排序。

多路归并的过程可以通过以下流程图理解:

MultiWayMergecluster_init1. 初始化阶段cluster_merge2. 归并阶段cluster_end3. 结束阶段cluster_example示例数据结构start开始open_files打开所有初始归并段start->open_filesread_first读取每个归并段的第一个元素open_files->read_firstcreate_heap创建最小堆存储首元素read_first->create_heapcheck_heap检查堆是否为空create_heap->check_heapextract_min从堆中取出最小元素check_heap->extract_minall_empty所有归并段都已处理完毕check_heap->all_empty(堆为空)output_min输出最小元素到结果文件extract_min->output_minfind_segment找到该元素所属的归并段output_min->find_segmentread_next从该归并段读取下一个元素find_segment->read_nextcheck_segment归并段是否为空?read_next->check_segmentcheck_segment->check_heap(该段已空)add_to_heap将新元素加入堆中check_segment->add_to_heapadjust_heap调整堆保持最小堆性质add_to_heap->adjust_heapadjust_heap->check_heapcomplete归并完成结果文件有序all_empty->completeend_node结束complete->end_nodeheap_example最小堆示例:  2 / \5   3segments归并段:S1: [2,8,15...]S2: [3,9,12...]S3: [5,7,11...]result结果文件:[已输出元素...]

继续用上文中通过 置换选择算法 生成的三个 初始归并段 {37, 51, 63, 92, 94, 99} ,{14, 15, 23, 31, 48, 56, 60, 90, 166} ,{8, 17, 43, 100} 作为例子。对于这三个 初始归并段多路归并 的过程如下(假设采用三路归并的话):

归并段 1归并段 2归并段 3工作区输出文件
37, 51, 63, 92, 94, 9914, 15, 23, 31, 48, 56, 60, 90, 1668, 17, 43, 100
51, 63, 92, 94, 9915, 23, 31, 48, 56, 60, 90, 16617, 43, 1008, 14, 37
51, 63, 92, 94, 9915, 23, 31, 48, 56, 60, 90, 16643, 10014, 17, 378
51, 63, 92, 94, 9923, 31, 48, 56, 60, 90, 16643, 10015, 17, 378, 14
51, 63, 92, 94, 9931, 48, 56, 60, 90, 16643, 10017, 23, 378, 14, 15
51, 63, 92, 94, 9931, 48, 56, 60, 90, 16610023, 37, 438, 14, 15, 17
51, 63, 92, 94, 9948, 56, 60, 90, 16610031, 37, 438, 14, 15, 17, 23
51, 63, 92, 94, 9956, 60, 90, 16610037, 43, 488, 14, 15, 17, 23, 31
63, 92, 94, 9956, 60, 90, 16610043, 48, 518, 14, 15, 17, 23, 31, 37
63, 92, 94, 9956, 60, 90, 16648, 51, 1008, 14, 15, 17, 23, 31, 37, 43
63, 92, 94, 9960, 90, 16651, 56, 1008, 14, 15, 17, 23, 31, 37, 43, 48
92, 94, 9960, 90, 16656, 63, 1008, 14, 15, 17, 23, 31, 37, 43, 48, 51
92, 94, 9990, 16660, 63, 1008, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56
92, 94, 9916663, 90, 1008, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60
94, 9916690, 92, 1008, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63
94, 9992, 100, 1668, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90
9994, 100, 1668, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90, 92
99, 100, 1668, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90, 92, 94
8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90, 92, 94, 99, 100, 166

胜者树

在进行 路归并(如外排序)时,我们需要从多个有序子序列中 快速找出当前最小元素,然后将其输出并替换为该序列的下一个元素。

最简单的做法就是每次遍历序列头部元素,找到最小值。该方法的时间复杂度为 ,效率比较低,尤其是当 比较大 时。

树形结构优化(胜者树/败者树)优化的目的就是优化这个过程:

用一棵完全二叉树维护每一轮比较的结果,使得我们可以在 时间内完成最小值查找与更新。


胜者树 可以理解为 胜者晋级的淘汰赛模型:类比体育比赛的淘汰制,每一轮两个选手进行比较,胜者晋级上一轮,最终全局胜者抵达根节点。

8
9
8
9
15
8
17
10
9
20
15
8
9
90
17
15
16
20
38
20
30
25
28
15
50
11
16
95
99
18
20

胜者树 中,每个 内部结点 记录的是该轮比较的 胜者(较小的元素),而 叶子节点 表示每个输入归并段的当前值。因此,整棵树的 根节点 就表示 全局最小值

当某个归并段输出了最小值并更新为下一个元素时,需要 从该叶子节点向上,逐层与兄弟节点重新比较,构建新的胜者路径,最终将新的最小值更新到根节点。

败者树

败者树 可以理解为 败者记录的升降赛模型:类比体育比赛中每场比赛将败者淘汰出局但保留记录,胜者则继续晋级下一轮,最终全局胜者脱颖而出,但 不会被记录在树中,而是单独保留,以便快速访问。

9
15
17
10
20
9
90
10
9
20
15
8
9
90
17
15
16
20
38
20
30
25
28
15
50
11
16
95
99
18
20
8
9
15
9
8
17
8
8
胜者需要进行下一轮比较

败者树 中,每个 内部结点 记录的是该轮比较的 败者(较大的元素),而 叶子节点 同样表示每个输入归并段的当前值。最终的全局胜者(最小值) 不保存在树中,而是单独保存在一个外部变量中

当某个归并段输出了最小值并更新为下一个元素时,需要 从该叶子节点出发,沿着路径向上与路径上的败者重新比较,并在每一层更新败者信息

补充

胜者树更加直观,败者树的优势在哪?

在用 胜者树 的时候,每个新元素上升时,首先先和兄弟结点比较,然后再更新父结点(访存 2 次)。

在使用 败者树 的时候,每个新元素上升时,只需要获得父节点并比较即可(访存 1 次)。

所以总的来说,减少了访存的时间,进而 提高了程序运行的效率