排序
文章目录
排序的概念及引用
排序是计算机科学中最基本且常用的操作之一,其核心是将一组数据按照特定的顺序(如升序、降序)重新排列。
排序的基本概念
-
定义:
- 排序是将一组数据元素按照某种规则(如数值大小、字母顺序)重新排列成有序序列的过程。
- 输入:无序的元素集合
- 输出:有序的元素序列
-
排序的稳定性:
- 稳定排序:相等元素的相对顺序在排序后保持不变。例如,冒泡排序、插入排序、归并排序。
- 不稳定排序:相等元素的相对顺序可能改变。例如,选择排序、快速排序、堆排序。
二、常见排序算法分类
类别 | 算法名称 | 时间复杂度(平均) | 空间复杂度 | 稳定性 |
---|---|---|---|---|
插入类 | 插入排序 | (O(n^2)) | (O(1)) | 稳定 |
希尔排序 | (O(n^{1.3})) | (O(1)) | 不稳定 | |
选择类 | 选择排序 | (O(n^2)) | (O(1)) | 不稳定 |
堆排序 | (O(n \log n)) | (O(1)) | 不稳定 | |
交换类 | 冒泡排序 | (O(n^2)) | (O(1)) | 稳定 |
快速排序 | (O(n \log n)) | (O(\log n)) | 不稳定 | |
归并类 | 归并排序 | (O(n \log n)) | (O(n)) | 稳定 |
三、排序算法的应用场景
-
插入排序:
- 适合小规模数据或部分有序数据。
-
希尔排序:
- 插入排序的优化版,适合中等规模数据。
-
选择排序:
- 交换次数少,适合交换成本高的场景。
-
堆排序:
- 高效且稳定,适合大规模数据。
- 应用:Top-K问题。
-
冒泡排序:
- 简单但效率低,适合教学演示或小规模数据。
-
快速排序:
- 实际应用中最快的排序算法之一。
-
归并排序:
- 稳定且适合外排序。
四、排序算法的选择策略
-
数据规模:
- 小规模数据:插入排序、冒泡排序
- 中等规模数据:希尔排序、归并排序
- 大规模数据:快速排序、堆排序、归并排序
-
数据特性:
- 部分有序数据:插入排序
- 随机数据:快速排序、归并排序
- 要求稳定排序:归并排序、插入排序
-
空间限制:
- 内存充足:归并排序、快速排序
- 内存受限:堆排序、选择排序
五、排序算法的优化
-
快速排序优化:
- 三数取中法:选择左、中、右三个元素的中位数作为基准值,避免最坏情况
- 插入排序优化:当子数组长度较小时,改用插入排序
-
归并排序优化:
- 原地归并:通过链表实现原地归并,减少空间复杂度
- 混合排序:结合插入排序处理小规模子数组
-
堆排序优化:
- 多叉堆:使用四叉堆或八叉堆,减少树的高度,提高效率
总结
排序算法是计算机科学的基础,不同的算法适用于不同的场景。选择合适的排序算法需要考虑数据规模、数据特性和空间限制等因素。在实际应用中,通常会结合多种排序算法的优点,以达到最佳性能
常见排序介绍
插入排序
插入排序是一种简单直观的比较排序算法,其基本思想类似于我们在整理扑克牌时,将新拿到的牌插入到手中已有牌的合适位置,使手中的牌始终保持有序。以下是关于插入排序的详细介绍:
算法原理
插入排序将待排序的数组分为已排序区间和未排序区间两部分。初始时,已排序区间只有数组的第一个元素,其余元素都在未排序区间。算法从第二个元素开始,依次将未排序区间的元素插入到已排序区间的合适位置,使得已排序区间不断扩大,直到未排序区间为空,此时整个数组就完成了排序。
具体步骤
- 从数组的第二个元素(索引为1)开始,将其视为待插入元素。
- 把待插入元素与已排序区间(即数组中索引0到当前元素前一个位置的元素)的元素从后往前依次比较。
- 如果待插入元素小于已排序区间中的元素,就将已排序区间中该元素向后移动一位,为待插入元素腾出位置。
- 重复步骤3,直到找到一个小于或等于待插入元素的位置,或者已排序区间的所有元素都被比较过。
- 将待插入元素插入到找到的位置。
- 对数组中剩余的未排序元素(即索引从2到数组末尾的元素),重复步骤2 - 5,直到所有元素都被插入到已排序区间,数组变为有序
代码实现:
java
public class InsertionSort {
public static void insertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int key = arr[i]; // 待插入元素
int j = i - 1;
// 将已排序区间中大于key的元素后移
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key; // 将key插入到合适位置
}
}
public static void main(String[] args) {
int[] arr = {5, 2, 4, 6, 1, 3};
insertionSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
时间复杂度
- 最好情况 :当数组本身已经是有序状态时,每一次插入操作只需要比较一次,不需要移动元素。对于长度为
n
的数组,只需要进行n - 1
次比较,时间复杂度为 O ( n ) O(n) O(n)。 - 最坏情况 :当数组是逆序状态时,每一次插入操作都需要与已排序区间的所有元素进行比较,并且移动相应数量的元素。第
i
次插入时,需要比较i
次,移动i
次。对于长度为n
的数组,总的比较和移动次数为 1 + 2 + 3 + ⋯ + ( n − 1 ) = n ( n − 1 ) 2 1 + 2 + 3 + \cdots + (n - 1) = \frac{n(n - 1)}{2} 1+2+3+⋯+(n−1)=2n(n−1),时间复杂度为 O ( n 2 ) O(n^2) O(n2)。 - 平均情况 :假设数组中元素的初始排列是随机的,平均情况下插入排序的时间复杂度也是 O ( n 2 ) O(n^2) O(n2)。
空间复杂度
插入排序是一种原地排序算法,它只需要几个额外的变量来进行元素的比较和移动,不需要额外的存储空间来存储整个数组,因此空间复杂度为 O ( 1 ) O(1) O(1)。
稳定性
插入排序是一种稳定的排序算法,因为在比较和移动元素时,相等元素的相对顺序不会改变。例如,对于数组[2, 2, 1]
,排序后两个2
的相对顺序仍然保持不变。
适用场景
插入排序适用于小规模数据或者部分有序的数据。由于其简单的实现方式,在数据量不大时,插入排序的性能表现尚可;而对于大规模的无序数据,插入排序的时间复杂度较高,效率不如快速排序、归并排序等时间复杂度为 O ( n log n ) O(n \log n) O(nlogn) 的算法
希尔排序
希尔排序是插入排序的一种改进版本,也称为缩小增量排序 。它通过引入"增量"概念,将原数组分割成多个子序列分别进行插入排序,逐步缩小增量直至为1,最终完成整个数组的排序。其核心思想是先让数组整体接近有序,再进行精细排序,从而克服插入排序在处理大规模无序数据时效率低下的问题
算法原理
- 增量序列 :选择一个增量序列(如
n/2, n/4, ..., 1
,其中n
为数组长度),增量决定了子序列的划分方式。 - 分组排序 :对于每个增量
gap
,将数组分割成若干个长度为gap
的子序列(每个子序列由下标相差gap
的元素组成),对每个子序列单独进行插入排序。 - 缩小增量:逐步减小增量,重复分组排序过程,直到增量为1。此时,整个数组被视为一个子序列,进行最后一次插入排序,最终得到有序数组。
通过逐步缩小增量,数组会逐渐变得接近有序,当增量为1时,插入排序的效率会非常高(接近 O ( n ) O(n) O(n))。
具体步骤
以数组 [8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
为例,使用增量序列 5, 2, 1
演示:
-
初始增量
gap=5
:将数组分为5个子序列:
[8,3]
、[9,5]
、[1,4]
、[7,6]
、[2,0]
,对每个子序列插入排序后得到:
[3, 5, 1, 6, 0, 8, 5, 4, 7, 9]
(整体更接近有序)。 -
增量
gap=2
:按增量2分组:
[3,1,0,5,7]
、[5,6,8,4,9]
,插入排序后得到:
[0, 4, 1, 5, 3, 6, 5, 7, 7, 9]
(进一步接近有序)。 -
增量
gap=1
:对整个数组进行插入排序,最终得到有序数组:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
。
代码实现
java
public class ShellSort {
public static void shellSort(int[] arr) {
int n = arr.length;
// 初始增量为n/2,每次缩小为原来的1/2
for (int gap = n / 2; gap > 0; gap /= 2) {
// 对每个子序列进行插入排序
for (int i = gap; i < n; i++) {
int key = arr[i]; // 待插入元素
int j = i - gap; // 子序列中前一个元素的索引
// 移动子序列中大于key的元素
while (j >= 0 && arr[j] > key) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = key; // 插入待排序元素
}
}
}
public static void main(String[] args) {
int[] arr = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
shellSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:0 1 2 3 4 5 6 7 8 9
}
}
}
时间复杂度
希尔排序的时间复杂度依赖于增量序列的选择,目前尚未有精确的数学表达式,常见分析如下:
- 若选择增量序列为
n/2, n/4, ..., 1
,时间复杂度约为 O ( n 2 ) O(n^2) O(n2)。
总体而言,希尔排序的时间复杂度在 O ( n log n ) O(n \log n) O(nlogn) 到 O ( n 2 ) O(n^2) O(n2) 之间,优于插入排序
空间复杂度
希尔排序是原地排序算法,仅需常数个额外变量(如 gap
、key
),因此空间复杂度为 O ( 1 ) O(1) O(1)。
稳定性
希尔排序是不稳定 的排序算法。因为在分组排序时,相同元素可能被分到不同子序列中,导致相对顺序被改变。例如,数组 [3, 2, 2]
以增量 gap=2
分组时,第一个 2
会被移到 3
之前,破坏稳定性。
适用场景
希尔排序适用于中等规模的无序数据,其优点是实现简单、空间开销小,且对部分有序数据的处理效率较高。
总结
希尔排序通过分组排序和逐步缩小增量,平衡了插入排序的简单性和高效性,是插入排序的重要优化版本,在实践中具有广泛应用价值
直接选择排序
直接选择排序是一种简单的选择排序算法,其核心思想是每次从待排序区间中选出最小(或最大)的元素,将其与待排序区间的第一个元素交换位置,逐步扩大已排序区间,直至整个数组有序。
算法原理
直接选择排序将数组分为已排序区间 和未排序区间:
- 初始时,已排序区间为空,未排序区间为整个数组。
- 重复以下步骤:
- 在未排序区间中找到最小元素的索引
- 将该最小元素与未排序区间的第一个元素交换位置
- 已排序区间长度增加1,未排序区间长度减少
- 直至未排序区间为空,数组完全有序
具体步骤
以数组 [5, 3, 8, 4, 2]
为例:
-
初始状态 :已排序区间
[]
,未排序区间[5, 3, 8, 4, 2]
。找到未排序区间最小值
2
,与第一个元素5
交换 →[2, 3, 8, 4, 5]
。 -
已排序区间
[2]
,未排序区间[3, 8, 4, 5]
。找到最小值
3
,与未排序区间第一个元素3
交换(无需变动)→[2, 3, 8, 4, 5]
。 -
已排序区间
[2, 3]
,未排序区间[8, 4, 5]
。找到最小值
4
,与8
交换 →[2, 3, 4, 8, 5]
。 -
已排序区间
[2, 3, 4]
,未排序区间[8, 5]
。找到最小值
5
,与8
交换 →[2, 3, 4, 5, 8]
。 -
排序完成,数组有序。
代码实现
java
public class SelectionSort {
public static void selectionSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
// 未排序区间的起始索引为i,找到最小值的索引
int minIndex = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j; // 更新最小值索引
}
}
// 交换最小值与未排序区间的第一个元素
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
public static void main(String[] args) {
int[] arr = {5, 3, 8, 4, 2};
selectionSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:2 3 4 5 8
}
}
}
时间复杂度
- 最好情况 :数组已有序,但仍需遍历未排序区间找最小值,时间复杂度为 O ( n 2 ) O(n^2) O(n2)
- 最坏情况 :数组逆序,同样需要 O ( n 2 ) O(n^2) O(n2) 的比较次数
- 平均情况 :时间复杂度为 O ( n 2 ) O(n^2) O(n2)
无论数组初始状态如何,直接选择排序的比较次数固定为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),因此时间复杂度稳定为 O ( n 2 ) O(n^2) O(n2)。
空间复杂度
仅需常数个额外变量(如 minIndex
、temp
),属于原地排序,空间复杂度为 O ( 1 ) O(1) O(1)。
稳定性
直接选择排序是不稳定 的排序算法。例如,数组 [2, 2, 1]
中,第一个 2
会与 1
交换,导致两个 2
的相对顺序改变。
适用场景
适用于数据量较小 或交换成本较高 的场景(如元素体积大、交换耗时)。由于其交换次数少(最多 n − 1 n-1 n−1 次),在某些特定场景下比冒泡排序更高效,但整体效率低于插入排序(尤其是部分有序数据)。
总结
直接选择排序是一种直观但效率较低的排序算法,其核心是"选最小、放前面",适合小规模数据或对交换操作敏感的场景,因稳定性差和时间复杂度较高,大规模数据排序中较少使用
堆排序
堆排序是一种基于堆数据结构的高效排序算法,利用了堆的特性(大根堆或小根堆)来实现排序,时间复杂度稳定为 O(n log n),且是原地排序算法。
堆的基本概念
-
堆的定义:堆是一种完全二叉树,满足以下性质:
- 大根堆 :每个父节点的值 大于等于 其左右子节点的值(根节点为最大值)。
- 小根堆 :每个父节点的值 小于等于 其左右子节点的值(根节点为最小值)。
-
堆与数组的映射:堆通常用数组实现,对于下标为 i 的节点:
- 左子节点下标:2i + 1
- 右子节点下标:2i + 2
- 父节点下标:(i - 1) / 2(整数除法)
堆排序的核心思想
堆排序的核心是 "利用大根堆选出最大值,逐步构建有序序列",步骤分为:
- 建堆:将无序数组构建成大根堆(根节点为最大值)
- 排序 :
- 交换堆顶(最大值)与堆的最后一个元素,将最大值放到数组末尾(已排序)
- 缩小堆的范围(排除已排序的末尾元素),重新调整剩余元素为大根堆
- 重复上述过程,直至堆中只剩一个元素,数组完全有序
具体步骤(以数组 [4, 6, 8, 5, 9]
为例)
步骤1:构建大根堆
- 从最后一个非叶子节点(下标
(n/2 - 1) = 1
,值为6)开始,依次向上调整堆:- 调整下标1的节点:6的右子节点9更大,交换后堆为
[4, 9, 8, 5, 6]
。 - 调整下标0的节点:4的左子节点9更大,交换后堆为
[9, 4, 8, 5, 6]
;继续调整4的子节点,最终大根堆为[9, 6, 8, 5, 4]
。
- 调整下标1的节点:6的右子节点9更大,交换后堆为
步骤2:排序过程
- 交换堆顶9与末尾元素4 → 数组
[4, 6, 8, 5 | 9]
(| 后为已排序)。
调整剩余元素为大根堆 →[8, 6, 4, 5 | 9]
。 - 交换堆顶8与末尾元素5 → 数组
[5, 6, 4, 8 | 9]
。
调整剩余元素为大根堆 →[6, 5, 4, 8 | 9]
。 - 交换堆顶6与末尾元素4 → 数组
[4, 5, 6, 8 | 9]
。
调整剩余元素为大根堆 →[5, 4, 6, 8 | 9]
。 - 交换堆顶5与末尾元素4 → 数组
[4, 5, 6, 8, 9]
,排序完成。
代码实现
java
public class HeapSort {
// 堆排序主方法
public static void heapSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
int n = arr.length;
// 步骤1:构建大根堆(从最后一个非叶子节点向上调整)
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 步骤2:排序(交换堆顶与末尾元素,重新调整堆)
for (int i = n - 1; i > 0; i--) {
// 交换堆顶(最大值)与当前堆的最后一个元素
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 缩小堆范围,重新调整堆
heapify(arr, i, 0);
}
}
// 调整堆(确保以i为根的子树是大根堆)
private static void heapify(int[] arr, int heapSize, int i) {
int largest = i; // 初始化最大值为根节点
int left = 2 * i + 1; // 左子节点
int right = 2 * i + 2; // 右子节点
// 比较左子节点与根节点
if (left < heapSize && arr[left] > arr[largest]) {
largest = left;
}
// 比较右子节点与当前最大值
if (right < heapSize && arr[right] > arr[largest]) {
largest = right;
}
// 若最大值不是根节点,交换并递归调整
if (largest != i) {
int temp = arr[i];
arr[i] = arr[largest];
arr[largest] = temp;
heapify(arr, heapSize, largest);
}
}
public static void main(String[] args) {
int[] arr = {4, 6, 8, 5, 9};
heapSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:4 5 6 8 9
}
}
}
复杂度分析
-
时间复杂度:
- 建堆:O(n)(通过数学推导,所有节点的调整时间总和为 O(n))。
- 排序:O(n log n)(每次调整堆耗时 O(log n)),共 n-1 次)。
- 整体:O(n og n)(无论最好/最坏情况,复杂度稳定)。
-
空间复杂度:O(1)(原地排序,仅需常数个额外变量)。
稳定性
堆排序是 不稳定 的排序算法。例如,数组 [2, 2, 1]
建堆后交换元素可能导致两个 2
的相对顺序改变。
适用场景
堆排序适用于 大规模数据排序,尤其是对时间复杂度稳定性要求高的场景(如数据库排序、大数据处理)。其优点是效率高且不依赖额外空间,但实现较复杂,且不适合小规模数据(相比插入排序无优势)。
总结
堆排序通过堆的特性实现高效排序,是一种原地、时间复杂度稳定的算法,适合处理大规模数据,在实际工程中应用广泛(如Top-K问题求解等)
冒泡排序
冒泡排序是一种简单直观的排序算法,其核心思想是通过重复遍历数组,每次比较相邻的两个元素,若顺序错误则交换它们的位置
算法原理
冒泡排序的工作过程如下:
- 从数组的第一个元素开始,依次比较相邻的两个元素(下标
i
和i+1
) - 如果前一个元素大于后一个元素,则交换它们的位置(确保较大元素向后移动)
- 遍历完一次遍历后,最大的元素会"冒泡"到数组的末尾(已排序区间)
- 缩小未排序区间(排除已就位的最大元素),重复上述过程,直到所有元素有序
可以优化的点:如果某轮遍历中若未发生任何次交换交换,说明数组已完全有序,可提前结束排序
具体步骤(以数组 [5, 3, 8, 4, 2]
为例)
-
第一轮遍历 (未排序区间
[5, 3, 8, 4, 2]
):- 比较5和3 → 交换 →
[3, 5, 8, 4, 2]
- 比较5和8 → 不交换 →
[3, 5, 8, 4, 2]
- 比较8和4 → 交换 →
[3, 5, 4, 8, 2]
- 比较8和2 → 交换 →
[3, 5, 4, 2, 8]
结果:最大元素8已就位,未排序区间变为[3, 5, 4, 2]
。
- 比较5和3 → 交换 →
-
第二轮遍历:
- 比较3和5 → 不交换
- 比较5和4 → 交换 →
[3, 4, 5, 2, 8]
- 比较5和2 → 交换 →
[3, 4, 2, 5, 8]
结果:第二大元素5就位,未排序区间[3, 4, 2]
。
-
第三轮遍历:
- 比较3和4 → 不交换
- 比较4和2 → 交换 →
[3, 2, 4, 5, 8]
结果:第三大元素4就位,未排序区间[3, 2]
。
-
第四轮遍历:
- 比较3和2 → 交换 →
[2, 3, 4, 5, 8]
结果:数组完全有序。
- 比较3和2 → 交换 →
代码实现(含优化)
java
public class BubbleSort {
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
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 = {5, 3, 8, 4, 2};
bubbleSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:2 3 4 5 8
}
}
}
复杂度分析
-
时间复杂度:
- 最好情况:数组已完全有序,仅需1轮遍历(无交换),时间复杂度为 (O(n))。
- 最坏情况:数组逆序,需 (n-1) 轮遍历,每轮比较 (n-i-1) 次,总次数为 (n(n-1)/2),时间复杂度为 (O(n^2))。
- 平均情况:时间复杂度为 (O(n^2))。
-
空间复杂度 :仅需常数个额外变量(如
temp
、swapped
),属于原地排序,空间复杂度为 (O(1))。
稳定性
冒泡排序是 稳定 的排序算法。因为当两个元素相等时,不会发生交换,它们的相对顺序得以保持。例如,数组 [2, 2, 1]
排序后仍为 [1, 2, 2]
,两个2的相对位置不变。
适用场景
冒泡排序适用于 小规模数据 或 教学演示。由于其时间复杂度较高O(n^2),在实际开发中很少用于大规模数据排序,但因实现简单、易于理解,常作为入门级排序算法学习。
总结
冒泡排序通过相邻元素的比较和交换实现排序,核心是"大数后移",优点是简单稳定,缺点是效率低。仅推荐在数据量小或对性能要求不高的场景使用
快速排序
快速排序一种高效的排序算法,基于分治法思想,通过选择一个"基准值"将数组分为两部分,再递归排序这两部分,平均时间复杂度为 O(n log n),是实际应用中性能最佳的排序算法之一
算法原理
快速排序的核心步骤是"分区",具体过程如下:
- 选择基准值(Pivot):从数组中选择一个元素作为基准(通常选最后一个元素,或随机元素)。
- 分区操作:将数组重新排列,使所有小于基准值的元素移到基准值左侧,所有大于基准值的元素移到右侧(等于基准值的元素可放任意一侧)。此时基准值的位置已固定。
- 递归排序:对基准值左侧和右侧的子数组分别重复步骤1-2,直至子数组长度为1(天然有序)
具体步骤(以数组 [3, 6, 8, 10, 1, 2, 1]
为例,基准值选最后一个元素1)
-
第一次分区:
- 目标:小于1的元素放左,大于1的放右(基准值1)。
- 过程:遍历数组,交换元素后分区为
[1, 6, 8, 10, 3, 2 | 1]
(| 为基准值位置)。 - 结果:基准值1固定,左侧子数组
[1,6,8,10,3,2]
,右侧子数组为空。
-
递归排序左侧子数组(基准值选最后一个元素2):
- 分区后:
[1, 2 | 8, 10, 3, 6]
,基准值2固定。 - 左侧
[1]
有序,右侧子数组[8,10,3,6]
继续排序。
- 分区后:
-
递归排序
[8,10,3,6]
(基准值选6):- 分区后:
[3, 6 | 10, 8]
,基准值6固定。 - 左侧
[3]
有序,右侧子数组[10,8]
继续排序。
- 分区后:
-
递归排序
[10,8]
(基准值选8):- 分区后:
[8 | 10]
,基准值8固定。 - 两侧子数组
[8]
和[10]
均有序。
- 分区后:
-
最终结果 :
[1, 1, 2, 3, 6, 8, 10]
代码实现(含优化)
java
import java.util.Random;
public class QuickSort {
private static final Random random = new Random();
public static void quickSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
quickSortHelper(arr, 0, arr.length - 1);
}
// 递归辅助函数
private static void quickSortHelper(int[] arr, int low, int high) {
if (low < high) {
// 分区操作,返回基准值位置
int pivotIndex = partition(arr, low, high);
// 递归排序左侧子数组
quickSortHelper(arr, low, pivotIndex - 1);
// 递归排序右侧子数组
quickSortHelper(arr, pivotIndex + 1, high);
}
}
// 分区操作(优化:随机选择基准值,避免极端情况)
private static int partition(int[] arr, int low, int high) {
// 随机选择基准值并与末尾元素交换
int randomIndex = low + random.nextInt(high - low + 1);
swap(arr, randomIndex, 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 = {3, 6, 8, 10, 1, 2, 1};
quickSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:1 1 2 3 6 8 10
}
}
}
复杂度分析
-
时间复杂度:
- 最好/平均情况:每次分区能将数组均匀分为两部分,递归深度为 (O(\log n)),总时间复杂度为 (O(n \log n))。
- 最坏情况:数组已排序且选择两端元素为基准,每次分区后子数组长度仅减1,递归深度为 (O(n)),时间复杂度为 (O(n^2))(可通过随机选择基准值避免)。
-
空间复杂度:主要来自递归栈,平均为 (O(\log n)),最坏为 (O(n))(可通过尾递归优化至 (O(\log n)))。
稳定性
快速排序是 不稳定 的排序算法。分区过程中,相等元素的相对顺序可能被交换。例如,数组 [2, 2, 1]
排序时,第一个2可能被交换到1后面,破坏稳定性。
适用场景
快速排序适用于 大规模数据排序 ,是实际开发中应用最广泛的排序算法之一
其优势在于平均效率高、原地排序(空间开销小),但实现较复杂,且对小规模数据不如插入排序高效(可混合使用优化)。
优化技巧
- 随机选择基准值:避免数组有序时的最坏情况。
- 三数取中法:选择左、中、右三个元素的中位数作为基准,进一步优化分区平衡性。
- 小规模子数组用插入排序:当子数组长度小于阈值(如10)时,切换为插入排序,减少递归开销。
- 尾递归优化:减少递归栈深度,降低栈溢出风险。
总结
快速排序通过分治思想和高效分区实现排序,平均性能优异,是处理大规模数据的首选算法之一。其核心在于基准值的选择和分区操作,合理优化后可避免最坏情况,在工程实践中应用广泛
归并排序
归并排序一种基于分治法的高效排序算法,其核心思想是将数组分成两个子数组,分别排序后再合并为一个有序数组。归并排序具有稳定性好、时间复杂度稳定的特点,是处理大规模数据排序的常用算法
算法原理
归并排序的工作流程分为三个步骤:
- 分解:将待排序数组不断二分,直到子数组长度为1(单个元素天然有序)。
- 解决:递归地对每个子数组进行排序。
- 合并:将两个已排序的子数组合并为一个更大的有序数组。
整个过程类似"拆分-排序-合并"的流水线,通过递归实现对整个数组的排序。
具体步骤(以数组 [38, 27, 43, 3, 9, 82, 10]
为例)
-
分解阶段 :
将数组反复二分,直至子数组长度为1:
[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]
和[43]
→[27,43]
;合并[38]
和[27,43]
→[27,38,43]
。 - 合并
[3]
和[9]
→[3,9]
;合并[82]
和[10]
→[10,82]
;再合并[3,9]
和[10,82]
→[3,9,10,82]
。 - 最后合并
[27,38,43]
和[3,9,10,82]
→[3,9,10,27,38,43,82]
(最终有序数组)。
- 合并
代码实现
java
public class MergeSort {
// 归并排序主方法
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
int[] temp = new int[arr.length]; // 辅助数组,用于临时存储合并结果
mergeSortHelper(arr, 0, arr.length - 1, temp);
}
// 递归辅助方法:排序 arr[left..right] 区间
private static void mergeSortHelper(int[] arr, int left, int right, int[] temp) {
if (left < right) { // 递归终止条件:子数组长度为1(left == right)
int mid = left + (right - left) / 2; // 计算中间位置(避免溢出)
// 递归排序左半部分
mergeSortHelper(arr, left, mid, temp);
// 递归排序右半部分
mergeSortHelper(arr, mid + 1, right, temp);
// 合并两个有序子数组
merge(arr, left, mid, right, temp);
}
}
// 合并两个有序子数组:arr[left..mid] 和 arr[mid+1..right]
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left; // 左子数组的起始索引
int j = mid + 1; // 右子数组的起始索引
int k = left; // 临时数组的起始索引
// 比较两个子数组的元素,按顺序放入临时数组
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++];
}
// 将临时数组中的有序元素复制回原数组
for (k = left; k <= right; k++) {
arr[k] = temp[k];
}
}
// 测试
public static void main(String[] args) {
int[] arr = {38, 27, 43, 3, 9, 82, 10};
mergeSort(arr);
for (int num : arr) {
System.out.print(num + " "); // 输出:3 9 10 27 38 43 82
}
}
}
复杂度分析
-
时间复杂度:
- 分解数组的递归深度为 O(log n)(每次二分)。
- 每一层的合并操作总耗时为 (O(n)(每个元素被合并一次)。
- 整体时间复杂度:最好/最坏/平均情况均为 O(n log n),稳定性极高
-
空间复杂度 :
需要一个与原数组等长的辅助数组(
temp
),因此空间复杂度为 O(n)
稳定性
归并排序是 稳定的排序算法 。在合并过程中,当两个子数组的元素相等时,会优先选择左子数组的元素,从而保持相等元素的相对顺序不变。例如,数组 [2, 2, 1]
排序后仍为 [1, 2, 2]
,两个 2
的原始顺序得以保留
适用场景
- 大规模数据排序:时间复杂度稳定为 (O(n \log n)),适合处理大数据量。
- 链表排序:无需辅助数组(通过指针操作合并),空间复杂度可优化至 (O(\log n))(递归栈)。
- 需要稳定排序的场景:如电商订单按金额排序时,需保持同金额订单的下单时间顺序。
优化方向
- 小规模子数组用插入排序:当子数组长度小于阈值(如15)时,切换为插入排序,减少递归和合并开销。
- 原地归并:通过数组移位实现合并,避免使用辅助数组(但会增加时间复杂度)。
- 并行化处理:分解后的子数组可并行排序,提高多核CPU环境下的效率。
总结
归并排序通过分治思想实现高效排序,具有时间复杂度稳定、稳定性好的优点,适合对排序稳定性有要求或处理大规模数据的场景。其主要缺点是需要额外的存储空间,这也是实际应用中与快速排序权衡的重要因素
排序总结
类别 | 算法名称 | 时间复杂度(平均) | 空间复杂度 | 稳定性 |
---|---|---|---|---|
插入类 | 插入排序 | (O(n^2)) | (O(1)) | 稳定 |
希尔排序 | (O(n^{1.3})) | (O(1)) | 不稳定 | |
选择类 | 选择排序 | (O(n^2)) | (O(1)) | 不稳定 |
堆排序 | (O(n \log n)) | (O(1)) | 不稳定 | |
交换类 | 冒泡排序 | (O(n^2)) | (O(1)) | 稳定 |
快速排序 | (O(n \log n)) | (O(\log n)) | 不稳定 | |
归并类 | 归并排序 | (O(n \log n)) | (O(n)) | 稳定 |