为啥 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
代替Dictionary
和keySet().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<TKey, TValue>
使用哈希表。
可能。我说的是一般情况(可互换使用哈希表和字典)-它应该适用于任何范例。具体来说,在 .net 中,它们在类型强制方面对两者产生了影响,但对手头的问题没有任何影响——数据的结构是相同的。【参考方案3】:
嗯,哈希表没有排序,我猜它必须先进行某种排序,然后才能进行迭代或某种扫描,如果它已经排序,它可以循环遍历。
【讨论】:
虽然,我相信 Dictionary 是后端的一棵树。 .Net 的Dictionary<TKey, TValue>
使用哈希表。
另外,在树上移除可能有点昂贵。【参考方案4】:
Dictionary<TKey, TValue>
维护一个哈希表。
它的枚举器将遍历哈希表中的桶,直到找到一个非空桶,然后返回该桶中的值。
一旦字典变大,这个操作就会变得很昂贵。
此外,从字典中删除项目不会缩小存储桶数组,因此当您删除项目时,First()
调用会变慢。 (因为它必须进一步循环才能找到一个非空桶)
因此,重复调用First()
并移除是O(n2)。
顺便说一句,您可以避免像这样的值查找:(这不会使其明显更快)
var kvp = todo.First();
//Use kvp.Key and kcp.Value
【讨论】:
是的,你的解释是正确和完整的。顺便说一句,Microsoft 文档说 GetEnumerator() 操作对于 Dictionary 来说是 O(1)。然而它并没有说明枚举器的 MoveNext() 性能。 ;)【参考方案5】:Reflector 显示 Dictionary<TKey, TValue>
维护一个 Entry<TKey, TValue>
数组,KeyCollection<TKey, TValue>.Enumerator<TKey, TValue>
使用该数组。通常,查找应该相对较快,因为它可以索引到数组中(假设您不想要排序的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() 这么慢?的主要内容,如果未能解决你的问题,请参考以下文章