哈希表实现

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define HASHTABLE_CAPACITY 20
// === File: array_hash_map.c ===
/* 键值对 int->string */
typedef struct {
	int key;
	char* val;
} Pair;

typedef struct {
	void* set;
	int len;
} MapSet;

/* 基于数组简易实现的哈希表 */
typedef struct {
	Pair* buckets[HASHTABLE_CAPACITY];
} ArrayHashMap;
/* 构造函数 */
ArrayHashMap* newArrayHashMap() {
	ArrayHashMap* hmap = malloc(sizeof(ArrayHashMap));
	return hmap;
}
/* 析构函数 */
void delArrayHashMap(ArrayHashMap* hmap) {
	for (int i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			free(hmap->buckets[i]->val);
			free(hmap->buckets[i]);
		}
	}

	free(hmap);
}
/* 添加操作 */
void put(ArrayHashMap* hmap, const int key, const char* val) {
	Pair* Pair = malloc(sizeof(Pair));
	Pair->key = key;
	Pair->val = malloc(strlen(val) + 1);
	strcpy(Pair->val, val);
	int index = hashFunc(key);
	hmap->buckets[index] = Pair;
}
/* 删除操作 */
void removeItem(ArrayHashMap* hmap, const int key) {
	int index = hashFunc(key);
	free(hmap->buckets[index]->val);
	free(hmap->buckets[index]);
	hmap->buckets[index] = NULL;
}
/* 获取所有键值对 */
void pairSet(ArrayHashMap* hmap, MapSet* set) {
	Pair* entries;
	int i = 0, index = 0;
	int total = 0;
	/* 统计有效键值对数量 */
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			total++;
		}
	}
	entries = malloc(sizeof(Pair) * total);
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			entries[index].key = hmap->buckets[i]->key;
			entries[index].val = malloc(strlen(hmap->buckets[i]->val + 1));
			strcpy(entries[index].val, hmap->buckets[i]->val);
			index++;
		}
	}
	set->set = entries;
	set->len = total;
}
/* 获取所有键 */

void keySet(ArrayHashMap * hmap, MapSet * set) {
	int* keys;
	int i = 0, index = 0;
	int total = 0;
	/* 统计有效键值对数量 */
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			total++;
		}
	}
	keys = malloc(total * sizeof(int));
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			keys[index] = hmap->buckets[i]->key;
			index++;
		}
	}
	set->set = keys;
	set->len = total;
}
/* 获取所有值 */
void valueSet(ArrayHashMap* hmap, MapSet* set) {
	char** vals;
	int i = 0, index = 0;
	int total = 0;
	/* 统计有效键值对数量 */
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			total++;
		}
	}
	vals = malloc(total * sizeof(char*));
	for (i = 0; i < HASHTABLE_CAPACITY; i++) {
		if (hmap->buckets[i] != NULL) {
			vals[index] = hmap->buckets[i]->val;
			index++;
		}
	}
	set->set = vals;
	set->len = total;
}
/* 打印哈希表 */
void print(ArrayHashMap* hmap) {
	int i;
	MapSet set;

	pairSet(hmap, &set);
	Pair* entries = (Pair*)set.set;
	for (i = 0; i < set.len; i++) {
		printf("%d -> %s\n", entries[i].key, entries[i].val);
	}
	free(set.set);
}

一种常见的实现方式是使用数组和链表的组合来构建哈希表。具体步骤如下:

  • 创建一个固定大小的数组,作为哈希表的桶(bucket)。
  • 使用哈希函数将键(key)转换为数组的索引,即桶的位置。
  • 如果发生碰撞(collisions),也就是两个不同的键生成相同的索引,可以在桶的位置上使用链表或其他数据结构来存储冲突的键值对。
  • 为了插入、查找或删除键值对,你需要实现相应的操作,并对桶中的链表进行遍历。

哈希冲突

多个输入对应同一输出的情况

容易想到,哈希表容量越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以
通过扩容哈希表来减少哈希冲突。

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量
capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算
开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。

「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希
冲突的严重程度,也常被作为哈希表扩容的触发条件。例如在 Java 中,当负载因子超过 0.75 时,系统会将
哈希表容量扩展为原先的 2 倍。

通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输
入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。

哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就
进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量
的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。

  1. 改良哈希表数据结构,使得哈希表可以在存在哈希冲突时正常工作。
  2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
    哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。

链式地址

// === File: hash_map_chaining.c ===
/* 链表节点 */
typedef struct Node {
	Pair* pair;
	struct Node* next;
} Node;
/* 链式地址哈希表 */
typedef struct {
	int size; // 键值对数量
	第 6 章 哈希表 hello‑algo.com 122
		int capacity; // 哈希表容量
	double loadThres; // 触发扩容的负载因子阈值
	int extendRatio; // 扩容倍数
	Node** buckets; // 桶数组
} HashMapChaining;
/* 构造函数 */
HashMapChaining* newHashMapChaining() {
	HashMapChaining* hashMap = (HashMapChaining*)malloc(sizeof(HashMapChaining));
	hashMap->size = 0;
	hashMap->capacity = 4;
	hashMap->loadThres = 2.0 / 3.0;
	hashMap->extendRatio = 2;
	hashMap->buckets = (Node**)malloc(hashMap->capacity * sizeof(Node*));
	for (int i = 0; i < hashMap->capacity; i++) {
		hashMap->buckets[i] = NULL;
	}
	return hashMap;
}
/* 析构函数 */
void delHashMapChaining(HashMapChaining* hashMap) {
	for (int i = 0; i < hashMap->capacity; i++) {
		Node* cur = hashMap->buckets[i];
		while (cur) {
			Node* tmp = cur;
			cur = cur->next;
			free(tmp->pair);
			free(tmp);
		}
	}
	free(hashMap->buckets);
	free(hashMap);
}
/* 哈希函数 */
int hashFunc(HashMapChaining* hashMap, int key) {
	return key % hashMap->capacity;
}
/* 负载因子 */
double loadFactor(HashMapChaining* hashMap) {
	return (double)hashMap->size / (double)hashMap->capacity;
}
/* 查询操作 */
char* get(HashMapChaining* hashMap, int key) {

		int index = hashFunc(hashMap, key);
	// 遍历桶,若找到 key 则返回对应 val
	Node* cur = hashMap->buckets[index];
	while (cur) {
		if (cur->pair->key == key) {
			return cur->pair->val;
		}
		cur = cur->next;
	}
	return ""; // 若未找到 key 则返回空字符串
}
/* 添加操作 */
void put(HashMapChaining* hashMap, int key, const char* val) {
	// 当负载因子超过阈值时,执行扩容
	if (loadFactor(hashMap) > hashMap->loadThres) {
		extend(hashMap);
	}
	int index = hashFunc(hashMap, key);
	// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
	Node* cur = hashMap->buckets[index];
	while (cur) {
		if (cur->pair->key == key) {
			strcpy(cur->pair->val, val); // 若遇到指定 key ,则更新对应 val 并返回
			return;
		}
		cur = cur->next;
	}
	// 若无该 key ,则将键值对添加至尾部
	Pair* newPair = (Pair*)malloc(sizeof(Pair));
	newPair->key = key;
	strcpy(newPair->val, val);
	Node* newNode = (Node*)malloc(sizeof(Node));
	newNode->pair = newPair;
	newNode->next = hashMap->buckets[index];
	hashMap->buckets[index] = newNode;
	hashMap->size++;
}
/* 扩容哈希表 */
void extend(HashMapChaining* hashMap) {
	// 暂存原哈希表
	int oldCapacity = hashMap->capacity;
	Node** oldBuckets = hashMap->buckets;
	// 初始化扩容后的新哈希表
	hashMap->capacity *= hashMap->extendRatio;
	hashMap->buckets = (Node**)malloc(hashMap->capacity * sizeof(Node*));

		for (int i = 0; i < hashMap->capacity; i++) {
			hashMap->buckets[i] = NULL;
		}
	hashMap->size = 0;
	// 将键值对从原哈希表搬运至新哈希表
	for (int i = 0; i < oldCapacity; i++) {
		Node* cur = oldBuckets[i];
		while (cur) {
			put(hashMap, cur->pair->key, cur->pair->val);
			Node* temp = cur;
			cur = cur->next;
			// 释放内存
			free(temp->pair);
			free(temp);
		}
	}
	free(oldBuckets);
}
/* 删除操作 */
void removeItem(HashMapChaining* hashMap, int key) {
	int index = hashFunc(hashMap, key);
	Node* cur = hashMap->buckets[index];
	Node* pre = NULL;
	while (cur) {
		if (cur->pair->key == key) {
			// 从中删除键值对
			if (pre) {
				pre->next = cur->next;
			}
			else {
				hashMap->buckets[index] = cur->next;
			}
			// 释放内存
			free(cur->pair);
			free(cur);
			hashMap->size--;
			return;
		}
		pre = cur;
		cur = cur->next;
	}
}
/* 打印哈希表 */
void print(HashMapChaining* hashMap) {
	for (int i = 0; i < hashMap->capacity; i++) {
	
			Node * cur = hashMap->buckets[i];
		printf("[");
		while (cur) {
			printf("%d -> %s, ", cur->pair->key, cur->pair->val);
			cur = cur->next;
		}
		printf("]\n");
	}
}

在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。图 6‑5 展示了一个链式地址哈希表的
例子。

基于链式地址实现的哈希表的操作方法发生了以下变化。

  • ‧ 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查
  • 找目标键值对。
  • ‧ 添加元素:先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中。
  • ‧ 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除。

链式地址存在以下局限性。

  • ‧ 占用空间增大,链表包含节点指针,它相比数组更加耗费内存空间。
  • ‧ 查询效率降低,因为需要线性遍历链表来查找对应元素。

以下代码给出了链式地址哈希表的简单实现,需要注意两点。

  • ‧ 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
  • ‧ 以下实现包含哈希表扩容方法。当负载因子超过 2/3 时,我们将哈希表扩容至 2 倍。

值得注意的是,当链表很长时,查询效率 O(n) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而
将查询操作的时间复杂度优化至 O(log n ) 。

开放寻址

「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主
要包括线性探测、平方探测、多次哈希等。

下面将主要以线性探测为例,介绍开放寻址哈希表的工作机制与代码实现。

1.线性探测

线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。

‧ 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为
1 ),直至找到空桶,将元素插入其中。

‧ 查找元素:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 value 即可;如
果遇到空桶,说明目标元素不在哈希表中,返回 None 。

图 6‑6 展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映
射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。

然而,线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲
突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。

值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶
None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序
可能误判这些元素不存在。

为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一
个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。

然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE
的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。

为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从
而优化查询效率。

以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们
将哈希表看作是一个“环形数组”,当越过数组尾部时,回到头部继续遍历。

// === File: hash_map_open_addressing.c ===
/* 开放寻址哈希表 */
typedef struct {
int size; // 键值对数量
int capacity; // 哈希表容量
double loadThres; // 触发扩容的负载因子阈值
int extendRatio; // 扩容倍数
Pair **buckets; // 桶数组
Pair *TOMBSTONE; // 删除标记
} HashMapOpenAddressing;
/* 构造函数 */
HashMapOpenAddressing *newHashMapOpenAddressing() {
HashMapOpenAddressing *hashMap = (HashMapOpenAddressing *)malloc(sizeof(HashMapOpenAddressing));
hashMap->size = 0;
hashMap->capacity = 4;
hashMap->loadThres = 2.0 / 3.0;
hashMap->extendRatio = 2;
hashMap->buckets = (Pair **)malloc(sizeof(Pair *) * hashMap->capacity);
hashMap->TOMBSTONE = (Pair *)malloc(sizeof(Pair));
hashMap->TOMBSTONE->key = -1;
hashMap->TOMBSTONE->val = "-1";
return hashMap;
}
/* 析构函数 */
void delHashMapOpenAddressing(HashMapOpenAddressing *hashMap) {
for (int i = 0; i < hashMap->capacity; i++) {
Pair *pair = hashMap->buckets[i];
if (pair != NULL && pair != hashMap->TOMBSTONE) {
free(pair->val);
free(pair);
}
}
}
/* 哈希函数 */
int hashFunc(HashMapOpenAddressing *hashMap, int key) {
return key % hashMap->capacity;
}
/* 负载因子 */
double loadFactor(HashMapOpenAddressing *hashMap) {
return (double)hashMap->size / (double)hashMap->capacity;
}
/* 搜索 key 对应的桶索引 */
int findBucket(HashMapOpenAddressing *hashMap, int key) {
int index = hashFunc(hashMap, key);
int firstTombstone = -1;
// 线性探测,当遇到空桶时跳出
while (hashMap->buckets[index] != NULL) {
// 若遇到 key ,返回对应桶索引
if (hashMap->buckets[index]->key == key) {
// 若之前遇到了删除标记,则将键值对移动至该索引
if (firstTombstone != -1) {
hashMap->buckets[firstTombstone] = hashMap->buckets[index];
hashMap->buckets[index] = hashMap->TOMBSTONE;
return firstTombstone; // 返回移动后的桶索引
}
return index; // 返回桶索引
}
// 记录遇到的首个删除标记
if (firstTombstone == -1 && hashMap->buckets[index] == hashMap->TOMBSTONE) {
firstTombstone = index;
}
// 计算桶索引,越过尾部返回头部
index = (index + 1) % hashMap->capacity;
}
// 若 key 不存在,则返回添加点的索引
return firstTombstone == -1 ? index : firstTombstone;
}
/* 查询操作 */
char *get(HashMapOpenAddressing *hashMap, int key) {
// 搜索 key 对应的桶索引
int index = findBucket(hashMap, key);
// 若找到键值对,则返回对应 val
if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) {
return hashMap->buckets[index]->val;
}
// 若键值对不存在,则返回空字符串
return "";
}
/* 添加操作 */
void put(HashMapOpenAddressing *hashMap, int key, char *val) {
// 当负载因子超过阈值时,执行扩容
if (loadFactor(hashMap) > hashMap->loadThres) {
extend(hashMap);
}
// 搜索 key 对应的桶索引
int index = findBucket(hashMap, key);
// 若找到键值对,则覆盖 val 并返回
if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) {
free(hashMap->buckets[index]->val);
hashMap->buckets[index]->val = (char *)malloc(sizeof(strlen(val + 1)));
strcpy(hashMap->buckets[index]->val, val);
hashMap->buckets[index]->val[strlen(val)] = '\0';
return;
}
// 若键值对不存在,则添加该键值对
Pair *pair = (Pair *)malloc(sizeof(Pair));
pair->key = key;
pair->val = (char *)malloc(sizeof(strlen(val + 1)));
strcpy(pair->val, val);
pair->val[strlen(val)] = '\0';
hashMap->buckets[index] = pair;
hashMap->size++;
}
/* 删除操作 */
void removeItem(HashMapOpenAddressing *hashMap, int key) {
// 搜索 key 对应的桶索引
int index = findBucket(hashMap, key);
// 若找到键值对,则用删除标记覆盖它
if (hashMap->buckets[index] != NULL && hashMap->buckets[index] != hashMap->TOMBSTONE) {
Pair *pair = hashMap->buckets[index];
free(pair->val);
free(pair);
hashMap->buckets[index] = hashMap->TOMBSTONE;
hashMap->size--;
}
}
/* 扩容哈希表 */
void extend(HashMapOpenAddressing *hashMap) {
// 暂存原哈希表
Pair **bucketsTmp = hashMap->buckets;
int oldCapacity = hashMap->capacity;
// 初始化扩容后的新哈希表
hashMap->capacity *= hashMap->extendRatio;
hashMap->buckets = (Pair **)malloc(sizeof(Pair *) * hashMap->capacity);
hashMap->size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (int i = 0; i < oldCapacity; i++) {
Pair *pair = bucketsTmp[i];
if (pair != NULL && pair != hashMap->TOMBSTONE) {
put(hashMap, pair->key, pair->val);
free(pair->val);
free(pair);
}
}
free(bucketsTmp);
}
/* 打印哈希表 */
void print(HashMapOpenAddressing *hashMap) {
for (int i = 0; i < hashMap->capacity; i++) {
Pair *pair = hashMap->buckets[i];
if (pair == NULL) {
printf("NULL\n");
} else if (pair == hashMap->TOMBSTONE) {
printf("TOMBSTONE\n");
} else {
printf("%d -> %s\n", pair->key, pair->val);
}
}
}

2.平方探测

平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固
定的步数,而是跳过“探测次数的平方”的步数,即 1, 4, 9, … 步。

  • ‧ 平方探测通过跳过平方的距离,试图缓解线性探测的聚集效应。
  • ‧ 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。

然而,平方探测也并不是完美的。

  • ‧ 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
  • ‧ 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。

3.多次哈希

多次哈希使用多个哈希函数 f1(x�)、�f2(x�)、f�3(x�)、… 进行探测。

‧ 插入元素:若哈希函数 �f1(x�)出现冲突,则尝试 �f2(x�) ,以此类推,直到找到空桶后插入元素。

‧ 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;或当遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None 。

与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量。

哈希算法

在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,它
们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生。

如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图 6‑8 所示,对于链地址哈希表,理想情况下键值
对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退
化至 �O(n�) 。

键值对的分布情况由哈希函数决定。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:

index = hash(key) % capacity

观察以上公式,当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。

这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。

哈希算法的目标

为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。

  • ‧ 确定性:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
  • ‧ 效率高:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
  • ‧ 均匀分布:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。

实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。

  • ‧ 密码存储:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希
    值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹
    配,那么密码就被视为正确。
  • ‧ 数据完整性检查:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的
    数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。

对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全
特性。

  • ‧ 单向性:无法通过哈希值反推出关于输入数据的任何信息。
  • ‧ 抗碰撞性:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
  • ‧ 雪崩效应:输入的微小变化应当导致输出的显著且不可预测的变化。

请注意,“均匀分布”与“抗碰撞性”是两个独立的概念,满足均匀分布不一定满足抗碰撞性。例如,在随机
输入 key 下,哈希函数 key % 100 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的
key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key ,从而破解密码。

  • ‧ 加法哈希:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
  • ‧ 乘法哈希:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
  • ‧ 异或哈希:将输入数据的每个元素通过异或操作累积到一个哈希值中。
  • ‧ 旋转哈希:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。

哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简
单的哈希算法。

// === File: simple_hash.c ===
/* 加法哈希 */
int addHash(char *key) {
long long hash = 0;
const int MODULUS = 1000000007;
for (int i = 0; i < strlen(key); i++) {
hash = (hash + (unsigned char)key[i]) % MODULUS;
}
return (int)hash;
}
/* 乘法哈希 */
int mulHash(char *key) {
long long hash = 0;
const int MODULUS = 1000000007;
for (int i = 0; i < strlen(key); i++) {
hash = (31 * hash + (unsigned char)key[i]) % MODULUS;
}
return (int)hash;
}
/* 异或哈希 */
int xorHash(char *key) {
int hash = 0;
const int MODULUS = 1000000007;
for (int i = 0; i < strlen(key); i++) {
hash ^= (unsigned char)key[i];
}
return hash & MODULUS;
}
/* 旋转哈希 */
int rotHash(char *key) {
long long hash = 0;
const int MODULUS = 1000000007;
for (int i = 0; i < strlen(key); i++) {
hash = ((hash << 4) ^ (hash >> 28) ^ (unsigned char)key[i]) % MODULUS;
}
return (int)hash;
}

观察发现,每种哈希算法的最后一步都是对大质数 1000000007 取模,以确保哈希值在合适的范围内。值得
思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。

先抛出结论:当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布。因为质数不会与其他数
字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。

举个例子,假设我们选择合数 9 作为模数,它可以被 3 整除。那么所有可以被 3 整除的 key 都会被映射到 0、3、6 这三个哈希值。

modulus = 9
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, … }
hash =

如果输入 key 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设
将 modulus 替换为质数 13 ,由于 key 和 modulus 之间不存在公约数,输出的哈希值的均匀性会明显提升。

modulus = 13
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, … }
hash =

值得说明的是,如果能够保证 key 是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都
能输出均匀分布的哈希值。而当 key 的分布存在某种周期性时,对合数取模更容易出现聚集现象。

总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算
法的稳健性。

常见哈希算法

小结

重点回顾

  • ‧ 输入 key ,哈希表能够在 𝑂(1) 时间内查询到 value ,效率非常高。
  • ‧ 常见的哈希表操作包括查询、添加键值对、删除键值对和遍历哈希表等。
  • ‧ 哈希函数将 key 映射为数组索引,从而访问对应桶并获取 value 。
  • ‧ 两个不同的 key 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈
  • 希冲突。
  • ‧ 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类
  • 似,哈希表扩容操作的开销很大。
  • ‧ 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容
  • 的条件。
  • ‧ 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查
  • 询效率,可以进一步将链表转换为红黑树来提高效率。
  • ‧ 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚
  • 集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算
  • 量。
  • ‧ 不同编程语言采取了不同的哈希表实现。例如,Java 的 HashMap 使用链式地址,而 Python 的 Dict 采
  • 用开放寻址。
  • ‧ 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该
  • 具备抗碰撞性和雪崩效应。
  • ‧ 哈希算法通常采用大质数作为模数,以最大化地保证哈希值的均匀分布,减少哈希冲突。
  • ‧ 常见的哈希算法包括 MD5、SHA‑1、SHA‑2 和 SHA3 等。MD5 常用于校验文件完整性,SHA‑2 常用
  • 于安全应用与协议。
  • 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变对象是可哈希的。
posted on 2024-02-02 16:58  lulixiu  阅读(8)  评论(0编辑  收藏  举报