返回首页返回书籍《Hello 算法》
目录

目录 · CONTENTS

  1. 01序
  2. 02前言
  3. 03关于本书
  4. 04如何使用本书
  5. 05小结
  6. 06初识算法
  7. 07算法无处不在
  8. 08算法是什么
  9. 09小结
  10. 10复杂度分析
  11. 11算法效率评估
  12. 12迭代与递归
  13. 13时间复杂度
  14. 14空间复杂度
  15. 15小结
  16. 16练习
  17. 17数据结构
  18. 18数据结构分类
  19. 19基本数据类型
  20. 20数字编码
  21. 21字符编码
  22. 22小结
  23. 23练习
  24. 24数组与链表
  25. 25数组
  26. 26链表
  27. 27列表
  28. 28内存与缓存
  29. 29小结
  30. 30练习
  31. 31栈与队列
  32. 32栈
  33. 33队列
  34. 34双向队列
  35. 35小结
  36. 36练习
  37. 37哈希表
  38. 38哈希表
  39. 39哈希冲突
  40. 40哈希算法
  41. 41小结
  42. 42练习
  43. 43树
  44. 44二叉树
  45. 45二叉树遍历
  46. 46二叉树数组表示
  47. 47二叉搜索树
  48. 48AVL 树
  49. 49小结
  50. 50练习
  51. 51堆
  52. 52堆
  53. 53建堆操作
  54. 54Top-k 问题
  55. 55小结
  56. 56练习
  57. 57图
  58. 58图
  59. 59图的基础操作
  60. 60图的遍历
  61. 61小结
  62. 62练习
  63. 63搜索
  64. 64二分查找
  65. 65二分查找插入点
  66. 66二分查找边界
  67. 67哈希优化策略
  68. 68重识搜索算法
  69. 69小结
  70. 70练习
  71. 71排序
  72. 72排序算法
  73. 73选择排序
  74. 74冒泡排序
  75. 75插入排序
  76. 76快速排序
  77. 77归并排序
  78. 78堆排序
  79. 79桶排序
  80. 80计数排序
  81. 81基数排序
  82. 82小结
  83. 83练习
  84. 84分治
  85. 85分治算法
  86. 86分治搜索策略
  87. 87构建二叉树问题
  88. 88汉诺塔问题
  89. 89小结
  90. 90练习
  91. 91回溯
  92. 92回溯算法
  93. 93全排列问题
  94. 94子集和问题
  95. 95n 皇后问题
  96. 96小结
  97. 97练习
  98. 98动态规划
  99. 99初探动态规划
  100. 100动态规划问题特性
  101. 101动态规划解题思路
  102. 1020-1 背包问题
  103. 103完全背包问题
  104. 104编辑距离问题
  105. 105小结
  106. 106练习
  107. 107贪心
  108. 108贪心算法
  109. 109分数背包问题
  110. 110最大容量问题
  111. 111最大切分乘积问题
  112. 112小结
  113. 113练习
  114. 114附录
  115. 115编程环境安装
  116. 116一起参与创作
  117. 117术语表
  118. 118参考文献
  119. 119纸质书
第 38 / 119 节

第 38 节 · SECTION 38

哈希表

哈希表(hash table),又称散列表,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键 key ,则可以在 $O(1)$ 时间内获取对应的值 value 。

如下图所示,给定 $n$ 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用下图所示的哈希表来实现。

哈希表的抽象表示

除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如下表所示。

  • 添加元素:仅需将元素添加至数组(链表)的尾部即可,使用 $O(1)$ 时间。
  • 查询元素:由于数组(链表)是乱序的,因此需要遍历其中的所有元素,使用 $O(n)$ 时间。
  • 删除元素:需要先查询到元素,再从数组(链表)中删除,使用 $O(n)$ 时间。

表   元素查询效率对比

数组 链表 哈希表
查找元素 $O(n)$ $O(n)$ $O(1)$
添加元素 $O(1)$ $O(1)$ $O(1)$
删除元素 $O(n)$ $O(n)$ $O(1)$

观察发现,在哈希表中进行增删查改的时间复杂度都是 $O(1)$ ,非常高效。

哈希表常用操作

哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等,示例代码如下:

Python

# 初始化哈希表
hmap: dict = {}

# 添加操作
# 在哈希表中添加键值对 (key, value)
hmap[12836] = "小哈"
hmap[15937] = "小啰"
hmap[16750] = "小算"
hmap[13276] = "小法"
hmap[10583] = "小鸭"

# 查询操作
# 向哈希表中输入键 key ,得到值 value
name: str = hmap[15937]

# 删除操作
# 在哈希表中删除键值对 (key, value)
hmap.pop(10583)

C++

/* 初始化哈希表 */
unordered_map<int, string> map;

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈";
map[15937] = "小啰";
map[16750] = "小算";
map[13276] = "小法";
map[10583] = "小鸭";

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
string name = map[15937];

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.erase(10583);

Java

/* 初始化哈希表 */
Map<Integer, String> map = new HashMap<>();

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map.put(12836, "小哈");
map.put(15937, "小啰");
map.put(16750, "小算");
map.put(13276, "小法");
map.put(10583, "小鸭");

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
String name = map.get(15937);

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.remove(10583);

C#

/* 初始化哈希表 */
Dictionary<int, string> map = new() {
    /* 添加操作 */
    // 在哈希表中添加键值对 (key, value)
    { 12836, "小哈" },
    { 15937, "小啰" },
    { 16750, "小算" },
    { 13276, "小法" },
    { 10583, "小鸭" }
};

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
string name = map[15937];

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.Remove(10583);

Go

/* 初始化哈希表 */
hmap := make(map[int]string)

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
hmap[12836] = "小哈"
hmap[15937] = "小啰"
hmap[16750] = "小算"
hmap[13276] = "小法"
hmap[10583] = "小鸭"

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
name := hmap[15937]

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
delete(hmap, 10583)

Swift

/* 初始化哈希表 */
var map: [Int: String] = [:]

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈"
map[15937] = "小啰"
map[16750] = "小算"
map[13276] = "小法"
map[10583] = "小鸭"

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
let name = map[15937]!

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.removeValue(forKey: 10583)

JS

/* 初始化哈希表 */
const map = new Map();
/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map.set(12836, '小哈');
map.set(15937, '小啰');
map.set(16750, '小算');
map.set(13276, '小法');
map.set(10583, '小鸭');

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
let name = map.get(15937);

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.delete(10583);

TS

/* 初始化哈希表 */
const map = new Map<number, string>();
/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map.set(12836, '小哈');
map.set(15937, '小啰');
map.set(16750, '小算');
map.set(13276, '小法');
map.set(10583, '小鸭');
console.info('\n添加完成后,哈希表为\nKey -> Value');
console.info(map);

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
let name = map.get(15937);
console.info('\n输入学号 15937 ,查询到姓名 ' + name);

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.delete(10583);
console.info('\n删除 10583 后,哈希表为\nKey -> Value');
console.info(map);

Dart

/* 初始化哈希表 */
Map<int, String> map = {};

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈";
map[15937] = "小啰";
map[16750] = "小算";
map[13276] = "小法";
map[10583] = "小鸭";

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
String name = map[15937];

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.remove(10583);

Rust

use std::collections::HashMap;

/* 初始化哈希表 */
let mut map: HashMap<i32, String> = HashMap::new();

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map.insert(12836, "小哈".to_string());
map.insert(15937, "小啰".to_string());
map.insert(16750, "小算".to_string());
map.insert(13279, "小法".to_string());
map.insert(10583, "小鸭".to_string());

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
let _name: Option<&String> = map.get(&15937);

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
let _removed_value: Option<String> = map.remove(&10583);

C

// C 未提供内置哈希表

Kotlin

/* 初始化哈希表 */
val map = HashMap<Int,String>()

/* 添加操作 */
// 在哈希表中添加键值对 (key, value)
map[12836] = "小哈"
map[15937] = "小啰"
map[16750] = "小算"
map[13276] = "小法"
map[10583] = "小鸭"

/* 查询操作 */
// 向哈希表中输入键 key ,得到值 value
val name = map[15937]

/* 删除操作 */
// 在哈希表中删除键值对 (key, value)
map.remove(10583)

Ruby

# 初始化哈希表
hmap = {}

# 添加操作
# 在哈希表中添加键值对 (key, value)
hmap[12836] = "小哈"
hmap[15937] = "小啰"
hmap[16750] = "小算"
hmap[13276] = "小法"
hmap[10583] = "小鸭"

# 查询操作
# 向哈希表中输入键 key ,得到值 value
name = hmap[15937]

# 删除操作
# 在哈希表中删除键值对 (key, value)
hmap.delete(10583)

可视化运行

https://pythontutor.com/render.html#code="""Driver Code""" if __name__ %3D%3D "__main__"%3A %23 初始化哈希表 hmap %3D {} %23 添加操作 %23 在哈希表中添加键值对 (key, value) hmap[12836] %3D "小哈" hmap[15937] %3D "小啰" hmap[16750] %3D "小算" hmap[13276] %3D "小法" hmap[10583] %3D "小鸭" %23 查询操作 %23 向哈希表中输入键 key ,得到值 value name %3D hmap[15937] %23 删除操作 %23 在哈希表中删除键值对 (key, value) hmap.pop(10583)&cumulative=false&curInstr=2&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=[]&textReferences=false

哈希表有三种常用的遍历方式:遍历键值对、遍历键和遍历值。示例代码如下:

Python

# 遍历哈希表
# 遍历键值对 key->value
for key, value in hmap.items():
    print(key, "->", value)
# 单独遍历键 key
for key in hmap.keys():
    print(key)
# 单独遍历值 value
for value in hmap.values():
    print(value)

C++

/* 遍历哈希表 */
// 遍历键值对 key->value
for (auto kv: map) {
    cout << kv.first << " -> " << kv.second << endl;
}
// 使用迭代器遍历 key->value
for (auto iter = map.begin(); iter != map.end(); iter++) {
    cout << iter->first << "->" << iter->second << endl;
}

Java

/* 遍历哈希表 */
// 遍历键值对 key->value
for (Map.Entry <Integer, String> kv: map.entrySet()) {
    System.out.println(kv.getKey() + " -> " + kv.getValue());
}
// 单独遍历键 key
for (int key: map.keySet()) {
    System.out.println(key);
}
// 单独遍历值 value
for (String val: map.values()) {
    System.out.println(val);
}

C#

/* 遍历哈希表 */
// 遍历键值对 Key->Value
foreach (var kv in map) {
    Console.WriteLine(kv.Key + " -> " + kv.Value);
}
// 单独遍历键 key
foreach (int key in map.Keys) {
    Console.WriteLine(key);
}
// 单独遍历值 value
foreach (string val in map.Values) {
    Console.WriteLine(val);
}

Go

/* 遍历哈希表 */
// 遍历键值对 key->value
for key, value := range hmap {
    fmt.Println(key, "->", value)
}
// 单独遍历键 key
for key := range hmap {
    fmt.Println(key)
}
// 单独遍历值 value
for _, value := range hmap {
    fmt.Println(value)
}

Swift

/* 遍历哈希表 */
// 遍历键值对 Key->Value
for (key, value) in map {
    print("\(key) -> \(value)")
}
// 单独遍历键 Key
for key in map.keys {
    print(key)
}
// 单独遍历值 Value
for value in map.values {
    print(value)
}

JS

/* 遍历哈希表 */
console.info('\n遍历键值对 Key->Value');
for (const [k, v] of map.entries()) {
    console.info(k + ' -> ' + v);
}
console.info('\n单独遍历键 Key');
for (const k of map.keys()) {
    console.info(k);
}
console.info('\n单独遍历值 Value');
for (const v of map.values()) {
    console.info(v);
}

TS

/* 遍历哈希表 */
console.info('\n遍历键值对 Key->Value');
for (const [k, v] of map.entries()) {
    console.info(k + ' -> ' + v);
}
console.info('\n单独遍历键 Key');
for (const k of map.keys()) {
    console.info(k);
}
console.info('\n单独遍历值 Value');
for (const v of map.values()) {
    console.info(v);
}

Dart

/* 遍历哈希表 */
// 遍历键值对 Key->Value
map.forEach((key, value) {
  print('$key -> $value');
});

// 单独遍历键 Key
map.keys.forEach((key) {
  print(key);
});

// 单独遍历值 Value
map.values.forEach((value) {
  print(value);
});

Rust

/* 遍历哈希表 */
// 遍历键值对 Key->Value
for (key, value) in &map {
    println!("{key} -> {value}");
}

// 单独遍历键 Key
for key in map.keys() {
    println!("{key}");
}

// 单独遍历值 Value
for value in map.values() {
    println!("{value}");
}

C

// C 未提供内置哈希表

Kotlin

/* 遍历哈希表 */
// 遍历键值对 key->value
for ((key, value) in map) {
    println("$key -> $value")
}
// 单独遍历键 key
for (key in map.keys) {
    println(key)
}
// 单独遍历值 value
for (_val in map.values) {
    println(_val)
}

Ruby

# 遍历哈希表
# 遍历键值对 key->value
hmap.entries.each { |key, value| puts "#{key} -> #{value}" }

# 单独遍历键 key
hmap.keys.each { |key| puts key }

# 单独遍历值 value
hmap.values.each { |val| puts val }

可视化运行

https://pythontutor.com/render.html#code="""Driver Code""" if __name__ %3D%3D "__main__"%3A %23 初始化哈希表 hmap %3D {} %23 添加操作 %23 在哈希表中添加键值对 (key, value) hmap[12836] %3D "小哈" hmap[15937] %3D "小啰" hmap[16750] %3D "小算" hmap[13276] %3D "小法" hmap[10583] %3D "小鸭" %23 遍历哈希表 %23 遍历键值对 key->value for key, value in hmap.items()%3A print(key, "->", value) %23 单独遍历键 key for key in hmap.keys()%3A print(key) %23 单独遍历值 value for value in hmap.values()%3A print(value)&cumulative=false&curInstr=8&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=[]&textReferences=false

哈希表简单实现

我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value 。

那么,如何基于 key 定位对应的桶呢?这是通过哈希函数(hash function)实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key ,输出空间是所有桶(数组索引)。换句话说,输入一个 key ,我们可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置。

输入一个 key ,哈希函数的计算过程分为以下两步。

  1. 通过某种哈希算法 hash() 计算得到哈希值。
  2. 将哈希值对桶数量(数组长度)capacity 取模,从而获取该 key 对应的桶(数组索引)index 。
index = hash(key) % capacity

随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value 。

设数组长度 capacity = 100、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。下图以 key 学号和 value 姓名为例,展示了哈希函数的工作原理。

哈希函数工作原理

以下代码实现了一个简单哈希表。其中,我们将 key 和 value 封装成一个类 Pair ,以表示键值对。

[file]{array_hash_map}-[class]{array_hash_map}-[func]{}

哈希冲突与扩容

从本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况。

对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到:

12836 % 100 = 36
20336 % 100 = 36

如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为哈希冲突(hash collision)。

哈希冲突示例

容易想到,哈希表容量 $n$ 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突。

如下图所示,扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失。

哈希表扩容

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。

负载因子(load factor)是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常作为哈希表扩容的触发条件。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表扩容至原先的 $2$ 倍。