楔子
两个对象的哈希值相等,那么映射出的索引肯定相同。而如果哈希值不相等,映射出的索引也有可能相同,因为与哈希值空间相比,哈希表的槽位是非常有限的。如果不同的对象在经过映射之后,生成的索引相同,或者说它们被映射到了同一个槽,那么便发生了索引冲突。
解决索引冲突的常用方法有两种:分离链接法和开放寻址法。
分离链接法比较简单,如果有多个键值对映射到同一槽位,那么就用链表将它们串起来。然后是开放寻址法,这也是 Python 使用的方法,我们详细介绍一下。
开放寻址法
首先将 key 映射成索引,如果发现该索引对应的哈希槽被占了,那么就尝试另一个。
new_key 被映射到了索引为 5 的槽,但是该槽已经存储了键值对数组的索引,即该槽被占了。如果是分离链接法,那么会使用链表将两个 entry 串起来,但开放寻址法则是选择一个新的槽,也就是重新找个坑。
那么问题来了,新的槽要怎么找呢?一般来说,会在首槽的基础上增加一个偏移量,得到新的槽,如果还冲突,那么继续增加偏移量,直到找到一个可用的槽。总的来说就是一个不断探测的过程,每一次探测都会尝试增加偏移量。
所以新的问题又产生了,每次增加的偏移量是多少呢?其实这个问题取决于具体实现,如果偏移量是线性增加的,我们称之为线性探测;如果偏移量是平方增加的,我们称之为平方探测。
但不管是线性探测,还是平方探测,其实都不够好。因为偏移量始终是以一种固定的模式增加,这就导致映射到同一槽位的两个 key 的探测序列是相同的。
比如这里的 key1 和 key2,它们的哈希值不同,但都映射到了索引为 2 的槽,这是完全正常的,因为哈希表的槽位有限。而由于偏移量的增加方式是固定的,比如这里每次都增加 2,再加上首槽相同,因此它们的探测序列也相同,显然这会导致后续出现多次冲突。
所以 Python 对此进行了优化,探测函数在发现索引冲突时,不会简单地增加一个偏移量,而是会参考对象的哈希值,计算下一个候选位置,这就大大降低了冲突的可能性。
Python 的这种做法被称为迭代探测,当然迭代探测也属于开放寻址法的一种。所以当出现索引冲突时,Python 并不是简简单单地加上一个偏移量,而是使用专门设计的探测函数进行二次探查,也就是之前说的改变规则、重新映射,然后在函数内部会参考对象的哈希值来计算出一个新的索引。
在 dictobject.c 文件开头有着大量的注释,对字典进行了全局性的概括,当然具体细节我们都会详细说明。
探测函数
当存储一个键值对时,要将 key 映射成一个索引,这个索引就是哈希索引数组的索引。那么具体是怎么映射的呢?这里先来回顾一下字典的结构。
字典有两种实现方式,一种是分离表,另一种是结合表。对于分离表而言,键由 ma_keys 维护,值由 ma_values 维护,两者分开存储。对于结合表而言,键和值均由 ma_keys 维护,即键值对会一起存储在 PyDictKeysObject 结构体中,至于 ma_values 则为 NULL。
关于字典为什么要有两种结构,上一篇文章解释地很详细了。一开始只有结合表,而引入分离表是为了节省内存,通过 key 和 value 分开存储,可以让多组 value 共享同一组 key。比如实例对象的属性字典,采用分离表再合适不过了。
然后注意结构体里面的 dk_kind 字段,它表示键的种类,有以下三种选择。
typedef enum { DICT_KEYS_GENERAL = 0, DICT_KEYS_UNICODE = 1, DICT_KEYS_SPLIT = 2 } DictKeysKind;
含义分别如下:
- DICT_KEYS_UNICODE:字典使用结合表实现,并且所有的键全部是字符串,此时键值对的类型为 PyDictUnicodeEntry 结构体,大小为 16 字节。
- DICT_KEYS_GENERAL:字典使用结合表实现,并且键不全是字符串,此时键值对的类型为 PyDictKeyEntry 结构体,大小为 24 字节。
- DICT_KEYS_SPLIT:字典使用分离表实现,此处不做讨论,我们自己创建的字典使用的都是结合表。注意,如果是分离表,那么所有的键也必须都是字符串,因为对象的属性字典里面的属性名都是字符串。
我们来验证一下:
from ctypes import * class PyDictKeysObject(Structure): _fields_ = [("dk_refcnt", c_ssize_t), ("dk_log2_size", c_uint8), ("dk_log2_index_bytes", c_uint8), ("dk_kind", c_uint8), ("dk_version", c_uint32), ("dk_usable", c_ssize_t), ("dk_nentries", c_ssize_t), ("dk_indices", c_char * 8)] class PyObject(Structure): _fields_ = [("ob_refcnt", c_ssize_t), ("ob_type", c_void_p)] class PyDictObject(PyObject): _fields_ = [("ma_used", c_ssize_t), ("ma_version_tag", c_uint64), ("ma_keys", POINTER(PyDictKeysObject)), ("ma_values", c_void_p)] DICT_KEYS_GENERAL = 0 DICT_KEYS_UNICODE = 1 DICT_KEYS_SPLIT = 2 # 创建一个字典,里面只包含字符串 d = {"a": 1, "b": 2, "c": 3} obj = PyDictObject.from_address(id(d)) # 此时 dk_kind 为 DICT_KEYS_UNICODE print( obj.ma_keys.contents.dk_kind == DICT_KEYS_UNICODE ) # True # 往字典里面插入一个键值对,并且键不是字符串 d[1] = None # 此时字典的结构发生改变,因为不再满足所有的键都是字符串 # dk_kind 会从 DICT_KEYS_UNICODE 变成 DICT_KEYS_GENERAL # 键值对也会从 PyDictUnicodeEntry 实例变成 PyDictKeyEntry 实例 print( obj.ma_keys.contents.dk_kind == DICT_KEYS_GENERAL ) # True # 那么问题来了,如果我将 key = 1 这个键值对删掉 # dk 会从 DICT_KEYS_GENERAL 变成 DICT_KEYS_UNICODE 吗 d.pop(1) # 很明显,答案是不会变的 print( obj.ma_keys.contents.dk_kind == DICT_KEYS_GENERAL ) # True # 不管 dk_kind 是 DICT_KEYS_GENERAL 还是 DICT_KEYS_UNICODE # 都表示字典使用的是结合表,它的特点是每组 value 独占一组 key # 但对于实例对象的属性字典来说,这么做就有点浪费内存了 # 比如某个类有 100 个实例,因为是同一个类的实例,所以它们的属性都是相同的 # 这也意味着属性字典里的 key 是相同的,那么这 100 个属性字典完全可以共享一组 key class A: pass a = A() obj = PyDictObject.from_address(id(a.__dict__)) # 对象的属性字典,采用分离表实现 # 分离表同样要求,所有的 key 都必须是字符串 print( obj.ma_keys.contents.dk_kind == DICT_KEYS_SPLIT ) # True # 插入一个键值对,并且 key 不是字符串 a.__dict__[1] = None # 字典的 PyDictKeysObject 发生重构,分离表会变成结合表 # dk_kind 从 DICT_KEYS_SPLIT 变成 DICT_KEYS_GENERAL print( obj.ma_keys.contents.dk_kind == DICT_KEYS_GENERAL ) # True
打印结果表明我们的分析没有问题,相信你已经明白了分离表和结合表的区别,以及 dk_kind 字段的作用。下面我们来看看 key 是怎么映射成索引的,这个过程由 _Py_dict_lookup 函数负责。
// Objects/dictobject.c Py_ssize_t _Py_dict_lookup(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject **value_addr) { // 将 key 映射成索引,这个索引就是哈希索引数组的索引 /* 参数 mp:指向字典的指针 * 参数 key:指向键的指针 * 参数 hash:键的哈希值 * 参数 value_addr:值的二级指针 */ // mp->ma_keys PyDictKeysObject *dk; // mp->ma_keys->dk_kind DictKeysKind kind; // 哈希索引数组中存储的键值对数组的索引 Py_ssize_t ix; start: dk = mp->ma_keys; kind = dk->dk_kind; // 如果 kind 不等于 DICT_KEYS_GENERAL // 说明要么是 DICT_KEYS_UNICODE,要么是 DICT_KEYS_SPLIT // 但不管哪一种,字典里面的 key 肯定都是字符串 if (kind != DICT_KEYS_GENERAL) { // 如果 key 是字符串,那么调用 unicodekeys_lookup_unicode if (PyUnicode_CheckExact(key)) { ix = unicodekeys_lookup_unicode(dk, key, hash); } // 但如果在迭代的时候,key 的类型发生改变 // 那么调用 unicodekeys_lookup_generic else { ix = unicodekeys_lookup_generic(mp, dk, key, hash); if (ix == DKIX_KEY_CHANGED) { goto start; } } // 如果 ix >= 0,说明找到了合法的键值对数组的索引 // 但这里不光要返回索引,还要把 value 也返回,该怎么做呢? // 因为 C 是单返回值语言,所以外部在调用函数时一般会传一个指针 // 然后在函数内部对指针指向的值进行修改,这是 C 的常用操作 // 所以外部会定义一个 PyObject *value,然后将 &value 传给参数 value_addr // 因此 value_addr 是一个二级指针,通过 *value_addr 修改外部的 value if (ix >= 0) { // 如果 kind 是 DICT_KEYS_SPLIT,说明 value 存在 ma_values 里面 if (kind == DICT_KEYS_SPLIT) { *value_addr = mp->ma_values->values[ix]; } // 否则说明 kind 是 DICT_KEYS_UNICODE // key 和 value 会整体作为一个 entry 存在 dk_entries 里面 else { *value_addr = DK_UNICODE_ENTRIES(dk)[ix].me_value; } } // 否则说明 ix < 0,即哈希槽尚未存储某个合法的键值对数组的索引 else { *value_addr = NULL; } } // 如果执行到这里,说明 kind 为 DICT_KEYS_GENERAL // 此时通过 dictkeys_generic_lookup 函数进行查找 else { ix = dictkeys_generic_lookup(mp, dk, key, hash); if (ix == DKIX_KEY_CHANGED) { goto start; } if (ix >= 0) { *value_addr = DK_ENTRIES(dk)[ix].me_value; } else { *value_addr = NULL; } } return ix; }
所以该函数里面会调用三个辅助函数。
- unicodekeys_lookup_unicode:在 unicode table 中查找字符串格式的 key。
- unicodekeys_lookup_generic:在 unicode table 中查找非字符串格式的 key。
- dictkeys_generic_lookup:在 generic table 中查找 key,格式不限。
我们以 unicodekeys_lookup_unicode 函数为例,看一下它的内部逻辑,不过在看之前,需要先了解几个其它函数。
// Include/internal/pycore_dict.h // 获取哈希表的长度,即内部哈希索引数组的长度 #define DK_LOG_SIZE(dk) _Py_RVALUE((dk)->dk_log2_size) #define DK_SIZE(dk) (((int64_t)1)<<DK_LOG_SIZE(dk)) // 获取紧跟在 PyDictKeysObject 结构体后面的键值对数组 // 这个和前面介绍字符串时,获取 PyASCIIObject 结构体后面的字符数组是类似的 // 主要是利用 C 语言指针的技巧(甚至都不能算技巧),这里再来详细介绍一下 // 假设有一个 int *a,那么 a + 3 和 a[3] 是等价的,都会向后偏移 3 个 int // 那么问题来了,对于任意类型的指针 T *p,如果我想向后偏移 n 个字节,该怎么做呢? // 很简单,可以将指针转成 int8_t *,然后加上 n 即可,会偏移 sizeof(int8_t) * n 个字节 // 比如 ((int8_t *)p) + n 或者 ((int8_t *)p)[n] staticinlinevoid* _DK_ENTRIES(PyDictKeysObject *dk) { // 获取哈希索引数组,转成 int8_t * 类型 int8_t *indices = (int8_t*)(dk->dk_indices); // 1 << dk->dk_log2_index_bytes 表示哈希索引数组的大小 size_t index = (size_t)1 << dk->dk_log2_index_bytes; // 向后偏移 index 个字节,定位到键值对数组的首字节 return (&indices[index]); } static inline PyDictKeyEntry* DK_ENTRIES(PyDictKeysObject *dk) { assert(dk->dk_kind == DICT_KEYS_GENERAL); // 获取键值对数组之后,转成 PyDictKeyEntry * 类型 // 这样指针加 1,才会偏移 sizeof(PyDictKeyEntry) 个字节,跳到下一个元素 return (PyDictKeyEntry*)_DK_ENTRIES(dk); } static inline PyDictUnicodeEntry* DK_UNICODE_ENTRIES(PyDictKeysObject *dk) { assert(dk->dk_kind != DICT_KEYS_GENERAL); // 获取键值对数组之后,转成 PyDictUnicodeEntry * 类型 // 这样指针加 1,才会偏移 sizeof(PyDictUnicodeEntry) 个字节,跳到下一个元素 return (PyDictUnicodeEntry*)_DK_ENTRIES(dk); } // 字典的 key 是否都是字符串,只需判断 dk_kind 是否不等于 DICT_KEYS_GENERAL 即可 #define DK_IS_UNICODE(dk) ((dk)->dk_kind != DICT_KEYS_GENERAL) #define DKIX_EMPTY (-1) #define DKIX_DUMMY (-2) #define DKIX_ERROR (-3) #define DKIX_KEY_CHANGED (-4)
这些应该都是比较简单的内容了,下面来看一下 unicodekeys_lookup_unicode 函数。
// Objects/dictobject.c // 哈希表的长度减 1 // 将 key 的哈希值和它按位与,便可将 key 映射成索引 #define DK_MASK(dk) (DK_SIZE(dk)-1) #define PERTURB_SHIFT 5 // 基于哈希索引数组的索引,获取指定的哈希槽里面存储的键值对数组的索引 static inline Py_ssize_t dictkeys_get_index(const PyDictKeysObject *keys, Py_ssize_t i) { // 获取 dk_log2_size int log2size = DK_LOG_SIZE(keys); // 键值对数组的索引 Py_ssize_t ix; // 如果哈希表的长度小于 1 << 8,哈希索引数组的每个元素占 1 字节 if (log2size < 8) { const int8_t *indices = (const int8_t*)(keys->dk_indices); ix = indices[i]; } // 如果哈希表的长度小于 1 << 16,哈希索引数组的每个元素占 2 字节 // 指针类型要转成 int16_t *,这样获取元素时会获取两个字节 else if (log2size < 16) { const int16_t *indices = (const int16_t*)(keys->dk_indices); ix = indices[i]; } // 如果哈希表的长度大于等于 1 << 32,哈希索引数组的每个元素占 8 字节 else if (log2size >= 32) { const int64_t *indices = (const int64_t*)(keys->dk_indices); ix = indices[i]; } // 否则哈希索引数组的每个元素占 4 字节 else { const int32_t *indices = (const int32_t*)(keys->dk_indices); ix = indices[i]; } assert(ix >= DKIX_DUMMY); // 返回键值对数组的索引 return ix; } static Py_ssize_t _Py_HOT_FUNCTION unicodekeys_lookup_unicode(PyDictKeysObject* dk, PyObject *key, Py_hash_t hash) { // 键值对数组 PyDictUnicodeEntry *ep0 = DK_UNICODE_ENTRIES(dk); // 哈希表的长度减 1 size_t mask = DK_MASK(dk); // perturb,初始值等于参数 hash,即 key 的哈希值 size_t perturb = hash; // 将哈希值和 mask 按位与,计算出哈希索引数组的索引 size_t i = (size_t)hash & mask; Py_ssize_t ix; for (;;) { // 基于哈希索引数组的索引,从指定的哈希槽中获取键值对数组的索引 ix = dictkeys_get_index(dk, i); // 如果 ix >= 0,说明确实存储了一个合法的键值对数组的索引 if (ix >= 0) { // 基于索引 ix 获取键值对 PyDictUnicodeEntry *ep = &ep0[ix]; assert(ep->me_key != NULL); assert(PyUnicode_CheckExact(ep->me_key)); // ep->me_key 和变量 key 都是指针 // 如果这两者相等,说明指向了同一个对象(字符串),显然查找的 key 已在字典中 // 如果 ep->me_key != key,说明两者指向的不是同一个对象 // 那么就比较两个字符串本身以及哈希值是否相等,如果相等,也表示 key 已存在 if (ep->me_key == key || (unicode_get_hash(ep->me_key) == hash && unicode_eq(ep->me_key, key))) { // 此时直接返回 ix 即可 return ix; } } // 哈希索引数组里面的元素初始为 -1,如果 ix == -1 // 证明当前 key 映射出的哈希槽,还没有存储键值对数组的某个索引 // 这就意味着当前要查找的 key 不存在,此时直接返回 -1 即可 else if (ix == DKIX_EMPTY) { return DKIX_EMPTY; } // 走到这里说明 ix 是合法的,但是对应的 entry->me_key 和当前要查找的 key 不相等 // 显然出现了索引冲突,于是将哈希值右移 5 位 perturb >>= PERTURB_SHIFT; // 然后加上 i*5 + 1,并重新和 mask 按位与,得到新的哈希索引数组的索引 // 也就是之前说的,改变规则、重新映射 i = mask & (i*5 + perturb + 1); // 基于新的哈希索引数组的索引 i,从哈希槽中获取存储的键值对数组的索引 ix ix = dictkeys_get_index(dk, i); // 基于 ix 获取键值对,然后重复之前的比较逻辑 if (ix >= 0) { PyDictUnicodeEntry *ep = &ep0[ix]; assert(ep->me_key != NULL); assert(PyUnicode_CheckExact(ep->me_key)); if (ep->me_key == key || (unicode_get_hash(ep->me_key) == hash && unicode_eq(ep->me_key, key))) { return ix; } } else if (ix == DKIX_EMPTY) { return DKIX_EMPTY; } // 如果映射出来的索引还不行,那么继续重复相同的步骤 // 哈希值右移 5 位,然后加上 i*5 + 1,再按位与 mask,进行下一轮循环 // 所以我们发现上面几行逻辑其实有点冗余,直接进行下一轮循环不就好了吗 // 其实 Python 这么做的目的是希望能够在一次循环中就把索引返回 // 毕竟连续冲突两次的概率还是不高的,但如果真的连续冲突两次,那么只能下一轮循环了 perturb >>= PERTURB_SHIFT; i = mask & (i*5 + perturb + 1); } Py_UNREACHABLE(); }
以上就是 unicodekeys_lookup_unicode 的具体逻辑,至于 unicodekeys_lookup_generic 函数和 dictkeys_generic_lookup 函数与之是类似的。
我们将逻辑再描述一遍,首先 perturb 初始等于 key 的哈希值,mask 等于哈希表的长度减一,然后执行 perturb & mask 便可得到一个索引。这个过程就是我们说的索引映射,映射出的索引便是哈希索引数组的索引,然后该索引对应的哈希槽又存储了一个索引,这个索引是键值对数组的索引。
哈希索引数组里面的一个位置我们称之为一个槽,如果映射出来的索引对应的哈希槽中存储的键值对数组的索引小于 0,说明该 key 对应的键值对还没有被存储过,那么直接返回初始值 -1 即可。
如果存储的键值对数组的索引(源码中的 ix)大于等于 0,说明已经存储了一个键值对,它的 key 和查找的 key 映射出的索引是相同的。然后比较 key 是否相等,如果相等,说明已经找到了,那么直接返回 ix 即可。如果 key 不相等,说明出现索引冲突了,那么要改变策略,重新映射。
perturb >>= PERTURB_SHIFT; i = mask & (i*5 + perturb + 1);
变量 i 便是映射出的哈希索引数组的索引,但发生冲突了,于是将 perturb 右移 5 位,然后加上 perturb + i * 5 + 1,再和 mask 按位与计算出新的 i。至于为什么要选择这种做法,这是 Python 官方经过多次实践得出来的结论。
当 unicodekeys_lookup_unicode 函数执行完之后,_Py_dict_lookup 函数就拿到了指定的 key 对应的键值对数组的索引,并且也能拿到 key 对应的 value(key 若不存在就是 NULL)。
以上就是 _Py_dict_lookup 函数的逻辑,它内部调用了 unicodekeys_lookup_unicode 函数。不过还没结束,我们看到 _Py_dict_lookup 返回的是变量 ix,而变量 i 才是我们需要的。
变量 i 表示槽的索引,变量 ix 表示该槽存储的键值对数组的索引。由于我们是要寻找指定的槽,那么返回的应该是槽的位置、也就是变量 i 才对啊,为啥要返回变量 ix 呢?别急,往下看。
#define DKIX_EMPTY (-1)
初始状态下,槽存储的都是 -1,表示当前槽是可用的。如果存储的索引大于等于 0,则表示该槽已经被占用了,当 key 不相等时会改变规则重新映射。总之最终的结果是:
- 如果 key 存在,那么返回的 ix 就是该 key 对应的键值对在键值对数组中的索引,此时 ix 大于等于 0;
- 如果 key 不存在,那么返回的 ix 就是哈希槽的初始值 -1。
所以 _Py_dict_lookup 函数只是告诉我们当前 key 在哈希表中是否存在,如果要获取槽的索引,即 _Py_dict_lookup 里面的变量 i,那么还需要另外两个函数。
- find_empty_slot:如果 _Py_dict_lookup 返回的 ix 小于 0,说明 key 不存在,那么调用该函数返回槽的索引。
- lookdict_index:如果 _Py_dict_lookup 返回的 ix 大于等于 0,说明 key 存在,那么调用该函数返回槽的索引。
所以这两个函数做的事情是一样的,都是返回槽的索引,我们看一下具体实现。
// Objects/dictobject.c static Py_ssize_t find_empty_slot(PyDictKeysObject *keys, Py_hash_t hash) { assert(keys != NULL); const size_t mask = DK_MASK(keys); // 获取槽的索引 i size_t i = hash & mask; // 获取槽存储的索引 ix Py_ssize_t ix = dictkeys_get_index(keys, i); // 不停映射,直到 ix 小于 0 // 因为调用该函数已经说明 key 不存在了 // 所以最终一定会映射到一个空槽 for (size_t perturb = hash; ix >= 0;) { perturb >>= PERTURB_SHIFT; // 映射的逻辑是相同的 i = (i*5 + perturb + 1) & mask; ix = dictkeys_get_index(keys, i); } // 当 ix 小于 0 时,便找到了槽的索引 return i; } static Py_ssize_t lookdict_index(PyDictKeysObject *k, Py_hash_t hash, Py_ssize_t index) { size_t mask = DK_MASK(k); size_t perturb = (size_t)hash; size_t i = (size_t)hash & mask; for (;;) { // 基于槽的索引 i,获取槽存储的索引 ix Py_ssize_t ix = dictkeys_get_index(k, i); // 参数 index 就是 _Py_dict_lookup 函数返回的 ix // 如果 ix 和 index 是相等的,说明此时的 i 就是要获取的槽的索引 if (ix == index) { return i; } if (ix == DKIX_EMPTY) { return DKIX_EMPTY; } // 整个映射逻辑都是一样的 // 因为在 _Py_dict_lookup 函数中只返回了 ix,即参数 index // 所以要按照相同的规则再映射一次,如果映射出的 ix 和 index 相等 // 那么我们就找到了槽的索引 i,并且索引为 i 的槽存储的就是 index perturb >>= PERTURB_SHIFT; i = mask & (i*5 + perturb + 1); } Py_UNREACHABLE(); }
所以我们说的探测函数应该是 _Py_dict_lookup 和 find_empty_slot、lookdict_index 的组合。
- _Py_dict_lookup 负责返回槽存储的索引,用于判断 key 是否存在。
- find_empty_slot、lookdict_index 则负责返回槽的索引,即 key 最终被映射到了哪一个槽。
另外还有一点,我们之前说索引冲突时,会执行探测函数计算新的存储位置。其实不管有没有发生冲突,即使存储键值对的时候哈希表是空的,也要执行探测函数,毕竟探测函数的目的就是基于哈希值映射出一个合适的槽。如果探测函数执行的时候发现索引冲突了,也就是槽里的索引 ix >= 0,并且 key 还不相等,那么会改变规则重新映射。
因此在存储某个键值对时,无论索引冲突多少次,探测函数只会执行一次。在探测函数里面,会不断尝试解决冲突,直到映射出一个可用的索引。
小结
以上就是索引映射的具体逻辑,以及出现冲突是怎么解决的。就是先用哈希函数计算出 key 的哈希值,然后作为参数传递到探测函数中。在探测函数里面,会将哈希值和 mask 按位与,得到索引。如果索引冲突了,那么会改变规则,这里的规则如下:
// 将当前哈希值右移 PERTURB_SHIFT 个位 perturb >>= PERTURB_SHIFT; // 然后将哈希值加上 i*5 + 1,这个 i 就是当前冲突的索引 // 运算之后的结果再和 mask 按位与,得到一个新的 i // 然后判断变量 i 对应的槽是否可用,不可用则重复当前逻辑,直到出现一个可用的槽 i = (i*5 + perturb + 1) & mask;
所以 Python 在索引冲突时,并不像线性探测和平方探测那样,简单地加一个固定的偏移量,而是参考对象的哈希值计算出一个新的索引。