前言
在专业程序员的成长过程中数据结构与算法的学习是至关重要的。虽然有许多书籍介绍数据结构与算法,但这些书大部分是作为高校教材并且以大学中常用于面向对象教学的Java或C++来讲述的。C#正在成为一种流行的语言。此书适合C#程序员们来学习数据结构与算法的基础知识。
C#是一个基于.Net Framework这个丰富的开发环境的语言。.Net Framework的类库中包括了一系列与数据结构相关的类型(也被称作集合类)。这些类包括了从Array,Arraylist与集合类型到Stack与Queue类到HashTable与SortedList这众多的类型。读者可以先学着怎样使用这些类而不用急着明白怎样实现这些类。在这之前,教学者不得不先抽象讨论数据结构的结构的原理(如堆栈),直到完整的构建出这样一个数据结构。现在教学者们可以先给学生演示怎样使用堆栈完成一些如数值转换的计算,从而快速演示这些类的使用。有了这些背景,学生们可以回过头来学习这些数据结构(或算法)的基础,甚至给出了他们自己的实现。
本书对所有程序员都需要知道并理解的数据结构和算法做了一个实用的概述。基于这个原因,本书不包括对这些数据结构与算法的正式分析。因此,本处没有单一的数学公式也没有提到大O分析(如果你不知道这是什么意思,可以参考本书中提到的任何一本书)。取而代之,不同的数据结构及算法被用做解决问题的工具,以此来展示他们。简单的时间测试被用来比较书中讨论的数据结构及算法的性能。
预备知识
本书唯一需要读者具有的预备知识是对C#有一个大概了解。特别是C#面向对象编程这方面。
章节组织
第1章介绍了数据结构作为一个数据集合的概念,线性与非线性集合类的概论;演示了集合类。本章也引入了泛型编程的概念,它允许程序员编写一个适用于多种数据类型的类或方法。泛型是C#中添加的一个重要的特性,以至于针对(用于C#2.0及以后的版本)泛型数据类型在System.Collections.Generic命名空间下增加了一个专门的类库。当此类库中有某一数据结构的泛型实现的时候,我们将研究它的用法。在本章末尾,我们将讨论衡量本书介绍的数据结构与算法性能的方法。
第2章回顾了数组是怎样构建的同时演示了Array类的一些特性。Array类封装了许多与数组操作有关的功能(如UBound,LBound等等)。ArrayList是一种特殊的数组,其提供了动态调整大小的能力。
第3章介绍了基本的排序算法,向冒泡排序,插入排序。第4章研究了大部分基本的内存查找算法,顺序查找与二分查找。
第5章讨论了两种经典的数据结构,栈与队列。本章的重点是这些数据结构在解决每天遇到的数据处理问题时的实用。第6章包含了BitArray类,其可以被用来高效的表示大量的整型数值,如测试分数。
字符串通常不是数据结构的图书所讨论的内容,但第7章介绍了字符串,包括String类和StringBuilder类。C#中许多数据操作是对字符串执行的,读者应该了解这两个类中所包含的特殊技巧。第8章讲解了正则表达式在进行文本处理及样式匹配时的作用。正则表达式提供了比传统字符串处理函数及方法更强大更高效的处理能力。
第9章介绍了字典这种数据结构的使用。字典以及基于它们的其它不同的数据结构,将数据存储为键/值对。本章给读者展示了基于DictionaryBase这个抽象类来创建自己的类型的方法。第10章涵盖了哈希表,讲述了使用HashTable这种特殊的字典类型的哈希方法在内部保存数据的方法。
第11章讲述了另一个经典的数据结构 – 链表。C#中的链表并不像如C++这种基于指针的程序设计语言中的链表一般重要。但在C#编程中也有它自己的角色。第12章介绍了另一种经典的数据结构 – 二叉树。二叉树中的一种特殊形式 – 二叉查找树是本章的主题,其它类型的二叉树被安排在第15章讨论。
第13章演示了怎样在集合类中存储数据。当只能在某一数据结构中存储独一无二的数据值时这种数据类型相当有用。第14章包括了更多的高效排序算法,如流行且高效的快速排序,这也是实现.Net Framework类库过程中所用到的大部分排序过程的基础。第15章研究了在讨论二叉查找树时没有涉及到的三种提供有用的查找功能的数据结构 – AVL树,红-黑树及跳跃表。
第16章讨论了图与图算法。图在展示许多不同类型的数据,尤其是网络的时候十分有用。第17章介绍了算法设计技术的根本:动态算法和贪婪算法。
- 介绍集合类,泛型类及计时类
本书讨论了基于C#语言的数据结构和算法的开发与实现。本书使用的数据结构源于.Net Framework类库中System.Collections命名空间中所包含的类。在本章中我们首先实现一个我们自己的集合类(使用数组作为我们自己实现的基础)来建立对于集合的概念,然后学习.Net Framework中的集合类。
C#2.0中引入的一个重要的新特性是泛型。使用泛型C#程序员使用可以编写一个版本的函数 – 或独立或存在于一个类中,在不用重载此函数多次的情况下即可接受不同的数据类型。C#2.0提供了一个特殊的命名空间,System.Collections.Generic,其中包含了System.Collections中数据结构的实现。本章将给读者介绍泛型编程。
最后,本章将引入一个自定义类型,他将在以后章节中作为测量数据结构或算法性能的工具。这个类将取代大O分析,并不是大O分析不重要。而是因为本身采用一种更实用的方法来讲授数据结构及算法。
集合定义
有序集合是一种用来存储数据的容器类型的数据结构,它提供向此容器添加数据,由其中移除数据,更新其中数据的方法同时提供了操作来设置或返回集合中不同属性的值。
集合类可以被分为两种类型:线性和非线性。线性集合是一个元素的列表,其中后一个元素跟着前一个元素。线性集合中元素按位置(第一个,第二个,第三个等等)排序。在现实生活中,杂货店商品列表就是一个线性集合的好例子,在计算机世界中(现实中也一样),数组被设计为一个线性集合。
非线性集合中的元素设有位置上的先后顺序。组织图标就是一个非线性集合的例子,就像撞球的架子。在计算机中,树,堆,图,无序集合都是非线性集合的例子。
集合,无论是线性的还是非线性,都定义了一系列属性来描述它们,并且定义了在它们上面可以执行的操作。集合的属性的例子有集合Count属性,它保存了集合中元素的数量。集合上定义的操作,也被称为方法。包含了Add(向集合增加一个元素),Insert(在指定位置插入一个元素),Remove(移除指定的元素),Clear(移除集合中所有元素),Contains(确定指定元素是否为集合的一个成员),IndexOf(确定一个指定元素在集合中的位置)。
集合描述
集合的两个主要类型中又包含了几种子类型。线性集合中既可直接访问的集合也有需要顺序访问的类型。同样非线性集合也分为层次化的或分组的。本部分分别讲述了以上这几种子类型。
直接访问集合
直接访问集合中最常见的例子就是数组。数组定义为一系列相同类型元素的集合,且可以通过一个整数索引直接访问它的元素。如图1.1所示
数组可以是静态的,这样当声明数组是指示了元素的数目后,其长度就固定了,或者它也可以是动态的,这样元素的数目可以通过ReDim或ReDim保留语句来增加元素的数目。
在C#中,数组不仅是一个内置的数据类型,它们同样被定义为一个类。在本章的后面,当我们研究数组使用的细节时,我们将讨论怎样将数组作为一个类使用。
我们可以使用数组存储一个线性集合。向数组添加新元素的操作很简单我们只需要将新元素放置在数组尾部第一个空闲位置即可。向数组插入元素的操作并不简单(或者说高效),因为我们不得不将数组的元素向后移以便为待插入的元素腾出空间。由数组尾部删除一个元素的操作同样是高效的,我们只需要简单的移除最后一个元素的值。由任意位置删除一个元素是低效的,因为就像插入,我们不得不将许多元素向前调整一个位置来保持数组的连续。我们将在本章后面讨论这些问题的关键所在。.Net Framework提供了一个专门的数组类ArrayList来简化线性集合的编程任务。我们会在第3章详细讨论这个类。
另一种直接访问集合的类型是字符串。字符串是一些可以通过索引来访问的字符的集合,就像我们访问数组的元素一样。字符串同样实现为C#中的类类型。这个类提供了大量基本的操作字符串的方法,如连接,返回子串,插入字符,移除字符等等。我们将在第8章细说String类。
C#字符串具有不变性,即一旦一个字符串被初始化,则不能在变化它,当你修改一个字符串时,一个新拷贝将被创建而不是改变原来的字符串的值。在某些情况下这种行为可能会导致性能下降,所以.Net Framework提供了一个StringBuilder类来使你可以使用可变字符串。同样我们会在第8章讨论StringBuilder类。
最后一个直接访问集合类型是结构(其它语言也称结构体或记录)。结构是一个由许多不同类型数据组成的复合数据类型,类如,一个雇员记录有雇员姓名(字符串),薪水(整型),身份证号(字符串或整型)及其它属性。因为将这些数据的每一个值单独存放在不同的变量中较易造成混乱,所以编程语言提供了结构类型来存储这类数据。
C#结构增加的一个强大功能是允许定义方法,以在存储于结构中的数据上执行计算。这使得结构看起来像一个类。虽然你不可以有结构继承或衍生一个新的类型。下面代码演示了C#中结构的简单使用:
1 using System;
2
3 public struct Name
4 {
5 private string fname, mname, lname;
6 public Name(string first, string middle, string last)
7 {
8 fname = first; mname = middle; lname = last;
9 }
10
11 public string firstName
12 {
13 get { return fname; }
14 set { fname = firstName; }
15 }
16
17 public string middleName
18 {
19 get { return mname; }
20 set { mname = middleName; }
21 }
22
23 public string lastName
24 {
25 get { return lname; }
26 set { lname = lastName; }
27 }
28
29 public override string ToString()
30 {
31 return (String.Format("{0} {1} {2}", fname, mname, lname));
32 }
33
34 public string Initials()
35 {
36 return (String.Format("{0}{1}{2}", fname.Substring(0, 1), mname.Substring(0, 1), lname.Substring(0, 1)));
37 }
38 }
39 public class NameTest
40 {
41 static void Main()
42 {
43 Name myName = new Name("Michael", "Mason", "McMillan");
44 string fullName, inits;
45 fullName = myName.ToString();
46 inits = myName.Initials();
47 Console.WriteLine("My name is {0} .", fullName);
48 Console.WriteLine("My initials are {0} .", inits);
49 }
50 }
显然.Net环境中许多元素被实现为类(如数组和字符串),语言中仍有几种主要元素,如数值数据类型,被实现为结构类型。例如,整数类型就是作为Int32结构类型实现的。使用Int32中的Parse方法,你可以将字符串形式表示的数字转化为一个整数。如下面例子所示:
1 public class IntStruct
2 {
3 static void Main()
4 {
5 int num;
6 string snum;
7 Console.Write("Enter a number: ");
8 snum = Console.ReadLine();
9 num = Int32.Parse(snum);
10 Console.WriteLine(num);
11 }
12 }
顺序访问集合
顺序访问集合是以连续顺序存储元素的列表。我们称这种集合类型为线性列表。当线性列表创建时并不限制其长度,这意味着它们可以动态扩展和连接。线性列表中的元素不是直接访问的通过其位置可以访问它们,如图1.2所示。线性列表的第一个元素位于其头部,最后一个元素位于其尾部。
因为没有一种方法直接访问线性列表中的元素,要访问一个元素你不得不遍历列表,直到你到达要找的元素的位置。线性列表的实现通常允许两种方法对列表进行遍历 – 从头到尾单向遍历和从头到尾从尾到头双向遍历。
一个线性列表的简单的例子是购物清单,你一个接一个的写下待购物商品直到清单完成从而创建了这样一个列表,当每一项商品被找到并购买后,就从列表中移除相应的项。
线性列表即可以是已排序的,也可以是未排序的。已排序的列表其元素相互之间有顺序。就像:
Beata Bernica David Frank Jennifer Mike Raymond Terrill 一个未排序列表包含了以任意顺序排放的元素,在列表上执行数据搜索时,其元素的顺序问题会使这个过程有很大不同。在第一章你将看到我们探索二叉搜索算法与简单线性查找算法的对比。
部分线性列表类型限制了对它们数据的访问。这些列表类型的例子有栈和队列。对栈这种列表类型的访问被限制在列表的开始处(或顶部)。列表项被放置在列表的顶部且只可以由顶部移除列表项。基于这种原因,栈被看作是一种后进先出的数据结构。当我们向栈添加一个对象时,我们称这种操作为Push。当我们由栈移除一个对象时,我们称这种操作为Pop。图1.3展示了这两种栈操作。
栈是一种非常通用的数据结构,尤其是在计算机系统编程中,在栈的许多应用当中,它常被用作评估算术表达式时进行符号平衡。
队列是一个将添加的对象置于列表尾部而由列表头部移除对象的列表,这种类型被看作先进先出的数据结构。向队列添加对象被称为EnQueue,由队列移除对象成为Dequeue。图1.4展示了队列操作。
队列用于系统编程如安排操作系统任务及模拟学习。队列是作为模拟在每一个能想到的零售店前排队时的情景的的极好的数据结构。优先队列是一种特殊形式的队列,这种队列中具有高优先级的元素可以先被从队列中移除。举例来说,优先队列可以被用来研究医院急诊室的操作,其中心脏病患者需要比一个胳膊受伤的病人更早的接受治疗。
我们要了解的最后一类线性集合称作一般索引集合(generalized indexed collections)。此类中的第一个是用来存储一系列与键相联系的数据的哈希表。哈希函数是哈希表中一种特殊的函数,其接受一个数据值并将值(被称作键)转化为用来检索数据的一个整型索引。索引用来访问与这个键相关联的数据记录。例如,一个雇员记录可能由人名、其薪水、在这家单位工作年数、其所在部门这些信息构成。图1.5展示了这个结构。这个数据集的键是雇员的姓名。C#中有一个叫做HashTable的类用于将数据存入一个哈希表中,我们将在第10章探索这种数据结构。
另一种一般索引集合是字典,字典由一系列键-值对组成,称作联合。这种数据结构模拟了一个单词字典,其中一个单词作为键,单词的定义是键所关联的值。虽然索引不必非得是整数,由于这种索引模式字典也常被称作关联数组。在第11章,我们将认识几个.Net Framework中的字典类。
层次集合
非线性集合主要分为两大类:层次集合与组集合(group collections)。层次集合是一组按层次划分的项,一个层级的项目有位于更低一层级上的后继项。
最常见的一种层次集合是树,树集合看起来像一颗倒置的树,有一个数据元素作为树根,其它数据值挂在树根下方作为叶子。树中的一个元素称作结点,在一个特定结点下方的元素被称作该结点的孩子,图1.6展示了一个简单的树
树被应用在几个不同的领域,大多数现代操作系统的文件系统被设计为书籍和,其中一个目录作为根结点,其它子目录作为根结点的孩子。
二叉树是一种特殊形式的树集合,其中每个结点的子结点不超过两个。二叉树可以变为二叉查找树,使大量数据的查找更高效。这是通过将结点以这样一种方式来放置完成从而实现的,其中从根结点到任意数据存储的结点的路径都是可能的最短路径。
另一种树型的结构是堆,堆的组织使最小的数据值总是位于根结点,在一次删除过程中根结点。在一次删除过程中根结点被移除。向一个堆插入数据或由其中删除数据都会导致一个堆重新排列,以使最小的元素始终位于根节点。堆常用于排序称作堆排序。存储于堆中的数据元素可以在删除根节点并重建堆的这个重复的过程中得到排序。第12章讨论这几种不同形式的存储数据的树。
组集合
一系列未排序的元素的非线性集合被称作一个组。三类主要的组集合是集、图与网络。
集是一系列独一无二的未排序的数据值组成的集合。一个班级中学生的列表是集的一个例子。可以在集上执行的操作包括并与交。下图1.7展示了集操作的例子。
图是一系列点与连接这些点的一系列边组成的,图用来对一下情况进行建模:图中每一个节点都必须被访问,有时以一种特殊的顺序,其目标是找到遍历图的最高效的路径。图用于后勤学及工作安排,计算机科学家与数学家都对图有很好的研究。你可能听说过"销售人员旅行"问题。这是一个特殊形式的图问题,它需要决定怎么在经费允许的情况下以最高效的方式完成行程需要经过的路线中的城市。图1.8展示了这个问题的一个示例图。
这个问题是有名的NP完全问题(NP-Complete)这个问题系列的一部分。这意味着对这种类型的大型问题,还没有已知的准确的解决方案。例如,要找到图1.8所示问题的解决翻案,需要10的阶乘次遍历,即3628800次遍历。如果我们将问题扩展到100个城市,我们需要检查100的阶乘次结果,以目前的方法我们还无法解决这个问题。必须找到一种近似的解决方案来代替。
网络是一种特殊类型的图,其中每一条边被赋予一个权值。权值关联了一个使用这条边由一个点移动到另一个点需要的开销。图1.9描述了一个城市网络,其中权值代表了城市(节点)之间的里数。
现在我们已经浏览了一遍将要在这本书中讨论的不同类型的集合。接下来我们要实际地看一下C#中是怎样实现集合类的。我们由使用.Net Framework中的CollectionBase类这个抽象类构造一个集合类作为开始。
CollectionBase类
.Net Framework类库中没有包含一个用来存储数据的泛型集合类,但是你可以使用CollectionBase这样一个抽象类来构建你自己的集合类。CollectionBase类提供给程序员实现一个自定义集合类的能力。这个类隐式实现了两个构建集合类必须的接口—ICollection与IEnumerable,留给程序员只需实现属于那个集合类的具体方法。
使用ArrayLists实现的集合类
在这节中,我们将学习怎样使用C#来实现我们自己的集合类。这个示例有如下目标。第一,如果你还不是很了解面向对象编程(OOP),这个示例将展示给你C#中一些简单的OOP技术。我们也会使用这部分内容讨论一下在后续讨论不同的C#数据结构问题时会遇到的性能问题。最后,我认为你会像喜欢本书其它讲实现的章节一样喜欢本节内容,因为仅仅使用语言内置的元素重新实现已存在数据结构的确充满乐趣。就像Don Knuth(计算机科学先驱之一)说过的,要了解一个问题,直到你教给一个电脑怎样做你才真正学到一些东西。所以,通过学习使用C#来实现不同的数据结构。我们将比仅仅在一天天编程过程中从类库中选择类来使用更多的学到关于那些数据结构的知识。
定义一个集合类
最简单的在C#中定义一个集合类的方法是继承类库中System.Collections命名空间下的CollectionBase这个抽象类。这个类提供了一系列的抽象方法,你可以实现他们以创建你的集合类。CollectionBase类提供了一个基础的数据结构,InnerList(一个ArrayList)。你可以使用它作为你的类的一个基础。在本节中。我们学习怎样使用CollectionBase构建一个集合类。
实现集合类
组成这个集合类的方法全部与这个类的一个基础数据类型 – InnerList进行一定形式的交互。在这第一小节中我们要实现的方法有Add,Remove,Count和Clear。这些方法对类是绝对不可缺少的,虽然其它方法一定程度上使类更有用。
让我们从Add方法开始。这个方法有一个参数 – 一个包含了要添加到结合的Object类型的对象。
代码如下:
1 public void Add(Object item)
2 {
3 InnerList.Add(item);
4 }
ArrayLists以对象(Object数据类型)形式存储数据。这是我们将item项声明为Object的原因。在第2章你将学到更多关于ArrayLists的东西。
Remove方法的工作方法类似:
1 public void Remove(Object item)
2 {
3 InnerList.Remove(item);
4 }
下面一个方法是Count。Count通常被实现为属性,但是我们更喜欢将它作为一个方法。同样Count被实现于基类型 -- CollectionBase中,所以我们必须使用new关键字隐藏基类CollectionBase中对Count的定义。
1 public new int Count()
2 {
3 return InnerList.Count;
4 }
Clear方法由InnerList中移除所有项。我们同样必须在方法的定义中使用new关键字:
1 public new void Clear()
2 {
3 InnerList.Clear();
4 }
这些足够我们启程了,让我们看一下使用这个集合类的程序,集合类的完整定义也在其中:
1 using System;
2 using System.Collections;
3
4 public class Collection : CollectionBase<T>
5 {
6 public void Add(Object item)
7 {
8 InnerList.Add(item);
9 }
10 public void Remove(Object item)
11 {
12 InnerList.Remove(item);
13 }
14 public new void Clear()
15 {
16 InnerList.Clear();
17 }
18 public new int Count()
19 {
20 return InnerList.Count;
21 }
22 }
23 class chapter1
24 {
25 static void Main()
26 {
27 Collection names = new Collection();
28 names.Add("David");
29 names.Add("Bernica");
30 names.Add("Raymond");
31 names.Add("Clayton");
32 foreach (Object name in names)
33 Console.WriteLine(name);
34 Console.WriteLine("Number of names: " + names.Count());
35 names.Remove("Raymond");
36 Console.WriteLine("Number of names: " + names.Count());
37 names.Clear();
38 Console.WriteLine("Number of names: " + names.Count());
39 }
40 }
你可以实现几个其它方法来创建一个更有用的集合类,在练习中,你将有机会实现这些方法中的一部分。
泛型编程
"代码膨胀"是OOP编程中存在的问题之一。当你不得不重载一个或一系列方法一接受所有可能的数据类型作为方法参数时,就会发生一种形式的代码膨胀。一种解决方案是提供只给某个值提供一种定义方式,令其承载多种数据类型的能力。这个技术被称为泛型编程。
泛型编程中提供了一个数据类型"占位符",在编译时它将被一个指定的数据类型填充。这个占位符用一个置于一队角括号(<>)之间的一个指示符来表示。让我们来看一个例子。
第一个规范的针对泛型编程的例子是Swap函数。下面C#语言描述的是一个泛型版本的Swap函数的定义。
1 static void Swap<T>(ref T val1, ref T val2)
2 {
3 T temp;
4 temp = val1;
5 val1 = val2;
6 val2 = temp;
7 }
数据类型的占位符紧接着函数名放置。置于角括号之中的指示符用于需要表示一个泛型数据类型的变量时。每一个参数被指定为一个泛型数据类型,用来做交换的临时变量也是如此,下面的程序测试了这段代码:
1 using System;
2
3 class chapter1
4 {
5 static void Main()
6 {
7 int num1 = 100;
8 int num2 = 200;
9 Console.WriteLine("num1: " + num1);
10 Console.WriteLine("num2: " + num2);
11 Swap<int>(ref num1, ref num2);
12 Console.WriteLine("num1: " + num1);
13 Console.WriteLine("num2: " + num2);
14 string str1 = "Sam";
15 string str2 = "Tom";
16 Console.WriteLine("String 1: " + str1);
17 Console.WriteLine("String 2: " + str2);
18 Swap<string>(ref str1, ref str2);
19 Console.WriteLine("String 1: " + str1);
20 Console.WriteLine("String 2: " + str2);
21 }
22 static void Swap<T>(ref T val1, ref T val2)
23 {
24 T temp;
25 temp = val1;
26 val1 = val2;
27 val2 = temp;
28 }
29 }
程序输出如下:
泛型类不限于定义于函数中:你同样可以创建泛型类。一个泛型类的定义中会在类名称后面包括一个泛型类型的占位符。任何时候定义中的类名被引用时,必须提供给类型占位符一个适当的类型。下面类的定义演示了怎样创建一个泛型类。
1 public class Node<T>
2 {
3 T data;
4 Node<T> link;
5 public Node(T data, Node<T> link)
6 {
7 this.data = data;
8 this.link = link;
9 }
10 }
这个类的使用方式如下:
1 Node<string> node1 = new Node<string>("Mike", null);
2 Node<string> node2 = new Node<string>("Raymond", node1);
我们将在本书中要学习的几个数据结构中使用Node类。
由于泛型编程的作用十分明显,C#提供了一个可以直接使用的泛型数据结构的类库。这些数据结构位于System.Collection.Generics命名空间下,当我们讨论位于这个命名空间下的一个数据结构时,我们将研究它的用法。一般来说,这些类与那些非泛型的数据结构的类拥有相同的功能,所以我们将只简要讨论怎样初始化一个泛型类的对象,因为其他方法及它们的用法与非泛型类没有区别。