十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++指针冒泡排序:从底层原理到代码调试的完全指南

C++指针冒泡排序:从底层原理到代码调试的完全指南 如果你正卡在“C入门练习题里的指针冒泡排序”上这篇笔记应该能帮到你。作为C入门阶段最常被拿来练手的组合题指针和冒泡排序绑在一起难度其实没有想象中那么高但它确实是检验你三样基本功够不够扎实的好题目指针变量的本质理解、数组和指针的内在关系、函数参数传递的地址语义。这篇文章会从题目拆解开始逐步讲清楚背后原理、完整代码、常见报错再到优化方向和进阶练习尽量让刚学完语法但还没“开窍”的同学也能照着写、照着调试、照着把这个知识点吃透。在开始之前先把结论放在前面这道题真正想让你掌握的不是“背一个排序算法”而是让你明白数组名在函数传参时到底发生了什么以及为什么用指针能直接改到外部数据。搞懂这两点指针冒泡排序就不存在任何魔法了。1. 题目拆解这道经典练习题真正在考你什么1.1 为什么偏偏是指针加冒泡排序学习C/C的路上几乎每个人都写过冒泡排序也几乎每个人都被指针卡过一段时间。但把这两者放在同一道练习题里并不是为了单纯增加题目难度而是因为它们在底层逻辑上有天然联系。冒泡排序操作的对象是一段连续存储的数据这段数据在C/C里最自然的载体就是数组。而数组这一块连续内存的起始地址恰恰就是指针变量最擅长保存和处理的东西。所以说白了用指针实现冒泡排序本质上就是在模拟“数组下标背后发生的事情”你用arr[i]能访问数据是因为编译器帮你做了“基地址加偏移量”的换算而指针则是把这个换算过程摊开给你看让你明确知道arr i这个地址里存的是什么。很多同学不理解为什么非要绕一圈用指针直接用数组下标不香吗我的看法是练习题的意义从来就不是“解决这一个问题”而是“通过这个问题掌握一类技能”。以后你要写动态内存管理、写链表、写树、做嵌入式开发或游戏引擎里的内存操作靠的全是指针这套地址思维。在下标写法里数组退化成指针的过程被隐藏了一旦切换到指针写法你必须直面地址、取值、偏移这些最底层的东西这个思维转换本身就是这道练习题的含金量所在。1.2 题目考察的三个核心能力点具体拆开来看这道题至少踩到了下面三个考察点指针的基本操作声明指针变量、取地址符号、解引用符号*、指针的加减运算。这些是零散的知识点单独考每个你都认识但放到一个完整的排序函数里就要求你能灵活组合它们。数组名与指针的关系数组名在很多场景下会隐式转换成指向首元素的指针。这导致了“用数组下标访问”和“用指针偏移访问”本质上是一回事。题目希望你亲自验证这个等价性而不是停留在“听别人说”。函数参数的地址传递如果你把数组传进函数并且在函数里交换元素外部数组真的会被改变。这背后的原因就是传进去的是地址、是指针函数通过指针修改的是同一块内存。这个特性是C/C里“输入输出参数”的雏形理解它后面学引用、学结构体指针、学链表节点插入都会顺利很多。把这三点连起来看你就会发现题目设计其实很巧妙排序算法负责制造“修改数据”的需求指针负责提供“修改原数据”的手段而数组刚好是把两者连接起来的桥梁。三个知识点在这样一个场景里互相印证比分开做十道填空题记得牢固得多。1.3 初学者最容易陷入的两个误区第一个误区是把指针“神秘化”。有些同学一听指针就觉得是特别底层、特别难的东西还没开始写代码就在心里打退堂鼓。其实指针说白了就是“存放地址的变量”地址就是内存的门牌号。你要修改别人家的东西光知道对方名字变量名不够必须拿到对方的门牌号地址然后通过门牌号找上门解引用才能动里面的东西。这个类比虽然粗糙但用来理解入门阶段90%的指针场景是够用的。第二个误区正好反过来觉得“反正就会用就行”完全不关心背后发生了什么。如果你只是照着网上的代码敲一遍能跑通就觉得自己会了那这道题的价值就浪费了大半。考试和面试里最常问的变体就是为什么形参写int* arr和int arr[]没区别为什么在函数里用sizeof(arr)求不出数组长度这些问题只要动手写过指针版本、并且稍微琢磨过一遍基本都能答上来。只凭记忆背结论过两天就忘了。2. 动手前的底层原理指针、数组、函数参数是怎么配合的2.1 从内存视角看待指针变量要理解指针冒泡排序先要把指针到底在“指”什么这件事彻底搞明白。我们声明一个普通变量比如int num 42编译器会为它分配一小块内存这块内存有地址你可以理解成门牌号也有内容就是整数42这个值。当你写int* p num时实际上干了这样一件事声明了一个新的指针变量p并把num这块内存的门牌号存进了p里。此时如果你打印p会得到一个类似0x7ffdb8a2c4ac的十六进制数字这就是num在内存中的地址。而如果你写*p编译器就按p里存的地址找到对应内存然后取出里面的值也就是42。这个取值的动作专业术语叫“解引用”它在表达式里就相当于“把p指向的那个变量当作普通变量来用”。#include iostream using namespace std; int main() { int num 42; int* p num; // p保存num的地址 cout num的地址: p endl; cout 通过指针取值: *p endl; *p 100; // 通过指针修改num的值 cout 修改后num: num endl; // 输出100 return 0; }注意一个关键点对*p赋值*p 100等价于对num本身赋值。这就是指针最核心的用途——间接修改。冒泡排序里的交换操作本质上就是对数组元素做间接修改用指针写完全符合这个语义。2.2 数组名和指针的“爱恨纠葛”数组和指针的关系是C/C里比较微妙的部分很多人在这里被绕晕。先说硬性结论在绝大多数表达式中“数组名”会隐式转换成“指向首元素的指针”。比如你声明int scores[5]那么scores这一个名字在当参数传、做赋值、参与运算时都会变成int*类型指向scores[0]的地址。举个例子int scores[5] {88, 95, 70, 60, 99}; int* p scores; // 不需要写 scoresscores自动变成指向首元素的指针 cout *(p 0) endl; // 输出88 cout *(p 3) endl; // 输出60 cout p[2] endl; // 输出70p[2]等价于*(p2)这里*(p 3)的含义是从数组首地址向后移动3个int的距离也就是移动3个int大小的字节然后取出那个位置的元素。这个“移动”不是简单的数字加3而是按指针类型的大小做偏移。如果p是int*p3就相当于地址值增加了3乘以sizeof(int)字节如果p是double*同样是p3地址值增加的就是3乘以sizeof(double)字节。这种“按类型定步长”的设计保证了你无论操作什么类型的数组指针加1总是能准确移动到下一个元素的位置。这里必须单独提醒一个反直觉的点sizeof(scores)并不会退化成指针大小。在声明scores的同一作用域里sizeof(scores) / sizeof(scores[0])能算出数组长度因为此时编译器知道scores是一个完整的数组对象但一旦scores被传进函数形参它就已经退化成指针了函数里写sizeof(scores)得到的只是指针本身占用的字节数64位系统下通常是8字节而不是整个数组的字节数。这是C初学者最容易踩的坑之一后面实战部分我会再强调。2.3 函数传参为什么形参写指针就能改外部数据C的函数参数传递默认是“按值传递”意思是把实参的内容拷一份给形参。对普通变量来说在函数里给形参赋值不会影响外部的实参。但这里有一个容易混淆的点当你把数组名传进函数时你拷贝的并不是整个数组而是数组首元素的地址因为数组名已经退化成了指针。于是函数里拿着这个地址完全可以顺着地址找到原始数组的内存并修改里面的内容。看一下这两种函数声明的关系void bubbleSort(int arr[], int n); void bubbleSort(int* arr, int n);这两种写法在编译器眼中是完全一样的都表示“第一个参数是一个int指针”。int arr[]这个写法只是为了在阅读上提示“这里传进来的应该是一个数组”它本质上还是int*。因此在函数内部arr就是一个普通的指针变量支持所有指针运算比如arr 1、*(arr j)、arr[j]等等。理解了这一点你就明白为什么在做数值交换时不需要返回值也能改到外部数组因为函数内通过*(arr j)访问的就是外部那段数组内存本身。而如果你试图在函数内给arr这个指针变量本身赋值比如arr new int[n]那外部不会受到影响因为这改的是形参指针的指向不是指针指向的内存内容。这个区分很关键很多同学就是在这里把“通过指针修改数据”和“修改指针本身”混为一谈后面遇到链表插入、指针重指向时就乱了。3. 核心实现指针冒泡排序的完整思路与代码3.1 算法本身先过关冒泡排序的每一轮在干什么在写指针版本之前先把冒泡排序的逻辑用大白话捋一遍。冒泡排序的核心思想是重复地遍历待排序序列一次比较相邻的两个元素如果顺序不对就把它们交换过来。每一轮遍历结束序列中最大的那个元素就会像气泡一样“浮”到当前范围的末尾所以这一轮叫做“冒泡”。假设数组有n个元素那么第一轮对下标0到n-1的相邻元素做比较交换结束后第n-1个位置一定是最大值。第二轮对下标0到n-2的相邻元素做比较交换结束后第n-2个位置一定是第二大的值。第i轮对下标0到n-1-i个元素做比较交换结束时当前范围最后一个位置被确定。因此总共需要n-1轮最后一轮只剩一个元素无需再排内层每轮比较的次数依次是n-1、n-2、...、1。这个“次数递减”用两个嵌套for循环表达就是for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { // 比较 arr[j] 和 arr[j1]如果 arr[j] arr[j1] 就交换 } }用生活场景来类比这就像一列排队的人每次只允许相邻两个人比较身高身高更高的往右挪。第一轮比完最高的人一定站到了最右边第二轮就不用再管最右边那个人了继续处理剩下的队列。如此重复整个队列就从矮到高排好了。3.2 第一版从数组下标过渡到指针写法很多教材会先展示数组下标版本然后说“改成指针就行了”。但怎么改、为什么能改往往是含糊带过。我们先从最接近下标风格的写法入手让过渡自然一些。#include iostream using namespace std; void bubbleSortByPointer(int* arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { // arr[j] 等价于 *(arr j) if (*(arr j) *(arr j 1)) { int temp *(arr j); *(arr j) *(arr j 1); *(arr j 1) temp; } } } } int main() { int scores[] {88, 95, 70, 60, 99, 82, 77}; int n sizeof(scores) / sizeof(scores[0]); cout 排序前: ; for (int i 0; i n; i) { cout *(scores i) ; } cout endl; bubbleSortByPointer(scores, n); cout 排序后: ; // 用指针遍历输出 for (int* p scores; p scores n; p) { cout *p ; } cout endl; return 0; }这段代码里需要用到一个练习中反复练习的等价关系*(arr j)就是arr[j]。arr是指向首元素的指针加j就移动到第j个元素的位置再解引用就取到该位置的元素值。同样*(arr j 1)就是arr[j 1]。这样写出来的代码逻辑和下标版本几乎一一对应只是把“数组运算符”换成了“指针运算加解引用”非常适合作为过渡。main函数里的输出段也刻意用了指针遍历int* p scores; p scores n; p。这里p从首元素地址开始每次自增就跳到下一个元素当p到达末尾地址scores n时循环结束。这里需要留意scores n指向的是数组最后一个元素之后的位置C标准里允许这样“越界一个位置”的地址存在只用来做比较不能解引用。所以循环条件是p scores n输出的是*p刚好覆盖下标0到n-1的所有元素。3.3 第二版让指针变量直接参与内层循环第一版虽然用了*(arr j)但整体上还是“把数组指针当作数组名来用”有些人觉得不够“指针味”。如果想更彻底一点可以让指针变量本身承担遍历职责。先看代码void bubbleSortByPointer(int* arr, int n) { // 外循环每轮确定一个最大值放到末尾 for (int i 0; i n - 1; i) { // 内层用指针遍历未排序区域 int* current arr; int* end arr (n - 1 - i); for (; current end; current) { if (*current *(current 1)) { int temp *current; *current *(current 1); *(current 1) temp; } } } }这里end指向当前这轮最后一个需要参与比较的元素而current遍历从首元素到end的前一个位置。因为比较的是*current和*(current 1)所以当current等于end时就不能再比了否则current 1就越界了。这个写法的好处是你把“循环变量是下标”替换成了“循环变量是地址”更贴近指针在底层做偏移的真实过程。说一个实际讲课时的感受很多同学第一次看到指针做循环变量会有点不习惯总觉得current好像很神秘。其实和整数循环变量没本质区别i是把整数加1current是把指针变量的地址值增加一个int类型的大小让它指向下一个元素。理解了这一点指针遍历数组就和其他语言的迭代器、Python里的for x in列表没有本质差别了。3.4 第三版终极写法用函数指针和泛型实现升级版如果你已经掌握了前两版可以再挑战一种更“工程化”的写法用函数指针控制排序方向用模板适配任意类型。这里作为入门阶段的拓展内容可以先体会思路不必强求立刻掌握所有细节。#include iostream using namespace std; // 比较函数升序 bool ascending(int a, int b) { return a b; // 当前面大于后面时需要交换 } // 比较函数降序 bool descending(int a, int b) { return a b; // 当前面小于后面时需要交换 } // 用函数指针接收比较规则 void bubbleSort(int* arr, int n, bool (*compare)(int, int)) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (compare(arr[j], arr[j 1])) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } } int main() { int nums[] {5, 2, 9, 1, 7, 3}; int n sizeof(nums) / sizeof(nums[0]); bubbleSort(nums, n, ascending); cout 升序: ; for (int i 0; i n; i) cout nums[i] ; cout endl; bubbleSort(nums, n, descending); cout 降序: ; for (int i 0; i n; i) cout nums[i] ; cout endl; return 0; }函数指针在这里扮演的角色是“把变化的规则参数化”这样同一个排序函数既能升序也能降序不需要复制粘贴两套代码。这其实也是C里“策略模式”的雏形算法骨架固定细节规则通过回调函数注入。如果把compare换成std::function或者直接使用更现代的lambda表达式逻辑会更简洁但入门阶段用普通函数指针看一遍这种写法对理解类型系统和函数地址的帮助挺大。3.5 复杂度分析为什么冒泡排序是O(n^2)排序算法学完了复杂度分析也得会算。冒泡排序有两层循环外层要执行n-1轮内层每轮平均比较约n/2次所以总的比较次数约为(n-1) (n-2) ... 1 n*(n-1)/2。在大O记法下这相当于O(n^2)。最好情况下数组已经有序如果没做优化仍然要进行相同次数的比较所以也是O(n^2)如果加了提前退出的标志位最好情况可以降到O(n)。交换次数在最坏情况下和比较次数同量级最好情况下是0。从实践角度看冒泡排序因为相邻交换的特性是稳定排序相等的元素不会改变相对顺序而且实现简单、便于理解做学习和面试入门题非常合适。但如果数据规模过万性能就比较尴尬了这时候快速排序、归并排序、堆排序这些O(n log n)的算法才是正选。学习冒泡排序的意义更多在于“理解排序是怎么一回事”而不是真的拿它处理大数据集。4. 常见问题、调试技巧与避坑实录4.1 编译报错和运行崩溃的原因对照表根据我这些年帮人看代码的经验指针冒泡排序的错误类型其实非常集中。我把最常见的几张“诊断卡”列在下面错误现象根本原因解决办法编译报错cannot convert int ()[5] to int调用时传了scores而不是scores直接传数组名scores不要画蛇添足加取地址符编译报错invalid conversion from int to int*声明指针时写成了int* p scores[0]误把元素值当地址改成int* p scores需要的是首元素的地址运行结果完全没排序函数形参传的是值或者内部复制了一份数组没有修改原数组确认形参是int*或int arr[]而不是int arr[10]检查调用时传的是数组名程序崩溃Segmentation fault内层循环越界访问比如j n而不是j n-1-i导致arr[j1]访问到数组末尾之后对照逻辑逐轮检查内层循环条件牢记每轮比较次数递减输出乱码或地址值输出时忘记解引用直接打印了p而不是*p检查输出语句指针要加*才能取到元素值数组长度不对排序只排了部分函数内用sizeof(arr)/sizeof(arr[0])计算长度数组传入函数后大小信息丢失必须在调用处算好长度再传进来这里面最容易踩到的是最后一条。很多同学在main函数里用sizeof算数组长度算得很顺就顺手在排序函数里也这么写。但函数形参里的arr已经退化成指针了sizeof(arr)只会返回指针大小比如8字节再除以sizeof(int)4字节得到2于是排序函数以为数组只有两个元素。这种错误不报错、不崩溃但结果诡异特别容易让人排查半天。4.2 调试冒泡排序的实用手段初学阶段写排序最重要的是把“程序运行时的过程”可视化出来。我的习惯是在关键位置临时加打印语句把每一轮比较后的数组状态打出来。比如在内层循环结束时加一行#include iostream using namespace std; void bubbleSortDebug(int* arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (*(arr j) *(arr j 1)) { int temp *(arr j); *(arr j) *(arr j 1); *(arr j 1) temp; } } cout 第 i 1 轮结束: ; for (int k 0; k n; k) { cout *(arr k) ; } cout endl; } } int main() { int arr[] {5, 1, 4, 2, 8}; int n sizeof(arr) / sizeof(arr[0]); bubbleSortDebug(arr, n); return 0; }运行上面的代码你会清楚看到每一轮之后数组的变化第1轮结束: 1 4 2 5 8 第2轮结束: 1 2 4 5 8 第3轮结束: 1 2 4 5 8 第4轮结束: 1 2 4 5 8第三轮和第四轮的输出完全一样说明第三轮已经没有发生任何交换整个数组已经有序了。如果你在代码里没有提前退出的优化那么第四轮就是在白跑。这种“肉眼可见的浪费”就是优化冒泡排序最直观的动力。除此之外对指针本身的调试我推荐两个办法。第一是打印地址值直接观察指针的移动轨迹int* p arr; for (int i 0; i n; i) { cout p p , *p *p endl; p; }第二是用调试器的监视窗口比如Visual Studio或VS Code里打断点把p、*p、p1、arr都加到监视列表里看它们的变化。这样做一次调试比空想十遍都管用。4.3 如何给排序加上“提前退出”优化前面提到冒泡排序最经典的优化是“如果某一轮没有任何交换说明数组已经有序提前结束”。实现起来很简单加一个布尔标志即可void bubbleSortOptimized(int* arr, int n) { for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - 1 - i; j) { if (*(arr j) *(arr j 1)) { int temp *(arr j); *(arr j) *(arr j 1); *(arr j 1) temp; swapped true; } } if (!swapped) { break; } } }这个优化在日常数据接近有序的场景下收益非常明显。想象一个几乎排好的长度为10000的数组普通冒泡要执行9999轮比较每次大约一半的概率还在做无用功加了提前退出之后可能第2轮就发现没有任何交换直接结束比较次数从约5000万次骤降到约2万次性能提升肉眼可见。这个思路在后面学习快速排序、归并排序时也通用任何一轮操作如果没有改变数据状态说明后续操作大概率也不会改变及时止损。4.4 针对指针的进阶练习const、指针数组、动态内存、字符串排序如果你把上面的代码都跑通了说明这道练习题的核心目标已经达成。但指针的重量远不止于此我建议趁热打铁做几个变体练习把知识面再拓一拓。练习一了解const在指针里的两种含义int value 42; const int* p1 value; // 指针指向的内容不能通过p1修改但p1本身可以指向其他地方 int* const p2 value; // 指针本身不能改指向但可以通过p2修改指向的内容 const int* const p3 value; // 既不能改指向也不能改内容这里其实就涉及到搜热词里经常提到的“顶层指针”和“底层指针”的区别。粗略理解const修饰的是“指针本身”就是顶层指针修饰的是“指向的内容”就是底层指针。底层指针可以赋值给底层指针顶层指针也可以赋值给顶层指针但要小心底层指针赋值给顶层指针是合法的因为权限没有扩大反过来顶层指针赋值给底层指针就不行因为这个操作让原本只读的内容变得可修改了编译器会拦下来。排序函数里如果想承诺“不修改数组”可以写成const int* arr但那样交换元素也会被禁所以排序场景下不能加这个限制这个矛盾本身也是值得思考的好题目。练习二用动态内存分配创建数组再排序int n; cin n; int* dynamicArr new int[n]; for (int i 0; i n; i) { cin dynamicArr[i]; } bubbleSort(dynamicArr, n); delete[] dynamicArr;这道题的意义在于打破“数组长度必须编译期确定”的限制。new出来的数组同样可以用下标或指针访问排序函数完全不需要改动因为函数接收的就是一个int*。注意排序结束后要delete[]释放内存避免内存泄漏。理解这个流程之后你就初步掌握了堆内存管理的基本套路。练习三对字符串数组进行排序如果你已经掌握了指针数组的概念可以试着一个更进阶的题目输入若干字符串用指针数组管理然后按字典序排序。这里的核心是把“交换两个字符串的指向”和“交换两个字符串本身的内容”区分开。如果每个字符串是char*交换指针指向往往比直接复制字符内容高效得多这也是指针在字符串处理中的经典应用。类似题目在面试里非常常见本质上是考察“修改指针本身”和“通过指针修改数据”的区别。练习四归并排序、快速排序的指针实现冒泡排序掌握后可以看看如何在快速排序里用双指针法做分区partitionint partition(int* arr, int low, int high) { int pivot arr[high]; int i low - 1; for (int j low; j high; j) { if (arr[j] pivot) { i; swap(arr[i], arr[j]); } } swap(arr[i 1], arr[high]); return i 1; }这里的双指针i和j一次遍历就完成“小于pivot的都放左边大于的放右边”比冒泡排序每轮只移动一个最大值要高效得多。你会发现理解指针偏移和数组内存布局后这些算法读起来就没有那么晦涩了。5. 写在最后的细节与个人建议这里想分享一个我自己当年踩过的坑也算给后来者提个醒。刚开始写指针版本时我理解错了数组名和首元素地址的关系以为数组名本身就是一个指针变量于是试图在函数里修改数组名的指向。直到后来我才明白数组名更像是一个“常量指针”它固定指向数组首元素的地址不能被重新赋值。所以你可以用另一个指针变量去保存它、去移动它但不能直接改数组名本身。搞清这一点后很多关于数组和指针的诡异报错就都说得通了。做这道练习题时我的建议是不要只满足于“把代码跑通”。你可以在纸上手动模拟一遍循环写出每一轮结束后数组的样子和程序实际输出做对照也可以故意把内层循环的边界写错观察报错结果、记住越界访问带来的后果还可以把数组换成double、char、结构体数组看看排序逻辑怎么变化。把一个题目吃透到可以应对任意变体比草草写十道类似的题更有价值。最后再给一个小技巧无论用哪种写法在main函数开头用sizeof算好长度再传给排序函数永远不要在排序函数内部尝试重新计算数组长度。这个问题我在上面反复提了好几次因为它确实是初学者最高频的失误之一。把这一条牢牢记住你在写这类题目时会少踩很多坑。指针和冒泡排序的结合练习算是C入门阶段一个非常经典的“关卡”。跨过去之后你对内存、地址、间接访问的理解会上一个台阶后面学链表、学二叉树、学容器源码都会顺畅很多。希望这篇笔记能帮你把这个知识点彻底拿下。
返回列表