哈希表核心原理
首先要明确的是,哈希表和java里的Map键值对不是一个东西,Map是系列接口,哈希表只是利用其数据结构的特点实现了一种Map,除此之外还有TreeMap等。主要是为了解释,不要听见Map就认为他的查询是O(1),这个得具体看是什么map的实现。
哈希表的基本原理
哈希表可以理解为一个加强版数组。
数组可以通过索引在O(1)的时间复杂度下查找对应的元素,索引是一个非负整数。
哈希表示类似的,可以通过key在O(1)的时间下查找到对应的value。key的类型可以是数字、自负转等多种类型。
hashmap的底层数据结构就是一个数组我们称之为table,首先吧key通过一个哈希函数转化成数组里面的索引,然后增删查改操作基本和数组相同
class MyHashMap{
private:
vector<void*> table;
public:
void put(auto key,auto value){
int index = hash(key);
table[index] = value;
}
auto get(auto key) {
int index = hash(key);
return table[index];
}
// 删,复杂度 O(1)
void remove(auto key) {
int index = hash(key);
table[index] = nullptr;
}
private:
// 哈希函数,把 key 转化成 table 中的合法索引
// 时间复杂度必须是 O(1),才能保证上述方法的复杂度都是 O(1)
int hash(auto key) {
// ...
}
};
哈希函数
这个函数是用于将任意长度的key转换为固定长度的索引。
整个哈希表其中一个步骤就是哈希函数,如果哈希函数没有设计好,就会导致整个哈希表的效果都变差,比如,如果哈希函数的时间复杂度是O(n),就算哈希表查询理论上是O(1),但由于需要加入哈希函数,就会导致哈希表复杂度变O(n)。所以要保证哈希函数复杂度要小,并且要保证同一个值经过哈希函数处理之后索引的值是唯一的,不能出现同一个a被哈希函数处理之后两次结果不一样。
哈希函数的实现方式有很多,这边介绍一下java里的默认的一种方式。任意java对象都会有一个int hashcode()函数,如果不重写这个方法的话,这个就会默认返回这个对象的内存地址,这个是唯一的,所以如果我们将key的值用hashcode处理之后就可以将它变成一个唯一的整数。
确保索引合法
hashcode方法返回的是int,所以第一问题就是万一返回的是负数会出现很大的问题。如果直接进行取反的操作,会出现另一个问题,因为在二进制里,32位比特位中,最左边的一位是符号位,当符号位为1时为负数,为0时为正数,所以最小可以到达-2^31,但最大时只能到2^31-1 。所以直接取反可能会导致越界问题。于是我们就直接进行与运算。
int h = hashcode();
h = h&0x7fffffff; , h和除了最左边的一位以外都是1的相与,这样h最左边的一位不管怎么样都会变成0。
之后就是要解决如何将索引映射到table里的合法索引,这个时候就可以通过循环数组里的技巧来操作,
int hash(K key){
int h = key.hashcode();
h = h & 0x7ffffff;
return h % table.length;
}
哈希冲突
从刚才说的索引合法性里面的“如何将索引映射到table里的合法索引“这个方法不难看出,我们的索引是容易重复的。比如一个数组里有五个位置,0,1,2,3,4,5.那么如果想将无限可能的数字映射到这五个数字上,我们用上述提到的方法来映射时,就会出现这种情况:我需要映射的值为0,5,10等值时,都会存放在同一个位置a[0]上,这就叫哈希冲突。
哈希冲突是无法避免的,只能通过算法层面尽量规避,所以说hash函数的选取也是减少哈希冲突的方式之一。
那一般解决方式分两种,一是拉链法,二是线性探查法(开放寻址法)。
拉链法
其实就是将冲突的地方纵向延伸,当出现了哈希冲突的时候,多个key映射到同一个数组的位置上时,将多个key连成一个链表来存储。
线性探查法
而线性探查法的思路是,一个 key 发现算出来的 index 值已经被别的 key 占了,那么它就去 index + 1 的位置看看,如果还是被占了,就继续往后找,直到找到一个空的位置为止。
扩容和负载因子
从上面的两个方法可以看出,如果我们采用了这两种方法后,每次inde = hashcode(key)之后,你发现这个value不是你的值时,你不能确定真的没有这个值,你得去遍历这个点的列表(拉链法)或者是遍历这个数组(线性探查法)去确保这个值没有。那这个时候你就需要O(K)的时间复杂度,K为连续探查的次数或者是链表的长度。
所以说如果频繁出现hash冲突,那就会导致k的次数变大,哈希表的效率也会降低。哈希冲突主要由以下两点导致
1、哈希函数设计有问题,导致key的分布不均匀,很多key投射到同一个索引上
2、哈希表里面已经装了太多的key-value对,这种情况下哈希函数再好也会导致冲突
1的话直接调用语言自带的哈希函数就可以了,2的话就要通过负载因子来进行扩容了。
负载因子
负载因子是一个哈希表装满的程度。一般来说负载因子越大说明他的哈希表里面的key-value越多,哈希冲突的可能越大,哈希表的效果就越差。
负载因子计算公式也简单,size/table.length size是哈希表里的key-value对数量,table.length是哈希表底层数组的容量。
我们可以看出来,拉链法实现的哈希表的负载因子可以无限大,因为他的size可以无限延长,而线性探查法的负载因子一般不会超过1,因为他的size最多跟数组长度一样。像 Java 的 HashMap,允许我们创建哈希表时自定义负载因子,不设置的话默认是 0.75,这个值是经验值,一般保持默认就行了。(后续根据进一步学习发现,数组里的键值对如果超过table的size * 0.75就需要扩容,而小于table的size * 0.125时就要缩容)
当哈希表内元素达到负载因子时,哈希表会扩容。和之前的 [动态数组的实现]是类似的,就是把哈希表底层 table 数组的容量扩大,把数据搬移到新的大数组中。size 不变,table.length 增加,负载因子就减小了。
为什么不能依赖哈希表的遍历
首先我们能看出来,根据前面的知识key是通过hash函数获取index,也就是数组下标,从而知道访问对应值的,那就不可能是顺序存储这个key-value。
其次我们刚才知道了,当哈希表达到负载因子就会出发扩容,那扩容之后就会创建一个新的数组,然后把旧的数组的key重新计算然后把对应的value放进新的index对应的位置里面,那么顺序又一次被打破。
为什么不建议for循环中增删哈希表的key
如果在遍历的过程中还往里面进行插入数据的话,那出现的问题就更多了,如果这个哈希表在增删的时候可能会扩/缩容,那就会导致整个table数组都变了,那你的整个key的顺序又再次改变了,那你后面要遍历的可能是你之前遍历过的。比如我一开始已经经过了k节点它的index是4,但是这个时候我往里面增加数据了,触发扩容。新的table的k节点的index在5,那我不就是又一次遍历他,那以前在5的又不知道去哪了,也有可能跑到1,那我就跳过了这个节点。
key必须是不可变的
所谓不可变类型,就是说这个对象一旦创建,它的值就不能再改变了。比如 Java 中的 String, Integer 等类型,一旦创建了这些对象,你就只能读取它的值,而不能再修改它的值了。
作为对比,Java 中的 ArrayList、LinkedList 这些对象,它们创建出来之后,可以往里面随意增删元素,所以它们是可变类型。
因此,你可以把 String 对象作为哈希表的 key,但不能把 ArrayList 对象作为哈希表的 key
以下是将arrylist当作哈希表的对象的后果:
public int hashCode() {
int h = 0;
for (int i = 0; i < elementData.length; i++) {
h = 31 * h + elementData[i];
}
}
第一个就是效率问题,每次计算 hashCode 都要遍历整个数组,复杂度是 O(N),这样就会导致哈希表的增删查改操作的复杂度退化成 O(N)。
更严重的问题是,ArrayList 的 hashCode 是根据它里面的元素计算出来的,如果你往这个 ArrayList 里面增删元素,或者其中某个元素的 hashCode 值发生改变,那么这个 ArrayList 的 hashCode 返回值也会发生改变。
比方说,你现在用一个 ArrayList 类型的 arr 变量作为哈希表的 key 在哈希表中保存了对应的 value。但如果 arr 中的某个元素在程序的其他位置被修改了,那么 arr 的 hashCode 就会变化。此时你再用这个 arr 变量去哈希表中查询,发现找不到任何值了。
也就是说,你存入哈希表的 key-value 意外丢失了,这是非常非常严重的 bug,还会带来潜在的内存泄漏问题。
public class Test {
public static void main(String[] args) {
// 错误示例
// 把可变类型作为 HashMap 的 key
Map<ArrayList<Integer>, Integer> map = new HashMap<>();
ArrayList<Integer> arr = new ArrayList<>();
arr.add(1);
arr.add(2);
map.put(arr, 999);
System.out.println(map.containsKey(arr)); // true
System.out.println(map.get(arr)); // 999
arr.add(3);
// 出现严重 bug,键值对丢失
System.out.println(map.containsKey(arr)); // false
System.out.println(map.get(arr)); // null
// 此时 map 底层的 table 中,arr 的键值对数据依然存在
// 但是由于 arr 的 hashCode 改变了,此键值对无法被查找到
// 这也会导致内存泄漏,因为这个 arr 变量被 map 引用着,无法被垃圾回收
}
}
拉链法实现哈希表
上面提到过,解决哈希冲突的实现哈希表的方式有两种,一种是拉链法,一种是线性探查法。这里就介绍如何用拉链法实现哈希表。
其实拉链法就是将哈希表底层的table里的每个成员设置成一个链表。如果出现了哈希冲突,就将映射到同一个key的内容放到这个链表里,从而解决哈希冲突。
实现过程
template<typename K,typename V>;
class MychainHashMap{
struct KVNode{
K key;
V value;
KVNode(K key, V val):key(key),value(val);
};
vector<list<KVNode>> table;
int size_; //table里面键值对的个数
static constexpr int INIT_CAP = 4; //初始table的大小
// 哈希函数,将键映射到 table 的索引
int hash(K key) {
return (hash<K>{}(key) & 0x7fffffff) % table.size();
}
void resize(int newcap){
newCap = max(newCap,1); //防止newcap为0,出现取空模的情况。
MychainHashMap<K,V> newMap(newcap);
for(auto &list : table){
for(auto &node : list){
newMap.put(node.key,node.value); //将旧的map里的元素放入新的map里面
}
}
this->table = newMap.table;
}
public:
MyChainHashMap(): MyChainHashMap(INIT_CAP){
}
/*explicit是用于让c++避免使用隐式转换的,因为隐式转换是可以让,
MyChainingHashMap map = 10成立,这样看着很奇怪,因为map不是int类型为啥可以等于10,实际上
他的意思是map =MyChainingHashMap(10),等于是调用了构造函数。这个禁止隐式转换
就必须要求写出构造函数才可以,避免出现误解。 */
explicit MyChainingHashMap(int initCapacity) {
size_ = 0;
// 保证底层数组的容量至少为 1,因为 hash 函数中有求余运算,避免出现除以 0 的情况
initCapacity = max(initCapacity, 1);
table.resize(initCapacity);
}
//增/改
void put(K key,V val){
auto &list = table[hash(key)];
// 如果 key 之前存在,则修改对应的 val
for (auto &node: list) {
if (node.key == key) {
node.value = val;
return;
}
}
// 如果 key 之前不存在,则插入,size 增加
list.emplace_back(key, val);
size_++;
// 如果元素数量超过了负载因子,进行扩容
if (size_ >= table.size() * 0.75) {
resize(table.size() * 2);
}
}
//删
void remove(K key){
auto &list = table[hash(key)];
for(auto it = list.begin();it != list.end; ++it){
if(it->key = key){
list.erase(it);
size_--;
}
if(size_<= table.size/8){
resize(table.size/4);
}
}
}
//查 ,如果有key对应的value,就返回value,没有就返回nullptr
V get(K key){
auto &list = table[hash(key)];
for(auto &node : list){
if(node.key == key){
return node.value;
}
}
return;
}
list<K> keys(){
list<K> keys;
for(const auto &list : table){
for(const auto &node : list){
keys.push_back(node.key);
}
}
return keys;
}
int size() const {
return size_;
}
};