第七章 字典与哈希
作者: Loiane Groner
如之前所学,集合是用于存储一些列不重复的值。字典是用于存储 键值对的,其中键是用于查找特定成员的索引。字典和集合是非常相似的:集合以[key,key]的形式存储成员;字典以[key,value]的形式存储成员。字典也被称为图。
在这一章,我们将会讲到字典结构在实现生活中的例子:字典本身和通讯录。
字典
让我们来生成一个字典结构:
function Dictionary(){
var items ={};
}
与集合结构类似,我们将使用对象而非数组来存储相关实例。
接下来,我们要声明字典结构的常用方法:
- set(key,value): 为字典新增一个成员
- remove(key): 从字典删除一个成员
- has(key): 如果该键存在于字典,函数会返回true;反之则为false
- get(key): 返回该键所对应的值
- clear( ): 清空字典的成员
- size( ): 返回字典成员的个数。这个方法与数组的length属性相似
- keys( ): 返回一个包含字典的所有的键的字典
- values( ): 返回一个包含字典所有值的数组
has和set方法
第一个要实现的是has(key)方法。我们第一个实现它,是因为它会被用在后面的set和remove方法中。我们来看看下面的代码:
this.has = function(key){
return key in items;
}
其实集合结构的has方法和字典结构的has方法是一样的——用JavaScript中对象的in操作符来验证该键是否存在于对象中。
下一个是set方法:
this.set = function(key, value){
items[key] =value; // {1}
};
这个方法将键和值作为函数的参数传入。之后让键与值关联上即可。这个方法可以用于增加新成员,或者更新已经存在的成员。
remove方法
接下来,我们要实现的是remove方法。这个方法和集合结构的remove方法也是很像的:
this.remove = function(key){
if (this.has[key]){
delete items[key];
return true;
}
return false;
}
在验证过items是否有参数key后,再使用delete操作符删除即可。
get方法 和 values方法
如果我们要从字典中找到一个特定的成员,我们可以使用下面的方法:
this.get = function(key){
return this.has(key) ? item[key] : undefined;
};
在get方法中,我们首先要验证字典中是否有这个键;如果有,返回该键所对应的值;如果没有,则返回undefined.
接下来要实现的是values方法,这个方法会返回一个包含字典所有值的数组:
this.values = function(){
var values = [];
for ( var k in items){ // {1}
if (this.has(key)){
values.push(items[k]); //{2}
}
}
return values;
};
首先,我们要遍历items对象的所有属性***(行 {1})。之后我们要用has方法来这个属性是否存在于items对象中,如果存在,则将之加入到values数组中(行 {2})***。最后,我们只要返回这个数组即可。
Note
我们不能只使用for-in语句来遍历items对象的所有属性。我们还需要使用has方法(去验证items对象是否有这个属性)。因为对象的原型还包含继承自Object基类的属性。
clear,size,keys和getItems方法
在字典结构中,clear,size,keys方法和集合结构一模一样,因此,本章节就不讲这几个方法了。
最后,为了可以检查items的值,我们要实现getItems方法:
this.getItems = function( ){
return items;
}
使用字典结构
首先,我们来产生一个字典的实例,之后我们为之增加三个电子邮箱地址。我们将使用字典实例来模拟邮箱地址通讯录。
让我们来看看以下代码:
var dictionary = new Dictionary();
dictionary.set(‘Gandalf’,’gandalf@email.com’);
dictionary.set(‘John’,’johnsnow@email.com’);
dictionary.set(‘Tyrion’,’tryion@email.com’);
如果上述代码执行成功,下面代码的输出结果为真:
console.log(dictionary.has(‘Gandalf’));
现在,让我们执行以下代码:
console.log(dictionary.keys());
console.log(dictionary.values());
console.log(dictionary.get(“Tyrion”));
以上代码的输出结果如下:
[“Gandalf”, ”John”, “Tyrion”]
[’gandalf@email.com’, ’johnsnow@email.com’, ’tryion@email.com’]
tryion@email.com
最后,让我们执行以下代码:
dictionary.remove(‘John’);
之后执行以下代码:
console.log(dictionary.keys( )); console.log(dictionary.values( )); console.log(dictionary.getItems( ));
以上的代码的输出如下:
['Gandalf', 'Tyrion']
['gandalf@email.com','tryion@email.com']
Object {Gandalf: 'gandalf@email.com',Tyrion: 'tryion@email.com '}
因为我们删了一个成员,所以字典现在只有两个成员。粗体字则是说明了items对象的内部结构。
哈希表
在这一部分,我们要学习哈希表。
使用哈希进行搜索,意味着该方法能够在最短的时间找到想要的值。我们在前面的章节学过,如果我们想要从数据结构得到特定的值,我们要遍历里面的成员直到我们找到为止。但当我使用哈希表获取成员时,我们已经知道该成员的位置了,所有我们可以快速得到它。哈希函数的作用是,它能够把给定的key转化为该key所对应的值在哈希表中的地址。
以前面讲到的电子邮箱通讯录为例,我们可以使用最简单的“lose lose”哈希函数(讲所有字符的ASCII码相加而得到地址)来给每一个key设定位置。
生成一个哈希表
我们将使用数组来接收哈希表的成员。
让我们来看看其代码实现:
function HashTable(){
var table = [];
}
之后,我们要为哈希表添加一些基本方法:
- put(key,value):为哈希表添加一个新成员(或者更新一个成员)
- remove(key):删除使用该key的值
- get(key):获取使用该key的值
在构建这三个方法前,我们要构建一个内部的私有哈希方法:
var loseloseHashCode = function(key){
var hash = 0; //{1}
for (var I = 0; i<key.length; i++){ //{2}
hash += key.charCodeAt(i); //{3}
}
return hash%37; //{4}
}
得到了给定的key参数后,我们会遍历参数里的每一个字母,并将他们的ASCII码相加。所以,我们首先需要一个sum变量来存储ASCII码相加的和***(行 {1})。之后,我们会遍历key参数的每一个字母(行 {2})然后把每个字母相应的ASCII码加到sum变量中(我们可以使用charCodeAt来得到字符串的ASCII码(行 {3})***)。最后,我们会返回这个哈希值。为了让哈希值不至于太大,我们会返回哈希值被随机数除后的余数。
Note
欲了解更多ASCII码的知识,可以访问 www.asciitable.com/
现在,我们有了哈希函数后,可以实现我们的put方法了:
this.put = function(key,value){
var position = loseloseHashCode(key); // {5}
console.log(position+’-‘+key); // {6}
table[position] = value; // {7}
};
首先,我们用哈希函数来产生新值的位置***(行 {5})。我们可以使用console.log函数来在控制台查看位置的具体值(行 {6})。当然,我们也可以删除这一行的代码,之后我们将位置和值的信息在数组中关联起来即可(行 {7})***。
要获取哈希表中的值也是很简单的。以下为get方法:
this.get = function(key){
return table[loseloseHashCode(key)];
}
而最后要实现的,便是remove方法:
this.remove = function(key){
table[loseloseHashTable] = undefined;
}
要从哈希表删除一个值,我们只要访问相应位置的成员,再将其指向改为undefined即可。
使用哈希表
让我们测试一下我们的代码:
var hash = new HashTable( );
hash.put(‘Gandalf’,’gandalf@email.com’);
hash.put(‘John’,’johnsnow@email.com’);
hash.put(‘Tyrion’,’tyrion@email.com’);
让我们看看上面代码的执行结果:
19 – Gandalf
29 – John
16 – Tyrion
下图展示了哈希表搜索相应值的过程:
现在,让我们使用get方法:
console.log(hash.get(‘Gandalf’));
console.log(hash.get(‘Loiane’));
代码的输出结果为以下:
gandalf@email.com
undefined
因为Gandalf键存在于HashTable中,所以get方法可以返回相应的值。同理,Loiane并不存在于HashTable,get方法的返回值为undefined。
接下来,让我们从HashTable中删除Gandalf:
hash.remove(‘Gandalf’);
console.log(‘hash.get(‘Gandalf’)’);
这段代码在控制台的输出结果为undefined,因为Gandalf键已经不存在于哈希表中了。
处理哈希值相同问题
有时候,不同的key会有相同的哈希值。我们称呼这种情况为冲突,因为我们要在哈希表同一个位置赋不同的值。让我们看看下面的代码:
var hash = new HashTable();
hash.put(‘Gandalf’,’’gandalf@email.com’);
hash.put(‘John’,’’johnsnow@email.com’);
hash.put(‘Tyrion’,’’tyrion@email.com’);
hash.put(‘Aaron’,’’aaron@email.com’);
hash.put(‘Donnie’,’’donnie@email.com’);
hash.put(‘Ana’,’’ana@email.com’); hash.put(‘Jonathanf’,’’jonathan@email.com’);
hash.put(‘Jamie’,’’jamie@email.com’);
hash.put(‘sue’,’’sue@email.com’);
hash.put(‘Mindy’,’’mindy@email.com’);
hash.put(‘Paul’,’’paul@email.com’);
hash.put(‘Nathan’,’’nathan@email.com’);
以下为代码的执行结果
19 – Gandalf
29 – John
16 – Tyrion
16 – Aaron
13 – Donnie
13 – Ana
5 – Jonathan
5 – Jamie
5 – Sue
32 – Mindy
32 –Paul
10 –Nathan
注意,Tyrion和Aarion有相同的哈希值。Donnie和Ana有相同的哈希值。Jonathan、Jamie和Sue有相同的哈希值。Mindy和Paul有相同的哈希值。
那么,在这个过程中,哈希表的实例内部到底发生了什么呢?
为了帮助我们理解其内部变化,我们需要一个帮手函数print来观察内部变化:
this.print = function( ){
for (var i = 0; i < table.length; i++){ // {1}
if (table[i] !== undefined) { // {2}
console.log( i + “:” + table[i]); // {3}
}
}
}
首先,我们要遍历数组的所有成员(行 {1})。因为i指向的位置已经有值了(行 {2}),我们可以在控制它打印位置与相应的值(行 {3})。
接下来,我们使用一下这个方法:
has.print();
我们可以在控制它得到以下结果:
5:sue@email.com
10: nathan@email.com
13: ana@email.com
16: aaron@email.com
19:gandalf@email.com
29: johnsnow@email.com
32: paul@email.com
Jonathan,Jamie和Sue有相同的哈希值,即5。因为Sue是最后被加到哈希表的,Sue会占据哈希表地位置5的值,首先,Jonathan会占据位置5,之后Jamie会覆盖Jonathan,之后Sue会覆盖Jonathan。这样的场景,会发生在其他的哈希冲突情形下。
我们会使用数据结构来存储数据,就是不希望这些数据丢失。因此,我们要处理这些冲突情形。有一些技巧可以处理这种冲突:separate chaining,linear probing,double hashing。本书会讲到前两个技巧。
Separate chaining
Separate chaining是由一系列的存在每个值的链表组成的。这是处理链表最简单的方法了;然而,这会用到哈希表以外的内存空间。
例如,如果我们用separate chaining来存储之前的邮箱地址,其输图示结构如下:
在位置5,我们会有一个由三个成员组成的链表;在位置13、16和32,我们都会看到一个由两个成员组成的链表;而在位置10、19和29,我们会看到一个只有一个成员的链表。
为了帮助我们更好的理解separate chaining,我们将会使用一个帮手函数,来在控制台展示链表实例,我们称呼这个函数为ValuePair:
var ValuePair = function(key, value){
this.key = key;
this.value = value;
this.toString = function( ){
return '['+ this.key + '-' + this.value + ']';
}
}
这个函数是用来存储实例的键和值的。
put方法
我们一起来实现put方法:
this.put = function( key, value) {
var position = loseloseHashCode(key);
if(table[position] == undefined){ // {1}
table[position] = newLinkedList( );
}
table[position].append(new Value(key,value)); // {2}
};
在这个方法中,我们首先要确认这个位置是否为undefined***(行 {1})。如果是我们第一次在这个位置添加成员,我们将会生成一个链表。之后,我们会用链表的append方法将ValuePair的值添加到链表中(行 {2})***。
get方法
接下来,我们要实现的是get方法:
this.get = function(key){
var positon = loseloseHashCode(key);
if (table[position] !== undefined){ // {3}
//遍历链表,直至发现制定的键/值
var current = table[position].get( ); // {4}
}
while(current.next){ // {5}
if (current.element.key === key){ // {6}
return current.element.value; // {7}
}
current = current.next; //{8}
}
// 检查链表的第一个、最后一个成员
if (current.element.key === key){ // {9}
return current.element.vaule;
}
}
return undefined; //{10}
}
首先,我们要检查的是,制定位置在哈希表中是否为undefined***(行 {3})。如果为undefined,函数会返回undefined、告诉我们没有找到制定的值(行 {10})。如果在指定位置不为undefined,则值得位置有一个链表。那么,接下来要做的就是遍历链表内部成员,直至找到指定key所对应的值。为了进行遍历,我们要找到遍历该链表的起点(行 {4})***,之后我们可以遍历链表成员直至链表尾部(行{5},current.next的指向将为null)。
链表成员包含next指针和element。element是ValuePair的实例,所有里面存储有key和value。为了访问链表成员的key,我们可以使用current.element,并与参数中的key进行比较***(行 {6})。如果两者相同,我们会返回该链表成员的值(行 {7});如果不相同,我们讲通过访问链表的下一个成员的方式继续遍历(行 {8})***。
如果我们要找的key在链表的头部或者尾部,那么它无法通过while循环访问到。为此,我们要使用一个if语句来处理这个特殊情况***(行 {9})***。
remove方法
remove方法在separate chaining中的实现,和我们之前讲过的remove方法的实现有细微的不同。我们现在使用的是LinkedList,我们需要从LinkedList中删除成员,让我们看看具体的代码实现:
this.remove = function(key){
var position = loseloseHashCode(key);
if (table[position] !== undefined){
var current = table[position].getHead( );
while(current.next){
if (current.element.key === key){ // {11}
table[position].remove(current.element); // {12}
if(table[position].isEmpty( )){ // {13}
table[position] = undefined; // {14}
}
return true; // {15}
}
current = current.next;
}
// 检查链表的第一个、最后一个成员
if (current.element.key === key){ // {16}
table[position].remove(current.element);
if (table[position].isEmpty( )){
table[position] = undefined;
}
return true;
}
}
return false; // {17}
};
在remove方法中,我们会使用get方法中一样的步骤来寻找制定的值。在遍历链表的实例时,如果链表中当前的成员有我们要寻找的键***(行 {11}),我们讲使用链表的remove方法来删除该成员(行 {12})。之后,我们还要进行一步额外的验证:这个链表是否为空(行 {13}——链表中已经没有任何成员),我们将把哈希表在这个位置的值指向undefined(行 {14})。最后,我们会返回一个true,来告诉我们已经完成了删除工作(行 {15}),或者返回一个false来告诉我们哈希表中并没有这个键(行 {17})。当然,我们也需要处理链表中第一个和最后一个成员(行 {16})***,就像在get方法中那样。
完成这三个方法后,我们就得到了一个可以处理哈希值冲突的哈希表了。
Linear probing
另一个常用的冲突处理技巧是linear probing。在我们增加新成员时,如果这个位置已经被其他成员占用了,我们把新成员放在 index+1。如果index+1被占用了,我们把新成员放在index+2,并依此类推。
put 方法 让我们看看linear probing下的put方法:
this.put = function(key , value){
var position = loseloseHashCode(key); // {1}
if (table[position] == undefined){ // {2}
table[position] = new ValuePair(key, value); // {3}
}else{
var index = ++position; // {4}
while (table[index] !=undefined){ // {5}
index++; // {6}
}
table[index] = new ValuePair(key,value); // {7}
}
};
和之前的一样,我们通过哈希函数获取成员在哈希表的位置***(行 {1})***。之后,我们要验证那个位置是否有其他值。如果这个位置为空,我们添加value参数至此(行 {3}——一个ValuePair的实例)。
如果哈希表生成的位置已经被占用了,我们要寻找下一个没被栈用的位置(该位置的值为undefinded),所以我们生成了一个index变量,并赋值position+1到index上***(行 {4})。之后,我们要判断新的位置是被占用了,如果被占用了,我给新的位置再加一(行 {6}),直到该位置的值为undefined。之后,将value参数的值赋到最后的位置即可(行 {7})***。
Note
在其他语言中,我们需要定义数组的大小。使用linear probing技巧时,有一个需要考量的变是数组是否超出了数组的既定大小。好在在JavaScript中,我们不用担心这个问题,因为JavaScript会动态调整数组的大小。
如果我们再次运行《处理哈希值相同问题》的邮箱代码,那么他们在linear probing的输出结果如下:
让我们模拟以下插入这些邮箱地址后发生的事情:
- 我们要把Gandalf放在哈希表中。它的哈值为19,因为哈希表刚建立时位置19为空——我们把Gandalf放在了哈希表位置19。
- 我们要把John放在位置29。这个位置也是空的,我们可以直接插入。
- 我们要把Tyrion放在位置16。这个位置也是空的,我们可以直接插入。
- 我们要把Aaron放在哈希表中,其哈希值为16。位置16已经被Tyrion占用了,所以我们要使用位置16+1。位置17为空,所以我们把Aaron放在了位置17。
- 我们要把Donnie放在位置13。这个位置也是空的,我们可以直接插入。
- 我们要把Ana放在哈希表中,其哈希值为13。位置13已经被占用了,所以我们要使用位置13+1。位置14为空,所以我们把Ana放在了位置14。
- 我们要把Jonathan放在位置5。这个位置也是空的,我们可以直接插入。
- 我们要把Jamie放在哈希表中,其哈希值为5。位置5已经被占用了,所以我们要使用位置5+1。位置6为空,所以我们把Jamie放在了位置14。
- 我们要把Sue放在哈希表中,其哈希值为5。位置5已经被占用了,所以我们要使用位置5+1。位置6被占用,所以我们要使用位置6+1。位置7为空,所以我们把Sue放在了位置7。
get 方法
现在,我们来实现linear probing下的get方法:
this.get = function(key){
var position = loseloseHashCode(key);
if (table[position] !== undefined){ // {8}
if (table[position].key === key){ // {9}
return table[position].value; // {10}
}else {
var index = ++position;
while(table[index] === undefined || table[index].key ! === key){ //{11}
if(table[index].key === key) { // {12}
return table[index].value; // {13}
}
}
}
}
return undefined; //{14}
};
为了找到该键所对应的值,我们首先要确认该键的哈希值是否存在于哈希表中***(行 {8})。如果该键不存在,函数会返回undefined(行 {14})。如果存在,我们确认这个哈希值所对应的key是否等于key参数(行 {9}),如果相等,则返回该key所对应的值(行 {10})***。
如果两者不相等,我们沿着现有位置继续往后找***(行 {11}),直至我们找到与key参数相等的成员(行 {12}),然后返回该key所对应的值(行 {13})***
remove方法
remove方法和get方法非常的相似。唯一的不同的是在行 {10}和行 {13},行 {10}和行 {13}应该为以下代码:
table[index] = undefined;
要删除一个成员,只要将其所在位置的指向改为undefined即可,这样这个位置就不被其他成员占用了。 生成更好的哈希函数 如之前的代码所示,“lose lose”哈希函数并不是一个好用的哈希函数(使用该哈希函数会产生太多冲突了)。我们在网上可以找到很多好的哈希函数,我们也可以自己生成一个。
我们可以生成一个更好的哈希函数——djb2:
var djb2HashCode = function (key) {
var hash = 5381; // {1}
for ( var i = 0; i < key.length; i++){ // {2}
hash = hash * 33 + key.charCodeAt(i); // {3}
}
return hash % 1013; // {4}
};
这个哈希函数由一个质数作为初始哈希值(行 {1}——最常用的初始哈希是5381),之后遍历遍历key参数的每一个字母***(行 {2}),之后讲每个字母的哈希值乘以33,并讲相乘的结果与初始哈希值相加(行 {3})***。
最后,我们讲哈希相加的总和除以一个质数(这个质数最好大于哈希表的大小),将相除的商作为哈希值返回。
如果我们把之前的邮箱地址插入到使用djb2Hash方法的哈希表里面,其哈希值如下:
798 – Gandalf
838 – John
624 – Tyrion
215 – Aaron
278 – Donnie
925 – Ana
288 – Jonathan
962 – Jamie
502 – Sue
804 – Mindy
54 – Paul
223 – Nathan
看,并没有发生哈希值冲突。
虽然这不是最好的哈希函数,但是是被各个社区广泛推荐的哈希函数。
小结
在这一章,我们学习了如何生成一个字典结构,如何对字典进行增删改查。我们也知道了字典与集合的不同。
我们也讲了如何生成一个哈希表结果,如何对哈希表进行增删改查,如何生成哈希函数。我们也学习了一些处理哈希冲突的技巧。
在下一章,我们将学习树结构。
注:本文翻译自Loiane Groner的《Learning JavaScript Data Structures and Algorithm》