ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

哈希算法题核心套路:快速判断、空间换时间与去重技巧解析

哈希算法题核心套路:快速判断、空间换时间与去重技巧解析 一提到算法题里的哈希我第一反应其实不是哈希表数据结构的定义而是三个词快速判断、空间换时间、去重。不管是刚入门的新手还是准备笔试的应聘者跟哈希相关的题目在各大平台的出镜率都相当高。这篇内容想把哈希题背后的出题逻辑、通用套路和容易踩坑的地方一次性讲清楚。核心不是让你背题而是让你搞懂什么时候该用哈希用了以后复杂度会有什么变化以及怎么把哈希和双指针、排序、滑动窗口这些常见技巧组合起来。无论你是刷了几十题的新人还是做过几百题却总在细节上翻车的老手这篇文章应该都能给你一些可复用的判断思路和代码模板。我会尽量用容易理解的语言讲原理然后配上可以直接上手的示例代码最后再把我实际做题和帮别人排查问题时碰到的坑单独拿出来说。1. 哈希题的考法共同点先明白出题人在问什么想学会用哈希解题第一件事不是背API而是识别题目的真实诉求。哈希能解决的算法问题无论题目包装成数组、字符串、链表还是矩阵本质上几乎都能归纳成下面三种情形之一成对存在、出现次数、唯一标识。1.1 成对存在找配得上的元素最经典的两数之和就是这种。给你一个数组和一个目标值问你哪两个数的加和等于目标值。最直接的双层循环当然是正确的时间复杂度O(n²)但如果数组长度上万基本就跑不动了。换成哈希的思路就很简单遍历的时候把当前这个数记为value那么我需要的另一半就是target减value。如果这一半已经在哈希表里说明前面某个位置已经出现过和目标配对的值直接返回两个下标如果不在就把当前值和下标存进哈希。def two_sum(nums, target): seen {} for i, value in enumerate(nums): need target - value if need in seen: return [seen[need], i] seen[value] i return []这里就体现了哈希最大的优势一次遍历边存边查查找时间从O(n)降到平均O(1)整体时间复杂度变成O(n)。出题人其实不是在考你怎么找到两个数而是在考你怎么能在一次遍历内完成查找和记录。只要把题目能识别成存在一个元素和当前元素满足某种关系就可以第一时间往哈希方向想。1.2 出现次数统计谁多谁少、谁重复了还有一种特别高频的考法给你字符串或者数组问某个字符出现了几次、哪些元素重复了、谁出现的次数最多。这种本质就是在做频次统计。部分题目可以用排序加双指针来做但哈希往往是实现最简单、理解成本最低的方案。以多数元素为例给一个数组返回出现次数超过一半的那个元素。最朴素的想法是两个循环统计每个数的出现次数但更优雅的方法是用哈希记录每个数已经出现的次数并在过程中判断是否超过阈值。对于这类问题哈希真正省掉的不是那一次次比较而是重新扫描整个数组的重复劳动。你只需要把每个元素往哈希表里塞一次之后所有的判断都是O(1)级别。另一个很有代表性的例子是字母异位词分组。异位词的意思是组成字母相同但排列不同比如ate和eat就是一对。如果两个字符串是异位词那么它们排序后的结果完全一致。用排序结果作为哈希键把同一组词归到一个桶里代码会特别干净。def group_anagrams(strs): from collections import defaultdict groups defaultdict(list) for s in strs: key .join(sorted(s)) groups[key].append(s) return list(groups.values())这里面的技巧是用排序后的字符串作为键。排序本身是O(k log k)k是一个单词的长度通常很小整个算法算下来批量排序也远比两两比较字符串要划算。1.3 唯一标识快速判断这件事之前有没有见过第三种常见场景是判断是否出现过或者建立从某个对象到另一个对象的映射。比如链表里判断是否有环或者记录一个坐标对是否已经访问过。这类问题你完全可以不用哈希改用数组、Set或者布尔矩阵但哈希的可扩展性最好——它不要求你知道待处理元素的取值范围也不要求元素必须是整数。所以刷哈希题之前我建议你先把题目归个类是找另一半、是统计频次、还是判断唯一性。分类做对了代码怎么写基本就有思路了。2. 哈希表内部怎么工作搞懂数组加链表才敢谈优化很多刷题教程会直接从API用法开始讲哈希但我觉得要真正会用哈希解题至少应该知道它底层做了什么。哈希表的核心思路可以概括成一句话用一个计算函数把任意类型的键映射成一个数组下标。不同的键可能被映射到同一个下标这种情况叫冲突解决冲突的典型办法是在同一个下标下面挂一个链表也就是大多数人见到的数组加链表结构。2.1 哈希函数、冲突与扩容哈希函数是哈希表性能好坏的关键。函数设计得好键分布得均匀查找就是一次数组访问函数设计得差大量键挤在同一个位置哈希表就退化成了链表复杂度回到O(n)。这也是为什么实际哈希表的实现会考虑扰动函数、红黑树转换、负载因子扩容这些工程细节。负载因子可以简单理解为桶里装了多少数据的拥挤程度。当已有元素数量除以桶总数超过某个阈值时哈希表会自动扩容把桶数量加大再把所有元素重新映射一遍。这个过程叫rehash开销不小所以在笔试里如果你能提前估算数据量给哈希表指定一个足够的初始容量往往能省掉大量扩容时间。以主流语言为例语言常用哈希结构底层实现概要无序/有序Pythondict / set哈希表键需要可哈希有序插入序但依赖版本JavaHashMap / HashSet数组加链表链表过长转红黑树无序Cunordered_map / unordered_set哈希表实现无序Gomap哈希桶加溢出桶随机无序2.2 刷题时真正需要记住的只有三件事第一哈希的平均查找时间是O(1)但这是平均情况不是绝对保证。第二哈希表的空间开销并不小每个键值对都要额外存储哈希信息所以空间换时间这个说法是准确的。第三哈希表对键的类型有要求不是所有对象都能直接作为键。在Python里只有不可变类型才能放进dict或set当key列表、字典这类可变对象会直接报类型错误。这也是为什么很多人用坐标做题时会选择用元组而不是列表。你说这些底层细节对刷题有用吗当然有用。比如你遇到了一个两数之和的变种题要求不能用额外空间这时候你就得意识到哈希虽然好用但违背了空间限制必须退回排序或双指针。如果不知道哈希占用额外空间这个题一上来就会做错方向。3. 以计数器为骨架的哈希解法三道题能复用一套代码如果看多了哈希相关的题你会发现很多题的核心就是一个计数器也就是用一个字典来记录每个键出现的次数。这种解法本身并不复杂难的是你能否识别出这个题其实只需要一个计数器就能搞定。3.1 从每个字符出现的次数到判断字符串能否重新排列判断一个字符串能否通过重排变成另一个字符串其实就是判断两个字符串的字符构成是否完全一致。最简单的做法就是分别统计两个字符串中每个字符的出现次数然后比较统计结果是否相等。Python里甚至可以不用遍历比较直接用Counter对象比较Counter(s) Counter(t)。表面上看这是一行代码背后其实已经做了两次数频统计。这类题的通式代码是这样的from collections import Counter def can_permute(s1, s2): if len(s1) ! len(s2): return False return Counter(s1) Counter(s2)这个通式的要点在于凡是涉及判断两个集合的构成是否一致都可以先统计频次再比较频次表。无论是异位词、重排、还是字符串能否通过删除一个字符变成另外一个本质上都在这个框架里。3.2 计数器加排序的两种思路对比处理字符串分组这类问题时有两种常见思路。第一种是排序后当键代码简单但每处理一个字符串都要做一次排序第二种是计数后当键也就是把每个字符的出现次数拼成一个固定的表示形式比如a3b2c1用它当哈希键。第二种思路的核心在于它把同一组异位词统一映射到同一个键上不需要排序每次只遍历一次字符串所以在字符种类有限且字符串数量比较大的场景下更有优势。代码大致是from collections import defaultdict def group_anagrams_by_count(strs): groups defaultdict(list) for s in strs: count [0] * 26 for ch in s: count[ord(ch) - ord(a)] 1 key tuple(count) groups[key].append(s) return list(groups.values())注意这里的key用的是tuple(count)因为列表不能作为字典键转成元组后就可以进行哈希了。这个细节很容易被忽略但恰恰是很多初学者在本地运行报错的原因。当题目给的是小写字母时长度26的计数数组是okay的但如果是Unicode字符或数字混合的情况就要改成通用Counter方案。拿到这类题先看字符范围再决定用计数数组还是通用哈希这会节省不少时间。4. 哈希配合双指针滑动窗口维护合法状态才是灵魂单纯使用哈希的题目难度通常不高真正容易拉开差距的是哈希加滑动窗口的复合题。这类题表面上是双指针但窗口内状态的合法性往往需要一个额外的哈希表来维护。4.1 最长无重复字符子串窗口加哈希的教科书给定一个字符串请你找出其中不含有重复字符的最长子串的长度是高频中的高频。思路不复杂用两个指针维护一个窗口右指针负责扩展窗口并记录字符出现位置当遇到重复字符时左指针跳到重复字符上一次出现位置的后面同时更新哈希中记录的字符位置。def length_of_longest_substring(s: str) - int: pos {} left 0 ans 0 for right, ch in enumerate(s): if ch in pos and pos[ch] left: left pos[ch] 1 pos[ch] right ans max(ans, right - left 1) return ans这里有个非常关键的细节判断是否存在重复字符时不只是判断ch in pos还要判断pos[ch] left。原因很简单窗口左边界已经移动之后旧位置如果再出现不算重复因为那些字符已经不在当前窗口范围内了。忽略这个判断这个代码就会在abba这种用例上出错。4.2 哈希表记录的三种形态位置、次数、最新值在滑动窗口类题目中哈希表里的值会以三种形态出现。第一种存位置像上面这个例子。第二种存次数比如最小覆盖子串这类题需要通过哈希表统计滑动窗口内某个字符还需要多少个。第三种存最新状态比如某些需要维护最近一次出现的场景。存位置时要时刻注意收敛左指针时的边界条件存次数时要注意更新频率的增减是否可能让状态误判存最新值时要小心过期数据被重复使用。招数本身不复杂但很多人挂了是因为对什么时候更新哈希、什么时候读取哈希没有理清。我的建议是写代码前先在注释里把窗口的合法定义写出来再开始动代码。哈希只是工具窗口的规则才是灵魂。5. 把复杂对象拍平成哈希键处理坐标题和缓存设计的关键哈希表不仅能存整数和字符串还能存元组、对象甚至你自定义的结构。但笔试和面试中很多人在什么可以当键这个问题上吃亏。最常见的例子是处理坐标类题目。5.1 坐标对、矩阵状态与元组键如果题目给你一个矩阵问是否存在某个坐标模式比如某个点是否在之前出现过你可以把坐标转成元组(x, y)直接作为哈希键。在Python里元组是不可变类型也是可哈希的所以下面这种写法完全合法visited {} visited[(2, 3)] True如果是二维平面棋盘上某个格子的状态甚至可以直接用(row, col)作为键去存储状态值。刷题时把多个值拼成一个不可变元组是建模的一招暗器。有时候你需要的键其实是两个点组成的边这时候可以拼(x1, y1, x2, y2)这样的四元组只要不担心空间哈希总能帮你把复杂问题建模成键到值的快速查找。5.2 缓存设计哈希加双向链表的经典形态哈希在工程里最知名的应用之一就是LRU缓存也就是最近最少使用淘汰策略。这个题目在面试中出现频率极高因为它同时考查哈希表的随机访问能力和链表的顺序维护能力。思路是哈希表负责O(1)快速找到节点双向链表负责维护访问的时间顺序。每次访问一个键就把对应节点移到链表头部缓存满了就删除链表尾部的节点并同时从哈希表里移除对应键。如果只给哈希表访问是快了但无法维护顺序如果只给链表维护顺序方便但查找慢。两者结合刚好互相补充。这类题的价值不仅在于记住实现代码更在于理解哈希和其他数据结构组合的思路。很多看似复杂的题目其实就是把哈希当成一个加速查找的索引再用其他结构解决顺序、大小、窗口这类问题。5.3 自定义对象作为哈希键时的注意事项有基础以后你可能会遇到需要把自定义对象放哈希表的情况。在Java里如果你不重写hashCode和equals两个内容相同的对象会被当成两个不同的键这会导致你明明往哈希里存了数据却查不到。在Python中则要注意把对象转成可哈希的表示比如使用元组、冻结集合或自定义__hash__方法。笔试的时候能不用自定义对象就尽量不用自定义对象哪怕繁琐一点用普通元组表示状态更稳妥也更容易调试。6. 笔试和工程里的哈希差异别再在本地测完就交很多人刷题的时候把Python的dict用得非常顺手但到了真正的工程环境哈希表的行为会和提供算法答案很不一样。这些问题不会直接暴露在大多数线上判题的用例里但做技术讨论或者项目开发时会突然变成事故。6.1 遍历顺序带来的隐性Bug我用Go写过一个数据聚合的小任务最开始用map存储分组结果然后直接按map顺序输出结果每次运行输出顺序都不一样。查到最后才发现Go的map遍历本身就是随机的语言层面的设计就是为了强制开发者不依赖遍历顺序。如果你需要稳定顺序必须先把键排序或者改用有序结构。Python 3.7以后dict保留插入顺序Java的HashMap不保证顺序C的unordered_map也不保证。这类差异笔试里如果不涉及顺序输出通常没事但如果出题人给了一个需要按固定顺序返回结果的题目你就要反思一下是不是该换用有序映射了。6.2 并发修改哈希表并不总是线程安全在并发场景下使用哈希表要格外小心。Java的HashMap在多线程并发写入时可能引发数据覆盖甚至无限循环所以工程上往往会改用ConcurrentHashMap。Go的map在并发读写时会直接触发运行时panic。刷题时完全不用考虑这些但如果你在项目里写过一个被并发访问的普通map就会明白那些标准库的坑不是危言耸听。如果你的面试环节涉及系统设计或实际项目提问能主动说出我用的是并发安全的哈希结构并说明了并发读写的取舍这通常会是一个加分点。6.3 哈希函数和对象哈希值的坑自己实现哈希函数或者在某个哈希结构里存放大量非基础类型数据时最容易忽略的是哈希值的分布质量。比如用hash x * 31 y这类简单公式处理坐标时如果数据存在规律性可能让大量键映射到同一个桶。笔试也许不会直接考哈希函数设计但你知道这个原理就能在任何需要把对象映射为一个整数的场景里留个心眼。7. 收尾的个人清单我判一道题该不该用哈希的思考顺序写了这么多最后还是想分享一套我自己做题时用来判断这道题要不要上哈希的思考顺序。这套顺序不是万能的但帮我减少了很多无效尝试。第一步看题目有没有查找动作。无论是找数、找下标、找状态只要需要反复查找优先考虑哈希。第二步看题目有没有去重或频次的描述。判断重复元素、统计字符次数、找出现次数最多的元素这些基本是哈希的主场。第三步看有没有成对关系。两数之和、四数相加、判断是否存在对称组合这些题要配对一般也可以走哈希。第四步看是否存在空间限制。如果题目明确要求O(1)额外空间哈希基本只能放弃转向排序加双指针。在实际做题和帮别人梳理代码的时候我发现把自己的解法先归类到上面其中一类能减少不少跑偏时间。哈希题看着花样多核心猎物就那几种。把底层原理、API差异和这四步判断在脑子里过一遍大部分哈希题都能找到清晰的解法路径。最后再分享一个小技巧每做完一道哈希题把这道题提炼成一句话放到自己的笔记里比如这道题用排序后的字符串当键这道题用元组当键存储坐标访问状态。积累一段时间后你会发现那些看起来全新的题目其实只是你笔记本里几个套路的变体。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表