秋招复习之哈希表

目录

前言

1 哈希表

哈希表常用操作

哈希表简单实现

哈希冲突与扩容

2 哈希冲突

链式地址

 开放寻址

线性探测

 平方探测

多次哈希

编程语言的选择

3 哈希算法

哈希算法的目标

哈希算法的设计

常见哈希算法

数据结构的哈希值

总结


前言

秋招复习之哈希表。


1 哈希表

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

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

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

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

哈希表常用操作

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

/* 初始化哈希表 */
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);
/* 初始化哈希表 */
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);

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

/* 遍历哈希表 */
// 遍历键值对 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);
}
/* 遍历哈希表 */
// 遍历键值对 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;
}

哈希表简单实现

我们先考虑最简单的情况,仅用一个数组来实现哈希表。在哈希表中,我们将数组中的每个空位称为「桶 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 ,以表示键值对。

/* 键值对 */
class Pair {public int key;public String val;public Pair(int key, String val) {this.key = key;this.val = val;}
}/* 基于数组实现的哈希表 */
class ArrayHashMap {private List<Pair> buckets;public ArrayHashMap() {// 初始化数组,包含 100 个桶buckets = new ArrayList<>();for (int i = 0; i < 100; i++) {buckets.add(null);}}/* 哈希函数 */private int hashFunc(int key) {int index = key % 100;return index;}/* 查询操作 */public String get(int key) {int index = hashFunc(key);Pair pair = buckets.get(index);if (pair == null)return null;return pair.val;}/* 添加操作 */public void put(int key, String val) {Pair pair = new Pair(key, val);int index = hashFunc(key);buckets.set(index, pair);}/* 删除操作 */public void remove(int key) {int index = hashFunc(key);// 置为 null ,代表删除buckets.set(index, null);}/* 获取所有键值对 */public List<Pair> pairSet() {List<Pair> pairSet = new ArrayList<>();for (Pair pair : buckets) {if (pair != null)pairSet.add(pair);}return pairSet;}/* 获取所有键 */public List<Integer> keySet() {List<Integer> keySet = new ArrayList<>();for (Pair pair : buckets) {if (pair != null)keySet.add(pair.key);}return keySet;}/* 获取所有值 */public List<String> valueSet() {List<String> valueSet = new ArrayList<>();for (Pair pair : buckets) {if (pair != null)valueSet.add(pair.val);}return valueSet;}/* 打印哈希表 */public void print() {for (Pair kv : pairSet()) {System.out.println(kv.key + " -> " + kv.val);}}
}
/* 键值对 */
struct Pair {public:int key;string val;Pair(int key, string val) {this->key = key;this->val = val;}
};/* 基于数组实现的哈希表 */
class ArrayHashMap {private:vector<Pair *> buckets;public:ArrayHashMap() {// 初始化数组,包含 100 个桶buckets = vector<Pair *>(100);}~ArrayHashMap() {// 释放内存for (const auto &bucket : buckets) {delete bucket;}buckets.clear();}/* 哈希函数 */int hashFunc(int key) {int index = key % 100;return index;}/* 查询操作 */string get(int key) {int index = hashFunc(key);Pair *pair = buckets[index];if (pair == nullptr)return "";return pair->val;}/* 添加操作 */void put(int key, string val) {Pair *pair = new Pair(key, val);int index = hashFunc(key);buckets[index] = pair;}/* 删除操作 */void remove(int key) {int index = hashFunc(key);// 释放内存并置为 nullptrdelete buckets[index];buckets[index] = nullptr;}/* 获取所有键值对 */vector<Pair *> pairSet() {vector<Pair *> pairSet;for (Pair *pair : buckets) {if (pair != nullptr) {pairSet.push_back(pair);}}return pairSet;}/* 获取所有键 */vector<int> keySet() {vector<int> keySet;for (Pair *pair : buckets) {if (pair != nullptr) {keySet.push_back(pair->key);}}return keySet;}/* 获取所有值 */vector<string> valueSet() {vector<string> valueSet;for (Pair *pair : buckets) {if (pair != nullptr) {valueSet.push_back(pair->val);}}return valueSet;}/* 打印哈希表 */void print() {for (Pair *kv : pairSet()) {cout << kv->key << " -> " << kv->val << endl;}}
};

哈希冲突与扩容

从本质上看,哈希函数的作用是将所有 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 倍。

2 哈希冲突

上一节提到,通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。

哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。

  1. 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作
  2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。

哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。

链式地址

在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。图展示了一个链式地址哈希表的例子。

基于链式地址实现的哈希表的操作方法发生了以下变化。

  • 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。
  • 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
  • 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。

链式地址存在以下局限性。

  • 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
  • 查询效率降低:因为需要线性遍历链表来查找对应元素。

以下代码给出了链式地址哈希表的简单实现,需要注意两点。

  • 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
  • 以下实现包含哈希表扩容方法。当负载因子超过 2/3 时,我们将哈希表扩容至原先的 2 倍。
/* 链式地址哈希表 */
class HashMapChaining {int size; // 键值对数量int capacity; // 哈希表容量double loadThres; // 触发扩容的负载因子阈值int extendRatio; // 扩容倍数List<List<Pair>> buckets; // 桶数组/* 构造方法 */public HashMapChaining() {size = 0;capacity = 4;loadThres = 2.0 / 3.0;extendRatio = 2;buckets = new ArrayList<>(capacity);for (int i = 0; i < capacity; i++) {buckets.add(new ArrayList<>());}}/* 哈希函数 */int hashFunc(int key) {return key % capacity;}/* 负载因子 */double loadFactor() {return (double) size / capacity;}/* 查询操作 */String get(int key) {int index = hashFunc(key);List<Pair> bucket = buckets.get(index);// 遍历桶,若找到 key ,则返回对应 valfor (Pair pair : bucket) {if (pair.key == key) {return pair.val;}}// 若未找到 key ,则返回 nullreturn null;}/* 添加操作 */void put(int key, String val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}int index = hashFunc(key);List<Pair> bucket = buckets.get(index);// 遍历桶,若遇到指定 key ,则更新对应 val 并返回for (Pair pair : bucket) {if (pair.key == key) {pair.val = val;return;}}// 若无该 key ,则将键值对添加至尾部Pair pair = new Pair(key, val);bucket.add(pair);size++;}/* 删除操作 */void remove(int key) {int index = hashFunc(key);List<Pair> bucket = buckets.get(index);// 遍历桶,从中删除键值对for (Pair pair : bucket) {if (pair.key == key) {bucket.remove(pair);size--;break;}}}/* 扩容哈希表 */void extend() {// 暂存原哈希表List<List<Pair>> bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets = new ArrayList<>(capacity);for (int i = 0; i < capacity; i++) {buckets.add(new ArrayList<>());}size = 0;// 将键值对从原哈希表搬运至新哈希表for (List<Pair> bucket : bucketsTmp) {for (Pair pair : bucket) {put(pair.key, pair.val);}}}/* 打印哈希表 */void print() {for (List<Pair> bucket : buckets) {List<String> res = new ArrayList<>();for (Pair pair : bucket) {res.add(pair.key + " -> " + pair.val);}System.out.println(res);}}
}
/* 链式地址哈希表 */
class HashMapChaining {private:int size;                       // 键值对数量int capacity;                   // 哈希表容量double loadThres;               // 触发扩容的负载因子阈值int extendRatio;                // 扩容倍数vector<vector<Pair *>> buckets; // 桶数组public:/* 构造方法 */HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) {buckets.resize(capacity);}/* 析构方法 */~HashMapChaining() {for (auto &bucket : buckets) {for (Pair *pair : bucket) {// 释放内存delete pair;}}}/* 哈希函数 */int hashFunc(int key) {return key % capacity;}/* 负载因子 */double loadFactor() {return (double)size / (double)capacity;}/* 查询操作 */string get(int key) {int index = hashFunc(key);// 遍历桶,若找到 key ,则返回对应 valfor (Pair *pair : buckets[index]) {if (pair->key == key) {return pair->val;}}// 若未找到 key ,则返回空字符串return "";}/* 添加操作 */void put(int key, string val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}int index = hashFunc(key);// 遍历桶,若遇到指定 key ,则更新对应 val 并返回for (Pair *pair : buckets[index]) {if (pair->key == key) {pair->val = val;return;}}// 若无该 key ,则将键值对添加至尾部buckets[index].push_back(new Pair(key, val));size++;}/* 删除操作 */void remove(int key) {int index = hashFunc(key);auto &bucket = buckets[index];// 遍历桶,从中删除键值对for (int i = 0; i < bucket.size(); i++) {if (bucket[i]->key == key) {Pair *tmp = bucket[i];bucket.erase(bucket.begin() + i); // 从中删除键值对delete tmp;                       // 释放内存size--;return;}}}/* 扩容哈希表 */void extend() {// 暂存原哈希表vector<vector<Pair *>> bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets.clear();buckets.resize(capacity);size = 0;// 将键值对从原哈希表搬运至新哈希表for (auto &bucket : bucketsTmp) {for (Pair *pair : bucket) {put(pair->key, pair->val);// 释放内存delete pair;}}}/* 打印哈希表 */void print() {for (auto &bucket : buckets) {cout << "[";for (Pair *pair : bucket) {cout << pair->key << " -> " << pair->val << ", ";}cout << "]\n";}}
};

值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至 O(log⁡n) 。

 开放寻址

「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。

下面以线性探测为例,介绍开放寻址哈希表的工作机制。

线性探测

采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。

  • 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
  • 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None 。

图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。

然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。

值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。

为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。

然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。

为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。

以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。

/* 开放寻址哈希表 */
class HashMapOpenAddressing {private int size; // 键值对数量private int capacity = 4; // 哈希表容量private final double loadThres = 2.0 / 3.0; // 触发扩容的负载因子阈值private final int extendRatio = 2; // 扩容倍数private Pair[] buckets; // 桶数组private final Pair TOMBSTONE = new Pair(-1, "-1"); // 删除标记/* 构造方法 */public HashMapOpenAddressing() {size = 0;buckets = new Pair[capacity];}/* 哈希函数 */private int hashFunc(int key) {return key % capacity;}/* 负载因子 */private double loadFactor() {return (double) size / capacity;}/* 搜索 key 对应的桶索引 */private int findBucket(int key) {int index = hashFunc(key);int firstTombstone = -1;// 线性探测,当遇到空桶时跳出while (buckets[index] != null) {// 若遇到 key ,返回对应的桶索引if (buckets[index].key == key) {// 若之前遇到了删除标记,则将键值对移动至该索引处if (firstTombstone != -1) {buckets[firstTombstone] = buckets[index];buckets[index] = TOMBSTONE;return firstTombstone; // 返回移动后的桶索引}return index; // 返回桶索引}// 记录遇到的首个删除标记if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {firstTombstone = index;}// 计算桶索引,越过尾部则返回头部index = (index + 1) % capacity;}// 若 key 不存在,则返回添加点的索引return firstTombstone == -1 ? index : firstTombstone;}/* 查询操作 */public String get(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则返回对应 valif (buckets[index] != null && buckets[index] != TOMBSTONE) {return buckets[index].val;}// 若键值对不存在,则返回 nullreturn null;}/* 添加操作 */public void put(int key, String val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则覆盖 val 并返回if (buckets[index] != null && buckets[index] != TOMBSTONE) {buckets[index].val = val;return;}// 若键值对不存在,则添加该键值对buckets[index] = new Pair(key, val);size++;}/* 删除操作 */public void remove(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则用删除标记覆盖它if (buckets[index] != null && buckets[index] != TOMBSTONE) {buckets[index] = TOMBSTONE;size--;}}/* 扩容哈希表 */private void extend() {// 暂存原哈希表Pair[] bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets = new Pair[capacity];size = 0;// 将键值对从原哈希表搬运至新哈希表for (Pair pair : bucketsTmp) {if (pair != null && pair != TOMBSTONE) {put(pair.key, pair.val);}}}/* 打印哈希表 */public void print() {for (Pair pair : buckets) {if (pair == null) {System.out.println("null");} else if (pair == TOMBSTONE) {System.out.println("TOMBSTONE");} else {System.out.println(pair.key + " -> " + pair.val);}}}
}
/* 开放寻址哈希表 */
class HashMapOpenAddressing {private:int size;                             // 键值对数量int capacity = 4;                     // 哈希表容量const double loadThres = 2.0 / 3.0;     // 触发扩容的负载因子阈值const int extendRatio = 2;            // 扩容倍数vector<Pair *> buckets;               // 桶数组Pair *TOMBSTONE = new Pair(-1, "-1"); // 删除标记public:/* 构造方法 */HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) {}/* 析构方法 */~HashMapOpenAddressing() {for (Pair *pair : buckets) {if (pair != nullptr && pair != TOMBSTONE) {delete pair;}}delete TOMBSTONE;}/* 哈希函数 */int hashFunc(int key) {return key % capacity;}/* 负载因子 */double loadFactor() {return (double)size / capacity;}/* 搜索 key 对应的桶索引 */int findBucket(int key) {int index = hashFunc(key);int firstTombstone = -1;// 线性探测,当遇到空桶时跳出while (buckets[index] != nullptr) {// 若遇到 key ,返回对应的桶索引if (buckets[index]->key == key) {// 若之前遇到了删除标记,则将键值对移动至该索引处if (firstTombstone != -1) {buckets[firstTombstone] = buckets[index];buckets[index] = TOMBSTONE;return firstTombstone; // 返回移动后的桶索引}return index; // 返回桶索引}// 记录遇到的首个删除标记if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {firstTombstone = index;}// 计算桶索引,越过尾部则返回头部index = (index + 1) % capacity;}// 若 key 不存在,则返回添加点的索引return firstTombstone == -1 ? index : firstTombstone;}/* 查询操作 */string get(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则返回对应 valif (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {return buckets[index]->val;}// 若键值对不存在,则返回空字符串return "";}/* 添加操作 */void put(int key, string val) {// 当负载因子超过阈值时,执行扩容if (loadFactor() > loadThres) {extend();}// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则覆盖 val 并返回if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {buckets[index]->val = val;return;}// 若键值对不存在,则添加该键值对buckets[index] = new Pair(key, val);size++;}/* 删除操作 */void remove(int key) {// 搜索 key 对应的桶索引int index = findBucket(key);// 若找到键值对,则用删除标记覆盖它if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {delete buckets[index];buckets[index] = TOMBSTONE;size--;}}/* 扩容哈希表 */void extend() {// 暂存原哈希表vector<Pair *> bucketsTmp = buckets;// 初始化扩容后的新哈希表capacity *= extendRatio;buckets = vector<Pair *>(capacity, nullptr);size = 0;// 将键值对从原哈希表搬运至新哈希表for (Pair *pair : bucketsTmp) {if (pair != nullptr && pair != TOMBSTONE) {put(pair->key, pair->val);delete pair;}}}/* 打印哈希表 */void print() {for (Pair *pair : buckets) {if (pair == nullptr) {cout << "nullptr" << endl;} else if (pair == TOMBSTONE) {cout << "TOMBSTONE" << endl;} else {cout << pair->key << " -> " << pair->val << endl;}}}
};

 平方探测

平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即 1,4,9,… 步。

平方探测主要具有以下优势。

  • 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。
  • 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。

然而,平方探测并不是完美的。

  • 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
  • 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。

多次哈希

顾名思义,多次哈希方法使用多个哈希函数 f1(x)、f2(x)、f3(x)、… 进行探测。

  • 插入元素:若哈希函数 f1(x) 出现冲突,则尝试 f2(x) ,以此类推,直到找到空位后插入元素。
  • 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None 。

与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。

请注意,开放寻址(线性探测、平方探测和多次哈希)哈希表都存在“不能直接删除元素”的问题。

编程语言的选择

各种编程语言采取了不同的哈希表实现策略,下面举几个例子。

  • Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
  • Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
  • Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶;当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。

3 哈希算法

无论是开放寻址还是链式地址,它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生

对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至O(n) 。

键值对的分布情况由哈希函数决定。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:

index = hash(key) % capacity

观察以上公式,当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。

这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。

哈希算法的目标

为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点。

  • 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
  • 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
  • 均匀分布:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。

实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。

  • 密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
  • 数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。

对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。

  • 单向性:无法通过哈希值反推出关于输入数据的任何信息。
  • 抗碰撞性:应当极难找到两个不同的输入,使得它们的哈希值相同。
  • 雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。

请注意,“均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 key 下,哈希函数 key % 100 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key ,从而破解密码。

哈希算法的设计

哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。

  • 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
  • 乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
  • 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
  • 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
/* 加法哈希 */
int addHash(String key) {long hash = 0;final int MODULUS = 1000000007;for (char c : key.toCharArray()) {hash = (hash + (int) c) % MODULUS;}return (int) hash;
}/* 乘法哈希 */
int mulHash(String key) {long hash = 0;final int MODULUS = 1000000007;for (char c : key.toCharArray()) {hash = (31 * hash + (int) c) % MODULUS;}return (int) hash;
}/* 异或哈希 */
int xorHash(String key) {int hash = 0;final int MODULUS = 1000000007;for (char c : key.toCharArray()) {hash ^= (int) c;}return hash & MODULUS;
}/* 旋转哈希 */
int rotHash(String key) {long hash = 0;final int MODULUS = 1000000007;for (char c : key.toCharArray()) {hash = ((hash << 4) ^ (hash >> 28) ^ (int) c) % MODULUS;}return (int) hash;
}
/* 加法哈希 */
int addHash(string key) {long long hash = 0;const int MODULUS = 1000000007;for (unsigned char c : key) {hash = (hash + (int)c) % MODULUS;}return (int)hash;
}/* 乘法哈希 */
int mulHash(string key) {long long hash = 0;const int MODULUS = 1000000007;for (unsigned char c : key) {hash = (31 * hash + (int)c) % MODULUS;}return (int)hash;
}/* 异或哈希 */
int xorHash(string key) {int hash = 0;const int MODULUS = 1000000007;for (unsigned char c : key) {hash ^= (int)c;}return hash & MODULUS;
}/* 旋转哈希 */
int rotHash(string key) {long long hash = 0;const int MODULUS = 1000000007;for (unsigned char c : key) {hash = ((hash << 4) ^ (hash >> 28) ^ (int)c) % MODULUS;}return (int)hash;
}

常见哈希算法

数据结构的哈希值

我们知道,哈希表的 key 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 hash() 函数来计算各种数据类型的哈希值。

  • 整数和布尔量的哈希值就是其本身。
  • 浮点数和字符串的哈希值计算较为复杂,之后可以自学一下。
  • 元组的哈希值是对其中每一个元素进行哈希,然后将这些哈希值组合起来,得到单一的哈希值。
  • 对象的哈希值基于其内存地址生成。通过重写对象的哈希方法,可实现基于内容生成哈希值。

不同编程语言的内置哈希值计算函数的定义和方法不同。

int num = 3;
int hashNum = Integer.hashCode(num);
// 整数 3 的哈希值为 3boolean bol = true;
int hashBol = Boolean.hashCode(bol);
// 布尔量 true 的哈希值为 1231double dec = 3.14159;
int hashDec = Double.hashCode(dec);
// 小数 3.14159 的哈希值为 -1340954729String str = "Hello 算法";
int hashStr = str.hashCode();
// 字符串“Hello 算法”的哈希值为 -727081396Object[] arr = { 12836, "小哈" };
int hashTup = Arrays.hashCode(arr);
// 数组 [12836, 小哈] 的哈希值为 1151158ListNode obj = new ListNode(0);
int hashObj = obj.hashCode();
// 节点对象 utils.ListNode@7dc5e7b4 的哈希值为 2110121908

总结

  • 输入 key ,哈希表能够在 O(1) 时间内查询到 value ,效率非常高。
  • 常见的哈希表操作包括查询、添加键值对、删除键值对和遍历哈希表等。
  • 哈希函数将 key 映射为数组索引,从而访问对应桶并获取 value 。
  • 两个不同的 key 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈希冲突。
  • 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类似,哈希表扩容操作的开销很大。
  • 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容的条件。
  • 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以通过进一步将链表转换为红黑树来提高效率。
  • 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算量。
  • 不同编程语言采取了不同的哈希表实现。例如,Java 的 HashMap 使用链式地址,而 Python 的 Dict 采用开放寻址。
  • 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该具备抗碰撞性和雪崩效应。
  • 哈希算法通常采用大质数作为模数,以最大化地保证哈希值均匀分布,减少哈希冲突。
  • 常见的哈希算法包括 MD5、SHA-1、SHA-2 和 SHA-3 等。MD5 常用于校验文件完整性,SHA-2 常用于安全应用与协议。
  • 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变对象是可哈希的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/598737.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

万界星空科技云MES,助力客户快速构建数字工厂

一、MES发展趋势 1、定制化趋势 工业2.0、3.0的技术已较为成熟&#xff0c;部分制造业水平较为发达的国家已经率先进入以网络化、智能化为代表的工业4.0发展阶段,MES作为制造业规划层随着物联网等持续发展&#xff0c;为适应定制化时代&#xff0c;整体技术模块化、服务化将重…

防蓝光护眼台灯哪个牌子好?2024护眼灯315合格产品

最近身边的宝妈们都来问我这个已有两个娃的老司机&#xff0c;刚上小学就是近视了&#xff0c;买什么台灯给家里孩子能保护视力&#xff0c;经过小学门口时&#xff0c;真的是戴眼镜的小朋友占多数&#xff0c;搜索了我国的近视数据&#xff0c;中国的人口有14亿人左右&#xf…

Chromedriver 下载和安装指南

1. 确定Chrome浏览器版本 首先&#xff0c;在谷歌浏览器中找到当前版本信息。 打开“设置”&#xff0c;点击“关于谷歌”即可看到版本号。确保后续下载的Chromedriver版本与Chrome浏览器版本一致。或者直接跳转网页地址&#xff1a;chrome://settings/help 2. 下载Chromedri…

ShardingSphere-JDBC初探

引言 为什么使用分库分表&#xff1f; 数据量太大单表放不下&#xff0c;并且公司不希望切换产品&#xff0c;可选的方案不多&#xff0c;ShardingSphere就是不错的选择。 切换产品指的是换成es、clickhouse、hbase这种支持大数据&#xff0c;试想一下切换产品对整个项目的改…

Linux第18步_安装“Ubuntu系统下的C语言编译器GCC”

Ubuntu系统没有提供C/C的编译环境&#xff0c;因此还需要手动安装build-essential软件包&#xff0c;它包含了 GNU 编辑器&#xff0c;GNU 调试器&#xff0c;和其他编译软件所必需的开发库和工具。本节用于重点介绍安装“Ubuntu系统下的C语言编译器&#xff27;&#xff23;&a…

电子化学品,预计2025年会增长到4302亿美元

电子化学品市场是一个庞大的细分市场&#xff0c;它包括了广泛的化学品种类&#xff0c;如涂料、塑料、精细化学品、农药和医药等。这个市场的发展相当迅速&#xff0c;下面我们将从全球市场和中国市场两个方面对其发展趋势进行分析。全球市场分析&#xff1a; 从全球市场的角度…

Redis命令---List篇

目录 1.Redis Lindex 命令 - 通过索引获取列表中的元素简介语法可用版本: > 1.0.0返回值: 列表中下标为指定索引值的元素。 如果指定索引值不在列表的区间范围内&#xff0c;返回 nil 。 示例 2.Redis Rpush 命令 - 在列表中添加一个或多个值简介语法可用版本: > 1.0.0返…

[C#]利用opencvsharp实现深度学习caffe模型人脸检测

【官方框架地址】 https://github.com/opencv/opencv/blob/master/samples/dnn/face_detector/deploy.prototxt 采用的是官方caffe模型res10_300x300_ssd_iter_140000.caffemodel进行人脸检测 【算法原理】 使用caffe-ssd目标检测框架训练的caffe模型进行深度学习模型检测 …

ubuntu 22.04 快速安装Odoo17.0详记

序言:时间是我们最宝贵的财富,珍惜手上的每个时分 如果为阿里云或者腾讯云&#xff0c;第一步可以忽略 1.更换阿里云源 第一步&#xff1a;先备份下原始源 sudo cp /etc/apt/sources.list /etc/apt/sources.list.backup 第二步&#xff1a;修改文件 sudo cp /etc/apt/sou…

最常用的自动化测试框架汇总

在开始学习python自动化测试之前&#xff0c;先了解目前市场上的自动化测试框架有哪些&#xff1f; 随着技术的不断迭代更新&#xff0c;优胜劣汰也同样发展下来。从一开始工具型自动化&#xff0c;到现在的框架型&#xff1b;从一开始的能用&#xff0c;到现在的不仅能用&…

app广告变现——广告预加载机制,提升用户体验

通过广告预加载&#xff0c;开发者可以避免在向用户显示广告时出现延迟。 应用在程序启动时需要请求网络&#xff0c;加载资源会需要等待时间&#xff0c;如果在等待过程中没有及时给用户展现画面或反馈&#xff0c;用户很可能会因为等待时间过长而推出应用。广告预加载在此时…

「解析」Windows 如何优雅使用 Terminal

所谓工欲善其事必先利其器&#xff0c;对于开发人员 Linux可能是首选&#xff0c;但是在家学习的时候&#xff0c;我还是更喜欢使用 Windows系统&#xff0c;首先是稳定&#xff0c;其次是习惯了。当然了&#xff0c;我还有一台专门安装 Linux系统的小主机用于学习Linux使用&am…

从技术角度分析:HTTP 和 HTTPS 有何不同

网络安全问题正变得日益重要&#xff0c;而 HTTP 与 HTTPS 对用户数据的保护十分关键。本文将深入探讨这两种协议的特点、工作原理&#xff0c;以及保证数据安全的 HTTPS 为何变得至关重要。 认识 HTTP 与 HTTPS HTTP 的工作原理 HTTP&#xff0c;全称超文本传输协议&#xf…

2024年我国网络安全发展形势展望

2023年&#xff0c;我国网络安全政策法规陆续出台&#xff0c;网络安全与数据安全产业发展势头强劲&#xff0c;网络安全形势整体向好。展望2024年&#xff0c;世界各国在网络空间中的竞争将变得愈发激烈&#xff0c;我国网络安全领域的法律法规将不断完善&#xff0c;数据安全…

超好玩的烧脑当当狸智能五子棋,锻炼孩子的超级大脑

数码时代&#xff0c;儿童沉迷于电子设备和网络游戏已经成为了常态 作为家长&#xff0c;我们都希望能够找到一种&#xff0c;既有趣又益于孩子成长发展的娱乐方式 ✨「当当狸智能五子棋」&#xff0c;儿童智力成长的好帮手学玩一体锻炼儿童思维 不仅是一种新型休闲娱乐的方…

Linux第4步_安装VMwareTools

安装Ubuntu操作系统后&#xff0c;就可以安装VMwareTools了&#xff0c;目的是实现“电脑和虚拟机之间互相复制粘贴文件”。 1、双击桌面图标“VMware Workstation Pro”&#xff0c;然后点击下图中的“开机”&#xff0c;打开虚拟机电源。 2、双击下图中的用户名“zgq” 3、…

ARCGIS PRO SDK Geoprocessing

调用原型&#xff1a;Dim gpResult AS IGPResult await Geoprocessing.ExecuteToolAsync(调用工具名称, GPValue数组, environment, null, null, executeFlags) 一、调用工具名称&#xff1a;地理处理工具名称。如面转线&#xff1a;management.PolygonToLine&#xff0c;而非…

吉林大学19、21级计算机学院《计算机网络》期末真题试题

一、21级&#xff08;考后回忆&#xff09; 一、不定项选择&#xff08;一共10个选择题&#xff0c;一个两分&#xff0c;选全得满分&#xff09; 不定项&#xff1a;可以选择1~4个 考点有&#xff1a; ①协议、服务 ②码分多路复用通过接受码片序列&#xff0c;求哪个站点发送…

H5 - - - - - 获取图片exif相关信息

1. EXIF是什么 【可交换图像文件格式】&#xff1a;&#xff08;英语&#xff1a;Exchangeable image file format&#xff0c;官方简称Exif&#xff09;,是专门为数码相机的照片设定的&#xff0c;可以记录数码照片的属性信息和拍摄数据。 2. EXIF 相关标识 { ApertureValu…

代码随想录算法训练营第二十四天 | 回溯算法

理论基础 代码随想录原文 什么是回溯法 回溯也可以叫做回溯搜索法&#xff0c;它是一种搜索的方式。 回溯是递归的副产品&#xff0c;只要有递归就会有回溯。 回溯法的效率 虽然回溯法很难&#xff0c;不好理解&#xff0c;但是回溯法并不是什么高效的算法。因为回溯的本…