对于同一个问题,我们往往其实有很多种解决它的思路和方法,也就是可以采用不同的算法
举个例子(现实的例子):在一个庞大的图书馆中,我们需要找一本书。在图书已经按照某种方式摆好的情况下(数据结构是固定的)
方式一: 顺序查找
function sequentSearch(array: number[], num: number) {
for (let i = 0; i < array.length; i++) {
const item = array[i]
if (item === num) return i
}
return -1
}
方式二:二分查找
function binarySearch(array: number[], num: number) {
// 1.定义左边的索引
let left = 0
// 2.定义右边的索引
let right = array.length - 1
// 3.开始查找
while (left <= right) {
let mid = Math.floor((left + right) / 2)
const midNum = array[mid]
if (midNum === num) {
return mid
} else if (midNum < num) {
left = mid + 1
} else {
right = mid - 1
}
}
return -1
}
import sequentSearch from './1.顺序查找'
import binarySearch from './2.二分查找'
const MAX_LENGTH = 10 * 1000 * 1000
const nums = new Array(MAX_LENGTH).fill(0).map((_, i) => i)
const num = MAX_LENGTH / 2
const startTime1 = performance.now()
const index1 = sequentSearch(nums, num)
const endTime1 = performance.now()
console.log('索引为:', index1, '。消耗的时间:', endTime1 - startTime1)
const startTime2 = performance.now()
const index2 = binarySearch(nums, num)
const endTime2 = performance.now()
console.log('索引为:', index2, '。消耗的时间:', endTime2 - startTime2)
大 O 表示法(Big O notation)英文翻译为大 O 符号(维基百科翻译),中文通常翻译为大 O 表示法(标记法)
大 O 符号在分析算法效率的时候非常有用
举个例子,解决一个规模为 n 的时间所花费的时间(或者所需步骤的数目)可以表示为:T(n) = 4n^2 -2n + 2
n^2
项开始占据主导地位,其他各项可以被忽略举例说明:当 n=500
4n^2
项是 2n
项的 1000 倍大,因此在多数场合下,省略后者对表达式的值的影响是可以忽略不计的n^2
的系数也是无关紧要的这样,针对这个例子 T(n) = 4n^2 -2n + 2
大 O 表示法就几下剩余的部分,写作:T(n) = O(n^2)
n^2
(平方阶)的时间复杂度,表示为:O(n^2)
符号 | 名称 |
---|---|
O(1) | 常数(阶) |
O(log^n) | 对数(阶) |
O(n) | 线性,次线性 |
O(n log^n) | 线性对数,或对数线性、拟线性、超线性 |
O(n^2) | 平方 |
O(n^c) | 多项式,有时叫作 “代数”(阶) |
O(c^n) | 指数,有时叫做 “几何”(阶) |
空间复杂度指的是程序运行过程中所需要的额外存储空间
举个栗子
当空间复杂度很大时,可能会导致内存不足,程序崩溃
在平时进行算法优化时,我们通常会进行如下的考虑:
数组 | 链表 | |
---|---|---|
访问 | O(1) | O(n) |
查询 | O(n) | O(n) |
插入 | O(n) | O(1) |
删除 | O(n) | O(1) |
数组是一种连续的存储结构,通过下标可以直接访问数组中的任意元素
链表是一种链式存储结构,通过指针链接起来的节点组成,访问链表中元素需要从头结点开始遍历
在实际开发中,选择使用数组还是链表,需要根据具体应用场景来决定
哈希表通常是基于数组进行实现的,但是相对于数组,它也有很多优势:
哈希表相对于数组的一些不足:
哈希表它的结构就是数组,但是它神奇的地方在于对数组下标值的一种变换,这种变换我们可以使用哈希函数,通过哈希函数可以获取到 HashCode
公司员工存储
方案一:数组
方案二:链表
如果我们知道员工的姓名,但不知道它的员工编号,只能线性查找,效率非常低
50000个单词的存储
方案一:数组
方案二:链表
方案三:将单词转成数组的下标值
字符串转下标值
方案一:数字想加
cats: 3 + 1 + 20 + 19 = 43
,这种方案明显的问题就是有很多单词最终的下标都是 43,比如:was/tin/give/tend/moan/tick 等等方案二:幂的连乘
cats: 3*27^3 + 1*27^2 + 20*27 + 17 = 60337
方案一产生的数组下标太小,方案二产生的数组下标又太多
开放地址法主要工作方式是寻找空白的单元格来添加重复的数据
从图片的文字中我们可以了解到,开放地址法其实就是要寻找空白的位置来防止冲突的数据项
但是探索这个位置的方式不同,有三种方法
哈希表中执行插入和搜索操作效率是非常高的
装填因子
线性探测效率
二次探测和再哈希法性能相当,它们的性能比线性探测略好
链地址法的效率分析有些不同,一般来说比开放地址法简单。我们来分析一下这个公式应该是怎么样的
N / arraySize
那么我们现在就可以求出查找成功和不成功的次数了
1 + loadFactor/ 2
1 + loadFactor
经过上面的比较我们可以发现,链地址法相对来说效率是好于开放地址法的。所以在真实开发中,使用链地址法的情况较多
快速的计算
均匀的分布
均匀分布
质数的使用
为什么他们使用质数,会让哈希表分布更加均匀呢?
31 * i
可以用 (i << 5) - i
来计算,位移操作效率更高Java中的hashCode的计算方法与原理
Java 中的哈希表采用的是链地址法
HashMap 的初始长度是 16,每次自动扩展,长度必须是 2 的次幂
HashMap 中为了提高效率,采用了位运算的方式
index = HashCode(Key) & (Length - 1)
public static int hashCode(byte[] value) {
int h = 0;
for (byte v : value) {
h = 31 * h + (v & 0xff);
}
return h;
}
Java 里是如上计算的,使用到了位运算,但是在 JavaScript 进行较大数据的位运算时会出问题,所以一下代码实现中还是使用取模
这里采用质数的原因是为了产生的数据不按照某种规律递增
这里建议两处使用质数:
/**
* 哈希函数,将 key 映射成 index
* @param key 转换的 key
* @param max 数组的长度(最大的数值)
* @returns
*/
function hashFunc(key: string, max: number): number {
// 1.计算hashCode cats=>60337(27为底)
let hashCode = 0
const length = key.length
for (let i = 0; i < length; i++) {
// 霍纳法则计算 hashCode
hashCode = 31 * hashCode + key.charCodeAt(i)
}
// 2.求出索引值
const index = hashCode % max
return index
}
同步性、安全性
继承父类不同
否可以使用 null 作为 key
遍历方式不同
初始化、扩容方式不同
<<1+1 -> *2+1
)<<1 -> *2
),并且要求容量是 2^n,保证计算下标效率计算 hash 方式不同
Hashtable 计算 hash 是直接使用 key 的 hashcode 之后与数组的长度直接进行取余
0x7FFFFFFF 最大整型树
HashMap 计算 hash 对 key 的 hashcode 进行了二次 hash(取哈希值并高位向右移进行异或运算),然后与数组长度取模
HashMap 效率虽然提高了,但是 hash 冲突也增加了,因为它得出的 hash 的低位相同的概率比较高,而计算位运算解决这个问题,HashMap 重新根据 hashcode 计算 has 值后,又对 hash 做了一些运算来打散数据,使得取得的位置更加分散,从而减少 hash 冲突
内部实现方式不同
HashMap
HashMap 底层是采用了数组这样一个结构来存储数据元素,数据默认长度是 16
当我们通过 put 方法去添加数据的时候,HashMap 会根据 Key 的 hash 值进行取模运算,最终把这样的一个值保存到数组一个指定位置
但是这样一个设计方式会存在 hash 冲突问题,也就是说两个不同 hash 值的 key 最终取模以后会落到同一个数组下标
所以 HashMap 引入了一个链式寻址法来解决 hash 冲突问题,也就是对于存在冲突的 key,HashMap 把这些 key 组成一个单向链表,然后采用尾插法,把这样一个 key 保存到链表的尾部
为了避免链表过长,导致查询效率下降,所以当链表大于 8 并且数组长度大于等于 64 的时候,HashMap 会把当前链表转换为红黑树,从而减少链表数据查询的时间复杂度,提升查询效率
解决 hash 冲突方法有很多
再 hash 法:如果某个 hash 函数产生了冲突,再用另外一个 hash 进行计算,比如布隆过滤器就采用了这种方法
开放寻址法,就是直接从冲突的数组位置往下寻找一个空的数组下标进行数据存储,这个在 ThreadLocal 里面有使用到
链式寻址法,就是把存在 hash 冲突的 key 以单向链表的方式来进行存储,这个在 HashMap 里面有使用到
在 JDK1.8 中使用链式寻址法及红黑树来解决 hash 冲突问题,红黑树是为了优化 hash 表链表过长导致时间复杂度增加的一个问题。当链表长度大于 8,并且 hash 表的容量大于 64 的时候再向链表中添加元素就会触发链表向红黑树的转化
建立公共溢出区,把 hash 表分为基本表、溢出表两个部分,把存在冲突的 key 统一放在一个公共溢出表里面
总结
长度不够会动态扩容,threshold(临界值)=loadFactor(负载因子)*capacity(容量大小)
loadFactor 默认值 0.75,capacity 默认值 16,当元素个数达到 12 的时候会触发扩容,扩容的大小是原来的 2 倍
扩容因子表示 Hash 表中的元素填充程度。扩容因子值越大意味着触发扩容的元素个数更多,虽然整体空间利用率比较高,但是 Hash 冲突的概率也会增加,扩容因子越小,Hash 冲突概率越小,但是内存空间的浪费就变多了。扩容因子本质上就是冲突的概率以及空间利用率之间的平衡,0.75 和统计学泊松分布
HashMap 里面采用的是链式寻址的方式解决 Hash 冲突,为了避免链表过长带来一个时间复杂度增加的情况,链表长度 >=7 就会转换红黑树,提升检索效率。扩容因子在 0.75 时,链表长度达到 8 的可能几乎为 0,比较好的达到一个空间成本和时间成本的平衡
最大容量是 Integer.MAX_VALUE,即 2^31 - 1
这里采用链地址法实现哈希表
[[[k, v], [k, v], [k, v]]]
哈希表的插入和修改操作是同一个函数
删除数据
class HashTable<T = any> {
// 创建一个数组,用来存放链地址法中的链(数组)
private storage: [string, T][][] = []
// 定义数组的长度
private length: number = 7
// 记录已经存放元素的个数
private count: number = 0
// 计算索引值
private getIndex(key: string, max: number): number {
let hashCode = 0
const length = key.length
for (let i = 0; i < length; i++) {
// 霍纳法则计算 hashCode
hashCode = 31 * hashCode + key.charCodeAt(i)
}
const index = hashCode % max
return index
}
// 插入/修改
put(key: string, value: T) {
// 1.根据key获取数组中对应的索引值
const index = this.getIndex(key, this.length)
// 2.取出索引值对应位置的数组(桶)
let bucket = this.storage[index]
// 3.判断bucket是否有值
if (!bucket) {
bucket = []
this.storage[index] = bucket
}
// 4.确定已经有一个数组,但是数组中是否已经存在key是不确定的
let isUpdate = false
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
if (tupleKey === key) {
isUpdate = true
tuple[1] = value
}
}
// 5.如果上面的代码没有进行覆盖,那么在该位置进行添加
if (!isUpdate) {
bucket.push([key, value])
this.count++
}
}
// 获取值
get(key: string): T | undefined {
// 1.根据key获取索引值index
const index = this.getIndex(key, this.length)
// 2.获取bucket(桶)
const bucket = this.storage[index]
if (!bucket) return undefined
// 3.对bucket进行遍历
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
const tupleValue = tuple[1]
if (tupleKey === key) {
return tupleValue
}
return undefined
}
}
// 删除操作
delete(key: string): T | undefined {
// 1.获取索引值的位置
const index = this.getIndex(key, this.length)
// 2.获取bucket(桶)
const bucket = this.storage[index]
if (!bucket) return undefined
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
const tupleValue = tuple[1]
if (tupleKey === key) {
bucket.splice(i, 1)
this.count--
return tupleValue
}
}
}
}
为什么需要扩容?
如何进行扩容?
class HashTable<T = any> {
private resize(newLength: number) {
// 设置新的长度
this.length = newLength
// 获取原来所有的数据,并且重新放入到新的容量数组中
// 1.对数据进行初始化操作
const oldStorage = this.storage
this.storage = []
this.count = 0
// 2.获取原来数,放入新的数组中
oldStorage.forEach(bucket => {
if (!bucket) return
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
this.put(tuple[0], tuple[1])
}
})
}
put(key: string, value: T) {
if (!isUpdate) {
// 如果loadFactor大于0.75,扩容操作
const loadFactor = this.count / this.length
if (loadFactor > 0.75) {
this.resize(this.length * 2)
}
}
}
delete(key: string): T | undefined {
for (let i = 0; i < bucket.length; i++) {
if (tupleKey === key) {
// 如果loadFactor小于0.25,缩容操作
const loadFactor = this.count / this.length
if (loadFactor < 0.25 && this.length > 7) {
this.resize(Math.floor(this.length / 2))
}
}
}
}
}
容量最好是质数
质数的特点
class HashTable<T = any> {
private isPrime(num: number): boolean {
const sqrt = Math.sqrt(num)
for (let i = 2; i <= sqrt; i++) {
if (num % i === 0) {
return false
}
}
return true
}
private getNextPrime(num: number) {
let newPrime = num
while (!this.isPrime(newPrime)) {
newPrime++
}
if (newPrime < 7) newPrime = 7
return newPrime
}
private resize(newLength: number) {
this.length = this.getNextPrime(newLength)
}
}
class HashTable<T = any> {
// 创建一个数组,用来存放链地址法中的链(数组)
storage: [string, T][][] = []
// 定义数组的长度
private length: number = 7
// 记录已经存放元素的个数
private count: number = 0
// 计算索引值
private getIndex(key: string, max: number): number {
let hashCode = 0
const length = key.length
for (let i = 0; i < length; i++) {
// 霍纳法则计算 hashCode
hashCode = 31 * hashCode + key.charCodeAt(i)
}
const index = hashCode % max
return index
}
// 获取下一个质数
private getNextPrime(num: number) {
let newPrime = num
while (!this.isPrime(newPrime)) {
newPrime++
}
if (newPrime < 7) newPrime = 7
return newPrime
}
// 扩容/缩容
private resize(newLength: number) {
// 设置新的长度
this.length = this.getNextPrime(newLength)
console.log(this.length, 'new')
// 获取原来所有的数据,并且重新放入到新的容量数组中
// 1.对数据进行初始化操作
const oldStorage = this.storage
this.storage = []
this.count = 0
// 2.获取原来数,放入新的数组中
oldStorage.forEach(bucket => {
if (!bucket) return
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
this.put(tuple[0], tuple[1])
}
})
}
// 是否是质数
private isPrime(num: number): boolean {
// 质数的特点:只能被1和num整除
const sqrt = Math.sqrt(num)
for (let i = 2; i <= sqrt; i++) {
if (num % i === 0) {
return false
}
}
return true
}
// 插入/修改
put(key: string, value: T) {
// 1.根据key获取数组中对应的索引值
const index = this.getIndex(key, this.length)
// 2.取出索引值对应位置的数组(桶)
let bucket = this.storage[index]
// 3.判断bucket是否有值
if (!bucket) {
bucket = []
this.storage[index] = bucket
}
// 4.确定已经有一个数组,但是数组中是否已经存在key是不确定的
let isUpdate = false
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
if (tupleKey === key) {
isUpdate = true
tuple[1] = value
}
}
// 5.如果上面的代码没有进行覆盖,那么在该位置进行添加
if (!isUpdate) {
bucket.push([key, value])
this.count++
// 如果loadFactor大于0.75,扩容操作
const loadFactor = this.count / this.length
if (loadFactor > 0.75) {
this.resize(this.length * 2)
}
}
}
// 获取值
get(key: string): T | undefined {
// 1.根据key获取索引值index
const index = this.getIndex(key, this.length)
// 2.获取bucket(桶)
const bucket = this.storage[index]
if (!bucket) return undefined
// 3.对bucket进行遍历
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
const tupleValue = tuple[1]
if (tupleKey === key) {
return tupleValue
}
return undefined
}
}
// 删除操作
delete(key: string): T | undefined {
// 1.获取索引值的位置
const index = this.getIndex(key, this.length)
// 2.获取bucket(桶)
const bucket = this.storage[index]
if (!bucket) return undefined
for (let i = 0; i < bucket.length; i++) {
const tuple = bucket[i]
const tupleKey = tuple[0]
const tupleValue = tuple[1]
if (tupleKey === key) {
bucket.splice(i, 1)
this.count--
// 如果loadFactor小于0.25,缩容操作
const loadFactor = this.count / this.length
if (loadFactor < 0.25 && this.length > 7) {
this.resize(Math.floor(this.length / 2))
}
return tupleValue
}
}
}
}