散列表—— Hash Table
散列表采用的是数组支持按照下标(通过寻址公式)在O(1)的时间复杂度下随机访问数据的特性,所以散列表其实是数组的一种扩展,由数组演化而来,没有数组就没有散列表。
通过散列函数把元素的键值映射为数组下标,然后在数组中根据下标找到对应数据。
散列函数
如何构造散列函数:
- 散列函数计算得到的散列值是个非负整数(因为要对应数组下标);
- 如果 key1 = key2,则hash(key1) == hash(key2);
- 如果 key1 != key2, 则hash(key1) != hash(key2);
实际情况下,第三点很难实现。即使比较有名的MD5、 SHA、CRC等哈希算法,也无法避免这种散列冲突。而且因为数组的存储空间有限,也会加大散列冲突的概率。
散列冲突解决
- 开放寻址法:如Java中TreadLocalMap
插入:
如果出现了散列冲突,就重新探测一个空闲位置,将数据插入(此时散列表中可能需要存储key和value)。简单的探测方法:线性探测(当前位置被占用,就从此往后依次查找,知道有空闲位置)
按照这种方式插入散列表,查找时则现根据散列值在数组中查找,未找到则依次向后查找直到遇到空闲位置,如还未找到则说明查找的元素未在散列表中。
删除:删除元素后该位置空,查找它之后的元素就会有问题。因此将要删除的元素标记为deleted,当查找时遇到deleted的空间,不停止,继续向下找。
但不论哪种探测方法,当散列表空闲位置不多时,散列冲突的概率就会提高。为了保证散列表的操做效率,一般要保证散列表中有一定比例的空闲槽位。用装载因子(load factor)来表示空位的多少。它的计算公式是:
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
优点:
使用数组存储,有效利用CPU缓存,序列化简单。
缺点:
删除麻烦,冲突后的代价更高,内存利用率低(需要提前申请大空间数组)。
综上适用范围:数据量较小、装载因子小
- 链表法:比如Java中LinkedHashMap 在散列表中,每个“桶(bucket)”或“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。
优点:
内存利用率高(需要时创建),更加灵活(链表可以用跳表、红黑树)。
综上适用范围:存储大对象、大数据量
思考
有两个字符串数组,每个数组约有10万条字符串,如何快速找出两个数组中相同的字符串?
根据前边学到的hashTable思想,相同的key经过哈希函数计算得到的散列值是相等的。因此可以利用散列表。
用第一个字符串数组["string1","string2","string3"...]中字符串作为key创建一个散列表(哈希函数可以随意找个好用的,保证尽可能少的碰撞就行)。再用第二个数组中的字符串作为key,输入到哈希函数中得到了和数组一中相同的值,就说明这两个字符串相等了。
散列函数
- 散列函数的设计不能太复杂。
- 散列函数生成的值尽可能随机并且均匀分布。
- 关键字的长度、特点、分布、散列表的大小等等。
几种简单常用的设计方法:
- 数据分析:如果数据的值本身就是随机的,均匀分布的,可以直接用数据(如:手机号码后几位都是随机的,可以取后四位作为散列值)
- key值是字符串,将字符串每个字母的ASCII码值“进位”相加,在跟散列表大小求余、取模作为散列值。
hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
- 除此之外还有直接寻址、平方取中、折叠、随机数等等,了解。
装载因子过大怎么办
说明空闲位置减少,此时冲突增加,执行效率下降,则需要动态扩容。散列表扩容,虽然仍是扩容数组,但是由于容量n的变化可能造成散列值的变化。所以不止重新申请空间,还需要重新计算哈希值,变迁数据。
此时插入操作的时间复杂度:最好是不需要扩容O(1);最坏需要扩容O(n),均摊下来,时间复杂度O(1)。
具体选择多大的装载因子,要看实际情况(要求空间还是执行效率)
如何高效扩容: 当需要扩容时,不再一次性完成所有迁移操作。每次有插入操作时,在将老数据插入新表中。
iOS中hashtable实现
hashtable中的散列表结构基于链表法来解决冲突。不过把链表改成了指针数组,当出现冲突时,会释放旧的数组结构,重新分配一个更大的数组来存放新的元素。
缺点:冲突较多时会频繁的alloc和free 优势:不需要通过二级指针的指向来查找元素
整个hash结构就是一个大数组,数组的基地址是buckets。每个bucket可以看成一个小数组,他存储这具有相同散列值的冲突数据。
typedef struct {
const NXHashTablePrototype * _Nonnull prototype ; // 相关函数
unsigned count ; // hash结构已经有的关键字个数
unsigned nbBuckets ; // hash表中元素的容量大小,随着count增加会动态扩容
void * _Nullable buckets ; // hash表的数组基地址
const void * _Nullable info ;
} NXHashTable ;
typedef struct {
uintptr_t (* _Nonnull hash)(const void * _Nullable info,
const void * _Nullable data);
int (* _Nonnull isEqual)(const void * _Nullable info,
const void * _Nullable data1,
const void * _Nullable data2);
void (* _Nonnull free)(const void * _Nullable info,
void * _Nullable data);
int style; /* reserved for future expansion; currently 0 */
} NXHashTablePrototype;
// 查找当前数据data在散列表中的位置
// 散列函数 hash(data) % (表大小n) = 0~(n-1)
# define BUCKETOF(table, data) (((HashBucket *)table->buckets)+((*table->prototype->hash)(table->info, data) % table->nbBuckets))
过程图:
当插入数据后table.count(当前插入的总数据d1~d7)>nbBuckets(6)则进行扩容2n+1=13
# define MORE_CAPACITY(b) (b*2+1)
// 用表oldtable指向table中原有的数据,把table扩容,在将旧数据重新复制到扩容后的table中,释放oldtable。
// 因为nbBuckets的变化,导致了原本旧数据的散列值发生了变化,因此需要重新计算位置插入table中。
static void _NXHashRehash (NXHashTable *table) {
_NXHashRehashToCapacity (table, MORE_CAPACITY(table->nbBuckets));
}
函数的实现
函数的实现分两种指针类型和字符串类型
- 求hash值
- 判等 // 可用于查询等
- 释放空间
这种设计适用于碰撞较少的情况。
iOS 中方法缓存列表的哈希实现
在类的结构体中,有一个方法缓存成员,用于存储用户访问过的方法。达到快速找到方法实现的目的。
该缓存使用哈希表的方式实现,解决冲突使用的是开放寻址法。