数据结构 二
排序算法是数据结构的核心基础,既是算法竞赛的入门必学内容,也是校招面试、考研数据结构的高频考点。排序的本质是将一组无序数据,按照指定规则调整为有序序列,根据核心思想可分为交换类、插入类、选择类、分治类等多个方向。
本文为排序算法上篇,系统讲解冒泡、选择、插入、希尔、归并、快速六种经典排序算法,覆盖核心原理、执行步骤、性能指标、优化方案与可运行Java实现,兼顾理论理解、代码落地与面试考点。
前置知识:排序算法的评判维度
衡量一个排序算法的优劣,通常从四个核心维度评估:
- 时间复杂度:描述数据量增长时操作次数的增长趋势,分为最好、最坏、平均三种场景。平均复杂度代表算法的常规表现,最坏复杂度代表极端场景下的性能下限。
- 空间复杂度 :排序过程中需要额外申请的内存空间大小。仅占用常数级额外空间的称为原地排序,无需申请大规模额外内存。
- 稳定性:若两个元素值相等,排序后二者的相对位置保持不变,则为稳定排序;反之为不稳定排序。稳定性在业务场景中有实际价值,例如电商场景中先按销量排序、再按价格排序,稳定排序可以保证价格相同的商品依然保留销量排序的相对顺序。
- 算法常数因子:时间复杂度只描述增长趋势,实际运行速度还受常数因子影响,例如同样是O(n²)复杂度,插入排序的常数因子远小于冒泡排序,实际运行速度更快。
六大排序算法分类总览
| 算法分类 | 包含算法 | 核心思想 |
|---|---|---|
| 交换类 | 冒泡排序、快速排序 | 通过元素交换调整顺序,快速排序为分治+交换 |
| 插入类 | 插入排序、希尔排序 | 将元素插入到已排序区间的正确位置,希尔排序为分组插入优化 |
| 选择类 | 选择排序 | 每轮选出极值放到已排序区 |
| 分治类 | 归并排序 | 拆分后合并,化整为零逐步排序 |
一、冒泡排序(交换类)
1. 核心思想
通过相邻元素两两比较与交换,让较大的元素逐步向后移动,如同水中气泡向上浮起,最终每一轮都会将当前未排序区间的最大值"冒泡"到区间末尾的正确位置。
2. 执行步骤
- 将数组分为已排序区(末尾)和未排序区(开头),初始已排序区长度为0
- 遍历未排序区,相邻元素两两比较,若前一个大于后一个则交换位置
- 一轮遍历结束后,未排序区的最大值会移动到未排序区的最后一位,该位置加入已排序区
- 重复上述过程,直到未排序区长度为0
3. 性能指标
- 时间复杂度:最好O(n)(数据已有序),最坏/平均O(n²)
- 空间复杂度:O(1),纯原地排序
- 稳定性:稳定排序(相等元素不交换,相对位置不变)
4. Java代码实现
java
public class BubbleSort {
// 基础版冒泡排序
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 外层循环控制排序轮数,共n-1轮
for (int i = 0; i < n - 1; i++) {
// 内层循环遍历未排序区,两两比较
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 交换相邻元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
// 优化版1:增加标记位,某轮无交换则提前结束
public static void bubbleSortOptimized(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
boolean swapped = false; // 标记本轮是否发生交换
for (int j = 0; j < n - 1 - i; 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;
}
}
}
}
5. 进阶优化:鸡尾酒排序(双向冒泡)
基础冒泡排序只能单向冒泡,当数组中大部分元素有序、仅少量小元素位于末尾时,需要多轮才能将小元素移到前面。鸡尾酒排序对此做了优化:
- 交替进行从左到右、从右到左的双向遍历
- 正向遍历将最大值移到末尾,反向遍历将最小值移到开头
- 适合"大部分有序、小元素集中在末尾、大元素集中在开头"的场景,能有效减少排序轮数
6. 特点与适用场景
实现逻辑最简单,适合作为排序入门案例;仅适合小规模或接近有序的数据,大规模数据下效率极低,工业界几乎不直接使用。
二、选择排序(选择类)
1. 核心思想
每一轮从未排序区间中遍历找到最小值,直接交换到未排序区间的起始位置,逐步扩大已排序区间。
2. 执行步骤
- 数组分为已排序区(开头)和未排序区(末尾),初始已排序区为空
- 遍历整个未排序区,记录最小值的下标
- 将最小值与未排序区的第一个元素交换,最小值加入已排序区
- 重复上述过程,直到未排序区为空
3. 性能指标
- 时间复杂度:最好/最坏/平均均为O(n²),无论数据是否有序都必须完成全部比较
- 空间复杂度:O(1),原地排序
- 稳定性:不稳定排序(跨位置交换可能打乱相等元素的相对顺序)
4. Java代码实现
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++) {
int minIndex = i; // 记录最小值的下标
// 内层循环:遍历未排序区找最小值
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 将最小值交换到已排序区末尾
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
}
5. 进阶优化:二元选择排序
基础选择排序每轮只找一个最小值,二元选择排序每轮同时找出最小值和最大值,分别放到未排序区的首尾,可将排序轮数减少一半,比较次数略有下降,但整体时间复杂度仍为O(n²)。
6. 特点与适用场景
思路直观,每轮最多只执行一次交换,交换次数远少于冒泡排序;但整体性能无明显优势,且不稳定,仅适合极小规模数据,实际应用较少。
三、插入排序(插入类)
1. 核心思想
将未排序元素逐个取出,向前插入到已排序区间的正确位置,逻辑和手动整理扑克牌完全一致。
2. 执行步骤
- 初始已排序区只有数组第一个元素
- 取出未排序区的第一个元素,作为待插入值
- 从已排序区的末尾向前遍历,若元素大于待插入值,则向后挪一位
- 直到找到小于等于待插入值的位置,将待插入值放入该空位
- 重复上述过程,直到所有元素插入完成
3. 性能指标
- 时间复杂度:最好O(n)(数据已有序,只需遍历一次),最坏/平均O(n²)
- 空间复杂度:O(1),原地排序
- 稳定性:稳定排序
4. Java代码实现
java
public class InsertionSort {
public static void insertionSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 从第二个元素开始逐个插入
for (int i = 1; i < n; i++) {
int insertValue = arr[i]; // 待插入的元素
int j = i - 1; // 已排序区的末尾下标
// 向前遍历,大于待插入值的元素后移
while (j >= 0 && arr[j] > insertValue) {
arr[j + 1] = arr[j];
j--;
}
// 插入到正确位置
arr[j + 1] = insertValue;
}
}
}
5. 进阶优化:二分插入排序
插入过程中,查找插入位置的步骤可以用二分查找优化,将比较次数从O(n)降到O(logn),但元素移动的次数不变,整体时间复杂度仍为O(n²)。适合数据量中等、比较操作开销远大于移动操作的场景。
6. 特点与适用场景
常数因子极小,对接近有序的小规模数据效率极高;是希尔排序的基础,也是很多高级排序算法在小数组场景下的兜底实现。Java原生的Arrays.sort()在数组长度小于47时,会直接使用插入排序,因为小数据量下O(n²)的插入排序,实际速度反而快于O(nlogn)的快速排序。
四、希尔排序(插入类·缩小增量排序)
1. 核心思想
插入排序的优化版本,核心是分组插入排序:通过设定增量将数组拆分为多个独立分组,每个分组内先做插入排序;逐步缩小增量,数组整体逐渐接近有序,最终增量为1时,对整体做一次插入排序。
希尔排序的本质是利用"插入排序在数据接近有序时效率极高"的特点,通过前期分组预排序,让数组提前达到基本有序状态,最终全局插入排序的成本大幅降低。
2. 执行步骤
- 设定初始增量,通常取数组长度的1/2
- 按增量对数组分组,增量为gap则分为gap组,每组内下标相差gap
- 每个分组内分别执行插入排序
- 增量逐步减半(gap = gap / 2),重复分组排序
- 当gap=1时,整个数组为一组,执行最后一次插入排序
3. 增量序列的选择
希尔排序的性能与增量序列的选择直接相关,常见增量序列:
- 希尔增量:初始为n/2,每次减半,实现最简单,但最坏复杂度仍为O(n²)
- Hibbard增量:增量为 2^k - 1,最坏时间复杂度约为O(n^1.5)
- Knuth增量:增量为 (3^k - 1)/2,是工程中常用的增量序列,性能表现更稳定
4. 性能指标
- 时间复杂度:平均约O(n^1.3),最坏场景O(n²),具体性能与增量序列选择有关
- 空间复杂度:O(1),原地排序
- 稳定性:不稳定排序(分组插入会打乱相等元素的全局相对位置)
5. Java代码实现
java
public class ShellSort {
public static void shellSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 希尔增量:初始为长度的一半,逐步减半
for (int gap = n / 2; gap > 0; gap /= 2) {
// 对每个分组执行插入排序
for (int i = gap; i < n; i++) {
int insertValue = arr[i];
int j = i - gap;
// 分组内向前遍历,元素后移
while (j >= 0 && arr[j] > insertValue) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = insertValue;
}
}
}
}
6. 特点与适用场景
第一个突破平方级时间复杂度的排序算法,实现简单,适合中等规模数据;性能优于直接插入排序,但略逊于归并、快速排序。由于性能不如快速排序等O(nlogn)算法,工业界直接使用较少,主要作为教学理解排序优化思路的案例。
五、归并排序(分治类)
1. 核心思想
典型的分治思想实现:将数组不断二分拆分为子序列,直到每个子序列只有1个元素(天然有序);再将两个有序子序列两两合并,最终得到整体有序的数组。
2. 执行步骤
拆分阶段(递归)
- 将数组从中间位置拆分为左、右两个子数组
- 对左、右子数组分别递归执行拆分
- 直到子数组长度为1,拆分结束
合并阶段
- 申请临时数组,大小等于两个有序子数组的长度之和
- 双指针分别指向两个子数组的起始位置
- 比较两个指针指向的元素,将较小的放入临时数组,对应指针后移
- 直到其中一个子数组遍历完毕,将另一个数组的剩余元素直接拷贝到临时数组
- 将临时数组的结果拷贝回原数组的对应位置
3. 复杂度推导
- 时间复杂度:递归树深度为logn,每一层合并的总操作量为n,因此总时间复杂度为O(nlogn)
- 空间复杂度:需要长度为n的临时数组存储合并结果,同时递归调用栈深度为logn,整体空间复杂度O(n)
4. 性能指标
- 时间复杂度:最好/最坏/平均均为O(nlogn),性能不受数据有序性影响,极其稳定
- 空间复杂度:O(n),需要额外临时数组存储合并结果,非原地排序
- 稳定性:稳定排序
5. Java代码实现(递归版)
java
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) {
return;
}
int mid = left + (right - left) / 2; // 中间位置,避免整数溢出
// 递归拆分左半部分
mergeSort(arr, left, mid, temp);
// 递归拆分右半部分
mergeSort(arr, mid + 1, right, temp);
// 合并两个有序子数组
merge(arr, left, mid, right, temp);
}
// 合并两个有序区间 [left, mid] 和 [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 = 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++];
}
// 将临时数组的结果拷贝回原数组
k = 0;
while (left <= right) {
arr[left++] = temp[k++];
}
}
}
6. 扩展:迭代版归并排序
递归版依赖调用栈,数据量极大时存在栈溢出风险,迭代版从底向上逐层合并,避免了递归开销:
java
public static void mergeSortIterative(int[] arr) {
if (arr == null || arr.length <= 1) return;
int n = arr.length;
int[] temp = new int[n];
// 从长度为1的子数组开始,逐步翻倍合并
for (int gap = 1; gap < n; gap *= 2) {
for (int left = 0; left < n; left += 2 * gap) {
int mid = Math.min(left + gap - 1, n - 1);
int right = Math.min(left + 2 * gap - 1, n - 1);
merge(arr, left, mid, right, temp);
}
}
}
7. 特点与适用场景
性能稳定,无最坏情况退化,适合大规模数据排序;缺点是需要额外内存空间,存在数据拷贝开销,内存敏感场景需谨慎使用。
归并排序是外部排序的核心算法,当数据量远超内存容量、无法全部加载到内存时,可以通过归并排序的思想分批次排序后合并,是海量数据排序的标准方案。
六、快速排序(分治·交换类)
1. 核心思想
分治思想的经典实现,选取一个基准值,通过一轮分区操作将数组拆分为两部分:小于基准的元素全部移到左侧,大于基准的元素全部移到右侧;基准值落到自身的最终正确位置,再对左右两个子区间递归执行排序。
2. 执行步骤
- 从数组中选取一个基准元素(pivot),常见选择:首元素、尾元素、随机元素
- 分区操作(Partition):
- 左右双指针分别从数组两端向中间靠拢
- 左指针找大于基准的元素,右指针找小于基准的元素
- 交换两个元素,直到左右指针相遇
- 将基准值放到指针相遇的位置,此时基准左侧全小于它,右侧全大于它
- 递归对基准左侧、右侧的子数组执行上述操作
- 子数组长度为0或1时,递归终止
3. 性能指标
- 时间复杂度:平均O(nlogn),最坏O(n²)(数据完全有序且选首尾为基准时退化);采用随机基准后,最坏情况出现概率可忽略
- 空间复杂度:O(logn),主要为递归调用栈的开销
- 稳定性:不稳定排序
4. Java代码实现(随机基准优化版)
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;
}
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int left, int right) {
if (left >= right) {
return;
}
// 分区,获取基准值的最终位置
int pivotIndex = partition(arr, left, right);
// 递归排序左半部分
quickSort(arr, left, pivotIndex - 1);
// 递归排序右半部分
quickSort(arr, pivotIndex + 1, right);
}
// 分区函数:返回基准元素的最终下标
private static int partition(int[] arr, int left, int right) {
// 随机选择基准,避免有序数据下的性能退化
int pivotIndex = left + random.nextInt(right - left + 1);
// 将基准元素交换到最左侧
swap(arr, left, pivotIndex);
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
// 右指针向左找小于基准的元素
while (i < j && arr[j] >= pivot) {
j--;
}
// 左指针向右找大于基准的元素
while (i < j && arr[i] <= pivot) {
i++;
}
// 交换两个元素
swap(arr, i, j);
}
// 基准元素放到最终正确位置
swap(arr, left, i);
return i;
}
private static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
5. 进阶优化方案
工业级快速排序通常会做多层优化,核心优化点包括:
- 三数取中法选基准:取区间首、中、尾三个元素的中位数作为基准,避免极端有序数据的退化,比随机基准更稳定
- 三路划分:将数组分为小于、等于、大于基准三部分,当数组中存在大量重复元素时,能大幅减少递归区间,性能提升显著
- 小数组切换插入排序:当子数组长度小于阈值(通常为10~20)时,改用插入排序,减少递归开销与常数成本
- 尾递归优化:对较短的子区间进行递归,较长的子区间用循环处理,降低调用栈深度,避免栈溢出
6. 特点与适用场景
综合性能极强,常数因子极小,平均场景下效率优于同复杂度的归并排序。快速排序是原地排序,对缓存更友好,是工业界最主流的通用排序算法。Java原生的Arrays.sort()底层在基本类型排序时,核心就采用了双轴快速排序的优化版本。
六大排序算法核心指标对比表
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 | 原地排序 |
|---|---|---|---|---|---|---|
| 冒泡排序 | 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^1.3) | O(n²) | O(n) | O(1) | 不稳定 | 是 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 | 否 |
| 快速排序 | O(nlogn) | O(n²) | O(nlogn) | O(logn) | 不稳定 | 是 |
第一期总结与高频面试考点
核心总结
六种排序算法覆盖了从入门到进阶的核心排序思想:
- 平方级复杂度的冒泡、选择、插入是排序基础,适合入门理解与小规模数据
- 希尔排序是插入排序的升级,突破了平方级复杂度,是理解排序优化的典型案例
- 归并与快速排序是分治思想的两大经典实现,是大规模数据排序的主流选择
高频面试问题
-
哪些排序是稳定的?哪些不稳定?
稳定:冒泡、插入、归并;不稳定:选择、希尔、快速。本质上,不发生跨位置交换的排序通常稳定,涉及远距离交换、分组的排序通常不稳定。
-
快速排序为什么比归并排序快?
快速排序是原地排序,无需额外内存拷贝;分区操作的缓存命中率更高,常数因子更小。虽然平均复杂度相同,但实际运行速度快2~3倍。
-
快速排序的最坏情况如何避免?
最坏情况出现在数据完全有序且基准选在首尾时,通过随机选基准、三数取中都可以有效避免;工业级实现还会通过三路划分、切换插入排序等进一步优化。
-
排序稳定性有什么实际意义?
多维度排序场景下,稳定排序可以保留上一次排序的相对顺序,例如先按销量排序、再按价格排序,价格相同的商品依然能保持销量高低的顺序。