哈希
哈希表
哈希表概述
哈希表由一个基于数组的数据结构和哈希函数组成。它的基本原理是将 key 通过哈希函数计算得到一个索引值,然后将键值对存储在该索引对应的位置上。这样,在需要查找、插入或删除值时,可以通过哈希函数快速计算出对应的索引,从而直接访问到目标值,而无需遍历整个数据集。
哈希算法与哈希表:用快递柜理解高效数据存储
什么是哈希算法?
想象你走进一家菜鸟驿站,想快速找到你的快递,如果你的快递随便放在某个架子上,你需要花几个小时翻找。假设这个菜鸟驿站中有一个超级大的快递柜,分为很多排,每排放着一些零散的快递,此时你查询你的菜鸟 APP,发现你的快递在 13 排,你就能快速定位到这个位置,并拿这一排的快递和你的快递进行对比,进而快速找到你的快递。这就涉及到了哈希算法,将一个快递,映射到了快递柜的某一排上。
哈希表:超级快递柜系统
哈希表(Hash Table)是基于哈希算法构建的快递柜。它包含两个核心部分:
- 数组:就像这个超级大的快递柜,每一个元素对应快递柜的一排。
- 哈希函数:自动计算存储位置的取件码生成器。
工作流程
假设快递柜的的容量为 ,用不同的字符串来表示不同的快递(下面过程假设不存在哈希冲突)。
-
存数据
- 存储快递
s1
。 - 哈希函数计算
hash(s1) % p = 5
→ 放在快递柜索引为5的排。 - 时间复杂度:O(1),直达目的地。
- 存储快递
-
取数据
- 再次计算
hash(s1) % p = 5
→ 找到快递柜索引为5的排,用s1进行对比,取出s1对应的快递。 - 无需遍历所有排,速度极快。
- 再次计算
冲突问题:当两个快递撞柜怎么办?
现实中可能出现 hash(s1) % p = 5
和 hash(s2) % p = 5
的情况,就像两个快递被分配到同一柜子。下面介绍两种解决方案(感兴趣的可以自行学习其它冲突解决方案):
方案一:开放寻址法(Open Addressing)
我们一行只放一个快递,发现第 5 行已经放了快递,就继续找相邻的第 6 行,直到找到空行,将快递放在空行。这样我们取快递的时候,同样根据取件码先找到第 5 行,如果不是我们想要的快递,就继续找相邻的第6行,直到找到我们想要的快递或快递不存在。
在数组中,存放元素时,如果索引为5的位置已经放了元素,就继续寻找索引为 6 的位置,直到找到空位置,把对应元素放在空位置上;取元素时,也按照同样的方式,从索引为 5 的位置开始,依次和自己的元素进行对比,直到找到目标元素或者目标不存在。
如下图所示,s1
和 s2
的哈希值都为 5,所以 s1
和 s2
被分配到了同一个柜子,为了区分这两个快递,我们使用了开放寻址法,将 s2
放在了索引为 6 的柜子上。
方案二:链式存储(Chaining)
我们一行放多个快递,先根据哈希函数找到第5行,然后将这个快递放在第 5 行的最后。取快递时,同样根据取件码找到第5行,然后从第一个快递开始,依次和自己的快递进行对比,直到找到自己的快递,或者找完后发现没有,证明快递不存在。
在数组中,存放元素时,如果索引为 5 的位置已经放了元素,就将这个元素放在索引为 5 位置上的链表的末尾;取元素时,也按照同样的方式,从索引为 5 的位置开始,依次和自己的元素进行对比,直到找到目标元素或者目标不存在。
如下图所示,s1
和 s2
的哈希值都为 5,所以 s1
和 s2
被分配到了同一个柜子,为了区分这两个快递,我们使用了链式存储,将 s2
放在了索引为 5 位置上的链表的末尾。
上面的两种方案,存放时都假设了一个 key 只会存放一次,那么存在一个 key 多次存放的情况,这个也很好解决。在开放寻址法中,存放元素会遍历一段连续数组,那么遍历时会去判断这个遍历到的 key 是否和要存放的 key 相同,如果相同,就把 value 覆盖掉。同理,在链式存储中,存放元素时会遍历链表,遍历到key相同的元素时,就把value覆盖掉。 总结就是,如果 key 是第二次及以上存放,那么就会把原先 key 上的 value 覆盖掉。如果是第一次存放,就会根据自己解决哈希冲突的方法,把元素放在对应的位置上。
为什么哈希表如此重要?
一个字:快! 两个字:很快! 三个字:非常快!
通过这个生活化的类比,相信你已经对哈希算法和哈希表有了直观理解。下次看到 HashMap
或 HashSet
时,想象一个闪着光的智能快递柜吧!
补充
下面给出了哈希表的常用语法,强烈建议同学们在理解的基础上,自己运行一遍,避免出现纸上谈兵的情况。
哈希表的声明与初始化
不同编程语言中,哈希表的声明和初始化语法略有不同,下面分别介绍几种常见语言的用法。
C++
在 C++ 中,声明哈希表的基本语法如下:
unordered_map<键类型,值类型> hash;
例如,我有一张成绩表,目前记录了 5 个人的成绩,可以这样写:
unordered_map<string, int> score; // 声明一个哈希表
score["XiaoMing"] = 85; // 初始化5个键值对
score["LiHua"] = 92;
score["ZhangSan"] = 76;
score["WangYing"] = 68;
score["ZhaoFei"] = 98;
关于C++中的哈希表,有几个点需要特别注意:
- 哈希表中的键必须是可哈希的,因为它是基于哈希函数来计算索引,如果键不可哈希,那么哈希表就无法正常工作。C++ 中默认的数据类型有默认的哈希函数和比较函数,对于自定义的数据类型,如果想要以它为键的话,就必须重载比较函数同时提供一个哈希函数来使得哈希表能够正常工作。
- 可以使用
[]
操作符对元素进行插入和访问。例如hash[key]
用于访问键值为key
的元素,如果存在,就返回这个元素对应的值,如果不存在,哈希表会插入一个键为key
,值为key
对应的数据类型的默认值的键值对。 - 如果我们预先知道这个哈希表需要存储的元素的大概数量,可以在初始化哈希表的时候指定哈希表的大小,这样可以避免频繁扩容带来的开销。
Java
在 Java 中,声明哈希表的基本语法如下:
HashMap<String, Integer> score = new HashMap<>();
例如,我有一张成绩表,目前记录了 5 个人的成绩,可以这样写:
HashMap<String, Integer> score = new HashMap<>();
score.put("XiaoMing", 85);
score.put("LiHua", 92);
score.put("ZhangSan", 76);
score.put("WangYing", 68);
score.put("ZhaoFei", 98);
关于 Java 中的哈希表,有几个点需要特别注意:
- 键的类型必须实现对应的哈希方法和比较方法。对于自定义的键,需要重写这两个方法。
- 元素的插入需要使用
put
方法,元素的访问需要使用get
方法,不能直接使用[]
进行元素的访问。 - 初始化哈希表时可以指定大小,避免频繁扩容带来的性能开销。
Python
在 Python 中,声明哈希表的基本语法如下:
hash_table = {}
// 或者
hash_table = dict()
例如,我有一张成绩表,目前记录了 5 个人的成绩,可以这样写:
score = {
"XiaoMing": 85,
"LiHua": 92,
"ZhangSan": 76,
"WangYing": 68,
"ZhaoFei": 98
}
关于 Python 中的哈希表,有几个点需要特别注意:
- 键的类型必须实现对应的哈希方法和比较方法。对于自定义的键,需要实现对应的方法。
- python 可以使用
{}
进行字典的定义,也可以使用dict()
方法来声明一个字典,其中dict()
方法更加灵活,dict 的一种方法实例如下:
string_list = ["key1", "key2", "key3"] # 字符串列表
int_list = [1, 2, 3] # 数组列表
# 通过zip将数组下标相等的元素两两绑定成一个二元组,并生成一个等长的列表,dict接收这个列表,对于列表的每个元素,以第一维为键,第二维为值,生成一个字典。
result_dict = dict(zip(string_list, int_list))
print(result_dict)
# {'key1': 1, 'key2': 2, 'key3': 3}
哈希表的增删改查
在哈希表中,元素的增删改查操作是常见的操作。 以下是关于哈希表增删改查的介绍:
C++
C++ 中哈希表元素的增删改查语法如下:
unordered_map<string, int> score; // 声明一个哈希表
score["XiaoMing"] = 85; // 初始化5个键值对
score["LiHua"] = 92;
score["ZhangSan"] = 76;
score["WangYing"] = 68;
score["ZhaoFei"] = 98;
// 插入元素
score["XiaoLi"] = 88;
// 修改元素
score["XiaoMing"] = 90;
// 查找元素
int s1 = score["LiHua"]; // 存在 "LiHua",返回对应的值 92
int s2 = score["xiaoming"]; // 不存在 "xiaoming",返回默认值 0
// 判断key是否存在
score.count("LiHua");
// 删除元素
score.erase("LiHua"); // 删除指定键的元素
score.clear(); // 清空哈希表
Java
Java 中哈希表元素的增删改查语法如下:
HashMap<String, Integer> score = new HashMap<>(); // 声明一个哈希表
score.put("XiaoMing", 85); // 初始化5个键值对
score.put("LiHua", 92);
score.put("ZhangSan", 76);
score.put("WangYing", 68);
score.put("ZhaoFei", 98);
// 插入元素
score.put("XiaoLi", 88);
// 修改元素
score.put("XiaoMing", 90);
// 查找元素
int s1 = score.getOrDefault("LiHua", 0); // 存在 "LiHua",返回对应的值 92,不存在返回默认值 0
int s2 = score.getOrDefault("xiaoming", 0); // 不存在 "xiaoming",返回默认值 0
// 判断key是否存在
score.containsKey("LiHua");
// 删除元素
score.remove("LiHua"); // 删除指定键的元素
score.clear(); // 清空哈希表
Python
Python 中哈希表元素的增删改查语法如下:
# 声明一个字典(类似C++中的unordered_map)
score = {
"XiaoMing": 85,
"LiHua": 92,
"ZhangSan": 76,
"WangYing": 68,
"ZhaoFei": 98
}
# 插入元素
score["XiaoLi"] = 88
# 修改元素
score["XiaoMing"] = 90
# 查找元素
try:
s1 = score["LiHua"] # 存在 "LiHua",返回对应的值 92
except KeyError:
s1 = 0
try:
s2 = score["xiaoming"] # 不存在 "xiaoming",在Python中会抛出KeyError,这里捕获后设置默认值 0
except KeyError:
s2 = 0
# 判断key是否存在
if "LiHua" in score:
key_exists = True
else:
key_exists = False
# 删除元素
if "LiHua" in score:
score.pop("LiHua") # 删除指定键的元素
score.clear() # 清空字典
print(s1)
print(s2)
print(key_exists)
哈希表的遍历
哈希表的遍历是指访问哈希表中的每个元素,通常用于查找、打印或其他操作。 以下是关于哈希表遍历的介绍:
unordered_map<string, int> score; // 声明一个哈希表
score["XiaoMing"] = 85; // 初始化5个键值对
score["LiHua"] = 92;
score["ZhangSan"] = 76;
score["WangYing"] = 68;
score["ZhaoFei"] = 98;
// 1. 使用增强for循环遍历
for (auto& pair : score) {
cout << pair.first << ": " << pair.second << endl;
}
// 或者
for (auto& [key, value] : score) {
cout << key << ": " << value << endl;
}
// 2. 使用迭代器遍历
for (auto it = score.begin(); it != score.end(); ++it) {
cout << it->first << ": " << it->second << endl;
}
HashMap<String, Integer> score = new HashMap<>();
score.put("XiaoMing", 85);
score.put("LiHua", 92);
score.put("ZhangSan", 76);
score.put("WangYing", 68);
score.put("ZhaoFei", 98);
// 1. 使用增强for循环遍历
for (Map.Entry<String, Integer> entry : score.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 2. 使用迭代器遍历
Iterator<Map.Entry<String, Integer>> iterator = score.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 3. 使用forEach遍历
score.forEach((key, value) -> System.out.println(key + ": " + value));
score = {
"XiaoMing": 85,
"LiHua": 92,
"ZhangSan": 76,
"WangYing": 68,
"ZhaoFei": 98
}
# score.items 返回键值对的数组,故可以遍历items
print(score.items())
# 1. 遍历 score.items
for key, value in score.items():
print(key, value)
# 2. 直接遍历score取出的是key,可以通过score[key] 取值
for key in score:
print(key)
# 3. 只遍历 key
for key in score.keys():
print(key)
# 4. 只遍历值
for value in score.values():
print(value)
哈希表的集合形式
理解了哈希表的键值对形式,哈希表的集合形式就很容易理解了。 哈希表的集合形式是指哈希表中只存储键,而不存储对应的值,本质上也是哈希表。 或者根据上面的快递柜的例子,可以将集合形式理解快递柜里面存放的快递是空的,也就是只存放了快递盒,而快递盒里面没有内容。 因此,只需要了解对应的函数操作即可。
// 声明一个哈希表集合
unordered_set<string> name;
// 插入元素
name.insert("XiaoMing");
name.insert("LiHua");
name.insert("ZhangSan");
// 查找元素
if (name.find("LiHua") != name.end()) { // 存在 "LiHua",返回 true
// 存在的操作逻辑
cout << "LiHua is in the set." << endl;
} else {
// 不存在的操作逻辑
cout << "LiHua is not in the set." << endl;
}
// 修改元素
// 由于集合中只存储键,只需要删除元素后插入新元素即可。
// 判断元素是否存在
cout << name.count("LiHua") << '\n'; // 存在 "LiHua",返回 1,不存在返回 0
// 删除元素
name.erase("LiHua"); // 删除指定键的元素
name.clear(); // 清空集合
cout << name.count("LiHua") << '\n'; // 存在 "LiHua",返回 1,不存在返回 0
// 声明一个哈希表集合
HashSet<String> name = new HashSet<>();
// 插入元素
name.add("XiaoMing");
name.add("LiHua");
// 查找元素
if (name.contains("LiHua")) { // 存在 "LiHua",返回 true
// 存在的操作逻辑
System.out.println("LiHua is in the set.");
} else {
// 不存在的操作逻辑
System.out.println("LiHua is not in the set.");
}
// 修改元素
// 由于集合中只存储键,只需要删除元素后插入新元素即可。
// 判断元素是否存在
System.out.println(name.contains("LiHua") ? 1 : 0); // 存在 "LiHua",返回 1,不存在返回 0
// 删除元素
name.remove("LiHua"); // 删除指定键的元素
name.clear(); // 清空集合
System.out.println(name.contains("LiHua")? 1 : 0); // 存在 "LiHua",返回 1,不存在返回 0
# 声明一个哈希表集合
name = set()
# 插入元素
name.add("XiaoMing")
name.add("LiHua")
# 查找元素
if "LiHua" in name: # 存在 "LiHua",返回 true
# 存在的操作逻辑
print("LiHua is in the set.")
else:
# 不存在的操作逻辑
print("LiHua is not in the set.")
# 修改元素
# 由于集合中只存储键,只需要删除元素后插入新元素即可。
# 判断元素是否存在
print(1 if "LiHua" in name else 0) # 存在 "LiHua",返回 1,不存在返回 0
# 删除元素
name.remove("LiHua") # 删除指定键的元素
name.clear() # 清空集合
print(1 if "LiHua" in name else 0) # 存在 "LiHua",返回 1,不存在返回 0
总结
通过以上内容的学习,相信你们已经理解了以下几点:
- 哈希表的底层数据结构是数组+链表(根据处理hash冲突有不同的实现,数组+链表是很经典的实现)。
- 哈希表为什么能高效的进行增删改查,以及如何处理哈希冲突。
- 如何声明一个哈希表。
- 哈希表的增删改查。
- 哈希表的遍历。
掌握了以上内容,相信你已经具备了哈希表的基本操作能力,在实际编程中可以灵活运用哈希表来解决各种问题。 为了确保你能够熟练使用哈希表,强烈建议至少完成以下两个例题,以确保你理解了本文的所有内容。
哈希表的应用
例题 1:链表相交
题意
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null。 题目数据保证整个链式结构中不存在环。
思路分析
要返回两个链表相交的起始节点,我们可以先遍历链表A,并把链表节点存储在哈希表中。然后再遍历链表B, 检查链表B的节点是否在哈希表中,如果存在,说明该节点是相交节点,直接返回即可。否则,说明不相交。
补充
一个很经典的集合相交问题,想要知道A和B的交集,可以先将集合A存入哈希表,然后遍历B,通过判断B中元素是否在哈希表中判断是否是相交元素。
代码实现
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
unordered_set<ListNode*> exist;
exist.insert(headA);
while (headA->next != NULL) {
headA = headA->next;
exist.insert(headA);
}
if (exist.count(headB)) {
return headB;
}
while (headB->next != NULL) {
headB = headB->next;
if (exist.count(headB)) {
return headB;
}
}
return NULL;
}
};
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode pA = headA, pB = headB;
HashSet<ListNode> setA = new HashSet<>();
setA.add(headA);
while (pA.next != null) {
pA = pA.next;
setA.add(pA);
}
if (setA.contains(pB)) {
return pB;
}
while (pB.next != null) {
pB = pB.next;
if (setA.contains(pB)) {
return pB;
}
}
return null;
}
}
class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
s = set()
p, q = headA, headB
while p:
s.add(p)
p = p.next
while q:
if q in s:
return q
q = q.next
return None
例题 2:两数之和
一个很直接的想法就是,枚举所有可能的情况,判断这两个数和是否为target,那么这个算法是 的。
想想如何优化?之前的枚举是先枚举i,然后枚举j从0到i-1,判断是否满足nums[i]+nums[j]==target。
那么这个判断等价于查找i前面是否存在一个下标j,满足nums[j]==target-nums[i]。那么我们可以用哈希表维护前面数组元素值和下标的键值对,就可以O(1)查找是否存在一个nums[j]=target-nums[i]。
代码实现
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> idx;
for (int i = 0; i < nums.size(); i++) {
int need = target - nums[i];
if (idx.count(need)) {
vector<int> ans{idx[need], i};
return ans;
}
idx[nums[i]] = i;
}
return vector<int>(0);
}
};
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
import java.util.HashMap;
import java.util.Map;
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> idx = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int need = target - nums[i];
if (idx.containsKey(need)) {
return new int[]{idx.get(need), i};
}
idx.put(nums[i], i);
}
return new int[0];
}
}
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
idx = {}
for i, num in enumerate(nums):
need = target - num
if need in idx:
return [idx[need], i]
idx[num] = i
return []