【数据结构与算法】2 - 10 八大内部排序算法(下):选择、归并与基数
§2-10 八大内部排序算法(下):选择、归并与基数
2-10.1 选择排序(Selection sort)
2-10.1.1 直接选择排序(Direct selection sort)
排序思想:从数组的起始索引开始,对其后面的元素逐个比较,找到比它小的值并交换,一轮循环后,最小值的位置已确定并已正确排列。接着从 1 索引开始,重复上述过程,直至到结束索引 - 1 处,每一轮索引都会确定一个最小值。因此,对于一个长度为 n
的数组,只需循环 n - 1
次即可。
算法实现:
public static void selectSort(int[] arr) {
// 从起始索引处开始遍历
for (int index = 0; index < arr.length - 1; index++) {
// 对每个索引,依次比较基准索引后元素
for (int i = index + 1; i < arr.length; i++) {
if (arr[index] > arr[i]) {
int temp = arr[index];
arr[index] = arr[i];
arr[i] = temp;
}
}
}
}
注意:
- 每一轮比较时,都存在一个基准索引,将基准索引的值与其后面的值做比较;
- 基准索引处的值会随着内层循环的进行可能发生变化,但一轮循环(外层循环)结束后,总会确定该轮循环基准索引处的值,即最小值。
直接选择排序的平均时间复杂度、最好情况、最坏情况都为 \(O(n^2)\),空间复杂度为 \(O(1)\),排序不稳定。
优化:直接确定当前轮中最小值,减少变量交换次数
若基准数后有多个比它小的数,每次满足条件时都会进行变量交换,耗费一定时间。可以先找到这一轮循环中的最小值,在循环结束时再做变量交换,就可实现每一轮循环仅一次交换,节省时间。
public static void selectOpt(int[] arr) {
// 从起始索引处开始遍历
for (int index = 0; index < arr.length - 1; index++) {
// 在每一轮直接找到最小值,减少交换次数
int minAtIndex = index; // 记录最小值所在索引,假设基准处为最小值
for (int i = index + 1; i < arr.length; i++) {
// 找出剩余元素的最小值索引
if (arr[i] < arr[minAtIndex]) {
minAtIndex = i;
}
}
// 变量交换
int temp = arr[index];
arr[index] = arr[minAtIndex];
arr[minAtIndex] = temp;
}
}
2-10.1.2 堆排序(Heap sort)
排序思想:堆排序使用堆的数据结构,将数组以完全二叉树的形式对数组进行排序。
算法流程:
下文内容来自:
首先得先要了解一些概念。
大顶堆:堆中的父节点的值比其子节点的值都大的堆称为大顶堆。在堆排序中,使用数组存储大顶堆,如图所示。
下标为 i
的节点的父节点下标为 (i-1) / 2
(整数除法);下标为 i
的父节点的左子节点下标为 i * 2 + 1
,右子节点下标为 i * 2 + 2
。
使用数组存储堆时,使用的遍历顺序为层序遍历。且最底层尽可能向左填充,其余层完全填充,是一棵完全二叉树。
类似地,小顶堆就是父节点的值小于子节点的值的堆。使用相同的遍历方法存储为数组。堆排序使用大顶堆。
维护堆的性质:
在排序过程中,要时刻维护大顶堆的性质,保证父节点的值大于子节点。当其中一个父节点不满足此性质时,应当从其直接子节点中找出最大值,将子节点中的该值与父节点的值对调。对调完成后,该子节点及其子节点可能不满足大顶堆的性质,应当检查该子节点是否满足大顶堆的性质,并用相同方法维护性质。维护完成,数组中存储的数据应当发生相同的变化。
维护堆的性质的代码实现:
/**
* 维护堆的性质(大顶堆)
*
* @param arr 待维护的数组
* @param length 数组(堆)长度
* @param index 待维护的下标(父节点)
*/
private static void heapify(int[] arr, int length, int index) {
int max = index; // 假设父节点具有最大值
int leftChild = index * 2 + 1; // 左子节点下标
int rightChild = index * 2 + 2; // 右子节点下标
// 从左右子节点中找到具有最大值的节点坐标,注意左右子节点坐标不应越界
if (leftChild < length && arr[leftChild] > arr[max])
max = leftChild;
if (rightChild < length && arr[rightChild] > arr[max])
max = rightChild;
// 左右子节点判断流程结束,若父节点值并非最大值
if (max != index) {
// 交换父子节点的值,维护性质
swap(arr, index, max);
// 子节点在交换后可能破坏了堆的性质,应当检查并继续维护(递归)
heapify(arr, length, max);
}
}
// 变量交换
private static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
维护堆性质的时间复杂度为 \(O(\log n)\)。
建立大顶堆:
进行堆排序时,所传入的数组可能是无序的,也就是说,这个堆也是无序的。则应当先建立大顶堆。这一过程实际上就是在维护大顶堆的性质。而建堆,需要从最后一个父节点(非叶节点)开始,以上图为例,则应当从索引 4 处的结点开始检查并维护堆的性质,然后到索引 3,索引 2,索引 1,以此类推。维护过程中仍然要注意变量交换时可能导致的性质破坏问题,应当递归调用上述方法维护性质。
建堆的时间复杂度为 O(n)
(使用级数证明)。
建堆完成后,将堆顶元素与堆中最后一个元素交换,并将最后一个元素从堆中删去。将剩余的元素视为一个新堆,重复上述建堆、维护堆、交换删除的过程。直至堆中只剩一个元素时,认为全数组有序,排序完成。
算法实现:
// 堆排序
public static void heapSort(int[] arr) {
// 建立大顶堆:实际上就是维护堆的性质
// 数组最后一位元素的下标为 arr.length - 1,其父节点(整数除法)下标为 (length - 1 - 1) / 2 = length / 2 - 1
// 自下向上建堆
for (int i = arr.length / 2 - 1; i >= 0; i--) {
heapify(arr, arr.length, i);
}
// 排序
for (int i = arr.length - 1; i > 0; i--) {
// 交换堆顶和堆最后一个元素
swap(arr, 0, i);
// 删去堆中最后一个元素,则新堆长度为 length - 1,对新堆进行维护
// 自上向下建堆
heapify(arr, i, 0);
}
}
对排序的时间复杂度是对 n
个数进行堆维护的时间复杂度,即 \(O(n \cdot \log n)\)(平均、最好、最坏),空间复杂度为 \(O(1)\),排序不稳定。
2-10.2 归并排序(Merge sort)
排序思想:将待排序的序列逐步对半划分,直至划分后得到的每一组都有且仅有 1 个元素,这样的组天然有序。然后,再对这样的组,再两两归并,归并过程中完成排序,直到最后整合为一个已经完全排好序的数组。这种排序方式也称为二路归并排序(2-way merge sort)。
算法实现参考自:
算法实现:
public static void mergeSort(int[] arr) {
// 创建一个临时数组
int[] tempArr = new int[arr.length];
// 划分区间,并归并
partition(arr, tempArr, 0, arr.length - 1);
}
/**
* 划分后归并。
*
* @param arr 待排序数组
* @param tempArr 辅助数组
* @param left 划分开始的左索引
* @param right 划分结束的右索引
*/
private static void partition(int[] arr, int[] tempArr, int left, int right) {
// 对原数组作划分
// 若划分区间为 1,该区间天然有序,无需划分
// 否则,继续划分,直至区间长度为 1
if (left < right) {
// 找到划分左右子区间的中间点
int mid = (left + right) / 2;
// 递归划分左子区间
partition(arr, tempArr, left, mid);
// 递归划分右子区间
partition(arr, tempArr, mid + 1, right);
// 划分完成后,归并已经排序的部分
merge(arr, tempArr, left, mid, right);
}
}
/**
* 归并,并排序。
*
* @param arr 原数组
* @param tempArr 辅助数组
* @param left 左划分子区间的起始索引
* @param mid 划分子区间的中间点
* @param right 右划分子区间的结束索引
*/
private static void merge(int[] arr, int[] tempArr, int left, int mid, int right) {
// 记录左子区间的首个未排序元素
int leftPos = left;
// 记录右子区间的首个未排序元素
int rightPos = mid + 1;
// 临时数组的下标
int tempPos = left;
// 比较两个区间的元素,按升序排列放到辅助数组中
while (leftPos <= mid && rightPos <= right) {
// 若左子区间的元素更小
if (arr[leftPos] < arr[rightPos])
tempArr[tempPos++] = arr[leftPos++]; // 赋值完需要移动指针,指向下一个未排序元素
else // 反之,则右子区间的元素更小
tempArr[tempPos++] = arr[rightPos++];
}
// 若分区大小不一致(即分区不均匀),则其中一区间已经遍历完,另一区间还余有元素
// 这时,上述循环条件不满足,应当拆开上述循环
// 余有元素的区间剩余元素已经有序,直接拼接,合并剩余元素
// 两个循环只能二选一进行
while (leftPos <= mid)
tempArr[tempPos++] = arr[leftPos++];
while (rightPos <= right)
tempArr[tempPos++] = arr[rightPos++];
// 将临时数组中的元素复制到原来的数组
while (left <= right) {
arr[left] = tempArr[left];
left++;
}
}
注意:
- 调用
partition()
方法划分区间时,若划分长度为 1,此时left == right
,不满足条件,无需划分。
该算法的平均时间复杂度、最好情况、最坏情况为 \(O(n \cdot \log n)\),空间复杂度为 \(O(n)\),排序稳定。
2-10.3 基数排序(Radix sort)
排序思想:不同于其他排序算法,基数排序没有任何的变量比较与交换,整个排序过程通过分配再收集的方式完成。数组中最大值的位数,决定排序的循环轮次,依次为个、十、百、千等,用于匹配每一轮循环中遍历数组时,收集元素对应的位数的值。以第一轮循环为例,遍历数组,将数组元素按照个位数的值收集到不同的 “桶” 当中,存储完成后,再从个位数 0-9 的顺序依次抽出,组成新数组。接着,进入百位循环(如果有的话),重复这一过程,直至完成排序。
但值得注意的是,该排序算法只对非负整数序列有效。
算法实现:
// 数组最大值的位数决定循环轮次
private static int getRound(int[] arr) {
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max)
max = arr[i];
}
// 返回其长度
return String.valueOf(max).length();
}
public static void radixSort(int[] arr) {
// 定义二维数组 “桶”,用于存放数位中对应的不同值
int[][] remainders = new int[10][arr.length]; // 防止极端情况,一维数组长度为待排序数组长度
// 定义统计数组,用于收集阶段统计数位对应值所有的元素的数量
int[] stats = new int[10];
// 确定循环轮次
int round = getRound(arr);
// 收集-分配-再收集-再分配过程
for (int i = 0, n = 1; i < round; i++, n *= 10) {
// 收集阶段
for (int i1 = 0; i1 < arr.length; i1++) {
// 提取对应数位上的数字,由循环轮次决定
int digit = arr[i1] / n % 10;
// 存放进基数数组中,统计数组同步更新
remainders[digit][stats[digit]++] = arr[i1];
}
// 分配阶段(取出)
// 根据统计数组中的统计信息抽取
int index = 0; // 原数组的索引,从起始处开始
for (int i1 = 0; i1 < stats.length; i1++) {
// 若为 0 表明当前值没有元素
if (stats[i1] != 0) {
// 抽取元素,按基数数组中的元素顺序放回原数组
for (int j = 0; j < stats[i1]; j++) {
arr[index] = remainders[i1][j];
index++; // 移动原数组指针
}
// 抽取完成,更新统计数据
stats[i1] = 0;
}
}
}
}
该算法的时间复杂度为 \(O(d(n+r))\),其中 \(d\) 是位数,\(r\) 是基数,空间复杂度为 \(O(r)\),排序稳定。
2-10.4 不同排序算法的选择
这八大排序算法的时间和空间复杂度如下表所示:
排序方法 | 最好时间 | 平均时间 | 最坏时间 | 辅助存储 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 稳定 |
快速排序 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n^2)\) | \(O(\log n)\) | 不稳定 |
直接插入排序 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 稳定 |
希尔排序 | \(O(n^{1.3})\) | \(O(n\log n)\) | \(O(n^2)\) | \(O(1)\) | 不稳定 |
直接选择排序 | \(O(n^2)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 不稳定 |
堆排序 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n\log n)\) | \(O(1)\) | 不稳定 |
归并排序 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n)\) | 稳定 |
基数排序 | \(O(nd)\) | \(O(nd)\) | \(O(nd)\) | \(O(n)\) | 稳定 |
从平均情况看:堆排序、归并排序、快速排序胜过希尔排序;
从最好情况看:冒泡排序和直接插入排序更胜一筹;
从最差情况看:堆排序和归并排序强于快速排序。
下文内容引用自《数据结构教程 第 5 版》李春葆 著
不同的排序方法适应不同的应用环境和要求,选择合适的排序算法应当综合考虑下列因素:
- 待排序的元素数目(问题规模);
- 元素的大小(每个元素的规模);
- 关键字结构及其初始状态;
- 对稳定性的要求;
- 语言工具的条件;
- 数据存储结构;
- 时间和空间复杂度等;
没有哪一种排序方法是绝对地好的,任何一种方法都有其优缺点,适用于不同的环境,应在实际应用场景中视具体情况作选择。
下面给出综合考虑了以上几个方面所得出的大致结论:
- 若
n
较小(如n <= 50
),可采用直接插入或简单选择排序;一般地,直接插入排序较好,但简单选择排序移动的元素少于直接排序; - 若文件初始状态基本有序(正序),则选用直接插入或冒泡排序;
- 若
n
较大,应采用时间复杂度为 \(O(n \cdot \log n)\) 的排序方法(快速排序、堆排序、二路归并排序)。快速排序是目前基于比较的内部排序中认为最好的方法,当待排序的关键字随机分布时,快速排序平均时间最少;堆排序的所需辅助空间少于快速排序,且不会出现快速排序可能出现的最坏情况;若要追求稳定,则应当选择归并排序; - 若需要将两个有序表合并形成新的有序表,最好用二路归并排序;
- 基数排序可能在 \(O(n)\) 的时间内完成排序,但只适用于字符串、整数这类具有明显结构特征的关键字;若
n
很大,元素关键字位数较少且可以分解时采用基数排序较好。