Resizable array list (List<T>)
Tree-based dictionary (SortedDictionary<K,T>)
Hash table based set (HashSet<T>)
Tree based set (SortedSet<T>)
在计算机程序设计中,数组(Array)是最简单的并且应用最普遍的数据结构之一。在任何编程语言中,数组都有一些共性:
对于数组的常规操做包括:
在 C# 中,能够经过以下的方式声明数组变量。
1 int allocationSize = 10; 2 bool[] booleanArray = new bool[allocationSize]; 3 FileInfo[] fileInfoArray = new FileInfo[allocationSize];
上面的代码将在 CLR 托管堆中分配一块连续的内存空间,用以容纳数量为 allocationSize ,类型为 arrayType 的数组元素。若是 arrayType 为值类型,则将会有 allocationSize 个未封箱(unboxed)的 arrayType 值被建立。若是 arrayType 为引用类型,则将会有 allocationSize 个 arrayType 类型的引用被建立。
若是咱们为 FileInfo[] 数组中的一些位置赋上值,则引用关系为下图所示。
.NET 中的数组都支持对元素的直接读写操做。语法以下:
1 // 读数组元素 2 bool b = booleanArray[7]; 3 4 // 写数组元素 5 booleanArray[0] = false;
访问一个数组元素的时间复杂度为 O(1),所以对数组的访问时间是恒定的。也就是说,与数组中包含的元素数量没有直接关系,访问一个元素的时间是相同的。
因为数组是固定长度的,而且数组中只能存储同一种类型或类型的衍生类型。这在使用中会受到一些限制。.NET 提供了一种数据结构 ArrayList 来解决这些问题。
1 ArrayList countDown = new ArrayList(); 2 countDown.Add(3); 3 countDown.Add(2); 4 countDown.Add(1); 5 countDown.Add("blast off!"); 6 countDown.Add(new ArrayList());
ArrayList 是长度可变的数组,而且它能够存储不一样类型的元素。
但这些灵活性是以牺牲性能为代价的。在上面 Array 的描述中,咱们知道 Array 在存储值类型时是采用未装箱(unboxed)的方式。因为 ArrayList 的 Add 方法接受 object 类型的参数,致使若是添加值类型的值会发生装箱(boxing)操做。这在频繁读写 ArrayList 时会产生额外的开销,致使性能降低。
当 .NET 中引入泛型功能后,上面 ArrayList 所带来的性能代价可使用泛型来消除。.NET 提供了新的数组类型 List<T>。
泛型容许开发人员在建立数据结构时推迟数据类型的选择,直到使用时才肯定选择哪一种类型。泛型(Generics)的主要优势包括:
List<T> 等同于同质的一维数组(Homogeneous self-redimensioning array)。它像 Array 同样能够快速的读取元素,还能够保持长度可变的灵活性。
1 // 建立 int 类型列表 2 List<int> myFavoriteIntegers = new List<int>(); 3 4 // 建立 string 类型列表 5 List<string> friendsNames = new List<string>();
List<T> 内部一样使用 Array 来实现,但它隐藏了这些实现的复杂性。当建立 List<T> 时无需指定初始长度,当添加元素到 List<T> 中时,也无需关心数组大小的调整(resize)问题。
1 List<int> powersOf2 = new List<int>(); 2 3 powersOf2.Add(1); 4 powersOf2.Add(2); 5 6 powersOf2[1] = 10; 7 8 int sum = powersOf2[1] + powersOf2[2];
List<T> 的渐进运行时(Asymptotic Running Time)复杂度与 Array 是相同的。
在链表(Linked List)中,每个元素都指向下一个元素,以此来造成了一个链(chain)。
在建立一个链表时,咱们仅需持有头节点 head 的引用,这样经过逐个遍历下一个节点 next 便可找到全部的节点。
链表与数组有着一样的线性运行时间 O(n)。例如在上图中,若是咱们要查找 Sam 节点,则必须从头节点 Scott 开始查找,逐个遍历下一个节点直到找到 Sam。
一样,从链表中删除一个节点的渐进时间也是线性的O(n)。由于在删除以前咱们仍然须要从 head 开始遍历以找到须要被删除的节点。而删除操做自己则变得简单,即让被删除节点的左节点的 next 指针指向其右节点。下图展现了如何删除一个节点。
向链表中插入一个新的节点的渐进时间取决于链表是不是有序的。若是链表不须要保持顺序,则插入操做就是常量时间O(1),能够在链表的头部或尾部添加新的节点。而若是须要保持链表的顺序结构,则须要查找到新节点被插入的位置,这使得须要从链表的头部 head 开始逐个遍历,结果就是操做变成了O(n)。下图展现了插入节点的示例。
链表与数组的不一样之处在于,数组的中的内容在内存中时连续排列的,能够经过下标来访问,而链表中内容的顺序则是由各对象的指针所决定,这就决定了其内容的排列不必定是连续的,因此不能经过下标来访问。若是须要更快速的查找操做,使用数组多是更好的选择。
使用链表的最主要的优点就是,向链表中插入或删除节点无需调整结构的容量。而相反,对于数组来讲容量始终是固定的,若是须要存放更多的数据,则须要调整数组的容量,这就会发生新建数组、数据拷贝等一系列复杂且影响效率的操做。即便是 List<T> 类,虽然其隐藏了容量调整的复杂性,但仍然难逃性能损耗的惩罚。
链表的另外一个优势就是特别适合以排序的顺序动态的添加新元素。若是要在数组的中间的某个位置添加新元素,不只要移动全部其他的元素,甚至还有可能须要从新调整容量。
因此总结来讲,数组适合数据的数量是有上限的状况,而链表适合元素数量不固定的状况。
在 .NET 中已经内置了 LinkedList<T> 类,该类实现了双向链表(doubly-linked list)功能,也就是节点同时持有其左右节点的引用。而对于删除操做,若是使用 Remove(T),则运算复杂度为 O(n),其中 n 为链表的长度。而若是使用 Remove(LinkedListNode<T>), 则运算复杂度为 O(1)。
当咱们须要使用先进先出顺序(FIFO)的数据结构时,.NET 为咱们提供了 Queue<T>。Queue<T> 类提供了 Enqueue 和 Dequeue 方法来实现对 Queue<T> 的存取。
Queue<T> 内部创建了一个存放 T 对象的环形数组,并经过 head 和 tail 变量来指向该数组的头和尾。
默认状况下,Queue<T> 的初始化容量是 32,也能够经过构造函数指定容量。
Enqueue 方法会判断 Queue<T> 中是否有足够容量存放新元素。若是有,则直接添加元素,并使索引 tail 递增。在这里的 tail 使用求模操做以保证 tail 不会超过数组长度。若是容量不够,则 Queue<T> 根据特定的增加因子扩充数组容量。
默认状况下,增加因子(growth factor)的值为 2.0,因此内部数组的长度会增长一倍。也能够经过构造函数中指定增加因子。Queue<T> 的容量也能够经过 TrimExcess 方法来减小。
Dequeue 方法根据 head 索引返回当前元素,以后将 head 索引指向 null,再递增 head 的值。
当须要使用后进先出顺序(LIFO)的数据结构时,.NET 为咱们提供了 Stack<T>。Stack<T> 类提供了 Push 和 Pop 方法来实现对 Stack<T> 的存取。
Stack<T> 中存储的元素能够经过一个垂直的集合来形象的表示。当新的元素压入栈中(Push)时,新元素被放到全部其余元素的顶端。当须要弹出栈(Pop)时,元素则被从顶端移除。
Stack<T> 的默认容量是 10。和 Queue<T> 相似,Stack<T> 的初始容量也能够在构造函数中指定。Stack<T> 的容量能够根据实际的使用自动的扩展,而且能够经过 TrimExcess 方法来减小容量。
若是 Stack<T> 中元素的数量 Count 小于其容量,则 Push 操做的复杂度为 O(1)。若是容量须要被扩展,则 Push 操做的复杂度变为 O(n)。Pop 操做的复杂度始终为 O(1)。
如今假设咱们要使用员工的社保号做为惟一标识进行存储。社保号的格式为 DDD-DD-DDDD(D 的范围为数字 0-9)。
若是使用 Array 存储员工信息,要查询社保号为 111-22-3333 的员工,则将会尝试遍历数组的全部位置,即执行渐进时间为 O(n) 的查询操做。好一些的办法是将社保号排序,以使查询渐进时间下降到 O(log(n))。但理想状况下,咱们更但愿查询渐进时间为 O(1)。
一种方案是创建一个大数组,范围从 000-00-0000 到 999-99-9999 。
这种方案的缺点是浪费空间。若是咱们仅须要存储 1000 个员工的信息,那么仅利用了 0.0001% 的空间。
第二种方案就是用哈希函数(Hash Function)压缩序列。
咱们选择使用社保号的后四位做为索引,以减小区间的跨度。这样范围将从 0000 到 9999。
在数学上,将这种从 9 位数转换为 4 位数的方式称为哈希转换(Hashing)。能够将一个数组的索引空间(indexers space)压缩至相应的哈希表(Hash Table)。
在上面的例子中,哈希函数的输入为 9 位数的社保号,输出结果为后 4 位。
H(x) = last four digits of x
上图中也说明在哈希函数计算中常见的一种行为:哈希冲突(Hash Collisions)。即有可能两个社保号的后 4 位均为 0000。
当要添加新元素到 Hashtable 中时,哈希冲突是致使操做被破坏的一个因素。若是没有冲突发生,则元素被成功插入。若是发生了冲突,则须要判断冲突的缘由。所以,哈希冲突提升了操做的代价,Hashtable 的设计目标就是要尽量减低冲突的发生。
处理哈希冲突的方式有两种:避免和解决,即冲突避免机制(Collision Avoidance)和冲突解决机制(Collision Resolution)。
避免哈希冲突的一个方法就是选择合适的哈希函数。哈希函数中的冲突发生的概率与数据的分布有关。例如,若是社保号的后 4 位是随即分布的,则使用后 4 位数字比较合适。但若是后 4 位是以员工的出生年份来分配的,则显然出生年份不是均匀分布的,则选择后 4 位会形成大量的冲突。咱们将这种选择合适的哈希函数的方法称为冲突避免机制(Collision Avoidance)。
在处理冲突时,有不少策略能够实施,这些策略称为冲突解决机制(Collision Resolution)。其中一种方法就是将要插入的元素放到另一个块空间中,由于相同的哈希位置已经被占用。
一般采用的冲突解决策略为开放寻址法(Open Addressing),全部的元素仍然都存放在哈希表内的数组中。
开放寻址法的最简单的一种实现就是线性探查(Linear Probing),步骤以下:
如今若是咱们要将五个员工的信息插入到哈希表中:
则插入后的哈希表可能以下:
元素的插入过程:
线性探查(Linear Probing)方式虽然简单,但并非解决冲突的最好的策略,由于它会致使同类哈希的汇集(Primary Clustering)。这致使搜索哈希表时,冲突依然存在。例如上面例子中的哈希表,若是咱们要访问 Edward 的信息,由于 Edward 的社保号 111-00-1235 哈希为 1235,然而咱们在 1235 位置找到的是 Bob,因此再搜索 1236,找到的倒是 Danny,以此类推直到找到 Edward。
一种改进的方式为二次探查(Quadratic Probing),即每次检查位置空间的步长为平方倍数。也就是说,若是位置 s 被占用,则首先检查 s + 12 处,而后检查s - 12,s + 22,s - 22,s + 32 依此类推,而不是象线性探查那样以 s + 1,s + 2 ... 方式增加。尽管如此,二次探查一样也会致使同类哈希汇集问题(Secondary Clustering)。
.NET 中的 Hashtable 类的实现,要求添加元素时不只要提供元素(Item),还要为该元素提供一个键(Key)。例如,Key 为员工社保号,Item 为员工信息对象。能够经过 Key 做为索引来查找 Item。
1 Hashtable employees = new Hashtable(); 2 3 // Add some values to the Hashtable, indexed by a string key 4 employees.Add("111-22-3333", "Scott"); 5 employees.Add("222-33-4444", "Sam"); 6 employees.Add("333-44-55555", "Jisun"); 7 8 // Access a particular key 9 if (employees.ContainsKey("111-22-3333")) 10 { 11 string empName = (string)employees["111-22-3333"]; 12 Console.WriteLine("Employee 111-22-3333's name is: " + empName); 13 } 14 else 15 Console.WriteLine("Employee 111-22-3333 is not in the hash table...");
Hashtable 类中的哈希函数比前面介绍的社保号的实现要更为复杂。哈希函数必须返回一个序数(Ordinal Value)。对于社保号的例子,经过截取后四位就能够实现。但实际上 Hashtable 类能够接受任意类型的值做为 Key,这都要归功于 GetHashCode 方法,一个定义在 System.Object 中的方法。GetHashCode 的默认实现将返回一个惟一的整数,而且保证在对象的生命周期内保持不变。
Hashtable 类中的哈希函数定义以下:
H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1))] % hashsize
这里的 GetHash(key) 默认是调用 key 的 GetHashCode 方法以获取返回的哈希值。hashsize 指的是哈希表的长度。由于要进行求模,因此最后的结果 H(key) 的范围在 0 至 hashsize - 1 之间。
当在哈希表中添加或获取一个元素时,会发生哈希冲突。前面咱们简单地介绍了两种冲突解决策略:
在 Hashtable 类中则使用的是一种彻底不一样的技术,称为二度哈希(rehashing)(有些资料中也将其称为双重哈希(double hashing))。
二度哈希的工做原理以下:
有一个包含一组哈希函数 H1...Hn 的集合。当须要从哈希表中添加或获取元素时,首先使用哈希函数 H1。若是致使冲突,则尝试使用 H2,以此类推,直到 Hn。全部的哈希函数都与 H1 十分类似,不一样的是它们选用的乘法因子(multiplicative factor)。
一般,哈希函数 Hk 的定义以下:
Hk(key) = [GetHash(key) + k * (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)))] % hashsize
当使用二度哈希时,重要的是在执行了 hashsize 次探查后,哈希表中的每个位置都有且只有一次被访问到。也就是说,对于给定的 key,对哈希表中的同一位置不会同时使用 Hi 和 Hj。在 Hashtable 类中使用二度哈希公式,其始终保持 (1 + (((GetHash(key) >> 5) + 1) % (hashsize – 1)) 与 hashsize 互为素数(两数互为素数表示二者没有共同的质因子)。
二度哈希使用了 Θ(m2) 种探查序列,而线性探查(Linear Probing)和二次探查(Quadratic Probing)使用了Θ(m) 种探查序列,故二度哈希提供了更好的避免冲突的策略。
Hashtable 类中包含一个私有成员变量 loadFactor,loadFactor 指定了哈希表中元素数量与位置(slot)数量之间的最大比例。例如:若是 loadFactor 等于 0.5,则说明哈希表中只有一半的空间存放了元素值,其他一半都为空。
哈希表的构造函数容许用户指定 loadFactor 值,定义范围为 0.1 到 1.0。然而,无论你提供的值是多少,范围都不会超过 72%。即便你传递的值为 1.0,Hashtable 类的 loadFactor 值仍是 0.72。微软认为loadFactor 的最佳值为 0.72,这平衡了速度与空间。所以虽然默认的 loadFactor 为 1.0,但系统内部却自动地将其改变为 0.72。因此,建议你使用缺省值1.0(但其实是 0.72)。
向 Hashtable 中添加新元素时,须要检查以保证元素与空间大小的比例不会超过最大比例。若是超过了,哈希表空间将被扩充。步骤以下:
由此看出,对哈希表的扩充将是以性能损耗为代价。所以,咱们应该预先估计哈希表中最有可能容纳的元素数量,在初始化哈希表时给予合适的值进行构造,以免没必要要的扩充。
Hashtable 类是一个类型松耦合的数据结构,开发人员能够指定任意的类型做为 Key 或 Item。当 .NET 引入泛型支持后,类型安全的 Dictionary<K,T> 类出现。Dictionary<K,T> 使用强类型来限制 Key 和 Item,当建立 Dictionary<K,T> 实例时,必须指定 Key 和 Item 的类型。
Dictionary<keyType, valueType> variableName = new Dictionary<keyType, valueType>();
若是继续使用上面描述的社保号和员工的示例,咱们能够建立一个 Dictionary<K,T> 的实例:
Dictionary<int, Employee> employeeData = new Dictionary<int, Employee>();
这样咱们就能够添加和删除员工信息了。
1 // Add some employees 2 employeeData.Add(455110189) = new Employee("Scott Mitchell"); 3 employeeData.Add(455110191) = new Employee("Jisun Lee"); 4 5 // See if employee with SSN 123-45-6789 works here 6 if (employeeData.ContainsKey(123456789))
Dictionary<K,T> 与 Hashtable 的不一样之处还不止一处。除了支持强类型外,Dictionary<K,T> 还采用了不一样的冲突解决策略(Collision Resolution Strategy),这种技术称为连接技术(chaining)。
前面使用的探查技术(probing),若是发生冲突,则将尝试列表中的下一个位置。若是使用二度哈希(rehashing),则将致使全部的哈希被从新计算。而连接技术(chaining)将采用额外的数据结构来处理冲突。Dictionary<K,T> 中的每一个位置(slot)都映射到了一个链表。当冲突发生时,冲突的元素将被添加到桶(bucket)列表中。
下面的示意图中描述了 Dictionary<K,T> 中的每一个桶(bucket)都包含了一个链表以存储相同哈希的元素。
上图中,该 Dictionary 包含了 8 个桶,也就是自顶向下的黄色背景的位置。必定数量的 Employee 对象已经被添加至 Dictionary 中。若是一个新的 Employee 要被添加至 Dictionary 中,将会被添加至其 Key 的哈希所对应的桶中。若是在相同位置已经有一个 Employee 存在了,则将会将新元素添加到列表的前面。
向 Dictionary 中添加元素的操做涉及到哈希计算和链表操做,但其仍为常量,渐进时间为 O(1)。
对 Dictionary 进行查询和删除操做时,其平均时间取决于 Dictionary 中元素的数量和桶(bucket)的数量。具体的说就是运行时间为 O(n/m),这里 n 为元素的总数量,m 是桶的数量。但 Dictionary 几乎老是被实现为 n = O(m),也就是说,元素的总数毫不会超过桶的总数,因此 O(n/m) 也变成了常量 O(1)。
参考资料
本篇文章《经常使用数据结构及复杂度》由 Dennis Gao 发表自博客园,任何未经做者赞成的爬虫或人为转载均为耍流氓。