【数据结构与算法】第30篇:哈希表(Hash Table)

一、什么是哈希表

1.1 基本思想

哈希表通过哈希函数将关键字映射到数组的某个位置,实现快速访问。

text

复制代码
key → 哈希函数 → 数组下标 → 访问/存储

示例hash(key) = key % 10

  • key=25 → 25%10=5 → 存入下标5

  • key=37 → 37%10=7 → 存入下标7

1.2 哈希冲突

不同的key映射到同一个位置,称为哈希冲突。

text

复制代码
key=25 → 5
key=35 → 5  // 冲突!

解决冲突的两种主要方法:

  • 链地址法:每个位置是一个链表

  • 开放地址法:冲突后找下一个空位


二、哈希函数

2.1 常用哈希函数

方法 公式 适用场景
直接定址法 H(key)=a×key+b 关键字分布连续
除留余数法 H(key)=key % p 最常用,p为质数
平方取中法 取平方后中间几位 关键字无规律
折叠法 分割后求和 关键字位数多

2.2 除留余数法

c

复制代码
int hash(int key, int size) {
    return key % size;  // size通常取质数
}

为什么size取质数:减少冲突概率,使分布更均匀。


三、链地址法(拉链法)

3.1 原理

每个数组元素是一个链表的头指针,冲突的元素放入同一链表。

text

复制代码
数组: [0] → [1] → [2] → [3] → ...
链表:      ↓
          key1 → key2 → ...

3.2 代码实现

c

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

#define SIZE 10

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

// 哈希表
typedef struct {
    Node *buckets[SIZE];
} HashTable;

// 初始化
void initHashTable(HashTable *ht) {
    for (int i = 0; i < SIZE; i++) {
        ht->buckets[i] = NULL;
    }
}

// 哈希函数
int hash(int key) {
    return key % SIZE;
}

// 插入
void insert(HashTable *ht, int key) {
    int index = hash(key);
    Node *newNode = (Node*)malloc(sizeof(Node));
    newNode->key = key;
    newNode->next = ht->buckets[index];
    ht->buckets[index] = newNode;
}

// 查找
int search(HashTable *ht, int key) {
    int index = hash(key);
    Node *cur = ht->buckets[index];
    while (cur != NULL) {
        if (cur->key == key) return 1;
        cur = cur->next;
    }
    return 0;
}

// 删除
int delete(HashTable *ht, int key) {
    int index = hash(key);
    Node *cur = ht->buckets[index];
    Node *prev = NULL;
    
    while (cur != NULL) {
        if (cur->key == key) {
            if (prev == NULL) {
                ht->buckets[index] = cur->next;
            } else {
                prev->next = cur->next;
            }
            free(cur);
            return 1;
        }
        prev = cur;
        cur = cur->next;
    }
    return 0;
}

// 打印
void printHashTable(HashTable *ht) {
    for (int i = 0; i < SIZE; i++) {
        printf("[%d]: ", i);
        Node *cur = ht->buckets[i];
        while (cur != NULL) {
            printf("%d -> ", cur->key);
            cur = cur->next;
        }
        printf("NULL\n");
    }
}

int main() {
    HashTable ht;
    initHashTable(&ht);
    
    insert(&ht, 25);
    insert(&ht, 35);
    insert(&ht, 45);
    insert(&ht, 17);
    insert(&ht, 28);
    
    printf("哈希表(链地址法):\n");
    printHashTable(&ht);
    
    printf("\n查找25: %s\n", search(&ht, 25) ? "找到" : "未找到");
    printf("查找99: %s\n", search(&ht, 99) ? "找到" : "未找到");
    
    delete(&ht, 35);
    printf("\n删除35后:\n");
    printHashTable(&ht);
    
    return 0;
}

运行结果:

text

复制代码
哈希表(链地址法):
[0]: NULL
[1]: NULL
[2]: NULL
[3]: NULL
[4]: NULL
[5]: 45 -> 35 -> 25 -> NULL
[6]: NULL
[7]: 17 -> NULL
[8]: 28 -> NULL
[9]: NULL

查找25: 找到
查找99: 未找到

删除35后:
[5]: 45 -> 25 -> NULL

四、开放地址法

4.1 线性探测

冲突时,依次检查下一个位置:H = (H(key) + i) % SIZE

缺点:容易产生聚集(一连串被占用的位置)。

c

复制代码
#define SIZE 10
#define EMPTY -1

typedef struct {
    int data[SIZE];
} OpenHashTable;

void initOpenHash(OpenHashTable *ht) {
    for (int i = 0; i < SIZE; i++) {
        ht->data[i] = EMPTY;
    }
}

int hash(int key) {
    return key % SIZE;
}

// 线性探测插入
void linearInsert(OpenHashTable *ht, int key) {
    int index = hash(key);
    int i = 0;
    while (ht->data[(index + i) % SIZE] != EMPTY && i < SIZE) {
        i++;
    }
    if (i < SIZE) {
        ht->data[(index + i) % SIZE] = key;
    } else {
        printf("哈希表已满\n");
    }
}

// 线性探测查找
int linearSearch(OpenHashTable *ht, int key) {
    int index = hash(key);
    int i = 0;
    while (i < SIZE) {
        int pos = (index + i) % SIZE;
        if (ht->data[pos] == key) return pos;
        if (ht->data[pos] == EMPTY) return -1;  // 空位说明不存在
        i++;
    }
    return -1;
}

4.2 二次探测

解决线性探测的聚集问题:H = (H(key) + i²) % SIZE

c

复制代码
// 二次探测插入
void quadraticInsert(OpenHashTable *ht, int key) {
    int index = hash(key);
    int i = 0;
    while (ht->data[(index + i * i) % SIZE] != EMPTY && i < SIZE) {
        i++;
    }
    if (i < SIZE) {
        ht->data[(index + i * i) % SIZE] = key;
    } else {
        printf("哈希表已满\n");
    }
}

五、完整哈希表实现(链地址法+动态扩容)

c

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

#define INIT_SIZE 8
#define LOAD_FACTOR 0.75

typedef struct Node {
    int key;
    struct Node *next;
} Node;

typedef struct {
    Node **buckets;
    int size;       // 桶的数量
    int count;      // 元素个数
} HashTable;

// 哈希函数
int hash(HashTable *ht, int key) {
    return key % ht->size;
}

// 创建哈希表
HashTable* createHashTable() {
    HashTable *ht = (HashTable*)malloc(sizeof(HashTable));
    ht->size = INIT_SIZE;
    ht->count = 0;
    ht->buckets = (Node**)calloc(ht->size, sizeof(Node*));
    return ht;
}

// 插入(不检查扩容)
void insertNoResize(HashTable *ht, int key) {
    int index = hash(ht, key);
    Node *newNode = (Node*)malloc(sizeof(Node));
    newNode->key = key;
    newNode->next = ht->buckets[index];
    ht->buckets[index] = newNode;
    ht->count++;
}

// 查找
int search(HashTable *ht, int key) {
    int index = hash(ht, key);
    Node *cur = ht->buckets[index];
    while (cur != NULL) {
        if (cur->key == key) return 1;
        cur = cur->next;
    }
    return 0;
}

// 获取所有键
void getAllKeys(HashTable *ht, int *keys, int *len) {
    *len = 0;
    for (int i = 0; i < ht->size; i++) {
        Node *cur = ht->buckets[i];
        while (cur != NULL) {
            keys[(*len)++] = cur->key;
            cur = cur->next;
        }
    }
}

// 扩容
void resize(HashTable *ht) {
    int oldSize = ht->size;
    Node **oldBuckets = ht->buckets;
    
    // 创建新哈希表
    ht->size = oldSize * 2;
    ht->count = 0;
    ht->buckets = (Node**)calloc(ht->size, sizeof(Node*));
    
    // 重新插入所有元素
    for (int i = 0; i < oldSize; i++) {
        Node *cur = oldBuckets[i];
        while (cur != NULL) {
            insertNoResize(ht, cur->key);
            Node *temp = cur;
            cur = cur->next;
            free(temp);
        }
    }
    
    free(oldBuckets);
}

// 插入(带扩容)
void insert(HashTable *ht, int key) {
    if ((float)ht->count / ht->size >= LOAD_FACTOR) {
        printf("负载因子 %.2f >= %.2f,扩容到 %d\n", 
               (float)ht->count / ht->size, LOAD_FACTOR, ht->size * 2);
        resize(ht);
    }
    insertNoResize(ht, key);
}

// 打印
void printHashTable(HashTable *ht) {
    printf("哈希表 (size=%d, count=%d, load=%.2f):\n", 
           ht->size, ht->count, (float)ht->count / ht->size);
    for (int i = 0; i < ht->size; i++) {
        printf("[%d]: ", i);
        Node *cur = ht->buckets[i];
        while (cur != NULL) {
            printf("%d -> ", cur->key);
            cur = cur->next;
        }
        printf("NULL\n");
    }
}

// 销毁
void destroyHashTable(HashTable *ht) {
    for (int i = 0; i < ht->size; i++) {
        Node *cur = ht->buckets[i];
        while (cur != NULL) {
            Node *temp = cur;
            cur = cur->next;
            free(temp);
        }
    }
    free(ht->buckets);
    free(ht);
}

int main() {
    HashTable *ht = createHashTable();
    
    printf("=== 插入元素(观察扩容)===\n");
    int values[] = {25, 35, 45, 17, 28, 19, 33, 42, 51, 67, 73, 88};
    for (int i = 0; i < 12; i++) {
        insert(ht, values[i]);
        printf("插入 %d\n", values[i]);
    }
    
    printf("\n");
    printHashTable(ht);
    
    printf("\n=== 查找测试 ===\n");
    printf("查找25: %s\n", search(ht, 25) ? "找到" : "未找到");
    printf("查找99: %s\n", search(ht, 99) ? "找到" : "未找到");
    
    destroyHashTable(ht);
    return 0;
}

运行结果:

text

复制代码
=== 插入元素(观察扩容)===
插入 25
插入 35
插入 45
插入 17
插入 28
插入 19
插入 33
负载因子 0.75 >= 0.75,扩容到 16
插入 42
插入 51
插入 67
插入 73
插入 88

哈希表 (size=16, count=12, load=0.75):
[0]: 64 -> NULL
[1]: 33 -> 17 -> 25 -> NULL
[2]: 42 -> 18 -> NULL
[3]: 35 -> 19 -> 51 -> 67 -> NULL
...

六、两种冲突解决方法的对比

对比项 链地址法 开放地址法
原理 冲突元素用链表存储 找下一个空位
空间利用率 需要额外指针 100%利用
删除操作 简单 复杂(需标记删除)
聚集问题 有(线性探测)
性能稳定性 稳定 可能退化
实现复杂度 中等 较低
适用场景 通用 数据量可预估

七、复杂度分析

操作 平均时间复杂度 最坏时间复杂度
插入 O(1) O(n)
查找 O(1) O(n)
删除 O(1) O(n)

最坏情况:所有key映射到同一个位置,退化成链表。

如何保证平均O(1)

  • 哈希函数分布均匀

  • 负载因子控制在0.75以下

  • 适时扩容


八、小结

这一篇我们学习了哈希表:

要点 说明
哈希函数 除留余数法最常用,size取质数
链地址法 数组+链表,实现简单,性能稳定
开放地址法 线性探测、二次探测,无额外指针
负载因子 扩容阈值,通常0.75
时间复杂度 平均O(1),最坏O(n)

哈希表的核心

  • 好的哈希函数让数据分布均匀

  • 合理的负载因子保证性能

  • 冲突解决策略影响实现复杂度

下一篇我们讲排序算法概述与插入排序。


九、思考题

  1. 除留余数法中,为什么模数取质数能减少冲突?

  2. 链地址法和开放地址法,哪种删除操作更简单?为什么?

  3. 如果负载因子超过1,链地址法和开放地址法分别会发生什么?

  4. 如何设计一个字符串的哈希函数?

欢迎在评论区讨论你的答案。

相关推荐
三品吉他手会点灯1 小时前
C语言学习笔记 - 20.C编程预备计算机专业知识 - 变量为什么必须的初始化【重点】
c语言·笔记·学习
sakiko_1 小时前
UIKit学习笔记1-创建项目(使用UIKit)、使用组件
笔记·学习
Old Uncle Tom1 小时前
OpenClaw 记忆系统 -- 记忆预加载
java·数据结构·算法·agent
会编程的土豆2 小时前
洛谷题单入门1 顺序结构
数据结构·算法·golang
生信碱移2 小时前
PACells:这个方法可以鉴定疾病/预后相关的重要细胞亚群,作者提供的代码流程可以学习起来了,甚至兼容转录组与 ATAC 两种数据类型!
人工智能·学习·算法·机器学习·数据挖掘·数据分析·r语言
智者知已应修善业2 小时前
【51单片机中的打飞机设计】2023-8-25
c++·经验分享·笔记·算法·51单片机
星幻元宇VR4 小时前
VR航空航天科普设备【VR时空直升机】
科技·学习·安全·生活·vr
_李小白4 小时前
【android opencv学习笔记】Day 2: Mat类(图片数据结构体)
android·opencv·学习
智者知已应修善业4 小时前
【51单片机按键调节占空比3位数码管显示】2023-8-24
c++·经验分享·笔记·算法·51单片机
JasmineX-15 小时前
数据结构(笔记)——双向链表
c语言·数据结构·笔记·链表