数据结构与算法-学习笔记(14)

260 阅读6分钟

散列表—— Hash Table

散列表采用的是数组支持按照下标(通过寻址公式)在O(1)的时间复杂度下随机访问数据的特性,所以散列表其实是数组的一种扩展,由数组演化而来,没有数组就没有散列表。

通过散列函数把元素的键值映射为数组下标,然后在数组中根据下标找到对应数据。

散列函数

如何构造散列函数:

  1. 散列函数计算得到的散列值是个非负整数(因为要对应数组下标);
  2. 如果 key1 = key2,则hash(key1) == hash(key2);
  3. 如果 key1 != key2, 则hash(key1) != hash(key2);

实际情况下,第三点很难实现。即使比较有名的MD5、 SHA、CRC等哈希算法,也无法避免这种散列冲突。而且因为数组的存储空间有限,也会加大散列冲突的概率。

散列冲突解决

  1. 开放寻址法:如Java中TreadLocalMap

插入:

如果出现了散列冲突,就重新探测一个空闲位置,将数据插入(此时散列表中可能需要存储key和value)。简单的探测方法:线性探测(当前位置被占用,就从此往后依次查找,知道有空闲位置)

查找:

按照这种方式插入散列表,查找时则现根据散列值在数组中查找,未找到则依次向后查找直到遇到空闲位置,如还未找到则说明查找的元素未在散列表中。

删除:删除元素后该位置空,查找它之后的元素就会有问题。因此将要删除的元素标记为deleted,当查找时遇到deleted的空间,不停止,继续向下找。

当控线位置不多时线性探测效率较低,极端情况O(n)。还有另外两种比较经典的探测方法:二次探测(探测的下标序列是 hash(key)+0,hash(key)+1^2, hash(key)+2^2...)和双重散列(用散列值进行第二个散列函数计算,直到找到空闲)。

但不论哪种探测方法,当散列表空闲位置不多时,散列冲突的概率就会提高。为了保证散列表的操做效率,一般要保证散列表中有一定比例的空闲槽位。用装载因子(load factor)来表示空位的多少。它的计算公式是:

散列表的装载因子 = 填入表中的元素个数 / 散列表的长度

优点:

使用数组存储,有效利用CPU缓存,序列化简单。

缺点:

删除麻烦,冲突后的代价更高,内存利用率低(需要提前申请大空间数组)。

综上适用范围:数据量较小、装载因子小

  1. 链表法:比如Java中LinkedHashMap 在散列表中,每个“桶(bucket)”或“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。

优点:

内存利用率高(需要时创建),更加灵活(链表可以用跳表、红黑树)。

综上适用范围:存储大对象、大数据量

思考

有两个字符串数组,每个数组约有10万条字符串,如何快速找出两个数组中相同的字符串?

根据前边学到的hashTable思想,相同的key经过哈希函数计算得到的散列值是相等的。因此可以利用散列表。

用第一个字符串数组["string1","string2","string3"...]中字符串作为key创建一个散列表(哈希函数可以随意找个好用的,保证尽可能少的碰撞就行)。再用第二个数组中的字符串作为key,输入到哈希函数中得到了和数组一中相同的值,就说明这两个字符串相等了。

散列函数

  1. 散列函数的设计不能太复杂。
  2. 散列函数生成的值尽可能随机并且均匀分布。
  3. 关键字的长度、特点、分布、散列表的大小等等。

几种简单常用的设计方法:

  1. 数据分析:如果数据的值本身就是随机的,均匀分布的,可以直接用数据(如:手机号码后几位都是随机的,可以取后四位作为散列值)
  2. key值是字符串,将字符串每个字母的ASCII码值“进位”相加,在跟散列表大小求余、取模作为散列值。
hash("nice")=(("n" - "a") * 26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978
  1. 除此之外还有直接寻址、平方取中、折叠、随机数等等,了解。

装载因子过大怎么办

说明空闲位置减少,此时冲突增加,执行效率下降,则需要动态扩容。散列表扩容,虽然仍是扩容数组,但是由于容量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));
    }

函数的实现

函数的实现分两种指针类型和字符串类型

  1. 求hash值
  2. 判等 // 可用于查询等
  3. 释放空间

这种设计适用于碰撞较少的情况。

iOS 中方法缓存列表的哈希实现

在类的结构体中,有一个方法缓存成员,用于存储用户访问过的方法。达到快速找到方法实现的目的。

该缓存使用哈希表的方式实现,解决冲突使用的是开放寻址法。

objc-cache.mm