一、先搞懂排序的「稳定性」:为啥它很重要?
排序的核心目标是将序列按指定规则(如升序 / 降序)重新排列,但「稳定性」这个容易被忽略的属性,直接决定了排序结果是否符合业务预期。
官方定义:若序列中两个等值数据(记为 k [i] 和 k [j],排序前 i<j),排序后依然保持 k [i] 在 k [j] 前面,则称这种排序是稳定的;反之则不稳定。
生活化例子:给 "学生成绩表" 按分数升序排序时,若两个同学分数相同 ------ 稳定排序会保留两人原本的提交时间 / 排名顺序,不稳定排序则可能打乱这个顺序。如果业务要求 "分数相同按提交时间排序",稳定性就直接决定了结果的正确性。
二、常用排序算法:性能 + 稳定性对比表
直接上干货!这张表覆盖面试 / 开发高频排序算法,核心指标一目了然:
| 排序方法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|---|
| 冒泡排序 | \(O(n^2)\) | \(O(n)\) | \(O(n^2)\) | \(O(1)\) | 稳定 | 小数据量(n<100)、代码极简 |
| 简单选择排序 | \(O(n^2)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 不稳定 | 交换成本高的场景(交换次数少) |
| 直接插入排序 | \(O(n^2)\) | \(O(n)\) | \(O(n^2)\) | \(O(1)\) | 稳定 | 数据基本有序、动态插入场景 |
| 折半插入排序 | \(O(n^2)\) | \(O(nlogn)\) | \(O(n^2)\) | \(O(1)\) | 稳定 | 优化查找,比直接插入更高效 |
| 希尔排序 | \(O(nlogn)~O(n^2)\) | \(O(n^{1.3})\) | \(O(n^2)\) | \(O(1)\) | 不稳定 | 中等规模数据,插入排序优化版 |
| 堆排序 | \(O(nlogn)\) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(1)\) | 不稳定 | 大数据量、内存受限场景 |
| 归并排序 | \(O(nlogn)\) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(n)\) | 稳定 | 对稳定性有要求的大数据场景 |
| 快速排序(递归) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(n^2)\) | \(O(logn)~O(n)\) | 不稳定 | 大数据量、平均效率最优 |
| 快速排序(非递归) | \(O(nlogn)\) | \(O(nlogn)\) | \(O(n^2)\) | \(O(logn)\) | 不稳定 | 生产环境大数据量,避免递归栈溢出 |
划重点
- 小数据量(n<100):冒泡 / 插入 / 选择排序够用(代码简单、空间占用少);
- 大数据量:优先选快排 / 归并(时间复杂度低);
- 对稳定性有要求:归并排序 / 插入排序是优选(快排 / 堆排序不稳定);
- 数据基本有序:直接插入排序效率接近\(O(n)\),性价比极高;
- 内存受限场景:堆排序是唯一选择(原地排序,无额外空间开销)。
三、9 大经典排序算法:
以下均为可直接运行的 C 语言实现,重点标注核心逻辑和关键细节。
1. *冒泡排序:最易理解的 "相邻交换"
核心原理:重复遍历数组,每次比较相邻元素,将较大的元素逐步 "冒泡" 到数组尾部;每轮遍历后,尾部已排序的部分无需再比较,内层循环范围可逐步缩小。
void bubble_sort(int *arr, int len)
{
// i:外层控制排序轮次;j:内层遍历未排序区间;k:临时变量用于交换
int i, j, k;
for(i=0; i<len; i++)
{
// len-i:每轮结束后,末尾i个元素已排好序,无需重复比较
for(j=1; j< len-i; j++)
{
// 前一项大于当前项,交换位置(保证升序)
if( arr[j-1] > arr[j] )
{
k = arr[j];
arr[j] = arr[j-1];
arr[j-1] = k;
}
}
}
}
2. *直接插入排序:"逐个归位" 的高效排序
核心原理:将数组分为 "有序区" 和 "无序区",初始有序区仅包含第一个元素;每次从无序区取一个元素,插入到有序区的合适位置,逐步扩大有序区直至整个数组有序。
// arr:待排序数组;len:数组长度
void insert_order(int *arr, int len)
{
int i, j, k;
// i:指向无序区第一个元素(有序区初始为arr[0])
for(i=1; i<len; i++)
{
k = arr[i]; // 保存待插入元素(避免移动时被覆盖)
j = i-1; // j:指向有序区最后一个元素
// 有序区元素大于待插入值,依次后移,腾出插入位置
while(j>=0 && arr[j] > k)
{
arr[j+1] = arr[j];
j--;
}
arr[j+1] = k; // 将待插入元素放入最终位置
}
}
3. *简单选择排序:"找最小值" 的交换优化
核心原理:每轮遍历无序区,找到最小值的下标,将其与无序区第一个元素交换;每轮仅需一次交换,相比冒泡排序大幅减少交换次数,适合交换成本高的场景。
void select_order(int *arr, int len)
{
// i:外层控制有序区边界;j:遍历无序区找最小值;min:最小值下标;k:交换临时变量
int i, j, k, min;
for(i=0; i<len; i++)
{
min = i; // 假设当前无序区第一个元素是最小值
// 遍历无序区,找到真正的最小值下标
for(j=i+1; j<len; j++)
{
if(arr[min] > arr[j])
min = j;
}
// 最小值不在当前位置,交换(避免无意义的自我交换)
if(min != i)
{
k = arr[min];
arr[min] = arr[i];
arr[i] = k;
}
}
}
4. *折半插入排序:二分查找优化插入效率
核心原理:在直接插入排序的基础上,用 "二分查找" 替代顺序查找,快速定位插入位置,减少比较次数(但元素移动次数不变,时间复杂度仍为\(O(n^2)\),但实际效率更高)。
void bin_sort(int *arr, int len)
{
int i, j, k, left, mid, right;
for(i=1; i<len; i++)
{
left = 0; // 有序区左边界
right = i-1; // 有序区右边界
k = arr[i]; // 待插入元素
// 二分查找插入位置
while(left <= right)
{
mid = (left + right)/2; // 中间位置
if(arr[mid] > k) // 待插入值更小,去左半区找
right = mid - 1;
else // 待插入值更大,去右半区找
left = mid + 1;
}
// 找到插入位置left后,有序区元素从后往前逐步后移
for(j=i; j>=left; j--)
{
arr[j] = arr[j-1];
}
arr[left] = k; // 插入待排序元素
}
}
5. *快速排序(递归版):大数据量的 "效率之王"
核心原理:采用 "分治思想",选一个基准值将数组分为两部分(左小右大),再递归处理左右子数组,直至子数组长度为 1。
// arr:待排序数组;left:左边界下标;right:右边界下标
void quick_sort(int *arr, int left, int right)
{
int i, j, k;
i = left; // 左边界指针
j = right; // 右边界指针
k = arr[i]; // 选左边界元素作为基准值(可优化为随机选基准)
// 左右指针未相遇时,进行分区
while(i < j)
{
// 从右往左找:比基准值小的元素
while(k <= arr[j] && i < j)
j--;
// 找到后,将该元素放到左指针位置
if(i < j)
arr[i] = arr[j];
// 从左往右找:比基准值大的元素
while(k >= arr[i] && i < j)
i++;
// 找到后,将该元素放到右指针位置
if(i < j)
arr[j] = arr[i];
}
// 基准值放到最终的中间位置(i=j)
arr[i] = k;
// 递归排序左半部分(左边界到基准值前一位)
if(left < i-1)
quick_sort(arr, left, i-1);
// 递归排序右半部分(基准值后一位到右边界)
if(right > i+1)
quick_sort(arr, i+1, right);
}
快排关键注意点
- 最坏情况(数组已完全有序):时间复杂度退化到\(O(n^2)\),可通过 "随机选基准值""三数取中法" 优化;
- 稳定性:快排是不稳定排序,等值元素的相对位置可能被打乱;
- 递归风险:递归深度过深可能导致栈溢出,实际开发中可改用 "尾递归优化" 或 "栈模拟非递归实现"。
6. 希尔排序:插入排序的 "步长优化版"
核心原理 :将数组按步长(gap) 划分为多个子序列,对每个子序列做直接插入排序;逐步缩小步长(通常减半),当步长为 1 时,数组已基本有序,最后一次直接插入排序即可完成整体排序。相比直接插入排序,希尔排序通过大跨度移动元素,大幅减少后续插入的移动次数,效率显著提升。
// arr:待排序数组;len:数组长度
void shell_sort(int *arr, int len)
{
int i, j, k, gap;
// gap:步长,初始为数组长度的一半,逐步减半至1
for (gap = len / 2; gap > 0; gap /= 2)
{
// 对每个步长对应的子序列执行插入排序
// i:指向每个子序列的无序区第一个元素
for (i = gap; i < len; i++)
{
k = arr[i]; // 保存待插入元素(避免移动覆盖)
j = i - gap; // j:指向当前子序列有序区最后一个元素
// 子序列内元素大于待插入值,按步长后移
while (j >= 0 && arr[j] > k)
{
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = k; // 插入到子序列的合适位置
}
}
}
关键注意点
- 步长选择影响效率:常用
len/2递减(简单易实现),最优步长(如 Knuth 序列:gap = gap*3 +1)可进一步优化; - 稳定性:不稳定(相同值的元素可能因步长分组被打乱相对位置);
- 适用场景:中等规模数据(n≈1000~10000),比直接插入 / 冒泡快一个量级。
7. 堆排序:基于完全二叉树的高效排序
核心原理 :利用大顶堆(父节点值≥子节点值)的特性:
-
构建大顶堆:将无序数组调整为堆结构,保证根节点是最大值;
-
堆顶交换:将堆顶(最大值)与堆尾元素交换,缩小堆范围(已排序区 + 1);
-
堆化调整:对新堆顶执行 "下沉" 操作,恢复大顶堆特性;
-
重复步骤 2~3,直至堆范围为 1。
// 堆化调整:对以i为根的子树进行大顶堆调整
// arr:数组;len:数组总长度;i:待调整的根节点下标
void heap_adjust(int arr, int len, int i)
{
int max_idx = i; // 假设当前根节点是最大值
int left = 2i + 1; // 左子节点下标
int right = 2*i + 2;// 右子节点下标
int temp;// 左子节点更大,更新最大值下标 if (left < len && arr[left] > arr[max_idx]) max_idx = left; // 右子节点更大,更新最大值下标 if (right < len && arr[right] > arr[max_idx]) max_idx = right; // 最大值不是根节点,交换并递归调整受影响的子树 if (max_idx != i) { temp = arr[i]; arr[i] = arr[max_idx]; arr[max_idx] = temp; // 递归调整交换后的子节点 heap_adjust(arr, len, max_idx); }}
// 堆排序主函数
void heap_sort(int *arr, int len)
{
int i, temp;
// 1. 构建大顶堆(从最后一个非叶子节点开始倒序调整)
// 最后一个非叶子节点下标:len/2 - 1
for (i = len/2 - 1; i >= 0; i--)
heap_adjust(arr, len, i);// 2. 逐个取出堆顶(最大值)放到数组尾部 for (i = len - 1; i > 0; i--) { // 堆顶(arr[0])与当前堆尾交换 temp = arr[0]; arr[0] = arr[i]; arr[i] = temp; // 3. 调整剩余堆(范围缩小为0~i-1) heap_adjust(arr, i, 0); }}
关键注意点
- 稳定性:不稳定(堆化交换会打乱等值元素的相对位置);
- 空间复杂度:O (1)(原地排序),适合内存受限的大数据场景;
- 时间复杂度:始终 O (nlogn)(无最坏情况退化),但常数项比快排大,实际效率略低。
8. 归并排序:稳定的分治排序
核心原理 :采用分治思想:
-
分:将数组递归拆分为左右两个子数组,直到子数组长度为 1(天然有序);
-
治:将两个有序子数组合并为一个有序数组;
-
合:逐层合并子数组,最终得到完整的有序数组。
// 合并两个有序子数组:arr[left...mid] 和 arr[mid+1...right]
// temp:临时数组,用于存储合并结果(避免频繁申请内存)
void merge(int *arr, int left, int mid, int right, int *temp)
{
int i = left; // 左子数组起始下标
int j = mid + 1;// 右子数组起始下标
int k = 0; // temp数组的下标// 合并两个有序子数组到temp while (i <= mid && j <= right) { // 稳定合并:左子数组元素≤右子数组时优先取左(保证稳定性) if (arr[i] <= arr[j]) temp[k++] = arr[i++]; else temp[k++] = arr[j++]; } // 左子数组剩余元素拷贝到temp while (i <= mid) temp[k++] = arr[i++]; // 右子数组剩余元素拷贝到temp while (j <= right) temp[k++] = arr[j++]; // 将temp中的有序数据拷贝回原数组 k = 0; while (left <= right) arr[left++] = temp[k++];}
// 归并排序递归函数
void merge_sort_recur(int *arr, int left, int right, int *temp)
{
if (left >= right) // 子数组长度为1,递归终止
return;int mid = (left + right) / 2; // 递归拆分左子数组 merge_sort_recur(arr, left, mid, temp); // 递归拆分右子数组 merge_sort_recur(arr, mid+1, right, temp); // 合并左右有序子数组 merge(arr, left, mid, right, temp);}
// 归并排序入口函数(封装递归,简化调用)
void merge_sort(int *arr, int len)
{
// 申请临时数组(仅一次,避免递归中频繁申请)
int *temp = (int *)malloc(len * sizeof(int));
if (temp == NULL) // 内存申请失败处理
{
printf("内存申请失败!\n");
return;
}
merge_sort_recur(arr, 0, len-1, temp);
free(temp); // 释放临时内存
}
关键注意点
- 稳定性:稳定(合并时优先取左子数组的等值元素);
- 空间复杂度:O (n)(需临时数组存储合并结果);
- 适用场景:对稳定性有要求的大规模数据(如电商订单排序:价格相同按下单时间)。
9. 快速排序(非递归版):避免栈溢出的生产级实现
前文递归版快排存在栈溢出风险,非递归版用栈模拟递归过程,适合生产环境大规模数据排序。
#include <stdio.h>
#include <stdlib.h>
// 分区函数:返回基准值最终位置(和递归版逻辑一致)
int partition(int *arr, int left, int right)
{
int i = left, j = right;
int pivot = arr[left]; // 基准值(可优化为三数取中)
while (i < j)
{
// 从右往左找小于基准值的元素
while (arr[j] >= pivot && i < j)
j--;
if (i < j)
arr[i++] = arr[j];
// 从左往右找大于基准值的元素
while (arr[i] <= pivot && i < j)
i++;
if (i < j)
arr[j--] = arr[i];
}
arr[i] = pivot; // 基准值归位
return i;
}
// 非递归快速排序(用栈模拟递归)
void quick_sort_non_recur(int *arr, int len)
{
if (len <= 1)
return;
// 用数组模拟栈,存储待排序区间的左右下标
int *stack = (int *)malloc(2 * len * sizeof(int));
if (stack == NULL)
{
printf("内存申请失败!\n");
return;
}
int top = -1; // 栈顶指针(初始为-1,空栈)
// 初始入栈:整个数组的左右边界
stack[++top] = 0;
stack[++top] = len - 1;
// 栈非空时循环
while (top >= 0)
{
// 出栈:先取右边界,再取左边界
int right = stack[top--];
int left = stack[top--];
// 分区,得到基准值位置
int pivot_idx = partition(arr, left, right);
// 左子区间(left ~ pivot_idx-1)入栈(长度>1才入栈)
if (pivot_idx - 1 > left)
{
stack[++top] = left;
stack[++top] = pivot_idx - 1;
}
// 右子区间(pivot_idx+1 ~ right)入栈(长度>1才入栈)
if (pivot_idx + 1 < right)
{
stack[++top] = pivot_idx + 1;
stack[++top] = right;
}
}
free(stack); // 释放栈内存
}
四、全场景排序算法选型指南
结合算法特性与业务需求,以下是精准的选型方案:
| 业务场景 | 最优算法 | 核心原因 |
|---|---|---|
| 小数据量(n<100) | 冒泡 / 直接插入 | 代码简单、调试成本低,无需复杂优化 |
| 数据基本有序 | 直接插入排序 | 效率接近\(O(n)\),无额外空间开销 |
| 大结构体数组排序(交换成本高) | 简单选择排序 | 交换次数最少,降低结构体拷贝成本 |
| 中等规模数据(n≈1000~10000) | 希尔排序 | 平衡效率与实现复杂度,比插入排序快一个量级 |
| 大规模无稳定性要求(常规场景) | 非递归优化版快速排序 | 避免栈溢出,平均时间复杂度最优 |
| 大规模且要求稳定性(如订单排序) | 归并排序 | 稳定 + O (nlogn),牺牲空间换业务正确性 |
| 内存受限的大数据场景(如嵌入式) | 堆排序 | 原地排序(O (1) 空间),时间复杂度稳定 |