ARTICLE DETAIL

资讯详情

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

C语言刷题:5×5矩阵鞍点求解,二维数组与gdb调试实战

C语言刷题:5×5矩阵鞍点求解,二维数组与gdb调试实战 学C语言最容易遇到的一种情况是题目看起来每个字都认识代码写完一运行结果却不对。这篇文章聊的是一个非常典型的例子5×5矩阵的鞍点求解。鞍点问题在几乎所有C语言题库里都会出现也是很多人刚学完二维数组、for循环嵌套之后的第一道“综合应用题”。标题里的“C语言-008”就当作是我自己刷题系列里的编号——第八个题目恰好用到了stdio.h和limits.h这两个头文件也恰好踩了一堆环境配置和调试的坑所以把这前前后后的事情完整记录下来。这个题目本身不难但特别适合用来检验你对二维数组、循环边界、初始化这几个基础知识点是不是真的理解扎实了。而且围绕它还会牵出一连串必须面对的问题Linux环境怎么搭、gdb调试工具怎么用、scanf和文件缓冲区到底怎么回事、程序为什么会莫名其妙输出“not found”。我把实际做题过程中整理出来的思路、代码、调试过程和踩坑记录都放在下面希望能让正在刷题的同学少走一点弯路。1. 题目看懂了吗鞍点问题到底在考什么很多同学拿到这个题的第一反应是鞍点是什么是不是高数里那个鞍点实际上不用想那么复杂在C语言的矩阵题目里鞍点的定义非常直白。1.1 什么是鞍点给定一个5行5列的矩阵二维数组如果某个元素满足两个条件在它所在的行上是最大值同时在它所在的列上是最小值那么这个位置就是鞍点。举个例子。假设矩阵长这样1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25第4行下标从0开始就是第3行的元素16、17、18、19、20这一行最大值是20。再看20所在的第4列5、10、15、20、25这一列最小值是5。20既不是整行的最大值吗是。但20在这一列是最小值吗不是因为这一列有5比它小。所以它不符合“列最小”的条件。再换一种情况如果矩阵是5 1 2 3 4 6 7 8 9第一行的最大值是5而5在第0列上的值分别是5、3、7最小值是3所以位置(0,0)的5也不是鞍点。真正的鞍点要同时满足“行最大”和“列最小”这两个硬性条件。实际题目里还有一个容易忽略的点鞍点可能不存在。输出“not found”是合法情况不是说程序出错。这是很多人第一次写完后反复怀疑自己代码有bug的原因之一。1.2 解题思路的两个关键决策想清楚定义之后思路其实就两条路可走。第一条路直接暴力验证对每一个元素a[i][j]先扫描第i行看它是不是最大值再扫描第j列看它是不是最小值。两个条件都满足就输出。这种做法的好处是思路无脑符合直觉代码也不容易写错。坏处是每个元素都要扫一整行和一整列5×5的矩阵无所谓但如果矩阵扩大到1000×1000复杂度就是O(n³)性能会很难看。第二条路先预处理再判断第一趟扫描把每一行的最大值找出来存好再把每一列的最小值找出来存好。第二趟扫描只需要对比a[i][j]是否同时等于rowMax[i]和colMin[j]就完事了。这样时间复杂度降到了O(n²)而且逻辑上更接近“用空间换时间”的经典思路。我在实际做题时用了第二条路因为题目用了“计算5*5鞍点问题”这个描述明显是想让你把二维数组的操作练扎实预处理法每一步都清晰也方便后面用gdb观察中间数据。如果只求快速AC第一种也完全没问题。两种方案我下面都会给出完整代码和对比。1.3 一个容易错的点行最大有并列怎么办这个点特别值得拿出来说。如果一行里面有两个相同的最大值比如{3, 5, 5, 2, 1}那么这行的“最大值”到底算哪个位置很多标准答案的做法是“取第一个最大值的位置”。也就是说只在a[i][j] rowMax的时候更新不用。这样遇到并列最大值时保留的是最早出现的那个。这种做法简洁但有一个隐患如果第一个最大值所在列不是列最小而第二个最大值所在列是列最小程序就会漏掉正确答案。严谨的做法是把一行中所有最大值的位置都记下来逐个去验证。对于学习阶段我更推荐用预处理法把每行的所有候选列号都存进数组再逐个检查。虽然代码多几行但逻辑无懈可击。下面代码里我会给出这种更严谨的写法。2. 环境准备在Ubuntu虚拟机里把C语言环境一次配好别看鞍点问题本身是代码层面的东西真正拦住新手的第一关往往是环境。我在一开始直接用Windows上的VSCode写C语言结果遇到了“无法打开源文件”“找不到stdio.h”之类的问题。后来换到Ubuntu虚拟机里用命令行编译调试一下子顺畅很多。2.1 为什么我推荐用虚拟机Linux而不是Windows原因很简单C语言和Linux是天生一对。GCC编译器是Linux自带的gdb调试工具也是Linux自带的不需要像Windows那样折腾MinGW、配置环境变量、处理VSCode的task.json和launch.json。这些折腾虽然也能搞定但会把学习精力从“理解C语言”转移到“折腾编辑器”。我用的是VMware里装一个Ubuntu Desktop虚拟机内存分配4GB硬盘分配20GB就够了。装了图形界面但实际编译调试都在终端里做。熟悉之后你会发现终端里敲命令比点鼠标高效得多。如果你实在不想装虚拟机用Windows下的WSLWindows Subsystem for Linux也是好选择。它本质上就是一个跑在Windows里的Linux子系统体验和虚拟机接近启动更快。不管用哪种方式核心目标只有一个让你能在纯正的Linux环境下用GCC和gdb干活。2.2 刚好够用的环境配置步骤我在一个全新Ubuntu上配C语言环境的操作记录如下配置一次后面都够用。先更新软件源索引然后安装GCC编译器和GDB调试器sudo apt update sudo apt install -y build-essential gdbbuild-essential是一个元包会帮你把gcc、g、make等工具链一次装齐。装完检查版本gcc --version gdb --version然后写一个最简单的C程序验证环境比如创建一个hello.c#include stdio.h int main(void) { printf(hello, c\n); return 0; }编译运行gcc -o hello hello.c ./hello看到输出“hello, c”就说明环境OK了。这里有一个小坑Linux下运行当前目录的程序要写./hello不能只写hello因为系统默认不会在当前位置找可执行文件。很多新手在这里卡住以为程序没编译成功。2.3 关于VSCode的两点补充我知道很多人习惯用VSCode写代码Ubuntu里装VSCode也完全没问题。但如果想省心我建议你分两层用写代码用VSCode有语法高亮、代码补全编译和调试用终端命令。这样你能清楚地看到发生了什么代码出错也知道去哪查。如果非要在VSCode里一键运行C语言需要自己装C/C扩展插件并且配置好编译器路径。Windows下还需要装MinGW并把bin目录加到PATH环境变量。相比之下Linux下VSCode默认就能找到gcc配置少很多。我遇到过的“无法打开源文件”问题绝大多数情况都是编译器没配置好或者工作区目录不对而不是代码本身的问题。3. 核心实现用stdio.h和limits.h写一个不翻车的鞍点程序环境就绪后代码就是主角了。这一节我会把两种主流解法都贴出来逐个注释讲清楚。特别是limits.h的INT_MAX和INT_MIN到底解决什么问题这是很多答案里没讲透的地方。3.1 方案A预处理法先记录每行最大值和每列最小值这个方案分三步走。第一步输入矩阵。第二步扫描每一行找最大值扫描每一列找最小值。第三步遍历每个元素判断它是否同时是行最大和列最小。完整代码如下#include stdio.h #include limits.h #define N 5 int main(void) { int a[N][N]; int rowMax[N]; // 每行的最大值 int colMin[N]; // 每列的最小值 int i, j; printf(请输入5x5矩阵\n); for (i 0; i N; i) { for (j 0; j N; j) { scanf(%d, a[i][j]); } } // 初始化 for (i 0; i N; i) { rowMax[i] INT_MIN; colMin[i] INT_MAX; } // 找每行最大值 for (i 0; i N; i) { for (j 0; j N; j) { if (a[i][j] rowMax[i]) { rowMax[i] a[i][j]; } } } // 找每列最小值 for (j 0; j N; j) { for (i 0; i N; i) { if (a[i][j] colMin[j]) { colMin[j] a[i][j]; } } } // 判断每个元素是否是鞍点 int found 0; for (i 0; i N; i) { for (j 0; j N; j) { if (a[i][j] rowMax[i] a[i][j] colMin[j]) { printf(鞍点: a[%d][%d] %d\n, i, j, a[i][j]); found 1; } } } if (!found) { printf(not found\n); } return 0; }注意看这个版本我用来判断也就是说只要元素同时等于行最大和列最小就输出。如果一行里有多个元素相等且都等于行最大理论上只要它们也同时是列最小就会被全部输出。这就是我之前说的“严谨做法”。大部分题目输出任意一个即可但多个输出也不扣分。3.2 方案B逐行找最大并当场验证更省内存但要注意并列另一种常见写法是每一行先找出最大值的位置然后立刻去检查这一列上有没有更小的数。这种方法节省了colMin数组内存占用更小思路也更直接。#include stdio.h #include limits.h #define N 5 int main(void) { int a[N][N]; int i, j; printf(请输入5x5矩阵\n); for (i 0; i N; i) { for (j 0; j N; j) { scanf(%d, a[i][j]); } } for (i 0; i N; i) { int max INT_MIN; int maxCol 0; for (j 0; j N; j) { if (a[i][j] max) { max a[i][j]; maxCol j; } } int isMin 1; for (int k 0; k N; k) { if (a[k][maxCol] max) { isMin 0; break; } } if (isMin) { printf(鞍点: a[%d][%d] %d\n, i, maxCol, max); return 0; } } printf(not found\n); return 0; }这个方案有个隐患如果一行里最大值并列比如第0行有两个5第一个5的位置在第1列第二个5的位置在第3列第1列不是列最小第3列才是列最小。这个写法只保存了第一个最大值的位置就会漏掉第二个。要彻底解决得把一行的所有最大值列号保存成数组逐个验证。我在学习阶段更推荐方案A因为它直观且不易出错。3.3 关键细节为什么要用INT_MAX和INT_MINlimits.h头文件里定义了一批跟整型范围相关的常量。INT_MIN表示int类型能表示的最小值通常是 -2147483648INT_MAX表示int类型能表示的最大值通常是 2147483647。找最大值之前把“历史最大值”初始化为INT_MIN这样矩阵里任何一个合法整数都比它大第一次比较就会更新。反过来找最小值之前把“历史最小值”初始化为INT_MAX这样任何一个合法整数都比它小。如果不这么干常见的错误写法是把max初始化为0。一旦矩阵里全是负数比如{-1, -2, -3, -4, -5}最大值-1比0小程序就会认为最大值是0最后结果完全错误。所以记住只要找最值就无脑初始化成INT_MIN或INT_MAX这是C语言刷题圈的通用防坑习惯。4. 调试实战用gdb把“not found”变成“找到了”题目本身逻辑不算复杂但真到运行的时候经常出现“明明有鞍点程序却输出not found”的情况。这种时候靠眼睛瞪代码很难发现问题用gdb工具一步步看中间变量的值才是高效办法。4.1 准备工作编译时加-g要让gdb能定位到源码里的行号和变量名编译时必须加-g参数gcc -g -o saddle saddle.c然后启动gdbgdb ./saddlegdb启动后不会直接运行程序而是等你的调试命令。这里我把最常用的命令整理成一张速查表命令作用break 行号在指定行设置断点run运行程序遇到断点停下next执行当前行不进入函数内部step执行当前行进入函数内部print 变量名打印变量当前的值watch 变量名当变量值变化时暂停info locals查看当前作用域所有局部变量continue继续运行到下一个断点quit退出gdb4.2 一个真实的调试场景有一次我写的程序对某个矩阵总是输出not found我怀疑是“列最小”的判断写错了。于是我在代码里“检查列最小”的那一行设了断点(gdb) break 45 (gdb) run程序运行到第45行停下后我一步步查看(gdb) print i $1 0 (gdb) print max $2 23 (gdb) print maxCol $3 2 (gdb) print a[1][2] $4 19我意识到maxCol是2也就是第0行的最大值在第2列。接着想看这一列所有元素(gdb) print a[0][2] $5 23 (gdb) print a[1][2] $6 19 (gdb) print a[2][2] $7 31发现第2行的31比23大所以第0行的最大值23在这一列不是最小值程序正确输出了not found。原本以为代码有bug实际是矩阵本身没有鞍点。用gdb确认了这个事实后我就回去检查测试数据了。4.3 更高级一点用watch监视变量有时候你想知道某个变量的值到底是什么时候变的。比如想知道colMin[j]是哪一次赋值被改掉的可以这样(gdb) watch colMin[0] (gdb) continue一旦colMin[0]的值发生变化gdb就会立刻暂停并告诉你是在源码哪一行、由什么操作触发的。这比在循环里手动打印要高效得多。用gdb还有一个额外的好处它能帮你建立对内存的直觉。比如用print a[0][0]能看到二维数组在内存中的实际地址再打印print a[1][0]会发现两者相差20个字节5个int每个int 4字节。这种直观感受是看再多书也比不上的。5. 常见问题与排查这些问题我都在群里见过鞍点问题写完后很多人会继续去刷别的题目比如字符串逆序、完数、冒泡排序、九九乘法表。不管刷什么有几个C语言基础问题会反复找上门来。这里我把高频问题整理成一个速查表遇到的时候直接对照排查。5.1 常见C语言问题速查表问题原因解决办法程序不输出卡在输入scanf格式和输入内容不匹配检查输入格式是否严格匹配%d、%c等占位符输出结果有乱码数组越界或未初始化用-Wall编译看警告用gdb检查数组边界无法打开源文件编译器没有正确配置切换到Linux命令行用gcc编译或修复include路径变量值“莫名其妙”局部变量未初始化声明时赋初值或用INT_MAX/INT_MIN初始化printf输出顺序不对缓冲区没有刷新理解文件缓冲区机制必要时调用fflush5.2 scanf相关的几个坑新手最喜欢在scanf上栽跟头。最经典的是“scanf一定要输入abc吗”这类疑问。实际上scanf(%d, x)要求输入的是十进制整数你输入abc它根本不会读进去scanf会返回0表示匹配失败字符还留在缓冲区里。下次再调用scanf又会读到同样的abc于是陷入死循环。解决方案有两个一是检查scanf的返回值匹配失败就清空缓冲区二是用getchar把残留字符吃掉。我之前写过一段清空缓冲区的代码供参考#include stdio.h int main(void) { int x; printf(请输入一个整数); if (scanf(%d, x) ! 1) { printf(输入格式不正确\n); // 清空输入缓冲区 while (getchar() ! \n); } else { printf(你输入的是%d\n, x); } return 0; }还有一个常见问题是“怎么换行输入”。%d格式符会自动跳过空格、换行、Tab所以输入矩阵时不管每个数字之间是空格还是回车都能正确读入。根本不需要额外处理换行。另外很多人对a b和a b的区别不清楚。b是先自增再赋值b原来是5执行a b后a和b都是6。b是先赋值再自增执行a b后a是5b变成6。这个搞清楚了很多“莫名其妙的值”就都有解释了。5.3 文件缓冲区到底是什么缓冲区这个问题看起来和鞍点没关系但刷题多了总会遇到。C语言标准I/O库会在内存里维护一段缓冲区数据不是立刻写入文件或屏幕而是攒够了再一起刷出去。printf输出的内容如果没遇到换行符或者程序没有正常结束可能在屏幕上迟迟看不到。理解这个机制后很多现象就能解释了。比如程序崩溃时最后的printf内容可能没有输出到屏幕就是因为它还在缓冲区里没来得及刷新。再比如运行到scanf等待输入时有时候printf的内容没有显示出来也是缓冲区的问题。这时候加一行fflush(stdout)强制刷新就能解决。文件操作里的fscanf和fprintf也有同样的缓冲区机制。读完文件别忘了fclose它除了释放资源还会把缓冲区里剩余的数据刷到文件里。5.4 while和do-while到底怎么选这个基础点我也顺便说清楚。while是先判断条件再执行循环体条件一开始就不成立的话循环体一次都不执行。do-while则是先执行一次循环体再判断条件所以至少会执行一次。在鞍点这类需要先“找出候选点”再“验证”的场景用顺序执行加标志位更自然。但如果你在写菜单程序希望选项界面至少显示一次用do-while就非常贴切。我在刷题时对这两个循环的选用原则是不知道要不要执行就用while必须至少执行一次就用do-while。这个原则简单好记能解决绝大多数场景。6. 从鞍点向外扩展刷题还要注意这些鞍点问题本身只是一个起点。把它做熟之后你会发现自己对二维数组、循环、初始化都有了更深的直觉。这些基本功在后面的指针、内存管理、字符串处理里都会反复用到。6.1 推荐顺手做的几个经典题目做完鞍点我建议按顺序刷这几个经典题目它们能帮你巩固不同维度的知识点完数一个数如果等于它的所有因子之和不含自身就是完数比如6 1 2 3。这个题目能练习循环嵌套和条件判断还能复习取余运算。字符串逆序把字符串原地反转。这个题目很适合用来理解双指针思想一个指针从头往后走一个从尾往前走交换两个字符。冒泡排序两层循环内层相邻元素两两比较。理解它之后再去理解选择排序、插入排序会轻松很多。九九乘法表看似简单实际很考验printf的格式控制特别是%2d这类宽度设置有几个空格都影响输出对齐效果。这些题目都不长但每一道都能让你对“循环边界”有更痛的领悟。比如冒泡排序到底循环几次、内层循环边界要不要减一这些细节写错一次你就能记住一年。6.2 指针和内存管理迟早要面对的大山鞍点题目里用到了二维数组很多人会进一步问二维数组和指针到底什么关系a[i][j]和*(*(a i) j)是等价的吗答案是等价的。数组名a本质上是一个指向数组首元素的指针常量二维数组在内存中是连续存储的a[i][j]就是*(a i * N j)。到了动态内存这一步就会牵扯到malloc和free。堆内存需要手动管理用完后必须释放否则会产生内存泄漏。在嵌入式或者数据量大的场景里内存分配失败还会返回NULL所以调用malloc后检查返回值是个好习惯。学到这里你会发现之前在鞍点里用INT_MAX初始化这种小事其实就是在养成一个起点很高的好习惯——显式地、安全地处理每一个变量。6.3 一点拓展思路如果你对编程题有兴趣很多在线题库里还有更多有趣的问题可以练手。比如某题库有一道“在霍格沃茨找零钱”的题本质就是单位换算和进制模拟和C语言的整数运算结合得非常紧密做起来很有趣。还有人会问C语言学到后面能做什么我用C语言写过网吧计费管理的小项目就是控制台程序加文件读写也写过简单的弹球游戏用到了图形库和坐标计算。还有人在嵌入式方向用C语言写ADC值滤波函数做数据平滑处理。这些方向的前提都是把数组、指针、循环这些基础打牢。我个人在实际操作中的体会是刷鞍点这类题目价值不在于这道题本身有多难而在于它把你脑子里的“数组应该怎么遍历”“边界怎么控制”这些事彻底逼到了台面上。写代码时每多思考一层“为什么会这样”后面的路就会顺畅一分。最后再送大家一个小技巧编译时加上-Wall -Wextra把警告当错误来修很多隐蔽的问题在运行前就会暴露出来。
返回列表