【问题标题】:.NET Dictionary, impressively fast but how does it work?.NET 字典,速度惊人,但它是如何工作的?
【发布时间】:2011-07-19 18:55:50
【问题描述】:

好吧,我承认我没有挖出反光板来看看这里发生了什么,但我希望有人能告诉我。

Microsoft 如何使添加和获取如此之快,我可以通过将项目粘贴到数组中来快速添加,我可以通过对数组进行排序和使用二进制搜索来快速获取。但是,如果我每次添加项目时都进行快速排序以加快获取数据的速度,那么添加速度会大大减慢,如果每次尝试获取数据时都必须对数据进行排序,那么添加项目的速度会大大降低。

有人知道字典的内部工作原理吗?它比数组消耗更多的内存,所以显然除了聪明的算法之外还有其他东西在幕后进行。

我正在努力理解魔法并从中学习!

【问题讨论】:

  • 非常简单:这取决于这样一个概念,即为了每次比较两个对象,您可以只比较它们的“指纹”,这会非常快。只有在发生碰撞时,您才真正需要比较对象,从而比较速度。

标签: .net memory dictionary performance


【解决方案1】:

它使用hash,就像几乎所有其他字典实现一样。

【讨论】:

    【解决方案2】:

    .Net 中的dictionary<T,T> 是一种称为哈希表的数据结构:

    关于哈希表和 .Net 字典:

    http://en.wikipedia.org/wiki/Hash_table

    http://msdn.microsoft.com/en-us/library/4yh14awz.aspx

    http://www.cs.auckland.ac.nz/~jmor159/PLDS210/hash_tables.html

    二分查找:

    http://en.wikipedia.org/wiki/Binary_search

    你是对的,它使用比数组更多的内存来检索数据。这是您为更快访问而付出的代价。 (在大多数情况下确实如此,当您开始考虑构建哈希表与数组的设置时间时,有时排序数组的设置时间和访问速度可能更快。但通常这是一个有效的假设。)

    【讨论】:

    【解决方案3】:

    基本原理是:

    1. 设置空数组。
    2. 获取哈希码。
    3. 重新散列哈希以适应数组的大小(例如,如果数组大小为 31 项,我们可以使用 hash % 31)并将其用作索引。

    然后,检索就是以相同的方式找到索引,获取键(如果存在),然后在该项目上调用Equals

    这里明显的问题是如果有两个项目属于同一个索引,该怎么办。一种方法是在数组中存储一个列表或类似的东西,而不是键值对本身,另一种方法是“重新探测”到不同的索引中。两种方法各有利弊,微软使用 reprobing 一个列表。

    超过一定大小时,重新探测的数量(或者如果您采用这种方法,则存储列表的大小)变得太大,并且接近 O(1) 的行为丢失,此时表的大小被调整为改善这一点。

    虽然很明显,一个非常糟糕的哈希算法会破坏这一点,您可以通过构建一个对象字典来向自己展示这一点,其中哈希码方法如下:

    public override int GetHashCode()
    {
      return 0;
    }
    

    这是有效的,但很可怕,并且会将您接近 O(1) 的行为变成 O(n)(即使 O(n) 变得很糟糕。

    还有很多其他的细节和优化,但以上是基本原理。

    编辑:

    顺便说一句,如果你有一个完美的散列(你知道所有可能的值,并且有一个散列方法可以在一个小范围内为每个这样的值提供一个唯一的散列),则可以避免更普遍的重新探测问题 -目的哈希表,并将哈希视为数组的索引。这给出了 O(1) 的行为和最小的内存使用,但仅适用于所有可能的值都在一个小范围内。

    【讨论】:

    • 我很确定Dictionary<K,V> 使用链接(使用某种链接列表)而不是探测来处理冲突。
    • @LukeH,是的,看看我发现你是正确的。很高兴我解释了这两种方法:)
    • .net 4 中有两个数组,一个用于存储桶,另一个用于条目,每个条目是一个伪链表,因为它可以包含同一存储桶中下一个条目的索引。这是同一条目数组的索引。所以我觉得它是探测和链表的混合体。
    【解决方案4】:

    这个问题让我很好奇,所以我写了一个超快、优化版本字典查找,它的速度快了 5 倍(5 倍) 比默认的 .NET 字典实现

    为了简洁起见,我省略了错误检查,但是,添加这将是微不足道的。我也没有将其模板化,以使其更易于理解。

    它创建了许多嵌套数组,因此查找是在内存中链接对象引用的问题。它直接导航到内存中的正确对象,而不使用任何描述的循环或哈希表。它的内存效率相当高,因为它只为它需要的东西分配内存。与哈希表不同,无意的存储桶冲突永远不会有任何问题(当然,除非密钥相同)。如果您想自己运行比较,我可以提供完整的测试项目。

    /// <summary>
    /// Ultra fast dictionary, optimized for retrieval of keys consisting of 3-letter uppercase strings, where each string is 'A' to 'Z'.
    /// This is 5 times faster than the default Dictionary<> implementation, but not as flexible.
    /// ----start output from tester---
    /// Ultra Fast Dictionary.
    ///   Total time for 2,000,000,000 key retrievals: 19,892 milliseconds. 0.00994600 nanoseconds per retrieval. Sum -1958822656.
    /// Normal Dictionary.
    ///   Total time for 2,000,000,000 key retrievals: 98,397 milliseconds. 0.04919850 nanoseconds per retrieval. Sum -1958822656.
    /// ----end output from tester---
    /// </summary>
    public class DictionaryUltraFast
    {
        string[][][] dictionary;
    
        /// <summary>
        /// Add a string to the dictionary.
        /// </summary>
        public void Add(string key, string value)
        {
            key = key.ToUpper();
            if (dictionary == null)
            {
                dictionary = new string['Z' - 'A' + 1][][];
            }
            if (dictionary[key[0] - 'A'] == null)
            {
                dictionary[key[0] - 'A'] = new string['Z' - 'A' + 1][];
            }
            if (dictionary[key[0] - 'A'][key[1] - 'A'] == null)
            {
                dictionary[key[0] - 'A'][key[1] - 'A'] = new string['Z' - 'A' + 1];
            }
            dictionary[key[0] - 'A'][key[1] - 'A'][key[2] - 'A'] = value;
        }
    
        public string Get(string key)
        {
            return dictionary[key[0] - 'A'][key[1] - 'A'][key[2] - 'A'];
        }
    }
    

    【讨论】:

    • 这是一个专门的数据结构。考虑到数组的分配方式,它可能会使用比通常的哈希表更多的内存。由于它是如此专业,我认为我们无法将其与通用词典进行比较。桶排序通常是散列的一个很好的替代方法(您在这里使用桶排序)。
    • @Gravitas 错误的线程发布了一个很好的答案,仍然 +1。你能告诉我数组数组在这里做什么吗?另外,我该如何实现 Clear 方法?你在某个地方有完整的来源吗?您可以将其设为通用,但我想知道如果字符串键的长度小于 3,您的方法是否有用
    • 我还想知道,一旦添加了错误检查,5X 的改进会损失多少。这种特殊情况需要您跳过一些额外的检查(例如,"A" 的键会导致崩溃)。
    【解决方案5】:

    不久前,我在我母亲的坟墓上发誓要为这个问题带来详细的答案,我花了很长时间,因为我的一些细节和概念有点生疏,但是,没有任何进一步的废话不多说:

    .NET 字典在长度上的工作原理,或...

    首先,让我们从概念开始,就像许多其他答案已经指出的那样,Dictionary&lt;TKey, TValue&gt;hash table 的通用(在 C# 语言功能的意义上)实现。

    哈希表只是一个关联数组,也就是说,当您传递一对(键,值)时,该键用于计算哈希码,该哈希码本身将有助于计算内存插槽的位置(称为存储桶)在底层存储阵列(称为...存储桶)中,您刚刚传递的对和一些其他附加信息将被保存在其中。这通常通过对数组/桶的大小计算哈希码的模% 来实现:hashCode % buckets.Length

    这种关联数组的搜索、插入和删除的平均复杂度为 O(1)(即恒定时间)……除非在某些情况下,我们稍后会深入研究。所以一般来说,在字典中查找内容比在列表或数组中查找要快得多,因为您不必~通常~遍历所有值。

    如果您到现在为止注意我所说的话,您会注意到可能已经存在问题。如果基于我们的密钥计算的哈希码与另一个完全相同怎么办?或者更糟糕的是一堆其他的钥匙?这基本上意味着我们可以最终在同一个位置?我们如何管理这些冲突?很显然,非常聪明的人早在几十年前就已经考虑过这个特殊问题,并提出了两种解决碰撞的主要方法:

    • Separate Chaining:基本上,这对存储在与存储桶不同的存储中(通常称为条目),例如,对于每个存储桶(计算每个索引),我们可以有一个条目列表,其中存储已存储在相同的“索引”(由于相同的哈希码),基本上在发生冲突的情况下,您必须遍历键(并找到另一种方法,而不是哈希码来区分它们)
    • Open Addressing: 一切都存储在桶中,基于我们接下来检查的第一个桶,在探测值Linear ProbingQuadratic Probing、双散列等的方式上也存在不同的方案。)

    任一冲突解决规则的实施有时可能会有很大差异。在 .NET 字典的情况下,数据结构依赖于冲突解决的独立链接类型,就像我们将在几分钟后看到的那样。

    好的,现在让我们看看如何在 .NET Dictionary&lt;TKey, TValue&gt; 中插入东西,归结为通过以下方法的代码:

    private void Insert(TKey key, TValue value, bool add)

    注意:阅读下面的插入步骤后,您可以通过检查在我的答案底部作为链接给出的代码来找出删除和查找操作背后的基本原理。

    第 1 步:给我哈希码

    TKey 键的哈希码有两种计算方式:

    • 如果您不将任何作为Dictionary&lt;TKey, TValue&gt; 的参数传递,则依赖默认的IEqualityComparer&lt;TKey&gt; 实现比较器,这基本上是由EqualityComparer&lt;TKey&gt;.Default 生成的(实现可用here),以防TKey 是一种类型与自定义类型等所有常见的东西(如原语和字符串)不同,IEqualityComparer&lt;in TKey&gt; 将利用以下实现(包括overrides):

      • bool Equals(object obj)
      • int GetHashCode()
    • 另一个依赖于 IEqualityComparer&lt;in TKey&gt; 的实现,您可以将其传递给 Dictionary&lt;TKey, TValue&gt; 构造函数。

    界面IEqualityComparer&lt;in T&gt;是这样的:

    // The generic IEqualityComparer interface implements methods to if check two objects are equal
    // and generate Hashcode for an object.
    // It is use in Dictionary class.  
    public interface IEqualityComparer<in T>
    {
        bool Equals(T x, T y);
        int GetHashCode(T obj);
    }
    

    无论哪种方式,字典最终都会使用比较器获得第一个哈希码:comparer.GetHashCode()

    第 2 步:获取目标存储桶

    我们从TKey 键通过IEqualityComparer&lt;in T&gt; 获得的哈希码有时可能是负数,如果我们想获得数组的正数索引,这并没有真正的帮助...

    为了消除负值,comparer.GetHashCode() 得到的Int32 哈希码与Int32.MaxValue(即21474836470x7FFFFFFF)“与”(在某种意义上布尔逻辑:位):

    var hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
    

    目标bucket(索引)获取方式如下:

    var targetBucket = hashCode % buckets.Length
    

    稍后还将看到如何调整 buckets 数组的大小。

    buckets (int[]) 是Dictionary&lt;TKey, TValue&gt;private 字段,包含entries 字段中第一个相关槽的索引,即Entry[],其中Entry 被定义如下:

    private struct Entry
    {
        public int hashCode;
        public int next;
        public TKey key;
        public TValue value;
    }
    

    keyvaluehashcode 是不言自明的字段,关于next 字段,它基本上表示该链中是否有另一个项目(即具有相同哈希码的多个键) ),如果该条目是链的最后一项,则 next 字段设置为 -1

    注意:Entrystruct中的hashCode字段为负值调整后的字段。

    第 3 步:检查是否已有条目

    在那个阶段,重要的是要注意,行为会有所不同,具体取决于您是更新 (add = false) 还是严格插入 (add = true) 新值。

    我们现在将检查与targetBucket 相关的条目,从第一个条目开始,该条目可由以下方式给出:

    var entryIndex = buckets[targetBucket];
    var firstEntry = entries[entryIndex];
    

    带有 cmets 的实际(简化)源代码:

    // Iterating through all the entries related to the targetBucket
    for (var i = buckets[targetBucket]; i >= 0; i = entries[i].next)
    {
        // Checked if all 
        if (entries[i].hashCode == hashCode && 
            comparer.Equals(entries[i].key, key)) 
        {
            // If update is not allowed
            if (add) 
            { 
                // Argument Exception:  
                // "Item with Same Key has already been added" thrown =]
                ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
            }
    
            // We update the entry value
            entries[i].value = value;
    
            // Modification while iterating check field
            version++;
    
            return;
        } 
    }
    

    注意:version 字段也用于其他常见的 .NET 数据结构(例如 List&lt;T&gt;),有助于在迭代时检测(在 MoveNext() 上)(并引发相关异常)。

    第 4 步:检查数组是否需要调整大小

    // The entries location in which the data will be inserted
    var index = 0;
    
    // The freeCount field indicates the number of holes / empty slotes available for insertions.
    // Those available slots are the results of prior removal operations
    if (freeCount > 0) 
    {
        // The freeList field points to the first hole (ie. available slot) in the entries
        index = freeList;
        freeList = entries[index].next;
        // The hole is no longer available
        freeCount--;
    }
    else 
    {
        // The entries array is full 
        // Need to resize it to make it bigger
        if (count == entries.Length)
        {
            Resize();
            targetBucket = hashCode % buckets.Length;
        }
        index = count;
        count++;
    }
    

    注意:关于Resize()的电话:

    其实在Resize()方法的早期,新的大小是这样计算的:

    public static int ExpandPrime(int oldSize)
    {
        var min = 2 * oldSize;
    
        if ((uint) min > 2146435069U && 2146435069 > oldSize)
        {
            return 2146435069;
        }
    
        return HashHelpers.GetPrime(min);
    }
    

    第 5 步:添加条目

    由于字典检查完孔和大小,它可以最后使用计算的hashCodekeyvalue和正确的index添加条目并调整目标桶相应地:

    entries[index].hashCode = hashCode;
    
    // If the bucket already contained an item, it will be the next in the collision resolution chain.
    entries[index].next = buckets[targetBucket];
    entries[index].key = key;
    entries[index].value = value;
    // The bucket will point to this entry from now on.
    buckets[targetBucket] = index;
    
    // Again, modification while iterating check field
    version++;
    

    奖励:字符串特殊处理

    引自下面列出的 CodeProject 源代码:

    为了确保每个“获取”和“添加”操作不会超过每个存储桶的 100 项以上,正在使用碰撞计数器。

    如果在遍历数组以查找或添加项目时,碰撞计数器超过 100(限制是硬编码的)并且 IEqualityComparer 的类型为 EqualityComparer&lt;string&gt;.Default,则正在生成一个新的 IEqualityComparer&lt;string&gt; 实例以供替代字符串哈希算法。

    如果找到这样的提供者,字典将分配新数组并使用新的哈希码和相等提供者将内容复制到新数组中。

    这种优化可能对您的字符串键未均匀分布的情况很有用,但也可能导致大量分配和浪费 CPU 时间来生成字典中可能有很多项目的新哈希码.

    用法

    每当您使用自定义类型作为键时,请不要忘记实现IEqualityComparer 接口或重写两个 Object 方法(hashcode + equal),以免在插入时误伤自己。

    您不仅可以避免一些糟糕和令人讨厌的意外,还可以控制您插入的项目的分布。通过均匀分布的哈希码,您可以避免链接过多的项目,从而避免浪费时间迭代相关条目。

    受访者/ers的旁注

    我想强调一个事实,知道面试的那些实现细节通常不是什么大问题(实际实现与 .NET 的某些版本(“常规”或核心......)不同,而且可能仍然是可能会在稍后的时间点发生变化))。

    如果有人问我这个问题,我会说:

    • 您正在寻找的答案在 StackOverflow 上 :)
    • 您正在寻找的答案也在
    • 您正在寻找的答案不需要实现细节,官方文档here 足以满足大多数用例。

    除非,除非...您应该在日常工作中自己实现哈希表斜线字典,在这种情况下,这种知识(即实现细节)可能会派上用场或甚至是强制性的。

    来源:

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2010-12-26
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多