# 排序
## 排序的基本概念
## 内部排序
- 直接插入排序
- 折半插入排序
- 冒泡排序
- 简单选择排序
- 希尔排序
- 快速排序
- 堆排序
- 二路归并排序
- 基数排序
## 外部排序
## 排序算法的分析
排序
1 - 内部排序
排序概念
稳定性
排序算法的 稳定性 是指:在排序过程中,如果两个元素的键值相等,排序后它们的 相对顺序保持不变,那么这个排序算法就是稳定的排序算法。
比如排序前的数组是这样:[... A ... B ...]
,并且 A
和 B
的值相同。
如果排序后的数组是这样:[... B A ...]
,那么排序算法就是不稳定的,反之排序算法就是稳定的。
元素移动次数
排序算法中的 元素移动次数,指的是在排序过程中对 数组元素进行位置变换的次数。
下表列出了各排序算法的元素移动次数:
排序算法 | 元素移动次数 | 说明 |
---|---|---|
冒泡排序 | 最坏 $O(n^{2})$ | 每次交换都移动两个元素,次数较多 |
选择排序 | 最多 $O(n)$ | 每轮找到最小值,只交换一次,移动次数较少,但比较多 |
插入排序 | 平均 $O(n^2)$ | 可能需要把一个元素插入前面,移动一串元素 |
归并排序 | $O(n log_{2}n)$ | 借助辅助数组,搬来搬去,数据复制较频繁 |
快速排序 | 平均 $O(n log_{2}n)$,最坏 $O(n^{2})$ | 每次划分区间时要交换元素 |
堆排序 | $O(n log_{2}n)$ | 每次堆化需要交换元素,整体移动较快排少 |
希尔排序 | $O(n^{1.3 \sim 2})$ | 分段插入,元素跳跃移动,次数难以精确界定 |
桶排序 | $O(n)$ | 元素在桶中“复制”而不是交换,适合不比较的排序场景 |
可以观察到,选择排序 和 桶排序 的元素移动次数是最少的,不涉及到大量的元素位置交换。
复杂度
排序方式 | 时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|
冒泡排序 | $O(n^2)$ | $O(1)$ | 稳定 |
选择排序 | $O(n^2)$ | $O(1)$ | 不稳定 |
插入排序 | $O(n^2)$ | $O(1)$ | 稳定 |
基数排序 | $O(d*(n+k))$ | $O(n+k)$ | 稳定 |
归并排序 | $O(n*log(n))$ | $O(n)$ | 稳定 |
希尔排序 | 取决于增量序列 最坏情况为$O(n^2)$ | $O(1)$ | 不稳定 |
快速排序 | $O(n^2)$(最坏情况) $O(n*log(n))$(平均情况) | $O(log(n))$ | 不稳定 |
堆排序 | $O(n*log(n))$ | $O(n)$ | 不稳定 |
BubbleSort(arr)
n = arr.length
for i from 0 to n-1
for j from 0 to n-i-1
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
if arr[j] < arr[minIndex]
minIndex = j
swap(arr[i], arr[minIndex])
RadixSort(arr)
Find the maximum number in arr, determine the number of digits in it, denoted as d
for i from 1 to d
Using a stable sort (e.g., counting sort) to sort arr based on the i-th digit
MergeSort(arr)
if arr.length <= 1
return arr
mid = arr.length / 2
left = arr[0 to mid-1]
right = arr[mid to end]
left = MergeSort(left)
right = MergeSort(right)
result = Merge(left, right)
return result
Merge(left, right)
result = []
while left is not empty and right is not empty
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] and arr[j]
swap arr[i + 1] and arr[high]
return i + 1
HeapSort(arr)
BuildMaxHeap(arr) // 构建最大堆
for i from n-1 down to 1
swap arr[0] and 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] and arr[largest]
MaxHeapify(arr, largest, n)
冒泡排序
冒泡排序(Bubble Sort)的名称来源于它的操作方式:重复地遍历要排序的序列,比较每对相邻的项,若它们的顺序错误就交换过来,就像“小泡泡”从底部冒向顶部一样。
比如,对于数组 5, 1, 4, 2, 8
的前两次冒泡过程如下:
- 第一次(效果相当于将最大的数放到最后一个位置):
( 5 1 4 2 8 ) → ( 1 5 4 2 8 )
( 1 5 4 2 8 ) → ( 1 4 5 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 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
执行插入排序的过程:
插入排序可以分为 直接插入排序 和 折半插入排序,其不同点在于 寻找插入位置时 使用的是顺序查找还是折半查找。
选择排序
选择排序(Selection Sort)的思想是 从未排序的部分中找到最小(或最大)的元素,然后与未排序部分的第一个元素交换位置。这样,每一次选择操作后,未排序部分减少一个元素,而有序部分则增加一个元素。
以排序数组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) | () |
归并排序
归并排序(Merge Sort)是一个典型的“分而治之”策略的应用。它将一个大问题分解成若干小问题分别解决,然后将这些小问题的解合并为大问题的解。归并排序的主要思想是将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
基本思想:
- 分解:分解待排序的数据数组为两个长度相等(或几乎相等)的子数组。
- 递归:递归地排序两个子数组。
- 合并:合并(归并)两个已排序的子数组以产生排序好的数据数组。
快速排序
快速排序(Quick Sort)的核心思想是分而治之 (Divide and Conquer):
- 选择一个 “基准” 元素(Pivot):从数组中选择一个元素作为基准。
- 分区 (Partition):重新排列数组,使得所有小于基准的元素都在其左侧,所有大于基准的元素都在其右侧。在这个分区结束之后,该基准就处于数组的最终排序位置。
- 递归地排序子序列:递归地对基准左侧和右侧的子数组进行快速排序。
代码实现
快排代码包含两个函数,一个是 partition
,用于选择基准元素,并对其进行分区。
其中分区的实现方式有很多种,此处使用双指针法实现分区,该算法比较简短,方便记忆。
// [low, high] 为分区的范围,返回值为分区后 pivot 在数组中的下表
int partition(int a[], int low, int high) {
int pivot = a[low]; // 设置基准元素为 [low, high] 区间中的第一个元素
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);
}
}
快速排序的代码实现需要熟练掌握(能够手写代码),在数据结构的算法解答题中可以作为兜底的方案。
单向递归算法
每一次基于基准元素执行分区操作后,可以基于基准元素的位置将数组分为两个部分,这个时候基准元素的位置与最后有序数组中的位置相同。
基于这一点常常会做一些考察,比如如果我们需要寻找数组中第k大的数(即找到有序数组中下标为k的数),这个时候我们就不需要向两个方向递归,只需要向一个方向递归即可,如果当前基准被确定在第k个位置,即可退出递归。
// 分区算法实现与标准快排相同,这里不赘述
int partition(int a[], int low, int high) { ... }
// 寻找数组中第 k 大的数字,返回值为该元素
int quickSelect(int a[], int low, int high, int k) {
if (low < high) {
pivotIdx = partition(a, low, high);
// 如果 pivotIndex 与 k 相等,那么 a[pivotIdx] 就是我们要找的第k大的数
if (pivotIdx == k) {
return a[pivotIdx];
}
// 如果 pivotIdx 小于 k,说明第 k 大的数在右边分区
if (pivotIdx < k) {
return quickSelect(a, pivotIdx+1, high, k);
}
// 否则在左边分区
return quickSelect(a, low, pivotIdx-1, k);
}
}
堆排序
堆排序的核心在于利用堆的性质进行排序,在了解堆排序过程之前,需要了解堆的基本概念。
堆
在二叉树一节中我们提到,二叉树的存储方式 包含 链接存储 和 顺序存储 两种。 堆就是采用顺序存储的方式,在这种方式中,我们使用一个数组来模拟一颗二叉树,二叉树中的结点操作被转化为数组元素操作。
概念
满足以下性质的树 被成为 堆(Heap):
- 结构性质:堆总是一颗 完全二叉树,即除了最后一层之外的其他每一层都被元素填满,且最后一层的元素都尽可能地靠左排列。
- 有序性质:树中的每个结点 相对于 孩子结点都保证有序关系,要么父结点的值都 大于等于 子结点,要么都 小于等于 子结点,按照这种有序关系堆可以分为两种类型:
- 大根堆 (Max-Heap):堆中的任意节点,其值都不小于其子节点的值。这意味着根节点是最大值。
- 小根堆 (Min-Heap):堆中的任意节点,其值都不大于其子节点的值。这意味着根节点是最小值。
基于这些性质,堆常常被用于实现优先队列。对于大根堆,我们总是可以在 O(1) 的时间内得到最大的元素(即根节点),而对于小根堆,我们总是可以在 O(1) 的时间内得到最小的元素。
实现
堆通常使用数组来实现,而不需要使用真正的树或链表结构。利用数组实现堆时,每个元素都有一个固定的位置,而该位置与元素在树中的位置有关。
假设数组的起始索引为1,若某元素的索引为 i
,则它的:
- 左孩子的索引为
2i
- 右孩子的索引为
2i + 1
- 父节点的索引为
i/2
(整数除法)
如果数组的起始索引为0,若某元素的索引为 i
,则它的:
- 左孩子的索引为
2i + 1
- 右孩子的索引为
2i + 2
- 父节点的索引为
(i-1)/2
(整数除法)
操作
对于堆,重点掌握以下三种操作的过程:
- 构造初始堆:将一个数组初始化为堆,其步骤为从最后一个非叶结点开始,向下堆化每个节点。这种方法也叫做 “下沉法”,因为这种方法会将最小值不断下沉到二叉树的底部。
- 插入:将一个新的元素添加到堆中,并保持堆的性质。
- 删除:删除堆中的最大(或最小)元素,并保持堆的性质。
这三种操作的时间复杂度都为 $O(n)$。
其中堆化(heapify)是实现以上操作的核心,heapify 可以保证某个子树符合堆的性质。
// 调整 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[current]); // 交换当前节点和父节点
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,因为最后一个元素已经排序好了。
- 重建堆:对于剩下的未排序的序列,重新调整为大顶堆。
- 重复步骤2和3,直到整个序列有序。
调整堆过程:
- 选择一个节点 i,它的左子节点为 2i,右子节点为 2i+1。
- 如果节点 i 的值小于其子节点的值,则找到最大的子节点。
- 将节点 i 与最大的子节点交换。
- 交换后可能会破坏下一层的堆结构,所以需要对换到的子节点重复步骤1-3的调整,直到整个子树满足堆的性质。
堆排序的代码实现如下所示:
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)是一种 基于插入排序 的改进算法,通过分组和逐步减小步长来提高效率。
希尔排序的过程如下所示:
- 确定初始步长(增量):
- 选择一个初始步长(gap),通常可以 取数组长度的一半(例如 gap = n/2)。
- 分组插入排序:
- 将数组按步长 gap 分成若干组,每组内的元素相距 gap 个位置。
- 对每组进行插入排序。例如,若 gap=4,比较和排序索引为 0,4,8… 的元素,1,5,9… 的元素,依此类推。
- 减小步长:
- 将步长缩小(通常除以 2 或按增量序列递减),例如 gap = gap/2。
- 重复步骤 2,对新的分组进行插入排序。
- 重复直到步长为 1:
- 当步长减小到 1 时,相当于对整个数组进行一次标准插入排序。
- 此时数组已接近有序,插入排序的效率较高。
- 排序完成:
- 步长为 1 的插入排序完成后,数组完全有序。
以数组 32, 95, 16, 82, 24, 66, 35, 19, 75, 54, 40, 43, 93, 68
为例进行选择希尔排序,数组长度为 14,
初始步长为 7,然后选择步长 3、1,依次按照步长对所有子数组排序。
桶排序
桶排序(bucket sort)核心思想是将数据映射到不同的桶中,然后对每个桶内部排序,最后合并所有桶中的数据。
桶排序的步骤如下:
- 选择合适桶数,并将数据放入桶中
- 桶内数据排序
- 合并所有桶
比如对 0.78, 0.17, 0.39, 0.26, 0.72
进行排序:
- 划分桶:
0.0 - 0.2
:[0.17]
0.2 - 0.4
:[0.26, 0.39]
0.4 - 0.6
:[]
0.6 - 0.8
:[0.72, 0.78]
0.8 - 1.0
:[]
- 对每个桶排序:
[0.17]
[0.26, 0.39]
→[0.26, 0.39]
[0.72, 0.78]
→[0.72, 0.78]
- 合并所有桶:
[0.17, 0.26, 0.39, 0.72, 0.78]
基数排序
基数排序(Radix Sort)的核心思想是 将整数分解为单独的数字,然后进行多轮排序,最终使数据有序。
基数排序分为低位优先(LSD,Least Significant Digit first)和高位优先(MSD,Most Significant Digit first)两种方案。 这里重点掌握 LSD 的方案,MSD 的方式了解即可。
低位优先
- 先按照最低位进行桶排序(或计数排序)。
- 然后按照次低位进行桶排序,但保持上一轮的相对顺序(稳定排序)。
- 依次进行,直到最高位排序完成。
以上排序的效果如下:
首先对最低位排序,然后对次低位排序,最后对最高位排序。通过三轮排序,可以保证结果序列是有序的。
高位优先
MSD(高位优先)基数排序的核心思想是从最高位开始,对数据进行递归分类,直到所有数字或字符串都排好序。
如上图所示,MSD 首先根据最高位对数据进行分组,然后再根据次高位对数据进行分组,以此类推,递归直到每组中只有一个元素。
MSD 基数排序适用于字符串或变长数据,因为它先处理最高位,可以提前分组。 适合字典序排序,如IP 地址、文件名、长整型数值等。
对比
方式 | 低位优先(LSD) | 高位优先(MSD) |
---|---|---|
排序顺序 | 从低位到高位 | 从高位到低位 |
是否递归 | 否 | 是 |
适用数据 | 等长数据(整数、固定长度字符串) | 变长数据(字符串、IP 地址) |
适用场景 | 大量数值排序 | 变长字符串、字典序排序 |
实现难度 | 较简单 | 需要递归,较复杂 |
2 - 外部排序
外部排序
当待排序的数据量非常大,以至于无法一次性全部加载到内存中时,就需要使用外部排序。
外部排序通常分为两个主要阶段:
- 初始归并段生成: 使用置换选择排序,根据原始无序文件生成若干个尽量长的的有序文件,这些有序文件叫做初始归并段。
- 多路归并: 将这些初始归并段逐步合并,最终得到完全有序的文件。
置换选择排序
置换选择排序(Replacement Selection Sort)是外部排序的一个步骤,用于生成初始归并段, 其核心思想是在内存缓冲区有限的情况下,尽可能地生成较长的初始归并段。
置换选择排序通过维护一个工作区来实现这一目标,工作区是一个小根堆。
其算法步骤如下:
- 初始化:
- 将待排序文件中的前 M 个记录读入工作区,建立一个最小堆(M 为工作区的大小)。
- 生成归并段:
- 判断是否有初始归并段
- 如果不存在任何初始归并段的话,创建一个初始归并段,并添加工作区中的最小元素加入其中。
- 否则将工作区中的元素与上一个初始归并段的最大值 MAXV 进行比较。
- 如果工作区中的所有元素都 ≤ MAXV 的话,创建一个新的初始归并段,并添加工作区中的最小元素加入其中。
- 否则找到第一个 > MAV 的元素,将其添加到上一个初始归并段的末尾。
- 从输入文件中读取下一个值加入工作区。
- 判断是否有初始归并段
举一个实际的例子,假设一个输入文件 FI 的内容为 51, 94, 37, 92, 14, 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100。
我们可以通过置换选择排序生成 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, 92 | 14, 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37 | 51, 94, 14, 92 | 63, 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51 | 63, 94, 14, 92 | 15, 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51, 63 | 15, 94, 14, 92 | 99, 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51, 63, 92 | 15, 94, 14, 99 | 48, 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51, 63, 92, 94 | 15, 48, 14, 99 | 56, 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51, 63, 92, 94, 99 | 15, 48, 14, 56 | 23, 60, 31, 17, 43, 8, 90, 166, 100 |
37, 51, 63, 92, 94, 99# | 15, 48, 14, 56 | 23, 60, 31, 17, 43, 8, 90, 166, 100 |
14 | 15, 48, 23, 56 | 60, 31, 17, 43, 8, 90, 166, 100 |
14, 15 | 60, 48, 23, 56 | 31, 17, 43, 8, 90, 166, 100 |
14, 15, 23 | 60, 48, 31, 56 | 17, 43, 8, 90, 166, 100 |
14, 15, 23, 31 | 60, 48, 17, 56 | 43, 8, 90, 166, 100 |
14, 15, 23, 31, 48 | 60, 43, 17, 56 | 8, 90, 166, 100 |
14, 15, 23, 31, 48, 56 | 60, 43, 17, 8 | 90, 166, 100 |
14, 15, 23, 31, 48, 56, 60 | 90, 43, 17, 8 | 166, 100 |
14, 15, 23, 31, 48, 56, 60, 90 | 166, 43, 17, 8 | 100 |
14, 15, 23, 31, 48, 56, 60, 90, 166 | 100, 43, 17, 8 | —— |
14, 15, 23, 31, 48, 56, 60, 90, 166# | 100, 43, 17, 8 | —— |
8 | 100, 43, 17 | —— |
8, 17 | 100, 43 | —— |
8, 17, 43 | 100 | —— |
8, 17, 43, 100 | —— | —— |
8, 17, 43, 100# | —— | —— |
多路归并
多路归并的目标是将多个已经排序的初始归并段(子文件)合并成一个更大的有序文件。
其核心思想是从若干归并段中每次选取一个最小的元素加入工作区(小根堆),然后从工作区中选取最小的元素添加到输出文件中,通过这种方式可以保证每次添加到输出文件中的值是当前的最小值。
多路归并的具体过程如下:
- 初始化:
- 打开所有参与归并的初始归并段,并读取每个归并段的第一个元素。
- 将这些元素放入一个数据结构中,以便快速找到最小值(例如,最小堆)。
- 归并:
- 从数据结构中取出最小的元素,将其输出到结果文件中。
- 找到该元素所属的归并段,并从该归并段中读取下一个元素。
- 将新读取的元素放入数据结构中,并调整数据结构,以保持有序。
- 重复上述步骤,直到所有归并段都被处理完毕。
- 结束:
- 当所有归并段都为空时,归并过程结束,结果文件即为完全有序的文件。
继续用上文中通过置换选择算法生成的三个初始归并段 {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, 99 | 14, 15, 23, 31, 48, 56, 60, 90, 166 | 8, 17, 43, 100 | – | – |
51, 63, 92, 94, 99 | 15, 23, 31, 48, 56, 60, 90, 166 | 17, 43, 100 | 8, 14, 37 | – |
51, 63, 92, 94, 99 | 15, 23, 31, 48, 56, 60, 90, 166 | 43, 100 | 14, 17, 37 | 8 |
51, 63, 92, 94, 99 | 23, 31, 48, 56, 60, 90, 166 | 43, 100 | 15, 17, 37 | 8, 14 |
51, 63, 92, 94, 99 | 31, 48, 56, 60, 90, 166 | 43, 100 | 17, 23, 37 | 8, 14, 15 |
51, 63, 92, 94, 99 | 31, 48, 56, 60, 90, 166 | 100 | 23, 37, 43 | 8, 14, 15, 17 |
51, 63, 92, 94, 99 | 48, 56, 60, 90, 166 | 100 | 31, 37, 43 | 8, 14, 15, 17, 23 |
51, 63, 92, 94, 99 | 56, 60, 90, 166 | 100 | 37, 43, 48 | 8, 14, 15, 17, 23, 31 |
63, 92, 94, 99 | 56, 60, 90, 166 | 100 | 43, 48, 51 | 8, 14, 15, 17, 23, 31, 37 |
63, 92, 94, 99 | 56, 60, 90, 166 | – | 48, 51, 100 | 8, 14, 15, 17, 23, 31, 37, 43 |
63, 92, 94, 99 | 60, 90, 166 | – | 51, 56, 100 | 8, 14, 15, 17, 23, 31, 37, 43, 48 |
92, 94, 99 | 60, 90, 166 | – | 56, 63, 100 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51 |
92, 94, 99 | 90, 166 | – | 60, 63, 100 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56 |
92, 94, 99 | 166 | – | 63, 90, 100 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60 |
94, 99 | 166 | – | 90, 92, 100 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63 |
94, 99 | – | – | 92, 100, 166 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90 |
99 | – | – | 94, 100, 166 | 8, 14, 15, 17, 23, 31, 37, 43, 48, 51, 56, 60, 63, 90, 92 |
– | – | – | 99, 100, 166 | 8, 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 |
胜者树败者树
在进行 k 路归并的过程中,我们需要从 k 个有序文件的当前值中筛选出来一个最小值加入输出文件中。 这里最简单的方式就是一次遍历,当然也可以使用胜者树,如下图所示,两者的时间复杂度都为 O(k)。
简而言之,胜者树(winner tree)就是通过树形结构将最小值不断向根结点传递。
败者树与胜者树不同,它在每次比较中将较大的元素(败者)向上传递并保存在中间结点中,胜者则继续与其他胜者进行下一轮的比较。最终的全局胜者(最小值)不会存储在树中,而是单独保存在树外或根节点位置,供快速访问。
对于胜者树败者树,了解两者的基本概念即可。具体来说,两者对比如下:
特性 | 胜者树(Winner Tree) | 败者树(Loser Tree) |
---|---|---|
根节点 | 存储最小值(或最大值) | 存储最后一轮的败者,最小值保存在树外 |
非叶子节点 | 存储每轮胜者 | 存储每轮败者 |
查找最小值 | 在根节点 | 需要从树外变量中取(一次性) |
更新操作成本 | $O(log_2{k}))$ | $O(log_2{k})$,但更新路径较短 |
编码复杂度 | 较简单 | 略微复杂,但也容易实现 |
局部更新性能 | 平均需要更新整条路径 | 只需更新路径中涉及的节点,不需全树重建 |