ARTICLE DETAIL

资讯详情

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

信息学奥赛经典题:图像模糊处理与二维数组防坑指南

信息学奥赛经典题:图像模糊处理与二维数组防坑指南 如果你正在刷《信息学奥赛一本通》的二维数组章节大概率会在“1128图像模糊处理”这一题上停一下。它同时也是 OpenJudge NOI 1.8 13几乎各大OJ题库都收录了这道题。题目本身不难但每年总有一批同学在“要不要另外开一个数组”这个点上栽跟头甚至样例都过不去却怎么都看不出问题在哪。这篇文章就把这道经典题彻底掰开揉碎题面在说什么、代码应该怎么写、哪些坑最容易踩以及做完这题之后你能得到什么通用能力。适合刚学完二维数组、想在信息学竞赛入门阶段把多维数组基础打牢的同学也适合带集训队的老师直接拿来当讲稿。1. 先把题目看明白它到底让你做什么1.1 用一句话翻译题目要求输入第一行是两个整数 n 和 m表示图像有 n 行 m 列。接下来 n 行每行 m 个整数表示每个像素点的灰度值。输出模糊处理后的图像格式和输入一样。处理规则拆开看只有两条最外圈的像素点灰度值不变也就是所有满足 i 0 或 i n-1 或 j 0 或 j m-1 的像素直接原样保留。内部的像素点新灰度值等于它自己加上上、下、左、右四个相邻像素的灰度值这五个数的平均值。如果平均值不是整数直接取整数部分。这里的“取整数部分”在 C 里有一个很直接的实现方式整型除以整型结果自动截断小数部分。比如 7 / 5在 C 的 int 运算里结果是 1而不是 1.4也不是四舍五入的 1。所以写代码的时候不用绕弯子直接除 5 就行。1.2 为什么这道“图像处理题”适合练二维数组很多人看到“图像模糊”四个字就心里发怵觉得是不是要涉及什么图像算法。其实竞赛题里的图像就是一张二维整数表灰度值无非是 0 到 255 之间的普通整数。这道题的核心操作说白了就是“读入一个二维数组按规则改掉中间一部分值再输出”。它考察的就是二维数组最基础的三板斧声明、读入、通过嵌套循环访问指定下标。从更高的视角看这题还是“均值滤波”的最小原型。图像处理里的模糊本质就是拿一个小矩阵滤波核在原图上滑动把滤波核覆盖区域的灰度求平均作为中心点的新值。本题的滤波核就是“自己加上下左右”五选一权重均匀是 1/5。如果你以后学数字图像处理再回头看这道题会发现它的思路完全一致。还有一个值得想清楚的问题为什么边缘像素不处理因为边缘像素凑不齐上下左右四个邻居如果非要用同样的公式硬算就得处理“越界”的问题比如把越界部分当 0 或者复制边缘这样反而会把图像边缘“拉花”。题目选择最简单的方式——边缘保持原样我们照做即可。2. 动手之前两个关键决策必须做对2.1 一定要备份原图不能原地计算这是整道题最大的坑没有之一。模糊处理要求每个内部像素的新值都基于“原图中自己加四个邻居”的旧值来计算。如果图省事直接在 a 数组上原地修改就会出现严重的连锁反应。我举个简单的 3x3 例子1 2 3 4 5 6 7 8 9如果你原地计算先处理中间的 a[1][1]假设按公式算出新值然后把它写回 a[1][1]。接下来处理 a[1][2] 的时候公式里要用到左边邻居 a[1][1] 的旧值可此时 a[1][1] 已经被覆盖成了新值。这个错误还会像病毒一样继续向下、向右扩散越到右下角偏差越大。所以最稳妥的姿势是准备两个数组a 数组只负责“读旧值”b 数组负责“存新值”读和写完全分离。很多同学第一次写这题时偷懒结果调半天最后发现根因就是“自己污染了自己”。可以这样理解你给照片做模糊需要照着原照片的像素信息去算。如果每改一个像素就立刻涂在原照片上之后你参考的其实已经不是原来的颜色了越改越离谱。2.2 边界处理怎么写更省心边界判断有几种常见写法我直接对比一下写法代码结构最大的雷点推荐程度循环里用 if 判断边缘一个双层循环解决内部判断是否边缘条件一多容易写漏可以但不够清晰先整体复制再只改中间区域分复制、更新两段做更新时容易误读 b 数组最推荐四周单独赋值再处理内部要写好几段循环容易重复覆盖边缘值不推荐很多初学者喜欢第一种写起来像这样for (int i 0; i n; i) { for (int j 0; j m; j) { if (i 0 || i n - 1 || j 0 || j m - 1) { b[i][j] a[i][j]; } else { b[i][j] (a[i][j] a[i-1][j] a[i1][j] a[i][j-1] a[i][j1]) / 5; } } }逻辑没错但每次循环都要检查一次边界条件。更好的做法是“先复制再只改内部”先把 a 的所有值原样复制给 b再用循环只处理 i 从 1 到 n-2、j 从 1 到 m-2 的内部区域。这样边界天然保留而且不需要在循环里写一长串 if 条件。顺序上建议先建数组、读入、复制再更新内部最后输出。3. 完整代码与逐段解析3.1 可以直接“抄作业”的 AC 代码下面这版代码使用标准输入输出两个数组分工明确可以直接在 OpenJudge NOI 和一本通 OJ 上通过#include cstdio int a[105][105]; int b[105][105]; int main() { int n, m; scanf(%d%d, n, m); for (int i 0; i n; i) { for (int j 0; j m; j) { scanf(%d, a[i][j]); } } // 第 1 步先把原图完整复制到结果数组 for (int i 0; i n; i) { for (int j 0; j m; j) { b[i][j] a[i][j]; } } // 第 2 步只处理内部像素读取永远用 a for (int i 1; i n - 1; i) { for (int j 1; j m - 1; j) { b[i][j] (a[i][j] a[i-1][j] a[i1][j] a[i][j-1] a[i][j1]) / 5; } } // 第 3 步按矩阵格式输出 for (int i 0; i n; i) { for (int j 0; j m; j) { if (j 0) { printf(%d, b[i][j]); } else { printf( %d, b[i][j]); } } printf(\n); } return 0; }数组开成 105 而不是 100是因为 n 和 m 最大是 100但在 C/C 的二维数组里下标从 0 开始访问 a[100][100] 就已经越界了。多留四五个单位的余量是竞赛选手的常规操作能有效避免一些莫名其妙的运行时错误。3.2 更新公式里为什么只能读 a 不能读 b这个问题被问的次数非常多我都已经把数据复制到 b 了为什么更新 b 的时候右边不能直接用 b答案是b 是一个“正在被修改”的数组。当你算到 b[i][j] 时b 的前面一些格子可能已经被赋了新值。如果你在公式里读 b[i-1][j] 或 b[i][j-1]读到的不一定是原图旧值而是已经处理过的新值计算就全乱了。所以正确的分工是a 永远是“只读数据源”b 永远是“只写结果区”。读值只从 a 取赋值只给 b 添。这不仅是本题的黄金法则也是以后写各种“状态同步更新”算法的通用思路。3.3 时间和空间复杂度这份代码的时间复杂度是 O(n*m)因为两层循环各自完整扫过一遍矩阵。n 和 m 都在 100 以内执行速度可以忽略不计。空间上开了两个 105 x 105 的 int 数组占用大约 88KB也完全没有压力。哪怕以后数据规模放大到 2000 x 2000开两个 int 二维数组大概是 16MB一般也扛得住再大的话可以换成 short 或者滚动数组那就是后话了。4. 常见错误与调试技巧4.1 高频错因速查表这题错误率最高的点并不在算法难度而在一些基础操作上。我把带学生时遇到的典型问题整理成了表格错误类型表现原因解决方式原地更新 a 数组输出结果越到右下越乱新值污染旧值导致后续计算基准错误另开 b 数组读 a 写 b边缘判断写反外圈被改动或中间没处理if 条件忘了处理某一行/某一列建议直接用“先复制再改内部”方案平均值用 double 再转 int个别输出比答案大 1double 除法后强转等于四舍五入行为直接用 int 除法截断数组开太小OJ 报运行时错误下标访问越界开到 105 或 110复制后更新时读 b 不读 a大部分格子对部分格子错用到了已经更新的邻居值更新公式里统一只写 a其中“平均值用 double”这个坑很有迷惑性。有些同学写b[i][j] (int)((a[i][j] a[i-1][j] a[i1][j] a[i][j-1] a[i][j1]) / 5.0);如果分子是 77 / 5.0 是 1.4强转 int 得到 1好像没问题。但如果分子是 88 / 5.0 是 1.6强转 int 还是 1此时结果和整数除法一致。真正的问题是当分子是 4 或者 6 这种小数部分没有超过 0.5 的值时直接截断和四舍五入没有区别可一旦分子是 99 / 5.0 1.8按四舍五入可能会想处理成 2但题目要的是整数部分 1。虽然 C 语言的强转是截断而不是四舍五入但很多同学会在强转之前自己加 0.5那就是画蛇添足。最干净的做法就是直接用 int 除法让截断自然发生。4.2 几个值得反复测的边界数据做这种二维数组题不能只测样例。我建议你准备几组小数据自己心里过一遍再提交全相同的矩阵3 3 5 5 5 5 5 5 5 5 5所有内部点算出来还是 5整个矩阵不变。如果程序把边缘改成其他值一眼就能发现。能验证“向下取整”的数据3 3 1 1 1 1 3 1 1 1 1中心点计算(3 1 1 1 1) / 5 7 / 5 1。如果输出中心是 2说明做成了四舍五入需要修正。单行或单列数据1 3 1 2 3整张图都是边缘应该原样输出 1 2 3。这种数据能测试你的更新循环是否安全跳过内部区域。只有边缘的行列2 4 1 2 3 4 5 6 7 8所有像素都在最外圈没有任何内部点输出必须和输入完全一致。4.3 调试时的一个小技巧如果你拿到 WA先不要急着重写。把 a 和 b 两个数组都打印出来一行一行对照着看一下先确认 a 是否读入正确再确认 b 的边缘是否等于 a 的边缘最后才看内部更新值。很多问题在打印中间结果的那一刻就暴露了。比如你发现 b 的内部某些值特别大那大概率是代码读取了尚未初始化或已被覆盖的数据。这个“打印中间数组”的习惯对以后做更复杂的矩阵题也很有用。5. 从一道入门题看出竞赛里的通用套路5.1 “先备份再更新”模型能用在哪些地方这题做完之后不要急着跳过。你其实已经接触到了一个非常重要的竞赛模型同一矩阵的状态需要同步更新时必须保留旧状态。最经典的同类问题是“生命游戏”Game of Life。每个细胞下一轮的生死取决于上一轮周围八个细胞的状态。如果你把当前细胞的新状态直接覆盖到原数组里再算下一个细胞时它看到的邻居状态就可能“穿越”了整个演化过程就乱套了。解决办法和本题完全一样先把当前状态复制一份或者用两个数组轮流做缓冲。还有扫雷游戏中的“根据地雷位置计算数字矩阵”也是典型的邻居统计问题。它虽然不需要覆盖但同样要求你区分“信息源矩阵”和“计算结果矩阵”。所以当你以后遇到任何“这一轮更新的结果要依赖上一轮整体状态”的题目时第一反应应该是我需不需要开一个副本这个条件反射一旦建立能帮你省下大量调试时间。5.2 做完这题之后还可以继续刷什么如果这题你已经完全掌握了可以考虑往这几个方向延伸读入一个矩阵求四周边缘元素之和或者只输出边缘元素练习边界控制。实现一个 n x m 矩阵绕中心旋转 90 度练习坐标映射和数组副本。做扫雷的地雷数字统计练习八方向邻居遍历。做二维数组的“蛇形填数”练习方向数组和控制范围。这些题跟本题共享同一批基本功坐标偏移、边界判断、循环范围控制、新旧状态区分。把 1128 嚼透后面遇到这些题会顺手很多。5.3 个人经验与最后提醒我带学生做这道题时常说的话是“如果你发现自己必须用到某个已经算过的新值才能算下一个值那你十有八九用错了数据源。”图像模糊处理这道题看起来只是讲二维数组但它真正想让你建立的是对“数据读写分离”的直觉。我自己第一次独立写这题时也栽在“原地更新”上。当时样例怎么都过不去最后把中间结果一行行打出来才发现a[1][1] 已经被我改掉了后面所有计算全在被污染的数据上打转。从那之后凡是遇到“同步更新”类问题我都会第一时间问自己要不要开第二个数组要不要旋转数组要不要保留快照这套思维习惯远比会做这一道题更有价值。最后再分享一个小技巧如果题目未来改成了多轮模糊也就是要连续做 k 次千万不要想着在一张图上就地算 k 遍。正确做法是使用“双缓冲”每轮用 a 算 b下一轮把 b 当 a再算回 a两个数组来回滚。这个思路一旦打通你会发现图像模糊处理不只是入门题它还是很多模拟类题目的基础原型。
返回列表