我很少会花一整篇的篇幅去复盘一个“听上去很简单”的排序算法,但归并排序确实值得。原因很简单:这个算法我前前后后写了不下十遍,每次以为自己稳了,结果一跑就是段错误、乱序、栈溢出,甚至无声无息地死循环。尤其是在调试器不够顺手的C语言环境里,排查起来相当折磨。而等到算法真正跑通,又会有新的问题冒出来——性能不够、内存占用过高、代码结构乱到不敢提交到开源仓库。
这篇文章就把我从头到尾的折腾过程完整记下来,包括C语言实现归并排序时最隐秘的边界条件、调试方法论、四个方向的优化手段(附带实测数据),以及最后把项目整理并推送到Gitee开源的整个流程。你如果也在写归并排序,或者正准备把自己第一个算法项目挂到Gitee上,这篇文章应该能帮你省下不少时间。
1. 为什么归并排序总在边界条件上翻车
先说结论:归并排序的核心思想并不难,难的是把“区间”和“索引”这件事搞对。
1.1 递归分治的直觉与真实执行逻辑
初学时最容易产生的直觉是:把一个数组分成两半,各自排序,再合并。这个理解在宏观上完全正确,但落到C语言的数组下标上,就会出现一连串的歧义。
比如你有一个数组 int arr[10],要对 arr[0] 到 arr[9] 排序。你可能会很自然地写一个递归函数:
c复制void merge_sort(int arr[], int left, int right) {
if (left >= right) return;
int mid = (left + right) / 2;
merge_sort(arr, left, mid);
merge_sort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
这套“左闭右闭”写法看起来正常,但里面藏着好几个容易出问题的地方。
第一个问题是 if (left >= right) 这个终止条件。用左闭右闭区间时,只有一个元素的区间是 left == right,你写 >= 其实也没错,但新手很容易写成 if (left == right),然后当区间非法时(比如 left > right)递归无限进行下去。这种情况在 mid + 1 超过 right 时就会发生,表现为栈溢出。
第二个问题是 mid 的计算。(left + right) / 2 在 left 和 right 都很大时可能溢出(int 上限),标准的做法是 left + (right - left) / 2。虽然排序算法一般不会遇到这么大的数组,但养成习惯总没坏处。
第三个问题,也是我踩得最久的坑,是合并时对“左右两个子数组”的边界界定。你必须非常明确:左子数组是 arr[left] 到 arr[mid],右子数组是 arr[mid+1] 到 arr[right]。在实现 merge 函数时,新手很容易把 mid 这个位置算错,导致元素被覆盖或重复。
1.2 区间开闭约定:一切混乱的根源
我后来才意识到,归并排序里几乎所有的 bug 都源于区间开闭不统一。如果你在递归函数里用的是左闭右闭,那么合并函数里也必须使用同一套约定;如果你在分治时用了 [left, mid) 和 [mid, right) 这种左闭右开区间,那递归终止条件和合并逻辑又完全是另一套写法。
我自己最推荐的是“左闭右开”表示法。原因有三点:
- 空区间容易表示:
[left, left)就是空,不用单独考虑 left > right 的情况。 - 遍历数组时更自然:
for (int i = left; i < right; i++)不用纠结<=还是<。 - C++ STL 的
sort系列、Go 的sort.Slice底层都采用类似的半开区间理念,写习惯了跨语言迁移也轻松。
用左闭右开重写上面的递归:
c复制void merge_sort(int arr[], int left, int right) {
if (right - left <= 1) return;
int mid = left + (right - left) / 2;
merge_sort(arr, left, mid);
merge_sort(arr, mid, right);
merge(arr, left, mid, right);
}
这样,left 到 mid 是左子数组,mid 到 right 是右子数组,两个区间互不重叠,合并时也容易确定各部分的长度。
1.3 合并时的索引漂移
有了统一区间约定,合并函数写起来就清晰很多:
c复制void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left;
int n2 = right - mid;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[left + i];
for (int j = 0; j < n2; j++) R[j] = arr[mid + j];
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) arr[k++] = L[i++];
else arr[k++] = R[j++];
}
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
这里最容易出错的点是 k 的起始位置。它是 left,不是 0,也不是 mid。如果写成 int k = 0,那合并结果会从头开始覆盖数组,直接把未处理的元素搞丢。我有一版代码就是在这个位置翻车的,调试了半小时才发现是 k 的赋值问题。
另外,L 和 R 的长度要仔细想清楚。左闭右开区间 [left, mid) 的元素个数是 mid - left,右子数组 [mid, right) 的元素个数是 right - mid。这里不能出现 +1 或 -1 的“修正”,否则数组访问就会越界,轻则乱序,重则段错误。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 我的完整调试链路:从段错误到正确输出
算法写出来是一回事,能跑对是另一回事。下面这段是我踩坑后总结出的完整调试流程,每一步都有明确目的。
2.1 第一轮崩溃:段错误到底发生在哪一行
我的第一版归并排序,运行后直接 Segmentation fault。用 gdb 跑了一遍,只提示崩溃在 merge 函数内部,但没有具体到哪一行。
大部分人到这里就开始瞎改,我当时的做法是把问题拆开:
- 确认递归终止条件是否正常。
- 确认
mid是否一定在[left, right)内。 - 确认合并访问的下标是否越界。
用左闭右开写法,终止条件是 right - left <= 1,递归深度有限,正常情况下不会栈溢出。所以问题大概率出在数组访问越界。我检查了 L[n1] 和 R[n2] 的定义,发现 n1、n2 计算没问题,那问题就在循环内部。
最终定位到问题:我在复制 L 数组时写成了 for (int i = 0; i <= n1; i++),多复制了一个元素。虽然 C 语言不检查数组越界,但读到一个垃圾值,就可能让后续的比较逻辑出错,甚至导致 k 越界写入。这是一个典型的“差一错误”。
2.2 用日志定位检查点的状态
段错误修掉后,排序结果依然不正确。这时候再用 gdb 单步跟踪效率太低,我换了个思路:在关键位置打印日志。
在 merge_sort 函数的开头打印区间:
c复制printf("merge_sort called: [%d, %d)\n", left, right);
在 merge 函数的开头打印合并区间:
c复制printf("merge [%d, %d, %d)\n", left, mid, right);
然后用一个非常小的数组测试,比如 4 个元素:[4, 3, 2, 1]。
日志输出能帮我快速确认递归的分割方式是否符合预期:
code复制merge_sort called: [0, 4)
merge_sort called: [0, 2)
merge_sort called: [0, 1)
merge_sort called: [1, 2)
merge [0, 1, 2)
merge_sort called: [2, 4)
merge_sort called: [2, 3)
merge_sort called: [3, 4)
merge [2, 3, 4)
merge [0, 2, 4)
从日志来看,递归分割是正确的,合并区间的传递也正确,但结果还是不对。我于是又把 merge 内部每次写入 arr[k] 的索引和值打印出来,这才发现是 L[i] <= R[j] 的比较方向写反了,导致降序而非升序。这种低级的逻辑错误如果不靠日志,光靠眼睛看代码很难注意到。
2.3 用随机数据验证,而不是只测一组数据
修完比较方向后,单个数组已经能排对了。但排序算法不比别的,一组数据通过不说明问题。我写了一个简单的验证脚本,随机生成 1000 组数组,每组包含 0 到 100 个随机整数,分别调用自己的 merge_sort 和系统的 qsort,然后比对结果。
这里有一个重要的点:比对时必须使用同样的数据副本,否则两个排序函数处理不同数据,你根本不知道谁对谁错。
c复制void test_random() {
srand(time(NULL));
for (int t = 0; t < 1000; t++) {
int n = rand() % 101;
int a[n], b[n];
for (int i = 0; i < n; i++) {
a[i] = rand() % 10000;
b[i] = a[i];
}
merge_sort(a, 0, n);
qsort(b, n, sizeof(int), cmp_int);
for (int i = 0; i < n; i++) {
if (a[i] != b[i]) {
printf("mismatch at test %d, index %d\n", t, i);
return;
}
}
}
printf("all tests passed\n");
}
这个测试帮我抓到了最后一个隐藏 bug:空数组。当 n == 0 时,merge_sort 收到 [0, 0),逻辑上应该直接返回,但如果终止条件写的是 if (left == right),虽然也能返回,可一旦后续调用 merge 时出现 right - left == 0 的情况,合并函数里的数组长度为 0,虽然不会访问,但如果代码里写了 arr[k] 的初始化赋值就可能出问题。所以我在 merge 函数开头加了保护:
c复制if (right - left <= 1) return;
有些语言对空数组的处理很宽容,但 C 语言里这种东西必须自己兜底。别觉得多余,排序函数之后要作为公共代码给别人调用,谁也不知道调用方会传入什么状态的容器。
2.4 通过时间线分析微观性能
排序结果正确之后,我开始测量性能。先用 10 万个随机整数,递归版归并排序耗时约 28ms,系统 qsort 约 18ms。差距不算大,但对于一个号称 O(n log n) 的排序算法来说,这个差距可以缩小。
我用 clock() 函数统计时间,为了避免偶然性,同一数据跑 10 次取平均值。后续优化过程中,我一直沿用这套测试流程,保证每次优化都能用数据说话,而不是靠感觉。
3. 从能跑到跑得快:四个方向的优化实践
归并排序的基础版本能跑通后,真正的折腾才开始。性能能不能再顶一顶?内存能不能再省一省?这是我在写排序工具时最关心的两个点。
3.1 小数组切换到插入排序
归并排序的递归开销在数据规模小的时候特别明显。每次递归都要压栈、传参、计算中间位置,而冒泡排序或插入排序在 n 很小的时候反而有优势,因为它们的常数因子小。
所以我加了一个阈值:
c复制void merge_sort_opt(int arr[], int left, int right) {
if (right - left <= 16) {
insertion_sort(arr, left, right);
return;
}
int mid = left + (right - left) / 2;
merge_sort_opt(arr, left, mid);
merge_sort_opt(arr, mid, right);
merge(arr, left, mid, right);
}
这里的 16 是经验值,不是拍脑袋拍的。我分别用 8、16、32、64 试了一遍,16 在大多数场景下表现最好。插入排序本身是稳定的,所以整个归并排序仍然保持稳定,不会破坏相同元素的相对顺序。
实测效果:10 万元素随机排序从 28ms 降到 21ms,提升约 25%。
3.2 用哨兵位减少比较次数
merge 函数里有两个 while 循环来处理剩余元素:
c复制while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
如果能在合并前给 L 和 R 数组末尾加上一个“无穷大”哨兵值,就可以把两个 while 循环合并成一个。因为在哨兵存在的情况下,任意一侧数组耗尽时,另一侧的元素一定会被选走,循环可以一直进行到所有元素都被放置完毕。
这种写法在《算法导论》里有介绍,代码也更简洁:
c复制void merge_with_sentinel(int arr[], int left, int mid, int right) {
int n1 = mid - left;
int n2 = right - mid;
int L[n1 + 1], R[n2 + 1];
for (int i = 0; i < n1; i++) L[i] = arr[left + i];
for (int j = 0; j < n2; j++) R[j] = arr[mid + j];
L[n1] = INT_MAX;
R[n2] = INT_MAX;
int i = 0, j = 0;
for (int k = left; k < right; k++) {
if (L[i] <= R[j]) arr[k] = L[i++];
else arr[k] = R[j++];
}
}
这个写法减少了循环分支,代码看起来也更利落。但要注意一个前提:数据中不能真的有 INT_MAX 这个值,否则哨兵会失效。对于普通整数排序,INT_MAX 几乎不会被用到,所以基本安全。但如果你的数据是来自键盘输入、文件读取等场景,最好先用一次扫描确认数据范围。
这版实测比基础版快了一点点,10 万元素随机排序约 19ms,提升不算大,但代码确实更紧凑了。
3.3 迭代式归并排序:消灭递归开销
递归不是不能用,但每次递归调用都有函数帧的创建和销毁开销。如果想彻底避免递归,可以从另一个角度实现归并排序:自底向上。
思路是:先把数组看成一个个长度为 1 的有序子数组,然后两两合并成长度为 2 的有序子数组,再合并成长度为 4、8、16……直到整个数组有序。
c复制void merge_sort_iterative(int arr[], int n) {
for (int width = 1; width < n; width *= 2) {
for (int left = 0; left < n; left += 2 * width) {
int mid = (left + width < n) ? left + width : n;
int right = (left + 2 * width < n) ? left + 2 * width : n;
merge(arr, left, mid, right);
}
}
}
这个版本没有递归,代码更接近底层思维。实测 10 万元素随机排序约 18ms,和加了小数组优化的递归版差不多,但递归深度不会再有栈溢出风险。
如果你要处理的是超大数组(比如百万级),迭代版会更安全。
3.4 内存复用:避免频繁分配临时数组
前面几个版本每次调用 merge 都会在栈上创建临时数组 L 和 R。对于小数组没什么问题,但对于大规模数据,频繁分配和释放会拖慢速度,还可能引发内存碎片。
更好的做法是:在调用排序之前,一次性分配一个与原数组等大的辅助数组,然后在递归或迭代过程中复用。
递归版改法如下:
c复制void merge_sort_with_buffer(int arr[], int buffer[], int left, int right) {
if (right - left <= 1) return;
int mid = left + (right - left) / 2;
merge_sort_with_buffer(arr, buffer, left, mid);
merge_sort_with_buffer(arr, buffer, mid, right);
// 将 arr[left..mid-1] 和 arr[mid..right-1] 合并到 buffer[left..right-1]
int i = left, j = mid, k = left;
while (i < mid && j < right) {
if (arr[i] <= arr[j]) buffer[k++] = arr[i++];
else buffer[k++] = arr[j++];
}
while (i < mid) buffer[k++] = arr[i++];
while (j < right) buffer[k++] = arr[j++];
// 复制回原数组
for (int t = left; t < right; t++) arr[t] = buffer[t];
}
这个版本只分配了一次辅助数组,避免了反复创建临时数组的开销。
实测下来,10 万元素随机排序约 15ms,是几个版本里最快的。而且内存占用是可控的 O(n),没有额外的隐藏分配。
我在最终提交到 Gitee 的那个项目里,用的就是这一版的思路,只做了少量封装。
4. 把项目推到 Gitee 开源的全流程
算法本身写完,只是一个开始。项目要放到 Gitee 上,还有很多工程化的事要做。这里我把整个流程从头到尾捋一遍。
4.1 首先:本地代码结构怎么组织
不要一个 main.c 写到底。一个好的排序算法项目,起码要有模块化意识。我的目录结构是这样的:
code复制merge-sort/
├── src/
│ ├── merge_sort.c
│ ├── merge_sort.h
│ └── main.c
├── tests/
│ └── test_merge_sort.c
├── Makefile
├── README.md
├── .gitignore
└── LICENSE
merge_sort.h 里放接口声明,merge_sort.c 里放实现,main.c 里只放一个示例调用入口,tests 目录放随机测试代码。这样别人 clone 下来后能快速了解项目结构,而不是在几百行代码里大海捞针。
4.2 .gitignore 和许可证选择
在 git init 之后第一件事,不是写代码,而是先创建 .gitignore。编译产生的 .o 文件、可执行文件、IDE 配置文件都不应该进仓库。我用的 .gitignore 内容很简单:
code复制# 编译产物
*.o
*.out
*.exe
# 可执行文件
merge_sort
# IDE 配置
.vscode/
.idea/
*.swp
接下来是许可证。Gitee 上很多新手项目都不带 LICENSE,这在开源里是个大问题——别人不知道能不能用你的代码,用了之后需要遵守什么义务。
如果你想让代码尽可能被人使用,选 MIT 或 Apache-2.0;如果你希望修改后的代码也必须开源,选 GPL-3.0。因为我的目的是演示算法和作为学习参考,所以选了 MIT,简单、宽松、不限制别人使用。
在 Gitee 创建仓库时,可以在页面上的“选择许可证”下拉框里选一个模板,也可以选择一个 LICENSE 模板直接放进代码。选好后,Gitee 会在仓库首页显示许可证标签,看着专业不少。
4.3 使用 Gitee 的具体操作过程
创建好远程仓库后,把本地代码推上去,我一般用这样的命令序列:
bash复制# 在项目根目录
git init
git add .
git commit -m "init: 归并排序基础实现"
git remote add origin https://gitee.com/你的用户名/merge-sort.git
git push -u origin master
如果你用的是 Gitee 的话,推送时可能会要求输入用户名密码(或私人令牌),推荐在 Gitee 个人设置里生成一个私人令牌,然后在推送时用令牌代替密码,比直接在命令行里输入密码更安全,也方便后续在其他终端上推送。
推送成功后,在仓库页面打开“管理 -> 分支设置”,把默认分支改成 master 或保留 main,看你习惯。
4.4 让 README 像一份说明书
README 是仓库的门面。别人看到你的项目,第一眼就是 README。我写 README 时遵循的模板大致是:
- 项目简介:这个项目是什么,解决了什么问题。
- 算法原理:用几句话和简单示例说明归并排序思路。
- 编译与运行:给出可直接复制执行的命令。
- 测试方法:告诉别人如何跑测试。
- 目录结构:让读者快速定位关键文件。
以这个项目为例,我写的 README 开头大致如下:
markdown复制# C 语言归并排序
一个从零实现、逐步优化并经过完整测试的归并排序示例。
包含递归、迭代、哨兵、内存复用等多种写法,
适合学习分治思想和排序算法时参考。
## 编译运行
make
./merge_sort
## 运行测试
make test
还可以在 README 里放几张运行截图、复杂度分析表格,这会让人感觉项目很严谨。之前看到一个原则是“README 里默认假设看的人对你不了解”,你写清楚,别人试用成本就低。
4.5 从零推送到开源维护过程中的小经验
推送成功后不要以为就结束了。开源意味着有人可能会看你的代码、提 issue、甚至给你发 pr。我这次的亲身经验是:
- 如果有人提了 issue,不需要立刻解决,但要在 issue 里回复一句“感谢反馈,我抽空看一下”,这会让对方觉得项目不是死仓库。
- 每次 push 之前先跑一遍测试,确保没有任何回归问题。我在 README 里就写了一行测试命令,每次改动后跑一下,几秒钟的事。
- 如果你发布了第一个稳定版本,建议在 Gitee 仓库的“发行版”页面创建一个 tag,比如
v1.0.0。之后别人引用你的代码时可以说“我用的是 v1.0.0”,而不是“我 clone 了 master 分支最新代码”。
5. 复盘:这些坑我为什么要再提一遍
整个项目做完后,我再回头审视整个过程中的坑,发现很多问题都是共通的,尤其是如果你以后要写更复杂的递归算法,类似的问题会反复出现。
5.1 区间约定:写代码之前先想清楚
我见过太多人写归并排序,一上来就写递归,写着写着就乱了。根本原因不是代码能力不行,而是没在动手前定义好区间表示法。
我的建议是:拿起笔在纸上写几组边界输入,比如空数组、单元素数组、双元素数组,然后手动推导一遍递归过程,确认递归终止条件、mid 计算、merge 参数三者是同一个约定体系。
这一步看起来麻烦,但能避免你后面花几小时去调试一个本不该存在的 bug。
5.2 测试数据要覆盖边界
很多人测试排序只用一个逆序数组、一个正序数组,就宣布“测完”。这远远不够。边界情况至少包括:
- 空数组
- 单元素数组
- 两个元素,且包含重复值
- 全部元素相等
- 逆序数组
- 随机数组(多组)
- 特别大的数组,观察是否栈溢出
我把我当时的测试用例整理成了表:
| 测试场景 | 预期结果 | 实际结果 |
|---|---|---|
| 空数组 | 什么都不做 | 通过 |
| 单元素 | 原样返回 | 通过 |
| 全相同元素 | 排序后不变 | 通过 |
| 逆序数组 | 升序输出 | 通过 |
| 随机数组 | 与 qsort 结果一致 | 通过 |
| 100 万元素 | 正常排序,无栈溢出 | 通过 |
基本覆盖了算法在各种输入情况下的表现。你以后测试其他算法时,也可以按这个思路列一张表。
5.3 性能优化永远以数据为准
我这次做了几个方向的优化,每一个都跑过测试对比。经验是:不要凭直觉判断某段代码“更快”。有时候加了哨兵、减少了循环分支,性能反而下降,因为 CPU 分支预测的机制和你想的可能不一样。
建议固定一套测试环境、一组随机数据、多次运行取平均值,再比较不同版本的耗时。这个过程本身也是工程素养的一部分。
最后再说点个人的体会
如果你现在正在学 C 语言归并排序,或者准备把自己的一个算法项目推上 Gitee,我的建议就一句话:先把基础版跑通,再考虑优化,最后再谈开源。
很多初学者会在一个多小时里写完一个“差不多能跑”的归并排序,然后直接想着推到 Gitee 上,结果别人 clone 下来一跑就崩。这样的项目不仅帮不到别人,自己回头再看也会尴尬。比起急着开源,更值得花时间的是测试覆盖和边界情况处理。
我在把项目推上 Gitee 之后,确实收到过别人的 star 和评论,也有同学在课程设计里用到了这个排序代码。那种“自己的代码被别人使用”的感觉,比单纯跑通一个算法开心得多。希望你也能感受到。
