数据结构:排序算法详解

一、引言:排序是什么、为什么重要

排序就是把一组数据按某种规则(如从小到大、从大到小)重新排列成有序序列。

为什么重要?

  • 查得快:有序数据能用二分查找,复杂度从 O(n) 降到 O(log n)。
  • 很多问题的基础:找第 k 大、去重、合并、求中位数,都依赖有序。

二、评价排序的三个维度

1. 时间复杂度

随数据量 n 增长的运行时间,通常关注 最好 / 平均 / 最坏 三种情况。

2. 空间复杂度

除输入数据外额外占用的内存(是否"原地排序")。

  • 原地排序:空间 O(1),如冒泡、选择、插入、堆排序。
  • 非原地:需要额外空间,如归并(O(n))、计数(O(k))。

3. 稳定性

相等的元素排序后,相对顺序是否保持不变。

  • 稳定[5, 3, 5, 1] → 排序后 5a 仍在 5b 前面。
  • 不稳定5b 跑到 5a 前面。

为什么重要?比如先按成绩排、再按班级排,若不稳定,同班同分的学生顺序会被打乱。

4. 复杂度记号速记

记号 含义 常见算法
O(1) 常数时间 交换、取数组元素
O(log n) 对数时间 二分查找
O(n) 线性时间 计数排序、一趟扫描
O(n log n) 线性对数 归并、快排、堆排
O(n²) 平方时间 冒泡、选择、插入

三、O(n²) 简单排序

3.1 冒泡排序

核心思想:相邻元素两两比较,大的往后"冒",每一轮把最大值冒到最后。

实现步骤

  1. 从第一个元素开始,比较相邻两个元素。
  2. 若前一个 > 后一个,则交换。
  3. 继续往后,直到把最大值冒到本轮末尾。
  4. 下一轮忽略已就位的末尾元素,重复步骤 1~3。
  5. 若某一轮没有发生任何交换,说明已有序,提前结束。

代码

cpp 复制代码
void bubbleSort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int flag = 0;//假设有序
        for (int j = 0; j < n - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                int t = arr[j]; 
                arr[j] = arr[j + 1]; 
                arr[j + 1] = t;
                flag = 1;//发生交换,说明无序
            }
        }
        if (!flag) break;   // 本轮无交换,已有序
    }
}
最好时间 O(n)(已有序,加标志位)
平均/最坏 O(n²)
空间 O(1)
稳定 ✅ 稳定

3.2 选择排序

核心思想 :每一轮从未排序区选出最小元素,交换到已排序区末尾。

实现步骤

  1. 假设未排序区第一个位置 i 是最小值位置。
  2. [i+1, n-1] 扫描,找到真正的最小值下标 minIdx
  3. minIdx != i,交换 arr[i]arr[minIdx]
  4. i 加 1,重复步骤 1~3,共 n-1 轮。

代码

cpp 复制代码
void selectionSort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int minIdx = i;
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIdx])
                minIdx = j;
        }
        if (minIdx != i) {
            int t = arr[i]; 
            arr[i] = arr[minIdx]; 
            arr[minIdx] = t;
        }
    }
}
项目
最好/平均/最坏时间 都是 O(n²)(无提前结束)
比较次数 固定 n(n-1)/2
交换次数 最多 n-1(最少 0)
空间 O(1)
稳定 ❌ 不稳定

为什么不稳定swap(&arr[i], &arr[minIdx]) 是一次长距离跳跃交换,会越过相等的元素。

反例:[5a, 8, 5b, 2],第 1 轮把 2 与 5a 交换 → [2, 8, 5b, 5a],两个 5 顺序颠倒。


3.3 插入排序

核心思想 :像打扑克牌,把当前元素插入到已排序部分中的正确位置。

实现步骤

  1. 从第 2 个元素开始,把它记为 key(第一个元素视为已排序)。
  2. 往前比较,比 key 大的元素都右移一位,腾出空位。
  3. key 放入空位。
  4. 继续处理下一个元素,直到末尾。

代码

cpp 复制代码
void insertionSort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int key = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {   // 比 key 大的右移
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;                  // 放入正确位置
    }
}
项目
最好时间 O(n)(已有序)
平均/最坏 O(n²)
空间 O(1)
稳定 ✅ 稳定

注意:while (arr[j] > key) 用严格大于保证稳定(相同值不会右移)。

特点:对"近有序"数据极快,常被用作快排/归并的小数组优化。


3.4 希尔排序(Shell Sort)

核心思想 :插入排序的改进。先用较大间隔 gap 分组做插入排序,让元素大步提前就位,再逐步缩小 gap,最后 gap=1 时就是普通插入排序(此时数据已近有序,接近 O(n))。

实现步骤

  1. 取初始 gap(常用 n/2)。
  2. 对间距为 gap 的元素分别做插入排序。
  3. 缩小 gap(常用 gap/2),重复步骤 2。
  4. 直到 gap = 1,完成最后一次整体插入排序。

代码

cpp 复制代码
void shellSort(int arr[], int n) {
    for (int gap = n / 2; gap > 0; gap /= 2) {
        for (int i = gap; i < n; i++) {
            int key = arr[i];
            int j = i;
            while (j >= gap && arr[j - gap] > key) {
                arr[j] = arr[j - gap];
                j -= gap;
            }
            arr[j] = key;
        }
    }
}
项目
最好时间 O(n log n)(取决于 gap 序列)
平均时间 O(n^1.3) 左右
最坏时间 O(n²)
空间 O(1)
稳定 ❌ 不稳定

四、O(n log n) 高级排序

这几种算法利用分治或堆结构,把复杂度降到 O(n log n),是真正实用的一档。

4.1 归并排序

核心思想分治法 。递归地把数组对半拆分,直到每段只剩一个元素(天然有序),再两两合并成有序段。

实现步骤

  1. 拆分 :找到中点 mid,把当前区间 [left, right] 拆成 [left, mid][mid+1, right]
  2. 递归:对左右两半分别递归排序。
  3. 合并:用双指针比较两半,谁小先取谁,放入临时数组。
  4. 收尾:把剩余元素追加进临时数组。
  5. 拷贝:把临时数组的有序结果拷回原数组。

代码

cpp 复制代码
void _MergeSort(int* arr, int left, int right, int* tmp)
{
	if (left >= right)
	{
		return;
	}
	int mid = (left + right) / 2;// 左半:[left, mid]   右半:[mid+1, right]
	_MergeSort(arr, left, mid, tmp);// 先让左边有序
	_MergeSort(arr, mid+1, right, tmp);// 再让右边有序
	int begin1 = left, end1 = mid;        // 左序列:[begin1, end1]
    int begin2 = mid + 1, end2 = right;   // 右序列:[begin2, end2]
    int index = left;                     // tmp 的写入位置,从 left 开始
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] <= arr[begin2])
		{
			tmp[index++] = arr[begin1++];
		}
		else
		{
			tmp[index++] = arr[begin2++];
		}
	}
	while (begin1 <= end1)// 右边先排完,把左边剩下的接上
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)// 左边先排完,把右边剩下的接上
	{
		tmp[index++] = arr[begin2++];
	}
	for (int i = left; i <= right; i++)
	{
		arr[i] = tmp[i];
	}
}
void MergeSort(int* arr, int n)
{
	int* tmp = (int*)malloc(n * sizeof(int));
	if (tmp == NULL)
	{
		perror("malloc");
		return;
	}
	_MergeSort(arr, 0, n - 1, tmp);
	free(tmp);
	tmp = NULL;
}
项目
最好/平均/最坏时间 都是 O(n log n)
空间 O(n)(辅助数组)
稳定 ✅ 稳定(等值优先取左边)

特点 :性能最稳定,适合外部排序(数据在磁盘、内存放不下)和链表排序。


4.2 快速排序------ Lomuto 与 Hoare 两种分区

核心思想分治法 。选一个基准值 pivot ,把小于它的放左边、大于它的放右边(分区),再递归排序左右两部分。

实现步骤:① 选 pivot(Lomuto 取最右,Hoare 取最左)→ ② 分区成左小右大 → ③ 递归左右两部分 → ④ 子数组只剩一个元素时停止。

版本一:Lomuto 分区(单指针,pivot 取末尾)
  • 返回的 p 就是 pivot 的最终位置
  • 递归区间用 [low, p-1][p+1, high]
cpp 复制代码
int partitionLomuto(int arr[], int low, int high) {
      int pivot = arr[high];          // 选最右元素作为基准
    int i = low - 1;                  // i 指向"小于等于区"的末尾
    for (int j = low; j < high; j++) 
    {                                 // j 从左往右扫描
        if (arr[j] <= pivot) 
        {                             //  发现小于等于基准的元素
            i++;                      //  扩展"小于等于区"
            swap(&arr[i], &arr[j]);   //  把它换到该区末尾
        }
    }
    swap(&arr[i + 1], &arr[high]);    // 把基准放到正确位置
    return i + 1;                     // 返回基准的最终下标
}
void quickSortLomuto(int arr[], int low, int high) {
   if (low < high) 
{                                                   //  递归终止条件
        int p = partitionLomuto(arr, low, high);    //  分区,拿到基准位置
        quickSortLomuto(arr, low, p - 1);           //  递归左半(不含 p)
        quickSortLomuto(arr, p + 1, high);          //  递归右半(不含 p)
}
}

注意 :这里跳过了 p (用 p-1p+1),因为 arr[p] 已经排好了。这正是 Lomuto 方案的特点------返回值就是基准的最终位置。

版本二:Hoare 分区(双指针相向,pivot 取开头)
  • 返回的 j 只是分界点,pivot 不一定在最终位置
  • 递归区间用 [low, p][p+1, high]
cpp 复制代码
int _partitionHoare(int* arr, int left, int right)
{
	int key = left;
	left++;
	while (left <= right)
	{
		while (left <= right && arr[key] < arr[right])
		{
			right--;
		}
		while (left <= right && arr[key] > arr[left])
		{
			left++;
		}
		if (left <= right)
		{
			swap(&arr[left++], &arr[right--]);
		}
	}
	swap(&arr[key], &arr[right]);
	return right;
}
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
		return;
	int key = _partitionHoare(arr, left, right);
	QuickSort(arr, left, key - 1);
	QuickSort(arr, key + 1, right);
}

外层循环

复制代码
while (left <= right)

只要两指针没有交错,就继续处理。

右指针左移:找"≤ 基准"的元素

复制代码
while (left <= right && arr[key] < arr[right])
	right--;
  • arr[right] 大于 基准时,说明它本来就该待在右半区,right-- 继续往前找。
  • 停下来的位置 arr[right] <= 基准,这个元素应该被换到左边去

左指针右移:找"≥ 基准"的元素

复制代码
while (left <= right && arr[key] > arr[left])
	left++;
  • arr[left] 小于 基准时,它本来就该待在左半区,left++ 继续往后找。
  • 停下来的位置 arr[left] >= 基准,这个元素应该被换到右边去

交换错位的一对

复制代码
if (left <= right)
	swap(&arr[left++], &arr[right--]);
  • 此时 arr[left] 是左边"偏大"的,arr[right] 是右边"偏小"的,正好错位,交换它们。
  • 交换后 left++right--,两指针各前进一格。
  • if (left <= right) 的判断很关键:如果两指针已经交错,说明没有可交换的元素了,直接跳过。

基准归位

复制代码
swap(&arr[key], &arr[right]);

循环结束时 left > rightright 就是左半区的最后一个位置 。把基准从 key 换到 right,基准就落在了最终正确位置上。

返回基准下标

复制代码
return right;

返回基准的最终位置,供 QuickSort 递归使用。

项目
最好/平均时间 O(n log n)
最坏时间 O(n²)(pivot 选到最大/最小,如已有序数组)
空间 O(log n)(递归栈)
稳定 ❌ 不稳定

关键坑(重点记忆)

Lomuto Hoare
pivot 位置 arr[high] arr[low]
返回分界点 pivot 最终位置 只是分界点,pivot 不在最终位置
递归区间 [low, p-1][p+1, high] [low, p][p+1, high]
交换次数 较多 较少(约 1/3)
速度 稍慢 更快

优化建议:随机选 pivot 或三数取中,避免有序数组退化 O(n²);小数组改用插入排序。


4.3 堆排序

核心思想 :利用大顶堆(父节点 ≥ 子节点的完全二叉树)。反复取堆顶最大值放到末尾,再重建堆。

实现步骤:① 建大顶堆(从最后一个非叶子节点自底向上逐个"下沉")→ ② 把堆顶(最大)与末尾元素交换 → ③ 堆大小减 1 → ④ 让新堆顶下沉 → ⑤ 重复直到堆空。

代码

cpp 复制代码
void swap(int* x, int* y) { int t = *x; *x = *y; *y = t; }

// 大顶堆下沉:把 parent 位置的元素下沉到正确位置
void adjustDown(int arr[], int n, int parent) {   // (arr, 堆大小, 父节点)
    int child = parent * 2 + 1;                    // 左孩子
    while (child < n) {
        if (child + 1 < n && arr[child] < arr[child + 1])
            child++;                               // 选较大的孩子
        if (arr[child] > arr[parent]) {
            swap(&arr[child], &arr[parent]);
            parent = child;
            child = parent * 2 + 1;
        } else {
            break;    // 已满足堆性质,避免死循环!
        }
    }
}

void heapSort(int arr[], int n) {
    // 1. 建大顶堆:从最后一个非叶子节点 (n-2)/2 往前逐个下沉
    for (int i = (n - 2) / 2; i >= 0; i--)
        adjustDown(arr, n, i);

    // 2. 依次把最大值放到末尾
    for (int end = n - 1; end > 0; end--) {
        swap(&arr[0], &arr[end]);        // 堆顶(最大)换到末尾
        adjustDown(arr, end, 0);          // 缩小堆后下沉堆顶
    }
}
项目
最好/平均/最坏时间 都是 O(n log n)
空间 O(1)(原地)
稳定 ❌ 不稳定

重点坑

  1. 最后一个非叶子节点下标是 (n - 2) / 2,建堆循环从这里往前。
  2. adjustDown 一定要有 else break,否则满足堆性质时会死循环。
  3. 参数顺序要一致:本实现是 (arr, size, parent),调用时别写反。

特点:原地、无递归栈风险、最坏情况也稳定在 O(n log n),适合内存受限、需要最坏性能保证的场景。


五、O(n) 非比较排序

这三类算法不靠比较元素大小,而是利用值分布的规律,能突破 O(n log n) 的比较排序下界。但有前提:数据是整数、取值范围有限。

5.1 计数排序

核心思想:统计每个值出现的次数,再按值从小到大顺序展开。范围大时用偏移(min)法支持负数。

实现步骤

  1. 找最小值 min 和最大值 max,算出范围 range = max - min + 1
  2. 开计数数组 count[range],统计每个值出现次数(下标记 arr[i] - min)。
  3. 求前缀和:count[i] 表示"值 ≤ i 的元素个数"。
  4. 从后往前遍历原数组,把每个元素放到正确位置(保证稳定)。
  5. 拷贝回原数组。

代码

cpp 复制代码
void CountSort(int* arr, int n)
{
	int min = arr[0];
	int max = arr[0];
	for (int i = 0; i < n; i++)//找最小值和最大值
	{
		if (arr[i] < min)
		{
			min = arr[i];
		}
		if (arr[i] > max)
		{
			max = arr[i];
		}
	}
	int range = max - min + 1;//值的总个数。比如 min=2、max=8,range = 7,对应值 2~8。
	int* count = (int*)malloc(range * sizeof(int));
	if (count == NULL)
	{
		perror("malloc");
		return;
	}
	memset(count, 0, sizeof(int) * range);//把数组全部置 0。
	for (int i = 0; i < n; i++)//统计每个值出现的次数
	{
		count[arr[i] - min]++;
	}
	int index = 0;
	for (int i = 0; i < range; i++)//按值从小到大铺回原数组
	{
		while (count[i]--)
		{
			arr[index++] = min + i;
		}
	}
}

arr = [3, 6, 2, 3, 8, 6, 4, 6, 8, 4]n = 10 为例。

阶段 1 :遍历得 min = 2max = 8range = 7

阶段 2count 大小 7,全 0

阶段 3:统计

阶段 4:铺回

结果[2, 3, 3, 4, 4, 6, 6, 6, 8, 8]

项目
时间 O(n + k),k 为取值范围大小
空间 O(k)(或 O(n + k))
稳定 ✅(前缀和 + 逆序)

适用整数、取值范围小(如成绩 0100、年龄 0150)。

重点

  1. 必须 free 申请的数组,否则内存泄漏。
  2. n == 0 时不能直接 arr[0],要加保护。
  3. 还原时 arr[index++] 中的 index 一定要自增。
  4. k 很大时(如范围 0~10⁹)时间和内存都爆炸,不适用。

六、总结对比

6.1 总对比表

算法 平均时间 最坏时间 空间 稳定 特点
冒泡 O(n²) O(n²) O(1) 简单,教学用
选择 O(n²) O(n²) O(1) 交换次数最少
插入 O(n²) O(n²) O(1) 小规模/近有序极快
希尔 O(n^1.3) O(n²) O(1) 插入排序改进
归并 O(n log n) O(n log n) O(n) 稳定,外部排序
快速 O(n log n) O(n²) O(log n) 实践中最快
O(n log n) O(n log n) O(1) 原地,最坏保证
计数 O(n+k) O(n+k) O(k) 整数、范围小

6.2 稳定性汇总

稳定 不稳定
冒泡、插入、归并、计数 选择、希尔、快速、堆

记忆:稳定的大多是"相邻逐步移动"(冒泡/插入/归并),或"非比较"(计数/基数)。

6.3 复杂度速记

复杂度 算法
O(n²) 冒泡、选择、插入
O(n^1.3) 希尔
O(n log n) 归并、快排、堆排
O(n) 计数
相关推荐
老当益壮梁奶奶1 小时前
51单片机中断系统详解
c语言·单片机·嵌入式硬件·学习·51单片机
青山木1 小时前
枚举类DP进阶:完全平方数与零钱兑换
java·数据结构·算法·leetcode·动态规划
CHHH_HHH1 小时前
【Linux系统篇】进程间通信揭秘:从管道到共享内存
linux·服务器·c语言·开发语言·后端·ubuntu
huang5791472 小时前
从缓存行角度优化链表遍历:批处理与预取策略
数据结构·链表·缓存
John_ToDebug9 小时前
判断力无法被 AI 替代,但它正在被你亲手扔掉
经验分享·笔记
sunshine22 girl10 小时前
Java学习二 基本语法2, 算术运算符
java·学习
2601_9622186110 小时前
万象生鲜系统业财一体化底层打通技术自动生成经营账单
大数据·数据库·人工智能·python·算法
吞下星星的少年·-·10 小时前
The 2026 ICPC Asia East Continent Online Contest (II)(构造)
数据结构·算法