题目编号里的 1128 和 1.8 13,一个是《信息学奥赛一本通》里的题号,一个是 OpenJudge NOI 题库里多维数组章节的第 13 题,两个编号指向的是同一道题——图像模糊处理。作为二维数组章节的经典题目,它代码量很小,逻辑也很直白,但带过的学生里每次提交的 WA 率都不低。原因不是题目难,而是它恰好踩中了几个新手最容易犯的毛病:边界判断写不全、四舍五入处理错、以及计算结果直接覆盖原始数组。这篇文章就从这三个角度把题目拆开,把代码从“能过样例”改到“边界也扛得住”,顺带聊聊它背后和真实图像处理的关系。
1. 先读题:谁在变,谁不变
1.1 原题逻辑重述
题面不复杂。输入一个 n 行 m 列的矩阵,每个元素是 0 到 255 之间的整数,表示图像对应像素点的灰度值。处理规则只有两条:
- 最外一圈像素的灰度值保持不变,相当于给图像加了一圈“保护框”。
- 其余中间像素,新灰度等于它本身加上上下左右四个邻居,一共五个数值的平均值,并四舍五入到最接近的整数。
最终输出处理后的 n 行 m 列矩阵。n 和 m 的范围是 1 到 100,时间空间都非常宽裕,哪怕用最朴素的 O(n*m) 算法也能轻松通过。所以这道题的难点从来不是性能,而是正确性。
这里有一个容易被忽略的隐藏信息:如果 n 等于 1 或者 m 等于 1,那整张图根本没有“中间点”,所有像素都属于最外圈,应当原样输出。你拿普通数据测试没什么问题,但代码一旦在这类极限输入上崩溃或者算错,基本就是边界条件没写全导致的。
1.2 题目想考察什么
作为数组章节的代表题,它集中考察四件事:二维数组读入、二维数组遍历、边界位置判断、平均值四舍五入。四件事单独拎出来都不难,凑在一起就很容易翻车。很多同学看到题会一拍大腿说“我会了”,写完一提交却收到 WA,原因就在于他把“数组相邻”理解成了一个抽象下标问题,没有把“像素点边界”“邻居”“平均值”这些概念落到代码细节上。
同时,这道题也是少有的“编码容易、一次 AC 难”的入门题。它非常适合用来训练一个习惯:自己构造测试数据。当你学会针对边界位置、最小规模、极端数值设计测试用例时,你排查 BUG 的效率会肉眼可见地提升。后面第四章就是围绕这个习惯展开的。
1.3 行和列,别搞反
输入第一行是 n 和 m,n 是行数,m 是列数。接下来是 n 行,每行 m 个数。这里见过太多学生把两层循环写反,结果第一轮读入就错位。记忆方法很笨但有效:先出现的 n 对应外层循环,因为输入是一行一行给的;m 是每一行内部的个数,对应内层循环。写代码时模拟一遍输入过程,基本不会错。
2. 核心思路:二维数组加邻域均值
2.1 为什么天然适合用二维数组
图像本身就是二维网格,每个像素和上下左右四个方向联系。你用一维数组也能存,但算邻居下标时要额外做除法取模,既别扭又容易错。直接开一个 a[n+1][m+1] 存原始图,再开一个 b[n+1][m+1] 存结果图,是最符合直觉的方案。
我刻意把行和列都多开一位,让下标从 1 开始计数。这个习惯在竞赛圈很常见,好处是自然的:访问 a[i-1][j] 时,i 最小是 1,即使逻辑上碰到 a[0][j],也不会出现负数下标;数组稍微开大一点,这些访问都落在合法内存区域内,不容易触发奇怪的运行时错误。
2.2 邻域平均值的计算方式
中间点 (i,j) 的邻居是固定的四个方向:上 (i-1,j)、下 (i+1,j)、左 (i,j-1)、右 (i,j+1),再加上自己 (i,j),一共五个数。把它们相加再除以 5,得到的就是新灰度。这个“十字邻域”的结构,是整道题最核心的部分,也解释了为什么这题归在二维数组而不是一维数组章节里。
计算过程里最需要注意的就是四舍五入。在 C/C++ 中,两个 int 相除会直接截断小数部分,比如 12/5 得到 2,而不是 2.4。如果你直接写 sum/5 再存进 int,四舍五入这一步就丢了。正确的做法要么让浮点参与除法,要么用整数技巧手动进位,这两种方案我都会在第三章给出完整写法。
2.3 最核心的坑:计算结果不能覆盖原数组
这一节必须单独拿出来强调,因为它是这道题出现 WA 的头号原因,也是最有迁移价值的经验。
假设你为了省一个数组,直接在 a 数组上原地修改:先算 a[2][2],赋成新值;再算 a[2][3] 时,需要读 a[2][2] 作为左邻居,但这个左邻居已经不是原始灰度,而是被更新过的结果了。一次污染看似影响不大,实际上会像接力赛一样传染下去:a[2][4] 又用到 a[2][3],a[3][2] 又用到 a[2][2],越往后错得越离谱,最后整张图全是错误数据。
我让学生理解这个问题时,常打一个比方:你有一排杯子,每个杯子里是各自的原始果汁。规则是每次取自己杯子和左右两个邻居杯子里的原味果汁各一点,混成一杯新口味,再把新口味倒回自己的杯子。如果从左往右处理,你刚把第一个杯子的果汁换新,做第二个杯子时,取到的“左边邻居”已经是混合过的了,风味早就跑偏。只有先把所有杯子的取样结果存在另一个托盘里,最后再一次性倒回,才能保证每一杯用的都是原始口味。
对应到代码,就是原始图存在 a,计算结果全部写进 b,a 始终保持只读。全部算完后再输出 b。这就是“读原值、写新数组”的原则,后面做动态规划、图论算法时同样用得上。
3. 两种四舍五入写法,代码全解析
3.1 浮点写法:直观,适合第一次理解
先给一个最容易看懂的版本:
#include <iostream> using namespace std; int a[105][105], b[105][105]; int main() { int n, m; cin >> n >> m; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) cin >> a[i][j]; for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (i == 1 || i == n || j == 1 || j == m) { b[i][j] = a[i][j]; } else { double sum = a[i][j] + a[i-1][j] + a[i+1][j] + a[i][j-1] + a[i][j+1]; b[i][j] = (int)(sum / 5.0 + 0.5); } } } for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (j > 1) cout << ' '; cout << b[i][j]; } cout << endl; } return 0; }逐段解释。a 和 b 都开 105×105,是因为 n、m 最大 100,多开一些给边界访问留余地。读入阶段循环从 1 开始,所以输入矩阵第 1 行存在 a[1],第 n 行存在 a[n],非常直观。
计算阶段里,边界判断 if (i == 1 || i == n || j == 1 || j == m) 命中即原样复制。非边界点则先求五数之和,用 double 保存,再除以 5.0 得浮点平均值,加 0.5 后强转 int。因为浮点数加 0.5 再截断,等价于四舍五入:2.4 变 2.9 截断成 2,2.6 变 3.1 截断成 3,2.5 变 3.0 截断成 3。
输出阶段用 if (j > 1) 控制行内空格,这样行尾不会残留多余空格,在任何判题系统下都是安全的。
3.2 整数写法:竞赛更推荐的手动进位
浮点写法在这道题里不会出精度问题,但 OI 圈一直有“能整不浮”的习惯,因为浮点数在极端情况下可能出现 2.500000001 或 2.499999999 这类表示误差。整数写法能完全避开这块风险,代码也更干净:
for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { if (i == 1 || i == n || j == 1 || j == m) { b[i][j] = a[i][j]; } else { int sum = a[i][j] + a[i-1][j] + a[i+1][j] + a[i][j-1] + a[i][j+1]; b[i][j] = (sum + 2) / 5; } } }核心就一行:(sum + 2) / 5。为什么加 2?因为 sum = 5q + r,其中 r 是 0 到 4 的整数,sum/5 的商 q 是整数部分,r/5 是小数部分。四舍五入要看 r/5 是否大于等于 0.5,也就是 r 是否大于等于 2.5。r 是整数,所以 r >= 3 时进位。再加上 2 之后,(sum+2)/5 = q + (r+2)/5,r+2 在 r=3 或 4 时大于等于 5,整数除法正好进位。于是这一个表达式就把四舍五入做完了。
如果不喜欢“加 2”的技巧,也可以写成分支形式:
int q = sum / 5; int r = sum % 5; if (r >= 3) q++; b[i][j] = q;效果完全一样,只是多几行。我建议至少理解其中一种,并且能在考场上稳定默写出来。
3.3 另一种边界处理:先外圈,再内核
如果不喜欢在嵌套循环里写复杂判断,可以把边框复制和内部计算拆成两个循环。第一轮用四个 for 循环把上下左右四条边拷贝进 b;第二轮 i 从 2 到 n-1、j 从 2 到 m-1 只计算内部点。这样做逻辑清晰,而且天然规避 n=1 或 m=1 时内部循环不执行的问题。
对于这道题,两种处理方式都能过,我建议你选自己顺手、不容易写错的那种。我自己教学生时更倾向于“先复制边框再算内核”,因为当 n、m 很小或者很大时,这个结构能减少判断出错的可能。
4. 边界条件与经典错误自查
4.1 必测的几组极端数据
我平时调试这题,会先跑这几组数据再提交:
- 输入 1 1,只有一个像素 5,输出必须是 5;
- 输入 1 5,一行五个像素,全部边界,原样输出;
- 输入 5 1,一列五个像素,全部边界,原样输出;
- 输入 2 2,整张图 4 个点全是外圈,原样输出;
- 输入 3 3,构造一个全 255 的图,内部点平均值显然还是 255,输出全 255;再构造一个灰度值递增的 3×3 图,手算验证中间点。
第一组和第二组专门用来检查 n=1、m=1 这种极限,能暴露访问 a[i+1][j] 越界的问题;最后一组能验证四舍五入是否写对。绝大多数第一次提交就 WA 的程序,至少有一组过不了。
4.2 手算一个小例子
假设 3 行 3 列输入:
3 3 1 2 3 4 5 6 7 8 9四个角 1、3、7、9 是边界,原样输出。四条边上的 2、4、6、8 也是边界,原样输出。只有中心的 5 需要计算,sum = 5 + 2 + 4 + 6 + 8 = 25,平均值 5,四舍五入还是 5。最终输出等于输入。
这个例子的意义不是让你手工算多复杂的数据,而是帮你推演出“哪些点是边界,哪些点是内部”。一旦代码里边界判断写漏,四个角或者某条边就会悄悄出错。
4.3 数组越界和 RE
有些同学喜欢把数组开成 a[100][100],然后循环从 0 开始读到 99,这本身没有错,但一旦想用 a[i-1][j] 时,就需要特别处理 i=0 的情况。更常见的问题是把下标从 1 用到 n,数组却只开到 100,当 i=n 时访问 a[n+1][j],就越界到数组末尾之外的内存,行为未定义,有时候不报错,有时候输出奇怪的数据。
最稳妥的做法是数组在题目上限基础上多开几个单位,写成 a[105][105] 或更大。多开一点没有成本,却能在很大程度上避免边界访问踩雷。这是竞赛中一直强调的“数组多开 5 到 10”习惯的来源。
4.4 输出格式的最后一道坎
OJ 判题通常忽略行尾空格和额外换行,但也存在严格模式。与其赌平台宽容度,不如在输出时用 if (j > 1) cout << ' '; 控制。这样每一行行尾干干净净,全平台通用。如果习惯用 scanf/printf 输入输出,注意格式串对应好 int,别把二维数组的遍历顺序写反。
5. WA 之后怎么排查:常见问题速查表
5.1 一个典型的“看不见的越界”案例
之前带一个学生,他样例测试全过,但提交到 OpenJudge 就是 WA。我让他把 n 改成 100、m 改成 100,构造一组全 0 数据,结果输出到第 50 行左右出现了奇怪的数字。他自己也懵,说逻辑没问题,数据怎么会变。
后来打印中间变量,发现他在循环中判断边界时,把 i == n 写成了 i = n。这看起来是个很低级的错误,但确实会真实发生。赋值表达式把 i 强行改成 n,整个遍历直接乱套,而且编译器不一定报错,运行时也不一定崩溃。这提醒我们:当“逻辑没问题”但结果不对时,先把所有判断条件重读一遍,重点看有没有少写一个等号。
5.2 常见问题速查表
下面这张表覆盖了新手最常见的几类 WA 和 RE,建议收藏备用:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 普通样例能过,极限数据崩溃 | 数组开小或越界 | 数组开 105×105,循环范围 1..n、1..m |
| 结果大面积偏小 1 | 平均值用了整数除法,没四舍五入 | 用 (sum+2)/5 或浮点 +0.5 |
| 结果越到后面越离谱 | 原地更新,新值污染了原始数据 | 结果写进 b,a 保持只读 |
| 边框像素发生变化 | 边界判断漏写了 i==n 或 j==m | 打印 i、j,对照边界条件 |
| 程序报 RE | 在 i=n 时访问了 a[i+1][j] | 内部点判断改成 i>=2 && i<=n-1 |
| 输出全在同一行 | 忘记在每行结束输出换行 | 内层循环结束后 cout << endl |
5.3 一个高效的调试技巧
当你觉得代码哪里不对但找不到时,把 n、m 调成 3 或 4,用一组自己手算过的数据,在代码里加 cout 输出每个内部点的 sum、商、余数,跟手算结果逐项对照。很多时候错误会在第一个内部点就暴露,根本不用看完整个矩阵。
如果收到的是 WA 而不是 RE,不要上来就怀疑 OJ 数据有问题。先把极端数据测试、边界条件、输出格式这三件事检查一遍,这三个环节是藏雷最多的地方。我用这个顺序帮别人排查过很多次,八九不离十。
6. 这题背后:从均值模糊到真实图像处理
6.1 一个更形象的坐标模型
把整张图想象成一张网格纸,每个格子是一个像素。模糊处理的目标是让“突变”的像素被周围像素“中和”,整张图看起来更柔和。题目使用的十字邻域是对“模糊”的一种简化模拟,实际图像处理软件里更常见的是方块邻域,比如取 3×3 九宫格的全部平均值。
九宫格均值是很多边缘检测和降噪算法的基础操作,而这个“取平均值”的过程,在图像处理术语里叫卷积。你以后如果接触 OpenCV,一句 cv::blur(src, dst, cv::Size(3, 3)) 就能完成 3×3 窗口的均值模糊,底层思路和这道题一脉相承,差别只在于邻域形状和边界处理策略。
6.2 真实世界的边界策略
这道题规定外圈像素保持不变,是为了方便竞赛出题而简化出来的规则。真实图像模糊时,边界像素通常也要参与计算,问题是边界以外的“虚拟像素”从哪里来。工程上有几种常见策略:在图像外补一圈 0,复制边缘像素,或者做镜像填充。不同策略会带来不同的边缘效果。
你现在不需要深入掌握这些细节,但可以带着这个认知。未来在真实项目中用图像处理库时,会看到很多函数带 borderType 参数,它就是用来选择边界策略的。理解了这个小点,再看那些参数会容易很多。
6.3 性能优化思路:以后还会遇到
n、m 最大只有 100,逐个点求五邻域平均完全没压力。但如果图像变成 1920×1080 甚至更大,每个点都重复访问邻居,计算量会随邻域大小线性增长。竞赛里后续会学到二维前缀和,预处理一张积分图之后,任意矩形区域的和都能在 O(1) 时间得到,均值模糊也就变成了几次加减法。
这道题正好是引导你去思考“直接计算 vs 预处理加查询”的跳板。很多同学在学前缀和时觉得抽象,如果你能先把均值模糊这个场景记在心里,后面理解“积分图”会顺畅很多。
说实话,这题本身不难,但它像一面放大镜,把二维数组学习里最容易被忽视的几类错误照得清清楚楚。我后来带学弟学妹时,会把“能不能原地更新”当成一个通用问题反复强调,因为它不仅出现在图像模糊处理里,后面学前缀和、学图论算法时同样要判断新状态能否直接覆盖旧状态。最后再分享一个不起眼但很实用的小习惯:碰到矩阵、图像、棋盘这类二维模型,先在草稿纸上画一个 3×3 的小例子,手动把流程走一遍,再开始敲代码。很多人省掉了这一步,结果写完要用几倍的时间去调试。希望这篇内容能帮你在 OJ 上顺利拿到 AC。