通常情况下哈希函数那点输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一个桶索引。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略:
改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。
仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
链式地址在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所发生冲突的键值对都存储在同一链表中。如图展示了一个链式哈希表的例子:
基于链式地址实现的哈希表的操作方法发生了以下变化:
查询元素:输入key,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比key以查找目标键值对。
添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
链式地址存在以下局限性:
占用空间增大:链表头节点包含节点指针,它相比数组更加耗费内存空间。
查询效率降低:因为需要线性遍历链表来查找对应元素。
以下代码给出了链式地址哈希表的简单实现,需要注意两点:
使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
以下实现包含哈希表扩容方法。当负载因子超过2/3时我们将哈希表扩容至原先的2倍。
/*键值对*/ struct Pair{ int key; string val; Pair(int key, string val){ this->key = key; this->val = val; } }; class HashMapChaining{ private: int capacity; // 哈希表容量 double loadThres; // 触发扩容的负载因子阈值 int extendRatio; // 扩容倍数 vector<vector<Pair*>> buckets; // 桶数组 public: int size; // 键值对数量 /*构造方法*/ 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){ int index = key % capacity; return index; } /*负载因子*/ double loadFactor(){ return (double)size / (double)capacity; } /*查询操作*/ string get(int key){ int index = hashFunc(key); if (buckets[index].size() == 0) return ""; for (Pair * pair : buckets[index]){ if (key == pair->key) return pair->val; } return ""; } /*添加操作*/ void put(int key, string val){ if (loadFactor() > loadThres) extend(); Pair * pair = new Pair(key, val); int index = hashFunc(key); buckets[index].push_back(pair); size++; } /*删除操作*/ void remove(int key){ int index = hashFunc(key); for (auto iter = buckets[index].begin(); iter != buckets[index].end();iter++){ if (key == (*iter)->key){ buckets[index].erase(iter); size--; return; } } cout << "未找到该元素" << endl; } /*扩容哈希表*/ void extend(){ buckets.resize(extendRatio * capacity); capacity *= extendRatio; } /*打印哈希表*/ void print(){ for (auto &bucket : buckets){ for (Pair *pair : bucket){ cout << pair->key << "->" << pair->val << endl; } } } };值得注意的是,当链表很长时,查询效率O(n)很差,此时我们可以将链表转换为“AVL树”或“红黑树”,从而将查询操作的时间复杂度优化至O(logn)。
开放寻址开放寻址(open addressing)不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
线性探测线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同:
插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则向冲突位置向后线性遍历(步长通常为1),直至找到空桶,将元素插入其中。
查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,知道找到对应元素,返回value即可;如果遇到空桶,说明目标不在哈希表中,返回None。
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的key都会被映射到相同的桶。而通过线性探测,它们被一次存储在该桶以及之下的桶中。
然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删改查操作效率劣化。
注意,我们不能再开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶None,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
为解决这个问题,我们可以采用懒删除(lazy deletion)机制:它不直接从哈希表中移除元素,而是利用一个常量TOMBSTONE来标记这个桶。在该机制下,None和TOMBSTONE都代表空桶,都可以放置键值对。但不同的是,线性探测到TOMBSTONE时应该继续遍历,因为其之下还可能存在键值对。
懒删除可能会加速哈希表的性能退化,每次删除操作都会产生一个删除标记,随着TOMBSTONE的增加,搜索时间也会增加,因为线性探测可能需要跳过多个TOMBSTONE才能找到目标元素。
为此,考虑在线性探测中(即在添加元素或查找元素时)记录遇到的首个TOMBSTONE的索引,并将搜索到的目标元素与该TOMBSTONE交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至理想位置(探测起始点)更近的桶,从而优化查询效率。可以将其封装为一个函数供添加和查找使用:
新增时,假设键值不存在与原哈希表中,程序会走完while循环(线性探测过程),将index循环累加直到数组中为空一个位置上,供添加使用,如果过程中出现了删除标记,则最终直接返回这个标记供添加使用,提高利用率。
/*搜索key对应的桶索引*/ int findBucket(int key){ int index = hashFunc(key); int firstTompStone = -1; Pair *pair; while (buckets[index] != nullptr){ pair = buckets[index]; if (key == pair->key){ // 若之前存在删除标记 if (firstTompStone != -1){ buckets[firstTompStone] = buckets[index]; buckets[index] = TOMBSTONE; return firstTompStone; } return index; } if (firstTompStone == -1 && pair == TOMBSTONE){ firstTompStone = index; } // 使其循环遍历 形成环形数组 index = (index + 1) % capacity; } // key不存在时,返回哈希函数值最近的空桶(供添加元素使用) return firstTompStone == -1 ? index : firstTompStone; }以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
#include "iostream" #include "vector" #include "string" using namespace std; /*键值对*/ struct Pair{ int key; string val; Pair(int key, string val){ this->key = key; this->val = val; } }; class HashMapOpenAddressing{ private: int capacity; // 哈希表容量 double loadThres; // 触发扩容的负载因子阈值 int extendRatio; // 扩容倍数 vector<Pair *> buckets; // 桶数组 Pair *TOMBSTONE = new Pair(-1, "-1");// 删除标记 public: int size; // 键值对数量 /*构造方法*/ HashMapOpenAddressing() :size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2){ buckets.resize(capacity); } /*析构方法*/ ~HashMapOpenAddressing(){ for (Pair* pair : buckets){ if (pair != nullptr && pair != TOMBSTONE){ delete pair; } } delete TOMBSTONE; } /*哈希函数*/ int hashFunc(int key){ int index = key % capacity; return index; } /*负载因子*/ double loadFactor(){ return (double)size / (double)capacity; } /*搜索key对应的桶索引*/ int findBucket(int key){ int index = hashFunc(key); int firstTompStone = -1; Pair *pair; while (buckets[index] != nullptr){ pair = buckets[index]; if (key == pair->key){ // 若之前存在删除标记 if (firstTompStone != -1){ buckets[firstTompStone] = buckets[index]; buckets[index] = TOMBSTONE; return firstTompStone; } return index; } if (firstTompStone == -1 && pair == TOMBSTONE){ firstTompStone = index; } // 使其循环遍历 形成环形数组 index = (index + 1) % capacity; } // key不存在时,返回哈希函数值最近的空桶(供添加元素使用) return firstTompStone == -1 ? index : firstTompStone; } /*查询操作*/ string get(int key){ int index = findBucket(key); if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) return (buckets[index]->val); return ""; } /*添加操作*/ void put(int key, string val){ if (loadFactor() > loadThres) extend(); int index = findBucket(key); // 找到已存在的key值 直接覆盖 if (buckets[index] != nullptr && buckets[index] != TOMBSTONE){ buckets[index]->val = val; return; } // 不存在则新增键值对 buckets[index] = new Pair(key, val); size++; } /*删除操作*/ void remove(int key){ int index = findBucket(key); // 找到键值对 if (buckets[index] != nullptr && buckets[index] != TOMBSTONE){ delete buckets[index]; buckets[index] = TOMBSTONE; size--; }else{ cout << "不存在该键值对" << endl; } } /*扩容哈希表*/ 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 && pair != TOMBSTONE) cout << pair->key << "->" << pair->val << endl; } } }; int main(){ HashMapOpenAddressing *OAmap = new HashMapOpenAddressing(); OAmap->put(123, "张三"); OAmap->put(154, "李四"); OAmap->put(198, "王麻子"); OAmap->put(244, "牛魔"); OAmap->put(388, "诗人"); OAmap->print(); cout << "====删除====" << endl; OAmap->remove(154); OAmap->print(); cout << "==========" << endl; cout << "大小:" << OAmap->size << endl; cout << "====查询====" << endl; cout << "198 ->" << OAmap->get(198) << endl; system("pause"); return 0; } 平方探测平方探测与线性探测类似,都是以开放寻址的常见策略之一。当发生冲突时,平方探测不是简单跳过一个固定的步数,而是跳过探测次数的平方的步数,即1,4,9,...步。
平方探测主要具有以下优势:
平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应
平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀
缺点:
仍然存在聚集现象,即某些位置比其他位置更容易被占用
由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。
多次哈希顾名思义,多次哈希方法使用多个哈希函数𝑓1(𝑥)、𝑓2(𝑥)、𝑓3(𝑥)、… 进行探测。
插入元素:当哈希函数f1(x)出现冲突,则尝试f2(x),以此类推,直到出现空位后插入元素
查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回None。
与线性探测相比,多次哈希不易产生聚集,但多个哈希函数会带来额外的计算量。
注意:开放寻址(线性探测、平方探测和多次哈希)哈希表都不能直接删除元素。
不同编程语言的实现各种编程语言采取了不同的哈希表实现策略,下面举几个例子:
Python 采用了开放寻址,字典dict使用为随机数进行探测
Java 采用链式地址,字JDK1.8以来,当HashMap内数组长度达到64且链表长度达到8时,链表会转换为红黑树以提升查找性能。
Go 采用链式地址。 Go 规定每个桶最多存储8个键值对,超出容量则连接一个溢出桶;当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。