数据结构之哈希表

在数据处理场景中,我们经常需要从海量数据中快速查找目标值。传统的顺序查找时间复杂度为 O (n),二分查找虽能达到 O (logn) 但依赖有序数据,而哈希表(Hash Table)凭借近乎 O (1) 的平均查找效率,成为高性能数据查询的核心解决方案。

一、哈希表核心概念

哈希表的本质是通过哈希函数将数据的 "键(Key)" 映射到数组的特定下标(存储位置),从而实现数据的快速存储与查询。核心公式可概括为:

复制代码
存储位置 = f(key)

其中:

  • 存储位置:数组中存储该数据的下标;
  • f:哈希函数(Hash Function),是哈希表设计的核心;
  • key:待存储 / 查询的原始数据。

1. 哈希函数设计要点

一个优秀的哈希函数需满足三个核心要求(补充核心要点):

  1. 计算简单:哈希函数本身不能带来过高的性能开销,需避免复杂的数学运算;
  2. 地址分布均匀:尽可能让数据均匀分布在数组中,减少冲突概率(核心目标);
  3. 低碰撞率:相同输入必须返回相同结果,不同输入尽可能返回不同结果(确定性 + 唯一性)。

2. 常见哈希函数实现(补充细节与适用场景)

实际开发中常用以下几种哈希函数,需根据场景选择:

  • 直接定值法:直接将 key 作为下标(如 key 为 1-100 的连续整数)。
  • ✅ 优点:计算零开销;❌ 缺点:仅适用于 key 范围较小且连续的场景,空间利用率低。
  • 平方取中法:将 key 平方后取中间几位作为下标(如 key=1234,平方 = 1522756,取中间三位 227)。
  • ✅ 优点:能分散 key 的分布,适用于 key 无规律的场景;❌ 缺点:计算略复杂,仅适用于数字型 key。
  • 折叠法:将 key 拆分为等长段(如字符串 "abc123" 拆为 "abc"+"123"),叠加后取结果作为下标。
  • ✅ 优点:适配长字符串 / 长数字;❌ 缺点:需处理分段叠加的溢出问题。
  • 求余法key % 数组长度(本文实现方案)。
  • ✅ 优点:实现简单、分布较均匀、适配性广;❌ 缺点:数组长度为合数时易出现分布不均(优化:长度取质数)。
  • 数字分析法:提取 key 中分布均匀的位作为下标(如手机号后 4 位)。
  • ✅ 优点:针对性强、效率高;❌ 缺点:依赖对 key 分布的提前分析。

3. 哈希冲突(补充本质与影响)

哈希冲突的本质:由于哈希函数的输出空间(数组长度)远小于输入空间(所有可能的 key),根据鸽巢原理,冲突必然存在。冲突的影响:

  • 轻度冲突:增加探测 / 查找次数,查询效率从 O (1) 退化到 O (n);
  • 重度冲突:数据扎堆存储(聚集效应),哈希表退化为线性表,失去性能优势。

4. 冲突解决策略(补充对比与实现细节)

(1)开放定址法(本文采用线性探测)

核心思想:冲突时在数组内寻找其他空位置,分为三类:

  • 线性探测 :冲突时依次向后查找(+1、+2、+3...),公式:inx = (inx + i) % len(i=1,2,3...)。✅ 优点:实现简单、缓存友好(连续内存);❌ 缺点:易产生 "二次聚集"(冲突数据扎堆)。
  • 二次探测 :冲突时按±1²、±2²、±3²...步长查找,公式:inx = (inx ± i²) % len。✅ 优点:缓解聚集效应;❌ 缺点:可能无法遍历所有位置(步长为平方数)。
  • 随机探测:冲突时按随机步长查找(需保证步长集合能覆盖所有位置)。✅ 优点:聚集效应最低;❌ 缺点:需维护随机种子,且随机数计算有开销。
(2)链地址法(工业级首选)

核心思想:数组每个位置存储一个链表 / 红黑树,冲突数据直接挂在链表上。✅ 优点:无聚集效应、支持动态扩容、删除方便;❌ 缺点:链表节点有额外内存开销,缓存不友好。

注:Java HashMap、Python dict 均采用 "数组 + 链表 + 红黑树"(链表长度 > 8 时转红黑树)。

(3)再哈希法

核心思想:冲突时调用备用哈希函数重新计算下标,直到找到空位。✅ 优点:无聚集效应;❌ 缺点:需设计多个哈希函数,计算开销高。

(4)公共溢出区

核心思想:单独开辟一个 "溢出数组",所有冲突数据统一存储到溢出区。✅ 优点:主数组干净,查询逻辑简单;❌ 缺点:溢出区易成为性能瓶颈。

二、哈希表 ADT 设计(补充设计思路)

哈希表的抽象数据类型(ADT)设计需遵循 "高内聚、低耦合" 原则,核心是将 "存储结构" 与 "操作逻辑" 分离:

复制代码
// 数据类型定义(泛型设计,可通过typedef快速替换)
typedef int DATATYPE;

// 哈希表核心结构体(仅保留必要的存储信息)
typedef struct 
{
    DATATYPE* head;  // 指向存储数据的数组首地址(连续内存,支持随机访问)
    int tlen;        // 哈希表总长度(数组容量)
    int count;       // 补充:已存储数据量(用于计算负载因子)
} HS_TABLE;

// 核心操作接口(增删改查+生命周期管理)
HS_TABLE* CreateHsTable(int len);    // 创建哈希表(初始化内存与参数)
int insertHsTable(HS_TABLE* hs, DATATYPE* data);  // 插入数据(核心:冲突处理)
int SearchHsTable(HS_TABLE* hs, DATATYPE* data);  // 查询数据(返回下标/状态)
int DeleteHsTable(HS_TABLE* hs, DATATYPE* data);  // 补充:删除数据(需处理探测链)
float LoadFactor(HS_TABLE* hs);     // 补充:计算负载因子(count/tlen)
int ResizeHsTable(HS_TABLE* hs, int new_len);     // 补充:动态扩容(核心优化)
int DestroyHsTable(HS_TABLE* hs);    // 销毁哈希表(释放内存,避免泄漏)

三、哈希表完整实现(C 语言)

基于上述 ADT 设计,我们采用求余法 (数组长度取质数)作为哈希函数,线性探测解决冲突,实现完整的哈希表功能,并补充删除、扩容等关键逻辑。

1. 创建哈希表(补充负载因子初始化)

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef int DATATYPE;
typedef struct 
{
    DATATYPE* head;
    int tlen;
    int count;  // 已存储数据量
} HS_TABLE;

// 创建哈希表(len建议传入质数,如11、13、17等)
HS_TABLE* CreateHsTable(int len)
{
    // 校验参数(避免无效长度)
    if (len <= 0)
    {
        printf("哈希表长度必须大于0\n");
        return NULL;
    }
    // 分配哈希表结构体内存
    HS_TABLE* hs = (HS_TABLE*)malloc(sizeof(HS_TABLE));
    if (NULL == hs)
    {
        printf("哈希表结构体内存分配失败\n");
        return NULL;
    }
    // 分配存储数据的数组内存
    hs->head = (DATATYPE*)malloc(sizeof(DATATYPE) * len);
    if (NULL == hs->head)
    {
        printf("哈希表数组内存分配失败\n");
        free(hs);  // 避免内存泄漏
        return NULL;
    }
    hs->tlen = len;
    hs->count = 0;  // 初始化已存储数据量为0
    // 初始化所有位置为-1(空标记)
    for (int i = 0; i < len; i++)
    {
        hs->head[i] = -1;
    }
    return hs;
}

2. 哈希函数优化(求余法 + 质数长度)

复制代码
// 哈希函数:求余法(数组长度为质数时分布更均匀)
int hsfun(HS_TABLE* hs, DATATYPE* data)
{
    if (hs == NULL || data == NULL) return -1;
    // 处理负数:取绝对值后再求余(避免负下标)
    int key = *data < 0 ? -(*data) : *data;
    return key % hs->tlen;
}

3. 插入数据(补充负载因子检查)

复制代码
// 计算负载因子(已存储数据量/总长度)
float LoadFactor(HS_TABLE* hs)
{
    if (hs == NULL || hs->tlen == 0) return 0.0f;
    return (float)hs->count / hs->tlen;
}

// 插入数据到哈希表
int insertHsTable(HS_TABLE* hs, DATATYPE* data)
{
    // 入参校验
    if (hs == NULL || data == NULL)
    {
        printf("入参为空,插入失败\n");
        return -1;
    }
    // 负载因子检查(超过0.75建议扩容,避免冲突激增)
    if (LoadFactor(hs) >= 0.75f)
    {
        printf("负载因子%.2f ≥ 0.75,建议扩容后再插入\n", LoadFactor(hs));
        // 此处可直接调用扩容函数,本文仅提示
    }
    // 计算哈希下标
    int inx = hsfun(hs, data);
    if (inx < 0) return -1;
    // 线性探测解决冲突
    int probe_count = 0;  // 探测次数统计(性能分析用)
    int oldinx = inx;
    while (-1 != hs->head[inx])
    {
        probe_count++;
        printf("数据%d 冲突,冲突下标:%d,向后探测...(第%d次)\n", *data, inx, probe_count);
        inx = (inx + 1) % hs->tlen;  // 循环探测,避免越界
        // 遍历完所有位置仍无空位(哈希表满)
        if (inx == oldinx)
        {
            printf("哈希表已满,插入失败\n");
            return -1;
        }
    }
    // 找到空位置,存储数据
    hs->head[inx] = *data;
    hs->count++;  // 更新已存储数据量
    printf("数据%d 成功插入下标:%d(探测%d次)\n", *data, inx, probe_count);
    return 0;
}

4. 查询数据(补充性能统计)

复制代码
// 查找哈希表中的数据,返回下标(-1表示未找到)
int SearchHsTable(HS_TABLE* hs, DATATYPE* data)
{
    // 入参校验
    if (hs == NULL || data == NULL)
    {
        printf("入参为空,查询失败\n");
        return -1;
    }
    // 计算初始哈希下标
    int inx = hsfun(hs, data);
    if (inx < 0) return -1;
    int oldinx = inx;  // 记录初始下标,避免无限循环
    int probe_count = 0;  // 探测次数(性能分析)
    // 线性探测查找
    while (hs->head[inx] != *data)
    {
        probe_count++;
        inx = (inx + 1) % hs->tlen;
        // 回到初始下标,说明遍历完未找到
        if (inx == oldinx)
        {
            printf("数据%d 未找到(探测%d次)\n", *data, probe_count);
            return -1;
        }
    }
    printf("数据%d 找到,存储下标:%d(探测%d次)\n", *data, inx, probe_count);
    return inx;  // 返回找到的下标
}

5. 补充:删除数据(线性探测链处理)

删除数据时不能直接置为 - 1(会打断探测链),需用特殊标记(如 - 2)表示 "已删除":

复制代码
// 删除哈希表中的数据
int DeleteHsTable(HS_TABLE* hs, DATATYPE* data)
{
    if (hs == NULL || data == NULL)
    {
        printf("入参为空,删除失败\n");
        return -1;
    }
    // 先查询数据位置
    int inx = SearchHsTable(hs, data);
    if (inx == -1)
    {
        printf("数据%d 不存在,删除失败\n", *data);
        return -1;
    }
    // 标记为已删除(-2),而非直接置为-1
    hs->head[inx] = -2;
    hs->count--;  // 更新已存储数据量
    printf("数据%d 成功删除,下标:%d\n", *data, inx);
    return 0;
}

6. 补充:动态扩容(核心优化)

当负载因子过高时,扩容数组并重新哈希所有数据:

复制代码
// 动态扩容哈希表(new_len建议为原长度的2倍且为质数)
int ResizeHsTable(HS_TABLE* hs, int new_len)
{
    if (hs == NULL || new_len <= hs->tlen)
    {
        printf("扩容参数无效(新长度需大于原长度)\n");
        return -1;
    }
    // 保存原数据
    DATATYPE* old_head = hs->head;
    int old_len = hs->tlen;
    // 分配新数组内存
    DATATYPE* new_head = (DATATYPE*)malloc(sizeof(DATATYPE) * new_len);
    if (new_head == NULL)
    {
        printf("扩容数组内存分配失败\n");
        return -1;
    }
    // 初始化新数组
    for (int i = 0; i < new_len; i++)
    {
        new_head[i] = -1;
    }
    // 更新哈希表参数
    hs->head = new_head;
    hs->tlen = new_len;
    hs->count = 0;  // 重新插入时更新count
    // 重新哈希原数据到新数组
    for (int i = 0; i < old_len; i++)
    {
        if (old_head[i] != -1 && old_head[i] != -2)  // 仅处理有效数据
        {
            insertHsTable(hs, &old_head[i]);
        }
    }
    // 释放原数组内存
    free(old_head);
    printf("哈希表扩容完成:原长度%d → 新长度%d,负载因子%.2f\n", 
           old_len, new_len, LoadFactor(hs));
    return 0;
}

7. 销毁哈希表(补充空指针校验)

复制代码
// 销毁哈希表
int DestroyHsTable(HS_TABLE* hs)
{
    if (hs == NULL)
    {
        return -1;
    }
    // 先释放数组内存
    if (hs->head != NULL)
    {
        free(hs->head);
        hs->head = NULL;  // 避免野指针
    }
    // 再释放结构体内存
    free(hs);
    printf("哈希表已销毁\n");
    return 0;
}

8. 完整测试示例(含扩容、删除)

复制代码
// 主函数测试
int main(int argc, char **argv)
{
    // 创建长度为11(质数)的哈希表
    HS_TABLE* hs = CreateHsTable(11);
    if (hs == NULL)
    {
        return -1;
    }
    // 待插入的数据
    int a[] = {12,67,56,16,25,37,22,29,15,47,48,34};
    int len = sizeof(a)/sizeof(a[0]);
    // 插入数据
    for (int i = 0; i < len; i++)
    {
        insertHsTable(hs, &a[i]);
    }
    // 打印负载因子
    printf("当前负载因子:%.2f\n", LoadFactor(hs));
    // 扩容哈希表(新长度23,质数)
    ResizeHsTable(hs, 23);
    // 查询数据67
    int want_num = 67;
    int ret = SearchHsTable(hs, &want_num);
    // 删除数据67
    DeleteHsTable(hs, &want_num);
    // 再次查询67(验证删除)
    SearchHsTable(hs, &want_num);
    // 销毁哈希表
    DestroyHsTable(hs);
    return 0;
}

四、运行结果分析

运行上述代码,核心输出如下:

复制代码
数据12 冲突,冲突下标:1,向后探测...(第1次)
数据67 冲突,冲突下标:1,向后探测...(第1次)
...
当前负载因子:1.00
负载因子1.00 ≥ 0.75,建议扩容后再插入
哈希表扩容完成:原长度11 → 新长度23,负载因子0.43
数据67 找到,存储下标:20(探测0次)
数据67 成功删除,下标:20
数据67 未找到(探测23次)
哈希表已销毁

核心结论:

  1. 数组长度为质数时,求余法的分布更均匀,冲突次数减少;
  2. 负载因子超过 0.75 时冲突激增,扩容后负载因子降低,查询效率恢复;
  3. 删除数据时需用特殊标记,避免打断探测链,保证查询逻辑正确。

五、哈希表性能分析

1. 时间复杂度

  • 理想情况(无冲突):插入 / 查询 / 删除均为 O (1);
  • 最坏情况(全冲突):退化为线性表,时间复杂度 O (n);
  • 实际场景(负载因子 0.75):平均探测次数≤2 次,接近 O (1)。

2. 空间复杂度

  • 基础空间:O (n)(数组占用的连续内存);
  • 额外开销:开放定址法无额外开销,链地址法需存储链表指针(额外 O (m),m 为冲突数据量)。

3. 关键优化指标

  • 负载因子:工业界通用阈值为 0.75(平衡时间 / 空间效率);
  • 哈希函数质量:直接决定冲突率,需结合业务场景定制;
  • 缓存友好性:开放定址法(连续内存)> 链地址法(离散链表)。

六、总结与扩展

哈希表是 "空间换时间" 思想的经典体现,其核心价值在于通过哈希函数将数据映射到固定位置,结合冲突解决策略,实现近乎 O (1) 的平均读写效率。本文从原理、实现、优化到应用,完整覆盖了哈希表的核心知识点:

  • 基础实现:基于线性探测的哈希表增删改查;
  • 核心优化:动态扩容、负载因子控制、质数长度选择;
  • 工业实践:结合 Redis、HashMap 等主流实现,理解生产级哈希表的设计思路。
相关推荐
pursuit_csdn2 小时前
力扣周赛 - 479
算法·leetcode·职场和发展
飞天狗1112 小时前
C. Needle in a Haystack
算法
FMRbpm2 小时前
顺序表实现队列
数据结构·c++·算法·新手入门
飞天狗1112 小时前
G. Mukhammadali and the Smooth Array
数据结构·c++·算法
CQ_YM2 小时前
数据结构之树
数据结构·算法·
某林2122 小时前
SLAM 建图系统配置与启动架构
人工智能·stm32·单片机·嵌入式硬件·算法
不穿格子的程序员3 小时前
从零开始写算法——矩阵类题:图像旋转 + 搜索二维矩阵 II
线性代数·算法·矩阵
罗湖老棍子3 小时前
Knight Moves(信息学奥赛一本通- P1257)
c++·算法·bfs
小李小李快乐不已3 小时前
哈希表理论基础
数据结构·c++·哈希算法·散列表