【问题标题】:Why do I get this many iterations when adding to and removing from a set while iterating over it?为什么在迭代集合时添加和删除集合时会得到这么多迭代?
【发布时间】:2020-07-27 13:05:24
【问题描述】:

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

>>> s = {0}
>>> for i in s:
...     s.add(i + 1)
...     s.remove(i)
...
>>> print(s)
{16}

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

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

【问题讨论】:

  • 根据您添加的项目,每次对s.add(i+1) 的调用(可能还有对s.remove(i) 的调用)都可以改变集合的迭代顺序,从而影响for 循环的集合迭代器创建后会看到。当你有一个活跃的迭代器时,不要改变一个对象。
  • 我还注意到 t = {16} 然后 t.add(15) 得出 t 是集合 {16, 15}。我认为问题出在某个地方。
  • 这是一个实现细节 - 16 的哈希值比 15 低(这是@Anon 注意到的),因此将 16 添加到集合类型中会将其添加到迭代器的“已经看到”部分,因此迭代器被耗尽。
  • 如果您阅读 trough de docs,有一条注释说在循环期间改变迭代器可能会产生一些错误。见:docs.python.org/3.7/reference/…
  • @Błotosmętek:在 CPython 3.8.2 上,hash(16) == 16 和 hash(15) == 15。这种行为并非来自哈希本身较低;元素不直接按哈希顺序存储在集合中。

标签: python python-internals


【解决方案1】:

Python 设置一个不记录元素位置或插入顺序的无序集合。 python集中的任何元素都没有附加索引。因此它们不支持任何索引或切片操作。

所以不要指望你的 for 循环会按照定义的顺序工作。

为什么要进行 16 次迭代?

user2357112 supports Monica 已经解释了主要原因。在这里,是另一种思维方式。

s = {0}
for i in s:
     s.add(i + 1)
     print(s)
     s.remove(i)
print(s)

当你运行这段代码时,它会给你输出:

{0, 1}                                                                                                                               
{1, 2}                                                                                                                               
{2, 3}                                                                                                                               
{3, 4}                                                                                                                               
{4, 5}                                                                                                                               
{5, 6}                                                                                                                               
{6, 7}                                                                                                                               
{7, 8}
{8, 9}                                                                                                                               
{9, 10}                                                                                                                              
{10, 11}                                                                                                                             
{11, 12}                                                                                                                             
{12, 13}                                                                                                                             
{13, 14}                                                                                                                             
{14, 15}                                                                                                                             
{16, 15}                                                                                                                             
{16}       

当我们像循环或打印集合一样访问所有元素时,必须有一个预定义的顺序才能遍历整个集合。 因此,在最后一次迭代中,您将看到顺序从 {i,i+1} 更改为 {i+1,i}。

在最后一次迭代之后,i+1 已经被遍历,所以循环退出。

有趣的事实: 使用任何小于 16 的值,除了 6 和 7 将始终为您提供结果 16。

【讨论】:

  • "使用任何小于 16 的值总是会给你结果 16。" - 用 6 或 7 试试,你会发现它不成立。
【解决方案2】:

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;

大多数小的 Python int 对自己进行哈希处理;特别是,您的测试哈希中的所有整数都是自己的。你可以在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;

您的集合最初以大小为 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;

当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);

so-&gt;used 是哈希表中填充的非虚拟条目的数量,即 2,因此 set_table_resize 接收 8 作为其第二个参数。基于此,set_table_resizedecides新的哈希表大小应该是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.
}

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

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

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

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

【讨论】:

    【解决方案3】:

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

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

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

    【讨论】:

      【解决方案4】:

      来自 python 3 文档:

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

      迭代副本

      s = {0}
      s2 = s.copy()
      for i in s2:
           s.add(i + 1)
           s.remove(i)
      

      应该只迭代 1 次

      >>> print(s)
      {1}
      >>> print(s2)
      {0}
      

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

      【讨论】:

      • 是的。但我的问题是为什么它会进行 16 次迭代。
      • 集合是无序的。字典和集合以非随机顺序迭代,并且这种迭代算法仅在您不修改任何内容时才成立。对于列表和元组,它可以只按索引进行迭代。 当我在 3.7.2 中尝试您的代码时,它进行了 8 次迭代。
      • 迭代顺序可能与散列有关,正如其他人提到的那样
      • “导致某种堆栈跟踪排序的事情”是什么意思?该代码没有导致崩溃或错误,所以我没有看到任何堆栈跟踪。如何在 python 中启用堆栈跟踪?
      猜你喜欢
      • 2012-06-28
      • 1970-01-01
      • 2011-03-03
      • 1970-01-01
      • 2012-05-13
      相关资源
      最近更新 更多