目录
[1.1 直接插入排序算法](#1.1 直接插入排序算法)
[1.2 折半排序算法](#1.2 折半排序算法)
[1.3 希尔排序](#1.3 希尔排序)
[3.2 堆排序](#3.2 堆排序)
1.插入排序
1.1 直接插入排序算法
直接插入排序(Straight Insertion Sort)是一种简单直观的排序算法,通过构建有序序列,对未排序数据逐个插入到已排序序列的合适位置。
算法步骤
- 初始化:将第一个元素视为已排序序列,其余元素为未排序序列。
- 插入过程:从未排序序列中取出第一个元素,与已排序序列从后向前比较,找到合适位置插入。
- 重复操作:重复上述步骤,直到所有元素均插入完毕。
代码实现(java)
java
public class InsertionSort {
public static void insertionSort(int[] arr) {
int n = arr.length;
for (int i = 1; i < n; ++i) {
int key = arr[i];
int j = i - 1;
// 将大于key的元素向后移动
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
arr[j + 1] = key;
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6};
insertionSort(arr);
System.out.println("排序后的数组:");
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析
- 最优情况 :输入数组已有序,时间复杂度为 O(n)。
- 最差情况 :输入数组逆序,时间复杂度为 O(n²)。
- 平均情况 :时间复杂度为 O(n²)。
空间复杂度
直接插入排序是原地排序算法,空间复杂度为 O(1)。
稳定性
直接插入排序是稳定的排序算法,相同元素的相对位置不会改变。
适用场景
适用于顺序存储和链式存储的的线性表,采用链式存储时无需移动元素,只需要改变指针。
1.2 折半排序算法
折半排序(Binary Insertion Sort)是插入排序的优化版本,通过二分查找确定插入位置,减少比较次数。适用于数据量较小或部分有序的序列。
算法步骤
-
初始化
- 从第二个元素开始遍历序列,当前元素为待插入元素。
-
二分查找插入位置
- 在已排序部分(当前元素左侧)使用二分查找确定插入位置。
-
移动元素
- 将插入位置右侧的元素向右移动一位。
-
插入元素
- 将待插入元素放入正确位置。
代码实现(Java)
java
public class BinaryInsertionSort {
public static void binaryInsertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int key = arr[i];
int left = 0;
int right = i - 1;
// 二分查找确定插入位置
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] > key) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// 移动元素腾出插入位置
for (int j = i - 1; j >= left; j--) {
arr[j + 1] = arr[j];
}
arr[left] = key;
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6};
binaryInsertionSort(arr);
System.out.println("排序后数组:");
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析
- 最好情况 :序列已有序,仅需比较
O(nlog n)次,移动O(n) 次。 - 最坏情况 :序列逆序,比较和移动均为O(n²)。
- 平均情况 :时间复杂度为O(n²),但比普通插入排序减少比较次数。
空间复杂度
- 仅需常数额外空间
O(1),属于原地排序算法。
稳定性
折半插入排序是稳定的排序算法,相同元素的相对位置不会改变。
适用场景
需要用下标进行折半查找,所以仅适用于顺序存储的线性表
1.3 希尔排序
算法介绍
希尔排序(Shell Sort)是插入排序的改进版本,通过将原始列表分割成多个子序列进行插入排序,逐步缩小子序列的间隔,最终完成整体排序。该方法由Donald Shell于1959年提出,核心思想是通过减少元素的移动次数提升效率。
算法步骤
- 选择增量序列:确定一个递减的增量序列(如初始间隔为数组长度的一半,逐步减半直至为1)。
- 分组插入排序 :对每个增量间隔下的子序列(比如 数组**[2,3,4,5,6,7,1]** 中增量为3,分组情况为**[2,5,1]** ,[3,6],[4,7])对这些组各自进行进行插入排序。
- 缩小增量:重复上述过程,直至增量为1,完成最后一次全量插入排序。
代码实现(Java)
java
public class ShellSort {
public static void shellSort(int[] arr) {
int n = arr.length;
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
public static void main(String[] args) {
int[] arr = {12, 34, 54, 2, 3};
shellSort(arr);
System.out.println(Arrays.toString(arr));
}
}
时间复杂度
时间复杂度依赖于所选增量的序列,但至今没有确定最优的序列是什么,所以对于希尔排序不讨论时间复杂度问题
空间复杂度
希尔排序是原地排序算法,分组是在一个数组内进行的,不需要额外创建空间储存,空间复杂度为O(1)。
稳定性
希尔排序是不稳定的算法。由于元素可能在不同子序列中移动,相同值的元素相对位置可能改变。
适用场
需要用下标进行分组所以,仅适用于顺序存储的线性表
2.交换排序
2.1冒泡排序
冒泡排序算法介绍
冒泡排序是一种简单的排序算法,通过重复遍历待排序序列,比较相邻元素并交换位置,使较大(或较小)的元素逐渐"浮"到序列末端。因其过程类似气泡上浮而得名。冒泡排序每一趟都可以将一个元素放在最终位置
算法步骤
- 比较相邻元素:从序列起始位置开始,依次比较相邻的两个元素。
- 交换位置:若顺序不符合要求(如升序时前一个元素大于后一个),则交换它们的位置。如果在遍历过程中遇到更大或更小的数字,就让该数字继续进行比较,直到将最大或最小的数字交换到正确位置。
- 重复遍历:对未排序部分重复上述过程,每次遍历后最大(或最小)元素会移动到正确位置。
- 终止条件:当某次遍历未发生任何交换时,说明序列已有序,算法终止。
代码实现(Java)
java
public class BubbleSort {
public static void bubbleSort(int[] arr) {
int n = arr.length;
boolean swapped;
for (int i = 0; i < n - 1; i++) {
swapped = false;
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换相邻元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
// 若无交换,提前结束
if (!swapped) break;
}
}
public static void main(String[] args) {
int[] arr = {64, 34, 25, 12, 22, 11, 90};
bubbleSort(arr);
System.out.println("Sorted array: ");
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度
- 最坏情况:O(n²),当序列完全逆序时,需进行n(n-1)/2次比较和交换。
- 最好情况:O(n),当序列已有序时,仅需一次遍历即可终止。
- 平均情况:O(n²)。
空间复杂度
- 原地排序 :仅需常数级额外空间(如临时变量
temp),空间复杂度为O(1)。
稳定性
冒泡排序是稳定排序,因为相等元素的相对位置在交换过程中不会改变(仅当严格大于或小于时才交换)。
适用场景
适用于顺序存储和链式存储的的线性表
2.2快速排序
算法介绍
快速排序(Quick Sort)是一种基于分治思想的高效排序算法。通过选择一个基准元素(pivot),将数组划分为两个子数组,使得左边的元素均小于基准,右边的元素均大于基准,然后递归地对子数组进行排序。
算法步骤
- 选择基准:从数组中选择一个元素作为基准(通常选择第一个、最后一个或随机元素)。
- 分区操作:重新排列数组,使得小于基准的元素位于左侧,大于基准的元素位于右侧。基准的最终位置即为分区点。
- 递归排序:对分区后的左右子数组重复上述过程,直到子数组长度为1或0。
代码实现(Java)
java
public class QuickSort {
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pivotIndex = partition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
private static int partition(int[] arr, int low, int high) {
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
swap(arr, i, j);
}
}
swap(arr, i + 1, high);
return i + 1;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
int[] arr = {10, 7, 8, 9, 1, 5};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
}
时间复杂度
- 最佳/平均情况:O(n log n),每次分区将数组均匀划分。
- 最坏情况:O(n²),当数组已有序或逆序时,分区极度不平衡。
空间复杂度
- 递归栈空间:O(log n)(平均),O(n)(最坏)。
稳定性
快速排序是不稳定的排序算法。分区过程中可能改变相同元素的相对顺序(例如交换操作)。
适用场景
要求能够随机访问数据,所以只适用于顺序存储的线性表
3.选择排序
3.1简单选择排序
算法介绍
选择排序是一种简单直观的排序算法,通过不断选择剩余元素中的最小值(或最大值)并将其放到已排序部分的末尾。其核心思想是每次遍历未排序部分,找到最小元素并进行交换。每一趟可以确定一个元素的位置
算法步骤
- 遍历数组,从第一个元素开始,假设当前元素为最小值。
- 在未排序部分中寻找比当前最小值更小的元素,并记录其位置。
- 将找到的最小值与当前元素交换位置。
- 重复上述过程,直到所有元素排序完成。
代码实现(Java)
java
public class SelectionSort {
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 交换最小值与当前元素
int temp = arr[minIndex];
arr[minIndex] = arr[i];
arr[i] = temp;
}
}
public static void main(String[] args) {
int[] arr = {64, 25, 12, 22, 11};
selectionSort(arr);
System.out.println("Sorted array:");
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度
选择排序的时间复杂度为 O(n²) ,其中 n 是数组长度。无论输入数据是否有序,算法都需要进行 n(n-1)/2 次比较和最多 n-1 次交换。
空间复杂度
选择排序的空间复杂度为 O(1),因为算法仅使用常数级别的额外空间用于交换操作,不依赖输入规模。
稳定性
选择排序是不稳定的排序算法。例如,数组 [5, 5, 2] 在排序后可能变为 [2, 5, 5],相同元素的相对顺序可能改变。
适用场景
- 适用于小规模数据排序,因时间复杂度较高,不适合大规模数据。
- 适用于顺序存储和链式存储的线性表。
3.2 堆排序
算法介绍
堆排序是一种基于二叉堆数据结构的比较排序算法。它利用堆的性质(最大堆或最小堆)进行排序,通过构建堆并反复提取堆顶元素实现排序。堆排序属于选择排序的一种改进,具有较好的时间复杂度表现。
算法步骤
构建最大堆:将待排序的数组视为完全二叉树,从最后一个非叶子节点开始调整,使得每个父节点的值大于其子节点的值。
交换堆顶与末尾元素:将堆顶元素(最大值)与数组末尾元素交换,此时末尾元素为最大值。
调整剩余堆:排除末尾元素后,对剩余堆重新调整,使其满足最大堆性质。
重复交换与调整:重复上述交换和调整步骤,直到所有元素有序。
代码实现(Java)
java
public class HeapSort {
public void sort(int[] arr) {
int n = arr.length;
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 交换堆顶元素与末尾元素并调整堆
for (int i = n - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify(arr, i, 0);
}
}
void heapify(int[] arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
heapify(arr, n, largest);
}
}
}
时间复杂度
堆排序的时间复杂度为O(n log n)。构建堆的过程需要O(n)时间,每次调整堆需要O(log n)时间,共进行n-1次调整,因此总时间复杂度为O(n log n)。
空间复杂度
堆排序的空间复杂度为O(1)。它是一种原地排序算法,仅需常数级别的额外空间用于交换元素。
稳定性
堆排序是不稳定的排序算法。在调整堆的过程中,相同值的元素可能会因交换而改变相对顺序。
适用场景
堆排序适用于需要原地排序且对时间复杂度要求较高的场景。由于不需要额外空间,适合内存受限的环境。堆排序也常用于实现优先队列。对于大规模数据排序,堆排序的效率优于O(n²)的简单排序算法。
堆排序的插入操作
堆排序中的插入操作通常用于构建初始堆。以大顶堆为例,插入新元素时需要从底部向上调整,确保父节点始终大于或等于子节点。
将新元素插入到堆的末尾位置(即数组最后一个元素)。 比较新插入节点与其父节点的值,若父节点值较小则交换两者位置。 重复上述比较和交换过程,直到新节点值小于等于父节点或到达堆顶。
示例代码(大顶堆插入):
python
def heap_insert(heap, value):
heap.append(value)
index = len(heap) - 1
parent = (index - 1) // 2
while index > 0 and heap[parent] < heap[index]:
heap[parent], heap[index] = heap[index], heap[parent]
index = parent
parent = (index - 1) // 2
堆的调整操作(堆化)
当堆顶元素被移除或修改时,需要从上至下调整堆结构,这个过程称为堆化(heapify)。
将堆顶元素与最后一个元素交换,然后移除最后一个元素(即原堆顶)。 从新的堆顶开始,比较其与左右子节点的值。 若子节点值大于当前节点值,则与较大的子节点交换位置。 重复上述过程直到当前节点大于其子节点或到达堆的末尾。
示例代码(大顶堆调整):
python
def heapify(heap, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and heap[left] > heap[largest]:
largest = left
if right < n and heap[right] > heap[largest]:
largest = right
if largest != i:
heap[i], heap[largest] = heap[largest], heap[i]
heapify(heap, n, largest)
时间复杂度分析
插入操作的时间复杂度为 O(log n),因为最坏情况下需要从堆底移动到堆顶。 堆调整操作的时间复杂度同样为 O(log n),因为需要从堆顶向下比较到叶子节点。 构建初始堆的时间复杂度为 O(n),这是通过从最后一个非叶子节点开始逐个调整实现的。
实际应用注意事项
小顶堆的实现只需修改比较符号方向。 在堆排序过程中,插入操作主要用于建堆阶段,而调整操作用于排序阶段。 当处理动态数据时,插入和调整操作可能交替进行。
4.归并排序
归并排序算法介绍
归并排序是一种基于分治思想的排序算法。通过递归将数组分成两半分别排序,再将两个有序子数组合并成一个有序数组。其核心在于合并操作,保证了算法的稳定性和效率。
算法步骤
- 分解:递归地将n个元素的数组分成更小的子数组,直到每个子数组只包含一个元素。
- 合并 :从最小的子数组开始,逐步合并相邻的有序子数组,直到整个合并成一个长度为n的有序数组为止,临时数组辅助存储合并结果。
代码实现(Java)
java
public class MergeSort {
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
private static void merge(int[] arr, int left, int mid, int right) {
int[] temp = new int[right - left + 1];
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
System.arraycopy(temp, 0, arr, left, temp.length);
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
mergeSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
}
时间复杂度
- 最优/最差/平均 :O(n log n)
分解过程形成递归树,深度为log n,每层合并操作总时间为O(n)。
空间复杂度
- 额外空间 :O(n)
合并时需要临时数组存储结果,空间与输入规模成正比。
稳定性
- 稳定排序
合并时遇到相等元素优先保留左子数组元素,相对顺序不变。
适用场景
需要顺序访问元素,因此适用于顺序存储和链式存储的线性表
5.基数排序
基数排序算法介绍
基数排序是一种不依赖元素间比较的排序算法,通过逐位分配和收集实现排序。适用于整数或字符串等可分解为固定基数的元素。核心思想是从最低位到最高位(或反之)依次进行稳定排序,最终得到有序序列。
算法步骤
数据准备 假设待排序数组为 [170, 45, 75, 90, 802, 24, 2, 66],最大数字为802(3位数)。
按最低位排序(个位数) 创建10个桶(0-9),按个位数分配:
- 桶0: 170, 90
- 桶2: 802, 2
- 桶4: 24
- 桶5: 45, 75
- 桶6: 66
收集后数组变为 [170, 90, 802, 2, 24, 45, 75, 66]
按次低位排序(十位数) 按十位数重新分配,分配顺序为上一次收集后的数组的顺序:
- 桶0: 802, 2
- 桶2: 24
- 桶4: 45
- 桶6: 66
- 桶7: 170, 75
- 桶9: 90
收集后数组变为 [802, 2, 24, 45, 66, 170, 75, 90]
按最高位排序(百位数) 按百位数分配:
- 桶0: 2, 24, 45, 66, 75, 90
- 桶1: 170
- 桶8: 802
最终收集结果 [2, 24, 45, 66, 75, 90, 170, 802]
代码实现(Java)
java
public class RadixSort {
public static void radixSort(int[] arr) {
if (arr == null || arr.length == 0) return;
int max = Arrays.stream(arr).max().getAsInt();
int exp = 1; // 当前位数
while (max / exp > 0) {
countingSortByDigit(arr, exp);
exp *= 10;
}
}
private static void countingSortByDigit(int[] arr, int exp) {
int[] output = new int[arr.length];
int[] count = new int[10];
// 统计当前位数字出现次数
for (int num : arr) {
int digit = (num / exp) % 10;
count[digit]++;
}
// 计算累计位置
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 从后向前填充output数组(保证稳定性)
for (int i = arr.length - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
// 拷贝回原数组
System.arraycopy(output, 0, arr, 0, arr.length);
}
}
时间复杂度
- 最优/平均/最坏 :O(d*(n + k))
d为最大位数,n为元素数量,k为基数(如十进制中k=10)。- 若d为常数且k=O(n),则时间复杂度为O(n)。
基数排序参数示例
d,n,k都是什么?假设场景
对以下10个三位数的十进制整数进行排序:
[329, 457, 657, 839, 436, 720, 355, 123, 901, 654]
参数定义
- n(元素数量) :待排序数组的长度,此处
n = 10。 - d(最大位数) :所有数字中最大位数,观察发现均为3位数,故
d = 3。 - k(基数) :数字的进制基数,十进制中每位可能的取值为0-9,因此
k = 10。
时间复杂度分析
当 d 为常数且 k = O(n)(例如 k = 10 而 n = 1000 时满足 k ≤ n),时间复杂度为 O(d(n + k))。若 d 固定且 k ≈ n,则简化为 O(n)。
具体步骤
- 按最低位排序 (个位数):
[720, 901, 123, 654, 355, 436, 457, 657, 329, 839] - 按中间位排序 (十位数):
[901, 720, 123, 329, 436, 355, 654, 457, 657, 839] - 按最高位排序 (百位数):
[123, 329, 355, 436, 457, 654, 657, 720, 839, 901]
关键点
- 每次排序使用稳定算法(如计数排序),时间复杂度为
O(n + k)。 - 总复杂度
O(d(n + k))在d固定且k与n同阶时退化为线性。
空间复杂度
- 额外空间 :O(n + k)
- 需要辅助数组
output(O(n))和计数数组count(O(k))。
- 需要辅助数组
稳定性
基数排序是稳定排序,因依赖的底层排序(如计数排序)是稳定的,相同值的元素相对顺序不会改变。
适用场景
- 整数或字符串排序:元素需可分解为固定基数的位或字符。
- 位数较少:若最大位数d远小于元素数量n,效率较高。
- 数据范围集中:基数k不宜过大,否则计数排序的空间开销显著增加。
- **数据结构要求:**适用于顺序存储和链式存储的线性表
6.计数排序
计数排序算法介绍
计数排序是一种非比较排序算法,适用于整数且范围较小的数据。通过统计每个元素的出现次数,直接确定元素在输出数组中的位置。
算法步骤
-
统计频率:遍历输入数组,统计每个元素出现的次数,存入计数数组。
- 例如数组
[4, 2, 2, 8, 3, 3, 1],计数数组为[0, 1, 2, 2, 1, 0, 0, 0, 1](对应元素 0~8,比如2,3出现两次,计数数组下标为2,3的赋值为2)。
- 例如数组
-
计算前缀和:将计数数组转换为前缀和形式,表示元素在输出数组中的最后一个位置。
- 上述计数数组变为
[0, 1, 3, 5, 6, 6, 6, 6, 7]。
- 上述计数数组变为
-
反向填充:从后往前遍历原数组,根据前缀和数组确定位置,确保排序稳定性。
- 例如元素
3的前缀和为5,输出到索引4(位置计算为5 - 1),并减少计数。
- 例如元素
-
输出结果 :填充完成后得到有序数组
[1, 2, 2, 3, 3, 4, 8]。
代码实现(Java)
java
public static void countingSort(int[] arr) {
if (arr.length == 0) return;
// 确定数据范围
int max = Arrays.stream(arr).max().getAsInt();
int min = Arrays.stream(arr).min().getAsInt();
int range = max - min + 1;
// 初始化计数数组和输出数组
int[] count = new int[range];
int[] output = new int[arr.length];
// 统计频率
for (int num : arr) {
count[num - min]++;
}
// 计算前缀和
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 反向填充
for (int i = arr.length - 1; i >= 0; i--) {
output[count[arr[i] - min] - 1] = arr[i];
count[arr[i] - min]--;
}
// 拷贝回原数组
System.arraycopy(output, 0, arr, 0, arr.length);
}
时间复杂度
- 最优/平均/最坏 :均为 O(n + k),其中
n为元素数量,k为数据范围(最大值与最小值之差)。
空间复杂度
- 额外空间:O(n + k),用于计数数组和输出数组。
稳定性
计数排序是稳定排序,反向填充步骤保证了相同元素的相对顺序不变。
适用场景
- 数据范围小 :适合
k远小于n的情况(如年龄、分数等)。 - 整数数据:仅适用于整数或可映射为整数的数据。
- 数据结构:适用于顺序存储的线性表
7.排序算法总结
|------|--------------|---------------------------------------------|---------------------------------------------|---------------------------------------------|--------------------------------------------|-----|
| 算法类型 | 操作方法 | 时间复杂度 ||| 空间复杂度 | 稳定性 |
| 算法类型 | 操作方法 | 最好情况 | 最坏情况 | 平均情况 | 空间复杂度 | 稳定性 |
| 插入排序 | 直接插入排序 | |
|
|
| 稳定 |
| 插入排序 | 折半插入排序 | |
|
|
| 稳定 |
| 插入排序 | 希尔排序 | | | | | 不稳定 |
| 交换排序 | 冒泡排序 | |
|
|
| 稳定 |
| 交换排序 | 快速排序 | |
|
|
| 不稳定 |
| 选择排序 | 简单选择排序 | |
|
|
| 不稳定 |
| 选择排序 | 堆排序 | |
|
|
| 不稳定 |
| 其他排序 | 归并排序(二路) | |
|
|
| 稳定 |
| 其他排序 | 基数排序 | |
|
|
| 稳定 |
8.外部排序
8.1外部排序的方法
外部排序的概念
外部排序是一种处理大规模数据集的算法,适用于数据量超过内存容量时。核心思想是将数据分块加载到内存中排序,再将排序后的块合并为最终结果。典型应用场景包括数据库操作、大数据分析等。
外部排序的基本步骤
分阶段(Sort Phase)
将大文件分割为多个小块(通常称为"运行"或"块"),每个块的大小不超过可用内存。对每个块单独进行内部排序(如快速排序、堆排序),并将排序后的块写入临时文件。
合并阶段(Merge Phase)
通过多路归并(如二路归并或K路归并)将已排序的块合并为更大的有序文件。合并过程中需使用优先队列(堆)优化选择最小元素的操作。
8.2多路归并和败者树
多路归并(k-way merge)算法步骤
路归并是一种外部排序算法,用于合并多个已排序的子序列。假设有k个已排序的子序列,需要将它们合并为一个有序序列。
初始化一个大小为k的最小堆(或最大堆,取决于排序顺序),堆中每个元素包含子序列的当前值和子序列的索引。
从每个子序列中读取第一个元素,并将这些元素插入堆中。堆顶元素即为当前最小的元素。
取出堆顶元素,将其添加到输出序列中。从该元素对应的子序列中读取下一个元素,并将其插入堆中。如果子序列已耗尽,则跳过。
重复上述步骤,直到堆为空,所有子序列的元素均被处理完毕。
败者树(Loser Tree)算法步骤
败者树是一种用于多路归并的高效数据结构,通过树形结构减少比较次数。假设有k个子序列需要合并。
初始化败者树,树中每个非叶子节点记录"败者"(即较大的值),而"胜者"向上传递。叶子节点存储子序列的当前值。
从每个子序列中读取第一个元素,初始化叶子节点。进行树的构建,从叶子节点向上比较,记录败者,胜者继续向上比较,直到根节点。
根节点记录当前的最小值,将其添加到输出序列中。从该最小值对应的子序列中读取下一个元素,更新叶子节点。
从更新的叶子节点开始,沿路径向上重新比较,更新败者树。重复上述步骤,直到所有子序列耗尽。
路归并与败者树的比较
路归并使用堆结构,每次插入和删除操作的时间复杂度为O(log k),总时间复杂度为O(n log k),其中n为总元素数。
败者树的调整操作时间复杂度为O(log k),但由于减少了比较次数,实际性能通常优于堆。特别适合k较大的场景。
败者树的实现相对复杂,需要维护树结构和节点状态,而堆的实现较为简单,直接使用优先队列即可。
应用场景
路归并适用于k较小或实现优先级队列的场景,代码简洁且易于维护。
败者树适用于k较大或对性能要求严格的场景,如外部排序或大规模数据处理。
8.3置换选择排序
置换选择排序概述
置换选择排序(Replacement Selection Sort)是一种用于外部排序的算法,主要用于处理大规模数据(如无法全部装入内存的情况)。其核心思想是通过优先队列(通常为最小堆或最大堆)动态选择元素,生成多个有序的子序列(称为"顺串"或"run"),再通过归并排序合并这些子序列。
算法流程
-
初始化阶段
从输入文件中读取足够的数据填充到工作区(通常是一个最小堆)。假设工作区大小为 M ,则读取前 M 个元素构建最小堆。
-
生成顺串阶段
从堆顶(最小值)输出到当前顺串,并从输入文件中读取下一个元素替换堆顶元素:
- 若新元素 ≥ 刚输出的元素,将其插入堆中并调整堆结构。
- 若新元素 < 刚输出的元素,将其暂存到堆的末尾,标记为"不可用",并减少堆的有效大小。
重复此过程直到堆中所有元素均被标记为"不可用",此时完成一个顺串的生成。
-
重建堆阶段
将堆中未被输出的"不可用"元素重新构建为新的堆,开始生成下一个顺串。重复上述步骤直到所有数据被处理。
-
归并阶段
将生成的多个有序顺串通过多路归并合并为最终有序文件。
示例说明
假设输入序列为 [5, 3, 8, 2, 7, 1, 4, 6] ,工作区大小 M=3。
-
初始堆构建
读取前3个元素
[5, 3, 8],构建最小堆:[3, 5, 8]。 -
生成第一个顺串
- 输出堆顶
3,读取下一个元素2。
因2 < 3,将2放到堆末尾,标记为"不可用",堆有效大小减为2。当前堆状态:[5, 8](2被搁置)。 - 输出
5,读取7。
因7 ≥ 5,替换堆顶为7并调整堆:[7, 8]。 - 输出
7,读取1。
因1 < 7,搁置1,堆有效大小减为1:[8]。 - 输出
8,读取4。
因4 < 8,搁置4,堆为空。
第一个顺串为[3, 5, 7, 8],搁置元素为[2, 1, 4]。
- 输出堆顶
-
重建堆并生成第二个顺串
用搁置元素
[2, 1, 4]重建堆:[1, 2, 4]。- 输出
1,读取6。
因6 ≥ 1,替换堆顶为6并调整堆:[2, 6, 4](实际为[2, 4, 6])。 - 输出
2,无剩余输入。 - 输出
4,输出6。
第二个顺串为[1, 2, 4, 6]。
- 输出
-
归并顺串
最终合并两个顺串
[3, 5, 7, 8]和[1, 2, 4, 6]得到完整有序序列。
关键点
- 堆的调整保证了每次输出的元素是当前最小且不小于前一个输出元素的值。
- 算法通过置换策略最大化顺串长度,减少后续归并次数。
- 适用于数据量远大于内存容量的场景,如外部排序。
关键点分析
- 顺串长度:理想情况下,顺串长度约为堆大小的两倍(若输入数据接近随机,实际长度可能更短)。
- 堆的作用:优先队列动态维护当前可用的候选元素,确保输出的顺串有序。
- 外部排序结合:生成的顺串需通过多路归并排序进一步合并为最终有序结果。
复杂度与优化
- 时间复杂度:构建堆的复杂度为 (O(n \log k)),其中 (k) 为堆大小,(n) 为总数据量。
- 优化方向:通过调整堆大小或预读取策略减少磁盘I/O次数,提升外部排序效率。
该算法适用于数据量远大于内存容量的场景,是传统归并排序外排序流程的重要优化步骤。
8.4最佳归并树
构造最佳归并树的方法
最佳归并树(Optimal Merge Tree)是一种用于多路归并排序的树结构,旨在最小化归并过程中的总比较次数或总I/O操作次数。构造最佳归并树的核心思想是利用哈夫曼树的贪心算法,每次选择权值最小的节点进行合并。
步骤:
- 初始化节点集合:将每个初始归并段的长度作为叶子节点的权值,构成初始节点集合。
- 选择最小权值节点:每次从集合中选择两个权值最小的节点。(可以是任意个,取决于要构建几叉的树)
- 合并节点:将这两个/多个节点合并为一个新节点,新节点的权值为两个/多个子节点权值之和。
- 更新集合:将新节点加入集合,并移除原来的两个子节点。
- 重复操作:重复上述步骤,直到集合中只剩一个节点,即根节点。
示例: 假设初始归并段长度为 [5, 10, 20, 30, 40],生成二叉的归并树:
- 合并5和10,生成新节点15。
- 合并15和20,生成新节点35。
- 合并30和35,生成新节点65。
- 合并40和65,生成根节点105。
判断是否需要添加虚拟节点
在构造最佳归并树时,若初始归并段数量不足以形成一棵完全的多叉树(如k路归并时,初始节点数不满足特定条件),则需要添加虚拟节点(权值为0的节点)以优化归并过程。
判断条件:
- k路归并的约束:对于k路归并,初始归并段数量需满足 ( (n-1) \mod (k-1) = 0 ),其中 ( n ) 为初始归并段数量。若不满足,需补充虚拟节点。
- 虚拟节点数量计算:需补充的虚拟节点数为 ( (k-1) - ((n-1) \mod (k-1)) )。
示例:
- 假设初始归并段数量 ( n = 6 ),进行3路归并:
- 计算 ( (6-1) \mod (3-1) = 5 \mod 2 = 1 ),取余不等于0,则需要补充结点。
- 需补充虚拟节点数 ( 2 - 1 = 1 )。
- 添加1个虚拟节点后,总节点数为7,满足 ( (7-1) \mod 2 = 0 )。
虚拟节点的作用
**虚拟节点的权值为0,**不影响归并的总代价,但确保每次归并都能充分利用k路归并的效率,避免部分归并步骤的路径过长。
总结
- 使用哈夫曼算法构造归并树,每次合并权值最小的节点。
- 检查初始归并段数量是否满足k路归并的条件,若不满足则补充虚拟节点。
- 虚拟节点的数量通过模运算确定,确保归并树的平衡性。