为啥 Dictionary.First() 这么慢?

Posted

技术标签:

【中文标题】为啥 Dictionary.First() 这么慢?【英文标题】:Why is Dictionary.First() so slow?为什么 Dictionary.First() 这么慢? 【发布时间】:2011-03-04 01:45:13 【问题描述】:

不是一个真正的问题,因为我已经找到了答案,但仍然很有趣。

我一直认为,如果你正确地散列,散列表是最快的关联容器。

但是,下面的代码非常慢。它只执行了大约 100 万次迭代,并且在 Core 2 CPU 上耗时超过 2 分钟。

代码执行以下操作:它维护需要处理的项目集合todo。在每次迭代中,它从该集合中获取一个项目(无论哪个项目),将其删除,如果未处理则处理它(可能添加更多要处理的项目),然后重复此操作直到没有要处理的项目。

罪魁祸首似乎是 Dictionary.Keys.First() 操作。

问题是为什么它很慢?

Stopwatch watch = new Stopwatch();
watch.Start();

HashSet<int> processed = new HashSet<int>();
Dictionary<int, int> todo = new Dictionary<int, int>();

todo.Add(1, 1);
int iterations = 0;

int limit = 500000;
while (todo.Count > 0)

    iterations++;
    var key = todo.Keys.First();
    var value = todo[key];
    todo.Remove(key);
    if (!processed.Contains(key))
    
        processed.Add(key);
        // process item here
        if (key < limit)  todo[key + 13] = value + 1; todo[key + 7] = value + 1; 
        // doesn't matter much how
    

Console.WriteLine("Iterations: 0; Time: 1.", iterations, watch.Elapsed);

这会导致:

Iterations: 923007; Time: 00:02:09.8414388.

只需将 Dictionary 更改为 SortedDictionary 即可:

Iterations: 499976; Time: 00:00:00.4451514.

速度提高 300 倍,而迭代次数仅减少 2 倍。

同样的情况也发生在 java 中。 使用HashMap 代替DictionarykeySet().iterator().next() 代替Keys.First()

【问题讨论】:

@polygenelubricants:它被标记为 java 和 .net,并且在他的最后一句话中,OP 说“Java 中也是如此” 真正的问题是,First 返回什么?由于字典使用哈希值,所以 First 是第一个??? First() 返回通过字典枚举时将返回的第一项。这个订单没有定义,所以你只是得到“一个项目”。 【参考方案1】:

字典不努力跟踪键列表。所以迭代器需要遍历桶。其中许多桶,特别是对于大型字典而言,其中很多都没有任何内容。

比较 OpenJDK 的 HashIterator.nextEntry 和 PrivateEntryIterator.nextEntry(使用 TreeMap.successor)可能会有所帮助。散列版本遍历未知数量的条目以寻找非空的条目。如果哈希表已删除许多元素(在您的情况下已删除),这可能会特别慢。在 TreeMap 中,我们唯一要做的就是按顺序遍历。途中没有空值(仅在叶子处)。

【讨论】:

不过,无论字典大小如何,每个返回项目的摊销时间都应该大致相同。 @Nick:不,不是。看我的回答。 对删除项目的边缘情况取模——这听起来像是 .net 实现的一个弱点——无论大小如何,已填充的桶的比例都应该相同。 @Nick,不仅仅是 .NET 的实现。 Java 也受苦。 C++ STL 没有。【参考方案2】:

不用看,排序字典的最简单实现是键和哈希组合的排序列表(如 TreeSet);列表为您提供排序,字典为您提供值。因此,密钥已经可用。 Hashtable 没有现成的密钥,因此罪魁祸首不是first,而是keys(都没有任何证据,请随意检验假设;D)

【讨论】:

.Net 的Dictionary&lt;TKey, TValue&gt; 使用哈希表。 可能。我说的是一般情况(可互换使用哈希表和字典)-它应该适用于任何范例。具体来说,在 .net 中,它们在类型强制方面对两者产生了影响,但对手头的问题没有任何影响——数据的结构是相同的。【参考方案3】:

嗯,哈希表没有排序,我猜它必须先进行某种排序,然后才能进行迭代或某种扫描,如果它已经排序,它可以循环遍历。

【讨论】:

虽然,我相信 Dictionary 是后端的一棵树。 .Net 的Dictionary&lt;TKey, TValue&gt; 使用哈希表。 另外,在树上移除可能有点昂贵。【参考方案4】:

Dictionary&lt;TKey, TValue&gt; 维护一个哈希表。

它的枚举器将遍历哈希表中的桶,直到找到一个非空桶,然后返回该桶中的值。 一旦字典变大,这个操作就会变得很昂贵。 此外,从字典中删除项目不会缩小存储桶数组,因此当您删除项目时,First() 调用会变慢。 (因为它必须进一步循环才能找到一个非空桶)

因此,重复调用First()并移除是O(n2)。


顺便说一句,您可以避免像这样的值查找:(这不会使其明显更快)

var kvp = todo.First();

//Use kvp.Key and kcp.Value

【讨论】:

是的,你的解释是正确和完整的。顺便说一句,Microsoft 文档说 GetEnumerator() 操作对于 Dictionary 来说是 O(1)。然而它并没有说明枚举器的 MoveNext() 性能。 ;)【参考方案5】:

Reflector 显示 Dictionary&lt;TKey, TValue&gt; 维护一个 Entry&lt;TKey, TValue&gt; 数组,KeyCollection&lt;TKey, TValue&gt;.Enumerator&lt;TKey, TValue&gt; 使用该数组。通常,查找应该相对较快,因为它可以索引到数组中(假设您不想要排序的First):

// Dictionary<TKey. TValue>
private Entry<TKey, TValue>[] entries;

但是,如果您要删除该数组的第一个元素,那么您最终会遍历该数组,直到找到一个非空的元素:

// Dictionary<TKey, TValue>.KeyCollection<TKey, TValue>.Enumerator<TKey, TValue>
while (this.index < this.dictionary.count) 
    if (this.dictionary.entries[this.index].hashCode >= 0) 
        this.currentKey = this.dictionary.entries[this.index].key;
        this.index++;
        return true;
    
    this.index++;

当您删除条目时,entries 数组的前面开始出现越来越多的空白,并且下次检索 First 变得更慢。

【讨论】:

以上是关于为啥 Dictionary.First() 这么慢?的主要内容,如果未能解决你的问题,请参考以下文章

为啥垃圾回收这么慢?

为啥这个 select 语句这么慢?

为啥这个查询运行这么慢?

为啥 QuerySet 迭代这么慢?

为啥videoview这么慢?

为啥 putImageData 这么慢?