我第一次碰到这类题,是在一个OJ的入门关卡列表里。题面不长,意思也直白:给两个整数,允许做加一、减一、乘二这三种操作,问从起点变到目标,最少需要几步。当时刚接触C++没多久,第一反应是写递归硬搜,结果要么超时要么答案不对。后来系统啃了一遍STL,回头再看这道所谓的“最少个数”问题,才恍然大悟——核心工具早就摆在面前了,就是queue容器,配合BFS逐层扩展,整道题就是一道标准的STL入门模板题。
这道题适合正在学C++ STL、准备算法面试、或者刚接触广度优先搜索的读者。它不像那些动辄几百行的工程代码那么劝退,却能一口气把queue的核心接口、BFS的层序思路、状态去重、边界裁剪这几个关键点全部串起来。你把这关吃透,后面再遇到“最短步数”“最少次数”“扩散感染”这类问题,套路都是一模一样的。
1. 题目拆解:从“最少个数”到一层一层找答案
1.1 这个例子具体在解决什么问题
我先还原一下题目的完整语义。输入两个整数x和y,每次可以对x执行三种操作之一:把x变成x+1、x-1、x*2。输出从x到达y需要的最少操作次数。注意,这里不是问“能不能到达”,而是问“最少几次”,所以本质上是一个最短路径问题。最短路径问题的关键特征是:每一步的代价相同,都是1次操作,因此先到达目标的路径就是最优路径。
有人可能会问,这和“queue”有什么关系?关系非常大。想象一下,从起点x出发,第一次操作后会有三个新数字,这三个数字就是“第一层”。再对第一层每个数字各做一次操作,得到的就是“第二层”。操作次数每增加1,就相当于向外扩散了一层。这种一层一层向外推进的搜索方式,天然就是先进先出(FIFO)的节奏:先把第一层全部检查完,才会去检查第二层。而queue这个容器,恰恰就是为先进先出而生的。
我用一个具体例子说明。假设x=5,y=17。一种看似合理的贪心思路是“能乘2就乘2”:5乘2得10,10乘2得20,再减三次到17,一共5步。但如果稍微变通一下:5先减1得到4,4乘2得到8,8乘2得到16,16加1得到17,只要4步。这说明贪心在这里会踩坑,因为每一步的局部最优不等于全局最优。而BFS会把所有可能的路径按层数从小到大同步探索,第一层找不到就找第二层,第二层找不到就找第三层,一旦在第4层发现17,立刻就能确定最少步数就是4,没有例外。
1.2 为什么BFS是这道题的正解
要理解BFS为什么是最优解,先要知道别的方案为什么不行。
先看DFS(深度优先搜索)。DFS是一条路走到黑,比如从5出发,一直乘2乘2乘2……很快就跑到几百万去了,然后回头减1,可能折腾很久才碰到目标。就算加了限制条件,DFS找到的第一条路径也不保证步数最少,必须把所有路径都搜完才能确定最优值。在这道题里,状态空间虽然可以用范围裁剪压缩,但DFS的搜索顺序决定了它在“求最少”这个问题上天然吃亏。
再看动态规划。对于这种每个状态有固定步数转移的问题,确实可以用DP做最短路径,比如经典的Bellman-Ford思想迭代更新。但DP需要你主动设计状态转移顺序,还要处理状态之间的依赖关系。而BFS把这种“同步推进”交给了队列来完成,代码写起来更直观,理解成本更低。尤其是在每个操作代价都相同的情况下,BFS是性价比最高的选择。
再看暴力枚举。如果数据范围小,比如x和y都在10以内,那确实可以把所有情况枚举一遍。但题目如果把范围放宽到十万级别,暴力枚举的分支会爆炸。每做一次操作就有3个分支,做20次操作就有3的20次方种情况,这个数字大概有10位数,普通计算机根本扛不住。BFS的优势就在于它按层推进,不会盲目往深处钻,再配合状态去重,能把搜索空间压缩到状态总数级别。
所以说,BFS加queue不是巧合,而是“逐层扫描”这个算法思想和“先进先出”这个数据结构特性之间的天然匹配。理解了这一层,你其实就掌握了所有最短步数类问题的钥匙。
2. queue容器的核心机制与选型内幕
2.1 queue的接口:看起来简单,细节不少
C++ STL里的queue定义在头文件#include 中,是一个典型的容器适配器。它把底层容器包装成了一个严格的FIFO队列,对外只暴露最小但够用的接口。
常用接口可以总结成下面这张表:
| 接口 | 作用 | 注意事项 |
|---|---|---|
| push(x) | 将x加入队尾 | 原名是push,C++11后推荐用emplace提升性能 |
| pop() | 弹出队首元素 | 没有返回值,先取再弹 |
| front() | 返回队首元素的引用 | 需要先判空 |
| back() | 返回队尾元素的引用 | 偶尔会用,BFS里不常用 |
| empty() | 判断队列是否为空 | 循环条件里几乎必用 |
| size() | 返回队列中元素个数 | 也能用来做分层计数 |
有一个非常经典的坑:pop()只负责弹出,不返回被弹出的元素。很多初学者想“取出队首并弹出”,写成了int cur = q.pop();,编译直接报错。正确姿势是先用q.front()拿到元素,再调用q.pop()把队首清掉。这个组合在BFS里几乎每一轮都会出现,写顺手了不难,但第一次接触的人一定要记牢。
另外,queue没有迭代器,也不能随机访问。你没法用下标访问第几个元素,也没法用范围for遍历整个队列。这是刻意设计的结果:既然队列的语义就是“一头进、一头出”,就不应该提供破坏这种语义的操作。理解这一点,比死记“queue有哪些接口”更重要。
2.2 queue的底层实现:为什么是deque而不是vector
queue在底层默认使用deque(双端队列)作为容器,也允许显式指定其他容器,比如queue<int, list >。那么问题来了:为什么默认用deque,而不是更常见的vector?
关键在于queue需要支持两端操作:push从尾部加入,pop从头部移除。vector的强项是尾部的push_back和pop_back,但头部操作就尴尬了。如果你用vector实现队列,每次从头部删除元素都要把后面所有元素往前挪,时间复杂度是O(n)。一旦队列里有十万个元素,每次出队都要搬动十万个元素,整体效率会退化得完全没法看。
deque的设计恰恰解决了这个问题。它内部采用分段连续存储的结构,由一小段一小段连续内存拼接而成,通过一个中控表管理各段地址。push_back和push_front都能在O(1)时间内完成,pop_front和pop_back同样O(1)。虽然deque的随机访问性能略逊于vector,但queue本来就不需要随机访问,这个代价可以忽略。
相比之下,list(双向链表)也支持头尾O(1)操作,理论上也能作为底层容器。但list的每个节点需要额外存储前后指针,内存开销大,而且节点分散在内存各处,访问时缓存不友好。所以在默认场景下,deque是比list更优的选择。STL把deque设为默认底层容器,算是在功能、性能、内存三者之间取了平衡。
2.3 对比:为什么BFS选queue而不是stack或vector
既然queue、stack、vector这三种容器都是STL里常见的序列容器,为什么BFS一定要用queue?我从两个角度分析。
首先,算法要求的顺序不同。BFS要求“逐层推进”,也就是说必须先处理完第k层的所有状态,才能处理第k+1层。queue的FIFO特性天然保证这一点:第k层状态先入队,自然先出队,它们展开出来的第k+1层状态排在队尾,必须等第k层全部处理完才能轮到。如果换成stack的LIFO,那就变成深度优先了,会沿着一条路径先走到头,完全破坏BFS的层序逻辑。vector如果用来模拟队列,头部删除需要O(n)搬移,性能不行。
其次,queue的接口约束也在帮你降低出错概率。stack只能访问栈顶,vector能随便访问任何一个位置,而queue只暴露front和back,这意味着你不太容易手滑写出“随机跳到一个状态”的操作。在BFS这种需要严格按层推进的场景里,这种约束其实是保护。很多工程上的设计哲学都是这样:宁可限制灵活性,也要保证正确性和可维护性。
所以结论很明确:BFS用queue,不是“能用就行”,而是算法与数据结构在逻辑上的必然匹配。你把这个匹配关系想清楚了,以后见到任何“最短步数”问题,第一反应就会是“这题可以用BFS,BFS需要queue”。
3. 完整代码实现与逐段拆解
3.1 数据结构和全局变量的设计
我直接给出这关的完整代码。代码不长,但每一行都值得细看。
#include <iostream> #include <queue> #include <utility> using namespace std; const int MAXN = 200005; bool visited[MAXN]; // 全局数组,自动初始化为 false int main() { int x, y; cin >> x >> y; // 小优化:如果起点已经不小于目标,直接做减法 if (x >= y) { cout << x - y << endl; return 0; } queue<pair<int, int>> q; q.push({x, 0}); // 第一个元素是当前数字,第二个元素是已走步数 visited[x] = true; while (!q.empty()) { int cur = q.front().first; int step = q.front().second; q.pop(); if (cur == y) { cout << step << endl; return 0; } int nextVals[3] = {cur - 1, cur + 1, cur * 2}; for (int nxt : nextVals) { if (nxt < 0 || nxt >= MAXN) continue; // 超范围直接跳过 if (visited[nxt]) continue; // 已经来过就跳过 visited[nxt] = true; q.push({nxt, step + 1}); } } return 0; }先说一个容易被初学者忽略的重要细节:visited数组我定义成了全局变量。原因是局部大数组默认放在栈上,而栈空间通常只有8MB左右。MAXN是200005,bool类型虽然只占1字节,也差不多200KB,看着不大,但如果面试平台栈空间给得小,再加上递归调用或嵌套函数,很容易爆栈。定义成全局变量后,数组放在静态存储区,就完全不用操心这个问题。虽然这道题200KB放栈上其实也可以,但我还是建议养成好习惯:大数组尽量全局或静态。
pair<int,int>的使用也是这关的考点之一。pair就是STL里专门用来打包两个值的工具,这里第一个int存当前数字,第二个int存已经用掉的步数。C++11之后可以直接用花括号初始化,比如q.push({x, 0})。如果是C++98的老环境,就得写q.push(make_pair(x, 0)),这点要注意,面试手写代码时如果环境是C++98,写花括号初始化会编译报错。
3.2 BFS主循环的五步套路
把这个代码提炼一下,BFS主循环其实就是一个固定模板,一共五步:
第一步,取出队首元素。这里要用front取出来,用一个临时变量保存。第二步,弹出队首元素。pop是void,不会把值返回给你,所以必须先取再弹。第三步,判断是否到达目标。如果当前状态就是目标值,直接输出步数并返回。第四步,扩展下一层状态。这道题就是生成三个新数字:cur-1、cur+1、cur*2,存入数组后循环处理。第五步,过滤无效状态并入队。
这里最关键的判断在于去重。visited数组的作用是记录某个数字是否已经被访问过。为什么要去重?举一个直观的例子:从5出发,5加1得到6,6减1又会得到5。如果不加标记,5会反复出现在队列里,程序就死循环了。更麻烦的是,同一个数字可能从多条路径到达,但BFS第一次到达它时用的步数一定是最少的,后续再来只会更长,完全没有意义。所以一旦发现visited[nxt]为true,直接跳过即可。
数组nextVals的存在让代码更简洁。如果你不用数组,就得写三份几乎一样的判断代码,逻辑重复而且更容易出错。把三个候选值放到一个数组里,再用循环统一处理,这个技巧在BFS扩展多个方向时非常实用,迷宫题里上下左右四个方向也是同一种写法。
3.3 两个边界条件和一个通用优化
代码里出现了两处边界条件,值得逐一解释。
第一处是if (x >= y)的提前返回。题目要求每一步可以做加一、减一、乘二。如果x已经大于等于y,再做加一或乘二只会让数字离目标更远或者绕远路,唯一有意义的就是不断减一。比如x=100,y=50,最少步数就是50,直接一次减一步就能得到,没必要启动BFS。这既是一个逻辑上的正确判断,也是一个性能优化。当然,就算不写这个判断,BFS也能找到正确结果,但会多费很多无谓的搜索。
第二处是if (nxt < 0 || nxt >= MAXN) continue。这一行很多人不理解,觉得“为什么不能走到负数和很大的数?”原因在于,状态空间需要被限定在合理的范围内。如果不限制,cur2会不断翻倍,很快就会超过y很多倍,queue里的数字越来越多,内存会爆炸。更重要的是,从数学上可以证明:一旦当前数字已经大于y,再对它乘2只会让数字距离目标更远,回头减下来需要的步数反而更多,所以超过某个界限的状态一定不可能是最优解。MAXN取200005,是因为题目范围通常保证y不超过100000,而x2的最大合理值不会超过200000。这个上限是保守且安全的,既能覆盖所有可能有用的状态,又不会让搜索空间无限膨胀。
提到这个通用优化,其实还可以延伸一下:如果你不想硬编码MAXN,也可以用int limit = max(x, y) * 2 + 5,但要注意x和y本身不能太大,否则limit可能溢出int。实际工程中,更稳妥的做法是提前判断乘2后的值是否超过limit,超过就不入队。这样limit本身可以设小一点,也能有效控制搜索空间。
4. 实战场上栽过的坑与排查清单
4.1 五个高频bug复盘
这道题我见过太多人提交失败,原因五花八门。我把最高频的五类问题整理成一份排查清单,每一条都是真实踩过的坑。
第一个坑:忘记调用pop()。这是新手最容易犯的错误。有人写完while循环后,忘记把队首元素弹出去,结果q.front()永远返回同一个值,程序死循环,界面卡死。有些OJ平台会提示超时(Time Limit Exceeded),有些平台直接内存耗尽。排查方法很简单:检查q.pop()是否在每次处理完状态后都被执行。
第二个坑:visited标记时机错误。常见错误写法是等到弹出元素时才标记visited。表面上看没问题,但仔细想想:如果两个不同状态都生成了同一个数字n,在n还没有被弹出之前,它会被同时加入队列两次。虽然最终结果可能还正确,但队列中会出现大量重复状态,搜索效率大打折扣。正确做法是在元素入队时就立刻标记visited。这个细节在迷宫题、状态搜索题里同样适用,属于BFS的通用规范。
第三个坑:没有状态范围限制。我第一次写的时候就是没加边界判断,结果queue里的元素以指数级增长,程序跑了半天也没停下来。原因是5乘2得10,10乘2得20,很快数字就成千上万了,每一层分支都在膨胀。加上范围裁剪后,同一层最多只有MAXN个不同状态,队列规模完全可控。
第四个坑:局部数组栈溢出。有的人喜欢在main函数里写bool visited[200005],在小数据量时可能侥幸跑过,但一旦数据范围变大,程序会异常崩溃。表现是编译通过,运行到某一步直接返回非零退出码。解决方式就是我前面说的:把数组挪到全局,或者用vector visited(MAXN)动态分配在堆上。
第五个坑:pair初始化方式不兼容。如果你用q.push({x, 0}),但编译环境是C++98标准,编译器会报错。解决办法就是改成q.push(make_pair(x, 0))。面试的时候,如果面试官没有明确说标准版本,用make_pair更稳妥,兼容性最好。
4.2 从这道题透视STL queue的面试考点
这道题表面上是算法题,但很多面试官会顺手追问STL底层原理。我总结一下围绕queue的高频追问方向,你们可以提前准备。
第一个方向是容器适配器。面试官可能会问“queue是容器吗?”答案是否。queue是容器适配器,它底层封装了一个容器,对外提供队列语义。类似地,stack和priority_queue也是适配器。那queue能不能用list代替底层容器?可以,只要list提供了front、push_back、pop_front这些接口就行。deque之所以是默认选择,我在前面已经讲过了。
第二个方向是deque和vector的区别。面试官喜欢问“为什么queue默认不用vector”或者“vector能实现队列吗”。答案是能,但效率差:vector在头部插入和删除需要搬移元素,O(n)复杂度。deque支持头尾O(1)插入删除。再深入一点,面试官可能问deque底层结构,你要能说出“分段连续存储,通过中控表管理”这个层次,就算基本合格了。
第三个方向是BFS的时间复杂度和空间复杂度。这题就是标准的O(MAXN)时间、O(MAXN)空间。visited数组的空间是O(MAXN),queue最多也就是存下一层所有可能状态,也差不多是O(MAXN)。在实际代码里,MAXN一般选择题目给出的数据范围上限,保证了算法可控。
第四个方向是变体题目。面试官把题目改个壳,本质上还是BFS加queue。比如“给定一个字符串,一次只能改一个字符,从起点单词到终点单词最少几次”,这就是单词接龙,核心思路一模一样。再比如“迷宫里从入口到出口最短步数”,把原本的一维数字变成二维坐标,状态换成了两个整数组成的pair,但BFS框架不变。你只要把第1关的模板理解透,这些变体无非是换换状态定义和扩展方式。
5. 写在最后:这关过了,下一步怎么练
第1关叫“最少个数”,实际上就是让你把queue和BFS这套组合拳练熟。我自己的体验是,光看懂代码远远不够,真正靠谱的方法是照着模板自己默写三遍:第一遍看着代码敲,第二遍只看题目敲,第三遍闭着眼睛一边说思路一边敲。三遍下来,queue接口和BFS模板基本就长在脑子里了。
还有一个小技巧:以后做题时,只要看到“最少”“最短”“最快”,并且每一步操作代价相同,就条件反射地想到BFS。如果带权,就要考虑Dijkstra或SPFA;如果状态复杂,就要考虑状态压缩。这些是后话,但起点都在今天这道queue基础题里。等你把queue的push、pop、front、empty、visited去重、边界裁剪这些点都摸透了,后面的路会顺畅很多。