Python字典中获取任意元素的最快方法是什么?

15

我有一个大约有17,000个键的字典。 我想逐个选择一个键 - 无论哪一个都可以,并且不需要按任何特定顺序(随机即可)。 但是,在选择一个键之后,我会更改该字典,例如添加或删除键,然后再选择另一个键。 因此,我没有一组可以迭代的键的列表。

由于我不需要按任何特定顺序访问它们,所以我每次都可以将字典键转换为列表,然后弹出第一个元素。 但是,由于有17,000个键,每次制作列表需要大约0.0005-7秒,这将花费我太多时间。 是否有捷径可供我使用,以便我不必每次想选择单个键时都编译一个庞大的键列表?


5
https://docs.python.org/3/library/stdtypes.html#dict.popitem - n1c9
8
你是否考虑过使用next(iter(dct)) - vaultah
2
这里有一段很好的代码,它可以以O(1)时间复杂度完成这个任务。由于您担心时间问题,您最好使用这个 - https://github.com/robtandy/randomdict - SRC
2
你能解释一下目的吗?我怀疑这是一个XY问题:你试图解决的根本问题是什么? - TemporalWolf
2
你在标题中要求随机项,但是你的问题正文说“不需要以任何特定顺序发生”。你能否编辑以澄清是否可以任意顺序,或者你是否需要随机顺序?此外,如果你选择了一个特定的键,然后没有立即删除它,那么再次选择该键是否可以(一遍又一遍)?或者你需要一些关于重新访问的键和从未被访问的键的保证? - user2357112
显示剩余7条评论
4个回答

6
有多种方法可供选择,但您需要做出一些权衡。其中一种方法是使用 popitem 清空字典;它是原子性的,并且会按任意顺序使用。但它修改了字典本身;任何被选中的项目都不再存在于其中。接下来想到的方法是像平常一样迭代,即使同时修改字典;项目的顺序可能会改变,因此您可能会多次获取项目。为了跟踪它,您可以构建第二个 set 可见键。将键添加到集合中相对便宜,检查每个项目是否在其中相对便宜,当您浏览整个字典时,可以检查集合是否与字典的键匹配,以确定是否有遗漏或删除的情况。您最终确实构建了一个关键集,但每次只迭代一个项目,在最坏的情况下,我们需要通过已访问项目的整个集扫描才能找到新项目。

是否有必要仅将此数据保留在字典中?例如,如果我们考虑一个随机播放歌曲的系统,我们可能不想访问整个歌曲库,而只是对最近播放的歌曲进行限制。这可以使用歌曲列表更有效地处理,在其中我们可以读取随机索引、避免重复的最近播放歌曲集合以及歌曲队列(可能在列表或deque中),使我们能够有序地更新集合(每次迭代删除最后一个条目)。请注意,引用相对便宜。

再思考一步,如果候选项根本不在我们的键中,则不需要检查重复项;通过将最早播放的歌曲与随机选择的下一首歌曲交换,播放和候选列表保持恒定大小,并且由于歌曲仅存在列表中的一个,因此不需要查找任何内容列表。

另一个想法是使用 collections.ChainMap 保持对两个字典的一致视图,已被访问的字典和未被访问的字典。然后,您可以通过 popitem 的方式将项目从后者迁移到前者,确保可读的方法处理集合中的所有内容,同时使其类似于字典。

def getnewitem(chainmap):
    # Raises KeyError when finished
    key,value=chainmap.maps[0].popitem()
    chainmap.maps[1][key]=value
    return key,value

由于这意味着两个字典都在不断变化,所以它可能不是最快的,但它保持了类似字典的集合和处理所有项的能力。它失去了直接删除项的能力,因为ChainMap不能隐藏继承的映射;您需要从支持字典中将它们删除。


如果该项需要保留在字典中,您可以在执行popitem()之后直接重新添加它。 - Irmen de Jong
1
脑海中浮现的下一个方法是像往常一样迭代,即使在修改字典时也是如此 - 这不起作用。 即使字典在中途没有被重建,您也可能会触发“迭代期间字典大小已更改”的安全检查。 - user2357112
你可以这样做,但问题在于“选择另一个”的要求有多严格。在进行其他修改后调用popitem可能会重复产生相同的项。如果我们在两个集合之间进行转移,它可能足够好用。 - Yann Vernier
@user2357112:你说得对。那意味着另一个导致迭代重新启动的原因,可能使方法变得病态。我们面临的两个核心问题是为什么数据特别保存在字典中,以及有什么顺序要求(例如是否最终应选择所有条目)。 - Yann Vernier

3

如SRC在评论中提到的,理想解决方案是使用索引字典,可以通过randomdict获取:

构建一个17,000个k,v字典并运行timeit:

t = timeit.Timer(my_dict.random_item)
print t.repeat()

[2.3373830318450928, 2.284735918045044, 2.2462329864501953]

这个结果大约是每个选择2.2微秒。

其他建议的答案要么不够快,要么不够随机,或者两者都不够。


2

谢谢您,vaultah!您提出了:

next(iter(dict)))

这需要大约0.00003秒的时间,减少了超过10倍的时间,因此速度非常快。
n1c9,您还提出了一个有趣的建议:
dict.popitem()

这是一个我之前不知道的函数,但不幸的是它只能节省 0.0002 秒,对于我的初始时间并没有太大改进。


1
值得注意的是,使用这些方法之一并不能保证随机性。 - TemporalWolf
4
你的时序对于popitem不公平…请记住,popitem还会删除该元素,这意味着popitem既进行了查找又进行了更新,而next(iter(dct))只进行了查找。但是如果你必须删除该元素,那么你也要支付删除操作的代价!如果大多数元素最终都被删除,则popitem总体上应该更快,但如果只有很少元素被删除,则popitem会做太多的修改。关键在于:不要仅仅分析单个操作,要分析整个循环! - Bakuriu
值得注意的是,我一直看到next(d.iterkeys())next(iter(dict))略微但显著地(约5%)提高。 对于d = {k:k * k for k in xrange(17000)},我使用%timeit next(d.iterkeys())(每个循环120-122 ns)和%timeit next(iter(d))(每个循环129 ns)。 这是Python 2.7.6与iPython 5.1.0的timeit接口。 - wchargin
@skrx:目前字典有序是一项实现细节,因此不应完全依赖它(参见https://dev59.com/6VkS5IYBdhLWcg3wXFg9)。有迹象表明,在以后的Python版本中这可能会变成官方功能(例如https://twitter.com/raymondh/status/850102884972675072,但请注意新的alpha 3.7发布说明并没有提到此事 https://docs.python.org/3.7/whatsnew/3.7.html)。 - Tom Church
1
@TomChurch 我并没有说你应该依赖于字典的顺序。我想说的是,这个答案中的解决方案不能在最新的Python版本中使用,因为字典是有序的。这种情况将来可能会改变,但这并不意味着在那种情况下应该使用这个解决方案,正是因为它是一个实现细节。 - skrx
显示剩余2条评论

0

由于dict()是根据内部哈希值进行快速访问而不是按照添加元素的顺序进行排序的,因此您可以认为它足够随机并使用dict.popitem()。

但是popitem()也会从字典中删除该元素。因此,您可能希望使用:

d = {...}
keys = d.keys()
item = keys.pop(0)
value = d[item]

然而,需要注意的是,具有相同/类似键的任何字典可能具有相同的键顺序。

如果要进行适当的随机获取,请执行以下操作:

import random
d = {"red": "#ff0000", "green": "#00ff00", "blue": "#0000ff", "black": "#000000", "white": "#ffffff"}
keys = d.keys()
item = random.choice(keys)
value = d[item]

当然,如果你想要防止重复,你就必须使用额外的 dict() 和 while 循环:
import random
d = {"red": "#ff0000", "green": "#00ff00", "blue": "#0000ff", "black": "#000000", "white": "#ffffff"}
keys = d.keys()
used = {}
def get_rand_item (d):
    item = random.choice(keys)
    while item in used:
        item = random.choice(keys)
    value = d[item]
    used[item] = None
    return item, value
get_rand_item(d)

在这里,我使用字典作为存储方式,因为它的搜索速度比列表更快。

你要求最快的方法。 :D

顺便说一下,让我们看看是否可以获得更快的随机项获取方式而不重复:



from random import choice

class RandomGetter:
    def __init__ (self, d, adapt=1):
        self.keys = keys = d.keys()
        if adapt:
            # Could be done in place too
            dct = {}
            for k in keys:
                dct[k] = (d[k], 0)
            self.dct = dct
            self.count = 1
        else:
            self.dct = d
            # Assume all items have been visited
            self.count = d[keys[0]][1]+1
        self.visited = 0
        self.length = len(self.dct)

    def __len__ (self):
        return self.length

    def randitem (self, default=None):
        if self.visited==self.length:
            # After 'default' is returned (all items gotten),
            # same RandomGetter() can be used again:
            self.count += 1
            self.visited = 0
            return default
        d  = self.dct
        kz = self.keys
        c  = self.count
        key = choice(kz)
        value, flag = d[key]
        while flag>=c:
            key = choice(kz)
            value, flag = d[key]
        d[key] = (value, flag+1)
        self.visited += 1
        return key, value

    def next (self):
        i = self.randitem()
        if i==None: raise StopIteration
        return i

    def __iter__ (self):
        while 1: yield self.next()

# Now testing:
# Lets create a dictionary of one million items:
d = dict.fromkeys(xrange(1000000))
# This takes about 0.128 seconds
# Now, lets initialize Rg
r = RandomGetter(d)
# If dict is not prepared in advance, as this one isn't we use adapt=1 and it takes
# about 8.92 seconds. Yack!
# Now measure time for each random getting:
from time import time
def check ():
    randitem = r.randitem # Faster access to the method
    e = []
    for _ in xrange(len(r)):
        t = time()
        randitem()
        e.append(time()-t)
    print "Total/min/max/med/avg/(0 time count)"
    e.sort()
    s = sum(e)
    if len(r)%2==0: m = (e[len(r)/2]+e[len(r)/2+1])/2
    else: m = e[len(r)/2+1]
    print s, e[0], e[-1], m, ("%.15f" % (s/1000000)), e.count(0.0)
check()
# It yields following results on my machine:
# About 25.224 seconds to randomly get all 1000000 items
# Minimal time needed is not measurable using this technique so it is 0.0
# Maximal time needed to get an item is about 1.678 seconds
# Median results with 0.0, thus we know that more than half randomly gotten items took practically no time
# In fact, there are about 998808 items with getting time of 0.0 seconds
# Average getting time is about 0.000025224 seconds
# By examining results closely I deduced that only last few items took a long time to get them.
# All in all, not bad for one million items, in my opinion anyway.
# For dict of 2000 items, total time was 0.016 and that was also the maximal value and it was for the last gotten item
# Time didn't cross one second until length of a given dictionary wasn't bigger than 100000
# If you want, you can run my code through timeit to recheck, but it seems that it is faster
# than suggested random dictionary.


请注意,.pop(0) 只适用于 Python2...... 在Python3中,keys返回的是一种类似于set的对象,而不是有序对象。 - Bakuriu
那么就使用item = keys[0]; del keys[0]。但在这种情况下,在使用pop()时,最好使用最后一个元素:keys[-1],这样会更快。列表并不是为了支持快速编辑,而是为了支持快速随机内存访问。 - Dalen
keys()方法构建了OP抱怨的大列表(在Python 3中为list(d.keys())),而且在该列表上执行list.pop(0)是O(n)。list.pop()将取出最后一个项目,这比其他项目更快,因为其他项目不需要移动。随着使用的集合增长,重复的随机选择以查找其中不存在的最后几项变得昂贵。 - Yann Vernier
@YannVernier:是的,但我们只创建一次键列表,然后就可以自由地使用其快速内存访问来获取随机键。如果我们想在真正大的字典上获得真正的随机性,并且希望它能够快速工作,那么这是我最好的选择。获取所有键,然后唯一耗时的操作是random.choice()和dict.get()。 - Dalen
是的,我刚想到这个问题就去查看文档了,但你比我更快。好吧,random.shuffle()会直接修改列表,复杂度为O(N)。然后我们将不得不使用list.pop()来获取最后一个元素。嗯,首先获取大量的键,然后洗牌,需要多长时间呢?总的来说,它后来应该会比重新检查一个项目是否被取走要快。我的解决方案中最快的事情是原始字典在每个项目中都包含一个标志,计算访问次数。 - Dalen
显示剩余4条评论

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