字典 和 散列表也和集合一样,都是用来存储唯一值(不重复的值)的数据结构。
集合中,我们看中的是每个值本身,并把值当作主要元素。在字典(或映射)中,我们用【键,值】对的形式来存储数据,散列表也是一样的。但是字典和散列表这俩种数据结构的实现方式略有不同。eg:字典的每个键只能有一个值。
我们已经学习过集合,了解到集合表示一组互不相同的元素(不重复的元素)。字典中,存储的是【键,值】对,其中呢,键名是用来查询特定元素的。字典和集合相似,集合以【值,值】的形式存储元素,字典则以【键,值】的形式存储元素。字典另外的名字也称作映射、符号或关联数组。
计算机科学中,字典经常用来保存对象的引用地址。
接下来我们来创建字典类,同样,es6中也存在一个Map类的实现,类似于我们说的字典。
class Dictionary{
constructor() {
this.table = {}
}
}
与Set类相似,我们将在一个object的实例而不是数组中存储字典中的元素。我们会将【键,值】对保存为table[key] = {key,value}
字典中,理想情况是用字典作为键名,值可以是任何类型(从数、字符串等原始类型,到复杂对象)。我们把所有作为键名传入的对象转化为字符串,使得从Dictionary类中搜索或获取值更简单
function ToString(item) {
if(item === null){
return 'NULL'
} else if (item === undefined) {
return 'UNDEFINED'
}else if (typeof item === 'string' || item instanceof String) {
return `${item}`;
}
return item.toString();
}
然后我们需要声明一些字典所能使用的方法:
- set(key,value):向字典追加新元素。key存在,value会被新值覆盖。如果只传一个参数,value值为undefined
- remove(key): 通过使用键值作为参数来从字典中移除键值对应的数据值
- hasKey(key):如果某个键值存在于该字典中,返回 true,否则返回 false
- get(key): 通过键值作为参数查找特定的数值并返回
- clear():删除该字典中的所有的值
- size(): 返回字典所包含值的数量
- isEmpty():字典内是否有元素,没有时返true,有则返回false
- keys():将字典所包含的所有键名以数组形式返回
- values:将字典中所包含的所有数值以数组形式返回
- keyValues():将字典中所有【键、值】对返回
- forEach(callbackFn):迭代字典中所有的键值对
检测一个键是否存在于字典中
我们首先来实现hasKey(key)方法。之所以要实现这个方法,是因为它会被set和remove等其他方法调用。
hasKey(key) {
return Object.keys(this.table).includes(ToString.call(this,key))
}
接下来来实现set方法,往字典中添加元素
set(key,value){
const argu = [...arguments]
if(argu.length==0){
this.table[undefined] = {undefined:undefined}
} else if(argu.length == 1){
let obj = {}
obj[ToString.call(this,key)] = undefined
this.table[ToString.call(this,key)] = obj
} else {
let obj = {}
obj[ToString.call(this,key)] = value
this.table[ToString.call(this,key)] = obj
}
return this.table
}
现在来实现remove(key)方法,删除指定键值对应的数据值
removeKey(key){
if(this.hasKey(ToString.call(this,key))){
delete this.table[ToString.call(this,key)]
return true
}
return false
}
get(key)我们通过键值来检索一个值并把值返回
get(key){
if(this.hasKey(ToString.call(this,key))) {
return this.table[ToString.call(this,key)]
}
return undefined
}
clear方法删除字典中所有的值
clear() {
this.table = {}
}
size方法返回字典中的所包含值的数量
size() {
return Object.keys(this.table).length
}
isEmpty()判断字典是否为空
isEmpty(){
return this.size() == 0
}
values() 返回所有value组成的数组
values(){
return Object.values(this.table)
}
keyValue()获取字典的键值对
keyValue(){
const valuePairs = []
for (const k in this.table) {
let obj = {}
obj[ToString.call(this,k)] = this.table[ToString.call(this,k)]
valuePairs.push(obj)
}
return valuePairs
}
keys()获取字典的key值组成的数组
keys(){
return Object.keys(this.table)
}
forEach()遍历字典内元素
forEach(callbackFn){
if(!this.isEmpty()){
Object.keys(this.table).forEach((item,index)=>{
callbackFn(item,index,this.table)
})
}
}
接下来,我们使用Dictionary类。首先,我们先创建一个Dictionary的实例,然后为它添加三条电子邮件地址。
const dictionary = new Dictionary();
dictionary.set('Gandalf', 'gandalf@email.com');
dictionary.set('John', 'johnsnow@email.com');
dictionary.set('Tyrion', 'tyrion@email.com');
console.log(dictionary.hasKey('Gandalf')); // true
console.log(dictionary.size()); // 3
console.log(dictionary.keys()); // ['Gandalf','John','Tyrion']
console.log(dictionary.values()); // [{Gandalf: "gandalf@email.com"},{John: "johnsnow@email.com"},{Tyrion: "tyrion@email.com"}]
console.log(dictionary.get('Tyrion')); // {Tyrion: "tyrion@email.com"}
散列表
接下来将学习到HashTable类,也叫HashMap 类,它是 Dictionary 类的一种散列表 实现方式。
散列算法的作用是尽可能快的在数据结构中找到一个值.
通过之前链表的学习,我们知道如果要早数据结构中获取一个值,需要迭代整个儿数据结构来找到它。如果使用散列函数,就知道值具体的位置,因此能够快速检索到该值。 散列函数的作用是给定一个键值,然后返回值在表中的地址。javascript语言内部就是使用散列表来表示每个对象。此时,对象的每个属性和方法被存储为key对象类型,每个key指向对应的对象成员。
我们将使用最常见的散列函数-- lose lose散列函数,方法是简单地将每个键值中的每个字母的ASCII值相加,如下图

创建散列表
我们使用对象来表示我们的数据结构,和我们再Dictionary类所做的一样
和之前一样,我们先搭建类的骨架
class HashTable{
constructor(){
this.toStrFn = ToString
this.table = {}
}
}
class ValuePair {
constructor(key, value) {
this.key = key;
this.value = value;
} toString() {
return `[#${this.key}: ${this.value}]`;
}
}
然后,给类添加一些方法。我们给每个类实现三个基本方法:
- put(key,value):向散列表增加一个新的项(也能更新散列表)
- remove(key):根据键值从散列表中移除值
- get(key):返回根据键值检索到的特定的值
创建散列函数
loseloseHashCode(key) {
if(typeof key == 'number') { //首先检查key是否是number类型,是直接返回key
return key
}
const tableKey = this.toStrFn(key) //将key转化为字符串类型,防止可以是一个对象而不是字符串
let hash = 0
for(let i =0;i<tableKey.length;i++){ //遍历字符串
hash += tableKey.chartCodeAt(i) // 将每个字符的ASCII码加在一起
}
return hash%37 // 为了得到比较小的key值,我们使用hash值和一个任意数做除法的余数--可以规避操作数超过数值变量最大表示范围的风险
}
hasCode(key){
return this.loseloseHashCode(key)
}
将键和值加入散列表
下面来实现put 方法
put(key,value){
if(key != null && value !=null){
const position = this.hasCode(key)
this.table[position] = new ValuePair(key, value)
return true
}
return false
}
从散列表中获取一个值
get(key){
const valuePair = this.table[this.hasCode(key)]
return valuePair == null ? undefined : valuePair.value
}
从散列表中移除一个值
remove(key) {
const hash = this.hasCode(key)
const hasPair = this.table[hash]
if(valuePair != null) {
delete this.table[hash]
return true
}
return false
}
使用HashTable类
const hash = new HashTable();
hash.put('Gandalf', 'gandalf@email.com');
hash.put('John', 'johnsnow@email.com');
hash.put('Tyrion', 'tyrion@email.com');
console.log(hash.hashCode('Gandalf') + ' - Gandalf');
console.log(hash.hashCode('John') + ' - John');
console.log(hash.hashCode('Tyrion') + ' - Tyrion');
执行上述代码,结果如下
19 - Gandalf
29 - John
16 - Tyrion

散列表和散列集合
散列表和散列映射是一样的,我们已经介绍了这种数据结构
散列集合由一个集合构成 ,但是插入、移除或获取元素时,使用hasCode函数。不同之处是,不在添加键值对,而是只插入值而没有键。eg:可以使用散列集合来存储所有的英语单词。和集合类似,散列集合只存储不重复的唯一值。
处理散列表中的冲突
一些键会有相同的散列值。不同的值在散列表中对应相同的位置的时候,我们称其为冲突 。例如,我们看下面的代码会输出什么结构。
const hash = new HashTable();
hash.put('Ygritte', 'ygritte@email.com');
hash.put('Jonathan', 'jonathan@email.com');
hash.put('Jamie', 'jamie@email.com');
hash.put('Jack', 'jack@email.com');
hash.put('Jasmine', 'jasmine@email.com');
hash.put('Jake', 'jake@email.com');
hash.put('Nathan', 'nathan@email.com');
hash.put('Athelstan', 'athelstan@email.com');
hash.put('Sue', 'sue@email.com');
hash.put('Aethelwulf', 'aethelwulf@email.com');
hash.put('Sargeras', 'sargeras@email.com');
通过对每个提到的名字调用 hash.hashCode 方法,输出结果如下。
4 - Ygritte
5 - Jonathan
5 - Jamie
7 - Jack
8 - Jasmine
9 - Jake
10 - Nathan
7 - Athelstan
5 - Sue
5 - Aethelwulf
10 - Sargeras
注意,Nathan 和 Sargeras 有相同的散列值(10)。Jack 和 Athelstan 有相同 的散列值(7),Jonathan、Jamie、Sue 和 Aethelwulf 也有相同的散列值(5)
我们现在实现一个方法,来获取散列表中值组成的字符串
toString() {
if (this.isEmpty()) {
return '';
}
const keys = Object.keys(this.table);
let objString = `{${keys[0]} => ${this.table[keys[0]].toString()}}`;
for (let i = 1; i < keys.length; i++) {
objString = `${objString},{${keys[i]} => ${this.table[keys[i]].toString()}}`;
}
return objString;
}
在调用 console.log(hashTable.toString())后,我们会在控制台中得到下面的输出 结果。
{4 => [#Ygritte: ygritte@email.com]}
{5 => [#Aethelwulf: aethelwulf@email.com]}
{7 => [#Athelstan: athelstan@email.com]}
{8 => [#Jasmine: jasmine@email.com]}
{9 => [#Jake: jake@email.com]}
{10 => [#Sargeras: sargeras@email.com]}
Jonathan、Jamie、Sue 和 Aethelwulf 有相同的散列值,也就是 5。由于 Aethelwulf 是后一个被添加的,它将是在 HashTable 实例中占据位置 5的元素。首先 Jonathan 会占据 这个位置,然后 Jamie 会覆盖它,Sue 会再次覆盖,后 Aethelwulf 会再覆盖一次。这对于 其他发生冲突的元素来说也是一样的。
使用一个数据结构来保存数据的目的显然不是丢失这些数据,而是通过某种方法将它们全部 保存起来 。处理冲突有几种方法: 分离链接 、线性探查和双散列法。下面我们介绍前俩种方法:
分离链接
分离链接法包括为散列表的每一个位置创建一个链表并将元素存储在里面。它是解决冲突的 简单的方法,但是在 HashTable 实例之外还需要额外的存储空间。
eg:我们使用之前的测试代码中使用分离链接并用图表示的话,输出结果将会是如下这样:

在位置 5上,将会有包含四个元素的 LinkedList 实例;在位置 7和 10上,将会有包含两 个元素的 LinkedList 实例;在位置 4、8和 9上,将会有包含单个元素的 LinkedList 实例
接下来我们重写put、get和remove三个方法
class HashTableSeparateChaining{
constructor(){
this.toStrFn = ToString
this.table = {}
}
}
class ValuePair {
constructor(key, value) {
this.key = key;
this.value = value;
} toString() {
return `[#${this.key}: ${this.value}]`;
}
}
put方法
put(key,value) {
if(key != null && value !=null){
const position = this.hasCode(key)
if(!this.table[position]){ //验证新元素位置是否已被占据
this.table[position]=new LinkedList() // 第一次加入时,我们先在该位置初始化一个linkeList
}
this.table[position].push(new ValuePair(key, value)) //我们再该位置的链表末尾追加新元素
return true
}
return false
}
get方法
get(key){
const position = this.hasCode(key)
const linkedList = this.table[position] //首先根据位置检索到链表
if(linkList != null && !linkedList.isEmpty()){ //检查是否存在链表实例,并且链表是否为空
let current = linkedList.getHead() // 获取链表的表头元素
while(current != null){ // 遍历链表直到找到该元素或者遍历至链表结尾
if(current.elment.key === key) {
return current.elment.value
}
current = current.next
}
}
return undefined
}
remove方法
remove(key){
const position = this.hasCode(key)
const linkedList = this.table[position] //首先根据位置检索到链表
if(linkList != null && !linkedList.isEmpty()){ //检查是否存在链表实例,并且链表是否为空
let current = linkedList.getHead() // 获取链表的表头元素
while(current != null){ // 遍历链表直到找到该元素或者遍历至链表结尾
if(current.elment.key === key) {
linkedList.remove(current.element)
if(linkedList.isEmpty()){
delete this.table[position]
}
return true
}
current = current.next
}
}
return false
}
线性探查
另一种解决冲突的方法就是 线性探查 。之所以称为线性,是因为它的处理冲突的方法是 将元素直接存储到表中,而不是在单独的数据结构中。
当想向表中某个位子添加一个新元素的时候,如果1.索引为position的位置已经被占据了,就尝试position+1的位置。如果2.position+1的位置也被占据了,就尝试position+2的位置,以此类推,直到在散列表中找到一个空闲的位置。
想象一下:有一个已经包含一些元素的散列表,我们想添加一个新的键和值。1.我们计算这个新键的hash,2.检查散列表中对应的位置是否被占据。3.如果没有,我们就将该值直接添加到正确的位置。4.如果被占据了,我们就迭代散列表,直到找到下一个空闲的位置。
下图展现了这个过程:

接下来是删除的时候:当我们从散列表中移除一个键值对的时候,仅仅将本章之前的数据结构所实现位置的元素删除是不够的。如果我们只是移除了元素,再次查找相同hash的其他元素时找到一个空位置,会导致算法错误。
线性探查技术分为两种。第一种是软删除方法。我们使用一个特殊的值(标记)来表示键 值对被删除了(惰性删除或软删除),而不是真的删除它。经过一段时间,散列表被操作过后, 我们会得到一个标记了若干删除位置的散列表。这会逐渐降低散列表的效率,因为搜索键值会 随时间变得更慢。能快速访问并找到一个键是我们使用散列表的一个重要原因。下图展示了这 个过程。

第二种方法需要检验是否有必要将一个或多个元素移动到之前的位置。当搜索一个键的时 候,这种方法可以避免找到一个空位置。如果移动元素是必要的,我们就需要在散列表中挪动键 值对。下图展现了这个过程。

我们接下来用第二种删除方法
接下来我们重写put、get和remove三个方法
class HashTableSeparateChaining{
constructor(){
this.toStrFn = ToString
this.table = {}
}
}
class ValuePair {
constructor(key, value) {
this.key = key;
this.value = value;
} toString() {
return `[#${this.key}: ${this.value}]`;
}
}
put方法
put(key,value) {
if(key != null && value !=null){
const position = this.hasCode(key)
if(!this.table[position]){ //验证新元素位置是否已被占据
this.table[position] = new ValuePair(key, value) // 第一次加入时,我们先在该位置初始化一个linkeList
} else {
let index = position + 1;
while(this.table[index] !=null){
index++
}
this.table[index] = new ValuePair(key, value)
}
return true
}
return false
}
get方法
get(key){
const position = this.hasCode(key)
if(this.table[position]){
if(this.table[position].key === key) {
return this.table[position].value
} else{
let index = position + 1;
while(this.table[index] !=null && this.table[index].key !== key){
index++
}
if (this.table[index] != null && this.table[index].key === key) {
return this.table[position].value;
}
}
}
return undefined
}
remove方法
remove(key){
const position = this.hasCode(key)
if(this.table[position]){
if(this.table[position].key === key) {
delete this.table[position]
this.verifyRemoveSideEffect(key, position);
return true
} else{
let index = position + 1;
while(this.table[index] !=null && this.table[index].key !== key){
index++
}
if (this.table[index] != null && this.table[index].key === key) {
delete this.table[position]
this.verifyRemoveSideEffect(key, position);
return true
}
}
return false
}
verifyRemoveSideEffect方法
verifyRemoveSideEffect(key,position){
const hash = this.hashCode(key)
let index = position + 1
while(this.table[index]!=null) {
const posHash = this.hashCode(this.table[index].key);
if(poshHash<=hash || poshHash<=position) {
this.table[position] = this.table[index]; // {6}
delete this.table[index];
position = index;
}
index++
}
}
下面来模拟一下删除的过程。
(1) 我们可以在位置 5找到并删除 Jonathan。位置 5现在空闲了。我们将验证一下是否有副 作用。
(2) 我们来到存储 Jamie 的位置 6,现在的散列值为 5,它的散列值 5小于等于散列值 5,所 以要将 Jamie 复制到位置5并删除 Jamie。位置 6现在空闲了,我们来验证下一个位置。
(3) 我们来到位置 7,这里保存了 Jack,散列值为 7。它的散列值 7大于散列值 5,并且散列 值 7大于 removedPosition 的值 6,所以我们不需要移动它。下一个位置也被占据了,那么我 们来验证下一个位置。
(4) 我们来到位置 8,此处保存了 Jasmine,散列值为 8。散列值 8大于 Jasmine 的散列 值 5,并且散列值 8大于 removedPosition 的值 6,因此不需要移动它。下一个位置也被占了, 那么我们来验证下一个位置。
(5) 我们来到位置 9,这里保存了 Jake,它的散列值是 9。散列值 9大于散列值5,并且散列 值 9大于 removedPosition 的值 6,所以不需要移动它。下一个位置也被占了,那么我们来验 证下一个位置。
(6) 我们重复相同的过程,直到位置 12。
(7) 我们来到位置 12,此处保存了 Sue,它的散列值为 5。散列值 5小于等于散列值 5,并且 散列值 5小于等于 removedPosition 的值 6,因此我们将 Sue 复制到位置 6,并删除位置 12 的 Sue。位置 12现在空闲了。下一个位置也被占据了,那么我们来验证下一个位置。
(8) 我们来到位置 13,此处保存了 Aethelwulf,它的散列值为 5。散列值 5小于等于散列 值 5,并且散列值 5小于等于 removedPosition 的值 12,因此我们需要将 Aethelwulf 复制 到位置 12并删除位置 13的值。位置 13现在空闲了。下一个位置也被占据了,那么我们来验证 下一个位置。
(9) 我们来到位置 14,此处保存了 Sargeras,散列值为 10。散列值 10大于 Aethelwulf 的散列值 5,但是散列值 10小于等于 removedPosition 的值 13,因此我们要将 Sargeras 复 制到位置 13并删除位置 14的值。位置 14现在空闲了。下一个位置也是空闲的,那么本次执行 完成了。
创建更好的散列函数
我们实现的 lose lose散列函数并不是一个表现良好的散列函数,因为它会产生太多的冲突。一个表现 良好的散列函数是由几个方面构成的:
- 插入和检索元素的时间(性能)
- 较低的冲突可能性
djb2HashCode(key) {
const tableKey = this.toStrFn(key); // 将键转化为字符串
let hash = 5381; // 将hash变量赋值为一个质数
for (let i = 0; i < tableKey.length; i++) { // 迭代参数key
hash = (hash * 33) + tableKey.charCodeAt(i); // 将hash与33相乘,并和当前迭代的字符的ASCII码值相加
}
return hash % 1013; // 将使用相加的和与另一个随机质数相除的语速,比我们认为的散列表大小要大
}
ES2015Map类
先来看看原生map类怎么用。
const map = new Map();
map.set('Gandalf', 'gandalf@email.com');
map.set('John', 'johnsnow@email.com');
map.set('Tyrion', 'tyrion@email.com');
console.log(map.has('Gandalf')); // true
console.log(map.size); // 3
console.log(map.keys()); // 输出{"Gandalf", "John", "Tyrion"}
console.log(map.values()); // 输出{"gandalf@email.com", "johnsnow@email.com", "tyrion@email.com"}
console.log(map.get('Tyrion')); // tyrion@email.com
ES2015 的 Map 类的 values 方法和 keys 方法都返回 Iterator,,而不是值或键构成的数组。另一个区别是,我们实现的 size 方法 返回字典中存储的值的个数,而 ES2015的 Map 类则有一个 size 属性。
ES2015 WeakMap类和 weakSet类
Map 和 Set 与其弱化版本之间仅有的区别是:
- WeakSet 或 WeakMap 类没有 entries、keys 和 values 等方法;
- 只能用对象作为键。
创建和使用这两个类主要是为了性能。WeakSet 和 WeakMap 是弱化的(用对象作为键), 没有强引用的键。这使得 JavaScript的垃圾回收器可以从中清除整个入口。
另一个优点是,必须用键才可以取出值。这些类没有 entries、keys 和 values 等迭代器方法,因此,除非你知道键,否则没有办法取出值。
使用 WeakMap 类的例子如下。
const map = new WeakMap();
const ob1 = { name: 'Gandalf' }; //
const ob2 = { name: 'John' };
const ob3 = { name: 'Tyrion' };
map.set(ob1, 'gandalf@email.com'); //
map.set(ob2, 'johnsnow@email.com');
map.set(ob3, 'tyrion@email.com');
console.log(map.has(ob1)); // true
console.log(map.get(ob3)); // tyrion@email.com
map.delete(ob2);
WeakMap 类也可以用 set 方法,但不能使用数、字符串、布尔值等基本数据类型, 需要将名字转换为对象。
同样的逻辑也适用于 WeakSet 类。
~~完结~~
参考文献:
《学习Javascript数据结构算法》(第3版) 【巴西】 洛伊安妮 · 格罗纳 著