1. 先看懂题目到底在问什么
LeetCode 1033 这道题,我在第一次刷到的时候其实愣了一下,不是因为看不懂题目,而是因为这道题和我平时刷的数据结构算法题完全不是一个路子——它不考链表、不考二叉树、不考图,纯粹就是一题数学观察题。很多朋友一看“Moving Stones Until Consecutive”这个英文标题,再加上“移动石子”的中文翻译,可能以为是什么搜索或者模拟遍历的题目,甚至有人会往 BFS、DFS 方向想,实际上根本不用那么复杂。
先说题目本身:数轴上有三颗石子,位置分别在 a、b、c,每次你只能选择一颗“处于端点位置”的石子,把它挪到一个“当前的空位置”上,要求挪动之后这颗石子不再处于端点位置。目标是用最少的步数和最多的步数,让三颗石子变成三个连续整数(也就是紧密排在一起,中间没有空隙)。
这个题有一个非常关键的限制——你必须移动端点石子,而且移动后它不能还是端点。很多人最开始会忽略“移动后不能是端点”这个约束,导致对最大步数的计算产生误解。
题目给的例子一般是这样:假如 a=1、b=2、c=5,那么三颗石子已经有两个相邻,第三颗在 5。我们可以把 5 移动到 4,这样就变成了 1、2、4,然后 4 还是端点,再把 4 移动到 3,就变成了连续。整个过程 2 步,这是最小步数。最大步数怎么算?答案是 3 步。你可能会问,为什么移动 5 到 4 再变成 2 步,最大却是 3 步?因为最大步数的移动策略是每次只挪动端点“一格”,比如先把 5 移到 4,然后把 1 移到 3,再把 4 移到 5?不对,这里需要注意,三颗石子只要连续了游戏就结束,所以最大值的计算有它自己的一套公式。
这道题被放在 LeetCode 的简单题分类里,是因为它的代码写出来非常短,核心逻辑就那么几行,但它之所以值得写一篇博客,是因为它的数学推导过程非常容易卡壳,尤其是“最大步数”这块,几乎没有官方题解讲得足够清楚。我自己的耗时代码大概在 100ms 附近,下面我把我完整的分析思路和代码都拆开来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从三个数的排布开始推导核心规律
2.1 先把三颗石子的位置整理成有序状态
做这道题的第一步,不用急着想公式,先把位置排个序。假设三颗石子的位置是 x、y、z,排序后满足 x ≤ y ≤ z。这里排序非常重要,因为“端点”这个概念只有有序之后才明确——x 和 z 是端点,y 是中间石子,这一点决定了我们后面所有计算的基准。
排序之后,我们可以定义两个关键的“空位数量”:
- 左侧空位数:x 到 y 之间有多少个空位,即 y - x - 1。
- 右侧空位数:y 到 z 之间有多少个空位,即 z - y - 1。
这两个数值是整个题目的钥匙。为什么这么说?因为每次移动的实质,就是“消耗”端点外侧的空隙,把它转移到中间的两个空隙之一。整个过程就是在不断压缩 y - x - 1 和 z - y - 1 这两个数字,直到它们都变成 0。
很多题解会直接甩出公式,比如最小值就是 min(1, 2) 这种判断,最大值就是 max(z - x - 2, 0) 这样。但如果你不理解中间空位的概念,看到这个公式会觉得莫名其妙。所以我建议你先把两个中间空位数写在草稿纸上,然后我们再从这两个数出发逐步推导。
2.2 最小步数的三种情况:0、1、2
最小步数的计算逻辑,本质上就是在问:我能不能用一步搞定?如果不能,那就两步,因为三颗石子变成连续,两步一定够了。
再具体一点,分三种情况:
第一种:三颗石子已经是连续的。比如位置是 1、2、3,那么一步都不用走,最小步数是 0。
第二种:存在某种方式,一步就可以让三颗石子连续。什么时候能一步完成?当且仅当两个端点中有一个与中间石子相邻(比如 x 和 y 相邻),而另一个端点可以一步挪到空缺的那个连续位置上。更通用的判断方法是:左侧空位数是 0 且右侧空位数是 1,或者右侧空位数是 0 且左侧空位数是 1。也就是说,中间石子已经贴着一个端点,而另一个端点与中间石子之间恰好空一个位置,那么把远端石子挪到那个空位,就连续了。
还有一种容易被忽略的边界情况:两个端点之间有且只有一个空位,比如位置是 1、3、5,中间石子 3,左侧空 1(2),右侧空 1(4),这时我把 5 移到 2,就得到 1、2、3,一步完成。这种情况在代码上怎么体现?其实也很好判断,就是 y - x == 2 或者 z - y == 2,也就是两个石子之间只隔一个位置。
第三种:剩下所有情况,最小步数都是 2。因为两步一定可以把三颗石子变成连续:第一步把靠外的端点移到一个中间位置,第二步再把另一个端点移到剩余空位。无论初始怎么排布,两步都是可完成的。
所以最小步数的代码可以简单地写成:
python复制if z - x == 2:
min_moves = 0
elif z - y == 2 or y - x == 2 or z - y == 1 or y - x == 1:
min_moves = 1
else:
min_moves = 2
这里需要注意,z - x == 2 涵盖了连续的情况,因为它表明三颗石子严格相邻。而第二步的 z - y == 1 or y - x == 1 是判断已有两颗相邻的情形,z - y == 2 or y - x == 2 是判断中间隔一个位置的情形。这些条件我后面在“坑点”里还会展开。
2.3 最大步数的关键:端点外侧的空位不能直接用
最大步数是这道题最绕的地方。很多人直觉上会觉得,最大步数就是把两个端点之间的所有空位都填满,也就是 (y - x - 1) + (z - y - 1) = z - x - 2。这个直觉对了一半,但直接拿它作为最大值,会在某些情况下出错。
问题出在“移动后不能是端点”这个约束上。假设三颗石子分别在 1、2、9,那么 x=1、y=2、z=9,中间空位数是 7(3 到 8 都是空的)。如果直接算 z - x - 2 = 6,这是最大步数吗?我们模拟一下:
- 第 1 步,把 1 移到 3,状态变成 2、3、9。
- 第 2 步,把 9 移到 4,状态变成 2、3、4?等一下,2、3、4 已经连续了,游戏结束,只用了 2 步就结束了,显然这不是最大步数的走法。
正确的最大步数走法应该是“每次只挪动一格”,而且要避免过早形成连续。比如:
- 第 1 步,把 9 移到 8(1、2、8)。
- 第 2 步,把 1 移到 3(2、3、8)。
- 第 3 步,把 8 移到 7(2、3、7)。
- 第 4 步,把 3 移到 4?不行,3 不是端点,端点应该是 2 和 7。把 2 移到 4?那状态变成 3、4、7。
- 继续这样,最终你会发现,实际上能走的最大步数不是 6,而是更小。
那最大步数的正确公式是什么?其实还是 z - x - 2,但这个值有个前提条件——你得先移动某个端点,使得原本“堵住”的一端被打开。对于 1、2、9 这个例子,为什么 z - x - 2 = 6 但实际走不到?因为无论怎么走,只要有一步棋让某个石子变成了中间石子的邻居,游戏就快要结束了。
其实这里我建议换一种更实用的思考方式:最大步数等于“被空位隔开的石子移动次数”。每次移动端点,都会让三个石子之间的距离总和减少 1(把最远的端点向内移动一格),而当距离总和降到 2 时游戏结束。初始距离总和是 (y - x) + (z - y) = z - x,降到 2 需要 (z - x - 2) 次,每次减少 1,所以上限是 z - x - 2。这个推导非常干净,但它的前提是每次移动都能把距离总和只减少 1(也就是只挪一格)。然而实际移动规则里,你可以把端点移动到任意空位,一旦你跳过多格,距离总和就会一次性减少好几,导致步数变少。
为了达到最大值,最优策略就是每次只挪动端点一格。但问题来了,每次只挪一格是否永远可行?如果中间有空位,你可以不断执行“把端点向中心挪一格”的操作。但有一个特殊场景:当三颗石子已经出现“两颗相邻”时,最外侧的端点向中心挪一格,就会立刻形成连续,游戏结束。
所以最大步数的真正公式是:z - x - 2 - min(y - x - 1, z - y - 1) 吗?我见过有题解这么写,也不对。让我直接给结论——官方题解的结论是最大步数等于 max(z - x - 2, 0) 再减去一个修正项?不,官方结论就是 z - x - 2,因为它允许你选择任意空位移动端点,并不是必须一次只挪一格,也没有额外限制。关键是我之前犯了一个误区:我以为距离总和每次只能减少 1,实际上你可以通过“合理选择移动目标”来让距离总和减少超过 1,但那样会让总步数减少,为了最大步数,你要尽量让距离总和减少得慢。
重新回头看 1、2、9 的例子,为什么我模拟出来感觉到不了 6?因为我移动的方向不对。正确的最大步数走法:
- 初始:1、2、9,距离总和是 8(1 到 2 距离 1,2 到 9 距离 7,合计 8)。
- 第 1 步:把 9 移到 3,变成 1、2、3——直接连续,结束。这不行。
- 那换个思路,先把 1 移走,因为 1 和 2 相邻,如果 1 移走,2 就变成端点了?不对,1 移走后,端点变成 2 和 9,2 到 9 中间一堆空位,可以继续操作。但 2 是端点,那就把 2 也移走?2 移走后,端点变成 1 和 9,1 又没地方可去(只能往中间移),又回到 1、2、9 附近的状态。
其实最大步数的正确策略不是“每次只挪一格”,而是“先制造一个远离的状态,再慢慢收拢”。我实际推演后发现,1、2、9 确实可以达到 6 步。走法如下:
- 1 移到 4:状态 2、4、9,距离总和 = 2 + 5 = 7。
- 9 移到 8:状态 2、4、8,距离总和 = 2 + 4 = 6。
- 2 移到 3:状态 3、4、8,距离总和 = 1 + 4 = 5。
- 8 移到 7:状态 3、4、7,距离总和 = 1 + 3 = 4。
- 7 移到 6:不能移 6,因为 7 移到 6 后 3、4、6 还是没连续,6 是端点,可以继续。
- 6 移到 5:状态 3、4、5,连续,结束。
数一下,1 到 4、9 到 8、2 到 3、8 到 7、7 到 6、6 到 5——正好 6 步。你看,我前面失败是因为第一步就把 9 移到了 3,跳过了太多空位。正确玩法是第一步别急着把远端拉进内部,而是先把 1 跳到一个中间位置,这样拉开 1 和 2 之间的距离,为后续逐步挪动创造空间。
所以最大步数公式就是 z - x - 2,不需要额外的修正项。上面 1、2、9 中 z - x - 2 = 9 - 2 - 2 = 6,吻合。这也是整个公式最迷人的地方:看起来不符合直觉,但其实只要你愿意“兜圈子”,可以先拉开相邻的石子,再进行逐步收拢,就能走满上限。
3. 代码实现与耗时 100ms 的写法
3.1 排序:一切推导的起点
这道题输入是三个整数 a、b、c,而且题目没有保证它们是有序的。所以代码第一步必须是排序。我见过一些新手不排序,直接拿 a、b、c 去比较大小,然后写了一堆 if-else 分支,代码又臭又长,还容易漏情况。排序之后,一切逻辑都建立在 x ≤ y ≤ z 的基础上,代码的简洁度和正确率会大幅提升。
排序在 Python 里就直接:
python复制x, y, z = sorted([a, b, c])
在 Java 里可以用 Arrays.sort 或者手动交换,总之先把顺序理清楚,这是整道题的地基。排序不只是为了求最大值公式,也影响最小值的所有判断条件。
3.2 完整的参考代码
下面是我提交通过的 Python 版本,思路就是把公式落到几行判断上:
python复制class Solution:
def numMovesStones(self, a: int, b: int, c: int) -> List[int]:
x, y, z = sorted([a, b, c])
# 最小步数
if z - x == 2:
min_moves = 0
elif z - y == 2 or y - x == 2 or z - y == 1 or y - x == 1:
min_moves = 1
else:
min_moves = 2
# 最大步数
max_moves = z - x - 2
return [min_moves, max_moves]
这里最需要注意的就是最小步数的判断条件。我拆开解释:
z - x == 2:三颗石子已经连成三个连续整数(因为排序后中间的 y 必然等于 x+1 和 z-1,比如 1、2、3,z-x 正好是 2)。z - y == 2 or y - x == 2:有一侧只隔一个空位,把另一侧端点移过来,一步完成。比如 1、3、4(y=3、x=1 时 y-x=2),把 1 移到 2,就得到 2、3、4。z - y == 1 or y - x == 1:已经有两个石子相邻,另一个端点可以一步进入空位形成连续。比如 1、2、5(y-x=1),把 5 移到 3,得到 1、2、3。
有朋友可能会疑惑,z-y == 1 or y-x == 1 是否覆盖了 z-x == 2 的情况?如果 z-x == 2 且排序后只有一个整数 x+1 在里面,那么 y 只能是 x+1,这时 y-x=1 和 z-y=1 都成立。所以 z-x == 2 专门处理最小步数为 0 的场景,需要在前面提前判断,否则会被后面条件误判成 1。这个顺序不能乱。
最大步数就直接是 z - x - 2,这个无需过多分支,因为它实际是把两个间距之和减去两端保留的固有距离。上面已经推导过,这里不再赘述。
3.3 耗时 100ms 是什么概念
我在 LeetCode 上提交的时候,耗时大约 100ms,其实这在 Python 里已经算还可以了。如果你用 C++、Java 或者 Go,这种规模的题目基本都在 0ms 到 4ms 左右,Python 因为解释器启动和排序开销,100ms 属于正常范围。
如果你想进一步压缩耗时,可以考虑不排序,直接通过判断三个数的大小关系来赋值:
python复制x = min(a, b, c)
z = max(a, b, c)
y = a + b + c - x - z
这样避免了 sorted 函数的额外开销,在 LeetCode 的 Python 环境下可能能省下几毫秒。不过说实话,这属于微优化,实际意义不大,我更建议追求代码清晰度。
4. 做题过程中的常见坑与排查经验
4.1 不排序直接判断,最容易漏边界
我第一次做这道题的时候,就是没排序,拿着 a、b、c 的原始顺序去判断相邻性,结果在输入为 3、1、2 这种顺序时就乱了套。排序不是为了好看,而是因为数学公式全部建立在有序三元组的定义上。你只要记住一条铁律:任何涉及“连续整数”的题目,第一步一定是排序或者确定大小关系,几乎从不出错。
4.2 最大步数的推导容易绕进死胡同
我见过不少题解在最大步数这里写得很含糊,甚至有人直接给出错误结论,说最大步数等于 max(z - y - 1, y - x - 1),这显然不对。回到 1、2、9 的例子,两个中间空位分别是 0 和 6,max 是 6,看起来和 z-x-2=6 吻合。但换一个例子:1、5、9,两个中间空位分别是 3 和 3,max=3,而 z-x-2=6,差了一半。哪一个才是真实的最大步数?我们手动走一下 1、5、9:
- 1 移到 2:2、5、9。
- 9 移到 8:2、5、8。
- 2 移到 3:3、5、8。
- 8 移到 7:3、5、7。
- 7 移到 6:3、5、6。
- 6 移到 4:3、4、5,连续,结束。
总共 6 步,和 z-x-2 一致,而 max(y-x-1, z-y-1) 只给了 3。所以千万别被“中间空位更多的一侧”误导,最大步数不是看单侧空位,而是看整段跨度减 2。
4.3 最小步数中的 2 不是随便猜的
为什么兜底一定是 2,而不是 3 或更大?因为三颗石子最多只需要两步就能变连续。你可以这样理解:第一步先把其中一个端点移到一个合适位置,让三颗石子中至少有两颗相邻;第二步再把另一个端点移到连续序列的缺口。这个策略对任何初始排布都成立。比如最极端的 1、100、199,第一步把 199 移到 2,变成 1、2、100;第二步把 100 移到 3,变成 1、2、3。两步完成。这就是“三步以内必成”的直觉来源。
但为什么有时候是 1 而不是 2?因为当已经有两颗相邻,或者两颗之间只有一个空位时,缺口只有一个,一步就能补齐。这也是上面判断条件的由来。
4.4 小心语言边界:负数位置
石子位置可以是负数吗?LeetCode 原题没有明确说只能是正整数,实际上位置是整个数轴上的整数坐标,所以负数位置也可能出现。但因为我们的公式只关心差值,不关心绝对值,所以负数完全不影响排序和计算结果。比如 -5、0、5,z-x-2 = 8,最小步数呢?x=-5、y=0、z=5,y-x=5、z-y=5,没有任何相邻,也没有隔一空位,所以最小步数是 2。逻辑完全成立。
4.5 返回值类型
这道题要求返回一个长度为 2 的数组或列表,第一个元素是最小步数,第二个是最大步数。有些语言里要小心类型匹配,比如 Java 的 int[],Python 的 List[int]。如果你直接返回 (min_moves, max_moves) 元组,在某些版本的 LeetCode 判题器里可能不会被接受,最好显式转换成列表。这是一个非常小的细节,但也是新手容易在运行时栽跟头的地方。
5. 从这道题延伸:同类“数学规律题”的通用套路
5.1 为什么这类题值得单独训练
刷过 LeetCode 的朋友都知道,题目可以粗略分成“数据机构类”和“数学规律类”。像 1033 这种移动类问题,其实和“爱吃香蕉的狒狒”(875 题)、回文子串(5 题)、目标和(494 题)在底层思维上完全不一样。
爱吃香蕉的狒狒考的是二分答案——你要在可能的速度范围内二分查找最小值;最长回文子串考的是动态规划或中心扩展;目标和考的是背包 DP。而移动石子直到连续,考的是博弈论里面“不变量”和“边界条件”的思路。它不需要你设计复杂的 DP 状态,也不需要什么高级数据结构,纯粹是你要在坐标系里发现几个关键量:排序、间距、空位、跨度。
一旦你理解这类“移动序列题”的套路,你再去做 LeetCode 上其他移动类题目,比如 283 移动零、453 最小操作次数使数组元素相等,就都会有“先找不变量,再找公式”的思维惯性。像 453 那题的不变量是“所有元素的相对差不变”,所以答案就是 sum - n * min。这些题表面千差万别,底层都是一句:找出什么是不变的,什么是可以操作的。
5.2 如果石子从 3 颗变成 n 颗,问题会变难吗
很多题解在最后会抛出一个延伸:如果石子数量变成 n,问最少和最多多少步能变成连续,那问题就完全不一样了。n 颗石子时,最小步数变成“滑动窗口内已有的石子最大数量”取反,最大步数则要考虑“哪些石子可以不动,其他都挪动”,本质是一个区间覆盖问题,复杂度会上升到 O(n^2) 甚至更高。这也是为什么 1033 被定义为简单题——它常数小、规律明确、实现简单;而一旦扩展成 n 的情况,就是另一道难题了。
如果你对这个延伸方向感兴趣,可以从“把 n 个石子看成 n 个点,目标连续区间长度是 n”这个角度入手。最小步数就是把不是石子位置的空位数填满,所以等价于 n - max(窗口内的石子数);而最大步数则要保证至少有一个石子作为“锚点”不动,其他石子都移到它周围的连续区间里,需要枚举每个可能的锚点。这已经是滑动窗口加贪心的组合题了,但理解了 1033 的基础推导,再看这类问题会轻松不少。
5.3 为什么我推荐把这类题整理进你的“手速题”清单
LeetCode 上有些题目适合用来练思维,比如动态规划和图论;有些题目适合用来练手速和代码熟练度,比如数组翻转、删除元素。1033 移动石子就是典型的手速题——它的思路一旦搞懂,以后再遇到只需要 3 分钟就能秒掉。我一般会把这类题放在面试前热身清单里,因为它们能在很短时间内帮你找回对“边界条件”“排序”和“数学公式”的敏感度。
具体来说,面试前我建议的顺序是:先做 5 道左右数组遍历题,再做 2 道数学规律题(比如 1033、453),最后做 1 道二分题或 DP 题。这样热身既有手感,又能激活不同方面的思维。
6. 实测中的进阶经验与总结
我在写这道题的题解时,其实还做过一个尝试:能不能把代码优化到一行?如果你只追求 LeetCode 上的通过,确实可以:
python复制class Solution:
def numMovesStones(self, a: int, b: int, c: int) -> List[int]:
x, y, z = sorted((a, b, c))
return [0 if z - x == 2 else (1 if z - y <= 2 or y - x <= 2 else 2), z - x - 2]
注意这里 z - y <= 2 或 y - x <= 2 这个写法其实很巧妙,因为当间距为 1 或 2 时都可以一步完成,用 <= 2 把两种情况合并成一个判断,代码更简短。不过这个写法有个前提,就是 z - x == 2 的情况已经在前面被拦住了,否则三颗石子已经连续时,z - y <= 2 也成立,会被误判为 1。所以顺序依然很关键。
我自己在实际提交时,其实用的是更明确的版本,因为可读性更好。LeetCode 的判题环境不像比赛那样必须追求极致精简,工作项目里更是如此。很多新手有一个误区,觉得代码越短越厉害。但实际上,如果你一个月后再回来看这段代码,你需要快速理解它,短码未必高效。
还有一个经验:这道题的英文名 “Moving Stones Until Consecutive” 里的 “Consecutive” 是一个高频词汇,在算法题里经常出现。它表示“连续的整数序列”,和 “Contiguous”(连贯的、相邻的)有区别。题目描述中的 “Consecutive” 指的是数值上相差 1,而不是物理位置相邻。理解这个语义能避免你在看题时产生歧义,比如 1、3、5 不连续,但 1、2、3 连续。
最后说一个我在面试中实际遇到的情况:有一次准备面试公司的时候,面试官问了一个变种题,他把三颗石子改成三张扑克牌,位置变成牌面数字,要求类似地求最短路数。我当时就是靠 1033 的推导思路很快给出了答案。这类“移动直到连续”的模型在现实中的映射也很广,比如日程碎片化整理、内存紧缩、磁盘碎片整理,本质上都是把零散的对象移动到连续区域的问题。虽然算法题的输入和现实规模差了很远,但那种“找到不变量,然后算最少/最大操作数”的思维是通用的。
