求在虚拟键盘上打印目标字符串的最少按键次数,核心在于预处理跳跃并使用 BFS 结合 max_k 高效剪枝,且注意避免空间超限。
OJ: roj
题目 ID: 1452
难度:提高+/省选-
标签:BFS最短路
创建: 2026-10-09 09:43
更新: 2026-10-09 09:43
形式化题目
给定一个
- 方向键(上/下/左/右):向该方向移动,直到遇到第一个与当前位置字符不同的格子并停下;若该方向上直到出界都没有不同的字符,则光标不移动(视为无动作)。
- 选择键(Select):打印光标当前所在的字符。
目标是依次打印给定文本串 * 字符)。求最少的总按键次数。保证一定有解。
数据范围:
正解
思路
因为每种按键代价均为 1,且要求最短操作次数,显然是一个最短路问题,通过 BFS 求解。
状态可设计为:(当前行 r, 当前列 c, 已匹配的字符数 k),代价为经过的步数。
瓶颈在哪里?
- 状态数庞大:整个搜索空间上界为
。 - 空间分配(MLE 隐患):如果在 C++ 中开三维全量静态数组,会占据约 480MB 的内存。题目限制一般在 128MB,会导致必然 MLE。所以需要动态分配内存,例如使用
std::queue保存状态即可,极大降低峰值内存。 - 如果在每次 BFS 转移时动态顺延寻找“下一个不同字符”,开销过大。
优化与正解:
- 预处理跳跃点:先对每个格子向 4 个方向寻找不同字符的位置,提前把结果存在
next[p][dir]中,在 BFS 时就能获知落点。 - 利用
max_k剪枝:记录max_k[i][j],代表走到坐标时匹配到的最长文本长度。若走到某个状态对应的 时,可以直接将其丢弃,这样能避免无用的冗余状态膨胀。结合上述优化,最终将状态队列精简到极小的量级。
代码
#!/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()/**
* 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;
}复杂度
- 时间复杂度:状态总数上界为
,每个状态有 5 条边进行转移。时间复杂度 。 - 空间复杂度:最核心的是
max_k数组与预处理跳跃落点的next数组,占用空间;BFS 的使用按需入队( std::queue),空间复杂度为,峰值常数大幅减小(相较于静态数组占用极小)。
总结
- 算法:BFS over
(位置, 已打印字符数)。状态数;关键预计算 next[p][dir]得到落点位置;终点为。总时间与空间理论复杂度 。 - ★★★ MLE 教训:由于状态数
,绝对不能用静态数组存状态队列。如果写成 State q[55][55][10005],静态段内存直接爆到 480 MB,而题目限额 128 MB,在data10及以后将必然导致 MLE。改为使用标准库std::queue<State>后,静态段骤降至一百多 KB,且真实数据的峰值 RSS 仅为 8.3 MB。 - ★★
*未被转义:题面里看到的字符X\*\*\*...Y是 Markdown 转义的结果,不等于数据里的真实字节。所有.in文件的实测\计数均为 0,完全没有转义字符,所以代码按纯*解析是完全正确的,若尝试兼容\*反而会报错。 - ★★
S不含*:题目数据中全部 19 个点的原字符串内本身全都不包含 *(0 个),“结尾的换行符”无法从原字符串里读取,所以这证明代码中的s += '*'主动拼接动作是必须的。 - ★★
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 步)。
- 起点 (0,0)
- ★★
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 | ✅ | ✅ |
最大 T9 五项:本题采用“上、下、左、右及选择”的五项控制逻辑,类似于 T9 或早期手机/电视遥控器的设计,不涉及浮点格式化及字典序排序。
11. 复杂度:时间与空间均为