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

Why is the order in dictionaries and sets arbitrary?

我不明白在python中如何以"任意"顺序循环字典或集合。

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

我错过了什么?


顺序不是任意的,而是取决于字典或集合的插入和删除历史,以及特定的Python实现。对于这个答案的其余部分,对于"dictionary",您还可以读取"set";集合作为字典实现,只有键,没有值。

键被散列,散列值被分配给动态表中的槽(它可以根据需要增长或收缩)。这个映射过程可能会导致冲突,这意味着必须根据已经存在的内容在下一个槽中开槽。

列出内容在插槽上循环,因此键按它们当前在表中的排列顺序列出。

以键'foo''bar'为例,假设表大小为8个插槽。在python 2.7中,hash('foo')-4177197833195190597hash('bar')327024216814240868。模块8,这意味着这两个键在插槽3和4中开槽,然后:

1
2
3
4
5
6
7
8
>>> hash('foo')
-4177197833195190597
>>> hash('foo') % 8
3
>>> hash('bar')
327024216814240868
>>> hash('bar') % 8
4

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

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

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

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

1
2
3
4
5
6
7
8
>>> hash('bar')
327024216814240868
>>> hash('baz')
327024216814240876
>>> hash('bar') % 8
4
>>> hash('baz') % 8
4

它们的顺序现在取决于哪个键是第一个槽;第二个键必须移动到下一个槽:

1
2
3
4
>>> {'baz': None, 'bar': None}
{'bar': None, 'baz': None}
>>> {'bar': None, 'baz': None}
{'baz': None, 'bar': None}

表的顺序在这里有所不同,因为其中一个键或另一个键是先开槽的。

cpython(最常用的python实现)使用的底层结构的技术名称是散列表,它使用开放寻址。如果您很好奇,并且对C有足够的了解,请查看C实现中的所有(有良好文档记录的)细节。您还可以观看Brandon Rhodes关于CPython dict如何工作的2010年Pycon演示,或者获取一份漂亮的代码副本,其中包括安德鲁·库克林(Andrew Kuchling)编写的关于实现的一章。

请注意,从python 3.3开始,还使用了随机散列种子,使得散列冲突无法预测,以防止某些类型的拒绝服务(在这种情况下,攻击者会导致大量散列冲突,从而使python服务器失去响应)。这意味着给定字典的顺序也依赖于当前Python调用的随机散列种子。

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

cpython 3.6引入了一种新的dict实现,它可以保持插入顺序,并且启动速度更快,内存效率更高。新的实现没有保留一个大的稀疏表,其中每一行引用存储的哈希值、键和值对象,而是添加了一个较小的哈希数组,该数组只引用密集表中的索引(其中一个数组只包含与实际键值对相同数量的行),并且恰好是密集表列出了所包含的索引。EMS订单。有关详细信息,请参阅向python dev提交的建议。注意,在python 3.6中,这被视为一个实现细节,python语言没有指定其他实现必须保持顺序。这在python3.7中发生了变化,其中这个细节被提升为一种语言规范;为了使任何实现与python3.7或更高版本正确兼容,它必须复制这个顺序保持行为。

python2.7和更新版本还提供了一个OrderedDict类,这是dict的一个子类,它添加了一个额外的数据结构来记录键顺序。以一定的速度和额外的内存为代价,这个类会记住您插入键的顺序;然后列出键、值或项将按该顺序进行。它使用存储在附加字典中的双重链接列表来高效地保持订单的最新状态。见雷蒙德·赫廷格的文章概述了这个想法。请注意,set类型仍然是无序的。

如果您想要订购一套,可以安装oset包;它可以在python2.5及更高版本上工作。


这更像是对python 3.41集的响应,在它作为副本关闭之前。

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

也就是说,有一件事你可以依靠:

1
list(myset) == list(myset)

也就是说,顺序是稳定的。

理解为什么有一个感知的秩序需要理解一些事情:

  • python使用散列集,

  • cpython的散列集如何存储在内存中,以及

  • 数字如何散列

从顶部:

哈希集是一种存储随机数据的方法,查找时间非常快。

它有一个后备阵列:

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

我们将忽略特殊的虚拟对象,它的存在只是为了使删除更容易处理,因为我们不会从这些集合中删除。

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

然后,通过按数组长度取模来生成索引:

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

这使得访问元素变得非常快速。

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

cpython的散列集存储如下:

  • 哈希集不能超过2/3满。如果有20个元素,并且支持数组的长度为30个元素,则支持存储将调整为更大的大小。这是因为与小的后备存储器的碰撞更频繁,而碰撞会减慢所有操作的速度。

  • 备份存储从8开始以4的幂次调整大小,但以2的幂次调整大小的大型集(50K元素)除外:(8,32,128,…)。

所以当您创建一个数组时,后备存储的长度是8。当它是5满的并且您添加了一个元素时,它将简短地包含6个元素。6 > 2??·8所以这会触发一个调整大小,后备存储器的大小是32的四倍。

最后,hash(n)只返回n作为数字(-1是特殊的除外)。

那么,让我们看看第一个:

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

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

1
__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

我们有

1
2
3
4
5
6
7
8
9
10
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
2
3
__  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
{[1 or 33], 3, 37, 7, 8, 11, 21, 55, 88}

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

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

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

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

现在你知道为什么了

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

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

1
__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

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

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

55进入槽hash(55) % 32,即23:

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

如果我们选择50,我们会期待

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

你看:

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

这是所有实现细节。


"武断"和"不确定"不是一回事。

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

因此,如果您编写的程序依赖于所有字典顺序中的任何属性,那么您就是在"打破使用字典类型的契约",而Python开发人员并没有承诺这将始终有效,即使在您测试它时它现在似乎有效。它基本上相当于依赖C语言中的"未定义行为"。


这个问题的其他答案很好,写得很好。操作询问"如何",我解释为"他们如何摆脱"或"为什么"。

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

the order in which the bindings are returned may be arbitrary

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

the order in which the elements of a set are listed is irrelevant

计算机科学

a set is an abstract data type that can store certain values, without any particular order

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


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

但是对于散列对象中的项目索引,python根据hashtable.c中的以下代码计算索引:

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

因此,由于整数的散列值是整数本身*索引基于数字(ht->num_buckets - 1是一个常量),因此由(ht->num_buckets - 1)和数字本身*之间按位计算的索引(其散列值为-2的-1除外),以及其他具有散列值的对象。

考虑使用哈希表的set的以下示例:

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

对于数字33,我们有:

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

实际上是:

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

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

对于1919

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

对于333

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

有关python散列函数的更多详细信息,可以从python源代码中读取以下引号:

Major subtleties ahead: Most hash schemes depend on having a"good" hash
function, in the sense of simulating randomness. Python doesn't: its most
important hash functions (for strings and ints) are very regular in common
cases:

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

This isn't necessarily bad! To the contrary, in a table of size 2**i, taking
the low-order i bits as the initial table index is extremely fast, and there
are no collisions at all for dicts indexed by a contiguous range of ints.
The same is approximately true when keys are"consecutive" strings. So this
gives better-than-random behavior in common cases, and that's very desirable.

OTOH, when collisions occur, the tendency to fill contiguous slices of the
hash table makes a good collision resolution strategy crucial. Taking only
the last i bits of the hash code is also vulnerable: for example, consider
the list [i << 16 for i in range(20000)] as a set of keys. Since ints are their own hash codes, and this fits in a dict of size 2**15, the last 15 bits of every hash code are all 0: they all map to the same table index.

But catering to unusual cases should not slow the usual ones, so we just take
the last i bits anyway. It's up to collision resolution to do the rest. If
we usually find the key we're looking for on the first try (and, it turns
out, we usually do -- the table load factor is kept under 2/3, so the odds
are solidly in our favor), then it makes best sense to keep the initial index
computation dirt cheap.

*类int的散列函数:

1
2
3
4
5
6
class int:
    def __hash__(self):
        value = self
        if value == -1:
            value = -2
        return value


从python 3.7开始(并且已经在cpython 3.6中),字典项保持插入顺序。