“你每次都选择合适的数据结构了吗?” - Jeffery Zhao
ICollection<T>继承IEnumerable<T>。在其基础上,增加了Add,Remove等方法,可以修改集合的内容。IEnumerable<T>的直接继承者还有Stack<T>和Queue<T>。
所有标准的泛型集合都实现了ICollection<T>。主要的几个继承类有IList<T>,IDictionary<K,T>,LinkedList<T>。
注意,Stack<T>和Queue<T>没有继承ICollection<T>,这是因为ICollection<T>拥有Add,Remove等方法,而栈和队列是不能随便添加删除元素的。
Stack<T>
当需要使用后进先出顺序(LIFO)的数据结构时,.NET为我们提供了Stack<T>。Stack<T> 类提供了Push和Pop方法来实现对Stack<T>的存取。
Stack<T>中存储的元素可以通过一个垂直的集合来形象的表示。当新的元素压入栈中(Push)时,新元素被放到所有其他元素的顶端。当需要弹出栈(Pop)时,元素则被从顶端移除。
Stack<T> 的默认容量是10。和Queue<T> 类似,Stack<T>的初始容量也可以在构造函数中指定。Stack<T> 的容量可以根据实际的使用自动的扩展(翻倍扩展),并且可以通过 TrimExcess方法来减少容量。
堆栈最基本的两种操作就是向堆栈内添加数据项以及从堆栈中删除数据项。Push(进栈)操作是向堆栈内添加数据项。而把数据项从堆栈内取走则用 Pop(出栈)操作。每次push进入栈的数据位于栈顶。Pop只能从栈顶取走数据。
堆栈的另外一种基本操作就是察看栈顶的数据项。Pop 操作会返回栈顶的数据项,但是此操作也会把此数据项从堆栈中移除。如果只是希望察看栈顶的数据项而不是真的要移除它,在 C#语言中有一种名为 Peek(取数)的操作可以实现。当然,此操作在其他语言和实现中可能采用其他的名称(比如 Top)。
如果Stack<T>中元素的数量Count小于其容量,则Push操作的复杂度为O(1)。如果容量需要被扩展,则 Push操作的复杂度变为 O(n),因为你需要移动已有的元素给新元素腾出空间。Pop操作的复杂度始终为O(1)。
自己实现一个栈还是比较简单的,可以借助List<T>进行存储。
Stack<T>应用一例:测试回文字符串
所谓回文是指向前和向后拼写都完全一样的字符串。例如,“dad”、“madam”以及“sees”都是回文,而“hello”就不是回文。检查字符串是否为回文的方法之一就是使用堆栈。常规算法是逐个字符的读取字符串,并且在读取时把每个字符都压入堆栈。这会产生反向存储字符串的效果。
下一步就是把堆栈内的每一个字符依次出栈,并且把它与原始字符串从开始处的对应字母进行比较。如果在任何时候发现两个字符不相同,那么此字符串就不是回文,同 时就此终止程序。如果比较始终都相同,那么此字符串就是回文。
程序实现很简单,代码留作练习。
Queue<T>
当我们需要使用先进先出顺序(FIFO)的数据结构时,.NET 为我们提供了Queue<T>。Queue<T>类提供了Enqueue和Dequeue方法来实现对Queue<T>的存取。队列的另外一个主要操作就是查看起始数据项。就像在 Stack 类中的对应操作一样,Peek 方法用来查看起始的数据项。这种方法仅仅返回数据项,而不会真的把数据项从队列中移除。
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 的值。
实现队列的方式和实现栈的方式大同小异。
实现一个带优先级的队列,只需要为队列本身加入一个优先级的属性,在入队时,必须指定一个优先级。出队时,沿着优先级别遍历队列,拥有最高级别的且排在最前的成员将会被移出队列。
IList<T>
IList<T>全部是关于定位的:它提供了一个索引器,InsertAt和RemoveAt(分别与Add,Remove相同,但可以指定位置),以及IndexOf。
注意C#没有List,只有IList,IList<T>和List<T>。其中第三个继承第二个。第一个是第二个的非泛型版本。ArrayList则继承第一个。
最常见的实现了IList<T>的数据结构是List<T>。但其并不是链表。它的内部实现是数组。靠链表实现的数据结构是LinkedList<T>。
List<T>
在大多数情况下,这都是默认的列表选择。List<T>内部是由数组来实现的。它和数组的区别在于不定长,但它们都是类型安全的。所以如果不知道集合的长度,可以选择List<T>。
插入:O(N)
删除:O(N)
按照索引器访问特定成员:O(1)
查找:O(N)
Array
Array关键字基本不会用到,通常我们都是用类型和[]来声明数组。尽管看上去很别扭,但Array其实继承自IList<T>。和List<T>相比,数组的优势在于不会浪费空间(如果你事先知道长度)。
这两个声明方法没有任何区别。在编译器看来,a和b的类型都是System.Int32[]。
Array a = new int[20]; int[] b = new int[20]; Console.WriteLine(a.GetType()); Console.ReadKey();
声明数组时必须给出长度,所以数组的初始化是很快的。数组的时间复杂度和List<T>完全相同。
插入:O(N)
删除:O(N)
按照索引器访问:O(1)
查找:O(N)
LinkedList<T>
这是内部使用双向链表来实现的数据结构。注意这个类继承自ICollection<T>,而并没有实现IList<T>,所以你不能通过索引器访问链表。使用情况通常是:当有非常多的在头尾进行的插入删除操作,却只有很少的访问操作时。(例如不需要索引器)。如果插入删除总是在中间进行,链表的性能和数组相差无几。
在链表(Linked List)中,每一个元素都指向下一个元素,以此来形成了一个链(chain)。
在创建一个链表时,我们仅需持有头节点 head 的引用,这样通过逐个遍历下一个节点 next 即可找到所有的节点。
链表与数组有着同样的查找时间 O(N)。同样,从链表中删除一个节点的渐进时间也是线性的O(n)。因为在删除之前我们仍然需要从 head 开始遍历以找到需要被删除的节点。而删除操作本身则变得简单,即让被删除节点的左节点的 next 指针指向其右节点。
向链表中插入一个新的节点的渐进时间取决于链表是否是有序的。如果链表不需要保持顺序,则插入操作就是常量时间O(1),可以在链表的头部添加新的节点。而如果需要保持链表的顺序结构,则需要查找到新节点被插入的位置,这使得需要从链表的head 开始逐个遍历,结果就是操作变成了O(N)。
双向链表LinkedList<T>:
插入:O(1) (在头尾部),O(N) (在其他位置)
删除:O(1) (在头尾部),O(N) (在其他位置)
按照索引器访问:没有索引器(因为没有实现IList<T>)
查找:O(N)
关于链表的算法面试题可谓五花八门,实现一个单向或双向链表,并实现它们的若干主要功能,是一个极好的编程练习。
IDictionary<K,T>与Dictionary<K,T>
Hashtable类是一个类型松耦合的数据结构,开发人员可以指定任意的类型作为 Key 或 Item。当 .NET 引入泛型支持后,类型安全的 Dictionary<K,T> 类出现。Dictionary<K,T> 使用强类型来限制 Key 和 Item,当创建 Dictionary<K,T> 实例时,必须指定 Key 和 Item 的类型。
字典储存键值对,并依靠键的值直接找到对应的value。查找,插入,删除速度O(1)。字典的实现原理前面已经说过了,它和哈希表的实现原理有所不同,但它最大的优势还是在于泛型。
SortedList<K,T>和SortedDictionary<K,T>
SortedList<K,T>实质上是一个不停维护的数组,维护是使之在任何时候都是排序的。
SortedDictionary<K,T>则是一个任何时候都排好序的红黑树,它和SortedList<TKey, TValue>的不同之处是在内存使用,以及插入和删除的速度:
- 比SortedDictionary<TKey, TValue>,SortedList<TKey, TValue>使用较少内存。因为SortedDictionary是树,在创建新成员时,要在堆上分配树节点。
- 假设有很多未排序的元素要一一插入这两个类中,则SortedDictionary<TKey, TValue>更快,因其平均速度为O(log n)。SortedList<TKey, TValue>仅仅在插入发生在头部时很快,而如果元素没有排序,我们不能期望插入总是发生在头部,例如插入一般发生在中间,而这时的速度为O(n)。
- 假设有很多已经排序的元素要一一插入这两个类中,则SortedList<TKey, TValue>的插入速度永远为O(1),显然要快于SortedDictionary<TKey, TValue>。
这两种数据结构都使用单独的集合公开它们的键和值。但SortedList公开的键和值的集合都实现了IList<T>,所以可以使用排序的键索引器有效的访问条目。
SortedList<string, string> books = new SortedList<string, string>(); books.Add("aladdin", "64kb@163.com"); books["aladdin"] = "haha_new";
ISet<T>
这是一个用来模拟数学中集合的接口。它提供集合的各种运算(是否为子集,交,并,补等)。集合的成员都是唯一的,不会出现超过一次。
HashSet<T>和SortedSet<T>
前者是不含值的字典,后者是不含值的SortedDictionary<TKey, TValue>。
IEnumerable<T>的派生类:小结
访问方式 |
继承自 |
特点 |
|
IEnumerable<T> |
通过ElementAt |
无 |
所有泛型集合都继承自此接口 |
ICollection<T> |
通过ElementAt |
IEnumerable<T> |
所有泛型集合都继承自此接口 提供add,
remove, insert等功能 |
LinkedList<T> |
没有索引,通过Find方法 |
ICollection<T> |
内部使用链表实现的列表 不继承自IList<T> 没有索引器 |
Dictionary<T, K> |
键值对 |
IDictionary<T> |
HashTable的泛型版本 |
IList<T> |
索引器 |
ICollection<T> |
部分泛型集合继承此接口 提供索引器 |
List<T> |
索引器 |
IList<T> |
继承了Ilist<T>(以及其他接口) |
IQueryable<T> |
通过IndexOf |
IEnumerable<T> |
从远端获得筛选之后的资料,和IEnumerable<T>不同,IQueryable<T>返回所有资料然后才进行筛选 可通过sql profiler看到区别 |
注:还有若干重要的派生类例如Concurrent类型,这些放到多线程同步中。
如何选择数据结构
在不同情况时选择恰当的数据结构,将会提升程序的性能。面试时,如果你在数据结构这一块对答如流,将会让面试官觉得你是一个基础牢固,时刻对程序性能有所意识,且重视细节的人,因为大部分人对这一块都不是十分看重。当然,数据结构除了C#实现的这些,还有各种树和图,不过在非算法工程师面试中,那些内容基本不会出现。
线性表和链表(使用最多的对象):
- Array (T[]):当元素的数量是固定的,并且需要使用索引器时。
- Linked list (LinkedList<T>):当元素的数量不是固定的,且存在大量列表的头尾添加的动作时。否则使用 List<T>。
- Resizable array list (List<T>):当元素的数量不是固定的,并且需要使用索引器时。
栈和队列(只有在模拟栈和队列时才考虑):
- Stack (Stack<T>):当需要实现 LIFO(Last In First Out)时。
- Queue (Queue<T>):当需要实现 FIFO(First In First Out)时。
哈希(需要大规模查找):
- Hash table (Dictionary<K,T>):当需要使用键值对(Key-Value)来快速添加和查找,并且元素没有特定的顺序时。有了泛型版本的字典,我们几乎永远不需要使用非泛型的HashTable。
- Tree-based dictionary (SortedDictionary<K,T>):当需要使用键值对(Key-Value)来快速添加和查找,并且元素总是需要根据 Key 来排序时。
集合(保存一组唯一的值/模拟集合运算):
- Hash table based set (HashSet<T>):当需要保存一组唯一的值,并且元素没有特定顺序时。
- Tree based set (SortedSet<T>):当需要保存一组唯一的值,并且元素总是需要排序时。
常用数据结构操作时间复杂度
这些时间复杂度都不难理解,可以很容易推断出来,而不是死记硬背。
参考: http://www.cnblogs.com/gaochundong/p/data_structures_and_asymptotic_analysis.html
http://blog.csdn.net/suifcd/article/details/42869341
Data Structure |
Add |
Find |
Delete |
GetByIndex |
Array (T[]) |
O(n),结尾则是O(1) |
O(n) (逐个比较) |
O(n),结尾则是O(1) |
O(1) |
链表 (LinkedList<T>) |
头尾的话是O(1),其他地方则是O(n) |
O(n)(逐节点查找) |
头尾的话是O(1),其他地方则是O(n) |
没有索引器 |
List<T> (和Array相同) |
O(n),结尾则是O(1) |
O(n) |
O(n),结尾则是O(1) |
O(1) |
Stack (Stack<T>) |
O(1) |
只能访问栈顶 |
O(1) 只能从栈顶删除 |
没有索引器 |
Queue (Queue<T>) |
O(1) |
只能访问队头 |
O(1) 只能从队尾删除 |
没有索引器 |
Dictionary<K,T> |
O(1)(一般来说是,如果存在哈希冲突可能会耗时多一点点) |
O(1) |
O(1) |
没有索引器 |
Tree-based dictionary (SortedDictionary<K,T>) |
O(log n)(因为要维持排序,所以插入慢了) |
O(log n) |
O(log n) (因为要维持排序,所以删除慢了) |
没有索引器 |
Hash table based set (HashSet<T>) HashSet是不含值的字典,故复杂度和字典完全相同 |
O(1) |
O(1) |
O(1) |
没有索引器 |
Tree based set (SortedSet<T>) SortedSet是不含值的SortedDictionary,故复杂度和它完全相同 |
O(log n) |
O(log n) |
O(log n) |
没有索引器 |
IEnumerable:小结
- IEnumerable及其泛型版本是所有集合的基础。它赋予集合迭代的能力。迭代是指从集合的头部,一个一个将元素拿出来,直到全部拿完为止的操作。迭代不能倒车,只能前进。IEnumerable是迭代器模式的实现。
- 通常将迭代中拿出来的元素称为iterator。
- 实现IEnumerable接口,必须实现它唯一的方法GetEnumerator。
- 方法GetEnumerator返回一个IEnumerator类型的输出。IEnumerator类型又是一个接口,所以我们还要写一个类,并将这个类继承IEnumerator接口(实现它的2个方法),建立这个类的一个新实例,并传入一个数组(作为迭代的源)作为方法GetEnumerator的返回值。
- IEnumerator接口拥有一个Current属性,我们需要实现它的get方法,返回当前的iterator。
- 我们需要为IEnumerator类型增加一个int类型的值,记录当前位置。该类型的初始值为-1。IEnumerator类型的Reset方法将这个值设为-1。通常不实现Reset方法,这是为了防止多次迭代。
- IEnumerator接口的MoveNext方法将位置增加一,并返回是否还有下一个元素。
- 可以通过yield简化方法GetEnumerator的实现。Yield本质上是一个状态机,它每次都返回全新的对象。
- 在C#中使用foreach将会隐式的调用MoveNext方法。可以通过查看IL得知foreach运作的全过程。
- IEnumerable<T>是整个LINQ的基础。整个LINQ都基于IEnumerable<T>的扩展方法之上。C#大部分数据结构都实现了IEnumerable<T>。
- IEnumerable的派生类由于没有泛型,所以基本不考虑使用。
- 字典,HashSet和哈希表(Hashtable)的实现有很大区别。
- HashSet是一个不含值的字典。由于集合必须保证元素的唯一性,使用不含值的字典再合适不过了。在遇到数组查重问题时,哈希永远都是一个利器:https://www.zhihu.com/question/31201024
- IEnumerable<T>最重要的一个派生类就是IList<T>接口。它又有两个主要的派生类Array和List<T>。List<T>的内部实现是一个数组而不是链表。LinkedList<T>才是C#的链表实现。LinkedList<T>不实现IList<T>接口。
- 只会在集合元素个数已知且不变时才考虑使用数组。
- 链表的优势在于插入删除时不需要整个表向后或向前移位。双向链表保证了插入删除在尾部发生时速度和在头部一样快。
- 当集合元素未知,且经常存在插入或删除的动作时,考虑使用LinkedList<T>取代List<T>。