1. 哈希表入门,为什么绕不开这三道题
LeetCode 刷到哈希表专题,有一组题几乎是所有刷题人都会碰到的组合:第 1 题“两数之和”、第 49 题“字母异位词分组”、第 128 题“最长连续序列”。我在带朋友入门刷题时,总喜欢拿这三道题当第一组材料,因为它们刚好覆盖了哈希表最核心的三种使用场景:查找补数、分组归堆、区间合并。
先说个直观感受。很多人觉得哈希表就是“用空间换时间”,这个说法没错,但太笼统。真正写题的时候会发现,哈希表的难点从来不是“会用 map”这件事,而是“什么时候该想到用 map”。第 1 题告诉你:当你要找某个数的“另一半”时,可以把已经见过的数存下来;第 49 题告诉你:当你要把某些元素“归到同一类”时,可以用一个统一的 key 作为分组的标识;第 128 题告诉你:当你要判断“连续性”时,可以通过检查前驱是否存在来避免重复计算。
这三道题的解法层层递进,从最简单的“一次遍历存 map”,到“自定义 key 做分组”,再到“利用 set 的 O(1) 查询做线性扫描”,难度是逐步抬升的。而且这三道题都能提炼出通用的模板代码,吃透之后,后面很多中等难度的哈希表题(比如第 560 题和为 K 的子数组、第 290 题单词规律)思考起来会顺很多。
这篇文章适合正在刷题准备面试的人,也适合那些刷了题但总是“看答案能懂、自己写就卡壳”的朋友。我会把三题的思路拆开揉碎了讲,包括为什么这么做、循环怎么写、边界条件在哪,最后整理一份哈希表的通用解题模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写代码之前,先建立哈希表的三种“条件反射”
2.1 从“暴力查找”到“空间记忆”的思维转弯
我见过太多人一上来就背题,结果题目稍微变个形式就不会了。哈希表题的根子其实是同一个思维模型:你在遍历数据的过程中,是否能把“过去的信息”以 O(1) 的成本留下来,供“当下的判断”使用。
举一个生活中的例子。你去超市买东西,每件商品的价格都贴在货架上,你要算总价,最快的方式是拿一件扫一件,这就是一趟遍历。但如果有人问你“我刚才有没有买过牛奶”,如果你没记账,只能回头翻购物车,这就是暴力扫描;而如果你每拿一件东西就念一遍名字记在脑子里,这个问题立刻就能回答。哈希表扮演的就是那本“记忆账本”。
第 1 题是最直接的体现。暴力解法是两层循环枚举所有数对,时间复杂度 O(n²)。这在一万个数的数据规模下还没问题,但 LeetCode 的测试数据经常给到十万甚至百万级别,O(n²) 必然超时。于是我们把第二层循环“数组里有没有 target - nums[i]”这个查询,从“扫描数组”变成“查哈希表”,单次查询就从 O(n) 降到了 O(1)。
这个“把查询换成哈希查找”的思路,表面上看只是换了数据结构,实际上是整个复杂度量级的下降。后面你会看到,49 题和 128 题本质上也是在不同维度上做同类优化。
2.2 哈希表在算法题里的三种死法:查找、去重、计数
我把哈希表的常见用法归纳成三种,方便你看到题以后快速对号入座。
第一种是查找。典型特征:题目问你“是否存在某个元素”“是否出现过”“是否满足配对条件”。第 1 题里我们查 target - nums[i] 是否存在;第 128 题里我们查 num - 1 是否存在来判断它是不是起点。这类题用 HashSet 就够,不需要记录额外信息。
第二种是计数。典型特征:题目问“出现次数”“频率最高”“能否用若干次构成”。比如判断两个字符串是不是互为字母异位词,本质上是比较 26 个字母的出现次数是否完全一致。这种场景可以用 HashMap 计数,也可以直接用数组(如果键的范围固定且较小)。
第三种是分组。典型特征:题目要求把“同一种特征”的元素放到一起。第 49 题的“字母异位词分组”就是个教科书式的案例——我们把每个字符串按照“字母出现次数”编码成一个 key,同一个 key 下的字符串就是同一组。
这三种用法不是孤立的,它们经常叠加出现。第 128 题虽然表面上是“去重 + 查找前驱”,但如果你用并查集去做,就又变成了“按根节点分组”的模型。心里有了这张地图,遇到新题就不会慌。
2.3 数组其实也是一种哈希表
在聊具体题目之前,必须提一个很多初学者忽略的点:数组就是最朴素的哈希表,而且它的性能比 HashMap 更好。在 Java/C++ 中,HashMap 的键需要计算 hashCode,还要处理哈希碰撞,而用数组时,下标直接就是键,访问复杂度是严格的 O(1) 且常数极小。
判断什么时候能用数组代替 HashMap,就看两个条件:键的取值范围是否有限、是否连续可枚举。比如第 49 题里,我们要统计的是一个字符串中 26 个字母的出现次数,键就是 0 到 25 的整数,完全可以用 int[26] 替代 HashMap<Character, Integer>。同理,如果题目限定字符串只含小写字母,很多计数场景都可以用 int[26]。
这一点看起来小,实际写起来差别很大。数组不需要装箱拆箱,内存占用低,代码也更简洁。我刷题时习惯先问自己一句:“这里能用数组做键吗?”能就用数组,不能才上 HashMap。
3. 第 1 题:两数之和——哈希表查找的“开宗立派”
3.1 题目到底在考什么
题目本身很简短:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。
很多人第一次看到这题会觉得“太简单了”,但真正面试时这道题依然高频出现。原因在于它考察的不只是“会不会写双重循环”,而是“能不能从暴力解法里提炼出重复查询,并用哈希表优化”。面试官往往会让候选人先讲暴力解,再引导优化到 O(n),整个思考过程能比较全面地反映编码基本功。
这里有一个容易忽略的陷阱:数组里可能有重复元素,但题目要求返回两个不同元素的下标。意思是,你不能用同一个元素凑数。比如 nums = [3, 3],target = 6,唯一的合法答案是 [0, 1],而不是 [0, 0]。这是因为题目描述中的“同一个元素不能使用两遍”约束了你的查找逻辑。
3.2 一次遍历的完整推导
先看最直观的暴力版本:
java复制public int[] twoSum(int[] nums, int target) {
int n = nums.length;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[0];
}
复杂度是 O(n²),空间 O(1)。问题出在第二层循环:每次我们在找 target - nums[i] 时,都把整个数组重新扫描了一遍,而这些扫描中包含了大量重复的工作。比如 nums[0] 已经被扫过一遍了,nums[1] 在找补数时又得把 nums[0] 再比一次。
优化的思路是:能不能把“已经扫描过的元素”存下来,之后每次只查这个存储结构?随着遍历的进行,哈希表里累积的元素越来越多,查询成本却始终是 O(1)。这就是空间换时间。
改成一趟遍历:
java复制public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
map.put(nums[i], i);
}
return new int[0];
}
为什么可以“先查再存”?因为题目只要求一对答案,而且前面的元素和后面的元素配对时,当前面的元素先被存进去了,等遍历到后面的元素时,查询就能命中。如果你把顺序反过来“先存再查”,会出现一个问题:当 target 是某个元素的两倍时,比如 nums = [3, 3], target = 6,遍历到第二个 3 之前先把它自己存进去了,查 map 时找到的就是自己,下标重复,会返回错误结果。
这里有一个值得养成的编码习惯:迭代变量不要写死为 i,而是把当前元素值单独拎出来。下面这种写法更清晰:
java复制for (int i = 0; i < nums.length; i++) {
int cur = nums[i];
...
}
3.3 为什么“先查再存”的顺序不能乱
这一点是第 1 题最常见的坑,我要单独拎出来讲。
如果改成先存再查,也就是:
java复制map.put(nums[i], i);
if (map.containsKey(target - nums[i])) {
return new int[]{map.get(target - nums[i]), i};
}
表面看起来差不多,但在一个用例上会挂:nums = [3, 2, 4], target = 6。遍历到 i = 0 时,先存入 3,再查询 6 - 3 = 3,结果 map 里有 3,于是直接返回 [0, 0],但题目要求两个数的下标必须不同。3 + 3 并不是用两个不同元素凑出来的,正确结果是 [1, 2](对应 2 和 4)。
这个坑的根源在于:你没有区分“这个补数来自之前遍历过的元素”和“这个补数就是当前元素自己”。先查再存天然保证了 map 里存的都是“当前位置之前的元素”,所以查到的下标永远不会等于当前 i。
还有一个小细节是重复元素的处理。假设 nums = [3, 3],在“先查再存”的逻辑中,i = 0 时把 key=3 的下标存为 0,i = 1 时再遇到 3,map.put(3, 1) 会覆盖之前的下标。但因为答案在 i = 1 的查询阶段就已经返回了 [0, 1],覆盖行为不会造成影响。可如果你把 map 当成“记录元素最后出现位置”来用,在别的题里就要格外小心覆盖问题。
3.4 变体题与延伸思考
第 1 题最经典的变体是 167 题“两数之和 II - 输入有序数组”,因为输入已经升序排列,可以用双指针 O(1) 空间解决。另一个变体是 15 题“三数之和”,虽然看起来只是多了一层循环,但因为要求“不重复三元组”,哈希表反而不是最优方案,排序 + 双指针才是主流解法。
从这题里抽离出的“补数查找”思想,在后面的 454 题“四数相加 II”里也大放异彩——把四个数组两两分组,前两组的和存进 map,后两组查补数,复杂度从 O(n⁴) 降到 O(n²)。所以说,第 1 题不仅是入门,更是一连串中等题的原型。
4. 第 49 题:字母异位词分组——哈希表的“分组艺术”
4.1 从题目到场景:什么算“同一类”
第 49 题的描述也不复杂:给你一个字符串数组,请你将字母异位词组合在一起。所谓字母异位词,指的是字母重新排列后能组成的单词,比如 "eat"、"tea"、"ate" 就是一组,因为它们都包含一个 e、一个 a、一个 t。
你可以把这个问题想成整理衣柜:一堆衣服散落在地上,你要按“颜色 + 类型”把它们分别挂好。这里的“颜色 + 类型”就是分组的依据。那对字符串来说,什么才算“同一类”的统一标识?既然异位词只是字母顺序不同,那就把字母顺序抹掉,保留“每个字母出现几次”这样的信息作为 key。
常见的不那么优雅的做法是:把字符串排序,排序后相等的字符串互为异位词。"eat" 排序后是 "aet","tea" 排序后也是 "aet",它们就被归到一起。这个方案可行,但排序一个字符串的时间是 O(k log k),其中 k 是字符串长度。如果题目给的数据是很多长字符串,性能上会有点亏。
4.2 两种 key 的设计方案对比
方案一:排序字符串作为 key。代码非常简短:
java复制public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] arr = s.toCharArray();
Arrays.sort(arr);
String key = new String(arr);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}
方案二:用长度为 26 的计数数组编码 key。比如 "eat" 的计数情况是 a:1, e:1, t:1,把它拼接成 "#1#0#1#0#......",只要是异位词,编码结果必然一样。
java复制public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
int[] count = new int[26];
for (char c : s.toCharArray()) {
count[c - 'a']++;
}
StringBuilder sb = new StringBuilder();
for (int n : count) {
sb.append('#').append(n);
}
String key = sb.toString();
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}
两个方案的时间复杂度不同。排序法每个字符串耗时 O(k log k),总耗时 O(n·k log k);计数法每个字符串只需要扫一遍字符再加一遍 26 的长度,耗时 O(n·k),虽然没有改变数量级,但常数更优,且避免了排序的额外开销。从工程角度,当字符串长度较大时,计数法明显更快。
从可读性角度,我一般建议面试时先讲排序法,因为逻辑直白、写起来快;但如果你能主动提到“对于只包含小写字母的输入,可以用计数法把时间复杂度优化到 O(nk)”,这会是面试中的一个加分项。
4.3 编码 key 时的两个细节
计数编码的方案有个特别容易写错的地方:分隔符不能省。如果你把计数数组拼接成 "101010..." 这种形式,遇到 count = [1, 0, 1] 和 count = [11, 1] 时就会冲突。前者表示 a 出现 1 次、b 出现 0 次、c 出现 1 次,后者表示 a 出现 11 次、b 出现 1 次。如果没有分隔符,它们的key都会变成 "1011" 之类模棱两可的结果。加一个 '#' 或者 ',' 作为分隔符就能消除歧义。这是我在代码 review 里经常提醒别人的点。
另一个细节是 StringBuilder 的拼接性能。在循环里反复用字符串相加(比如 key += "#" + n)会产生大量中间对象,开销大。用 StringBuilder 一次性拼好是更稳妥的做法,这也算 Java 面试里的老生常谈了。
4.4 边界情况与输入陷阱
字符串数组可能为空,也就是 strs.length == 0,这种情况返回空列表即可,代码天然支持。数组中可能只有一个字符串,那它自己作为一组返回。
还有一类特殊情况:字符串里包含相同字符但顺序完全相同,比如 ["a", "a"]。这两个 "a" 是同一组,编码后的 key 也相同,能正常合并到一组。空字符串 "" 是另一个边界,它的排序结果和计数编码结果都是“空串”,所有空字符串会被归到同一个组里,这也是符合语义的。
这题想传达的哈希表思想是:key 不一定非要来自自身,它可以是某种“规范化表示”。所谓规范化,就是把所有等价的元素映射到同一个标准形态。这在字符串类题里特别常见,以后遇到“同素异形体”“单词变形”这类概念,基本都能往“定义规范化 key”的方向想。
5. 第 128 题:最长连续序列——哈希表线性扫描的精髓
5.1 为什么这题比前两道难一个档次
128 题的描述是:给定一个未排序的整数数组 nums,找出数字连续的最长序列的长度(不要求序列元素在原数组中连续)。比如 nums = [100, 4, 200, 1, 3, 2],最长数字连续序列是 [1, 2, 3, 4],长度为 4。
这题的进阶要求很有意思:时间复杂度为 O(n)。看到这个要求,很多人的第一反应是“排序行不行”,排序最快也要 O(n log n),不符合要求。那只能想别的招。
连续序列的本质是“一段数字区间”。如果我们把数组里的所有数字装进一个 HashSet,就能在 O(1) 时间内判断任意数字是否存在。问题是:如何避免重复计算区间?比如 [1, 2, 3, 4] 这个区间,如果从 1 开始往后数能数到 4,那从 2 开始数也能数到 4,但后面的计算就是白费的。
5.2 核心解法:找起点,再往后推
解法思路很巧妙:对于一个数 num,只有当 num - 1 不在集合中时,num 才有可能是一段连续区间的起点。换句话说,我们要找到每个连续区间最左边的那个数,从那里开始往右延伸统计长度。
举例说明。集合为 {1, 2, 3, 4, 100, 200}。遍历到 1 时,检查 0 是否在集合中,不在,说明 1 是一个起点,于是从 1 开始数 2、3、4、5,直到 5 不在集合中,得到长度 4。遍历到 2 时,检查 1 在集合中,于是跳过,不重复计算。遍历到 100 时,检查 99 不在集合中,开始往后数,只有本身,长度 1。
算法整体只需要遍历一次数组做起点判断,再加上每个数字最多被往后延伸一次,总复杂度就是 O(n)。这里有一个很多人问的问题:“最坏情况下,如果整个数组就是 1 到 n 的连续序列,外层循环不是要重复很多次吗?”确实,外层 for 会遍历到每个数字,但只有起点 1 会进入内层 while 循环,其他数字(2 到 n)因为存在前驱,全部被 continue 跳过了,内层总步数加起来还是 O(n)。这就是“平摊分析”的典型例子。
代码实现如下:
java复制public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
int longest = 0;
for (int num : set) {
// 只从连续序列的起点开始统计
if (set.contains(num - 1)) {
continue;
}
int currentNum = num;
int length = 1;
while (set.contains(currentNum + 1)) {
currentNum++;
length++;
}
longest = Math.max(longest, length);
}
return longest;
}
注意,这里外层循环用的是 for (int num : set) 而不是 for (int num : nums)。两种写法结果一样,但遍历 set 可以避免重复值造成无意义的判断。比如数组是 [1, 2, 2, 3],如果遍历 nums,遍历到第二个 2 时会再次检查前驱并跳过,虽然不影响结果,但多了无谓操作。遍历 set 天然去重。
5.3 去重与边界条件的处理
去重的必要性很关键。假设数组是 [1, 2, 2, 2, 3, 4],如果不先去重,从第一个 1 开始往后数,能数到 4,长度是 4。这时序列长度不受重复值影响,因为重复的 2 不会让“从 1 到 4”的区间变长。但如果数组是 [1, 1, 1, 2, 3],从 1 开始数,1 -> 2 -> 3,长度是 3,重复的 1 不会让长度变成 5。所以必须去重,否则 set 里出现过的重复元素会导致计数逻辑混乱——比如 while 循环里 currentNum + 1 的判断会重复命中同一个值,陷入死循环。用 HashSet 就能从根源上解决这个问题。
空数组也是边界条件。如果 nums 为空,set 为空,longest 保持为 0,直接返回,代码天然安全。只有一个元素时,[5],5 没有前驱,是起点,while 循环进不去,length = 1,返回 1,符合预期。
还有负数和超大整数也无需额外处理。HashSet 支持任意 int 值,contains 判断也不涉及下标运算,所以负数、0、Integer.MAX_VALUE 都能正常工作。只是如果你要用数组下标映射数字,比如用 boolean[] 或 BitSet 来存出现情况,那就必须考虑负数偏移和整数范围的问题——这也是这题推荐用 HashSet 而不是数组的原因之一。
5.4 这题的“隐藏考点”:平摊复杂度分析
面试时如果只写出代码,可能只能算合格;真正加分的是能解释清楚“为什么总复杂度是 O(n)”。我建议用平摊分析的口吻讲:每个数字最多只会在一次 while 循环中被访问一次,因为只有在它是区间起点时才会进入内层循环,且一旦作为起点被访问,它会一直延伸到区间终点。区间之间不重叠、长度不相交,所以所有内层 while 的总步数不超过 n。外层 for 循环每个元素只检查一次 contains(num - 1),也是 O(1) 的。
从空间复杂度看,HashSet 存储所有数字,空间 O(n)。这是典型的“把时间压到 O(n) 但空间可能到 O(n)”的题目。如果你能顺带补充一句“如果要求 O(1) 空间,就得用原地哈希或者位图,具体要看数据范围”,面试官会觉得你对复杂度有整体把握。
这题的另一个可扩展点是并查集方案。把连续相邻的数字加入同一个集合,通过维护集合的大小来求最长连通块。但并查集的实现明显比 HashSet 线性扫描更复杂,实际面试中我倾向先讲哈希表方案,并查集作为延伸思考提一嘴即可。
6. 三题通吃的哈希表使用模板
6.1 基础模板:HashMap 的查找与更新
从三道题里提炼出来的代码模板,其实可以总结成三段式。第一段是“遍历 + 查表”,适用于第 1 题以及一切“需要判断历史信息”的场景:
java复制Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
// 1. 需要找的 key
int key = ...;
// 2. 先查表,如果命中,直接返回或更新答案
if (map.containsKey(key)) {
...
}
// 3. 再把当前位置的信息写进表里
map.put(nums[i], i);
}
写这类代码时,要警惕 put 的时机和覆盖问题:如果当前元素的 key 之前已经存在,覆盖会丢失旧值。如果旧值还有用,就需要用 merge 自定义合并逻辑,或者对 value 用 List/Set 等容器。
6.2 分组模板:key 的规范化设计
第二段是“自定义 key 做分组”,对应第 49 题。通用结构就是把每个元素转换成统一的 key,然后放进 map 对应的分组里:
java复制Map<String, List<...>> map = new HashMap<>();
for (元素 : 集合) {
String key = normalize(元素); // 规范化函数
map.computeIfAbsent(key, k -> new ArrayList<>()).add(元素);
}
// 最后返回 map.values()
normalize 这一步是整个算法的灵魂。不同题目的差异基本都体现在这个地方:异位词的 key 是排序或计数编码;同字母单词的 key 是小写化后的字母重排;变位词检查的 key 可以是每个字符的 frequency 串。知道这点,你完全可以把 49 题的解法平移到 438 题“找到字符串中所有字母异位词”等题目上。
6.3 去重模板:用 Set 做起点探测
第三段是“Set + 前驱检测”,对应第 128 题。它适用于判断“连续区间”“连通块”“可达性”的问题:
java复制Set<Integer> set = new HashSet<>(所有元素);
for (int num : set) {
// 只处理“起点”
if (set.contains(num - 1)) {
continue;
}
// 从起点往后延伸
int len = 1;
while (set.contains(num + len)) {
len++;
}
更新答案;
}
关键点在于“前驱检测”这个 if 过滤掉了大量重复计算。以后遇到求最长连续 1 的个数、最长连续子序列等问题,可以先想想这个模式能不能套。
6.4 模板的价值与边界
模板能帮你减少思考负担,但不要死记硬背。你要理解每个模板“解决了什么问题”:查找类模板解决“配对/找补数”,分组类模板解决“按特征归类”,起点检测模板解决“避免从区间中间开始重复延伸”。理解了问题的类型,题目变形成什么样都不怕。
7. 常见问题与排查技巧实录
7.1 问题一:遍历时一边修改 HashMap,导致 ConcurrentModificationException
场景:遍历 map 的过程中,如果直接调用 map.put 或 map.remove,在 Java 的 fail-fast 迭代器机制下会抛出 ConcurrentModificationException。
这类错误常出现在第 49 题的分组逻辑里,如果新手误以为“遍历 strs 过程中往 map 的某个 List 加元素没问题,那直接在遍历 strs 时改 map 结构也没问题”,就会踩坑。
解决方案:只需要把“修改 map”和“遍历 map”分开。在 49 题里,你遍历的源是 strs,不是 map,往里 add 的是 map 中某个 List,并没有增删 map 的键,所以不会报错。如果你真的需要边遍历边过滤,就用 Iterator 显式删除或者先收集再统一处理。
7.2 问题二:用可变对象做 HashMap 的 key,导致查询失效
场景:有人图方便,用 List
解决方案:key 必须用不可变对象。如果非要用数组表示计数状态,就把它编码成字符串再用;如果非要用 List,确定存进去之后绝不再修改。比如 49 题中,计数数组先编码成 String 再做 key,就是规避 key 可变性问题的最常见实践。
7.3 问题三:哈希冲突导致性能退化,需要重写 hashCode/equals
HashMap 在理想情况下各操作是 O(1),但如果有大量 key 发生哈希碰撞(比如 key 都是同一个值),Java 8 以后链表会转成红黑树,单次操作最坏也能到 O(log n)。在算法题环境里,通常是没人会故意构造哈希碰撞攻击你的,但如果你自己定义了一个糟糕的 hashCode,比如所有对象都返回同一个常数,HashMap 就退化成链表了。
排查方式:如果提交后发现时间异常慢,先检查自定义 key 的 hashCode 是否足够分散。LeetCode 这类在线评测基本不需要自己定义复杂 key,最多是 String 或 Integer,它们的 hashCode 实现足够好。
7.4 问题四:复杂度分析时的“假 O(n)”
128 题有一个容易糊弄自己的地方:外层 for 遍历 set,内层 while 也在推进 currentNum,有人会以为最坏能达到 O(n²)。实际上不是,前面说过所有内层 while 的总体执行次数被限制在 O(n)。面试时如果能主动解释“平摊分析”,说明你对复杂度的理解是到位的。
做题时有一个小习惯值得养成:提交之前标注每个循环的复杂度来源。如果发现“for 套 while”且 while 每次都会从头开始扫,那十有八九是 O(n²),需要检查是否有 visited 数组或前驱过滤来剪枝。
7.5 自查清单:提交之前过一遍
我在写完哈希表题后,会在心里过几个检查点:
- 是否考虑过重复元素?如果有重复元素,set 去重了吗?map 的 value 被覆盖会不会影响答案?
- 是否考虑过空输入?空数组或空字符串会导致 NPE 或逻辑错误吗?
- 是否考虑过 key 的可变性?自定义对象作为 key 时,是否可能被修改?
- 是否考虑了 target 是当前元素两倍的情况?如果“先存再查”会不会返回同一个下标?
- 是否考虑过 key 的编码冲突?用字符串拼接计数时有没有加分隔符?
这些问题虽然听起来琐碎,却是决定你的代码从“能跑通示例”到“能通过全部测试用例”的关键。
8. 从三题到一类题的迁移能力
刷完这三题以后,我建议你把它们当成一张地图,横向对比一下身边的同类题。第 1 题的补数思想可以迁移到 454 题“四数相加 II”和 15 题“三数之和”(不过三数之和推荐先排序再用双指针);第 49 题的规范化 key 思想可以迁移到 249 题“移位字符串分组”、242 题“有效的字母异位词”以及 438 题“找到字符串中所有字母异位词”;第 128 题的“Set + 起点探测”思想可以迁移到 594 题“最长和谐子序列”以及一些需要判断区间连续性的场景。
我自己刷题有一个体会:把一个专题的 3 道经典题吃透,比盲目刷 30 道同专题的题更有效。因为真正的能力来自“识别题目模式”,而不是“背答案”。当你做完第 1 题,能条件反射地说出“这里可以用哈希表避免每次扫数组找补数”,当你看完第 49 题,能主动思考“怎么把一组元素转化为稳定的 key”,当你看完第 128 题,能解释为什么前驱检测让平摊复杂度变为 O(n)——到这个程度,哈希表这块的基础就算真正打牢了。
如果你现在正卡在某道题上,建议别急着看题解,先把这道题归到三类中去:是找补数,还是做分组,还是找区间?确定了方向之后,再回来看这个模板,思路会清晰很多。用个人经验打个包:哈希表的题目,难点永远不是你不会写 map 的 API,而是你没有在读完题以后问自己——我能不能把过去的信息存下来,让现在的判断更快?这个问题问到位的次数多了,写代码的感觉就出来了。
