← 目录 第 7 / 60 节 全部 60 节

07 · 二维数组与网格坐标

把“值当下标”升级成“两个下标”:用二维数组表示网格,用方向数组遍历邻居,用状态机走出蛇形矩阵。重点是那三条铁律——行列别写反、先判边界再访问、方向的顺序决定转向。
⭐ 已学范围入门预计 55 分钟二维数组方向数组蛇形矩阵
本节出现的新函数 / 新语法(先看懂再用)

前面几节的数组只有一个下标,是一条“线”。网格题的数组有两个下标:a[i][j]。下标从 1 个变 2 个,错的种类也翻倍:行列写反、越界没判、方向数组配错,这三类占了入门阶段网格题 WA 的绝大多数。这一节把这三件事逐个钉死。

j = 1j = 2j = 3
i = 1a[1][1]a[1][2]a[1][3]
i = 2a[2][1]a[2][2]a[2][3]
i = 3a[3][1]a[3][2]a[3][3]

第一个下标 i 是行,从上往下增大;第二个下标 j 是列,从左往右增大。数学课上的点 (x, y) 是 x 向右、y 向上,和这里正好差一次转置——题目给你 (x, y) 时,先在纸上改写成 (行, 列),再动键盘。同一份代码里同时出现两种约定,是查三小时也查不出来的那种错。

网格题的铁律只有一条:先判边界,再碰数组。顺序反了,程序读到的东西就不由你决定了。
新函数拆解 · 二维数组 a[i][j]
int a[105][105]; // 105 行 105 列 a[i][j] = x; // 第 i 行第 j 列
组成含义 / 格式要求
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[] / dy[]
const int dx[4] = {0, 1, 0, -1}; // 右 下 左 上 const int dy[4] = {1, 0, -1, 0};
组成含义 / 格式要求
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),否则格子会把自己也算成邻居。

第一个例子选扫雷:它把“访问邻居”和“判边界”这两件事同时用上,而且不用任何别的算法。

例题 · 扫雷游戏洛谷 P2670普及-去洛谷做这题 ↗

给出 n 行 m 列的雷区,* 表示地雷,? 表示待计算的非地雷格。对每个 ? 求出它周围 8 个方向内地雷的个数,* 原样输出。

输入:第一行 n、m(1 ≤ n, m ≤ 100),接下来 n 行每行 m 个字符。输出:n 行,* 保留,? 换成 0~8 的数字。

输入: 3 3 *?? ??? ?*? 输出: *10 221 1*1
扫雷:三步走,别边读边算

第一步,把整张图读进来。不能边读边算:算第 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 行。
网格题最常见的 6 个错,对照症状查

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²) 写法,必挂。

扫雷是“站在一格看周围”。第二类网格题是“沿着一条路一直走”:从某格出发,每步换个方向。典型代表就是蛇形方阵——它看起来是打印题,本质是状态机。

例题 · 蛇形方阵洛谷 P5731入门去洛谷做这题 ↗

给出一个不大于 9 的正整数 n,输出 n×n 的蛇形方阵:从左上角填 1,顺时针方向依次填入 1 到 n²。

输出 n 行,每行 n 个数,每个数占 3 个字符宽度、右对齐、前面补空格。

输入: 4 输出: 1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7
蛇形方阵:把“填数”想成“一个小人在走”

不要想“第几圈、第几条边、这条边填几个”,那个思路的分支条件非常多。换成:有个小人从 (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 判“能不能进下一格”、扫描线判“能不能放下一个矩形”,都是先算候选、再判、最后提交。

蛇形方阵专属的 4 个坑

格式 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²,说明你的行/列范围有重叠或漏掉。

举一反三 · 想清楚这些再往下走
  1. 扫雷里把 8 方向换成 4 方向(只数上下左右),3×3 样例的输出会变成什么?先手算,再改代码验证。
  2. 为什么网格题的数组常见 105、1005 这种“多 5”的尺寸?如果坐标改成从 0 开始存,边界判断能短多少,又会多出哪些坑?
  3. 只允许上下左右走 k 步,能到达的格子构成什么形状?用 |dr| + |dc| 怎么描述?这和 BFS 里的“第几层”是什么关系?
  4. 如果 n = 2000,蛇形方阵还能先存进 a[2005][2005] 再输出吗?算一下内存。哪些题必须边生成边输出?
  5. 蛇形方阵的答案和“方向数组怎么写”有关吗?如果题目要求从中心出发、逆时针向外,代码要改哪几行?
  6. 扫雷的输入如果是“每行一个不含空格的字符串”,读法要怎么改?为什么那时候必须用 string 而不是 char?
自测:能全部回答再进下一节
挑战题 · 【Mc生存】插火把
洛谷 P1789普及-去洛谷做这题 ↗

在 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;最后统一统计,别在标记过程中维护答案,那样每点亮一格都要判断是不是第一次。
这题最容易错的地方
  • 把火把当成 5×5 整块:多照亮 12 格,答案偏小。25 − 13 = 12,看到答案差十几先查这里。
  • 抄偏移表时漏一格或重一格(比如 (-1,1) 和 (1,-1) 抄成同一个),亮区就少一块;抄完数一遍 1 + 4 + 8 = 13。
  • 边界只判了 nx 没判 ny,或者把 nx > n 写成 nx > m——本题 m 是火把个数,写混了编译器不报错。
  • 坐标从 0 开始存进 1-indexed 的数组,整张图错位一行一列,答案完全对不上。
  • 把 lit 定义在 main 里又不初始化:局部数组的值是随机的,答案每次都不一样。定全局,或 memset(lit, 0, sizeof lit)。
延伸阅读 / 参考