☰
从“奇怪的电梯”看BFS最短路径与数组下标从1开始的艺术
2026/9/29 16:43:42 网站建设 项目流程

今天在刷洛谷 P1135「奇怪的电梯」的时候,忽然想通了一个以前一直没在意的细节:为什么那么多题解里,数组都要开到 N+1,数据老老实实从下标 1 开始存。以前总觉得这就是个个人习惯,无伤大雅,直到这次把样例从一组扩到多组,亲手被边界问题坑了几次,才真正体会到这个小习惯有多省事。

这篇文章我就把这道题从头到尾拆一遍:题目到底在说什么、为什么它本质上是一个最短路问题、BFS 怎么写、以及“从下标 1 开始存数据”到底妙在哪里。新手可以直接照着代码敲,老手也可以看看自己在边界处理上有没有踩过同款坑。

1. 题目拆解:奇怪的电梯到底在问什么

1.1 题意与输入输出格式

先还原一下题目。假设一栋大楼有 N 层,你一开始在第 A 层,目标是去第 B 层。每一层楼的电梯按钮旁边都写着一个数字 Ki,表示这一层的电梯按钮只能让你做两件事之一:

  • 向上走 Ki 层;
  • 向下走 Ki 层。

但有一个限制:无论向上还是向下,目标层必须落在 1 到 N 之间,否则这个按钮按了也没用。问从 A 层到 B 层最少需要按几次按钮,如果永远到不了,输出 -1。

输入格式很直观,第一行是三个正整数 N、A、B,第二行是 N 个整数 Ki,依次表示第 1 层到第 N 层的按钮数字。注意这里题目自己就说了“第 i 层楼上的数字”,天然就是 1-based 的编号。

标准样例长这样:

5 1 5 3 3 1 2 5

输出是 3。怎么来的?1 楼按钮是 3,向上到 4 楼;4 楼按钮是 2,向下到 2 楼;2 楼按钮是 3,向上到 5 楼。路径就是 1 → 4 → 2 → 5,正好按下 3 次按钮。

1.2 为什么叫“奇怪的电梯”

这题的“奇怪”在于:每一层楼上的 Ki 只决定你能跳多远,但你并不知道往哪个方向跳更优。你站在第 i 层,能看到的信息只有“向上 i+Ki”“向下 i-Ki”这两个候选,但哪一个能更快接近目标,完全看不出来。

这其实和你平时坐电梯的直觉完全不同。真实电梯里你想去 5 楼,你会去按一个写着 5 的按钮;但在这道题里,你按下按钮之后去哪一层,完全由“出发楼层”决定,不由你的意志决定。所以整个搜索过程更像是在走迷宫:电梯的每一次移动都受到当前楼层数字的约束,而不是受你目的地的约束。

更严谨一点说,这栋楼可以被抽象成一张有向图:

  • 楼层 1 到 N 是这张图的 N 个节点;
  • 从节点 i 出发,如果 i+Ki 在 1~N 范围内,就有一条边指向节点 i+Ki;
  • 如果 i-Ki 在范围内,也有一条边指向节点 i-Ki;
  • 每条边的权重都是 1,代表“按一次按钮”。

于是原问题就变成了一个非常经典的问题:在一张无权有向图上,求从节点 A 到节点 B 的最短路径长度。到这里,“奇怪的电梯”就不再奇怪了,它就是一个披着生活场景外衣的 BFS 板子题。

1.3 题目考察的核心能力

这道题放在算法学习里,想考察的并不是什么高深的技巧,主要是三件事:

第一,能不能识别出“最少操作次数 + 每次操作代价相同”的组合,并联想到底层结构是图。只要想到图,BFS 几乎是自动浮出来的方案。

第二,能不能处理好边界条件。向上和向下都会越界,越界的分支直接剪掉。这个判断只要漏掉一个“<= N”或者“>= 1”,程序就可能在运行时出现诡异行为。

第三,能不能把楼层编号和数组下标正确对应。这也是我整篇文章最想强调的地方。楼层编号从 1 开始,那么在数组里就应该让下标 1 对应第 1 层,而不是强行从 0 开始给自己添堵。

所以这道题表面上是搜索题,实际上也是“数据结构基本功”的试金石。

2. 数组从下标 1 开始存储的妙处

2.1 为什么坚持用 k[1] 表示第 1 层

很多编程语言里数组天然从 0 开始,所以不少人在读入 Ki 的时候习惯这样写:

for (int i = 0; i < n; i++) { cin >> k[i]; // k[0] 存的是第 1 层 }

诚实地讲,这种写法不是不行。你完全可以维护一个“下标 i 对应楼层 i+1”的映射,搜索时把楼层号减一再用。真正的问题在于:当代码变长、样例变多以后,这种手工偏移很容易出错。出错的方式还非常隐蔽。

举个例子,队列里弹出来的 cur 是楼层号 4,你想访问 4 楼的按钮数字。如果按从 0 存储的习惯,你得写 k[cur - 1],而不是 k[cur]。写的时候精神高度集中可能没问题,但一旦你在某个分支里直接写了 k[cur],程序不会立刻报错,而是取到了错误的按钮数字,搜索结果就会随机性地对、随机性地错。这种 bug 在单组样例下可能侥幸通过,一旦样例增加,立刻原形毕露。

反过来,从下标 1 开始存储,k[cur] 就是第 cur 层的按钮,k[1] 就是第 1 层的按钮。楼层号就是下标,下标就是楼层号,中间没有任何转换层。搜索代码里你甚至不需要想一想“这里要不要减一”,直接把队列里弹出的节点当作数组下标用就行。

2.2 两种写法的对比

我把两种写法的核心代码摆在一起,你们感受一下差别。

从 1 存储的写法:

// 数组开大一位 int k[MAXN]; for (int i = 1; i <= n; i++) { cin >> k[i]; // 第 i 层楼,存到 k[i] } int cur = q.front(); q.pop(); int up = cur + k[cur]; // 直接读,不需要偏移 int down = cur - k[cur];

从 0 存储的写法:

int k[MAXN]; for (int i = 0; i < n; i++) { cin >> k[i]; // k[i] 对应第 i+1 层楼 } int cur = q.front(); q.pop(); int realFloor = cur + 1; // 先把“下标”转回“楼层” int up = realFloor + k[cur]; int down = realFloor - k[cur];

表面上看,第二种也就多写了一行,损失不大。但在实际比赛或者刷题环境下,这些多余的转换会持续消耗你的注意力,而且在边界判断时特别容易心态失衡。比如判断 up 是否越界时,你用的是 realFloor + k[cur] 跟 N 比,还是直接用 cur + k[cur] 跟 N 比?如果这里稍一混乱,写成了 cur + k[cur] <= N,但队列里存的是下标而不是楼层,那边界判断就是错的。

从 1 存储的核心思想是让“数据表达”和“业务语义”保持一致。楼层这个概念天然从 1 开始数,那就别硬把它掰成 0 开始。很多有经验的竞赛选手在开数组时,都会习惯性地多开几个位置,目的就是为了让下标直接对应题目的编号体系。这不是洁癖,这是降低认知负担的实用技巧。

2.3 样例增加后更能体现这种妙处

标题里专门提到“样例增加”,这不是随便写的。我自己实测下来,数据从 0 存储的代码,在做单组样例时,往往一遍就对了,因为人脑在短代码里可以手动完成偏移校正。但当你开始连续跑十几组样例,尤其是目标楼层在边界附近、每组数据 N 还不同的时候,从 0 存储的代码就会开始出现各种“间歇性”错误。

典型的症状是:这组样例输出是对的,下一组样例突然多了一个 1,再下一组又对了。排查半天发现,原来是有一段代码在某些分支里用了 k[cur],另一些分支里用了 k[cur-1],而 cur 恰好落在不同区间时,错误不会触发。这种 bug 用调试器都难抓,因为它的错误依赖具体数据。

从 1 存储的代码就没有这种困扰。队列里存的是楼层号,数组下标直接就是楼层号,所有分支用的都是同一个 k[cur]。同一个变量,同一个语义,不管样例怎么增加,都不会出现“同一份代码里混用两套下标体系”的情况。

我给一个非常简单的建议:只要题目里出现了“第 1 到第 N”这种编号,数组就开 N+1,下标范围 1~N,下标 0 永远空着不用。你就当这个位置不存在,只把 1 到 N 号位置当作有效空间。这个习惯一旦养成,能帮你规避掉一大类低级 bug。

3. BFS 解法:求最短路径的经典套路

3.1 为什么这里首选 BFS 而不是 DFS

很多人拿到这道题,第一反应是写 DFS,因为递归搜索看起来直观。但 DFS 有一个致命缺陷:它找到的第一条路径不一定是步数最少的路径。你可能会想,那就把所有路径都搜一遍,取最小值好了。这当然可以,但在最坏情况下,这种暴力搜索会反复访问大量节点,指数级膨胀,甚至栈溢出。

BFS 不一样。BFS 的特点是逐层扩展,先访问距离起点为 1 的所有节点,再访问距离为 2 的所有节点。当边权全部为 1 时,BFS 第一次“碰到”目标节点时的层数,就一定是最少步数。这在图论里是教科书级别的结论:无权图上的最短路径,BFS 就是最优解。

从图上理解也很简单:BFS 的队列天然维护了“距离从小到大的访问顺序”,你不需要记录任何路径长度做比较,第一次到达即最优。这比 DFS 加回溯干净利索得多。

3.2 从 1 存储的 BFS 完整代码

这里我给一份 C++ 版本,风格偏竞赛,注释写得比较细。

#include <bits/stdc++.h> using namespace std; const int MAXN = 205; int n, a, b; int k[MAXN]; // k[i] 表示第 i 层的按钮数字,下标从 1 开始 bool vis[MAXN]; // 访问标记,防止同一层被重复入队 int main() { cin >> n >> a >> b; for (int i = 1; i <= n; i++) { cin >> k[i]; // 第 1 层存到 k[1],第 n 层存到 k[n] } // 起点等于终点,直接返回 0,不需要按任何按钮 if (a == b) { cout << 0 << endl; return 0; } queue<int> q; q.push(a); vis[a] = true; // step 数组记录“从起点到当前层按了多少次按钮” vector<int> step(n + 1, 0); while (!q.empty()) { int cur = q.front(); q.pop(); int up = cur + k[cur]; // 向上走 k[cur] 层 int down = cur - k[cur]; // 向下走 k[cur] 层 // 向上走:目标层必须在 1~n 范围内 if (up <= n && !vis[up]) { vis[up] = true; step[up] = step[cur] + 1; if (up == b) { cout << step[up] << endl; return 0; } q.push(up); } // 向下走:同理,不能小于 1 if (down >= 1 && !vis[down]) { vis[down] = true; step[down] = step[cur] + 1; if (down == b) { cout << step[down] << endl; return 0; } q.push(down); } } // 队列都弹完了还没到目标,说明不可达 cout << -1 << endl; return 0; }

这套代码的核心逻辑其实只有三句话:取出队首节点,计算两个候选楼层,合法且未访问就入队并更新步数。因为使用了 vis 数组,每个楼层最多入队一次,复杂度是 O(N) 级别,对 200 层这种数据规模来说毫无压力。

3.3 Python 版本的实现

如果你用 Python 刷题,逻辑完全一样,只是队列换成了 collections.deque。顺手把 step 数组初始化为 -1,这样既能记录步数,又能充当访问标记,省掉一个布尔数组。

import sys from collections import deque def solve(): data = sys.stdin.read().strip().split() if not data: return n, a, b = map(int, data[:3]) # 关键:k[0] 占位不用,k[1] 对应第 1 层 k = [0] + list(map(int, data[3:3 + n])) if a == b: print(0) return # step[i] = -1 表示从未访问过;否则记录到达 i 层的最少步数 step = [-1] * (n + 1) step[a] = 0 q = deque([a]) while q: cur = q.popleft() # 两个方向一起处理,用元组遍历更简洁 for nxt in (cur + k[cur], cur - k[cur]): if 1 <= nxt <= n and step[nxt] == -1: step[nxt] = step[cur] + 1 if nxt == b: print(step[nxt]) return q.append(nxt) print(-1) if __name__ == "__main__": solve()

Python 版本里最值得注意的就是那一行k = [0] + list(...)。这个前导的 0 就是专门用来占住下标 0 的,让 k[1] 成为真正的“第 1 层的按钮数字”。很多 Python 初学者会困惑为什么这里要加一个 0,实际上目的只有一个:让数组下标和楼层编号完全对齐。

3.4 处理 Ki = 0 的情况,防止死循环

这道题原题的数据范围里,Ki 是可以取 0 的。如果某层楼的按钮数字是 0,那么向上和向下的结果都是原地不动。如果没有 vis 数组帮忙挡住,队列弹出这一层时会把这一层再次入队,形成一个永远跳不出去的自循环,整个程序就卡死了。

所以无论 Ki 是不是可能为 0,vis 数组都必须写。而且要注意,起点也要标记为已访问。有人会在 BFS 开头忘记做vis[a] = true,导致起点被重复入队,虽然大部分时候不会死循环,但逻辑上不严谨,很容易在特殊数据下翻车。

我个人的建议是:BFS 的访问标记一定要“入队即标记”,而不是“出队再标记”。入队时就把 vis 置为 true,能有效防止同一个节点被多个方向同时加入队列,从而保证每个节点只处理一次。如果你写成出队时标记,同一个节点可能已经在队列里排了两份,步数计算也会乱。

4. 换个视角:这题也是最短路问题的入门模型

4.1 建图思路:每层楼是一个节点

BFS 已经能解决这道题了,但如果你学图论学到后面,再回头看这道题会有不一样的感觉。它本质上就是一个无权有向图的最短路问题,BFS 只是这个问题的特化解法。

建图逻辑很简单。V 是 {1, 2, ..., N},每个节点 i 至多有两条出边:

  • i → i + k[i],前提是 i + k[i] 在 1 到 N 内;
  • i → i - k[i],前提是 i - k[i] 在 1 到 N 内。

每一条边的权重都是 1,因为按一次按钮算一个单位的代价。目标则是求从节点 A 到节点 B 的最短路径。

如果未来题目改一改,比如不同楼层的按钮有多有少、按下不同楼层花费的时间不同,那 BFS 就不够用了,得换成 Dijkstra 或者 SPFA。但“建图”这个思维过程是完全一致的。这也是为什么我建议初学者在做这道题时,不要只满足于背出 BFS 模板,而是多想想“为什么 BFS 能解决它”。

4.2 Dijkstra 风格的非标准实现

严格来说,边权都为 1 时上 Dijkstra 是杀鸡用牛刀,但拿它练手建图也是一个不错的选择。这里我写一个简化版,直接用 C++ 优先队列模拟 Dijkstra 的过程,方便以后迁移到加权图场景。

#include <bits/stdc++.h> using namespace std; const int MAXN = 205; const int INF = 0x3f3f3f3f; int n, a, b; int k[MAXN]; int dist[MAXN]; int main() { cin >> n >> a >> b; for (int i = 1; i <= n; i++) cin >> k[i]; memset(dist, 0x3f, sizeof(dist)); dist[a] = 0; // pair<当前距离, 楼层号>,优先队列默认是大顶堆,所以反着存距离 priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; pq.push({0, a}); while (!pq.empty()) { auto [d, cur] = pq.top(); pq.pop(); if (d != dist[cur]) continue; // 旧数据,跳过 if (cur == b) break; // 目标出队,距离已确定 int nxt = cur + k[cur]; if (nxt <= n && dist[nxt] > d + 1) { dist[nxt] = d + 1; pq.push({dist[nxt], nxt}); } nxt = cur - k[cur]; if (nxt >= 1 && dist[nxt] > d + 1) { dist[nxt] = d + 1; pq.push({dist[nxt], nxt}); } } cout << (dist[b] == INF ? -1 : dist[b]) << endl; return 0; }

这段代码里dist[nxt] > d + 1就是 Dijkstra 的松弛操作。由于所有边权都是 1,这个形式看起来有点像 BFS 的 step+1,但意义不同:Dijkstra 是在允许不同权重的前提下,不断用更小的距离去更新邻居。

我之所以把这份代码也放出来,是希望大家看到两种解法在结构上的相似性。BFS 用队列,Dijkstra 用优先队列;BFS 第一次到达即最优,Dijkstra 出队时距离才最终确定。理解了这一点,以后遇到“电梯按钮有不同等待时间”之类的变种题,你就能自然地过渡到 Dijkstra,而不是重新学一遍。

4.3 这类模型还能迁移到哪些题目

“奇怪电梯”这种“每层只能跳到固定偏移位置”的模型,其实是很多搜索题的母题。比如:

  • 青蛙跳台阶问题,每次可以跳若干步,问最少跳几次;
  • 数字华容道类的拼图搜索,每个状态是图上的一个节点;
  • 迷宫问题里带“传送门”或“单向滑动”的变体;
  • 打开轮盘锁问题,每个状态相当于图上的节点,转动一次相当于走一条边。

它们的共同点都是:把每一种“局面”看作一个节点,把“一次操作”看作一条边,然后求最短路径。所以「奇怪的电梯」虽然简单,但它把 BFS、图建模、边界处理、数组下标这一整套基本功全部串起来了,是一道性价比特别高的训练题。

5. 常见问题与排查技巧实录

5.1 程序陷入死循环怎么排查

如果你发现程序跑起来不输出结果,大概率是访问标记出了问题。最常见的是两种情况。

一是起点忘了标记。起点不标记的话,第一次从队列弹出起点时,会把起点再次放进队列,但因为有 step 数组或者 vis 数组,通常会在第二次弹出时被挡住;但如果你的代码里根本不写访问数组,只是纯暴力搜索,那就会无限递归或者无限循环。

二是 Ki = 0 的自环问题。遇到这种层,向上和向下都回到自身。如果没有访问标记,这一层会被无限地入队、出队、再入队,程序就会卡住。排查方法很简单:在循环里加一个计数器,看看循环次数是否明显大于节点数;或者直接断点打印每次出队的楼层号,看到同一个楼层反复出现,就能确认是被自环卡住了。

5.2 越界访问不报错反而让你更慌

这个坑我真的是踩到过。在 C++ 里,数组越界属于未定义行为,它不一定会崩溃,有时候会读到相邻内存里的随机值。于是你的程序表现就是“时好时坏”:某些样例答案对了,某些样例莫名其妙输出一个巨大的数,或者输出随机结果。

在 Python 里更阴间,列表的负数下标是合法的,所以当你想访问 k[cur-1] 却写成 k[cur] 且 cur 是 0 时,Python 不会报错,而是默默返回最后一个元素。想象一下:你为了从 0 存储而下意识地给楼层号减一,但边界条件下减成了 -1,程序不仅没炸,还用数组末尾的值继续算,最后给出一个看着合理、实则全错的答案。这种 bug 最难定位,因为它不中断、不报错、结果也不是明显离谱。

对策就一条:所有数组访问前,先确认下标在合法区间。如果队列里存的是楼层号,那么 k[floor] 合法当且仅当 1 <= floor <= n。写越界判断的顺序也很重要,我建议先判断楼层是否越界,再访问数组,不要反过来。

5.3 输出始终比答案大 1 或小 1

有些读者会碰到“答案老是大 1”的问题。这通常出在步数初始化逻辑上。

如果你把起点步数初始化为 0,每次扩展时加 1,那么第一次扩展到目标时,输出 step[target] 就是正确答案。但如果你把起点也计了一次按钮,或者拿了 step[cur] 没有加 1 就直接赋值给邻居,步数就会错位。建议用一个简单样例手推一遍:起点是不是终点?如果起点就是终点,应该输出 0。这个特判很多人会漏,漏了之后答案恰好比正确答案大 1。

5.4 快速问题排查速查表

我把上面提到的问题整理成一个表,方便你以后遇到类似表现时快速定位。

现象可能原因快速定位方法
程序卡住不结束访问标记缺失或 Ki = 0 形成自环打印每次出队的楼层号,观察是否有重复
偶尔对、偶尔错从 0 存储导致下标偏移混乱检查所有 k 数组访问处,确认是否统一用楼层号当下标
Python 访问 k[负数] 不报错但结果错城市边界判断写反或漏写在访问数组前打印 cur 和 n,确认是否出现 0 或负值
答案总是比期望大 1起点终点的特判缺失或起点步数多算了 1单独用 a == b 的样例测试
输出 -1 但肉眼能看到可行路径向上或向下方向的边界判断有误,把合法分支剪掉了手推一遍样例,每遍历一层就把候选楼层打印出来

5.5 最后一个细节:先判越界再判访问

我写 BFS 时固定一个顺序:先判断候选楼层是否在 [1, N] 内,再判断这个楼层是否访问过。

if (up <= n && !vis[up]) { ... }

顺序别看反了。如果你先判断!vis[up],而 up 刚好是越界的负数,在 C++ 里访问 vis[-1] 是未定义行为,可能会莫名其妙地返回 true 或者 false,导致分支错误。在 Python 里,step[-1] 会访问到列表最后一个元素,同样会干扰判断。把范围判断放在第一优先级,既是为了逻辑正确,也是为了安全。这个顺序习惯在我写过的几乎所有 BFS 题里都适用,属于那种“没人强调但非常关键”的细节。

回到开头的话题,我现在的习惯已经彻底固化:凡是题目里的节点编号从 1 开始,数组必开 N+1,下标 0 永远空着。这个习惯最初就是从「奇怪的电梯」这道题里学到的,之后刷迷宫、刷图论题、刷各种带编号的模拟题,都再也没因为“第几层”和“第几个”搞混而返工。你可以把这个当作一个手到擒来的技巧,但我更建议你理解它背后的逻辑:让代码里的每一个数字,都和题目里说的话一一对应。数据从下标 1 开始存储,不是玄学,就是最朴素的“少做一道转换,少踩一堆坑”。

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

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

立即咨询