题单 · 习题解析

洛谷 P1451 求细胞数量

★★★ 「和上一道几乎一样」是危险信号;而「省掉一个 vis 数组」把同一个笔误从 WA 升级成了 RE

原题:洛谷 P1451出自 第 13 章 DFS 深度优先搜索:网格连通块 的题单题面本地存档:2026-08-27
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1451,日期见页头。两边不一致时信原站。

题目描述

一矩形阵列由数字 09 组成,数字 19 代表细胞,细胞的定义为沿细胞数字上下左右若还是细胞数字则为同一细胞,求给定矩形阵列的细胞个数。

输入格式

第一行两个整数代表矩阵大小 nm

接下来 n 行,每行一个长度为 m 的只含字符 09 的字符串,代表这个 n × m 的矩阵。

输出格式

一行一个整数代表细胞个数。

说明 / 提示

对于 100% 的数据,保证 1 ≤ n, m ≤ 100

输入输出样例

输入

4 10
0234500067
1034560500
2045600671
0000000089

输出

4

1★★★ 这道题和上一道只差两个旋钮 —— 而那正是危险所在

p1451.cpp★ 这一版就能 AC
// P1451 求细胞数量 —— 能 AC 的那一版
//
// 和上一道 [P1596](/sol/p1596/) 是**同一份代码**,只差**两个旋钮**:
//
// 连通性 什么算「格子」
// P1596 ★ 八连通 字符 == 'W'
// P1451 ★ 四连通 字符 != '0' ← 注意是字符 '0',不是数字 0
//
// 题面原话:「沿细胞数字**上下左右**若还是细胞数字则为同一细胞」——
// · **上下左右** ⇒ 四连通,没有斜角;
// · **还是细胞数字** ⇒ 只要非 0 就行,**不要求是同一个数字**
// (样例里 `234` 和 `1` 是连着的一坨,算一个细胞)。
//
// ⚠ 「和上一道几乎一样」是危险信号,不是安全信号:
// 这一页三个错法里有两个,就是把上面那两个旋钮各拧错了一个。
#include <bits/stdc++.h>
using namespace std;
static int n, m;
static char g[105][105];
static const int DX[4] = {-1, 1, 0, 0};
static const int DY[4] = {0, 0, -1, 1};
static void dfs(int x, int y) {
g[x][y] = '0'; // 走过就抹成 0
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (g[nx][ny] != '0') dfs(nx, ny); // ★ 只要非 '0',不管是不是同一个数字
}
}
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < n; i++) if (scanf("%s", g[i]) != 1) return 0;
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (g[i][j] != '0') { cnt++; dfs(i, j); }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
连通性 什么算「格子」
P1596 ★ 八连通 字符 == 'W'
这道题 四连通 字符 != '0'

题面原话拆开看,两个旋钮都写在里面了:

沿细胞数字上下左右(⇒ 四连通,没有斜角) 若还是细胞数字(⇒ 只要非 0 就行,不要求是同一个数字)则为同一细胞

★★★ 「和上一道几乎一样」是危险信号,不是安全信号

这一页有四个错法,没有一个在算法上 —— 四份代码的 DFS 骨架和正解一模一样。 它们全都是读题手滑

  • 第 ② 步:!= '0' 少写两个引号(★ 它给的不是错答案,是段错误);
  • 第 ③ 步:以为「同一个数字」才算同一细胞;
  • 第 ④ 步:把上一道题的八连通顺手搬了过来。

★ 而三个里有两个,正是上面那张表里的旋钮被拧错了一格。

2★★★ 少两个引号,换来的不是 WA 是 RE

p1451Zero.cpp⚠ != '0' 写成 != 0
// P1451 ⚠ 错法一:`!= '0'` 少写了两个引号,写成 `!= 0`
//
// `g[i][j]` 是 **char**。字符 `'0'` 的 ASCII 是 **48**,而 `0` 是数字零。
// ⇒ `g[i][j] != 0` 对**每一个字符**都成立(输入里根本没有 `\0`)。
//
// ★★★ 而它给出的**不是错误答案,是段错误**(本机实测退出码 139)——
// 因为这份代码没有独立的 `vis` 数组,它靠「把走过的格子抹成 `'0'`」来兼职标记。
// 可 `'0' != 0` 仍然成立 ⇒ **抹了等于没抹** ⇒ 两个相邻格子来回递归,8 MB 栈几毫秒就没了。
//
// ⇒ ★★★ 同一个笔误,同时破坏了**两件事**:「什么算格子」和「怎么标记走过」。
// 而它们本来是两件事 —— 是「省掉一个 vis 数组」这个常见写法把它们绑在了一起。
// ⇒ 对照 p1451ZeroVis.cpp:**同一个笔误、独立 vis,它只是安静地输出 1**(WA,不崩)。
// **「省一个数组」把一个 WA 升级成了 RE。**
#include <bits/stdc++.h>
using namespace std;
static int n, m;
static char g[105][105];
static const int DX[4] = {-1, 1, 0, 0};
static const int DY[4] = {0, 0, -1, 1};
static void dfs(int x, int y) {
g[x][y] = '0';
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (g[nx][ny] != 0) dfs(nx, ny); // ⚠ 就是这里:'0' 写成了 0
}
}
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < n; i++) if (scanf("%s", g[i]) != 1) return 0;
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (g[i][j] != 0) { cnt++; dfs(i, j); }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

g[i][j]char。字符 '0' 的 ASCII 是 48,而 0 是数字零 —— g[i][j] != 0 对输入里的每一个字符都成立。

本机跑样例:退出码 139SIGSEGV),一个字都没输出。

p1451ZeroVis.cpp⚠ 同一个笔误,但用独立 vis
// P1451 ⚠ 错法一的**孪生版**:同一个笔误(`!= '0'` 写成 `!= 0`),但用独立的 vis 数组
//
// 和 p1451Zero.cpp 的差别只有一处:走过的格子记在 `vis[][]` 里,不去抹 `g[][]`。
//
// ⇒ 同一个笔误,结果完全不同:
// · p1451Zero(抹成 '0' 当标记):`'0' != 0` 仍成立 ⇒ 抹了等于没抹 ⇒ **段错误**;
// · 这一版(独立 vis) :标记照常生效 ⇒ 整张网格是一个细胞 ⇒ **安静地输出 1**。
//
// ★★★ 一句话:**「省掉一个 vis 数组」这个常见写法,把一个 WA 升级成了 RE。**
// ⚠ 而 RE 在对拍里还有第二重代价:崩一次要付出进程退出的开销
// (第 5 章 P1042 那条:让崩溃版进对拍表会把对拍拖垮)。
#include <bits/stdc++.h>
using namespace std;
static int n, m;
static char g[105][105];
static bool vis[105][105];
static const int DX[4] = {-1, 1, 0, 0};
static const int DY[4] = {0, 0, -1, 1};
static void dfs(int x, int y) {
vis[x][y] = true; // ★ 标记记在别处,不动 g
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (!vis[nx][ny] && g[nx][ny] != 0) dfs(nx, ny); // ⚠ 同一个笔误
}
}
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < n; i++) if (scanf("%s", g[i]) != 1) return 0;
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (!vis[i][j] && g[i][j] != 0) { cnt++; dfs(i, j); }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 同一个笔误、两份代码,一个崩、一个安静地输出 1

差别只有一处:走过的格子记在哪儿。

怎么标记「走过」 同一个笔误的后果
p1451Zero 把格子抹成 '0'(省掉 vis 数组) '0' != 0 仍成立 ⇒ 抹了等于没抹 ⇒ 两个相邻格子来回递归 ⇒ ★ 段错误
p1451ZeroVis 独立的 vis[][] 标记照常生效 ⇒ 整张网格算一个细胞 ⇒ 安静地输出 1(WA)

⇒ ★★★ 「省掉一个 vis 数组」这个常见写法,把一个 WA 升级成了 RE。 它把两件本来无关的事绑在了一起:「什么算格子」「怎么标记走过」。 一个笔误同时打中两处。

⚠ 顺带一条对拍上的代价:崩溃版进对拍表要付出进程异常退出的开销 (第 5 章 P1042 那条)。好在这道题网格小、栈几毫秒就爆完了。

3⚠ 读题错法一:以为「同一个数字」才算同一细胞

p1451Same.cpp⚠ 要求数字相等
// P1451 ⚠ 错法二:以为「同一个数字」才算同一细胞
//
// 题面写的是「沿细胞数字上下左右**若还是细胞数字**则为同一细胞」——
// 「还是细胞数字」= 只要非 0,**不要求相等**。
//
// 而「1 到 9 代表细胞」这句话很容易被读成「不同的数字是不同的细胞」。
// ⇒ 这一版把条件写成 `g[nx][ny] == g[x][y]`,于是同一坨里数字一变就断开。
//
// ★ 它和错法一、错法三合起来说明一件事:**这道题三个 bug,全都在「读题」上,
// 一个都不在算法上** —— 三份代码的 DFS 骨架一模一样。
#include <bits/stdc++.h>
using namespace std;
static int n, m;
static char g[105][105];
static const int DX[4] = {-1, 1, 0, 0};
static const int DY[4] = {0, 0, -1, 1};
static void dfs(int x, int y, char c) {
g[x][y] = '0';
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (g[nx][ny] == c) dfs(nx, ny, c); // ⚠ 就是这里:要求数字相等
}
}
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < n; i++) if (scanf("%s", g[i]) != 1) return 0;
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (g[i][j] != '0') { cnt++; dfs(i, j, g[i][j]); }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

「数字 19 代表细胞」很容易被读成「不同的数字是不同的细胞」。 样例一眼打死它:题面答案 4,它给 18

★★ 而它有一档抓获率是「精确的 0」—— 而且那一档很容易被造出来

生成器多一个旋钮:非零格子的数字是随机挑的,还是全用同一个?

生成器(n = m = 10 Same 被抓
密度 30%,数字随机 300 / 300
密度 30%,★ 全用同一个数字 0 / 300

⇒ 全用同一个数字时,「相等」和「非零」是同一个条件 —— 这个 bug 结构上不可能现形

⚠ 而「所有细胞都用 1」是非常自然的偷懒写法(很多人写生成器就这么随手来)。 这就是第 12 章 P1226 那条的又一个现场: 「对拍 0 次」要先问是「题面挡死了」还是「生成器缺一档」 —— 这里是后者。

4⚠ 读题错法二:把上一道题的八连通搬了过来

p1451Eight.cpp⚠ 八连通(从 P1596 搬错)
// P1451 ⚠ 错法三:把上一道题的**八连通**顺手搬了过来
//
// [P1596](/sol/p1596/) 是八连通、这道题是四连通 —— 两份代码除了这一处几乎一样,
// 而「刚写完那道题」恰恰是最容易搬错的时候。
//
// ★ 注意它和 P1596 那个错法是**同一个旋钮拧向相反的方向**:
// 那边是「该八连通写成了四连通」,这边是「该四连通写成了八连通」。
// ⇒ 抓获率的曲面形状也跟着反过来(见解析页第 ④ 步那张表)。
#include <bits/stdc++.h>
using namespace std;
static int n, m;
static char g[105][105];
static const int DX[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; // ⚠ 就是这两行
static const int DY[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
static void dfs(int x, int y) {
g[x][y] = '0';
for (int d = 0; d < 8; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (g[nx][ny] != '0') dfs(nx, ny);
}
}
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < n; i++) if (scanf("%s", g[i]) != 1) return 0;
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (g[i][j] != '0') { cnt++; dfs(i, j); }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例:题面答案 4,它给 2

★ 它和 P1596 那个错法是同一个旋钮,拧向相反的方向
  • P1596:该八连通,写成了四连通 ⇒ 答案偏大
  • 这道题:该四连通,写成了八连通 ⇒ 答案偏小

⇒ 两道题连着做的时候,这个旋钮最容易被上一道题的记忆带偏。

5四档 × 300 轮:每个 bug 都有自己怕的那个旋钮

p1451Gen.cpp生成器:密度 + 数字
// 数据生成器(P1451 对拍用):`./p1451Gen <seed> [density] [n] [m]`
//
// density = 每个格子是**细胞**(非 0)的概率百分数,默认 30。
//
// ⚠ 两条和 P1596 不一样的:
// ① 非零格子还要**随机挑 1..9 里的一个数字** ——
// 而「数字挑得散不散」直接决定 `p1451Same`(以为同数字才连通)抓不抓得到:
// 全用同一个数字 ⇒ 那个 bug **精确的 0**;
// ② 这道题是**四连通**,所以「只斜着挨着的一对」在这里是**反过来**起作用的
// (见解析页第 ④ 步)。
//
// level 由第五个参数给:0 = 数字随机(默认),1 = ★ **所有非零格都用同一个数字**。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) {
rng.seed((argc > 1) ? (unsigned)atoi(argv[1]) : 1);
int density = (argc > 2) ? atoi(argv[2]) : 30;
int n = (argc > 3) ? atoi(argv[3]) : ri(1, 12);
int m = (argc > 4) ? atoi(argv[4]) : ri(1, 12);
int level = (argc > 5) ? atoi(argv[5]) : 0;
printf("%d %d\n", n, m);
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (ri(1, 100) > density) putchar('0');
else putchar(level == 1 ? '7' : (char)('0' + ri(1, 9)));
}
putchar('\n');
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

n = m = 10,四档各 300 轮:

生成器 Zero(RE) ZeroVis Same Eight
密度 30%,数字随机 300 300 300 297
密度 30%,★ 全同一个数字 300 300 0 299
密度 70%,数字随机 300 243 300 232
密度 8%,数字随机 300 299 176 154
★★ 四个 bug、三个旋钮 —— 每个 bug 只怕其中一个
  • Same 只怕「数字散不散」:全用同一个数字 ⇒ 精确的 0;
  • EightZeroVis 只怕「密度」:太密(70%)或太稀(8%)都掉到七八成以下;
  • Zero(RE)谁都不怕:它每一轮都崩,300/300 × 4。

一个旋钮只护着一部分 bug。 生成器的档位不是「造得越花越好」, 是每个已知的 bug 都得有一档专门对着它第 7 章 P1638 那条「为一个 bug 精心造的档位正是另一个 bug 的盲区」的正面说法)。

6一张总表

版本 错在哪 样例 最差那一档 结果
p1451Zero != '0' 写成 != 0,且抹格子当标记 RE 300/300 RE
p1451ZeroVis 同一个笔误,独立 vis ✗ 1 243/300 ✗ WA
p1451Same 要求数字相等 ✗ 18 0/300 ✗ WA
p1451Eight 八连通 ✗ 2 ★ 154/300 ✗ WA
p1451 ✓ 4 AC
这一页记住三句话
  1. ★★★ 「和上一道几乎一样」是危险信号,不是安全信号。 这道题和 P1596 只差两个旋钮(4/8 连通、什么算格子), 而四个错法里有两个就是把旋钮拧错了一格 —— 没有一个错在算法上
  2. ★★★ 「省掉一个 vis 数组」把一个 WA 升级成了 RE。 同一个笔误(!= '0' 写成 != 0):抹格子当标记的那版段错误, 用独立 vis 的那版安静地输出 1。 ⇒ 那个写法把「什么算格子」和「怎么标记走过」绑在了一起,一个笔误打中两处。
  3. ★★ 一个旋钮只护着一部分 bug。 Same 只怕「数字散不散」(全同一个数字 ⇒ 精确的 0), Eight / ZeroVis 只怕密度。⇒ 每个已知的 bug 都得有一档专门对着它。