【阅读笔记:散列表】Javascript任何对象都是一个散列表(hash表)!

时间:2024-03-07 21:00:16

什么是散列表?

  • 散列表是Dictionary(字典)的一种散列表实现方式,字典传送门
  • 一个很常见的应用是使用散列表来表示对象。Javascript语言内部就是使用散列表来表示每个对象。此时,对象的每个属性和方法(成员)被存储为key对象类型,每个key指向对应的对象成员。
  • 字典中使用的电子邮件地址簿为例。我们将使用最常见的散列函数:lose lose散列函数,方法是简单的将每个键值中的每个字符的ASCII值相加,如下图所示:

创建散列表

class HashTable {
this.table = {};
}

实现几个简单方法

  1. toStrFn() 转字符串 和字典中一样
toStrFn (key){
    if (key === null) {
    return \'NULL\';
  } else if (key === undefined) {
    return \'UNDEFINED\';
  } else if (typeof key === \'string\' || key instanceof String) {
    return `${key}`;
  }else if ( Object.prototype.toString.call({})===\'[object Object\'] ){
    return JSON.stringify(obj)
  }
  return key.toString();
    }
  1. hashCode(key) 创建散列函数
loseloseHashCode(key) {
  if (typeof key === \'number\') { // 检验key是否是一个数字
  return key; 
  } 
  const tableKey = this.toStrFn(key); // 将 key 转换为一个字符串
  let hash = 0; // 创建一个hash变量
  for (let i = 0; i < tableKey.length; i++) { // 迭代转为字符串后的key
  hash += tableKey.charCodeAt(i); // 从ASCII表中查到的每个字符对应的 ASCII 值加到 hash 变量中
  } 
  return hash % 37; // 返回hash值。为了得到比较小的值,使用hash值和任意数取余(规避超过最大表示范围的风险,暂时有坑!!!)
} 

hashCode(key) { //hashCode 方法简单地调用了 loseloseHashCode 方法,将 key 作为参数传入
  return this.loseloseHashCode(key); 
}
  1. put(key,value) 将键和值加入散列表
put(key, value) {
 if (key != null && value != null) { // 检验 key 和 value 是否合法,如果不合法就返回 false
  const position = this.hashCode(key); // 根据给出的key,在表中找到一个位置
  this.table[position] = new ValuePair(key, value); // 用 key 和 value 创建一个 ValuePair (此实例和字典中的一样)实例
  return true; 
  } 
} 
  return false;
  1. get(key)从散列表中获取一个值
get(key) { 
 const valuePair = this.table[this.hashCode(key)]; 
 return valuePair == null ? undefined : valuePair.value; 
}
  1. remove(key) 从散列表中移除一个值
remove(key) {
 const hash = this.hashCode(key); // 获取hash
 const valuePair = this.table[hash]; // 获取值
 if (valuePair != null) { // 如果有值
 delete this.table[hash]; // 删除它
 return true; // 返回true
 } 
 return false; // 如果没找到对应的值,返回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\'); // 19 - Gandalf
console.log(hash.hashCode(\'John\') + \' - John\'); // 29 - John
console.log(hash.hashCode(\'Tyrion\')+\' - Tyrion\'); // 16 - Tyrion

console.log(hash.get(\'Gandalf\')); // gandalf@email.com 
console.log(hash.get(\'Loiane\')); // undefined 由于 Loiane 是一个不存在的键,所以返回会是 undefined(即不存在)。

hash.remove(\'Gandalf\'); 
console.log(hash.get(\'Gandalf\')); // undefined

处理散列表中的冲突(解决上面的坑)

  • 有时候,一些键会有相同的散列值。不同的值在散列表中对应相同位置的时候,我们称其为 冲突。来看一下下面代码的输出结果:
const hash = new HashTable();
hash.put(\'Jonathan\', \'jonathan@email.com\'); 0
hash.put(\'Jamie\', \'jamie@email.com\'); 

通过对每个提到的名字调用 hash.hashCode 方法,输出结果如下。
5 - Jonathan 
5 - Jamie
  • Jonathan和Jamie有相同的散列值5。
  • 由于 Jamie是最后一个被添加的,它将是在 HashTable 实例中占据位置 5 的元素。
  • 如果调用Hash.get(Jonathan)后输出的是\'jonathan@email.com\'还是\'jamie@email.com\'呢?
  • 有两种处理冲突的方法:分离链接和线性探查。

分离链接

  • 分离链接法包括为散列表的每一个位置创建一个链表并将元素存储在里面。它是解决冲突的 最简单的方法,但是在 HashTable 实例之外还需要额外的存储空间。
  • 重写一下三个方法:put、get和remove。
  1. put()
put(key, value) { 
 if (key != null && value != null) {
     const position = this.hashCode(key);
     if (this.table[position] == null) { // 判断新元素的位置是否已被占据
         this.table[position] = new LinkedList(); // 初始化一个 LinkedList 类(链表类的实现方法见链表传送门)的实例
     } 
     this.table[position].push(new ValuePair(key, value)); // 向链表中添加一个ValuePair实例
     return true; 
 } 
 return false; 
}

链表传送门

  1. get()
get(key) { 
  const position = this.hashCode(key); // 转化hash值
  const linkedList = this.table[position]; // 获取hash对应的地址
  if (linkedList != null && !linkedList.isEmpty()) { // 如果链表实例存在
      let current = linkedList.getHead(); // 如果有,获取链表头的引用地址
      while (current != null) { // 迭代到最后
          if (current.element.key === key) { // 找到key值与传入key相同的
            return current.element.value; // 返回value值
          }
          current = current.next; // 如果key值与传入key不同,再往下找
      } 
  } 
  return undefined; // 如果链表实例不存在,返回undefined
}
  1. remove()
remove(key) { 
  const position = this.hashCode(key);  // 转化hash值
  const linkedList = this.table[position];  // 获取hash对应的地址
  if (linkedList != null && !linkedList.isEmpty()) { // 如果链表实例存在 
        let current = linkedList.getHead();  // 如果有,获取链表头的引用地址
        while (current != null) { // 迭代到最后
            if (current.element.key === key) { // 找到key值与传入key相同的
              linkedList.remove(current.element); // 使用 remove 方法将其从链表中移除
              if (linkedList.isEmpty()) { // 删除后如果空了
                delete this.table[position]; // 也要在散列表中的位置删除
              }
              return true; // 返回 true 表示该元素已经被移除
            } 
            current = current.next; // 如果key值与传入key不同,再往下找
        } 
    } 
    return false; // 返回false表示该元素在散列表中不存在
}

线性探查

  • 另一种解决冲突的方法是线性探查。之所以称作线性,是因为它处理冲突的方法是将元素直 4 接存储到表中,而不是在单独的数据结构中。
  • 当想向表中某个位置添加一个新元素的时候,如果索引为 position 的位置已经被占据了,就尝试 position+1 的位置。如果 position+1 的位置也被占据了,就尝试 position+2 的位 置,以此类推,直到在散列表中找到一个空闲的位置。
  • 想象一下,有一个已经包含一些元素的散列表,我们想要添加一个新的键和值。我们计算这个新键的 hash,并检查散列表中对应的位置 是否被占据。如果没有,我们就将该值添加到正确的位置。如果被占据了,我们就迭代散列表, 直到找到一个空闲的位置。
  • 同样的也需要重写一下三个方法
  1. put()
put(key, value) { 
  if (key != null && value != null) { // 检验传入的key和value是否有效
      const position = this.hashCode(key);  // 获取hash值
      if (this.table[position] == null) { // 如果这个hash值的位置没有元素存在
        this.table[position] = new ValuePair(key, value); // 直接等于一个ValuePair实例就好了
      } else {  // 反之,不存在
          let index = position + 1; // 先创建一个变量,等于hash值加一
          while (this.table[index] != null) { // 迭代,直到找到一个空位置
            index++;
          } 
          this.table[index] = new ValuePair(key, value); // 在这个空位置处放入一个ValuePair实例
      } 
      return true; 
  } 
  return false; 
}
  1. get()
get(key) {  const position = this.hashCode(key); 
  if (this.table[position] != null) { // 确定这个键存在
      if (this.table[position].key === key) { // 如果这个值没变动过
        return this.table[position].value; // 直接返回该位置的value值
      } 
      while (this.table[index] != null && this.table[index].key !== key) { // 如果这个值改变了,就从下一个位置继续迭代,直到找到要找的元素或者空位置
        let index = position + 1;
        index++; 
      } 
      if (this.table[index] != null && this.table[index].key === key) { //当跳出循环时,如果该位置不是空并且它的key和传入的key相同,返回它的value
        return this.table[position].value;
      } 
      return undefined; // {8} 
  } 
}
  1. remove() 和get方法基本相同

verifyRemoveSideEffect(key, removedPosition) { // 该函数用于在删除后把添加时移动的值移回原位置,接收两个值:被删除的 key 和该 key 被删除的位置。
  const hash = this.hashCode(key); // 获取被删除的 key 的 hash 值
  let index = removedPosition + 1; // 创建一个变量,等于删除位置+1
  while (this.table[index] != null) { // 迭代 直到找到空位置
  const posHash = this.hashCode(this.table[index].key); // 迭代时当前位置上元素的 hash 值
  if (posHash <= hash || posHash <= removedPosition) { // 如果当前元素的hash值小于等于原始的值或者小于等于删除key的hash值,就需要把它移动到删除的位置
  this.table[removedPosition] = this.table[index];
  delete this.table[index]; // 再删除它当前元素(因为它已经被复制到删除的位置了)
  removedPosition = index;  // 再把变量更新为新删除的位置,重复,直到有空位置
  } 
  index++; 
  } 
}

remove(key) { 
  const position = this.hashCode(key); 
  if (this.table[position] != null) {  // 判断该位置是否有值
      if (this.table[position].key === key) { // 如果该位置的key等于传入的key
          delete this.table[position]; // 删除该值
          this.verifyRemoveSideEffect(key, position); // 删除后把原来属于该位置的值挪回来
          return true; 
      } 
      let index = position + 1; // 如果该位置的key不等于传入的key,证明被移动过
      while (this.table[index] != null && this.table[index].key !== key ) { // 迭代
        index++; 
      } 
      if (this.table[index] != null && this.table[index].key === key) {// 如果该位置不为空,并且它的key等于传入的key
          delete this.table[index]; // 删除
          this.verifyRemoveSideEffect(key, index); // 挪回来
          return true; 
      } 
  } 
  return false; 
}

创建更好的散列函数

  • 我们实现的散列函数并不是一个表现良好的散列函数,因为它会产生太多的冲突。 一个表现良好的散列函数是由几个方面构成的:
    • 插入和检索元素的时间(即性能)
    • 较低的冲突可能性。
  • 另一个可以实现的更好的散列函数:
djb2HashCode(key) {
  const tableKey = this.toStrFn(key); // 先将键转化为字符串
  let hash = 5381; // 初始化一个hash变量并复制为一个质数(大多数实现都使用 5381)
  for (let i = 0; i < tableKey.length; i++) { // 迭代key
    hash = (hash * 33) + tableKey.charCodeAt(i); // 将hash与33相乘再加上当前迭代到的字符的 ASCII码
  } 
  return hash % 1013; // 最后,我们将使用相加的和与另一质数(1013)相除后的余数
}

ES2015 Map 类

  • 和我们的 Dictionary 类不同,ES2015 的 Map 类的 values 方法和 keys 方法都返回 Iterator,而不是值或键构成的数组。
  • 另一个区别是,我们实现的 size 方法返回字典中存储的值的个数,而 ES2015 的 Map 类则有一个 size 属性。
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

map.delete(\'Gandalf\');
console.log(map.has(\'Gandalf\')); // false 

ES2105 WeakMap 类和 WeakSet 类

  • 除了 Set 和 Map 这两种新的数据结构,ES2015还增加了它们的弱化版本,WeakSet 和 WeakMap。
  • WeakSet 或 WeakMap 类没有 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\'); //// WeakMap 类也可以用 set方法,但不能使用数、字符串、布尔值等基本数据类型,需要将名字转换为对象
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); // 删除
console.log(map.has(ob2)); // false