文章目录
- 一、排序的概念
-
- [1. 稳定性](#1. 稳定性)
- [2. 内部排序与外部排序](#2. 内部排序与外部排序)
- 二、基于比较的排序算法详解
-
- (一)插入排序
-
- [1. 直接插入排序](#1. 直接插入排序)
- [2. 希尔排序(缩小增量排序)](#2. 希尔排序(缩小增量排序))
- (二)选择排序:每次选最值,逐步定位
-
- [1. 直接选择排序](#1. 直接选择排序)
- [2. 堆排序](#2. 堆排序)
- (三)交换排序:通过交换调整次序
-
- [1. 冒泡排序](#1. 冒泡排序)
- [2. 快速排序](#2. 快速排序)
- (四)归并排序:分而治之,合并有序
- 三、海量数据排序:归并排序的实际应用
- 四、七大排序算法性能对比与选择
-
- [1. 性能与稳定性汇总表](#1. 性能与稳定性汇总表)
- [2. 算法选择策略](#2. 算法选择策略)
- 五、Java中的常用排序方法
-
- [1. 数组排序:Arrays.sort()](#1. 数组排序:Arrays.sort())
- [2. 集合排序:Collections.sort()](#2. 集合排序:Collections.sort())
- [3. 注意事项](#3. 注意事项)
一、排序的概念
1. 稳定性
稳定性是排序算法的重要特性:
- 稳定排序:若待排序序列中存在多个关键字相同的记录,排序后它们的相对次序保持不变。例如原序列中
5a在5b之前,排序后5a还在5b之前。 - 不稳定排序:排序后关键字相同的记录相对次序被打乱。
稳定性的实际意义:当需要按多个关键字排序时(比如先按成绩排序,再按姓名排序),稳定排序能保证后续排序不破坏之前的排序结果。
2. 内部排序与外部排序
- 内部排序:所有数据元素都能放入内存,排序过程在内存中完成(本文重点讲解的七大算法均为内部排序)。
- 外部排序:数据量过大,无法全部放入内存,需借助磁盘等外部存储设备,排序过程中需在内外存间移动数据。归并排序常用于外部排序。
二、基于比较的排序算法详解
基于比较的排序算法通过比较元素间的关键字大小来确定次序,包括插入排序、选择排序、交换排序、归并排序四大类,共七种算法。
(一)插入排序
插入排序的核心思想是"将待排序元素逐个插入到已有序的序列中",就像玩扑克牌时整理手牌的过程。
1. 直接插入排序
-
基本思想 :
当插入第
i(i≥1)个元素时,前面的array[0]~array[i-1]已有序。将array[i]与前面的元素从后往前逐一比较,找到合适的插入位置,将该位置及后续元素后移,再插入array[i]。 -
示例过程 :
待排序序列:
[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]- 插入第1个元素(44):前面只有3,44>3,直接插入,序列变为
[3,44,38,...] - 插入第2个元素(38):与44比较,38<44,44后移;再与3比较,38>3,插入3和44之间,序列变为
[3,38,44,...] - 依次类推,直到所有元素插入完毕。
- 插入第1个元素(44):前面只有3,44>3,直接插入,序列变为
-
代码实现:
java
public static void insertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
int temp = array[i]; // 待插入元素
int j = i - 1;
// 从后往前找插入位置
while (j >= 0 && array[j] > temp) {
array[j + 1] = array[j]; // 元素后移
j--;
}
array[j + 1] = temp; // 插入待排序元素
}
}
- 特性总结 :
- 时间复杂度:O(N²)(最坏和平均情况);最好情况O(N)(序列已有序,只需遍历无需移动)。
- 空间复杂度:O(1)(原地排序,仅需临时变量)。
- 稳定性:稳定。
- 适用场景:元素集合接近有序或数据量较小时(N≤1000),效率较高。
2. 希尔排序(缩小增量排序)
希尔排序是直接插入排序的优化版本,解决了直接插入排序在序列无序时移动元素过多的问题。
-
基本思想:
- 选定一个增量
gap(初始值通常为数组长度的一半),将数组按gap分成多个组,每组包含距离为gap的元素。 - 对每组分别进行直接插入排序,使数组初步有序。
- 缩小
gap(如gap = gap/2或gap = gap/3 + 1),重复分组和排序步骤。 - 当
gap=1时,数组已接近有序,此时进行最后一次直接插入排序,完成最终排序。
- 选定一个增量
-
示例过程 :
初始序列:
[9,1,2,5,7,4,8,6,3,5]- gap=5:分成5组
[9,4]、[1,8]、[2,6]、[5,3]、[7,5],每组排序后得到[4,1,2,3,5,9,8,6,5,7] - gap=2:分成2组
[4,2,5,8,6,7]、[1,3,9,5],每组排序后得到[2,1,4,3,5,5,6,7,8,9] - gap=1:进行直接插入排序,最终得到
[1,2,3,4,5,5,6,7,8,9]
- gap=5:分成5组
-
代码实现(Java,Knuth增量):
java
public static void shellSort(int[] array) {
int gap = array.length;
// 增量序列:gap = gap/3 + 1
while (gap > 1) {
gap = gap / 3 + 1;
for (int i = gap; i < array.length; i++) {
int temp = array[i];
int j = i - gap;
while (j >= 0 && array[j] > temp) {
array[j + gap] = array[j];
j -= gap;
}
array[j + gap] = temp;
}
}
}
- 特性总结 :
- 时间复杂度:难以精确计算,与增量序列相关。常用的Knuth增量下,时间复杂度约为O(N1.25)~O(1.6*N1.25)。
- 空间复杂度:O(1)(原地排序)。
- 稳定性:不稳定(分组排序时可能打乱相同关键字的相对次序)。
- 适用场景:数据量较大且无序的场景,效率远高于直接插入排序。
(二)选择排序:每次选最值,逐步定位
选择排序的核心思想是"每次从待排序序列中选出最值元素,放到序列的指定位置"。
1. 直接选择排序
-
基本思想:
- 从
array[i]~array[n-1]中找到关键字最小(或最大)的元素。 - 若该元素不是当前区间的第一个元素,将其与区间第一个元素交换。
- 缩小待排序区间(
i++),重复上述步骤,直到区间只剩1个元素。
- 从
-
示例过程 :
待排序序列:
[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]- 第一次选择:找到最小值2,与第一个元素3交换,序列变为
[2, 44, 38, 5, 47, 15, 36, 26, 27, 3, 46, 4, 19, 50, 48] - 第二次选择:从剩余元素中找到最小值3,与第二个元素44交换,序列变为
[2, 3, 38, 5, 47, 15, 36, 26, 27, 44, 46, 4, 19, 50, 48] - 依次类推,直到排序完成。
- 第一次选择:找到最小值2,与第一个元素3交换,序列变为
-
代码实现(Java):
java
public static void selectSort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {
int minIndex = i; // 记录最小值索引
// 找待排序区间的最小值
for (int j = i + 1; j < array.length; j++) {
if (array[j] < array[minIndex]) {
minIndex = j;
}
}
// 交换最小值与当前区间第一个元素
if (minIndex != i) {
int temp = array[i];
array[i] = array[minIndex];
array[minIndex] = temp;
}
}
}
- 特性总结 :
- 时间复杂度:O(N²)(最坏、平均、最好情况均为O(N²),需遍历所有元素找最值)。
- 空间复杂度:O(1)(原地排序)。
- 稳定性:不稳定(如序列
[2, 2, 1],第一次选择1与第一个2交换,导致两个2的相对次序改变)。 - 适用场景:简单易实现,但效率较低,仅适用于数据量极小的场景。
2. 堆排序
堆排序是选择排序的优化版本,利用堆(完全二叉树)的数据结构快速查找和获取最值元素,大幅提升效率。
-
核心前提:
- 堆的定义:大顶堆(父节点值≥子节点值)、小顶堆(父节点值≤子节点值)。
- 排序规则:排升序用大顶堆(每次提取堆顶最大值放到序列末尾);排降序用小顶堆(每次提取堆顶最小值放到序列末尾)。
-
基本思想:
- 将待排序数组构建成大顶堆。
- 交换堆顶元素(最大值)与堆尾元素,将最大值固定在序列末尾。
- 缩小堆的范围(排除已固定的最大值),对剩余元素重新调整为大顶堆。
- 重复步骤2和3,直到堆的范围缩小为1,排序完成。
-
代码实现(Java,升序):
java
public static void heapSort(int[] array) {
// 1. 构建大顶堆(从最后一个非叶子节点开始调整)
for (int i = array.length / 2 - 1; i >= 0; i--) {
adjustHeap(array, i, array.length);
}
// 2. 逐步提取最大值并调整堆
for (int i = array.length - 1; i > 0; i--) {
// 交换堆顶与堆尾
int temp = array[0];
array[0] = array[i];
array[i] = temp;
// 调整剩余元素为大顶堆
adjustHeap(array, 0, i);
}
}
// 调整堆结构(大顶堆)
private static void adjustHeap(int[] array, int parent, int heapSize) {
int temp = array[parent]; // 保存父节点
int child = 2 * parent + 1; // 左子节点索引
while (child < heapSize) {
// 找到左右子节点中的最大值
if (child + 1 < heapSize && array[child + 1] > array[child]) {
child++;
}
// 若父节点≥子节点,调整结束
if (temp >= array[child]) {
break;
}
// 子节点值赋给父节点
array[parent] = array[child];
// 继续向下调整
parent = child;
child = 2 * parent + 1;
}
array[parent] = temp;
}
- 特性总结 :
- 时间复杂度:O(N*logN)(构建堆O(N),每次调整堆O(logN),共N-1次调整)。
- 空间复杂度:O(1)(原地排序,仅需临时变量)。
- 稳定性:不稳定(调整堆时可能打乱相同关键字的相对次序)。
- 适用场景:数据量较大且对空间要求严格的场景,效率高于直接选择排序和冒泡排序。
(三)交换排序:通过交换调整次序
交换排序的核心思想是"比较两个元素的关键字,若次序错误则交换它们的位置",逐步将关键字大的元素移到序列尾部。
1. 冒泡排序
冒泡排序是最直观的排序算法,通过相邻元素的反复比较和交换,让"大元素"像气泡一样浮到序列末尾。
-
基本思想:
- 从序列头部开始,依次比较相邻的两个元素,若前一个元素>后一个元素,则交换它们。
- 一轮遍历后,最大的元素会被移到序列末尾。
- 缩小排序范围(排除已固定的最大元素),重复上述步骤,直到没有元素需要交换。
-
优化点 :添加标志位
flag,若某一轮遍历中没有发生交换,说明序列已有序,直接退出循环。 -
代码实现(Java,优化版):
java
public static void bubbleSort(int[] array) {
boolean flag = false; // 标记是否发生交换
for (int i = 0; i < array.length - 1; i++) {
flag = false;
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j] > array[j + 1]) {
// 交换相邻元素
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
flag = true;
}
}
if (!flag) {
break; // 无交换,序列已有序
}
}
}
- 特性总结 :
- 时间复杂度:O(N²)(最坏、平均情况);最好情况O(N)(序列已有序,一轮遍历无交换)。
- 空间复杂度:O(1)(原地排序)。
- 稳定性:稳定(仅相邻元素交换,不改变相同关键字的相对次序)。
- 适用场景:简单易理解,适用于数据量小或接近有序的场景。
2. 快速排序
快速排序是由Hoare于1962年提出的排序算法,基于分治法思想,是实际应用中综合性能最优的排序算法之一。
-
基本思想:
- 选基准值:从待排序序列中任取一个元素作为基准值(pivot)。
- 分区:将序列分割为两部分,左部分元素均≤基准值,右部分元素均≥基准值,基准值位于最终排序位置。
- 递归排序:对左右两部分分别重复上述步骤,直到所有子序列长度≤1(天然有序)。
-
核心步骤:分区实现
快速排序的效率关键在于分区方式,常用的有三种:
(1)Hoare版(左右指针法)
- 思路:左指针从左向右找大于基准值的元素,右指针从右向左找小于基准值的元素,交换两元素;重复直到左右指针相遇,最后交换基准值与相遇位置元素。
java
private static int partitionHoare(int[] array, int left, int right) {
int pivot = array[left]; // 基准值(取左端点)
int i = left;
int j = right;
while (i < j) {
// 右指针找小于基准值的元素
while (i < j && array[j] >= pivot) {
j--;
}
// 左指针找大于基准值的元素
while (i < j && array[i] <= pivot) {
i++;
}
// 交换两元素
if (i < j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
// 交换基准值与相遇位置元素
array[left] = array[i];
array[i] = pivot;
return i; // 返回基准值最终位置
}
(2)挖坑法
- 思路:将基准值存为临时变量,形成"坑位";右指针找小于基准值的元素,填入坑位并形成新坑位;左指针找大于基准值的元素,填入新坑位并形成新坑位;重复直到左右指针相遇,将基准值填入最后一个坑位。
java
private static int partitionPit(int[] array, int left, int right) {
int pivot = array[left]; // 基准值(坑位初始在left)
int i = left;
int j = right;
while (i < j) {
// 右指针找小于基准值的元素,填入坑位
while (i < j && array[j] >= pivot) {
j--;
}
array[i] = array[j]; // 新坑位在j
// 左指针找大于基准值的元素,填入坑位
while (i < j && array[i] <= pivot) {
i++;
}
array[j] = array[i]; // 新坑位在i
}
array[i] = pivot; // 基准值填入最后一个坑位
return i;
}
(3)前后指针法
- 思路:prev指针指向已处理区间的末尾,cur指针遍历序列;若cur指向的元素小于基准值,prev后移并交换prev与cur指向的元素;遍历结束后,交换基准值与prev指向的元素。
java
private static int partitionPrevCur(int[] array, int left, int right) {
int pivot = array[left];
int prev = left;
int cur = left + 1;
while (cur <= right) {
// 找到小于基准值的元素,交换到prev后
if (array[cur] < pivot && array[++prev] != array[cur]) {
int temp = array[prev];
array[prev] = array[cur];
array[cur] = temp;
}
cur++;
}
// 交换基准值与prev指向的元素
array[left] = array[prev];
array[prev] = pivot;
return prev;
}
- 快速排序主框架(递归版):
java
public static void quickSort(int[] array, int left, int right) {
if (right - left <= 1) {
return; // 子序列长度≤1,直接返回
}
// 分区(三种方式任选其一)
int pivotIndex = partitionPrevCur(array, left, right);
// 递归排序左子序列
quickSort(array, left, pivotIndex - 1);
// 递归排序右子序列
quickSort(array, pivotIndex + 1, right);
}
-
优化策略:
- 三数取中法选基准值:避免因序列有序或逆序导致基准值选到最值,使时间复杂度退化为O(N²)。(取左、中、右三个位置的元素,选中间值作为基准值)
- 小区间用插入排序:当子序列长度小于阈值(如5~10),递归排序的开销大于插入排序,改用插入排序提升效率。
- 非递归实现:用栈模拟递归过程,避免递归深度过深导致栈溢出。
-
非递归实现(Java):
java
public static void quickSortNonRecursive(int[] array, int left, int right) {
Stack<Integer> stack = new Stack<>();
stack.push(left);
stack.push(right);
while (!stack.isEmpty()) {
int r = stack.pop();
int l = stack.pop();
if (r - l <= 1) {
continue;
}
int pivotIndex = partitionPrevCur(array, l, r);
// 压入右子序列区间
stack.push(pivotIndex + 1);
stack.push(r);
// 压入左子序列区间
stack.push(l);
stack.push(pivotIndex - 1);
}
}
- 特性总结 :
- 时间复杂度:O(NlogN)(平均情况);最坏情况O(N²)(基准值选到最值,如有序序列);最好情况O(NlogN)。
- 空间复杂度:O(logN)~O(N)(递归栈空间,优化后可控制在O(logN))。
- 稳定性:不稳定(分区交换时可能打乱相同关键字的相对次序)。
- 适用场景:数据量较大、对排序效率要求高的场景,是实际开发中的首选排序算法之一。
(四)归并排序:分而治之,合并有序
归并排序是基于分治法的典型应用,核心思想是"先分后合",通过合并两个有序子序列来构建完整的有序序列。
-
基本思想:
- 分解:将待排序数组递归拆分为两个长度大致相等的子数组,直到每个子数组长度为1(天然有序)。
- 合并:将两个有序子数组合并为一个有序数组,重复合并过程,直到得到完整的有序数组。
-
示例过程 :
待排序序列:
[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]- 分解:
[3,44,38,5,47,15,36,26]和[27,2,46,4,19,50,48]→ 继续分解直到子数组长度为1。 - 合并:
[3,44]、[5,38]、[15,47]等 → 合并为[3,5,38,44]、[15,26,36,47]等 → 最终合并为完整有序序列。
- 分解:
-
代码实现(Java,递归版):
java
public static void mergeSort(int[] array) {
if (array.length <= 1) {
return;
}
int mid = array.length / 2;
// 拆分左、右子数组
int[] left = Arrays.copyOfRange(array, 0, mid);
int[] right = Arrays.copyOfRange(array, mid, array.length);
// 递归排序左、右子数组
mergeSort(left);
mergeSort(right);
// 合并两个有序子数组
merge(array, left, right);
}
// 合并两个有序子数组到原数组
private static void merge(int[] result, int[] left, int[] right) {
int i = 0, j = 0, k = 0;
// 比较左、右子数组元素,按序放入结果数组
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result[k++] = left[i++];
} else {
result[k++] = right[j++];
}
}
// 放入左子数组剩余元素
while (i < left.length) {
result[k++] = left[i++];
}
// 放入右子数组剩余元素
while (j < right.length) {
result[k++] = right[j++];
}
}
- 特性总结 :
- 时间复杂度:O(NlogN)(最坏、平均、最好情况均为O(NlogN),分解和合并过程的时间复杂度固定)。
- 空间复杂度:O(N)(需额外空间存储拆分后的子数组和合并结果)。
- 稳定性:稳定(合并时按顺序比较,相同关键字的元素保持原有次序)。
- 适用场景:数据量较大、要求稳定排序的场景,尤其适合外部排序(如海量数据排序)。
三、海量数据排序:归并排序的实际应用
当数据量远超内存(如内存1G,数据100G)时,需使用外部排序,归并排序是外部排序的首选算法,具体步骤如下:
- 分割文件:将100G数据按内存大小分割为200个512M的小文件。
- 内部排序:对每个512M的小文件进行内部排序(如快速排序、堆排序),得到200个有序小文件。
- 多路归并:利用归并排序的合并思想,同时读取200个有序小文件的元素,按序合并到输出文件,最终得到100G的有序数据。
四、七大排序算法性能对比与选择
1. 性能与稳定性汇总表
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | 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) | O(N^1.3) | O(N²) | O(1) | 不稳定 |
| 堆排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(1) | 不稳定 |
| 快速排序 | O(N*logN) | O(N*logN) | O(N²) | O(logN)~O(N) | 不稳定 |
| 归并排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(N) | 稳定 |
2. 算法选择策略
- 数据量小(N≤1000):优先选择直接插入排序或冒泡排序(简单易实现,效率足够)。
- 数据量中大型(N≥1000):优先选择快速排序(综合效率最优)或堆排序(空间开销小)。
- 要求稳定排序:选择归并排序或冒泡排序、直接插入排序(小数据量)。
- 序列接近有序:选择直接插入排序或冒泡排序(最好情况O(N))。
- 海量数据(外部排序):选择归并排序。
- 对空间敏感:选择堆排序、希尔排序或直接选择排序(空间复杂度O(1))。
五、Java中的常用排序方法
Java提供了内置的排序工具类java.util.Arrays和java.util.Collections,底层实现结合了多种排序算法,性能优异。
1. 数组排序:Arrays.sort()
-
针对基本类型数组(int[]、long[]等):底层使用双轴快速排序(Dual-Pivot QuickSort),是快速排序的优化版本,效率更高。
-
针对对象数组(String[]、自定义对象数组等):底层使用归并排序(稳定排序),保证对象的相对次序不被打乱。
-
使用示例:
java
// 基本类型数组排序
int[] arr1 = {3, 1, 4, 1, 5, 9};
Arrays.sort(arr1); // 结果:[1,1,3,4,5,9]
// 对象数组排序(需实现Comparable接口或传入Comparator)
String[] arr2 = {"apple", "banana", "cherry", "date"};
Arrays.sort(arr2); // 按字典序排序:[apple, banana, cherry, date]
// 自定义对象排序
Person[] people = {new Person("Alice", 25), new Person("Bob", 20)};
Arrays.sort(people, Comparator.comparingInt(Person::getAge)); // 按年龄排序
2. 集合排序:Collections.sort()
-
用于排序实现了
List接口的集合(如ArrayList、LinkedList)。 -
底层调用
Arrays.sort(),对于对象集合同样使用稳定排序。 -
使用示例:
java
List<Integer> list = new ArrayList<>(Arrays.asList(5, 2, 8, 1));
Collections.sort(list); // 结果:[1,2,5,8]
// 自定义排序规则
List<Person> personList = new ArrayList<>();
personList.add(new Person("Alice", 25));
personList.add(new Person("Bob", 20));
Collections.sort(personList, (p1, p2) -> p2.getAge() - p1.getAge()); // 按年龄降序
3. 注意事项
Arrays.sort()和Collections.sort()均为稳定排序(对象类型)。- 对于自定义对象,需通过实现
Comparable接口或传入Comparator来指定排序规则。 - 底层算法会根据数据规模自动优化,无需手动选择排序算法,直接使用即可满足绝大多数场景。