为什么字典和集合中的顺序是任意的?


151

我不明白如何通过“任意”顺序完成字典或在python中设置的循环。

我的意思是,这是一种编程语言,因此该语言中的所有内容都必须100%确定,对吗?Python必须具有某种算法来决定选择字典或集合的哪一部分,第一,第二等等。

我想念什么?


1
最新的PyPy版本(对于Python 2.7为2.5)默认字典排序
Veedrac

Answers:


236

注意:此答案是dict在Python 3.6中更改类型的实现之前编写的。此答案中的大多数实现细节仍然适用,但是字典中键的列出顺序不再由哈希值确定。设置的实现保持不变。

顺序不是任意的,而是取决于字典或集合的插入和删除历史记录以及特定的Python实现。对于该答案的其余部分,对于“字典”,您还可以阅读“设置”;集被实现为仅具有键而没有值的字典。

对键进行散列,并将散列值分配给动态表中的插槽(它可以根据需要增加或缩小)。映射过程可能导致冲突,这意味着必须根据已存在的键将密钥插入下一个插槽。

列出内容循环遍历插槽,因此键以它们当前在表中的顺序列出。

以键'foo''bar'为例,假设表的大小为8个插槽。在Python 2.7中,hash('foo')is -4177197833195190597hash('bar')is 327024216814240868。模数8,这意味着这两个键分别插入插槽3和4中,然后:

>>> hash('foo')
-4177197833195190597
>>> hash('foo') % 8
3
>>> hash('bar')
327024216814240868
>>> hash('bar') % 8
4

这通知了他们的上市顺序:

>>> {'bar': None, 'foo': None}
{'foo': None, 'bar': None}

除3和4之外的所有插槽均为空,在表上循环先列出插槽3,然后列出插槽4,因此'foo'在之前列出'bar'

barbaz,但是散列值恰好相距8,因此映射到完全相同的插槽4

>>> hash('bar')
327024216814240868
>>> hash('baz')
327024216814240876
>>> hash('bar') % 8
4
>>> hash('baz') % 8
4

现在,他们的顺序取决于首先插入哪个密钥。第二个密钥将必须移至下一个插槽:

>>> {'baz': None, 'bar': None}
{'bar': None, 'baz': None}
>>> {'bar': None, 'baz': None}
{'baz': None, 'bar': None}

此处的表顺序有所不同,因为一个或另一个键先插入插槽。

CPython使用的基础结构(最常用的Python实现)的技术名称是哈希表,该哈希表使用开放式寻址。如果您感到好奇,并且对C足够了解,请查看C实现的所有(详细记录)细节。您还可以观看Brandon RhodesPycon 2010上所作的有关CPython如何dict工作的演示,或获取Beautiful Code的副本,其中包括Andrew Kuchling编写的有关实现的章节。

请注意,从Python 3.3开始,还使用了随机哈希种子,使得哈希冲突无法预测,以防止某些类型的拒绝服务(攻击者通过引起大量哈希冲突而使Python服务器无响应)。这意味着给定字典或集合的顺序取决于当前Python调用的随机哈希种子。

其他实现可以自由地为字典使用不同的结构,只要它们满足已记录的Python接口即可,但是我相信到目前为止,所有实现都使用哈希表的变体。

CPython 3.6引入了一个新的 dict实现,该实现可以维持插入顺序,并且启动起来更快,内存效率更高。新的实现没有保留一个大的稀疏表,其中的每一行都引用存储的哈希值以及键和值对象,而是添加了一个较小的哈希数组,该数组仅引用单独的“密集”表中的索引(一个表仅包含尽可能多的行) (因为有实际的键/值对),而密集表恰好按顺序列出了包含的项。有关更多详细信息,请参见Python-Dev建议。请注意,在Python 3.6中,这被视为实现细节,Python语言不会指定其他实现必须保留顺序。这在Python 3.7中有所更改,在该版本中,此详细信息已提升为一种语言规范;为了使任何实现与Python 3.7或更高版本正确兼容,必须复制此保留顺序的行为。明确地说:此更改不适用于集合,因为集合已经具有“小”哈希结构。

Python 2.7及更高版本还提供了一个OrderedDict该类的子类dict添加了额外的数据结构来记录键顺序。以某种速度和额外的内存为代价,此类会记住您按什么顺序插入键。然后列出键,值或项目将按此顺序进行。它使用存储在其他词典中的双向链接列表来使订单保持最新状态。请参阅Raymond Hettinger帖子,概述该想法OrderedDict对象还有其他优点,例如可重新排序

如果您需要订购的套装,则可以安装oset软件包;它适用于Python 2.5及更高版本。


1
我认为其他Python实现无法以一种或另一种方式使用不是哈希表的任何东西(尽管现在有数十亿种不同的方式实现哈希表,因此仍然有一些自由)。词典使用__hash____eq__(以及其他所有内容)的事实实际上是一种语言保证,而不是实现细节。

1
@delnan:我想知道您是否仍然可以使用具有哈希和相等性测试的BTree。:-)
马丁·彼得

1
这当然是正确的,我很高兴被证明在可行性上是错误的,但是我看不到有任何方法可以在不需要更广泛的合同的情况下击败哈希表。BTree的平均用例性能不会更好,最坏的情况也不会更好(哈希冲突仍然意味着线性搜索)。因此,您只能更好地抵抗许多哈希新的一致(mod tablesize),并且还有许多其他很好的方式来处理该哈希(其中一些用于中dictobject.c),最终得到的比BTree甚至需要找到正确的方法少得多的比较子树。

@delnan:我完全同意;我最不想因为不允许其他实现选项而受到抨击。
马丁·皮特斯

37

这更多是对Python 3.41集的响应,该集在被关闭之前被重复了。


其他人是对的:不要依赖命令。甚至不要假装有一个。

也就是说,您可以依靠件事:

list(myset) == list(myset)

也就是说,顺序是稳定的


要了解为什么会有感知的顺序,就需要了解以下几点:

  • Python使用哈希集

  • CPython的哈希集如何存储在内存中以及

  • 数字如何散列

从顶部:

一个哈希集合是存储随机数据与真快,查找时间的方法。

它具有一个支持数组:

# A C array; items may be NULL,
# a pointer to an object, or a
# special dummy object
_ _ 4 _ _ 2 _ _ 6

我们将忽略特殊的伪对象,该伪对象的存在只是为了使移除更易于处理,因为我们不会从这些集合中移除。

为了真正快速地进行查找,您需要做一些魔术来计算对象的哈希值。唯一的规则是两个相等的对象具有相同的哈希值。(但是,如果两个对象具有相同的哈希,则它们可能不相等。)

然后,通过将模数乘以数组长度来建立索引:

hash(4) % len(storage) = index 2

这使得访问元素确实非常快。

散列只是故事的大部分,因为hash(n) % len(storage)并且hash(m) % len(storage)可以产生相同的数目。在这种情况下,几种不同的策略可以尝试解决冲突。CPython在做复杂的事情之前先使用了9次“线性探测”,因此在寻找其他位置之前,它会在插槽的左侧查找多达9个位置。

CPython的哈希集存储如下:

  • 哈希集不能超过2/3 full。如果有20个元素,并且后备数组长30个元素,则后备存储将调整为更大的大小。这是因为您与小型后备店的碰撞更为频繁,而碰撞会使一切变慢。

  • 除大型存储集(50k元素)以2的幂(8、32、128,...)调整大小外,后备存储以8的幂从4开始调整大小。

因此,当您创建阵列时,后备存储区的长度为8。当存储区的容量为5并添加一个元素时,它将短暂包含6个元素。6 > ²⁄₃·8因此这会触发调整大小,后备存储将大小增加三倍,达到32。

最后,hash(n)仅返回n数字(-1特殊情况除外)。


因此,让我们看第一个:

v_set = {88,11,1,33,21,3,7,55,37,8}

len(v_set)是10,因此在添加所有项目后,后备存储至少为15(+1)。2的相关乘方为32。因此,后备存储为:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

我们有

hash(88) % 32 = 24
hash(11) % 32 = 11
hash(1)  % 32 = 1
hash(33) % 32 = 1
hash(21) % 32 = 21
hash(3)  % 32 = 3
hash(7)  % 32 = 7
hash(55) % 32 = 23
hash(37) % 32 = 5
hash(8)  % 32 = 8

所以这些插入为:

__  1 __  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
   33 ← Can't also be where 1 is;
        either 1 or 33 has to move

因此,我们希望订单像

{[1 or 33], 3, 37, 7, 8, 11, 21, 55, 88}

与1或33不在其他地方的开始。这将使用线性探测,因此我们将具有:


__  1 33  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

要么


__ 33  1  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

您可能希望33是被替换的,因为1已经存在,但是由于在构建集合时会发生调整大小,实际上并非如此。每次重建集合时,已经添加的项目都会有效地重新排序。

现在你明白了为什么

{7,5,11,1,4,13,55,12,2,3,6,20,9,10}

可能是有秩序的。有14个元素,因此后备存储区至少为21 + 1,这意味着32:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

前13个插槽中的1到13个哈希值。20进入插槽20。

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ __ __ __ __ __ __ __ __ __

55进入插槽hash(55) % 3223

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ 55 __ __ __ __ __ __ __ __

如果我们选择50,我们期望

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ 50 __ 20 __ __ __ __ __ __ __ __ __ __ __

瞧瞧:

{1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 20, 50}
#>>> {1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 50, 20}

pop 通过事物的外观非常简单地实现:遍历列表并弹出第一个列表。


这是所有实现细节。


17

“任意”与“不确定”不同。

他们的意思是,没有“在公共界面中”的字典迭代顺序有用的属性。几乎可以肯定,迭代顺序的许多属性完全由当前实现字典迭代的代码确定,但是作者并没有向您保证可以使用它们。这给了他们更大的自由,可以在Python版本之间(甚至在不同的操作条件下,或者在运行时完全随机地)更改这些属性,而不必担心程序会中断。

因此,如果您编写的程序在所有字典顺序上都依赖于任何属性,那么您正在“违反使用字典类型的约定”,并且Python开发人员并不保证这将始终有效,即使它看起来可以正常工作现在,当您对其进行测试时。从根本上讲,这等效于依赖C中的“未定义行为”。


3
请注意,字典迭代的一部分已得到很好的定义:对给定字典的键,值或项的迭代将以相同的顺序进行,只要两者之间没有对字典进行任何更改即可。这意味着与d.items()基本上相同zip(d.keys(), d.values())。但是,如果有任何项目添加到词典中,则所有投注均关闭。顺序可能会完全更改(如果需要调整哈希表的大小),尽管在大多数情况下,您只会发现新项目出现在序列中的任意位置。
Blckknght

6

这个问题的其他答案都很好并且写得很好。OP询问“如何”,我将其解释为“他们如何摆脱”或“为什么”。

Python文档说字典没有排序,因为Python字典实现了抽象数据类型 关联数组。正如他们所说

返回绑定的顺序可以是任意的

换句话说,计算机科学专业的学生不能假设关联数组是有序的。数学中的集合也是如此

集合中元素的列出顺序无关紧要

计算机科学

集合是一种抽象数据类型,可以存储某些值,而没有任何特定顺序

使用哈希表实现字典是一个实现细节,它很有趣,因为就顺序而言,它具有与关联数组相同的属性。


1
基本上是正确的,但是说它是哈希表的实现而不是assoc数组会更近一点(并很好地暗示它“无序”的原因)。
两位炼金术士2015年

5

Python使用哈希表来存储字典,因此使用哈希表的字典或其他可迭代对象中没有顺序。

但是关于哈希对象中项目的索引,python根据以下代码在其中hashtable.c计算索引:

key_hash = ht->hash_func(key);
index = key_hash & (ht->num_buckets - 1);

因此,因为整数的哈希值是整数本身*索引基于数字(ht->num_buckets - 1是一个常数),所以按位计算-和之间的索引(ht->num_buckets - 1)与数字本身*(预期-1的哈希值是-2 ),以及具有其哈希值的其他对象。

考虑以下set使用hash-table的示例:

>>> set([0,1919,2000,3,45,33,333,5])
set([0, 33, 3, 5, 45, 333, 2000, 1919])

对于数量,33我们有:

33 & (ht->num_buckets - 1) = 1

实际上是:

'0b100001' & '0b111'= '0b1' # 1 the index of 33

注意在这种情况下(ht->num_buckets - 1)8-1=70b111

对于1919

'0b11101111111' & '0b111' = '0b111' # 7 the index of 1919

对于333

'0b101001101' & '0b111' = '0b101' # 5 the index of 333

有关python哈希函数的更多详细信息,请阅读python源代码中的以下引号:

未来的主要细节:在模拟随机性的意义上,大多数哈希方案都依赖于具有“良好”的哈希函数。Python并非如此:在最常见的情况下,它最重要的哈希函数(用于字符串和整数)非常规则:

>>> map(hash, (0, 1, 2, 3))
  [0, 1, 2, 3]
>>> map(hash, ("namea", "nameb", "namec", "named"))
  [-1658398457, -1658398460, -1658398459, -1658398462]

这不一定坏!相反,在大小为2 ** i的表中,以低序i位作为初始表索引非常快,并且对于由连续整数范围索引的字典,根本没有冲突。当键是“连续”字符串时,情况大致相同。因此,这在通常情况下会提供比随机行为更好的行为,这是非常理想的。

OTOH,当发生冲突时,填充哈希表的连续切片的趋势使得良好的冲突解决策略至关重要。仅采用哈希码的最后i位也是容易受到攻击的:例如,将列表[i << 16 for i in range(20000)]视为一组键。 由于int是它们自己的哈希码,并且适合大小为2 ** 15的字典,因此每个哈希码的最后15位均为0:它们映射到相同的表索引。

但是迎合不寻常的情况不应减慢通常的情况,因此我们无论如何都只接受最后的i个信息。剩下的事要靠冲突解决来解决。如果我们通常在第一次尝试时就找到了要寻找的密钥(事实证明,我们通常会这样做-表负载因数保持在2/3以下,那么我们的优势很明显),那么就可以了保持初始索引计算的便宜是最好的选择。


*类的哈希函数int

class int:
    def __hash__(self):
        value = self
        if value == -1:
            value = -2
        return value


By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.