数据结构,你还记得吗(中)

2000年6月,微软公司发布了一种新的编程语言C#,主要由安德斯·海尔斯伯格(Anders Hejlsberg)主持开发,它是第一个面向组件的编程语言,其源码会编译成msil(中间语言)再运行。
  C#是一种安全的、稳定的、简单的、优雅的,由C和C++衍生出来的面向对象的编程语言。它在继承C和C++强大功能的同时去掉了一些它们的复杂特性(例如没有宏以及不允许多重继承)。C#综合了VB简单的可视化操作和C++的高运行效率,以其强大的操作能力、优雅的语法风格、创新的语言特性和便捷的面向组件编程的支持成为.NET开发的首选语言。
  接下来,我会介绍C#的数据结构。
>跟上一篇《[数据结构,你还记得吗(上)](https://www.cnblogs.com/zhan520g/p/10201822.html)》目录进行一一对应讲解C#中各种数据结构,以此来提升理解。

数组

同一类型和不同类型的多个对象

  • 同一类型多个对象
    • 可以使用集合和数组管理。
    • C#用特殊的记号声明、初始化和使用数组。
    • Array类在后台发挥作用,它为数组中元素的排序和过滤提供了几个方法。
    • 使用枚举器,可以迭代数组中的所有元素。
  • 不同类型多个对象
    • 可以使用Tuple(元组)类型管理。

数组类型

  • 一维数组
  • 多维数组
  • 锯齿数组
        多维数组,行和列是固定的:
        int[][] arrMore=new int[3][6];

        锯齿数组只要在第一个方括号设置行数,每行的个数是可变的。
        int[][] jagged=new int[3][];
        jagged[0]=new int[2]{1,2};
        jagged[1]=new int[6]{1,2,3,4,5,6};
        jagged[2]=new int[3]{1,2,3};

数组演变

1. Array

  Array 类是 C# 中所有数组的基类,它是在 System 命名空间中定义(System.Array)。Array 类提供了各种用于数组的属性和方法。
用方括号[] 声明数组是C#中使用Array类的表示法。在后台使用C#语法,会创建一个派生自抽象基类Array的新类。这样,就可以使用Array类为每个C#数组定义的方法和属性了。

  • 数组存储在连续的内存上。
  • 数组的内容都是相同类型。
  • 数组可以直接通过下标访问。

 创建一个新的数组时将在 CLR 托管堆中分配一块连续的内存空间,来存放数量为n,类型为所声明类型的数组元素。如果类型为值类型,则将会有n个未装箱的该类型的值被创建。如果类型为引用类型,则将会有n个相应类型的引用被创建。

  • 优点
    由于是在连续内存上存储的,所以它的索引速度非常快,访问一个元素的时间是恒定的也就是说与数组的元素数量无关,而且赋值与修改元素也很简单。
  • 缺点
    由于是连续存储,所以在两个元素之间插入新的元素就变得不方便。而且就像上面的代码所显示的那样,声明一个新的数组时,必须指定其长度,这就会存在一个潜在的问题,那就是当我们声明的长度过长时,显然会浪费内存,当我们声明长度过短的时候,则面临这溢出的风险。有点投机,针对这种缺点,引出了ArrayList。

2. ArrayList

  为了解决数组创建时必须指定长度以及只能存放相同类型的缺点而推出的数据结构。ArrayList是System.Collections命名空间下的一部分,所以若要使用则必须引入System.Collections。正如上文所说,ArrayList解决了数组的一些缺点。

  • 优点
  1. 不必在声明ArrayList时指定它的长度,这是由于ArrayList对象的长度是按照其中存储的数据来动态增长与缩减的。
  2. ArrayList可以存储不同类型的元素。这是由于ArrayList会把它的元素都当做Object来处理。因而,加入不同类型的元素是允许的。
  • 缺点
  1. ArrayList不是类型安全的。因为把不同的类型都当做Object来做处理,很有可能会在使用ArrayList时发生类型不匹配的情况。
  2. 数组存储值类型时并未发生装箱,但是ArrayList由于把所有类型都当做了Object,所以不可避免的当插入值类型时会发生装箱操作,在索引取值时会发生拆箱操作。这么多缺点,当然不能忍,这开始引出了List泛型List 

3. List泛型

  为了解决ArrayList不安全类型与装箱拆箱的缺点,所以出现了泛型的概念,作为一种新的数组类型引入。也是工作中经常用到的数组类型。和ArrayList很相似,长度都可以灵活的改变,最大的不同在于在声明List集合时,我们同时需要为其声明List集合内数据的对象类型,这点又和Array很相似,其实List内部使用了Array来实现,内部的容量成本扩展。

  • 优点
  1. 即确保了类型安全。
  2. 也取消了装箱和拆箱的操作。
  3. 它融合了Array可以快速访问的优点以及ArrayList长度可以灵活变化的优点。
  • 缺点
  1. 由于内部使用Array实现,所以同样继承了Array的缺点,在两个元素之间插入新的元素就变得不方便。 由此引出链表的概念。

4. 其他的列表

有序列表 SortedList<Tkey,TElement> 只允许每个键有一个对应的值,如果需要每个键对应多个值,就需要使用Lookup<Tkey,TElement>

动态创建数组

  Array类是一个抽象类,所以不能使用构造函数来创建数组。但除了可以使用C#语法创建数组实例之外,还可以使用静态方法CreateInstance()创建数组。如果事先不知道元素的类型,该静态方法就非常有用,因为类型可以作为Type对象传递给CreateInstance()方法。

例如:
  Array arr=Array.CeateInstance(typeof(int),5);
  for(int i=0;i<5;i++)
   { 
     arr.SetVaule(i,i);
   }
  for(int i=0;i<5;i++)
   { 
     int  vaule=arr.getVaule(i);
   }


羽毛球筒

Stack

  堆栈(Stack)代表了一个后进先出的对象集合。当您需要对各项进行后进先出的访问时,则使用堆栈。当您在列表中添加一项,称为推入元素,当您从列表中移除一项时,称为弹出元素。

  • 栈以及泛型栈 
public class Stack<T> : IEnumerable<T>, ICollection, IEnumerable

public class Stack : ICollection, IEnumerable, ICloneable
属性 描述
Count 获取 Stack 中包含的元素个数
方法 描述
Pop public virtual object Pop();移除并返回在 Stack 的顶部的对象
push public virtual void Push(object obj);向 Stack 的顶部添加一个对象
peek public virtual object Peek();返回在 Stack 的顶部的对象,但不移除它
ToArray public virtual object[] ToArray();创建数组并将堆栈元素复制到其中
Contains public virtual bool Contains(object obj);判断一个元素是否在栈中
Clear public virtual void Clear();从 Stack 中移除所有的元素。

队列

水管子

Queue

队列(Queue)代表了一个先进先出的对象集合。当您需要对各项进行先进先出的访问时,则使用队列。当您在列表中添加一项,称为入队,当您从列表中移除一项时,称为出队。

  • 泛型队列
public class Queue<T> : IEnumerable<T>, IEnumerable, IReadOnlyCollection<T>, ICollection
属性 描述
Count 获取 Queue 中包含的元素个数
方法 描述
Clear public virtual void Clear(); 从 Queue 中移除所有的元素。
Contains public virtual bool Contains( object obj ); 判断某个元素是否在 Queue 中。
Dequeue public virtual object Dequeue();移除并返回在 Queue 的开头的对象。
Enqueue public virtual void Enqueue( object obj ); 向 Queue 的末尾添加一个对象。
ToArray public virtual object[] ToArray();复制 Queue 到一个新的数组中。
TrimToSize public virtual void TrimToSize();设置容量为 Queue 中元素的实际个数。

链表

单链表

  • 啥是单链表?
     单链表是一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。这组存储单元既可以是连续的,也可以是不连续的。
     链表中的数据是以结点来表示的,每个结点的构成:元素(数据元素的映象) + 指针(指示后继元素存储位置),元素就是存储数据的存储单元,指针就是连接每个结点的地址数据。
  • 链表的结点结构
    ┌───┬───┐
    │data│next │
    └───┴───┘
     data域--存放结点值的数据域[元素]
     next域--存放结点的直接后继的地址(位置)的指针域(链域)[指针]
实现方式
public class Node<T>
{
    public T Data { set; get; }          //数据域,当前结点数据
    public Node<T> Next { set; get; }    //位置域,下一个结点地址

    public Node(T item)
    {
        this.Data = item;
        this.Next = null;
    }

    public Node()
    {
        this.Data = default(T);
        this.Next = null;
    }
}
  • 优缺点
  1. 既然链表最大的特点就是存储在内存的空间不一定连续,那么链表相对于数组最大优势和劣势就显而易见了。
  2. 向链表中插入或删除节点无需调整结构的容量。因为本身不是连续存储而是靠各对象的指针所决定,所以添加元素和删除元素都要比数组要有优势。
  3. 链表适合在需要有序的排序的情境下增加新的元素,这里还拿数组做对比,例如要在数组中间某个位置增加新的元素,则可能需要移动移动很多元素,而对于链表而言可能只是若干元素的指向发生变化而已。
  4. 有优点就有缺点,由于其在内存空间中不一定是连续排列,所以访问时候无法利用下标,而是必须从头结点开始,逐次遍历下一个节点直到寻找到目标。所以当需要快速访问对象时,数组无疑更有优势。

综上,链表适合元素数量不固定,需要两端存取且经常增减节点的情况。

请转到《数据结构:单链表》查看更详细内容!

双向链表

  LinkedListC#封装的是一个双向链表,其元素会指向它前面和后面的元素。这样,通过移动到下一个元素可以正向遍历链表,通过移动到前一个元素可以反向遍历链表。

链表在存储元素时,不仅要存储元素的值,还必须存储每个元素的下一个元素和上一个元素的信息。这就是LinkedList包含LinkedListNode类型的元素的原因。使用LinkedListNode,可以获得列表中的下一个和上一个元素。LinkedListNode定义了属性List,Next,Previous和Value。List属性返回与节点相关的LinkedList对象。Next和Previous属性用于遍历链表,访问当前节点之后和之前的节点。Value属性返回与节点相关的元素,其类型是T。
  链表的优点是,如果将元素插入到列表的中间位置,使用链表就会很快。在插入一个元素时,只需要修改上一个元素的Next引用和下一个元素的Previous引用,使它们引用所插入的元素。在List中,插入一个元素,需要移动该元素后面的所以元素。
  链表的缺点是,链表元素只能一个接一个的访问,这需要较长时间来查找位于链表中间或尾部的元素。
LinkedList类定义的成员可以访问链表中的第一个和最后一个元素(First和Last);
  在指定位置插入元素:AddAfter(),AddFirst()和AddLast();
  删除指定位置的元素:Remove(),RemoveFirst(),RemoveLast();
  搜索:Find(),FindLast()。


菜单树

C#中没有实现树的具体类,一般可以通过自己实现。
结点树包含:父结点(根结点的父结点为null)、子结点(List集合)、数据对象。

请转到《 数据结构:树》查看更详细的内容!


  图状结构简称图,是另一种非线性结构,它比树形结构更复杂。树形结构中的结点是一对多的关系,结点间具有明显的层次和分支关系。每一层的结点可以和下一层的多个结点相关,但只能和上一层的一个结点相关。而图中的顶点(把图中的数据元素称为顶点)是多对多的关系,即顶点间的关系是任意的,图中任意两个顶点之间都可能相关。也就是说,图的顶点之间无明显的层次关系,这种关系在现实世界中大量存在。因此,图的应用相当广泛,在自然科学、社会科学和人文科学等许多领域都有着非常广泛的应用。

c#没有实现图的数据结构,但是可以自己实现,参考如下
请转到《数据结构:图》查看更详细内容!


字典树

  字典树,又称为单词查找树,Tire数,是一种树形结构,它是一种哈希树的变种。

基本性质

  • 根节点不包含字符,除根节点外的每一个子节点都包含一个字符
  • 从根节点到某一节点。路径上经过的字符连接起来,就是该节点对应的字符串
  • 每个节点的所有子节点包含的字符都不相同

应用场景

典型应用是用于统计,排序和保存大量的字符串(不仅限于字符串),经常被搜索引擎系统用于文本词频统计。

c#也没有实现字典树,可以自己实现,参考如下
请转到《数据结构:字典树》查看更详细内容!
请转到《字典树(Trie树)实现与应用》查看更详细内容!

利用字符串的公共前缀来减少查询时间,最大限度的减少无谓的字符串比较,查询效率比哈希树高。


散列表(哈希表)

哈希表(HashTable)简述

  Hashtable是System.Collections命名空间提供的一个容器,用于处理和表现类似keyvalue的键值对,其中key通常可用来快速查找,同时key是区分大小写;value用于存储对应于key的值。Hashtable中keyvalue键值对均为object类型,所以Hashtable可以支持任何类型的keyvalue键值对.

什么情况下使用哈希表

  • 某些数据会被高频率查询
  • 数据量大
  • 查询字段包含字符串类型
  • 数据类型不唯一

使用方法

  • 哈希表需要使用的namespace
using System.Collections;
using System.Collections.Generic;
  • 哈希表的基本操作:
//添加一个keyvalue键值对:
HashtableObject.Add(key,value);

//移除某个keyvalue键值对:
HashtableObject.Remove(key);

//移除所有元素:           
HashtableObject.Clear(); 

// 判断是否包含特定键key:
HashtableObject.Contains(key);
  • 遍历哈希表
 遍历哈希表需要用到DictionaryEntry Object,代码如下:
for(DictionaryEntry de in ht) //ht为一个Hashtable实例
{
   Console.WriteLine(de.Key);  //de.Key对应于keyvalue键值对key
   Console.WriteLine(de.Value);  //de.Key对应于keyvalue键值对value
}

请转到《数据结构:哈希表》查看更详细内容!

字典 Dictionary

表示索引鍵和值的集合。

[System.Runtime.InteropServices.ComVisible(false)]
[System.Serializable]
public class Dictionary<TKey,TValue> : System.Collections.Generic.ICollection<System.Collections.Generic.KeyValuePair<TKey,TValue>>, System.Collections.Generic.IDictionary<TKey,TValue>, System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey,TValue>>, System.Collections.Generic.IReadOnlyCollection<System.Collections.Generic.KeyValuePair<TKey,TValue>>, System.Collections.Generic.IReadOnlyDictionary<TKey,TValue>, System.Collections.IDictionary, System.Runtime.Serialization.IDeserializationCallback, System.Runtime.Serialization.ISerializable

  因为字典的实现方式就是哈希表的实现方式,只不过字典是类型安全的,也就是说当创建字典时,必须声明key和item的类型。

  • 优势
    1. 各种方便操作
    2. 因为指定了类型,所以安全
  • 缺点
    以空间换时间。通过更多的内存开销来满足我们对速度的追求。在创建字典时,我们可以传入一个容量值,但实际使用的容量并非该值。而是使用“不小于该值的最小质数来作为它使用的实际容量,最小是3。”(老赵),当有了实际容量之后,并非直接实现索引,而是通过创建额外的2个数组来实现间接的索引,即int[] buckets和Entry[] entries两个数组(即buckets中保存的其实是entries数组的下标),这里就是第二条字典与哈希表的区别,还记得哈希冲突吗?对,第二个区别就是处理哈希冲突的策略是不同的!字典会采用额外的数据结构来处理哈希冲突,这就是刚才提到的数组之一buckets桶了,buckets的长度就是字典的真实长度,因为buckets就是字典每个位置的映射,然后buckets中的每个元素都是一个链表,用来存储相同哈希的元素,然后再分配存储空间。

    因此,我们面临的情况就是,即便我们新建了一个空的字典,那么伴随而来的是2个长度为3的数组。所以当处理的数据不多时,还是慎重使用字典为好,很多情况下使用数组也是可以接受的。

结论:Dictionary<K,V>是泛型的,当K或V是值类型时,其速度远远超过Hashtable。

由于 Hashtable 和 Dictionary 同时存在, 在使用场景上必然存在选择性, 并不任何时刻都能相互替代.

  1. 单线程程序中推荐使用 Dictionary, 有泛型优势, 且读取速度较快, 容量利用更充分.
  2. 多线程程序中推荐使用 Hashtable, 默认的 Hashtable 允许单线程写入, 多线程读取, 对 Hashtable 进一步调用 Synchronized() 方法可以获得完全线程安全的类型. 而 Dictionary 非线程安全, 必须人为使用 lock 语句进行保护, 效率大减.
  3. Dictionary 有按插入顺序排列数据的特性 (注: 但当调用 Remove() 删除过节点后顺序被打乱), 因此在需要体现顺序的情境中使用 Dictionary 能获得一定方便.

哈希 Hashing

关键字和它在表中存储位置之间存在一种函数关系。这个函数我们称为为哈希函数。

  hash : 翻译为“散列”,就是把任意长度的输入,通过散列算法,变成固定长度的输出,该输出就是散列值。
这种转换是一种压缩映射,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值,由此引出hash冲突。
简单的说就是一种将任意长度的消息压缩到固定长度的消息的函数。

  • hash冲突
    就是键(key)经过hash函数得到的结果作为地址去存放当前的键值对(key-value)(这个是hashmap的存值方式),但是却发现该地址已经有人先来了,一山不容二虎,就会产生冲突。这个冲突就是hash冲突了。如果两个不同对象的hashCode相同,这种现象称为hash冲突。

  • 解决hash冲突的办法

  1. 开发定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)

这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:
Hi=(H(key)+di)% m i=1,2,…,n
其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:

  1. 线性探测再散列
  2. 二次(平方)探测再散列
  3. 伪随机探测再散列
  1. 再哈希法

这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

  1. 链地址法

将所有哈希地址相同的都链接在同一个链表中 ,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
hashmap就是用此方法解决冲突的。

  1. 建立一个公共溢出区

将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

开放散列(open hashing)/ 拉链法(针对桶链结构)

  • 优点
  1. 对于记录总数频繁可变的情况,处理的比较好(也就是避免了动态调整的开销)
  2. 删除记录时,比较方便,直接通过指针操作即可
  • 缺点
  1. 存储的记录是随机分布在内存中的,这样在查询记录时,相比结构紧凑的数据类型(比如数组),哈希表的跳转访问会带来额外的时间开销 。
  2. 如果所有的 key-value 对是可以提前预知,并之后不会发生变化时(即不允许插入和删除),可以人为创建一个不会产生冲突的完美哈希函数(perfect hash function),此时封闭散列的性能将远高于开放散列。
  3. 由于使用指针,记录不容易进行序列化(serialize)操作。

封闭散列(closed hashing)/ 开放定址法

  • 优点
  1. 记录更容易进行序列化(serialize)操作
  2. 如果记录总数可以预知,可以创建完美哈希函数,此时处理数据的效率是非常高的
  • 缺点
  1. 存储记录的数目不能超过桶数组的长度,如果超过就需要扩容,而扩容会导致某次操作的时间成本飙升,这在实时或者交互式应用中可能会是一个严重的缺陷。
  2. 使用探测序列,有可能其计算的时间成本过高,导致哈希表的处理性能降低 。
  3. 删除记录时,比较麻烦。比如需要删除记录a,记录b是在a之后插入桶数组的,但是和记录a有冲突,是通过探测序列再次跳转找到的地址,所以如果直接删除a,a的位置变为空槽,而空槽是查询记录失败的终止条件,这样会导致记录b在a的位置重新插入数据前不可见,所以不能直接删除a,而是设置删除标记。这就需要额外的空间和操作。
    引用来自《解决hash冲突的三个方法

总结

综上所述,找了相关的文档之后,发现C#本身没有封装部分数据结构,可能是让大家自己发挥,也可能跟它当初设计的原因有关,因为它不是专们为处理数据而诞生的。写完之后,发现写到这里还不够,于是将标题改为《数据结构,你还记得吗(中)》,接下来还要继续《数据结构,你还记得吗(下)》 未完待续!

其他系列的C#数据结构参考《C# 数据结构

补充浅薄的关系线

数组

数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的索引查找复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表

链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

哈希表

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表综合以上两个优点,但同时还有一个缺点,就是在连续查询的时候性能非常差。那怎么寻址容易,插入,删除也容易,连续查询也容易呢? 这个就引出了数据库底层采用的存储数据结构,B+树。

posted @ 2019-01-04 09:40  天空的湛蓝  阅读(734)  评论(1编辑  收藏  举报