TypeScript 哈希表

文章目录

  • 概念
    • 哈希化
    • 冲突
      • 链地址法
      • 开放地址法
    • 装填因子(loadFactor)
    • 效率对比
    • 哈希函数
      • 字符串转数字算法 —— 幂的连乘
      • 压缩数字范围 —— 取余
      • 优秀哈希算法的优点
      • 快速计算:霍纳法则
      • 均匀分布 —— 质数
      • Java 中的 HashMap
      • N次幂的底数
  • 实现
    • 哈希函数
    • 哈希表
      • 插入/更新操作
      • 获取
      • 删除
      • 扩容、缩容
      • 判断一个数是否为质数
    • 完整代码

概念

哈希表通常底层实现是一个数组,它的特点就是弥补了数组查找、插入、删除慢的缺点。哈希表它就是最终存放数据的数组。但我们平时说哈希表,确切说是一种操作数组的方式,一种算法。

哈希表巧妙的地方就在于转换了思维角度。
之前搜索一个东西,我们不知道它位置,所有要一个一个找,为啥不知道位置?因为放的时候是随机放的。而哈希表是放的时候不允许随机放,直接就规定了位置,那去找的时候,不就按位置直接去拿即可。比如你知道苹果一定放第三个格子,现在你要找苹果,就会直奔第三个格子,而不是从第一个格子找起。

哈希化

那怎么确定苹果就是放第三个格子呢?
这就需要哈希函数来映射了,这个过程称为哈希化。

image.png

理想情况三个水果,算出来就放在三个对应的格子里,但现实很骨感。哈希函数的映射算法没这么完美,会出现只有三个水果,但算出来一个水果要放在第1000个格子的情况,因此中间会出现很多空格子浪费了。优化哈希算法,使之不要这么浪费是衡量哈希算法优劣的一个重要指标。

冲突

那会不会出现哈希函数设计不好,算出来苹果和梨都被放到第三个格子的情况呢?
存在的,通常有两种方式解决这个问题。

链地址法

可以在第三个格子里保存一个链表或数组,然后把苹果和梨都挂在链表上。这种方式称为链地址法。当然挂一个数组也可以,如果重复的数据实在太多,还可以挂一个二叉树。

套娃的这个链表或者数组,我们通常称为打水的吊桶 bucket。

有个误区要注意:设计哈希表的时候要明白,存储过程不是这个位置本是没内容的,然后放进一个内容,直到此处出现了冲突,这个位置才开始挂一个桶,然后新旧内容一起都放桶里。而是哈希表生成的时候,确实没内容,为 undefined,但第一个元素放进去的时候,此处就会生成一个桶存放数据,不用等到发生冲突才开始换成桶。

image.png

开放地址法

还可以让重复的元素去后面找空白的位置坐下,后面还有空位,老弟往后走。这是开发地址法。

至于怎么找后面的空位,又有三种方式:

  1. 线性探测:一个一个往后找
  2. 二次探测:也是线性探测,但是不是一个一个找,而是步长为 2,2、4、8… 跳着找。
  3. 再次hash

经过前人的验证,总的来说,链地址法效率是更高的,所以链地址法也用的最多。

装填因子(loadFactor)

装填因子是数据量与哈希表数组容量的比值,也就是哈希表装的满不满。
整这么个概念,主要是为哈希表的性能优化做一个指标以及作为是否自动扩容的依据。

我们知道完美的哈希表,装填因子应该是 1,三个水果,就放三个格子,没浪费没冲突。

但冲突和浪费都不可避免,开发地址法装填因子不会超过1,虽然它很好的利用了格子,可是多了找新地址的计算。
链地址法装填因子可能超过 1,装的数据比哈希表数组长度还多,因为它可以在里面挂一个超长的链。当然挂太多,效率肯定不高。

  • 一般装填因子小于 0.25 需要缩容,大于 0.75 需要扩容。

效率对比

下面的等式显示了线性探测时,探测序列§和填装因子(L)的关系。公式来自于Knuth(算法分析领域的专家,现代计算机的先驱人物)。

线性探测二次探测和再哈希化链地址法

经过上面的比较我们可以发现,链地址法相对来说效率是好于开放地址法的。

所以在真实开发中,使用链地址法的情况较多。因为它不会因为添加了某元素后性能急剧下降。比如在Java的HashMap中使用的就是链地址法。

哈希函数

说白了哈希化,也就是哈希函数的功能就是将字符串转成对应的数组下标,也就是数字。

那具体怎么做呢?

字符串转数字算法 —— 幂的连乘

现在我们需要设计一种方案,可以将单词转成适当的下标值:
 其实计算机中有很多的编码方案就是用数字代替单词的字符。就是字符编码。(常见的字符编码?)
 比如ASCII编码:a是97,b是98,依次类推122代表z
 我们也可以设计一个自己的编码系统,比如a是1,b是2,c是3,依次类推,z是26。
 当然我们可以加上空格用0代替,就是27个字符(不考虑大写问题)
 但是,有了编码系统后,一个单词如何转成数字呢?

方案一:数字相加
 一种转换单词的简单方案就是把单词每个字符的编码求和。
 例如单词cats转成数字:3+1+20+19=43,那么43就作为cats单词的下标存在数组中。

◼ 问题:按照这种方案有一个很明显的问题就是很多单词最终的下标可能都是43。
 比如was/tin/give/tend/moan/tick等等。
 我们知道数组中一个下标值位置只能存储一个数据
 如果存入后来的数据,必然会造成数据的覆盖。
 一个下标存储这么多单词显然是不合理的。
 虽然后面的方案也会出现,但是要尽量避免。

方案二:幂的连乘
 现在,我们想通过一种算法,让cats转成数字后不那么普通。
 数字相加的方案就有些过于普通了。
 有一种方案就是使用幂的连乘,什么是幂的连乘呢?
 其实我们平时使用的大于10的数字,可以用一种幂的连乘来表示它的唯一性:比如:7654 = 710³+610²+510+4
 我们的单词也可以使用这种方案来表示:比如 cats = 3
27³+127²+2027+17= 60337
 这样得到的数字可以基本保证它的唯一性,不会和别的单词重复。

两种方案总结:
 第一种方案(把数字相加求和)产生的数组下标太少。
 第二种方案(与27的幂相乘求和)产生的数组下标又太多。

第一种方案缺陷无解,第二种方案,我们可以采用数字压缩算法缓解。

压缩数字范围 —— 取余

除以几,就能把数字压缩到[0,这个数字 - 1]的范围。比如除以 10,就压缩到了 [0, 9] 的范围。

优秀哈希算法的优点

两点:

  1. 快速的计算:计算 hashcode 要快。
    • 提高速度的一个办法就是让哈希函数中尽量少的有乘法和除法。因为它们的性能是比较低的。
  2. 分布均匀,也就是冲突少。

前面我们已经知道,幂的连乘 和 取余操作实现哈希算法。但这还不够,还可以继续优化。

快速计算:霍纳法则

霍纳法则可以减少多项式中的乘法,转换成加法。

做法就是一直在提取公因式,提到不能再提为止。

以 abc 为例:
原本公式:a的编码 * 31^2 + b的编码 * 31^1 + c的编码 * 31^0

公因式就是幂底:31

霍纳法则:

  1. 31*(a*31 + b) + c
  2. 31*(31*(31*0 + a) + b) + c

上面提取到第二次已经无法再提了,用语言描述就是 hashcode 从 0 开始与幂底的积再加上字符串第一个字符的编码的和作为下一次的 hashcode,继续乘幂底与第二个字符的编码的和再次作为 hashcode 进入下一轮循环,直到加完所有的字符。

可以见到一个循环即可完成霍纳算法对多项式的计算。

const POWER_BASE = 31;
let hashcode = 0;
// 霍纳法则,计算hash值
for (let i = 0; i < key.length; i++) {hashcode = hashcode * POWER_BASE + key.charCodeAt(i);
}

霍纳法则

image.png

均匀分布 —— 质数

在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法。但是无论哪种方案,为了提供效率,最好的情况还是让数据在哈希表中均匀分布。

因此,我们需要在使用常量的地方,尽量使用质数。

质数的使用:

  • 哈希表的长度。
  • N次幂的底数(我们之前使用的是27)

为什么他们使用质数,会让哈希表分布更加均匀呢?
 质数和其他数相乘的结果相比于其他数字更容易产生唯一性的结果,减少哈希冲突。
 Java中的N次幂的底数选择的是31,是经过长期观察分布结果得出的;

Java 中的 HashMap

◼ Java中的哈希表采用的是链地址法。

◼ HashMap的初始长度是16,每次自动扩展,长度必须是2的次幂。
 这是为了服务于从Key映射到index的算法。60000000 % 100 = 数字。下标值

◼ HashMap中为了提高效率,采用了位运算的方式。
 HashMap中index的计算公式:index = HashCode(Key) & (Length - 1)
 比如计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001
 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111
 把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9

为什么 Java hashmap 中使用的数组长度不是质数,因为它使用了位运算,而不是取模。

N次幂的底数

◼ 这里采用质数的原因是为了产生的数据不按照某种规律递增。
 比如我们这里有一组数据是按照4进行递增的:0 4 8 12 16,将其映射到长度为8的哈希表中。
 它们的位置是多少呢?0 - 4 - 0 - 4,依次类推。
 如果我们哈希表本身不是质数,而我们递增的数量可以使用质数,比如5,那么 0 5 10 15 20
 它们的位置是多少呢?0 - 5 - 2 - 7 - 4,依次类推。也可以尽量让数据均匀的分布。
 我们之前使用的是27,这次可以使用一个接近的数,比如31/37/41等等。一个比较常用的数是31或37。

◼ 总之,质数是一个非常神奇的数字。

◼ 这里建议两处都使用质数:
 哈希表中数组的长度。
 N次幂的底数。

实现

哈希函数

/*** 哈希函数, 将key映射成index* @param key 转换的key* @param capacity 容量 (数组的长度)* @returns index 索引值*/
export function hashFun(key: string, capacity: number): number {const POWER_BASE = 31;let hashcode = 0;// 霍纳法则,计算hash值for (let i = 0; i < key.length; i++) {hashcode = hashcode * POWER_BASE + key.charCodeAt(i);}// 取余压缩范围return hashcode % capacity;
}

哈希表

export class HashTable<T = any> {// 哈希主表数组,采用拉链法,元素本是链表,这里以数组代替,并且元素类型为元祖,便于将 key 和 value 都存一起。// 简言之数组元素还是数组,这个元素数组里元素为元祖类型。private store: [string, T][][] = [];// 装填因子:自动扩容依据// 容量private capacity: number = 7;// 装载数private count: number = 0;// 哈希函数private hashFun(key: string, capacity: number): number {const POWER_BASE = 31;let hashcode = 0;// 霍纳法则,计算hash值for (let i = 0; i < key.length; i++) {hashcode = hashcode * POWER_BASE + key.charCodeAt(i);}// 取余压缩范围return hashcode % capacity;}
}

插入/更新操作

哈希表的插入和修改操作是同一个函数:
 因为,当使用者传入一个<Key,Value>时
 如果原来不存该key,那么就是插入操作。
 如果已经存在该key,那么就是修改操作。

// 插入/更新
put(key: string, value: T) {const index = this.hashFun(key, this.capacity);// 获取对应下标的数组const bucket = this.store[index];if (!bucket) {// 插入this.store[index] = [[key, value]];this.count++;} else {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {// 更新tuple[1] = value;return;}}// 插入bucket.push([key, value]);this.count++;}
}

获取

// 获取值
get(key: string): T | null {const index = this.hashFun(key, this.capacity);const bucket = this.store[index];if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {return tuple[1];}}}return null;
}

删除

// 删除
remove(key: string): T | null {const index = this.hashFun(key, this.capacity);const bucket = this.store[index];if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {bucket.splice(i, 1);this.count--;return tuple[1];}}}return null;
}

扩容、缩容

修改容量有两步操作:

  1. 修改容量
  2. 把之前的数据再次哈希化计算一次,放进新数组
private MIN_CAPACITY = 7;
private MIN_LOAD_FACTOR = 0.25;
private MAX_LOAD_FACTOR = 0.75;
private capacity: number = this.MIN_CAPACITY;private resize(newCapacity: number) {const oldStore = this.store;// 1. 构建新数组this.store = [];this.capacity = newCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : newCapacity;this.count = 0;// 2. 旧数据哈希化放入新数组oldStore.forEach(bucket => {if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];this.put(tuple[0], tuple[1]);}}});
}

修改插入和删除方法:

// 插入/更新
put(key: string, value: T): void {const index = this.hashFun(key, this.capacity);// 获取对应下标的数组const bucket = this.store[index];if (!bucket) {// 插入this.store[index] = [[key, value]];this.count++;} else {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {// 更新tuple[1] = value;return;}}bucket.push([key, value]);this.count++;}// 自动扩容if (this.loadFactor > this.MAX_LOAD_FACTOR) this.resize(this.capacity * 2);
}// 删除
remove(key: string): T | null {const index = this.hashFun(key, this.capacity);const bucket = this.store[index];if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {bucket.splice(i, 1);this.count--;// 缩容if (this.loadFactor < this.MIN_LOAD_FACTOR && this.capacity > this.MIN_CAPACITY) {this.resize(Math.floor(this.capacity / 2));}return tuple[1];}}}return null;
}

但现在有个问题,扩容和缩容都是按照 2 倍来做的,那容量不就不是质数了吗,这不利于保证元素均匀分布呀?
是的,因此容量不能简单设为 2 倍,而是应该通过一个算法找附近的质数。

判断一个数是否为质数

质数表示大于1的自然数中,只能被1和自己整除的数。
因此让这个数字 n 从 2 开始除,一直除到小于根号 n 的最大整数即可。如果除尽了,说明 n 不是质数。

怎么判断除没除尽?
用取余,有余数说明没除尽。

/*** 判断数字是否为质数* @param num * @returns boolean*/
export function isPrime(num: number) {if (num < 2) return false;for (let i = 2; i <= Math.sqrt(num); i++) {if (num % i === 0) return false;}return true;
}

保证容量为质数,并且当缩容后的容量小于最小容量时,保持最小容量:

// 判断质数
private isPrime(num: number) {if (num < 2) return false;for (let i = 2; i <= Math.sqrt(num); i++) {if (num % i === 0) return false;}return true;
}// 获取质数
private getPrime(num: number) {while (!this.isPrime(num)) {num++;}return num;
}private resize(newCapacity: number) {const primeCapacity = this.getPrime(newCapacity);const oldStore = this.store;this.store = [];// 最小容量为底线this.capacity = primeCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : primeCapacity;this.count = 0;...
}

完整代码

  • 注:数组实现的 bucket,并非链表。
export class HashTable<T = any> {// 哈希主表数组,采用拉链法,元素本是链表,这里以数组代替,并且元素类型为元祖,便于将 key 和 value 都存一起。// 简言之数组元素还是数组,这个元素数组里元素为元祖类型。private store: [string, T][][] = [];// 容量private MIN_CAPACITY = 7;private MIN_LOAD_FACTOR = 0.25;private MAX_LOAD_FACTOR = 0.75;private capacity: number = this.MIN_CAPACITY;// 装载数private count: number = 0;// 装填因子:自动扩容依据private get loadFactor() {return this.count / this.capacity;}// 哈希函数private hashFun(key: string, capacity: number): number {const POWER_BASE = 31;let hashcode = 0;// 霍纳法则,计算hash值for (let i = 0; i < key.length; i++) {hashcode = hashcode * POWER_BASE + key.charCodeAt(i);}// 取余压缩范围return hashcode % capacity;}// 判断质数private isPrime(num: number) {if (num < 2) return false;for (let i = 2; i <= Math.sqrt(num); i++) {if (num % i === 0) return false;}return true;}// 获取质数private getPrime(num: number) {while (!this.isPrime(num)) {num++;}return num;}private resize(newCapacity: number) {const primeCapacity = this.getPrime(newCapacity);const oldStore = this.store;// 1. 构建新数组this.store = [];this.capacity = primeCapacity < this.MIN_CAPACITY ? this.MIN_CAPACITY : primeCapacity;this.count = 0;// 2. 旧数据哈希化放入新数组oldStore.forEach(bucket => {if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];this.put(tuple[0], tuple[1]);}}});}// 插入/更新put(key: string, value: T): void {const index = this.hashFun(key, this.capacity);// 获取对应下标的数组const bucket = this.store[index];if (!bucket) {// 插入this.store[index] = [[key, value]];this.count++;} else {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {// 更新tuple[1] = value;return;}}bucket.push([key, value]);this.count++;}// 自动扩容if (this.loadFactor > this.MAX_LOAD_FACTOR) this.resize(this.capacity * 2);}// 获取值get(key: string): T | null {const index = this.hashFun(key, this.capacity);const bucket = this.store[index];if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {return tuple[1];}}}return null;}// 删除remove(key: string): T | null {const index = this.hashFun(key, this.capacity);const bucket = this.store[index];if (bucket) {for (let i = 0; i < bucket.length; i++) {const tuple = bucket[i];if (tuple[0] === key) {bucket.splice(i, 1);this.count--;// 缩容if (this.loadFactor < this.MIN_LOAD_FACTOR && this.capacity > this.MIN_CAPACITY) {this.resize(Math.floor(this.capacity / 2));}return tuple[1];}}}return null;}
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/511940.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Unity 摄像机的深度切换与摄像机画面投影

摄像机可选&#xff1a;透视、正交 正交类似投影&#xff0c;1比1 透视类似人眼&#xff0c;近大远小 摄像机投影 在项目中新建&#xff1a;渲染器纹理 将新建纹理拖动到相机的目标纹理中 新建一个平面&#xff0c;将新建材质组件放到平面中即可。 相机深度切换 使用代…

年货商家告诉你!短信API为何能赋能撬动市场的增量空间?

&#x1f680; 短信API&#xff1a;年货电商的心头好 当一年一度的年货大战打响的时候&#xff0c;作为卖家的你是不是还在苦恼如何高效传递信息给消费者&#xff1f;那就不得不借助堪称商家们的营销神器——短信API&#xff0c;也就是短信应用接口。它让开发者能够将短信发送功…

[最佳实践] conda环境内安装cuda 和 Mamba的安装

Mamba安装失败的过程中&#xff0c;causal-conv1d安装报错为连接超时 key word: vision mamba&#xff0c; DL &#xff0c;深度学习 &#xff0c;mamba unet&#xff0c;mamba环境安装 Mamba安装 主要故障是 pip install causal-conv1d1.2.0和 pip install mamba-ssm1.2.0 安…

基于java的母婴商城系统设计与实现

摘 要 现代经济快节奏发展以及不断完善升级的信息化技术&#xff0c;让传统数据信息的管理升级为软件存储&#xff0c;归纳&#xff0c;集中处理数据信息的管理方式。本母婴商城系统就是在这样的大环境下诞生&#xff0c;其可以帮助管理者在短时间内处理完毕庞大的数据信息&a…

如何做代币分析:以 CRO 币为例

作者&#xff1a;lesleyfootprint.network 编译&#xff1a;Mingfootprint.network 数据源&#xff1a;CRO Token Dashboard &#xff08;仅包括以太坊数据&#xff09; 在加密货币和数字资产领域&#xff0c;代币分析起着至关重要的作用。代币分析指的是深入研究与代币相关…

一个完整的Flutter项目的基本构成

目录 1.页面跳转2.本地数据库和读取2.1 在pubspec.yaml中添加数据库框架依赖2.2 创建db.dart 初始化数据库并创建表2.3 安装JsonToDart插件2.4 创建实体类 user_bean.dart2.5 增删改查&#xff1a; 3.网络请求数据解析UI渲染 本篇主要总结下一个完整的Flutter项目有哪些基本构成…

常用“树”数据结构

哈夫曼树 在许多应用中&#xff0c;树中结点常常被赋予一个表示某种意义的数值&#xff0c;称为该结点的权。从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积&#xff0c;称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度&am…

hot100 -- 普通数组

目录 &#x1f382;最大子数组和 O(n) 暴力 O(n) 动态规划 &#x1f6a9;合并区间 O(nlogn) 排序 &#x1f33c;轮转数组 O(n) 辅助数组 O(n) 环状替换 O(n) 数组翻转 &#x1f33c;除自身以外数组的乘积 O(n) 前缀和 时间O(n) 空间O(1) &#x1f319;缺失的…

波奇学Linux:信号的产生:异常和软件条件

异常与信号 进程异常的本质是程序收到操作系统信号 eg&#xff1a;除0让进程收到异常信号&#xff0c;kill掉进程&#xff0c;使得进程崩溃 进程收到异常信号不一定会退出&#xff0c;对出程序异常&#xff0c;捕捉信号&#xff0c;进程可能不退出。 操作系统如何知道除0操作…

javaWebssh题库管理系统myeclipse开发mysql数据库MVC模式java编程计算机网页设计

一、源码特点 java ssh题库管理系统是一套完善的web设计系统&#xff08;系统采用ssh框架进行设计开发&#xff09;&#xff0c;对理解JSP java编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。开发环境为TOMCAT7.0,Mye…

折线图实现柱状阴影背景的demo

这个是一个由官网的基础折线图实现的流程&#xff0c;将涉及到的知识点附上个人浅薄的见解&#xff0c;源码在最后&#xff0c;需要的可自取。 折线图 成果展示代码注解参数backgroundColordataZoomlegendtitlexAxisyAxisgridseries 源码 成果展示 官网的基础折线图&#xff…

Jupyter Notebook的安装和使用(windows环境)

一、jupyter notebook 安装 前提条件&#xff1a;安装python环境 安装python环境步骤&#xff1a; 1.下载官方python解释器 2.安装python 3.命令行窗口敲击命令pip install jupyter 4.安装jupyter之后&#xff0c;直接启动命令jupyter notebook,在默认浏览器中打开jupyte…