我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

玛雅人的密码:BFS与字符串交换的机试经典题

玛雅人的密码:BFS与字符串交换的机试经典题 考研机试里有一类题看起来冠以文化背景实际考的是最朴素的搜索和字符串处理。“玛雅人的密码”就是这么一道。我第一次在题库里刷到它时确实被名字唬住了一下还以为要先理解一下玛雅历法结果读完题发现本质就是让你在若干次相邻字符交换中找到最小步数让某个目标子串出现。这道题非常适合用来检验你对BFS广度优先搜索是否真的理解到位而不是只会套模板。如果你正在准备计算机考研机试或者刷各类算法题库我建议你认认真真把这道题吃透。它同时覆盖了状态搜索、判重、字符串处理和剪枝几个高频考点代码量不大但里面的细节足够让粗心的人翻车。下面我会按照从读题到ACAccepted的完整过程来分析包括踩过的坑、优化思路以及如果题目数据范围变大我们应该往哪个方向去想。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”n4。字符串已经包含连续子串“2012”不需要做任何交换答案是0。输入是“2210”n4。目标仍然是让整个字符串变成“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次方个不同字符串。n10时3的10次方是59049n12时3的12次方是531441。每个状态最多有n-1个相邻交换也就是最多扩展出n-1个新状态。所以最坏情况下需要处理的状态转移数量级大约是“状态数乘以n”n12时也就是几百万次操作普通的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; } queuepairstring, int q; unordered_setstring 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_setstring判重虽然哈希字符串有一些开销但对n10到12非常够用。交换用swap即可不必自己写三步赋值代码更简洁。3.2 三进制编码优化unordered_set虽然方便但如果你希望进一步优化或者想证明自己对状态理解得更深可以考虑把字符串编码成一个整数。因为字符只可能是‘0’、‘1’、‘2’我们可以把它看成三进制数字符‘0’对应0字符‘1’对应1字符‘2’对应2。把每一位乘上对应的3的幂次累加就得到一个唯一的整数编码。vectorint 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; };有了编码函数判重就可以用一个vectorbool或者vectorchar大小是3的n次方。n12时只需要531441个格子内存占用极小访问也变成常数级别比哈希集合要快出一截。vectorchar 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改成100BFS立刻失效因为状态空间会爆炸到天文数字。这种时候就要换一种视角相邻交换的最小次数本质上可以转化为“把某个目标排列变成另一个排列所需的最少相邻交换次数”。有个经典结论通过相邻交换把序列A变成序列B的最小次数等于两个序列对应元素顺序关系的逆序对数量。如果把目标子串“2012”的四个字符在原字符串中的位置找出来再确定它们要移动到哪个连续区间、以什么顺序排列就可以用逆序对计数来算最小交换次数。不过这道题真正难的是目标连续区间的选择。比如n100我们要选择一段长度为4的连续区间放下“2012”可以枚举区间起点然后计算把原串中选中的四个字符移动到这个区间并排列成“2012”的最小代价。这又涉及到“应该选择原串中哪几个字符来充当2012的四个字符”因为相同字符可能有多个候选位置。这个方向就变成了动态规划或者贪心加组合计算的问题。5.2 枚举目标区间的最小交换代价思路简单地说如果数据规模变大我们可以枚举结果中“2012”这四个连续字符最终在字符串中的起始位置p。然后原串中会有四个字符被选中为最终的2、0、1、2。为了让其他字符保持相对稳定通常是挑选距离p最近且满足字符要求的候选位置再把这些位置排序计算移动到p、p1、p2、p3所需的最小相邻交换代价。相邻交换的代价等于目标位置序列的逆序对数量。具体实现时可以先做字符位置预处理把原串中所有‘2’、‘0’、‘1’的位置存进若干个列表。枚举p之后在相应列表里找合适的四个位置最后计算代价。这个做法已经脱离了考研机试的常规范围更像竞赛题目但了解这种“邻交换等价于逆序对”的思考方式对理解BFS的适用范围非常有帮助。6. 机试实战这道题在考场上的定位和拿分技巧6.1 题目的难度分级与时间分配“玛雅人的密码”在大多数机试里属于中等偏下的题目考察点是搜索入门。如果你已经刷过一定量的BFS题这道题应该在15到20分钟内搞定。我的建议是读题后先在草稿纸上把样例“2210”的3步路径画一遍确认自己没有理解偏再动手写代码。很多人一上来就写结果样例都没过反而浪费时间。从整场机试的战略来说如果给你2到3个小时做3到4道题这类搜索题应该排在第二优先完成的位置先做最简单的签到题然后立刻做这种中等搜索题把确定能拿到的分数攥在手里。如果读完题5分钟内没有思路可以在草稿纸上标记好先跳到后面的题但不要直接放弃因为BFS题一旦想到状态图模型代码写起来非常快。6.2 考场上完整检查清单最后我分享一个自己屡试不爽的检查顺序每次写完这类题都按这个顺序过一遍检查输入格式确认字符串长度和第二行字符串是否对应避免cin读取顺序错误。检查无解剪枝的三组判断0的数量、1的数量、2的数量。检查初始状态是否满足条件模拟n4、s“2012”时输出0。检查交换循环边界是i 1 n确保不越界。检查判重生成新状态之后、入队之前。用至少两个样例跑一遍一个是正常有解的多步case一个是一定无解的字符不足case。想一下最坏数据规模n12且字符串字符分散时程序运行时间是否在可接受范围内。按照这个清单检查基本能避免大多数低级失误。我在实际刷题和模拟考试中的体会是BFS题的代码模板其实很固定能不能做出来关键在于能不能快速把题目场景抽象成状态图。“玛雅人的密码”是一个相当标准的练习样本甚至比那些迷宫问题更能锻炼人对字符串状态的直觉。如果你能把这道题的思路讲给别人听讲清楚为什么用BFS而不是DFS为什么判重是必要的那这道题才算真正吃透了。后面再遇到类似的“最少步数变换”类题目比如“交换相邻字符得到目标串”或者“滑动拼图”的简化版你都会觉得顺理成章。
返回列表