CC++ & Algorithm

冲突处理方法(链地址法与开放地址法)

较难3
语言版本:通用
概述:当不同键映射到相同哈希值时,我们有多种方法解决冲突,包括链地址法和开放地址法。

哈希表冲突处理:链地址法与开放地址法(生活版)

你有没有遇到过这种情况?去班级信箱拿信,发现你的信和别人的信都塞进了同一个编号的格子?这就是哈希表里的“冲突”——两个不同的键(比如你的名字和同学的名字)通过计算(哈希函数)得到了同一个位置编号。既然冲突无法避免,我们就得想办法妥善处理。这篇文章就用生活里最熟悉的例子,把两种最常用的冲突处理方法讲明白。

冲突是怎么来的?

哈希表就像一个带编号的储物柜,每个编号对应一个格子。哈希函数就是把你的物品(键)变成格子编号的“翻译官”。比如:编号 = 键 % 柜子总数。但柜子数量有限(比如只有10格),而你可能有无穷多个键,所以不可避免会出现两个不同的键映射到同一编号。这时候就发生了冲突

方法一:链地址法——把格子变成“一筐钥匙”

链地址法的思路很简单:把每个格子变成一个小篮子(链表或动态数组),所有撞到同一编号的键,全部放进这个篮子里,用一根绳子串起来。找东西的时候,先找到篮子编号,再在篮子里挨个翻找。

生活中的比喻

想象你家里有一个大书架,每层隔板可以放一本书。但今天你和弟弟都想在“第3层”放书。链地址法的做法是:把第3层的隔板换成一个大的收纳盒,你们俩的书都放进去,书脊朝外。以后找书时,先看是哪一层,然后从收纳盒里一本本找。

示意图

哈希表数组(大小M)
+------+   +------+   +------+   +------+
| 0    |   | 1    |   | 2    |   | 3    |
+------+   +------+   +------+   +------+
   |           |           |          |
   v           v           v          v
 [节点]      [节点]      [节点]     [节点]
  / \         / \         / \        / \
 ...         ...         ...        ...

每个格子是一个链表的头,插入时直接挂到链表末尾或头部。

优点

  • 简单直接,像把钥匙挂到挂钩上一样。
  • 删除操作非常方便:直接找到链表中的节点,摘掉即可。
  • 负载因子(每个格子的平均元素个数)可以大于1,也就是说,即使元素很多,也能正常工作(只是链表变长)。

缺点

  • 需要额外内存存储指针(比如C++里链表节点有next指针)。
  • 如果哈希函数设计不好,某个格子里的链表会变得特别长,查找就会退化成在长链表里一个个找,速度变慢(O(n))。

新手常犯的错误

  1. 忘了查找时要遍历整个链表:以为直接取第一个元素就是答案,导致漏掉其他键。
  2. 链表插入时忘记考虑重复键:应该先检查链表里是否已经有相同的键,如果有则覆盖或返回失败。
  3. 忽略内存释放:用链表实现时,删除节点后要记得释放内存(C/C++里容易忘记)。

代码示例(C++链地址法,简化版)

#include <iostream>
#include <vector>
#include <list>   // 使用标准库链表
using namespace std;

class ChainedHashTable {
private:
    int bucket_count;            // 桶的个数
    vector<list<int>> buckets;   // 每个桶是一个链表

    int hashFunction(int key) {
        return key % bucket_count;
    }

public:
    ChainedHashTable(int size) : bucket_count(size) {
        buckets.resize(bucket_count);
    }

    // 插入一个键
    void insert(int key) {
        int idx = hashFunction(key);
        // 检查是否已存在(链表遍历)
        for (int val : buckets[idx]) {
            if (val == key) return;   // 已存在,不重复插入
        }
        buckets[idx].push_back(key);  // 插到链表末尾
    }

    // 查找键是否存在
    bool search(int key) {
        int idx = hashFunction(key);
        for (int val : buckets[idx]) {
            if (val == key) return true;
        }
        return false;
    }

    // 删除键
    bool remove(int key) {
        int idx = hashFunction(key);
        // list的remove方法会删除所有匹配的元素,这里只删一个
        auto it = buckets[idx].begin();
        while (it != buckets[idx].end()) {
            if (*it == key) {
                buckets[idx].erase(it);
                return true;
            }
            ++it;
        }
        return false;
    }

    void print() {
        for (int i = 0; i < bucket_count; ++i) {
            cout << i << ": ";
            for (int val : buckets[i]) cout << val << " -> ";
            cout << "空" << endl;
        }
    }
};

int main() {
    ChainedHashTable ht(5);          // 5个桶
    ht.insert(10);                   // 10 % 5 = 0 => 桶0
    ht.insert(15);                   // 15 % 5 = 0 => 桶0(冲突,放在链表里)
    ht.insert(22);                   // 22 % 5 = 2 => 桶2
    ht.insert(3);                    // 3 % 5 = 3 => 桶3

    cout << "链地址法哈希表:" << endl;
    ht.print();

    cout << "查找15: " << (ht.search(15) ? "找到" : "未找到") << endl;
    ht.remove(15);
    cout << "删除15后查找15: " << (ht.search(15) ? "找到" : "未找到") << endl;
    return 0;
}

方法二:开放地址法——找不到空位就往前走一步

开放地址法的思路是:如果目标格子已经被占用了,就按照某种规则去寻找下一个空位。整个哈希表只有一张连续的大桌子,所有键都直接放在桌子上的某个位置,没有额外的链表。

生活中的比喻

你参加一个考试,考场里的座位编号是1到30。你拿到的座位号是13号,但你走到13号发现已经坐了一个人。怎么办?监考老师说:“去找旁边空的座位!”于是你检查14号、15号……直到找到第一个空位坐下。这就是线性探测。但这样容易形成“人堆”:如果连续几个座位都有人,后来的人就要走很远。

三种主要的探测方式

  1. 线性探测(Linear Probing)
    公式:h(key, i) = (h(key) + i) % M,i = 0, 1, 2, …
    如果位置被占,就+1、+2……直到找到空位。
    优点:简单。缺点:容易产生“聚集”(连续占用的区域越来越大),查找速度会变慢。

  2. 二次探测(Quadratic Probing)
    公式:h(key, i) = (h(key) + i^2) % M,i = 0, 1, 2, …
    第一次冲突跳1^2=1步,第二次跳2^2=4步,第三次跳9步……这样能跳开连续区域,减少聚集。
    注意:可能跳不满所有位置,但通常设计得当就没问题。

  3. 双重散列(Double Hashing)
    公式:h(key, i) = (h1(key) + i * h2(key)) % M
    用第二个哈希函数计算步长,每个键的探测序列都不一样,能有效避免聚集。

新手常犯的错误

  1. 删除操作后查找出错:如果直接将被删除的位置标记为“空”,会导致后续插入或查找时“断链”。例如:先插入10(位置0)、20(位置1),再删除10,然后把位置0设回空。此时查找20时,从hash(20)=0开始,发现位置0是空,就会误以为20不存在!所以开放地址法删除时,不能简单地置空,而要用“已删除”标记(惰性删除)。
  2. 哈希表满时无限循环:线性探测时如果表满了,会一直循环直到回到起点,陷入死循环。需要提前判断表是否还有空位。
  3. 二次探测只检查一半位置:当M是2的幂时,二次探测只能访问一半位置,可能漏掉空位。一般要求M是素数。

代码示例(线性探测,带惰性删除标记)

#include <iostream>
#include <vector>
using namespace std;

enum State { EMPTY, ACTIVE, DELETED };  // 状态:空、有效、已删除

class OpenHashTable {
private:
    struct Node {
        int key;
        State state;
        Node() : key(0), state(EMPTY) {}
    };
    vector<Node> table;
    int capacity;               // 表大小

    int hashFunction(int key) {
        return key % capacity;
    }

public:
    OpenHashTable(int size) : capacity(size) {
        table.resize(capacity);
    }

    // 插入键(如果已存在则返回false)
    bool insert(int key) {
        int index = hashFunction(key);
        int start = index;
        while (table[index].state == ACTIVE) {
            if (table[index].key == key) {
                return false;   // 已存在
            }
            index = (index + 1) % capacity;
            if (index == start) {
                return false;   // 表满了
            }
        }
        table[index].key = key;
        table[index].state = ACTIVE;
        return true;
    }

    // 查找键
    bool search(int key) {
        int index = hashFunction(key);
        int start = index;
        while (table[index].state != EMPTY) {
            if (table[index].state == ACTIVE && table[index].key == key) {
                return true;
            }
            index = (index + 1) % capacity;
            if (index == start) break;
        }
        return false;
    }

    // 删除键(标记为DELETED)
    bool remove(int key) {
        int index = hashFunction(key);
        int start = index;
        while (table[index].state != EMPTY) {
            if (table[index].state == ACTIVE && table[index].key == key) {
                table[index].state = DELETED;
                return true;
            }
            index = (index + 1) % capacity;
            if (index == start) break;
        }
        return false;
    }

    void print() {
        for (int i = 0; i < capacity; ++i) {
            cout << i << ": ";
            if (table[i].state == EMPTY) cout << "空";
            else if (table[i].state == DELETED) cout << "已删除";
            else cout << table[i].key;
            cout << endl;
        }
    }
};

int main() {
    OpenHashTable ht(7);
    ht.insert(10);   // 10%7=3
    ht.insert(22);   // 22%7=1
    ht.insert(31);   // 31%7=3 -> 冲突,线性探测到4
    ht.insert(4);    // 4%7=4 -> 冲突(被31占了),探测到5
    ht.insert(15);   // 15%7=1 -> 冲突,探测到2
    ht.insert(28);   // 28%7=0
    ht.insert(17);   // 17%7=3 -> 冲突,探测到4(已占)->5(已占)->6

    cout << "开放地址法(线性探测)表:" << endl;
    ht.print();

    cout << "查找22: " << (ht.search(22) ? "找到" : "未找到") << endl;
    ht.remove(22);
    cout << "删除22后查找22: " << (ht.search(22) ? "找到" : "未找到") << endl;

    // 再插入一个新键,验证惰性删除不影响
    ht.insert(35);   // 35%7=0 -> 冲突(0已有28),探测到1(已删除)->插入1
    cout << "插入35后:" << endl;
    ht.print();
    return 0;
}

两种方法对比(表格升级版)

特性链地址法开放地址法
内存使用每个桶要存指针(链表节点),额外开销大只需一个数组,无指针开销,但需要更大初始空间(负载因子通常<0.7)
负载因子可以大于1,比如每个桶平均有3个元素必须小于1,否则几乎找不空位,通常建议小于0.7
删除操作简单,直接从链表移除节点必须用惰性删除(标记),否则会断链;而且标记多了会浪费空间
缓存性能链表节点在内存不连续,缓存不友好数组连续,访问相邻位置速度快(适合CPU缓存)
实现难度简单,但要注意链表操作较复杂,需处理探测、惰性删除、表满等
对哈希函数的要求比较宽容,即使分布不均匀,也只是链表变长要求严格,不均匀会导致聚集严重
常见场景不确定数据量、删除频繁、内存充足(如Python的dict内部用链地址法)内存受限、查询密集、数据量可预估(如某些系统库)

总结与相关指引

  • 冲突是哈希表绕不开的问题,两种方法各有千秋。
  • 链地址法像“多格收纳盒”,适合元素多且经常变动的情况。
  • 开放地址法像“连续座位”,适合元素少且追求速度的情况。
  • 掌握这两种方法,你就能自己设计哈希表了!接下来可以学习:
    • 哈希函数的设计:如何让键均匀分布,减少冲突。
    • 动态扩容:当负载因子过高时,如何把整个表扩大一倍(重新哈希)。
    • 完美哈希:如果所有键已知且静态,能否实现零冲突?

现在,你已经知道了哈希表冲突处理的两种经典方法。下次写哈希表时,想想你的数据是像“班级信箱”还是“电影院座位”,选对方法,事半功倍!

例题精讲

1单选题

哈希表使用链地址法解决冲突时,若哈希函数为H(key)=key mod 7,依次插入关键字12, 22, 29, 36, 15, 7,则哈希地址为1的链表中包含几个关键字?

A1
B2
C3
D4
2判断题

开放地址法中的线性探测法容易产生“二次聚集”现象,而链地址法不存在这种现象。

3填空题
以下函数使用链地址法向哈希表插入一个关键字,请补充完整。假设哈希表结构为Node* table[TABLE_SIZE],每个节点有key和next指针。

void insert(int key) {
    int index = key % TABLE_SIZE;
    Node* newNode = (Node*)malloc(sizeof(Node));
    newNode->key = key;
    newNode->next = ___;
    table[index] = newNode;
}
4单选题

在开放地址法中,若采用二次探测法处理冲突,哈希函数H(key)=key%13,已存入关键字16、29、38、47,现插入45,其探测序列的最后一个探测位置是?

A6
B7
C8
D10
5填空题
以下函数使用开放地址法(线性探测)在哈希表中查找关键字key,找到返回索引,否则返回-1。请补充完整。

int search(int key) {
    int index = key % TABLE_SIZE;
    int start = index;
    while (table[index] != EMPTY) {
        if (table[index] == key) return index;
        index = (index + 1) % TABLE_SIZE;
        if (index == start) ___;
    }
    return -1;
}