结合数学思维来深入内存理解哈希散列的实现原理和处理冲突的逻辑

0x01.前言

众所周知,在实际工程领域中,往往要进行数据查找,这涉及到数据结构中的查找算法。但是,当数据量很大时不论是直接遍历查找(太慢)还是折半查找、分区查找(实际工程中数据往往是乱序)都不是最优解,此时哈希散列能够通过关键值运算直接算出存储位置且不需要比较大小而深受推崇,但是冲突问题不可避免。那么,哈希散射有哪些实现方法呢?是什么数学原理?

0x02.哈希查找逻辑
实际工程中,待查找的数据类型有字母,汉字,数字等等,种类繁多,为了实现数据映射地址下标,非数字类数据将首先转化为数字,之后利用哈希函数将该数字映射为数据地址下标。
image

0x03.哈希散射的风险与挑战
我以最基础的数学原理的方式来帮助读者到底为什么会有哈希冲突。
倘若:
Hash(key) = key % 7,
如果我们要找的数据是7,正好算出来的位置是0,,如果是14,位置算出来也是0.
问题来了,0位置存储的到底是7还是14?这就是典型的哈希冲突了。
image
同样key可以是7的任意倍数,都可以对应位置0,但是表中0位置只存了14,直接就冲突了。
因此,哈希冲突的本质就是:

关键字的数量(很多都是假的,哈希表没有存放这个数,但是完全可以用它算出来哈希表中的位置)远远大于哈希表长度。

读者可能会问,风险原理我懂了,怎么避免呢?
答曰:难以避免,但是可以解决。解决方法后面讲解。


0x04.四种实现方法
我们不妨先讲解如何实现的,好处几何,劣势几何。
直接地址法的实现

我们在中学时知道,一元一次函数中自变量x和因变量y是一一对应的,我们很容易想到:数据存放的位置就在它的大小对应的地址。比如3存放在下标3中,5存放在下标5中。这样我们要查找某数,直接按位置就找到了,而且因为key,location一一对应,完全可以避免哈希冲突。

image

但是我们再想想,如果像之前我讲计数排序算法遇到的问题一样,如果数据极值较大呢?太浪费空间。并且我们常常忽视一个问题(笔者就经常忽视这个问题哈哈),万一有数据是负数呢?

显然直接地址法不适用于大多数情况。

点击查看代码
int directHash(int key) {
    return key; // 直接用 key 当下标
}

除留余数法的实现
如前文所举之例,该实现方法就是将key对p进行取模得到下标:
image
有人会问,别的我懂,为什么p这样定义?合数都不行?
笔者刚学的时候也在这里懵了一会儿,但是别怕这个数学原理很简单,容我娓娓道来:

首先我以一个例子来引入:
如果p是10(合数中的偶数)会怎样?
(1)key为0-9:原数
(2)key为10-19:和(1)一一对应
(3)key为20-29:同
...
显然这些都会冲突。
有人说:如果是11这种质数,不也是一样会循环吗?
这样说的是对的,都是会不断循环的。但是有一个问题,在实际工程中,数据往往不会像这样连续排列,在上述情景中,如果要查找的数据中有0,5,10,15,20,25,...用10和11会怎样?
image
读者朋友们应该看出来问题了,这里0,5,10,15,20,25和10都有公因数5或2,如果p为合数,像这里的10一样,所有和它有相同的公因数的数字会集中分布,这样的话冲突问题可就大了。而如果是质数11,会让他们从0到14均匀分布冲突少(这里没冲突,数据量大的话可能就冲突了)。好处是:1)均匀分布空间利用率高,2)减少哈希冲突

但是大家可能会问:为什么p为合数,所有和它有相同的公因数的数字会集中分布?

以下为我的数学证明:
image
显然这就可以说明,两个有公共因子的数字取模,结果是公共因子d的倍数,这会导致结果集中分布。

我们实际最常用这个实现方法,冲突少且结果相对均匀,空间利用率高。

点击查看代码
int modHash(int key, int p) {
    return key % p;
}

平方取中法实现
不是很重要,读者知道这个思想即可。

比如key = 123,key^2 = 15129,取中间三位,location=151或152,这样高位地位都影响结果,分布更为均匀。

点击查看代码
int squareHash(int key) {
    int square = key * key;
    return (square / 10) % 100; // 取中间两位
}

数字分析法实现
这个不需要记,很容易理解,具体问题具体分析。

比如学号,以网安班级为例,我们要把网安1班所有学生学号存入指定系统,学号只有最后两位不一样其他都一样,那么函数只取学号key后两位作为location即可。

点击查看代码
int analyzeHash(int key, int p) {
    return key % 100 ;
}

0x05哈希冲突的两种解决方法

线性探测法

这种方法名字很高大上,实则很朴实,就是我们平时遇到问题时的方法解决逻辑:比如你想到超市A去买文具,超市A关门了你就去它附近的B超市买。
一个道理,这个方法在插入时去找key值映射的location,看看是否被占了,占了就去下一个位置。注意这里是循环着去下一个位置的,也就是说,当你一直找,找到最后一个
位置还是没有空位,就从头再来。
对应的代码就是
hash(key) = (hash(key)+1)%m;
在查找时就是看对应位置是不是key(比较一次),不是就往后面找(不断比较直到找到key或找到空位置还是没找到该key)。

线性探测法重点关注删除逻辑:
image
问:这里的8能直接删除吗?
显然大家都会觉得不能。毕竟,8删完后面的15本来插入的位置就应该是1,后面的两个元素应该前移才对。
的确,删完指定元素后开始遍历后面的元素,看看后面的元素是不是该插入的位置在前面,是的话就遍历。
这就是线性探测+回填前移,用的是维护探测链的思想。
优点在于:
✅️减少空洞加快查找
✅️节省空间
✅️不像考研DEL写法,越查越慢
✅️实现真正的物理删除
代码写法如下:

点击查看代码
void delete(int key) {
    int pos = hash(key);

    // 1. 找到要删除的位置
    while (table[pos] != key) {
        pos = (pos + 1) % m;
    }

    // 2. 删除它
    printf("删除 %d,位置 %d\n", key, pos);
    table[pos] = -1;

    // 3. 后面元素前移回填
    int empty = pos;//empty:删除后空位置的下标
    pos = (pos + 1) % m;

    while (table[pos] != -1) {
        int h = hash(table[pos]); // 算这个元素本来应该在哪

        // 判断:这个元素能不能前移填补空洞?
        // 规则:如果 h 在 empty 左边/等于empty,就能前移
        if (h <= empty) {
            table[empty] = table[pos]; // 前移
            table[pos] = -1;          // 旧位置清空
            empty = pos;              // 新空洞变成当前位置
        }

        pos = (pos + 1) % m;
    }
}

注:考研的同学不要用这个思路!我查了资料才知道原来考研只让用DEL标记法,考研为了让你算ASL所以不让你动已经排好的key

ASL平均查找长度(非考研党可以忽略)

分为成功ASL和失败ASL,成功ASL即所有元素均成功查找,较为简单,

成功查找的平均查找长度 ASL = 比较次数/查找元素个数

至于失败ASL,显然是查找一个【不存在的 key】时,一共比较了多少次才确定 “找不到”。

读者不妨想一想,什么情况下算失败?(上文我有提到)
有人会觉得:应该是整个哈希表都遍历一遍才算吧。但是再想想,出现堆积是不是往后移?一旦后面出现空位的话,是不是说明该key值对应位置堆积的元素肯定都遍历过了?
再找不到不用想了,就是没有!一个位置的失败查找次数就是从该位置开始计数,一直计数到空位置。
所以,

失败ASL = 每个元素失败查找次数之和/元素个数。

链地址法(最常用)

我后面会在数据结构专题里讲到邻接表,这个和邻接表类似,没学过没关系,我一讲你就懂了。
还以之前的hash(key)=key%7举例,此时7,14,21都能够存放在0位置上,采用头插法,依次将元素插入对应位置的链表中(链表的头插我接下来在数据结构满分计划系列中讲),
这样我们就把插入不妨碍别的位置的情况下实现了。
查找就直接找到该位置后进行遍历链表即可,删除也直接按照链表的删除即可完成。
image

posted on 2026-03-21 21:13  richu  阅读(11)  评论(0)    收藏  举报