前面几节的数组只有一个下标,是一条“线”。网格题的数组有两个下标:a[i][j]。下标从 1 个变 2 个,错的种类也翻倍:行列写反、越界没判、方向数组配错,这三类占了入门阶段网格题 WA 的绝大多数。这一节把这三件事逐个钉死。
| j = 1 | j = 2 | j = 3 | |
|---|---|---|---|
| i = 1 | a[1][1] | a[1][2] | a[1][3] |
| i = 2 | a[2][1] | a[2][2] | a[2][3] |
| i = 3 | a[3][1] | a[3][2] | a[3][3] |
第一个下标 i 是行,从上往下增大;第二个下标 j 是列,从左往右增大。数学课上的点 (x, y) 是 x 向右、y 向上,和这里正好差一次转置——题目给你 (x, y) 时,先在纸上改写成 (行, 列),再动键盘。同一份代码里同时出现两种约定,是查三小时也查不出来的那种错。
| 组成 | 含义 / 格式要求 |
|---|---|
int a[N][M]; | 定义。第一个方括号管行(纵向),第二个管列(横向)。N、M 必须是编译期常量,不能是运行时读进来的变量。 |
a[i][j] | 访问第 i 行第 j 列。行从上往下编号增大,列从左往右编号增大。 |
有效范围 | 0 ≤ i < N 且 0 ≤ j < M。C++ 不做越界检查:a[N][0] 会安静地写到数组外面的内存里。 |
行优先存储 | 内存里是 a[0][0] a[0][1] … a[0][M-1] a[1][0] … 一整条连续空间。所以按行遍历(外层 i、内层 j)比反过来快,大数组上能差好几倍。 |
全局 vs 局部 | 全局数组在静态区,默认全 0,不占函数栈;函数里的数组占栈。a[1000][1000] 是 4 MB,直接栈溢出 RE。 |
清零 | memset(a, 0, sizeof a) 按字节填,只对 0 和 -1 成立。要填别的值就老老实实双重循环。 |
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int a[N][N]; // 全局:自动全 0,也不占栈
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]; // 第 i 行第 j 列
a[1][2] = 7; // 第 1 行第 2 列,不是第 2 行第 1 列
cout << a[1][2] << "\n"; // 7
cout << a[2][1] << "\n"; // 这是输入读进来的第 2 行第 1 列,和上面的 7 毫无关系
return 0;
}不要写 int a[n][m](用运行时变量开数组):GCC 当扩展支持,标准 C++ 不允许,而且没法定义成全局数组。
a[i][j] 与 a[j][i] 是两个不同的格子。网格题一半的 WA 就来自把这两个写反。
| 组成 | 含义 / 格式要求 |
|---|---|
dx[d] | 第 d 个方向的行增量:nr = r + dx[d]。 |
dy[d] | 第 d 个方向的列增量:nc = c + dy[d]。两个数组长度必须相同、下标一一对应。 |
4 方向 | 顺时针写成 (0,1)(1,0)(0,-1)(-1,0),好处是 (d+1)%4 恰好等于“向右转 90°”。 |
8 方向 | {-1,-1,0,1,1,1,0,-1} 配 {0,1,1,1,0,-1,-1,-1},从上开始顺时针绕一圈,不重不漏。 |
标准用法 | for (d = 0; d < 8; ++d) { 算 ni、nj;先判边界;再访问 a[ni][nj]; } |
为什么用数组 | 把 8 段 if 压成一次循环;而且 d 可以当“朝向”存进变量,转向就是一句取模。 |
const int dx[4] = {0, 1, 0, -1}; // 右 下 左 上
const int dy[4] = {1, 0, -1, 0};
// 站在 (r,c),看四个邻居分别是哪一格
for (int d = 0; d < 4; ++d) {
int nr = r + dx[d];
int nc = c + dy[d];
if (nr < 1 || nr > n || nc < 1 || nc > m) continue; // 先判边界
// 到这里 (nr,nc) 一定在棋盘内,可以放心访问
}dx 与 dy 写反(把行增量塞进 dy)不会编译报错,只会让整张图静默地转 90°,极难查。
用 -1..1 双重循环凑 8 方向时,必须跳过 (0,0),否则格子会把自己也算成邻居。
第一个例子选扫雷:它把“访问邻居”和“判边界”这两件事同时用上,而且不用任何别的算法。
给出 n 行 m 列的雷区,* 表示地雷,? 表示待计算的非地雷格。对每个 ? 求出它周围 8 个方向内地雷的个数,* 原样输出。
输入:第一行 n、m(1 ≤ n, m ≤ 100),接下来 n 行每行 m 个字符。输出:n 行,* 保留,? 换成 0~8 的数字。
第一步,把整张图读进来。不能边读边算:算第 1 行的格子时,第 2 行的字符还没进内存。
第二步,坐标统一用 1..n、1..m。这样“越界”只剩两种:某个下标小于 1,或者大于 n。判断短,也不容易漏写。
第三步,对每个格子:自己是雷就输出 * 并跳过;否则用方向数组把 8 个邻居过一遍,数里面有几个雷。
复杂度:n·m·8 次比较,n、m ≤ 100 时约 8×10⁴ 次,随便过。
#include <bits/stdc++.h>
using namespace std;
char g[105][105]; // 全局:自动清零,也不吃函数栈
// 8 个方向:上、右上、右、右下、下、左下、左、左上(顺时针一圈)
const int dx[8] = {-1, -1, 0, 1, 1, 1, 0, -1};
const int dy[8] = { 0, 1, 1, 1, 0, -1, -1, -1};
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
cin >> g[i][j]; // char 版 cin 只取一个非空白字符,换行被自动跳过
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (g[i][j] == '*') { // 雷格原样输出,不用数
cout << '*';
continue;
}
int cnt = 0;
for (int d = 0; d < 8; ++d) {
int ni = i + dx[d], nj = j + dy[d];
// 顺序不能反:边界不合法时,g[ni][nj] 已经是数组外的内存
if (ni < 1 || ni > n || nj < 1 || nj > m) continue;
if (g[ni][nj] == '*') ++cnt;
}
cout << cnt; // 输出 0~8,不加空格、不加分隔符
}
cout << '\n'; // 一整行输出完才换行
}
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
char g[105][105]; | 数组开到 105 而不是 100,是给“手滑写到 n+1”留一层缓冲;char 每个 1 字节,总共约 11 KB。 |
const int dx[8] = {...}; | 第 d 个方向的行增量。dx、dy 必须同序配对:dx[0]=-1 配 dy[0]=0 才是正上方。 |
cin >> g[i][j]; | g 是 char,cin 读一个非空白字符,行末换行被跳过,所以不需要 getline,也不用手动吸回车。 |
if (g[i][j] == '*') { ... continue; } | 雷格先处理掉,后面数邻居的循环就只管非雷格,少一层缩进、少一个条件。 |
int ni = i + dx[d], nj = j + dy[d]; | 把“方向”翻译成“候选坐标”。内外层循环加起来就是沿着 (i,j) 绕一圈。 |
if (ni < 1 || ni > n || nj < 1 || nj > m) continue; | 四个条件一个都不能少。少判 nj 上界,就会读到下一行第一格,答案看起来“只差一点”,最难查。 |
if (g[ni][nj] == '*') ++cnt; | 累加器写在最内层,每换一个格子必须重置为 0——这就是 int cnt = 0 放在 j 循环里的原因。 |
cout << cnt; | 输出的是一位数字字符。题目不要求空格分隔,加了就是格式错误。 |
cout << '\n'; | 换行写在内层循环外面,才能形成 n 行 m 列,而不是 n·m 行。 |
WA,答案全错但每格只差一点点:边界判断写成 ni < 0 || ni >= n,却又用 1-indexed 存图,整体错位一格。用 1-indexed 就只判 < 1 和 > n,别混着来。
WA,个别格子数值随机:先访问 g[ni][nj] 再判越界。越界读到的是相邻数组的残留数据,本地能过、提交就错。
WA,输出全 0 或全 1:8 方向里忘了跳过自己。用双重循环 -1..1 时一定加 if (dx == 0 && dy == 0) continue;。
RE(运行错误):把 105×105 的 char 改成 int 再挪进 main——还好;但若开到 1000×1000 的 int 放局部,4 MB 直接爆栈。网格数组一律定义在 main 外面。
WA 在最后一列:行列写反。g[j][i] 编译完全合法,只是你读的是转置后的图。调试时先把 (i,j) 打出来看。
TLE(超时):数据一放大(比如 n = m = 3000)而不写 ios::sync_with_stdio(false); cin.tie(nullptr);,光读入就可能超时;再叠加一句“对每个格子重新扫一遍全图”的 O(n²m²) 写法,必挂。
扫雷是“站在一格看周围”。第二类网格题是“沿着一条路一直走”:从某格出发,每步换个方向。典型代表就是蛇形方阵——它看起来是打印题,本质是状态机。
给出一个不大于 9 的正整数 n,输出 n×n 的蛇形方阵:从左上角填 1,顺时针方向依次填入 1 到 n²。
输出 n 行,每行 n 个数,每个数占 3 个字符宽度、右对齐、前面补空格。
不要想“第几圈、第几条边、这条边填几个”,那个思路的分支条件非常多。换成:有个小人从 (1,1) 出发,手里拿着 1、2、3……每走到一格就写下当前数字。
小人只会两个动作:直走和右转。规则一句话:下一步如果出界、或者那格已经写过,就右转;否则直走。
于是状态只有三个:当前位置 (r,c)、朝向 dir、当前要写的数 k。dir 取 0..3 表示右、下、左、上,右转就是 dir = (dir + 1) % 4。
注意 a[nr][nc] != 0 这一项:因为全局数组初始全 0,而写进去的数都 ≥ 1,所以“非 0”就是“写过”,省掉一个 vis 数组。
// 站在 (r,c)、朝向 dir,问下一步去哪
int nr = r + dx[dir], nc = c + dy[dir];
if (nr < 1 || nr > n || nc < 1 || nc > n || a[nr][nc] != 0) {
dir = (dir + 1) % 4; // 撞墙或撞到旧数字:右转
nr = r + dx[dir], nc = c + dy[dir]; // 转向后用新方向再算一次
}为什么这样一定对:每一步都保证 r、c 在界内且 a[r][c] == 0,n² 步恰好把 n² 个格子各写一次。把这句不变量记住,写错了也能自己推回来。
#include <bits/stdc++.h>
using namespace std;
int a[15][15]; // 全局:全 0,用 0 表示“还没写过”
int main() {
int n;
cin >> n;
const int dx[4] = {0, 1, 0, -1}; // 右 下 左 上
const int dy[4] = {1, 0, -1, 0};
int r = 1, c = 1, dir = 0; // 从左上角出发,朝右
for (int k = 1; k <= n * n; ++k) {
a[r][c] = k; // 每走一步就写一个数
if (k == n * n) break; // 最后一格写完后不再预判,免得算出界外坐标
int nr = r + dx[dir], nc = c + dy[dir];
// 出界 or 撞到自己写过的数字,就顺时针转 90° 再试一次
if (nr < 1 || nr > n || nc < 1 || nc > n || a[nr][nc] != 0) {
dir = (dir + 1) % 4;
nr = r + dx[dir], nc = c + dy[dir];
}
r = nr; c = nc; // 只有合法才真正前进
}
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) printf("%3d", a[i][j]); // 题目要求 3 字符宽
printf("\n"); // 一行结束才换行
}
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
int a[15][15]; | n ≤ 9,开到 15 留余量。全局数组全 0,这个 0 有两种作用:占位的“空”和判断“写过”的标志。 |
int r = 1, c = 1, dir = 0; | 起点在左上角,初始朝右。用 1-indexed,出界判断就只剩两种形式。 |
const int dx[4] = {0, 1, 0, -1}; | 顺序是右、下、左、上,加 1 就是顺时针右转。若把顺序改成右、上、左、下,就变成逆时针螺旋——输出会是另一个矩阵。 |
a[r][c] = k; | 先写再走,保证每格写且只写一次;写完的数非 0,后面的 a[nr][nc] != 0 才有意义。 |
if (k == n * n) break; | 最后一格写满就收工。少了这句,程序会继续算出界外的 nr、nc 并赋给 r、c,虽然循环马上结束不会写坏内存,但“变量已经越界却没人管”是不该留的习惯。 |
int nr = r + dx[dir], nc = c + dy[dir]; | 只算候选坐标,不动 r、c。这是整段代码的关键:候选和目标分开存,判断不通过时原状态一点没被污染。 |
|| a[nr][nc] != 0 | 第二个条件不能漏。只判出界的话,小人会沿着已经写过的路径一直撞,n² 步填不满 n² 个格子。 |
dir = (dir + 1) % 4; | 取模让 3 的下一个回到 0,省掉 if 分支,也保证 dir 永远是合法下标。 |
r = nr; c = nc; | 所有判断都通过后才前进,所以 r、c 永远停在“合法且空白”的格子上。 |
printf("%3d", a[i][j]); | 3 表示最小场宽 3,默认右对齐、左边补空格。这是本题的格式要求,用 cout 会 WA。 |
先走再退:r += dx[dir]; c += dy[dir]; 发现不合法再退回来、转向、再走一次。同一句“走”出现两次,改一处漏一处。
预判:先算 nr、nc,不合法只改 dir,合法才赋值。全程 r、c 都在合法格子上,出了错只需要打印 (r, c, dir)。
同一个套路后面还会用到:网格 DFS/BFS 判“能不能进下一格”、扫描线判“能不能放下一个矩形”,都是先算候选、再判、最后提交。
格式 WA:用 cout << a[i][j] << " "。题目要求每数占 3 字符宽,必须 printf("%3d", ...)。
给“最后一格”写特判:填满的判据只有 k == n * n。别去猜终点在哪个坐标——n 的奇偶不同,终点位置也不同,猜错就会提前跳出,矩阵里留一片 0。
只判出界、忘了判重:小人写完一圈会掉头冲进已经写过的格子,把 1、2、3 覆盖掉,同时留下几格 0。看到“外圈数字被改、内圈是 0”,就是漏了 a[nr][nc] != 0。
数组开成 a[9][9]:n 正好 9 时下标 9 越界,写坏内存。网格数组的每一维都留余量,这是肌肉记忆。
关掉页面,从空文件开始重写扫雷(P2670),只允许看题目描述,20 行以内写完并提交。
改一改:把扫雷的 8 方向缩成 4 方向(只数上下左右),先猜 3×3 样例会变成什么样,再改代码验证。
再改一改:把蛇形方阵的 dx/dy 顺序改成右、上、左、下,用 n = 4 手推一次再看输出——你会亲眼看到“方向的顺序就是转向的方向”。
① 二维 ↔ 一维。m 列的格子,0 起下标的 (r, c) 对应一维编号 id = r*m + c;反过来 r = id / m,c = id % m(整数除法)。一维数组之所以能当二维用,就是因为这个双射。后面 BFS 的队列里只存一个 int,靠的就是它。
② 顺时针 90° 的坐标变换。方向 (dr, dc) 顺时针转 90° 变成 (dc, −dr)。验证:(0,1) 向右 → (1,0) 向下;(1,0) 向下 → (0,−1) 向左。蛇形矩阵的 dir = (dir+1) % 4 只是这条公式的查表版;没有表的时候,用公式现推。
③ 同一句“照 2 格”,两种距离两种形状。|dr| + |dc| ≤ 2 是菱形,含 13 格;max(|dr|, |dc|) ≤ 2 是正方形,含 25 格。写范围类题目第一件事就是判断题目说的是哪种距离。
| 距离名 | 公式 | 偏移 2 内的格数 | 形状 |
|---|---|---|---|
| 曼哈顿距离 | |dr| + |dc| | 13 | 菱形 |
| 切比雪夫距离 | max(|dr|, |dc|) | 25 | 正方形 |
④ 螺旋的层号。n×n 里 0 起的格子 (r, c) 落在第 k = min(r, c, n−1−r, n−1−c) 层。第 k 层边长为 n−2k,格子数 = 4(n−2k−1)(边长 1 时取 1)。全部加起来恰好 n²——这是自检公式:算出的各层之和不是 n²,说明你的行/列范围有重叠或漏掉。
a[i][j] 里 i、j 各是哪一维。a[nr][nc] != 0 这个条件漏了会怎样。cout << a[i][j] 的区别。在 n×n 的方阵上放了 m 个火把和 k 个萤石。火把照亮自己所在的格子,以及和它曼哈顿距离不超过 2 的所有格子(形状是上下左右各延伸 2 格的菱形,共 13 格);萤石照亮以自己为中心的 5×5 正方形(共 25 格)。
超出方阵的部分忽略。没有被任何光源照到的格子会生成怪物,求这样的格子数。
输入格式:第一行三个整数 n、m、k(1 ≤ n ≤ 100,0 ≤ m, k ≤ 100);接下来 m 行每行两个整数,是火把的坐标;再接下来 k 行每行两个整数,是萤石的坐标。坐标从 1 开始,保证在方阵内。
输出格式:一行一个整数,表示会生成怪物的格子数。
先把“照亮”变成一张布尔表:被照到就是 true。最后统一数 false 的个数,比边照边数省事得多。
别急着写两套代码:先写出“以 (x,y) 为中心,遍历 5×5 范围并标记”的循环,再想火把要在这个循环上加什么条件。
把火把的 5×5 画出来,标出哪 13 格是亮的,用两个偏移量之间的关系描述这个形状(提示:不是“两个都小”,而是“加起来小”)。
开 bool lit[105][105],全局定义自动全 false,表示还没有光。
火把的 13 个格子可以直接列成两个常量数组——这就是“方向数组”,只不过这张表有 13 项而不是 4 项(马的 8 种走法也是同一招)。
萤石是完整的 5×5,不需要表:两重循环 dx、dy 各从 -2 到 2,正好生成 25 个偏移。
每个偏移都要先判 x + dx 是否落在 1..n 内——越界那部分“照到方阵外面”,直接丢掉。
最后双重循环数 lit 为 false 的格子数。复杂度 (m+k)×25 + n²,n ≤ 100 时约 1.5×10⁴。
#include <bits/stdc++.h>
using namespace std;
bool lit[105][105]; // lit[i][j] = 这一格有没有光;全局自动全 false
// 火把照亮的 13 个偏移:曼哈顿距离 <= 2,也就是 (0,0) + 4 个距离 1 + 8 个距离 2
const int tx[13] = { 0, -1, 1, 0, 0, -1, 1, -1, 1, -2, 2, 0, 0};
const int ty[13] = { 0, 0, 0, -1, 1, -1, -1, 1, 1, 0, 0, -2, 2};
int main() {
int n, m, k;
cin >> n >> m >> k;
for (int t = 0; t < m; ++t) {
int x, y;
cin >> x >> y;
for (int d = 0; d < 13; ++d) {
int nx = x + tx[d], ny = y + ty[d];
if (nx < 1 || nx > n || ny < 1 || ny > n) continue; // 照到方阵外面的部分不算
lit[nx][ny] = true;
}
}
for (int t = 0; t < k; ++t) {
int x, y;
cin >> x >> y;
// 萤石是完整的 5×5:两重循环直接生成 25 个偏移,不需要表
for (int dx = -2; dx <= 2; ++dx)
for (int dy = -2; dy <= 2; ++dy) {
int nx = x + dx, ny = y + dy;
if (nx < 1 || nx > n || ny < 1 || ny > n) continue;
lit[nx][ny] = true;
}
}
int ans = 0;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
if (!lit[i][j]) ++ans; // 一次都没被照到
cout << ans << '\n';
return 0;
}| 片段 | 作用 |
|---|---|
bool lit[105][105]; | 只关心“有光/没光”,用 bool 而不是 int,占 1 字节,也把意图写在类型上。 |
const int tx[13] = {...}; | 把 13 个偏移抄成表,和方向数组一个思路,只是这张表更长。抄错一个偏移,答案就少亮或多亮一格,所以抄完要数一下:1 + 4 + 8 = 13。 |
for (int d = 0; d < 13; ++d) | 遍历表里每一个偏移。d 只是下标,没有任何几何含义,别把它当方向编号。 |
int nx = x + tx[d], ny = y + ty[d]; | 候选坐标:先算出来,判完再用。 |
if (nx < 1 || nx > n || ny < 1 || ny > n) continue; | 方阵只有 1..n,越界部分等于照到墙外。四个条件都要写,少一个就会写到别的行上去。 |
lit[nx][ny] = true; | 重复点亮同一个格子无所谓,true 是幂等的,所以不用先判断“是不是已经亮过”。 |
for (int dx = -2; dx <= 2; ++dx) | 萤石不需要表:两重循环各 5 个取值,生成的正好是 25 个偏移。 |
if (!lit[i][j]) ++ans; | 最后统一统计,别在标记过程中维护答案,那样每点亮一格都要判断是不是第一次。 |