Unity 3D 中常用的数据结构

常用的数据结构复杂度
数据结构AddDeleteFindGetByIndex
ArrayO(n)O(n)O(n)O(1)
ArrayListO(1)O(n)O(n)O(1)
List<T>O(1)O(n)O(n)O(1)
LinkedList<T>O(1)O(n)O(n)O(n)
StackO(1)O(1)--
QueueO(1)O(1)--
HashTableO(1)O(1)O(1)-
Dictionary<K,T>O(1)O(1)O(1)-
HashSet<T>O(1)O(1)O(1)-
SortedSet<T>O(Logn)O(Logn)O(Logn)-

Array 数组

数组是最简单的数据结构之一。其具有一下3个特点。

(1)数组存储在连续的内存上。

(2)数组的元素都是相同类型或者类型的衍生类型。因此数组又被认为是同质数据结构。

(3)数组可以直接通过下标访问。array[i]。

一个数组的常规操作主要两种:

(1)分配存储空间。声明一个新的数组:int[] arr = new int[5]。

(2)访问数组中的元素数据。int i = arr[0]。

创建一个新的数组时,将在Mono运行的托管堆中分配一块连续的内存空间来盛放数量为 size ,类型为所声明类型的数组元素。

由于是在连续内存上存储的,所以它的索引速度非常快,访问一个元素的时间是恒定的。也就是说与数组的元素数量无关,而且赋值与修改元素也很简单。

由于是在连续内存上存储的,所以在两个元素之间插入新的元素就变得不方便。而且声明一个新的数组时,必须指定其长度或初始化其元素,就会存在一个潜在的问题。那就是当声明的长度过长时,显然会浪费内存,当声明长度过短时,则面临溢出的风险。

ArrayList 数组

为了解决 Array 创建时必须指定长度,以及只能存放相同类型的缺点而推出的数据结构。

ArrayList 解决了 Array 的一些缺点:

不必在声明 ArrayList 时指定它的长度,这是由于 ArrayList 对象的长度是按照其中存储的数据来动态增长与缩减的。

ArrayList 可以存储不同类型的元素。这是由于 ArrayList 会把它的元素都当作 Obect 来处理。因此加入不同类型的元素是允许的。

问题:

ArrayList 是类型不安全的。因为把不同的类型都当作 Object 来做处理,很有可能会在使用 ArrayList 时发生类型不匹配的情况。

数组存储值类型时并未发生装箱,但是 ArrayList 由于把所有类型都当作了 Object ,所以不可避免的是当插入值类型时会发生装箱操作,在索引值时会发生拆箱操作。因此在频繁读写 ArrayList 时会产生额外的开销,导致性能下降。

List<T>数组

为了解决 ArrayList 不安全类型与装箱拆箱的缺点,在C# 引入泛型概念后,引入新的数组类型 List<T> 。

和 ArrayKist 很相似,长度都可以灵活变化。而最大的不同在于声明 List 集合时,同时需要为其声明 List 集合内数据的对象类型,这点和 Array 很相似。

其实 List<T> 内部使用了 Array 来实现,但它隐藏了这些实现的复杂性。当创建 List<T> 时无需指定初始长度,当添加元素到 List<T> 时,也无需关心数组大小的调整问题。

使用 List<T> 有以下3点好处:

(1)即确保了类型安全。因此 List<T> 是类型安全的。

(2)取消了装箱和拆箱的操作,以及由于引入泛型而无需运行时类型检查。因此 List<T> 是高性能的。

(3)融合了 Array 可以快速访问的优点,以及 ArrayList 长度可以灵活变化的优点。

LinkedList<T> 链表

所谓链表即每一个元素都指向下一个元素,形成了一个链。

在创建一个链表时,仅需持有第一个结点,即头节点 “Head” 的引用,这样通过逐个访问下一个节点 “next” 即可遍历到链表中所有的节点。

在运行时间方面,链表与数组有着同样的线性运行时间 O(n) 。如果要查找节点 P ,则必须从头节点 head 开始查找,逐个访问下一个节点直到找到目标标点 P 。

同样,从链表中删除一个节点的运行时间也是 O(n) 。因为在删除之前仍然需要从 head 节点开始向下逐个遍历以找到要被删除的目标节点。而删除操作本身则变得简单,即让被删除节点的前一个节点的 next 指针指向被删除节点的 next 节点即可。

向链表中插入一个新的节点的运行时间则取决于链表是否是有序的。如果链表不需要保持顺序,则插入操作就是常量时间 O(1) ,可以在链表的头部或者添加新的节点。而如果需要保持链表的顺序结构,则需要查找新的节点被插入的位置,这使得需要从链表的头部 head 开始逐个遍历结点,结果就是操作变成了 O(n) 。

链表中内容的顺序是由各个对象的指针所决定的,这就决定了其内容的排序不一定是连续的,所以不能通过下标来访问。

因此如果考虑到这一点,在需要更快的查找操作的情景下,使用数组可能是更好的选择。

而使用链表最主要的优势就在于向链表中插入或删除节点时,无需考虑调整结构的容量。

链表的另一个优点就是特别适合以排序的顺序动态地添加新元素。如果要在数组中间的某个位置添加新元素,不仅要移动所有其余的元素,甚至还有可能需要重新调整容量。

数组适合数据的数量是有上限,且需要快速访问其元素内容的情况,而链表适合元素数量不固定且需要经常增删结点的情况。

LinkedList<T> 链表类,LinkedListNode<T> 类用来代表链表中的结点, LinkedList<T> 对象中的每个节点都属于 LinkedListNode<T> 类型。由于 LinkedList<T> 是双向链表,因此每个节点向前指向 Next ,向后指向 Previous 节点。

LinkedList<T> 类的插入和移除的运算复杂度都是 O(1) 。由于该列表还维护内部计数,因此获取 Count 属性的运算复杂度也是 O(1)。

常见操作:

 AddFirst ,将一个新结点加入该链表的第一个结点的位置;RemoveFirst ,将第一个结点移除;AddLast ,将一个新节点加入该链表最后一个节点的位置;以及在某个节点前后插入新的节点的 AddBefore 和 AddAfter 方法。

队列 Queue<T> , 栈 Stack<T> 

队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(head)进行删除操作,而在表的后端(tail)进行插入操作。队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。根据队列的这个特点,当需要使用先进先出顺序(FIFO)的数据结构时,我们会采用这种数据结构。

Queue<T> 类提供了 Enqueue 和 Dequeue 方法来实现对 Queue<T> 的入列和出列操作。

在 Queue<T> 内部,有一个存放类型为 T 的对象的环形数组,并通过 head 和 tail 变量来指向该数组的头和尾。当使用 Enqueue 方法将新的元素入列时,会判断队列的长度是否足够。若不足,则依据增长因子来增加容量,例如当初始的2.0时,则队列容量增长2倍。

需要说明的一点是, Queue<T> 接受 null 作为引用类型的有效值,并且允许有重复的元素。而且在默认情况下, Queue<T> 的初始化容量是 32 ,但是也可以通过构造函数指定容量。

栈(Stack)又名堆栈,它和队列一样是一种运算受限制的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一段被称为栈顶,相对的,把另一端称为栈底。向一个栈插入新元素又称左进栈,入栈,压栈,它是把新元素放到栈顶元素的上面,使其成为新的栈顶元素。从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除,使其相邻的元素成为新的栈顶元素。

根据栈的这个特点,当需要使用后进先出顺序(LIFO)的数据结构时,我们的选择往往是栈。

Stack<T> 类提供了 Push 和 Pop 方法来实现对 Stack<T> 的出栈和入栈操作。

 Stack<T> 类的内部同样使用了数组来实现。 Count 是 Stack<T> 可以包含的元素数。如果 Stack<T> 中元素的数量 Count 小于其容量,则 Push 操作的复杂度为O(1) 。如果容量需要被扩展,则要根据需要来重新分配内部数组以自动增大容量,在这种情况下 Push 操作的复杂度变为O(n) 。而出栈操作 Pop 操作的复杂度始终为O(1)。

哈希表 Hash Table 和 字典 Dictionary<K,T> 

哈希表也叫散列表,是根据关键码/值(Key/Value)而直接进行访问的数据结构。也就是说,它通过把关键码/值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫哈希函数或散列函数,存放记录的数组就叫哈希表。

假设游戏中有一种英雄单位,需要使用英雄 ID 作为唯一标识进行存储。英雄 ID 格式 DDD-DD-DDDD( D 的范围为数字 0~9 )

如果使用 Array 数组来存储这些英雄信息,查询 ID 为 111-22-3333 的游戏单位,则将会尝试遍历数组的所有位置,即执行渐进时间为 O(n) ,的查询操作。好一点的办法是将 ID 排序,使查询渐进时间降低到 O(log(n)) ,但在理想情况下,我们希望查询渐进时间为 O(1) 。

一种方案是建立一个大数组,范围从 000-00-0000 到 999-99-9999 。这种方案的缺点是浪费空间。

第二种方案就是用哈希函数压缩序列。选择使用 ID 的后四位作为索引,以减少区间的跨度。这样范围将从 0000 到 9999 。在数学上从 9 位数转换为 4 位数的方式称为哈希转换。可以将一个数组的索引空间压缩至相应的哈希表。

在哈希函数计算中常见的一种行为----哈希冲突。

当要添加新元素到哈希表中时,哈希冲突是导致操作被破坏的一个重要因素。如果没有冲突发生,则元素被成功插入。如果发生了冲突,则需要判断冲突的原因。因此哈希冲突提高了操作的代价,哈希表的设计目标就是要尽可能减低冲突的发生。

就像处理任何可能潜在的问题时所考虑的一样,在处理哈希冲突时,也有两种思路---避免和解决。

冲突避免机制

冲突解决机制

避免哈希冲突的一个方法就是尽可能选择合适的哈希函数。而哈希函数中的冲突发生的机率与数据的分布有关。

例如,如果单位 ID 的后 4 位是随机分布的,则使用后 4 位数比较合适。单如果后 4 位是以单位的某种特性来分配的,则显然按照这种 方式,数据不是均匀分布的,则选择后 4 位会造成大量的冲突。

我们将这种选择合适的哈希函数的方法称为冲突避免机制,目的是尽可能减少哈希冲突发生的机率,而并非在哈希冲突发生时去解决冲突。

因此当冲突发生时,还需要有解决冲突的方案和策略,这些方案和策略称为冲突解决机制。其中一种方法就是将要插入的元素放到另外一个块空间中,因为相同的哈希位置已经被占用。

通常采用的冲突解决策略为 开放寻址法,所有的元素仍然都放在哈希表内的数组中。

开放寻址法最简单的一种实现就是线性探查,有以下 3 个步骤。

(1)当插入新的元素时,使用哈希函数在哈希表中定位元素位置。

(2)检查哈希表中该位置是否已经存在元素。如果该位置内容为空,则插入并返回,否则进行步骤 3 的操作。

(3)如果该位置为 i ,则检查 i + 1 是否为空。如果已被占用,则检查 i + 2 。以此类推,直到找到一个内容为空的位置。

线性探查方式虽然简单,但并不是解决冲突的最好策略,因为它会导致同类哈希的聚集。这会导致搜索哈希表时,冲突依然存在。

针对线性探查方式所存在的问题,一种改进的方式为二次探查,即每次检查位置空间的步长为平方倍数。也就是说,如果位置 S 被占用,则首先检查 S + 12 处,然后检查 S - 12 ,S + 22 ,S -22,S+32 ...以此类推。尽管如此,二次探查同样也会导致同类哈希聚集问题。

C# 中的 Hashtable 类的实现,要求添加元素时不仅要提供元素,还要为该元素提供一个键。可以通过 key 作为索引来查找 Item

在 C# 中 Hashtable 类所实现的哈希函数很复杂,哈希函数必须返回一个序数。Hashtable 类可以接受任意类型的值作为 Key ,这都要归功于 GetHashCode 方法,一个定义在 System.Object 中的方法,默认实现将返回一个唯一的整数,并且保证在对象的生命周期内保持不变。

Hashtable 类中的哈希函数定义的代码如下:

H(key) = [GetHash(key) + 1 + (((GetHash(key) >> 5 ) + 1)%(hashsize - 1))]%hashsize

这里的 GetHash(key) 默认是调用 key 的 GetHashCode 方法以获取返回的哈希值。hashsize 指的是哈希表的长度。因为要进行求模,所以最后的结果H(key) 的范围在 0 至 hashsize - 1 之间。

当在哈希表中添加或获取一个元素时,会发生哈希冲突。前面简单介绍了两种冲突解决策略,即线性探查和二次探查。

在 Hashtable 类中则使用一种完全不同的技术,称为二度哈希也叫双重哈希。

二度哈希的工作原理为:有一个包含一组哈希函数 H1....Hn 的集合。当需要从哈希表中添加或获取元素时,首先使用哈希函数 H1.如果导致冲突则尝试使用H2...以此类推,直到Hn。所有的哈希函数都与H1十分相似,不同的是它们选用不同的额乘法因子

Hk(key) = [GetHash(key) + k + (((GetHash(key) >> 5 ) + 1)%(hashsize - 1))]%hashsize

当使用二度哈希时,重要的是在执行了 hashsize 次探查后,哈希表中的每一个位置都有且只有一次被访问到。

空间扩充的步骤:

(1)Hashtable 类实例的位置空间几乎被翻倍。准确的说,位置空间值从当前的素数值增加到下一个最大的素数值。

(2)因为二度哈希时,Hashtable 类实例中的所有元素值将依赖于 Hashtable 类实例的位置空间值,所有 Hashtable 类实例中保存的所有值也需要重新二度哈希。

对 Hashtable 类实例的扩充是以性能损耗为代价。

Hashtable 类不是泛型的,其元素属于Object 类型。

C# 引入了泛型的概念,和 Hashtable 类似的但是类型是安全的,那就是Dictionary<K,T> 。

除了支持强类型外,Dictionary<K,T> 还采用了不同的冲突解决策略,链接技术。

链接技术将采用额外的数据结构来处理冲突。Dictionary<K,T> 中的每个位置(slot)都映射到了一个链表。当冲突发生时,冲突的元素将被添加到桶(bucket)列表中。

对Dictionary<K,T> 进行查询和删除操作时,其平均时间取决于Dictionary 中元素的数量和桶的数量。具体的说就是运行时间为O(n/m),这里n为元素的总数量,m是桶的数量。单Dictionary几乎总是被实现为n=O(m),也就是说,元素的总数绝不会超过桶的总数,所以O(n/m)也变成了常量O(1)。

缺点就是空间,以空间换时间,通过更多的内存开销来满足对速度的追求。在创建字典时,可以传入一个容量值,但实际使用的容量并非该值。而是使用不小于该值的最小质数作为它使用的实际容量,容量的最小值是3。

当有了实际容量后,并非直接实现索引,而是通过创建额外的数组来实现间接索引,即int[] buckets和Entry[]entries两个数组。

所以当处理的数据不多时,还是慎重使用字典为好,在很多情况下使用数组也是可以的。

  • 3
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值