Python学习笔记20:字典与集合

Python学习笔记20:字典与集合

这篇笔记的内容源自《Fluent Python》第二章的部分,主要是对之前笔记中字典和集合没有涉猎部分的一个补充。

关于字典和集合的基础部分可以阅读Python学习笔记2:字典Python学习笔记3:元组和集合

散列

在介绍字典与集合的延伸内容之前,我们先要理解一个数据结构:散列。

数据结构

散列这种数据结构用于解决这么一类问题:如何将一组随机分布的数据放置在一组容量有限的存储中,并且要有一个快速读写的性能。

这个问题有这么几个要点:

  • 容量有限:如果是无限空间,自然可以用无限长的数组实现寻址访问,但那样在现实世界中是不现实的。
  • 数据样本随机:这里体现了散列算法的重要性,即如何能将一组数据计算出一个尽量“分散地”散列值。
  • 快速读写:要保证一个常量化地时间复杂度,散列的基础肯定是一个数组类型的快速寻址的数据结构,通过散列函数来定位。但考虑到这个问题的实际性,必然会出现散列值相同或者数组被装满的情况,这时候就要考虑扩容或者再外挂其它数据结构。

强烈建议花几分钟阅读散列表、全域散列与完全散列,其中介绍散列的内容非常不错,简明扼要。要了解散列只需要阅读开头和结尾部分即可,全域散列和完全散列无需涉猎。

字典遍历

为何需要在讲解字典和集合前先说散列?

因为字典的key正是用散列实现的,所以才能以常数级的时间复杂度实现检索和插入。

但正如我们之前所提到的,散列结构是动态的,为了维持一个低时间复杂度,它会在恰当的时间(比如散列中的空隙快被填满)进行扩容,而扩容后和扩容前的数据位置必然会不同。

这就意味着我们对字典和集合这类依赖于散列的容器进行遍历操作时,不能在遍历的同时进行删除和添加元素,因为这类操作可能触发散列结构的改变,而我们的迭代器可能因此出现错误,会忽略一些本应存在的元素。

正确的做法应该是用其它容器来记录我们的删除、添加动作,在遍历结束后再在原字典中进行操作。

顺带一提,字典的无序性也源自于此,简单的散列结构并不需要保存散列表中元素的顺序,自然是无序的。

当然也可以实现一个有序散列,比如通过给散列表附加上一个链表的结构,但这样做会带来额外的维护开销。顺带一提,PHP中的数组就是一个有序散列。

案例

我本科读软件工程的时候,有个简单搜索引擎的课题作业,就涉及到散列。

搜索引擎

简单的搜索引擎原理并不复杂,无非是先用爬虫爬取页面和链接,然后对页面内容做词法分析,进行词频统计,并通过链接关联和网站权重做一个综合权重排序。这样就能针对每一个关键字生成一个网站排行。

那剩下的问题就是如何在用户输入后快速匹配出一个关键字。

这个是不是和散列有点像?

对,那个时候我们的课题作业并没有用数据库等复杂工具,就是用一个散列加载关键字实现的。

当然这只是简单的课题作业,实际中的关键字库容量庞大,内存肯定是不够的,必然要用到数据库等其它工具。

我们已经简单介绍了散列结构和使用案例,再来讨论一个在实际编程中颇为重要的概念:可散列。

可散列

当一个变量符合一定条件,可以存放进散列这种数据结构中的时候,我们可以称之为可散列。

条件

要符合可散列,变量需要具有以下特性:

  • 在变量的生命周期内散列值不能发生变化。
  • 两个相等的变量其散列值必须相等。

第一个特性很好理解,我们已经说过了,散列的目的是为了把给定的数据尽量分散地存储进有限空间。而散列算法就是根据这些数据算出地一个特征值,如果这个值会变来变去,那存储的位置是不是也要随之改变?这样就会让问题变得复杂,不符合散列解决的问题场景。

第二个特性也不难理解,两个相等的变量,必然会是相同的散列值,会存储在散列表中相同(外挂链式结构)或者相邻位置。

但我们需要明确的是,不相等的变量也可能具有相同的散列值,原因也很简单,散列本来就是映射到有限空间,必然会出现散列冲突的情况。

字典

效率

我们之前已经讨论过了,散列查询是一个常量级的时间复杂度。而字典的key就是用散列来实现的,所以对字典的查询和插入动作都是常量级时间复杂度。

字面量与构造函数

在我还是应届生的时候,找工作常会遇到类似这样的面试题:a=[1,2,3]a=list(1,2,3)有什么区别。

当然,那个时候还不会Python,只不过是其它语言的版本。

大概很多在学校里没有深入学习的学生会骂娘,这不是鸡蛋里挑石头吗。但这里面还真有一些说道。

a=[1,2,3]这种写法在Python中称为使用字面量创建列表,解释器在处理的时候会调用C语言编写的底层模块处理,效率很高。而a=list(1,2,3)是使用list的构造函数来生成列表,效率自然比前者要差一些。

当然,后者底层肯定还是通过C语言模块实现,但毕竟还要经过构造函数,多了一层调用。

这点我们通过查看汇编指令能更清晰地看到:

from dis import dis
dis("a=[1,2,3]")
dis("a=list(1,2,3)")
#输出
#   1           0 BUILD_LIST               0
#               2 LOAD_CONST               0 ((1, 2, 3))
#               4 LIST_EXTEND              1
#               6 STORE_NAME               0 (a)        
#               8 LOAD_CONST               1 (None)
#              10 RETURN_VALUE
#   1           0 LOAD_NAME                0 (list)
#               2 LOAD_CONST               0 (1)
#               4 LOAD_CONST               1 (2)
#               6 LOAD_CONST               2 (3)
#               8 CALL_FUNCTION            3
#              10 STORE_NAME               1 (a)
#              12 LOAD_CONST               3 (None)
#              14 RETURN_VALUE

映射的弹性键查询

这个段落标题可能很容易令人疑惑和绕口,但看一个例子就很容易明白了:

string = "Hellow World"
charCounter = {
     }
for char in string:
    if char not in charCounter:
        charCounter[char] = 0
    charCounter[char] += 1
print(charCounter)
#输出
#{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 1, ' ': 1, 'W': 1, 'r': 1, 'd': 1}

这段代码实现了一个词频统计。如果是用强类型语言,比如C++或者Java,我们在进行此类工作地时候可能先需要初始化一个数组或者别的什么容器,然后再进行词频统计。

如果统计样本规模比较小,这样做并无问题,但如果样本大,那么对一些不会统计的元素进行初始化就毫无必要且浪费空间,所以我们需要一个可以动态添加的“弹性”容器,比如Python中的字典。而这种用到的时候再查询的方式,就叫做映射的弹性键查询。

PHP和Python都是弱类型语言,也同样都有“弹性”容器。而在使用的时候都面临一个问题,即初始化问题。

除了如上边示例中的那样,通过键是否存在判断后 进行初始化,Python还支持一些其它的方式。

get

字典的get方法是可以设定默认值的,如果key不存在,则返回默认值,所以我们可以将词频统计代码改写成这样:

string = "Hellow World"
charCounter = {
     }
for char in string:
    charCounter[char] = charCounter.get(char, 0)+1
print(charCounter)
# 输出
#{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 1, ' ': 1, 'W': 1, 'r': 1, 'd': 1}

setdefault

Python中更常见和推荐的方式是使用setdefault

string = "Hellow World"
charCounter = {
     }
for char in string:
    charCounter.setdefault(char, 0)
    charCounter[char] += 1
print(charCounter)
# 输出
#{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 1, ' ': 1, 'W': 1, 'r': 1, 'd': 1}

这种方式在之前Python学习笔记2:字典中有过介绍。

使用setdefault相对于if判断的写法,更为简洁,且和业务逻辑完全剥离,可读性更好。

defaultdict

除了上面两种方式,Python还提供一个特殊的字典容器:defaultdict

from collections import defaultdict
def setZero():
    return 0
charCounter = defaultdict(setZero)
string = "Hellow world!"
for char in string:
    charCounter[char] += 1
print(charCounter)
#输出
#defaultdict(, {'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 2, ' ': 1, 'r': 1, 'd': 1, '!': 1})

需要特别说明的是,defaultdict的构造函数接收的并不是一个普通变量,必须是一个callable变量,即函数对象。

defaultdict之所以可以做到在查询没有初始化的弹性键的时候不抛出异常,而能正确使用默认值,这都归功于一个字典类都有的魔术方法__missing__

__missing__

当字典查询不到对用的键的时候,就会调用这个魔术方法,如果是基类,肯定是简单抛出typeError。而如果要实现默认值,这个方法就很有用。

下面我们尝试从dict继承并构建一个自己的默认字典:

class myDefaultDict(dict):

    def setDefault(self, default):
        self.default = default

    def __missing__(self, key):
        self[key] = self.default
        return self.default


counter = myDefaultDict()
counter.setDefault(0)
string = "Hellow world!"
for char in string:
    counter[char] += 1
print(counter)
#输出
#{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 2, ' ': 1, 'r': 1, 'd': 1, '!': 1}

事实上,编写Python代码的时候如果需要自定义字典,通常做法并非是直接从dict继承,而是从UserDict进行派生。

子类化UserDict

from collections import UserDict


class MyDefaultDict(UserDict):
    def setdefault(self, default):
        self.defaultVal = default

    def __missing__(self, key):
        self.data[key] = self.defaultVal
        return self.defaultVal


charCounter = MyDefaultDict()
charCounter.setdefault(0)
string = "Hellow world!"
for char in string:
    charCounter[char] += 1
print(charCounter)
#{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 2, ' ': 1, 'r': 1, 'd': 1, '!': 1}

需要特别说明的是,实际上UserDict是通过一个内部变量data持有实际的字典数据。这样做有很多好处,其中包括在重写字典相关调用方法的时候无需顾虑可能引发的无限递归调用。

其它特种字典

实际上,Python标准库还提供了其它独特用途的字典容器,这里仅通过一个Counter类举例说明:

from collections import Counter
charCounter = Counter("Hellow world!")
print(charCounter)
# 输出
# Counter({'l': 3, 'o': 2, 'w': 2, 'H': 1, 'e': 1, ' ': 1, 'r': 1, 'd': 1, '!': 1})

我们可以看到,通过使用collections.Counter,我们的词频统计代码只需要一行就实现了。

集合

通过前面的一系列学习,我们已经很清楚了,为了实现快速索引,字典的key必须是可散列的,而集合的元素也必须是可散列的。

正是因为集合有着这样的特性,才可以高效地进行去重以及其它集合运算,因为其查询时间复杂度是常数级,并不会随着容量地增加而显著变慢。

就像之前我说过的,算法的本质是时间换空间或者空间换时间。散列的本质就是通过空间(额外的散列表空隙)来换取查询和插入速度的提升。

集合的大部分内容已经在之前的文章中讨论过了,这里不过多赘述,这里着重说明一下集合运算相关的内容。

操作

对于集合运算,Python提供两种方式:运算符和函数调用。这里简单比较一下两种方式。

setA = {
     1, 2, 3, 4, 5, 6}
setB = {
     2, 5, 9, 10}
print(setA & setB)
print(setA.intersection(setB))
listA = [2, 5, 5, 9, 9, 10]
print(setA.intersection(listA))
# 输出
# {2, 5}
# {2, 5}
# {2, 5}

很简单,两种方式的结果是等效的,运算符的方式相对来说更简洁直观。

值得一提的是,使用运算符进行集合运算的时候,运算符两端必须都是集合,而函数调用则更灵活,只要求参数是一个可迭代对象即可。其余集合运算也都具有这个特点,后边不再赘述。

setA = {
     1, 2, 3, 4, 5, 6}
setB = {
     2, 5, 9, 10}
print(setA | setB)
print(setA.union(setB))
listA = [2, 5, 5, 9, 9, 10]
print(setA.union(listA))
# 输出
# {1, 2, 3, 4, 5, 6, 9, 10}
# {1, 2, 3, 4, 5, 6, 9, 10}
# {1, 2, 3, 4, 5, 6, 9, 10}

setA = {
     1, 2, 3, 4, 5, 6}
setB = {
     2, 5, 9, 10}
print(setA - setB)
print(setA.difference(setB))
listA = [2, 5, 5, 9, 9, 10]
print(setA.difference(listA))
# 输出
# {1, 3, 4, 6}
# {1, 3, 4, 6}
# {1, 3, 4, 6}

对称差

setA = {
     1, 2, 3, 4, 5, 6}
setB = {
     2, 5, 9, 10}
print(setA ^ setB)
print(setA.symmetric_difference(setB))
listA = [2, 5, 5, 9, 9, 10]
print(setA.symmetric_difference(listA))
# 输出
# {1, 3, 4, 6, 9, 10}
# {1, 3, 4, 6, 9, 10}
# {1, 3, 4, 6, 9, 10}

好了,字典和集合的扩展部分内容介绍完了,说实话还是蛮累的,但写过一遍后学过的知识的确会扎实不少。

谢谢阅读。

你可能感兴趣的:(Python,Python,字典,集合,散列)