力扣914题卡牌分组:巧用最大公约数解决算法面试高频题 在算法面试和日常刷题中我们常常会遇到一些看似简单实则暗藏数学玄机的题目。力扣LeetCode第914题「卡牌分组」就是这样一个典型。题目描述很简单给定一副牌每张牌上都写着一个整数。你需要判断是否可能将这些牌分成若干组使得每组都有X张牌并且每组内的牌数字都相同。其中X是一个大于等于2的整数。很多同学的第一反应可能是暴力枚举所有分组可能性但这样时间复杂度会非常高。实际上这道题的核心在于对数字出现频次的分析并巧妙地运用数学中的最大公约数GCD概念。本文将带你从零开始深入剖析这道题的解题思路并用 Python 实现一个高效优雅的解法。无论你是正在准备面试还是想提升自己的算法思维这篇文章都将为你提供清晰的路径和可运行的代码。1. 问题背景与核心概念拆解在开始编码之前我们首先要彻底理解题目在问什么以及它背后考察的知识点。1.1 题目重述与示例分析题目描述给定一副牌一个整数数组deck其中deck[i]表示第 i 张牌上的数字。 如果存在一个整数XX 2使得可以将整副牌分成1 组或多组。每组都有恰好 X 张牌。每组内的所有牌上都写着相同的整数。则返回true否则返回false。示例 1输入deck [1,2,3,4,4,3,2,1] 输出true 解释可行的分组是 [1,1][2,2][3,3][4,4]这里每种数字1,2,3,4都出现了2次。我们可以取 X2将相同的数字两两分成一组。示例 2输入deck [1,1,1,2,2,2,3,3] 输出false 解释没有满足要求的分组。数字1、2、3都出现了3次。虽然每个数字的出现次数相同3次但3是一个质数且大于等于2。看似可以 X3每组三张相同数字的牌。但是题目要求是分成“若干组”并没有说所有牌必须用完且只属于一个组吗仔细读题“分成若干组”意味着所有牌都必须被分配到一个组里且每个牌只能属于一个组。所以对于[1,1,1,2,2,2,3,3]我们有三张1三张2两张3。由于3的数量只有2张无法组成一个X3的组。如果我们尝试X2那么1和2各有三张三张牌无法被均匀地分成若干组每组2张总会多出一张。所以不行。示例 3输入deck [1] 输出false 解释无法分成 X2 的组。牌数太少无法分组。示例 4关键输入deck [1,1,2,2,2,2] 输出true 解释可行的分组是 [1,1], [2,2], [2,2]数字1出现2次数字2出现4次。我们可以取 X2。对于数字12次正好组成1组2张。对于数字24次可以组成2组每组2张。所有牌都被妥善分组。通过这几个例子我们可以提炼出问题的关键问题转化为了对数组中每个数字出现次数的分析。1.2 核心思路与数学模型我们不再关注牌面上的具体数字是什么只关心每个数字出现的次数频率。设总共有N种不同的数字它们的出现次数分别为C1, C2, C3, ..., CN。 我们需要找到一个整数XX 2使得每一个次数 Ci 都能被 X 整除。为什么如果Ci能被X整除那么对于数字i它就可以被分成Ci / X组每组X张牌。必须所有的Ci都能被X整除才能保证每一种数字都能被完整地分组不会有剩余的单张牌。因此问题转化为是否存在一个整数 X 2它是所有出现次数C1, C2, ..., CN的公约数。更进一步如果存在这样的X那么所有次数的最大公约数g一定也满足条件因为最大公约数也是公约数。并且只要g 2我们就可以取X g从而满足题意。如果g 1说明所有次数的最大公约数是1不存在大于等于2的公约数则返回false。结论计算所有数字出现次数的最大公约数Greatest Common Divisor, GCD。如果最大公约数gcd 2则返回true否则返回false。这就是本问题的数学本质。算法步骤非常清晰统计每个数字的出现次数。计算所有次数的最大公约数。判断最大公约数是否大于等于2。2. 环境准备与工具解决这个问题我们只需要一个基础的 Python 环境。不需要任何第三方库。编程语言 Python 3.6 或以上版本本文代码兼容 Python 3。开发工具 任何你喜欢的编辑器或 IDE如 VS Code, PyCharm, 甚至是在线的 LeetCode 编辑器。关键知识 Python 基础语法、字典或collections.Counter的使用、循环、以及如何计算最大公约数math.gcd。确保你的 Python 已正确安装。可以在命令行输入python --version或python3 --version检查。3. 核心算法与代码实现我们将分步骤实现上述算法思路并逐步优化代码。3.1 步骤一统计出现次数在 Python 中统计一个列表中元素出现次数的最简单方法是使用collections.Counter。它是一个字典子类专门用于计数。from collections import Counter deck [1,2,3,4,4,3,2,1] counter Counter(deck) print(counter) # 输出Counter({1: 2, 2: 2, 3: 2, 4: 2})counter是一个字典键是牌的数字值是该数字出现的次数。我们只需要这些次数值。如果不使用Counter也可以用普通字典手动统计def count_manually(deck): count_dict {} for num in deck: count_dict[num] count_dict.get(num, 0) 1 return list(count_dict.values()) deck [1,2,3,4,4,3,2,1] frequencies count_manually(deck) print(frequencies) # 输出[2, 2, 2, 2]3.2 步骤二计算所有次数的最大公约数GCD计算两个数的最大公约数Python 的math模块提供了gcd函数Python 3.5 在math模块之前版本在fractions模块。计算多个数的最大公约数可以迭代计算gcd(a, b, c) gcd(gcd(a, b), c)。计算多个数 GCD 的函数import math from functools import reduce def gcd_of_list(nums): 计算整数列表 nums 中所有数字的最大公约数 return reduce(math.gcd, nums) # 示例 nums [2, 4, 6, 8] print(gcd_of_list(nums)) # 输出2 nums [3, 6, 9] print(gcd_of_list(nums)) # 输出3 nums [3, 5, 7] print(gcd_of_list(nums)) # 输出1这里使用了functools.reduce函数它将math.gcd依次作用于列表中的元素最终得到所有数的 GCD。如果不熟悉reduce也可以用循环实现import math def gcd_of_list_loop(nums): if not nums: return 0 # 空列表按题目不会出现 result nums[0] for num in nums[1:]: result math.gcd(result, num) if result 1: # 提前终止优化 break return result3.3 步骤三整合与判断现在将前两步结合起来并判断最终结果。完整函数实现from collections import Counter import math from functools import reduce class Solution: def hasGroupsSizeX(self, deck): :type deck: List[int] :rtype: bool # 1. 统计次数 counter Counter(deck) # 2. 获取所有出现次数 frequencies list(counter.values()) # 3. 计算所有次数的最大公约数 gcd_all reduce(math.gcd, frequencies) # 4. 判断最大公约数是否 2 return gcd_all 2让我们用之前的例子测试一下solution Solution() print(solution.hasGroupsSizeX([1,2,3,4,4,3,2,1])) # True print(solution.hasGroupsSizeX([1,1,1,2,2,2,3,3])) # False print(solution.hasGroupsSizeX([1])) # False print(solution.hasGroupsSizeX([1,1,2,2,2,2])) # True输出与预期一致。3.4 代码优化与边界情况处理上面的代码已经可以正确工作但我们还需要考虑一些边界情况和进行微优化。牌数少于2张 如果牌的总数少于2张无论如何也无法分成每组至少2张牌可以直接返回False。这是一个有效的提前判断。频率列表长度为1 如果只有一种数字例如[5,5,5]那么它的出现次数就是列表长度。我们只需要判断这个次数是否大于等于2。实际上计算单个数的 GCD 就是它本身所以我们的通用算法也能处理但提前判断可以稍微优化。使用math.gcd处理0math.gcd(0, a)返回abs(a)。在我们的场景中频率列表不会包含0所以是安全的。提前终止 在计算 GCD 的过程中一旦中间结果变为1就可以提前返回False因为1和任何数的 GCD 都是1。优化后的最终版本from collections import Counter import math from functools import reduce class Solution: def hasGroupsSizeX(self, deck): :type deck: List[int] :rtype: bool # 优化1: 如果牌数少于2直接返回False if len(deck) 2: return False # 统计频率 freq list(Counter(deck).values()) # 优化2: 如果只有一种数字直接判断其数量是否2 # 这一步不是必须的因为gcd计算也能得出正确结果但逻辑更清晰 if len(freq) 1: return freq[0] 2 # 计算所有频率的最大公约数 # 使用reduce和math.gcd gcd_all reduce(math.gcd, freq) # 判断结果 return gcd_all 2这个版本考虑了边界情况逻辑更健壮。4. 算法复杂度分析理解算法效率是面试中的重要环节。时间复杂度O(N K log M)N是牌的数量len(deck)。Counter(deck)需要遍历整个数组一次时间复杂度 O(N)。K是不同数字的种类数即freq列表的长度。计算K个数的最大公约数需要调用K-1次math.gcd函数。每次math.gcd计算的时间复杂度可以认为是 O(log min(a,b))其中 a, b 是输入的两个数。在最坏情况下这个值不会很大因为频率值最大为 N。总体可以近似为 O(K log M)M 是频率的平均值。因此总时间复杂度为 O(N K log M)这在实际应用中是非常高效的可以轻松处理大规模数据LeetCode 典型约束下 N 10000。空间复杂度O(K)主要用于存储Counter字典其大小等于不同数字的种类数K。在最坏情况下如果所有牌数字都不同则 K N空间复杂度为 O(N)。但通常 K 远小于 N。5. 深入理解为什么是最大公约数有些同学可能会问为什么找到最大公约数就行如果最大公约数是1但存在另一个大于1的公约数呢 这是一个很好的问题触及了数学的基本原理。反证法假设所有频率值的最大公约数是g且g 1。 根据最大公约数的定义g1意味着这些频率值互质不一定两两互质但整体互质。也就是说不存在一个大于1的整数能同时整除所有这些频率值。 因此不可能存在一个X 2能整除每一个频率值。 所以g1时答案必定为False。反之如果g 2那么g本身就是一个大于等于2的整数并且它能整除每一个频率值。因此取X g就满足题目条件答案为True。结论判断最大公约数是否大于等于2是解决此问题的充分必要条件。6. 常见错误与排查思路在实现过程中可能会遇到一些典型的错误。问题现象常见原因解决思路输出始终为True或False1. 误用了min而不是gcd。2. 只判断了是否有数字出现次数小于2而没判断所有次数的公约数。回顾问题本质需要的是所有次数的公约数而不是最小值。使用math.gcd进行计算。对于[1,1,2,2,2,2]返回False手动计算 GCD 的逻辑有误例如错误地认为 GCD 必须是所有数的约数这没错但实现时用了取余判断所有数是否能整除第一个数。GCD 是能整除所有数的最大数。计算多个数的 GCD 应使用迭代法gcd(a,b,c) gcd(gcd(a,b), c)。使用reduce(math.gcd, list)。处理空输入或单张牌时出错没有考虑边界情况直接对频率列表求 GCD可能遇到列表为空或除零错误。在函数开始处添加边界判断if len(deck) 2: return False。时间复杂度太高超时可能尝试了暴力枚举所有可能的 X从2到 min(freq)然后检查每个 X 是否能整除所有频率。该方法最坏复杂度为 O(K * min(F))当 min(F) 很大时效率低。应转换为求 GCD 问题复杂度显著降低。使用numpy.gcd或自定义 GCD 函数出错环境没有numpy或自定义的 GCD 函数如欧几里得算法实现有误。优先使用 Python 标准库math.gcd。确保自定义函数能正确处理非正整数本题频率都是正整数。调试技巧打印中间变量在计算前后打印deck,counter,frequencies,gcd_all看是否符合预期。def hasGroupsSizeX(self, deck): print(输入牌组:, deck) counter Counter(deck) print(计数结果:, counter) freq list(counter.values()) print(频率列表:, freq) gcd_all reduce(math.gcd, freq) print(最大公约数:, gcd_all) return gcd_all 2编写单元测试针对题目给出的示例和自定义的边界案例进行测试。在 LeetCode 讨论区对比如果结果不对可以对比其他人的题解看看思路差异在哪里。7. 最佳实践与扩展思考掌握了基础解法后我们可以从工程和算法角度思考更多。7.1 代码风格与可读性函数命名hasGroupsSizeX是 LeetCode 预设的函数名在实际项目中可以命名为更易懂的名字如can_group_cards。添加注释关键步骤尤其是涉及数学原理的部分添加简要注释。使用类型提示Python 3.5提高代码可读性和可维护性。from typing import List from collections import Counter import math from functools import reduce class Solution: def hasGroupsSizeX(self, deck: List[int]) - bool: 判断一副牌是否能按规则分组。 规则每组X张牌X2且组内牌数字相同。 if len(deck) 2: return False freq list(Counter(deck).values()) # 计算所有出现次数的最大公约数 gcd_all reduce(math.gcd, freq) return gcd_all 27.2 算法扩展与变种如果要求每组牌数 X 必须相同且每组牌的数字可以不同这是原题的一个变种。例如牌为[1,1,2,2,3,3,3,3]是否可以分成若干组每组3张牌组内牌数字可以混合这变成了一个完全不同的问题可能涉及图论或贪心算法不再是简单的求 GCD。如果要求找出所有可能的分组大小 X在原题基础上不仅判断是否存在还要列出所有可能的 X。那么答案就是所有频率值的所有大于等于2的公约数。可以先求出最大公约数g然后找出g的所有大于等于2的因子。def find_all_possible_X(deck): from collections import Counter import math from functools import reduce if len(deck) 2: return [] freq list(Counter(deck).values()) gcd_all reduce(math.gcd, freq) if gcd_all 2: return [] # 找出 gcd_all 的所有大于等于2的因子 possible_x [] for i in range(2, int(math.sqrt(gcd_all)) 1): if gcd_all % i 0: possible_x.append(i) if i ! gcd_all // i: # 避免重复添加平方根 possible_x.append(gcd_all // i) # 添加自身如果大于等于2 if gcd_all 2: possible_x.append(gcd_all) possible_x.sort() return possible_x # 测试 deck [1,1,2,2,2,2] # 频率 [2,4], gcd2 print(find_all_possible_X(deck)) # 输出 [2] deck [1,1,1,1,2,2,2,2,3,3,3,3] # 频率 [4,4,4], gcd4 print(find_all_possible_X(deck)) # 输出 [2, 4]7.3 在实际项目中的应用思维虽然“卡牌分组”是一个抽象的算法题但其核心思想——通过统计频率并分析频率之间的数学关系来解决问题——在实际软件开发中很有用。资源分配假设有若干种任务数字每种任务有若干实例出现次数。需要将实例分配到若干个相同的执行单元组中每个单元处理固定数量的实例X且一个单元只处理同种任务。判断是否存在这样的分配方案。这本质上就是卡牌分组问题。数据分片在分布式存储或计算中需要将数据项牌根据其键数字分布到不同的分片组中且希望每个分片负载均衡数量为X。判断是否存在一个统一的分片大小能满足数据分布约束。编码与压缩某些编码方案要求将相同符号分组打包。判断给定符号频率下是否存在固定长度的打包方案。理解这类问题的数学转化能力能帮助你在遇到复杂业务逻辑时找到简洁高效的解决方案。8. 总结与刷题建议力扣第914题「卡牌分组」是一道非常好的题目它巧妙地将一个分组问题转化为求最大公约数的数学问题。回顾解题关键问题转化将“按相同数字分组”转化为“分析数字出现频率”。数学建模判断是否存在X2使得所有频率都能被X整除 ⇔ 所有频率的最大公约数gcd 2。工具使用熟练运用collections.Counter统计频率math.gcd和functools.reduce计算多个数的最大公约数。边界处理考虑牌数过少、只有一种数字等特殊情况。给刷题者的建议不要急于编码面对新题目先仔细阅读并用自己的话复述题意通过多个例子验证理解。像本题示例2和示例4就是理解的关键。寻找规律与转化很多算法题的本质是数学问题或经典计算机科学问题的变体。尝试忽略具体场景抽象出核心数据模型本题中的频率数组。掌握基础工具Python 的Counter,defaultdict,math.gcd,reduce等工具能极大简化代码。熟悉它们能让你在面试中快速实现思路。复杂度分析即使题目通过也要习惯性分析时间、空间复杂度并思考是否有优化空间。举一反三解决一道题后思考它的变种如本文7.2节或者寻找类似解题模式的题目如与“最大公约数”、“频率统计”相关的题目。希望这篇详细的解析能帮助你彻底掌握这道题。算法学习是一个积累的过程从理解每一道题背后的思想开始逐步构建自己的知识体系。如果你在刷题中遇到其他问题欢迎在评论区交流讨论。