ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

快速排序算法详解:从递归实现到工程优化实践

快速排序算法详解:从递归实现到工程优化实践 1. 快速排序到底在排什么核心思想与整体设计1.1 一句话说清分治思路快速排序在排序算法里的地位有点像手机里的微信——你未必天天研究它但绕不开它。C语言课程设计用它数据结构期末考试用它找工作时面试官也爱问它。我自己最早接触快速排序是大学《数据结构》课上当时老师花了整整两节课讲Hoare的原始论文思路班上还是有一半人没转过弯来。后来工作里写图像处理、做数据预处理才真正体会到这个算法的分量。它的核心思想其实可以压缩成一句话选一个基准值把数组分成左边小、右边大两部分然后对左右两部分递归地重复这个过程。这句话听起来简单但里面藏着的分区partition操作是全部精髓所在。很多初学者第一次看快速排序的递归代码会觉得“这不就是冒泡排序的升级版吗”还真不是。冒泡排序每轮只解决一个元素的最终位置而且是通过相邻交换慢慢“冒”上去快速排序则是通过一次分区让基准值直接落到它最终该待的位置同时还把整体数据切成了互不相干的两块后续排序互不干扰。这个“互不干扰”特别关键——它意味着左右两边可以独立处理所以才能用递归也才可以在工程上并行化。我见过不少教材直接甩出快速排序的代码然后把递归过程画成一张很复杂的树形图初学者看完更晕。我建议反过来先把“一次分区”彻底搞懂再去看递归就水到渠成。因为整个快速排序就是在反复调用这个分区函数而已。1.2 基准元素的选择策略基准值pivot是整个排序的“支点”。同样一组数据选不同的基准值性能差距可能是天壤之别。最朴素的选法有三类固定选第一个元素、固定选最后一个元素、随机选一个元素。教科书里为了讲解方便通常默认选数组第一个元素或最后一个元素。但这里藏着一个大坑如果数据本身已经有序升序或降序固定选端点元素会导致每次分区都严重失衡——左边只有一个元素右边是剩下的所有元素。这种情况下快速排序的时间复杂度会从理想的O(n log n)直接跌到O(n²)跑起来比插入排序还慢。随机选基准值是为了打破这种对输入数据的依赖。思路很简单在[left, right]范围内随机生成一个下标把该位置的元素和第一个元素交换然后还是按照“选第一个元素”的流程走。这样即使数据本身有序随机化之后出现最坏情况的概率也微乎其微。工程上更多采用“三数取中”的策略取左端、中间、右端三个位置的元素选它们中间大小的那个作为基准值这比单纯随机更稳定后面第5章我会专门展开。我自己在LeetCode上刷题时遇到过几次快速排序模板直接超时的案例十有八九都是固定选端点导致退化。后来养成习惯凡是自己手写快排默认就带三数取中或随机化不给自己留踩坑的机会。1.3 分区过程图解一次partition发生的细节理解了基准值的选择接下来看最关键的分区操作。我用一个具体例子来走一遍。假设数组是[6, 1, 2, 7, 9, 3, 4, 5, 10, 8]我们选第一个元素6作为基准值目标是通过一轮扫描让数组变成“6左边的都比6小6右边的都比6大”。教科书里常见的是挖坑法。先把基准值6取出来此时第一个位置相当于一个“坑”。用两个指针i和ji从左边开始j从右边开始j从右往左找第一个比6小的元素找到5把5填到坑里此时5原来的位置变成新坑。i从左往右找第一个比6大的元素找到7把7填到刚才5留下的坑里7原来的位置变成新坑。j继续从右往左找比6小的找到4填入坑i从左往右找比6大的找到9填入坑。j继续找找到3填入坑i继续找和j相遇了。此时i和j重合这个位置就是基准值6的最终归宿把6填进去。数组变成[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]可以看到6左边的5、1、2、4、3都小于6右边的9、7、10、8都大于6而且6已经固定在了它排序后的正确位置——第6个位置。后面再也不用动它了。还有一种**双边扫描法Hoare分区**也很重要i从左往右找比基准大的j从右往左找比基准小的找到后交换两者。注意Hoare分区最后基准值归位的方式和挖坑法略有区别返回值可能指向基准值也可能指向最后一个交换位置写代码时要特别小心边界。我个人觉得初学先用挖坑法逻辑更直观不容易写出死循环。2. 递归实现详解C语言风格与C实现的完整代码2.1 最经典的递归写法下面给出最经典、最容易理解的C风格快速排序实现C和C可以直接共用。我这里用C的语法写但保持C风格的数组操作方便两个语言的读者对照。#include iostream using namespace std; // 挖坑法分区返回基准值最终下标 int partition(int arr[], int left, int right) { int pivot arr[left]; // 取第一个元素为基准值left位置形成坑 int i left, j right; while (i j) { // 从右往左找第一个小于基准值的元素 while (i j arr[j] pivot) { j--; } if (i j) { arr[i] arr[j]; // 填坑j位置形成新坑 i; } // 从左往右找第一个大于基准值的元素 while (i j arr[i] pivot) { i; } if (i j) { arr[j] arr[i]; // 填坑i位置形成新坑 j--; } } arr[i] pivot; // 基准值归位 return i; } void quickSort(int arr[], int left, int right) { if (left right) { return; } int pivotIndex partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); // 排左半边 quickSort(arr, pivotIndex 1, right); // 排右半边 } int main() { int arr[] {6, 1, 2, 7, 9, 3, 4, 5, 10, 8}; int n sizeof(arr) / sizeof(arr[0]); quickSort(arr, 0, n - 1); for (int i 0; i n; i) { cout arr[i] ; } cout endl; return 0; }这段代码里partition函数返回值是基准值最终的位置。quickSort拿到这个位置后递归处理左右两个子区间。递归终止条件就是left right——子区间里只有一个元素或者为空天然有序不需要再排。有一个很容易写错的点是内层两个while循环的条件。很多初学者会写成arr[j] pivot而不是arr[j] pivot。如果数据里存在大量重复元素写成严格大于会导致左右指针在重复值附近反复横跳甚至出现死循环。我建议在分区比较时都加上等号保证重复元素能稳定跳过。2.2 递归调用过程拆解一组数据怎么被层层切分我们拿上面那组数据继续走一遍递归过程。第一次分区完成后数组变成了[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]基准值6的下标是5。接下来递归调用quickSort(arr, 0, 4)处理左半边[5, 1, 2, 4, 3]调用quickSort(arr, 6, 9)处理右半边[9, 7, 10, 8]。左半边选基准值5分区后变成类似[3, 1, 2, 4, 5]5固定在下标4。继续递归处理[0,2]和[3,3]。[3,3]只有一个元素直接返回[0,2]选基准值3继续切分……整个过程像一棵二叉树向下展开。我一般在纸上画这种递归树的时候习惯把“已经归位的元素”用方括号标出来这样能直观看到每轮递归让多少个元素到达最终位置。快速排序和选择排序有个相似点每轮至少有一个元素基准值到达最终位置。区别在于选择排序每轮只确定一个而快速排序一轮分区还同时把数组切成了两块后续所有操作都在更小的区间上做所以整体效率高得多。2.3 递归的隐形成本与栈深度看到这里细心的读者可能会问递归调用的开销到底大不大其实这里有两层开销一层是函数调用本身的压栈和弹栈另一层是最坏情况下递归栈的深度。函数调用开销在现代CPU面前很小但也不是完全忽略不计。如果你对性能有极致要求可以参考后面第3章的非递归实现。但更值得关注的是递归深度。理想平衡情况下递归深度大约是log₂n100万条数据也就20层左右完全不是问题。但如果数据本身有序且我们固定选端点基准那么每次分区只切掉一个元素递归深度会变成n也就是100万层——不用等算法跑完栈先爆了。这在C/C里就是典型的栈溢出stack overflow崩溃。很多人在Windows上跑快排数据量一大就莫名其妙退出查了半天发现不是代码逻辑问题而是默认栈空间不够。Windows上MSVC默认栈大小一般是1MBLinux上pthread默认8MB。如果你要排特别大的数组要么改成非递归要么用循环展开优化要么就得考虑增大栈空间。不过最优雅的方案还是从算法层面保证递归深度可控也就是做基准值随机化或三数取中让最坏情况不再出现。3. 非递归实现用栈模拟系统的递归调用3.1 为什么很多人觉得非递归很难写我接触过不少读者总觉得递归代码“有点虚”——明明逻辑对但脑子里跟不上它一层层展开又收回的过程。这时候非递归实现反而能帮他们看清楚快速排序的本质。另一种情况更实际递归深度可能太大导致程序栈溢出。这一点在嵌入式开发、单片机等栈空间极小的环境下尤其明显。所以掌握非递归写法不仅是为了面试时秀操作更是工程中的保命技能。其实非递归的思路并不复杂。递归版本之所以能自动处理左右区间靠的是系统栈保存了函数调用的上下文。我们完全可以用一个显式的栈或者队列来手动保存这些待处理的区间边界。这里有个值得注意的细节我们并不需要模拟完整的函数调用栈因为快速排序的递归中每个函数体的局部状态非常简单——只有待排序区间的 left 和 right 两个值。所以只需要把这两个值入栈即可。3.2 基于栈的循环实现区间边界管理是核心下面给出用栈模拟递归的完整C实现#include iostream #include stack using namespace std; int partition(int arr[], int left, int right) { int pivot arr[left]; int i left, j right; while (i j) { while (i j arr[j] pivot) j--; if (i j) arr[i] arr[j]; while (i j arr[i] pivot) i; if (i j) arr[j--] arr[i]; } arr[i] pivot; return i; } void quickSortNonRecursive(int arr[], int left, int right) { stackpairint, int st; st.push({left, right}); while (!st.empty()) { auto [l, r] st.top(); st.pop(); if (l r) continue; int pivotIndex partition(arr, l, r); // 左右区间入栈等待后续处理 st.push({l, pivotIndex - 1}); st.push({pivotIndex 1, r}); } } int main() { int arr[] {6, 1, 2, 7, 9, 3, 4, 5, 10, 8}; int n sizeof(arr) / sizeof(arr[0]); quickSortNonRecursive(arr, 0, n - 1); for (int i 0; i n; i) { cout arr[i] ; } cout endl; return 0; }这段代码里stackpairint, int存储待处理的区间。每次循环弹出一个区间分区后把左右两个子区间压栈继续循环。循环结束的条件是栈为空也就是所有区间都处理完毕。这里有两个容易踩坑的地方。第一个是压栈顺序不影响正确性但如果你希望先处理左区间就要后压左区间因为栈是先进后出。第二个是边界处理l r时直接跳过这就是递归版本里left right这个终止条件的对应物。如果你忘了这个判断空区间或单元素区间会被反复压栈和弹出造成死循环。3.3 非递归实现的性能与工程价值从性能角度看非递归版本相比递归版本省掉了函数调用的开销。实测在大数据量下大约有10%~20%的性能提升具体取决于编译器的优化程度。有些同学可能觉得这个提升不够明显但在实时计算或嵌入式场景里这点提升可能就决定了系统能不能扛住压力。另外非递归实现还有个隐藏优势迭代器友好。C标准库的很多容器访问方式并不能天然配合递归函数但循环版本可以用迭代器范围来控制。我在重构一些旧项目时就经常把递归快排替换成非递归版本这样能更自然地和其他STL算法配合。不过要注意一点非递归版本虽然避免了系统栈溢出但如果分区严重失衡我们自己维护的栈也会变长极端情况下同样可能占用较多内存。所以不管用递归还是非递归核心还是要做好基准值选择让分区尽量平衡。4. 复杂度分析与稳定性真相4.1 最好情况、最坏情况和平均情况快速排序的时间复杂度是面试高频考点也是很多人的易混点。我直接给出结论再解释原因情况时间复杂度发生条件最好O(n log n)每次分区都恰好把数组对半分平均O(n log n)各种输入情况综合期望最坏O(n²)每次分区严重失衡如数据有序且选端点基准最好情况好理解每次分区后左右两个子区间大小接近n/2递归树的高度是log₂n每层合计处理n个元素所以总共是n log n次操作。最坏情况为什么是O(n²)假设数组已经完全升序我们每次选第一个元素为基准。第一次分区后基准值就是最小值它左边没有元素右边有n-1个元素。第二次分区又在n-1个元素里选最小值右边剩n-2个……这个过程等于每次只缩小一个规模总操作次数是n (n-1) (n-2) ... 1 n(n1)/2也就是O(n²)。平均情况的分析稍微复杂可以用期望来理解对于随机排列的输入每个元素被选为基准值的概率相等期望的分区效果是在某一半附近递归深度约等于log₂n总复杂度还是O(n log n)。这也是快速排序在实践里表现极好的原因——绝大多数真实数据都不是精心构造的恶意数据。4.2 空间复杂度不只是递归栈那么简单快速排序的空间复杂度经常被误以为是O(1)因为它看起来只用了几个临时变量。但实际上递归调用需要在系统栈上保存上下文所以空间复杂度其实是O(log n)到O(n)。最好情况平衡分区递归深度O(log n)空间复杂度O(log n)。最坏情况极端失衡递归深度O(n)空间复杂度O(n)。有经验的面试官会追问那非递归版本的空间复杂度是多少答案是一样的因为你自己维护的栈同样要保存待处理区间。区别只是系统栈换成了程序栈量级没有变化。真正能做到O(1)额外空间的排序算法有堆排序。这也是为什么堆排序在很多空间受限场景下仍然不可替代的原因。快速排序的优点是常数因子小、缓存友好但空间上并不占优。4.3 为什么快速排序是不稳定的排序算法“稳定性”这个概念排序算法里很关键如果两个相等的元素在排序前后的相对顺序不变算法就是稳定的。快速排序是不稳定的。原因在于分区过程中元素可能被大跨度地交换或移动。比如数组[5(第一个), 3, 5(第二个), 1]我们选第一个5为基准。挖坑法从右往左找比5小的元素1填到左边的坑里原来的5被覆盖掉了两个5的相对位置很可能在后续操作中发生变化。我见过不少人在面试时被问到这个问题答不上来或者直接说“不重要”。实际上稳定性在工程中非常有用——比如你需要先按日期排序再按优先级排序如果第二趟排序是稳定的第一趟的日期顺序就能保留下来。这也是为什么C标准库的std::stable_sort存在的原因。如果必须保证稳定性有两个选择一是用归并排序替代它是天然的稳定排序二是对快速排序做改造比如在元素比较时带上原始下标作为次要关键字但这样会引入额外开销有些得不偿失。绝大多数场景下直接选归并排序更干脆。5. 快速排序的工程级优化从能用到好用5.1 三数取中一条语句解决最坏情况第1.2节提过随机基准和三数取中这里展开讲。三数取中的做法是在当前区间的左端、中间、右端各取一个元素找出这三个值中间大小的那个作为基准值然后把它交换到区间第一个位置再走标准的挖坑法分区。代码实现很简单int medianOfThree(int arr[], int left, int right) { int mid left (right - left) / 2; if (arr[left] arr[mid]) swap(arr[left], arr[mid]); if (arr[left] arr[right]) swap(arr[left], arr[right]); if (arr[mid] arr[right]) swap(arr[mid], arr[right]); // 此时 arr[mid] 是三者的中位数 swap(arr[left], arr[mid]); // 把基准值放到最左边 return arr[left]; }为什么要取中位数因为对于已经有序或基本有序的数据中间位置元素的数值通常也接近中位数选它做基准能最大概率保证分区平衡。三数取中几乎不增加额外开销只有三次比较和几次交换却能把最坏情况的概率降到极低。我在实际项目里从来不用裸的“选第一个元素”写法已经是条件反射了。5.2 小区间插入排序别小看这几十行代码一个被很多人忽略的事实是快速排序在区间很小时递归调用的开销占比会越来越大。当子区间只剩几个元素时继续递归反而比直接插入排序更慢。所以你可以在quickSort里加一个阈值判断比如right - left 1 10时改用插入排序。这个阈值选多大合适我自己的经验是10~20之间太小效果不明显太大则丢失了快排的优势。C标准库的std::sort内部就做了类似的事情当区间长度小于某个阈值时切换到插入排序。工程实现时可以在递归函数的第一行加判断也可以在分区前判断。我习惯写成void quickSortOptimized(int arr[], int left, int right) { if (right - left 1 10) { insertionSort(arr, left, right); return; } // 其他逻辑不变 }这个优化在数据量小时看不出差别但在百万级数据上能明显降低递归调用次数整体性能提升大约20%~30%。面试时主动提到这个优化往往会成为加分项因为它说明你不只是背代码而是思考过性能问题。5.3 三路划分应对大量重复元素如果数组里有大量重复元素比如100万个1和少量其他数字普通快速排序会怎样你会发现分区后基准值被放到了某个位置但所有等于基准值的元素散落在左右两边后续递归还要反复处理它们效率很低。三路划分3-way partition就是专门解决这个问题的。它的思路是把数组分成三块小于基准值、等于基准值、大于基准值。这样等于基准值的元素一次分区就全部归位不用再参与后续递归。实现上经典的Dijkstra荷兰国旗算法就可以用来做三路划分。算法用三个指针lt、i、gt[left, lt-1]存放小于基准值的元素。[lt, gt]存放等于基准值的元素。[gt1, right]存放大于基准值的元素。void quickSort3Way(int arr[], int left, int right) { if (left right) return; int pivot arr[left]; int lt left; // arr[left1..lt] pivot int i left 1; // 扫描指针 int gt right; // arr[gt..right] pivot while (i gt) { if (arr[i] pivot) { swap(arr[lt], arr[i]); lt; i; } else if (arr[i] pivot) { swap(arr[i], arr[gt]); gt--; } else { i; } } quickSort3Way(arr, left, lt - 1); quickSort3Way(arr, gt 1, right); }这个版本的妙处在于一旦扫描完成[lt, gt]区间里的所有元素都已经等于基准值直接跳过。在处理大量重复数据的场景三路划分的速度可以比普通快排快好几倍。5.4 尾递归优化减少递归深度尾部递归优化是编译器层面的技巧。在快速排序的递归版本里最后一步是对右子区间递归调用。如果编译器支持尾递归优化栈帧可以被复用递归深度就不会累加。但C/C标准编译器对快速排序这种“递归后又接着递归”的模式优化效果并不总是理想的。更实际的做法是在一轮递归中只对较短的那半个区间递归调用较长的区间用循环迭代处理。这个技巧有点小众但能有效把递归深度控制在O(log n)以内和尾递归优化的效果异曲同工。void quickSortTailOptimized(int arr[], int left, int right) { while (left right) { int pivotIndex partition(arr, left, right); // 只递归较短的区间 if (pivotIndex - left right - pivotIndex) { quickSortTailOptimized(arr, left, pivotIndex - 1); left pivotIndex 1; } else { quickSortTailOptimized(arr, pivotIndex 1, right); right pivotIndex - 1; } } }这个写法第一次看可能有点绕但原理很简单每次迭代处理掉一个区间把另一个区间留给下一轮循环。因为始终先处理短区间递归深度被限制在对数级别栈溢出的风险大大降低。6. 从排序到解决实际问题快速排序的典型应用6.1 快速选择算法不排序也能找第K大快速排序的一个经典变体是快速选择QuickSelect它只递归处理基准值所在的半边用来在无序数组中寻找第K大或第K小的元素。思路是分区后基准值下标为p。如果p正好等于K-1基准值就是第K小的元素如果p大于K-1只需要在左半边继续找否则在右半边继续找。平均时间复杂度是O(n)比先排序再取值的O(n log n)快不少。这个算法在数据分析里特别实用。比如你有一个百万级的用户评分数组想知道90分位线是多少不需要把整个数组排好序只要用快速选择找到第900000大的分数就行。我有一次处理图像像素直方图时也用过类似思路省了不少时间。6.2 结构体与自定义类型排序的注意事项实际项目中我们排的通常不是简单的整数而是结构体、对象或者类。这时快速排序的比较逻辑就要做相应调整。C语言里可以用函数指针C里可以用lambda表达式或仿函数。下面是一个按成绩对学生排序的例子struct Student { string name; int score; }; void sortStudents(Student arr[], int left, int right) { if (left right) return; int pivotIdx partition(arr, left, right); sortStudents(arr, left, pivotIdx - 1); sortStudents(arr, pivotIdx 1, right); }注意这种情况下分区函数里的比较要改成arr[j].score pivot.score这样按字段比较而不能直接比较整个结构体。这是我带新人时长常见到的一个错误写整数快排写顺手了换成结构体忘了改比较逻辑结果整个程序跑出莫名其妙的结果。6.3 并行化把分治发挥到极致快速排序的分治结构天然适合并行化。你可以把左右两个子区间交给不同的线程去排序完了之后数组合并起来——严格来说不需要合并因为它们已经通过分区互不干扰了。C11之后可以用std::async或std::thread来做并行快速排序。但要注意线程的创建也有开销数据量太小的时候并行反而更慢一般建议数据量达到几十万以上再开多线程。另外还要注意递归深度和线程数的平衡不能无限开线程。我在多核服务器上处理千万级数据时把快速排序改成四线程版本实测加速比大约2.5~3倍效果还不错。7. 实操经验VSCode配置C/C运行环境并跑通快速排序7.1 工具链安装与基本配置很多读者拿到上面的代码第一反应是想运行一下。但C/C环境配置这件事看起来简单实际操作起来坑不少。尤其是VSCode它本身只是一个编辑器编译和运行还需要额外配置工具链。在Windows上我推荐使用MinGW-w64作为编译器。下载安装后最关键的一步是把bin目录里面放着g.exe添加到系统PATH环境变量里。这个步骤经常有人漏掉导致VSCode里怎么配置都报“找不到编译器”。macOS上可以用Xcode Command Line Tools命令行执行xcode-select --install就能装好Clang编译器。Linux上就更简单了sudo apt install build-essential或者sudo yum groupinstall Development Tools。装完之后在终端里输入g --version能看到版本信息就说明工具链没问题了。7.2 VSCode三个核心配置文件VSCode运行C程序需要三个配置文件tasks.json负责编译launch.json负责调试c_cpp_properties.json负责代码智能提示。tasks.json的核心是把你的.cpp文件编译成可执行文件。我常用的简单配置如下{ version: 2.0.0, tasks: [ { label: build, type: shell, command: g, args: [ -g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: { kind: build, isDefault: true } } ] }launch.json负责启动调试器让代码能打断点看变量值。注意调试器和编译器要配套MinGW的gdb和Visual Studio的调试器不能混用。很多人遇到“无法启动调试”的报错多半就是这个不配套导致的。7.3 常见报错与解决办法我总结一下配置过程中最常见的报错和处理方法供大家参考报错信息原因解决方法g 不是内部或外部命令编译器没有加入PATH检查MinGW的bin目录路径是否加到PATHundefined reference to __gxx_personality_v0用的是gcc而不是g编译C代码确保tasks.json里command是g而不是gcctask not foundtasks.json配置有误检查label字段是否匹配或重新生成配置无法打开源文件iostream智能提示找不到头文件路径配置c_cpp_properties.json里的includePath已检测到匹配的 Visual C Redistributable跳过安装安装某些依赖时提示VC运行库已存在这是正常提示不代表报错其中“已检测到匹配的 Visual C Redistributable跳过安装”这个提示很多新手会误以为安装失败了。其实它只是说电脑里已经有VC运行库不需要重复安装直接继续就行。我第一次遇到时也愣了半天查了一圈才发现是虚惊一场。8. 常见问题与排查技巧实录8.1 死循环问题两个while的边界条件快速排序里最常见的bug就是死循环。我见过最多的写法是while (i j arr[j] pivot) j--; while (i j arr[i] pivot) i;这里如果arr[j]正好等于pivot第一层循环会停下来但紧接着如果arr[i]也等于pivot第二层循环也会停下来。如果两个指针都卡在等于pivot的元素上就会发生无限交换导致死循环。解决办法就是我第2章反复强调的把严格不等改成和。排查死循环问题我的经验是在代码里临时加一个计数器循环次数超过数组长度就强制退出并打印当前状态。这个方法土但有效能很快定位是哪一段循环卡住了。8.2 栈溢出问题数据量一大就崩溃如果程序在小数据量下一切正常一旦数据量到几十万或者上百万就崩溃优先怀疑递归深度过大。可以打印每次递归的深度看看最大深度到了多少。解决方案按优先级排列先做三数取中或随机化基准从根源上避免最坏情况再把递归改成非递归彻底摆脱系统栈限制最后可以考虑增大栈空间治标不治本。我建议前两种一起做基本可以解决99%的栈溢出问题。8.3 排序结果错误基准值归位不正确排序结果不对通常是分区函数里基准值的位置放错了。有时候是最后基准值填入的坑位置不对有时候是返回的下标和实际位置差了1还有可能是递归时左右区间的边界算错了比如把pivotIndex - 1写成了pivotIndex。遇到排序结果不对的情况我的排查方法是复用第2.1节的测试数据在partition函数里逐步打印数组状态特别关注基准值最终被放到了哪个位置。一旦看到基准值的左右两侧不符合“左小右大”的规则问题基本就锁定了。8.4 乱码问题Windows控制台中文输出异常最后说一个和快速排序本身无关、但很多人会遇到的坑在Windows控制台里用cout输出中文字符串时出现乱码。这通常不是代码逻辑问题而是编码不一致。VSCode默认使用UTF-8编码而Windows控制台可能使用GBK或936代码页。解决方法是代码开头加#pragma execution_character_set(utf-8)仅MSVC有效或者在运行完程序后在控制台执行chcp 65001切换代码页。更简单的办法是用英文输出调试信息省心。C新标准也支持使用std::cout u8中文处理UTF-8字符串但控制台显示的兼容性还是要看终端。我个人在实际操作中的体会是快速排序这个算法看十遍不如自己从头到尾写一遍。第一次写的时候死循环、边界错乱、栈溢出这些坑基本都会踩一遍踩完之后再去读优化技巧每一条都能看懂背后的动机。如果你能把递归版本、非递归版本和三路划分版本各写一遍还能讲清楚它们各自的适用场景那快速排序这一关就算真正过了。最后再分享一个小技巧平时刷题或者做项目的时候除非题目明确要求手写快排否则优先考虑C标准库的std::sort它内部融合了内省排序、插入排序和堆排序的混合策略工程表现比手写版稳得多——但前提是你得先能手写出快排才看得懂它为什么那么设计。
返回列表