藏宝图

把单词的每次出现看成一条最多拐一次 90° 弯的路径,枚举起点与初始方向,顺着路径逐格匹配计数。

OJ: roj

题目 ID: 20017

难度:普及-

标签:网格枚举搜索

日期: 2026-08-28 22:10

形式化题目

给定 R × C 个大写字母网格(R, C ≤ 100)和由互不相同字母组成的单词 W(长度 ≥ 2),统计 W 在网格中的出现次数。

一次出现 = 一条恰好按顺序覆盖 W 全部字母的格子序列:

  • 直线:所有字母在同一条水平 / 竖直 / 主对角 / 副对角线上,可正向也可反向读取;
  • L 形:先沿一条直线放置前 k 个字母(k ≥ 2),再沿与之垂直的直线放置剩余字母,形成一次 90° 直角拐弯。

同一格子的同一种摆放只计一次。

思路

一句话本质:把单词的每次出现看成一条"最多拐一次 90° 弯"的路径;只要枚举起点和初始方向,路径的剩余形状就完全确定,顺着路径逐格匹配即可计数。

问题? 为什么一次出现可以被当成一条"路径"来看?

直线和 L 形本质上都是同一件事:一个起点、一条初始方向,先沿直线走若干格,最多在某处转一次 90° 弯再走完剩下的字母。直线就是"不转弯"的特例,L 形就是"在某两个字母之间转了弯"。于是问题变成:网格里有多少条格子序列恰好按顺序覆盖 W 的字母。

问题? 要枚举什么才能不重不漏地找到所有出现?

任何一次出现,放 W[0] 的起点格子是唯一的,从起点到第二个字母的方向也一定是 8 个方向之一。所以枚举"起点(内容为 W[0] 的格子)+ 初始方向"就覆盖了所有出现,且不同的起点对应不同的出现,天然不重复。正向 / 反向读取也不用特殊处理:反向读就是从那头的 W[0] 格子开始正向走,同样落在枚举范围内。

问题? 拐弯到底发生在哪一步?

把路径分成两段:第一段沿方向 d1 放 k 个字母,第二段沿垂直方向 d2 放剩余 L-k 个字母。这里有一个官方数据验证过的细节:第一段至少要有 2 个字母,即拐点不能是起点。若允许第一段只有 1 个字母,官方 10 组数据中的 6 组答案都会偏大(例如第 1 组 6 变成 18,第 7 组 20 变成 30),所以"一部分沿一条直线"的隐含意思是这段至少有两个字母。

看一个同时出现两种形状的小例子,单词 ABCDE:

text
  1 2 3 4 5
1 A B C D E
2 X X D X X
3 X X E X X
  • 直线:A(1,1) → B(1,2) → C(1,3) → D(1,4) → E(1,5);
  • L 形:A(1,1) → B(1,2) → C(1,3),在 C 处拐 90° 弯,D(2,3) → E(3,3)。

这张图同时展示两种出现:直线和 L 形共用一个起点,差别只在 C 之后是继续直走还是转弯。注意 L 形的拐点 C 不是起点——从 A 到 B 已经走了一格,第一段有 2 个以上字母后才允许转弯,这正是"第一段至少 2 个字母"的含义。

问题? 最直接的写法是什么?

把摆放方式完整枚举出来:对每个起点、每个初始方向 d1、每个拐点位置 k(k = L 是直线,2 ≤ k ≤ L-1 是 L 形)、每个垂直方向 d2,从头到尾逐字符检查整条路径。这就是下面的朴素解:

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-28 21:28
 * update_at: 2026-08-28 21:28
 */
// brute.cpp:小数据暴力解,枚举所有“摆放方式”并逐字符检查。
// 摆放方式 = (起点, 第一段方向, 拐点位置 k, 第二段方向),
// 直线看成拐点不存在,L 形看成第一段 k 个字母后转 90 度。
// 复杂度较高(O(R*C*8*L*L)),只适合小数据验证与对拍。
#include <bits/stdc++.h>
using namespace std;

const int MAXRC = 105;

string w;                 // 要找的单词 W
int R, C;                 // 网格行数、列数
char g[MAXRC][MAXRC];     // 网格
int ans = 0;

// 8 个方向:0 上,1 右上,2 右,3 右下,4 下,5 左下,6 左,7 左上
int dx[8] = {-1, -1, 0, 1, 1, 1, 0, -1};
int dy[8] = {0, 1, 1, 1, 0, -1, -1, -1};

// 从 (x,y) 出发,第一段沿方向 d1 放 len1 个字母,第二段沿方向 d2 放剩余字母,
// 逐字符检查整条路径上的字母是否恰好等于 w。
bool check_path(int x, int y, int d1, int len1, int d2) {
    int L = (int)w.size();
    // 第一段:第 0..len1-1 个字母
    for (int t = 0; t < len1; t++) {
        int nx = x + dx[d1] * t;
        int ny = y + dy[d1] * t;
        if (nx < 0 || nx >= R || ny < 0 || ny >= C || g[nx][ny] != w[t]) {
            return false;
        }
    }
    // 第二段:拐点在 (x + dx[d1]*(len1-1), y + dy[d1]*(len1-1)),之后沿 d2 走
    for (int t = len1; t < L; t++) {
        int nx = x + dx[d1] * (len1 - 1) + dx[d2] * (t - len1 + 1);
        int ny = y + dy[d1] * (len1 - 1) + dy[d2] * (t - len1 + 1);
        if (nx < 0 || nx >= R || ny < 0 || ny >= C || g[nx][ny] != w[t]) {
            return false;
        }
    }
    return true;
}

void solve() {
    int L = (int)w.size();
    for (int i = 0; i < R; i++) {
        for (int j = 0; j < C; j++) {
            if (g[i][j] != w[0]) {
                continue;
            }
            for (int d1 = 0; d1 < 8; d1++) {
                // 直线:整条路径沿 d1,相当于第一段长度为 L、没有第二段
                if (check_path(i, j, d1, L, d1)) {
                    ans++;
                }
                // L 形:第一段 k 个字母(2 <= k <= L-1),然后向两个垂直方向拐
                for (int k = 2; k <= L - 1; k++) {
                    for (int d2 = 2; d2 <= 6; d2 += 4) {   // 两个垂直方向
                        int nd2 = (d1 + d2) % 8;
                        if (check_path(i, j, d1, k, nd2)) {
                            ans++;
                        }
                    }
                }
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> w >> R >> C;
    for (int i = 0; i < R; i++) {
        for (int j = 0; j < C; j++) {
            cin >> g[i][j];
        }
    }

    solve();
    cout << ans << '\n';
    return 0;
}

问题? 朴素解慢在哪里?

对同一个 (起点, 方向),换一个拐点 k 就要把前面 k 个字母重新检查一遍:k=2 检查前 2 个,k=3 又从头检查前 3 个……同一段前缀被反复比对,单个起点要花 O(L²) 次字符比较。本题数据小(R,C ≤ 100)能跑,但重复是多余的。

问题? 怎样消除重复检查?

边走边匹配:从起点沿一个方向逐格推进,每走一步只检查当前这一个格子;"转弯"作为至多一次的选择并入游走过程,用 DFS 记录状态 (已匹配字母数, 当前位置, 当前方向, 是否已转弯)。这样每个前缀只被检查一次。

问题? DFS 为什么不会重复计数?

一次出现就是一条确定的格子序列;DFS 从起点出发生成的游走记录恰好是这条序列本身,一条序列只会被生成一次。更形式化地说:格子序列唯一决定起点(放 W[0] 的格子)、第一段方向(第 1→2 格的方向)、拐点位置(方向第一次改变处)和第二段方向,所以"每格每方向游走一次"不重不漏。turned 标记保证整条路径至多拐一次弯。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-28 21:28
 * update_at: 2026-08-28 21:28
 */
// main.cpp:B. Treasure(藏宝图)最终解。
// 从每个格子出发,沿 8 个方向搜索单词;用一个标记记录是否已经转过一次 90 度弯。
// 每个格子作为起点、每个方向作为初始方向,天然保证同一种摆放只被计数一次。
#include <bits/stdc++.h>
using namespace std;

const int MAXRC = 105;

string w;                 // 要找的单词 W(由不同大写字母组成)
int R, C;                 // 网格行数、列数
char g[MAXRC][MAXRC];     // 网格
int ans = 0;

// 8 个方向:0 上,1 右上,2 右,3 右下,4 下,5 左下,6 左,7 左上
// 在这个编号下,hd + 2 和 hd + 6(对 8 取模)恰好是两个垂直方向
int dx[8] = {-1, -1, 0, 1, 1, 1, 0, -1};
int dy[8] = {0, 1, 1, 1, 0, -1, -1, -1};

// 已经匹配了 w[0..pos-1],当前停在 (x,y),前进方向是 hd,turned 表示是否转过弯。
// 下一步尝试把 w[pos] 放到下一个格子里:要么继续直走,要么(在还没转过弯时)转 90 度。
void dfs(int pos, int x, int y, int hd, bool turned) {
    if (pos == (int)w.size()) {   // 整条路径的字母全部匹配成功
        ans++;
        return;
    }

    // 不转弯:继续沿 hd 方向走到下一个格子
    int nx = x + dx[hd];
    int ny = y + dy[hd];
    if (nx >= 0 && nx < R && ny >= 0 && ny < C && g[nx][ny] == w[pos]) {
        dfs(pos + 1, nx, ny, hd, turned);
    }

    // 转弯:90 度直角拐弯只能发生一次,且第一段至少要有 2 个字母
    //(pos >= 2 表示当前格子是 w[pos-1],即拐点不在起点上)
    if (pos >= 2 && !turned) {
        for (int d = 2; d <= 6; d += 4) {       // 左转 d=6,右转 d=2,两个垂直方向
            int hd2 = (hd + d) % 8;
            int tx = x + dx[hd2];
            int ty = y + dy[hd2];
            if (tx >= 0 && tx < R && ty >= 0 && ty < C && g[tx][ty] == w[pos]) {
                dfs(pos + 1, tx, ty, hd2, true);
            }
        }
    }
}

void solve() {
    for (int i = 0; i < R; i++) {
        for (int j = 0; j < C; j++) {
            if (g[i][j] != w[0]) {
                continue;
            }
            // 从起点 (i,j) 分别向 8 个方向开始匹配
            for (int hd = 0; hd < 8; hd++) {
                dfs(1, i, j, hd, false);
            }
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> w >> R >> C;
    for (int i = 0; i < R; i++) {
        for (int j = 0; j < C; j++) {
            cin >> g[i][j];
        }
    }

    solve();
    cout << ans << '\n';
    return 0;
}

复杂度

  • main.cpp(DFS 游走):每个格子 × 8 个初始方向,每条路径深度 ≤ L,每步至多 3 个分支。时间复杂度 O(R·C·8·L),R=C=100、L=26 时约 2×10^6 次操作。
  • brute.cpp(模板枚举):O(R·C·8·L²),只适合小数据验证与对拍。
  • 空间复杂度均为 O(R·C)(网格 + 递归栈 O(L))。

总结

本题的考点不是优化,而是把"单词出现"正确地建模成"至多拐一次 90° 弯的路径"并干净地实现。两个关键点:一是枚举起点 + 初始方向后路径形状完全确定,天然去重;二是第一段至少 2 个字母的隐含规则(官方数据验证)。main.cpp 用带 turned 标记的 DFS 沿路径游走,比逐个模板检查的 brute.cpp 少做大量重复前缀比对,也正好对应官方"从每个格子向 8 个方向搜索、记录有没有转弯"的写法。