LeetCode 771:哈希集合优化宝石与石头问题,从O(m*n)到O(m+n)
2026/8/23 9:24:48 网站建设 项目流程

1. 问题引入:从“宝石与石头”到哈希集合的初体验

如果你刚开始接触LeetCode,或者正在用Python刷题来巩固基础,那么第771题“Jewels and Stones”绝对是一个完美的起点。这道题在力扣的题库里被标记为“简单”,但千万别小看它。它就像是你编程工具箱里的第一把螺丝刀,看起来简单,却能帮你拧开很多复杂问题的大门。题目描述非常直白:给你两个字符串,jewels代表宝石的类型,stones代表你拥有的石头。你需要统计在stones字符串中,有多少个字符是出现在jewels字符串中的。简单来说,就是在你的石头堆里,找出哪些是宝石,并数一数个数。

我第一次看到这个题目时,觉得这太简单了,不就是两层循环遍历比较吗?但正是这种“简单”的错觉,让我后来在面试中栽过跟头。面试官追问:“如果jewelsstones的长度都非常大,比如各有一百万个字符,你的算法效率如何?” 我当时的朴素解法瞬间就暴露了性能瓶颈。这道题的核心,远不止是完成功能,而是引导你思考一个在编程中至关重要的问题:如何高效地进行存在性判断。而解决这个问题的钥匙,就是“哈希集合”。在Python中,它对应的数据结构是set。通过这道题,你会深刻理解为什么以及何时该用set,而不是无脑地用list。这不仅是解一道题,更是建立一种正确的、高效的编程思维定式。

2. 暴力解法剖析:为什么两层循环是“性能杀手”

我们先从最直观的解法开始,这也是很多初学者(包括当年的我)会第一时间想到的方法:暴力枚举。

2.1 暴力解法的实现代码

class Solution: def numJewelsInStones(self, jewels: str, stones: str) -> int: count = 0 for stone in stones: for jewel in jewels: if stone == jewel: count += 1 break # 找到即跳出内层循环 return count

这段代码的逻辑清晰得像一面镜子:对于stones里的每一块石头(外层循环),我们都去jewels这个宝石列表里从头到尾比对一遍(内层循环)。如果找到了相同的字符,计数器加一,并且因为一块石头只可能是一种宝石(找到了就不用继续比了),所以用break跳出内层循环。

2.2 时间复杂度分析与性能陷阱

我们来算一笔账。假设jewels的长度为mstones的长度为n

  • 在最坏的情况下(比如没有一块石头是宝石,或者最后一块石头才是宝石),对于stones中的每一个字符,我们都需要遍历完整个jewels字符串。
  • 因此,总的比较次数是n * m。在算法领域,我们用大O表示法来描述这种增长关系,即时间复杂度为O(m * n)

mn都很小时,比如几十上百,这个速度人类感知不到差异。但正如我面试时遇到的问题,当数据规模上升到十万、百万级别时,O(m*n)的复杂度是灾难性的。计算量会呈平方级增长,程序可能会运行数秒甚至更久,这在算法竞赛或线上服务中是绝对不允许的。

这里有一个常见的误解:我用了break,不是应该快很多吗?break确实能在找到匹配后提前结束内层循环,但这改变不了时间复杂度最坏情况仍然是O(m*n)的事实。它只是一个常数级别的优化,并没有改变算法随数据规模增长的本质。这就好比你要在一本无序的电话簿里找一个人,最坏情况下你还是得翻遍每一页,虽然中途找到可以停下,但改变不了“可能需要翻完整本”这个糟糕的流程设计。

3. 哈希集合解法:将查找时间降至常数级

既然暴力解法的瓶颈在于,对于每一块石头,都需要在宝石列表中“线性扫描”查找,那么优化的核心就是让这个查找动作变得飞快。哈希表(Hash Table)正是为此而生的数据结构,在Python中,它的一个典型应用就是集合set

3.1 哈希集合的核心思想与Python实现

set是一个无序的不重复元素集。它的魔法在于,基于哈希表实现,使得判断一个元素是否存在于集合中的操作,其平均时间复杂度是O(1),即常数时间。这意味着无论这个集合里有10个元素还是10万个元素,检查“某个元素在不在里面”所花的时间几乎是一样的。

解题思路立刻变得清晰:

  1. 预处理宝石列表:将字符串jewels转换成集合jewel_set。这个操作需要遍历jewels一次,时间复杂度O(m)。
  2. 高效统计石头:遍历字符串stones,对于每一块石头,只需用in操作符判断它是否在jewel_set中。这个判断是O(1)的。遍历stones是O(n)。
  3. 汇总结果:将在集合中的石头计数。

转换后的代码如下:

class Solution: def numJewelsInStones(self, jewels: str, stones: str) -> int: jewel_set = set(jewels) # 关键步骤:构建哈希集合 count = 0 for stone in stones: if stone in jewel_set: # O(1)时间复杂度的查找 count += 1 return count

3.2 复杂度对比与质的飞跃

让我们重新计算复杂度:

  • 时间复杂度:构建集合O(m)+ 遍历石头O(n)=O(m + n)。从乘法关系O(m*n)降到了加法关系O(m+n),这是一个质的飞跃。当m和n都是100万时,暴力解法可能需要万亿次操作,而哈希集合解法只需要大约200万次操作。
  • 空间复杂度:我们额外使用了一个集合来存储宝石类型,其大小最多为m(如果jewels中字符全不重复)。因此空间复杂度是O(m)。这是典型的“以空间换时间”策略,在绝大多数情况下,这点额外的内存开销换取巨大的时间性能提升是完全值得的。

你可以自己做一个实验,用timeit模块测试两种解法在长字符串下的性能差异,结果会非常直观。哈希集合解法的优势,在处理大规模数据时是碾压性的。

4. 一行代码的优雅解法:Pythonic思维的体现

对于Python开发者来说,追求代码的简洁与优雅是一种习惯。利用Python强大的内置函数和生成器表达式,我们可以将上面的解法浓缩成一行代码:

class Solution: def numJewelsInStones(self, jewels: str, stones: str) -> int: return sum(stone in set(jewels) for stone in stones)

这行代码做了以下几件事:

  1. set(jewels):同样,先将宝石字符串转换为集合。
  2. (stone in set(jewels) for stone in stones):这是一个生成器表达式。它会遍历stones中的每个字符,并产生一个布尔值序列(TrueFalse),表示该石头是否是宝石。
  3. sum(...):在Python中,布尔值TrueFalse在参与算术运算时,会被当作10处理。sum函数将这个由1和0组成的序列加起来,自然就得到了宝石的总数。

注意:这种写法虽然极其简洁,但在某些讨论中需要注意性能细节。这里set(jewels)在生成器表达式内部,但因为它只被创建一次(生成器表达式会先计算其可迭代对象),所以时间复杂度依然是O(m+n)。不过,更严谨且高效的写法是将set(jewels)赋值给一个变量,避免任何潜在的误解或重复构造(尽管解释器通常会优化)。不过在这种短小的表达式中,可读性和简洁性成为了更优先的考量,这也是Pythonic哲学的一部分。

5. 深入拓展:从本题到更广泛的哈希表应用场景

解完这道题,绝不能止步于此。LeetCode 771的真正价值在于,它为你打开了一扇门,让你看到了哈希表(在Python中主要是dictset)在解决一大类问题时的威力。这类问题的核心模式可以概括为:需要频繁、快速地检查某个元素是否存在,或者需要建立元素到其他信息的映射关系

5.1 同类问题举一反三

掌握了Jewels and Stones的思维,下面这些题目你都能触类旁通:

  • LeetCode 1. Two Sum(两数之和):这是哈希表(字典)最经典的入门题。核心是,在遍历数组时,用字典记录每个数字的补数(target - num)及其索引。当遇到一个数字,它的值已经在字典中作为键存在时,就找到了答案。这本质上是将“寻找满足条件的另一个数”的查找操作,从遍历优化成了O(1)的字典查找。
  • LeetCode 349. Intersection of Two Arrays(两个数组的交集):几乎和本题一模一样,求两个数组的交集。直接使用集合的&(交集)操作,或者将一个数组转为集合,然后遍历另一个数组判断是否存在。
  • LeetCode 387. First Unique Character in a String(字符串中的第一个唯一字符):通常需要两遍遍历。第一遍用字典统计每个字符出现的次数(O(n)),第二遍遍历字符串,找到第一个计数为1的字符(O(n))。如果不用哈希表统计,查找过程会变得低效。

5.2 哈希表在Python中的选择:setvsdict

本题我们用了set,因为它只关心“存在与否”。但在更多场景下,我们需要关联更多的信息,这时就要用到dict

  • set:存储唯一的、不可变的对象集合。只支持成员检测、交集并集等集合操作。适用于去重或单纯的存在性检查。
  • dict:存储键值对(key-value pairs)。键必须是不可变类型(如字符串、数字、元组),值可以是任意对象。适用于需要根据键快速检索关联值的场景,如统计频率、缓存结果、建立映射关系。

例如,在“两数之和”中,我们需要存储的是“数值”和“它的索引”的映射,所以必须用dict。而在“宝石与石头”中,我们只需要知道宝石类型有哪些,不需要知道其他信息,所以set就足够了。

5.3 一个综合性的实战变种

假设题目稍微变化一下:jewels不再是一个简单的字符串,而是一个列表,里面每个元素是一个宝石对象,对象有type(类型,如‘R’, ‘S’)和value(价值,整数)属性。stones是一个字符串。现在需要计算你拥有的所有宝石的总价值。

这时,set就不够用了,因为我们需要根据石头类型(字符)快速查到对应的价值。解决方案是使用字典:

def total_jewel_value(jewels_list, stones): # 构建一个从宝石类型到价值的映射字典 jewel_value_map = {} for jewel in jewels_list: jewel_value_map[jewel.type] = jewel.value total_value = 0 for stone in stones: # 如果石头类型在字典中,累加其价值 if stone in jewel_value_map: total_value += jewel_value_map[stone] return total_value

这个变种清晰地展示了何时该从set升级到dict:当你需要存储和查找与键相关联的额外信息时。

6. 常见误区与避坑指南

在实际编码和面试中,围绕这道题和哈希表的使用,有一些高频的“坑点”。

6.1 误区一:忽视字符集与输入范围

题目虽简单,但一个良好的习惯是考虑输入范围。题目说明jewelsstones仅由英文字母组成。这意味着:

  • 字母区分大小写。‘a‘和’A‘是不同的宝石类型。
  • 总字符种类是有限的(52种)。这带来一个有趣的点:当jewels种类很少时(比如只有几种),暴力法和哈希法在实际运行时间上可能相差不大,因为常数项很小。但算法思维不应依赖于这种特定数据。我们学习的是通用、高效的解法,它应该在任何规模、任何分布的数据上都表现良好。

6.2 误区二:在循环中重复构造集合

这是一个初学者和追求“一行代码”时容易犯的错误:

# 低效写法 def numJewelsInStones(jewels, stones): count = 0 for stone in stones: if stone in set(jewels): # 错误!在每次循环中都创建新的集合 count += 1 return count

这段代码的时间复杂度退化成了O(n * m),因为set(jewels)在每次循环中都被重新创建。一定要记住,将不变的、可复用的计算结果(如这里的jewel_set)提到循环外部,这是一个重要的性能优化原则。

6.3 误区三:过度优化与可读性权衡

我们讨论了一行代码的写法。但在团队合作或大型项目中,有时需要权衡简洁性与可读性。对于刚入门的同事来说,下面这种展开的写法可能更友好,意图更清晰:

def numJewelsInStones(jewels, stones): jewel_types = set(jewels) jewel_count = 0 for stone in stones: if stone in jewel_types: jewel_count += 1 return jewel_count

变量名jewel_typess更能表达其含义,循环体也清晰明了。在追求“Pythonic”的同时,永远不要牺牲代码的清晰度和可维护性。尤其是在面试中,先写出清晰正确的版本,再视情况优化,往往是更稳妥的策略。

7. 测试用例设计与边界条件思考

写出代码只是第一步,验证其正确性同样重要。自己设计测试用例是一个优秀程序员必备的习惯。针对本题,我们可以考虑以下几类:

  1. 基础功能测试

    • 输入:jewels = “aA“, stones = ”aAAbbbb“
    • 预期输出:3(a出现1次,A出现2次)
    • 目的:验证基本计数功能。
  2. 边界条件测试

    • 空字符串
      • jewels = ““, stones = ”abc“-> 输出0
      • jewels = “a“, stones = ””-> 输出0
      • jewels = ““, stones = ””-> 输出0
    • 无匹配
      • jewels = “z“, stones = ”ZZZ“-> 输出0
    • 全匹配
      • jewels = “abc“, stones = ”cbaabc“-> 输出6
  3. 性能暗示测试

    • 构造很长的jewelsstones字符串(例如各10万个字符)。虽然无法手动验证结果,但可以运行并感受两种解法的时间差异。用暴力解法可能会卡住,而哈希集合解法瞬间完成。

养成在提交前用多种用例测试自己代码的习惯,能极大提高一次通过率,并锻炼出严密的思维。

8. 总结与思维升华

回顾LeetCode 771 “Jewels and Stones”的整个解题过程,它绝不仅仅是一道简单的计数题。它是一次经典的算法思维训练:

  1. 从暴力到优化:我们经历了最直观的O(m*n)解法,并分析了其性能瓶颈。这是算法学习的必经之路——先解决问题,再优化问题。
  2. 认识核心数据结构:我们引入了哈希集合(set),理解了其O(1)查找时间的原理,并将算法复杂度优化至O(m+n)。这是“以空间换时间”策略的入门级示范。
  3. 掌握Python工具:我们运用了setin操作符、生成器表达式和sum函数,领略了Python的简洁与高效。
  4. 建立问题模式:我们识别出了“频繁存在性检查”这一模式,并将其与哈希表这一数据结构关联起来,为解决“两数之和”、“数组交集”等更多问题打下了基础。
  5. 培养工程习惯:我们讨论了代码风格、性能陷阱、测试用例和边界条件,这些都是编写健壮、可维护代码的重要组成部分。

这道题像一颗种子,它种下的是一种思想:当你的程序需要在数据集中反复查找时,第一个应该想到的就是哈希表。在后续遇到更复杂的问题,比如缓存(LRU Cache)、索引、去重、关系映射时,你会发现哈希表及其变种(如defaultdictCounter)是你武器库中最常被使用的利器之一。所以,下次再看到“简单”的题目,不妨多想一想,它背后试图教给你的,究竟是什么。

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

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

立即咨询