Python 在 O(1) 中的字典中获取随机键

Posted

技术标签:

【中文标题】Python 在 O(1) 中的字典中获取随机键【英文标题】:Python get random key in a dictionary in O(1) 【发布时间】:2012-05-31 20:36:16 【问题描述】:

我需要一个支持 FAST 插入和删除(键、值)对的数据结构,以及“获取随机键”,它与字典的 random.choice(dict.keys()) 执行相同的操作.我在互联网上搜索过,尽管它是线性时间,但大多数人似乎对 random.choice(dict.keys()) 方法感到满意。

我知道更快地实现这一点是可能的

我可以使用调整大小的哈希表。如果我保持键与槽的比率在 1 和 2 之间,那么我可以选择随机索引,直到遇到非空槽。我只看 1 到 2 个键,在意料之中。 我可以使用 AVL 树在保证最坏情况 O(log n) 的情况下获得这些操作,并增加等级。

但是,有没有什么简单的方法可以在 Python 中实现这一点?好像应该有!

【问题讨论】:

这里有一个想法:保留另一个dict 形式为i: key,其中i 是一个计数器。然后,要进行随机查找,请在另一个字典上调用 randint。如果我错了,请纠正我,但这听起来像 O(1)。 嗯,从你说的情况来看,如何进行插入和删除并不明显。但是,这在一定程度上确实有效。跟踪最大计数器值,称为 n。对于插入,我们首先尝试 1 和 n 之间的 2 个(或 5 个,或任何恒定数量的)随机值。如果它们都被占用,请使用 n 并增加最大计数器值。否则,插入空白处。整洁! 我很好奇这个数据结构的预期用途。用例是什么? 不好解释。 500 字符版本:我正在为概率编程语言编写编译器(请参阅link),推理需要对可能的随机性选择进行随机游走(请参阅link)。程序的执行点有一个复杂的标记系统,其中发生随机性。这些标签是我字典中的键。要进行推理,需要插入、删除和“获取随机密钥”(以便使用 Metropolis-Hastings 中的提议密度)。 【参考方案1】:

这可能与上面列出的特定用例没有特别相关,但这是我在寻找一种很好地掌握字典中“任何”键的方法时遇到的问题。

如果您不需要真正随机的选择,而只需要一些任意键,那么我找到了两个简单的选项:

key = next(iter(d))    # may be a little expensive, but presumably O(1)

仅当您乐于使用字典中的键+值时,第二个才真正有用,并且由于突变不会在算法上有效:

key, value = d.popitem()     # may not be O(1) especially if next step
if MUST_LEAVE_VALUE:
    d[key] = value

【讨论】:

next(iter(d)) 适用于 Python3 和 Python2(而不是 d.iterkeys().next() @kd88 谢谢!我更新了我的答案以包含该提示。【参考方案2】:

[edit:完全重写,但保留问题,cmets 完好无损。]

以下是字典包装器的实现,其中 O(1) 获取/插入/删除,以及 O(1) 选择随机元素。

主要思想是我们希望有一个从 range(len(mapping)) 到键的 O(1) 但任意映射。这将让我们获得random.randrange(len(mapping)),并通过映射传递它。

这很难实现,直到您意识到我们可以利用 映射可以是任意的这一事实。 实现 O(1) 时间硬绑定的关键思想是:无论何时删除一个元素,都将其与任意 ID 最高的元素交换,并更新任何指针。

class RandomChoiceDict(object):
    def __init__(self):
        self.mapping =   # wraps a dictionary
                           # e.g. 'a':'Alice', 'b':'Bob', 'c':'Carrie'

        # the arbitrary mapping mentioned above
        self.idToKey =   # e.g. 0:'a', 1:'c' 2:'b', 
                           #      or 0:'b', 1:'a' 2:'c', etc.

        self.keyToId =   # needed to help delete elements

获取、设置和删除:

    def __getitem__(self, key):  # O(1)
        return self.mapping[key]

    def __setitem__(self, key, value):  # O(1)
        if key in self.mapping:
            self.mapping[key] = value
        else: # new item
            newId = len(self.mapping)

            self.mapping[key] = value

            # add it to the arbitrary bijection
            self.idToKey[newId] = key
            self.keyToId[key] = newId

    def __delitem__(self, key):  # O(1)
        del self.mapping[key]  # O(1) average case
                               # see http://wiki.python.org/moin/TimeComplexity

        emptyId = self.keyToId[key]
        largestId = len(self.mapping)  # about to be deleted
        largestIdKey = self.idToKey[largestId]  # going to store this in empty Id

        # swap deleted element with highest-id element in arbitrary map:
        self.idToKey[emptyId] = largestIdKey
        self.keyToId[largestIdKey] = emptyId

        del self.keyToId[key]
        del self.idToKey[largestId]

随机选择一个(键,元素):

    def randomItem(self):  # O(1)
        r = random.randrange(len(self.mapping))
        k = self.idToKey[r]
        return (k, self.mapping[k])

【讨论】:

保留 list 的密钥,而不是 set @BasicWolf:不,set.pop 不是随机的。 嗯,它不能同时是“快速、好和便宜” :) 但无论如何,我的错,谢谢你的解释。 @WuTheFWasThat:在 CPython 中将其列为列表的问题在于,它无法执行 O(1) 删除操作。通常,如果编程语言实现良好,如果您仅从列表的末尾或开头删除它应该能够,但 CPython 并没有声称能够在 O(1) 时间内这样做。虽然也许我的来源不够具体:wiki.python.org/moin/TimeComplexity 由于 CPython 可以在 O(1) 时间内 append 它可能会在 O(1) 时间内从最后删除,但来源没有说。 @ninjagecko 似乎上面的许多 cmets(以及最后几个)都是无用的,甚至会产生误导,因为您对代码进行了大幅编辑。那么我们应该删除它们吗?我猜我们做不到?【参考方案3】:

这是一个有点复杂的方法:

为每个键分配一个索引,将其与字典中的值一起存储。 保留一个表示下一个索引的整数(我们称之为 next_index)。 保留已删除索引(间隙)的链接列表。 保留将索引映射到键的字典。 添加键时,检查是否使用(并删除)链表中的第一个索引作为索引,或者如果列表为空,则使用并递增 next_index。然后将键、值和索引添加到字典 (dictionary[key] = (index, value)) 并将键添加到索引到键的字典 (indexdict[index] = key)。 移除键时,从字典中获取索引,从字典中移除键,从索引到键的字典中移除索引,并将索引插入到链表的最前面。 要获取随机密钥,请使用类似random.randrange(0, next_index) 的方式获取随机整数。如果索引不在 key-to-index 字典中,请重试(这种情况应该很少见)。

这是一个实现:

import random

class RandomDict(object):
    def __init__(self): # O(1)
        self.dictionary = 
        self.indexdict = 
        self.next_index = 0
        self.removed_indices = None
        self.len = 0

    def __len__(self): # might as well include this
        return self.len

    def __getitem__(self, key): # O(1)
        return self.dictionary[key][1]

    def __setitem__(self, key, value): # O(1)
        if key in self.dictionary: # O(1)
            self.dictionary[key][1] = value # O(1)
            return
        if self.removed_indices is None:
            index = self.next_index
            self.next_index += 1
        else:
            index = self.removed_indices[0]
            self.removed_indices = self.removed_indices[1]
        self.dictionary[key] = [index, value] # O(1)
        self.indexdict[index] = key # O(1)
        self.len += 1

    def __delitem__(self, key): # O(1)
        index = self.dictionary[key][0] # O(1)
        del self.dictionary[key] # O(1)
        del self.indexdict[index] # O(1)
        self.removed_indices = (index, self.removed_indices)
        self.len -= 1

    def random_key(self): # O(log(next_item/len))
        if self.len == 0: # which is usually close to O(1)
            raise KeyError
        while True:
            r = random.randrange(0, self.next_index)
            if r in self.indexdict:
                return self.indexdict[r]

【讨论】:

谢谢!是的,这会奏效。不知道为什么我没有想到。嗯……如果你做了很多删除它就不好了,所以 next_index 比项目数大得多。这实际上有时会在我的程序中发生。但是,我可以进行优化,这样这不是问题。 @WuTheFWasThat 是的,我想不出一个简单的方法来解决这个问题。至少在删除后添加内容时,它会重用它们的索引。 我不相信这会导致 O(1) random_key() 函数。例如,如果您插入 1000000 个元素并删除 1000000 个元素,则每次调用 random_key 都会导致 1/1000000 的成功机会,尽管映射中有少量元素。 @ninjagecko 你说得对,我们只是在讨论这个问题。 (虽然从技术上讲你错了,1000000 - 1000000 = 0,所以它会立即引发异常。你的意思可能是删除 999999 个元素。) @ninjagecko - 是的,我想马特和我都知道这个问题。这是一个不比哈希表调整大小更糟糕的解决方案,但不涉及重新实现字典:跟踪表中的事物数量。当它低于 next_index / 2 时,使用 new_next_index = next_index/2 重建整个索引系统,对表中的人使用索引 1、...、new_next_index【参考方案4】:

要获得 O(1) 空间,您需要一个数组数据结构和一个在数组中存储值及其索引的字典。

然后,在添加值时,您只需将它们推送到数组和字典中,并在数组中使用它的索引。

那么,由于您使用的是数组数据结构,因此您可以随机访问。

删除值时,您会查看要在字典中删除的值的索引。然后用数组中的最后一个值替换数组中的那个值(确保它不是最后一个元素)和 pop() 数组中的最后一个值。 之后,您使用已删除的值索引更新字典中替换值(数组中的最后一个值)的键。最后,您删除要删除的值的键和值,因为在字典中没有意义。

class RandomizedSet:

    def __init__(self):
        self.container = []
        self.indices = 
       
        
    def insert(self, val: int) -> bool:
        if val in self.indices:
            return False
        
        self.indices[val] = len(self.container)
        self.container.append(val)
        return True

    def remove(self, val: int) -> bool:
        if val not in self.indices:
            return False
        
        idxOfValueToRemove = self.indices[val]
        lastValue = self.container[-1]
        
        if idxOfValueToRemove < len(self.container)-1:
            self.container[idxOfValueToRemove] = lastValue
            self.indices[lastValue] = idxOfValueToRemove
    
        self.container.pop()
        
        del self.indices[val]
    
        return True
        
        
            

    def getRandom(self) -> int:
         return random.choice(list(self.container))

【讨论】:

【参考方案5】:

我遇到了同样的问题,写了

https://github.com/robtandy/randomdict

希望对你有帮助!它提供对随机键、值或项目的 O(1) 访问。

【讨论】:

不建议发布指向外部资源的链接,因为那样将来可能会中断。我建议你在这里提供一些解释并提供参考。 也就是说,我认为这实际上是代码方面的最佳解决方案。它很干净,似乎没有任何主要的算法缺陷。有一个需要注意的错误(很容易修复,但在未合并的 PR 中)。 正如@zplizzi 建议的那样,randomdict 是最佳解决方案。 与@ninjagecko 的otherwise useful solution that internally maintains three (!) dictionaries 相比,randomdict 仅在内部维护一个字典和 2 个列表-元组。是的,不幸的是,这个答案有效地简化为 HTTP 301 URL 重定向——但这就是开源代码野兽的本质。有时,最好的实现已经作为黑盒包存在。这是其中之一。 实际上... 也许不是。 randomdict 删除是 O(n),因为底层实现从内部 list 中删除。这是经典的空间与时间权衡。一般来说,我们更喜欢消耗空间来节省时间——这使得randomdict 对于一般情况来说不是最理想的。尽管如此,randomdict 空间消耗确实对于更大的字典大小显着更好地扩展。 &lt;/shrug&gt;

以上是关于Python 在 O(1) 中的字典中获取随机键的主要内容,如果未能解决你的问题,请参考以下文章

从 Python 中的字典中提取不相等的随机键

如何在不从参考节点获取所有数据的情况下获取 Firebase 数据库中的随机键?

键绑定中的修饰符(SHIFT +(随机键))

如何使用随机键从 Firebase 数据库中检索子项到 Android 的回收站视图中?

具有随机键名的 Graphql 对象

javascript 函数随机键来自对象