集合是一个很基础的东西,这篇博客会把集合及其背后的实现好好梳理一下:
对于所有的集合来说,都需要实现IEnumerable和IEnumerator接口,这两个接口在我们之前的迭代器那一篇博客里已经讲过了,在我们自己实现的手动迭代器里,其内部正是通过数组来进行迭代的管理的,我们今天的博客也从数组讲起:
数组是将多个数据项当做一个集合来处理的机制。在C#中,所有的数组都派生自System.Array类,而System.Array类又派生自System.Object类型,所以一个数组不管数据项是引用类型还是值类型,它都是引用类型(在托管堆上分配)的。那么进而引申,我们操控数组也是操控数组的引用而不是数组本身。下图来源于《CLR via C#》,描述了托管堆中数组的状态,例如有如下的代码:
int[] myArray = new int[100]; Control[] controlArray = new Control[100]; TextBox box = new TextBox(); controlArray[1] = box; controlArray[3] = box; controlArray[98] = new DataGrid(); controlArray[99] = new Button();那么它们在托管堆中对应的布局为:
数组背后的实现:IEnumerable接口、ICollection接口、IList接口
数组背后的诸多功能都是通过实现这个接口来完成的。但是从上图我们可以发现,除了数组以外的集合类型都存在泛型版本,但考虑到多维数组和非0基数的问题,CLR并没有明显的给Array提供泛型版本。而是隐式实现了泛型数组,例如:
SampleClass[] a = new SampleClass[20];这里实际上CLR自动使数组实现了 IEnumerable<T>、ICollection<T>、IList<T>。当CLR创建SampleClass[]类型时,则自动为这个类型实现了 IEnumerable<SampleClass>、ICollection<SampleClass>、IList<SampleClass>。同样的若SampleClass存在基类(假设基类叫BigClass),CLR也会自动实现它的基类的泛型数组:IEnumerable<BigClass>、IEnumerable<Object>、ICollection<BigClass>、ICollection<Object>、IList<BigClass>、IList<Object>。
但是如果是值类型的泛型数组,则CLR不会为元素的基类型实现接口,例如:
DateTime[] dateArray = new DateTime[10];那么,DateTime[]类型只会实现 IEnumerable<DateTime>、ICollection<DateTime>、IList<DateTime>类型的接口。而不会实现例如System.TypeValue的接口。
ICollection接口是C#中非常基础但很关键的一个接口,它是System.Collection的基接口。
相较于枚举接口IEnumerable和IEnumerator来说,它除了实现基本的遍历,还包括了:
统计集合中的元素个数(Count属性)获取元素的下标(索引器)判断集合中是否存在某种类型添加和移除元素需要稍微留意一下的是,非泛型集合ICollection接口不实现Add、Remove、Contain三个方法,只有泛型接口才实现这些。
我们尝试模仿一下泛型数组的建立,通过ICollection<T>接口实现一个自定义的泛型集合:
public class MyCollection<T> : ICollection<T> { private IEqualityComparer<T> comparer; private List<T> innerCol; public MyCollection() { innerCol = new List<T>(); comparer = EqualityComparer<T>.Default; } public T this[int index] { get { return (T)innerCol[index]; } set { innerCol[index] = value; } } public bool Contains(T item) { bool found = false; foreach (T bx in innerCol) { if (bx.Equals(item)) { found = true; } } return found; } public void Add(T item) { if (!Contains(item)) { innerCol.Add(item); } else { Console.WriteLine("该对象已经存在于集合中"); } } public void Clear() { innerCol.Clear(); } public void CopyTo(T[] array, int arrayIndex) { if (array == null) { throw new ArgumentNullException("数组不能为空"); } if (arrayIndex < 0) { throw new ArgumentNullException("数组索引值不能为负"); } if (Count > array.Length - arrayIndex + 1) { throw new ArgumentNullException("超出了数组的索引"); } for (int i = 0; i < innerCol.Count; i++) { array[i + arrayIndex] = innerCol[i]; } } public int Count { get { return innerCol.Count; } } public bool IsReadOnly { get { return false; } } public IEnumerator<T> GetEnumerator() { return new MyCollectionEnumerator<T>(this); } IEnumerator IEnumerable.GetEnumerator() { return new MyCollectionEnumerator<T>(this); } public bool Remove(T item) { bool result = false; for (int i = 0; i < innerCol.Count; i++) { T curMyCollection = (T)innerCol[i]; if (comparer.Equals(curMyCollection, item)) { innerCol.RemoveAt(i); result = true; break; } } return result; } IEnumerator<T> IEnumerable<T>.GetEnumerator() { return new MyCollectionEnumerator<T>(this); } }我们在ICollection内部维护一个List<T>的表(在例子中命名为innerCol),任何在外部传入的泛型的操作,只需要在内部实现List<T>中的操作即可,非常的简单,同时,我们如果需要自定义迭代器,还需要手写一个关于自定义集合的迭代器类(当然可以直接调用List的迭代器更为方便):
public class MyCollectionEnumerator<T> : IEnumerator<T> { private MyCollection<T> collection; private int curIndex; private T currentT; public MyCollectionEnumerator(MyCollection<T> collection) { this.collection = collection; curIndex = -1; currentT = default(T); } public bool MoveNext() { curIndex++; if (curIndex >= collection.Count) { return false; } else { currentT = collection[curIndex]; } return true; } public void Reset() { curIndex = -1; } void IDisposable.Dispose() { } public T Current { get { return currentT; } } object IEnumerator.Current { get { return Current; } } T IEnumerator<T>.Current { get { return currentT; } } }上述的迭代器在我们之前使用迭代器那一章就已经实现过了,这里只是很简单的依葫芦画瓢。 实际上内部都是操控名为innerCol的List表。
我们写一个例子来测试一下它的功能,我们做一个对照组,一个是我们自己手动实现泛型集合的MyCollection,一个是官方的泛型数组:
class SampleClass { public int Number; public SampleClass(int a) { Number = a; } } static void Main() { MyCollection<SampleClass> samples = new MyCollection<SampleClass>(); samples.Add(new SampleClass(77)); SampleClass A = new SampleClass(34); samples.Add(A); samples.Add(new SampleClass(23)); samples.Add(new SampleClass(63)); Console.WriteLine("此时手动数组的长度为" + samples.Count); foreach (SampleClass a in samples) { Console.WriteLine(a.Number); } samples.Remove(A); Console.WriteLine("删除了一项"); foreach (SampleClass a in samples) { Console.WriteLine(a.Number); } Console.WriteLine("数组中第一项为"+samples[0]); Console.WriteLine("========================================"); SampleClass[] samples2 = new SampleClass[4]; samples2[0]=new SampleClass(77); SampleClass B = new SampleClass(34); samples2[1] = B; samples2[2] = new SampleClass(23); samples2[3] = new SampleClass(63); Console.WriteLine("此时手动数组的长度为" + samples2.Length); foreach (SampleClass a in samples2) { Console.WriteLine(a.Number); } samples2[1] = null; Console.WriteLine("删除了一项"); foreach (SampleClass a in samples2) { if (a != null) { Console.WriteLine(a.Number); } else { Console.WriteLine(" "); } } Console.WriteLine("数组中第一项为" + samples2[0]); }此时我们看到,两种数组除了在删除时略有区别,其他情况下二者的功能是一致的:
IList接口直接继承自ICollection接口和IEnumerable接口,所以二者的功能它都具备了,同时,它自己也单独规定了一些功能:
通过索引器获得特定项的索引在指定位置插入元素在指定位置删除元素我初学C#的时候,非常讶异于这两者可以动态的添加任意长度的内容,但是实际上,List内部仍然维护的数组,它通过数组满员时将数组空间翻倍来实现存放任意长度的内容。我们平时在C#中使用得最多集合除了数组的就是ArrayList或者List<T>类型的表,它们都继承自IList接口,不同的是ArrayList内部维护Object数组,List<T>内部维护泛型数组。
对于ArrayList来说,它的内部维护一个Object数组。而对List<T>来说,它的内部维护一个泛型数组。我们参考源码重写一下List<T>,来实现动态长度的泛型List:
class SampleList<T> : IList<T> { //定义初始长度为4的泛型数组 private T[] contents = new T[defaultCapacity]; private const int defaultCapacity = 4; //listCount代表当前List中最后一个元素的索引 private int listCount=0; public int Count { get { return listCount; } } //顺序增加元素 public void Add(T value) { //当插入一个元素时,首先判断数组是否越界 if (listCount == contents.Length) { //重新确定容量长度 EnsureCapacity(Count + 1); //此时的Count已经是当前需要存入的第一个位置了 contents[Count] = value; listCount++; } else if (listCount < contents.Length) { //如果数组没有越界,则正常放入就好 contents[Count] = value; listCount++; } } //重新确定数组长 private void EnsureCapacity(int min) { if (contents.Length < min) { //这里确定数组是否为空,若为空则数组长为4,不为空则数组长翻倍 int num = (contents.Length == 0) ? 4 : contents.Length * 2; if (num > 2146435071) { //数组长度不能大于数组最大值 num = 2146435071; } if (num < min) { //新的数组长度不能小于当前长度 num = min; } Capacity = num; } } //复制数组 public int Capacity { get { return contents.Length; } set { if (value < listCount) { throw new ArgumentOutOfRangeException("手动:此时数组越界"); } if (value != contents.Length && value > 0) { //创建一个新的数组,再将原来的数组赋值过去 T[] array = new T[value]; if (listCount > 0) { contents.CopyTo(array, 0); } contents = array; array = null; } } } //确定是否存在某一值 public bool Contains(T value) { foreach (T a in contents) { if (a != null && a.Equals(value)) { return true; } } return false; } //确定是否存在某一值,并输出位置 public int IndexOf(T value) { for (int i = 0; i < contents.Length; i++) { if (contents[i].Equals(value)) { return i; } } return -1; } //在指定位置插入某一值 public void Insert(int index, T value) { if (listCount == contents.Length) { //如果插入时数组已满,将数组翻倍 EnsureCapacity(listCount + 1); } if (index < Count && index >= 0) { listCount++; for (int i = listCount - 1; i > index; i--) { //将指定位置后的数组往后挪一位 contents[i] = contents[i - 1]; } contents[index] = value; } } //删除某一值 public void Remove(T value) { RemoveAt(IndexOf(value)); } //在指定位置删除某一值 public bool RemoveAt(int index) { if (index >= 0 && index < Count) { int i; for (i = index; i < Count - 1; i++) { contents[i] = contents[i + 1]; } contents[i] = default(T); listCount--; return true; } return false; } bool ICollection<T>.Remove(T item) { return RemoveAt(IndexOf(item)); } void IList<T>.RemoveAt(int index) { Console.WriteLine("传入的需要删除的位置是" + index); if (index >= 0 && index < Count) { int i; for (i = index; i < Count - 1; i++) { contents[i] = contents[i + 1]; } contents[i] = default(T); listCount--; } } //输出List表 public void PrintContents() { Console.Write("该列表有" + Count + "个元素,"); Console.WriteLine("里面的元素有"); foreach (T a in contents) { Console.WriteLine(a); } Console.WriteLine("此时的表内维护的尾部索引为" + Count); } //将List表复制到指定数组里 public void CopyTo(T[] array, int index) { Console.WriteLine("数组执行复制,此时的初始复制索引为" + index); for (int i = 0; i < Count; i++) { array.SetValue(contents[i], index); index++; } } //索引器 public T this[int index] { get { return contents[index]; } set { contents[index] = value; } } public void Clear() { listCount = -1; } public IEnumerable GetEnumerator() { return contents.GetEnumerator() as IEnumerable; } IEnumerator IEnumerable.GetEnumerator() { return contents.GetEnumerator(); } IEnumerator<T> IEnumerable<T>.GetEnumerator() { return contents.GetEnumerator() as IEnumerator<T>; } public bool IsFixedSize { get{ return true; } } public bool IsReadOnly { get{ return false; } } public bool IsSynchronized { get{ return false; } } public object SyncRoot { get{ return this; } } }我们自己实现List<T>时需要注意:
要在添加和删除的时候确定数组是否越界,以免造成数组越界。内部维护的数组除了本身的长度以外,还存在已有元素的数量的值来标识当前数组多少位是满的,在我们的例子中, 即为ListCount。将数组复制时并不是复制整个数组,而是复制数组的对象的引用。由于List内部维护的数组,所以我们看到,它在查找的情况下比较简单,但是插入和删除操作需要移动元素的位置,所以List和数组一样都不适合频繁的插入或者删除。所以List<T>适合读多写少的情况。
IDictionary是集合中的重中之重,与前面的一些集合都不同,是键值对的匹配来实现,也是C#学习的一个重点。
实现IDictionary的最常用的集合就是Dictionary<TKey,TVlaue>,它内部使用散列表并维护两个数组,这也是这篇博客的重点。
散列表建立一个确定的对应关系H,使得每个关键码key都和它唯一的存贮位置H(key)相对应。
存储记录时,通过散列函数计算记录的散列地址,并用该地址存储记录。查找记录时,通过同样的散列函数计算记录的散列地址,并访问该记录。在数据结构中,散列表使用散列函数将一个不定长度的数据集映射到一个较短的二进制长度数据集。散列函数也叫做hash函数,它有如下几点特征:
数据相同,则通过同一个哈希函数得出的哈希码一定相同。不同的数据使用同一个哈希函数进行计算,结果也可能会相同。这样两个数据产生同一个结果的情况,称为冲突哈希函数的运算不可逆,不能通过哈希码获得原始的数据哈希函数也有如下几种类型,我们假设一个数据对应的值称为key:
直接寻址法:取数据对应的Key的某个值为散列地址,即H(key)=key或H(key) = a*key + b数字分析法:分析数据中差距较大的值作为散列地址。一般都是手动设置。平方取中法:取keyword平方后的中间几位作为散列地址。折叠法:将key分为位数相同的几部分,取这几部分的叠加和作为散列地址。随机取数法:选择一组随机函数,取key的随机值作为散列地址(这种方法常常用于key长度各异的场合)。除留余数法:取key中某个不大于散列表表长的数,然后将key与之相除,得出的结果为散列地址。这个方法可以与上面的折叠法或者平方取中法同时使用。在C#中,已经存在了一个GetHashCode方法,它对值类型的HashCode进行原值输出,引用类型的HashCode进行哈希码的计算。不同的内存地址所产生的哈希码也不同。
在程序的生命周期中,相同的对象或者变量返回的哈希码是相同且唯一的。但是哈希码不能作为持久性存储,因为程序一旦结束并重启后,同样的对象无法获得上次程序运行时的哈希码。
哈希表最重要的就是冲突的解决,一般来说分为以下几种解决方法:
开放定址法:如果由key得到的散列地址产生了冲突,就去寻找下一个散列地址,只要散列表足够大,那么空地址总能找到。拉链法:将所有散列地址相同的记录,存储在一个单链表中。(Dictionary所采用的冲突解决方法)二度哈希法:将产生冲突的key再次进行哈希计算,直到得到不存在冲突的散列地址为止。(HashTable所采用的冲突解决方法)在C#中的两个常用集合中,HashTable使用了二度哈希法来解决冲突,而Dictionary采用了拉链法来解决冲突,但是Dictionary在拉链法的基础上,将单链表改为了一个数组,以保证存储记录的表所衍生出来的表不会太多。由于HashTable基本已经被Dictionary取代了,所以我们这篇文章主要来剖析Dictionary。
在得出一个哈希码后,我们需要将哈希码对应的数据存入桶中。但是往往得出的哈希码位数都比较大(常常有2^32次方的长度),不可能对每个哈希码都能指定一个映射。
为了解决这个问题,我们把保存哈希映射记录的表称为桶,每一个哈希码只需要与桶的长度取余,例如bucketIndex=H(key)%bucket.Length。结果即为它在桶中的位置。但是这样也加剧了hash的冲突。
在Dictionary中,通过两个数组来实现键值对的存取:
Bucket数组,存放映射到的数据哈希码的数组,即为我们的桶。entries数组,存放数据信息的结构体的数组。二者长度相同。接下来我们将剖析Dictionary的实现:
Dictionary的数据成员:用于存放数据信息的Entry结构体以及其他的成员
struct Entry { //entries数组中下一个数据的索引 public int next; //键 public Tkey key; //值 public TValue value; //哈希码 public int hashCode; } //Entry的泛型数组 private Entry[] entries; //桶数组 private int[] Buckets; //存入数据的数量,也是entries中第一个为空的位置的索引 public int Count; //Entry数组中非最后一位的空位索引 private int FreeList; //Entry数组中非最后一位的空位数量 private int FreeCount; //版本号 public int version; //用于生成哈希码的EqualityComparer对象 private IEqualityComparer<Tkey> comparer;并且,在构造结构体对象时,需要将各种类型的值置空:
public MyDictionary(int size) { if (size <= 0) { throw new ArgumentNullException(); } entries = new Entry[size]; Buckets = new int[size]; for (int i = 0; i < size; i++) { Buckets[i] = -1; } FreeList = -1; comparer = EqualityComparer<Tkey>.Default; }当我们初始化后(假设我们初始化的数组长度为4),我们上面的数据成员即为我们这张图里这样分布:
我们先不看代码,先讲情况,假设我们 泛型为<char,string>。
1.此时放入一个键值对('1',"first"),经过计算它在桶中的位置为1。为了方便,假设哈希码为XX
那么此时,Count等于1,意思是entries数组中索引为1的地方正好是空的:
2.此时, 如果存入键值对('A',"special"),它的桶中位置也为1,则此时发生了冲突,那么字典的解决策略即为,将entries数组中第一个不为空的地方放置这个键值对,这个地方的索引即为我们的count,并且将它的链键索引设为此时的桶中的索引(也就正好是原来指向的键),然后再将桶中的索引设为它在entries数组中的索引,即为我们的下图:
此时,如果我们删除key为1的索引,很显然entries数组索引为0的entry将被置空,但是此时entries数组产生了空洞,如果我们按之前的放入新元素的方式填充entries数组,那么索引为0的位置永远不会被填充,造成了内存空间的浪费,这个时候FreeList和FreeCount的好处就体现出来了:FreeList保存空缺的索引位置,FreeCount记录空缺索引的数量,此时我们的数据表如下:
接着又添加一个键值对的时候,则Entry不会存入Count此时的位置,而是先根据FreeCount的大小来判断是否存在空缺索引,如果FreeCount大于0,则说明存在空缺索引,此时新的键值对会放入空缺索引的位置而不是Count的位置,此时我们放入的键值对是('2',"second"),它对应的桶坐标是2
这样就实现了我们基本的增加和删除的操作,我们此时再来看实现这样功能的代码:
为了应对不同的情况,Insert实现了不同的重载功能,而且判断了不同的情况,这样的代码我们需要注意:
存在根据索引修改键值的功能,所以此时字典中存在一个bool参数来判断是否当前为修改功能对于得到的哈希码,在计算桶位置前需要与0x7ffffff进行按位与操作,由于二进制的第一位划分了这个类型的正负,进行按位与操作可以防止哈希码为负数我们每次增加一个值,都需要让这个entry的next等于当前桶中索引,再让桶中索引等于当前entries数组的索引。对于字典中的删除操作,我们应该注意到:
字典的索引的删除很类似与链表中的索引的删除,将上一个键的next索引改为当前键的next索引来达到数据跟踪的需求将字典进行增删操作时,都需要对版本号进行更新对于字典来说既然内部实现是用的数组,如果要实现动态存取的功能,势必和List表一样需要提供扩容的功能,我们在之前的Insert函数中就看到了这样的操作,一般来说,字典需要扩容有两种情况:
1.字典的数组容量已满,即entries数组不能存放更多元素,如下图:
2.字典过于不平衡,产生的冲突过多。由此也导致字典的查找效率变得低下,如下图:
那么这样的情况就需要重新对字典进行扩容,和List表一样,字典的扩容同样是数组重新定长:
private void Resize() { int Count = entries.Length * 2; Resize(Count, false); } private void Resize(int newSize, bool forceNewHashCodes) { int[] newBeckets = new int[newSize]; //创建一个新的桶 for (int i = 0; i < newBeckets.Length; i++) { newBeckets[i] = -1; //将桶内所有的值都置空 } //创建一个新的键结构体数组 Entry[] newEntries = new Entry[newSize]; //将数组的索引Copy到新数组中 Array.Copy(entries, 0, newEntries, 0, Count); //如果需要重新生成桶的位置来解决不平衡 if (forceNewHashCodes) { for (int i = 0; i < Count; i++) { if (newEntries[i].hashCode != -1) { //对所有元素重新计算哈希码 newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7fffffff); } } } for (int i = 0; i < Count; i++) { if (newEntries[i].hashCode >= 0) { //对存在哈希码的对象计算桶中的位置 int bucket = newEntries[i].hashCode % newSize; //此时的新的桶中的entries表索引统统为-1 newEntries[i].next = newBeckets[bucket]; //再将当前的entry的索引(即为i)复制到桶中。 newBeckets[bucket] = i; } } Buckets = newBeckets; entries = newEntries; newBeckets = null; newEntries = null; }此时即实现了数组的扩容操作,我们在上文中Insert函数可以看到两次扩容时的数组大小不一样,这是因为两次扩容的原因不一样,当forceNewHashCodes为True时,则是由于数组不平衡产生的扩容,则需要重新计算每一个键的哈希码。
之前的其他数据成员在上文中字典的基本操作中都有应用,但是唯独version没有看到在哪个地方使用了它,但是它是不可或缺的。
在我们使用迭代器IEnumerator接口进行遍历时,与for循环不一致的在于它在遍历时不能对集合进行修改,我们看到哈希表的迭代器实现:
在哈希表的迭代中,每次迭代都要对version进行判断,以防止在foreach中程序员对Dictionary进行了改动。这也就是每次我们增加和删除时都需要更新版本号的原因。
上文只写了关于字典的三个比较关键的函数,实际上IDictionary接口实现的功能很多,我在这里就不一一地写明了(写完这篇博客的长度就没完没了了),上面的代码实际上经过简化,我们仍然需要注意一些地方:
1.C#中的字典的默认碰撞长度是100次,当哈希碰撞达到这个值的时候,将会对两个数组进行重新定长:
entries数组的所有值都连接在桶数组的一个位置上,那么查找的性能为O(n),导致性能下降,在JDK中,HashMap如果碰撞的次数太多了,那么会将单链表转换为红黑树提升查找性能。目前.Net Framwork中还没有这样的优化,.Net Core中已经有了类似的优化。
2.字典生成的时候构造函数会将两个数组的引用定长,而且在翻倍的时候并不是像List<T>那样简单的乘以2,而是在内部的HashHelpler类中一个数组中寻找下一个标定的数组长,我们可以看到初始的两个数组长度是3(而不是上面例子里的4):
3.虽然HashTable类与Dictionary实现的功能类似,但二者的内部并不一样,HashTable类的内部仅使用一个Bucket[],并且在数组重新定长时使用二度哈希的方法进行Bucket位置的计算,这样相对于字典来说内存占用要少些:
HashTable的部分内部成员:
HashTable的二度哈希函数rehash:
我们平时能在C#中用到的并不止上文说的这些集合,同时还有以下集合类型:
关联性泛型集合类:
SortedDictionary<TKey,TValue>:适用于增删操作较为频繁的情况。
排序字典SortedDictionary和Dictionary类似,但是它内部是有序的。它的内部存储结构是平衡搜索二叉树:红黑树。并且基于二分查找法,所以添加、查找、删除元素的时间复杂度是O(logn)。相对于数组和List来说查找效率不高,但是增删操作效率较好。
SortedList<TKey,TValue>:适用于快速查找排序序列的情况。
Dictionary使用了散列函数来存储数据,所以不支持线性排序。而SortedList内部存储结构是数组,添加和删除元素的时间复杂度是O(n),但查找时使用了二分法来查找,时间复杂度为O(n)。SortedList相对于Dictionary来说支持了有序序列,但是也因此牺牲了查找效率。如果我们想要用字典来排序,比较常用的方法是将它导出到List的线性集合中然后排序。
SortedList相对于SortedDictionary来说占用的内存更少,但是增删操作耗费的内存更多。
非关联性泛型集合类:
LinkedList<T>:适用于写多读少的情况
LinkedList与List不同的是,它的内部使用了双向链表来代替泛型数组,这样保证了内部在增删时效率更好,但是链表的查找效率比较低下,所以更适合写多读少的情况。
HashSet<T>:简化版的字典,仅存储值而不存储键值对
HashSet与Dictionary类似,存储结构为散列表加上双数组,但是里面不存储键,只存储值。我们可以将它看成是简化版的Dictionary。
SortedSet<T>:排序版的HashSet
SortedSet与HashSet的关系很类似于SortedDictionary与Dictionary的关系那样,它也只保存数据对象而不保存对应的键位。内部的存储结构为红黑树,并且支持元素的排序。
哇,这篇博客终于写完了,写了将近四五天,本身来说,一个语言的集合就是这个语言的基石,我以前听说过一句话,叫:集合不牢,地动山摇。这个东西确实挺重要,但是对红黑树实现的SortDictionary我感觉还是不熟悉(因为我压根不懂啥是红黑树),并且,这篇博客并不涉及集合的线程安全性,这是比较遗憾的,之后会写博客将这两个大点补上。
这里贴上C#源码的查看地址:https://referencesource.microsoft.com/#mscorlib/system/collections/hashtable.cs,10fefb6e0ae510dd
这篇博客对网上的很多的大牛写的文章都有参考,在这里非常感谢他们!