为什么在迭代时添加到集合和从集合中删除时会得到这么多的迭代?

noo*_*low 70 python python-internals

试图理解 Python for 循环,我认为这会给出{1}一次迭代的结果,或者只是陷入无限循环,这取决于它是否像在 C 或其他语言中那样进行迭代。但实际上两者都没有。

>>> s = {0}
>>> for i in s:
...     s.add(i + 1)
...     s.remove(i)
...
>>> print(s)
{16}
Run Code Online (Sandbox Code Playgroud)

为什么要进行 16 次迭代?结果{16}从何而来?

这是使用 Python 3.8.2。在 pypy 上,它产生了预期的结果{1}

use*_*ica 97

Python 没有承诺这个循环何时(如果有的话)结束。在迭代过程中修改集合可能会导致元素被跳过、元素重复和其他奇怪的现象。永远不要依赖这种行为。

我要说的一切都是实现细节,如有更改,恕不另行通知。如果您编写的程序依赖于其中任何一个,则您的程序可能会因 Python 实现和版本的任何组合而中断,而不是 CPython 3.8.2。

为什么循环在 16 结束的简短解释是 16 是第一个元素,它碰巧放置在比前一个元素更低的哈希表索引处。完整的解释如下。


Python 集合的内部哈希表大小始终为 2 的幂。对于大小为 2^n 的表,如果没有发生冲突,元素将存储在哈希表中与其哈希的 n 个最低有效位相对应的位置。您可以在set_add_entry以下位置看到它的实现:

mask = so->mask;
i = (size_t)hash & mask;

entry = &so->table[i];
if (entry->key == NULL)
    goto found_unused;
Run Code Online (Sandbox Code Playgroud)

大多数小的 Python 整数都是自己散列的;特别是,您的测试中的所有整数都是自己的。您可以在long_hash. 由于您的集合从不包含哈希中具有相同低位的两个元素,因此不会发生冲突。


Python 集合迭代器使用集合内部哈希表中的简单整数索引来跟踪其在集合中的位置。当请求下一个元素时,迭代器在哈希表中从该索引开始搜索填充的条目,然后将其存储的索引设置为紧跟在找到的条目之后并返回条目的元素。你可以在setiter_iternext

while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy))
    i++;
si->si_pos = i+1;
if (i > mask)
    goto fail;
si->len--;
key = entry[i].key;
Py_INCREF(key);
return key;
Run Code Online (Sandbox Code Playgroud)

您的集合最初以大小为 8 的散列表和指向0散列表中索引 0 处的int 对象的指针开始。迭代器也位于索引 0 处。当您迭代时,元素被添加到哈希表中,每个元素都在下一个索引处,因为这是它们的哈希表示将它们放置的位置,并且始终是迭代器查看的下一个索引。删除的元素在其旧位置存储了一个虚拟标记,用于解决冲突。你可以看到它在set_discard_entry

entry = set_lookkey(so, key, hash);
if (entry == NULL)
    return -1;
if (entry->key == NULL)
    return DISCARD_NOTFOUND;
old_key = entry->key;
entry->key = dummy;
entry->hash = -1;
so->used--;
Py_DECREF(old_key);
return DISCARD_FOUND;
Run Code Online (Sandbox Code Playgroud)

4被添加到集合中时,集合中的元素和哑元的数量变得足够高,set_add_entry触发哈希表重建,调用set_table_resize

if ((size_t)so->fill*5 < mask*3)
    return 0;
return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);
Run Code Online (Sandbox Code Playgroud)

so->used是哈希表中填充的非虚拟条目的数量,即 2,因此set_table_resize接收 8 作为其第二个参数。基于此,set_table_resize 决定新的哈希表大小应为 16:

/* Find the smallest table size > minused. */
/* XXX speed-up with intrinsics */
size_t newsize = PySet_MINSIZE;
while (newsize <= (size_t)minused) {
    newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
}
Run Code Online (Sandbox Code Playgroud)

它重建大小为 16 的哈希表。所有元素仍以新哈希表中的旧索引结束,因为它们的哈希值中没有设置任何高位。

随着循环的继续,元素不断被放置在迭代器将要查找的下一个索引处。触发了另一个哈希表重建,但新的大小仍然是 16。

当循环添加 16 作为一个元素时,模式中断。没有索引 16 可以放置新元素。16 的最低 4 位是 0000,将 16 放在索引 0 处。此时迭代器存储的索引是 16,当循环向迭代器请求下一个元素时,迭代器看到它已经超过了哈希表。

迭代器此时终止循环,只留16在集合中。


Jan*_*oci 17

我相信这与 python 中集合的实际实现有关。集合使用哈希表来存储它们的项目,因此迭代集合意味着迭代其哈希表的行。

当您迭代并将项目添加到您的集合时,将创建新的哈希并将其附加到哈希表中,直到您达到第 16 位。此时,下一个数字实际上是添加到哈希表的开头而不是末尾。由于您已经遍历了表的第一行,因此迭代循环结束。

我的回答是基于这个类似问题,它实际上显示了这个完全相同的例子。我真的建议阅读它以获取更多详细信息。


Eri*_*Jin 10

来自 python 3 文档:

在迭代同一集合时修改集合的代码可能很难正确。相反,循环遍历集合的副本或创建新集合通常更直接:

迭代一个副本

s = {0}
s2 = s.copy()
for i in s2:
     s.add(i + 1)
     s.remove(i)
Run Code Online (Sandbox Code Playgroud)

应该只迭代 1 次

>>> print(s)
{1}
>>> print(s2)
{0}
Run Code Online (Sandbox Code Playgroud)

编辑:此迭代的一个可能原因是因为集合是无序的,导致某种堆栈跟踪之类的事情。如果你用一个列表而不是一个集合来做它,那么它就会结束,s = [1]因为列表是有序的,所以 for 循环将从索引 0 开始,然后移动到下一个索引,发现没有一个,并且退出循环。