【问题标题】:Updating a set while iterating over its elements在迭代其元素时更新集合
【发布时间】:2018-06-09 15:54:36
【问题描述】:

当我尝试在迭代其元素时更新集合时,它的行为应该是什么?

我在各种场景中进行了尝试,它不会对迭代开始后添加的元素进行迭代,也不会对迭代期间删除的元素进行迭代。如果我在迭代期间删除并放回任何元素,则正在考虑该元素。确切的行为是什么以及它是如何工作的?

这会打印字符串的所有排列:

def permutations(s):
    ans = []
    def helper(created, remaining):
        if len(created) == len(s):
            ans.append(''.join(created))
            return
        for ch in remaining:
            remaining.remove(ch)
            created.append(ch)
            helper(created, remaining)
            remaining.add(ch)
            created.pop()
    helper([], set(s))
    return ans

这里的行为是不可预测的,有时会打印e,而有时却不会:

ab = set(['b','c','d'])
x = True
for ch in ab:
    if x:
        ab.remove('c')
        ab.add('e')
        x = False
    print(ch)

在这里我总是只看到一次'c'。即使第一个字符是'c':

ab = set(['b','c','d'])
x = True
for ch in ab:
    if x:
        ab.remove('c')
        ab.add('c')
        x = False
    print(ch)

以及实现上述功能相同目标的另一种方法:

def permwdups(s):
    ans = []
    def helper(created, remaining):
        if len(created) == len(s):
            ans.append(''.join(created))
            return
        for ch in remaining:
            if (remaining[ch]<=0):
                continue
            remaining[ch] -=1
            created.append(ch)
            helper(created, remaining)
            remaining[ch] +=1
            created.pop()
    counts = {}
    for i in range(len(s)):
        if s[i] not in counts:
            counts[s[i]] = 1
        else:
            counts[s[i]]+= 1
    helper([], counts)
    print(len(set(ans)))
    return ans

【问题讨论】:

  • 是的,不要那样做。
  • 我应该避免在迭代期间更改列表/集吗?正如您在上面的示例中看到的,我将不得不创建副本以避免空间复杂度上升。
  • 您是因为对实现细节感到好奇而询问,还是有实际使用它的意图?对于列表,我偶尔会在迭代时修改,因为它相当可预测且方便,但我认为即使是我也不敢在迭代时修改 set。或者也许我只是还没有一个用例......
  • 在您的permutations/helper 中,空间复杂度非常小。您正在处理排列,无论如何数据都会很小,否则您的结果会爆炸。但是如果你确实想在那里节省时间和空间,我建议只给helper 一个 list 和一个 index,这意味着助手仍将置换该索引处的所有内容之后。或类似的东西。
  • 顺便说一句,刚刚看到你的set([str[i] for i in range(len(str))])。这与set(str) 相同。并且最好不要调用变量str(遮蔽了您无法再访问的内置变量,而且它令人困惑,因为人们希望它是内置变量)。

标签: python set iteration python-internals


【解决方案1】:

其实很简单,sets 在 CPython 中实现为 hash - item 表:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -
       ...

CPython 使用开放寻址,因此不会填充表中的所有行,它会根据项目的(截断的)散列确定元素的位置,并在发生冲突时使用“伪随机”位置确定。我将在此答案中忽略截断哈希冲突。

我还将忽略散列截断的细节,只使用整数,所有(除了一些例外)将它们的散列映射到实际值:

>>> hash(1)
1
>>> hash(2)
2
>>> hash(20)
20

因此,当您使用值 1、2 和 3 创建 set 时,您将(大致)拥有下表:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1
-----------------
    2   |    2
-----------------
    3   |    3
       ...

该集合从表的顶部迭代到表的末尾,并忽略空“行”。因此,如果您在不修改的情况下迭代该集合,您将获得数字 1、2 和 3:

>>> for item in {1, 2, 3}: print(item)
1
2
3

基本上,迭代器从第 0 行开始,看到该行为空,然后转到包含项目 1 的第 1 行。该项目由迭代器返回。下一次迭代它在第 2 行并返回那里的值,即2,然后它转到第 3 行并返回存储在那里的3。在接下来的迭代中,迭代器在第 4 行,它是空的,所以它去到第 5 行,它也是空的,然后到第 6 行,......直到它到达表的末尾并抛出一个 StopIteration 异常,这表示迭代器已完成。

但是,如果您在迭代集合时更改集合,则记住迭代器的当前位置(行)。这意味着如果您在前一行中添加一个项目,迭代器将不会返回它,如果它是在后面的行中添加的,它将被返回(至少在迭代器存在之前它没有被再次删除)。

假设,您总是删除当前项目并将整数 item + 1 添加到集合中。像这样的:

s = {1}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item+1)

迭代前的集合是这样的:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1
-----------------
    -   |    -
       ...

迭代器将从第 0 行开始,发现它是空的,然后转到包含值 1 的第 1 行,然后返回并打印该值。如果箭头指示迭代器的位置,它在第一次迭代中将如下所示:

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1      <----------
-----------------
    -   |    -

然后把1去掉,加上2:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -      <----------
-----------------
    2   |    2

所以在下一次迭代中,迭代器找到值2 并返回它。然后两个相加,一个3相加:

  hash  |  item  
-----------------
    -   |    -
-----------------
    -   |    -
-----------------
    -   |    -      <----------
-----------------
    3   |    3

等等。

直到到达7。此时会发生一些有趣的事情:8 的截断哈希意味着8 将被放入第 0 行,但是第 0 行已经被迭代,所以它将以7 停止。实际值可能取决于 Python 版本和集合的添加/删除历史记录,例如,仅更改 set.addset.discard 操作的顺序将产生不同的结果(因为集合已调整大小,所以最多为 15) .

出于同样的原因,如果在每次迭代中添加 item - 1,迭代器只会显示 1,因为 0 将(因为哈希 0)到第一行:

s = {1}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item-1)

  hash  |  item  
-----------------
    -   |    -
-----------------
    1   |    1      <----------
-----------------
    -   |    -

  hash  |  item  
-----------------
    0   |    0
-----------------
    -   |    -
-----------------
    -   |    -      <----------

使用简单的 GIF 进行可视化:

请注意,这些示例非常简单,如果 set 在迭代期间调整大小,它将根据“新”截断散列重新分配存储的项目,并且还会删除在您删除一个一组中的项目。在这种情况下,您仍然可以(大致)预测会发生什么,但它会变得更加复杂。

另一个但非常重要的事实是 Python(自 Python 3.4 起)为每个解释器随机化字符串的哈希值。这意味着每个 Python 会话将为字符串生成不同的哈希值。因此,如果您在迭代时添加/删除字符串,行为也将是随机的。

假设你有这个脚本:

s = {'a'}
for item in s: 
    print(item)
    s.discard(item)
    s.add(item*2)

你从命令行多次运行它,结果会有所不同。

例如我的第一次跑步:

'a'
'aa'

我的第二次/第三次/第四次跑步:

'a'

我的第五次跑步:

'a'
'aa'

这是因为命令行中的脚本总是启动一个新的解释器会话。如果您在同一会话中多次运行该脚本,结果不会有所不同。例如:

>>> def fun():
...     s = {'a'}
...     for item in s: 
...         print(item)
...         s.discard(item)
...         s.add(item*2)

>>> for i in range(10):
...     fun()

产生:

a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa
a
aa

但它也可以给出 10 次 a 或 10 次 aaaaaaa,...


总结一下:

  • 如果项目放置在未迭代的位置,则将显示在迭代期间添加到集合的值。位置取决于项目的截断哈希和碰撞策略。

  • 哈希的截断取决于集合的大小,而该大小取决于集合的添加/删除历史记录。

  • 对于字符串,我们无法预测位置,因为在最近的 Python 版本中,它们的哈希值是在每个会话的基础上随机分配的。

  • 最重要的是:避免在迭代时修改 set / list / dict / ...。它几乎总是会导致问题,即使没有,也会让任何阅读它的人感到困惑!尽管在少数非常罕见的情况下,在迭代列表时将元素添加到列表中是有意义的。那将需要非常具体的 cmets 旁边,否则它看起来像一个错误!特别是对于 set 和 dicts,您将依赖可能随时更改的实现细节!


以防你好奇,我使用 Jupyter Notebook 中的 Cython 内省(有点脆弱,可能只适用于 Python 3.6,绝对不能用于生产代码)检查了集合的内部:

%load_ext Cython

%%cython

from cpython cimport PyObject, PyTypeObject
cimport cython

cdef extern from "Python.h":
    ctypedef Py_ssize_t Py_hash_t

    struct setentry:
        PyObject *key
        Py_hash_t hash

    ctypedef struct PySetObject:
        Py_ssize_t ob_refcnt
        PyTypeObject *ob_type
        Py_ssize_t fill
        Py_ssize_t used
        Py_ssize_t mask
        setentry *table
        Py_hash_t hash
        Py_ssize_t finger

        setentry smalltable[8]
        PyObject *weakreflist

cpdef print_set_table(set inp):
    cdef PySetObject* innerset = <PySetObject *>inp
    for idx in range(innerset.mask+1):
        if (innerset.table[idx].key == NULL):
            print(idx, '<EMPTY>')
        else:
            print(idx, innerset.table[idx].hash, <object>innerset.table[idx].key)

其中打印集合内的键值表:

a = {1}
print_set_table(a)

for idx, item in enumerate(a):
    print('\nidx', idx)
    a.discard(item)
    a.add(item+1)
    print_set_table(a)

请注意,输出将包含虚拟对象(已删除集合项的剩余部分),并且它们有时会消失(当集合变得太满调整大小时)。

【讨论】:

  • 我还观看了 Brandom Rhodes 的 youtube 视频,他说当您添加要设置的元素并且它的大小超过分配的大小时,它会调整大小,但是当您删除元素时它不会调整大小。这意味着在迭代期间添加大量项目然后删除所有项目会使迭代不稳定。 trinket.io/python/7911daac2e如果添加和删除相同的字符串,迭代似乎是稳定的。
  • a = set([]) import randomlets1 = ''.join([chr(ord('a') +i) for i in range(13)])lets2 = ''. join([chr(ord('a') +i) for i in range(13,26)]) for i in range(15): a.add(random.choice(lets1)) newset = set([] ) for chr in a: newset.add(chr) toadd = [] for i in range(150): t = random.choice(lets2) a.add(t) toadd.append(t) for s in toadd: if s in a: a.remove(s) print(newset == a)
  • @user3285099 是的,它肯定会使其不稳定。这就是 CPython 开发人员添加检查并抛出 RuntimeError: Set changed size during iteration 的原因。但是,您可以通过保持集合大小不变来解决这个问题——这并不意味着在迭代集合时修改集合是明确定义的(它不是),只是避免了异常。请注意,在迭代期间调整大小的情况下,任何事情都可能发生,因为调整大小还会触发项目的重新分配和不同的哈希截断(因此顺序可能会改变)。
  • 我认为值得再次强调最后一点:“最重要的是:避免在迭代时修改 set / list / dict / ...。它几乎总是会导致问题,即使它它不会让任何阅读它的人感到困惑吗!”
  • “集在迭代时也无法调整大小” 是什么意思? As I had shown,他们可以:ideone.com/celqTh。和错字:你说你的代码可能会打印aaa,但这是不可能的,你的意思可能是aaaa
猜你喜欢
  • 1970-01-01
  • 2012-05-13
  • 1970-01-01
  • 2011-11-14
  • 1970-01-01
相关资源
最近更新 更多