news 2026/11/2 6:53:15

PTA《寻找大富翁》归并排序实战:原理、边界与优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PTA《寻找大富翁》归并排序实战:原理、边界与优化

在算法练习平台(PTA)上刷到《寻找大富翁》这道题时,大部分人的第一反应是:排个序,取前M名,完事。如果题目又明摆着要求用归并排序实现,这就不是“完事”那么简单了。题目本身并不复杂,真正考的是对分治排序的理解,以及对数组边界、临时空间、多组输入、输出格式这些底层细节的处理。这篇文章就围绕这道题,把归并排序的完整实现、判题时常见的坑,以及“求前K大”在不同数据规模下的方案取舍一次讲清楚。适合正在学数据结构、准备机试,或者想手写排序练基本功的人阅读。

我自认C语言和排序都还算熟,但第一次提交这道题还是吃了个超时。原因不是归并排序不会写,而是merge函数里每次递归都malloc了一块临时数组,数据量一上来,内存申请和释放的开销直接拖垮了性能。后来把临时数组放到全局、改成自底向上的迭代归并,才真正摸清归并排序的脾气。下面从题目本身开始拆。

1. 题目拆解:考点不在排序结果,而在排序过程

1.1 题面看似简单,实际限制不少

《寻找大富翁》的输入格式一般是这样的:第一行两个正整数 n 和 m,表示有 n 个人、要输出前 m 名;第二行有 n 个正整数,表示每个人的财产。多组测试数据,n 为 0 时结束。输出要求按降序输出前 m 名的财产,数字之间用空格分隔。

这个题面放在日常开发里,一个 sort 就能解决。但在数据结构与算法的配套练习里,题目名称后面往往挂着“归并排序”,这就是很明确的提示:排序函数不能调库,得自己实现。有的版本 n 能到 1000000,m 可能只有 10,也可能接近 n,输入规模一大,很多看起来“能过”的写法就会原形毕露。更关键的是,判题系统不只看最终排序结果对不对,数组越界、多组输入处理不当、输出多一个空格,都会导致提交失败。换句话说,这道题既考算法实现,也考工程细节,两个层面都得稳。

1.2 可选解题路线不止一条

题目没有限制解法时,常见思路有这么几种:

  • 暴力选择:每次在剩余元素中找最大值,做 m 轮,时间复杂度 O(nm),m 稍大就挂。
  • 内置排序:调用 C 标准库的 qsort 或者 C++ 的 sort,O(nlogn),代码量小,但不是这题想考的东西。
  • 手写快速排序:平均 O(nlogn),但最坏可能退化到 O(n^2),而且不稳定。
  • 手写归并排序:稳定 O(nlogn),额外 O(n) 空间,正是这题的训练目标。
  • 小根堆维护前 m 大:O(nlogm),适合 m 很小的场景。
  • 快速选择:用 partition 思路求第 K 大,平均 O(n),但输出有序前 m 个还需要额外处理。

这些方法各有适用场景。比如 m 固定为 10 时,小根堆复杂度远低于任何全排序;如果只求中位数,快速选择更合适。但题目既然点名归并,那就踏踏实实把归并写熟,顺带再思考一下不同方案的取舍,这比单纯 AC 一道题更有价值。

1.3 归并排序在这个场景里的独特价值

归并排序最大的特点是所有情况下复杂度都是 O(nlogn),不依赖输入数据的初始顺序。快速排序如果枢轴选得不好,在接近有序的数据上可能退化到 O(n^2);堆排序虽然也是 O(nlogn),但不稳定,也不方便扩展成外部排序。归并排序的“分治—合并”结构,是后续学习多路归并、文件外部排序、分布式排序的地基,所以很多课程拿这种题来检验学生是不是真会写分治。

另外,归并排序可以很方便地控制升序还是降序,只需要改一行比较符号。在《寻找大富翁》这种“从大到小输出前 m 个”的题目里,这个特性非常顺手。理解了这一点,后面的代码就是水到渠成的事。

2. 归并排序核心实现:从递归拆分到有序合并

2.1 核心思想:分而治之

归并排序可以类比成整理两叠已经按大小排好的扑克牌:你只需要每次比较两叠牌最上面的一张,把更小的那张抽出来放进新牌堆,直到两叠牌都被抽完,最后得到的新牌堆依然有序。在数组上,分治的第一步是“分”:找到中点,把区间 [left, right] 拆成 [left, mid] 和 [mid+1, right] 两个子区间;第二步是“治”:递归地把两个子区间分别排好;第三步是“合”:调用 merge 函数,把两个有序子区间合并成一个更大的有序区间。

递归入口是对整个数组调用一次 mergeSort,出口是区间只有一个元素时直接返回,因为单个元素天然有序。这个结构理解起来不难,但写代码时会遇到不少细节,尤其是临时数组、边界条件、最后拷贝这三件事,任何一个出问题,排序结果都会错。

2.2 merge 函数逐行讲解

merge 负责把两个相邻有序区间合并。闭区间写法下,左侧区间是 [left, mid],右侧区间是 [mid+1, right]。三个下标 i、j、k 分别指向左区间、右区间和临时数组的当前位置。

int tmp[MAXN]; void merge(int arr[], int left, int mid, int right) { int i = left; int j = mid + 1; int k = left; while (i <= mid && j <= right) { if (arr[i] >= arr[j]) // 降序,等号保证稳定 tmp[k++] = arr[i++]; else tmp[k++] = arr[j++]; } while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; for (i = left; i <= right; i++) arr[i] = tmp[i]; }

第一个 while 是核心,每次从两个有序子区间中挑一个更大的元素放进临时数组。这里的 >= 不是 >,是为了数值相等时优先取左区间元素,保持稳定性。第二个 while 处理左区间剩余,第三个 while 处理右区间剩余。最后必须把临时数组内容拷贝回原数组,否则原数组始终没有被更新,外层递归拿到的还是旧数据。很多人第一次写 merge 时会漏掉最后这个 for 循环,结果排序输出乱序,这个看似不起眼的回写,是整个排序真正“落笔”的地方。

2.3 降序排序与稳定性的一行之差

标准教材里 merge 用的是if (arr[i] <= arr[j]),那是升序。本题要求降序输出,有两种做法:第一种是升序排序后倒着读;第二种是直接把比较符号改成 >=,让归并结果本身就是降序。我推荐第二种,因为归并结束后最大的数就在 a[0],前 m 个直接顺序输出即可,少一层出错的可能。所有基于比较的排序,降序和升序只差一个比较符号,不需要改算法框架。

稳定性这个问题,很多人在纯数值题里觉得无所谓。“大富翁”只输出财产数字,相同时谁前谁后确实不影响结果。但稳定排序在实际场景中很有用,比如先按工资降序,再按入职时间排序时,稳定排序能保留前一次的相对顺序。养成写 >= 而不是 > 的习惯,表面上是多写一个等号,实际是在建立稳定性的概念。

2.4 边界条件和常见 Bug

  • 递归出口:if (left >= right) return;忘记写,会无限递归。
  • 中点计算:mid = left + (right - left) / 2;比(left + right) / 2更安全,不会在 left+right 很大时整数溢出。
  • 区间划分:递归调用是mergeSort(arr, left, mid)和mergeSort(arr, mid+1, right),左右都是闭区间,不重叠不遗漏。如果把右边写成 mid,mid 那个元素会被排序两次,右半永久少一个元素。
  • 临时数组下标:merge 里 k 从 left 开始,不是从 0 开始。因为 merge 只负责 [left, right] 这一段,k 从 0 开始,回写时就会错位。
  • 回写范围:必须for (i = left; i <= right; i++)回写,写错区间会导致部分数据没排上。

这些错误在大数据量下很难用肉眼发现,必须靠边界分析和少量数据的手动推演。我的建议是先用 n=5 到 10 的小数组,把每次递归拆分的 mid 标出来,配合代码走一遍。调试时可以在 merge 入口打印 left、mid、right,但提交前一定要删掉,否则打印输出会影响判题结果。这种方式比盯着大数据空想要高效得多。

3. 实战:一份可以直接提交的 AC 代码

3.1 递归版完整代码

下面这份代码是 C 语言。全局数组开 100 万不会爆栈,递归深度也只有几十层,完全安全。输入部分兼容多组数据,n 和 m 都为 0 时结束。

#include <stdio.h> #define MAXN 1000005 int a[MAXN]; int tmp[MAXN]; void merge(int arr[], int left, int mid, int right) { int i = left; int j = mid + 1; int k = left; while (i <= mid && j <= right) { if (arr[i] >= arr[j]) tmp[k++] = arr[i++]; else tmp[k++] = arr[j++]; } while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; for (i = left; i <= right; i++) arr[i] = tmp[i]; } void mergeSort(int arr[], int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } int main(void) { int n, m; while (scanf("%d %d", &n, &m) == 2) { if (n == 0 && m == 0) break; for (int i = 0; i < n; i++) scanf("%d", &a[i]); if (m > n) m = n; mergeSort(a, 0, n - 1); for (int i = 0; i < m; i++) { if (i > 0) putchar(' '); printf("%d", a[i]); } putchar('\n'); } return 0; }

数组 a 和 tmp 都放在全局区,避免函数内定义大数组导致栈溢出;mergeSort 入参是闭区间;排序完成后 a[0] 到 a[m-1] 就是最大的 m 个,直接输出。m > n 时先修正 m,防止输出阶段数组越界。

3.2 多组输入和结束条件的处理

原题很可能采用“多组测试数据,n 为 0 结束”的格式。上面代码里,scanf的返回值作为循环条件,能处理读到文件末尾的情况;又用n == 0 && m == 0作为业务结束条件,能处理题目指定的 0 结束。两种约定都能兼容,是比较稳妥的写法。

如果题目说 m 为 0 时也结束,循环里就不输出数字,直接输出一个空行。有些判题系统对多余空行很敏感,所以这种边界场景需要细读题目。总体思路是:判循环条件看 scanf 返回值,判结束条件看具体数值,不要想当然只判断 n。

3.3 m 大于 n 的防御处理

输入数据可能存在 n=5、m=10 这类不严谨的情况。没有if (m > n) m = n;时,输出阶段会读取 a[5] 到 a[9],这些位置可能是未初始化的数据,也可能越界到数组末尾之外。全局数组越界读大概率不会立刻崩溃,但输出的数字毫无意义,判题结果自然是答案错误。加一行防御,是写这类题的基本习惯,尤其在 n 上限比较大的题目里。

3.4 迭代版归并:去掉递归调用的优化写法

递归版代码清晰,但函数调用有额外开销。数据量到 100 万时,递归函数调用次数大约是 2n-1 次,也就是 200 万次,这个开销在 C 语言里通常可接受。不过我更喜欢迭代版,因为它省去了递归栈,边界也更统一。

void mergeSortIter(int arr[], int n) { for (int len = 1; len < n; len <<= 1) { for (int i = 0; i + len < n; i += (len << 1)) { int left = i; int mid = i + len - 1; int right = i + (len << 1) - 1; if (right >= n) right = n - 1; merge(arr, left, mid, right); } } }

len 表示当前左右两个子区间中左半的长度,初始为 1,代表把两个长度为 1 的有序区间合并成两个元素的有序区间。内层循环的条件i + len < n保证右侧区间存在,否则不合并。每轮结束 len 翻倍,最终整个数组有序。这个版本和递归版共用同一个 merge 函数,复杂度相同,常数略小。在《寻找大富翁》这类明确要求归并的题目里,用迭代版同样能得分,而且踩递归边界的坑更少。

4. 常见问题与排查技巧实录

4.1 一运行就段错误

段错误最常出现在三个位置:数组开太小、局部大数组导致栈溢出、merge 里访问了不存在的下标。

数组开太小很好理解,n 最大 1000000,你只开了 100000,读入时就会越界写。局部大数组的问题我也踩过:把临时数组int tmp[1000005]定义在 main 或 merge 函数里,程序一跑就崩。原因是函数内局部变量放在栈上,进程默认栈空间通常只有几 MB,100 万个 int 就是 4MB,再加上递归调用的多个栈帧,栈很快爆掉。解决办法就是全部放全局区,静态区空间足够大,也不占栈。

如果排除了以上两种,仍然段错误,就检查递归边界。比如mergeSort(arr, left, mid)和mergeSort(arr, mid, right)区间重叠,会导致无限递归,最终栈溢出。这类问题在小数据下可能表现为“还能跑但结果不对”,数据一大就直接崩。

4.2 答案错误,但排序看起来没问题

输出方向错了,是《寻找大富翁》里最容易出现的 WA。题目要求降序输出前 m 个,如果你写的是升序归并,却仍然从 a[0] 开始输出,那输出的就是最小的 m 个,必错。升序归并时,正确做法是从 a[n-1] 往回到 a[n-m],也就是倒序输出末尾 m 个。相比之下,直接降序归并更直观,输出阶段不会犹豫。

输出格式也容易错。最后一个数字后面不能带多余空格。很多判题系统对多空格并不宽容,所以写稳一点:第一个数字前不输出空格,之后每个数字前输出一个空格。代码里的if (i > 0) putchar(' ')就是干这件事。换行也不要忘,每组输出后都得补一个换行。

4.3 提交超时,先检查这三个地方

第一,merge 里不要创建临时数组。我见过不少写法在 merge 内部用 malloc 申请临时数组,排序完再 free。表面看没问题,但 n=100 万时递归调用会产生大量 malloc/free,系统调用开销非常大,时间直接爆掉。把临时数组定义为全局变量,所有 merge 共用同一块空间,是最基础的优化。

第二,检查有没有死循环。递归边界错误、mid 计算错误、循环变量没有递增,都可能导致程序陷入无限循环。小数据看不出性能问题,提交后就表现为 Time Limit Exceeded。用 n=4 或 n=5 的小数据跑一遍,每一步打印 left、mid、right,很快就能定位。

第三,考虑输入输出效率。scanf 和 printf 在 100 万级别通常够用,但有些评测机配置低,或者数据到了千万级别,就需要手写快读。下面是一个只处理正整数的 readInt 函数,在本题场景够用:

int readInt(void) { int x = 0; char c = getchar(); while (c < '0' || c > '9') c = getchar(); while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); } return x; }

如果要用快读,建议只读普通数据,n 和 m 继续用 scanf 判断结束条件,避免在 EOF 时快读函数陷入死等。不要盲目上快读,1e6 以内的数据用 scanf 已经能通过绝大多数题目。

4.4 从全排序到只找前 K 大的优化思路

如果 m 很小,比如 m=3,只需要前三大,那全排序确实有点浪费。可以维护一个大小为 3 的小根堆,堆顶始终是堆中最小的那个。扫描数组时,只要当前数比堆顶大,就把堆顶替换成当前数,再调整堆。这样每个数最多和堆顶比较一次,复杂度从 O(nlogn) 降到 O(nlog3),几乎是线性。

但题目明确要求归并时,不能拿这个思路交差。理解归并仍然是主线。学习时把堆排序版也写一遍,对“为什么不同排序适用不同场景”的体会会更深。说到底,算法题的价值不在于找一条最快路径蒙混过关,而在于看清不同工具之间的边界。

4.5 模块化测试:用随机数据验证排序结果

一个很管用的排查方法:单独写一个小程序,生成随机数组,调用你写的 mergeSort,再对照标准库排序结果,验证排序是否正确。如果发现某几组数据不一致,就把数据量调到很小,逐行打印递归过程。调排序的 bug,靠眼睛看大数据输出几乎不可能,但缩小规模后清楚多了。

例如输入 5 个数:5 3 8 1 9,要求降序排序。手推一遍递归:mid=2,左区间 [5,3,8] 排序后是 [8,5,3],右区间 [1,9] 排序后是 [9,1],合并时先比较 8 和 9,9 在前,然后 8、5、3、1,最终结果是 [9,8,5,3,1]。如果代码输出和这个不一致,哪一步错了基本一眼可见。

5. 复杂度验证与方案取舍:为什么这道题选归并

5.1 时间复杂度的直观推算

设总数据量为 n,归并排序每一层都要把整个数组扫描一遍,合并总代价是 O(n)。递归过程把数组分成了 logn 层,所以总复杂度是 O(nlogn)。说直白一点,n=1000000 时,log2(n) 大约是 20,每一层都要对 100 万个元素进行读、比、写,总操作在几千万次量级。C 语言完成这个量级通常在一秒上下,前提是临时数组复用、没有频繁内存申请。

空间方面,归并排序需要一个与原数组等长的临时数组,也就是 O(n)。加上递归栈的 O(logn),整体空间复杂度 O(n)。对 100 万数据来说,两个 int 数组大约 8MB,满足绝大多数题目的内存限制。如果题目内存紧到只有 8MB,n 又到 100 万,int 数组本身就要 8MB,怎么都不够,那就得考虑分块处理了,这正好引出外部排序。

5.2 归并排序、快速排序、堆排序怎么选

学习场景和真实场景要分开看。从学习场景看,题目要求归并,就是要掌握分治思想和稳定排序的实现。从真实场景看,三种排序各有适用面:归并稳定、复杂度可预测,适合数据量大、要求稳定或需要外部排序的场合;快速排序常数小、额外空间少,是绝大多数内存内排序的首选;堆排序空间 O(1),总能保持 O(nlogn),但常数较大且不稳定。

排序算法平均时间最坏时间额外空间稳定性典型场景
归并排序O(nlogn)O(nlogn)O(n)稳定大数据、外部排序、链表排序
快速排序O(nlogn)O(n^2)O(logn)不稳定内存内排序,工程常用
堆排序O(nlogn)O(nlogn)O(1)不稳定需要原地排序且不要求稳定

快速排序的最坏情况可以通过随机选枢轴缓解,但工程上引入随机化也要额外代码;归并排序没有这个烦恼,它把“最坏情况”也锁死在 O(nlogn)。这也是为什么排序讲解和某些稳定性敏感的排序场景里,归并依然有一席之地。

5.3 从归并到多路归并:这道题能延伸的方向

如果哪天你面对的不是 100 万个能装进内存的数,而是内存装不下的上亿个数,递归归并就不能直接用了。标准做法是把大文件切块,每块读进内存排序后写回磁盘,形成多个有序小文件,再用堆做多路归并,不断从每个文件头部取最大或最小元素输出。这个过程本质上就是归并排序“合并有序序列”那一步的放大版。

理解了这一点,就会明白为什么课程会在排序题里特意点名归并排序。《寻找大富翁》表面是排序题,背后是在训练一种思路:把大问题切成小块,分别解决,再合并结果。这种思路在分治算法、外部排序、并行计算里反复出现,一次写明白了,后面再遇到类似场景会轻松很多。

我最初写这道题时,第一个版本在 merge 函数里定义了局部临时数组,n=10 万时程序直接崩掉。改成全局数组后,又发现输出的是最小的 m 个数,因为排序方向写成了升序。来回调试之后,我把递归树亲手在纸上画了一遍,才真正理解闭区间和边界条件的含义。如果你现在也在跟这道题较劲,建议先用小数据把 merge 的边界弄透,再谈性能和优化。归并排序写顺之后,再看分治类的题目,心里会踏实很多。

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

数字信号处理技术:从基础原理到现代硬件加速

1. 数字信号处理的底层逻辑数字信号处理的核心在于将连续时间信号转换为离散数字序列&#xff0c;并通过算法实现特定功能。与模拟滤波相比&#xff0c;数字滤波具有可编程性、稳定性强和精度高等优势。现代数字信号处理器&#xff08;DSP&#xff09;通常采用哈佛架构&#xf…

作者头像 李华
网站建设 2026/10/18 18:29:30

2025年Java面试题精选:新特性、并发与实战场景解析

简介&#xff1a;这是一份面向Java求职者与开发者的面试题精选文档&#xff0c;聚焦2025年最新面试高频考点&#xff0c;系统梳理Tomcat运行原理、Spring IOC/DI与AOP、JVM字节码执行机制、包装类与基本类型差异、Shiro权限标签、Servlet生命周期以及Java作用域等核心内容&…

作者头像 李华
网站建设 2026/10/19 8:49:37

Maya摄像机动画导入UE4实时渲染出片全流程指南

1. 项目缘起与整体思路拆解1.1 为什么要在两个软件之间来回折腾做过三维动画的人大概都有这种体会&#xff1a;Maya的摄像机动画调起来顺手&#xff0c;曲线编辑器直观&#xff0c;拍屏预览也快&#xff0c;但一到最终渲染&#xff0c;尤其是需要实时预览、快速出片、或者客户要…

作者头像 李华
网站建设 2026/11/1 11:04:19

上下文工程:让大模型看到该看的东西——从43%到88%的实战复盘

同一个模型、同一套参数&#xff0c;只改动请求体里的上下文内容&#xff0c;我最近在一个客服问答系统上做了一次对照测试&#xff1a;回答的可用率从四成出头涨到了接近九成。这个提升幅度&#xff0c;比我折腾模型选型和微调投入的时间换来的收益大得多。当时我就觉得&#…

作者头像 李华
网站建设 2026/11/1 23:46:35

【计算机毕业设计单片机案例】基于STM32的婴儿床哭声监测与远程摇篮安抚装置设计 基于单片机的智能婴儿床远程监护与安眠曲控制系统设计(032703)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/10/22 13:26:08

SpringBoot+Vue全栈开发电信宽带业务管理系统

1. 项目背景与核心价值宽带业务管理系统是电信运营商和互联网服务提供商的核心业务支撑平台。随着光纤入户普及和千兆宽带升级&#xff0c;传统基于Excel和纸质工单的管理方式已经无法满足现代业务需求。这个SpringBootVue全栈项目正是为解决以下痛点而生&#xff1a;业务复杂度…

作者头像 李华