“牛几”这三个字,第一次看到的人多半会愣一下,以为是某种方言或者暗语,其实它就是纸牌游戏里最常被问的那句话——“这手牌是牛几?”背后对应的是一个相当经典的组合判定问题:五张牌,从中挑三张,点数和凑成 10 的倍数,剩下两张的点数和取个位,就是所谓“牛几”。这道题看着像小学算术,真写起来却处处是细节,从牌面怎么编码、组合怎么枚举,到特殊牌型的优先级、模运算的边界,稍微马虎一点就会在对拍时被打回来。
我把它当成一道数据结构与算法的基础练习来做:用最小的数据结构承载牌面,用最直接的枚举完成判定,再把判定过程抽成可复用的模块。这篇东西就是把这套流程完整拆一遍,包括我一开始写错的地方、后来想明白的恒等式、以及把判定压到 O(1) 的查表做法。适合刚学完数组和循环、想找一道有真实业务味道的题目练手的人,也适合已经能写出来、但想看看有没有更省事写法的朋友。不需要什么高深前置知识,会写 for 循环、知道取模是什么意思,就能跟着往下走。
1. 先把“牛几”翻译成数据结构问题
1.1 规则的本质是一次五选三的组合枚举
把规则用最朴素的话讲一遍:手上有五张牌,每张牌有一个点数,A 记 1 点,2 到 10 按面值记,J、Q、K 统一记 10 点。从这五张里任取三张,如果它们的点数和是 10 的倍数,这三张就叫“成组”;成组之后,剩下两张的点数和除以 10 取余数,余几就是牛几,余 0 就叫牛牛。如果五张牌里无论怎么挑三张都凑不出 10 的倍数,那就是无牛。
这个描述里藏着一个关键信息:判定的核心动作是“从 5 个元素里挑 3 个”。5 选 3 一共只有 C(5,3)=10 种组合,这是个常数级的枚举量,跟牌的大小、顺序、花色统统无关。所以从数据结构角度看,这道题根本不需要什么树、图、堆,一个长度为 5 的整数数组就够了,剩下的全是怎么把枚举写对、写快。
我见过不少人一上来就想搞复杂,弄个状态机、弄个哈希表记录中间结果,其实完全没必要。为什么?因为问题规模被规则死死锁在 5 这个数字上,任何试图“优化结构”的动作,收益都远小于它带来的出错概率。数据结构选型的第一原则是匹配问题规模,5 个元素的问题,数组就是最优解。
真正需要花心思的地方不在结构,而在编码:怎么把一张牌映射成一个数,怎么把“和是 10 的倍数”这个条件写成不会漏掉边界的形式,怎么在枚举过程中顺手把结果算出来而不是枚举完再回头找。这些才是这道题区分度的来源。
1.2 为什么贪心和排序在这里都不好使
这是我最想先讲的一点,因为太多人第一反应是“排个序,取最小的三张试试”。这个直觉是错的,而且错得很典型。举一个具体的例子:手牌点数是 1、2、3、4、5。如果按“取最小的三张”的思路,会选 1、2、3,和是 6,不是 10 的倍数,于是判无牛。但实际上 1、4、5 的和正好是 10,这手牌是实打实的牛 5(剩下 2 和 3,和 5)。
贪心失效的根源在于,我们要找的不是“和最大”或“和最小”的三张,而是“和落在某个同余类里”的三张。这是一个模运算约束,跟大小顺序没有单调关系。任何基于排序的贪心策略,本质上都在利用单调性做剪枝,而模 10 这件事在数值轴上是周期性的,没有单调性可言。
那能不能先算总和,用总和去反推?可以,但只能反推“牛几”,不能反推“有没有牛”。因为只要存在一组三张牌的和是 10 的倍数,那么剩下两张的和的个位,必然等于五张牌总和的个位——这个恒等式后面会详细说。但“存在性”这一步,还是得老老实实枚举。想跳过枚举直接拍脑袋判断,就会掉进上面 1、2、3、4、5 那个坑里。
所以结论很明确:存在性必须靠枚举,结果值可以靠恒等式直接算。把这两件事分开,代码会干净很多,也不容易出错。这是我在改了三版实现之后才想明白的,一开始非要用一次遍历同时搞定两件事,结果就是在循环里塞了一堆临时变量,可读性极差。
1.3 输入该用什么结构承载,输出该定成什么类型
先说输入。最小可用的表示是一个长度为 5 的整数数组,每个元素取值 1 到 13,1 代表 A,11、12、13 代表 J、Q、K。这个表示法有个好处:点数的映射关系可以做成一张常量表,查表 O(1),而且天然支持“同点数不同花色”的场景——因为在牛几判定里,花色不影响结果,只有点数影响。
如果你的场景需要校验“同一张牌不能出现两次”,那就得把花色也带上,用一个二元组或者一个整数编码,比如rank * 4 + suit,或者干脆用字符串"S3"、"H10"。我个人的习惯是用整数编码,因为整数比较快,也方便塞进集合里做去重。但这属于工程层面的洁癖,纯算法练习里用 1 到 13 的数组完全够用。
输出这边,建议直接定成一个枚举值或者字符串,别用裸数字。理由很实际:数字 0 既可以表示“无牛”,又容易被人误读成“牛 0”,而“牛牛”在数值上又该记 10 还是 0,不同实现有不同约定。用字符串"无牛"、"牛5"、"牛牛"就没有任何歧义。如果后面要做牌型大小比较(比如比谁的牛更大),再单独写一个比较函数,把字符串映射成可排序的整数,两者分离,职责清晰。
这里还有个小约定要提前定死:牛牛到底记 10 还是记 0。我的做法是内部用一个整数表示强度,0 表示无牛,1 到 9 表示牛一到牛九,10 表示牛牛。这样排序的时候直接比大小就行,不会出现“牛牛比牛九小”这种因为 0 < 9 导致的诡异 bug。这个坑我踩过,当时调试了半天才反应过来是数值约定不一致。
2. 牌面编码与判定链路的细节拆解
2.1 点数映射表怎么建最不容易错
最直接的做法是写一个函数,里面用 if-else 分支:如果 rank 等于 1 返回 1,如果在 2 到 10 之间返回 rank,否则返回 10。这段逻辑没错,但每次调用都要走一遍分支判断,而且写起来啰嗦。更好的做法是建一张常量表,用 rank 直接当下标去取。
# 下标 0 占位不用,1..13 对应 A,2,3,...,10,J,Q,K CARD_POINT = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10]这张表的长度是 14,索引 0 是占位,索引 1 到 13 依次是 A 到 K。用的时候直接CARD_POINT[rank]就完事,一次数组访问,没有分支,也不会因为漏写某个 case 而出错。我特别推荐这种写法,因为它的错误面几乎为零:只要表本身写对了,后面怎么调用都不会错。而 if-else 版本容易在“10 到底归到哪一档”这种地方写错,尤其是边界值。
建表的时候有个细节值得说一下:J、Q、K 都映射成 10,而不是 11、12、13。这是牛几规则里最容易搞混的地方,很多人下意识觉得 J 是 11 点,结果算出来全是错的。为什么是 10?因为这套规则里所有“花牌”统一按 10 点计,本质上是为了让点数的取值范围收敛到 1 到 10,方便做模 10 运算。如果按 11、12、13 算,模 10 的周期性就被破坏了,整个判定逻辑都得推倒重来。
再补充一个校验点:如果你的输入可能来自外部(比如用户手输、文件解析),务必在入口处检查 rank 是否落在 1 到 13 之间。越界的值拿去查表会直接抛索引异常,或者更糟——在某些语言里静默取到错误的值。这类问题排查起来非常痛苦,因为报错位置和真正的错误源头隔了很远。
2.2 三张牌的组合枚举:下标法与掩码法
枚举 5 选 3,最直观的写法是三重循环加下标约束:
for i in range(3): for j in range(i + 1, 4): for k in range(j + 1, 5): s = pts[i] + pts[j] + pts[k]这三层循环的边界看着有点绕,其实规律很清楚:第一层的上界是 3,因为 i 最大只能取到 2(这样后面还剩两个位置);第二层的起点是 i+1,保证不重复;第三层的起点是 j+1。三层加起来正好产出 10 组下标,一一对应 C(5,3) 的十种组合。这种写法的好处是不依赖任何库,任何语言都能照搬,而且下标组合是确定的,便于调试时打印出来对照。
另一种写法是用位掩码:
TRIPLE_MASKS = [ 0b00111, 0b01011, 0b01101, 0b01110, 0b10011, 0b10101, 0b10110, 0b11001, 0b11010, 0b11100, ]每个掩码里有三个 1,代表选中哪三个位置。遍历这十个常量,对每个掩码做一次位与判断就能取出对应的三张牌。这种写法在需要频繁枚举、又想把循环展开的场景下很有用,而且掩码可以预先算好存成常量,运行时零开销。缺点是可读性稍差,第一次看的人得反应一下。
我更推荐下标法作为默认写法,掩码法留给性能敏感的场景。理由是可读性在这个问题上是压倒性的优势——这题的逻辑本来就绕,再叠一层位运算,维护成本会陡增。除非你确实要跑几百万手牌,否则三重循环的性能完全够用,现代语言的解释器处理十次加法加十次取模,快得可以忽略不计。
还有第三种写法,用标准库的组合生成器,比如 Python 的itertools.combinations。写起来最短,一行就能拿到所有组合。但它在每次调用时都要构造迭代器对象,在热路径上会有一定开销。做练习可以用,生产代码里如果调用量很大,还是手写循环更稳。
2.3 余下两张决定牛几,以及那个被忽略的恒等式
枚举到一组三张牌、和是 10 的倍数之后,接下来要算剩下两张的和。最直白的做法是把没用到的两个下标找出来,加一下,再取模。但这样写要多写一段“找出剩下两个下标”的逻辑,容易出错,也不优雅。
更好的做法是用总和减去已选三张的和:rest = total - s。这里total是五张牌点数的总和。这个减法天然避开了“找剩余下标”的麻烦,而且因为s是 10 的倍数,所以rest % 10恒等于total % 10。这个恒等式值得单独拎出来讲,因为它能省掉很多重复计算。
推导很简单:设total = s + rest,且s ≡ 0 (mod 10),那么total ≡ rest (mod 10)。也就是说,不管枚举到哪一组三张牌,最后算出来的牛几都是一样的,永远等于total % 10。这意味着什么?意味着我们根本不需要在枚举过程中记录“是哪三张牌成组”,只要确认“存在一组”就够了,结果值在枚举之前就已经确定了。
我第一次意识到这一点的时候还挺兴奋的,因为代码一下子简化了一半。原本我是这么写的:枚举、找到一组、算出 rest、取模、返回。后来变成:先算total % 10存起来,然后枚举只负责判断存在性,存在就返回存好的值。再后来发现,其实“先算”和“后算”在结果上没区别,但在语义上更清晰——判定分成“存在性检查”和“数值计算”两个独立的部分,各自都能单独测试。
不过有个例外要提醒:如果你需要在界面上展示“是哪三张牌成的组”(很多游戏界面确实要展示),那就不能只做存在性判断,得把下标记录下来。这时候建议专门开辟一个数组存结果,而不是在循环里写一堆break和标记变量。我见过用布尔标记 + 二次遍历的写法,能跑,但读起来很难受。
2.4 特殊牌型的优先级与短路顺序
标准牛几之外,通常还会挂几个特殊牌型,用来做大牌。常见的约定有这几种:五小牛,指五张牌点数都不超过 4 且总和不超过 10;炸弹,指五张里有四张点数相同;五花牛,指五张牌全是 J、Q、K。这些牌型的强度一般高于普通牛牛,具体的排序各地规则有差异,得在代码里做成可配置的,别写死在判断链条里。
关键在于短路顺序。如果一手牌既满足五小牛,又满足炸弹(比如四张 A 加一张 2),那应该判成哪个?这就取决于优先级配置了。我的做法是定义一个有序的规则列表,从高到低依次检查,第一个命中的直接返回,后面的不再检查。这样既保证了确定性,又避免了“多个条件同时成立时行为不确定”的问题。
RULES = [ ("五小牛", is_five_small), ("炸弹", is_bomb), ("五花牛", is_five_flower), ]每个is_xxx函数接收五张牌的点数数组,返回布尔值。这种结构的好处是扩展方便,想加新牌型就往列表里插一条,不用改主流程。而且每个判定函数都很短,单独测试也容易。
这里有个实操层面的坑:特殊牌型的判定通常要看原始点数,而不是映射后的点数。比如“五花牛”要求牌面是 J、Q、K,这三个在映射表里都变成 10 了,如果你用映射后的数组去判断,就分不清一张 10 和一张 K。所以判定函数必须拿原始 rank 数组,不能拿转换后的点数数组。这个区分我在早期版本里搞混过一次,导致一手带 K 的普通牌被误判成五花牛,查了半小时才发现是数据源用错了。
3. 从伪代码到能跑的实现
3.1 单次判定的核心函数
把前面的思路拼起来,一个完整的判定函数大概长这样:
CARD_POINT = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10] def judge_niu(ranks): """ranks: 长度为 5 的原始点数数组,取值 1..13""" pts = [CARD_POINT[r] for r in ranks] total = sum(pts) for i in range(3): for j in range(i + 1, 4): for k in range(j + 1, 5): if (pts[i] + pts[j] + pts[k]) % 10 == 0: r = total % 10 return 10 if r == 0 else r return 0返回值的约定是:0 表示无牛,1 到 9 表示牛一到牛九,10 表示牛牛。这个函数只有十几行,但每一行都有讲究。第一行做点数映射,第二行算总和,后面是三重循环加判定,最后一行处理“找不到”的情况。
有个细节值得单独说:return 10 if r == 0 else r这一句。为什么要把 0 改成 10?因为在这个约定里,0 被“无牛”占用了,而牛牛的强度实际上是最大的,必须给它一个比 9 大的值。如果直接在函数里返回 0,调用方就没法区分“无牛”和“牛牛”,这是最经典的语义冲突。
另一个细节是% 10 == 0这个写法。很多人会写成s == 10 or s == 20 or s == 30,逻辑上等价,但更容易漏。三张牌点数和的范围是 3 到 30,其中能成为 10 的倍数的只有 10、20、30 三个值。写成三个等号判断,一旦漏掉 30(也就是三张 10 点牌),就会漏判。用取模一句话解决,不用枚举具体值,也就没有漏的可能。这种“用数学性质代替枚举值”的写法,能显著降低出错率。
3.2 组合枚举的两种写法与性能对照
前面提了下标法和掩码法,这里做个实测对照。我在一台普通笔记本上用 Python 跑了一百万次判定,输入是随机生成的点数数组,两种写法的耗时差异大概在 15% 到 25% 之间,掩码法略快,但绝对值都很小——百万次调用大概在几秒这个量级。
这个量级说明什么?说明对于绝大多数应用场景(比如一个牌类小游戏的判定逻辑),性能根本不是瓶颈。真正的瓶颈往往在网络请求、界面渲染、数据读写这些地方。所以选型的时候,别为了那 20% 的枚举性能牺牲可读性,除非你用 profiler 明确测出来这里是热点。
如果你的场景确实需要极致性能,那思路不该是“换枚举写法”,而是“换算法”。前面提到的恒等式能帮你砍掉一半的计算:既然结果值恒等于total % 10,那循环里就只剩下存在性判断,可以写得极简。
def exists_triple(pts): for i in range(3): for j in range(i + 1, 4): for k in range(j + 1, 5): if (pts[i] + pts[j] + pts[k]) % 10 == 0: return True return False这个函数只返回布尔值,不涉及任何结果计算,逻辑纯粹,也更容易做内联优化。配合一个提前算好的total % 10,整体结构会清爽很多。我在后来的版本里就是拆成这两个函数,主流程负责调度,判定逻辑各自独立,测试起来也方便——存在性判断和数值计算可以分别写单元测试,互不干扰。
3.3 批量判定与预计算查表
如果要处理大量手牌,比如做数据分析或者跑批量测试,逐个调用判定函数的效率就不太够了。这时候可以用预计算查表的思路。
关键观察是:五张牌的点数组合(允许重复)一共只有 C(13+5-1, 5) = 6188 种。这个数字非常小,完全可以把所有组合的结果提前算好,存进字典,运行时只需要把五张牌的点数排个序,然后查表。
from itertools import combinations_with_replacement NIU_TABLE = {} for combo in combinations_with_replacement(range(1, 14), 5): NIU_TABLE[combo] = judge_niu(list(combo)) def judge_fast(ranks): pts = sorted(CARD_POINT[r] for r in ranks) return NIU_TABLE[tuple(pts)]这段代码的思路是:先把所有可能的点数组合穷举一遍,用慢速但正确的判定函数算出结果,建成表。运行时只需要把当前手牌点数排序、转成元组、查字典,三步搞定。排序五个数加一次哈希查找,比十次循环快得多,尤其是手牌数量上了百万级别之后。
有个细节要注意:查表用的键必须是映射后的点数,而不是原始 rank。因为原始 rank 有 13 种取值,而映射后的点数只有 1 到 10 这 10 种取值,用后者建表,组合数从 6188 降到 C(10+5-1,5)=2002 种,表更小,查得更快。当然,前提是判定逻辑里不涉及“是不是花牌”这类需要原始 rank 的信息;如果涉及,就得两套键都留着。
这个预计算的思路在很多牌类问题里都能复用。它的本质是把“运行时计算”换成“编译期(或启动期)计算”,用空间换时间。代价是启动时多花一点时间建表,收益是每次判定都变成 O(1) 的常数操作。对于判定规则固定、牌型空间有限的场景,这是性价比最高的优化手段。
3.4 测试用例怎么设计才算覆盖到位
判定逻辑最大的风险不是写不出来,而是写出来有隐藏的错,平时跑着没事,一遇到特定组合就翻车。所以测试用例的设计比代码本身更重要。我的做法是按“覆盖关键分支”来构造,而不是随机撒点。
| 用例 | 手牌点数 | 预期结果 | 覆盖点 |
|---|---|---|---|
| 典型成组 | 1, 4, 5, 2, 3 | 牛 5 | 非最小三张成组,验证贪心失效场景 |
| 三张 10 点 | 10, 10, 10, 1, 2 | 牛 2 | 验证 30 这个边界值没被漏掉 |
| 牛牛 | 2, 3, 5, 10, 10 | 牛牛 | 验证余数为 0 的映射 |
| 无牛 | 5, 5, 5, 5, 5 | 无牛 | 验证所有组合都不成组 |
| 单张 A | 1, 1, 1, 1, 1 | 无牛 | 最小点数组合,五小牛规则另算 |
| 花牌组合 | 11, 12, 13, 10, 1 | 牛 7 | 验证花牌统一按 10 点处理 |
| 重复点数 | 7, 7, 7, 7, 1 | 牛 1 | 验证同点数多张的处理 |
表格里每一行都对应一个具体的逻辑分支,跑通这七行,基本能覆盖 90% 以上的错误可能。特别是第一行和第二行,它们分别验证了“不能用贪心”和“不能漏掉 30”这两个最容易出问题的地方,务必重点跑。
除了功能用例,还建议加一组“对拍”测试:用两套实现(比如一个是三重循环版本,一个是查表版本)跑同一批随机输入,比对输出是否一致。这种测试能发现那些靠人工构造用例想不到的边界问题。我当时就是靠对拍发现了一个 bug:在点数数组含重复值的时候,某个版本的排序去重逻辑把两张相同的牌当成一张处理了,导致组合数不足。这种问题单靠看代码很难发现,一跑对拍就原形毕露。
4. 常见问题与排查技巧实录
4.1 我遇到过的高频错误清单
翻看自己前后几版的实现,以及帮别人看代码时发现的问题,出错频率最高的几类基本集中在映射和取模这两个环节。
第一类是花牌点数搞错。把 J 当成 11 点、Q 当成 12 点、K 当成 13 点,这是最普遍的问题。症状是:所有含花牌的手牌判定结果都偏大或者偏移,而且偏移量不固定,很难一眼看出规律。排查方法是单独打一张表,把 1 到 13 每个 rank 对应的点数打印出来,逐个核对。
第二类是忘了把余数 0 映射成牛牛。症状是:明明是一手牛牛,程序却输出 0,而 0 在约定里代表无牛,于是判定结果变成“无牛”。这个 bug 特别隐蔽,因为输出是个合法值,不会报错,只是语义错了。解决办法是在函数里就把映射做掉,不要留给调用方处理。
第三类是组合枚举漏了一组。三重循环的边界写错,比如第二层写成range(i, 4)而不是range(i+1, 4),就会产生重复组合;写成range(i+1, 3)就会漏掉。症状是某些本来有牛的手牌被判成无牛,而且往往只在特定组合下出现。排查方法是把十组下标打印出来,人工数一遍是不是恰好十个。
第四类是特殊牌型判定用错了数据源。前面提过,五花牛必须看原始 rank,如果拿映射后的点数数组去判断,就分不清 10 和 K。症状是普通牌被误判成特殊牌型,而且误判率跟花牌出现频率正相关。
第五类是输入校验缺失。rank 传进来是 0 或者 14,直接拿去查表就下标越界。这类问题在脚本里跑的时候可能表现为异常,但在某些语言里会静默截断,结果就是随机性的错误输出,极难定位。
4.2 定位错误的三步法
遇到“结果不对但不知道错在哪”的情况,我一般按这三步走,效率比盲改高得多。
第一步,把中间状态全部打出来。具体到这道题,就是把原始 rank 数组、映射后的点数数组、总和、每一组三张牌的下标和它们的和,全部打印一遍。很多时候错误在看到打印结果的瞬间就清楚了,根本不用往下查。这一步的关键是不要只打印最终结果,那等于什么信息都没有。
第二步,构造最小复现用例。如果错误只在某些手牌上出现,就把它缩到最小的手牌集合。比如发现错误总是出现在含重复点数的牌上,那就可以确定问题在去重或者枚举环节,范围一下子缩小了很多。最小复现用例还有一个好处:可以直接写进测试集,防止以后回归。
第三步,对照另一套实现。如果自己看不出问题,就换一种写法重写一遍判定逻辑,两套实现跑同样的输入,找出第一处输出不同的地方。这个方法的威力在于它不依赖你对错误的理解——你不需要知道哪里错了,只要知道两套结果不一致,就能顺藤摸瓜。我通常用查表版对照循环版,两套的算法路径完全不同,很难同时错在同一个地方。
4.3 问题速查表
把上面的经验整理成一张对照表,遇到问题的时候可以直接对号入座,省掉从头排查的时间。
| 现象 | 可能原因 | 快速验证方式 |
|---|---|---|
| 含花牌的结果普遍偏大 | J、Q、K 被映射成 11、12、13 | 打印点数映射表,核对 11 到 13 三项 |
| 牛牛被判成无牛 | 余数 0 没有映射成 10 | 构造一手确定的牛牛,看返回值 |
| 某些有牛的牌判成无牛 | 组合枚举漏组 | 打印十组下标,人工核对 |
| 结果随机波动 | 输入含越界值,查表取到脏数据 | 在入口处加断言,检查 rank 范围 |
| 特殊牌型误判率高 | 判定函数用了映射后的点数 | 检查判定函数的入参类型 |
| 大量重复组合被计算 | 循环边界写错,产生了重复下标 | 统计枚举次数,应为 10 |
| 批量处理速度慢 | 每手牌都重新枚举 | 改用排序加查表的预计算方案 |
这张表不需要背,遇到问题时扫一眼,基本能覆盖八成以上的常见故障。剩下的两成通常是业务规则理解偏差,比如某地规则里牛牛和炸弹的强弱顺序不同,这种就得回去翻规则文档了,代码层面反而没什么可查的。
5. 再往前一步的扩展方向
5.1 从五张牌到七张选五
很多牌类玩法不是固定五张,而是发七张、从中选五张组成最优牌型。这个变化看起来只是数量增加,实际复杂度上升得很快:从 7 张里选 5 张,组合数是 C(7,5)=21 种,每一种还要再跑一次五选三的判定。如果直接嵌套两层枚举,总共 21×10=210 次判定,虽然还是常数级,但代码结构的复杂度上去了。
更清晰的写法是分层:先写一个“给定五张牌返回牌型强度”的函数,再写一个“给定七张牌,遍历所有五张组合,返回强度最大者”的外层函数。两层职责完全分离,各自都能单独测试。这样即使以后改成九张选五,外层只需要换一个组合生成器,内层一行都不用动。
from itertools import combinations def best_of_seven(ranks): best = 0 for five in combinations(ranks, 5): best = max(best, judge_niu(list(five))) return best这段代码的正确性依赖于判定函数的返回值可以直接比较大小,也就是前面说的“0 到 10 的强度约定”。这个约定在这里体现出了价值:如果返回值是字符串,就没法直接比大小,还得额外写一个映射。所以数据结构的设计要往前多看一步,考虑到后续会被怎么用。
5.2 癞子牌与变体规则怎么接
有些玩法会引入万能牌,可以代替任意点数。这会让判定的性质发生根本变化:不再是“枚举所有组合看有没有满足条件的”,而是“在枚举过程中允许某些位置取任意值”。处理方式通常是把万能牌单独拎出来,在枚举时对它做特殊处理——要么在组合里把它替换成 1 到 10 的所有可能值,要么在存在性判断时放宽条件。
以前者为例,如果手里有一张万能牌,那枚举的时候就要把它展开成十种可能,组合数从 10 变成 100。数量仍然可控,但代码分支会变多。我的建议是把万能牌的处理封装成独立的一层,在进入判定之前先把它展开成一组候选手牌,然后对每个候选跑标准判定。这样标准判定的代码完全不用改,新增的复杂度被隔离在展开层。
规则变体还有一个常见差异是特殊牌型的强弱顺序。有的地方五小牛最大,有的地方炸弹最大。处理方式不是写死判断顺序,而是把顺序做成配置项,比如一个字符串列表,按列表顺序依次检查。这个改动很小,但对可维护性的提升很大,因为规则变动的时候只需要改配置,不用碰逻辑代码。
5.3 一些实战心得
写这道题最大的收获不是算法本身,而是对“数据结构选型要匹配问题规模”这件事有了更具体的体会。五张牌的判定,用数组加三重循环就够了,任何更复杂的数据结构都是过度设计。反过来,如果是七张选五、带万能牌、还要算最优牌型,那适当引入分层和配置就是必要的。判断标准不是“哪个更高级”,而是“当前问题的规模需要多复杂的结构”。
另一个体会是关于常量的。这道题里的 5 和 3 看起来是魔法数字,但它们其实是由规则固定的,不会变。这种情况下,直接写数字比定义一堆常量更清晰,因为读者看到 5 就知道是五张牌。但如果你的代码要支持多种玩法(比如三人斗牛、五人斗牛),那这些数字就该提出来做成参数。是不是要抽象,取决于它会不会变,而不是取决于它是不是数字。
最后分享一个调试时的小技巧:在判定函数里加一个可选参数,用来控制是否输出详细的判定过程。默认关掉,出问题的时候打开,就能看到每一组三张牌的和、是否成组、最终结果是怎么来的。这个开关只在开发阶段用,上线前记得关掉或者删掉,避免影响性能。我靠这个开关省下了大量在调试器里单步的时间,尤其是排查那些“只在特定输入下出现”的问题时,日志比断点好用得多。
回到最初那个问题——“这手牌是牛几?”现在你应该能从规则、编码、枚举、优化一路讲到怎么排查 bug 了。这道题真正的价值在于它足够小,小到你能在一小时内写完,又足够绕,绕到你能在里面踩到几乎所有组合枚举和模运算的坑。把这些坑踩一遍,下次遇到类似的问题,反应速度会完全不一样。