资讯动态

C++实现四大经典排序算法:希尔、快排、堆排与归并详解

发布时间:2026/8/29 20:55:25 来源:尧图企业网站定制
简介排序算法是计算机科学中数据处理与算法设计的核心基础其核心原理在于通过特定策略对数据集合进行重新排列以实现有序访问。从基础的比较交换到分治策略不同算法在时间与空间复杂度上各有权衡这直接决定了其技术价值与应用场景。例如快速排序凭借其平均情况下的高效性成为通用场景的优选而归并排序的稳定性则在需要保持原始顺序的多关键字排序中不可或缺。堆排序因其最坏情况下的时间复杂度保障适用于对性能有严格要求的实时系统。本文聚焦于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(vectorint 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(vectorint 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(vectorint 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(vectorint 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(vectorint 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); // 区间是[p1, high] } }关键解析与注意事项基准选择这是快速排序的“命门”。选择第一个或最后一个元素作为基准在已排序数组上会导致最坏情况。最佳实践是“三数取中”即取low、high、mid三个位置元素的中位数作为基准能有效避免最坏情况。上面的Hoare分区示例就选择了中间元素。Lomuto vs HoareLomuto思路直观以最后一个元素为基准i标记小于基准区的末尾j扫描将小于基准的元素交换到i区。但它在遇到所有元素都相等时仍会进行不必要的交换。Hoare使用两个指针从两端向中间扫描交换逆序对。它通常交换次数更少效率更高。但要注意分区后基准元素不一定在最终位置递归区间是[low, p]和[p1, high]这与Lomuto的[low, pi-1]和[pi1, high]不同容易出错。递归终止条件if (low high)是必须的否则会无限递归。对于小数组如长度小于10可以切换到插入排序因为插入排序在小规模数据上常数因子更小这通常是工业级实现如STL的优化策略。栈溢出风险在最坏情况下递归深度可能达到O(n)可能导致栈溢出。可以采用尾递归优化或显式使用栈来模拟递归迭代版快速排序。实操心得在实现Hoare分区时我最初在递归调用时错误地使用了p-1和p1导致排序错误或死循环。记住Hoare分区返回的j是右子数组的左边界-1。画图理解指针i和j的移动轨迹是掌握它的关键。3.3 堆排序的建堆与调整过程堆排序分为两大步构建最大堆和反复取出堆顶元素。// 调整以i为根的子树使其满足最大堆性质n是当前堆的大小 void heapify(vectorint 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(vectorint 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就减1heapify(arr, i, 0)中的i确保了调整不会影响到已排序好的末尾元素。下标关系对于下标从0开始的数组节点i的左子节点是2*i1右子节点是2*i2父节点是(i-1)/2。务必记牢这是所有堆操作的基础。不稳定性堆排序的交换是长距离的例如在构建堆或调整堆时一个较小的元素可能从根一路“沉”到叶子这会导致相同元素的相对顺序发生变化因此堆排序是不稳定的。实操心得调试堆排序时建议单独写一个打印堆的函数按树形结构打印在每次heapify或swap后打印当前堆的状态这对于理解堆的调整过程非常有帮助。另外确保你的heapify函数是递归或迭代正确的我最初曾错误地在交换后没有递归调用heapify导致堆性质被破坏。3.4 归并排序的递归与迭代实现归并排序体现了分治思想的清晰性。我们先看递归版本它更直观。递归版本// 合并两个有序子数组 arr[l..m] 和 arr[m1..r] void merge(vectorint arr, int l, int m, int r) { int n1 m - l 1; int n2 r - m; // 创建临时数组 vectorint 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(vectorint 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(vectorint arr) { int n arr.size(); vectorint 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[mid1..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; // 生成随机数组 vectorint generateRandomArray(int n, int rangeL, int rangeR) { srand(time(nullptr)); vectorint arr(n); for (int i 0; i n; i) { arr[i] rand() % (rangeR - rangeL 1) rangeL; } return arr; } // 生成近乎有序的数组 vectorint generateNearlyOrderedArray(int n, int swapTimes) { vectorint 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 vectorint arr) { for (size_t i 1; i arr.size(); i) { if (arr[i] arr[i - 1]) return false; } return true; } // 复制数组用于对不同算法测试相同数据 vectorint copyArray(const vectorint arr) { return vectorint(arr.begin(), arr.end()); } // 测试排序算法函数 templatetypename Func void testSort(const string sortName, Func sortFunc, vectorint 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; vectorint arr1 generateRandomArray(n, 0, n); vectorint arr2 copyArray(arr1); vectorint arr3 copyArray(arr1); vectorint arr4 copyArray(arr1); vectorint arr5 copyArray(arr1); // 用于std::sort testSort(Shell Sort, shellSort, arr1); testSort(Quick Sort (Lomuto), [](vectorint a){ quickSortLomuto(a, 0, a.size()-1); }, arr2); testSort(Heap Sort, heapSort, arr3); testSort(Merge Sort (Recursive), [](vectorint a){ mergeSortRecursive(a, 0, a.size()-1); }, arr4); testSort(std::sort, [](vectorint a){ sort(a.begin(), a.end()); }, arr5); cout \nTest for nearly ordered array, size n , swap times 10 endl; vectorint 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 sSTL实现通常是混合算法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]和[pi1, high]。如果错误地包含了pi会导致无限递归或错误。Hoare分区分区函数返回的j是左子数组的右边界。递归区间应为[low, j]和[j1, high]。如果使用j-1和j会导致元素丢失或排序错误。排查技巧使用最小用例调试。用一个长度为2或3的数组如[2, 1]或[3, 1, 2]进行单步调试仔细观察每次递归调用传入的low和high值以及分区后返回的位置。画图辅助理解。问题在堆排序的heapify或建堆循环中下标计算错误。忘记完全二叉树的下标从0开始时左子节点是2*i1右子节点是2*i2。建堆循环的起始索引i n/2 - 1写错成i n/2。排查技巧实现一个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(vectorint 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(vectorint 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[gt1..high] pivot quickSort3Way(arr, low, lt - 1); quickSort3Way(arr, gt 1, high); }5.4 性能优化小技巧小数组使用插入排序在快速排序或归并排序的递归基中当子数组长度小于某个阈值如10-20时改用插入排序。因为插入排序在小数组上常数因子小且是稳定排序。避免频繁内存分配在归并排序中如之前所述在入口函数只分配一次临时数组然后在所有递归调用中复用。迭代代替递归对于深度可能很大的算法如快速排序迭代版本可以完全避免递归调用栈的开销。使用更优的增量序列如果决定使用希尔排序去研究一下Sedgewick增量序列它能带来显著的性能提升。经过这一轮从理论到实现再到测试和问题排查的完整过程我对这几种经典排序算法的理解深刻了许多。它们不再是书本上抽象的步骤而是有血有肉、有各自脾气和适用场景的工具。最后给我的启示是没有最好的算法只有最合适的场景。在平时的工作和学习中std::sort足以应对99%的情况但了解其背后的原理以及这些基础算法的实现是成为一名合格工程师的必经之路。当你下次再遇到排序问题时希望你能更从容地做出选择。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价