关于字典和集合顺序的一些思考
要探究这个问题,首先需要明白,字典和集合的底层逻辑都是哈希表。那先来复习一下什么叫哈希表
哈希表
哈希(hash)
hash,意译为散列,音译为哈希。是把任意长度的输入通过特定的算法函数变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间。这种映射的算法就叫做哈希函数。哈希有几个特征:
若记关键码为k,哈希函数为f,则散列值为f(k)。
- 哈希函数计算得到的哈希值是一个固定长度的输出值
- 如果 f(k1) 不等于 f(k2),那么 key1、key2 一定不相等
- 如果 f(k1) 等于 f(k2),那么 key1、key2 可能相等,也可能不相等。也就是说不同的输入经过哈希函数处理之后可能会得到相同的输出,因此通过输出也无法逆推输入。这种情况称为”碰撞“或者”冲突“。
哈希表(hash table)
传统的线性表,因为查找的时候是依据索引,需要从索引为0的地址开始遍历,效率低下。而哈希表的原理则是通过哈希函数,使用自定义的关键码key,来得出一个值,这个值就是key所对应的数据在表中存储的位置,因此可以直接访问该地址来获取值,而不需要从头遍历了。所以关键码key和存储值value的地址之间通过哈希函数建立了映射关系,共同形成了一个key-value键值对。
故,哈希表的定义为:
-
若关键字为k,其对应值存放在表中f(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为散列函数,按这个思想建立的表为散列表。(f(k)为关键字k通过哈希函数计算出的地址)
设计哈希函数的方法有很多,例如直接寻址法,平方取中法,随机数法,除留余数法等等。但不管哪种函数,都不可避免的会出现冲突,所以又有很多解决冲突的方法,例如开放寻址法,再散列法等等,这里就不详细说了。
总之:哈希表就是以键值对的形式来存储数据,通过键可以直接访问值,从而大大提高了查找效率。
可哈希与不可哈希
Python中的字典就是一个典型的哈希表,字典的键必须是不可变类型(数字,元组,字符串)。是因为不可变类型的数据才是可哈希的对象,不同的值意味着不同的内存地址,相同的值存储在相同的内存地址,将的不可变对象理解成哈希表中的Key,将内存理解为经过哈希运算的哈希值Value,如果是可变类型作为key,那对于key每次哈希运算的结果都不一样,从而导致找到的value不一样。这显然是离谱的。
所以当尝试用可变类型列表作为字典的键时,会报错:unhashable type: 'list',也就是list类型是不可哈希的。同理,因为集合中的元素也必须是不可变类型,如果尝试向集合中添加一个列表,也会报错:unhashable type: 'list'。所以这两点证明了Python中的字典与集合,底层都是哈希表结构。
字典
字典是有序还是无序?
首先,简单的判断有序还是无序的方法其实就是看元素之间输入的顺序是否和输出的顺序一致,那经过反复实验可以发现,对于字典,定义的顺序和输入的顺序总是相同的,上网查一下,就可以得到答案:字典在Python 3.6版本之后就变成有序的了。
事实上,字典有一个方法popitem(),可以删除最后一个键值对,这也充分佐证了字典是有序的。虽然他是有序的,但是它并不支持索引访问。字典并不是一个Subscriptible(可下标)的对象。
索引
说到这里,不得不提一下索引这个东西,因为Python其实并不存在所谓基本类型,列表、字符串这些都是一个对象,他们支持索引访问的原因是实现了__getitem__这个方法,翻开源码,可以发现,list和str类里都有这个方法,但是dict没有,所以字典并不支持索引访问。我们也可以自定义一个类,只要在里面实现了__getitem__方法,那这个类的对象就可以使用索引来访问。
class Student(): def __init__(self, *values): self.array = values def __getitem__(self, index): return self.array[index] stu = Student("a", "b", "c", "d") print(stu[0])
那字典为啥不支持索引呢?其根本原因,还是因为字典是一个哈希表,在创建的时候,它的容量要比列表大很多:
dc = {"a": "abc", "b": "abc", "c": "abc"} ls = ["abc", "abc", "abc"] #结果为232和80 print(getsizeof(dc)) print(getsizeof(ls))
同样是存储三个“abc”,字典占用的空间几乎是列表的三倍。虽然同样是有序的,但是字典内元素的地址空间因为是哈希得到的,所以并不像列表一样紧凑而有规律。用索引来访问的成本就很高,而且没有意义,因为哈希表的优势就是不用像顺序线性表一样从头遍历访问元素,而索引的原理是从首地址(索引为0的元素地址)开始,往后顺序查找。那对于哈希表,如果采用索引去访问,那就是自废武功,既浪费空间也浪费时间。所以字典当然不会支持用索引去访问。
一个有趣的发现
dc = {"a": "abc", "b": "abc", "c": "abc"} print(id(dc["a"])) print(id(dc["b"])) print(id(dc["c"]))
在字典当中,用不同的键对应三个同样的字符串,输出他们的地址。发现这三个字符串“abc”的地址是相同的,这是因为Python中的字典、列表、元组、集合其实都不是真正意义上的容器,他们中间存放不是“值”而是“引用”,也就是地址。那为了节约空间,如果这样定义,Python就会让这三个“abc”引用同一个对象,当然,每次运行,这个对象的地址都不相同。
但是,有趣的是:
dc = {"a": 1, "b": 2, "c": 3} ls = [1,2,3] print(id(dc["a"])) print(id(dc["b"])) print(id(dc["c"])) print(id(ls[0])) print(id(ls[1])) print(id(ls[2]))
如果换成整数1、2、3,会发现这三个数的地址是挨着的,并且列表中的1、2、3和字典中的1、2、3,地址是相同的。同时,在相同设备上重复运行,会发现它们的地址不会变,这就说明了每次程序运行的时候,并不是申请一个新地址来存放这些数字,而是直接引用现成的数字。不管在什么结构中、运行多少次,数字1的地址始终是那一个,不会更改。可以理解为:这些数字是预定义的,当程序需要使用的时候,直接引用这些定义好的数字就可以了。经过实验,-5到256之间的整数,满足这个条件。超过这个范围,每次运行得到的地址都会变。
集合
字典和集合都是哈希表,在Python当中,是性能特化的结构。集合有三大特征:
1.确定性:集合中的元素必须是确定的;
2.互异性:集合中的元素互不相同;
3.无序性:集合中的元素没有先后之分,如:{3,4,5}=={3,5,4}结果为True。
其中,确定性指集合中的元素不能被修改,也就是必须是不可变类型,其原理就是因为集合是一张哈希表,而哈希的对象就是集合中的元素,可变类型是不可哈希的,所以不能作为集合元素,这和字典的键必须是不可变类型一样。同时,因为集合元素要被用来作哈希,而如果出现相同的元素,就会得到相同的哈希值,会造成哈希冲突,所以字典的键和集合元素都要求不能重复,而判断两个元素是否重复的依据就是对两个元素调用内置函数hash(),如果得到相同的值,那就认为这两个元素是重复的。
前两个特征,从哈希表的角度,很容易得到解释,但是无序性这一点,却有点麻烦。
s = {"a","b","c"}
for i in range(10): print(s)
多次运行,因为集合是无序的,所以会得到不同的输出顺序。但每次运行的这十次打印结果是相同的,因为第一次创建好集合之后,后续的循环都是对这个集合的重复引用。
但比较有趣的是:
s = {1,2,3,4,5,6,7,8,9} print(s)
不管运行多少次,输出的顺序都和定义的顺序一致。但如果换成:
s = {1,4,7,10} print(s)
虽然输出的顺序变成了{1,10,4,7},但不管运行多少次,结果始终是{1,10,4,7},至于为什么,暂时还不清楚。