news 2026/10/15 11:10:20

LeetCode 383 赎金信:哈希表与定长数组计数法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 383 赎金信:哈希表与定长数组计数法详解

1. 题目拆解:赎金信到底在考什么

1.1 先看清题目的约束条件

LeetCode 383 赎金信(Ransom Note)这道题,表面是字符串题,骨子里考的却是最基础的计数思想。题面给你两个字符串ransomNote和magazine,要求判断ransomNote能不能由magazine中的字符构成。很多人第一眼看到"构成"两个字,脑子里立刻跳出子序列判断、包含关系判断之类的解法,但其实题目里有一句决定性的限制:magazine中的每个字符只能使用一次。

这句话是整道题的题眼。它意味着你要做的事情不是"检查ransomNote的字符是否都出现在magazine里",而是"检查magazine中每个字符的数量是否足够覆盖ransomNote的需求"。我身边第一次刷这道题的朋友,十个里有六七个会栽在这个理解偏差上。拿个具体例子:ransomNote = "aab",magazine = "ab"。如果只看字符是否出现,a和b都出现了,好像能构成,但实际你手头只有一个a,怎么也凑不出两个a,所以答案是false。这个细节,就是这道题和那些"字符串包含关系"问题的本质区别。

题目还有一层约束很友好:两个字符串都只包含小写字母。这意味着字符集合的规模是确定的、有上限的 26。这个限定条件在解法选择上极其关键,后面我会重点展开。如果你没注意到这一点,一上来就写HashMap<Character, Integer>,也能做对,但你就错过了这道题想教给你的最核心的优化思路。

1.2 适合什么人刷、能练出什么

这道题的定位非常明确:它是哈希表/计数类题目的入门热身题。它不像滑动窗口那样需要维护窗口边界,不像双指针那样需要精细的移动逻辑,也不像动态规划那样需要推导状态转移方程。它的核心逻辑就一句话:先统计库存,再逐项扣减。这个逻辑一旦建立起来,后面刷字母异位词(242)、字符串排列(567)都会顺很多。

如果你是刚开始刷题的新手,这道题非常适合作为你理解"空间换时间"策略的第一课。因为它足够简单,你能把注意力完全放在"为什么计数数组比哈希表快"、"为什么复杂度是 O(m+n) 而不是 O(m×n)"这些更本质的问题上。如果你已经有了一定基础,这道题也不至于无聊——你可以借它练习面试时的口头讲解能力、边界条件梳理能力和多解法对比能力。我见过不少人在面试里遇到这道题翻车,不是不会写,而是讲不清楚为什么数组方案更好。

2. 基础解法:哈希表计数法

2.1 用生活场景理解计数逻辑

要理解计数法的思路,可以想象一个生活场景:你要给朋友发一封密信,手头有一堆从杂志上剪下来的字母贴纸。每张贴纸用一次就没了。你要做的,就是先把杂志里所有字母贴纸的种类和数量都盘点一遍,然后对照密信里需要的字母,一张一张地消耗。如果某个字母在盘点清单上已经归零,但密信里还有,那就说明贴纸不够,这封信发不出去。

这个场景对应到代码里就是两个循环。第一遍遍历magazine,建立"库存表";第二遍遍历ransomNote,模拟"消耗库存"的过程。任何一次消耗失败,立即返回false。全部消耗成功,返回true。整个过程不需要复杂的指针操作,不需要回溯,线性推进即可。

2.2 哈希表版本的完整代码

先用 Java 写一版最直白的实现:

class Solution { public boolean canConstruct(String ransomNote, String magazine) { Map<Character, Integer> count = new HashMap<>(); for (char c : magazine.toCharArray()) { count.put(c, count.getOrDefault(c, 0) + 1); } for (char c : ransomNote.toCharArray()) { if (count.getOrDefault(c, 0) == 0) { return false; } count.put(c, count.get(c) - 1); } return true; } }

Python 版本可以借助collections.Counter,代码更短:

from collections import Counter class Solution: def canConstruct(self, ransomNote: str, magazine: str) -> bool: counter = Counter(magazine) for c in ransomNote: if counter[c] == 0: return False counter[c] -= 1 return True

这段逻辑里有一个非常容易被忽略的细节:查询"剩余库存"时,必须使用getOrDefault(c, 0)而不是直接get(c)。原因很简单,如果ransomNote里出现了magazine中完全没见过的字符,get会返回null,你拿null跟0比较还好,一旦做减法就会抛空指针异常。Java 的字符串类题目里,这种空指针是面试官最爱埋的雷。Python 的Counter则不太一样,它访问不存在的键不会报错,而是返回 0,这也是为什么 Python 写起来更省心的原因之一。

2.3 为什么复杂度是 O(m+n) 而不是 O(m×n)

这个问题的答案,直接影响你对后续所有字符串计数题的理解。两个循环是并列关系,不是嵌套关系,所以总操作次数是magazine的长度 m 加上ransomNote的长度 n,写成 O(m+n)。嵌套循环之所以会是 O(m×n),是因为每一层循环的每一轮都要完整跑一遍内层循环,而这里的内层循环只是 O(1) 的哈希表读写操作。这一点在面试时一定要讲清楚,因为很多候选人会把"两个 for 循环"误认为"嵌套",把复杂度说成 O(m²) 之类,那就明显露怯了。

空间复杂度方面,哈希表存储的是字符到频次的映射。因为题目限定小写字母,最多只有 26 个键,所以空间是 O(26),也就是 O(1)。但如果不限定字符集,键的个数会跟实际出现的不同字符数量有关,空间复杂度就得写成 O(k),k 是不同字符的个数。这个区分在进阶面试题里经常被追问。

3. 核心优化:用定长数组替代哈希表

3.1 数组凭什么比哈希表快

既然题目明确说只包含小写字母,那么再使用HashMap,就属于典型的"杀鸡用牛刀"了。哈希表虽然好用,但它每一次读写都要经历一系列操作:计算哈希值、根据哈希值定位桶、处理可能发生的哈希碰撞、处理键值对的装箱拆箱。哪怕这些操作在单次执行时都是纳秒级别,累积起来也相当可观。

而定长数组int[26]则完全不同。数组的内存是连续分配的,访问某个下标就是一次基地址加偏移量的计算,处理器对这种访问模式有极强的优化能力,几乎可以把时间压到极限。用c - 'a'把字符映射到 0 到 25 的下标,一个循环累加,另一个循环递减,全程不需要创建对象、不需要计算哈希、不需要处理缺失键。我曾在本地用随机生成的长度为 10 万级的字符串做过对比测试,HashMap 版本的平均耗时大约是数组版本的 3 到 4 倍。虽然放在 LeetCode 的判题环境里这可能只是几毫秒的差距,但在面试现场,这个选择本身就展示了你对底层数据结构的理解深度。

3.2 优化后的代码逐行解读

class Solution { public boolean canConstruct(String ransomNote, String magazine) { int[] count = new int[26]; for (char c : magazine.toCharArray()) { count[c - 'a']++; } for (char c : ransomNote.toCharArray()) { if (count[c - 'a'] == 0) { return false; } count[c - 'a']--; } return true; } }

逐行说下:

  • int[] count = new int[26]:创建一个长度为 26 的整型数组,Java 会默认把每个元素初始化为 0。这里不需要手动初始化,省了一行代码。
  • count[c - 'a']++:把字符c转成数组下标。'a'的 ASCII 码是 97,'b'是 98,依此类推,c - 'a'恰好能映射到 0~25。
  • if (count[c - 'a'] == 0) return false:库存不足的检测。注意这里必须在减 1 之前判断,顺序反了就会把库存减成负数,导致后续判断失真。
  • count[c - 'a']--:消耗一个库存。

对比哈希表版本,这个版本省去了getOrDefault、put这些方法调用,也不需要处理空指针,逻辑反而更直白。很多人第一次看到c - 'a'这种写法会觉得有点绕,但它实现上就是把字符编码映射到数组下标的标准做法,字符串题目里出现频率极高,建议直接记熟。

3.3 剪枝优化:第一个 if 别省

在进入核心逻辑之前,还有一个值得加上的前置判断:

if (ransomNote.length() > magazine.length()) { return false; }

这个判断有没有必要?有。道理很简单:如果赎金信需要的字符总数超过了杂志提供的字符总数,那无论怎么分配都不可能构成,直接返回false即可。它虽然只是 O(1) 的操作,但可以在某些测试用例上省掉两次完整的遍历。更关键的是,这个判断在某些变种题里还有防止数组越界的作用,养成写的习惯没有坏处。

也有一种观点认为这个剪枝"多余",因为后面统计循环自然会判断出来。确实,从正确性角度看它是可有可无的;但从工程习惯角度看,这种基于明显事实的提前返回,跟业务代码里"查询前先校验参数"是一样的思路。合理,且推荐。

3.4 两个实现的顺序问题

写计数数组的时候,"先遍历谁"这个看似无关紧要的顺序,实际是有讲究的。正确做法是先遍历magazine建立库存,再遍历ransomNote消耗库存。如果反过来,先遍历ransomNote建立需求表,再遍历magazine去逐个满足需求,代码会变得绕一些:你需要处理"需求为 0 之后就不要再扣减"的状态,还要在最后检查所有需求是否都被清零。这个逻辑也能写对,但可读性和出错概率都会变差。

我的经验是:凡是"库存-消耗"模型,一律先建库存再消耗,这个顺序在面试中描述起来也是最通顺的。如果你在用 Python 写Counter版本,还有一个更取巧的写法:

from collections import Counter class Solution: def canConstruct(self, ransomNote: str, magazine: str) -> bool: return not (Counter(ransomNote) - Counter(magazine))

Counter的减法操作符会把结果中计数为 0 或负数的键丢弃,所以只要相减结果为空,就说明magazine的字符能全覆盖ransomNote。这个写法极其 Pythonic,但可读性对新人不友好,我在笔试时一般不用,面试官也不一定喜欢这种炫技式写法。

4. 另一种解法:排序加双指针

4.1 从排序视角重新看问题

计数法虽然高效,但并不是唯一的路。如果把两个字符串分别排序,问题就变成了一个有序数组的匹配问题。想象你把杂志里的字母按字典序排成一排,再把赎金信里的字母也排成一排,现在你要从左边往右逐一比对:杂志指针指到的字母如果偏小,说明这个字母对当前需求没用,可以直接跳过;如果相等,说明这个字母正好满足当前需求,两个指针一起前进;如果杂志指针指到的字母已经大于需求字母了,说明剩下的杂志字母只会更大,当前需求永远无法满足,可以直接判定失败。

这个思路的直观性很强,适合在面试时作为"不同解法"的补充展示。它的时间复杂度是 O(m log m + n log n),主要开销在排序上;空间复杂度 O(1),因为排序是在原数组上进行的,只需要常数级别的辅助空间。单从复杂度看,它不如计数法,但它的价值在于提供了一种不依赖字符集大小的通用思路——即使字符集扩大到任意 Unicode,这个解法依然成立,不需要修改代码。

4.2 完整代码与边界处理

class Solution { public boolean canConstruct(String ransomNote, String magazine) { char[] r = ransomNote.toCharArray(); char[] m = magazine.toCharArray(); Arrays.sort(r); Arrays.sort(m); int i = 0, j = 0; while (i < r.length && j < m.length) { if (r[i] > m[j]) { j++; } else if (r[i] == m[j]) { i++; j++; } else { return false; } } return i == r.length; } }

这段代码里最容易被忽略的是最后一行return i == r.length。为什么不能用return true?因为循环退出的原因有两种:要么两个指针都走到了末尾,匹配成功;要么magazine先走完了,但ransomNote还没匹配完。如果是后者,说明杂志的字符数量不够,必须返回false。这个判断正是整个双指针算法正确性的最后一道关,漏掉它,遇到ransomNote比magazine长的用例时就会答错。

还要注意三个分支的顺序。先处理r[i] > m[j](杂志字符太小、跳过),再处理相等(消费),最后处理r[i] < m[j](需求无法满足、判定失败)。如果你把这个顺序弄反,逻辑上会混乱。顺便说一句,C++ 里用sort对string的迭代器排序也行,但转成vector<char>或直接对字符数组操作通常更顺手。

4.3 两种解法的取舍:什么时候用哪个

直接回答:这道题的标准答案就是计数数组法,排序双指针更适合作为思路拓展。但工程上并非总是标准答案最好。如果面试官追加一个条件,"字符串长度都是 10 的 6 次方级别,但字符集不固定",计数法就需要改用哈希表,空间开销会上升;排序法的空间占用反而稳定。又或者面试官说"不能修改原字符串,且要求 O(1) 额外空间",那你只能对副本排序,或者想别的办法。

说到底,多会一种解法不是为了在 383 这道题上炫技,而是为了应对那些换汤不换药的变种题。比如某些题目要求你返回能构成的最大长度,计数法和排序法就会衍生出不同的扩展路径。我在刷题群里见过有人用TreeMap也做出来了,每次遍历都取最小值减 1,逻辑没问题但性能很差。多一种解法,你就能多一层应对变化的余地。

5. 边界条件与常见坑位复盘

5.1 三个高频错误,我逐一说给你听

第一个错误:只判断存在性,不判断数量。有些人一看到"判断能否由 magazine 构成",直接调用indexOf或者contains方法判断每个字符是否出现,完全忽略"每字符只能用一次"的约束。这类代码在简单用例上报不出错,但一旦遇到重复字符的需求就会翻车。而且这种错误特别隐蔽,因为它不是那种一跑就 crash 的错,而是让你 debug 半天也看不出逻辑漏洞的错。

第二个错误:扣减次数前没检查剩余量。代码写成count[c - 'a']--但没在前面加if (count[c - 'a'] == 0)判断。结果是库存变成负数,程序继续运行,最终在某个奇怪的组合下面返回错误结果。更要命的是,如果你后续又加了"所有 count 必须非负"的校验逻辑,负数库存会让你彻底混乱。正确的做法很简单:发现库存为 0,立即返回false,不做无谓的继续。

第三个错误:Java 的 HashMap 版本没有用getOrDefault。前面我说过,访问不存在的键会返回null,跟null做比较和减法都会出问题。字符串题目里这个错误出现频率极高,几乎算是一种"经典面试陷阱"。你甚至可以在面试时主动提一句"这里我用了 getOrDefault,避免键不存在时返回 null",面试官会觉得你考虑得很周全。

5.2 边界情况速查表

场景ransomNotemagazine预期结果原因
赎金信为空"""abc"true不消费任何字符,自然可以构成
杂志为空"a"""false没有可用字符
两者都为空""""true空串可以由空串构成
需求大于供给"aa""a"falsea 只有 1 个,需要 2 个
种类齐全但数量不足"abc""aabbc"falsea 和 b 各缺 1 个
供给远大于需求"abc""aabbccdd"true数量足够
完全匹配"abc""cba"true数量恰好相等

把这表过一遍,你基本就能保证边界不出问题。面试手撕代码前,也建议在脑子里快速走一遍这些用例,尤其是空字符串这个最常见的边界。

5.3 本地压测数据:给个量级概念

为了让你对性能差距有个直观感受,我拿本地环境做过一轮简单测试。生成长度在 10 万量级的随机字符串,分别测试三种写法:

  • 数组计数法(含前置长度判断):耗时约 3 毫秒到 5 毫秒。
  • HashMap 计数法:耗时约 12 毫秒到 18 毫秒。
  • 排序双指针法:耗时约 30 毫秒到 50 毫秒,如果输入本身接近有序,会稍快;如果随机性很强,排序开销会更大。

这个数据只代表我本地环境的相对量级,不同机器、不同 JDK 版本会有差异,但三者的相对关系是稳定可复现的。所以如果你想在 LeetCode 上追求好看的执行用时,直接上数组计数法就对了。

6. 从赎金信延伸出去:计数范式的迁移

6.1 一串老朋友:242、567、3

这道题练完,千万别急着删笔记。你后面遇到的很多字符串题目,底层都是同一个计数范式。LeetCode 242 判断字母异位词,本质就是构建两个计数数组然后逐位比较是否相等;LeetCode 567 字符串排列,在滑动窗口的每一轮更新里都要做字符频次表的增减,核心也是计数;LeetCode 3 最长无重复子串,滑动窗口里维护的最关键的还是字符出现的频次。

把这些题目串起来看,你会发现 383 教你的不是"这道题怎么写",而是"遇到字符频次问题,先想到用一个定长数组或者哈希表去维护状态"。这个直觉一旦建立,后续的迁移成本会非常低。我刷题刷到中后期,看到"字符""次数""构成"这类关键词,第一反应几乎都是计数,这确实是从 383 这类题目里练出来的条件反射。

6.2 如果字符集扩大,解法怎么变

面试官常在 383 后面追加一个变种问题:如果字符串不再限于小写字母,而是包含任意 ASCII 字符,甚至任意 Unicode 字符,你的解法还成立吗?

答案是:数组方案需要调整。ASCII 可见字符有 95 个左右,全 ASCII 字符有 128 个,你可以把数组长度扩到 128 或 256,依然可以用定长数组。但如果字符集是任意 Unicode,字符数量超过 14 万,再开定长数组就太浪费了,这时候应该回归哈希表方案。你要能跟面试官讲清楚这层递进:数组方案的前提是字符集规模小且固定,一旦不满足,哈希表的空间自适应特性就体现出价值了。

6.3 复杂度下界的追问

还有一个进阶问题常被追问:这个题的复杂度能不能低于 O(m+n)?答案是不能。因为无论用什么方法,你至少要读一遍ransomNote的全部字符,也要至少读一遍magazine中的相关部分,不然你无法知道需求是什么、供给是否足够。这就是信息论意义上的复杂度下界。在面试中如果能主动说出"这个复杂度已经是最优下界",往往比单纯写对代码更能给面试官留下印象。

7. 实操经验与细节补充

代码写多了你会发现,很多 LeetCode 的简单题不是难在思路,而是难在把限制条件读到脑子里。383 的题目描述已经把"只含小写字母"和"每个字符只能用一次"写得明明白白,剩下的就是照做。做这类题我一般先回答三个问题:字符集是什么?是否区分大小写?同一个字符能否重复使用?这三个答案基本决定了解法框架。

还有几个工程层面的经验,顺便分享给你:

数组的声明要放在方法最前面,不要在循环体内反复new。LeetCode 判题环境宽松,但生产环境的代码审查工具会直接把循环内新建数组标为坏味道。如果你刷题习惯不好,到写业务代码时会下意识写出性能很差的代码。

字符串遍历用增强 for 循环还是下标访问,本质上差距不大,但for (char c : magazine.toCharArray())这种写法在可读性上更好,也避免了每次循环都重新计算magazine.length()。C++ 里类似地写成for (char c : magazine),简单直接。

如果你写了多语言版本对照,注意 Java 的char是 16 位无符号类型,做c - 'a'时自动提升为 int,不会有溢出问题;C++ 的char可能是无符号也可能是有符号,但c - 'a'同样安全;Python 的ord(c) - ord('a')则要显式调用ord函数,别忘记。不同语言在字符到索引的转换上写法不同,但思路完全一致。

最后再说一个我调试时常用的技巧:如果你怀疑某个字符串题的做法在边界上有问题,就手写几组"同字符不同数量"的用例跑一遍。比如"aa"对"a"、"aaa"对"aab"、"abc"对"ab",这类用例最容易暴露只判断存在性、不判断数量的逻辑漏洞。这个习惯救了我好几次,也建议你保留。

这道题后续的扩展空间其实很大,比如把它改成"magazine 中的字符可以重复使用任意次",那解法就变成了简单的字符存在性判断;再比如改成"返回能构成的最大长度",又需要二分答案加计数验证。每一次变体都在考验你对计数范式的掌握深度,而 383 正是这一切的起点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 11:09:28

场景识别中的词袋模型:从SIFT特征到SVM分类完整实践

这门课布置到第三份作业时&#xff0c;不少同学盯着题目里的 Scene Recognition with Bag of Words 有点发懵。手里刚会的深度学习工具还没捂热&#xff0c;突然要求回到十年前的传统视觉套路&#xff0c;第一反应多半是&#xff1a;这都什么年代了&#xff0c;词袋模型还有必要…

作者头像 李华
网站建设 2026/10/15 11:09:15

TypeScript类型守卫:告别as强转的四种实践方案

1. 为什么我们需要告别 as 强转 在 TypeScript 开发中&#xff0c; as 类型断言就像是一把双刃剑。我刚接触 TS 时也经常滥用这个语法&#xff0c;直到在团队协作中踩了几个大坑才明白问题的严重性。想象一下这样的场景&#xff1a;你从后端拿到一个 API 响应&#xff0c;随手…

作者头像 李华
网站建设 2026/10/15 11:08:59

Mission Planner参数调优实战:从AHRS到FS的避坑指南

简介&#xff1a;这份文档面向无人机、航模飞控调试的入门与进阶用户&#xff0c;针对 Mission Planner 地面站参数树条目繁多、英文术语晦涩、新手难以快速定位关键项的痛点&#xff0c;对常用参数逐条进行中文翻译与要点归纳&#xff0c;可作为对照调参时的速查手册。资源包内…

作者头像 李华
网站建设 2026/10/15 11:07:55

实用具身机器人从设计到落地:电气拓扑、机械臂选型与排障实战

具身机器人这两年从实验室一路火到产业圈&#xff0c;我身边做机械臂的朋友、搞强化学习的同学、甚至做工业自动化的老工程师&#xff0c;最近都在聊同一个话题&#xff1a;怎么让机器人真正"用起来"。不是那种在展台上走两步、抓个方块就完事的演示&#xff0c;而是…

作者头像 李华