为什么字典中的顺序和设置是任意的?

时间:2013-03-18 14:59:06

标签: python dictionary set python-internals

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

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

我错过了什么?

6 个答案:

答案 0 :(得分:220)

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

对密钥进行哈希处理,并将哈希值分配给动态表中的插槽(它可以根据需要增大或缩小)。并且该映射过程可能导致冲突,这意味着必须根据已经存在的内容在 next 插槽中插入密钥。

在插槽中列出内容循环,因此密钥按当前所在的顺序列出。

例如,使用键'foo''bar',并假设表格大小为8个插槽。在Python 2.7中,hash('foo')-4177197833195190597hash('bar')327024216814240868。 Modulo 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实现)使用的底层结构的技术名称是hash table,它使用开放寻址。如果你很好奇,并且对C足够了解,请查看C implementation以获取所有(详细记录的)详细信息。您还可以观看Pycon 2010 presentation by Brandon Rhodes关于CPython dict的工作原理,或者获取Beautiful Code的副本,其中包括Andrew Kuchling撰写的有关实施的章节。

请注意,从Python 3.3开始,也会使用随机散列种子,使得散列冲突无法预测,以防止某些类型的拒绝服务(攻击者通过引发大规模散列冲突而使Python服务器无响应)。这意味着给定字典的顺序依赖于当前Python调用的随机散列种子。

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

CPython 3.6引入了一个新的 dict实现,它维护了插入顺序,并且启动速度更快,内存效率更高。新实现不是保留每个行引用存储的哈希值以及键和值对象的大型稀疏表,而是添加一个较小的哈希数组,它只引用密集表中的索引(仅包含一个与实际键值对一样多的行),并且密集表恰好按顺序列出包含的项目。请参阅proposal to Python-Dev for more details。请注意,在Python 3.6中,这被认为是实现细节,Python-the-language没有指定其他实现必须保留顺序。这在Python 3.7中有所改变,其中详细信息为elevated to be a language specification;要使任何实现与Python 3.7或更新版本正确兼容,必须复制此订单保留行为。

Python 2.7及更新版本还提供了OrderedDict classdict的子类,它添加了一个额外的数据结构来记录键顺序。以某种速度和额外内存为代价,这个类会记住你插入键的顺序;列表键,值或项目将按此顺序执行。它使用存储在附加字典中的双向链表来有效地保持订单的最新状态。请参阅post by Raymond Hettinger outlining the idea。请注意,set类型仍然是无序的。

如果您想要一个有序集,可以安装oset package;它适用于Python 2.5及更高版本。

答案 1 :(得分:36)

这更像是对Python 3.41 A set作为副本关闭之前的回复。


其他人是对的:不要依赖订单。不要假装有一个。

那就是说,你可以依靠一个的东西:

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完整。如果有20个元素且后备数组长度为30个元素,则后备存储将调整为更大。这是因为你经常会与小型后备存储器发生冲突,并且冲突会使一切都变慢。

  • 后备存储的大小调整为4,从8开始,除了大集(50k元素),其大小调整为2:(8,32,128,...)。

因此,当您创建一个数组时,后备存储的长度为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) % 32,即23:

__  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的实现非常简单:它遍历列表并弹出第一个。


这是所有实施细节。

答案 2 :(得分:16)

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

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

因此,如果你编写的程序依赖于所有字典顺序的任何属性,那么你就是“违反了使用字典类型的合同”,并且Python开发人员不承诺这样做即使它在你测试它时似乎现在有效,它也会一直有效。它基本上相当于依赖于C中的“未定义行为”。

答案 3 :(得分:6)

这个问题的其他答案都非常好而且写得很好。 OP询问“我如何”解释为“他们如何逃脱”或“为什么”。

Python文档说dictionaries没有排序,因为Python字典实现了abstract data type associative array。正如他们所说

  

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

换句话说,计算机科学专业的学生不能假设关联数组是有序的。 math

中的集合也是如此
  

列出一组元素的顺序无关紧要

computer science

  

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

使用哈希表实现字典是一个implementation detail,它的有趣之处在于,就订单而言,它具有与关联数组相同的属性。

答案 4 :(得分:5)

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

但是关于哈希对象中项目的索引,python根据以下代码within 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),以及其他具有散列值的对象。

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

>>> 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 source code中的以下引用:

  

未来的主要微妙之处:大多数哈希计划依赖于拥有一个好的"哈希   功能,在模拟随机性的意义上。 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 << 16 for i in range(20000)]作为一组键。 由于int是他们自己的哈希码,并且这适合于大小为2 ** 15的dict,每个哈希码的最后15位都是0:他们 all 映射到同一个表索引。

     

但迎合不寻常的情况不应该减慢通常情况,所以我们只是采取   无论如何,最后的i位。它可以解决冲突问题。如果   我们通常在第一次尝试时找到我们正在寻找的钥匙(而且,它会转变   我们通常做 - 表负载系数保持在2/3以下,所以赔率   坚定地支持我们,然后最好保留初始索引   计算污垢便宜。

<子> *类int的哈希函数:

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

答案 5 :(得分:1)

从Python 3.7(和already in CPython 3.6)开始,字典项stay in the order they were inserted