在上一章中,我们讨论了哈希表的直接定址法,但是这种哈希函数的设计理论,仅仅适合关键字连续分布的情况,如果关键字不连续,则会造成空间浪费。
例如,一个班级在大一的时候,有 100 个学生,学号是 1001 到 1100。但是随着后续的发展变化,会经历分班、转专业、休学、退学等情况,导致学号空缺比较多。此时,如果我们依然使用直接定址法,那么就会造成空间的大量浪费。
因此,我们期望设计一个哈希函数,能够在不连续的学号中,将学号尽可能的映射到有限数组的连续空间中。
那么针对这种情况,一个比较常用的哈希函数是:除留余数法,他的核心就是用一个关键字都整数,除以一个整数,用得到的余数去映射数组的下标
因此,这里我们以数组的长度作为除数,用关键字对数组长度取余,得到的结果就是数组的下标。
1function hash(key: number, length: number) {2return key % length;3}
由于我们这里的关键字学号是四位数,数组长度为 8,因此,我们可以针对实际情况对哈希函数进行调整,例如:
1function hash(key: number) {2return (key - 1000) % 8;3}
代入这个哈希函数,我们可以得到如下图所示的分布结果
我们可以看到,由于数组的空间有限,但是学生的个数比较多,因此,必然会有多个学生的学号映射到同一个数组下标中, 例如上图中的 1001 和 1033 都映射到了下标为 1 的位置,这就是哈希碰撞。
很明显,哈希碰撞是一个无法正常使用的问题。因此,我们需要在此基础之上,进一步改变数据的存储方式,来解决冲突问题。
在哈希碰撞的情况之下,我们需要区分两种情况,
我们上面的图例演示了当输入量大于数组的存储空间时的情况,此时,同一个数组下标可能会映射到多个输入值。我们可以使用拉链法来解决冲突,也就是说,多个映射到同一个下标地址的输入,通过链表的形式存储在同一个数组下标的位置中
在这个基础之上,为了回顾之前学过的数据结构,我们这里使用环形链表来存储映射同一个下标的多个数据。
首先定义一个链表节点的格式,每一个节点上存储一个键值对,与链表指针
1type HNode<V> = {2// 由于案例单一,因此这里我们将 key 的类型强制限定为 number3key: number,4value: V,5next: HNode<V> | null6}
这里之所以需要使用环形链表,是因为在插入新的链表节点时,我们希望直接插入到链表尾部,但是单向链表要拿到最后一个节点的引用需要依次向后查询,这非常不方便,而我们使用环形链表可以通过指向尾部节点的方式,快速拿到尾部节点和头部节点,从而简化查询这个过程
除此之外,使用环形链表更多的用意是基于学习的目的,如果在逻辑中,本身就需要一个查询过程,例如在插入节点时判断链表上是否存在重复节点,就需要遍历链表,那么此时就可以使用单链表在共用这个逻辑去找到尾节点
然后,我们定一个头部指针指向链表的尾部节点,存储在数组中
1type Queue<V> = {2length: number,3last: HNode<V> | null4}
由于每一个数组节点都有可能存储一个链表,因此,我们可以把每一个数组节点看成是一个存储桶 bucket
,因此,在定义 HashMap
类时,我们可以先简单写这样一个代码出来,把基础属性和需要的 API 先标记好
10export default class HashMap<V> {20private buckets: Queue<V>[];30private size: number;4050constructor(size = 32) {60this.buckets = new Array(size);70this.size = size;80}9010// 哈希函数11hash(key: number) {12return (key - 1000) % this.size;13}1415// 插入键值对16set(key: number, value: V) {17...18}1920// 获取值21get(key: number) {22...23}2425// 删除键值对26delete(key: number) {27...28}2930// 检查是否包含键31has(key: number) {32...33}3435// 获取所有键36keys() {37...38}3940// 获取所有值41values() {42...43}4445// 清空哈希表46clear() {47this.buckets = new Array(this.size);48}49}
哈希函数我们使用除留余数法已经写好。接下来要考虑把节点存入数组中的情况。构建完成之后,我们期望得到的链表结构如下所示
第一种情况,初始化时,对应数组位置里面什么东西都没有,此时,我们需要创建 queue 对象,并 queue.last
指向新节点
第二种情况,链表节点全部被删除,只剩下 queue
节点,此时我们只需要创建新的节点即可
最终得到一个只有一个节点的链表,首尾节点都是自己
第三种情况,往链表尾节点之后添加新的节点。这里需要注意的是,queue.last
指向的是尾节点,因此指针的变化要小心理解错
再新增一个节点
再新增一个节点
环形链表往尾部新增节点理解起来比较困难,因此我这里多绘制了几张图,大家可以对比图例仔细揣摩。理解了新增节点情况与过程,我们的代码就很好写出来了
10// 插入键值对20set(key: number, value: V) {30const index = this.hash(key);40const bucket = this.buckets[index]5060const node: HNode<V> = {70key: key,80value: value,90next: null10}1112// 如果桶是空的,初始化一个链表13if (!bucket) {14node.next = node15this.buckets[index] = {16length: 1,17last: node18};19return20}2122const last = bucket.last2324if (!last) {25node.next = node26bucket.last = node27return28}2930const first = last.next3132// 检查是否已存在相同的key33let current = last34while(true) {35if (current.key === key) {36current.value = value37break38}39current = current.next!40if (current === bucket.last) {41break42}43}4445// add the node to last46last.next = node47node.next = first48bucket.last = node49bucket.length += 150}
完整理解了如何插入链表节点,那么获取和删除就比较简单了。我们直接通过完整代码学习即可
100type HNode<V> = {200key: number,300value: V,400next: HNode<V> | null500}600700type Queue<V> = {800length: number,900last: HNode<V> | null100}110120export default class HashMap<V> {130private buckets: Queue<V>[];140private size: number;150160constructor(size = 32) {170this.buckets = new Array(size);180this.size = size;190}200210// 哈希函数220hash(key: number) {230return (key - 1000) % this.size;240}250260// 插入键值对270set(key: number, value: V) {280const index = this.hash(key);290const bucket = this.buckets[index]300310const node: HNode<V> = {320key: key,330value: value,340next: null350}360370// 如果桶是空的,初始化一个链表380if (!bucket) {390node.next = node400this.buckets[index] = {410length: 1,420last: node430};440return450}460470const last = bucket.last480490if (!last) {500node.next = node510bucket.last = node520return530}540550const first = last.next560570// 检查是否已存在相同的key580let current = last590while(true) {600if (current.key === key) {610current.value = value620break630}640current = current.next!650if (current === bucket.last) {660break670}680}690700// add the node to last710last.next = node720node.next = first730bucket.last = node740bucket.length += 1750}760770// 获取值780get(key: number) {790const index = this.hash(key);800const bucket = this.buckets[index];810820if (!bucket) return undefined;830840if (!bucket.last) {850return undefined860}870880let current: HNode<V> = bucket.last890while(true) {900if (current.key === key) {910return current.value920}930current = current.next as HNode<V>940if (current === bucket.last) {950break960}970}980990return undefined;100}101102// 删除键值对103delete(key: number) {104const index = this.hash(key);105const bucket = this.buckets[index];106107if (!bucket) return false;108109let prev = bucket.last110let current = bucket.last111112if (current === null || prev === null) {113return false114}115116while(true) {117if (current.key === key) {118if (prev === current) {119bucket.last = null120} else {121let next = current.next122prev.next = next123}124return true125}126prev = current127current = current.next as HNode<V>128if (current === bucket.last) {129break130}131}132133return false;134}135136// 检查是否包含键137has(key: number) {138return this.get(key) !== undefined;139}140141// 获取所有键142keys() {143const keys = [];144for (const bucket of this.buckets) {145if (bucket && bucket.last) {146let current = bucket.last;147do {148keys.push(current.key);149current = current.next!;150} while (current !== bucket.last);151}152}153return keys;154}155156// 获取所有值157values() {158const values = [];159for (const bucket of this.buckets) {160if (bucket && bucket.last) {161let current = bucket.last;162do {163values.push(current.value);164current = current.next!;165} while (current !== bucket.last);166}167}168return values;169}170171// 清空哈希表172clear() {173this.buckets = new Array(this.size);174}175}176
在个别少数情况下,会有输入量关键字不连续,但是总体数量小于等于存储空间的。这种情况下我们可以使用开放定址法来解决冲突。这个方式的思路是遇到冲突则在数组中寻找下一个下标位置放下输入值。因为数组空间大于等于输入量,因此我们总能把所有的数据直接放到数组中去