- 理想状况下,无需任何比较就能找到待查关键字,查找的期望时间复杂度为
O(1)
PHP7散列表基本结构:
// zend_array和HashTable的含义是相同的,没有任何区别
typedef struct _zend_array zend_array;
typedef struct _zend_array HashTable;
struct _zend_array {
zend_refcounted_h gc;
// 此处的union可以先忽略
union {
...
} u;
// 用于散列函数映射存储在arData数组中的下标
uint32_int nTableMask;
// 存储元素数组,每个元素的结构统一为Bucket,arData指向第一个Bucket
Bucket *arData;
// 已用Bucket数
uint32_t nNumUsed;
// 数组实际存储的元素数
uint32_t nNumOfElements;
// 数组的总容量
uint32_t nTableSize;
uint32_t nInternalPointer;
// 下一个可用的数值索引,如arr[] = 1;arr["a"] = 2;arr[] = 3;则nNextFreeElement = 2;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
}
散列表的结构中有很多成员,以下是比较重要的几个成员:
arData
:散列表中保存 存储元素(即Bucket
)的数组,其内存是连续的,arData
指向数组的起始位置nTableSize
:数组的总容量,即可以容纳的元素数量。arData
的内存大小是根据这个值确定的,他的大小是2的幂次方,最小为8。也就是说散列表的大小依次按照8
、16
、32
、64
…进行递增。nTableMask
:这个值在散列函数根据key
的hash code
映射元素的位置时用到。他的值实际就是nTableSize
的负数,即nTableMask = -nTableSize
,用位运算来表示的话则为:nTableMask = ~nTableSize + 1
(此处需要撰写一篇笔记专门说明,参考本文件夹内《4.2、原码、反码和补码》)。nNumUsed
、nNumOfElements
:nNumUsed
是指当前使用掉的Bucket
数量,而nNumOfElements
则是数组中的有效元素的数量,注意两者是有区别的。在数组中,使用掉的Bucket
中并非一定是有效元素,当我们从数组中删除元素的时候并不会把元素马上从数组中移除,而是将元素的类型标记为IS_UNDEF
。只有在数组的容量超限,需要进行扩容时才会删除。这一机制使得nNumUsed >= nNumOfElements
。如果数组没有扩容,那么nNumUsed
将一直是递增的,无论是否删除元素。nNextFreeElement
:这个值是给自动确认数值索引使用的,从0
开始。比如$a[] = 1
,这个时候就将这值增加为1
,下次再有$a[]
的操作时就使用刚刚得到的1
作为新元素的索引值。pDestructor
:当删除或覆盖数组中的某个元素时,如果提供了这个函数句柄,则在删除或覆盖后调用此函数,对旧元素进行清理。u
:这个结构主要是一些辅助作用,比如flags
用来设置散列表的一些属性:是否持久化、是否已经初始化等。
Bucket
Bucket
的结构较为简单,此结构主要用于保存元素的key
和value
。除了这两个还有一个整型的h
,它的含义是hash code
:如果元素是数值索引,那么它的元素就是数值索引的值;如果是字符串索引,那么就根据字符串key
通过Time33
算法计算得到散列值,h
的值用来映射元素的存储位置。另外,存储的value
也直接嵌入到Bucket
结构中:
typedef struct _Bucket {
zval val;// 存储的具体value,这里嵌入一个zval,而不是一个指针
zend_ulong h;// key根据Time33计算得到的哈希值,或者是数值索引
zend_string *key;// 存储元素的key
} Bucket;
arData
arData
在数组初始化的时候并不分配内存,而是在首次插入数据的时候触发内存分配。由zend_hash_real_init_ex()
进行内存的分配,分配的大小包括中间表和元素数组,共计:nTableSize * (sizeof(Bucket) + sizeof(uint32_t))
。分配完成后会把HashTable->u.flags
打上HASH_FLAG_INITIALIZED
掩码,这样下次再插入的时候发现已经分配了内存就不会再触发内存分配了。分配完成后,HashTable->arData
会指向第一个Bucket
的位置。
完成内存的分配之后就可以进行插入操作了。插入时首先将元素按照顺序插入
arData
,然后将其在arData
中的位置存储到中间表,它在中间表中的位置计算方式是:根据key
的hash code
(即key->h
)与nTableMask
计算后得到的中间表的位置(nIndex = h | ht->nTableMask
)。
哈希冲突
散列表中不同元素的
key
有时候经过hash计算
会得到相同的结果(即想存入相同的映射表的位置),而映射表中的一个位置只能存储一个元素,这时候就发生了哈希冲突。
常用的解决方法是把冲突的
Bucket
串成链表,这样一来映射表映射的就不是一个Bucket元素
,而是一个Bucket链表
。在查找的时候需要根据key
遍历链表来查找相应的元素。PHP
就是采用的这种方式解决哈希冲突。
HashTable
中的Bucket
会记录与他冲突的元素的位置(当前是只能标记相邻的)。在设置映射时,如果发现对应的位置已经有元素了,就会把已经存在的值(后加:的位置)放到新申请的Bucket
中再把映射表指向新申请的Bucket
。《PHP7内核剖析》书中有句话叫做“即每次都会把冲突的元素插到开头”,这里有两点不明:一是冲突的元素是指新元素还是旧元素(后续查看,是新元素),二是链表的开头是指新申请的Bucket
还是之前的旧Bucket
(后续查看,是新申请的Bucket
)。这里的描述与例子是冲突的,例子中并没有把旧的值放入新申请的Bucket
,例子截图如下:
关于上述冲突的说明:“就会把已经存在的值(后加:的位置)放到新申请的
Bucket
中再把映射表指向新申请的Bucket
。”这句话坑死爹爹了。。之前一直感觉很奇怪,才有了下面的疑惑。这边解读一下:这里是指把已经存在的值的位置放到新申请的Bucket
中的next
中,并非是把整个值拷过去。。
个人觉得明明只要把新
Bucket
连接到旧的Bucket
后面就可以了,为何需要修改映射表的指向呢?很繁琐啊,把值拷来拷去的。。
写在最后:将冲突流程走了一遍之后发现,现在的做法确实是最优的:冲突时只需要修改映射表的值,再将新元素的
next
指向之前的元素。如果按照我的想法,保持映射表位置不变,那么就需要写入新元素,再修改末端元素的指向。假如链表中冲突元素已经较多,定位到末端元素也是个费劲的事,还不如现在这种方案,确保每次只影响两个地方即可。
查找
查找过程比较简单,流程如下:首先根据
key
计算hash code
,即zend_string->h
与nTableMask
计算得到的nIndex
。然后根据nIndex
从映射表查找到链表头元素位置idx
。从idx
取出第一个Bucket
,开始与key
进行对比,判断是否是需要查找的元素,如果是就终止遍历,否就继续根据zval.u2.next
往下查找。。