ARTICLE DETAIL

资讯详情

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

哈希表与字典的区别及应用:数据结构和算法刷题实战笔记

哈希表与字典的区别及应用:数据结构和算法刷题实战笔记 今天是我持续刷数据结构和算法的第6天同时也是哈希表专题的第二天。如果你在搜索引擎里搜“哈希表”和“字典”大概率会看到一堆理论解释但真正动手做算法题时关心的其实是另一件事哈希表到底怎么用字典和哈希表是不是一回事专题里的这个“2”又意味着什么。这篇文章就围绕这几个问题把这段时间的实操笔记整理一遍重点讲透哈希表和字典的区别、哈希表的高频使用套路以及我自己踩过的几个坑。适合有一定编程基础、正在刷题或准备面试的同学也适合看完理论后想进一步落实到代码的人。1. 哈希表和字典的区别先把底层问题搞明白1.1 哈希表是一种数据结构字典是它的一种产品形态很多人会把“哈希表”和“字典”当成同义词尤其是Python选手做算法题时嘴里说着“字典”心里想的却是哈希表。严格来说哈希表Hash Table是一种基于哈希函数实现的数据结构它解决的核心问题是给定一个键如何快速找到对应的值。字典Dictionary则是编程语言对外提供的映射容器接口在Python里就是dict在Java里是HashMap在C里是unordered_map。也就是说字典通常是哈希表的一种具体实现但哈希表本身并不等于字典。哈希表的用途比字典更广。除了实现“键值对映射”它还可以用来实现集合Set、缓存Cache、布隆过滤器的底层存储。比如Python里的set底层就是哈希表结构只是它只关心键是否存在不关心值。而字典关心的是key到value的完整映射。理解这层关系后再看“哈希表和字典的区别”这个问题答案其实很清晰字典是面向使用者的接口哈希表是背后的存储和查找机制。1.2 哈希表内部存储流程从哈希函数到桶数组哈希表底层通常是一个数组这个数组的每个位置叫作“桶”Bucket或“槽”Slot。当我们插入一个键值对(key, value)时先计算hash(key)再对数组长度取模得到一个索引然后把值放到对应位置。比如有一个容量为8的哈希表键是字符串apple假设hash(apple) % 8 3那么它就被存到下标为3的桶里。查找的时候也一样先算索引再去对应桶里找。这里最核心的问题是哈希冲突。两个不同的键经过取模后有可能落到同一个桶里。假设banana的哈希值对8取模也是3那它就和apple冲突了。处理冲突的常见方案有两种一是拉链法每个桶里挂一个链表或红黑树冲突的元素都放在链表里二是开放寻址法冲突后顺着数组继续找下一个空位。Python的字典底层用的是经过深度优化过的开放寻址法Java的HashMap则使用数组加链表、链表过长时转红黑树的拉链法。我用一个生活化类比帮助理解把哈希表想象成酒店前台每个客人根据身份证号分配房间正常情况下一个客人一个房间查找时只要报身份证号就能马上知道住哪个房间。但偶尔两个人算出了同一个房间号前台就得用特殊规则处理比如把人引导到隔壁房间或者让同一房间的人排队入住。不管用哪种规则只要房间分配足够均匀大多数时候还是能一步到位。1.3 为什么哈希表查找平均是O(1)一次计算定位哈希表之所以让人着迷是因为它的插入、查找、删除平均时间复杂度都是O(1)。这个O(1)并不是说不需要比较而是比较次数被控制在一个很小的常数范围内。只要哈希函数足够均匀每个桶里的元素数量就趋近于平均数量。假设桶数为m元素个数为n负载因子a n / m。在拉链法下查找一个元素平均需要比较1 a/2次当a控制在0.7左右时这个值基本逼近常数1。所以真正影响哈希表性能的不是元素总量而是负载因子和哈希函数的均匀程度。需要注意O(1)是平均情况不是最坏情况。如果哈希函数设计很差所有键都映射到同一个桶哈希表就退化成了链表查找复杂度变成O(n)。这也是面试时常问“哈希表什么时候会退化为链表”的原因。Python官方对字符串做了随机哈希种子目的就是防止恶意构造数据让所有字符串哈希值一致导致性能崩塌。你在自己的代码里写哈希算法时也要尽量避免把所有对象都映射到同一个余数上。2. 哈希表的经典应用场景刷题时无论如何都用得上2.1 去重和存在性判断用哈希集合哈希表最常见的一个用途是判断一个元素是否出现过。刷题时我们经常需要“标记某个数字已经出现”这种场景用哈希集合Python里的set最方便。集合的底层就是哈希表只不过它只存储键不存储值。插入一个元素的时间是O(1)判断元素是否存在也是O(1)。举一个典型例子给定一个未排序的数组要求找出数组中最长连续序列的长度并且时间复杂度要控制在O(n)。例如[100, 4, 200, 1, 3, 2]最长连续序列是1, 2, 3, 4长度为4。暴力做法是排序之后扫描复杂度O(n log n)不满足要求。正确的哈希集合做法是先把所有数字放进set然后遍历set里的每个数字只有当num - 1不在set中时才把num当作连续序列的起点向后逐个数num1、num2判断是否在set里。每个数字只会作为起点被遍历一次后面的扩展过程虽然看起来有内层循环但总体每个数字最多被访问两次所以总复杂度仍然是O(n)。这个题的几个细节很值得注意。第一去重是必须的原数组可能有重复数字但连续序列只跟数值有关跟出现次数无关。第二不能看到“连续”就用排序要先把数据放进哈希集合再分析。第三扩展序列时每一次while查找都是O(1)这比在数组里in扫描要快得多。2.2 频率统计计数是哈希表的主场除了判断存在性哈希表还特别适合统计频率。很多题目要求你统计每个元素出现的次数然后找出频率最高、前K个高频或者判断两个字符串的字符出现情况是否一致。Python里最直接的方式是使用collections.Counter它本质上就是一个以元素为键、以次数为值的字典。不过我建议学习阶段先自己手动用字典实现一遍掌握底层逻辑后再直接用Counter提高效率。手动统计时常见的写法是from collections import defaultdict nums [1, 2, 2, 3, 3, 3] count defaultdict(int) for num in nums: count[num] 1 print(count) # defaultdict(class int, {1: 1, 2: 2, 3: 3})defaultdict(int)会自动为不存在的键初始化默认值0省去if key not in dict的判断。有些人图省事直接用普通字典配合get方法count {} for num in nums: count[num] count.get(num, 0) 1两种方式都可以但defaultdict在可读性上更清晰。频率统计的典型应用有“有效的字母异位词”“字符串中第一个唯一字符”“前K个高频元素”。做这类题时先问自己我需要的是键的集合还是键对应的统计值如果只是判断是否出现过用set如果还要统计次数用dict。2.3 两数之和从暴力到哈希表的优化过程可以说两数之和Two Sum是哈希表里最经典的题目。题意很简单给定一个整数数组和一个目标值找出数组中两个数使它们的和等于目标值返回这两个数的下标。暴力做法是两层循环枚举所有数对时间复杂度O(n^2)。但用哈希表一次遍历就能解决。核心思路是遍历数组时把当前元素的值作为键、下标作为值存进字典。对于每个元素x我们只需要判断target - x是否已经在字典里。如果在就找到了两个数的下标如果不在就把x存进字典。利用字典查找O(1)的特性总时间复杂度降为O(n)。def two_sum(nums, target): seen {} for i, num in enumerate(nums): complement target - num if complement in seen: return [seen[complement], i] seen[num] i return []这里有一个新手很容易犯的错有人喜欢先把数组全部塞进字典然后再遍历一遍查找。但如果数组里有重复元素或者两个数恰好是同一个下标就会出问题。正确做法是边遍历边存保证每个元素只和它之前的元素匹配不会出现“自己和自己的和等于target”的情况。这个细节面试的时候经常考。2.4 滑动窗口与哈希表结合维护无重复区间哈希表单独用已经很强大和滑动窗口结合后可以解决更多问题。最典型的是求一个字符串里不含重复字符的最长子串长度。比如abcabcbb的结果是3对应abc。这个题如果靠纯暴力枚举子串复杂度是O(n^2)。但用双指针加哈希集合可以在O(n)时间内完成。做法是右指针不断向右移动把字符加入窗口如果发现某个字符已经在窗口里就把左指针向右移动直到窗口里不再有重复字符。判断字符是否在窗口里就需要一个哈希集合或字典实时记录窗口内的字符状态。def length_of_longest_substring(s: str) - int: window set() left 0 ans 0 for right, ch in enumerate(s): while ch in window: window.remove(s[left]) left 1 window.add(ch) ans max(ans, right - left 1) return ans这道题的精髓在于每一次移动左指针时都能快速移除左边的字符而这个“移除”操作底层的哈希表删除也是O(1)。如果不用哈希集合每次判断窗口内是否含有重复字符都要重新扫描窗口复杂度就会退化。很多滑动窗口题本质上都是“哈希表维护状态 双指针控制区间”把这两个工具组合起来能对付大量子串问题。3. Python字典进阶哈希表与编程语言的碰撞3.1 字典的键必须可哈希先看能不能做key哈希表的关键是“键”。在Python里不是任何对象都能当字典的键只有“可哈希”的对象才行。可哈希意味着对象有一个稳定的哈希值并且在生命周期内不会改变。不可变对象比如整数、浮点数、字符串、元组前提是元组里没有可变元素通常是可哈希的。而列表、字典、集合这类可变对象不可哈希它们不能作为字典的键。这个限制本质上是哈希表结构造成的如果键的内容变了它对应的哈希索引也会变那么原本存储的位置就会找不到数据。所以当你试图用列表当key时Python会抛TypeError: unhashable type: list。解决办法通常是把列表转成元组再当key或者把字典转成JSON字符串、把集合转成frozenset。自定义类对象默认是可哈希的但默认哈希值基于对象的id也就是内存地址。如果两个对象内容相同但id不同它们会被当成两个不同的键。想让内容相等的对象在字典里也被视为同一个键就要重写__hash__和__eq__两个方法。这属于Python字典和哈希表结合时最容易踩坑的地方后面第五部分会详细说。3.2 字典底层细节为什么Python dict值得单独学Python的dict虽然也叫哈希表但它的实现比教科书上的经典哈希表更复杂也更讲究。从Python 3.6开始dict底层改用了更紧凑的存储结构把哈希索引和真正的键值对分开存放内存占用明显下降。同时它保留了插入顺序遍历字典时能按插入顺序输出这在3.7之后甚至成了语言规范。哈希表是“键值对字典”这句话还有另一层意思Python在计算字符串哈希时引入了随机化每次新进程都会使用不同的哈希种子防止某些恶意攻击造成大量字符串冲突。所以你在不同次运行程序时看到字典的存储内部顺序可能不同但遍历顺序一定是插入顺序两者不要混淆。有一个学习建议不要只把dict当成“能存数据的容器”尝试从哈希表视角去理解它。比如为什么d[key]取值很快为什么内存占用可能很大为什么加载大量数据时字典扩容会带来一次明显卡顿。这些问题的答案都藏在底层哈希表的设计里。3.3 不同语言里的“字典”有哪些差异“哈希表和字典的区别”在不同语言中体现得更加明显。Python的dict使用开放寻址法插入顺序保留按键遍历有序适合绝大多数业务场景。Java的HashMap使用拉链法底层是数组加链表链表长度超过阈值后转红黑树key可以为null但不保证顺序如果需要顺序可以用LinkedHashMap。C的unordered_map同样基于哈希表但标准库只规定平均常数复杂度和迭代器失效规则具体桶数和冲突处理由实现决定比如libstdc使用链式哈希。这些差异意味着你在LeetCode上用Python写的哈希表算法思路可以平移到其他语言但写法细节完全不同。比如Java里要初始化一个带初始容量的HashMap减少扩容C里要小心operator[]会增加默认值Python里则要习惯使用collections.defaultdict。理解这些差异比死记某个API更有价值。4. 手把手实操三道经典哈希表题目的完整拆解4.1 第一题有效的字母异位词给定两个字符串s和t判断它们是不是字母异位词也就是两个字符串包含的字母种类和数量是否完全相同。比如s anagramt nagaram返回 True。最直观的解法是排序后比较sorted(s) sorted(t)时间复杂度O(n log n)。但用哈希表可以做到O(n)。思路是统计第一个字符串中每个字符出现的次数然后遍历第二个字符串对应字符的次数减一最后检查所有字符的次数是否都归零。from collections import Counter def is_anagram(s: str, t: str) - bool: if len(s) ! len(t): return False return Counter(s) Counter(t)但更符合“手写哈希表”精神的解法是只用一个字典def is_anagram(s: str, t: str) - bool: if len(s) ! len(t): return False count {} for ch in s: count[ch] count.get(ch, 0) 1 for ch in t: if ch not in count: return False count[ch] - 1 if count[ch] 0: count.pop(ch) return len(count) 0注意这里的pop操作当某个字符的计数减到0就从字典中移除。这样最后只要判断字典是否为空即可不需要再遍历所有字母。这个技巧可以有效减少内存占用也避免残留零值影响判断。字母异位词虽然简单但考察的是哈希表统计频率的基本功。4.2 第二题最长连续序列前面已经提到过一次这里完整地做一遍。题目要求给定未排序的整数数组找出最长连续序列的长度且时间复杂度为O(n)。比如输入[100, 4, 200, 1, 3, 2]输出4。解题步骤分三步把所有数字放入一个set这一步自动去重。遍历set中的每个数字num如果num - 1不在set中说明它是一个连续序列的起点。从起点开始依次检查num 1、num 2是否存在找到最大长度。代码实现def longest_consecutive(nums): num_set set(nums) max_len 0 for num in num_set: if num - 1 not in num_set: current num cur_len 1 while current 1 in num_set: current 1 cur_len 1 max_len max(max_len, cur_len) return max_len为什么只有num - 1不存在时才开始统计因为这样可以保证每个连续序列只会被计算一次从最左侧的起点开始避免了重复扫描整个序列。假设数组很长但每个数字都属于某个连续段这个算法的总复杂度仍然是O(n)因为每个数字最多被它所在序列的起点头项访问一次在while循环里被线性延伸到一次。这个题非常鼓励用哈希集合因为我们需要频繁判断某个数是否存在。如果用数组去判断in每次都是O(n)整体复杂度直接爆炸。这也是哈希表“存在性判断”价值的最好证明。4.3 第三题字母异位词分组给定一组字符串把字母异位词放在同一组里。比如[eat, tea, tan, ate, nat, bat]输出结果按组划分[eat, tea, ate]、[tan, nat]、[bat]。这道题的关键在于如何设计“异位词分组”的键。两个字符串互为异位词说明它们包含的字母种类和数量相同所以每组可以用“排序后的字符串”作为字典的键。这样一来eat和tea排序后都是aet会被放进同一个列表。from collections import defaultdict def group_anagrams(strs): groups defaultdict(list) for s in strs: key .join(sorted(s)) groups[key].append(s) return list(groups.values())如果字母范围固定为26个小写字母还有一种更高效的键用一个长度为26的元组记录每个字母出现次数。比如eat对应(1, 0, ..., 1, 1)这样的计数元组这个元组可作为哈希键避免了每次排序的O(k log k)成本。不过排序的写法更通用也更容易理解应对面试足够了。这个题体现了一个重要的哈希表思路不一定要用原字符串当键我们可以先对数据进行某种标准化变换再把变换结果作为键。只要能保证“同一类的数据标准化后相同不同类的标准化后不同”就是一个合格的哈希键设计。5. 常见问题和排查技巧实录哈希表实战中的血泪教训5.1 重写了__hash__却忘记重写__eq__自定义对象放入字典或集合时很多人只重写了__hash__然后发现两个内容相等的对象在字典里被当成不同的键。原因很简单哈希表判断两个键是否相等时先比哈希值。如果哈希值相等还要调用__eq__判断内容是否相等。你只重写了__hash__没有重写__eq__那么Python仍然使用默认的“对象id相同才相等”的规则两个内容相同的对象自然被认为不同键。正确的做法是同时重写两个方法并保证一个原则两个对象如果相等它们的哈希值也必须相等。否则你会在查找时遇到更诡异的问题明明存在这个键却因为哈希值不同根本找不到。我自己在写一个坐标缓存时就踩过这个坑用元组做键万事大吉换成自定义Point类后缓存全部失效。后来统一在类里加入class Point: def __init__(self, x, y): self.x x self.y y def __hash__(self): return hash((self.x, self.y)) def __eq__(self, other): return (self.x, self.y) (other.x, other.y)从此之后遇到“内容相同但查找失败”的问题第一步就去检查__eq__是否匹配内容。5.2 遍历字典时不能修改大小有时候你想在遍历字典时删除某些符合条件的键直接写for key in d:然后在循环体里d.pop(key)大概率会抛RuntimeError: dictionary changed size during iteration。这是因为Python字典在遍历时记录了版本号任何插入或删除操作都会改变字典大小被迭代器检测到后立即报错。如果你的确需要在遍历时筛选键可以先收集要删除的键遍历结束后再统一删除keys_to_delete [k for k in d if condition(k)] for k in keys_to_delete: del d[k]也可以直接构造一个新字典只保留不需要删除的键d {k: v for k, v in d.items() if not condition(k)}第二种方式更Pythonic但要注意它会产生新的字典对象如果原字典还在被其他地方引用引用不会自动更新。刷题时队列场景没这么多讲究但写工程代码时要格外小心。5.3 哈希冲突导致性能骤降哈希表平均O(1)的前提是哈希函数均匀。如果大量键被映射到同一个桶查找速度会退化。有人觉得自己写的哈希函数值看起来随机但实际上取模后可能重叠。比如用自定义对象哈希值直接取模数组大小如果对象的哈希值都是偶数而数组大小也是偶数那么所有键只会落在偶数桶另一半桶完全闲置。这个问题的排查并不难如果在压测场景下明明所有操作都是“哈希表操作”耗时却异常高可以先打印每个桶里元素的数量看看分布是否均匀。如果分布严重偏斜就要考虑换一个更好的哈希函数或者把底层数组大小调整为素数。很多经典哈希表实现里数组长度都选择素数或2的幂再配合特殊掩码目的就是让低位信息也能参与索引计算。通用方案是在__hash__里引入一个扰动因子比如把坐标转成x * 31 y能明显改善分布。5.4 用浮点数做键要小心浮点数看起来可以当字典键但有几个坑。第一浮点数的精度问题会导致两个“逻辑上相同”的数字在哈希时不同。比如0.1 0.2和0.3在数学上相等但浮点表示不同哈希值也不同。第二float(nan)的哈希值基于对象id每次运行可能都不一样而且它不等于自身放进集合后某些情况下查找会失败。第三-0.0和0.0的哈希结果一样这在数学上合理但在排序或分组场景可能有意想不到的效果。刷题时如果题目输入是小数我一般建议不要直接用浮点数做键而是先转成整数、字符串或Decimal。实在要用浮点数宁可先乘以一个足够大的倍数转成处理过的整数再哈希。这不是性能问题而是正确性问题。5.5 字典插入顺序和排序需求Python 3.7之后的dict会保留插入顺序遍历时按插入顺序输出。但很多人误以为字典会自动按键排序这是两码事。如果你需要按键排序输出应显式使用sorted(d.items())。另外在做算法题时有时需要按值排序或统计频率后取TopK直接对字典items排序即可top_sorted sorted(d.items(), keylambda x: x[1], reverseTrue)这里没有魔法哈希表本身不负责排序排序是额外操作。厘清“哈希表负责快速查找排序负责有序输出”的分工能避免很多误解。6. 第六天学习的一点个人体会刷完哈希表专题的第二天我最真实的感受是理论看再多都不如亲手写几道题来得深刻。哈希表与字典的区别课本上讲得很抽象但当你用defaultdict统计频率失败或者重写__hash__后查不到键时才会真正明白“哈希函数”“冲突”“相等判断”这些概念落地后是什么样子。前几天我还在纠结要不要背数据结构定义现在已经能熟练地说哈希表是一种按哈希函数定位的查找结构字典是它在编程语言里的接口形态。更重要的是我已经养成了一种“条件反射”看到“找唯一”“是否有重复”“统计次数”“匹配配对”这类问题第一时间就会考虑哈希集合或字典。我个人在刷题时有一个习惯每个哈希表题目先问自己三个问题。第一我用的是集合还是字典第二键选什么值选什么第三哈希值的均匀性和相等判断是否有坑这三问能过滤掉绝大多数低级错误。第六天过后哈希表这块的骨架已经立起来了接下来就是靠更多题目把细节填满。如果你也在刷这个专题建议把两数之和、最长连续序列、字母异位词分组三题反复写到闭眼能默的程度消化它们的思路远比盲刷一百道题更有效。
返回列表