ARTICLE DETAIL

资讯详情

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

哈希表算法实战:从两数之和到最小覆盖子串的LeetCode Hot100通关攻略

哈希表算法实战:从两数之和到最小覆盖子串的LeetCode Hot100通关攻略 1. 先把哈希表这层窗户纸捅破如果你打开LeetCode Hot100翻到“哈希”这个标签大概率会看到两数之和、字母异位词分组、最长连续序列这一串老朋友。很多初学者会误以为哈希就是“存键值对的玩意儿”背个HashMap语法就冲题了结果到了“和为K的子数组”卡半天到了“最小覆盖子串”直接心态崩了。我在一线刷题带人的时候发现一个特别普遍的现象能把哈希表用明白的人和只会用HashMap的人进阶速度完全是两个量级。说白了哈希表这层窗户纸得先捅破——它本质上是一种“通过精确计算存储位置来换取查询速度”的思维而不是一个API。哈希算法在这类题目里的角色是给“快速判断某个元素是否出现过”和“统计某些东西出现的频次”提供近乎O(1)的底层能力。LeetCode Hot100里凡是涉及到“找重复”“找缺失”“统计次数”“快速匹配”的题基本都绕不开这个结构。Hot100之所以把哈希排在最前面是因为它是串联数组、字符串、滑动窗口、链表、树的底层基础设施后面你刷一切题型都会回来用哈希。这篇文章我会按自己的实战视角把Hot100里哈希相关题目的底层逻辑、解题套路、代码里暗藏的坑全部拆开揉碎讲一遍。无论你是一刷的小白还是二刷巩固的老手我都尽量让你读完有一种“原来哈希题是这么个套路”的通透感。2. 哈希表到底解决了什么问题——先理解再做题2.1 用生活场景说清“哈希思维”想象一下你有一排没贴标签的储物柜里面放了100本书。现在别人问你“第37号书在哪”你只能一本一本翻运气好第一本就中运气差得翻100本这就是数组顺序查找的困境。哈希表的思路是提前给每本书算一个“楼层号”和“柜子号”问的时候直接按公式去对应的柜子拿一步到位。这个“公式”就是哈希函数。LeetCode里最常见的哈希函数是hash key % capacity这样朴素的取模运算当然Java的HashMap在JDK8之后内部是(h key.hashCode()) ^ (h 16)再配合数组长度取模还会对低16位做扰动目的都只有一个让数据尽可能均匀散列减少碰撞。2.2 哈希冲突处理刷题也要懂一点很多人会忽略冲突处理觉得这是面试八股文跟刷题无关。实际上你在做“字母异位词分组”时如果用String的char[]转String做key底层就是在处理潜在的哈希冲突——因为不同的字符串理论上可能映射到同一个哈希桶。JDK8的HashMap在桶内冲突超过8个时会转成红黑树就是为了把冲突情况下的查询从O(n)降到O(logn)但刷题场景我们通常不考虑这种极端情况反而要小心的是自己设计的哈希函数是否足够“分散”否则碰撞一多理论O(1)直接退化成O(n)。这里给一个实操层面的建议做题时不要自己造复杂的哈希函数直接用Java/C/Python内置的HashMap/HashSet语言实现者已经帮你处理好了散列和冲突。你自己造轮子反而容易踩坑实测下来得不偿失。2.3 哈希表、HashMap与HashSet的分工为了把“哈希”这个大词落到Hot100的具体场景里先区分三样东西HashMap存key-value对用于“记录某个状态对应的值”比如“前缀和 - 出现次数”。HashSet只存key不存value用于“判断某个元素是否出现过”底层就是个阉割版HashMap。数组当哈希使当key的取值是有限且紧凑的整数范围比如ASCII字符范围数字范围时用数组下标做key往往比HashMap更快更省。这一条太重要了。Hot100里有大量题目比如“无重复字符的最长子串”、“最小覆盖子串”用数组做哈希因为字符范围固定128或256在速度和代码简洁度上都碾压HashMap。实操中如果你发现HashMap的key是Integer或Character这种离散密集类型先停下来想想数组能不能替代。3. Hot100中哈希的三大高频出题模式3.1 模式一先历史记录再回头查询这是哈希表最朴素的应用一次遍历把见过的记下来每次处理新元素时回头看看“之前有没有能满足条件的记录”。典型如“两数之和”从左往右扫每扫到一个数nums[i]就去补数target - nums[i]是否在之前出现过。为啥不先全存进Map再二次遍历因为一次遍历边存边查天然避免了重复元素覆盖导致的问题也省了第二次循环。这个套路几乎贯穿整个Hot100的哈希题两数之和四数相加II最长连续序列这里略有变形是先全存再查询实操心得是用“边遍历边查历史”的模板第一反应就是定义“历史记录的key是什么value是什么”。value往往不是索引就是出现次数这取决于题目要返回下标还是统计次数。3.2 模式二分组归类寻找特征一致的集合有一类题需要你把“有相同特征”的元素归到一组比如“字母异位词分组”。异位词的意思是两个字符串的字符组成相同、排列不同。很多人第一反应是排序后比较eat和tea排序后都是aet。这个思路没问题但它本质上是把“特征提取”交给了排序。如果追求极致性能可以用字符计数作为key准备一个int[26]数每个字符出现的次数转成字符串比如1#0#0#1#0#...作为HashMap的key。这样同样字符组成的字符串无论如何排列计数数组都一样从而实现了O(n)的归类而不是O(klogk)。到这里你会发现哈希题的第二个灵魂问题“我该拿什么当key”同一个场景选排序串当key简单直观选计数串当key复杂但高效两种都通过关键看你是否理解当前题的瓶颈。Hot100刷多了你会形成一种条件反射级的能力看到“分组”立刻想“我拿什么东西唯一标识这组的特征”。3.3 模式三前缀状态配平用差值找子数组如果说前两种模式是哈希的入门第三类模式就是哈希真正让你“哇”出来的地方“和为K的子数组”。这题的经典之处在于暴力循环需要O(n²)而一个前缀和数组加一个HashMap能直接拉到O(n)。核心逻辑一句话sum[i]表示从0到i的前缀和那么子数组[j, i]的和 sum[i] - sum[j-1]。我们要找“等于K的子数组个数”等价于在当前前缀和sum[i]下找历史上有多少个sum[j-1]等于sum[i] - K。实操中用HashMap记录“前缀和值 - 出现次数”遍历整个数组时边算边存。这里有个非常隐蔽的坑一定要先查Map再更新Map不然前缀和刚好等于K的时候会把自己下一次统计污染掉。具体来说如果当前位置的前缀和等于K你需要在map里查到并计数然后才把当前前缀和加入map顺序反了就会重复计数。这类“前缀和哈希”的套路难度跨度极大可以从“和为K的子数组”一路延展到“连续数组”、“和为K的最长子数组”等变种。Hot100里出的基本都是基础款但把基础款吃透面试时遇到进阶款你也能一眼识破。4. 经典题实战拆解哈希的正确打开方式4.1 两数之和从暴力到哈希的思维跃迁这道题Hot100直接排第一。暴力做法是双重循环时间复杂度O(n²)哈希做法是一次遍历O(n)。代码极短public int[] twoSum(int[] nums, int target) { MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { int need target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[0]; }这里面的核心细节是value存下标key存数值。为什么要存下标因为题目要求返回两个数的索引位置。为什么可以边遍历边put因为一个数只用一次如果先全部put再遍历碰到[3, 3]、target6这种重复值场景就会出问题——后put的3会覆盖前面的3导致丢了一个答案。4.2 字母异位词分组选对key是成败关键题目要求把互为字母异位词的字符串分到同一组。两种主流写法方式一排序字符串当keypublic ListListString groupAnagrams(String[] strs) { MapString, ListString map new HashMap(); for (String s : strs) { char[] ca s.toCharArray(); Arrays.sort(ca); String key String.valueOf(ca); map.computeIfAbsent(key, k - new ArrayList()).add(s); } return new ArrayList(map.values()); }方式二字符计数当keypublic ListListString groupAnagrams(String[] strs) { MapString, ListString 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()); }两条路都AC但“选key”的思维是有级别的。方式一代码少但每次排序O(klogk)k是字符串长度方式二O(k)提取特征总体更快。如果面试官追问“你key的选取依据是什么”你要能说出来“key必须能唯一代表一组所有元素的共同特征且不同特征的key互不冲突”。这个思想可以重复利用到很多题比如“有效的字母异位词”用计数数组“查找重复的子树”用序列化串当key“亲密字符串”用差异位置索引当key。4.3 最长连续序列HashSet的“存在性判断”妙用这题给一个未排序的整数数组要求找出数字连续的最长序列长度。经典解法是先把所有数字丢进HashSet然后遍历每个数字只从“连续序列的起点”开始向后数。所谓的“起点”就是如果当前数num的前一个数num - 1不存在于Set中那么num就是一段连续序列的起点。此时才用一个while循环往Set里猛查num 1、num 2……统计长度。这个思路的巧妙之处在于它解决了暴力解法里的重复计算每个数字只会被当作起点遍历一次后续的每一个数也只会被访问常数次整体复杂度O(n)。要是没有“只从起点数”这个优化暴力解法会退化到O(n²)。同时这个优化极度依赖哈希表的O(1)存在性查询——它就是利用HashSet来把“找前一个存不存在”变得无比便宜。注意这道题千万别试图排序解决。虽然排序后一趟扫描也行但时间O(nlogn)在LeetCode上能过却违背了题目对O(n)的隐含要求。Hot100刷题的一个隐形训练目标就是让你识别“什么时候必须用空间换时间”。4.4 和为K的子数组前缀和加哈希的高级组合拳第3.3节讲了核心思路这里补一个更完整的代码实现public int subarraySum(int[] nums, int k) { MapInteger, Integer prefixCount new HashMap(); prefixCount.put(0, 1); // 前缀和为0的出现次数初始为1代表空前缀 int sum 0; int count 0; for (int num : nums) { sum num; count prefixCount.getOrDefault(sum - k, 0); prefixCount.put(sum, prefixCount.getOrDefault(sum, 0) 1); } return count; }代码看起来很短但里面暗含三个关键决策为什么要put(0, 1)因为如果某个前缀和本身等于k比如从0到i整段和就是k那sum - k 0你需要能在map里找到前缀和0。初始化这个记录保证了从数组头开始的子数组不被漏掉。为什么先查后更新如果先更新再查当前这个前缀和会被计进“历史前缀”导致子数组长度为0的情况被算进去容易出现“差为0”的假结果。为什么value是次数而不是下标因为题目统计的是个数而非位置多个前缀和相同时它们分别能构成不同的子数组计次数即可。这一类“前缀和配平”的题是哈希从“工具”升级为“核心算法”的典型场景。如果你能把这道题复盘清楚后面遇到类似题型基本都能举一反三。5. 哈希表的工程级使用细节——绕过那些防不胜防的坑5.1 初始化容量到底有没有必要很多人在刷题时直接new HashMap()在Hot100大部分题目里确实够用。但如果你用超大用例测试会发现扩容是个隐藏的性能杀手。HashMap默认初始容量16loadFactor是0.75也就是存到12个元素就会扩容。扩容要rehash所有键值对是增量里比较昂贵的一步。实操建议是如果你知道大概的数据规模初始化时直接给容量。比如两数之和里数组长度可能10万个你可以new HashMap(nums.length * 2)减少扩容次数。注意这里给的是容量上限而不是“元素个数上限”因为还要考虑负载因子。比nums.length稍大一点通常就够了给太大反而浪费内存。5.2 key的选择直接决定是否踩坑使用HashMap时key对象要正确重写equals()和hashCode()。如果你用自定义对象做key但不重写这两个方法那么map查的是“同一个对象引用”而不是“内容相同的对象”这会导致业务上匹配不上的诡异bug。Hot100里的哈希大多用字符串、整数做key基本没这个问题但后续刷“并查集”、“图论”这类更高级的内容时如果自定义节点塞进HashMap/HashSet就得特别注意这一条。另外一个朴实无华但容易踩的坑是用StringBuilder做key。StringBuilder没重写hashCode和equals同一个字符串内容每次new出来的对象哈希值不一样放进HashMap后根本查不到。还有一点关于Integer的缓存陷阱Java的Integer默认缓存了-128~127可能导致你以为的“两个不同对象”实际上是同一个缓存对象但当数字超过127时比较会返回false而equals()才能正确比较值。在做哈希题时如果涉及到把数字当成value从map里取出来再做比较最好一律用equals()避免遭受拳击缓存的毒打。5.3 遍历中修改map引发的并发修改异常这个坑我见得太多了。有些题目需要在遍历HashMap的过程中删除某些元素比如“前K个高频元素”的变种直接在foreach循环里调map.remove()会抛ConcurrentModificationException。正确姿势是用Iterator的remove()方法或者把条件统计到List里再统一删除。// 错误姿势 for (String key : map.keySet()) { if (condition) map.remove(key); } // 正确姿势 IteratorMap.EntryString, Integer it map.entrySet().iterator(); while (it.hasNext()) { Map.EntryString, Integer entry it.next(); if (condition) it.remove(); }5.4 key的排序问题HashMap是无序的Hot100里有一类题不仅要求统计频次还要求按某种顺序输出结果。比如“前K个高频元素”如果用HashMap统计完再放进PriorityQueue排序这个组合操作的本质是“哈希负责统计堆负责排序”。如果你试图让HashMap本身有序那是搞错方向了应该用TreeMap或者额外排序。实操结论是HashMap负责“快速找到”排序交给对应的数据结构。哈希的核心价值永远是查询和统计不是排序。做题时先想清楚这一步能少走很多弯路。6. 哈希与滑动窗口的联动——字符频次维护的艺术6.1 无重复字符的最长子串HashSet的窗口收缩这是Hot100里滑动窗口和哈希联动的启蒙题。思路是维护一个左指针和一个右指针右指针每次往前走一步如果发现新字符已经在窗口里出现过了左指针就不断右移直到窗口内没有重复字符。判断“在窗口里是否出现过”这个动作就是HashSet登场的地方。实际上这里存在两种写法用HashSetCharacter存储窗口内已有的字符右指针移动时判断contains重复则remove左指针字符并左移。用HashMapCharacter, Integer存“字符 - 最近一次出现的下标”重复时直接把左指针跳到重复字符最后一次出现的位置1。后者比前者平均更快因为前者左指针是一步一步挪后者是直接跳。这两者都用哈希结构但存储的信息量不同后者存了位置信息能将O(n)到O(n)的常数优化得更漂亮。我自己刷题时的体会是Hot100里凡是“子串”相关HashSet/HashMap加双指针几乎是条件反射。6.2 最小覆盖子串数组哈希才是隐藏的主角这道题是Hot100里少有的“困难”难度但拆开看并不虚。题面要求给两个字符串s和t找出s中包含t所有字符的最小子串。核心套路还是滑动窗口但判断“窗口是否覆盖了t中所有字符”这一步非常关键。很多人会想MapCharacter, Integer need统计t里每个字符的需求量MapCharacter, Integer window统计当前窗口的字符量然后每次右移后逐个比对太慢了。更高效的实现是用一个变量formed记录“已经满足需求的字符种类数”每当窗口内某个字符的数量达到了need里的需求量formed加一当formed need.size()时说明窗口覆盖完整可以尝试收缩左指针。这里真正让我推荐的是用int数组替代两个HashMapint[] need new int[128]; int[] window new int[128];因为字符范围确定有限用数组下标直接定位字符虽然哈希结构也能做但数组的实现速度更快、代码更不易出错。唯一需要维护的额外变量是formed用它记录满足条件的字符类型数量这比每次循环比较两个Map的所有键省太多时间。这道题也是衡量“哈希是否学透”的分水岭哈希本身只是一个快速定位真正的算法在滑动窗口的收缩判断上。当一个题目同时考察两种高频技巧时它就出现在Hot100的困难档。6.3 滑动窗口框架与哈希的配合心得我个人刷完Hot100里所有滑动窗口题后总结出一个通用骨架右指针往右扩展把新元素更新进哈希结构。判断当前窗口是否满足条件查哈希结构。满足条件后右指针暂停尝试收缩左指针同时更新哈希结构。记录满足条件的最优答案。这个框架几乎能通吃“无重复字符的最长子串”、“最小覆盖子串”、“字符串的排列”、“找到字符串中所有字母异位词”这几道经典题。区别只在于“哈希结构里存什么、更新什么、判断条件是什么”。你只要把这个框架完完整整写熟练哈希在滑动窗口题型里的生产力会极大释放。7. 哈希与双指针的骚操作——去重和优化7.1 三数之和的场景哈希不是万能的Hot100里“三数之和”是双指针代表题但也有不少人尝试用哈希硬解。思路是先固定一个数剩下的两个数用“两数之和”哈希法找。理论上可行但实际写出来会发现去重极其麻烦很容易产生重复三元组然后被迫用SetListInteger去重。本质上哈希在处理“多个数组合”类题目时去重能力天然弱鸡。因为哈希擅长的是“快速找单个匹配”而“组合类”题目需要的更多是“有序枚举”。三数之和用排序双指针时间O(n²)空间O(1)用哈希时间也是O(n²)空间O(n)还平添一堆去重代码。这是我踩过坑后的肺腑之言在Hot100里不是所有题都该硬套哈希。7.2 四数相加II两个HashMap分治与三数之和形成鲜明对比的是“四数相加II”。它给了四个数组每个数组取一个数四数之和等于0。这时哈希反而成为最优解统计前两个数组所有两数组合的和放进HashMap和值-次数后两个数组的所有两数组合去HashMap里查目标0的补数。这个思路本质是“分组降维”把四数问题拆成两个两数问题各查一次。因为不要求去重返回的是组合个数哈希计数正好完美适配。拆开看四数相加II其实就是两数之和的升维版本。这个对比很有教育意义哈希擅长“匹配查询”和“计数”不擅长“去重枚举”。做题时先判断题目本质是哪种再决定哈希是否适用而不是看到“多个数”就往哈希上冲。7.3 sort双指针哈希的三位一体Hot100里的哈希题最后一类很有意思的是“哈希排序组合”。比如“根据字符出现频率排序”统计字符频率用数组或HashMap之后按频率排序可以用桶排序或优先队列。这里哈希完成统计排序解决输出顺序两者职责分明。刷到这里你会发现哈希真正的价值不是单独出现而是作为算法组合件里的“查询加速器”。它跟双指针、滑动窗口、前缀和、排序都有联动的经典场景。所谓“Hot100哈希”其实考察的是你能否在多种算法交织时准确判断哪一环适合用哈希来提速。8. 常见问题与排查技巧实录8.1 “为什么我的HashMap查不到刚put进去的key”这往往是key对象的hashCode()或equals()没正确实现导致的尤其是自定义对象或StringBuilder做key。排查方法是打印key的类名看是不是用了可变对象。字符串用常量、Integer之类的不可变对象做key最稳妥。8.2 “明明复杂度是O(n)为什么还是超时”大概率是你在循环里频繁调用了一些看起来是常数的操作比如map.getOrDefault每次创建一个新的默认对象或者用String拼接当key每次拼接都是O(k)。替换成数组哈希或StringBuilder就能快不少。某些情况下还可以给HashMap初始化容量减少扩容损耗。8.3 “统计字符频率用Map还是数组”Hot100里凡是统计英文字母、ASCII字符频次的题数组优先。比如int[26]或int[128]直接下标操作省去装箱拆箱、哈希计算、哈希冲突处理。字符集如果是utf-8中文这种大范围才考虑HashMap。实际操作中这个选择能影响一倍以上的耗时差距。8.4 “两数之和的变种排序加双指针可以吗”如果题目要求返回下标排序会破坏下标映射此时哈希是最优解如果题目只要求判断是否存在排序加双指针也行。刷题时先问自己题目需要下标吗需要去重吗需要计数吗这三个问题决定了数据结构和算法的选择。8.5 一个防坑自检清单[ ] key类型是否是String、Integer等不可变类型[ ] 如果返回下标value是否存了正确下标[ ] 前缀和类的题目是否先查后存[ ] 统计窗口类题目是否初始put了“空窗口”对应的值[ ] 需要排序时是否错误试图让HashMap无序变有序[ ] 自定义对象做key时是否重写hashCode()和equals()9. 从Hot100哈希出发的拓展路线Hot100里的哈希题只是起点。我刷完这些之后明显感觉到下面的拓展路径很顺哈希链表LRU缓存Hot100里也有本质是HashMap定位节点双向链表维护顺序。哈希树二叉树高频节点、根据前序中序重建二叉树哈希用来快速定位根节点在中序序列中的位置。哈希图克隆图、判断二分图等哈希表用于存储“原节点到新节点的映射”。这些题目看似和“哈希”标签无关但底层全是哈希的映射思维。如果你能把Hot100哈希这一块吃透后面碰到这些更复杂的映射结构就能自然过渡而不觉得突兀。我个人刷题的经验是每道哈希题多问自己三遍——“我在用什么做key为什么用这个做key能不能用更轻量的结构替代”这三个问题练熟了触类旁通的速度会远超预期。哈希表不是一个API它是一套“空间换时间、以存储换速度”的设计思路真正掌握它的标志是你在设计一个搜索策略时会下意识想到“能否用哈希把某个维度预存起来把查询变成O(1)”。Hot100的哈希专题刷完再回头看这些题你会发现最终留下的不是代码模板而是一种直觉看到“是否存在、是否重复、统计次数、快速匹配”条件反射式地想到哈希看到“连续子数组、子串、分组特征”条件反射式地想到“用什么当key”。这个直觉才是你刷完Hot100哈希之后真正拿到手的东西。
返回列表