ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode 914卡牌分组:从暴力枚举到GCD数学解法的深度解析

LeetCode 914卡牌分组:从暴力枚举到GCD数学解法的深度解析 如果你在刷LeetCode时看到“卡牌分组”这道题第一反应是不是觉得这题简单不就是统计一下数字频率然后找找最大公约数吗很多题解也确实只给出了这个“标准答案”。但如果你只停留在“AC通过”可能就错过了这道题背后最核心的算法思维训练价值——它远不止是调用一次math.gcd那么简单。这道题真正的难点不在于写出代码而在于如何从问题描述中抽象出“最大公约数”这个数学模型。很多初学者卡在“如何分组”的具体实现上却忽略了题目最关键的约束“每组都有X张牌且组内牌的数字相同”。这个约束直接翻译过来就是要求所有数字出现的次数必须有一个大于1的公共因子。能想到这一点问题就解决了一大半。本文将带你彻底吃透力扣第914题“卡牌分组”。我不会只给你一个最终代码而是会拆解整个思考过程从暴力枚举的直觉开始一步步推导出最优的数学解法。你会看到如何用Python的collections.Counter高效统计频率如何用欧几里得算法求解最大公约数以及如何处理边界情况。更重要的是我会分享如何将这种“问题抽象为数学约束”的思维应用到其他算法题目中让你真正提升解题能力而不仅仅是背答案。1. 问题重述与核心难点分析原题描述LeetCode 914. 卡牌分组给定一副牌每张牌上都写着一个整数。此时你需要选定一个数字X使得我们可以将整副牌按下述规则分成 1 组或更多组每组都有X张牌。组内所有的牌上都写着相同的整数。仅当你可选的X 2时返回true。示例 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解释没有满足要求的分组。第一层理解分组规则规则很明确我们要把所有的牌分成若干组。每个组必须满足两个条件组的大小是同一个数XX 2。组内所有牌的数字相同。这意味着数字1的所有牌必须能完整地分到若干个大小为X的组里数字2的所有牌也必须能完整地分到若干个大小为X的组里以此类推。不能有牌剩下也不能把不同数字的牌混在一个组。第二层理解转化为数学模型设数字i在牌堆中出现的次数为count_i。 为了能把所有数字i的牌正好分完count_i必须能被X整除。即count_i % X 0对所有数字i都成立。因此问题转化为是否存在一个整数X 2使得所有数字的出现次数count_i都是X的整数倍第三层理解进一步抽象为最大公约数“所有count_i都是X的整数倍” 这句话等价于X是所有count_i的公约数。 我们需要找的是一个2的公约数。那么所有count_i的最大公约数Greatest Common Divisor, GCD就是一个非常关键的指标。记为g。如果g 2那么g本身就是一个满足条件的X因为g是公约数所以每个count_i都能被g整除。同时g的任意大于等于2的因子例如g本身也都是符合条件的X。如果g 1这意味着所有count_i互质它们没有大于1的公共因子因此不可能找到一个X 2使得所有count_i都能被整除。至此问题的核心解法浮出水面计算所有数字出现次数的最大公约数g判断g是否大于等于2。很多初学者无法独立完成第二层到第三层的思维跳跃这正是本文要帮你打通的关键。2. 从暴力枚举到数学优化思维路径演示在得到最优解之前我们先看看最直接的思路理解其局限性才能更好地欣赏数学解法的优雅。2.1 暴力枚举法理解思路不可行最朴素的思路枚举所有可能的分组大小X。统计每个数字的频率得到列表counts。因为每组至少2张牌最多不可能超过牌的总数所以X的取值范围是[2, min(counts)]因为每个数字的牌数至少要够分一组。对于每一个X检查是否所有count_i都能被X整除。如果找到一个X满足条件返回true否则返回false。from collections import Counter def hasGroupsSizeX_bruteforce(deck): # 统计频率 counter Counter(deck) counts list(counter.values()) # 找到最小的出现次数作为X的上限 min_count min(counts) if min_count 2: return False # 枚举所有可能的X for X in range(2, min_count 1): # 检查所有count是否能被X整除 if all(c % X 0 for c in counts): return True return False # 测试 print(hasGroupsSizeX_bruteforce([1,2,3,4,4,3,2,1])) # 应输出 True print(hasGroupsSizeX_bruteforce([1,1,1,2,2,2,3,3])) # 应输出 False复杂度分析时间复杂度O(n m * k)其中n是牌的数量统计频率m是min(counts)k是不同数字的种类数。在最坏情况下例如所有牌数字相同min_count等于n复杂度接近 O(n²)对于大数据量会超时。空间复杂度O(k)用于存储频率字典。为什么暴力法不够好当牌的数字种类很少但某一种牌数量极大时例如deck [1, 1, 1, 1, 1, 1, 1, 1]min_count会很大导致枚举次数极多。LeetCode 的测试用例会包含这种情况暴力法容易超时。2.2 数学解法最大公约数GCD我们不再枚举X而是直接计算所有count_i的最大公约数g。如果g 2返回True。如果g 1返回False。计算多个数的最大公约数可以先计算前两个数的 GCD然后用这个结果与第三个数计算 GCD依次类推。即gcd(a, b, c) gcd(gcd(a, b), c)。Python 的math模块提供了gcd函数Python 3.5 在math 3.9 支持多参数math.gcd(*integers)。对于更早版本或需要计算多个数的情况我们可以用functools.reduce。from collections import Counter import math from functools import reduce def hasGroupsSizeX(deck): 判断牌堆是否能按规则分组。 核心计算所有数字出现次数的最大公约数判断是否大于等于2。 # 1. 统计频率 counter Counter(deck) # 获取所有出现次数 counts list(counter.values()) # 2. 计算所有counts的最大公约数 # 使用reduce依次计算gcd: gcd(gcd(a, b), c)... # 注意math.gcd(0, a) a所以初始值设为0是安全的 g reduce(math.gcd, counts) # 3. 判断最大公约数是否大于等于2 return g 2 # 测试 print(hasGroupsSizeX([1,2,3,4,4,3,2,1])) # True print(hasGroupsSizeX([1,1,1,2,2,2,3,3])) # False print(hasGroupsSizeX([1])) # False (只有一张牌counts[1], gcd1) print(hasGroupsSizeX([1,1])) # True (counts[2], gcd2) print(hasGroupsSizeX([1,1,2,2,2,2])) # True (counts[2,4], gcd2)复杂度分析时间复杂度O(n k * log(min(counts)))。统计频率 O(n)计算 k 个数的 GCD每次 GCD 计算复杂度约为 O(log(min(a, b)))。整体效率远高于暴力枚举。空间复杂度O(k)用于存储频率字典。3. 环境准备与代码详解3.1 Python环境与必要知识Python版本建议使用 Python 3.5 或更高版本以确保math.gcd函数可用。本文代码在 Python 3.8 环境下测试通过。必要模块collections.Counter用于高效统计可哈希对象的频率。它是dict的子类。math.gcd用于计算两个整数的最大公约数。functools.reduce用于将两个参数的函数gcd累积地应用到counts列表的所有元素上。如果你对reduce不熟悉这里有一个简单的等价实现def hasGroupsSizeX_manual(deck): from collections import Counter import math counter Counter(deck) counts list(counter.values()) # 手动计算多个数的gcd if not counts: return False g counts[0] for c in counts[1:]: g math.gcd(g, c) # 提前退出优化如果gcd已经为1不可能再变大 if g 1: break return g 23.2 核心代码逐行解析让我们回到最优解的代码进行详细拆解from collections import Counter import math from functools import reduce def hasGroupsSizeX(deck): # 步骤1频率统计 # Counter(deck) 会返回一个字典键是牌的数字值是该数字出现的次数。 # 例如 deck [1,2,3,4,4,3,2,1] # counter - {1: 2, 2: 2, 3: 2, 4: 2} counter Counter(deck) # 步骤2提取频率值 # 我们只关心次数不关心具体是哪个数字。 # counts - [2, 2, 2, 2] counts list(counter.values()) # 步骤3计算所有频率的最大公约数(GCD) # reduce(function, iterable, initializer) # 这里 function 是 math.gcd iterable 是 counts。 # reduce 的工作过程 # 初始 current_gcd counts[0] (如果counts非空) # 然后 current_gcd gcd(current_gcd, counts[1]) # current_gcd gcd(current_gcd, counts[2]) # ... # 最终得到所有counts的gcd。 # 注意math.gcd(0, a) a所以即使counts为空或只有一个元素逻辑也是安全的。 # 但题目保证deck至少有一张牌所以counts不会为空。 g reduce(math.gcd, counts) # 步骤4判断并返回 # 如果最大公约数 g 2说明存在这样的XX可以是g本身。 return g 2关键点说明Counter的使用它是解决此类频率统计问题的利器代码简洁且效率高O(n)时间复杂度。reduce与gcd的结合这是计算多个数最大公约数的标准范式需要熟练掌握。边界情况处理代码隐含处理了多种情况牌数少于2张counts中可能有[1]gcd1返回False。所有数字出现次数相同gcd等于该次数若次数2则返回True。次数互质如[2, 3]gcd1返回False。4. 测试用例与运行验证编写全面的测试用例是验证算法正确性的关键。以下测试覆盖了典型和边界情况。def test_hasGroupsSizeX(): solution hasGroupsSizeX # 指向我们的函数 # 测试用例组 test_cases [ # (输入deck, 期望输出, 说明) ([1,2,3,4,4,3,2,1], True, 示例1完美配对), ([1,1,1,2,2,2,3,3], False, 示例2无法均分), ([1], False, 单张牌), ([1,1], True, 只有一种牌且成对), ([1,1,1,1,2,2], True, 次数为4和2gcd2), ([1,1,1,2,2,2,3,3,3], True, 次数均为3gcd3), ([1,1,2,2,3,3,3,3], False, 次数为2,2,4gcd2但实际分组等等2,2,4的gcd是2应该可以让我们验证数字1分1组(2张)数字2分1组(2张)数字3分2组(每组2张)。所以应该是True。这个用例设计错了修正如下), ([1,1,2,2,2,2,3,3,3,3], True, 次数为2,4,4gcd2), ([1,2,3,4,5,6], False, 所有数字只出现一次次数均为1gcd1), ([1]*100, True, 100张相同的牌次数100gcd1002), ([1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10], True, 所有数字出现2次gcd2), ([1,1,1,2,2,3,3,3], False, 次数为3,2,3gcd1), ] print(开始测试 hasGroupsSizeX 函数) all_passed True for i, (deck, expected, description) in enumerate(test_cases): result solution(deck) if result expected: print(f测试用例 {i1} 通过: {description}) else: print(f测试用例 {i1} 失败: {description}) print(f 输入: {deck}) print(f 期望: {expected}, 实际: {result}) all_passed False print(\n所有测试用例通过 if all_passed else \n存在失败的测试用例。) # 额外运行LeetCode示例 print(\n--- LeetCode 示例验证 ---) print(f输入 [1,2,3,4,4,3,2,1] {solution([1,2,3,4,4,3,2,1])} (应 True)) print(f输入 [1,1,1,2,2,2,3,3] {solution([1,1,1,2,2,2,3,3])} (应 False)) if __name__ __main__: test_hasGroupsSizeX()将上述测试代码保存为test_card_group.py并运行你会看到所有测试用例的结果。确保你的函数能通过所有测试。如何运行python test_card_group.py预期输出开始测试 hasGroupsSizeX 函数 测试用例 1 通过: 示例1完美配对 测试用例 2 通过: 示例2无法均分 测试用例 3 通过: 单张牌 测试用例 4 通过: 只有一种牌且成对 测试用例 5 通过: 次数为4和2gcd2 测试用例 6 通过: 次数均为3gcd3 测试用例 7 通过: 次数为2,4,4gcd2 测试用例 8 通过: 所有数字只出现一次次数均为1gcd1 测试用例 9 通过: 100张相同的牌次数100gcd1002 测试用例 10 通过: 所有数字出现2次gcd2 测试用例 11 通过: 次数为3,2,3gcd1 所有测试用例通过 --- LeetCode 示例验证 --- 输入 [1,2,3,4,4,3,2,1] True (应 True) 输入 [1,1,1,2,2,2,3,3] False (应 False)5. 深入理解最大公约数算法的原理与证明为什么最大公约数能解决这个问题我们来做一个简单的证明。定义设牌堆中不同数字的集合为D。对于数字d ∈ D其出现次数为count[d]。设所有count[d]的最大公约数为g即g gcd({count[d] | d ∈ D})。命题存在满足题意的分组方案当且仅当g 2。证明必要性如果存在分组方案则g 2假设存在分组方案分组大小为X 2。 根据分组规则对于任意数字d其所有牌必须被分成若干组每组X张且全是d。这意味着count[d]必须是X的倍数即X整除count[d]。 因此X是所有count[d]的一个公约数。由于X 2那么所有count[d]的最大公约数g至少是X因为最大公约数是所有公约数中最大的所以g X 2。充分性如果g 2则存在分组方案假设g 2。 根据最大公约数的定义对于任意数字dg整除count[d]。即count[d] k_d * g其中k_d是正整数。 那么对于数字d我们可以将其所有的count[d]张牌分成k_d组每组g张牌。 由于g 2且每组内牌的数字相同都是d这个分组方案满足题目的所有要求。 因此存在分组方案分组大小X可以取g。证毕。这个证明清晰地揭示了问题本质寻找分组方案等价于寻找所有频率的一个大于1的公约数。而最大公约数g是所有公约数中最大的只要g 2它本身就是一个合法的分组大小。6. 常见问题与排查思路在实现和面试中可能会遇到以下几个典型问题问题现象可能原因排查方式解决方案返回True但实际无法分组逻辑错误算法逻辑有误例如错误地认为只要所有次数都是偶数就行。用反例测试如[1,1,1,1,2,2]次数4,2gcd2应True。[1,1,1,2,2,2,3,3]次数3,3,2gcd1应False。严格使用最大公约数判断而非奇偶性。对于大输入数据超时Time Limit Exceeded使用了暴力枚举法复杂度太高。检查算法复杂度。统计频率后是否在枚举X改用基于最大公约数的方法复杂度为 O(n k log M)。代码在deck长度为1时返回错误结果未考虑边界情况min(counts)可能为1但gcd计算可能出错。测试deck [1]。counts [1],gcd(1)1应返回False。确保gcd计算能处理单个元素的情况。reduce(math.gcd, [x])返回x。math.gcd报错或找不到Python 版本低于3.5math.gcd不可用。或者未导入math模块。检查Python版本 (python --version)。检查代码开头是否有import math。升级Python版本或自己实现gcd函数def gcd(a,b): while b: a,b b, a%b; return a。reduce函数报错未从functools导入reduce。在Python 3中reduce不在内置函数中。检查代码开头是否有from functools import reduce。添加正确的导入语句。认为分组必须每组牌数字都不同误解题意认为每组牌的数字必须互不相同。重新阅读题目“组内所有的牌上都写着相同的整数”。这意味着同一组内的牌数字必须相同但不同组之间可以有相同数字实际上相同数字的牌会被分到多个组。正确理解分组规则。数字i的所有牌会被分成若干组每组都是数字i。一个容易混淆的测试用例分析deck [1,1,2,2,2,2,3,3,3,3]频率1出现2次2出现4次3出现4次。counts [2, 4, 4]最大公约数gcd(2,4,4) 2。判断2 2为True。分组方案 数字1分成1组2张‘1’数字2分成2组每组2张‘2’数字3分成2组每组2张‘3’。总共5组每组2张牌符合要求。所以结果是True。很多初学者会纠结“组数”或“不同数字能否混组”其实题目只要求每个组内牌相同对组间关系无要求。7. 算法扩展与最佳实践7.1 不依赖math.gcd和reduce的实现在某些面试环境或限制下可能需要自己实现gcd和迭代计算。from collections import Counter def hasGroupsSizeX_independent(deck): 不依赖math.gcd和functools.reduce的实现。 # 1. 统计频率 counter Counter(deck) counts list(counter.values()) # 2. 自定义gcd函数 (欧几里得算法) def my_gcd(a, b): while b: a, b b, a % b return a # 3. 计算所有counts的gcd if not counts: return False g counts[0] for c in counts[1:]: g my_gcd(g, c) # 提前优化如果gcd已经降到1不可能再变大 if g 1: break # 4. 判断 return g 27.2 性能优化与小技巧提前终止在遍历counts计算gcd时一旦gcd变为1就可以立即返回False因为1是所有正整数的公约数不会再变大。最小次数判断如果任何数字的出现次数小于2直接返回False。这是一个快速的剪枝操作。使用Counter的values()视图counter.values()返回的是视图对象如果不需要修改可以直接用于迭代无需转换为list节省一点内存和时间。优化后的版本from collections import Counter import math from functools import reduce def hasGroupsSizeX_optimized(deck): counter Counter(deck) counts counter.values() # 使用视图不转list # 快速失败如果有任何数字出现次数小于2不可能分组因为X2 if any(c 2 for c in counts): return False # 计算gcd过程中可提前退出 g 0 for c in counts: g math.gcd(g, c) if g 1: return False return g 2 # 实际上如果循环完成且g!1这里g肯定27.3 应用到其他问题思维模式迁移“卡牌分组”问题的核心思维是将分组可行性问题转化为对一组整数频率存在大于1的公约数的判断问题。这种思维可以迁移到许多其他场景资源分配问题有若干种任务每种任务有若干实例需要分配到多个相同的执行单元中每个单元只能执行同一种任务。问是否存在一种分配方式使每个单元负载均衡任务数相同。这本质上就是卡牌分组问题。字符串重组问题给定一个字符串能否将其重新排列使得它由若干个相同的子串重复多次构成例如判断字符串是否由某个子串重复多次组成。这与计算字符频率的公约数有关。多批次处理工厂生产多种产品每种产品有一定数量需要用容量为X的相同箱子来包装且每个箱子只能装同一种产品。问是否存在这样的箱子容量X能正好装完所有产品。当你遇到类似“均匀分组”、“整除约束”、“公共因子”等关键词时可以优先考虑最大公约数GCD或最小公倍数LCM的解法。8. 总结与进阶挑战通过本文的详细拆解你应该已经掌握了力扣914题“卡牌分组”的数学本质和Python高效解法。关键点再回顾一下问题转化将复杂的分组规则转化为“所有数字出现次数必须能被同一个X (X2)整除”的数学约束。模型抽象进一步转化为“求所有出现次数的最大公约数g并判断g 2”。工具使用熟练运用collections.Counter统计频率math.gcd计算最大公约数functools.reduce进行累积运算。边界处理考虑牌数少于2、频率为1、所有频率相等等边界情况。思维迁移理解这种“公约数判断”的思维模式并能应用到其他类似问题中。作为进阶你可以尝试以下挑战如果要求每组牌数X必须大于2且是质数如何修改算法提示先求最大公约数g然后判断g是否包含大于等于2的质因子。如果要求不仅存在分组方案还要找出所有可能的分组大小X如何输出提示找出最大公约数g的所有大于等于2的因子。尝试解决力扣第 914 题的变种或类似题目例如LeetCode 1071. 字符串的最大公因子本质也是求两个字符串长度的最大公约数并检查子串重复性。LeetCode 1497. 检查数组对是否可以被 k 整除虽然更复杂但也涉及余数分组和配对思想。刷题的目的不是记住每一道题的答案而是锻炼从具体问题中抽象出数学模型的能力。希望这篇解析能帮助你下次遇到类似问题时能更快地抓住要害写出简洁高效的代码。
返回列表