C--数据结构与算法第二版-全-
C# 数据结构与算法第二版(全)
原文:
zh.annas-archive.org/md5/153253b84c4b97976a7232e011ca347e译者:飞龙
前言
你好,我是马尔钦!
很高兴见到你,并邀请你踏上一次通过本书中展示的各种数据结构和算法的精彩之旅。正如你可能已经知道的,开发应用程序当然是一件令人兴奋的工作,但它也是具有挑战性的,尤其是当你需要解决一些复杂问题时。在这种情况下,你通常需要关注性能,以确保解决方案能够在资源有限的设备上顺利运行。这样的任务可能真的很难,并且可能需要关于不仅编程语言而且数据结构和算法的显著知识。然而,你有没有深入思考过它们以及它们对你应用程序性能的影响?如果没有,那么现在是时候关注这个主题了,而这本书是一个很好的起点!
你知道仅仅用一个数据结构替换另一个数据结构可能会使性能结果提高数百倍甚至更多吗?这听起来不可能吗?也许吧,但这是真的!作为一个例子,我想告诉你一个关于我之前参与的一个项目的简短故事。目标是优化算法以找到图形图中块之间的连接。这些连接应该在任何块在图中移动时自动重新计算、刷新和重新绘制。当然,连接不能穿过块,也不能与其他线条重叠,并且交叉点和方向变化的数量应该受到限制。根据图的大小和复杂性,性能结果差异很大。然而,在进行测试时,我们收到了从 1 毫秒到几乎 800 毫秒的相同测试用例的结果。也许最令人惊讶的是,这种巨大的改进主要是通过...改变两个集合的数据结构来实现的。
你是否对了解选择合适的数据结构对你应用程序性能的影响感兴趣?你想要知道如何通过选择正确的伴随算法来提高解决方案的质量和性能吗?你对各种数据结构在实际场景中的应用以及解决一些常见问题可能使用的算法好奇吗?不幸的是,这些问题的答案并不简单。然而,在这本书中,你将找到大量关于数据结构和算法的信息,这些信息是在 C#编程语言的背景下呈现的,包括许多示例、代码片段、插图和详细解释。这样的内容可以帮助你在开发下一个伟大的解决方案时回答上述问题,这些解决方案将被世界各地的人们使用!你准备好开始你的数据结构和算法冒险之旅了吗?如果是这样,欢迎加入这本书的行列!
本书涵盖了多种数据结构,从简单的开始,即数组和它们的一些变体,作为随机访问数据结构的代表。然后,介绍了列表及其排序、链式和循环版本。本书还解释了基于栈和队列的有限访问数据结构,包括优先队列和循环队列。在此之后,我们将向您介绍字典数据结构,它允许您将键映射到值并执行快速查找。字典的排序变体也得到了支持。如果您想从高性能的集合相关操作中受益,可以使用另一种数据结构,即哈希集。其中最强大的结构之一是树,它存在许多变体,包括二叉树、二叉搜索树、自平衡树、前缀树和堆。最后分析的数据结构是图,它支持许多有趣的算法主题,如图遍历、最小生成树、节点着色和寻找最短路径。
数组、列表、栈、队列、字典、哈希集、树、前缀树、堆和图,以及相关的算法——下一页将为您展示一个广泛的主题范围!让我们开始这场冒险,迈出掌握数据结构和算法的第一步,这希望会对您的项目和作为软件开发者的职业生涯产生积极的影响!
本书面向的对象
本书旨在帮助那些希望了解在 C#语言的各种应用中可以使用的数据结构和算法的开发者,包括 Web 和移动解决方案。这里介绍的主题适合具有各种经验水平的程序员,即使是初学者也会发现有趣的内容。然而,至少具备 C#编程语言的基本知识,例如面向对象编程,将是一个额外的优势。
值得注意的是,本书可能包含一些简化,以便使主题更容易理解。更重要的是,一些数据结构只是简要提及,没有详细的解释或示例。本书的目的是激发您对数据结构和算法主题的兴趣,以便您可以通过其他书籍、研究论文或在线资源进一步扩展您的知识。
为了轻松理解内容,本书配备了大量的插图和示例。更重要的是,配套项目的源代码附在章节中,并在 GitHub 仓库中可用。因此,您可以轻松运行示例应用程序并对其进行调试,而无需自己编写代码。
值得注意的是,代码可以简化,并且可能与最佳实践有所不同。示例可能具有显著限制,甚至没有安全检查和功能。在发布书中提供的内容所构建的应用程序之前,应彻底测试应用程序,以确保在各种情况下都能正确运行,例如在传递错误数据的情况下。
本书涵盖的内容
第一章,数据类型,介绍了在 C#编程语言中开发应用程序时可用数据类型的话题,包括值类型和引用类型。你将了解内置的值类型,如整数或浮点数,以及常量、枚举、值元组、用户定义的结构类型和可空值类型。至于引用类型,你将看到对象和字符串类型、类、记录、接口以及委托和动态类型,以及可空引用类型。
第二章,算法简介,向你展示了算法的定义和一些来自你日常生活的真实世界示例。然后,你将学习一些算法的表示方法,即自然语言、流程图、伪代码和编程语言。还展示了各种类型的算法,包括递归、分而治之、回溯、贪婪、启发式、穷举和动态规划。还介绍了计算复杂性,包括时间复杂性,并进行了解释。
第三章,数组和排序,涵盖了使用一些随机访问数据结构的代表来存储数据的情况,即数组。首先,解释了数组的三个变体,即一维、多维和锯齿形。你还将了解七种流行的排序算法,包括选择排序、插入排序、冒泡排序、归并排序、希尔排序、快速排序和堆排序。所有这些算法都配有插图、实现代码和详细解释。
第四章,列表的变体,处理其他随机访问数据结构的代表,即列表的一些变体。列表类似于数组,但在必要时可以动态增加集合的大小。介绍了几个列表的变体,包括单链表、双链表、循环单链表和循环双链表。这些数据结构以插图形式呈现,使用 C#代码实现,并进行了详细解释。
第五章,栈和队列,解释了如何使用两种有限访问数据结构,即栈和队列,包括优先队列和循环队列。本章展示了如何在栈上执行推入和弹出操作,并描述了队列中的入队和出队操作。为了帮助您理解这些主题,还提供了一些示例,包括汉诺塔游戏和模拟具有多个顾问和呼叫者的呼叫中心的程序。
第六章,字典和集合,专注于能够将键映射到值、执行快速查找并在集合上执行各种操作的数据结构。本章介绍了哈希表的非泛型和泛型变体、排序字典以及高性能的集合操作解决方案,允许您获取并集、交集、差集和对称差集。还展示了“排序”集合的概念。
第七章,树的变体,描述了一些与树相关的话题。首先,它展示了一个基本的树,以及其在 C#中的实现和展示其功能的示例。本章还介绍了二叉树、二叉搜索树和自平衡树,即 AVL 树和红黑树。然后,您将看到一种 trie,这是一种执行字符串相关操作的优秀方法。本章的其余部分简要介绍了二叉堆作为其他基于树的结构的话题。
第八章,探索图论,包含大量关于图的信息,从解释其基本概念开始,包括节点和几种边的变体。还涵盖了基于 C#的图实现。本章介绍了两种图遍历模式,即深度优先搜索和广度优先搜索。然后,它使用 Kruskal 和 Prim 算法介绍了最小生成树的主题,节点着色问题,以及使用 Dijkstra 算法在图中找到最短路径。
第九章,实战演练,展示了各种类型算法的几个示例。您将看到一种递归方法来计算斐波那契数列中的数字,并使用动态规划进行优化。然后,您将学习一种贪心算法来解决最小硬币找零问题,一种用于获取最近点对的分而治之方法,一种生成分形图的递归方法,以及一种解决迷宫中的老鼠和数独谜题的回溯和递归方法,还包括遗传算法和暴力破解密码猜测。
第十章,结论,是总结前几章所获得的所有知识。它简要地分类了数据结构,将它们分为两组,即线性结构和非线性结构。最后,本章讨论了各种数据结构的多样化应用。每个数据结构都附有简要描述,其中一些还配有插图,以帮助您在阅读前九章时记住所学信息。
要充分利用本书
本书面向具有各种经验的程序员。然而,初学者也会发现一些有趣的内容。尽管如此,至少需要具备 C#的基本知识,如面向对象编程,这将是一个额外的优势。
您需要安装Visual Studio集成开发环境(IDE),版本需支持.NET 8.0。您可以从visualstudio.microsoft.com/free-developer-offers/免费下载Visual Studio 2022 Community。如何安装的详细步骤可以在learn.microsoft.com/en-us/visualstudio/install/install-visual-studio?view=vs-2022找到。请注意,在配置安装时,应在工作负载选项卡中选择.NET 桌面开发选项。
如果您使用的是本书的电子版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/C-Sharp-Data-Structures-and-Algorithms---Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名和用户输入。以下是一个示例:“该类包含三个属性,即Id、Name和Role,以及两个构造函数。”
代码块设置如下:
int[,] numbers = new int[,]
{
{ 9, 5, -9 },
{ -11, 4, 0 },
{ 6, 115, 3 },
{ -12, -9, 71 },
{ 1, -6, -1 }
};
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
enum CurrencyEnum { Pln, Usd, Eur };
任何命令行输入或输出都如下所示:
Name Birth date Temp. [C] -> Result
Marcin 09.11.1988 36.6 -> Normal
Adam 05.04.1995 39.1 -> High
Martyna 24.07.2003 35.9 -> Low
粗体: 表示新术语、重要单词、重要句子或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“听起来很复杂,但幸运的是,它非常简单。锯齿数组可以理解为一个一维数组,其中每个元素都是 另一个数组。”
提示或重要注意事项
看起来像这样。
联系我们
我们读者的反馈始终受到欢迎。
一般反馈: 如果您对此书的任何方面有疑问,请通过电子邮件发送给我们 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送链接到 copyright@packt.com 与我们联系。
如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
分享您的想法
一旦您阅读了 C# 数据结构和算法,我们很乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面 并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-80324-827-1
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件地址
第一章:数据类型
欢迎来到第一章,在这里你将开始一段令人惊叹的旅程,学习在C#编程语言背景下数据结构和算法。首先,我们将对这个语言进行简要介绍。你将了解到它的可能性有多么广泛,你可以在多少场景下应用这种语言,以及你可以使用的一些基本构造。这不是一个 C#课程,因此我们不会逐一介绍其各种特性,而只会提供简要的描述。
本章的剩余部分将致力于数据类型,包括内置和用户定义的数据类型,你可以在你的应用程序中使用。首先,你将了解值类型和引用类型之间的区别。然后,你将了解各种可用的数据类型,从值类型开始。在这里,我们将涵盖整数数值类型、浮点数值类型、布尔类型、Unicode 字符、常量、枚举、值元组、结构类型和可空值类型。最后,我们将涵盖引用类型,包括对象和字符串类型,以及类、记录、接口和委托,以及动态和可空引用类型。
如你所见,你面前还有很长的路要走。然而,如果你能很好地掌握基础知识,那么你将更容易从本书剩余部分的内容中获得最大收益。作为作者,我在为你祈祷——祝你好运!
在本章中,我们将涵盖以下主题:
-
作为一种编程语言,C#
-
基于.NET 的控制台应用程序
-
值类型和引用类型之间的数据类型划分
-
值类型
-
引用类型
作为一种编程语言,C#
作为一名开发者,你可能已经听说过许多编程语言,包括C#、Java、C++、C、PHP和Ruby。在所有这些语言中,你都可以使用各种数据结构,并实现算法来解决基本和复杂的问题。然而,每种语言在实现数据结构和相应的算法时都有其独特性。如前所述,这本书只关注 C#编程语言。这也是本节的主要内容。
C#语言,发音为C sharp,是一种现代、通用、强类型和面向对象的编程语言,可用于开发各种应用程序,例如 Web、移动、桌面、分布式和嵌入式解决方案,甚至游戏!它与各种附加技术和平台协同工作,包括ASP.NET Core、XAML和Unity。因此,当你学习 C#语言,并在此编程语言的背景下了解更多关于数据结构和算法的知识时,你可以使用这些技能来创建多种类型的软件。更重要的是,即使你决定将你的主要编程语言更改为另一种,你对数据结构和算法的知识仍然有用。你可能想知道,这是如何可能的? 答案出乎意料地简单——你将理解各种数据结构是如何工作的,如何实现它们,以及如何将它们应用于使用专用算法解决各种问题。但让我们回到 C#语言。
当前语言的版本是C# 12。值得提及的是,它在各种语言版本(包括 2.0、3.0、5.0 和 8.0)中的有趣历史,在这些版本中,新功能被添加以增加语言的可能性。当你查看特定版本的发布说明时,你会看到语言是如何随着时间的推移而不断改进和扩展,成为一个强大且方便的解决方案,供开发者使用。新功能非常出色,可以显著简化你的工作,并允许你重构代码,使其更短,同时更容易理解和维护。这是 C#开发团队所做的一项出色工作,你现在可以在编写代码时从中受益。
C#编程语言的语法与其他语言类似,例如 Java 或 C++。因此,如果你了解这些语言,你应该能够轻松理解用 C#编写的代码。例如,与之前提到的语言类似,代码由以分号(;)结尾的语句组成。花括号,即{和},用于分组语句。
有几种语句类别,包括以下内容:
-
if和switch。if语句允许你根据提供的条件有条件地执行代码。switch语句使得使用模式匹配选择要执行的语句列表成为可能。 -
do-while、while、for和foreach。它们与循环相关,用于在满足条件时多次执行代码的一部分。 -
break、continue和goto。它们用于控制循环的执行,例如用于中断循环或移动到下一个迭代。 -
throw、try-catch、try-finally和try-catch-finally。它们与处理代码中可能抛出的异常有关。
还存在其他语句,例如lock、yield、checked、unchecked和fixed。你将在本书后续章节中展示的代码片段中看到前述列表中的某些语句,以及相应的解释。
C#语言的许多附加优秀功能也简化了各种应用程序的开发,例如语言集成查询(LINQ),它允许开发者从各种来源获取数据,包括 SQL 数据库和 XML 文档,并且可以一致地操作。还有一些方法可以缩短所需的代码,例如使用 Lambda 表达式、模式匹配、属性、表达式成员体、记录和字符串插值。自动垃圾回收也值得提及,它极大地简化了释放内存的任务。
哪里可以找到更多信息?
你可以在learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12上了解更多关于 C#语言最新版本的信息。语言的历史可以在learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-version-history中查看。一套关于语言参考(包括值类型和引用类型)的详细信息可以在learn.microsoft.com/en-us/dotnet/csharp/language-reference中找到。你可以在那里找到有关可用数据类型的信息,包括支持的值范围和精度,这些信息将在本章后面部分展示。
当然,之前提到的解决方案只是 C#开发中可用功能的一个非常有限的子集。你将在本书的后续部分看到一些其他功能,包括示例和详细描述。
基于.NET 的控制台应用程序
为了保持简单,在阅读这本书的过程中,你将创建许多基于控制台的应用程序,但数据结构和算法也可以用于其他类型的解决方案。基于控制台的应用程序将在Microsoft Visual Studio 2022 Community中创建。这个集成开发环境(IDE)是开发各种项目的综合解决方案,并配备了简化应用程序开发和测试的许多优秀功能。
在启动 IDE 之后,我们可以通过创建一个新的项目来继续操作。要创建一个项目,请按照以下步骤进行:
-
在主菜单中点击文件 | 新建 | 项目。
-
在创建新项目窗口的右侧选择控制台应用程序。
-
输入项目的名称(项目名称),选择文件的位置(位置),并输入解决方案的名称(解决方案名称)。然后,按下一步。
-
在附加信息窗口中,将框架版本设置为.NET 8.0(长期支持)并确保不使用顶层语句选项未被勾选。如果你准备好了,点击创建按钮来自动创建项目和生成必要的文件。
恭喜!你刚刚创建了第一个项目。但里面有什么?
让我们来看看解决方案资源管理器窗口,它展示了项目的结构。值得一提的是,项目以相同的名称包含在解决方案中。当然,一个解决方案可以包含多个项目,这在开发更复杂的应用程序时是一个常见的场景。如果你浏览这本书的 GitHub 仓库,你可以看到它包含一个包含 40 多个项目的解决方案。
没有解决方案资源管理器?
如果你找不到解决方案资源管理器窗口,你可以通过从主菜单中选择视图 | 解决方案资源管理器选项来打开它。同样,你也可以打开其他窗口,例如输出或类视图。如果你在视图选项中找不到合适的窗口(例如,C#交互式窗口),你可以在视图 | 其他 窗口节点中找到它。
自动生成的项目包含依赖项元素,它展示了项目使用的额外依赖项。值得注意的是,你可以通过从依赖项元素的上下文菜单中选择添加项目引用、添加共享项目引用或添加 COM 引用选项来轻松添加引用。此外,你可以使用NuGet 包管理器安装额外的包,这可以通过从依赖项上下文菜单中选择管理 NuGet 包来启动。
是从头开始编写还是重用现有包?
在自己编写复杂的模块之前查看已经可用的包是个好主意,因为可能已经有一个合适的包可供开发者使用。在这种情况下,你不仅可以缩短开发时间,还可以减少引入错误的机会。然而,请检查许可证条件并确保外部模块是可靠的。
Program.cs文件包含 C#中的主代码。你可以通过更改以下默认实现来调整应用程序的行为:
// See https://aka.ms/new-console-template
for more information
Console.WriteLine("Hello, World!");
此文件的初始内容只有两行。第一行是注释,而另一行在程序启动时会在控制台写入以下文本:
Hello, World!
它看起来如此简单且易于修改,不是吗?这是真的,并且由于Program类及其Main静态方法(其中放置了简单程序的逻辑)的功能,这个文件的默认实现在过去几年中已经发生了显著变化。
那么,如果你禁用了顶层语句,默认代码会是什么样子呢?让我们来看看:
namespace GettingStarted
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
上述代码包含GettingStarted命名空间内Program类的定义。这个类包含一个Main静态方法,当应用程序启动时会自动调用。
在继续之前,让我们看看文件资源管理器中项目的结构,而不是在解决方案资源管理器窗口中。这些结构是否相同?
如何打开项目目录
你可以通过在解决方案资源管理器窗口的项目节点上下文菜单中选择在文件资源管理器中打开文件夹选项,在文件资源管理器中打开包含项目的目录。
首先,你可以看到自动生成的bin和obj目录。这两个目录都包含Debug和Release目录,其名称与在 IDE 中设置的配置相关。在构建项目后,bin目录的子目录(即Debug或Release)包含带有.exe、.dll和.pdb文件的net8.0目录。更重要的是,没有Dependencies目录,但有一个.csproj文件,其中包含基于 XML 的项目配置。同样,基于解决方案的.sln配置文件位于解决方案目录中。
使用 Git 忽略某些文件和目录
如果你正在使用bin和obj目录,以及.csproj.user文件,我强烈建议你为各种项目使用版本控制系统,并频繁地提交和推送更改。如果你能的话,你也可以尝试自动化测试和部署的过程,例如通过引入持续集成和持续交付(CI/CD)。引入这样的流程可以对您优秀应用程序的质量和稳定性产生非常积极的影响,无论它们的类型如何。
如你所知,我们将在本书中涵盖的示例创建项目,让我们专注于可用的数据类型及其基本划分,以及编写一些代码!
数据类型的划分
在用 C#语言开发应用程序时,你可以使用各种数据类型,这些数据类型分为两大类,即值类型和引用类型。它们之间的区别相当简单——值类型的变量直接包含数据,而引用类型的变量仅存储对数据的引用,该数据位于 其他地方。
下面是一个示例:

图 1.1 – 值类型和引用类型之间的区别
如你所见,值类型的变量(显示为A)直接在栈内存中存储其实际值,而引用类型的变量在这里只存储一个引用。实际值位于堆内存中。因此,可以有两个或更多引用类型的变量引用相同的值,如前图中C和D框所示。
小心——这是一个简化!
请记住,这仅仅是一种简化,因为值类型并不总是存储在栈上。在某些情况下,它们会被存储在堆上。如果你对这个主题感兴趣,你可以在tooslowexception.com/heap-vs-stack-value-type-vs-reference-type/上阅读更多关于它的内容。
当然,在编程时区分值类型和引用类型的差异很重要,你应该知道哪些类型属于之前提到的组。否则,你可能会在代码中犯一些相当难以发现的错误。
例如,当你使用 == 比较两个对象时应该小心,因为值类型的两个变量如果数据相等,则它们是相等的,而引用类型的两个变量如果它们引用了 同一位置,则它们是相等的**。
你在将引用类型的变量赋值给另一个变量,或者将引用类型的变量作为参数传递给方法并更新其数据时也应该小心。这是因为更改可能会反映在其他引用同一对象的变量中。相比之下,在使用值类型时,变量值在作为参数传递给方法、从方法返回结果或将其赋值给另一个变量时会被复制,所以你只修改一个位置的数据。
值类型和引用类型之间的区别对你来说是否清晰?如果是的话,让我们继续到下一节,在那里将更详细地描述值类型,并提供一些代码片段。
值类型
为了让你更好地理解数据类型,让我们首先分析第一组,即值类型。它们进一步分为以下类别:
-
封装数据和功能的结构,它们分为以下类别:
-
内置值类型,也称为简单类型。这些分为:
-
整数 数值类型
-
浮点 数值类型
-
布尔值
-
Unicode UTF-16 字符
-
-
值元组
-
用户定义 结构类型
-
-
常量
-
枚举
所有这些组都将在本节中描述,首先是简单类型。
整数
内置值类型的第一个组是System命名空间。这些类型通过使用的字节数以及它们是否表示有符号或无符号整数值而有所不同。
想象一个整数值
如果你想要更好地可视化一个整数,你可以找到一些周围的例子——这本书的出版年份,你桌子的腿数,以及你键盘上的键数。所有这些都是整数,例如 2024,4,和 84。是的,我为你数了键盘上的键数,特别是为你!
支持的整数数值类型如下:
-
Byte(byte关键字),作为 8 位无符号 -
Sbyte(sbyte),作为 8 位有符号 -
Int16(short),作为 16 位有符号 -
Uint16(ushort),作为 16 位无符号 -
Int32(int),作为 32 位有符号 -
UInt32(uint),作为 32 位无符号 -
Int64(long),作为 64 位有符号 -
UInt64(ulong),作为 64 位无符号 -
System.IntPtr(nint),作为 32 位或 64 位(平台相关)有符号 -
System.UintPtr(nuint),作为 32 位或 64 位(平台相关)无符号
如你所见,类型的不同在于存储值的字节数,因此也在于可用值的范围。例如,byte 数据类型支持从 0 到 255 的值,sbyte 从 -128 到 127,short 从 -32,768 到 32,767,而 uint 从 0 到 4,294,967,295。这是一个很大的数字吗?是的,它很大。然而,让我们看看 ulong 的范围,它是从 0 到 18,446,744,073,709,551,615。
你可以在以下模式中指定整数类型的值:
-
45。 -
0x或0X作为前缀——例如,0xff表示 255。 -
0b或0B作为前缀——例如,0b1101110表示 110。它也可以写成0b_0110_1110以提高数字的可读性。
这里有一个示例代码片段:
int a = -20;
byte b = 0x0f;
uint c = 0b01101110;
我们要说的最后一件事是关于任何内置整数数值类型的默认值。如果你听到我说它是零,你可能不会感到惊讶。
浮点数
第二组内置值类型是浮点数值类型,允许你存储浮点值。
想象一个浮点数
如果你想要更好地想象一个浮点数,你可以测量你当前的体温(摄氏度),获取你的身高(厘米),数一数你钱包里当前的钱,或者看看你电脑处理器的频率(GHz)。所有这些值都是浮点数——例如,36.6,184.8,105.34,和 1.7。
你可以使用三种浮点数值类型:
-
使用 32 位——例如,
1.53f(f或F后缀) -
使用 64 位——例如,
1.53(无后缀或d/D后缀) -
使用 128 位——例如,
1.53M(m或M后缀)
让我们看看以下代码:
float temperature = 36.6f;
double reading = -4.5178923;
decimal salary = 10000.47M;
由于使用的位数从 32 位到 128 位不等,因此值的范围和精度差异很大。只需看看这些数字:
-
float存储介于 ±1.5×10−45 和 ±3.4×1038 之间的数字 -
double存储介于 ±5.0×10−324 和 ±1.7×10308 之间的数字 -
decimal存储介于 ±1.0×10-28 和 ±7.9228×1028 之间的数字
您可能会惊讶,尽管decimal值使用的位数是double类型的两倍,但其范围却显著更小。然而,decimal类型是货币计算的好选择。
最后,请记住,任何浮点数值类型的默认值是零。
布尔值
关于Boolean类型或bool关键字。这使得可以存储true或false。默认值是false。
想象一个布尔值
如果您想可视化一个布尔值,请回答以下问题:您目前正在阅读这本书吗?您有至少 5 年的经验吗?您已经完成大学学业了吗?您只能用是(true)或否(false)来回答这些问题。不接受其他答案。因此,您可以存储这样的回复作为布尔变量。
让我们看看以下代码:
bool isTrue = true;
bool first = isTrue || false; // true
bool second = isTrue && false; // false
bool third = true and false values, but also the null. In such circumstances, you can benefit from the nullable Boolean type (bool?), which also supports three-valued logic. You’ll learn more about nullable value types later.
Unicode characters
The last built-in value type we’ll mention here is the `Char` type or the `char` keyword. It represents a single Unicode character.
Imagine a character
If you want to understand what a Unicode character is, please write your first name on a piece of paper, separating the following letters. Each one is a character – for example, `M`, `a`, `r`, `c`, `i`, and `n`. You can use also a character to indicate the gender of a person – that is, `m` (for male), `f` (for female), and `o` (for other). As we are talking about UTF-16 encoding, a lot more values can be stored using a `char` variable, including symbols such as `©`, `﷼`, or `ϔ`. But that’s not all – you can use also geometric symbols, such as `▶` and `◉`, or even mathematical ones, such as `⅖` or `∑`.
A `char` value can be specified using the following:
* `'a'` or `'M'`
* `\u` – for example, `'\u25cf'` for `●`
* `\x` – for example, `'\x107'` for `ć`
The exemplary code snippet is as follows:
char letter = 'M';
char bullet = '\u25cf';
char special = char value is \0 (U+0000).
常量
值得注意的是,您还可以定义常量值。每个常量值都是一个 不可变值 ,不能更改。常量值只能从简单类型创建。
想象一个常量
如果您想轻松记住常量值,请考虑一些不可变值,例如一周中的天数(7)、厘米中的毫米数(10)、传感器可接受的最高温度值(90)或算法的最大迭代次数(5)。这些值在创建后都不能更改,可以定义为常量。
您可以使用const关键字来创建一个常量值,如下所示:
const int DaysInWeek = 7;
另一个有趣的事实是,对于所有操作数都是简单类型常量值的常量表达式,它们在编译时就会被评估。这对您应用程序的性能有积极影响。
枚举
除了已经提到的类型外,值类型还包含枚举。每个枚举都有一个 命名常量 集合,用于指定可用的值集。
想象一个枚举
如果您想更好地可视化一个枚举,在配置汽车时尝试指定可用的颜色(例如,黑色、白色、灰色、红色和黄色),应用程序支持的语言(例如,英语、波兰语和德语),或您接受的货币(例如,PLN、USD 和 EUR)。在这些所有场景中,都有一个精确定义的可选值列表,因此它们是枚举的良好代表。
一个示例定义如下:
0. This means that the Pln constant is equal to 0, while Eur is equal to 2. What’s more, the default value for the enumeration is 0, which means that it is Pln in this case.
You can use the defined enumeration as a data type, as follows:
CurrencyEnum currency = CurrencyEnum.Pln;
switch (currency)
{
case CurrencyEnum.Pln: /* 波兰兹罗提 */ break;
case CurrencyEnum.Usd: /* 美元 */ break;
default: /* 欧元 */ break;
}
Please keep in mind that if you place the preceding code in the `Program.cs` file – that is, the line containing the enumeration definition and then a few lines of code with the `switch` statement – you will receive an error stating **Top-level statements must precede namespace and type declarations**. This means that the declaration of the enumeration declaration must be placed at the end of the code, as shown here:
CurrencyEnum currency = CurrencyEnum.Pln;
switch (currency)
{
case CurrencyEnum.Pln: /* 波兰兹罗提 */ break;
case CurrencyEnum.Usd: /* 美元 */ break;
default: /* 欧元 */ break;
}
enum CurrencyEnum { Pln, Usd, Eur };
This note is not related to enumerations only as you could receive a similar error while using other types, such as records or classes. So, please remember the rule and **place type declaration at the end**, even if they are presented in this book before the remaining code.
Should you add all the code to one file?
In simple exemplary applications, there’s nothing wrong with placing all the code within one file. However, if you are developing something even a bit more complex, I strongly encourage you to divide the whole solution into suitable projects, as well as to **put various type declarations in separate files**. When you need to create types (for example, enumerations, classes, or records) while reading the remaining parts of this book, it is assumed that you add them to new files. Each file should be named the same as the type that is declared within it. From my point of view, writing code has some similarities to creating art, so let’s try to **write beautiful code that is not only correct and tested but also greatly arranged** **and organized**!
You can also benefit from more advanced features of enumerations, such as changing the underlying type or specifying values for particular constants. You can even do more and use the enumeration as a **bit field** – that is, as a set of **flags** – as presented here:
[标志]
enum ActionEnum
{
None = 0b_0000_0000, // 0
List = 0b_0000_0001, // 1
Details = 0b_0000_0010, // 2
Add = 0b_0000_0100, // 4
Edit = 0b_0000_1000, // 8
Delete = 0b_0001_0000, // 16
Publish = 0b_0010_0000 // 32
}
Here, you can see the `ActionEnum` enumeration, which represents various actions that are allowed for users of the blog module, such as listing posts, showing details of a particular post, as well as adding, editing, deleting, and publishing a post. The constants have the following powers of two assigned, starting with `0` (`None`). The values are 20 (`1`), 21 (`2`), 22 (`4`), 23 (`8`), 24 (`16`), and 25 (`32`). These values are provided using the binary literal. Have you noticed that in each binary value, the `1`s are located in different places and everywhere there is only one `1`? Thanks to this, you can freely combine various flags, simply by using the `OR` binary operation, which is indicated by the `|` operator, as shown here:
ActionEnum guest = ActionEnum.List;
ActionEnum user = ActionEnum.List | ActionEnum.Details;
ActionEnum editor = ActionEnum.List | ActionEnum.Add
将 List 和 Details 权限赋予用户,组合权限等于 00000011。拥有系统完全访问权限的管理员组合权限等于 00111111。简单且高效,不是吗?
值得注意的是,枚举允许你用一些魔法字符串(如Pln或Usd)替换常量值。这对代码质量有非常积极的影响。更重要的是,它显著简化了重构、维护以及在将来对代码的更改。
值元组
值元组类型由System.ValueTuple类型表示,它是一个轻量级的数据结构,允许你将多个特定类型的数据元素组合在一起。它具有非常简单的语法,你只需要指定所有数据成员的类型,并且可选地提供它们的名称。所有元素都是公共字段,因此元组类型是一个可变的值类型。更重要的是,它是一个非常以数据为中心的类型,因此你甚至不能在它内部定义方法。
想象一个值元组
如果你想更容易地理解值元组,请暂时停下来思考一下当你需要从由两个或三个值组成的方法返回结果时的编程问题,例如所选货币的价格和从基础货币使用的转换率,或者由最小值、最大值和平均值组成的统计集合。作为解决方案,你可以定义一个专门的类、记录或结构体,并将其用作返回类型。然而,这种数据结构将只使用一次,并且会不必要地使项目复杂化并影响未来的更改。另一个解决方案是使用out参数。然而,这种参数不能在所有情况下使用。为了解决这个问题,你可以使用值元组类型,并简单地指定从方法返回的数据成员类型。
尽管值元组类型看起来很简单,但它可以在各种场景中使用。让我们看看以下代码片段,其中我们使用值元组并指定从方法返回的数据成员类型:
(int, int, double) result = Calculate(4, 8, 13);
Console.WriteLine($"Min = {result.Item1}
/ Max = {Item1, Item2, and Item3 fields. The result is as follows:
最小值 = 4 / 最大值 = 13 / 平均值 = 8.33
If you don’t want to use `Item1`, `Item2`, and so on, you can change the code to specify the names of variables, as presented here:
(int min, int max, double avg) = Calculate(4, 8, 13);
Console.WriteLine($"最小值 = {min} / 最大值 = {max}
/ 平均值 = {avg:F2}");
Here, we’ll **deconstruct** a tuple by explicitly declaring the type and name of each field. If you execute the code, the result in the console will be the same as what it was previously. So far, you know how to get a result value that is a value tuple type, but how can you initialize it and return it from the method? Let’s take a look:
(int, int, double) Calculate(params int[] numbers)
{
if (numbers.Length == 0) { return (0, 0, 0); }
int min = int.MaxValue;
int max = int.MinValue;
int sum = 0;
foreach (int number in numbers)
{
if (number > max) { max = number; }
if (number < min) { min = number; }
sum += number;
}
return (min, max, (double)sum / numbers.Length);
}
You can further simplify the code and make it more readable by specifying an **alias** for this value tuple type. You can do so by using the following line of code:
using Statistics = (int Min, int Max, double Avg);
Then, it can be used in the remaining part of the code, as follows:
Statistics Calculate(params int[] numbers)
{
/* (...) */
return (min, max, (double)sum / numbers.Length);
}
You can call the `Calculate` method as follows:
Statistics result = Calculate(4, 8, 13);
Console.WriteLine($"Min = {result.Min} / Max = {result.Max}");
/ Avg = {result.Avg:F2}");
Now that you understand how value tuples work, let’s move on to the next data type.
User-defined structs
Apart from using the previously mentioned value types, you can create **data-centric struct types** (also named **structure types**) and use them in your applications.
Imagine a struct
If you want to better visualize a data-centric struct type, think about the readings that are obtained from a weather station. A single reading consists of the current values of temperature, pressure, and humidity. You can specify a type for such readings as a user-defined struct type with three immutable data members, namely for temperature, pressure, and humidity. Such values cannot be changed once you’ve received the results from the weather station.
Structs have some of the same capabilities as classes, which we’ll cover later. However, there are some differences – for example, a structure does not support inheritance. Despite the similarities to classes, you should only use structs in scenarios when a type does not provide behavior or provide it in a small amount.
This means that `readonly` modifier for the whole structure and all its data members, as shown here:
public readonly struct Price
{
public Price(decimal amount, CurrencyEnum currency)
{
Amount = amount;
Currency = currency;
}
public readonly decimal Amount { get; init; }
public readonly CurrencyEnum Currency { get; init; }
public override string ToString()
=> $"{Amount} {Currency}";
}
Here, we define the `Price` immutable struct type, which has two auto-implemented read-only properties, specified using the `init` accessor. This allows us to set a value for such properties during the object’s construction and restrict later modifications. The struct type has also its own implementation of the `ToString` method, formatting the object as the amount and currency, separated by space. There is also a constructor with two parameters that sets the values of both properties.
You can make this code a bit shorter, as follows:
public readonly struct Price(
decimal amount, CurrencyEnum currency)
{
public readonly decimal Amount { get; init; } = amount;
public readonly CurrencyEnum Currency { get; init; }
= currency;
public override string ToString()
=> $"{Amount} {Currency}";
}
Don’t forget about the declaration of the `CurrencyEnum` enumeration, together with the `public` access modifier, as follows:
public enum CurrencyEnum { Pln, Usd, Eur };
The usage of the `Price` struct is quite simple:
Price priceRegular = new(100, CurrencyEnum.Pln);
Console.WriteLine(priceRegular);
The result that’s shown in the console is as follows:
100 Pln
Since we’re talking about struct types, it is worth noting the `with` **expression****, which allows you to create a copy of a structure type instance, together with changing values of some properties and fields**. You can achieve this using the **object initializer syntax** **while specifying** **which members should be modified and what values should be assigned**. Let’s take a look at the following code:
Price priceDiscount = priceRegular priceRegular 实例并将 Amount 属性的值设置为 50。然而,其余属性的值与 priceRegular 实例相同,因此货币设置为 Pln。
要总结用户定义的结构体主题,值得记住的是,每个它们的默认值是通过将所有引用类型字段设置为 null 以及所有值类型字段设置为它们的默认值来创建的。
可空值类型
现在我们已经到达了关于值类型的本节结尾,请考虑一个需要存储特定值(例如数值,例如,154)或信息(表示值未提供)的场景。一个可能的解决方案是使用两个变量。第一个指定值是否提供(bool 类型),而另一个存储数值(例如,int 类型)。然而,是否可能只使用一个变量而不是两个?答案是肯定的!为了实现这一点,您可以使用 null 值。
想象一个可空值类型
如果您想更容易地理解可空值类型,请考虑计算您门户用户年龄的场景。如果用户向您提供了他们的出生日期,任务相当简单。然而,如果缺少这样的日期,您无法计算他们的年龄,因此年龄变量可以设置为 null 而不是整数值。在这里,您可以使用 int? 类型。
关于其实现,您可以在类型名后直接使用 ? 操作符,或者使用 System.Nullable<T> 结构,它们具有相同的效果,如下所示:
int? age = 34;
float? note = 5.5f;
null, you can compare it with null or use the HasValue property. Then, you can get a value using the Value property, as presented here:
if (age != null) { Console.WriteLine(age.Value); }
if (note.??)GetValueOrDefault 方法。以下为两种方法的示例:
int chosenAge = age ?? 18;
float shownNote = note.age variable is not null, it is assigned to chosenAge. Otherwise, 18 is set. In the second line of code, the note value is assigned to shownNote if it is not null. Otherwise, 5.0f is applied. Seems simple, doesn’t it?
While talking about the null-coalescing operator (`??`), you should also take a look at the `??=`. `??` operator, as presented in the following code:
DateTime date = new(1988, 11, 9);
int? age = GetAgeFromBirthDate(date);
age ??= 18; // 与:age = age ?? 18; 相同
int? GetAgeFromBirthDate(DateTime date)
{
double days = (DateTime.Now - date).TotalDays;
return days > 0 ? (int)(days / 365) : null;
}
Since we’re presenting various `null`-related operators, let’s introduce the **null conditional operator** as well. **It is represented by the** **?.** **operator and returns** **null** **if the left-hand side operand is** **null****. Otherwise, it is used as a standard dot operator**. An example is as follows:
string? GetFormatted(float? number)
=> numberGetFormatted 方法如果提供 null 作为数字参数,则返回 null。否则,它返回使用指定格式格式化的数字。
不要忘记,可空值类型的默认值代表 null。这意味着 HasValue 属性返回 false。
引用类型
第二组主要类型是 object、string、delegate 和 dynamic。此外,还可以声明 类、记录 和 接口。可空引用类型也存在。本节将描述所有这些类型。让我们开始吧!
对象
Object 类(object 别名)在 System 命名空间中声明,并在使用 C# 开发应用程序时扮演着重要的角色。为什么?因为 Object 中的所有其他类型。这意味着内置的值类型、内置的引用类型,以及用户定义的值类型和用户定义的引用类型,都派生自 Object 类。
想象一个对象
如果你想更容易地理解对象类型,可以将其视为“某物”。因为所有东西都是“某物”,所以所有东西都是对象。各种值类型和引用类型的代表都是对象。哦不——对象无处不在!😉
让我们看看所有对象都可用的一组方法:
-
ToString返回对象的字符串表示形式 -
GetType返回实例的类型 -
Equals检查对象是否等于给定的对象 -
GetHashCode使用哈希函数并返回其结果
由于 Object 类型是所有值类型的基实体,这意味着可以将任何值类型的变量(例如,int 或 float)转换为 object 类型,也可以将 object 类型的变量转换为特定的值类型。这些操作被称为 装箱(前者)和 拆箱(后者),如下所示:
int age = 28;
object ageBoxing = age;
int ageUnboxing = ageBoxing to bool instead of int, the code compiles without any errors. However, it fails at runtime with a System.InvalidCastException error. The additional message informs you that it is impossible to cast an object of the System.Int32 type to the System.Boolean type.
Strings
There is often a necessity to store some text values. You can achieve this using the `String` built-in reference type from the `System` namespace, which is also available using the `string` keyword. The `string` type is `string` variable can be set to `null`.
Imagine a string
If you want to better visualize a string, take a look at this sentence. It is a string! Your first name is a string too. Close this book for a moment and take a look out of your window. The name of your street is a string. That’s not all – even a car number plate is a string. It is one of the most common types that you’ll use frequently while developing applications, so please read this chapter and this book carefully since all the text in this book is a string as well!
You can perform various operations on `string` objects, such as `[]` operator, as shown here:
string firstName = "Marcin", lastName = "Jamro";
int year = 1988;
string note = firstName + " " + lastName.ToUpper()
- " 出生于 " + year;
string initials = firstName[0] + "." + lastName[0] + ".";
First, the `firstName` variable is declared, and the `Marcin` value is assigned to it. Similarly, `Jamro` is set as a value of the `lastName` variable. In the third line, we concatenate five elements (using the `+` operator), namely the current value of `firstName`, the space, the current value of `lastName` converted into uppercase (by calling `ToUpper`), the `was born in` string (with additional spaces), and the current value of `year`. In the last line, the first characters of the `firstName` and `lastName` variables are obtained using the `[]` operator, as well as concatenated with two dots to form the initials – that is, `M.J.` – which are stored as a value of the `initials` variable.
The `Format` method can also be used for constructing this string, as shown here:
string note = string.Format("{0} {1} 出生于 {2}",
firstName, lastName.ToUpper(), year);
In this example, we specify the `firstName` (represented by `{0}`), uppercase `lastName` (`{1}`), and `year` (`{2}`). The objects to format are specified as the following parameters of the method.
It is also worth mentioning the `$` character should be placed before `"`, as shown in the following example:
string note = $"{firstName} {lastName.ToUpper()}
出生于 ,10 值用于最小 10 个字符和右对齐,以及负值 -10 用于最小 10 个字符和左对齐)或 :F2 用于带有逗号后两位小数的浮点数或 :HH:mm 用于显示带分钟的小时)。
下面是一些示例代码:
string[] names = ["Marcin", "Adam", "Martyna"];
DateTime[] dates = [new(1988, 11, 9), new(1995, 4, 25),
new(2003, 7, 24)];
float[] temperatures = [36.6f, 39.1f, 35.9f];
Console.WriteLine($"{"Name",-8} {"Birth date",10}
{"Temp. [C]",11} -> Result");
for (int i = 0; i < names.Length; i++)
{
string line = $"{names[i],-8} {dates[i],10:dd.MM.yyyy}
{temperatures[i],11:F1} -> {
temperatures[i] switch
{
> 40.0f => "Very high",
> 37.0f => "High",
> 36.0f => "Normal",
> 35.0f => "Low",
_ => "Very low"
}
}";
Console.WriteLine(line);
}
本例展示了一个简单的表格,包含三个人的体温,即 Marcin、Adam 和 Martyna,以及他们的出生日期。使用了对齐方式(例如,-8)和格式字符串(例如,dd.MM.yyyy)。更重要的是,还有 > 40.0f(高)、> 37.0f(正常)、> 36.0f(低)、> 35.0f(非常低)。后者是最后一种情况,也称为 _,并匹配所有其他值。
当你执行此代码时,你会看到以下结果:
Name Birth date Temp. [C] -> Result
Marcin 09.11.1988 36.6 -> Normal
Adam 05.04.1995 39.1 -> High
Martyna 24.07.2003 35.9 -> Low
如你所见,C# 语言配备了各种可能性,甚至与 string 类型相关。更重要的是,你可以组合不同的特性,例如,将字符串插值与 switch 语句和模式匹配结合起来,以创建易于理解和维护的代码。
然而,你应该记住,string 不是一个典型的引用类型,其行为与其他引用类型略有不同。你可以在使用 == 操作符比较两个 string 变量时看到这种差异。在这里,如果两个 string 实例包含相同的字符序列,则它们是相同的,所以这与值类型的行为相似。
类
如前所述,C# 是一种面向对象的语言,支持声明类以及各种成员,包括 public、protected、internal、private 和 file。这些访问修饰符与类一起提及,但你应该记住,它们也可以用于某些其他类型。
想象一个类
如果你想要可视化一个类,想想一辆车。每辆车都有一些属性,即品牌、型号、颜色、长度、宽度、高度和重量。一辆车可以执行一些动作,例如行驶一定的距离。你还可以定义更具体的车辆变体,例如汽车、飞机和船。每个都有与基本车辆相同的属性,以及一些额外的属性,例如汽车的牌照和燃料类型(例如,汽油、柴油或电动)。它还具有开门的动作。你还可以创建此类类的实例 - 例如,你可以创建三个不同型号和牌照的汽车实例,以及创建一个飞机实例。然后,你可以在这些实例上执行动作。
下面展示了一个示例类:
public class Person
{
private string _location = string.Empty;
public string Name { get; set; }
public required int Age { get; set; }
public Person() => Name = "---";
public Person(string name)
{
Name = name;
}
public void Relocate(string location)
{
if (!string.IsNullOrEmpty(location))
{
_location = location;
}
}
public float GetDistance(string location)
=> DistanceHelpers.GetDistance(_location, location);
}
Person 类包含一个 _location 私有字段,默认值设置为空字符串 (string.Empty),以及两个公共的 Name 和 Age。你将在编写本书中展示的代码示例时经常使用这些属性,所以让我们停下来解释一下。
每个属性都是类的成员,它提供了使用 访问器 读写机制:
-
get用于返回属性值 -
set用于为属性分配新值 -
init用于在对象构造期间设置值并防止修改
通过组合这样的访问器,属性可以置于以下实例之一:
-
get访问器和没有set访问器 -
set访问器而没有get访问器 -
get和set访问器
另一个有趣的功能是属性的必需变体,它由紧跟在访问修饰符之后的 required 关键字指定,如 Age 属性的例子所示。它要求客户端代码初始化属性,如果应该在类实例使用开始时初始化属性,则将属性标记为 required 是一个好主意。还值得注意的是,属性具有一个访问级别,它是访问修饰符之一,包括 public 和 private。
让我们更仔细地看看示例类:
-
它包含一个默认构造函数,使用 表达式体定义 将
Name属性的值设置为---。 -
它包含一个接受一个参数的构造函数,并设置
Name属性的值。 -
它包含一个
Relocate方法,该方法更新私有字段的值。 -
它包含一个
GetDistance方法,该方法调用DistanceHelpers类的GetDistance静态方法,并返回两个城市之间的距离(以公里为单位)。辅助类的实现没有在前面的代码中展示。请注意,如果你对如何创建计算城市之间距离的机制的实际实现感到好奇,可以查看第八章,探索图论,其中将提到这种图的用途。
你可以使用 new 运算符创建类的实例。然后,你可以对创建的对象执行各种操作,例如调用方法,如下所示:
Person person = new("Martyna") { Age = 20 };
person.Relocate("Rzeszow");
float distance = person.GetDistance("Warsaw");
由于 C# 语言仍在开发和改进中,后续版本中引入了一些新的令人惊叹的功能,这些功能也与类相关。例如,在新版本中,你可以使用一些概念,这些概念可以显著减少代码量。以下代码展示了其中的一些:
public class Person(string name)
{
private string _location = string.Empty;
public string Name { get; set; } = name;
public required int Age { get; set; }
public void Relocate(string? location) =>
_location = location ?? _location;
public float GetDistance(string location) =>
DistanceHelpers.GetDistance(_location, location);
}
前面的代码几乎与之前的变体执行相同的角色,但它更短。如果你也喜欢这样的改进,请继续阅读——你将在本书剩余的章节中看到 C# 语言的多种可能性。
让我们继续下一节,该节专门介绍记录。
记录
在 C# 语言的最新版本中,又引入了一个伟大的引用类型:record 或 record class 关键字。
值类型记录也存在
值得注意的是,record struct 构造也存在。这种构造表示具有相似功能的值类型。然而,在这本书中,我将只关注引用类型版本。当然,你也可以自己尝试另一种版本。
记录的一个很好的特点是,需要编写的代码量更少,因为编译器会自动生成只读的init属性(称为具有一系列out参数的Deconstruct方法,每个参数代表一个位置参数。这意味着这种数据类型是数据中心的,并且旨在不可变,提供了一种简短且 清晰的语法。
想象一个记录
如果你想可视化一个记录,站起来,在镜子前看看自己,专注于你那漂亮的 T 恤。它有一些属性,例如尺寸(例如,S、M 或 L)、颜色(例如,白色或红色)和品牌。所有这些属性都是不可变的,所以你不能改变它们,就像你不能改变你最喜欢的 T 恤的大小,因为它已经生产出来了。所以,由于你的漂亮的 T 恤以数据为中心且其属性不可变,它是一个很好的记录代表。对着镜子对自己微笑,然后回到这本书的第一章阅读!
让我们看看以下记录声明的示例:
public record Dog(string Name, string Breed, int Height,
float Weight, int Age);
那就结束了!现在,你有一个包含五个不可变属性(Name、Breed、Height、Weight和Age)的记录,以及一个与记录声明中的位置参数相关的五个参数的构造函数。你可以用它来创建一个新的实例,如下面的代码行所示:
Dog rex = ToString method, which is a nice feature while debugging because you can easily see the values of all properties. To see how it works, add the following line of code:
Console.WriteLine(rex);
The result is as follows:
Dog { Name = Rex, Breed = Schnauzer, Height = 40,
Weight = 11, Age = 5 }
As you can see, the name of the record is shown, together with the names and values of the following properties. Please keep in mind that properties are defined with `get` and `init` accessors, so their values can be read and cannot be changed after they are initialized. So, the following line will cause a compiler error:
rex.Name = "Puppy";
If you want to change this behavior, you can do so with a record without positional parameters on the record declaration, but by defining the particular properties using the standard syntax, as shown here:
public record Dog
{
public required string Name { get; set; }
public required string Breed { get; set; }
public required int Height { get; set; }
public required float Weight { get; set; }
public required int Age { get; set; }
}
Another useful feature is a clear syntax for `with` expression, as shown here:
Dog beauty = rex Beauty is created, based on Rex. All of the values of the properties are taken from Rex, apart from Name and Height, as specified after the with keyword. You also need to remember that you can adjust both positional properties and properties created using the standard syntax that have the init or set accessor. In such a case, a shallow copy is created, so for value types, a copy is used, while for reference types, only a reference is copied, so both a source and a target property will reference the same instance of a reference type.
让我们看看以下行:
Name and Age positional properties, ignoring others using _. As you can see, records are equipped with a lot of useful features, but there are even more of them, such as the support for inheritance.
Interfaces
Previously, we mentioned classes. They can implement one or more **interfaces**. This means that such a class must implement all methods, properties, events, and indexers that are specified in all implemented interfaces.
Imagine an interface
If you want to remember what an interface is, think about various things that you have on yourself, including a shirt, pants, and a watch. As all of these things have different sets of properties, you can create a dedicated class for each of them. However, how can you indicate which things can be worn and which can be washed in a washing machine? You can mark various classes with special indicators, such as “wearable” and “washable.” This is where interfaces come to the rescue! You can create `IWearable` and `IWashable` interfaces. Then, you can implement `IWearable` by `Shirt`, `Pants`, and `Watch`, as well as implement `IWashable` by `Shirt` and `Pants` only. You can also require that everything washable must have a property regarding a maximum temperature for washing in a washing machine. Looks nice, doesn’t it? But that’s not all – you can also create a class regarding a washing machine with the `Wash` method, which takes the `IWashable` parameter, so you can pass `Shirt` or `Pants`. No watches are allowed here! Is this magic?
You can easily define interfaces in the C# language using the `interface` keyword:
public interface IDevice
{
string Model { get; set; }
string Number { get; set; }
int Year { get; set; }
void Configure(DeviceConfiguration configuration);
bool Start();
bool Stop();
}
The `IDevice` interface contains three properties representing the following:
* A device model (`Model`)
* A serial number (`Number`)
* A production year (`Year`).
What’s more, it contains the signatures of three methods:
* `Configure` for device setup. Please note that the `DeviceConfiguration` class is missing here, so try to prepare it on your own.
* `Start` for starting the operation.
* `Stop` for stopping the operation.
When a class implements the `IDevice` interface, it should contain all of these properties and methods, as presented in the following code snippet:
- public class Display
- IDevice
{
public string Model { get; set; }
public string Number { get; set; }
public int Year { get; set; }
public int Diagonal { get; set; }
public void Configure(
DeviceConfiguration configuration) { (...) }
public bool Start() { (...) }
public bool Stop() { (...) }
}
As you can see, the `Display` class contains all of the properties and methods specified in the interface it implements. However, you can add more elements to the class according to your preferences, such as the `Diagonal` property in this example.
Delegates
The `delegate` reference type **specifies the required signature of** **a method**.
Imagine a delegate
If you want to understand what a delegate type is, think about various ways of calculating the mean of three numbers. You can get it as an arithmetic mean, as a geometric mean, as a harmonic mean, or even as a root mean square or a power mean. However, in all of these cases, you need a function that takes three parameters (for three numbers) and returns a number as the result. In this case, you can understand a delegate as a template for a way of calculating any of the mentioned means. Then, you can prepare an exact implementation of each calculation.
The delegate could then be instantiated, as well as invoked, as shown here:
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 a = arithmetic.Invoke(5, 6.5, 7);
double g = 几何函数.Invoke(5, 6.5, 7);
double h = 调谐函数.Invoke(5, 6.5, 7);
Console.WriteLine($"{a:F2} / {g:F2} / {h:F2}");
double Harmonic(double a, double b, double c) =>
3 / ((1 / a) + (1 / b) + (1 / c));
平均委托指定了计算三个浮点数平均值所需的方法的签名。它使用以下方式实例化:
-
A
算术) -
An
几何) -
A
调谐)
每个委托都是通过调用 Invoke 方法来调用的。结果将在控制台显示,如下所示:
6.17 / 6.10 / 6.04
在这里,展示了 Lambda 表达式,因此最好告诉你更多关于这种结构的信息。它使用 => 操作符,该操作符将输入参数和 (a, b, c) 分隔开来。它们与委托的参数相同。在右侧,是 Lambda 体,它将结果计算为输入值的和除以 3。在用 C# 开发应用程序时,你将经常使用 Lambda 表达式,这是一个可以对你的代码质量产生积极影响的良好特性。
动力学
除了我们已描述的类型之外,dynamic 对开发者也是可用的。它允许你在编译时绕过类型检查,以便在运行时进行。这种机制在访问某些类型的 应用程序编程 接口(API)时可能很有用。
想象一个动态类型
如果你想更好地可视化动态类型,可以让附近的人给你蒙上眼睛,然后给你一套指令,告诉你如何从一个房间移动到另一个房间并坐在椅子上——例如,向前走 5 步,向右转,向前走 10 步,然后坐下。如果指令正确,你将到达另一个房间并舒适地坐在椅子上。然而,如果有什么错误,你不会在开始时通过听所有指令就知道,只有当你撞到墙或坐在地板上而不是椅子上时才会知道。这种情况与使用动态类型有些相似。如果指令正确,应用程序将正常工作,但如果有什么错误,可能会造成伤害。😉
在阅读这本书时,你不会使用 dynamic 类型。然而,为了简要介绍这个特性,请看一下以下代码:
dynamic posts = await GetPostsAsync();
foreach (dynamic post in posts)
{
string title = post.title;
Console.WriteLine($"Title: {title}");
}
Task<GetPostsAsync method. The result is assigned to the posts variable with the dynamic type. Thus, we bypass dynamic type allows you to significantly limit the amount of code, but it should be used with caution.
Strongly typed features are cool!
The strongly typed features of the C# language give you great development support. Both errors and warnings are useful as they help you make your code robust and more reliable. Remember that warnings are not something that should exist in the production version of your application. You should always try to decrease the number of warnings to zero, as well as take into account various hints provided by the IDE.
In the preceding code, the `await` keyword is used. It is related to `async` keyword. The `await` operator is placed in the line where the `GetPostsAsync` method is called. This means that the evaluation of the code is suspended until the asynchronous operation of getting posts from an external API is completed. Then, the `await` operator returns the collection of posts.
Asynchronous programming is cool too!
Asynchronous programming is a very interesting and powerful topic and is crucial for the development of robust and highly efficient applications. This topic also involves the `ConcurrentQueue`, which will be covered in *Chapter 5*, *Stacks and Queues*), as well as some **synchronization primitives**.
Are you ready to proceed to the last type that will be described in this chapter? If so, let’s go!
Nullable reference types
Now that we’ve come to the end of this section regarding reference types, let’s take a look at `System.NullReferenceException` type. It is thrown when you access one of the members of a variable using the dot operator (`.`), when a variable is `null`. When using a new feature, you can explicitly mark a reference type as nullable using the `?` operator, similarly as in the case of nullable value types.
Imagine a nullable reference type
If you want to understand what nullable reference types are, just remind yourself that reference types allow `null` values. So, why am I talking about nullable reference types? They are a special feature that tells you “*Be careful, it could be null!*” Using nullable reference types could sometimes seem to be an unnecessary complication for reference types. However, when using them for a longer period, you will see that they have a positive impact on your code’s quality and allow you to pay more attention to null reference issues and therefore could limit the number of errors while the program is running. I recommend that you familiarize yourself with this feature, even if it can be quite cumbersome at the beginning. I like it! What about you?
This feature is equipped with `null`, so the mechanism emits a warning that can be solved by you. The first solution is to add a conditional statement that checks whether the value is not equal to `null`. Another way is to use the `!`), when you are certain that the variable is not `null` here.
Let’s take a look at an example:
Random random = new();
List<Measurement?> measurements = [];
for (int i = 0; i < 100; i++)
{
Measurement? measurement = 随机数.Next(3) != 0
- ? new(DateTime.Now, 随机数.Next(1000) / 1000.0f)
- null;
measurements.Add(measurement);
Console.WriteLine(IsValid(measurement)
- ? 测量值!.ToString()
- "-");
await Task.Delay(100);
}
static bool IsValid(Measurement? measurement)
{
return 测量值 != null
&& 测量值.Value >= 0.0f
&& 测量值.Value <= 1.0f;
}
当从某些外部设备未能正确接收到测量值时,使用public record null。在 for 循环中,我们通过使用 Random 类来模拟获取测量值。从统计上看,大约 2/3 的总测量值被正确检索(此时创建 Measurement 实例),而剩余的 1/3 总测量值没有被检索(使用 null 代替)。这些值被添加到测量值列表中。然后,我们需要检查获得的读数是否正确——也就是说,除了提供之外,其值还应在<0.0, 1.0>的范围内。我们使用 IsValid 静态方法并传递可空的 Measurement 实例作为参数来执行此检查。
最后,我们只需在控制台显示读数信息。对于正确的读数,我们展示Measurement实例的格式化值。否则,我们使用破折号(-)。然而,如果我们编写measurement.ToString(),我们将收到一条警告信息,指出“可能为 null 的引用解引用”,因为编译器不知道我们在IsValid方法中检查测量值是否为null。我们可以通过在调用ToString方法之前,变量名后添加!符号来使用 null 忽略运算符来避免此警告。对于包含Delay方法调用的行,可能也需要一个小解释。它仅用于模拟从每个 100 毫秒读取一次测量值的设备的真实行为。
如你所见,可空值类型和可空引用类型为你提供了非常相似的语义(例如,bool?用于可空值类型和string?用于可空引用类型),但它们的实现方式不同。可空值类型在内部使用System.Nullable<T>(例如,对于bool?是System.Nullable<System.Boolean>),而非可空值类型使用另一种类型(对于bool是System.Boolean)。可空引用类型对可空和非可空变体使用相同的类型。这意味着在两种情况下,string?和string都是由System.String类提供的。
摘要
这只是本书的第一章,但它包含了在阅读剩余章节时将非常有用的信息。首先,简要介绍了C#编程语言,重点是展示各种数据类型,包括值类型和引用类型。你学习了它们之间的区别以及为什么在开发应用程序时理解这种区别如此重要。
接下来,你看到了各种值类型,包括内置的类型,例如整数数值类型、浮点数值类型、布尔类型和 Unicode 字符。然后,你学习了常量、枚举、值元组、用户定义的结构类型和可空值类型。所有这些类型都配备了详细的描述,以及一些代码示例,以便更容易、更快地理解。
最后,你学习了第二组类型,即引用类型。在这里,你看到了对象和字符串类型、类、记录、接口,以及委托和动态类型。然后,你学习了可空引用类型。同样,大量的信息都通过解释和一些代码片段得到了支持。
通过这个介绍,你应该已经准备好进入下一章,学习算法是什么以及为什么它们如此重要。让我们开始吧!
第二章:算法简介
在阅读这本书的第一章时,你了解到了各种数据类型。现在,是时候介绍算法这一主题了。在这一章中,你将了解它们的定义,以及一些现实世界的例子、表示法和类型。由于你应该注意应用程序的性能,因此算法的计算复杂性,包括时间复杂性,也将被介绍和解释。
首先,值得指出的是,算法这一主题非常广泛且复杂。你可以在互联网上轻松找到大量关于它们的科学出版物,这些出版物由来自世界各地的研究人员发表。算法的数量是巨大的,甚至记住所有常用算法的名称都几乎是不可能的。当然,有些算法易于理解和实现,而有些则极其复杂,没有对算法学、数学和其他相关科学领域的深入了解几乎无法理解。还有根据不同关键特征的算法分类,有很多类型,包括递归、贪婪、分而治之、回溯和启发式。然而,对于各种算法,你可以通过说明它们在处理输入大小增加时所需的时间和空间来指定计算复杂性。
这听起来是不是令人感到压倒性、复杂和困难?别担心。在这一章中,我将尝试以每个人都能理解的方式介绍算法这一主题,而不仅仅是数学家或其他科学家。因此,在这一章中,你会发现一些简化,以便使这个主题更简单、更容易理解。然而,目标是让你对这个主题感兴趣,而不是创建另一本包含大量正式定义和公式的学术出版物或书籍。你准备好了吗?让我们开始吧!
在这一章中,我们将涵盖以下主题:
-
什么是算法?
-
算法表示的符号
-
算法的类型
-
计算复杂性
什么是算法?
你知道吗,你通常每天都会使用算法,甚至在没有编写任何代码或绘制图表的情况下,你已经是某些算法的作者了吗?如果这听起来不可能,给我几分钟时间,阅读这一节来了解这是如何可能的。
定义
首先,你需要知道什么是算法。它是一个为解决特定问题或执行计算而定义良好的解决方案。它是一系列按给定顺序执行的精确指令,考虑(如果有)一个定义良好的输入,以产生一个定义良好的输出,如下所示:

图 2.1 – 算法的示意图
要更精确地说,算法应该包含有限的一系列明确指令,这为你提供了一种有效且高效地解决问题的方法。当然,算法可以包含条件表达式、循环或递归。
哪里可以找到更多信息?
如果你对这个算法主题感兴趣,你可以在许多书中找到关于它们的详细信息,包括托马斯·H·科门、查尔斯·E·利瑟森、罗纳德·L·里维斯和克利福德·斯坦因合著的《算法导论》。当然,还有许多在线资源,如GeeksForGeeks (www.geeksforgeeks.org)、The Algorithms (the-algorithms.com)和罗伯特·赛奇威克与凯文·韦恩合著的《算法,第 4 版》(algs4.cs.princeton.edu)。如果你在 GitHub 上浏览算法主题,你还可以找到大量的资源(github.com/topics/algorithms)。我强烈建议你在阅读完这本书后,在书籍或互联网上搜索各种资源,并继续学习算法。
现实世界例子
在掌握算法的定义后,你可能正在想,“好吧——输入、输出、指令……我在哪里能找到它们?”答案比你想象的要简单得多,因为你可以几乎在任何地方、任何时候找到这些项目!
让我们从简单的早晨例行程序开始。首先,你醒来,看看你的手机。如果有任何通知,你会浏览它们并回复紧急信息。对于任何非紧急事项,你会推迟它们。然后,你去洗手间。如果它被占用,你会等待直到它空闲,告诉里面的人快点。一旦你在洗手间里,你就洗澡并刷牙。最后,你根据当前的天气和温度选择合适的衣服。惊喜!你的早晨例行程序就是一个算法。你可以将其描述为一系列指令,它有一些输入,例如通知和当前温度,以及输出,例如选择的衣服。更重要的是,一些指令是条件性的,例如只回复紧急信息。其他指令可以在循环中执行,例如等待洗手间空闲。
上述早晨例行程序还包含其他算法,例如使用面部识别解锁智能手机的算法。这是一个基于算法的机制,你可以用它来确保只有你才能解锁你的手机。更重要的是,甚至在手机上组织通知也是考虑通知作为输入,将它们分组并适当排序后呈现给你的算法的结果。
到这个时候,你已经穿戴整齐,准备享用一顿健康美味的早餐。想象一下,你想要用你奶奶的秘密食谱准备炒鸡蛋。你需要一些原料,即三个鸡蛋、盐和胡椒。结果,你将为你完美的早餐制作出一道惊人的菜肴。首先,你将鸡蛋敲入碗中,并用一小撮盐和胡椒搅拌。然后,你在中火至小火的平底锅中融化黄油。接下来,你将鸡蛋混合物倒入平底锅中,并保持鸡蛋移动,直到没有液态鸡蛋。这样,你的早餐就准备好了。然而,如果没有一个写得很好且组织有序的算法,具有精确的输入和美味的输出,那它又是什么呢?
早餐后,你需要去上班。所以,你跳进你的车,在智能手机上启动导航应用,查看考虑到当前交通情况的最快路线。这个任务由复杂的算法完成,甚至可能涉及人工智能(AI),以及使用专用数据结构表示的计算机可理解的路由,以及从其他用户那里获得的数据。当它们结合在一起时,就形成了交通数据。正如你所见,算法对复杂的输入进行各种计算,为你提供一个有序的路线方向列表——例如,前往 A4 路线,右转至 S19 路线,并沿着这条路线直到你到达目的地。
在工作中,你需要为你的会计师准备文件,因此你需要从同事那里收集文件,从电子邮件中打印一些,然后按编号对所有发票进行排序。你如何进行排序?你从文件堆中取出第一份文件并将其放在桌子上。然后,你从未排序的文件堆中取出第二份文件,如果编号小于第一张发票,就把它放在上面,否则就放在上一张下面。接着,你取出第三张发票,并在有序的文件堆中找到合适的位置。你一直这样做,直到未排序的文件堆中没有文件为止。哇,又一个算法?没错!这正是排序算法之一。你将在下一章中了解它们。
工作时间到了,该休息一下了!你打开你最喜欢的社交应用,收到新朋友的建议。然而,他们是如何被找到并推荐给你的呢?是的,你是对的——这又是另一个算法,它从你的个人资料和活动数据以及可用用户的数据中获取输入,并为你返回一系列最适合的建议。它可以使用许多复杂和高级的技术,例如机器学习(ML)算法,这些算法可以学习并考虑你之前的反应。想想看,在这种情况下可以使用哪些数据结构。你如何组织与朋友的关系,以及你如何找出有多少其他人在你和好莱坞你最喜欢的演员之间?知道你的朋友认识玛丽,玛丽认识亚当,亚当是你的偶像的朋友,这不是很好吗?这样的任务可以使用一些图结构来完成,正如你将在本书后面看到的。
你会在本书中学到 AI 算法吗?
很遗憾,不是的。由于页面有限,本书没有包括与 AI 相关的各种算法。然而,请注意,这是一个非常有趣的话题,涉及许多概念,例如机器学习(ML)和深度学习(DL),这些在许多应用中使用,包括推荐系统、语音转文本、在极大量数据上搜索(大数据的概念)、生成文本和图形内容,以及控制自动驾驶汽车。为了实现这些目标,使用了大量有趣的算法。我强烈建议你亲自研究这个话题,或者选择 Packt 出版的专注于 AI 相关主题的书籍之一。
这些例子足够了吗?如果还不够,就想想在晚上去电影院看电影时选择一部电影,同时考虑基于电影院地理位置数据的电影推荐,或者根据你第二天计划的设定闹钟。正如你所看到的,算法无处不在,我们都在使用它们,即使我们没有意识到。
那么,如果算法如此普遍且如此有用,我们为什么不从可用的巨大算法集合中受益,甚至编写我们自己的算法呢?仍然有一些问题需要使用算法来解决。作为这本书的作者,我衷心希望你能解决这些问题!
算法表示的符号
在上一节中,算法是用英语表示的。然而,这并不是指定和记录算法的唯一方式。在本节中,你将了解四种算法表示的符号,即自然语言、流程图、伪代码和编程语言。
为了使这个任务更容易理解,你将使用所有这些符号指定计算算术平均值的算法。作为提醒,平均值可以使用以下公式计算:

图 2.2 – 计算算术平均数的公式
如您所见,使用了两个输入,即提供的数字(a)和元素总数(n)。如果没有提供数字,则返回null,表示没有可用的平均值。否则,您将数字相加,然后除以元素总数以获得结果。
自然语言
首先,让我们用自然语言指定算法。这是一种提供算法信息非常简单的方法,但它可能含糊不清。因此,让我们这样描述我们的算法:
该算法读取输入,表示将从其中计算算术平均数的元素总数。如果输入的数字等于 0,则算法应返回 null。否则,它应读取等于预期总数量的数字。最后,它应以数字的总和除以它们的数量作为结果返回。
非常简单易懂,不是吗?您可以使用这种符号表示简单的算法,但对于复杂和高级算法可能毫无用处。当然,无论算法的复杂程度如何,自然语言中的某些描述通常是有用的。它们可以为您提供算法目的的简要了解,以及算法的工作原理和您在分析或实现算法时应考虑的方面。
流程图
另一种表示算法的方法是通过流程图。流程图使用一组图形元素来准备一个指定算法操作的图表。以下是一些可用的符号:

图 2.3 – 设计流程图时可用符号
算法应包含入口点和一个或多个出口点。它还可以包含其他块,包括操作、输入、输出或条件。以下块通过箭头连接,指定执行顺序。您还可以绘制循环。
让我们看看计算算术平均数的流程图:

图 2.4 – 计算算术平均数的流程图
执行从START块开始。然后,我们将sum变量的值赋为0,该变量存储所有输入数字的总和。接下来,我们从输入读取一个值并将其存储为n变量的值。这是用于计算算术平均数的元素总数。接下来,我们检查n是否等于0。如果是这样,则选择YES分支,将null返回到输出,并停止执行。如果n不等于0,则选择NO分支,并将i变量的值赋为0。它存储已从输入中读取的元素数量。接下来,我们从输入读取一个数字并将其保存为a变量的值。接下来的操作块将sum增加a的值,并增加i的值。
下一个块是一个条件块,它检查i是否不等于n,这意味着所需的元素数量尚未从输入中读取。如果i等于n,则选择NO分支,并将result变量的值设置为sum除以n的结果。然后,返回result变量并停止执行。当条件表达式评估为true时,使用了一个有趣的构造,这意味着我们需要读取另一个输入。然后,使用循环,执行回到读取a的输入块之前。因此,我们可以多次执行某些操作,直到满足条件。
如你所见,流程图是一种图表,它以比自然语言更精确的方式指定算法的操作方式。对于简单的算法来说,这是一个有趣的选择,但在高级和复杂的算法中,它可能相当繁琐,因为不可能在合理大小的图表中展示整个操作。
模拟代码
我们接下来要探讨的下一个表示法是模拟代码。它允许你以另一种方式指定算法,这与编写在编程语言中的代码有点相似。在这里,我们使用英语来定义输入和输出,以及清晰地、简洁地展示一系列指令,但不包含任何编程语言的语法。
这里有一些用于计算算术平均数的模拟代码示例:
INPUT:
n – total number of elements used for mean calculation.
a – the following numbers entered by a user.
OUTPUT:
result - arithmetic mean of the entered numbers.
INSTRUCTIONS:
sum <- 0
read n
if n = 0 then
return null
endif
i <- 0
do
read a
sum <- sum + a
i <- i + 1
while i <> n
result <- sum / n
return result
如你所见,模拟代码为我们提供了一种易于理解和遵循的语法,以及相当接近编程语言的语法。因此,它是一种精确的算法表示和文档化方式,可以后来将其转换为所选编程语言中的一系列指令。
编程语言
现在,让我们来看算法表示法的最后一种形式:编程语言。它非常精确,可以编译和运行。因此,我们可以看到其操作的结果,并使用一组测试案例进行检查。当然,我们可以用任何编程语言实现算法。然而,在这本书中,你将只会看到用 C#语言编写的示例。
让我们看看均值计算算法的实现:
double sum = 0;
Console.Write("n = ");
int.TryParse(Console.ReadLine(), out int n);
if (n == 0) { Console.WriteLine("No result."); }
int i = 0;
do
{
Console.Write("a = ");
double.TryParse(Console.ReadLine(), out double a);
sum += a;
i++;
}
while (i != n);
double result = sum / n;
Console.WriteLine($"Result: {result:F2}");
之前的代码包含一个if条件语句和一个do-while循环。
如果我们运行应用程序,我们需要输入要计算算术平均值的元素数量。然后,我们将被要求输入数字n次。当提供的元素数量等于预期值时,结果将在控制台计算并显示,如下所示:
n = 3
a = 1
a = 5
a = 10
Result: 5.33
就这些!现在,你知道了算法是什么,你可以在日常生活中找到它们,以及如何使用自然语言、流程图、伪代码和编程语言来表示算法。有了这些知识,让我们继续学习不同类型的算法,包括递归和启发式算法。
算法类型
如前所述,算法几乎无处不在,甚至直观上,你在解决各种任务时每天都在使用它们。有大量的算法和许多类型,根据不同的标准进行选择。为了简化这个主题,这里将只展示一些类型,这些类型来自不同的分类,以展示多样性并鼓励你自己了解更多。同样,同一个算法也可能被归入几个不同的组。
递归算法
首先,让我们看看递归算法,它与递归的概念紧密相连,是迭代算法的对立面。这意味着什么?如果一个算法通过调用自身来解决原始问题的较小子问题,则该算法是递归的。算法会多次调用自身,直到满足基本条件。
这种技术为你提供了解决问题的强大解决方案,可以限制代码量,并且易于理解和维护。然而,递归有一些与性能或栈内存空间需求相关的缺点,这可能导致栈溢出问题。幸运的是,你可以使用动态规划来预防这些问题,这是一种支持递归的优化技术。
递归可以在多个算法中使用,包括以下:
-
使用归并排序和快速排序算法对数组进行排序,这些算法在第三章中详细实现和介绍,数组和排序
-
解决汉诺塔游戏,如第五章中所示,栈和队列
-
遍历树,如第七章中所述,树的变体
-
从斐波那契数列中获取一个数,如第九章中所示,实战 见
-
生成分形,如第九章中所示,实战 见
-
计算
n! -
使用欧几里得算法计算两个数的最大公约数
-
遍历文件系统中的目录和子目录
分而治之算法
另一组算法被称为分而治之。它与通过将问题分解为更小的子问题(“分”步骤)来解决问题的算法范式相关,递归调用它们,直到它们足够简单可以直接解决(“治”),然后将子问题的结果组合起来以获得最终结果(“合”)。这种方法具有许多优点,也借鉴了递归的优点,包括易于实现、理解和维护。通过将问题分解为许多子问题,它支持并行计算,这可能导致性能提升。不幸的是,这种范式也有一些缺点,包括需要正确定义基本案例以终止算法的执行。与递归算法类似,也可能存在性能问题。
分而治之是解决各种算法问题的流行方法,您可以在广泛的应用程序中看到其实现:
-
使用归并排序和快速排序算法对数组进行排序,这些算法在第三章中实现并详细介绍,数组和排序
-
找到位于二维表面上的最近点对,这将在第九章中介绍,见 实际操作
-
计算一个数的幂
-
在数组中找到最小值和最大值
-
计算快速傅里叶变换
-
使用 Karatsuba 算法进行大数乘法
回溯算法
接下来,我们将介绍回溯算法。它们用于解决由一系列决策组成的问题,每个决策都依赖于已经做出的决策,逐步构建解决方案。当您意识到已经做出的决策不能提供正确的解决方案时,您 回溯。当然,您可以使用递归来支持这种方法,尝试各种变体,从而找到合适的解决方案,如果存在的话。
您可以使用这种方法来完成许多任务,包括以下内容:
-
解决迷宫中的老鼠问题,如第九章中所示,见 实际操作
-
如第九章中所示,解决数独,见 实际操作
-
通过在空格中输入字母来解决填字游戏
-
解决八皇后问题,即在棋盘上放置八个皇后,并确保它们不能相互攻击
-
解决骑士巡游问题,其中您在棋盘的第一个方块上放置一个骑士,并移动它以便它恰好访问所有方块一次
-
生成格雷码以创建只有一位不同的位模式
-
解决与图相关主题的m-着色问题
贪心算法
现在我们已经介绍了递归、分治和回溯算法,是时候介绍另一种类型了,即贪心算法。贪心算法通过在每一步选择最佳选项来逐步构建解决方案,不考虑整体解决方案,并且在操作上具有短视性。因此,无法保证最终结果是最优的。然而,在许多场景中,使用局部最优解可以导致全局最优解,或者足够好。
这里有一些例子:
-
使用迪杰斯特拉算法在图中找到最短路径,如第八章《探索图论》中所示并详细解释。
-
如第八章《探索图论》中所示,使用克鲁斯卡尔算法和普里姆算法计算图中的最小生成树
-
解决最小硬币找零问题,如第九章《见行动》中解释。
-
数据压缩算法中霍夫曼编码的贪心方法
-
负载均衡和网络路由
启发式算法
现在,是时候通过启发式算法给你的算法添加更多“魔法”了!启发式算法为优化问题计算一个近似最优解,特别适用于精确方法不可用或太慢的场景。因此,你可以看到显著的加速,但结果的准确性会降低。这种方法在解决各种现实世界问题中很受欢迎且适用,这些问题通常很复杂且规模很大,并且应用于许多不同的科学领域,甚至包括生物信息学。
启发式算法有许多应用和子类型:
-
遗传算法,这是一种自适应启发式搜索算法,可以用来猜测这本书的标题,如第九章《见行动》中所示。
-
使用禁忌搜索算法解决车辆路径问题和旅行商问题
-
解决背包问题,其中需要选择具有最大总价值的物品,在质量限制内打包
-
过滤和处理信号
-
检测病毒
动态规划
由于我们正在讨论各种类型的算法,值得提一下动态规划。这是一种通过限制重复计算相同结果的需要来优化递归算法的技术。这种技术可以使用两种方法之一:
-
自顶向下的方法,它使用记忆化来保存子问题的结果。因此,算法可以使用缓存中的值,无需多次重新计算相同的结果,或者无需多次调用具有相同参数的方法。
-
自底向上的方法,它使用表格来用迭代替换递归。它限制了函数调用的次数,并解决了栈溢出的问题。
这两种方法都可以显著降低时间复杂度并提高性能,因此可以加快你的算法。每次使用递归时,尝试使用动态规划优化它都是一个好主意。如果你想学习如何优化计算斐波那契数列中的数字,请参阅第九章,见 实战。
你还可以使用动态规划通过使用Floyd-Warshall 算法在图中找到所有顶点对之间的最短路径,以及在Dijkstra 算法中。另一个应用是解决汉诺塔数学游戏。可能性甚至更广泛,你还可以将其应用于人工****神经网络。
暴力算法
当我们介绍各种类型的算法时,我们也应该考虑暴力算法。暴力算法是解决一个问题的通用解决方案,通过检查所有可能的选项并选择最佳选项。这是一种可能具有巨大时间复杂度的方法,其操作可能需要很长时间,因此在现实场景中可能毫无用处。然而,当需要解决某些算法问题时,暴力算法通常是首选。这样做并没有什么不好,因为你可以更多地了解你想要解决的领域,并看到一些简单情况的结果。然而,在开发算法时,使用其他范式显著改进它是明智的。
这里有一些你可以使用暴力算法的例子:
-
猜测密码,正如在第九章**, 见 实战中所述,逐个检查每个可能的密码
-
在未排序数组中寻找最小值,因为你需要遍历所有项,因为数组中的值之间没有关系
-
寻找一天中最佳的计划,在会议之间安排各种任务,并尝试以你可以晚些开始工作并早点结束的方式组织它
-
解决旅行商问题
在介绍了几种算法类型之后,你会发现其中一些提供了更快的解决方案,而另一些则可能具有巨大的时间复杂度。但这意味着什么呢?你将在下一节中学习关于计算复杂度,特别是时间复杂度。
计算复杂度
在本节的最后,让我们看看算法的计算复杂度,重点关注时间复杂度和空间复杂度。为什么这如此重要?因为它可以决定你的算法是否可以在现实场景中使用。例如,以下哪个是你更喜欢的?
-
(A) 绝对是最好的工作路线方向,但你是在已经在工作后一个小时才收到它们的。
-
(B) 足够好的工作路线方向,但你是在进入你的车后几秒钟内收到的。
我确信你选择了B – 我也是!
时间复杂度
首先,让我们关注时间复杂度,它表示算法作为输入长度函数所需的时间量,即n。你可以使用渐近分析来指定它。这包括大 O 符号,用于表示算法随着输入大小的增加将花费多少时间。
例如,如果你在一个大小为n的无序数组中搜索最小值,你需要访问所有元素,因此最大操作数等于n,表示为O(n)。如果算法遍历一个大小为n x n的二维数组中的每个项目,时间复杂度是O(nn),所以它是O(n²)*。
存在着各种时间复杂度,包括这里展示的:

图 2.5 – 时间复杂性的说明
第一个是O(1),被称为常数时间。它表示一个执行时间不依赖于输入大小的算法。符合O(1)约束的典型操作包括从数组中获取第i个元素、检查哈希集合是否包含给定值或检查一个数字是偶数还是奇数。
这里接下来展示的时间复杂度是O(log n),被称为对数时间。在这种情况下,执行时间不是常数,但它的增长速度比线性方法慢。O(log n)约束的一个著名例子是在排序数组中使用二分查找来查找一个项目。
第三种情况是O(n),被称为线性时间。在这里,执行时间与输入长度成线性增长。你可以将找到无序列表中最小或最大值的算法或简单地找到无序列表中的给定值作为O(n)约束的例子。
这里最后展示的时间复杂度是多项式时间,表示为O(n^m),所以它可以表示为O(n²)(二次时间)、O(n³)(三次时间)等等。在这种情况下,执行时间比线性约束的情况增长得更快。它可以涉及使用嵌套循环的解决方案。例如,冒泡排序、插入排序和选择排序算法。我们将在第三章中介绍这些,数组和排序。
当然,还有更多的时间复杂度可用,其中包括双对数时间、多项对数时间、分数幂时间、线性对数时间、指数时间和阶乘时间。
空间复杂度
与时间复杂度类似,你可以使用渐近分析和大 O 符号来指定空间复杂度。空间复杂度表示随着输入长度的增加,运行算法所需的内存量。你可以使用类似的指标,如O(1),O(n),或O(n²)。
你在哪里可以找到更多信息?
在本章中,只对算法的主题进行了非常简短的介绍。我强烈建议你尝试自己扩展关于算法的知识。这是一个极其有趣且具有挑战性的主题。例如,你可以在www.techtarget.com/whatis/definition/algorithm和www.geeksforgeeks.org/most-important-type-of-algorithms/了解更多关于各种算法的类型,而在en.wikipedia.org/wiki/Computational_complexity了解计算复杂性。我衷心祝愿你在算法学习上取得成功!
摘要
你刚刚完成了这本书的第二章,本章全部关于 C#语言中的数据结构和算法。这次,我们专注于算法,并指出了它们在各种应用开发中的关键作用,无论其类型如何。
首先,你学习了什么是算法以及在日常生活中你可以在哪里找到算法。正如你所看到的,算法几乎无处不在,你甚至在不自知的情况下使用和设计它们。
然后,你学习了算法表示的符号。在那里,你学习了如何以几种方式指定算法,即在自然语言中、使用流程图、通过伪代码或直接在编程语言中。
接下来,你学习了关于几种不同类型的算法,从递归算法开始,这些算法通过调用自身来解决更小的子问题。然后,你学习了分而治之算法,它将问题分为三个阶段,即分解、征服和合并。接下来,你学习了回溯算法,它允许你解决由一系列决策组成的问题,每个决策都依赖于已经做出的决策,以及如果决策不提供正确解决方案时的回溯选项。然后,你学习了贪心算法,它们在操作的每一步都选择最佳选项,而不关心整体解决方案。你学习的一组算法还包括用于寻找近似最优解的启发式算法。然后,你学习了如何使用动态规划及其自顶向下和自底向上的方法来优化递归算法。最后,你学习了暴力算法。
本章的最后一部分探讨了计算复杂性,从时间和空间复杂性的角度进行。介绍了渐近分析和大 O 符号。
在下一章中,我们将介绍数组和多种排序算法。你准备好继续在 C#语言中探索数据结构和算法的冒险了吗?如果是的话,那就让我们出发吧!
第三章:数组和排序
作为一名开发者,您肯定在您的应用程序中存储了各种集合,例如用户数据、书籍和日志。存储此类数据的一种自然方式是使用数组。然而,您是否曾想过它们的变体?例如,您听说过锯齿形数组吗?在本章中,您将看到数组在实际中的应用,包括示例和详细描述。
您可以使用数组来存储int、string,以及用户自定义的类或记录。只需记住,数组在初始化后其元素数量不能改变。因此,您无法轻松地在数组的末尾添加新项或在数组中指定位置插入元素,同时将剩余项移动一个位置。如果您需要此类功能,可以使用另一种数据结构,即列表及其变体,这些将在下一章中描述。
在用 C#语言开发应用程序时,您可以受益于几种数组的变体,即单维数组、多维数组和锯齿形数组。在本章中,您还将了解七种排序算法,即选择排序、插入排序、冒泡排序、归并排序、希尔排序、快速排序和堆排序。对于每种算法,您将看到基于图示的示例、实现代码和逐步解释。您还将看到它们的性能分析,以图表的形式展示。
在本章中,我们将涵盖以下主题:
-
单维数组
-
多维数组
-
锯齿形数组
-
排序算法
单维数组
让我们从数组最简单的变体开始,即单维数组。第一个元素的索引为0,而最后一个元素的索引等于数组的长度减一。
想象一个单维数组
如果您想更好地想象单维数组,请暂时将目光从这本书上移开,看看您房间里的抽屉柜或衣柜。一个标准的抽屉柜由几个抽屉组成,单维数组看起来很相似。它也有几个元素(即抽屉),可以通过索引访问。您不能像改变抽屉数量一样改变数组的大小,因为家具已经准备好了。数组相对于抽屉柜有一个显著的优势,即所有的“抽屉”总是按预期工作。
以下图显示了单维数组的示例:

图 3.1 – 单维数组的示例
它包含五个元素,其值分别为9、-11、6、-12和1。第一个元素的索引等于0,而最后一个元素的索引等于4。
要使用一维数组,你需要声明和初始化它。声明非常简单,因为你只需要指定元素类型和名称,如下所示:
type[] name;
整数数组的声明如下所示:
new operator, as shown here:
numbers = new int[5];
Of course, you can combine a declaration and initialization in the same line, as follows:
int[] numbers = new int[5];
Unfortunately, all the elements currently have default values – that is, zeros in the case of integer values. Thus, you need to set the values of particular elements. You can do this using the `[]` operator and an index of an element, as shown in the following code:
numbers[0] = 9;
numbers[1] = -11;
numbers[2] = 6;
numbers[3] = -12;
numbers[4] = 1;
Moreover, you can combine a declaration and initialization of array elements to specific values using one of the following variants:
int[] numbers = new int[] { 9, -11, 6, -12, 1 };
int[] numbers = { 9, -11, 6, -12, 1 };
Another approach involves using the **collection expression**, as follows:
int[] numbers = [] 运算符和指定索引,如下所示的一行代码:
int middle = 2) from the numbers array and store it as a value of the middle variable.
The array has some properties that can be useful while developing applications. For example, the `Length` property makes it possible to get the size of the array, namely the number of elements stored within it. If you want to access the last item in the array, regardless of its size, you can use the following line of code:
int last = numbers[numbers.Length - 1];
You can simplify this with the **index operator**, as follows:
int last = numbers[²],第三个通过 [³],以此类推。
另一个属性名为 Rank,返回数组的维度数。此属性的用法如下所示的一行代码:
int rank = numbers.Array class, such as Exists, to check whether there is any element in the array that matches the given predicate. For example, you can easily verify whether the array contains any element whose value is greater than zero, as follows:
bool anyPositive = Array.TrueForAll 检查是否所有元素都满足提供的谓词,例如确保数组中没有零:
bool noZeros = Array.Find method:
int firstNegative = Array.FindAll 方法。以下代码展示了如何获取所有负数:
int[] negatives = Array.IndexOf method, which returns an index of the first found occurrence of the value or -1, if not found:
int index = Array.ForEach。它允许你对数组中的所有元素执行一些操作。例如,你可以用它将每个数组元素的绝对值写入控制台,如下面的代码所示:
Array.ForEach(numbers,
e => Console.WriteLine(Math.Abs(e)));
如你所见,即使对于像一维数组这样简单的数据结构,你也有很多有用的内置功能。让我们继续学习它们,并看看接下来的两种方法,即 Reverse 和 Sort。根据它们的名称,第一个允许你反转元素顺序,无论是整个数组还是仅在某些范围内。以下代码展示了如何反转前三个元素:
Array.Sort method has even more variants. In its simplest form, it sorts the whole array. After running the following line, you’ll get the array with the elements sorted from the smallest to the biggest:
Array.for 循环并简单地遍历合适的索引并分配给定的值。然而,你可以使用 Fill 方法。以下行将 3 作为数组中所有元素的值:
Array.Clear, which makes it possible to clear the whole array or a range of its elements. For example, you can fill the whole array with the default value of the integer type, namely zeros, using the following line of code:
Array.Copy,它将源数组中的一系列元素复制到目标数组。你可以使用几种变体之一,例如指定两个数组中的索引。例如,让我们从 numbers 数组(作为源数组)复制 3 个元素(指定为长度),从第一个元素(源索引设置为 0)开始,并将它们放置在 subarray 数组中,从第一个元素(目标索引设置为 0)开始:
int[] subarray = new int[3];
Array.Contains and Max.
Have you ever heard about extension methods?
If not, think of them as methods that are “added” to a particular existing type (both built-in or user-defined) and can be called in the same way as when they are defined directly as instance methods. The declaration of an extension method requires you to specify it within a static class as a static method with the first parameter indicating the type to which you want to “add” this method with the `this` keyword.
You can use the `Contains` extension method to check whether the array contains an element passed as the parameter. As an example, let’s learn how to ensure that the `numbers` array contains `6` as one of its elements:
bool contains = numbers.Contains 方法不是唯一可用的扩展方法。其中,你可以找到 All 和 Any。第一个(All)检查所有元素是否与给定的谓词匹配,而另一个(Any)验证是否至少有一个元素满足条件。你可以使用它们来确保数组中没有零并检查是否至少有一个正元素,如下所示:
bool noZeros = numbers.All(n => n != 0);
bool anyPositive = numbers.Min and Max extension methods, as shown here:
int min = numbers.Min();
int max = numbers.Average 和 Sum 方法,它们可以轻松计算所有元素的平均值以及它们的总和:
double avg = numbers.Average();
int sum = numbers.Sum();
在对一维数组进行简短介绍之后,现在是时候看看如何在现实场景中应用这样的数组了。
你在哪里可以找到更多信息?
你可以在C#语言中关于数组和它们各种变体的有趣信息中找到很多。
示例 – 月份名称
总结一下关于一维数组的所学内容,我们可以用一个数组来存储英文月份的名称。这些名称应该自动获取,而不是通过在代码中硬编码它们。
实现如下所示:
using System.Globalization;
CultureInfo culture = new("en");
string[] months = new string[12];
for (int month = 1; month <= 12; month++)
{
DateTime firstDay = new(DateTime.Now.Year, month, 1);
string name = firstDay.ToString("MMMM", culture);
months[month - 1] = name;
}
foreach (string m in months)
{
Console.WriteLine(m);
}
首先,你创建一个新的CultureInfo类实例(来自System.Globalization命名空间),传递en作为参数,以便稍后获取英文月份的名称。然后,你声明一个新的单维数组,并用默认值初始化它。它包含12个元素,用于存储一年中所有月份的名称。然后,使用for循环遍历所有月份的数字——即从1到12。对于每一个,创建一个表示当前年份特定月份第一天的DateTime实例。
通过在DateTime实例上调用ToString方法,并传递适当的日期格式(MMMM)以及指定文化,来获取月份的名称。然后,使用[]操作符和元素的索引将名称存储在数组中。值得注意的是,索引等于month变量的当前值减一。这种减法是必要的,因为数组中的第一个元素的索引等于零,而不是一。
代码中下一个有趣的部分是foreach循环,它遍历数组的所有元素。对于每一个元素,月份的名称会在控制台显示:
January
February (...)
December
如前所述,一维数组并不是唯一可用的变体。你将在下一节中了解更多关于多维数组的内容。
多维数组
在 C#语言中,数组不需要只有一个维度。你可以创建二维数组。正如你将看到的,多维数组非常有用,并且在开发各种应用程序时经常被使用。
想象一个二维数组
如果你想想象一个二维数组,请休息一下,闭上眼睛,玩玩数独。如果你不知道这是什么,数独是一种流行的游戏,要求你用 1 到 9 的数字填充 9x9 葫芦娃的空格。然而,每一行、每一列以及每一个 3x3 的方块只能包含唯一的数字。惊喜!这个板子形成了一个二维数组!你可以通过指定其 行 和 列 来指向板上的任何位置,就像在二维数组中一样。如果你对用铅笔和一张纸解决这样的谜题感到有点厌倦,请看看 第九章,实战,在那里你将学习如何创建解决数独谜题的算法!
这里展示了一个存储整数值的二维数组示例:

Figure 3.2 – Example of a two-dimensional array
首先,你需要声明并初始化一个具有 5 行和 3 列的二维数组,如下面的代码行所示:
int[,] numbers = new int[5, 3];
numbers[0, 0] = 9; (...)
你也可以以稍微不同的方式将声明和初始化结合起来:
int[,] numbers = new int[,]
{
{ 9, 5, -9 },
{ -11, 4, 0 },
{ 6, 115, 3 },
{ -12, -9, 71 },
{ 1, -6, -1 }
};
对于从二维数组中访问特定元素的方式,需要做一些简单的解释。让我们看看以下示例:
int number = numbers[2, 1];
numbers2) and second column (index equal to 1) is obtained (that is, 115) and set as a value of the number variable. The other line replaces -11 with 11 in the second row and the first column.
Now that you’ve learned about one-dimensional and two-dimensional arrays, let’s proceed to three-dimensional ones. Do you know how to understand this structure?
Imagine a three-dimensional array
If you want to better imagine a three-dimensional array, launch a game in which you can create buildings from blocks. You place each of them in a specified location on the board, in *X* and *Y* coordinates. However, you can also build the next building floors, so you can specify the block’s *Z* coordinate as well. In such circumstances, you operate in a three-dimensional world with three-dimensional arrays!
An example three-dimensional array is presented in the following figure:

Figure 3.3 – Example of a three-dimensional array
If you want to create a three-dimensional array, you can use the following code:
int[,,] numbers = new int[3, 2, 3];
The remaining operations can be performed similarly as in the case of arrays with a different number of dimensions. Of course, you need to specify three indices while accessing a particular element of the array.
So far, you’ve learned about one-, two-, and three-dimensional arrays. But is it possible to use four-dimensional arrays? Of course!
Imagine a four-dimensional array
Imagining a four-dimensional array is not very easy, but let’s try to do so! Once again, think about the three-dimensional game board we mentioned previously, but with content that changes depending on your level in the game. In this way, you can access a particular block in the three-dimensional world using *X*, *Y*, and *Z* coordinates. To get a target value, you need to use another dimension, namely by providing your current level. In this way, you will get different results depending on the fourth dimension. Not so difficult, right?
You can declare such an array using the following line of code:
int[,,,] numbers = new int[5, 4, 3, 2];
If you need more dimensions, you can apply them. However, please keep in mind that using more dimensions can be quite difficult to understand and your code can be more difficult to follow and maintain in the future.
With this introduction to the topic of multi-dimensional arrays out of the way, let’s proceed to some examples. They will show you how to use such data structures in the real world.
Example – multiplication table
This first example shows basic operations being performed on a two-dimensional array to present a multiplication table. It stores the results of the multiplication of all integer values in the range from `1` to `10` in the array and present them in the console:
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
Let’s take a look at the declaration and initialization of the array:
int[,] results = new int[10, 10];
Here, a two-dimensional array with `10` rows and `10` columns is created and its elements are initialized to default values – that is, to zeros. When the array is ready, you fill it with the results of the multiplication, as well as present the result in the console. Such a task can be performed using two `for` loops, as shown here:
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);
Console.Write($"{results[i, j],4}");
}
Console.WriteLine();
}
In the preceding code, you can see the `GetLength` method, which is called on the `results` array. This method returns the number of elements in a particular dimension – that is, the first (when passing `0` as the parameter) and the second (`1` as the parameter). In both cases, a value of `10` is returned, according to the values specified during the array’s initialization. Another important part of the code is the way of setting the value of an element. To do so, you must provide two indices.
The multiplication results, after converting them into `string` values, have different lengths, from one character (as in the case of `4` as a result of `2*2`) to three (`100` from `10*10`). To improve their presentation, you need to write each result in `4` characters. Therefore, if an integer value takes less space, leading spaces should be added. As an example, `1` will be shown with three leading spaces (`___1`, where `_` is a space), while `100` will be shown with only one space (`_100`). You can achieve this goal by using a proper composite format string (namely, `,4`) within the interpolated string.
Example – game map
Another example is a program that presents a map of a game. This map is a rectangle with 6 rows and 8 columns. Each element of the array specifies a type of terrain as grass, sand, water, or brick (also referred to as wall). Each place on the map should be shown in a particular color (such as green for grass), as well as using a custom character that depicts the terrain type (such as `≈` for water), as shown in the following figure:

Figure 3.4 – Screenshot of the game map example
Let’s start by creating two auxiliary methods that make it possible to get a particular color and character depending on the terrain’s type (`GetColor` and `GetChar`, respectively). The code for these methods is as follows:
ConsoleColor GetColor(char terrain)
{
return terrain switch
{
'g' => ConsoleColor.Green,
's' => ConsoleColor.Yellow,
'w' => ConsoleColor.Blue,
_ => ConsoleColor.DarkGray
};
}
char GetChar(char terrain)
{
return terrain switch
{
'g' => '\u201c',
's' => '\u25cb',
'w' => '\u2248',
_ => '\u25cf'
};
}
As you can see, the code of the `GetColor` method is self-explanatory. However, the `GetChar` method returns a proper Unicode character depending on the character’s value (`g`, `s`, `w`, or `b`). For example, in the case of water, the `'\u2248'` value is returned, which is a representation of the `≈` character.
Let’s take a look at the remaining part of the code. Here, you configure the map, as well as present it in the console. The code is as follows:
using System.Text;
char[,] map =
{
{ 's', 's', 's', 'g', 'g', 'g', 'g', 'g' },
{ 's', 's', 's', 'g', 'g', 'g', 'g', 'g' },
{ 's', 's', 's', 's', 's', 'b', 'b', 'b' },
{ 's', 's', 's', 's', 's', 'b', 's', 's' },
{ 'w', 'w', 'w', 'w', 'w', 'b', 'w', 'w' },
{ 'w', 'w', 'w', 'w', 'w', 'b', 'w', 'w' }
};
Console.OutputEncoding = Encoding.UTF8;
for (int r = 0; r < map.GetLength(0); r++)
{
for (int c = 0; c < map.GetLength(1); c++)
{
Console.ForegroundColor = GetColor(map[r, c]);
Console.Write(GetChar(map[r, c]) + " ");
}
Console.WriteLine();
}
Console.ResetColor();
This code should not require additional comments or explanations. Just keep in mind that to use Unicode values in the console output, don’t forget to choose the UTF-8 encoding by setting the `Encoding.UTF8` value for the `OutputEncoding` property. You can set the foreground color for the console using the `ForegroundColor` property. If you want to reset such a color to the default one, just call the `ResetColor` method, as presented in the last line.
So far, you’ve learned about both single- and multi-dimensional arrays, but one more variant remains to be presented in this book, namely jagged arrays. Let’s continue reading to learn more about them.
Jagged arrays
The last variant of arrays to be described in this book is **jagged arrays**, also referred to as an **array of arrays**. It sounds complicated, but fortunately, it is very simple. A jagged array can be understood as **a single-dimensional array, where each element is another array**. Of course, such inner arrays can have different lengths or they can even be not initialized.
Imagine a jagged array
If you want to better imagine a jagged array, stop reading this book for a moment, open your calendar, and switch its view so that it presents the whole year. It contains 365 or 366 boxes, depending on the year. For each day, you have a different number of meetings. On some days, you have three meetings, while on others, only one or even zero. Your holidays are marked in the calendar and blocked for meetings. You can easily imagine an application of a jagged array in this case. Each day box is an element of this array and it contains an array with data of meetings organized on a particular day. If this day is during your holidays, a related item is not initialized. This makes a jagged array much easier to visualize.
An example jagged array is presented in the following figure:

Figure 3.5 – Example of a jagged array
This jagged array contains four elements. The first has an array with two elements (`9` and `5`). The second element has an array with three elements (`0`, `-3`, and `12`). The third is not initialized (`null`), while the last one is an array with only one element (`54`).
Before proceeding to the example, it is worth mentioning the way of declaring and initializing a jagged array since it is a bit different from the arrays we’ve already described. Let’s take a look at the following code snippet:
int[][] numbers = new int[4][];
numbers[0] = new int[] { 9, 5 };
numbers[1] = new int[] { 0, -3, 12 };
numbers[3] = new int[] { 54 };
This code can be simplified with a collection expression, as follows:
int[][] numbers = new int[4][];
numbers[0] = [9, 5];
numbers[1] = [0, -3, 12];
numbers[3] = numbers 数组使用默认值初始化,即 null。因此,我们需要手动初始化特定元素,如下三行代码所示。值得注意的是,第三个元素未初始化。
你也可以用不同的方式编写前面的代码,如下所示:
int[][] numbers =
{
new int[] { 9, 5 },
new int[] { 0, -3, 12 },
null!,
new int[] { 54 }
};
这还不是全部 - 还有一个更短的变体可用:
int[][] numbers =
[
[9, 5],
[0, -3, 12],
null!,
[54]
];
如何从一个交错数组中访问特定元素?让我们看看:
int number = numbers[1][2];
numbersnumber variable to 12 – that is, to the value of the third element (index equal to 2) from the array, which is the second element of the jagged array. The other line changes the value of the second element within the array, which is the second element of the jagged array, from -3 to 50.
Now that we’ve introduced jagged arrays, let’s look at an example.
Example – yearly transport plan
In this example, you’ll learn how to develop a program that creates a plan for your transportation for the whole year. For each day of each month, the application draws one of the available means of transport, such as by car, by bus, by subway, by bike, or simply on foot. In the end, the program presents the generated plan, as shown in the following screenshot:

Figure 3.6 – Screenshot of the yearly transport plan example
First, let’s declare the enumeration type with constants representing types of transport:
public enum MeanEnum { Car, Bus, Subway, Bike, Walk }
The next part of the code is as follows:
Random random = new();
int meansCount = Enum.GetNames
int year = DateTime.Now.Year;
MeanEnum[][] means = new MeanEnum[12][];
for (int m = 1; m <= 12; m++)
{
int daysCount = DateTime.DaysInMonth(year, m);
means[m - 1] = new MeanEnum[daysCount];
for (int d = 1; d <= daysCount; d++)
{
int mean = random.Next(meansCount);
means[m - 1][d - 1] = (MeanEnum)mean;
}
}
First, a new instance of the `Random` class is created. This will be used to draw a suitable means of transport from the available ones. In the next line, we get the number of available transport types. Then, the jagged array is created. It is assumed that it has `12` elements, representing all months in the current year.
Next, a `for` loop is used to iterate through all the months within the year. In each iteration, the number of days is obtained using the `DaysInMonth` static method of `DateTime`. Each element of the jagged array is a single-dimensional array with `MeanEnum` values. The length of such an inner array depends on the number of days in a month. For instance, it is set to `31` elements for January and `30` elements for April.
The next `for` loop iterates through all the days of the month. Within this loop, you draw a transport type and set it as a value of a suitable element within an array that is an element of the jagged array.
The next part of the code is related to presenting the plan in the console:
string[] months = GetMonthNames();
int nameLength = months.Max(n => n.Length) + 2;
for (int m = 1; m <= 12; m++)
{
string month = months[m - 1];
Console.Write($"{month}:".PadRight(nameLength));
for (int d = 1; d <= means[m - 1].Length; d++)
{
MeanEnum mean = means[m - 1][d - 1];
(char character, ConsoleColor color) = Get(mean);
Console.ForegroundColor = ConsoleColor.White;
Console.BackgroundColor = color;
Console.Write(character);
Console.ResetColor();
Console.Write(" ");
}
Console.WriteLine();
}
First, a single-dimensional array with month names is created using the `GetMonthNames` method, which will be presented and described later. Then, a value of the `nameLength` variable is set to the maximum necessary length of text for storing the month name. To do so, the `Max` extension method is used to find the maximum length of text from the collection with names of months. The obtained result is increased by `2` to reserve space for a colon and a space.
A `for` loop is used to iterate through all the elements of the jagged array – that is, through all months. In each iteration, the month’s name is presented in the console. The next `for` loop is used to iterate through all the items of the current element of the jagged array – that is, through all the days of the month. For each day, proper colors are set (for the foreground and background), and a suitable character is shown. Both a color and a character are returned by the `Get` method, taking the `MeanEnum` value as a parameter. This method will be shown a bit later.
Now, let’s take a look at the implementation of the `GetMonthNames` method:
string[] GetMonthNames()
{
CultureInfo culture = new("en");
string[] names = new string[12];
foreach (int m in Enumerable.Range(1, 12))
{
DateTime firstDay = new(DateTime.Now.Year, m, 1);
string name = firstDay.ToString("MMMM", culture);
names[m - 1] = name;
}
return names;
}
This code is self-explanatory, but let’s focus on the line where we call the `Range` method. It returns a collection of integer values from `1` to `12`. Therefore, we can use it together with the `foreach` loop, instead of a simple `for` loop iterating from `1` to `12`. Just think about it as an alternative way of solving the same problem.
Finally, it is worth mentioning the `Get` method. It allows us to use one method instead of two, namely returning a character and a color for a given transport type. By returning data as a value tuple, the code is shorter and simpler, as shown here:
(char Char, ConsoleColor Color) Get(MeanEnum mean)
{
return mean switch
{
MeanEnum.Bike => ('B', ConsoleColor.Blue),
MeanEnum.Bus => ('U', ConsoleColor.DarkGreen),
MeanEnum.Car => ('C', ConsoleColor.Red),
MeanEnum.Subway => ('S', ConsoleColor.Magenta),
MeanEnum.Walk => ('W', ConsoleColor.DarkYellow),
_ => throw new Exception("Unknown type")
};
}
Arrays are everywhere in this chapter! Now that we’ve learned about this data structure and its C# implementation-related topics, we can focus on some algorithms that are strictly related to arrays, namely sorting algorithms. Are you ready to get to know a few of them? If so, let’s proceed to the next section.
Sorting algorithms
Many algorithms use arrays for a very broad range of applications. However, one of the most common tasks is **sorting an array to arrange its elements in the correct order, either ascending or descending**. Of course, you can sort data of various types, including numbers, strings, or even instances of user-defined classes. However, to keep things a bit simpler, here, we will only focus on sorting integer values.
Imagine a sorting algorithm
You benefit from the sorting procedure frequently in your daily life! For example, your inbox is sorted in a way to present the newest messages first (by sending date in descending order), your calendar presents a day plan sorted by hours (by event start date in ascending order), as well as your list of tasks shows entries from the most important to the least important (by priority in descending order). That’s not all – at work, you sort documents by their issue date, then you choose a suitable road to home from the variants sorted by time to reach the destination, and in the evening, you change programs on the TV using a remote control according to the predefined order of channels.
Sorting algorithms involve many approaches and are also a popular subject of research. There are a lot of sorting types, including selection sort, insertion sort, bubble sort, merge sort, Shell sort, quicksort, and heap sort. These will be explained in detail in this chapter. However, these are not all of the available approaches. Various types differ in their performance results, which is one of the most important aspects that you should take into account while choosing your sorting implementation. This topic will be analyzed at the end of this chapter to give you some tips in this area.
Where can you find more information?
Array sorting is a popular topic that’s presented in various resources in books and research papers, as well as online. For example, you can read more about sorting algorithms presented in this chapter at Wikipedia, as well as you can take a look at some implementation codes at Wikibooks. You can browse for more information about the merge sort at [`en.wikipedia.org/wiki/Merge_sort`](https://en.wikipedia.org/wiki/Merge_sort) and [`en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Merge_sort`](https://en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Merge_sort), about Shell sort at [`en.wikipedia.org/wiki/Shellsort`](https://en.wikipedia.org/wiki/Shellsort) and [`en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Shell_sort`](https://en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Shell_sort), about quicksort at [`en.wikipedia.org/wiki/Quicksort`](https://en.wikipedia.org/wiki/Quicksort) and [`en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Quicksort`](https://en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Quicksort), and about heap sort at [`en.wikipedia.org/wiki/Heapsort`](https://en.wikipedia.org/wiki/Heapsort) and [`en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Heapsort`](https://en.wikibooks.org/wiki/Algorithm_Implementation/Sorting/Heapsort). In a similar way, you can find information about other sorting algorithms. Of course, Wikipedia, together with Wikibooks, is not the only available source of content regarding such algorithms. There are a huge number of websites dedicated to this subject. Some of them also contain animations that show how various algorithms operate. This can help you visualize how they work.
Selection sort
Let’s start with **selection sort**, which is one of the simplest sorting algorithms. **This algorithm divides the array into two parts, namely sorted and unsorted**. First, the sorted part is empty. In the following iterations, **the algorithm finds the smallest element in the unsorted part and exchanges it with the first element in the unsorted part**. Thus, the sorted part increases by one element. This sounds quite simple, doesn’t it?
To better understand the selection sort algorithm, let’s take a look at the following iterations for an array with nine elements (`-11`, `12`, `-42`, `0`, `1`, `90`, `68`, `6`, and `-9`), as shown in the following figure:

Figure 3.7 – Illustration of the selection sort algorithm
Bold lines are used to present the borders between the sorted and unsorted parts of the array. First (*Step 1*), the border is located just at the top of the array, which means that the sorted part is empty. Here, the algorithm finds the smallest value in the unsorted part (namely `-42`) and swaps it with the first element in this part (`-11`). The result is shown in *Step 2*, where the sorted part contains one element (`-42`), while the unsorted part consists of eight elements. In the next step, the algorithm finds `-11` as the smallest value in the unsorted part and swaps it with `12`, which is the first element in the unsorted part. As a result, the sorted part consists of two elements, namely `-42` and `-11`, while the unsorted part contains only seven elements, as shown in *Step 3*. The aforementioned steps are performed a few times until only one element is left in the unsorted part. The final result is shown in *Step 9*.
With that, you know how the selection sort algorithm works, but what role is performed by the `i` and `m` indicators shown on the left in the preceding diagram? They are related to the variables that are used in the implementation of this algorithm. So, it is time to see the code in the C# language!
The implementation is created within the `Sort` method, which takes the `a` array as the parameter and sorts it using selection sort:
void Sort(int[] a)
{
for (int i = 0; i < a.Length - 1; i++)
{
int minIndex = i;
int minValue = a[i];
for (int j = i + 1; j < a.Length; j++)
{
if (a[j] < minValue)
{
minIndex = j;
minValue = a[j];
}
}
(a[i], a[minIndex]) = (a[minIndex], a[i]);
}
}
A `for` loop is used to iterate through the elements until only one item is left in the unsorted part. Thus, the number of iterations of the loop is equal to the length of the array minus one (`a.Length - 1`). In each iteration, another `for` loop is used to find the smallest value in the unsorted part (`minValue`, from the `i + 1` index until the end of the array), as well as to store an index of the smallest value (`minIndex`, referred to as the `m` indicator in the preceding diagram). Finally, the smallest element in the unsorted part (with an index equal to `minIndex`) is swapped with the first element in the unsorted part (the `i` index).
That’s all! Let’s use the following code to test the implementation of the selection sort algorithm:
int[] array = [-11, 12, -42, 0, 1, 90, 68, 6, -9];
Sort(array);
Console.WriteLine(string.Join(" | ", array));
In the preceding code, an array is declared and initialized. Then, the `Sort` method is called, passing the `array` as a parameter. Finally, the `string` value is created by joining elements of the array, separated by `|`. The result is shown in the console:
-42 | -11 | -9 | 0 | 1 | 6 | 12 | 68 | 90
Since we’re talking about various algorithms, one of the most important topics is computational complexity, especially time complexity. In the case of selection sort, both `for` loops (one within the other), each iterating through many elements of the array, which contains *n* elements. For this reason, the complexity is indicated as *O(n*2*)*.
A small reminder about computational complexity
You learned about computational complexity in the previous chapter. As a quick reminder, there are a few variants, such as for the worst or average case. This complexity can be interpreted as the number of basic operations that need to be performed by the algorithm, depending on the input size (*n*). The time complexity can be specified using Big O notation – for example, as *O(n)*, *O(n*2*)*, *O(n log(n))* or *O(1)*. As an example, the *O(n)* notation indicates that the number of operations increases linearly with the input size (*n*).
With that, you’ve learned about selection sort. If you are interested in another approach to sorting, proceed to the next section, where insertion sort is presented.
Insertion sort
**Insertion sort** is another algorithm that makes it possible to sort a single-dimensional array simply. Here, **the array is divided into two parts, namely sorted and unsorted**. However, at the beginning, the first element is included in the sorted part. In each iteration, **the algorithm takes the first element from the unsorted part and places it in a suitable location within the sorted part, to leave the sorted part in the correct order**. Such operations are repeated until the unsorted part is empty.
As an example, let’s take a look at an illustration of sorting an array with nine elements (`-11`, `12`, `-42`, `0`, `1`, `90`, `68`, `6`, and `-9`) using insertion sort:

Figure 3.8 – Illustration of the insertion sort algorithm
First, only one element (namely `-11`) is located in the sorted part (*Step 1*). Then, you take the first element from the unsorted part (`12`). In this case, the location of this element does not need to be changed, so the sorted part is increased to two elements, namely `-11` and `12`. Then, you take `-42` as the first element in the unsorted part and you move it to the correct location in the sorted part. To do so, you need to perform two swap operations, as shown in *Step 2*. Thus, the length of the sorted part is increased to three elements, namely `-42`, `-11`, and `12`. In *Step 3*, you take `0` as the first element from the unsorted part and perform one swap operation to place it in the correct position, just before `12`, as presented in *Step 4*. At the same time, the size of the sorted part is increased to four already sorted elements, namely `-42`, `-11`, `0`, and `12`. Such operations are repeated until the unsorted part is empty (*Step 9*).
The implementation code for the insertion sort algorithm is very simple:
void Sort(int[] a)
{
for (int i = 1; i < a.Length; i++)
{
int j = i;
while (j > 0 && a[j] < a[j - 1])
{
(a[j], a[j - 1]) = (a[j - 1], a[j]);
j--;
}
}
}
A `for` loop is used to iterate through all elements in the unsorted part. Thus, the initial value of the `i` variable is set to `1`, instead of `0`, because the unsorted part contains one element at the beginning. In each iteration of the `for` loop, a `while` loop is executed to move the first element from the unsorted part of the array (with the index equal to `i`) to the correct location within the sorted part, by swapping.
Finally, it is worth mentioning the time complexity of the insertion sort algorithm. Similarly, as in the case of the selection sort, both `for` and `while`) placed one within the other, which could iterate multiple times, depending on the input size (*n*).
Bubble sort
The third sorting algorithm we’ll cover is **bubble sort**. Its way of operation is very simple. **The algorithm just iterates through the array and compares adjacent elements. If they are located in an incorrect order, they are swapped.** It sounds very easy, doesn’t it? Unfortunately, the algorithm is not efficient and its usage with large collections can cause performance-related problems.
To better understand how the algorithm works, let’s take a look at the following figure, which shows how the algorithm operates in the case of sorting a single-dimensional array with nine elements (`-11`, `12`, `-42`, `0`, `1`, `90`, `68`, `6`, and `-9`):

Figure 3.9 – Illustration of the bubble sort algorithm
In each step, the algorithm compares two adjacent elements in the array and swaps them, if necessary. For example, in *Step 1*, `-11` and `12` are compared. They are placed in the correct order, so it is not necessary to swap such elements. In *Step 2*, the next adjacent elements are compared (namely `12` and `-42`). This time, such elements are not placed in the correct order, so they are swapped. The aforementioned operations are performed many times. Finally, the array is sorted, as shown in *Step 72*.
The algorithm seems to be very easy, but what about its implementation? Is it also simple? Fortunately, yes! You just need to use two loops, compare adjacent elements, and swap them if necessary. That’s all! Let's take a look at the following code snippet:
void Sort(int[] a)
{
for (int i = 0; i < a.Length; i++)
}
for (int j = 0; j < a.Length - 1; j++)
{
if (a[j] > a[j + 1])
{
(a[j], a[j + 1]) = (a[j + 1], a[j]);
{
}
}
}
Here, two `for` loops are used, together with a comparison and swapping. As mentioned previously, this algorithm is not efficient and its application can cause problems related to performance, especially in the case of large collections of data. However, it is possible to use a bit more efficient version of the bubble sort algorithm by introducing a simple modification. It is based on the assumption that **comparisons should be stopped when no changes are discovered during one iteration through the array**. The code is as follows:
void Sort(int[] a)
{
for (int i = 0; i < a.Length; i++)
{
bool isAnyChange = false;
for (int j = 0; j < a.Length - 1; j++)
}
if (a[j] > a[j + 1])
{
isAnyChange = true;
(a[j], a[j + 1]) = (a[j + 1], a[j]);
}
}
if (!isAnyChange) { break; }
}
}
By introducing such a simple modification, the number of steps can decrease. In the preceding example, it decreases from 72 steps to 56 steps.
Before moving on to the next sorting algorithm, it is worth mentioning the time complexity of the bubble sort algorithm. As you may have already guessed, **both worst and average cases** are the same as in the case of the selection and insertion sort algorithms – that is, **O(n**2**)**.
Merge sort
The fourth sorting algorithm operates in a significantly different way than the three already presented. This approach is named **merge sort**. **This algorithm recursively splits the array in half until the array contains only one element, which is sorted. Then, the algorithm merges the already sorted subarrays (starting with these with only one element) into the sorted array.** Finally, the whole array is sorted and the algorithm stops its operation.
To better understand the merge sort algorithm, let’s take a look at the following iterations for an array with six elements (`-11`, `12`, `-42`, `0`, `90`, and `-9`):

Figure 3.10 – Illustration of the merge sort algorithm
First (*Step 1*), you have the whole unsorted array, which you split into two parts, namely (`-11`, `12`, `-42`) and (`0`, `90`, `-9`), as shown in *Step 2*. In the next step, each of these subarrays is further split into (`-11`), (`12`, `-42`), (`0`), and (`90`, `-9`). In *Step 4*, you have the whole array divided into the subarrays with only one element each, namely (`-11`), (`12`), (`-42`), (`0`), (`90`), and (`-9`). Next, you merge all of these subarrays, together with sorting. Thus, in *Step 5*, you have three subarrays – that is, (`-11`, `12`), (`-42`, `0`), and (`-9`, `90`). Please keep in mind that these subarrays are already sorted. In *Step 6*, you need to merge and sort them further into (`-42`, `-11`, `0`, `12`) and (`-9`, `90`). Finally, you have the whole array sorted, namely (`-42`, `-11`, `-9`, `0`, `12`, `90`).
Does this seem simpler than just reading the textual description of the algorithm? If so, let’s proceed to its implementation:
void Sort(int[] a)
{
if (a.Length <= 1) { return; }
int m = a.Length / 2;
int[] left = GetSubarray(a, 0, m - 1);
int[] right = GetSubarray(a, m, a.Length - 1);
Sort(left);
Sort(right);
int i = 0, j = 0, k = 0;
while (i < left.Length && j < right.Length)
{
if (left[i] <= right[j]) { a[k] = left[i++]; }
else { a[k] = right[j++]; }
k++;
}
while (i < left.Length) { a[k++] = left[i++]; }
while (j < right.Length) { a[k++] = right[j++]; }
}
The `Sort` method is called `a`. To stop infinitely calling this method recursively, you must specify the stop condition at the beginning. It simply checks whether the size of the array is not greater than 1\. It is related to the assumption that you cannot further divide an array with one element only, because it is already sorted.
Next, you calculate an index of the middle element and store it as a value of `m`. In the following two lines, you call the auxiliary `GetSubarray` method, which creates a new array with only a part of elements, either from its left-hand side (with indices from `0` to `m-1`, stored as `left`) or the right-hand side (from `m` to the length of the array minus 1, stored as `right`). You will see its implementation after the explanation of the `Sort` method. Coming back to the explanation of the `Sort` method, you then recursively call the `Sort` method, passing the `left` and `right` subarrays.
The remaining part of the code is related to merging subarrays into the whole sorted array. Of course, this procedure is performed step by step, merging the subarrays into bigger and bigger subarrays until the whole array is sorted. You use a `while` loop to iterate through the `left` and `right` subarrays. You use three auxiliary variables, namely `i` as an index of the currently analyzed element from the `left` array, `j` from the `right` array, and `k` from the `a` array. Initially, all of them are set to `0`, so you keep an eye on the first element of the `left`, `right`, and `a` arrays.
Within the `while` loop, you check whether the current element from the `left` array (with the `i` index) is not greater than the current element from the `right` array (with the `j` index). If so, you place the current element from the `left` array as the first element in the `a` array. You also increase the `i` index, which means that the second element from the `left` array is the current one. If this condition is not met – that is, the current element from the `right` array is smaller than the current element from the `left` array – you use the current element from the `right` array as the first element in the `a` array and increase the `j` index. Finally, you increase the `k` index to keep an eye on the second element from the `a` array. The `while` loop ends when you are out of bounds of either the `left` or `right` array.
What about when some elements haven’t been analyzed yet from the `left` or `right` arrays? To handle such cases, you use two additional `while` loops. These allow you to place the remaining elements from either the `left` or `right` array on the remaining places in the `a` array. As you can see, the `Sort` method is equipped with a very simple way of merging two arrays into one, together with their sorting.
While explaining the algorithm’s implementation, the `GetSubarray` auxiliary method was mentioned. So, let’s show its code, together with a short explanation:
int[] GetSubarray(int[] a, int si, int ei)
{
int[] result = new int[ei - si + 1];
Array.Copy(a, si, result, 0, ei - si + 1);
return result;
}
This method uses the `Copy` static method of the `Array` class to copy a part of the source array (`a`) to the declared and initialized here destination array (`result`). To perform this task, you need to take the correct number of elements, namely `ei` `–` `si` `+` `1`. Here, `ei` stands for *end index* and `si` stands for *start index*. You need to copy elements between arrays starting with the `si` index in the source array (`a`) and store them starting from the `0` index in the destination array (`result`).
Of course, you can fill a subarray in different ways, such as using a `for` loop, which iterates through elements and copies them accordingly. If you want, you can prepare the alternative implementation on your own and then compare it during the performance tests, which you will see later in this chapter.
What about the time complexity? It’s not very easy to specify it in the case of the merge sort algorithm compared to the other sorting algorithms I’ve presented. However, its time complexity is much better and can be indicated as **O(n log(n))** **for both average and worst cases**. You will see what this means in practice while analyzing the performance results.
However, you still have some algorithms to learn about, so let’s proceed to the next one.
Shell sort
A different approach to sorting is used in the **Shell sort** algorithm, whose name comes from its author’s name. It is a variation of the already presented insertion sort. **The algorithm performs h-****sorting** **to sort virtual subarrays consisting of elements with a distance equal to h, using the insertion sort. At the beginning, h is set to half of the array’s length and is divided by 2 in each iteration, until it is equal to 1.** This description can seem a bit complicated, but it is a surprisingly efficient algorithm with a very simple implementation.
First, let’s take a look at a figure that should make this topic much simpler and easier to understand than just the plain text:

Figure 3.11 – Illustration of the Shell sort algorithm
As the source array contains 7 elements, the initial `h` value is set to `3`. So, now, it is time for `0`, `3`, `6`), (`1`, `4`), and (`2`, `5`). The first virtual subarray consists of (`-11`, `-15`, `-13`), so you sort it and receive (`-15`, `-13`, `-11`). The second is (`12`, `-4`) and forms (`-4`, `12`) after sorting. The last is (`13`, `-9`) and is sorted into (`-9`, `13`). When 3-sorting is completed, you calculate the next *h* value, simply by dividing the current value by 2\. The result is 1 and it is also the last *h*-sorting iteration, namely **1-sorting**. Now, you perform a simple insertion sort.
The illustration and description look pretty simple, don’t they? Let's write some C# code to implement the Shell sort algorithm, as shown below:
void Sort(int[] a)
{
for (int h = a.Length / 2; h > 0; h /= 2)
{
for (int i = h; i < a.Length; i++)
{
int j = i;
int ai = a[i];
while (j >= h && a[j - h] > ai)
{
a[j] = a[j - h];
j -= h;
}
a[j] = ai;
}
}
使用for循环来计算h的适当值,从数组(a)的长度除以 2 开始。每次迭代后,它进一步除以 2,最后一个可接受值是 1。
下一个for循环计算i索引,从h开始,并增加到达到数组的末尾。这部分用于在虚拟子数组上执行插入排序。
在循环中,你可以使用ai变量来存储具有i索引的元素的当前值,以便稍后用另一个值替换它。然后,使用一个while循环来移动虚拟子数组中的元素,以找到ai的正确位置。最后,将ai变量存储在由j变量指示的位置。
如你所见,实现非常简短且简单。更重要的是,这个算法效率高,可以用于排序大量数据,正如你将在本章后面看到的那样。但是,时间复杂度如何?在最坏的情况下,它是 O(n2)。然而,其平均时间复杂度大约是 O(n log(n))。
快速排序
本书所描述的第六种排序算法是快速排序。它是分治法组中的一种流行算法,将一个问题分解成一系列更小的子问题。它是如何工作的?
算法选择某个值(例如,从数组的最后一个元素)作为枢轴。然后,它以这种方式重新排列数组,使得小于枢轴的值放在它之前(形成下子数组),而大于或等于枢轴的值放在它之后(形成上子数组)。这个过程称为分区****。接下来,算法递归地对上述每个子数组进行排序。每个子数组进一步分解成下一个两个子数组,依此类推。递归调用在子数组中有一个或零个元素时停止,因为在这样的情况下,没有东西** 需要排序。
上述描述可能听起来有点复杂。然而,以下图和算法的实现应该可以消除任何疑问。
以下图表显示了快速排序算法如何对一个包含九个元素的单维数组(-11,12,-42,0,1,90,68,6 和 -9)进行排序:

图 3.12 – 快速排序算法的示意图
在我们的情况下,假设枢轴被选为当前正在排序的子数组的最后一个元素的值。在 步骤 1 中,-9 被选为枢轴。然后,需要将 12 与 -42(步骤 1)以及 12 与 -9(步骤 2)交换,以确保只有小于枢轴的值(-11,-42)位于较低子数组中,而大于或等于枢轴的值(0,1,90,68,6,12)被放置在较高子数组中(步骤 3)。然后,算法对上述两个子数组进行递归调用,即(-11,-42,从 步骤 4)和(0,1,90,68,6,12,从 步骤 7),这样它们就可以像输入数组一样进行处理。
例如,步骤 7 显示 12 被选为枢轴。在分区后,子数组被分为两个其他子数组,即(0,1,6)和(90,68)。对于这两个子数组,选择其他枢轴元素,即 6 和 68。在执行所有剩余数组部分的此类操作后,你将得到 步骤 16 中显示的结果。
值得注意的是,枢轴可以在该算法的其他实现中根据不同的方式选择。现在你了解了算法的工作原理,让我们继续其实现。它并不比前面展示的例子更复杂,它使用 递归 来调用子数组的排序方法。主要代码如下:
void Sort(int[] a)
{
SortPart(a, 0, a.Length - 1);
}
Sort 方法只接受一个参数,即应该排序的数组。它只是调用 SortPart 方法,这使得 SortPart 方法得以展示如下:
void SortPart(int[] a, int l, int u)
{
if (l >= u) { return; }
int pivot = a[u];
int j = l - 1;
for (int i = l; i < u; i++)
{
if (a[i] < pivot)
{
j++;
(a[j], a[i]) = (a[i], a[j]);
}
}
int p = j + 1;
(a[p], a[u]) = (a[u], a[p]);
SortPart(a, l, p - 1);
SortPart(a, p + 1, u);
}
首先,方法检查数组(或子数组)是否至少有两个元素,通过比较 l(下标)和 u(上标)变量的值。如果不是,你将从这个方法返回。否则,你将执行分区阶段。
在这里,枢轴被选为数组(或子数组)中的最后一个元素的值,并存储为 pivot 变量的值。然后,使用 for 循环通过比较和交换元素来重新排列数组。你需要执行这个阶段以确保小于枢轴的值位于其前面,而大于或等于枢轴的值位于其后。
最后,你将枢轴值的新索引存储为 p 并执行交换以将其放置在那里。p 变量还用于计算子数组的上下界,即作为 (l, p-1) 和 (p+1, u)。然后,在递归调用 SortPart 方法对较低和较高部分进行排序时使用这些范围。这就是全部!
关于时间复杂度呢?它具有O(n log(n))的平均时间复杂度,尽管最坏情况下的时间复杂度为 O(n²)。这看起来像 Shell 排序吗?如果是这样,你就对了!你越来越接近本章的结尾了,在那里你将看到对各种排序算法进行性能测试的结果。
堆排序
我们将要介绍的最后一个方法是基于一种有趣的数据结构,称为二叉堆。为了给你一个简要的介绍,它是一种基于树的结构的树,其中每个节点包含零个、一个或两个子节点。你将在本书的后面部分了解更多关于树及其变体的内容。
你可能不会感到惊讶,这个排序解决方案被命名为堆排序。首先,算法 从数组中构建一个 最大堆 (执行堆化操作)。然后,它重复以下几个步骤,直到堆中只剩下一个元素:
-
将第一个元素(最大值的根)与最后一个元素交换。
-
从堆中移除最后一个元素(当前的最大值)。
-
再次构建 最大堆。
通过执行这些操作,你有效地得到了排序后的数组。
由于这里需要引入一个新的数据结构,让我们看看二叉堆的样子以及算法是如何对示例数组进行排序的:

图 3.13 – 堆排序算法的示意图
输入数组由六个元素组成,即-11、12、-42、0、90和-9。你通过将第一个元素作为根,然后添加它的两个子节点:12和-42,从它形成二叉堆。在这个堆的级别上,你没有更多的空间,所以让我们将数组中的以下两个元素(0和90)作为子节点添加到值为12的节点上。数组的最后一个元素被留下。你必须将它放置在值为-42的节点的子节点上。正如你所看到的,你可以轻松地将一个数组映射到二叉堆数据结构,并使用数组作为数据结构来存储二叉堆的数据。
二叉堆的有趣特性
记住,在以数组表示的二叉堆中,根节点位于array[0]。如果你需要访问第i个元素的父节点数据,你可以从array[(i-1)/2]中获取它。第i个元素的左子节点和右子节点分别在array[(2*i)+1]和array[(2*i)+2]中可用。
堆排序算法中扮演重要角色的下一个操作被命名为90作为根。它包含12和-9作为节点。值为12的节点包含值较小的子节点,即0和-11。值为-9的节点只包含一个元素,这个元素也小于它,即-42。
最大堆不是唯一的选择
你也可以使用heapify操作来形成min-heap。它与最大堆类似,但每个节点都需要满足其子节点的值大于或等于父节点值的条件。
接下来,我们看一下前面图中的第二行。在这里,数组的最后一个元素(90)已经排序了。这是通过将根(之前是90)与数组中的最后一个元素(之前是-42)交换的结果。然后,你必须执行另一个heapify操作,并得到以12为root的最大堆。上述操作会一直重复,直到堆中只剩下一个元素。最后,你将得到前面图右下角所示的排序数组。
在这一点上,你应该准备好分析 C#语言的实现代码:
void Sort(int[] a)
{
for (int i = a.Length / 2 - 1; i >= 0; i--)
{
Heapify(a, a.Length, i);
}
for (int i = a.Length - 1; i > 0; i--)
{
(a[0], a[i]) = (a[i], a[0]);
Heapify(a, i, 0);
}
}
Sort方法包含两个for循环。第一个循环执行初始的heapify操作,以准备max-heap。你可以通过多次调用Heapify来实现,即以相反的顺序和每个非叶子节点进行操作。然后,你将得到由数据形成的max-heap的数组。
第二个for循环会一直执行,直到堆中至少有一个元素。在每次迭代中,它将root元素(索引等于0)与最后一个元素(索引等于i)交换。然后,你需要通过调用Heapify方法来恢复max-heap属性,这涉及到堆的受影响部分。
现在,让我们来看看Heapify方法的代码:
void Heapify(int[] a, int n, int i)
{
int max = i;
int l = 2 * i + 1;
int r = 2 * i + 2;
max = l < n && a[l] > a[max] ? l : max;
max = r < n && a[r] > a[max] ? r : max;
if (max != i)
{
(a[i], a[max]) = (a[max], a[i]);
Heapify(a, n, max);
}
}
它有三个参数,即数组(a)、堆中的元素数量(n),以及一个元素的索引(i),它是应该heapify的子树的根。首先,你得到最大元素的索引(root,作为max),以及它的左子节点和右子节点(l和r,分别)。你可以根据前面提到的公式计算索引,即2*i+1和2*i+2。
在以下两行中,你检查左子节点索引(l)是否仍在堆中(l<n),以及具有该索引的元素(a[l])是否大于当前根值(a[max])。如果是这样,你更新根索引(max)。以同样的方式,你检查右子节点并调整max变量,如果需要的话。
在下一行,你检查在提到的操作中root索引是否发生了变化。如果是这样,这意味着当前的root不是最大值,你需要交换数组中的两个元素,即表示root(i索引)和最大值(max索引)。接下来,你递归地对受影响的子树执行heapify操作,即具有新根值的树。
在这个详细的解释之后,值得提到的是时间复杂度。在这种情况下,它非常重要,因为该方法效率高,可以在排序大型数据集合时成功使用。时间复杂度是 O(n log(n))。
尽管学习了七种不同的排序算法,但请记住,还有许多其他这样的算法可供选择,包括块排序、树排序、立方排序、链排序和循环排序。如果你对这个主题感兴趣,我强烈建议你亲自查看它们。在此期间,让我们比较一下本章中涵盖的算法。
性能分析
要进行一些测试,你需要配置你的环境。因此,让我们首先准备运行各种排序算法的代码,使用相同的输入数组。
你还记得吗,本章中展示的每个实现都涉及Sort方法,它只接受一个参数(即a数组)?现在,你可以利用这个假设并创建一个AbstractSort抽象类,该类要求你在派生此类时实现此方法。
抽象类的代码如下:
public abstract class AbstractSort
{
public abstract void Sort(int[] a);
}
然后,你需要为每个排序算法(如SelectionSort或HeapSort)准备一个单独的类,根据以下模板:
public class SelectionSort
: AbstractSort
{
public override void Sort(int[] a) { (...) }
}
由于所有表示排序算法的类都从基抽象类AbstractSort派生,你可以轻松地创建一个包含它们实例的列表:
List<AbstractSort> algorithms = new()
{
new SelectionSort(),
new InsertionSort(),
new BubbleSort(),
new MergeSort(),
new ShellSort(),
new QuickSort(),
new HeapSort()
};
代码中最有趣的部分如下所示:
for (int n = 0; n <= 100000; n += 10000)
{
Console.WriteLine($"\nRunning tests for n = {n}:");
List<(Type Type, long Ms)> milliseconds = [];
for (int i = 0; i < 5; i++)
{
int[] array = GetRandomArray(n);
int[] input = new int[n];
foreach (AbstractSort algorithm in algorithms)
{
array.CopyTo(input, 0);
Stopwatch stopwatch = Stopwatch.StartNew();
algorithm.Sort(input);
stopwatch.Stop();
Type type = algorithm.GetType();
long ms = stopwatch.ElapsedMilliseconds;
milliseconds.Add((type, ms));
}
}
List<(Type, double)> results = milliseconds
.GroupBy(r => r.Type)
.Select(r =>
(r.Key, r.Average(t => t.Ms))).ToList();
foreach ((Type type, double avg) in results)
{
Console.WriteLine($"{type.Name}: {avg} ms");
}
}
在这里,你使用一个for循环来选择合适的n值,这是用于排序的输入数组的长度。你从一个包含零个元素的数组开始(n = 0),并以每迭代增加10000的方式,直到有数十万个元素(n = 100000)。n的值将是0、10000、20000、30000,直到100000。
在每次迭代中,你创建列表(milliseconds)的新实例。每个元素存储一个由两个元素组成的元组,即排序算法类的类型(Type)和执行消耗的毫秒数(Ms)。然后,你使用另一个for循环执行这样的测试5次。在每次测试中,你通过调用GetRandomArray获取一个给定大小的随机数组(array),它将被用作每个测试的模板。接下来,你声明并初始化输入数组(input)。
下一个部分涉及一个foreach循环,遍历所有从AbstractSort派生出来的类的实例。对于每一个,你通过从array复制元素到input来创建一个输入数组。然后,你开始计时并调用Sort方法。一旦它运行完成,你停止计时并将结果添加到milliseconds列表中。
代码的最后部分与计算每个排序算法的平均结果及其在控制台中的展示有关。为此,你使用一些扩展方法,例如GroupBy、Select和Average,以及一个foreach循环。
之前提到了GetRandomArray方法,让我们来看看它:
int[] GetRandomArray(long length)
{
Random random = new();
int[] array = new int[length];
for (int i = 0; i < length; i++)
{
array[i] = random.Next(-100000, 100000);
}
return array;
}
它使用Random类在<-100,000, 100,000)范围内获取一个随机整数。整个数组填充了这样的随机值。
到目前为止,你的环境已经准备就绪,你可以进行测试!所以,让我们运行代码并查看结果。我得到了以下值:

图 3.14 – 分析排序算法性能的结果
除了表格及其数据之外,让我们看看图表:

图 3.15 – 排序算法性能结果的比较
如你所见,最差的结果是冒泡排序,然后是插入排序和选择排序算法。对于包含 10 万个元素的数组,它们需要几乎 33 秒(冒泡排序)、几乎 14 秒(插入排序)和超过 5 秒(选择排序)。与归并排序、希尔排序、快速排序和堆排序的结果相比,这些值看起来非常高。这些算法需要 12 到 28 毫秒!这看起来令人惊讶吗?如果你回想一下时间复杂度,就不应该感到惊讶。
让我们记住所提及算法的平均时间复杂度:
-
O(n²):选择排序、插入排序和冒泡排序
-
O(n log(n)):归并排序、希尔排序、快速排序和堆排序
哦,所以这样的时间复杂度确实很重要! 😉 如果你之前有任何怀疑,现在是时候注意你在应用中使用的算法了。你应该仔细选择它们,并优化解决方案以处理需要处理的各种数据量。
不要忘记性能
重视性能不仅对排序操作重要,对你在移动应用、Web 应用、API 和长时间运行的后台服务中进行的所有操作都很重要。让我们尝试编写高效的代码,并通过满足功能需求以及关注非功能需求(如与性能相关的需求)来测试它。
在之前的图表中,你几乎看不到任何关于具有O(n log(n))时间复杂度的算法的数据,所以让我们准备另一组测试。现在,你只能选择这些算法,并将最大数n增加到一百万!你可以在以下图表中看到我的结果:

图 3.16 – 排序算法性能结果的比较
这里有一些差异,尤其是在快速排序和其他排序算法(如希尔排序、归并排序和堆排序)之间。然而,这种变化只有在相当大的输入大小下才能看到,并且可能由实现细节引起。所有具有 O(n log(n)) 时间复杂度的排序算法都是排序的良好解决方案,可以处理各种数量的数据。还值得注意的是,这些结果是在我的设备上获得的,所以你可能得到不同的结果。然而,接收到的已过毫秒数之间的关系应该是一致的。
摘要
数组是开发各种类型的应用程序(如移动、Web 或分布式应用程序)时最常用的数据结构之一。然而,这个话题并不像看起来那么简单,因为即使是数组也可以分为几种变体,即一维和多维,例如二维和三维,以及锯齿数组,也称为数组数组。
在讨论数组时,不要忘记排序算法,这是与这种数据结构一起使用最流行的算法之一。有大量的排序算法,它们在概念、应用、实现细节和性能结果上都有所不同。在本章中,你学习了七种不同的排序算法,即选择排序、插入排序、冒泡排序、归并排序、希尔排序、快速排序和堆排序。每种算法都进行了描述,并在图中进行了可视化,并以 C# 代码编写。
在本章结束时,你看到了时间复杂性的重要性以及它在使用不同计算复杂性的算法(如 O(n²) 和 O(n log(n))) 时对性能结果的影响有多大。你学习了如何准备一个简单的性能测试环境并运行它们以获取结果。这些结果随后在表格以及图表中展示,并附有解释。
你准备好学习其他数据结构了吗?如果是的话,请继续阅读下一章,在那里你将学习关于各种列表变体的内容,包括简单、泛型、排序的,以及单链、双链和循环链。你将看到它们的实现和一些如何在现实世界例子中使用它们的示例。
第四章:列表的变体
在上一章中,你学习了数组和它们的类型。当然,数组并不是存储数据的唯一方式。另一组更受欢迎且更强大的数据结构包含各种列表的变体。在本章中,你将看到这些数据结构在实际中的应用,包括插图、解释和描述。
首先,你将看到一个简单列表,它作为一个数组列表和通用列表,你可以根据需要轻松地添加和删除元素。然后,你将了解排序列表,它保持元素的顺序。接下来,你将学习关于链表的四种变体,即单链表、双链表、循环单链表和循环双链表。最后,你将熟悉在开发应用程序时可以使用的一些与列表相关的接口。这听起来有点复杂吗?如果是这样,请不要担心。你将得到全程指导。
本章我们将涵盖以下主题:
-
简单列表
-
排序列表
-
链表
-
与列表相关的接口
简单列表
数组是非常有用的数据结构,它们在许多算法中都有应用。然而,在某些情况下,由于它们的性质,它们的适用可能会变得复杂,因为它们不允许你增加或减少已创建数组的长度。如果你不知道要存储在集合中的元素总数,你应该怎么做?你是否需要创建一个非常大的数组,然后不使用不必要的元素?这样的解决方案听起来并不好,对吧?一个更好的方法是在必要时使用一种数据结构,它可以使集合的大小动态增加或减少。
想象一个简单的列表
如果你想要更好地可视化一个简单列表,并区分它与数组的不同,请闭上眼睛片刻,试图回忆起当你只有几岁的时候,圣诞节即将到来。你和你的家人正在准备挂在圣诞树上的链。你拿了一张另一张纸,穿过链的最后一段,将新的一段链粘合在一起。这样,你的链就通过另一个元素增长,你可以不断地向链中添加更多元素。好吧,也许限制是纸张和胶水的数量,或者你的疲劳。列表的工作方式与此类似,你可以轻松地添加新元素。你也可以像移除链的一段并将其重新粘合在一起一样移除它们,你仍然可以将它挂在你的美丽圣诞树上!
数组列表
第一个允许您从 System.Collections 命名空间中的 ArrayList 类的数据结构。您可以使用此类存储大量数据,并在需要时轻松添加新元素。当然,您也可以删除它们,计算项目数量,并在数组列表中找到特定值的索引。您如何做到这一点?让我们看看以下代码:
using System.Collections;
ArrayList arrayList = new() { 5 };
arrayList.Add(6);
arrayList.AddRange(new int[] { -7, 8 });
arrayList.AddRange(new object[] { "Marcin", "Kate" });
arrayList.ArrayList class is created and 5 is added as the first element. This can be simplified, as shown here:
ArrayList 类的 Add, AddRange, 和 Insert 方法用于向数组列表中添加新元素。它们之间的区别如下:
-
Add在列表末尾添加一个新项目 -
AddRange在数组列表末尾添加一系列元素 -
Insert在集合的指定位置放置一个元素
当执行前面的代码时,数组列表包含以下元素:5, 6, -7, 8, "Marcin",7.8,和 "Kate"。请记住,数组列表中存储的所有项目都是 object 类型。因此,您可以在同一集合中同时放置各种类型的数据。
您需要指定类型吗?
使用 object 而不是特定类型并不总是好主意。所以,如果您想指定列表中每个元素的类型,您可以使用通用的 List 类,它将在 ArrayList 之后描述。我鼓励您在可能的情况下始终使用强类型版本的集合。
值得注意的是,您可以使用索引轻松访问数组列表中的特定元素,如下面的两行代码所示:
object first = arrayList[0]!;
int third = (int)arrayListint in the second line. Such casting is necessary because the array list stores object values. As in the case of arrays, the zero-based indices are used while accessing particular elements within the collection. When you run the preceding lines of code, first will be equal to 5, while third will be equal to -7.
Of course, you can use a `foreach` loop to iterate through all items, as follows:
foreach (object element in arrayList)
{
Console.WriteLine(element);
}
That’s not all – the `ArrayList` class has a set of properties and methods that you can use while developing applications utilizing the aforementioned data structure. To start with, let’s take a look at the `Count` and `Capacity` properties:
int count = arrayList.Count;
int capacity = arrayList.Count) 返回当前存储在数组列表中的元素数量,而另一个属性(容量)表示可以存储在其中的元素数量。如果您在向数组列表添加新元素后检查容量属性的值,您将看到此值会自动增加以为新项目腾出空间。这如图所示,展示了 Count(标记为 A)和 Capacity(B)之间的区别:

图 4.1 – Count 和 Capacity 的区别
下一个常见且重要的任务是检查数组列表是否包含具有特定值的元素。您可以通过调用 Contains 方法来执行此操作,如下面的代码行所示:
bool containsMarcin = arrayList.true is returned. Otherwise, false is returned. But how can you find an index of this element? To do so, you can use the IndexOf or LastIndexOf method, as shown in the following line of code:
int minusIndex = arrayList.IndexOf 方法返回数组列表中元素首次出现的索引,而 LastIndexOf 返回最后一次出现的索引。如果没有找到值,方法返回 -1。因此,你可以使用 IndexOf 检查数组列表是否包含指定的元素。如果结果是小于零的,这意味着元素不可用。另一方面,如果结果是大于或等于零的,则表示找到了项目,如下所示:
bool containsAnn = arrayList.Remove, RemoveAt, RemoveRange, and Clear methods, as shown here:
arrayList.Remove(5);
arrayList.RemoveAt(1);
arrayList.RemoveRange(1, 2);
arrayList.Clear();
The difference between the mentioned methods is as follows:
* `Remove` removes the first occurrence of a given value
* `RemoveAt` removes an item with a provided index
* `RemoveRange` removes a given number of elements starting from some index
* `Clear` removes all elements
Among other methods, it is worth mentioning `Reverse`, which reverses the order of the elements within the array list, as well as `ToArray`, which returns an array with all items stored in the `ArrayList` instance.
Where can you find more information?
You can find content regarding an array list at [`learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist).
Generic lists
As you can see, the `ArrayList` class contains a broad range of features, but it has a significant drawback – that is, it is not a strongly typed list. If you want to benefit from a strongly typed list, you can use the generic `List` class, which represents the collection whose size can be increased and decreased as necessary. This class is available in the `System.Collections.Generic` namespace.
The generic `List` class contains many properties and methods that are useful while developing applications that store data. Many members are named the same as in the `ArrayList` class. An example is the following two properties:
* `Count`, which returns the current number of elements in the list
* `Capacity`, which indicates how many elements can be currently stored in the list
There are also many similar methods, including the following ones:
* `Add` adds an item at the end of the list
* `AddRange` adds a collection of elements at the end of the list
* `Insert` places an element in a specified location within the list
* `InsertRange` places a collection of items in a specified location in the list
* `Contains` checks whether the list contains a given element
* `IndexOf` returns an index of the first occurrence of a given item
* `LastIndexOf` returns an index of the last occurrence of a given item
* `Remove` removes the first occurrence of a given value
* `RemoveAt` removes an item with a provided index
* `RemoveRange` removes a given number of elements starting from some index
* `Clear` removes all elements from the list
* `Reverse` reverses the order of items within the list
* `ToArray` returns an array with all items stored in the list
You can get a particular element from the list using the `[]` operator with an index.
Apart from the already-described features, you can use a comprehensive set of extension methods from the `System.Linq` namespace. Some of them are as follows:
* `Min` finds the minimum value in the list
* `Max` finds the maximum value in the list
* `Sum` returns a sum of all elements in the list
* `Average` calculates the average value of elements in the list
* `All` checks whether all elements in the list satisfy a condition
* `Any` verifies whether at least one element in the list satisfies a condition
* `ElementAtOrDefault` returns an element at a given index in the collection or a default value if the index is out of bounds
* `Distinct` returns a collection with only unique elements, namely without duplicates
* `OrderBy` and `OrderByDescending` order all elements in the list in ascending or descending order, as well as return the ordered collection
* `Skip` returns a collection bypassing a given number of elements in the list
* `Take` returns a given number of elements from the list
After this theoretical introduction, let’s see such methods in action! First, let’s get the minimum, maximum, sum, and average values from the list, as shown here:
List
int min = list.Min();
int max = list.Max();
int sum = list.Sum();
double avg = list.min 等于 -20,max 等于 90,sum 等于 110,且 avg 接近 12.22。
现在,让我们检查列表元素的一些条件:
bool allPositive = list.All(x => x > 0);
bool anyZero = list.allPositive is equal to false, while anyZero to true.
The next part of the code is shown in the following block:
int existingElement = list.ElementAtOrDefault(5);
int nonExistingElement = list.ElementAtOrDefault 方法用于获取索引等于 5 和 100 的元素的值。在第一种情况下,返回 1 并存储为 existingElement 变量的值。当你尝试获取索引等于 100 的元素时,使用 int 的默认值代替并返回,即 0。
下一个扩展方法是名为 Distinct 的方法,可以使用如下方式:
List<int> unique = list.IEnumerable<int> type, which you can convert into List<int> by calling the ToList extension method. The resulting list contains 6, 90, -20, 0, 4, 1, 8, and 41.
Let’s order the list using the `OrderBy` extension method, as follows:
List
另一个有趣的组方法包括 Skip 和 Take,如下所示:
List<int> skipped = list.Skip(4).ToList();
List<int> taken = list.Skip method skips 4 elements and returns the collection with the remaining elements, namely 4, 1, 8, -20, and 41.
The `Take` method simply takes `3` first elements – that is, `6`, `90`, and `-20`.
Do you have any idea how to combine `Skip` with `Take` in some real-world examples? If not, just think about the **pagination** mechanism, which you can find on many websites. It allows you to navigate between pages of data, where each page contains a specified number of elements. How you can get such items for a given page? The answer is as follows:
int page = 1;
int size = 10;
List
.Skip((page - 1) * size)
.Take(size)
.ToList();
Of course, these are not the only features available for developers while creating applications using generic lists in the C# language. I strongly encourage you to discover more possibilities on your own. Next, we’ll look at two examples that show how to use a generic list in practice.
Where can you find more information?
You can find content regarding a generic list at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1).
Example – average value
The first example utilizes the generic `List` class to store floating-point values (of the `double` type) entered by the user. After typing a number, the average value is calculated and presented in the console. The program stops the operation when an incorrect value is entered. The code is as follows:
List
do
{
Console.Write("Enter the number: ");
string numStr = Console.ReadLine() ?? string.Empty;
if (!double.TryParse(numStr, out double n)) { break; }
num.Add(n);
Console.WriteLine($"Average value: {num.Average()}");
}
while (true);
First, an instance of the `List` class is created. Then, within the infinite loop (`do-while`), the program waits until the user enters a number. If it is correct, the entered value is added to the list (by calling `Add`), and an average value from elements in the list is calculated (by calling `Average`). The result is shown in the console:
输入数字:10.5
平均值:10.5 (...)
输入数字:15.5
平均值:9.375
In this section, you saw how to use a list that stores `double` values. However, can it also store instances of user-defined classes or records? Of course! You will see how to achieve this goal in the next example.
Example – list of people
This second example shows you how to use a list to create a very simple database of people. For each, a name, an age, and a country are stored. When the program is launched, some data of people are added to the list. Then, such data is sorted and presented in the console.
Let’s start with the declaration of the `Person` record:
public record Name, Age, and Country,它存储国家代码。在代码的主要部分,你创建一个新的 List 类实例,并添加一些具有不同姓名、年龄和国家的数据,如下所示:
List<Person> people =
[
new("Marcin", 35, "PL"),
new("Sabine", 25, "DE"),
new("Mark", 31, "PL")
];
在下一行中,你按人员的姓名按升序排序列表:
List<Person> r = people.OrderBy(p => p.Name).ToList();
这一行可以使用集合表达式简化,如下所示:
List<Person> r = foreach loop:
foreach (Person p in r)
{
string line = $"{p.Name} ({p.Age}) from {p.Country}.";
Console.WriteLine(line);
}
After running the program, the following results will be presented:
Marcin (35) from PL.
Mark (31) from PL.
Sabine (25) from DE.
That’s all! Now, let’s talk a bit more about the LINQ expressions, which can be used not only to order elements but also to filter items based on the provided criteria, and even more.
As an example, let’s take a look at the following query, which is using the **method syntax**:
List
.Where(p => p.Age <= 30)
.OrderBy(p => p.Name)
.Select(p => p.Name)
.Select 子句(Where clause)中所有年龄低于或等于 30 岁(OrderBy clause)的人员。查询然后执行,并将结果作为列表返回(ToList)。
同样的任务可以使用 ToList 方法完成:
List<string> names = (from p in people
where p.Age <= 30
orderby p.Name
ArrayList class and the generic List class to store data in collections, the size of which could be dynamically adjusted. However, this is not the end of list-related topics within this chapter. Are you ready to get to know another data structure that maintains the elements in the sorted order? If so, let’s proceed to the next section, which focuses on sorted lists.
Sorted lists
So far, you’ve learned how to store data using simple lists. However, do you know that you can even use a data structure that ensures that the elements are sorted all the time? If not, let’s get to know the `SortedList` generic class (from the `System.Collections.Generic` namespace), `null`.
Imagine a sorted list
If you want to imagine a sorted list, think about a business holder in which you put business cards that you have received from other people. Since you like order and want to always be able to quickly find a business card for a specific person, you make sure that they are all arranged in alphabetical order, by last name. What a terrible waste of time, especially if you have dozens of business cards and suddenly you have to put in a card for Mrs. Ana Ave. Oh, no... almost all the business cards have to be moved. What can help you at this point is a sorted list! On its basis, your magic business card holder works, which automatically inserts a new business card into the appropriate place in the business card holder. Thanks to this, you always have order and you do not have to waste time constantly taking out and inserting business cards. Congratulations!
You can easily add an element to a sorted list using the `Add` method, as well as remove a specified item using the `Remove` method. Among other methods, it is worth noting `ContainsKey` and `ContainsValue` for checking whether the collection contains an item with a given key or value, as well as `IndexOfKey` and `IndexOfValue` for returning an index of an element by its key or value.
As a sorted list stores key-value pairs, you have also access to the `Keys` and `Values` properties. Particular keys and values can be easily obtained using the `[]` operator together with an index. As you can see, this data structure is quite similar to the ones that have already been presented. However, it has some significant differences. So, let’s take a look at an example that will show you how to use this data structure. You will also see differences in code compared with the previously described `List` class.
Where can you find more information?
You can find content regarding a sorted list at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sortedlist-2`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sortedlist-2).
Example – address book
This example uses the `SortedList` class to create a very simple address book, which is sorted by names of people. For each person, the following data is stored: `Name`, `Street`, `PostalCode`, `City`, and `Country`. The declaration of the `Person` record is shown in the following code:
public record Person(
string Name,
string Street,
string PostalCode,
string City,
string Country);
In the main part of the code, you create a new instance of `SortedList`. You need to specify types for keys and values, namely `string` and `Person`. Within the following part of the code, you also initialize the sorted list with data for `Marcin` and `Martyna`:
SortedList<string, Person> people = new()
{
{ "Marcin Jamro", new("Marcin Jamro",
"Polish Street 1/23", "35-001", "Rzeszow", "PL") },
{ "Martyna Kowalska", new("Martyna Kowalska",
"World Street 5", "00-123", "Warsaw", "PL") }
};
Then, you can easily add data to the sorted list by calling the `Add` method, passing two parameters, namely a key (that is, a name), and a value (that is, an instance of the `Person` record), as shown in the following code snippet regarding `Mark`:
people.Add("Mark Smith", new("Mark Smith",
"German Street 6", "10000", "Berlin", "DE"));
When all the data is stored within the collection, you can easily iterate through its elements (namely through key-value pairs) using a `foreach` loop. It is worth mentioning that a type of the variable that’s used in the loop is `KeyValuePair<string, Person>`. However, you can use a value tuple to get access to a key (`k`) and a value (`p`):
foreach ((string k, Person p) in people)
{
Console.WriteLine($"{k}: {p.Street}, {p.PostalCode}
{p.City}, {p.Country}.");
}
When the program is launched, you receive the following result in the console:
Marcin Jamro: Polish Street 1/23, 35-001 Rzeszow, PL.
Mark Smith: German Street 6, 10000 Berlin, DE.
Martyna Kowalska: World Street 5, 00-123 Warsaw, PL.
As you can see, the collection is automatically sorted by names, which are used as keys for the sorted list. However, you need to remember that keys must be unique, so you cannot add more than one person with the same full name in this example.
Linked lists
While using the `List` generic class, you can easily get access to particular elements of the collection using indices. However, when you get a single element, how can you move to the next element of the collection? Is it possible? To do so, you may consider the `IndexOf` method to get an index of the element. Unfortunately, it returns an index of the first occurrence of a given value in the collection, so it will not always work as expected in this scenario. Fortunately, **linked lists** exist and can help you with this problem! In this section, you will learn about a few variants.
Singly linked lists
A **singly linked list** is a data structure in which **each list element contains a** **pointer to the next element**. Thus, you can easily **move from any element to the next one, but you cannot go back**. Of course, the last element in the list has an empty pointer to the next element because there is nothing more located in the list.
Imagine a singly linked list
If you want to better visualize a singly linked list, think about how to represent the phases of human development. Life after birth consists of the neonatal period, infancy, post-infanthood, preschool, school, adolescence, adulthood, and old age. From each phase, you can only go to the next one and you can never go back, even if you try very, very hard. It’s similar to a linked list, where you can easily move from a given item to the next item, but you don’t have any data to return to the item that brought you here. But it would be nice to be able to go back a dozen or so years and repeat some phase of development, right? Unfortunately, there is no “back” button here. :-)
Here’s an example of a singly linked list:

Figure 4.2 – Illustration of a singly linked list
Is it possible to further expand this data structure so that you can both go forward and backward from a given list element? Of course! Let’s take a look.
Doubly linked lists
A **doubly linked list** is another data structure that **allows you to navigate both forward and backward from each list item**. It can be created based on the singly linked list by adding a second pointer, namely to the previous element.
Imagine a doubly linked list
If you want to better imagine a doubly linked list, open a text editor and start describing your day in it. Whenever you make a mistake, you press the “undo” button and you see the earlier version. You can also press “redo” and suddenly, you see what was in the document just before you undone the changes. Of course, you can perform such an operation many times, and the system remembers many previous and next operations. This is how you can think of a doubly linked list. In each element of the list, you can easily go to both the next element (equivalent to a “redo” operation) and the previous element (equivalent to a “back” operation). Just look how easy it is to find applications for various data structures in everyday life!
The following figure illustrates a doubly linked list:

Figure 4.3 – Illustration of a doubly linked list
As you can see, the `FIRST` box indicates the first element in the list. Each item has two properties that point to the previous and next element (`PREV` and `NEXT`, respectively). If there is no previous element, the `PREV` property is equal to `null`. Similarly, when there is no next element, the `NEXT` property is set to `null`. Moreover, the doubly linked list contains the `LAST` box that indicates the last element.
Do you need to implement such a data structure on your own if you want to use it in your C#-based applications? Fortunately, no! It is already available as the `LinkedList` generic class in the `System.Collections.Generic` namespace. While creating an instance of this class, you need to specify the type parameter that indicates a type of a value stored in each element in the list, such as `int` or `string`. Each element (also referred to as a *node*) is represented by an instance of the `LinkedListNode` generic class, such as `LinkedListNode<int>` or `LinkedListNode<string>`.
Some additional explanation is necessary for the methods of adding new nodes to the doubly linked list. For this purpose, you can use a set of methods:
* `AddFirst` adds an element at the beginning of the list
* `AddLast` adds an element at the end of the list
* `AddBefore` adds an element before the specified node in the list
* `AddAfter` adds an element after the specified node in the list
All these methods return an instance of the `LinkedListNode` class. Moreover, there are some other methods:
* `Contains` checks whether the specified value exists in the list
* `Remove` removes a node from the list
* `Clear` removes all elements from the list
After this short introduction, let’s take a look at an example that shows how to apply the doubly linked list, implemented as the `LinkedList` class, in practice.
Where can you find more information?
You can find content regarding a linked list at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.linkedlist-1`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.linkedlist-1).
Example – book reader
As an example, you will prepare a simple application that allows a user to read a book by changing pages. The user should be able to move to the next page (if it exists) after pressing the *N* key, and go back to the previous page (if it exists) after pressing the *P* key. The content of the current page, together with the page number, should be shown in the console, as presented in the screenshot:

Figure 4.4 – Screenshot of the book reader example
Let’s start with a declaration of the `Page` record, as shown in the following code:
public record Content property. 然后,你创建了几个 Page 类的实例,代表书的六页:
Page p1 = new("Welcome to (...)");
Page p2 = new("While reading (...)");
Page p3 = new("As a developer (...)");
Page p4 = new("In the previous (...)");
Page p5 = new("So far, you (...)");
Page p6 = new("The current (...)");
当实例创建完成后,可以使用一些与添加相关的方法构建双向链表,如下面的代码行所示:
LinkedList<Page> pages = new();
pages.AddLast(p2);
LinkedListNode<Page> n4 = pages.AddLast(p4);
pages.AddLast(p6);
pages.AddFirst(p1);
pages.AddBefore(n4, p3);
pages.AddAfter(n4, p5);
在第一行,创建了一个新的空列表。然后,执行以下给定操作:
-
在末尾添加第二页(
[2])。 -
在末尾添加第四页(
[2, 4])。 -
在末尾添加第六页(
[2, 4, 6])。 -
在列表开头添加第一页(
[1, 2, 4, 6])。 -
在第四页之前添加第三页(
[1, 2, 3, 4, 6])。 -
在第四页之后添加第五页(
[1, 2, 3, 4, 5, 6])。
代码的下一部分负责在控制台显示一页,以及按下适当的键后在页面之间导航。代码如下:
LinkedListNode<Page> c = pages.First!;
int number = 1;
while (c != null)
{
Console.Clear();
string page = $"- {number} -";
int spaces = (90 - page.Length) / 2;
Console.WriteLine(page.PadLeft(spaces + page.Length));
Console.WriteLine();
string content = c.Value.Content;
for (int i = 0; i < content.Length; i += 90)
{
string line = content[i..];
line = line.Length > 90 ? line[..90] : line;
Console.WriteLine(line.Trim());
}
Console.WriteLine($"\nQuote from (...)");
Console.Write(c.Previous != null
? "< PREV [P]" : GetSpaces(14));
Console.Write(c.Next != null
? "[N] NEXT >".PadLeft(76) : string.Empty);
Console.WriteLine();
ConsoleKey key = Console.ReadKey(true).Key;
if (key == ConsoleKey.N && c.Next != null)
{
c = c.Next;
number++;
}
else if (key == ConsoleKey.P && c.Previous != null)
{
c = c.Previous;
number--;
}
}
在第一行,将c变量的值设置为双向链表的第一个节点。一般来说,c变量代表当前在控制台显示的页面。然后,将页面编号的初始值设置为1(number变量)。然而,代码中最有趣和复杂的部分是while循环。
在循环中,清除控制台当前内容,并正确格式化用于显示页码的字符串。在其前后添加-字符。此外,插入前导空格(使用PadLeft方法)以准备水平居中的字符串。
然后,将页面内容分成不超过90个字符的行,并在控制台写入。为了分割字符串,使用Length属性和content[i..]。同样,在控制台显示其他信息。然后,如果存在上一页或下一页,显示PREV和NEXT标题。
你能改进这个示例吗?
此示例将文本分成几行,而不考虑空格。我鼓励您修改代码,以便它支持更友好的文本换行。祝你好运!
在代码的后续部分,程序会等待用户按下任意键,并且不会在控制台(通过将true作为ReadKey参数)中显示它。当用户按下N键时,使用Next属性将c变量设置为下一个节点。当然,当下一页不可用时不执行此操作。P键的处理方式类似,这会导致用户导航到上一页。值得一提的是,页面编号(number变量)在c变量值改变的同时也会被修改。
最后,展示辅助方法GetSpaces的代码:
string GetSpaces(int number) => string.Join(
null, Enumerable.Range(0, number).Select(n => " "));
这将准备并返回具有指定空格数的string变量。当然,有几种方法可以完成这个任务。然而,在这本书中,我想向你展示各种方法,甚至那些不太典型的方法。目的是向你展示实现目标的各种方式,并尽可能拓宽你的视野。
这样,你应该准备好继续你的列表冒险之旅。在下一节中,你将学习关于循环列表及其两种子类型的内容。
循环单链表
在前两个部分中,你已经学习了链表。你应该记得,在单链表中,你可以使用Next属性在节点之间导航。然而,最后一个节点的Next属性被设置为null。你知道你可以轻松地扩展这种方法来创建一个循环单链表,其中最后一个节点指向第一个元素,创建一个可以无限迭代的列表吗?
想象一个循环单链表
如果你想更好地想象一个循环单链表,那么请稍作思考,想象一下显示特定文件夹照片的屏保。在一段时间的不活动后,你的屏幕开始依次显示照片。当最后一张照片显示完毕后,目录中的第一张照片会自动显示。当然,你无法自己控制这些照片,因为任何与键盘或鼠标的交互都会关闭屏保。循环单链表的工作原理与此类似。在这里,只保存关于下一个列表元素的详细信息,而没有返回的可能性。列表的最后一个元素将你带到最开始的位置。一旦你能想象出一个现实生活中的案例,那就很容易理解了,对吧?现在,移动你的鼠标使屏保消失,然后继续学习更多关于数据结构和算法的知识!
以下图示展示了循环单链表:

图 4.5 – 循环单链表的示意图
在对循环单链表主题的简要介绍之后,现在是时候看看实现代码了。由于在 C#开发中默认情况下没有这种数据结构,你将学习如何基于链表自己实现它。让我们从以下代码片段开始:
using System.Collections;
public class CircularLinkedList<T>
: LinkedList<T>
{
public new IEnumerator GetEnumerator() =>
new CircularEnumerator<T>(this);
}
实现可以创建为一个泛型类,它扩展了LinkedList,如前所述代码所示。值得一提的是GetEnumerator方法的实现,它使用了CircularEnumerator类。通过创建它,你将能够使用foreach循环无限迭代循环链表的所有元素。CircularEnumerator的代码如下:
public class CircularEnumerator<T>(LinkedList<T> list)
: IEnumerator<T>
{
private LinkedListNode<T>? _current = null;
public T Current => _current != null
? _current.Value
: default!;
object IEnumerator.Current => Current!;
public bool MoveNext()
{
if (_current == null)
{
_current = list?.First;
return _current != null;
}
else
{
_current = _current.Next
?? _current!.List?.First;
return true;
}
}
public void Reset()
{
_current = null;
}
public void Dispose() { }
}
CircularEnumerator类实现了IEnumerator接口。这个类声明了一个private字段,它代表迭代列表时的当前节点(_current)。它还包含两个属性,即Current和IEnumerator.Current,这是IEnumerator接口所要求的。
代码中最重要的部分之一是MoveNext方法。它检查当前元素是否等于null。如果是,它尝试从列表中获取第一个元素并从它开始迭代。如果不存在,由于列表中没有项目,该方法返回false。如果当前元素不等于null,它将当前元素更改为下一个元素或列表中的第一个节点,如果下一个节点不可用。在Reset方法中,你只需将_current字段的值设置为null。
最后,你创建了Next扩展方法,在尝试从列表的最后一个元素获取下一个元素时导航到第一个元素。为了简化实现,这个功能将作为一个方法而不是Next属性提供。代码如下所示:
public static class CircularLinkedListExtensions
{
public static LinkedListNode<T>? Next<T>(
this LinkedListNode<T> n)
{
return n != null && n.List != null
? n.Next ?? n.List.First
: null;
}
}
该方法检查节点是否存在以及列表是否可用。在这种情况下,它返回节点的Next属性值(如果该值不等于null)或使用First属性返回列表中的第一个元素的引用。
那就结束了!你已经完成了基于 C#的循环单链表的实现,你可以在各种应用中使用它。但如何使用呢?让我们看看以下示例,它使用了这种数据结构。
示例 – 旋转轮子
这个例子模拟了一个用户以随机速度旋转轮子的游戏。轮子旋转得越来越慢,直到停止。然后,用户可以从之前的停止位置再次旋转,如图所示:

图 4.6 – 旋转轮子示例的说明
让我们继续代码的第一部分:
CircularLinkedList<string> categories = new();
categories.AddLast("Sport");
categories.AddLast("Culture");
categories.AddLast("History");
categories.AddLast("Geography");
categories.AddLast("People");
categories.AddLast("Technology");
categories.AddLast("Nature");
categories.CircularLinkedList class is created, which represents a circular singly linked list with string elements. Then, eight values are added, namely Sport, Culture, History, Geography, People, Technology, Nature, and Science.
The following part of the code performs the most important operations:
bool isStopped = true;
Random random = new();
DateTime targetTime = DateTime.Now;
int ms = 0;
foreach (string category in categories)
{
if (isStopped)
{
Console.WriteLine("按[Enter]键开始。");
ConsoleKey key = Console.ReadKey().Key;
if (key == ConsoleKey.Enter)
{
ms = random.Next(1000, 5000);
targetTime = DateTime.Now.AddMilliseconds(ms);
isStopped = false;
Console.WriteLine(category);
}
else { return; }
}
else
{
int remaining = (int)(targetTime
- DateTime.Now).TotalMilliseconds;
int waiting = Math.Max(100, (ms - remaining) / 5);
await Task.Delay(waiting);
if (DateTime.Now >= targetTime)
{
Console.ForegroundColor = ConsoleColor.Red;
isStopped = true;
}
Console.WriteLine(category);
Console.ResetColor();
}
}
First, a few variables are declared:
* `isStopped`, which indicates whether the wheel is currently stopped
* `random`, for drawing random values of wheel spin in milliseconds
* `targetTime`, which is the target time when the wheel should stop
* `ms`, which is the last drawn number of milliseconds for wheel-spinning
Then, the `foreach` loop is used to iterate through all the elements within a circular singly linked list. If there are no `break` or `return` instructions within such a loop, it will execute indefinitely due to the nature of a circular linked list. If the last item is reached, the first element in the list is taken automatically in the next iteration.
In this loop, you check whether the wheel is currently stopped or has not been started yet. If so, the message is presented to the user and the program waits until the *Enter* key is pressed. In such a situation, the new spinning operation is configured by drawing the total time of spinning, setting the expected stop time, indicating that the wheel is not stopped, as well as writing the current category. When the user presses any other key, the program stops its execution.
If the wheel is currently not stopped, you calculate the remaining number of milliseconds and the waiting time. This formula makes it possible to provide smaller times at the beginning (the wheel spins faster) and bigger times at the end (the wheel spins slower). Then, the program waits for the specified number of milliseconds.
At the end, you check whether the target time is reached. If so, the foreground color is changed to red and you indicate that the wheel is stopped. Then, the currently chosen category on the spinning wheel is presented in the console.
When you run the application, you will get the result similar to the following one:

Figure 4.7 – Screenshot of the spin the wheel example
With that, we’ve looked at an example that uses a circular singly linked list. Are you curious whether you can expand it further to create a circular doubly linked list?
Circular doubly linked lists
The last data structure we’ll cover in this chapter is named the **circular doubly linked list**. It is similar to the circular singly linked list but **allows you not only to iterate indefinitely in the forward direction but also in the backward direction**. You can achieve this by adding pointers to previous elements for each item in the list. Of course, you also need to point to the last element in the list as the previous element of the first one in the list.
Imagine a circular doubly linked list
If you want to better visualize a circular doubly linked list, grab your camera and start browsing the gallery of photos you’ve taken. Here, you can easily go from the first photo to the last one by clicking “back.” You can also go from the last photo to the first one by clicking “next.” Of course, you can also switch between subsequent photos in the photo gallery by clicking “back” and “next.” There is no issue with you taking another photo, at which point it will be added to the collection of photos you’ve already taken. Take a look for yourself! This is how a circular doubly linked list works. Snap, photo taken, and... let’s move on!
A circular doubly linked list is presented in the following diagram:

Figure 4.8 – Illustration of a circular doubly linked list
Here, the `PREV` property of the first node navigates to the last one, while the `NEXT` property of the last node navigates to the first. This data structure can be useful in some specific cases, as you will see while developing a real-world example.
After this short introduction to the topic of circular doubly linked lists, it is time to take a look at the implementation code. If you use the code that’s already been prepared for the circular singly linked list, you only need to add one extension method, as shown here:
public static class CircularLinkedListExtensions
{
public static LinkedListNode
this LinkedListNode
{
return n != null && n.List != null
- ? n.Next ?? n.List.First
- null;
}
public static LinkedListNode
this LinkedListNode
{
return n != null && n.List != null
- ? n.Previous ?? n.List.Last
- null;
}
}
The `Prev` method checks whether the node exists and whether the list is available. In such a case, it returns a value of the `Previous` property of the node (if such a value is not equal to `null`) or returns a reference to the last element in the list using the `Last` property. That’s all! Let’s take a look at the example.
Example – art gallery
This example is a viewer of drawings presented in the console. Does this sound strange? It could be, but let’s try to create some console-based art!
Real art in the console exists!
The topic of creating console-based graphics is quite popular and some amazing art has already been created by various authors! If you are curious about this topic, just search for *ASCII arts* in your web browser. Will you join this community with your drawings? If so, please share them with me as well!
When a user presses *the right* or *left arrow*, the drawing is changed to the next or the previous one, respectively. As a result, the following art can be viewed in the console:

Figure 4.9 – Screenshots of the art gallery example
The code uses the `CircularLinkedList` class, as shown here:
string[][] arts = GetArts();
CircularLinkedList<string[]> images = new();
foreach (string[] art in arts) { images.AddLast(art); }
LinkedListNode<string[]> node = images.First!;
ConsoleKey key = ConsoleKey.Spacebar;
do
{
if (key == ConsoleKey.RightArrow)
{
node = node.Next()!;
}
else if (key == ConsoleKey.LeftArrow)
{
node = node.Prev()!;
}
Console.Clear();
foreach (string line in node.Value)
{
Console.WriteLine(line);
}
}
while ((key = Console.ReadKey().Key) != ConsoleKey.Escape);
You create a circular doubly linked list consisting of a few elements. Each stores an array of strings. Such an array represents a particular image, namely the following rows forming the art. When you populate the list with data of all images, you store a reference to the first image as `node`. Then, you use a `do-while` loop that is executed until the *Escape* button is pressed. If the user presses the right arrow, you update the `node` variable using the `Next` method. If the left arrow is pressed, the `Prev` method is used instead. In each iteration, you clear the console and print the art so that you can receive a simple animation of a dancing figure.
If you are curious how such images are defined, take a look at the following code:
string[][] GetArts() => [
[
" +-----+ ",
"o-| o o |-o",
"| - | ",
" +-----+ ",
" | | "
],
[
"o +-----+ ",
" \| o o |\ ",
" | - | o",
" +-----+ ",
" / | "
],
[
"+-----+ o",
" /| o o |/ ",
"o | - | ",
" +-----+ ",
" | \ "
]
];
With that, you’ve learned how to use a circular doubly linked list. In the final section of this chapter, we’ll learn about three list-related interfaces.
List-related interfaces
While developing applications in C#, you frequently use various collections, including lists. For this reason, it is worth mentioning three common interfaces:
* `IEnumerable`
* `ICollection`
* `IList`
The order of them is important because `IEnumerable` is the base interface for `ICollection` and `IList`, while `ICollection` is the base interface for `IList`. However, what is inside such interfaces? Let’s take a look!
`IEnumerable` only provides you with a `GetEnumerator` method.
The `ICollection` interface adds the following methods for **manipulating** **the collection**:
* `Add` adds a given item to the collection
* `Clear` removes all the items from the collection
* `Contains` checks whether a given item exists in the collection
* `Remove` removes the first occurrence of a given item from the collection
It also exposes the `Count` and `IsReadOnly` properties, as well as the `CopyTo` method for copying the collection to an array.
The last interface I’ll mention here is `IList`. It allows you to **access items within the collection by an index**. Thus, the interface contains the indexer for getting or setting an item at a specified index in the collection, as well as methods:
* `IndexOf` returns an index of a given item in the collection
* `Insert` inserts a given item at a specified index in the collection
* `RemoveAt` removes an item at a specified index in the collection
As an example, do you know that the `LinkedList` generic class implements both generic and non-generic variants of the `ICollection` and `IE``numerable` interfaces? I strongly encourage you to take a look at other collections to see what interfaces are implemented by them. You can see this by clicking on the collection name in your code (such as `List` or `ArrayList`) and choosing the **Go To Definition** option from the context menu or simply by pressing *F12*.
Where can you find more information?
You can find content regarding the mentioned interfaces at: [`learn.microsoft.com/en-us/dotnet/api/system.collections.ienumerable`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.ienumerable), [`learn.microsoft.com/en-us/dotnet/api/system.collections.icollection`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.icollection), and [`learn.microsoft.com/en-us/dotnet/api/system.collections.ilist`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.ilist).
Next, I’ll summarize this chapter.
Summary
This chapter was dedicated to **lists**, which are among the most common data structures that are used while developing various kinds of applications. However, this topic is not very easy because there are various variants of lists, including simple, sorted, and linked ones. Even this structure can be further divided as you saw while reading this chapter.
First, you learned about **simple lists**, which can be implemented as an **array list** or as a **generic list**. One of the most important differences between them is that the array list is not strongly typed, while the generic list is. You learned about various properties and methods available for these data structures, together with extension methods. Such information was supported by code snippets.
Next, you learned about **sorted lists**, which ensures a proper order of elements available in the collection. It is a bit of a different data structure but can be useful in various development scenarios. You learned how to use it while creating an address book, which is always sorted by names.
Finally, you took a closer look at linked lists, starting with **singly linked lists** and **doubly linked lists**. They allow you to navigate between elements, either in only one direction or in both directions. Such a data structure can be easily extended to a **circular linked list**, either in singly or doubly linked variants. Therefore, you can benefit from the features of suitable structures without the significant development effort.
The available types of data structures can sound quite complicated. However, in this chapter, you saw detailed descriptions of particular data structures, together with illustrations and C#-based implementations. These should have helped simplify things for you and can be used as a basis for your future projects.
Are you ready to learn other data structures? If so, proceed to the next chapter and read about **stacks** and **queues**!
第五章:栈和队列
到目前为止,您已经学到了很多关于数组和列表的知识。然而,这些结构并不是唯一可用的。在其他结构中,还有一组更专业的数据结构,称为 有限访问 数据结构。
这是什么意思?为了解释这个名称,让我们暂时回到数组的话题,数组属于 随机访问数据结构 的组。它们之间的区别只有一个词 - 那就是有限或随机。如您所知,数组允许您存储数据,并使用索引访问各种元素。因此,您可以从数组中轻松获取第一个、中间的、第 n 个或最后一个元素。因此,它可以被称为随机访问数据结构。
然而,“有限”是什么意思?答案是简单的。在有限访问数据结构中,您不能从结构中访问每个元素。因此,获取元素的方式是严格指定的。例如,您只能获取第一个或最后一个元素,但不能从数据结构中获取第 n 个元素。有限访问数据结构的流行代表是栈和队列,这些是本章中提到的主题。
您将看到栈的应用,以及队列的一些变体,包括普通队列、优先队列和循环队列。为了使理解更加容易,文本配有插图和带有详细解释的代码片段。
本章将涵盖以下主题:
-
栈
-
队列
-
优先队列
-
循环队列
栈
首先,让我们来谈谈 栈。它是一种数据结构,允许您 只在顶部添加新元素(称为 push 操作)和 通过从顶部移除元素来获取元素(pop 操作)。因此,栈符合 LIFO 原则,即 后进先出。
想象一个栈
如果您想更好地可视化栈,让我们暂时合上书本,去厨房看看一堆盘子,每个盘子都放在另一个盘子的上面。您只能将新盘子放在堆的顶部,您也只能从堆的顶部取盘子。您不能在不取掉顶部前六个盘子的情况下取走第七个盘子,您也不能在盘子堆的中间添加盘子。所以,最后添加的盘子(后进先出)将首先从盘子堆中取出。而且,甚至不要尝试从盘子堆的中间取盘子,因为您不想打碎盘子!栈的操作方式类似。它允许您只在顶部添加新元素(push 操作)并且只能通过从顶部移除元素来获取元素(pop 操作)。
如下所示是带有 push 和 pop 操作的栈的图示:

图 5.1 – 栈的示意图
这似乎很简单,不是吗?确实如此,您可以通过使用内置的泛型Stack类来享受堆栈的各种功能。值得记住的是,它位于System.Collections.Generic命名空间中。
让我们提及这个类中的三个方法:
-
Push在堆栈顶部插入一个元素 -
Pop从堆栈顶部移除一个元素并返回它 -
Peek返回堆栈顶部的元素而不移除它
您还可以访问其他方法,例如从堆栈中移除所有元素(Clear)或检查给定元素是否在堆栈中可用(Contains)。您可以使用Count属性获取当前堆栈中的元素数量。
关于性能如何?
值得注意的是,Push方法要么是O(1)操作,如果容量不需要增加,否则是O(n),其中n是堆栈中的元素数量。Pop和Peek都是O(1)操作。
由于时间复杂度看起来非常有前景,现在是时候看看一些展示堆栈实际应用的示例了。
哪里可以找到更多信息?
您可以在learn.microsoft.com/en-us/dotnet/api/system.collections.generic.stack-1找到有关堆栈的内容
示例 – 反转一个单词
对于第一个示例,让我们尝试使用堆栈来反转一个单词。您可以通过遍历形成字符串的字符,将每个字符添加到堆栈顶部,然后移除堆栈中的所有元素来实现这一点。最后,您将收到反转后的单词,如下面的图表示例所示,如何反转MARCIN:

图 5.2 – 反转单词示例的说明
以下代码片段展示了实现:
string text = "MARCIN";
Stack<char> chars = new();
foreach (char c in text) { chars.Push(c); }
while (chars.Stack class is created. In this scenario, the stack can contain only char elements. Then, you iterate through all characters using a foreach loop and insert each character at the top of the stack by calling the Push method. The remaining part of the code consists of a while loop, which is executed until the stack is empty. This condition is checked using the Count property. In each iteration, the top element is removed from the stack (by calling Pop) and written in the console (using the Write static method of the Console class).
After running the code, you will receive the following result:
NICRAM
Example – Tower of Hanoi
The next example is a significantly more complex application of stacks. It is related to the mathematical game *Tower of Hanoi*. The game requires three rods, onto which you can put discs. Each disc has a different size. At the beginning, all discs are placed on the first rod, forming a stack, ordered from the smallest (at the top) to the biggest (at the bottom). It is presented in the following diagram (on the left):

Figure 5.3 – Illustration of the Tower of Hanoi example
The aim of the game is to `FROM`) `TO`). However, during the whole game, you **cannot place a bigger disc on a smaller one**. Moreover, you **can only move one disc at a time**, and, of course, you **can only take a disc from the top of** **any rod**.
How could you move discs between the rods to comply with the aforementioned rules? The problem can be divided into sub-problems:
* `FROM` to `TO`, without using the `AUXILIARY` rod.
* `FROM` to `AUXILIARY`. Then, you move the remaining disc from `FROM` to `TO`. At the end, you move a disc from `AUXILIARY` to `TO`.
* `FROM` to `AUXILIARY`, using the mechanism described earlier. The operation involves `TO` as the auxiliary rod. Then, you move the remaining disc from `FROM` to `TO`, and then move two discs from `AUXILIARY` to `TO`, using `FROM` as the auxiliary rod.
As you can see, you can solve the problem of `FROM` to `AUXILIARY`, using `TO` as the auxiliary rod. Then, you should move the remaining disc from `FROM` to `TO`. At the end, you just need to move *n-1* discs from `AUXILIARY` to the `TO` rod, using `FROM` as the auxiliary rod.
Now that you know the basic rules, let’s proceed to the code. First, let’s focus on the `Game` class, which contains the logic related to the game:
public class Game
{
public Stack
public Stack
public Stack
public int DiscsCount { get; private set; }
public int MovesCount { get; private set; }
public event EventHandler
}
The class contains five properties, representing the following:
* Three rods (`From`, `To`, `Auxiliary`)
* The overall number of discs (`DiscsCount`)
* The number of performed moves (`MovesCount`)
The `MoveCompleted` event is declared as well. It is fired after each move to inform that the user interface should be refreshed. Therefore, you can show the proper content, illustrating the current state of the rods.
Apart from the properties and the event, the class also has the following constructor:
public Game(int discsCount)
{
DiscsCount = discsCount;
From = new Stack
To = new Stack
Auxiliary = new Stack
for (int i = 0; i < discsCount; i++)
{
int size = discsCount - i;
From.Push(size);
}
}
The constructor takes only one parameter, namely the number of discs (`discsCount`), and sets it as a value of the `DiscsCount` property. Then, new instances of the `Stack` class are created, and references to them are stored in the `From`, `To`, and `Auxiliary` properties. At the end, a `for` loop is used to create the necessary number of discs and to add elements to the first stack (`From`), using the `Push` method.
It is worth noting that `From`, `To`, and `Auxiliary` stacks only store integer values (`Stack<int>`). Each integer value represents the size of a particular disc. Such data is crucial due to the rules of moving discs between rods.
One of the most interesting and important parts of the code is the `MoveAsync` recursive method. It takes four parameters, namely the number of discs and references to three stacks. However, what happens in the `MoveAsync` method? Let’s look inside:
public async Task MoveAsync(int discs, Stack
Stack
{
if (discs == 0) { return; }
await MoveAsync(discs - 1, from, auxiliary, to);
to.Push(from.Pop());
MovesCount++;
MoveCompleted?.Invoke(this, EventArgs.Empty);
await Task.Delay(250);
await MoveAsync(discs - 1, auxiliary, to, from);
}
As `MoveAsync` is called recursively, it is necessary to specify an exit condition to prevent the method from being called infinitely. In this case, the method will not call itself when the value of the `discs` parameter is equal to `0`.
Otherwise, the `MoveAsync` method is called, but the order of stacks is changed. Then, the element is removed from the stack represented by the second parameter (`from`), and inserted at the top of the stack represented by the third parameter (`to`).
In the following lines, the number of moves (`MovesCount`) is incremented and the `MoveCompleted` event is fired. It is responsible for refreshing the user interface. Then, the algorithm stops for 250 milliseconds to show the following steps of the operation in a way well visible to a user.
At the end, the `MoveAsync` method is called again, with another configuration of rod order. By calling this method several times, the discs will be moved from the first (`From`) rod to the second (`To`) rod. The operations performed in the `MoveAsync` method are consistent with the description of the problem of moving *n* discs between rods, as explained in the introduction to this example.
When the class with the logic regarding the *Tower of Hanoi* game is created, let’s see how to create a user interface that allows you to present the following moves of the algorithm. Such a task is accomplished by the `Visualization` class:
public class Visualization
{
private readonly Game _game;
private readonly int _columnSize;
private readonly char[,] _board;
public Visualization(Game game)
{
_game = game;
_columnSize = Math.Max(6,
GetDiscWidth(_game.DiscsCount) + 2);
_board = new char[_game.DiscsCount,
_columnSize * 3];
}
}
It contains three private fields, namely storing a reference to data of the game (`_game`), the number of characters to present a single rod (`_columnSize`), as well as a two-dimensional array with visualization of all rods, shown in the console (`_board`). The constructor takes only one parameter and sets values for all private fields.
Column size is calculated using the `GetDiscWidth` auxiliary method:
`private int Show, which is shown next:
public void Show(Game game)
{
Console.Clear();
if (game.DiscsCount <= 0) { return; }
FillEmptyBoard();
FillRodOnBoard(1, game.From);
FillRodOnBoard(2, game.To);
FillRodOnBoard(3, game.Auxiliary);
Console.WriteLine(Center("FROM")
+ Center("TO") + Center("AUXILIARY"));
DrawBoard();
Console.WriteLine($"\nMoves: {game.MovesCount}");
Console.WriteLine($"Discs: {game.DiscsCount}");
}
该方法通过调用Clear方法清除控制台当前内容。然后,它调用FillEmptyBoard和FillRodOnBoard方法来清除控制台应显示的内容,然后使用FillRodOnBoard的每次调用填充杆的当前状态数据。接下来,显示每个杆的标题,绘制板,以及写入移动次数和圆盘数。
要清除板的内容,只需遍历二维数组中的所有元素,并将每个项目的值设置为空格,如下所示:
private void FillEmptyBoard()
{
for (int y = 0; y < _board.GetLength(0); y++)
{
for (int x = 0; x < _board.GetLength(1); x++)
{
_board[y, x] = ' ';
}
}
}
如果你想了解如何填充与特定杆相关的二维数组部分,让我们看看FillRodOnBoard的代码:
private void FillRodOnBoard(int column, Stack<int> stack)
{
int discsCount = _game.DiscsCount;
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++)
{
_board[row, x] = '=';
}
}
}
首先,计算左边界以在整体数组中正确部分添加数据 - 也就是说,在正确的列范围内。方法的主要部分是for循环,其中迭代次数等于堆栈中放置的圆盘数量。在每次迭代中,使用ElementAt扩展方法(来自System.Linq命名空间)读取当前圆盘的大小。接下来,计算圆盘应显示的行索引,以及列的起始和结束索引。最后,使用for循环在数组中适当位置插入等号(=)。
其中一个辅助方法是Center。它的目的是在传递给参数的文本前后添加额外的空格,以在列中居中文本:
private string Center(string text)
{
int margin = (_columnSize - text.Length) / 2;
return text.PadLeft(margin + text.Length)
.PadRight(_columnSize);
}
最后使用的方法命名为DrawBoard。它简单地遍历二维数组中的所有元素,并在控制台中写入内容。代码如下所示:
private void DrawBoard()
{
for (int y = 0; y < _board.GetLength(0); y++)
{
string line = string.Empty;
for (int x = 0; x < _board.GetLength(1); x++)
{
line += _board[y, x];
}
Console.WriteLine(line);
}
}
最后,让我们看看位于Program.cs文件中的主要代码:
Game game = new(10);
Visualization vis = new(game);
game.MoveCompleted += (s, e) => vis.Show((Game)s!);
await game.MoveAsync(game.DiscsCount,
Game class is created. The parameter indicates that 10 discs are used. In the next line, you create a new instance of the Visualization class responsible for showing the following steps of the game. You also specify that the Show method is called when the MoveCompleted event is fired. Finally, you call the MoveAsync method to start moving discs between rods.
You already added the necessary code to run the *Tower of Hanoi* mathematical game. Let’s launch the application and see it in action! Just after starting the program, you see that all discs are located in the first rod (`FROM`). In the next step, the smallest disc is moved from the top of the first rod (`FROM`) to the top of the third rod (`AUXILIARY`), as shown in the following screenshot:

Figure 5.4 – The second step in the Tower of Hanoi example
While making many other moves in the program, you can see how discs are moved between all three rods. One of the intermediate steps is as follows:

Figure 5.5 – One of the intermediate steps in the Tower of Hanoi example
When the necessary moves are completed, all discs are moved from the first rod (`FROM`) to the second one (`TO`). The final result is presented next:

Figure 5.6 – Final step in the Tower of Hanoi example
Finally, it is worth mentioning the number of moves necessary to complete the *Tower of Hanoi* game. In the case of 10 discs, the number of moves is 1,023\. If you use only 3 discs, the number of moves is 7\. Generally speaking, **the number of moves can be calculated with the formula** **2**n**-1**, where *n* is the number of discs.
That’s all! In this section, you learned the first limited access data structure, namely a stack. Now, it is high time that you get to know more about queues.
Queues
A **queue** is a data structure that allows you **to add a new element only at the end of the queue** (referred to as an **enqueue** operation) and **to get an element only from the beginning of the queue** (a **dequeue** operation). For this reason, a queue is consistent with the **FIFO** principle, which stands for **First-In First-Out**.
Imagine a queue
If you want to better imagine a queue, let’s take a break from learning data structures and algorithms, wear your favorite jacket, and go to a shop in the vicinity. You buy your favorite ice cream, and you see five people waiting for checkout. Oh no... You are the last one, so you need to wait until the first, second, third, fourth, and fifth person pay. These lines in shops can be frustrating! In general, new people stand at the end of the line, and the next person is taken to the checkout from the beginning of the line. No one is allowed to choose a person from the middle and serve them in a different order. The queue data structure operates similarly. You can only add new elements at the end of the queue and remove an element from the beginning of the queue. So, people who come first (first-in) are served at the beginning (first-out).
The operation of a queue is presented in the following diagram:

Figure 5.7 – Illustration of a queue
It is worth mentioning that a queue is a **recursive data structure**, similar to a stack. This means that **a queue can be either empty or consists of the first element and the rest of the queue, which also forms a queue**. Let’s take a look at the following diagram, where the beginning of the queue is marked with a bold line:

Figure 5.8 – A queue as a recursive data structure
The queue data structure seems to be very easy to understand, as well as being similar to a stack, apart from the way of removing an element. Does this mean that you can also use a built-in class to use a queue in your programs? Fortunately, yes! The available generic class is `Queue` from the `System.Collections.Generic` namespace.
Where can you find more information?
You can find content regarding a queue at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.queue-1`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.queue-1).
The `Queue` class contains the following set of methods:
* `Enqueue` adds an element at the end of the queue
* `Dequeue` removes an element from the beginning of the queue and returns it
* `Peek` returns an element from the beginning of the queue without removing it
* `Clear` removes all elements from the queue
* `Contains` checks whether the queue contains the given element
The `Queue` class also contains the `Count` property, which returns the total number of elements located in the queue. It can be used to check whether the queue is empty.
What about the performance?
It is worth mentioning that the `Enqueue` method is either an *O(1)* operation, if the internal array does not need to be reallocated, or *O(n)* otherwise, where *n* is the number of elements in the queue. Both `Dequeue` and `Peek` are *O(1)* operations.
The great performance results are supported by a very easy application of this data structure, as shown in the following part of the code:
List<int> items = [2, -4, 1, 8, 5];
Queue<int> queue = new();
items.ForEach(queue.Enqueue);
while (queue.Count > 0)
{
Console.WriteLine(queue.Dequeue());
}
Here, you create a new list and a queue containing only integer values. Then, you add all elements from the list to the queue, using the `Enqueue` method. At the end, you use a `while` loop to dequeue all the elements, using the `Dequeue` method.
It is worth noting that in the third line, you do not use the lambda expression and simply use the name of the method. Of course, you can use the following form instead:
`items.ForEach(ConcurrentQueue generic class from the System.Collections.Concurrent namespace. This class contains a set of built-in methods to perform various operations on the queue, such as the following:
-
Enqueue将一个元素添加到队列的末尾 -
TryDequeue尝试从开始移除一个元素并返回它 -
TryPeek尝试从开始返回一个元素而不移除它
TryDequeue和TryPeek都有带有out关键字的参数。如果操作成功,此类方法返回true,并将结果作为out参数的值返回。此外,ConcurrentQueue类还包含两个属性,即Count用于获取存储在集合中的元素数量,以及IsEmpty用于返回一个值,指示队列是否为空。
哪里可以找到更多信息?
你可以在learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentqueue-1找到有关ConcurrentQueue类的相关内容。
在这个简短的介绍之后,让我们来看两个例子,这两个例子代表了呼叫中心环境中的队列,其中有许多客户和一位或多位顾问。
示例 – 单顾问呼叫中心
这个第一个例子代表了对呼叫中心解决方案的简单方法,其中有许多客户(具有不同的标识符),以及仅有一位顾问,他按照来电出现的顺序回答等待的电话。
下一个场景如下所示:

图 5.9 – 单顾问呼叫中心示例的说明
如前图所示,客户执行了四个呼叫。它们被添加到等待电话的队列中,即来自客户#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? AnswerTime { get; set; }
public DateTime? EndTime { get; set; }
public string? Consultant { get; set; }
}
该类包含六个属性,代表一个呼叫的唯一标识符(Id)、客户标识符(ClientId)、呼叫开始时的日期和时间(CallTime)、回答时的日期和时间(AnswerTime)、结束时的日期和时间(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 IncomingCall Call(int clientId)
{
IncomingCall call = new()
{
Id = ++_counter,
ClientId = clientId,
CallTime = DateTime.Now
};
Calls.Enqueue(call);
return call;
}
在这里,你创建IncomingCall类的新实例并设置其属性的值,即其标识符(包括预增量_counter字段),客户标识符(使用clientId参数),以及呼叫时间。通过调用Enqueue方法将创建的实例添加到队列中并返回。
下一个方法是 Answer。它表示从等待时间最长的队列中接听电话的操作。这样的电话由队列开头的元素表示。Answer 方法如下所示:
public IncomingCall? Answer(string consultant)
{
if (!AreWaitingCalls()) { return null; }
IncomingCall call = Calls.Dequeue();
call.Consultant = consultant;
call.AnswerTime = DateTime.Now;
return call;
}
在此方法中,你检查队列是否为空。如果是,则方法返回 null,这意味着没有电话可以被顾问接听。否则,电话将从队列中移除(使用 Dequeue 方法),并通过设置顾问的姓名(使用 consultant 参数)和接听时间(到当前日期和时间)来更新其属性。最后,返回电话的数据。
除了 Call 和 Answer 方法之外,你还实现了 End 方法,该方法在顾问结束与特定客户的通话时被调用。在这种情况下,你只需设置结束时间,如下所示:
public void End(IncomingCall call)
=> call.EndTime = DateTime.Now;
CallCenter 类中的最后一个方法是名为 AreWaitingCalls 的方法。它返回一个值,表示队列中是否有等待的电话,使用 Queue 类的 Count 属性。其代码如下:
public bool Program.cs file and its code:
Random random = new();
CallCenter center = new();
center.Call(1234);
center.Call(5678);
center.Call(1468);
center.Call(9641);
while (center.AreWaitingCalls())
{
IncomingCall call = center.Answer("Marcin")!;
记录($"电话 #{call.Id} 来自客户 #{call.ClientId}
接听 by {call.Consultant}.");
await Task.Delay(random.Next(1000, 10000));
center.End(call);
记录($"电话 #{call.Id} 来自客户 #{call.ClientId}
结束 by {call.Consultant}.");
}
You create a new instance of the `Random` class (for getting random numbers), as well as an instance of the `CallCenter` class. Then, you simulate making a few calls by clients, namely with the following identifiers: `1234`, `5678`, `1468`, and `9641`. The most interesting part of the code is located in the `while` loop, which is executed until there are no waiting calls in the queue. Within the loop, the consultant answers the call (using the `Answer` method) and a log is generated (using the `Log` auxiliary method). Then, you wait for a random number of milliseconds (between `1000` and `10000`) to simulate the various lengths of a call. When this has elapsed, the call ends (by calling the `End` method), and a proper log is generated.
The last part of the code necessary for this example is the `Log` method:
void Log(string text) =>
控制台输出($"[{DateTime.Now:HH:mm:ss}] {text}");
When you run the example, you will receive a result similar to the following:
[13:10:53] 来自客户 #1234 的电话 #1 由 Marcin 接听。
[13:10:56] 来自客户 #1234 的电话 #1 由 Marcin 结束。
[13:10:56] 来自客户 #5678 的电话 #2 由 Marcin 接听。
[13:10:59] 来自客户 #5678 的电话 #2 由 Marcin 结束。
[13:10:59] 来自客户 #1468 的电话 #3 由 Marcin 接听。
[13:11:06] 来自客户 #1468 的电话 #3 由 Marcin 结束。
[13:11:06] 来自客户 #9641 的电话 #4 由 Marcin 接听。
[13:11:09] 来自客户 #9641 的电话 #4 由 Marcin 结束。
Congratulations! You just completed the first example regarding a queue data structure. If you want to learn more about the thread-safe version of the queue-related class, let’s proceed to the next example.
Example – call center with many consultants
The example shown in the preceding section was intentionally simplified to make understanding a queue much simpler. However, it is high time you make it more related to real-world problems. In this section, you will see how to expand it to support many consultants, as shown in the following diagram:

Figure 5.10 – Illustration of the call center with many consultants example
What is important is that both clients and consultants operate at the same time. If there are more incoming calls than available consultants, a new call will be added to the queue and will wait until there is a consultant who can answer the call. If there are too many consultants and few calls, the consultants will wait for a call. To perform this task, you create a few threads, which access the queue. Therefore, you use a thread-safe version of the queue, namely the `ConcurrentQueue` class.
Let’s take a look at the code! First, you need to declare an `IncomingCall` class, the code of which is exactly the same as in the previous example. Various modifications are necessary in the `CallCenter` class, as presented next:
using System.Collections.Concurrent;
public class CallCenter
{
private int _counter = 0;
public ConcurrentQueue
{ get; private set; }
public CallCenter() => Calls =
new ConcurrentQueue
}
As the `Enqueue` method is available in both the `Queue` and `ConcurrentQueue` classes, no changes are necessary in the `Call` method.
However, the `Dequeue` method does not exist in `ConcurrentQueue`. For this reason, you need to modify the `Answer` method to use the `TryDequeue` method. It returns a value indicating whether the element is removed from the queue. The removed element is returned using the `out` parameter, as shown next:
public IncomingCall? Answer(string consultant)
{
if (!Calls.IsEmpty
&& Calls.TryDequeue(out IncomingCall? call))
{
call.Consultant = consultant;
call.AnswerTime = DateTime.Now;
return call;
}
return null;
}
You can also slightly modify the `AreWaitingCalls` method to use the `IsEmpty` property instead of `Count`, presented as follows:
public bool AreWaitingCalls() => CallCenter 类。然而,还需要在 Program.cs 中的代码进行更多更改,如下所示:
Random random = new();
CallCenter center = new();
Parallel.Invoke(
() => Clients(center),
() => Consultant(center, "Marcin", ConsoleColor.Red),
() => Consultant(center, "James", ConsoleColor.Yellow),
() => CallCenter instance, you start execution of four actions, namely representing clients and three consultants, using the Invoke static method of the Parallel class from the System.Threading.Tasks namespace. The lambda expressions are used to specify methods that are called, namely Clients for client-related operations and Consultant for consultant-related tasks. You also specify additional parameters, such as a name and a color for a given consultant.
The `Clients` method represents operations performed cyclically by many clients. Its code is shown in the following block:
void Clients(CallCenter center)
{
while (true)
{
int clientId = random.Next(1, 10000);
IncomingCall call = center.Call(clientId);
记录($"来电 #{call.Id}
from client #{clientId}");
记录($"等待队列中的电话:
{center.Calls.Count}");
Thread.Sleep(random.Next(500, 2000));
}
}
Within the `while` loop, you get a random number as an identifier of a client (`clientId`), and the `Call` method is called. The client identifier is logged, together with the number of waiting calls. At the end, the client-related thread is suspended for a random number of milliseconds in the range between 500 ms and 2,000 ms, to simulate the delay between another call made by the next client.
The following method is named `Consultant` and is executed on a separate thread for each consultant. The method takes three parameters, namely an instance of `CallCenter`, as well as a name and color for the consultant. The code is as follows:
void Consultant(CallCenter center, string name,
ConsoleColor color)
{
while (true)
{
Thread.Sleep(random.Next(500, 1000));
IncomingCall? call = center.Answer(name);
if (call == null) { continue; }
Log($"Call #{call.Id} from client #{call.ClientId}
answered by {call.Consultant}.", color);
Thread.Sleep(random.Next(1000, 10000));
center.End(call);
`Log($"Call #{call.Id} from client #{call.ClientId}
ended by {call.Consultant}.", color);
}
}
Within the `while` loop, the consultant waits for a random period, between 0.5 and 1 second. Then, they try to answer the first waiting call, using the `Answer` method. If there are no waiting calls, you skip to the next iteration. Otherwise, the log is presented in a color of the current consultant. Then, the thread is suspended for a random period of time between 1 and 10 seconds. After this time, the consultant ends the call, which is indicated by calling the `End` method, and a log is generated.
The last method is named `Log` and is similar to the previous example:
void Log(string text,
ConsoleColor color = ConsoleColor.Gray)
{
Console.ForegroundColor = color;
Console.WriteLine(
$"[{DateTime.Now:HH:mm:ss.fff}] {text}");
Console.ResetColor();
}
When you run the program and wait for some time, you will receive a result similar to the one shown in the following screenshot:

Figure 5.11 – Screenshot of the call center with many consultants example
You just completed two examples representing the application of a queue in the case of a call center scenario. Are you already a queue master?
Try to modify parameters on your own
It is a good idea to modify various parameters of the program, such as the number of consultants, as well as delay times, especially the delay between following calls performed by clients. Then, you will see how the algorithm works in the case when there are too many clients, as well as too many or too few consultants.
However, how can you handle clients with priority support? In the current solution, they wait in the same queue as clients with the standard support plan. Do you need to create two queues and first take clients from the prioritized queue? If so, what should happen if you introduce another support plan? Do you need to add another queue and introduce such modifications in the code? Fortunately, no! You can use another data structure, namely a priority queue, as explained in detail in the following section.
Priority queues
A `0`, while lower priority is specified by `1`, `2`, `3`, and so on.
Imagine a priority queue
If you want to better visualize a priority queue, close your eyes for a moment and imagine yourself going on the greatest vacation of your life. All passengers are already lining up at the gate, including you, but it turns out that right next to it, there is a much shorter queue for people who have a gold airline card. There are only 3 people in that line, and in yours there are over 100\. These 3 people will be served first, and only then will the service of your queue begin. Well, that’s how a priority queue works! You first serve all the highest priority items in the order they were added to the priority queue. Then, you return all lower priority items, also in the order they were added to the priority queue. Then, you take all items with an even lower priority, and so on, until all priorities are properly handled. And now the dream about holidays is over, it’s time to get back to further learning data structures and algorithms!
A diagram of a priority queue is presented next:

Figure 5.12 – Illustration of a priority queue
Let’s analyze the diagram. First, the priority queue contains two elements with the same priority (equal to `1`), namely `Marcin` (first) and `Lily` (second). Then, `Mary` is added with the lowest priority (`2`), which means that this element is placed at the end of the queue. In the next step, `John` is added with the highest priority (`0`), so it is added at the beginning of the priority queue. The third column presents the addition of `Emily` with a priority equal to `1` - the same as `Marcin` and `Lily`. As `Emily` is added last, it is added after `Lily`. According to the aforementioned rules, you add the following elements - namely, `Sarah` with a priority set to `2` and `Luke` with a priority equal to `1`. The final order is shown on the right-hand side of the preceding diagram.
Of course, it is possible to implement a priority queue `PriorityQueue` from the `System.Collections.Generic` namespace. The mentioned class requires you to specify two types, namely for the stored data and for the priority. The class contains some useful methods, such as the following:
* `Enqueue` adds an element to the priority queue
* `Dequeue` removes an element from the beginning and returns it
* `Clear` removes all elements from the priority queue
* `Peek` returns an element from the beginning of the queue without removing it
You can also get the number of elements in the queue using the `Count` property. The class contains a set of other methods as well - for example, `TryDequeue` and `TryPeek`.
Where can you find more information?
You can find content regarding a priority queue at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.priorityqueue-2`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.priorityqueue-2).
To make your horizons even broader, you will learn how to use `OptimizedPriorityQueue`. More information about this package is available at [`www.nuget.org/packages/OptimizedPriorityQueue`](https://www.nuget.org/packages/OptimizedPriorityQueue).
How to install a NuGet package?
Do you know how you can add a NuGet package to your project? If not, select **Manage NuGet Packages** from the context menu of the project node in the **Solution Explorer** window. Then, choose the **Browse** tab in the opened window and type the name of the package in the **Search** box. Click on the name of the package and press **Install**. Confirm this operation and wait until the installation is ready.
While the package is being installed, do you know that you can also be an author of a NuGet package that can be later used by developers from various regions of the world? If you create something great, please let me know! In the meantime, please keep in mind that you always should comply with the license terms of particular packages, and you should not fully trust all available packages, especially those with a smaller number of downloads. However, NuGet packages are a nice feature that can significantly simplify and speed up your work.
The `OptimizedPriorityQueue` library simplifies the application of a priority queue. Within it, the `SimplePriorityQueue` generic class is available, which contains some useful methods, such as the following:
* `Enqueue` adds an element to the priority queue
* `Dequeue` removes an element from the beginning of the queue and returns it
* `GetPriority` returns the priority of the element
* `UpdatePriority` updates the priority of the element
* `Contains` checks whether the element exists in the priority queue
* `Clear` removes all elements from the priority queue
You can get the number of elements currently available in the priority queue using the `Count` property. If you want to get an element from the beginning of the priority queue without removing it, you can use the `First` property. Moreover, the class contains a set of other methods, such as `TryDequeue` and `TryRemove`. As you can see, the names of some members of the class are even the same, as in the case of the `PriorityQueue` built-in class. Thus, you can easily change one implementation to another and check the impact of the implementation on the results or the performance of your solution.
What about the performance?
Both `Enqueue` and `Dequeue` methods are *O(log* *n)* operations.
If you want to see in action the priority queue depicted in the preceding diagram, you can use the following part of the code:
using Priority_Queue;
SimplePriorityQueue
queue.Enqueue("Marcin", 1);
queue.Enqueue("Lily", 1);
queue.Enqueue("Mary", 2);
queue.Enqueue("John", 0);
queue.Enqueue("Emily", 1);
queue.Enqueue("Sarah", 2);
queue.Enqueue("Luke", 1);
while (queue.Count > 0)
{
Console.WriteLine(queue.Dequeue());
}
At the beginning, you create a new priority queue containing only `string` values. Then, you add all elements in the correct order, together with specifying their priority, using the `Enqueue` method. At the end, you use a `while` loop to dequeue all the elements, using the `Dequeue` method. Pretty simple and easy to understand, isn’t it?
When you run the code, you will get the following result:
John
Marcin
Lily
Emily
Luke
Mary
Sarah
After this short introduction to the topic of priority queues, let’s proceed to the example of a call center with priority support, which is described next.
Example – call center with priority support
As an example of a priority queue, let’s present a simple approach to the call center solution, where there are many clients (with different identifiers), and only one consultant who answers waiting calls, first from clients with the priority support plan, and then from clients with the standard support plan.
This scenario is presented in the following diagram. Calls with standard priority are marked with `–`, while calls with priority support are indicated by `∆`, as follows:

Figure 5.13 – Illustration of the call center with priority support example
The priority queue contains only three elements, which will be served in the following order: `#5678` (the priority support), `#1234`, and `#1468`. However, the call from the client with the `#9641` identifier causes the order to change to `#5678`, `#9641` (due to priority support), `#1234`, and `#1468`.
It is high time to write some code! Let’s proceed to the implementation of the `IncomingCall` class:
public class IncomingCall
{
public int Id { get; set; }
public int ClientId { get; set; }
public DateTime CallTime { get; set; }
public DateTime? AnswerTime { get; set; }
public DateTime? EndTime { get; set; }
public string? Consultant { get; set; }
public bool IsPriority { get; set; }
}
Here, there is only one change in comparison to the previously presented scenario of the simple call center application - namely, the `IsPriority` property is added. It indicates whether the current call has priority (`true`) or standard support (`false`).
Some modifications are also necessary for the `CallCenter` class, where a type of the `Calls` property is changed to `SimplePriorityQueue<IncomingCall>`, as shown next:
public class CallCenter
{
private int _counter = 0;
public SimplePriorityQueue
{ get; private set; }
public CallCenter() => Calls =
new SimplePriorityQueue
}
The following changes are necessary for the `Call` method:
public IncomingCall Call(int clientId, bool isPriority)
{
IncomingCall call = new()
{
Id = ++_counter,
ClientId = clientId,
CallTime = DateTime.Now,
IsPriority = isPriority
};
Calls.Enqueue(call, isPriority ? 0 : 1);
return call;
}
Here, a value of the `IsPriority` property is set using the parameter. Moreover, while calling the `Enqueue` method, two parameters are used, not only the value of the element (an instance of the `IncomingCall` class), but also an integer value representing the priority, namely `0` in the case of priority support, or `1` otherwise.
No more changes are necessary in the methods of the `CallCenter` class, namely in `Answer`, `End`, and `AreWaitingCalls`, which are shown next for your convenience:
public IncomingCall? Answer(string consultant)
{
if (!AreWaitingCalls()) { return null; }
IncomingCall call = Calls.Dequeue();
call.Consultant = consultant;
call.AnswerTime = DateTime.Now;
return call;
}
public void End(IncomingCall call) =>
call.EndTime = DateTime.Now;
public bool Program.cs 文件:
Random random = new();
CallCenter center = new();
center.Call(1234, false);
center.Call(5678, true);
center.Call(1468, false);
center.Call(9641, true);
while (center.AreWaitingCalls())
{
IncomingCall call = center.Answer("Marcin")!;
Log($"Call #{call.Id} from client #{call.ClientId} is
answered by {call.Consultant}.", call.IsPriority);
await Task.Delay(random.Next(1000, 10000));
center.End(call);
Log($"Call #{call.Id} from client #{call.ClientId} is
ended by {call.Consultant}.", call.IsPriority);
}
void Log(string text, bool isPriority)
{
Console.ForegroundColor = isPriority
? ConsoleColor.Red : ConsoleColor.Gray;
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {text}");
Console.ResetColor();
}
您可能会惊讶地发现,在这个代码部分只需要进行微小的改动。原因是关于已使用数据结构的逻辑隐藏在CallCenter类中。在Program.cs文件中,您调用CallCenter类公开的方法和使用属性。您只需修改如何将调用添加到队列中(包括优先级),以及当顾问接听电话时调整显示的日志,根据电话的优先级选择合适的颜色。就这样!
当您运行应用程序时,您将收到类似以下的结果:

图 5.14 – 带优先级支持的呼叫中心截图示例
如你所见,呼叫是按照正确的顺序服务的。这意味着具有优先级支持的客户的呼叫比具有标准支持计划的客户的呼叫先被服务,尽管这样的呼叫需要等待更长的时间才能得到回答。
环形队列
在本章结束时,让我们看看另一种数据结构,即环形队列,也称为环形缓冲区。在这种情况下,队列形成一个环,内部使用数组,并且可以放置在队列中的最大元素数量是有限的。你需要指定两个变量,它们指示前和后元素的索引。前一个指向第一个将被出队的元素。后一个指向队列中的最后一个元素。
想象一个环形队列
如果你想更好地想象环形队列,回想一下你年轻的时候,当你说服你的父母带你坐过山车的时候。它由 10 节车厢组成,每节车厢可以容纳 2 人,所以只有 20 人可以参加一次过山车之旅。由于这是一个独特的景点,这样的旅程每小时只发生一次。这意味着只有 20 人被允许进入过山车的队列,其他人则不行。随着出发日期的临近,人们按照他们进入队列的顺序被允许进入。而环形队列的工作方式类似!它有一定的容量,并且不能向其中入队其他东西。然而,当你出队元素时,新的元素可以替代之前的元素。嗯,这意味着一个小时后,你可以用新的人来填满过山车的队列!
所述数据结构在以下图中展示:

图 5.15 – 环形队列的示意图
在开始时,环形队列是空的,所以都是-1。然后,你添加2、-4、1和8个元素,这个状态在前图中第一步显示。在这里,前索引等于0,后索引等于3。
在下一步中,你执行一个5位于索引4,正如前图中第三步所示。当然,后索引更新为4,而前索引保持不变,即0。
以下步骤显示返回了一个2,前索引变为1。这意味着目前,环形队列在数组的前索引1(队首)和后索引4(队尾)之间存储了4个元素。
性能如何?
在这种情况下,性能结果非常出色!入队和出队方法都是O(1)操作,因为你不需要遍历数组。
你可以执行更多的入队和出队操作,以查看队列内容在环形队列中是如何“旋转”的。为此,你需要实现这个数据结构。让我们编写一些代码,从CircularQueue类开始:
public class CircularQueue<T>(int size)
where T : struct
{
private readonly T[] _items = new T[size];
private int _front = -1;
private int _rear = -1;
private int _count = 0;
public int Count { get { return _count; } }
}
这是一个泛型类,它使用主构造函数,将队列中元素的最大数量作为size参数。你可以看到四个私有字段:
-
存储元素的数组(
_items) -
队列中前后元素的下标(
_front和_rear) -
环形队列中当前元素的数量(
_count)
还添加了一个公共只读Count属性,它返回_count字段的值。如果你对此已经很清楚,让我们看看Enqueue方法:
public bool Enqueue(T item)
{
if (_count == _items.Length) { return false; }
if (_front < 0) { _front = _rear = 0; }
else { _rear = ++_rear % _items.Length; }
_items[_rear] = item;
_count++;
return true;
}
在开始时,你需要检查环形队列中是否有空余空间,所以你需要比较队列中当前元素的数量(_count)与存储此类数据的数组长度(_items)。如果这两个值相等,你将返回false,因为没有空间,所以你不能入队任何元素。
下一行检查环形队列是否为空,这意味着前索引小于0。如果是这样,前索引和后索引都被设置为0。这表示环形队列中只有一个元素,并且这两个索引都指向它。
如果队列中已经有东西了,你增加后索引的值。如果它等于数组中的元素数量,你将其设置为0。
在最后三行中,你将新元素添加到由后索引(_rear)指示的位置,增加存储队列中当前元素数量的计数器(_count),以及返回true以指示入队操作成功。
现在让我们转到Dequeue方法,其代码如下所示:
public T? Dequeue()
{
if (_count == 0) { return null; }
T result = _items[_front];
if (_front == _rear) { _front = _rear = -1; }
else { _front = ++_front % _items.Length; }
_count--;
return result;
}
在这里,你检查环形队列是否为空。如果是这样,你返回一个null值。否则,你将前索引指示的值保存为result。这个值将在本方法结束时返回。
在接下来的几行中,你检查前索引和后索引是否相等。这意味着队列中只有一个元素。如果是这样,你将这两个索引都设置为-1,这表示环形队列是空的。否则,你增加前索引。如果它等于数组中的元素数量,你将其设置为0。
在最后两行中,你只是减少队列中的元素数量,以及返回之前保存的值(result)。
另一个方法名为Peek,如下所示:
public T? Peek()
{
if (_count == 0) { return null; }
return _items[_front];
}
这个方法只是返回队列中的第一个元素,而不从队列中移除它。当然,如果队列为空,它将返回null。
如你所见,环形队列的实现并不困难,并且只需要少量代码。所以,让我们看看以下代码的实际效果:
CircularQueue<int> queue = new(8);
queue.Enqueue(2);
queue.Enqueue(-4);
queue.Enqueue(1);
queue.Enqueue(8);
queue.Enqueue(5);
int item = queue.Dequeue();
Console.WriteLine(item);
前面的几行执行了图中展示的环形队列操作(图 5.15)。你创建了一个新的环形队列,可以存储八个int类型的元素。然后,你添加了2、-4、1、8和5这些值,并出队一个元素。
在介绍了循环队列的主题之后,是时候看看一个现实世界的例子了。
示例 – 重力过山车
让我们模拟位于山腰上的重力过山车的行为。这个队列在同一时间最多可以有 12 个车卡,它们在重力作用下沿着滑道下滑。参与者进入车卡后,它会自动加速,并在其路径上有几个转弯。到达山脚下后,车卡和参与者被一个简单的滑轮拉上来。参与者从他们上车的地方下车,如图所示:

图 5.16 – 重力过山车示例示意图
你可以使用最大容量设置为 12 的循环队列来模拟这个例子,这意味着目前最多可以有 12 个人在这个重力过山车上。直到有座位空出,不会让另一个人进入。进入车卡意味着执行一个入队操作,离开车卡意味着执行一个出队操作。还值得一提的是,无法改变参与者被服务的顺序。首先进入队列的人将首先被放出,这与 FIFO(先进先出)原则一致。
让我们看看位于Program.cs文件中的代码:
using QueueItem = (System.DateTime StartedAt,
System.ConsoleColor Color);
const int rideSeconds = 10;
Random random = new();
CircularQueue<QueueItem> queue = new(12);
ConsoleColor color = ConsoleColor.Black;
在这里,你为包含车进入时间和所选颜色的值元组类型指定了一个QueueItem别名。然后,将骑行长度设置为 10 秒,以及创建了一些其他变量,包括循环队列和最后使用的颜色。
以下是代码的下一部分,展示如下:
while (true)
{
while (queue.Peek() != null)
{
QueueItem item = queue.Peek()!.Value;
TimeSpan elapsed = DateTime.Now - item.StartedAt;
if (elapsed.TotalSeconds < rideSeconds) { break; }
queue.Dequeue();
Log($"> Exits\tTotal: {queue.Count}", item.Color);
}
bool isNew = random.Next(3) == 1;
if (isNew)
{
color = color == ConsoleColor.White
? ConsoleColor.DarkBlue
: (ConsoleColor)(((int)color) + 1);
if (queue.Enqueue((DateTime.Now, color)))
{
Log($"< Enters\tTotal: {queue.Count}", color);
}
else
{
Log($"! Not allowed\tTotal: {queue.Count}",
ConsoleColor.DarkGray);
}
}
await Task.Delay(500);
}
它包含一个无限while循环。在这个循环中,你首先检查哪些项目应该被出队,这意味着它们的骑行时间(即 10 秒)已经过去。如果是这样,你也会记录消息。然后,你随机抽取一个数字来决定是否在这个无限while循环的这次迭代中向循环队列中添加一个新的项目。如果是这样,你从ConsoleColor枚举中选择下一个颜色,尝试将新项目入队,并记录消息。在迭代结束时,你等待 500 毫秒。
这里展示了辅助Log方法的代码:
void Log(string text, ConsoleColor color)
{
Console.ForegroundColor = color;
Console.WriteLine($"{DateTime.Now:HH:mm:ss} {text}");
Console.ResetColor();
}
当你运行代码时,你会得到以下结果:

图 5.17 – 重力过山车示例截图
恭喜 – 你现在知道如何使用几种类型的队列了!你查看了一个常规队列、一个优先队列以及一个循环队列,并一起看了示例。所以,是时候总结本章内容了。
摘要
在本章中,你学习了两种有限访问数据结构,即栈和队列,包括常规的、优先的和循环的。值得记住的是,这些数据结构有严格指定的访问元素的方式。它们都有各种现实世界的应用。其中一些在本章中提到了。
首先,你看到了栈如何根据 LIFO(后进先出)原则操作。在这种情况下,你可以在栈顶添加一个元素(一个压入操作),并从栈顶移除一个元素(一个弹出操作)。栈在两个示例中进行了展示,即用于反转单词和解决汉诺塔数学游戏。
在本章的下一部分,你了解了作为数据结构的队列,它根据 FIFO(先进先出)原则操作。在这种情况下,介绍了入队和出队操作。队列使用两个示例进行了说明,都是关于模拟呼叫中心的应用。你学习了如何在 C#语言开发应用程序时使用线程安全的队列相关类的变体。
本章接下来介绍的数据结构被称为优先队列,它是支持特定元素优先级的队列的扩展。最后,你学习了循环队列,它通过形成一个圆来扩展常规队列的概念,其中第一个和最后一个元素由索引指示。
这只是这本书的第五章,而你已经学到了很多关于在 C#开发应用程序时非常有用的各种数据结构和算法。你对通过学习字典和集合来增加你的知识感兴趣吗?如果是这样,那就让我们继续到下一章,了解更多关于它们的内容!
第六章:字典和集合
本章重点介绍与字典和集合相关的数据结构。应用这些数据结构使得将键映射到值和快速查找成为可能,以及在集合上执行各种操作。为了简化你对字典和集合的理解,本章包含插图、代码片段以及详细的描述。
首先,你将了解字典的非泛型和泛型版本,字典是一个由键值对组成的集合。然后,将介绍字典的排序变体。本章的其余部分将向你展示如何使用哈希集合以及“排序”集合变体。是否可以有“排序”集合?你将在本章的后面了解更多。
在本章中,将涵盖以下主题:
-
哈希表
-
字典
-
排序字典
-
哈希集合
-
“排序”集合
哈希表
让我们从第一个数据结构开始,即哈希表,也称为哈希映射。它允许你将键映射到特定的值。哈希表最重要的假设之一是基于键对值进行非常快速查找的可能性,这应该是O(1)操作。
想象一个哈希表或字典
如果你想要更好地想象哈希表或字典,值得思考一个包含大量数据的集合,其中快速检查字典是否包含特定的键以及快速检索分配给给定键的值至关重要。所以,想象一个系统,它允许你确定一个特定的 IP 地址来自哪个国家。正如你所知,有许多可能的 IP 地址,你的系统必须快速获取信息,以确定用户的请求来自哪个国家,从而选择应用程序的默认语言版本。这就是哈希表和字典的工作方式!你使用 IP 地址作为键(例如,50.50.50.50)和国家代码作为值(例如,PL)。这样,你可以快速找出用户来自哪个国家,而无需手动浏览整个集合。我来自波兰,我诚挚地邀请您来访问!这里有山脉、大海、湖泊和历史悠久的城市。所有这些都在这里等着你!
为了实现非常快速的查找,使用了哈希函数。它接受键来生成一个桶的索引,其中可以找到值。因此,如果你需要找到键的值,你不需要遍历集合中的所有项,因为你只需使用哈希函数轻松地定位合适的桶并获取值。正如你所看到的,哈希函数的作用至关重要,理想情况下,它应该为所有键生成一个唯一的结果。然而,不同的键可能会生成相同的结果。这种情况称为哈希冲突,应该得到处理。
下图展示了将键映射到特定值的方式:

图 6.1 – 将键映射到特定值的说明
由于哈希表具有出色的性能,它们在许多实际应用中经常被使用,例如用于关联数组、数据库索引和缓存系统。
从头实现哈希表似乎相当困难,尤其是在使用哈希函数、处理哈希冲突以及将特定的键分配给桶时。幸运的是,在用 C#语言开发应用程序时,有一个合适的实现,并且它的使用非常简单。
非泛型和泛型版本
与哈希表相关的类有两种变体,即非泛型(Hashtable)和泛型(Dictionary)。本节将描述第一种,下一节将描述另一种。如果你可以使用强类型泛型版本,我强烈建议使用它。
让我们来看看System.Collections命名空间中的Hashtable类。如前所述,它存储了一组键值对,其中每个都包含一个键和一个值。对是由DictionaryEntry实例表示的。
下面是一些使用Hashtable类的示例代码:
using System.Collections;
Hashtable hashtable = new()
{
{ "Key #1", "Value #1" },
{ "Key #2", "Value #2" }
};
hashtable.Add("Key #3", "Value #3");
hashtableKey #1 and Key #2), by using the Add method (Key #3) or by using the indexer (Key #4).
When you use the indexer to set a value for an already existing key, the value of this element is updated. A different behavior occurs while using the `Add` method because it throws an exception when an item with the same key already exists in the collection. You can handle this situation by using the `try-catch` statement, but there is a much better approach to check whether such an entry already exists – using the `ContainsKey` method. This will be shown a bit later.
It is worth mentioning that the `null` value is incorrect for the key of an element, but it is acceptable as a value of an element.
You can easily gain access to a particular element using the indexer. As the `Hashtable` class is a non-generic variant of hash table-related classes, you need to cast the returned result to the proper type (for example, `string`), as shown here:
string value = foreach 循环遍历存储在集合中的所有键值对,如下所示:
foreach (DictionaryEntry entry in hashtable)
{
Console.WriteLine($"{entry.Key}: {entry.Value}");
}
在循环中使用的变量是DictionaryEntry类型。因此,你可以使用它的Key和Value属性来分别访问键和值。
Hashtable类配备了一些属性,例如获取存储元素的数量(Count),以及返回键和值的集合(Keys和Values)。你可以使用以下方法:
-
Add,用于添加新元素 -
Remove,用于移除一个元素 -
Clear,用于移除所有元素 -
ContainsKey,用于检查集合是否包含指定的键 -
ContainsValue,用于检查集合是否包含指定的值
性能方面如何?
哈希表是一种高效的数据结构。通过键检索值、检查集合是否包含指定的键以及通过键移除项都是O(1)操作。至于添加,如果不需要增加容量,它也是O(1)操作。否则,它是O(n)操作,其中n是项的数量。
在这个简短的介绍之后,让我们来看一个例子。
你在哪里可以找到更多信息?
你可以在learn.microsoft.com/en-us/dotnet/api/system.collections.hashtable找到有关哈希表的内容。
示例 – 电话簿
例如,假设你创建了一个电话簿应用程序。这里使用Hashtable类来存储条目,其中人的姓名是键,电话号码是值,如下所示:
NAME ---> PHONE
Marcin -> 101-202-303
John ---> 202-303-404
Aline --> 303-404-505
该程序演示了如何向集合中添加元素,获取存储项的数量,遍历所有项,检查是否存在具有给定键的元素,以及如何根据键获取值。
首先,让我们创建Hashtable类的新实例,并使用一些条目初始化它,如下面的代码所示:
Hashtable phoneBook = new()
{
{ "Marcin", "101-202-303" },
{ "John", "202-303-404" }
};
phoneBookCount property and comparing its value with 0, as presented here:
Console.WriteLine("电话号码:")
if (phoneBook.Count == 0)
{
Console.WriteLine("列表为空。")
}
Then, you can iterate through all the pairs:
foreach (DictionaryEntry entry in phoneBook)
{
Console.WriteLine($"{entry.Key}: {entry.Value}")
}
Finally, let’s see how we can check whether a specific key exists in the collection, as well as how to get its value. The first task can be accomplished just by calling the `ContainsKey` method, which returns a value indicating whether a suitable element exists (`true`) or not (`false`). To get a value, you can use the indexer. Please keep in mind that you must cast the returned value to a suitable type, such as `string` in this example. This requirement is caused by the non-generic version of the hash table-related class. This code is as follows:
Console.Write("\n 通过姓名搜索:")
string name = Console.ReadLine() ?? string.Empty;
if (phoneBook.ContainsKey(name))
{
string number = (string)phoneBook[name]!;
Console.WriteLine($"电话号码:{number}")
}
else
{
Console.WriteLine("不存在。")
}
Your first program using the hash table is ready! After launching it, you should receive a result similar to the following:
电话号码:
Marcin: 101-202-303
Aline: 303-404-505
John: 202-303-404
通过姓名搜索:Aline
电话号码:303-404-505
It is worth noting that the order of the pairs stored using the `Hashtable` class is not consistent with the order of their addition or keys. For this reason, if you need to present the sorted results, you need to sort the elements on your own or use another data structure, namely `SortedDictionary`, which is described later in this book.
Now, let’s take a look at one of the most common classes used while developing in C#, namely `Dictionary`, which is a generic version of hash table-related classes.
Dictionaries
In the previous section, you learned about the `Hashtable` class, a non-generic variant of the hash table-related classes. However, it has a significant limitation, because it does not allow you to specify a type of a key and a value. Both the `Key` and `Value` properties of the `DictionaryEntry` class are of the `object` type. Therefore, you need to perform boxing and unboxing operations, even if all the keys and values are of the same type. If you want to benefit from the `Dictionary` generic class, which is the main subject of this section.
First of all, you should specify two types, namely a type of a key and a value, while creating an instance of the `Dictionary` class. Moreover, it is possible to define the initial content of the dictionary using the following code:
Dictionary<string, string> dictionary = new()
{
{ "Key #1", "Value #1" },
{ "Key #2", "Value #2" }
};
In the preceding code, a new instance of the `Dictionary` class is created. It stores `string`-based keys and values. Here, two entries exist in the dictionary, namely `Key #1` and `Key #2`. Their values are `Value #1` and `Value #2`.
Similar to the `Hashtable` class, you can also use the indexer to get access to a particular element within the collection, as shown in the following line of code:
string value = dictionarystring type is unnecessary because Dictionary is the strongly typed version of the hash table-related classes. Therefore, the returned value already has the proper type. If an element with the given key does not exist in the collection, KeyNotFoundException is thrown. To avoid problems, you can either check whether the element exists (by calling ContainsKey) or use the TryGetValue method.
你可以使用索引器添加新元素或更新现有元素的值:
dictionarykey cannot be equal to null, but value can be if it is allowed by the type of values stored in the collection.
Where can you find more information?
You can find content regarding a dictionary at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2).
The `Dictionary` class is equipped with a few properties:
* `Count`, which gets the number of stored elements
* `Keys`, which returns the collection of keys
* `Values`, which returns the collection of values
You can also use some available methods:
* `Add`, which adds a new element to the dictionary
* `Remove`, which removes an element from the dictionary
* `Clear`, which removes all the elements from the dictionary
* `ContainsKey`, which checks whether the dictionary contains a key
* `ContainsValue`, which checks whether the dictionary contains a given value
* `TryGetValue`, which tries to get a value for a given key from the dictionary
As you can see, many properties and methods are almost the same as in the case of the `Hashtable` class. The consistency of naming allows you to easily use various classes without the necessity of learning everything from scratch.
What about performance?
You should remember that the performance of getting a value of an element (using an indexer or `TryGetValue`), updating an existing one (using an indexer), and checking whether the given key exists in the dictionary (`ContainsKey`) approaches the *O(1)* operation. However, the process of checking whether the collection contains a given value (`ContainsValue`) is the *O(n)* operation and requires you to search the entire collection for the particular value.
If you want to iterate through all pairs stored in the collection, you can use a `foreach` loop. However, the variable that’s used in the loop is an instance of the `KeyValuePair` generic class with `Key` and `Value` properties, allowing you to access the key and the value. This `foreach` loop is shown in the following code snippet:
foreach (KeyValuePair<string, string> pair in dictionary)
{
Console.WriteLine($"{pair.Key}: {pair.Value}")
}
Here, you can also apply what you’ve learned about value tuples and the deconstruct operation. Thus, the preceding `foreach` loop can be simplified, as shown here:
foreach ((string k, string v) in dictionary)
{
Console.WriteLine($"{k}: {v}")
}
As you can see, the C# language is being equipped with more and more useful features that make your code shorter, simpler, and easier to understand. You should keep an eye on the updates to the language. Good work, C# team – I am looking forward to more!
Thread-safe version
Do you remember a thread-safe queue-related class from the previous chapter? If so, the situation looks quite similar in the case of `Dictionary` because the `ConcurrentDictionary` class (from the `System.Collections.Concurrent` namespace) is available.
With this short introduction, let’s start coding! In the following sections, you will find two real-world examples that use dictionaries for storing data.
Example – product location
The first example we’ll look at is an application that helps employees of a shop to find a product’s location. Let’s imagine that each employee has a phone with your application on it, which is used to scan a barcode of the product, and the application tells them that the product should be located in area `A1` or `C9`. Sounds interesting, doesn’t it?
As the number of products in the shop is often very high, it is necessary to find results very quickly. For this reason, the data of products, together with their locations, is stored in the hash table, using the generic `Dictionary` class. The key is the barcode (`string`), while the value is the area code (also `string`), as shown here:
BARCODE -------> AREA
5901020304050 -> A1
5910203040506 -> B5
5920304050607 -> C9
First, you create a new collection and add some data:
Dictionary<string, string> products = new()
{
{ "5901020304050", "A1" },
{ "5910203040506", "B5" },
{ "5920304050607", "C9" }
};
products["5930405060708"] = "D7";
The code shows two ways of adding elements to the collection, namely by passing their data while creating a new instance of the class and by using the indexer. A third solution also exists and uses the `Add` method, as shown in the following part of the code:
string key = "5940506070809";
if (!products.ContainsKey(key))
{
products.Add(key, "A3")
}
Another solution uses the `TryAdd` method, as presented here:
if (!products.TryAdd(key, "B4"))
{
Console.WriteLine("无法添加。")
}
In the following part of the code, you present data of all the products that are available in the system. To do so, you use the `foreach` loop. Before that, you check whether there are any elements in the dictionary. If not, the proper message is presented to the user. Otherwise, keys and values from all pairs are presented in the console:
Console.WriteLine("所有产品:")
if (products.Count == 0) { Console.WriteLine("为空。") }
foreach ((string k, string v) in products)
{
Console.WriteLine($"{k}: {v}")
}
Now, let’s take a look at the part of the code that makes it possible to find the location of the product by its barcode. To do so, you can use `TryGetValue` to check whether the element exists. If so, a message with the target location is presented in the console. Otherwise, other information is shown. The code is presented here:
Console.Write("\n 通过条码搜索:")
string barcode = Console.ReadLine() ?? string.Empty;
if (products.TryGetValue(barcode, out string? location))
{
Console.WriteLine($"该产品位于:{location}。")
}
else
{
Console.WriteLine("该产品不存在。")
}
When you run the program, you see a list of all the products in the shop and the program asks you to enter the barcode. After typing it, you receive a message containing the area code. The result that’s shown in the console should be similar to the following:
Cannot add.
所有产品:
5901020304050: A1
5910203040506: B5
5920304050607: C9
5930405060708: D7
5940506070809: A3
通过条码搜索:5901020304050
该产品位于:A1。
You’ve just completed the first example! Let’s proceed to the next one.
Example – user details
This second example shows you how to store more complex data in the dictionary. In this scenario, you’ll create an application that shows details of a user based on their identifier, as shown here:
ID -> FIRST NAME | LAST NAME | PHONE NUMBER
100 -> Marcin | Jamro | 101-202-303
210 -> John | Smith | 202-303-404
303 -> Aline | Weather | 303-404-505
The program starts with the data of three users. You should be able to enter an identifier and see details of the found user. Of course, the situation of the non-existence of a given user should be handled by presenting the proper information in the console.
First, let’s add the `Employee` record, which stores data of an employee, namely first name, last name, and phone number. The code is as follows:
public record Employee(string FirstName, string LastName,
string PhoneNumber);
Then, create a new instance of the `Dictionary` class and add data to it:
Dictionary<int, Employee> employees = new()
{
{ 100, new Employee("Marcin", "Jamro", "101-202-303") },
{ 210, new Employee("John", "Smith", "202-303-404") },
{ 303, new Employee("Aline", "Weather", "303-404-505") }
};
The most interesting operations are performed in the following `do-while` loop:
do
{
Console.Write("输入标识符: ");
string idString = Console.ReadLine() ?? string.Empty;
if (!int.TryParse(idString, out int id)) { break; }
if (employees.TryGetValue(id, out Employee? Employee))
{
Console.WriteLine(
"Full name: {0} {1}\nPhone number: {2}\n",
employee.FirstName,
employee.LastName,
employee.PhoneNumber);
}
else { Console.WriteLine("不存在。\n"); }
}
while (true);
First, the user is asked to enter an identifier of the employee, which is then parsed to the integer value. The loop is stopped when the provided identifier cannot be parsed to the integer value. Otherwise, the `TryGetValue` method is used to try to get details of the user. If the user is found (`TryGetValue` returns `true`), the details are presented in the console. Otherwise, an error message is shown.
When you run the application and enter some data, you will receive the following result:
输入标识符:100
全名:Marcin Jamro
电话号码:101-202-303
输入标识符:101
不存在。
That’s all! You’ve completed two examples showing how to use dictionaries while developing applications in the C# language. Do you remember that another kind of dictionary was already mentioned, namely a sorted dictionary? Are you interested in finding out what it is and how you can use it in your programs? If so, move on to the next section.
Sorted dictionaries
Both non-generic and generic variants of the hash table-related classes do not keep the order of the elements. For this reason, if you need to present data from the collection sorted by keys, you need to sort them before presenting them. However, you can use another data structure, known as a **sorted dictionary**, to solve this problem and **keep keys sorted all the time**. Therefore, you can easily get the sorted collection if necessary.
Imagine a sorted dictionary
If you want to better imagine a sorted dictionary, remember the times from a dozen or so years ago, when the internet was not as popular and widespread as it is today, and at home there was a book on your shelf that allowed you to learn the meaning of a word in another language. How does it work? Let’s assume that you have a Polish-English dictionary, thanks to which you can find out how to translate a specific word from Polish to English, such as *cześć* to *hello*. You open this book and look for words that start with the letter *c*. Found! Now, you are browsing through words starting with *c* to find the one you are interested in, namely *cześć*. Fortunately, it’s not that complicated because all the words are listed in the dictionary in alphabetical order. And that’s how a sorted dictionary works! You can easily view all the items in the dictionary in alphabetical order. You can also quickly check if the dictionary contains a specific key and what its value is. Today, you just enter a foreign word in a search engine and you instantly know what it means in your language, as well as in probably any other language in the world. You like this kind of technological progress, don’t you?
The sorted dictionary is implemented as the `SortedDictionary` generic class, available in the `System.Collections.Generic` namespace. You can specify types for keys and values while creating a new instance of `SortedDictionary`. An item key cannot be equal to `null`, but its value can be if it is allowed by the type of values stored in the collection. Moreover, the class contains similar properties and methods to `Dictionary`.
Where can you find more information?
You can find content regarding a sorted dictionary at [`learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sorteddictionary-2`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sorteddictionary-2).
The exemplary application is as follows:
SortedDictionary<string, string> dictionary = new()
{
{ "Key #1", "Value #1" },
{ "Key #2", "Value #2" }
};
dictionary.Add("Key #3", "Value #3");
dictionary["Key #4"] = "Value #4";
string value = dictionary["Key #1"];
dictionary.Count),以及返回键和值的集合(分别称为键和值)。此外,你可以使用可用的方法,包括以下内容:
-
Add,用于添加新元素 -
Remove,用于删除项 -
Clear,用于删除所有元素 -
ContainsKey,用于检查集合是否包含特定键 -
ContainsValue,用于检查集合是否包含给定值 -
TryGetValue,用于尝试获取给定键的值
如果你想要遍历集合中存储的所有键值对,你可以使用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需要更多的内存。正如你所见,选择合适的数据结构并非易事,你应该仔细考虑数据结构使用的场景,并权衡利弊。
通过这个简短的介绍,让我们看看排序字典的实际应用。
示例 – 百科全书
例如,让我们创建一个简单的百科全书,你可以添加条目并显示它们的完整内容。由于百科全书可以包含数百万个条目,因此为用户提供按正确顺序、按键字母顺序浏览条目的可能性,以及快速查找条目的可能性至关重要。因此,排序字典是一个不错的选择。
百科全书的概念在这里展示:
NAME -> EXPLANATION
Lancut -> A city located near Rzeszow, with a castle.
Rzeszow -> A capital of the Subcarpathian voivodeship.
Warszawa -> A capital city of Poland.
Zakopane -> A city located in Tatra mountains in Poland.
当程序启动时,它显示一个简单的菜单,有两个选项,即[A]dd和[L]ist。按下A后,应用程序要求你输入条目的键和解释。如果提供的数据正确,则将新条目添加到百科全书中。如果用户按下L,则在控制台中按键排序显示所有条目的数据。当按下任何其他键时,将显示额外的确认,如果确认,则程序退出。
让我们看看代码:
Console.WriteLine("Welcome to your encyclopedia!\n");
SortedDictionary<string, string> definitions = [];
do
{
Console.WriteLine("\nChoose option ([A]dd, [L]ist): ");
ConsoleKeyInfo keyInfo = Console.ReadKey(true);
if (keyInfo.Key == ConsoleKey.A)
{
Console.Write("Enter the key: ");
string key = Console.ReadLine() ?? string.Empty;
Console.Write("Enter the explanation: ");
string explanation = Console.ReadLine()
?? string.Empty;
definitions[key] = explanation;
}
else if (keyInfo.Key == ConsoleKey.L)
{
foreach ((string k, string e) in definitions)
{
Console.WriteLine($"{k}: {e}");
}
}
else
{
Console.WriteLine("Do you want to exit? Y or N.");
if (Console.ReadKey().Key == ConsoleKey.Y)
{
break;
}
}
}
while (true);
首先,创建一个SortedDictionary类的新实例,它代表基于string键和基于string值的对集合。然后,使用无限do-while循环。在循环内部,程序等待用户按下任何键。如果是A键,则从用户输入的值中获取条目的键和解释。然后,使用索引器向字典中添加一个新的条目。因此,如果具有相同键的条目已经存在,它将被更新。在按下L键的情况下,使用foreach循环显示所有输入的条目。当按下任何其他键时,向用户提出另一个问题,并且程序等待确认。如果用户按下Y,则退出循环。
当你运行程序时,你可以输入一些条目,以及展示它们。控制台的结果可以类似于以下块中所示:
Welcome to your encyclopedia!
Choose option ([A]dd, [L]ist):
Enter the key: Zakopane
Enter the explanation: A city located in Tatra mountains in Poland.
Choose option ([A]dd, [L]ist):
Enter the key: Rzeszow
Enter the explanation: A capital of the Subcarpathian voivodeship.
Choose option ([A]dd, [L]ist):
Rzeszow: A capital of the Subcarpathian voivodeship.
Zakopane: A city located in Tatra mountains in Poland.
Choose option ([A]dd, [L]ist):
Do you want to exit? Y or N.
到目前为止,你已经了解了三个与字典相关的类,即Hashtable、Dictionary和SortedDictionary。为了使理解它们更容易,提供了一些示例,以及详细的解释。
然而,你知道一些其他数据结构只存储键,而不存储值吗?你将在下一节中了解这些。
哈希集
在许多算法中,对各种数据集进行操作是必要的。然而,什么是集合?集合是一组没有重复元素且没有特定顺序的独立对象。因此,你只能知道给定元素是否在集合中。这些集合与数学模型和操作(如并集、交集、减法和对称差)严格相关。集合可以存储各种数据,例如整数或字符串值。当然,你也可以创建一个包含用户定义类或记录实例的集合,并且可以在任何时候向集合中添加或删除元素。
想象一个哈希集
如果你想更好地可视化哈希集合,不妨想想在许多国家流行的机会游戏,该游戏涉及从许多可用的数字中选择几个数字,然后从这些数字中抽取。根据你从抽取的数字中得到的数量,你将获得奖品。当然,匹配所有抽取数字的机会非常非常小。现在,你可能想知道这些集合在这里。我急于回答这个问题!这里有三个集合,即所有可用数字的集合、随机抽取数字的集合以及你选择的数字的集合。每个集合都不能包含任何重复项。当然,随机抽取数字的集合和你选择的数字的集合都是所有可用数字集合的子集。你如何检查你选择了多少个正确的数字呢?这非常简单!只需对两个集合执行“交集”操作,即随机选择的数字的集合和你选择的数字的集合,以获得结果集。现在,你只需要祈祷这个结果集中的元素数量与抽取数字的集合中的元素数量相匹配。如果那样的话,那么...你可能会变得非常富有,因为你匹配了所有抽取的数字。如果是这样,恭喜你!
下面的图中展示了两个示例集合:

图 6.2 – 整数和字符串值集合的示意图
在看到集合的实际应用之前,提醒您一些可以在两个集合上执行的基本操作是个好主意,这两个集合被命名为A和B。
并集(如下面的图中左侧所示为A ∪ B)是包含属于 A 或 B 的所有元素的集合。交集(如下面的图中右侧所示为A ∩ B)仅包含属于 A 和 B 的元素:

图 6.3 – 集合并集和交集示意图
另一个常见的操作是集合减法。集合 A \ B 的结果集包含属于 A 但不属于 B 的元素。在下面的图中,展示了两个示例,即A \ B和B \ A:

图 6.4 – 集合减法示意图
在对集合执行各种操作时,也值得提一下对称差集,如下面的图中所示为A ∆ B。最终的集合可以解释为两个集合的并集,即(A \ B)和(B \ A)。因此,它包含仅属于一个集合的元素,要么是 A,要么是 B。属于两个集合的元素被排除在结果之外:

图 6.5 – 集合对称差集和集合之间的关系示意图
另一个重要的话题是集合之间的关系。如果集合 B 的每个元素都属于集合 A,这意味着 B 是 A 的 子集 。这在前面的图中右侧所示。同时,A 是 B 的** 超集 。此外,如果 B 是 A 的子集,但 B 不等于 A,那么 B 是 A 的** 真子集 ,而 A 是 B 的 真超集 **。
在开发 C#语言的各类应用时,你可以从System.Collections.Generic命名空间中HashSet类提供的高性能操作中受益。该类包含一些属性,包括Count,它返回集合中元素的数量。
你在哪里可以找到更多信息?
你可以在learn.microsoft.com/en-us/dotnet/api/system.collections.generic.hashset-1找到有关哈希集合的内容。
此外,你可以使用许多方法来执行集合操作。第一组方法使得可以修改当前集合(方法被调用的集合)以创建与作为参数传递的集合的并集(UnionWith)、交集(IntersectWith)、差集(ExceptWith)和对称差集(SymmetricExceptWith)。
你还可以检查两个集合之间的关系,例如检查当前集合(方法被调用的集合)是否是作为参数传递的集合的子集(IsSubsetOf)、超集(IsSupersetOf)、真子集(IsProperSubsetOf)或真超集(IsProperSupersetOf)。
此外,你可以验证两个集合是否包含相同的元素(SetEquals)或者两个集合是否至少有一个共同元素(Overlaps)。
除了这些操作之外,你还可以向集合添加新元素(Add)、移除特定元素(Remove)、移除所有元素(Clear)以及检查给定元素是否存在于集合中(Contains)。
性能如何?
哈希集合使得对给定项的快速查找成为可能。因此,检查集合是否包含项以及删除项的操作是O(1)操作。至于添加,如果不需要增加内部数组,它也是一个O(1)操作。如果需要调整大小,它将变成O(n)操作,其中n是项的数量。
通过这个介绍,尝试将你所学的知识应用到实践中。现在,让我们来看两个示例,展示你如何在应用中应用哈希集合。
示例 – 优惠券
第一个示例表示一个系统,用于检查一次性优惠券是否已被使用。如果是,则向用户显示一条合适的信息。否则,系统通知用户优惠券有效。然后,将其标记为已使用,并且不能再使用。由于优惠券数量众多,需要选择一个允许您快速检查集合中是否存在元素的数结构。因此,选择了哈希集合作为存储已使用优惠券标识符的数据结构。
让我们看看代码:
HashSet<int> usedCoupons = [];
do
{
Console.Write("Enter the number: ");
string number = Console.ReadLine() ?? string.Empty;
if (!int.TryParse(number, out int coupon)) { break; }
if (usedCoupons.Contains(coupon))
{
Console.WriteLine("Already used.");
}
else
{
usedCoupons.Add(coupon);
Console.WriteLine("Thank you!");
}
}
while (true);
首先,创建了一个新的HashSet实例,用于存储整数值。然后,大多数操作都在do-while循环内执行。在这里,程序会等待用户输入优惠券标识符。如果无法将其解析为整数值,则跳出循环。否则,检查集合中是否已包含该优惠券标识符(使用Contains方法)。如果是,则在控制台显示合适的信息。如果不存在,则使用Add方法将其添加到已使用优惠券的集合中,并通知用户结果。
当您跳出循环时,只需显示已使用优惠券的完整标识符列表。您可以使用foreach循环遍历集合,并在控制台写入其元素,如下面的代码所示:
Console.WriteLine("\nUsed coupons:");
foreach (int coupon in usedCoupons)
{
Console.WriteLine(coupon);
}
现在,您可以启动应用程序,输入一些数据,看看它是如何工作的:
Enter the number: 100
Thank you!
Enter the number: 101
Thank you! (...)
Enter the number: 101
Already used.
Enter the number:
Used coupons:
100
101
500
345
这是第一个示例的结束。让我们继续下一个示例,您将看到一个更复杂的解决方案,它使用哈希集合。
示例 – 游泳池
此示例展示了拥有四个游泳池的 SPA 中心的系统,分别是休闲、竞技、温泉和儿童游泳池。每位访客都会收到一条特殊的手环,允许他们进入所有游泳池。然而,每次用户进入任何游泳池时都需要扫描手环。您的程序使用这些数据来创建各种统计信息。
为了存储在每个游泳池入口扫描的独特手环编号,选择了哈希集合作为数据结构。每个游泳池使用一个集合。此外,它们被分组在字典中,以简化并缩短代码,同时使未来的修改更加容易。为了简化应用程序的测试,初始数据被随机设置。然后,创建统计信息,即按游泳池类型统计的游客数量、最受欢迎的游泳池、至少访问过一次游泳池的人数,以及访问过所有游泳池的人数。
让我们从PoolTypeEnum枚举开始,它表示游泳池的可能类型:
enum PoolTypeEnum
{
Recreation,
Competition,
Thermal,
Kids
};
然后,打开Program.cs文件并添加random变量。这将用于用一些随机值填充哈希集合。代码行如下:
Random random = new();
在代码的下一部分,你创建Dictionary的新实例。它包含四个条目。每个键是PoolTypeEnum类型,每个值是HashSet<int>类型——即一个包含整数值的集合。代码如下所示:
Dictionary<PoolTypeEnum, HashSet<int>> tickets = new()
{
{ PoolTypeEnum.Recreation, new() },
{ PoolTypeEnum.Competition, new() },
{ PoolTypeEnum.Thermal, new() },
{ PoolTypeEnum.Kids, new() }
};
之后,你将集合填充随机的值,如下所示:
for (int i = 1; i < 100; i++)
{
foreach ((PoolTypeEnum p, HashSet<int> t) in tickets)
{
if (random.Next(2) == 1) { t.Add(i); }
}
}
为了做到这一点,你使用两个循环,即for和foreach。第一个循环100次并模拟100个手环。在其中,有一个foreach循环,它遍历所有可用的游泳池类型。对于每一个,你随机检查访问者是否进入了一个特定的游泳池。如果是,该标识符将被添加到相应的集合中。
剩余的代码与生成各种统计信息相关。首先,让我们展示按游泳池类型划分的访问者数量。这样的任务非常简单,因为你只需要遍历字典,以及写出游泳池类型和集合中的元素数量(使用Count属性),如下所示:
Console.WriteLine("Number of visitors by a pool type:");
foreach ((PoolTypeEnum p, HashSet<int> t) in tickets)
{
Console.WriteLine($"- {p}: {t.Count}");
}
下一个部分是找到访问人数最多的游泳池。这是通过使用几个扩展方法来完成的,即按集合中的元素数量降序排序(OrderByDescending),仅选择池类型(Select),以及仅取第一个元素(FirstOrDefault)。然后,你只需展示结果。完成此操作的代码如下所示:
PoolTypeEnum maxVisitors = tickets
.OrderByDescending(t => t.Value.Count)
.Select(t => t.Key)
.FirstOrDefault();
Console.WriteLine($"{maxVisitors} - the most popular.");
接下来,你想要得到至少访问过至少一个游泳池的人数。你通过创建所有集合的并集并获取最终集合的计数来完成这个任务。首先,你创建一个新的集合,并用关于休闲游泳池的标识符填充它。在下面的代码行中,你调用UnionWith方法来创建与以下三个集合的并集:
HashSet<int> any = new(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(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: 60
- Competition: 40
- Thermal: 47
- Kids: 45
Recreation - the most popular.
91 people visited at least one pool.
10 people visited all pools.
你刚刚完成了关于哈希集合的两个示例。尝试修改代码并添加新功能以学习更多关于这种数据结构的知识是个好主意。在下一节中,我们将查看“排序”集合。
“排序”集合
之前描述的HashSet类可以理解为只存储键而不存储值的字典。所以,如果存在SortedDictionary类,也许也存在SortedSet类?是的!然而,集合可以“排序”吗?为什么“排序”用引号括起来?答案非常简单。根据定义,集合存储一组不同的对象,没有重复的元素,也没有特定的顺序。如果集合不支持顺序,那么它如何“排序”?因此,“排序”集合可以理解为 HashSet 和 SortedList 的组合,而不是集合本身。
想象一个“排序”集合
如果您想更好地想象“排序”集合,请回忆与机会游戏相关的先前示例。为了便于手动比较结果,随机抽取的数字集合和您选择的数字集合都可以“排序”并按升序显示。这就是“排序”集合派上用场的地方。它非常简单明了,不是吗?
如果您想要使用SortedSet并且它在System.Collections.Generic命名空间中可用,则可以使用“排序”集合。
您可以在哪里找到更多信息?
您可以在learn.microsoft.com/en-us/dotnet/api/system.collections.generic.sortedset-1找到有关“排序”集合的内容。
它有一组方法,类似于在HashSet类的情况下已经描述的方法,包括UnionWith、IntersectWith、ExceptWith、SymmetricExceptWith、Overlaps、IsSubsetOf、IsSupersetOf、IsProperSubsetOf和IsProperSupersetOf。
它包含额外的属性,用于返回最小和最大值(分别为Min和Max)。还值得一提的是GetViewBetween方法,因为它返回一个包含给定范围内值的SortedSet实例。
性能如何?
在性能方面,“排序”集合是一个有趣的数据结构。它是在功能性和性能之间的一种权衡。因此,检查集合中是否包含一个项目以及从集合中删除任何项目都是O(log n)操作。因此,您应该预期与前面描述的数据结构相比,性能结果会更差。
让我们继续一个简单的例子,看看如何在代码中使用“排序”集合。
示例 - 删除重复项
例如,您将创建一个简单的应用程序,用于从姓名列表中删除重复项。当然,姓名的比较应该是大小写不敏感的,因此不允许在同一个集合中同时有Marcin和marcin。
要做到这一点,我们可以使用以下代码:
List<string> names =
[
"Marcin", "Mary", "James", "Albert", "Lily",
"Emily", "marcin", "James", "Jane"
];
SortedSet<string> sorted = new(
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
你知道你可以使用SortedSet构造函数的另一种变体,并只传递第一个参数,即列表,而不传递比较器吗?在这种情况下,将使用默认的比较器,并且是大小写敏感的。
恭喜你——你刚刚完成了本章最后展示的示例!
概述
本章重点介绍了哈希表、字典和集合。所有这些集合都是有趣的数据结构,可以在开发许多应用程序的各种场景中使用。通过详细描述、性能解释和示例,你看到选择合适的数据结构并非易事,需要分析性能相关的话题。
首先,你学习了如何使用两种哈希表的变体,即非泛型和泛型。这些的优点是,基于键的值查找非常快,接近O(1)操作。为了实现这个目标,使用了哈希函数。此外,排序字典被引入作为一种有趣的解决方案,用于解决集合中未排序项的问题,并始终保持键的排序。
之后,介绍了集合操作的高性能解决方案。集合可以理解为没有重复元素且没有特定顺序的独立对象的集合。所展示的类使得可以在集合上执行各种操作,例如并集、交集、减法和对称差集。然后,介绍了“排序”集合的概念,即没有重复元素的独立对象的排序集合。
你是否想在用 C#语言开发应用程序的同时,深入探讨数据结构和算法这一主题?如果是的话,请继续阅读下一章,其中将介绍树。
第七章:树的变体
在前面的章节中,你学习了关于许多数据结构的知识,从简单的数组开始。现在,是时候了解一个显著更复杂的数据结构组了,即树。
本章开头将介绍一个基本树,以及其在 C#语言中的实现,并有一些示例展示其作用。然后,将介绍二叉树,包括其实现的详细描述和应用的示例。二叉搜索树(BST)是另一种树变体,是最流行的树类型之一,在许多算法中使用。你还将了解自平衡树,即AVL 树和红黑树(RBTs)。然后,你将看到字典树作为一个专门的数据结构,用于执行字符串操作。本章的其余部分将简要介绍堆的主题。
数组、列表、栈、队列、字典、集合,现在还有树。你准备好提高难度并学习下一个数据结构了吗?如果是这样,让我们开始阅读吧!
本章将涵盖以下主题:
-
基本树
-
二叉树
-
二叉搜索树
-
自平衡树
-
字典树
-
堆
基本树
让我们从介绍树开始。它们是什么?你对这样一个数据结构的外观有什么想法吗?如果没有,让我们看看以下图表,它描绘了一个带有关于其特定元素的标题的树:

图 7.1 – 树的示意图
树由多个节点组成,包括一个根节点(图中为100)。根节点不包含父节点,而所有其他节点都包含。例如,节点1的父元素是100,而节点96的父节点是30。
此外,每个节点可以有任意数量的子节点,例如在根节点的情况下有三个子节点(即50、1和150)。同一节点的子节点可以被称为兄弟节点,例如节点70和61的情况。没有子节点的节点被称为叶节点,例如图中的45和6。
让我们看看包含三个节点(即30、96和9)的矩形。这样的树的一部分可以称为子树。当然,你可以在树中找到许多子树。
想象一棵树
如果你想要更好地想象一棵树,看看一个稍微大一点的公司结构,在层级结构的顶端是首席执行官(CEO),向他分配首席运营官(COO)、首席营销官(CMO)、首席财务官(CFO)和首席技术官(CTO)。由于销售是公司运营中的关键主题之一,区域总监向 COO 汇报,并为他们中的每一个分配三到五个销售专家。你自己找找看——你现在脑中就有了一棵树!它的根是 CEO,它有四个子节点(COO、CMO、CFO 和 CTO),这些子节点可以进一步创建后续层级的子节点。不再有任何下属的销售专家被称为叶子节点。
让我们简要地讨论一下节点子节点的最小和最大数量。一般来说,这些数字没有限制,每个节点可以包含零个、一个、两个、三个甚至更多的子节点。然而,在实际应用中,子节点的数量通常限制为两个,正如你很快就会看到的。
实现
基于 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) -
对父节点的引用(
Parent) -
对子节点引用的集合(
Children)
除了属性之外,TreeNode类还包含GetHeight方法,它返回节点的高度——即从该节点到根节点的距离。这个方法的实现非常简单,因为它仅仅使用一个while循环从节点向上移动,直到没有父元素,这意味着到达了根节点。
树
下一个必要的类被命名为Tree。它代表整个树,如下所示:
public class Tree<T>
{
public TreeNode<T>? Root { get; set; }
}
该类只有一个属性,Root。你可以使用这个属性来访问根节点,然后你可以使用它的Children属性来获取其子节点的数据。然后,你可以查看每一个,并获取它们的子节点数据。通过重复这样的操作,你可以从树中所有节点获取数据。
值得注意的是,TreeNode和Tree类都是泛型的,并且在这些类的情况下使用的是相同的类型。例如,如果树节点应该存储string类型的值,那么string类型应该用于Tree和TreeNode类的实例。
示例 - 标识符层次结构
你想看看如何在基于 C#的应用程序中使用树吗?让我们看看我们的第一个例子。目标是构建一个包含几个节点的树,如图所示。代码中只展示带有更粗边框的节点组。然而,自己调整代码来构建整个树是个好主意:

图 7.2 – 标识符层次结构示例的说明
在这里,每个节点存储一个整数值,因此int类型被用于Tree和TreeNode类。以下代码应放置在Program.cs文件中:
Tree<int> tree = new() { Root = new() { Data = 100 } };
tree.Root.Children =
[
new() { Data = 50, Parent = tree.Root },
new() { Data = 1, Parent = tree.Root },
new() { Data = 150, Parent = tree.Root }
];
tree.Root.Children[2].Children =
[
new() { Data = 30, Parent = tree.Root.Children[2] }
];
代码看起来相当简单,不是吗?一开始,创建了一个Tree类的新实例,并通过创建TreeNode类的新实例并设置Data属性的值为100来配置根节点。
在以下行中,指定了根节点的子节点,即值为50、1和150的节点。对于每个节点,将Parent属性的值设置为先前添加的根节点的引用。
代码的其余部分展示了如何为给定节点添加子节点,即根节点的第三个子节点——即值为150的节点。这里只添加了一个节点:值为30的节点。当然,你还需要指定父节点的引用。
那就结束了!你已经创建了第一个使用树的程序。现在,你可以运行它,但在控制台你将看不到任何输出。如果你想查看节点数据的组织方式,你可以调试程序并查看调试时的变量值。
示例 – 公司结构
在前面的例子中,你看到了如何在树中的每个节点存储整数值作为数据。然而,也可以在节点中存储用户定义类的实例。在这个例子中,你将看到如何创建一个表示公司结构的树,该树分为三个主要部门:开发、研究和销售。
在每个部门内,可能存在另一种结构,例如在开发团队的情况下。在这里,约翰·史密斯是开发团队的负责人。他是克里斯·莫里斯的老板,克里斯·莫里斯是两位初级开发人员埃里克·格林和阿什莉·洛佩兹的经理。后者也是艾米丽·杨的导师,艾米丽·杨是开发实习生。
下面的图中展示了示例树:

图 7.3 – 公司结构示例的说明
如您所见,每个节点应该存储比仅仅整数值更多的信息。应该有一个姓名和角色。此类数据作为Person记录实例的属性值存储,如下所示:
public record Person(string Name, string Role);
除了创建新的记录外,还需要添加一些代码:
Tree<Person> company = new()
{
Root = new()
{
Data = new Person("Marcin Jamro",
"Chief Executive Officer"),
Parent = null
}
};
company.Root.Children =
[
new() { Data = new Person("John Smith",
"Head of Development"), Parent = company.Root },
new() { Data = new Person("Alice Batman",
"Head of Research"), Parent = company.Root },
new() { Data = new Person("Lily Smith",
"Head of Sales"), Parent = company.Root }
];
company.Root.Children[2].Children =
[
new() { Data = new Person("Anthony Black",
"Senior Sales Specialist"),
Parent = company.Root.Children[2] }
];
在第一行,创建了一个新的Tree类实例。值得注意的是,在创建Tree和TreeNode类的新实例时使用了Person记录作为指定的类型。因此,您可以轻松地为每个节点存储多个简单数据类型。代码的其余部分与第一个基本树的示例类似。在这里,您也指定了根节点(对于Chief Executive Officer角色),然后配置其子元素(John Smith、Alice Batman和Lily Smith),并为现有节点之一设置子节点,即Head of Sales角色的节点。
这看起来简单直接吗?在下一节中,您将看到一种更受限制,但非常重要且广为人知的树变体:二叉树。
二叉树
一般而言,基本树中的每个节点可以包含任意数量的子节点。然而,在二叉树的情况下,一个节点不能包含超过两个子节点。这意味着它可以包含零个、一个或两个子节点。这样的要求对二叉树的形状有重要影响,如下两个图所示,它们展示了二叉树:

图 7.4 – 二叉树的示意图
如前所述,二叉树中的一个节点最多可以包含两个子节点。因此,它们被称为左子节点和右子节点。在前一个图中左侧显示的二叉树中,节点21有两个子节点,即作为左子节点的68和作为右子节点的12,而节点100只有一个左子节点。
遍历
您是否想过如何遍历树中的所有节点?您如何在树的遍历过程中指定节点的顺序?有三种常见的方法,即先序、中序和后序,如下所示:

图 7.5 – 先序、中序和后序遍历
如您在图中所见,这些方法之间存在明显的差异。然而,您是否有任何想法如何应用二叉树的先序、中序或后序遍历?让我们详细解释所有这些方法。
先序
如果您想使用先序方法遍历二叉树,您首先访问根节点。然后,您访问左子节点。最后,访问右子节点。当然,这样的规则不仅适用于根节点,也适用于树中的任何节点。因此,您可以理解先序遍历的顺序为首先访问当前节点,然后是其左子节点(使用先序方法递归地遍历整个左子树),最后是其右子节点(以类似的方式遍历右子树)。
解释可能听起来有点复杂,所以让我们看看前一个图中左侧所示树的简单示例。首先,访问根节点(即 1)。然后,分析它的左子节点。因此,下一个访问的节点是当前节点,9。下一步是它的左子节点的中序遍历。因此,访问 5。由于这个节点没有子节点,你可以回到当前节点是 9 的遍历阶段。它已经被访问,以及它的左子节点,所以现在是时候继续到它的右子节点。在这里,你首先访问当前节点,6,然后跟随到它的左子节点,3。你可以应用相同的规则继续遍历树。最终的顺序是 1,9,5,6,3,4,2,7,8。
如果这仍然有点令人困惑,以下图应该可以消除任何疑问:

图 7.6 – 前序遍历的详细图
该图展示了前序遍历的以下步骤,并增加了额外的指示: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。
性能如何?
如果你想检查二叉树是否包含某个给定的值,你需要检查每个节点,通过三种可用的模式之一遍历树:前序、中序或后序。这意味着查找时间是线性的,即 O(n)。
在这个简短的介绍之后,让我们继续进行基于 C# 的实现。
实现
二叉树的实施很简单,特别是如果你使用已经描述的基本树代码。让我们从一个表示节点的类开始。
节点
二叉树中的节点由 BinaryTreeNode 类的实例表示,该类继承自 TreeNode 泛型类。在 BinaryTreeNode 类中,有必要隐藏从基类继承的 Children 定义,并声明两个属性,Left 和 Right,它们代表节点的两个可能的子节点。相关代码如下:
public class BinaryTreeNode<T>
: TreeNode<T>
{
public new BinaryTreeNode<T>?[] Children { get; set; }
= [null, null];
public BinaryTreeNode<T>? Left
{
get { return Children[0]; }
set { Children[0] = value; }
}
public BinaryTreeNode<T>? Right
{
get { return Children[1]; }
set { Children[1] = value; }
}
}
此外,你需要确保包含子节点的数组恰好包含两个项目,最初设置为 null。因此,如果你想添加一个子节点,应该将其引用放置在 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) { return; }
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) { return; }
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) { return; }
TraversePostOrder(node.Left, result);
TraversePostOrder(node.Right, result);
result.Add(node);
}
代码与已描述的方法类似,但当然,应用了另一种访问节点的顺序。这里,你从左子节点开始,然后访问右子节点,接着将当前节点添加到已访问节点的列表中。
最后,让我们添加一个公共方法来以各种模式遍历树,该方法调用前面提到的私有方法。相关代码如下:
public List<BinaryTreeNode<T>> Traverse(TraversalEnum mode)
{
List<BinaryTreeNode<T>> nodes = [];
if (Root == null) { return nodes; }
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语句根据参数的值调用一个合适的私有方法。所提到的枚举如下:
public enum BinaryTree class is GetHeight. It returns the height of the tree, which can be understood as the maximum number of steps to travel from any leaf node to the root. The implementation is as follows:
public int GetHeight() => Root != null
? Traverse(TraversalEnum.PreOrder)
- .Max(n => n.GetHeight())
- 0;
After the introduction to the topic of binary trees, let’s see an example where this data structure is used for storing questions and answers in a simple quiz.
Example – simple quiz
As an example of a binary tree, a simple quiz application will be prepared. The quiz consists of a few questions and answers, shown depending on previously taken decisions. The application presents the question, waits until the user presses *Y* (yes) or *N* (no), and proceeds to the next question or shows the answer.
The structure of the quiz is created in the form of a binary tree, as follows:

Figure 7.7 – Illustration of the simple quiz example
At the beginning, the user is asked whether they have any experience in application development. If so, the program asks whether they have worked as a developer for more than 5 years. In the case of a positive answer, the result regarding applying to work as a senior developer is presented. Of course, other answers and questions are shown in the case of different decisions taken by the user.
The implementation of the simple quiz requires the `BinaryTree` and `BinaryTreeNode` classes, which were presented and explained earlier. Each node stores only a `string` value as data, representing either a question or an answer.
Let’s take a look at the main part of the code:
BinaryTree
BinaryTreeNode
while (node != null)
{
如果节点左侧和右侧都不为空
{
Console.WriteLine(node.Data);
node = Console.ReadKey(true).Key switch
{
ConsoleKey.Y => node.Left,
ConsoleKey.N => node.Right,
_ => node
};
}
else
{
Console.WriteLine(node.Data);
node = null;
}
}
In the first line, the `GetTree` method is called to construct a tree with questions and answers. Such a method will be shown next. Then, the root node is taken as the current node, for which the following operations are taken until an answer is reached.
At the beginning, you check whether the left and right child nodes exist – that is, whether it is a question and not an answer. Then, the textual content is written in the console, and the program waits until the user presses a key. If it is equal to *Y*, the current node’s left child is used as the current node. In the case of pressing *N*, the current node’s right child is used instead.
When decisions taken by the user cause an answer to be shown, it is presented in the console, and `null` is assigned to the `node` variable. Therefore, you break out of the `while` loop.
As mentioned, the `GetTree` method is used to construct a binary tree with questions and answers. Its code is presented as follows:
BinaryTree
{
BinaryTree
tree.Root = new BinaryTreeNode
{
Data = "Do you have an experience
in app development?",
Children =
[
new BinaryTreeNode
{
Data = "Have you worked as a developer
for 5+ years?",
Children =
[
new() { Data = "Apply as
一名高级开发者" },
new() { Data = "Apply as
一名中级开发者" }
]
},
new BinaryTreeNode
{
Data = "Have you completed a university?",
Children =
[
new() { Data = "Apply as
一名初级开发者" },
new BinaryTreeNode
{
Data = "Will you find some time
during the semester?",
Children =
[
new() { Data = "Apply for
长期实习" },
new() { Data = "Apply for
summer internship" }
]
}
]
}
]
};
tree.Count = 9;
return tree;
}
At the beginning, a new instance of the `BinaryTree` generic class is created, and you assign a new instance of `BinaryTreeNode` to the `Root` property.
What is interesting is that even while creating questions and answers programmatically, you create some kind of a tree-like structure because you use the `Children` property and specify items directly within such constructions. Therefore, you do not need to create many local variables for all questions and answers. It is worth noting that a question-related node is an instance of the `BinaryTreeNode` class with two child nodes (for *yes* and *no* decisions), while an answer-related node is a leaf, so it does not contain any child nodes.
Important note
In the presented solution, the values of the `Parent` property of the `BinaryTreeNode` instances are not set. If you want to use them or get the height of a node or a tree, you should set them on your own.
The simple quiz application is ready! You can build the project, launch it, and answer a few questions to see the results. Then, let’s close the program and proceed to the next section, where a variant of the binary tree data structure is presented.
Binary search trees
A binary tree is an interesting data structure that allows the creation of a hierarchy of elements, with the restriction that each node can contain at most two children, but without any rules about relationships between the nodes. For this reason, if you want to check whether a binary tree contains a given value, you need to check each node, traversing the tree using one of three available modes: pre-order, in-order, or post-order. This means that the lookup time is linear, namely *O(n)*.
What about a situation where there are some precise rules regarding relations between nodes in a tree? Let’s imagine a scenario where you know that the left subtree contains nodes with values smaller than the root’s value, while the right subtree contains nodes with values greater than the root’s value. Then, you can compare the searched value with the current node and decide whether you should continue searching in the left or right subtree. Such an approach can significantly limit the number of operations necessary to check whether the tree contains a given value. It seems quite interesting, doesn’t it?
This approach is applied in the **binary search tree (BST)** data structure. It is a kind of binary tree that introduces two strict rules regarding relations between nodes in the tree. **The rules state that for any node, the values of all nodes in its left subtree must be smaller than its value, and the values of all nodes in its right subtree must be greater than** **its value.**
Can you add duplicates to BSTs?
In general, a BST can contain two or more elements with the same value. However, within this book, a simplified version is given, which does not accept more than one element with the same value.
How does it look in practice? Let’s take a look at the following diagram of BSTs:

Figure 7.8 – Illustration of binary search trees.
The tree shown on the left-hand side contains 12 nodes. Let’s check whether it complies with the BST rule. You can do so by analyzing each node in the tree, except leaf nodes. Let’s start with the root node (with value **50**), which contains four descendant nodes in the left subtree (**40**, **30**, **45**, **43**), all smaller than **50**. The root node contains seven descendant nodes in the right subtree (**60**, **80**, **70**, **65**, **75**, **90**, **100**), all greater than **50**. That means that the BST rule is satisfied for the root node. While checking the BST rule for node **80**, you see that the values of all descendant nodes in the left subtree (**70**, **65**, **75**) are smaller than **80**, while the values in the right subtree (**90**, **100**) are greater than **80**. You should perform the same verification for all other nodes. Similarly, you can confirm that the BST from the right-hand side of the diagram adheres to the rules.
However, two such BSTs significantly differ in their **topology**. Both have the same height, but the number of nodes is different, namely 12 and 7\. The one on the left seems to be **fat**, while the other is rather **skinny**. Which one is better? To answer this question, let’s think about the algorithm for searching a value in the tree. As an example, the process of searching for the value **43** is shown in the following diagram:

Figure 7.9 – Searching for a given value in a BST
At the beginning (**Step 1**), you take a value of the root node (that is, **50**) and check whether the given value (**43**) is smaller or greater. It is smaller, so you proceed to search in the left subtree (**Step 2**). Thus, you compare **43** with **40**. This time, the right subtree is chosen because **43** is greater than **40**. Next, **43** is compared with **45** (**Step 3**), and the left subtree is chosen. Here, you compare **43** with **43**, and the given value is found (**Step 4**). If you take a look at the tree, you will see that only four comparisons are necessary.
What about the performance?
The shape of a tree has a great impact on the lookup performance. Of course, **it is much better to have a fat tree with limited height than a skinny tree with a bigger height**. The performance boost is caused by making decisions as to whether searching should be continued in the left or right subtree, without the necessity of analyzing the values of all nodes. If nodes do not have both subtrees, the positive impact on the performance will be limited. In the worst case, when each node contains only one child, the search time is even linear. However, in the ideal BST, the lookup time is an *O(log* *n)* operation.
After this short introduction, let’s proceed to the implementation in the C# language. At the end, you will see an example that shows how to use this data structure in practice.
Implementation
The implementation of a BST is a bit more difficult than the previously described variants of trees. For example, it requires you to prepare operations of insertion and removal of nodes from a tree, which do not break the rule regarding the arrangement of elements in the BST. You will see a solution shortly.
Tree
The whole tree is represented by an instance of the `BinarySearchTree` class, which inherits from the `BinaryTree` generic class, as in the following code snippet:
- public class BinarySearchTree
- BinaryTree
where T : IComparable
{
}
It is worth mentioning that the type of data, stored in each node, should be comparable. For this reason, it has to implement the `IComparable` interface. Such a requirement is necessary because the algorithm needs to know the relationships between values.
Of course, it is not the final version of the implementation of the `BinarySearchTree` class. You will very soon learn how to add new features, such as lookup, insertion, and removal of nodes.
Lookup
Now, let’s take a look at the `Contains` method, which checks whether the tree contains a node with a given value. Of course, this method takes into account the BST rule regarding the arrangement of nodes to limit the number of comparisons. The code is presented in the following block:
public bool Contains(T data)
{
BinaryTreeNode
while (node != null)
{
int result = data.CompareTo(node.Data);
如果结果为 0,则返回 true;
else if (result < 0) { node = node.Left; }
else { node = node.Right; }
}
return false;
}
The method takes only one parameter, namely the value to find in the tree. Inside the method, a `while` loop exists. Within it, the searched value is compared with the value of the current node. If they are equal (the comparison returns `0`), the value is found, and `true` is returned to inform that the search is completed successfully. If the searched value is smaller than the value of the current node, the algorithm continues searching in the left subtree. Otherwise, the right subtree is used instead.
How to compare objects?
The `CompareTo` method is provided by the implementation of the `IComparable` interface from the `System` namespace. Such a method makes it possible to compare two values. If they are equal, `0` is returned. If the object on which the method is called is bigger than the parameter, a value higher than `0` is returned. Otherwise, a value lower than `0` is returned.
The loop is executed until the node is found or there is no suitable child node to follow.
Insertion
The next necessary operation is the insertion of a node into a BST. Such a task is a bit more complicated because you need to find a place for adding a new element that will not violate BST rules. Let’s take a look at the code of the `Add` method:
public void Add(T data)
{
BinaryTreeNode
BinaryTreeNode
{
Data = data,
Parent = parent
};
if (parent == null)
{
Root = node;
}
else if (data.CompareTo(parent.Data) < 0)
{
parent.Left = node;
}
else
{
parent.Right = node;
}
Count++;
}
The method takes one parameter, namely a value that should be added to the tree. Within the method, you find a parent element (using the `GetParentForNewNode` auxiliary method, shown a bit later), where a new node should be added as a child. Then, a new instance of the `BinaryTreeNode` class is created, and the values of its `Data` and `Parent` properties are set.
In the following part of the method, you check whether the found parent element is equal to `null`. It means that there are no nodes in the tree, and a new node should be added as the root, which is well visible in the line, where a reference to the node is assigned to the `Root` property.
The next comparison checks whether the value for addition is smaller than the value of the parent node. In such a case, a new node should be added as the left child of the parent node. Otherwise, the new node is placed as the right child of the parent node. At the end, the number of elements stored in the tree is incremented.
Let’s take a look at the auxiliary method for finding the parent element for a new node:
private BinaryTreeNode
{
BinaryTreeNode
BinaryTreeNode
while (current != null)
{
parent = current;
int result = data.CompareTo(current.Data);
if (result == 0) { throw new ArgumentException(
$"节点 {data} 已存在。"); }
else if (result < 0) { current = current.Left; }
else { current = current.Right; }
}
return parent;
}
This method takes one parameter, namely a value of the new node. Within this method, you declare two variables representing the currently analyzed node (`current`) and the parent node (`parent`). Such values are modified in a `while` loop until the algorithm finds a proper place for the new node.
In the loop, you store a reference to the current node as the potential parent node. Then, you check whether the value for addition is equal to the value of the current node. If so, an exception is thrown because it is not allowed to add more than one element with the same value to the analyzed version of the BST. If the value for addition is smaller than the value of the current node, the algorithm continues searching for a place for the new node in the left subtree. Otherwise, the right subtree of the current node is used. At the end, the value of the `parent` variable is returned to indicate the found location for the new node.
Removal
You now know how to create a new BST, add some nodes to it, as well as check whether a given value already exists in the tree. However, can you also remove an item from a tree? Of course! The main method regarding the removal of a node from the tree is named `Remove` and takes only one parameter, namely the value of the node that should be removed. The implementation of the `Remove` method is as follows:
public void Remove. 这个私有方法的实现更为复杂,具体如下:
private void Remove(BinaryTreeNode<T>? node, T data)
{
if (node == null)
{
return;
}
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)
{
BinaryTreeNode<T>? newNode =
node.Left == null && node.Right == null
? null
: node.Left ?? node.Right;
ReplaceInParent(node, newNode!);
Count--;
}
else
{
BinaryTreeNode<T> successor =
FindMinimumInSubtree(node.Right);
node.Data = successor.Data;
Remove(successor, successor.Data!);
}
}
}
在开始时,方法检查当前节点(node参数)是否存在。如果不存在,则退出方法。
然后,Remove方法尝试找到要删除的节点。这是通过比较当前节点的值与要删除的值,并对当前节点的左子树或右子树递归调用Remove方法来实现的。
方法中最有趣的操作在以下部分进行。在这里,你需要处理四种节点删除场景,如下所示:
-
删除叶子节点
-
删除只有一个左子节点的节点
-
删除只有一个右子节点的节点
-
删除既有左子节点又有右子节点的节点
删除叶子节点的情况,你只需更新父元素中删除节点的引用。因此,将不会有从父节点到删除节点的引用,在遍历树时无法访问到它。
删除只有一个左子节点的节点也很简单,因为你只需要将删除节点的引用(在父元素中)替换为删除节点的左子节点。以下图示展示了如何删除只有一个左子节点的节点80:

图 7.10 – 从二叉搜索树中删除只有一个左子节点的节点
删除只有一个右子节点的节点的情况与第二种情况非常相似。因此,你只需将删除节点的引用(在父元素中)替换为删除节点的右子节点。
所有的这三种情况都在代码中以类似的方式处理,通过调用ReplaceInParent辅助方法,其代码如下:
private void ReplaceInParent(BinaryTreeNode<T> node,
BinaryTreeNode<T> newNode)
{
if (node.Parent != null)
{
BinaryTreeNode<T> parent =
(BinaryTreeNode<T>)node.Parent;
if (parent.Left == node) { parent.Left = newNode; }
else { parent.Right = newNode; }
}
else { Root = newNode; }
if (newNode != null) { newNode.Parent = node.Parent; }
}
该方法接受两个参数:要删除的节点(node)和应在父节点中替换它的节点(newNode)。因此,如果你想删除一个叶子节点,只需将第二个参数传递为null,因为你不想用任何其他东西替换被删除的节点。在删除只有一个子节点的情况下,你传递左子节点或右子节点的引用。
如果被删除的节点不是根节点,你需要检查它是否是父节点的左子节点。如果是,则更新适当的引用。这意味着新节点被设置为被删除节点的父节点的左子节点。以类似的方式,该方法处理被删除节点是父节点的右子节点的情况。如果被删除的节点是根节点,则替换节点被设置为根节点。最后,你检查新节点是否不等于null。这意味着你并没有删除一个叶子节点。在这种情况下,你设置Parent属性的值以指示新节点应该具有与被删除节点相同的父节点。
一个更复杂的情况是Remove方法递归地应用于找到的节点。相关代码片段如下所示,此代码片段是从Remove私有方法中复制出来的,以方便你阅读:
BinaryTreeNode<T> successor =
FindMinimumInSubtree(node.Right);
node.Data = successor.Data;
Remove(successor, successor.Data!);
最后的辅助方法命名为FindMinimumInSubtree,如下所示:
private BinaryTreeNode<T> FindMinimumInSubtree(
BinaryTreeNode<T> node)
{
while (node.Left != null) { node = node.Left; }
return node;
}
该方法接受子树的根节点作为参数,其中应找到最小值。在方法内部,使用while循环来获取最左边的元素。当没有左子节点时,返回node变量的当前值。
你可以在哪里找到更多信息?
书籍、研究论文以及互联网上关于 BST 的信息有很多。然而,所提供的 BST 实现是基于在 https://en.wikipedia.org/wiki/Binary_search_tree 中显示的代码,在那里你还可以找到有关此数据结构的更多信息。我强烈建议你对各种数据结构和算法保持好奇心,并拓宽你的知识面。
前面的代码看起来并不复杂,对吧?然而,它在实际中是如何工作的呢?让我们看看一个表示删除具有两个子节点的节点的图表:

图 7.11 – 在 BST 中删除具有两个子节点的节点
图表显示了如何删除值为40的节点。为此,你需要找到后继节点。它是被删除节点的右子树中具有最小值的节点。后继节点是42,它替换了40。
示例 – BST 可视化
在阅读有关二叉搜索树(BSTs)的部分时,你学到了很多关于这种数据结构的知识。因此,现在是时候创建一个示例程序来查看这种树的变体在实际中的表现了。该应用程序将向你展示如何创建一个 BST,添加一些节点(手动添加和使用之前介绍的插入方法),删除节点,遍历树,以及在控制台中可视化树。
开始时,通过创建 BinarySearchTree 类的新实例来准备一个新的树(存储整数值的节点)。通过手动添加三个节点,并指示适当的子元素和父元素引用来配置。相关代码如下:
BinarySearchTree<int> tree = new();
tree.Root = new BinaryTreeNode<int>() { Data = 100 };
tree.Root.Left = new() { Data = 50, Parent = tree.Root };
tree.Root.Right = new() { Data = 150, Parent = tree.Root };
tree.Count = 3;
Visualize(tree, "BST with 3 nodes (50, 100, 150):");
然后,你使用 Add 方法向树中添加一些节点,并使用 Visualize 方法可视化当前树的状态,如下所示:
tree.Add(75);
tree.Add(125);
Visualize(tree, "BST after adding 2 nodes (75, 125):");
让我们添加五个节点,以下代码如下:
tree.Add(25);
tree.Add(175);
tree.Add(90);
tree.Add(110);
tree.Add(135);
Visualize(tree, "BST after adding 5 nodes
(25, 175, 90, 110, 135):");
接下来的操作集与从树中删除各种节点以及可视化特定更改有关。相关代码如下:
tree.Remove(25);
Visualize(tree, "BST after removing the node 25:");
tree.Remove(50);
Visualize(tree, "BST after removing the node 50:");
tree.Remove(100);
Visualize(tree, "BST after removing the node 100:");
最后,展示了三种遍历模式。相应的代码如下:
foreach (TraversalEnum mode
in Enum.GetValues<TraversalEnum>())
{
Console.Write($"\n{mode} traversal:\t");
Console.Write(string.Join(", ",
tree.Traverse(mode).Select(n => n.Data)));
}
另一个有趣的任务是在控制台中开发树的可视化。这样的功能非常有用,因为它允许舒适且快速地观察树,无需在 IDE 中调试应用程序并展开工具提示中的以下元素以显示变量的当前值。然而,在控制台中展示树不是一个简单任务。幸运的是,你无需担心这一点,因为在本节中你将学习如何实现此功能。
首先,让我们看看 Visualize 方法:
void Visualize(BinarySearchTree<int> tree, string caption)
{
char[,] console = Initialize(tree, out int width);
VisualizeNode(tree.Root, 0, width / 2,
console, width);
Console.WriteLine(caption);
Draw(console);
}
该方法接受两个参数,即代表整个树的 BinarySearchTree 类的实例,以及应在可视化上方显示的标题。在方法内部,使用 Initialize 辅助方法初始化一个字符数组,该数组应在控制台中显示,稍后展示。然后,调用 VisualizeNode 递归方法将特定节点在树中的数据填充到数组的各个部分。最后,将标题和板(由数组表示)写入控制台。
Initialize 方法创建了上述数组,如下代码片段所示:
const int ColumnWidth = 5;
char[,] Initialize(BinarySearchTree<int> tree,
out int width)
{
int height = tree.GetHeight();
width = (int)Math.Pow(2, height) - 1;
char[,] console =
new char[height * 2, ColumnWidth * width];
for (int y = 0; y < console.GetLength(0); y++)
{
for (int x = 0; x < console.GetLength(1); x++)
{
console[y, x] = ' ';
}
}
return console;
}
二维数组包含的行数等于树的高度乘以 2,以便也有空间用于连接节点与父节点的线条。列数根据公式 columnwidth * 2^height - 1 计算,其中 columnwidth 是 ColumnWidth 常量值,height 是树的高度。
如果你看一下结果,这些值可能更容易理解:

图 7.12 – BST 可视化示例的截图
在 Visualize 方法中,调用了 VisualizeNode。你对了解它是如何工作的以及如何不仅展示节点值还展示线条感兴趣吗?如果是这样,让我们看看它的代码,如下所示:
void VisualizeNode(BinaryTreeNode<int>? node, int row,
int column, char[,] console, int width)
{
if (node == null) { return; }
char[] chars = node.Data.ToString().ToCharArray();
int margin = (ColumnWidth - chars.Length) / 2;
for (int i = 0; i < chars.Length; i++)
{
int col = ColumnWidth * column + i + margin;
console[row, col] = 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)。在方法内部,有一个检查以确定当前节点是否存在。如果存在,则获取节点的值作为一个char数组,计算边距,并在for循环中将带有值字符表示的char数组写入缓冲区(console变量)。
在以下代码行中,对当前节点的左右子节点调用了VisualizeNode方法。当然,你需要调整行索引(通过添加2)和列索引(通过添加或减去计算出的值)。
最后,通过调用DrawLineLeft和DrawLineRight方法来绘制线条。第一个在以下代码片段中展示:
void DrawLineLeft(BinaryTreeNode<int> node, int row,
int column, char[,] console, int columnDelta)
{
if (node.Left == null) { return; }
int sci = ColumnWidth * (column - columnDelta) + 2;
int eci = ColumnWidth * column + 2;
for (int x = sci + 1; x < eci; x++)
{
console[row + 1, x] = '-';
}
console[row + 1, sci] = '+';
console[row + 1, eci] = '+';
}
该方法也接受五个参数:
-
应该绘制线的当前节点(
node) -
行的索引(
row) -
列的索引(
column) -
作为屏幕缓冲区的数组(
console) -
在
VisualizeNode方法中计算出的增量值
在开始时,你检查当前节点是否包含左子节点,因为只有在这种情况下才需要绘制线的左侧部分。如果是这样,你计算列的起始(sci,代表起始列索引)和结束(eci作为结束列索引)索引,并用破折号填充数组的适当元素。最后,在绘制线条将与另一个元素的右侧线连接的地方以及线的另一侧添加一个加号。
几乎以相同的方式,你为当前节点绘制右侧线。当然,你需要调整有关计算列起始和结束索引的代码。DrawLineRight方法的最终代码如下:
void DrawLineRight(BinaryTreeNode<int> node, int row,
int column, char[,] console, int columnDelta)
{
if (node.Right == null) { return; }
int sci = ColumnWidth * column + 2;
int eci = ColumnWidth * (column + columnDelta) + 2;
for (int x = sci + 1; x < eci; x++)
{
console[row + 1, x] = '-';
}
console[row + 1, sci] = '+';
console[row + 1, eci] = '+';
}
最后,让我们看看在控制台中显示棋盘的Draw方法。它只是遍历数组的所有元素并将它们写入控制台,如下所示:
void Draw(char[,] console)
{
for (int y = 0; y < console.GetLength(0); y++)
{
for (int x = 0; x < console.GetLength(1); x++)
{
Console.Write(console[y, x]);
}
Console.WriteLine();
}
}
那就结束了!你已经写下了构建项目、启动程序并看到其运行的整个代码。启动后,你会看到第一个 BST,如下所示:

图 7.13 – BST 可视化示例截图,步骤 1
添加下一个两个节点75和125后,BST 看起来略有不同:

图 7.14 – BST 可视化示例截图,步骤 2
然后,你为接下来的五个元素执行插入操作。这些操作对树形结构有非常明显的影响,如控制台所示:

图 7.15 – BST 可视化示例截图,步骤 3
在添加了 10 个元素之后,程序显示了移除特定节点对树形状的影响。首先,让我们移除值为25的叶节点:

图 7.16 – BST 可视化示例的截图,步骤 4
然后,程序移除只有一个子节点的节点,即右子节点。有趣的是,右子节点也有一个右子节点。然而,所提出的算法在这种情况下也能正常工作,您得到了以下结果:

图 7.17 – BST 可视化示例的截图,步骤 5
最后一个移除操作是最复杂的,因为它需要您移除具有两个子节点的节点,并且它还扮演着根的角色。在这种情况下,从根的右子树的最左端找到元素,并将其替换为要移除的节点,如图树最终视图所示:

图 7.18 – BST 可视化示例的截图,步骤 6
剩下的一组操作是树的遍历,即先序、中序和后序遍历。应用程序呈现了以下结果:
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
创建的应用程序看起来相当令人印象深刻,不是吗?您不仅从头开始实现了 BST 的实现,还为在控制台中可视化它准备了平台。干得好!
它已经排序了吗?
让我们再次查看中序遍历的结果。如您所见,在 BST 的情况下,它以升序排序节点。
然而,您能看出创建的解决方案中存在潜在问题吗?考虑一下只从树的给定区域移除节点或插入已排序值的情况。这可能意味着一个具有适当宽度-深度比的胖树可能会变成瘦树。在最坏的情况下,它甚至可能被描绘成一个列表,其中所有节点只有一个子节点。您有什么想法来解决不平衡树的问题并始终保持其平衡吗?如果没有,接下来您将找到一些关于如何实现这一目标的信息。
自平衡树
在本节中,您将了解自平衡树的两种变体,这些变体在添加和移除节点时始终保持树平衡。然而,为什么这如此重要呢?如前所述,查找性能取决于树的形状。在不适当的节点组织情况下,形成列表,搜索给定值的操作可能是一个O(n)操作。而一个正确排列的树,其性能可以通过O(log n)显著提高。
你知道 BST 可以非常容易地变成一个不平衡树吗?让我们做一个简单的测试,向树中添加以下九个数字,从1到9。然后,你将收到一个如左图所示的树形。然而,相同的值可以以另一种方式排列,形成一个平衡树,具有显著更好的宽度-深度比,如右图所示:

图 7.19 – 不平衡树与平衡树的差异
你现在知道了不平衡树和平衡树是什么,以及自平衡树的目标是什么。然而,AVL 树或红黑树是什么?它们是如何工作的?在使用这些数据结构时应该考虑哪些规则?你将在下一部分找到这些问题的答案。
AVL 树
AVL 树是以其发明者 Adelson-Velsky 和 Landis 的名字命名的。它是一种二叉搜索树,它还要求每个节点的左右子树的高度之差不能超过一。当然,在向树中添加和删除节点后,必须保持这条规则。旋转在这个过程中扮演着重要的角色,用于纠正节点的错误排列。
性能如何?
在讨论 AVL 树时,指出这种数据结构的性能至关重要。在这种情况下,插入、删除和查找的平均和最坏情况的时间复杂度都是O(log n),因此与 BST 相比,最坏情况下的性能有显著提升。
AVL 树的实现,包括保持树平衡状态所需的必要旋转,并不简单,需要相当长的解释。由于本书的页数有限,其实现在此未展示。幸运的是,你可以使用一个支持此类基于树的 NuGet 包,在你的应用程序中利用 AVL 树。
红黑树
红黑树(RBT)是自平衡二叉搜索树的下一个变体。作为 BST 的变体,这种数据结构要求维护标准的 BST 规则。此外,还必须考虑以下规则:
-
每个节点必须着色为红色或黑色。因此,你需要为存储颜色的节点添加额外的数据。
-
NIL伪节点应作为树的叶子使用,而所有其他节点都是内部节点。此外,所有NIL伪节点都必须是黑色的。 -
如果一个节点是红色,那么它的两个子节点必须是黑色。
-
对于任何节点,
NIL伪节点必须相同。
正确的红黑树在以下图中展示:

图 7.20 – 红黑树的示意图
该树由九个节点组成,每个节点着色为红色或黑色。值得一提的是 NIL 伪节点,它们被添加为叶子节点。如果你再次查看之前列出的规则集,你可以确认在这种情况下所有这些规则都得到了维护。
与 AVL 树类似,RBTs 在添加或删除节点后也必须维护规则。在这种情况下,恢复 RBT 属性的过程甚至更加复杂,因为它涉及到 重新着色 和 旋转。
性能如何?
在讨论这种自平衡二叉搜索树变体时,也值得注意其性能。在平均情况和最坏情况下,插入、删除和查找操作都是 O(log n) 操作,因此它们与 AVL 树的情况相同,并且在最坏情况下比 BSTs 更好。
幸运的是,你不需要了解和理解内部细节,这些细节相当复杂,才能从这种数据结构中受益并将其应用于你的项目。正如在 AVL 树的情况下已经提到的,你也可以使用可用的 NuGet 包之一来处理 RBTs。
你在哪里可以找到更多信息?
树的主题比本章所展示的要广泛得多。因此,如果你对这样的主题感兴趣,我强烈建议你自己去寻找更多信息。你还可以在 Wikipedia 上找到一些内容,例如在 en.wikipedia.org/wiki/Binary_tree 和 en.wikipedia.org/wiki/Binary_search_tree。自平衡树的内容在 en.wikipedia.org/wiki/AVL_tree 和 en.wikipedia.org/wiki/Red-black_tree 中介绍。本章后面提到的 tries 和二叉堆(binary heaps)的主题也在 en.wikipedia.org/wiki/Trie 和 en.wikipedia.org/wiki/Binary_heap 中介绍。
你已经了解了一些关于自平衡树的基本信息,即 AVL 树和 RBTs。那么,让我们看看另一种基于树的结构,即 trie,它是字符串相关操作的绝佳解决方案。
Tries
树是一种强大的数据结构,在各种场景中使用。其中之一与处理字符串相关,例如用于 自动完成 和 拼写检查 功能,这些功能你肯定从许多系统中都了解过。如果你想在你的应用程序中实现它,你可以从另一种基于树的数据库结构中受益,即 Trie。它用于存储字符串并执行基于前缀的搜索。
Trie 是一种树,只有一个根节点,其中每个节点代表一个字符串,每条边表示一个字符。Trie 节点包含对下一个节点的引用,作为一个包含 26 个元素的数组,代表字母表中的 26 个字符(从a到z)。当您从根节点到每个节点移动时,您会收到一个字符串,它要么是一个已保存的单词,要么是它的子串。
为什么正好是 26 个元素?
在这里,我们使用代表 26 个字符的 26 个元素,因为这是字母表中 a 和 z 之间基本字符的确切数量,没有任何特殊字符存在于各种语言中。当然,在您的实现中,您可以扩展这个集合,包括其他字符,例如来自波兰语的 ą、ę 或 ś,以及甚至数字或一些特殊字符,例如破折号。选择合适的字符集取决于此数据结构将用于的场景。
这听起来复杂吗?可能是的,所以让我们看看下面的图,它应该能消除任何疑虑:

图 7.21 – trie 的示意图
该图展示了存储以下单词的 trie:ai、aid、aim、air、airplane、airport、algorithm、all、allergy、allow、allowance。正如您所看到的,有一个根节点(用-标记),它只包含一个子节点,即代表 a 子串。此节点包含两个子节点,分别对应 ai 单词和 al 子串。ai 节点有三个子节点,分别代表 aid、aim 和 air 单词。以类似的方式,您可以分析整个 trie。请记住,单词用粗线标记,而子串用较细的线表示。
性能如何?
在 trie 的情况下,搜索和插入是 O(n) 操作,其中 n 表示单词长度。因此,trie 是一种高效的字符串操作数据结构。
实现
在这个简短的介绍之后,让我们转向一些更令人兴奋的事情——编码!
节点
请查看以下表示节点的类的实现:
public class TrieNode
{
public TrieNode[] Children { get; set; }
= new TrieNode[26];
public bool IsWord { get; set; } = false;
}
TrieNode 类包含两个属性。第一个属性名为 Children,是一个包含 26 个元素的数组。每个元素代表字母表中的一个特定字母,从 a(索引等于 0)到 z(索引等于 25)。如果有另一个具有相同前缀的单词,则下一个节点的引用位于 Children 数组的一个合适元素中。第二个属性名为 IsWord,表示当前节点是否是单词的最后一个字符。这意味着您可以通过从根元素移动到该节点来获取此单词。
Trie
代码的下一部分展示了表示 trie 的类的实现:
public class Trie
{
private readonly TrieNode _root = new();
}
在这里,有一个表示根元素的私有字段。当然,你需要添加一些方法来使其可用。首先,让我们实现一个检查给定单词是否存在于字典树中的方法。其代码如下:
public bool DoesExist(string word)
{
TrieNode current = _root;
foreach (char c in word)
{
TrieNode child = current.Children[c - 'a'];
if (child == null) { return false; }
current = child;
}
return current.IsWord;
}
首先,你保存根元素的引用作为当前节点。然后,你遍历构成单词的以下字符。对于每个字符(由c变量表示),你获取一个适当的节点(child)。如果它是null,则意味着该单词不在字典树中。否则,你保存子元素作为当前节点。当foreach循环结束时,当前节点代表最后一个字符的节点,因此你只需返回IsWord属性的值。
下一个方法允许你将单词插入到字典树中,如下所示:
public void Insert(string word)
{
TrieNode current = _root;
foreach (char c in word)
{
int i = c - 'a';
current.Children[i] = current.Children[i] ?? new();
current = current.Children[i];
}
current.IsWord = true;
}
前面的代码与已描述的代码有些相似。然而,在foreach循环中有一个重要的区别。在这里,如果你为构成单词的任何字符都没有找到子节点,则创建一个新的子节点。最后,通过将IsWord属性的值设置为true,你表明该节点代表一个单词。
如前所述,字典树是一种数据结构,它允许你以高效的方式执行基于前缀的搜索。因此,让我们来实现它:
public List<string> SearchByPrefix(string prefix)
{
TrieNode current = _root;
foreach (char c in prefix)
{
TrieNode child = current.Children[c - 'a'];
if (child == null) { return []; }
current = child;
}
List<string> results = [];
GetAllWithPrefix(current, prefix, results);
return results;
}
该方法接受一个参数,即搜索单词的前缀。一开始,你遍历前缀的所有字符以获取形成前缀的最后一个字符的引用。如果在任何阶段找不到子节点,则返回一个空列表,这意味着没有结果。
否则,你创建一个List<string>实例来存储结果,然后调用GetAllWithPrefix方法,其代码如下:
private void GetAllWithPrefix(TrieNode node,
string prefix, List<string> results)
{
if (node == null) { return; }
if (node.IsWord) { results.Add(prefix); }
for (char c = 'a'; c <= 'z'; c++)
{
GetAllWithPrefix(node.Children[c - 'a'],
prefix + c, results);
}
}
你需要检查当前节点是否为null。如果是,则从方法中返回。否则,你将验证当前节点是否构成一个单词。如果是,则将其添加到results中。接下来,你遍历所有字母字符,即从a到z,并递归调用相同的方法以找到下一个单词并将它们添加到results列表中。
如你所见,字典树的基本实现并不复杂,可以用清晰简洁的代码完成。然而,你如何测试字典树的实际效果呢?让我们看看:
Trie trie = new();
trie.Insert("algorithm");
trie.Insert("aid");
trie.Insert("aim");
trie.Insert("air");
trie.Insert("ai");
trie.Insert("airport");
trie.Insert("airplane");
trie.Insert("allergy");
trie.Insert("allowance");
trie.Insert("all");
trie.Insert("allow");
bool isAir = trie.DoesExist("air");
List<string> words = trie.SearchByPrefix("ai");
foreach (string word in words)
{
Console.WriteLine(word);
}
前面的代码形成一个字典树,如图 7.21所示,有以a开头的 11 个单词,例如algorithm和allow。你使用Insert方法添加这样的单词。然后,使用DoesExist方法检查air单词是否存在。接下来,你获取所有以ai前缀开头的单词并将它们写入控制台:
ai
aid
aim
air
airplane
airport
在关于 trie 的部分结束时,让我们谈谈这种数据结构的空间复杂度。如您所见,您需要为每个 trie 节点存储 26 个子节点引用,并且可能有很多只有一两个引用被设置的情况。例如,您可以看看 algorithm 这个词,其中浪费了很多空间。通过某种方式优化它,使整个树更小会更好。
幸运的是,可以使用另一种名为 radix tree 或 压缩 trie 的数据结构,它是 trie 的空间优化版本。区别相当简单:即 您将此父节点的唯一子节点与父节点合并。当然,在这种情况下,边可以表示子串。
如果您想看到与 trie 图表相同的输入数据的 radix tree 的样子,请看以下图表:

图 7.22 – 根树示意图
看起来简单多了,不是吗?例如,让我们分析从根节点到 算法 的路径。在这里,您只需使用三个边,即 a、l 和 gorithm。
尝试自己实现它
基于前面的图表和 trie 的实现,我鼓励您尝试自己实现 radix tree。您还应该准备一个在这样数据结构中搜索单词的方法。祝你好运!
示例 – 自动完成
作为 trie 应用程序的示例,您将创建一个包含国家名称的 Countries.txt 文件,并将其作为内容添加到项目中,该内容将被自动复制到输出目录。
如何将文件添加到项目中?
您应该在 .txt 扩展名的项目节点上右键单击。确认后,文件将被创建。如果您想将此文件标记为内容文件并自动将其复制到输出目录,您应该单击文件并在 属性 窗口中更改两个属性。首先,将 生成操作 更改为 内容。然后,将 复制到输出目录 设置为 始终复制。
国家名称的部分文件内容如下:
Afghanistan
Albania
Algeria (...)
Pakistan
Palau
Panama
Papua New Guinea
Paraguay
Peru
Philippines
Poland
Portugal (...)
Zambia
Zimbabwe
当然,前面的代码片段中省略了很多国家名称。然而,当国家名称文件准备好后,您需要读取其内容并形成一个 trie,如下面的代码块所示:
using System.Text.RegularExpressions;
Trie trie = new();
string[] countries =
await File.ReadAllLinesAsync("Countries.txt");
foreach (string country in countries)
{
Regex regex = new("[^a-z]");
string name = country.ToLower();
name = regex.Replace(name, string.Empty);
trie.Insert(name);
}
在开始时,您创建 Trie 类的新实例。然后,您从 Countries.txt 文件中读取所有行并将它们存储在 countries 数组中。
代码的其余部分由一个 foreach 循环组成,该循环遍历所有国家名称。对于每一个,您将其转换为小写并删除所有除了 a-z 的字符。这个任务通过正则表达式和 System.Text.RegularExpressions 命名空间中的 Regex 类来完成。
当 trie 准备就绪后,您使用一个 while 循环,如下所示:
string text = string.Empty;
while (true)
{
Console.Write("Enter next character: ");
ConsoleKeyInfo key = Console.ReadKey();
if (key.KeyChar < 'a' || key.KeyChar > 'z') { return; }
text = (text + key.KeyChar).ToLower();
List<string> results = trie.SearchByPrefix(text);
if (results.Count == 0) { return; }
Console.WriteLine(
$"\nSuggestions for {text.ToUpper()}:");
results.ForEach(r => Console.WriteLine(r.ToUpper()));
Console.WriteLine();
}
在 while 循环中,你等待用户按下任何键。如果这个键不是 a 到 z 之间的字母,程序将结束其操作。否则,你将输入的字符追加到用于搜索以该前缀开头的所有国家名称的前缀中。如果结果数量为零,应用程序将结束其操作。否则,你使用 ForEach 扩展方法将每个建议写入单独的一行。
如你所见,前缀树为你提供了一个强大而高效的机制来实现自动完成功能。但在实践中它看起来是什么样子呢?让我们看看以下关于搜索 POLAND 的输出:
Enter next character: p
Suggestions for P:
PAKISTAN
PALAU
PANAMA
PAPUANEWGUINEA
PARAGUAY
PERU
PHILIPPINES
POLAND
PORTUGAL
Enter next character: o
Suggestions for PO:
POLAND
PORTUGAL
Enter next character: l
Suggestions for POL:
POLAND
Enter next character: e
在开始时,你可以看到以 P 开头的国家名称。在输入 O 之后,你将结果限制为以 PO 开头的国家名称。以同样的方式,你可以进一步增加前缀,并得到越来越少的结果。
让我们继续本章的最后部分,这部分与堆有关。堆是什么,为什么它们会在关于树的章节中出现?
堆
堆是树的另一种变体,你已经在 第三章 数组和排序 中了解到了。在那里,你使用堆在堆排序算法中对数组进行排序。因此,在本章中,你将只看到这个数据结构的一个简要总结。然而,我强烈建议你不要离开这个主题,并且自己学习更多关于堆的知识,因为它们是强大且流行的数据结构。
如你所知,二叉堆存在两种版本:最小堆和最大堆。对于每一种,都必须满足一个额外的属性:
-
对于最小堆:每个节点的值必须大于或等于其父节点的值
-
对于最大堆:每个节点的值必须小于或等于其父节点的值
这些规则起着非常重要的作用,因为它们规定根节点始终包含最小值(在最小堆中)或最大值(在最大堆中)。你在排序时受益于这个假设。你还记得吗?
二叉堆还必须遵循完全二叉树规则,该规则要求每个节点不能包含超过两个子节点,并且树的所有层级必须完全填充,除了最后一层,最后一层必须从左到右填充,并且可以在右侧留出一些空间。
让我们看看以下两个二叉堆:

图 7.23 – 最小堆和最大堆的示意图
你可以轻松地检查这两个堆是否都遵循所有规则。作为一个例子,让我们验证最小堆变体中值等于 20 的节点(如图中左侧所示)的堆属性。该节点有两个子节点,其值分别为 35 和 50,这两个值都大于 20。以同样的方式,你可以检查堆中的其他节点。
二叉树规则也被保持,因为每个节点最多包含两个子节点。最后一个要求是树的每一层除了最后一层都要完全填满,最后一层不需要完全填满,但必须从左到右包含节点。在最小堆的例子中,有三层被完全填满(分别包含一个、两个和四个节点),而最后一层包含两个节点(25和70),放置在两个最左边的位置。同样,你可以确认最大堆(如右图所示)配置正确。
在对堆的主题,尤其是二叉堆的简短介绍结束时,值得提到的是其广泛的应用范围。首先,这种数据结构是实现优先队列的便捷方式,具有插入新值和移除最小值(在最小堆中)或最大值(在最大堆中)的操作。此外,堆在堆排序算法以及图算法中也被使用。
二叉堆可以从头开始实现,或者你可以使用一些已经可用的实现作为 NuGet 包。其中一个解决方案名为PommaLabs.Hippie,可以使用NuGet 包管理器轻松地在项目中安装。提到的库包含了一些堆变体的实现,包括二叉堆、二项堆和斐波那契堆。
在本章中,树无处不在,堆也是这种数据结构的代表!既然你已经对树有了很多了解,让我们继续到总结部分。
总结
当前章节是书中迄今为止最长的章节。然而,它包含了大量关于树变体的信息。这些数据结构在许多算法中扮演着非常重要的角色,因此了解它们以及如何在应用程序中使用它们是很好的。因此,本章不仅包含了简短的理论介绍,还包含了图表、解释和代码示例。
在一开始,树的概念被描述了。作为提醒,树由节点组成,包括一个根节点。根节点没有父节点,而所有其他节点都有。每个节点可以有任意数量的子节点。同一节点的子节点可以被称为兄弟节点,而没有子节点的节点被称为叶节点。
树的多种变体遵循这种结构。本章首先描述的是二叉树。在这种情况下,一个节点最多可以包含两个子节点。然而,二叉搜索树的规则更为严格。对于这类树中的任何节点,其左子树中所有节点的值必须小于该节点的值,而其右子树中所有节点的值必须大于该节点的值。BSTs 在许多应用中都有广泛的应用,并为开发者提供了显著的查找性能提升。然而,在向树中添加排序值时,很容易使树变得不平衡。因此,对性能的积极影响可能有限。
幸运的是,存在NIL伪节点。此外,如果节点是红色的,那么它的两个子节点必须是黑色的,并且对于任何节点,到达其子叶的路径上黑色节点的数量必须相同。
然后,你学习了关于字典树的很多知识,并看到了它们在处理字符串方面的出色性能,例如用于自动完成或拼写检查功能。每个字典树是一个只有一个根节点的树,其中每个节点代表一个字符串,每条边表示一个字符。字典树节点包含指向下一个节点的引用,这些引用作为一个包含可能字符的数组的元素。当你从根节点到每个节点移动时,你会得到一个字符串,它要么是一个已保存的单词,要么是其子串。在这一部分,还提到了基数树,它是字典树的空间优化版本。
本章剩余部分与二叉堆相关。作为提醒,堆是树的一种变体,存在两种版本,最小堆和最大堆。值得注意的是,每个节点的值必须大于或等于(对于最小堆)或小于或等于(对于最大堆)其父节点的值。
让我们继续讨论图,这是下一章的主题!
第八章:探索图
在上一章中,你学习了关于树的知识。然而,你知道这样的数据结构也属于图吗?但什么是图,你如何在应用中使用它?你将在本章中找到这些以及其他许多问题的答案!
首先,将介绍关于图的基本信息,包括节点和边的解释。由于图是实践中常用的数据结构,你还将看到它们的一些应用,例如用于存储社交媒体上的朋友数据或用于在城市中寻找道路。然后,将介绍图表示的主题,即使用邻接表和矩阵。
在这简短的介绍之后,你将学习如何在 C#语言中实现图。此外,你还将了解两种图的遍历模式,即深度优先搜索(DFS)和广度优先搜索(BFS)。对于这两种方法,都会展示代码和详细的描述。
接下来,你将学习关于最小生成树(MSTs)的主题,以及创建它们的两种算法,即 Kruskal 算法和 Prim 算法。这些算法将以描述、代码片段和示意图的形式展示。此外,还将提供一个实际应用的例子。
另一个有趣的与图相关的问题是节点的着色,这将在本章的后续部分中考虑。最后,将使用 Dijkstra 算法分析在图中找到最短路径的主题。
正如你所见,图论涉及许多有趣的问题,而本书中仅提及其中的一些。然而,所选的主题适合在 C#语言的环境中展示各种与图相关的方面。你准备好深入图论的话题了吗?如果是的话,就开始阅读这一章吧!
本章将涵盖以下主题:
-
图的概念
-
应用
-
表示
-
实现
-
遍历
-
最小生成树
-
着色
-
最短路径
图的概念
让我们从问题“什么是图?”开始。广义上讲,图是一种由 节点 (也称为 顶点 )和 边 组成的 数据结构。每条边连接两个节点。图数据结构不需要关于节点之间连接的任何特定规则,如下面的图所示:

图 8.1 – 图的示意图
这个概念看起来很简单,不是吗?让我们尝试分析前面的图,以消除任何疑问。它包含9 个节点,其数值介于1和9之间。这些节点通过11 条边相连,例如节点2和4之间。此外,图可以包含环 – 例如,由节点2、3和4表示的环 – 以及不相连的节点组。
然而,关于父节点和子节点的主题,你是从学习树的结构中知道的?由于图中没有关于连接的具体规则,因此在这种情况下不使用这些概念。
想象一个图
如果你想更好地可视化一个图,暂时放下这本书,看看展示你国家最重要道路的地图,比如高速公路或快速路。这样一条道路的每一部分连接两个城镇,并有一定的长度。一旦你在纸上画出这样的结构,你就会发现,由于它,你可以找到两个城镇之间的路线,以及整个路线的总距离。你知道你刚刚创建了一个图吗?单个城镇是节点,连接它们的线是边。两个城镇之间的距离是边的权重。当你能够将理论与实践联系起来时,事情变得如此简单,不是吗?现在,是时候把地图放一边,专注于学习这本书将要介绍的最后一个数据结构,即图。
图中的边需要更多的注释。在先前的图中,你可以看到一个所有节点都通过无向边连接的图 – 也就是说,双向边。它们表示可以在两个方向之间旅行 – 例如,从节点2到3,以及从节点3到2。这样的边在图形上表示为直线。当一个图包含无向边时,它是一个无向图。
然而,当你需要表示节点之间的旅行只能在一个方向上进行时,情况会怎样?在这种情况下,你可以使用有向边 – 也就是说,单向边 – 它们在图形上表示为带有指示边方向的箭头的直线。如果一个图包含有向边,它可以被称为有向图。
自环是什么?
一个图也可以包含自环。每个自环都是连接给定节点与自身的边。然而,这样的主题超出了本书的范围,并且在本章的示例中不会考虑。
以下右图展示了一个有向图的例子,而左图展示了一个无向图:

图 8.2 – 无向图和有向图的区别
作为简短的解释,先前的图中右侧所示的有向图包含8 个节点,通过15 条单向边连接。例如,它们表示可以从节点1到2在两个方向上旅行,但只能从节点1到3单向旅行,因此无法直接从3到达1。
无向边和有向边之间的划分并非唯一。你也可以为特定的边指定权重(也称为成本),以表示节点之间旅行的成本。当然,这样的权重可以分配给无向边和有向边。如果提供了权重,则边被称为加权边,整个图被称为加权图。同样,如果没有提供权重,则图中使用无权重边。这种图被称为无权重图。
以下图显示了具有无向(左侧)和有向(右侧)边的加权图的示例:

图 8.3 – 加权无向图和加权有向图之间的区别
这种加权边的图形表示显示了边旁边线的权重。例如,在无向图中,从节点1到2以及从节点2到1的旅行成本等于3,如前图所示。在有向图(右侧)的情况下,情况要复杂一些。在这里,你可以以等于9的成本从节点1到2旅行,而相反方向的旅行(从节点2到1)要便宜得多,成本仅为3。
应用
到目前为止,你已经了解了一些关于图的基本信息,特别是关于节点和不同类型的边。然而,为什么图的主题如此重要,为什么它占据了这本书的一整章?你能在你的应用中使用这种数据结构吗?答案很明显:是的!图在解决算法问题时被广泛使用,并且有无数的实际应用。
首先,让我们思考一下社交媒体上可用的朋友结构。每个用户都有许多联系人,但他们也有许多朋友,等等。你应该选择哪种数据结构来存储这样的数据?图是最简单的答案。在这种情况下,节点代表联系人,而边表示人与人之间的关系。例如,让我们看一下以下无向无权重图的示意图:

图 8.4 – 表示朋友结构的图形说明
如你所见,吉米·戈德有五个联系人,即约翰·史密斯、安迪·伍德、埃里克·格林、艾什莉·洛佩兹和保拉·斯科特。同时,保拉·斯科特还有两个其他朋友:马辛·雅姆罗和汤米·巴特勒。通过使用图作为数据结构,你可以轻松地检查两个人是否是朋友,或者他们是否有共同联系人。
图的另一个常见应用涉及寻找最短路径的问题。让我们想象一个程序,它应该找到城市中两点之间的路径,考虑到驾驶特定道路所需的时间。在这种情况下,你可以使用图来表示城市地图,其中节点表示交叉口,边表示道路。当然,你应该给边分配权重,以表示驾驶给定道路所需的时间。寻找最短路径的主题可以理解为找到从源节点到目标节点的边的列表,其总成本最小。以下是基于图的城市场景图:

图 8.5 – 表示城市地图的图的示意图
如你所见,选择了有向加权图。有向边使得可以支持双向和单向道路,而加权边允许你指定在两个交叉口之间旅行所需的时间。
表示法
到目前为止,你已经知道了什么是图以及何时可以使用它,但如何在计算机的内存中表示一个图呢?解决这个问题有两种流行的方法,即使用邻接表和邻接矩阵。
邻接表
第一种方法要求你通过指定其邻居的列表来扩展节点的数据。因此,你只需遍历给定节点的邻接表,就可以轻松地获取给定节点的所有邻居。这种解决方案空间效率高,因为你只存储相邻边的数据。让我们看看下面的图示:

图 8.6 – 表示无向无权图的邻接表
这个示例图包含 8 个节点和 10 条边。对于每个节点,创建了一个包含相邻节点(即邻居)的列表,如图表右侧所示。例如,节点1有两个邻居,即节点2和3,而节点5有四个邻居,即节点4、6、7和8。如你所见,无向无权图的邻接表表示法简单明了,易于使用、理解和实现。
但在有向图中,邻接表是如何工作的呢?答案很明显,因为分配给每个节点的列表只显示了可以从给定节点到达的相邻节点。以下是一个示例图:

图 8.7 – 表示有向无权图的邻接表
让我们看看节点3。在这里,邻接表只包含一个元素——即节点4。节点1没有被包括在内,因为它不能从节点3直接到达。
在加权图的情况下,可能需要更多的解释。在这种情况下,还需要存储特定边的权重。你可以通过扩展邻接表中存储的数据来实现这一点,如下面的图所示:

图 8.8 – 表示有向加权图的邻接表
例如,节点7的邻接表包含两个元素,即关于到节点5(权重等于4)和到节点8(权重等于6)的边。
邻接矩阵
另一种图表示方法涉及邻接矩阵,它使用一个二维数组来显示哪些节点通过边连接。矩阵包含与节点数量相同的行和列。主要思想是在矩阵的特定行和列的元素中存储有关特定边的信息。行和列的索引取决于与边连接的节点。例如,如果你想获取节点索引为1和5之间的边的信息,你必须检查索引设置为1的行和索引设置为5的列中的元素。
这种解决方案为你提供了一个快速检查两个特定节点是否通过边连接的方法。然而,它可能需要你存储比邻接表显著更多的数据,尤其是在节点之间没有许多边的情况下。
首先,让我们分析无向无权图的基本场景。在这种情况下,邻接矩阵可能只存储布尔值。放置在i行j列的true值表示索引等于i的节点与索引设置为j的节点之间存在连接。如果这听起来很复杂,请看下面的图:

图 8.9 – 表示无向无权图的邻接矩阵
在这里,邻接矩阵包含 64 个元素(8 行 8 列),因为图中包含 8 个节点。数组中许多元素被设置为false,用缺失的指示符表示。其余的用交叉标记,表示true值。例如,第四行第三列的这种值表示节点4和3之间存在边,如前面的图所示。
对称邻接矩阵
由于前面的图是无向图,邻接矩阵是对称的。如果节点i和j之间存在边,那么节点j和i之间也存在边。
以下示例涉及一个有向和无权图。在这种情况下,可以使用相同的规则,但邻接矩阵不需要是对称的。让我们看一下图的插图,它与邻接矩阵一起展示:

图 8.10 - 表示有向和无权图的邻接矩阵
在显示的邻接矩阵中,你可以找到 15 条边的数据,这些数据由 15 个具有true值的元素表示,在矩阵中以交叉表示。例如,从节点5到4的单向边在矩阵的第 5 行和第 4 列显示为交叉。
在前两个示例中,你学习了如何使用邻接矩阵表示无权图。然而,你如何存储加权图的数据,无论是无向还是有向的呢?答案是简单的——你只需要将邻接矩阵中特定元素的数据类型从布尔型更改为数值型。因此,你可以指定边的权重,如下面的图所示:

图 8.11 - 表示有向和加权图的邻接矩阵
为了消除任何疑问,让我们看一下节点5和6之间的边,其权重设置为2。这样的边由第 5 行和第 6 列的元素表示。该元素的值等于节点之间旅行的成本。
实现
你已经了解了一些关于图的基本信息,包括节点、边以及两种表示方法,即使用邻接表和矩阵。然而,你如何在应用程序中使用这样的数据结构呢?在本节中,你将学习如何使用 C#语言实现图。为了使你对这一内容更容易理解,将提供两个示例。
节点
首先,让我们看一下表示图中单个节点的通用类代码。这样的类被命名为Node,其代码如下:
public class Node<T>
{
public int Index { get; set; }
public required T Data { get; set; }
public List<Node<T>> Neighbors { get; set; } = [];
public List<int> Weights { get; set; } = [];
public override string ToString() => $"Index: {Index}.
Data: {Data}. Neighbors: {Neighbors.Count}.";
}
该类包含四个属性。由于所有这些元素在本章中显示的代码片段中都扮演着重要的角色,让我们详细分析它们:
-
第一个属性(
Index)存储了图中节点集合中特定节点的索引,以简化访问特定元素的过程。因此,可以通过使用索引轻松地获取Node类的实例。 -
下一个属性名为
Data,它只是在节点中存储一些数据。值得注意的是,这种数据类型与创建泛型类实例时指定的类型一致。 -
Neighbors属性表示特定节点的邻接表。因此,它包含指向表示相邻节点的Node实例的引用。 -
最后一个属性名为
Weights,存储分配给相邻边的权重。在有向图中,Weights列表中的元素数量与邻居的数量(Neighbors)相同。如果图是无向的,Weights列表为空。
除了上述属性外,该类还包含重写的ToString方法,它返回对象的文本表示。在这里,字符串以"Index: [index]. Data: [data]. Neighbors: [count]."格式返回。
边
如同在图论主题的简要介绍中提到的,一个图由节点和边组成。节点由Node类的实例表示,通用的Edge类可以用来表示边。合适的代码部分如下:
public class Edge<T>
{
public required Node<T> From { get; set; }
public required Node<T> To { get; set; }
public int Weight { get; set; }
public override string ToString() => $"{From.Data}
-> {To.Data}. Weight: {Weight}.";
}
该类包含三个属性,即表示与边相邻的节点(From和To),以及边的权重(Weight)。此外,重写了ToString方法,以展示关于边的一些基本信息。
图
下一个类名为Graph,它代表一个完整的图,具有有向或无向边,以及加权或无加权边。实现包括各种属性和方法。这些将在下面详细描述。
让我们看看Graph类的基本版本:
public class Graph<T>
{
public required bool IsDirected { get; init; }
public required bool IsWeighted { get; init; }
public List<Node<T>> Nodes { get; set; } = [];
}
该类包含两个表示边是否为有向(IsDirected)和加权(IsWeighted)的属性。此外,声明了Nodes属性,它存储图中存在的节点列表。
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) { return null; }
Edge<T> edge = new()
{
From = nodeFrom,
To = nodeTo,
Weight = i < nodeFrom.Weights.Count
? nodeFrom.Weights[i] : 0
};
return edge;
}
}
在索引器中,根据索引获取表示两个节点(nodeFrom和nodeTo)的Node类实例。由于你想找到从第一个节点(nodeFrom)到第二个节点(nodeTo)的边,你需要尝试使用IndexOf方法在第一个节点的邻接节点集合中找到第二个节点。如果不存在这样的连接,IndexOf方法返回一个负值,并且索引器返回null。否则,你创建一个Edge类的新实例并设置其属性值,包括From和To。如果提供了特定边的权重数据,Edge类的Weight属性值也会被设置。
到目前为止,你知道如何在图中存储节点的数据,但如何添加一个新节点呢?要做到这一点,你可以实现AddNode方法,如下所示:
public Node<T> AddNode(T value)
{
Node<T> node = new() { Data = value };
Nodes.Add(node);
UpdateIndices();
return node;
}
在此方法中,您创建 Node 类的新实例并根据参数的值设置 Data 属性的值。然后,将新创建的实例添加到 Nodes 集合中,并调用(稍后描述的)UpdateIndices 方法来更新集合中存储的所有节点的索引。最后,返回表示新添加节点的 Node 实例。
您还可以删除现有的节点。此操作通过 RemoveNode 方法执行,如下代码片段所示:
public void RemoveNode(Node<T> nodeToRemove)
{
Nodes.Remove(nodeToRemove);
UpdateIndices();
Nodes.ForEach(n => RemoveEdge(n, nodeToRemove));
}
此方法接受一个参数,即应删除的节点实例。首先,您将其从节点集合中删除。然后,您更新剩余节点的索引。最后,您遍历图中所有节点以删除与已删除节点相连的所有边。
如您所知,图由节点和边组成。因此,Graph 类的实现应向开发者提供一个添加新边的方法。当然,它应支持边的各种变体,无论是有向、无向、加权还是无权。建议的实现方式如下:
public void AddEdge(Node<T> from, Node<T> to, int w = 0)
{
from.Neighbors.Add(to);
if (IsWeighted) { from.Weights.Add(w); }
if (!IsDirected)
{
to.Neighbors.Add(from);
if (IsWeighted) { to.Weights.Add(w); }
}
}
AddEdge 方法接受三个参数,即表示通过边连接的两个节点实例(from 和 to),以及连接的权重(w),默认设置为 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) { return; }
from.Neighbors.RemoveAt(index);
if (IsWeighted) { from.Weights.RemoveAt(index); }
if (!IsDirected)
{
index = to.Neighbors.FindIndex(n => n == from);
if (index < 0) { return; }
to.Neighbors.RemoveAt(index);
if (IsWeighted) { to.Weights.RemoveAt(index); }
}
}
此方法接受两个参数,即两个节点(from 和 to),它们之间有一个应删除的边。首先,您尝试在第一个节点的邻居节点列表中找到第二个节点。如果找到,则将其删除。如果考虑加权图,还应删除权重数据。在无向图的情况下,您会自动删除一个反向节点,即在 to 和 from 节点之间。
最后一个公共方法名为 GetEdges,它使得能够获取图中所有可用边的集合。建议的实现方式如下:
public List<Edge<T>> GetEdges()
{
List<Edge<T>> edges = [];
foreach (Node<T> from in Nodes)
{
for (int i = 0; i < from.Neighbors.Count; i++)
{
int weight = i < from.Weights.Count
? from.Weights[i] : 0;
Edge<T> edge = new()
{
From = from,
To = from.Neighbors[i],
Weight = weight
};
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.12 – 无向无权边示例的说明
如您所见,该图包含 8 个节点和 10 条边。实现从以下行开始,初始化一个新的无向无权图:
Graph<int> graph = new()
{ Node<int> type, as follows:
Node
Node
Node
Node
Node
Node
Node
Node
Finally, you only need to add edges between nodes, as shown in the preceding diagram. The necessary code is as follows:
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);
That’s all! As you can see, configuring a graph is very easy using the proposed implementation of this data structure. Now, let’s proceed to a slightly more complex scenario with directed and weighted edges.
Example – directed and weighted edges
The following example involves a directed and weighted graph, as follows:

Figure 8.13 – Illustration of the directed and weighted edges example
The implementation is similar to the one described previously. However, some modifications are necessary. To start with, different values of the properties are used to indicate that a directed and weighted variant of the edges is being considered:
Graph
{ IsDirected = true, IsWeighted = true };
The part regarding adding nodes is the same as in the previous example:
Node
Node
Node
Node
Node
Node
Node
Node
Some changes are easily visible in the lines of code regarding the addition of edges. Here, you specify directed edges and their weights, as follows:
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);
You’ve just completed the basic implementation of a graph, shown in two examples. So, let’s proceed to another topic, namely traversing a graph.
Traversal
One of the operations that’s commonly performed on a graph is **traversal** – that is, **visiting all of the nodes in some particular order**. Of course, the aforementioned problem can be solved in various ways, such as using **DFS** or **BFS** approaches. It is worth mentioning that the traversal topic is strictly connected with the task of **searching for a given node in** **a graph**.
Depth-first search
The first graph traversal algorithm described in this chapter is named **DFS**. It tries to go as deep as possible. **First, it proceeds to the next levels of the nodes instead of visiting all the neighbors of the current node**. Its steps, in the context of the example graph, are as follows:

Figure 8.14 – Illustration of a DFS of a graph
Of course, it can be a bit difficult to understand how the DFS algorithm operates just by looking at the preceding diagram. For this reason, let’s try to analyze its stages.
In **Step 1**, there’s the graph with 8 nodes. In **Step 2**, node **1** is marked with a gray background (indicating that the node was already visited), as well as with a bolder border (indicating that it is the node that is currently being visited). Moreover, an important role in the algorithm is performed by the neighbor nodes (shown as circles with dashed borders) of the current one. When you know the roles of particular indicators, it is clear that in **Step 2**, node **1** is visited. It has two neighbors, namely nodes **2** and **3**.
Then, the first neighbor (node **2**) is taken into account (**Step 3**) and the same operations are performed – that is, the node is visited and its neighbors (nodes **1** and **4**) are analyzed. As node **1** was visited, it is skipped. In **Step 4**, the first suitable neighbor of node **2** is taken into account, namely node **4**. It has two neighbors, namely node **2** (already visited) and **8**. Next, node **8** is visited (**Step 5**) and, according to the same rules, node **5** (**Step 6**). It has four neighbors, namely nodes **4** (already visited), **6**, **7**, and **8** (already visited). Thus, in **Step 7**, node **6** is taken into account. As it has only one neighbor (node **7**), it is visited next (**Step 8**).
Then, you check the neighbors of node **7**, namely nodes **5** and **8**. Both were already visited, so you return to the node with an unvisited neighbor. In this example, node **1** has one unvisited node, namely node **3**. When it is visited (**Step 9**), all nodes are traversed and no further operations are necessary.
Given this example, let’s try to create the implementation in the C# language. To start, the code of the public `DFS` method (in the `Graph` class) is presented as follows:
public List<Node
{
bool[] isVisited = new bool[Nodes.Count];
List<Node
DFS(isVisited, Nodes[0], result);
return result;
}
The important role is performed by the `isVisited` array. It has the same number of elements as the number of nodes and stores values indicating whether a given node has already been visited. If so, the `true` value is stored. Otherwise, `false` is stored. The list of traversed nodes is represented as a list in the `result` variable. What’s more, another variant of the `DFS` method is called here, passing three parameters:
* A reference to the `isVisited` array
* The first node to analyze
* The list for storing results
The code for the aforementioned variant of the `DFS` method is as follows:
private void DFS(bool[] isVisited, Node
List<Node
{
result.Add(node);
isVisited[node.Index] = true;
foreach (Node
{
if (!isVisited[neighbor.Index])
{
DFS(isVisited, neighbor, result);
}
}
}
First, the current node is added to the collection of traversed nodes, and the element in the `isVisited` array is updated. Then, you use the `foreach` loop to iterate through all the neighbors of the current node. For each of them, if they haven’t already been visited, the `DFS` method is called recursively.
To finish, let’s take a look at the code that can be placed in the `Program.cs` file. Its main parts are presented in the following code snippet:
Graph
{ IsDirected = true, IsWeighted = true };
Node
Node
graph.AddEdge(n1, n2, 9); (...)
graph.AddEdge(n8, n5, 3);
List<Node
nodes.ForEach(Console.WriteLine);
Here, you initialize a directed and weighted graph. It is worth noting that the missing lines of code (indicated by three dots) are the same as in the example where you created a graph with directed and weighted edges.
To start traversing the graph, you just need to call the `DFS` method, which returns a list of `Node` instances. Then, you can easily iterate through elements of the list to print some basic information about each node in the console:
索引:0。数据:1。邻居:2。
索引:1。数据:2。邻居:2。
索引:3。数据:4。邻居:2。
索引:7。数据:8。邻居:1。
索引:4。数据:5。邻居:4。
索引:5。数据:6。邻居:1。
索引:6。数据:7。邻居:2。
索引:2。数据:3。邻居:1。
That’s all! As you can see, the algorithm tries to go as deep as possible and then goes back to find the next unvisited neighbor that can be traversed.
However, this algorithm is not the only approach to the problem of graph traversal. We’ll cover another method and its implementation in the next section.
Breadth-first search
In the previous section, you learned about the DFS approach. Now, you will see another solution, namely **BFS**. Its main aim is to **visit all the neighbors of the current node and then proceed to the next level** **of nodes**.
If the previous description sounds a bit complicated, take a look at this diagram, which depicts the steps of the BFS algorithm:

Figure 8.15 – Illustration of a BFS of a graph
The algorithm starts by visiting node **1** (**Step 2**). It has two neighbors, namely nodes **2** and **3**, which are visited next (**Step 3** and **Step 4**). As node **1** does not have more neighbors, the neighbors of its first neighbor (node **2**) are considered. As it has only one unvisited neighbor (node **4**), it is visited in the next step. According to the same method, the remaining nodes are visited in this order: **8**, **5**, **6**, **7**.
It sounds very simple, doesn’t it? Let’s take a look at the implementation:
public List<Node
private List<Node<T>> BFS(Node<T> node)
{
bool[] isVisited = new bool[Nodes.Count];
isVisited[node.Index] = true;
List<Node<T>> result = [];
Queue<Node<T>> queue = [];
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循环的下一个迭代中进行分析。
最后,返回已访问节点的列表。如果你想测试这个算法,可以使用以下代码:
Graph<int> graph = new()
{ IsDirected = true, IsWeighted = 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>> nodes = graph.BFS();
nodes.ForEach(Console.WriteLine);
前面的代码调用BFS公共方法根据 BFS 算法遍历图。最后一行负责遍历结果,以在控制台显示节点的数据,如下所示:
Index: 0\. Data: 1\. Neighbors: 2.
Index: 1\. Data: 2\. Neighbors: 2.
Index: 2\. Data: 3\. Neighbors: 1.
Index: 3\. Data: 4\. Neighbors: 2.
Index: 7\. Data: 8\. Neighbors: 1.
Index: 4\. Data: 5\. Neighbors: 4.
Index: 5\. Data: 6\. Neighbors: 1.
Index: 6\. Data: 7\. Neighbors: 2.
你刚刚学习了两种图遍历算法,即 DFS 和 BFS。为了使你对这类主题的理解更加容易,本章包含了详细的描述、插图和示例。现在,让我们继续探讨另一个重要主题,即最小生成树,它在现实世界中有很多应用。
你在哪里可以找到更多信息?
关于图的遍历,有很多在线资源。你可以在en.wikipedia.org/wiki/Depth-first_search了解更多关于 DFS 的信息,同时你可以在www.geeksforgeeks.org/breadth-first-traversal-for-a-graph/找到更多关于 BFS 算法及其实现的信息。
最小生成树
当谈论图时,介绍生成树的主题是有益的。那是什么?生成树是图的边的子集,它连接图中的所有节点而不形成环。当然,同一个图中可以有多个生成树。例如,让我们看看以下图表:

图 8.16 – 图中生成树的示意图
在左侧是一个包含以下边的生成树:(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)。生成树是“最小”的意思是什么?答案是真的很简单:它是在图中所有可能的生成树中具有最小成本的生成树。你可以通过将边(6, 7)替换为(5, 7)来得到 MST。然后,成本等于 30。还值得一提的是,生成树中的边数等于节点数减一。
为什么 MST(最小生成树)这个主题如此重要?让我们想象一个场景,你需要将许多建筑连接到电信电缆。当然,有各种可能的连接方式,比如从一个建筑到另一个建筑,或者使用一个中心节点。更重要的是,环境条件可能会由于需要穿越道路甚至河流而严重影响投资成本。你的任务是成功地将所有建筑以最低的成本连接到电信电缆。你应该如何设计这些连接?要回答这个问题,你只需要创建一个图,其中节点代表连接器,边表示可能的连接。然后,你找到 MST,这就完成了!
你想要一些例子吗?
在本节末尾关于 MST 的例子中,展示了将许多建筑连接到电信电缆的问题。
接下来的问题是如何找到最小生成树(MST)。解决这个问题有各种方法,包括应用克鲁斯卡尔或普里姆算法。这些方法将在以下章节中介绍和解释。
克鲁斯卡尔算法
用于找到 MST 的一种算法是由克鲁斯卡尔发现的。它的操作非常简单易懂。算法从剩余的边中选择具有最小权重的边并将其添加到 MST 中,但只有当添加它不会创建环时。算法在所有节点都连接时停止。
让我们看看一个展示使用克鲁斯卡尔算法找到 MST 步骤的图表:

图 8.17 – 克鲁斯卡尔算法的示意图
在步骤 1中,选择边(5,8),因为它具有最小的权重,即1。然后,在步骤 2中选择边(1,2),在步骤 3中选择边(2,4),在步骤 4中选择边(5,6),在步骤 5中选择边(1,3),以及在步骤 6中选择边(5,7)和(4,8)。值得注意的是,在取(4,8)边之前,考虑了(6,7),因为它的权重更低(6 而不是 8)。然而,将其添加到 MST 中将会引入由(5,6),(6,7)和(5,7)边形成的环。因此,这样的边被忽略,算法选择了(4,8)。最后,MST 中的边数为 7。节点数为 8,这意味着算法可以停止运行,MST 已经找到。
让我们看看它的实现。这涉及到MSTKruskal方法,该方法应添加到Graph类中。提出的代码如下:
public List<Edge<T>> MSTKruskal()
{
List<Edge<T>> edges = GetEdges();
edges.Sort((a, b) => a.Weight.CompareTo(b.Weight));
Queue<Edge<T>> queue = new(edges);
Subset<T>[] subsets = new Subset<T>[Nodes.Count];
for (int i = 0; i < Nodes.Count; i++)
{
subsets[i] = new() { Parent = Nodes[i] };
}
List<Edge<T>> result = [];
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) { continue; }
result.Add(edge);
Union(subsets, from, to);
}
return result;
}
此方法不接受任何参数。首先,通过调用GetEdges方法获取边列表。然后,根据权重将边按升序排序。这一步至关重要,因为在算法的后续迭代中,你需要获取具有最小成本的边。在下一行,创建了一个新的队列,并使用Queue类的构造函数将Edge实例入队。
在下一块代码中,创建了一个包含子集数据的数组。默认情况下,每个节点被添加到单独的子集中。这就是为什么subsets数组中的元素数量等于节点数量的原因。子集用于检查将边添加到 MST 中是否会导致创建环。
然后,创建用于存储 MST 边的列表(result)。代码中最有趣的部分是 while 循环,它迭代直到在 MST 中找到正确数量的边。在这个循环中,你只需通过在 Queue 实例上调用 Dequeue 方法,就可以得到具有最小权重的边。然后,你可以检查通过将找到的边添加到 MST 中是否引入了任何循环。在这种情况下,边被添加到目标列表中,并调用 Union 方法来合并两个子集。
在分析前面的方法时,提到了 GetRoot 方法。它的目的是更新子集的父节点,并返回子集的根节点,如下所示:
private Node<T> GetRoot(Subset<T>[] subsets, Node<T> node)
{
int i = node.Index;
ss[i].Parent = ss[i].Parent != node
? GetRoot(ss, ss[i].Parent) : ss[i].Parent;
return ss[i].Parent;
}
最后一个私有方法命名为 Union,它执行两个集合的 union 操作(通过秩)。它接受三个参数,即 Subset 实例的数组以及两个 Node 实例,代表要执行 union 操作的子集的根节点。代码的合适部分如下:
private void Union(Subset<T>[] ss, Node<T> a, Node<T> b)
{
ss[b.Index].Parent =
ss[a.Index].Rank >= ss[b.Index].Rank
? a : ss[b.Index].Parent;
ss[a.Index].Parent =
ss[a.Index].Rank < ss[b.Index].Rank
? b : ss[a.Index].Parent;
if (ss[a.Index].Rank == ss[b.Index].Rank)
{
ss[a.Index].Rank++;
}
}
在前面的代码片段中,你可以看到 Subset 类,但它是什么样子呢?让我们看看它的声明:
public class Subset<T>
{
public required Node<T> Parent { get; set; }
public int Rank { get; set; }
public override string ToString() => $"Rank: {Rank}.
Parent: {Parent.Data}. Index: {Parent.Index}.";
}
该类包含表示父节点的属性(Parent),以及子集的秩(Rank)。该类还包含重写的 ToString 方法,它以文本形式呈现有关子集的一些基本信息。
你在哪里可以找到更多信息?
你知道所提出的方法代表了一种 贪心算法 吗?这里显示的代码基于在 www.geeksforgeeks.org/greedy-algorithms-set-2-kruskals-minimum-spanning-tree-mst/ 可用的实现。你可以在那里找到关于 Kruskal 算法以及关于许多其他图算法的大量有趣信息,例如关于着色的一种简单方法,这也是本章等待你的一个主题。GeeksForGeeks 是一个关于各种算法的极好资源,拥有大量内容,我强烈推荐!
让我们看看 MSTKruskal 方法的用法:
Graph<int> graph = new()
{ IsDirected = false, IsWeighted = 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, 3);
graph.AddEdge(n1, n3, 5);
graph.AddEdge(n2, n4, 4);
graph.AddEdge(n3, n4, 12);
graph.AddEdge(n4, n5, 9);
graph.AddEdge(n4, n8, 8);
graph.AddEdge(n5, n6, 4);
graph.AddEdge(n5, n7, 5);
graph.AddEdge(n5, n8, 1);
graph.AddEdge(n6, n7, 6);
graph.AddEdge(n7, n8, 20);
List<Edge<int>> edges = graph.MSTKruskal();
edges.ForEach(Console.WriteLine);
首先,你初始化一个无向加权图,并添加节点和边。然后,你调用 MSTKruskal 方法使用 Kruskal 算法找到 MST。最后,你使用 ForEach 方法将 MST 中每条边的数据写入控制台。示例输出如下:
8 -> 5\. Weight: 1.
1 -> 2\. Weight: 3.
2 -> 4\. Weight: 4.
5 -> 6\. Weight: 4.
1 -> 3\. Weight: 5.
7 -> 5\. Weight: 5.
8 -> 4\. Weight: 8.
如前所述,你将在本章学习两种寻找 MST 的算法。现在,是时候看看第二种算法了,即 Prim 算法。
Prim 算法
解决寻找 MST 问题的另一种方法是Prim 算法。它使用两组不相交的节点集,即位于 MST 中的节点和尚未放置在那里的节点。在后续迭代中,算法找到连接第一组中的一个节点和第二组中的一个节点的最小权重的边。该边上的节点,如果它尚未在 MST 中,则被添加到该集合中。
上述描述听起来相当简单,不是吗?让我们通过分析展示使用 Prim 算法寻找 MST 步骤的图表来实际看看:

图 8.18 – Prim 算法的示意图
让我们看看在图中节点旁边添加的附加指标。它们表示从任何邻居到达此类节点的最小权重。默认情况下,起始节点的此值设置为0,而所有其他节点都设置为无穷大,如步骤 1所示。
在步骤 2中,起始节点被添加到形成最小生成树(MST)的节点子集中,并更新其邻居的距离,即到达节点3的距离为5,到达节点2的距离为3。其他节点的值仍然设置为无穷大。
在步骤 3中,选择具有最小成本的节点。在这种情况下,选择节点2,因为其成本等于3。其竞争对手(即节点3)的成本等于5。接下来,你需要更新到达当前节点(即节点4)邻居的成本,成本设置为4。
下一个选择的节点是节点4,因为它不在 MST 集合中,并且具有最低的到达成本(步骤 4)。同样,你将按照以下顺序选择下一个边:步骤 5中的(1,3),步骤 6中的(4,8),步骤 7中的(8,5),步骤 8中的(5,6),以及步骤 9中的(5,7)。现在,所有节点都包含在 MST 中,算法可以停止其操作。
给出了算法步骤的详细描述后,让我们继续进行基于 C#的实现。大多数操作都在MSTPrim方法中执行,该方法应添加到Graph类中:
public List<Edge<T>> MSTPrim()
{
int[] previous = new int[Nodes.Count];
previous[0] = -1;
int[] minWeight = new int[Nodes.Count];
Array.Fill(minWeight, int.MaxValue);
minWeight[0] = 0;
bool[] isInMST = new bool[Nodes.Count];
Array.Fill(isInMST, false);
for (int i = 0; i < Nodes.Count - 1; i++)
{
int mwi = GetMinWeightIndex(minWeight, isInMST);
isInMST[mwi] = true;
for (int j = 0; j < Nodes.Count; j++)
{
Edge<T>? edge = this[mwi, j];
int weight = edge != null ? edge.Weight : -1;
if (edge != null
&& !isInMST[j]
&& weight < minWeight[j])
{
previous[j] = mwi;
minWeight[j] = weight;
}
}
}
List<Edge<T>> result = [];
for (int i = 1; i < Nodes.Count; i++)
{
result.Add(this[previous[i], i]!);
}
return result;
}
MSTPrim方法不接收任何参数。它使用三个辅助节点相关数组,为图中的节点分配额外的数据:
-
第一个,即
previous,存储从给定节点可以到达的前一个节点的索引。默认情况下,所有元素的值都等于0,除了第一个,其设置为-1。 -
minWeight数组存储访问给定节点的边的最小权重。默认情况下,所有元素都设置为int类型的最大值,而第一个元素的值被设置为0。 -
isInMST数组表示给定的节点是否已经在 MST 中。一开始,所有元素的值应该设置为false。
代码中最有趣的部分位于for循环中。在其中,你会找到从不在 MST 中的节点集中,以最小成本可达的节点的索引。这个任务由GetMinWeightIndex方法执行。然后,另一个for循环被使用。在其中,你得到一个连接具有mwi索引(代表最小权重索引)和j的节点的边。你检查节点是否不在 MST 中,以及到达该节点的成本是否小于之前的最低成本。如果是这样,previous和minWeight数组中与节点相关的元素值将被更新。
代码的其余部分只是准备最终结果。在这里,你创建了一个包含形成 MST 的边的数据的新列表实例。for循环用于获取以下边的数据并将它们添加到result列表中。
在分析代码时,提到了GetMinWeightIndex私有方法。其代码如下所示:
private int GetMinWeightIndex(
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;
}
GetMinWeightIndex方法只是找到一个节点索引,该节点不在 MST 中,并且可以以最小成本到达。为此,你使用for循环遍历所有节点。对于每个节点,你检查当前节点是否不在 MST 中,以及到达它的成本是否小于已存储的最低值。如果是这样,minValue和minIndex变量的值将被更新。最后,返回索引。
你在哪里可以找到更多信息?
与克鲁斯卡尔算法类似,普里姆算法的变体也是贪婪算法的一个代表。我强烈建议你在书籍、研究论文和互联网上搜索更多关于这个算法的有趣信息。值得注意的是,所提供的代码基于www.geeksforgeeks.org/greedy-algorithms-set-5-prims-minimum-spanning-tree-mst-2/中展示的实现。
让我们看看MSTPrim方法的用法:
Graph<int> graph = new()
{ IsDirected = false, IsWeighted = 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>> edges = graph.MSTPrim();
edges.ForEach(Console.WriteLine);
代码中缺失的部分与关于克鲁斯卡尔算法的示例代码相同。当你运行代码时,你会得到以下结果:
1 -> 2\. Weight: 3.
1 -> 3\. Weight: 5.
2 -> 4\. Weight: 4.
8 -> 5\. Weight: 1.
5 -> 6\. Weight: 4.
5 -> 7\. Weight: 5.
4 -> 8\. Weight: 8.
现在我们已经了解了多种寻找最小生成树(MST)的算法,让我们来看一个例子。
示例 - 电信电缆
如同在 MST 主题介绍中提到的,这个问题有一些重要的实际应用,例如制定建筑之间连接的计划,以最低的成本为所有建筑提供电信电缆。当然,有各种可能的连接方式,例如从一个建筑到另一个建筑或使用中心节点。更重要的是,环境条件可能会由于需要穿越道路甚至河流而严重影响投资成本。
例如,让我们创建一个程序,在一系列建筑的环境中解决此问题,如图所示:

图 8.19 – 电信电缆示例示意图
如你所见,住宅社区由六个建筑组成。这些建筑位于一条小河的两侧,只有一座桥。此外,还有两条道路。当然,不同点之间的连接成本不同,这取决于距离和环境条件。例如,两个建筑(B1和B2)之间的直接连接成本等于2,而使用桥梁(在R1和R5之间)的成本等于75。如果你需要在没有桥梁的情况下穿越河流(在R3和R6之间),成本甚至更高,等于100。
你的任务是找到 MST。在这个示例中,你将应用 Kruskal 算法和 Prim 算法来解决这个问题。首先,让我们初始化无向加权图,并添加节点和边,如下所示:
Graph<string> graph = new()
{ IsDirected = false, IsWeighted = true };
Node<string> nodeB1 = graph.AddNode("B1");
Node<string> nodeB2 = graph.AddNode("B2");
Node<string> nodeB3 = graph.AddNode("B3");
Node<string> nodeB4 = graph.AddNode("B4");
Node<string> nodeB5 = graph.AddNode("B5");
Node<string> nodeB6 = graph.AddNode("B6");
Node<string> nodeR1 = graph.AddNode("R1");
Node<string> nodeR2 = graph.AddNode("R2");
Node<string> nodeR3 = graph.AddNode("R3");
Node<string> nodeR4 = graph.AddNode("R4");
Node<string> nodeR5 = graph.AddNode("R5");
Node<string> nodeR6 = graph.AddNode("R6");
graph.AddEdge(nodeB1, nodeB2, 2);
graph.AddEdge(nodeB1, nodeB3, 20);
graph.AddEdge(nodeB1, nodeB4, 30);
graph.AddEdge(nodeB2, nodeB3, 30);
graph.AddEdge(nodeB2, nodeB4, 20);
graph.AddEdge(nodeB2, nodeR2, 25);
graph.AddEdge(nodeB3, nodeB4, 2);
graph.AddEdge(nodeB4, nodeR4, 25);
graph.AddEdge(nodeR1, nodeR2, 1);
graph.AddEdge(nodeR2, nodeR3, 1);
graph.AddEdge(nodeR3, nodeR4, 1);
graph.AddEdge(nodeR1, nodeR5, 75);
graph.AddEdge(nodeR3, nodeR6, 100);
graph.AddEdge(nodeR5, nodeR6, 3);
graph.AddEdge(nodeR6, nodeB5, 3);
graph.AddEdge(nodeR6, nodeB6, 10);
graph.AddEdge(nodeB5, nodeB6, 6);
现在,你只需要调用MSTKruskal方法来使用 Kruskal 算法找到 MST。当获得结果时,你可以轻松地在控制台展示它们,包括总成本。合适的代码部分如下所示:
Console.WriteLine("Minimum Spanning Tree - Kruskal:");
List<Edge<string>> kruskal = graph.MSTKruskal();
kruskal.ForEach(Console.WriteLine);
Console.WriteLine("Cost: " + kruskal.Sum(e => e.Weight));
控制台显示的结果如下:
Minimum Spanning Tree - Kruskal:
R4 -> R3\. Weight: 1.
R3 -> R2\. Weight: 1.
R2 -> R1\. Weight: 1.
B1 -> B2\. Weight: 2.
B3 -> B4\. Weight: 2.
R6 -> R5\. Weight: 3.
R6 -> B5\. Weight: 3.
B6 -> B5\. Weight: 6.
B1 -> B3\. Weight: 20.
R2 -> B2\. Weight: 25.
R1 -> R5\. Weight: 75.
Cost: 139
如果你将此类结果可视化在地图上,你会发现以下 MST:

图 8.20 – 电信电缆示例的结果示意图
同样,你可以应用 Prim 算法:
Console.WriteLine("\nMinimum Spanning Tree - Prim:");
List<Edge<string>> prim = graph.MSTPrim();
prim.ForEach(Console.WriteLine);
Console.WriteLine("Cost: " + prim.Sum(e => e.Weight));
结果如下:
Minimum Spanning Tree - Prim:
B1 -> B2\. Weight: 2.
B1 -> B3\. Weight: 20.
B3 -> B4\. Weight: 2.
R6 -> B5\. Weight: 3.
B5 -> B6\. Weight: 6.
R2 -> R1\. Weight: 1.
B2 -> R2\. Weight: 25.
R2 -> R3\. Weight: 1.
R3 -> R4\. Weight: 1.
R1 -> R5\. Weight: 75.
R5 -> R6\. Weight: 3.
Cost: 139
你刚刚完成了一个与 MST 实际应用相关的示例。你准备好继续学习另一个被称为着色的图相关主题了吗?
上色
寻找最小生成树(MST)的问题并不是唯一的图相关问题。其中之一是节点着色。它的目的是将颜色(数字)分配给所有节点,以符合规则,即不能存在两个具有相同颜色的节点之间的边。当然,颜色数量应尽可能少。此类问题有一些实际应用,例如绘制地图。本章中展示的着色算法实现相当简单,在某些情况下可能需要比必要的更多颜色。
四色定理
你知道每个平面图的节点可以用不超过四种颜色着色吗?如果你对这个主题感兴趣,请查看 四色定理 (mathworld.wolfram.com/Four-ColorTheorem.html)。由于我在谈论平面图,你应该理解它是一个在平面上绘制时边不会交叉的图。
让我们看看以下图表:

图 8.21 – 图着色的示意图
左侧的插图展示了一个使用四种颜色着色的图:红色(索引等于 0)、绿色(1)、蓝色(2)和黄色(3)。正如你所见,没有节点通过边连接具有相同的颜色。右侧展示的图显示了具有两个额外边(2,6)和(2,5)的图。在这种情况下,着色已改变,但颜色的数量保持不变。
问题在于,如何为节点找到颜色以满足上述规则?幸运的是,算法非常简单,其实现如下。以下是 Color 方法的代码,它应该添加到 Graph 类中:
public int[] Color()
{
int[] colors = new int[Nodes.Count];
Array.Fill(colors, -1);
colors[0] = 0;
bool[] available = new bool[Nodes.Count];
for (int i = 1; i < Nodes.Count; i++)
{
Array.Fill(available, true);
foreach (Node<T> neighbor in Nodes[i].Neighbors)
{
int ci = colors[neighbor.Index];
if (ci >= 0) { available[ci] = false; }
}
colors[i] = Array.IndexOf(available, true);
}
return colors;
}
Color 方法使用两个与节点相关的辅助数组。第一个名为 colors,存储为特定节点选择的颜色索引。默认情况下,所有元素值设置为 -1,除了第一个,它被设置为 0。这意味着第一个节点的颜色将自动设置为第一种颜色(例如,红色)。另一个辅助数组(available)存储有关特定颜色可用性的信息。
代码中最关键的部分是 for 循环。在这个循环中,你通过将 available 数组中所有元素的值设置为 true 来重置颜色的可用性。然后,你遍历当前节点的邻居节点,读取它们的颜色,并通过将 available 数组中特定元素的值设置为 false 来标记这些颜色为不可用。然后,你找到当前节点的第一个可用颜色并使用它。
让我们看看 Color 方法的用法:
Graph<int> graph = new()
{ IsDirected = false, IsWeighted = false };
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(n4, n8);
graph.AddEdge(n5, n6);
graph.AddEdge(n5, n7);
graph.AddEdge(n5, n8);
graph.AddEdge(n6, n7);
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
通过这个简短的介绍,你就可以继续进行实际应用了,即着色省份地图。
示例 – 省份地图
让我们创建一个程序,将波兰的省地图表示为图,并着色这些区域,使得相邻省份的颜色不同。当然,你应该尽量限制颜色的数量。
首先,让我们思考一下图的表示。在这里,节点代表特定的地区,而边代表地区之间的共同边界。
下图显示了已经着色的波兰地图:

图 8.22 – 地区地图示例的说明
你的任务只是使用之前描述的算法在图中着色节点。为此,你创建一个无向无权图,添加代表地区的节点,并添加边来表示共同边界。代码如下:
Graph<string> graph = new()
{ IsDirected = false, IsWeighted = false };
List<string> borders =
[
"PK:LU|SK|MA",
"LU:PK|SK|MZ|PD",
"SK:PK|MA|SL|LD|MZ|LU",
"MA:PK|SK|SL",
"SL:MA|SK|LD|OP",
"LD:SL|SK|MZ|KP|WP|OP",
"WP:LD|KP|PM|ZP|LB|DS|OP",
"OP:SL|LD|WP|DS",
"MZ:LU|SK|LD|KP|WM|PD",
"PD:LU|MZ|WM",
"WM:PD|MZ|KP|PM",
"KP:MZ|LD|WP|PM|WM",
"PM:WM|KP|WP|ZP",
"ZP:PM|WP|LB",
"LB:ZP|WP|DS",
"DS:LB|WP|OP"
];
Dictionary<string, Node<string>> nodes = [];
foreach (string border in borders)
{
string[] parts = border.Split(':');
string name = parts[0];
nodes[name] = graph.AddNode(name);
}
foreach (string border in borders)
{
string[] parts = border.Split(':');
string name = parts[0];
string[] vicinities = parts[1].Split('|');
foreach (string vicinity in vicinities)
{
Node<string> from = nodes[name];
Node<string> to = nodes[vicinity];
if (!from.Neighbors.Contains(to))
{
graph.AddEdge(from, to);
}
}
}
然后,在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
SK: 2
MA: 1
SL: 0
LD: 1
WP: 0
OP: 2
MZ: 0
PD: 2
WM: 1
KP: 2
PM: 3
ZP: 1
LB: 2
DS: 1
你刚刚学习了如何在图中着色节点!然而,这并不是本书中关于图的有意思话题的结束。接下来,我们将搜索图中的最短路径。
最短路径
图是一种存储各种地图数据(如城市及其之间的距离)的绝佳数据结构。因此,图的一个明显的实际应用是在两个节点之间搜索 最短路径 ,考虑到特定的成本,例如距离、所需时间,甚至所需的燃油量。
在图中搜索最短路径的方法有很多。然而,一个常见的解决方案是Dijkstra 算法,它使得计算从起始节点到图中所有节点的距离成为可能。然后,你可以轻松地得到两个节点之间的连接成本,还可以找到位于起始节点和结束节点之间的节点。
Dijkstra 算法使用两个与节点相关的辅助数组:
-
一个用于存储前一个节点的标识符,即当前节点可以通过最小的总成本到达的节点
-
一个用于存储最小距离(成本),这对于访问当前节点是必要的
更重要的是,它使用队列来存储应该检查的节点。在连续迭代过程中,算法更新图中特定节点的最小距离。最后,辅助数组包含从所选起始节点到达所有节点的最小距离(成本),以及如何使用最短路径到达每个节点的信息。
你在哪里可以找到更多信息?
你可以在互联网上找到大量关于 Dijkstra 算法的内容。只需搜索其名称,你将看到大量的结果。例如,你可以在本章中找到有关实现的有用内容,链接为en.wikipedia.org/wiki/Dijkstra%27s_algorithm。
让我们看看以下图表,它展示了使用 Dijkstra 算法找到的两个不同最短路径。左侧显示从节点8到1的路径,而右侧显示从节点1到7的路径:

图 8.23 – 图中路径的最短路径示意图
是时候看看一些可以用来实现 Dijkstra 算法的 C#代码了。GetShortestPath方法扮演主要角色,应该添加到Graph类中。代码如下:
using Priority_Queue; (...)
public List<Edge<T>> GetShortestPath(
Node<T> source, Node<T> target)
{
int[] previous = new int[Nodes.Count];
Array.Fill(previous, -1);
int[] distances = new int[Nodes.Count];
Array.Fill(distances, int.MaxValue);
distances[source.Index] = 0;
SimplePriorityQueue<Node<T>> nodes = new();
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 wTotal = distances[node.Index] + weight;
if (distances[neighbor.Index] > wTotal)
{
distances[neighbor.Index] = wTotal;
previous[neighbor.Index] = node.Index;
nodes.UpdatePriority(neighbor,
distances[neighbor.Index]);
}
}
}
List<int> indices = [];
int index = target.Index;
while (index >= 0)
{
indices.Add(index);
index = previous[index];
}
indices.Reverse();
List<Edge<T>> result = [];
for (int i = 0; i < indices.Count - 1; i++)
{
Edge<T> edge = this[indices[i], indices[i + 1]]!;
result.Add(edge);
}
return result;
}
GetShortestPath方法接受两个参数,即source和target节点。首先,它创建两个与节点相关的辅助数组,用于存储从给定节点可以到达的前一个节点的索引,这些索引具有最小的整体成本(previous),以及用于存储到给定节点的当前最小距离(distances)。默认情况下,previous数组中所有元素的值设置为-1,而在distances数组中,它们设置为int类型的最大值。当然,源节点的距离设置为0。
然后,你创建一个新的优先队列并将所有节点的数据入队。每个元素优先级等于到该节点的当前距离。在这里,你使用与第五章中介绍的相同的优先队列实现,即来自OptimizedPriorityQueue NuGet 包。
代码中最有趣的部分是while循环,它一直执行到队列变为空。在这个while循环中,你从队列中获取第一个节点,并使用for循环遍历其所有邻居。在这样一个循环中,你通过将当前节点的距离和边的权重相加来计算到邻居的距离。如果计算出的距离小于当前存储的值,你将更新有关给定邻居的最小距离以及可以到达邻居的前一个节点索引的值。值得注意的是,队列中元素的优先级也应该更新。
剩余的操作用于使用存储在previous数组中的值来解析路径。为此,你将后续节点的索引保存到indices列表中。然后,你将其反转以实现从源节点到目标节点的顺序。最后,你创建一个边列表,以便以适合从方法返回的形式呈现结果。
让我们看看GetShortestPath方法的用法:
Graph<int> graph = new()
{ IsDirected = true, IsWeighted = 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.GetShortestPath(n1, n5);
path.ForEach(Console.WriteLine);
在这里,你创建一个新的有向加权图,并添加节点和边。代码中缺失的部分与有向加权边示例中的相同。然后,你调用GetShortestPath方法来搜索节点1和5之间的最短路径。结果,你将收到形成最短路径的边的列表。然后,你只需遍历所有边,并在控制台显示结果:
1 -> 3\. Weight: 5.
3 -> 4\. Weight: 12.
4 -> 8\. Weight: 8.
8 -> 5\. Weight: 3.
在这个简短的介绍和简单示例的基础上,让我们继续探讨与游戏开发相关的高级和有趣的应用。
示例 - 游戏中的路径
本章我们将涵盖的最后一个示例涉及将 Dijkstra 算法应用于在游戏地图中找到最短路径。让我们想象你有一个带有各种障碍物的棋盘。因此,玩家只能使用棋盘的一部分来移动。你的任务是找到棋盘上两个位置之间的最短路径。
首先,让我们将棋盘表示为一个交错数组。合适的代码部分如下所示:
using System.Text;
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()
{ IsDirected = false, IsWeighted = true };
for (int i = 0; i < map.Length; i++)
{
for (int j = 0; j < map[i].Length; j++)
{
if (!map[i][j]) { continue; }
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。然后,你调用GetShortestPath方法。在这种情况下,算法将尝试找到第一行第一列的节点和最后一行最后一列的节点之间的最短路径。代码如下所示:
Node<string> s = graph.Nodes.Find(n => n.Data == "0-0")!;
Node<string> t = graph.Nodes.Find(n => n.Data == "16-24")!;
List<Edge<string>> path = graph.GetShortestPath(s, t);
代码的最后部分与在控制台显示地图有关:
Console.OutputEncoding = Encoding.UTF8;
for (int r = 0; r < map.Length; r++)
{
for (int c = 0; c < map[r].Length; c++)
{
bool isPath = path.Any(e =>
e.From.Data == $"{r}-{c}"
|| e.To.Data == $"{r}-{c}");
Console.ForegroundColor = isPath
? ConsoleColor.White
: map[r][c]
? ConsoleColor.Green
: ConsoleColor.Red;
Console.Write("\u25cf ");
}
Console.WriteLine();
}
Console.ResetColor();
首先,你需要在控制台中设置适当的编码,以便能够以 Unicode 字符的形式显示。然后,你使用两个for循环遍历棋盘上的所有位置。在这些循环内部,你选择一个颜色来表示控制台中的点,绿色(点可用)或红色(不可用)。如果当前分析的点是最短路径的一部分,则设置白色。最后,你写入表示点的 Unicode 字符。当程序执行退出这两个循环时,控制台的颜色被重置。
当你运行应用程序时,你会看到以下结果:

图 8.24 – 游戏地图示例的截图
干得好!现在,让我们总结本章中涵盖的主题。
摘要
本章与开发应用程序时可用的重要数据结构之一相关:图。正如你所学的,图是一种由节点和边组成的数据结构。每条边连接两个节点。更重要的是,还有各种边的变体,如无向和有向,以及无权和加权。所有这些都被详细描述和解释,并提供了插图和代码示例。还解释了两种图表示方法,即使用邻接表和邻接矩阵。你还学习了如何在 C#语言中实现图。
在讨论图时,展示一些实际应用是很重要的,尤其是在这种数据结构被广泛使用的情况下。例如,本章解释了社交媒体上可用的朋友结构或在城市中搜索最短路径的问题。
在本章涵盖的主题中,你学习了如何遍历图以以某种特定顺序访问所有节点。介绍了两种方法,即深度优先搜索(DFS)和广度优先搜索(BFS)。值得一提的是,遍历主题也可以应用于在图中搜索给定节点。
接下来,介绍了生成树的主题,以及最小生成树。作为提醒,生成树是图中所有节点之间无环连接的边的子集,而最小生成树(MST)是从图中所有生成树中选择成本最低的生成树。寻找 MST 的方法有几种,包括克鲁斯卡尔算法和普里姆算法。
然后,你学习了如何解决着色问题,其中你为所有节点分配颜色(数字),以符合规则:两个节点之间不能有相同颜色的边。
另一个问题是在两个节点之间寻找最短路径,这需要考虑特定的成本,例如距离、所需时间,甚至所需的燃油量。在图论中,寻找最短路径有几种不同的方法。然而,其中一种常见的解决方案是迪杰斯特拉算法,它使得从起始节点到图中所有节点的距离计算成为可能。我们在这章中对此进行了详细的介绍。
现在,是时候进入下一章了,这一章将重点介绍来自各种组别,包括递归、贪婪、回溯,甚至遗传算法的算法的实际应用方面。让我们翻到下一页,看看它们是如何发挥作用的!
第九章:看看实际应用
如你所知,算法几乎无处不在,并且有众多类型和分类。它们由众多数据结构支持,其中一些你在阅读前几章时已经学过。在理论部分之后,现在是时候基于有趣的例子继续实践了。这些例子来自各种类型的算法,总结了你已经了解的许多主题。
首先,你将看到如何通过几种在性能结果上有显著差异的变体来计算斐波那契数列中的给定数,这样你将了解如何优化你的代码。有时,即使是微小的变化也能带来巨大的性能提升。然后,你将学习如何应用贪婪算法来解决最小硬币找零问题,以及如何利用分而治之算法来找到位于二维表面上的最近点对。你还将看到一个美丽的分形以及设计此类图形的代码。以下例子将涉及使用递归回溯法解决谜题的应用,即迷宫中的老鼠和数独。随着章节接近尾声,你将看到如何根据达尔文的进化论和自然选择规则,应用遗传算法来猜测这本书的标题。最后一个例子将是用于猜测****密码的暴力算法。
如你所见,前方有许多有趣的例子,所以准备好写很多代码并一起解决这些任务吧!让我们开始!
在本章中,你将涵盖以下主题:
-
斐波那契数列
-
最小硬币找零
-
最近点对
-
分形生成
-
迷宫中的老鼠
-
数独谜题
-
标题猜测
-
密码猜测
斐波那契数列
作为第一个例子,让我们看看如何使用以下递归函数计算斐波那契数列中的给定数:

图 9.1 – 计算斐波那契数列中一个数的公式
其解释非常简单:
-
F(0) 等于 0
-
F(1) 等于 1
-
F(n) 是 F(n-1) 和 F(n-2) 的和,这意味着这个数是前两个数的和
例如,F(2) 等于 F(0) 和 F(1) 的和。因此,它等于 1,而 F(3) 等于 2。值得注意的是,有两个基本案例,即当 n 等于 0 和 1 时。对于这两个,都有一个特定的值定义,即 0 和 1。
C#语言中的递归实现如下所示:
long Fibonacci(int n)
{
if (n == 0) { return 0; }
if (n == 1) { return 1; }
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
如你所见,Fibonacci方法以不同的参数值调用自身两次,即比传递给方法的n参数小 1 和 2。如果你传递25给这个方法,你将得到 75025 作为结果,如下所示:
long result = Fibonacci(25);
请记住,所提供的斐波那契函数值的递归版本非常低效,对于较大的输入数字将会非常慢。
您可以使用动态规划来显著提高其性能,无论是自顶向下还是自底向上的方法。首先,让我们使用带有记忆化的自顶向下方法来缓存子问题的计算结果:
Dictionary<int, long> cache = [];
long Fibonacci(int n)
{
if (n == 0) { return 0; }
if (n == 1) { return 1; }
if (cache.ContainsKey(n)) { return cache[n]; }
long result = Fibonacci(n - 1) + Fibonacci(n - 2);
cache[n] = result;
return result;
}
您使用Dictionary类作为缓存,其中键是传递给Fibonacci方法的n的值,值是计算结果,即Fibonacci(n)。在方法内部,您检查缓存是否包含等于n的键。如果是这样,您就不执行进一步的操作,而是简单地从缓存中返回值。如果缓存还没有这样的键,您就使用与递归版本相同的方法,并在返回结果之前将计算结果添加到缓存中。
引入这样的改变值得吗?让我们看看关于斐波那契数列第 50 个数的执行时间的几个数字。在基本的递归版本中,在我的机器上需要超过 88 秒。引入自顶向下方法导致的结果与...不到 1 毫秒的结果相同。这个解决方案几乎快了 100,000 倍!
现在您已经知道动态规划可以带来巨大的差异,让我们看看斐波那契数计算的自底向上方法:
long Fibonacci(int n)
{
if (n == 0) { return 0; }
if (n == 1) { return 1; }
long a = 0;
long b = 1;
for (int i = 2; i <= n; i++)
{
long result = a + b;
a = b;
b = result;
}
return b;
}
在这里,引入了更大的修改,因为您用迭代代替了递归。然而,代码非常简单,因为它只包含一个从 2 迭代到给定数字的for循环,并计算前两个值的和。当然,对于n参数的 0 和 1 值,有单独的if条件。
那么在这种情况下性能如何?让我们比较使用自顶向下和自底向上方法计算斐波那契数列的第 5,000 个数。自顶向下方法大约需要 2 毫秒,而自底向上在我的笔记本电脑上仍然不到 1 毫秒。记住,我们现在讨论的是斐波那契数列的第 5,000 个数,而之前的测试只是针对第 50 个数。性能提升令人难以置信,不是吗?
结果可能会有所不同
性能结果是在我的电脑上获得的,并且以非常简单的方式计算,甚至不需要重复多次。当然,在其他情况下,例如在使用您的机器时,这些结果可能会有所不同。然而,展示一些趋势,而不是精确的毫秒级结果至关重要。这种性能测试旨在向您展示基本递归版本和任何具有动态规划的优化版本之间的巨大差异。
在第一个例子之后,让我们继续解决最小硬币兑换问题。
最小硬币兑换
本章中展示的第二个例子是一个 贪婪算法,用于解决 最小硬币找零 问题,即找到获取指定输入金额所需的最少硬币数量。

图 9.2 – 欧元货币面额的示意图
例如,对于由 1、2、5、10、20、50、100、200 和 500 面额组成的硬币系统,如果你想获取 158 的金额,你需要选择 5 枚硬币,即 100、50、5、2 和 1。贪婪算法非常简单,因为你只需 选择不大于剩余金额的最大面额。你执行此操作,直到剩余金额等于 0。正如你所见,该算法不考虑整体解决方案,而是试图在每一步选择最佳解决方案。
这里展示了基于 C#的实现:
int[] den = [1, 2, 5, 10, 20, 50, 100, 200, 500];
List<int> coins = GetCoins(158);
coins.ForEach(Console.WriteLine);
List<int> GetCoins(int amount)
{
List<int> coins = [];
for (int i = den.Length - 1; i >= 0; i--)
{
while (amount >= den[i])
{
amount -= den[i];
coins.Add(den[i]);
}
}
return coins;
}
最重要的是 GetCoins 方法所扮演的角色,它接受一个输入,即要获取的金额。它返回所选硬币的列表。例如,如果你调用此方法并传入 158,你将在控制台看到 100、50、5、2 和 1。
这只是一个快速示例!现在,让我们继续探讨一些更复杂的内容。
最近点对
另一个例子是寻找位于二维表面上的 最近点对 的算法。这是一个有趣的算法问题,可以使用 分而治之 的范式来解决。
每个点由 x 和 y 坐标表示,值从表面的左上角(0, 0)开始。为了找到最近的点对,你首先根据 x 坐标对所有点进行排序,如下面的图所示,标记为 A 到 N:

图 9.3 – 寻找最近点对算法的示意图
然后,你将表面分成两半。你可以通过计算点数的一半来实现这一点,在我们的例子中是 7,然后取前 7 个点作为左半部分,接下来的 7 个点作为右半部分。
这是一个关于 递归 的任务,所以你递归地找到两半中的最近点,并将数据存储为 rl(点 D 和 E)和 rr(点 I 和 K)。你通过比较这些距离来选择较近的点对,并将结果存储为 r,即在我们的例子中是点 D 和 E。
那不是全部——你还需要检查左半部分和右半部分点之间的距离,如图所示,在右侧。为此,你得到一个包含所有点的数据的数组,这些点相对于中间点(仅就 x 坐标而言)比已找到的点对(D和E在我们的例子中)的r距离更近。然后,你在这个数组中找到最近的点对(例子中的G和H)。让我们称这个结果为s。为了完成任务,选择r(D和E)和s(G和H)中哪个更近。然后,只需返回结果(在我们的情况下是D和E)。
代码最重要的部分如下所示:
Result? FindClosestPair(Point[] points)
{
if (points.Length <= 1) { return null; }
if (points.Length <= 3) { return Closest(points); }
int m = points.Length / 2;
Result r = Closer(
FindClosestPair(points.Take(m).ToArray())!,
FindClosestPair(points.Skip(m).ToArray())!);
Point[] strip = points.Where(p => Math.Abs(p.X
- points[m].X) < r.Distance).ToArray();
return Closer(r, Closest(strip));
}
首先,有一个基本条件,当点数组为空或只包含一个元素时终止执行。然后,你检查数组中点的数量是否小于或等于 3。如果是这样,你只需通过检查所有可能的变体来选择集合中的最近点对。否则,你选择中间点的索引,并递归地对左右两半部分调用该方法。然后,你从两半部分获取足够接近中间点的点,只考虑x坐标。接下来,你计算strip数组中所有点之间的距离,以从其中获取最近的点对。最后,你只需返回最近的点对。
如你所见,算法的主要部分实现和理解都非常简单。那么,让我们谈谈其余的部分,从Point定义开始:
public record Point(int X, int Y)
{
public float GetDistanceTo(Point p) =>
(float)Math.Sqrt(Math.Pow(X - p.X, 2)
+ Math.Pow(Y - p.Y, 2));
};
Result记录如下所示:
public record Closest and Closer. The first one searches for the closest pair of points, and its code is shown here:
Result Closest(Point[] points)
{
Result result = new(points[0], points[0], double.MaxValue);
for (int i = 0; i < points.Length; i++)
{
for (int j = i + 1; j < points.Length; j++)
{
double distance = points[i].GetDistanceTo(points[j]);
if (distance < result.Distance)
{
result = new(points[i], points[j], distance);
}
}
}
return result;
}
The `Closer` method is presented in the following code snippet:
Result Closer(Result r1, Result r2) =>
r1.Distance < r2.Distance ? r1 : r2;
Finally, let’s take a look at a way of calling the described method:
List
[
new Point(6, 45), // A
new Point(12, 8), // B
new Point(14, 31), // C
new Point(24, 18), // D
new Point(32, 26), // E
new Point(40, 41), // F
new Point(44, 6), // G
new Point(57, 20), // H
new Point(60, 35), // I
new Point(72, 9), // J
new Point(73, 41), // K
new Point(85, 25), // L
new Point(92, 8), // M
new Point(93, 43) // N
];
points.Sort((a, b) => a.X.CompareTo(b.X));
Result? closestPair = FindClosestPair(points.ToArray());
if (closestPair != null)
{
Console.WriteLine(
"Closest pair: ({0}, {1}) and ({2}, {3})
with distance: {4:F2}",
closestPair.P1.X,
closestPair.P1.Y,
closestPair.P2.X,
closestPair.P2.Y,
closestPair.Distance);
}
You provide the collection of points, sort them by *x* coordinates, and call the `FindClosestPair` method, passing the whole array as a parameter. Finally, you show the following result in the console:
Closest pair: (24, 18) and (32, 26) with distance: 11.31
So, you get the same result as received when you analyzed the example at the beginning of this section. Good work – congratulations!
Where can you find more information?
The examples shown in this chapter are representatives of various popular algorithmic problems, which you can receive even during interviews while recruiting for a job as a developer. These topics are also popular on the internet. For example, you can find more information about the aforementioned approach to the *closest pair of points* problem and its implementation at [`www.geeksforgeeks.org/closest-pair-of-points-using-divide-and-conquer-algorithm/`](https://www.geeksforgeeks.org/closest-pair-of-points-using-divide-and-conquer-algorithm/). As *GeeksForGeeks* contains a huge number of various articles, you can also find entries there about some other problems mentioned in this chapter, together with some implementations, such as about the rat in a maze problem at [`www.geeksforgeeks.org/rat-in-a-maze/`](https://www.geeksforgeeks.org/rat-in-a-maze/) and about the Sudoku puzzle at [`www.geeksforgeeks.org/sudoku-backtracking-7`](https://www.geeksforgeeks.org/sudoku-backtracking-7)/.
In my opinion, coding can be understood as a kind of art. Similar to painters who paint beautiful paintings, developers can write beautiful code. So, while we are talking about art, let’s write beautiful code that will paint beautiful fractals!
Fractal generation
The **recursion** can be applied to many various algorithms, also related to computer graphics. For this reason, let’s take a look at another example – **fractal generation** creating interesting patterns, such as the following:

Figure 9.4 – An exemplary fractal generated using the recursive function
It’s really beautiful, isn’t it? Can you see some tree patterns in this image? If not, let’s follow the bold line in the middle of the image (the tree *trunk*) and note that it is divided into two lines (*branches*), each rotated by a given degree. Then, follow one of these lines and see that it is divided according to the same rule. This process is applied further and further until the specified number of levels is reached.
The description of this recursive algorithm in the natural language is quite easy, so let’s take a look at code to calculate the coordinates of the start and end points of the following lines that together form the beautiful drawing. The code of the `AddLine` method is shown as follows:
void AddLine(int level, float x, float y,
float length, float angle)
{
if (level < 0) { return; }
float endX = x + (float)(length * Math.Cos(angle));
float endY = y + (float)(length * Math.Sin(angle));
lines.Add(new(x, y, endX, endY));
AddLine(level - 1, endX, endY, length * 0.8f,
angle + (float)Math.PI * 0.3f);
AddLine(level - 1, endX, endY, length * 0.6f,
angle + (float)Math.PI * 1.7f);
}
The method takes a few parameters, namely the following:
* A level of pattern, starting with the non-negative number and leading to 0
* The *x* and *y* coordinates of the start point
* A length of the line
* Its angle, provided in radians
Within the method, you check the base condition, namely whether the level is smaller than 0\. If not, you calculate *x* and *y* coordinates of the end point and add the line to the collection of lines (`lines`). At the end, you recursively call the `AddLine` method, passing different parameters. You decrease the level, pass the calculated end point coordinates as coordinates of a start point for the next line, decrease the length by 20% and 40% (depending on the branch), and also modify the angle.
It is worth noting that the preceding code uses the `Line` record, the code for which is as follows:
record Line(float X1, float Y1, float X2, float Y2)
{
public float GetLength() =>
(float)Math.Sqrt(Math.Pow(X1 - X2, 2)
- Math.Pow(Y1 - Y2, 2));
}
The next part of the code is presented here:
using System.Drawing;
using System.Drawing.Drawing2D;
const int maxSize = 1000;
List
maxSize)。然后,你准备一个空的线条列表。在最后一行,你调用 AddLine 方法。你指定将添加 14 级的图案。
需要的 NuGet 包
由于你使用了 System.Drawing 和 System.Drawing.Drawing2D 命名空间中的元素,因此需要安装一个额外的 NuGet 包,即 System.Drawing.Common。
一旦你有了线条集合,你可以计算最小和最大的 x 和 y 坐标,以及目标 宽度 和 高度,如下所示:
float xMin = lines.Min(l => Math.Min(l.X1, l.X2));
float xMax = lines.Max(l => Math.Max(l.X1, l.X2));
float yMin = lines.Min(l => Math.Min(l.Y1, l.Y2));
float yMax = lines.Max(l => Math.Max(l.Y1, l.Y2));
float size = Math.Max(xMax - xMin, yMax - yMin);
float factor = maxSize / size;
int width = (int)((xMax - xMin) * factor);
int height = (int)((yMax - yMin) * factor);
代码的剩余部分与在位图中打印分形有关:
using Bitmap bitmap = new(width, height);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.Clear(Color.White);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
using Pen pen = new(Color.Black, 1);
foreach (Line line in lines)
{
pen.Width = line.GetLength() / 20;
float sx = (line.X1 - xMin) * factor;
float sy = (line.Y1 - yMin) * factor;
float ex = (line.X2 - xMin) * factor;
float ey = (line.Y2 - yMin) * factor;
graphics.DrawLine(pen, sx, sy, ex, ey);
}
bitmap.Save($"{DateTime.Now:HH-mm-ss}.png");
在展示的代码中,你创建了一个具有指定大小的 Bitmap 类的新实例,并准备了 Graphics 对象来绘制此位图。然后,你用白色绘制整个位图,设置抗锯齿,并指定黑色笔进行绘制。
前面的代码片段涉及 foreach 循环。在循环中,你计算线条宽度以及起始和结束坐标。循环中的最后一行简单地绘制线条。最后,你将准备好的位图保存到工作目录中的文件,其名称基于当前时间创建。
你看到警告了吗?
准备的代码在 IDE 中显示了一些警告。它们仅通知你图形相关功能仅在 Windows 平台上可用。你可以在前面的代码之前添加一行 #pragma warning disable CA1416 来隐藏此类警告,以及添加一行 #pragma warning restore CA1416 来在代码末尾恢复。此外,如果你想在其他平台上绘制图形,可以使用其他可用的 NuGet 包,例如 SkiaSharp。我强烈建议你使用 SkiaSharp 创建此示例。
那就结束了!你现在可以调整各种参数来绘制美丽的分形,甚至比前面图中的更好。其他一些结果如下所示:

图 9.5 – 使用递归函数生成的示例分形
你在哪里可以找到更多信息?
你可以在互联网上找到很多关于分形的内容。然而,与这里展示的类似的方法,可以在 www.csharphelper.com/howtos/howto_curly_tree.html 中找到描述。
一旦你对分形的设计满意,让我们进入下一节,在那里你将解决 迷宫中的老鼠 智力题。
迷宫中的老鼠
让我们通过解决老鼠在迷宫中的问题,使用回溯算法继续我们的冒险。图示如下:

图 9.6 – 老鼠在迷宫示例的示意图
让我们想象老鼠位于板上的左上角字段,在先前的图中标记为(0,0),我们需要找到通往出口的路径,出口位于右下角,标记为(7,7)。当然,一些方块是禁用的(以灰色显示),老鼠不能通过它们。为了到达目标,老鼠只能通过可用的方块向上、下、左或右移动。
你可以使用递归来检查可能的路径,这些路径将老鼠从入口引导到出口。如果当前计算的路径没有到达出口,你将回溯并尝试其他变体。
实现的主要部分是Go方法,如下所示:
bool Go(int row, int col)
{
if (row == size - 1
&& col == size - 1
&& maze[row, col])
{
solution[row, col] = true;
return true;
}
if (row >= 0 && row < size
&& col >= 0 && col < size
&& maze[row, col])
{
if (solution[row, col]) { return false; }
solution[row, col] = true;
if (Go(row + 1, col)) { return true; }
if (Go(row, col + 1)) { return true; }
if (Go(row - 1, col)) { return true; }
if (Go(row, col - 1)) { return true; }
solution[row, col] = false;
return false;
}
return false;
}
该方法接受两个参数,即row和column。它还使用三个额外的变量。第一个名为maze,它是一个二维数组,表示迷宫,其中包含老鼠可用的(用true值填充)和不可用的(用false值填充)字段。第二个,即size,存储迷宫的大小,即行数,这也等于列数。另一个变量(solution)与maze类似,但它存储当前检查路径的数据。形成解决方案的字段用true值填充,而其他字段用false值填充。
在方法开始时,你检查老鼠是否已经到达出口。如果是这样,你将最终字段标记为解决方案的一部分,并返回一个值表示老鼠完成了其任务并走出了迷宫。否则,你检查老鼠是否仍然在迷宫内且不在任何不可用的字段上。如果所有这些条件都满足,你检查该字段是否已经是路径的一部分,如果是,你通知这种解决方案是错误的。
如果老鼠在迷宫内且位于尚未访问的可用的字段上,你将此字段标记为解决方案的一部分,并尝试通过调用递归的Go方法向下、右、上和左移动。如果这些移动中的任何一个都没有达到目标(当然,在下一步之后),你将当前字段标记为不是解决方案的一部分,这代表回溯。然后,你返回一个值表示目标尚未到达。
接下来,看看调用Go方法的第一次代码:
int size = 8;
bool t = true;
bool f = false;
bool[,] maze =
{
{ t, f, t, f, f, t, t, t },
{ t, t, t, t, t, f, t, f },
{ t, t, f, t, t, f, t, t },
{ f, t, t, f, t, f, f, t },
{ f, t, t, t, t, t, t, t },
{ t, f, t, f, t, f, f, t },
{ t, t, t, t, t, t, t, t },
{ f, t, f, f, f, t, f, t }
};
bool[,] solution = new bool[size, size];
Print method:
void Print()
{
for (int row = 0; row < size; row++)
{
for (int col = 0; col < size; col++)
{
Console.Write(solution[row, col] ? "x" : "-");
}
Console.WriteLine();
}
}
The result is shown here:
x-------
x-------
xx------
-x------
-xx-----
--x-----
--xxxxxx
-------x
As we conclude this example, it is worth mentioning how the code is simple and short. Thus, you can define the solution to a problem in a clear way. However, keep in mind that if there is more than one path, an algorithm shows only one.
After helping the rat to find a path in a maze, let’s move on to the next example, where you will learn how to automatically solve a Sudoku puzzle.
A Sudoku puzzle
Have you ever solved **Sudoku**? It is a very popular game that requires you to **fill empty cells of a 9x9 board with numbers from 1 to 9**. However, **each row, each column, and each 3x3 box must contain only unique numbers**. An exemplary starting board and a solved one are shown as follows:

Figure 9.7 – An example of non-solved and solved Sudoku puzzles
Now, you will learn how to solve Sudoku not with the usage of a pencil and a piece of paper but with an algorithm! You can perform this task using the **back-tracking** approach, trying to assign numbers to empty cells if, of course, they meet the conditions regarding unique numbers in each row, column, and box. If an entered number does not result in solving the whole puzzle, you assign another number and perform the check again. Let’s take a look at the most important part of the code:
bool Solve()
{
(int row, int col) = GetEmpty();
if (row < 0 && col < 0) { return true; }
for (int i = 1; i <= 9; i++)
{
if (IsCorrect(row, col, i))
{
board[row, col] = i;
if (Solve()) { return true; }
else { board[row, col] = 0; }
}
}
return false;
}
At the beginning of the `Solve` method, you get coordinates of the first empty cell. If there are no empty cells, the `GetEmpty` method returns (`true`, indicating that the game is solved.
Otherwise, you iterate through all possible numbers (namely from 1 to 9), using the `for` loop. In each iteration, you check whether the number can be correctly entered in this cell, using the `IsCorrect` method, ensuring that the number is unique in a row, a column, and a box. If so, you enter this number into the cell and `Solve` method. If it returns `false`, indicating that this variant does not work, you backtrack by clearing the value entered in the cell, which means that it is empty and another variant needs to be used. If no variants lead to the solution, you return `false`.
The presented code uses two auxiliary methods, including `GetEmpty`, which searches for the first cell that is not already filled. Its code is as follows:
(int, int) GetEmpty()
{
for (int r = 0; r < 9; r++)
{
for (int c = 0; c < 9; c++)
{
if (board[r, c] == 0) { return (r, c); }
}
}
return (-1, -1);
}
The second auxiliary method is named `IsCorrect` and ensures that after entering a provided number in a given cell (with a specified row and column), the board still meets the criteria of the Sudoku game. Its code is presented here:
bool IsCorrect(int row, int col, int num)
{
for (int i = 0; i < 9; i++)
{
if (board[row, i] == num) { return false; }
if (board[i, col] == num) { return false; }
}
int rs = row - row % 3;
int cs = col - col % 3;
for (int r = rs; r < rs + 3; r++)
{
for (int c = cs; c < cs + 3; c++)
{
if (board[r, c] == num) { return false; }
}
}
return true;
}
At the beginning, you check whether values are unique in a given row and column. The remaining part checks whether a particular 3x3 box contains only unique numbers.
The exemplary code for launching the Sudoku solving algorithm is as follows:
int[,] board = new int[,]
{
{ 0, 5, 0, 4, 0, 1, 0, 0, 6 },
{ 1, 0, 0, 9, 5, 0, 8, 0, 0 },
{ 9, 0, 4, 0, 6, 0, 0, 0, 1 },
{ 6, 2, 0, 0, 0, 5, 3, 0, 4 },
{ 0, 9, 0, 0, 7, 0, 2, 0, 5 },
{ 5, 0, 7, 0, 0, 0, 0, 8, 9 },
{ 8, 0, 0, 5, 1, 9, 0, 0, 2 },
{ 2, 3, 0, 0, 0, 6, 5, 0, 8 },
{ 4, 1, 0, 2, 0, 8, 6, 0, 0 }
};
打印方法:
void Print()
{
for (int r = 0; r < 9; r++)
{
for (int c = 0; c < 9; c++)
{
Console.Write($"{board[r, c]} ");
}
Console.WriteLine();
}
}
结果如下所示:
7 5 3 4 8 1 9 2 6
1 6 2 9 5 7 8 4 3
9 8 4 3 6 2 7 5 1
6 2 1 8 9 5 3 7 4
3 9 8 1 7 4 2 6 5
5 4 7 6 2 3 1 8 9
8 7 6 5 1 9 4 3 2
2 3 9 7 4 6 5 1 8
4 1 5 2 3 8 6 9 7
如您所见,回溯算法可以成功应用于解决迷宫中的老鼠和数独问题。您可以通过简短、清晰且易于理解的代码实现这一目标。因此,在这些示例之后,让我们继续到下一节,在那里您将看到遗传算法的一个有趣应用。
标题猜测
是时候将应用算法的类型从一种启发式算法转变为一种,这种算法有众多应用和子类型。在这里,我们只关注遗传算法,它们是自适应启发式搜索算法。它们与达尔文的进化论和自然选择理论相关。根据这一理论,种群中的个体相互竞争,种群会进化以产生更适合生存的下一代。遗传算法在字符串上操作,这些字符串会进化以接收可能的最大适应度值,遵守生存规则,并基于随机数据交换传递最适应父母的基因。算法在达到合适的适应度值或达到最大代数时结束操作。
你可以在哪里找到更多信息?
您可以在互联网上找到大量关于遗传算法的内容,例如在link.springer.com/article/10.1007/s11042-020-10139-6上发表的文章。本章中展示的遗传算法的简单方法基于www.geeksforgeeks.org/genetic-algorithms/上提出的解决方案。
让我们看看一个遗传算法应用的例子,用于猜测这本书的标题。代码的第一部分如下:
const string Genes = "abcdefghijklmnopqrstuvwxyz
#ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
const string Target = "C# Data Structures and Algorithms";
Random random = new();
int generationNo = 0;
List<Individual> population = [];
for (int i = 0; i < 1000; i++)
{
string chromosome = GetRandomChromosome();
population.Add(new(chromosome,
GetFitness(chromosome)));
}
首先,你创建一个初始种群,包含 1,000 个个体。每个个体都有一个随机的染色体,表示为一个长度等于目标字符串的随机字符串,即书名。让我们进一步了解:
List<Individual> generation = [];
while (true)
{
population.Sort((a, b) =>
b.Fitness.CompareTo(a.Fitness));
if (population[0].Fitness == Target.Length)
{
Print();
break;
}
generation.Clear();
for (int i = 0; i < 200; i++)
{
generation.Add(population[i]);
}
for (int i = 0; i < 800; i++)
{
Individual p1 = population[random.Next(400)];
Individual p2 = population[random.Next(400)];
Individual offspring = Mate(p1, p2);
generation.Add(offspring);
}
population.Clear();
population.AddRange(generation);
Print();
generationNo++;
}
最有趣的部分位于无限while循环中。在这里,你按照适应度从高到低的顺序对种群进行排序,即生存能力最强的个体排在前面。为了详细解释,当染色体字符串中没有字符与目标字符串中的字符匹配时,适应度等于 0。反过来,当染色体字符串与目标字符串相等时,适应度等于 33(即书名中字符的数量)。因此,如果种群中的第一个元素(即最适应的个体)的适应度等于目标字符串的长度,这意味着找到了解决方案,所以你只需打印出来并退出循环。
否则,你清除新的一代的数据列表,并添加 200 个适应度最高的个体到其中。这意味着20%的适应度最高的个体会自动进入下一代。对于新的一代中剩余的 800 个位置,你执行交叉并随机选择父母,从 40%的适应度最高的个体中,生成新的个体。然后,你用新的一代替换当前种群,并继续下一轮迭代。
值得注意的是Individual记录,其代码如下:
record Chromosome and Fitness. The first stores the string adjusted in the evolution, while the other is the number indicating how this particular individual is fit to survive. Of course, a higher value is better.
The `Mate` method is used to generate a new individual using two parents:
Individual Mate(Individual p1, Individual p2)
{
string child = string.Empty;
for (int i = 0; i < Target.Length; i++)
{
float r = random.Next(101) / 100.0f;
if (r < 0.45f) { child += p1.Chromosome[i]; }
else if (r < 0.9f) { child += p2.Chromosome[i]; }
else { child += GetRandomGene(); }
}
return new Individual(child, GetFitness(child));
}
The most interesting part of this method is the `for` loop in which the chromosome of the child is created, according to the following rules:
* **Approximately 45% of genes are taken from the** **first parent**
* **Approximately 45% of genes are taken from the** **second parent**
* **The remaining 10%** **are randomized**
And how can you get a random single gene or generate a random whole chromosome? You just take a look at the code:
char GetRandomGene() => Genes[random.Next(Genes.Length)];
string GetRandomChromosome()
{
string chromosome = string.Empty;
for (int i = 0; i < Target.Length; i++)
{
chromosome += GetRandomGene();
}
return chromosome;
}
The next necessary method is named `GetFitness`, which simply returns the number of characters that matches the target book title. Its code is as follows:
int GetFitness(string chromosome)
{
int fitness = 0;
for (int i = 0; i < Target.Length; i++)
{
if (chromosome[i] == Target[i]) { fitness++; }
}
return fitness;
}
Finally, let’s take a look at the `Print` method:
void Print() => Console.WriteLine(
$"第{generationNo:D2}代:
{population[0].Chromosome} / {population[0].Fitness}");
When you run the code, the best-fitted individual from each generation is presented, as shown in the following output:
第 00 代:UvWvvtycVTYAsJYxXZpanLkj#rDrmDIEI / 4
第 01 代:sXDGuQQDPnbjpRvWZs evqRNlg#yiwIPL / 5
第 02 代:j#TvvtmKToXuTjxBegpaCLkmNsornzg R / 7
第 03 代:fZCUBIT QrnuzwuWTskTOf bezodQwhmM / 8
第 04 代:CyDwafZZpinLziuPgs yID AevGrGf bs / 9
第 05 代:C# ZaBawSWwLoturSXOcIq wLeSgQOhme / 12 (...)
第 10 代:Sboats ttrDcterus Mnt jmvGrifhms / 17 (...)
第 15 代:C kData ltrCkteres entbAagorZthmD / 21 (...)
第 20 代:C#VDatahStrdcturessanU Al#orithmd / 26 (...)
第 25 代:CZ Data StrunturOs awd Algorithms / 29 (...)
第 30 代:C# Data Structures Qjd Algorithms / 31 (...)
第 35 代:C# Data Structures and Algorothms / 32 (...)
第 37 代:C# 数据结构和算法 / 33
Is this *magic*? No, it’s just the algorithm you wrote that manages the following generations and evolves the individuals, giving you the expected result.
A password guess
As an example of a **brute-force algorithm**, let’s create a program to **generate all possible passwords and trying to guess your secret one**, which consists of small letters and digits only. The program starts with passwords of a length equal to 2 and proceeds until 8.
The first part of the code is presented here:
using System.Diagnostics;
使用 System.Text;
const string secretPassword = "csharp";
int charsCount = 0;
char[] chars = new char[36];
for (char c = 'a'; c <= 'z'; c++)
{
chars[charsCount++] = c;
}
for (char c = '0'; c <= '9'; c++)
{
chars[charsCount++] = c;
}
First, you specify a secret password, which you will try to guess in the remaining part of the code. Then, you create an array with available characters, namely small letters and digits. At the end of this code snippet, the `charsCount` variable stores the number of available characters.
The most interesting part of the code is the `for` loop, where each iteration represents a particular length of a password, between two and eight chars. The code is presented here:
for (int length = 2; length <= 8; length++)
{
Stopwatch sw = Stopwatch.StartNew();
int[] indices = new int[length];
for (int i = 0; i < length; i++) { indices[i] = 0; }
bool isCompleted = false;
StringBuilder builder = new();
long count = 0;
while (!isCompleted)
{
builder.Clear();
for (int i = 0; i < length; i++)
{
builder.Append(chars[indices[i]]);
}
string guess = builder.ToString();
if (guess == secretPassword)
{
Console.WriteLine("找到。");
}
count++;
if (count % 10000000 == 0)
{
Console.WriteLine($" > 已检查: {count}.");
}
indices[length - 1]++;
if (indices[length - 1] >= charsCount)
{
for (int i = length - 1; i >= 0; i--)
{
indices[i] = 0;
indices[i - 1]++;
if (indices[i - 1] < charsCount) { break; }
if (i - 1 == 0 && indices[0] >= charsCount)
{
isCompleted = true;
break;
}
}
}
}
sw.Stop();
int seconds = (int)sw.ElapsedMilliseconds / 1000;
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine($"{length} 个字符: {seconds}s");
Console.ResetColor();
}
The `indices` array has a length equal to the value of the `length` variable. Each item stores a current index from the `chars` array, indicating the char that is currently placed on the *i*-th location in the string. In each iteration of the `while` loop, you change values in the `indices` array until all possible combinations of the indices are used.
Furthermore, you save the guessed password in the `guess` variable, and here, it can be either printed on the console or hashed and compared with the hashed password that you want to guess. As this is only a demonstration of a brute-force algorithm, it does not stop its operation when the password is guessed. Thus, you can get more performance results and observe what impact the password length has on the required time for guessing.
As you can see, the brute-force approach is very simple, but what about performance? In the preceding code, you can see the usage of `Stopwatch`, so you can get some results. Generating all possible variants of a password consisting of two chars takes less than 1 millisecond. For three- and four-char passwords, the time is also very small, much less than 100 milliseconds. For five-char passwords, the time goes up to about two seconds, while generating passwords of a length equal to six chars takes almost a minute. If you add a mechanism to hash a password and compare it with the target hash, also taking into account that passwords can also contain capital letters and many other chars, the brute-force algorithm seems to be simply impractical in the case of longer passwords.
It is worth mentioning that the presented performance results were received on my computer and can be different on other devices. They are shown only to indicate a trend that as a password length increases, the time necessary to guess it is significantly longer with each added character. Thus, it is also a useful tip that you should always use a complicated password that contains small and capital letters, digits, and special characters. Of course, the length of the password is also important.
Summary
You just completed the ninth chapter of this book, which examined data structures and algorithms in the context of the C# language. This time, we focused on practical examples of algorithms, with code snippets, detailed descriptions, and also brief indications of which types of algorithms the aforementioned examples belong to.
First, you learned how to implement a simple algorithm to calculate a given number from the **Fibonacci series** in three variants. You saw a simple recursive approach as well as top-down and bottom-up approaches to dynamic programming.
The next example showed the greedy approach to solve the **minimum coin change** problem. It was followed by the divide-and-conquer algorithm to find the **closest pair of points** located on a two-dimensional surface. The fourth example presented a recursive way of **generating fractals** and drawing them on a bitmap.
The following two examples were related to back-tracking algorithms to solve the **rat in a maze** and the **Sudoku** puzzles. These examples used recursion as well.
Another interesting approach involved a genetic algorithm as a subtype of a heuristic algorithm. It was used to **guess the title of the book**, with the rules of the Darwinian theory of evolution and natural selection.
The last example used a brute-force algorithm to **guess a secret password**, by checking all possible variants of passwords. You saw that with the increasing password length, the time necessary to guess it increased significantly.
Now, it is high time to proceed to the overall summary to take a look at all of the data structures that have been presented in the book so far. Let’s turn the page and proceed to the last chapter!
第十章:结论
正如你在阅读本书时所看到的,有许多具有许多配置变体的数据结构。因此,选择合适的数据结构并不是一件容易的事情,这可能会对开发解决方案的性能产生重大影响。即使是本书中提到的主题也形成了一个相当长的数据结构描述列表。因此,以某种方式对它们进行分类是一个好主意。
在本章中,描述的数据结构被分为线性和非线性类别。在线性数据结构中,每个元素可以逻辑上与下一个或前一个元素相邻。在非线性数据结构的情况下,单个元素可以逻辑上与许多其他元素相邻,而不仅仅是单个或两个。
作为本书的最后一章,我们也将总结所有收集到的知识。每个数据结构都将提供一个简要的描述,其中一些还将通过插图展示,以帮助您记住这些信息。
在本章中,将涵盖以下主题:
-
分类
-
数组
-
列表
-
栈
-
队列
-
字典
-
集合
-
树
-
图
-
最后一词
分类
我将从对本书中展示的数据结构的分类开始。这个分类将所有结构分为线性和非线性。
线性数据结构意味着每个元素可以逻辑上与下一个或前一个元素相邻。有几个数据结构遵循这个规则,例如数组、列表、栈和队列。当然,你也应该注意所提到数据结构的各种子类型,例如链表的四种变体,它是列表的子类型。
非线性数据结构表示单个元素可以逻辑上与许多其他元素相邻,而不仅仅是单个或两个。它们可以在内存中自由分布。当然,包括树在内的基于图的数据结构也包含在这个类别中。树包括二叉树、Trie 树和堆,而二叉搜索树是二叉树的子类型。以类似的方式,你可以描述本书中展示和解释的其他数据结构之间的关系。
以下图表展示了所提到的分类:

图 10.1 – 将数据结构分类为线性和非线性
你还记得书中展示的所有数据结构吗?由于描述的主题数量众多,重新审视以下数据结构是一个好主意。同时也会提到相关的算法。本章剩余部分是对一些实际应用的简要总结。
数组
让我们从 int、string或用户定义的类开始。重要的假设是数组的元素数量在初始化后不能改变。此外,数组属于随机访问数据结构。这意味着你可以使用索引来访问数组的第一个、中间的、第 n 个或最后一个元素。
你可以从数组的几种变体中受益——即单维、多维和锯齿形数组,也称为数组数组。所有这些变体都在下面的插图中有展示:

图 10.2 – 数组的变体
数组有很多应用,作为一个开发者,你可能已经多次使用这种数据结构了。在这本书中,你看到了如何使用它来存储各种数据,例如月份名称、乘法表,甚至游戏地图。在后一种情况下,你创建了一个与地图大小相同的二维数组,其中每个元素指定了某种地形类型,例如草地。
有许多算法在数组上执行操作。然而,最常见的任务之一是将数组排序,以正确地排列其元素,无论是升序还是降序。这本书重点介绍了七个算法,即选择排序、插入排序、冒泡排序、归并排序、希尔排序、快速排序和堆排序。每个算法都在插图和 C# 代码中进行了描述和展示,并附有详细的解释。
列表
接下来的一组数据结构是 ArrayList及其泛型(List)和排序(SortedList)变体。后者可以理解为键值对的集合,始终按键排序。
列表还有一些其他变体,包括LinkedList。你可以相当容易地扩展它以表现得像任何循环链表,无论是循环单链表还是循环双链表。
下面的插图显示了列表的多种变体:

图 10.3 – 列表的变体
列表在解决各种应用中的各种问题时有很多应用。在这本书中,你看到了如何利用列表存储一些浮点值并计算平均值,如何使用这种数据结构创建一个简单的人员数据库,以及如何开发一个自动排序的地址簿。此外,你还准备了一个示例应用,允许用户通过改变页面来阅读书籍,以及一个游戏,用户可以通过随机力量旋转轮盘。轮盘旋转得越来越慢,直到停止。然后,用户可以再次旋转它,从上一个停止位置开始,这说明了循环链表。
栈
第五章 栈和队列 专注于栈和队列。现在,让我们回顾一下,Stack 类也是可用的。
栈的示意图如下所示:

图 10.4 – 栈的示意图
栈在现实世界中也有许多应用。其中一个例子是关于许多盘子堆叠的,每个盘子都放在另一个盘子的上面。你只能在新盘子堆的顶部添加一个新的盘子,你只能从盘子堆的顶部取出一个盘子。你不能在不取走顶部前六个盘子的情况下取出第七个盘子,也不能在盘子堆的中间添加一个盘子。你还看到了如何使用栈来反转一个单词以及如何将其应用于解决数学游戏汉诺塔。但这还不是全部,因为栈的应用范围更广,例如用于计算逆波兰表示法中提供的数学表达式。
队列
第五章的另一个主要主题是 栈和队列,其中也为你提供了一个 Queue 类。
队列的示意图如下所示:

图 10.5 – 队列的示意图
还可以使用优先队列,它通过为每个元素设置优先级来扩展队列的概念。因此,出队操作返回的是优先级最高的元素,这是最早添加到队列中的。当所有具有最高优先级的元素都被出队后,优先队列处理具有下一个最高优先级的元素,并从最早添加的元素中出队这些元素。
队列的另一种变体是循环队列,也称为环形缓冲区,这在书中也被介绍和解释了。在这里,队列形成一个圆圈,内部使用数组,可以放置在队列中的最大元素数量是有限的。在这种情况下,你指定前导元素和尾元素的下标。
队列在现实世界中有很多应用。例如,队列可以用来表示在结账处等待的人群队列。新来的人站在队伍的末尾,下一个被带到结账处的人是从队伍的起始位置取出的。不允许从队伍中间选择一个人并为他们服务。此外,你还看到了一些呼叫中心解决方案的例子,其中有许多客户和一个顾问,或者许多客户和许多顾问,或者许多客户(具有不同的计划,无论是标准支持还是优先支持)以及只有一个顾问,他回答等待的电话。在介绍基于图的算法时还展示了另一组队列应用。在广度优先搜索算法中使用了队列来遍历图或搜索图中的给定值。在迪杰斯特拉算法中应用了优先队列来搜索图中的最短路径。
字典
第六章,“字典和集合”这一主题与字典和集合相关。首先,让我们回顾一下字典,它允许将键映射到值并执行快速查找。字典使用哈希函数,可以理解为包含一对对的集合,每对由一个键和一个值组成。
字典有两种内置版本——非泛型(Hashtable)和泛型(Dictionary)。还有字典的排序版本(SortedDictionary)。所有这些都在详细描述中。
以下插图展示了哈希表的机制:

图 10.6 – 将键映射到特定值的示意图
由于哈希表的高性能,这种数据结构在许多实际应用中经常被使用,例如用于关联数组、数据库索引或缓存系统。在本书中,你看到了如何创建一个电话簿来存储条目,其中一个人的名字是键,电话号码是值。在其他例子中,你开发了一个帮助商店员工找到产品放置位置的应用程序,并且你应用了排序字典来创建一个简单的百科全书,用户可以添加条目。
集合
另一个来自第六章,“字典和集合”的数据结构是集合,它是一个没有重复元素且没有特定顺序的独立对象的集合。因此,你只能知道给定元素是否在集合中。集合与数学模型和操作(如并集、交集、差集和对称差集)紧密相关。
如下所示,示例集合存储了各种类型的数据:

图 10.7 – 整数和字符串值集合的示意图
在开发 C# 语言的应用程序时,你可以从 HashSet 类提供的高性能集合相关操作中受益。例如,你看到了如何创建一个处理一次性促销优惠券的系统,并允许你检查扫描的优惠券是否已被使用。另一个例子是 SPA 中心四个游泳池系统的报告服务。通过使用集合,你可以计算统计数据,例如游泳池的访问人数、最受欢迎的游泳池以及至少访问过一次游泳池的人数。
树
下一个主题是关于树的,这是第七章“树的变体”的主题。树由具有一个根的节点组成。根节点没有父节点,而所有其他节点都有。此外,每个节点可以有任意数量的子节点。同一父节点的子节点可以称为兄弟节点,而没有子节点的节点称为叶节点。
这里展示了一个示例树:

图 10.8 – 树的示意图
树是一种非常适合表示各种数据的数据结构,例如,一个公司结构,分为几个部门,每个部门都有自己的结构。你也看到了一个例子,其中使用树来安排一个简单测验,由几个问题和答案组成,这些问题和答案根据之前做出的决策显示出来。
通常来说,树中的每个节点可以包含任意数量的子节点。然而,在二叉树的情况下,一个节点不能包含超过两个子节点——也就是说,它可以没有子节点,或者只有一个或两个。然而,节点之间的关系没有规则。这里展示了示例二叉树:

图 10.9 – 二叉树和二叉搜索树的示意图
如果你想使用二叉搜索树(BST),接下来介绍的一条规则是:对于任何节点,其左子树中所有节点的值必须小于其值,而其右子树中所有节点的值必须大于其值。上一个图例的右侧展示了示例二叉搜索树。
另一类树被称为自平衡树,在添加和删除节点的同时始终保持树的平衡。它们的应用非常重要,因为它允许你形成正确排列的树,这对性能有积极影响。自平衡树有多种变体,但AVL 树和红黑树(RBTs)是最受欢迎的一些。
树的一个应用与处理字符串有关,例如a到z)。当你从根节点走到每个节点时,你会得到一个字符串,它要么是一个已保存的单词,要么是其子串,如下面的图例所示:

图 10.10 – trie 的示意图
堆是树的另一种子类型,存在许多变体,包括二叉堆。它包含两个版本——即最小堆和最大堆。对于每一个,都必须满足额外的属性。对于最小堆,每个节点的值必须大于或等于其父节点的值。因此,根节点包含最小的值。对于最大堆,每个节点的值必须小于或等于其父节点的值。因此,根节点总是包含最大的值。以下是最小二叉堆的示例:

图 10.11 – 最小堆和最大堆的示意图
堆是实现优先队列的方便数据结构。另一个有趣的应用是排序算法,名为堆排序,这在关于数组和排序的章节中进行了介绍和解释。
图
第八章,探索图,与图相关——这是一个应用范围广泛且非常受欢迎的数据结构。提醒一下,图是一种由节点和边组成的数据结构。每条边连接两个节点。图中边的变体有几种,如无向和有向,以及无权和有权。图可以用邻接表或邻接矩阵来表示。
所有这些主题都在书中进行了描述,包括使用广度优先搜索和深度优先搜索算法进行图遍历的问题,使用克鲁斯卡尔和普里姆算法找到最小生成树,节点着色,以及使用迪杰斯特拉算法在图中找到最短路径。
典型的图示如下所示:

图 10.12 – 图的示意图
图数据结构在多种应用中都很常见。它也是一种表示各种数据的好方法,例如社交媒体网站上可用的朋友结构。在这里,节点可以代表联系人,而边代表人与人之间的关系。因此,你可以轻松地检查两个联系人是否认识彼此,或者需要多少人参与安排两个特定人之间的会议。
图的另一个常见应用涉及寻找路径的问题。例如,你可以使用图来找到城市中两点之间的路径,考虑到驾驶所需的距离或时间。你可以使用图来表示城市的地图,其中节点是交叉口,边代表道路。你可以给边分配权重,以表示驾驶给定道路所需的距离或时间。
与图相关的应用还有很多。例如,最小生成树可以用来创建建筑物之间的连接计划,以最小的成本为所有建筑物提供电信电缆。在书中使用了节点着色问题来为波兰地图上的省进行着色,根据规则,两个有共同边界的省份不能有相同的颜色。另一个展示的例子涉及迪杰斯特拉算法在游戏地图中寻找最短路径,考虑到各种障碍。
最后的话
您已经到达了这本书的最后一章的结尾。首先,介绍了数据结构的分类,考虑了线性和非线性数据结构。在第一组中,您可以找到数组、列表、栈和队列,而第二组包括图及其子类型,包括树和堆。在这一章的后续部分,考虑了各种数据结构的多样性应用。您看到了每种描述的数据结构的简要总结,以及使用特定数据结构(如队列或图)可以解决的问题的信息。为了使内容更容易理解,以及提醒您之前章节中的各种主题,总结配备了数据结构的简要描述和插图。
在这本书的引言中,我邀请您开始您的数据结构和算法冒险之旅。在阅读以下章节、编写数百行代码和调试的过程中,您有机会熟悉各种数据结构,从数组、列表开始,经过栈、队列、字典和集合,最后到树和图。我希望这本书只是您在数据结构和算法领域漫长、充满挑战和成功的冒险的第一步。
我想感谢您阅读这本书。如果您对描述的内容有任何问题或问题,请直接使用显示在marcin.com的联系方式与我联系。在访问我的网站时,您还可以找到许多您在开发生涯中可以提出的问题的答案。请告诉我,您想从这本书的下一版或我的其他书中学习哪些缺失的主题。我真心希望您能从展示的内容中受益。我衷心祝愿您在软件开发者的职业生涯中一切顺利,并希望您有许多成功的项目!如果您让我知道您的大项目,特别是如果它们受到了这本书内容的启发,我将非常高兴。祝您好运,保持联系!


浙公网安备 33010602011771号