题目
给定 5 张牌,牌面数字 1~13(A=1,J=11,Q=12,K=13),判断是不是顺子(连续 5 张)。追问:如果牌里有大小王(记作 0,可当任意牌),怎么判断?
输入: [1, 2, 3, 4, 5]
输出: true
输入: [0, 0, 1, 2, 5] 两张王补上 3、4,凑出 1-2-3-4-5
输出: true
这就是
LeetCode 面试题 61. 扑克牌中的顺子(剑指 Offer 61)。这题代码只有十行,但面试分数全在细节里:重复怎么办、王怎么算、边界怎么判。官方样例只有两组,恰好把三个最常见的错法全部放过去了。
一、核心洞察:把「连续 5 张」塌缩成「相邻差」
不要问「这 5 张是不是顺子」,要问「排序之后,相邻两张差多少」。
5 张连续的牌,排序后相邻差全是 1;有断档的地方,差就大于 1。而大小王(0)是一张「万能填空牌」:断档缺几个数,就拿几张王去填。这一句话就是全部的算法:
排序 → 数王 → 逐对检查相邻差 → 差多少就补多少王 → 王够就是顺子
但有一条约束藏在题面里,漏掉它的人最多:
顺子是 5 个互不相同的数。王能填「缺失的数」,填不了「重复的数」 ——
[1,2,2,3,4] 里有两个 2,再来十张王也变不成顺子。
一句话总结:无重复 + 跨度够小,就一定是顺子;王只是把「跨度」的上限从 4 放宽了一点。
二、基础版:没有王,相邻差必须全是 1
先解追问之前的版本:牌面 1~13、没有王。5 张不同的牌连续,排序后每对相邻牌的差恰好是 1 —— 任何一处差 2 就意味着中间缺了一个数,而没人能补。
等价的写法是用跨度:5 个互不相同的数连续,最小值和最大值必须正好差 4(比如 1-5、9-13)。于是得到两条判据,缺一不可:
无重复 (5 个数互不相同)
max - min = 4 (跨度恰好 4)
注意第二条是
= 4 不是
<= 4:
[1,2,3,4,6] 跨度 5,看着只差一点,但中间实实在在缺一个 5。
三、含大小王:排序、数王、补洞
加上大小王(0 当任意牌)之后,判据从「差必须是 1」放宽成「差超过 1 的地方,拿王去补」。完整代码:
def is_straight(cards: list[int]) -> bool:
if len(cards) != 5:
raise ValueError("必须恰好 5 张牌")
s = sorted(cards) # 王(0)小于所有牌面,自动排到最前
jokers = s.count(0) # 每张王 = 一次「任意填」的机会
rest = s[jokers:] # 非王牌,升序
for i in range(1, len(rest)):
if rest[i] == rest[i - 1]:
return False # 重复:王填不了已有的数
if rest[i] - rest[i - 1] > 1:
jokers -= rest[i] - rest[i - 1] - 1 # 中间的洞拿王补
if jokers < 0:
return False # 王用完了还不够
return True
三步分解:
排序(王浮到最前)、
数王(
jokers 张王是预算)、
逐对补洞(相邻差
gap 大于 1 就消耗
gap - 1 张王)。重复检查放在补洞之前 —— 这两个失败原因是独立的,谁都救不了谁。
还有一个更漂亮的等价判据,一行就能写完:
def is_straight(cards: list[int]) -> bool:
s = sorted(cards)
rest = [c for c in s if c != 0]
if len(set(rest)) != len(rest):
return False
return True if not rest else max(rest) - min(rest) < 5
为什么
< 5 就够了?设非王牌有
m = 5 - k 张(
k 是王数),它们互不相同,中间的洞数是
跨度 - (m - 1)。「王够」即
k >= 跨度 - (m - 1),代入
m = 5 - k 化简后正好是
跨度 <= 4,也就是
跨度 < 5。无王时它自动收紧成
跨度 = 4(因为 5 个不同数的跨度至少是 4)——
一条判据同时覆盖了两个版本。
主例
[0, 3, 4, 5, 7],一个王、一个洞:
四、动画怎么看
动画一([3, 0, 7, 5, 4],7 帧) 演示完整流程。乱序入手,排序后王浮到最前;然后逐对检查非王牌:
3→4 差 1 天然连续,
4→5 差 1 天然连续,
5→7 差 2 缺一个 6 —— 拿唯一一张王补上,预算刚好用完。画面分五段:
- 横幅:当前阶段(排序 / 数王 / 检查相邻对 / 判定)、动作、关键数值;
- 牌面:排序后的 5 张牌,王烫金,当前检查的相邻对橙框相连;
- 相邻对账本:每对非王牌一枚 chip(差 1 连续 / 差 N 王补 / 重复 / 王不够),检查到哪枚亮到哪枚;
- 拼出的顺子:已经接上的连续段,王补的格子烫金标注「王补」;
- 判定:是顺子(绿)/ 不是顺子(红)+ 王的用量。
值得盯的是第 6 帧(
5→7):这是全程唯一一次动用王,缺的 6 被烫金标出,王的预算从 1 变 0 —— 「刚好够」和「不够」之间没有任何缓冲。
动画二(3 组反例,8 帧) 演示三个官方样例抓不住的错法,每组给正解和错法的判定对照 —— 三个错法全部朝同一个方向错:把不是顺子的牌放过去。
五、完整推演表
主例
[0, 3, 4, 5, 7],排序后非王
[3, 4, 5, 7]、王 1 张:
结论:非王全部接上,拼出
3-4-5-6-7,需要补 1 个洞、王 1 张 ——
是顺子。
官方样例
[0, 0, 1, 2, 5],非王
[1, 2, 5]、王 2 张:
1→2 差 1;
2→5 差 3,缺 2 个(3、4),2 张王刚好补上 ——
是顺子。
常用用例速查(每一行都对应一条被忽略的约束):
所有判定都用「枚举王的全部填法」的暴力解校验过:0~13 的 5 张可重组合共 8568 种,扫描解与暴力解逐一一致。
六、Java 版:两个官方样例抓得住的错
public class PokerStraight {
// cards 长度必须为 5,0 表示大小王
public static boolean isStraight(int[] cards) {
if (cards == null || cards.length != 5) {
throw new IllegalArgumentException("必须恰好 5 张牌");
}
Arrays.sort(cards);
int jokers = 0;
while (jokers < 5 && cards[jokers] == 0) {
jokers++;
}
for (int i = jokers + 1; i < 5; i++) {
if (cards[i] == cards[i - 1]) {
return false; // 重复,王救不了
}
jokers -= cards[i] - cards[i - 1] - 1; // 补洞
if (jokers < 0) {
return false; // 王不够
}
}
return true;
}
}
这题没有溢出问题(5 张牌、最大 13),Java 版的坑全在变量的初始化上。两个经典错误,官方样例
都能抓住,列出来是因为它们出现频率极高:
错法一:min 用 0 初始化。 写
int min = 0, max = 0 再扫一遍求最值 —— 非王牌最小也比 0 大,
min 永远停在 0,跨度虚大。
[1,2,3,4,5] 被判成
max - min = 5,直接返回 false。官方样例 1 就是这个输入,一跑便知。
错法二:把王算进跨度。 求
max - min 时没有跳过 0 —— 王比所有牌面都小,
min 被王占走,跨度虚大。
[0,0,1,2,5] 被算成
5 - 0 = 5,判 false,正确是 true。官方样例 2 一跑便知。
两个错法共享同一个教训:
王不是一张「数字为 0 的牌」,它是「还没有决定是什么」的牌 —— 统计跨度、查重复、算差值时都不该有它的戏份,它唯一的出场方式是「补洞」。
七、变体:环形 A、k 张王、n 张牌
环形 A(10,J,Q,K,A 也算顺子):真实扑克里 A 既能当 1 也能当 14,所以
10-11-12-13-A 是顺子。判法:若牌里含 A(1),把 A 换成 14 再判一次,两种身份任一成立即 true:
def is_straight_ring(cards: list[int]) -> bool:
if len(cards) != 5 or len(set(cards)) != 5:
return False
if 1 in cards:
alt = sorted(14 if c == 1 else c for c in cards)
if all(alt[i + 1] - alt[i] == 1 for i in range(4)):
return True
return is_straight_base(cards) # A 当 1 的普通判法
[1,10,11,12,13] 换成
10-11-12-13-14 → true;
[13,1,2,3,4] 两种身份都接不上(13,14,2,3,4 与 13,1,2,3,4 都断档)→ false;
[1,2,3,4,5] 两种读法都是顺子。带王的环形版要枚举 A 的身份(2 种)× 王的填法,组合数很小,直接枚举即可。
k 张王(不止 2 张):算法一个字不用改 ——
jokers 是计数,几张都一样。极端情况直接覆盖:
[0,0,0,0,5] 四张王兜底任意一张牌,自然成顺子(补出 1-2-3-4-5);
[0,0,0,0,0] 全王同理。但真实牌堆只有两张王,输入里出现 3 张以上说明数据不合法,严谨的入口会先校验
s.count(0) <= 2(防御式编程,面试里提一句是加分点)。
n 张牌里挑 5 张(n > 5):先去重排序,然后对每个可能的顺子起点枚举 —— 看是否存在某个长度 5 的区间
[a, a+4],落在区间里的不同牌数加王数 ≥ 5。核心判据不变:无重复 + 跨度 ≤ 4,只是从「判定」变成「搜索」。
同花顺:加花色维度。先按花色分组,某一花色凑够 5 张后,在组内套用本题判法。本题的「无重复 + 跨度」判据在花色组内原样复用。
八、复杂度
这题的「效率」不是考点 —— 输入就 5 张牌,怎么写都是常数。真正的分水岭在
正确性:这题名义上是「简单题」,实际是把「想清楚约束」的功夫全压在了边界和反例上。
顺带一个组合数学的全景(都是脚本枚举验证过的):1~13 里无王选 5 张共 C(13,5) = 1287 种组合,顺子只有 9 种(1-5 到 9-13),占比 0.70%;带 1 张王 37 / 715,带 2 张王 58 / 286 —— 王越多顺子越廉价,两张王在手,接近每五组牌就有一组能凑成顺子。
九、常见坑:官方样例抓不住的三个 bug
官方样例只有两组:
[1,2,3,4,5] 和
[0,0,1,2,5]。第一组无王且完美连续,第二组两张王正好补满两个洞 ——
重复、王不够补、王的记账方式,样例一个都没考到。下面三个错法在两组样例上全部返回 true,一声不响,各自的最小反例才能现形:
9.1 不查重复,只看跨度
只判
max(非王) - min(非王) < 5,不查重复。反例
[1,2,2,3,4]:非王跨度 3,判据放行 —— 但两个 2 重复,王填不了已有的数。更极端的
[2,2,2,2,2] 跨度 0,错法照样判 true。
测顺子必带一组带重复的用例。
9.2 判据写成 ≤ 5
5 张无王的牌要连续,跨度必须
恰好是 4。写成
<= 5,
[1,2,3,4,6](跨度 5、无王)被放过去 —— 它中间实实在在缺一个 5,而手里没有王。
< 和
<= 的一字之差,恰好落在样例覆盖不到的边界上。
9.3 遍历从下标 1 开始,把「王-王」也算成相邻对
剑指的经典错写法:排序后
for i in 1..4: gap += nums[i] - nums[i-1] - 1,最后判
gap <= 王数。看着没问题,但 0 和 0 这一对也被算了进去:
0 - 0 - 1 = -1,
每对王贡献一个负 gap,把真正的洞抵掉了。
反例
[0,0,1,2,6]:四对相邻差的账面是
-1, 0, 0, 3,合计 2,两张王「刚好够」—— 实际呢?非王
[1,2,6],要补的是 3、4、5 三个洞,2 张王根本不够。正确答案是 false,错法判 true。
测法:
[1,2,2,3,4](重复)、
[1,2,3,4,6](跨 5 无王)、
[0,0,1,2,6](王被负 gap 抵掉)这三组反例应当常备 —— 每一组都精准打在一条被忽略的约束上。三个错法方向一致:
把不是顺子的放过去。对照第六节的两个错法(
min 初始化为 0、王算进跨度),那两个方向相反、会把顺子错杀,而且官方样例恰好能抓住 —— 样例的覆盖是有偏的,不能只靠它。
十、面试怎么答
30 秒版本:
排序,王(0)浮到最前;数王,每张王是一次任意填的机会;逐对检查非王牌的相邻差:差 1 连续,差 N 就消耗 N-1 张王补中间的洞,遇到重复直接 false —— 王能填缺失的数,填不了已有的数。王用完还不够,就不是顺子。时间空间都是 O(1)。
如果还有时间,主动补三句,分别是三个加分点:
- 等价判据:非王无重复 + 非王跨度 < 5,一条判据同时覆盖有王无王 —— 无王时自动收紧成跨度恰为 4;
- 三个反例:
[1,2,2,3,4]、[1,2,3,4,6]、[0,0,1,2,6] —— 官方样例对三个经典错法全部失声,测试必须自带反例;
- 变体:A 可当 14 时枚举 A 的身份再判一次;王超过 2 张说明输入不合法,入口先校验。
这套题的完整状态机、渲染层和 28 个单测(含与暴力解的全量交叉验证:8568 种组合逐一一致)都在仓库里,动画对应文章里的两个占位符。