☰
C++十种排序算法全解析:从原理到工程实践
2026/10/22 9:35:51 网站建设 项目流程

排序算法这东西,几乎是每个写C++的人绕不过去的一道坎。我见过太多人面试前临时抱佛脚,把十种排序的代码背一遍,结果面试官问一句"为什么快排最坏是O(n²)还比归并快"就卡壳了。也见过工作三五年的朋友,写业务代码时随手一个std::sort,从来没想过底层用的什么策略、为什么这样设计。这篇东西就是把我这些年反复翻看、反复给团队新人讲的内容整理出来,从原理到代码到复杂度到稳定性,一次说透。不管你是刚学数据结构的学生,还是准备面试的求职者,或者单纯想把基础打牢的开发者,应该都能从中拿到点实在的东西。

1. 先把"稳定性"和"复杂度"这两个词掰开揉碎

很多人一上来就背代码,结果连"稳定性"到底指什么都没搞清楚。我刚开始学的时候也这样,觉得能默写出快排就行了,后来被问"什么是稳定排序"直接懵了。所以咱们先把地基打牢。

1.1 稳定性不是"排序结果稳不稳定"

稳定性的定义是:如果两个元素的值相等,排序后它们的相对顺序和排序前保持一致,那这个算法就是稳定的。注意,是值相等的元素,不是整个数组。

举个例子,你有一组学生数据,先按姓名排好序了,现在要按班级再排一次。如果用的排序算法是稳定的,那同一个班里的学生,仍然保持着之前按姓名排好的顺序。如果用的是不稳定的排序,同一个班里的学生顺序就可能被打乱,之前那次按姓名排的工作就白做了。

这个场景在实际开发里非常常见——多级排序。比如电商后台先按销量排、再按价格排,如果第二次排序不稳定,第一次的结果就废了。所以稳定性不是学术概念,是实打实会影响业务逻辑的。

1.2 时间复杂度的三种情况要分开看

时间复杂度分最好、最坏、平均三种。很多人只记一个平均复杂度,这是不够的。

拿快速排序来说,平均O(n log n),最坏O(n²)。什么时候最坏?当每次选的基准值都是当前区间最大或最小的时候,分区极度不平衡,递归深度变成n。这在实践中完全可能发生——比如对一个已经有序的数组用最朴素的快排,每次选第一个元素做基准,那就是妥妥的O(n²)。

所以看复杂度不能只看一个数,要理解它在什么条件下会退化。这也是面试官最爱追问的点。

1.3 空间复杂度里藏着算法的设计哲学

空间复杂度反映的是算法的"额外"内存需求,不包括输入数组本身。

  • 冒泡、插入、选择、希尔、堆排序都是O(1),属于原地排序
  • 快速排序平均O(log n),来自递归调用栈
  • 归并排序O(n),需要额外的辅助数组
  • 计数、基数、桶排序则需要根据数据范围或桶数量来确定

这里有个很有意思的权衡:归并排序用O(n)的额外空间换来了稳定性和稳定的O(n log n)时间;快排用O(log n)的栈空间换来了实践中更快的常数因子。没有免费的午餐,每个选择都有代价。

2. 比较类排序:从O(n²)到O(n log n)的进化之路

比较类排序是指通过比较元素大小来决定顺序的算法。理论上,这类算法的下界是O(n log n)——也就是说,你不可能比这更快了。下面按复杂度从高到低逐个拆解。

2.1 冒泡排序:最直观但也最慢

冒泡排序的思路就像水里的气泡往上冒——每一轮把最大的元素"推"到末尾。

void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { bool swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; // 优化:没有交换说明已经有序 } }

那个swapped标志位是必须加的优化。没有它,即使数组已经有序,也要跑满O(n²)。加上之后,最好情况降到O(n)。

复杂度:最好O(n),最坏O(n²),平均O(n²),空间O(1),稳定。

实操心得:冒泡排序在实际项目中基本不会用,但它是理解"比较-交换"这个范式的最佳入口。我建议新手一定要手写一遍,感受一下双重循环的节奏。

2.2 选择排序:交换次数最少的O(n²)算法

选择排序每轮从未排序区间里找出最小值,放到已排序区间的末尾。

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) std::swap(arr[i], arr[minIdx]); } }

复杂度:最好、最坏、平均都是O(n²),空间O(1),不稳定。

为什么不稳定?假设数组是[5a, 5b, 2],第一轮找到最小值2,和第一个5a交换,变成[2, 5b, 5a]。两个5的相对顺序变了。

实操心得:选择排序的唯一优势是交换次数少——最多n-1次。如果交换操作的成本远高于比较(比如元素是很大的结构体),它反而有优势。但绝大多数场景下,这个优势可以忽略。

2.3 插入排序:小数组之王

插入排序像打扑克时整理手牌——每来一张新牌,插入到已排好的牌里合适的位置。

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) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }

复杂度:最好O(n)(已经有序),最坏O(n²)(逆序),平均O(n²),空间O(1),稳定。

实操心得:插入排序是O(n²)算法里最有实战价值的。当数据量小于16~32时,插入排序往往比快排还快——因为它的常数因子极小,没有递归开销。这也是为什么很多标准库的std::sort在递归到小数组时会切换到插入排序。这个优化策略叫introsort,后面会细说。

2.4 希尔排序:插入排序的进化版

希尔排序的核心思想是:先用较大的间隔做插入排序,让数组"基本有序",再逐步缩小间隔,最后做一次间隔为1的插入排序。

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 - gap; while (j >= 0 && arr[j] > key) { arr[j + gap] = arr[j]; j -= gap; } arr[j + gap] = key; } } }

复杂度:取决于间隔序列。用n/2这种朴素序列,最坏O(n²);用Hibbard序列可以到O(n^1.5);用Sedgewick序列能到O(n^1.3)左右。空间O(1),不稳定。

实操心得:希尔排序的价值在于它打破了O(n²)的壁垒,是第一个时间复杂度突破平方的算法。虽然实践中被快排和归并碾压,但它的思路——"先粗排再细排"——非常值得学习。另外,间隔序列的选择对性能影响巨大,我实测下来,对于中等规模数据(几千到几万),Sedgewick序列比朴素的n/2序列快30%以上。

2.5 快速排序:实践中最快的比较排序

快排是分治法的经典应用:选一个基准值,把数组分成"小于基准"和"大于基准"两部分,然后递归处理两边。

int partition(int arr[], int low, int high) { int pivot = arr[high]; // 选最后一个元素做基准 int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; std::swap(arr[i], arr[j]); } } std::swap(arr[i + 1], arr[high]); return i + 1; } void quickSort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } }

复杂度:最好O(n log n),最坏O(n²),平均O(n log n),空间O(log n)(递归栈),不稳定。

快排的优化手段非常多,这里列几个最实用的:

  • 三数取中:选low、mid、high三个位置的中位数做基准,避免有序数组退化
  • 随机基准:随机选一个位置和末尾交换,再按上面的逻辑走
  • 小区间插入排序:当区间长度小于阈值(通常16左右)时,改用插入排序
  • 三路划分:把数组分成"小于""等于""大于"三部分,处理大量重复元素时效率极高

实操心得:快排的常数因子是所有O(n log n)算法里最小的,这就是它"实践中最快"的原因。但它的最坏情况O(n²)是真实存在的风险。工业级实现(比如std::sort)用的是introsort——快排+堆排+插入排序的混合体:递归深度超过2*log(n)时切换到堆排,保证最坏O(n log n);小区间切换到插入排序,减少递归开销。

2.6 归并排序:稳定且可预测的O(n log n)

归并排序也是分治:把数组分成两半,分别排序,然后合并两个有序数组。

void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1, n2 = right - mid; std::vector<int> L(n1), R(n2); for (int i = 0; i < n1; i++) L[i] = arr[left + i]; for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j]; int i = 0, j = 0, k = left; while (i < n1 && j < n2) { if (L[i] <= R[j]) arr[k++] = L[i++]; // <= 保证稳定性 else arr[k++] = R[j++]; } while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; } void mergeSort(int arr[], int left, int right) { if (left < right) { int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } }

复杂度:最好、最坏、平均都是O(n log n),空间O(n),稳定。

注意L[i] <= R[j]这个条件——用<=而不是<,是保证稳定性的关键。当两边元素相等时,优先取左边的,这样左边(原数组中靠前)的元素就排在了前面。

实操心得:归并排序的最大优势是"可预测"——不管输入什么样,都是O(n log n)。这在实时系统里很重要。另外它是外部排序的基础——当数据大到内存放不下时,归并是唯一可行的方案。缺点是O(n)的额外空间,对于内存敏感的场景要慎重。

2.7 堆排序:原地且最坏O(n log n)

堆排序利用二叉堆的性质:大顶堆的根节点是最大值。每次把根节点和末尾交换,然后调整堆。

void heapify(int arr[], int n, int i) { int largest = i; int left = 2 * i + 1, right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { std::swap(arr[i], arr[largest]); heapify(arr, n, largest); } } void heapSort(int arr[], int n) { for (int i = n / 2 - 1; i >= 0; i--) heapify(arr, n, i); for (int i = n - 1; i > 0; i--) { std::swap(arr[0], arr[i]); heapify(arr, i, 0); } }

复杂度:最好、最坏、平均都是O(n log n),空间O(1),不稳定。

实操心得:堆排序是唯一一个"原地+最坏O(n log n)"的算法,这是它的独特价值。但实践中它比快排慢——因为它的内存访问模式对CPU缓存不友好(跳跃式访问),而快排是顺序访问。这就是为什么std::sort只在递归太深时才切换到堆排作为"保险"。

3. 非比较类排序:突破O(n log n)下界的秘密

前面说了,比较类排序的下界是O(n log n)。但如果我们不比较元素大小,而是利用元素的数值特征呢?这就是非比较类排序的思路。它们能到O(n),但有额外的限制条件。

3.1 计数排序:用空间换时间的极致

计数排序适用于数据范围不大的整数。核心思路是统计每个值出现的次数,然后直接按顺序输出。

void countingSort(int arr[], int n) { int maxVal = *std::max_element(arr, arr + n); int minVal = *std::min_element(arr, arr + n); int range = maxVal - minVal + 1; std::vector<int> count(range, 0); for (int i = 0; i < n; i++) count[arr[i] - minVal]++; for (int i = 1; i < range; i++) count[i] += count[i - 1]; // 前缀和 std::vector<int> output(n); for (int i = n - 1; i >= 0; i--) { // 倒序遍历保证稳定性 output[count[arr[i] - minVal] - 1] = arr[i]; count[arr[i] - minVal]--; } for (int i = 0; i < n; i++) arr[i] = output[i]; }

复杂度:O(n + k),其中k是数据范围。空间O(n + k),稳定。

实操心得:计数排序的"倒序遍历"是保证稳定性的关键,很多人写的时候会忽略。另外,当k远大于n时(比如数组只有10个数,但最大值是100万),计数排序会浪费大量空间,这时候就不该用它。判断标准很简单:k和n在同一量级时用,否则别用。

3.2 基数排序:按位排序的艺术

基数排序从最低位开始,对每一位做一次稳定的排序(通常用计数排序),最终得到有序结果。

void countingSortByDigit(int arr[], int n, int exp) { std::vector<int> output(n); int count[10] = {0}; for (int i = 0; i < n; i++) count[(arr[i] / exp) % 10]++; for (int i = 1; i < 10; i++) count[i] += count[i - 1]; for (int i = n - 1; i >= 0; i--) { output[count[(arr[i] / exp) % 10] - 1] = arr[i]; count[(arr[i] / exp) % 10]--; } for (int i = 0; i < n; i++) arr[i] = output[i]; } void radixSort(int arr[], int n) { int maxVal = *std::max_element(arr, arr + n); for (int exp = 1; maxVal / exp > 0; exp *= 10) countingSortByDigit(arr, n, exp); }

复杂度:O(d * (n + k)),d是最大数的位数,k是基数(这里是10)。空间O(n + k),稳定。

实操心得:基数排序的每一轮必须是稳定排序,否则前面的工作全废。这就是为什么它内部用计数排序而不是快排。另外,基数排序对负数处理比较麻烦,通常的做法是把负数分开处理或者加偏移量转成正数。

3.3 桶排序:分而治之的另一种形态

桶排序把数据分到若干个桶里,每个桶内部单独排序,最后按顺序合并。

void bucketSort(float arr[], int n) { std::vector<std::vector<float>> buckets(n); for (int i = 0; i < n; i++) { int idx = n * arr[i]; // 假设arr[i]在[0,1)范围内 buckets[idx].push_back(arr[i]); } for (int i = 0; i < n; i++) std::sort(buckets[i].begin(), buckets[i].end()); int idx = 0; for (int i = 0; i < n; i++) for (float val : buckets[i]) arr[idx++] = val; }

复杂度:平均O(n + k),最坏O(n²)(所有元素落在一个桶里)。空间O(n + k),稳定性取决于桶内排序算法。

实操心得:桶排序的性能高度依赖数据分布。如果数据均匀分布,每个桶里元素很少,接近O(n);如果数据严重倾斜,退化成O(n²)。所以用之前一定要先看看数据的分布特征。我一般会先画个直方图,确认分布均匀再用。

4. 十种算法横向对比:一张表看清所有差异

前面逐个拆解了十种算法,现在把它们放在一起对比。这张表我建议你收藏,面试前扫一眼就能回忆起关键点。

算法最好最坏平均空间稳定性适用场景
冒泡排序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 log n)O(n²)取决于序列O(1)不稳定中等规模数据
快速排序O(n log n)O(n²)O(n log n)O(log n)不稳定通用场景首选
归并排序O(n log n)O(n log n)O(n log n)O(n)稳定需要稳定性、外部排序
堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定内存受限、最坏情况保证
计数排序O(n+k)O(n+k)O(n+k)O(n+k)稳定整数、范围小
基数排序O(d(n+k))O(d(n+k))O(d(n+k))O(n+k)稳定整数、位数少
桶排序O(n+k)O(n²)O(n+k)O(n+k)取决于桶内均匀分布的浮点数

从这张表能看出几个关键结论:

第一,没有全能选手。快排平均最快但不稳定、最坏会退化;归并稳定且可预测但费内存;堆排原地且最坏有保证但缓存不友好。选哪个取决于你的具体约束。

第二,稳定性是有代价的。稳定的算法(归并、计数、基数、桶)要么费空间,要么有数据范围限制。不稳定的算法(快排、堆排、选择、希尔)通常在性能或空间上更有优势。

第三,非比较排序不是万能的。它们能突破O(n log n),但只适用于特定类型的数据(整数、范围有限、分布均匀)。通用场景下还是得靠比较类排序。

5. 工程实践中的排序:标准库是怎么做的

理论讲完了,现在聊聊实际工程中怎么用。你平时写的std::sort,底层到底是什么?

5.1 std::sort的introsort策略

std::sort用的是introsort,可以理解为"带保险的快排":

  1. 主体是快速排序,用三数取中选基准
  2. 递归深度超过2*log(n)时,切换到堆排序,保证最坏O(n log n)
  3. 区间长度小于阈值(通常是16)时,切换到插入排序

这个策略的精妙之处在于:它结合了快排的平均性能、堆排的最坏保证、插入排序的小数组优势。三种算法的优点全占了。

std::sort是不稳定的。如果你需要稳定排序,用std::stable_sort,它底层是归并排序(或者归并+插入的混合),时间复杂度O(n log n),空间O(n)。

5.2 什么时候该自己写排序

绝大多数情况下,直接用std::sort就对了。但以下几种情况可能需要自己实现:

  • 数据有特殊结构:比如数据范围很小,用计数排序能到O(n),比std::sort的O(n log n)快得多
  • 需要部分排序:只需要前k个有序,用std::partial_sort或者自己写堆选择
  • 内存极度受限:堆排序是O(1)空间,比std::sort的O(log n)更省
  • 需要自定义稳定性语义:标准库的稳定性定义可能不满足你的需求

实操心得:我见过有人为了"性能"自己手写快排,结果没做三数取中,遇到有序数据直接退化成O(n²),比std::sort慢了几百倍。除非你明确知道数据特征并且做了针对性优化,否则别轻易替换标准库。

5.3 一个真实的性能对比测试

我之前做过一组测试,对100万个随机整数排序,结果大致如下(不同机器会有差异,但相对关系稳定):

方法耗时(相对值)
std::sort1.0
手写快排(三数取中+小区间插入)1.1
手写快排(朴素版)1.8
std::stable_sort1.5
堆排序2.5
归并排序1.6

可以看到,std::sort确实是最快的,手写快排即使做了优化也略慢——因为标准库还做了很多底层优化(比如针对特定类型的特化、SIMD指令等)。朴素快排慢了80%,这就是不做优化的代价。

6. 面试高频追问与避坑指南

最后聊聊面试和实战中容易踩的坑。这部分是我这些年被问过、也问过别人的问题,含金量比较高。

6.1 "快排最坏O(n²),为什么还比归并快?"

这是最经典的追问。答案有三层:

第一,平均情况下快排的常数因子更小。快排的每次分区只需要一次遍历,而归并需要额外的数组拷贝。虽然都是O(n log n),但快排的实际操作次数更少。

第二,快排是原地排序,缓存友好。快排的顺序访问模式对CPU缓存非常友好,而归并需要频繁读写辅助数组,缓存命中率低。

第三,最坏情况可以通过优化避免。三数取中+随机化之后,遇到最坏情况的概率极低。再加上introsort的深度限制,实际工程中快排的最坏情况基本不会出现。

6.2 "为什么归并排序的空间复杂度是O(n)而不是O(n log n)?"

很多人会想:递归log n层,每层合并需要O(n)的辅助空间,那总共不是O(n log n)吗?

关键在于:辅助空间是可以复用的。每一层的合并完成后,辅助数组就被释放了,下一层可以重新使用。同一时刻只有一条递归路径上的合并在进行,所以峰值空间是O(n),不是O(n log n)。

这个点面试官很爱考,因为它区分了"是否真正理解递归的空间使用"。

6.3 "稳定性在实际开发中什么时候重要?"

前面提过多级排序,这里再补充一个场景:数据库的ORDER BY。SQL标准要求排序是稳定的,因为用户可能先按A列排、再按B列排,期望A列相同的行保持之前的顺序。如果数据库用的排序算法不稳定,这个期望就会落空。

另一个场景是日志分析:先按时间排,再按级别排,希望同一级别的日志保持时间顺序。这也是稳定性的典型应用。

6.4 手写代码时的常见bug

我review过很多人的排序代码,几个高频bug:

  • 快排的边界条件:while (i < j)还是while (i <= j),差一个等号结果完全不同
  • 归并的稳定性:<=写成<,稳定性就没了
  • 计数排序的前缀和:忘了做前缀和,直接输出count数组,结果全错
  • 堆排序的索引:2*i+1和2*i+2,从0开始和从1开始的公式不一样
  • 希尔排序的间隔:gap /= 2最后要能到1,否则最后一轮没排

实操心得:手写排序代码,写完一定要用几组边界数据测一下——空数组、单元素、两个元素、全部相同、已经有序、完全逆序。这六组数据能覆盖90%的边界bug。

6.5 一个容易被忽略的优化:哨兵

插入排序里,while (j >= 0 && arr[j] > key)这个条件每次都要判断j >= 0。如果在数组最前面放一个比所有元素都小的哨兵,就可以省掉这个判断:

// 假设arr[0]是哨兵,比所有元素都小 void insertionSortWithSentinel(int arr[], int n) { for (int i = 2; i < n; i++) { int key = arr[i]; int j = i - 1; while (arr[j] > key) { // 不用判断j >= 0 arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }

这个优化在数据量大时能省不少时间。虽然现代编译器可能会做类似优化,但显式写出来更保险。

7. 我个人的学习路径建议

如果你刚开始学排序,我的建议是:

第一步,理解原理。不要急着写代码,先用纸笔画出每种算法的执行过程。特别是快排的分区、归并的合并、堆的调整,这三个是核心。

第二步,手写代码。不看参考,自己从零写一遍。写完再对照标准实现,看看哪里不一样、为什么。

第三步,分析复杂度。对每一行代码,问自己"这行执行了多少次"。把最好、最坏、平均三种情况都推一遍。

第四步,实际测试。写个简单的benchmark,用不同规模、不同分布的数据测一下,感受理论和实际的差距。

第五步,看标准库实现。去读std::sort的源码(libstdc++或libc++),看看工业级代码是怎么做的。这一步能让你从"会写"进阶到"懂工程"。

排序算法是计算机科学里少有的"既基础又实用"的知识点。它不像某些理论课那样学完就忘,而是你写代码时随时可能用到的。把这十种算法吃透,不只是为了面试,更是为了在遇到性能问题时,脑子里有足够的工具去分析和解决。

我在实际项目里遇到过好几次因为排序选择不当导致的性能问题——有一次是用了不稳定的排序做多级排序,结果数据错乱;还有一次是对小数组用了快排,递归开销比排序本身还大。这些坑踩过之后,才真正理解为什么"没有最好的算法,只有最合适的算法"这句话是对的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询