一、引言
在计算机编程的世界里,排序算法是基础且至关重要的一部分。不同的排序算法在时间复杂度、空间复杂度和稳定性等方面各有优劣。本文将详细介绍七种常见的排序算法,包括冒泡排序、选择排序、插入排序、归并排序、快速排序、堆排序、希尔排序,还会提及非基于比较的排序(以计数排序为例),同时,我们会分析它们的时间复杂度,探寻谁才是时间复杂度上的“王者”,也会分享从初学者到高手使用这些算法的必备技巧。
二、七大排序算法 Java 实现
排序:所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的⼤⼩,递增或递减的排列起来的 操作。
1. 冒泡排序
冒泡排序是一种简单直观的排序算法,它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。
public class BubbleSort {
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换 arr[j+1] 和 arr[j]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
public static void main(String[] args) {
int[] arr = {64, 34, 25, 12, 22, 11, 90};
bubbleSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:最坏和平均情况下为
O
(
n
2
)
O(n^2)
O(n2),最好情况下(数组已经有序)为
O
(
n
)
O(n)
O(n)
空间复杂度:O(1)
稳定性:稳定。
2. 选择排序
2.1 基本思想:
每⼀次从待排序的数据元素中选出最⼩(或最⼤)的⼀个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2 直接选择排序
• 在元素集合array[i]–array[n-1]中选择关键码最⼤(⼩)的数据元素
• 若它不是这组元素中的最后⼀个(第⼀个)元素,则将它与这组元素中的最后⼀个(第⼀个)元素交
换
• 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个
元素
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;
}
}
// 交换 arr[i] 和 arr[minIndex]
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
public static void main(String[] args) {
int[] arr = {64, 25, 12, 22, 11};
selectionSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:无论数组初始状态如何,时间复杂度都为 O ( n 2 ) O(n^2) O(n2)。
3. 插入排序
3.1 基本思想:
直接插⼊排序是⼀种简单的插⼊排序法,其基本思想是:把待排序的记录按其关键码值的⼤⼩逐个插⼊到⼀个已经排好序的有序序列中,直到所有的记录插⼊完为⽌,得到⼀个新的有序序列 。
实际中我们玩扑克牌时,就⽤了插⼊排序的思想。
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;
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);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:最坏和平均情况下为 O ( n 2 ) O(n^2) O(n2),最好情况下(数组已经有序)为 O ( n ) O(n) O(n)。
4. 归并排序
归并排序(MERGE-SORT)是建⽴在归并操作上的⼀种有效的排序算法,该算法是采⽤分治法(Divideand Conquer)的⼀个⾮常典型的应⽤。将已有序的⼦序列合并,得到完全有序的序列;即先使每个⼦序列有序,再使⼦序列段间有序。若将两个有序表合并成⼀个有序表,称为⼆路归并。 归并排序采用分治法,将一个数组分成两个子数组,对这两个子数组分别进行排序,然后将排好序的子数组合并成一个最终的有序数组。归并排序核⼼步骤:
4.1海量数据的排序问题
外部排序:排序过程需要在磁盘等外部存储进⾏的排序 前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为⽆法把所有数据全部放下,所以需要外部排序,⽽归并排序是最常⽤的外部排序
- 先把⽂件切分成 200 份,每个 512 M
- 分别对 512 M 排序,因为内存已经可以放的下,所以任意排序⽅式都可以
- 进⾏ 2路归并,同时对 200 份有序⽂件做归并过程,最终结果就有序了
public class MergeSort {
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
}
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left < right) {
int mid = (left + right) / 2;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
int t = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
t = 0;
while (left <= right) {
arr[left++] = temp[t++];
}
}
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 6,7,9,10};
mergeSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:在最坏、平均和最好情况下都是 O ( n l o g n ) O(n log n) O(nlogn)。
5. 快速排序
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两⼦序列,左⼦序列中所有元素均⼩于基准值,右⼦序列中所有元素均⼤于基准值,然后最左右⼦序列重复该过程,直到所有元素都排列在相应位置上为⽌。
public class QuickSort {
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 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++;
// 交换 arr[i] 和 arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 交换 arr[i+1] 和 arr[high] (即 pivot)
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1;
}
public static void main(String[] args) {
int[] arr = {10, 7, 8, 9, 1, 5};
int n = arr.length;
quickSort(arr, 0, n - 1);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:平均情况下为 O ( n l o g n ) O(n log n) O(nlogn),最坏情况下(如数组已经有序且选择第一个元素作为基准)为 O ( n 2 ) O(n^2) O(n2)。
6. 堆排序
堆排序(Heapsort)是指利⽤堆积树(堆)这种数据结构所设计的⼀种排序算法,它是选择排序的⼀种。它是通过堆来进⾏选择数据。需要注意的是排升序要建⼤堆,排降序建⼩堆。
public class HeapSort {
public static void heapSort(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);
}
}
private static 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);
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:在最坏、平均和最好情况下都是 O ( n l o g n ) O(n log n) O(nlogn)。
7. 希尔排序
希尔排序法⼜称缩⼩增量法。希尔排序法的基本思想是:先选定⼀个整数,把待排序⽂件中所有记录 分成多个组,所有距离为的记录分在同⼀组内,并对每⼀组内的记录进⾏排序。然后,取,重复上述 分组和排序的⼯作。当到达=1时,所有记录在统⼀组内排好序。
希尔排序的特性总结:
- 希尔排序是对直接插⼊排序的优化。
- 当gap > 1时都是预排序,⽬的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这 样就会很快。这样整体⽽⾔,可以达到优化的效果。我们实现后可以进⾏性能测试的对⽐。
希尔排序是对插入排序的一种改进,它通过将原始数据分成多个子序列来改善插入排序的性能,每个子序列的元素间隔逐渐缩小。
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);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度分析:希尔排序的时间复杂度与所选择的增量序列有关,希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。
8、其他非基于比较的排序:计数排序、基数排序、桶排序
这些排序算法的共同特点是,它们不依赖于比较元素的大小,而是通过其他方式来决定排序结果。它们在某些特定条件下比基于比较的排序算法(如快速排序、归并排序)更高效,特别是当元素范围较小或数据类型特定时。
排序算法 | 基本思想 | 时间复杂度 | 空间复杂度 | 应用场景 | 优缺点 |
---|---|---|---|---|---|
计数排序 | 通过统计每个元素出现的次数,并根据出现的次数将元素放置到其最终位置。 | O(n + k) | O(n + k) | 适用于已知数据范围(例如小范围的整数) | 优点:时间复杂度为O(n + k),适用于范围较小的整数;不依赖于元素比较,速度较快。 缺点:当数据范围大时,空间复杂度较高,不适合处理非常大的范围。 |
基数排序 | 通过按位对元素进行排序,从最低位开始,逐步处理高位。 | O(nk) | O(n + k) | 适用于整型数据,特别是当数字位数较少时 | 优点:适用于特定范围的整数,时间复杂度较好,尤其适合处理具有固定长度的字符串或整数。 缺点:仅适用于特定数据类型(整型或具有相同位数的字符串)。 |
桶排序 | 将数据划分到多个桶中,对每个桶内的数据进行排序,再将桶中的数据合并。 | O(n + k) | O(n + k) | 适用于数据分布均匀的情况,特别是浮点数或小范围整数 | 优点:适用于数据分布均匀的情况,可以非常高效。 缺点:数据分布不均匀时性能较差,需要额外的存储空间。 |
8.1总结:非基于比较的排序算法的优势
这些排序算法的优势在于它们不依赖于比较,而是通过计数、按位处理或者分桶的方式来进行排序,因此在特定的条件下,它们比基于比较的排序算法(如快速排序、归并排序)更高效。适用于已知范围、特定数据类型(如整数、固定长度的字符串)和均匀分布的数据。但它们也有一些局限性,尤其是在数据范围较大或数据分布不均匀的情况下,可能会失去优势。
三、排序算法复杂度及稳定性分析
四、时间复杂度 “王者” 之争
从上述时间复杂度分析来看,在大多数情况下,归并排序、快速排序和堆排序的时间复杂度表现较好,尤其是在处理大规模数据时,它们的平均时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)。而归并排序在最坏情况下也能保证 O ( n l o g n ) O(n log n) O(nlogn) 的时间复杂度,相对更加稳定。不过,快速排序在实际应用中,由于其常数因子较小,通常速度更快,但要注意避免最坏情况的发生。
非基于比较的排序算法(如计数排序)在特定场景下(数据范围较小)能达到线性时间复杂度
O
(
n
+
k
)
O(n + k)
O(n+k),是时间复杂度方面的佼佼者,但它的适用范围相对较窄,需要满足一定的条件。
对比七大排序算法的特点、时间复杂度、应用场景和优缺点:
排序算法 | 基本思想 | 时间复杂度 | 空间复杂度 | 应用场景 | 优缺点 |
---|---|---|---|---|---|
冒泡排序 | 相邻元素比较交换,使较大的元素“冒泡”到数组末尾。 | 最坏:O(n²) 平均:O(n²) 最优:O(n) | O(1) | 数据量小或数据接近有序时 | 实现简单,易理解;但时间复杂度较高,性能差,尤其是数据量较大时。 |
选择排序 | 每次选最小(大)元素与未排序部分的第一个元素交换。 | 最坏:O(n²) 平均:O(n²) 最优:O(n²) | O(1) | 小规模数据排序,内存要求较高的情况 | 简单易懂,但排序效率较低,特别是大规模数据时性能较差。 |
插入排序 | 将元素逐一插入已排序部分的正确位置。 | 最坏:O(n²) 平均:O(n²) 最优:O(n) | O(1) | 数据已接近有序或数据量较小的情况 | 对小数据量高效,简洁,易实现;但大数据量时效率差。 |
归并排序 | 采用分治法,将数组分成两部分分别排序,再合并。 | 最坏:O(n log n) 平均:O(n log n) 最优:O(n log n) | O(n) | 大规模数据排序,外部排序(磁盘数据) | 时间复杂度稳定,但空间复杂度较高,可能需要额外的内存。 |
快速排序 | 选择基准元素,分成两部分递归排序。 | 最坏:O(n²) 平均:O(n log n) 最优:O(n log n) | O(log n) | 大数据量排序,尤其是内存排序 | 时间复杂度优秀,实际应用中速度很快;最坏情况下性能退化,可能达到O(n²)。 |
堆排序 | 构建堆,每次取出根节点进行排序,再调整堆。 | 最坏:O(n log n) 平均:O(n log n) 最优:O(n log n) | O(1) | 用于需要稳定排序的情况,如优先队列 | 时间复杂度稳定,适合大规模数据;但性能不如快速排序,且有常数时间开销。 |
希尔排序 | 对数组进行分组,逐步减小组间隔并对每组进行插入排序。 | 最坏:O(n²) 平均:O(n log n) 最优:O(n log n) | O(1) | 中等规模数据排序,相比插入排序更高效 | 改进了插入排序,对于大部分数据比插入排序效率更高,但时间复杂度取决于间隔序列的选择。 |
计数排序 | 统计每个元素的出现次数,通过计数数组重新构建排序结果。 | O(n + k) | O(n + k) | 适用于整数或范围较小的数据集 | 速度快,适用于已知范围的数据;但不适合对范围较大的数据进行排序,且只能用于整数。 |
基数排序 | 按位对元素排序,每次按最低有效位开始排序。 | O(nk) | O(n + k) | 适用于整数排序,特别是位数较少的情况 | 在特定场景下效率非常高,但仅适用于整数或固定长度的字符串。 |
桶排序 | 将数据分到不同的桶中,对每个桶内部数据排序,最后合并桶中的数据。 | O(n + k) | O(n + k) | 适用于数据均匀分布的情况,整数或浮点数排序 | 对于均匀分布的数据非常高效;但不适合数据分布不均匀的情况,且需要额外的存储空间。 |
五、从初学者到高手的必备技巧
初学者
- 理解基本概念:先从简单的排序算法(如冒泡排序、选择排序、插入排序)入手,理解排序的基本思想和实现过程。
- 多写代码:通过手动实现代码,加深对算法的理解,同时注意边界条件的处理。
- 分析复杂度:学习分析算法的时间复杂度和空间复杂度,了解不同算法在性能上的差异。
进阶者
- 掌握分治法和递归思想:深入理解归并排序和快速排序的分治策略和递归实现,这对于解决其他复杂问题也很有帮助。
- 优化算法:尝试对已有的算法进行优化,例如快速排序中选择更好的基准元素,减少最坏情况的发生。
- 学习数据结构:了解堆这种数据结构,掌握堆排序的原理和实现。
高手
- 应用场景分析:根据不同的应用场景选择合适的排序算法,考虑数据规模、数据特点(如是否接近有序)等因素。
- 研究算法变体:探索排序算法的各种变体和优化方案,进一步提升算法的性能。
- 性能测试和调优:通过性能测试工具,对不同算法进行实际测试和调优,确保在实际应用中达到最佳性能。
六、总结:选择最适合你的排序算法
- 小数据量:冒泡排序、选择排序、插入排序。
- 大数据量:快速排序、归并排序、堆排序。
- 特定数据类型:计数排序、基数排序、桶排序(适用于整数或范围小的数据)。
- 外部排序:归并排序。
七、最后在加上一些题目:
- 快速排序算法是基于(A的⼀个排序算法。
A:分治法 B:贪⼼法 C:递归法 D:动态规划法
2.对记录(54,38,96,23,15,72,60,45,83)进⾏从⼩到⼤的直接插⼊排序时,当把第8个记录45插 ⼊到有序表时,为找到插⼊位置需⽐较(C)次?(采⽤从后往前⽐较)
A: 3 B: 4 C: 5 D: 6
3.以下排序⽅式中占⽤O(n)辅助存储空间的是(D)
A: 简单排序 B: 快速排序 C: 堆排序 D: 归并排序
4.下列排序算法中稳定且时间复杂度为O(n^2)的是(B)
A: 快速排序 B: 冒泡排序 C: 直接选择排序 D: 归并排序
5.关于排序,下⾯说法不正确的是(D)
A: 快排时间复杂度为O(N*logN),空间复杂度为O(logN)
B: 归并排序是⼀种稳定的排序,堆排序和快排均不稳定
C: 序列基本有序时,快排退化成 “冒泡排序”,直接插⼊排序最快
D: 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)
6.设⼀组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第⼀个关键字65为基准⽽得到的 ⼀趟快速排序结果是(A)
A: 34,56,25,65,86,99,72,66 B: 25,34,56,65,99,86,72,66
C:34,56,25,65,66,99,86,72 D: 34,56,25,65,99,86,72,66