在Python 3中以常数时间从字典中随机选择一个值?

8

我知道可以用几种方法从字典中选择随机值。

在Python 2中:

random.choice(d.keys())

在Python 3中:
random.choice(list(d.keys()))

然而,这两种方法都需要进行转换,即在随机选择之前将列表转换为线性时间O(n)。例如,我知道在Python 3中返回一个迭代器,并且我猜测在Python 3中,列表是从字典内部创建的。
是否可能以常数时间(即O(1))从字典中选择值?
编辑:根据迄今为止的评论,我认为这不可能,至少不是直接的方式。需要辅助结构。
编辑2:我认为字典可以在常数时间内进行随机选择,因为它在内部是哈希表,即在内部必须具有数组或类似物。当然,这取决于内部实现,但从理论上讲,我认为这是可能的。

你的使用场景是什么,标准解决方案(调用dict.keys()并从中选择一个随机值)对你来说不够好吗? - wkl
我正在构建一个计算机模拟,因此我需要多次调用dict.keys()。我非常确定在Python 3中它需要O(n)的时间,因为dict.keys()返回一个迭代器。我认为在Python 2中情况也不会更好,因为它可能会在内部转换字典。 - toto_tico
我认为针对您的使用情况,最好使用预定义的“前缀”和递增的“值”来分配键,例如“A1”,“A2”,“B1”,“B2”等。然后,要随机访问这些键,您只需要从正确的前缀中获取随机数,并从“值”的长度中以O(1)的时间复杂度获取一个随机值,这正是您想要的。 - Anzel
@Anzel,问题在于字典中不断地进行删除和添加,因此我无法保持一致的键。 - toto_tico
1
奇怪的是,这里有一个重复的https://dev59.com/_Ggv5IYBdhLWcg3wHNHG,但是它有完全不同的答案。 - strubbly
显示剩余10条评论
4个回答

1
很明显,我认为通过标准的dict公共API是不可能实现的。然而,有几个可替换dict的插件提供对某些排序后键的高效访问。这可以被索引以获取随机元素。虽然它们的理论渐近复杂度与dict不同,但在实践中,它们通常表现得更好。从Stutzbach Enterprises提供的blist包提供了专门经过测试以完全兼容字典的blist.sorteddict。它提供了对其键视图进行对数复杂度索引的功能。它是使用B+树实现的。Grant Jenks的SortedContainers包类似地提供了对其键视图进行高效索引的sortedcontainers.SortedDict。其他可用的插件通常基于搜索树。

我不明白为什么有人会对这个答案投反对票。这实际上可能是一种替代方案。文档中说,当字典的大小最近没有改变时,访问索引的时间复杂度为O(1),否则为O(log)。我的字典经常发生变化,但相对来说并不那么频繁。这可能有效。 - toto_tico
1
或许也不是,因为它在关键字删除方面具有O(log)的时间复杂度,如果考虑到维护树所需的常数时间,这可能并不是最好的想法。另外,我忘记了我正在使用networkx实现,所以我必须保持两个结构。无论如何,谢谢!我认为有人可能会觉得这个想法很有用。 - toto_tico
两个结构是一个问题,但我不会担心log(n)的移除。O(1)和O(log)之间的理论差异通常被其他考虑因素所淹没 - 这两个容器类的基准肯定与dict相当可比。 - strubbly
我同意对于非常大的结构,但问题在于这个结构并不是那么庞大(1000个节点),因此保持树的常数可能会真正消耗掉其优势。尽管如此,我可以尝试使用blist作为辅助结构,具有O(log n)的删除和添加操作(当大小变化不是最近发生时为O(1)),这可能是我的最佳选择。 - toto_tico
1
涉及的许多因素(例如处理器缓存、哈希冲突等)意味着唯一确定的方法是尝试它。也许还可以尝试sortedcontainer包:这里的基准测试使其看起来具有竞争力:http://www.grantjenks.com/docs/sortedcontainers/performance.html,特别是因为我怀疑固定开销更低(因为它基于列表)。 - strubbly

1
在这种情况下,我只能想象一种(轻微)优化方式:不要创建列表,只需获取一个随机数r并迭代d.keys()直到获取第r个项目。
def take_nth(sequence, n):
    i = iter(sequence)
    for _ in range(n):
        next(i)

    return next(i)

import random
rand_key = d[take_nth(d.keys(), random.randint(0, len(d)-1))]

这样做可以提高一点性能,因为您不必每次迭代整个列表,但这仍然是一个坏主意。
如果您想在一个固定的字典中重复执行随机选择,则只需将其键缓存到单独的列表中,并使用随机索引值进行索引。
更新:
总之,在评论中讨论的以下类别具有前向/后向缓存和重用删除的项目可能会有所帮助:
import random

class RandomSampleDict(object):

    def __init__(self):
        self.data     = {}
        self.cache_ik = {}
        self.cache_ki = {}
        self.track    = []

    def lookup(self, key):
        return self.data[key]

    def set(self, key, value):
        self.data[key] = value

    def add(self, key, value):
        self.data[key] = value
        if len(self.track) == 0:
            i = len(self.data) - 1
        else:
            i = self.track.pop()

        self.cache_ik[i] = key
        self.cache_ki[key] = i

    def delete(self, key):
        del self.data[key]
        i = self.cache_ik[i]
        del self.data_ik[i]
        del self.data_ki[key]

        self.track.append(i)

    def random_sample_key(self):
        key = None
        while key is None:
            i = random.randint(0, len(self.data))
            if i in self.cache_ik:
                return self.cache_ik[i]

1
忘记了一个闭括号,应该是 d[take_nth(d.keys(), random.randint(0, len(d)-1))]。 - user4322779
@firegurafiku,我猜这样平均来说会给我更好的性能。谢谢。我现在会尝试这个方法。我曾考虑过在旁边维护一个元组或列表,但显然删除元素是O(n)(https://wiki.python.org/moin/TimeComplexity),所以我可能会陷入更糟糕的境地。显然,在标准Python中没有链表(https://dev59.com/oW025IYBdhLWcg3wNC92)。 - toto_tico
1
@toto_tico:你不必从缓存列表中删除项目,只需在删除时将它们标记为None,如果需要,重复使用randint。如果您不必最终删除大多数项目,则此方法可行。 - firegurafiku
1
@toto_tico:我的意思是像下面这样(只是为了阐述一个想法而写的,没有经过测试):https://gist.github.com/firegurafiku/1a10e7335e2e81e46883 - firegurafiku
1
@toto_tico 另一种方法可能是在纯Python中自己实现哈希表。如果你的哈希足够大,由于消除复制,它们将优于内置字典。 - firegurafiku
显示剩余10条评论

1

在Python 3中,next(islice(d.values(),np.random.randint(0, len(d)-1),None))是我发现的从字典d中选择随机值的最佳方法。以下讨论对此进行了解释。

一些标准库的随机方法比可比较的numpy.random方法需要更长的运行时间。例如:

import numpy as np

timeit random.randint(0, 10)
100000 loops, best of 3: 2.52 µs per loop

timeit np.random.randint(0, 10)
1000000 loops, best of 3: 453 ns per loop

使用numpy.random.randint可以提高选择字典中随机值的方法的运行时间:
from itertools import islice
import random

d = {1:'a',2:'b',3:'c',4:'d',5:'e',6:'f',7:'g',8:'h',9:'i',10:'j'}

timeit next(islice(d.values(),random.randint(0, len(d)-1),None))
100000 loops, best of 3: 3.58 µs per loop

timeit next(islice(d.values(),np.random.randint(0, len(d)-1),None))
100000 loops, best of 3: 1.26 µs per loop

# d[5] access time is about 25X smaller than 1.26 µs
timeit d[5]
10000000 loops, best of 3: 51.3 ns per loop

def take_nth(sequence, n):
    i = iter(sequence)
    for _ in range(n):
        next(i)
    return next(i)

timeit d[take_nth(d.keys(), random.randint(0, len(d)-1))]
100000 loops, best of 3: 5.07 µs per loop

timeit d[take_nth(d.keys(), np.random.randint(0, len(d)-1))]
100000 loops, best of 3: 2.66 µs per loop

你是怎么运行的?random.sample似乎不接受字典:TypeError: Population must be a sequence or set. For dicts, use list(d). - toto_tico
它适用于Python 2,并且我已经在这方面给出了合格的答案。我使用Python 2,因为它广泛用于numpy和依赖于它的库,比如pandas,并且使得与现有脚本和教程的兼容性更容易。此外,我注意到在比较基准测试时,Python 2通常比Python 3运行得更快,有时甚至高达50%,例如参见http://stackoverflow.com/questions/32781288/looking-for-a-number-with-specific-conditions-in-python/32784496#32784496。 - user4322779
3
Python 3在数值堆栈中同样有效。无论如何,在2中的random.sample并没有执行任何神奇操作 - 在这种情况下它只是在内部调用list(population),因此与OP直接调用它没有任何区别。 - DSM
@DSM,这正是我所想的。我也在使用numpy、scipy来保持一些结构,并且我即将使用它们来以更手动(即基于咖啡)的方式解决这个问题。无论如何,谢谢。至少,我确定今天对我来说没有免费的方法。 - toto_tico
@DSM:由于某种原因,今天使用Anaconda Python 2.7.10 x64在Windows 7 x64上进行的timeit测试表明,random.sample(d,1)比random.sample(list(d),1)快了约14%。其中d = {1:'a',2:'b',3:'c',4:'d',5:'e',6:'f',7:'g',8:'h',9:'i',10:'j'}。 - user4322779
当我运行next(islice(d.values(),np.random.randint(0, len(d)-1),None)时,它给了我一个ValueError: high <= 0的错误。我不得不将其更改为next(islice(d.values(),np.random.randint(0, len(d)),None) - undefined

1
假设仅使用Python的dict无法完成此操作,需要第二个数据结构。这里提供了一种简单高效的辅助数据结构,它只跟踪当前节点。它只是将节点保存在列表中。为了支持删除,它只清空位置并保留另一个自由空间列表。请注意,如果您只随机删除节点,则可以正常使用。如果您想要删除通过其他方法选择的节点,则需要在节点中存储序列号,以便找到它们进行删除。除非随机抽样变慢时节点列表变得大部分为空,否则它能很好地工作。如果您需要处理该情况,那么您需要在那一点上重新分配列表-这是一种摊销成本,但会增加相当多的复杂性。例如,您需要添加从节点到序列号的字典,并在重新分配节点列表时更新它。
import random
RNG = random.Random()

class Tracker(object):

    def __init__(self):
        self.free = []
        self.nodes = []

    def add(self,node):
        if self.free:
            seq_num = self.free.pop()
            self.nodes[seq_num] = node
        else:
            seq_num = len(self.nodes)
            self.nodes.append(node)

    def random_node(self):
        seq_num = RNG.randint(0,len(self.nodes)-1)
        while self.nodes[seq_num] == None:
            seq_num = RNG.randint(0,len(self.nodes)-1)
        return self.nodes[seq_num],seq_num

    def delete(self,seq_num):
        self.nodes[seq_num] = None
        self.free.append(seq_num)

    def delete_random_node(self):
        node,seq_num = self.random_node()
        self.delete(seq_num)
        return node

这里可能有一些小的优化可用。通过将空闲列表替换为collections.deque,可能会使其更快,因为如果列表的大小经常改变,它们会变慢一些。但这并不是什么大问题。我认为你的节点列表将达到一个平衡点,然后变得非常高效,但你可以使用Nones填充它以避免反复增长的启动成本。你可以进行一些公共子表达式消除。但所有这些都只会有很小的影响。

这绝对值得一试。我的问题是我需要随机选择不止一个节点(例如使用random.sample而不是random.choice)。模拟设置为节点的2.5%,但它是分析的因素之一。模拟有许多细节,如果我试图解释所有这些细节,我会混淆问题。有关完整详细信息,请参阅此处的文章。 - toto_tico
我认为你可以调用“random_node”正确的次数并检查重复项。节点数为“len(self.nodes) - len(self.free)”。除非有太多的空闲插槽,否则这将很好地工作。 - strubbly

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接