TL;DR
inline int32_t hash_index(const uint64_t* keys, uint32_t keys_size, uint64_t key) {
if (!keys_size || key >= HASH_TOMBSTONE)
return -1;
const uint32_t max_distance = keys_size;
uint32_t i = hash_first_index(key, keys_size);
uint32_t distance = 0;
while (keys[i] != key) {
if (distance > max_distance)
return -1;
if (keys[i] == HASH_UNUSED)
return -1;
i = (i + 1) & (keys_size - 1); // instead of (i+1) % keys_size
++distance;
}
return i;
}
背景
这是一个 开放寻址哈希表中 hash 值的查找函数。
参数中:keys 是存储哈希值的数组,keys_size 是表的大小,key 是要查找的 hash 值。返回 hash 值所在的数组下标(如果不存在则返回 -1)。
开放寻址哈希表 hash key 往往很大,要把它塞进数组中,常见的方法就是把 key 做被除数,哈希表数组大小做除数,把余数作为下标结果,可以有效保证它在数组范围 (0~keys_size-1) 内 ,即 key % keys_size。这种计算是频繁的,每次添加、查找都会执行一次或多次。
现在让我们关注求余运算的性能问题,
首先我们写一个求余函数:
int func(int a, int b)
{
return a % b;
}
用 Visual Studio 开 /O2 编译选项(最大编译优化)来编译此函数,求余运算部分的汇编指令及其占用的CPU时钟周期如下:
mov eax, ebx ; 1 cycle (零延迟移动,通常被重命名处理) cdq ; 1 cycle idiv ecx ; ≈20-40 cycles (有符号除法,周期数可变) mov eax, edx ; 1 cycle
看上去这并不是 1 个 cycle 就可以完成的运算,优化求余运算可以优化哈希表的性能。
解决
这有一个简单实用的 trick:
计算 m % n 时候,当除数 n 是 2 的 k 次幂(k为正整数),m % n 等于 m & (n-1)。
其中 % 为求余(模除),& 为按位与。
而 m & (n-1) 的性能优于 m % n。因为执行同样的逻辑,它只需要 1 个 cycle:
and eax, ebx ; ebx 等于 keys_size - 1
做到这一切,只需要保持哈希表的总大小为 2 的 幂。
证明
设 n = 2k,
则 m = q⋅2k+r,q 是商,r 是余数。m & (q⋅2k−1) = (q⋅2k+r) & (q⋅2k-1) = (q⋅2k+r) & (2k-1)
这步可以进一步分为高 k 位和低 k 位的计算: = q⋅2k & (2k-1) + r & (2k-1)
在二进制中:2k 是 1 后面跟 k 个 0。比如 8 的二进制为 1000。2k−1 是 k 个 1。比如 8-1 的二进制为 0111。q⋅2k
= q 的二进制 << k
= q 的二进制后面补 k 个 0
也就是说,q⋅2k 的低 k 位全是 0,而 (2k-1) 的只有 k 位,所以它们位与的结果是 0。
因此,m & (q⋅2k−1) = q⋅2k & (2k-1) + r & (2k-1) = r & (2k-1)。
2k 二进制形式为高一位是 1,后面的 k-1 位都是 0;因为余数一定小于除数,所以 r 不会大于 2k, r 的高 1 位一定是 0,后面的 k-1 位都代表自己。所以 r & (2k-1) 的值为 r 本身。
因此,m & (q⋅2k−1) = r。
Q.E.D.
其他
这种优化方法确实需要更多的空间,是用空间换时间的一种办法。
哈希表的使用中,一般时间复杂度更重要些。至于空间分配上的要求比常规方式更高,这其实也符合线性探测法的需求。
线性探测法是用来处理不同 hash 值计算后下标恰好一致的方法,如果下标已经有值了,要不断重新计算可存入的下一个位置,这种开销在哈希表空间使用率高的情况下尤为明显:
| 空间使用率 | 平均查找长度 | 性能评价 |
| < 0.5 | ≈1.5 | 优秀 |
| 0.5-0.7 | 2-5 | 良好 |
| 0.7-0.8 | 5-10 | 一般 |
| > 0.8 | > 10 | 较差 |