介绍:
本文是介绍在.Net平台下使用数据结构的系列文章,共分为六部分,这是本文的第一部分.本文试图考察几种数据结构,其中有的包含在.Net Framework的基类库中,有的是我们自己创建的.如果你对这些名词不太熟悉,那么我们可以把数据结构看作是一种抽象结构或是类,它通常用来组织数据,并提供对数据的操作.最常见并为我们所熟知的数据结构就是数组array,它包含了一组连续的数据,并通过索引进行访问.
在阅读本文内容之前,让我们先看看这六部分的主要内容.如果你有什么想法,或觉得本文有什么遗漏之处,希望你通过e-mail(mitchell@4guysfromrolla.com)和我联系,共同分享你的思想.假如有时间的话,我很高兴将你的建议放到合适的部分,如有必要,可以在这篇系列文章中加上第七部分.
第一部分:首先介绍数据结构在算法设计中的重要性.决定数据结构的优劣在于其性能.我们将经过严格分析数据结构的各种性能.此部分还将介绍.Net Frameword下两种常用的数据机构:Array 和ArrayList.我们将考察其结构的操作方式及其效率.
第二部分:我们将继续从更多细节上分析ArrayList结构,同时还将介绍Queue类和Stack类.和ArrayList一样,Queue和Stack存放的都是一组连续的数据集合,都属于.Net Framework基类库.与ArrayList不同的是,Stack和Queue只能以预先规定的序列顺序读取其数据(先进先出和先进后出),而ArrayList可以任意获取数据项.我们将通过示例程序来考察Queue,Stack,并通过扩展ArrayList类来实现它们.之后,我们还要分析哈希表HashTable,它象ArrayList一样可以直接访问数据,不同的是它以key(字符串)为索引.
ArrayList对数据直接读取和存储是一种理想的数据结构,同时,它也是支持数据搜索的候选方案.在第三部分,我们将考察二叉树结构,对于数据搜索而言,它比ArrayList更加有效. .Net Framework并不包含此种内置数据结构,因此需要我们自己创建.
二叉树搜索的效率受制于插入到树中的数据的顺序.如果我们插入的是有序或近似有序的数据,实际上,它的效率不如ArrayList.为了将这两种的优势结合起来,在第四部分,我门将考察一种有趣的随机数据结构——SkipList. SkipList既保留了二叉树搜索的高效率,同时输入数据的顺序对其效率影响甚微.
第五部分我们将注意力转向通常用来表现图形的数据结构.图(graph)是众多节点以及节点之间边的集合.举例来说,地图就可以图的形式来表现.城市是节点,公路则是连接节点之间的边.许多现实问题都可以抽象成图的形式,因此,图也是我们经常要用到的数据结构.
最后,第六部分我们将谈到reprisent sets(表示集?)和disjoint sets(非关联集,即交集为空?)集合是一种无序数据的集中.非关联集是指它和另外一个集合没有共同的元素.我们在程序编写时会经常用到集合和非关联集.我们将在这一部分中详细描述它.
数据结构性能分析
当我们在思考一个特别的应用程序或者程序的问题时,多数开发人员(包括我自己)都将兴趣集中到算法上以解决手头的难题,或者为应用程序加上一个很酷的特色以丰富用户的经验.我们似乎很少听到有人会为他所使用的数据结构而激动不已,啧啧赞叹. 然而,用在一个特定算法中的数据结构能够很大程度上影响其性能.最常见的例子就是在数据结构中查找一个元素.在数组中,查找过程所耗时间是与这个数组中元素的个数是成正比的.采用二叉数或者SkipLists(我找不到合适的翻译,按前所述,它包含了随机数的集合,也许看了后面的部分会想到合适的中文),耗时与数据个数比例成线型下降(sub-linear,我又黔驴词穷了).当我们要搜索大量的数据时,数据结构的选择对程序的性能尤其重要,其差别甚至达到数秒,乃至于数分钟.
既然在算法中使用的数据结构影响了算法的效率,因此比较各种数据结构的效率并从中选择一种更佳的方法就显得尤为重要.作为开发者而言,我们首先要关注的是随着存储的数据量的增长,数据结构性能是怎样随之改变的的?也就是说,每当数据结构中添加一个新元素时,它将怎样影响数据结构的运行时间?
考虑这样一种情形,我们在程序中使用了System.IO.Directory.GetFiles(路径)方法以返回文件的列表,存放到一个特定的字符串数组directory中.假设你需要搜索这个数组以判断在文件列表中是否存在XML文件(即扩展名为.xml的文件),一种方法是扫描(scan,或者是遍历)整个数组,当找到XML文件时,就设置一个标识.代码可能是这样:
using System;
using System.Collections;
using System.IO;
public class MyClass
{
public static void Main()
{
string [] fs = Directory.GetFiles(@"C:/Inetpub/wwwroot");
bool foundXML = false;
int i = 0;
for (i = 0; i < fs.Length; i++)
if (String.Compare(Path.GetExtension(fs[i]), ".xml", true) == 0)
{
foundXML = true;
break;
}
if (foundXML)
Console.WriteLine("XML file found - " + fs[i]);
else
Console.WriteLine("No XML files found.");
}
}
现在我们来看看最糟糕的一种情况,当这个列表中不存在XML文件或者XML文件是在列表的最后,我们将会搜索完这个数组的所有元素.再来分析一下数组的效率,我们必须问问自己,"假设数组中现有n个元素,如果我添加一个新元素,增长为n+1个元素,那么新的运行时间是多少?(术语"运行时间"--running time,不能顾名思义地认为是程序运行所消耗的绝对时间,而指的是程序完成该任务所必须执行的步骤数.以数组而言,运行时间特定被认为是访问数组元素所需执行的步骤数。)要搜索数组中的一个值,潜在的可能是访问数组的每一个元素,如果数组中有n+1个元素,就将执行n+1次检查。那就是说,搜索数组耗费的时间与数组元素个数成几何线形比。
当数据结构的长度趋于无穷大时,分析其结构的效率,我们把这种分析方法称为渐进分析(asymptotic analysis)。渐进分析中常用的符号是大写的O(big-Oh),以O(n)的形式描述遍历数组的性能。O是术语学中big-Oh符号的表示,n则代表遍历数组时随长度增长而与之线形增长的程序执行步数。
计算代码块中算法的运行时间的一种系统方法应遵循以下步骤:
1、判断组成算法运行时间的步骤。如前所述,对于数组而言,典型的步骤应是对数组进行读写访问的操作。而对于其他数据结构则不尽然。特别地,你应该考虑的是数据结构自身的步骤,而与计算机内部的操作无关。以上面的代码块为例,运行时间应该只计算访问数组的次数,而不用考虑创建和初始化变量以及比较两个字符串是否相等的时间。
2、找到符合计算运行时间条件的代码行。在这些行上面置1。
3、判断这些置1的行是否包含在循环中,如果是,则将1改为1乘上循环执行的最大次数。如果嵌套两重或多重循环,继续对循环做相同的乘法。
4、找到对每行写下的最大值,它就是运行时间。
现在我们按照这种步骤来标记上面的代码块。首先我们已经能够确定与计算运行时间有关的代码行,再根据步骤2,在数组fs被访问的两行代码作上标记,一行是数组元素作为String.Compare()方法的参数,一行是在Console.WriteLine()方法中。我们将这两行标记为1。然后根据步骤3,String.Compare()方法是在循环中,最大循环次数为n(因为数组长度为n)。因此将该行的标记1改为n。最后,我们得到的运行时间就是标记的最大值n,记为O(n)。(译注:即为数据结构中通常所说的时间复杂度)
O(n),或者说线形时间(linear-time),表示了多种算法运行时间中的一种。其他还有O(log2 n),O(n log 2 n),O(n2),O(2n)等等。我们无须关心这些繁杂的big-Oh记号,只需要知道在括号中的值越小,则代表数据结构的性能越好。举例来说,时间复杂度(在这里我还是觉得用时间复杂度比运行时间更能理解)为O(log n)的算法远比O(n)更有效率,因为log n
注:
我们需要温习以下数学知识。在这里,log a b另外一种表示方法为ay=b。因此,log24=2,因为22=4。Log2n增长速度比单个的n要慢得多,在第三部分我们将考察时间复杂度为O(log2n)的二叉树结构。(这个注释没多大意思啊!)
在这篇系列文章中,我们将计算每一种新的数据结构和它们的渐进操作运行时间,并通过相似的操作比较其他数据结构在运行时间上的区别。
数组:一种线形的,可以直接访问的,单一数据结构
在程序编写中,数组是最简单也是最广泛使用的数据结构。在所有的程序语言中数组都具备以下共同的属性:
1.数组的数据存储在一段连续的内存之中;
2.数组的所有元素都必须是同一种数据类型,因此数组又被认为是单一数据结构(homogeneous data structures);
3.数组元素可以直接访问。(在很多数据结构中,这一特点是不必要的。例如,文章第四部分介绍的数据结构SkipList。要访问SkipList中的特定元素,你必须根据搜索其他元素直到找到搜索对象为止。然而对于数组而言,如果你知道你要查找第i个元素,就可以通过arrayName[i]来访问它。)(译注:很多语言都规定数组的下标从0开始,因此访问第i个元素,应为arrayName[i-1])
以下是数组常用的操作:
1.分配空间
2.数据访问
3.数组空间重分配(Redimensioning)
在C#里声明数组时,数组为空值(null)。下面的代码创建了一个名为booleanArray的数组变量,其值为空(null):
Bool [] boolleanArray;
在使用该数组时,必须用一个特定数字给它分配空间,如下所示:
booleanArray = new bool[10];
通用的表述为:
arrayName = new arrayType[allocationSize];
它将在CLR托管堆里分配一块连续的内存空间,足以容纳数据类型为arrayTypes、个数为allocationSize的数组元素。如果arrayType为值类型(译注:如int类型),则有allocationSize个未封箱(unboxed)的arrayType值被创建。如果arrayType为引用类型(译注:如string类型),则有allocationSize个arrayType引用类型值被创建。(如果你对值类型和引用类型、托管堆和栈之间的区别不熟悉,请查阅“理解.Net公共类型系统Common Type System”)
为帮助理解.Net Framework中数组的内部存储机制,请看下面的例子:
arrayName = new arrayType[allocationSize];
This allocates a contiguous block of memory in the CLR-managed heap large enough to hold the allocationSize number of arrayTypes. If arrayType is a value type, then allocationSize number of unboxed arrayType values are created. If arrayType is a reference type, then allocationSize number of arrayType references are created. (If you are unfamiliar with the difference between reference and value types and the managed heap versus the stack, check out Understanding .NET's Common Type System.)
To help hammer home how the .NET Framework stores the internals of an array, consider the following example:
bool [] booleanArray;
FileInfo [] files;
booleanArray = new bool[10];
files = new FileInfo[10];
这里,booleanArray是值类型System.Boolean数组,而files数组则是引用类型System.IO.FileInfo数组。图一显示了执行这四行代码后CLR托管堆的情况。
图一:在托管堆中顺序存放数组元素
请记住在files数组中存放的十个元素指向的是FileInfo实例。图二强调了这一点(hammers home this point,有些俚语的感觉,不知道怎么翻译),显示了如果我们为files数组中的FileInfo实例分配一些值后内存的分布情况。
图二:在托管堆中顺序存放数组元素
.Net中所有数组都支持对元素的读写操作。访问数组元素的语法格式如下:
// 读一个数组元素
bool b = booleanArray[7];
// 写一个数组元素,即赋值
booleanArray[0] = false;
访问一个数组元素的运行时间表示为O(1),因为对它的访问时间是不变的。那就是说,不管数组存储了多少元素,查找一个元素所花的时间都是相同的。运行时间之所以不变,是因为数组元素是连续存放的,查找定位的时候只需要知道数组在内存中的起始位置,每个元素的大小,以及元素的索引值。
在托管代码中,数组的查找比实际的实现稍微复杂一些,因为在CLR中访问每个数组,都要确保索引值在其边界之内。如果数组索引超出边界,会抛出IndexOutOfRangeException异常。这种边界检查有助于确保我们在访问数组不至于意外地超出数组边界而进入另外一块内存区。而且它不会影响数组访问的时间,因为执行边界检查所需时间并不随数组元素的增加而增加。
注:如果数组元素特别多,索引边界检查会对应用程序的执行性能有稍许影响。而对于非托管代码,这种边界检查就被忽略了。要了解更多信息,请参考Jeffrey Richter所著的Applied Microsoft .NET Framework Programming第14章。
使用数组时,你也许需要改变数组大小。可以通过根据特定的长度大小创建一个新数组实例,并将旧数组的内容拷贝到新数组,来实现该操作。我们称这一过程为数组空间重分配(redimensioning),如下代码:
using System;
using System.Collections;
public class MyClass
{
public static void Main()
{
// 创建包含3个元素的int类型数组
int [] fib = new int[3];
fib[0] = 1;
fib[1] = 1;
fib[2] = 2;
// 重新分配数组,长度为10
int [] temp = new int[10];
// 将fib数组内容拷贝到临时数组
fib.CopyTo(temp, 0);
// 将临时数组赋给fib
fib = temp;
}
}
在代码的最后一行,fib指向包含10个元素的Int32类型数组。Fib数组中3到9(译注:注意下标从0开始)的元素值默认为0(Int32类型)。
当我们要存储同种类型的数据(原文为heterogeneous types——异类数据类型,我怀疑有误)并仅需要直接访问数据时,数组是较好的数据结构。搜索未排序的数组时间复杂度是线形的。当我们对小型数组进行操作,或很少对它进行查询操作时,数组这种结构是可以接受的。但当你的应用程序需要存储大量数据,且频繁进行查询操作时,有很多其他数据结构更能适应你的工作。我们来看看本文接下来将要介绍的一些数据结构。(如果你要根据某个属性查找数组,且数组是根据该属性进行排序的,你可以使用二叉法(binary search)对其搜索,它的时间复杂度为O(log n),与在二叉树中搜索的时间复杂度相同。事实上,数组类中包含了一个静态方法BinarySearch()。如要了解该方法的更多信息,请参考我早期的一篇文章“有效地搜索有序数组”。
注:.Net Framework同样支持多维数组。与一维数组一样,多维数组对数据元素的访问运行时间仍然是不变的。回想一下我们前面介绍的在n个元素的一维数组中查询操作的时间复杂度为O(n)。对于一个nxn的二维数组,时间复杂度为O(n2),因为每次搜索都要检查n2个元素。以此类推,k维数组搜索的时间复杂度为O(nk)。
ArrayList:可存储不同类型数据、自增长的数组
明确地,数组在设计时受到一些限制,因为一维数组只能存储相同类型的数据,而且在使用数组时,必须为数组定义特定的长度。很多时候,开发人员要求数组更加灵活,它可以存储不同类型的数据,也不用去关心数组空间的分配。在.Net Framework基类库中提供了满足这样条件的数据结构——System.Collections.ArrayList。
如下的一小段代码是ArrayList的示例。注意到使用ArrayList时可以添加任意类型的数据,且不需要分配空间。所有的这些都由系统控制。
ArrayList countDown = new ArrayList();
countDown.Add(5);
countDown.Add(4);
countDown.Add(3);
countDown.Add(2);
countDown.Add(1);
countDown.Add("blast off!");
countDown.Add(new ArrayList());
从深层次的含义来讲,ArrayList使用的存放类型为object的System.Array对象。既然所有类型都是直接或间接从object派生,自然一个object类型的数组也可以存放任何类型的元素。ArrayList默认创建16个object类型元素的数组,当然我们也可以通过构造函数中的参数或设置Capacity属性来定制ArrayList大小。通过Add()方法添加新元素,数组内部自动检查其容量。如果添加新元素导致越界,则容量则自动成倍增加,我们称为自增长。
ArrayList和Array一样,也可以通过索引直接访问:
// Read access
int x = (int) countDown[0];
string y = (string) countDown[5];
// Write access
countDown[1] = 5;
// 会产生ArgumentOutOfRange 异常
countDown[7] = 5;
既然ArrayList存储的是object类型的元素,因此从ArrayList中读元素时应该显示的指定类型转换。同时要注意的是,如果你访问的数组元素超过ArrayList的长度,系统会抛出System.ArgumentOutOfRange异常。
ArrayList提供了标准数组所不具备的自增长灵活性,但这种灵活性是以牺牲性能为代价的,尤其是当我们存储的是值类型——例如System.Int32,System.Double,System.Boolean等。它们在托管堆中是以未封箱形式(unboxed form)连续存放的。然而,ArrayList的内部机制是一个引用的object对象数组;因此,即使ArrayList中只存放了值类型,这些元素仍然会通过封箱(boxing)转换为引用类型。如图三所示:
图三:存储连续块的object引用的ArrayList
在ArrayList中使用值类型,将额外进行封箱(boxing)和撤箱(unboxing)操作,当你的应用程序是一个很大的ArrayList,并频繁进行读写操作时,会很大程度上影响程序性能。如图3所示,对于引用类型而言,ArrayList和数组的内存分配是相同的。
比较数组而言,ArrayList的自增长并不会导致任何性能的下降。如果你知道存储到ArrayList的元素的准确数量,可以通过ArrayList构造函数初始化容量以关闭其自增长功能。而对于数组,当你不知道具体容量时,不得不在插入的数据元素超过数组长度的时候,手动改变数组的大小。
一个经典的计算机科学问题是:当程序运行时超出了缓存空间,应该分配多少新的空间为最佳。一种方案是是原来分配空间的基础上每次加1。例如数组最初分配了5个元素,那么在插入第6个元素之前,将其长度增加为6。显然,这种方案最大程度上节约了内存空间,但代价太大,因为每插入一个新元素都要进行一次再分配操作。
另一种方案刚好相反,也就是每次分配都在原来大小的基础上增加100倍。如果数组最初分配了5个元素,那么在插入第6个元素之前,数组空间增长为500。显然,该方案大大地减少了再分配操作的次数,但仅当插入极少的数据元素时,就会有上百的元素空间未使用,实在太浪费空间了!
ArrayList的渐近运行时间和标准数组一样。即使对ArrayList的操作是高开销的,尤其是存储值类型,其元素个数和每次操作的代价之间的关系与标准数组相同。