C--数据结构和算法-全-
C# 数据结构和算法(全)
原文:
zh.annas-archive.org/md5/66e5287ccd1157bc24ed3bd6a5b7c4bf
译者:飞龙
前言
作为开发人员,您肯定听说过各种数据结构和算法。然而,您是否曾深入思考过它们及其对应用程序性能的影响?如果没有,现在是时候深入研究这个话题了,而本书是一个很好的开始!
本书涵盖了许多数据结构,从简单的开始,即数组和它们的一些变体,作为随机访问数据结构的代表。然后,介绍了列表,以及它们的排序变体。本书还解释了基于栈和队列的有限访问数据结构,包括优先队列。在此之后,我们向您介绍了字典数据结构,它允许您将键映射到值并进行快速查找。字典的排序变体也得到支持。如果您想要从高性能的集合相关操作中受益,可以使用另一种数据结构,即哈希集合。树是最强大的构造之一,它存在几种变体,如二叉树、二叉搜索树,以及自平衡树和堆。我们分析的最后一个数据结构是图,它受到许多有趣的算法主题的支持,如图遍历、最小生成树、节点着色以及在图中找到最短路径。前方有很多内容等待着您!
您是否有兴趣了解选择合适的数据结构对应用程序性能的影响?您想知道如何通过选择正确的数据结构和相应的算法来提高解决方案的质量和性能吗?您对这些数据结构可以应用于现实场景感到好奇吗?如果对这些问题中的任何一个回答是肯定的,让我们开始阅读本书,了解在开发 C#应用程序时可以使用的各种数据结构和算法。
数组、列表、栈、队列、字典、哈希集合、树、堆和图,以及相应的算法——在接下来的页面中等待着您的是广泛的主题范围!让我们开始冒险,迈出掌握数据结构和算法的第一步,这将有望对您的项目和作为软件开发人员的职业产生积极影响!
本书适合的读者
本书旨在面向希望了解在各种应用程序中可以使用的 C#中的数据结构和算法的开发人员,包括 Web 和移动解决方案。这里介绍的主题适合具有不同经验水平的程序员,即使是初学者也会发现有趣的内容。然而,至少具有关于面向对象编程等 C#编程语言的基本知识将是一个额外的优势。
为了更容易理解内容,本书配有许多插图和示例。此外,附带项目的源代码附加在各章节中。因此,您可以轻松运行示例应用程序并进行调试,而无需自己编写代码。
值得一提的是,代码可以简化,并且可能与最佳实践有所不同。此外,示例可能具有显著有限甚至没有安全检查和功能。在使用本书中提供的内容发布应用程序之前,应对应用程序进行彻底测试,以确保它在各种情况下(如传递不正确的数据的情况)能够正确运行。
本书涵盖的内容
第一章,入门,解释了使用正确的数据结构和算法的非常重要的作用,以及它对开发解决方案的性能的影响。该章简要介绍了 C#编程语言和各种数据类型,包括值类型和引用类型。然后,它介绍了 IDE 的安装和配置过程,以及创建新项目,开发示例应用程序,以及使用断点和逐步技术进行调试的过程。
第二章,数组和列表,涵盖了使用两种随机访问数据结构存储数据的场景,即数组和列表。首先,解释了三种数组的变体,即单维、多维和交错。您还将了解四种排序算法,即选择、插入、冒泡排序和快速排序。该章还涉及了几种列表的变体,如简单、排序、双向链接和循环链接。
第三章,栈和队列,解释了如何使用两种有限访问数据结构的变体,即栈和队列,包括优先队列。该章展示了如何在栈上执行push
和pop
操作,并在队列的情况下描述了enqueue
和dequeue
操作。为了帮助您理解这些主题,还提供了一些示例,包括汉诺塔游戏和模拟具有多个顾问和呼叫者的呼叫中心的应用程序。
第四章,字典和集合,侧重于与字典和集合相关的数据结构,这使得将键映射到值,执行快速查找,并在集合上执行各种操作成为可能。该章介绍了哈希表的非泛型和泛型变体,排序字典,以及高性能的集合操作解决方案,以及“排序”集合的概念。
第五章,树的变体,描述了一些与树相关的主题。它介绍了基本树,以及在 C#中的实现,并展示了这一概念的示例。该章还向您介绍了二叉树、二叉搜索树和自平衡树,即 AVL 和红黑树。该章的其余部分致力于堆作为基于树的结构,即二叉、二项式和斐波那契堆。
第六章,探索图形,包含了大量关于图形的信息,从基本概念的解释开始,包括节点和几种边的变体。还涵盖了在 C#中实现图形。该章介绍了图形遍历的两种模式,即深度优先和广度优先搜索。然后,它介绍了使用 Kruskal 和 Prim 算法的最小生成树的主题,节点着色问题,以及使用 Dijkstra 算法在图中找到最短路径的解决方案。
第七章,总结,是对前几章所学知识的总结。它简要分类了数据结构,将它们分为线性和非线性两组。最后,该章讨论了各种数据结构的多样化应用。
为了充分利用本书
本书旨在面向具有不同经验的程序员。然而,初学者也会发现一些有趣的内容。然而,至少具有关于 C#的基本知识,比如面向对象编程,将是一个额外的优势。
下载示例代码文件
您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packtpub.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip 适用于 Windows
-
Zipeg/iZip/UnRarX 适用于 Mac
-
7-Zip/PeaZip 适用于 Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/C-Sharp-Data-Structures-and-Algorithms
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/
上获得。去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/CSharpDataStructuresandAlgorithms_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码字、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。例如:"该类包含三个属性(即Id
、Name
和Role
),以及两个构造函数。"
代码块设置如下:
int[,] numbers = new int[,] =
{
{ 9, 5, -9 },
{ -11, 4, 0 },
{ 6, 115, 3 },
{ -12, -9, 71 },
{ 1, -6, -1 }
};
任何命令行输入或输出都以以下方式编写:
Enter the number: 10.5
The average value: 10.5 (...)
Enter the number: 1.5
The average value: 4.875
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"当显示消息“安装成功!”时,请单击“启动”按钮启动 IDE。"
警告或重要说明会显示为这样。
技巧和窍门会显示为这样。
第一章:入门
开发应用程序肯定是一件令人兴奋的工作,但也具有挑战性,特别是如果您需要解决涉及高级数据结构和算法的复杂问题。在这种情况下,您经常需要关注性能,以确保解决方案在资源有限的设备上能够平稳运行。这样的任务可能非常困难,可能需要对编程语言、数据结构和算法有相当的了解。
您知道吗,即使将一个数据结构替换为另一个,也可能导致性能结果增加数百倍?听起来不可能吗?也许,但这是真的!举个例子,我想告诉您一个我参与的项目的简短故事。其目标是优化在图形图表上查找块之间连接的算法。这样的连接应该在图表中的任何块移动时自动重新计算、刷新和重绘。当然,连接不能穿过块,也不能重叠其他线,并且交叉点和方向变化的数量应该是有限的。根据图表的大小和复杂性,性能结果会有所不同。然而,在进行测试时,我们得到了同一个测试用例的结果范围从 1 毫秒到近 800 毫秒。最令人惊讶的可能是,这样巨大的改进主要是通过...改变了两组数据结构来实现的。
现在,您可能会问自己一个显而易见的问题:在特定情况下应该使用哪些数据结构,以及可以用哪些算法来解决一些常见问题?不幸的是,答案并不简单。然而,在本书中,您将找到许多关于数据结构和算法的信息,以 C#编程语言的背景呈现,包括许多示例、代码片段和详细解释。这样的内容可以帮助您回答前面提到的问题,同时开发下一个伟大的解决方案,这些解决方案可以被世界各地的许多人使用!您准备好开始您的数据结构和算法之旅了吗?如果是的,让我们开始吧!
在本章中,您将涵盖以下主题:
-
编程语言
-
数据类型
-
IDE 的安装和配置
-
创建项目
-
输入和输出
-
启动和调试
编程语言
作为开发人员,您肯定听说过许多编程语言,如 C#、Java、C++、C、PHP 或 Ruby。在所有这些语言中,您可以使用各种数据结构,以及实现算法,来解决基本和复杂的问题。然而,每种语言都有其自身的特点,这在实现数据结构和相应的算法时可能是可见的。正如前面提到的,本书将专注于 C#编程语言,这也是本节的主要内容。
C#语言,发音为“C Sharp”,是一种现代的、通用的、强类型的、面向对象的编程语言,可用于开发各种应用程序,如 Web、移动、桌面、分布式和嵌入式解决方案,甚至游戏。它与各种其他技术和平台合作,包括 ASP.NET MVC、Windows Store、Xamarin、Windows Forms、XAML 和 Unity。因此,当您学习 C#语言,以及在这种编程语言的背景下更多地了解数据结构和算法时,您可以利用这些技能来创建多种特定类型的软件。
当前版本的语言是 C# 7.1。值得一提的是它与语言的以下版本(例如 2.0、3.0 和 5.0)的有趣历史,在这些版本中,已添加了新功能以增加语言的可能性并简化开发人员的工作。当您查看特定版本的发布说明时,您将看到语言如何随着时间的推移而得到改进和扩展。
C#编程语言的语法类似于其他语言,比如 Java 或 C++。因此,如果您了解这些语言,您应该很容易理解用 C#编写的代码。例如,与之前提到的语言类似,代码由以分号(;
)结尾的语句组成,花括号({
和}
)用于分组语句,比如在foreach
循环中。您还可以找到类似的代码结构,比如if
语句,或while
和for
循环。
在 C#语言中开发各种应用程序也因为许多额外的出色功能而变得简化,比如语言集成查询(LINQ),它允许开发人员以一致的方式从各种集合中获取数据,比如 SQL 数据库或 XML 文档。还有一些缩短所需代码的方法,比如使用 lambda 表达式、表达式主体成员、getter 和 setter,或者字符串插值。值得一提的是自动垃圾回收,它简化了释放内存的任务。当然,上述解决方案只是在 C#开发中可用功能的非常有限的子集。在本书的后续部分中,您将看到一些其他功能,以及示例和详细描述。
数据类型
在 C#语言中开发应用程序时,您可以使用各种数据类型,它们分为两组,即值类型和引用类型。它们之间的区别非常简单——值类型的变量直接包含数据,而引用类型的变量只是存储对数据的引用,如下所示:
正如您所看到的,值类型直接将其实际值存储在堆栈内存中,而引用类型只在此处存储引用。实际值位于堆内存中。因此,也可能有两个或更多引用类型的变量引用完全相同的值。
当然,值类型和引用类型之间的区别在编程时非常重要,您应该知道哪些类型属于上述组。否则,您可能会在代码中犯错,这可能会很难找到。例如,您应该记住在更新引用类型的数据时要小心,因为更改也可能会反映在引用相同对象的其他变量中。此外,您在使用等号(=
)运算符比较两个对象时也要小心,因为在比较两个引用类型的实例时,您可能会比较引用而不是数据本身。
C#语言还支持指针类型,可以声明为type* identifier
或void* identifier
。然而,这些类型超出了本书的范围。您可以在以下链接中了解更多信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/unsafe-code-pointers/pointer-types
。
值类型
为了让您更好地理解数据类型,让我们从对第一组(即值类型)的分析开始,它可以进一步分为结构和枚举。
更多信息请访问:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/value-types
。
结构
在结构体中,您可以访问许多内置类型,这些类型可以作为关键字或来自System
命名空间的类型使用。
其中之一是Boolean
类型(bool
关键字),它可以存储逻辑值,也就是两个值中的一个,即true
或false
。
至于存储整数值,您可以使用以下类型之一:Byte
(byte
关键字)、SByte
(sbyte
)、Int16
(short
)、UInt16
(ushort
)、Int32
(int
)、UInt32
(uint
)、Int64
(long
)和UInt64
(ulong
)。它们通过存储值的字节数和可用值的范围而有所不同。例如,short
数据类型支持范围从-32,768 到 32,767 的值,而uint
支持范围从 0 到 4,294,967,295 的值。整数类型中的另一种类型是Char
(char
),它表示单个 Unicode 字符,例如'a'
或'M'
。
在浮点值的情况下,您可以使用两种类型,即Single
(float
)和Double
(double
)。第一种使用 32 位,而第二种使用 64 位。因此,它们的精度有很大的不同。
此外,Decimal
类型(decimal
关键字)也是可用的。它使用 128 位,是货币计算的一个很好的选择。
C#编程语言中变量的一个示例声明如下:
int number;
您可以使用等号(=
)将值赋给变量,如下所示:
number = 500;
当然,声明和赋值可以在同一行中执行:
int number = 500;
如果您想声明和初始化一个不可变值,也就是一个常量,您可以使用const
关键字,如下面的代码行所示:
const int DAYS_IN_WEEK = 7;
有关内置数据类型的更多信息,以及完整的范围列表,请访问:msdn.microsoft.com/library/cs7y5x0x.aspx
。
枚举
除了结构体,值类型还包括枚举。每个枚举都有一组命名的常量来指定可用的值集。例如,您可以创建可用语言或支持的货币的枚举。一个示例定义如下:
enum Language { PL, EN, DE };
然后,您可以将定义的枚举用作数据类型,如下所示:
Language language = Language.PL;
switch (language)
{
case Language.PL: /* Polish version */ break;
case Language.DE: /* German version */ break;
default: /* English version */ break;
}
值得一提的是,枚举允许您用常量值替换一些魔术字符串(如"PL"
或"DE"
),这对代码质量有积极的影响。
您还可以从枚举的更高级特性中受益,例如更改基础类型或为特定常量指定值。您可以在此处找到更多信息:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/enum
。
引用类型
第二个主要类型组称为引用类型。作为一个快速提醒,引用类型的变量并不直接包含数据,因为它只是存储数据的引用。在这个组中,您可以找到三种内置类型,即string
、object
和dynamic
。此外,您可以声明类、接口和委托。
有关引用类型的更多信息,请访问:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/reference-types
。
字符串
通常需要存储一些文本值。您可以使用System
命名空间中的内置引用类型String
来实现这一目标,也可以使用string
关键字。string
类型是 Unicode 字符的序列。它可以有零个字符、一个或多个字符,或者string
变量可以设置为null
。
您可以对string
对象执行各种操作,例如连接或使用[]
运算符访问特定字符,如下所示:
string firstName = "Marcin", lastName = "Jamro";
int year = 1988;
string note = firstName + " " + lastName.ToUpper()
+ " was born in " + year;
string initials = firstName[0] + "." + lastName[0] + ".";
一开始,声明了firstName
变量,并将"Marcin"
赋给它。同样,"Jamro"
被设置为lastName
变量的值。在第三行,您连接了五个字符串(使用+
运算符),即firstName
的当前值,空格,lastName
的当前值转换为大写字符串(通过调用ToUpper
方法),字符串" was born in "
,以及year
变量的当前值。在最后一行,使用[]
运算符获取了firstName
和lastName
变量的第一个字符,并与两个点连接起来形成了缩写,即M.J.
,这些缩写作为initials
变量的值存储。
Format
静态方法也可用于构造字符串,如下所示:
string note = string.Format("{0} {1} was born in {2}",
firstName, lastName.ToUpper(), year);
在这个例子中,您指定了包含三个格式项的复合格式字符串,即firstName
(由{0}
表示),大写lastName
({1}
),以及year
({2}
)。要格式化的对象被指定为以下参数。
更多信息可在以下网址找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/string
。
还值得一提的是插值字符串,它使用插值表达式来构造一个string
。要使用这种方法创建一个string
,需要在“”之前放置$
字符,如下例所示:
string note = $"{firstName} {lastName.ToUpper()}
was born in {year}";
更多信息可在以下网址找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/interpolated-strings
。
对象
Object
类在System
命名空间中声明,它在 C#语言中开发应用程序时扮演着非常重要的角色,因为它是所有类的基类。这意味着内置值类型和内置引用类型,以及用户定义的类型,都是从Object
类派生出来的,也可以使用object
别名来访问。
由于object
类型是所有值类型的基本实体,这意味着可以将任何值类型的变量(例如int
或float
)转换为object
类型,也可以将object
类型的变量转换回特定的值类型。这些操作分别称为装箱(第一个)和拆箱(另一个)。它们如下所示:
int age = 28;
object ageBoxing = age;
int ageUnboxing = (int)ageBoxing;
更多信息可在以下网址找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/object
。
动态
除了已经描述的类型,还有dynamic
类型可供开发人员使用。它允许在编译期间绕过类型检查,以便您可以在运行时执行它。这种机制在访问一些应用程序编程接口(API)时非常有用,但本书不会使用它。
更多信息可在以下网址找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/dynamic
。
类
如前所述,C#是一种面向对象的语言,支持声明类以及各种成员,包括构造函数、终结器、常量、字段、属性、索引器、事件、方法和运算符,以及委托。此外,类支持继承和实现接口。还有静态、抽象和虚拟成员可用。
以下是一个示例类:
public class Person
{
private string _location = string.Empty;
public string Name { get; set; }
public int Age { get; set; }
public Person() => Name = "---";
public Person(string name, int age)
{
Name = name;
Age = age;
}
public void Relocate(string location)
{
if (!string.IsNullOrEmpty(location))
{
_location = location;
}
}
public float GetDistance(string location)
{
return DistanceHelpers.GetDistance(_location, location);
}
}
Person
类包含_location
私有字段,默认值设置为空字符串(string.Empty
),两个公共属性(Name
和Age
),一个默认构造函数,使用表达式体定义将Name
属性的值设置为---
,一个接受两个参数并设置属性值的额外构造函数,Relocate
方法更新私有字段的值,以及GetDistance
方法调用DistanceHelpers
类的GetDistance
静态方法,并返回两个城市之间的距离(以公里为单位)。
您可以使用new
运算符创建类的实例。然后,您可以对创建的对象执行各种操作,比如调用方法,如下所示:
Person person = new Person("Mary", 20);
person.Relocate("Rzeszow");
float distance = person.GetDistance("Warsaw");
更多信息可在此处找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/class
。
接口
在前面的部分中,提到了一个可以实现一个或多个接口的类。这意味着这样一个类必须实现所有在所有实现的接口中指定的方法、属性、事件和索引器。您可以使用interface
关键字在 C#语言中轻松定义接口。
举个例子,让我们来看一下以下代码:
public interface IDevice
{
string Model { get; set; }
string Number { get; set; }
int Year { get; set; }
void Configure(DeviceConfiguration configuration);
bool Start();
bool Stop();
}
IDevice
接口包含三个属性,分别表示设备型号(Model
)、序列号(Number
)和生产年份(Year
)。此外,它还具有三个方法的签名,分别是Configure
、Start
和Stop
。当一个类实现IDevice
接口时,它应该包含上述属性和方法。
更多信息可在此处找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/interface
。
委托
delegate
引用类型允许指定方法的必需签名。然后可以实例化委托,并像下面的代码中所示那样调用它。
delegate double Mean(double a, double b, double c);
static double Harmonic(double a, double b, double c)
{
return 3 / ((1 / a) + (1 / b) + (1 / c));
}
static void Main(string[] args)
{
Mean arithmetic = (a, b, c) => (a + b + c) / 3;
Mean geometric = delegate (double a, double b, double c)
{
return Math.Pow(a * b * c, 1 / 3.0);
};
Mean harmonic = Harmonic;
double arithmeticResult = arithmetic.Invoke(5, 6.5, 7);
double geometricResult = geometric.Invoke(5, 6.5, 7);
double harmonicResult = harmonic.Invoke(5, 6.5, 7);
}
在示例中,Mean
委托指定了用于计算三个浮点数的平均值的方法的必需签名。它使用 lambda 表达式(arithmetic
)、匿名方法(geometric
)和命名方法(harmonic
)进行实例化。通过调用Invoke
方法来调用每个委托。
更多信息可在此处找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/delegate
。
IDE 的安装和配置
在阅读本书时,您将看到许多示例,展示了数据结构和算法,以及详细的描述。代码的最重要部分将直接显示在书中。此外,完整的源代码也可以下载。当然,您可以只从书中阅读代码,但强烈建议您自己编写这样的代码,然后启动和调试程序,以了解各种数据结构和算法的运行方式。
如前所述,本书中展示的示例将使用 C#语言准备。为了保持简单,将创建基于控制台的应用程序,但这样的数据结构也可以用在其他类型的解决方案中。
示例项目将在Microsoft Visual Studio 2017 Community中创建。这个集成开发环境(IDE)是开发各种项目的综合解决方案。要下载、安装和配置它,您应该:
-
打开网站
www.visualstudio.com/downloads/
,并在 Visual Studio Community 2017 部分的 Visual Studio Downloads 标题下选择免费下载选项。安装程序的下载过程应该会自动开始。 -
运行下载的文件并按照说明开始安装。当显示可能选项的屏幕时,选择.NET 桌面开发选项,如下面的屏幕截图所示。然后,点击安装。安装可能需要一些时间,但可以使用获取和应用进度条来观察其进展。
- 当显示安装成功!的消息时,点击启动按钮启动 IDE。您将被要求使用 Microsoft 帐户登录。然后,您应该在“以熟悉的环境开始”部分选择适当的开发设置(如 Visual C#)。此外,您应该从蓝色、蓝色(额外对比)、深色和浅色中选择颜色主题。最后,点击“启动 Visual Studio”按钮。
创建项目
在启动 IDE 后,让我们继续创建一个新项目。在阅读本书时,根据特定章节提供的信息,将执行这样的过程多次,以创建示例应用程序。
要创建一个新项目:
-
在主菜单中点击“文件 | 新建 | 项目”。
-
在新项目窗口的左侧选择已安装 | Visual C# | Windows 经典桌面,如下面的屏幕截图所示。然后,在中间点击 Console App (.NET Framework)。您还应该输入项目的名称(名称)和解决方案的名称(解决方案名称),并通过按浏览按钮选择文件的位置(位置)。最后,点击确定以自动创建项目并生成必要的文件:
恭喜,您刚刚创建了第一个项目!但里面有什么呢?
让我们看看“解决方案资源管理器”窗口,它显示了项目的结构。值得一提的是,该项目包含在同名的解决方案中。当然,一个解决方案可以包含多个项目,这在开发更复杂的应用程序时是常见的情况。
如果找不到“解决方案资源管理器”窗口,可以通过从主菜单中选择“查看 | 解决方案资源管理器”选项来打开它。类似地,您可以打开其他窗口,如输出或类视图。如果在“查看”选项中找不到合适的窗口(例如 C#交互),让我们尝试在“查看 | 其他窗口”节点中找到它。
自动生成的项目(名为GettingStarted
)具有以下结构:
-
“属性”节点包含一个文件(
AssemblyInfo.cs
),其中包含有关应用程序的程序集的一般信息,例如标题、版权和版本。使用属性进行配置,例如AssemblyTitleAttribute
和AssemblyVersionAttribute
。 -
“引用”元素显示了项目使用的其他程序集或项目。值得注意的是,您可以通过从“引用”元素的上下文菜单中选择“添加引用”选项来轻松添加引用。此外,您可以使用 NuGet 软件包管理器安装其他软件包,该软件包可以通过从“引用”上下文菜单中选择“管理 NuGet 软件包”来启动。
在自己编写复杂模块之前,先看看已经可用的包是个好主意,因为适当的包可能已经为开发人员提供。在这种情况下,您不仅可以缩短开发时间,还可以减少引入错误的机会。
-
App.config
文件包含应用程序的基于可扩展标记语言(XML)的配置,包括.NET Framework 平台的最低支持版本号。 -
Program.cs
文件包含 C#语言中主类的代码。您可以通过更改以下默认实现来调整应用程序的行为:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GettingStarted
{
class Program
{
static void Main(string[] args)
{
}
}
}
Program.cs
文件的初始内容包含了GettingStarted
命名空间中Program
类的定义。该类包含了Main
静态方法,当应用程序启动时会自动调用。还包括了五个using
语句,分别是System
、System.Collections.Generic
、System.Linq
、System.Text
和System.Threading.Tasks
。
在继续之前,让我们在文件资源管理器中查看项目的结构,而不是在“解决方案资源管理器”窗口中。这些结构是否完全相同?
您可以通过在“解决方案资源管理器”窗口中的项目节点的上下文菜单中选择“在文件资源管理器中打开文件夹”选项来打开项目所在的目录。
首先,您可以看到自动生成的bin
和obj
目录。两者都包含与 IDE 中设置的配置相关的Debug
和Release
目录。构建项目后,bin
目录的子目录(即Debug
或Release
)包含.exe
、.exe.config
和.pdb
文件,而obj
目录中的子目录,例如,包含.cache
和一些临时.cs
文件。此外,没有References
目录,但是有项目的基于 XML 的.csproj
和.csproj.user
文件。类似地,基于解决方案的.sln
配置文件位于解决方案的目录中。
如果您正在使用版本控制系统,比如SVN或Git,您可以忽略bin
和obj
目录,以及.csproj.user
文件。所有这些都可以自动生成。
如果您想学习如何编写一些示例代码,以及启动和调试程序,让我们继续到下一节。
输入和输出
书的后面部分中展示的许多示例将需要与用户进行交互,特别是通过读取输入数据和显示输出。您可以按照本节中的说明轻松地向应用程序添加这些功能。
从输入中读取
应用程序可以使用System
命名空间中Console
静态类的几种方法从标准输入流中读取数据,例如ReadLine
和ReadKey
。这两者都在本节的示例中展示了。
让我们来看看下面的代码行:
string fullName = Console.ReadLine();
在这里,您使用ReadLine
方法。它会等待用户按下Enter键。然后,输入的文本将作为fullName
字符串变量的值存储。
以类似的方式,您可以读取其他类型的数据,例如int
,如下所示:
string numberString = Console.ReadLine();
int.TryParse(numberString, out int number);
在这种情况下,调用了相同的ReadLine
方法,并将输入的文本存储为numberString
变量的值。然后,您只需要将其解析为int
并将其存储为int
变量的值。您可以如何做到这一点?解决方案非常简单——使用Int32
结构的TryParse
静态方法。值得一提的是,这样的方法返回一个布尔值,指示解析过程是否成功完成。因此,当提供的string
表示不正确时,您可以执行一些额外的操作。
在下面的示例中,展示了关于DateTime
结构和TryParseExact
静态方法的类似情况:
string dateTimeString = Console.ReadLine();
if (!DateTime.TryParseExact(
dateTimeString,
"M/d/yyyy HH:mm",
new CultureInfo("en-US"),
DateTimeStyles.None,
out DateTime dateTime))
{
dateTime = DateTime.Now;
}
这个示例比之前的更复杂,所以让我们详细解释一下。首先,日期和时间的字符串表示被存储为dateTimeString
变量的值。然后,调用了DateTime
结构的TryParseExact
静态方法,传递了五个参数,即日期和时间的字符串表示(dateTimeString
)、日期和时间的预期格式(M/d/yyyy HH:mm
)、支持的文化(en-US
)、附加样式(None
),以及通过out
参数修饰符传递的输出变量(dateTime
)。
如果解析未成功完成,则将当前日期和时间(DateTime.Now
)分配给dateTime
变量。否则,dateTime
变量包含与用户提供的string
表示一致的DateTime
实例。
在涉及CultureInfo
类名称的代码部分中,您可能会看到以下错误:CS0246 The type or namespace name 'CultureInfo' could not be found (are you missing a using directive or an assembly reference?)
。这意味着您在文件顶部没有合适的using
语句。您可以通过单击显示在错误行左侧边缘的灯泡图标并选择using System.Globalization;
选项来轻松添加一个。IDE 将自动添加缺少的using
语句,错误将消失。
除了读取整行外,您还可以了解用户按下了哪个字符或功能键。为此,您可以使用ReadKey
方法,如下面的代码部分所示:
ConsoleKeyInfo key = Console.ReadKey();
switch (key.Key)
{
case ConsoleKey.S: /* Pressed S */ break;
case ConsoleKey.F1: /* Pressed F1 */ break;
case ConsoleKey.Escape: /* Pressed Escape */ break;
}
调用ReadKey
静态方法后,一旦用户按下任意键,按下的键的信息就会被存储为ConsoleKeyInfo
实例(在当前示例中为key
)。然后,您可以使用Key
属性获取表示特定键的枚举值(ConsoleKey
)。最后,使用switch
语句根据按下的键执行操作。在所示的示例中,支持三个键,即S,F1和Esc。
写入输出
现在,您知道如何读取输入数据,但如何向用户提问或在屏幕上显示结果呢?答案以及示例在本节中展示。
与读取数据一样,与标准输出流相关的操作使用System
命名空间中Console
静态类的方法执行,即Write
和WriteLine
。让我们看看它们的运作方式!
要写一些文本,您只需调用Write
方法,将文本作为参数传递。代码示例如下:
Console.Write("Enter a name: ");
前一行导致显示以下输出:
Enter a name:
这里重要的是,所写的文本后面没有跟随换行符。如果要写一些文本并移到下一行,可以使用WriteLine
方法,如下面的代码片段所示:
Console.WriteLine("Hello!");
执行此行代码后,将呈现以下输出:
Hello!
当然,您还可以在更复杂的情况下使用Write
和WriteLine
方法。例如,您可以向WriteLine
方法传递许多参数,即格式和附加参数,如下面代码的部分所示:
string name = "Marcin";
Console.WriteLine("Hello, {0}!", name);
在这种情况下,该行将包含Hello
,逗号,空格,name
变量的值(即Marcin
),以及感叹号。输出如下所示:
Hello, Marcin!
下一个示例呈现了一个更复杂的场景,涉及在餐厅预订桌子的确认。输出应具有格式Table [number] has been booked for [count] people on [date] at [time]
。您可以通过使用WriteLine
方法来实现这个目标,如下所示:
string tableNumber = "A100";
int peopleCount = 4;
DateTime reservationDateTime = new DateTime(
2017, 10, 28, 11, 0, 0);
CultureInfo cultureInfo = new CultureInfo("en-US");
Console.WriteLine(
"Table {0} has been booked for {1} people on {2} at {3}",
tableNumber,
peopleCount,
reservationDateTime.ToString("M/d/yyyy", cultureInfo),
reservationDateTime.ToString("HH:mm", cultureInfo));
该示例以声明四个变量开始,即tableNumber
(A100
),peopleCount
(4
),reservationDateTime
(2017 年 10 月 28 日上午 11:00),以及cultureInfo
(en-US
)。然后,调用WriteLine
方法,传递五个参数,即格式字符串,后跟应显示在标有{0}
,{1}
,{2}
和{3}
的位置的参数。值得一提的是最后两行,其中基于reservationDateTime
变量的当前值创建了表示日期(或时间)的字符串。
执行此代码后,将在输出中显示以下行:
Table A100 has been booked for 4 people on 10/28/2017 at 11:00
当然,在现实场景中,您将在同一代码中使用读取和写入相关的方法。例如,您可以要求用户提供一个值(使用Write
方法),然后读取输入的文本(使用ReadLine
方法)。
这个简单的例子,在本章的下一节中也很有用,如下所示。它允许用户输入与表格预订相关的数据,即桌号和人数,以及预订日期。当所有数据都输入后,将呈现确认。当然,用户将看到应提供的数据的信息:
using System;
using System.Globalization;
namespace GettingStarted
{
class Program
{
static void Main(string[] args)
{
CultureInfo cultureInfo = new CultureInfo("en-US");
Console.Write("The table number: ");
string table = Console.ReadLine();
Console.Write("The number of people: ");
string countString = Console.ReadLine();
int.TryParse(countString, out int count);
Console.Write("The reservation date (MM/dd/yyyy): ");
string dateTimeString = Console.ReadLine();
if (!DateTime.TryParseExact(
dateTimeString,
"M/d/yyyy HH:mm",
cultureInfo,
DateTimeStyles.None,
out DateTime dateTime))
{
dateTime = DateTime.Now;
}
Console.WriteLine(
"Table {0} has been booked for {1} people on {2}
at {3}",
table,
count,
dateTime.ToString("M/d/yyyy", cultureInfo),
dateTime.ToString("HH:mm", cultureInfo));
}
}
}
前面的代码片段是基于先前显示和描述的代码部分。启动程序并输入必要的数据后,输出可能如下所示:
The table number: A100
The number of people: 4
The reservation date (MM/dd/yyyy): 10/28/2017 11:00
Table A100 has been booked for 4 people on 10/28/2017 at 11:00
Press any key to continue . . .
编写代码时,改进其质量是个好主意。与 IDE 相关的有趣可能性之一是删除未使用的using
语句,以及对剩余语句进行排序。您可以通过在文本编辑器中选择“删除并排序使用”选项来轻松执行此操作。
启动和调试
不幸的是,编写的代码并不总是按预期工作。在这种情况下,最好开始调试,看看程序的运行方式,找到问题的源头并进行更正。这项任务对于复杂的算法特别有用,其中流程可能很复杂,因此仅通过阅读代码就很难分析。幸运的是,IDE 配备了各种调试功能,将在本节中介绍。
首先,让我们启动应用程序,看看它的运行情况!要这样做,您只需从下拉列表中选择适当的配置(在本例中为调试),然后单击主工具栏中带有绿色三角形和“开始”标题的按钮,或按下F5。要停止调试,您可以选择调试 | 停止调试,或按下Shift + F5。
您还可以在不调试的情况下运行应用程序。要这样做,请从主菜单中选择调试 | 启动无调试,或按下Ctrl + F5。
如前所述,有各种调试技术,但让我们从基于断点的调试开始,因为这是提供巨大机会的最常见方法之一。您可以在代码的任何行中放置断点。程序将在达到该行之前停止执行。然后,您可以查看特定变量的值,以检查应用程序是否按预期工作。
要添加断点,您可以单击左边的边距(在应放置断点的行旁边)或将光标放在应添加断点的行上,并按下F9键。在这两种情况下,将显示红色圆圈,并且给定行的代码将标有红色背景,如下截图中的第 17 行所示:
当执行程序时到达带有断点的行时,程序将停止,并且该行将标有黄色背景,边距图标也会更改,如截图中的第 15 行所示。现在,您可以通过简单地将光标移动到其名称上来检查变量的值。当前值将显示在工具提示中。
您还可以单击位于工具提示右侧的图钉图标,将其固定在编辑器中。然后,该值将在不必移动光标到变量名称上的情况下可见。一旦值发生变化,该值将自动刷新。结果如下截图所示。
IDE 可以根据当前执行的操作调整其外观和功能。例如,在调试时,您可以访问一些特殊的窗口,例如 Locals、Call Stack 和 Diagnostic Tools。第一个显示可用的本地变量及其类型和值。Call Stack 窗口显示有关以下调用方法的信息。最后一个(即 Diagnostic Tools)显示有关内存和 CPU 使用情况以及事件的信息。
此外,IDE 支持条件断点,仅当关联的布尔表达式计算为true
时才停止程序的执行。您可以通过选择上下文菜单中的 Conditions 选项来为给定的断点添加条件,该菜单在右键单击左侧边栏中的断点图标后显示。然后,断点设置窗口将出现,在那里您应该勾选条件复选框并指定条件表达式,例如在以下屏幕截图中显示的表达式。在示例中,只有当count
变量的值大于5
时,即count > 5
时,执行才会停止:
当执行停止时,您可以使用逐步调试技术。要将程序的执行移动到下一行(而不是加入另一个断点),您可以单击主工具栏中的 Step Over 图标,或按F10。如果要进入在执行停止的行中调用的方法,只需单击 Step Into 按钮或按F11。当然,您也可以通过单击 Continue 按钮或按F5来转到下一个断点。
IDE 中的下一个有趣功能称为 Immediate Window。它允许开发人员在程序执行停止时使用变量的当前值执行各种表达式。您只需在 Immediate Window 中输入表达式,然后按Enter键。示例如下屏幕截图所示:
在这里,通过执行table.ToLower()
返回表号的小写版本。然后,计算并显示当前日期和dateTime
变量之间的总分钟数。
摘要
这只是本书的第一章,但它包含了很多信息,在阅读剩下的章节时将会很有用。一开始,您看到使用适当的数据结构和算法并不是一件容易的事,但可能会对开发解决方案的性能产生重大影响。然后,简要介绍了 C#编程语言,重点介绍了各种数据类型,包括值类型和引用类型。还描述了类、接口和委托。
在本章的后续部分,介绍了 IDE 的安装和配置过程。然后,您学习了如何创建一个新项目,并详细描述了其结构。接下来,您看到了如何从标准输入流中读取数据,以及如何将数据写入标准输出流。读取和写入相关的操作也混合在一个示例中。
在本章结束时,您学会了如何运行示例程序,以及如何使用断点和逐步调试来找到问题的根源。此外,您还了解了 Immediate Window 功能的可能性。
介绍完毕后,您应该准备继续下一章,了解如何使用数组和列表,以及相关的算法。让我们开始吧!
第二章:数组和列表
作为开发人员,您肯定在应用程序中存储了各种集合,例如用户数据、书籍和日志。存储这些数据的一种自然方式是使用数组和列表。但是,您是否曾想过它们的变体?您是否听说过交错数组或循环链表?在本章中,您将看到这些数据结构的实际应用,以及示例和详细描述。这还不是全部,因为本章涉及许多关于数组和列表的主题,适合具有不同编程技能水平的开发人员。
在本章的开头,将介绍并将数组分为单维、多维和交错数组。您还将了解四种排序算法,即选择、插入、冒泡排序和快速排序。对于每一种算法,您将看到基于示例的说明、实现代码和逐步解释。
数组有很多可能性。然而,在使用 C#语言开发时可用的通用列表更加强大。在本章的剩余部分,您将看到如何使用几种列表的变体,例如简单、排序、双向和循环链表。对于每一个,都将展示一个示例的 C#代码,并附有详细描述。
本章将涵盖以下主题:
-
数组
-
排序算法
-
简单列表
-
排序列表
-
链表
-
循环链表
数组
让我们从数组数据结构开始。您可以使用它来存储许多相同类型的变量,例如int
,string
或用户定义的类。正如在介绍中提到的,在使用 C#语言开发应用程序时,您可以从以下图表中看到数组的几种变体。您不仅可以访问单维数组(表示为a),还可以访问多维(b)和交错(c)数组。所有这些的示例都在下图中显示:
重要的是,数组中的元素数量在初始化后无法更改。因此,您将无法轻松地在数组末尾添加新项或在数组中的特定位置插入新项。如果需要这样的功能,可以使用本章中描述的其他数据结构,例如通用列表。
您可以在以下链接找到有关数组的更多信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/
。
通过这个简短的描述,您应该已经准备好了解更多关于数组的特定变体,并查看一些 C#代码。因此,让我们继续学习数组的最简单变体,即单维数组。
单维数组
单维数组存储相同类型的项目集合,可以通过索引访问。重要的是要记住,在 C#中,数组的索引是从零开始的。这意味着第一个元素的索引等于0,而最后一个元素的索引等于数组长度减一。
在前面的图表中显示了一个示例数组(在左侧,表示为a)。它包含五个元素,其值分别为9,-11,6,-12和1。第一个元素的索引等于0,而最后一个元素的索引等于4。
要使用单维数组,您需要声明和初始化它。声明非常简单,因为您只需要指定元素类型和名称,如下所示:
type[] name;
以下行显示了具有整数值的数组的声明:
int[] numbers;
现在您知道如何声明数组了,但初始化呢?要将数组元素初始化为默认值,可以使用new
运算符,如下所示:
numbers = new int[5];
当然,您可以在同一行中组合声明和初始化,如下所示:
int[] numbers = new int[5];
不幸的是,所有元素目前都具有默认值,即整数值的情况下为零。因此,您需要设置特定元素的值。您可以使用[]
运算符和元素的索引来做到这一点,就像下面的代码片段中所示的那样:
numbers[0] = 9;
numbers[1] = -11; (...)
numbers[4] = 1;
此外,您可以使用以下一种变体将数组元素的声明和初始化组合为特定值:
int[] numbers = new int[] { 9, -11, 6, -12, 1 };
int[] numbers = { 9, -11, 6, -12, 1 };
当您在数组中有正确的元素值时,可以使用[]
运算符并指定索引来获取值,就像下面的代码行所示的那样:
int middle = numbers[2];
在这里,从名为numbers
的数组中获取第三个元素(索引等于2
)的值,并将其存储为middle
变量的值。
有关单维数组的更多信息可在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/single-dimensional-arrays
找到。
示例-月份名称
总结一下您所学到的关于单维数组的信息,让我们看一个简单的例子,其中数组用于存储英文月份的名称。这些名称应该是自动获取的,而不是在代码中硬编码的。
实现如下所示:
string[] months = new string[12];
for (int month = 1; month <= 12; month++)
{
DateTime firstDay = new DateTime(DateTime.Now.Year, month, 1);
string name = firstDay.ToString("MMMM",
CultureInfo.CreateSpecificCulture("en"));
months[month - 1] = name;
}
foreach (string month in months)
{
Console.WriteLine($"-> {month}");
}
首先,声明一个新的单维数组,并用默认值初始化。它包含12
个元素,用于存储一年中的月份名称。然后,使用for
循环来迭代所有月份的数字,即从1
到12
。对于每个月,创建表示特定月份第一天的DateTime
实例。
通过在DateTime
实例上调用ToString
方法,传递日期的正确格式(MMMM
),以及指定文化(例如en
),来获取月份的名称。然后,使用[]
运算符和元素的索引将名称存储在数组中。值得注意的是,索引等于当前month
变量的值减一。这种减法是必要的,因为数组中的第一个元素的索引等于零,而不是一。
代码的下一个有趣部分是foreach
循环,它遍历数组的所有元素。对于每个元素,在控制台中显示一行,即->
后面的月份名称。结果如下:
-> January
-> February (...)
-> November
-> December
如前所述,单维数组并非唯一可用的变体。您将在下一节中了解更多关于多维数组的信息。
多维数组
C#语言中的数组不一定只有一维。也可以创建二维甚至三维数组。首先,让我们看一个关于声明和初始化具有5
行和2
列的二维数组的例子:
int[,] numbers = new int[5, 2];
如果您想创建一个三维数组,可以使用以下代码:
int[, ,] numbers = new int[5, 4, 3];
当然,您也可以将声明与初始化结合起来,就像下面的例子中所示的那样:
int[,] numbers = new int[,] =
{
{ 9, 5, -9 },
{ -11, 4, 0 },
{ 6, 115, 3 },
{ -12, -9, 71 },
{ 1, -6, -1 }
};
对于从多维数组中访问特定元素的方式需要一些解释。让我们看下面的例子:
int number = numbers[2][1];
numbers[1][0] = 11;
在代码的第一行中,获取了第三行(索引等于2
)和第二列(索引等于1
)的值(即115
),并将其设置为number
变量的值。另一行将第二行和第一列中的-11
替换为11
。
有关多维数组的更多信息可在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/multidimensional-arrays
找到。
示例-乘法表
第一个示例展示了对二维数组进行基本操作,目的是呈现一个乘法表。它写入了从1
到10
范围内所有整数值的乘法结果,如下所示:
1 2 3 4 5 6 7 8 9 10
2 4 6 8 10 12 14 16 18 20
3 6 9 12 15 18 21 24 27 30
4 8 12 16 20 24 28 32 36 40
5 10 15 20 25 30 35 40 45 50
6 12 18 24 30 36 42 48 54 60
7 14 21 28 35 42 49 56 63 70
8 16 24 32 40 48 56 64 72 80
9 18 27 36 45 54 63 72 81 90
10 20 30 40 50 60 70 80 90 100
让我们来看一下数组的声明和初始化方法:
int[,] results = new int[10, 10];
在这里,创建了一个有10
行和10
列的二维数组,并将其元素初始化为默认值,即零。
当数组准备好后,您应该用乘法的结果填充它。这样的任务可以使用两个for
循环来执行:
for (int i = 0; i < results.GetLength(0); i++)
{
for (int j = 0; j < results.GetLength(1); j++)
{
results[i, j] = (i + 1) * (j + 1);
}
}
在前面的代码中,您可以找到在数组对象上调用的GetLength
方法。该方法返回特定维度中的元素数量,即第一个(当参数为0
时)和第二个(参数为1
时)。在两种情况下,根据数组初始化时指定的值,都返回了10
。
代码的另一个重要部分是设置二维数组中元素的值的方式。为此,您需要提供两个索引,例如results[i, j]
。
最后,您只需要呈现结果。您可以使用两个for
循环来做到这一点,就像填充数组一样。代码的这一部分如下所示:
for (int i = 0; i < results.GetLength(0); i++)
{
for (int j = 0; j < results.GetLength(1); j++)
{
Console.Write("{0,4}", results[i, j]);
}
Console.WriteLine();
}
乘法结果在转换为string
值后,长度不同,从一个字符(如2*2
的结果4
)到三个字符(10*10
的100
)。为了改善显示效果,需要始终在4
个字符上写入每个结果。因此,如果整数值占用的空间较小,就应该添加前导空格。例如,结果 1 将显示为三个前导空格(___1
,其中_
是空格),而100
只有一个(_100
)。您可以通过在调用Console
类的Write
方法时使用适当的复合格式字符串(即{0,4}
)来实现这个目标。
示例-游戏地图
另一个应用二维数组的例子是一个呈现游戏地图的程序。地图是一个有 11 行和 10 列的矩形。数组的每个元素指定了草地、沙地、水域或墙壁等类型的地形。地图上的每个位置都应该以特定的颜色显示(例如草地为绿色),并使用一个自定义字符来描述地形类型(例如水域为≈
),如截图所示:
首先,让我们声明枚举值TerrainEnum
,其中包括四个常量,即GRASS
、SAND
、WATER
和WALL
,如下所示:
public enum TerrainEnum
{
GRASS,
SAND,
WATER,
WALL
}
为了提高整个项目的可读性,建议在一个单独的文件中声明TerrainEnum
类型,命名为TerrainEnum.cs
。这个规则也应该适用于所有用户定义的类型,包括类。
然后,您创建了两个扩展方法,可以根据地形类型(分别是GetColor
和GetChar
)获取特定的颜色和字符。这些扩展方法在TerrainEnumExtensions
类中声明,如下所示:
public static class TerrainEnumExtensions
{
public static ConsoleColor GetColor(this TerrainEnum terrain)
{
switch (terrain)
{
case TerrainEnum.GRASS: return ConsoleColor.Green;
case TerrainEnum.SAND: return ConsoleColor.Yellow;
case TerrainEnum.WATER: return ConsoleColor.Blue;
default: return ConsoleColor.DarkGray;
}
}
public static char GetChar(this TerrainEnum terrain)
{
switch (terrain)
{
case TerrainEnum.GRASS: return '\u201c';
case TerrainEnum.SAND: return '\u25cb';
case TerrainEnum.WATER: return '\u2248';
default: return '\u25cf';
}
}
}
值得一提的是,GetChar
方法根据TerrainEnum
值返回适当的 Unicode 字符。例如,在WATER
常量的情况下,返回了'\u2248'
值,这是≈
字符的表示。
您听说过扩展方法吗?如果没有,可以将其视为“添加”到特定现有类型(内置或用户定义)的方法,可以像定义实例方法一样调用它们。扩展方法的声明要求您在静态类中指定它作为带有第一个参数指示要“添加”此方法的类型的静态方法,并使用this
关键字。您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods
找到更多信息。
让我们来看看Program
类中Main
方法的主体。在这里,您配置地图,并在控制台中呈现它。代码如下:
TerrainEnum[,] map =
{
{ TerrainEnum.SAND, TerrainEnum.SAND, TerrainEnum.SAND,
TerrainEnum.SAND, TerrainEnum.GRASS, TerrainEnum.GRASS,
TerrainEnum.GRASS, TerrainEnum.GRASS, TerrainEnum.GRASS,
TerrainEnum.GRASS }, (...)
{ TerrainEnum.WATER, TerrainEnum.WATER, TerrainEnum.WATER,
TerrainEnum.WATER, TerrainEnum.WATER, TerrainEnum.WATER,
TerrainEnum.WATER, TerrainEnum.WALL, TerrainEnum.WATER,
TerrainEnum.WATER }
};
Console.OutputEncoding = UTF8Encoding.UTF8;
for (int row = 0; row < map.GetLength(0); row++)
{
for (int column = 0; column < map.GetLength(1); column++)
{
Console.ForegroundColor = map[row, column].GetColor();
Console.Write(map[row, column].GetChar() + " ");
}
Console.WriteLine();
}
Console.ForegroundColor = ConsoleColor.Gray;
关于获取颜色和获取特定地图位置的字符的方式可能会有所帮助。这两个操作都是使用“添加”到TerrainEnum
用户定义类型的扩展方法执行的。因此,您首先获取特定地图位置的TerrainEnum
值(使用[]
运算符和两个索引),然后调用适当的扩展方法,即GetChar
或GetColor
。要使用 Unicode 值,您不应忘记通过将UTF8Encoding.UTF8
值设置为OutputEncoding
属性来选择 UTF-8 编码。
到目前为止,您已经了解了单维和多维数组,但本书还有一个变体需要介绍。让我们继续阅读,以了解更多信息。
交错数组
本书中描述的数组的最后一种变体是交错数组,也称为数组的数组。听起来很复杂,但幸运的是,它非常简单。交错数组可以理解为单维数组,其中每个元素都是另一个数组。当然,这样的内部数组可以具有不同的长度,甚至可以未初始化。
如果您看一下以下图表,您将看到一个具有四个元素的交错数组的示例。第一个元素有一个具有三个元素(9
,5
,-9
)的数组,第二个元素有一个具有五个元素(0
,-3
,12
,51
,-3
)的数组,第三个未初始化(NULL
),而最后一个是一个只有一个元素(54
)的数组:
在继续示例之前,值得一提的是声明和初始化交错数组的方式,因为它与已经描述的数组有些不同。让我们看一下以下代码片段:
int[][] numbers = new int[4][];
numbers[0] = new int[] { 9, 5, -9 };
numbers[1] = new int[] { 0, -3, 12, 51, -3 };
numbers[3] = new int[] { 54 };
在第一行中,您可以看到具有四个元素的单维数组的声明。每个元素都是另一个整数值的单维数组。当执行第一行时,numbers
数组将用默认值NULL
初始化。因此,您需要手动初始化特定元素,如代码的下面三行所示。值得注意的是,第三个元素未初始化。
您还可以以不同的方式编写前面的代码,如下所示:
int[][] numbers =
{
new int[] { 9, 5, -9 },
new int[] { 0, -3, 12, 51, -3 },
NULL,
new int[] { 54 }
};
对于访问交错数组中的特定元素的方法也需要一些说明。您可以按以下方式执行此操作:
int number = numbers[1][2];
number[1][3] = 50;
代码的第一行将number
变量的值设置为12
,即数组中的第三个元素(索引等于2
)的值,这是交错数组的第二个元素。另一行将数组中的第四个元素的值更改为50
,这是交错数组的第二个元素,从51
更改为50
。
有关交错数组的更多信息,请访问docs.microsoft.com/en-us/dotnet/csharp/programming-guide/arrays/jagged-arrays
。
示例-年度交通计划
在引入了交错数组之后,让我们继续举个例子。您将看到如何开发一个程序,为整年的交通制定一个计划。对于每个月的每一天,应用程序会绘制出一种可用的交通工具。最后,程序会呈现生成的计划,如下面的屏幕截图所示:
首先,让我们声明一个枚举类型,其中包含代表可用交通类型的常量,即汽车、公共汽车、地铁、自行车或步行,如下所示:
public enum TransportEnum
{
CAR,
BUS,
SUBWAY,
BIKE,
WALK
}
接下来,创建两个扩展方法,它们返回控制台中给定交通工具的表示的字符和颜色。代码如下所示:
public static class TransportEnumExtensions
{
public static char GetChar(this TransportEnum transport)
{
switch (transport)
{
case TransportEnum.BIKE: return 'B';
case TransportEnum.BUS: return 'U';
case TransportEnum.CAR: return 'C';
case TransportEnum.SUBWAY: return 'S';
case TransportEnum.WALK: return 'W';
default: throw new Exception("Unknown transport");
}
}
public static ConsoleColor GetColor(
this TransportEnum transport)
{
switch (transport)
{
case TransportEnum.BIKE: return ConsoleColor.Blue;
case TransportEnum.BUS: return ConsoleColor.DarkGreen;
case TransportEnum.CAR: return ConsoleColor.Red;
case TransportEnum.SUBWAY:
return ConsoleColor.DarkMagenta;
case TransportEnum.WALK:
return ConsoleColor.DarkYellow;
default: throw new Exception("Unknown transport");
}
}
}
前面的代码不需要额外的解释,因为它与本章中已经呈现的代码非常相似。现在让我们继续到Program
类的Main
方法中的代码,它将分部分显示和描述。
在第一部分中,创建了一个交错数组,并用适当的值填充。假设交错数组有 12 个元素,代表当前年份的月份。每个元素都是一个具有TransportEnum
值的单维数组。这样的内部数组的长度取决于给定月份的天数。例如,对于一月,它设置为 31 个元素,对于四月,它设置为 30 个元素。代码如下所示:
Random random = new Random();
int transportTypesCount =
Enum.GetNames(typeof(TransportEnum)).Length;
TransportEnum[][] transport = new TransportEnum[12][];
for (int month = 1; month <= 12; month++)
{
int daysCount = DateTime.DaysInMonth(
DateTime.Now.Year, month);
transport[month - 1] = new TransportEnum[daysCount];
for (int day = 1; day <= daysCount; day++)
{
int randomType = random.Next(transportTypesCount);
transport[month - 1][day - 1] = (TransportEnum)randomType;
}
}
让我们分析前面的代码。首先,创建了Random
类的一个新实例。稍后将用于从可用的交通工具中选择合适的交通工具。接下来,获取了TransportEnum
枚举类型中的常量数量,即可用交通类型的数量。然后,创建了交错数组,并使用for
循环来遍历一年中的所有月份。在每次迭代中,使用DateTime
的DaysInMonth
静态方法获取天数,并使用零初始化一个数组(作为交错数组的一个元素)。在下一行代码中,您可以看到下一个for
循环,它遍历月份的所有天。在此循环中,您会绘制一种交通类型,并将其设置为交错数组的一个元素的适当值。
代码的下一部分与在控制台中呈现计划的过程有关:
string[] monthNames = GetMonthNames();
int monthNamesPart = monthNames.Max(n => n.Length) + 2;
for (int month = 1; month <= transport.Length; month++)
{
Console.Write(
$"{monthNames[month - 1]}:".PadRight(monthNamesPart));
for (int day = 1; day <= transport[month - 1].Length; day++)
{
Console.ForegroundColor = ConsoleColor.White;
Console.BackgroundColor =
transport[month - 1][day - 1].GetColor();
Console.Write(transport[month - 1][day - 1].GetChar());
Console.BackgroundColor = ConsoleColor.Black;
Console.ForegroundColor = ConsoleColor.Gray;
Console.Write(" ");
}
Console.WriteLine();
}
首先,使用GetMonthNames
方法创建一个包含月份名称的单维数组,稍后将对其进行描述。然后,将monthNamesPart
变量的值设置为存储月份名称的文本的最大必要长度。为此,使用 LINQ 表达式来从月份名称集合中找到文本的最大长度。获得的结果增加 2,以保留冒号和空格的位置。
C#语言的一个伟大特性是其使用 LINQ 的能力。这样的机制使得不仅可以从各种集合中获取数据,还可以以一致的方式从结构化查询语言(SQL)数据库和可扩展标记语言(XML)文档中获取数据。您可以在docs.microsoft.com/dotnet/csharp/linq/index
上阅读更多内容。
然后,使用for
循环来遍历交错数组的所有元素,即遍历所有月份。在每次迭代中,在控制台中呈现月份的名称。然后,使用下一个for
循环来遍历交错数组当前元素的所有元素,即遍历月份的所有天。对于每个元素,设置适当的颜色(用于背景和前景),并呈现适当的字符。
最后,让我们来看一下GetMonthNames
方法的实现:
private static string[] GetMonthNames()
{
string[] names = new string[12];
for (int month = 1; month <= 12; month++)
{
DateTime firstDay = new DateTime(
DateTime.Now.Year, month, 1);
string name = firstDay.ToString("MMMM",
CultureInfo.CreateSpecificCulture("en"));
names[month - 1] = name;
}
return names;
}
这段代码不需要额外的解释,因为它是基于已经在单维数组示例中描述的代码。
排序算法
有许多算法对数组执行各种操作。然而,最常见的任务之一是对数组进行排序,以便将其元素按正确的顺序排列,无论是升序还是降序。排序算法的主题涉及许多方法,包括选择排序、插入排序、冒泡排序和快速排序,这些将在本章的这一部分中详细解释。
选择排序
让我们从选择排序开始,这是最简单的排序算法之一。该算法将数组分为已排序和未排序两部分。在接下来的迭代中,算法找到未排序部分中的最小元素,并将其与未排序部分中的第一个元素交换。听起来很简单,不是吗?
为了更好地理解算法,让我们看一下具有九个元素的数组的以下迭代(-11,12,-42,0,1,90,68,6,-9)的情况,如下图所示:
为了简化分析,使用粗体线来表示数组的已排序和未排序部分之间的边界。在开始(步骤 1)时,边界位于数组顶部,这意味着已排序部分为空。因此,算法找到未排序部分中的最小值(-42)并将其与该部分中的第一个元素(-11)交换。结果显示在步骤 2中,其中已排序部分包含一个元素(-42),而未排序部分包含八个元素。上述步骤重复执行几次,直到未排序部分只剩下一个元素。最终结果显示在步骤 9中。
现在你知道了选择排序算法的工作原理,但在前面的图表中显示的步骤左侧的 i
和 m
指示器扮演了什么角色?它们与该算法的实现中使用的变量有关。因此,现在是时候看看 C# 语言中的代码了。
算法实现为 SelectionSort
静态类,具有 Sort
通用静态方法,如下代码片段所示:
public static class SelectionSort
{
public static void Sort<T>(T[] array) where T : IComparable
{
for (int i = 0; i < array.Length - 1; i++)
{
int minIndex = i;
T minValue = array[i];
for (int j = i + 1; j < array.Length; j++)
{
if (array[j].CompareTo(minValue) < 0)
{
minIndex = j;
minValue = array[j];
}
}
Swap(array, i, minIndex);
}
} (...)
}
Sort
方法接受一个参数,即应该排序的数组(array
)。在方法内部,使用 for
循环来迭代元素,直到未排序部分只剩下一个项目。因此,循环的迭代次数等于数组长度减一(array.Length-1
)。在每次迭代中,另一个 for
循环用于找到未排序部分中的最小值(minValue
,从 i+1
索引到数组末尾),并存储最小值的索引(minIndex
,在前面的图表中称为 m
指示器)。然后,未排序部分中的最小元素(索引为 minIndex
)与未排序部分中的第一个元素(索引为 i
)进行交换,使用 Swap
辅助方法,其实现如下:
private static void Swap<T>(T[] array, int first, int second)
{
T temp = array[first];
array[first] = array[second];
array[second] = temp;
}
如果你想测试选择排序算法的实现,可以将以下代码放入 Program
类的 Main
方法中:
int[] integerValues = { -11, 12, -42, 0, 1, 90, 68, 6, -9 };
SelectionSort.Sort(integerValues);
Console.WriteLine(string.Join(" | ", integerValues));
在前面的代码中,声明并初始化了一个新数组。然后调用 Sort
静态方法,传递数组作为参数。最后,通过连接数组元素(以 |
字符分隔)创建了一个 string
值,并在控制台中显示,如下所示:
-42 | -11 | -9 | 0 | 1 | 6 | 12 | 68 | 90
通过使用通用方法,你可以轻松地使用创建的类来对各种数组进行排序,例如浮点数或字符串。示例代码如下:
string[] stringValues = { "Mary", "Marcin", "Ann", "James",
"George", "Nicole" };
SelectionSort.Sort(stringValues);
Console.WriteLine(string.Join(" | ", stringValues));
因此,你将收到以下输出:
Ann | George | James | Marcin | Mary | Nicole
在讨论各种算法时,最重要的话题之一是计算复杂性,特别是时间复杂性。它有一些变体,例如最坏或平均情况。复杂性可以解释为算法在输入大小(n)上需要执行的基本操作数量。时间复杂性可以使用大 O 表示法来指定,例如O(n)、O(n²)或O(n log(n))。但是,这是什么意思呢?O(n)表示操作数量与输入大小(n)呈线性增长。O(n²)变体称为二次,而O(n log(n))称为线性对数。还有其他变体,例如O(1),它是常数。
在选择排序的情况下,最坏和平均时间复杂度都是O(n²)。为什么?让我们看一下代码来回答这个问题。有两个循环(一个在另一个内部),每个循环都遍历数组的许多元素。因此,复杂性被表示为O(n²)。
有关选择排序及其实现的更多信息可以在以下网址找到:
您刚刚了解了第一个排序算法!如果您对下一个排序方法感兴趣,请继续阅读下一节,介绍插入排序。
插入排序
插入排序是另一种算法,可以简单地对单维数组进行排序,如下图所示。与选择排序类似,数组被分为两部分,即排序和未排序。但是,一开始,第一个元素包括在排序部分中。在每次迭代中,算法从未排序部分中取出第一个元素,并将其放在排序部分的适当位置,以使排序部分保持正确的顺序。这样的操作重复,直到未排序部分为空。
让我们看一个使用插入排序对包含九个元素(-11、12、-42、0、1、90、68、6、-9)的数组进行排序的例子,如下图所示:
一开始,排序部分中只有一个元素(-11)(步骤 1)。然后,在未排序部分中找到最小的元素(-42),并将其移动到排序部分的正确位置,即数组的开头,执行一系列交换操作(步骤 2和3)。因此,排序部分的长度增加到两个元素,即-42和-11。这样的操作重复,直到未排序部分为空(步骤 22)。
插入排序的实现代码非常简单:
public static class InsertionSort
{
public static void Sort<T>(T[] array) where T : IComparable
{
for (int i = 1; i < array.Length; i++)
{
int j = i;
while (j > 0 && array[j].CompareTo(array[j - 1]) < 0)
{
Swap(array, j, j - 1);
j--;
}
}
} (...)
}
与选择排序类似,实现是在一个新类中提供的,即InsertionSort
。静态泛型Sort
方法执行有关排序的操作,并将数组作为参数。在这个方法中,使用for
循环来迭代未排序部分中的所有元素。因此,i
变量的初始值设置为1
,而不是0
。在for
循环的每次迭代中,执行while
循环,将数组的未排序部分中的第一个元素(索引等于i
变量的值)移动到排序部分的正确位置,使用与选择排序中所示的相同实现的Swap
辅助方法。测试插入排序的方式也非常相似,但应该使用另一个类名,即InsertionSort
而不是SelectionSort
。
有关插入排序及其实现的更多信息可以在以下网址找到:
最后,值得一提的是插入排序的时间复杂度。与选择排序类似,最坏和平均时间复杂度均为O(n²)。如果你看一下代码,你还会看到两个循环(for
和while
)嵌套在一起,这取决于输入大小,可能会迭代多次。
冒泡排序
书中介绍的第三种排序算法是冒泡排序。它的操作方式非常简单,因为算法只是遍历数组并比较相邻元素。如果它们的位置不正确,就交换它们。听起来很简单,但这个算法并不是很高效,使用大型集合可能会导致性能问题。
为了更好地理解算法的工作原理,让我们看一下以下图表,展示了算法在对一个包含九个元素(-11,12,-42,0,1,90,68,6,-9)的单维数组进行排序时的操作:
正如你所看到的,在每一步中,算法比较数组中的两个相邻元素并在必要时交换它们。例如,在步骤 1中,比较了-11和12,但它们已经按正确顺序排列,因此不需要交换这些元素。在步骤 2中,比较了下一个相邻元素(即12和-42)。这次,这些元素没有按正确顺序排列,因此它们被交换了。上述操作被执行了多次。最后,数组将被排序,如步骤 72所示。
这个算法看起来很简单,但实现呢?它也是如此简单吗?幸运的是,是的!你只需要使用两个循环,比较相邻元素,并在必要时交换它们。就是这样!让我们看一下以下代码片段:
public static class BubbleSort
{
public static void Sort<T>(T[] array) where T : IComparable
{
for (int i = 0; i < array.Length; i++)
{
for (int j = 0; j < array.Length - 1; j++)
{
if (array[j].CompareTo(array[j + 1]) > 0)
{
Swap(array, j, j + 1);
}
}
}
} (...)
}
BubbleSort
类中声明的Sort
静态泛型方法包含了冒泡排序算法的实现。如前所述,使用了两个for
循环,以及一个比较和调用Swap
方法(与先前描述的排序算法的情况相同)。此外,你可以使用类似的代码来测试实现,但不要忘记将类的名称替换为BubbleSort
。
还可以通过在实现中引入简单的修改来使用冒泡排序算法的更优化版本。这是基于这样的假设:当在数组的一次迭代中未发现任何更改时,比较应该停止。修改后的代码如下:
public static T[] Sort<T>(T[] array) where T : IComparable
{
for (int i = 0; i < array.Length; i++)
{
bool isAnyChange = false;
for (int j = 0; j < array.Length - 1; j++)
{
if (array[j].CompareTo(array[j + 1]) > 0)
{
isAnyChange = true;
Swap(array, j, j + 1);
}
}
if (!isAnyChange)
{
break;
}
}
return array;
}
通过引入这样一个简单的修改,比较的次数可以显著减少。在前面的例子中,它从 72 步减少到 56 步。
有关冒泡排序及其实现的更多信息可以在以下网址找到:
在转向下一个排序算法之前,值得一提的是冒泡排序的时间复杂度。你可能已经猜到,最坏和平均情况都与选择和插入排序相同,即O(n²)。
快速排序
本书中描述的最后一个排序算法名为快速排序。它是一种流行的分而治之算法之一,将问题分解为一组较小的问题。此外,这种算法为开发人员提供了一种有效的排序方式。这是否意味着它的思想和实现非常复杂?幸运的是,不是!您将在本节中了解算法的工作原理,以及它的实现代码是什么样子的。让我们开始吧!
算法是如何工作的?首先,它选择某个值(例如来自数组的第一个或中间元素)作为枢轴。然后,它重新排列数组,使得小于或等于枢轴的值放在它之前(形成较低的子数组),而大于枢轴的值放在它之后(较高的子数组)。这个过程称为分区。本书中使用霍尔分区方案。接下来,算法递归地对上述每个子数组进行排序。当然,每个子数组进一步分成下一个两个子数组,依此类推。当子数组中有一个或零个元素时,递归调用停止,因为在这种情况下没有需要排序的内容。
前面的描述可能听起来有点复杂,所以让我们看一个例子:
示例展示了快速排序算法如何对一个具有九个元素的一维数组(-11, 12, -42, 0, 1, 90, 68, 6, -9)进行排序。在这种情况下,假设枢轴被选择为当前正在排序的子数组的第一个元素的值。在步骤 1中,值-11被选择为枢轴。然后,需要重新排列数组。因此,-11与-42交换,12与-11交换,以确保只有小于或等于枢轴的值(-42, -11)在较低的子数组中,而大于枢轴的值(12, 0, 1, 90, 68, 6, -9)放在较高的子数组中。然后,对上述两个子数组,即(-42, 11)和(12, 0, 1, 90, 68, 6, -9)递归调用算法,因此它们以与输入数组相同的方式进行分析。
例如,步骤 5显示值12被选择为枢轴。分区后,子数组分为两个其他子数组,即(-9, 0, 1, 6, 12)和(68, 90)。对于这两个子数组,选择其他的枢轴元素,即-9和68。对数组的所有剩余部分执行这样的操作后,你将得到最终结果,如图中右侧所示(步骤 15)。
值得一提的是,在该算法的其他实现中,枢轴可以以不同的方式选择。例如,让我们看看在选择数组的中间元素的值时,以下步骤将如何改变:
如果你理解算法的工作原理,让我们继续实现。这比之前展示的例子更复杂,它使用递归来调用子数组的排序方法。代码放在QuickSort
类中:
public static class QuickSort
{
public static void Sort<T>(T[] array) where T : IComparable
{
Sort(array, 0, array.Length - 1);
} (...)
}
QuickSort
类包含Sort
方法的两个变体。第一个只接受一个参数,即应该排序的数组,并且在前面的代码片段中显示。它只调用Sort
方法的另一个变体,这使得可以指定指示应该排序数组的哪一部分的下限和上限索引。Sort
方法的另一个版本在这里显示:
private static T[] Sort<T>(T[] array, int lower, int upper)
where T : IComparable
{
if (lower < upper)
{
int p = Partition(array, lower, upper);
Sort(array, lower, p);
Sort(array, p + 1, upper);
}
return array;
}
Sort
方法通过比较lower
和upper
变量的值来检查数组(或子数组)是否至少有两个元素。在这种情况下,它调用Partition
方法,该方法负责分区阶段,然后递归调用Sort
方法以获得两个子数组,即较低(从lower
到p
的索引)和较高(从p+1
到upper
的索引)。
有关分区的代码显示在这里:
private static int Partition<T>(T[] array, int lower, int upper)
where T : IComparable
{
int i = lower;
int j = upper;
T pivot = array[lower];
// or: T pivot = array[(lower + upper) / 2];
do
{
while (array[i].CompareTo(pivot) < 0) { i++; }
while (array[j].CompareTo(pivot) > 0) { j--; }
if (i >= j) { break; }
Swap(array, i, j);
}
while (i <= j);
return j;
}
首先,选择枢轴值并将其存储为pivot
变量的值。如前面的代码片段所示,可以以各种方式选择它,例如取第一个元素的值(如前面的代码片段所示),取中间元素的值(如前面的代码中的注释所示),甚至取随机值。然后,使用do-while
循环根据 Hoare 分区方案重新排列数组,使用比较并交换元素。最后,返回j
变量的当前值。
所呈现的实现是基于 Hoare 分区方案的,其伪代码和解释在en.wikipedia.org/wiki/Quicksort
中呈现。有各种可能的实现快速排序的方式。您可以在en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Quicksort
中找到更多信息。
时间复杂度呢?您认为它与选择、插入和冒泡排序相比有所不同吗?如果是这样,你是对的!它的平均时间复杂度为O(n log(n)),尽管最坏时间复杂度为O(n²)。
简单列表
数组真的是非常有用的数据结构,它们应用于许多算法中。然而,在某些情况下,由于其性质,它们的应用可能会变得复杂,这不允许增加或减少已创建数组的长度。如果您不知道要存储在集合中的元素的总数,该怎么办?您需要创建一个非常大的数组,然后只是不使用不必要的元素吗?这样的解决方案听起来不好,对吧?一个更好的方法是使用数据结构,如果有必要,可以动态增加集合的大小。
数组列表
满足此要求的第一个数据结构是数组列表,它由System.Collections
命名空间中的ArrayList
类表示。您可以使用此类存储大量数据,必要时可以轻松添加新元素。当然,您也可以删除它们,计算项目数,并找到存储在数组列表中的特定值的索引。
你怎么做到的?让我们看看以下代码:
ArrayList arrayList = new ArrayList();
arrayList.Add(5);
arrayList.AddRange(new int[] { 6, -7, 8 });
arrayList.AddRange(new object[] { "Marcin", "Mary" });
arrayList.Insert(5, 7.8);
在第一行中,创建了ArrayList
类的一个新实例。然后,您可以使用Add
,AddRange
和Insert
方法向数组列表添加新元素。第一个(即Add
)允许您在列表末尾添加新项目。AddRange
方法在数组列表末尾添加一系列元素,而Insert
可以用于将元素放置在集合中的指定位置。当执行前面的代码时,数组列表将包含以下元素:5
,6
,-7
,8
,"Marcin"
,7.8
和"Mary"
。正如您所看到的,数组列表中存储的所有项目都是object
类型。因此,您可以同时在同一集合中放置各种类型的数据。
如果要指定列表中存储的每个元素的类型,可以使用泛型List
类,该类在ArrayList
之后描述。
值得一提的是,您可以使用索引轻松访问数组列表中的特定元素,如下面两行代码所示:
object first = arrayList[0];
int third = (int)arrayList[2];
让我们看看第二行中的int
转换。这种转换是必要的,因为数组列表存储object
值。与数组的情况一样,在访问集合中的特定元素时使用基于零的索引。
当然,您可以使用foreach
循环来遍历所有项目,如下所示:
foreach (object element in arrayList)
{
Console.WriteLine(element);
}
这还不是全部!ArrayList
类有一组属性和方法,您可以在开发应用程序时使用这些属性和方法利用上述数据结构。首先,让我们看一下Count
和Capacity
属性:
int count = arrayList.Count;
int capacity = arrayList.Capacity;
第一个(Count
)返回存储在数组列表中的元素数量,而另一个(Capacity
)指示可以存储多少元素。如果在向数组列表添加新元素后检查Capacity
属性的值,您将看到该值会自动增加以准备新项目的位置。这在下图中显示了Count
(作为A)和Capacity
(B)之间的差异:
下一个常见且重要的任务是检查数组列表是否包含具有特定值的元素。您可以通过调用Contains
方法来执行此操作,如下面的代码行所示:
bool containsMary = arrayList.Contains("Mary");
如果在数组列表中找到指定的值,则返回true
值。否则,返回false
。使用此方法,您可以检查元素是否存在于集合中。但是,如何找到此元素的索引?为此,您可以使用IndexOf
或LastIndexOf
方法,如下面的代码行所示:
int minusIndex = arrayList.IndexOf(-7);
IndexOf
方法返回数组列表中元素的第一次出现的索引,而LastIndexOf
返回最后一次出现的索引。如果未找到值,则该方法返回-1
。
除了向数组列表添加一些项目之外,您还可以轻松地删除添加的元素,如下面的代码所示:
arrayList.Remove(5);
要从数组列表中删除项目,可以使用多种方法,即Remove
,RemoveAt
和RemoveRange
。第一个(Remove
)删除作为参数提供的值的第一次出现。RemoveAt
方法删除具有与作为参数传递的值相等的索引的项目,而另一个(RemoveRange
)使您可以从提供的索引开始删除指定数量的元素。而且,如果要删除所有元素,可以使用Clear
方法。
在其他方法中,值得一提的是Reverse
,它可以颠倒数组列表中元素的顺序,以及ToArray
,它返回存储在ArrayList
实例中的所有项目的数组。
有关ArrayList
类的更多信息可在msdn.microsoft.com/library/system.collections.arraylist.aspx
找到。
通用列表
正如您所看到的,ArrayList
类包含广泛的功能,但它有一个重大缺点——它不是强类型列表。如果要从强类型列表中受益,可以使用泛型List
类,该类表示集合,其大小可以根据需要增加或减少。
泛型List
类包含许多在存储数据时开发应用程序时非常有用的属性和方法。您将看到许多成员的名称与ArrayList
类完全相同,例如Count
和Capacity
属性,以及Add
,AddRange
,Clear
,Contains
,IndexOf
,Insert
,InsertRange
,LastIndexOf
,Remove
,RemoveAt
,RemoveRange
,Reverse
和ToArray
方法。您还可以使用索引和[]
运算符从列表中获取特定元素。
除了已经描述的功能之外,您还可以使用System.Linq
命名空间中的全面扩展方法集,例如查找最小值或最大值(Min
或Max
),计算平均值(Average
),按升序或降序排序(OrderBy
或OrderByDescending
),以及检查列表中的所有元素是否满足条件(All
)。当然,这些并不是在使用 C#语言中的通用列表创建应用程序时开发人员可用的唯一功能。
有关通用List
类的更多信息,请访问msdn.microsoft.com/library/6sh2ey19.aspx
。
让我们来看两个示例,展示如何在实践中使用通用列表。
示例-平均值
第一个示例利用通用List
类存储用户输入的浮点值(double
类型)。输入数字后,将计算平均值并在控制台中呈现。当用户输入不正确的值时,程序停止操作。
Program
类中Main
方法中的代码如下:
List<double> numbers = new List<double>();
do
{
Console.Write("Enter the number: ");
string numberString = Console.ReadLine();
if (!double.TryParse(numberString, NumberStyles.Float,
new NumberFormatInfo(), out double number))
{
break;
}
numbers.Add(number);
Console.WriteLine($"The average value: {numbers.Average()}");
}
while (true);
首先创建List
类的一个实例。然后,在无限循环(do-while
)中,程序等待用户输入数字。如果正确,输入的值将被添加到列表中(通过调用Add
方法),并计算列表元素的平均值(通过调用Average
方法)并显示在控制台中。
因此,您可能会收到类似以下的输出:
Enter the number: 10.5
The average value: 10.5 (...)
Enter the number: 1.5
The average value: 4.875
在当前示例中,您已经看到了如何使用存储double
值的列表。但是,它也可以存储用户定义类的实例吗?当然可以!您将在下一个示例中看到如何实现这一目标。
示例-人员列表
关于List
类的第二个示例展示了如何使用这个数据结构来创建一个非常简单的人员数据库。为每个人存储姓名、国家和年龄。启动程序时,将一些人的数据添加到列表中。然后,使用 LINQ 表达式对数据进行排序,并在控制台中呈现。
让我们从Person
类的声明开始,如下面的代码所示:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public CountryEnum Country { get; set; }
}
该类包含三个公共属性,即Name
、Age
和Country
。值得注意的是,Country
属性是CountryEnum
类型,它定义了三个常量,即PL
(波兰)、UK
(英国)和DE
(德国),如下面的代码所示:
public enum CountryEnum
{
PL,
UK,
DE
}
代码的以下部分应该添加在Program
类中Main
方法中。它创建List
类的一个新实例,并添加一些人的数据,这些人具有不同的姓名、国家和年龄,如下所示:
List<Person> people = new List<Person>();
people.Add(new Person() { Name = "Marcin",
Country = CountryEnum.PL, Age = 29 });
people.Add(new Person() { Name = "Sabine",
Country = CountryEnum.DE, Age = 25 }); (...)
people.Add(new Person() { Name = "Ann",
Country = CountryEnum.PL, Age = 31 });
在下一行中,使用 LINQ 表达式按人名升序对列表进行排序,并将结果转换为列表:
List<Person> results = people.OrderBy(p => p.Name).ToList();
然后,您可以使用foreach
循环轻松遍历所有结果:
foreach (Person person in results)
{
Console.WriteLine($"{person.Name} ({person.Age} years)
from {person.Country}.");
}
运行程序后,呈现以下结果:
Marcin (29 years) from PL. (...)
Sabine (25 years) from DE.
就是这样!现在让我们再多谈一些 LINQ 表达式,它不仅可以用于对元素进行排序,还可以根据提供的条件执行筛选,并且更多。
例如,让我们来看一下使用方法语法的以下查询:
List<string> names = people.Where(p => p.Age <= 30)
.OrderBy(p => p.Name)
.Select(p => p.Name)
.ToList();
它选择所有年龄低于或等于30
岁的人的姓名(Select
子句)(Where
子句),按姓名排序(OrderBy
子句)。然后执行查询,并将结果作为列表返回。
可以使用查询语法完成相同的任务,如下例所示,结合调用ToList
方法:
List<string> names = (from p in people
where p.Age <= 30
orderby p.Name
select p.Name).ToList();
在本章的这一部分,您已经了解了如何使用ArrayList
类和泛型List
类来存储可以动态调整大小的集合中的数据。但这并不是本章中与列表相关主题的结束。您准备好了解另一个数据结构了吗?它可以保持元素的排序顺序。如果是这样,让我们继续到下一节,重点介绍排序列表。
排序列表
在本章中,您已经学会了如何使用数组和列表存储数据。但是,您知道您甚至可以使用一种确保元素排序的数据结构吗?如果不知道,让我们来了解一下SortedList
泛型类(来自System.Collections.Generic
命名空间),它是一个按键排序的键值对集合,无需自行排序。值得一提的是,所有键必须是唯一的,且不能等于null
。
您可以使用Add
方法轻松地向集合中添加元素,并使用Remove
方法删除指定的项目。值得注意的是,除了其他方法之外,还有ContainsKey
和ContainsValue
用于检查集合是否包含具有给定键或值的项目,以及IndexOfKey
和IndexOfValue
用于返回集合中给定键或值的索引。由于排序列表存储键值对,因此您还可以访问Keys
和Values
属性。可以使用索引和[]
运算符轻松获取特定的键和值。
有关SortedList
泛型类的更多信息,请访问msdn.microsoft.com/library/ms132319.aspx
。
在这个简短的介绍之后,让我们看一个示例,它将向您展示如何使用这种数据结构,并且还将指出与先前描述的List
类相比的代码中的一些重要差异。
示例 - 通讯录
这个示例使用SortedList
类创建了一个非常简单的按人名排序的通讯录。对于每个人,存储了以下数据:Name
,Age
和Country
。Person
类的声明如下所示:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public CountryEnum Country { get; set; }
}
Country
属性的值可以设置为CountryEnum
中的常量之一:
public enum CountryEnum
{
PL,
UK,
DE
}
代码中最有趣的部分放在Program
类中的Main
方法中。在这里,创建了SortedList
泛型类的新实例,为键和值指定了类型,即string
和Person
,如下所示:
SortedList<string, Person> people =
new SortedList<string, Person>();
然后,您可以通过调用Add
方法轻松地向排序列表中添加数据,传递两个参数,即键(即名称)和值(即Person
类的实例),如下面的代码片段所示:
people.Add("Marcin", new Person() { Name = "Marcin",
Country = CountryEnum.PL, Age = 29 });
people.Add("Sabine", new Person() { Name = "Sabine",
Country = CountryEnum.DE, Age = 25 }); (...)
people.Add("Ann", new Person() { Name = "Ann",
Country = CountryEnum.PL, Age = 31 });
当所有数据都存储在集合中时,您可以轻松地使用foreach
循环迭代其元素(键值对)。值得一提的是,循环中使用的变量类型是KeyValuePair<string, Person>
。因此,您需要使用Key
和Value
属性分别访问键和值,如下所示:
foreach (KeyValuePair<string, Person> person in people)
{
Console.WriteLine($"{person.Value.Name} ({person.Value.Age}
years) from {person.Value.Country}.");
}
程序启动后,您将在控制台中收到以下结果:
Ann (31 years) from PL. (...)
Marcin (29 years) from PL. (...)
Sabine (25 years) from DE.
如您所见,集合会根据名称自动排序,这些名称被用作排序列表的键。但是,您需要记住键必须是唯一的,因此在这个示例中不能添加多个具有相同名称的人。
链表
在使用List
泛型类时,您可以轻松地使用索引访问集合的特定元素。但是,当您获取单个元素时,如何移动到集合的下一个元素呢?这可能吗?为此,您可以考虑使用IndexOf
方法来获取元素的索引。不幸的是,它返回给定值在集合中的第一次出现的索引,因此在这种情况下它不总是按预期工作。
如果有一种指针指向下一个元素将会很好,如下图所示:
通过这种方法,您可以轻松地使用Next
属性从一个元素导航到下一个元素。这样的结构被称为单向链表。但是,通过添加Previous
属性,可以进一步扩展它以允许向前和向后导航吗?当然可以!这样的数据结构被称为双向链表,并在下图中显示:
正如您所看到的,双向链表包含First
属性,指示列表中的第一个元素。每个项目都有两个属性,指向前一个和后一个元素(分别为Previous
和Next
)。如果没有前一个元素,则Previous
属性等于null
。同样,当没有下一个元素时,Next
属性设置为null
。此外,双向链表包含Last
属性,指示最后一个元素。当列表中没有项目时,First
和Last
属性都设置为null
。
但是,如果您想在基于 C#的应用程序中使用它,是否需要自己实现这样的数据结构?幸运的是,不需要,因为它作为System.Collections.Generic
命名空间中的LinkedList
泛型类可用。
在创建类的实例时,您需要指定类型参数,指示列表中单个元素的类型,例如int
或string
。但是,单个节点的类型不仅仅是int
或string
,因为在这种情况下,您将无法访问与双向链表相关的任何其他属性,例如Previous
或Next
。为了解决这个问题,每个节点都是LinkedListNode
泛型类的实例,例如LinkedListNode<int>
或LinkedListNode<string>
。
对于向双向链表添加新节点的方法需要一些额外的解释。为此,您可以使用一组方法,即:
-
AddFirst
:用于在列表的开头添加元素 -
AddLast
:用于在列表的末尾添加元素 -
AddBefore
:用于在列表中指定节点之前添加元素 -
AddAfter
:用于在列表中指定节点之后添加元素
所有这些方法都返回LinkedListNode
类的实例。此外,还有其他方法,例如Contains
用于检查列表中是否存在指定的值,Clear
用于从列表中删除所有元素,Remove
用于从列表中删除节点。
有关LinkedList
泛型类的更多信息,请访问msdn.microsoft.com/library/he2s3bh7.aspx
。
在这个简短的介绍之后,您应该准备好查看一个示例,展示如何在实践中应用双向链表,实现为LinkedList
类。
示例 - 书籍阅读器
例如,您将准备一个简单的应用程序,允许用户通过更改页面来阅读书籍。按下N键后,应能够转到下一页(如果存在),按下P键后,应能够返回到上一页(如果存在)。当前页面的内容以及页码应该显示在控制台中,如下面的屏幕截图所示:
让我们从Page
类的声明开始,如下面的代码所示:
public class Page
{
public string Content { get; set; }
}
这个类表示一个单独的页面,包含Content
属性。您应该在Program
类的Main
方法中创建Page
类的几个实例,表示书的六页,如下面的代码片段所示:
Page pageFirst = new Page() { Content = "Nowadays (...)" };
Page pageSecond = new Page() { Content = "Application (...)" };
Page pageThird = new Page() { Content = "A lot of (...)" };
Page pageFourth = new Page() { Content = "Do you know (...)" };
Page pageFifth = new Page() { Content = "While (...)" };
Page pageSixth = new Page() { Content = "Could you (...)" };
创建实例后,让我们继续使用一些与添加相关的方法来构建链表,如下面的代码行所示:
LinkedList<Page> pages = new LinkedList<Page>();
pages.AddLast(pageSecond);
LinkedListNode<Page> nodePageFourth = pages.AddLast(pageFourth);
pages.AddLast(pageSixth);
pages.AddFirst(pageFirst);
pages.AddBefore(nodePageFourth, pageThird);
pages.AddAfter(nodePageFourth, pageFifth);
第一行创建了一个新列表。 然后执行以下操作:
-
将第二页的数据添加到列表的末尾(
[2]
) -
在列表的末尾添加第四页的数据(
[2, 4]
) -
在列表的末尾添加第六页的数据(
[2, 4, 6]
) -
在列表的开头添加第一页的数据(
[1, 2, 4, 6]
) -
在第四页的节点之前添加第三页的数据(
[1, 2, 3, 4, 6]
) -
在第四页的节点后添加第五页的数据(
[1, 2, 3, 4, 5, 6]
)
代码的下一部分负责在控制台中呈现页面,以及在按下适当的键后在页面之间导航。 代码如下:
LinkedListNode<Page> current = pages.First;
int number = 1;
while (current != null)
{
Console.Clear();
string numberString = $"- {number} -";
int leadingSpaces = (90 - numberString.Length) / 2;
Console.WriteLine(numberString.PadLeft(leadingSpaces
+ numberString.Length));
Console.WriteLine();
string content = current.Value.Content;
for (int i = 0; i < content.Length; i += 90)
{
string line = content.Substring(i);
line = line.Length > 90 ? line.Substring(0, 90) : line;
Console.WriteLine(line);
}
Console.WriteLine();
Console.WriteLine($"Quote from "Windows Application
Development Cookbook" by Marcin
Jamro,{Environment.NewLine}published by Packt Publishing
in 2016.");
Console.WriteLine();
Console.Write(current.Previous != null
? "< PREVIOUS [P]" : GetSpaces(14));
Console.Write(current.Next != null
? "[N] NEXT >".PadLeft(76) : string.Empty);
Console.WriteLine();
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.N:
if (current.Next != null)
{
current = current.Next;
number++;
}
break;
case ConsoleKey.P:
if (current.Previous != null)
{
current = current.Previous;
number--;
}
break;
default:
return;
}
}
这部分代码可能需要一些解释。 在第一行,将current
变量的值设置为链表中的第一个节点。 一般来说,current
变量表示当前在控制台中呈现的页面。 然后,将页面编号的初始值设置为1
(number
变量)。 但是,代码中最有趣和复杂的部分在while
循环中显示。
在循环中,清除控制台的当前内容,并正确格式化用于显示页面编号的字符串。 在其前后添加-
字符。 此外,插入前导空格(使用PadLeft
方法)以准备水平居中的字符串。
然后,将页面的内容分成不超过 90 个字符的行,并写入控制台。 为了分割字符串,使用了Substring
方法和Length
属性。 类似地,控制台中呈现了有关另一本书的引用的其他信息。 值得一提的是,Environment.NewLine
属性会在字符串的指定位置插入换行符。 然后,如果上一页或下一页可用,则显示PREVIOUS
和NEXT
标题。
在代码的下一部分中,程序会等待用户按下任意键,然后不在控制台中呈现它(通过将true
值作为参数传递)。 当用户按下N键时,使用Next
属性将current
变量设置为下一个节点。 当下一页不可用时,当然不应执行此操作。 类似地,处理P键,这会导致用户导航到上一页。 值得一提的是,页面的编号(number
变量)会随着current
变量的值的改变而修改。
最后,显示了辅助GetSpaces
方法的代码:
private static string GetSpaces(int number)
{
string result = string.Empty;
for (int i = 0; i < number; i++)
{
result += " ";
}
return result;
}
这只是准备并返回具有指定空格数的string
变量。
循环链表
在上一部分,您已经了解了双向链表。 正如您所看到的,这种数据结构的实现允许使用Previous
和Next
属性在节点之间导航。 但是,第一个节点的Previous
属性设置为null
,最后一个节点的Next
属性也是如此。 您知道您可以轻松扩展此方法以创建循环链表吗?
这样的数据结构在下图中呈现:
在第一个节点的Previous
属性导航到最后一个节点,而最后一个节点的Next
属性导航到第一个节点。 在某些特定情况下,这种数据结构可能会很有用,就像您在开发真实世界示例时所看到的那样。
值得一提的是,节点之间导航的方式不需要实现为属性。 它也可以用方法替换,正如您将在以下部分的示例中看到的。
实施
在对循环链表主题进行简短介绍之后,是时候看一下实现代码了。 让我们从以下代码片段开始:
public class CircularLinkedList<T> : LinkedList<T>
{
public new IEnumerator GetEnumerator()
{
return new CircularLinkedListEnumerator<T>(this);
}
}
循环链表的实现可以创建为一个扩展LinkedList
的通用类,如前面的代码所示。值得一提的是GetEnumerator
方法的实现,它使用CircularLinkedListEnumerator
类。通过创建它,您将能够使用foreach
循环无限迭代循环链表的所有元素。
CircularLinkedListEnumerator
类的代码如下:
public class CircularLinkedListEnumerator<T> : IEnumerator<T>
{
private LinkedListNode<T> _current;
public T Current => _current.Value;
object IEnumerator.Current => Current;
public CircularLinkedListEnumerator(LinkedList<T> list)
{
_current = list.First;
}
public bool MoveNext()
{
if (_current == null)
{
return false;
}
_current = _current.Next ?? _current.List.First;
return true;
}
public void Reset()
{
_current = _current.List.First;
}
public void Dispose() { }
}
CircularLinkedListEnumerator
类实现了IEnumerator
接口。该类声明了表示列表迭代中当前节点(_current
)的private
字段。它还包含两个属性,即Current
和IEnumerator.Current
,这是IEnumerator
接口所需的。构造函数只是根据作为参数传递的LinkedList
类的实例设置了_current
变量的值。
代码中最重要的部分之一是MoveNext
方法。当_current
变量设置为null
时,即列表中没有项目时,它停止迭代。否则,它将当前元素更改为下一个元素,或者更改为列表中的第一个节点,如果下一个节点不可用。在Reset
方法中,只需将_current
字段的值设置为列表中的第一个节点。
最后,您需要创建两个扩展方法,使得在尝试从列表中的最后一个项目获取下一个元素时,可以导航到第一个元素,以及在尝试从列表中的第一个项目获取上一个元素时,可以导航到最后一个元素。为了简化实现,这些功能将作为Next
和Previous
方法而不是Next
和Previous
属性提供,如前面的图所示。代码如下所示:
public static class CircularLinkedListExtensions
{
public static LinkedListNode<T> Next<T>(
this LinkedListNode<T> node)
{
if (node != null && node.List != null)
{
return node.Next ?? node.List.First;
}
return null;
}
public static LinkedListNode<T> Previous<T>(
this LinkedListNode<T> node)
{
if (node != null && node.List != null)
{
return node.Previous ?? node.List.Last;
}
return null;
}
}
第一个扩展方法,即Next
,检查节点是否存在以及列表是否可用。在这种情况下,它返回节点的Next
属性的值(如果这个值不等于null
),或者使用First
属性返回列表中的第一个元素的引用。Previous
方法以类似的方式操作。
到此为止!您刚刚完成了基于 C#的循环链表的实现,这可以在以后的各种应用中使用。但是如何呢?让我们看一下下面使用这种数据结构的示例。
示例 - 旋转轮子
这个示例模拟了一个游戏,用户以随机速度旋转轮子。轮子的旋转速度越来越慢,直到停止。然后用户可以再次旋转它,从上一次停止的位置开始,如下图所示:
让我们继续Program
类中Main
方法的代码的第一部分:
CircularLinkedList<string> categories =
new CircularLinkedList<string>();
categories.AddLast("Sport");
categories.AddLast("Culture");
categories.AddLast("History");
categories.AddLast("Geography");
categories.AddLast("People");
categories.AddLast("Technology");
categories.AddLast("Nature");
categories.AddLast("Science");
首先创建了CircularLinkedList
类的新实例,它表示具有string
元素的循环链表。然后添加了八个值,即Sport
,Culture
,History
,Geography
,People
,Technology
,Nature
和Science
。
代码的下一部分执行了最重要的操作:
Random random = new Random();
int totalTime = 0;
int remainingTime = 0;
foreach (string category in categories)
{
if (remainingTime <= 0)
{
Console.WriteLine("Press [Enter] to start
or any other to exit.");
switch (Console.ReadKey().Key)
{
case ConsoleKey.Enter:
totalTime = random.Next(1000, 5000);
remainingTime = totalTime;
break;
default:
return;
}
}
int categoryTime = (-450 * remainingTime) / (totalTime - 50)
+ 500 + (22500 / (totalTime - 50));
remainingTime -= categoryTime;
Thread.Sleep(categoryTime);
Console.ForegroundColor = remainingTime <= 0
? ConsoleColor.Red : ConsoleColor.Gray;
Console.WriteLine(category);
Console.ForegroundColor = ConsoleColor.Gray;
}
首先声明了三个变量,即用于生成随机值的变量(random
),旋转轮子的总时间(以毫秒为单位)(totalTime
),以及旋转轮子的剩余时间(以毫秒为单位)(remainingTime
)。
然后,使用foreach
循环来迭代循环链表中的所有元素。如果在这样的循环中没有break
或return
指令,它将由于循环链表的特性而无限执行。如果到达最后一个项目,下一个迭代将自动获取列表中的第一个元素。
在循环中,检查剩余时间。如果剩余时间小于或等于零,即车轮已停止或尚未启动,将向用户显示消息,并等待Enter键被按下。在这种情况下,通过绘制旋转的总时间和设置剩余时间来配置新的旋转操作。当用户按下其他键时,程序将停止执行。
在下一步中,计算了循环的一次迭代时间。该公式使得在开始时可以提供较小的时间(车轮旋转更快),在结束时可以提供较大的时间(车轮旋转更慢)。然后,剩余时间减少,程序使用Sleep
方法等待指定的毫秒数。
最后,如果显示了最终结果,则将前景色更改为红色,并在控制台中显示当前选择的旋转轮上的类别。
当您运行应用程序时,您可以得到以下结果:
Press [Enter] to start or any other to exit.
Culture
History
Geography (...)
Culture
History
Press [Enter] to start or any other to exit.
Geography (...)
Nature
Science (...)
People
Technology
Press [Enter] to start or any other to exit.
您已经完成了使用循环链表的示例。这是本章中描述的数据结构之一。如果您想简要总结您所学到的信息,让我们继续对这个主题进行简要总结。
总结
数组和列表是开发各种应用程序时最常用的数据结构之一。然而,这个主题并不像看起来那么简单,因为即使数组也可以分为几个变体,即单维数组、多维数组和交错数组,也称为数组的数组。
在列表的情况下,差异更加明显,正如您在简单、通用、排序、单链、双链和循环链列表的情况下所看到的。幸运的是,数组列表、通用、排序和双链列表都有内置的实现。此外,您可以相当容易地扩展双链表以表现为循环链表。因此,您可以在不需要显著开发工作的情况下从适当的结构特性中受益。
可用的数据结构类型听起来可能相当复杂,但在本章中,您已经看到了特定数据结构的详细描述,以及基于 C#的示例的实现代码。它们应该为您简化事情,并可以作为您未来项目的基础。
您准备好学习其他数据结构了吗?如果是这样,让我们继续到下一章,了解关于栈和队列的内容!
第三章:堆栈和队列
到目前为止,您已经学到了很多关于数组和列表的知识。然而,这些结构并不是唯一可用的。除此之外,还有一组更专业的数据结构,它们被称为有限访问数据结构。
这意味着什么?为了解释这个名字,让我们暂时回到数组的话题,数组属于随机访问数据结构的一部分。它们之间的区别只有一个词,即有限或随机。正如您已经知道的那样,数组允许您存储数据并使用索引访问各种元素。因此,您可以轻松地从数组中获取第一个、中间、n^(th)或最后一个元素。因此,它可以被称为随机访问数据结构。
然而,有限是什么意思?答案非常简单——对于有限访问数据结构,您无法访问结构中的每个元素。因此,获取元素的方式是严格指定的。例如,您只能获取第一个或最后一个元素,但无法从数据结构中获取第n个元素。有限访问数据结构的常见代表是堆栈和队列。
在本章中,将涵盖以下主题:
-
堆栈
-
队列
-
优先队列
堆栈
首先,让我们谈谈堆栈。它是一种易于理解的数据结构,可以用许多盘子堆叠的例子来表示。您只能将新盘子添加到堆叠的顶部,并且只能从堆叠的顶部获取盘子。您无法在不从顶部取出前六个盘子的情况下移除第七个盘子,也无法在堆叠的中间添加盘子。
堆栈的操作方式与队列完全相同!它允许您在顶部添加新元素(push操作)并通过从顶部移除元素来获取元素(pop操作)。因此,堆栈符合LIFO原则,即后进先出。根据我们堆盘子的例子,最后添加的盘子(最后进)将首先从堆中移除(先出)。
堆栈的推送和弹出操作的图示如下:
看起来非常简单,不是吗?的确如此,您可以通过使用System.Collections.Generic
命名空间中的内置通用Stack
类来从堆栈的特性中受益。值得一提的是该类中的三种方法,即:
-
Push
,在堆栈顶部插入元素 -
Pop
,从堆栈顶部移除元素并返回 -
Peek
,从堆栈顶部返回元素而不移除它
当然,您还可以使用其他方法,例如从堆栈中删除所有元素(Clear
)或检查给定元素是否可用于堆栈(Contains
)。您可以使用Count
属性获取堆栈中的元素数量。
值得注意的是,如果容量不需要增加,Push
方法是O(1)操作,否则是O(n),其中n是堆栈中的元素数量。Pop
和Peek
都是O(1)操作。
您可以在msdn.microsoft.com/library/3278tedw.aspx
找到有关Stack
通用类的更多信息。
现在是时候看一些例子了。让我们开始吧!
示例-反转单词
首先,让我们尝试使用堆栈来反转一个单词。您可以通过迭代形成字符串的字符,将每个字符添加到堆栈的顶部,然后从堆栈中移除所有元素来实现这一点。最后,您将得到反转的单词,如下图所示,它展示了反转MARCIN单词的过程:
应添加到Program
类中的Main
方法的实现代码如下所示:
Stack<char> chars = new Stack<char>();
foreach (char c in "LET'S REVERSE!")
{
chars.Push(c);
}
while (chars.Count > 0)
{
Console.Write(chars.Pop());
}
Console.WriteLine();
在第一行,创建了Stack
类的一个新实例。值得一提的是,在这种情况下,堆栈只能包含char
元素。然后,您使用foreach
循环遍历所有字符,并通过在Stack
实例上调用Push
方法将每个字符插入堆栈顶部。代码的剩余部分包括while
循环,该循环执行直到堆栈为空。使用Count
属性来检查此条件。在每次迭代中,从堆栈中移除顶部元素(通过调用Pop
)并在控制台中写入(使用Console
类的Write
静态方法)。
运行代码后,您将收到以下结果:
!ESREVER S'TEL
示例 - 汉诺塔
下一个示例是堆栈的一个显着更复杂的应用。它与数学游戏汉诺塔有关。让我们从规则开始。游戏需要三根杆,您可以在上面放置圆盘。每个圆盘的大小都不同。开始时,所有圆盘都放在第一根杆上,形成堆栈,从最小的(顶部)到最大的(底部)排序如下:
游戏的目标是将所有圆盘从第一个杆(FROM)移动到第二个杆(TO)。然而,在整个游戏过程中,您不能将较大的圆盘放在较小的圆盘上。此外,您一次只能移动一个圆盘,当然,您只能从任何杆的顶部取一个圆盘。您如何在杆之间移动圆盘以符合上述规则?问题可以分解为子问题。
让我们从只移动一个圆盘的示例开始。这种情况很简单,您只需要将一个圆盘从FROM杆移动到TO杆,而不使用AUXILIARY杆。
稍微复杂一点的情况是移动两个圆盘。在这种情况下,您应该将一个圆盘从FROM杆移动到AUXILIARY杆。然后,您将剩下的圆盘从FROM移动到TO。最后,您只需要将一个圆盘从AUXILIARY移动到TO。
如果要移动三个圆盘,您应该从FROM移动两个圆盘到AUXILIARY,使用前面描述的机制。操作将涉及TO杆作为辅助杆。然后,您将剩余的圆盘从FROM移动到TO,然后从AUXILIARY移动两个圆盘到TO,使用FROM作为辅助杆。
正如您所看到的,您可以通过将n-1个圆盘从FROM移动到AUXILIARY,使用TO作为辅助杆来解决移动n个圆盘的问题。然后,您应该将剩余的圆盘从FROM移动到TO。最后,您只需要将n-1个圆盘从AUXILIARY移动到TO,使用FROM作为辅助杆。
就是这样!现在您知道了基本规则,让我们继续进行代码。
首先,让我们专注于包含与游戏相关逻辑的HanoiTower
类。代码的一部分如下所示:
public class HanoiTower
{
public int DiscsCount { get; private set; }
public int MovesCount { get; private set; }
public Stack<int> From { get; private set; }
public Stack<int> To { get; private set; }
public Stack<int> Auxiliary { get; private set; }
public event EventHandler<EventArgs> MoveCompleted; (...)
}
该类包含五个属性,存储总圆盘数(DiscsCount
),执行的移动数(MovesCount
)以及三个杆的表示(From
,To
,Auxiliary
)。还声明了MoveCompleted
事件。每次移动后都会触发它,以通知用户界面应该刷新。因此,您可以显示适当的内容,说明杆的当前状态。
除了属性和事件之外,该类还具有以下构造函数:
public HanoiTower(int discs)
{
DiscsCount = discs;
From = new Stack<int>();
To = new Stack<int>();
Auxiliary = new Stack<int>();
for (int i = 1; i <= discs; i++)
{
int size = discs - i + 1;
From.Push(size);
}
}
构造函数只接受一个参数,即圆盘数量(discs
),并将其设置为DiscsCount
属性的值。然后,创建了Stack
类的新实例,并将它们的引用存储在From
、To
和Auxiliary
属性中。最后,使用for
循环来创建必要数量的圆盘,并将元素添加到第一个堆栈(From
)中。值得注意的是,From
、To
和Auxiliary
堆栈只存储整数值(Stack<int>
)。每个整数值表示特定圆盘的大小。由于移动圆盘的规则,这些数据是至关重要的。
通过调用Start
方法来启动算法的操作,其代码如下所示:
public void Start()
{
Move(DiscsCount, From, To, Auxiliary);
}
该方法只是调用Move
递归方法,将总圆盘数和三个堆栈的引用作为参数传递。但是,Move
方法中发生了什么?让我们来看一下:
public void Move(int discs, Stack<int> from, Stack<int> to,
Stack<int> auxiliary)
{
if (discs > 0)
{
Move(discs - 1, from, auxiliary, to);
to.Push(from.Pop());
MovesCount++;
MoveCompleted?.Invoke(this, EventArgs.Empty);
Move(discs - 1, auxiliary, to, from);
}
}
如您已经知道的,此方法是递归调用的。因此,有必要指定一些退出条件,以防止方法被无限调用。在这种情况下,当discs
参数的值等于或小于零时,该方法将不会调用自身。如果该值大于零,则调用Move
方法,但是堆栈的顺序会改变。然后,从由第二个参数(from
)表示的堆栈中移除元素,并将其插入到由第三个参数(to
)表示的堆栈的顶部。在接下来的几行中,移动次数(MovesCount
)递增,并触发MoveCompleted
事件。最后,再次调用Move
方法,使用另一种杆顺序的配置。通过多次调用此方法,圆盘将从第一个(From
)移动到第二个(To
)杆。Move
方法中执行的操作与在本示例的介绍中解释的在杆之间移动n个圆盘的问题的描述一致。
创建了关于汉诺塔游戏的逻辑的类之后,让我们看看如何创建用户界面,以便呈现算法的下一步移动。Program
类中的必要更改如下:
private const int DISCS_COUNT = 10;
private const int DELAY_MS = 250;
private static int _columnSize = 30;
首先,声明了两个常量,即整体圆盘数量(DISCS_COUNT
,设置为10
)和算法中两次移动之间的延迟(以毫秒为单位)(DELAY_MS
,设置为250
)。此外,声明了一个私有静态字段,表示用于表示单个杆的字符数(_columnSize
,设置为30
)。
Program
类中的Main
方法如下所示:
static void Main(string[] args)
{
_columnSize = Math.Max(6, GetDiscWidth(DISCS_COUNT) + 2);
HanoiTower algorithm = new HanoiTower(DISCS_COUNT);
algorithm.MoveCompleted += Algorithm_Visualize;
Algorithm_Visualize(algorithm, EventArgs.Empty);
algorithm.Start();
}
首先,使用辅助的GetDiscWidth
方法计算了单个列(表示杆)的宽度,其代码稍后将显示。然后,创建了HanoiTower
类的新实例,并指示在触发MoveCompleted
事件时将调用Algorithm_Visualize
方法。接下来,调用了上述的Algorithm_Visualize
方法来呈现游戏的初始状态。最后,调用Start
方法来开始在杆之间移动圆盘。
Algorithm_Visualize
方法的代码如下:
private static void Algorithm_Visualize(
object sender, EventArgs e)
{
Console.Clear();
HanoiTowers algorithm = (HanoiTowers)sender;
if (algorithm.DiscsCount <= 0)
{
return;
}
char[][] visualization = InitializeVisualization(algorithm);
PrepareColumn(visualization, 1, algorithm.DiscsCount,
algorithm.From);
PrepareColumn(visualization, 2, algorithm.DiscsCount,
algorithm.To);
PrepareColumn(visualization, 3, algorithm.DiscsCount,
algorithm.Auxiliary);
Console.WriteLine(Center("FROM") + Center("TO") +
Center("AUXILIARY"));
DrawVisualization(visualization);
Console.WriteLine();
Console.WriteLine($"Number of moves: {algorithm.MovesCount}");
Console.WriteLine($"Number of discs: {algorithm.DiscsCount}");
Thread.Sleep(DELAY_MS);
}
算法的可视化应该在控制台中呈现游戏的当前状态。因此,每当需要刷新时,Algorithm_Visualize
方法清除控制台的当前内容(通过调用Clear
方法)。然后,它调用InitializeVisualization
方法来准备应该写入控制台的内容的交错数组。这样的内容包括三列,通过调用PrepareColumn
方法准备。调用后,visualization
数组包含应该只是呈现在控制台中的数据,没有任何额外的转换。为此,调用DrawVisualization
方法。当然,标题和额外的解释使用Console
类的WriteLine
方法写入控制台。
重要的角色由代码的最后一行执行,其中调用了System.Threading
命名空间中Thread
类的Sleep
方法。它暂停当前线程DELAY_MS
毫秒。这样一行代码被添加以便以方便的方式呈现算法的以下步骤给用户。
让我们来看看InitializeVisualization
方法的代码:
private static char[][] InitializeVisualization(
HanoiTowers algorithm)
{
char[][] visualization = new char[algorithm.DiscsCount][];
for (int y = 0; y < visualization.Length; y++)
{
visualization[y] = new char[_columnSize * 3];
for (int x = 0; x < _columnSize * 3; x++)
{
visualization[y][x] = ' ';
}
}
return visualization;
}
该方法声明了一个交错数组,行数等于总盘数(DiscsCount
属性)。列数等于_columnSize
字段的值乘以3
(表示三根杆)。在方法内部,使用两个for
循环来迭代遍历行(第一个for
循环)和所有列(第二个for
循环)。默认情况下,数组中的所有元素都被初始化为单个空格。最后,初始化的数组被返回。
要用当前杆的状态的插图填充上述的交错数组,需要调用PrepareColumn
方法,其代码如下:
private static void PrepareColumn(char[][] visualization,
int column, int discsCount, Stack<int> stack)
{
int margin = _columnSize * (column - 1);
for (int y = 0; y < stack.Count; y++)
{
int size = stack.ElementAt(y);
int row = discsCount - (stack.Count - y);
int columnStart = margin + discsCount - size;
int columnEnd = columnStart + GetDiscWidth(size);
for (int x = columnStart; x <= columnEnd; x++)
{
visualization[row][x] = '=';
}
}
}
首先,计算左边距以在整体数组中的正确部分添加数据,即在正确的列范围内。然而,方法的主要部分是for
循环,其中迭代次数等于给定堆栈中的盘数。在每次迭代中,使用ElementAt
扩展方法(来自System.Linq
命名空间)读取当前盘的大小。接下来,计算应该显示盘的行的索引,以及列的起始和结束索引。最后,使用for
循环将等号(=
)插入到作为visualization
参数传递的交错数组的适当位置。
下一个与可视化相关的方法是DrawVisualization
,其代码如下:
private static void DrawVisualization(char[][] visualization)
{
for (int y = 0; y < visualization.Length; y++)
{
Console.WriteLine(visualization[y]);
}
}
该方法只是遍历作为visualization
参数传递的交错数组的所有元素,并为交错数组中的每个数组调用WriteLine
方法。结果是,整个数组中的数据被写入控制台。
其中一个辅助方法是Center
。它的目的是在参数中传递的文本之前和之后添加额外的空格,以使文本在列中居中。该方法的代码如下:
private static string Center(string text)
{
int margin = (_columnSize - text.Length) / 2;
return text.PadLeft(margin + text.Length)
.PadRight(_columnSize);
}
另一个方法是GetDiscWidth
,它只返回以参数指定大小呈现的盘所需的字符数。其代码如下:
private static int GetDiscWidth(int size)
{
return 2 * size - 1;
}
您已经添加了运行应用程序所需的代码,该应用程序将呈现汉诺塔数学游戏的以下移动。让我们启动应用程序并看看它的运行情况!
在程序启动后,您将看到类似以下的结果,其中所有盘都位于第一根杆(FROM
)中:
FROM TO AUXILIARY
==
====
======
========
==========
============
==============
================
==================
====================
在下一步中,最小的盘从第一根杆(FROM
)的顶部移动到第三根杆(AUXILIARY
)的顶部,如下图所示:
FROM TO AUXILIARY
====
======
========
==========
============
==============
================
==================
==================== ==
在进行许多其他移动时,您可以看到盘在三根杆之间移动。其中一个中间状态如下:
FROM TO AUXILIARY
====
==========
============
==============
================
================== ======
==================== ======== ==
当完成必要的移动后,所有圆盘都从第一个圆盘(FROM
)移动到第二个圆盘(TO
)。最终结果如下图所示:
FROM TO AUXILIARY
==
====
======
========
==========
============
==============
================
==================
====================
最后,值得一提的是完成汉诺塔游戏所需的移动次数。在 10 个圆盘的情况下,移动次数为 1,023。如果只使用三个圆盘,移动次数只有七次。一般来说,可以用公式2^n-1来计算移动次数,其中n是圆盘的数量。
就这些了!在本节中,您已经学习了第一个有限访问数据结构,即栈。现在,是时候更多地了解队列了。让我们开始吧!
队列
队列是一种数据结构,可以用在商店结账时等待的人排队的例子中。新人站在队伍的末尾,下一个人从队伍的开头被带到结账处。不允许您从中间选择一个人并按不同的顺序为他或她服务。
队列数据结构的操作方式完全相同。您只能在队列的末尾添加新元素(enqueue操作),并且只能从队列的开头删除一个元素(dequeue操作)。因此,这种数据结构符合FIFO原则,即先进先出。在商店结账时等待的人排队的例子中,先来的人(先进)将在后来的人之前(先出)被服务。
队列的操作如下图所示:
值得一提的是,队列是一个递归数据结构,与栈类似。这意味着队列可以是空的,也可以由第一个元素和其余队列组成,后者也形成一个队列,如下图所示(队列的开始标记为灰色):
队列数据结构似乎很容易理解,与栈类似,除了删除元素的方式。这是否意味着您也可以在程序中使用内置类来使用队列?幸运的是,可以!可用的通用类名为Queue
,定义在System.Collections.Generic
命名空间中。
Queue
类包含一组方法,例如:
-
Enqueue
,在队列末尾添加一个元素 -
Dequeue
,从开头删除一个元素并返回它 -
Peek
,从开头返回一个元素而不删除它 -
Clear
,从队列中删除所有元素 -
Contains
,检查队列是否包含给定元素
Queue
类还包含Count
属性,返回队列中的元素总数。它可以用于轻松检查队列是否为空。
值得一提的是,如果内部数组不需要重新分配,则Enqueue
方法是O(1)操作,否则为O(n),其中n是队列中的元素数量。Dequeue
和Peek
都是O(1)操作。
您可以在msdn.microsoft.com/library/7977ey2c.aspx
找到有关Queue
类的更多信息。
在想要从多个线程同时使用队列的情况下,需要额外的注释。在这种情况下,需要选择线程安全的队列变体,即System.Collections.Concurrent
命名空间中的ConcurrentQueue
通用类。该类包含一组内置方法,用于执行线程安全队列的各种操作,例如:
-
Enqueue
,在队列末尾添加一个元素 -
TryDequeue
,尝试从开头删除一个元素并返回它 -
TryPeek
,尝试从开头返回一个元素而不删除它
值得一提的是,TryDequeue
和TryPeek
都有一个带有out
关键字的参数。如果操作成功,这些方法将返回true
,并将结果作为out
参数的值返回。此外,ConcurrentQueue
类还包含两个属性,即Count
用于获取集合中存储的元素数量,以及IsEmpty
用于返回一个值,指示队列是否为空。
您可以在msdn.microsoft.com/library/dd267265.aspx
找到有关ConcurrentQueue
类的更多信息。
在这个简短的介绍之后,您应该准备好继续进行两个示例,代表呼叫中心中的队列,有许多呼叫者和一个或多个顾问。
示例 - 仅有一个顾问的呼叫中心
这个第一个示例代表了呼叫中心解决方案的简单方法,其中有许多呼叫者(具有不同的客户标识符),以及只有一个顾问,他按照呼叫出现的顺序接听等待的电话。这种情况在下图中呈现:
正如您在前面的图表中所看到的,呼叫者执行了四次呼叫。它们被添加到等待电话呼叫的队列中,即来自客户#1234,#5678,#1468和#9641。当顾问可用时,他或她会接听电话。通话结束后,顾问可以接听下一个等待的电话。根据这个规则,顾问将按照以下顺序与客户交谈:#1234,#5678,#1468和#9641。
让我们来看一下第一个类IncomingCall
的代码,它代表了呼叫中心中由呼叫者执行的单个呼入呼叫。其代码如下:
public class IncomingCall
{
public int Id { get; set; }
public int ClientId { get; set; }
public DateTime CallTime { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public string Consultant { get; set; }
}
该类包含六个属性,代表呼叫的标识符(Id
),客户标识符(ClientId
),呼叫开始的日期和时间(CallTime
),呼叫被接听的日期和时间(StartTime
),呼叫结束的日期和时间(EndTime
),以及顾问的姓名(Consultant
)。
这个实现中最重要的部分与CallCenter
类相关,它代表了与呼叫相关的操作。其片段如下:
public class CallCenter
{
private int _counter = 0;
public Queue<IncomingCall> Calls { get; private set; }
public CallCenter()
{
Calls = new Queue<IncomingCall>();
}
}
CallCenter
类包含_counter
字段,其中包含最后一次呼叫的标识符,以及Calls
队列(带有IncomingCall
实例),其中存储了等待呼叫的数据。在构造函数中,创建了Queue
泛型类的新实例,并将其引用分配给Calls
属性。
当然,该类还包含一些方法,比如Call
,代码如下:
public void Call(int clientId)
{
IncomingCall call = new IncomingCall()
{
Id = ++_counter,
ClientId = clientId,
CallTime = DateTime.Now
};
Calls.Enqueue(call);
}
在这里,您创建了IncomingCall
类的新实例,并设置了其属性的值,即其标识符(连同预增量_counter
字段)、客户标识符(使用clientId
参数)和呼叫时间。通过调用Enqueue
方法,将创建的实例添加到队列中。
下一个方法是Answer
,它代表了回答呼叫的操作,来自队列中等待时间最长的人,也就是位于队列开头的人。Answer
方法如下所示:
public IncomingCall Answer(string consultant)
{
if (Calls.Count > 0)
{
IncomingCall call = Calls.Dequeue();
call.Consultant = consultant;
call.StartTime = DateTime.Now;
return call;
}
return null;
}
在这个方法中,您检查队列是否为空。如果是,该方法返回null
,这意味着顾问没有可以接听的电话。否则,呼叫将从队列中移除(使用Dequeue
方法),并通过设置顾问姓名(使用consultant
参数)和开始时间(为当前日期和时间)来更新其属性。最后,返回呼叫的数据。
除了Call
和Answer
方法,您还应该实现End
方法,每当顾问结束与特定客户的通话时都会调用该方法。在这种情况下,您只需设置结束时间,如下面的代码片段所示:
public void End(IncomingCall call)
{
call.EndTime = DateTime.Now;
}
CallCenter
类中的最后一个方法名为AreWaitingCalls
。它使用Queue
类的Count
属性返回一个值,指示队列中是否有任何等待的呼叫。其代码如下:
public bool AreWaitingCalls()
{
return Calls.Count > 0;
}
让我们继续到Program
类和它的Main
方法:
static void Main(string[] args)
{
Random random = new Random();
CallCenter center = new CallCenter();
center.Call(1234);
center.Call(5678);
center.Call(1468);
center.Call(9641);
while (center.AreWaitingCalls())
{
IncomingCall call = center.Answer("Marcin");
Log($"Call #{call.Id} from {call.ClientId}
is answered by {call.Consultant}.");
Thread.Sleep(random.Next(1000, 10000));
center.End(call);
Log($"Call #{call.Id} from {call.ClientId}
is ended by {call.Consultant}.");
}
}
在这里,你创建了Random
类的一个新实例(用于获取随机数),以及CallCenter
类的一个实例。然后,你通过呼叫者模拟了一些呼叫,即使用以下客户标识符:1234
,5678
,1468
和9641
。代码中最有趣的部分位于while
循环中,该循环执行直到队列中没有等待的呼叫为止。在循环内,顾问接听呼叫(使用Answer
方法),并生成日志(使用Log
辅助方法)。然后,线程暂停一段随机毫秒数(在 1,000 到 10,000 之间)以模拟呼叫的不同长度。当时间到达后,呼叫结束(通过调用End
方法),并生成适当的日志。
这个示例中必要的最后一部分代码是Log
方法:
private static void Log(string text)
{
Console.WriteLine($"[{DateTime.Now.ToString("HH:mm:ss")}]
{text}");
}
当你运行这个示例时,你会收到类似以下的结果:
[15:24:36] Call #1 from 1234 is answered by Marcin.
[15:24:40] Call #1 from 1234 is ended by Marcin.
[15:24:40] Call #2 from 5678 is answered by Marcin.
[15:24:48] Call #2 from 5678 is ended by Marcin.
[15:24:48] Call #3 from 1468 is answered by Marcin.
[15:24:53] Call #3 from 1468 is ended by Marcin.
[15:24:53] Call #4 from 9641 is answered by Marcin.
[15:24:57] Call #4 from 9641 is ended by Marcin.
就是这样!你刚刚完成了关于队列数据结构的第一个示例。如果你想了解更多关于队列的线程安全版本,让我们继续到下一部分,看看下一个示例。
示例 - 带有多个顾问的呼叫中心
在前面的部分中显示的示例被故意简化,以使理解队列变得更简单。然而,现在是时候让它更相关于现实世界的问题了。在这一部分,你将看到如何扩展它以支持多个顾问,如下图所示:
重要的是,呼叫者和顾问将同时工作。如果有更多的呼叫比可用的顾问多,新的呼叫将被添加到队列中,并等待直到有顾问可以接听呼叫。如果顾问过多而呼叫过少,顾问将等待呼叫。为了执行这个任务,你需要创建一些线程,它们将访问队列。因此,你需要使用ConcurrentQueue
类的线程安全版本。
让我们看一下代码!首先,你需要声明IncomingCall
类,其代码与前面的示例完全相同:
public class IncomingCall
{
public int Id { get; set; }
public int ClientId { get; set; }
public DateTime CallTime { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public string Consultant { get; set; }
}
CallCenter
类中需要进行各种修改,比如用ConcurrentQueue
泛型类的实例替换Queue
类的实例。适当的代码片段如下所示:
public class CallCenter
{
private int _counter = 0;
public ConcurrentQueue<IncomingCall> Calls
{ get; private set; }
public CallCenter()
{
Calls = new ConcurrentQueue<IncomingCall>();
}
}
由于Enqueue
方法在Queue
和ConcurrentQueue
类中都可用,所以在Call
方法的最重要部分不需要进行任何修改。然而,在将新呼叫添加到队列后,引入了一个小的修改来返回等待呼叫的数量。修改后的代码如下:
public int Call(int clientId)
{
IncomingCall call = new IncomingCall()
{
Id = ++_counter,
ClientId = clientId,
CallTime = DateTime.Now
};
Calls.Enqueue(call);
return Calls.Count;
}
ConcurrentQueue
类中不存在Dequeue
方法。因此,你需要稍微修改Answer
方法,使用TryDequeue
方法,该方法返回一个值,指示元素是否已从队列中移除。移除的元素使用out
参数返回。适当的代码部分如下:
public IncomingCall Answer(string consultant)
{
if (Calls.Count > 0
&& Calls.TryDequeue(out IncomingCall call))
{
call.Consultant = consultant;
call.StartTime = DateTime.Now;
return call;
}
return null;
}
在CallCenter
类中声明的剩余方法End
和AreWaitingCalls
中不需要进行进一步的修改。它们的代码如下:
public void End(IncomingCall call)
{
call.EndTime = DateTime.Now;
}
public bool AreWaitingCalls()
{
return Calls.Count > 0;
}
在Program
类中需要进行更多的修改。在这里,你需要启动四个线程。第一个代表呼叫者,而其他三个代表顾问。首先,让我们看一下Main
方法的代码:
static void Main(string[] args)
{
CallCenter center = new CallCenter();
Parallel.Invoke(
() => CallersAction(center),
() => ConsultantAction(center, "Marcin",
ConsoleColor.Red),
() => ConsultantAction(center, "James",
ConsoleColor.Yellow),
() => ConsultantAction(center, "Olivia",
ConsoleColor.Green));
}
在这里,在创建CallCenter
实例后,您使用System.Threading.Tasks
命名空间中Parallel
类的Invoke
静态方法开始执行四个操作,即代表呼叫者和三个咨询师,使用 lambda 表达式来指定将被调用的方法,即呼叫者相关操作的CallersAction
和咨询师相关任务的ConsultantAction
。您还可以指定其他参数,比如给定咨询师的名称和颜色。
CallersAction
方法代表了许多呼叫者循环执行的操作。其代码如下所示:
private static void CallersAction(CallCenter center)
{
Random random = new Random();
while (true)
{
int clientId = random.Next(1, 10000);
int waitingCount = center.Call(clientId);
Log($"Incoming call from {clientId},
waiting in the queue: {waitingCount}");
Thread.Sleep(random.Next(1000, 5000));
}
}
代码中最重要的部分是无限执行的while
循环。在其中,您会得到一个随机数作为客户的标识符(clientId
),并调用Call
方法。等待呼叫的数量被记录下来,连同客户标识符。最后,呼叫者相关的线程将暂停一段随机毫秒数,范围在 1,000 毫秒到 5,000 毫秒之间,即 1 到 5 秒之间,以模拟呼叫者进行另一个呼叫之间的延迟。
下一个方法名为ConsultantAction
,并在每个咨询师的单独线程上执行。该方法接受三个参数,即CallCenter
类的一个实例,以及咨询师的名称和颜色。代码如下:
private static void ConsultantAction(CallCenter center,
string name, ConsoleColor color)
{
Random random = new Random();
while (true)
{
IncomingCall call = center.Answer(name);
if (call != null)
{
Console.ForegroundColor = color;
Log($"Call #{call.Id} from {call.ClientId} is answered
by {call.Consultant}.");
Console.ForegroundColor = ConsoleColor.Gray;
Thread.Sleep(random.Next(1000, 10000));
center.End(call);
Console.ForegroundColor = color;
Log($"Call #{call.Id} from {call.ClientId}
is ended by {call.Consultant}.");
Console.ForegroundColor = ConsoleColor.Gray;
Thread.Sleep(random.Next(500, 1000));
}
else
{
Thread.Sleep(100);
}
}
}
与CallersAction
方法类似,最重要和有趣的操作是在无限的while
循环中执行的。在其中,咨询师尝试使用Answer
方法回答第一个等待的呼叫。如果没有等待的呼叫,线程将暂停 100 毫秒。否则,根据当前咨询师的情况,以适当的颜色呈现日志。然后,线程将暂停 1 到 10 秒之间的随机时间。在此时间之后,咨询师结束呼叫,通过调用End
方法来指示,并生成日志。最后,线程将暂停 500 毫秒到 1,000 毫秒之间的随机时间,这代表了呼叫结束和另一个呼叫开始之间的延迟。
最后一个辅助方法名为Log
,与前一个示例中的方法完全相同。其代码如下:
private static void Log(string text)
{
Console.WriteLine($"[{DateTime.Now.ToString("HH:mm:ss")}]
{text}");
}
当您运行程序并等待一段时间后,您将收到类似于以下截图所示的结果:
恭喜!您刚刚完成了两个示例,代表了呼叫中心场景中队列的应用。
修改程序的各种参数是一个好主意,比如咨询师的数量,以及延迟时间,特别是呼叫者之间的延迟时间。然后,您将看到算法在呼叫者或咨询师过多的情况下是如何工作的。
然而,如何处理具有优先支持的客户呢?在当前解决方案中,他们将与标准支持计划的客户一起等待在同一个队列中。您需要创建两个队列并首先从优先队列中取客户吗?如果是这样,如果您引入另一个支持计划会发生什么?您需要添加另一个队列并在代码中引入这样的修改吗?幸运的是,不需要!您可以使用另一种数据结构,即优先队列,来支持这样的情景,如下一节中详细解释的那样。
优先队列
优先级队列使得可以通过为队列中的每个元素设置优先级来扩展队列的概念。值得一提的是,优先级可以简单地指定为整数值。然而,较小或较大的值是否表示更高的优先级取决于实现。在本章中,假设最高优先级等于 0,而较低的优先级由 1、2、3 等指定。因此,出队操作将返回具有最高优先级的元素,该元素首先添加到队列中,如下图所示:
让我们分析一下图表。首先,优先级队列包含两个具有相同优先级(等于1)的元素,即Marcin和Lily。然后,添加了具有更高优先级(0)的Mary元素,这意味着该元素位于队列的开头,即在Marcin之前。在下一步中,具有最低优先级(2)的John元素被添加到优先级队列的末尾。第三列显示了具有优先级等于1的Emily元素的添加,即与Marcin和Lily相同。因此,Emily元素在Lily之后添加。根据前述规则,您添加以下元素,即优先级设置为0的Sarah和优先级等于1的Luke。最终顺序显示在前述图表的右侧。
当然,可以自己实现优先级队列。但是,您可以通过使用其中一个可用的 NuGet 包,即OptimizedPriorityQueue
来简化此任务。有关此包的更多信息,请访问www.nuget.org/packages/OptimizedPriorityQueue
。
您知道如何将此包添加到您的项目中吗?如果不知道,您应该按照以下步骤进行:
-
从解决方案资源管理器窗口中的项目节点的上下文菜单中选择管理 NuGet 包。
-
选择打开窗口中的浏览选项卡。
-
在搜索框中键入
OptimizedPriorityQueue
。 -
单击 OptimizedPriorityQueue 项目。
-
在右侧单击安装按钮。
-
在预览更改窗口中单击确定。
-
等待直到在输出窗口中显示完成消息。
OptimizedPriorityQueue
库显着简化了在各种应用程序中应用优先级队列。其中,可用SimplePriorityQueue
泛型类,其中包含一些有用的方法,例如:
-
Enqueue
,向优先级队列中添加元素 -
Dequeue
,从开头删除元素并返回它 -
GetPriority
,返回元素的优先级 -
UpdatePriority
,更新元素的优先级 -
Contains
,检查优先级队列中是否存在元素 -
Clear
,从优先级队列中删除所有元素
您可以使用Count
属性获取队列中元素的数量。如果要从优先级队列的开头获取元素而不将其删除,可以使用First
属性。此外,该类包含一组方法,这些方法在多线程场景中可能很有用,例如TryDequeue
和TryRemove
。值得一提的是,Enqueue
和Dequeue
方法都是O(log n)操作。
在对优先级队列的主题进行了简短介绍之后,让我们继续介绍具有优先级支持的呼叫中心的示例,该示例在以下部分中进行了描述。
示例 - 具有优先级支持的呼叫中心
作为优先级队列的示例,让我们介绍一种简单的方法,即呼叫中心示例,其中有许多呼叫者(具有不同的客户标识符),并且只有一个顾问,他首先从优先级队列中回答等待的呼叫,然后从具有标准支持计划的客户那里回答。
上述情景在以下图表中呈现。标有-的是标准优先级的呼叫,而标有*****的是优先级支持的呼叫,如下所示:
让我们来看看优先级队列中元素的顺序。目前,它只包含三个元素,将按以下顺序提供服务:#5678(具有优先级支持),#1234和#1468。然而,来自标识符#9641的客户的呼叫导致顺序变为#5678,#9641(由于优先级支持),#1234和#1468。
是时候写一些代码了!首先,不要忘记将OptimizedPriorityQueue
包添加到项目中,如前所述。当库配置正确时,您可以继续实现IncomingCall
类:
public class IncomingCall
{
public int Id { get; set; }
public int ClientId { get; set; }
public DateTime CallTime { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public string Consultant { get; set; }
public bool IsPriority { get; set; }
}
在这里,与之前呈现的简单呼叫中心应用程序的情景相比,只有一个变化,即添加了IsPriority
属性。它指示当前呼叫是否具有优先级支持(true
)或标准支持(false
)。
CallCenter
类中也需要进行一些修改,其片段如下代码片段所示:
public class CallCenter
{
private int _counter = 0;
public SimplePriorityQueue<IncomingCall> Calls
{ get; private set; }
public CallCenter()
{
Calls = new SimplePriorityQueue<IncomingCall>();
}
}
如您所见,Calls
属性的类型已从Queue
更改为SimplePriorityQueue
泛型类。在Call
方法中需要进行以下更改,代码如下所示:
public void Call(int clientId, bool isPriority = false)
{
IncomingCall call = new IncomingCall()
{
Id = ++_counter,
ClientId = clientId,
CallTime = DateTime.Now,
IsPriority = isPriority
};
Calls.Enqueue(call, isPriority ? 0 : 1);
}
在这个方法中,使用参数设置了IsPriority
属性(前面提到的)。此外,在调用Enqueue
方法时,使用了两个参数,不仅是元素的值(IncomingCall
类的实例),还有一个优先级的整数值,即在优先级支持的情况下为0
,否则为1
。
在CallCenter
类的方法中不需要进行更多的修改,即Answer
,End
和AreWaitingCalls
方法。相关代码如下:
public IncomingCall Answer(string consultant)
{
if (Calls.Count > 0)
{
IncomingCall call = Calls.Dequeue();
call.Consultant = consultant;
call.StartTime = DateTime.Now;
return call;
}
return null;
}
public void End(IncomingCall call)
{
call.EndTime = DateTime.Now;
}
public bool AreWaitingCalls()
{
return Calls.Count > 0;
}
最后,让我们来看看Program
类中Main
和Log
方法的代码:
static void Main(string[] args)
{
Random random = new Random();
CallCenter center = new CallCenter();
center.Call(1234);
center.Call(5678, true);
center.Call(1468);
center.Call(9641, true);
while (center.AreWaitingCalls())
{
IncomingCall call = center.Answer("Marcin");
Log($"Call #{call.Id} from {call.ClientId}
is answered by {call.Consultant} /
Mode: {(call.IsPriority ? "priority" : "normal")}.");
Thread.Sleep(random.Next(1000, 10000));
center.End(call);
Log($"Call #{call.Id} from {call.ClientId}
is ended by {call.Consultant}.");
}
}
private static void Log(string text)
{
Console.WriteLine($"[{DateTime.Now.ToString("HH:mm:ss")}]
{text}");
}
您可能会惊讶地发现,在代码的这一部分只需要进行两个更改!原因是使用的数据结构的逻辑被隐藏在CallCenter
类中。在Program
类中,您调用了一些方法并使用了CallCenter
类公开的属性。您只需要修改向队列添加呼叫的方式,并调整呼叫被顾问接听时呈现的日志,以展示呼叫的优先级。就是这样!
运行应用程序时,您将收到类似以下的结果:
[15:40:26] Call #2 from 5678 is answered by Marcin / Mode:
**priority**.
[15:40:35] Call #2 from 5678 is ended by Marcin.
[15:40:35] Call #4 from 9641 is answered by Marcin / Mode:
**priority**.
[15:40:39] Call #4 from 9641 is ended by Marcin.
[15:40:39] Call #1 from 1234 is answered by Marcin / Mode: **normal**.
[15:40:48] Call #1 from 1234 is ended by Marcin.
[15:40:48] Call #3 from 1468 is answered by Marcin / Mode: **normal**.
[15:40:57] Call #3 from 1468 is ended by Marcin.
如您所见,呼叫按正确的顺序提供服务。这意味着具有优先级支持的客户的呼叫比具有标准支持计划的客户的呼叫更早得到服务,尽管这类呼叫需要等待更长时间才能得到答复。
总结
在本章中,您已经了解了三种有限访问数据结构,即栈、队列和优先级队列。值得记住的是,这些数据结构都有严格指定的访问元素的方式。它们都有各种各样的现实世界应用,本书中已经提到并描述了其中一些。
首先,您看到了栈如何按照 LIFO 原则运作。在这种情况下,您只能在栈的顶部添加元素(推送操作),并且只能从顶部移除元素(弹出操作)。栈已在两个示例中展示,即用于颠倒一个单词和解决汉诺塔数学游戏。
在本章的后续部分,您了解了队列作为一种数据结构,它根据 FIFO 原则运作。在这种情况下,介绍了入队和出队操作。队列已通过两个示例进行了解释,都涉及模拟呼叫中心的应用程序。此外,您还学会了如何运行几个线程,以及如何在 C#语言开发应用程序时使用线程安全的队列变体。
本章介绍的第三种数据结构称为优先队列,是队列的扩展,支持特定元素的优先级。为了更容易地使用这种数据结构,您已经学会了如何使用外部 NuGet 包。例如,呼叫中心场景已扩展为处理两种支持计划。
这只是本书的第三章,您已经学到了很多关于各种数据结构和算法的知识,这些知识在 C#应用程序开发中非常有用!您是否有兴趣通过学习字典和集合来增加您的知识?如果是的话,让我们继续下一章,了解更多关于这些数据结构的知识!
第四章:字典和集
本章将重点介绍与字典和集相关的数据结构。正确应用这些数据结构可以将键映射到值,并进行快速查找,以及对集合进行各种操作。为了简化对字典和集的理解,本章将包含插图和代码片段。
在本章的前几部分,您将学习字典的非泛型和泛型版本,即由键和值组成的一对集合。然后,还将介绍字典的排序变体。您还将看到字典和列表之间的一些相似之处。
本章的剩余部分将向您展示如何使用哈希集,以及名为“排序”集的变体。是否可能有一个“排序”集?在阅读最后一节时,您将了解如何理解这个主题。
本章将涵盖以下主题:
-
哈希表
-
字典
-
排序字典
-
哈希集
-
“排序”集
哈希表
让我们从第一个数据结构开始,即哈希表,也称为哈希映射。它允许将键映射到特定值,如下图所示:
哈希表最重要的假设之一是可以非常快速地查找基于Key的Value,这应该是O(1)操作。为了实现这一目标,使用了哈希函数。它将Key生成一个桶的索引,Value可以在其中找到。
因此,如果您需要查找键的值,您不需要遍历集合中的所有项,因为您可以使用哈希函数轻松定位适当的桶并获取值。由于哈希表的出色性能,在许多现实世界的应用程序中经常使用这样的数据结构,例如用于关联数组、数据库索引或缓存系统。
正如您所看到的,哈希函数的作用至关重要,理想情况下应该为所有键生成唯一的结果。然而,可能会为不同的键生成相同的结果。这种情况被称为哈希冲突,需要处理。
从头开始实现哈希表的实现似乎相当困难,特别是涉及使用哈希函数、处理哈希冲突以及将特定键分配给桶。幸运的是,在 C#语言中开发应用程序时可以使用合适的实现,而且使用起来非常简单。
哈希表相关类有两个变体,即非泛型(Hashtable
)和泛型(Dictionary
)。第一个在本节中描述,而另一个在下一节中描述。如果可以使用强类型的泛型版本,我强烈建议使用它。
让我们来看看System.Collections
命名空间中的Hashtable
类。如前所述,它存储了一组成对的集合,每个集合包含一个键和一个值。一对由DictionaryEntry
实例表示。
您可以轻松地使用索引器访问特定元素。由于Hashtable
类是与哈希表相关类的非泛型变体,您需要将返回的结果转换为适当的类型(例如string
),如下所示:
string value = (string)hashtable["key"];
类似地,您可以设置值:
hashtable["key"] = "value";
值得一提的是,null
值对于元素的key
是不正确的,但对于元素的value
是可以接受的。
除了索引器之外,该类还配备了一些属性,可以获取存储的元素数量(Count
),以及返回键或值的集合(分别为Keys
和Values
)。此外,您可以使用一些可用的方法,例如添加新元素(Add
),删除元素(Remove
),删除所有元素(Clear
),以及检查集合是否包含特定键(Contains
和ContainsKey
)或给定值(ContainsValue
)。
如果要从哈希表中获取所有条目,可以使用foreach
循环来迭代存储在集合中的所有对,如下所示:
foreach (DictionaryEntry entry in hashtable)
{
Console.WriteLine($"{entry.Key} - {entry.Value}");
}
循环中使用的变量具有DictionaryEntry
类型。因此,您需要使用其Key
和Value
属性分别访问键和值。
您可以在msdn.microsoft.com/library/system.collections.hashtable.aspx
找到有关Hashtable
类的更多信息。
在这个简短的介绍之后,现在是时候看一个例子了。
示例-电话簿
例如,您将创建一个电话簿应用程序。Hashtable
类将用于存储条目,其中人名是键,电话号码是值,如下图所示:
该程序将演示如何向集合中添加元素,检查存储的项目数量,遍历所有项目,检查是否存在具有给定键的元素,以及如何基于键获取值。
此处呈现的整个代码应放在Program
类的Main
方法的主体中。首先,让我们创建Hashtable
类的新实例,并使用一些条目对其进行初始化,如下面的代码所示:
Hashtable phoneBook = new Hashtable()
{
{ "Marcin Jamro", "000-000-000" },
{ "John Smith", "111-111-111" }
};
phoneBook["Lily Smith"] = "333-333-333";
您可以以各种方式向集合中添加元素,例如在创建类的新实例时(在前面的示例中为Marcin Jamro
和John Smith
的电话号码),通过使用索引器(Lily Smith
),以及使用Add
方法(Mary Fox
),如下面的代码部分所示:
try
{
phoneBook.Add("Mary Fox", "222-222-222");
}
catch (ArgumentException)
{
Console.WriteLine("The entry already exists.");
}
如您所见,Add
方法的调用位于try-catch
语句中。为什么?答案很简单——您不能添加具有相同键的多个元素,在这种情况下会抛出ArgumentException
。为了防止应用程序崩溃,使用try-catch
语句,并在控制台中显示适当的消息,通知用户情况。
当您使用索引器为特定键设置值时,如果已经存在具有给定键的项目,它不会抛出任何异常。在这种情况下,将更新此元素的值。
在代码的下一部分中,您将遍历集合中的所有对,并在控制台中呈现结果。当没有项目时,将向用户呈现附加信息,如下面的代码片段所示:
Console.WriteLine("Phone numbers:");
if (phoneBook.Count == 0)
{
Console.WriteLine("Empty");
}
else
{
foreach (DictionaryEntry entry in phoneBook)
{
Console.WriteLine($" - {entry.Key}: {entry.Value}");
}
}
您可以使用Count
属性检查集合中是否没有元素,并将其值与0
进行比较。通过foreach
循环的可用性,遍历所有对的方式变得更加简单。但是,您需要记住,Hashtable
类中的单个对由DictionaryEntry
实例表示,您可以使用Key
和Value
属性访问其键和值。
最后,让我们看看如何检查特定键是否存在于集合中,以及如何获取其值。第一个任务可以通过调用Contains
方法来完成,该方法返回一个值,指示是否存在合适的元素(true
)或不存在(false
)。另一个任务(获取值)使用索引器,并且需要将返回的值转换为适当的类型(在本例中为string
)。这个要求是由哈希表相关类的非泛型版本引起的。代码如下:
Console.WriteLine();
Console.Write("Search by name: ");
string name = Console.ReadLine();
if (phoneBook.Contains(name))
{
string number = (string)phoneBook[name];
Console.WriteLine($"Found phone number: {number}");
}
else
{
Console.WriteLine("The entry does not exist.");
}
您的第一个使用哈希表的程序已经准备好了!启动后,您将收到类似以下的结果:
Phone numbers:
- John Smith: 111-111-111
- Mary Fox: 222-222-222
- Lily Smith: 333-333-333
- Marcin Jamro: 000-000-000
Search by name: Mary Fox
Found phone number: 222-222-222
值得注意的是,使用Hashtable
类存储的键值对的顺序与它们添加或键的顺序不一致。因此,如果需要呈现排序后的结果,您需要自行对元素进行排序,或者使用另一个数据结构,即稍后在本书中描述的SortedDictionary
。
然而,现在让我们来看一下在 C#中开发时最常用的类之一,即Dictionary
,它是哈希表相关类的泛型版本。
字典
在上一节中,您了解了Hashtable
类作为哈希表相关类的非泛型变体。但是,它有一个重要的限制,因为它不允许您指定键和值的类型。DictionaryEntry
类的Key
和Value
属性都是object
类型。因此,即使所有键和值都具有相同的类型,您仍需要执行装箱和拆箱操作。
如果要使用强类型变体,可以使用Dictionary
泛型类,这是本章节的主要内容。
首先,在创建Dictionary
类的实例时,您应该指定两种类型,即键的类型和值的类型。此外,可以使用以下代码定义字典的初始内容:
Dictionary<string, string> dictionary =
new Dictionary<string, string>
{
{ "Key 1", "Value 1" },
{ "Key 2", "Value 2" }
};
在上面的代码中,创建了Dictionary
类的一个新实例。它存储基于string
的键和值。默认情况下,字典中存在两个条目,即键Key 1
和Key 2
。它们的值分别是Value 1
和Value 2
。
与Hashtable
类类似,您也可以使用索引器来访问集合中的特定元素,如下面的代码行所示:
string value = dictionary["key"];
值得注意的是,不需要将类型转换为string
类型,因为Dictionary
是哈希表相关类的强类型版本。因此,返回的值已经具有正确的类型。
如果集合中不存在具有给定键的元素,则会抛出KeyNotFoundException
。为了避免问题,您可以选择以下之一:
-
将代码行放在
try-catch
块中 -
检查元素是否存在(通过调用
ContainsKey
) -
使用
TryGetValue
方法
您可以使用索引器添加新元素或更新现有元素的值,如下面的代码行所示:
dictionary["key"] = "value";
与非泛型变体类似,key
不能等于null
,但value
可以,当然,如果允许存储在集合中的值的类型。此外,获取元素的值、添加新元素或更新现有元素的性能接近O(1)操作。
Dictionary
类配备了一些属性,可以获取存储元素的数量(Count
),以及返回键或值的集合(分别是Keys
和Values
)。此外,您可以使用可用的方法,例如添加新元素(Add
),删除项目(Remove
),删除所有元素(Clear
),以及检查集合是否包含特定键(ContainsKey
)或给定值(ContainsValue
)。您还可以使用TryGetValue
方法尝试获取给定键的值并返回它(如果元素存在),否则返回null
。
虽然通过给定键返回值(使用索引器或TryGetValue
)和检查给定键是否存在(ContainsKey
)的场景接近O(1)操作,但检查集合是否包含给定值(ContainsValue
)的过程是O(n)操作,并且需要您搜索整个集合以查找特定值。
如果要遍历集合中存储的所有对,可以使用foreach
循环。但是,循环中使用的变量是KeyValuePair
泛型类的实例,具有Key
和Value
属性,允许您访问键和值。foreach
循环显示在以下代码片段中:
foreach (KeyValuePair<string, string> pair in dictionary)
{
Console.WriteLine($"{pair.Key} - {pair.Value}");
}
您还记得上一章中一些类的线程安全版本吗?如果记得,那么在Dictionary
类的情况下,情况看起来与ConcurrentDictionary
类相当相似,因为System.Collections.Concurrent
命名空间中提供了ConcurrentDictionary
类。它配备了一组方法,例如TryAdd
、TryUpdate
、AddOrUpdate
和GetOrAdd
。
您可以在msdn.microsoft.com/library/xfhwa508.aspx
找到有关Dictionary
泛型类的更多信息,而有关线程安全替代方案ConcurrentDictionary
的详细信息则显示在msdn.microsoft.com/library/dd287191.aspx
。
让我们开始编码!在接下来的部分,您将找到两个展示字典的示例。
示例-产品位置
第一个示例是帮助商店员工找到产品应放置的位置的应用程序。假设每个员工都有一部手机,上面安装了您的应用程序,用于扫描产品的代码,应用程序会告诉他们产品应放置在A1或C9区域。听起来很有趣,不是吗?
由于商店中的产品数量通常非常庞大,因此有必要快速找到结果。因此,产品的数据以及其位置将存储在哈希表中,使用泛型Dictionary
类。键将是条形码,而值将是区域代码,如下图所示:
让我们看一下应该添加到Program
类的Main
方法中的代码。首先,您需要创建一个新的集合,并添加一些数据:
Dictionary<string, string> products =
new Dictionary<string, string>
{
{ "5900000000000", "A1" },
{ "5901111111111", "B5" },
{ "5902222222222", "C9" }
};
products["5903333333333"] = "D7";
代码显示了向集合中添加元素的两种方法,即在创建类的新实例时传递它们的数据和使用索引器。还存在第三种解决方案,使用Add
方法,如代码的以下部分所示:
try
{
products.Add("5904444444444", "A3");
}
catch (ArgumentException)
{
Console.WriteLine("The entry already exists.");
}
在Hashtable
类的情况下提到,如果您想要添加与集合中已存在的元素具有相同键的元素,则会抛出ArgumentException
。您可以通过使用try-catch
块来防止应用程序崩溃。
在代码的下一部分中,您会展示系统中所有可用产品的数据。为此,您使用foreach
循环,但在此之前,您要检查字典中是否有任何元素。如果没有,则向用户呈现适当的消息。否则,控制台中显示所有对的键和值。值得一提的是,在foreach
循环中的变量类型是KeyValuePair<string, string>
,因此其Key
和Value
属性是string
类型,而不是object
类型,与非泛型变体的情况相同。代码如下所示:
Console.WriteLine("All products:");
if (products.Count == 0)
{
Console.WriteLine("Empty");
}
else
{
foreach (KeyValuePair<string, string> product in products)
{
Console.WriteLine($" - {product.Key}: {product.Value}");
}
}
最后,让我们看一下代码的一部分,该代码使得可以通过其条形码找到产品的位置。为此,您使用TryGetValue
来检查元素是否存在。如果是,控制台中会显示带有目标位置的消息。否则,会显示其他信息。重要的是,TryGetValue
方法使用out
参数来返回找到的元素的值。代码如下:
Console.WriteLine();
Console.Write("Search by barcode: ");
string barcode = Console.ReadLine();
if (products.TryGetValue(barcode, out string location))
{
Console.WriteLine($"The product is in the area {location}.");
}
else
{
Console.WriteLine("The product does not exist.");
}
运行程序时,您将看到商店中所有产品的列表,并且程序会要求您输入条形码。输入后,您将收到带有区域代码的消息。控制台中显示的结果将类似于以下内容:
All products:
- 5900000000000: A1
- 5901111111111: B5
- 5902222222222: C9
- 5903333333333: D7
- 5904444444444: A3
Search by barcode: 5902222222222
The product is in the area C9.
您刚刚完成了第一个示例!让我们继续到下一个。
示例-用户详细信息
第二个示例将向您展示如何在字典中存储更复杂的数据。在这种情况下,您将创建一个应用程序,根据用户的标识符显示用户的详细信息,如下图所示:
程序应该以三个用户的数据开始。您应该能够输入标识符并查看找到的用户的详细信息。当然,应该通过在控制台中呈现适当的信息来处理给定用户不存在的情况。
首先,让我们添加Employee
类,它只存储员工的数据,即名字、姓氏和电话号码。代码如下:
public class Employee
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string PhoneNumber { get; set; }
}
下面的修改将在Program
类的Main
方法中执行。在这里,您创建了Dictionary
类的一个新实例,并使用Add
方法添加了三个员工的数据,如下面的代码片段所示:
Dictionary<int, Employee> employees =
new Dictionary<int, Employee>();
employees.Add(100, new Employee() { FirstName = "Marcin",
LastName = "Jamro", PhoneNumber = "000-000-000" });
employees.Add(210, new Employee() { FirstName = "Mary",
LastName = "Fox", PhoneNumber = "111-111-111" });
employees.Add(303, new Employee() { FirstName = "John",
LastName = "Smith", PhoneNumber = "222-222-222" });
最有趣的操作是在以下do-while
循环中执行的:
bool isCorrect = true;
do
{
Console.Write("Enter the employee identifier: ");
string idString = Console.ReadLine();
isCorrect = int.TryParse(idString, out int id);
if (isCorrect)
{
Console.ForegroundColor = ConsoleColor.White;
if (employees.TryGetValue(id, out Employee employee))
{
Console.WriteLine("First name: {1}{0}Last name:
{2}{0}Phone number: {3}",
Environment.NewLine,
employee.FirstName,
employee.LastName,
employee.PhoneNumber);
}
else
{
Console.WriteLine("The employee with the given
identifier does not exist.");
}
Console.ForegroundColor = ConsoleColor.Gray;
}
}
while (isCorrect);
首先,用户被要求输入员工的标识符,然后将其解析为整数值。如果此操作成功完成,则使用TryGetValue
方法尝试获取用户的详细信息。如果找到用户,即TryGetValue
返回true
,则在控制台中呈现详细信息。否则,显示“给定标识符的员工不存在。”
消息。循环执行,直到提供的标识符无法解析为整数值为止。
当您运行应用程序并输入一些数据时,您将收到以下结果:
Enter the employee identifier: 100
First name: Marcin
Last name: Jamro
Phone number: 000-000-000
Enter the employee identifier: 500
The employee with the given identifier does not exist.
就是这样!您刚刚完成了两个示例,展示了如何在 C#语言中开发应用程序时使用字典。
然而,在关于Hashtable
类的部分提到了另一种字典,即有序字典。您是否有兴趣了解它的作用以及如何在程序中使用它?如果是的话,让我们继续到下一节。
有序字典
与哈希表相关的类的非泛型和泛型变体都不保留元素的顺序。因此,如果您需要按键排序的方式呈现来自集合的数据,您需要在呈现之前对它们进行排序。但是,您可以使用另一种数据结构,有序字典,来解决这个问题,并始终保持键的排序。因此,您可以在必要时轻松获取排序后的集合。
有序字典实现为SortedDictionary
泛型类,位于System.Collections.Generic
命名空间中。您可以在创建SortedDictionary
类的新实例时指定键和值的类型。此外,该类包含与Dictionary
类类似的属性和方法。
首先,您可以使用索引器访问集合中的特定元素,如下面的代码行所示:
string value = dictionary["key"];
您应该确保元素存在于集合中。否则,将抛出KeyNotFoundException
。
您可以添加新元素或更新现有元素的值,如下所示的代码:
dictionary["key"] = "value";
与Dictionary
类类似,键不能等于null
,但值可以,当然,如果允许存储在集合中的值的类型允许的话。
该类配备了一些属性,可以获取存储元素的数量(Count
),以及返回键和值的集合(Keys
和Values
)。此外,您可以使用可用的方法,例如添加新元素(Add
),删除项目(Remove
),删除所有元素(Clear
),以及检查集合是否包含特定键(ContainsKey
)或给定值(ContainsValue
)。您可以使用TryGetValue
方法尝试获取给定键的值并返回它(如果元素存在),否则返回null
。
如果您想要遍历集合中存储的所有键值对,可以使用foreach
循环。循环中使用的变量是KeyValuePair
泛型类的实例,具有Key
和Value
属性,允许您访问键和值。
尽管自动排序有优势,但与Dictionary
相比,SortedDictionary
类在性能上有一些缺点,因为检索、插入和删除都是O(log n)操作,其中n是集合中的元素数量,而不是O(1)。此外,SortedDictionary
与第二章中描述的SortedList
非常相似,数组和列表。然而,它们在与内存相关和性能相关的结果上有所不同。这两个类的检索都是O(log n)操作,但对于未排序的数据,SortedDictionary
的插入和删除是O(log n),而SortedList
是O(n)。当然,SortedDictionary
需要比SortedList
更多的内存。正如您所看到的,选择合适的数据结构并不是一件容易的事,您应该仔细考虑特定数据结构将被使用的场景,并考虑其优缺点。
您可以在msdn.microsoft.com/library/f7fta44c.aspx
找到关于SortedDictionary
泛型类的更多信息。
让我们通过创建一个示例来看看排序字典的实际操作。
示例-定义
例如,您可以创建一个简单的百科全书,可以添加条目,并显示其完整内容。百科全书可以包含数百万条目,因此至关重要的是为其用户提供按正确顺序浏览条目的可能性,按键的字母顺序排列,以及快速找到条目。因此,在这个例子中,排序字典是一个很好的选择。
百科全书的概念如下图所示:
当程序启动时,它会显示一个简单的菜单,包括两个选项,即[a] - add
和[l] - list
。按下A键后,应用程序会要求您输入条目的名称和解释。如果提供的数据是正确的,新条目将被添加到百科全书中。如果用户按下L键,则按键排序的所有条目数据将显示在控制台中。当按下其他键时,会显示额外的确认信息,如果确认,则程序退出。
让我们来看看代码,它应该放在Program
类的Main
方法的主体中:
SortedDictionary<string, string> definitions =
new SortedDictionary<string, string>();
do
{
Console.Write("Choose an option ([a] - add, [l] - list): ");
ConsoleKeyInfo keyInfo = Console.ReadKey();
Console.WriteLine();
if (keyInfo.Key == ConsoleKey.A)
{
Console.ForegroundColor = ConsoleColor.White;
Console.Write("Enter the name: ");
string name = Console.ReadLine();
Console.Write("Enter the explanation: ");
string explanation = Console.ReadLine();
definitions[name] = explanation;
Console.ForegroundColor = ConsoleColor.Gray;
}
else if (keyInfo.Key == ConsoleKey.L)
{
Console.ForegroundColor = ConsoleColor.White;
foreach (KeyValuePair<string, string> definition
in definitions)
{
Console.WriteLine($"{definition.Key}:
{definition.Value}");
}
Console.ForegroundColor = ConsoleColor.Gray;
}
else
{
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine("Do you want to exit the program?
Press [y] (yes) or [n] (no).");
Console.ForegroundColor = ConsoleColor.Gray;
if (Console.ReadKey().Key == ConsoleKey.Y)
{
break;
}
}
}
while (true);
首先,创建了SortedDictionary
类的新实例,它表示具有基于string
的键和基于string
的值的一组对。然后,使用无限的do-while
循环。在其中,程序会等待用户按下任意键。如果是A键,程序将从用户输入的值中获取条目的名称和解释。然后,使用索引器将新条目添加到字典中。因此,如果具有相同键的条目已经存在,它将被更新。如果按下L键,则使用foreach
循环显示所有输入的条目。当按下其他键时,会向用户显示另一个问题,并等待确认。如果用户按下Y,则跳出循环。
当运行程序时,您可以输入一些条目,并将它们显示出来。控制台的结果如下所示:
Choose an option ([a] - add, [l] - list): a
Enter the name: Zakopane
Enter the explanation: a city located in Tatra mountains in Poland
Choose an option ([a] - add, [l] - list): a
Enter the name: Rzeszow
Enter the explanation: a capital of the Subcarpathian voivodeship
in Poland
Choose an option ([a] - add, [l] - list): a
Enter the name: Warszawa
Enter the explanation: a capital city of Poland
Choose an option ([a] - add, [l] - list): a
Enter the name: Lancut
Enter the explanation: a city located near Rzeszow with
a beautiful castle
Choose an option ([a] - add, [l] - list): l
Lancut: a city located near Rzeszow with a beautiful castle
Rzeszow: a capital of the Subcarpathian voivodeship in Poland
Warszawa: a capital city of Poland
Zakopane: a city located in Tatra mountains in Poland
Choose an option ([a] - add, [l] - list): q
Do you want to exit the program? Press [y] (yes) or [n] (no).
yPress any key to continue . . .
到目前为止,您已经学习了三个与字典相关的类,分别是Hashtable
、Dictionary
和SortedDictionary
。它们都有一些特定的优势,并且可以在各种场景中使用。为了更容易理解它们,我们提供了一些示例,并附有详细的解释。
然而,你知道还有一些其他只存储键而没有值的数据结构吗?想要了解更多吗?如果是的话,让我们继续到下一节。
哈希集
在一些算法中,有必要对具有不同数据的集合执行操作。但是,什么是集合?集合是一组不重复元素的集合,没有重复的元素,也没有特定的顺序。因此,你只能知道给定的元素是否在集合中。集合与数学模型和操作紧密相关,如并集、交集、差集和对称差。
集合可以存储各种数据,如整数或字符串值,如下图所示。当然,你也可以创建一个包含用户定义类实例的集合,并随时向集合中添加和删除元素。
在看到集合的实际操作之前,值得提醒一下可以对两个集合A和B执行的一些基本操作。让我们从并集和交集开始,如下图所示。如你所见,并集(左侧显示为A∪B)是一个包含属于A或B的所有元素的集合。交集(右侧显示为A∩B)仅包含属于A和B的元素:
另一个常见的操作是集合减法。A \ B的结果集包含属于A而不属于B的元素。在下面的示例中,分别呈现了A \ B和B \ A:
在对集合执行操作时,还值得提到对称差,如下图左侧所示的A ∆ B。最终集合可以解释为两个集合的并集,即(A \ B)和(B \ A)。因此,它包含属于只属于一个集合的元素,要么是A,要么是B。属于两个集合的元素被排除在结果之外:
另一个重要的主题是集合之间的关系。如果B的每个元素也属于A,那么B是A的子集,如前图中右侧所示。同时,A是B的超集。此外,如果B是A的子集,但B不等于A,那么B是A的真子集,而A是B的真超集。
在 C#语言中开发应用程序时,你可以从System.Collections.Generic
命名空间中的HashSet
类提供的高性能操作中受益。该类包含一些属性,包括返回集合中元素数量的Count
。此外,你可以使用许多方法来执行集合操作,如下面所述。
第一组方法使得可以修改当前集合(调用方法的集合)以创建以下集合,其中传递的集合作为参数:
-
并集(
UnionWith
) -
交集(
IntersectWith
) -
差集(
ExceptWith
) -
对称差(
SymmetricExceptWith
)
你还可以检查两个集合之间的关系,例如检查调用方法的当前集合是否是:
-
传递的集合的子集(
IsSubsetOf
) -
传递的集合的超集(
IsSupersetOf
) -
传递的集合的真子集(
IsProperSubsetOf
) -
传递的集合的真超集(
IsProperSupersetOf
)
此外,你可以验证两个集合是否包含相同的元素(SetEquals
),或者两个集合是否至少有一个公共元素(Overlaps
)。
除了上述操作,您还可以向集合中添加新元素(Add
),删除特定元素(Remove
)或删除所有元素(Clear
),以及检查给定元素是否存在于集合中(Contains
)。
您可以在msdn.microsoft.com/library/bb359438.aspx
找到有关HashSet
泛型类的更多信息。
在这个介绍之后,尝试将学到的信息付诸实践是一个好主意。因此,让我们继续进行两个示例,它们将向您展示如何在应用程序中应用哈希集。
示例 - 优惠券
第一个示例代表了一个系统,用于检查一次性优惠券是否已经被使用。如果是,应向用户呈现适当的消息。否则,系统应通知用户优惠券有效,并且应标记为已使用,不能再次使用。由于优惠券数量众多,有必要选择一种数据结构,可以快速检查某个集合中是否存在元素。因此,哈希集被选择为存储已使用优惠券的标识符的数据结构。因此,您只需要检查输入的标识符是否存在于集合中。
让我们来看看应该添加到Program
类的Main
方法的代码。第一部分如下所示:
HashSet<int> usedCoupons = new HashSet<int>();
do
{
Console.Write("Enter the coupon number: ");
string couponString = Console.ReadLine();
if (int.TryParse(couponString, out int coupon))
{
if (usedCoupons.Contains(coupon))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("It has been already used :-(");
Console.ForegroundColor = ConsoleColor.Gray;
}
else
{
usedCoupons.Add(coupon);
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Thank you! :-)");
Console.ForegroundColor = ConsoleColor.Gray;
}
}
else
{
break;
}
}
while (true);
首先,创建存储整数值的HashSet
泛型类的新实例。然后,大多数操作都在do-while
循环内执行。在这里,程序会等待用户输入优惠券标识符。如果无法解析为整数值,则跳出循环。否则,将检查集合是否已包含等于优惠券标识符的元素(使用Contains
方法)。如果是,将呈现适当的警告信息。但是,如果不存在,则将其添加到已使用优惠券的集合中(使用Add
方法)并通知用户。
当您跳出循环时,您只需要显示已使用优惠券的标识符的完整列表。您可以使用foreach
循环实现此目标,遍历集合,并在控制台中写入其元素,如下面的代码所示:
Console.WriteLine();
Console.WriteLine("A list of used coupons:");
foreach (int coupon in usedCoupons)
{
Console.WriteLine(coupon);
}
现在您可以启动应用程序,输入一些数据,然后查看它的运行情况。控制台中的结果如下所示:
Enter the coupon number: 100
Thank you! :-)
Enter the coupon number: 101
Thank you! :-)
Enter the coupon number: 500
Thank you! :-)
Enter the coupon number: 345
Thank you! :-)
Enter the coupon number: 101
It has been already used :-(
Enter the coupon number: l
A list of used coupons:
100
101
500
345
这是第一个示例的结束。让我们继续进行下一个示例,在这个示例中,您将看到一个使用哈希集的更复杂的解决方案。
示例 - 游泳池
这个例子展示了一个 SPA 中心的系统,有四个游泳池,分别是休闲、比赛、温泉和儿童。每位访客都会收到一个特殊的手腕带,可以进入所有游泳池。但是,必须在进入任何游泳池时扫描手腕带,您的程序可以使用这些数据来创建各种统计数据。
在这个例子中,哈希集被选择为存储已经在每个游泳池入口扫描的手腕带的唯一编号的数据结构。将使用四个集合,每个游泳池一个,如下图所示。此外,它们将被分组在字典中,以简化和缩短代码,以及使未来的修改更容易:
为了简化测试应用程序,初始数据将被随机设置。因此,您只需要创建统计数据,即按游泳池类型统计的访客人数,最受欢迎的游泳池,至少访问过一个游泳池的人数,以及访问过所有游泳池的人数。所有统计数据将使用集合。
让我们从PoolTypeEnum
枚举开始(在PoolTypeEnum.cs
文件中声明),它表示可能的游泳池类型,如下面的代码所示:
public enum PoolTypeEnum
{
RECREATION,
COMPETITION,
THERMAL,
KIDS
};
接下来,向Program
类添加random
私有静态字段。它将用于使用一些随机值填充哈希集。代码如下:
private static Random random = new Random();
然后,在Program
类中声明GetRandomBoolean
静态方法,返回true
或false
值,根据随机值。代码如下所示:
private static bool GetRandomBoolean()
{
return random.Next(2) == 1;
}
接下来的更改只需要在Main
方法中进行。第一部分如下:
Dictionary<PoolTypeEnum, HashSet<int>> tickets =
new Dictionary<PoolTypeEnum, HashSet<int>>()
{
{ PoolTypeEnum.RECREATION, new HashSet<int>() },
{ PoolTypeEnum.COMPETITION, new HashSet<int>() },
{ PoolTypeEnum.THERMAL, new HashSet<int>() },
{ PoolTypeEnum.KIDS, new HashSet<int>() }
};
在这里,你创建了一个Dictionary
的新实例。它包含四个条目。每个键都是PoolTypeEnum
类型,每个值都是HashSet<int>
类型,也就是一个包含整数值的集合。
在接下来的部分,你会用随机值填充集合,如下所示:
for (int i = 1; i < 100; i++)
{
foreach (KeyValuePair<PoolTypeEnum, HashSet<int>> type
in tickets)
{
if (GetRandomBoolean())
{
type.Value.Add(i);
}
}
}
为此,你使用两个循环,即for
和foreach
。第一个循环 100 次,模拟 100 个手环。其中有一个foreach
循环,遍历所有可用的游泳池类型。对于每一个,你随机检查访客是否进入了特定的游泳池。通过获取一个随机的布尔值来检查。如果收到true
,则将标识符添加到适当的集合中。false
值表示具有给定手环号(i
)的用户没有进入当前游泳池。
剩下的代码与生成各种统计数据有关。首先,让我们按游泳池类型呈现访客人数。这样的任务非常简单,因为你只需要遍历字典,以及写入游泳池类型和集合中的元素数量(使用Count
属性),如下面的代码部分所示:
Console.WriteLine("Number of visitors by a pool type:");
foreach (KeyValuePair<PoolTypeEnum, HashSet<int>> type in tickets)
{
Console.WriteLine($" - {type.Key.ToString().ToLower()}:
{type.Value.Count}");
}
接下来的部分找到了访客人数最多的游泳池。这是使用 LINQ 及其方法执行的,即:
-
OrderByDescending
按集合中元素的数量降序排序元素 -
Select
来选择游泳池类型 -
FirstOrDefault
来获取第一个结果
然后,你只需呈现结果。做这件事的代码如下所示:
PoolTypeEnum maxVisitors = tickets
.OrderByDescending(t => t.Value.Count)
.Select(t => t.Key)
.FirstOrDefault();
Console.WriteLine($"Pool '{maxVisitors.ToString().ToLower()}'
was the most popular.");
然后,你需要获取至少访问了一个游泳池的人数。你可以通过创建所有集合的并集并获取最终集合的计数来执行此任务。首先,创建一个新的集合,并用有关休闲游泳池的标识符填充它。在代码的下面几行中,你调用UnionWith
方法创建与以下三个集合的并集。代码的这部分如下所示:
HashSet<int> any =
new HashSet<int>(tickets[PoolTypeEnum.RECREATION]);
any.UnionWith(tickets[PoolTypeEnum.COMPETITION]);
any.UnionWith(tickets[PoolTypeEnum.THERMAL]);
any.UnionWith(tickets[PoolTypeEnum.KIDS]);
Console.WriteLine($"{any.Count} people visited at least
one pool.");
最后的统计数据是在 SPA 中心一次访问中访问了所有游泳池的人数。要执行这样的计算,你只需要创建所有集合的交集,并获取最终集合的计数。为此,让我们创建一个新的集合,并用有关休闲游泳池的标识符填充它。然后,调用IntersectWith
方法创建与以下三个集合的交集。最后,使用Count
属性获取集合中的元素数量,并呈现结果,如下所示:
HashSet<int> all =
new HashSet<int>(tickets[PoolTypeEnum.RECREATION]);
all.IntersectWith(tickets[PoolTypeEnum.COMPETITION]);
all.IntersectWith(tickets[PoolTypeEnum.THERMAL]);
all.IntersectWith(tickets[PoolTypeEnum.KIDS]);
Console.WriteLine($"{all.Count} people visited all pools.");
就是这样!当你运行应用程序时,你可能会收到类似以下的结果:
Number of visitors by a pool type:
- recreation: 54
- competition: 44
- thermal: 48
- kids: 51
Pool 'recreation' was the most popular.
93 people visited at least one pool.
5 people visited all pools.
你刚刚完成了两个关于哈希集的例子。尝试修改代码并添加新功能是了解这种数据结构的更好方法。当你准备好学习下一个数据结构时,让我们继续阅读。
“排序”集合
前面描述的HashSet
类可以被理解为一个只存储键而没有值的字典。所以,如果有SortedDictionary
类,也许还有SortedSet
类?确实有!但是,一个集合可以被“排序”吗?为什么“排序”一词用引号括起来?答案很简单——根据定义,一个集合存储一组不重复的对象,没有重复的元素,也没有特定的顺序。如果一个集合不支持顺序,它怎么能被“排序”呢?因此,“排序”集合可以被理解为HashSet
和SortedList
的组合,而不是一个集合本身。
如果您想要一个排序的不重复元素集合,可以使用“sorted”集合。适当的类名为SortedSet
,并且位于System.Collections.Generic
命名空间中。它具有一组方法,类似于已经描述的HashSet
类的方法,例如UnionWith
,IntersectWith
,ExceptWith
,SymmetricExceptWith
,Overlaps
,IsSubsetOf
,IsSupersetOf
,IsProperSubsetOf
和IsProperSupersetOf
。但是,它还包含用于返回最小值和最大值(分别为Min
和Max
)的附加属性。还值得一提的是GetViewBetween
方法,它返回一个具有给定范围内的值的SortedSet
实例。
您可以在msdn.microsoft.com/library/dd412070.aspx
找到有关SortedSet
泛型类的更多信息。
让我们继续进行一个简单的示例,看看如何在代码中使用“sorted”集合。
示例 - 删除重复项
例如,您将创建一个简单的应用程序,从名称列表中删除重复项。当然,名称的比较应该是不区分大小写的,因此不允许在同一集合中同时拥有"Marcin"
和"marcin"
。
要查看如何实现此目标,让我们将以下代码添加为Program
类中Main
方法的主体:
List<string> names = new List<string>()
{
"Marcin",
"Mary",
"James",
"Albert",
"Lily",
"Emily",
"marcin",
"James",
"Jane"
};
SortedSet<string> sorted = new SortedSet<string>(
names,
Comparer<string>.Create((a, b) =>
a.ToLower().CompareTo(b.ToLower())));
foreach (string name in sorted)
{
Console.WriteLine(name);
}
首先,创建一个包含九个元素的名称列表,并初始化,包括"Marcin"
和"marcin"
。然后,创建SortedSet
类的新实例,传递两个参数,即名称列表和不区分大小写的比较器。最后,只需遍历集合以在控制台中写入名称。
运行应用程序后,您将看到以下结果:
Albert
Emily
James
Jane
Lily
Marcin
Mary
这是本章中展示的最后一个例子。因此,让我们继续进行总结。
总结
本书的第四章着重介绍了哈希表、字典和集合。所有这些集合都是有趣的数据结构,可以在各种场景中使用。通过详细描述和示例介绍这些集合,您已经看到选择适当的数据结构并不是一项微不足道的任务,需要分析与性能相关的主题,因为其中一些在检索值方面运行更好,而另一些则促进数据的添加和删除。
首先,您学习了如何使用哈希表的两个变体,即非泛型(Hashtable
类)和泛型(Dictionary
)。这些的巨大优势是基于键进行值查找的非常快速,接近O(1)的操作。为了实现这个目标,使用了哈希函数。此外,已经介绍了排序字典作为解决集合中无序项目问题并始终保持键排序的有趣解决方案。
随后,介绍了高性能解决方案的集合操作。它使用HashSet
类,表示一个没有重复元素和特定顺序的对象集合。该类使得可以对集合执行各种操作,如并集、交集、差集和对称差。然后,介绍了“sorted”集合(SortedSet
类)的概念,作为一个排序的不重复元素集合。
您是否想深入了解数据结构和算法,同时在 C#语言中开发应用程序?如果是这样,让我们继续进行下一章,介绍树。
第五章:树的变体
在前几章中,您已经了解了许多数据结构,从简单的数组开始。现在,是时候让您了解一组显著更复杂的数据结构,即树。
在本章的开头,将介绍基本树,以及在 C#语言中的实现和一些示例展示它的运行情况。然后,将介绍二叉树,详细描述其实现并举例说明其应用。二叉搜索树是另一种树的变体,是许多算法中使用的最流行的树类型之一。接下来的两节将涵盖自平衡树,即 AVL 和红黑树。
本章的其余部分将专门介绍堆作为基于树的数据结构。将介绍三种堆:二叉堆、二项式堆和斐波那契堆。这些类型将被简要介绍,并将展示这些数据结构的应用,使用外部包。
数组、列表、栈、队列、字典、集合,现在...树。您准备好提高难度并学习下一组数据结构了吗?如果是这样,让我们开始阅读!
在本章中,将涵盖以下主题:
-
基本树
-
二叉树
-
二叉搜索树
-
AVL 树
-
红黑树
-
二叉堆
-
二项式堆
-
斐波那契堆
基本树
让我们从介绍树开始。它们是什么?您对这样的数据结构应该是什么样子有任何想法吗?如果没有,让我们看一下以下图表,其中描述了一个带有关于其特定元素的标题的树:
树由多个节点组成,包括一个根(图表中的100)。根不包含父节点,而所有其他节点都包含。例如,节点1的父元素是100,而节点96的父元素是30。此外,每个节点可以有任意数量的子节点,例如根的情况下有三个子节点(即50、1和150)。同一节点的子节点可以被称为兄弟,就像节点70和61的情况一样。没有子节点的节点称为叶子,例如图表中的45和6。看一下包含三个节点(即30、96和9)的矩形。树的这一部分可以称为子树。当然,您可以在树中找到许多子树。
让我们简要讨论节点的最小和最大子节点数。一般来说,这些数字是没有限制的,每个节点可以包含零、一个、两个、三个,甚至更多的子节点。然而,在实际应用中,子节点的数量通常限制为两个,正如您将在以下部分中看到的。
实现
基本树的 C#实现似乎是相当明显和不复杂的。为此,您可以声明两个类,表示单个节点和整个树,如下一节所述。
节点
第一个类名为TreeNode
,声明为通用类,以便为开发人员提供指定存储在每个节点中的数据类型的能力。因此,您可以创建强类型化的解决方案,从而消除了将对象转换为目标类型的必要性。代码如下:
public class TreeNode<T>
{
public T Data { get; set; }
public TreeNode<T> Parent { get; set; }
public List<TreeNode<T>> Children { get; set; }
public int GetHeight()
{
int height = 1;
TreeNode<T> current = this;
while (current.Parent != null)
{
height++;
current = current.Parent;
}
return height;
}
}
该类包含三个属性:节点中存储的数据(Data
)是在创建类的实例时指定的类型(T
)的引用,指向父节点(Parent
)的引用,以及指向子节点(Children
)的引用的集合。
除了属性之外,TreeNode
类还包含GetHeight
方法,该方法返回节点的高度,即到根节点的距离。该方法的实现非常简单,因为它只是使用while
循环从节点向上移动,直到没有父元素(达到根时)。
树
下一个必要的类名为Tree
,它代表整个树。它的代码甚至比前一节中呈现的更简单,如下所示:
public class Tree<T>
{
public TreeNode<T> Root { get; set; }
}
该类只包含一个属性,Root
。您可以使用此属性访问根节点,然后可以使用其Children
属性获取树中其他节点的数据。
值得注意的是,TreeNode
和Tree
类都是泛型的,这些类使用相同的类型。例如,如果树节点应存储string
值,则在Tree
和TreeNode
类的实例中应使用string
类型。
示例 - 标识符的层次结构
您想看看如何在基于 C#的应用程序中使用树吗?让我们看看第一个示例。目标是构建具有几个节点的树,如下图所示。只有深色背景的节点组将在代码中呈现。但是,调整代码以自行扩展此树是一个好主意。
正如您在示例中看到的那样,每个节点都存储一个整数值。因此,int
将是Tree
和TreeNode
类都使用的类型。以下代码的一部分应放在Program
类的Main
方法中:
Tree<int> tree = new Tree<int>();
tree.Root = new TreeNode<int>() { Data = 100 };
tree.Root.Children = new List<TreeNode<int>>
{
new TreeNode<int>() { Data = 50, Parent = tree.Root },
new TreeNode<int>() { Data = 1, Parent = tree.Root },
new TreeNode<int>() { Data = 150, Parent = tree.Root }
};
tree.Root.Children[2].Children = new List<TreeNode<int>>()
{
new TreeNode<int>()
{ Data = 30, Parent = tree.Root.Children[2] }
};
代码看起来相当简单,不是吗?
首先,创建Tree
类的新实例。然后,通过创建TreeNode
类的新实例,设置Data
属性的值(为100
),并将对TreeNode
实例的引用分配给Root
属性来配置根节点。
在接下来的几行中,指定了根节点的子节点,其值分别为50
,1
和150
。对于每个节点,Parent
属性的值都设置为对先前添加的根节点的引用。
代码的其余部分显示了如何为给定节点添加子节点,即根节点的第三个子节点,即值等于150
的节点。在这里,只添加了一个节点,其值设置为30
。当然,您还需要指定对父节点的引用。
就是这样!您已经创建了使用树的第一个程序。现在可以运行它,但您在控制台中看不到任何输出。如果要查看节点数据是如何组织的,可以调试程序并在调试时查看变量的值。
示例 - 公司结构
在前面的示例中,您看到如何将整数值用作树中每个节点的数据。但是,还可以将用户定义的类的实例存储在节点中。在此示例中,您将看到如何创建一个树,展示公司的结构,分为三个主要部门:开发、研究和销售。
在每个部门中都可以有另一个结构,例如开发团队的情况。在这里,John Smith是开发部门主管。他是Chris Morris的上司,后者是两名初级开发人员Eric Green和Ashley Lopez的经理。后者还是Emily Young的主管,后者是开发实习生。
以下是示例树的示意图:
正如您所看到的,每个节点应存储的信息不仅仅是一个整数值。应该有一个标识符、一个名称和一个角色。这些数据存储为Person
类实例的属性值,如下面的代码片段所示:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public string Role { get; set; }
public Person() { }
public Person(int id, string name, string role)
{
Id = id;
Name = name;
Role = role;
}
}
该类包含三个属性(Id
,Name
和Role
),以及两个构造函数。第一个构造函数不带任何参数,而另一个带有三个参数,并设置特定属性的值。
除了创建一个新类之外,还需要在Program
类的Main
方法中添加一些代码。必要的行如下:
Tree<Person> company = new Tree<Person>();
company.Root = new TreeNode<Person>()
{
Data = new Person(100, "Marcin Jamro", "CEO"),
Parent = null
};
company.Root.Children = new List<TreeNode<Person>>()
{
new TreeNode<Person>()
{
Data = new Person(1, "John Smith", "Head of Development"),
Parent = company.Root
},
new TreeNode<Person>()
{
Data = new Person(50, "Mary Fox", "Head of Research"),
Parent = company.Root
},
new TreeNode<Person>()
{
Data = new Person(150, "Lily Smith", "Head of Sales"),
Parent = company.Root
}
};
company.Root.Children[2].Children = new List<TreeNode<Person>>()
{
new TreeNode<Person>()
{
Data = new Person(30, "Anthony Black", "Sales Specialist"),
Parent = company.Root.Children[2]
}
};
在第一行,创建了Tree
类的一个新实例。值得一提的是,在创建Tree
和TreeNode
类的新实例时,使用了Person
类作为指定类型。因此,你可以轻松地为每个节点存储多个简单数据。
代码的其余部分看起来与基本树的第一个示例相似。在这里,你还指定了根节点(CEO
角色),然后配置了它的子元素(John Smith
,Mary Fox
和Lily Smith
),并为现有节点之一设置了一个子节点,即Head of Sales
的节点。
看起来简单明了吗?在下一节中,你将看到一种更受限制但非常重要和著名的树的变体:二叉树。
二叉树
一般来说,基本树中的每个节点可以包含任意数量的子节点。然而,在二叉树的情况下,一个节点不能包含超过两个子节点。这意味着它可以包含零个、一个或两个子节点。这一要求对二叉树的形状有重要影响,如下图所示展示了二叉树:
如前所述,二叉树中的节点最多可以包含两个子节点。因此,它们被称为左子节点和右子节点。在前面图中左侧显示的二叉树中,节点21有两个子节点,68为左子节点,12为右子节点,而节点100只有一个左子节点。
你有没有想过如何遍历树中的所有节点?在树的遍历过程中,你如何指定节点的顺序?有三种常见的方法:前序遍历、中序遍历和后序遍历,如下图所示:
正如你在图中所看到的,这些方法之间存在明显的差异。然而,你有没有想过如何在二叉树中应用前序遍历、中序遍历或后序遍历?让我们详细解释所有这些方法。
如果你想使用前序遍历方法遍历二叉树,首先需要访问根节点。然后,访问左子节点。最后,访问右子节点。当然,这样的规则不仅适用于根节点,而且适用于树中的任何节点。因此,你可以理解前序遍历的顺序为首先访问当前节点,然后访问它的左子节点(使用前序遍历递归地遍历整个左子树),最后访问它的右子节点(以类似的方式遍历右子树)。
解释可能听起来有点复杂,所以让我们看一个简单的例子,关于前面图中左侧显示的树。首先,访问根节点(即1)。然后,分析它的左子节点。因此,下一个访问的节点是当前节点9。下一步是它的左子节点的前序遍历。因此,访问5。由于这个节点不包含任何子节点,你可以返回到遍历时9是当前节点的阶段。它已经被访问过,它的左子节点也是,所以现在是时候继续到它的右子节点。在这里,首先访问当前节点6,然后转到它的左子节点3。你可以应用相同的规则来继续遍历树。最终的顺序是1,9,5,6,3,4,2,7,8。
如果这听起来有点令人困惑,下图应该消除任何困惑:
该图展示了前序遍历的以下步骤,并附有额外的指示:C表示当前节点,L表示左子节点,R表示右子节点。
第二个遍历模式称为中序遍历。它与前序遍历方法的区别在于节点访问的顺序:首先是左子节点,然后是当前节点,然后是右子节点。如果您看一下图表中显示的具有所有三种遍历模式的示例,您会发现第一个访问的节点是5。为什么?开始时,分析根节点,但不访问,因为中序遍历从左子节点开始。因此,它分析节点9,但它也有一个左子节点5,所以您继续到这个节点。由于此节点没有任何子节点,因此访问当前节点(5)。然后,返回到当前节点为9的步骤,并且 - 由于其左子节点已经被访问 - 您还访问当前节点。接下来,您转到右子节点,但它有一个左子节点3,应该先访问。根据相同的规则,您访问二叉树中的剩余节点。最终顺序是5,9,3,6,1,4,7,8,2。
最后的遍历模式称为后序遍历,支持以下节点遍历顺序:左子节点,右子节点,然后是当前节点。让我们分析图表右侧显示的后序遍历示例。开始时,分析根节点,但不访问,因为后序遍历从左子节点开始。因此 - 与中序遍历方法一样 - 继续到节点9,然后5。然后,需要分析节点9的右子节点。然而,节点6有左子节点(3),应该先访问。因此,在5之后,访问3,然后6,然后是9。有趣的是,二叉树的根节点在最后访问。最终顺序是5,3,6,9,8,7,2,4,1。
您可以在en.wikipedia.org/wiki/Binary_tree
找到有关二叉树的更多信息。
在这个简短的介绍之后,让我们继续进行基于 C#的实现。
实现
二叉树的实现真的很简单,特别是如果您使用了已经描述的基本树的代码。为了您的方便,整个必要的代码都放在了以下部分,但只有它的新部分被详细解释。
节点
二叉树中的节点由BinaryTreeNode
的实例表示,它继承自TreeNode
泛型类,具有以下代码:
public class TreeNode<T>
{
public T Data { get; set; }
public TreeNode<T> Parent { get; set; }
public List<TreeNode<T>> Children { get; set; }
public int GetHeight()
{
int height = 1;
TreeNode<T> current = this;
while (current.Parent != null)
{
height++;
current = current.Parent;
}
return height;
}
}
在BinaryTreeNode
类中,需要声明两个属性Left
和Right
,它们分别表示节点的两个可能的子节点。代码的相关部分如下:
public class BinaryTreeNode<T> : TreeNode<T>
{
public BinaryTreeNode() => Children =
new List<TreeNode<T>>() { null, null };
public BinaryTreeNode<T> Left
{
get { return (BinaryTreeNode<T>)Children[0]; }
set { Children[0] = value; }
}
public BinaryTreeNode<T> Right
{
get { return (BinaryTreeNode<T>)Children[1]; }
set { Children[1] = value; }
}
}
此外,您需要确保子节点的集合包含确切两个项目,最初设置为null
。您可以通过在构造函数中为Children
属性分配默认值来实现此目标,如前面的代码所示。因此,如果要添加子节点,应将对其的引用放置为列表(Children
属性)的第一个或第二个元素。因此,这样的集合始终具有确切两个元素,并且可以访问第一个或第二个元素而不会出现任何异常。如果它设置为任何节点,则返回对其的引用,否则返回null
。
树
下一个必要的类名为BinaryTree
。它表示整个二叉树。通过使用泛型类,您可以轻松指定存储在每个节点中的数据类型。BinaryTree
类的实现的第一部分如下:
public class BinaryTree<T>
{
public BinaryTreeNode<T> Root { get; set; }
public int Count { get; set; }
}
BinaryTree
类包含两个属性:Root
,表示根节点(作为BinaryTreeNode
类的实例),以及Count
,表示树中放置的节点的总数。当然,这些不是类的唯一成员,因为它还可以配备一组关于遍历树的方法。
本书中描述的第一个遍历方法是先序遍历。作为提醒,它首先访问当前节点,然后是其左子节点,最后是右子节点。TraversePreOrder
方法的代码如下:
private void TraversePreOrder(BinaryTreeNode<T> node,
List<BinaryTreeNode<T>> result)
{
if (node != null)
{
result.Add(node);
TraversePreOrder(node.Left, result);
TraversePreOrder(node.Right, result);
}
}
该方法接受两个参数:当前节点(node
)和已访问节点的列表(result
)。递归实现非常简单。首先,通过确保参数不等于null
来检查节点是否存在。然后,将当前节点添加到已访问节点的集合中,开始对左子节点执行相同的遍历方法,最后对右子节点执行相同的遍历方法。
类似的实现也适用于中序和后序遍历模式。让我们从TraverseInOrder
方法的代码开始:
private void TraverseInOrder(BinaryTreeNode<T> node,
List<BinaryTreeNode<T>> result)
{
if (node != null)
{
TraverseInOrder(node.Left, result);
result.Add(node);
TraverseInOrder(node.Right, result);
}
}
在这里,您递归调用TraverseInOrder
方法来处理左子节点,将当前节点添加到已访问节点的列表中,并开始对右子节点进行中序遍历。
下一个方法与后序遍历模式有关,如下所示:
private void TraversePostOrder(BinaryTreeNode<T> node,
List<BinaryTreeNode<T>> result)
{
if (node != null)
{
TraversePostOrder(node.Left, result);
TraversePostOrder(node.Right, result);
result.Add(node);
}
}
该代码与已描述的方法非常相似,但是应用了另一种访问节点的顺序。在这里,您首先访问左子节点,然后访问右子节点,最后访问当前节点。
最后,让我们添加用于以各种模式遍历树的公共方法,该方法调用先前介绍的私有方法。相关代码如下:
public List<BinaryTreeNode<T>> Traverse(TraversalEnum mode)
{
List<BinaryTreeNode<T>> nodes = new List<BinaryTreeNode<T>>();
switch (mode)
{
case TraversalEnum.PREORDER:
TraversePreOrder(Root, nodes);
break;
case TraversalEnum.INORDER:
TraverseInOrder(Root, nodes);
break;
case TraversalEnum.POSTORDER:
TraversePostOrder(Root, nodes);
break;
}
return nodes;
}
该方法只接受一个参数,即TraversalEnum
枚举的值,选择适当的先序、中序和后序模式。Traverse
方法使用switch
语句根据参数的值调用适当的私有方法。
为了使用Traverse
方法,还需要声明TraversalEnum
枚举,如下所示:
public enum TraversalEnum
{
PREORDER,
INORDER,
POSTORDER
}
本节中描述的最后一个方法是GetHeight
。它返回树的高度,可以理解为从任何叶节点到根节点所需的最大步数。实现如下:
public int GetHeight()
{
int height = 0;
foreach (BinaryTreeNode<T> node
in Traverse(TraversalEnum.PREORDER))
{
height = Math.Max(height, node.GetHeight());
}
return height;
}
该代码只是使用先序遍历遍历树的所有节点,读取当前节点的高度(使用先前描述的TreeNode
类的GetHeight
方法),如果大于当前最大值,则将其保存为最大值。最后返回计算出的高度。
在介绍了二叉树的主题之后,让我们看一个示例,其中使用这种数据结构来存储简单测验中的问题和答案。
示例 - 简单的测验
作为二叉树的一个示例,将使用一个简单的测验应用程序。测验由几个问题和答案组成,根据先前做出的决定显示。应用程序呈现问题,等待用户按下Y(是)或N(否),然后继续下一个问题或显示答案。
测验的结构以二叉树的形式创建,如下所示:
首先,用户被问及是否有应用程序开发经验。如果是,程序会询问他或她是否已经作为开发人员工作了五年以上。在肯定答案的情况下,将呈现关于申请成为高级开发人员的结果。当然,在用户做出不同决定的情况下,还会显示其他答案和问题。
简单测验的实现需要BinaryTree
和BinaryTreeNode
类,这些类在先前已经介绍和解释过。除此之外,还应该声明QuizItem
类来表示单个项目,例如问题或答案。每个项目只包含文本内容,存储为Text
属性的值。适当的实现如下:
public class QuizItem
{
public string Text { get; set; }
public QuizItem(string text) => Text = text;
}
在Program
类中需要进行一些修改。让我们来看一下修改后的Main
方法:
static void Main(string[] args)
{
BinaryTree<QuizItem> tree = GetTree();
BinaryTreeNode<QuizItem> node = tree.Root;
while (node != null)
{
if (node.Left != null || node.Right != null)
{
Console.Write(node.Data.Text);
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.Y:
WriteAnswer(" Yes");
node = node.Left;
break;
case ConsoleKey.N:
WriteAnswer(" No");
node = node.Right;
break;
}
}
else
{
WriteAnswer(node.Data.Text);
node = null;
}
}
}
在方法中的第一行,调用GetTree
方法(如下面的代码片段所示)来构建具有问题和答案的树。然后,将根节点作为当前节点,直到到达答案为止。
首先,检查左侧或右侧子节点是否存在,即是否为问题(而不是答案)。然后,在控制台中写入文本内容,并等待用户按键。如果等于Y,则显示有关选择是选项的信息,并使用当前节点的左子节点作为当前节点。在选择否的情况下执行类似的操作,但然后使用当前节点的右子节点。
当用户做出的决定导致答案显示时,它会在控制台中呈现,并将null
赋给node
变量。因此,您会跳出while
循环。
如前所述,GetTree
方法用于构建具有问题和答案的二叉树。其代码如下所示:
private static BinaryTree<QuizItem> GetTree()
{
BinaryTree<QuizItem> tree = new BinaryTree<QuizItem>();
tree.Root = new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Do you have experience in developing
applications?"),
Children = new List<TreeNode<QuizItem>>()
{
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Have you worked as a
developer for more than 5 years?"),
Children = new List<TreeNode<QuizItem>>()
{
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Apply as a senior
developer!")
},
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Apply as a middle
developer!")
}
}
},
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Have you completed
the university?"),
Children = new List<TreeNode<QuizItem>>()
{
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Apply for a junior
developer!")
},
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Will you find some
time during the semester?"),
Children = new List<TreeNode<QuizItem>>()
{
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Apply for our
long-time internship program!")
},
new BinaryTreeNode<QuizItem>()
{
Data = new QuizItem("Apply for
summer internship program!")
}
}
}
}
}
}
};
tree.Count = 9;
return tree;
}
首先,创建BinaryTree
泛型类的新实例。还配置每个节点包含QuizItem
类的实例的数据。然后,将Root
属性分配给BinaryTreeNode
的新实例。
有趣的是,即使在以编程方式创建问题和答案时,您也会创建某种类似树的结构,因为您使用Children
属性并直接在这些结构中指定项目。因此,您无需为所有问题和答案创建许多本地变量。值得注意的是,与问题相关的节点是BinaryTreeNode
类的实例,具有两个子节点(用于是和否决定),而与答案相关的节点不能包含任何子节点。
在所提供的解决方案中,BinaryTreeNode
实例的Parent
属性的值未设置。如果要使用它们或获取节点或树的高度,则应自行设置它们。
最后一个辅助方法是WriteAnswer
,代码如下:
private static void WriteAnswer(string text)
{
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine(text);
Console.ForegroundColor = ConsoleColor.Gray;
}
该方法只是在控制台中以白色显示传递的文本参数。它用于显示用户做出的决定和答案的文本内容。
简单的测验应用程序已准备就绪!您可以构建项目,启动它,并回答一些问题以查看结果。然后,让我们关闭程序并继续到下一部分,介绍二叉树数据结构的变体。
二叉搜索树
二叉树是一种有趣的数据结构,允许创建元素的层次结构,每个节点最多可以包含两个子节点,但没有关于节点之间关系的任何规则。因此,如果要检查二叉树是否包含给定值,需要检查每个节点,使用三种可用模式之一遍历树:前序,中序或后序。这意味着查找时间是线性的,即O(n)。
如果树中存在一些关于节点关系的明确规则呢?假设有这样一种情况,左子树包含小于根值的节点,而右子树包含大于根值的节点。然后,您可以将搜索值与当前节点进行比较,并决定是否应继续在左侧或右侧子树中搜索。这种方法可以显著限制检查树是否包含给定值所需的操作数量。这似乎很有趣,不是吗?
这种方法应用于二叉搜索树数据结构,也称为BST。它是一种二叉树,引入了两个关于树中节点关系的严格规则。规则规定对于任何节点:
-
其左子树中所有节点的值必须小于其值
-
其右子树中所有节点的值必须大于其值
一般来说,二叉搜索树可以包含两个或更多具有相同值的元素。但是,在本书中给出了一个简化版本,不接受多个具有相同值的元素。
实际上是什么样子?让我们看一下以下二叉搜索树的图表:
左侧显示的树包含 12 个节点。让我们检查它是否符合二叉搜索树的规则。您可以通过分析树中除了叶节点以外的每个节点来进行检查。
让我们从根节点(值为50)开始,它在左子树中包含四个后代节点(40、30、45、43),都小于50。根节点在右子树中包含七个后代节点(60、80、70、65、75、90、100),都大于50。这意味着根节点满足了二叉搜索树的规则。如果您想检查节点80的二叉搜索树规则,您会发现左子树中所有后代节点的值(70、65、75)都小于80,而右子树中的值(90、100)都大于80。您应该对树中的所有节点执行相同的验证。同样,您可以确认图表右侧的二叉搜索树遵守了规则。
然而,这两个二叉搜索树在拓扑结构上有很大的不同。它们的高度相同,但节点的数量不同——12 和 7。左边的看起来很胖,而另一个则相对瘦。哪一个更好?为了回答这个问题,让我们考虑一下在树中搜索一个值的算法。例如,搜索值43的过程在下图中描述和展示:
开始时,您取根节点的值(即50)并检查给定的值(43)是较小还是较大。它较小,所以您继续在左子树中搜索。因此,您将43与40进行比较。这次选择右子树,因为43大于40。接下来,43与45进行比较,并选择左子树。在这里,您将43与43进行比较。因此,找到了给定的值。如果您看一下树,您会发现只需要四次比较,对性能的影响是显而易见的。
因此,很明显树的形状对查找性能有很大影响。当然,拥有高度有限的胖树要比高度更大的瘦树好得多。性能提升是由于在继续在左子树或右子树中搜索时做出决策,而无需分析所有节点的值。如果节点没有两个子树,对性能的积极影响将受到限制。在最坏的情况下,当每个节点只包含一个子节点时,搜索时间甚至是线性的。然而,在理想的二叉搜索树中,查找时间是O(log n)操作。
您可以在en.wikipedia.org/wiki/Binary_search_tree
找到更多关于二叉搜索树的信息。
在这个简短的介绍之后,让我们继续使用 C#语言进行实现。最后,您将看到一个示例,展示了如何在实践中使用这种数据结构。
实现
二叉搜索树的实现比先前描述的树的变体更困难。例如,它要求您准备树中节点的插入和删除操作,这些操作不会违反二叉搜索树中元素排列的规则。此外,您需要引入一个比较节点的机制。
节点
让我们从表示树中单个节点的类开始。幸运的是,您可以使用已经描述的二叉树类(BinaryTreeNode
)的实现作为基础。修改后的代码如下:
public class BinaryTreeNode<T> : TreeNode<T>
{
public BinaryTreeNode() => Children =
new List<TreeNode<T>>() { null, null };
public BinaryTreeNode<T> Parent { get; set; }
public BinaryTreeNode<T> Left
{
get { return (BinaryTreeNode<T>)Children[0]; }
set { Children[0] = value; }
}
public BinaryTreeNode<T> Right
{
get { return (BinaryTreeNode<T>)Children[1]; }
set { Children[1] = value; }
}
public int GetHeight()
{
int height = 1;
BinaryTreeNode<T> current = this;
while (current.Parent != null)
{
height++;
current = current.Parent;
}
return height;
}
}
由于 BST 是二叉树的一种变体,每个节点都有对其左右子节点(如果不存在则为null
)以及父节点的引用。节点还存储给定类型的值。正如您在前面的代码中所看到的,BinaryTreeNode
类添加了两个成员,即Parent
属性(BinaryTreeNode
类型)和GetHeight
方法。它们是从TreeNode
类的实现中移动和调整的。最终代码如下:
public class TreeNode<T>
{
public T Data { get; set; }
public List<TreeNode<T>> Children { get; set; }
}
修改的原因是为开发人员提供一种简单的方法,以便在不需要从TreeNode
到BinaryTreeNode
进行转换的情况下访问给定节点的父节点。
树
整个树由BinarySearchTree
类的实例表示,该类继承自BinaryTree
泛型类,如下面的代码片段所示:
public class BinarySearchTree<T> : BinaryTree<T>
where T : IComparable
{
}
值得一提的是,每个节点中存储的数据类型应该是可比较的。因此,它必须实现IComparable
接口。这种要求是必要的,因为算法需要了解值之间的关系。
当然,这不是BinarySearchTree
类实现的最终版本。在接下来的部分中,您将看到如何添加新功能,比如查找、插入和删除节点。
查找
让我们来看一下Contains
方法,它检查树中是否包含具有给定值的节点。当然,此方法考虑了有关节点排列的 BST 规则,以限制比较的数量。代码如下:
public bool Contains(T data)
{
BinaryTreeNode<T> node = Root;
while (node != null)
{
int result = data.CompareTo(node.Data);
if (result == 0)
{
return true;
}
else if (result < 0)
{
node = node.Left;
}
else
{
node = node.Right;
}
}
return false;
}
该方法只接受一个参数,即应在树中找到的值。在方法内部,存在while
循环。在其中,将搜索的值与当前节点的值进行比较。如果它们相等(比较返回0
作为结果),则找到该值,并返回true
布尔值以通知搜索成功完成。如果搜索的值小于当前节点的值,则算法继续在以当前节点的左子节点为根的子树中搜索。否则,使用右子树。
CompareTo
方法由System
命名空间中的IComparable
接口的实现提供。这种方法使得比较值成为可能。如果它们相等,则返回0
。如果调用该方法的对象大于参数,则返回大于0
的值。否则,返回小于0
的值。
循环执行直到找到节点或没有合适的子节点可以跟随。
插入
下一个必要的操作是将节点插入 BST。这项任务有点复杂,因为您需要找到一个不会违反 BST 规则的新元素添加位置。让我们来看一下Add
方法的代码:
public void Add(T data)
{
BinaryTreeNode<T> parent = GetParentForNewNode(data);
BinaryTreeNode<T> node = new BinaryTreeNode<T>()
{ Data = data, Parent = parent };
if (parent == null)
{
Root = node;
}
else if (data.CompareTo(parent.Data) < 0)
{
parent.Left = node;
}
else
{
parent.Right = node;
}
Count++;
}
该方法接受一个参数,即应添加到树中的值。在方法内部,找到应将新节点添加为子节点的父元素(使用GetParentForNewNode
辅助方法),然后创建BinaryTreeNode
类的新实例,并设置其Data
和Parent
属性的值。
在方法的后续部分,您检查找到的父元素是否等于null
。这意味着树中没有节点,新节点应该被添加为根节点,这在将节点的引用分配给Root
属性的行中很明显。下一个比较检查要添加的值是否小于父节点的值。在这种情况下,新节点应该被添加为父节点的左子节点。否则,新节点将被放置为父节点的右子节点。最后,树中存储的元素数量增加。
让我们来看看用于查找新节点的父元素的辅助方法:
private BinaryTreeNode<T> GetParentForNewNode(T data)
{
BinaryTreeNode<T> current = Root;
BinaryTreeNode<T> parent = null;
while (current != null)
{
parent = current;
int result = data.CompareTo(current.Data);
if (result == 0)
{
throw new ArgumentException(
$"The node {data} already exists.");
}
else if (result < 0)
{
current = current.Left;
}
else
{
current = current.Right;
}
}
return parent;
}
该方法名为GetParentForNewNode
,只需要一个参数,即新节点的值。在这个方法中,您声明了两个变量,表示当前分析的节点(current
)和父节点(parent
)。这些值在while
循环中被修改,直到算法找到新节点的合适位置。
在循环中,您将当前节点的引用存储为潜在的父节点。然后,进行比较,就像在先前描述的代码片段中一样。首先,您检查要添加的值是否等于当前节点的值。如果是,将抛出异常,因为不允许向分析版本的 BST 中添加多个具有相同值的元素。如果要添加的值小于当前节点的值,则算法继续在左子树中搜索新节点的位置。否则,使用当前节点的右子树。最后,将parent
变量的值返回以指示找到新节点的位置。
删除
现在你知道如何创建一个新的 BST,向其中添加一些节点,并检查树中是否已经存在给定的值。但是,你也能从树中删除一个项目吗?当然可以!您将在本节中学习如何实现这一目标。
从树中删除节点的主要方法名为Remove
,只需要一个参数,即应该被删除的节点的值。Remove
方法的实现如下:
public void Remove(T data)
{
Remove(Root, data);
}
正如您所看到的,该方法只是调用另一个名为Remove
的方法。该方法的实现更加复杂,如下所示:
private void Remove(BinaryTreeNode<T> node, T data)
{
if (node == null)
{
throw new ArgumentException(
$"The node {data} does not exist.");
}
else if (data.CompareTo(node.Data) < 0)
{
Remove(node.Left, data);
}
else if (data.CompareTo(node.Data) > 0)
{
Remove(node.Right, data);
}
else
{
if (node.Left == null && node.Right == null)
{
ReplaceInParent(node, null);
Count--;
}
else if (node.Right == null)
{
ReplaceInParent(node, node.Left);
Count--;
}
else if (node.Left == null)
{
ReplaceInParent(node, node.Right);
Count--;
}
else
{
BinaryTreeNode<T> successor =
FindMinimumInSubtree(node.Right);
node.Data = successor.Data;
Remove(successor, successor.Data);
}
}
}
在开始时,该方法检查当前节点(node
参数)是否存在。如果不存在,则会抛出异常。然后,Remove
方法尝试找到要删除的节点。通过将当前节点的值与要删除的值进行比较,并递归调用Remove
方法,尝试在当前节点的左子树或右子树中找到要删除的节点。这些操作在条件语句中执行,条件为data.CompareTo(node.Data) < 0
和data.CompareTo(node.Data) > 0
。
最有趣的操作是在方法的以下部分执行的。在这里,您需要处理节点删除的四种情况,即:
-
删除叶节点
-
只有左子节点的节点
-
只有右子节点的节点
-
删除具有左右子节点的节点
在第一种情况中,您只需更新父元素中对被删除节点的引用。因此,父节点到被删除节点的引用将不存在,无法在遍历树时到达。
第二种情况也很简单,因为您只需要用被删除节点的左子节点替换父元素中对被删除节点的引用。这种情况在下图中显示,演示了如何删除只有左子节点的节点80:
第三种情况与第二种情况非常相似。因此,您只需用被删除节点的右子节点替换对被删除节点(在父元素中)的引用。
所有这三种情况都通过调用辅助方法(ReplaceInParent
)在代码中以类似的方式处理。它接受两个参数:要删除的节点和应该在父节点中替换它的节点。因此,如果要删除叶节点,只需将null
作为第二个参数传递,因为您不希望用其他任何东西替换已删除的节点。在仅具有一个子节点的情况下,您将传递到左侧或右侧子节点的引用。当然,您还需要递减存储在树中的元素数量的计数器。
代码的相关部分如下(对于不同情况有所不同):
ReplaceInParent(node, node.Left);
Count--;
当然,最复杂的情况是删除具有两个子节点的节点。在这种情况下,您会在要删除的节点的右子树中找到具有最小值的节点。然后,您交换要删除的节点的值与找到的节点的值。最后,您只需要对找到的节点递归调用Remove
方法。代码的相关部分如下所示:
BinaryTreeNode<T> successor = FindMinimumInSubtree(node.Right);
node.Data = successor.Data;
Remove(successor, successor.Data);
重要的角色由ReplaceInParent
辅助方法执行,其代码如下:
private void ReplaceInParent(BinaryTreeNode<T> node,
BinaryTreeNode<T> newNode)
{
if (node.Parent != null)
{
if (node.Parent.Left == node)
{
node.Parent.Left = newNode;
}
else
{
node.Parent.Right = newNode;
}
}
else
{
Root = newNode;
}
if (newNode != null)
{
newNode.Parent = node.Parent;
}
}
该方法接受两个参数:要删除的节点(node
)和应该在父节点中替换它的节点(newNode
)。如果要删除的节点不是根,则检查它是否是父节点的左子节点。如果是,则更新适当的引用,也就是将新节点设置为要删除的节点的父节点的左子节点。以类似的方式,该方法处理了要删除的节点是父节点的右子节点的情况。如果要删除的节点是根,则将替换节点设置为根。
最后,您检查新节点是否不等于null
,也就是说,您没有删除叶节点。在这种情况下,您将Parent
属性的值设置为指示新节点应该与要删除的节点具有相同父节点。
最后的辅助方法名为FindMinimumInSubtree
,代码如下:
private BinaryTreeNode<T> FindMinimumInSubtree(
BinaryTreeNode<T> node)
{
while (node.Left != null)
{
node = node.Left;
}
return node;
}
该方法只接受一个参数,即应找到最小值的子树的根。在方法内部,使用while
循环来获取最左边的元素。当没有左子节点时,返回node
变量的当前值。
所呈现的 BST 实现基于en.wikipedia.org/wiki/Binary_search_tree
上显示的代码。
代码看起来相当简单,不是吗?但是,在实践中它是如何工作的呢?让我们看一下图表,描述了删除具有两个子节点的节点的过程:
该图显示了如何删除值为40的节点。为此,您需要找到继承者,也就是要删除的节点右子树中具有最小值的节点。继承者是节点42,它替换了节点40。
示例-BST 可视化
在阅读有关 BST 的部分时,您已经了解了有关数据结构的很多知识。因此,现在是时候创建一个示例程序,以查看这种树的变体如何运作。该应用程序将展示如何创建 BST,手动添加一些节点(使用先前呈现的插入方法),删除节点,遍历树,并在控制台中可视化树。
让我们调整Program
类的代码,如下所示:
class Program
{
private const int COLUMN_WIDTH = 5;
public static void Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
BinarySearchTree<int> tree = new BinarySearchTree<int>();
tree.Root = new BinaryTreeNode<int>() { Data = 100 };
tree.Root.Left = new BinaryTreeNode<int>()
{ Data = 50, Parent = tree.Root };
tree.Root.Right = new BinaryTreeNode<int>()
{ Data = 150, Parent = tree.Root };
tree.Count = 3;
VisualizeTree(tree, "The BST with three nodes
(50, 100, 150):");
tree.Add(75);
tree.Add(125);
VisualizeTree(tree, "The BST after adding two nodes
(75, 125):"); (...)
tree.Remove(25);
VisualizeTree(tree,
"The BST after removing the node 25:"); (...)
Console.Write("Pre-order traversal:\t");
Console.Write(string.Join(", ", tree.Traverse(
TraversalEnum.PREORDER).Select(n => n.Data)));
Console.Write("\nIn-order traversal:\t");
Console.Write(string.Join(", ", tree.Traverse(
TraversalEnum.INORDER).Select(n => n.Data)));
Console.Write("\nPost-order traversal:\t");
Console.Write(string.Join(", ", tree.Traverse(
TraversalEnum.POSTORDER).Select(n => n.Data)));
}
一开始,通过创建BinarySearchTree
类的新实例来准备一个新树(其中节点存储整数值)。通过手动配置,添加了三个节点,并指示了适当的子节点和父节点元素的引用。代码的相关部分如下:
BinarySearchTree<int> tree = new BinarySearchTree<int>();
tree.Root = new BinaryTreeNode<int>() { Data = 100 };
tree.Root.Left = new BinaryTreeNode<int>()
{ Data = 50, Parent = tree.Root };
tree.Root.Right = new BinaryTreeNode<int>()
{ Data = 150, Parent = tree.Root };
tree.Count = 3;
然后,使用Add
方法向树中添加一些节点,并使用VisualizeTree
方法可视化树的当前状态,如下所示:
tree.Add(125);
VisualizeTree(tree, "The BST after adding two nodes (75, 125):");
接下来的一系列操作与从树中删除各种节点以及可视化特定更改相关。代码如下:
tree.Remove(25);
VisualizeTree(tree, "The BST after removing the node 25:");
最后,展示了所有三种遍历模式。与前序遍历相关的代码部分如下:
Console.WriteLine("Pre-order traversal:\t");
Console.Write(string.Join(", ",
tree.Traverse(TraversalEnum.PREORDER).Select(n => n.Data)));
另一个有趣的任务是在控制台中开发树的可视化。这样的功能非常有用,因为它允许舒适快速地观察树,而无需在 IDE 中调试应用程序并展开工具提示中的当前变量值。然而,在控制台中呈现树并不是一项简单的任务。幸运的是,您不需要担心,因为您将在本节中学习如何实现这样的功能。
首先,让我们看一下VisualizeTree
方法:
private static void VisualizeTree(
BinarySearchTree<int> tree, string caption)
{
char[][] console = InitializeVisualization(
tree, out int width);
VisualizeNode(tree.Root, 0, width / 2, console, width);
Console.WriteLine(caption);
foreach (char[] row in console)
{
Console.WriteLine(row);
}
}
该方法接受两个参数:代表整个树的BinarySearchTree
类的实例,以及应该显示在可视化上方的标题。在方法内部,使用InitializeVisualization
辅助方法初始化了不规则数组(其中包含应在控制台中显示的字符)。然后,调用VisualizeNode
递归方法,将不同部分的不规则数组填充为有关树中特定节点的数据。最后,在控制台中写入标题和缓冲区(由不规则数组表示)中的所有行。
下一个有趣的方法是InitializeVisualization
,它创建了前面提到的不规则数组,如下面的代码片段所示:
private static char[][] InitializeVisualization(
BinarySearchTree<int> tree, out int width)
{
int height = tree.GetHeight();
width = (int)Math.Pow(2, height) - 1;
char[][] console = new char[height * 2][];
for (int i = 0; i < height * 2; i++)
{
console[i] = new char[COLUMN_WIDTH * width];
}
return console;
}
不规则数组包含的行数等于树的高度乘以2
,以便为连接节点与父节点的线留出空间。列数根据公式宽度 * 2^(高度) - 1 计算,其中宽度是常量值COLUMN_WIDTH
,高度是树的高度。如果您在控制台中查看结果,这些值可能更容易理解:
100
┌-------------------+-------------------┐
50 150
┌---------+---------┐ ┌---------+---------┐
25 75 125 175
+----┐ ┌----+----┐
90 110 135
在这里,不规则数组有 8 个元素。每个都是一个包含 75 个元素的数组。当然,您可以将其理解为具有 8 行和 75 列的屏幕缓冲区。
在VisualizeTree
方法中,调用了VisualizeNode
。您是否有兴趣了解它是如何工作的,以及如何呈现节点的值以及线条?如果是的话,让我们看一下它的代码,如下所示:
private static void VisualizeNode(BinaryTreeNode<int> node,
int row, int column, char[][] console, int width)
{
if (node != null)
{
char[] chars = node.Data.ToString().ToCharArray();
int margin = (COLUMN_WIDTH - chars.Length) / 2;
for (int i = 0; i < chars.Length; i++)
{
console[row][COLUMN_WIDTH * column + i + margin]
= chars[i];
}
int columnDelta = (width + 1) /
(int)Math.Pow(2, node.GetHeight() + 1);
VisualizeNode(node.Left, row + 2, column - columnDelta,
console, width);
VisualizeNode(node.Right, row + 2, column + columnDelta,
console, width);
DrawLineLeft(node, row, column, console, columnDelta);
DrawLineRight(node, row, column, console, columnDelta);
}
}
VisualizeNode
方法接受五个参数:用于可视化的当前节点(node
)、行的索引(row
)、列的索引(column
)、作为缓冲区的不规则数组(console
)和宽度(width
)。在方法内部,检查当前节点是否存在。如果存在,则获取节点的值作为char
数组,计算边距,并将char
数组(表示值的基于字符的表示)写入缓冲区(console
变量)。
在接下来的代码中,为当前节点的左右子节点调用了VisualizeNode
方法。当然,您需要调整行的索引(加2
)和列的索引(加或减计算出的值)。
最后,通过调用DrawLineLeft
和DrawLineRight
方法来绘制线条。第一个方法在以下代码片段中呈现:
private static void DrawLineLeft(BinaryTreeNode<int> node,
int row, int column, char[][] console, int columnDelta)
{
if (node.Left != null)
{
int startColumnIndex =
COLUMN_WIDTH * (column - columnDelta) + 2;
int endColumnIndex = COLUMN_WIDTH * column + 2;
for (int x = startColumnIndex + 1;
x < endColumnIndex; x++)
{
console[row + 1][x] = '-';
}
console[row + 1][startColumnIndex] = '\u250c';
console[row + 1][endColumnIndex] = '+';
}
}
该方法还接受五个参数:应该绘制线的当前节点(node
)、行索引(row
)、列索引(column
)、作为缓冲区的嵌套数组(console
)和在VisualizeNode
方法中计算的增量值(columnDelta
)。首先,你检查当前节点是否包含左子节点,因为只有在这种情况下才需要绘制线的左部分。如果是这样,你计算列的起始和结束索引,并用破折号填充嵌套数组的适当元素。最后,在绘制的线将与另一个元素的右线连接的地方,加入加号到嵌套数组中。此外,Unicode 字符┌(\u250c
)也被添加到线的另一侧,以创建用户友好的可视化。
几乎以相同的方式,你可以为当前节点绘制右线。当然,你需要调整代码以计算列的起始和结束索引,并更改用于表示线方向变化的字符。DrawLineRight
方法的最终代码版本如下:
private static void DrawLineRight(BinaryTreeNode<int> node,
int row, int column, char[][] console, int columnDelta)
{
if (node.Right != null)
{
int startColumnIndex = COLUMN_WIDTH * column + 2;
int endColumnIndex =
COLUMN_WIDTH * (column + columnDelta) + 2;
for (int x = startColumnIndex + 1;
x < endColumnIndex; x++)
{
console[row + 1][x] = '-';
}
console[row + 1][startColumnIndex] = '+';
console[row + 1][endColumnIndex] = '\u2510';
}
}
就是这样!你已经编写了构建项目、启动程序并看到它运行所需的全部代码。启动后,你将看到第一个 BST,如下所示:
The BST with three nodes (50, 100, 150):
100
┌----+----┐
50 150
在添加了下一个两个节点75
和125
之后,BST 看起来有点不同:
The BST after adding two nodes (75, 125):
100
┌---------+---------┐
50 150
+----┐ ┌----+
75 125
然后,你执行下一个五个元素的插入操作。这些操作对树形状有非常明显的影响,如在控制台中呈现的那样:
The BST after adding five nodes (25, 175, 90, 110, 135):
100
┌-------------------+-------------------┐
50 150
┌---------+---------┐ ┌---------+---------┐
25 75 125 175
+----┐ ┌----+----┐
90 110 135
在添加了 10 个元素后,程序展示了删除特定节点对树形状的影响。首先,让我们删除值为25
的叶节点:
The BST after removing the node 25:
100
┌-------------------+-------------------┐
50 150
+---------┐ ┌---------+---------┐
75 125 175
+----┐ ┌----+----┐
90 110 135
然后,程序检查删除只有一个子节点的节点,即右侧节点。有趣的是右子节点也有一个右子节点。然而,在这种情况下,呈现的算法也能正常工作,你会得到以下结果:
The BST after removing the node 50:
100
┌-------------------+-------------------┐
75 150
+----┐ ┌---------+---------┐
90 125 175
┌----+----┐
110 135
最后的删除操作是最复杂的,因为它需要你删除具有两个子节点的节点,并且还扮演着根的角色。在这种情况下,找到根的右子树中最左边的元素,并替换要删除的节点,如树的最终视图所示:
The BST after removing the node 100:
110
┌-------------------+-------------------┐
75 150
+---------┐ ┌---------+---------┐
90 125 175
+----┐
135
还有一组操作剩下——以三种不同的方式遍历树:前序、中序和后序。应用程序呈现以下结果:
Pre-order traversal: 110, 75, 90, 150, 125, 135, 175
In-order traversal: 75, 90, 110, 125, 135, 150, 175
Post-order traversal: 90, 75, 135, 125, 175, 150, 110
创建的应用程序看起来相当令人印象深刻,不是吗?你不仅从头开始创建了二叉搜索树的实现,还为在控制台中可视化它做好了准备。干得好!
让我们再来看看中序遍历方法的结果。正如你所看到的,它会给出二叉搜索树中按升序排序的节点。
然而,你能看到创建的解决方案存在潜在问题吗?如果你只从树的给定区域删除节点,或者插入已排序的值,会怎么样?这可能意味着,具有适当宽度深度比的胖树可能变成瘦树。在最坏的情况下,它甚至可能被描述为一个列表,其中所有节点只有一个子节点。你有没有想法如何解决不平衡树的问题,并始终保持它们平衡?如果没有,让我们继续到下一节,介绍两种自平衡树的变体。
AVL 树
在这一节中,你将了解一种自平衡树的变体,它在添加和删除节点时始终保持树的平衡。然而,为什么这么重要呢?如前所述,查找时间的性能取决于树的形状。在节点的组织不当形成列表的情况下,查找给定值的过程可能是O(n)操作。通过正确排列树,性能可以显著提高到O(log n)。
您知道 BST 很容易变成失衡树吗?让我们对树添加以下九个数字进行简单测试,从 1 到 9。然后,您将得到左侧图表中显示的形状的树。然而,相同的值可以以另一种方式排列,作为平衡树,具有明显更好的宽度深度比,如右侧图表所示:
您知道什么是失衡和平衡树,以及自平衡树的目的,但 AVL 树是什么?它是如何工作的?在使用这种数据结构时应该考虑哪些规则?
AVL 树是具有附加要求的二叉搜索树,对于每个节点,其左右子树的高度不能相差超过一。当然,在向树中添加和删除节点后,必须保持这个规则。旋转起着重要作用,用于修复节点的不正确排列。
在谈论 AVL 树时,还必须指出这种数据结构的性能。在这种情况下,插入、删除和查找的平均和最坏情况都是O(log n),因此与二叉搜索树相比,在最坏情况下有显着的改进。
您可以在en.wikipedia.org/wiki/AVL_tree
找到有关 AVL 树的更多信息。
在这个简短的介绍之后,让我们继续实现。
实现
AVL 树的实现,包括保持树平衡状态所需的各种旋转,似乎相当复杂。幸运的是,您不需要从头开始创建其实现,因为您可以使用其中一个可用的 NuGet 包,例如Adjunct,它将用于创建我们的示例。
有关 Adjunct 库的更多信息可以在以下网址找到:
该软件包为开发人员提供了一些类,可用于创建基于 C#的应用程序。让我们专注于AvlTree
泛型类,它代表 AVL 树。该类非常易于使用,因此您无需了解 AVL 树的所有内部细节,就可以轻松地从中受益。
例如,AvlTree
类配备有Add
方法,该方法在树中的适当位置插入新节点。您可以使用Remove
方法轻松删除节点。此外,您可以通过调用Height
方法获取给定节点的高度。还可以使用GetBalanceFactor
获取给定节点的平衡因子,该平衡因子是左右子树高度之差计算得出的。
另一个重要的类是AvlTreeNode
。它实现了IBinaryTreeNode
接口,并包含四个属性,表示节点的高度(Height
),左右节点的引用(Left
和Right
),以及节点中存储的值(Value
),在创建类的实例时指定了类型。
示例-保持树平衡
AVL 树的介绍中提到,有一个非常简单的测试可以导致 BST 树失衡。您只需添加有序数字即可创建一个又长又瘦的树。因此,让我们尝试创建一个使用Adjunct
库实现的 AVL 树的示例,添加完全相同的数据集。
Program
类中Main
方法中的代码如下:
AvlTree<int> tree = new AvlTree<int>();
for (int i = 1; i < 10; i++)
{
tree.Add(i);
}
Console.WriteLine("In-order: "
+ string.Join(", ", tree.GetInorderEnumerator()));
Console.WriteLine("Post-order: "
+ string.Join(", ", tree.GetPostorderEnumerator()));
Console.WriteLine("Breadth-first: "
+ string.Join(", ", tree.GetBreadthFirstEnumerator()));
AvlTreeNode<int> node = tree.FindNode(8);
Console.WriteLine($"Children of node {node.Value} (height =
{node.Height}): {node.Left.Value} and {node.Right.Value}.");
首先,创建AvlTree
类的新实例,并指示节点将存储整数值。然后,使用for
循环将以下数字(从 1 到 9)添加到树中,使用Add
方法。循环执行后,树应包含 9 个节点,按照 AVL 树的规则排列。
此外,您可以使用常规方法遍历树:中序(GetInorderEnumerator
),后序(GetPostorderEnumerator
)和广度优先(GetBreadthFirstEnumerator
)方法。您已经了解了前两种方法,但是广度优先遍历是什么?它的目的是首先访问同一深度上的所有节点,然后继续到下一深度,直到达到最大深度。
当您运行应用程序时,您将收到以下遍历的结果:
In-order: 1, 2, 3, 4, 5, 6, 7, 8, 9
Post-order: 1, 3, 2, 5, 7, 9, 8, 6, 4
Breadth-first: 4, 2, 6, 1, 3, 5, 8, 7, 9
代码的最后部分显示了 AVL 树的查找功能,使用FindNode
方法。它用于获取表示具有给定值的节点的AvlTreeNode
实例。然后,您可以轻松地获取有关节点的各种数据,例如其高度,以及AvlTreeNode
类的属性的左右子节点的值。有关查找功能的控制台输出部分如下:
Children of node 8 (height = 2): 7 and 9.
简单、方便,而且不需要太多的开发工作——这很准确地描述了应用其中一个可用包来支持 AVL 树的过程。通过使用它,您无需自己准备复杂的代码,可能出现的问题数量也可以得到显著减少。
红黑树
红黑树,也称为RBT,是自平衡二叉搜索树的下一个变体。作为 BST 的变体,这种数据结构要求维护标准的 BST 规则。此外,必须考虑以下规则:
-
每个节点必须被着为红色或黑色。因此,您需要为存储颜色的节点添加额外的数据。
-
所有具有值的节点不能是叶节点。因此,NIL 伪节点应该用作树中的叶子节点,而所有其他节点都是内部节点。此外,所有 NIL 伪节点必须是黑色的。
-
如果一个节点是红色,那么它的两个子节点必须是黑色。
-
对于任何节点,到后代叶子节点(即 NIL 伪节点)的路径上黑色节点的数量必须相同。
适当的 RBT 如下图所示:
树由九个节点组成,每个节点都着为红色或黑色。值得一提的是 NIL 伪节点,它们被添加为叶子节点。如果您再次查看前面列出的规则集,您可以确认在这种情况下所有这些规则都得到了遵守。
与 AVL 树类似,RBT 在添加或删除节点后也必须维护规则。在这种情况下,恢复 RBT 属性的过程更加复杂,因为它涉及重新着色和旋转。幸运的是,您无需了解和理解内部细节,这些细节相当复杂,才能从这种数据结构中受益并将其应用于您的项目中。
在谈论这种自平衡 BST 的变体时,还值得注意性能。在平均和最坏情况下,插入、删除和查找都是O(log n)操作,因此它们与 AVL 树的情况相同,并且在最坏情况下与 BST 相比要好得多。
您可以在en.wikipedia.org/wiki/Red-black_tree
找到有关 RBT 的更多信息。
您已经学习了一些关于 RBT 的基本信息,所以让我们继续使用其中一个可用的库来实现。
实施
如果您想在应用程序中使用 RBT,您可以从头开始实现它,也可以使用其中一个可用的库,例如TreeLib
,您可以使用 NuGet 软件包管理器轻松安装它。该库支持几种树,其中包括 RBT。
您可以在programmatom.github.io/TreeLib/
和www.nuget.org/packages/TreeLib
找到有关该库的更多信息。
由于该库为开发人员提供了许多类,因此最好查看与 RBT 相关的类。第一个类名为RedBlackTreeList
,表示 RBT。它是一个通用类,因此您可以轻松指定存储在每个节点中的数据类型。
该类包含一组方法,包括Add
用于向树中插入新元素,Remove
用于删除具有特定值的节点,ContainsKey
用于检查树是否包含给定值,以及Greatest
和Least
用于返回树中存储的最大和最小值。此外,该类配备了几种遍历节点的变体,包括枚举器。
示例-RBT 相关功能
与 AVL 树一样,让我们使用外部库为 RBT 准备示例。简单的程序将展示如何创建新树,添加元素,删除特定节点,并从库的其他功能中受益。
让我们看一下以下代码片段,它应该添加到Program
类中的Main
方法中。第一部分如下:
RedBlackTreeList<int> tree = new RedBlackTreeList<int>();
for (int i = 1; i <= 10; i++)
{
tree.Add(i);
}
在这里,创建了RedBlackTreeList
类的新实例。指定节点将存储整数值。然后,使用for
循环将 10 个数字(从 1 到 10 排序)添加到树中,使用Add
方法。执行后,具有 10 个元素的正确排列的 RBT 应该准备就绪。
在下一行中,使用Remove
方法删除值等于 9 的节点:
tree.Remove(9);
以下代码行检查树是否包含值等于5
的节点。然后使用返回的布尔值在控制台中呈现消息:
bool contains = tree.ContainsKey(5);
Console.WriteLine(
"Does value exist? " + (contains ? "yes" : "no"));
代码的下一部分显示了如何使用Count
属性以及Greatest
和Least
方法。这些功能允许计算树中元素的总数,以及存储在其中的最小和最大值。相关的代码行如下:
uint count = tree.Count;
tree.Greatest(out int greatest);
tree.Least(out int least);
Console.WriteLine(
$"{count} elements in the range {least}-{greatest}");
在使用树数据结构时,您可能需要一种获取节点值的方法。您可以使用GetEnumerable
方法来实现这个目标,如下所示:
Console.WriteLine(
"Values: " + string.Join(", ", tree.GetEnumerable()));
在树中遍历节点的另一种方法涉及foreach
循环,如以下代码片段所示:
Console.Write("Values: ");
foreach (EntryList<int> node in tree)
{
Console.Write(node + " ");
}
正如您所看到的,使用TreeLib
库非常简单,您可以在几分钟内将其添加到您的应用程序中。但是,在启动程序后控制台中显示的结果是什么?让我们看看:
Does value exist? yes
9 elements in the range 1-10
Values: 1, 2, 3, 4, 5, 6, 7, 8, 10
Values: 1 2 3 4 5 6 7 8 10
值得注意的是,TreeLib
并不是唯一支持 RBT 的软件包,因此最好看看各种解决方案,并选择最适合您需求的软件包。
您已经到达关于自平衡二叉搜索树部分的章节的末尾。现在,让我们继续进行与堆相关的最后一部分。它们是什么,为什么它们位于树的章节中?您很快就会得到这些问题的答案以及许多其他问题的答案!
二叉堆
堆是树的另一种变体,存在两个版本:最小堆和最大堆。对于它们中的每一个,必须满足一个额外的属性:
-
对于最小堆:每个节点的值必须大于或等于其父节点的值
-
对于最大堆:每个节点的值必须小于或等于其父节点的值
这些规则起着非常重要的作用,因为它们规定了根节点始终包含最小值(在最小堆中)或最大值(在最大堆中)。因此,它是实现优先队列的便捷数据结构,详见第三章 栈和队列。
堆有许多变体,包括二叉堆,这是本节的主题。在这种情况下,堆必须符合先前提到的规则之一(取决于种类:最小堆或最大堆),并且必须遵守完全二叉树规则,该规则要求每个节点不能包含超过两个子节点,以及树的所有层都必须是完全填充的,除了最后一层,该层必须从左到右填充,并且右侧可能有一些空间。
让我们来看一下以下两个二叉堆:
您可以轻松检查两个堆是否遵守所有规则。例如,让我们验证最小堆变体(左侧显示)中值等于20的节点的堆属性。该节点有两个子节点,值分别为35和50,均大于20。同样,您可以检查堆中的其余节点。二叉树规则也得到了遵守,因为每个节点最多包含两个子节点。最后一个要求是树的每一层都是完全填充的,除了最后一层不需要完全填充,但必须从左到右包含节点。在最小堆示例中,有三个层是完全填充的(分别有一个、两个和四个节点),而最后一层包含两个节点(25和70),位于最左边的两个位置。同样,您可以确认右侧显示的最大堆是否正确配置。
在这个关于堆的简短介绍,特别是关于二叉堆的介绍中,值得一提的是其广泛的应用范围。正如前面提到的,这种数据结构是实现优先队列的便捷方式,可以插入新值并移除最小值(在最小堆中)或最大值(在最大堆中)。此外,堆还用于堆排序算法,该算法将在接下来的示例中进行描述。该数据结构还有许多其他应用,例如在图算法中。
您可以在en.wikipedia.org/wiki/Binary_heap
找到有关二叉堆的更多信息。
您准备好看堆的实现了吗?如果是的话,让我们继续到下一节,介绍支持堆的可用库之一。
实现
二叉堆可以从头开始实现,也可以使用一些已有的实现。其中一个解决方案名为Hippie
,可以通过 NuGet 软件包管理器安装到项目中。该库包含了堆的几个变体的实现,包括二叉堆、二项式堆和斐波那契堆,这些都在本书的本章中进行了介绍和描述。
您可以在github.com/pomma89/Hippie
和www.nuget.org/packages/Hippie
找到有关该库的更多信息。
该库包含了一些类,比如通用类MultiHeap
,它适用于各种堆的变体,包括二叉堆。但是,如果同一个类用于二叉堆、二项式堆和斐波那契堆,那么您如何选择要使用哪种类型的堆呢?您可以使用HeapFactory
类的静态方法来解决这个问题。例如,可以使用NewBinaryHeap
方法创建二叉堆,如下所示:
MultiHeap<int> heap = HeapFactory.NewBinaryHeap<int>();
MultiHeap
类配备了一些属性,例如用于获取堆中元素总数的Count
和用于检索最小值的Min
。此外,可用的方法允许添加新元素(Add
),删除特定项(Remove
),删除最小值(RemoveMin
),删除所有元素(Clear
),检查给定值是否存在于堆中(Contains
)以及合并两个堆(Merge
)。
示例-堆排序
作为使用Hippie
库实现的二进制堆的示例,堆排序算法如下所示。应该将基于 C#的实现添加到Program
类中的Main
方法中,如下所示:
List<int> unsorted = new List<int>() { 50, 33, 78, -23, 90, 41 };
MultiHeap<int> heap = HeapFactory.NewBinaryHeap<int>();
unsorted.ForEach(i => heap.Add(i));
Console.WriteLine("Unsorted: " + string.Join(", ", unsorted));
List<int> sorted = new List<int>(heap.Count);
while (heap.Count > 0)
{
sorted.Add(heap.RemoveMin());
}
Console.WriteLine("Sorted: " + string.Join(", ", sorted));
正如您所看到的,实现非常简单和简短。首先,您创建一个包含未排序整数值的列表作为算法的输入。然后,准备一个新的二进制堆,并将每个输入值添加到堆中。在这个阶段,从输入列表中的元素写入控制台。
在代码的下一部分中,创建了一个新列表。它将包含排序后的值,因此它将包含算法的结果。然后,使用while
循环在每次迭代中从堆中删除最小值。循环执行,直到堆中没有元素为止。最后,在控制台中显示排序后的列表。
堆排序算法的时间复杂度为O(n * log(n))。
当构建项目并运行应用程序时,您将看到以下结果:
Unsorted: 50, 33, 78, -23, 90, 41
Sorted: -23, 33, 41, 50, 78, 90
如前所述,二进制堆并不是堆的唯一变体。除其他外,二项堆是非常有趣的方法之一,这是下一节的主题。
二项堆
另一种堆是二项堆。这种数据结构由一组具有不同顺序的二项树组成。顺序为0的二项树只是一个单个节点。您可以使用两个顺序为n-1的二项树构造顺序为n的树。其中一个应该作为第一个树的父节点的最左子节点附加。听起来有点复杂,但以下图表应该消除任何困惑:
如前所述,顺序为0的二项树只是一个单个节点,如左侧所示。顺序为1的树由两个顺序为0的树(用虚线边框标记)连接在一起。在顺序为2的树的情况下,使用两个顺序为1的树。第二个作为第一个树的父节点的最左子节点附加。以同样的方式,您可以配置具有以下顺序的二项树。
然而,您如何知道二项堆中应该放置多少个二项树,以及它们应该包含多少个节点?答案可能有点令人惊讶,因为您需要准备节点数的二进制表示。例如,让我们创建一个包含13个元素的二项堆。数字13的二进制表示如下:1101,即12⁰ + 02¹ + 12² + 12³。
需要获取集合位的基于零的位置,即在这个例子中的0,2和3。这些位置表示应该配置的二项树的顺序:
此外,在二项堆中不能有两个具有相同顺序(例如两个顺序为2的树)。还值得注意的是,每个二项树必须保持最小堆属性。
您可以在en.wikipedia.org/wiki/Binomial_heap
找到有关二项堆的更多信息。
二项堆的实现比二进制堆复杂得多。因此,最好使用现有的实现之一,而不是从头开始编写自己的实现。正如在二进制堆的情况下所述,Hippie
库是一个支持各种堆变体的解决方案,包括二项堆。
可能会让人惊讶,但与二进制堆的示例相比,代码中唯一的区别是在创建MultiHeap
类的新实例的那一行进行了修改。为了支持二项堆,你需要使用HeapFactory
类中的NewBinomialHeap
方法,如下所示:
MultiHeap<int> heap = HeapFactory.NewBinomialHeap<int>();
不需要进行更多的更改!现在你可以执行剩下的操作,比如插入或删除元素,方式与二进制堆的情况完全相同。
你已经了解了两种堆,即二进制堆和二项堆。在接下来的部分中,将简要介绍斐波那契堆。
斐波那契堆
斐波那契堆是堆的一个有趣的变体,某些方面类似于二项堆。首先,它也由许多树组成,但对于每棵树的形状没有约束,因此比二项堆灵活得多。此外,堆中允许有多棵具有完全相同形状的树。
斐波那契堆的一个示例如下:
其中一个重要的假设是每棵树都是最小堆。因此,整个斐波那契堆中的最小值肯定是其中一棵树的根节点。此外,所呈现的数据结构支持以“懒惰”的方式执行各种操作。这意味着除非真正必要,否则不执行额外的复杂操作。例如,它可以将一个新节点添加为只有一个节点的新树。
你可以在en.wikipedia.org/wiki/Fibonacci_heap
找到更多关于斐波那契堆的信息。
与二项堆类似,斐波那契堆的实现也不是一项简单的任务,需要对这种数据结构的内部细节有很好的理解。因此,如果你需要在你的应用程序中使用斐波那契堆,最好使用现有的实现之一,而不是从头开始编写自己的实现。正如之前所述,Hippie
库是一个支持许多堆变体的解决方案,包括斐波那契堆。
值得一提的是,与二进制和二项堆相比,代码中唯一的区别是在创建MultiHeap
类的新实例的那一行进行了修改。为了支持斐波那契堆,你需要使用HeapFactory
类中的NewFibonacciHeap
方法,如下所示:
MultiHeap<int> heap = HeapFactory.NewFibonacciHeap<int>();
就是这样!你刚刚读了一个关于斐波那契堆的简要介绍,作为堆的另一种变体,因此也是树的另一种类型。这是本章的最后一个主题,所以是时候进行总结了。
总结
当前章节是本书迄今为止最长的章节。然而,它包含了许多关于树变体的信息。这些数据结构在许多算法中扮演着非常重要的角色,了解更多关于它们的知识以及如何在你的应用程序中使用它们是很有益的。因此,本章不仅包含简短的理论介绍,还包括图表、解释和代码示例。
一开始描述了树的概念。作为提醒,树由节点组成,包括一个根。根节点不包含父节点,而所有其他节点都包含。每个节点可以有任意数量的子节点。同一节点的子节点可以被称为兄弟节点,而没有子节点的节点被称为叶子节点。
树的各种变体都遵循这种结构。章节中描述的第一种是二叉树。在这种情况下,一个节点最多可以包含两个子节点。然而,BST 的规则更加严格。对于这种树中的任何节点,其左子树中所有节点的值必须小于节点的值,而右子树中所有节点的值必须大于节点的值。BST 具有非常广泛的应用,并且可以显著提高查找性能。不幸的是,很容易在向树中添加排序值时使树失衡。因此,性能的积极影响可能会受到限制。
为了解决这个问题,可以使用某种自平衡树,它在添加或删除节点时始终保持平衡。在本章中,介绍了两种自平衡树的变体:AVL 树和 RBT。第一种类型有额外的要求,即对于每个节点,其左右子树的高度不能相差超过一。RBT 稍微复杂一些,因为它引入了将节点着色为红色或黑色的概念,以及 NIL 伪节点。此外,要求如果一个节点是红色,那么它的两个子节点必须是黑色,并且对于任何节点,到后代叶子的路径上的黑色节点数量必须相同。正如您在分析这些数据结构时所看到的,它们的实现要困难得多。因此,本章还介绍了可通过 NuGet 软件包管理器下载的额外库。
本章剩下的部分与堆有关。作为提醒,堆是树的另一种变体,有两个版本,最小堆和最大堆。值得注意的是,每个节点的值必须大于或等于(对于最小堆)或小于或等于(对于最大堆)其父节点的值。堆存在许多变体,包括二叉堆、二项式堆和斐波那契堆。本章简要介绍了所有这些类型,以及关于使用来自 NuGet 软件包之一的实现的信息。
让我们继续讨论下一章的主题——图!
第六章:探索图
在上一章中,您了解了树。但是,您知道这样的数据结构也属于图吗?但图是什么,以及您如何在应用程序中使用它?您可以在本章中找到这些问题的答案以及许多其他问题的答案!
在开始时,将介绍有关图的基本信息,包括节点和边的解释。此外,您将看到有向和无向边之间的区别,以及加权和非加权边之间的区别。由于图是常用的数据结构,您还将看到一些应用,例如在社交媒体中存储朋友的数据或在城市中寻找道路。然后,将介绍图的表示主题,即使用邻接表和矩阵。
在这个简短的介绍之后,您将学习如何在 C#语言中实现图。这项任务涉及到声明一些类,例如节点和边。整个必要的代码将在本章中详细描述。
此外,您还将有机会阅读两种图遍历模式的描述,即深度优先和广度优先搜索。对于两者,将显示 C#代码和详细描述。
下一部分将介绍最小生成树的主题,以及用于创建它们的两种算法,即 Kruskal 和 Prim。这些算法将以文本描述、基于 C#的代码片段以及易于理解的插图的形式呈现。此外,还将提供一个实际的示例应用程序。
另一个有趣的与图相关的问题是节点的着色,这将在本章的后面部分中考虑。最后,将使用 Dijkstra 算法分析在图中找到最短路径的主题。当然,还将展示一个实际的示例应用程序,以及基于 C#的实现。
正如您所看到的,图的主题涉及许多有趣的问题,本书只提到了其中一些。但是,所选择的主题适合在基于 C#的实现的背景下呈现各种与图相关的方面。您准备好深入研究图的主题了吗?如果是这样,请开始阅读本章!
在本章中,将涵盖以下主题:
-
图的概念
-
应用
-
表示
-
实施
-
遍历
-
最小生成树
-
着色
-
最短路径
图的概念
让我们从问题“什么是图?”开始。广义上说,图是由节点(也称为顶点)和边组成的数据结构。每条边连接两个节点。图数据结构不需要关于节点之间连接的任何特定规则,如下图所示:
前面提到的概念似乎非常简单,不是吗?让我们尝试分析前面的图以消除任何疑虑。它包含九个节点,值在 1 和 9 之间。这些节点由 11 条边连接,例如节点 2 和 4 之间。此外,图可以包含循环,例如由节点 2、3 和 4 表示的循环,以及未连接在一起的单独节点组。但是,关于父节点和子节点的主题呢?这是您从树的学习中了解的。由于图中没有关于连接的特定规则,因此在这种情况下不使用这些概念。
图还可以包含自环。每个自环都是连接给定节点与自身的边。但是,这样的主题超出了本书的范围,并且在本章中显示的示例中没有考虑。
对于图中的边,还需要一些额外的说明。在前面的图中,你可以看到一个所有节点都通过无向边连接的图,也就是双向边。它们表示可以在两个方向之间旅行,例如,从节点2到3,从节点3到2。这些边在图形上呈现为直线。当一个图包含无向边时,它是一个无向图。
然而,当你需要表明节点之间的旅行只能单向进行时怎么办?在这种情况下,你可以使用有向边,也就是单向边,在图形上呈现为带箭头的直线,箭头表示边的方向。如果一个图包含有向边,它可以被称为有向图。
一个有向图的例子在右侧的下图中展示,而一个无向图在左侧展示:
简单解释一下,在前面图中右侧的有向图包含了 8 个节点,通过 15 条单向边连接。例如,它们表示可以在节点1和2之间双向旅行,但是只能从节点1到3单向旅行,所以无法直接从3到1。
无向和有向边之间的区分并不是唯一的。你还可以为特定的边指定权重(也称为成本),以表示节点之间旅行的成本。当然,这样的权重可以分配给无向和有向边。如果提供了权重,边被称为加权边,整个图被称为加权图。同样,如果没有提供权重,图中使用无权重边,可以称为无权重图。
下图展示了带有无向(左侧)和有向(右侧)边的例子加权图:
加权边的图形表示只是在线旁边添加了边的权重。例如,在前面图中左侧的无向图中,从节点1到2的旅行成本,以及从节点2到1的成本都等于3。在有向图的情况下(右侧),情况稍微复杂一些。在这里,你可以从节点1到2旅行的成本等于9,而在相反方向(从节点2到1)旅行的成本要便宜得多,只有3。
应用
简短介绍之后,你已经了解了一些关于图的基本信息,特别是关于节点和各种边的信息。但是,为什么图的主题如此重要,为什么它在这本书中占据了一个完整的章节?你的应用程序可以使用这种数据结构吗?答案是显而易见的:可以!在解决各种算法问题和有许多现实世界应用中,图通常被使用。下面的图表中展示了两个例子。
首先,让我们考虑社交媒体中可用的朋友结构。每个用户都有许多联系人,但他们也有许多朋友,等等。你应该选择什么数据结构来存储这样的数据?图是最简单的答案之一。在这种情况下,节点代表联系人,而边表示人与人之间的关系。例如,让我们看一个无向且无权重图的下图:
正如你所看到的,Jimmy Stewart有五个联系人,分别是John Smith,Andy Wood,Eric Green,Ashley Lopez和Paula Scott。与此同时,Paula Scott还有另外两个朋友:Marcin Jamro和Tommy Butler。通过使用图作为数据结构,你可以轻松地检查两个人是否是朋友,或者他们是否有共同的联系人。
图的另一个常见应用涉及搜索最短路径的问题。想象一下一个程序,它应该找到城市中两点之间的路径,考虑到行驶特定道路所需的时间。在这种情况下,你可以使用图来表示城市的地图,其中节点表示交叉路口,边表示道路。当然,你应该为边分配权重,以指示行驶给定道路所需的时间。搜索最短路径的主题可以理解为找到从源节点到目标节点的边的列表,其总成本最小。基于图的城市地图的图表如下所示:
正如你所看到的,选择了有向加权图。有向边的应用使得支持双向和单向道路成为可能,而加权边允许指定在两个交叉路口之间行驶所需的时间。
表示
现在你知道了图是什么,以及它何时可以使用,但是你如何在计算机的内存中表示它呢?解决这个问题有两种流行的方法,即使用邻接表和邻接矩阵。这两种方法将在接下来的部分中详细描述。
邻接表
第一种方法要求你通过指定其邻居的列表来扩展一个节点的数据。因此,你可以通过迭代给定节点的邻接表来轻松获取给定节点的所有邻居。这样的解决方案是空间高效的,因为你只存储相邻边的数据。让我们看一下下面的图表:
示例图包含 8 个节点和 10 条边。对于每个节点,创建了一个相邻节点(即邻居)的列表,如图表右侧所示。例如,节点1有两个邻居,即节点2和3,而节点5有四个邻居,即节点4,6,7和8。正如你所看到的,基于邻接表的无向无权图的表示非常直接,易于使用、理解和实现。
然而,在有向图的情况下,邻接表是如何工作的呢?答案是显而易见的,因为分配给每个节点的列表只显示可以从给定节点到达的相邻节点。示例图如下所示:
让我们看一下节点3。在这里,邻接表只包含一个元素,即节点4。节点1没有包括在内,因为它不能直接从节点3到达。
在加权图的情况下可能需要更多的解释。在这种情况下,还需要存储特定边的权重。你可以通过扩展邻接表中存储的数据来实现这个目标,如下图所示:
节点7的邻接表包含两个元素,即指向节点5的边(权重为4)和指向节点8的边(权重为6)。
邻接矩阵
图的另一种表示方法涉及邻接矩阵,它使用二维数组来显示哪些节点通过边相连。矩阵包含相同数量的行和列,即节点的数量。主要思想是在矩阵的给定行和列中存储关于特定边的信息。行和列的索引取决于与边相连的节点。例如,如果你想获取索引为 1 和 5 的节点之间边的信息,你应该检查矩阵中索引为 1 的行和索引为 5 的列的元素。
这样的解决方案可以快速检查两个特定节点是否通过边相连。然而,它可能需要存储的数据比邻接表要多得多,特别是如果图中节点之间的边不多的话。
首先,让我们分析无向无权图的基本情况。在这种情况下,邻接矩阵可能只存储布尔值。在第i
行和第j
列的元素中放置的true
值表示索引为i
的节点和索引为j
的节点之间存在连接。如果听起来很复杂,看看下面的例子:
在这里,邻接矩阵包含 64 个元素(八行八列),因为图中有八个节点。数组中许多元素的值设置为false
,表示缺失指示。其余的用叉号表示true
值。例如,在第四行和第三列的元素中的这样一个值表示图中节点4和3之间有一条边,如前面图中所示。
由于所呈现的图是无向的,邻接矩阵是对称的。如果节点i
和j
之间有一条边,那么节点j
和i
之间也有一条边。
下一个例子涉及有向无权图。在这种情况下,可以使用相同的规则,但邻接矩阵不需要对称。让我们看一下下图所示的图和邻接矩阵:
在所示的邻接矩阵中,你可以找到 15 条边的数据,用 15 个带有true
值的元素表示,矩阵中用叉号表示。例如,从节点5到4的单向边在矩阵的第五行和第四列处用叉号表示。
在前面的两个例子中,你已经学会了如何使用邻接矩阵来表示无权图。然而,你如何存储加权图的数据,无论是无向还是有向的?答案很简单——你只需要将邻接矩阵中特定元素存储的数据类型从布尔值改为数字。因此,你可以指定边的权重,如下图所示:
前面的图和邻接矩阵都是不言自明的。然而,为了消除任何疑惑,让我们看一下节点5和6之间权重为2的边。这样的边由矩阵中第五行和第六列的元素表示。元素的值等于这些节点之间的旅行成本。
实现
你已经了解了一些关于图的基本信息,包括节点、边以及邻接表和邻接矩阵两种表示方法。然而,你如何在应用程序中使用这样的数据结构呢?在本节中,你将学习如何使用 C#语言实现图。为了让你更容易理解所呈现的内容,提供了两个例子。
节点
首先,让我们来看一下表示图中单个节点的泛型类的代码。这样的类名为Node
,其代码如下所示:
public class Node<T>
{
public int Index { get; set; }
public T Data { get; set; }
public List<Node<T>> Neighbors { get; set; }
= new List<Node<T>>();
public List<int> Weights { get; set; } = new List<int>();
public override string ToString()
{
return $"Node with index {Index}: {Data},
neighbors: {Neighbors.Count}";
}
}
该类包含四个属性。由于所有这些元素在本章中显示的代码片段中都扮演着重要角色,让我们详细分析它们:
-
第一个属性(
Index
)存储了图中特定节点在节点集合中的索引,以简化访问特定元素的过程。因此,可以通过使用索引轻松获取表示特定节点的Node
类的实例。 -
下一个属性名为
Data
,只是在节点中存储一些数据。值得一提的是,此类数据的类型与创建泛型类实例时指定的类型一致。 -
Neighbors
属性表示特定节点的邻接表。因此,它包含对表示相邻节点的Node
实例的引用。 -
最后一个属性名为
Weights
,用于存储分配给相邻边的权重。在加权图的情况下,Weights
列表中的元素数量与相邻节点(Neighbors
)的数量相同。如果图是无权的,则Weights
列表为空。
除了属性之外,该类还包含重写的ToString
方法,该方法返回对象的文本表示。在这里,以格式"Node with index [index]: [data], neighbors: [count]"
返回字符串。
边
如在关于图的简短介绍中提到的,图由节点和边组成。由于节点由Node
类的实例表示,因此Edge
泛型类可用于表示边。代码的适当部分如下所示:
public class Edge<T>
{
public Node<T> From { get; set; }
public Node<T> To { get; set; }
public int Weight { get; set; }
public override string ToString()
{
return $"Edge: {From.Data} -> {To.Data},
weight: {Weight}";
}
}
该类包含三个属性,分别表示与边相邻的节点(From
和To
),以及边的权重(Weight
)。此外,ToString
方法被重写以呈现有关边的一些基本信息。
图
下一个类名为Graph
,表示整个图,具有有向或无向边,以及加权或无权边。实现包括各种字段和方法,如下所述。
让我们来看一下Graph
类的基本版本:
public class Graph<T>
{
private bool _isDirected = false;
private bool _isWeighted = false;
public List<Node<T>> Nodes { get; set; }
= new List<Node<T>>();
}
该类包含两个字段,指示边是否有向(_isDirected
)和加权(_isWeighted
)。此外,声明了Nodes
属性,该属性存储图中存在的节点列表。
该类还包含以下构造函数:
public Graph(bool isDirected, bool isWeighted)
{
_isDirected = isDirected;
_isWeighted = isWeighted;
}
在这里,根据传递给构造函数的参数的值,只设置了_isDirected
和_isWeighted
私有字段的值。
Graph
类的下一个有趣成员是索引器,它接受两个索引,即两个节点的索引,以返回表示这些节点之间边的Edge
泛型类的实例。实现如下代码片段所示:
public Edge<T> this[int from, int to]
{
get
{
Node<T> nodeFrom = Nodes[from];
Node<T> nodeTo = Nodes[to];
int i = nodeFrom.Neighbors.IndexOf(nodeTo);
if (i >= 0)
{
Edge<T> edge = new Edge<T>()
{
From = nodeFrom,
To = nodeTo,
Weight = i < nodeFrom.Weights.Count
? nodeFrom.Weights[i] : 0
};
return edge;
}
return null;
}
}
在索引器中,根据索引获取表示两个节点(nodeFrom
和nodeTo
)的Node
类的实例。如果要找到从第一个节点(nodeFrom
)到第二个节点(nodeTo
)的边,需要尝试在第一个节点的相邻节点集合中找到第二个节点,使用IndexOf
方法。如果这样的连接不存在,IndexOf
方法将返回负值,并且索引器将返回null
。否则,创建Edge
类的新实例,并设置其属性的值,包括From
和To
。如果提供了关于特定边权重的数据,则还设置Edge
类的Weight
属性的值。
现在您知道如何存储图中节点的数据,但是如何添加新节点呢?为此,实现了AddNode
方法,如下所示:
public Node<T> AddNode(T value)
{
Node<T> node = new Node<T>() { Data = value };
Nodes.Add(node);
UpdateIndices();
return node;
}
在此方法中,您创建Node
类的新实例,并根据参数的值设置Data
属性的值。 然后,新创建的实例被添加到Nodes
集合中,并调用UpdateIndices
方法(稍后描述)来更新集合中存储的所有节点的索引。 最后,返回表示新添加节点的Node
实例。
您也可以移除现有节点。 这个操作是由RemoveNode
方法执行的,如下面的代码片段所示:
public void RemoveNode(Node<T> nodeToRemove)
{
Nodes.Remove(nodeToRemove);
UpdateIndices();
foreach (Node<T> node in Nodes)
{
RemoveEdge(node, nodeToRemove);
}
}
该方法接受一个参数,即应该被移除的节点的实例。 首先,您将其从节点集合中移除。 然后,您更新剩余节点的索引。 最后,您遍历图中的所有节点,以删除与已删除节点相连的所有边。
正如您已经知道的,图由节点和边组成。 因此,Graph
类的实现应该为开发人员提供添加新边的方法。 当然,它应该支持各种边的变体,无论是有向的,无向的,加权的,还是无权重的。 提议的实现如下所示:
public void AddEdge(Node<T> from, Node<T> to, int weight = 0)
{
from.Neighbors.Add(to);
if (_isWeighted)
{
from.Weights.Add(weight);
}
if (!_isDirected)
{
to.Neighbors.Add(from);
if (_isWeighted)
{
to.Weights.Add(weight);
}
}
}
AddEdge
方法接受三个参数,即表示由边连接的两个Node
类实例(from
和to
),以及连接的权重(weight
),默认设置为0
。
在方法内的第一行,您将表示第二个节点的Node
实例添加到第一个节点的邻居节点列表中。 如果考虑加权图,上述边的权重也将被添加。
代码的以下部分仅在图是无向的情况下考虑。 在这种情况下,您需要自动在相反方向上添加一条边。 为此,您将表示第一个节点的Node
实例添加到第二个节点的邻居节点列表中。 如果边是加权的,那么上述边的权重也将添加到Weights
列表中。
从图中删除边的过程由RemoveEdge
方法支持。 代码如下:
public void RemoveEdge(Node<T> from, Node<T> to)
{
int index = from.Neighbors.FindIndex(n => n == to);
if (index >= 0)
{
from.Neighbors.RemoveAt(index);
if (_isWeighted)
{
from.Weights.RemoveAt(index);
}
}
}
该方法接受两个参数,即两个节点(from
和to
),它们之间有一条应该被移除的边。 首先,您尝试在第一个节点的邻居节点列表中找到第二个节点。 如果找到了,您将其移除。 当然,如果考虑加权图,您还应该移除权重数据。
最后一个公共方法名为GetEdges
,它可以获取图中所有可用边的集合。 提议的实现如下:
public List<Edge<T>> GetEdges()
{
List<Edge<T>> edges = new List<Edge<T>>();
foreach (Node<T> from in Nodes)
{
for (int i = 0; i < from.Neighbors.Count; i++)
{
Edge<T> edge = new Edge<T>()
{
From = from,
To = from.Neighbors[i],
Weight = i < from.Weights.Count
? from.Weights[i] : 0
};
edges.Add(edge);
}
}
return edges;
}
首先,初始化一个新的边列表。 然后,您使用foreach
循环遍历图中的所有节点。 在其中,您使用for
循环创建Edge
类的实例。 实例的数量应该等于当前节点(foreach
循环中的from
变量)的邻居节点的数量。 在for
循环中,通过设置其属性的值来配置Edge
类的新创建实例,即第一个节点(from
变量,即foreach
循环中的当前节点),第二个节点(当前分析的邻居节点),以及权重。 然后,新创建的实例被添加到边的集合中,由edges
变量表示。 最后,返回结果。
在各种方法中,您使用UpdateIndices
方法。 代码如下:
private void UpdateIndices()
{
int i = 0;
Nodes.ForEach(n => n.Index = i++);
}
该方法只是遍历图中的所有节点,并更新Index
属性的值为连续的数字,从0
开始。 值得注意的是,迭代是使用ForEach
方法执行的,而不是foreach
或for
循环。
现在您知道如何创建图的基本实现。 下一步是将其应用于表示一些示例图,如下面的两个部分所示。
示例-无向且无权重的边
让我们尝试使用先前的实现来创建无向和无权图,如下图所示:
如您所见,图包含 8 个节点和 10 条边。您可以在Program
类的Main
方法中配置示例图。实现始于以下代码行,该行初始化了一个新的无向图(第一个参数的值为false
)和一个无权图(第二个参数的值为false
):
Graph<int> graph = new Graph<int>(false, false);
然后,您添加必要的节点,并将它们存储为Node<int>
类型的新变量,如下所示:
Node<int> n1 = graph.AddNode(1);
Node<int> n2 = graph.AddNode(2);
Node<int> n3 = graph.AddNode(3);
Node<int> n4 = graph.AddNode(4);
Node<int> n5 = graph.AddNode(5);
Node<int> n6 = graph.AddNode(6);
Node<int> n7 = graph.AddNode(7);
Node<int> n8 = graph.AddNode(8);
最后,您只需要根据图的先前图表在节点之间添加边。必要的代码如下所示:
graph.AddEdge(n1, n2);
graph.AddEdge(n1, n3);
graph.AddEdge(n2, n4);
graph.AddEdge(n3, n4);
graph.AddEdge(n4, n5);
graph.AddEdge(n5, n6);
graph.AddEdge(n5, n7);
graph.AddEdge(n5, n8);
graph.AddEdge(n6, n7);
graph.AddEdge(n7, n8);
就是这样!如您所见,使用这种数据结构的建议实现非常容易配置图。现在,让我们继续进行一个稍微复杂一点的有向和加权边的场景。
示例 - 有向和加权边
下一个示例涉及有向加权图,如下所示:
该实现与前一节中描述的实现非常相似。但是,需要进行一些修改。首先,构造函数的参数值不同,即使用true
而不是false
来指示正在考虑有向和加权的边。适当的代码行如下:
Graph<int> graph = new Graph<int>(true, true);
关于添加节点的部分与前面的例子完全相同:
Node<int> n1 = graph.AddNode(1);
Node<int> n2 = graph.AddNode(2);
Node<int> n3 = graph.AddNode(3);
Node<int> n4 = graph.AddNode(4);
Node<int> n5 = graph.AddNode(5);
Node<int> n6 = graph.AddNode(6);
Node<int> n7 = graph.AddNode(7);
Node<int> n8 = graph.AddNode(8);
在关于添加边的代码行中,一些更改很容易看到。在这里,您指定了带有它们权重的有向边,如下所示:
graph.AddEdge(n1, n2, 9);
graph.AddEdge(n1, n3, 5);
graph.AddEdge(n2, n1, 3);
graph.AddEdge(n2, n4, 18);
graph.AddEdge(n3, n4, 12);
graph.AddEdge(n4, n2, 2);
graph.AddEdge(n4, n8, 8);
graph.AddEdge(n5, n4, 9);
graph.AddEdge(n5, n6, 2);
graph.AddEdge(n5, n7, 5);
graph.AddEdge(n5, n8, 3);
graph.AddEdge(n6, n7, 1);
graph.AddEdge(n7, n5, 4);
graph.AddEdge(n7, n8, 6);
graph.AddEdge(n8, n5, 3);
您刚刚完成了图的基本实现,分别在两个示例中展示。因此,让我们继续另一个主题,即遍历图。
遍历
图上执行的一个有用操作是其遍历,即以某种特定顺序访问所有节点。当然,前面提到的问题可以以各种方式解决,例如使用深度优先搜索(DFS)或广度优先搜索(BFS)方法。值得一提的是,遍历主题与在图中搜索给定节点的任务密切相关。
深度优先搜索
本章描述的第一个图遍历算法称为 DFS。在示例图的上下文中,其步骤如下:
当然,仅仅通过查看前面的图表就很难理解 DFS 算法是如何操作的。因此,让我们尝试分析其各个阶段。
在第一步中,您可以看到具有八个节点的图。节点1用灰色背景标记(表示该节点已被访问),并用红色边框标记(表示当前正在访问的节点)。此外,算法中的邻居节点(以虚线边框显示为圆圈)起着重要作用。当您了解特定指示器的作用时,很明显,在第一步中访问了节点1。它有两个邻居(节点2和3)。
然后,首个邻居(节点2)被考虑,并执行相同的操作,即访问节点并分析邻居(节点1和4)。由于节点1已经被访问过,所以它被跳过。在下一步(标为步骤#3)中,节点2的第一个合适的邻居被考虑——节点4。它有两个邻居,即节点2(已经被访问)和8。接下来,节点8被访问(步骤#4),根据相同的规则,访问节点5(步骤#5)。它有四个邻居,即节点4(已经被访问)、6、7和8(已经被访问)。因此,在下一步中,节点6被考虑(步骤#6)。由于它只有一个邻居(节点7),所以下一个被访问的是它(步骤#7)。
然后,您检查节点7的邻居,即节点5和8。两者都已经被访问过,所以您返回到具有未访问邻居的节点。在这个例子中,节点1有一个未访问的节点,即节点3。当它被访问(步骤#8)时,所有节点都被遍历,不需要进行进一步的操作。
给定这个例子,让我们尝试在 C#语言中创建实现。首先,DFS
方法的代码(在Graph
类中)如下所示:
public List<Node<T>> DFS()
{
bool[] isVisited = new bool[Nodes.Count];
List<Node<T>> result = new List<Node<T>>();
DFS(isVisited, Nodes[0], result);
return result;
}
isVisited
数组发挥了重要作用。它的元素数量与节点数量相同,并存储指示给定节点是否已经被访问的值。如果是,就存储true
值,否则存储false
。遍历节点的列表以result
变量的形式表示。此外,这里调用了DFS
方法的另一个变体,传递了三个参数,即对isVisited
数组的引用、要分析的第一个节点,以及用于存储结果的列表。
上述DFS
方法的代码如下所示:
private void DFS(bool[] isVisited, Node<T> node,
List<Node<T>> result)
{
result.Add(node);
isVisited[node.Index] = true;
foreach (Node<T> neighbor in node.Neighbors)
{
if (!isVisited[neighbor.Index])
{
DFS(isVisited, neighbor, result);
}
}
}
所示的实现非常简单。在开始时,当前节点被添加到遍历节点的集合中,并更新isVisited
数组中的元素。然后,您使用foreach
循环来遍历当前节点的所有邻居。对于每一个邻居,如果它还没有被访问过,就会递归调用DFS
方法。
您可以在en.wikipedia.org/wiki/Depth-first_search
找到有关 DFS 的更多信息。
最后,让我们看一下可以放在Program
类的Main
方法中的代码。其主要部分如下代码片段所示:
Graph<int> graph = new Graph<int>(true, true);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2, 9); (...)
graph.AddEdge(n8, n5, 3);
List<Node<int>> dfsNodes = graph.DFS();
dfsNodes.ForEach(n => Console.WriteLine(n));
在这里,您初始化了一个有向加权图。要开始遍历图,您只需要调用DFS
方法,它会返回一个Node
实例的列表。然后,您可以轻松地遍历列表中的元素,打印每个节点的一些基本信息。结果如下所示:
Node with index 0: 1, neighbors: 2
Node with index 1: 2, neighbors: 2
Node with index 3: 4, neighbors: 2
Node with index 7: 8, neighbors: 1
Node with index 4: 5, neighbors: 4
Node with index 5: 6, neighbors: 1
Node with index 6: 7, neighbors: 2
Node with index 2: 3, neighbors: 1
就是这样!正如您所看到的,该算法试图尽可能深入,然后返回以找到下一个可以遍历的未访问邻居。然而,所呈现的算法并不是解决图遍历问题的唯一方法。在下一部分,您将看到另一种方法,以及一个基本示例和其实现。
广度优先搜索
在前面的部分,您学习了 DFS 方法。现在您将看到另一种解决方案,即 BFS。它的主要目的是首先访问当前节点的所有邻居,然后继续到下一级节点。
如果前面的描述听起来有点复杂,请看一下这个图表,它描述了 BFS 算法的步骤:
该算法从访问节点1(步骤#1)开始。它有两个邻居,即节点2和3,接下来访问它们(步骤#2和步骤#3)。由于节点1没有更多的邻居,因此考虑其第一个邻居(节点2)的邻居。由于它只有一个邻居(节点4),它在下一步被访问。按照相同的方法,剩下的节点按照这个顺序被访问:8,5,6,7。
听起来很简单,不是吗?让我们看一下实现:
public List<Node<T>> BFS()
{
return BFS(Nodes[0]);
}
BFS
公共方法被添加到Graph
类中,仅用于启动图的遍历。它调用私有的BFS
方法,将第一个节点作为参数传递。其代码如下所示:
private List<Node<T>> BFS(Node<T> node)
{
bool[] isVisited = new bool[Nodes.Count];
isVisited[node.Index] = true;
List<Node<T>> result = new List<Node<T>>();
Queue<Node<T>> queue = new Queue<Node<T>>();
queue.Enqueue(node);
while (queue.Count > 0)
{
Node<T> next = queue.Dequeue();
result.Add(next);
foreach (Node<T> neighbor in next.Neighbors)
{
if (!isVisited[neighbor.Index])
{
isVisited[neighbor.Index] = true;
queue.Enqueue(neighbor);
}
}
}
return result;
}
代码中的重要角色由isVisited
数组发挥,它存储布尔值,指示特定节点是否已经被访问。这样的数组在BFS
方法开始时被初始化,与当前节点相关的元素的值被设置为true
,表示该节点已被访问。
然后,创建用于存储遍历节点的列表(result
)和用于存储应在以下迭代中访问的节点的队列(queue
)。就在初始化队列之后,当前节点被添加到队列中。
直到队列为空为止,执行以下操作:从队列中获取第一个节点(next
变量),将其添加到已访问节点的集合中,并迭代当前节点的邻居。对于每个邻居,检查它是否已经被访问。如果没有,通过在isVisited
数组中设置适当的值来标记为已访问,并将邻居添加到队列中,以便在while
循环的下一次迭代中进行分析。
你可以在www.geeksforgeeks.org/breadth-first-traversal-for-a-graph/
找到有关 BFS 算法及其实现的更多信息。
最后,返回已访问节点的列表。如果要测试所描述的算法,可以将以下代码放入Program
类中的Main
方法中:
Graph<int> graph = new Graph<int>(true, true);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2, 9); (...)
graph.AddEdge(n8, n5, 3);
List<Node<int>> bfsNodes = graph.BFS();
bfsNodes.ForEach(n => Console.WriteLine(n));
代码初始化图形,添加适当的节点和边,并调用BFS
公共方法来根据 BFS 算法遍历图形。最后一行负责迭代结果以在控制台中呈现节点的数据:
Node with index 0: 1, neighbors: 2
Node with index 1: 2, neighbors: 2
Node with index 2: 3, neighbors: 1
Node with index 3: 4, neighbors: 2
Node with index 7: 8, neighbors: 1
Node with index 4: 5, neighbors: 4
Node with index 5: 6, neighbors: 1
Node with index 6: 7, neighbors: 2
你刚刚学会了两种遍历图的算法,即 DFS 和 BFS。为了让你更容易理解这些主题,本章包含了详细的描述、图表和示例。现在,让我们继续到下一节,了解另一个重要主题,即最小生成树,它在现实世界中有许多应用。
最小生成树
在谈论图形时,介绍生成树的主题是有益的。什么是生成树?生成树是连接图中所有节点而没有循环的边的子集。当然,在同一个图中可能有许多生成树。例如,让我们看一下以下图表:
左侧是一个由以下边组成的生成树:(1, 2), (1, 3), (3, 4), (4, 5), (5, 6), (6, 7), 和 (5, 8)。总权重等于 40。右侧显示了另一个生成树。这里考虑了以下边:(1, 2), (1, 3), (2, 4), (4, 8), (5, 8), (5, 6), 和 (6, 7)。总权重等于 31。
然而,前述的生成树都不是该图的最小生成树(MST)。生成树是最小的是什么意思?答案非常简单:它是图中所有生成树中成本最低的生成树。您可以通过用(5,7)替换(6,7)边来获得最小生成树。然后,成本等于 30。还值得一提的是,生成树中的边数等于节点数减一。
为什么最小生成树的主题如此重要?让我们想象一个场景,当您需要将许多建筑物连接到电信电缆时。当然,有各种可能的连接,例如从一个建筑物到另一个建筑物,或者使用中心。而且,环境条件可能会严重影响投资成本,因为需要穿越道路甚至河流。您的任务是以尽可能低的成本成功将所有建筑物连接到电信电缆。您应该如何设计连接?要回答这个问题,您只需要创建一个图,其中节点表示连接器,边表示可能的连接。然后,找到最小生成树,就这样!
上述连接许多建筑物到电信电缆的问题在最小生成树的相关部分结束时的示例中进行了介绍。
下一个问题是如何找到最小生成树?有各种方法来解决这个问题,包括应用 Kruskal 或 Prim 算法,这些算法在下面的部分中进行了介绍和解释。
Kruskal 算法
找到最小生成树的算法之一是由 Kruskal 发现的。它的操作非常简单。该算法从剩余的边中取出权重最小的边,并将其添加到最小生成树中,只有在添加它不会创建循环时才这样做。当所有节点连接时,算法停止。
让我们来看一下使用 Kruskal 算法找到最小生成树的步骤的图表:
在第一步中,选择边(5,8),因为它的权重最小,即 1。然后选择边(1,2),(2,4),(5,6),(1,3),(5,7)和(4,8)。值得注意的是,在选择(4,8)边之前,考虑了(6,7)边,因为它的权重更低。然而,将其添加到最小生成树中将会引入由(5,6),(6,7)和(5,7)边组成的循环。因此,忽略这样的边,算法选择了边(4,8)。最后,最小生成树中的边数为 7。节点数等于 8,这意味着算法可以停止运行并找到最小生成树。
让我们来看一下实现。它涉及到MinimumSpanningTreeKruskal
方法,应该添加到Graph
类中。建议的代码如下:
public List<Edge<T>> MinimumSpanningTreeKruskal()
{
List<Edge<T>> edges = GetEdges();
edges.Sort((a, b) => a.Weight.CompareTo(b.Weight));
Queue<Edge<T>> queue = new Queue<Edge<T>>(edges);
Subset<T>[] subsets = new Subset<T>[Nodes.Count];
for (int i = 0; i < Nodes.Count; i++)
{
subsets[i] = new Subset<T>() { Parent = Nodes[i] };
}
List<Edge<T>> result = new List<Edge<T>>();
while (result.Count < Nodes.Count - 1)
{
Edge<T> edge = queue.Dequeue();
Node<T> from = GetRoot(subsets, edge.From);
Node<T> to = GetRoot(subsets, edge.To);
if (from != to)
{
result.Add(edge);
Union(subsets, from, to);
}
}
return result;
}
该方法不接受任何参数。首先,通过调用GetEdges
方法获得边的列表。然后,按权重升序对边进行排序。这一步非常关键,因为您需要在算法的后续迭代中获得具有最小成本的边。在下一行,创建一个新的队列,并使用Queue
类的构造函数将Edge
实例入队。
在下一段代码中,创建了一个包含子集数据的数组。默认情况下,每个节点都被添加到一个单独的子集中。这就是为什么subsets
数组中的元素数量等于节点数的原因。这些子集用于检查将边添加到最小生成树是否会导致创建循环。
然后,创建用于存储来自 MST 的边的列表(result
)。代码中最有趣的部分是while
循环,它迭代直到在 MST 中找到正确数量的边。在循环内,通过在Queue
实例上调用Dequeue
方法来获取具有最小权重的边。然后,检查添加找到的边是否引入了循环。在这种情况下,将边添加到目标列表,并调用Union
方法来合并两个子集。
在分析前面的方法时,提到了GetRoot
方法。它的目的是更新子集的父节点,并返回子集的根节点。
private Node<T> GetRoot(Subset<T>[] subsets, Node<T> node)
{
if (subsets[node.Index].Parent != node)
{
subsets[node.Index].Parent = GetRoot(
subsets,
subsets[node.Index].Parent);
}
return subsets[node.Index].Parent;
}
最后一个私有方法名为Union
,执行两个集合的联合操作(按秩)。它接受三个参数,即Subset
实例的数组和两个Node
实例,表示应在其上执行联合操作的子集的根节点。代码的适当部分如下:
private void Union(Subset<T>[] subsets, Node<T> a, Node<T> b)
{
if (subsets[a.Index].Rank > subsets[b.Index].Rank)
{
subsets[b.Index].Parent = a;
}
else if (subsets[a.Index].Rank < subsets[b.Index].Rank)
{
subsets[a.Index].Parent = b;
}
else
{
subsets[b.Index].Parent = a;
subsets[a.Index].Rank++;
}
}
在前面的代码片段中,您可以看到Subset
类,但它是什么样子的呢?让我们看一下它的声明:
public class Subset<T>
{
public Node<T> Parent { get; set; }
public int Rank { get; set; }
public override string ToString()
{
return $"Subset with rank {Rank}, parent: {Parent.Data}
(index: {Parent.Index})";
}
}
该类包含代表父节点(Parent
)和子集秩(Rank
)的属性。该类还重写了ToString
方法,以文本形式呈现有关子集的一些基本信息。
所呈现的代码基于www.geeksforgeeks.org/greedy-algorithms-set-2-kruskals-minimum-spanning-tree-mst/
中显示的实现。您还可以在那里找到有关 Kruskal 算法的更多信息。
让我们看一下MinimumSpanningTreeKruskal
方法的用法:
Graph<int> graph = new Graph<int>(false, true);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2, 3); (...)
graph.AddEdge(n7, n8, 20);
List<Edge<int>> mstKruskal = graph.MinimumSpanningTreeKruskal();
mstKruskal.ForEach(e => Console.WriteLine(e));
首先,初始化一个无向加权图,并添加节点和边。然后,调用MinimumSpanningTreeKruskal
方法,使用 Kruskal 算法找到 MST。最后,使用ForEach
方法将 MST 中每条边的数据写入控制台。
Prim 算法
解决查找 MST 问题的另一种方法是Prim 算法。它使用两组不相交的节点,即位于 MST 中的节点和尚未放置在那里的节点。在接下来的迭代中,算法找到连接第一组节点和第二组节点的具有最小权重的边。尚未在 MST 中的边的节点将被添加到此集合中。
前面的描述听起来相当简单,不是吗?让我们通过分析图表,看看使用 Prim 算法找到 MST 的步骤:
让我们看一看图中节点旁边添加的额外指示器。它们表示从任何邻居到达该节点所需的最小权重。默认情况下,起始节点的此值设置为0,而其他所有节点均设置为无穷大。
在步骤#2中,将起始节点添加到形成 MST 的节点子集中,并更新到其邻居的距离,即到达节点3的距离为5,到达节点2的距离为3。
在下一步(即步骤#3)中,选择具有最小成本的节点。在这种情况下,选择节点2,因为成本等于3。它的竞争对手(即节点3)的成本等于5。接下来,您需要更新到达当前节点的邻居的成本,即将节点4的成本设置为4。
显然,下一个选择的节点是节点4,因为它不存在于 MST 集合中,并且具有最低的到达成本(步骤#4)。以相同的方式,按以下顺序选择下一个边:(1,3),(4,8),(8,5),(5,6)和(5,7)。现在,所有节点都包含在 MST 中,算法可以停止其操作。
鉴于对算法步骤的详细描述,让我们继续进行基于 C#的实现。大部分操作都在MinimumSpanningTreePrim
方法中执行,应将其添加到Graph
类中:
public List<Edge<T>> MinimumSpanningTreePrim()
{
int[] previous = new int[Nodes.Count];
previous[0] = -1;
int[] minWeight = new int[Nodes.Count];
Fill(minWeight, int.MaxValue);
minWeight[0] = 0;
bool[] isInMST = new bool[Nodes.Count];
Fill(isInMST, false);
for (int i = 0; i < Nodes.Count - 1; i++)
{
int minWeightIndex = GetMinimumWeightIndex(
minWeight, isInMST);
isInMST[minWeightIndex] = true;
for (int j = 0; j < Nodes.Count; j++)
{
Edge<T> edge = this[minWeightIndex, j];
int weight = edge != null ? edge.Weight : -1;
if (edge != null
&& !isInMST[j]
&& weight < minWeight[j])
{
previous[j] = minWeightIndex;
minWeight[j] = weight;
}
}
}
List<Edge<T>> result = new List<Edge<T>>();
for (int i = 1; i < Nodes.Count; i++)
{
Edge<T> edge = this[previous[i], i];
result.Add(edge);
}
return result;
}
MinimumSpanningTreePrim
方法不接受任何参数。它使用三个辅助的与节点相关的数组,为图的节点分配附加数据。首先,即previous
存储先前节点的索引,可以从该节点到达给定节点。默认情况下,所有元素的值都相等为0
,除了第一个元素,它设置为-1
。minWeight
数组存储访问给定节点的边的最小权重。默认情况下,所有元素都设置为int
类型的最大值,而第一个元素的值设置为0
。isInMST
数组指示给定节点是否已经在 MST 中。首先,所有元素的值都应设置为false
。
代码中最有趣的部分位于for
循环中。在其中,找到未位于 MST 中的节点集合中可以以最小成本到达的节点的索引。GetMinimumWeightIndex
方法执行此任务。然后,使用另一个for
循环。在其中,获取连接具有索引minWeightIndex
和j
的节点的边。检查节点是否尚未位于 MST 中,以及到达节点的成本是否小于先前的最小成本。如果是,则更新previous
和minWeight
数组中与节点相关的元素的值。
代码的其余部分只是准备最终结果。在这里,创建一个新的带有形成 MST 的边数据的列表的实例。使用for
循环获取以下边的数据,并将它们添加到result
列表中。
在分析代码时,提到了GetMinimumWeightIndex
私有方法。其代码如下:
private int GetMinimumWeightIndex(int[] weights, bool[] isInMST)
{
int minValue = int.MaxValue;
int minIndex = 0;
for (int i = 0; i < Nodes.Count; i++)
{
if (!isInMST[i] && weights[i] < minValue)
{
minValue = weights[i];
minIndex = i;
}
}
return minIndex;
}
GetMinimumWeightIndex
方法只是找到一个索引,该索引是未位于 MST 中且可以以最小成本到达的节点的索引。为此,使用for
循环遍历所有节点。对于每个节点,检查当前节点是否未位于 MST 中,以及到达它的成本是否小于已存储的最小值。如果是,则更新minValue
和minIndex
变量的值。最后,返回索引。
所示的代码基于www.geeksforgeeks.org/greedy-algorithms-set-5-prims-minimum-spanning-tree-mst-2/
中显示的实现。您还可以在那里找到有关 Prim 算法的更多信息。
此外,还使用了辅助的Fill
方法。它只是将数组中所有元素的值设置为作为第二个参数传递的值。该方法的代码如下:
private void Fill<Q>(Q[] array, Q value)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = value;
}
}
让我们来看一下MinimumSpanningTreePrim
方法的用法:
Graph<int> graph = new Graph<int>(false, true);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2, 3); (...)
graph.AddEdge(n7, n8, 20);
List<Edge<int>> mstPrim = graph.MinimumSpanningTreePrim();
mstPrim.ForEach(e => Console.WriteLine(e));
首先,初始化一个无向加权图,并添加节点和边。然后,调用MinimumSpanningTreePrim
方法使用 Prim 算法找到 MST。最后,使用ForEach
方法将 MST 中每条边的数据写入控制台。
示例-通信电缆
正如在 MST 主题的介绍中提到的,这个问题有一些重要的现实应用,比如为建筑物之间的连接创建一个供给所有建筑物的通信电缆的计划,成本最小。当然,有各种可能的连接,比如从一个建筑物到另一个建筑物,或者使用一个中心。此外,环境条件可能会对投资成本产生严重影响,因为需要穿越道路甚至河流。例如,让我们创建一个解决这个问题的程序,该程序在以下图表中显示了建筑物集合的上下文:
正如您所看到的,房地产社区由 12 栋建筑组成,包括位于河边的公寓楼和小亭。建筑物位于一条小河的两侧,只有一座桥。此外,还有两条道路。当然,连接各个点之间的成本各不相同,这取决于距离和环境条件。例如,两栋建筑物(B1和B2)之间的直接连接成本为2,而使用桥(R1和R5之间)的成本为75。如果您需要在没有桥的情况下穿过河流(R3和R6之间),成本甚至更高,为100。
您的任务是找到 MST。在此示例中,您将应用 Kruskal 和 Prim 算法来解决此问题。首先,让我们初始化无向加权图,并添加节点和边,如下所示:
Graph<string> graph = new Graph<string>(false, true);
Node<string> nodeB1 = graph.AddNode("B1"); (...)
Node<string> nodeR6 = graph.AddNode("R6");
graph.AddEdge(nodeB1, nodeB2, 2); (...)
graph.AddEdge(nodeR6, nodeB6, 10);
然后,您只需要调用MinimumSpanningTreeKruskal
方法来使用 Kruskal 算法找到 MST。当结果获得时,您可以轻松地在控制台中呈现它们,同时呈现总成本。代码的适当部分如下所示:
Console.WriteLine("Minimum Spanning Tree - Kruskal's Algorithm:");
List<Edge<string>> mstKruskal =
graph.MinimumSpanningTreeKruskal();
mstKruskal.ForEach(e => Console.WriteLine(e));
Console.WriteLine("Total cost: " + mstKruskal.Sum(e => e.Weight));
在控制台中呈现的结果如下所示:
Minimum Spanning Tree - Kruskal's Algorithm:
Edge: R4 -> R3, weight: 1
Edge: R3 -> R2, weight: 1
Edge: R2 -> R1, weight: 1
Edge: B1 -> B2, weight: 2
Edge: B3 -> B4, weight: 2
Edge: R6 -> R5, weight: 3
Edge: R6 -> B5, weight: 3
Edge: B5 -> B6, weight: 6
Edge: B1 -> B3, weight: 20
Edge: B2 -> R2, weight: 25
Edge: R1 -> R5, weight: 75
Total cost: 139
如果您在地图上可视化这样的结果,将找到以下 MST:
类似地,您可以应用 Prim 算法:
Console.WriteLine("nMinimum Spanning Tree - Prim's Algorithm:");
List<Edge<string>> mstPrim = graph.MinimumSpanningTreePrim();
mstPrim.ForEach(e => Console.WriteLine(e));
Console.WriteLine("Total cost: " + mstPrim.Sum(e => e.Weight));
获得的结果如下:
Minimum Spanning Tree - Prim's Algorithm:
Edge: B1 -> B2, weight: 2
Edge: B1 -> B3, weight: 20
Edge: B3 -> B4, weight: 2
Edge: R6 -> B5, weight: 3
Edge: B5 -> B6, weight: 6
Edge: R2 -> R1, weight: 1
Edge: B2 -> R2, weight: 25
Edge: R2 -> R3, weight: 1
Edge: R3 -> R4, weight: 1
Edge: R1 -> R5, weight: 75
Edge: R5 -> R6, weight: 3
Total cost: 139
就是这样!您刚刚完成了与 MST 的实际应用相关的示例。您准备好继续进行另一个与图相关的主题了吗?它被称为着色。
着色
寻找 MST 并不是唯一与图相关的问题。其中包括节点着色。其目的是为所有节点分配颜色(数字),以符合不能存在相同颜色的两个节点之间的边的规则。当然,颜色的数量应尽可能少。这样的问题在现实世界中有一些应用,例如着色地图,这是稍后显示的示例的主题。
您知道每个平面图的节点最多可以用四种颜色着色吗?如果您对此话题感兴趣,请查看四色定理(mathworld.wolfram.com/Four-ColorTheorem.html
)。本章中所示的着色算法的实现简单,并且在某些情况下可能使用比实际需要更多的颜色。
让我们看一下以下图表:
第一个图表(左侧显示)呈现了使用四种颜色着色的图:红色(索引等于0),绿色(1),蓝色(2)和紫罗兰色(3)。如您所见,没有使用相同颜色的节点通过边连接。右侧显示的图表描绘了具有两条额外边的图,即(2,6)和(2,5)。在这种情况下,着色已更改,但颜色数量保持不变。
问题是,您如何找到节点的颜色以符合上述规则?幸运的是,算法非常简单,其实现在此处呈现。应添加到Graph
类的Color
方法的代码如下:
public int[] Color()
{
int[] colors = new int[Nodes.Count];
Fill(colors, -1);
colors[0] = 0;
bool[] availability = new bool[Nodes.Count];
for (int i = 1; i < Nodes.Count; i++)
{
Fill(availability, true);
int colorIndex = 0;
foreach (Node<T> neighbor in Nodes[i].Neighbors)
{
colorIndex = colors[neighbor.Index];
if (colorIndex >= 0)
{
availability[colorIndex] = false;
}
}
colorIndex = 0;
for (int j = 0; j < availability.Length; j++)
{
if (availability[j])
{
colorIndex = j;
break;
}
}
colors[i] = colorIndex;
}
return colors;
}
Color
方法使用两个辅助节点相关数组。第一个名为colors
,存储为特定节点选择的颜色的索引。默认情况下,所有元素的值都设置为-1
,除了第一个元素,它设置为0
。这意味着第一个节点的颜色自动设置为第一个颜色(例如红色)。另一个辅助数组(availability
)存储有关特定颜色的可用性的信息。
代码的最关键部分是for
循环。在其中,通过将true
设置为availability
数组中所有元素的值,重置颜色的可用性。然后,你遍历当前节点的相邻节点,读取它们的颜色,并通过将false
设置为availability
数组中特定元素的值,标记这些颜色为不可用。最后的内部for
循环只是遍历availability
数组,并找到当前节点的第一个可用颜色。
所呈现的代码基于www.geeksforgeeks.org/graph-coloring-set-2-greedy-algorithm/
中展示的实现。此外,你可以在那里找到有关着色问题的更多信息。
此外,辅助的Fill
方法与之前的示例中解释的完全相同的代码一起使用。它只是将数组中所有元素的值设置为作为第二个参数传递的值。方法的代码如下:
private void Fill<Q>(Q[] array, Q value)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = value;
}
}
让我们看一下Color
方法的用法:
Graph<int> graph = new Graph<int>(false, false);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2); (...)
graph.AddEdge(n7, n8);
int[] colors = graph.Color();
for (int i = 0; i < colors.Length; i++)
{
Console.WriteLine($"Node {graph.Nodes[i].Data}: {colors[i]}");
}
在这里,你创建了一个新的无向无权图,添加了节点和边,并调用Color
方法来执行节点着色。结果,你收到了一个包含特定节点颜色索引的数组。然后,你在控制台中呈现结果:
Node 1: 0
Node 2: 1
Node 3: 1
Node 4: 0
Node 5: 1
Node 6: 0
Node 7: 2
Node 8: 3
在这个简短的介绍之后,你已经准备好继续进行真实世界的应用,即对省份地图进行着色,接下来将会呈现。
示例 - 省份地图
让我们创建一个代表波兰省份地图的程序,将其表示为一个图,并对这些区域进行着色,以便具有共同边界的两个省份不具有相同的颜色。当然,你应该限制颜色的数量。
首先,让我们考虑图的表示。在这里,节点代表特定的省份,而边代表省份之间的共同边界。
已经着色的波兰地图如下图所示:
你的任务就是使用已经描述的算法对图中的节点进行着色。为此,你创建无向无权图,添加代表省份的节点,并添加边来表示共同的边界。代码如下:
Graph<string> graph = new Graph<string>(false, false);
Node<string> nodePK = graph.AddNode("PK"); (...)
Node<string> nodeOP = graph.AddNode("OP");
graph.AddEdge(nodePK, nodeLU); (...)
graph.AddEdge(nodeDS, nodeOP);
然后,在Graph
实例上调用Color
方法,并返回特定节点的颜色索引。最后,你只需在控制台中呈现结果。代码的适当部分如下所示:
int[] colors = graph.Color();
for (int i = 0; i < colors.Length; i++)
{
Console.WriteLine($"{graph.Nodes[i].Data}: {colors[i]}");
}
部分结果如下所示:
PK: 0
LU: 1 (...)
OP: 2
你刚刚学会了如何给图中的节点着色!然而,这并不是本书中介绍的关于图的有趣主题的结束。现在,让我们继续搜索图中的最短路径。
最短路径
图是一个用于存储各种地图数据的优秀数据结构,例如城市和它们之间的距离。因此,图的一个明显的真实世界应用之一是搜索两个位置之间的最短路径,考虑到特定的成本,例如距离、所需时间,甚至所需燃料的数量。
在图中搜索最短路径有几种方法。然而,其中一个常见的解决方案是Dijkstra 算法,它可以计算从起始节点到图中所有节点的距离。然后,你不仅可以轻松地获得两个节点之间的连接成本,还可以找到位于起始节点和结束节点之间的节点。
Dijkstra 算法使用两个辅助节点相关数组,分别用于存储前一个节点的标识符(可以通过最小总成本到达当前节点的节点),以及访问当前节点所需的最小距离(成本)。此外,它使用队列来存储应该被检查的节点。在连续的迭代中,算法更新图中特定节点的最小距离。最后,辅助数组包含了从选择的起始节点到达所有节点的最小距离(成本),以及如何使用最短路径到达每个节点的信息。
在继续示例之前,让我们看一下以下图表,展示了使用 Dijkstra 算法找到的两条不同的最短路径。左侧显示了从节点8到1的路径,而右侧显示了从节点1到7的路径:
现在是时候看一些 C#代码了,这些代码可以用来实现 Dijkstra 算法。主要作用由GetShortestPathDijkstra
方法执行,该方法应添加到Graph
类中。代码如下:
public List<Edge<T>> GetShortestPathDijkstra(
Node<T> source, Node<T> target)
{
int[] previous = new int[Nodes.Count];
Fill(previous, -1);
int[] distances = new int[Nodes.Count];
Fill(distances, int.MaxValue);
distances[source.Index] = 0;
SimplePriorityQueue<Node<T>> nodes =
new SimplePriorityQueue<Node<T>>();
for (int i = 0; i < Nodes.Count; i++)
{
nodes.Enqueue(Nodes[i], distances[i]);
}
while (nodes.Count != 0)
{
Node<T> node = nodes.Dequeue();
for (int i = 0; i < node.Neighbors.Count; i++)
{
Node<T> neighbor = node.Neighbors[i];
int weight = i < node.Weights.Count
? node.Weights[i] : 0;
int weightTotal = distances[node.Index] + weight;
if (distances[neighbor.Index] > weightTotal)
{
distances[neighbor.Index] = weightTotal;
previous[neighbor.Index] = node.Index;
nodes.UpdatePriority(neighbor,
distances[neighbor.Index]);
}
}
}
List<int> indices = new List<int>();
int index = target.Index;
while (index >= 0)
{
indices.Add(index);
index = previous[index];
}
indices.Reverse();
List<Edge<T>> result = new List<Edge<T>>();
for (int i = 0; i < indices.Count - 1; i++)
{
Edge<T> edge = this[indices[i], indices[i + 1]];
result.Add(edge);
}
return result;
}
GetShortestPathDijkstra
方法接受两个参数,即source
和target
节点。首先,它创建了两个与节点相关的辅助数组,用于存储前一个节点的索引,从中可以以最小总成本到达给定节点(previous
),以及用于存储到给定节点的当前最小距离(distances
)。默认情况下,previous
数组中所有元素的值都设置为-1
,而在distances
数组中它们设置为int
类型的最大值。当然,到源节点的距离设置为0
。然后,创建一个新的优先队列,并将所有节点的数据入队。每个元素的优先级等于到达该节点的当前距离。
值得注意的是,示例使用了 NuGet 中的OptimizedPriorityQueue
包。有关此包的更多信息,请访问www.nuget.org/packages/OptimizedPriorityQueue
,以及第三章中的优先队列部分。
代码中最有趣的部分是while
循环,该循环执行直到队列为空。在while
循环中,您从队列中获取第一个节点,并使用for
循环迭代其所有邻居。在这样的循环内部,通过将当前节点的距离和边的权重相加来计算到邻居的距离。如果计算出的距离小于当前存储的值,则更新关于给定邻居的最小距离的值,以及可以到达邻居的前一个节点的索引。值得注意的是,队列中元素的优先级也应该更新。
其余操作用于使用previous
数组中存储的值解析路径。为此,您将下一个节点的索引(相反方向)保存在indices
列表中。然后,您将其反转以获得从源节点到目标节点的顺序。最后,您只需创建边的列表,以便以适合从方法返回的形式呈现结果。
所呈现和描述的实现是基于en.wikipedia.org/wiki/Dijkstra%27s_algorithm
上显示的伪代码。您可以在那里找到有关 Dijkstra 算法的一些额外信息。
让我们看一下GetShortestPathDijkstra
方法的用法:
Graph<int> graph = new Graph<int>(true, true);
Node<int> n1 = graph.AddNode(1); (...)
Node<int> n8 = graph.AddNode(8);
graph.AddEdge(n1, n2, 9); (...)
graph.AddEdge(n8, n5, 3);
List<Edge<int>> path = graph.GetShortestPathDijkstra(n1, n5);
path.ForEach(e => Console.WriteLine(e));
在这里,您创建了一个新的有向加权图,添加了节点和边缘,并调用了GetShortestPathDijkstra
方法来搜索两个节点之间的最短路径,即节点1
和5
之间的路径。 结果,您将收到形成最短路径的边缘列表。 然后,您只需遍历所有边缘并在控制台中呈现结果:
Edge: 1 -> 3, weight: 5
Edge: 3 -> 4, weight: 12
Edge: 4 -> 8, weight: 8
Edge: 8 -> 5, weight: 3
在这个简短的介绍之后,再加上简单的示例,让我们继续进行更高级和有趣的与游戏开发相关的应用。 让我们开始吧!
示例-游戏地图
本章中显示的最后一个示例涉及应用 Dijkstra 算法来查找游戏地图中的最短路径。 假设您有一个带有各种障碍物的棋盘。 因此,玩家只能使用棋盘的一部分移动。 您的任务是找到棋盘上两个位置之间的最短路径。
首先,让我们将棋盘表示为一个二维数组,其中棋盘上的给定位置可以用于移动或不可用。 适当的代码部分应添加到Program
类中的Main
方法中,如下所示:
string[] lines = new string[]
{
"0011100000111110000011111",
"0011100000111110000011111",
"0011100000111110000011111",
"0000000000011100000011111",
"0000001110000000000011111",
"0001001110011100000011111",
"1111111111111110111111100",
"1111111111111110111111101",
"1111111111111110111111100",
"0000000000000000111111110",
"0000000000000000111111100",
"0001111111001100000001101",
"0001111111001100000001100",
"0001100000000000111111110",
"1111100000000000111111100",
"1111100011001100100010001",
"1111100011001100001000100"
};
bool[][] map = new bool[lines.Length][];
for (int i = 0; i < lines.Length; i++)
{
map[i] = lines[i]
.Select(c => int.Parse(c.ToString()) == 0)
.ToArray();
}
为了提高代码的可读性,地图被表示为一个string
值的数组。 每一行都以文本形式呈现,字符数等于列数。 每个字符的值表示点的可用性。 如果等于0
,则该位置可用。 否则,不可用。 然后,基于string
的地图表示应转换为布尔型二维数组。 此任务由几行代码执行,如前面的代码片段所示。
下一步是创建图表,以及添加必要的节点和边缘。 适当的代码部分如下所示:
Graph<string> graph = new Graph<string>(false, true);
for (int i = 0; i < map.Length; i++)
{
for (int j = 0; j < map[i].Length; j++)
{
if (map[i][j])
{
Node<string> from = graph.AddNode($"{i}-{j}");
if (i > 0 && map[i - 1][j])
{
Node<string> to = graph.Nodes.Find(
n => n.Data == $"{i - 1}-{j}");
graph.AddEdge(from, to, 1);
}
if (j > 0 && map[i][j - 1])
{
Node<string> to = graph.Nodes.Find(
n => n.Data == $"{i}-{j - 1}");
graph.AddEdge(from, to, 1);
}
}
}
}
首先,您初始化了一个新的无向加权图。 然后,您使用两个for
循环来迭代棋盘上的所有位置。 在这些循环内,您检查给定位置是否可用。 如果是,您创建一个新节点(from
)。 然后,您检查当前节点上方是否也可用。 如果是,将添加权重为1
的适当边缘。 以类似的方式检查当前节点左侧是否可用,并在必要时添加边缘。
现在,您只需要获取表示源节点和目标节点的Node
实例。 您可以通过使用Find
方法并提供节点的文本表示(例如0-0
或16-24
)来实现。 然后,只需调用GetShortestPathDijkstra
方法。 在这种情况下,算法将尝试找到第一行和列中的节点与最后一行和列中的节点之间的最短路径。 代码如下:
Node<string> source = graph.Nodes.Find(n => n.Data == "0-0");
Node<string> target = graph.Nodes.Find(n => n.Data == "16-24");
List<Edge<string>> path = graph.GetShortestPathDijkstra(
source, target);
代码的最后部分与在控制台中呈现地图有关:
Console.OutputEncoding = Encoding.UTF8;
for (int row = 0; row < map.Length; row++)
{
for (int column = 0; column < map[row].Length; column++)
{
ConsoleColor color = map[row][column]
? ConsoleColor.Green : ConsoleColor.Red;
if (path.Any(e => e.From.Data == $"{row}-{column}"
|| e.To.Data == $"{row}-{column}"))
{
color = ConsoleColor.White;
}
Console.ForegroundColor = color;
Console.Write("\u25cf ");
}
Console.WriteLine();
}
Console.ForegroundColor = ConsoleColor.Gray;
首先,您需要在控制台中设置适当的编码,以便能够呈现 Unicode 字符。 然后,您使用两个for
循环来迭代棋盘上的所有位置。 在这些循环内,您选择应用于在控制台中表示点的颜色,可以是绿色(点可用)或红色(不可用)。 如果当前分析的点是最短路径的一部分,则颜色将更改为白色。 最后,您只需设置适当的颜色并写入表示子弹的 Unicode 字符。 当程序执行退出两个循环时,将设置默认的控制台颜色。
运行应用程序时,您将看到以下结果:
干得好! 现在,让我们进行简短的总结,以总结您在阅读本章时学到的内容。
总结
您刚刚完成了与开发应用程序时最重要的数据结构之一,即图相关的章节。正如您所学到的,图是由节点和边组成的数据结构。每条边连接两个节点。此外,图中有各种变体的边,如无向和有向,以及无权重和有权重。所有这些都已经被详细描述和解释,还有图表和代码示例。图的两种表示方法,即使用邻接表和邻接矩阵,也已经被解释。当然,您还学会了如何使用 C#语言实现图。
在谈论图时,也很重要介绍一些现实世界的应用,特别是由于这种数据结构的常见使用。例如,本章包含了社交媒体中可用的朋友结构的描述,或者在城市中搜索最短路径的问题。
在本章的主题中,您已经了解了如何遍历图,即以某种特定顺序访问所有节点。介绍了两种方法,即深度优先搜索和广度优先搜索。值得一提的是,遍历主题也可以应用于在图中搜索给定节点。
在另一节中,介绍了生成树和最小生成树的主题。提醒一下,生成树是连接图中所有节点而没有循环的边的子集,而最小生成树是具有图中所有可用生成树中最小成本的生成树。有几种方法可以找到最小生成树,包括 Kruskal 或 Prim 算法的应用。
然后,您学习了下面两个流行的与图相关的问题的解决方案。第一个是节点的着色,您需要为所有节点分配颜色(数字),以符合不能存在相同颜色的两个节点之间的边的规则。当然,颜色的数量应该尽可能少。
另一个问题是在两个节点之间搜索最短路径,考虑了特定的成本,比如距离、所需时间,甚至所需燃料的数量。在图中搜索最短路径有几种方法。然而,其中一个常见的解决方案是 Dijkstra 算法,它可以计算从起始节点到图中所有节点的距离。这个主题已经在本章中进行了介绍和解释。
现在,是时候进行总结,看看到目前为止在书中介绍的所有数据结构和算法。让我们翻开书页,继续到最后一章!
第七章:总结
在阅读这本书的许多页面时,你已经学到了很多关于各种数据结构和算法的知识,这些知识可以在开发 C#语言应用程序时使用。数组、列表、栈、队列、字典、哈希集、树、堆和图,以及相应的算法——这是相当广泛的主题,不是吗?现在是时候总结这些知识了,同时提醒你一些特定结构的特定应用。
首先,你将看到数据结构的简要分类,分为线性和非线性两组。然后,将考虑各种数据结构的多样化应用主题。你将看到每个描述的数据结构的简要总结,以及关于可以用特定数据结构解决的一些问题的信息。
你准备好开始阅读最后一章了吗?如果是的,让我们享受阅读的过程,看看在阅读之前的所有章节中你学到了多少知识。让我们开始吧!
在本章中,将涵盖以下主题:
-
数据结构的分类
-
应用的多样性
第八章:数据结构的分类
正如你在阅读本书时所看到的,有许多数据结构及其许多配置变体。因此,选择合适的数据结构并不是一件容易的事情,这可能会对开发解决方案的性能产生重大影响。即使本书中提到的主题形成了相当长的数据结构描述列表。因此,最好以某种方式对它们进行分类。
在本章中,描述的数据结构被分为线性和非线性类别。线性数据结构中的每个元素可以在逻辑上与其后一个或前一个元素相邻。在非线性数据结构的情况下,单个元素可以在逻辑上与许多其他元素相邻,不一定只有一个或两个。它们可以自由分布在内存中。
让我们来看看下面的图表,根据所提到的标准对数据结构进行分类:
正如你所看到的,线性数据结构组包括数组、列表、栈和队列。当然,你还应该关注所提到的数据结构的各种子类型,比如链表的三种变体,它是列表的一个子类型。
在非线性数据结构的情况下,图表起着最重要的作用,因为它还包括树的子类型。此外,树包括二叉树和堆,而二叉搜索树是二叉树的一个子类型。同样,你可以描述本书中介绍和解释的其他数据结构的关系。
应用的多样性
你还记得书中展示的所有数据结构吗?由于描述的主题数量众多,最好再次查看以下数据结构,以及它们相关的算法,只是以简要摘要的形式,包括一些真实世界应用的信息。
数组
让我们从数组开始,这是第一章的两个主要主题之一。你可以使用这种数据结构来存储许多相同类型的变量,比如int
、string
或用户定义的类。重要的假设是数组中的元素数量在初始化后不能改变。此外,数组属于随机访问数据结构,这意味着你可以使用索引来访问数组的第一个、中间、第 n 个或最后一个元素。你可以从几种数组变体中受益,即单维、多维和不规则数组,也称为数组的数组。
所有这些变体都显示在下图中:
数组有许多应用,作为开发人员,你可能多次使用了这种数据结构。在本书中,你已经看到了如何使用它来存储各种数据,比如月份的名称、乘法表,甚至是游戏地图。在最后一种情况下,你创建了一个与地图大小相同的二维数组,其中每个元素指定了某种地形,例如草地或墙壁。
有许多算法可以对数组执行各种操作。然而,其中最常见的任务之一是对数组进行排序,以便将其元素按正确的顺序排列,无论是升序还是降序。本书重点介绍了四种算法,即选择排序、插入排序、冒泡排序以及快速排序。
列表
第一章描述的下一组数据结构与列表相关。它们类似于数组,但可以在必要时动态增加集合的大小。在下图中,你可以看到列表的几种变体,即单链表、双链表和循环链表:
值得一提的是,数组列表(ArrayList
)以及其泛型(List
)和排序(SortedList
)变体都有内置的实现。后者可以被理解为一组键值对,始终按键排序。
短评对于单链表、双链表和循环链表可能是有益的。第一个数据结构使得可以通过Next
属性轻松地从一个元素导航到下一个元素。然而,通过添加Previous
属性可以进一步扩展,允许在前后方向导航,形成双链表。在循环链表中,第一个节点的Previous
属性导航到最后一个节点,而Next
属性将最后一个节点链接到第一个节点。值得注意的是,双链表有一个内置的实现(LinkedList
),你可以很容易地扩展双链表以使其行为像循环链表。
列表有很多应用,可以解决各种类型应用程序中的不同问题。在本书中,你已经看到如何利用列表来存储一些浮点值并计算平均值,如何使用这种数据结构来创建一个简单的人员数据库,以及如何开发一个自动排序的地址簿。此外,你还准备了一个简单的应用程序,允许用户通过改变页面来阅读书籍,以及一个游戏,用户可以旋转具有随机动力的轮子。轮子旋转得越来越慢,直到停止。然后,用户可以再次旋转它,从上一次停止的位置开始,这说明了循环链表。
栈
本书的第三章专注于栈和队列。在本节中,让我们来看看栈,它是有限访问数据结构的代表。这个名字意味着你不能从结构中访问每个元素,获取元素的方式是严格指定的。在栈的情况下,你只能在顶部添加一个新元素(推入操作),并通过从顶部移除元素来获取元素(弹出操作)。因此,栈符合 LIFO 原则,即后进先出。
栈的图示如下所示:
当然,栈有许多现实世界的应用。其中一个例子是与许多盘子堆叠在一起的一堆盘子有关。你只能在堆叠的顶部添加一个新的盘子,只能从堆叠的顶部获取一个盘子。你不能移除第七个盘子而不先从顶部取出前六个盘子,也不能在堆叠的中间添加一个盘子。你还看到了如何使用栈来颠倒一个单词,以及如何应用它来解决数学游戏汉诺塔。
队列
本书第三章的另一个主题是队列。使用这种数据结构时,只能在队列的末尾添加新元素(入队操作),并且只能从队列的开头移除元素(出队操作)。因此,这种数据结构符合 FIFO 原则,即先进先出。
队列的图示如下所示:
还可以使用优先队列,它通过为每个元素设置优先级来扩展队列的概念。因此,Dequeue
操作返回最早添加到队列中的优先级最高的元素。
以下是示例 BST 的图示:
第四章的主题与字典和集合有关。首先,让我们看一下字典,它允许将键映射到值并进行快速查找。字典使用哈希函数,可以理解为一组成对的集合,每对由键和值组成。字典有两个内置版本,即非泛型(Hashtable
)和泛型(Dictionary
)。还有排序的字典(SortedDictionary
)可用。
字典
哈希表的机制如下图所示:
由于哈希表的出色性能,这种数据结构经常在许多现实世界的应用中使用,例如用于关联数组、数据库索引或缓存系统。在本书中,你已经看到如何创建电话簿来存储条目,其中一个人的名字是键,电话号码是值。在其他示例中,你已经开发了一个帮助商店员工找到产品放置位置的应用,并且应用了排序字典来创建简单的百科全书,用户可以添加条目并显示其全部内容。
集合
另一个数据结构是集合,它是一个不重复元素的集合,没有特定顺序。因此,你只能知道给定元素是否在集合中。集合与数学模型和操作严格相关,如并集、交集、差集和对称差。
以下是存储各种类型数据的示例集:
在 C#语言中开发应用程序时,你可以从HashSet
类提供的高性能集合相关操作中受益。例如,你已经看到如何创建一个处理一次性促销券的系统,并允许你检查扫描的促销券是否已经被使用。另一个例子是 SPA 中心系统的报告服务,有四个游泳池。通过使用集合,你已经计算了统计数据,例如游泳池的访客人数,最受欢迎的游泳池以及至少访问过一个游泳池的人数。
队列有许多现实世界的应用。例如,队列可以用来表示在商店结账处等待的人群。新人站在队伍的末尾,下一个人从队伍的开头被带到结账处。你不能选择队伍中间的人来服务。此外,你已经看到了呼叫中心解决方案的几个示例,其中有许多呼叫者(具有不同的客户标识符)和一个顾问,许多呼叫者和许多顾问,以及许多呼叫者(具有不同的计划,标准或优先支持)和只有一个顾问,回答等待的呼叫。
树
一般来说,树中的每个节点可以包含任意数量的子节点。但是,在二叉树的情况下,一个节点不能包含超过两个子节点,即它可以不包含子节点,或者只包含一个或两个,但是没有关于节点之间关系的规则。如果要使用二叉搜索树(BST),则引入下一个规则。它规定,对于任何节点,其左子树中所有节点的值必须小于其值,并且其右子树中所有节点的值必须大于其值。
下一个主题是关于树,它是由具有一个根节点的数据结构组成。根节点不包含父节点,而所有其他节点都包含。此外,每个节点可以有任意数量的子节点。同一节点的子节点可以称为兄弟节点,而没有子节点的节点称为叶子节点。
另一组树称为自平衡树,它在添加和删除节点时始终保持树的平衡。它们的应用非常重要,因为它允许您形成正确排列的树,对性能有积极影响。自平衡树有各种变体,但 AVL 和红黑树(RBTs)是最受欢迎的。这两种树在本书中都有简要描述。
在谈论树时,也有必要介绍一些遍历树的方法。在本书中,您已经学习了先序、中序和后序遍历的变体。
树是一种非常适合表示各种数据的数据结构,例如公司的结构,分为几个部门,每个部门都有自己的结构。您还看到了一个树的例子,用于安排由几个问题和答案组成的简单测验,这些问题和答案根据先前的决定显示。
堆
堆是树的另一种变体,有两个版本,即最小堆和最大堆。对于每一个,必须满足额外的属性。对于最小堆,每个节点的值必须大于或等于其父节点的值。对于最大堆,每个节点的值必须小于或等于其父节点的值。所述规则起着关键作用,确保根节点始终包含最小值(在最小堆中)或最大值(在最大堆中)。因此,它是一个非常方便的数据结构,用于实现优先队列。
堆存在许多变体,包括二叉堆,它还必须遵守完全二叉树规则,即每个节点不能包含两个以上的子节点,并且树的所有级别必须完全填满,除了最后一个级别,它可以从左到右填满,右边留有一些空间。
示例堆如下所示:
当然,二叉堆不是唯一可用的堆。除了二叉堆,还有二项堆和斐波那契堆。这三种变体都在本书中有所描述。
堆的一个有趣应用是排序算法,名为堆排序。
图
前一章与图相关,作为一种广泛应用于现实世界的非常流行的数据结构。提醒一下,图是一个由节点和边组成的数据结构。每条边连接两个节点。此外,图中有几种边的变体,如无向和有向,以及无权重和有权重。图可以表示为邻接表或邻接矩阵。
所有这些主题都在本书中有所描述,包括图的遍历问题、寻找最小生成树、节点着色以及在图中寻找最短路径的问题。
以下图显示了示例图:
图数据结构通常用于各种应用程序,并且是表示各种数据的绝佳方式,例如社交媒体网站上可用的朋友结构。在这里,节点可以表示联系人,而边表示人与人之间的关系。因此,您可以轻松地检查两个联系人是否互相认识,或者需要多少人参与安排两个特定人之间的会面。
图的另一个常见应用涉及寻找路径的问题。例如,您可以使用图来找到城市中两点之间的路径,考虑到驾驶所需的距离或时间。您可以使用图来表示城市的地图,其中节点是交叉路口,边表示道路。当然,您应该为边分配权重,以指示驾驶给定道路所需的距离或时间。
图还有许多其他相关的应用。例如,最小生成树可以用来创建建筑物之间的连接计划,以最小的成本为它们提供电信电缆,就像前一章中所解释的那样。
节点着色问题已经被用来根据这样一个规则对波兰地图上的省进行着色,即有共同边界的两个省份不能有相同的颜色。当然,颜色的数量应该是有限的。
这本书中另一个例子涉及到 Dijkstra 算法,用于在游戏地图中寻找最短路径。任务是在棋盘上找到两个地方之间的最短路径,考虑到各种障碍。
最后的话
你刚刚到达了这本书的最后一章的结尾。首先,介绍了数据结构的分类,考虑了线性和非线性数据结构。在第一组中,你可以找到数组、列表、栈和队列,而第二组涉及到图、树、堆,以及它们的变体。在本章的后续部分中,考虑了各种数据结构的多样化应用。你已经看到了对每种描述的数据结构的简要总结,以及关于可以用特定数据结构解决的一些问题的信息,比如队列或图。为了使内容更容易理解,并提醒你之前章节中的各种主题,总结中配有数据结构的插图。
在这本书的介绍中,我邀请您开始您的数据结构和算法之旅。在阅读以下章节、编写数百行代码和调试的过程中,您有机会熟悉各种数据结构,从数组和列表开始,经过栈、队列、字典和哈希集,最后到树、堆和图。我希望这本书只是您与数据结构和算法长期、充满挑战和成功的冒险的第一步。
感谢您阅读这本书。如果您对所描述的内容有任何问题或困惑,请不要犹豫直接联系我,联系信息显示在jamro.biz
。我希望您在作为软件开发人员的职业生涯中一切顺利,并且希望您有许多成功的项目!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
2020-05-17 HowToDoInJava Spring 教程·翻译完成