刷PTA的人都有一个共识:基础排序算法题看着简单,AC(通过)起来却没那么容易。尤其是“PTA 排序算法设计 3 冒泡排序”这类题目,题面就短短几行,真正动手写代码才发现,坑全藏在细节里:模板背了无数遍,一提交就是Wrong Answer;本地跑得贼好,一交上去就段错误;明明排序结果对了,却因为多打了一个空格被判0分。
这篇文章就是来讲透冒泡排序的。我会从算法原理说到真题形态,从C语言实现聊到PTA判题机制,顺便把我自己踩过的坑、总结的自测方法和考场技巧全部倒出来。适合正在刷PTA的新手、准备数据结构实验考试的同学,以及那些“会背代码但不会答题”的迷茫选手。
1. 先弄明白PTA这道题到底要考什么
很多同学上来就写代码,写完了才发现自己根本不知道题目的判分点在哪里。PTA上的题目不是让人“实现一个排序”就完事,它背后有一套固定的考核逻辑,理解了这套逻辑,答题方向才不会跑偏。
1.1 题目背后隐藏的三个考点
第一是算法理解。PTA不会直接考“请写出冒泡排序”,而是会把冒泡排序包装成不同的输入输出要求,比如“输出每一趟排序后的结果”或者“输出第K趟冒泡排序的结果”,如果你只背了排序代码、不理解每一趟发生了什么,这种题一写就露馅。
第二是代码实现。这里的实现不光是“能跑”,还要在限定时间内跑完。PTA的判题服务器会用一个隐藏的测试点去跑你的程序,时间超了或者内存超了都会被拒。
第三是输出格式。这一点最容易被忽略。PTA的自动判题系统是比较你的输出和标准答案是否完全一致,空格、换行、中英文符号都不能错。很多同学代码写对了但拿0分,问题大多出在输出上。
所以,刷这道题的正确姿势是:先分析题目要求,再设计算法,最后小心地处理输入输出。
1.2 冒泡排序的核心逻辑:相邻交换,一趟沉底一个数
冒泡排序的核心操作是“比较相邻元素,顺序不对就交换”。每趟排序从头到尾扫描一遍数组,遇到相邻逆序对就交换,一趟下来,最大的数就像气泡一样“浮”到了数组末尾。下一趟继续处理剩下的部分,但最后一个位置已经归位,不用再碰它。
我习惯用一个生活场景去理解:一堆人按身高排队站成一列,你从队头开始,挨个比较相邻两个人,如果前面的比后面的高,就让两人换位置。走完一趟,最高的人就到了队尾。下一趟再从队头开始,但队尾那个人已经不用管了。重复这个过程,队列就排好了。
以数组[5, 1, 4, 2, 8]为例,从小到大排序。第一趟从下标0开始,5和1比较,逆序,交换,数组变成[1, 5, 4, 2, 8];接着5和4比较,交换,变成[1, 4, 5, 2, 8];继续比较5和2,交换,变成[1, 4, 2, 5, 8];最后比较5和8,顺序正确,不交换。第一趟结束后,最大值8已经到达末尾。可以看到,一趟排序确定了当前未排序部分的最大值位置,这就是“一趟沉底一个数”。
时间复杂度上,最坏情况是数组完全逆序,比较次数为 n(n-1)/2,交换次数同样是 n(n-1)/2,复杂度 O(n²)。最好的情况是数组已经有序,只需要一趟扫描即可确认无交换,复杂度降到 O(n)。平均情况仍是 O(n²)。空间复杂度 O(1),只在交换时用了一个临时变量。
1.3 PTA题目的典型形态
我在PTA上见到的冒泡排序题目,大致有四种形态。
第一种最基础:输入一个整数N,然后是N个整数,程序用冒泡排序将它们按非递减排序,最后输出排序结果。这种题只要实现基础排序逻辑,再注意输出间隔即可。
第二种稍微进阶:要求输出“每趟排序后的结果”。这种题要求你每完成一趟外层循环就打印一次数组,考察的是你是否真的理解冒泡排序的分趟过程。我第一次写这种题就栽了,因为我把数组完全排序后才输出,结果只输出了最终结果,中间过程全没打。
第三种是“输出第K趟排序后的结果”。这种题往往先输入N和K,再输入N个整数,要求排序K趟后输出数组状态。注意这里的K可能小于总趟数,也可能大于总趟数,当K大于等于N时,输出的一定是完整有序的数组。
第四种是函数题。PTA会把核心功能封装成一个函数,比如void bubble_sort(int a[], int n),你只需要补全冒泡排序的代码,不需要处理输入输出。这种题更考验“模块化”意识,也最容易在指针和数组传参上出问题。
顺便提一句,这类“排序算法设计”系列实验通常不止一题,前面可能有插入排序、选择排序,后面可能接快速排序和归并排序。把这几种题的套路摸透了,整个系列都能轻松不少。
2. 代码实现:从教科书版本到PTA稳妥版本
代码到底怎么写,是大多数人最关心的部分。我给三种语言都写了完整版本,并且标注了哪些是关键行、哪些地方最容易出错。代码不是背出来的,是理解后写出来的。
2.1 最经典的C语言写法
#include <stdio.h> void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { // 外层循环:n-1趟 for (int j = 0; j < n - 1 - i; j++) { // 内层循环:每趟比较的范围逐渐缩小 if (a[j] > a[j + 1]) { // 相邻元素逆序 int temp = a[j]; // 交换 a[j] = a[j + 1]; a[j + 1] = temp; } } } } int main() { int n; scanf("%d", &n); int a[1000]; for (int i = 0; i < n; i++) { scanf("%d", &a[i]); } bubble_sort(a, n); for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n"); return 0; }这段代码里,我见过新手最容易问的一个问题是:外层循环为什么是n - 1而不是n?因为每一趟都能确定一个元素的最终位置,当 n-1 个元素都归位后,最后一个元素自然就在它该在的位置上,不需要再排序。比如5个元素,最多只要4趟。
内层循环的条件j < n - 1 - i也是重点:“-i”是因为每一趟结束后,数组末尾已经有i个元素排好了,这些位置不需要再去比较;“-1”是因为比较时访问的是a[j]和a[j+1],如果j能取到n-1-i,那a[j+1]就越界了。
2.2 加一个标志位,优化到“最好O(n)”
上面这个版本,即使输入已经有序,它依然傻乎乎地跑完所有趟数。优化思路很朴素:如果某一趟内层循环一次交换都没发生,说明数组已经有序,直接结束。
void bubble_sort_optimized(int a[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; // 标志位,记录本趟是否发生交换 for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; swapped = 1; } } if (swapped == 0) break; // 没有交换,说明已经有序 } }这个标志位在PTA上有什么用?说实话,在数据量很小的时候看不出区别,但在某些专门考察“已排序数组”处理效率的题目里,它能把排序时间从 O(n²) 降到 O(n),属于“用不上最好,用上了救命”的技巧。笔试和面试中问“冒泡排序怎么优化”,标准答案就是这个标志位优化。
2.3 PTA函数题怎么补全
PTA的函数题一般会给出函数原型,比如:
void bubble_sort(int a[], int n);你的任务是只写函数体,不用写main函数,也不用处理输入输出。这种题最容易犯的错误是:
第一,把printf写进了排序函数里。函数题要求“只负责排序,不负责输出”,如果你在函数里打印数组,多余的输出会让判题系统误判为格式错误。第二,忘记处理空数组和单元素数组。当n <= 1时,函数应该什么都不做,直接返回。你的外层循环for (int i = 0; i < n - 1; i++)在n = 0时,n - 1是负数,循环条件不成立,其实不会执行,但因为整型溢出问题,建议还是加一个if (n <= 1) return;这样的保护,既清晰又安全。
2.4 语言换一换:C++和Python怎么写
用C++写冒泡排序,写法上跟C语言几乎一样,只是输入输出换成cin和cout,或者直接用scanf和printf。有人问既然C和C++差不多,那用哪个好?我个人的体会是:在PTA上做简单题,C语言足够;做涉及STL容器的题(比如要排序的是vector),用C++更方便。
#include <iostream> using namespace std; void bubble_sort(int a[], int n) { for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { swap(a[j], a[j + 1]); } } } }swap是C++标准库自带的交换函数,比自己写临时变量省事,但要注意它只能用于C++,不能用于C。
Python版本的冒泡排序,最大的坑是“原地交换”写起来要小心。如果你写:
a[j], a[j+1] = a[j+1], a[j]这是Python的元组赋值,交换是安全的,不需要临时变量。
def bubble_sort(a): n = len(a) for i in range(n - 1): swapped = False for j in range(n - 1 - i): if a[j] > a[j + 1]: a[j], a[j + 1] = a[j + 1], a[j] swapped = True if not swapped: break在这段代码里,range(n - 1 - i)生成的是0到n-2-i,正好对应C语言的j < n - 1 - i。Python版本在PTA上跑,效率比C语言慢很多,因为Python是解释型语言,循环开销大,所以如果题目数据规模给定到了几百以上,建议优先用C/C++提交。
2.5 双向冒泡变体:鸡尾酒排序
有些PTA进阶题会提到“双向冒泡排序”。这种排序也叫鸡尾酒排序,原理是每趟交替从两个方向扫描:第一趟从左到右把最大值送到末尾,第二趟从右到左把最小值送到开头,第三趟再从左到右……如此交替,每一趟都能确定两个元素的最终位置,扫描范围收缩得更快。
void cocktail_sort(int a[], int n) { int left = 0, right = n - 1; while (left < right) { int swapped = 0; for (int i = left; i < right; i++) { if (a[i] > a[i + 1]) { swap(&a[i], &a[i + 1]); swapped = 1; } } right--; for (int i = right; i > left; i--) { if (a[i] < a[i - 1]) { swap(&a[i], &a[i - 1]); swapped = 1; } } left++; if (!swapped) break; } }复杂度依然是 O(n²),但常数更小,数据量越大优势越明显。如果PTA题面里写了“双向冒泡”,你直接用这个实现就不会跑偏。
3. 上机实操:PTA判题是很严格的
代码写对了,不代表能AC。PTA的自动判题机制决定了它对“输入输出格式”的要求近乎苛刻。这一部分我重点讲如何让你的程序在判题系统面前做到“精确命中”。
3.1 输入输出格式的细节
很多题目要求输出结果时,数字之间用空格隔开,最后一个数字后面不能有空格。我见过最冤的一种错误就是“样例全对,提交全错”,因为样例输出里最后恰好没有空格,很多人就没留意。
我惯用的处理方式是:在循环里判断当前元素是不是第一个,不是第一个就先打印一个空格,再打印数字。这样最后一个数字后面自然没有多余空格。
for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n");还有一种情况是题目要求“多组测试数据”,比如输入直到文件结束。这时候要用while (scanf("%d", &n) != EOF)包裹主体逻辑。注意每处理完一组,输出之后要换行,组与组之间有没有空行,以题面为准。
如果N和数组在同一行输入,用scanf就能自动跳过空白字符,不需要特意处理。但要小心输入里可能有多个空格,或者混有换行,scanf都能吸收,只要格式串写对就行。
3.2 边界情况与自测用例
写代码时脑子里要有“测试用例”意识,不要写完就急着提交。我一般会自测这几组数据:
| 测试场景 | 输入 | 预期输出 |
|---|---|---|
| 最普通的情况 | 5\n5 1 4 2 8 | 1 2 4 5 8 |
| 单元素数组 | 1\n7 | 7 |
| 空数组 | 0 | 无输出(或按题意) |
| 已经有序 | 5\n1 2 3 4 5 | 1 2 3 4 5 |
| 完全逆序 | 5\n5 4 3 2 1 | 1 2 3 4 5 |
| 全部相等元素 | 5\n3 3 3 3 3 | 3 3 3 3 3 |
| 含有重复元素的乱序 | 7\n3 1 4 1 5 9 2 | 1 1 2 3 4 5 9 |
这里特别提一下空数组,也就是n = 0的情况。在PTA上有些题目会特意放一个空数据测试点,如果你的输出多了一个换行或者什么都不输出,都可能导致格式错误。最好的办法是在输出循环前加个判断:if (n > 0) { ... }。
我见过有人在PTA上用int a[100000]这种大数组,然后在函数内又创建了一个大数组,结果运行时报“段错误”。原因是函数内的大数组保存在栈上,而栈空间是有限的,数组太大就会爆栈。解决办法很简单:把大数组定义为全局变量,或者用malloc动态分配。
3.3 为什么冒泡会超时
PTA的普通题目时间限制一般在1000ms左右。冒泡排序的时间复杂度是 O(n²),当n = 1000时,大约要做1000 * 999 / 2 = 499500次比较,瞬间完成;当n = 10000时,比较次数飙升到接近5000万,在C语言里可能需要几十毫秒到上百毫秒,运气好能过;当n = 100000时,比较次数是50亿,基本必超时。
所以写代码之前先看一眼题目给出的数据范围。如果 n 的范围到了10000以上,而且题目没有明确要求用冒泡,那么换成快速排序或堆排序是更稳妥的选择。但如果是“排序算法设计3”这种专门练冒泡的题,数据范围一般控制在n <= 100,你大胆用冒泡写就行。
3.4 稳定性与等值元素的处理
“稳定性”是排序算法的一个重要性质:如果两个相等的元素在排序前后相对位置不变,那么这个排序是稳定的。冒泡排序是稳定排序,因为我们只在a[j] > a[j+1]时才交换,等值元素不交换。
PTA和面试里经常会问:输入包含重复元素时,输出是否要求保持原有顺序?冒泡天然满足这个要求。但如果你在写代码时不小心把>写成了>=,那排序就变得不稳定了,而且会多出大量无意义的交换,在某些题目里甚至会导致答案错误。所以,判断条件是>还是>=,不是小细节。
4. 踩坑实录:我在PTA上翻过车的几个瞬间
这一节全是干货。我把自己(以及我身边一圈同学)在PTA冒泡题上翻过的车集中盘点一下,每个坑都附上原因和解决思路,你们读的时候可以对照自己的代码自查。
4.1 常见报错类型速查
PTA提交后返回的评测结果一般有这几种,我整理成表格方便对照。
| 返回结果 | 含义 | 常见原因 |
|---|---|---|
| Accepted | 通过 | 无 |
| Wrong Answer | 答案错误 | 排序逻辑不对、输出格式错、忽略了空数组/等值元素边界 |
| Compile Error | 编译错误 | 语法错误、C语言混用了C++的语法(比如swap) |
| Runtime Error | 运行错误 | 数组越界、栈溢出、访问了非法内存 |
| Time Limit Exceeded | 运行超时 | 外层循环写成n次导致多跑一趟、数据太大、用了非最优写法 |
| Presentation Error | 输出格式错误 | 空格、换行、大小写、中英文符号不一致 |
值得多说一句的是Presentation Error,很多同学以为这个离“通过”只差一步,其实PTA的判题系统在格式不对时会直接报Wrong Answer,不会给“格式错误”这种温柔提示。所以不要心存侥幸,输出必须严格符合题面。
4.2 案例:样例全对但提交0分
我印象很深的一次翻车,是我写了个“输出每一趟结果”的冒泡题。代码在本地跑样例,输出跟题面一模一样,但提交后是0分。排查了很久才发现,我没有把“初始数组”作为第0趟输出。题目要求“输出初始状态以及每一趟后的状态”,我只输出了每趟后的结果,第一行缺失,整个输出序列全乱了。
这种题的关键在于:动笔写代码前,把题面要求的所有输出列成一个清单。比如:“第一行输出初始数组,之后每行输出一趟排序后的数组,直到排序结束。”然后照单实现。
第二个惨案是“输出格式里多了一个换行符”。题目要求每个数字之间用空格隔开,最后没有换行。我按习惯在最后补了一个printf("\n"),结果被判错。后来发现,PTA对末尾换行是否必需是有明确规定的:允许也行,不允许也行,要看题面。稳妥的做法是:如果题面没有明确说“最后带换行”,就不要画蛇添足,直接输出内容就好。如果你不确定,就按题目给的样例输出格式来。
4.3 如何自己构造测试数据
PTA出错的时候不会告诉你是哪个测试点挂了,所以我养成了一个习惯:本地写一个简单程序,随机生成测试数据,然后把自己写的排序结果和标准排序结果(比如C标准库的qsort)比对。
比如在C语言里,可以用rand()生成随机数组,然后用qsort作为“正确答案”:
int cmp_int(const void *a, const void *b) { return (*(int *)a - *(int *)b); }构造几百组随机数据,每一组都对比你的冒泡结果和qsort的结果,只要有一组不一致,就能复现问题。这个方法比盯着代码看半天有效得多。
另一个简单的方法是手动构造“类型化”的测试数据:全正数、全负数、正负交替、大量相同元素、数组长度为0或1。每种都跑一遍。我敢说,覆盖了这几种情况,大多数隐藏测试点都打不倒你。
4.4 调试技巧:printf大法和提交前检查
调试排序代码最快的方法是在关键位置加printf,比如每一趟结束后打印数组。但记住:调试输出的printf在提交前必须全部删掉或注释掉,否则判题系统会把这些调试信息当成你的输出,直接报错。
我的习惯是在代码里写一个print_array函数,调试时调用,提交时只删掉调用语句,不动的函数体,这样比一行一行删printf快多了。
提交前最后30秒,我还会检查四件事:第一,数组大小是否足够大(比题目的N上限多留一点余量);第二,循环边界对不对(尤其是n-1-i这类表达式);第三,输入输出格式是否跟样例一致;第四,有没有留了调试用的printf。这四步穷不了多少时间,但能挽救一大堆低级错误。
5. 一个可能被忽略的考点:冒泡排序的“退化”与“加速”
冒泡排序看起来简单,但它在算法设计的坐标里处于一个很关键的位置。理解它的优点和缺点,你才能明白为什么后面还有那么多排序算法要学。
5.1 什么时候最坏,什么时候最好
冒泡排序的比较次数和交换次数都取决于初始数组的逆序对数量。完全逆序时,每一对相邻元素都需要交换,比较次数和交换次数都达到最大;完全有序时,只需要扫描一趟,交换0次。
这个特性在很多PTA题目里会被拿来出题:“给定一个序列,冒泡排序需要交换多少次?”解法不是去模拟整个排序,而是统计这个序列中逆序对的数量。逆序对越多,冒泡要做的交换就越多。这也是为什么后面你会学到归并排序,它天然适合计算逆序对数量,因为归并过程可以顺便统计出逆序对的个数,复杂度还更低。
5.2 冒泡、选择、插入,到底谁更快
我经常被问到:同样是 O(n²) 级别的排序,冒泡、选择、插入有什么区别?我把三者的核心特性整理成了表格:
| 算法 | 最好复杂度 | 平均复杂度 | 最坏复杂度 | 稳定性 | 主要优势 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | 稳定 | 代码简单,能提前退出 |
| 选择排序 | O(n²) | O(n²) | O(n²) | 不稳定 | 交换次数少 |
| 插入排序 | O(n) | O(n²) | O(n²) | 稳定 | 对小规模数据或近似有序数据很快 |
选择排序每趟找最小值放到前面,最后一轮交换次数很少,但它没有提前退出的机制,所以即使数组已经有序,它也要跑满所有趟。插入排序对于部分有序数组表现极好,PTA里有一类专门考察“基本有序数组”的题,用插入排序往往比冒泡更快。
从实际对比来看,冒泡排序唯一的高光时刻是“本身已经有序的数组”和“代码极短的需求”。它的交换次数多、比较次数多,在工程上并不实用。但作为教学和入门题,它完美地向初学者展示了“比较-交换-循环”这三个算法设计的核心动作。
5.3 从排序到查找:PTA题库的自然延伸
刷到PTA平台的排序算法相关题目时,很多人的体验是:字符串逆序、二分查找、模式匹配这些题会接二连三地出现。它们背后有一个共同的逻辑:排序是查找的基础。你先把数据排好序,二分查找才能发挥威力;你理解了比较-交换的循环模式,字符串逆序这道题也不是难事。
比如PTA里有一类题,要求先把一组整数排序,再执行二分查找,判断某个数是否存在。如果你只会排序但不理解有序数列的特性,二分查找的边界条件很容易写错。“模式匹配”这类题,表面上跟排序无关,但它的核心也是“比较”和“循环”,算法思想是一脉相承的。
如果你把这到“冒泡排序”的题目吃透了,再往后学快速排序、归并排序、堆排序,会发现它们的骨架依然是“比较、交换、分治、迭代”。先慢下来,把这个最简单的算法搞明白,后面的大楼才盖得稳。
最后再说一点我的个人经验。教了这么多届初学者,我发现大家最常犯的错误不是在排序逻辑上,而是“不读题、不看数据范围、不自己设计测试用例”。我自己刷题时有一个笨办法:不管题目多简单,写完代码后都会在本地跑三组自定义数据,再提交到PTA。这个习惯帮我省下了不知道多少个“提交后失败的等待时间”。冒泡排序这道题是起点,不是重点——把它彻底弄懂,你收获的不只是AC,更是一整套应对算法题的思维方法。