数据结构——哈希表

哈希表

一、什么是哈希表?

1. 从查找说起

在之前学习了顺序查找折半查找索引查找。这些查找方法的共同点是通过比较关键字来定位元素,要么逐个比较,要么在有序表中折半比较,要么先在索引表中比较再在块内比较。

但有没有一种方法,可以不经过任何比较,直接通过关键字找到元素的位置呢?

就像去体育馆存包——储物柜上有编号,你根据手环上的号码直接走到对应的柜子前,打开就能存取物品,完全不需要挨个去试哪个柜子是空的。这就是哈希表(Hash Table)的核心思想。

哈希表(Hash Table,又称散列表)是一种通过关键字(Key)直接计算出存储位置的数据结构。它利用一个函数(称为哈希函数 / 散列函数)将关键字映射到表中的某个位置,从而实现 $O(1)$ 时间内的查找、插入和删除。

2. 哈希表的基本思想

哈希表的基本思想可以用一个公式概括:

$$\text{存储位置} = \text{Hash}(\text{关键字})$$

其中 $\text{Hash}$ 是一个函数,它接收关键字作为输入,输出该关键字在表中的存储位置(通常是一个整型下标)。

// 哈希表最理想的样子:直接通过关键字找到位置
int position = Hash(key);   // 算出位置
data[position] = value;     // 直接存取

3. 哈希表的几个重要概念

概念 含义
关键字(Key) 用来标识数据元素的唯一标识
哈希函数(Hash Function) 将关键字映射为存储位置的函数
哈希表(Hash Table) 存储数据元素的数组(或结构)
哈希地址(Hash Address) 哈希函数计算出的存储位置
冲突(Collision) 不同关键字映射到同一个位置
同义词(Synonym) 发生冲突的多个关键字

注意:哈希表是一种存储结构(物理结构),属于"散列存储"。它与顺序存储、链式存储、索引存储并列,是四种基本的存储方式之一。


二、哈希函数

1. 哈希函数的核心性质

哈希函数是将关键字映射为存储位置的函数,它有两个最核心的性质:

① 确定性(Determinism)——相同的参数必须返回相同的值

这是哈希表能工作的前提。如果用同一个关键字 key 调用哈希函数,有时得到地址 3、有时得到地址 7,那查找就完全无法进行了。不管调用多少次,Hash("张三") 都必须返回同一个地址。

$$
\text{Hash}(key) \quad \text{每次调用结果都相同}
$$

② 均匀性(Uniformity)——不同的参数应尽可能返回不同的值

理想情况下,不同的关键字应该映射到不同的存储位置,这样才能一对一地存放,查找时一步到位。

$$
\text{如果 } key_1 \neq key_2 \text{,应尽量让 } \text{Hash}(key_1) \neq \text{Hash}(key_2)
$$

满足上述两个性质后,哈希表的工作方式就是:

  • :计算 position = Hash(key),把数据放到该位置
  • :计算 position = Hash(key),直接从该位置读取数据

整个过程只需要一次函数计算,没有任何比较——这就是哈希表能做到 $O(1)$ 的原因。

2. 冲突的出现

但现实中没有那么完美——哈希表的地址空间是有限的(比如只有 100 个位置),而关键字的取值范围可能非常大(比如学号有 8 位数,共 10^8 种可能)。当关键字数量超过地址空间时,必然会有不同的关键字映射到同一个地址。

即使关键字数量不超过地址空间,由于哈希函数是对关键字的一种"压缩映射"(将大范围映射到小范围),不同关键字算出的地址偶然相同也在所难免:

$$\text{Hash}(key_1) = \text{Hash}(key_2) \quad \text{但} \quad key_1 \neq key_2$$

这就是冲突(Collision)。因此,冲突是哈希表无法完全避免的。当冲突发生时,需要有策略来解决。常见的冲突处理方法分为两大类:开放地址法链地址法,将在下一章详细讨论。

3. 什么是好的哈希函数?

哈希函数的设计直接影响哈希表的性能和冲突的多少。一个好的哈希函数应当满足:

  1. 计算简单:哈希函数的计算时间应尽可能短,不能比直接查找还慢
  2. 分布均匀:不同关键字应尽可能均匀地映射到整个地址空间,减少冲突
  3. 冲突尽可能少:即使是相似的关键字(如 "abc" 和 "abd"),也应映射到不同的位置

4. 常见的哈希函数

(1)直接定址法

直接取关键字本身或关键字的某个线性函数作为哈希地址:

$$\text{Hash}(key) = a \times key + b$$

其中 $a$ 和 $b$ 为常数。

// 例子:key 本身是学号(如 2024001),直接作为地址
int Hash(int key) {
    return key - 2024000;  // a = 1, b = -2024000
}

特点

  • 不会产生冲突(因为是一一映射)
  • 但要求关键字分布连续且范围较小,否则会造成大量空间浪费

适用场景:关键字是连续的整数,或者可以映射为连续的整数。

(2)除留余数法

用关键字除以一个不大于哈希表长度的正整数 $p$,取余数作为哈希地址:

$$\text{Hash}(key) = key \bmod p$$

其中 $p$ 一般取不大于表长 $m$ 的最大质数(或至少是一个质数),这样可以使关键字分布更均匀。

#define M 100  // 哈希表长度

int Hash(int key) {
    return key % 97;  // 97 是不大于 100 的最大质数
}

为什么 $p$ 要选质数? 如果 $p$ 是合数,比如 $p = 2^k$,那么实际上只取了 key 的最低 k 位,高位完全被丢弃,分布极不均匀。而质数可以保留关键字的全部信息,减少周期性模式带来的聚集。

特点

  • 实现最简单,应用最广泛
  • 需要精心选择 $p$ 以减小冲突

(3)平方取中法

先计算关键字平方的中间几位作为哈希地址。因为平方的中间几位与关键字的每一位都有关,分布较为均匀。

// 例子:key = 1234
// key^2 = 1522756,取中间两位 22 作为哈希地址
int Hash(int key) {
    int square = key * key;
    return (square / 100) % 100;  // 取中间两位
}

特点

  • 地址分布均匀,与关键字每一位都相关
  • 适合关键字位数较少、且不了解关键字分布的情况

(4)折叠法

将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后将这几部分叠加求和(舍弃进位)作为哈希地址。

// 例子:key = 1234567890,每 3 位一组
// 123 + 456 + 789 + 0 = 1368,取后 3 位 368
int Hash(int key) {
    int sum = 0;
    while (key > 0) {
        sum += key % 1000;  // 每次取低 3 位
        key /= 1000;
    }
    return sum % 1000;      // 取后 3 位
}

特点

  • 适合关键字位数较长的情况
  • 不需要了解关键字的分布

(5)数字分析法

分析关键字的各位数字分布情况,选取分布较均匀的几位作为哈希地址。

例如:有 100 个学生的学号:2024030120240302、……、20240400。前 4 位 "2024" 全部相同(无区分度),后 2 位 "01"~"40" 变化较大,可取后 2 位作为哈希地址。

特点

  • 需要预先知道关键字的分布情况
  • 适合关键字位数较多、且分布已知的场景

(6)字符串的哈希函数

对于字符串类型的关键字,需要将字符串转换为整数再进行哈希:

// 一种简单的字符串哈希:将字符串视为 base 进制数
int HashString(char *str) {
    int hash = 0;
    while (*str) {
        hash = hash * 31 + (*str);  // 31 是一个常用的质数因子
        str++;
    }
    return hash % M;
}

5. 哈希函数的选择

方法 适用场景 冲突概率
直接定址法 关键字连续分布 无冲突
除留余数法 通用,最常用 较低($p$ 选质数时)
平方取中法 不了解关键字分布 较低
折叠法 关键字位数较长 较低
数字分析法 关键字分布已知 最低(分析得当)

三、处理冲突的方法

如上所述,冲突是哈希表无法避免的。当不同关键字映射到同一个地址时,需要有具体的策略来解决。常见的方法分为两大类:开放地址法链地址法

1. 开放地址法

开放地址法(Open Addressing)的思想是:当发生冲突时,按照某种规则在哈希表中寻找另一个空闲位置来存放元素。

$$H_i = (H(key) + d_i) \bmod m$$

其中 $H(key)$ 是初始哈希地址,$d_i$ 是增量序列,$m$ 是表长。

根据增量序列 $d_i$ 的不同,开放地址法又分为以下几种:

(1)线性探测法

线性探测法(Linear Probing)的增量序列是:$d_i = 1, 2, 3, \dots, m-1$。即冲突后逐个往后找,直到找到空位或回到起点。

示例:哈希表长度 $m = 7$,哈希函数为 $\text{Hash}(key) = key \bmod 7$,依次插入关键字:50、62、43、57、48。

步骤 插入 key $\text{Hash}(key)$ 过程 结果数组
50 $50 \bmod 7 = 1$ 位置 1 空,直接放入 [ ][50][ ][ ][ ][ ][ ]
62 $62 \bmod 7 = 6$ 位置 6 空,直接放入 [ ][50][ ][ ][ ][ ][62]
43 $43 \bmod 7 = 1$ 位置 1 被占,查 2 空,放入 [ ][50][43][ ][ ][ ][62]
57 $57 \bmod 7 = 1$ 1、2 均被占,3 空,放入 [ ][50][43][57][ ][ ][62]
48 $48 \bmod 7 = 6$ 6 被占,查 0(回到开头)空,放入 [48][50][43][57][ ][ ][62]

查找过程:要查找 key = 57,先算得 $\text{Hash}(57) = 1$,检查位置 1 发现是 50(不等),继续往后直到位置 3 找到 57。如果要查找 key = 58($\text{Hash}=2$),位置 2、3、4 都不等,直到位置 5 为空,说明 58 不存在。

线性探测的缺点是聚集。 如上例所示,43、57 等本应分散的元素因为冲突而连续聚集在一起,形成"堆积"(Clustering)。堆积区域越长,后续插入和查找需要探测的位置就越多,性能下降越严重。

#define M 100

typedef struct {
    int key;   // 关键字
    int val;   // 值
    int flag;  // 标志:0 空,1 占用,2 已删除(惰性删除)
} HashEntry;

typedef struct {
    HashEntry data[M];
    int count;  // 当前元素个数
} HashTable;

// 线性探测插入
int HashInsert(HashTable *H, int key, int val) {
    int addr = key % M;            // 计算哈希地址
    int i = 0;
    while (H->data[addr].flag == 1 && H->data[addr].key != key) {
        i++;
        addr = (addr + 1) % M;     // 线性探测下一个位置
        if (i >= M) return 0;      // 表已满
    }
    H->data[addr].key = key;
    H->data[addr].val = val;
    H->data[addr].flag = 1;
    H->count++;
    return 1;
}

// 线性探测查找
int HashSearch(HashTable H, int key) {
    int addr = key % M;            // 计算哈希地址
    while (H.data[addr].flag != 0) {
        if (H.data[addr].flag == 1 && H.data[addr].key == key)
            return addr;           // 查找成功,返回位置
        addr = (addr + 1) % M;     // 继续探测
    }
    return -1;                     // 查找失败
}

惰性删除:线性探测中不能直接删除一个元素(将 flag 置为 0),否则会切断查找路径,导致后续元素查找不到。例如上例中删除 43 后将位置 2 置空,再查找 57 时,从位置 1 开始探测,到位置 2 发现为空就会误以为 57 不存在。正确的做法是将 flag 置为 2(已删除),查找时遇到已删除标记继续探测,插入时才可用该位置。

(2)平方探测法

平方探测法(Quadratic Probing)的增量序列是:$d_i = 1^2, -1^2, 2^2, -2^2, \dots$。即冲突后按下标跳 $\pm 1^2, \pm 2^2, \pm 3^2, \dots$ 的位置探测。

$$H_i = (H(key) + i^2) \bmod m \quad \text{或} \quad (H(key) - i^2) \bmod m \quad (i = 1, 2, 3, \dots)$$

示例:$m = 11$,$\text{Hash}(key) = key \bmod 11$,插入 47、36、58、69:

步骤 插入 key $\text{Hash}$ 冲突探测过程 存入位置
47 $47 \bmod 11 = 3$ 位置 3 空,直接放入 3
36 $36 \bmod 11 = 3$ 位置 3 被占,探测 $3+1^2=4$ 空 4
58 $58 \bmod 11 = 3$ 3、4 被占,探测 $3-1^2=2$ 空 2
69 $69 \bmod 11 = 3$ 3、4、2 被占,探测 $3+2^2=7$ 空 7

平方探测的优点:探测位置跳跃式分布,有效避免了线性探测的"聚集"问题。

平方探测的缺点:可能探测不到所有空位。有定理证明:当表长 $m$ 为 $4k+3$ 的质数时(如 7、11、19、23 等),平方探测法一定能探测到整个表空间。

(3)双散列法

双散列法(Double Hashing)使用两个哈希函数:第一个计算初始位置,第二个计算探测步长。

$$H_i = (H_1(key) + i \times H_2(key)) \bmod m$$

其中 $H_2(key)$ 必须与 $m$ 互质,且不能为 0。

int H1(int key) { return key % M; }
int H2(int key) { return (M - 2) - key % (M - 2); }  // 确保与 M 互质

// 双散列插入
int DoubleHashInsert(HashTable *H, int key, int val) {
    int addr = H1(key);
    int step = H2(key);          // 探测步长
    int i = 0;
    while (H->data[addr].flag == 1 && H->data[addr].key != key) {
        i++;
        addr = (addr + step) % M;  // 按步长跳跃探测
        if (i >= M) return 0;
    }
    H->data[addr].key = key;
    H->data[addr].val = val;
    H->data[addr].flag = 1;
    H->count++;
    return 1;
}

双散列法让每个关键字的探测步长各不相同,即使初始地址相同,后续的探测路径也不同,基本上避免了聚集问题,是最优秀的开放地址法之一。

2. 链地址法

链地址法(Separate Chaining)的思路与开放地址法完全不同,它不试图在表中找空位,而是让每个哈希地址对应一个链表,所有映射到同一地址的元素都存入该链表中。

哈希表数组             链表
[0]  →                NULL
[1]  →      [43|·] → [50|·] → [57|NULL]
[2]  →                NULL
[3]  →             [62|NULL]
[4]  →                NULL
[5]  →             [48|NULL]
[6]  →                NULL

(1)链地址法的实现

#define M 100

// 链表结点
typedef struct Node {
    int key;
    int val;
    struct Node *next;
} Node;

// 哈希表:每个位置是一个链表的头指针
typedef struct {
    Node *head[M];  // 每个位置存储一个链表头指针
    int count;      // 元素总数
} HashTable;

// 初始化
void InitHashTable(HashTable *H) {
    for (int i = 0; i < M; i++)
        H->head[i] = NULL;
    H->count = 0;
}

// 插入(头插法,也可以尾插)
int ChainInsert(HashTable *H, int key, int val) {
    int addr = key % M;
    Node *p = (Node *)malloc(sizeof(Node));
    p->key = key;
    p->val = val;
    p->next = H->head[addr];  // 新结点指向原来的链表头
    H->head[addr] = p;        // 更新链表头
    H->count++;
    return 1;
}

// 查找
int ChainSearch(HashTable H, int key) {
    int addr = key % M;
    Node *p = H.head[addr];
    while (p) {
        if (p->key == key)
            return p->val;   // 查找成功
        p = p->next;
    }
    return -1;               // 查找失败
}

// 删除
int ChainDelete(HashTable *H, int key) {
    int addr = key % M;
    Node *p = H->head[addr];
    Node *prev = NULL;
    while (p) {
        if (p->key == key) {
            if (prev == NULL)
                H->head[addr] = p->next;  // 删除第一个结点
            else
                prev->next = p->next;     // 跳过 p
            free(p);
            H->count--;
            return 1;
        }
        prev = p;
        p = p->next;
    }
    return 0;  // 未找到
}

为什么链地址法不需要惰性删除? 因为删除是对链表结点的直接操作——修改指针、释放内存,不会影响其他关键字的查找路径。每个关键字只在自己的链表中,删除一个不会"切断"其他人的路。

(2)链地址法的优缺点

优点

  • 处理冲突简单,不会出现开放地址法中的"聚集"现象
  • 删除操作方便,不需要惰性删除
  • 表长可以灵活设定,即使装填因子接近 1 也能正常工作
  • 适合元素个数不确定的场景,链表可以动态增长

缺点

  • 需要额外存储指针,存储密度较低
  • 查找时要遍历链表,缓存不友好(链表结点在内存中不连续)
  • 当链表过长时,查找性能退化为 $O(n)$

3. 两种冲突处理方法的对比

对比维度 开放地址法 链地址法
存储密度 高(只存数据) 较低(额外存指针)
查找性能 可能产生聚集,性能退化 链表变长时性能退化
删除操作 惰性删除,不能直接删除 直接修改指针删除
装填因子 通常 $\alpha \le 0.75$ $\alpha$ 可以接近或超过 1
缓存友好 好(连续内存访问) 差(链表结点分散)
实现复杂度 较低 略高(需要指针操作)

四、装填因子与性能分析

1. 装填因子

装填因子(Load Factor)$\alpha$ 是衡量哈希表"有多满"的指标:

$$\alpha = \frac{\text{表中已有的元素个数}}{\text{哈希表的长度}}$$

  • $\alpha$ 越大,表越满,冲突概率越高,查找/插入效率越低
  • $\alpha$ 越小,表越空,冲突概率越低,但空间浪费越大

2. 各种查找方法的综合对比

查找方法 时间复杂度 空间复杂度 适用条件
顺序查找 $O(n)$ $O(1)$ 无要求
折半查找 $O(\log n)$ $O(1)$ 有序顺序表
索引查找 $O(\sqrt{n})$ $O(n)$ 块间有序
哈希查找 $O(1)$(平均) $O(m)$ 需要设计哈希函数

总结:哈希表用"空间换时间",通过额外的空间和巧妙的映射函数,将查找时间从 $O(n)$ 降到了 $O(1)$。但这是平均情况,最坏情况下(所有关键字映射到同一位置)会退化为 $O(n)$。


五、总结

哈希表是一种通过关键字直接计算存储位置的数据结构,它利用哈希函数将关键字映射为地址,并在冲突发生时通过开放地址法或链地址法来解决。追求查找速度、不需要有序遍历时选 哈希表

哈希表的核心优势是平均 $O(1)$ 的查找、插入和删除性能,是所有查找方法中平均效率最高的。但它也有缺点:

  • 需要额外空间:为了减少冲突,哈希表通常预留比实际数据更多的空间
  • 最坏情况性能差:当哈希函数设计不当或装填因子过高时,可能退化为 $O(n)$
  • 不支持有序操作:哈希表中的元素是无序的,无法进行范围查询或排序输出

哈希表的设计权衡:表长越大,冲突越少,速度越快,但空间浪费越多;表长越小,空间利用率越高,但冲突增多、速度下降。装填因子 $\alpha$ 就是用来衡量这个平衡的指标,通常控制在 0.5~0.75 之间最为理想。

posted @ 2026-07-24 16:40  Javenwww  阅读(28)  评论(0)    收藏  举报