LeetCode 914卡牌分组:哈希计数与最大公约数算法精解
2026/8/25 5:25:37 网站建设 项目流程

如果你在准备算法面试,或者正在刷LeetCode,大概率会遇到这样一类题目:题目描述看起来很简单,甚至有点像小学数学题,但当你真正动手写代码时,却发现处处是坑,一不小心就掉进“暴力解法”或“复杂度过高”的陷阱。

力扣第914题“卡牌分组”就是这类题的典型代表。题目要求很简单:给定一副牌,每张牌上都有一个整数。你需要判断是否可以将整副牌分成若干组,使得每组都有X张牌,且每组内的牌数字都相同。

听起来是不是像在玩“找规律”的游戏?很多人的第一反应是:统计每种数字出现的次数,然后看看这些次数有没有一个大于1的公共约数。这个思路方向是对的,但问题在于:

  1. 如何高效地求所有频次的最大公约数?
  2. 边界情况有哪些?(比如只有一种牌、频次为1、数组长度小于2等)
  3. Python中有什么现成的工具可以简化计算?

更关键的是,这道题考察的远不止是写对一个if-else。它背后串联了哈希计数、最大公约数计算、数学思维在算法中的应用等多个核心知识点。理解这道题,你收获的不仅仅是一个“Accepted”,而是一种将具体问题抽象为数学模型,并用高效算法解决的能力。

本文将带你彻底拆解LeetCode 914题。我们不只给出最终答案,更会深入分析:

  • 为什么“最大公约数”是这道题的最优解?从数学原理上理解,而不仅仅是记忆。
  • 如何用Python优雅地实现?利用collections.Countermath.gcd,写出简洁高效的代码。
  • 有哪些容易忽略的“坑”?我们将逐一分析并给出测试用例。
  • 如何举一反三?理解这类“分组”问题的通用解题框架。

无论你是正在入门算法的新手,还是想巩固基础的进阶者,这篇文章都将帮你把这道“简单”题吃透,转化为实实在在的解题能力。

1. 问题重述与核心难点分析

首先,我们严格定义一下题目(LeetCode 914. X of a Kind in a Deck of Cards):

输入:一个整数数组deck,其中deck[i]表示第 i 张牌上的数字。输出:一个布尔值。如果可以按要求分组则返回True,否则返回False分组规则

  1. 将所有牌分成一组或多组
  2. 每组牌的数量X必须相同,且X >= 2
  3. 每组内的所有牌,其数字必须完全相同。

示例 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的约数,且X>=2。然后对于每个X,检查是否能将每种牌都恰好分成若干组,每组X张。 这种方法理论可行,但效率极低。假设牌有M种数字,N张牌,N的约数个数约为O(√N),每次检查需要遍历所有牌,复杂度接近O(M * √N)。当N很大时(比如10^4),这个复杂度就很高了。

核心难点在于,我们不需要模拟分组过程,而是要将问题转化为一个关于频次的数学问题

关键转化

  1. 统计每种数字出现的次数,得到一个频次列表counts
  2. 分组要求意味着:对于任意一种数字,它的出现次数count必须能被组大小X整除。因为这种数字要被分成若干组,每组X张且都是这个数字。
  3. 因此,X必须是所有counts的公约数
  4. 同时,X必须大于等于 2。
  5. 所以,问题转化为:所有频次的最大公约数g是否大于等于 2?如果g >= 2,那么取X = g就能满足分组条件(因为g是所有counts的约数)。如果g == 1,则不存在满足X>=2的公约数。

至此,我们将一个具体的分组问题,抽象成了一个求多个整数最大公约数的数学问题。复杂度从模拟的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 问题抽象总结

我们可以将解题流程总结为以下几步:

  1. 统计频次:遍历牌组,记录每个数字出现的次数。
  2. 提取频次列表:获得所有大于0的频次值。
  3. 计算总GCD:计算所有频次值的最大公约数。
  4. 判断结果:如果总GCD大于等于2,返回True;否则返回False
  5. 处理边界:牌组总数小于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:边界情况检查

这是写出健壮代码的第一步。对于本题,有两个明显的边界:

  1. 牌组总数小于2len(deck) < 2。因为每组至少需要2张牌,总牌数都不够一组,直接返回False
  2. 牌组只有一种数字:如果所有牌都相同,那么只要牌数>=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列表中所有整数的最大公约数。

  1. 初始化:将第一个频次作为当前的最大公约数current_gcd
  2. 迭代计算:遍历剩余的频次,将current_gcd与下一个频次计算最大公约数,并更新current_gcd
  3. 数学依据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张牌(X>=2),且组内牌数字相同。 参数: 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,说明可以找到一个分组大小X(X = 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):这是核心计算。reducecounts的第一个元素开始,依次与后面的元素计算最大公约数。如果counts为空(理论上不会,因为牌组不为空),reduce需要提供初始值,但本题情况不需要。
  • return total_gcd >= 2:最终的判断逻辑。total_gcd为1表示存在互质的频次,无法找到X>=2的公约数。

版本二:简洁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. 基本功能测试:验证常规能分组和不能分组的情况。
  2. 边界测试:牌数少、频次为1、只有一种牌等。
  3. 性能测试:大数据量输入(虽然本题限制不大,但好习惯要保持)。

本地测试代码示例

# 将上面的 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], gcd=6 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. 忽略了X>=2的条件。
2. 边界情况处理不当(如只有一张牌)。
3. 计算GCD的逻辑错误,例如对单个数字求GCD。
1. 检查最终判断是否是gcd >= 2
2. 在函数开头添加if len(deck) < 2: return False
3. 打印中间变量countstotal_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^4),Counter内存开销可以接受。如果数字范围极大,考虑使用数组计数(如果数字范围已知且不大)。通常无需优化。如果数字范围已知在[0, K],可以用[0]*(K+1)数组代替Counter

一个典型的思维陷阱: 有同学会想:“我先求总牌数N,然后找出N的所有大于等于2的约数,再逐个尝试是否所有频次都能被其整除。” 这个思路是对的,但效率不如求频次的GCD。因为:

  1. N的约数需要O(√N)时间。
  2. 对每个约数X,检查所有频次需要O(M)时间。
  3. 总时间O(M√N)。 而求GCD的方法,计算所有频次的GCD时间复杂度约为O(M * log(min(count))),通常更优。当问题可以转化为数学性质时,先尝试数学解法往往是更优的。

8. 最佳实践与进阶思考

掌握了这道题的基础解法后,我们可以从工程和算法两个角度思考如何做得更好。

8.1 工程最佳实践

  1. 防御性编程:始终检查输入边界。即使题目有假设,好的习惯是在函数开始处检查deck的长度。
  2. 善用标准库collections.Countermath.gcd是Python标准库的利器,它们经过高度优化,比自己实现更可靠、更高效。
  3. 代码可读性:在追求简洁(如一行代码版)和清晰(如详细注释版)之间取得平衡。对于团队项目或面试,清晰性优先。可以在清晰的基础上,通过提取函数来简化主逻辑。
  4. 添加类型注解:如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(满足X>=2且能整除所有频次),那么答案就是所有频次的最大公约数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.gcdmath.lcm(Python 3.9+)是必备工具。
  • 从暴力法到优化:先思考最直接的暴力解法,然后分析其瓶颈,再寻找优化点。暴力解法(枚举X)能帮你理解问题,而优化解法(求GCD)则体现了算法的价值。

8.4 关联题目推荐

为了巩固此类问题的解法,建议练习以下LeetCode题目,它们都涉及类似的“分组”、“整除”、“公约数/公倍数”思想:

  • LeetCode 365. 水壶问题:判断能否用两个水壶得到目标水量,本质是裴蜀定理(线性丢番图方程)。
  • LeetCode 1497. 检查数组对是否可以被 k 整除:分组配对问题,需要用到余数统计和配对思想。
  • LeetCode 1010. 总持续时间可被 60 整除的歌曲:寻找配对,利用余数进行计数。
  • LeetCode 2344. 使数组可以被整除的最少删除次数:涉及最大公约数的操作。

通过这道“卡牌分组”题,我们不仅学会了一个具体的解法,更重要的是掌握了一种将现实约束转化为数学条件,并利用高效算法(Counter+gcd)求解的思维模式。这种模式在解决许多中等难度算法题时都非常有用。

下次再遇到类似“能否均匀分组”的问题时,你的第一反应就应该是:先统计频次,再看这些频次之间是否存在大于1的公约数。这就是刷题的意义——积累可复用的解题模式和思维框架。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询