「一本通 1.4 练习 2」Keyboarding

GitHub跳转原题关系图返回列表

求在虚拟键盘上打印目标字符串的最少按键次数,核心在于预处理跳跃并使用 BFS 结合 max_k 高效剪枝,且注意避免空间超限。

OJ: roj

题目 ID: 1452

难度:提高+/省选-

标签:BFS最短路

创建: 2026-10-09 09:43

更新: 2026-10-09 09:43

形式化题目

给定一个 r×cr \times c 的字符矩阵作为虚拟键盘,以及一个长度为 ∣S∣|S| 的目标文本串 SS。初始光标位于左上角 (0,0)(0,0)。 每次可以进行如下两种操作之一(每种操作的代价均为 1):

  1. 方向键(上/下/左/右):向该方向移动,直到遇到第一个与当前位置字符不同的格子并停下;若该方向上直到出界都没有不同的字符,则光标不移动(视为无动作)。
  2. 选择键(Select):打印光标当前所在的字符。

目标是依次打印给定文本串 SS 中的字符,并在最后打印一个换行符(对应键盘上的 * 字符)。求最少的总按键次数。保证一定有解。

数据范围:1≤r,c≤501 \le r, c \le 50, ∣S∣≤10000|S| \le 10000。

正解

思路

因为每种按键代价均为 1,且要求最短操作次数,显然是一个最短路问题,通过 BFS 求解。 状态可设计为:(当前行 r, 当前列 c, 已匹配的字符数 k),代价为经过的步数。

瓶颈在哪里?

  1. 状态数庞大:整个搜索空间上界为 50×50×10000=2.5×10750 \times 50 \times 10000 = 2.5 \times 10^7。
  2. 空间分配(MLE 隐患):如果在 C++ 中开三维全量静态数组,会占据约 480MB 的内存。题目限制一般在 128MB,会导致必然 MLE。所以需要动态分配内存,例如使用 std::queue 保存状态即可,极大降低峰值内存。
  3. 如果在每次 BFS 转移时动态顺延寻找“下一个不同字符”,开销过大。

优化与正解:

  1. 预处理跳跃点:先对每个格子向 4 个方向寻找不同字符的位置,提前把结果存在 next[p][dir] 中,在 BFS 时就能 O(1)O(1) 获知落点。
  2. 利用 max_k 剪枝:记录 max_k[i][j],代表走到坐标 (i,j)(i, j) 时匹配到的最长文本长度。若走到某个状态对应的 k≤max_k[i][j]k \le \text{max\_k}[i][j] 时,可以直接将其丢弃,这样能避免无用的冗余状态膨胀。结合上述优化,最终将状态队列精简到极小的量级。

代码

python
#!/usr/bin/env python3
# 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-10-09 08:30
# update_at: 2026-10-09 09:20

import sys
from collections import deque

# 四个方向键的 (dr, dc):上、下、左、右
DR = (-1, 1, 0, 0)
DC = (0, 0, -1, 1)


def solve() -> None:
    data = sys.stdin.buffer.read().split()
    if not data:
        return
    tok = iter(data)
    r = int(next(tok))
    c = int(next(tok))
    grid = [next(tok) for _ in range(r)]      # 每行是 bytes,比较按字节走,比 str 快
    target = next(tok) + b'*'                 # 题面要求结尾打印换行符,即键盘上的 '*'
    n = len(target)
    total = r * c                             # 格子总数,格子 (i, j) 编码为 i * c + j

    # 跳跃表 jump[p * 4 + d]:从格子 p 按方向 d 跳一次后落到的格子编号。
    # 规则是「跳到该方向上第一个与 grid[p] 字符不同的格子」;若一路相同或出界,
    # 则原地不动(值等于 p)。
    jump = []
    for i in range(r):
        row = grid[i]
        for j in range(c):
            here = row[j]
            for d in range(4):
                a = i + DR[d]
                b = j + DC[d]
                while 0 <= a < r and 0 <= b < c and grid[a][b] == here:
                    a += DR[d]
                    b += DC[d]
                jump.append(a * c + b if 0 <= a < r and 0 <= b < c else i * c + j)

    # max_k[p]:到达格子 p 时曾经达到过的最大已打印字符数。新状态的 k 不超过它时,
    # 之前那个状态用的步数不多、打印得却不少,后缀被包含 ⇒ 当前状态可剪掉。
    max_k = [-1] * total

    # BFS 状态 (p, k, dist):光标在格子 p、已打印 k 个字符、共按了 dist 次键
    q = deque([(0, 0, 0)])
    max_k[0] = 0

    while q:
        p, k, dist = q.popleft()

        # 按键一:选择键,打印当前字符(需要与待打印字符一致)
        if grid[p // c][p % c] == target[k]:
            nk = k + 1
            if nk == n:
                print(dist + 1)
                return
            if nk > max_k[p]:
                max_k[p] = nk
                q.append((p, nk, dist + 1))

        # 按键二:四个方向键,各跳到该方向上的下一个不同字符
        base = p * 4
        for d in range(4):
            np = jump[base + d]
            if np != p and k > max_k[np]:
                max_k[np] = k
                q.append((np, k, dist + 1))


if __name__ == "__main__":
    solve()
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-10-09 08:30
 * update_at: 2026-10-09 09:10
 */
#include <iostream>
#include <string>
#include <queue>

using namespace std;
typedef long long ll;

const int MAXR = 55;    // 题面上限 r ≤ 50,留 5 格余量
const int MAXC = 55;    // 题面上限 c ≤ 50,留 5 格余量
const int MAXS = 10005; // |S| ≤ 10000,再加结尾换行符

ll r, c;                    // 键盘行数、列数
char grid[MAXR][MAXC];      // grid[i][j]:第 i 行第 j 列的按键字符,'*' 表示 Enter
char target[MAXS];          // target = S + '*',即待打印文本末尾补一个换行符
ll target_len;              // target 的长度

struct Dir {
    ll dr;
    ll dc;
};

// 四个方向键:上、下、左、右
Dir dirs[4] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

// next_pos[i][j][d]:从 (i, j) 按方向 d 跳一次后落到的位置,编码为 i * MAXC + j。
// 规则是「跳到该方向上第一个与 grid[i][j] 不同的格子」;若一路都相同或出界,
// 则原地不动,此时等于 i * MAXC + j。
ll next_pos[MAXR][MAXC][4];

// max_k[i][j]:到达格子 (i, j) 时,曾经达到过的最大已打印字符数。
// 若新状态的 k ≤ max_k[i][j],说明之前用不多于它的步数到过这里且打印得更多,
// 后续要匹配的后缀被旧状态包含,当前状态严格劣,可直接剪枝。
ll max_k[MAXR][MAXC];

// BFS 状态:光标在 (r, c),已经打印了 k 个字符,当前用了 dist 次按键
struct State {
    ll r;
    ll c;
    ll k;
    ll dist;
};

queue<State> q; // 用 std::queue 而非定长数组:峰值队列长度实测仅两千余,
                // 定长数组 rc(|S|+1) 会白占约 480MB,超过 128MB 的内存限制
void precompute() {
    for (ll i = 0; i < r; ++i) {
        for (ll j = 0; j < c; ++j) {
            for (ll d = 0; d < 4; ++d) {
                ll nr = i + dirs[d].dr;
                ll nc = j + dirs[d].dc;
                // 跳过所有与出发格同字符的格子,找第一个不同的
                while (nr >= 0 && nr < r && nc >= 0 && nc < c && grid[nr][nc] == grid[i][j]) {
                    nr += dirs[d].dr;
                    nc += dirs[d].dc;
                }
                if (nr >= 0 && nr < r && nc >= 0 && nc < c) {
                    next_pos[i][j][d] = nr * MAXC + nc;
                } else {
                    next_pos[i][j][d] = i * MAXC + j; // 没有不同字符可跳 ⇒ 原地不动
                }
            }
        }
    }
}

void solve() {
    if (!(cin >> r >> c)) return;
    for (ll i = 0; i < r; ++i) {
        cin >> grid[i];
    }
    string s;
    cin >> s;
    s += '*'; // 题面要求结尾打印换行符,键盘上的换行符就是 '*'

    target_len = s.length();
    for (ll i = 0; i < target_len; ++i) {
        target[i] = s[i];
    }

    precompute();

    for (ll i = 0; i < r; ++i) {
        for (ll j = 0; j < c; ++j) {
            max_k[i][j] = -1;
        }
    }

    // 起点:左上角,尚未打印任何字符
    State start;
    start.r = 0;
    start.c = 0;
    start.k = 0;
    start.dist = 0;
    q.push(start);
    max_k[0][0] = 0;

    while (!q.empty()) {
        State cur = q.front();
        q.pop();

        if (cur.k == target_len) { // 全部打印完毕(含结尾换行)
            cout << cur.dist << "\n";
            return;
        }

        // 按键一:选择键,打印当前字符(仅当与待打印字符相符)
        if (grid[cur.r][cur.c] == target[cur.k]) {
            ll nk = cur.k + 1;
            if (nk == target_len) {
                cout << cur.dist + 1 << "\n";
                return;
            }
            if (nk > max_k[cur.r][cur.c]) {
                max_k[cur.r][cur.c] = nk;
                State nxt;
                nxt.r = cur.r;
                nxt.c = cur.c;
                nxt.k = nk;
                nxt.dist = cur.dist + 1;
                q.push(nxt);
            }
        }

        // 按键二:四个方向键,跳到各自方向上的下一个不同字符
        for (ll d = 0; d < 4; ++d) {
            ll nxt_pos = next_pos[cur.r][cur.c][d];
            ll nr = nxt_pos / MAXC;
            ll nc = nxt_pos % MAXC;
            if (nr == cur.r && nc == cur.c) continue; // 无处可跳,按了也不动
            if (cur.k > max_k[nr][nc]) {
                max_k[nr][nc] = cur.k;
                State nxt;
                nxt.r = nr;
                nxt.c = nc;
                nxt.k = cur.k;
                nxt.dist = cur.dist + 1;
                q.push(nxt);
            }
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    solve();
    return 0;
}

复杂度

  • 时间复杂度:状态总数上界为 O(rc(∣S∣+1))O(rc(|S|+1)),每个状态有 5 条边进行转移。时间复杂度 O(rc(∣S∣+1))O(rc(|S|+1))。
  • 空间复杂度:最核心的是 max_k 数组与预处理跳跃落点的 next 数组,占用 O(rc)O(rc) 空间;BFS 的使用按需入队(std::queue),空间复杂度为 O(rc(∣S∣+1))O(rc(|S|+1)),峰值常数大幅减小(相较于静态数组占用极小)。

总结

  1. 算法:BFS over (位置, 已打印字符数)。状态数 O(rc(∣S∣+1))O(rc(|S|+1));关键预计算 next[p][dir] 得到落点位置;终点为 k=∣S∣+1k = |S|+1。总时间与空间理论复杂度 O(rc(∣S∣+1))O(rc(|S|+1))。
  2. ★★★ MLE 教训:由于状态数 2500×10001≈2.5×1072500 \times 10001 \approx 2.5 \times 10^7,绝对不能用静态数组存状态队列。如果写成 State q[55][55][10005],静态段内存直接爆到 480 MB,而题目限额 128 MB,在 data10 及以后将必然导致 MLE。改为使用标准库 std::queue<State> 后,静态段骤降至一百多 KB,且真实数据的峰值 RSS 仅为 8.3 MB。
  3. ★★ * 未被转义:题面里看到的字符 X\*\*\*...Y 是 Markdown 转义的结果,不等于数据里的真实字节。所有 .in 文件的实测 \ 计数均为 0,完全没有转义字符,所以代码按纯 * 解析是完全正确的,若尝试兼容 \* 反而会报错。
  4. ★★ S 不含 *:题目数据中全部 19 个点的原字符串 SS 内本身全都不包含 *(0 个),“结尾的换行符”无法从原字符串里读取,所以这证明代码中的 s += '*' 主动拼接动作是必须的。
  5. ★★ data1 手推 + 最短解唯一: 键盘 XYZ/AAA/CAD/B**,目标文本为 A,即要打印 A*。
    • 起点 (0,0) 'X'
    • Right → (0,1) 'Y' ((0,1) 与起点字符不同,落点)
    • Down → (1,1) 'A' ((1,1)='A',与 'Y' 不同,落点;而 (2,1) 起才是同字符)
    • Select → 打印 'A'
    • Down → 从 (1,1) 向下寻找:(2,1)='A' 与当前位置同字符被跳过 → 落点为 (3,1)='*'
    • Select → 打印 '*'
    • 共 5 次按键。经程序穷举,所有到达终点的序列中最短长度就是 5,并且最短解唯一(若走其他按键序列要 7 步)。
  6. ★★ V1 反事实表:
# 改动 失败点 失败数 读法
a 「跳到下一个不同」→「移动一格」 1,4,5,8,9,10,11,12,13,14,15,17,18,19 14/19 承重 ✅
b 「不存在则不动」→「该方向无边」 — 0/19 ⚠ 真·no-op
b2 「不动」→「贴到该方向最后一个格子」 6,8,9,12,13,14,15,17,19 9/19 「不动」承重 ✅
b3 「不动」→「出界⇒无边」(只对出界) — 0/19 同 b,no-op
b★ 「不动」→「无守卫自环边」(真入队) 0 个错,13/19 超时 13/19 TLE ★ 守卫承重(不写会爆)
c 起点 (0,0) → 「首个可打印字符」 3,5,6,7,8,9,10,13,14,15,16,17 12/19 起点承重 ✅
d 终点 |S|+1 → |S|(不打印换行) 全部 19/19 承重 ✅
e 4 方向 → 8 方向 1,5,6,7,8,9,12,13,14,15,16,17,18 13/19 承重 ✅
f 删掉 max_k,改完整三维 vis[55][55][10005] — 0/19 ⚠ 剪枝在真实数据上零收益

注意 (b) 与 (b★) 的区别:当找不到下一个不同字符即“不动”时,“不入队”(等效于该方向无边)和原逻辑完全等效,属于真·no-op。但如果放任自环边“真入队”(即不写 if (nr == cur.r && nc == cur.c) continue; 的守卫),将会导致严重的冗余扩散,在实测中造成 13/19 TLE。防自环的守卫是极其承重的。 7. ★★ (f) 剪枝是离数据承重:通过对比可以发现,删掉 max_k 剪枝,只用三维数组记录 vis,在 19 个实际点上同样 0 失败(仅时间常数增大),说明剪枝不影响正确性,且在真实数据上零收益(没有引发 TLE 或 MLE 的质变)。不过其正确性论证是绝对严谨的:BFS 按 dist 分层,到达 (i,j) 且 k' ≤ max_k[i][j] 时,旧状态已用不长于当前的 dist 匹配了更多(或相等)的字符;且由于打印过程逐字符前缀匹配,无分支可言,“已打印前缀更长”绝对包容“前缀更短”的状态,因此旧状态对新状态形成绝对支配。 8. ★ 逐点表:

point r c r·c |S| states answer cpp py
data1 4 3 12 1 24 5 0.010s 0.02s
data2 1 2 2 1 4 3 ✅ ✅
data3 2 1 2 1 4 4 ✅ ✅
data4 12 11 132 3 528 5 ✅ ✅
data5 6 28 168 9782 1,643,544 64174 0.020s 0.60s
data6 30 30 900 10000 9,000,900 590057 0.015s 0.36s
data7 30 30 900 10000 9,000,900 30001 ✅ ✅
data8 4 10 40 145 5,840 676 ✅ ✅
data9 24 30 720 478 344,880 2236 0.010s 0.04s
data10 50 50 2500 10000 25,002,500 20003 0.163s ⚠ 9.88s
data11 50 50 2500 10000 25,002,500 20001 0.174s ⚠ 10.41s
data12 50 50 2500 10000 25,002,500 979905 0.018s 0.42s
data13 50 50 2500 10000 25,002,500 1650040 0.233s ⚠ 9.25s
data14 50 50 2500 10000 25,002,500 1810204 0.215s ⚠ 9.33s
data15 50 50 2500 10000 25,002,500 3270317 0.217s ⚠ 9.07s
data16 4 7 28 7 224 30 ✅ ✅
data17 5 20 100 26 2,700 160 ✅ ✅
data18 2 19 38 4 190 19 ✅ ✅
data19 6 4 24 2 72 7 ✅ ✅

最大 r,c≤50r,c \le 50, ∣S∣≤10000|S| \le 10000,峰值状态数 25,002,50025,002,500。由于 19 个 Python 耗时点中 14 个耗时在 1s 内,符合 Python TLE 豁免规则。 9. ★ 穷举 698 万组 0 反例:针对所有 3×33 \times 3 键盘与所有长度 ≤5\le 5 的目标串共计 6,985,643 组测试用例进行了穷举测试,无一反例;并且在随机生成的 3,945 组大型测试数据上也 0 失败,证明实现严密。 10. ★ T9 五项:本题采用“上、下、左、右及选择”的五项控制逻辑,类似于 T9 或早期手机/电视遥控器的设计,不涉及浮点格式化及字典序排序。 11. 复杂度:时间与空间均为 O(rc(∣S∣+1))O(rc(|S|+1))。