在编程和数据处理中,排序就像 "整理房间"------ 把杂乱无章的数据按规则梳理有序,后续的查找、统计、分析才能高效推进。无论是电商商品按价格排序,还是后台数据按时间归档,排序算法都是底层核心能力。
一、排序的基本概念
在学具体算法前,得先明确几个关键定义 ------ 这些是判断算法优劣的 "标尺"。
1. 排序的本质
排序是将一组记录,按照某个(或某些)关键字(比如价格、成绩、时间)的大小,按递增或递减顺序排列的操作。比如把[3,1,4,2]按 "数值递增" 排序后得到[1,2,3,4]。
2. 稳定性:排序的 "隐性要求"
假设待排序数据中有多个关键字相同的元素(比如两个价格都是 50 的商品),排序后如果它们的相对位置不变,就称这个算法是 "稳定的";反之则不稳定。
举个例子:原序列[5a, 3, 5b](a 和 b 代表两个不同商品,仅价格相同),稳定排序后还是[3, 5a, 5b];若变成[3, 5b, 5a],就是不稳定的。为什么在乎稳定性? 比如电商排序 ------ 先按价格排序,再按销量排序时,同价格商品的销量排序需要保留之前的相对顺序,这就需要稳定算法。
3. 内部排序 vs 外部排序
- 内部排序:所有数据都能放进内存,排序过程只在内存中进行(比如给一个 1000 个元素的数组排序)。我们常用的七大排序算法都属于内部排序。
- 外部排序:数据量太大,内存放不下(比如 100G 数据,内存只有 1G),需要在磁盘和内存之间频繁读写数据才能排序(归并排序是外部排序的核心算法)。
二、七大基于比较的排序算法:原理与特性
所谓 "基于比较",就是通过比较元素大小来决定位置 ------ 这是最通用的排序思路,覆盖了我们日常开发中 90% 以上的场景。下面逐个拆解核心逻辑和优缺点。
1. 插入排序:从 "玩扑克牌" 学起
插入排序的核心是 "逐个插入有序序列",分两种:直接插入排序和希尔排序。
(1)直接插入排序:简单但 "看数据"
基本思想 :像玩扑克牌时 "摸牌插牌"------ 摸一张牌,就把它插入到手里已排好序的牌中,直到摸完所有牌。具体步骤:假设要排序数组arr,当插入第i个元素时,arr[0]~arr[i-1]已经有序;我们用arr[i]从后往前对比arr[i-1]、arr[i-2]... 找到合适位置插入,同时把后面的元素往后挪。
public class SortAlgorithms {
//直接插入排序
public static void insertSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return; //数组为空或长度为1,无需排序
}
//从第2个元素(索引1)开始,逐个插入前面的有序序列
for (int i = 1; i < arr.length; i++) {
int temp = arr[i]; //保存当前待插入元素
int j = i - 1;
//从后往前比较已排序序列,找到待插入位置
while (j >= 0 && arr[j] > temp) {
arr[j + 1] = arr[j]; //元素后移
j--;
}
arr[j + 1] = temp; //插入当前元素到正确位置
}
}
}
特性总结:
- 数据越接近有序,效率越高(最好情况时间复杂度
O(n),比如已经排好序的数组); - 平均 / 最坏时间复杂度
O(n²)(数据完全无序时,要频繁挪元素); - 空间复杂度
O(1)(只需要临时变量存插入元素); - 稳定排序(相同元素不会因为插入而换位置)。
(2)希尔排序:直接插入的 "优化版"
直接插入排序在数据量大且无序时效率太低,希尔排序通过 "分组预排序" 解决这个问题,也叫 "缩小增量排序"。
基本思想 :先选一个 "增量gap"(比如gap = 数组长度/2),把数组分成gap组(比如gap=3时,第 1、4、7 个元素为一组,第 2、5、8 个为一组...),每组内部用直接插入排序;然后缩小gap(比如gap=gap/2),重复分组和排序;直到gap=1,此时数组已经接近有序,最后做一次直接插入排序即可。
public class SortAlgorithms {
//希尔排序(Knuth增量:gap = gap/3 + 1)
public static void shellSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int gap = arr.length;
//逐步缩小增量,直到gap=1
while (gap > 1) {
gap = gap / 3 + 1; //符合文档中Knuth的增量取法
//按当前gap分组,每组内执行直接插入排序
for (int i = gap; i < arr.length; i++) {
int temp = arr[i];
int j = i - gap;
while (j >= 0 && arr[j] > temp) {
arr[j + gap] = arr[j]; //组内元素后移
j -= gap;
}
arr[j + gap] = temp; //插入当前元素
}
}
}
}
特性总结:
- 本质是对直接插入排序的优化,通过预排序让数组 "接近有序",最终提升效率;
- 时间复杂度不好计算(依赖
gap的取值),常用O(n^1.25)~O(1.6n^1.25)(Knuth 增量的统计结果); - 空间复杂度
O(1),但不稳定(分组排序时可能打乱相同元素的相对位置)。
2. 选择排序:"挑最小的放前面"
选择排序的思路很直白:每次从剩下的元素里挑最小(或最大)的,放到已排序序列的末尾,直到选完所有元素。分直接选择排序和堆排序。
(1)直接选择排序:简单但 "低效"
基本思想 :比如排升序 ------ 第一次从arr[0]~arr[n-1]挑最小的,和arr[0]交换;第二次从arr[1]~arr[n-1]挑最小的,和arr[1]交换;以此类推,直到只剩一个元素。
public class SortAlgorithms {
//直接选择排序(选最小元素放起始位置)
public static void selectSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
//每次确定第i个位置的元素(0~n-2,最后一个元素自动有序)
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i; // 记录最小元素的索引
//遍历剩余序列,找到最小元素
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
//交换最小元素与当前起始位置元素
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
}
特性总结:
- 逻辑简单,容易实现,但效率低(无论数据是否有序,都要遍历找最小元素,时间复杂度始终
O(n²)); - 空间复杂度
O(1),不稳定 (比如[3, 2, 2],第一次选最小的 2 和 3 交换,会变成[2, 3, 2],两个 2 的相对位置变了)。
(2)堆排序:选择排序的 "性能王者"
直接选择排序找最小元素要遍历整个数组,堆排序用 "堆" 这种数据结构,能快速找到最大 / 最小元素,大幅提升效率。
基本思想:排升序要建 "大堆"(堆顶是最大元素),排降序建 "小堆"。步骤如下:
-
把无序数组建成大堆;
-
把堆顶(最大元素)和堆尾元素交换,此时最大元素放到了最终位置;
-
缩小堆的范围(排除已排好的堆尾),重新调整堆为大堆;
-
重复步骤 2~3,直到堆的范围为 1。
public class SortAlgorithms {
//堆排序(升序:建大堆)
public static void heapSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
//1. 构建大堆(从最后一个非叶子节点开始向下调整)
for (int i = (n - 2) / 2; i >= 0; i--) {
adjustMaxHeap(arr, i, n);
}
//2. 交换堆顶与堆尾,调整堆(重复直到堆范围为1)
for (int i = n - 1; i > 0; i--) {
//堆顶(最大元素)与堆尾交换
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
//缩小堆范围,调整剩余元素为大堆
adjustMaxHeap(arr, 0, i);
}
}//向下调整为大堆(堆范围:0~heapSize-1) private static void adjustMaxHeap(int[] arr, int parent, int heapSize) { int temp = arr[parent]; //保存父节点值 int child = 2 * parent + 1; //左孩子索引 while (child < heapSize) { //若右孩子存在且比左孩子大,选择右孩子 if (child + 1 < heapSize && arr[child + 1] > arr[child]) { child++; } //父节点值大于孩子值,无需调整 if (temp >= arr[child]) { break; } //孩子值上移到父节点 arr[parent] = arr[child]; parent = child; //父节点指针下移 child = 2 * parent + 1; //左孩子指针下移 } arr[parent] = temp; //插入原父节点值到正确位置 }}
特性总结:
- 效率高,时间复杂度始终
O(nlogn)(建堆O(n),调整堆每次O(logn),共n次); - 空间复杂度
O(1),不稳定(调整堆时可能打乱相同元素的位置); - 适合数据量大的场景,不需要额外空间。
3. 交换排序:"大的往后挪,小的往前挤"
交换排序通过比较元素大小,交换位置来实现排序,核心是 "冒泡排序" 和 "快速排序"。
(1)冒泡排序:最易理解的 "入门级"
基本思想:像水里的泡泡往上冒 ------ 从数组开头开始,两两比较相邻元素,若前面比后面大(升序),就交换;每一轮都把最大的元素 "冒" 到数组末尾。
public class SortAlgorithms {
//冒泡排序(优化版:无交换则提前退出)
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
boolean swapped; //标记本轮是否发生交换
//每轮将最大元素冒泡到尾部,轮次为n-1(最后一个元素自动有序)
for (int i = 0; i < n - 1; i++) {
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;
}
}
}
}
特性总结:
- 逻辑极简,新手友好(比如
[3,1,2],第一轮3和1交换→[1,3,2],再3和2交换→[1,2,3]); - 最好时间复杂度
O(n)(数据有序时,加个 "是否交换" 的标记,可提前退出),平均 / 最坏O(n²); - 空间复杂度
O(1),稳定(相同元素不交换,相对位置不变)。
(2)快速排序:综合性能 "天花板"
快速排序是 Hoare 在 1962 年提出的,基于 "分治法",是实际开发中用得最多的排序算法(比如 Java 的Arrays.sort()对基本类型数组就是用快排)。
基本思想:选一个 "基准值"(比如数组第一个元素),把数组分成两部分 ------ 左边全比基准值小,右边全比基准值大;然后对左右两部分重复这个过程(递归),直到每部分只有一个元素。
**核心步骤:如何划分数组?**常用三种划分方式,本质都是找到基准值的最终位置:
- Hoare 版:左右指针从两端向中间移动,左指针找比基准大的,右指针找比基准小的,交换两者;直到指针相遇,该位置就是基准的最终位置。
- 挖坑法:先把基准值存起来(形成 "坑"),右指针找比基准小的,填入坑中(右指针变新坑);左指针找比基准大的,填入新坑(左指针变新坑);直到指针相遇,把基准值填入最后一个坑。
- 前后指针法 :
prev指针指向已排序区间末尾,cur指针遍历数组;若cur找到比基准小的元素,prev后移,交换prev和cur的元素;遍历结束后,交换prev和基准值,prev就是基准的最终位置。
快速排序的优化技巧:
-
三数取中法选基准:避免选到最大 / 最小元素(比如数据有序时,基准选第一个会导致划分失衡,时间复杂度退化为
O(n²)),而是选 "左、中、右" 三个元素的中间值作为基准。 -
小区间用插入排序:当递归到子数组长度很小时(比如小于 10),用直接插入排序(此时数据接近有序,插入排序效率更高)。
public class SortAlgorithms {
private static final int INSERT_SORT_THRESHOLD = 10; //小区间阈值(文档优化思想)//快速排序(前后指针法+三数取中+小区间插入排序) 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 left, int right) { //小区间用直接插入排序(优化) if (right - left + 1 <= INSERT_SORT_THRESHOLD) { insertSortRange(arr, left, right); return; } //三数取中选基准(避免基准为极值) int pivotIndex = medianOfThree(arr, left, right); //基准值放到区间左端(方便前后指针法) swap(arr, pivotIndex, left); //划分区间,得到基准值最终位置 int div = partitionByPrevCur(arr, left, right); //递归排序左区间(left~div-1)和右区间(div+1~right) quickSortHelper(arr, left, div - 1); quickSortHelper(arr, div + 1, right); } //三数取中:选择left、mid、right三者的中间值作为基准 private static int medianOfThree(int[] arr, int left, int right) { int mid = left + (right - left) / 2; //比较left、mid、right,返回中间值索引 if (arr[left] > arr[mid]) { swap(arr, left, mid); } if (arr[left] > arr[right]) { swap(arr, left, right); } if (arr[mid] > arr[right]) { swap(arr, mid, right); } return mid; // mid为中间值索引 } //前后指针法划分区间 private static int partitionByPrevCur(int[] arr, int left, int right) { int pivot = arr[left]; //基准值(已通过三数取中放到left) int prev = left; //已排序区间尾部指针 int cur = left + 1; //遍历指针 while (cur <= right) { //cur找到比基准小的元素,prev后移并交换 if (arr[cur] < pivot && arr[++prev] != arr[cur]) { swap(arr, prev, cur); } cur++; } //基准值放到最终位置(prev) swap(arr, left, prev); return prev; //返回基准值索引 } //区间内的直接插入排序(用于快速排序小区间优化) private static void insertSortRange(int[] arr, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = arr[i]; int j = i - 1; while (j >= left && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; } } //交换数组中两个元素 private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }}
特性总结:
- 综合性能最好,平均时间复杂度
O(nlogn),最坏O(n²)(数据有序且基准选得差); - 空间复杂度
O(logn)~O(n)(递归栈的深度,优化后通常是O(logn)); - 不稳定(划分时可能交换相同元素的位置);
- 适合数据量大、无序的场景,是内部排序的首选。
4. 归并排序:"分而治之,合并有序"
归并排序是 "分治法" 的经典应用,核心优势是 "稳定" 且适合外部排序(海量数据)。
基本思想:先 "分" 后 "合"------
- 分:把数组从中间分成两部分,再把每部分继续分成两半,直到每部分只有一个元素(此时每部分都是有序的);
- 合:把两个有序的子数组合并成一个有序数组,比如
[1,3]和[2,4]合并成[1,2,3,4];重复合并,直到整个数组有序。
海量数据排序的应用:如果内存只有 1G,要排序 100G 数据,归并排序是最佳选择:
-
把 100G 数据切成 200 个 512M 的块(内存能放下);
-
对每个 512M 的块单独排序(用快排、堆排都可以);
-
用 "二路归并" 同时处理 200 个有序块 ------ 每次从 200 个块中取最小元素,放入结果文件,直到所有块处理完。
public class SortAlgorithms {
//归并排序
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);
}//递归分割数组 private static void mergeSortHelper(int[] arr, int left, int right, int[] temp) { if (left >= right) { return; //子数组长度为1,递归终止 } int mid = left + (right - left) / 2; //中间位置(避免溢出) //递归分割左子数组(left~mid)和右子数组(mid+1~right) mergeSortHelper(arr, left, mid, temp); mergeSortHelper(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 = 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++]; } //临时数组元素复制回原数组(left~right区间) for (k = left; k <= right; k++) { arr[k] = temp[k]; } }}
特性总结:
- 时间复杂度始终
O(nlogn)(分的过程O(logn),合的过程O(n)); - 空间复杂度
O(n)(需要额外数组存合并后的结果); - 稳定(合并时相同元素按原顺序放);
- 适合外部排序、需要稳定排序的场景,但需要额外空间。
三、非基于比较的排序:特殊场景的 "快刀"
基于比较的排序有个理论上限 ------ 时间复杂度最低是O(nlogn),但非基于比较的排序不通过比较元素大小来排序,在特定场景下更快。这里重点讲最常用的 "计数排序"。
计数排序:"鸽巢原理" 的应用
基本思想:利用 "数据范围小" 的特点,统计每个元素出现的次数,再根据次数把元素放回数组。比如给学生成绩(0-100 分)排序,范围固定,计数排序比快排还快。
步骤举例 :排序[2,5,3,0,2,3,0,3]
-
找数据范围:0-5,建一个计数数组
count,长度为 6; -
统计次数:
count[0]=2(0 出现 2 次),count[1]=0,count[2]=2,count[3]=3,count[4]=0,count[5]=1; -
计算 "小于等于当前值的元素个数":把
count转化为前缀和,count[0]=2,count[1]=2,count[2]=4,count[3]=7,count[4]=7,count[5]=8; -
倒序填结果:从原数组末尾开始,比如最后一个元素 3,
count[3]是 7,所以 3 放在结果数组的第 6 位(索引 6),然后count[3]减1;依次填完所有元素,得到有序数组。public class SortAlgorithms {
//计数排序(支持非负整数,可扩展至负数)
public static void countSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
//1. 找到数组中的最大值和最小值(确定计数数组范围)
int min = arr[0];
int max = arr[0];
for (int num : arr) {
if (num < min) {
min = num;
}
if (num > max) {
max = num;
}
}//2. 创建计数数组,统计每个元素出现次数 int range = max - min + 1; int[] countArr = new int[range]; for (int num : arr) { countArr[num - min]++; // 偏移量:解决min非0的情况 } //3. 计算计数数组的前缀和(确定元素最终位置) for (int i = 1; i < countArr.length; i++) { countArr[i] += countArr[i - 1]; } //4. 倒序遍历原数组,填入结果数组(保证稳定性) int[] result = new int[arr.length]; for (int i = arr.length - 1; i >= 0; i--) { int num = arr[i]; int index = countArr[num - min] - 1; // 元素在结果数组中的索引 result[index] = num; countArr[num - min]--; // 计数减1(处理重复元素) } //5. 结果数组复制回原数组 System.arraycopy(result, 0, arr, 0, arr.length); } // 测试所有排序算法 public static void main(String[] args) { // 文档中示例数组(、等) int[] arr = {3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48}; // 测试直接插入排序 int[] insertArr = arr.clone(); insertSort(insertArr); System.out.println("直接插入排序结果:" + Arrays.toString(insertArr)); // 测试希尔排序 int[] shellArr = arr.clone(); shellSort(shellArr); System.out.println("希尔排序结果:" + Arrays.toString(shellArr)); // 测试直接选择排序 int[] selectArr = arr.clone(); selectSort(selectArr); System.out.println("直接选择排序结果:" + Arrays.toString(selectArr)); // 测试堆排序 int[] heapArr = arr.clone(); heapSort(heapArr); System.out.println("堆排序结果:" + Arrays.toString(heapArr)); // 测试冒泡排序 int[] bubbleArr = arr.clone(); bubbleSort(bubbleArr); System.out.println("冒泡排序结果:" + Arrays.toString(bubbleArr)); // 测试快速排序 int[] quickArr = arr.clone(); quickSort(quickArr); System.out.println("快速排序结果:" + Arrays.toString(quickArr)); // 测试归并排序 int[] mergeArr = arr.clone(); mergeSort(mergeArr); System.out.println("归并排序结果:" + Arrays.toString(mergeArr)); // 测试计数排序 int[] countArr = arr.clone(); countSort(countArr); System.out.println("计数排序结果:" + Arrays.toString(countArr)); }}
特性总结:
- 时间复杂度
O(n + 范围)(n 是元素个数,范围是数据的最大值 - 最小值 + 1); - 空间复杂度
O(范围)(计数数组的长度); - 稳定(倒序填结果能保留相同元素的相对位置);
- 只适合数据范围小的场景(比如成绩、年龄),范围大时(比如 1-100 万)不适用。
四、一张表理清所有排序算法的性能
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 核心适用场景 |
|---|---|---|---|---|---|---|
| 直接插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 数据接近有序、小规模数据 |
| 希尔排序 | O(n) | O(n^1.25) | O(n²) | O(1) | 不稳定 | 中等规模数据 |
| 直接选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 小规模数据、不在乎效率 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 | 大规模数据、省空间 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 教学演示、小规模有序数据 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn) | 不稳定 | 大规模无序数据、内部排序首选 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 | 外部排序、需要稳定排序 |
| 计数排序 | O (n + 范围) | O (n + 范围) | O (n + 范围) | O (范围) | 稳定 | 数据范围小(如成绩、ID) |
五、经典例题:避坑指南
-
**快速排序基于什么思想?**答案:A(分治法)。快排的核心是 "分"(划分数组)和 "治"(递归排序子数组),属于分治法;递归是实现方式,不是核心思想。
-
直接插入排序插入 45 时,需要比较几次? 原序列
[54,38,96,23,15,72,60,45],插入第 8 个元素 45 时,前 7 个元素已排序为[15,23,38,54,60,72,96]。从后往前比:96>45(1)、72>45(2)、60>45(3)、54>45(4)、38<45(5),共 5 次。答案:C。 -
**哪个排序需要 O (n) 辅助空间?**答案:D(归并排序)。简单排序(插入、冒泡、选择)和堆排序都是 O (1),快排是 O (logn),归并需要额外数组存合并结果,是 O (n)。
六、总结:没有 "最好",只有 "最合适"
排序算法没有绝对的优劣,选择时要结合三个维度:
- 数据规模:小规模用插入 / 冒泡,大规模用快排 / 堆排 / 归并;
- 数据有序性:接近有序用直接插入,完全无序用快排;
- 稳定性需求:需要稳定排序(如多关键字排序)用归并 / 计数,不需要则用快排 / 堆排。
理解每个算法的核心逻辑和适用场景,才能在实际开发中 "对症下药"------ 这也是学习排序算法的最终目的。