我不知道的hash表

前言

为何要写散列表(Hash Table)?!由于忘记,就算之前在大学的时候学得多好,分数考多高。成了前端狗以后一方面是本身对本身不够严格,另外一方面工做方面也是用得少这方面的知识,平时大多都是一群朋友吹牛皮的时候,亮出来吓吓人(也只能说本身记得的部分)。当夜深人静时都会想一想本身吹过的牛皮~感叹这些知识本身真的忘得差很少了,三分钟热度来写个文章温故知新!前端

正文

什么是散列表

散列表(Hash Table),用一个映射函数将数据的特征值与数据保存表的存储位置创建联系,以便在寻找数据的时候能够快速定位,这样的表成为散列表(Hash Table),上述映射函数成为hash函数。java

根据设定的哈希函数和冲突处理方法将一组关键字映射到一个有限连续的地址集上,并以关键字映射获得的值做为关键字对应数据在表中的存储位置,这种称为哈希表。数组

一个通俗的例子是,为了查找电话簿中某人的号码,能够建立一个按照人名首字母顺序排列的表(即创建人名 x 到首字母 F(x) 的一个函数关系),在首字母为W的表中查找“王”姓的电话号码,显然比直接查找就要快得多。这里使用人名做为关键字,“取首字母”是这个例子中散列函数的函数法则 F(),存放首字母的表对应散列表。关键字和函数法则理论上能够任意肯定。(摘自散列表维基百科bash

为何须要散列表

通常来讲hash表的数据会用一个一维数组存储。而在低级语言中使用数组是须要预先申请数组的长度,便是内存大小。而在实际应用中,很难肯定最后应该申请多大的内存,过大浪费资源,太小则会形成内存溢出。在溢出前你就须要扩容,扩容的时候又涉及到内存拷贝的问题,由于数组是一组连续的地址集,你难保在这段地址集后面的地址没有保存另外的数据。另外,单纯用数组存储数据,存储的时候很爽,可是在寻找的时候就一身骚了~毕竟在数组必定规模的时候你遍历一次或许就要作成千上万次的对比数据,这仍是单纯的一维数组而已,若是是个矩阵呢?复杂度是否是就蹭蹭往上涨!函数

如何构建散列表

哈希函数

如上文所述,哈希函数就是用来肯定数据特征值与数据在哈希表存储位置的映射关系。ui

对不一样的关键字可能获得同一散列地址,即k1≠k2,而f(k1)=f(k2),这种现象称为冲突。具备相同函数值的关键字对该散列函数来讲称作同义词。this

经常使用的几个用于构建哈希函数的方法以下:spa

  • 直接定址法:使用一个线性函数做为映射函数;
  • 除留余数法:经过模 一个小于或等于散列表长度的值,取其他数做为散列表的存储位置;
  • 数字分析法:肉眼观测,剔除显而易见的噪点;
  • 平方中值法:将数据特征(通常是天然数)平方后取中间几位数,取几位取决于表的大小;
  • 折叠法:将数据特征值分隔成为数相同的几部分,而后取叠加和(舍去高位);
  • 伪随机数法;

以上每一个方法不一一阐述,我只挑出“除留余数法”浅析。一是它经常使用;二是我只想说它。指针

除留余数法的数学表达式:code

 

hash( key ) = key mod p ( p ≤ m )
key: 数据特征值; m: 散列表的长度;

 

下面用一例子对上述数学表达式进行实践(注意,一下例子只是对除留余数法的实践,并未彻底达到理想的映射关系,仅仅是基本的关系):

假设现有一组数据

const names = ['Issac', 'Fanck', 'Sonia', 'Gary', 'Rick', 'Ryron', 'Emma'];
复制代码

取以上人名的首字母的 ASCII码 做为数据的特征值。

const nameKeys = names.map((name) => {
	const initial = name.slice(0, 1);
	const key = initial.charCodeAt();
	return key;
});

// print: [73, 70, 83, 71, 82, 82, 69]
复制代码

而后,取 p = 7对p的选择很重要,通常取素数(质数)或m,若p选择很差,容易产生冲突(哈希冲突)。

const p = 7;
const indexs = nameKeys.map((nameKey) => {
    const index = nameKey % p;
    return index;
});

// print: [3, 0, 6, 1, 5, 5, 6]
复制代码

根据 namesindexs 的值:

['Issac', 'Fanck', 'Sonia', 'Gary', 'Rick', 'Ryron', 'Emma']
[      3,       0,       6,      1,      5,       5,      6]
复制代码

能够获得一个基本的关系表:

0 1 2 3 4 5 6
Fanck Gary - Issac - Rick Sonia
- - - - - Ryron Emma

由表中的数据你能够看见三、五、6这三个位置都有一个以上的name映射到了同一个存储位置上,显然一个位置是不能存放多个数据,除非使用别的储存方式。该当如何下节分解!

哈希冲突(碰撞)

前一小节中出现“多个name映射到了同一个存储位置”的状况就是典型的哈希冲突。 出现哈希冲突,一是更换其余的哈希函数,二是对哈希函数的结果进行处理。要知道在数据足够大后不管怎么更换哈希函数都没法避免哈希冲突,只能尽可能减少出现哈希冲突的概率。

经常使用方法有如下几种:

  • 开放定址法;
  • 单独链表法;
  • 再散列;
  • 双散列;

如下浅析前两种方法,缘由同上。

 

开放定址法

开放定址法的数学表达式:

 

Hash = (hash(key) + d i) mod m, i=1,2,3...k (k <= m-1)
m为散列表长,di为增量序列(函数),i为已发生冲突的次数

 

增量序列的构建方法有如下几种:

  • 线性探测:d(i) = i;
  • 平方探测:d(i) = i^2;
  • 伪随机探测;

下面用“线性探测”编写例子。

 

回顾哈希函数获得的映射结果:

['Issac', 'Fanck', 'Sonia', 'Gary', 'Rick', 'Ryron', 'Emma']
[      3,       0,       6,      1,      5,       5,      6]
复制代码

取哈希表的长度m = p = 7

映射 Ryron 时出现冲突

# i = 1, d(1) = 1,hash(key) = 5
(5 + 1) mod 7 = 6   # 冲突

# i = 2, d(2) = 2,hash(key) = 5
(5 + 2) mod 7 = 0   # 冲突

// ...

# i = 4, d(4) = 4,hash(key) = 5
(5 + 4) mod 7 = 2   # 命中
复制代码
0 1 2 3 4 5 6
Fanck Gary Ryron Issac - Rick Sonia

映射 Emma 时继续出现冲突

# i = 1, d(1) = 1,hash(key) = 6
(6 + 1) mod 7 = 0   # 冲突

# ...

# i = 5, d(5) = 5,hash(key) = 6
(6 + 5) mod 7 = 4   # 命中
复制代码
0 1 2 3 4 5 6
Fanck Gary Ryron Issac Emma Rick Sonia

到此,冲突处理完毕,散列表构建完成!咱们能够试着在散列表中查找数据。

假设如今要查找 IssacRyron:

# Issac
# key('I') = 73, Hash(key) = 3
hashTable(3)  # output 'Issac' => Hit


# Ryron
# key('R') = 82, Hash(key) = 5
hashTable(3)  # output 'Rick' => Miss

# d(1) = 1, Hash(key) = 6
hashTable(6)  # output 'Sonia' => Miss

# ...

# d(4) = 4, Hash(key) = 2
hashTable(2)  # output 'Ryron' => Hit
复制代码

 

单独链表法

使用单链表做为存储数据的方式,言外之意,hash(key)获得的在哈希表的存储位置再也不是数据的存储位置,而是用于存储数据所用链表的地址。

单链表(LinkList)形如:

class LinkList() {
    constructor(_data, _next) {
        this.head = new Entry();
    }
    
    insert(_data) {
        // ...
    }
    
    find(_data) {
        // ...
    }
    
    // ...
}
复制代码

链表的每一个节点形如:

class Entry {
    constructor(_data, _next) {
        this.data = _data;
        this.next = _next;
    }
}
复制代码

回顾哈希函数获得的映射结果:

['Issac', 'Fanck', 'Sonia', 'Gary', 'Rick', 'Ryron', 'Emma']
[      3,       0,       6,      1,      5,       5,      6]
复制代码
let key;
let index;
const hashTable = new Array(7);

// insert 'Issac'
key = getKey('Issac');    // 73
index = hash(key);    // 3, 命中
hashTable(index) = new LinkList();
hashTable(index).insert('Issac');

// insert 'Fanck'
key = getKey('Fanck');    // 70
index = hash(key);    // 0, 命中
hashTable(index) = new LinkList();
hashTable(index).insert('Fanck');

// insert 'Sonia'
key = getKey('Sonia');    // 83
index = hash(key);    // 6, 命中
hashTable(index) = new LinkList();
hashTable(index).insert('Sonia');

// insert 'Gary'
key = getKey('Gary');    // 71
index = hash(key);    // 1, 命中
hashTable(index) = new LinkList();
hashTable(index).insert('Gary');

// insert 'Rick'
key = getKey('Rick');    // 82
index = hash(key);    // 5, 命中
hashTable(index) = new LinkList();
hashTable(index).insert('Rick');

// insert 'Ryron'
key = getKey('Ryron');    // 82
index = hash(key);    // 5, 冲突
hashTable(index).insert('Ryron');

// insert 'Emma'
key = getKey('Emma');    // 69
index = hash(key);    // 6, 冲突
hashTable(index).insert('Emma');
复制代码

平均查找长度

为何要求取“平均查找长度”?从上文中你能够发现,因为哈希冲突的出现,用数据特征并不能直接获取数据的储存位置,是须要进行一次以上的对比才能找到目标数据,也可能数据并不存在于哈希表中,即查找不成功。所以,就须要一个量度去衡量查找效率,即平均查找长度。

平均查找长度分为下面两类:

  • 成功的平均查找长度:
ASL = (d 0 + d 1 + d 2 + ... + d i) / n, (i = 0, 1, 2 ... n)
ASL: 平均查找长度, n: 哈希表的元素个数
di: 第 i 个元素查找成功所须要的查找(比对)次数
  • 不成功的平均查找长度:
ASL = (d 0 + d 1 + d 2 + ... + d i) / n, (i = 0, 1, 2 ... l)
l: 哈希表的长度, n: 哈希表的元素个数, ASL: 平均查找长度
di: 在第 i 个存储位置开始查找直到肯定不存在所须要的查找(比对)次数

下面分别计算前一小节中哈希表的成功和不成功的平均查找长度。

# 成功的平均查找长度
Frank:1
Gary: 1
Issac: 1
Rick: 1
Ryron: 2
Sonia: 1
Emma: 2

ASL = (1 + 1 + 1 + 1 + 2 + 1 + 2) / 7 ≈ 1.2857
复制代码

对于不成功的平均查找长度数学代数式中di有必要再细说一下。 好比如今要再上表中查找Fiona,这是不存在中的。

hash('F')  # output 0
复制代码

而后,就是在 hashTable(0) 存储的单链表中查找Fiona

# 第一次查找, 第一个节点的data存的是Frank,不匹配!
# 而且第一个的指针域为空指针(没有指向下一个节点),这样就成功肯定Fiona不在表中,即查找失败!
结果是hash(key)等于0的查找失败长度为1
复制代码

由上面的例子就能够知道:

# 不成功的平均查找长度
hash(key) => 0: 1
hash(key) => 1: 1
hash(key) => 2: 0
hash(key) => 3: 1
hash(key) => 4: 0
hash(key) => 5: 2
hash(key) => 6: 2

ASL = (1 + 1 + 0 + 1 + 0 + 2 + 2) / 7 = 1
复制代码

 

载荷(装填)因子

载荷因子指的是哈希表的装满程度,具体计算式是:

 

α = 表中元素个数 / 哈希表长度

 

从公式中你能够直观地感觉到,α 越大下次存入新元素的时候发生冲突的概率越大,由于表中空缺的位置已经很小了! 发生冲突的次数越多,也就是意味着查找长度就可能越大!想一想哈希表的存在乎义:为了方便定位目标查找元素!查找长度足够大时哈希表就不在方便了,也就失去存在乎义。而α就能够直观地指明当前哈希表已经不够方便了,那么这个时候就须要对哈希表进行扩容!没错,它就是标示什么时候应该扩容,好比java中的hashMap则是α > 0.75 时后对当前哈希表进行扩容!

最后写两句

上文其实已经很早就写好,剩下本身定的两个问题原本想浅析一下,可是因为自身缘由一拖再拖,也只能先在此留个坑,但愿本身有生之年能够回来填坑(滑稽)

  • 如何判断一个哈希表的好坏?
  • 为何除留余数发中p通常取质数?

但愿本身一如既往能够对未知的知识保有好奇心~

相关文章
相关标签/搜索