数据结构:开放散列(Open Hashing)

目录

[为什么叫开散列法(Open Hashing)?](#为什么叫开散列法(Open Hashing)?)

[为什么叫链地址法(Separate Chaining)?](#为什么叫链地址法(Separate Chaining)?)

回到根本问题

从思想一步步到代码结构

第一步:改造"节点"

第二步:定义"表"

第三步:初始化"表"

核心操作的实现

插入 (Insertion)

查找 (Search)

性能分析 (为什么它快?)


我们已经从根本上理解了为什么需要哈希,以及哈希冲突是不可避免的。我们还提到了解决冲突的两种主要思路,其中一种就是开放散列(Open Hashing),它最经典的实现方式就是 链地址法(Separate Chaining,我们常称之为"拉链法")。

数据结构:哈希(Hashing)-CSDN博客

今天,我们就从第一性原理出发,一步步推导并实现它。

为什么叫开散列法(Open Hashing)?

开散列法这个名字的重点在于"开"(Open)。在哈希表中,如果多个键通过哈希函数映射到了同一个索引(即发生了哈希冲突),开散列法的解决方案是:

在同一个哈希表索引内,为每个冲突的元素开辟一个独立的存储空间,通常是一个链表。

你可以把哈希表的每一个位置想象成一个"门"。当数据来到这个位置时,这个门是开着的,里面可以容纳多个元素,而不是只有一个。

每个元素通过一个"链条"(即指针)连接起来,形成一个独立的"房间"(链表)。因此,这种方法叫做开散列法,因为它不限制每个位置只能存放一个元素

为什么叫链地址法(Separate Chaining)?

链地址法这个名字则更直接地描述了其实现方式。

  • 链(Chaining): 这个词指的就是链表。当发生哈希冲突时,所有冲突的元素都会被添加到同一个位置上的链表中。这个链表将这些元素链接在一起。

  • 地址(Separate): 这个词强调的是,每个链表都是独立于哈希表本身的。虽然链表的头节点位于哈希表的一个索引位置上,但整个链表本身是单独存在的,它不占用哈希表中其他位置的空间。

  • 也就是说,它将冲突的元素分开存储在独立的链表中,而不是把它们挤在哈希表本身的其他空闲位置。

所以,开散列法和链地址法是同一种哈希冲突解决方法的两种不同称呼,它们从不同角度描述了同一件事:

  • 开散列法强调的是宏观概念:每个位置的存储是开放的,可以容纳多个元素。

  • 链地址法强调的是微观实现:通过独立的链表来解决冲突。

在大多数计算机科学教材中,这两种叫法是可以互换的,它们都指的是使用链表来处理哈希冲突的方法。


回到根本问题

我们的核心矛盾是:多个不同的 Key,经过哈希函数计算后,得到了同一个数组下标 index

想象一个公寓楼,哈希函数就是前台接待员,他会告诉你应该去几号房间 (index)。现在,张三和李四都被告知要去 302 房间。

拉链法 (Chaining) 的思路是:我们对 302 房间进行改造,不再让它只能住一个人。我们把它变成一个"入口",这个入口后面挂了一串独立的"子房间"。

张三来了,住进第一个子房间;李四来了,住进第二个子房间。他们都通过 302 这个入口进入,但互不干扰。

这个"子房间"串,在我们数据结构的世界里,最灵活、最简单的实现是什么?就是链表 (Linked List)

拉链法的本质思想,是把哈希表的每一个"槽位 (slot)"从"只能存放一个元素的空间"转变为"可以存放多个元素的容器的入口"。而"链表"就是实现这个容器最自然的选择。

所以,我们的哈希表,就不再是一个"学生数组",而是一个"学生链表的头指针数组"。


从思想一步步到代码结构

基于这个核心思想,我们来设计我们的代码结构。

第一步:改造"节点"

既然要用链表,那么我们存储的数据单元(我们称之为节点 Node)就必须具备链表节点的特征。一个链表节点需要什么?

  1. 存储自身数据的地方。

  2. 一个指向下一个节点的指针。

所以,我们之前的 Student 结构体需要升级。

cpp 复制代码
// 之前是这样:
// struct Student {
//     int id;
//     char name[50];
// };

// 现在,为了形成链表,我们必须加入 next 指针
// 我们直接叫它 Node,更能体现它的本质
struct Node {
    int id;          // 数据域:学号
    char name[50];   // 数据域:姓名
    Node* next;      // 指针域:指向下一个节点
};

这个 Node* next 就是拉链法的灵魂。它让数据可以在哈希表的"槽位"之外,进行无限延伸。

第二步:定义"表"

我们的哈希表 hashTable 是什么?它是一个数组。

数组的每个元素是什么?根据我们的思想,每个元素都是一条链表的头指针 。一条空链表的头指针是什么?是 NULL

cpp 复制代码
#include <iostream>
#include <string.h>

// 哈希表的大小,仍然推荐是素数
const int TABLE_SIZE = 11;

// 哈希表的定义:
// 它是一个数组,数组的每个元素都是一个 Node 类型的指针。
// 这些指针将作为各个链表的头指针。
Node* hashTable[TABLE_SIZE];

第三步:初始化"表"

当我们刚创建一个哈希表时,它应该是空的。这意味着,每一条链表都应该是空的。因此,我们需要一个初始化函数,把所有链表的头指针都设置为 NULL

cpp 复制代码
// 初始化哈希表
void init_hash_table() {
    for (int i = 0; i < TABLE_SIZE; ++i) {
        // 将每个槽位的头指针都置为 NULL,表示所有链表初始为空
        hashTable[i] = NULL;
    }
}

至此,我们已经有了一个空荡荡的、准备就绪的哈希表框架。它有 TABLE_SIZE 个"链表入口",每个入口都指向 NULL


核心操作的实现

现在我们来逐步实现哈希表的关键操作:插入和查找。

插入 (Insertion)

当一个新学生(新的 Node)要插入时,我们首先要问:他应该去哪条链表?

答案由哈希函数决定。 int index = hash_function(key);

创建节点: 我们需要为这个新学生在内存中创建一个新的 Node

Node* newNode = new Node;

如何插入链表? 这是关键。将一个新节点插入到一个链表,最简单高效的方法是什么?

头插法 (Insert at the head)。为什么?

因为我们不需要遍历链表,直接在头部操作指针即可,时间复杂度是 O(1)。

  • 新节点的 next 应该指向谁?应该指向这个链表原来的第一个节点

  • 这个链表新的第一个节点应该是谁?应该是这个新节点。

cpp 复制代码
// 哈希函数我们保持不变
int hash_function(int key) {
    return key % TABLE_SIZE;
}

// 插入操作
void insert(int id, const char* name) {
    // 第 1 步:计算哈希地址,确定要操作哪条链表
    int index = hash_function(id);

    // 第 2 步:创建新节点并填充数据
    Node* newNode = new Node;
    newNode->id = id;
    strcpy(newNode->name, name);
    // newNode->next 暂时可以不设置,因为马上会被覆盖

    // 第 3 步:执行头插法
    // a. 让新节点的 next 指向当前链表的第一个节点
    newNode->next = hashTable[index];
    // b. 让哈希表该位置的头指针,指向这个新节点
    hashTable[index] = newNode;

    std::cout << "学号 " << id << " (" << name << ") 插入到索引 " << index << " 的链表中。" << std::endl;
}

看,这个过程非常清晰。无论 hashTable[index] 原来是 NULL(空链表)还是指向一个长长的链表,这套头插法逻辑都完美适用。


查找 (Search)

  • 去哪找? 同样,先通过哈希函数确定要去哪条链表里寻找。 int index = hash_function(key);

  • 如何查找? 既然目标在一个特定的链表里,我们只需要从这条链表的头节点开始,顺着 next 指针一个个往下比对,直到找到,或者走到链表末尾(NULL)都没找到。

cpp 复制代码
// 查找操作
Node* search(int id) {
    // 第 1 步:计算哈希地址,锁定链表
    int index = hash_function(id);

    // 第 2 步:从该链表的头节点开始遍历
    Node* current = hashTable[index];

    while (current != NULL) {
        // a. 检查当前节点的 id 是否匹配
        if (current->id == id) {
            // 找到了!返回当前节点的地址
            return current;
        }
        // b. 不匹配,移动到下一个节点
        current = current->next;
    }

    // 第 3 步:如果循环走完(current 变为 NULL),说明没找到
    return NULL;
}

这个查找逻辑就是标准的链表遍历,非常基础。哈希的作用在于,它把一个在 N 个元素中的查找问题,极大地缩小为了在一个平均长度为 fracNM (M为表大小) 的小链表中的查找问题。


性能分析 (为什么它快?)

我们引入一个非常重要的概念:负载因子 (Load Factor),通常用 α 表示。

负载因子(α)的计算很简单,核心是 "表里实际装的元素数量" 除以 "表本身的总容量",公式写出来是这样的:

α = 表中元素的个数 ÷ 哈希表的大小 = N ÷ TABLE_SIZE

这个 α 的物理意义是什么?它代表了每条链表的平均长度。

插入操作:我们总是使用头插法,所以插入操作的时间复杂度永远是 O(1),与链表多长无关。

查找操作

  • 最坏情况 : 运气极差,哈希函数设计得极烂,所有 N 个元素都哈希到了同一个 index。此时哈希表退化成了一个长度为 N 的单链表,查找时间复杂度是 O(N)。

  • 平均情况: 这才是我们关心的。在一个设计良好的哈希表中,元素会比较均匀地分布。查找一个元素,平均需要遍历多长的链表?就是平均长度α。

  • 所以一次成功的查找,平均需要比较 α/2 次,一次不成功的查找,需要比较 α 次。因此,我们说查找的平均时间复杂度是 O (1+α)。

拉链法的性能关键在于控制负载因子 α。只要我们保证哈希函数足够散列,并且让 TABLE_SIZE 和元素数量 N 保持一个合理的比例(通常让 α 在 1 附近),那么 O(1+α) 就近似于 O(1)。这就实现了我们最初追求的"近乎常数时间"的查找效率。

当 α 过大时(比如大于1或2),意味着链表开始变得太长,效率下降,这时就需要进行 "再哈希 (Rehashing)":创建一个更大的哈希表(比如两倍大小),把所有旧表的元素重新计算哈希值,放入新表。这是一个更高级的话题,但其根本目的就是为了降低 α,维持哈希表的 O(1) 性能。

相关推荐
技术小泽7 小时前
Redis-底层数据结构篇
数据结构·数据库·redis·后端·性能优化
白菜帮张同学7 小时前
LP嵌入式软件/驱动开发笔试/面试总结
数据结构·驱动开发·经验分享·笔记·学习·算法·面试
蒹葭玉树7 小时前
【C++上岸】C++常见面试题目--数据结构篇(第十七期)
数据结构·c++·面试
熊大与iOS8 小时前
iOS 长截图的完美实现方案 - 附Demo源码
android·算法·ios
nonono8 小时前
数据结构——树(04二叉树,二叉搜索树专项,代码练习)
数据结构
Elylicery8 小时前
【职业】算法与数据结构专题
数据结构·算法
岁月静好20258 小时前
Leetcode二分查找(3)
算法·leetcode·职场和发展
一支鱼8 小时前
leetcode-4-寻找两个正序数组的中位数
算法·leetcode·typescript
Christo39 小时前
TSMC-1987《Convergence Theory for Fuzzy c-Means: Counterexamples and Repairs》
人工智能·算法·机器学习·kmeans