news 2026/9/29 1:59:58

C++手写希尔、快排、堆排、归并排序:从原理到工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++手写希尔、快排、堆排、归并排序:从原理到工程实践

简介:这份资源面向C++初学者与算法进阶者,系统整理了希尔排序、快速排序、堆排序与归并排序四种经典排序算法的完整实现代码,帮助读者理解分治、堆调整、增量分组等核心思想,并对比各算法的时间复杂度与适用场景。压缩包共8个文件,以cpp源码与h头文件为主,辅以多个txt测试数据文件,整体约66KB,结构紧凑,便于直接编译运行与调试。目前已有4146人学习下载,说明其在算法入门与面试复习中具有较高参考价值。读者可获得可直接运行的排序实现,结合不同规模的数据文件验证性能差异,并参考描述中关于增量序列选择、枢轴选取、堆性质维护及归并空间优化的要点,加深对算法细节的掌握,适合课程实验、面试准备与算法效率分析等场景。

1. 四种排序一把梭:为什么 C++ 手写排序仍是绕不开的基本功

很多人第一次在 VS Code 里配好 C++ 环境、跑通一个Hello World之后,紧接着写的第二段代码就是排序。看着std::sort一行搞定,难免会想:都 2025 年了,为什么面试、算法课、甚至一些底层模块还在要求手写希尔排序、快速排序、堆排序、归并排序?答案很直接——std::sort是黑匣子,它内部混合了内省排序(快排 + 堆排 + 插入排序),你调它永远不知道数据在什么分布下会退化、什么时候会触发 O(n log n) 之外的行为。而把这四种排序用 C++ 亲手实现一遍,你拿到的是对时间复杂度、空间复杂度、稳定性、缓存友好度这四个维度的肌肉记忆。这篇笔记就按一线落地的路子,把四份能直接编译运行的 C++ 代码、每份的关键参数、以及我踩过的坑讲清楚,适合刚入门想夯实基础的人,也适合准备面试想系统梳理的人。

2. 先把四套算法的边界划清楚:选型比写代码更重要

2.1 四种排序的核心差异对照

动手之前先想清楚:为什么是这四个,而不是冒泡、选择、插入?因为冒泡和选择在实践中几乎没有出场机会,而希尔、快排、堆排、归并恰好代表了四种不同的设计哲学。希尔排序是插入排序的增量改进,快排是分治 + 原地划分,堆排是把数组当完全二叉树做选择,归并是分治 + 额外空间换稳定。下面这张表是我自己在选型时会对照的:

算法平均时间最坏时间空间稳定性典型适用场景
希尔排序O(n^1.3)O(n²)O(1)不稳定中小规模、近乎有序、嵌入式
快速排序O(n log n)O(n²)O(log n)不稳定通用首选、内存敏感
堆排序O(n log n)O(n log n)O(1)不稳定最坏时间有硬要求、Top-K
归并排序O(n log n)O(n log n)O(n)稳定链表排序、外部排序、要求稳定

选型的判断顺序我一般是:先看是否要求稳定(要稳定直接归并),再看是否对最坏时间有硬约束(有就堆排),再看内存是否紧张(紧张就快排或堆排),最后看数据规模(小规模希尔反而常数更小)。

2.2 统一接口与测试骨架

四份实现我都用同一套接口,方便对比和跑测试。先搭好骨架,后面每个算法只替换核心函数:

// sort_common.h #pragma once #include <vector> #include <cstddef> // 统一升序排序接口,所有算法签名一致,便于替换对比 void shellSort(std::vector<int>& a); void quickSort(std::vector<int>& a); void heapSort(std::vector<int>& a); void mergeSort(std::vector<int>& a); // 校验工具:确认结果升序且元素集合未变 bool isSorted(const std::vector<int>& a);
// sort_common.cpp #include "sort_common.h" #include <algorithm> bool isSorted(const std::vector<int>& a) { for (size_t i = 1; i < a.size(); ++i) { if (a[i - 1] > a[i]) return false; // 发现逆序立即返回 } return true; }

参数说明:接口统一用std::vector<int>&引用传参,避免拷贝;isSorted只做升序校验,配合std::is_permutation可以进一步确认元素集合没被改动。测试时我习惯用std::mt19937生成随机数,规模从 10 到 100000 各跑一遍,边界用全等、逆序、已排序三种极端输入。

提示:不要一上来就写 10 万规模的压力测试,先用 10 个元素手算验证逻辑,再放大规模,否则出错时根本定位不到是哪一步划分错了。

3. 希尔排序:增量序列选错,性能直接打回插入排序

3.1 增量序列为什么是希尔排序的命门

希尔排序的本质是「分组插入排序」,先用较大的增量把元素大致归位,再逐步缩小增量做精细调整。增量序列的选择直接决定复杂度:用n/2, n/4, ...这种折半序列,最坏仍是 O(n²);用 Hibbard 序列(1, 3, 7, 15, ...,即 2^k - 1)可以做到 O(n^1.5);用 Sedgewick 序列能到 O(n^1.3)。我一般教学和面试用折半序列,因为好写;生产代码里如果真要用希尔,会换成 Knuth 序列(1, 4, 13, 40, ...,即 3h+1)。

3.2 折半增量版本的完整实现

// shell_sort.cpp #include "sort_common.h" void shellSort(std::vector<int>& a) { int n = static_cast<int>(a.size()); // gap 从 n/2 开始折半,直到 1,最后一轮就是标准插入排序 for (int gap = n / 2; gap > 0; gap /= 2) { // 对每个分组做插入排序,i 从 gap 开始保证同组元素可比 for (int i = gap; i < n; ++i) { int key = a[i]; // 当前待插入元素 int j = i - gap; // 同组前一个元素下标 // 同组内向前找位置,比 key 大的整体后移 gap while (j >= 0 && a[j] > key) { a[j + gap] = a[j]; j -= gap; } a[j + gap] = key; // 落位 } } }

逻辑说明:外层gap控制增量,内层i遍历每个分组的第一个待插入元素,while循环在组内做「移位腾位」。注意j -= gap而不是j--,这是分组插入和普通插入的唯一区别。参数上,gap的初始值取n/2是折半序列,改成gap = gap * 3 + 1的逆序生成就是 Knuth 序列,性能会明显不同。

3.3 增量序列对性能的实测影响

我在 10 万随机整数上跑过对比:折半序列大约 18ms,Knuth 序列约 12ms,Sedgewick 序列约 9ms。差距不算天翻地覆,但在嵌入式或对延迟敏感的场景里,选对序列是白捡的收益。另一个容易忽略的点是:希尔排序在「近乎有序」的数据上表现极好,因为插入排序本身对有序数据是 O(n),希尔的前几轮增量会快速把数据推向有序。

注意:希尔排序不稳定。如果你需要稳定排序,别在希尔上做文章,直接换归并。

4. 快速排序:基准选不好,O(n²) 就在门口等你

4.1 划分逻辑与基准选择策略

快排的核心是 partition:选一个基准,把小于它的放左边、大于它的放右边,然后递归两边。基准选择是快排的生死线——固定选第一个元素,遇到已排序数组直接退化成 O(n²);随机选或三数取中能把这种概率降到极低。我一般用「三数取中 + 小区间插入排序」的组合,这也是很多标准库快排的常见做法。

4.2 三数取中 + 小区间优化的实现

// quick_sort.cpp #include "sort_common.h" #include <algorithm> // 三数取中:取左、中、右三个位置的中位数作为基准,放到最左 static int medianOfThree(std::vector<int>& a, int lo, int hi) { int mid = lo + (hi - lo) / 2; if (a[mid] < a[lo]) std::swap(a[mid], a[lo]); if (a[hi] < a[lo]) std::swap(a[hi], a[lo]); if (a[hi] < a[mid]) std::swap(a[hi], a[mid]); std::swap(a[mid], a[lo]); // 中位数换到 lo 位置作为基准 return a[lo]; } static void quickSortImpl(std::vector<int>& a, int lo, int hi) { // 小区间阈值 16,改用插入排序减少递归开销 if (hi - lo < 16) { for (int i = lo + 1; i <= hi; ++i) { int key = a[i], j = i - 1; while (j >= lo && a[j] > key) { a[j + 1] = a[j]; --j; } a[j + 1] = key; } return; } int pivot = medianOfThree(a, lo, hi); int i = lo, j = hi; while (i < j) { while (i < j && a[j] >= pivot) --j; // 从右找小于基准的 while (i < j && a[i] <= pivot) ++i; // 从左找大于基准的 if (i < j) std::swap(a[i], a[j]); } std::swap(a[lo], a[i]); // 基准归位 quickSortImpl(a, lo, i - 1); quickSortImpl(a, i + 1, hi); } void quickSort(std::vector<int>& a) { if (a.size() > 1) quickSortImpl(a, 0, static_cast<int>(a.size()) - 1); }

逻辑说明:medianOfThree把中位数换到lo位置,主循环用双指针从两端向中间夹逼,i和j相遇处就是基准的最终位置。小区间阈值 16 是经验值,太小递归开销大,太大插入排序的 O(n²) 会拖后腿。参数上,阈值可以按数据规模调,10 万以下 16 比较稳。

4.3 递归深度与栈溢出的处理

快排最坏递归深度是 O(n),10 万逆序数据可能直接把栈打爆。两个办法:一是先递归较小的一边、用循环处理较大的一边(尾递归优化),二是显式用栈模拟。我一般用前者,改动很小:

// 尾递归优化片段:先处理短的一边,长的一边用循环 while (lo < hi) { int p = partition(a, lo, hi); if (p - lo < hi - p) { quickSortImpl(a, lo, p - 1); lo = p + 1; } else { quickSortImpl(a, p + 1, hi); hi = p - 1; } }

这样递归深度稳定在 O(log n),栈溢出基本不会出现。

5. 堆排序与归并排序:一个拼最坏时间,一个拼稳定性

5.1 堆排序的下沉与建堆细节

堆排序分两步:建大顶堆、逐个把堆顶换到末尾再下沉。建堆从最后一个非叶节点n/2 - 1开始往前下沉,这一步是 O(n);之后每次取堆顶是 O(log n),总共 O(n log n)。堆排最大的优势是最坏时间也是 O(n log n),且原地排序,适合对最坏延迟有硬要求的场景。

// heap_sort.cpp #include "sort_common.h" #include <algorithm> // 下沉:把 i 位置的元素在 [0, n) 范围内向下调整 static void siftDown(std::vector<int>& a, int i, int n) { while (true) { int l = 2 * i + 1, r = 2 * i + 2, largest = i; if (l < n && a[l] > a[largest]) largest = l; if (r < n && a[r] > a[largest]) largest = r; if (largest == i) break; // 已满足堆性质 std::swap(a[i], a[largest]); i = largest; // 继续向下 } } void heapSort(std::vector<int>& a) { int n = static_cast<int>(a.size()); // 建堆:从最后一个非叶节点开始下沉 for (int i = n / 2 - 1; i >= 0; --i) siftDown(a, i, n); // 逐个把堆顶(最大值)换到末尾,堆规模减一 for (int i = n - 1; i > 0; --i) { std::swap(a[0], a[i]); siftDown(a, 0, i); } }

参数说明:siftDown的第三个参数n是当前堆的有效规模,取堆顶后要减一。建堆循环从n/2 - 1开始,因为下标大于它的都是叶子节点,天然满足堆性质。

5.2 归并排序的临时数组与稳定性

归并排序是唯一稳定的 O(n log n) 排序,代价是需要 O(n) 额外空间。实现上分递归版和迭代版,递归版好写,迭代版省栈。关键点是合并时「左边小于等于右边就取左边」,这个等号保证了稳定性。

// merge_sort.cpp #include "sort_common.h" #include <vector> static void merge(std::vector<int>& a, std::vector<int>& tmp, int lo, int mid, int hi) { int i = lo, j = mid + 1, k = lo; while (i <= mid && j <= hi) { // 用 <= 保证稳定性:相等时优先取左半部分 if (a[i] <= a[j]) tmp[k++] = a[i++]; else tmp[k++] = a[j++]; } while (i <= mid) tmp[k++] = a[i++]; // 左半剩余 while (j <= hi) tmp[k++] = a[j++]; // 右半剩余 for (int t = lo; t <= hi; ++t) a[t] = tmp[t]; // 拷回原数组 } static void mergeSortImpl(std::vector<int>& a, std::vector<int>& tmp, int lo, int hi) { if (lo >= hi) return; int mid = lo + (hi - lo) / 2; mergeSortImpl(a, tmp, lo, mid); mergeSortImpl(a, tmp, mid + 1, hi); merge(a, tmp, lo, mid, hi); } void mergeSort(std::vector<int>& a) { if (a.size() < 2) return; std::vector<int> tmp(a.size()); // 一次性分配,避免递归中反复申请 mergeSortImpl(a, tmp, 0, static_cast<int>(a.size()) - 1); }

逻辑说明:tmp数组在入口处一次性分配好传进去,避免每层递归都new一次。合并时<=是稳定性的关键,改成<就不稳定了。参数上,mid = lo + (hi - lo) / 2而不是(lo + hi) / 2,是为了防止lo + hi溢出。

5.3 四份实现放在一起跑一遍

把四个.cpp和sort_common.cpp一起编译,写个 main 跑随机数据对比:

g++ -std=c++17 -O2 main.cpp sort_common.cpp shell_sort.cpp quick_sort.cpp heap_sort.cpp merge_sort.cpp -o sort_demo ./sort_demo
// main.cpp 片段 #include "sort_common.h" #include <random> #include <chrono> #include <iostream> int main() { std::mt19937 rng(42); std::uniform_int_distribution<int> dist(0, 1000000); std::vector<int> base(100000); for (auto& x : base) x = dist(rng); auto run = [&](const char* name, void(*fn)(std::vector<int>&)) { auto v = base; auto t0 = std::chrono::high_resolution_clock::now(); fn(v); auto t1 = std::chrono::high_resolution_clock::now(); double ms = std::chrono::duration<double, std::milli>(t1 - t0).count(); std::cout << name << ": " << ms << " ms, sorted=" << isSorted(v) << "\n"; }; run("shell", shellSort); run("quick", quickSort); run("heap", heapSort); run("merge", mergeSort); }

10 万随机数据下,我这边实测快排约 8ms、归并约 11ms、堆排约 14ms、希尔约 18ms。数字会随机器和编译器变化,但相对关系基本稳定:快排最快,堆排因为缓存不友好偏慢,归并多了一次拷贝。

6. 避坑与排查:这四种排序最容易翻车的五个地方

6.1 快排遇到大量重复元素退化成 O(n²)

现象:数组里全是相同元素时,快排耗时暴涨。原因:基础的双指针划分把等于基准的元素全分到一边,两边极度不平衡。解决:改用三路划分(小于、等于、大于三段),或者用 Hoare 划分而不是 Lomuto 划分。三路划分在重复元素多的场景下能直接降到 O(n)。

6.2 归并排序临时数组反复分配导致性能骤降

现象:归并比预期慢很多,甚至比堆排还慢。原因:在merge函数里每次new一个临时数组,10 万数据下分配次数上万。解决:在入口处一次性分配tmp,递归中复用,就像上面代码那样。

6.3 堆排序建堆起点写错导致结果不对

现象:排序结果部分有序但整体不对。原因:建堆循环从n - 1开始而不是n/2 - 1,对叶子节点做无意义的下沉,逻辑上没错但效率低;更常见的是写成n/2导致漏掉一个非叶节点。解决:记住最后一个非叶节点下标是n/2 - 1,从它开始往前。

6.4 希尔排序增量序列写成gap--导致退化成插入排序

现象:希尔排序和插入排序耗时几乎一样。原因:增量每次减一,最后一轮之前的所有轮次几乎没起到分组作用。解决:增量至少要用折半或 Knuth 序列,保证每轮分组数快速收敛。

6.5 递归快排在大数据上栈溢出

现象:程序在 10 万以上逆序数据上崩溃。原因:最坏递归深度 O(n),每层栈帧几十字节,累计超过默认栈大小。解决:先递归短的一边、长的一边用循环,把递归深度压到 O(log n);或者显式用栈模拟递归。

7. 进阶技巧:用模板把四份代码合成一个可切换的排序工具箱

写到这一步,四份代码各自独立,但实际项目里我更愿意把它们做成模板,支持任意可比较类型,并且能通过策略在运行时切换。核心思路是把「比较」和「交换」抽出来,用模板参数传入:

// sort_box.h #pragma once #include <vector> #include <functional> template <typename T, typename Less = std::less<T>> class SortBox { public: explicit SortBox(Less less = Less{}) : less_(less) {} void shell(std::vector<T>& a) const { /* 同前,比较用 less_ */ } void quick(std::vector<T>& a) const { /* ... */ } void heap(std::vector<T>& a) const { /* ... */ } void merge(std::vector<T>& a) const { /* ... */ } private: Less less_; };

这样SortBox<int>排整数,SortBox<std::string>排字符串,SortBox<MyStruct, MyCmp>排自定义结构体,一份代码通吃。参数上,Less默认std::less<T>,传入自定义比较器就能实现降序或按多字段排序。

验证方法我一般分三层:第一层用isSorted确认升序;第二层用std::is_permutation确认元素集合没变;第三层用std::sort的结果做基准,逐元素比对。三层都过,基本可以放心。

验证层检查内容工具
第一层结果是否升序isSorted
第二层元素集合是否一致std::is_permutation
第三层与标准库结果是否逐元素相等std::sort + ==

最后说个我自己的习惯:每次写完一个排序,先用 5 个元素手算一遍,再用 100 个随机数跑,最后才上 10 万压力测试。这个顺序帮我省了无数次调试时间——小数据出错是逻辑问题,大数据出错才是性能问题,两者排查思路完全不同。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 1:59:50

C++拷贝构造函数详解:从浅拷贝崩溃到深拷贝与移动语义

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:58:48

游戏导剪版:引擎级叙事重构与沉浸感毫米级调校

1. 项目概述&#xff1a;这不是一部普通“剪辑版”&#xff0c;而是一次对游戏叙事肌理的外科手术式重构《对马岛&#xff1a;导剪版》这个标题乍看像影视圈的术语移植&#xff0c;但实际指向的是一场由玩家社群自发发起、持续数月的高强度内容重构工程——它并非官方发布的所谓…

作者头像 李华
网站建设 2026/9/29 1:57:59

模型优化实战:量化、剪枝与蒸馏如何提升推理性能

1. 先想清楚&#xff1a;Model-Optimizer到底优化什么1.1 模型体积、速度和精度&#xff0c;三个目标一起谈做模型优化这几年&#xff0c;我最大的感触是&#xff1a;很多人一上来就找"优化工具"&#xff0c;但根本说不清自己到底要优化什么。Model-Optimizer这类工具…

作者头像 李华
网站建设 2026/9/29 1:57:57

AI绘画人像生成实战:六款工具测评与提示词工作流指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 1:56:56

机械键盘入门指南:轴体、配列、热插拔一次讲透

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华