散列函数
散列函数的作用是将关键字映射到表格中的一个存储地址。
一个好的散列函数应满足:
1. 简单:计算 not 太复杂,避免消耗过多资源;
2. 随机性:可以均匀映射关键字到所有存储地址,避免"聚集"现象;
3. 一致性:对同一关键字,始终映射到同一个地址;
4. 全方位:散列函数的范围应能覆盖表格的整个存储空间,以充分利用空间。
具体散列函数方法主要有:
1. 直接定址法:h(key) = key mod N,N为表格大小。这是最简单方法但要求N足够大,否则冲突较多。
2. 分而治之法:将关键字分成几部分分别计算散列值,最后组合。如5位数分3位数和2位数计算散列值。这方法可以减少直接定址法中的冲突,利用关键字的高位和低位特征。
3. 平方取中法:取关键字的平方值的中间几位作为散列值。如5位数取平方值中间3位数。这种方法可以较好地分散关键字,减少冲突。
4. 折叠法:将关键字分成几个部分,这几部分的值相加得到散列值。如5位数分3个2位数,3个2位数的值之和为散列值。这是一种常用和较好的散列方法。
5. 除留余数法:散列函数为h(key) = key mod p, p是一个不大于表格大小的质数。这种方法可以较均匀地映射关键字,并且计算简单。质数p的选择会影响散列效果。
除此之外,还有随机数法、拉链法等。选取何种方法根据关键字范围和组成来获得最佳散列效果:最均匀映射和最小冲突。当冲突较多时,我们可以使用拉链法或再散列法来解决冲突,提高散列表性能。
这里给出Python代码实操散列表和散列函数:
首先,我们定义一个散列表HashTable类:
class HashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
然后定义一个简单的散列函数,这里采用除留余数法:
def hash_func(self, key):
return key % self.size
实现插入操作,采用拉链法解决冲突:
def insert(self, key, value):
hash_key = self.hash_func(key)
if self.table[hash_key] is None:
self.table[hash_key] = [(key, value)]
else:
self.table[hash_key].append((key, value))
实现搜索操作:
def search(self, key):
hash_key = self.hash_func(key)
if self.table[hash_key] is not None:
for pair in self.table[hash_key]:
if pair[0] == key:
return pair[1]
return None
实现删除操作:
def delete(self, key):
hash_key = self.hash_func(key)
if self.table[hash_key] is not None:
for i in range(len(self.table[hash_key])):
if self.table[hash_key][i][0] == key:
self.table[hash_key].pop(i)
break
我们可以这样使用HashTable:
ht = HashTable()
ht.insert(1, 'a')
ht.insert(2, 'b')
ht.insert(3, 'c')
print(ht.search(1))
ht.delete(2)
print(ht.search(2))
输出:
a
None
这是一个简单的散列表实现,采用拉链法解决冲突,并实现了基本的插入、搜索和删除操作。希望通过这个代码示例,可以加深您对散列表原理与实现的理解。
冲突解决
在散列表中,冲突指两个或多个关键字映射到同一个存储地址的情况。由于散列函数的随机性,想完全避免冲突是不现实的,所以我们需要采取一定的方法来解决冲突,提高散列表的性能。
常见的冲突解决方法有:
1. 开放定址法:也叫探测再散列法。当有冲突时,通过某种探测序列在表格中查找下一个空闲单元,将记录存入。常见的探测序列有:
- 线性探测:h(key, i) = (h'(key) + i) mod N,i是探测次数;
- 平方探测:h(key, i) = (h'(key) + i*i) mod N;
- 双散列探测:h(key, i) = (h1'(key) + i*h2'(key)) mod N,h1和h2是两个散列函数。
开放定址法的缺点是,当表格较满时,查找时间会增大;并且在删除时会出现"坏空洞"的问题。
2. 拉链法:每个存储地址包含一个链表,当有冲突时,将记录存储在链表中。这种方法解决了开放定址法中的问题,但是增加了链表操作的时间消耗。这是一种常用和较好的冲突解决方法。
3. 再散列法:当有冲突时,使用第二个散列函数计算另一个存储地址,直到找到空闲位置。这种方法的时间复杂度较高,一般不常用。除此之外,还有其他方法如使用公共溢出区等。 Choosing 一种冲突解决方法需要综合考虑表格大小,关键字的特征以及实际应用中的性能要求。 一般来说,当表格较小或关键字范围较大时,拉链法是比较常用的方法;当关键字较少或性能要求较高时,开放定址法也可以得到很好应用。
这里给出采用开放定址法解决散列表冲突的Python代码实现:
首先,我们仍然定义一个散列表HashTable类:
class HashTable:
def __init__(self, size=10):
self.size = size
self.table = [None] * self.size
然后定义散列函数,这里也采用除留余数法:
def hash_func(self, key):
return key % self.size
实现插入操作,这里采用开放定址法中的线性探测解决冲突:
def insert(self, key, value):
hash_key = self.hash_func(key)
if self.table[hash_key] is None:
self.table[hash_key] = (key, value)
else:
i = 1
while self.table[(hash_key + i) % self.size] is not None:
i += 1
self.table[(hash_key + i) % self.size] = (key, value)
实现搜索操作:
def search(self, key):
hash_key = self.hash_func(key)
i = 1
while self.table[(hash_key + i) % self.size] is not None:
if self.table[(hash_key + i) % self.size][0] == key:
return self.table[(hash_key + i) % self.size][1]
i += 1
return None
实现删除操作:
def delete(self, key):
hash_key = self.hash_func(key)
i = 1
while self.table[(hash_key + i) % self.size] is not None:
if self.table[(hash_key + i) % self.size][0] == key:
self.table[(hash_key + i) % self.size] = None
i += 1
我们可以这样使用:
ht = HashTable()
ht.insert(1, 'a')
ht.insert(2, 'b')
ht.insert(3, 'c')
print(ht.search(1))
ht.delete(2)
print(ht.search(2))
输出:
a
None 这是采用开放定址法中的线性探测解决散列表冲突的实现,相比拉链法增加了一定的性能开销,但是节省了额外空间。通过代码实现可以加深对散列表冲突解决方法的理解。
动态扩容
动态扩容的核心原理是:当散列表的装填因子(已插入元素个数/表格大小)达到一定阈值后,自动扩大表格大小,并将原表格中的所有元素重新散列插入新表格。这可以避免散列表的装填因子过高,保证查询性能,同时节省空间且重新散列可以得到更均匀的分布。常见的动态扩容方法是将原表格大小加倍。扩容时,需要重新计算每个元素在新表格中的散列地址,并插入元素。扩容操作会带来额外的计算开销,所以需要选择一个适当的装填因子阈值,一般在0.7-0.8之间。
下面是动态扩容操作实现的详细流程:
1. 定义一个装填因子阈值,如0.75;
2. 在每次插入元素时,判断当前表格的装填因子是否达到阈值;如果达到,进行扩容操作;
3. 扩容时,新表格大小为原表格大小的2倍。如原大小为10,则新大小为20;
4. 遍历原表格中的所有元素,重新计算其在新表格中的散列地址,并插入新表格;
5. 将新表格替换原表格;
6. 更新表格大小为新大小。插入元素时,需要使用新大小计算散列地址。
7. 由于表格扩大,搜索和删除元素时也需要使用新表格和新大小,重新计算元素对应的散列地址。
下面是HashTable类中动态扩容的实现:
class HashTable:
def __init__(self, size=10):
self.size = size
self.threshold = 0.75
self.table = [None] * self.size
# 插入元素
def insert(self, key, value):
# 判断是否扩容
if len(self.table) / self.size >= self.threshold:
# 扩容,大小加倍
self.size *= 2
new_table = [None] * self.size
# 重新散列插入元素
for pair in self.table:
hash_key = self.hash_func(pair[0])
new_table[hash_key] = pair
# 使用新表格和大小
self.table = new_table
# 散列插入
hash_key = self.hash_func(key)
self.table[hash_key] = (key, value)
# 搜索和删除元素也需要判断是否扩容并重新计算散列值
位图
位图(Bitmap)是一种节省空间的散列表表示方法。它适用于关键字范围较小的应用场景。位图的原理很简单:它将一个较大的散列表空间压缩为一个字节或多个字节。每个字节的每一位表示散列表的一个存储单元。0表示该单元为空,1表示已插入元素。比如关键字范围在0-255之间,我们可以用一个字节的8位来表示这个散列表,每一位对应一个存储单元。如果要插入关键字5,则将第5位设置为1。要查找关键字30,则检查第30位的值。利用位图,可以节省较大空间,代价是关键字范围受限制在一个字节或多个字节的位数之内。此外,位图不支持存储关联值,只能作为存在性查询来使用。
下面给出位图在Python中的简单实现:
class BitMap:
def __init__(self, size):
self.size = size
# 计算需要的字节数
self.bytes = self.size // 8
if self.size % 8 != 0:
self.bytes += 1
self.bitmap = [0] * self.bytes
# 插入元素
def insert(self, key):
# 计算关键字对应的字节和位
byte_index = key // 8
bit_index = key % 8
# 将对应位设置为1
self.bitmap[byte_index] |= (1 << bit_index)
# 查找元素
def search(self, key):
# 计算关键字对应的字节和位
byte_index = key // 8
bit_index = key % 8
# 检查对应位的值
return self.bitmap[byte_index] & (1 << bit_index) > 0
我们可以这样使用:
bm = BitMap(256)
bm.insert(5)
bm.insert(30)
print(bm.search(5)) # True
print(bm.search(10)) # False
位图是一种节省空间的散列表表示方法,适用于关键字范围较小的场景。
本期的学习内容以上已经完全交代清楚。不过学习数据结构应该要有整体的学习路线把握节奏,一下为学习的路线:
学习数据结构应遵循以下原则:
1. 内容广度和深度并重。既要了解常见数据结构、算法的基本思想和实现,也要深入理解时间复杂度分析、运用场景等核心概念。
2. 理论和实践并重。看书理解概念仅是入门,实现、编码、 practice 才是最关键的一步。要通过大量实践来巩固理论,培养运用能力。
3. 持之以恒。数据结构与算法的学习是一个漫长的过程,需要不断实践和回顾。一遍学习不会真正掌握,需要反复去理解,编码,运用。
更详细的学习计划和时间划分可以如下:
1. 入门基础:数组、链表、栈和队列(1-2周)。理解思想,实现基本操作。
2. 散列表和树形结构(2-4周)。散列表包括散列函数、冲突解决、动态扩容、位图等。树形结构包括二叉树、平衡树、B树等。实现并分析时间复杂度。
3. 排序与查找(2-3周)。分别实现不同的排序算法和查找算法,分析时间复杂度,理解适用场景。
4. 项目实践(2-4周)。选择感兴趣的项目,利用所学数据结构与算法来实现,加深理解和掌握。如LRU缓存、文件索引、搜索引擎等。
5. 扩展学习(3-6周)。框架与库的使用,如STL;算法导论中的高级算法;其他高级数据结构如Trie树、并查集、线段树等。
6. 持续复习(长期)。通过自己总结的知识点,实现代码等,不断回顾和巩固。
综上,要系统掌握比较全面的数据结构与算法,至少需要3-6个月的时间。
详细的学习知识点掌握:
1. 入门基础(2周)- 理解数组、链表、栈和队列的思想和实现
- 实现数组、链表、栈和队列,并分析时间复杂度
- 编写测试用例测试数据结构代码
2. 散列表(Hash Table)(4周) - 理解散列函数的种类和原理,如除留余数法、折叠法等
- 理解散列表的冲突解决方法,如开放定址法、拉链法等
- 实现散列表,并测试不同的散列函数和冲突解决方法
- 深入理解动态扩容、装填因子及其实现
- 实现位图,理解与散列表的区别和使用场景
3. 树形结构(4周) - 理解二叉树、完全二叉树的概念和实现
- 实现二叉树的遍历:先序、中序、后序,并理解各自的应用
- 实现二叉树的插入和删除操作
- 理解平衡二叉树的意义,实现AVL树
- 理解红黑树的性质和实现,实现简单的红黑树
- 理解 B 树和 B+ 树,实现 B+ 树
4. 排序与查找(2周)- 实现冒泡排序、选择排序、插入排序、归并排序、快速排序、堆排序
- 分析各排序算法的时间复杂度和空间复杂度,理解其最坏、最好、平均情况
- 实现二分查找,分析其时间复杂度
5. 项目实践(4周)- 实现 LRU 缓存淘汰算法
- 实现一个简单的词频统计与排名
- 实现搜索引擎的基本功能:爬取网页、构建索引、查询结果排序
6. 扩展学习(至少6周) - 系统学习STL中的数据结构与算法,如 set、map、vector 等
- 学习算法导论中的高级算法,如最短路径、最小生成树等
- 学习其他高级数据结构,如线段树、并查集、Trie 树等
- 不断复习与提高,修正理解的错误和不足
学习数据结构与算法,以下几点方法与鼓励非常重要:
1. 理论与实践结合。只看理论,不编程实现无法真正理解和掌握。要通过实现,编码,练习来巩固理论知识。
2. 阅读推荐书籍。《算法导论》《数据结构与算法 Python 语言描述》等,这些书籍详细而深入,有助更全面掌握知识。
3. 学习官方文档。像 Python 的官方文档对各数据结构与算法都有详细介绍与实现,这也是一个很好的学习资源。
4. 实践通过项目。实现一些实用项目,如 LRU 缓存,使用数据结构与算法知识,极大提高理解和运用能力。
5. 划定学习进度与时间表。制定一份学习计划,比如一周一小结,每天花多长时间学习,避免断断续续,这样可以持之以恒。
6. 边学习边总结。将数据结构与算法理论知识,实现细节与示例代码进行总结,便于复习与 recollection。
7. 不断复习与回顾。利用总结的知识与代码,进行重复的回顾与复习,巩固理解,弥补不足。这是一个永无止境的过程。
8. 多与人交流。将学习遇到的问题、感触与疑问与他人交流,这可以得到新的视角与思路,加速理解。
9. 培养态度。数据结构与算法的学习难度较大,需要耐心与乐观的态度。相信通过不断实践,定会慢慢掌握。
10. 遇到瓶颈再回顾基础。如果在学习或实践中遇到 concept 难以理解的地方,不妨再回顾基础数据结构的相关知识,然后再于深入学习。
综上,学习数据结构与算法最关键是培养良好的学习态度与习惯。