7、开放地址法实现(下):删除操作的陷阱(惰性删除)、二次探测实现、双重哈希实现

好,咱们接着聊开放地址法。上一节我们把线性探测的代码撸了一遍,你可能会觉得:嗯,好像也没那么复杂嘛。但别急,真正的坑还没踩呢。今天我要讲的这三个东西——惰性删除二次探测双重哈希——才是开放地址法里最容易翻车的地方。

7.1 删除操作的陷阱:为什么不能直接删?

先问一个问题:在开放地址法的哈希表里,你找到要删的元素,直接把它置空(比如设成 NULL 或 0),行不行?

我告诉你,绝对不行。我在项目里第一次这么干的时候,差点把线上服务搞崩了。

原因很简单:开放地址法的探测序列是连续的。假设你插入了 A、B、C 三个元素,它们因为哈希冲突,依次占用了位置 3、4、5。现在你把 B 删了,位置 4 变成空。下次你查找 C 的时候,从位置 3 开始线性探测,走到位置 4 发现是空的——按照开放地址法的规则,空位就意味着查找结束,直接返回“没找到”。但 C 明明就在位置 5 啊!

这就是删除操作带来的探测链断裂问题。说白了,你删了一个中间节点,后面的元素就全“失联”了。

⚠️ 警告: 开放地址法中,绝对不能物理删除元素。直接置空会破坏探测链,导致后续元素无法被找到。

7.2 惰性删除:给元素打上“墓碑”标记

那怎么办?业界通用的做法是——惰性删除(Lazy Deletion)。

思路很简单:不真的把元素删掉,而是给它打一个特殊的标记,比如叫 DELETED 或“墓碑”。查找的时候,遇到墓碑不能停,要继续往后找。插入的时候,遇到墓碑可以覆盖。

我个人的习惯是用一个枚举来定义三种状态:

typedef enum {
    EMPTY,      // 空位
    OCCUPIED,   // 已占用
    DELETED     // 已删除(墓碑)
} SlotStatus;

每个槽位除了存数据,还要存一个状态字段。这样查找时遇到 DELETED 就跳过继续往后探测,插入时遇到 EMPTYDELETED 都可以放进去。

💡 小技巧: 墓碑标记虽然解决了查找问题,但会让表越来越“虚”。当墓碑数量超过一定阈值(比如 30%),我建议做一次重哈希,把墓碑清理掉,重新排列所有元素。

我曾经在一个缓存系统里用过惰性删除,结果忘了做重哈希,表里堆了 40% 的墓碑,查找效率直接掉到 O(n)。嗯,那天的排查过程,至今记忆犹新。

7.3 二次探测:解决线性探测的“聚集”问题

线性探测有个毛病——一次聚集。冲突的元素会挤成一团,越挤越大,像个雪球。你想想看,如果位置 3、4、5 都被占了,下一个冲突的元素就得跑到位置 6,然后位置 7……这团“拥堵区”会越来越长。

二次探测就是为了解决这个问题。它的探测步长不是固定的 1,而是 1², 2², 3², … 这样跳着走。

公式是:index = (hash(key) + i²) % table_size,其中 i 从 0 开始递增。

举个例子:假设哈希值是 3,表大小是 11。

  • i=0:位置 3
  • i=1:位置 4(3+1)
  • i=2:位置 7(3+4)
  • i=3:位置 1(3+9=12,12%11=1)

你看,探测的位置跳开了,不会挤在一起。这就是二次探测的核心思想——分散探测路径

📌 注意: 二次探测也有自己的问题——二次聚集。虽然比线性探测好很多,但哈希值相同的元素,它们的探测序列是完全一样的。另外,如果表大小不是质数,二次探测可能无法遍历所有槽位。

我建议表大小选一个4k+3 形式的质数,比如 7、11、19、43。这样二次探测可以保证遍历整个表。

7.4 双重哈希:最灵活的探测策略

如果说二次探测是“跳着走”,那双重哈希就是“随机走”——当然,这个随机是伪随机的,由第二个哈希函数决定。

公式:index = (hash1(key) + i * hash2(key)) % table_size

这里有两个哈希函数:

  • hash1(key):决定初始位置
  • hash2(key):决定探测步长

因为每个 key 的 hash2 值不同,所以即使两个 key 的初始位置相同,它们的探测序列也不一样。这就彻底避免了聚集问题。

举个例子:

hash1(key) = key % 11
hash2(key) = 1 + (key % 9)   // 保证步长不为0

插入 key=15:
  hash1 = 15 % 11 = 4
  hash2 = 1 + (15 % 9) = 7
  探测序列:4, (4+7)%11=0, (4+14)%11=7, ...

插入 key=26:
  hash1 = 26 % 11 = 4(冲突了)
  hash2 = 1 + (26 % 9) = 8
  探测序列:4, (4+8)%11=1, (4+16)%11=9, ...

你看,两个 key 初始位置都是 4,但后续的探测路径完全不同。这就是双重哈希的魅力。

💡 实战建议: 双重哈希的 hash2 必须满足两个条件:
1. 永远不为 0(否则死循环)
2. 与表大小互质(保证能遍历所有槽位)
我常用的写法是 hash2 = 1 + (key % (table_size - 1)),简单有效。

7.5 三种探测策略对比

策略 探测公式 优点 缺点 适用场景
线性探测 i = 1, 2, 3, ... 实现简单,缓存友好 一次聚集严重 表小、负载因子低
二次探测 i = 1², 2², 3², ... 减少聚集 二次聚集,可能无法遍历全表 中等规模,负载因子 < 0.5
双重哈希 i * hash2(key) 无聚集,分布均匀 计算开销稍大 高负载、高性能要求

7.6 知识结构图

下面这张图帮你理清今天讲的核心逻辑:

开放地址法核心知识点 开放地址法 删除操作 惰性删除(墓碑标记) 重哈希清理墓碑 二次探测 步长:1², 2², 3²... 表大小选 4k+3 质数 双重哈希 hash1 + i * hash2 hash2 ≠ 0 且与表大小互质

7.7 代码实现:双重哈希的插入与查找

最后,我贴一段双重哈希的插入和查找代码。你注意看墓碑标记的处理:

#define TABLE_SIZE 11

typedef struct {
    int key;
    int value;
    SlotStatus status;
} HashEntry;

HashEntry table[TABLE_SIZE];

int hash1(int key) {
    return key % TABLE_SIZE;
}

int hash2(int key) {
    return 1 + (key % (TABLE_SIZE - 1));
}

void insert(int key, int value) {
    int i = 0;
    int index;
    int first_deleted = -1;  // 记录第一个遇到的墓碑位置

    while (i < TABLE_SIZE) {
        index = (hash1(key) + i * hash2(key)) % TABLE_SIZE;

        if (table[index].status == EMPTY) {
            // 如果之前遇到过墓碑,优先覆盖墓碑
            if (first_deleted != -1) {
                index = first_deleted;
            }
            table[index].key = key;
            table[index].value = value;
            table[index].status = OCCUPIED;
            return;
        }

        if (table[index].status == DELETED && first_deleted == -1) {
            first_deleted = index;  // 记下第一个墓碑
        }

        if (table[index].status == OCCUPIED && table[index].key == key) {
            table[index].value = value;  // 更新已有值
            return;
        }

        i++;
    }

    // 表满了,需要扩容
    printf("Hash table is full!\n");
}

int search(int key) {
    int i = 0;
    int index;

    while (i < TABLE_SIZE) {
        index = (hash1(key) + i * hash2(key)) % TABLE_SIZE;

        if (table[index].status == EMPTY) {
            return -1;  // 遇到空位,肯定没找到
        }

        if (table[index].status == OCCUPIED && table[index].key == key) {
            return table[index].value;  // 找到了
        }

        // 遇到墓碑继续往后找
        i++;
    }

    return -1;  // 遍历完整个表都没找到
}

这段代码里有个细节:插入时如果遇到墓碑,我不会立刻覆盖,而是先记下第一个墓碑的位置。等真正找到空位或者确定要插入时,再回头覆盖墓碑。这样做的好处是——尽量把墓碑留在后面,减少对查找路径的影响。

嗯,这个小技巧是我在调优一个高并发哈希表时总结出来的。虽然提升不大,但积少成多嘛。


好了,今天的内容就到这里。开放地址法的删除、二次探测、双重哈希,这三个点你消化一下。代码最好自己敲一遍,光看是记不住的。

公众号:蓝海资料掘金营,微信 deep3321