今天来系统地梳理一下常见的排序算法。包括冒泡、选择、插入、希尔、归并、快排、堆排,还有非比较类的如计数、桶排、基数。排序是算法的基础,深入理解它们对编程思维和性能优化至关重要。
接下来将从简单到复杂,逐一剖析它们的核心思想、步骤、复杂度、优缺点,以及C++的实现代码以及适用场景。
1. 概述
排序算法可以分为两大类:
- 比较类排序:通过比较元素间的相对次序来进行排序。其平均时间复杂度不可能突破 O(n log n)。
- 非比较类排序:不通过比较来决定元素次序,而是利用额外的信息(如整数的数值范围)。它可以突破基于比较排序的时间下界,达到线性时间复杂度,但通常有特定的适用条件。
2. 常见排序算法详解
一、 冒泡排序 (Bubble Sort)
-
核心思想:重复地遍历待排序序列,一次比较两个相邻元素,如果它们的顺序错误就把它们交换过来。遍历序列的工作是重复地进行直到没有再需要交换,也就是说该序列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
-
过程 :
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个(以及后续已经排序好的)。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
-
C++实现 :
cppvoid bubbleSort(vector<int>& arr) { int n = arr.size(); 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]) { swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; // 本轮无交换,提前结束 } }
-
复杂度分析 :
- 时间复杂度:
- 最好情况(已有序):O(n)(优化后)。
- 最坏/平均情况:O(n²)。
- 空间复杂度:O(1),是原地排序。
- 稳定性 :稳定(相等元素不会交换)。
- 时间复杂度:
-
评价:效率很低,除了教学和极小的数据集,基本不会在实际中使用。
二、 选择排序 (Selection Sort)
-
核心思想:在未排序序列中找到最小(或最大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
-
过程 :
- 在序列中找到最小元素。
- 将其与序列第一个元素交换(如果第一个元素就是最小元素则和自己交换)。
- 在剩余未排序序列中重复上述过程。
-
C++实现 :
cppvoid selectionSort(vector<int>& arr) { int n = arr.size(); 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; } } swap(arr[i], arr[minIndex]); // 将找到的最小值放到已排序序列的末尾 } }
-
复杂度分析 :
- 时间复杂度:无论什么数据输入都是 O(n²)。因为寻找最小值的循环必须执行完。
- 空间复杂度:O(1),是原地排序。
- 稳定性 :不稳定 。例如序列
[5, 8, 5, 2, 9]
,第一个5
会和2
交换,导致两个5
的相对顺序改变。
-
评价:相比冒泡排序,它的交换次数更少(最多交换 n-1 次),但时间复杂度依然很高,适用于数据量小且对交换开销敏感的场景。
三、 插入排序 (Insertion Sort)
-
核心思想:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
-
过程 :
- 将第一个元素视为已排序序列。
- 取出下一个元素,在已经排序的元素序列中从后向前扫描。
- 如果该元素(已排序)大于新元素,将该元素移到下一位置。
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
- 将新元素插入到该位置后。
- 重复步骤2~5。
-
C++实现 :
cppvoid insertionSort(vector<int>& arr) { int n = arr.size(); for (int i = 1; i < n; ++i) { // i从1开始,认为arr[0]是已排序的 int key = arr[i]; // 待插入的元素 int j = i - 1; // 将arr[0..i-1]中比key大的元素都向后移动一位 while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; // 插入到正确位置 } }
-
复杂度分析 :
- 时间复杂度:
- 最好情况(已有序):O(n)。
- 最坏/平均情况:O(n²)。
- 空间复杂度:O(1),是原地排序。
- 稳定性 :稳定。
- 时间复杂度:
-
评价 :对于小规模或基本有序的数据集非常高效。STL中的
std::sort
在递归到小数组时也会转而使用插入排序。它是高级排序算法优化的重要组成部分。
四、 希尔排序 (Shell's Sort) - 插入排序的改进
-
核心思想:是插入排序的一种更高效的改进版本。它通过将原始数组"分组"并进行插入排序,随着增量序列的减小,数组变得越来越"部分有序",最后当增量为1时进行一次标准的插入排序,此时效率很高。
-
过程 :
- 选择一个增量序列
gap
,例如n/2, n/4, ..., 1
。 - 按增量序列个数
k
,对序列进行k
趟排序。 - 每趟排序,根据对应的增量
gap
,将待排序列分割成若干长度为gap
的子序列,分别对各子序列进行直接插入排序。 - 当增量因子为1时,整个序列作为一个表来处理,进行最后一次插入排序。
- 选择一个增量序列
-
C++实现 (使用
gap = n/2, gap /= 2
序列):cppvoid shellSort(vector<int>& arr) { int n = arr.size(); for (int gap = n / 2; gap > 0; gap /= 2) { // 增量序列 for (int i = gap; i < n; ++i) { // 从第gap个元素开始,对各个分组进行插入排序 int temp = arr[i]; int j; for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) { arr[j] = arr[j - gap]; } arr[j] = temp; } } }
-
复杂度分析 :复杂度依赖于增量序列的选择,分析非常复杂。
- 时间复杂度:使用上述增量序列最坏是 O(n²),一些更好的增量序列(如Hibbard)可以达到 O(n^(3/2)),甚至 O(n log² n)。
- 空间复杂度:O(1)。
- 稳定性 :不稳定。相同的元素可能在各自的分组排序中被打乱顺序。
-
评价:是第一批突破 O(n²) 复杂度的算法之一,实现简单,在中等规模数据上表现良好。
五、 归并排序 (Merge Sort) - 分治法
-
核心思想 :采用分治法 。将已有序的子序列合并,得到完全有序的序列。
- 分割:递归地把当前序列平均分割成两半。
- 治理:在不能再分割后,对子序列进行排序(通常子序列长度为1时自然有序)。
- 合并:将两个已经排序的子序列合并成一个有序序列。
-
过程 :关键是
merge
函数。 -
C++实现 :
cpp// 合并两个有序子数组 arr[l..m] 和 arr[m+1..r] void merge(vector<int>& arr, int l, int m, int r) { vector<int> left(arr.begin() + l, arr.begin() + m + 1); vector<int> right(arr.begin() + m + 1, arr.begin() + r + 1); int i = 0, j = 0, k = l; while (i < left.size() && j < right.size()) { if (left[i] <= right[j]) { arr[k++] = left[i++]; } else { arr[k++] = right[j++]; } } // 拷贝剩余元素 while (i < left.size()) arr[k++] = left[i++]; while (j < right.size()) arr[k++] = right[j++]; } void mergeSort(vector<int>& arr, int l, int r) { if (l >= r) return; // 终止递归条件 int m = l + (r - l) / 2; // 防止溢出,等效于 (l+r)/2 mergeSort(arr, l, m); mergeSort(arr, m + 1, r); merge(arr, l, m, r); } // 包装函数 void mergeSort(vector<int>& arr) { mergeSort(arr, 0, arr.size() - 1); }
-
复杂度分析 :
- 时间复杂度:最好、最坏、平均都是 O(n log n)。非常稳定。
- 空间复杂度:O(n) 。因为合并操作需要额外的空间。不是原地排序。
- 稳定性 :稳定 (
merge
时判断用<=
即可保证)。
-
评价:效率高且稳定,是外部排序(数据量大到在磁盘中)的基础。缺点是空间复杂度高。Java、Python等语言的通用排序算法之一(对于对象排序,稳定性很重要)。
六、 快速排序 (Quick Sort) - 分治法
-
核心思想 :同样采用分治法。选择一个"基准"元素,通过一趟排序将待排记录分割成独立的两部分,其中一部分的所有元素均比基准小,另一部分均比基准大。然后递归地对这两部分数据进行排序。
-
过程 :关键是
partition
操作。- 从数列中挑出一个元素,称为 "基准"。
- 分区:重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。
- 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。
-
C++实现 ( Lomuto partition scheme ):
cpp// 分区函数,选择最右元素为基准 int partition(vector<int>& arr, int low, int high) { int pivot = arr[high]; // 选择最右元素作为基准 int i = (low - 1); // 指向小于基准的区域的最后一个元素 for (int j = low; j <= high - 1; j++) { if (arr[j] < pivot) { // 当前元素小于基准 i++; // 扩大小于基准的区域 swap(arr[i], arr[j]); } } swap(arr[i + 1], arr[high]); // 将基准放到正确位置 return (i + 1); } void quickSort(vector<int>& arr, int low, int high) { if (low < high) { int pi = partition(arr, low, high); // pi是基准的最终位置 quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } } // 包装函数 void quickSort(vector<int>& arr) { quickSort(arr, 0, arr.size() - 1); }
(注:Hoare partition scheme 是另一种更高效的选择,但逻辑稍复杂)
-
复杂度分析 :
- 时间复杂度:
- 平均情况:O(n log n)。
- 最坏情况(数组已有序或逆序):O(n²)。但通过随机选择基准或中位数基准可以极大避免。
- 空间复杂度:平均 O(log n)(递归调用栈的深度),最坏 O(n)。是原地排序。
- 稳定性 :不稳定。分区操作会打乱顺序。
- 时间复杂度:
-
优化 :
- 随机化快排:随机选择一个元素作为基准,避免在有序数组上出现最坏情况。
- 三数取中:选择头、中、尾三个元素的中位数作为基准。
- 对于小数组切换到插入排序。
-
评价 :在平均情况下是内部排序中最快的算法,
std::sort
的核心就是基于快排的IntroSort。
七、 堆排序 (Heap Sort)
-
核心思想 :利用堆这种数据结构的特性进行排序。堆是一个近似完全二叉树,且满足父节点的值总是大于等于(或小于等于)其子节点的值。
-
过程 :
- 建堆:将待排序序列构建成一个大顶堆(升序排序用大顶堆)。
- 此时,整个序列的最大值就是堆顶的根节点。
- 将其与末尾元素进行交换,此时末尾就为最大值。
- 然后将剩余
n-1
个元素重新构造成一个大顶堆,这样会得到次大值。 - 如此反复执行,便能得到一个有序序列了。
-
C++实现 :
cpp// 调整以节点i为根的子树为大顶堆,n是堆的大小 void heapify(vector<int>& arr, int n, int i) { int largest = i; // 初始化最大值为根 int left = 2 * i + 1; // 左子节点 int 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) { swap(arr[i], arr[largest]); heapify(arr, n, largest); // 递归地调整受影响的子树 } } void heapSort(vector<int>& arr) { int n = arr.size(); // 构建大顶堆(从最后一个非叶子节点开始调整) for (int i = n / 2 - 1; i >= 0; i--) { heapify(arr, n, i); } // 一个个从堆顶取出元素 for (int i = n - 1; i > 0; i--) { swap(arr[0], arr[i]); // 将当前最大值移到数组末尾 heapify(arr, i, 0); // 在缩小的堆上重新调整 } }
-
复杂度分析 :
- 时间复杂度:建堆 O(n),每次调整 O(log n) ,总复杂度为 O(n log n) 。最好、最坏、平均都是 O(n log n)。
- 空间复杂度:O(1),是原地排序。
- 稳定性 :不稳定 。例如
[5, 5, 5]
,建堆和交换过程可能打乱顺序。
-
评价:时间复杂度稳定且是原地排序,常用于嵌入式系统等内存受限的场景。但由于数据交换是跳跃式的,缓存不友好,实际性能常不如好的快排实现。
八、 非比较排序 (计数排序、桶排序、基数排序)
这类排序用于特定情况,可以达到线性时间复杂度 O(n)。
-
计数排序 (Counting Sort)
- 思想 :将输入的数据值转化为键存储在额外开辟的数组空间中。要求输入的数据必须是有确定范围的整数。
- 过程:统计每个元素出现的次数,然后根据计数结果直接输出到有序序列中。
- 复杂度:O(n + k),k是整数的范围。k过大时效率低下。
-
桶排序 (Bucket Sort)
- 思想:将数据分到有限数量的桶里,每个桶再分别排序(可能使用别的排序算法或递归方式继续使用桶排序)。
- 复杂度:O(n),取决于桶的数量和桶内使用的排序算法。
-
基数排序 (Radix Sort)
- 思想:按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。
- 复杂度:O(n * k),k是最大数字的位数。
3. 总结与对比
排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | 核心思想 |
---|---|---|---|---|---|---|
冒泡排序 | 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(n log² n) | O(n²) | O(1) | 不稳定 | 分组插入 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 分治、合并 |
快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 | 分治、分区 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 | 堆结构 |
4. C++中的排序:std::sort
在C++实际开发中,你几乎不需要自己实现排序算法。标准库提供了极其高效的 std::sort
函数(位于 <algorithm>
头文件中)。
-
实现原理 :它并非单一的排序算法,而是一种混合算法 IntroSort。
- 快速排序:作为主要排序方法。
- 堆排序 :当递归深度过深(超过
log(n)
层)时,转为堆排序,避免快排的最坏O(n²)情况。 - 插入排序:当分区的元素数量少于一定阈值(如16)时,转为插入排序,因为对小数组插入排序常数项更小。
-
用法:
cpp#include <algorithm> #include <vector> int main() { std::vector<int> arr = {5, 2, 8, 1, 9}; // 默认升序排序 std::sort(arr.begin(), arr.end()); // arr becomes {1, 2, 5, 8, 9} // 降序排序:使用lambda表达式或greater<>() std::sort(arr.begin(), arr.end(), std::greater<int>()); // 或 std::sort(arr.begin(), arr.end(), [](int a, int b) { return a > b; }); return 0; }
它是绝大多数情况下你的最佳选择。