每日数据结构-全-
每日数据结构(全)
原文:
zh.annas-archive.org/md5/14471d76ac2357aa321887ee33d26782译者:飞龙
前言
很常见,作为软件开发者,当我们面对新的任务或挑战时,我们会发现自己正在抓取最熟悉的代码片段或模式。我们通常做出这样的选择,因为这些片段和模式代表了两个点之间的最短路径,即客户的需求和截止日期(也称为发薪日)。然而,这种方法有时会阻止我们学习新的技能和想法,这些技能和想法会使我们成为更好的、更高效的开发者。
这本书是为了给有抱负的、新手的或相对缺乏经验但忙碌的开发者一个机会,让他们退后一步,检查一些关于数据类型和数据结构的基本概念。为此,我们将研究这些类型和结构是如何构建的,它们是如何工作的,以及我们如何在日常应用中利用它们。通过这样做,我们将获得新的知识、技能和能力,并希望得到一些关于如何利用这些基本组件的新想法。
本书涵盖内容
第一章, 数据类型:基础结构,是对构成数据结构的基本数据类型的简要概述。这将是一个快速概述,因为即使是新程序员也已经熟悉这些组件中的某些或大部分。将关注每种类型的应用、最佳实践以及平台之间任何变体的高级比较。
第二章, 数组:基础集合,为您介绍了数组数据结构。这次讨论将包括结构的详细信息,包括典型应用以及每种语言的特定关注点。这是非常重要的一章,因为后续许多数据结构都是基于数组构建的。
第三章, 列表:线性集合,涵盖了列表数据结构的详细信息,包括与列表相关联的最常见函数、列表的典型应用以及每种语言的特定关注点。
第四章, 栈:后进先出集合,为您介绍了栈数据结构。在本章中,读者将学习结构的详细信息,包括与栈相关联的最常见函数、栈的典型应用以及每种语言的特定关注点。
第五章, 队列:先进先出集合,讲述了队列数据结构的特定细节,包括与队列相关联的最常见函数、队列的典型应用以及每种语言的特定关注点。
第六章, 字典:键值集合,深入探讨了字典数据结构的特定细节,包括与字典相关联的最常见函数、字典的典型应用以及每种语言的特定关注点。
第七章, 集合:无重复,讨论了集合数据结构的特定细节,包括集合理论的基础;与集合相关联的最常见函数、集合的典型应用以及每种语言的特定关注点。
第八章, 结构体:复杂类型,探讨了结构体或结构体数据结构的特定细节,包括与结构体相关联的最常见函数、结构体的典型应用以及每种语言的特定关注点。
第九章, 树:非线性结构,讨论了具有特定重点的二叉树的抽象树结构的特定细节。这次讨论将包括对与树相关联的最常见函数、树的典型应用以及每种语言的特定关注点的考察。
第十章, 堆:有序树,深入探讨了堆数据结构的特定细节,包括与堆相关联的最常见函数、堆的典型应用以及每种语言的特定关注点。
第十一章, 图:具有关系的值,介绍了图数据结构的特定细节,包括与图相关联的最常见函数、图的典型应用以及每种语言的特定关注点。
第十二章, 排序:从混乱中带来秩序,是一个高级章节,专注于排序的概念。这个概念将通过检查几个常见和流行的排序算法来介绍,特别关注操作成本、常见应用以及伴随每个算法的关注点。
第十三章,搜索:找到你需要的内容,也是一个高级章节,专注于在数据结构内搜索数据的概念。这一概念将通过检查几个常见和流行的搜索算法来介绍,特别关注操作成本、常见应用和每种语言的关注点。
你需要这本书
为了让你充分利用本书,你需要一台现代计算机。本书中的代码示例足够广泛,你可以使用 Mac、PC,甚至 Linux 机器。最终,你还需要一个可以在所选开发机器上运行的功能性开发环境,例如 Visual Studio、XCode、Eclipse 或 NetBeans。
本书面向的对象
本书面向任何希望提高与数据结构相关的根本编程概念知识和技能的人。更具体地说,本书面向新程序员或自学成才的程序员,以及经验从相对较新手到有三四年经验之间的程序员。本书专注于在移动软件开发中最常用的四种语言,因此读者也包括对移动软件开发感兴趣的人。读者应该对编程有一个基本的了解,包括如何创建控制台应用程序,以及如何使用他们首选开发语言的集成开发环境(IDE)。
习惯用法
在本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
本书每一章都将包含一个案例研究或类似的代码示例,这些示例将被分解并详细说明,以解释数据结构是如何应用的。因此,本书充满了代码示例。
代码块是这样设置的:
public boolean isEmpty()
{
return this._commandStack.empty();
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
func canAddUser(user: EDSUser) -> Bool
{
if (_users.contains(user))
{
return false;
} else {
return true;
}
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“第一个验证方法,isFull(),检查我们的栈是否已达到其容量。”
我们还将讨论与算法相关的算法和数学概念。当显示用大 O 符号表示的操作成本值时,它们将如下显示:“这虽然有些安慰,但是选择排序算法仍然有O(n²)复杂度成本。”
同样,当使用数学公式和算法时,它们将如下显示:“我们的算法将在S[0...4]中找到最小值,在这个例子中是 3,并将其放置在S[0...4]的开始处。”
注意
警告或重要注意事项以如下框的形式出现。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们读者的反馈总是受欢迎的。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载此书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送给您。
您可以通过以下步骤下载代码文件:
-
登录或注册我们的网站,使用您的电子邮件地址和密码。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与错误清单。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保使用最新版本解压或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Everyday-Data-Structures。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过版权@packtpub.com 与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 与我们联系,我们将尽力解决问题。
第一章:数据类型:基础结构
将数据类型称为“基础结构”可能听起来有点名不副实,但当你考虑到开发者使用数据类型来构建他们的类和集合时,情况并非如此。因此,在我们检查适当的数据结构之前,快速回顾数据类型是个好主意,因为这些都是接下来内容的基石。本章旨在从 10,000 英尺的高度回顾最常见和最重要的基本数据类型。如果你已经对这些基本概念有很强的理解,那么你可以自由地浏览本章,甚至根据需要完全跳过它。
在本章中,我们将涵盖以下主题:
-
数值数据类型
-
类型转换、窄化、和宽化
-
32 位和 64 位架构关注点
-
布尔数据类型
-
逻辑操作
-
运算顺序
-
嵌套操作
-
短路操作
-
字符串数据类型
-
字符串的可变性
数值数据类型
对以下四种语言(C#、Java、Objective-C 和 Swift)中所有数值数据类型的详细描述,可以很容易地涵盖一本自己的书。在这里,我们将仅回顾每种语言中最常见的数值类型标识符。评估这些类型的最简单方法是基于数据的基本大小,使用每种语言的示例作为讨论的框架。
小贴士
比较苹果与苹果!
当你为多个移动平台开发应用程序时,你应该意识到你使用的语言可能共享一个数据类型标识符或关键字,但在底层,这些标识符可能并不等价。同样,一种语言中的相同数据类型在另一种语言中可能有不同的标识符。例如,考察 16 位无符号整数的情况,有时被称为unsigned short。嗯,在 Objective-C 中它被称为unsigned short。在 C#中,我们谈论的是ushort,而 Swift 则称之为UInt16。另一方面,Java 为 16 位无符号整数提供的唯一选择是char,尽管这个对象通常不会用于数值。这些数据类型中的每一个都代表一个 16 位无符号整数;它们只是使用了不同的名称。这看起来可能是一个小问题,但如果你使用每个平台的本地语言为多个设备开发应用程序,为了保持一致性,你需要了解这些差异。否则,你可能会引入平台特定的错误,这些错误非常难以检测和诊断。
整数类型
整数数据类型定义为表示整数,可以是有符号的(负数、零或正数)或无符号的(零或正数)。每种语言都使用自己的标识符和关键字来表示整数类型,因此最容易从内存长度的角度来考虑。就我们的目的而言,我们只将讨论表示 8 位、16 位、32 位和 64 位内存对象的整数类型。
8 位数据类型,或更常见地称为 bytes,是我们将要考察的最小数据类型。如果你已经复习了二进制数学,你会知道一个 8 位的内存块可以表示 2⁸,或 256 个值。有符号字节可以在 -128 到 127,或 -(2⁷) 到 (2⁷) - 1 的范围内变化。无符号字节可以在 0 到 255,或 0 到 (2⁸) -1 的范围内变化。
16 位数据类型通常被称为 short,尽管这并不总是如此。这些类型可以表示 2¹⁶ 个值。有符号短整型可以在 -(2¹⁵) 到 (2¹⁵) - 1 的范围内变化。无符号短整型可以在 0 到 (2¹⁶) - 1 的范围内变化。
32 位数据类型最常见的是整数,尽管有时也被称为 long。整型可以表示 2³² 个值。有符号整数可以在 -2³¹ 到 2³¹ - 1 的范围内变化。无符号整数可以在 0 到 (2³²) - 1 的范围内变化。
最后,64 位数据类型最常见的是 long,尽管 Objective-C 将其识别为 long long。长整型可以表示 2⁶⁴ 个值。有符号长整型可以在 -(2⁶³) 到 (2⁶³) - 1 的范围内变化。无符号长整型可以在 0 到 (2⁶³) - 1 的范围内变化。
注意
注意,这些值恰好在我们将要使用的四种语言中是一致的,但某些语言可能会引入轻微的变化。熟悉一种语言的数字标识符的细节总是一个好主意。这尤其重要,如果你预期将处理涉及标识符极端值的情况。
C#
C# 将整数类型称为 整型。该语言提供了两种创建 8 位类型的机制,byte 和 sbyte。这两个容器可以存储多达 256 个值,无符号字节的范围从 0 到 255。有符号字节支持负值,因此范围从 -128 到 127:
// C#
sbyte minSbyte = -128;
byte maxByte = 255;
Console.WriteLine("minSbyte: {0}", minSbyte);
Console.WriteLine("maxByte: {0}", maxByte);
/*
Output
minSbyte: -128
maxByte: 255
*/
有趣的是,C# 对于较长的位标识符会反转其模式。它不是像 sbyte 一样在有符号标识符前加 s,而是将无符号标识符前加 u。因此,对于 16 位、32 位和 64 位标识符,我们有 short、ushort;int、uint;long 和 ulong 分别:
short minShort = -32768;
ushort maxUShort = 65535;
Console.WriteLine("minShort: {0}", minShort);
Console.WriteLine("maxUShort: {0}", maxUShort);
int minInt = -2147483648;
uint maxUint = 4294967295;
Console.WriteLine("minInt: {0}", minInt);
Console.WriteLine("maxUint: {0}", maxUint);
long minLong = -9223372036854775808;
ulong maxUlong = 18446744073709551615;
Console.WriteLine("minLong: {0}", minLong);
Console.WriteLine("maxUlong: {0}", maxUlong);
/*
Output
minShort: -32768
maxUShort: 65535
minInt: -2147483648
maxUint: 4294967295
minLong: -9223372036854775808
maxUlong: 18446744073709551615
*/
Java
Java 将整数类型作为其原始数据类型的一部分。Java 语言只为 8 位存储提供了一个构造,也称为 byte。它是一个有符号数据类型,因此它将表示从 -127 到 128 的值。Java 还提供了一个名为 Byte 的包装类,它包装原始值并提供对可解析字符串或文本的额外构造支持,这些字符串或文本可以转换为数值,例如文本 42。这种模式在 16 位、32 位和 64 位数据类型中重复:
//Java
byte myByte = -128;
byte bigByte = 127;
Byte minByte = new Byte(myByte);
Byte maxByte = new Byte("128");
System.out.println(minByte);
System.out.println(bigByte);
System.out.println(maxByte);
/*
Output
-128
127
127
*/
Java 与 C#共享所有整数数据类型的标识符,这意味着它也提供了byte、short、int和long标识符,用于 8 位、16 位、32 位和 64 位类型。Java 中的模式有一个例外是char标识符,它用于无符号 16 位数据类型。然而,需要注意的是,char数据类型通常仅用于 ASCII 字符赋值,而不是实际整数值:
//Short class
Short minShort = new Short(myShort);
Short maxShort = new Short("32767");
System.out.println(minShort);
System.out.println(bigShort);
System.out.println(maxShort);
int myInt = -2147483648;
int bigInt = 2147483647;
//Integer class
Integer minInt = new Integer(myInt);
Integer maxInt = new Integer("2147483647");
System.out.println(minInt);
System.out.println(bigInt);
System.out.println(maxInt);
long myLong = -9223372036854775808L;
long bigLong = 9223372036854775807L;
//Long class
Long minLong = new Long(myLong);
Long maxLong = new Long("9223372036854775807");
System.out.println(minLong);
System.out.println(bigLong);
System.out.println(maxLong);
/*
Output
-32768
32767
32767
-2147483648
2147483647
2147483647
-9223372036854775808
9223372036854775807
9223372036854775807
*/
在前面的代码中,请注意int类型和Integer类。与其他原始包装类不同,Integer与其支持的标识符名称不同。
此外,请注意long类型及其指定的值。在每种情况下,值都有后缀L。这是 Java 中long字面量的要求,因为编译器将所有数字字面量解释为 32 位整数。如果你想明确指定你的字面量大于 32 位,你必须附加后缀L。然而,当将字符串值传递给Long类构造函数时,这不是一个要求:
Long maxLong = new Long("9223372036854775807");
Objective-C
对于 8 位数据,Objective-C 提供了带符号和无符号格式的char数据类型。与其他语言一样,带符号的数据类型范围从-127 到 128,而无符号数据类型的范围从 0 到 255。开发者还有选择使用 Objective-C 的固定宽度对应类型int8_t和uint8_t。这种模式在 16 位、32 位和 64 位数据类型中重复。最后,Objective-C 还提供了NSNumber类作为每个整数类型的面向对象包装类:
注意
char或其他整数数据类型标识符与其固定宽度对应类型之间的区别是一个重要的区分。除了总是精确为 1 字节的char之外,Objective-C 中的其他每个整数数据类型的大小将根据实现和底层架构而变化。这是因为 Objective-C 基于 C,C 是为与各种底层架构以最高效率工作而设计的。虽然可以在运行时确定整数类型的确切长度,但在编译时,你只能确定short <= int <= long <= long long。
这就是固定宽度整数派上用场的地方。如果你需要更严格的字节数量控制,(u)int<n>_t数据类型允许你表示长度精确为 8 位、16 位、32 位或 64 位的整数。
//Objective-C
char number = -127;
unsigned char uNumber = 255;
NSLog(@"Signed char number: %hhd", number);
NSLog(@"Unsigned char uNumber: %hhu", uNumber);
//fixed width
int8_t fixedNumber = -127;
uint8_t fixedUNumber = 255;
NSLog(@"fixedNumber8: %hhd", fixedNumber8);
NSLog(@"fixedUNumber8: %hhu", fixedUNumber8);
NSNumber *charNumber = [NSNumber numberWithChar:number];
NSLog(@"Char charNumber: %@", [charNumber stringValue]);
/*
Output
Signed char number: -127
Unsigned char uNumber: 255
fixedNumber8: -127
fixedUNumber8: 255
Char charNumber: -127
*/
在前面的示例中,你可以看到,当在代码中使用char数据类型时,你必须指定unsigned标识符,例如unsigned char。然而,signed是默认的,并且可以省略,这意味着char类型等同于signed char。这种模式适用于 Objective-C 中每个整数数据类型。
Objective-C 中的更大整数类型包括 short 用于 16 位,int 用于 32 位,以及 long long 用于 64 位。每个这些类型都有一个遵循 (u)int<n>_t 模式的固定宽度对应类型。NSNumber 类中也为每种类型提供了支持方法:
//Larger Objective-C types
short aShort = -32768;
unsigned short anUnsignedShort = 65535;
NSLog(@"Signed short aShort: %hd", aShort);
NSLog(@"Unsigned short anUnsignedShort: %hu", anUnsignedShort);
int16_t fixedNumber16 = -32768;
uint16_t fixedUNumber16 = 65535;
NSLog(@"fixedNumber16: %hd", fixedNumber16);
NSLog(@"fixedUNumber16: %hu", fixedUNumber16);
NSNumber *shortNumber = [NSNumber numberWithShort:aShort];
NSLog(@"Short shortNumber: %@", [shortNumber stringValue]);
int anInt = -2147483648;
unsigned int anUnsignedInt = 4294967295;
NSLog(@"Signed Int anInt: %d", anInt);
NSLog(@"Unsigned Int anUnsignedInt: %u", anUnsignedInt);
int32_t fixedNumber32 = -2147483648;
uint32_t fixedUNumber32 = 4294967295;
NSLog(@"fixedNumber32: %d", fixedNumber32);
NSLog(@"fixedUNumber32: %u", fixedUNumber32);
NSNumber *intNumber = [NSNumber numberWithInt:anInt];
NSLog(@"Int intNumber: %@", [intNumber stringValue]);
long long aLongLong = -9223372036854775808;
unsigned long long anUnsignedLongLong = 18446744073709551615;
NSLog(@"Signed long long aLongLong: %lld", aLongLong);
NSLog(@"Unsigned long long anUnsignedLongLong: %llu", anUnsignedLongLong);
int64_t fixedNumber64 = -9223372036854775808;
uint64_t fixedUNumber64 = 18446744073709551615;
NSLog(@"fixedNumber64: %lld", fixedNumber64);
NSLog(@"fixedUNumber64: %llu", fixedUNumber64);
NSNumber *longlongNumber = [NSNumber numberWithLongLong:aLongLong];
NSLog(@"Long long longlongNumber: %@", [longlongNumber stringValue]);
/*
Output
Signed short aShort: -32768
Unsigned short anUnsignedShort: 65535
fixedNumber16: -32768
fixedUNumber16: 65535
Short shortNumber: -32768
Signed Int anInt: -2147483648
Unsigned Int anUnsignedInt: 4294967295
fixedNumber32: -2147483648
fixedUNumber32: 4294967295
Int intNumber: -2147483648
Signed long long aLongLong: -9223372036854775808
Unsigned long long anUnsignedLongLong: 18446744073709551615
fixedNumber64: -9223372036854775808
fixedUNumber64: 18446744073709551615
Long long longlongNumber: -9223372036854775808
*/
Swift
Swift 语言与其他语言类似,它为有符号和无符号整数提供了单独的标识符,例如 Int8 和 UInt8。这种模式适用于 Swift 中的每个整数数据类型,使其在记住哪个标识符适用于哪种类型方面可能是最简单的语言:
//Swift
var int8 : Int8 = -127
var uint8 : UInt8 = 255
print("int8: \(int8)")
print("uint8: \(uint8)")
/*
Output
int8: -127
uint8: 255
*/
在前面的例子中,我已明确使用 :Int8 和 : UInt8 标识符来声明数据类型以演示显式声明。在 Swift 中,也可以省略这些标识符,并允许 Swift 在运行时动态推断类型:
//Larger Swift types
var int16 : Int16 = -32768
var uint16 : UInt16 = 65535
print("int16: \(int16)")
print("uint16: \(uint16)")
var int32 : Int32 = -2147483648
var uint32 : UInt32 = 4294967295
print("int32: \(int32)")
print("uint32: \(uint32)")
var int64 : Int64 = -9223372036854775808
var uint64 : UInt64 = 18446744073709551615
print("int64: \(int64)")
print("uint64: \(uint64)")
/*
Output
int16: -32768
uint16: 65535
int32: -2147483648
uint32: 4294967295
int64: -9223372036854775808
uint64: 18446744073709551615
*/
我为什么需要了解这些?
你可能会问,我为什么需要了解这些数据类型的细节?难道我不能只声明一个 int 对象或类似的标识符,然后继续编写有趣的代码吗?现代计算机甚至移动设备提供了几乎无限的资源,所以这并不是什么大问题,对吧?
嗯,并不完全是这样。确实,在你的日常编程经验中的许多情况下,任何整数类型都适用。例如,在某个给定的一天,通过西弗吉尼亚州州立机动车辆管理局(DMV)办公室发行的牌照列表进行循环,可能会得到几十到几百个结果。你可以使用 short 或 long long 来控制 for 循环的迭代次数。无论如何,循环对你的系统性能的影响都非常小。
然而,如果你处理的数据集中每个离散的结果都可以适应 16 位类型,但你选择了一个 32 位标识符仅仅因为你习惯了这样做?你刚刚将管理该集合所需的内存量翻倍了。对于 100 或甚至 10 万个结果来说,这个决定可能无关紧要。然而,当你开始处理非常大的数据集时,有数十万甚至数百万个离散结果时,这样的设计决策可能会对系统性能产生巨大影响。
单精度浮点数
单精度浮点数,或更常见地称为 floats,是 32 位浮点容器,可以存储比整数类型具有更高精度的值,通常为六到七位有效数字。许多语言使用 float 关键字或标识符来表示单精度浮点值,我们讨论的四种语言也是如此。
你应该意识到浮点数会受到舍入误差的影响,因为它们不能精确地表示十进制数。浮点类型的算术是一个相当复杂的话题,其细节对于任何给定日子的大多数开发者来说并不相关。然而,熟悉每种语言中底层科学以及实现的细节仍然是一个好的实践。
注意
由于我绝不是该领域的专家,这次讨论只会触及这些类型背后的科学表面,我们甚至不会开始涉及算术。然而,在这个领域确实有真正的专家,我强烈建议你查看本章末尾的 附加资源 部分中列出的他们的一些作品。
C#
在 C# 中,float 关键字标识 32 位浮点数。C# 的 float 数据类型具有约 -3.4 × 10³⁸ 到 +3.4 × 10³⁸ 的范围和 6 位有效数字的精度:
//C#
float piFloat = 3.14159265358979323846264338327f;
Console.WriteLine("piFloat: {0}", piFloat);
/*
Output
piFloat: 3.141593
*/
当你检查前面的代码时,你会注意到 float 值赋值带有 f 后缀。这是因为,与其他基于 C 的语言一样,C# 默认将赋值右侧的实数文字视为 double(稍后讨论)。如果你在赋值中省略 f 或 F 后缀,你将收到编译错误,因为你正在尝试将双精度值赋给单精度类型。
此外,请注意最后一位的舍入误差。我们用 30 位有效数字表示的 π 填充了 piFloat 对象。然而,float 只能保留 6 位有效数字,因此软件将之后的数字四舍五入。当 π 计算到 6 位有效数字时,我们得到 3.141592,但由于这个限制,我们的 float 值现在是 3.141593。
Java
与 C# 一样,Java 使用 float 标识符表示浮点数。在 Java 中,float 的近似范围为 -3.4 × 10³⁸ 到 +3.4 × 10³⁸,并且具有 6 或 7 位有效数字的精度:
//Java
float piFloat = 3.141592653589793238462643383279f;
System.out.println(piFloat);
/*
Output
3.1415927
*/
当你检查前面的代码时,你会注意到浮点数值赋值带有 f 后缀。这是因为,与其他基于 C 的语言一样,Java 默认将赋值右侧的实数文字视为 double。如果你在赋值中省略 f 或 F 后缀,你将收到编译错误,因为你正在尝试将双精度值赋给单精度类型。
Objective-C
Objective-C 使用 float 标识符表示浮点数。在 Objective-C 中,float 的近似范围为 -3.4 × 10³⁸ 到 +3.4 × 10³⁸,并且具有 6 位有效数字的精度:
//Objective-C
float piFloat = 3.14159265358979323846264338327f;
NSLog(@"piFloat: %f", piFloat);
NSNumber *floatNumber = [NSNumber numberWithFloat:piFloat];
NSLog(@"floatNumber: %@", [floatNumber stringValue]);
/*
Output
piFloat: 3.141593
floatNumber: 3.141593
*/
当你检查前面的代码时,你会注意到 float 值赋值有 f 后缀。这是因为,像其他基于 C 的语言一样,Swift 默认将赋值右侧的实数字面量视为 double。如果你在赋值时省略 f 或 F 后缀,你将收到编译错误,因为你正在尝试将双精度值赋给单精度类型。
此外,请注意最后一位的舍入误差。我们用 pi 以 30 位有效数字的形式填充了 piFloat 对象,但 float 只能保留六位有效数字,因此软件将之后的数字都四舍五入。当 pi 以六位有效数字计算时,我们得到 3.141592,但我们的 float 值现在变成了 3.141593,这是由于这种限制。
Swift
Swift 使用 float 标识符表示浮点数。在 Swift 中,float 的近似范围为 -3.4 × 10³⁸ 到 +3.4 × 10³⁸,并且具有六位有效数字的精度:
//Swift
var floatValue : Float = 3.141592653589793238462643383279
print("floatValue: \(floatValue)")
/*
Output
floatValue: 3.141593
*/
当你检查前面的代码时,你会注意到 float 值赋值有 f 后缀。这是因为,像其他基于 C 的语言一样,Swift 默认将赋值右侧的实数字面量视为 double。如果你在赋值时省略 f 或 F 后缀,你将收到编译错误,因为你正在尝试将双精度值赋给单精度类型。
此外,请注意最后一位的舍入误差。我们用 pi 以 30 位有效数字的形式填充了 floatValue 对象,但 float 只能保留六位有效数字,因此软件将之后的数字都四舍五入。当 pi 以六位有效数字计算时,我们得到 3.141592,但我们的 float 值现在变成了 3.141593,这是由于这种限制。
双精度浮点
双精度浮点数,或更常见地称为 doubles,是 64 位浮点值,允许存储比整数类型具有更高的精度,通常为 15 位有效数字。许多语言使用 double 标识符表示双精度浮点值,我们讨论的四种语言也是如此。
注意
在大多数情况下,选择float而不是double通常不会有什么影响,除非内存空间是一个考虑因素,在这种情况下,你将尽可能选择float。许多人认为在大多数情况下float比double性能更好,一般来说,这是正确的。然而,还有其他情况下double会比float性能更好。现实是每种类型的效率都会根据具体案例而变化,这些标准太多,无法在本讨论的上下文中详细说明。因此,如果你的特定应用程序确实需要达到顶峰效率,你应该仔细研究需求和环境因素,并决定最适合你情况的选择。否则,只需使用任何能完成工作的容器,然后继续前进。
C#
在 C#中,double关键字标识 64 位浮点值。C#的double具有大约的范围为±5.0 × 10^(−324)到±1.7 × 10³⁰⁸,并且精度为 14 或 15 位有效数字:
//C#
double piDouble = 3.14159265358979323846264338327;
double wholeDouble = 3d;
Console.WriteLine("piDouble: {0}", piDouble);
Console.WriteLine("wholeDouble: {0}", wholeDouble);
/*
Output
piDouble: 3.14159265358979
wholeDouble: 3
*/
当你检查前面的代码时,你会注意到wholeDouble值赋值有d后缀。这是因为,像其他基于 C 的语言一样,C#默认将赋值右侧的实数字面量视为整数。如果你在赋值时省略d或D后缀,你将收到编译错误,因为你试图将一个整数值赋给双精度浮点类型。
此外,请注意最后一位的舍入误差。我们使用π到 30 位有效数字来填充piDouble对象,但double只能保留 14 位有效数字,因此软件将之后的数字四舍五入。当π计算到 15 位有效数字时,我们得到 3.141592653589793,但由于这个限制,我们的float值现在是 3.14159265358979。
Java
在 Java 中,double关键字标识 64 位浮点值。Java 的double具有大约的范围为±4.9 × 10^(−324)到±1.8 × 10³⁰⁸和 15 或 16 位有效数字的精度:
double piDouble = 3.141592653589793238462643383279;
System.out.println(piDouble);
/*
Output
3.141592653589793
*/
当你检查前面的代码时,请注意最后一位的舍入误差。我们使用π到 30 位有效数字来填充piDouble对象,但double只能保留 15 位有效数字,因此软件将之后的数字四舍五入。当π计算到 15 位有效数字时,我们得到 3.1415926535897932,但由于这个限制,我们的float值现在是 3.141592653589793。
Objective-C
Objective-C 也使用double标识符表示 64 位浮点值。Objective-C 的double具有大约的范围为 2.3E^(-308)到 1.7E³⁰⁸和 15 位有效数字的精度。Objective-C 通过提供称为long double的更精确的double版本,将精度提升了一步。long double标识符用于 80 位存储容器,其范围为 3.4E^(-4932)到 1.1E⁴⁹³²和 19 位有效数字的精度:
//Objective-C
double piDouble = 3.14159265358979323846264338327;
NSLog(@"piDouble: %.15f", piDouble);
NSNumber *doubleNumber = [NSNumber numberWithDouble:piDouble];
NSLog(@"doubleNumber: %@", [doubleNumber stringValue]);
/*
Output
piDouble: 3.141592653589793
doubleNumber: 3.141592653589793
*/
在我们前面的示例中,请注意最后一位的舍入误差。我们使用 pi 的 30 位有效数字填充了 piDouble 对象,但 double 只能保留 15 位有效数字,因此软件将之后的数字四舍五入。当 pi 计算到 15 位有效数字时,我们得到 3.1415926535897932,但由于这个限制,我们的 float 值现在是 3.141592653589793。
Swift
Swift 使用 double 标识符表示 64 位浮点值。在 Swift 中,double 的近似范围是 2.3E^(-308) 到 1.7E³⁰⁸,精度为 15 位有效数字。请注意,根据 Apple 对 Swift 的文档,当 float 或 double 类型都适用时,推荐使用 double:
//Swift
var doubleValue : Double = 3.141592653589793238462643383279
print("doubleValue: \(doubleValue)")
/*
Output
doubleValue: 3.14159265358979
*/
在我们前面的示例中,请注意最后一位的舍入误差。我们使用 pi 的 30 位有效数字填充了 doubleValue 对象,但 double 只能保留 15 位有效数字,因此软件将之后的数字四舍五入。当 pi 计算到 15 位有效数字时,我们得到 3.141592653589793,但由于这个限制,我们的 float 值现在是 3.141592653589793。
货币
由于浮点算术固有的不精确性,这是基于它们基于二进制算术的事实,浮点数和 double 无法准确表示我们用于货币的十进制倍数。将货币表示为 float 或 double 最初可能看起来是个好主意,因为软件会四舍五入你的算术中的微小误差。然而,当你开始在这些不精确的结果上执行更多和更复杂的算术运算时,你的精度误差将开始累积,并导致严重的不准确性和难以追踪的漏洞。这使得 float 和 double 数据类型在需要完美精度(10 的倍数)的货币处理中不足。幸运的是,我们讨论的每种语言都提供了一种处理货币以及需要高精度十进制值和计算的其它算术问题的机制。
C#
C# 使用 decimal 关键字来表示精确的浮点值。在 C# 中,decimal 的范围是 ±1.0 x 10^(-28) 到 ±7.9 x 10²⁸,精度为 28 或 29 位有效数字:
var decimalValue = NSDecimalNumber.init(string:"3.141592653589793238462643383279")
print("decimalValue \(decimalValue)")
/*
Output
piDecimal: 3.1415926535897932384626433833
*/
在前面的示例中,请注意我们使用 pi 的 30 位有效数字填充了 decimalValue 对象,但框架将其四舍五入到 28 位有效数字。
Java
Java 以 BigDecimal 类的形式提供了一个面向对象的解决方案来解决货币问题:
BigDecimal piDecimal = new BigDecimal("3.141592653589793238462643383279");
System.out.println(piDecimal);
/*
Output
3.141592653589793238462643383279
*/
在前面的示例中,我们使用一个接受字符串表示的十进制值作为参数的构造函数初始化 BigDecimal 类。当程序运行时,输出证明 BigDecimal 类没有丢失任何我们预期的精度,返回了 30 位有效数字的 pi。
Objective-C
Objective-C 也以 NSDecimalNumber 类的形式提供了一个面向对象的解决方案来解决货币问题:
//Objective-C
NSDecimalNumber *piDecimalNumber = [[NSDecimalNumber alloc] initWithDouble:3.14159265358979323846264338327];
NSLog(@"piDecimalNumber: %@", [piDecimalNumber stringValue]);
/*
Output
piDecimalNumber: 3.141592653589793792
*/
Swift
Swift 还提供了一个面向对象的解决方案来解决货币问题,并且它与 Objective-C 中使用的同一个类相同,即 NSDecimalNumber 类。Swift 版本初始化略有不同,但与 Objective-C 的对应版本具有相同的功能:
var decimalValue = NSDecimalNumber.init(string:"3.141592653589793238462643383279")
print("decimalValue \(decimalValue)")
/*
Output
decimalValue 3.141592653589793238462643383279
*/
注意,在 Objective-C 和 Swift 的示例中,精度都保留到 30 位有效数字,这证明了 NSDecimalNumber 类在处理货币和其他十进制值方面是优越的。
小贴士
在充分披露的精神下,使用这些自定义类型有一个简单且可以说是更优雅的替代方案。你可以直接使用 int 或 long 进行货币计算,并按分而不是按美元计数:
//C# long total = 316;
类型转换
在计算机科学领域,类型转换或类型转换意味着将一个对象或数据类型的实例转换为另一个。例如,假设你调用了一个返回整数值的方法,但你需要使用该值在另一个需要将 long 值作为输入参数的方法中。由于整数值根据定义存在于允许的 long 值范围内,因此 int 值可以被重新定义为 long。
这种转换可以通过隐式转换(有时称为强制转换)或显式转换(通常称为类型转换)来完成。要完全理解类型转换,我们还需要了解静态和动态语言之间的区别。
静态类型语言与动态类型语言
静态类型语言将在编译时执行其类型检查。这意味着,当你尝试构建你的解决方案时,编译器将验证并强制执行应用于应用程序中类型的每个约束。如果它们没有被强制执行,你将收到错误,并且应用程序将无法构建。C#、Java 和 Swift 都是静态类型语言。
动态类型语言,另一方面,在运行时进行大多数或所有的类型检查。这意味着应用程序可能构建得很好,但如果开发者没有在编写代码时小心谨慎,那么在应用程序实际运行时可能会遇到问题。Objective-C 是一种动态类型语言,因为它使用静态类型对象和动态类型对象的混合。本章前面讨论的用于数值的普通 C 对象都是静态类型对象的例子,而 Objective-C 类 NSNumber 和 NSDecimalNumber 都是动态类型对象的例子。以下是一个 Objective-C 代码示例:
double myDouble = @"chicken";
NSNumber *myNumber = @"salad";
编译器将在第一行抛出错误,指出 初始化 'double' 时使用了一个不兼容类型的表达式 'NSString *'。这是因为 double 是一个普通的 C 对象,它是静态类型的。编译器在我们甚至开始构建之前就知道如何处理这个静态类型的对象,所以你的构建将失败。
然而,编译器只会在第二行抛出警告,指出初始化 'NSNumber *' 的指针类型不兼容,表达式类型为 'NSString *'。这是因为NSNumber是 Objective-C 类,它是动态类型的。编译器足够智能,能够捕捉到您的错误,但它将允许构建成功(除非您已在构建设置中指示编译器将警告视为错误)。
小贴士
虽然在前面的示例中,运行时即将发生的崩溃是明显的,但有些情况下,即使有警告,您的应用程序也能正常工作。然而,无论您使用的是哪种编程语言,始终在继续编写新代码之前一致地清理代码警告都是一个好主意。这有助于保持代码整洁,并避免任何难以诊断的运行时错误。
在那些不适宜立即处理警告的罕见情况下,您应该清楚地记录代码并解释警告的来源,以便其他开发者能够理解您的推理。作为最后的手段,您可以利用宏或预处理器(预编译器)指令,这些指令可以逐行抑制警告。
隐式和显式转换
隐式转换在您的源代码中不需要任何特殊的语法。这使得隐式转换变得相对方便。以下是一个 C#中的代码示例:
int a = 10;
double b = a++;
在这种情况下,由于a可以被定义为int和double两种类型,因此转换为double类型是完全可接受的,因为我们已经手动定义了这两种类型。然而,由于隐式转换不一定手动定义它们的类型,编译器无法始终确定哪些约束适用于转换,因此无法在编译时检查这些约束。这使得隐式转换也具有一定的危险性。以下是一个同样在 C#中的代码示例:
double x = "54";
这是一种隐式转换,因为你没有告诉编译器如何处理字符串值。在这种情况下,当尝试构建应用程序时,转换将失败,编译器将抛出错误,指出无法隐式转换类型 'string' 到 'double'。现在,考虑这个示例的显式转换版本:
double x = double.Parse("42");
Console.WriteLine("40 + 2 = {0}", x);
/*
Output
40 + 2 = 42
*/
这种转换是显式的,因此是类型安全的,假设字符串值是可解析的。
扩展和收缩
在两种类型之间进行转换时,一个重要的考虑因素是变化的结果是否在目标数据类型的范围内。如果您的源数据类型支持的字节比目标数据类型多,则这种转换被认为是收缩转换。
窄化转换要么是无法证明始终成功的转换,要么是已知可能丢失信息的转换。例如,从浮点数到整数的转换将导致信息丢失(在这种情况下是精度),因为结果将被四舍五入到最接近的整数。在大多数静态类型语言中,窄化转换不能隐式执行。以下是一个例子,借鉴了本章前面提到的 C# 单精度和双精度示例:
//C#
piFloat = piDouble;
在这个例子中,编译器将抛出一个错误,指出“无法隐式转换类型 'double' 到 'float'”。并且存在显式转换(你是否遗漏了一个转换?)。编译器将其视为窄化转换,并将精度损失视为错误。错误消息本身很有帮助,并建议显式转换作为我们问题的潜在解决方案:
//C#
piFloat = (float)piDouble;
我们现在已经明确地将双精度值 piDouble 转换为 float 类型,编译器不再关心精度损失的问题。
如果您的源数据类型支持的字节少于您的目标数据类型,则该转换被认为是宽化转换。宽化转换将保留源对象的值,但可能会以某种方式更改其表示。大多数静态类型语言将允许隐式宽化转换。让我们再次借鉴我们之前的 C# 示例:
//C#
piDouble = piFloat;
在这个例子中,编译器对隐式转换完全满意,应用将构建。让我们进一步扩展这个例子:
//C#
piDouble = (double)piFloat;
这种显式转换提高了可读性,但以任何方式都没有改变语句的本质。编译器也认为这种格式完全可接受,尽管它可能有些冗长。除了提高可读性之外,在宽化转换时显式转换对您的应用程序没有任何增加。因此,如果您想在宽化转换时使用显式转换,这是一个个人偏好的问题。
布尔数据类型
布尔数据类型旨在表示二进制值,通常用 1 和 0、true 和 false 或甚至 YES 和 NO 表示。布尔类型用于表示基于布尔代数的真值逻辑。这仅仅是一种说法,即布尔值用于条件语句,如 if 或 while,以评估逻辑或有条件地重复执行。
等于操作包括任何比较两个实体值值的操作。等价操作符包括:
-
==表示等于 -
!=表示不等于
关系操作包括任何测试两个实体之间关系的操作。关系操作符包括:
-
>表示大于 -
>=表示大于或等于 -
<表示小于 -
<=表示小于或等于
逻辑运算包括程序中评估和操作布尔值的任何操作。主要有三个逻辑运算符,即AND、OR和NOT。另一个稍微不太常用的运算符是异或,或称为 XOR 运算符。所有布尔函数和语句都可以使用这四个基本运算符构建。
AND 运算符是最为严格的比较运算符。给定两个布尔变量 A 和 B,AND 运算符只有在 A 和 B 都为true时才会返回true。布尔变量通常使用称为真值表的工具进行可视化。以下为 AND 运算符的真值表:
| A | B | A ^ B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
此表展示了 AND 运算符。在评估条件语句时,0 被视为false,而任何其他值都视为true。只有当 A 和 B 的值都为true时,A 与 B 的运算结果才为true。
OR 运算符是包含运算符。给定两个布尔变量 A 和 B,OR 运算符在 A 或 B 为true时返回true,包括 A 和 B 都为true的情况。以下为 OR 运算符的真值表:
| A | B | A v B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
接下来,NOT A 运算符在 A 为false时为true,在 A 为true时为false。以下为 NOT 运算符的真值表:
| A | !A |
|---|---|
| 0 | 1 |
| 1 | 0 |
最后,XOR 运算符在 A 或 B 为true但不同时为true时为true。另一种说法是,XOR 在 A 和 B 不同时为true。在许多情况下,以这种方式评估表达式非常有用,因此大多数计算机架构都包括它。以下为 XOR 运算符的真值表:
| A | B | A XOR B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
运算符优先级
就像算术一样,比较和布尔运算也有运算符优先级。这意味着架构将赋予一个运算符比另一个运算符更高的优先级。一般来说,所有语言的布尔运算顺序如下:
-
括号
-
关系运算符
-
等式运算符
-
位运算符(未讨论)
-
NOT
-
AND
-
OR
-
XOR
-
三元运算符
-
赋值运算符
在处理布尔值时,理解运算符优先级非常重要,因为错误地理解架构将如何评估复杂的逻辑运算会在代码中引入你无法解决的错误。如有疑问,请记住,就像算术中的括号一样,优先级最高,括号内的内容将首先被评估。
短路
如你所知,AND 运算符仅在两个操作数都为true时返回true,而 OR 运算符只要有一个操作数为true就会返回true。这些特性有时使得仅通过评估其中一个操作数就能确定表达式的结果成为可能。当你的应用程序在确定表达式的整体结果后立即停止评估时,这被称为短路。你可能会在代码中使用短路的三种主要原因。
首先,短路可以通过限制代码必须执行的操作数量来提高应用程序的性能。其次,当后续的操作可能基于先前操作数的值生成错误时,短路可以在达到更高风险的操作数之前停止执行。最后,短路可以通过消除嵌套逻辑语句的需要来提高代码的可读性和复杂性。
C#
C#使用bool关键字作为System.Boolean的别名,并存储true和false值:
//C#
bool a = true;
bool b = false;
bool c = a;
Console.WriteLine("a: {0}", a);
Console.WriteLine("b: {0}", b);
Console.WriteLine("c: {0}", c);
Console.WriteLine("a AND b: {0}", a && b);
Console.WriteLine("a OR b: {0}", a || b);
Console.WriteLine("NOT a: {0}", !a);
Console.WriteLine("NOT b: {0}", !b);
Console.WriteLine("a XOR b: {0}", a ^ b);
Console.WriteLine("(c OR b) AND a: {0}", (c || b) && a);
/*
Output
a: True
b: False
c: True
a AND b: False
a OR b: True
NOT a: False
NOT b: True
a XOR b: True
(c OR b) AND a: True
*/
Java
Java 使用boolean关键字表示原始布尔数据类型。Java 还提供了一个Boolean包装类来表示相同的原始类型:
//Java
boolean a = true;
boolean b = false;
boolean c = a;
System.out.println("a: " + a);
System.out.println("b: " + b);
System.out.println("c: " + c);
System.out.println("a AND b: " + (a && b));
System.out.println("a OR b: " + (a || b));
System.out.println("NOT a: " + !a);
System.out.println("NOT b: " + !b);
System.out.println("a XOR b: " + (a ^ b));
System.out.println("(c OR b) AND a: " + ((c || b) && a));
/*
Output
a: true
b: false
c: true
a AND b: false
a OR b: true
NOT a: false
NOT b: true
a XOR b: true
(c OR b) AND a: true
*/
Objective-C
Objective-C 使用BOOL标识符来表示布尔值:
//Objective-C
BOOL a = YES;
BOOL b = NO;
BOOL c = a;
NSLog(@"a: %hhd", a);
NSLog(@"b: %hhd", b);
NSLog(@"c: %hhd", c);
NSLog(@"a AND b: %d", a && b);
NSLog(@"a OR b: %d", a || b);
NSLog(@"NOT a: %d", !a);
NSLog(@"NOT b: %d", !b);
NSLog(@"a XOR b: %d", a ^ b);
NSLog(@"(c OR b) AND a: %d", (c || b) && a);
/*
Output
a: 1
b: 0
c: 1
a AND b: 0
a OR b: 1
NOT a: 0
NOT b: 1
a XOR b: 1
(c OR b) AND a: 1
*/
注意
事实上,布尔数据类型给了 Objective-C 另一个证明它比其对手更复杂的机会。该语言没有提供一个标识符或类来表示逻辑值,而是提供了五个。为了简单起见(并且因为我的编辑器不会给我额外的页面),我们在这篇文章中只使用BOOL。如果你想了解更多,我鼓励你查看本章末尾的附加资源部分。
Swift
Swift 使用Bool关键字表示原始布尔数据类型:
//Swift
var a : Bool = true
var b : Bool = false
var c = a
print("a: \(a)")
print("b: \(b)")
print("c: \(c)")
print("a AND b: \(a && b)")
print("a OR b: \(a || b)")
print("NOT a: \(!a)")
print("NOT b: \(!b)")
print("a XOR b: \(a != b)")
print("(c OR b) AND a: \((c || b) && a)")
/*
Output
a: true
b: false
c: true
a AND b: false
a OR b: true
NOT a: false
NOT b: true
a XOR b: true
(c OR b) AND a: true
*/
在前面的例子中,布尔对象c并未显式声明为Bool,但它被隐式地指定为Bool。用 Swift 的话说,在这种情况下数据类型已经被推断。此外,请注意 Swift 不提供特定的 XOR 运算符,所以如果你需要这种比较,你应该使用(a != b)模式。
小贴士
Objective-C nil 值
在 Objective-C 中,值nil也评估为false。尽管其他语言必须小心处理 NULL 对象,但 Objective-C 在尝试在 nil 对象上执行操作时不会崩溃。从我个人的经验来看,这可能会让在学习 Objective-C 之前先学习了 C#或 Java 的开发者感到有些困惑,因为他们期望未处理的 NULL 对象会导致他们的应用崩溃。然而,Objective-C 开发者通常利用这种行为来获得优势。很多时候,仅仅检查一个对象是否为nil在逻辑上就能确认操作是否成功,从而节省了你编写繁琐的逻辑比较。
字符串
字符串并不是精确的数据类型,尽管作为开发者,我们经常将它们当作这样。实际上,字符串只是值是文本的对象;在底层,字符串包含一个只读的 char 对象的顺序集合。字符串对象的这种只读性质使得字符串 不可变,这意味着一旦在内存中创建,对象就不能被更改。
重要的是要理解,更改任何不可变对象,不仅仅是字符串,意味着你的程序实际上是在内存中创建一个新的对象并丢弃旧的一个。这比简单地更改内存地址中的值要复杂得多,需要更多的处理。将两个字符串合并在一起称为 连接,这是一个成本更高的过程,因为你是在创建新对象之前先丢弃了两个对象。如果你发现你经常编辑字符串值,或者经常将字符串连接在一起,请注意,你的程序可能没有它本可以做到的那样高效。
在 C#、Java 和 Objective-C 中,字符串是严格不可变的。有趣的是,Swift 文档将字符串称为可变的。然而,行为与 Java 类似,即当字符串被修改时,它会在赋值给另一个对象时被复制。因此,尽管文档说不同,但在 Swift 中字符串实际上也是不可变的。
C#
C# 使用字符串关键字来声明字符串类型:
//C#
string one = "One String";
Console.WriteLine("One: {0}", one);
String two = "Two String";
Console.WriteLine("Two: {0}", two);
String red = "Red String";
Console.WriteLine("Red: {0}", red);
String blue = "Blue String";
Console.WriteLine("Blue: {0}", blue);
String purple = red + blue;
Console.WriteLine("Concatenation: {0}", purple);
purple = "Purple String";
Console.WriteLine("Whoops! Mutation: {0}", purple);
Java
Java 使用系统类 String 来声明字符串类型:
//Java
String one = "One String";
System.out.println("One: " + one);
String two = "Two String";
System.out.println("Two: " + two);
String red = "Red String";
System.out.println("Red: " + red);
String blue = "Blue String";
System.out.println("Blue: " + blue);
String purple = red + blue;
System.out.println("Concatenation: " + purple);
purple = "Purple String";
System.out.println("Whoops! Mutation: " + purple);
Objective-C
Objective-C 提供了 NSString 类来创建字符串对象:
//Objective-C
NSString *one = @"One String";
NSLog(@"One: %@", one);
NSString *two = @"Two String";
NSLog(@"Two: %@", two);
NSString *red = @"Red String";
NSLog(@"Red: %@", red);
NSString *blue = @"Blue String";
NSLog(@"Blue: %@", blue);
NSString *purple = [[NSArray arrayWithObjects:red, blue, nil] componentsJoinedByString:@""];
NSLog(@"Concatenation: %@", purple);
purple = @"Purple String";
NSLog(@"Whoops! Mutation: %@", purple);
当你查看 Objective-C 的示例时,你可能会想知道为什么我们需要为创建紫色对象编写那么多额外的代码。这段代码是必要的,因为 Objective-C 并没有提供像我们使用的其他三种语言那样的字符串连接快捷机制。因此,在这种情况下,我选择将两个字符串放入一个数组中,然后调用 NSArray 方法 componentsJoinedByString:。我也可以选择使用 NSMutableString 类,它提供了一个用于连接字符串的方法。然而,由于我们讨论的语言中并没有涉及可变字符串类,所以我选择了不使用这种方法。
Swift
Swift 提供了 String 类来创建字符串对象:
//Swift
var one : String = "One String"
print("One: \(one)")
var two : String = "Two String"
print("Two: \(two)")
var red : String = "Red String"
print("Red: \(red)")
var blue : String = "Blue String"
print("Blue: \(blue)")
var purple : String = red + blue
print("Concatenation: \(purple)")
purple = "Purple String";
print("Whoops! Mutation: \(purple)")
/*
Output from each string example:
One: One String
Two: Two String
Red: Red String
Blue: Blue String
Concatenation: Red StringBlue String
Whoops! Mutation: Purple String
*/
摘要
在本章中,你学习了在四种最常见的移动开发语言中,程序员可用的基本数据类型。数值和浮点数据类型的特性和操作既取决于底层架构,也取决于语言的规范。你还学习了如何将对象从一个类型转换为另一个类型,以及转换的类型是如何根据源数据和目标数据类型的大小定义为宽转换或窄转换的。接下来,我们讨论了布尔类型及其在比较器中如何影响程序流程和执行。在这里,我们讨论了运算符的优先级顺序和嵌套操作。你还学习了如何使用短路来提高代码的性能。最后,我们考察了String数据类型以及与可变对象一起工作的含义。
第二章。数组:基础集合
很常见,我们的应用程序在运行时需要在内存中存储多个用户数据或对象。一个解决方案是在我们的各种类中定义多个字段(属性)来存储我们所需的数据点。不幸的是,即使在处理最简单的流程时,这种方法也会很快变得无效。我们可能需要处理太多的字段,或者我们根本无法在编译时预测我们项目的所有动态需求。
解决这个问题的方法之一是使用数组。数组是简单的数据集合,由于许多其他数据结构都是建立在它们之上,因此它们是你日常编程经验中遇到的最常见的数据结构之一。
数组是包含特定类型固定数量项的容器。在 C 语言及其后裔语言中,数组的大小在创建数组时确定,并且从那时起长度保持固定。数组中的每个项称为元素,每个元素都可以通过其索引号访问。一般来说,数组是可以通过运行时确定的索引选择的数据项集合。
在本章中,我们将涵盖以下主题:
-
定义
-
可变数组与不可变数组
-
数组的示例应用
-
线性搜索
-
基本数组
-
对象数组
-
混合数组
-
多维数组
-
锯齿形数组
注意
注意,大多数语言中的数组使用所谓的零基索引,这意味着数组中的第一个项目索引为 0,第二个为 1,依此类推。
索引错误发生在源代码试图访问一个比实际想要访问的项目索引远一点的给定索引时。这种错误对于新手和经验丰富的程序员都是常见的,并且往往会导致索引超出范围或索引超出界限的运行时错误。

小贴士
编译时间和运行时间
在编译型编程语言(与解释型语言相对)中,编译时间和运行时间之间的区别仅仅是应用程序编译和运行之间的区别。在编译过程中,开发者编写的高级源代码被输入到另一个程序(通常称为编译器,奇怪的是)。编译器检查源代码是否有正确的语法,确认类型约束得到执行,优化代码,然后生成目标架构可以利用的低级语言的可执行文件。如果一个程序成功编译,我们知道源代码是良好形成的,生成的可执行文件可以启动。请注意,开发者有时会使用术语编译时间来包括编写源代码的实际过程,尽管这在语义上是不正确的。
在运行时,编译后的代码在执行环境中运行,但它仍然可能遇到错误。例如,尝试除以零、取消引用空内存指针、内存不足或尝试访问不存在的资源都可能导致你的应用程序崩溃,如果你的源代码没有优雅地处理这些场景。
可变数组与不可变数组
通常,基于 C 语言的编程语言共享许多相同的根本特性。例如,在 C 语言中,一旦创建了一个普通数组,其大小就不能改变。由于我们在这里检查的四种语言都是基于 C 语言的,因此我们将要处理的数组也将具有固定长度。然而,尽管数组的大小不能改变,但在数组创建后,结构的内容可以改变。
那么,数组是可变的还是不可变的?在可变性的术语中,我们说普通 C 数组是 不可变的,因为一旦创建结构本身就不能改变。因此,通常不建议将普通 C 数组用于除静态数据集之外的其他用途。这是因为,每当数据集发生变化时,你的程序需要将修改后的数据复制到一个新的数组对象中,并丢弃旧的一个,这两个操作都是昂贵的操作。
在高级语言中,你将要处理的数组对象大多数不是普通的 C 数组,而是为开发者的便利而创建的包装类。数组包装类封装了底层数据结构的复杂性,以便于处理幕后重负载的方法和暴露数据集特性的属性。
小贴士
无论何时,一种语言为特定类型或数据结构提供了一个包装类,你应该利用它。这些比编写自己的实现更方便,并且通常更可靠。
案例研究:登录到网络服务的用户
业务问题:一位开发者创建了一个应用程序,用于将移动用户登录到特定的网络服务。由于服务器硬件的限制,该网络服务在任何给定时间只能允许 30 个连接的用户。因此,开发者需要一种方法来跟踪和限制连接到服务的移动设备用户数量。为了避免允许重复用户登录并超载服务,简单的连接计数是不够的,因为开发者将无法区分每个连接的所有者。维护一个表示已登录用户的对象数组被选为解决方案的核心组件。
C#
using System;
//...
User[] _users;
public LoggedInUserArray ()
{
User[] users = new User[0];
_users = users;
}
在前面的例子中,有几个重要的部分我们需要注意。首先,我们将User实例存储在一个名为_users的私有类字段中。接下来,构造函数正在实例化一个新的User对象数组。最后,我们将数组实例化为长度为 0 的集合,并将其分配给我们的私有后端字段。这是因为我们的数组还没有分配任何用户,我们不希望通过尝试跟踪空值来进一步复杂化此代码。在现实世界的例子中,你可能会选择在一行中实例化和分配私有后端字段:
_users = new User[0];
前一个例子更为冗长,因此更易于阅读。然而,使用更简洁的例子则占用的空间更少。两种方法都可以行得通。接下来,我们将探讨一种方法,允许我们将User对象添加到数组中:
bool CanAddUser(User user)
{
bool containsUser = false;
foreach (User u in _users)
{
if (user == u)
{
containsUser = true;
break;
}
}
if (containsUser)
{
return false;
} else {
if (_users.Length >= 30)
{
return false;
} else {
return true;
}
}
}
在这里,我们引入了一个私有方法来进行某种形式的验证。此方法的目的在于确定在此时刻将用户添加到数组中是否是一个有效的操作。首先,我们声明了一个名为containsUser的bool变量。我们将使用此标志来指示数组是否已经包含正在传递的User对象。接下来,我们执行了一个for循环来检查数组中的每个对象与传递的User对象是否匹配。如果我们找到一个匹配项,我们将containsUser标志设置为true并退出for循环以节省处理器时间。如果containsUser为true,我们知道找到了用户对象,添加另一个副本将违反我们指定的业务规则。因此,该方法返回false。如果用户不存在于数组中,则执行继续。
然后,我们通过评估其Length属性来检查数组是否已经包含 30 个或更多项。如果是true,则返回false,因为根据我们的业务规则,数组已满,添加更多将构成违规。否则,返回true,程序执行可以继续:
public void UserAuthenticated(User user)
{
if (this.CanAddUser(user))
{
Array.Resize(ref _users, _users.Length + 1);
_users[_users.Length - 1] = user;
Console.WriteLine("Length after adding user {0}: {1}", user.Id, _users.Length);
}
}
此方法在用户经过身份验证后调用,这是我们想要将用户添加到用户名单中的唯一时间。在这个方法中,我们通过调用CanAddUser()方法来验证添加用户操作。如果CanAddUser()方法返回true,则方法执行继续。首先,我们使用Array包装类的Resize()方法将数组扩展一个元素,为新添加的对象腾出空间。接下来,我们将新的User对象赋值到调整大小后的数组中的最后一个位置。最后,我们通过将用户 ID 和新的_users数组长度记录到控制台来进行一些简单的维护:
public void UserLoggedOut(User user)
{
int index = Array.IndexOf(_users, user);
if (index > -1)
{
User[] newUsers = new User[_users.Length - 1];
for (int i = 0, j = 0; i < newUsers.Length - 1; i++, j++)
{
if (i == index)
{
j++;
}
newUsers[i] = _users[j];
}
_users = newUsers;
}
else
{
Console.WriteLine("User {0} not found.", user.Id);
}
Console.WriteLine("Length after logging out user {0}: {1}", user.Id, _users.Length);
}
当一个先前认证的用户从网络服务中注销时,会调用此方法。它使用数组包装类的IndexOf()方法来确定传递的User对象是否存在于数组中。由于IndexOf()在找不到匹配对象时返回-1,此方法确认i的值等于-1。如果index的值等于-1,我们执行一些维护工作,例如在控制台中显示此用户 ID 当前未登录的消息。否则,我们开始从数组中删除对象的进程。
首先,我们必须创建一个比旧数组少一个元素的临时数组。接下来,我们从 0 循环到新数组的长度,其中i标记新数组中的位置,j标记旧数组中的位置。如果i等于我们想要删除的项目位置,我们就增加j以跳过旧数组中的该元素。最后,我们将从旧数组中正确位置的用户分配到新数组中。一旦我们遍历完数组,我们就将新列表分配给_users属性。之后,我们通过在控制台记录已删除的用户 ID 和_users数组的新长度来执行一些简单的维护工作。
Java
User[] _users;
public LoggedInUserArray()
{
User[] users = new User[0];
_users = users;
}
在前面的示例中,有几个重要的部分我们需要注意。首先,我们将User实例存储在一个名为_users的私有类字段中。其次,构造函数正在实例化一个新的User对象数组。最后,我们将数组实例化为长度为 0 的集合,并将其分配给我们的私有后端字段。这是因为我们的数组还没有分配任何用户,我们不希望通过尝试跟踪空值来进一步复杂化此代码。在现实世界的示例中,你可能会选择在一行中实例化和分配私有后端字段:
_users = new User[0];
前面的示例更冗长,因此更易读。然而,使用更简洁的示例会占用更少的空间。与 C#一样,两种方法都可以工作:
boolean CanAddUser(User user)
{
boolean containsUser = false;
for (User u : _users)
{
if (user.equals(u))
{
containsUser = true;
break;
}
}
if (containsUser)
{
return false;
} else {
if (_users.length >= 30)
{
return false;
} else {
return true;
}
}
}
在这里,我们引入一个私有方法来进行某种验证。这个方法的目的在于确定在这个时候将用户添加到数组中是否是一个有效的操作。首先,我们声明了一个名为containsUser的boolean变量。我们将使用这个标志来表示数组是否已经包含正在传递的User对象。接下来,我们执行一个for循环来检查数组中的每个对象与传递的User对象是否匹配。如果我们找到一个匹配项,我们将containsUser标志设置为true并退出for循环以节省处理器时间。如果containsUser为true,我们知道找到了用户对象,添加另一个副本将违反我们指定的业务规则。因此,该方法返回false。如果用户不存在于数组中,执行将继续。
接下来,我们通过评估其 Length 属性来检查数组是否已经包含 30 个或更多项目。如果是 true,我们返回 false,因为根据我们的业务规则,数组已满,添加更多将是一种违规行为。否则,我们返回 true,程序执行可以继续:
public void UserAuthenticated(User user)
{
if (this.CanAddUser(user))
{
_users = Arrays.copyOf(_users, _users.length + 1);
_users[_users.length - 1] = user;
System.out.println("Length after adding user " + user.GetId() + ": " + _users.length);
}
}
此方法在用户认证后调用,这是我们想要将用户添加到用户名单的唯一时间。在此方法中,我们通过调用 CanAddUser() 方法验证了添加用户操作。如果 CanAddUser() 返回 true,则方法执行继续。首先,我们使用 Arrays 包装类的 copyOf() 方法创建一个新数组的新副本,为我们的新添加腾出空间。接下来,我们将新的 User 对象分配给调整大小后的数组中的最后一个位置。最后,我们通过将用户 ID 和 _users 数组的新长度记录到控制台来进行一些简单的维护工作:
public void UserLoggedOut(User user)
{
int index = -1;
int k = 0;
for (User u : _users)
{
if (user == u)
{
index = k;
break;
}
k++;
}
if (index == -1)
{
System.out.println("User " + user.GetId() + " not found.");
}
else
{
User[] newUsers = new User[_users.length - 1];
for (int i = 0, j = 0; i < newUsers.length - 1; i++, j++)
{
if (i == index)
{
j++;
}
newUsers[i] = _users[j];
}
_users = newUsers;
}
System.out.println("Length after logging out user " + user.GetId() + ": " + _users.length);
}
当之前认证的用户从网络服务中注销时,会调用此方法。首先,它会遍历 _users 数组以定位与传入的 User 对象匹配的对象。我们将索引值初始化为 -1,这样,如果找不到匹配的对象,此值不会改变。接下来,此方法确认 index 的值是否等于 -1。如果是 true,我们通过在控制台记录此用户 ID 当前未登录来进行一些维护工作。否则,我们开始从 _users 数组中删除对象的过程。
首先,我们必须创建一个比旧数组少一个元素的临时数组。然后,我们从 0 遍历到新数组的长度,i 标记新数组中的位置,j 标记旧数组中的位置。如果 i 等于要删除的项目位置,我们增加 j 以跳过旧数组中的该元素。最后,我们将从旧数组中正确位置的用户分配到新数组中。一旦我们完成循环,我们将新列表分配给 _users 属性。之后,我们通过将删除的用户 ID 和 _users 数组的新长度记录到控制台来进行一些简单的维护工作。
Objective-C
在 Objective-C 中与原始 C 数组一起工作与在 C# 或 Java 中相当不同,主要是因为 Objective-C 不提供直接与原始类型一起工作的方法。然而,Objective-C 提供了 NSArray 包装类,我们将在下面的代码示例中使用它:
@interface EDSLoggedInUserArray()
{
NSArray *_users;
}
-(instancetype)init
{
if (self = [super init])
{
_users = [NSArray array];
}
return self;
}
首先,我们的 Objective-C 类接口为我们的数组定义了一个 ivar 属性。接下来,我们的初始化器使用 [NSArray array] 便利初始化器实例化 _users 对象:
-(BOOL)canAddUser:(EDSUser *)user
{
BOOL containsUser = [_users containsObject:user];
if (containsUser)
{
return false;
}
else
{
if ([_users count] >= 30)
{
return false;
}
else
{
return true;
}
}
}
canAddUser:方法也作为我们 Objective-C 示例中的内部验证。此方法的目的是在当前时间将用户添加到数组中是否是一个有效的操作。由于我们正在使用NSArray,我们可以访问containsUser:方法,该方法可以立即确定传入的User对象是否存在于_users数组中。然而,不要被这段代码的简单性所迷惑,因为在NSArray的底层,containsUser:方法看起来像这样:
BOOL containsUser = NO;
for (EDSUser *u in _users) {
if (user.userId == u.userId)
{
containsUser = YES;
break;
}
}
如果这段代码看起来很熟悉,那是因为它在功能上几乎与我们的之前的 C#和 Java 示例相同。containsObject:方法是为了我们的方便而存在的,并且在我们背后执行繁重的工作。再次强调,如果找到用户对象,添加另一个副本将违反我们指定的业务规则,并且方法返回false。如果用户不存在,则执行继续。
接下来,我们通过评估其count属性来检查数组是否已经包含 30 个或更多项。如果是,则返回false,因为根据我们的业务规则,数组已满,添加更多将违反规则。否则,返回true,程序执行可以继续:
-(void)userAuthenticated:(EDSUser *)user
{
if ([self canAddUser:user])
{
_users = [_users arrayByAddingObject:user];
NSLog(@"Length after adding user %lu: %lu", user.userId, [_users count]);
}
}
这种方法是在用户认证成功后调用的,这是我们唯一想要将用户添加到用户角色列表中的时候。在这个方法中,我们通过调用canAddUser:来验证添加用户操作。如果canAddUser:返回true,则方法执行继续。我们使用NSArray类的arrayByAddingObject:方法创建一个包含我们新的User对象的新数组副本。最后,我们通过将用户 id 和_users数组的新长度记录到控制台来进行一些简单的维护操作:
-(void)userLoggedOut:(EDSUser *)user
{
NSUInteger index = [_users indexOfObject:user];
if (index == NSNotFound)
{
NSLog(@"User %lu not found.", user.userId);
}
else
{
NSArray *newUsers = [NSArray array];
for (EDSUser *u in _users)
{
if (user != u)
{
newUsers = [newUsers arrayByAddingObject:u];
}
}
_users = newUsers;
}
NSLog(@"Length after logging out user %lu: %lu", user.userId, [_users count]);
}
当之前认证过的用户从网络服务中注销时,将调用此方法。首先,它使用NSArray indexOfObject:数组来获取与已传入的User对象匹配的任何对象的索引。如果找不到对象,则方法返回NSNotFound,这相当于NSIntegerMax。
此方法接下来确认index的值是否等于NSNotFound。如果是,我们通过将此用户 id 当前未登录的控制台记录到控制台来进行一些维护操作。否则,我们开始从_users数组中删除对象的过程。
不幸的是,NSArray 不提供从底层不可变数组中删除对象的方法,因此我们需要有点创意。首先,我们创建一个名为 newUsers 的临时数组对象来保存我们想要保留的所有 User 对象。然后,我们遍历 _users 数组,检查每个对象是否与我们要删除的 User 匹配。如果没有匹配项,我们以与将新用户添加到 _users 时相同的方式将其添加到 newUsers 数组中。如果 User 对象匹配,我们简单地跳过它,从而从最终对象数组中删除它。正如你所想象的那样,这个程序非常耗时,如果可能的话,应尽量避免这种模式。一旦循环完成,我们将新数组赋值给 _users 属性。最后,我们通过将删除的用户 ID 和 _users 数组的新计数记录到控制台来进行一些简单的维护工作。
Swift
在 Swift 中与原始 C 数组一起工作与在 C# 或 Java 中做得很相似,因为它提供了 Array 类,我们将在下面的代码示例中使用它:
var _users: Array = [EDSUser]()
我们只需要一个类属性来支持我们的用户数组。Swift 数组与 C# 和 Java 一样具有类型依赖性,我们必须在声明数组属性时声明类型。注意 Swift 初始化数组的方式,它是通过在类型名称或对象类名称周围使用订阅操作符,而不是将其附加到名称上:
func canAddUser(user: EDSUser) -> Bool
{
if (_users.contains(user))
{
return false;
}
else
{
if (_users.count >= 30)
{
return false;
}
else
{
return true;
}
}
}
canAddUser: 方法也用作内部验证。此方法的目的在于确定在此时刻将用户添加到数组中是否是一个有效的操作。首先,我们使用 Array.contains() 方法来确定我们想要添加的用户是否已经存在于数组中。如果找到用户对象,添加另一个副本将违反我们指定的业务规则,并且方法返回 false。如果用户不存在,则继续执行。
接下来,我们使用 _users 数组的 count 属性来检查数组内的对象总数是否不大于或等于 30。如果为 true,则返回 false,因为根据我们的业务规则,数组已满,添加更多将违反规则。否则,返回 true,程序执行可以继续:
public func userAuthenticated(user: EDSUser)
{
if (self.canAddUser(user))
{
_users.append(user)
}
print("Length after adding user \(user._userId): \ (_users.count)");
}
再次强调,此方法是在用户经过认证后调用的,这是我们想要将用户添加到用户名单的唯一时间。在这个方法中,我们通过调用 canAddUser() 方法来验证添加用户操作。如果 canAddUser() 返回 true,则方法执行继续,我们使用 Array.append() 方法将用户添加到数组中。最后,我们通过将用户 ID 和 _users 数组的新长度记录到控制台来进行一些简单的维护工作:
public func userLoggedOut(user: EDSUser)
{
if let index = _users.indexOf(user)
{
_users.removeAtIndex(index)
}
print("Length after logging out user \(user._userId): \(_users.count)")
}
最后,为了在注销时删除用户,我们首先需要确定该对象是否存在于数组中,并获取其在数组中的索引。Swift 允许我们同时声明index变量,执行此检查,并将值赋给index。如果此检查返回true,我们调用Array.removeAtIndex()从数组中移除user对象。最后,我们通过记录被删除的用户 ID 和_users数组的新计数到控制台来进行一些简单的维护工作。
小贴士
关注点分离
当你检查前面的例子时,你可能会想知道当我们完成对它们的使用后,所有那些User对象会发生什么。如果是这样,那是个很好的发现!如果你仔细观察,你会看到在这个例子中我们没有实例化或修改任何一个User对象——只有包含对象的数组被修改了。这是有意为之的。
在面向对象编程中,关注点分离的概念规定,计算机程序应该被分解成尽可能少重叠的操作特性。例如,一个名为LoggedInUserArray的类,作为底层数组结构的包装器,应该只操作其数组的操作,对数组中的对象影响很小。在这种情况下,传入的User类对象的内部工作和细节不是LoggedInUserArray类的关注点。
一旦每个User从数组中移除,该对象就会继续其愉快的旅程。如果应用程序没有保留对User对象的任何其他引用,那么某种形式的垃圾回收最终会将其从内存中清除。无论如何,LoggedInUserArray类不负责垃圾回收,并且对这些细节保持中立。
高级主题
现在我们已经看到了数组在常见实践中的应用,让我们来探讨一些与数组相关的高级主题:搜索模式和数组中可以存储的基本对象类型的变体。
线性搜索
在学习数据结构时,不可避免地要讨论搜索和排序这两个主题。如果没有在数据结构中进行搜索的能力,数据对我们来说将几乎毫无用处。如果没有对数据集进行排序以便在特定应用中使用的能力,数据的管理将变得极其繁琐。
我们执行特定数据结构的搜索或排序所遵循的步骤或过程称为算法。算法在计算机科学中的性能或复杂度是通过使用大 O 表示法来衡量的,它来源于函数 f(n) = O (g(n)),读作f of n equals big oh of g of n。用最简单的术语来说,大 O 是我们用来描述算法运行最长时间的最坏情况的术语。例如,如果我们知道在数组中搜索的对象的索引,那么只需要一次比较就可以定位和检索该对象。因此,最坏情况需要一次比较,搜索的成本是 O(1)。
虽然我们将在稍后更详细地研究搜索和排序,但到目前为止,我们将研究线性搜索,或顺序搜索,这是搜索集合中最简单且效率最低的模式。迭代意味着重复执行一个过程。在线性搜索中,我们按顺序遍历对象集合,直到找到与我们的搜索模式匹配的项。对于包含 n 个项目的集合,最佳搜索情况是目标值等于集合中的第一个项目,这意味着只需要一次比较。在最坏的情况下,目标值根本不在集合中,这意味着需要 n 次比较。这意味着线性搜索的成本是 O(n)。如果你回顾代码示例,你会在几个地方看到 O(n) 搜索:
C#
这里是我们 C# 代码中的线性搜索算法,但已重新格式化以使用 for 循环,这更好地说明了 O(n) 成本的概念:
for (int i = 0; i < _users.Count; i++)
{
if (_users[i] == u)
{
containsUser = true;
break;
}
}
Java
这里是我们 Java 代码中的线性搜索算法,但已重新格式化以使用 for 循环,这更好地说明了 O(n) 成本的概念:
for (int i = 0; I < _users.size(); i++)
{
if (_users[i].equals(u))
{
containsUser = true;
break;
}
}
Objective-C
这里是我们 Objective-C 代码中的线性搜索算法,但已重新格式化以使用 for 循环,这更好地说明了 O(n) 成本的概念:
for (int i = 1; i < [_users count]; i++)
{
if (((User*)[_users objectAtIndex:i]).userId == u.userId)
{
containsUser = YES;
break;
}
}
Swift
我们的 Swift 代码中没有包含线性搜索的示例,但一个示例可能看起来像这样:
for i in 1..<_users.count
{
//Perform comparison
}
原始数组
原始数组是仅包含原始类型的数组。在 C#、Java 和 Swift 中,你通过在原始类型上声明一个数组来声明一个原始数组。作为弱类型语言,Objective-C 不支持显式类型化的数组,因此也不支持显式原始数组。
C#
int[] array = new int[10];
Java
int[] array = new int[10];
Objective-C
NSArray *array = [NSArray array];
Swift
var array: Array = [UInt]()
对象数组
对象数组是仅包含特定对象实例的数组。在 C#、Java 和 Swift 中,你通过在类上声明一个数组来声明一个对象数组。作为弱类型语言,Objective-C 不支持显式类型化的数组,因此也不支持显式对象数组。
C#
Vehicle[] cars = new Vehicle[10];
Java
Vehicle[] cars = new Vehicle[10];
Objective-C
NSArray *array = [NSArray array];
Swift
var vehicle: Array = [Vehicle]()
混合数组
当与数组一起工作时,您使用一种数据类型声明数组,并且数组中的所有元素都必须匹配该数据类型。通常,这种约束是合适的,因为元素通常彼此紧密相关或共享相似的性质值。在其他时候,数组中的元素并不紧密相关或没有相似的性质值。在这些情况下,您可能希望能够在同一数组中混合匹配类型。C#和 Java 都提供了类似的机制来实现这一点——将数组声明为根类对象类型。由于 Objective-C 语言是弱类型,其数组默认就是混合的。Swift 提供了AnyObject类型来声明混合数组。
C#
Object[] data = new Object[10];
Java
Object[] data = new Object[10];
Objective-C
NSArray *data = [NSArray array];
Swift
var data: Array = [AnyObject]()
与混合数组一起工作可能看起来很方便,但请注意,作为开发人员,您将类型检查的责任从编译器移走。对于像 Objective-C 这样的弱类型语言的开发人员来说,这不会是一个重大的调整,但经验丰富的强类型语言开发人员需要对此问题非常关注。
多维数组
多维数组是一个包含一个或多个额外数组的数组。我们正在使用的四种语言都可以支持1...n维度的多维数组。然而,请注意,超过三个级别的多维数组管理起来会变得极其困难。
有时,将多维数组概念化为与它们的维度相关的内容会有所帮助。例如,一个二维数组可能有行和列,或者x和y值。同样,一个三维数组可能有x、y和z值。让我们看看每种语言中二维和三维数组的示例。
C#
C#中的多维数组使用[,]语法创建,其中每个逗号代表数组中的一个额外维度。相应的new初始化器必须提供正确数量的尺寸参数以匹配定义,否则代码将无法编译:
//Initialize
int[,] twoDArray = new int[5, 5];
int[, ,] threeDArray = new int[5, 6, 7];
//Set values
twoDArray[2,5] = 90;
threeDArray[0, 0, 4] = 18;
//Get values
int x2y5 = twoDArray[2,5];
int x0y0z4 = threeDArray[0,0,4];
Java
在 Java 中创建多维数组的语法简单涉及将[]配对连接起来,其中每一对代表数组中的一个维度。相应的new初始化器必须提供正确数量的括号大小参数以匹配定义,否则代码将无法编译:
//Initialize
int[][] twoDArray = new int[5][5];
int[][][] threeDArray = new int[5][6][7];
//Set values
twoDArray[2][5] = 90;
threeDArray[0][0][4] = 18;
//Get values
int x2y5 = twoDArray[2][5];
int x0y0z4 = threeDArray[0][0][4];
Objective-C
Objective-C 不直接支持NSArray类中的多维数组。如果您的代码需要多维数组,您将需要使用NSMutableArray或一个普通的 C 数组,这两者都不在本章的范围内。
Swift
Swift 中的多维数组一开始看起来可能有些令人困惑,但您需要意识到您正在创建数组的数组。定义语法是[[Int]],初始化语法是[[1, 2], [3, 4]],其中初始化时使用的值可以是指定类型的任何值:
//Initialize
var twoDArray: [[Int]] = [[1, 2], [3, 4]]
var threeDArray: [[[Int]]] = [[[1, 2, 3], [4, 5, 6], [7, 8, 9]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]], [[1, 2, 3], [4, 5, 6], [7, 8, 9]]]
//Set values
twoDArray[0][1] = 90;
threeDArray[0][0][2] = 18;
//Get values
var x0y1: Int = twoDArray[0][1];
var x0y0z2: Int = threeDArray[0][0][2];
锯齿状数组
当多维数组包含不同大小的数组时,会创建交错数组。在极少数情况下,这种设计是必要的,但请注意,交错数组可能非常复杂且难以管理。C#、Java 和 Swift 支持交错数组。Objective-C 不支持使用NSArray的多维数组,因此也不支持使用它来创建交错数组。与多维数组类似,Objective-C 可以使用NSMutableArray或纯 C 数组来支持交错数组。
摘要
在本章中,你学习了数组结构的基本定义,数组在内存中的样子,以及我们讨论的四种语言如何实现纯 C 数组结构。接下来,我们讨论了可变数组和不可变数组之间的区别。通过示例,我们探讨了四种语言如何实现数组和数组功能。在本章的剩余部分,我们研究了线性搜索算法,并介绍了大O符号,包括如何将此符号应用于数组,并举例说明简单的迭代。我们讨论了原始数组、对象数组和混合数组之间的区别。最后,我们研究了多维数组及其对应项,交错数组。
作为最后的注意事项,了解何时使用数组是很重要的。数组非常适合存储少量恒定数据或变化极小甚至不变化的数据。如果你发现自己经常在操作数组中的数据,或者经常添加和删除对象,那么你可能需要考虑使用其他数据结构,例如列表,我们将在下一章中讨论。
第三章:列表:线性集合
在我们上一章中,我们介绍了数组数据结构,这是我们将在本文中探讨的许多结构的基础。尽管数组为静态数据集合提供了良好的性能,但我们的编码示例证明,对于许多应用来说,它们既不灵活也不高效——以至于从集合中添加或删除一个元素这样的简单操作都变得极其复杂且成本高昂。
在某些方面,列表是数组的演变。列表可以被定义为有限、有序的对象或值的序列,称为元素。空列表是没有元素的列表,而列表的长度是集合中元素的总数。列表中的第一个项目称为头,而最后一个项目称为尾。在长度为 1 的列表中,头和尾是同一个对象。
注意
虽然数组是一种具体的数据结构,但列表是数据结构的一个抽象概念,许多语言都提供了具体的实现。我们将在本章后面的 Java 示例中更详细地探讨这种区别。
有序列表不应与排序列表混淆,因为列表可以是排序的或未排序的。有序仅仅意味着每个元素在列表中都有一个定义的位置。排序列表中的对象之间存在某种关系,而未排序列表中的对象之间没有显著的关系。例如,当我的妻子创建购物清单时,她会坐下来并仔细组织杂货,使其与她对超市的布局的了解相关。她清单上的项目是杂货的类型,并且它们根据在超市中的相对位置排列,因此它们有空间关系。这是一个排序列表。另一方面,我创建购物清单时,会在冰箱上贴一张纸,并在注意到架子空了或容器缺失时在纸上乱写项目。尽管我清单上的项目都是杂货的类型,但它们没有以任何特定的方式排列,因此它们之间没有显著的关系。这是一个未排序列表的例子。
本章我们将涵盖以下内容:
-
列表数据结构的定义
-
初始化列表
-
列表的应用示例
-
列表实现
-
追加、插入和删除操作
-
基于数组的列表
-
链表
-
双向链表
-
搜索
列表实现
列表数据结构最常见的实现之一是基于数组的列表。一般来说,基于数组的列表只是一个连续的数组位置列表,每个位置都持有指向列表元素的指针。由于列表基于数组,其功能和性能与数组非常相似。
如前例所示,另一种常见的实现是链表。链表也是一个元素序列,但大多数实现将元素称为节点。在链表中,元素的指针不包含在数组结构中,而是在内存中存在一个指针来标识第一个节点。然后每个节点都包含指向列表中后续节点的链接。
最后,还有双向链表。在双向链表中,每个节点都包含指向列表中后续节点和前一个节点的链接。双向链表使得双向遍历列表变得更加简单。头节点的上一个链接和尾节点的下一个链接都是空的。另一种选择是将头节点的上一个链接指向尾节点,尾节点的下一个链接指向头节点,这样双向链表就变成了循环链表。
注意
双向链表这个术语并不常用,但如果你在使用 Java 或 C#中的LinkedList类,实际上你就是在使用双向链表。C#和 Java 没有提供单链表类型,双向链表为你提供了相同的功能甚至更多,所以你通常不需要它。然而,如果你真的出于学术目的想要实现一个,那也很容易做到。
通常,这个结构的每个具体实现都为你提供了方便的方法,允许你从列表中追加、插入和删除元素。基于数组和基于链接的列表都提供了访问基本追加、插入和删除操作的方法。然而,这些操作的实施方式和它们相关的成本在这两种实现之间略有不同。如果现有的方法没有内置,那么创建这种功能通常是一个简单的练习。
注意
与其他抽象数据结构的实现一样,当在创建我们自己的方法和框架提供的任何方法之间做出选择时,选择后者,因为它们通常会更健壮和可靠。
基于数组的列表
在基于数组的列表中,追加操作的成本是O(1),因为我们总能通过简单地增加尾位置的索引来确定新元素的位置:

每当我们向基于数组的列表中插入一个元素时,我们还需要管理数组中现有对象的位置以容纳新的节点。在索引i处插入元素需要我们将i之后的所有元素向尾部移动一个位置,这意味着我们需要执行n - i次操作,其中n是列表中已有的元素数量。在头部位置插入是一个最坏情况的操作,耗时O(n)。由于我们在评估算法效率时总是计算最坏情况下的成本,因此插入一个元素的成本是O(n)。
从索引i处删除列表中的元素需要我们将i之后的所有元素向头部移动一个位置,这意味着我们需要执行n - i- 1 次操作,其中n是列表中的元素数量。从头部位置删除是一个最坏情况的操作,耗时O(n)。因此,删除一个元素的成本是O(n),如下图中所示:

链表
与基于数组的列表一样,追加操作的成本是O(1)。然而,插入和删除操作的成本也是O(1)。与基于数组的列表相比,链表的一个关键优势是可变性。与数组不同,链表是一系列通过内存指针相互关联的离散对象,因此插入或删除元素只是简单地添加或修改这些指针。换句话说,链表能够以非常高效的方式根据集合的增加和删除进行扩展和收缩。

如果我们想在位置i插入一个元素,我们需要将原始指针i - 1 -> i改为指向我们的新元素i,并插入一个新的指针i -> i + 1。同样,删除位置i的元素需要调整指针从i - 1 -> i到i - 1 -> i + 1。
实例化列表
与其他数据结构一样,列表在使用之前必须定义和实例化。我们将在这篇文本中检查的四种语言对列表数据结构的支持各有不同,并且具有独特的实现方式。让我们简要地看看如何在每种语言中实例化一个列表。
C#
在 C#中实例化列表需要使用new关键字:
//Array backed lists
ArrayList myArrayList = new ArrayList();
List<string> myOtherArrayList = new List<string>();
//Linked lists
LinkedList<string> myLinkedList = new LinkedList<string>();
C#的ArrayList类起源于.NET 1.0,现在使用得不太多了。大多数开发者更喜欢使用基于数组的列表的泛型具体实现,即List<of T>。这同样适用于泛型具体链表实现,即LinkedList<of T>。在 C#中没有非泛型链表数据结构。
Java
与 C#类似,在 Java 中初始化列表也需要使用new关键字:
//Array backed lists
List<string> myArrayList = new ArrayList<string>();
//Linked lists
LinkedList<string> myLinkedList = new LinkedList<string>();
Java 开发者将使用List<E>类的具体实现来创建一个基于数组的列表。这同样适用于具体的链表实现,即LinkedList<E>。Java 中没有非泛型的链表数据结构。
Objective-C
在 Objective-C 中创建列表的过程如下:
//Array backed lists
NSArray *myArrayList = [NSArray array];
//Linked lists
NSMutableArray *myLinkedList = [NSMutableArray array];
如果你阅读了关于数组的章节,这个例子可能引起了你的注意。实际上,这并不是一个错误。在 Objective-C 中,基于数组的列表最接近的实现可以在NSArray类中找到,而链表的最接近实现可以在NSMutableArray类中找到。这是因为NSArray和NSMutableArray被称为类簇。类簇提供了真正的抽象类公共 API。当你初始化这些类之一时,你会得到一个针对你提供的数据定制的具体数据结构实现。这些实现甚至可以在运行时根据数据集的性质变化而变化,使得数组类非常灵活。这意味着我们将在本文中讨论的许多数据结构都只通过三个抽象类在 Objective-C(和 Swift)中实现。
注意
Objective-C 中的类簇
类簇是基于抽象工厂模式的设计模式,该模式返回一个遵循特定接口(C#/Java)或协议(Objective-C/Swift)的类型。它们在Foundation框架中被大量使用,这是一个好事。
类簇将私有、具体的子类分组在抽象超类或 API 下。与直接处理每个子类相比,这个公共 API 更容易使用。NSNumber、NSString、NSArray和NSDictionary都是 Foundation 中类簇的例子。
Swift
最后,这是在 Swift 中实例化列表的方法:
//Lists
var myArray = [string]()
var myOtherArray: Array<string> = [String]()
Swift 也使用类簇来表示许多不同的抽象集合。对于列表,我们使用Array类,它默认既泛型又可变。Swift 中数组有简写和显式声明两种形式。尽管更冗长,但显式定义更清楚地展示了 API 的泛型特性。
重新访问登录到服务的用户
在第二章,“数组:基础结构”中,我们创建了一个应用程序来跟踪登录到网络服务的用户,使用数组作为包含User对象的底层数据结构。然而,通过使用列表数据结构,这个设计可以大大改进。在这里,让我们重新审视登录到服务的用户问题,并通过用列表替换类数组,我们将看到在大多数情况下我们的原始代码既简短又易于阅读。
C#
在这个例子中,我们将User[]对象替换为了List<User>对象。大部分的代码重构都是显而易见的,但有三行代码需要特别注意。首先,在CanAddUser()方法中,我们通过利用List<T>.Contains()方法将 15 行代码缩减为 2 行,并简化了我们的逻辑循环。接下来,在UserAuthenticated()方法中,我们使用了List<T>.Add()方法,这替代了对Array.Resize()的调用以及使用下标操作符赋值时容易出错的方法。最后,我们使用List<T>.Remove()方法替换了近 20 行复杂且难看的代码。仅从缩短的代码来看,这个包装类提供的便利和功能应该是显而易见的:
List<User> _users;
public LoggedInUserList()
{
_users = new List<User>();
}
bool CanAddUser(User user)
{
if (_users.Contains(user) || _users.Count >= 30)
{
return false;
} else {
return true;
}
}
public void UserAuthenticated(User user)
{
if (this.CanAddUser(user))
{
_users.Add(user);
}
}
public void UserLoggedOut(User user)
{
_users.Remove(user);
}
Java
在这个例子中,我们将User[]对象替换为了List<User>对象。大部分的代码重构都是显而易见的,但有三行代码需要特别注意。首先,在CanAddUser()方法中,我们通过利用List<E>.contains()方法将 15 行代码缩减为 2 行,并简化了我们的逻辑循环。接下来,在UserAuthenticated()方法中,我们使用了List<E>.add()方法,这替代了对Array.copyOf()的调用以及使用下标操作符赋值时容易出错的方法。最后,我们使用List<E>.remove()方法替换了近 20 行复杂且难看的代码:
List<User> _users;
public LoggedInUserList()
{
_users = new LinkedList<User>;
}
boolean CanAddUser(User user)
{
if (_users.contains(user) || _users.size() >= 30)
{
return false;
} else {
return true;
}
}
public void UserAuthenticated(User user)
{
if (this.CanAddUser(user))
{
_users.add(user);
}
}
public void UserLoggedOut(User user)
{
_users.remove(user);
}
以下截图展示了当可以使用泛型类时使用泛型类的另一个好处。正如你所见,我们的代码补全,或称为Intellisense,建议了可能的补全选项,包括应包含在集合中的正确类型。这可以避免你反复检查以确保使用正确的对象和集合,这既耗时又令人烦恼。

Objective-C
在这个例子中,我们将NSArray _users对象替换为了NSMutableArray _users对象。在这个例子中,除了某些合并和代码清理之外,实际上只有一个重构。在userLoggedOut:中,我们使用NSMutableArray removeObject:方法替换了近 20 行复杂且难看的代码,而不是检查索引、循环和合并对象:
@interface EDSLoggedInUserList()
{
NSMutableArray *_users;
}
-(instancetype)init
{
if (self = [super init])
{
_users = [NSMutableArray array];
}
return self;
}
-(BOOL)canAddUser:(EDSUser *)user
{
if ([_users containsObject:user] || [_users count] >= 30)
{
return false;
} else {
return true;
}
}
-(void)userAuthenticated:(EDSUser *)user
{
if ([self canAddUser:user])
{
[_users addObject:user];
}
}
-(void)userLoggedOut:(EDSUser *)user
{
[_users removeObject:user];
}
Swift
如果你仔细比较这段代码与原始代码,你会发现它们实际上是相同的!这是因为 Swift 的Arrays已经是可变的,并且已经支持泛型类型,所以我们的原始LoggedInUserArray类已经像 Swift 那样能够通过现成的代码产生输出,表现得像是一个链表。我们可以在 Swift 中创建自己的链表实现,但这只有在非常特定的用例中才是必要的:
var _users: Array = [User]()
init() { }
func canAddUser(user: User) -> Bool
{
if (_users.contains(user) || _users.count >= 30)
{
return false;
} else {
return true;
}
}
public func userAuthenticated(user: User)
{
if (self.canAddUser(user))
{
_users.append(user)
}
}
public func userLoggedOut(user: User)
{
if let index = _users.indexOf(user)
{
_users.removeAtIndex(index)
}
}
注意
这些使用列表数据结构各种变体的重构示例不仅流程简化,而且比它们的数组对应物性能更优。出于这两个原因,列表结构被证明是此应用的更优选择。
泛型
你可能已经注意到了我们的 C#示例中的List<T>.Contains()方法或 Java 示例中的List<E>.Add()方法。这些方法是定义为泛型类的类的一部分。在计算机科学中,泛型允许你在声明类或调用方法之前不指定数据类型。例如,假设你有一个将两个数值相加的方法。为了直接与这些类型交互,你可以为Add()方法创建多个重载:
//C#
public int Add(int a, int b)
public double Add(double a, double b)
public float Add(float a, float b)
泛型允许你创建一个针对调用它的类型定制的单一方法,这极大地简化了你的代码。在这个例子中,T可以被替换为调用者需要的任何类型:
public T Add<T>(T a, T b)
泛型是一个非常强大的工具,我们将在第十二章中更详细地讨论,排序:从混乱中带来秩序。
案例研究:骑行路线
业务问题:为喜欢越野骑行的骑行爱好者编写一个移动应用程序。其中一个关键的业务需求是能够在路径中存储航点。路径必须有起点和终点,并且需要能够双向遍历。应用程序还需要能够实时修改骑行者的路径,以便绕过障碍物、访问休息区或添加兴趣点。
由于应用程序的性质及其需求,表示路径的类将需要几个基本的功能。首先,它将需要添加、删除和插入航点的功能。接下来,它将需要启动路径并遍历路径以向前和向后导航的能力。最后,该类应能够轻松地识别起点和终点以及当前关注的航点。
由于此类数据的性质是所有航点之间都有空间关系,并且应用程序必须利用这种关系从一点到另一点进行遍历,因此数组作为数据结构的选择会很差。然而,由于列表本身提供了一种定义和遍历集合中对象之间关系的机制,开发者选择使用链表结构来构建此组件。
C#
C#通过LinkedList<T>类和LinkedListNode<T>类方便地公开了链表结构。因此,在 C#中构建此类应该是一个相当直接的过程。以下是一个简单的 C#实现示例:
LinkedList<Waypoint> route;
LinkedListNode<Waypoint> current;
public WaypointList()
{
this.route = new LinkedList<Waypoint>();
}
首先,我们声明了两个属性。第一个是route属性,它是LinkedList<Waypoint>类型。下一个对象是current节点。我们声明了这两个对象而没有明确定义它们的范围,因此它们默认为private。我们希望这些字段是私有的,因为我们只允许这个类中的方法修改它们的值。我们的构造函数只实例化了route属性,因为current节点将根据需要分配:
public void AddWaypoints(List<Waypoint> waypoints)
{
foreach (Waypoint w in waypoints)
{
this.route.AddLast(w);
}
}
public bool RemoveWaypoint(Waypoint waypoint)
{
return this.route.Remove(waypoint);
}
AddWaypoints(List<Waypoint>)方法允许我们向现有路线添加1...n个新的航点。C#不提供合并List<T>与LinkedList<T>的机制,因此我们必须求助于遍历waypoints并使用LinkedList<T>.AddLast()逐个添加新节点,这意味着这个操作的成本是O(i),其中i是waypoints列表中的元素数量。
RemoveWaypoint(Waypoint)方法简单地调用LinkedList<T>.Remove()在路线上,传递waypoint作为参数。由于这实际上是搜索操作,它也花费O(n):
public void InsertWaypointsBefore(List<Waypoint> waypoints, Waypoint before)
{
LinkedListNode<Waypoint> node = this.route.Find(before);
if (node != null)
{
foreach (Waypoint w in waypoints)
{
this.route.AddBefore(node, w);
}
} else {
this.AddWaypoints(waypoints);
}
}
InsertWaypointsBefore(List<Waypoint>, Waypoint)方法使我们的类能够创建替代路线并在运行时添加中间目的地。首先,我们尝试定位before节点。如果我们找到了它,我们就开始顺序地将新航点的列表插入到before节点之前。如果没有找到,我们立即调用AddWaypoints(List<Waypoint>)将新航点列表附加到路线上。尽管这个循环的功能可能看起来有些奇怪,但通过在before节点之前逐个添加每个项目,我们每次操作都将before节点向尾部移动一个节点,确保每个新节点按正确顺序插入。
这是这个类中最昂贵的操作,因为它结合了搜索和插入。这意味着它的操作成本是O(n+i),其中n是现有route集合中的元素数量,i是waypoints列表中的元素数量:
public bool StartRoute()
{
if (this.route.Count > 1)
{
this.current = this.StartingLine();
return this.MoveToNextWaypoint();
}
return false;
}
StartRoute()方法用于设置我们的初始当前位置并标记它已被禁用。由于我们的整体类代表一个路线,而路线至少是一个二维对象,StartRoute()方法立即验证route至少有两个航点。如果没有,我们返回false,因为路线尚未准备好被穿越。如果我们有两个或更多的航点,我们将current航点设置为起点并移动到下一个点。StartRoute()方法的操作成本是O(1)。
注意
我们可以轻松地复制 StartingLine() 方法中的关键代码和 MoveToNextWaypoint() 方法中的代码,在 StartRoute() 方法中本地复制。这样做意味着,如果我们想改变识别起点线或导航路线的方式,我们需要在多个位置维护这段代码。通过遵循这种代码重用的模式,我们最小化了工作量并减少了这种重构可能引入的新潜在错误数量。
接下来我们将查看那些改变对象位置的方法。
public bool MoveToNextWaypoint()
{
if (this.current != null)
{
this.current.Value.DeactivateWaypoint();
if (this.current != this.FinishLine())
{
this.current = this.current.Next;
return true;
}
return false;
}
return false;
}
public bool MoveToPreviousWaypoint()
{
if (this.current != null && this.current != this.StartingLine())
{
this.current = this.current.Previous;
this.current.Value.ReactivateWaypoint();
return true;
}
return false;
}
MoveToNextWayPoint() 和 MoveToPreviousWaypoint() 方法引入了我们的路线遍历功能。在 MoveToNextWayPoint() 中,我们检查当前航标点是否为 null,然后将其停用。接下来,我们检查是否到达了终点线,如果没有,我们将 current 设置为 route 中的下一个节点并返回 true。MoveToPreviousWaypoint() 方法验证 current 是否不为 null 并确保我们不在起点线。如果是这样,我们将 current 移动到前一个航标点并重新激活它。如果这两个方法中的任何检查失败,我们返回 false。这些方法中的每一个都有 O(1) 的操作成本。
注意
在 MoveToNextWaypoint() 中看到的这种双重 false 返回值可能看起来像是一个设计缺陷,但请记住,我们的类并不负责整个应用程序的功能,只负责路线的功能。检查路线是否准备好遍历的责任在于 调用者 在调用 MoveToNextWaypoint() 之前。我们的返回值仅表示操作的成功或失败。
最后,我们将查看指示位置的方法:
public LinkedListNode<Waypoint> StartingLine()
{
return this.route.First;
}
public LinkedListNode<Waypoint> FinishLine()
{
return this.route.Last;
}
public LinkedListNode<Waypoint> CurrentPosition()
{
return this.current;
}
我们添加了 StartingLine() 和 FinishLine() 方法来公开路线集合的头和尾节点。最后,我们添加了 CurrentPosition() 方法来公开路线中的哪个节点是我们下一个立即的目的地。这些方法中的每一个都有 O(1) 的操作成本。
Java
Java 通过 LinkedList<E> 类也公开了一个链表数据结构。然而,Java 并没有提供列表节点结构的实现。这是因为,在 Java 中,你通常不直接与节点交互,而是通过列表迭代器。ListIterator<E> 类提供了实现链表结构所需的基本功能。如果我们需要我们自己的节点类,实现起来会很容易。以下是一个简单的 WaypointList 类实现示例:
LinkedList<Waypoint> route;
Waypoint current;
public WaypointList()
{
this.route = new LinkedList<Waypoint>();
}
首先,我们声明两个属性。第一个是 route,它是一个抽象的 List<Waypoint>,下一个对象是 current 节点。我们未明确定义这两个对象的范围,因此它们默认为 package-private,这对于我们的情况来说是合适的。我们希望这些字段是私有的,因为我们只允许这个类中的方法修改它们的值。我们的构造函数只实例化了 route,因为 current 节点将根据需要分配:
public void AddWaypoints(List<Waypoint> waypoints)
{
this.route.addAll(waypoints);
}
public boolean RemoveWaypoint(Waypoint waypoint)
{
return this.route.remove(waypoint);
}
AddWaypoints(List<Waypoint>) 方法允许我们向现有路线添加 1...n 个新的航点。我们使用 LinkedList<E>.addAll() 方法将对象添加到我们的列表中。这个操作非常简单,成本为 O(1)。RemoveWaypoint(Waypoint) 方法简单地调用路线上的 LinkedList<E>.remove(),并将 waypoint 作为参数传递。由于这是一个搜索操作,它成本为 O(n):
public void InsertWaypointsBefore(List<Waypoint> waypoints, Waypoint before)
{
int index = this.route.indexOf(before);
if (index >= 0)
{
this.route.addAll(index, waypoints);
} else {
this.AddWaypoints(waypoints);
}
}
InsertWaypointsBefore(List<Waypoint>, Waypoint) 方法使我们的类能够创建替代路线并在飞行中添加中间目的地。首先,我们尝试使用 LinkedList<E>.indexOf() 定位 before 节点。如果 indexOf() 方法找不到对象,它将返回 -1,因此我们确认值大于 -1;否则,我们立即调用 AddWaypoints(List<Waypoint>) 将新的航点列表附加到路线上。如果 before 节点是有效的,我们在 before 节点之前添加新的航点列表。
这是这个类中最昂贵的操作方法,因为它是一个搜索和插入的组合。这意味着它的操作成本是 O(n+i),其中 n 是现有 route 中的元素数量,i 是航点列表中的元素数量:
public boolean StartRoute()
{
if (this.route.size() > 1)
{
this.current = this.StartingLine();
return this.MoveToNextWaypoint();
}
return false;
}
StartRoute() 方法用于设置我们的初始当前位置并标记它已被禁用。由于我们的整体类代表一个路线,而路线按定义至少是一个二维对象,StartRoute() 方法立即验证 route 至少有两个航点,如果没有,我们返回 false,因为 route 还未准备好被遍历。如果我们有两个或更多航点,我们将 current 航点设置为起点并移动到下一个点。StartRoute() 的操作成本为 O(1):
public boolean MoveToNextWaypoint()
{
if (this.current != null)
{
this.current.DeactivateWaypoint();
if (this.current != this.FinishLine())
{
int index = this.route.indexOf(this.current);
this.current = this.route.listIterator(index).next();
return true;
}
return false;
}
return false;
}
public boolean MoveToPreviousWaypoint()
{
if (this.current != null && this.current != this.StartingLine())
{
int index = this.route.indexOf(this.current);
this.current = this.route.listIterator(index).previous();
this.current.ReactivateWaypoint();
return true;
}
return false;
}
MoveToNextWayPoint() 和 MoveToPreviousWaypoint() 方法介绍了我们的路线遍历功能。在 MoveToNextWayPoint() 方法中,我们检查当前航标点是否不是 null,然后将其停用。接下来,我们检查是否不在终点线,如果不是,我们将 current 设置为下一个,通过将其分配给 listIterator 属性和 route 的 next() 方法,然后返回 true。MoveToPreviousWaypoint() 方法验证 current 不是 null 并确保我们不在起点线。如果是这样,我们将 current 设置为前一个航标点并重新激活它。如果这两个方法中的任何检查失败,我们返回 false。由于需要搜索 current 的匹配项,这些方法每个都有 O(n+1) 的操作成本:
public Waypoint StartingLine()
{
return this.route.getFirst();
}
public Waypoint FinishLine()
{
return this.route.getLast();
}
public Waypoint CurrentWaypoint()
{
return this.current;
}
我们添加了 StartingLine() 和 FinishLine() 方法来暴露路线集合的头尾节点。最后,我们添加了 CurrentPosition() 方法来暴露路线中的哪个节点是我们的下一个直接目的地。这些方法每个都有 O(1) 的操作成本。
Objective-C
Objective-C 默认不提供任何链表的实现。尽管我们可以创建自己的实现,但本文的目的是演示在现有工具下最佳的方法。对于这个场景,我们再次使用类簇,NSMutableArray。以下是一个简单的示例,说明 EDSWaypointList 类如何在 Objective-C 中实现:
@interface EDSWaypointList()
{
NSMutableArray *_route;
EDSWaypoint *_current;
}
-(instancetype)init
{
if (self = [super init])
{
_route = [NSMutableArray array];
}
return self;
}
首先,我们声明两个 ivar 属性。第一个是 _route,它是一个 NSMutableArray 数组。下一个对象是 _current 节点。同样,我们将其声明为 ivars,因为我们只允许这个类中的方法修改它们的值。我们的初始化器只实例化了 _route,因为 _current 节点将根据需要分配:
-(void)addWaypoints:(NSArray*)waypoints
{
[_route addObjectsFromArray:waypoints];
}
-(BOOL)removeWaypoint:(EDSWaypoint*)waypoint
{
if ([_route containsObject:waypoint])
{
[_route removeObject:waypoint];
return YES;
}
return NO;
}
addWaypoints: 方法允许我们向现有路线添加 1...n 个新的航标点。NSMutableArray 允许我们通过调用 addObjectsFromArray: 将新数组与现有路线合并。这个操作相当简单,成本为 O(1)。
removeWaypoint: 方法确认 _route 包含 waypoint 使用 containsObject:,然后调用 removeObject:。如果我们不关心这个操作的成功或失败,我们可以简单地调用 removeObject: 并继续。请注意,由于我们的 _route 对象是一个基于数组的列表,它允许进行 O(1) 的搜索操作。由于我们事先不知道航标点的索引,removeObject: 操作仍然成本为 O(n):
-(void)insertWaypoints:(NSArray*)waypoints beforeWaypoint:(EDSWaypoint*)before
{
NSUInteger index = [_route indexOfObject:before];
if (index == NSNotFound)
{
[self addWaypoints:waypoints];
} else {
NSRange range = NSMakeRange(index, [waypoints count]);
NSIndexSet *indexSet = [NSIndexSetindexSetWithIndexesInRange:range];
[_route insertObjects:waypoints atIndexes:indexSet];
}
}
insertWaypoints:beforeWaypoint: 方法使我们的类能够创建替代路线并在飞行中添加中间目的地。首先,我们尝试使用 indexOfObject: 定位 before 节点。如果我们找不到它,我们立即调用 addWaypoints: 将新的航标列表附加到路线上。否则,我们编写一些丑陋的代码来定义一个 NSRange 对象和一个 NSIndexSet 对象,并使用这些对象与 insertObjects:atIndexes: 一起。由于此方法代表搜索和插入,其操作成本是 O(n+1),其中 n 是现有 _route 对象中的元素数量:
-(BOOL)startRoute
{
if ([_route count] > 1)
{
_current = [self startingLine];
return [self moveToNextWaypoint];
}
return NO;
}
startRoute: 方法用于设置我们的初始当前位置并表明它已被停用。由于我们的整体类代表一个路线,而路线按定义至少是一个二维对象,startRoute: 方法立即验证 _route 至少有两个航标;如果没有,我们返回 NO,因为路线尚未准备好被穿越。如果我们有两个或更多的航标,我们将 _current 航标设置为起点线并移动到下一个点。startRoute: 具有操作成本 O(1)。
-(BOOL)moveToNextWaypoint
{
if (_current)
{
[_current deactivateWaypoint];
if (_current != [self finishLine])
{
NSUInteger index = [_route indexOfObject:_current];
_current = [_route objectAtIndex:index+1];
return YES;
}
return NO;
}
return NO;
}
-(BOOL)moveToPreviousWaypoint
{
if (_current && _current != [self startingLine])
{
NSUInteger index = [_route indexOfObject:_current];
_current = [_route objectAtIndex:index-1];
[_current reactivateWaypoint];
return YES;
}
return NO;
}
在 moveToNextWaypoint: 方法中,我们检查当前航标是否不是 nil,然后将其停用。接下来,我们验证我们是否不在终点线,如果不是,我们获取 _current 在列表中的索引,并将下一个最高索引的对象分配给属性,然后返回 YES。moveToPreviousWaypoint: 方法验证 _current 是否不是 nil,并确保我们不在起点线。如果是这样,我们将 _current 设置为前一个航标并重新激活它,然后返回 YES。如果这两个方法中的任何检查失败,我们返回 NO。由于需要搜索 _current 的匹配项,这些方法中的每一个都具有 O(n+1) 的操作成本:
-(EDSWaypoint*)startingLine
{
return [_route firstObject];
}
-(EDSWaypoint*)finishLine
{
return [_route lastObject];
}
-(EDSWaypoint*)currentWaypoint
{
return _current;
}
我们添加了 startingLine: 和 finishLine: 方法来暴露路线集合的头尾节点。最后,我们添加了 currentPosition: 方法来暴露路线中的哪个节点是我们的下一个直接目的地。这些方法中的每一个都具有 O(1) 的操作成本。
Swift
与 Objective-C 类似,Swift 没有公开任何链表数据结构的实现。因此,我们将使用 Swift 的 Array 类来创建我们的数据结构。以下是一个使用 Swift 实现此类的一个简单示例:
var _route: Array = [Waypoint]()
var _current: Waypoint?
init() { }
首先,我们声明两个属性。第一个是 _route,它是一个数组。下一个对象是 _current 节点,它是一个 Waypoint 对象,标记为 optional。同样,我们将这些声明为私有的,因为我们只允许这个类中的方法修改它们的值。我们的初始化器不需要实例化任何对象,因为声明实例化了 _route,而 _current 被标记为 optional,并且将根据需要分配:
public func addWaypoints(waypoints: Array<Waypoint>)
{
_route.appendContentsOf(waypoints)
}
public func removeWaypoint(waypoint: Waypoint) -> Bool
{
if let index = _route.indexOf(waypoint)
{
_route.removeAtIndex(index)
return true
}
return false
}
addWaypoints(Array<Waypoint>) 方法允许我们向现有路线添加 1...n 个新的航点。Array 允许我们通过调用 appendContentsOf(Array) 将新数组与现有路线合并。这个操作相当简单,成本为 O(1)。
removeWaypoint(Waypoint) 方法确认 _route 包含 waypoint 并通过调用 if .. indexOf() 在一个操作中获取其索引。如果我们没有检索到索引,我们返回 false,否则我们调用 removeAtIndex() 并返回 true。请注意,由于我们的 _route 对象是一个基于数组的列表,removeAtIndex() 操作的成本仅为 O(1):
public func insertWaypoints(waypoints: Array<Waypoint>, before: Waypoint)
{
if let index = _route.indexOf(before)
{
_route.insertContentsOf(waypoints, at:index)
} else {
addWaypoints(waypoints)
}
}
insertWaypoints(Array<Waypoint>, Waypoint) 方法首先尝试使用 if..indexOf() 定位 before 节点。如果我们找不到它,我们立即调用 addWaypoints() 将新的航点列表追加到路线中。否则,我们调用 insertContentOf()。由于这个方法代表搜索和插入,其操作成本为 O(n+1),其中 n 是现有 route 中元素的数量:
public func startRoute() -> Bool
{
if _route.count > 1
{
_current = startingLine()
return moveToNextWaypoint()
}
return false
}
startRoute: 对象用于设置我们的初始当前位置并表明它已被停用。如果我们有两个或更多航点,我们将 _current 航点设置为起点线并移动到下一个点。startRoute() 对象的操作成本为 O(1):
public func moveToNextWaypoint() -> Bool
{
if (_current != nil)
{
_current!.DeactivateWaypoint()
if _current != self.finishLine()
{
let index = _route.indexOf(_current!)
_current = _route[index!+1]
return true
}
return false;
}
return false
}
public func moveToPreviousWaypoint() -> Bool
{
if (_current != nil && _current != self.startingLine())
{
let index = _route.indexOf(_current!)
_current = _route[index!-1]
_current!.ReactivateWaypoint()
return true
}
return false
}
在 moveToNextWaypoint() 中,我们验证当前航点不是 nil,然后将其停用。接下来,我们确认我们不在终点线;如果不是,我们在列表中获取 _current 的索引,并将下一个最高索引的对象分配给 _current,然后返回 true。moveToPreviousWaypoint() 方法验证 _current 不是 nil 并确保我们不在起点线。如果是这样,我们将 _current 设置为前一个航点,重新激活它,并返回 YES。如果这两个方法中的任何检查失败,我们返回 NO。由于需要搜索 _current 的匹配项,这些方法中的每一个都有 O(n+1) 的操作成本:
public func startingLine() -> Waypoint
{
return _route.first!
}
public func finishLine() -> Waypoint
{
return _route.last!
}
public func currentWaypoint() -> Waypoint
{
return _current!;
}
我们添加了 startingLine() 和 finishLine() 方法来暴露路线集合的头和尾节点。最后,我们添加了 currentPosition() 方法来暴露路线中的哪个节点是我们的下一个直接目的地。这些方法中的每一个都有 O(1) 的操作成本。
双向链表
双向链表增加了 n 个额外的指针开销,其中 n 是列表的长度。这个额外的指针提供了对列表的简单反向遍历。这种额外的开销可以忽略不计,通常可以忽略,除非在非常特殊的情况下。追加、插入和删除操作的成本仍然只有 O(1)。
搜索
如果事先知道对象的索引,基于数组的列表提供O(1)的操作成本进行搜索。否则,对于未排序的列表,列表中的所有搜索成本为O(n),而对于应用了二分搜索模式的排序列表,搜索成本为O(log n)。第十三章搜索:找到你需要的东西将更详细地讨论二分搜索算法。
一些要点
许多语言将内存视为一系列连续的单元,每个单元的大小为一定数量的字节,并且每个单元都有一个唯一的地址。指针是内存管理工具,实际上是引用或指向内存单元地址的对象。通过利用指针,程序可以在内存中存储比单个内存块更大的对象。一些语言使用*运算符来表示指针的赋值。如果你使用 Objective-C,或者如果你已经使用过 C/C++,那么你将非常熟悉这个运算符。C#、Java 和 Swift 的开发者可能没有太多使用这个运算符的经验,但无论如何,你应该熟悉指针的工作原理,原因如下:
当内存中的对象不再有指针引用其内存地址时,它应该被释放或从内存中移除。移除未使用的对象以防止它们填满内存被称为内存管理。在一些较老的语言中,管理内存指针对于初学者来说是一项繁琐且经常出现错误的任务。大多数现代语言通过使用某种形式的内存管理设备来免除我们这种苦差事。C#、Java 和 Swift 使用所谓的垃圾回收(GC),而现代 Objective-C 提供了自动引用计数(ARC)来自动管理内存。
虽然这些工具很棒,但你不应完全依赖 GC 或 ARC 来管理你的内存。GC 和 ARC 一开始并不完美,但两者都可能因为糟糕的实现而被挫败。区分程序员和工程师的是诊断和修复内存管理问题的能力。理解指针及其使用将使你更好地捕捉 GC 或 ARC 经常错过的问题。
对指针的更深入讨论超出了本书的范围,但你绝对应该花时间研究和熟悉这个主题。更好的是,花些时间用像 C 或 C++这样的语言编写代码,这些语言使用手动内存管理。你的职业生涯会感谢你。
摘要
在本章中,你学习了列表结构的基本定义,包括排序列表和无序列表之间的区别,以及数组支持与链表之间的区别。我们讨论了如何使用我们在这本书中使用的四种语言中的每一种来初始化列表或伪列表。我们回顾了登录用户类,看看我们是否可以使用列表而不是数组来提高其性能,并了解了四种语言之间有趣的差异,包括它们在过程中使用泛型和类簇的方式。接下来,我们创建了一个代表自行车爱好者的路线的类,利用链表的特性来动态地操作和改变我们的航点集合。
在我们的高级主题部分,我们更详细地考察了列表结构的不同实现方式,包括基于数组的列表、(单链)链表和双链表。最后,对于每种实现方式,我们评估了包括追加、插入、删除和搜索节点在内的基本操作的性能。
第四章:栈:后进先出集合
栈是一种抽象数据结构,它作为一个基于后进先出(LIFO)原则插入和删除对象的集合。因此,最清楚地定义栈结构的是push操作,它向集合中添加对象,以及pop操作,它从集合中移除对象。其他常见操作包括 peek、clear、count、empty 和 full,所有这些将在本章后面的高级主题部分进行探讨。
栈可以是基于数组或基于链表的。同样,类似于链表,栈可以是排序的或未排序的。考虑到链表的结构,基于链表的栈在排序操作上比基于数组的栈更有效率。
栈数据结构非常适合任何需要仅从列表尾部添加和移除对象的应用程序。一个很好的例子是沿着指定的路径或一系列操作进行回溯。如果应用程序允许在集合的任何位置添加或移除数据,那么与我们已经考察过的数据结构相比,链表将是一个更好的选择。
在本章中,我们将介绍以下内容:
-
栈数据结构的定义
-
初始化栈
-
案例研究:运动规划算法
-
栈实现
-
常见栈操作
-
基于数组的栈
-
基于列表的栈
-
搜索
初始化栈
每种语言都为栈数据结构提供了不同级别的支持。以下是一些初始化集合、向集合中添加对象以及从集合中移除顶部对象的示例。
C#
C# 通过 Stack<T> 泛型类提供了栈数据结构的具体实现。
Stack<MyObject> aStack = new Stack<MyObject>();
aStack.Push(anObject);
aStack.Pop();
Java
Java 通过 Stack<T> 泛型类提供了栈数据结构的具体实现。
Stack<MyObject> aStack = new Stack<MyObject>();
aStack.push(anObject);
aStack.pop();
Objective-C
Objective-C 没有提供栈数据结构的具体实现,但可以通过类簇 NSMutableArray 轻易地创建。请注意,这将创建一个基于数组的栈实现,这通常比基于链表的实现效率低。
NSMutableArray<MyObject *> *aStack = [NSMutableArray array];
[aStack addObject:anObject];
[aStack removeLastObject];
UINavigationController
说不提供栈数据结构并不完全准确。任何 Objective-C 的 iOS 编程都会立即让开发者通过使用UINavigationController类接触到栈数据结构的实现。
UINavigationController 类管理导航堆栈,这是一个基于视图控制器数组的堆栈。该类公开了几个对应于基本堆栈操作的方法。这些包括 pushViewController:animated: (push), popViewControllerAnimated: (pop), popToRootViewControllerAnimated: (clear...sort of), 和 topViewController: (peek)。导航堆栈永远不会是 empty,除非它是一个 nil 对象,并且只有当你的应用添加了如此多的视图控制器以至于设备耗尽系统资源时,它才能被认为是 full。
由于这是一个基于数组的实现,你可以通过简单地检查集合本身的 count 来获取堆栈的 count。然而,这不是你可以用于应用中任何目的的集合类。如果你需要一个适用于更一般情况的堆栈,你需要自己构建一个。
Swift
与 Objective-C 一样,Swift 没有提供堆栈数据结构的具体实现,但 Array 类确实公开了一些类似堆栈的操作。以下示例演示了 popLast() 方法,它移除并返回数组中的最后一个对象:
var aStack: Array [MyObject]();
aStack.append(anObject)
aStack.popLast()
堆栈操作
并非所有堆栈数据结构的实现都公开相同的操作方法。然而,更常见的操作应该可用或根据开发者的需要提供。这些操作中的每一个,无论是基于数组实现还是基于链表实现,都有 O(1) 的操作成本。
-
push:push 操作通过向集合追加(如果它是基于数组的)或向集合添加新节点(如果它是基于链表的)来将新对象添加到堆栈中。
-
pop:pop 操作是 push 的反操作。在大多数实现中,pop 操作既移除也返回堆栈顶部的对象给调用者。
-
peek:peek 操作返回堆栈顶部的对象给调用者,但不从集合中移除该对象。
-
clear:clear 操作从堆栈中移除所有对象,有效地将集合重置为空状态。
-
count:count 操作,有时也称为大小或长度,返回集合中对象的总数。
-
empty:empty 操作通常返回一个布尔值,表示集合是否有任何对象。
-
full:full 操作通常返回一个布尔值,表示集合是否已满或是否还有空间添加更多对象。
案例研究:运动规划算法
商业问题:一位工业工程师编程一个机器人制造设备,以在部件的顺序接收器中插入螺栓,然后在每个螺栓上安装并拧紧螺母。机器人携带每个操作不同的工具,并且可以在命令下自动在它们之间切换。然而,在工具之间切换的过程增加了整体工作流程的相当多的时间,尤其是在工具在每一个螺栓上反复切换时。这已被确定为效率低下的一个来源,工程师希望提高该过程的速度,减少完成每个单元所需的总时间。
为了消除反复切换工具引入的延迟,工程师决定编程机器人先安装所有的螺栓,然后再切换工具,返回并安装所有的螺母。为了进一步提高性能,他不想让机器人重置到它原来的起始位置,而是希望它在安装螺母的同时重走自己的步骤。通过在安装螺母之前移除重置,他的工作流程消除了在部件上跨越的两个额外的遍历。为了实现他的目标,工程师需要存储在插入螺栓时移动机器人跨越部件的命令,然后以相反的顺序播放它们。
由于数据和应用的本质,表示命令的类将需要几个基本的功能。首先,它需要一个机制来添加和删除命令作为正常操作的一部分,以及在工作流程遇到错误时能够重置系统。在重置的情况下,该类必须能够报告当前等待执行命令的数量,以便计算库存损失。最后,该类应该能够轻松报告当命令列表达到容量或所有命令都已完成时。
C#
正如我们在先前的实现示例中所看到的,C#通过Stack<T>类方便地公开了一个堆栈数据结构。以下是一个简单的 C#实现的示例:
public Stack<Command> _commandStack { get; private set; }
int _capacity;
public CommandStack(int commandCapacity)
{
this._commandStack = new Stack<Command>(commandCapacity);
this._capacity = commandCapacity;
}
我们声明了两个字段。第一个是_commandStack,它代表我们的堆栈数据结构,也是这个类的核心。该字段是公开可见的,但只能由我们类中的方法修改。第二个字段是_capacity。该字段维护我们的调用者定义的集合中命令的最大数量。最后,构造函数初始化_commandStack并将commandCapacity分配给_capacity。
public bool IsFull()
{
return this._commandStack.Count >= this._capacity;
}
public bool IsEmpty()
{
return this._commandStack.Count == 0;
}
我们的首要任务是验证我们的集合。第一个验证方法IsFull()检查我们的栈是否已达到其容量。由于我们的业务规则规定,机器人必须在进入新部件之前回溯所有命令,因此我们将始终跟踪添加到我们的集合中的命令数量。如果由于任何原因我们发现我们已超过预定义的_commandStack容量,那么在之前的回溯操作中肯定出了问题,必须解决。因此,我们检查_commandStack.Count是否大于或等于_capacity并返回该值。IsEmpty()是下一个验证方法。在尝试通过查看集合读取我们的栈的任何操作之前,必须调用此方法。这两个操作的成本都是O(1)。
public bool PerformCommand(Command command)
{
if (!this.IsFull())
{
this._commandStack.Push(command);
return true;
}
return false;
}
PerformCommand(Command)方法提供了我们类的推送功能。它接受一个类型为Command的单个参数,然后检查_commandStack是否已满。如果已满,PerformCommand()方法返回false。否则,我们通过调用Stack<T>.Push()方法将command添加到我们的集合中。然后方法返回true给调用者。此操作的成本是O(1)。
public bool PerformCommands(List<Command> commands)
{
bool inserted = true;
foreach (Command c in commands)
{
inserted = this.PerformCommand(c);
}
return inserted;
}
如果调用者有一个可以连续执行的命令脚本,我们的类包括PerformCommands(List<Command>)类。
PerformCommands()方法接受一个命令列表,并通过调用PerformCommand()按顺序将它们插入到我们的集合中。此操作的成本是O(n),其中n是commands中的元素数量。
public Command UndoCommand()
{
return this._commandStack.Pop();
}
UndoCommand()方法提供了我们类的弹出功能。它不接受任何参数,但通过调用Stack<T>.Pop()从我们的栈中弹出最后一个Command。Pop()方法从我们的_commandStack集合中移除最后一个Command并返回它。如果_commandStack为空,Pop()返回一个null对象。这种行为实际上对我们有利,至少在这个代码块的作用域内是这样。由于UndoCommand()方法被设计为返回一个Command实例,如果_commandStack为空,我们无论如何被迫返回null。因此,在调用Pop()之前首先检查IsEmpty()将是浪费时间。此操作的成本是O(1)。
public void Reset()
{
this._commandStack.Clear();
}
public int TotalCommands()
{
return this._commandStack.Count;
}
我们CommandStack类的最后两种方法,Reset()和TotalCommands(),分别提供了清晰的功能和计数的功能。
Java
如前所述的实现示例所示,Java 也通过 Stack<E> 类公开了一个栈数据结构,它是 Vector<E> 的扩展,包括五个方法,允许它作为一个类操作。然而,Stack<E> 的 Java 文档建议您使用 Deque<E> 而不是 Stack<E>。然而,由于我们将在 第五章 中评估 Queue<E> 和 Deque<E>,即 队列:FIFO 集合,因此我们将在此处使用 Stack<E> 类。以下是一个简单的 Java 实现示例:
private Stack<Command> _commandStack;
public Stack<Command> GetCommandStack()
{
return this._commandStack;
}
int _capacity;
public CommandStack(int commandCapacity)
{
this._commandStack = new Stack<Command>();
this._capacity = commandCapacity;
}
我们类声明了三个字段。第一个是 _commandStack,它代表我们的栈数据结构,也是这个类的核心。该字段是私有的,但我们还声明了一个公开可见的获取器 GetCommandStack()。这是必要的,因为只有我们类中的方法应该能够修改这个集合。第二个字段是 _capacity。该字段维护我们的调用者定义的集合中的最大命令数。最后,构造函数初始化 _commandStack 并将 commandCapacity 赋值给 _capacity。
public boolean isFull()
{
return this._commandStack.size() >= this._capacity;
}
public boolean isEmpty()
{
return this._commandStack.empty();
}
再次,我们需要在开始时对我们的集合进行一些验证。第一个验证方法是 isFull(),它检查我们的栈是否已达到其容量。由于我们的业务规则规定,机器人必须在其命令全部回溯之后才能继续到新的部件,我们将跟踪添加到我们的集合中的命令数量。如果由于任何原因我们发现我们已超过 _commandStack 的预定义容量,那么在之前的回溯操作中肯定出了问题,必须解决。因此,我们检查 _commandStack.size() 是否大于或等于 _capacity 并返回该值。isEmpty() 是下一个验证方法。此方法必须在尝试通过 peek 集合读取我们的栈的任何操作之前调用。这两个操作的成本都是 O(1)。
public boolean performCommand(Command command)
{
if (!this.IsFull())
{
this._commandStack.push(command);
return true;
}
return false;
}
performCommand(Command) 方法提供了我们类中的 push 功能。它接受一个类型为 Command 的单个参数,然后检查 _commandStack 是否已满。如果已满,performCommand() 返回 false。否则,我们通过调用 Stack<t>.push() 方法将 command 添加到我们的集合中。然后该方法向调用者返回 true。此操作的成本为 O(1)。
public boolean performCommands(List<Command> commands)
{
boolean inserted = true;
for (Command c : commands)
{
inserted = this.performCommand(c);
}
return inserted;
}
如果调用者有一个可以连续执行的命令脚本,那么我们的类还包括 performCommands(List<Command>) 方法。
performCommands() 方法接受一个命令列表,并通过调用 performCommand() 依次将它们插入到我们的集合中。此操作的成本为 O(n),其中 n 是 commands 中元素的数量。
public Command undoCommand()
{
return this._commandStack.pop();
}
undoCommand() 方法提供了我们类中的 弹出 功能。它不接受任何参数,通过调用 Stack<E>.pop() 弹出我们栈中的最后一个 Command。pop() 方法从我们 _commandStack 集合中移除最后一个 Command 并返回它。如果 _commandStack 为空,pop() 返回一个 null 对象。与 C# 示例一样,这种行为在这个代码块的作用域内对我们有利。由于 undoCommand() 方法被设计为返回 Command 的一个实例,如果 _commandStack 为空,我们无论如何都会被迫返回 null。因此,在调用 pop() 之前先检查 isEmpty() 是一种浪费时间的操作。这个操作的成本是 O(1)。
public void reset()
{
this._commandStack.removeAllElements();
}
public int totalCommands()
{
return this._commandStack.size();
}
我们 CommandStack 类的最后两个方法,Reset() 和 TotalCommands(),分别提供了 清除 和 计数 功能。
Objective-C
如我们之前所见(并且很可能在文本结束前还会再次见到),Objective-C 并没有暴露出显式的具体实现栈数据结构,而是提供了 NSMutableArray 类簇来达到这个目的。有些人可能会认为这是 Objective-C 的一个弱点,指出由于没有提供开发者可能需要的每一个可想象的操作的方法,这很不方便。另一方面,也有人可能会认为 Objective-C 在其简洁性方面要强大得多,为开发者提供了一个简化的 API 和构建所需任何数据结构的基本组件。我将把这个问题的结论留给你自己得出。同时,这里有一个 Objective-C 中简单实现的例子:
@interface EDSCommandStack()
{
NSMutableArray<EDSCommand*> *_commandStack;
NSInteger _capacity;
}
-(instancetype)initWithCommandCapacity:(NSInteger)commandCapacity
{
if (self = [super init])
{
_commandStack = [NSMutableArray array];
_capacity = capacity;
}
return self;
}
我们类声明了两个 ivar 属性。第一个是 _commandStack,它代表我们的栈数据结构以及这个类的核心。这个属性是私有的,但我们还声明了一个公开可见的访问器 commandStack。这是必要的,因为只有我们类中的方法应该能够修改这个集合。第二个属性是 _capacity。这个属性维护了我们调用者定义的集合中命令的最大数量。最后,构造函数初始化 _commandStack 并将 commandCapacity 赋值给 _capacity。
-(BOOL)isFull
{
return [_commandStack count] >= _capacity;
}
-(BOOL)isEmpty
{
return [_commandStack count] == 0;
}
同样,我们需要在开始时对我们的集合进行一些验证。第一个验证方法 isFull: 检查我们的栈是否达到了其容量。由于我们的业务规则指出,机器人必须在其所有命令回溯之后才能继续到新的部件,我们将跟踪被添加到我们集合中的命令数量。如果由于任何原因我们发现我们已超过了 _commandStack 的预定义容量,那么在之前的回溯操作中肯定出了问题,必须得到解决。因此,我们检查 [_commandStack count] 是否大于或等于 _capacity 并返回该值。isEmpty: 是下一个验证方法。这两个操作的成本都是 O(1)。
注意
由于 Objective-C 对传递 nil 对象相当宽容,你可能甚至不会考虑 isEmpty: 是一个验证方法,而更像是它自己的属性。然而,考虑一下,如果这个方法被声明为一个属性,我们除了在实现文件中包含这个方法之外,还需要将其声明为 readonly。否则,Objective-C 会为我们动态生成 ivar _isEmpty,调用者可以直接修改这个值。为了简单和清晰起见,在这种情况下,仅仅声明这个值为一个方法会更好。
-(BOOL)performCommand:(EDSCommand*)command
{
if (![self isFull])
{
[_commandStack addObject:command];
return YES;
}
return NO;
}
performCommand: 方法提供了我们类中的 推送 功能。它接受一个类型为 Command 的单个参数,然后检查 _commandStack 是否已满。如果已满,performCmmand: 返回 NO。否则,我们通过调用 addObject: 方法将 command 添加到我们的集合中。然后该方法向调用者返回 YES。这个操作的成本是 O(1)。
-(BOOL)performCommands:(NSArray<EDSCommand*> *)commands
{
bool inserted = true;
for (EDSCommand *c in commands) {
inserted = [self performCommand:c];
}
return inserted;
}
如果调用者有一个可以连续执行的命令脚本,我们的类包括 performCommands: 类。performCommands: 接受一个 EDSCommand 对象的数组,并通过调用 performCommand: 将它们按顺序插入我们的集合中。这个操作的成本是 O(n),其中 n 是 commands 中元素的数量。
-(EDSCommand*)undoCommand
{
EDSCommand *c = [_commandStack lastObject];
[_commandStack removeLastObject];
return c;
}
undoCommand: 方法提供了我们类中的 弹出 功能。由于 Objective-C 没有提供堆栈结构的具体实现,我们的类在这里需要有些创新。这个方法通过调用 lastObject 从堆栈中获取顶部对象,然后通过调用 removeLastObject 从集合中移除命令。最后,它将 Command 对象 c 返回给调用者。这一系列调用有效地模拟了在 C# 和 Java 的具体堆栈实现中找到的 弹出 功能。尽管这个方法需要跳过一些障碍来完成工作,但我们始终在处理数组中的最后一个对象,因此这个操作仍然具有 O(1) 的成本。
-(void)reset
{
[_commandStack removeAllObjects];
}
-(NSInteger)totalCommands
{
return [_commandStack count];
}
再次强调,我们的 CommandStack 类的最后两个方法,reset() 和 totalCommands(),分别提供了 清除 功能和 计数 功能。遵循 Objective-C 规则!
Swift
与 Objective-C 一样,Swift 并不直接暴露堆栈数据结构的具体实现,但我们可以使用可变的、通用的 Array 类来达到这个目的。以下是一个 Swift 中简单实现的例子:
public fileprivate(set) var _commandStack: Array = [Command]()
public fileprivate(set) var _capacity: Int;
public init (commandCapacity: Int)
{
_capacity = commandCapacity;
}
我们的类声明了两个属性。第一个是 _commandStack,它代表我们的堆栈数据结构,并且是这个类的核心。这个属性是公开可见的,但只能由我们类中的方法修改。第二个属性是 _capacity。这个字段维护了我们调用者定义的集合中命令的最大数量。最后,构造函数初始化 _commandStack 并将 commandCapacity 赋值给 _capacity。
public func IsFull() -> Bool
{
return _commandStack.count >= _capacity
}
public func IsEmpty() -> Bool
{
return _commandStack.count == 0;
}
与其他语言的示例一样,我们包含了两个验证方法,分别称为 IsFull() 和 IsEmpty()。IsFull() 方法检查我们的栈是否达到了其容量。由于我们的业务规则规定,机器人必须在其命令全部回溯之后才能继续到新的部件,我们将跟踪添加到我们集合中的命令数量。如果由于任何原因我们发现我们已超过 _commandStack 的预定义容量,那么之前的回溯操作就出了问题,必须解决。因此,我们检查 _commandStack.count 是否大于或等于 _capacity 并返回该值。在尝试从我们的栈中读取操作之前,必须调用 IsEmpty()。这两个操作的成本都是 O(1)。
public func PerformCommand(_command: Command) -> Bool
{
if (!IsFull())
{
_commandStack.append(command)
return true;
}
return false;
}
PerformCommand(Command) 方法为我们类提供了 push 功能。它接受一个类型为 Command 的单个参数,然后检查 _commandStack 是否已满。如果已满,PerformCmmand() 方法返回 false。否则,我们通过调用 Array.append() 方法将 command 添加到我们的集合中。然后方法返回 true 给调用者。这个操作的成本是 O(1)。
public func PerformCommands(_commands: [Command]) -> Bool
{
var inserted: Bool = true;
for c in commands
{
inserted = PerformCommand(c);
}
return inserted;
}
如果调用者有一个可以连续执行的命令脚本,我们的类包括 PerformCommands(List<Command>) 类。PerformCommands() 接受一个命令列表,并通过调用 PerformCommand() 方法将这些命令按顺序插入到我们的集合中。这个操作的成本是 O(n),其中 n 是 commands 中元素的数量。
public func UndoCommand() -> Command
{
return _commandStack.popLast()!
}
UndoCommand() 方法为我们类提供了 pop 功能。它不接受任何参数,但通过调用 Array.popLast()! 并使用强制解包操作符来访问 return 内部的 wrapped 值,从而弹出我们栈中的最后一个 Command(假设对象不是 nil)。popLast() 方法从我们的 _commandStack 集合中移除最顶部的 Command 并返回它。如果 _commandStack 为空,popLast() 返回 nil。正如在 Java 和 Objective-C 中所见,这种行为在我们的代码块范围内对我们有利。由于 UndoCommand() 方法被设计为返回 Command 的一个实例,如果 _commandStack 为空,我们无论如何都会被迫返回 nil。因此,在调用 popLast() 之前首先检查 IsEmpty() 是一种浪费时间的行为。这个操作的成本是 O(1)。
public func Reset()
{
_commandStack.removeAll()
}
public func TotalCommands() -> Int
{
return _commandStack.count;
}
我们 CommandStack 类的最后一个方法对,Reset() 和 TotalCommands(),分别提供了 clear 和 count 功能。
注意
空合并运算符,或称为其他语言中的空合并运算符,是更冗长的三元运算符和显式的if...else语句的简写。例如,C#和 Swift 将??指定为这个运算符。Swift 更进一步,包括!,或解包运算符,用于返回值是可选的或可能为 nil 的情况。Swift 中的??运算符在解包可选类型时定义默认值是必要的。
高级主题 - 栈实现
现在我们已经看到了栈在常见实践中的应用,让我们来考察你可能会遇到的不同类型的栈实现。最常见的两种实现是基于数组的栈和基于链表的栈。我们将在下面考察每一种。
基于数组的栈
基于数组的栈使用可变数组来表示集合。在这个实现中,数组的 0 位置代表栈的底部。因此,array[0]是第一个推入栈中的对象,也是最后一个弹出栈的对象。基于数组的结构对于排序栈来说并不实用,因为任何对结构的重新组织都会比基于列表的栈需要显著更多的操作成本。汉诺塔问题是一个典型的基于数组的排序示例,其操作成本为O(2^n),其中n是起始塔上的盘子数量。汉诺塔问题将在第十二章中更详细地考察,排序:从混乱中带来秩序。
基于链表的栈
基于链表的栈使用一个指向栈中底部对象的指针,以及随着每个新对象从列表中的最后一个对象链接而来,后续的指针。从栈顶弹出对象只是简单地从集合中移除最后一个对象。对于需要排序数据的应用,链表栈要高效得多。
摘要
在本章中,我们学习了栈数据结构的基本定义,包括如何在所讨论的四种语言中初始化结构的具体实现。接下来,我们讨论了与栈数据结构相关联的最常见操作及其操作成本。我们通过一个案例研究来考察使用栈跟踪传递给机器人制造设备的命令。这些例子展示了 C#和 Java 如何提供栈的具体实现,而 Objective-C 和 Swift 则没有。最后,我们考察了两种最常见的栈类型,基于数组和基于链表的,并展示了基于数组的栈不适合用于排序栈。
第五章。队列:FIFO 集合
队列是一种抽象数据结构,它作为基于先进先出(FIFO)原则插入和删除对象的线性集合。队列最显著的操作是入队,它将对象添加到集合的尾部或末尾,以及出队,它从集合的头部或前端移除对象。以下图示展示了队列数据结构以及这两个基本操作。其他常见操作包括查看、空和满,所有这些将在本章后面进行探讨:

队列与栈非常相似,它们共享一些相同的功能。甚至它们的主要操作也非常相似,只是基于相反的原则。像栈一样,队列可以是基于数组的或基于链表的,而在大多数情况下,基于链表的版本更有效率。然而,与可以排序或未排序的栈不同,队列根本不打算排序,并且每次向集合中添加对象时对队列进行排序会导致可怕的O(n.log(n))操作成本。队列的一个替代版本,称为优先队列,基于堆数据结构。优先队列支持一种排序类型,但这仍然很昂贵,并且通常只在特殊应用中使用。
总体而言,队列数据结构非常适合任何需要基于先来先服务原则对操作进行优先级排序的应用程序。如果你在可视化队列结构时遇到困难,只需想想你曾经排队等待的任何时刻。在小学时,我们等待饮水机;在超市里,我们等待收银员;在熟食店,我们等待我们的号码;在各个政府办公室,我们等待(并且等待)下一个可用的出纳员。实际上,我们自出生以来就一直在排队...除非你是双胞胎,那样你比我们中的大多数人开始得早一点。
在本章中,我们将涵盖以下主题:
-
队列数据结构的定义
-
初始化队列
-
案例研究 - 客户服务
-
队列实现
-
常见队列操作
-
基于数组的队列
-
基于列表的队列
-
基于堆的队列
-
双端队列
-
优先队列
初始化队列
每种语言对队列数据结构的支持程度不同。以下是一些初始化集合、向集合的尾部添加对象以及从集合的头部移除头对象的示例。
C#
C#通过Queue<T>泛型类提供了队列数据结构的具体实现:
Queue<MyObject> aQueue = new Queue<MyObject>();
aQueue.Enqueue(anObject);
aQueue.Dequeue();
Java
Java 提供了抽象的 Queue<E> 接口,并且有几个队列数据结构的具体实现使用了这个接口。队列也被扩展到 Deque<E> 接口,它代表一个双端队列。ArrayDeque<E> 类是 Deque<E> 接口的一个具体实现:
ArrayDeque<MyObject> aQueue = new ArrayDeque<MyObject>();
aQueue.addLast(anObject);
aQueue.getFirst();
Objective-C
Objective-C 没有提供队列数据结构的具体实现,但可以使用 NSMutableArray 类簇轻松创建。请注意,这将创建一个基于数组的队列实现,这通常不如基于链表的实现高效:
NSMutableArray<MyObject *> *aStack = [NSMutableArray array];
[aStack addObject:anObject];
[aStack removeObjectAtIndex:0];
注意
我对使用 NSMutableArray 实现的栈和队列在效率上的可测量差异感到好奇,因此我进行了一系列简单的测试。在这些测试中,我首先实例化了一个包含 1,000,000 个 EDSUser 对象的 NSMutableArray 对象。在第一个测试中,我将数组视为栈,并通过调用 removeLastObject 对象依次从数组的尾部弹出每个项目。在第二个测试中,我将数组视为队列,并通过调用 removeObjectAtIndex:0 依次从数组的头部出队每个用户。使用 for 循环,我对每个测试序列进行了 1,000 次操作,然后平均了每次迭代移除所有对象所需的时间。我预计队列结构将和栈结构表现相当,或者略低效,所以我对这些结果感到惊讶:
平均栈时间:0.202993
平均队列时间:0.184913
如您所见,队列结构实际上表现得略好于栈结构,平均快了大约 18 毫秒。当然,不同环境下的结果会有所不同,18 毫秒几乎可以忽略不计,但经过这些测试,我有信心地说,NSMutableArray 类足够高效,可以作为队列结构使用。如果您想亲自运行测试,请在 Objective-C 代码文件中的 EDSCollectionTests 执行静态方法 stackTest 和 queueTest。
Swift
与 Objective-C 类似,Swift 没有提供队列数据结构的具体实现,但可以使用 Array 类来实现该结构。以下示例演示了 append() 和 popLast() 方法:
var aStack: Array [MyObject]();
aStack.append(anObject)
aStack.popLast()
队列操作
并非所有队列数据结构的实现都公开相同的操作方法。然而,更常见的操作应该可用,或者根据开发者的需要提供:
-
入队:入队操作通过向集合追加(如果它是基于数组的)或向集合添加新节点(如果它是基于链表的)将新对象添加到队列的末尾。
-
出队:出队操作是入队的相反操作。在大多数实现中,出队操作会从数组或列表中移除并返回第一个对象给调用者。
-
查看:查看操作返回数组或列表中的第一个对象给调用者,但不从集合中移除该对象。
-
计数:计数操作返回集合中当前对象或节点的总数。
-
空载:空载操作通常返回一个布尔值,表示集合中是否有对象。
-
满载:满载操作通常返回一个布尔值,表示集合是否已满载或是否仍有空间添加更多对象。并非所有实现都允许调用者定义容量,但这个细节可以通过队列计数轻松添加。
案例研究:客户服务
业务问题:一家小型软件公司希望通过一款移动应用程序进入新市场,该应用程序用于跟踪在机动车管理局(DMV)服务地点的客户服务请求。该应用程序将允许用户在通过代表服务区域的地理围栏时使用他们的移动设备取号。这将允许客户立即移动到下一个可用的窗口或坐下舒适地等待工作人员协助他们。一个主要业务需求是,服务将按照先到先得的原则向客户提供服务。除了业务需求外,团队还希望以通用设计实现核心功能,这样他们就可以在不修改底层业务逻辑的情况下扩展到新市场。
负责创建核心功能的主开发人员决定,跟踪每位客户在队列中位置的类应该与网络服务捆绑在一起。这个类在正常操作中需要一些机制来添加和删除客户,以及在办公室当天关闭时能够清除等待名单上的所有客户。由于客户通常想知道在与工作人员交谈之前他们需要等待多长时间,因此该类还必须能够报告当前等待被接待的客户总数以及当前客户前面的客户数量。如果客户的移动设备再次穿越地理围栏,他们实际上已经离开了服务区域,并放弃了他们在队列中的位置。因此,尽管从队列中间移除对象不是队列操作,但该类还应该能够在客户在与工作人员交谈之前离开时取消他们在队列中的位置。最后,该类应该能够报告客户名单是否为空以及它是否达到了位置的占用上限。
C#
如实现示例所示,C# 在 Queue<T> 类中提供了队列支持。这个类是泛型的,它包括了实现 CustomerQueue 类所需的所有基本操作。以下是一个使用 C# 的简单实现的示例:
Queue<Customer> _custQueue;
int _cap;
public CustomerQueue(int capacity)
{
_custQueue = new Queue<Customer>();
_cap = capacity;
}
我们这个类声明了两个字段。第一个是 _custQueue,它代表我们的队列数据结构以及这个类的核心。该字段是私有的,因此只有我们类中的方法可以修改它。第二个字段是 _cap,它维护我们调用者定义的集合中客户的最大数量。最后,构造函数初始化 _custQueue 并将 capacity 赋值给 _cap:
private bool CanCheckinCustomer()
{
return this._custQueue.Count < this._cap;
}
CanCheckinCustomer() 方法通过确认 _custQueue.Count 小于定义的容量并返回结果来对 CustomerQueue 进行简单的验证:
public void CustomerCheckin(Customer c)
{
if (this.CanCheckinCustomer())
{
this._custQueue.Enqueue(c);
}
}
两个基本队列操作中的第一个,入队,被封装在 CustomerCheckin(Customer) 方法中。这个方法验证一个新的 Customer 对象可以被添加,然后调用 Enqueue(T) 将 c 添加到 _custQueue 集合中。这个操作的成本是 O(1):
public Customer CustomerConsultation()
{
return this._custQueue.Peek();
}
为了保持当前排队等待的客户准确数量,我们不希望在一位员工处理完客户的需求或咨询之前就出队一个客户。因此,当客户到达队列头部时,CustomerConsultation() 方法会调用 Peek()。这个方法返回 _custQueue 中的下一个 Customer 对象,但不会从集合中移除该对象。实际上,这个方法提供了发送“正在服务:”消息或类似消息所需的数据。这个操作的成本是 O(1):
public void CustomerCheckout()
{
this._custQueue.Dequeue();
}
一位员工完成与当前客户的交易后,客户在队列中的位置可以被清除。CustomerCheckout() 方法调用 Dequeue() 方法,从 _custQueue 的前端位置移除 Customer 对象。这个操作的成本是 O(1):
public void ClearCustomers()
{
this._custQueue.Clear();
}
当是时候关闭大门时,我们的类需要一种方法来清理滞留的客户。ClearCustomers() 方法提供了清除功能,因此我们的类可以将集合重置为空状态:
public void CustomerCancel(Customer c)
{
Queue<Customer> tempQueue = new Queue<Customer>();
foreach (Customer cust in this._custQueue)
{
if (cust.Equals(c))
{
continue;
}
tempQueue.Enqueue(c);
}
this._custQueue = tempQueue;
}
CustomerCancel(Customer) 方法引入了非队列操作,用于从 _custQueue 集合中移除 Customer 对象。由于 Queue<T> 没有提供执行此操作的接口,我们需要进行改进。该方法首先创建一个临时的队列集合,tempQueue,然后遍历 _custQueue 中的每一个 Customer 对象。如果 cust 不等于 c,则将其添加到 tempQueue。当我们的 for 循环完成后,只有仍然在队列中的客户会被添加到 tempQueue。最后,将 tempQueue 赋值给 _custQueue。这个操作的成本是 O(n),但这是可以接受的,因为这个方法不应该经常作为正常操作的一部分被调用:
public int CustomerPosition(Customer c)
{
if (this._custQueue.Contains(c))
{
int i = 0;
foreach (Customer cust in this._custQueue)
{
if (cust.Equals(c))
{
return i;
}
i++;
}
}
return -1;
}
为了以任何程度的准确性估计客户的当前等待时间,有必要知道他们在队列中的位置,而CustomerPosition(Customer)方法为我们提供了这个功能。再次强调,Queue<T>不提供这个功能,因此我们需要自己编写。CustomerPosition(Customer)方法检查_custQueue是否包含我们要找的Customer。如果集合不包含Customer c,则该方法返回-1。否则,它将遍历整个集合,直到找到c。位于队列末尾的Customer c对象是Queue<T>.Contains(T)方法和foreach循环的最坏情况,每个都代表一个O(n)的成本。由于这些操作是嵌套的,因此该方法的总成本为O(2n):
public int CustomersInLine()
{
return this._custQueue.Count;
}
public bool IsLineEmpty()
{
return this._custQueue.Count == 0;
}
public bool IsLineFull()
{
return this._custQueue.Count == this.cap;
}
最后三个方法,CustomersInLine()、IsLineEmpty()和IsLineFull(),为我们这个类引入了计数、空和满功能。这些操作的成本都是O(1)。
提示
嵌套循环
当你发现自己嵌套循环时,总是要仔细检查。从整体实现来看,CustomerPosition()方法有两个特别需要注意的原因。对于这样一个简单的操作,O(2n)的操作成本非常高。此外,由于用户在最佳情况下也倾向于缺乏耐心,因此他们可能会几乎不断监控预期的等待时间。这种行为将转化为对CustomerPosition()方法的多次调用。可以说,这种低效在实践中可以忽略不计,因为处理一个物理队列中的人的列表所需的时间,即使是等待进入体育场的队列,也是微不足道的。然而,成本为x^n(其中x > 1)的算法有一个糟糕的代码味道,大多数开发者会在将其发布到野外之前尝试构建一个更好的解决方案。
Java
如在实现示例中讨论的那样,Java 支持几个具体的列表实现,这些实现可以用于队列类,但最合适的版本符合双端队列Dequeue<E>接口。一个具体的实现是ArrayQueue<E>类。以下是一个使用 Java 的ArrayQueue<E>类进行简单实现的示例:
ArrayQueue<Customer> _custQueue;
int _cap;
public CustomerQueue(int capacity)
{
_custQueue = new ArrayDeque<Customer>();
_cap = capacity;
}
我们这个类声明了两个字段。第一个是_custQueue,它代表我们的队列数据结构以及这个类的核心。该字段是私有的,因此只有我们类中的方法可以修改它。第二个字段是_cap,它维护我们的调用者定义的集合中客户的最大数量。最后,构造函数初始化_custQueue并将capacity分配给_cap:
private boolean canCheckinCustomer()
{
return this._custQueue.size() < this._cap;
}
canCheckinCustomer()方法通过确认_custQueue.size()小于定义的容量并返回结果来为CustomerQueue添加简单的验证:
public void customerCheckin(Customer c)
{
if (this.canCheckinCustomer())
{
this._custQueue.addLast(c);
}
}
两个基本队列操作中的第一个,enqueue,被封装在 customerCheckin(Customer) 方法中。这个方法确认我们可以向队列中添加一个新的 Customer,然后调用 AddLast(E) 将 c 添加到 _custQueue 集合中。这个操作的成本是 O(1):
public Customer customerConsultation()
{
return this._custQueue.peek();
}
为了保持当前排队等待的客户准确数量,我们不希望在一位员工处理完客户的需求或咨询之前就出队一个客户。因此,当客户到达队列头部时,customerConsultation() 方法会调用 peek()。这个方法返回 _custQueue 中的下一个 Customer 对象,但不会从集合中移除该对象。这个操作的成本是 O(1):
public void customerCheckout()
{
this._custQueue.removeFirst();
}
一位员工完成与当前客户的交易后,客户的队列位置可以被清除。customerCheckout() 方法调用 Dequeue(),从 _custQueue 的前端位置移除 Customer 对象。这个操作的成本是 O(1):
public void clearCustomers()
{
this._custQueue.clear();
}
ClearCustomers() 方法提供了 clear 功能,因此我们的类可以将集合重置为空状态:
public void customerCancel(Customer c)
{
this._custQueue.remove(c);
}
customerCancel(Customer) 方法引入了非队列操作来从 _custQueue 集合中移除 Customer 对象。由于 ArrayQueue<E> 提供了 remove(E) 方法来从队列中移除任何匹配 E 的对象,customerCancel(Customer) 简单地调用该方法。这个操作的成本是 O(n),但在正常操作中这个方法不应该经常被调用:
public int customerPosition(Customer c)
{
if (this._custQueue.contains(c))
{
int i = 0;
for (Customer cust : this._custQueue)
{
if (cust.equals(c))
{
return i;
}
i++;
}
}
return -1;
}
为了以任何形式的准确性估计客户的当前等待时间,有必要知道他们在队列中的位置。customerPosition(Customer) 方法为我们提供了位置功能。ArrayQueue<E> 接口不提供这个功能,因此我们需要自己编写。CustomerPosition(Customer) 检查 _custQueue 是否包含我们要找的 Customer。如果集合不包含 Customer c,该方法返回 -1。否则,它将遍历整个集合,直到找到 c。位于队列末尾的 Customer c 对象是 Queue<T>.Contains(T) 方法和 foreach 循环的最坏情况,每个都代表一个 O(n) 成本。由于这些操作是嵌套的,这个方法的总成本是 O(2n):
public int customersInLine()
{
return this._custQueue.size();
}
public boolean isLineEmpty()
{
return this._custQueue.size() == 0;
}
public boolean isLineFull()
{
return this._custQueue.size() == this._cap;
}
最后三个方法,customersInLine()、isLineEmpty() 和 isLineFull(),为我们的类引入了 count、empty 和 full 功能。这些操作的成本都是 O(1)。
Objective-C
如前所述,Objective-C 不提供队列数据结构的具体实现,但可以使用 NSMutableArray 类簇轻松模拟。以下是一个简单实现的示例:
NSMutableArray *_custQueue;
int _cap;
-(instancetype)initWithCapacity:(int)capacity
{
if (self = [super init])
{
_custQueue = [NSMutableArray array];
_cap = capacity;
}
return self;
}
我们的类声明了两个 ivar 属性。第一个是一个名为 _custQueue 的 NSMutableArray 对象,它代表我们的队列数据结构以及这个类的核心。第二个字段是 _cap。这个字段维护了我们调用者定义的集合中客户的最大数量。这两个都是 ivars,所以只有我们类中的方法可以修改它们的值。最后,构造函数初始化 _custQueue 并将 capacity 赋值给 _cap:
-(BOOL)canCheckinCustomer
{
return [_custQueue count] < _cap;
}
canCheckinCustomer: 通过确认 [_custQueue count] 小于定义的容量并返回结果来为 CustomerQueue 添加简单的验证:
-(void)checkInCustomer:(EDSCustomer*)c
{
if ([self canCheckinCustomer])
{
[_custQueue addObject:c];
}
}
两个基本队列操作中的第一个,入队,被封装在 checkInCustomer: 方法中。这个方法确认我们可以向队列中添加一个新的 Customer,然后调用 addObject: 方法将 c 添加到 _custQueue 集合中。这个操作的成本是 O(1):
-(EDSCustomer*)customerConsultation
{
return [_custQueue firstObject];
}
为了保持当前排队等待的客户准确数量,我们不希望在一位同事处理完他们的需求或咨询之前就 出队 一个客户。因此,当客户到达队列头部时,customerConsultation: 方法返回 firstObject。这返回了 _custQueue 中的下一个 Customer 对象,但不会从集合中移除该对象。这个操作的成本是 O(1):
-(void)checkoutCustomer
{
[_custQueue removeObjectAtIndex:0];
}
一位同事完成与当前客户的交易后,可以清除客户在队列中的位置。checkoutCustomer: 方法调用 removeObjectAtIndex:0,从 _custQueue 的前端位置移除 Customer 对象。这个操作的成本是 O(1):
-(void)clearCustomers
{
[_custQueue removeAllObjects];
}
clearCustomers: 方法提供了 清除 功能,因此我们的类可以将集合重置为空状态:
-(void)cancelCustomer:(EDSCustomer*)c
{
NSUInteger index = [self positionOfCustomer:c];
if (index != -1)
{
[_custQueue removeObjectAtIndex:index];
}
}
cancelCustomer: 方法引入了非队列操作来从 _custQueue 集合中移除 Customer 对象。由于 NSMutableArray 提供了 removeObjectAtIndex: 属性,cancelCustomer: 简单地调用该方法。这个操作的成本是 O(n+1),但在正常操作中不应频繁调用此方法:
-(NSUInteger)positionOfCustomer:(EDSCustomer*)c
{
return [_custQueue indexOfObject:c];
}
为了以任何程度的准确性估计客户的当前等待时间,有必要知道他们在队列中的位置。positionOfCustomer: 属性通过简单地返回 indexOfObject: 为我们的类提供位置功能。这个操作的成本是 O(n):
-(NSUInteger)customersInLine
{
return [_custQueue count];
}
-(BOOL)isLineEmpty
{
return [_custQueue count] == 0;
}
-(BOOL)isLineFull
{
return [_custQueue count] == _cap;
}
最后三个方法,customersInLine()、isLineEmpty() 和 isLineFull(),为我们的类引入了 计数、空 和 满 功能。每个操作的成本都是 O(1)。
Swift
如前所述,Swift 没有提供队列数据结构的具体实现,但可以使用 Array 类轻松模拟。以下是一个简单实现的示例:
var _custQueue: Array = [Customer]()
var _cap: Int;
public init(capacity: Int)
{
_cap = capacity;
}
我们的这个类声明了两个属性。第一个是一个名为 _custQueue 的 Customer 数组,它代表我们的队列数据结构和这个类的核心。第二个字段是 _cap。这个字段维护我们的调用者定义的集合中客户的最大数量。这两个都是私有的,所以只有我们这个类的方法可以修改它们的值。最后,构造函数初始化 _custQueue 并将 capacity 赋值给 _cap:
public func canCheckinCustomer() -> Bool
{
return _custQueue.count < _cap
}
canCheckinCustomer() 方法通过确认 _custQueue.count 小于定义的容量并对结果进行返回,为 CustomerQueue 添加了简单的验证:
public func checkInCustomer(c: Customer)
{
if canCheckinCustomer()
{
_custQueue.append
}
}
两个基本队列操作中的第一个,入队,被封装在 checkInCustomer() 方法中。这个方法确认我们可以向队列中添加一个新的 Customer,然后调用 append() 将 c 添加到 _custQueue 集合中。这个操作的成本是 O(1):
public func customerConsultation() -> Customer
{
return _custQueue.first!
}
当一个客户到达队列头部时,customerConsultation() 方法调用 first!。这返回 _custQueue 中的下一个 Customer,但不从集合中移除该对象。这个操作的成本是 O(1):
public func checkoutCustomer()
{
_custQueue.removeFirst()
}
一旦一个关联方完成了与其当前客户的交易,客户的队列位置可以被清除。checkoutCustomer() 方法调用 removeFirst,从 _custQueue 的前端位置移除 Customer 对象。这个操作的成本是 O(1):
public func clearCustomers()
{
_custQueue.removeAll()
}
clearCustomers() 方法提供了 清除 功能,因此我们的类可以将集合重置为空状态:
public func cancelCustomer(c: Customer)
{
if let index = _custQueue.index(of: c)
{
_custQueue.removeAtIndex(at: index)
}
}
cancelCustomer(Customer) 方法引入了非队列操作,用于从 _custQueue 集合中移除 Customer 对象。由于 Array 不提供简单的移除类型方法,我们需要再次进行改进。我们的代码首先使用 indexOf() 设置条件 var index。如果 index 有值,该方法将 index 传递给 removeAtIndex()。这个操作的成本是 O(n+1):
注意
在 Swift 实现中,我们没有调用实例方法 positionOfCustomer()。这是因为 let ... = 符号是一个 条件绑定 的初始化器,而 positionOfCustomer() 返回 Int,这不是一个可选值。由于 positionOfCustomer() 和此方法都使用了相同的 indexOf() 方法调用,所以在操作成本上没有差异。
这个代码的示例如下:
public func positionOfCustomer(c: Customer) -> Int
{
return _custQueue.index(of:c)!
}
为了以任何形式的准确性估计客户的当前等待时间,有必要知道他们在队列中的位置。positionOfCustomer() 方法通过简单地返回 indexOf() 为我们的类提供位置功能。这个操作的成本是 O(n):
public func customersInLine() -> Int
{
return _custQueue.count
}
public func isLineEmpty() -> Bool
{
return _custQueue.count == 0
}
public func isLineFull() -> Bool
{
return _custQueue.count == _cap
}
最后三个方法,customersInLine()、isLineEmpty()和isLineFull(),引入了我们类的计数、空和满功能。每个操作的成本为O(1)。总的来说,Swift 队列实现与其在 C#、Java 和 Objective-C 中的对应物非常相似,尽管 Swift 语言与其他语言有相当大的不同。
高级主题
队列数据结构可以建立在几种不同的底层数据结构之上。每个基础提供了不同的优势,而选择哪种类型通常取决于应用程序的需求。最常见的三种实现是数组基础、链表基础和堆基础。
队列数据结构还有两种额外的变体,包括双端队列和优先队列。同样,每种变体都提供了优势和劣势,而选择哪种类型将主要取决于应用程序的需求。
基于数组的队列
基于数组的队列使用可变数组来表示队列。Objective-C 和 Swift 中的两个示例都采用这种形式。在这个实现中,数组的[0]位置代表队列的头部或前端。尽管一般来说,队列是严格的 FIFO 集合,开发者不应尝试对它们进行排序,但基于数组的队列排序特别困难和昂贵。如果你的应用程序绝对需要有序集合,你应该考虑使用其他数据结构,例如列表。
基于链表的队列
基于链表的队列使用一个指向队列前端的指针,以及随着每个新对象被附加到集合上而后续的指针。从队列前端移除对象只需将头指针从节点 0 的对象移动到节点 1 的对象。如果你的对象集合必须是有序队列,则优先选择基于链表的队列而不是基于数组的队列。
基于堆的队列
基于堆的队列是一个使用堆集合作为其后盾创建的队列。堆本身是专门化的基于树的数据库结构,其中对象根据对象的某些值或属性自然排序为升序(最小堆)或降序(最大堆)。
注意
堆不应与计算机系统的堆(或动态分配的内存池)混淆。我们将在第十章(part0058_split_000.html#1NA0K1-77f2b5b248f04368a6f723b0e9357ef3 "第十章。堆:有序树")中更详细地讨论堆的概念,堆:有序树。将在第十二章(part0069_split_000.html#21PMQ2-77f2b5b248f04368a6f723b0e9357ef3 "第十二章。排序:从混乱中带来秩序")中广泛讨论排序堆数据结构的方法。
双端队列
双端队列是一个集合,对象可以添加到或从队列的前端或后端移除。ArrayQueue<E>接口是 Java 对Queue<E>接口的具体实现,它是一个双端队列的例子。
优先队列
优先队列根据某些值或优先级对集合中的对象进行排序。由于堆的自然层次结构,优先队列通常被实现为一个基于堆的队列。在这种设计中,具有更高优先级的对象自然地排列在队列的前端附近,因此每次出队时,总是具有最高优先级的对象。在两个或多个对象具有相同优先级的情况下,已在队列中等待时间最长的对象将首先出队。
摘要
在本章中,你学习了队列数据结构的基本定义,包括如何在所讨论的四种语言中初始化该结构的具体实现。接下来,我们讨论了与队列数据结构相关联的最常见操作及其操作成本。我们考察了一个使用队列跟踪在先到先得队列中等待的客户的应用示例。这些示例展示了 C#和 Java 如何提供队列的具体实现,而 Objective-C 和 Swift 则没有。随后,我们考察了三种最常见的队列实现类型,包括基于数组、基于链表和基于堆的队列。最后,我们探讨了队列数据结构的双端和优先级变体。
第六章。字典:键值集合
字典是一种抽象数据结构,可以被描述为一个键的集合及其相关值的集合,其中每个键在集合中只出现一次。这种键和值之间的关联关系是为什么字典有时被称为关联数组。字典也被称为映射,或者更具体地说,对于基于哈希表的字典称为哈希映射,对于基于搜索树的字典称为树映射。与字典相关联的四个最常见函数是添加、更新、获取和删除。其他常见操作包括包含、计数、重新分配和设置。这些操作将在本章后面详细探讨。
字典的映射或关联性质使得插入、搜索和更新操作非常高效。通过在创建、编辑或获取值时指定键,大多数在设计良好的字典中的操作都具有最小的O(1)成本。也许正因为这种效率,字典是你日常开发经验中最常见的几种数据结构之一。
你可能会想知道,为什么描述为键值对的集合应该被称为字典。这个名字是类比于物理字典,其中每个单词(键)都有一个相关的定义(值)。如果这仍然有点抽象,可以考虑一个代客泊车服务。当你把车停在活动地点时,你从车里出来,有人在你离开前给你一张小票,然后开车离开。这张小票代表你的车,只代表你的车。没有其他带有相同标识符的小票与你现在持有的相同。因此,唯一能够取回你的车的方式就是向代客泊车服务出示这张特定的小票。一旦你这样做,有人就会带着你的车过来,你给他们小费,然后你开车离开。
这个过程是字典数据结构的一个具体例子。每张小票代表一个键,而每辆车代表某种类型的值。每个键都是唯一的,并且唯一地标识一个特定的值。当你的代码调用一个值时,代客泊车服务就是使用键来定位和返回你正在寻找的值的集合。给你的开发机器小费是可选的。
在本章中,我们将涵盖以下主题:
-
字典数据结构的定义
-
初始化字典
-
哈希表
-
常见的字典操作
-
案例研究 - 游艺厅票券总额
-
基于哈希表的字典
-
基于搜索树的字典
初始化字典
字典如此普遍,难怪我们正在检查的每种语言都通过具体的实现来支持它们。以下是一些初始化字典、向集合中添加几个键值对,然后从集合中删除这些对之一的示例。
C#
C# 通过 Dictionary<TKey, TValue> 类提供了字典数据结构的具体实现。由于这个类是泛型的,调用者可以定义用于键和值的类型。以下是一个示例:
Dictionary<string, int> dict = new Dictionary<string, int>();
这个示例初始化了一个新的字典,其中键将是 string 类型,值将是 int 类型:
dict.Add("green", 1);
dict.Add("yellow", 2);
dict.Add("red", 3);
dict.Add("blue", 4);
dict.Remove("blue");
Console.WriteLine("{0}", dict["red"]);
// Output: 3
Java
Java 提供了一个 Dictionary<K, V> 类,但最近已经弃用,转而使用实现了 Map<K, V> 接口的任何类。在这里,我们将查看 HashMap<K, V> 类的一个示例。这个类扩展了 AbstractMap<K, V> 并实现了 Map<K, V> 接口:
HashMap<String, String> dict = new HashMap<String, String>();
dict.put("green", "1");
dict.put("yellow", "2");
dict.put("red", "3");
dict.put("blue", "4");
dict.remove("blue");
System.out.println(dict.get("red"));
// Output: 3
这个类被称为 HashMap,因为它是一个具体的、基于哈希表的映射实现。值得注意的是,Java 不允许在 HashMap 类中使用原始数据类型作为键或值,因此在我们前面的例子中,我们用 String 类型替换了我们的值。
提示
哈希表
由于 Java 的一个字典实现被称为 哈希表,这似乎是介绍 哈希表 的好时机,有时也称为哈希表。哈希表使用 哈希函数 将数据映射到数组中的索引位置。技术上讲,哈希函数是任何可以将随机大小的数据绘制到静态大小的数据的函数。
在设计良好的哈希表中,搜索、插入和删除函数的成本为 O(1),因为复杂性不依赖于集合中元素的数量。在许多情况下,与数组、列表或其他查找数据结构相比,哈希表要高效得多。这就是它们经常被用来构建字典的原因。这也是它们通常用于数据库索引、缓存以及作为 集合 数据结构基础的原因。我们将在第七章 Chapter 7 中更详细地讨论集合。集合:无重复。
事实上,哈希表是一种数据结构,尽管它们最常用于创建关联数组。那么,我们为什么不对哈希表数据结构进行更深入的探讨呢?在大多数语言中,对于类似的应用,字典比哈希表更受欢迎。这是因为字典是 泛型类型化的,而哈希表依赖于语言的根对象类型来内部分配值,例如 C# 的 object 类型。虽然哈希表允许几乎任何对象用作键或值,但泛型类型的字典将限制调用者只能将声明的类型对象作为元素的键或值。这种方法既类型安全又更高效,因为值不需要在每次更新或检索值时进行 装箱 和 拆箱(类型转换)。
话虽如此,不要犯下这样一个错误:认为字典仅仅是另一种名称的哈希表。确实,哈希表大致对应于Dictionary<object, object>的一些变体,但它是一个不同的类,具有不同的功能和方法。
Objective-C
Objective-C 提供了不可变和可变的字典类,分别是NSDictionary和NSMutableDictionary。由于我们将在后面的示例中使用可变字典,所以我们在这里只考察NSDictionary。NSDictionary可以使用@{K : V, K : V}语法初始化一个字面量数组,其中包含1...n个键/值对。还有两种常见的初始化方法。第一种是dictionaryWithObjectsAndKeys:,它接受一个以nil结尾的对象/键对数组。第二种是dictionaryWithObjects:forKeys:,它接受一个对象数组和第二个键数组。与 Java 的HashMap类似,Objective-C 的NSDictionary和NSMutableDictionary类簇不允许使用标量数据作为键或值:
NSDictionary *dict = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithInt:1], @"green",
[NSNumber numberWithInt:2], @"yellow",
[NSNumber numberwithInt:3], @"red", nil];
NSArray *colors = @[@"green", @"yellow", @"red"];
NSArray *positions = @[[NSNumber numberWithInt:1],
[NSNumber numberWithInt:2],
[NSNumber numberWithInt:3]];
dict = [NSDictionary dictionaryWithObjects:positions forKeys:colors];
NSLog(@"%li", (long)[(NSNumber*)[_points valueForKey:@"red"] integerValue]);
// Output: 3
你可能会注意到dictionaryWithObjects:forKeys:方法更冗长,这使得它稍微易于阅读。然而,你必须格外小心,确保你的键和值正确地映射到对方。
Swift
Swift 中的字典是通过Dictionary类创建的。当使用var初始化为变量时,Swift 字典是可变的,但也可以通过使用let初始化为常量来创建不可变字典。字典中使用的键可以是整数或字符串。Dictionary类还可以接受任何类型的值,包括在其他语言中通常被认为是原生的类型,因为这些在 Swift 中实际上是命名类型,并且使用结构体在 Swift 标准库中定义。在任一情况下,都必须在初始化集合时声明你的键和值类型,并且之后不能更改。由于我们将在后面使用一个变量或可变字典,所以我们在这里初始化一个常量不可变集合:
let dict:[String: Int] = ["green":1, "yellow":2, "red":3]
print(dict[red])
// Output: 3
注意
我们将在第八章中更详细地考察结构体,结构体:复杂类型。
字典操作
并非所有字典数据结构的具体实现都公开了相同的操作方法。然而,更常见的操作应该是可用的,或者根据开发者的需要提供。以下是一些操作:
-
add:add 操作,有时称为插入,向集合中引入一个新的键/值对。add 操作具有O(1)的成本。
-
get:get 操作,有时称为查找,返回与给定键关联的值。如果找不到给定键的值,某些字典将引发一个异常。通过指定键,get 操作具有O(1)的成本。
-
更新:更新操作允许调用者修改集合中已经存在的值。并非所有字典实现都提供定义明确的更新方法,而是通过引用支持更新值。这意味着一旦使用获取操作从字典中取出对象,就可以直接修改它。通过指定键,更新操作的成本为O(1)。
-
移除:移除或删除操作将根据有效的键从集合中删除键/值对。大多数字典将优雅地忽略指定的不存在键。通过指定键,移除操作的成本为O(1)。
-
包含:包含操作返回一个布尔值,标识给定的键是否可以在集合中找到。包含操作必须遍历字典中的键集合以搜索匹配项。因此,此操作的最坏情况成本为O(n)。
-
计数:计数,有时被称为大小,可以是集合的一个方法,也可以是集合的一个属性,它返回字典中键/值元素的数量。计数通常是集合上的一个简单属性,因此,其成本为O(1)。
-
重新分配:重新分配操作允许将新值分配给现有键。在许多实现中,此操作不如更新操作常见,因为更新操作充当重新分配操作。通过指定键,重新分配操作的成本为O(1)。
-
集合:集合操作有时被视为添加和重新分配操作的单一替代方案。如果键不存在,则集合将插入一个新的键/值对,或者它将重新分配指定键的值。不需要在同一实现中支持集合、添加和重新分配操作。与添加和更新一样,集合操作的成本为O(1)。
案例研究:游艺厅票券总额
业务问题:一位游艺厅经理希望通过消除游戏中的实体票券来降低成本。票券成本很高且浪费,因为一旦顾客兑换后,它们就必须被丢弃或回收。她决定引入一个电子积分系统,允许顾客通过积分而不是票券来赚取积分,并将积分数字化存储。一旦她安装了支持转换的硬件,她需要一个移动应用程序,允许她和她的顾客高效地跟踪他们的当前积分总额。
这个应用程序有几个关键要求。首先,它应该仅根据客户在登记时提供的姓名存储客户数据。其次,它必须保持所有获得、损失和兑换的积分的累计总数。第三,它必须能够显示任何给定时间点的客户的积分和游艺厅中的客户总数。最后,它应该允许删除单个客户记录或一次性删除所有客户记录。基于这些要求,开发者决定使用字典来跟踪所有客户积分将是最有效的方法,因此核心类的功能将基于这种数据结构。
C#
C#提供了泛型集合Dictionary<TKey, TValue>。这个类提供了我们预期在具体字典实现中看到的所有基本操作,并且增加了泛型类型转换的优势:
Dictionary<string, int> _points;
public PointsDictionary()
{
_points = new Dictionary<string, int>();
}
使用Dictionary<TKey, TValue>,我们为我们的类创建了一个名为_points的私有字段。我们的构造函数实例化这个字段,为我们提供了构建PointsDictionary类的基础数据结构:
//Update - private
private int UpdateCustomerPoints(string customerName, int points)
{
if (this.CustomerExists(customerName))
{
_points[customerName] = _points[customerName] += points;
return _points[customerName];
}
return 0;
}
UpdateCustomerPoints(string customerName, int points)方法为我们这个类提供了核心的更新功能。该方法首先确认键是否存在于我们的集合中。如果键不存在,该方法立即返回0。否则,我们使用下标符号来同时获取键并更新键的值。再次使用下标符号,我们最终将更新后的值返回给调用者。
我们将这个方法设为私有,选择创建几个更适合我们业务需求的额外更新方法。稍后讨论的这些公共方法将向调用者公开更新功能:
//Add
public void RegisterCustomer(string customerName)
{
this.RegisterCustomer(customerName, 0);
}
public void RegisterCustomer(string customerName, int previousBalance)
{
_points.Add(customerName, previousBalance);
}
两个RegisterCustomer()方法为我们这个类提供了添加功能。在两种情况下,我们都需要一个客户名称作为键。如果返回的客户在登记时带有之前的余额,我们希望承认这一点,因此我们的类会重载该方法。最终,重载的方法调用Dictionary<TKey, TValue>.Add(T)来将新记录插入到集合中:
//Get
public int GetCustomerPoints(string customerName)
{
int points;
_points.TryGetValue(customerName, out points);
return points;
}
我们的获取功能是通过GetCustomerPoints(string customerName)方法引入的。在这个方法中,我们使用TryGetValue()来安全地确认customerName键是否存在,并同时获取其值。如果键不存在,应用程序会优雅地处理这个问题,并且不会为points分配任何值。然后,该方法返回points中当前设置的任何值:
//Update - public
public int AddCustomerPoints(string customerName, int points)
{
return this.UpdateCustomerPoints(customerName, points);
}
public int RemoveCustomerPoints(string customerName, int points)
{
return this.UpdateCustomerPoints(customerName, -points);
}
public int RedeemCustomerPoints(string customerName, int points)
{
//Perform any accounting actions
return this.UpdateCustomerPoints(customerName, -points);
}
接下来,我们来看公共更新方法,AddCustomerPoints(string customerName, int points)、RemoveCustomerPoints(string customerName, int points)和RedeemCustomerPoints(string customerName, int points)。每个这些方法都调用私有的UpdateCustomerPoints(string customerName, int points)方法,但在调用之前,后两种情况会先对points取反:
//Remove
public int CustomerCheckout(string customerName)
{
int points = this.GetCustomerPoints(customerName);
_points.Remove(customerName);
return points;
}
CustomerCheckout(string customerName) 方法引入了集合的 remove 功能。该方法首先记录客户键的最终值,然后调用 Dictionary<TKey, TValue>.Remove(T) 从集合中删除客户的键。最后,它将客户的最后积分值返回给调用者:
//Contains
public bool CustomerExists(string customerName)
{
return _points.ContainsKey(customerName);
}
Dictionary<TKey, TValue> 接口提供了一个方便的 ContainsKey() 方法,该方法被 CustomerExists(string customerName) 方法用来引入我们类的 contains 功能:
//Count
public int CustomersOnPremises()
{
return _points.Count;
}
使用 Dictionary<TKey, TValue> 类的 Count 字段,CustomersOnPremises() 提供了 count 功能:
public void ClosingTime()
{
//Perform any accounting actions
_points.Clear();
}
最后,根据我们的业务需求,我们需要一种方法来从集合中移除所有对象。ClosingTime() 方法使用 Dictionary<TKey, TValue>.Clear() 方法来完成这个任务。
Java
如前所述,Java 提供了一个 Dictionary 类,但它已被弃用,转而使用实现 Map<K, V> 接口的任何类。HashMap<K, V> 实现了该接口,并基于哈希表提供字典。与先前的 C# 示例一样,HashMap<K, V> 类公开了我们预期在字典的具体实现中看到的所有基本操作:
HashMap<String, Integer> _points;
public PointsDictionary()
{
_points = new HashMap<>();
}
HashMap<K, V> 的实例成为我们 Java PointsDictionary 类的核心。再次强调,我们命名私有字段为 _points,而我们的构造函数实例化了集合。你可能注意到,当我们实例化 _points 集合时,我们没有显式声明类型。在 Java 中,当我们已经在声明时定义了键和值类型时,实例化时不需要显式声明类型。如果你真的想声明类型,这将在编译器中生成警告:
private Integer UpdateCustomerPoints(String customerName, int points)
{
if (this.CustomerExists(customerName))
{
_points.put(customerName, _points.get(customerName) + points);
return _points.get(customerName);
}
return 0;
}
UpdateCustomerPoints(string customerName, int points) 方法为我们这个类提供了核心的 update 功能。该方法首先确认键是否存在于我们的集合中。如果键不存在,该方法立即返回 0。否则,我们使用 put() 和 get() 来更新键的值。再次使用 get(),我们最终将更新后的值返回给调用者:
//Add
public void RegisterCustomer(String customerName)
{
this.RegisterCustomer(customerName, 0);
}
public void RegisterCustomer(String customerName, int previousBalance)
{
_points.put(customerName, previousBalance);
}
两个 RegisterCustomer() 方法为我们这个类提供了 add 功能。在两种情况下,我们都需要一个客户名称作为键。如果一个返回的客户有之前的余额,我们希望承认这一点,以便我们的类重载该方法。最终,重载的方法调用 HashMap<K, V>.put(E) 将新记录插入到集合中:
//Get
public Integer GetCustomerPoints(String customerName)
{
return _points.get(customerName) == null ? 0 : _points.get(customerName);
}
我们的 get 功能是通过 GetCustomerPoints(string customerName) 方法引入的。在这个方法中,我们使用 get() 方法,并检查返回值是否不为 null,以安全地确认 customerName 键是否存在。使用三元运算符,如果不存在则返回 0,如果存在则返回值:
//Update
public Integer AddCustomerPoints(String customerName, int points)
{
return this.UpdateCustomerPoints(customerName, points);
}
public Integer RemoveCustomerPoints(String customerName, int points)
{
return this.UpdateCustomerPoints(customerName, -points);
}
public Integer RedeemCustomerPoints(String customerName, int points)
{
//Perform any accounting actions
return this.UpdateCustomerPoints(customerName, -points);
}
接下来,我们来看公共更新方法,AddCustomerPoints(String customerName, int points)、RemoveCustomerPoints(String customerName, int points) 和 RedeemCustomerPoints(String customerName, int points)。这些方法中的每一个都会调用私有的 UpdateCustomerPoints(String customerName, int points) 方法,但在后两种情况下,它首先会取反 points:
//Remove
public Integer CustomerCheckout(String customerName)
{
Integer points = this.GetCustomerPoints(customerName);
_points.remove(customerName);
return points;
}
CustomerCheckout(String customerName) 方法引入了集合的 remove 功能。该方法首先记录客户键的最终值,然后调用 HashMap<K, V>.remove(E) 从集合中删除客户的键。最后,它将客户的最后积分值返回给调用者:
//Contains
public boolean CustomerExists(String customerName)
{
return _points.containsKey(customerName);
}
HashMap<K, V> 方法提供了一个方便的 containsKey() 方法,CustomerExists(String customerName) 方法使用它来引入我们类的 contains 功能:
//Count
public int CustomersOnPremises()
{
return _points.size();
}
使用 HashMap<K, V> 类的 size() 字段,CustomersOnPremises() 提供了 count 功能:
//Clear
public void ClosingTime()
{
//Perform accounting actions
_points.clear();
}
最后,根据我们的业务需求,我们需要一种方法来从集合中移除所有对象。ClosingTime() 方法使用 HashMap<K, V>.clear() 方法来完成这项任务。
Objective-C
对于我们的 Objective-C 示例,我们将使用 NSMutableDictionary 类簇来表示我们的集合。NSMutableDictionary 类簇并没有暴露我们预期在字典的具体实现中看到的所有基本操作,但那些不直接可用的操作非常简单就可以复制。重要的是要注意,Objective-C 不允许将标量值添加到 NSDictionary 或 NSMutableDictionary 集合的实例中。因此,由于我们试图存储整数作为值,我们必须在将它们添加到集合之前,将每个 NSInteger 标量放入 NSNumber 包装器中。不幸的是,这给我们的实现增加了一些开销,因为所有这些值都必须在插入或从集合中检索时装箱和拆箱:
@interface EDSPointsDictionary()
{
NSMutableDictionary<NSString*, NSNumber*> *_points;
}
@implementation EDSPointsDictionary
-(instancetype)init
{
if (self = [super init])
{
_points = [NSMutableDictionary dictionary];
}
return self;
}
使用类簇 NSMutableDictionary,我们为我们的类创建了一个名为 _points 的实例变量。我们的初始化器实例化了这个字典,为我们提供了构建 PointsDictionary 类的基础数据结构:
-(NSInteger)updatePoints:(NSInteger)points
forCustomer:(NSString*)customerName
{
if ([self customerExists:customerName])
{
NSInteger exPoints = [[_points objectForKey:customerName] integerValue];
exPoints += points;
[_points setValue:[NSNumber numberWithInteger:exPoints] forKey:customerName];
return [[_points objectForKey:customerName] integerValue];
}
return 0;
}
updatePoints:forCustomer: 方法为我们类提供了核心的 update 功能。该方法首先通过调用我们的 customerExists: 方法来确认键是否存在于我们的集合中。如果键不存在,该方法立即返回 0。否则,该方法使用 objectForKey: 来获取存储的 NSNumber 对象。从这个对象中,我们立即通过调用对象的 integerValue 来提取 NSInteger 值。接下来,值在字典中使用 setValue:forKey: 调整并更新。再次使用 objectForKey:,我们最终将更新的值返回给调用者:
//Add
-(void)registerCustomer:(NSString*)customerName
{
[self registerCustomer:customerName withPreviousBalance:0];
}
-(void)registerCustomer:(NSString*)customerName
withPreviousBalance:(NSInteger)previousBalance
{
NSNumber *points = [NSNumber numberWithInteger:previousBalance];
[_points setObject:points forKey:customerName];
}
registerCustomer: 方法为我们的类提供了 添加 功能。在两种情况下,我们都需要一个客户名称作为键。如果一个回头客在结账时带有之前的余额,我们希望确认这一点,以便我们的类在 registerCustomer:withPreviousBalance: 中重载该方法。最终,重载的方法调用 setObject:forKey: 将新的键/值对插入字典中:
//Get
-(NSInteger)getCustomerPoints:(NSString*)customerName
{
NSNumber *rawsPoints = [_points objectForKey:customerName];
return rawsPoints ? [rawsPoints integerValue] : 0;
}
我们的 获取 功能是通过 getCustomerPoints: 方法引入的。在这个方法中,我们使用 objectForKey: 获取传递的键的 NSNumber 对象,并将其分配给 rawPoints。接下来,该方法检查 rawPoints 是否不为 nil,如果可用,则返回 rawPoints 的 integerValue,否则返回 0:
//Update
-(NSInteger)addPoints:(NSInteger)points
toCustomer:(NSString*)customerName
{
return [self updatePoints:points forCustomer:customerName];
}
-(NSInteger)removePoints:(NSInteger)points
fromCustomer:(NSString*)customerName
{
return [self updatePoints:-points forCustomer:customerName];
}
-(NSInteger)redeemPoints:(NSInteger)points
forCustomer:(NSString*)customerName
{
//Perform any accounting actions
return [self updatePoints:-points forCustomer:customerName];
}
接下来,我们来看公共更新方法,addPoints:toCustomer:, removePoints:fromCustomer: 和 redeemPoints:forCustomer:。这些方法中的每一个都调用私有的 updatePoints:forCustomer: 方法,但在后者两种情况下,它首先对 points 取反:
-(NSInteger)customerCheckout:(NSString*)customerName
{
NSInteger points = [[_points objectForKey:customerName] integerValue];
[_points removeObjectForKey:customerName];
return points;
}
customerCheckout: 方法引入了集合的 删除 功能。该方法首先记录客户键的最终值,然后调用 removeObjectForKey: 从集合中删除客户的键。最后,它将客户的最后积分值返回给调用者:
//Contains
-(bool)customerExists:(NSString*)customerName
{
return [_points objectForKey:customerName];
}
NSMutableDictionary 类簇不提供一种机制来确定集合中是否存在键。一个简单的解决方案是直接调用 objectForKey:;如果返回的值是 nil,则表示键不存在,nil 评估为 NO。基于这个原则,因此我们的 customerExists: 方法简单地返回 objectForKey:,允许返回值被评估为 BOOL:
//Count
-(NSInteger)customersOnPremises
{
return [_points count];
}
使用 NSDictionary 类的 count 属性,customersOnPremises 提供了 计数 功能:
//Clear
-(void)closingTime
{
[_points removeAllObjects];
}
最后,根据我们的业务需求,我们需要一种方法来从集合中删除所有对象。closingTime 方法使用 removeAllObjects 方法来完成这项任务。
Swift
Swift 提供的 Dictionary 类,与 Objective-C 的 NSMutableDictionary 类一样,并不暴露我们在字典数据结构的具体实现中期望看到的所有操作。同样,这些缺失的功能很容易复制。值得注意的是 Swift 字典的值类型与其 Objective-C 对应类型之间的区别。由于 Swift 中的原始类型被包装在 structs 中,我们可以毫无问题地将 Int 对象添加到我们的集合中:
var _points = Dictionary<String, Int>()
使用 Dictionary 类,我们为我们的类创建了一个私有属性,称为 _points。由于我们的属性是声明和实例化同时进行的,且没有其他自定义代码需要实例化,我们可以排除显式的公共初始化器,并依赖于默认初始化器:
public func updatePointsForCustomer(points: Int, customerName: String) -> Int
{
if customerExists(customerName)
{
_points[customerName] = _points[customerName]! + points
return _points[customerName]!
}
return 0
}
updatePointsForCustomer() 方法为我们类的核心 更新 功能提供支持。该方法首先通过调用我们的 customerExists() 方法来确认键是否存在于我们的集合中。如果键不存在,该方法立即返回 0。否则,该方法使用下标符号来获取存储的值。接下来,该值在字典中进行调整和更新,同样使用下标符号。最后,我们将更新后的值返回给调用者:
//Add
public func registerCustomer(customerName: String)
{
registerCustomerWithPreviousBalance(customerName, previousBalance: 0)
}
public func registerCustomerWithPreviousBalance(customerName: String, previousBalance: Int)
{
_points[customerName] = previousBalance;
}
registerCustomer() 方法为我们类提供了 添加 功能。在两种情况下,我们都需要一个客户名称作为键。如果返回的客户带有之前的余额登记入住,我们希望承认这一点,以便我们的类在 registerCustomerWithPreviousBalance() 中重载该方法。最终,重载的方法使用下标符号将新的键/值对插入到字典中:
//Get
public func getCustomerPoints(customerName: String) -> Int
{
let rawsPoints = _points[customerName]
return rawsPoints != nil ? rawsPoints! : 0;
}
我们的 获取 功能是通过 getCustomerPoints() 方法引入的。在这个方法中,我们使用下标符号来获取键的值,但在返回值之前,我们确认返回值不是 nil。如果值不是 nil,我们的方法返回该值;否则,它返回 0:
//Update
public func addPointsToCustomer(points: Int, customerName: String) -> Int
{
return updatePointsForCustomer(points, customerName: customerName)
}
public func removePointsFromCustomer(points: Int, customerName: String) -> Int
{
return updatePointsForCustomer(-points, customerName: customerName)
}
public func redeemPointsForCustomer(points: Int, customerName: String) -> Int
{
//Perform any accounting actions
return updatePointsForCustomer(-points, customerName: customerName)
}
接下来,我们来看公共更新方法,addPointsToCustomer()、removePointsFromCustomer() 和 redeemPointsForCustomer()。这些方法中的每一个都调用私有的 updatePointsForCustomer() 方法,但在调用之前,它对后两种情况下的 points 进行取反:
public func customerCheckout(customerName: String) -> Int
{
let points = _points[customerName]
_points.removeValueForKey(customerName)
return points!;
}
customerCheckout() 方法引入了集合的 移除 功能。该方法首先记录客户键的最终值,然后调用 removeObjectForKey: 从集合中删除客户的键。最后,它将客户的最后积分值返回给调用者:
//Contains
public func customerExists(customerName: String) -> Bool
{
return _points[customerName] != nil
}
与 NSMutableDictionary 类似,Dictionary 不提供一种机制来确定集合中是否存在键。幸运的是,我们的 Objective-C 中的解决方案在 Swift 中同样适用。我们的方法使用下标符号,如果返回的值是 nil,则键不存在,nil 评估为 false。因此,基于这个原则,我们的 customerExists() 方法简单地返回 _points[cusrtomerName],允许返回值被评估为 Bool:
//Count
public func customersOnPremises() -> Int
{
return _points.count
}
通过 Dictionary 类的 count 属性,customersOnPremises() 提供了 计数 功能:
//Clear
public func closingTime()
{
_points.removeAll()
}
最后,根据我们的业务需求,我们需要一种方法来从集合中移除所有对象。closingTime() 方法使用 Dictionary.removeAll() 方法来完成这项任务。
高级主题
现在我们已经考察了字典在常见应用中的使用方式,我们应该花些时间来探讨字典在底层是如何实现的。大多数字典分为两种不同的类型:基于哈希表和基于搜索树。尽管这两种方法的机制相似,并且它们通常共享许多相同的方法和功能,但每种类型的内部工作原理和理想应用却非常不同。
基于哈希表的字典
字典最常见的一种实现方式是基于哈希表的关联数组。当正确实现时,哈希表方法非常高效,允许进行O(1)复杂度的搜索、插入和删除操作。在我们考察的每种语言中,基本的字典类默认都是基于哈希表的。基于哈希表的字典的一般概念是,指定键的映射存储在数组的索引中,该索引是通过将哈希函数应用于键获得的。调用者随后检查数组中相同索引处的指定键,并使用存储在该处的绑定来检索元素的值。
基于哈希表的字典有一个缺点,即哈希函数有可能产生冲突,或者有时会尝试将两个键映射到相同的索引。因此,基于哈希表的实现必须有一种机制来解决这个问题。存在许多冲突解决策略,但这些细节超出了本文的范围。
基于搜索树的字典
字典较少见的实现方式是基于搜索树的关联数组。基于搜索树的字典非常适合按某些标准或值的属性对键和值进行排序,并且可以构建以更高效地处理自定义键或值类型。基于搜索树的实现的一个优点是增加了超出先前描述的基本函数的操作,例如找到与指定键相似的映射的能力。然而,这些优点是有代价的,因为基于搜索树的实现的基本操作成本更高,而集合本身对可以处理的数据类型有更严格的限制。有关基于搜索树的字典的排序操作将在第十二章排序:从混乱中带来秩序中更详细地讨论。
概述
在本章中,你学习了字典或关联数组的基
第七章。集合:无重复
在计算机科学的范畴内,集合通常被用作一个简单的对象集合,其中不包含重复项。然而,在更广泛的数学领域,集合是一个抽象的数据结构,可以描述为按无特定顺序存储的不同对象或值的集合。为了讨论的目的,我们将选择将集合视为数学有限集合的计算机实现。
当处理可以应用集合论数学概念的问题时,集合数据结构提供了一组强大的工具,用于组合和检查类似对象集合之间的关系。然而,即使在集合论和数学之外,集合数据结构也提供了在日常生活中可能有用的功能。例如,由于集合自然地消除了重复项,任何需要维护或编辑唯一元素集合的应用程序都将从存储对象在集合数据结构中受益。同样,如果你需要从现有集合中消除重复项,大多数集合数据结构的实现都将允许你从一个数组集合中创建一个新的集合;在这样做的时候,你将自动过滤掉重复项。总的来说,集合是一个相对简单的数据结构,在分析数据集合时提供了巨大的功能性和力量。
在本章中,我们将涵盖以下内容:
-
集合数据结构的定义
-
集合论
-
初始化集合
-
常见集合运算
-
重新审视登录到服务的用户问题
-
案例研究 - 音乐播放列表
-
基于哈希表的集合
-
基于树的集合
-
基于数组的集合
集合论
集合的概念相对简单,但在实践中,由于其数学起源,具体的实现可能有些难以理解。因此,为了完全欣赏集合数据结构,有必要检查构建集合数据结构的基础——集合论的一些特性和函数。集合论是研究对象集合或集合的数学分支。尽管集合论是数学中的一个主要研究领域,有许多相互关联的子领域,但我们实际上只需要检查五个用于结合和关联集合的函数,以理解集合数据结构:
- 并集:并集是结合和关联集合的基本方法之一。一系列 n 个集合的并集是仅包含那些在这些集合中出现的不同元素的集合。这意味着,如果你将集合 A 和 B 结合起来,结果集合将只包含来自集合 A 和 B 的唯一元素。如果一个元素同时存在于 A 和 B 中,它将只在我们结果集中出现一次。我们使用符号 A ∪ B 来表示集合 A 和 B 的并集。以下维恩图表示了两个集合的并集:

- 交集:交集是组合和关联集合的第二种基本方法。一个由 n 个集合组成的交集是存在于每个被评估集合中的元素集合。因此,如果我们检查集合 A 和 B 的交集,我们的结果集合将只包括存在于 A 和 B 中的那些元素。任何只属于 A 或 B 的元素将被丢弃。我们使用符号 A ∩ B 来表示集合 A 与集合 B 的交集。以下维恩图表示了两个集合的交集:

- 差集:差集操作是交集操作的相反操作。一个由 n 个集合组成的差集是每个被评估集合中唯一的元素集合。如果我们检查集合 A 和 B 的差集,我们的结果集合将只包括存在于 A 或 B 中的那些元素。任何属于 A 和 B 交集的元素将被丢弃。我们使用符号 A Δ B 来表示集合 A 和 B 之间的差集。以下维恩图表示了两个集合的差集:

- 补集:A 在 B 中的补集,或称为相对补集,是存在于 B 但不存在于 A 中的元素集合。如果我们检查集合 A 和 B 的补集,只有那些只属于 B 的元素将包含在我们的结果集合中。任何只属于 A 或是 A 和 B 交集的元素将被丢弃。我们使用符号 B*A* 来表示集合 A 相对于集合 B 的相对补集。以下维恩图表示了两个集合的补集:

- 子集:子集是组合和关联集合的最终基本方法。子集操作确定集合 A 是否是集合 B 的子集,或者换句话说,集合 B 是否是集合 A 的超集。一个集合是另一个集合的子集的关系称为包含,或者当一个集合是另一个集合的超集时,称为包含。在下一个图中,我们可以说 A 是 B 的子集,或者 B 是 A 的超集。我们使用符号 A ⊂ B 来表示集合 A 是集合 B 的包含,或者 B ⊃ A 来表示集合 B 是集合 A 的包含。

初始化集合
集合在开发中并不十分常见,但我们正在检查的每种语言都支持以某种具体形式实现的数据结构。以下是一些初始化集合、向集合中添加一些值(包括一个重复值)以及在每一步后将集合的计数打印到控制台的示例。
C#
C#通过HashSet<T>类提供了集合数据结构的具体实现。由于此类是泛型的,调用者可以定义用于元素的类型。例如,以下示例初始化了一个新集合,其中元素将是string类型:
HashSet<string, int> mySet = new HashSet<string>();
mySet.Add("green");
Console.WriteLine("{0}", mySet.Count);
mySet.Add("yellow");
Console.WriteLine("{0}", mySet.Count);
mySet.Add("red");
Console.WriteLine("{0}", mySet.Count);
mySet.Add("red");
Console.WriteLine("{0}", mySet.Count);
mySet.Add("blue");
Console.WriteLine("{0}", mySet.Count);
/* Output:
1
2
3
3 since "red" already exists in the collection
4
*/
Java
Java 提供了一个HashSet<E>类以及其他实现Set<E>接口的类。在本章中,我们将仅查看HashSet<E>类的示例:
HashSet<String> mySet = new HashSet< >();
mySet.add("green");
System.out.println(mySet.size());
mySet.add("yellow");
System.out.println(mySet.size());
mySet.add("red");
System.out.println(mySet.size());
mySet.add("red");
System.out.println(mySet.size());
mySet.add("blue");
System.out.println(mySet.size());
/* Output:
1
2
3
3 since "red" already exists in the collection
4
*/
Objective-C
Objective-C 提供了不可变和可变的集合类,NSSet和NSMutableSet。在本章中,我们只将详细检查可变版本:
NSMutableSet *mySet = [NSMutableSet set];
[mySet addObject:@"green"];
NSLog(@"%li", (long)[mySet count]);
[mySet addObject:@"yellow"];
NSLog(@"%li", (long)[mySet count]);
[mySet addObject:@"red"];
NSLog(@"%li", (long)[mySet count]);
[mySet addObject:@"red"];
NSLog(@"%li", (long)[mySet count]);
[mySet addObject:@"blue"];
NSLog(@"%li", (long)[mySet count]);
/* Output:
1
2
3
3 since "red" already exists in the collection
4
*/
Swift
Swift 中的集合使用Set类创建。当使用var初始化为变量时,Swift 集合是可变的,但它们也可以通过使用let初始化为常量来创建不可变。在本章中,我们只将详细检查可变版本:
let mySet: Set<String> = Set<String>()
mySet.insert(@"green")
print(mySet.count)
mySet.insert(@"yellow")
print(mySet.count)
mySet.insert(@"red")
print(mySet.count)
mySet.insert(@"red")
print(mySet.count)
mySet.insert(@"blue")
print(mySet.count)
/* Output:
1
2
3
3 since "red" already exists in the collection
4
*/
集合操作
并非所有集合数据结构的具体实现都公开相同的操作方法。然而,更常见的操作应该是可用的,或者可以通过开发者的需要来提供。在检查这些操作时,请注意语言与之前讨论的集合理论操作语言的相似性。您会发现大多数集合数据结构的功能将紧密地反映集合理论的一般功能:
-
添加: 添加操作,有时称为插入,如果该对象尚未存在于集合中,则将其引入集合。这种防止重复对象被添加到集合中的功能是使用集合而不是许多其他数据结构的核心优势之一。大多数集合数据结构的实现将返回一个布尔值,表示元素是否可以添加到集合中。添加操作具有O(n)的成本。
-
删除: 删除或删除操作允许调用者从集合中删除一个值或对象,如果它存在。大多数集合数据结构的实现返回一个布尔值,表示删除操作是否成功。删除操作具有O(n)的成本。
-
容量: 容量操作返回集合可以存储的最大值数。这不是开发者在讨论的四种语言中自然看到的操作,因为这些语言中找到的每个可变集合都可以根据需要动态调整大小。然而,一些实现确实允许将集合的大小限制为其定义的一部分。容量具有O(1)的操作成本。
-
并集: 并集操作返回一个包含两个或多个集合中唯一元素的新集合。因此,此操作的最坏情况成本为O(n+m),其中n是第一个集合的大小,m是第二个集合的大小。
-
intersection: 交集操作仅返回两个或多个集合之间共享的元素。这意味着,如果您向该方法提供两个集合,您将仅获得存在于两个集合中的那些元素。交集的成本为 O(nm),其中 n 是第一个集合的大小,m* 是第二个集合的大小。有趣的是,如果您尝试对三个或更多集合进行交集操作,成本将变为 (n-1) ** O(L),其中 n 是参与操作的集合数量,L* 是系列中最大集合的大小。显然,这个成本相当高,并且同时使用此操作在多个集合上可能会迅速失控。
-
difference: 差集操作是交集操作的相反,仅返回每个集合中独特的元素。此操作的成本为 O(m),其中 m 是两个被评估集合中较短的那个的长度。
-
subset: 子集操作返回一个布尔值,确定集合 A 是否是集合 B 的子集。对于集合 A 被认为是集合 B 的子集,集合 A 中的每个元素也必须包含在集合 B 中。如果集合 A 的只有部分元素包含在集合 B 中,那么集合 A 和 B 有交集,但 A 不是 B 的子集。此操作的操作成本为 O(m),其中 m 是集合 A 的长度。
-
count:
count操作,或称为大小,表示特定集合的 基数,这实际上是集合论中表达集合中元素数量的方式。计数通常是集合上的一个简单属性,因此具有 O(1) 的成本。 -
isEmpty:
isEmpty操作返回一个布尔值,表示集合是否包含任何元素。一些实现提供了相应的isFull操作,但仅限于那些可以将集合容量限制为特定值的实例。isEmpty和isFull都有 O(1) 的成本。
示例:回顾登录服务的用户
让我们再次回顾 第二章 中的用户登录服务问题,数组:基础集合,并检查如果我们选择集合而不是数组或列表作为底层数据结构,代码将如何改变。
C#
在这个例子中,我们将List<User>对象替换为了HashSet<User>对象。我们的代码大部分没有改变,但请注意,我们排除了CanAddUser(User)方法。原本,这个方法通过确保集合有空间容纳另一个对象,然后确保要添加的对象尚未包含在集合中来验证已认证用户的操作。集合数据结构消除了进行第二步的需要,因为它本质上防止了重复对象的添加。由于我们的类现在只需要进行容量检查验证,我们可以将这个检查与UserAuthenticated(User)功能一起内联处理。作为额外的奖励,我们现在可以轻松地报告用户是否成功添加,因为HashSet<T>.Add(T)在成功时返回true,当对象已存在于集合中时返回false:
public class LoggedInUserSet
{
HashSet<User> _users;
public LoggedInUserSet()
{
_users = new HashSet<User>();
}
public bool UserAuthenticated(User user)
{
if (_users.Count < 30)
{
return _users.Add(user);
}
return false;
}
public void UserLoggedOut(User user)
{
_users.Remove(user);
}
}
Java
我们 Java 示例中的更改几乎与我们的 C#示例相同。同样,我们用HashSet<User>对象替换了List<User>对象。我们的代码大部分没有改变,除了排除了canAddUser(User)方法。在 Java 中,HashSet<E>类实现了Set<E>接口,并基于集合数据结构,消除了在添加对象之前检查对象是否存在于集合中的需要。由于我们的类现在只需要进行容量检查验证,我们可以将这个检查与userAuthenticated(User)功能一起内联处理。同样,我们现在可以轻松地报告用户是否成功添加,因为HashSet<E>.add(E)在成功时返回true,当对象已存在于集合中时返回false:
HashSet<User> _users;
public LoggedInUserSet()
{
_users = new HashSet<User>();
}
public boolean userAuthenticated(User user)
{
if (_users.size() < 30)
{
return _users.add(user);
}
return false;
}
public void userLoggedOut(User user)
{
_users.remove(user);
}
Objective-C
我们 Objective-C 示例的更改产生了一些有趣的结果。尽管我们用NSMutableArray集合替换了NSMutableSet集合,但大部分代码保持不变,包括我们不会返回一个表示addObject:操作成功或失败的BOOL值。这是因为addObject:不返回任何值;如果我们将其包含在userAuthenticated:中,我们不得不在调用集合上的addObject:之前调用containsObject:方法。由于这个练习的整个目的是使用集合来消除在添加新对象之前检查重复的需要,重新引入这个功能将违背初衷,并可能使我们处于比简单地坚持使用数组或列表更昂贵的位置。
这并不是说没有有效的应用可以从集合以及关于addObject:操作成功或失败的报告中获得好处;这只是说这种情况并不适用:
@interface EDSLoggedInUserSet()
{
NSMutableSet *_users;
}
@end
@implementation EDSLoggedInUserSet
-(instancetype)init
{
if (self = [super init])
{
_users = [NSMutableSet set];
}
return self;
}
-(void)userAuthenticated:(EDSUser *)user
{
if ([_users count] < 30)
{
[_users addObject:user];
}
}
-(void)userLoggedOut:(EDSUser *)user
{
[_users removeObject:user];
}
Swift
我们 Swift 示例的结果几乎与我们的 Objective-C 示例完全相同。再次强调,我们正在用集合替换数组,但在 Swift 中集合的工作方式与在 Objective-C 中类似。因此,我们的最终代码更加简洁,但并不立即提供与我们的 C#和 Java 实现相同的功能:
var _users: Set<User> = Set<User>()
public func userAuthenticated(user: User)
{
if (_users.count < 30)
{
_users.insert(user)
}
}
public func userLoggedOut(user: User)
{
if let index = _users.indexOf(user)
{
_users.removeAtIndex(index)
}
}
我们需要一份合同
如果你仔细观察解决已登录用户业务问题的三个方案中的每一个,你可能会注意到它们都共享一些公共方法。在我们的数组实现、列表实现和集合实现中,我们有两个名为 UserAuthenticated() 和 UserLoggedOut() 的公共方法,或者根据语言的不同,这些名称可能会有所变化。如果我们只是选择最适合我们需求的一个实现并继续前进,这不会成为问题。然而,如果我们有合理的理由保留这些类中的每一个,以便在特定的环境条件下高效工作,那会怎样呢?
实际上,看到多个类共享相同的公共方法,但在底层有独特实现的代码非常普遍。如果我们简单地创建三个(或更多)完全独立的独立实现,我们的应用程序将产生一个 代码异味。这是因为,每当我们想要使用特定的实现时,我们都需要通过名称来调用它,这需要我们对哪些类和实现可用有一定的预先了解。此外,尽管我们的代码可能运行得很好,但它将是脆弱的、不可扩展的,并且长期维护起来会更加困难。
一个更好的解决方案将涉及定义一个每个类都实现的合同。在 C#或 Java 中,我们会定义一个接口,而在 Objective-C 和 Swift 中,我们会定义一个协议。这两种模式之间的区别主要在于语义,因为它们都将为我们的调用者提供方法名称、方法期望的内容以及方法将返回的内容。重要的是,通过这样做,我们极大地简化并加强了功能实现和调用类结构的实现。
案例研究:音乐播放列表
业务问题:一个音乐流媒体服务希望为用户提供更好的流媒体体验。目前,用户播放列表只是一个简单的歌曲集合,被倒入一个没有提供过滤或排序集合方式的桶中。内容管理团队已经听到了用户的投诉,并已将构建更好的播放列表的任务分配给了工程团队。
这个新的播放列表工具将会有几个关键要求。更基本的要求包括能够从列表中添加和删除歌曲,能够区分空列表和包含元素的列表,以及能够报告列表中元素的总数。对于那些对付费高级服务不感兴趣的客户,列表将限制为 100 首歌曲,因此我们的播放列表工具还必须具备设置容量和轻松识别容量已满的能力。
此外,许多高级用户在他们的播放列表中拥有数千首歌曲,以及针对从骑自行车到洗衣服等一切事物的多个主题播放列表。对于这些用户,播放列表工具必须包括一些高级分析和编辑功能。首先,必须有一种简单的方法来轻松合并播放列表,并且由于我们不希望同时存在于两个播放列表中的歌曲出现两次,这种合并必须防止重复。接下来,播放列表应该能够轻松识别两个列表之间重复的歌曲,以及识别特定列表中独特歌曲。最后,一些用户可能希望了解有关他们播放列表集合的更多信息,例如是否有一个播放列表作为另一个播放列表的一部分存在。基于这些要求,开发者决定使用集合来表示播放列表将是最有效的方法,因此核心类的功能将基于该数据结构。
C#
C# 提供了泛型集合 HashSet<T>。这个类提供了我们在具体的集合实现中期望看到的所有基本操作,并增加了泛型类型转换的额外好处:
HashSet<Song> _songs;
public Int16 capacity { get; private set; }
public bool premiumUser { get; private set; }
public bool isEmpty
{
get
{
return _songs.Count == 0;
}
}
public bool isFull
{
get
{
if (this.premiumUser)
{
return false;
}
else
{
return _songs.Count == this.capacity;
}
}
}
public PlaylistSet(bool premiumUser, Int16 capacity)
{
_songs = new HashSet<Song>();
this.premiumUser = premiumUser;
this.capacity = capacity;
}
使用 HashSet<T> 接口,我们为我们的类创建了一个名为 _songs 的私有字段。我们的构造函数实例化了这个字段,为我们提供了构建 PlaylistSet 类的基础数据结构。我们还创建了四个公共字段:capacity、premiumUser、isEmpty 和 isFull。capacity 字段存储非高级用户可以在他们的播放列表中存储的最大歌曲数量,而 premiumUser 表示这个列表是否属于高级账户。isEmpty 和 isFull 字段允许我们的类轻松实现同名操作。isEmpty 字段简单地返回集合的计数是否为 0。isFull 字段首先检查这个列表是否属于高级账户。如果是 true,则集合永远不会满,因为我们允许高级用户在他们的播放列表中存储无限数量的歌曲。如果这个列表不属于高级账户,我们的获取器确保 _songs 的当前计数没有超过容量,并返回这个比较:
public bool AddSong(Song song)
{
if (!this.isFull)
{
return _songs.Add(song);
}
return false;
}
AddSong(Song song) 方法为我们这个类提供了 添加 功能。该方法首先确认集合没有满。如果是这样,该方法返回 false,因为我们不能向列表中添加更多歌曲。否则,该方法返回 HashSet<T>.Add(T) 的结果,如果 song 被添加,则返回 true,这意味着歌曲不在列表中。
public bool RemoveSong(Song song)
{
return _songs.Remove(song);
}
RemoveSong(Song song) 方法为我们这个类提供了 移除 功能。这个方法简单地返回 HashSet<T>.Remove(T) 的结果,如果歌曲存在于列表中,则返回 true;否则,返回 false:
public void MergeWithPlaylist(HashSet<Song> playlist)
{
_songs.UnionWith(playlist);
}
MergeWithPlaylist(HashSet<Song> playlist) 方法为我们这个类提供了 并集 功能。幸运的是,HashSet<T> 通过 Union(HashSet<T>) 方法公开了并集功能,所以我们的方法只是简单地调用它。在这种情况下,Union() 将合并 playlist 参数和我们的现有 _songs 列表:
public HashSet<Song> FindSharedSongsInPlaylist(HashSet<Song> playlist)
{
HashSet<Song> songsCopy = new HashSet<Song>(_songs);
songsCopy.IntersectWith(playlist);
return songsCopy;
}
接下来,FindSharedSongsInPlaylist(HashSet<Song> playlist) 方法为我们这个类提供了 交集 功能。同样,HashSet<T> 方便地提供了 IntersectWith(HashSet<T>) 方法,我们这个方法正是利用了它。请注意,这个方法不会修改我们的列表,而是返回我们的列表和 playlist 参数的实际交集。我们这样做是因为仅仅消除列表中独特歌曲并不是很有用。这个方法将用于整体应用程序中的其他功能的信息目的。
由于我们不是修改现有的列表,而是只返回关于交集的信息,我们的方法首先使用重载的 HashSet<T> 对象复制 _songs 集合。然后,我们的方法修改复制的列表,并返回交集操作的结果:
public HashSet<Song> FindUniqueSongs(HashSet<Song> playlist)
{
HashSet<Song> songsCopy = new HashSet<Song>(_songs);
songsCopy.ExceptWith(playlist);
return songsCopy;
}
FindUniqueSongs(HashSet<Song> playlist) 方法为我们这个类提供了 差集 功能,并且其工作方法与上一个方法非常相似。同样,这个方法不会修改我们现有的集合,而是返回对复制的集合和 playlist 参数的 ExceptWith() 操作的结果:
public bool IsSubset(HashSet<Song> playlist)
{
return _songs.IsSubsetOf(playlist);
}
public bool IsSuperset(HashSet<Song> playlist)
{
return _songs.IsSupersetOf(playlist);
}
IsSubset(HashSet<Song> playlist) 和 IsSuperset(HashSet<Song> playlist) 方法提供了它们名字所暗示的功能。这些方法分别利用 HashSet<T>.IsSubSetOf(HashSet<T>) 和 HashSet<T>.IsSuperSetOf(HashSet<T>) 方法,并返回一个表示这些比较结果的布尔值:
public int TotalSongs()
{
return _songs.Count;
}
最后,TotalSongs() 方法返回 _songs 集合中找到的元素数量,为我们这个集合提供了 计数 功能。
Java
Java 提供了实现 Set<E> 接口的泛型集合 HashSet<E>。这个类提供了我们在具体的集合实现中期望看到的所有基本操作,并增加了泛型类型转换的额外好处:
private HashSet<Song> _songs;
public int capacity;
public boolean premiumUser;
public boolean isEmpty()
{
return _songs.size() == 0;
}
public boolean isFull()
{
if (this.premiumUser)
{
return false;
}
else {
return _songs.size() == this.capacity;
}
}
public PlaylistSet(boolean premiumUser, int capacity)
{
_songs = new HashSet<>();
this.premiumUser = premiumUser;
this.capacity = capacity;
}
使用 HashSet<E>,我们为我们的类创建了一个名为 _songs 的私有字段。我们的构造函数实例化这个字段,为我们提供了构建 PlaylistSet 类的基础数据结构。我们还创建了两个公共字段和两个公共访问器:capacity、premiumUser、isEmpty() 和 isFull()。capacity 字段存储非高级用户可以在他们的播放列表中存储的最大歌曲数量,而 premiumUser 表示这个列表是否属于高级账户。isEmpty() 和 isFull() 访问器允许我们的类轻松实现同名操作。这两个访问器的工作方式与它们的 C# 字段对应物完全相同。isEmpty() 方法简单地返回集合的计数是否为 0。isFull() 方法首先检查这个列表是否属于高级账户。
如果是 true,则集合永远不会满,因为我们允许高级用户在他们的播放列表中存储无限数量的歌曲。如果这个列表不属于高级账户,我们的获取器确保 _songs 的当前计数没有超过 capacity,并返回这个比较结果:
public boolean addSong(Song song)
{
if (!this.isFull())
{
return _songs.add(song);
}
return false;
}
addSong(Song song) 方法为我们这个类提供了 添加 功能。这个方法首先确认集合没有满。如果是这样,方法返回 false,因为我们不能向列表中添加更多歌曲。否则,方法返回 HashSet<E>.add(E) 的结果,如果歌曲被添加,并且只有在歌曲尚未存在于这个播放列表中时才会返回 true:
public boolean removeSong(Song song)
{
return _songs.remove(song);
}
removeSong(Song song) 方法为我们这个类提供了 删除 功能。这个方法简单地返回 HashSet<E>.remove(E) 的结果,如果歌曲存在于集合中,则返回 true;否则,返回 false。
public void mergeWithPlaylist(HashSet<Song> playlist)
{
_songs.addAll(playlist);
}
mergeWithPlaylist(HashSet<Song> playlist) 方法为我们这个类提供了 并集 功能,这也是我们的类开始真正与之前的 C# 示例不同的地方。HashSet<E> 提供了我们需要的 并集 功能,但只能通过调用 HashSet<E>.addAll(HashSet<E>) 方法来实现。这个方法接受一个 Song 对象的集合作为参数,并尝试将每个对象添加到我们的 _songs 集合中。如果被添加的 Song 元素已经存在于 _songs 集合中,该元素将被丢弃,只留下来自两个列表或两个集合的唯一的 Song 对象:
public HashSet<Song> findSharedSongsInPlaylist(HashSet<Song> playlist)
{
HashSet<Song> songsCopy = new HashSet<>(_songs);
songsCopy.retainAll(playlist);
return songsCopy;
}
接下来,findSharedSongsInplaylist(HashSet<Song> playlist) 方法为我们这个类提供了 交集 功能。同样,HashSet<E> 提供了交集功能,但不是直接提供。我们的方法使用 HashSet<E>.retainAll(HashSet<E>) 方法,该方法保留 _songs 集合中所有也存在于 playlist 参数中的元素,或者两个集合的交集。正如我们的 C# 示例一样,我们并没有在原地修改 _songs 集合,而是返回 _songs 的一个副本和 playlist 参数之间的交集:
public HashSet<Song> findUniqueSongs(HashSet<Song> playlist)
{
HashSet<Song> songsCopy = new HashSet<>(_songs);
songsCopy.removeAll(playlist);
return songsCopy;
}
findUniqueSongs(HashSet<Song> playlist) 方法为我们提供的类提供 差异 功能。再次,HashSet<E> 揭示了差异功能,但通过 removeAll(HashSet<E>) 方法。removeAll() 方法移除所有在播放列表参数或两个集合之间的差异中包含的 _songs 元素。同样,此方法不会修改我们现有的集合,而是返回 _songs 复制和 playlist 参数上的 removeAll() 方法或差异操作的结果:
public boolean isSubset(HashSet<Song> playlist)
{
return _songs.containsAll(playlist);
}
public boolean isSuperset(HashSet<Song> playlist)
{
return playlist.containsAll(_songs);
}
isSubset(HashSet<Song> playlist) 和 isSuperset(HashSet<Song> playlist) 方法提供了同名功能。这两个方法都利用了 HashSet<E>.containsAll(HashSet<E>) 方法,并返回一个布尔值,表示这些比较的结果。我们的方法只是交换源集合和参数以获得所需的比较,因为 HashSet<E> 没有为每个函数提供特定的比较器:
public int totalSongs()
{
return _songs.size();
}
最后,totalSongs() 方法使用集合的 size() 方法返回 _songs 集合中找到的元素数量,为我们提供的集合提供 计数 功能。
Objective-C
Objective-C 提供了 NSSet 和 NSMutableSet 类簇作为集合数据结构的具体实现。这些类簇提供了我们在集合数据结构中预期看到的大部分功能,并且缺少的显式函数非常简单易实现,这使得 Objective-C 的实现相当直接:
@interface EDSPlaylistSet()
{
NSMutableSet<EDSSong*>* _songs;
NSInteger _capacity;
BOOL _premiumUser;
BOOL _isEmpty;
BOOL _isFull;
}
@end
@implementation EDSPlaylistSet
-(instancetype)playlistSetWithPremiumUser:(BOOL)isPremiumUser andCapacity:(NSInteger)capacity
{
if (self == [super init])
{
_songs = [NSMutableSet set];
_premiumUser = isPremiumUser;
_capacity = capacity;
}
return self;
}
-(BOOL)isEmpty
{
return [_songs count] == 0;
}
-(BOOL)isFull
{
if (_premiumUser)
{
return NO;
}
else
{
return [_songs count] == _capacity;
}
}
使用 NSMutableSet,我们为我们的类创建了一个名为 _songs 的私有 ivar。我们的初始化器实例化了这个字段,为我们提供了构建 EDSPlaylistSet 类的基础数据结构。我们还在头文件中创建了四个公共属性:capacity、premiumUser、isEmpty 和 isFull,这些属性由同名的私有 ivar 支持。capacity 属性存储非高级用户可以在他们的播放列表中存储的最大歌曲数量,而 premiumUser 表示此列表是否属于高级账户。isEmpty 和 isFull 属性允许我们的类轻松实现同名操作。isEmpty 属性简单地返回集合的计数是否为 0,而 isFull 属性首先检查此列表是否属于高级账户。如果是 true,则集合永远不会满,因为我们允许高级用户在他们的播放列表中存储无限数量的歌曲。如果此列表不属于高级账户,我们的方法确保 _songs 的当前计数没有超过容量,并返回该比较的结果:
-(BOOL)addSong:(EDSSong*)song
{
if (!_isFull && ![_songs containsObject:song])
{
[_songs addObject:song];
return YES;
}
return NO;
}
addSong: 方法为我们类提供 添加 功能。此方法首先确认集合未满,然后确认对象实际上包含在 _songs 集合中。如果集合未通过这两个测试,则方法返回 NO,因为我们不能向列表添加更多歌曲或歌曲已存在于集合中。否则,方法调用 addObject: 并返回 YES:
-(BOOL)removeSong:(EDSSong*)song
{
if ([_songs containsObject:song])
{
[_songs removeObject:song];
return YES;
}
else
{
return NO;
}
}
removeSong: 方法为我们类提供 移除 功能。此方法确认歌曲存在于集合中,然后使用 removeObject: 移除歌曲,并最终返回 YES。如果歌曲不在集合中,则方法返回 NO:
-(void)mergeWithPlaylist:(NSMutableSet<EDSSong*>*)playlist
{
[_songs unionSet:playlist];
}
mergeWithPlaylist: 方法为我们类提供 并集 功能。幸运的是,NSSet 通过 unionSet: 方法公开了并集功能,因此我们的方法只需简单地调用它。在这种情况下,unionSet: 将将 playlist 参数与我们的现有 _songs 列表合并:
-(NSMutableSet<EDSSong*>*)findSharedSongsInPlaylist: (NSMutableSet<EDSSong*>*)playlist
{
NSMutableSet *songsCopy = [NSMutableSet setWithSet:_songs];
[songsCopy intersectSet:playlist];
return songsCopy;
}
接下来,findSharedSongsInplaylist: 方法为我们类提供 交集 功能。同样,NSSet 通过 intersectSet: 方法公开了交集功能。正如我们的 C# 示例一样,我们不是在原地修改 _songs 集合,而是在 _songs 复制和 playlist 参数之间返回交集:
-(NSMutableSet<EDSSong*>*)findUniqueSongs:(NSMutableSet<EDSSong*>*)playlist
{
NSMutableSet *songsCopy = [NSMutableSet setWithSet:_songs];
[songsCopy minusSet:playlist];
return songsCopy;
}
findUniqueSongs: 方法为我们类提供 差集 功能。再次,NSSet 通过 minusSet: 方法公开了差集功能。同样,此方法不会修改我们现有的集合,而是返回 _songs 复制和 playlist 参数上的 minusSet: 或差集操作的结果:
-(BOOL)isSubset:(NSMutableSet<EDSSong*>*)playlist
{
return [_songs isSubsetOfSet:playlist];
}
-(BOOL)isSuperset:(NSMutableSet<EDSSong*>*)playlist
{
return;
}
isSubset: 和 isSuperset: 方法通过其名称提供功能。这些方法以与我们的 Java 示例使用 Set<E> 接口的 containsAll(HashSet<E>) 方法类似的方式,利用 NSSet 上的 isSubsetOfSet: 方法:
-(NSInteger)totalSongs
{
return [_songs count];
}
最后,totalSongs 方法返回 _songs 集合中找到的元素数量,为我们集合提供 计数 功能。
Swift
Swift 提供了 Set 类作为集合数据结构的具体实现。此类提供了我们在集合数据结构中预期看到的所有功能,甚至比其 Objective-C 对应物还要多,这使得 Swift 实现非常简洁:
var _songs: Set<Song> = Set<Song>()
public private(set) var _capacity: Int
public private(set) var _premiumUser: Bool
public private(set) var _isEmpty: Bool
public private(set) var _isFull: Bool
public init (capacity: Int, premiumUser: Bool)
{
_capacity = capacity
_premiumUser = premiumUser
_isEmpty = true
_isFull = false
}
public func premiumUser() -> Bool
{
return _premiumUser
}
public func isEmpty() -> Bool
{
return _songs.count == 0
}
public func isFull() -> Bool
{
if (_premiumUser)
{
return false
}
else
{
return _songs.count == _capacity
}
}
使用 Set,我们为我们的类创建了一个私有实例变量 _songs,并在其声明时直接初始化,这为我们构建 PlaylistSet 类提供了底层的数据结构。我们还创建了四个公共字段:_capacity、_premiumUser、_isEmpty 和 _isFull,以及后三个字段的公共访问器。capacity 字段存储非高级用户可以在他们的播放列表中存储的最大歌曲数量,而 premiumUser 表示此列表是否属于高级账户。isEmpty 和 isFull 字段允许我们的类轻松实现同名操作。isEmpty() 字段简单地返回集合的计数是否为 0。isFull() 字段首先检查此列表是否属于高级账户。如果是 true,则集合永远不会满,因为我们允许高级用户在他们的播放列表中存储无限数量的歌曲。如果此列表不属于高级账户,我们的获取器将确保 _songs 的当前计数没有超过 capacity,并返回这个比较结果:
public func addSong(song: Song) -> Bool
{
if (!_isFull && !_songs.contains(song))
{
_songs.insert(song)
return true
}
return false
}
addSong(song: Song) 方法为我们类提供了 add 功能。此方法首先确认集合不为满,然后确认对象实际上包含在 _songs 集合中。如果集合未通过这两个测试,则方法返回 false,因为我们不能向列表中添加更多歌曲或歌曲已存在于集合中。否则,方法调用 insert() 并返回 true:
public func removeSong(song: Song) -> Bool
{
if (_songs.contains(song))
{
_songs.remove(song)
return true
}
else
{
return false
}
}
removeSong(song: Song) 方法为我们类提供了 remove 功能。此方法确认歌曲存在于集合中,然后使用 remove() 删除歌曲,并最终返回 true。如果歌曲不存在于集合中,则方法返回 false:
public func mergeWithPlaylist(playlist: Set<Song>)
{
_songs.unionInPlace(playlist)
}
mergeWithPlaylist(playlist: Set<Song>) 方法为我们类提供了 union 功能。幸运的是,Set 通过 unionInPlace() 方法暴露了并集功能,因此我们的方法只需调用它。在这种情况下,unionInPlace() 将将 playlist 参数与我们的现有 _songs 列表合并:
public func findSharedSongsInPlaylist(playlist: Set<Song>) -> Set<Song>
{
return _songs.intersect(playlist)
}
接下来,findSharedSongsInplaylist(playlist: Set<Song>) 方法为我们类提供了 intersection 功能。Set 类通过 intersect() 方法暴露了交集功能。intersect() 方法不会修改 _songs,但只返回 _songs 和 playlist 参数之间的交集结果,因此我们只需返回这个方法调用的结果:
public func findUniqueSongs(playlist: Set<Song>) -> Set<Song>
{
return _songs.subtract(playlist)
}
findUniqueSongs(playlist: Set<Song>) 方法为我们类提供了 difference 功能。再次强调,Set 通过 subtract() 方法暴露了差集功能。subtract() 方法不会修改 _songs,但只返回 _songs 和 playlist 参数之间的差集结果,因此我们只需返回这个方法调用的结果:
public func isSubset(playlist: Set<Song>) -> Bool
{
return _songs.isSubsetOf(playlist)
}
public func isSuperset(playlist: Set<Song>) -> Bool
{
return _songs.isSupersetOf(playlist)
}
isSubset(playlist: Set<Song>) 和 isSuperset(playlist: Set<Song>) 方法通过其名称提供功能。这些方法分别利用 isSubSetOf() 和 isSuperSetOf() 方法,并返回一个表示这些比较结果的布尔值:
public func totalSongs() -> Int
{
return _songs.count;
}
最后,totalSongs() 方法返回在 _songs 集合中找到的元素数量,为我们集合提供 计数 功能。
高级主题
现在我们已经考察了集合在常见应用中的使用方式,我们应该花些时间来考察它们在底层是如何实现的。大多数集合有三种类型:基于哈希表的集合、基于树的集合和基于数组的集合。
基于哈希表的集合
基于哈希表的集合通常用于无序数据集合。因此,对于非专业应用,你将遇到的绝大多数集合将是基于哈希表的。基于哈希表的集合与字典具有相似的操作成本。例如,搜索、插入和删除操作的操作成本都是 O(n)。
基于树的集合
基于树的集合通常基于二叉搜索树,但有时也可以基于其他结构。由于它们的设计,二叉搜索树允许在平均情况下非常高效的搜索功能,因为每个被检查的节点都可以允许从剩余的搜索模式中丢弃树的分支。尽管搜索二叉搜索树的最坏情况下的操作成本是 O(n),但在实践中这很少需要。
基于数组的集合
数组可以用来实现集合的子集,这使得在正确组织的基于数组的集合中进行并集、交集和差集操作变得更加高效。
摘要
在本章中,你学习了集合数据结构的基本定义。为了充分欣赏该结构的功能,我们简要地考察了集合数据结构所基于的集合论的基本原则。随后,我们探讨了集合的常见操作以及它们与集合论函数的关系。然后,我们研究了如何在文本中研究的四种语言中实现集合。接下来,我们再次审视了登录到服务的用户问题,看看我们能否使用集合数据结构而不是数组或列表来改进其实施。在此之后,我们考察了一个案例研究,其中集合将是有益的。最后,我们研究了集合的不同实现方式,包括基于哈希表的集合、基于树的集合和基于数组的集合。
第八章。结构体:复杂类型
结构体是一组数据变量或值的集合,这些变量或值被组织在单个内存块下,而数据结构通常是某种以某种方式相互关联的对象集合。因此,结构体,也称为结构,更多的是一种复杂的数据类型,而不是数据结构。这个定义听起来很简单,但在这个情况下,外表是欺骗性的。结构体的主题是复杂的,我们正在检查的每种语言在支持结构体方面都有其独特的特点,如果它们支持的话。
在本章中,我们将涵盖以下内容:
-
结构数据结构的定义
-
创建结构体
-
结构体的常见应用
-
每种语言中结构体的示例
-
枚举
基础知识
由于语言之间的支持不同,我们将在本章采取不同的方法。我们不会将结构体作为一个整体来检查,然后再检查一个案例研究,而是将每种语言的结构体和案例研究同时进行检查。这将给我们机会在适当的环境中检查每种语言中结构体的细微差别。
C#
在 C#中,结构体被定义为封装相关字段的小组值的值类型,这听起来与底层 C 语言实现非常相似。然而,C#结构体实际上与 C 中的结构体有很大不同,它们更类似于那种语言的常规类。例如,C#结构体可以有方法、字段、属性、常量、索引器、运算符方法、嵌套类型和事件,以及定义的构造函数(但不包括默认构造函数,它是自动定义的)。结构体还可以实现一个或多个接口,所有这些都使得 C#版本比 C 更加灵活。
然而,将结构体视为轻量级类是错误的。C#结构体不支持继承,这意味着它们不能从类或其他结构体继承,也不能用作其他结构或类的基类。结构体成员不能声明为抽象的、受保护的或虚拟的。与类不同,结构体可以在不使用new关键字的情况下实例化,尽管这样做会阻止结果对象在所有字段被分配之前被使用。最后,也许最重要的是,结构体是值类型,而类是引用类型。
这个最后一点不能过分强调,因为它代表了选择结构体而不是类的主要优势。结构体是值的集合,因此不存储诸如数组之类的对象的引用。因此,当你将结构体传递给方法时,它是按值传递而不是按引用传递。此外,根据 MSDN 文档,作为值类型,结构体不需要分配堆内存,因此不携带类在内存和处理需求方面的开销。
注意
这意味着什么?为什么这有益?当你使用 new 运算符创建一个新的类时,返回的对象将在堆上分配。另一方面,当你实例化一个结构体时,它直接在堆栈上创建,这带来了性能提升,因为堆栈提供的内存访问速度比堆快得多。只要你不过度使用堆栈并导致堆栈溢出,有策略地使用结构体可以极大地提高你应用程序的性能。
现在你可能自己在想,如果结构体这么棒,我们为什么还要有类呢? 首先,C#中结构体的应用非常有限。根据微软的说法,你应该只在类型的实例很小且生命周期短暂,或者它们通常嵌入在其他对象中时,才考虑使用结构体而不是类。此外,除非结构体至少满足以下三个标准之一,否则不应定义结构体:
-
结构体将逻辑上表示一个类似于整数、双精度浮点数等原始类型的单个值
-
结构体的每个实例都将小于 16 字节
-
结构体中的数据一旦实例化后将是不可变的
-
结构体不需要反复装箱和拆箱
这些要求相当严格!当你考虑你可以实际用结构体做什么时,前景会稍微变得糟糕一些。这里有一个提示--不多。让我们比较一下结构体和类的能力:
-
您可以设置和访问单个组件--类也可以这样做。
-
您可以将结构体传递给函数--是的,您也可以用类这样做。
-
您可以使用赋值运算符(
=)将一个结构体的内容赋值给另一个结构体--这里没有特别之处。 -
您可以从函数中返回一个结构体,这实际上会创建结构体的一个副本,因此现在堆栈上有两个。类?检查。然而,在这方面类更优越,因为当一个函数返回类的实例时,对象是通过引用传递的,因此不需要创建额外的副本。
-
结构体不能使用等号运算符(
==)进行相等性测试,因为结构体可能包含其他数据。然而,类可以使用等号运算符进行比较。事实上,如果您想在结构体中实现相同的功能,您必须逐个字段比较,这是很繁琐的。
如果有人要对这场对决进行评分,我认为结果可能看起来像结构体:4,类:5(也许 6)。所以很明显,在功能和便利性方面,类更加灵活,这就是为什么以 C 为基础的高级语言通常提供机制来实现这些更复杂对象的原因。
这并不意味着结构体没有其价值。尽管它们的实用性局限于非常狭窄的场景,但在某些时候,结构体是完成这项工作的正确工具。
在 C#中创建结构体
在 C#中创建结构体是一个相当简单的过程。我们只有两个要求:使用using System和用struct关键字声明我们的对象。以下是一个示例:
using System;
public struct MyStruct
{
private int xval;
public int X
{
get
{
return xval;
}
set
{
if (value < 100)
xval = value;
}
}
public void WriteXToConsole()
{
Console.WriteLine("The x value is: {0}", xval);
}
}
//Usage
MyStruct ms1 = new MyStruct();
MyStruct ms2 = MyStruct();
ms.X = 9;
ms.WriteXToConsole();
//Output
//The x value is: 9
如前例所示,我们的结构体使用私有后置字段、公共访问器和名为WriteXToConsole()的一个实例方法声明,这些都是 C#中结构体的完全合法特性。注意MyStruct的两个实例。第一个使用new关键字实例化,而第二个没有。再次强调,这两个操作在 C#中都是完全有效的,尽管后者要求你在以任何方式使用对象之前必须填充所有成员属性。如果你将定义中的struct关键字改为class,第二个初始化器将无法编译。
接下来,我们将从第三章的例子中进行分析,列表:线性集合。在该章节的案例研究中,我们构建了一个存储Waypoint对象列表的数据结构。以下是 C#中Waypoint类的样子:
public class Waypoint
{
public readonly Int32 lat;
public readonly Int32 lon;
public Boolean active { get; private set; }
public Waypoint(Int32 latitude, Int32 longitude)
{
this.lat = latitude;
this.lon = longitude;
this.active = true;
}
public void DeactivateWaypoint()
{
this.active = false;
}
public void ReactivateWaypoint()
{
this.active = true;
}
}
如你所见,这个类非常简单。简单到让人质疑这样一个简单的值集合是否值得分配给类所提供的开销和资源,尤其是当你考虑到我们的列表可能包含数百个这样的Waypoint对象时。我们能否通过将类转换为结构体来提高性能,而无需进行重大的重构以支持这种更改?首先,我们需要确定这样做是否推荐,甚至是否可行,我们可以通过检查我们的结构体准则规则来做出这个决定。
规则 1:结构体将逻辑上表示单个值
在这种情况下,我们的类有三个字段,即lat、lon和active。三个显然不是单个,但根据规则,结构体必须逻辑上表示单个值,因此我们将类转换为结构体的计划仍然是有效的。这是因为Waypoint对象代表二维空间中的单个位置,而我们至少需要两个值来表示二维坐标,所以这里没有违反规则。此外,活动属性表示航点的状态,因此这也符合特征性可接受。在你对这种解释提出异议之前,让我指出,即使是微软也对此规则不太严格。例如,System.Drawing.Rectangle被定义为结构体,该类型存储表示矩形大小和位置的四个整数。大小和位置是单个对象的两个属性,这被认为是可接受的,所以我相信Waypoint在这里是合适的。
规则 2:结构体的每个实例必须小于 16 字节
我们的Waypoint类很容易符合这一安全规则。参考第一章,数据类型:基础结构,Int32结构体长度为 4 字节,布尔原始类型长度仅为 1 字节。这意味着单个Waypoint实例的总重量仅为九字节,我们还有七个字节的空间。
规则 3:数据必须是不可变的
结构体应该理想上是不可变的,这与它们作为值类型的状态有关。如前所述,每当传递一个值类型时,你最终得到的是该值的副本,而不是原始值的引用。这意味着当你更改结构体内的值时,你只是在更改该结构体,而不会影响到堆栈中可能存在的其他任何结构体。
这个要求可能对我们来说是一个问题,而且不是一个小问题。在我们的应用程序中,我们选择在对象本身上存储Waypoint值的活跃状态,而这个字段肯定不是不可变的。我们可以以某种方式将属性移出Waypoint类,但这样做需要比如果我们简单地让它保持原样进行更多的重构。由于我们目前想要避免重大的重构,我们将保持该字段不变,并将此规则视为对我们计划的打击。我们唯一的补救办法是检查我们代码中对Waypoint对象的使用,以确保我们永远不会创建一个Waypoint实例以这种方式传递,以至于我们失去了对正确实例的关注。从技术上来说,只要Waypoint通过下一个要求,我们仍然在业务中。
规则 4:结构体不需要重复装箱
由于Waypoint对象一旦实例化后就是直接使用的,因此每个实例很少,如果不是从未,会被装箱或解箱。因此,我们的类通过了这个测试,并符合转换为结构体的条件。
转换
接下来的问题是,能否将 Waypoint 类转换为结构体? 在我们的类中有三个需要注意的点可能需要解决。首先,我们有一个可变的active字段需要处理。在其当前形式下,这个字段并不太像结构体,因为它实际上应该是不可变的。由于在这个阶段我们真的没有其他办法,我们不得不以另一种方式处理它。主要来说,这意味着我们需要非常严格地监控我们对Waypoint对象的使用,以确保当我们认为我们在处理原始结构体时,我们实际上并没有在处理结构体的副本。尽管这可能会变得繁琐,但这并不不合理。我们的下一个关注点是定义的构造函数,但由于这不是没有参数或默认构造函数,所以这里一切正常,我们可以继续前进。最后,我们的类有两个名为DeactivateWaypoint()和ReactivateWaypoint()的公共方法。由于 C#也允许在结构体中使用公共方法,这两个方法在这里也是可以的。事实上,我们真正需要做的,将这个类转换为结构体,就是将class关键字改为struct关键字!以下是我们的结果代码:
public struct Waypoint
{
public readonly Int32 lat;
public readonly Int32 lon;
public Boolean active { get; private set; }
public Waypoint(Int32 latitude, Int32 longitude)
{
this.lat = latitude;
this.lon = longitude;
this.active = true;
}
public void DeactivateWaypoint()
{
this.active = false;
}
public void ReactivateWaypoint()
{
this.active = true;
}
};
最后,我们需要知道这个更改是否会在整体上代表我们应用的任何改进。没有对应用在运行时的广泛测试和分析,我们无法确定地说,但可能性很大,这个修改将对我们越野骑行应用的整体性能产生积极影响,而不会引入任何进一步的重构需求。
Java
这将是一个简短的讨论,因为 Java 不支持结构体。显然,Java 的作者们决定,当这种语言最终从 C 编程的泥潭中爬出来时,它不会带着这些非面向对象的结构体四处奔波。因此,在 Java 中我们唯一的办法是创建一个具有公共属性的类来模拟结构体的行为,但没有任何性能上的优势。
Objective-C
Objective-C 不支持直接使用结构体;然而,你可以在代码中实现和使用简单的 C 结构体。C 结构体与它们的 C#对应物类似,因为它们允许你将几个原始值组合成一个更复杂的值类型。但是,C 结构体不允许添加方法或初始化器,也不允许 C#结构体所享有的任何其他酷炫的面向对象编程特性。此外,C 结构体不能包含从NSObject继承的对象,因为这些是类而不是值类型。
话虽如此,在 Objective-C 应用程序中,结构体实际上是非常常见的。结构体最常见的一个应用是在枚举或枚举类型的定义中。枚举是一系列表示整数值的常量列表,其目的是在代码中创建更高层次的抽象,这样开发者就可以专注于值的含义,而不必担心它们在后台的实现方式。我们将在本章后面更详细地探讨枚举。
在 Objective-C 中创建结构体
Objective-C 中结构体的另一个常见来源可以在Core Graphics 框架中找到,该框架包含四个有用的结构体。我们将详细研究这些结构体,以展示如何在 Objective-C 中定义结构体:
CGPoint:这个结构体包含一个简单的二维坐标系,由两个CGFloat值组成。下面是CGPoint结构体的定义:
struct CGPoint {
CGFloat x;
CGFloat y;
};
typedef struct CGPoint CGPoint;
CGSize:这个结构体只是一个宽度和高度的容器,由两个CGFloat值组成。下面是CGSize结构体的定义:
struct CGSize {
CGFloat width;
CGFloat height;
};
typedef struct CGSize CGSize;
CGRect:这是一个定义矩形位置和大小的结构体,由一个CGPoint值和一个CGSize值组成。下面是CGRect结构体的定义:
struct CGRect {
CGPoint origin;
CGSize size;
};
typedef struct CGRect CGRect;
CGVector:这是一个仅包含二维向量的结构体,由两个CGFloat值组成。下面是CGVector结构体的定义:
struct CGVector {
CGFloat dx;
CGFloat dy;
};
typedef struct CGVector CGVector;
注意
你应该注意在每个结构体定义之后跟随的typedef和struct关键字。这一行是为了我们程序员的方便而包含的。无论何时我们需要调用这些结构体,如果结构体没有用typedef关键字装饰,我们都需要在调用结构体之前始终加上struct关键字,如下所示:
struct CGRect rect;
显然,这会很快变得令人厌烦。通过将typedef应用于结构体名称,我们允许调用者简单地使用结构体名称,而不需要struct关键字,如下所示:
struct CGRect rect;
这使得我们的代码更容易编写,但同时也使得代码在长期来看更加简洁和易于阅读。
现在,我们将查看第三章中的EDSWaypoint类,并确定我们是否可以将该类转换为 C 结构体。以下是原始代码:
@interface EDSWaypoint()
{
NSInteger _lat;
NSInteger _lon;
BOOL _active;
}
@end
@implementation EDSWaypoint
-(instancetype)initWithLatitude:(NSInteger)latitude andLongitude:(NSInteger)longitude
{
if (self = [super init])
{
_lat = latitude;
_lon = longitude;
_active = YES;
}
return self;
}
-(BOOL)active
{
return _active;
}
-(void)reactivateWaypoint
{
_active = YES;
}
-(void)deactivateWaypoint
{
_active = NO;
}
@end
立刻在接口中,我们就看到了将这个类转换为结构体的一些问题。_lat 和 _lon ivars 都是 NSInteger 类,这意味着它们在结构体中使用是无效的,它们必须被移除或改为值类型。那么 initWithLatitude:andLongitude: 初始化器呢?不行,你也不能在 C 结构体中定义初始化器。所以,现在我们需要处理 reactivateWaypoint 和 deactivateWaypoint 方法。当然,这些简单的属性和方法肯定可以通过接受进入结构体的考验?不,它们不能。这里的一切都需要被移除。
因此,唯一剩下的问题就是我们应该如何处理 _active 值和相关的 -(BOOL)active 属性。实际上,BOOL 类型在结构体中使用是完全可以接受的,所以我们可以实际上保留这个属性。然而,_active 在 EDSWaypoint 结构体中确实代表了一个可变属性,这是不被提倡的,对吧?虽然不被提倡,但在 C 中结构体并不是不可变的。以下是一个使用 Core Graphics 结构体 CGPoint 的例子:
CGPoint p = CGPointMake(9.0, 5.2);
p.x = 9.8;
p.y = 5.5;
如果你将此代码复制到你的应用程序中,编译器不会发出错误或警告,因为 CGPoint 不是不可变的,属性也不是只读的。因此,我们可以在最终的 struct 定义中保留 _active 值。不幸的是,对于 -(BOOL)active 属性来说,情况并非如此?像这样的属性访问器在 C 结构体中是禁止的,所以这个属性需要被移除,这代表了对我们应用程序处理 Waypoint 对象活动状态方式的重大改变。因此,如果我们想将这个类转换为结构体,我们将得到以下内容:
struct EDSWaypoint {
int lat;
int lon;
BOOL active;
};
typedef struct EDSWaypoint EDSWaypoint;
严格来说,typedef 声明不是必需的,但我们必须重构整个 EDSWaypointList 类以支持这些更改已经足够糟糕了。我们不应该再让我们的开发者每次想要访问这些类型之一时都要多输入八个额外的字符。
Swift
就像在其他语言中一样,Swift 中的结构体是值类型,它们封装了一组相关的属性。与 C# 中的结构体类似,Swift 结构体比 C 结构体更像是一个常规类,并且与类共享以下所有能力:
-
能够定义属性以存储值
-
能够包含定义扩展功能的方法
-
能够定义下标以使用下标符号访问值
-
能够定义自定义初始化器
-
Swift 结构体可以被扩展以提供超出其初始化状态的额外功能
-
最后,Swift 结构体可以被定义为符合提供常规功能的协议
然而,请注意,Swift 的结构体不支持继承,这意味着它们不能从类或其他结构体继承,也不能作为其他结构体或类的基类。此外,它们不支持类型转换,以使编译器能够在运行时检查和解释实例的类型。这些结构体不能像类那样显式地被销毁以释放其资源,结构体也不支持自动引用计数进行内存管理。最后两点与 Swift 中的结构体(与其他语言一样)是值类型而不是类或引用类型的事实相关。
关于 Swift 的这一点需要再次强调。结构体是值的集合,因此它们不会像数组或字典等其他集合那样存储对象的引用。因此,当你将结构体作为参数传递给或从方法中返回时,它是按值传递而不是按引用传递。
那么,在 Swift 中何时应该选择使用结构体而不是类呢?Apple 的文档提供了一些一般性规则,以帮助你做出决定。你应该在以下情况下使用结构体:
-
你的对象的主要目的是收集一些简单的数据值
-
你预计你创建的对象在分配或发送该对象的实例时将被复制而不是被引用
-
你对象中的任何属性都是值类型,而不是类,你也期望它们的值会被复制而不是被引用
-
你的对象没有必要从现有对象或类型继承属性或行为
你会注意到,这个列表并不像 C# 中的相同列表那样严格,但它确实代表了一种很好的常识方法,用于决定使用结构体带来的价值是否超过了对象中有限的功能。
在 Swift 中创建结构体
如果你使用 Swift 超过五分钟,那么你很可能已经使用过一些内置的结构体,例如 Int、String、Array、Dictionary 以及 Swift 框架中定义的许多其他结构体。以下是一个使用 Swift 定义你自己的结构体的快速演示:
Public struct MyColor {
var red = 0
var green = 0
var blue = 0
var alpha = 0.0
}
以下示例定义了一个名为 MyColor 的新结构体,它描述了一个基于 RGBA 的颜色定义。这个结构体有四个属性,分别称为 red、green、blue 和 alpha。尽管这些属性都已被定义为可变变量使用 var,但 Swift 中的存储属性也可以使用 let 定义为不可变。我们结构体中的前三个属性通过将其默认值设置为 0 被推断为 Int 类型,而剩余的属性通过将其默认值设置为 0.0 被推断为 Double 类型。由于我们尚未为我们的方法定义任何自定义初始化器,我们可以如下初始化这个对象的实例:
var color = MyColor()
color.red = 139
color.green = 0
color.blue = 139
color.alpha = .5
上述代码初始化了我们的结构体,并将值设置为类似于 50%透明度的深洋红色。这个演示是好的,但初始化对于许多开发者的口味来说有点冗长。如果我们想在一行中创建一个新对象怎么办?在这种情况下,我们需要修改我们的结构体以包含一个自定义初始化器,如下所示:
public struct MyColor {
var red = 0
var green = 0
var blue = 0
var alpha = 0.0
public init(R: Int, G: Int, B: Int, A: Double)
{
red = R
green = G
blue = B
alpha = A
}
}
var color = MyColor(R: 139, G:0, B:139, A:0.5)
利用 Swift 允许结构体定义自定义初始化器的优势,我们创建了一个接受 RGBA 值并将它们分配给对象属性的 init 方法,极大地简化了对象创建。
现在,我们将查看 第三章 中的 Waypoint 类,并确定我们是否可以将该类转换为结构体。以下是原始代码:
public class Waypoint : Equatable
{
var lat: Int
var long: Int
public private(set) var active: Bool
public init(latitude: Int, longitude: Int) {
lat = latitude
long = longitude
active = true
}
public func DeactivateWaypoint()
{
active = false;
}
public func ReactivateWaypoint()
{
active = true;
}
}
public func == (lhs: Waypoint, rhs: Waypoint) -> Bool {
return (lhs.lat == rhs.lat && lhs.long == rhs.long)
}
现在这是一个有趣的类对象。我们首先解决房间里的大象:Equatable 接口和名为 == 的公共函数被声明在类结构外部。我们的类必须实现 Equatable 接口,因为 WaypointList 中的几个方法需要比较两个 Waypoint 对象的相等性。没有这个接口和相关的 == 方法实现,这是不可能的,我们的代码也无法编译。幸运的是,Swift 结构体可以实施接口,如 Equatable,所以这根本不是问题,我们可以继续前进。
我们已经讨论并演示了 Swift 结构体可以定义自定义初始化器,所以我们的公共 init 方法就很好。Waypoint 类还有两个名为 DeactivateWaypoint() 和 ActivateWaypoint() 的方法。由于结构体旨在不可变,我们需要对类进行最后的更改,以将 mutating 关键字添加到每个方法中,以表示每个方法修改或突变实例中的一个或多个值。以下是我们的 Waypoint 类的最终版本:
public struct Waypoint : Equatable
{
var lat: Int
var long: Int
public private(set) var active: Bool
public init(latitude: Int, longitude: Int) {
lat = latitude
long = longitude
active = true
}
public mutating func DeactivateWaypoint()
{
active = false;
}
public mutating func ReactivateWaypoint()
{
active = true;
}
}
public func == (lhs: Waypoint, rhs: Waypoint) -> Bool {
return (lhs.lat == rhs.lat && lhs.long == rhs.long)
}
注意
将 mutating 关键字添加到我们的实例方法中,将允许我们将 Waypoint 重新定义为结构体,但它也会给我们的实现引入一个新的限制。考虑以下示例:
let point = Waypoint(latitude: 5, longitude: 10)
point.DeactivateWaypoint()
这段代码将无法编译,并出现错误 不可变的类型 'Waypoint' 只包含名为 DeactivateWaypoint 的 mutating 成员。等等。现在怎么办?通过包含 mutating 关键字,我们也是明确地声明这个结构体是可变的类型。声明这个类型为不可变是可以的,除非你尝试调用其中一个 mutating 方法,这时代码将无法编译。在此之前,我们可以根据需要将 Waypoint 的任何实例声明为可变的 var 或不可变的 let,但现在,如果我们打算使用 mutating 方法,我们只能将这个对象声明为可变的实例。
枚举
如前所述,枚举增加了应用程序的抽象级别,并允许开发者关注值的含义,而不是担心值在内存中的存储方式。这是因为enum类型允许你用有意义的或易于记忆的名称标记特定的整数数值。
案例研究:地铁线路
商业问题:你与一个负责编写应用程序以跟踪地铁通勤列车团队的工程师合作。其中一个关键的业务需求是能够轻松识别列车当前位于哪个车站或正在前往哪个车站。每个车站都有一个独特的名称,但数据库通过其 ID 值(如 1100、1200、1300 等)跟踪车站。而不是通过名称跟踪车站,因为名称既繁琐又容易随时间变化,你的应用程序将利用车站 ID。然而,最初用名称而不是 ID 标记车站的原因是为了使通勤者更容易识别它们。这也适用于程序员,他们在编写代码时很难记住几十个甚至几百个车站的 ID。
你决定利用枚举数据结构来满足你的应用程序和开发者的需求。你的枚举将提供易于记忆的车站名称与它们关联的车站 ID 之间的映射,因此你的应用程序可以根据车站名称利用 ID,而你的程序员将使用名称。
C#
为了避免在大型车站多个列车线路重叠时产生混淆,我们不想简单地创建一个包含整个地铁线路所有车站的枚举。相反,我们将根据地铁的每条线路创建枚举。以下是一个定义为 C#枚举的 Silver Line:
public enum SilverLine
{
Wiehle_Reston_East = 1000,
Spring_Hill = 1100,
Greensboro = 1200,
Tysons_Corner = 1300,
McClean = 1400,
East_Falls_Church = 2000,
Ballston_MU = 2100,
Virginia_Sq_GMU = 2200,
Clarendon = 2300,
Courthouse = 2400,
Rosslyn = 3000,
Foggy_Bottom_GWU = 3100,
Farragut_West = 3200,
McPherson_Sq = 3300,
Metro_Center = 4000,
Federal_Triangle = 4100,
Smithsonian = 4200,
LEnfant_Plaza = 5000,
Federal_Center_SW = 5100,
Capital_South = 5200,
Eastern_Market = 5300,
Potomac_Ave = 5400,
Stadium_Armory = 6000,
Benning_Road = 6100,
Capital_Heights = 6200,
Addison_Road = 6300,
Morgan_Blvd = 6400,
Largo_Town_Center = 6500
}
现在,无论我们想在何处使用SilverLine枚举的值,我们只需声明一个同名的值类型并分配一个值,如下所示:
SilverLine nextStop = SilverLine.Federal_Triangle;
nextStop = SilverLine.Smithsonian;
在我们刚才看到的示例中,我们的代码初始化一个SilverLine值,以显示 Silver Line 的下一站为车站4100,使用SilverLine.Federal_Triangle。一旦车门在站台关闭,我们需要更新这个值以显示我们的列车正在前往车站4200,因此我们将值更新为SilverLine.Smithsonian。
Java
尽管 Java 不允许我们显式地定义结构体,但我们可以定义枚举。然而,定义可能不会像你预期的那样:
public enum SilverLine
{
WIEHLE_RESTON_EAST,
SPRING_HILL,
GREENSBORO,
TYSONS_CORNER,
MCCLEAN,
EAST_FALLS_CHURCH,
BALLSTON_MU,
VIRGINIA_SQ_GMU,
CLARENDON,
COURTHOUSE,
ROSSLYN,
FOGGY_BOTTOM_GWU,
FARRAGUT_WEST,
MCPHERSON_SQ,
METRO_CENTER,
FEDERAL_TRIANGLE,
SMITHSONIAN,
LENFANT_PLAZA,
FEDERAL_CENTER_SW,
CAPITAL_SOUTH,
EASTERN_MARKET,
POTOMAC_AVE,
STADIUM_ARMORY,
BENNING_ROAD,
CAPITAL_HEIGHTS,
ADDISON_ROAD,
MORGAN_BLVD,
LARGO_TOWN_CENTER
}
你可能会注意到我们没有明确地为这些条目中的每一个分配整数值。这是因为 Java 不允许我们这样做。记住,Java 不支持结构体,所以在这个语言中的枚举实际上不是原语,而是它们自己类型的对象。因此,它们不遵循其他语言中枚举的规则,有些人认为 Java 枚举因此更加健壮。
对于我们计划使用此结构的情况,这个限制将是一个小障碍,因为我们不能直接将站点名称映射到它们关联的 ID 值。这里的一个选择是添加一个public static方法,它将操作this的字符串值,并使用该值在幕后将字符串映射到整数值。这可能是一个相当冗长的解决方案,但当你考虑到这是可能的这一事实时,它为解决整体业务问题开辟了一个全新的解决方案世界。
Objective-C
就像 Objective-C 不支持结构体一样,它也不直接支持枚举。幸运的是,在这种情况下,我们也可以使用底层的 C 语言枚举。下面是如何做的:
typedef enum NSUInteger
{
Wiehle_Reston_East = 1000,
Spring_Hill = 1100,
Greensboro = 1200,
Tysons_Corner = 1300,
McClean = 1400,
East_Falls_Church = 2000,
Ballston_MU = 2100,
Virginia_Sq_GMU = 2200,
Clarendon = 2300,
Courthouse = 2400,
Rosslyn = 3000,
Foggy_Bottom_GWU = 3100,
Farragut_West = 3200,
McPherson_Sq = 3300,
Metro_Center = 4000,
Federal_Triangle = 4100,
Smithsonian = 4200,
LEnfant_Plaza = 5000,
Federal_Center_SW = 5100,
Capital_South = 5200,
Eastern_Market = 5300,
Potomac_Ave = 5400,
Stadium_Armory = 6000,
Benning_Road = 6100,
Capital_Heights = 6200,
Addison_Road = 6300,
Morgan_Blvd = 6400,
Largo_Town_Center = 6500
} SilverLine;
首先,请注意,我们已经将typedef关键字集成到这个定义中,这意味着我们不需要在我们的代码中单独一行添加SilverLine枚举对象的声明。还要注意enum关键字,它是 C 中声明枚举所必需的。请注意,我们明确声明这个枚举是NSUInteger类型的值。我们在这里使用NSUInteger是因为我们不希望支持有符号值,但如果我们这样做,我们同样可以轻松地选择NSInteger来达到这个目的。最后,请注意,enum变量的实际名称在定义之后。
否则,我们的枚举定义与其他大多数基于 C 的语言的枚举定义相似,只是有几个注意事项。首先,如果你打算在当前文件的作用域之外使用枚举,则必须在头文件(*.h)中声明枚举。在任何情况下,枚举也必须在@interface或@implementation标签之外声明,否则你的代码将无法编译。最后,你的枚举名称必须在工作区内的所有其他对象中是唯一的。
Swift
Swift 中的结构体与 C#的结构体比 Objective-C 的结构体有更多的共同之处,这得益于它们的广泛灵活性。在我们的示例中,我们不会添加任何额外的方法或init函数,但如果我们需要,我们可以这样做:
public enum SilverLine : Int
{
case Wiehle_Reston_East = 1000
case Spring_Hill = 1100
case Greensboro = 1200
case Tysons_Corner = 1300
case McClean = 1400
case East_Falls_Church = 2000
case Ballston_MU = 2100
case Virginia_Sq_GMU = 2200
case Clarendon = 2300
case Courthouse = 2400
case Rosslyn = 3000
case Foggy_Bottom_GWU = 3100
case Farragut_West = 3200
case McPherson_Sq = 3300
case Metro_Center = 4000
case Federal_Triangle = 4100
case Smithsonian = 4200
case LEnfant_Plaza = 5000
case Federal_Center_SW = 5100
case Capital_South = 5200
case Eastern_Market = 5300
case Potomac_Ave = 5400
case Stadium_Armory = 6000
case Benning_Road = 6100
case Capital_Heights = 6200
case Addison_Road = 6300
case Morgan_Blvd = 6400
case Largo_Town_Center = 6500
}
注意我们的定义中包含了Int声明。在大多数情况下,这并不是严格必要的,除非我们打算像我们在这里所做的那样明确地为条目设置值。这可以让编译器提前知道预期的类型,以便进行类型检查。如果我们选择省略显式值,我们也可以选择省略Int声明。
摘要
在本章中,你学习了结构体数据结构的基本定义,以及如何在适用语言中创建结构体。我们还考察了一些结构体的常见应用,包括非常常见的枚举数据类型。最后,我们查看了一些之前的代码示例,以检查我们是否可以使用结构体对象而不是自定义类来改进它们。
第九章:树:非线性结构
树结构基本上是节点的集合,通常包括防止对每个节点有多个引用的约束,并规定没有引用指向根节点。这种结构模拟了一个层次化的树状结构,可以根据每个节点中包含的值是有序的还是无序的。此外,节点可以包含值类型或对象的实例,具体取决于树的目的。
树在编程中是极其有用的数据结构,尽管它们的用途可能有些受限。即使一个结构在使用中,你也可能并不总是意识到它们的存在,因为许多其他数据结构都是建立在它们之上的。在本章中,我们将详细检查树数据结构,并在后续章节中检查其他通常以树结构为基础的结构。
在本章中,我们将涵盖以下主题:
-
树数据结构的定义
-
树数据结构与树数据类型
-
与树相关的术语
-
常见操作
-
创建树
-
递归
-
遍历
树数据结构 versus 树数据类型
实际上,既有树数据类型,也有树数据结构,两者相当不同。因此,在我们继续之前,区分树数据结构和树数据类型非常重要。
首先,数据类型只是数据的排列,没有定义如何实现该数据集合的任何定义。另一方面,数据结构精确地关注如何将特定的数据类型详细说明,以创建该类型的可用、具体实现。
在树的情况下,一个树数据类型必须有一个值以及一些关于子节点的概念,其中每个子节点也是一个树。树数据结构是一组节点,这些节点根据树数据类型的模式相互链接。
下面的两个图表显示了两种类型的树:
- 有序树:

- 无序树:

因此,每个节点都是一个树,具有潜在的孩子节点,这些孩子节点也是树。在本章中,我们将关注树数据结构的具体实现。
树术语
树中使用的许多术语和定义都是这些数据结构特有的。因此,在我们检查树数据结构之前,我们需要花时间学习这种语言。
这里有一些最常见和最重要的术语:
-
节点:树中存储的任何对象或值都代表一个节点。在上面的图中,根及其所有子节点和后代都是独立的节点。
-
根节点:根节点是树的基节点。具有讽刺意味的是,这个节点通常在树的图形表示的顶部。请注意,即使根节点没有后代,它本身也代表了一整棵树。
-
父节点:父节点是包含 1...n 个子节点的任何节点。父节点仅对其子节点中的一个而言是父节点。此外,请注意,任何父节点可以有 0...n 个子节点,这取决于与树的结构相关的规则。
-
子节点:任何非根节点都是其他一个(且仅一个)节点的子节点。任何不是其他结构子树的树的根节点是唯一一个不是自身子节点的节点。
-
兄弟节点:兄弟节点,也称为子节点,代表了一个特定父节点的所有子节点集合。例如,参考前面的图,根节点下方的两个节点集合表示兄弟节点。
-
叶节点:任何没有子节点的节点被称为叶节点。
-
边:边是父节点与子节点之间的路径或引用。
-
后代:节点的后代是可以通过从该节点沿边远离根节点到达的所有节点。
-
祖先节点:节点的祖先是从该节点沿边向根节点到达的所有节点。
-
路径:路径被描述为一组节点与其后代之间的边。
-
树的高度:树的高度表示根节点与离根节点最远的叶节点之间的边的数量。
-
深度:节点与根节点之间的边的数量表示节点的深度。因此,根节点的深度为零。
常见操作
树数据结构可以由 1...n 个节点组成,这意味着即使是一个没有父节点或任何子节点的单个节点也被认为是树。因此,许多与树相关的常见操作可以用单个节点或从相同的角度来定义。以下是与树相关最常见的操作列表
-
数据:数据操作与单个节点相关联,并返回该节点中包含的对象或值。
-
子节点:子节点操作返回与该父节点关联的兄弟节点集合。
-
父节点:某些树结构提供了一种机制来“爬升”树,或从任何特定节点遍历结构回到根节点。
-
枚举:枚举操作将返回一个列表或包含特定节点的所有后代的集合,包括根节点本身。
-
插入:插入操作允许将新节点添加到树中现有节点的子节点。当树结构对特定父节点关联的子节点数量有限制时,插入操作可能会变得有些复杂。当允许的最大子节点数量已经就位时,必须将其中一个子节点重新定位为新插入节点的子节点。
-
嫁接:嫁接操作与插入操作类似,但被插入的节点有自己的后代,这意味着它是一个多层树。与插入操作一样,当树结构对特定父节点关联的子节点数量有限制时,嫁接操作可能会变得有些复杂。当允许的最大子节点数量已经就位时,必须将其中一个子节点逻辑上重新定位为新插入的树的叶子的子节点。
-
删除:删除操作将从树中删除指定的节点。如果被删除的节点有后代,这些节点必须以某种方式重新定位到被删除节点的父节点,否则该操作被分类为修剪操作。
-
修剪:修剪操作将从树中删除一个节点及其所有后代。
树的实例化
考虑到树在计算机科学中出现的频率,我们讨论的语言中没有一种简单且通用的具体实现为通用用途的树结构,这有点令人惊讶。因此,我们将创建我们自己的实现。
树结构
在我们开始之前,我们需要详细说明我们的树结构将具有的一些特性。首先,我们将创建一个有序树,因此我们不会允许添加重复值,这将简化我们的实现。此外,我们将限制每个节点有两个子节点。从技术上讲,这意味着我们正在定义一个二叉树结构,但到目前为止,我们将忽略这种结构的特定优点和应用,稍后我们将更详细地研究这个定义。接下来,我们的结构将通过简单地暴露每个节点中包含的底层对象来实现数据和子节点操作。我们不会实现父节点操作,因为我们目前没有需要反向遍历树的需求。
插入操作将作为两个独立的方法实现,支持原始数据和现有节点,而嫁接操作将仅支持现有节点。由于我们决定不允许重复,因此嫁接操作将类似于在集合数据结构内的并集操作,即结果树将只包含来自两个输入树的唯一值。这三个操作中的每一个都将返回布尔值,指示操作是否成功。
删除 操作也将提供两个支持原始数据和现有节点的方法,而 修剪 操作将仅支持现有节点。这三个方法中的每一个都将从树中删除节点并将该节点返回给调用者。这样,删除 和 修剪 操作将类似于队列或栈中的 pop 函数。
我们将需要实现 搜索 操作,这些操作将返回匹配的节点,但不会从树中删除节点。这样,搜索函数将类似于队列或栈中的 peek 函数。
我们的 enumerate 操作将实现为一个递归函数。我们将在稍后更详细地探讨递归,但现在我们只需实现这个方法。最后,我们将实现某种形式的 copy 操作。
C#
C# 提供了足够的功能,使我们能够用惊人的少量代码创建一个多功能的树数据结构。首先,我们需要构建一个表示树节点的类。以下是一个 Node 类的具体实现示例,在 C# 中可能看起来是这样的:
public Int16 Data;
public Node Left;
public Node Right;
一个 Node 代表两个基本组件,包括节点中包含的数据,以及由我们的节点引用的子节点集合。在我们的实现中,我们有一个公共字段用于我们的节点数据,在这种情况下是一个整数。我们还有一个公共字段用于两个子节点,分别称为 Left 和 Right。
public List<Node> Children
{
get
{
List<Node> children = new List<Node>();
if (this.Left != null)
{
children.Add(this.Left);
}
if (this.Right != null)
{
children.Add(this.Right);
}
return children;
}
}
我们添加了一个额外的获取器 Children,它返回一个包含此节点中存在的任何子节点的 List<Node>。这个属性与其说是为了方便,不如说是我们后面将要到来的各种递归函数的一个组成部分。
public Node(Int16 data)
{
this.Data = data;
}
我们的 Node 类定义了一个自定义构造函数,它接受一个类型为 Int 的单个参数。该参数填充我们的 Data 字段,因为它是我们结构中唯一的必需字段,因为子节点始终是可选的。
public bool InsertData(Int16 data)
{
Node node = new Node (data);
return this.InsertNode(node);
}
public bool InsertNode(Node node)
{
if (node == null || node.Data == this.Data)
{
return false;
}
else if (node.Data < this.Data)
{
if (this.Left == null)
{
this.Left = node;
return true;
}
else
{
return this.Left.InsertNode(node);
}
}
else
{
if (this.Right == null)
{
this.Right = node;
return true;
}
else
{
return this.Right.InsertNode(node);
}
}
}
我们的前两种方法支持插入数据和插入节点。InsertData(Int data) 方法提供了我们用于原始节点数据的 插入 功能。因此,此方法在将对象传递给 InsertNode(Node node) 方法之前,会从数据点创建一个新的 Node 对象。
InsertNode(Node node) 方法提供了对现有 Node 对象的 插入 功能。该方法首先检查 node 是否为 null,或者 node 的 Data 值是否与当前节点匹配。如果是这样,我们返回 false,这可以防止重复项被添加到我们的树中。接下来,我们检查值是否小于我们当前节点的数据值。如果是这样,我们首先检查 Left 节点是否存在,如果不存在,我们将新插入的节点分配到那个空位。否则,这个新节点必须插入到 Left 节点下方,因此我们递归调用 InsertNode(Node node) 在 Left 节点上。那个递归调用将再次开始这个过程,确认 Left 不包含此值,依此类推。
如果插入的 Node 的值大于我们当前的节点,整个过程将重复,但开始于 Right 节点。最终,我们或者在树中找到已存在的值,或者在叶子节点找到一个可用的子节点位置,可以接受插入的 Node。这种方法的最坏情况复杂度为 O(log(n))。
使用此方法,理论上我们可以通过单个调用合并整个树。不幸的是,如果当前树中存在的值也是插入节点的后代,则 InsertNode(Node node) 不会阻止重复值进入我们的树。为此功能,需要执行 graft 操作。
public bool Graft(Node node)
{
if (node == null)
{
return false;
}
List<Node> nodes = node.ListTree();
foreach (Node n in nodes)
{
this.InsertNode(n);
}
return true;
}
Graft(Node node) 方法利用现有的 InsertNode(Node node) 方法。该方法首先确认 node 不是 null,如果是,则返回 false。接下来,该方法通过在 node 上调用 ListTree() 来创建一个新的 List<Node> 集合。我们稍后会检查 ListTree(),但在此刻,要知道 ListTree() 将返回一个包含 node 及其所有后代的列表。
public Node RemoveData(Int16 data)
{
Node node = new Node (data);
return this.RemoveNode(node);
}
public Node RemoveNode(Node node)
{
if (node == null)
{
return null;
}
Node retNode;
Node modNode;
List<Node> treeList = new List<Node>();
if (this.Data == node.Data)
{
//Root match
retNode = new Node(this.Data);
modNode = this;
if (this.Children.Count == 0)
{
return this; //Root has no childen
}
}
else if (this.Left.Data == node.Data)
{
retNode = new Node(this.Left.Data);
modNode = this.Left;
}
else if (this.Right.Data == node.Data)
{
retNode = new Node(this.Right.Data);
modNode = this.Right;
}
else
{
foreach (Node child in this.Children)
{
if (child.RemoveNode(node) != null)
{
return child;
}
}
//No match in tree
return null;
}
//Reorder the tree
if (modNode.Left != null)
{
modNode.Data = modNode.Left.Data;
treeList.AddRange(modNode.Left.ListTree());
modNode.Left = null;
}
else if (modNode.Right != null)
{
modNode.Data = modNode.Right.Data;
treeList.AddRange(modNode.Right.ListTree());
modNode.Right = null;
}
else
{
modNode = null;
}
foreach (Node n in treeList)
{
modNode.InsertNode(n);
}
//Finished
return retNode;
}
下两个方法支持删除数据和删除节点。RemoveData(Int data) 方法为我们提供了对原始节点数据的 删除 功能。因此,该方法接受数据点,并从中创建一个新的 Node 对象,然后将该对象传递给 RemoveNode(Node node) 方法。
RemoveNode(Node node) 方法为现有的 Node 对象提供 删除 功能。该方法首先确认 node 不是 null,如果是,则返回 null。否则,该方法设置三个对象,包括 retNode,它表示将要返回的节点;modNode,它表示需要修改以容纳被删除节点的节点;以及 treelist,当删除节点时将用于重新排序树。
接着,该方法分为两个主要部分。第一个部分搜索与 node 参数匹配的内容。第一个 if 块检查当前节点数据是否与节点匹配。如果节点匹配,则使用 this.Data 创建 retNode,并将 modNode 设置为 this。在执行继续之前,该方法检查 this 是否有任何子节点。如果没有,我们就有一个单节点树,因此我们的方法简单地返回 this。这种逻辑防止我们尝试完全删除树,这只能由另一个实例化根 Node 对象的类来完成。接下来的两个 if else 块分别检查节点是否匹配 Left 或 Right。在任何情况下,都使用匹配子节点的 Data 创建 retNode,并将 modNode 设置为匹配的子节点。如果我们仍然找不到匹配项,该方法将递归地对两个子节点分别调用 RemoveNode(Node node)。如果其中任何调用返回一个 Node 对象,则将该对象返回给调用者。如果所有其他方法都失败,我们的方法返回 null,意味着没有找到与 node 匹配的内容。
注意
由于算法的编写方式,第一个 if 块的内容只能在检查树的根时执行。那是因为,当我们开始递归调用子节点的方法时,我们已经知道它们的 Data 值与 node 的不匹配。从这一点开始,我们的方法始终在寻找匹配的后代。在递归方面,我们将第一个 if 语句称为我们算法的 基本案例。我们将在本章的后面部分更详细地探讨递归。
RemoveNode(Node node) 的第二个组件重新排序剩余的节点,以便在删除节点过程中不会丢失排序。该组件首先检查 Left 是否不是 null,这意味着此节点左侧有一个节点分支。如果 Left 竟然是 null,则接下来检查 Right。如果 Left 和 Right 都为 null,那么我们很容易完成,因为这个节点是一个没有后代的叶子节点,不需要重新排序。
如果 Left 或 Right 有对象,则需要处理后代。在任何情况下,代码块都会将子节点的 Data 值移动到 modNode.Data,如果您还记得,这就是我们实际上想要删除的节点。通过这种方式移动数据,我们同时删除了节点并将其子 Data 上移以取代其位置。随后,我们的方法通过在子节点上调用 ListTree() 创建一个 List<Node> 集合。此操作返回子节点及其所有后代。然后,代码块通过将子节点设置为 null 来完成,从而有效地删除了整个分支。
最后,该方法遍历 treeList 集合,并使用列表中的每个 Node 调用 InsertNode(Node node)。这种方法确保我们的子节点在最终树中的数据值不会重复,并且最终树在操作完成之前将正确排序。
尽管许多算法可以执行此重新排序,也许其中一些比这个更有效,但到目前为止,我们只需要确保我们的最终树结构仍然包含每个节点(除了被删除的节点)并且是正确排序的。换句话说,RemoveNode(Node node) 方法有一个 痛苦地 高复杂度成本为 O(n²)。
public Node Prune(Node root)
{
Node matchNode;
if (this.Data == root.Data)
{
//Root match
Node b = this.CopyTree();
this.Left = null;
this.Right = null;
return b;
}
else if (this.Left.Data == root.Data)
{
matchNode = this.Left;
}
else if (this.Right.Data == root.Data)
{
matchNode = this.Right;
}
else
{
foreach (Node child in this.Children)
{
if (child.Prune(root) != null)
{
return child;
}
}
//No match in tree
return null;
}
Node branch = matchNode.CopyTree();
matchNode = null;
return branch;
}
Prune(Node root) 方法的工作方式与 RemoveNode(Node node) 类似。我们首先确认 root 不是 null,如果是,则返回 null。接下来,我们建立基本案例,并在 this 中寻找匹配项。如果根节点匹配,该方法会创建整个树的副本,命名为 b,然后将 Left 和 Right 设置为 null 以删除根的所有后代,然后返回 b。与 RemoveNode(Node node) 类似,这种逻辑防止我们尝试完全删除树,这只能由另一个实例化根 Node 对象的类来完成。
如果根节点不匹配root,我们的方法将检查Left和Right,最后递归检查Children。如果所有其他方法都失败了,我们仍然返回null,表示找不到匹配项。
如果在Left或Right中找到匹配项,matchNode将被设置为匹配的节点,该节点随后被复制到Node branch。最后,matchNode被设置为null,这将从树中删除节点及其后代,并最终返回分支。该方法的最坏情况复杂度为O(n)。
public Node FindData(Int16 data)
{
Node node = new Node (data);
return this.FindNode(node);
}
public Node FindNode(Node node)
{
if (node.Data == this.Data)
{
return this;
}
foreach (Node child in this.Children)
{
Node result = child.FindNode(node);
if (result != null)
{
return result;
}
}
return false;
}
我们的Node类使用FindData(Int data)和FindNode(Node node)方法实现搜索功能。FindData(Int data)允许我们传入一个原始的Int值,这会创建一个新的Node对象,并将其传递给FindNode(Node node)。
FindNode(Node node)方法反过来检查搜索节点数据是否与当前节点数据匹配。如果是这样,我们返回true,因为我们找到了匹配项。否则,该方法将递归地对Children集合中的每个节点调用FindNode(Node node),直到找到匹配项,或者我们到达树的末尾。在这种情况下,我们返回false,表示数据在树中不存在。该方法的最坏情况复杂度为O(log(n))。
public Node CopyTree()
{
Node n = new Node (this.Data);
if (this.Left != null)
{
n.Left = this.Left.CopyTree();
}
if(this.Right != null)
{
n.Right = this.Right.CopyTree();
}
return n;
}
CopyTree()方法复制当前节点,然后使用递归方法调用将Left和Right设置为该副本。当方法返回复制的节点时,该副本表示整个树、分支或节点的完整副本。
public List<Node> ListTree()
{
List<Node> result = new List<Node>();
result.Add(new Node(this.Data());
foreach (Node child in this.Children)
{
result.AddRange(child.ListTree());
}
return result;
}
最后,我们来到由ListTree()方法提供的枚举功能。此方法简单地创建一个新的List<Node>集合,根据this中的Data添加一个新的Node到集合中,然后递归地对Children集合中的每个节点调用ListTree(),直到我们收集到树中的每个节点。最后,该方法将result返回给调用者。
注意
这个简单的类代表我们树中的每个节点。然而,你可能想知道为什么节点类实现了整个树数据结构的所有功能。如果你还记得关于术语的讨论,没有任何后代的根节点代表一个完整的树。这意味着任何节点的定义都必须必然提供整个树的所有功能,本身就是一个完整的树。任何随后的树结构实现都将使用单个Node对象作为其核心。这个节点将有子节点,这些子节点反过来也会有子节点,依此类推,从而在单个字段中封装整个树结构。
Java
Java 还提供了构建我们Node类健壮实现所需的基本工具,而且几乎不需要付出太多努力。以下是一个该实现可能的样子示例:
public int Data;
public Node left;
public Node right;
public List<Node> getChildren()
{
List<Node> children = new LinkedList<Node>();
if (this.Left != null)
{
children.add(this.Left);
}
if (this.Right != null)
{
children.add(this.Right);
}
return children;
}
与 C# 类似,我们的 Java Node 类包括一个用于节点数据的公共字段,以及两个子节点 Left 和 Right 的公共字段。我们的 Java Node 类同样包括一个名为 getChildren() 的公共方法,该方法返回一个包含此节点中存在的任何子节点的 LinkedList<Node>。
public Node(int data)
{
this.Data = data;
}
我们的 Node 类定义了一个自定义构造函数,它接受一个类型为 int 的单个参数,用于填充 Data 字段。
public boolean insertData(int data)
{
Node node = new Node (data);
return this.insertNode(node);
}
public boolean insertNode(Node node)
{
if (node == null || node.Data == this.Data)
{
return false;
}
else if (node.Data < this.Data)
{
if (this.Left == null)
{
this.Left = node;
return true;
}
else
{
return this.Left.insertNode(node);
}
}
else
{
if (this.Right == null)
{
this.Right = node;
return true;
}
else
{
return this.Right.insertNode(node);
}
}
}
我们的前两个方法支持插入数据和插入节点。insertData(int data) 方法为我们提供了对原始节点数据的 插入 功能。因此,此方法接受数据点,并从它创建一个新的 Node 对象,然后再将此对象传递给 insertNode(Node node) 方法。
insertNode(Node node) 方法为现有的 Node 对象提供了 插入 功能。该方法首先检查 node 是否为 null,或者 node 的 Data 值是否与当前节点匹配。如果是这样,我们返回 false,这可以防止重复项被添加到我们的树中。接下来,我们检查值是否小于我们当前节点的数据值。如果是这样,我们首先检查 Left 节点是否存在,如果不存在,我们将新插入的节点分配到那个空位。否则,这个新节点必须插入到 Left 节点下方某个位置,因此我们在 Left 节点上递归调用 insertNode(Node node)。那个递归调用将重新开始这个过程,确认 Left 不包含这个值,等等。
如果插入的 Node 的值大于我们当前节点,整个过程将重复使用 Right 节点。最终,我们将确定值已经在我们的树中存在,或者我们找到一个有可用子位置可以接受插入的 Node 的叶子节点。此方法的最坏情况复杂度为 O(log(n))。
public boolean graft(Node node)
{
if (node == null)
{
return false;
}
List<Node> nodes = node.listTree();
for (Node n : nodes)
{
this.insertNode(n);
}
return true;
}
graft(Node node) 方法利用现有的 insertNode(Node node)。该方法首先确认 node 不是 null,如果是,则返回 false。接下来,该方法通过在 node 上调用 listTree() 创建一个新的 List<Node> 集合,该方法返回一个包含 node 和其所有后代的列表。
public Node removeData(int data)
{
Node node = new Node(data);
return this.removeNode(node);
}
public Node removeNode(Node node)
{
if (node == null)
{
return null;
}
Node retNode;
Node modNode;
List<Node> treeList = new LinkedList<Node>();
if (this.Data == node.Data)
{
//Root match
retNode = new Node(this.Data);
modNode = this;
if (this.getChildren().size() == 0)
{
return this; //Root has no childen
}
}
else if (this.Left.Data == node.Data)
{
//Match found
retNode = new Node(this.Left.Data);
modNode = this.Left;
}
else if (this.Right.Data == node.Data)
{
//Match found
retNode = new Node(this.Right.Data);
modNode = this.Right;
}
else
{
for (Node child : this.getChildren())
{
if (child.removeNode(node) != null)
{
return child;
}
}
//No match in tree
return null;
}
//Reorder the tree
if (modNode.Left != null)
{
modNode.Data = modNode.Left.Data;
treeList.addAll(modNode.Left.listTree());
modNode.Left = null;
}
else if (modNode.Right != null)
{
modNode.Data = modNode.Right.Data;
treeList.addAll(modNode.Right.listTree());
modNode.Right = null;
}
else
{
modNode = null;
}
for (Node n : treeList)
{
modNode.insertNode(n);
}
//Finished
return retNode;
}
接下来的两个方法支持删除数据和删除节点。removeData(int data) 方法为我们提供了对原始节点数据的 删除 功能。因此,此方法接受数据点,并从它创建一个新的 Node 对象,然后再将此对象传递给 removeNode(Node node) 方法。
removeNode(Node node) 方法为现有的 Node 对象提供了 删除 功能。该方法首先确认 node 不是 null,如果是,则返回 null。否则,该方法设置三个对象,包括 retNode,它表示将要返回的节点;modNode,它表示将被修改以适应被删除节点的节点;以及 treelist,在删除节点时将用于重新排序树。
下一个块首先搜索与node参数匹配的项。第一个if块检查当前节点是否匹配该节点。如果节点匹配,则使用this.Data创建retNode,并将modNode设置为this。在执行继续之前,方法会检查this是否有任何子节点。如果没有,我们有一个单节点树,因此我们的方法只需返回this。接下来的两个if else块检查节点是否匹配Left或Right,分别。在这两种情况下,使用匹配子节点的数据创建retNode,并将modNode设置为匹配的子节点。如果我们仍然找不到匹配项,方法会递归地对两个子节点中的每个调用removeNode(Node node)。如果其中任何调用返回一个Node对象,则将该对象返回给调用者。如果所有其他方法都失败了,我们的方法返回null,意味着在我们的树中没有与node匹配的节点。
removeNode(Node node)的第二块代码重新排列剩余的节点,以便在删除节点的过程中不会丢失排序。此组件首先检查Left是否不是null,这意味着此节点左侧有一个节点分支。如果Left恰好是null,则接下来检查Right。如果Left和Right都是null,则完成。
如果Left或Right中的任何一个不是null,该方法会将Data值从子节点移动并分配给modNode.Data。随后,我们的方法通过在子节点上调用listTree()来创建一个List<Node>集合。然后,通过将子节点设置为null来结束这个块,从而有效地删除整个分支。
最后,该方法遍历treeList集合,并对列表中的每个Node调用insertNode(Node node)。RemoveNode(Node node)方法的开销为O(n²)。
public Node prune(Node root)
{
if (root == null)
{
return null;
}
Node matchNode;
if (this.Data == root.Data)
{
//Root match
Node b = this.copyTree();
this.Left = null;
this.Right = null;
return b;
}
else if (this.Left.Data == root.Data)
{
matchNode = this.Left;
}
else if (this.Right.Data == root.Data)
{
matchNode = this.Right;
}
else
{
for (Node child : this.getChildren())
{
if (child.prune(root) != null)
{
return child;
}
}
//No match in tree
return null;
}
Node branch = matchNode.copyTree();
matchNode = null;
return branch;
}
prune(Node root)方法与removeNode(Node node)类似。我们首先确认root不是null,如果是,则返回null。接下来,我们建立我们的基本情况,并在this中寻找匹配项。如果我们的根节点匹配,该方法会创建整个树的副本,命名为b,然后设置Left和Right为null以删除根节点的所有后代,然后返回b。
如果根节点与root不匹配,我们的方法会检查Left和Right,最后递归检查Children。如果所有其他方法都失败了,我们返回null,因为我们的树中没有与root匹配的节点。
如果在Left或Right中找到匹配项,matchNode会被设置为匹配的节点,然后该节点稍后会被复制到Node branch。最后,matchNode被设置为null,这会从树和分支中删除该节点及其后代,并最终返回分支。此方法的开销为O(n)。
public Node findData(int data)
{
Node node = new Node (data);
return this.findNode(node);
}
public Node findNode(Node node)
{
if (node.Data == this.Data)
{
return this;
}
for (Node child : this.getChildren())
{
Node result = child.findNode(node);
if (result != null)
{
return result;
}
}
return null;
}
我们的Node类使用findData(Int data)和findNode(Node node)方法实现搜索功能。findData(Int data)允许我们传入一个原始的int值,这会创建一个新的Node对象并将其传递给findNode(Node node)。
findNode(Node node) 方法接着检查搜索节点数据是否与当前节点的数据匹配。如果是,我们返回 true,因为我们找到了匹配项。否则,该方法将递归地对 Children 集合中的每个节点调用 findNode(Node node),直到找到匹配项,或者我们到达树的末尾。在这种情况下,我们返回 false,表示数据不在树中。此方法的开销为 O(log(n)):
public Node copyTree()
{
Node n = new Node(this.Data);
if (this.Left != null)
{
n.Left = this.Left.copyTree();
}
if(this.Right != null)
{
n.Right = this.Right.copyTree();
}
return n;
}
copyTree() 方法首先复制当前节点,然后使用递归方法调用将 Left 和 Right 设置为相同的副本。当方法返回复制的节点时,该副本表示整个树、分支或节点的完整副本。
public List<Node> listTree() {
List<Node> result = new LinkedList<Node>();
result.add(new Node(this.Data));
for (Node child : this.getChildren())
{
result.addAll(child.listTree());
}
return result;
}
最后,我们来到由 listTree() 方法提供的 枚举 功能。此方法简单地创建一个新的 LinkedList<Node> 集合,根据 this 中的 Data 添加一个新的 Node 到集合中,然后递归地对 Children 集合中的每个节点调用 listTree(),直到我们收集到树中的每个节点。最后,方法将 result 返回给调用者。
Objective-C
与 Objective-C 中的其他数据结构实现一样,我们必须稍微跳出思维定式来构建我们的节点类。在某些方面,Objective-C 使得我们的工作变得更简单,但并不总是如此。以下是一个 Node 实现可能的样子,在 Objective-C 中:
-(instancetype)initNodeWithData:(NSInteger)data
{
if (self = [super init])
{
_data = data;
}
return self;
}
我们的 EDSNode 类定义了一个初始化器,它接受一个类型为 NSInetger 的单个参数。该参数填充我们的 _data 字段,因为它是我们结构中唯一的必需字段,因为子节点始终是可选的。
-(NSInteger)data
{
return _data;
}
-(EDSNode*)left
{
return _left;
}
-(EDSNode*)right
{
return _right;
}
-(NSArray*)children
{
return [NSArray arrayWithObjects:_left, _right, nil];
}
EDSNode 节点有三个公共属性用于数据,以及两个子节点 left 和 right,还有一个名为 children 的数组属性,表示子节点集合:
-(BOOL)insertData:(NSInteger)data
{
EDSNode *node = [[EDSNode alloc] initNodeWithData:data];
return [self insertNode:node];
}
-(BOOL)insertNode:(EDSNode*)node
{
if (!node || [self findNode:node])
{
return NO;
}
else if (node.data < _data)
{
if (!_left)
{
_left = node;
return YES;
}
else
{
return [_left insertNode:node];
}
}
else
{
if (!_right)
{
_right = node;
return YES;
}
else
{
return [_right insertNode:node];
}
}
}
我们的前两种方法支持插入数据和插入节点。insertData: 方法提供了我们用于原始节点数据的 插入 功能。因此,此方法在将对象传递给 insertNode: 方法之前,会从数据点创建一个新的 EDSNode 对象。
insertNode: 为现有的 EDSNode 对象提供 插入 功能。该方法首先检查 node 是否为 nil,或者 node 的 data 值是否与当前节点的 data 值匹配。如果是,我们返回 NO。接下来,我们检查 data 的值是否小于我们当前节点的 data 值。如果是,我们首先检查 left 节点是否存在,如果不存在,我们将新插入的节点分配到该可用位置。否则,这个新节点必须插入到 left 节点下方,因此我们递归地对 left 节点调用 insertNode:。如果插入的 EDSNode 的值大于我们当前节点,整个过程将重复使用 right 节点。最终,我们或者在树中确认值已经存在,或者找到一个有可用子位置可以接受插入的 EDSNode 的叶子节点。此方法的最坏情况复杂度为 O(log(n)):
-(BOOL)graft:(EDSNode*)node
{
if (!node)
{
return NO;
}
NSArray *nodes = [node listTree];
for (EDSNode *n in nodes)
{
[self insertNode:n];
}
return true;
}
graft: 方法利用现有的 insertNode: 方法。该方法首先确认 node 不是 nil,如果是,则返回 false。接下来,该方法通过在 node 上调用 listTree 来创建一个新的 NSArray 集合。我们将在稍后更详细地检查 listTree 方法,但在此刻只需知道该方法将返回一个包含节点对象及其所有后代的列表。
-(EDSNode*)removeData:(NSInteger)data
{
EDSNode *node = [[EDSNode alloc] initNodeWithData:data];
return [self removeNode:node];
}
-(EDSNode*)removeNode:(EDSNode*)node
{
if (!node)
{
return NO;
}
EDSNode *retNode;
EDSNode *modNode;
NSMutableArray *treeList = [NSMutableArray array];
if (self.data == node.data)
{
//Root match
retNode = [[EDSNode alloc] initNodeWithData:self.data];
modNode = self;
if ([self.children count] == 0)
{
return self; //Root has no childen
}
}
else if (_left.data == node.data)
{
//Match found
retNode = [[EDSNode alloc] initNodeWithData:_left.data];
modNode = _left;
}
else if (_right.data == node.data)
{
//Match found
retNode = [[EDSNode alloc] initNodeWithData:_right.data];
modNode = _right;
}
else
{
for (EDSNode *child in self.children)
{
if ([child removeNode:node])
{
return child;
}
}
//No match in tree
return nil;
}
//Reorder the tree
if (modNode.left)
{
modNode.data = modNode.left.data;
[treeList addObjectsFromArray:[modNode.left listTree]];
modNode.left = nil;
}
else if (modNode.right)
{
modNode.data = modNode.right.data;
[treeList addObjectsFromArray:[modNode.right listTree]];
modNode.right = nil;
}
else
{
modNode = nil;
}
for (EDSNode *n in treeList)
{
[modNode insertNode:n];
}
//Finished
return retNode;
}
接下来的两个方法支持删除数据和删除节点。removeData: 方法为我们提供了原始节点数据的 删除 功能。因此,该方法接受数据点并从中创建一个新的 EDSNode 对象,然后将该对象传递给 removeNode: 方法。removeNode: 方法提供了对现有 Node 对象的 删除 功能。该方法首先确认 node 不是 nil,如果是,则返回 nil。否则,该方法设置三个对象,包括 retNode,它表示将要返回的节点;modNode,它表示需要修改以适应被删除节点的节点;以及 treelist,当删除节点时将用于重新排列树。
然后,该方法分为两个主要组件。第一个组件搜索与 node 参数匹配的对象。第一个 if 块检查 self.data 是否与 node.data 匹配。如果节点匹配,则使用 this.data 创建 retNode,并将 modNode 设置为 this。在执行继续之前,该方法检查 this 是否有任何子节点。如果没有,我们有一个单个节点树,因此我们的方法简单地返回 this。这个逻辑阻止我们尝试完全删除树,这只能由另一个实例化根 EDSNode 对象的类来完成。接下来的两个 if else 块检查节点是否与 left 或 right 匹配,分别。在任何情况下,都使用匹配子节点的 data 创建 retNode,并将 modNode 设置为匹配的子节点。如果我们仍然找不到匹配项,则方法递归地对两个子节点中的每个调用 removeNode:。如果其中任何调用返回 Node 对象,则将该对象返回给调用者。当所有其他方法都失败时,我们的方法返回 nil,这意味着没有找到与 node 匹配的对象。
removeNode: 方法的后半部分重新排列剩余的节点,以便在移除节点过程中不会丢失排序。这个组件首先检查 left 是否不是 nil,这意味着在这个节点左侧有一个节点的分支。如果 left 竟然是 nil,则接下来检查 Right。如果 left 和 right 都不是 nil,则完成。
如果 left 或 right 有对象,我们的代码会将 data 从子节点移动并分配给 modNode.data。在此之后,我们的方法通过在子节点上调用 listTree 创建一个 NSArray。然后,方法将子节点设置为 nil,实际上删除了整个分支。最后,方法遍历 treeList 集合,并对列表中的每个 EDSNode 调用 insertNode:。removeNode: 方法的成本为 O(n²):
-(EDSNode*)prune:(EDSNode*)root
{
if (!root)
{
return nil;
}
EDSNode *matchNode;
if (self.data == root.data)
{
//Root match
EDSNode *b = [self copyTree];
self.left = nil;
self.right = nil;
return b;
}
else if (self.left.data == root.data)
{
matchNode = self.left;
}
else if (self.right.data == root.data)
{
matchNode = self.right;
}
else
{
for (EDSNode *child in self.children)
{
if ([child prune:root])
{
return child;
}
}
//No match in tree
return nil;
}
EDSNode *branch = [matchNode copyTree];
matchNode = nil;
return branch;
}
prune: 方法首先确认 root 不是 nil,如果是,则返回 nil。接下来,我们建立基本案例并在 this 中寻找匹配项。如果根节点匹配,该方法创建整个树的副本,命名为 b,然后设置 left 和 right 为 nil 以删除根节点的所有后代,然后返回 b。如果根节点不匹配 root,我们的方法会检查 left 和 right,最后递归检查 children。如果所有其他方法都失败,我们仍然返回 nil 表示找不到匹配项。
如果在 left 或 right 中找到匹配项,matchNode 被设置为匹配的节点,然后该节点稍后被复制到 EDSNode branch。最后,matchNode 被设置为 nil,这将从树中删除节点及其后代,并最终返回分支。该方法的最坏情况复杂度为 O(n):
-(EDSNode*)findData:(NSInteger)data
{
EDSNode *node = [[EDSNode alloc] initNodeWithData:data];
return [self findNode:node];
}
-(EDSNode*)findNode:(EDSNode*)node
{
if (node.data == self.data)
{
return self;
}
for (EDSNode *child in self.children)
{
EDSNode *result = [child findNode:node];
if (result)
{
return result;
}
}
return nil;
}
我们的 EDSNode 类使用 findData: 和 findNode: 方法实现 搜索 功能。findData: 允许我们传入一个原始的 NSInteger 值,这会创建一个新的 EDSNode 对象并将其传递给 findNode:。
findNode: 方法会依次检查搜索节点数据是否与当前节点的数据匹配。如果是,我们返回 YES 因为找到了匹配项。否则,该方法会递归地在 children 集合中的每个节点上调用 findNode:,直到找到匹配项或达到树的末尾。在这种情况下,我们返回 NO 表示数据在树中不存在。该方法的最坏情况复杂度为 O(log(n)):
-(EDSNode*)copyTree
{
EDSNode *n = [[EDSNode alloc] initNodeWithData:self.data];
if (self.left)
{
n.left = [self.left copyTree];
}
if(self.right)
{
n.right = [self.right copyTree];
}
return n;
}
copyTree 方法首先复制当前节点,然后使用递归方法调用将 left 和 right 设置为该节点的副本。当方法返回复制的节点时,副本表示整个树、分支或节点的完整副本:
-(NSArray*)listTree
{
NSMutableArray *result = [NSMutableArray array];
[result addObject:[[EDSNode alloc] initNodeWithData:self.data]];
for (EDSNode *child in self.children) {
[result addObjectsFromArray:[child listTree]];
}
return [result copy];
}
最后,我们来到 枚举 功能,这是由 listTree: 方法提供的。该方法简单地创建一个新的 NSArray 集合,将基于 this 中的 data 的新 EDSNode 添加到集合中,然后递归地在 children 集合中的每个节点上调用 listTree,直到收集到树中的每个节点。最后,方法将 result 返回给调用者。
Swift
我们的 Swift Node 类在结构和功能上与 C# 和 Java 实现类似。以下是一个 Swift 中 Node 类的示例:
public var data: Int
public var left: Node?
public var right: Node?
public var children: Array<Node> {
return [left!, right!]
}
我们的 Swift Node 有三个公共属性用于数据,以及两个子节点 left 和 right,还有一个名为 children 的数组属性,表示子节点集合:
public init (nodeData: Int)
{
data = nodeData
}
我们的 EDSNode 类定义了一个初始化器,它接受一个类型为 NSInetger 的单个参数。该参数填充我们的 _data 字段,因为它是我们结构中唯一的必填字段,因为子节点始终是可选的:
public func insertData(data: Int) -> Bool
{
return insertNode(node: Node(nodeData:data))
}
public func insertNode(node: Node?) -> Bool
{
if (node == nil)
{
return false
}
if ((findNode(node: node!)) != nil)
{
return false
}
else if (node!.data < data)
{
if (left == nil)
{
left = node
return true
}
else
{
return left!.insertNode(node: node)
}
}
else
{
if (right == node)
{
right = node
return true
}
else
{
return right!.insertNode(node: node)
}
}
}
我们的前两个方法支持插入数据和插入节点。insertData: 方法为我们提供了原始节点数据的 插入 功能。因此,此方法接收数据点,并从它创建一个新的 EDSNode 对象,然后再将其传递给 insertNode: 方法。
insertNode: 方法为现有的 EDSNode 对象提供了 插入 功能。该方法首先检查 node 是否为 nil,或者 node 的 data 值与当前节点匹配。如果是,我们返回 NO。接下来我们检查 data 的值是否小于我们当前节点的 data 值。如果是,我们首先检查 left 节点是否存在,如果不存在,我们将新插入的节点分配到那个可用位置。否则,这个新节点必须插入到 left 节点下方,因此我们递归调用 insertNode: 在 left 节点上。如果插入的 EDSNode 的值大于我们当前节点的值,整个过程将重复进行 right 节点。最终,我们或者在树中确认值已经存在,或者在叶节点找到一个可用的子节点位置,该位置可以接受插入的 EDSNode。此方法的最坏情况复杂度为 O(log(n)):
public func graft(node: Node?) -> Bool
{
if (node == nil)
{
return false
}
let nodes: Array = node!.listTree()
for n in nodes
{
self.insertNode(node: n)
}
return true
}
graft: 方法利用现有的 insertNode: 方法。该方法首先确认 node 不是 nil,如果是,则返回 false。接下来,该方法通过在 node 上调用 listTree 创建一个新的 NSArray 集合。我们稍后将检查 listTree,但在此刻我们知道 listTree 将返回一个包含 node 和其所有后代列表:
public func removeData(data: Int) -> Node?
{
return removeNode(node: Node(nodeData:data))
}
public func removeNode(node: Node?) -> Node?
{
if (node == nil)
{
return nil
}
var retNode: Node
var modNode: Node?
var treeList = Array<Node>()
if (self == node!)
{
//Root match
retNode = Node(nodeData: self.data)
modNode = self
if (children.count == 0)
{
return self //Root has no childen
}
}
else if (left! == node!)
{
//Match found
retNode = Node(nodeData: left!.data)
modNode = left!
}
else if (right! == node!)
{
//Match found
retNode = Node(nodeData: right!.data)
modNode = right!
}
else
{
for child in self.children
{
if (child.removeNode(node: node) != nil)
{
return child
}
}
//No match in tree
return nil
}
//Reorder the tree
if ((modNode!.left) != nil)
{
modNode! = modNode!.left!
treeList = modNode!.left!.listTree()
modNode!.left = nil
}
else if ((modNode!.right) != nil)
{
modNode! = modNode!.right!
treeList = modNode!.right!.listTree()
modNode!.right = nil
}
else
{
modNode = nil
}
for n in treeList
{
modNode!.insertNode(node: n)
}
//Finished
return retNode
}
接下来的两个方法支持删除数据和删除节点。removeData: 方法为我们提供了原始节点数据的 删除 功能。因此,此方法接收数据点,并从它创建一个新的 EDSNode 对象,然后再将其传递给 removeNode: 方法。
removeNode: 方法为现有的 Node 对象提供了 删除 功能。该方法首先确认 node 不是 nil,如果是,则返回 nil。否则,该方法设置三个对象,包括 retNode,它表示将要返回的节点;modNode,它表示将被修改以容纳被删除节点的节点;treelist,当删除节点时将用于重新排序树。
在此之后,该方法被分解为两个主要组件。第一个组件搜索与node参数匹配的内容。第一个if块检查self.data是否与node.data匹配。如果节点匹配,则使用this.data创建retNode,并将modNode设置为this。在执行继续之前,该方法检查this是否有任何子节点。如果没有,我们就有一个单独的节点树,因此我们的方法简单地返回this。这个逻辑阻止我们尝试完全消除树,这只能由另一个实例化根EDSNode对象的类来完成。接下来的两个if else块分别检查节点是否匹配left或right。在任何情况下,都会使用匹配子节点的data创建retNode,并将modNode设置为匹配的子节点。如果我们仍然找不到匹配项,该方法将递归地对两个子节点分别调用removeNode:。如果其中任何调用返回Node对象,则将该对象返回给调用者。如果所有其他方法都失败,我们的方法返回nil,表示没有找到与node匹配的内容。removeNode:方法的第二部分重新排列剩余的节点,以便在删除节点过程中不会丢失排序。这个组件首先检查left是否不是nil,这意味着在这个节点左侧有一个节点分支。如果left恰好是nil,则接下来检查right。如果left和right都是nil,则完成。
如果left或right中有一个对象,我们的代码将子节点的data移动到modNode.data。在此之后,我们的方法通过在子节点上调用listTree来创建一个NSArray。然后,方法将子节点设置为nil,实际上删除了整个分支。最后,方法遍历treeList集合,并使用列表中的每个EDSNode调用insertNode:。removeNode:方法的成本为O(n²):
public func prune(root: Node?) -> Node?
{
if (root == nil)
{
return nil
}
var matchNode: Node?
if (self == root!)
{
//Root match
let b = self.copyTree()
self.left = nil
self.right = nil
return b
}
else if (self.left! == root!)
{
matchNode = self.left!
}
else if (self.right! == root!)
{
matchNode = self.right!
}
else
{
for child in self.children
{
if (child.prune(root: root!) != nil)
{
return child
}
}
//No match in tree
return nil;
}
let branch = matchNode!.copyTree()
matchNode = nil
return branch
}
prune:方法首先确认root不是nil,如果是,则返回nil。接下来,我们建立基本案例并在this中寻找匹配项。如果根节点与root匹配,则方法创建整个树的副本,命名为b,然后将left和right设置为nil以删除根的所有后代,然后返回b。如果根节点不匹配root,则方法检查left和right,最后递归检查children。如果所有其他方法都失败,我们仍然返回nil,表示找不到匹配项。
如果在left或right中找到匹配项,则将matchNode设置为匹配的节点,并将该节点稍后复制到EDSNode branch。最后,将matchNode设置为nil,这将删除树中的节点及其后代,并最终返回分支。该方法的最坏情况复杂度为O(n):
public func findData(data: Int) -> Node?
{
return findNode(node: Node(nodeData:data))
}
public func findNode(node: Node) -> Node?
{
if (node == self)
{
return self
}
for child in children
{
let result = child.findNode(node: node)
if (result != nil)
{
return result
}
}
return nil
}
我们的EDSNode类使用findData:和findNode:方法实现了搜索功能。findData:允许我们传入一个原始的NSInteger值,这会创建一个新的EDSNode对象并将其传递给findNode:。findNode:方法随后检查搜索节点数据是否与当前节点的数据匹配。如果是这样,我们返回YES,因为我们找到了匹配项。否则,该方法递归地对children集合中的每个节点调用findNode:,直到找到匹配项,或者我们到达树的末尾。在这种情况下,我们返回NO,表示数据在树中不存在。这个方法的最坏情况复杂度为O(log(n))。
public func copyTree() -> Node
{
let n = Node(nodeData: self.data)
if (self.left != nil)
{
n.left = self.left!.copyTree()
}
if(self.right != nil)
{
n.right = self.right!.copyTree()
}
return n
}
copyTree方法通过递归方法调用复制当前节点,然后将left和right设置为这个节点的副本。当方法返回复制的节点时,这个副本代表整个树、分支或节点的完整副本:
public func listTree() -> Array<Node>
{
var result = Array<Node>()
result.append(self)
for child in children
{
result.append(contentsOf: child.listTree())
}
return result
}
我们的 Swift Node类通过listTree:方法实现了枚举功能。这个方法简单地创建一个新的NSArray集合,将基于this中的data的新EDSNode添加到集合中,然后递归地对children集合中的每个节点调用listTree,直到我们收集到树中的每个节点。最后,该方法将result返回给调用者:
public func == (lhs: Node, rhs: Node) -> Bool {
return (lhs.data == rhs.data)
}
最后,由于我们的类实现了Equatable协议,我们需要针对Node重写==运算符。这个方法允许我们通过简单地比较节点本身来比较我们的Node对象的数据标签;这使得我们的代码更加简洁易读。
递归
尽管许多程序员甚至计算机科学学生都难以理解递归,但这个概念实际上非常简单。简单地说,递归是通过让该操作的函数调用自身来重复执行相同的操作。因此,任何调用自身实例的函数都是递归函数。实际上,如果一个函数f()调用另一个函数g(),而g()又可能再次调用函数f(),这仍然是一个递归函数,因为f()最终会调用自身。递归是解决复杂问题的优秀工具,其中问题的解决方案基于相同问题的较小示例的解决方案。
递归的概念或递归函数非常强大,以至于几乎每种现代计算机语言都通过提供方法自我调用的能力来支持它。然而,在你定义递归函数之前,你应该意识到任何调用自身的函数很容易变成一个会导致应用程序崩溃的无限循环。如果没有任何方法可以使递归函数停止,那么任何递归函数都是无用的。为了避免这种情况,你的算法必须定义一个基本情况,或者一个标记处理结束的值,允许递归函数返回。让我们看看经典的递归示例,斐波那契数列。
斐波那契数列是一系列整数,其中列表中的每个整数都是前两个整数的和。这个定义可以很容易地转换为算法 x[n] = x[n-1] + x[n-2],其中 n 是列表中任何整数的值。例如,对于整数列表 [1, 1, 2, 3, 5, 8, 13, 21, 34, ..., x[i]],其中 x[n] = 8, x[n-1] = 5 和 x[n-2] = 3,所以 5 + 3 = 8。同样,当 n = 21 时,x[n-1] = 13 和 x[n-2] = 8,所以 13 + 8 = 21。这种模式在整个整数列表中是一致的,其中 n > 2。
因此,我们有一个可重复的模式,其中 n > 2,但如果 n = 2 呢?在这种情况下,x[n] = 1, x[n-1] = 1,而 x[n-2] = undefined,这意味着我们的算法会崩溃。在 n = 1 时,我们会遇到类似的问题。因此,我们需要为 n = 1 和 n = 2 定义基本案例,或者 x[1] 和 x[2]。在斐波那契数列中,x[1] = 1 和 x[2] = 1。如果我们使用这些基本案例值,我们可以创建一个递归函数来返回任何 n 值的斐波那契整数列表。在这个方法中,我们将为 n = 0 和 n = 1 定义两个基本案例值,但当 n > 1 时,我们的方法会调用自身并返回值。以下是一个 C# 的示例:
public static int Fibonacci(int n)
{
if (n == 0) return 0; //Base case
if (n == 1) return 1; //Base case
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
递归是你应该拥有的一个很好的工具,但不要滥用它!我的经验是,许多程序员分为两类。一方面,有些程序员要么没有完全理解递归,要么选择永远不使用它。另一方面,有些程序员试图用它来解决每一个问题,如果你在 LISP 语言中编程,这是可以原谅的。
事实上,你应该在适当的地方使用递归,而且它感觉自然时才使用。当你试图解决一个递归适合的问题时,你很可能会本能地识别出来。你将能够区分问题的递归性质,或者无论你多么努力,你都无法开发出一个可以处理所有基本情况的迭代解决方案。
对于递归函数来说,可读性是一个需要考虑的最终因素。在编写函数时,请记住其他程序员将不得不阅读你的工作。如果你在写完函数几分钟后就发现自己难以理解它,想象一下那些远离问题领域的程序员在阅读它时会感到多么困难。因此,仔细审查你的代码,确保它尽可能易于阅读和理解。你的同行会感谢你的。
遍历
在树形数据结构中遍历节点有几种方法,但你选择哪种方法将主要基于你的树节点是如何实现的。例如,我们的 Node 类包括从父节点到子节点的引用,但不包括反向引用。它也不提供任何同等级别或同一层级的兄弟或堂兄弟的引用。因此,我们的遍历模式仅限于通过跟随从父节点到子节点的边或引用来遍历树。这种遍历被称为遍历树。
我们的节点构造也允许我们在检查父节点之前检查任意一个子节点。如果我们把搜索模式结构化成先检查左子节点,然后是右子节点,最后是节点本身,那么我们就实现了中序遍历。如果我们节点的对象之间包含同等级别的链接,我们可以在检查任何子节点之前检查特定等级的所有父节点。这种方法被称为前序遍历。如果我们还包含从子节点到其相应父节点的链接,我们可以执行相反的操作,即在我们检查任何父节点之前,检查特定等级的所有子节点。这种方法被称为后序遍历。这两种方法都可以归类为层次遍历,它在整个树上执行广度优先搜索,逐层检查节点。
摘要
在本章中,我们学习了树形数据结构,以及它们与树形数据类型的区别。我们花了时间来检查与树相关的术语,包括树形数据结构的视觉表示。接下来,我们评估了在处理树时最常见的操作及其复杂度成本。随后,我们从头开始创建了自己的简单二叉树数据结构类,并讨论了如何使用递归操作来遍历树。我们考察了递归的含义以及如何编写自己的递归函数,以斐波那契序列作为这个过程的一个例子。最后,我们考察了根据树中节点之间的关系,树形数据结构可以以各种方式遍历。
第十章。堆:有序树
堆是树数据结构的一个特殊类别,它们根据树节点的值或与每个节点关联的键进行排序。这种排序在最小堆中是升序的,意味着根节点的值或优先级小于其子节点,或者在最大堆中是降序的,意味着根节点的值或优先级大于其子节点。请注意,堆数据结构不应与计算机系统的堆内存混淆,后者通常用于系统动态分配的内存。
在本章中,我们将涵盖以下主题:
-
定义堆数据结构
-
数组实现
-
创建堆
-
常见操作
堆实现
与树类似,堆通常使用链表或链节点,或数组来实现。由于我们在第九章(第九章
二叉堆是一种树结构,其中树的所有层级都被完全填充,除了最后一层或最深层。在最后一层的情况下,节点从左到右填充,直到该层填满。如图所示,在基于数组的实现中,每个父节点有两个子节点,它们位于 2i + 1 和 2i + 2,其中 i 是父节点的索引,集合的第一个节点位于索引 0。
注意
不同的实现跳过了数组的 0 索引,以简化给定索引查找子节点和父节点的算术。在这个设计中,任何给定索引 i 的子节点位于 2i 和 2i + 1。
堆操作
并非所有堆数据结构的实现都公开相同的操作方法。然而,更常见的操作应该可用,或者根据开发者的需要提供。
-
Insert:Insert 操作向堆中添加一个新节点。此操作必须重新排序堆,以确保新添加的节点保持堆属性。此操作的操作成本为 O(log n)。
-
FindMax:FindMax 操作与最大堆同义,并返回集合中的最大值或最高优先级对象。在基于数组的实现中,这通常是索引 0 或索引 1 的对象,具体取决于设计。这相当于栈或队列中的 peek 操作,当使用堆实现优先队列时非常重要。此操作的操作成本为 O(1)。
-
FindMin: FindMin 操作与最小堆相关,并返回集合中最小值或最低优先级的对象。在基于数组的实现中,这通常是索引为 0 或 1 的对象,具体取决于设计。此操作的操作成本为O(1)。
-
ExtractMax: ExtractMax 操作与最大堆相关,既返回集合中最大值或最高优先级的对象,又将其从集合中移除。这相当于在栈或队列结构中的pop操作。与 FindMax 操作类似,这通常是索引为 0 或 1 的对象,具体取决于设计。此操作还将重新排序堆以维护堆属性。此操作的操作成本为O(n)。
-
ExtractMin: ExtractMin 操作与最小堆相关,既返回集合中最小值或最低优先级的对象,又将其从集合中移除。与 FindMin 操作类似,这通常是索引为 0 或 1 的对象,具体取决于设计。此操作还将重新排序堆以维护堆属性。此操作的操作成本为O(n)。
-
DeleteMax: 删除最大值操作与最大堆相关,简单地说就是从集合中移除最大值或最高优先级的对象。与 FindMax 操作类似,这通常是索引为 0 或 1 的对象,具体取决于设计。此操作还将重新排序堆以维护堆属性。此操作的操作成本为O(n)。
-
DeleteMin: 删除最小值操作与最小堆相关,简单地说就是从集合中移除最小值或最低优先级的对象。与 FindMin 操作类似,这通常是索引为 0 或 1 的对象,具体取决于设计。此操作还将重新排序堆以维护堆属性。此操作的操作成本为O(n)。
-
Count: Count 操作返回堆中的节点总数。此操作的操作成本为O(1)。
-
Children: 子节点操作将返回给定节点或节点索引的两个子节点。由于必须执行两次计算以收集子节点,因此此操作的操作成本为O(2)。
-
Parent: 父节点操作将返回任何给定节点或节点索引的父节点。此操作的操作成本为O(1)。
注意
这一系列操作可能会让你想起第九章中讨论的树数据结构,即“非线性结构:树”。重要的是要注意,尽管二叉堆与二叉搜索树非常相似,但两者不应混淆。像二叉搜索树一样,堆数据结构组织集合中的每个节点。堆根据节点或环境的某些任意属性对节点进行排序,而每个节点的值并不一定是有序的。另一方面,在二叉搜索树中,节点的值本身是有序的。
实例化堆
由于堆是一种树形数据结构,我们在讨论的语言中不会找到原生的具体实现并不奇怪。然而,堆数据结构实际上非常简单实现。因此,我们将构建自己的堆结构,具体来说是一个最小堆。
最小堆结构
在我们开始之前,我们需要详细说明我们的堆结构将具有的一些特性。首先,我们将使用数组来实现堆,第一个节点将占据这个数组的0索引。这个决定很重要,因为它影响我们计算每个节点父节点和子节点的公式。接下来,我们需要一个对象来表示我们堆中的节点。由于这将是我们的演示中一个非常简单的对象,我们将直接在堆实现中定义它的类。
由于这是一个最小堆,我们只需要实现min操作。因此,我们的实现必须公开FindMin(查看)、ExtractMin(弹出)和DeleteMin方法。堆的Insert、Count、Children和Parent操作将分别作为单独的方法实现。
我们的最小堆实现还需要两个辅助方法来重新排序集合,每当添加或删除节点时。我们将这些方法命名为OrderHeap和SwapNodes,它们的功能应该是自解释的。
注意
注意,最大堆的实现几乎与最小堆相同,只是在几个操作中交换了变量。我们将在实现中详细说明这些差异。
C#
C#提供了足够的功能,让我们用很少的代码创建一个通用的堆数据结构。首先,我们需要构建一个简单的类来表示堆的节点:
public class HeapNode
{
public int Data;
}
这个类非常简单,只包含一个public属性来存储我们的整数数据。由于这个类的内容在以下每种语言示例中都是一致的,我们在这里只检查它。
接下来,我们可以实现我们的堆函数。下面是一个MinHeap类在 C#中的具体实现示例:
List<HeapNode> elements;
public int Count
{
get
{
return elements.Count;
}
}
public MinHeap()
{
elements = new List<HeapNode>();
}
我们的MinHeap类包含两个公共字段。第一个是一个名为elements的List<HeapNode>,它代表我们的堆集合。第二个是一个Count字段,它将返回集合中的元素总数。最后,我们的构造函数简单地初始化elements集合。
public void Insert(HeapNode item)
{
elements.Add(item);
OrderHeap();
}
Insert(HeapNode item)方法接受一个新的HeapNode对象并将其添加到集合中。一旦对象被添加,方法就调用OrderHeap()以确保新对象被放置在正确的位置以保持堆属性。
public void Delete(HeapNode item)
{
int i = elements.IndexOf(item);
int last = elements.Count - 1;
elements[i] = elements[last];
elements.RemoveAt(last);
OrderHeap();
}
Delete(HeapNode item)方法接受一个要从中移除的HeapNode对象。该方法首先找到要移除的项的索引,然后获取集合中最后一个对象的索引。接下来,方法通过用堆中的最后一个节点的引用覆盖其位置来删除匹配的节点,然后移除最后一个节点。最后,调用OrderHeap()方法以确保最终的集合满足堆属性。
public HeapNode ExtractMin()
{
if (elements.Count > 0)
{
HeapNode item = elements[0];
Delete(item);
return item;
}
return null;
}
ExtractMin()方法首先确认elements集合至少有一个元素。如果没有,则方法返回null。否则,方法创建一个新的HeapNode实例,称为item,并将其设置为集合中的根对象,即最小的对象或具有最低优先级的对象。接下来,方法调用Delete(item)从集合中删除节点。最后,由于ExtractMin函数必须返回一个对象,方法将item返回给调用者。
public HeapNode FindMin()
{
if (elements.Count > 0)
{
return elements[0];
}
return null;
}
FindMin()方法与ExtractMin()方法非常相似,不同之处在于它不会从集合中移除返回的最小值。该方法首先确认元素集合至少有一个元素。如果没有,则方法返回null。否则,方法返回集合中的根对象,即最小的对象或具有最低优先级的对象。
private void OrderHeap()
{
for (int i = elements.Count - 1; i > 0; i--)
{
int parentPosition = (i - 1) / 2;
if (elements[parentPosition].Data > elements[i].Data)
{
SwapElements(parentPosition, i);
}
}
}
private void SwapElements(int firstIndex, int secondIndex)
{
HeapNode tmp = elements[firstIndex];
elements[firstIndex] = elements[secondIndex];
elements[secondIndex] = tmp;
}
私有的OrderHeap()方法是MinHeap类的核心。这是负责维护集合堆属性的方法。该方法首先根据元素集合的长度建立了一个for循环,并从集合的末尾开始向前迭代。
注意
由于我们知道任何索引为 i 的对象的两个子节点位于索引2i + 1和2i + 2,我们同样知道任何索引为 i 的对象的父节点位于(i - 1) / 2。这个公式之所以有效,是因为结果值被定义为整数,这意味着任何浮点值都会被截断,只保留整数值。这个算法通过OrderHeap()方法中的int parentPosition = (i - 1) / 2;代码实现,确保堆数据结构保持其二进制性质。
使用最小堆属性公式,for 循环首先确定当前节点的父索引。接下来,将当前节点 Data 字段的值与父节点的值进行比较;如果父节点更大,则方法调用 SwapElements(parentPosition, i)。一旦评估了每个节点,方法就完成了,整个集合的堆属性是一致的。
注意
注意,通过交换 if 语句的两个操作数,或者简单地更改比较器从 > 到 <,或者,我们的集合实际上会从最小堆变为最大堆。利用这一知识,确实可以非常简单地创建一个堆集合,该集合可以在 运行时 被定义为最小堆或最大堆。
SwapElements(int firstIndex, int secondIndex) 方法的功能是显而易见的。给定索引处的每个节点都被交换,以强制执行堆属性。
public List<HeapNode> GetChildren(int parentIndex)
{
if (parentIndex >= 0)
{
List<HeapNode> children = new List<HeapNode>();
int childIndexOne = (2 * parentIndex) + 1;
int childIndexTwo = (2 * parentIndex) + 2;
children.Add(elements[childIndexOne]);
children.Add(elements[childIndexTwo]);
return children;
}
return null;
}
使用相同的规则,即任何对象的索引为 i 的两个子节点位于索引 2i + 1 和 2i + 2,GetChildren(int parentIndex) 方法会收集并返回给定父索引的两个子节点。该方法首先确认 parentIndex 不小于 0,否则返回 null。如果 parentIndex 有效,该方法会创建一个新的 List<Heapnode> 并使用计算出的子索引填充它,然后返回 children 集合。
public HeapNode GetParent(int childIndex)
{
if (childIndex > 0 && elements.Count > childIndex)
{
int parentIndex = (childIndex - 1) / 2;
return elements[parentIndex];
}
return null;
}
最后,GetParent(int childIndex) 方法与 GetChildren 方法的工作原理相同。如果给定的 childIndex 大于 0,则节点有一个父节点。该方法确认我们不是在搜索根节点,并确认索引不在集合的界限之外。如果任一检查失败,该方法返回 null。否则,该方法确定节点的父索引,然后返回在该索引处找到的节点。
Java
Java 还提供了构建我们 MinHeap 类的健壮实现所需的基本工具,而无需编写太多代码。以下是该类在 Java 中的可能外观:
List<HeapNode> elements;
public int size()
{
return elements.size();
}
public MinHeap()
{
elements = new ArrayList<HeapNode>();
}
我们的 MinHeap 类包括一个名为 elements 的公共字段,其类型为抽象类型 List<HeapNode>,代表我们的堆集合。该类还包括一个名为 size() 的方法,它将返回集合中的元素总数。最后,我们的构造函数只是将 elements 集合初始化为 ArrayList<HeapNode>:
public void insert(HeapNode item)
{
elements.add(item);
orderHeap();
}
insert(HeapNode item) 方法接受一个新的 HeapNode 对象并将其添加到集合中。一旦对象被添加,该方法调用 orderHeap() 确保新对象被放置在正确的位置以保持堆属性。
public void delete(HeapNode item)
{
int i = elements.indexOf(item);
int last = elements.size() - 1;
elements.set(i, elements.get(last));
elements.remove(last);
orderHeap();
}
delete(HeapNode item) 方法接受一个要从中移除的 HeapNode 项目。该方法首先找到要删除的项目索引,然后获取集合中最后一个对象的索引。接下来,通过将堆中最后一个节点的引用覆盖到该位置来删除匹配的节点,然后删除最后一个节点。最后,调用 orderHeap() 确保最终的集合满足堆属性。
public HeapNode extractMin()
{
if (elements.size() > 0)
{
HeapNode item = elements.get(0);
delete(item);
return item;
}
return null;
}
extractMin() 方法首先确认 elements 集合至少有一个元素。如果没有,该方法返回 null。否则,该方法创建一个名为 item 的新 HeapNode 实例,并将其设置为集合中的根对象,即最小的对象或具有最低优先级的对象。接下来,该方法调用 delete(item) 从集合中删除该节点。最后,由于 ExtractMin 函数必须返回一个对象,因此该方法将 item 返回给调用者。
public HeapNode findMin()
{
if (elements.size() > 0)
{
return elements.get(0);
}
return null;
}
findMin() 方法与 extractMin() 方法非常相似,不同之处在于它不会从集合中删除返回的最小值。该方法首先确认 elements 集合至少有一个元素。如果没有,该方法返回 null。否则,该方法通过调用 elements.get(0) 返回集合中的根对象。
private void orderHeap()
{
for (int i = elements.size() - 1; i > 0; i--)
{
int parentPosition = (i - 1) / 2;
if (elements.get(parentPosition).Data > elements.get(i).Data)
{
swapElements(parentPosition, i);
}
}
}
private void swapElements(int firstIndex, int secondIndex)
{
HeapNode tmp = elements.get(firstIndex);
elements.set(firstIndex, elements.get(secondIndex));
elements.set(secondIndex, tmp);
}
私有的 orderHeap() 方法负责维护集合的堆属性。该方法首先根据 elements 集合的长度建立 for 循环,并从集合的末尾开始迭代到开头。
使用最小堆属性公式,for 循环首先确定当前节点的父索引。接下来,将当前节点的 Data 字段值与父节点的值进行比较,如果父节点更大,则调用 swapElements(parentPosition, i)。一旦评估了每个节点,方法就完成了,并且整个集合的堆属性是一致的。
swapElements(int firstIndex, int secondIndex) 方法的功能是显而易见的。给定索引处的每个节点都被交换,以强制执行堆属性。
public List<HeapNode> getChildren(int parentIndex)
{
if (parentIndex >= 0)
{
ArrayList<HeapNode> children = new ArrayList<HeapNode>();
int childIndexOne = (2 * parentIndex) + 1;
int childIndexTwo = (2 * parentIndex) + 2;
children.add(elements.get(childIndexOne));
children.add(elements.get(childIndexTwo));
return children;
}
return null;
}
使用相同的规则,即任何对象的两个子节点位于索引 2i + 1 和 2i + 2,getChildren(int parentIndex) 方法收集并返回给定父索引的两个子节点。该方法首先确认 parentIndex 不小于 0,否则返回 null。如果 parentIndex 有效,该方法创建一个新的 ArrayList<Heapnode> 并使用计算出的子索引填充它,然后再返回 children 集合。
public HeapNode getParent(int childIndex)
{
if (childIndex > 0 && elements.size() > childIndex)
{
int parentIndex = (childIndex - 1) / 2;
return elements.get(parentIndex);
}
return null;
}
最后,getParent(int childIndex) 与 getChildren 的工作原理相同。如果给定的 childIndex 大于 0,则节点有一个父节点。该方法确认我们不是在搜索根节点,并确认索引不在集合的界限之外。如果任一检查失败,则方法返回 null。否则,该方法确定节点的父索引,然后返回在该索引处找到的节点。
Objective-C
使用 NSMutableArray 作为核心结构,Objective-C 也可以轻松实现最小堆数据结构。以下是 EDSMinHeap 类在 Objective-C 中的可能外观:
@interface EDSMinHeap()
{
NSMutableArray<EDSHeapNode*> *_elements;
}
@implementation EDSMinHeap
-(instancetype)initMinHeap{
if (self = [super init])
{
_elements = [NSMutableArray array];
}
return self;
}
使用类簇 NSMutableArray,我们为我们的类创建一个名为 _elements 的 ivar。初始化器实例化此数组,为我们构建 EDSMinHeap 类提供了底层数据结构。
-(NSInteger)getCount
{
return [_elements count];
}
我们的 EDSMinHeap 类包括一个名为 Count 的公共属性,getCount() 访问器返回 _elements 数组的 count 属性。
-(void)insert:(EDSHeapNode*)item
{
[_elements addObject:item];
[self orderHeap];
}
insert: 方法接受一个新的 EDSHeapNode 对象并将其添加到数组中。一旦对象被添加,该方法就调用 orderHeap 确保新对象被放置在正确的位置以维护堆属性:
-(void)delete:(EDSHeapNode*)item
{
long i = [_elements indexOfObject:item];
_elements[i] = [_elements lastObject];
[_elements removeLastObject];
[self orderHeap];
}
delete: 方法接受一个要从中移除的 EDSHeapNode 对象。该方法首先使用 indexOfObject: 找到要移除的对象的索引,然后通过用堆中的 lastObject 的引用覆盖其位置来删除匹配的节点。接下来,使用 removeLastObject 移除最后一个节点。最后,调用 orderHeap: 确保最终的集合满足堆属性。
-(EDSHeapNode*)extractMin
{
if ([_elements count] > 0)
{
EDSHeapNode *item = _elements[0];
[self delete:item];
return item;
}
return nil;
}
extractMin 方法首先确认 _elements 集合至少有一个元素。如果没有,该方法返回 nil。否则,该方法创建一个名为 item 的新 EDSHeapNode 实例,并将其设置为集合中的根对象,即最小的对象或具有最低优先级的对象。接下来,该方法调用 delete: 从集合中删除该节点。最后,由于 ExtractMin 函数必须返回一个对象,因此该方法将 item 返回给调用者。
-(EDSHeapNode*)findMin
{
if ([_elements count] > 0)
{
return _elements[0];
}
return nil;
}
findMin 方法与 extractMin 方法非常相似,不同之处在于它不会从集合中移除返回的最小值。该方法首先确认元素集合至少有一个元素。如果没有,该方法返回 nil。否则,该方法返回集合中的第一个对象,即根节点。
-(void)orderHeap
{
for (long i = [_elements count] - 1; i > 0; i--)
{
long parentPosition = (i - 1) / 2;
if (_elements[parentPosition].data > _elements[i].data)
{
[self swapElement:parentPosition withElement:i];
}
}
}
-(void)swapElement:(long)firstIndex withElement:(long)secondIndex
{
EDSHeapNode *tmp = _elements[firstIndex];
_elements[firstIndex] = _elements[secondIndex];
_elements[secondIndex] = tmp;
}
私有的 orderHeap 方法负责维护集合的堆属性。该方法首先根据元素集合的长度建立 for 循环,并从后向前遍历集合。
使用最小堆属性公式,for循环首先识别当前节点的父索引。接下来,将当前节点data属性的值与父节点的值进行比较,如果父节点更大,则方法调用swapElement:withElement:。一旦每个节点都被评估,方法就完成了,整个集合的堆属性是一致的。
swapElement:withElement:方法的功能是显而易见的。给定索引处的每个节点都被交换,以强制执行堆属性。
-(NSArray<EDSHeapNode*>*)childrenOfParentIndex:(NSInteger)parentIndex
{
if (parentIndex >= 0)
{
NSMutableArray *children = [NSMutableArray array];
long childIndexOne = (2 * parentIndex) + 1;
long childIndexTwo = (2 * parentIndex) + 2;
[children addObject:_elements[childIndexOne]];
[children addObject:_elements[childIndexTwo]];
return children;
}
return nil;
}
使用规定任何对象在索引 i 处的两个子节点位于索引2i + 1和2i + 2的规则,childrenOfParentIndex:方法收集并返回给定父索引的两个子节点。该方法首先确认parentIndex不小于 0,否则返回nil。如果parentIndex有效,则方法创建一个新的NSMutableArray,并使用计算出的子索引中的节点填充它,然后返回children集合。
-(EDSHeapNode*)parentOfChildIndex:(NSInteger)childIndex
{
if (childIndex > 0 && [_elements count] > childIndex)
{
long parentIndex = (childIndex - 1) / 2;
return _elements[parentIndex];
}
return nil;
}
最后,parentOfChildIndex:与childrenOfParentIndex:的工作原理相同。如果给定的childIndex大于 0,则节点有一个父节点。该方法确认我们不是在搜索根节点,并且也确认索引不在集合的界限之外。如果任一检查失败,则方法返回nil。否则,该方法确定节点的父索引,然后返回在该索引处找到的节点。
Swift
我们的 Swift MinHeap类在结构和功能上与 C#和 Java 实现相似。以下是一个 Swift 中MinHeap类的示例:
public var _elements: Array = [HeapNode]()
public init () {}
public func getCount() -> Int
{
return _elements.count
}
使用Array类,我们为我们的类创建一个名为_elements的私有属性。由于我们的属性是声明和实例化同时进行的,并且没有其他需要实例化的自定义代码,我们可以排除显式的公共初始化器并依赖于默认初始化器。我们的类还提供了一个名为getCount()的公共方法,它返回_elements数组的大小。
public func insert(item: HeapNode)
{
_elements.append(item)
orderHeap()
}
insert(HeapNode item)方法接受一个新的HeapNode对象并将其添加到集合中。一旦对象被添加,方法就调用orderHeap()以确保新对象被放置在正确的位置以保持堆属性。
public func delete(item: HeapNode)
{
if let index = _elements.index(of: item)
{
_elements[index] = _elements.last!
_elements.removeLast()
orderHeap()
}
}
delete(HeapNode item)方法接受一个要从集合中删除的HeapNode项。该方法首先找到要删除的项的index,然后通过用堆中的last对象的引用覆盖其位置来删除匹配的节点。最后,调用orderHeap()方法以确保最终的集合满足堆属性。
public func extractMin() -> HeapNode?
{
if (_elements.count > 0)
{
let item = _elements[0]
delete(item: item)
return item
}
return nil
}
extractMin() 方法首先确认 elements 集合至少有一个元素。如果没有,则方法返回 nil。否则,该方法创建一个名为 item 的新变量,并将其设置为集合中的根对象,即最小的 HeapNode 或具有最低优先级的 HeapNode。接下来,该方法调用 delete(item: Heapnode) 从集合中删除节点。最后,该方法将 item 返回给调用者。
public func findMin() -> HeapNode?
{
if (_elements.count > 0)
{
return _elements[0]
}
return nil
}
findMin() 方法与 extractMin() 方法非常相似,不同之处在于它不会从集合中移除返回的最小值。该方法首先确认元素集合至少有一个元素。如果没有,则方法返回 nil。否则,该方法返回 _elements[0],这是集合中的根对象。
public func orderHeap()
{
for i in (0..<(_elements.count) - 1).reversed()
{
let parentPosition = (i - 1) / 2
if (_elements[parentPosition].data! > _elements[i].data!)
{
swapElements(first: parentPosition, second: i)
}
}
}
public func swapElements(first: Int, second: Int)
{
let tmp = _elements[first]
_elements[first] = _elements[second]
_elements[second] = tmp
}
私有 orderHeap() 方法负责维护集合的堆属性。该方法首先根据元素集合的长度建立 for 循环,并从末尾开始迭代集合。
使用最小堆属性公式,for 循环首先确定当前节点的父索引。然后,将当前节点 data 字段的值与父节点的值进行比较,如果父节点更大,则方法调用 swapElements(first: Int, second: Int)。一旦评估了每个节点,方法完成,整个集合的堆属性保持一致。
swapElements(int firstIndex, int secondIndex) 方法的功能是显而易见的。给定索引处的每个节点都被交换,以强制执行堆属性:
public func getChildren(parentIndex: Int) -> [HeapNode]?
{
if (parentIndex >= 0)
{
var children: Array = [HeapNode]()
let childIndexOne = (2 * parentIndex) + 1;
let childIndexTwo = (2 * parentIndex) + 2;
children.append(_elements[childIndexOne])
children.append(_elements[childIndexTwo])
return children;
}
return nil;
}
使用相同的规则,即任何对象的索引 i 的两个子节点位于索引 2i + 1 和 2i + 2,getChildren(parentIndex: Int) 方法收集并返回给定父索引的两个子节点。该方法首先确认 parentIndex 不小于 0,否则返回 nil。如果 parentIndex 有效,该方法创建一个新的 Array,包含 HeapNode 对象,并使用计算出的子索引填充它,然后返回 children 集合:
public func getParent(childIndex: Int) -> HeapNode?
{
if (childIndex > 0 && _elements.count > childIndex)
{
let parentIndex = (childIndex - 1) / 2;
return _elements[parentIndex];
}
return nil;
}
最后,getParent(childIndex: Int) 方法与 getChildren 方法的工作原理相同。如果给定的 childIndex 大于 0,则节点有一个父节点。方法确认我们不是在寻找根节点,并确认索引不在集合的界限之外。如果任一检查失败,则方法返回 nil。否则,方法确定节点的父索引,然后返回在该索引处找到的节点。
常见应用
堆数据结构实际上相当常见,尽管你可能并不总是意识到你正在处理一个。以下是堆数据结构最常见的应用之一:
-
选择算法:选择算法用于确定集合中的第 k 个最小或最大元素,或者集合的中值对象。在通常的集合中,这种操作的成本为 O(n)。然而,在一个使用数组实现的有序堆中,找到第 k 个元素是一个O(1)操作,因为我们只需简单地检查数组中的 k 索引即可找到该元素。
-
优先队列:优先队列是一种类似于标准队列的抽象数据结构,除了节点包含一个额外的值,表示该对象相对于集合中其他对象的优先级。由于堆数据结构的自然排序,优先队列通常使用堆来实现。
摘要
在本章中,我们学习了堆数据结构。我们考察了与堆一起工作时最常用的操作及其复杂度成本。随后,我们从头开始创建了自己的简单最小堆数据结构类,并讨论了如何使用最小堆属性公式来计算任何给定节点索引的父节点或子节点。最后,我们考察了堆数据结构最常见的应用。
第十一章。图:具有关系的值
我们将要考察的最终数据结构是图。图是一组没有特定结构关系的对象,其中每个对象可以与集合中一个或多个其他对象有链接。图中的对象通常被称为节点、顶点或点。链接,或对象之间的关系,被称为边、线或弧。这些链接可以是简单的引用,也可以是具有自己值的对象。更正式地说,图是一对集合 (N, E),其中 N 是节点的集合,E 是边的集合。
图应用的一个优秀例子将是可视化社交媒体数据库中个人之间的关系。在这样的数据库中,数据库中的每个人代表一个节点,他们与其朋友圈中其他人的每个链接代表一条边。在这样的朋友圈中,看到节点之间呈环形或甚至交织的关系是完全合理的,因为一个人可以和另一个人分享许多相同的朋友或同事。当试图合理化这些集合时,树和堆结构会迅速崩溃,而图数据结构实际上正是为了这样的场景而设计的。
在本章中,我们将涵盖以下主题:
-
图数据结构的定义
-
图结构的视觉概念
-
常见操作
-
图的实现
视觉图概念
有时使用某些集合的视觉表示来理解图数据结构的概念更容易。考虑以下图示:

这是一个由十一个节点和十二条边组成的基本图。集合 N 和 E 可以描述如下:
N = {2, 3, 4, 5, 9, 11, 19, 38, 52, 77, 97}
E = {2:38, 2:77, 2:97, 3:19, 4:77, 5:2, 5:19, 11:2, 11:4, 11:5, 11:52, 77:9}
注意,在这个例子中,节点之间只有单向边。这是完全可以接受的,但当允许双向节点时,图就更加强大。考虑以下示例:

这是我们在前面看到的同一个图,但现在集合 E 包含了现有节点之间的一些新互惠边。集合 N 和 E 现在可以描述如下:
N = {2, 3, 4, 5, 9, 11, 19, 38, 52, 77, 97}
E = {2:5, 2:38, 2:77, 2:97, 3:19, 4:11, 4:77, 5:2, 5:19, 11:2, 11:4, 11:5, 11:52, 77:9, 97:2}
最后,节点之间的边也可以定义为一个特定的值。考虑以下示例:

在这个图中,我们看到一个包含六个节点和七条边的图。然而,在这种情况下,边被进一步定义为特定的值。这个值不仅限于整数,它可以表示任何类型或所需的自定义对象。对于这个图的集合 N 和 E 可以描述如下:
N = {2, 4, 5, 52, 97}
E = {2:5(84), 4:11(97), 11:2(70), 11:4(97), 11:5(16), 11:52(102), 97:2(14)}
图操作
由于图支持节点之间的双向引用,并且节点可以几乎无限地拥有邻居,因此为了实现集合,有必要定义两个基本对象。这些包括构成图的节点以及图集合本身。如果实现支持包含值的边,则可能还需要一个边对象。因此,请注意,一些常见的图操作将在多个类中具有组件:
-
添加节点:这个操作有时被称为添加顶点或添加点操作,并且取决于定义图的所用语言。添加节点操作只是将新节点插入到图中,而不定义任何边或对相邻节点的引用。由于一个节点不一定需要邻居才能存在于图中,因此添加节点操作代表一个O(1)操作。此外,请注意,添加节点操作仅由图集合对象实现。
-
删除节点:这个操作有时被称为删除顶点或删除点操作,并且取决于定义图的所用语言。删除节点操作从图中删除节点,并删除任何到和从相邻节点的边或引用。此操作的操作成本为O(n + k),其中n是我们图中节点的数量,k是边的数量。删除节点操作仅由图集合对象实现。
注意
对于简单的删除操作,这可能会显得有些昂贵,但请记住,图中的引用可以是双向的,这意味着我们的节点可能潜在地有指向图中每个其他节点的边,同时图中的每个其他节点也可能同时有指向我们的节点的边。
这主要是针对设计为支持包含值的边的图而言的。在这种情况下,必须逐个检查每条边,以确定它是否指向要删除的节点,如果确实如此,则必须相应地处理。在边仅仅是对象之间指针的图中,将对象设置为
null或nil将有效地消除指向它的任何边,这可能会将此操作的成本降低到O(1)。 -
AddEdge: 此操作有时被称为AddArc或AddLine操作,它依赖于定义节点的语言。AddEdge 操作简单地从节点
x到节点y添加一条新边。AddEdge 操作在集合对象和节点对象中实现。在节点级别,只需传递目标节点y作为参数;而在图级别,则需要提供x和y。如果图支持具有值的边,则新值也必须作为参数传递给图操作。由于图支持节点之间的双向关系,因此无需首先确认从节点y到节点x是否存在边。这意味着在节点之间添加新边是一个简单的过程,具有O(1)的操作成本。 -
RemoveEdge: 此操作有时被称为RemoveArc或RemoveLine操作,它依赖于定义节点的语言。RemoveEdge 操作简单地从节点
x到节点y删除现有边(如果存在)。在节点级别,只需传递目标节点y作为参数,而在图级别,则需要提供x和y。如果图支持具有值的边,则新值也必须作为参数传递给图操作。由于图支持节点之间的双向关系,从节点x到节点y删除边作为操作与从节点y到节点x的现有边完全独立;因此,这个过程具有O(1)的操作成本。 -
GetNodeValue: GetNodeValue 操作有时被称为GetVertexValue或GetPointValue操作,这取决于定义节点的语言。此操作返回与节点关联的值,无论是原始类型还是某种自定义对象类型,并且操作具有O(1)的操作成本。此操作可以在图或节点级别定义,但如果它作为图对象的一部分定义,则必须将需要查询的节点作为参数传递给操作。
-
SetNodeValue: SetNodeValue 操作有时被称为SetVertexValue或SetPointValue操作,它依赖于定义节点的语言。此操作设置节点的值,并具有O(1)的操作成本。再次强调,此操作可以在图或节点级别定义,但如果它作为图对象的一部分定义,则必须将需要设置的节点作为参数传递给操作。
-
Adjacent: Adjacent 操作检查是否存在从节点
x到节点y的边,通常返回表示结果的布尔值。此操作通常在图级别定义,并需要提供节点x和y。这个简单的操作具有O(1)的操作成本。 -
Neighbors:这个操作在树数据结构中的子操作功能类似。
Neighbors操作返回一个包含所有节点y的列表,其中从节点x到节点y存在边。这个操作通常在图级别定义,并需要提供节点x。这个操作具有O(1)的操作成本。 -
Count:与其他许多集合一样,图通常公开一个计数操作,该操作返回集合中包含的节点数。尽管这取决于实现,但这个操作通常具有O(1)的操作成本。
-
GetEdgeValue:这个操作有时被称为 GetArcValue 或 GetLineValue 操作,这取决于定义节点的语言。在支持带值边的图中,这个操作返回与边关联的值,无论是原始类型还是某种自定义对象类型,并且操作具有O(1)的操作成本。这个操作也可以定义为节点对象的一部分,在这种情况下,必须将需要查询的边作为参数传递给操作。
-
SetEdgeValue:这个操作有时被称为SetArcValue或SetLineValue操作,这取决于定义边的语言。这个操作设置边的值,并且具有O(1)的操作成本。再次强调,这个操作可以定义为节点对象的一部分,在这种情况下,要设置的边必须作为参数传递给操作。
图实现
与堆一样,图是树数据结构的一种形式,因此在我们讨论的语言中,我们不会找到原生的具体实现。然而,图数据结构实现起来出奇地简单,因此我们将从头开始构建自己的Graph类。
图数据结构
在我们开始之前,我们需要详细说明我们的图结构将具有的一些特性。我们的图将支持没有与其他节点相连的节点。我们的图还将支持单向和双向边。为了简洁起见,我们的图集合中的边将不支持边值,但如果您决定在自定义实现中使用它们,添加值到边是一个简单的问题。
我们将使用两个类来构建我们的图。第一个是Graph类本身,在我们的实现中,它将包含大多数标准图操作。下一个是GraphNode类,它将代表我们的集合中的节点。请注意,这个类也可以命名为GraphVertex或GraphPoint,但为了与我们的第九章中的树Node类示例保持一致,即“非线性结构:树”,我们将坚持使用节点。
Graph 类将基于一个包含节点根引用的数组或列表。每个 GraphNode 对象也将包含一个数组或列表,它持有对其他节点的引用。在这个实现中,这些引用代表我们的数据结构中的边。这个类将支持从头开始实例化或通过传递现有的 GraphNode 对象列表进行实例化。在 Graph 类中实现添加和删除节点和边的操作。Graph 类还将包含检查节点相邻性、节点邻居和集合中节点总数操作的函数。
C#
C# 并没有提供具体的 Graph 或 GraphNode 类,因此我们需要自己创建。我们将从 GraphNode 类开始。以下是一个基本的 GraphNode 类实现示例,在 C# 中可能看起来是这样的:
public class GraphNode
{
public Int16 Value;
private List<GraphNode> _neighbors;
public List<GraphNode> Neighbors
{
get
{
return _neighbors;
}
}
public GraphNode()
{
_neighbors = new List<GraphNode>();
}
public GraphNode(Int16 value)
{
_neighbors = new List<GraphNode>();
Value = value;
}
}
这个类非常简单,包含一个名为 Value 的公共字段来存储我们的整数数据,以及一个名为 neighbors 的 List<GraphNode> 对象,它表示此节点与其邻居之间的边。该类还有两个构造函数,它们都实例化了 _neighbors 列表。重载的 GraphNode(Int16 value) 构造函数还允许在实例化时定义一个值。
接下来,我们可以实现我们的图函数。以下是一个 Graph 类的具体实现示例,在 C# 中可能看起来是这样的:
private List<GraphNode> _nodes;
public List<GraphNode> Nodes
{
get
{
return _nodes;
}
}
public Graph(List<GraphNode> nodes)
{
if (nodes == null)
{
_nodes = new List<GraphNode>();
}
else
{
_nodes = nodes;
}
}
我们的 Graph 类包括一个公共字段,一个名为 Nodes 的 List<GraphNode> 集合,它公开了私有 List<GraphNode> _nodes 字段的只读访问。该字段维护了到相邻节点的边列表。最后,我们的构造函数接受一个类型为 List<Graphnode> 的参数,如果它不为空,则将 _nodes 设置为这个值;否则,初始化 _nodes 集合:
public void AddNode(GraphNode node)
{
_nodes.Add(node);
}
public void AddNodeForValue(Int16 value)
{
_nodes.Add(new GraphNode(value));
}
Graph 中的前两个公共方法是 AddNode(GraphNode node) 和 AddNodeForValue(Int16 value),它们为我们这个类添加了两种版本的 AddNode 功能。第一个将一个预存在的节点添加到 _nodes 集合中,而第二个使用 value 实例化一个新的节点,然后将该节点添加到 _nodes 集合中。这两个方法添加节点时不定义任何边,因此这些操作的成本为 O(1):
public bool RemoveNode(Int16 value)
{
GraphNode nodeToRemove = _nodes.Find(n => n.Value == value);
if (nodeToRemove == null)
{
return false;
}
_nodes.Remove(nodeToRemove);
foreach (GraphNode node in _nodes)
{
int index = node.Neighbors.IndexOf(nodeToRemove);
if (index != -1)
{
node.Neighbors.RemoveAt(index);
}
}
return true;
}
RemoveNode(Int16 value) 方法为我们这个类提供了 RemoveNode 功能。此方法接受一个类型为 Int16 且命名为 value 的参数,代表调用者请求删除的节点。该方法首先使用一个 LINQ 语句检查集合中的每个节点,寻找与 value 匹配的节点。如果没有找到匹配项,则方法返回 false。否则,匹配的节点将从 _nodes 集合中移除,并且方法执行继续。
此方法的后半部分遍历集合中的每个节点,检查每个节点的邻居以找到nodeToRemove的匹配项。找到匹配项意味着从node对象到nodeToRemove对象存在一条边,并返回该匹配项的索引值。通过使用index从node.Neighbors集合中删除nodeToRemove对象,我们消除了引用并删除了边。
如我们在关于图操作的讨论中所考察的,RemoveNode操作的操作成本为O(n + k),其中n是集合中节点的数量,k是边的数量。在RemoveNode(Int16 value)方法中,方程的前半部分代表n,后半部分代表k:
public void AddEdge(GraphNode from, GraphNode to)
{
from.Neighbors.Add(to);
}
public void AddBidirectedEdge(GraphNode from, GraphNode to)
{
from.Neighbors.Add(to);
to.Neighbors.Add(from);
}
AddEdge(GraphNode from, GraphNode to)和AddBidirectedEdge(GraphNode from, GraphNode to)方法为Graph类提供了 AddEdge 功能。第一个方法是标准的 AddEdge 操作,而第二个方法更多是作为一种便利存在,以防调用者希望立即添加双向引用。第一个方法具有O(1)的操作成本,而第二个实际上具有更不寻常的O(2)操作成本:
public bool Adjacent(GraphNode from, GraphNode to)
{
return from.Neighbors.Contains(to);
}
Adjacent(GraphNode from, GraphNode to)方法返回一个布尔值,表示两个节点from和to之间是否存在边。希望签名使这条边的方向清晰,但为了清晰起见,此方法仅确认从from节点到to节点存在边,但不确认反向。由于此方法基于contains函数,它具有O(n)的操作成本,其中n是from.Neighbors中包含的边的数量:
public List<GraphNode> Neighbors(Int16 value)
{
GraphNode node = _nodes.Find(n => n.Value == value);
if (node == null)
{
return null;
}
else
{
return node.Neighbors;
}
}
Neighbors(Int16 value)方法为我们类提供了邻居功能。此方法接受一个类型为Int16的参数,命名为value,代表调用者请求检查的节点。该方法首先使用 LINQ 语句检查集合中的每个节点,寻找与value匹配的节点。如果没有找到匹配项,则方法返回null。否则,该方法返回匹配节点的Neighbors集合。如果事先知道GraphNode对象,则此操作将具有O(1)的操作成本。然而,由于我们根据特定节点的值在Graph级别检查整个_nodes集合,因此此实现具有O(n)的操作成本:
public int Count
{
get
{
return _nodes.Count;
}
}
最后,Count字段是一个只读值,通过返回_nodes.Count来返回集合中包含的节点总数。该字段为我们Graph类提供了 Count 功能,并且具有O(1)的操作成本。
Java
与 C#一样,Java 不提供具体的Graph或GraphNode类,因此我们需要自己创建。同样,我们将从GraphNode类开始。以下是一个基本的GraphNode类实现示例在 Java 中看起来像什么:
public class GraphNode
{
public int Value;
private LinkedList<GraphNode> _neighbors;
public LinkedList<GraphNode> GetNeighbors()
{
return _neighbors;
}
public GraphNode()
{
_neighbors = new LinkedList<GraphNode>();
}
public GraphNode(int value)
{
_neighbors = new LinkedList<GraphNode>();
Value = value;
}
}
这个类非常简单,包含一个名为Value的公共字段来存储我们的整数数据,以及一个名为_neighbors的私有LinkedList<GraphNode>对象,它表示该节点与其邻居之间的边。还有一个名为GetNeighbors()的公共方法,它公开了私有_neighbors列表。该类还有两个构造函数,它们都实例化了_neighbors列表。重载的GraphNode(Int16 value)构造函数还允许在实例化时定义一个值。
接下来,我们可以实现我们的图函数。以下是一个Graph类在 Java 中的具体实现示例:
private LinkedList<GraphNode> _nodes;
public LinkedList<GraphNode> GetNodes()
{
return _nodes;
}
public Graph(){
_nodes = new LinkedList<GraphNode>();
}
public Graph(LinkedList<GraphNode> nodes)
{
_nodes = nodes;
}
我们的Graph类包含一个私有字段,一个名为_nodes的List<GraphNode>集合和一个名为GetNodes()的方法,该方法提供了对私有List<GraphNode> _nodes字段的只读访问。该字段维护了当前节点与其相邻节点之间的边列表。最后,我们的构造函数接受一个类型为List<Graphnode>的参数,如果它不是null,则将_nodes设置为该值;否则,初始化_nodes集合:
public void AddNode(GraphNode node)
{
_nodes.add(node);
}
public void AddNodeForValue(int value)
{
_nodes.add(new GraphNode(value));
}
Graph中的前两个公共方法是AddNode(GraphNode node)和AddNodeForValue(int value),它们为我们这个类添加了两种版本的 AddNode 功能。第一个将一个现有的节点添加到_nodes集合中,而第二个使用value实例化一个新的节点,然后将该节点添加到_nodes集合中。这两个方法添加节点时不定义任何边,因此这些操作的成本为O(1):
public boolean RemoveNode(int value)
{
GraphNode nodeToRemove = null;
for (GraphNode node : _nodes)
{
if (node.Value == value)
{
nodeToRemove = node;
break;
}
}
if (nodeToRemove == null)
{
return false;
}
_nodes.remove(nodeToRemove);
for (GraphNode node : _nodes)
{
int index = node.GetNeighbors().indexOf(nodeToRemove);
if (index != -1)
{
node.GetNeighbors().remove(index);
}
}
return true;
}
RemoveNode(int value)方法为我们这个类提供了RemoveNode功能。该方法接受一个类型为int的参数value,表示调用者请求删除的节点。方法开始时遍历每个节点,搜索value的匹配项。如果没有找到匹配项,则方法返回false。否则,使用remove(E)函数和方法从_nodes集合中删除匹配的节点,并继续方法执行。
此方法的后半部分遍历集合中的每个节点,检查每个节点的邻居以找到nodeToRemove的匹配项。找到匹配项意味着从node到nodeToRemove存在一条边,并返回该匹配项的索引值。通过使用index从node.Neighbors中删除nodeToRemove,我们消除了引用并删除了边。
在 Java 中的操作成本与在 C#中相同。RemoveNode操作的成本为O(n + k),其中n是集合中节点的数量,k是边的数量。在RemoveNode(int value)方法中,方程中的前半部分代表n,后半部分代表k:
public void AddEdge(GraphNode from, GraphNode to)
{
from.GetNeighbors().add(to);
}
public void AddBidirectedEdge(GraphNode from, GraphNode to)
{
from.GetNeighbors().add(to);
to.GetNeighbors().add(from);
}
AddEdge(GraphNode from, GraphNode to) 和 AddBidirectedEdge(GraphNode from, GraphNode to) 方法为 Graph 类提供了 AddEdge 功能。第一个方法执行标准的 AddEdge 操作,而第二个方法则更像是方便调用者立即添加双向引用的便捷方法。第一个方法具有 O(1) 的操作成本,而第二个方法在技术上具有更不寻常的 O(2) 操作成本:
public boolean Adjacent(GraphNode from, GraphNode to)
{
return from.GetNeighbors().contains(to);
}
Adjacent(GraphNode from, GraphNode to) 方法返回一个布尔值,表示两个节点 from 和 to 之间是否存在边。希望这个签名使这条边的方向清晰,但为了清晰起见,此方法仅确认从 from 节点到 to 节点存在边,但不确认反向。由于此方法基于 contains 函数,它具有 O(n) 的操作成本,其中 n 是 from.Neighbors 中包含的边的数量:
public LinkedList<GraphNode> Neighbors(int value)
{
GraphNode node = null;
for (GraphNode n : _nodes)
{
if (n.Value == value)
{
return node.GetNeighbors();
}
}
return null;
}
Neighbors(int value) 方法为我们类提供 Neighbors 功能。此方法接受一个类型为 int 的参数,名为 value,表示调用者请求检查的节点。方法首先遍历节点集合,寻找 value 的匹配项。如果没有找到匹配项,则方法返回 null。否则,方法使用 GetNeighbors() 返回匹配节点的 Neighbors 集合。如果事先知道 GraphNode 对象,此操作将具有 O(1) 的操作成本。然而,由于我们根据特定节点的值在 Graph 层面上检查整个 _nodes 集合,此实现具有 O(n) 的操作成本:
public int GetCount()
{
return _nodes.size();
}
最后,GetCount() 方法通过返回 _nodes.size() 提供对集合中包含的总节点数的只读访问。此字段为我们 Graph 类提供 Count 功能,并具有 O(1) 的操作成本。
Objective-C
Objective-C 不提供具体的 Graph 或 GraphNode 类,但它提供了构建它们所需的基本组件。以下是一个 EDSGraphNode 类在 Objective-C 中的基本实现示例:
@interface EDSGraphNode()
{
NSInteger _value;
NSMutableArray *_neighbors;
}
-(instancetype)initGraphNode
{
if (self = [super init])
{
_neighbors = [NSMutableArray array];
}
return self;
}
-(instancetype)initGraphNodeWithValue:(NSInteger)value
{
if (self = [super init])
{
_value = value;
_neighbors = [NSMutableArray array];
}
return self;
}
-(NSMutableArray*)neighbors
{
return _neighbors;
}
-(NSInteger)value
{
return _value;
}
此类包含两个 ivar 属性,名为 _value 和 _neighbors。_value 属性是一个 NSInteger 对象,用于存储我们的整数数据,而 _neighbors 是一个 NSMutableArray 对象,表示此节点与其邻居之间的边。该类有两个初始化器,两者都实例化了 _neighbors 列表。initGraphNode: 方法仅实例化 _neighbors 数组,而 initGraphNodeWithValue: 还将 _value 设置为传递的值属性。
接下来,我们可以实现我们的图函数。以下是一个 EDSGraph 类在 Objective-C 中的具体实现示例:
@interface EDSGraph()
{
NSMutableArray<EDSGraphNode*>* _nodes;
}
-(NSMutableArray<EDSGraphNode*>*)nodes
{
return _nodes;
}
-(instancetype)initGraphWithNodes:(NSMutableArray<EDSGraphNode *> *)nodes
{
if (self = [super init])
{
if (nodes)
{
_nodes = nodes;
}
else
{
_nodes = [NSMutableArray array];
}
}
return self;
}
我们的EDSGraph类包括一个 ivar 属性,一个名为_nodes的NSMutableArray<EDSGraphNode*>,它维护了到相邻节点的边列表。还有一个名为nodes的方法,它提供了对私有_nodes属性的只读访问。最后,我们的初始化器initGraphWithNodes:(NSMutableArray<EDSGraphNode *> *)nodes接受一个EDSGraphnode数组,如果它不是nil,则将_nodes设置为该值。否则,初始化器方法初始化_nodes集合:
-(NSInteger)countOfNodes
{
return [_nodes count];
}
countOfNodes方法通过返回[_nodes count]来提供对集合中包含的总节点数的只读访问。此方法为我们提供的EDSGraph类提供了计数功能,并且具有O(1)的操作成本:
-(void)addNode:(EDSGraphNode*)node
{
[_nodes addObject:node];
}
-(void)addNodeForValue:(NSInteger)value
{
EDSGraphNode *node = [[EDSGraphNode alloc] initGraphNodeWithValue:value];
[_nodes addObject:node];
}
EDSGraph中的前两个公共方法是addNode:和addNodeForValue:,它们为我们提供的类添加了两种版本的AddNode功能。第一个方法将现有的节点添加到_nodes集合中,而第二个方法使用value实例化一个新的节点,然后将该节点添加到_nodes集合中。这两个方法在添加节点时没有定义任何边,因此这些操作的成本为O(1):
-(BOOL)removeNodeForValue:(NSInteger)value
{
EDSGraphNode *nodeToRemove;
for (EDSGraphNode *n in _nodes)
{
if (n.value == value)
{
nodeToRemove = n;
break;
}
}
if (!nodeToRemove)
{
return NO;
}
[_nodes removeObject:nodeToRemove];
for (EDSGraphNode *n in _nodes)
{
long index = [n.neighbors indexOfObject:nodeToRemove];
if (index != -1)
{
[n.neighbors removeObjectAtIndex:index];
}
}
return YES;
}
removeNodeForValue:方法为我们提供的类提供了移除节点的功能。此方法接受一个类型为NSInteger的参数,命名为value,表示调用者请求移除的节点。方法首先遍历节点集合,寻找与value匹配的对象。如果没有找到匹配项,则方法返回NO。否则,使用removeObject:从_nodes集合中移除匹配的节点,并继续方法执行。
此方法的后半部分遍历集合中的每个节点,检查每个节点的邻居以找到与nodeToRemove匹配的对象。找到匹配项意味着从node到nodeToRemove存在一条边,并返回该匹配项的索引值。通过使用index从node.Neighbors中移除nodeToRemove,我们消除了引用并删除了边。
如我们在图操作讨论中所考察的,RemoveNode操作的操作成本为O(n + k),其中n是集合中节点的数量,k是边的数量。在removeNodeForValue:方法中,方程的前半部分代表n,后半部分代表k:
-(void)addEdgeFromNode:(EDSGraphNode*)from toNode:(EDSGraphNode*)to
{
[from.neighbors addObject:to];
}
-(void)addBidirectionalEdgeFromNode:(EDSGraphNode*)from toNode:(EDSGraphNode*)to
{
[from.neighbors addObject:to];
[to.neighbors addObject:from];
}
addEdgeFromNode:toNode:和addBidirectionalEdgeFromNode:toNode:方法为Graph类提供了添加边的功能。第一个方法是标准的添加边操作,而第二个方法则更方便,如果调用者希望立即添加双向引用。第一个方法的操作成本为O(1),而第二个方法的操作成本为O(2):
-(BOOL)adjacent:(EDSGraphNode*)from toNode:(EDSGraphNode*)to
{
return [from.neighbors containsObject:to];
}
adjacent:toNode: 方法返回一个 BOOL 值,表示两个节点 from 和 to 之间是否存在边。希望这个签名使这条边的方向变得清晰,但为了清晰起见,此方法仅确认从 from 节点到 to 节点存在边,但不确认反向。由于此方法基于 containsObject: 函数,它具有 O(n) 的操作成本,其中 n 是 from.neighbors 中包含的边的数量:
-(NSMutableArray<EDSGraphNode*>*)neighborsOfValue:(NSInteger)value
{
for (EDSGraphNode *n in _nodes)
{
if (n.value == value)
{
return n.neighbors;
}
}
return nil;
}
neighborsOfValue: 方法为我们提供了类中的邻居功能。此方法接受一个类型为 NSInteger 并命名为 value 的参数,表示调用者请求检查的节点。该方法首先遍历节点集合以寻找 value 的匹配项。如果没有找到匹配项,则方法返回 nil。否则,该方法返回匹配节点的 neighbors 集合。如果事先知道 EDSGraphNode 对象,则此操作的成本为 O(1)。然而,由于我们根据特定节点的值在 EDSGraph 级别检查整个 _nodes 集合,因此此实现具有 O(n) 的操作成本。
Swift
与其对应物一样,Swift 默认不提供具体的 Graph 或 GraphNode 类,因此我们需要创建自己的。我们将从 GraphNode 类开始。以下是一个 GraphNode 类在 Swift 中的基本实现示例:
public class GraphNode : Equatable
{
public var neighbors: Array = [GraphNode]()
public var value : Int
public init(val: Int) {
value = val
}
}
public func == (lhs: GraphNode, rhs: GraphNode) -> Bool {
return (lhs.value == rhs.value)
}
这个类扩展了 Equatable。这是为了支持按值和按对象进行搜索。该类包含两个公共属性。第一个是一个名为 neighbors 的 GraphNode 对象数组,它表示节点与其相邻节点之间的边。第二个是一个名为 value 的 Int 变量,它用于存储对象的整数数据。该类有一个自定义构造函数,它接受 Int 并将该值分配给 value 变量。最后,该类定义了一个重载的比较运算符以支持 Equatable 功能。
接下来,我们可以实现我们的图函数。以下是一个 Graph 类在 Swift 中的具体实现示例:
public var nodes: Array = [GraphNode]()
public init(nodes: Array<GraphNode>)
{
self.nodes = nodes
}
我们的 Graph 类包含一个名为 nodes 的公共 Array 属性。该属性维护了到相邻节点的边列表。该类有一个自定义构造函数,它接受一个类型为 Array<GraphNode> 的参数,并将 _nodes 设置为该值,如果它不是 nil。由于 nodes 对象在声明时就已经初始化,因此在这里不需要初始化它:
public func count() -> Int
{
return nodes.count
}
该类中的第一个方法是 count(),它通过返回 nodes.count 提供对集合中包含的总节点数的只读访问。此方法为我们 Graph 类提供计数功能,并且具有 O(1) 的操作成本:
public func addNode(node: GraphNode)
{
nodes.append(node)
}
public func addNodeForValue(value: Int)
{
let node = GraphNode(val: value)
nodes.append(node);
}
在Graph中的下一个两个公共方法AddNode(node: GraphNode)和AddNodeForValue(value: Int),为我们的类添加了两种版本的AddNode功能。第一个将预存在的节点添加到nodes集合中,而第二个使用value实例化一个新的节点,然后将该节点添加到nodes集合中。这两个方法在添加节点时没有定义任何边,因此这些操作的成本为O(1):
public func removeNodeForValue(value: Int) -> Bool
{
var nodeToRemove: GraphNode? = nil
for n in nodes
{
if (n.value == value)
{
nodeToRemove = n;
break
}
}
if (nodeToRemove == nil)
{
return false
}
if let index = nodes.index(of: nodeToRemove!)
{
nodes.remove(at: index)
for n in nodes
{
if let foundIndex = n.neighbors.index(of: nodeToRemove!)
{
n.neighbors.remove(at: foundIndex)
}
}
return true
}
return false
}
removeNodeForValue(value: Int)方法为我们类提供了RemoveNode功能。此方法接受一个类型为Int的参数,命名为value,代表调用者请求删除的节点。方法首先遍历集合中的每个节点,寻找与value对象的匹配项。如果没有找到匹配项,则方法返回false。否则,匹配的节点将从nodes集合中移除,并且方法执行继续。
该方法的后半部分遍历集合中的每个节点,检查每个节点元素的邻居以找到nodeToRemove的匹配项。找到匹配项意味着node和nodeToRemove对象之间存在边,并返回该匹配项的索引值。通过使用index从node.neighbors中移除nodeToRemove,我们消除了引用并删除了边。
如我们在图操作讨论中所考察的,RemoveNode操作的操作成本为O(n + k),其中n是集合中节点的数量,k是边的数量。在removeNodeForValue(value: Int)方法中,方程的前半部分代表n,后半部分代表k:
public func addEdgeFromNodeToNode(from: GraphNode, to: GraphNode)
{
from.neighbors.append(to)
}
public func addBidirectionalEdge(from: GraphNode, to: GraphNode)
{
from.neighbors.append(to)
to.neighbors.append(from)
}
addEdgeFromNodeToNode(from: GraphNode, to: GraphNode)和addBidirectedEdge(from: GraphNode, to: GraphNode)方法为Graph类提供了AddEdge功能。第一个方法是标准的 AddEdge 操作,而第二个方法更像是方便方法,以防调用者想要立即添加双向引用。第一个方法具有O(1)的操作成本,而第二个方法在技术上具有O(2)的操作成本:
public func adjacent(from: GraphNode, to: GraphNode) -> Bool
{
if from.neighbors.index(of: to) != nil
{
return true
}
else
{
return false
}
}
adjacent(from: GraphNode, to: GraphNode)方法返回一个Bool值,表示两个节点from和to之间是否存在边。希望这个签名使这条边的方向清晰,但为了清晰起见,此方法仅确认从from节点到to节点的边存在,但不确认反向。由于此方法基于contains函数,它具有O(n)的操作成本,其中n是from.Neighbors中包含的边的数量:
public func neighborsOfValue(value: Int) -> Array<GraphNode>?
{
for n in nodes
{
if (n.value == value)
{
return n.neighbors
}
}
return nil
}
neighborsOfValue(value: Int) 为我们的类提供了邻居功能。此方法接受一个类型为 Int 的参数,命名为 value,代表调用者请求检查的节点。该方法首先通过遍历节点集合来寻找与 value 匹配的节点。如果没有找到匹配项,则方法返回 nil。否则,该方法返回匹配节点的 neighbors 集合。如果事先知道 GraphNode 对象,则此操作的成本为 O(1)。然而,由于我们根据特定节点的值在 Graph 层面上检查整个 nodes 集合,因此此实现具有 O(n) 的操作成本。
摘要
在本章中,你学习了图数据结构。我们通过图形的视觉表示来更好地理解它们的结构和它们的使用方式。接下来,我们考察了与图操作最常见的情况,并讨论了它们的典型复杂度成本。随后,我们从头开始,在本书中考察的四种语言中,分别创建了自己的简单图节点对象和图数据结构类。
第十二章. 排序:从混乱中带来秩序
能够为特定应用构建正确的数据结构或集合类只是战斗的一半。除非你的问题域中的数据集非常小,否则你的数据集合将受益于一点组织。通过特定的值或值集对列表或集合中的元素进行组织被称为排序。
对数据进行排序并非绝对必要,但这样做可以使搜索或查找操作更加高效。同样,当你需要合并多个数据集时,在合并之前对各种数据集进行排序可以大大提高合并操作的效率。
如果你的数据是一组数值,那么排序可能只是按升序或降序排列它。然而,如果你的数据由复杂对象组成,你可以通过特定的值对集合进行排序。在这种情况下,数据排序所依据的字段或属性被称为键。例如,如果你有一个汽车对象的集合,并且你想按制造商(如福特、雪佛兰或道奇)对其进行排序,那么制造商就是键。然而,如果你想要按多个键排序,比如制造商和型号,那么制造商成为主键,而型号成为次键。这种模式的进一步扩展将导致三级键、四级键等等。
排序算法形态各异,大小不一,其中许多特别适合特定的数据结构。尽管对已知或甚至只是流行的排序算法进行全面考察超出了本书的范围,但在本章中,我们将重点关注那些相对常见或非常适合我们已考察的一些数据结构的算法。在每种情况下,我们将回顾我们一直在查看的四种语言中的示例,并讨论复杂度成本。在本章中,我们将涵盖以下内容:
-
选择排序
-
插入排序
-
冒泡排序
-
快速排序
-
归并排序
-
桶排序
-
计数排序
选择排序
选择排序可以被描述为原地比较。这个算法将一个集合或对象列表分为两部分。第一部分是已经排序的对象子集,范围从0到i,其中i是下一个要排序的对象。第二部分是尚未排序的对象子集,范围从i到n,其中n是集合的长度。
选择排序算法通过在集合中找到最小或最大值,并通过与当前索引的对象交换来将其放置在未排序子数组的开头。例如,考虑按升序对集合进行排序。一开始,已排序的子数组将包含 0 个成员,而未排序的子数组将包含集合中的所有成员。选择排序算法将在未排序的子数组中找到最小的成员,并将其放置在未排序子数组的开头。
到目前为止,已排序的子数组包含一个成员,而未排序的子数组包含原始集合中所有剩余的成员。这个过程将重复进行,直到未排序子数组中的所有成员都被放置在已排序子数组中。
给定以下值集合:
S = {50, 25, 73, 21, 3}
我们的算法将在S[0...4]中找到最小的值,在这个例子中是3,并将其放置在S[0...4]:的开头。
S = {3, 25, 73, 21, 50}
这个过程会重复进行S[1...4],返回值为 21:
S = {3, 21, 73, 25, 50}
在S[2...4]的下一个评估返回值为 25:
S = {3, 21, 25, 73, 50}
最后,函数再次对S[3...4]进行重复,返回最小值 50:
S = {3, 21, 25, 50, 73}
没有必要检查集合中的最后一个对象,因为它,按照必然性,已经是剩余的最大值。然而,这只能算是一点点安慰,因为选择排序算法仍然有O(n²)的复杂度成本。此外,这个最坏情况的复杂度分数并不能完全说明这个特定情况。选择排序始终是O(n²)的复杂度,即使在最佳情况下也是如此。因此,选择排序可能是你可能会遇到的慢速且效率最低的排序算法。
备注
本章中的每个代码示例都将检查算法,以这些方法最基本的形式,这些方法与其父类分离。此外,在每个情况下,要排序的对象集合将在类级别定义,在示例代码之外。同样,后续的对象实例化和这些集合的填充也将定义在示例代码之外。要查看完整的类示例,请使用伴随此文本的代码示例。
C#
public void SelectionSort(int[] values)
{
if (values.Length <= 1)
return;
int j, minIndex;
for (int i = 0; i < values.Length - 1; i++)
{
minIndex = i;
for (j = i + 1; j < values.Length; j++)
{
if (values[j] < values[minIndex])
{
minIndex = j;
}
}
Swap(ref values[minIndex], ref values[i]);
}
}
void Swap(ref int x, ref int y)
{
int t = x;
x = y;
y = t;
}
我们对SelectionSort方法的每个实现都是从确认values数组至少有两个成员开始的。如果没有,该方法将返回,因为没有足够的成员进行排序。否则,我们创建两个嵌套循环。外层for循环每次移动未排序数组的边界一个索引,而内层for循环用于在未排序边界内找到最小值。一旦我们得到最小值,方法就会将i处的成员与当前最小值的成员进行交换。由于 C#默认不支持通过引用传递原始数据类型,我们必须在swap(ref int x, ref int y)方法签名以及调用的参数上显式调用ref关键字。尽管创建一个单独的swap方法来执行此操作可能看起来更麻烦,但交换功能是几种流行排序算法的共同点,将此代码放在单独的方法中可以在以后节省一些按键操作。
提示
嵌套循环
记住,嵌套循环会自动使算法的复杂度呈指数级增加。任何包含for循环的算法都有复杂度成本O(n),但一旦在第一个for循环内嵌套另一个for循环,复杂度成本就增加到O(n²)。在第二个循环内嵌套另一个for循环会使成本增加到O(n³),依此类推。
还要注意,在任何实现中嵌套for循环都会成为观察者注意的红旗,你应该总是准备好为这种设计进行辩护。只有在你绝对必须的时候才嵌套for循环。
Java
public void selectionSort(int[] values)
{
if (values.length <= 1)
return;
int j, minIndex;
for (int i = 0; i < values.length - 1; i++)
{
minIndex = i;
for (j = i + 1; j < values.length; j++)
{
if (values[j] < values[minIndex])
{
minIndex = j;
}
}
int temp = values[minIndex];
values[minIndex] = values[i];
values[i] = temp;
}
}
Java 实现的设计几乎与 C#实现相同,除了数组length函数的名称。然而,Java 根本不支持通过引用传递原始数据类型。尽管可以通过将原始数据类型传递给可变包装类的实例来模拟这种行为,但大多数开发者都认为这是一个坏主意。相反,我们的 Java 实现直接在for循环内执行交换。
Objective-C
-(void)selectionSort:(NSMutableArray<NSNumber*>*)values
{
if ([values count] <= 1)
return;
NSInteger j, minIndex;
for (int i = 0; i < [values count] - 1; i++)
{
minIndex = i;
for (j = i + 1; j < [values count]; j++)
{
if ([values[j] intValue] < [values[minIndex] intValue])
{
minIndex = j;
}
}
NSNumber *temp = (NSNumber*)values[minIndex];
values[minIndex] = values[i];
values[i] = temp;
}
}
由于NSArray只能存储对象,我们需要将我们的值转换为NSNumber,当我们评估成员时,需要显式检查intValue对象。像 Java 一样,我们选择不创建一个单独的交换方法,而是通过引用传递值。否则,实现方式。
Swift
open func selectionSort( values: inout [Int])
{
if (values.count <= 1)
{
return
}
var minIndex: Int
for i in 0..<values.count
{
minIndex = i
for j in i+1..<values.count
{
if (values[j] < values[minIndex])
{
minIndex = j
}
}
swap(x: &values[minIndex], y: &values[i])
}
}
open func swap( x: inout Int, y: inout Int)
{
let t: Int = x
x = y
y = t
}
Swift 不允许使用 C 样式的for循环,因此我们的方法必须使用 Swift 3.0 的等效方法。此外,由于 Swift 将数组视为struct实现而不是类实现,因此values参数不能简单地通过引用传递。因此,我们的 Swift 实现包括在values参数上使用inout修饰符。否则,功能与其前辈基本相同。此规则也适用于我们的swap(x: inout Int, y: inout Int)方法,该方法用于在排序过程中交换值。
插入排序
插入排序是一个非常简单的算法,它查看集合中的一个对象,并将其键与它之前的键进行比较。您可以将这个过程想象成我们中多少人按顺序排列一副扑克牌,逐个从左到右按升序移除和插入卡片。
例如,考虑按升序对集合进行排序的情况。插入排序算法将检查索引i处的对象,并确定其键值是否低于或优先于索引i - 1处的对象。如果是这样,索引i处的对象将被移除并插入到i - 1处。此时,函数将重复并继续以这种方式循环,直到i - 1处的对象键值不低于i处的对象键值。
给定以下值集:
S = {50, 25, 73, 21, 3}
我们将开始检查列表的i = 1。我们这样做是因为在i = 0处,i - 1是一个不存在的值,需要特殊处理。
由于 25 小于 50,因此它被移除并重新插入到i = 0的位置。由于我们处于索引 0,因此没有东西可以检查 25 左侧的,所以这次迭代完成:
S = {25, 50, 73, 21, 3}
接下来我们检查i = 2。由于 73 不小于 50,因此这个值不需要移动。由于我们已经将i = 2左侧的所有东西都排序好了,所以这次迭代立即完成。在i = 3处,值 21 小于 73,因此它被移除并重新插入到i = 2。再次检查,21 小于 50,所以值 21 被移除并重新插入到索引 1。最后,21 小于 25,所以值 21 被移除并重新插入到i = 0。由于我们现在处于索引 0,因此没有东西可以检查 21 左侧的,所以这次迭代完成:
S = {21, 25, 50, 73, 3}
最后,我们到达列表的i = 4,即列表的末尾。由于 3 小于 21,因此值 3 被移除并重新插入到i = 3。接下来,3 小于 73,所以值 3 被移除并重新插入到i = 2。在i = 2处,3 小于 50,所以值 3 被移除并重新插入到i = 1。在i = 1处,3 小于 25,所以值 3 被移除并重新插入到i = 0。由于我们现在处于索引 0,因此没有东西可以检查 3 左侧的,所以这次迭代和我们的排序函数都完成了:
S = {3, 21, 25, 50, 73}
如您所见,这个算法简单,但对于较大的对象或值列表来说可能成本较高。插入排序在最坏情况和平均情况下的复杂度都是O(n²)。然而,与选择排序不同,当对先前已排序的列表进行排序时,插入排序的效率有所提高。因此,它具有最佳复杂度O(n),这使得该算法比选择排序略好一些。
C#
public void InsertionSort(int[] values)
{
if (values.Length <= 1)
return;
int j, value;
for (int i = 1; i < values.Length; i++)
{
value = values[i];
j = i - 1;
while (j >= 0 && values[j] > value)
{
values[j + 1] = values[j];
j = j - 1;
}
values[j + 1] = value;
}
}
我们对InsertionSort方法的每个实现都首先确认values数组至少有两个成员。如果没有,则方法返回,因为没有足够的成员进行排序。否则,声明两个名为j和value的整数变量。接下来创建一个for循环,遍历集合的成员。索引i用于跟踪最后排序成员的位置。在这个for循环中,value被分配给最后排序的成员,而j用于跟踪当前迭代中未排序成员的位置。在我们的while循环中,value被分配给索引j处的成员,而j用于跟踪当前迭代中未排序成员的位置。我们的while循环将继续,直到j等于0且索引j处的值大于索引i处的值。在while循环的每次迭代中,我们将位置j处的成员与位置j + 1处的成员交换,然后循环将j的值减 1,以便在集合中回溯。最后一步是将存储在value中的成员设置在位置j + 1。
Java
public void insertionSort(int[] values)
{
if (values.length <= 1)
return;
int j, value;
for (int i = 1; i < values.length; i++)
{
value = values[i];
j = i - 1;
while (j >= 0 && values[j] > value)
{
values[j + 1] = values[j];
j = j - 1;
}
values[j + 1] = value;
}
}
Java 实现的设计几乎与 C#实现相同,只是数组length函数的名称不同。
Objective-C
-(void)insertionSort:(NSMutableArray<NSNumber*>*)values
{
if ([values count] <= 1)
return;
NSInteger j, value;
for (int i = 1; i < [values count]; i++)
{
value = [values[i] intValue];
j = i - 1;
while (j >= 0 && [values[j] intValue] > value)
{
values[j + 1] = values[j];
j = j - 1;
}
values[j + 1] = [NSNumber numberWithInteger:value];
}
}
由于NSArray只能存储对象,我们需要将我们的值转换为NSNumber变量,并且在评估成员时需要显式检查intValue变量。否则,此实现与 C#或 Java 实现基本相同。
Swift
open func insertionSort( values: inout [Int])
{
if (values.count <= 1)
{
return
}
var j, value: Int
for i in 1..<values.count
{
value = values[i];
j = i - 1;
while (j >= 0 && values[j] > value)
{
values[j + 1] = values[j];
j = j - 1;
}
values[j + 1] = value;
}
}
Swift 不允许使用 C 风格的for循环,因此我们的方法必须使用 Swift 3.0 的等效方法。此外,由于 Swift 将数组视为结构体实现而不是类实现,因此values参数不能简单地通过引用传递。因此,我们的 Swift 实现包括在values参数上使用inout修饰符。否则,其功能与前辈基本相同。
冒泡排序
冒泡排序是另一种简单的算法,它通过遍历要排序的值或对象的列表,并比较相邻项或它们的键来确定它们是否处于错误的顺序。这个名字来源于无序项似乎会冒到列表顶部的样子。然而,一些开发者有时将其称为下沉排序,因为对象也可能看起来像是从列表中掉下来的。
总体来说,冒泡排序只是另一种低效的比较排序算法。然而,它确实具有其他比较排序算法所没有的一个显著优点,那就是:内在地确定列表是否已排序。冒泡排序通过不在之前迭代中已排序的对象上执行比较,并在集合被证明有序后停止,来实现这一点。
例如,考虑按升序对集合进行排序的情况。冒泡排序算法将检查索引 i 处的对象,并确定其键值是否低于或优先级低于索引 i + 1 处的对象,如果是这样,则交换这两个对象。
给定以下值集:
S = {50, 25, 73, 21, 3}
冒泡排序算法将比较{i = 0, i = 1}。由于 50 大于 25,所以这两个数被交换。接下来,方法比较{i = 1, i = 2}。在这种情况下,50 小于 73,所以没有变化。在{i = 2, i = 3}时,73 大于 21,所以它们被交换。最后,在{i = 3, i = 4}时,73 大于 3,所以它们也被交换。在我们的第一次迭代之后,我们的集合现在看起来是这样的:
S = {25, 50, 21, 3, 73}
让我们检查另一个迭代。在这个迭代中,我们的算法将首先比较{i = 0, i = 1}),由于 25 小于 50,所以没有变化。接下来,我们检查{i = 1, i = 2})。由于 50 大于 21,所以这两个数被交换。在{i = 2, i = 3}时,50 大于 3,所以这两个数被交换。由于在之前的迭代中i = 4已被排序,循环停止并重置到i = 0以进行下一次迭代。在第二次迭代之后,我们的集合看起来是这样的:
S = {25, 21, 3, 50, 73}
这表明通过集合的迭代包括n - j次比较,其中n是集合中项目数,j是当前迭代计数。因此,每次迭代后,冒泡排序都会变得稍微更有效率。此外,一旦集合被证明已排序,迭代就会完全停止。尽管冒泡排序的最坏情况和平均情况复杂度为O(n²),但将排序限制为未排序的对象的能力为算法提供了O(n)的最佳情况复杂度,这使得这种方法略优于选择排序,但与插入排序大致相等。在列表已经排序的某些情况下,冒泡排序也比快速排序(我们稍后讨论)稍微有效率。然而,冒泡排序仍然是一个非常低效的算法,不适合除了小型对象集合之外的所有情况。
C#
public void BubbleSort(int[] values)
{
bool swapped;
for (int i = 0; i < values.Length - 1; i++)
{
swapped = false;
for (int j = values.Length - 1; j > i; j--)
{
if (values[j] < values[j - 1])
{
Swap(ref values[j], ref values[j - 1]);
swapped = true;
}
}
if (swapped == false)
break;
}
}
我们对BubbleSort方法的每个实现都是从声明一个名为swapped的布尔值开始的。这个值对于优化的冒泡排序方法至关重要,因为它用于跟踪当前迭代过程中是否有任何对象被交换。如果为true,则不能保证列表已排序,因此至少还需要进行一次迭代。如果为false,则没有对象被交换,这意味着列表已排序,算法可以立即停止。
接下来,我们创建一个for循环,遍历集合的成员。这个循环有效地跟踪我们的当前迭代。在这个循环内部,我们立即将swapped变量设置为false,然后创建另一个内部循环,它通过集合向后移动,对成对的对象进行比较。如果成对的两个对象被判定为顺序错误,BubbleSort()方法调用在选择排序讨论中检查的相同swap()方法,并将swapped改为true。否则,执行继续到j的下一个迭代。一旦内部循环完成,方法检查swapped变量以确定是否有对象被排序。如果为false,则执行继续到i的下一个迭代。否则,方法跳出外部循环,执行结束。
Java
public void bubbleSort(int[] values)
{
boolean swapped;
for (int i = 0; i < values.length - 1; i++)
{
swapped = false;
for (int j = values.length -1; j > i; j--)
{
if (values[j] < values[j - 1])
{
int temp = values[j];
values[j] = values[j - 1];
values[j - 1] = temp;
swapped = true;
}
}
if (swapped == false)
break;
}
}
Java 实现的设计几乎与 C#实现相同,只是数组length函数的名称不同。然而,Java 根本不支持通过引用传递原始数据类型。尽管可以通过将原始数据类型传递给可变包装类的实例来模拟这种行为,但大多数开发者都认为这是一个糟糕的想法。相反,我们的 Java 实现直接在for循环内部执行交换。
Objective-C
-(void)bubbleSortArray:(NSMutableArray<NSNumber*>*)values
{
bool swapped;
for (NSInteger i = 0; i < [values count] - 1; i++)
{
swapped = false;
for (NSInteger j = [values count] - 1; j > i; j--)
{
if (values[j] < values[j - 1])
{
NSInteger temp = [values[j] intValue];
values[j] = values[j - 1];
values[j - 1] = [NSNumber numberWithInteger:temp];
swapped = true;
}
}
if (swapped == false)
break;
}
}
由于NSArray变量只能存储对象,我们需要将我们的值转换为NSNumber,在评估成员时需要显式检查intValue。与 Java 类似,我们选择不创建单独的交换方法,并通过引用传递值。否则,此实现与 C#或 Java 实现基本相同。
Swift
open func bubbleSort( values: inout [Int])
{
var swapped: Bool
for i in 0..<values.count - 1
{
swapped = false
for j in ((i + 1)..<values.count).reversed()
{
if (values[j] < values[j - 1])
{
swap(x: &values[j], y: &values[j - 1])
swapped = true
}
}
if (swapped == false)
{
break
}
}
}
Swift 不允许使用 C 风格的for循环,因此我们的方法必须使用 Swift 3.0 的等效方法。此外,由于 Swift 将数组视为结构体实现而不是类实现,values参数不能简单地通过引用传递。因此,我们的 Swift 实现包括在values参数上的inout修饰符。否则,其功能与前辈基本相同。这个规则也适用于我们的swap(x: inout Int, y: inout Int)方法,该方法在排序过程中用于交换值。
快速排序
快速排序是被称为分而治之算法集合中的一员。分而治之算法通过递归地将一组对象分解成两个或更多的子集,直到每个子集足够简单,可以直接解决。在快速排序的情况下,算法选择一个称为基准点的元素,然后通过将其前的所有较小元素和其后的所有较大元素进行排序。在基准点前后移动元素是快速排序算法的主要组成部分,被称为分区。分区在越来越小的子集上递归重复,直到每个子集包含 0 或 1 个元素,此时集合是有序的。
在保持快速排序改进性能方面,选择正确的枢轴点至关重要。例如,选择列表中的最小或最大元素将导致O(n²)复杂度。尽管没有万无一失的方法来选择最佳枢轴,但你的设计可以采取以下四种基本方法:
-
总是选择集合中的第一个对象。
-
总是选择集合中的中值对象。
-
总是选择集合中的最后一个对象。
-
从集合中随机选择一个对象。
在以下示例中,我们将采取第三种方法,选择集合中的最后一个对象作为枢轴。
尽管快速排序算法的最坏情况复杂度与其他我们迄今为止检查的排序一样,为O(n²),但它具有改进的平均和最佳情况复杂度O(n log(n)),这使得它平均而言比选择排序、插入排序和冒泡排序方法更好。
C#
public void QuickSort(int[] values, int low, int high)
{
if (low < high)
{
int index = Partition(values, low, high);
QuickSort(values, low, index -1);
QuickSort(values, index +1, high);
}
}
int Partition(int[] values, int low, int high)
{
int pivot = values[high];
int i = (low - 1);
for (int j = low; j <= high -1; j++)
{
if (values[j] <= pivot)
{
i++;
Swap(ref values[i], ref values[j]);
}
}
i++;
Swap(ref values[i], ref values[high]);
return i;
}
我们对QuickSort方法的每个实现都是从检查低索引是否小于高索引开始的。如果为false,子集为空或有单个项目,因此根据定义它是有序的,方法返回。如果为true,方法首先通过调用Partition(int[] values, int low, int high)方法确定子集下一次划分的index。接下来,基于index定义的上下子集上递归调用QuickSort(int[] values, int low, int high)方法。
这个算法真正的魔力发生在Partition(int[] values, int low, int high)方法中。在这里,定义了一个用于枢轴的index变量,在我们的例子中是集合中的最后一个对象。接下来,i被定义为low索引-1。然后,我们的算法从low到high -1遍历列表。在循环中,如果i处的值小于或等于枢轴,我们就增加i,这样我们就有了集合中第一个未排序对象的索引,然后我们将其与j处的对象交换,j处的对象小于枢轴。
一旦循环完成,我们就将i增加一次,因为i + 1是集合中第一个大于枢轴的对象,而i + 1之前的所有对象都小于枢轴。我们的方法交换i处的值和索引high处的枢轴对象,这样枢轴也被正确排序。最后,方法返回i,这是QuickSort(int[] values, int low, int high)方法的下一个断点索引。
Java
public void quickSort(int[] values, int low, int high)
{
if (low < high)
{
int index = partition(values, low, high);
quickSort(values, low, index - 1);
quickSort(values, index + 1, high);
}
}
int partition(int[] values, int low, int high)
{
int pivot = values[high];
int i = (low - 1);
for (int j = low; j <= high - 1; j++)
{
if (values[j] <= pivot)
{
i++;
int temp = values[i];
values[i] = values[j];
values[j] = temp;
}
}
i++;
int temp = values[i];
values[i] = values[high];
values[high] = temp;
return i;
}
Java 实现的设计几乎与 C#实现相同,只是数组length函数的名称不同。然而,Java 根本不支持通过引用传递原始数据。尽管可以通过将原始数据传递给可变包装类的实例来模拟这种行为,但大多数开发者都认为这是一个坏主意。相反,我们的 Java 实现直接在for循环内和该方法本身执行交换操作。
Objective-C
-(void)quickSortArray:(NSMutableArray<NSNumber*>*)values forLowIndex:(NSInteger)low andHighIndex:(NSInteger)high
{
if (low < high)
{
NSInteger index = [self partitionArray:values forLowIndex:low andHighIndex:high];
[self quickSortArray:values forLowIndex:low andHighIndex:index - 1];
[self quickSortArray:values forLowIndex:index + 1 andHighIndex:high];
}
}
-(NSInteger)partitionArray:(NSMutableArray<NSNumber*>*)values forLowIndex:(NSInteger)low andHighIndex:(NSInteger)high
{
NSInteger pivot = [values[high] intValue];
NSInteger i = (low - 1);
for (NSInteger j = low; j <= high - 1; j++)
{
if ([values[j] intValue] <= pivot)
{
i++;
NSInteger temp = [values[i] intValue];
values[i] = values[j];
values[j] = [NSNumber numberWithInteger:temp];
}
}
i++;
NSInteger temp = [values[i] intValue];
values[i] = values[high];
values[high] = [NSNumber numberWithInteger:temp];
return i;
}
由于 NSArray 变量只能存储对象,我们需要将我们的值转换为 NSNumber,在评估成员时需要显式检查 intValue。像 Java 一样,我们选择不创建单独的交换方法并通过引用传递值。否则,这个实现与 C# 或 Java 实现基本相同。
Swift
open func quickSort( values: inout [Int], low: Int, high: Int)
{
if (low < high)
{
let index: Int = partition( values: &values, low: low, high: high)
quickSort( values: &values, low: low, high: index - 1)
quickSort( values: &values, low: index + 1, high: high)
}
}
func partition( values: inout [Int], low: Int, high: Int) -> Int
{
let pivot: Int = values[high]
var i: Int = (low - 1)
var j: Int = low
while j <= (high - 1)
{
if (values[j] <= pivot)
{
i += 1
swap(x: &values[i], y: &values[j])
}
j += 1
}
i += 1
swap(x: &values[i], y: &values[high])
return i;
}
Swift 不允许使用 C 风格的 for 循环,因此我们的 Swift 3.0 版本的 mergeSort: 方法在这方面有些受限。因此,我们将使用 while 循环来替换 for 循环。这样,我们定义 j 为 low 索引值,并在 while 循环的每次迭代中显式地增加 j。另外,由于 Swift 将数组视为结构体实现而不是类实现,values 参数不能简单地通过引用传递。因此,我们的 Swift 实现包括在 values 参数上使用 inout 装饰器。否则,其功能与前辈们基本相同。这个规则也适用于我们的 swap(x: inout Int, y: inout Int) 方法,该方法用于在排序过程中交换值。
归并排序
归并排序是分治算法的另一种流行版本。它是一个非常高效、通用的排序算法。算法的命名来源于它将集合分成两半,递归地对每个半集合进行排序,然后将两个排序后的半集合合并在一起。集合的每个半部分都会反复分成一半,直到只剩下一个对象,此时根据定义进行排序。在合并每个排序后的半部分时,算法会比较对象以确定每个子集的放置位置。
就分治算法而言,归并排序是最有效的算法之一。该算法的最坏、平均和最佳情况复杂度为 O(n log(n)),即使在最坏情况下也优于快速排序。
C#
public void MergeSort(int[] values, int left, int right)
{
if (left == right)
return;
if (left < right)
{
int middle = (left + right) / 2;
MergeSort(values, left, middle);
MergeSort(values, middle + 1, right);
int[] temp = new int[values.Length];
for (int n = left; n <= right; n++)
{
temp[n] = values[n];
}
int index1 = left;
int index2 = middle + 1;
for (int n = left; n <= right; n++)
{
if (index1 == middle + 1)
{
values[n] = temp[index2++];
}
else if (index2 > right)
{
values[n] = temp[index1++];
}
else if (temp[index1] < temp[index2])
{
values[n] = temp[index1++];
}
else
{
values[n] = temp[index2++];
}
}
}
}
在我们 MergeSort 方法的每个实现中,left 和 right 参数定义了整体 values 数组中集合的开始和结束位置。当方法最初被调用时,left 参数应该是 0,而 right 参数应该是 values 集合中最后一个对象的索引。
该方法首先检查 left 索引是否等于 right 索引。如果是 true,子集为空或只有一个项目,因此根据定义是有序的,方法返回。否则,方法检查 left 索引是否小于 right 索引。如果是 false,方法返回,因为该子集已经是有序的。
如果为true,方法执行将真正开始。首先,方法确定当前子集的中点,因为这将被用来将子集分成两个新的 halves。声明并定义middle变量,通过将left和right相加然后除以 2。接下来,通过传递值数组和使用left、right和middle作为指南,递归地调用每个 halves 的MergeSort(int[] values, int left, int right)方法。随后,方法创建一个名为temp的新数组,其大小与values相同,并仅填充与当前子集相关的索引。一旦temp数组被填充,方法创建两个名为index1和index2的int变量,它们代表当前子集内两个 halves 的起始点。
最后,我们到达for循环,它从开始到结束(left到right)遍历子集并对找到的值进行排序。每个if语句中的逻辑是显而易见的,但了解这些特定比较背后的推理是有帮助的:
-
第一次比较仅在左子集耗尽值时为
true,此时将values[n]数组设置为temp[index2]的值。随后,使用后增量运算符,index2变量增加 1,将指针在右子集内向右移动一个索引。 -
第二次比较仅在右子集耗尽值时为
true,此时将values[n]数组设置为temp[index1]的值。随后,使用后增量运算符,index1变量增加 1,将指针在左子集内向右移动一个索引。 -
第三次也是最后一次比较仅在左右子集都有尚未排序的值时才会评估。当
temp[index1]数组中的值小于temp[index2]数组中的值时,此比较为true,此时将values[n]数组设置为temp[index1]。同样,随后,使用后增量运算符,index1变量增加 1,将指针在左子集内向右移动一个索引。 -
最后,当所有其他逻辑选项都无效时,默认行为假定
temp[index1]数组中的值大于temp[index2]数组中的值,因此 else 块将values[n]数组中的值设置为temp[index2]。随后,使用后增量运算符,index2变量增加 1,将指针在右子集内向右移动一个索引。
Java
public void mergeSort(int[] values, int left, int right)
{
if (left == right)
return;
if (left < right)
{
int middle = (left + right) / 2;
mergeSort(values, left, middle);
mergeSort(values, middle + 1, right);
int[] temp = new int[values.length];
for (int n = left; n <= right; n++)
{
temp[n] = values[n];
}
int index1 = left;
int index2 = middle + 1;
for (int n = left; n <= right; n++)
{
if (index1 == middle + 1)
{
values[n] = temp[index2++];
}
else if (index2 > right)
{
values[n] = temp[index1++];
}
else if (temp[index1] < temp[index2])
{
values[n] = temp[index1++];
}
else
{
values[n] = temp[index2++];
}
}
}
}
Java 实现的设计几乎与 C#实现相同,只是数组length函数的名称不同。
Objective-C
-(void)mergeSort:(NSMutableArray*)values withLeftIndex:(NSInteger)left andRightIndex:(NSInteger)right
{
if (left == right)
return;
if (left < right)
{
NSInteger middle = (left + right) / 2;
[self mergeSort:values withLeftIndex:left andRightIndex:middle];
[self mergeSort:values withLeftIndex:middle + 1 andRightIndex:right];
NSMutableArray *temp = [NSMutableArray arrayWithArray:values];
NSInteger index1 = left;
NSInteger index2 = middle + 1;
for (NSInteger n = left; n <= right; n++)
{
if (index1 == middle + 1)
{
values[n] = temp[index2++];
}
else if (index2 > right)
{
values[n] = temp[index1++];
}
else if (temp[index1] < temp[index2])
{
values[n] = temp[index1++];
}
else
{
values[n] = temp[index2++];
}
}
}
}
mergeSort:withLeftIndex:andRightIndex:的 Objective-C 实现与 C#和 Java 实现基本相同。
Swift
open func mergeSort( values: inout [Int], left: Int, right: Int)
{
if (values.count <= 1)
{
return
}
if (left == right)
{
return
}
if (left < right)
{
let middle: Int = (left + right) / 2
mergeSort(values: &values, left: left, right: middle)
mergeSort(values: &values, left: middle + 1, right: right)
var temp = values
var index1: Int = left
var index2: Int = middle + 1
for n in left...right
{
if (index1 == middle + 1)
{
values[n] = temp[index2]
index2 += 1
}
else if (index2 > right)
{
values[n] = temp[index1]
index1 += 1
}
else if (temp[index1] < temp[index2])
{
values[n] = temp[index1]
index1 += 1
}
else
{
values[n] = temp[index2]
index2 += 1
}
}
}
}
Swift 不允许使用 C 风格的for循环,因此我们的方法与 Swift 3.0 的等效方法在此情况下有些受限。由于 Swift 将数组视为结构体实现而不是类实现,values参数不能简单地通过引用传递。这对于这个归并排序实现来说并不一定是问题,因为每当方法递归调用时,整个values数组都会作为参数传递。然而,为了使该方法与其他在此讨论的算法更一致,并避免需要声明返回类型,此实现仍然在values参数上包含了inout修饰符。否则,其功能与前辈们基本相同。
桶排序
桶排序,也称为箱排序,是一种分布排序算法。分布排序是那些将原始值散布到任何中间结构中的算法,然后对这些中间结构进行排序、收集和合并到最终输出结构中的算法。需要注意的是,尽管桶排序被认为是分布排序,但大多数实现通常利用比较排序来对桶的内容进行排序。该算法通过在整个数组数组(称为桶)中分配值来排序值。元素根据其值和分配给每个桶的值范围进行分配。例如,如果一个桶接受从 5 到 10 的值范围,原始集合包括 3、5、7、9 和 11,那么值 5、7 和 9 将放入这个假设的桶中。
一旦所有值都分配到各自的桶中,然后通过递归调用桶排序算法再次对桶本身进行排序。最终,每个桶都被排序,然后排序结果被连接成一个完整的排序集合。
由于元素分配到桶的方式,桶排序可以比其他排序算法快得多,通常每个桶使用一个数组,其中值表示索引。尽管该算法仍然具有O(n²)的最坏情况复杂度,但平均和最佳情况复杂度仅为O(n + k),其中n是原始数组中的元素数量,k是用于排序集合的总桶数。
C#
public void BucketSort(int[] values, int maxVal)
{
int[] bucket = new int[maxVal + 1];
int num = values.Length;
int bucketNum = bucket.Length;
for (int i = 0; i < bucketNum; i++)
{
bucket[i] = 0;
}
for (int i = 0; i < num; i++)
{
bucket[values[i]]++;
}
int pos = 0;
for (int i = 0; i < bucketNum; i++)
{
for (int j = 0; j < bucket[i]; j++)
{
values[pos++] = i;
}
}
}
我们对BucketSort方法的每个实现都是从根据values数组中的元素总数创建空桶开始的。接下来,使用for循环将基础值0填充到桶中。这立即被第二个for循环所跟随,该循环将元素从values分配到各个桶中。最后,使用嵌套for循环对桶中的元素以及values数组本身进行排序。
Java
public void BucketSort(int[] values, int maxVal)
{
int[] bucket = new int[maxVal + 1];
int num = values.length;
int bucketNum = bucket.length;
for (int i = 0; i < bucketNum; i++)
{
bucket[i] = 0;
}
for (int i = 0; i < num; i++)
{
bucket[values[i]]++;
}
int pos = 0;
for (int i = 0; i < bucketNum; i++)
{
for (int j = 0; j < bucket[i]; j++)
{
values[pos++] = i;
}
}
}
Java 实现的设计几乎与 C#实现相同,只是数组的length函数名称不同。
Objective-C
-(void)bucketSortArray:(NSMutableArray<NSNumber*>*)values withMaxValue:(NSInteger)maxValue
{
NSMutableArray<NSNumber*>*bucket = [NSMutableArray array];
NSInteger num = [values count];
NSInteger bucketNum = maxValue + 1;
for (int i = 0; i < bucketNum; i++)
{
[bucket insertObject:[NSNumber numberWithInteger:0] atIndex:i];
}
for (int i = 0; i < num; i++)
{
NSInteger value=[bucket[[values[i] intValue]] intValue]+ 1;
bucket[[values[i] intValue]] = [NSNumber numberWithInteger:value];
}
int pos = 0;
for (int i = 0; i < bucketNum; i++)
{
for (int j = 0; j < [bucket[i] intValue]; j++)
{
values[pos++] = [NSNumber numberWithInteger:i];
}
}
}
由于NSArray数组只能存储对象,我们需要将我们的值转换为NSNumber数组,并且在评估成员时需要显式检查intValue变量。否则,这种实现与 C#或 Java 实现的基本上是相同的。
Swift
open func bucketSort( values: inout [Int], maxVal: Int)
{
var bucket = [Int]()
let num: Int = values.count
let bucketNum: Int = bucket.count
for i in 0..<bucketNum
{
bucket[i] = 0
}
for i in 0..<num
{
bucket[values[i]] += 1
}
var pos: Int = 0
for i in 0..<bucketNum
{
for _ in 0..<bucket[i]
{
values[pos] = i
pos += 1
}
}
}
Swift 不允许使用 C 风格的for循环,因此我们的方法必须使用 Swift 3.0 的等效方法。否则,其功能与其前辈基本相同。
摘要
在本章中,我们讨论了你在日常经验中可能会遇到的几种常见排序算法。我们首先介绍了几种比较排序,包括选择排序、插入排序和冒泡排序。我们指出,选择排序可能是你在现实生活中可能遇到的最不高效的排序算法,但这并不意味着它是完全学术性的。插入排序在某种程度上改进了选择排序,冒泡排序算法也是如此。接下来,我们考察了两种分而治之的排序算法,包括快速排序和归并排序。这两种方法都比比较排序更高效。最后,我们探索了一种常见且高效的分布排序,称为计数排序。计数排序是我们考察过的最有效率的算法,但它并不一定适合所有情况。
第十三章。搜索:找到你需要的东西
对你的集合进行排序可能会很昂贵,但通常这代表在创建集合后的单次成本。然而,在应用程序运行周期中,这种前期的时间和精力投入可以显著提高性能。即使添加新对象,当它被添加到已排序的集合中时,这个过程也要便宜得多。
当需要搜索你的集合以查找特定元素或值时,真正的性能提升才会到来。在本章中,我们将探讨排序集合如何根据你选择的搜索算法大大提高搜索时间。我们不会讨论你可以选择的所有搜索算法,但我们将检查三种最常见的算法:
-
线性搜索(顺序搜索)
-
二分搜索
-
跳转搜索
线性搜索
搜索,也称为顺序搜索,简单地说就是通过某种比较函数遍历一个集合,以定位匹配的元素或值。大多数线性搜索返回一个表示集合中匹配对象的索引的值,或者当对象未找到时,返回一些不可能的索引值,例如-1。这个搜索的替代版本可以返回对象本身,或者当对象未找到时返回null。
这是最简单的搜索模式,它具有O(n)的复杂度成本。这种复杂度在集合是无序的还是已经排序的情况下都是一致的。在非常小的集合中,线性搜索是完全可接受的,许多开发者每天都在使用它们。然而,当处理非常大的集合时,找到这种顺序搜索方法的替代方案通常是有益的。这尤其适用于处理非常复杂的对象列表,例如空间几何,其中搜索或分析可能是非常耗处理器的操作。
注意
本章中的每个代码示例都将通过操作中最基本的方法形式来检查搜索算法,这些方法与它们的父类分离。此外,在每个情况下,将要排序的对象集合将在类级别上定义,在下面展示的示例代码之外。同样,后续的对象实例化和这些集合的填充也将定义在示例代码之外。要查看完整的类示例,请使用伴随此文本的代码示例。
C#
线性搜索算法的第一个示例是在LinearSearchIndex(int[] values, int key)方法中。正如你所看到的,这个方法非常简单,几乎是自我解释的。这个实现有两个主要特点值得提及。首先,该方法接受values数组(值)和搜索key。其次,该方法返回任何匹配元素的索引i,或者如果搜索键未找到,则简单地返回-1。
public int LinearSearchIndex(int[] values, int key)
{
for (int i = 0; i < values.Length - 1; i++)
{
if (values[i] == key)
{
return i;
}
}
return -1;
}
线性搜索的第二个例子几乎与第一个相同。然而,在LinearSearchCustomer(Customer[] customers, int custId)方法中,我们不是在搜索一个值,而是在搜索一个代表调用者想要检索的客户的键。请注意,现在的比较是在Customer对象的customerId字段上进行的;如果找到匹配项,则返回customers[i]处的Customer。如果没有找到匹配项,该方法返回null:
public Customer LinearSearchCustomer(Customer[] customers, int custId)
{
for (int i = 0; i < customers.Length - 1; i++)
{
if (customers[i].customerId == custId)
{
return customers[i];
}
}
return null;
}
Java
每个方法的 Java 实现的设计几乎与 C#实现相同,只是数组的length函数的名称不同。
public int linearSearchIndex(int[] values, int key)
{
for (int i = 0; i < values.length - 1; i++)
{
if (values[i] == key)
{
return i;
}
}
return -1;
}
public Customer linearSearchCustomer(Customer[] customers, int custId)
{
for (int i = 0; i < customers.length - 1; i++)
{
if (customers[i].customerId == custId)
{
return customers[i];
}
}
return null;
}
Objective-C
由于NSArray只能存储对象,我们需要将我们的值转换为NSNumber,在评估成员时,我们需要显式检查intValue。否则,这些实现与 C#或 Java 实现基本相同:
-(NSInteger)linearSearchArray:(NSMutableArray<NSNumber*>*)values byKey:(NSInteger) key
{
for (int i = 0; i < [values count] - 1; i++)
{
if ([values[i] intValue] == key)
{
return i;
}
}
return -1;
}
-(EDSCustomer*)linearSearchCustomers:(NSMutableArray<NSNumber*>*)customers byCustId:(NSInteger)custId
{
for (EDSCustomer *c in customers)
{
if (c.customerId == custId)
{
return c;
}
}
return nil;
}
Swift
Swift 不允许 C 样式的for循环,因此我们的方法必须使用 Swift 3.0 的等效方法。此外,Swift 不允许方法返回nil,除非返回类型明确声明为可选,因此linearSearchCustomer(customers: [Customer], custId: Int)方法的返回类型为Customer?。否则,其功能与其前辈基本相同:
open func linearSearhIndex( values: [Int], key: Int) -> Int
{
for i in 0..<values.count
{
if (values[i] == key)
{
return i
}
}
return -1
}
open func linearSearchCustomer( customers: [Customer], custId: Int) -> Customer?
{
for i in 0..<customers.count
{
if (customers[i].custId == custId)
{
return customers[i]
}
}
return nil
}
二分搜索
当处理未排序的集合时,顺序搜索可能是最合理的方法。然而,当与排序集合一起工作时,有更好的方法来找到与搜索键匹配的方法。一个替代方案是二分搜索。二分搜索通常实现为一个递归函数,其工作原理是反复将集合分成两半,并搜索越来越小的集合块,直到找到匹配项或搜索耗尽剩余选项并返回空。
例如,给定以下有序值集合:
S = {8, 19, 23, 50, 75, 103, 121, 143, 201}
使用线性搜索查找值143将具有O(8)的复杂度成本,因为143在我们的集合中位于索引 7(位置 8)。然而,二分搜索可以利用集合的排序特性来提高这种复杂度成本。
我们知道集合由九个元素组成,因此二分搜索将首先检查索引 5 的中值元素,并将其与键值143进行比较。由于i[5] = 75,这小于143,因此集合被分割,可能的匹配项的范围仅包括上半部分,留下:
S = {103, 121, 143, 201}
对于四个元素,中值元素是位置二的元素。位置i[2] = 121,这小于143,因此集合被分割,可能的匹配项的范围仅包括上半部分,留下:
S = {143, 201}
使用两个元素时,中值元素是位置一的元素。由于i[1] = 143,我们找到了匹配项,可以返回该值。这种搜索只花费了O(3)的时间,几乎提高了 67%的线性搜索时间。尽管个别结果可能会有所不同,但当集合已排序时,二分搜索模式始终比线性搜索更有效。这是在应用程序开始使用它们提供的数据之前花时间对集合进行排序的强有力的理由:
C#
BinarySort(int[] values, int left, int right, int key)首先检查right索引是否大于或等于left索引。如果不是,则指定范围内的没有元素,分析已经用尽,因此方法返回-1。我们稍后将检查原因。否则,方法执行继续,因为定义的范围内至少有一个对象。
接下来,方法检查middle索引处的值是否与我们的key匹配。如果是true,则返回middle索引。否则,方法检查middle索引处的值是否大于key值。如果是true,则以选择当前元素范围下半部分的边界递归调用BinarySort(int[] values, int left, int right, int key)。否则,middle索引处的值小于key,因此以选择当前元素范围上半部分的边界递归调用BinarySort(int[] values, int left, int right, int key):
public int BinarySearch(int[] values, int left, int right, int key)
{
if (right >= left)
{
int middle = left + (right - left) / 2;
if (values[middle] == key)
{
return middle;
}
else if (values[middle] > key)
{
return BinarySearch(values, left, middle - 1, key);
}
return BinarySearch(values, middle + 1, right, key);
}
return -1;
}
Java
除了binarySearch(int[] values, int left, int right, int key)这个名称外,Java 实现的设计与 C#实现相同:
public int binarySearch(int[] values, int left, int right, int key)
{
if (right >= left)
{
int mid = left + (right - left) / 2;
if (values[mid] == key)
{
return mid;
}
else if (values[mid] > key)
{
return binarySearch(values, left, mid - 1, key);
}
return binarySearch(values, mid + 1, right, key);
}
return -1;
}
Objective-C
由于NSArray只能存储对象,我们需要将我们的值转换为NSNumber,并且在评估成员时,我们需要显式检查intValue。否则,这些实现与 C#或 Java 实现基本相同:
-(NSInteger)binarySearchArray:(NSMutableArray<NSNumber*>*)values withLeftIndex:(NSInteger)left
rightIndex:(NSInteger)right
andKey:(NSInteger)key
{
if (right >= left)
{
NSInteger mid = left + (right - left) / 2;
if ([values[mid] intValue] == key)
{
return mid;
}
else if ([values[mid] intValue] > key)
{
return [self binarySearchArray:values withLeftIndex:left rightIndex:mid - 1 andKey:key];
}
return [self binarySearchArray:values withLeftIndex:mid + 1 rightIndex:right andKey:key];
}
return -1;
}
Swift
基本上,Swift 实现与其前辈相同:
open func binarySearch( values: [Int], left: Int, right: Int, key: Int) -> Int
{
if (right >= left)
{
let mid: Int = left + (right - left) / 2
if (values[mid] == key)
{
return mid
}
else if (values[mid] > key)
{
return binarySearch(values: values, left: left, right: mid - 1, key: key)
}
return binarySearch(values: values, left: mid + 1, right: right, key: key)
}
return -1
}
跳跃搜索
另一种可以改善排序数组性能的搜索算法是跳跃搜索。跳跃搜索在某种程度上与线性搜索和二分搜索算法相似,因为它从集合的第一个块开始,从左到右搜索元素块,并且在每次跳跃时,算法将搜索键值与当前步骤的元素值进行比较。如果算法确定键可能存在于当前元素子集中,下一步(无意中开玩笑)就是检查当前子集中的每个元素,以确定它是否小于键。
一旦找到一个不小于键的元素,该元素就会与键进行比较。如果元素等于键,则返回;否则,它大于键,这意味着键不存在于集合中。
跳跃长度 m 不是一个任意值,而是基于集合长度通过公式 m = √n 计算得出的,其中 n 是集合中元素的总数。跳跃搜索首先检查第一个块或子集的最后一个对象的值。
例如,让我们在以下有序值集合中搜索值 143:
S = {8, 19, 23, 50, 75, 103, 121, 143, 201}
由于我们的集合包含九个元素,m = √ n 给我们一个值为 3 的结果。由于 i[2] = 23,并且这个值小于 143,算法跳到下一个块。接下来,i[4] = 103,这也小于 143,所以这个子集被排除。最后,i[8] = 201。由于 201 大于 143,键可能存在于第三个子集中:
S[3] = {121, 143, 201}
接下来,算法检查这个子集中的每个元素,以确定它是否小于 143。并且 i[6] = 121,所以算法继续检查。另外,i[7] = 143,这并不小于 143,所以执行继续到最后一步。由于 i[7] = 143,我们找到了与我们的键匹配的值,并且可以返回 i 的值。这次搜索的成本是 O(5),这比线性搜索产生的 O(7) 略好,但比我们找到的 O(3) 成本略差。然而,对于更大的数据集,当集合已排序时,跳跃搜索在大多数情况下比线性搜索和二分搜索更有效。
再次强调,对集合进行排序确实在时间和性能上代表了一些前期成本,但你的应用程序运行周期中的回报远远超过了这些努力。
C#
我们对 BubbleSort 方法的每个实现都从声明三个 int 变量开始,以跟踪集合的大小、步长和先前评估的索引。随后,一个 while 循环使用 prev 和 step 值来定义和搜索集合的子集,以确定 key 可能存在的范围。如果没有找到可接受的子集,该方法返回 -1,表示 key 不能存在于这个集合中。否则,prev 和 step 的值标识了 key 可能存在的子集。
下一个 while 循环检查子集中每个元素,以确定它是否小于 key。如果没有找到可接受的元素,该方法返回 -1,表示 key 不能存在于这个集合中。否则,prev 的值标识了 key 在子集中可能的最佳匹配。
最后,将 prev 位置的元素与 key 进行比较。如果两个值匹配,则返回 prev。否则,我们到达执行结束,返回 -1:
public int JumpSearch(int[] values, int key)
{
int n = values.Length;
int step = (int)Math.Sqrt(n);
int prev = 0;
while (values[Math.Min(step, n) - 1] < key)
{
prev = step;
step += (int)Math.Floor(Math.Sqrt(n));
if (prev >= n)
{
return -1;
}
}
while (values[prev] < key)
{
prev++;
if (prev == Math.Min(step, n))
{
return -1;
}
}
if (values[prev] == key)
{
return prev;
}
return -1;
}
Java
每个方法的 Java 实现在设计上几乎与 C# 实现相同,只是数组 length 函数的名称不同。
public int jumpSearch(int[] values, int key)
{
int n = values.length;
int step = (int)Math.sqrt(n);
int prev = 0;
while (values[Math.min(step, n) - 1] < key)
{
prev = step;
step += (int)Math.floor(Math.sqrt(n));
if (prev >= n)
{
return -1;
}
}
while (values[prev] < key)
{
prev++;
if (prev == Math.min(step, n))
{
return -1;
}
}
if (values[prev] == key)
{
return prev;
}
return -1;
}
Objective-C
由于 NSArray 只能存储对象,我们需要将我们的值转换为 NSNumber,当我们评估成员时,需要显式检查 intValue。否则,这个实现本质上与 C# 或 Java 实现相同。
-(NSInteger)jumpSearchArray:(NSMutableArray<NSNumber*>*)values forKey: (NSInteger)key
{
NSInteger n = [values count];
NSInteger step = sqrt(n);
NSInteger prev = 0;
while ([values[(int)fmin(step, n)-1] intValue] < key)
{
prev = step;
step += floor(sqrt(n));
if (prev >= n)
{
return -1;
}
}
while ([values[prev] intValue] < key)
{
prev++;
if (prev == fmin(step, n))
{
return -1;
}
}
if ([values[prev] intValue] == key)
{
return prev;
}
return -1;
}
Swift
除了从 sqrt() 和 floor() 方法返回的值需要额外的类型转换外,其功能本质上与前辈相同:
open func jumpSearch( values: [Int], key: Int) -> Int
{
let n: Int = values.count
var step: Int = Int(sqrt(Double(n)))
var prev: Int = 0
while values[min(step, n) - 1] < key
{
prev = step
step = step + Int(floor(sqrt(Double(n))))
if (prev >= n)
{
return -1
}
}
while (values[prev] < key)
{
prev = prev + 1
if (prev == min(step, n))
{
return -1
}
}
if (values[prev] == key)
{
return prev
}
return -1
}
摘要
在本章中,我们探讨了几个搜索算法。首先,我们研究了线性搜索,或顺序搜索。线性搜索几乎不能算是一个算法,因为你的代码只是简单地从左到右遍历集合中的元素,直到找到匹配项。当处理非常小的集合或未排序的集合时,这种方法是有用的,如果其他原因的话,那就是从开发角度来看易于实现。然而,当处理大型排序数据集时,有更好的替代方案。
接下来,我们研究了二分搜索算法。二分搜索算法本质上是通过将集合划分为更小的子集来分而治之,直到找到匹配项或可能的匹配项列表耗尽。与线性搜索的 O(n) 复杂度成本相比,二分搜索模式具有显著改进的 O(log(n)) 复杂度成本。然而,在运行二分搜索之前,确保集合被正确排序是绝对必要的,否则结果将毫无意义。
最后,我们研究了跳跃搜索。跳跃搜索通过顺序检查集合的子集来实现,每个子集的长度为 √n,其中 n 是集合中元素的总数。尽管实现起来稍微复杂一些,并且最坏情况下的复杂度为 O(n),但跳跃搜索的平均成本复杂度显著提高,为 O(√n),其中 n 是集合中元素的总数。


浙公网安备 33010602011771号