❑ 学习散列表——最有用的基本数据结构之一。散列表用途广泛,本章将介绍其常见的用途。
❑ 学习散列表的内部机制:实现、冲突和散列函数。这将帮助你理解如何分析散列表的性能。
1. 散列函数
散列函数:数组和链表都被直接映射到内存,但散列表更复杂,它使用散列函数来确定元素的存储位置。
要求:
❑ 它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都必须为4。如果不是这样,散列表将毫无用处。
❑ 它应将不同的输入映射到不同的数字。例如,如果一个散列函数不管输入是什么都返回1,它就不是好的散列函数。最理想的情况是,将不同的输入映射到不同的数字。
❑ 散列函数总是将同样的输入映射到相同的索引。每次你输入avocado,得到的都是同一个数字。因此,你可首先使用它来确定将鳄梨的价格存储在什么地方,并在以后使用它来确定鳄梨的价格存储在什么地方。
❑ 散列函数将不同的输入映射到不同的索引。avocado映射到索引4,milk映射到索引0。每种商品都映射到数组的不同位置,让你能够将其价格存储到这里。
❑ 散列函数知道数组有多大,只返回有效的索引。如果数组包含5个元素,散列函数就不会返回无效索引100。
你可能根本不需要自己去实现散列表,任一优秀的语言都提供了散列表实现。Python提供的散列表实现为字典,你可使用函数dict来创建散列表。
book = dict()
book['apple']=0.67
book['milk']=1.49
book['avocado']=1.6
print (book['apple'])
散列表由键和值组成。在前面的散列表book中,键为商品名,值为商品价格。散列表将键映射到值。
【练习】
当说一个散列函数是“一致的”时,意味着对于相同的输入,这个散列函数总是会产生相同的输出。
f(x)=len(x) 一致,每次都返回这个字符串x的长度。
f(x) = rand () 随机数
2. 应用案例
【映射:将列表用于查找】
假设你要创建一个类似这样的电话簿,将姓名映射到电话号码。该电话簿需要提供如下功能。
❑ 添加联系人及其电话号码。
❑ 通过输入联系人来获悉其电话号码。这非常适合使用散列表来实现!在下述情况下,使用散列表是很不错的选择。
❑ 创建映射。
❑ 查找。
phone_book=dict()
phone_book['Hym']=111222
phone_book['feifei']=2222222
phone_book['ashley']=111111
print(phone_book['ashley'])
散列表被用于大海捞针式的查找,将网址映射到IP地址。例如,你在访问像http://adit.io这样的网站时,计算机必须将adit.io转换为IP地址。
【防止重复,区分集合和列表】
1. 集合
voted = {'Ashley', 'Hym', 'Feifei'}
def check_voter(name):
if name in voted:
print("Kick them out!")
else:
voted.add(name)
print("Let them vote!")
check_voter('Tony')
2. 列表
在Python中,列表使用方括号 `[]` 来表示。列表是一种有序、可变的数据类型,可以包含多种数据类型的元素,包括整数、浮点数、字符串等。
voted = ['Ashley', 'Hym', 'Feifei']
def check_voter(name):
if name in voted:
print("Kick them out!")
else:
voted.append(name) #.append
print("Let them vote!")
check_voter('Tony')
【比较列表[]和集合字典{}】
操作 | 集合 | 列表 |
创建 | my_set = {1, 2, 3} | my_list = [1, 2, 3, 'hello', 4.5] |
添加 | my_set.add(4) | my_list.append(6) 到末尾 |
删除 | my_set.remove(2) # 如果元素不存在,会抛出 KeyError my_set.discard(2) # 如果元素不存在,不会抛出错误 my_set.pop() # 随机删除一个元素 | del my_list[1] |
集合操作 | intersection_set = set1 & set2 # 或者使用 intersection_set = set1.intersection(set2) | |
difference_set = set1 - set2 # 或者使用 difference_set = set1.difference(set2) | ||
symmetric_difference_set = set1 ^ set2 # 或者使用symmetric_difference_set = set1.symmetric_difference(set2) | ||
set1 = {1, 2, 3} set2 = {3, 4, 5} union_set = set1 | set2 # 或者使用 union_set = set1.union(set2) | ||
插入 | my_list.insert(2,'new_element') | |
移动 | my_list.remove('hello') | |
获取列表长度 | len() | |
切片 | sublist = my_list[1:3] # 获取索引1到2的子列表 | |
列表合并 | new_list = my_list + [7, 8, 9] |
【将散列表用作缓存cache】
访问facebook网站
(1)你向Facebook的服务器发出请求。(2)服务器做些处理,生成一个网页并将其发送给你。(3)你获得一个网页。
例如,Facebook的服务器可能搜集你朋友的最近活动,以便向你显示这些信息,这需要几秒钟的时间。作为用户的你,可能感觉这几秒钟很久,进而可能认为Facebook怎么这么慢!另一方面,Facebook的服务器必须为数以百万的用户提供服务,每个人的几秒钟累积起来就相当多了。为服务好所有用户,Facebook的服务器实际上在很努力地工作。有没有办法让Facebook的服务器少做些工作,从而提高Facebook网站的访问速度呢?
网站将数据记住,而不再重新计算。
如果你登录了Facebook,你看到的所有内容都是为你定制的。你每次访问facebook.com,其服务器都需考虑你感兴趣的是什么内容。
这就是缓存,具有如下两个优点。
❑ 用户能够更快地看到网页。
❑ Facebook需要做的工作更少。缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在散列表中!
import urllib.request
cache = {}
def get_page(url):
if url in cache:
return cache[url] # 返回缓存中的数据
else:
data = get_data_from_server(url)
cache[url] = data # 将数据保存到缓存中
return data
# 从服务器获取真实数据的函数
def get_data_from_server(url):
try:
response = urllib.request.urlopen(url)
return response.read().decode('utf-8')
except urllib.error.URLError as e:
print(f"Error fetching data from server: {e}")
return None
# 替换为你的网址
url_to_fetch = " "
# 调用 get_page 函数
result = get_page(url_to_fetch)
print(result)
结果是已经登陆过的内容。
注意:
get_page()函数名后圆括号
{}集合/字典中的的键-值,cache[url] 表示索引,键。通过键访问值。
【散列表的三个作用】
❑ 模拟映射关系;
❑ 防止重复;
❑ 缓存/记住数据,以免服务器再通过处理来生成它们。
3. 冲突collision
冲突(collision):给两个键分配的位置相同。
Avocado 和 apple分配了两个同样的键怎么办?
处理冲突的方式很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表(分别指向avocado, avocado下一个是 apple)
❑ 散列函数很重要。前面的散列函数将所有的键都映射到一个位置,而最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
❑ 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很好,这些链表就不会很长!
4. 性能
线性时间(简单查找) | 对数时间(二分查找) | 常量时间(散列表) |
O(n) | O(logn) | O(1) |
这意味着无论散列表包含一个元素还是10亿个元素,从其中获取数据所需的时间都相同。实际上,你以前见过常量时间——从数组中获取一个元素所需的时间就是固定的:不管数组多大,从中获取一个元素所需的时间都是相同的。在平均情况下,散列表的速度确实很快。
在最糟情况下,散列表所有操作的运行时间都为O(n)——线性时间,这真的很慢。
读取(查地址) | 插入+删除 (集体插入/分别) | |
数组 | O(1) | O(n) |
链表 | O(n) | O(1) |
数列表平均情况 | O(1) | O(1) |
数列表最糟情况 | O(n) | O(n) |
平均情况下,兼具两者优点。
最糟糕情况下,各项操作都很慢。因此要避免冲突。
需要具备:
❑ 较低的填装因子;❑ 良好的散列函数。
以下是散列表运行原理(其实不管你使用的是哪种编程语言,其中都内置了散列表实现。你可使用内置的散列表,并假定其性能良好。)
【填装因子】
公式:因子=散列表包含的元素数/位置数
散列表使用数组来存储数据,因此你需要计算数组中被占用的位置数。
填装因子大于1意味着商品数量超过了数组的位置数。一旦填装因子开始增大,你就需要在散列表中添加位置,这被称为调整长度(resizing)。填装因子越低,发生冲突的可能性越小,散列表的性能越高。
【良好的散列函数】
良好的散列函数均匀分布,糟糕的散列函数链表扎堆。
【练习】
5.4 散列函数的结果必须是均匀分布的,这很重要。它们的映射范围必须尽可能大。最糟糕的散列函数莫过于将所有输入都映射到散列表的同一个位置。假设你有四个处理字符串的散列函数。
A.不管输入是什么,都返回1。 所有键都指向1的位置。
B.将字符串的长度用作索引。 长度一样的字符串指向同一个位置。
C.将字符串的第一个字符用作索引。即将所有以a打头的字符串都映射到散列表的同一个位置,以此类推。
D.将每个字符都映射到一个素数:a=2,b=3,c=5,d=7,e=11,等等。对于给定的字符串,这个散列函数将其中每个字符对应的素数相加,再计算结果除以散列表长度的余数。例如,如果散列表的长度为10,字符串为bag,则索引为(3+2+17)% 10=22% 10=2。在下面的每个示例中,上述哪个散列函数可实现均匀分布?假设散列表的长度为10。
使用素数相加可能减缓冲突的发生。
5.5 将姓名和电话号码分别作为键和值的电话簿,其中联系人姓名为Esther、Ben、Bob和Dan。
phone_book = {'Esther': '123-456-7890', 'Ben': '987-654-3210', 'Bob': '555-123-4567', 'Dan': '111-222-3333'}
5.6 电池尺寸到功率的映射,其中电池尺寸为A、AA、AAA和AAAA。
battery_power = {'A': 'Low', 'AA': 'Medium', 'AAA': 'High', 'AAAA': 'Ultra'}
5.7 书名到作者的映射,其中书名分别为Maus、Fun Home和Watchmen。
book_author = {'Maus': 'Art Spiegelman', 'Fun Home': 'Alison Bechdel', 'Watchmen': 'Alan Moore'}
5.小结
你几乎根本不用自己去实现散列表,因为你使用的编程语言提供了散列表实现。
你可使用Python提供的散列表,并假定能够获得平均情况下的性能:常量时间。
散列表是一种功能强大的数据结构,其操作速度快,还能让你以不同的方式建立数据模型。你可能很快会发现自己经常在使用它。
❑ 你可以结合散列函数和数组来创建散列表。
❑ 冲突很糟糕,你应使用可以最大限度减少冲突的散列函数。
❑ 散列表的查找、插入和删除速度都非常快。
❑ 散列表适合用于模拟映射关系。
❑ 一旦填装因子超过0.7,就该调整散列表的长度。
❑ 散列表可用于缓存数据(例如,在Web服务器上)。
❑ 散列表非常适合用于防止重复。