ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LeetCode 914卡牌分组:哈希计数与最大公约数算法精解

2026/8/25 5:26:01 拓冰建站 浏览量
LeetCode 914卡牌分组:哈希计数与最大公约数算法精解 如果你在准备算法面试或者正在刷LeetCode大概率会遇到这样一类题目题目描述看起来很简单甚至有点像小学数学题但当你真正动手写代码时却发现处处是坑一不小心就掉进“暴力解法”或“复杂度过高”的陷阱。力扣第914题“卡牌分组”就是这类题的典型代表。题目要求很简单给定一副牌每张牌上都有一个整数。你需要判断是否可以将整副牌分成若干组使得每组都有X张牌且每组内的牌数字都相同。听起来是不是像在玩“找规律”的游戏很多人的第一反应是统计每种数字出现的次数然后看看这些次数有没有一个大于1的公共约数。这个思路方向是对的但问题在于如何高效地求所有频次的最大公约数边界情况有哪些比如只有一种牌、频次为1、数组长度小于2等Python中有什么现成的工具可以简化计算更关键的是这道题考察的远不止是写对一个if-else。它背后串联了哈希计数、最大公约数计算、数学思维在算法中的应用等多个核心知识点。理解这道题你收获的不仅仅是一个“Accepted”而是一种将具体问题抽象为数学模型并用高效算法解决的能力。本文将带你彻底拆解LeetCode 914题。我们不只给出最终答案更会深入分析为什么“最大公约数”是这道题的最优解从数学原理上理解而不仅仅是记忆。如何用Python优雅地实现利用collections.Counter和math.gcd写出简洁高效的代码。有哪些容易忽略的“坑”我们将逐一分析并给出测试用例。如何举一反三理解这类“分组”问题的通用解题框架。无论你是正在入门算法的新手还是想巩固基础的进阶者这篇文章都将帮你把这道“简单”题吃透转化为实实在在的解题能力。1. 问题重述与核心难点分析首先我们严格定义一下题目LeetCode 914. X of a Kind in a Deck of Cards输入一个整数数组deck其中deck[i]表示第 i 张牌上的数字。输出一个布尔值。如果可以按要求分组则返回True否则返回False。分组规则将所有牌分成一组或多组。每组牌的数量X必须相同且X 2。每组内的所有牌其数字必须完全相同。示例 1输入deck [1,2,3,4,4,3,2,1] 输出true 解释可行的分组是 [1,1][2,2][3,3][4,4]示例 2输入deck [1,1,1,2,2,2,3,3] 输出false 解释没有满足条件的分组方式。初步思路与陷阱 很多人的第一直觉是模拟分组过程尝试所有可能的分组大小X。比如牌的总数是N那么X必须是N的约数且X2。然后对于每个X检查是否能将每种牌都恰好分成若干组每组X张。 这种方法理论可行但效率极低。假设牌有M种数字N张牌N的约数个数约为O(√N)每次检查需要遍历所有牌复杂度接近O(M * √N)。当N很大时比如10^4这个复杂度就很高了。核心难点在于我们不需要模拟分组过程而是要将问题转化为一个关于频次的数学问题。关键转化统计每种数字出现的次数得到一个频次列表counts。分组要求意味着对于任意一种数字它的出现次数count必须能被组大小X整除。因为这种数字要被分成若干组每组X张且都是这个数字。因此X必须是所有counts的公约数。同时X必须大于等于 2。所以问题转化为所有频次的最大公约数g是否大于等于 2如果g 2那么取X g就能满足分组条件因为g是所有counts的约数。如果g 1则不存在满足X2的公约数。至此我们将一个具体的分组问题抽象成了一个求多个整数最大公约数的数学问题。复杂度从模拟的O(N√N)降低到了统计频次O(N) 计算GCDO(M * log(min(count)))效率有了质的飞跃。2. 基础概念与数学原理在深入代码之前我们必须夯实两个核心概念最大公约数和它在本题中的应用逻辑。2.1 最大公约数 (Greatest Common Divisor, GCD)定义两个或多个整数共有约数中最大的一个。例如12 和 8 的公约数有 1, 2, 4其中最大的是 4所以gcd(12, 8) 4。特别地gcd(a, b, c) gcd(gcd(a, b), c)。这意味着多个数的最大公约数可以通过两两计算得到。在本题中的意义 假设我们有三种牌出现次数分别是 6, 9, 12。它们的最大公约数是gcd(6, 9, 12) 3。这意味着数字“6”可以分成 2 组6 / 3 2每组3张牌。数字“9”可以分成 3 组9 / 3 3每组3张牌。数字“12”可以分成 4 组12 / 3 4每组3张牌。所有分组大小X 3满足X 2。因此可以成功分组。如果频次是 2, 3, 5那么gcd(2, 3, 5) 1。因为1是任何整数的约数但题目要求X 2所以无法找到满足条件的分组大小。例如你想分成每组2张那么频次为3和5的牌就无法被2整除。2.2 欧几里得算法 (辗转相除法)这是计算两个数最大公约数最高效的算法之一。原理gcd(a, b) gcd(b, a mod b)直到余数为0此时的除数就是最大公约数。Python实现递归版def gcd(a, b): if b 0: return a return gcd(b, a % b)Python实现迭代版def gcd(a, b): while b: a, b b, a % b return a幸运的是Python标准库math模块提供了现成的math.gcd()函数它支持两个参数。对于多个数我们需要连续调用。2.3 问题抽象总结我们可以将解题流程总结为以下几步统计频次遍历牌组记录每个数字出现的次数。提取频次列表获得所有大于0的频次值。计算总GCD计算所有频次值的最大公约数。判断结果如果总GCD大于等于2返回True否则返回False。处理边界牌组总数小于2时无法组成至少2张的组直接返回False。这个思维框架是解决此类“均匀分组”问题的通用钥匙。3. 环境准备与Python工具在编写解题代码前确保你有一个可运行的Python环境。本题对环境要求极低。Python版本建议使用 Python 3.6 及以上版本。math.gcd函数在 Python 3.5 之后是标准库的一部分。本文代码在 Python 3.8 上测试通过。所需模块collections其中的Counter类是统计频次的神器。math提供gcd函数用于计算最大公约数。functools其中的reduce函数可以方便地对序列进行累积操作用于计算多个数的GCD。开发工具任何文本编辑器或IDE均可如VSCode, PyCharm, Jupyter Notebook。你也可以直接在LeetCode的在线编辑器里编写。验证方式除了在LeetCode提交你可以在本地编写测试用例进行验证。安装与检查 通常Python标准库无需安装。你可以通过以下命令快速检查python --version # 查看Python版本 python -c import collections, math, functools; print(Modules ready.) # 检查模块如果上述命令没有报错说明环境已就绪。4. 解题思路与步骤拆解让我们将第1、2节的理论转化为可执行的步骤。步骤1边界情况检查这是写出健壮代码的第一步。对于本题有两个明显的边界牌组总数小于2len(deck) 2。因为每组至少需要2张牌总牌数都不够一组直接返回False。牌组只有一种数字如果所有牌都相同那么只要牌数2显然可以分组一组或多组。这个情况会被我们后面的通用逻辑覆盖但提前考虑有助于理解。步骤2统计每种数字的出现频次我们需要一个高效的数据结构来计数。手动用字典(dict)循环是可以的但Python提供了更优雅的工具collections.Counter。from collections import Counter count_dict Counter(deck)Counter对象本质上是一个字典键(key)是牌的数字值(value)是该数字出现的次数。例如deck [1,1,2,2,2,3]则count_dict为{1: 2, 2: 3, 3: 1}。步骤3获取频次列表我们只关心频次值不关心具体的数字是什么。所以从Counter对象中提取所有的值(values)。counts list(count_dict.values())得到counts [2, 3, 1]。步骤4计算所有频次的最大公约数现在我们需要计算counts列表中所有整数的最大公约数。初始化将第一个频次作为当前的最大公约数current_gcd。迭代计算遍历剩余的频次将current_gcd与下一个频次计算最大公约数并更新current_gcd。数学依据gcd(a, b, c) gcd(gcd(a, b), c)。我们可以用循环实现也可以使用functools.reduce函数更简洁地实现累积计算。import math from functools import reduce final_gcd reduce(math.gcd, counts)reduce(function, sequence)会将函数function累积地应用在序列sequence上。例如reduce(math.gcd, [2, 3, 1])等价于math.gcd(math.gcd(2, 3), 1)。步骤5根据最大公约数判断结果如果计算出的final_gcd大于等于2说明存在一个满足条件的分组大小XX final_gcd或它的倍数返回True。否则返回False。一个重要的细节如果频次列表中有1那么任何数与1的最大公约数都是1。所以一旦有某种牌只出现了一次final_gcd必然为1直接导致返回False。这符合直觉一张孤牌无法和其他牌组成“数字相同的组”。5. 完整代码实现与逐行解析我们将上述步骤整合并添加详细的注释。这里提供两种风格一种是新手友好、步骤清晰的版本另一种是追求简洁、Pythonic的版本。版本一清晰详细版推荐新手学习from collections import Counter import math from functools import reduce class Solution: def hasGroupsSizeX(self, deck: List[int]) - bool: 判断牌组是否能按规则分组。 规则每组X张牌X2且组内牌数字相同。 参数: deck (List[int]): 牌组列表 返回: bool: 是否可以成功分组 # 步骤1: 边界情况检查 - 总牌数不足2张 if len(deck) 2: return False # 步骤2: 使用Counter统计每种数字的出现次数 # Counter 会返回一个字典例如 {1:3, 2:4, 3:2} counter Counter(deck) # 步骤3: 提取所有的频次数值组成列表 # 我们只关心每种牌有多少张不关心具体是数字几 counts list(counter.values()) # 步骤4: 计算所有频次的最大公约数(GCD) # 使用reduce对counts列表进行累积的gcd计算 # reduce(math.gcd, [a,b,c]) 等价于 math.gcd(math.gcd(a,b), c) total_gcd reduce(math.gcd, counts) # 步骤5: 判断结果 # 如果最大公约数大于等于2说明可以找到一个分组大小XX total_gcd # 使得每种牌都能被均匀分组 return total_gcd 2关键代码解析from collections import Counter导入计数工具。counter Counter(deck)一行代码完成整个数组的频次统计比手动写循环更高效、更不易出错。counts list(counter.values())将频次提取为列表。注意counter.values()返回的是一个视图(dict_values)用list()转换为列表是为了兼容性在某些情况下更安全。total_gcd reduce(math.gcd, counts)这是核心计算。reduce从counts的第一个元素开始依次与后面的元素计算最大公约数。如果counts为空理论上不会因为牌组不为空reduce需要提供初始值但本题情况不需要。return total_gcd 2最终的判断逻辑。total_gcd为1表示存在互质的频次无法找到X2的公约数。版本二简洁Pythonic版对于熟悉Python的开发者代码可以写得非常紧凑from collections import Counter import math from functools import reduce class Solution: def hasGroupsSizeX(self, deck: List[int]) - bool: # 一行代码版本包含了边界检查和核心逻辑 # 1. len(deck) 1 检查牌数 # 2. Counter(deck).values() 获取频次 # 3. reduce(math.gcd, ...) 计算总GCD # 4. ... 2 判断结果 return len(deck) 1 and reduce(math.gcd, Counter(deck).values()) 2这个版本将逻辑压缩到了一行利用了Python的短路求值and和函数的链式调用。虽然极其简洁但对于初学者来说可读性稍差。在面试或团队协作中版本一的清晰性更受青睐。版本三不使用reduce的循环版本如果你不熟悉functools.reduce可以用显式循环实现逻辑完全一样from collections import Counter import math class Solution: def hasGroupsSizeX(self, deck: List[int]) - bool: if len(deck) 2: return False counter Counter(deck) counts list(counter.values()) # 手动计算多个数的GCD # 先取第一个数作为初始GCD current_gcd counts[0] for cnt in counts[1:]: # 从第二个数开始遍历 current_gcd math.gcd(current_gcd, cnt) # 一个小优化如果中途发现GCD已经降到1可以提前结束 # 因为1和任何数的GCD都是1 if current_gcd 1: return False return current_gcd 2这个版本更清晰地展示了“累积计算”的过程并且加入了提前终止的优化if current_gcd 1: return False。这在频次很多且早期就出现互质数时能略微提升效率。6. 运行测试与效果验证编写完代码后必须用多种测试用例进行验证确保覆盖所有边界情况和常见陷阱。我们设计以下几组测试测试用例设计基本功能测试验证常规能分组和不能分组的情况。边界测试牌数少、频次为1、只有一种牌等。性能测试大数据量输入虽然本题限制不大但好习惯要保持。本地测试代码示例# 将上面的 Solution 类定义放在这里 def test(): sol Solution() # 测试1: 示例1 - 应该为True deck1 [1,2,3,4,4,3,2,1] print(f测试1 {deck1}: {sol.hasGroupsSizeX(deck1)} (预期: True)) assert sol.hasGroupsSizeX(deck1) True # 测试2: 示例2 - 应该为False deck2 [1,1,1,2,2,2,3,3] print(f测试2 {deck2}: {sol.hasGroupsSizeX(deck2)} (预期: False)) assert sol.hasGroupsSizeX(deck2) False # 测试3: 只有一种数字且数量2 - 应该为True deck3 [5,5,5,5] print(f测试3 {deck3}: {sol.hasGroupsSizeX(deck3)} (预期: True)) assert sol.hasGroupsSizeX(deck3) True # 测试4: 频次包含1 - 应该为False deck4 [1,1,2,2,3] # 频次: [2,2,1] print(f测试4 {deck4}: {sol.hasGroupsSizeX(deck4)} (预期: False)) assert sol.hasGroupsSizeX(deck4) False # 测试5: 频次互质最大公约数为1 - 应该为False deck5 [1,1,1,2,2,3,3,3,3] # 频次: [3,2,4], gcd(3,2,4)1 print(f测试5 {deck5}: {sol.hasGroupsSizeX(deck5)} (预期: False)) assert sol.hasGroupsSizeX(deck5) False # 测试6: 频次有大于2的公约数 - 应该为True deck6 [1,1,1,1,2,2,2,2,2,2] # 频次: [4,6], gcd(4,6)2 print(f测试6 {deck6}: {sol.hasGroupsSizeX(deck6)} (预期: True)) assert sol.hasGroupsSizeX(deck6) True # 测试7: 边界 - 只有一张牌 - 应该为False deck7 [7] print(f测试7 {deck7}: {sol.hasGroupsSizeX(deck7)} (预期: False)) assert sol.hasGroupsSizeX(deck7) False # 测试8: 边界 - 空牌组根据题意可能不出现但防御性编程 - 应该为False deck8 [] print(f测试8 {deck8}: {sol.hasGroupsSizeX(deck8)} (预期: False)) assert sol.hasGroupsSizeX(deck8) False # 测试9: 复杂情况频次多且公约数大 deck9 [1]*12 [2]*18 [3]*24 # 频次: [12,18,24], gcd6 print(f测试9 长度{len(deck9)}: {sol.hasGroupsSizeX(deck9)} (预期: True)) assert sol.hasGroupsSizeX(deck9) True print(所有测试用例通过) if __name__ __main__: test()运行与输出 将上述测试代码保存为test_leetcode914.py并运行你应该看到如下输出测试1 [1, 2, 3, 4, 4, 3, 2, 1]: True (预期: True) 测试2 [1, 1, 1, 2, 2, 2, 3, 3]: False (预期: False) 测试3 [5, 5, 5, 5]: True (预期: True) 测试4 [1, 1, 2, 2, 3]: False (预期: False) 测试5 [1, 1, 1, 2, 2, 3, 3, 3, 3]: False (预期: False) 测试6 [1, 1, 1, 1, 2, 2, 2, 2, 2, 2]: True (预期: True) 测试7 [7]: False (预期: False) 测试8 []: False (预期: False) 测试9 长度54: True (预期: True) 所有测试用例通过所有断言(assert)通过说明我们的代码逻辑是正确的。在LeetCode上提交 将Solution类的代码复制到LeetCode的编辑器中点击提交。通常你会看到运行时间在O(N)级别击败大部分用户。内存消耗主要取决于Counter字典的大小也是O(N)。结果Accepted。7. 常见问题与排查思路即使理解了算法在实现时也可能遇到一些问题。下表总结了常见错误及其解决方法问题现象可能原因排查方式解决方案返回True但预期False或反之1. 忽略了X2的条件。2. 边界情况处理不当如只有一张牌。3. 计算GCD的逻辑错误例如对单个数字求GCD。1. 检查最终判断是否是gcd 2。2. 在函数开头添加if len(deck) 2: return False。3. 打印中间变量counts和total_gcd的值。确保逻辑覆盖所有规则。使用第6节的测试用例进行验证。代码在特定用例上报错如reduce空序列输入牌组deck可能为空。虽然题目可能保证非空但防御性编程是好的。检查Counter(deck).values()是否可能为空。如果牌组为空Counter返回空字典values()为空。在调用reduce前检查counts列表是否为空。或者提前处理len(deck) 2的情况。时间复杂度太高大数据超时使用了暴力枚举分组大小X的方法尝试所有可能的X。审查算法。本题最优解是O(N M*logC)其中N是牌数M是数字种类C是最大频次。暴力法是O(N√N)。切换到基于最大公约数(GCD)的数学解法。math.gcd报错或找不到Python版本低于3.5。math.gcd在Python 3.5中引入。在命令行运行python --version查看版本。升级Python版本或自己实现一个gcd函数如2.2节的欧几里得算法。对于频次列表[6, 9, 12]返回False计算多个数GCD的方式错误。错误地计算了gcd(6, 9)3, 然后计算gcd(12, 9)3但误判了结果。确认多个数GCD的计算顺序gcd(gcd(a,b), c)。使用reduce(math.gcd, counts)可以避免顺序错误。使用reduce或正确的循环累积方法。内存使用过高使用了不必要的数据结构或者Counter统计的键非常多数字范围极大且稀疏。本题输入限制通常较小1 deck.length 10^4Counter内存开销可以接受。如果数字范围极大考虑使用数组计数如果数字范围已知且不大。通常无需优化。如果数字范围已知在[0, K]可以用[0]*(K1)数组代替Counter。一个典型的思维陷阱 有同学会想“我先求总牌数N然后找出N的所有大于等于2的约数再逐个尝试是否所有频次都能被其整除。” 这个思路是对的但效率不如求频次的GCD。因为求N的约数需要O(√N)时间。对每个约数X检查所有频次需要O(M)时间。总时间O(M√N)。 而求GCD的方法计算所有频次的GCD时间复杂度约为O(M * log(min(count)))通常更优。当问题可以转化为数学性质时先尝试数学解法往往是更优的。8. 最佳实践与进阶思考掌握了这道题的基础解法后我们可以从工程和算法两个角度思考如何做得更好。8.1 工程最佳实践防御性编程始终检查输入边界。即使题目有假设好的习惯是在函数开始处检查deck的长度。善用标准库collections.Counter和math.gcd是Python标准库的利器它们经过高度优化比自己实现更可靠、更高效。代码可读性在追求简洁如一行代码版和清晰如详细注释版之间取得平衡。对于团队项目或面试清晰性优先。可以在清晰的基础上通过提取函数来简化主逻辑。添加类型注解如def hasGroupsSizeX(self, deck: List[int]) - bool:。这提高了代码的可读性和可维护性现代IDE也能提供更好的支持。8.2 算法进阶与变种这道题的本质是判断一组正整数是否存在一个大于1的公约数。我们可以思考一些变种变种1每组数量X必须等于一个特定值K如果题目改为“是否可以分为若干组每组恰好K张相同数字的牌”那么问题就简化为检查所有频次是否都能被K整除。即all(cnt % K 0 for cnt in counts)。变种2求所有可能的分组大小X如果题目要求返回所有可能的X满足X2且能整除所有频次那么答案就是所有频次的最大公约数g的所有大于等于2的约数。from math import gcd, isqrt from functools import reduce def all_group_sizes(deck): if len(deck) 2: return [] counts list(Counter(deck).values()) g reduce(gcd, counts) if g 2: return [] # 找出g的所有大于等于2的约数 divisors set() for i in range(1, int(isqrt(g)) 1): if g % i 0: if i 2: divisors.add(i) if g // i 2: divisors.add(g // i) return sorted(divisors)变种3频次非常大时的优化当频次数值极大时比如超过10^9虽然math.gcd依然高效但我们可以考虑一个优化因为gcd(a,b) min(a,b)如果在计算过程中当前gcd已经降到1可以立即返回False无需继续计算后面的频次。这在频次列表很长且早期就出现互质数时有效。我们在版本三的循环中已经实现了这个优化。8.3 数学思维的培养“卡牌分组”这类题是典型的数学建模算法题。它的解题过程启示我们不要急于编码先花时间分析问题本质寻找数学规律或性质。将具体问题抽象为数学模型如本题的“公约数”模型往往是突破的关键。掌握基础数论最大公约数、最小公倍数、质因数分解等基础数论知识在算法题中频繁出现。math.gcd和math.lcmPython 3.9是必备工具。从暴力法到优化先思考最直接的暴力解法然后分析其瓶颈再寻找优化点。暴力解法枚举X能帮你理解问题而优化解法求GCD则体现了算法的价值。8.4 关联题目推荐为了巩固此类问题的解法建议练习以下LeetCode题目它们都涉及类似的“分组”、“整除”、“公约数/公倍数”思想LeetCode 365. 水壶问题判断能否用两个水壶得到目标水量本质是裴蜀定理线性丢番图方程。LeetCode 1497. 检查数组对是否可以被 k 整除分组配对问题需要用到余数统计和配对思想。LeetCode 1010. 总持续时间可被 60 整除的歌曲寻找配对利用余数进行计数。LeetCode 2344. 使数组可以被整除的最少删除次数涉及最大公约数的操作。通过这道“卡牌分组”题我们不仅学会了一个具体的解法更重要的是掌握了一种将现实约束转化为数学条件并利用高效算法Countergcd求解的思维模式。这种模式在解决许多中等难度算法题时都非常有用。下次再遇到类似“能否均匀分组”的问题时你的第一反应就应该是先统计频次再看这些频次之间是否存在大于1的公约数。这就是刷题的意义——积累可复用的解题模式和思维框架。