.NET中的泛型集合
近对集合相关的命名空间比较感兴趣,以前也就用下List, Dictionary<Tkey, TValue>之类,总之,比较小白。点开N多博客,MSDN,StackOverflow,没找到令我完全满意的答案,本打算自己总结下写出来,工作量好大的感觉……直到昨晚随意翻到看了一些又放下的《深入理解C#》-附录B部分,高兴地简直要叫出来——“这总结真是太绝了,好书不愧是好书”。真是“踏破铁鞋无觅处,得来全不费工夫”,最好的资源就在眼下,而自己居然浑然不知。或许只有深入技术细节的时候,才能认识到经典为什么经典吧!言归正传,本博客主要是对《深入理解C#》-附录B的摘录,并加了些标注。
所有的集合都是继承自IEnumerable。集合总体可以分为以下几类:
关联/非关联型集合,顺序/随机访问集合,顺序/无序集合,泛型/非泛型集合,线程集合。
各集合类底层接口关系图
泛型与非泛型集合类的分析
- 泛型集合是类型安全的,基于固定的泛型T,运行时不需要像非泛型的执行Object和具体类型的类型转换。
- 泛型集合的效率相对较高。
- 两者都能实现数据存储,不同的是泛型只能存放T类型数据,有运行时检测,而非泛型的都转化为Object存储,能存储任意类型,包括值类型,会带来装箱拆箱的性能损耗,同时都是Object类型(弱类型)编译时无法类型检测,运行时会导致类型不一致的安全性问题。
具体接口/类分析
- CollectionBase/DictionaryBase的目的
都是抽象类,不能实例化;
目的是提供给用户自定义实现强类型的集合,解决一般非泛型集合的弱类型不安全的问题。
- IEnumerator/IEnumerable
IEnumerator定义了我们遍历集合的基本方法,以便我们可以实现单向向前的访问集合中的每一个元素。 所有的集合类都继承了IEnumerator接口,包括String类。而IEnumerable只有一个方法GetEnumerator即得到遍历器。
- ICollection和ICollection
从最上面第一张图我们可以知道,ICollection是直接继承自IEnumerable。而实际上也是如此,我们可以说ICollection比IEnumerable多支持一些功能,不仅仅只提供基本的遍历功能,还包括:
- 统计集合和元素个数
- 获取元素的下标
- 判断是否存在
- 添加元素到未尾
- 移除元素等等。。。
ICollection 与ICollection 略有不同,ICollection不提供编辑集合的功能,即Add和Remove。包括检查元素是否存在Contains也不支持。
- IList和IList
IList则是直接继承自ICollection和IEnumerable。所以它包括两者的功能,并且支持根据下标访问和添加元素。IndexOf, Insert, RemoveAt等等。我们可以这样说,IEnumerable支持的功能最少,只有遍历。而ICollection支持的功能稍微多一点,不仅有遍历还有维护这个集合的功能。而IList是最全的版本。
- IReadOnlyList
这个是在Framework4.5中新增的接口类型,可以被看作是IList的缩减版,去掉了所有可能更改这个集合的功能。比如:Add, RemoveAt等等。
**
- IDictionary<TKey,TValue>
**
IDictionary提供了对键值对集合的访问,也是继承了ICollection和IEnumerable,扩展了通过Key来访问和操作数据的方法。
关联性泛型集合类
关联性集合类即我们常说的键值对集合,允许我们通过Key来访问和维护集合。我们先来看一下 FCL为我们提供了哪些泛型的关联性集合类:
- Dictionary <TKey,TValue>
- SortedDictionary<TKey,TValue>
- SortedList<TKey,TValue>
- Dictionary<TKey,TValue>
是我们最常用的关联性集合了,它的访问,添加,删除数据所花费的时间是所有集合类里面最快的,因为它内部用了Hashtable作为存储结构,所以不管存储了多少键值对,查询/添加/删除所花费的时间都是一样的,它的时间复杂度是O(1)。
Dictionary<TKey,TValue>
优势是查找插入速度快==,那么什么是它的劣势呢?因为采用Hashtable1作为存储结构,就意味着里面的数据是无序排列的,所以想按一定的顺序去遍历Dictionary<TKey,TValue>里面的数据是要费一点工夫的。
作为TKey的类型必须实现GetHashCode()和Equals() 或者提供一个IEqualityComparer,否则操作可能会出现问题。
SortedDictioanry<TKey,TValue>
SortedDictionary<TKey,TValue>和Dictionary<TKey,TValue>大致上是类似的,但是在实现方式上有一点点区别。SortedDictionary<TKey,TValue>用二叉树作为存储结构的。并且按key的顺序排列。那么这样的话SortedDictionary<TKey,TValue>的TKey就必须要实现IComparable。如果想要快速查询的同时又能很好的支持排序的话,那就使用SortedDictionary吧。
SortedList<TKey,TValue>
SortedList<TKey,TValue>是另一个支持排序的关联性集合。但是不同的地方在于,SortedList实际是将数据存存储在数组中的。也就是说添加和移除操作都是线性的,时间复杂度是O(n),因为操作其中的元素可能导致所有的数据移动。但是因为在查找的时候利用了二分搜索,所以查找的性能会好一些,时间复杂度是O(log n)。所以推荐使用场景是这样地:如果你想要快速查找,又想集合按照key的顺序排列,最后这个集合的操作(添加和移除)比较少的话,就是SortedList了。
非关联性泛型集合类
非关联性集合就是不用key操作的一些集合类,通常我们可以用元素本身或者下标来操作。FCL主要为我们提供了以下几种非关联性的泛型集合类。
List
LinkedList
HashSet
SortedSet
Stack
Queue
List
泛型的List 类提供了不限制长度的集合类型,List在内部维护了一定长度的数组(默认初始长度是4),当我们插入元素的长度超过4或者初始长度 的时候,会去重新创建一个新的数组,这个新数组的长度是初始长度的2倍(不永远是2倍,当发现不断的要扩充的时候,倍数会变大),然后把原来的数组拷贝过来。所以如果知道我们将要用这个集合装多少个元素的话,可以在创建的时候指定初始值,这样就避免了重复的创建新数组和拷贝值。
另外的话由于内部实质是一个数组,所以在List的未必添加数据是比较快的,但是如果在数据的头或者中间添加删除数据相对来说更低效一些因为会影响其它数据的重新排列。
LinkedList
LinkedList在内部维护了一个双向的链表,也就是说我们在LinkedList的任何位置添加或者删除数据其性能都是很快的。因为它不会导致其它元素的移动。一般情况下List已经够我们使用了,但是如果对这个集合在中间的添加删除操作非常频繁的话,就建议使用LinkedList。
HashSet
HashSet是一个无序的能够保持唯一性的集合。我们也可以把HashSet看作是Dictionary<TKey,TValue>,只不过TKey和TValue都指向同一个对象。HashSet非常适合在我们需要保持集合内元素唯一性但又不需要按顺序排列的时候。
HashSet不支持下标访问。
SortedSet
SortedSet和HashSet,就像SortedDictionary和Dictionary一样,还记得这两个的区别么?SortedSet内部也是一个二叉树,用来支持按顺序的排列元素。
Stack
后进先出的队列
不支持按下标访问
Queue
先进先出的队列
不支持按下标访问
————————————————
————————————————
附录B .NET中的泛型集合
.NET中包含很多泛型集合,并且随着时间的推移列表还在增长。本附录涵盖了最重要的泛型集合接口和类,但不会涉及System.Collections、System.Collections.Specialized和System.ComponentModel中的非泛型集合。同样,也不会涉及ILookup<TKey,TValue>这样的LINQ接口。本附录是参考而非指南——在写代码时,可以用它来替代MSDN。在大多数情况下,MSDN显然会提供更详细的内容,但这里的目的是在选择代码中要用的特定集合时,可以快速浏览不同的接口和可用的实现。
我没有指出各集合是否为线程安全,MSDN中有更详细的信息。普通的集合都不支持多重并发写操作;有些支持单线程写和并发读操作。B.6节列出了.NET 4中添加的并发集合。此外,B.7节介绍了.NET4.5中引入的只读集合接口。
B.1 接口
几乎所有要学习的接口都位于System.Collections.Generic命名空间。图B-1展示了.NET4.5以前主要接口间的关系,此外还将非泛型的IEnumerable作为根接口包括了进来。为避免图表过于复杂,此处没有包含.NET 4.5的只读接口。
正如我们已经多次看到的,最基础的泛型集合接口为IEnumerable,表示可迭代的项的序列。IEnumerable可以请求一个IEnumerator类型的迭代器。由于分离了可迭代序列和迭代器,这样多个迭代器可以同时独立地操作同一个序列。如果从数据库角度来考虑,表就是IEnumerable,而游标是IEnumerator。本附录仅有的两个可变(variant)集合接口为.NET 4中的IEnumerable和IEnumerator;其他所有接口的元素类型值均可双向进出,因此必须保持不变。
接下来是ICollection,它扩展了IEnumerable,添加了两个属性(Count和IsReadOnly)、变动方法(Add、Remove和Clear)、CopyTo(将内容复制到数组中)和Contains(判断集合是否包含特殊的元素)。所有标准的泛型集合实现都实现了该接口。
- IList全都是关于定位的:
它提供了一个索引器、InsertAt和RemoveAt(分别与Add和Remove相同,但可以指定位置),以及IndexOf(判断集合中某元素的位置)。对IList进行迭代时,返回项的索引通常为0、1,以此类推。文档里没有完整的记录,但这是个合理的假设。同样,通常认为可以快速通过索引对IList进行随机访问。 - IDictionary<TKey, TValue>表示一个独一无二的键到它所对应的值的映射。
值不必是唯一的,而且也可以为空;而键不能为空。可以将字典看成是键/值对的集合,因此IDictionary<TKey, TValue>扩展了ICollection<KeyValuePair<TKey, TValue>>。获取值可以通过索引器或TryGetValue方法;与非泛型IDictionary类型不同,如果试图用不存在的键获取值,IDictionary<TKey, TValue>的索引器将抛出一个KeyNotFoundException。TryGetValue的目的就是保证在用不存在的键进行探测时还能正常运行。 - ISet是.NET 4新引入的接口,表示唯一值集。
它反过来应用到了.NET 3.5中的HashSet上,以及.NET 4引入的一个新的实现——SortedSet。
在实现功能时,使用哪个接口(甚至实现)是十分明显的。难的是如何将集合作为API的一部分公开;返回的类型越具体,调用者就越依赖于你指定类型的附加功能。这可以使调用者更轻松,但代价是降低了实现的灵活性。我通常倾向于将接口作为方法和属性的返回类型,而不是保证一个特定的实现类。在API中公开易变集合之前,你也应该深思熟虑,特别是当集合代表的是对象或类型的状态时。通常来说,返回集合的副本或只读的包装器是比较适宜的,除非方法的全部目的就是通过返回集合做出变动。
B.2 列表
从很多方面来说,列表是最简单也最自然的集合类型。框架中包含很多实现,具有各种功能和性能特征。一些常用的实现在哪里都可以使用,而一些较有难度的实现则有其专门的使用场景。
B.2.1 List
在大多数情况下,List都是列表的默认选择。它实现了IList,因此也实现了ICollection、IEnumerable和IEnumerable。此外,它还实现了非泛型的ICollection和IList接口,并在必要时进行装箱和拆箱,以及进行执行时类型检查,以保证新元素始终与T兼容。
List在内部保存了一个数组,它跟踪列表的逻辑大小和后台数组的大小。向列表中添加元素,在简单情况下是设置数组的下一个值,或(如果数组已经满了)将现有内容复制到新的更大的数组中,然后再设置值。这意味着该操作的复杂度为O(1)或O(n),取决于是否需要复制值。扩展策略没有在文档中指出,因此也不能保证——但在实践中,该方法通常可以扩充为所需大小的两倍。这使得向列表末尾附加项为O(1)平摊复杂度(amortized complexity);有时耗时更多,但这种情况会随着列表的增加而越来越少。
你可以通过获取和设置Capacity属性来显式管理后台数组的大小。TrimExcess方法可以使容量等于当前的大小。实战中很少有必要这么做,但如果在创建时已经知道列表的实际大小,则可将初始的容量传递给构造函数,从而避免不必要的复制。
从List中移除元素需要复制所有的后续元素,因此其复杂度为O(n – k),其中k为移除元素的索引。从列表尾部移除要比从头部移除廉价得多。另一方面,如果要通过值移除元素而不是索引(通过Remove而不是RemoveAt),那么不管元素位置如何复杂度都为O(n):每个元素都将得到平等的检查或打乱。
List中的各种方法在一定程度上扮演着LINQ前身的角色。ConvertAll可进行列表投影;FindAll对原始列表进行过滤,生成只包含匹配指定谓词的值的新列表。Sort使用类型默认的或作为参数指定的相等比较器进行排序。但Sort与LINQ中的OrderBy有个显著的不同:Sort修改原始列表的内容,而不是生成一个排好序的副本。并且,Sort是不稳定的,而OrderBy是稳定的;使用Sort时,原始列表中相等元素的顺序可能会不同。LINQ不支持对List进行二进制搜索:如果列表已经按值正确排序了,BinarySearch方法将比线性的IndexOf搜索效率更高( 二进制搜索的复杂度为O(log n),线性搜索为O(n))。
List中略有争议的部分是ForEach方法。顾名思义,它遍历一个列表,并对每个值都执行某个委托(指定为方法的参数)。很多开发者要求将其作为IEnumerable的扩展方法,但却一直没能如愿;Eric Lippert在其博客中讲述了这样做会导致哲学麻烦的原因(参见http://mng.bz/Rur2)。在我看来使用Lambda表达式调用ForEach有些矫枉过正。另一方面,如果你已经拥有一个要为列表中每个元素都执行一遍的委托,那还不如使用ForEach,因为它已经存在了。
B.2.2 数组
在某种程度上,数组是.NET中最低级的集合。所有数组都直接派生自System.Array,也是唯一的CLR直接支持的集合。一维数组实现了IList(及其扩展的接口)和非泛型的IList、ICollection接口;矩形数组只支持非泛型接口。数组从元素角度来说是易变的,从大小角度来说是固定的。它们显示实现了集合接口中所有的可变方法(如Add和Remove),并抛出NotSupportedException。
引用类型的数组通常是协变的;如Stream[]引用可以隐式转换为Object[],并且存在显式的反向转换(容易混淆的是,也可以将Stream[]隐式转换为IList,尽管IList本身是不变的)。这意味着将在执行时验证数组的改变——数组本身知道是什么类型,因此如果先将Stream[]数组转换为Object[],然后再试图向其存储一个非Stream的引用,则将抛出ArrayTypeMismatchException。
CLR包含两种不同风格的数组。向量是下限为0的一维数组,其余的统称为数组(array)。向量的性能更佳,是C#中最常用的。T[][]形式的数组仍然为向量,只不过元素类型为T[];只有C#中的矩形数组,如string[10, 20],属于CLR术语中的数组。在C#中,你不能直接创建非零下限的数组——需要使用Array.CreateInstance来创建,它可以分别指定下限、长度和元素类型。如果创建了非零下限的一维数组,就无法将其成功转换为T[]——这种强制转换可以通过编译,但会在执行时失败。
C#编译器在很多方面都内嵌了对数组的支持。它不仅知道如何创建数组及其索引,还可以在foreach循环中直接支持它们;在使用表达式对编译时已知为数组的类型进行迭代时,将使用Length属性和数组索引器,而不会创建迭代器对象。这更高效,但性能上的区别通常忽略不计。
与List相同,数组支持ConvertAll、FindAll和BinarySearch方法,不过对数组来说,这些都是Array类的以数组为第一个参数的静态方法。
回到本节最开始所说的,数组是相当低级的数据结构。它们是其他集合的重要根基,在适当的情况下有效,但在大量使用之前还是应该三思。Eric同样为该话题撰写了博客,指出它们有“些许害处”(参见http://mng.bz/3jd5)。我不想夸大这一点,但在选择数组作为集合类型时,这是一个值得注意的缺点。
B.2.3 LinkedList
什么时候列表不是list呢?答案是当它为链表的时候。LinkedList在很多方面都是一个列表,特别的,它是一个保持项添加顺序的集合——但它却没有实现IList。因为它无法遵从通过索引进行访问的隐式契约。它是经典的计算机科学中的双向链表:包含头节点和尾节点,每个节点都包含对链表中前一个节点和后一个节点的引用。每个节点都公开为一个LinkedListNode,这样就可以很方便地在链表的中部插入或移除节点。链表显式地维护其大小,因此可以访问Count属性。
在空间方面,链表比维护后台数组的列表效率要低,同时它还不支持索引操作,但在链表中的任意位置插入或移除元素则非常快,前提是只要在相关位置存在对该节点的引用。这些操作的复杂度为O(1),因为所需要的只是对周围的节点修改前/后的引用。插入或移除头尾节点属于特殊情况,通常可以快速访问需要修改的节点。迭代(向前或向后)也是有效的,只需要按引用链的顺序即可。
尽管LinkedList实现了Add等标准方法(向链表末尾添加节点),我还是建议使用显式的AddFirst和AddLast方法,这样可以使意图更清晰。它还包含匹配的RemoveFirst和RemoveLast方法,以及First和Last属性。所有这些操作返回的都是链表中的节点而不是节点的值;如果链表是空(empty)的,这些属性将返回空(null)。
B.2.4 Collection、BindingList、ObservableCollection和 KeyedCollection<TKey, TItem>
Collection与我们将要介绍的剩余列表一样,位于System.Collections.ObjectModel命名空间。与List类似,它也实现了泛型和非泛型的集合接口。
尽管你可以对其自身使用Collection,但它更常见的用法是作为基类使用。它常扮演其他列表的包装器的角色:要么在构造函数中指定一个列表,要么在后台新建一个List。所有对于集合的变动行为,都通过受保护的虚方法(InsertItem、SetItem、RemoveItem和ClearItems)实现。派生类可以拦截这些方法,引发事件或提供其他自定义行为。派生类可通过Items属性访问被包装的列表。如果该列表为只读,公共的变动方法将抛出异常,而不再调用虚方法,你不必在覆盖的时候再次检查。
BindingList和ObservableCollection派生自Collection,可以提供绑定功能。BindingList在.NET 2.0中就存在了,而ObservableCollection是WPF(Windows Presentation Foundation)引入的。当然,在用户界面绑定数据时没有必要一定使用它们——你也许有自己的理由,对列表的变化更有兴趣。这时,你应该观察哪个集合以更有用的方式提供了通知,然后再选择使用哪个。注意,只会通知你通过包装器所发生的变化;如果基础列表被其他可能会修改它的代码共享,包装器将不会引发任何事件。
KeyedCollection<TKey, TItem>是列表和字典的混合产物,可以通过键或索引来获取项。与普通字典不同的是,键不能独立存在,应该有效地内嵌在项中。在许多情况下,这很自然,例如一个拥有CustomerID属性的Customer类型。KeyedCollection<,>为抽象类;派生类将实现GetKeyForItem方法,可以从列表中的任意项中提取键。在我们这个客户的示例中,GetKeyForItem方法返回给定客户的ID。与字典类似,键在集合中必须是唯一的——试图添加具有相同键的另一个项将失败并抛出异常。尽管不允许空键,但GetKeyForItem可以返回空(如果键类型为引用类型),这时将忽略键(并且无法通过键获取项)。
B.2.5 ReadOnlyCollection和ReadOnlyObservableCollection
最后两个列表更像是包装器,即使基础列表为易变的也只提供只读访问。它们仍然实现了泛型和非泛型的集合接口。并且混合使用了显式和隐式的接口实现,这样使用具体类型的编译时表达式的调用者将无法使用变动操作。
ReadOnlyObservableCollection派生自ReadOnlyCollection,并和ObserverbleCollection一样实现了相同的INotifyCollectionChanged和INotifyPropertyChanged接口。ReadOnlyObservableCollection的实例只能通过一个ObservableCollection后台列表进行构建。尽管集合对调用者来说依然是只读的,但它们可以观察对后台列表其他地方的改变。
尽管通常情况下我建议使用接口作为API中方法的返回值,但特意公开ReadOnlyCollection也是很有用的,它可以为调用者清楚地指明不能修改返回的集合。但仍需写明基础集合是否可以在其他地方修改,或是否为有效的常量。
B.3 字典
在框架中,字典的选择要比列表少得多。只有三个主流的非并发IDictionary<TKey, TValue>实现,此外还有ExpandoObject(第14章已介绍过)、ConcurrentDictionary(将在介绍其他并发集合时介绍)和RouteValueDictionary(用于路由Web请求,特别是在ASP.NET MVC中)也实现了该接口。
注意,字典的主要目的在于为值提供有效的键查找。
B.3.1 Dictionary<TKey, TValue>
如果没有特殊需求,Dictionary<TKey, TValue>将是字典的默认选择,就像List是列表的默认实现一样。它使用了散列表,可以实现有效的查找(参见),虽然这意味着字典的效率取决于散列函数的优劣。可使用默认的散列和相等函数(调用键对象本身的Equals和GetHashCode),也可以在构造函数中指定IEqualityComparer作为参数。
最简单的示例是用不区分大小写的字符串键实现字典,如代码清单B-1所示。
代码清单B-1 在字典中使用自定义键比较器
var comparer = StringComparer.OrdinalIgnoreCase;
var dict = new Dictionary<String, int>(comparer);
dict["TEST"] = 10;
Console.WriteLine(dict["test"]); //输出10
尽管字典中的键必须唯一,但散列码并不需要如此。两个不等的键完全有可能拥有相同的散列码;这就是散列冲突(hash collision)(http://en.wikipedia.org/wiki/Collision_(computer_science)——译者注),尽管这多少会降低字典的效率,但却可以正常工作。如果键是易变的,并且散列码在插入后发生了改变,字典将会失败。易变的字典键总是一个坏主意,但如果确实不得不使用,则应确保在插入后不会改变。
散列表的实现细节是没有规定的,可能会随时改变,但一个重要的方面可能会引起混淆:尽管Dictionary<TKey, TValue>有时可能会按顺序排列,但无法保证总是这样。如果向字典添加了若干项然后迭代,你会发现项的顺序与插入时相同,但请不要信以为真。有点不幸的是,刻意添加条目以维持排序的实现可能会很怪异,而碰巧自然扰乱了排序的实现则可能带来更少的混淆。
与List一样,Dictionary<TKey, TValue>将条目保存在数组中,并在必要的时候进行扩充,且扩充的平摊复杂度为O(1)。如果散列合理,通过键访问的复杂度也为O(1);而如果所有键的散列码都相等,由于要依次检查各个键是否相等,因此最终的复杂度为O(n)。在大多数实际场合中,这都不是问题。
1.HashTable大数据量插入数据时需要花费比Dictionary大的多的时间。
2.for方式遍历HashTable和Dictionary速度最快。
3.在foreach方式遍历时Dictionary遍历速度更快。
五:在单线程的时候使用Dictionary更好一些,多线程的时候使用HashTable更好。
因为HashTable可以通过Hashtable tab = Hashtable.Synchronized(new Hashtable());获得线程安全的对象。
B.3.2 SortedList<TKey, TValue>和SortedDictionary<TKey, TValue>
乍一看可能会以为名为SortedList<,>的类为列表,但实则不然。这两个类型都是字典,并且谁也没有实现IList。如果取名为ListBackedSortedDictionary和TreeBackedSortedDictionary可能更加贴切,但现在改已经来不及了。
这两个类有很多共同点:比较键时都使用IComparer而不是IEqualityComparer,并且键是根据比较器排好序的。在查找值时,它们的性能均为O(log n),并且都能执行二进制搜索。但它们的内部数据结构却迥然不同:SortedList<,>维护一个排序的条目数组,而SortedDictionary<,>则使用的是红黑树结构(参见维基百科条目http://mng.bz/K1S4)。这导致了插入和移除时间以及内存效率上的显著差异。如果要创建一个排序的字典,SortedList<,>将被有效地填充,想象一下保持List排序的步骤,你会发现向列表末尾添加单项是廉价的(若忽略数组扩充的话将为O(1)),而随机添加项则是昂贵的,因为涉及复制已有项(最糟糕的情况是O(n))。向SortedDictionary<,>中的平衡树添加项总是相当廉价(复杂度为O(log n)),但在堆上会为每个条目分配一个树节点,这将使开销和内存碎片比使用SortedList<,>键值条目的数组要更多。
这两种集合都使用单独的集合公开键和值,并且这两种情况下返回的集合都是活动的,因为它们将随着基础字典的改变而改变。但SortedList<,>公开的集合实现了IList,因此可以使用排序的键索引有效地访问条目。
我不想因为谈论了这么多关于复杂度的内容而给你造成太大困扰。如果不是海量数据,则可不必担心所使用的实现。如果字典的条目数可能会很大,你应该仔细分析这两种集合的性能特点,然后决定使用哪一个。
B.3.3 ReadOnlyDictionary<TKey, TValue>
熟悉了B.2.5节中介绍的ReadOnlyCollection后,ReadOnlyDictionary<TKey, TValue>应该也不会让你感到特别意外。ReadOnlyDictionary<TKey, TValue>也只是一个围绕已有集合(本例中指IDictionary<TKey, TValue>)的包装器而已,可隐藏显式接口实现后所有发生变化的操作,并且在调用时抛出NotSupportedException。
与只读列表相同,ReadOnlyDictionary<TKey, TValue>的确只是一个包装器;如果基础集合(传入构造函数的集合)发生变化,则这些修改内容可通过包装器显现出来。
B.4 集
在.NET 3.5之前,框架中根本没有公开集(set)集合。如果要在.NET 2.0中表示集,通常会使用Dictionary<,>,用集的项作为键,用假数据作为值。.NET3.5的HashSet在一定程度上改变了这一局面,现在.NET 4还添加了SortedSet和通用的ISet接口。尽管在逻辑上,集接口应该只包含Add/Remove/Contains操作,但ISet还指定了很多其他操作来控制集(ExceptWith、IntersectWith、SymmetricExceptWith和UnionWith)并在各种复杂条件下验证集(SetEquals、Overlaps、IsSubsetOf、IsSupersetOf、IsProperSubsetOf和IsProperSupersetOf)。所有这些方法的参数均为IEnumerable而不是ISet,这乍看上去会很奇怪,但却意味着集可以很自然地与LINQ进行交互。
B.4.1 HashSet
HashSet是不含值的Dictionary<,>。它们具有相同的性能特征,并且你也可以指定一个IEqualityComparer来自定义项的比较。同样,HashSet所维护的顺序也不一定就是值添加的顺序。
HashSet添加了一个RemoveWhere方法,可以移除所有匹配给定谓词的条目。这可以在迭代时对集进行删减,而不必担心在迭代时不能修改集合的禁令。
B.4.2 SortedSet(.NET 4)
就像HashSet之于Dictionary<,>一样,SortedSet是没有值的SortedDictionary<,>。它维护一个值的红黑树,添加、移除和包含检查(containment check)的复杂度为O(log n)。在对集进行迭代时,产生的是排序的值。
和HashSet一样它也提供了RemoveWhere方法(尽管接口中没有),并且还提供了额外的属性(Min和Max)用来返回最小和最大值。一个比较有趣的方法是GetViewBetween,它返回介于原始集上下限之内(含上下限)的另一个SortedSet。这是一个易变的活动视图——对于它的改变将反映到原始集上,反之亦然,如代码清单B-2所示。
代码清单B-2 通过视图观察排序集中的改变
var baseSet = new SortedSet<int> { 1, 5, 12, 20, 25 };
var view = baseSet.GetViewBetween(10, 20);
view.Add(14);
Console.WriteLine(baseSet.Count); //输出6
foreach (int value in view)
{
Console.WriteLine(value); //输出12、14、20
}
尽管GetViewBetween很方便,却不是免费的午餐:为保持内部的一致性,对视图的操作可能比预期的更昂贵。尤其在访问视图的Count属性时,如果在上次遍历之后基础集发生了改变,操作的复杂度将为O(n)。所有强大的工具,都应该谨慎用之。
SortedSet的最后一个特性是它公开了一个Reverse()方法,可以进行反序迭代。Enumerable.Reverse()没有使用该方法,而是缓冲了它调用的序列的内容。如果你知道要反序访问排序集,使用SortedSet类型的表达式代替更通用的接口类型可能会更有用,因为可访问这个更高效的实现。
B.5 Queue和Stack
队列和栈是所有计算机科学课程的重要组成部分。它们有时分别指FIFO(先进先出)和LIFO(后进先出)结构。这两种数据结构的基本理念是相同的:向集合添加项,并在其他时候移除。所不同的是移除的顺序:队列就像排队进商店,排在第一位的将是第一个被接待的;栈就像一摞盘子,最后一个放在顶上的将是最先被取走的。队列和栈的一个常见用途是维护一个待处理的工作项清单。
正如LinkedList一样,尽管可使用普通的集合接口方法来访问队列和栈,但我还是建议使用指定的类,这样代码会更加清晰。
B.5.1 Queue
Queue实现为一个环形缓冲区:本质上它维护一个数组,包含两个索引,分别用于记住下一个添加项和取出项的位置(slot)。如果添加索引追上了移除索引,所有内容将被复制到一个更大的数组中。
Queue提供了Enqueue和Dequeue方法,用于添加和移除项。Peek方法用来查看下一个出队的项,而不会实际移除。Dequeue和Peek在操作空(empty)队列时都将抛出InvalidOperationException。对队列进行迭代时,产生的值的顺序与出队时一致。
B.5.2 Stack
Stack的实现比Queue还简单——你可以把它想成是一个List,只不过它还包含Push方法用于向列表末尾添加新项,Pop方法用于移除最后的项,以及Peek方法用于查看而不移除最后的项。同样,Pop和Peek在操作空(empty)栈时将抛出InvalidOperationException。对栈进行迭代时,产生的值的顺序与出栈时一致——即最近添加的值将率先返回。
B.6 并行集合(.NET 4)
作为.NET 4并行扩展的一部分,新的System.Collections.Concurrent命名空间中包含一些新的集合。它们被设计为在含有较少锁的多线程并发操作时是安全的。该命名空间下还包含三个用于对并发操作的集合进行分区的类,但在此我们不讨论它们。
B.6.1 IProducerConsumerCollection和BlockingCollection
IProducerConsumerCollection被设计用于BlockingCollection,有三个新的集合实现了该接口。在描述队列和栈时,我说过它们通常用于为稍后的处理存储工作项;生产者/消费者模式是一种并行执行这些工作项的方式。有时只有一个生产者线程创建工作,多个消费者线程执行工作项。在其他情况下,消费者也可以是生产者,例如,网络爬虫(crawler)处理一个Web页面时会发现更多的链接,供后续爬取。
**IProducerConsumerCollection**是生产者/消费者模式中数据存储的抽象,BlockingCollection以易用的方式包装该抽象,并提供了限制一次缓冲多少项的功能。
BlockingCollection假设没有东西会直接添加到包装的集合中,所有相关方都应该使用包装器来对工作项进行添加和移除。构造函数包含一个重载,不传入IProducerConsumerCollection参数,而使用ConcurrentQueue作为后台存储。
IProducerConsumerCollection只提供了三个特别有趣的方法:ToArray、TryAdd和TryTake。ToArray将当前集合内容复制到新的数组中,这个数组是集合在调用该方法时的快照。TryAdd和TryTake都遵循了标准的TryXXX模式,试图向集合添加或移除项,返回指明成功或失败的布尔值。它允许有效的失败模式,降低了对锁的需求。例如在Queue中,要把“验证队列中是否有项”和“如果有项就进行出队操作”这两个操作合并为一个,就需要一个锁——否则Dequeue就可能抛出异常(例如,当队列有且仅有一个项时,两个线程同时判断它是否有项,并且都返回true,这时其中一个线程先执行了出队操作,而另一个线程再执行出队操作时,由于队列已经空了,因此将抛出异常。——译者注)。
BlockingCollection包含一系列重载,允许指定超时和取消标记,可以在这些非阻塞方法之上提供阻塞行为。通常不需要直接使用BlockingCollection或IProducerConsumerCollection,你可以调用并行扩展中使用了这两个类的其他部分。但了解它们还是很有必要的,特别是在需要自定义行为的时候。
B.6.2 ConcurrentBag、ConcurrentQueue和ConcurrentStack
框架自带了三个IProducerConsumerCollection的实现。本质上,它们在获取项的顺序上有所不同;队列和栈与它们非并发等价类的行为一致,而ConcurrentBag没有顺序保证。
它们都以线程安全的方式实现了IEnumerable。GetEnumerator()返回的迭代器将对集合的快照进行迭代;迭代时可以修改集合,并且改变不会出现在迭代器中。这三个类都提供了与TryTake类似的TryPeek方法,不过不会从集合中移除值。与TryTake不同的是,IProducerConsumerCollection中没有指定TryPeek方法。
B.6.3 ConcurrentDictionary<TKey, TValue>
ConcurrentDictionary<TKey, TValue>实现了标准的IDictionary<TKey, TValue>接口(但是所有的并发集合没有一个实现了IList),本质上是一个线程安全的基于散列的字典。它支持并发的多线程读写和线程安全的迭代,不过与上节的三个集合不同,在迭代时对字典的修改,可能会也可能不会反映到迭代器上。
它不仅仅意味着线程安全的访问。普通的字典实现基本上可以通过索引器提供添加或更新,通过Add方法添加或抛出异常,但ConcurrentDictionary<TKey, TValue>提供了名副其实的大杂烩。你可以根据前一个值来更新与键关联的值;通过键获取值,如果该键事先不存在就添加;只有在值是你所期望的时候才有条件地更新;以及许多其他的可能性,所有这些行为都是原子的。在开始时都显得很难,但并行团队的Stephen Toub撰写了一篇博客,详细介绍了什么时候应该使用哪一个方法(参见http://mng.bz/WMdW)。
B.7 只读接口(.NET 4.5)
NET 4.5引入了三个新的集合接口,即IReadOnlyCollection、IReadOnlyList和IReadOnlyDictionary<TKey, TValue>。截至本书撰写之时,这些接口还没有得到广泛应用。尽管如此,还是有必要了解一下的,以便知道它们不是什么。图B-2展示了三个接口间以及和IEnumerable接口的关系。
图B-2 .NET 4.5的只读接口
如果觉得ReadOnlyCollection的名字有点言过其实,那么这些接口则更加诡异。它们不仅允许其他代码对其进行修改,而且如果集合是可变的,甚至可以通过结合对象本身进行修改。例如,List实现了IReadOnlyList,但显然它并不是一个只读集合。
当然这并不是说这些接口没有用处。IReadOnlyCollection和IReadOnlyList对于T都是协变的,这与IEnumerable类似,但还暴露了更多的操作。可惜IReadOnlyDictionary<TKey, TValue>对于两个类型参数都是不变的,因为它实现了IEnumerable<KeyValuePair<TKey, TValue>>,而KeyValuePair<TKey, TValue>是一个结构,本身就是不变的。此外,IReadOnlyList的协变性意味着它不能暴露任何以T为参数的方法,如Contains和IndexOf。其最大的好处在于它暴露了一个索引器,通过索引来获取项。
目前我并没怎么使用过这些接口,但我相信它们在未来肯定会发挥重要作用。2012年底,微软在NuGet上发布了不可变集合的预览版,即Microsoft.Bcl.Immutable。BCL团队的博客文章(http://mng.bz/Xlqd)道出了更多细节,不过它基本上无需解释:不可变的集合和可冻结的集合(可变集合,在冻结后变为不可变集合)。当然,如果元素类型是可变的(如StringBuilder),那它也只能帮你到这了。但我依然为此兴奋不已,因为不可变性实在是太有用了。
B.8 小结
.NET Framework包含一系列丰富的集合(尽管对于集来说没那么丰富)(作者前面使用了a rich set of collecions,后面用了a rich collection of sets,分别表示丰富的集合和集。此处的中文无法体现原文这种对仗。——译者注)。它们随着框架的其他部分一起逐渐成长起来,尽管接下来的一段时间内,最常用的集合还应该是List和Dictionary<TKey, TValue>。
当然未来还会有其他数据结构添加进来,但要在其好处与添加到核心框架中的代价之间做出权衡。也许未来我们会看到明确的基于树的API,而不是像现在这样使用树作为已有集合的实现细节。也许可以看到斐波纳契堆(Fibonacci heaps)、弱引用缓存等——但正如我们所看到的那样,对于开发者来说已经够多了,并且有信息过载的风险。
如果你的项目需要特殊的数据结构,可以上网找找开源实现;Wintellect的Power Collections作为内置集合的替代品,已经有很长的历史了(参见http://powercollections.codeplex.com)。但在大多数情况下,框架完全可以满足你的需求,希望本附录可以在创造性使用泛型集合方面扩展你的视野。
推荐使用场景
比如在 .NET 1.x 版本下,我们可以这样使用:
namespace Lucifer.CSharp.Sample
{
class Program
{
public static void Main()
{
Hashtable table = new Hashtable();
//插入操作
table[1] = "A";
table.Add(2, "B");
table[3] = "C";
//检索操作
string a = (string)table[1];
string b = (string)table[2];
string c = (string)table[3];
//删除操作
table.Remove(1);
table.Remove(2);
table.Remove(3);
}
}
}
而在 .NET 2.0 及以上版本下,我们则这样使用:
namespace Lucifer.CSharp.Sample
{
class Program
{
public static void Main()
{
Dictionary<int, string> table = new Dictionary<int, string>();
//插入操作
table[1] = "A";
table.Add(2, "B");
table[3] = "C";
//检索操作
string a = table[1];
string b = table[2];
string c;
table.TryGetValue(3, out c);
//删除操作
table.Remove(1);
table.Remove(2);
table.Remove(3);
}
}
}
众所周知,假如在数组中知道了某个索引的话,也就知道了该索引位置上的值。同理,在散列表中,我们所要做的就是根据 Key 来知道 Value 在表中的位置 。 Key 的作用只不过用来指示位置。而通过 Key 来查找位置,意味着查找时间从顺序查找的 O(N),折半查找的 O(lgN) 骤减至 O(1)。
那么我们如何把可能是字符串,数字等的某 Key 转换成表的索引呢?这一步,在 .NET 中由 GetHashCode 方法来完成。当然在散列表内还需要根据 Hash Code 来进一步计算,但我们现在暂且认为通过 Key 的 GetHashCode 方法我们已经可以找到 Value 了。实际上,对于外部开发人员来说的确不需要再做别的工作了。而这也是 Object 类带有 GetHashCode 虚方法的原因。当我们在使用 Stack,List,Queue 等集合时,根本不需要在乎有没有 GetHashCode 方法,但是如果你想使用 Dictionary<TKey, TValue>,HashSet(.NET 3.5新增) 等集合时,则必须正确重写 GetHashCode 方法,否则这些集合不能正常工作。当然,使用.NET基元类型没有任何问题,因为 Microsoft 已经为这些类型实现了良好的重载。
而在讲解数据结构的书籍里,把 GetHashCode 方法完成的工作称为“散列函数(hash function)”。
散列函数
那么散列函数是如何工作的呢?通常来说,它会把某个数字或者能够转换成数字的类型映射成固定位数的数字。比如 .NET 的 GetHashCode 方法返回 32 位有符号整型。当我们把64 位或更多位数字映射成 32 位时,很显然,这带来了一个复杂问题:两个或多个不同的 Key 可能被散列到同一位置,引起碰撞冲突。这种情况很难避免,因为 Key 的个数比位置要多。当然,如果能够避免碰撞冲突,那就完美了。我们把能够完成这种情况的散列函数叫做完全散列函数(perfect hash function)。
从定义和实现来看,散列函数其实就是伪随机数生成器(PRNG)。总的来说,散列函数的性能通常可以接受,而且也可以把散列函数当作 PNRG 来进行比较。理论上,存在一个完全散列函数。它从不会让数据发生碰撞冲突。实际上,要找到这样的散列函数以及应用该散列函数的实际应用程序太困难了。即使是它最低限度的变体,也相当有限。
实践中,有很多种数据排列。有一些非常随机,另外一些则相当的格式化。一种散列函数很难概括所有的数据类型,即使针对某种数据类型也很困难。我们所能做的就是通过不断尝试来寻找最适合我们需要的散列函数。这也是必须重写 GetHashCode 方法的原因之一。
下面是我们分析选择散列函数的两大要素:
数据分布。这是衡量散列函数生成散列值好坏的尺度。分析这个需要知道在数据集内发生碰撞冲突的数量,即非唯一的散列值。
散列函数的效率。这是衡量散列函数生成散列值快慢的尺度。理论上,散列函数非常快。但是也应当注意到,散列函数并不总是保持 O(1) 的时间复杂度。
那么如何来实现散列函数呢?基本上有以下两大方法论:
-
加法和乘法。
这个方法的主要思想是通过遍历数据,然后以某种计算形式来构造散列值。通常情况下是乘以某个素数的乘法形式。如下图所示:
目前来说,还没有数学方法能够证明素数和散列函数之间的关系。不过在实践中利用一些素数可以得到很好的结果。 -
位移。
顾名思义,散列值是通过位移处理获得的。每一次的处理结果都累加,最后返回该值。如下图所示:
此外,还有很多方法可以用来计算散列值。不过这些方法也不外乎是上述两种的变种或综合运用。老实说,一个良好的散列函数很大程度上是靠经验得来。除此之外,别无良方。幸运的是,前人留下了许多经典的散列函数实现。接下来,我们就来了解下这些经典的散列函数。注意,本文所介绍的散列函数均不能使用在诸如加密,数字签名等领域。
关于整型和浮点类型的散列函数,因为都很简单,在这里就不再详细阐述。有兴趣的可以使用 Reflector 等反编译工具自行查看其 GetHashCode 实现。值得一提的是浮点类型要注意使 +0.0 和 -0.0 的散列值结果一致,还有就是 128 位的 Decimal 类型实现。
接下来将详细介绍几个字符串散列函数。
先看下 Java 的字符串散列函数是什么样。注意,本文代码均以C#写就,下同。代码如下:
public int JavaHash(string str)
{
int hashCode = 0;
for (int i = 0; i < str.Length; i++)
{
hashCode = 31 * hashCode + str[i];
}
return hashCode;
}
该函数最早由 Daniel J. Bernstein 教授展示于新闻组 comp.lang.C 上,是最有效率的散列函数之一。
我们再来看看 .NET 中的字符串散列函数。代码如下:
public unsafe int DotNetHash(string str)
{
fixed(char* charPtr = new String(str.ToCharArray()))
{
int hashCode = (5381 << 16) + 5381;
int numeric = hashCode;
int* intPtr = (int*)charPtr;
for (int i = str.Length; i > 0; i -= 4)
{
hashCode = ((hashCode << 5) + hashCode + 40 (hashCode >> 27)) ^ intPtr[0];
if (i <= 2)
{
break;
}
numeric = ((numeric << 5) + numeric + (numeric >> 27)) ^ intPtr[1];
intPtr += 2;
}
return hashCode + numeric * 1566083941;
}
}
我们知道了散列函数会使得 Key 发生碰撞冲突。
那么,.NET 的 Hashtable 类是如何解决该问题的呢?
很简单,探测。
我们首先利用散列函数 GetHashCode() 取得 Key 的散列值。为了保证该值在数组索引范围内,让其与数组大小求模。这样便得到了 Key 对应的 Value 在数组内的实际位置,即 f(K) = (GetHashCode() & 0x7FFFFFFF) % Array.Length。
当有多个 Key 的散列值重复的时候(即发生碰撞冲突时),算法将会尝试着把该值放到下一个合适的位置上,如果该位置已经被占用,则继续寻找,直到找到合适的空闲的位置。如果冲突的数量越多,那么搜索的次数也越多,效率也越低(无论是线性探测法,二次探测法,双散列法都会这样寻找,只不过寻找的偏移位置算法不同而已,.NET Hashtable 类使用的是双散列法)。整个过程如下图所示:
如果散列表的容量接近饱和时,找到合适的空闲的位置将会很困难,而且发生碰撞冲突的几率也很大。这个时候,就要对散列表进行扩容。那我们根据什么来判断应该扩容了呢?根据散列表内部数组容量和装填因子。
当散列表元素数量 = 数组大小 * 装填因子时,就应该扩容了。
.NET Hashtable 类默认的装填因子是 1.0。但实际上它默认的装填因子是 0.72,Microsoft 认为这个值对于开发人员来说不好记,所以改成了 1.0。所有从构造函数输入的装填因子,Hashtable 类内部都会将其乘以 0.72。这是一个要求苛刻的数字, 某些时刻将装填因子增减 0.01, 可能你的 Hashtable 存取效率就提高或降低了 50%, 其原因是装填因子决定散列表容量, 而散列表容量又影响 Key 的冲突几率, 进而影响性能. 0.72 是 Microsoft 经过大量实验得出的一个比较平衡的值. (取什么值合适和解决冲突的算法也有关, 0.72 不一定适合其他结构的散列表,比如 Java 的 HashMap<K, V> 默认的装填因子是 0.75)。
扩容是个耗时非常惊人的内部操作,Hashtable 之所以写入效率仅为读取效率的 1/10 数量级, 频繁的扩容是一个因素。当进行扩容时,散列表内部要重新 new 一个更大的数组,然后把原来数组的内容拷贝到新数组,并进行重新散列。如何 new 这个更大的数组也有讲究。散列表的初始容量一般来讲是个素数。当扩容时,新数组的大小会设置成原数组双倍大小的相近的一个素数。为了避免生成素数的额外开销,.NET 内部有一个素数数组,记录了常用到的素数。如下所示:
internal static readonly int[] primes =
{
3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107,
131, 163, 197, 239, 293, 353, 431, 521, 631, 761,
919, 1103, 1327, 1597, 1931, 2333, 2801, 3371,
4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,
17519, 21023, 25229, 30293, 36353, 43627, 52361,
62851, 75431, 90523, 108631, 130363, 156437,
187751, 225307, 270371, 324449, 389357, 467237,
560689, 672827, 807403, 968897, 1162687, 1395263,
1674319, 2009191, 2411033, 2893249, 3471899,
4166287, 4999559, 5999471, 7199369
};
当要扩容的数组大小超过以上素数时,再使用素数生成算法来获取跟其两倍大小相近的素数。正常情况下,我们可能不会存储这么多内容。细心的你可能发现这样很耗内存。没错,这的确非常耗费内存资源。比如当我们要在容量为 11 的 Hashtable 中添加 8 个元素。因为 8 / 11 > 0.72,所以要扩容。根据算法,跟 2 * 11 相近的素数是 23。看出有多浪费了吧。即使通过构造函数把容量设置为 17,也浪费了 9 个空间。假如你有 Key - Value 映射的需求,同时对内存又比较苛刻,可以考虑使用由红黑树构造的词典或映射。
那 Dictionary<TKey, TValue> 又是什么情况呢?
它没有采用 Hashtable 类的探测方法,而是采用了一种更流行,更节约空间的方法:分离链接散列法(separate chaining hashing)。
采用分离链接法的 Dictionary<TKey, TValue> 会在内部维护一个链表数组。对于这个链表数组 L0,L1,…,LM-1,散列函数将告诉我们应当把元素 X 插入到链表的什么位置。然后在 find 操作时告诉我们哪一个表中包含了 X。这种方法的思想在于:尽管搜索一个链表是线性操作,但如果表足够小,搜索非常快(事实也的确如此,同时这也是查找,插入,删除等操作并非总是 O(1) 的原因)。特别是,它不受装填因子的限制。
这种情况下,常见的装填因子是 1.0。更低的装填因子并不能明显的提高性能,但却需要更多的额外空间。Dictionary<TKey, TValue> 的默认装填因子便是 1.0。Microsoft 甚至认为没有必要修改装填因子,所以我们可以看到 Dictionary<TKey, TValue> 的构造函数中找不到关于装填因子的信息。Java 的 HashMap<K, V> 默认装填因子是 0.75。它的理由是这样可以减少检索的时间。我在测试的时候,发现Java HashMap<K, V> 检索时间的确要比 .NET Dictionay<TKey, TValue> 的检索时间要少,但差距相当微小。同时 HashMap<K, V> 的插入时间却跟 Dictionary<TKey, TValue> 差了老大一截,几乎是后者的 3~8 倍。一开始,我以为是错觉。因为 HashMap<K, V> 没有采用取模操作,而是位移操作,而且它使用的容量大小也是以 2 的指数级增长。这些都是些加速操作。甚是疑惑,望达人解答。
分离链接散列法的吸引力不仅在于适度增加装填因子时,性能不受影响,而且可以在扩容时避免再次散列(这相当耗时)。
最后,当我们要在应用程序中使用 Hashtable 或 Dictionary<TKey, TValue> 时,请尽量评估要插入的元素数量,因为这可以有效避免扩容和再次散列操作。同时,装填因子尽量使用 1.0。
PS:实现代码就不给出了。待描述并发散列表时,一并给出吧。😃
HashMap默认加载因子为什么选择0.75?(阿里)
Hashtable 初始容量是11 ,扩容 方式为2N+1;
HashMap 初始容量是16,扩容方式为2N;
阿里的人突然问我为啥扩容因子是0.75,回来总结了一下; 提高空间利用率和 减少查询成本的折中,主要是泊松分布,0.75的话碰撞最小,
HashMap有两个参数影响其性能:初始容量和加载因子。
容量是哈希表中桶的数量,
初始容量只是哈希表在创建时的容量。
加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。=当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行扩容、rehash操作(即重建内部数据结构),也就是 rehash,因此这个 rehash 相当耗时,扩容后的哈希表将具有两倍的原容量。
通常,加载因子需要在时间和空间成本上寻求一种折衷。
- 加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;
- 加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。
在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,减少扩容操作。
选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择,
正文
前几天在一个群里看到有人讨论hashmap中的加载因子为什么是默认0.75。
HashMap源码中的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
当时想到的是应该是“哈希冲突”和“空间利用率”矛盾的一个折衷。
跟数据结构要么查询快要么插入快一个道理,hashmap就是一个插入慢、查询快的数据结构。
加载因子是表示Hash表中元素的填满的程度。
- 加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。
- 反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
- 冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
- 因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。
哈希冲突主要与两个因素有关,
(1)填装因子,填装因子是指哈希表中已存入的数据元素个数与哈希地址空间的大小的比值,a=n/m ; a越小,冲突的可能性就越小,相反则冲突可能性较大;但是a越小空间利用率也就越小,a越大,空间利用率越高,为了兼顾哈希冲突和存储空间利用率,通常将a控制在0.6-0.9之间,而.net中的HashTable则直接将a的最大值定义为0.72 (虽然微软官方MSDN中声明HashTable默认填装因子为1.0,但实际上都是0.72的倍数),
(2)与所用的哈希函数有关,如果哈希函数得当,就可以使哈希地址尽可能的均匀分布在哈希地址空间上,从而减少冲突的产生,但一个良好的哈希函数的得来很大程度上取决于大量的实践,不过幸好前人已经总结实践了很多高效的哈希函数,可以参考大神Lucifer文章:数据结构:HashTable: http://www.cnblogs.com/lucifer1982/archive/2008/06/18/1224319.html
但是为什么一定是0.75?而不是0.8,0.6#
本着不嫌事大的精神继续深挖,在此之前先简单补充点本文需要的基础知识:
1.冲突定义:假设哈希表的地址集为[0,n),冲突是指由关键字得到的哈希地址为j(0<=j<=n-1)的位置上已经有记录。在关键字得到的哈希地址上已经有记录,那么就称之为冲突。
2.处理冲突:就是为该关键字的记录扎到另一个“空”的哈希地址。即在处理哈希地址的冲突时,若得到的另一个哈希地址H1仍然发生冲突,则再求下一个地址H2,若H2仍然冲突,再求的H3,直至Hk不发生冲突为止,则Hk为记录在表中的地址。
处理冲突的几种方法:#
一、 开放定址法#
Hi=(H(key) + di) MOD m i=1,2,...k(k<=m-1)其中H(key)为哈希函数;m为哈希表表长;di为增量序列。
开放定址法根据步长不同可以分为3种:
1)线性探查法(Linear Probing):di=1,2,3,…,m-1
简单地说就是以当前冲突位置为起点,步长为1循环查找,直到找到一个空的位置就把元素插进去,循环完了都找不到说明容器满了。就像你去一条街上的店里吃饭,问了第一家被告知满座,然后挨着一家家去问是否有位置一样。
2)线性补偿探测法:di=Q 下一个位置满足 Hi=(H(key) + Q) mod m i=1,2,…k(k<=m-1) ,要求 Q 与 m 是互质的,以便能探测到哈希表中的所有单元。
继续用上面的例子,现在你不是挨着一家家去问了,拿出计算器算了一下,然后隔Q家问一次有没有位置。
3)伪随机探测再散列:di=伪随机数序列。还是那个例子,这是完全根据心情去选一家店来问了
缺点:
这种方法建立起来的hash表当冲突多的时候数据容易堆聚在一起,这时候对查找不友好;
删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点
当空间满了,还要建立一个溢出表来存多出来的元素。
二、再哈希法#
Hi = RHi(key),i=1,2,...k
RHi均是不同的哈希函数,即在同义词产生地址冲突时计算另一个哈希函数地址,直到不发生冲突为止。这种方法不易产生聚集,但是增加了计算时间。
缺点:增加了计算时间。
三、建立一个公共溢出区#
假设哈希函数的值域为[0,m-1],则设向量HashTable[0…m-1]为基本表,每个分量存放一个记录,另设立向量OverTable[0…v]为溢出表。所有关键字和基本表中关键字为同义词的记录,不管他们由哈希函数得到的哈希地址是什么,一旦发生冲突,都填入溢出表。
简单地说就是搞个新表存冲突的元素。
四、链地址法(拉链法)#
将所有关键字为同义词的记录存储在同一线性链表中,也就是把冲突位置的元素构造成链表。
拉链法的优点:
拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
拉链法的缺点:
指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度
Hash table,国内相当一部分书籍将其直译为哈希表,本人喜欢称其为散列表。散列表支持任何基于 Key-Value 对的插入,检索,删除操作。
在这里插入代码片
↩︎