测试开发基础之算法(9):散列表原理及在Python中的应用

我们知道,数组具有一个特别强大的特性是,能够根据下标随机访问数组元素,时间复杂度是O(1)。散列表(Hash表)正是列用了数组的这一特性,对数组进行了扩展,实现了针对非整型下标的高效存储和访问。

1. 散列思想

举个简单例子,假设运动员编码是6位数字,要想将99名运动员的姓名按照运动员编号存入数组a中,数组下标对应运动员编号的后两位,也就是数组下标1的位置a[1]存放编号为030101的运动员姓名,数组下标2的位置a[2]存放编号为04022的运动员姓名,以此类推,数组下标k处a[k]存放编号后两位为k的运动员姓名。存储方式如下图:
在这里插入图片描述
这里面有一个关键是将运动员编号转换成为了数组下标。我们将运动员编号转化为数组下标的映射方法就叫作散列函数,运动员编号叫做,散列函数的结果值叫做散列值,在我们的这个例子,就是数组下标。这就是典型的散列思想。

下面我们分别介绍一下散列表中如何进行插入、查找和删除数据。

  • 散列表的插入过程

下图展示了散列表的插入过程:
在这里插入图片描述
键040202经过hash函数后得到值是2,所以将键040202对应的运动员姓名“小花”插入散列表下标为2的位置。

  • 散列表的查找过程

当我们按照键(040202)查询元素时,我们用同样的散列函数,将键转化数组下标,从对应的数组下标的位置取数据。以上面例子为例,在查询players[‘040202’]时,将040202经过散列函数运算得到2,再到散列表中查询下标为2的元素 a[2] 。

  • 判断某个元素是否存在
    通过散列函数求出要查找元素的键对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
    在这里插入图片描述
  • 散列表的删除操作
    从散列表中删除元素,不能单纯地把要删除的元素设置为空,而是应该特殊标记为 deleted。为什么不能直接将散列表的数据设置为空呢?因为这样会导致原来的查找算法失效。我们拿一张图来解释:
    在这里插入图片描述
    在删除散列表中下标为1的元素后,我们将下标为1的元素置为deleted,当线性探测查找y的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测,从而能够找到y。但是如果将我们将下标为1的元素置为空,在当线性探测查找y的时候,探测到下标为1的时候,就停止了,这样就找不到y了。

2. 散列函数

可以看到在散列表中,散列函数至关重要。我们可以把它定义成 hash(key),其中 key 表示元素的键,hash(key) 的返回值表示经过散列函数计算得到的散列值。
针对上面的例子,散列函数可以定义成如下所示:

def hash(key):
    return int(key[-2:])

上面的例子,运动员的编号是比较有规律的,但是如果是6位随机字符串,那么散列函数如何设计呢?
首先,因为数组下标是从 0 开始的,所以散列函数生成的散列值也要是非负整数。
其次,相同的 key,经过散列函数得到的散列值也应该是相同的。
最后,不同的key,最好经过散列函数后得到的散列值也不相同。

满足前面两点要求的函数很好设计,第三点是比较难,如果不同的key 得到相同的散列值,我们叫发生了“散列冲突”。因此散列函数的设计关键是解决散列冲突。

常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。

  • 开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。重新探测新位置的方法,线性探测(Linear Probing)、二次探测和双重散列。

线性探测,指的是某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。用图来表示就是:
在这里插入图片描述
上图中,x经过散列函数散列后得到7,但是下标为7的位置被占用,所以要从下标为7的位置顺序往后查找空余的地方,直到找到下标为2的位置将其插入。

随着散列表插入的数据越来越多,空闲的槽位越来越少,发生散列冲突的可能性大大增加了。极端情况下,我们要探测整个散列表才能找到插入的位置。除了线性探测之外,二次探测寻找空闲槽位的办法是,每次探测的步长变成原来的二次方。双重散列则是,遇到冲突后,换一个散列函数,看看新的散列函数能不能找到空闲槽位。

但是不管什么办法,都不能从根本上解决问题,散列冲突的根本原因还是由于空闲的槽位太少了,也就是装载因子太高了。

  • 链表法

相比开放寻址法,链表法要简单很多。链表法解决散列冲突的思路是,在散列表中,每个槽位都会对应一条链表,散列值相同的元素,都放到同一个槽位对应的链条中。

在这里插入图片描述
在插入时,通过散列函数计算槽位,将散列值放入到对应的链条中。在查找和删除时,通过散列函数计算槽位,再遍历链表进行查找和删除。查找和删除的时间复杂度与链条长度成正比O(k),链条长度k是n/m,n是散列值个数,m是槽位个数。

对比开放寻址法和链表法,链表法的适用性更强。但是对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。

3.如何设计散列函数

关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。

4. 散列表的扩容

前面说的散列冲突的根本原因就是装载因子过大。当装载因子过大时,我们可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中,降低装载因子。降低装载因子,就会增加足够的槽位,散列冲突的可能性就会降低。

前面的文章中,介绍过数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。我们需要通过散列函数重新计算每个数据的存储位置。

插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算散列位置,并且搬移数据,所以时间复杂度是 O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。

5. 散列表的无序性

散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。

6.散列表在Python中的应用

  • 字典

Python中字典是一系列由键(key)和值(value)配对组成的元素的集合,相比于列表和元组,字典的性能更优,特别是对于查找、添加和删除操作,字典都能在O(1)时间复杂度内完成。这个性能,正是来自于散列表的特性。

我们先来感受一下Python中字典相比列表和元祖这两种数据结构,它的性能到底有多高呢?

比如电商后台,存储了每件产品的 ID、价格。现在的需求是,给定某件商品的 ID查找出其价格。
如果用列表数据结构,查找算法如下:

products_list = [(123, 10), (234, 8), (345, 19), (456, 20)]


def find_price(products, product_id):
    for _id, _price in products:
        if _id == product_id:
            return _price
        else:
            return None

通过这段代码,我们可以发现,时间复杂度是O(n),即使使用二分查找,算法复杂度最快也是O(logn),更何况使用二分查找还要事先进行O(nlogn)时间复杂度的排序操作。

利用字典的数据结构,只需 O(1) 的时间复杂度就可以完成。原因就是字典的内部组成是一张哈希表,直接通过键的哈希值,找到其对应的值。

products_list = {123: 10, 234: 8, 345: 19, 456: 20}


def find_price(products, product_id):
    return products.get(product_id, None)
  • 集合

集合是高度优化的哈希表,里面元素不能重复。
看下集合与列表和元祖的对比,它的性能如何。对上面的例子,提出新的需求,要找出这些商品有多少种不同的价格。如果用列表实现,代码应该是如下:

products_list = [(123, 10), (234, 8), (345, 19), (456, 20), (567, 8)]


def find_unique_price(products):
    unique_price = []  
    for _, price in products:  # 一层循环
        if price not in unique_price:  # 二层循环,虽然没用for但也是循环
            unique_price.append(price)
    return unique_price

代码中有两层循环,在最差情况下,需要 O(n^2) 的时间复杂度。

再来看看集合的实现方法。这里少用一层循环,集合是高度优化的哈希表,里面元素不能重复,并且其添加和查找操作只需 O(1) 的复杂度,那么,总的时间复杂度就只有 O(n)。

products_list = [(123, 10), (234, 8), (345, 19), (456, 20), (567, 8)]


def find_unique_price(products):
    unique_price = set()
    for _, price in products:
        unique_price.add(price)
    return unique_price

7.Python字典的存储结构和操作原理

上面通过例子以及与列表的对比,看到了字典和集合操作的高效性。不过,字典和集合为什么能够如此高效,特别是查找、插入和删除操作?因为他们本质上都是一张散列表。

  • 字典,散列表存储了哈希值(hash)、键和值这 3 个元素。
  • 集合,散列表中存储志存出了哈希值(hash)、键。键对应的值并不关心,可以将其设置成任意值。

在老版本的Python中,字典的散列表结构如下:

--+-------------------------------+
  | 哈希值(hash)  键(key)  值(value)
--+-------------------------------+
0 |    hash0      key0    value0
--+-------------------------------+
1 |    hash1      key1    value1
--+-------------------------------+
2 |    hash2      key2    value2
--+-------------------------------+
. |           ...
__+_______________________________+

举个例子,比如我有这样一个字典:
{'name': 'mike', 'dob': '1999-01-01', 'gender': 'male'}
那么它会存储为类似下面的形式:

entries = [
['--', '--', '--']
[-230273521, 'dob', '1999-01-01'],
['--', '--', '--'],
['--', '--', '--'],
[1231236123, 'name', 'mike'],
['--', '--', '--'],
[9371539127, 'gender', 'male']
]

通过前面散列表的介绍,随着散列表的插入和删除,散列表的存储会越来越系数,浪费太多的空间。
为了提高存储空间的利用率,在新版的Python对其字典的存储进行了改进,把索引和哈希值、键、值分开存储。变成下面的样子:

Indices
----------------------------------------------------
None | index | None | None | index | None | index ...
----------------------------------------------------

Entries
--------------------
hash0   key0  value0
---------------------
hash1   key1  value1
---------------------
hash2   key2  value2
---------------------
        ...
---------------------

那么,刚刚的这个例子,在新的哈希表结构下的存储形式,就会变成下面这样:

indices = [None, 1, None, None, 0, None, 2]
entries = [
[1231236123, 'name', 'mike'],
[-230273521, 'dob', '1999-01-01'],
[9371539127, 'gender', 'male']
]

很明显,这种存储方式相比之前的存储方式节约了大量内存。
清楚了字典的存储方式,接下来看看如何进行字典的插入、查找和删除操作。

  • 插入

当每次向字典或者集合中插入元素,Python会计算键的散列值hash(key),再计算这个元素应该插入哈希表的位置 index = hash(key) & mask,其中mask=PyDicMinSize - 1 ,如果哈希表中此位置是空的,那么这个元素就会被插入其中。

这个过程可以用下面的图来展示,比如插入a的过程,先计算a的哈希值,然后与7进行“与”运算,计算出index是0,如果哈希表中索引0处是空的,就将其插入。

而如果此位置已被占用,Python 便会比较两个元素的哈希值和键是否相等。

  • 若两者都相等,则表明这个元素已经存在,如果值不同,则更新值。
  • 若两个元素的键不相等,但是哈希值相等,也就是说明发生了哈希冲突,Python 便会继续利用开放寻址法和链表法寻找表中空余的位置,直到找到位置为止。
    在这里插入图片描述
  • 查找

与插入类似,查找某一个元素时,Python先计算键的哈希值(hash(key),然后找到索引位置。再比较这个索引上,存储的键和哈希值,是否与查找的元素和对应的哈希值一致。如果一致,则直接返回值。如果不一致,则继续查找,直到找到空位或者抛出异常。

  • 删除

当删除某一个元素时,Python并不会立即删除,而是先将其赋值为一个特殊的值,等到重新调整散列表大小时,再将其删除。

前面分析过,装载因子太高时,发生散列冲突的概率就会加大,导致插入的速度降低。因此,Python为了保证高效的字典和集合操作,通常会在保证装载因子不高于2/3,当高于2/3时,Python就会申请更大内存,扩充散列表,这时,散列表中的所有元素都会重新计算散列值,重新排放。

字典在 Python3.7+ 是有序的数据结构,而集合是无序的,其内部的哈希表存储结构,保证了其查找、插入、删除操作的高效性。所以,字典和集合通常运用在对元素的高效查找、去重等场景。

8.练习题1:对 10 万条 URL 访问日志,按照访问次数对URL 排序

我们把这10万条url的访问数量,存入Python字典这种数据结构中。然后根据字典的值来进行排序,就可以了。方法类似于,对一个字典products_dict={“url1”: 10, 234: 8, 345: 19, 456: 20, 567: 8},按照值来进行从大到小排序:

products_dict = {"url1": 10, "url2": 8, "url3": 19, "url4": 20, "url5": 8}
print(sorted(products_dict.values(), reverse=True))
print(sorted(products_dict.items(), key=lambda x: x[1], reverse=True))

9.练习题2:有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?

先遍历一遍第一个数组,得到一个以字符串为键,值为任意值的字典A,然后遍历第二个数组,如果元素在字典A里,即是相同的字符串,时间复杂度是O(n)。解决思路如下:

list1 = ["hello", "world", "code"]
list2 = ["code", "change", "world"]

a = {x: 0 for x in list1}
common = []
for x in list2:  # 单层循环,时间复杂度是O(n)
    if x in a:  # 高效,时间复杂度是O(1)
        common.append(x)

print(common)

类似的,Word 文档中单词拼写检查功能,也是同样的思路,将英文字典中所有单词存入Python字典中。然后在word文档中,对每一个单词到Python字典中查找,如果找不到则表示单词拼写错误。

10. 动手实现一个类似Python字典的散列表

来自https://leetcode-cn.com/problems/design-hashmap/

不使用任何内建的哈希表库设计一个哈希映射

具体地说,你的设计应该包含以下的功能

put(key, value):向哈希映射中插入(键,值)的数值对。如果键对应的值已经存在,更新这个值。
get(key):返回给定的键所对应的值,如果映射中不包含这个键,返回-1。 remove(key):如果映射中存在这个键,删除这个数值对。

示例:

MyHashMap hashMap = new MyHashMap(); hashMap.put(1, 1);
hashMap.put(2, 2); hashMap.get(1); // 返回 1
hashMap.get(3); // 返回 -1 (未找到) hashMap.put(2, 1);
// 更新已有的值 hashMap.get(2); // 返回 1 hashMap.remove(2);
// 删除键为2的数据 hashMap.get(2); // 返回 -1 (未找到)

注意:

所有的值都在 [1, 1000000]的范围内。 操作的总数目在[1, 10000]范围内。 不要使用内建的哈希库。

我们使用拉链法来实现一个散列表。它的思想很简单,在哈希表中的每个位置上,用一个链表来存储所有映射到该位置的元素。

  • 对于put(key,value)操作

先求出key的hash值,然后遍历该位置上的链表,如果链表中包含key,则更新对应的value;如果链表中不包含key,则直接将(key,value)插入改链表中。

  • 对于get(key)操作:

求出key对应的hash值,遍历该位置上的链表,如果key在链表中,则返回其对应的value,否则返回-1。

  • 对于remove(key)操作:

求出key的hash值,遍历该位置上的链表,如果key在链表中,则将其删除。

代码如下:

class MyHashMap:

    def __init__(self):
        """
        Initialize your data structure here.
        """
        self.hash = [[] for _ in range(20011)]  # 开辟一个大数组,长度为质数,注意这里不能用 [[]] * 20011
        # 一般定义成离2的整次幂比较远的质数,这样取模之后冲突的概率比较低。

    def put(self, key: int, value: int) -> None:
        """
        value will always be non-negative.
        """
        t = key % 20011  # 求hash值
        for item in self.hash[t]:  # 遍历哈希到的链表中,查找key,并更新值
            if item[0] == key:
                item[1] = value
                return  # 更新完之后,直接返回
        self.hash[t].append([key, value])  # 如果链表中找不到对应的key,将其新添到链表中

    def get(self, key: int) -> int:
        """
        Returns the value to which the specified key is mapped, or -1 if this map contains no mapping for the key
        """
        t = key % 20011
        for item in self.hash[t]:
            if item[0] == key:
                return item[1]
        return -1  # 可能哈希的位置,所对应的链表不为空,但是不存在该值

    def remove(self, key: int) -> None:
        """
        Removes the mapping of the specified value key if this map contains a mapping for the key
        """
        t = key % 20011
        delete = []
        for item in self.hash[t]:
            if item[0] == key:
                delete = item  # remove方法,这里可以偷懒,把对应的value值设为-1,即表示它已经删除
        if delete:
            self.hash[t].remove(delete)

总结

散列表示一种高效的数据结构,对于插入、查找和删除都能做到O(1)的时间复杂度。理解散列表的原理,重点是理解散列函数的设计要求,如何解决散列冲突。散列表在Python语言中的应用是字典和集合这两种数据结构,非常适合需要对元素进行高效查找和去重的场景。

发布了187 篇原创文章 · 获赞 270 · 访问量 172万+

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/103458602
今日推荐