JavaScript 数据结构(5)-字典和散列表

306 阅读14分钟

学习代码 git 仓库地址:gitee.com/zhangning18…

八、字典和散列表

上面学习了集合。这里继续学习使用字典和散列表来存储唯一值(不重复的值)的数据结构。集合中我们感兴趣的是每个值自身,并把它当作主要元素。在字典中,我们用键值对的形式来存储数据。在散列表中也是一样(也是以键值对的形式来存储数据)。但是两种数据结构的实现方式不同,例如字典中的每个键只能有一个值。

8.1 字典

集合表示一组互不相同的元素。在字典中 存储的是 键值对,键名用来查询特定元素的。字典和集合很相似,集合以 值-值 的形式存储元素,字典则是以 键-值 来存储元素。字典也称为 映射、符号表或关联数组。

在计算机中,字典经常用来保存对象的引用地址。例如:chrome 浏览器中 控制台上的 Memory 标签页,执行快照共功能,就能看到内存中的一些对象和他们对应的地址的引用(用 @<数>表示)。

8.1.1 创建字典类

与 set 类相似,ES 2015 同样包含了一个 Map 类的实现,就是要学习的字典

以 Map 类的实现为基础实现字典。与 Set 类很相似,但不同于 存储 [值, 值] 对的形式,我们将要存储的是 [键, 值] 对。

Dictionary.js 字典类

import {defaultToString} from '../utils.js';

// 作为 Dictionary 的值
class ValuePair {
  constructor(key, value) {
    this.key = key;
    this.value = value;
  }

  toString() {
    return `[#${this.key}: ${this.value}]`;
  }
}

export default class Dictionary {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    // 与 set 类类似,将在一个 Object 的实例而不是数组中存储字典中的元素。
    // 我们会将 [键, 值] 对保存为 table[key] = {key, value}
    this.table = {};
  }
  // ... 自定义方法
}

在字典中,理想的情况是使用 字符串作为键名,值可以是任何类型。但是由于 JS 不是强类型的语言,我们不能保证键一定是字符串。我们需要把所有作为键名传入的对象转化为字符串,使得从 Dictionary 类中搜索和获取值更简单。要实现这个功能,需要将 key 转化为字符串的函数 toStrFn。默认情况下使用 defaultToString 函数。

./utils.js

// key 转化为字符串
export function defaultToString(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();
}

需要注意,这里 item 如果是一个对象的话,就需要实现 toString 方法,否则会导致出现异常的输出结果,如 [object Object] 。这并不好。

8.1.2 检测一个键是否存在于字典中

先实现这个方法,因为它会被 set 和 remove 等别的方法调用。

  // 检测一个键是否存在字典里
  hasKey(key) {
    return this.table[this.toStrFn(key)] != null;
  }

JS 只允许使用字符串作为对象的键名或属性名,如果传入一个复杂对象作为键,需要将它转换为一个字符串,因此需要调用 toStrFn 函数,

8.1.3 设置 键值对

该方法可以用于添加新的值,或是更新已有的值。

  // 设置 键值对
  set(key, value) {
    if (key != null && value != null) {
      const tableKey = this.toStrFn(key);
      this.table[tableKey] = new ValuePair(key, value);
      return true;
    }
    return false;
  }

为了在字典中保存 value,将 key 转化为了字符串,而为了保存信息的重要,同样要保存原始的 key。因此我们不是只将 value 保存在字典中,而是要保存两个值:原始的 key 和 value。为了字典能更简单地通过 toString 方法输出结果,同样要为 ValuePair 类创建 toString 方法。

8.1.4 移除字典中的值

  // 移除字典中的数据
  remove(key) {
    if (this.hasKey(key)) {
      delete this.table[this.toStrFn(key)];
      return true;
    }
    return false;
  }

8.1.5 在字典中检索一个值

  // 在字典中 检索 值
  get(key) {
    const valuePair = this.table[this.toStrFn(key)];
    return valuePair == null ? undefined : valuePair.value;
  }

  // 在字典中 检索 值,第二种实现方式
  get2(key) {
    if (this.hasKey(key)) {
      return this.table[this.toStrFn[key]];
    }
    return undefined;
  }

第二种实现方式,我们会获取两次 key 的字符串以及访问两次 table 对象:第一次是在 hasKey 方法中,第二次是在 if 语句内。以上,第一种方式消耗更小资源。

8.1.6 keys、values 和 valuePairs 方法

  // 获取每个键的数组
  keys() {
    return this.keyValues().map(valuePair => valuePair.key);
  }

  // 获取每个值的数组,即 valuePairs 数组
  keyValues() {
    return Object.values(this.table);
  }

  // 获取每个 pairs 值的数组
  values() {
    return this.keyValues().map(valuePair => valuePair.value);
  }

8.1.7 用 forEach 迭代字典中的每个键值对

  // 迭代
  forEach(callbackFn) {
    const valuePairs = this.keyValues();
    for (let i = 0; i < valuePairs.length; i++) {
      const result = callbackFn(valuePairs[i].key, valuePairs[i].value);
      if (result === false) {
        break;
      }
    }
  }

获取字典中所有 valuePair 构成的数组,然后迭代每个 valuePair 并执行以参数形式传入 forEach 方法的 callbackFn 函数,保存他的结果。如果回调函数返回了 false,我们会中断 forEach 方法的执行,打断正在迭代 valuePairs 的for循环。

8.1.8 clear、size、isEmpty 和 toString 方法

  // 返回字典中的值的个数
  size() {
    return Object.keys(this.table).length;
    // 也可以通过调用 keyValues 方法并返回它所返回的数组长度
    // return this.keyValues().length;
  }

  // 检验字典是否为空
  isEmpty() {
    return this.size() === 0;
  }

  // 清空字典表
  clear() {
    this.table = {};
  }

  // toString 方法
  toString() {
    // 空返回字符串
    if (this.isEmpty()) {
      return '';
    }
    const valuePairs = this.keyValues();
    // 调用 valuePair 的 toString 方法来将第一个 valuePair 加入字符串,
    let objString = `${valuePairs[0].toString()}`;
    // 如果数组中还有值,继续加入字符串
    for (let i = 1; i < valuePairs.length; i++) {
      objString = `${objString},${valuePairs[i].toString()}`;
    }
    return objString;
  }

8.1.8 字典表的使用

const dictionary = new Dictionary();
dictionary.set('zz', 'zhangning')
dictionary.set('height', '187')
dictionary.set('six', '男')
dictionary.set('age', '24')
console.log(dictionary);
console.log(dictionary.hasKey('zz'));
console.log(dictionary.values());

打印结果:

8.2 散列表

这里实现 HashTable 类,也叫 HashMap 类,它是 Dictionary 类的一种散列表实现方式。

散列 算法的作用是尽可能快地在 数据结构中找到一个值。在之前的章节中,你已经知道如果要在数据结构中获得一个值(使用 get 方法),需要迭代整个数据结构来找到它,如果使用散列函数,就知道值的具体位置,因此能够快速检索到该值。散列函数的作用是给定一个键值,然后返回值在表中的地址。

散列表在计算机中应用的例子。散列表是字典的一种实现,可以用作关联数组。也可以用来对数据库进行索引。当我们在关系型数据库中创建一个新的表时,一个不错的做法是同时创建一个索引来更快地查询到记录的 key。在这种情况下,散列表可以用来保存键 和 对表中记录的引用。另一个很常见的应用是使用散列表来表示对象。JS 语言内部就是使用散列表来表示每个对象。这时对象的每个属性和方法被存储为 key 对象类型,每个 key 指向对应的对象成员。

以 email 邮件地址为例。使用最常见的散列函数--lose lose 散列函数,方法是简单地将每个键值中的每个字母的 ASCII 值相加:

8.2.1 创建散列表

通过使用一个关联数组(对象)来表示我们的数据结构

HashTable.js

class HashTable {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    this.table = {};
  }
}

主要在该类中实现 put、remove、get 三个基本方法

8.2.2 创建散列函数

在实现这三个方法之前,要先实现第一个方法--散列函数

  // 散列函数,就是将 key 中的每个字母对应的 ASCII 值相加
  loseloseHashCode(key) {
    if (typeof key === 'number') {
      return key;
    }
    ;
    // 防止 key 是一个对象而不是字符串,先转成字符串
    const tableKey = this.toStrFn(key);
    // hash 变量,存储总和
    let hash = 0;
    // 便利将每个 ASCII 相加
    for (let i = 0; i < tableKey.length; i++) {
      hash += tableKey.charCodeAt(i);
    }
    // 使用 hash 和一个任意数做触发的余数,可以得到比较小的数值,
    // 这可以规避操作数超过数值变量最大表示范围的风险。
    return hash % 37;
  }

  hashCode(key) {
    return this.loseloseHashCode(key);
  }

8.2.3 实现 put、get 方法

  // 向散列表添加元素
  put(key, value) {
    if (key != null && value !== null) {
      // 得到 散列值
      const position = this.hashCode(key);
      this.table[position] = new ValuePair(key, value);
      return true;
    }
    return false;
  }
  // 获取散列表中的值
  get(key) {
    const valuePair = this.table[this.hashCode(key)];
    return valuePair == null ? undefined : valuePair.value;
  }

HashTable 和 Dictionary 类很相似。不同之处在于在 Dictionary 类中,我们将 valuePair 保存在 table 的 key 属性中(在它被转化为字符串之后),而在 HashTable 类中,我们由 key(hash)生成一个数,并将 valuePair 保存在 hash 位置(或属性)。

8.2.4 从散列表中移除一个值

  // 移除一个值
  remove(key) {
    const hash = this.hashCode(key);
    const valuePair = this.table[hash];
    if (valuePair != null) {
      delete this.table[hash];
      // 除了使用 delete 运算符,还可以将删除的 hash 位置赋值为 null 或 undefined
      // this.table[hash] = null
      return true;
    }
    return false;
  }

8.2.5 简单的使用

const hashTable = new HashTable();
hashTable.put('zn', 'zhangning');
hashTable.put('age', 'zhangning24');
hashTable.put('six', 'zhangning男');
hashTable.put('nz', 'ningzhang');

console.log(hashTable);

8.2.6 散列表和散列集合

还有一种叫做散列集合的实现。散列集合由一个集合构成,但是掺入、移除和获取元素时,使用的是 hashCode 函数。我们可以复用本章中实现的所有代码来实现散列集合,不同之处在于,不再添加键值对,而是只插入值而没有键。例如,可以使用散列集合来存储所有的英语单词。和集合相似,散列集合只存储不重复的唯一值。

8.2.7 处理散列表中的冲突

有时候一些键会有相同的散列值('zn' 和 'nz')。不同的值在散列表中对应相同位置的时候,我们称其为 冲突

const hashTable = new HashTable();
hashTable.put('zn', 'zhangning');
hashTable.put('age', 'zhangning24');
hashTable.put('nz', 'ningzhang');
console.log(hashTable);

上面插入了三个值。打印结果是两个值,也就是第一个 zn 的值被 nz 给替换掉了。后面插入的值对之前插入的相同的 hashCode 值进行了覆盖。

处理上面的冲突有几种方法:分离链接、线性探查和双散列表法。

  1. 分离链接方法解决冲突

分离链接法包括为散列表的每一个位置创建一个链表并将元素存储在里面。它是解决冲突最简单的方法,但是在 HashTable 实例之外还需要额外的存储空间

就是 散列表的值是一个链表,如果连续插入相同的 hashCode 的key的值,就在链表后面继续添加。

对于 分离链接和线性探查来说,重写散列表的三个方法即可 put、get、remove.

/*
* @author: zhangning
* @date: 2022/3/20 16:33
* @Description: 分离链接法实现散列表的冲突
**/
import {defaultToString} from '../utils.js';
import LinkedList from '../../六、链表/1.封装链表/LinkedList.js';
import {ValuePair} from '../ValuePair.js';
import HashTable from '../2.散列表/HashTable.js';

class HashTableSeparateChaining extends HashTable {
  constructor(toStrFn = defaultToString) {
    super();
    this.toStrFn = toStrFn;
    this.table = {};
  }

  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);
      // 是不是第一次加入该 key
      if (this.table[position] == null) {
        this.table[position] = new LinkedList();
      }
      // 向链表中添加元素
      this.table[position].push(new ValuePair(key, value));
      return true;
    }
    return false;
  }

  get(key) {
    const position = this.hashCode(key);
    const linkedList = this.table[position];
    // 检测是否存在链表
    if (linkedList != null && !linkedList.isEmpty()) {
      // 获取链表第一个元素
      let current = linkedList.head;
      while (current != null) {
        // 找到链表中的元素并返回
        if (current.element.key === key) {
          return current.element.value;
        }
        current = current.next;
      }
    }
    return undefined;
  }

  remove(key) {
    const position = this.hashCode(key);
    const linkedList = this.table[position];
    // 检验链表是否存在
    if (linkedList != null && !linkedList.isEmpty()) {
      // 得到链表的第一个元素
      let current = linkedList.head;
      // 迭代链表找到我们要找的元素
      while (current != null) {
        // 匹配到当前元素
        if (current.element.key === key) {
          // 匹配到当前元素就把当前元素在链表中移除
          linkedList.remove(current.element);
          // 链表是空的,就删除
          if (linkedList.isEmpty()) {
            delete this.table[position];
          }
          return true;
        }
        current = current.next;
      }
    }
    return false;
  }
}

const table = new HashTableSeparateChaining();
table.put('zn', 'zhangning');
table.put('age', 'zhangning24');
table.put('nz', 'ningzhang');
console.log(table);

以上实现了散列表的冲突:打印的数据如下

  1. 线性探查法解决冲突

第二中解决冲突的方法就是 线性探查法:之所以称为线性,因为它处理冲突的方法是将元素直接存储到表中,而不是在单独的数据结构中。

当向表中添加一个新元素的时候,如果索引为 position 的位置已经被占据了,就尝试 position + 1的位置。如果 position + 1 的位置也被占据了,就尝试 position + 2 的位置,以此类推,直到在散列表中找到一个空闲的位置。有一个已经包含一些元素的散列表,我们想要添加一个新的键和值。我们计算这个新建的 hash,并检查散列表中对应的位置是否被占据。如果没有,我们就将该值添加到正确的位置。如果被占据了,我们就迭代散列表,直到找到一个空闲的位置。

当从散列表中移除一个键值对的时候,仅将数据结构所实现位置的元素移除是不够的。如果只是移除了元素,就可能在查找有相同 hash (位置)的其他元素时找到一个空的位置,这会导致算法出现问题。

线性探查技术分为两种。

第一种是 软删除 方法。我们使用了一个特殊的值(标记)来表示键值对被删除了(惰性删除或软删除),而不是真的删除它。经过一段时间,散列表被操作过后,我们会得到一个标记了若干删除位置的散列表。这会逐渐降低散列表的效率,因为搜索键值会随时间变得更慢。能快速访问并找到一个键是我们使用散列表的一个重要原因。

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

两种方法都有各自的优缺点,这里实现下第二种方法(移动一个或多个元素到之前的位置)

实现该方法,继续重写 三个方法

LineHashTable.js

import {defaultToString} from '../utils.js';
import {ValuePair} from '../ValuePair.js';
import HashTable from '../2.散列表/HashTable.js';


class LineHashTable extends HashTable {
  constructor(toStrFn = defaultToString) {
    super();
    this.toStrFn = toStrFn;
    this.table = {};
  }


  put(key, value) {
    if (key != null && value != null) {
      // 散列表的位置
      const position = this.hashCode(key);
      // 验证这个位置是否有元素存在
      if (this.table[position] == null) {
        // 添加
        this.table[position] = new ValuePair(key, value);
      } else {
        // 存在 找下一个位置是否被占用
        let index = position + 1;
        // 一直循环到没有被占用的位置为止,
        while (this.table[index] != null) {
          index++;
        }
        this.table[index] = new ValuePair(key, value);
      }
      return true;
    }
    return false;
  }

  // JavaScript 在数组上比其他语言要方便的多,可以自动改变数组大小,不需要自己定义数组大小了


  get(key) {
    // 先找位置
    const position = this.hashCode(key);
    // 判断是否存在
    if (this.table[position] != null) {
      // 判断是否在原始的位置上
      if (this.table[position].key === key) {
        return this.table[position].value;
      }
      // 不在原始位置上,们就找下一个元素
      let index = position + 1;
      // 一直找到相同的元素的位置 position,或者是空位置就跳过
      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(key) {
    const position = this.hashCode(key);
    // 是否存在元素
    if (this.table[position] != null) {
      // 当前元素下标就是当前元素
      if (this.table[position].key === key) {
        // 移除当前元素
        delete this.table[position];
        this.verifyRemoveSideEffect(key, position);
        return true;
      }
      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[index];
        this.verifyRemoveSideEffect(key, index);
        return true;
      }
    }
    return false;
  }

  // 被删除的 key 和 该 key 被删除的位置
  verifyRemoveSideEffect(key, removedPosition) {
    // 得到删除的 散列值
    const hash = this.hashCode(key);
    // 在下一个位置循环 散列表
    let index = removedPosition + 1;
    // 直到循环到 空 位置
    while (this.table[index] != null) {
      // 当前位置元素的 hash 值
      const posHash = this.hashCode(this.table[index].key);
      // 当前 hash 小于或等于原始值 或者
      // 当前元素的 hash 值小于或等于 removedPosition (被删除的位置),
      // 表示我们需要将当前元素移动至 removedPosition 的位置.移动完成后,
      // 可以删除当前的元素,因为它已经被复制到 removedPosition 的位置.一直重复这件事
      if (posHash <= hash || posHash <= removedPosition) {
        this.table[removedPosition] = this.table[index];
        delete this.table[index];
        removedPosition = index;
      }
      index++;
    }
  }
}

以上用两种方法解决了散列表的冲突。

8.2.8 创建更好的散列函数

上面实现了 lose lose 散列函数并不是一个表现良好的散列函数,因为它会产生太多的冲突。一个表现良好的散列表函数是由几个方面构成的:插入和检索元素的时间(性能),以及较低的冲突可能性。

另一个实现比 lose lose 更好的散列函数是 djb2,

  djb2HashCode(key) {
    // 将键转化为字符串
    const tableKey = this.toStrFn(key);
    // 初始化一个 hash 变量并赋值为一个质数,(大多数都用 5381)
    // 然后迭代参数 key,将 hash 与 33 相乘(用作一个幻数)
    // 并和当前迭代到的字符的 ASCII 码值相加
    let hash = 5381;
    for (let i = 0; i < tableKey.length; i++) {
      hash = (hash * 33) + tableKey[i].charCodeAt(i);
    }
    // 最后使用相加的和与另一个随机质数相除的余数,比我们认为的散列表大小要大,zh
    // 1013 即散列表比 1013 小
    return hash % 1013; // {5}
  }

该函数代替 hashCode ,最后会发现没有冲突,不用解决 散列表 产生的冲突。

这并不是最好的散列函数,但这是社区里面最推崇的散列表函数之一。

8.3 Map 类

ECMAScript 2015 新增了 Map 类。可以基于 ES 2015 的 Map 类开发 Dictionary 类。

基本可以完全实现

8.4 ES 2015 WeakMap 类和 WeakSet 类

除了 Set 和 Map 这两种新的数据结构,ES 2015 还增加了它们的弱化版本 WeakSet 和 WeakMap。

与 Map 和 Set 的区别是:

    1. WeakSet 或 WeakMap 类没有 entries、keys 和 values 等方法;
    2. 只能用对象作为键;

创建和使用这两个类主要是为了性能。WeakSet 和 WeakMap 是弱化的(用对象作为键),没有强引用的键。这使得 JavaScript 的垃圾回收器可以从中清楚整个入口。

另一个优点是:必须用键才可以取出值。这些类没有 entries、keys 和 values 等迭代器方法,因此,除非你知道键,否则没有办法取出值。上面学习栈的时候用到了 WeakMap 类封装私有属性,用到了这点。

WeakMap 类的使用

const map = new WeakMap();
const ob1 = {name: 'zz'};
const ob2 = {name: 'nn'};
const ob3 = {name: 'zn'};
map.set(ob1, 'zzzz');
map.set(ob2, 'nnnn');
map.set(ob3, 'zznn');
console.log(map.has(ob1));// true
console.log(map.get(ob3));// 'zznn'
map.delete(ob2);// true

WeakMap 类也可以用 set 方法,但不能使用数、字符串、布尔值等基本数据类型,需要将名字进行转换为对象

搜索、读取 和 删除,也要传入作为键的对象。

同样的逻辑也适用于 WeakSet 类

8.5 小结

在这里学习到了字典的相关知识点,了解到如何添加、移除和获取元素以及其他一些方法。我们还了解了字典和集合的不同之处。

还学到了散列运算,怎样创建一个散列表(或者说散列映射)数据结构,如何添加、移除和获取元素,以及如何创建散列函数。还了解到使用两种不同的方法解决散列表中的冲突。

最后了解了 Map、WeakMap 和 WeakSet 类