JavaScript数据结构之字典和散列表
集合、字典和散列表可以存储不重复的值。在字典中,我们用[键,值]的形式来存储数据。在散列表中也是一样(也是以[键,值]对的形式来存储数据)
字典
集合表示一组互不相同的元素(不重复的元素)。在字典中,存储的是[键,值]
对,其中键名是用来查询特定元素的。字典和集合很相似,集合以[值,值]的形式存储元素,字典则是以[键,值]的形式来存储元素。字典也称作映射。
创建一个字典
与 Set 类相似,ECMAScript 6同样包含了一个 Map 类的实现,即我们所说的字典。
Dictionary 类的骨架:
function Dictionary() {
var items = {};
}
与 Set 类类似,将在一个 Object 的实例而不是数组中存储元素。
然后,需要声明一些映射/字典所能使用的方法。
- set(key,value) :向字典中添加新元素。
- remove(key) :通过使用键值来从字典中移除键值对应的数据值。
- has(key) :如果某个键值存在于这个字典中,则返回 true ,反之则返回 false 。
- get(key) :通过键值查找特定的数值并返回。
- clear() :将这个字典中的所有元素全部删除。
- size() :返回字典所包含元素的数量。与数组的 length 属性类似。
- keys() :将字典所包含的所有键名以数组形式返回。
- values() :将字典所包含的所有数值以数组形式返回。
has 和 set 方法
首先来实现 has(key) 方法
this.has = function(key) {
return key in items;
}
使用JavaScript中的 in 操作符来验证一个 key 是否是 items 对象的一个属性。
然后是 set 方法的实现:
this.set = function(key, value) {
items[key] = value; //{1}
}
该方法接受一个 key 和一个 value 作为参数。我们直接将 value 设为 items 对象的 key 属性的
值。它可以用来给字典添加一个新的值,或者用来更新一个已有的值。
remove 方法
接下来,实现 remove 方法。它和 Set 类中的 remove 方法很相似,唯一的不同点在于将先搜索 key (而不是 value ):
this.remove = function(key) {
if (this.has(key)) {
delete items[key];
return true;
}
return false;
}
然后可以使用JavaScript的 remove 操作符来从 items 对象中移除 key 属性。
![](/qrcode.jpg)
get 和 values 方法
如果想在字典中查找一个特定的项,并检索它的值,可以使用下面的方法:
this.get = function(key) {
return this.has(key) ? items[key] : undefined;
};
get 方法首先会验证想要检索的值是否存在(通过查找 key 值),如果存在,将返回该值,反之将返回一个 undefined 值*(请记住 undefined 值和 null 值是不一样的)*
下一个是 values 方法。这个方法以数组的形式返回字典中所有 values 实例的值:
this.values = function() {
var values = {};
for (var k in items) { //{1}
if (this.has(k)) {
values.push(items[k]); //{2}
}
}
return values;
};
首先遍历 items 对象的所有属性值(行 {1} )。为了确定值存在,使用 has 函数来验证 key 确实存在,然后将它的值加入 values 数组(行 {2} )。最后就能返回所有找到的值。
注意:不能仅仅使用 for-in 语句来遍历 items 对象的所有属性,还需要使用has 方法(验证 items 对象是否包含某个属性),因为对象的原型也会包含对象的其他属性(JavaScript基本的 Object 类中的属性将会被继承,并存在于当前对
象中,而对于这个数据结构来说,并不需要它们)
clear 、 size 、 keys 和 getItems 方法
clear 、 size 和 keys 方法与 Set 类中是完全一样的,就不详细介绍了。
验证 items 属性的输出值。可以实现一个返回 items 变量的方法,叫作
getItems
this.getItems = function() {
return items;
}
使用 Dictionary 类
首先,创建一个 Dictionary 类的实例,然后给它添加三条电子邮件地址。使用这个 dictionary 实例来实现一个电子邮件地址簿。
var dictionary = new Dictionary();
dictionary.set('sun', '[email protected]');
dictionary.set('John', '[email protected]');
dictionary.set('Tom', '[email protected]');
如果执行了如下代码,输出结果将会是 true :
console.log(dictionary.has('sun'));
下面的代码将会输出 3 ,因为向字典实例中添加了三个元素:
console.log(dictionary.size());
执行下面的几行代码:
console.log(dictionary.keys());
console.log(dictionary.values());
console.log(dictionary.get('Tom'));
输出结果分别如下所示:
["sun", "John", "Tom"]
["[email protected]", "[email protected]", "[email protected]"]
[email protected]
散列表
散列算法的作用是尽可能快的在数据结构中找到一个值。如果要在数据结构中获得一个值(使用 get 方法),需要遍历整个数据结构来找到它。如果使用散列函数,就知道值的具体位置,因此能够快速检索到该值。散列函数的作用是给定一个键值,然后返回值在表中的地址。
举个例子,继续使用在前面的电子邮件地址簿。将要使用最常见的散列函数——“lose lose”散列函数,方法是简单地将每个键值中的每个字母的ASCII
值相加。
创建一个散列表
首先搭建类的骨架:
function HashTable() {
var table = [];
}
然后,给类添加一些方法。给每个类实现三个基础的方法。
- put(key,value) :向散列表增加一个新的项(也能更新散列表)。
- remove(key) :根据键值从散列表中移除值。
- get(key) :返回根据键值检索到的特定的值。
在实现这三个方法之前,要实现的第一个方法是散列函数,它是 HashTable 类中的一个私有方法:
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 参数,根据组成 key 的每个字符的ASCII码值的和得到一个数字。所以,首先需要一个变量来存储这个总和(行 {1} )。然后,遍历 key (行 {2} )并将从ASCII表中查到的每个字符对应的ASCII值加到 hash 变量中(可以使用JavaScript的 String 类中的 charCodeAt方法——行 {3} )。最后,返回 hash 值。为了得到比较小的数值,使用hash值和一个任意数做除法的余数( mod )。
有了散列函数,就可以实现 put 方法了:
this.put = function(key, value) {
var position = loseloseHashCode(key); //{5}
console.log(position + ' - ' + key); //{6}
table[position] = value; //{7}
};
首先,根据给定的 key ,我们需要根据所创建的散列函数计算出它在表中的位置(行 {5} )。为了便于展示信息,我们将计算出的位置输出至控制台(行 {6} )。由于它不是必需的,我们也可以将这行代码移除。然后要做的,是将 value 参数添加到用散列函数计算出的对应的位置上(行 {7} )。
从 HashTable 实例中查找一个值也很简单。为此,将会实现一个 get 方法:
this.get = function (key) {
return table[loseloseHashCode(key)];
};
首先,使用所创建的散列函数来求出给定 key 所对应的位置。这个函数会返回值的位置,因此所要做的就是根据这个位置从数组 table 中获得这个值。
实现的最后一个方法是 remove 方法:
this.remove = function(key) {
table[loseloseHashCode(key)] = undefined;
};
要从 HashTable 实例中移除一个元素,只需要求出元素的位置(可以使用散列函数来获取)并赋值为 undefined 。
对于 HashTable 类来说,我们不需要像 ArrayList 类一样从 table 数组中将位置也移除。由于元素分布于整个数组范围内,一些位置会没有任何元素占据,并默认为 undefined 值。我们也不能将位置本身从数组中移除(这会改变其他元素的位置),否则,当下次需要获得或移除一个元素的时候,这个元素会不在我们用散列函数求出的位置上。
【注释:
charCodeAt()
方法可返回指定位置的字符的 Unicode 编码。这个返回值是 0 - 65535 之间的整数。
方法 charCodeAt()
与 charAt()
方法执行的操作相似,只不过前者返回的是位于指定位置的字符的编码,而后者返回的是字符子串。
语法
stringObject.charCodeAt(index)
】
使用 HashTable 类
测试 HashTable 类:
var hash = new HashTable();
hash.put('Gandalf', '[email protected]');
hash.put('John', '[email protected]');
hash.put('Tyrion', '[email protected]');
执行上述代码,会在控制台中获得如下输出:
19 - Gandalf
29 - John
16 - Tyrion
下面的图表展现了包含这三个元素的 HashTable 数据结构:
现在来测试 get 方法:
console.log(hash.get('Gandalf'));
console.log(hash.get('Loiane'));
获得如下的输出:
[email protected]
undefined
由于 Gandalf 是一个在散列表中存在的键, get 方法将会返回它的值。而由于 Loiane 是一个不存在的键,当我们试图在数组中根据位置获取值的时候(一个由散列函数生成的位置),返回值将会是 undefined (即不存在)。
从散列表中移除 Gandalf :
hash.remove('Gandalf');
console.log(hash.get('Gandalf'));
由于 Gandalf 不再存在于表中, hash.get(‘Gandalf’) 方法将会在控制台上给出
undefined 的输出结果。
散列表和散列集合
散列表和散列映射是一样的。
在一些编程语言中,还有一种叫作散列集合的实现。散列集合由一个集合构成,但是插入、移除或获取元素时,使用的是散列函数。我们可以重用之前实现的所有代码来实现散列集合,
不同之处在于,不再添加键值对,而是只插入值而没有键。例如,可以使用散列集合来存储所有的英语单词(不包括它们的定义)。和集合相似,散列集合只存储唯一的不重复的值。
处理散列表中的冲突
有时候,一些键会有相同的散列值。不同的值在散列表中对应相同位置的时候,称其为冲突。
var hash = new HashTable();
hash.put('Gandalf', '[email protected]');
hash.put('John', '[email protected]');
hash.put('Tyrion', '[email protected]');
hash.put('Aaron', '[email protected]');
hash.put('Donnie', '[email protected]');
hash.put('Ana', '[email protected]');
hash.put('Jonathan', '[email protected]');
hash.put('Jamie', '[email protected]');
hash.put('Sue', '[email protected]');
hash.put('Mindy', '[email protected]');
hash.put('Paul', '[email protected]');
hash.put('Nathan', '[email protected]');
输出结果如下:
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 和 Aaron 有相同的散列值( 16 )。 Donnie 和 Ana 有相同的散列值( 13 ),Jonathan 、 Jamie 和 Sue 有相同的散列值( 5 ), Mindy 和 Paul 也有相同的散列值( 32 )。
为了获得结果,实现一个叫作 print 的辅助方法,在控制台上输出 HashTable
中的值:
this.print = function() {
for (var i = 0; i < table.length; ++i) { //{1}
if (table[i] !== undefined) { //{2}
console.log(i + ": " + table[i]);//{3}
}
}
};
首先,遍历数组中的所有元素(行 {1} )。当某个位置上有值的时候(行 {2} ),会在控制台上输出位置和对应的值(行 {3} )。
现在来使用这个方法:
hash.print();
在控制台上得到如下的输出结果:
5: [email protected]
10: [email protected]
13: [email protected]
16: [email protected]
19: [email protected]
29: [email protected]
32: [email protected]
Jonathan 、 Jamie 和 Sue 有相同的散列值,也就是 5 。由于 Sue 是最后一个被添加的, Sue将是在 HashTable 实例中占据位置 5 的元素。这对于其他发生冲突的元素来说也是一样的。
使用一个数据结构来保存数据的目的显然不是去丢失这些数据,而是通过某种方法将它们全部保存起来。因此,当这种情况发生的时候就要去解决它。处理冲突有几种方法:分离链接、线性探查和双散列法。
1. 分离链接
分离链接法包括为散列表的每一个位置创建一个链表并将元素存储在里面。它是解决冲突的最简单的方法,但是它在 HashTable 实例之外还需要额外的存储空间。
例如,之前的测试代码中使用分离链接的话,输出结果将会是这样:
在位置5上,将会有包含三个元素的 LinkedList 实例;在位置13、16和32上,将会有包含两个元素的 LinkedList 实例;在位置10、19和29上,将会有包含单个元素的 LinkedList 实例。
对于分离链接和线性探查来说,只需要重写三个方法: put 、 get 和 remove 。这三个方法在每种技术实现中都是不同的。
为了实现一个使用了分离链接的 HashTable 实例,我们需要一个新的辅助类来表示将要加入LinkedList 实例的元素。叫他 ValuePair 类(在 HashTable 类内部定义):
var ValuePair = function(key, value){
this.key = key;
this.value = value;
this.toString = function() {
return '[' + this.key + ' - ' + this.value + ']';
}
};
这个类只会将 key 和 value 存储在一个 Object 实例中。我们也重写了 toString 方法,以便之后在浏览器控制台中输出结果。
(1) put 方法
实现第一个方法, put 方法,代码如下:
this.put = function(key, value){
var position = loseloseHashCode(key);
if (table[position] == undefined) { //{1}
table[position] = new LinkedList();
}
table[position].append(new ValuePair(key, value)); //{2}
};
在这个方法中,将验证要加入新元素的位置是否已经被占据(行 {1} )。如果这个位置是第一次被加入元素,会在这个位置上初始化一个 LinkedList 类的实例,实现的 append 方法向 LinkedList 实例中添加一个 ValuePair 实例(键和值)(行 {2} )。
(2) get 方法
this.get = function(key) {
var position = loseloseHashCode(key);
if (table[position] !== undefined){ //{3}
//遍历链表来寻找键/值
var current = table[position].getHead(); //{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.value;
}
}
return undefined; //{10}
};
要做的第一个验证,是确定在特定的位置上是否有元素存在(行 {3} )。如果没有,则返回一个 undefined 表示在 HashTable 实例中没有找到这个值(行 {10} )。如果在这个位置上有值存在,我们知道这是一个 LinkedList 实例。现在要做的是遍历这个链表来寻找需要的元素。在遍历之前先要获取链表表头的引用(行 {4} ),然后就可以从链表的头部遍历到尾部(行{5} , current.next 将会是 null )。
Node 链表包含 next 指针和 element 属性。而 element 属性又是 ValuePair 的实例,所以它又有 value 和 key 属性。可以通过 current.element.next 来获得 Node 链表的 key 属性,并通过比较它来确定它是否就是我们要找的键(行 {6} )。(这就是要使用 ValuePair 这个辅助类来存储元素的原因。我们不能简单地存储值本身,这样就不能确定哪个值对应着特定的键。)如果 key值相同,就返回 Node 的值(行 {7} );如果不相同,就继续遍历链表,访问下一个节点(行 {8} )。
如果要找的元素是链表的第一个或最后一个节点,那么就不会进入 while 循环的内部。因此,需要在行 {9} 处理这种特殊的情况。
(3) remove 方法
使用分离链接法从 HashTable 实例中移除一个元素和之前在本章实现的 remove 方法有一些不同。现在使用的是链表,需要从链表中移除一个元素。
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 方法一样的步骤找到要找的元素。遍历 LinkedList 实例时,如果链表中的 current 元素就是要找的元素(行 {11} ),使用 remove 方法将其从链表中移除。然后进行一步额外的验证:如果链表为空了(行 {13} ——链表中不再有任何元素了),就将散列表这个位置的值设为 undefined (行 {14} ),这样搜索一个元素或打印它的内容的时候,就可以跳过这个位置了。最后,返回 true 表示这个元素已经被移除(行 {15} )或者在最后返回 false
表示这个元素在散列表中不存在(行 {17} )。同样,需要和 get 方法一样,处理元素在第一个或最后一个的情况(行 {16} )。
写了这三个方法后,我们就拥有了一个使用了分离链接法来处理冲突的 HashMap 实例。
2. 线性探查
另一种解决冲突的方法是线性探查。当想向表中某个位置加入一个新元素的时候,如果索引为index的位置已经被占据了,就尝试index+1的位置。如果index+1的位置也被占据了,就尝试index+2的位置,以此类推。
(1) 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} ),然后验证这个位置是否有元素存在(如果这个位置被占据了,将会通过行 {2} 的验证)。如果没有元素存在,就在这个位置加入新元素(行 {3} ——一个 ValuePair 的实例)。
如果这个位置已经被占据了,需要找到下一个没有被占据的位置( position 的值是undefined ),因此声明一个 index 变量并赋值为 position+1 (行 {4} ——在变量名前使用自增运算符 ++ 会先递增变量值然后再将其赋值给 index )。然后验证这个位置是否被占据(行{5} ),如果被占据了,继续将 index 递增(行 {6} ),直到找到一个没有被占据的位置。然后要做的,就是将值分配到这个位置(行 {7} )。
模拟一下散列表中的插入操作。
- 试着插入Gandalf。它的散列值是19,由于散列表刚刚被创建,位置19还是空的——可以在这里插入数据。
- 试着在位置29插入John。它也是空的,所以可以插入这个姓名。
- 试着在位置16插入Tyrion。它是空的,所以可以插入这个姓名。
- 试着插入Aaron,它的散列值也是16。位置16已经被Tyrion占据了,所以需要检查索引值为position+1的位置(16+1)。位置17是空的,所以可以在位置17插入Aaron。
- 接着,试着在位置13插入Donnie。它是空的,所以可以插入这个姓名。
- 想在位置13插入Ana,但是这个位置被占据了。因此在位置14进行尝试,它是空的,所以可以在这里插入姓名。
- 然后,在位置5插入Jonathan,这个位置是空的,所以可以插入这个姓名。
- 试着在位置5插入Jamie,但是这个位置被占了。所以跳至位置6,这个位置是空的,因此可以在这个位置插入姓名。
- 试着在位置5插入Sue,但是位置被占据了。所以跳至位置6,但也被占了。接着跳至位置7,这里是空的,所以可以在这里插入姓名。
- 以此类推。
(2) 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}
index++;
}
if (table[index].key === key) { //{12}
return table[index].value; //{13}
}
}
}
return undefined; //{14}
};
要获得一个键对应的值,先要确定这个键存在(行 {8} )。如果这个键不存在,说明要查找的值不在散列表中,因此可以返回 undefined (行 {14} )。如果这个键存在,需要检查要找的值是否就是这个位置上的值(行 {9} )。如果是,就返回这个值(行 {10} )。如果不是,就在散列表中的下一个位置继续查找,直到找到一个键值与我们要找的键值相同的元素(行 {11} )。然后,验证一下当前项就是要找的项(行 {12} ——只是为了确认一下)并且将它的值返回(行 {13} )。
无法确定要找的元素实际上在哪个位置,这就是使用 ValuePair 来表示 HashTable 元素的原因。
(3) remove 方法
remove 方法和 get 方法基本相同,不同之处在于行 {10} 和 {13} ,它们将会由下面的代码代替:
table[index] = undefined;
要移除一个元素,只需要给其赋值为 undefined ,来表示这个位置不再被占据并且可以在必要时接受一个新元素。
创建更好的散列函数
实现的“lose lose”
散列函数并不是一个表现良好的散列函数,因为它会产生太多的冲突。如果使用这个函数的话,会产生各种各样的冲突。一个表现良好的散列函数是由几个方面构成的:插入和检索元素的时间(即性能),当然也包括较低的冲突可能性。另一个可以实现的比“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}
};
它包括初始化一个 hash 变量并赋值为一个质数(行 {1} ——大多数实现都使用 5381 ),然后迭代参数 key (行 {2} ),将 hash 与 33 相乘(用来当作一个魔力数),并和当前迭代到的字符的ASCII码值相加(行 {3} )。最后,将使用相加的和与另一个随机质数(比认为的散列表的大小要大——在本例中,认为散列表的大小为1000)相除的余数。如果再次执行前面实现的插入数据的代码,就是用djb2HashCode
代替 loseloseHash
Code 的最终结果:
798 - Gandalf
838 - John
624 - Tyrion
215 - Aaron
278 - Donnie
925 - Ana
288 - Jonathan
962 - Jamie
502 - Sue
804 - Mindy
54 - Paul
223 - Nathan
注释:没有冲突!但这并不是最好的散列函数。