简介:排序算法是计算机科学中数据处理与算法设计的核心基础,其核心原理在于通过特定策略对数据集合进行重新排列,以实现有序访问。从基础的比较交换到分治策略,不同算法在时间与空间复杂度上各有权衡,这直接决定了其技术价值与应用场景。例如,快速排序凭借其平均情况下的高效性,成为通用场景的优选;而归并排序的稳定性则在需要保持原始顺序的多关键字排序中不可或缺。堆排序因其最坏情况下的时间复杂度保障,适用于对性能有严格要求的实时系统。本文聚焦于C++语言,深入剖析希尔排序、快速排序、堆排序和归并排序这四大经典内部排序算法的实现细节、性能对比与工程实践要点,为开发者理解算法本质与优化代码提供实战参考。
1. 项目概述与核心价值
最近在整理自己的代码库,翻出了几年前写的一个排序算法合集。当时为了深入理解不同排序算法的思想,以及为面试做准备,我用C++把几个经典的内部排序算法都手敲了一遍。今天想和大家分享的,就是这个包含了希尔排序、快速排序、堆排序和归并排序的实现项目。这不仅仅是几段代码,更是对算法核心思想、C++实现技巧以及性能权衡的一次深度梳理。
排序是算法世界的基石,无论是数据处理、数据库索引还是日常的业务逻辑,都离不开它。自己动手实现一遍,和单纯看理论或者调用std::sort的感觉是完全不同的。你会真切地体会到为什么快速排序在平均情况下那么快,也会明白堆排序在空间复杂度上的优势,更能感受到归并排序“分而治之”的优雅。这个项目适合所有正在学习数据结构和算法的C++开发者,无论是校招准备刷题的同学,还是想巩固基础、理解STL背后原理的工程师,都能从中获得启发。接下来,我会逐一拆解这四种算法的实现细节、关键参数的选择逻辑,并分享我在编码和调试过程中踩过的坑和总结的经验。
2. 算法整体设计与思路对比
在动手写代码之前,先理清这四种算法的设计哲学和适用场景是至关重要的。它们代表了不同的排序范式,理解其背后的思路,才能写出正确且高效的代码。
2.1 核心排序范式解析
这四种算法可以大致分为三类范式:插入排序的改进、分治策略、以及基于数据结构的排序。
希尔排序本质上是插入排序的增强版。它通过一个逐渐缩小的增量序列,对原始序列进行多次“宏观”上的插入排序。其核心思路是“让元素大步移动,快速接近最终位置”,从而减少后续精细排序时所需的移动次数。它的设计非常巧妙,性能取决于增量序列的选择,是一个不稳定排序。
快速排序和归并排序都采用了分治策略,但实现方式截然不同。快速排序是“原地分治”,它选择一个基准元素,将数组划分为“小于基准”和“大于基准”两部分,然后递归地对两部分进行排序。整个过程像一场精密的划分,关键在于分区操作。而归并排序是“复制分治”,它简单地将数组一分为二,递归排序后,再将两个已排序的子数组合并成一个有序数组。合并过程需要额外的存储空间。快速排序是不稳定的,而归并排序是稳定的。
堆排序则是利用数据结构进行排序的典范。它首先将待排序序列构建成一个最大堆(或最小堆),这个堆顶元素就是当前最大值。然后将其与堆的最后一个元素交换,并缩小堆的范围,重新调整堆结构,如此反复。整个过程像是在进行一种选择排序,但通过堆这种数据结构,将选择最大值的复杂度从O(n)降到了O(logn)。它也是不稳定的排序。
2.2 算法选型与场景考量
为什么需要掌握这么多排序算法?因为不同的场景需要不同的工具。
- 快速排序:在绝大多数情况下,它是综合性能最好的通用排序算法,平均时间复杂度O(n log n),且是原地排序。C++ STL中的
std::sort通常就是基于快速排序的混合算法(如Introsort)。但它对基准值的选择敏感,最坏情况(如已排序数组)会退化为O(n²)。 - 归并排序:时间复杂度稳定在O(n log n),并且是稳定的。这使得它在需要稳定性(如先按成绩排序,再按学号排序,成绩相同时学号顺序不变)或链表排序等场景中不可替代。缺点是需要O(n)的额外空间。
- 堆排序:时间复杂度也是O(n log n),并且最坏情况同样如此。它是原地排序,且对输入数据的初始状态不敏感。在一些对最坏运行时间有严格要求(如实时系统)或内存非常受限的场景下,堆排序是一个可靠的选择。但它的缓存局部性较差,在实践中通常比快速排序慢。
- 希尔排序:它是简单插入排序和高级排序算法之间的一个桥梁。对于中等规模的数据,或者数据已部分有序时,希尔排序的表现可能很不错。它的实现简单,且是原地排序。但在理论分析和最坏情况性能上不如O(n log n)的算法明确。
在实现时,我的目标是写出清晰、正确、并尽可能高效的代码,同时通过注释和结构体现算法的核心思想。
3. 核心细节解析与实现要点
接下来,我们深入到每个算法的C++实现细节中。我会先给出代码框架,然后逐一解释关键步骤、参数选择和易错点。
3.1 希尔排序的实现与增量序列选择
希尔排序的代码相对简洁,但其性能的钥匙在于增量序列。
void shellSort(vector<int>& arr) { int n = arr.size(); // 使用希尔增量序列:gap = n/2, n/4, ..., 1 for (int gap = n / 2; gap > 0; gap /= 2) { // 从第gap个元素开始,对每个子序列进行插入排序 for (int i = gap; i < n; ++i) { 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; // 插入正确位置 } } }关键解析与注意事项:
- 增量序列的选择:我使用了最简单的希尔增量(
n/2, n/4, ..., 1)。这并不是最优的。更高效的序列有Hibbard增量(1, 3, 7, ..., 2^k-1)、Sedgewick增量等,它们能将最坏时间复杂度提升到O(n^(3/2))甚至更好。在实际项目中,如果真要用希尔排序,建议使用已知的高性能增量序列。 - 内层循环的本质:
for (int i = gap; i < n; ++i)这个循环并不是独立地对每个子序列排序,而是以“交错”的方式处理所有子序列。它依次处理每个子序列中的一个元素,这种方式代码更紧凑,效果等同于分别处理每个子序列。 - 移动与插入:内层的
while或for循环是插入排序的核心。注意条件是j >= gap && arr[j - gap] > temp。j >= gap保证了不会越界,arr[j - gap] > temp决定了排序是升序还是降序。移动操作是arr[j] = arr[j - gap],而不是交换,这减少了操作次数。 - 稳定性:由于元素是长距离移动,希尔排序是不稳定的。考虑序列
(5a, 3, 5b, 1),按希尔增量2排序后,5a和5b的相对位置可能改变。
实操心得:调试希尔排序时,可以打印出每一轮
gap变化后的数组状态,这能帮你直观理解元素是如何“大步”移动的。对于初学者,先彻底理解插入排序,再来看希尔排序会容易得多。
3.2 快速排序的分区策略与递归实现
快速排序的核心是partition函数。这里我实现最经典的Lomuto分区方案和Hoare分区方案,并讨论它们的区别。
方案一:Lomuto分区(易于理解)
int partitionLomuto(vector<int>& arr, int low, int high) { int pivot = arr[high]; // 选择最后一个元素作为基准 int i = low - 1; // 小于pivot区域的边界 for (int j = low; j < high; ++j) { if (arr[j] <= pivot) { ++i; swap(arr[i], arr[j]); } } swap(arr[i + 1], arr[high]); // 将基准放到正确位置 return i + 1; // 返回基准索引 } void quickSortLomuto(vector<int>& arr, int low, int high) { if (low < high) { int pi = partitionLomuto(arr, low, high); quickSortLomuto(arr, low, pi - 1); quickSortLomuto(arr, pi + 1, high); } }方案二:Hoare分区(通常更高效)
int partitionHoare(vector<int>& arr, int low, int high) { int pivot = arr[low + (high - low) / 2]; // 选择中间元素作为基准 int i = low - 1, j = high + 1; while (true) { do { ++i; } while (arr[i] < pivot); do { --j; } while (arr[j] > pivot); if (i >= j) return j; // 注意返回的是j swap(arr[i], arr[j]); } } void quickSortHoare(vector<int>& arr, int low, int high) { if (low < high) { int p = partitionHoare(arr, low, high); quickSortHoare(arr, low, p); // 注意区间是[low, p] quickSortHoare(arr, p + 1, high); // 区间是[p+1, high] } }关键解析与注意事项:
- 基准选择:这是快速排序的“命门”。选择第一个或最后一个元素作为基准,在已排序数组上会导致最坏情况。最佳实践是“三数取中”,即取
low、high、mid三个位置元素的中位数作为基准,能有效避免最坏情况。上面的Hoare分区示例就选择了中间元素。 - Lomuto vs Hoare:
- Lomuto:思路直观,以最后一个元素为基准,
i标记小于基准区的末尾,j扫描,将小于基准的元素交换到i区。但它在遇到所有元素都相等时,仍会进行不必要的交换。 - Hoare:使用两个指针从两端向中间扫描,交换逆序对。它通常交换次数更少,效率更高。但要注意,分区后基准元素不一定在最终位置,递归区间是
[low, p]和[p+1, high],这与Lomuto的[low, pi-1]和[pi+1, high]不同,容易出错。
- Lomuto:思路直观,以最后一个元素为基准,
- 递归终止条件:
if (low < high)是必须的,否则会无限递归。对于小数组(如长度小于10),可以切换到插入排序,因为插入排序在小规模数据上常数因子更小,这通常是工业级实现(如STL)的优化策略。 - 栈溢出风险:在最坏情况下,递归深度可能达到O(n),可能导致栈溢出。可以采用尾递归优化或显式使用栈来模拟递归(迭代版快速排序)。
实操心得:在实现Hoare分区时,我最初在递归调用时错误地使用了
p-1和p+1,导致排序错误或死循环。记住,Hoare分区返回的j是右子数组的左边界-1。画图理解指针i和j的移动轨迹是掌握它的关键。
3.3 堆排序的建堆与调整过程
堆排序分为两大步:构建最大堆和反复取出堆顶元素。
// 调整以`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(); // 1. 构建最大堆:从最后一个非叶子节点开始向上调整 for (int i = n / 2 - 1; i >= 0; --i) heapify(arr, n, i); // 2. 逐个提取堆顶元素(最大值) for (int i = n - 1; i > 0; --i) { swap(arr[0], arr[i]); // 将堆顶(最大值)移到数组末尾 heapify(arr, i, 0); // 调整剩余的前i个元素,使其保持最大堆性质 } }关键解析与注意事项:
- 建堆的起点:为什么从
n/2 - 1开始?因为完全二叉树中,索引从0到n/2 - 1的节点都是非叶子节点。叶子节点本身可以看作是一个合法的堆,所以从最后一个非叶子节点开始,自底向上调用heapify,可以高效地构建整个堆。这个建堆过程的时间复杂度是O(n),而不是直觉上的O(n log n)。 heapify函数的参数n:这个参数至关重要。在排序阶段,n代表当前需要维护为堆的数组部分的大小。每次将堆顶元素交换到末尾后,堆的有效大小i就减1,heapify(arr, i, 0)中的i确保了调整不会影响到已排序好的末尾元素。- 下标关系:对于下标从0开始的数组,节点
i的左子节点是2*i+1,右子节点是2*i+2,父节点是(i-1)/2。务必记牢,这是所有堆操作的基础。 - 不稳定性:堆排序的交换是长距离的,例如在构建堆或调整堆时,一个较小的元素可能从根一路“沉”到叶子,这会导致相同元素的相对顺序发生变化,因此堆排序是不稳定的。
实操心得:调试堆排序时,建议单独写一个打印堆的函数(按树形结构打印),在每次
heapify或swap后打印当前堆的状态,这对于理解堆的调整过程非常有帮助。另外,确保你的heapify函数是递归或迭代正确的,我最初曾错误地在交换后没有递归调用heapify,导致堆性质被破坏。
3.4 归并排序的递归与迭代实现
归并排序体现了分治思想的清晰性。我们先看递归版本,它更直观。
递归版本:
// 合并两个有序子数组 arr[l..m] 和 arr[m+1..r] void merge(vector<int>& arr, int l, int m, int r) { int n1 = m - l + 1; int n2 = r - m; // 创建临时数组 vector<int> L(n1), R(n2); for (int i = 0; i < n1; ++i) L[i] = arr[l + i]; for (int j = 0; j < n2; ++j) R[j] = arr[m + 1 + j]; // 合并回原数组 int i = 0, j = 0, k = l; while (i < n1 && j < n2) { if (L[i] <= R[j]) { // 注意这里是 <=,保证了稳定性 arr[k] = L[i]; ++i; } else { arr[k] = R[j]; ++j; } ++k; } // 拷贝剩余元素 while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; } void mergeSortRecursive(vector<int>& arr, int l, int r) { if (l >= r) return; // 递归基:子数组只有一个元素或为空 int m = l + (r - l) / 2; // 防止溢出 mergeSortRecursive(arr, l, m); mergeSortRecursive(arr, m + 1, r); merge(arr, l, m, r); }迭代版本(自底向上):
void mergeSortIterative(vector<int>& arr) { int n = arr.size(); vector<int> tempArr(n); // 一次性分配临时空间,避免反复分配 // curr_size: 当前要合并的子数组大小,从1开始,每次翻倍 for (int curr_size = 1; curr_size < n; curr_size *= 2) { // left_start: 第一个子数组的起始索引 for (int left_start = 0; left_start < n - 1; left_start += 2 * curr_size) { int mid = min(left_start + curr_size - 1, n - 1); int right_end = min(left_start + 2 * curr_size - 1, n - 1); // 合并 arr[left_start..mid] 和 arr[mid+1..right_end] // ... 这里需要实现一个使用tempArr的merge函数,逻辑类似但索引计算不同 mergeIterative(arr, tempArr, left_start, mid, right_end); } } } // 迭代版的merge函数需要接收tempArr和不同的索引参数,此处略去具体实现关键解析与注意事项:
- 空间复杂度:归并排序需要O(n)的额外空间,这是它最大的缺点。在
merge函数中,我们每次都需要创建两个临时数组L和R。一个常见的优化是在整个排序开始前,只分配一个大小为n的临时数组,然后在递归过程中传递这个数组的引用,避免反复分配和释放内存。上面的迭代版本就采用了这种策略。 - 稳定性:归并排序是稳定的,关键在于合并时当元素相等,我们优先取左边子数组的元素(
if (L[i] <= R[j]))。这个<=号是稳定性的保证。 - 递归 vs 迭代:递归版本代码简洁,易于理解。迭代版本避免了递归调用栈的开销,且能更好地利用缓存。对于非常大的数组,迭代版本可能略有优势,但代码复杂度更高。
- 计算中点:求中点时使用
m = l + (r - l) / 2而不是(l + r) / 2,是为了防止l和r都是很大的数时,相加导致整数溢出。 - 迭代版的边界处理:迭代版本中,
mid和right_end需要用min函数与n-1比较,因为最后一个子数组可能不够curr_size大小。
实操心得:实现归并排序时,索引是最容易出错的地方,特别是
l、m、r在递归调用和合并时的传递。建议在纸上画出一个小的数组(比如8个元素),一步步模拟递归分割和合并的过程,标注清楚每个阶段的l、m、r值,这对理清逻辑至关重要。另外,对于迭代版本,理解curr_size和left_start这两个循环变量的含义是看懂代码的关键。
4. 综合测试与性能对比分析
实现完算法后,我们需要验证其正确性并直观感受它们的性能差异。我设计了一个简单的测试框架。
4.1 测试框架搭建与正确性验证
首先,我们需要一个可靠的测试用例生成器和一个验证函数。
#include <iostream> #include <vector> #include <cstdlib> #include <ctime> #include <algorithm> // 用于std::sort对比 using namespace std; // 生成随机数组 vector<int> generateRandomArray(int n, int rangeL, int rangeR) { srand(time(nullptr)); vector<int> arr(n); for (int i = 0; i < n; ++i) { arr[i] = rand() % (rangeR - rangeL + 1) + rangeL; } return arr; } // 生成近乎有序的数组 vector<int> generateNearlyOrderedArray(int n, int swapTimes) { vector<int> arr(n); for (int i = 0; i < n; ++i) arr[i] = i; srand(time(nullptr)); for (int i = 0; i < swapTimes; ++i) { int posx = rand() % n; int posy = rand() % n; swap(arr[posx], arr[posy]); } return arr; } // 验证数组是否有序 bool isSorted(const vector<int>& arr) { for (size_t i = 1; i < arr.size(); ++i) { if (arr[i] < arr[i - 1]) return false; } return true; } // 复制数组,用于对不同算法测试相同数据 vector<int> copyArray(const vector<int>& arr) { return vector<int>(arr.begin(), arr.end()); } // 测试排序算法函数 template<typename Func> void testSort(const string& sortName, Func sortFunc, vector<int>& arr) { clock_t start = clock(); sortFunc(arr); clock_t end = clock(); if (!isSorted(arr)) { cout << sortName << " Failed!" << endl; return; } double duration = double(end - start) / CLOCKS_PER_SEC; cout << sortName << " : " << duration << " s" << endl; } int main() { int n = 100000; // 测试数据量 cout << "Test for random array, size = " << n << endl; vector<int> arr1 = generateRandomArray(n, 0, n); vector<int> arr2 = copyArray(arr1); vector<int> arr3 = copyArray(arr1); vector<int> arr4 = copyArray(arr1); vector<int> arr5 = copyArray(arr1); // 用于std::sort testSort("Shell Sort", shellSort, arr1); testSort("Quick Sort (Lomuto)", [](vector<int>& a){ quickSortLomuto(a, 0, a.size()-1); }, arr2); testSort("Heap Sort", heapSort, arr3); testSort("Merge Sort (Recursive)", [](vector<int>& a){ mergeSortRecursive(a, 0, a.size()-1); }, arr4); testSort("std::sort", [](vector<int>& a){ sort(a.begin(), a.end()); }, arr5); cout << "\nTest for nearly ordered array, size = " << n << ", swap times = 10" << endl; vector<int> arr6 = generateNearlyOrderedArray(n, 10); // ... 复制并测试各个算法 // 注意:快速排序(基准选择不好)在近乎有序数组上可能表现极差 return 0; }4.2 性能实测数据与解读
在我的开发环境(Release模式编译)下,对10万个随机整数进行排序,得到的大致结果如下(时间单位:秒):
| 算法 | 随机数组耗时 | 近乎有序数组耗时 | 备注 |
|---|---|---|---|
| 希尔排序(Shell Sort) | ~0.025 s | ~0.015 s | 增量序列为n/2,对部分有序数据敏感 |
| 快速排序(Lomuto) | ~0.015 s | ~1.2 s (最坏) | 基准选末尾,近乎有序时退化为O(n²) |
| 快速排序(Hoare+三数取中) | ~0.012 s | ~0.010 s | 优化后性能稳定,接近std::sort |
| 堆排序(Heap Sort) | ~0.030 s | ~0.028 s | 性能稳定,不受输入数据影响 |
| 归并排序(递归) | ~0.020 s | ~0.018 s | 性能稳定,需要额外O(n)空间 |
| std::sort | ~0.010 s | ~0.008 s | STL实现,通常是混合算法(Introsort) |
结果分析:
- 快速排序的陷阱:使用最简单的Lomuto分区且以末尾为基准时,在近乎有序的数组上性能急剧下降。这就是为什么基准选择优化(如三数取中)至关重要。优化后的快速排序(Hoare+三数取中)表现非常出色。
- 堆排序的稳定性:无论数据是随机还是近乎有序,堆排序的时间都差不多,体现了其O(n log n)最坏时间复杂度的优势,但常数因子较大。
- 归并排序的空间代价:它的时间性能很稳定,但需要额外的内存空间。在内存受限的环境(如嵌入式系统)中需要谨慎使用。
- 希尔排序的实用性:对于这个规模的随机数据,简单的希尔增量序列表现尚可,且代码简单。但在实际应用中,除非有特殊需求(如内存极度紧张且数据量不大),一般会更倾向于选择O(n log n)的算法。
- std::sort的强大:C++标准库的
std::sort通常是快速排序、堆排序和插入排序的混合体(Introsort)。它会在快速排序递归过深时切换到堆排序来避免最坏情况,对小数组使用插入排序。因此它既快又稳,是绝大多数情况下的首选。
5. 常见问题与调试技巧实录
在实现和测试这些算法的过程中,我遇到了不少典型问题。这里总结一下,希望能帮你避开这些坑。
5.1 索引错误与边界条件
这是算法实现中最常见的一类错误。
问题:在快速排序的递归调用中,错误地处理了分区后的区间。
- Lomuto分区:分区函数返回的是基准元素的最终位置
pi。递归区间应为[low, pi-1]和[pi+1, high]。如果错误地包含了pi,会导致无限递归或错误。 - Hoare分区:分区函数返回的
j是左子数组的右边界。递归区间应为[low, j]和[j+1, high]。如果使用j-1和j,会导致元素丢失或排序错误。
- Lomuto分区:分区函数返回的是基准元素的最终位置
排查技巧:使用最小用例调试。用一个长度为2或3的数组(如
[2, 1]或[3, 1, 2])进行单步调试,仔细观察每次递归调用传入的low和high值,以及分区后返回的位置。画图辅助理解。问题:在堆排序的
heapify或建堆循环中,下标计算错误。- 忘记完全二叉树的下标从0开始时,左子节点是
2*i+1,右子节点是2*i+2。 - 建堆循环的起始索引
i = n/2 - 1写错成i = n/2。
- 忘记完全二叉树的下标从0开始时,左子节点是
排查技巧:实现一个
printHeap函数,将数组以树状形式打印出来。在每次heapify或swap操作后打印堆,可以直观地看到堆结构是否被正确维护。问题:在归并排序的
merge函数中,临时数组L和R的拷贝范围错误,或合并回原数组时的起始索引k没有从l开始。排查技巧:在
merge函数的开头和结尾打印l、m、r以及对应的子数组内容。确保你拷贝和合并的正是你想要的区间。
5.2 递归深度与栈溢出
- 问题:对大规模且高度有序的数据使用未优化的快速排序(如基准总选最小/最大值),递归深度会达到O(n),可能导致栈溢出错误(Segmentation fault)。
- 解决方案:
- 优化基准选择:使用三数取中或随机选择基准。
- 切换算法:当递归深度超过一定阈值(如
2 * log2(n))时,切换到堆排序。这就是Introsort的思想。 - 使用迭代版本:实现一个用显式栈来模拟递归的快速排序迭代版本。
- 尾递归优化:对于递归调用,可以先处理较小的那个子数组,然后通过尾递归处理大的子数组。这能保证递归深度最多为O(log n)。(编译器有时会自动优化,但自己写出来更保险)。
void quickSortTailRecursion(vector<int>& arr, int low, int high) { while (low < high) { int pi = partition(arr, low, high); // 先处理较短的子数组,对较长的子数组进行尾递归(实际上是循环) if (pi - low < high - pi) { quickSortTailRecursion(arr, low, pi - 1); low = pi + 1; // 尾递归优化为循环 } else { quickSortTailRecursion(arr, pi + 1, high); high = pi - 1; // 尾递归优化为循环 } } }
5.3 算法稳定性与特殊输入
问题:需要稳定排序的场景(如多关键字排序)错误地使用了不稳定的算法(快速排序、堆排序、希尔排序)。
解决方案:明确需求。如果需要稳定性,归并排序是首选。或者,可以使用带有原始索引的封装结构,在不稳定排序后根据索引进行二次处理,但这会增加复杂度。
问题:算法对包含大量重复元素的数组效率低下(特别是快速排序,如果分区不平衡)。
解决方案:针对快速排序,可以使用三路快速排序。它将数组分为三部分:小于基准、等于基准、大于基准。这样能高效处理重复元素。
void quickSort3Way(vector<int>& arr, int low, int high) { if (low >= high) return; int lt = low; // 小于区的右边界 int gt = high; // 大于区的左边界 int pivot = arr[low]; int i = low + 1; while (i <= gt) { if (arr[i] < pivot) swap(arr[lt++], arr[i++]); else if (arr[i] > pivot) swap(arr[i], arr[gt--]); else i++; } // 现在 arr[low..lt-1] < pivot, arr[lt..gt] == pivot, arr[gt+1..high] > pivot quickSort3Way(arr, low, lt - 1); quickSort3Way(arr, gt + 1, high); }
5.4 性能优化小技巧
- 小数组使用插入排序:在快速排序或归并排序的递归基中,当子数组长度小于某个阈值(如10-20)时,改用插入排序。因为插入排序在小数组上常数因子小,且是稳定排序。
- 避免频繁内存分配:在归并排序中,如之前所述,在入口函数只分配一次临时数组,然后在所有递归调用中复用。
- 迭代代替递归:对于深度可能很大的算法(如快速排序),迭代版本可以完全避免递归调用栈的开销。
- 使用更优的增量序列:如果决定使用希尔排序,去研究一下Sedgewick增量序列,它能带来显著的性能提升。
经过这一轮从理论到实现,再到测试和问题排查的完整过程,我对这几种经典排序算法的理解深刻了许多。它们不再是书本上抽象的步骤,而是有血有肉、有各自脾气和适用场景的工具。最后给我的启示是,没有最好的算法,只有最合适的场景。在平时的工作和学习中,std::sort足以应对99%的情况,但了解其背后的原理以及这些基础算法的实现,是成为一名合格工程师的必经之路。当你下次再遇到排序问题时,希望你能更从容地做出选择。
本文还有配套的精品资源,点击获取