系列文章目录索引:《.NET,你忘记了么》
之前,一直在谈.NET框架方面的问题,今天来谈谈关于Array和List的使用问题,这应该算是属于算法的最基础的东西了。只是提醒大家对这个问题稍加注意。
写这个是因为一个同学的求助,事情是这样的,他去负责公司的一个培训模块,在培训模块中,有一个功能是自动成卷。然后,我们会很容易地想到洗牌算法。于是我给他大概解释了洗牌算法的过程和步骤,然后他给出了这样的代码,还很骄傲地告诉我,他使用了泛型……
List<int> list = new List<int>(); for (int i = 0; i < 10; i++) { list.Add(i); } Random r = new Random(); for (int j = 0; j < 100; j++) { int temp; int x1 = r.Next(10); int x2 = r.Next(10); temp = list[x1]; list[x1] = list[x2]; list[x2] = temp; }
我委婉地告诉他,他这个方法不好,然后写下了下面的代码:
int[] array = new int[10]; for (int i = 0; i < 10; i++) { array[i] = i; } Random r = new Random(); for (int j = 0; j < 100; j++) { int temp; int x1 = r.Next(10); int x2 = r.Next(10); temp = array[x1]; array[x1] = array[x2]; array[x2] = temp; }
他很不屑地对我说,不是都一样么!而且还在以使用了泛型为豪!我无语……
我仅仅把List(链表)换成了Array(数组),有人会说,这样的关系大么?
让我们先来简单地回顾一下基础知识。
Array和List都属于顺序表。
Array是一段连续的存储结构,如int[] i=new int[3],i其实记录的是数组的首地址,而i[1]其实相当于在i的地址的基础上加上1个整数的地址偏移,然后再取这块地址中的值。也就是相当于*(&i[0]+4);
而List则是不连续的存储结构,List的每个节点都有着一个Next属性,这个属性则记录着他的下一个节点的地址。也就是说当我们想找第100个节点的时候,他还是需要从第一个节点,然后做99次Next操作,才能找到list[99]节点。这是个蛮痛苦的过程。
很多人会说,那无论是List还是Array,不都是一个索引么!让我们来请出IL:
先来看Array查找某个元素的IL:
IL_0020: ldloc.0
IL_0021: ldc.i4.3
IL_0022: ldelem.i4
IL_0023: stloc.2
然后让我们来看下List查找某个元素的IL:
IL_0022: ldloc.0
IL_0023: ldc.i4.3
IL_0024: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<int32>::get_Item(int32)
IL_0029: stloc.2
我们可以发现,虽然他们的写法是一样的,但是在IL中却有着很大的差别。
通过这两段IL,我只是希望证明List和Array对索引元素的方式是不同的。当然,我们无从知道Microsoft对List方法get_Item的实现。但是我们不难想象:
因为List是一个链表,所以我需要从第一个元素开始逐个Next到所需索引的元素。这是一个耗时的过程。
因此,在使用洗牌算法时,使用List是个很差劲的做法。再进一步说,当我们需要大量的索引步骤时,使用List是个很差劲的做法。
那List和Array各自应该用在什么情况下呢?我来做个简单的总结:
1. 从空间扩展角度上来说:
数组必须要在初始化时分配固定的大小,比如说int[] a=new int[3];如果我们仅仅写int[] a=new int[];编译器就会无情地给我们报错。但是List由于空间不必连续,所以无须指定初始大小。
总结1: 当不确定大小时,最好使用List代替Array。
2. 从操作角度上来看:
关于索引这个就不赘述了。
总结2:当需要大量的查找操作时,最好使用Array。
对于插入(删除)操作,很多人是从插入(删除)的时间上分析,说List优于Array,我觉得是不合理的。
更合理的解释应该是从两个角度分析(以插入为例):
<1> 指定位置插入指定元素:
对于Array讲,有两套解决方案:
A. 使用一个新数组,N+1个元素重新赋值的过程。一个for循环,时间复杂度O(n)。
B. 在原数组上操作,那么首先需要为该数组预留空间,这是个很难办的事情。而且其后续元素的移动耗费时间复杂度仍未O(n)。
对于List来讲,很多人说复杂度就是O(1)。这其实是不合理的,因为List插入元素固然容易,但是在指定位置的插入,需要一个时间复杂度为O(n)的查找过程。
但是只考虑时间复杂度是不够的,我们要考虑总体的情况。如果使用新数组,不仅浪费了新的空间,而且需要反复的赋值过程,是N+1次。如果不使用新数组,预留空间实在太麻烦,因此综上所述,还是List好。
(补充下:很多同事和朋友都问我,说为什么要学那么多的排序和搜索算法,排序学个快速排序,搜索学个二分搜索,这样就够了呗!但是我想说的是,所说的最快,不过是他们的平均(或最坏)情况下的时间复杂度,并不能代表通用的情况,我们在实际工作中,还是要根据实际情况去选择最适用的算法,这就是我们学习很多算法的目的)
<2> 给出前一个节点,然后在后面插入元素。这个我的意思就是不仅仅给出了PreviousNode的Value,还给出了他的Next。这个情况我就不废话了,List的优势太大了。可是在实际情况中,这种情况的可能性几乎为零。
因此,总结3:当需要进行频繁的插入,删除操作时,最好使用List代替Array。
另外,给出个不太重要的补充,由于List需要存储他下一个节点的地址,所以List比Array相对起来浪费了更多的空间。
续:
在上文中,我对List<T>的理解大错特错,在成文前,首先做下自我批评,然后也对造成的不良影响表示道歉。
首先开始的就是对List的重新认知。在这里,让我们先从构造方法来重新认识List<T>的本质,先来看下上文中我所粘出的代码:
List<int> list = new List<int>(); for (int i = 0; i < 10; i++) { list.Add(i); } Random r = new Random(); for (int j = 0; j < 100; j++) { int temp; int x1 = r.Next(10); int x2 = r.Next(10); temp = list[x1]; list[x1] = list[x2]; list[x2] = temp; }
在上文中,我对这个List大批特批,现在,我们来重新看下这个List的构造:
public List() { this._items = List<T>._emptyArray; }
先来看无参的构造方法,当无参的时候,.NET Framework其实是用一个_emptyArray来初始化了List中的items集合。那么_emptyArray又是什么呢?我们继续向下看:
private static T[] _emptyArray;
恩,他是一个静态字段,然后我们看下List<T>的静态构造方法:
static List() { List<T>._emptyArray = new T[0]; }
我们看到,_emptyArray其实是一个T类型的数组,个数为0。那么也就是说,当我们执行0参数的构造方法时,系统是把items集合给赋值为了一个T类型的个数为0的数组。
那么items又是什么?我们继续向下看:
public void Add(T item) { if (this._size == this._items.Length) { this.EnsureCapacity(this._size + 1); } this._items[this._size++] = item; this._version++; }
这是List<T>中一个Add(T item)方法,但是我们可以从方法中敲出些端倪来。
在这里,我并不是想解释这个方法的原理,只是想说,在List中,其实维护这一个items,然后很多操作,是基于items的操作。
恩,所以在上文中,List<int> list=new List<int>();和Array a=new int[10]();的操作其实差别并不大。
我们肯定还记得在《Effective C#》中有这样一条规则,就是说:在初始化List之前最好对List初始化大小。
让我们从源码中来找到这一条规则的答案。
private void EnsureCapacity(int min) { if (this._items.Length < min) { int num = (this._items.Length == 0) ? 4 : (this._items.Length * 2); if (num < min) { num = min; } this.Capacity = num; } }
我们来看,在这个方法体中,List会新建一个数组,然后把数组的长度设置为原来的二倍(如果原有的数组长度为0,那就默认将数组的长度设置为4)。
因此,这种,让List的方法自己去调用EnsureCapacity(int min)方法,不仅浪费了构造数组的时间,还浪费了大量的空间(因为将原有的数组空间扩充了二倍)。
因此,请记得:在初始化List之前最好指定List的大小。
为了证明上述的观点,我们再来随便看一些代码:
public int IndexOf(T item) { return Array.IndexOf<T>(this._items, item, 0, this._size); }
public int FindIndex(int startIndex, int count, Predicate<T> match) { if (startIndex > this._size) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.startIndex, ExceptionResource.ArgumentOutOfRange_Index); } if ((count < 0) || (startIndex > (this._size - count))) { ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_Count); } if (match == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.match); } int num = startIndex + count; for (int i = startIndex; i < num; i++) { if (match(this._items[i])) { return i; } } return -1; }
由上面的代码,我想证明的是:其实List<T>不过是对Array的进一步封装,说得再直接点,我愿意理解List<T>为Array的可扩充版本,然后扩展了一些方法;
那关于Array和List的选择,我重新做一个说明:
List是基于Array存在的,因此,在创建一个List对象时,需要耗费比Array相对更多的时间,以及更大的空间,因为List除了初始化内部的items外还需要初始化一些其他的属性。而且在方法调用时,这点我没有证实,只是一个猜测,List需要的是再去调用Array的相关方法,因此也许会存在方法调用的时间消耗问题。
因此,我的建议是:
如果初始化时确定大小,那么就使用Array。
如果初始化时不确定大小,那么就使用List。当然,其实完全可以自己去实现List中的数组扩充功能的,也许会更棒,因为我们没有必要去将Array每次都扩充为原来的二倍。
另外的补充,Array相对于List还有个优势就是:多维数组比List的嵌套更容易理解,也就是说int[][](或者是int[,])要强于List>,也就说在类型确定且多维的情况下,用Array要优于List。
本文来自:http://www.cnblogs.com/xinyuperfect/archive/2009/03/05/1403578.html 和http://www.cnblogs.com/xinyuperfect/archive/2009/03/09/1406657.html