☰
玛雅人的密码:BFS与字符串交换的机试经典题
2026/10/14 14:57:03 网站建设 项目流程

考研机试里有一类题,看起来冠以文化背景,实际考的是最朴素的搜索和字符串处理。“玛雅人的密码”就是这么一道。我第一次在题库里刷到它时,确实被名字唬住了一下,还以为要先理解一下玛雅历法,结果读完题发现,本质就是让你在若干次相邻字符交换中找到最小步数,让某个目标子串出现。这道题非常适合用来检验你对BFS(广度优先搜索)是否真的理解到位,而不是只会套模板。

如果你正在准备计算机考研机试,或者刷各类算法题库,我建议你认认真真把这道题吃透。它同时覆盖了状态搜索、判重、字符串处理和剪枝几个高频考点,代码量不大,但里面的细节足够让粗心的人翻车。下面我会按照从读题到AC(Accepted)的完整过程来分析,包括踩过的坑、优化思路,以及如果题目数据范围变大,我们应该往哪个方向去想。

1. 先把题目还原清楚:玛雅人的密码到底在问什么

1.1 原题设定与输入输出

机试平台上的题目表述经过多次改版,但核心设定一直没变:有一串由数字0、1、2组成的密码,长度记为n,每次允许你交换相邻两个字符的位置。目标是让这串字符中出现一个连续的子串“2012”。你需要求的是达成目标所需的最少交换次数,如果根本不可能达成,就输出-1。

输入格式一般是两行:

  • 第一行一个整数n,表示字符串长度;
  • 第二行一个长度为n的字符串s,保证只包含字符‘0’、‘1’、‘2’。

输出则是一个整数。这里我把n的范围默认在4到10之间,因为机试中这道题通常设置成这个量级,方便验证搜索类算法。有些变体可能放到12,依然在暴力搜索可接受的范围内。如果题目明确n可以达到100,那就不是单纯BFS的事了,我后面会专门讨论延伸思路。

1.2 几个必须手工算一遍的样例

很多同学看到字符串题第一反应是模拟,但模拟之前一定要先手动推演几个例子,否则很容易把目标理解偏。我从最简单的情况开始:

  • 输入是“2012”,n=4。字符串已经包含连续子串“2012”,不需要做任何交换,答案是0。
  • 输入是“2210”,n=4。目标仍然是让整个字符串变成“2012”或者某个位置的连续子串是“2012”。由于长度恰好等于4,所以必须把整个串调成“2012”。我试过的一条交换路径是:2210先交换位置2和3变成2201,再交换位置1和2变成2021,再交换位置2和3变成2012,一共3步。也有别的走法,比如2210到2120再到2102再到2012,同样是3步。所以答案应该是3。
  • 输入是“00012”这样的字符串,我们统计一下字符:0有三个、1有一个、2只有一个。目标子串“2012”需要两个‘2’、一个‘0’、一个‘1’,而这里‘2’的数量不够,无论怎么交换都不可能构造出来,答案就是-1。

这里有一个很重要的点:字符统计可以直接决定无解的情况。只要0的数量少于1、1的数量少于1、或者2的数量少于2,必然输出-1。这个判断放在程序最前面,等于拦截掉一大部分人认为“还要跑一遍BFS才能确认无解”的无效计算。

2. 从状态图看问题:为什么BFS是这道题的最优解

2.1 一次交换的本质是状态迁移

理解这道题最关键的视角,是把“当前字符串”看成一个状态。一次相邻交换就是从当前状态跳转到另一个合法状态。比如“2210”交换位置0和1,得到“2210”?不对,位置0和1都是2,交换后还是“2210”,所以这个转移是自环,可以跳过。交换位置1和2,得到“2120”,这是新的状态。交换位置2和3,得到“2201”,也是新状态。

于是整道题变成了:在由所有可达字符串组成的状态图中,从初始状态出发,找到最短路径,使得路径上的某个状态包含连续子串“2012”。在边权都是1的情况下,求最短路径的标准算法就是BFS。BFS按层扩展,每一层对应“走了step步”的所有状态,一旦某个状态满足条件,当前层数就是最小步数,这是非常自然的结论。

2.2 DFS与贪心为什么都不合适

可能有同学会想,用DFS行不行?DFS在找“存在一条路径到达目标”时可行,但找“最短路径”就会很吃亏。DFS会一路走到黑,找到一条路径后不能保证它是最短的,必须把所有路径都搜索完再比较,复杂度往往膨胀到无法接受,还容易陷入重复状态的死循环。虽然可以设置深度限制,但边界的选取本身就很麻烦,不如BFS干净。

那贪心呢?比如每次都试图把某个合适的字符挪到目标位置,感觉上可行,但很容易构造反例。相邻交换的代价和字符之间的距离、以及多个字符相互之间的相对顺序耦合在一起,局部最优往往不等于全局最优。举个例子,你觉得先把最左边的‘2’挪到开头是合理的,但也许最优方案是移动另一个‘2’,因为另一个‘2’的移动还能顺带把‘0’和‘1’带到位。这种多个目标字符互相牵扯的问题,用贪心需要证明很多性质,比赛和机试里都不是首选。

2.3 BFS的复杂度估算

既然确定用BFS,就要评估一下状态空间有多大,确保不会超时超内存。

字符串只包含0、1、2三种字符,长度为n时,理论上最多有3的n次方个不同字符串。n=10时,3的10次方是59049;n=12时,3的12次方是531441。每个状态最多有n-1个相邻交换,也就是最多扩展出n-1个新状态。所以最坏情况下需要处理的状态转移数量级大约是“状态数乘以n”,n=12时也就是几百万次操作,普通的C++程序在1秒内完全可以跑完。

更何况实际字符串中每个字符数量是固定的,排列数远小于3的n次方。比如“0002221111”这种,状态数就会明显少很多。所以对于n不超过12的数据范围,BFS是非常稳的。

3. 代码实现:从set判重到三进制状态编码

3.1 基础BFS的完整实现

我直接给出一个最清晰、最适合机试的BFS写法。这里使用队列存储“当前字符串”和“当前步数”,用一个哈希集合记录已经访问过的字符串。

#include <bits/stdc++.h> using namespace std; int main() { int n; string s; cin >> n >> s; // 剪枝:字符数量不够,直接无解 int cnt[3] = {0}; for (char c : s) { cnt[c - '0']++; } if (cnt[0] < 1 || cnt[1] < 1 || cnt[2] < 2) { cout << -1 << endl; return 0; } queue<pair<string, int>> q; unordered_set<string> visited; q.push({s, 0}); visited.insert(s); while (!q.empty()) { string cur = q.front().first; int step = q.front().second; q.pop(); if (cur.find("2012") != string::npos) { cout << step << endl; return 0; } for (int i = 0; i + 1 < n; i++) { string nxt = cur; swap(nxt[i], nxt[i + 1]); if (visited.count(nxt)) { continue; } visited.insert(nxt); q.push({nxt, step + 1}); } } cout << -1 << endl; return 0; }

这个版本的逻辑非常直接:每次从队头取出一个状态,先检查是否已经存在“2012”子串,如果存在就直接返回步数。然后枚举每一个可以交换的相邻位置,交换生成新串,如果没有访问过就入队。

几个容易忽略的小细节:

  • cur.find("2012")返回的是size_t类型,如果找不到会返回一个特殊常量string::npos,所以判断需要用!= string::npos。
  • 使用unordered_set<string>判重,虽然哈希字符串有一些开销,但对n=10到12非常够用。
  • 交换用swap即可,不必自己写三步赋值,代码更简洁。

3.2 三进制编码优化

unordered_set虽然方便,但如果你希望进一步优化,或者想证明自己对状态理解得更深,可以考虑把字符串编码成一个整数。

因为字符只可能是‘0’、‘1’、‘2’,我们可以把它看成三进制数:字符‘0’对应0,字符‘1’对应1,字符‘2’对应2。把每一位乘上对应的3的幂次累加,就得到一个唯一的整数编码。

vector<int> pow3(n + 1); pow3[0] = 1; for (int i = 1; i <= n; i++) { pow3[i] = pow3[i - 1] * 3; } auto encode = [&](const string& str) { int code = 0; for (int i = 0; i < n; i++) { code += (str[i] - '0') * pow3[i]; } return code; };

有了编码函数,判重就可以用一个vector<bool>或者vector<char>,大小是3的n次方。n=12时只需要531441个格子,内存占用极小,访问也变成常数级别,比哈希集合要快出一截。

vector<char> seen(pow3[n], 0); ... int code = encode(nxt); if (seen[code]) continue; seen[code] = 1;

这里有个小陷阱:编码结果必须能唯一映射到字符串。三进制编码是天然唯一的,因为每一位的系数必须是0、1、2,而字符串只包含这三种字符,不会出现歧义。

3.3 提前判无解的字符计数剪枝

我在前面已经提到了字符计数剪枝,这里单独强调,是因为它带来的收益被很多人低估了。

如果不做这个判断,对于确实无解的数据,BFS会遍历全部可达状态之后才会返回-1。比如输入是“1111”,它所有可能的状态虽然不多,但也要白跑一遍。更极端的情况是“001122”这种,如果把字符凑不齐目标子串,BFS照样会完整搜完全部状态。虽然n小的时候影响不明显,但这道题既然考搜索,出题人很可能故意放一组无解数据来卡你时间或者卡你心态。直接在开头用两个if判断掉,既省时间,也避免在无解用例上出现一些莫名其妙的超时。

另外,扩展一下思路:当字符串长度大于4时,只要字符数量满足条件,是否一定可以通过交换构造出“2012”子串?答案是肯定的,因为我们可以把需要的那四个字符通过相邻交换聚拢到某个连续区间内,再调整它们的相对顺序。所以字符计数足够时,有解是确定的,BFS不会空跑。这个结论让我在做题时心里很踏实,也方便写对拍程序验证结果。

4. 最容易出错的三个地方及现场调试方法

4.1 交换操作越界与只交换一半

BFS主循环的交换范围是i从0到n - 2,也就是i + 1 < n。如果你写成了i < n,那么当i = n - 1时,i + 1就越界了,程序可能直接崩溃,或者在无符号下标运算中出现奇怪的字符串内容。这个错误在紧张的状态下很容易犯,我建议先把这个边界条件默认为i + 1 < n,并且提交前重点检查这个循环。

还有一类错误是“只交换一半”,比如用nxt[i] = cur[i + 1],却忘了把nxt[i + 1]赋值为cur[i]。如果你手动写交换赋值,三条语句缺一不可。我的习惯是直接调用swap,从语法上杜绝这种错误。字符串交换也是同理。

4.2 初始状态已经满足条件的处理

还有一个很隐蔽的情况:初始字符串就已经包含“2012”。如果你的程序在把初始状态入队之后,进入循环的第一件事是判断当前状态是否满足条件,那么这个case会正确返回0。但如果你把判断条件放在“扩展完新状态之后”或者先扩展再判断,就有可能漏掉一开始就满足条件的情况,从而错误地向上累加步数。

正确的写法一定是:

while (!q.empty()) { // 先取出状态 // 判断是否满足 // 再决定是否扩展 }

先判断、后扩展,这一步顺序不要反过来。我也见过有同学把初始字符串单独判断一次,然后在循环里再判断,这样做虽然功能上没错,但属于冗余代码,容易让逻辑变乱。

4.3 判重放置的位置与死循环排查

判重代码应该放在生成新状态之后、将新状态入队之前。如果你把它放在交换之前,那相当于没有任何判重作用。如果你忘记判重,BFS会在几个状态之间反复横跳,出现“队列永远清不空”的死循环,表现就是程序运行时间异常久或者内存疯涨。

我自己排查这类问题时,常用一个小技巧:在调试模式下打印步数和当前状态,设定一个最大步数比如10,观察前几层的状态变化。如果看到某个状态在两层中重复出现,就说明判重可能失效。对于这道题,状态重复是很明显的,比如两个相邻字符相同,交换之后字符串根本没有变化,这种新状态和原状态一样,判重会自动跳过,也算是一种微小的优化。

5. 如果n放大到100,这道题该如何变形思考

5.1 邻交换的最小步数与逆序对的关系

题目如果只是把n从10改成100,BFS立刻失效,因为状态空间会爆炸到天文数字。这种时候就要换一种视角:相邻交换的最小次数本质上可以转化为“把某个目标排列变成另一个排列所需的最少相邻交换次数”。

有个经典结论:通过相邻交换把序列A变成序列B的最小次数,等于两个序列对应元素顺序关系的逆序对数量。如果把目标子串“2012”的四个字符在原字符串中的位置找出来,再确定它们要移动到哪个连续区间、以什么顺序排列,就可以用逆序对计数来算最小交换次数。

不过这道题真正难的是目标连续区间的选择。比如n=100,我们要选择一段长度为4的连续区间放下“2012”,可以枚举区间起点,然后计算把原串中选中的四个字符移动到这个区间并排列成“2012”的最小代价。这又涉及到“应该选择原串中哪几个字符来充当2012的四个字符”,因为相同字符可能有多个候选位置。这个方向就变成了动态规划或者贪心加组合计算的问题。

5.2 枚举目标区间的最小交换代价思路

简单地说,如果数据规模变大,我们可以枚举结果中“2012”这四个连续字符最终在字符串中的起始位置p。然后原串中会有四个字符被选中为最终的2、0、1、2。为了让其他字符保持相对稳定,通常是挑选距离p最近且满足字符要求的候选位置,再把这些位置排序,计算移动到p、p+1、p+2、p+3所需的最小相邻交换代价。相邻交换的代价等于目标位置序列的逆序对数量。

具体实现时可以先做字符位置预处理:把原串中所有‘2’、‘0’、‘1’的位置存进若干个列表。枚举p之后,在相应列表里找合适的四个位置,最后计算代价。这个做法已经脱离了考研机试的常规范围,更像竞赛题目,但了解这种“邻交换等价于逆序对”的思考方式,对理解BFS的适用范围非常有帮助。

6. 机试实战:这道题在考场上的定位和拿分技巧

6.1 题目的难度分级与时间分配

“玛雅人的密码”在大多数机试里属于中等偏下的题目,考察点是搜索入门。如果你已经刷过一定量的BFS题,这道题应该在15到20分钟内搞定。我的建议是,读题后先在草稿纸上把样例“2210”的3步路径画一遍,确认自己没有理解偏,再动手写代码。很多人一上来就写,结果样例都没过,反而浪费时间。

从整场机试的战略来说,如果给你2到3个小时做3到4道题,这类搜索题应该排在第二优先完成的位置:先做最简单的签到题,然后立刻做这种中等搜索题,把确定能拿到的分数攥在手里。如果读完题5分钟内没有思路,可以在草稿纸上标记好,先跳到后面的题,但不要直接放弃,因为BFS题一旦想到状态图模型,代码写起来非常快。

6.2 考场上完整检查清单

最后,我分享一个自己屡试不爽的检查顺序,每次写完这类题都按这个顺序过一遍:

  1. 检查输入格式,确认字符串长度和第二行字符串是否对应,避免cin读取顺序错误。
  2. 检查无解剪枝的三组判断:0的数量、1的数量、2的数量。
  3. 检查初始状态是否满足条件,模拟n=4、s=“2012”时输出0。
  4. 检查交换循环边界是i + 1 < n,确保不越界。
  5. 检查判重:生成新状态之后、入队之前。
  6. 用至少两个样例跑一遍,一个是正常有解的多步case,一个是一定无解的字符不足case。
  7. 想一下最坏数据规模:n=12且字符串字符分散时,程序运行时间是否在可接受范围内。

按照这个清单检查,基本能避免大多数低级失误。

我在实际刷题和模拟考试中的体会是,BFS题的代码模板其实很固定,能不能做出来,关键在于能不能快速把题目场景抽象成状态图。“玛雅人的密码”是一个相当标准的练习样本,甚至比那些迷宫问题更能锻炼人对字符串状态的直觉。如果你能把这道题的思路讲给别人听,讲清楚为什么用BFS而不是DFS,为什么判重是必要的,那这道题才算真正吃透了。后面再遇到类似的“最少步数变换”类题目,比如“交换相邻字符得到目标串”或者“滑动拼图”的简化版,你都会觉得顺理成章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询