Rust-数据结构与算法实用指南-全-
Rust 数据结构与算法实用指南(全)
原文:
annas-archive.org/md5/7f1da5d2727e73d498671c17765467d9译者:飞龙
前言
当我第一次努力每年学习一门编程语言时,我从 Ruby 开始,然后学习了一点点 Scala,直到 2015 年,我开始学习一门非常新的语言:Rust。我第一次尝试创建一个 Slack(一个团队聊天程序)机器人多少有些成功,但非常令人沮丧。习惯了 Python 对 JSON 数据的灵活性和宽容的编译器,Rust 陡峭的学习曲线很快让我感到压力山大。
接下来的项目更加成功。一个数据库驱动程序,以及我为树莓派定制的物联网(IoT)客户端和服务器应用程序,使我能够以非常稳定的方式收集温度数据。与 Python 不同,如果程序编译成功,它几乎肯定会按预期工作——我非常喜欢它。
从那时起,发生了许多变化。像微软和亚马逊这样的大公司开始采用 Rust 作为在嵌入式设备和云中创建安全且快速代码的方式。随着WebAssembly(Wasm)的兴起,Rust 在 Web 前端领域也开始受到关注,游戏公司也开始在 Rust 中构建游戏引擎。2018 年是技术和 Rust 社区的一个好年,两者都将在 2019 年(以及以后)继续增长。
因此,我希望提供一种从实用角度创建更复杂 Rust 代码的学习资源。无论你的旅程将你引向何方,了解 Rust 及其各种编程模型将使你对代码的看法变得更好。
本书面向的对象
Rust 有很好的教程,可以学习语言的基础知识。每个会议都有研讨会,许多城市都有定期的聚会,还有一个非常有帮助的在线社区。然而,许多开发者发现自己已经超出了这些资源,但仍感觉没有准备好更复杂的解决方案。特别是来自不同背景,有多年经验的人,过渡可能会很艰难:一方面有“Hello World!”程序的一些示例;另一方面,有数千行代码的巨大的 Rust 开源项目——很难快速学习。如果你有这样的感觉,那么这本书就是为你准备的。
本书涵盖的内容
第一章,你好,Rust!,简要回顾了 Rust 编程语言及其 2018 版的变化。
第二章,货物与板条箱,讨论了 Rust 的cargo构建工具。我们将探讨配置以及构建过程和模块化选项。
第三章,高效存储,探讨了在 Rust 中,知道值存储的位置不仅对性能很重要,而且对理解错误信息和语言本身也很重要。在本章中,我们思考了栈和堆内存。
第四章,列表,列表,还有更多列表,涵盖了第一个数据结构:列表。通过几个示例,这一章深入探讨了顺序数据结构的变体及其实现。
第五章,健壮的树,继续我们探索流行数据结构的旅程:树是下一个要讨论的。在几个详细的示例中,我们探讨了这些高效设计的内部工作原理以及它们如何显著提高应用程序的性能。
第六章,探索映射和集合,探讨了最流行的键值存储:映射。在这一章中,详细描述了围绕哈希映射;哈希;以及它们的近亲集合的技术。
第七章,Rust 中的集合,试图与 Rust 程序员的日常生活联系起来,深入探讨了 Rust std::collections 库的细节,该库包含了 Rust 标准库提供的各种数据结构。
第八章,算法评估,教你如何评估和比较算法。
第九章,排序事物,将探讨排序值,这是编程中的一个重要任务——这一章揭示了如何快速且安全地完成这项任务。
第十章,寻找东西,转向搜索,如果没有基本的数据结构来支持它,这一点尤为重要。在这些情况下,我们使用算法来快速找到我们想要的东西。
第十一章,随机与组合,我们将看到,除了排序和搜索之外,还有很多问题可以通过算法来解决。这一章全部关于这些:随机数生成、回溯以及提高计算复杂度。
第十二章,标准库算法,探讨了 Rust 标准库在排序和搜索等日常算法任务中的实现方式。
为了充分利用这本书
这本书附带了很多代码示例和实现。为了让你尽可能多地学习,建议安装 Rust(任何高于 1.33 的版本都应适用)并运行所有示例。以下是一些关于文本编辑器和其它工具的推荐:
-
微软的 Visual Studio Code (
code.visualstudio.com/),可以说是最好的 Rust 代码编辑器之一 -
通过插件支持 Visual Studio Code 的 Rust (
github.com/rust-lang/rls-vscode) -
Rust 语言服务器(RLS),位于
github.com/rust-lang/rls-vscode,通过rustup(rustup.rs/)安装。 -
使用 Visual Studio Code 的 LLDB 前端插件(
github.com/vadimcn/vscode-lldb)提供的调试支持
在设置好此环境并熟悉它之后,对您的日常 Rust 编程大有裨益,并让您能够调试和检查本书提供的代码的工作原理。为了最大限度地利用本书,我们建议您执行以下操作:
-
检查存储库中的源代码以获得完整情况。代码片段只是展示具体内容的独立示例。
-
不要盲目相信我们的结果;运行每个子项目(章节)的测试和基准测试以自行重现结果。
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788995528_ColorImages.pdf。
下载示例代码文件
您可以从 www.packt.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packt.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Rust。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富的图书和视频目录的其他代码包可供选择,位于 github.com/PacktPublishing/。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“原因是 passing_through 变量比 x 存活时间更长。”
代码块应如下设置:
fn my_function() {
let x = 10;
do_something(x); // ownership is moved here
let y = x; // x is now invalid!
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
fn main() {
let mut a = 42;
let b = &a; // borrow a
let c = &mut a; // borrow a again, mutably
// ... but don't ever use b
}
任何命令行输入或输出都应如下编写:
$ cargo test
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果你对此书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com发送邮件给我们。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packt.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式发现我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。
如果你有兴趣成为作者: 如果你精通某个主题,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com.
评论
留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?潜在读者可以看到并使用你的客观意见来做出购买决定,我们 Packt 可以了解你对我们的产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com.
第一章:欢迎来到 Rust!
首先,感谢您拿起这本书!你们中的许多人可能只在大学时讨论过算法和数据结构这个话题。实际上,无论这是您编程生涯的第一次尝试与否,我们都努力使这本书成为一次极好的学习体验。我们的主要关注点将是 Rust 对算法和数据结构设计的独特影响,因此我们想从回顾一些重要的基础知识开始。
从 Rust 2018 版的变化开始,我们将探讨借用和所有权、可变性以及并发如何影响数据可以存储的位置以及可以执行哪些算法。在本章中,您可以期待学习以下内容:
-
快速回顾 Rust 以及 2018 版(Rust 1.31)中期待的内容
-
关于借用和所有权的最新和最佳内容
-
我们如何正确利用并发和可变性
-
指向 Rust 所在之处的引用(不是指针!)
2018 年的 Rust
Rust 有多久了?它始于 2006 年,是 Mozilla 工程师 Graydon Hoare 的一个副项目,后来(在 2009 年)被公司采纳。快进到不到十年后的 2015 年 5 月 15 日,Rust 团队宣布了稳定版本 1.0!
在其发展过程中,许多特性被添加和移除(例如,垃圾回收器、类和接口),以帮助它成为今天既快速又安全的语言。
在深入探讨 Rust 中的借用和所有权、可变性、并发、安全性等之前,我们想回顾一下 Rust 的一些主要概念以及它们如何显著改变架构模式。
2018 年版
Rust 的 2015 年版基本上是 1.0 版本,增加了一些非破坏性特性。然而,在 2015 年和 2018 年之间,特性和请求评论(RFCs)——Rust 社区改变核心特性的方式——不断积累,对向后兼容性的担忧也随之产生。
为了保持这种兼容性,引入了版本,并且随着第一个附加版本,许多重大变化被纳入了语言:
-
模块路径系统的变化
-
dyn Trait和impl Trait语法 -
async/await语法 -
生命周期语法的简化
通过这些新增特性,Rust 将异步编程引入其语法(async/await关键字),并提高了语言的可用性。本书默认使用 2018 年 12 月 6 日发布的 Rust 2018 版(blog.rust-lang.org/2018/12/06/Rust-1.31-and-rust-2018.html),因此所有以下代码片段都将包含这些新的语言特性!
Rust 语言
当今许多已建立的编程语言是多范式语言,但仍然专注于面向对象的原则。这意味着它们有类、方法、接口、继承等,这些在 Rust 中都找不到,这使得许多已建立的开发者学习曲线陡峭。
经验更丰富的读者可能会错过许多使 Rust 成为优秀语言的方面,例如静态与动态方法调用、内存布局等。我认识到这些事情的重要性,但为了简洁和专注,选择将其留给您进一步探索。请参阅 进一步阅读 部分,以获取资源。
作为一种多范式语言,Rust 有许多功能概念和范式来指导它,但它们使得传统的面向对象模式更难应用。除了不使用类和接口来组织代码之外,还有各种方法来处理错误、更改代码本身,甚至与原始指针一起工作。
在以下章节中,我们想要探索一些使 Rust 独特并对我们开发算法和数据结构有重大影响的几个概念。
对象和行为
在 Rust 中组织代码与常规的面向对象语言(如 C#)略有不同。在那里,一个对象应该改变自己的状态,接口是简单的合同定义,而专业化通常是通过类继承来建模的:
class Door {
private bool is_open = false;
public void Open() {
this.is_open = true;
}
}
在 Rust 中,这种模式将需要任何 Door 实例的恒定可变性(因此需要显式锁定以实现线程安全),而且没有继承,GlassDoor 将不得不重复代码,这使得维护变得更加困难。
相反,建议创建特质来实现(共享)行为。特质与传统语言中的抽象类有很多共同之处(例如方法的默认实现),而且 Rust 中的任何 struct 都可以(并且应该)实现这些特质中的几个:
struct Door {
is_open: bool
}
impl Door {
fn new(is_open: bool) -> Door {
Door { is_open: is_open }
}
}
trait Openable {
fn open(&mut self);
}
impl Openable for Door {
fn open(&mut self) {
self.is_open = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_door() {
let mut door = Door::new(false);
door.open();
assert!(door.is_open);
}
}
这种模式在标准库中非常常见,第三方库甚至会在自己的代码中通过实现特质来向现有类型添加行为(也称为扩展特质)。
除了典型的类,其中数据字段和方法位于单个结构中之外,Rust 强调通过声明一个用于数据的 struct 和一个用于方法/函数的 impl 部分来分离它们。特质命名并封装行为,以便可以轻松导入、共享和重用。
出错
除了课程之外,Rust 没有另一个众所周知的伴侣:null。在没有指针和非常不同的内存管理模型的情况下,不存在典型的 null 指针/引用。
相反,该语言使用Option和Result类型,让开发者能够建模成功或失败。实际上,也没有异常系统,因此任何函数的失败执行都应该在返回类型中指示。只有在需要立即终止的情况下,该语言才提供用于恐慌的宏:panic!()。
Option<T>和Result<T, E>都封装了一个(Option<T>)或两个(Result<T, E>)值,可以返回以传达错误或是否找到了某些内容。例如,一个find()函数可以返回Option<T>,而像read_file()这样的函数通常会有Result<T, E>返回类型来传达内容或错误:
fn find(needle: u16, haystack: Vec<u16>) -> Option<usize> {
// find the needle in the haystack
}
fn read_file(path: &str) -> Result<String, io::Error> {
// open the path as a file and read it
}
通常使用match或if let子句来处理这些返回值,以处理成功或失败的情况:
match find(2, vec![1,3,4,5]) {
Some(_) => println!("Found!"),
None => println!("Not found :(")
}
// another way
if let Some(result) = find(2, vec![1,2,3,4]) {
println!("Found!")
}
// similarly for results!
match read_file("/tmp/not/a/file") {
Ok(content) => println!(content),
Err(error) => println!("Oh no!")
}
这是因为Option<T>和Result<T, E>都是具有泛型类型参数的枚举;它们可以假设其变体中的任何类型。对其变体的匹配提供了访问其内部值和类型的能力,从而允许执行代码的一个分支并相应地处理该情况。这不仅消除了需要具有多个——有时是类型转换——异常臂的 try/catch 结构的需求,而且还使失败成为需要处理的正常工作流程的一部分。
宏
另一方面,Rust 具有使用宏进行元编程的能力——基本上是编程编程!宏在 Rust 代码编译之前展开,这使得它们比普通函数拥有更多的功能。生成的代码可以动态创建函数或为结构体实现特质。
这些代码片段通过减少创建和初始化向量、派生结构体克隆能力或简单地打印内容到命令行的需求,使日常生活变得更加容易。
这是一个简化版的vec![]声明性宏示例,它包含在《Rust 编程语言》(第二版,附录 D)中:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$( temp_vec.push($x); )*
temp_vec
}
};
}
声明性宏在模式上工作,如果该模式匹配,则运行代码;前面的示例匹配*0 - n*表达式(例如,一个数字,或返回数字的函数)并插入temp_vec.push(...) n 次,迭代提供的表达式作为参数。
第二种类型,过程宏,操作方式不同,通常用于提供默认的特质实现。在许多代码库中,可以在结构体顶部找到#[derive(Clone, Debug)]语句来自动实现Clone和Debug特质。
在本章的后面,我们将使用一个结构体,FileName,来展示引用计数,但为了使用调试字面量"{:?}"将其打印到命令行,我们需要派生Debug,它递归地将所有成员打印到命令行:
#[derive(Debug)]
struct FileName {
name: Rc<String>,
ext: Rc<String>
}
Rust 标准库已经提供了几个宏,通过创建自定义宏,您可以最小化必须编写的样板代码。
不安全
Rust 的代码是“安全”的,因为编译器在内存访问和管理方面检查并强制执行某些行为。然而,有时这些规则必须被放弃,使得代码变得不安全。unsafe是 Rust 中的一个关键字,它声明了一段代码,可以执行 C 编程语言允许你做的许多事情。例如,它允许用户执行以下操作(来自《Rust 编程语言》,第 19.1 章):
-
解引用原始指针
-
调用一个
不安全的函数或方法 -
访问或修改可变静态变量
-
实现一个
unsafe特质
这四种能力可以用于诸如非常低级的设备访问、语言互操作性(编译器无法知道原生库如何使用它们的内存)等情况。在大多数情况下,当然在这本书中,unsafe是不必要的。事实上,Rustonomicon(doc.rust-lang.org/nomicon/what-unsafe-does.html)定义了一个列表,列出了语言试图通过提供安全部分来防止发生的问题:
-
解引用空、悬垂或未对齐的指针。
-
读取未初始化的内存。
-
破坏指针别名规则。
-
生成无效的原始值:
-
悬垂/空引用
-
空的
fn指针 -
不是 0 或 1 的布尔值
-
未定义的
enum判别符 -
字符超出范围[
0x0,0xD7FF]和[0xE000,0x10FFFF] -
非 UTF8 字符串
-
-
调用其他语言。
-
导致数据竞争。
这些潜在问题在安全 Rust 中被防止,这无疑使开发者的生活变得更轻松,尤其是在设计算法或数据结构时。因此,这本书将始终使用安全 Rust。
借用和所有权
Rust 以其内存管理模型而闻名,该模型用编译时对内存安全的检查取代了运行时垃圾回收。Rust 能够在没有垃圾回收器的情况下工作,并且仍然让程序员摆脱易出错的内存管理,原因简单(但不容易):借用和所有权。
虽然具体细节相当复杂,但高层次的观点是,编译器为开发者插入任何“提供x数量的内存”和“移除x数量的内存”(对于 C 程序员来说类似于malloc()和free())语句。然而,它是如何做到这一点的呢?
所有权规则如下:
-
值的所有者是变量
-
在任何时候,只允许一个所有者
-
当所有者超出作用域时,值就会丢失
这就是 Rust 声明性语法发挥作用的地方。通过声明一个变量,编译器在编译时就知道需要预留一定量的内存。其生命周期也是明确定义的,从块的开始到结束,或只要struct实例存在。如果这个变量的大小在编译时已知,编译器就可以为函数提供所需的精确内存量。为了说明这一点,让我们考虑这个片段,其中两个变量以确定的顺序分配和释放:
fn my_func() {
// the compiler allocates memory for x
let x = LargeObject::new();
x.do_some_computation();
// allocate memory for y
let y = call_another_func();
if y > 10 {
do_more_things();
}
} // deallocate (drop) x, y
这难道不是每个其他编译器都做的事情吗?答案是是的——也不是。在编译时,“提供x量的内存”这部分相对简单;棘手的部分是跟踪在可以自由传递引用时还有多少内存在使用中。如果在函数执行过程中,某个局部引用变得无效,静态代码分析会告诉编译器引用背后的值的生命周期。然而,如果在函数执行过程中某个线程在未知的时间改变了这个值呢?
在编译时,这是不可能知道的,这就是为什么许多语言使用垃圾回收器在运行时进行这些检查。Rust 放弃了这种方法,采用两种主要策略:
-
每个变量在任何时候都恰好由一个作用域拥有
-
因此,开发者被迫按照要求传递所有权
尤其是在处理作用域时,栈变量的特性非常有用。存在两个内存区域,栈和堆,类似于其他语言,开发者使用类型来决定是分配堆(Box、Rc等)还是栈内存。
栈内存通常是短暂的且较小的,以先进先出的方式操作。因此,在将变量放入栈之前,必须知道其大小:

堆内存不同;它是内存的一部分,这使得在需要时很容易分配更多内存。没有顺序,内存通过地址访问。由于堆上的地址指针在编译时已知大小,它非常适合放在栈上:

在其他语言中,栈变量通常按值传递,这意味着整个值被复制并放置在函数的栈帧中。Rust 也这样做,但它还使该变量在——现在的父——作用域中的进一步使用无效。所有权移动到新的作用域,并且只能作为返回值转移回来。当尝试编译这个片段时,编译器会报错:
fn my_function() {
let x = 10;
do_something(x); // ownership is moved here
let y = x; // x is now invalid!
}
借用类似,但不是复制整个值,而是将原始值的引用移动到新的作用域。就像在现实生活中一样,值继续由原始作用域拥有;具有引用的作用域只是允许像提供的那样使用它。当然,这也带来了可变性的缺点,并且一些函数可能需要所有权,这既有技术原因也有语义原因,但它也有优点,例如更小的内存占用。
这些是借用规则:
-
拥有者可以拥有不可变或可变引用,但不能同时拥有两者
-
可以有多个不可变引用,但只能有一个可变引用
-
引用不能是无效的
通过将前面的片段更改为将变量借用给do_something()(假设这是允许的,当然),编译器将很高兴:
fn my_function() {
let x = 10;
do_something(&x); // pass a reference to x
let y = x; // x is still valid!
}
借用的变量在很大程度上依赖于生命周期。最基本的生命周期是它在其中创建的作用域。然而,如果引用应该进入结构体字段,编译器如何知道底层值没有被无效化?答案是显式生命周期!
特殊生命周期
一些生命周期是不同的,Rust 用'来表示它们。虽然这可能是预定义的'static,但在处理结构时,创建自己的生命周期也是同样可能的。
当考虑底层内存结构时,这是有意义的:如果输入参数被传递到函数中并在最后返回,它的生命周期超过了函数。虽然函数在其生命周期内拥有这部分内存,但它不能借用一个比它实际存在的时间更长的变量。因此,这个片段无法工作:
fn another_function(mut passing_through: MyStruct) -> MyStruct {
let x = vec![1, 2, 3];
// passing_through cannot hold a reference
// to a shorter lived x!
// the compiler will complain.
passing_through.x = &x;
return passing_through;
} // x's life ends here
原因是passing_through变量比x存在的时间更长。解决这个问题有几种方法:
- 将
MyStruct的类型定义更改为要求所有权。这样,结构现在拥有变量,并且它的生命周期将与结构相同:
fn another_function(mut passing_through: MyStruct) -> MyStruct {
let x = vec![1, 2, 3];
// passing_through owns x and it will be
// dropped together with passing_through.
passing_through.x = x;
return passing_through;
}
- 将
x克隆到passing_through以传递所有权:
fn another_function(mut passing_through: MyStruct) -> MyStruct {
let x = vec![1, 2, 3];
let y = &x;
// passing_through owns a deep copy of x'value that is be
// dropped together with passing_through.
passing_through.x = y.clone();
return passing_through;
}
- 在这种情况下,
vec![]是静态定义的,因此将其作为函数参数可能是有意义的。这不仅更高效地分配内存,还可以强制执行适当的生命周期:
fn another_function<'a>(mut passing_through: MyStruct<'a>, x: &'a Vec<u32>) -> MyStruct<'a> {
// The compiler knows and expects a lifetime that is
// at least as long as the struct's
// of any reference passed in as x.
passing_through.x = x;
return passing_through;
}
生命周期给许多 Rust 用户带来了很多奇怪的错误,在 2018 版中有一个可以少担心的问题。随着非词法生命期的引入,借用检查器变得更聪明了,现在它能够在一定程度上检查变量是否被使用。回想一下借用规则,如果创建了一个可变引用,则不能存在不可变引用。
在 Rust 1.31 之前,这段代码无法编译:
fn main() {
let mut a = 42;
let b = &a; // borrow a
let c = &mut a; // borrow a again, mutably
// ... but don't ever use b
}
现在它将能够编译,因为编译器不仅检查作用域的开始和结束,还检查引用是否被使用过。
多个所有者
单一所有权的功能虽然强大,但并不适用于所有用例。例如,大型对象或需要其他实例拥有的共享对象,在这些情况下不可变所有权会使生活变得更简单。考虑一个需要传入拥有对象的函数:
#[derive(Debug)]
struct FileName {
name: String,
ext: String
}
fn no_ref_counter() {
let name = String::from("main");
let ext = String::from("rs");
for _ in 0..3 {
println!("{;?}", FileName {
name: name,
ext: ext
});
}
}
当尝试编译no_ref_counter()时,编译器为循环的每次迭代创建一个作用域,并拥有其中使用的任何值。这只会成功一次,因为之后变量已经被移动,并且对于后续迭代不可访问。
因此,这些值(在这种情况下,name和ext)消失了,编译将产生两个错误,每个错误对应于字符串的“第二次”移动:
error[E0382]: use of moved value: `name`
--> src/main.rs:63:33
|
63 | let _ = FileName { name: name, ext: ext };
| ^^^^ value moved here in previous iteration of loop
|
= note: move occurs because `name` has type `std::string::String`, which does not implement the `Copy` trait
error[E0382]: use of moved value: `ext`
--> src/main.rs:63:44
|
63 | let _ = FileName { name: name, ext: ext };
| ^^^ value moved here in previous iteration of loop
|
= note: move occurs because `ext` has type `std::string::String`, which does not implement the `Copy` trait
一种解决方案是在每次迭代中克隆对象,但这会导致大量的缓慢内存分配。为此,Rust 标准库提供了一个解决方案:引用计数。
引用计数器(std::rc::Rc<T>)封装了一个在堆上分配的类型为T的变量,并在创建时返回一个不可变引用。这个引用可以以低开销克隆(它只是增加引用计数),但永远不会转换为可变引用。无论如何,它就像拥有数据一样,通过函数调用和属性查找传递。
虽然这需要改变变量类型,但现在调用clone()比直接克隆数据要便宜得多:
use std::rc::Rc;
#[derive(Debug)]
struct FileName {
name: Rc<String>,
ext: Rc<String>
}
fn ref_counter() {
let name = Rc::new(String::from("main"));
let ext = Rc::new(String::from("rs")));
for _ in 0..3 {
println!("{;?}", FileName {
name: name.clone(),
ext: ext.clone()
});
}
}
运行此代码片段会打印出FileName对象的调试版本三次:
FileName { name: "main", ext: "rs" }
FileName { name: "main", ext: "rs" }
FileName { name: "main", ext: "rs" }
这种方法对于单线程和不可变场景非常有效,但会拒绝编译多线程代码。这个解决方案将在下一节讨论。
并发和可变性
Rust 管理内存的方法是一个强大的概念。事实上,它足够强大,可以促进并发和并行执行。然而,首先:Rust 标准库中的线程是如何工作的?
并发和并行性是两种不同的执行模式。虽然并发意味着程序的某些部分可以独立运行,但并行性指的是这些部分同时执行。为了简单起见,我们将这两个概念统称为并发。
由于其底层性质,Rust 提供了操作系统线程功能的 API(例如,Linux/Unix 系统上的 POSIX)。如果没有变量传递到作用域中,其使用非常简单:
use std::thread;
fn threading() {
// The to pipes (||) is the space where parameters go,
// akin to a function signature's parameters, without
// the need to always declare types explicitly.
// This way, variables can move from the outer into the inner scope
let handle = thread::spawn(|| {
println!("Hello from a thread");
});
handle.join().unwrap();
}
然而,在传递数据时,为了保持 Rust 的安全性保证,需要做更多的工作,尤其是在涉及可变性时。在深入探讨这一点之前,回顾不可变性是很重要的。
不可变变量
Rust——就像许多函数式语言一样——拥抱不可变变量。它们是默认的,改变可变性需要使用mut进行显式声明,这告诉编译器变量将要被用于什么(读取或写入)。
函数式编程语言因通过不可变性保证促进并发工作能力而闻名;读取数据不会产生副作用!要求显式可变性给编译器一个检查何时以及是否需要可变性的机会,因此是否可能发生数据竞争。
这导致编译时警告和错误而不是运行时崩溃和奇怪的竞争条件,这是许多生产用户所欣赏的。简而言之,如果可变性是(罕见的)选项而不是规范,那么思考代码就更容易。
阴影(Shadowing)
与改变变量属性不同,用不同的值(例如,原始值的更改副本)覆盖变量通常更易于阅读。这种技术被称为阴影(shadowing)。
通常,这用于重用变量名,即使实际值已经改变,以便在当前情况下工作。此代码片段清理String,通过在整个函数中使用相同的名称,始终可以清楚地知道是输入参数被更改:
fn sanitize(s: String) -> String {
let s = s.trim();
let s = s.replace(" ", "_");
s
}
虽然这与改变变量的值类似,但阴影(shadowing)并不会取代可变性,尤其是在实际改变该变量的属性成本较低时;Rust 为此有一个特定的设计模式!
内部可变性
变量能否同时是不可变和可变的?当然可以。装箱变量(Box、Rc等)是对堆的不可变引用,它们包含实际值。
对于这些类型的容器,没有理由说内部变量不能被更改——这是一个可以在 Rust 中使用RefCell安全完成的任务。RefCell维护值的单一所有权,但允许在运行时进行可变借用检查。而不是编译器错误,违反借用规则将导致运行时panic!,使程序崩溃。
整个概念被称为内部可变性(interior mutability),通常与Rc结合使用,以便为多个所有者提供可变性的值。显然,为了提供良好的用户体验,强烈建议确保借用规则不会被其他方式违反。
将RefCell包裹在Rc中充当拥有多个所有者的看门人,包括更改内容的方式。这实际上类似于更传统的编程语言,如 Java 或 C#,在这些语言中,通常在方法调用之间移动引用,指向堆内存中的对象实例。
这种模式对于实现复杂程序和数据结构非常重要,因为特定变量的所有权并不总是明确的。例如,本书后面我们将研究双链表,它著名地有一个指向前一个和后继节点的指针。哪个节点应该拥有哪个指针的所有权?内部可变性允许我们说两者都有。考虑我们稍后将使用的节点声明:
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone)]
struct Node {
value: String,
next: Link,
prev: Link,
}
type Link = Option<Rc<RefCell<Node>>>;
通过这个列表声明,我们可以看到这个append函数简单版本中的模式:
pub fn append(&mut self, value: String) {
let new = Rc::new(RefCell::new(Node::new(value)));
match self.tail.take() {
Some(old) => {
old.borrow_mut().next = Some(new.clone());
new.borrow_mut().prev = Some(old);
}
None => self.head = Some(new.clone()),
};
}
此代码在列表的前端(头部)添加一个新节点,该节点包含所有以节点形式存储在堆上的数据。为了在列表头部添加一个节点,必须正确设置引用,以便前一个和下一个指针实际上指向相同的节点而不是副本。更详细的探索将在第三章,“列表,列表,还有更多列表”中介绍。现在,重要部分是使用borrow_mut()设置变量。这个可变引用仅在赋值期间存在,因此排除了创建过大作用域并违反借用规则的可能性。
通过使用RefCell函数的borrow_mut(),它将检查并强制执行借用规则,并在违反规则的情况下引发恐慌。稍后,我们还将讨论Mutex类型,它本质上是多线程版本的这些单元格。
数据移动
介绍片段展示了启动线程但未将任何数据传递到作用域中的代码。就像任何其他作用域一样,它需要拥有一个值的所有权或至少一个借用引用才能处理该数据。在这种情况下,传递所有权是我们想要的,这被称为移动数据到作用域中。
如果我们将简介中的片段修改为包含一个用于从线程内部打印的简单变量,编译将会失败:
use std::thread;
fn threading() {
let x = 10;
let handle = thread::spawn(|| {
println!("Hello from a thread, the number is {}", x);
});
handle.join().unwrap();
}
原因很简单:编译器无法确定每个作用域的生命周期(当线程需要时x还在吗?),因此拒绝编译代码:
Compiling ch1 v0.1.0 (file:///code/ch1)
error[E0373]: closure may outlive the current function, but it borrows `x`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `x`
6 | println!("Hello from a thread, the number is {}", x);
| - `x` is borrowed here
help: to force the closure to take ownership of `x` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ^^^^^^^
如编译器消息所示,添加move关键字将解决问题!此关键字允许一个线程将所有权传递给另一个线程;它“移动”内存区域:
fn threading() {
let x = 10;
let handle = thread::spawn(move || {
println!("Hello from a thread, the number is {}", x);
});
handle.join().unwrap();
}
当运行此片段时,输出如下:
Hello from a thread, the number is 10
然而,对于将多个消息传递到线程或实现演员模型,Rust 标准库提供了通道。通道是单消费者、多生产者队列,允许调用者从多个线程发送消息。
此片段将启动 10 个线程,每个线程将一个数字发送到通道,发送者执行完毕后,这些数字将被收集到一个向量中:
use std::sync::mpsc::{channel, Sender, Receiver};
fn channels() {
const N: i32 = 10;
let (tx, rx): (Sender<i32>, Receiver<i32>) = channel();
let handles = (0..N).map(|i| {
let _tx = tx.clone();
thread::spawn(move || {
// don't use the result
let _ = _tx.send(i).unwrap();
})
});
// close all threads
for h in handles {
h.join().unwrap();
}
// receive N times
let numbers: Vec<i32> = (0..N).map(|_|
rx.recv().unwrap()
).collect();
println!("{:?}", numbers);
}
如预期,输出如下:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
使用这些工具,多线程应用程序可以在线程之间移动数据,而无需手动锁定或无意中创建副作用的风险。
数据共享
除了将数据以某种方式发送到线程之外,许多程序在共享状态上操作,其中多个执行流必须访问和更改一个或多个共享变量。通常,这需要使用互斥锁(简称互斥),以确保在锁定互斥锁内访问任何内容时,都保证是单个线程。
这是一个旧概念,在 Rust 标准库中实现。它是如何方便访问变量的?将变量包装成 Mutex 类型将提供锁定机制,从而使其可以从多个并发写入者处访问。然而,他们还没有拥有那个内存区域。
为了在多个线程之间提供这种所有权——类似于 Rc 在单个线程中所做的——Rust 提供了 Arc 的概念,一个原子引用计数器。使用这个 Mutex,它是一个线程安全的 Rc 包装 RefCell 的等价物,一个包装可变容器的引用计数器。为了提供一个例子,这工作得很好:
use std::thread;
use std::sync::{Mutex, Arc};
fn shared_state() {
let v = Arc::new(Mutex::new(vec![]));
let handles = (0..10).map(|i| {
let numbers = Arc::clone(&v);
thread::spawn(move || {
let mut vector = numbers
.lock()
.unwrap();
(*vector).push(i);
})
});
for handle in handles {
handle.join().unwrap();
}
println!("{:?}", *v.lock().unwrap());
}
当运行此示例时,输出如下:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
虽然进行并发编程的首选方式仍然是尽可能多地使用不可变变量,但安全的 Rust 提供了处理共享数据而不产生副作用的工具。
Send 和 Sync
这些标记特质是 Rust 多线程策略的基础。它们有各自的目的:
-
Send: 数据类型可以安全地从一条线程发送(移动)到另一条线程 -
Sync: 数据类型可以在线程之间共享,无需手动锁或互斥区域
这些标记特质在标准库的所有基本类型中实现,并且可以继承到自定义类型中(如果一个类型的所有属性都是 Sync,那么该类型本身也是 Sync)。
实现 Sync 或 Send 是不安全的,因为编译器无法知道你是否正确,代码是否可以在线程之间共享/发送,这就是为什么这样做非常不寻常。
如果你的程序需要这种深度的 Rust 编程,务必阅读《Rust 书》第十六章(doc.rust-lang.org/1.31.0/book/ch16-04-extensible-concurrency-sync-and-send.html)的相关内容。
深入 Rust
Rust 的另一个强点是它繁荣的社区。许多用户积极参与他们的本地社区(通过参加或组织聚会)或通过工作组、IRC/Discord 频道或官方论坛在线参与。以下是最重要的在线资源:
-
主网站,包含指向各种资源的链接:
www.rust-lang.org -
Rust 用户论坛:
users.rust-lang.org -
官方的 Twitter 账号:
twitter.com/rustlang -
一系列位于
wiki.mozilla.org/Rust的 IRC 频道 -
Rust 的官方博客:
blog.rust-lang.org -
《Rust 书》:
doc.rust-lang.org/book/
除了这些,用户还创建了额外的内容,例如播客、博客以及各种工具和库。然而,最令人印象深刻的用户贡献可以在核心语言中找到!
Rust 的官方 GitHub 仓库位于github.com/rust-lang,其中包含了众多资源(例如网站、博客、书籍和文档)的源代码,并且非常欢迎贡献。
Mozilla 在创建和培养开源社区方面有着令人印象深刻的记录,Rust 也不例外。作为这些社区的活跃成员,我们鼓励每个人参与进来,帮助使 Rust 成为最受欢迎和最有用的语言之一!
请求评论(RFCs)
由于 Rust 的开源特性,有一套治理规则在位,以保持接口的稳定性和灵活性,同时鼓励随着语言的发展进行变革和讨论。
对于像编程语言及其标准库这样敏感的事物,比常规的拉取请求审批更严格的过程是必要的,以便进行更深入的讨论。想象一下更改单个关键字的影响,以及会有多少项目立即停止工作!
这就是 RFCs 发挥作用的地方。它们为所有利益相关者提供了一个平等的机会来参与讨论。开源项目中整合变更的典型工作流程使用的是分支和拉取请求方法,其中贡献者创建一个拉取请求(PR)来提出变更(help.github.com/articles/about-pull-requests/)。与 RFC 过程不同,在大型代码库中这很难管理,并且只有在解决方案提出之后才开始讨论,大大缩小了焦点。
活跃和过去的 RFCs 的仓库可以在这里找到:github.com/rust-lang/rfcs。
摘要
Rust 是一种多范式语言,具有卓越的概念:该语言强调通过结构和特性实现数据和行为的分离,使用宏进行元编程,并利用显式的内存所有权来确定变量的生命周期。了解这些生命周期可以消除运行时垃圾收集的需要,同时,通过仅在特定情况下允许可变借用,极大地促进了并发性。
因此,线程和其他异步进程只能在它们拥有可变所有权时更改变量,这通常在编译时强制执行,但也可以在运行时完成!因此,安全的 Rust 实际上没有数据竞争。
Rust 生态系统另一个显著的优势是其多元化和包容性的社区。Mozilla 赞助了社区的发展,开发过程由 RFCs 指导,活动由中心组织并宣传,在线上提供学习资源。成为生态系统的一部分另一种方式是向crates.io(crates.io/),Rust 的公共包仓库贡献包。阅读下一章,了解更多关于cargo的信息,它是 Rust 构建和打包的通用工具。
问题
-
特性和接口有什么不同?
-
为什么 Rust 没有垃圾回收器?
-
列举三个在 Rust 中创建生命周期(显式和隐式)的例子!
-
变量的不可变性为什么很重要?
-
同步标记特性有什么作用?
-
你可以去哪里参与 Rust 社区?
-
为什么 RFC 比 PR 更受欢迎?
进一步阅读
以下书籍提供了更多信息:
-
《Rust 并发实战》 by Brian L. Troutwine (Packt)
-
《Rust 中的函数式编程》 by Andrew Johnson (Packt)
第二章:Cargo 和 Crates
Rust 是一种相当年轻的语言,从头开始设计,旨在成为程序员的一个实用且有用的工具。这是一个非常好的情况:没有遗留应用程序需要关心,并且从其他语言中学到的许多经验教训都融入了 Rust——特别是在工具方面。
在过去,集成和管理第三方包一直是许多语言的问题,并且存在几种不同的方法:
-
NPM:Node 的包管理器,在 JavaScript 社区中非常受欢迎
-
Maven:基于 XML 格式的企业级 Java 包管理器
-
NuGet:.NET 的包管理器
-
PyPI:Python 包索引
这些方法各有不同的配置风格、命名指南、发布基础设施、功能、插件等。Rust 团队从所有这些方法中学习,并构建了自己的版本:cargo。本章将全部关于cargo的力量,以及如何和在哪里与大量的包(称为 crates)集成。无论你是在开发自己的小型库,还是在构建大型企业级系统,cargo都将是项目的一个核心部分。通过阅读本章,你可以期待以下内容:
-
了解更多关于
cargo、其配置和插件的信息 -
了解更多关于不同类型的 crates
-
在
cargo中完成的基准测试和测试集成
Cargo
基本的 Rust 工具由三个程序组成:
-
cargo:Rust 包管理器 -
rustc:Rust 编译器 -
rustup:Rust 工具链管理器
大多数用户永远不会直接接触(甚至看到)rustc,而是通常使用rustup来安装它,然后让cargo来协调编译过程。
不带任何参数运行cargo会显示它提供的子命令:
$ cargo
Rust's package manager
USAGE:
cargo [OPTIONS] [SUBCOMMAND]
OPTIONS:
-V, --version Print version info and exit
--list List installed commands
--explain <CODE> Run `rustc --explain CODE`
-v, --verbose Use verbose output (-vv very verbose/build.rs output)
-q, --quiet No output printed to stdout
--color <WHEN> Coloring: auto, always, never
--frozen Require Cargo.lock and cache are up to date
--locked Require Cargo.lock is up to date
-Z <FLAG>... Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details
-h, --help Prints help information
Some common cargo commands are (see all commands with --list):
build Compile the current project
check Analyze the current project and report errors, but don't build object files
clean Remove the target directory
doc Build this project's and its dependencies' documentation
new Create a new cargo project
init Create a new cargo project in an existing directory
run Build and execute src/main.rs
test Run the tests
bench Run the benchmarks
update Update dependencies listed in Cargo.lock
search Search registry for crates
publish Package and upload this project to the registry
install Install a Rust binary
uninstall Uninstall a Rust binary
这里有一些关于包管理器可以做什么的线索。除了为项目解决不同类型的依赖关系外,它还充当基准测试和单元/集成测试的测试运行器,并提供对crates.io(crates.io/)等注册表的访问。许多这些属性都可以在.cargo/config文件中进行配置,该文件使用 TOML(github.com/toml-lang/toml)语法,可以在你的主目录、项目目录或两者之间的层次结构中。
可以配置的各个属性可以很容易地随时间演变,因此我们将关注一些核心部分。
本地仓库可以通过文件根部分的paths属性(一个路径数组)进行自定义,而cargo new命令的任何命令行参数都可以在文件的[cargo-new]部分找到。如果这些自定义仓库是远程的,可以在[registry]和[http]中配置proxy地址和端口、自定义证书颁发机构(cainfo)或高延迟(timeout)。
这些是针对具有私有仓库的企业系统或使用共享驱动作为缓存的 CI 构建的有用配置。然而,可以通过让用户在 [target.$triple] 部分提供一些配置来自定义工具链(例如,[target.wasm32-unknown-unknown] 用于自定义 Wasm 目标)。每个这些部分都包含以下属性:
-
针对所选三重组合的特定
linker -
通过自定义
ar的另一个归档器 -
用于运行程序及其相关测试的
runner -
编译器的标志位于
rustflags
最后,构建配置是在 [build] 部分设置的,其中可以设置 jobs 的数量、二进制文件,如 rustc 或 rustdoc、target 三重组合、rustflags 或增量编译。要了解更多关于配置 cargo 和获取此配置的示例,请访问 doc.rust-lang.org/cargo/reference/config.html。
在接下来的章节中,我们将探讨 cargo 的核心:项目。
项目配置
为了识别 Rust 项目,cargo 需要其清单存在,其中大多数其他方面(元数据、源代码位置等)可以配置。一旦完成这些配置,构建项目将创建另一个文件:Cargo.lock。此文件包含项目的依赖项树,包括库版本和位置,以便加快未来的构建。这两个文件对于 Rust 项目都是必不可少的。
清单文件 – Cargo.toml
Cargo.toml 文件遵循——正如其名所示——TOML 结构。它是手写的,包含有关项目以及依赖项、指向其他资源的链接、构建配置文件、示例等元数据。其中大部分是可选的,并有合理的默认值。实际上,cargo new 命令生成清单的最小版本:
[package]
name = "ch2"
version = "0.1.0"
authors = ["Claus Matzinger"]
edition = "2018"
[dependencies]
还有更多章节和属性,我们将在下面介绍一些重要的内容。
软件包
此清单部分主要关于软件包的元数据,例如名称、版本和作者,但也包含指向默认为相应页面的文档的链接 (docs.rs/). 虽然许多这些字段是为了支持 crates.io 并显示各种指标(类别、徽章、仓库、主页等),但某些字段无论是否发布在那里都应该填写,例如许可证(特别是开源项目)。
另一个有趣的章节是 package.metadata 中的元数据表,因为它被 cargo 忽略。这意味着项目可以在清单中存储自己的数据,用于项目或发布相关的属性——例如,用于在 Android 的 Google Play 商店发布,或生成 Linux 软件包的信息。
配置文件
当你运行cargo build、cargo build --release或cargo test时,cargo使用配置文件来确定每个阶段的单独设置。虽然这些有合理的默认值,但你可能想要自定义一些设置。清单提供了这些开关,包括[profile.dev]、[profile.release]、[profile.test]和[profile.bench]部分:
[profile.release]
opt-level = 3
debug = false
rpath = false
lto = false
debug-assertions = false
codegen-units = 16
panic = 'unwind'
incremental = false
overflow-checks = false
这些值是默认值(截至撰写本书时),对大多数用户来说已经很有用了。
依赖项
这可能是对大多数开发者来说最重要的部分。依赖项部分包含一个值列表,代表crates.io(或你配置的私有注册表)上的 crate 名称,作为键,版本作为值。
与版本字符串一样,也可以提供内联表作为值,指定可选性或其他字段:
[dependencies]
hyper = "*"
rand = { version = "0.5", optional = true }
有趣的是,由于这是一个对象,TOML 允许我们像使用部分一样使用它:
[dependencies]
hyper = "*"
[dependencies.rand]
version = "0.5"
features = ["stdweb"]
由于在 2018 版中,.rs文件内的extern crate 声明是可选的,因此可以通过使用package属性在Cargo.toml规范内部重命名依赖项。然后,指定的键可以成为此package的别名,如下所示:
[dependencies]
# import in Rust with "use web::*"
web = { version = "*", package = "hyper" }
[dependencies.random] # import in Rust with "use random::*"
version = "0.5"
package = "rand"
features = ["stdweb"]
功能是特定于 crate 的字符串,包括或排除某些功能。在 rand(以及一些其他情况)中,stdweb是一个功能,允许我们在 Wasm 场景中使用 crate,通过排除那些否则无法编译的内容。请注意,这些功能可能在它们依赖于工具链时自动应用。
需要通过这些对象指定的是对远程 Git 仓库或本地路径的依赖。这对于在本地测试库的补丁版本非常有用,而无需将其发布到crates.io (crates.io/),并在父构建阶段由cargo构建:
[dependencies]
hyper = "*"
rand = { git = "https://github.com/rust-lang-nursery/rand", branch = "0.4" }
使用cargo指定版本也遵循一个模式。由于任何 crate 都鼓励遵循语义版本控制方案(<major>.<minor>.<patch>),因此存在包括或排除某些版本(以及因此 API)的运算符。对于cargo,这些运算符如下:
-
波浪号 (
~): 只允许补丁增加。 -
插入符 (
^): 不会进行主要更新(从 2.0.1 到 2.1.0 是可以的,到 3.0.1 则不行!)。 -
通配符 (
*): 允许任何版本,但也可以用来替换一个位置。
这些运算符避免了未来的依赖问题,并引入了一个稳定的 API,同时不会错过所需的更新和修复。
无法发布带有通配符依赖的 crate。毕竟,目标计算机应该使用哪个版本?这就是为什么cargo在运行cargo publish时强制执行显式版本号。
有几种方式可以处理特定目的的依赖。它们可以通过平台([target.wasm32-unknown-unknown])声明,或者通过它们的意图:存在一个依赖类型,[dev-dependencies],用于编译测试、示例和基准测试,但还有一个仅用于构建的依赖规范,[build-dependencies],它将与其他依赖分开。
一旦指定了依赖,它们就会被解析并查找,以在项目内部生成依赖树。这就是Cargo.lock文件发挥作用的地方。
依赖 – Cargo.lock
这里有一句来自cargo常见问题解答(doc.rust-lang.org/cargo/faq.html)的精彩引言,关于此文件的目的以及它所做的工作:
Cargo.lock 的目的在于描述成功构建时的世界状态。然后,通过确保正在编译的依赖完全相同,它被用来确保在构建项目的任何机器上都能提供确定性的构建。
这种序列化状态可以轻松地在团队或计算机之间传输。因此,如果依赖引入了补丁更新中的错误,除非运行cargo update,否则你的构建应该不会受到太大影响。实际上,建议将Cargo.lock文件提交到版本控制中,以保留一个稳定且可工作的构建。出于调试目的,简化依赖树也非常方便。
命令
cargo支持大量易于扩展的命令。它与项目深度集成,允许添加额外的构建脚本、基准测试、测试等。
编译和运行命令
作为主要的构建工具,cargo通过创建并执行输出二进制文件(通常位于target/<profile>/<target-triple>/)来进行编译和运行。
如果需要使用不同语言编写的库来先于 Rust 构建,那该怎么办?这正是构建脚本发挥作用的地方。如项目配置部分所述,清单提供了一个名为build的字段,它接受一个指向build脚本的路径或名称。
脚本本身可以是一个普通的 Rust 二进制文件,在指定的文件夹中生成输出,甚至可以在Cargo.toml中指定依赖([build-dependencies],但不能是其他任何内容)。任何所需的信息(目标架构、输出等)都通过环境变量传递给程序,并且任何针对cargo的输出都需要遵循cargo:key=value格式。这些信息会被cargo捕获以配置后续步骤。虽然构建本地依赖是最受欢迎的,但也可以生成代码(如绑定、数据访问类等)。更多详情请参阅cargo参考文档:doc.rust-lang.org/cargo/reference/build-scripts.html。
较大的项目需要比简单的src/文件夹更复杂的结构来包含所有源代码,这就是为什么cargo提供了将项目拆分为子项目(称为工作空间)的选项。这对于像微服务(每个服务可以是项目)或松耦合组件(清洁架构)这样的架构模式很有用。要设置此环境,将每个子项目放置在子目录中,并在工作空间中创建一个Cargo.toml文件来声明其成员:
[workspace]
members = [ "core", "web", "data"]
这将应用在顶层运行的任何命令到工作空间中的每个 crate。调用cargo test将运行所有类型的测试,这可能会花费很长时间。
测试
在命令方面,cargo支持test和bench来运行 crate 的测试。这些测试通过在模块内部创建一个“模块”并用#[cfg(test)]进行注释来指定。此外,每个测试还必须用#[test]或#[bench]进行注释,而后者需要传递一个参数给Bencher,这是一个基准运行器类,允许我们收集每次运行的统计数据:
#![feature(test)]
extern crate test;
pub fn my_add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
use test::Bencher;
#[test]
fn this_works() {
assert_eq!(my_add(1, 1), 2);
}
#[test]
#[should_panic(expected = "attempt to add with overflow")]
fn this_does_not_work() {
assert_eq!(my_add(std::i32::MAX, std::i32::MAX), 0);
}
#[bench]
fn how_fast(b: &mut Bencher) {
b.iter(|| my_add(42, 42))
}
}
运行cargo test后,输出符合预期:
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running target/debug/deps/ch2-6372277a4cd95206
running 3 tests
test tests::how_fast ... ok
test tests::this_works ... ok
test tests::this_does_not_work ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
在这个例子中,测试正在导入并调用其父模块中的一个函数,称为my_add。其中一个测试甚至期望抛出一个panic!(由溢出引起),这就是为什么添加了#[should_panic]注释的原因。
此外,cargo支持 doctests,这是一种特殊的测试形式。重构时最繁琐的事情之一是更新文档中的示例,这就是为什么它们经常不起作用。从 Python 迁移过来,doctest 是解决这个困境的方案。通过在指定的示例中运行实际代码,doctests 确保文档中打印出的所有内容都可以执行——同时创建一个黑盒测试。
每个 Rust 函数都可以使用特殊的文档字符串进行注释——该字符串用于在 DOCS.RS(docs.rs/)生成文档。
doctests 仅适用于库类型的 crate。
这份文档有章节(由 Markdown 标题#指示),如果某个特定章节被命名为Examples,则其中包含的任何代码都将被编译并运行:
/// # A new Section
/// this [markdown](https://daringfireball.net/projects/markdown/) is picked up by `Rustdoc`
我们现在可以通过创建几行文档来向先前的示例添加另一个测试:
/// # A Simple Addition
///
/// Adds two integers.
///
/// # Arguments
///
/// - *a* the first term, needs to be i32
/// - *b* the second term, also a i32
///
/// ## Returns
/// The sum of *a* and *b*.
///
/// # Panics
/// The addition is not done safely, overflows will panic!
///
/// # Examples
///
/// ```rust
/// assert_eq!(ch2::my_add(1, 1), 2);
/// ```rs
pub fn my_add(a: i32, b: i32) -> i32 {
a + b
}
cargo test命令现在将运行示例中的代码:
$ cargo test
Compiling ch2 v0.1.0 (file:///home/cm/workspace/Mine/rust.algorithms.data.structures/code/ch2)
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running target/debug/deps/ch1-8ed0f81f04655fe4
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/ch2-3ddb7f7cbab6792d
running 3 tests
test tests::how_fast ... ok
test tests::this_does_not_work ... ok
test tests::this_works ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests ch2
running 1 test
test src/lib.rs - my_add (line 26) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
对于较大的测试或黑盒测试,也可以(并且推荐)将测试放入项目的子文件夹中,称为tests。cargo会自动检测并相应地运行测试。
除了测试之外,通常还需要其他命令(如代码度量、linting 等)和推荐。为此,cargo提供了一个第三方命令接口。
第三方子命令
cargo 允许通过子命令扩展其命令行界面。这些子命令是当调用 cargo <command>(例如,cargo clippy 用于流行的代码检查器)时被调用的二进制文件。
为了安装一个新的命令(针对特定的工具链),运行 cargo +nightly install clippy,这将下载、编译并安装一个名为 cargo-clippy 的 crate,并将其放入您家目录中的 .cargo 目录。实际上,这适用于任何被称作 cargo-<something> 且可以从任何命令行执行的二进制文件。cargo 项目在 github.com/rust-lang/cargo/wiki/Third-party-cargo-subcommands 的仓库中维护了一个有用的子命令更新列表。
Crates
一旦完成所有编译和测试,Rust 的模块(crates)可以轻松打包和分发,无论它们是库还是可执行文件。首先,让我们看看 Rust 二进制文件的一般情况。
Rust 库和二进制文件
Rust 中有可执行二进制文件和库。当这些 Rust 程序使用依赖项时,它们依赖于链接器来集成这些依赖项,以便在至少当前平台上运行。链接主要有两种类型:静态链接和动态链接——这两种类型都多少依赖于操作系统。
静态和动态库
通常,Rust 依赖项有两种类型的链接:
-
静态链接:通过
rlib格式。 -
动态链接:通过共享库(
.so或.dll)。
如果可以找到相应的 rlib,首选是静态链接,因此将所有依赖项包含到输出二进制文件中,使得文件变得很大(这让嵌入式程序员感到沮丧)。因此,如果多个 Rust 程序使用相同的依赖项,每个程序都会带有其内置的版本。尽管如此,这完全取决于上下文,因为,正如 Go 的成功所展示的,静态链接可以简化复杂的部署,因为只需要分发单个文件。
除了大小之外,静态链接方法还有其他缺点:对于静态库,所有依赖项都必须是 rlib 类型,这是 Rust 的原生包格式,并且不能包含动态库,因为格式(例如,ELF 系统上的 .so(动态)和 .a(静态))是不可转换的。
对于 Rust,动态链接通常用于本地依赖项,因为它们通常在操作系统中可用,不需要包含在包中。Rust 编译器可以通过 -C prefer-dynamic 标志优先使用动态链接,这将使编译器首先查找相应的动态库。
这就是编译器的当前策略:根据输出格式(--crate-format= rlib、dylib、staticlib、library 或 bin),它决定最佳的链接类型,并通过标志影响你的选择。然而,有一条规则是输出不能静态链接相同的库两次,因此它不会链接具有相同静态依赖的两个库。
关于这个主题的更多信息,我们建议查看doc.rust-lang.org/reference/linkage.html。话虽如此,编译器通常是可信的,除非有互操作性目标,否则 rustc 将决定最优方案。
链接和互操作性
Rust 编译成原生代码,就像许多其他语言一样,这很好,因为它扩展了可用的库,并允许你选择最佳技术来解决一个问题。“与其他人友好相处”一直是 Rust 的主要设计目标。
在那个层面上,互操作性就像声明你想要导入的函数,并动态链接导出此函数的库一样简单。这个过程在很大程度上是自动化的:唯一需要的是创建和声明一个构建脚本,该脚本编译依赖项,然后告诉链接器输出所在的位置。根据你构建的库类型,链接器执行必要的操作将其包含到 Rust 程序中:静态链接或动态链接(默认)。
如果只有一个要动态链接的原生库,清单文件提供了一个 links 属性来指定这一点。通过使用外部函数接口,程序化地与这些包含的库交互非常简单。
FFI
外部函数接口(FFI)是 Rust 通过简单的关键字 extern 调用其他原生库(反之亦然)的方式。通过声明一个 extern 函数,编译器知道,要么需要通过链接器(导入)绑定外部接口,要么声明的函数将被导出,以便其他语言可以使用它(导出)。
除了关键字之外,编译器和链接器还需要了解预期的二进制布局类型。这就是为什么通常的 extern 声明看起来如下:
extern "C" {
fn imported_function() -> i32;
}
#[no_mangle]
pub extern "C" fn exported_function() -> i32 {
42
}
这允许从 Rust 中调用 C 库函数。然而,有一个注意事项:调用部分必须包裹在 unsafe 部分。Rust 编译器无法保证外部库的安全性,因此对它的内存管理持悲观态度是有道理的。导出的函数是安全的,通过添加 #[no_mangle] 属性,没有名称混淆,因此可以通过其名称找到它。
为了使用 C/C++库中可用的专用算法库,有一个名为rust-bindgen的工具,它可以生成合适的结构、extern "C"声明和数据类型。更多详情请访问github.com/rust-lang-nursery/rust-bindgen。这些互操作性能力使得 Rust 代码可用于遗留软件或用于非常不同的环境中,例如网页前端。
Wasm
Wasm,现在通常称为WebAssembly,是一种二进制格式,旨在补充 JavaScript,Rust 可以编译成这种格式。该格式旨在在多个沙盒执行环境中(如网页浏览器或 Node.js 运行时)作为堆栈机器运行,用于性能关键的应用程序(blog.x5ff.xyz/blog/azure-functions-wasm-rust/)。尽管目前仍处于早期阶段,但 Rust 和 Wasm 目标已被用于实时前端设置(如浏览器游戏),并且在 2018 年有一个专门的工作组寻求改进这种集成。
与其他目标,例如 ARM 类似,Wasm 目标是一个 LLVM(Rust 构建所依赖的编译器技术)后端,因此必须使用rustup target add wasm32-unknown-unknown进行安装。此外,没有必要声明二进制布局(extern "C"中的"C")和不同的 bindgen 工具来完成剩余工作:wasm-bindgen,可在github.com/rustwasm/wasm-bindgen找到。我们强烈建议阅读文档以获取更多信息。
主要仓库 - crates.io
crates.io网站(crates.io/)提供了一个巨大的 crates 仓库,可用于 Rust。除了可发现性功能,如tags和search,它还允许 Rust 程序员将他们的工作提供给他人。
仓库本身提供了与仓库交互的 API 以及大量关于cargo、crates 等文档的指针。源代码可在 GitHub 上找到——我们建议查看仓库以获取更多信息:github.com/rust-lang/crates.io。
发布
为了让开发者的 crate 进入这个仓库,cargo提供了一个命令:cargo publish。实际上,这个命令在幕后做了更多的事情:首先运行cargo包来创建一个包含上传内容的*.crate文件。然后通过基本上运行cargo test来验证包的内容,并检查本地仓库中是否有未提交的文件。只有当这些检查通过时,cargo才会将*.crate文件的内容上传到仓库。这需要一个有效的crates.io账户(可以通过 GitHub 登录获得)来获取个人秘密 API 令牌,并且 crate 必须遵循某些规则。
在之前提到的 Wasm 目标下,甚至可以将 Rust 包发布到著名的 JavaScript 包仓库:npm。请注意,Wasm 的支持仍然非常新,但一旦库编译成 Wasm,就可以使用 Wasm-pack 打包成一个 npm 包:github.com/rustwasm/wasm-pack。
crates.io 致力于成为 Rust 库的永久存储库,因此没有“取消发布”按钮。版本可以通过 cargo yank 被撤回,但这不会删除任何代码;它只会禁止更新这个特定版本。此外,你还可以在仓库网站上设置团队结构、多彩的 README、徽章等等,我们强烈建议你也查看相关的文档:doc.rust-lang.org/cargo/reference/publishing.html。
摘要
cargo 是 Rust 的包管理器和构建工具,可以通过名为 Cargo.toml 的清单进行配置。该文件由 cargo 使用,以构建具有指定依赖项、配置文件、工作空间和包元数据的所需二进制文件。在这个过程中,包状态被保存在一个名为 Cargo.lock 的文件中。得益于其 LLVM 前端,Rust 能够在各种平台上编译成原生代码,包括网络(使用 Wasm),从而保持高度的互操作性。成功构建的包可以发布到一个名为 crates.io 的仓库,这是一个 Rust 库和二进制文件的中央枢纽。
在我们深入数据结构(从列表开始)之前,下一章将介绍 Rust 在内存中存储变量和数据的方式,是复制还是克隆,以及什么是有大小和无大小的类型。
问题
-
cargo都做了些什么? -
cargo提供了代码风格检查支持吗? -
在哪些情况下,
Cargo.lock文件对于发布很重要? -
发布到
crates.io需要满足哪些要求? -
Wasm 是什么,为什么你应该关心?
-
Rust 项目中的测试是如何组织的?
进一步阅读
你可以参考以下链接,了解更多关于本章涵盖主题的信息:
第三章:高效存储
在前几章的基础上,我们现在可以继续探讨算法和数据结构的更多架构方面。Rust——凭借其所有权模型——要求在算法设计中考虑生命周期、内存放置和可变性。在本章中,你可以期待学习以下主题:
-
考虑速度和可读性的权衡
-
访问堆和栈变量
-
不变性如何影响设计
堆和栈
如我们在第一章中讨论的那样,Hello Rust!,由于栈变量相比堆分配数据具有低开销和速度优势,因此栈变量更受欢迎。对于栈变量,Rust 的类型甚至允许零开销结构,因此不会存储额外的元数据。以下代码片段断言数组或用户定义类型没有使用额外的字节:
use std::mem;
struct MyStruct {
a: u8,
b: u8,
c: u8
}
fn main() {
assert_eq!(mem::size_of::<MyStruct>(), 3 * mem::size_of::<u8>());
assert_eq!(mem::size_of::<[MyStruct; 2]>(), 3 * mem::size_of::<u8>() * 2);
}
因此,MyStruct类型的实例大小始终是三个字节——非常适合将其放置在栈上。这有什么好处呢?简而言之,数据局部性。与指针解引用不同,数据存储在执行点,这使得它易于缓存且访问速度快。
没有可预测大小(例如String实例)的类型需要堆分配,就像被Rc、Cell、RefCell或Box实例包裹的对象一样。然而,堆分配和访问代价不菲,因为最小化这些通常能带来巨大的性能提升。
有大小和无大小
为了编译器能够将编写的代码转换为二进制格式,有必要知道每种类型的大小。正如我们之前讨论的那样,大小很重要,这样我们就可以在栈上放置其他类型,如果大小与其包含的数据无关(有大小类型),这很容易做到。这个最好的例子是u32:它使用 32 位(或 4 字节),无论你存储0还是10000900。
当类型是无大小或动态大小时,情况并非如此,最好的例子是str类型。根据字符数,这种类型的大小会有很大变化,这也是为什么实例通常以切片的形式出现。
切片是 Rust 提供泛型算法给所有数据类型的方式,它们将在第十二章中进一步讨论,标准库算法。
切片通过存储一个固定大小的引用(&str)到堆分配的值及其字节数来绕过大小问题。类似于指针,这是一个固定大小的对先前未指定大小的值的视图。每次创建某种类型的指针(&、Rc、Box、Cell 等)时,都会将引用与长度和一些(固定大小)元数据一起存储。当类型事先未知时——例如在处理 Rust 的泛型时——了解大小与未指定大小之间的区别特别有用。
泛型
Rust 支持泛型,甚至允许我们强制实现某些特质。这些约束可以作为一个附加在函数定义上的 where 子句,或者是在泛型类型声明中使用冒号:
fn my_generic_func<T: MyTrait>(t: T) {
// code
}
// ... is the same as
fn my_generic_func <T>(t: T) where T: MyTrait {
// code
}
// but better use in 2018 and beyond
fn my_generic_func(t: impl MyTrait) {
// code
}
此外,2018 年的 impl Trait 语法简化了输入和返回参数的单个特质要求(执行静态而不是动态分派),从而消除了使用 Box 或冗长的类型约束(如前面片段中的 MyTrait)的需要。除非需要多个特质实现(例如,fn f(x: T) where T: Clone + Debug + MyTrait {}),否则 impl Trait 语法允许我们将它们放在它们应该放的地方,即参数列表中:
fn my_generic_func<T>(t: T) {
// code
}
// ... is the same as
fn my_generic_func <T: Sized>(t: T) {
// code
}
当使用泛型时,情况会稍微复杂一些。类型参数默认是 Sized(见前面的片段),这意味着它们不会匹配未指定大小的类型。为了匹配这些类型,可以使用特殊的 ?Sized 类型约束。这个片段还展示了传递引用所需的更改:
fn my_generic_func <T: ?Sized>(t: &T) {
// code
}
然而,任何类型的堆分配引用在访问包含的值时都会多一个步骤。
访问盒子
额外的步骤听起来不多,但它有相当大的影响。这种为了在各个函数或线程之间轻松共享所有权而进行的权衡,消除了尽可能将大量数据放入 CPU 缓存的能力,因为指针使任何数据局部性变得困难。堆分配本身是昂贵的操作,减少这些操作将已经提供重大的速度提升。
此外,如果编译器在某个地方仍然引用了 boxed 值,它就无法释放 boxed 值——这个问题在程序大且复杂时尤其明显。类似于 C# 或 Java 中的孤儿对象,一个保存的 Rc 引用很容易被遗忘,从而造成内存泄漏。因此,建议仅在需要时使用堆内存。
在 Rust 中需要 boxed 值的一个额外建议是“优先使用对象组合而不是类继承”(Gang of Four 1995:20)。在没有类继承的情况下,显然选择使用对象组合。考虑到你也应该“面向接口编程而不是面向实现”(同上),通常会有一个强烈的愿望将特质的引用放在 struct 中,而不是直接与实现交互。
要在 Rust 中应用这种架构,语言要求我们将特质的实现放入Box<dyn TheTrait>中,这使得处理、测试和推理变得更加困难。这个特质对象要求编译器依赖于动态分派,这比默认的静态分派要慢得多。
静态和动态分派是许多编程语言(包括 Rust)中调用函数的两种主要方式。对于静态分派函数,位置在编译时已知,而动态分派函数仅在运行时已知,并且必须在指向实际地址的虚表中查找。两者都有其优点,所以使用时要谨慎。
除了泛型之外,没有默认的解决方案来解决这个问题。Rust 2018 的impl Trait添加缓解了函数参数和返回值的问题,但不能用于字段类型。
到目前为止,看起来最好的选择是使用具体类型而不是特质来避免多次解引用操作——只要在更改时重构似乎可行。如果你创建了一个库,泛型对于性能和灵活性来说是一个更好的选择。
拷贝和克隆
在第一章,“Hello Rust!”中,我们讨论了Send,这是一个标记特质,允许一个类型“发送”到多个线程。类似但更简单的是局部移动,这在程序中很常见——例如,当你将一个变量传递给一个函数时。
相反,拷贝和克隆发生在不同的场合。当一个变量被赋值给另一个变量时,编译器通常会隐式地拷贝值,对于栈分配的变量,这可以安全且便宜地完成。
Copy是变量的值的隐式、位拷贝。如果该变量是指针,内存责任就会变得模糊(谁负责释放?)并且编译将失败。这就是Clone发挥作用的地方。该特质要求显式实现clone()函数以提供适当的类型拷贝。
克隆始终是类型的深度拷贝——通过手动实现(使用Clone特质)或使用derive宏来实现。然后,克隆只是调用clone()函数的问题,这个操作并不一定便宜。以下代码片段说明了这两个操作:
let y = 5;
let x = y; // Copy
let a = Rc::new(5);
let b = a.clone(); // Clone
这些特质和操作的常规用法通常是直观的,不太可能出错。通常编译器会清楚地指出需要Copy实现。
建议尽可能使用Copy特质或实现,但要注意破坏性变更。添加特质是一个非侵入性操作,而移除特质可能会破坏其他人的代码。
虽然拷贝和克隆对于向多个作用域提供所有权非常有用,但在处理不可变存储时是必需的。
不可变存储
垃圾收集器大大简化了可变性,因此,许多这些语言不需要特定的修饰符来表示可变性。虽然这种垃圾收集机制以运行时频繁清理为代价,但无需关心变量是否可变是非常方便的。它让开发者能够专注于他们正在实现的逻辑。
那么为什么 Rust(以及许多函数式语言)又引入了这个概念?
状态和推理
对象的状态本质上是在任何给定时间其字段具有的当前值。通过对象本身通过定义的行为(称为方法)上的消息来更改此状态,根据面向对象的原则。这些状态更改需要可变性。
在它们的整个生命周期中,大多数对象会多次改变其状态,由于这发生在运行时,我们常常在沮丧中查看对象的调试打印,心想,“这个值是如何到这里来的?”
不可变数据结构通过使其内容无法更改来解决这个问题,所以每次查看对象时,它都具有完全正确的值。众所周知,大多数变量不需要可变,除非存在资源限制,否则建议创建具有新状态的另一个对象实例。这个原则被称为写时复制,它提高了可读性,从而改善了维护性。
采用写时复制的流行类型是String——在几乎任何语言中。该类型封装了一个字节数组,并使用提供的字符集(通常是 UTF-8)对其进行解释,如果你修改了一个字符,则该数组会被复制并保存所做的更改。这种情况发生的频率很高,以至于String分配成为常见的性能陷阱。
在 Rust 标准库中,有一个Cow枚举(std::borrow::Cow),它会在需要修改或拥有权时懒加载包含的引用。为了一个很好的例子,请查看 Cow 文档:doc.rust-lang.org/std/borrow/enum.Cow.html。
写时复制的原则也可以在文件系统中找到,用于创建快照(例如,在 ZFS 或 BRTFS 中),并且以运行时资源为代价提供了不可变性和可变性的好处。这是维护性和绝对性能之间的权衡。持久数据结构也采用了类似的概念,这些结构可以是部分或完全持久的,同时仍然是不可变的。
并发与性能
在多线程场景中,拥有易于推理的代码且状态无法更改尤为重要。这防止了所谓的异常(或副作用)发生,即对象的状态在依赖的线程之外被更改。
锁通常是为了改变共享对象的状态——它们保护临界区,在任何给定时间只有一个线程可以修改。其他线程必须“排队”等待锁释放才能访问该部分。在 Rust 中,这被称为互斥锁。
锁和互斥区域有以下几个原因不好:
-
它们必须按照正确的顺序(获取和释放)进行。
-
当一个线程在互斥区域中崩溃时会发生什么?
-
它们很难无缝集成到它们所保护的程序部分中。
-
它们是性能的瓶颈。
不变性是一种避免所有这些问题的简单方法,并且有许多不可变数据结构库可用,包括一个名为Rust 持久数据结构(RPDS)的库(crates.io/crates/rpds),它使用写时复制和版本控制来捕获状态变化。由于这些变化是相互叠加的,线程可以在一次只完全读取一个一致的对象状态的同时,无需等待或获取锁。
无锁数据结构是数据结构的一种特殊版本,其实施非常具有挑战性。这些数据结构使用原子操作来修改重要部分(例如,栈的头指针)并因此在不锁定的情况下实现优异的性能。
持久数据结构是一种创建数据结构的方法,它们在效率和可变性方面与传统对应物一样高效,但更适合并发。这是通过保持原始数据不可变并存储版本化更改集来实现的。
不可变数据的概念最好在函数式编程的背景下思考。函数式编程建立在数学函数的原则之上。一个函数是两个数据集(通常是X和Y)之间的关系,其中X中的每个元素在Y中都有且只有一个元素与之对应,使用f函数映射(简而言之:
其中
)。
作为结果,输入数据X不会被改变以产生输出数据Y,这使得并行运行f函数变得容易。缺点是运行时成本的提高:无论操作是什么,无论是仅仅在输入数据上翻转一个位还是彻底重写一切,结果总是完整的复制。
为了减少这种低效,可以使用 Gang of Four 在X迭代器上的装饰器模式来堆叠仅有的更改并在每次调用时执行它们,从而减少运行时复杂度并避免输出数据的多次复制。一个问题仍然存在,即如果输入和输出都很大,则需要大量的内存。这是一个棘手的情况,只能通过程序员仔细思考更好地分解函数来避免。
摘要
在代码的细节之上提升一个层次,本章讨论了设计和使用类型时的考虑因素。Rust 在代码中对栈分配和堆分配变量的区分提供了一种应该用来提高性能和 API 灵活性的控制级别。"Sized",一个主要用于栈分配值的标记特质,是泛型类型参数的默认值,可以通过应用 ?Sized 约束来放宽。
当与更面向对象的架构一起工作时,特质对象成为了一种“使用接口”而不是特定实现的方法。然而,它们会带来性能成本,即动态调度,这是在可维护性和性能之间的另一种权衡。
除了移动之外,Rust 在必要时可以复制或克隆变量。对于有大小值,Copy 执行深拷贝;无大小值需要克隆的引用。当使用名为“写时复制”的原则处理不可变数据类型时,这些操作经常遇到。选择我们是否能够在任何给定时间推理对象的状态,并避免数据竞争条件,但每次更改都需要创建一个副本,这是设计数据结构时的另一个重要权衡。
这种权衡将在下一章中变得明显,我们将开始处理列表,例如单链表、双链表和动态数组。
问题
-
Sized类型与其他类型有何不同? -
Clone与Copy有何区别? -
不变数据结构的主要缺点是什么?
-
应用程序如何从不可变数据结构中受益?
-
想象一下你想要工作的一个不可变列表——你将如何将它分配到多个线程中?
进一步阅读
关于本章涵盖的主题的更多信息,请查看以下链接:
第四章:列表,列表,还有更多列表
列表无处不在:购物清单、待办事项清单、食谱、西方国家街道号码……简而言之,无处不在。它们的定义特征,以线性、定义好的关系存储事物,帮助我们跟踪物品并在以后找到它们。从数据结构的角度来看,它们对于几乎任何程序都是必不可少的,并且以各种形状和形式出现。虽然某些列表在 Rust 中实现起来可能很棘手,但一般原则也可以在这里找到,还有一些关于借用检查器的宝贵经验教训!在本章之后,我们希望你知道更多关于以下内容:
-
(双链)列表以及何时使用它们
-
数组列表,也称为 Rust 的向量
-
跳表,以及理想情况下纽约地铁系统
-
实现一个简单的交易日志
最后的注意事项,本章将构建各种列表的安全实现,尽管不安全的版本可能更快且代码更少。这个决定是因为,在处理常规用例时,不安全几乎从未是解决方案。查看本章“进一步阅读”部分的链接,了解不安全列表。
链表
为了跟踪一堆物品,有一个简单的解决方案:在列表中的每个条目中,存储一个指向下一个条目的指针。如果没有下一个项目,则存储null/nil/None等,并保持对第一个项目的指针。这被称为单链表,其中每个项目通过单个链接与下一个项目连接,如下面的图所示——但你已经知道了:

那么链表的真实用例是什么?难道每个人不都是用动态数组来处理所有事情吗?
考虑一个事务日志,这是一个典型的只追加结构。任何新的命令(如 SQL 语句)都简单地追加到现有链中,并最终写入持久存储。因此,初始要求很简单:
-
向现有列表中添加命令
-
从头到尾按顺序重放每个命令
换句话说,它是一个队列(或LIFO——即后进先出)结构。
事务日志
首先,必须定义一个列表——在 Rust 中,由于缺少null类型,每个项目通过一个Option属性链接到下一个。Option实例是封装值(在这种情况下是一个堆引用,如Box、Rc等)或无值的枚举。为什么?让我们来看看!
创建一个典型实现以探索某个方面始终是一个好主意,特别是编译器经常提供出色的反馈。因此,整数列表的实现是第一步。每个列表元素的这个struct怎么样?
看看下面的代码片段:
struct Node {
value: i32,
next: Option<Node>
}
对于实际考虑,它需要一个知道从哪里开始以及列表长度的方法。考虑到计划的append操作,一个指向末尾(尾部)的引用也会很有用:
struct TransactionLog {
head: Option<Node>,
tail: Option<Node>,
pub length: u64
}
看起来很棒!但它真的工作吗?
error[E0072]: recursive type `Node` has infinite size
--> ch4/src/lib.rs:5:1
|
5 | struct Node {
| ^^^^^^^^^^^^^ recursive type has infinite size
6 | value: i32,
7 | next: Option<Node>
| ------------------ recursive without indirection
|
= help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `Node` representable
不幸的是,它不起作用——回想一下前面的章节,原因变得很清楚:编译器无法确定数据结构的大小,因为整个列表必须嵌套在第一个元素中。然而,正如我们所知,编译器无法以这种方式计算并因此分配所需的内存量——这就是为什么需要引用类型的原因。
引用类型(如Box、Rc等)非常适合,因为它们在堆上分配空间,因此允许更大的列表。这是一个更新后的版本:
use std::cell::RefCell;
use std::rc::Rc;
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>
}
struct TransactionLog {
head: Option<Rc<RefCell<Node>>>,
tail: Option<Rc<RefCell<Node>>>,
pub length: u64
}
将每个节点项存储在Rc<RefCell<T>>中提供了按需检索和替换数据的能力(内部可变性模式)——在执行列表操作时至关重要。另一个好的做法是别名类型,特别是如果有许多泛型。这使得替换类型实现变得容易,并提供了更易读的定义:
type SingleLink = Option<Rc<RefCell<Node>>>;
#[derive(Clone)]
struct Node {
value: i32,
next: SingleLink,
}
完美!这是事务日志的基本定义,但为了使用它,还有很多事情要做。首先,值类型必须是String:
#[derive(Clone)]
struct Node {
value: String,
next: SingleLink,
}
impl Node {
// A nice and short way of creating a new node
fn new(value: String) -> Rc<RefCell<Node>> {
Rc::new(RefCell::new(Node {
value: value,
next: None,
}))
}
}
除了那个,创建一个空列表将很有用,因此列表的impl块目前只有一个函数——new_empty():
impl TransactionLog {
pub fn new_empty() -> TransactionLog {
TransactionLog { head: None, tail: None, length: 0 }
}
}
尽管如此,还有很多事情要做。为了总结,事务日志有两个要求:
-
在末尾添加条目
-
从前面删除条目
让我们从第一个要求开始:向列表的末尾添加项目!
添加条目
事务日志现在可以创建并保存条目,但无法向列表中添加任何内容。通常,列表具有将元素添加到任一端的能力——只要有一个指向该端点的指针。如果情况不是这样,任何操作都会变得计算成本高昂,因为必须查看每个项目以找到其后续者。有了列表末尾(尾部)的指针,这种情况就不会发生在追加操作中;然而,要访问列表上的随机索引,可能需要花费一些时间来遍历所有内容。
命名——尤其是如果你英语是第二语言——通常很棘手。操作在不同的语言或库中有不同的名称。例如,向列表添加项目的常见名称包括push(可以添加到前面或后面)、push_back、add、insert(通常带有位置参数)或append. 除了能够猜测方法名称外,一些名称暗示了与其他名称完全不同的过程!如果你设计接口或库,找到最描述性和最简单的名称,并在可能的情况下重复使用!
这就是链表做得非常好的事情之一——在任一端添加项目。尽管如此,还有一些关键事项不应被忽视:
-
在方法内创建
Node对象使得 API 更好,并且更好地处理所有权。 -
边界情况,例如空列表。
-
增加长度是一个好主意。
-
使用
RefCell的borrow_mut()函数(内部可变性)来获取可变所有权,以便使用它设置新的后继者。
一旦考虑到这一点,实际的实现并不太糟糕。Rust 的 Option 类型提供了一个方法来检索它包含的值的所有权,并用 None 替换它(参见 Option.take() 的文档——doc.rust-lang.org/std/option/enum.Option.html#method.take 和 mem::replace()——doc.rust-lang.org/stable/std/mem/fn.replace.html),这方便地缩短了添加新节点所需的代码:
pub fn append(&mut self, value: String) {
let new = Node::new(value);
match self.tail.take() {
Some(old) => old.borrow_mut().next = Some(new.clone()),
None => self.head = Some(new.clone())
};
self.length += 1;
self.tail = Some(new);
}
有了这个,现在可以创建任何字符串命令通过日志。然而,这里也缺少一个重要的东西:日志回放。
日志回放
通常在数据库中,事务日志是在发生数据库必须恢复的糟糕情况下的一个弹性措施,或者为了保持副本更新。原理相当简单:日志代表了一系列按此顺序执行的命令。因此,为了重新创建数据库的最终状态,必须从最旧的条目开始,并按此顺序应用后续的每个事务。
你可能已经注意到这很好地符合了链表的功能。那么,当前实现中缺少了什么?
能够从前面开始删除元素。
由于整个数据结构类似于队列,这个函数将被命名为 pop,因为这是此类操作的典型名称。此外,pop 将消耗返回的项目,使列表成为单次使用的结构。这很有意义,可以避免重复播放任何内容!
这看起来比实际要复杂得多:内部可变性模式确实增加了实现的复杂性。然而,它使整个实现变得安全——多亏了 RefCells 在运行时检查借用规则。这也导致了最后一部分的函数链——它从其包装器中检索值:
pub fn pop(&mut self) -> Option<String> {
self.head.take().map(|head| {
if let Some(next) = head.borrow_mut().next.take() {
self.head = Some(next);
} else {
self.tail.take();
}
self.length -= 1;
Rc::try_unwrap(head)
.ok()
.expect("Something is terribly wrong")
.into_inner()
.value
})
}
按顺序调用此函数返回插入的命令顺序,提供了一种很好的回放功能。对于实际应用,提供将此状态序列化到磁盘的能力也很重要,特别是由于此操作完全消耗了列表。此外,优雅地处理错误(而不是恐慌和崩溃)也是推荐的。
使用后
无论何时需要销毁列表,Rust 都会调用一个自动实现的 drop() 方法。然而,由于这是一个自动化的过程,每个成员都会递归地被销毁——直到嵌套的 next 指针级别超过执行 drop() 方法的栈,导致程序因意外栈溢出信息而崩溃。
因此,对于生产使用,最好也实现 Drop 特性,并迭代地销毁列表元素。顺便说一下,在使用派生的 Debug 实现打印 Node 时也会发生栈溢出——原因相同。
总结
日志(事务)是链表的一个很好的用例:它们通常会增长到意外的尺寸,且不需要索引。虽然在其他语言中链表通常是一个非常简单的类型,但在 Rust 中它却隐藏着大量的挑战。这主要归因于借用和所有权概念,这些概念要求程序员详细考虑数据应该放在哪里。然而,对于实际应用场景,最好使用 Rust 的标准库链表 (std::collections::LinkedList)。从性能角度来看,在最坏的情况下,在单链表中查找特定项目需要查看整个列表,导致运行时复杂度为 O(n),其中 n 是列表中的项目数量(关于运行时复杂性的更多内容请参阅第八章,算法评估)。
优点
链表的主要优点是能够以低廉的成本增长到非常大的尺寸,始终保持一定的方向,并允许单独访问项目。是什么使得这种数据结构独一无二?
有几个要点:
-
每个项目的开销分配较低。
-
项目数量仅受堆内存限制。
-
迭代时可以进行修改。
-
方向是严格强制执行的——不能回头。
-
实现相当简单(即使在 Rust 中也是如此)。
-
高效的追加、预追加、删除和插入操作——与数组相比(不需要移动)。
通常,在有限内存不允许开销分配的环境(如动态数组)或作为异构无锁数据结构的基础时,链表表现良好。
缺点
链表有一些明显的缺点:
-
索引效率低下,因为必须查看每个节点。
-
迭代通常涉及在堆上进行大量的跳跃,这需要更多的时间,并使得操作难以缓存。
-
反转列表非常低效。
最后一点很重要,因此,通常链表实现还会有一个回链,使其成为双向链表。
双向链表
上一节的事务日志需要升级。产品团队希望用户能够通过向前和向后查看每个步骤做了什么来检查日志。这对常规链表来说是个坏消息,因为它在除了向前之外的地方效率非常低。那么,这是如何纠正的呢?
这是通过双向链表来纠正的。双向链表引入了back链接。虽然这听起来像是一个微小的变化,但它允许向后以及向前工作在该列表上,这显著提高了查找项的能力。通过在先前的单链表项中增加一个反向指针,几乎就创建了一个双向链表:
#[derive(Debug, Clone)]
struct Node {
value: String,
next: Link,
prev: Link,
}
type Link = Option<Rc<RefCell<Node>>>;
#[derive(Debug, Clone)]
pub struct BetterTransactionLog {
head: Link,
tail: Link,
pub length: u64,
}
与单链表类似,列表本身只包含一个头指针和一个尾指针,这使得访问列表的任一端既便宜又容易。此外,节点现在也包含一个指向前一个节点的指针,使得列表看起来像这样:

这也是使 Rust 中的双向链表变得棘手的地方。如果存在所有权的层次结构,所有权原则是很好的:客户有一个地址,文本文件有几行文本,等等。然而,双向链表中的节点对其邻居的任何一方都没有明确的所有权。
更好的事务日志
因此,需求列表得到了扩展:
-
向前移动通过日志
-
向后移动通过日志
-
移动不会消耗日志
对于双向链表来说,这是一个很好的匹配,因此现有的事务日志可以进行升级!有了对节点两个邻居的指针,它可以解决这个问题。然而,在不删除元素的情况下移动列表怎么办呢?
为了实现这一点,需要另一个概念:迭代器。Rust 的迭代器依赖于编程的函数式方面,并为与语言中所有其他数据结构和命令的集成提供了一个灵活的接口。例如,for循环将检测迭代器并按预期行为。
迭代器是指向当前项的指针,有一个名为next()的方法,该方法在移动指针的同时产生下一个项!当使用更函数式的方法处理集合时,这个概念被大量应用:通过将它们链接在一起并在调用next()之后应用一个函数,遍历列表可以非常高效。请参阅进一步阅读部分和本书的最后一章以获取更多信息!
数据模型将看起来像单链表,因此大多数操作都可以直接使用——它们只需要升级以支持反向指针。
检查日志
查看列表而不消耗它是迭代器的任务(见信息框),这在 Rust 以及大多数其他语言中都是一个简单的接口或特性的实现。事实上,这是如此常见,以至于 Rust 文档有一个非常好的文章(doc.rust-lang.org/std/iter/index.html#implementing-iterator),这正是所需的。
由于我们已经在处理堆引用,迭代器可以简单地保存一个可选的节点引用,并且很容易向前和向后移动:
pub struct ListIterator {
current: Link,
}
impl ListIterator {
fn new(start_at: Link) -> ListIterator {
ListIterator {
current: start_at,
}
}
}
如文档所述,for循环使用两个特性:Iterator和IntoIterator。实现前者通常是一个好主意,因为它提供了对Iterator中强大方法的访问,如map、fold等,并且与其他——兼容的——迭代器很好地链接在一起:
impl Iterator for ListIterator {
type Item = String;
fn next(&mut self) -> Option<String> {
let current = &self.current;
let mut result = None;
self.current = match current {
Some(ref current) => {
let current = current.borrow();
result = Some(current.value.clone());
current.next.clone()
},
None => None
};
result
}
}
这个迭代器负责移动一个方向:向前。我们如何走回头路呢?
反转
现在,由于要求也包括向后移动,迭代器需要双向移动。一种简单的方法是简单地向结构体中添加一个名为reverse()的函数,但这不会很好地集成,并且需要开发者阅读这个 API,并且它还增加了额外的工作,因为向前/向后迭代器是分开的。
Rust 的标准库为此提供了一个有趣的概念:DoubleEndedIterator。实现这个特性将提供以标准化的方式反转迭代器的能力,通过提供一个next_back()函数来获取前一个值——对于双向链表来说,这只是一个将哪个属性设置为当前项的问题!因此,这两个迭代器共享大量代码:
impl DoubleEndedIterator for ListIterator {
fn next_back(&mut self) -> Option<String> {
let current = &self.current;
let mut result = None;
self.current = match current {
Some(ref current) => {
let current = current.borrow();
result = Some(current.value.clone());
current.prev.clone()
},
None => None
};
result
}
}
在此基础上,可以通过在列表类型上调用iter()函数来创建迭代器,通过调用iter().rev(),迭代器将被反转,提供向前和向后的能力。
总结
双向链表在很多情况下是普通链表的改进版本(也是默认版本),这得益于每个节点只需一个指针和稍微复杂一些的操作带来的更好的灵活性。
尤其是通过保持代码的安全性(在 Rust 术语中,即没有使用unsafe {}),代码中充满了RefCells和borrow()来创建一个在运行时由借用检查器审计的数据结构。查看 Rust 的LinkedList源代码,情况并非如此(更多内容请参阅第七章,Rust 中的集合)。基本结构是相似的,但操作在底层使用了一堆不安全的代码——这需要良好的 Rust 编写经验。
PhantomData<T>是一个零大小的类型,当涉及泛型时,它向编译器传达有关一系列事情的信息,例如释放行为、大小等。
作为快速预览,这里提供了 Rust 标准库的LinkedList<T>定义和实现。它是一个双链表!此外,push_front_node(prepend)函数展示了如何使用不安全区域来加速插入。有关更多信息,请查看章节末尾的“进一步阅读”部分中链接到在线书籍《用大量链表学习 Rust》:
pub struct LinkedList<T> {
head: Option<Shared<Node<T>>>,
tail: Option<Shared<Node<T>>>,
len: usize,
marker: PhantomData<Box<Node<T>>>,
}
struct Node<T> {
next: Option<Shared<Node<T>>>,
prev: Option<Shared<Node<T>>>,
element: T,
}
[...]
impl<T> LinkedList<T> {
/// Adds the given node to the front of the list.
#[inline]
fn push_front_node(&mut self, mut node: Box<Node<T>>) {
unsafe {
node.next = self.head;
node.prev = None;
let node = Some(Shared::from(Box::into_unique(node)));
match self.head {
None => self.tail = node,
Some(mut head) => head.as_mut().prev = node,
}
self.head = node;
self.len += 1;
}
}
// [...] The remaining code was left out.
}
无论实现方式如何,双链表都有其普遍的优缺点。
优点
作为链表,原理相同但略有不同。然而,当列表是一个好选择的主要观点与单链表是共享的:
-
每个项目的开销较低(但比单链表多)。
-
项目计数仅受堆内存的限制。
-
迭代时的变异是可能的。
-
实现起来更复杂,但仍然相当简单。
-
插入、删除、追加和预追加仍然高效。
-
高效的反向。
这使得双链表成为两种链表版本的优越版本,这也是为什么它通常是默认的LinkedList类型。
缺点
双链表与其较简单的兄弟共享许多缺点,用“无法回头”替换为“更多内存开销”和“更复杂的实现”。以下是列表再次:
-
索引仍然效率低下。
-
节点也分配在堆上,这也需要大量的跳跃。
-
每个节点必须存储一个额外的指针。
-
实现更复杂。
不高效的索引和迭代是许多开发者想要摆脱的问题,因此他们发明了一种更奇特的链表版本:跳表。
跳表
许多人喜欢纽约——我们也是如此。它有许多难以描述的品质;它是一个疯狂(以好的方式)、充满活力的城市,汇集了许多文化、背景、种族、活动和机会。纽约还拥有庞大的公共交通网络,几乎像欧洲的城市一样。
这一切与跳表有什么关系?一个地铁系统可以表示为一个简单的站点列表(用街道号码表示,在美国很常见):14 -> 23 -> 28 -> 33 -> 42 -> 51 -> 59 -> 68。然而,纽约地铁系统有一种称为快车的东西,可以减少覆盖更大距离所需的站点数量。
假设有人想从第 14 站去第 51 站。他们不必看到车门打开和关闭五次,他们可以在第三个站点下车。实际上,这就是纽约人在 14 街(联合广场)和 51 街之间乘坐 4、5 和 6 号线的方式。将地铁线路图侧放,看起来大致如下:

地铁服务列车在沿途的每个站点都会停靠,但快车服务列车会跳过某些较小的站点,只在旅客可以在这两种服务之间换乘的共享站停靠。在某些站点,这种跳过实际上就是列车直接驶过,有时会同时让游客和当地人感到困惑。
用数据结构表达,列表本质上是由几个列表组成的,每个列表位于不同的级别。最低级别包含所有节点,而上层级别是它们的“快车服务”,可以跳过一定数量的节点以更快地前进。这导致了一个多层列表,仅在具有这些特定级别连接的某些节点上融合在一起:

理想情况下,每个级别的节点数是上一级别节点数的一半,这意味着需要一个能够处理增长列表并保持此约束的决策算法。如果不保持此约束,搜索时间会变差,在最坏的情况下,它就像一个带有大量开销的常规链表。
节点的级别是通过概率方法决定的:只要硬币翻转结果相同,就增加级别。虽然这产生了期望的分布,但这只有当高级节点均匀分布时才有意义。在进一步阅读部分有一些关于改进版本的帖子。
此外,跳表必须是有序的才能正常工作。毕竟,如果列表的元素是无序的,列表如何知道它跳过了什么?然而,一般来说,这个——基本——跳表的节点类型看起来是这样的:
type Link = Option<Rc<RefCell<Node>>>;
struct Node {
next: Vec<Link>,
pub value: u64,
}
为了将它们连接起来,还需要一种列表类型:
struct SkipList {
head: Link,
tails: Vec<Link>,
max_level: usize,
pub length: u64,
}
最引人注目的是,struct与之前的列表非常相似。确实如此——这种关系是无可否认的,因为它们几乎共享所有属性。然而,有两个区别:tails是一个Vec<Link>,而max_level是列表的一个属性。
tails属性是一个向量,是因为每个级别都会有一个尾部,这意味着每当发生追加操作时,所有尾部可能都需要更新。此外,开发者负责提供适当的max_level值,因为更改max_level会导致构建一个新的列表!
回到之前的例子,产品团队要求更多功能!用户对列表中缺乏明确的方向感到困惑,他们很烦恼没有快速跳过开头冗长但不太有趣部分的方法。
因此,产品团队希望以下内容:
-
与已记录的交易相关的时间
-
能够快速跳转到任意时间
-
从那里开始迭代
这听起来不像是一个跳表吗?
最佳交易日志
为了按照产品团队描述的方式改进交易日志,跳表是一个完美的选择。按一个u32数字——从初始时间戳开始的毫秒偏移量——对命令进行排序怎么样?它包含的命令将被存储为与偏移量关联的字符串。
尽管如此,列表及其节点需要被实现。
与之前的实现(尤其是单链表是一个紧密的亲戚)相比,这个声明有两个主要差异。首先,下一个指针是一个数组,这是由于节点在每一个级别都有一个不同的后继节点。
其次,内容之前被命名为value,但为了区分时间戳偏移和实际内容,value已被替换为offset和command:
#[derive(Clone)]
struct Node {
next: Vec<Link>,
pub offset: u64,
pub command: String,
}
这些节点构成了这个——改进的——交易日志的基础。与单链表一样,这是通过创建一个具有头指针的类型来完成的。
列表
除了指向头部的简单指针外,列表最好还存储长度以及元素可以拥有的最大级别。这个用户提供的参数至关重要,因为如果它设置得太低,搜索将接近单链表的搜索性能(O(n))。
相反,选择一个过高的最大级别也会导致分布不均,可能会看到与水平迭代(O(n + h))一样多的垂直(向下级别)迭代,这些都不好。大 O 符号(O(n)等)将在第八章 算法评估 中讨论。
因此,这个参数需要设置得稍微反映列表未来的大小,并且最高级别最多只包含两个或三个节点:
#[derive(Clone)]
pub struct BestTransactionLog {
head: Link,
tails: Vec<Link>,
max_level: usize,
pub length: u64,
}
tails属性是一个指向每个级别尾部的向量。当添加数据时,这是更新这个交易日志的主要地方,多亏了我们的跳表只添加性质。
添加数据
基本数据结构准备就绪后,需要一个插入数据的功能。如前所述,跳表只能在某些值可以比较并按升序排列的情况下工作。这很有意义:跳过前进只有在你知道你要去哪里时才有用!
创建有序列表的一个非常有效的方法是通过进行排序插入(有时称为插入排序)。通常,这会给插入逻辑增加一些复杂性,以找到节点正确的位置。然而,由于时间戳是自然升序的,并且是一个可比较的值,这个版本的交易日志无需复杂的插入即可工作,因此需要更少的测试,并且在一年后阅读时更少头痛。
事实上,这意味着可以重用早期部分的一些代码:
pub fn append(&mut self, offset: u64, value: String) {
let level = 1 + if self.head.is_none() {
self.max_level // use the maximum level for the first node
} else {
self.get_level() // determine the level by coin flips
};
let new = Node::new(vec![None; level], offset, value);
// update the tails for each level
for i in 0..level {
if let Some(old) = self.tails[i].take() {
let next = &mut old.borrow_mut().next;
next[i] = Some(new.clone());
}
self.tails[i] = Some(new.clone());
}
// this is the first node in the list
if self.head.is_none() {
self.head = Some(new.clone());
}
self.length += 1;
}
然而,还有一个重要的补充:决定一个节点应该(也)存在于哪个级别。这就是使列表强大的原因,并且是在创建节点之前完成的:
let level = 1 + if self.head.is_none() {
self.max_level
} else {
self.get_level()
};
let new = Node::new(vec![None; level], offset, value);
这个片段显示了某些重要细节:
-
第一个节点始终存在于所有级别上,这使得搜索变得相当容易,因为算法只需要向下移动。然而,这仅得益于只添加不删除的方法!
-
每个节点的
next向量必须存储该级别索引处的后续指针,这意味着实际的长度需要是最高级别 + 1。
那么你如何决定级别呢?这是一个很好的问题,因为这是高性能跳表的核心。
升级
由于跳表中的search操作非常类似于二叉搜索树中的search操作(第五章的第一节[84f203ac-a9f6-498b-90ff-e069c41aaca0.xhtml],健壮树,将更深入地介绍这一点),它必须保持一定节点的分布才能有效。威廉·普的原始论文提出了一种通过反复抛硬币(假设p = 0.5)来创建特定级别上所需节点分布的方法。
这是提出的算法(威廉·普,跳表:平衡树的概率替代方案,图 5):
randomLevel()
lvl := 1
-- random() that returns a random value in [0...1)
while random() < p and lvl < MaxLevel do
lvl := lvl + 1
return lvl
由于这是一个简单易懂的实现,本章中的跳表也将使用这种方法。然而,有更好的方法来生成所需的分布,这留给你进一步探索。为此任务,将使用第一个外部 crate:rand。
rand由 Rust 项目提供,但发布在其自己的仓库中。当然,有关于为什么这不是默认标准库的一部分的讨论;然而,如果需要用更轻量级的东西替换,或者目标平台不受支持,有选择地导入 crate 还是不错的。
这段 Rust 代码应该可以很好地工作,并在调用时生成所需的级别:
fn get_level(&self) -> usize {
let mut n = 0;
// bool = p(true) = 0.5
while rand::random::<bool>() && n < self.max_level {
n += 1;
}
n
}
关于算法,请记住:输出的级别范围是[0, max_level],包括级别。每次插入一个值时,都会调用此函数来获取结果节点的级别,因此跳跃实际上可以使search更快。
跳跃搜索
跳表只类似于二叉搜索树,但它能够在不进行昂贵平衡的情况下实现相同的运行时间复杂度(O(log n))。这是由于跳表允许的跳跃。从逻辑上讲,这是有道理的:通过跳过多个节点,这些节点不需要被检查以确定它们是否是正在搜索的值。节点越少,比较就越少,从而导致运行时间减少。
跳跃的实现也很快,可以通过几个循环在一个函数中实现:
pub fn find(&self, offset: u64) -> Option<String> {
match self.head {
Some(ref head) => {
let mut start_level = self.max_level;
let node = head.clone();
let mut result = None;
loop {
if node.borrow().next[start_level].is_some() {
break;
}
start_level -= 1;
}
let mut n = node;
for level in (0..=start_level).rev() {
loop {
let next = n.clone();
match next.borrow().next[level] {
Some(ref next)
if next.borrow().offset <= offset =>
n = next.clone(),
_ => break
};
}
if n.borrow().offset == offset {
let tmp = n.borrow();
result = Some(tmp.command.clone());
break;
}
}
result
}
None => None,
}
}
这 30 行代码允许你在几步之内快速搜索列表。首先,必须从最高可能的级别开始,找到一个合理的起始级别,以查看哪个级别有有效的后续节点。以下是这个部分发生的事情:
let mut start_level = self.max_level;
let node = head.clone();
loop {
if node.borrow().next[start_level].is_some() {
break;
}
start_level -= 1;
}
一旦确定了这一级别,下一步就是垂直移动到所需的节点并向下移动,因为潜在的下一个节点的值大于我们正在寻找的值:
let mut n = node;
for level in (0..=start_level).rev() {
loop {
let next = n.clone();
match next.borrow().next[level] {
Some(ref next)
if next.borrow().offset <= offset =>
n = next.clone(),
_ => break
};
}
if n.borrow().offset == offset {
let tmp = n.borrow();
result = Some(tmp.command.clone());
break;
}
}
result
最后,搜索结果以包含在指定时间发出的命令的Option返回——或者None。根据失败语义,使用带有适当消息的Result可能是一个更好的选择,该消息告知用户为什么没有结果(列表为空,未找到值等)。
思考与讨论
skip list是一种迷人的数据结构,因为它实现起来相对简单,并且结合了列表中树状结构的优点,而无需进行昂贵的插入或平衡。为了可视化这种数据结构的强大功能,以下是一个比较跳表和(std::collections::)LinkedList的find()操作的图表:

跳表查找()和链表查找()的图形输出
第一张图(较高的)显示了跳表根据O(log n)类型的函数表现,这证明了实现是有效的!第二张图(较低的)显示了LinkedList中的线性搜索,所需时间以O(n)增长。原始数字甚至更加令人印象深刻:
| 大小 | 跳表 [平均纳秒] | 链表 [平均纳秒] |
|---|---|---|
| 1,000 | 311 | 825 |
| 10,000 | 438 | 17,574 |
| 100,000 | 1,190 | 428,259 |
| 1,000,000 | 2,609 | 5,440,420 |
| 10,000,000 | 3,334 | 45,157,562 |
这些数字反映了单个find()方法调用所需的纳秒(ns),这是在多次试验中平均得出的。这确实是一个用于搜索的非常好的数据结构。
优点
简而言之:搜索。检索单个项目所需的步骤数是线性的(找到项目所需的步骤数与列表中的项目数相同),在最坏的情况下。通常,时间会达到二叉搜索树的水平!
在更实际的意义上,这将提供在列表中存储大量数据并快速找到所需项的能力。然而,还有更多;以下是一些优点:
-
项目数量仅受堆内存限制
-
搜索非常高效
-
比许多树更易于实现
然而,这个列表也有缺点。
缺点
跳表的内存效率和其复杂性可能是一个问题。采用只追加的方法,本书中实现的列表避免了排序插入等一些复杂性(我们稍后会讨论)。其他要点包括以下内容:
-
内存效率:大量的指针会带来开销
-
实现复杂性
-
需要排序
-
更新成本高昂
-
将节点提升到某些级别的概率方法
根据项目的类型,这些问题可能具有威慑力。然而,还有其他类型的列表可能更适合,其中之一就是动态数组。
动态数组
数组是存储数据序列的另一种常见方式。然而,它们缺少列表的一个基本特性:扩展。数组之所以高效,是因为它们是一个长度为 n 的固定大小容器,其中每个元素的大小相等。因此,可以通过计算使用简单公式 start_address + n * element_size 跳转到的地址来访问任何元素,这使得整个过程非常快速。此外,这对 CPU 缓存非常友好,因为数据始终至少有一个跳跃距离。
使用数组来模拟列表行为的想法已经存在很长时间了(Java 1.2 在 1998 年包含了ArrayList类,但这个想法可能更早)并且它仍然是实现列表高性能的绝佳方式。Rust 的Vec<T>使用了相同的技巧。首先,这是如何构建数组列表的:

因此,这个 Rust 实现将使用数组(实际上是一个切片,但稍后再谈)作为主要的存储设施:
pub struct DynamicArray {
buf: Box<[Option<u64>]>,
cap: usize,
pub length: usize,
}
策略是,以内存和潜在的过度分配为代价来模拟动态列表大小。因此,关键点是当前分配的大小被超过,列表需要增长时。问题变成了这样:需要多少内存?
内存过少的后果是,重新分配将很快再次发生——这将消除与常规列表相比的任何性能提升。如果调整大小过大,将会浪费很多内存,并且根据程序的目标平台,这可能会成为一个大问题。因此,获取更多内存的策略是至关重要的。Rust 的Vec采用了一种智能实现,允许精确分配和简单地将当前内部数组的大小加倍(或更多)的摊销分配。
Java 的实现通过简单地创建一个新数组来增长向量,该数组将旧容量加上旧容量的位右移版本(向右一位)相加。当然,只有当这足够时才会这样做。通常,这会导致将当前容量的半数或更多添加到可能元素的数量中。自然地,在丢弃原始内存之前,所有现有元素都会(浅拷贝)到新数组中。在代码中,它看起来如下(来自 OpenJDK 8,ArrayList类,第 237 至 247 行;新增行以提高可读性):
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
这段代码具有迷人的简单性,并被全球数十亿个程序使用,本书动态数组的实现也将采用相同的策略。
再次,产品团队又有另一个功能请求。用户非常喜欢来回切换功能,所以他们想在单独的列表中保存一些值得注意的时间戳。
通常,这类需求会直接让开发者转向哈希表或字典类型。然而,这些通常不会保留插入项的顺序,如果迭代是主要关注点,它们可能不是最有效的方法。
喜爱的交易
为了清理产品团队的需求,以下是一个所需功能的列表:
-
在列表中保存交易的时间戳
-
通过索引快速访问元素,顺序不限
-
按照保存的顺序迭代项目
动态数组利用底层的扩展数组,并且运行得非常快,可以直接访问索引同时支持迭代——非常适合保存一系列值得注意的时间戳。直接索引访问提供了一种方法,可以获取存储的数据而无需遍历整个列表,并且由于事务时间戳基本上是u64数字(毫秒),数据结构可以是一个包含多个u64的动态数组。
除了之前的列表之外,这次,节点只存储数据,因此也可以是一个类型别名:
type Node = Option<u64>;
将节点设置为Option类型是必要的,因为内部切片的容量和实际长度可能不同——这意味着需要一个“空”标记:
pub struct TimestampSaver {
buf: Box<[Node]>,
cap: usize,
pub length: usize,
}
一旦声明了节点类型,它就可以在新的列表内部缓冲区中使用。这种结构被称为boxed 切片(见下一节),并以类似数组的方式存储节点。
内部数组
数组定义为在编译时具有已知大小的数据结构。Rust 对此非常认真,数组构造函数只会接受常量来表示数组的大小。[0u8; 4]将工作,但let my_array_size = 2 * 2; [0u8; my_array_size]不会。
那么,如何动态地重新分配一个新的数组呢?在 Rust 中,还有一种叫做slices的东西,它是序列数据结构的一个视图,类似于数组。当存储在Box指针中时,它们非常适合:在堆上分配,具有动态大小的数组的所有优点。
如前所述,此实现与 Java 的ArrayList增长策略相同,并且每次需要更多容量时至少增加 50%。虽然这有一个不幸的指数增长效应,但它已经为几十年的 Java——一种非常流行的语言——工作了。
Rust 实现接近其 Java 对应物;事实上,只是缺少了超大的种类:
fn grow(&mut self, min_cap: usize) {
let old_cap = self.buf.len();
let mut new_cap = old_cap + (old_cap >> 1);
new_cap = cmp::max(new_cap, min_cap);
new_cap = cmp::min(new_cap, usize::max_value());
let current = self.buf.clone();
self.cap = new_cap;
self.buf = vec![None; new_cap].into_boxed_slice();
self.buf[..current.len()].clone_from_slice(¤t);
}
你很快就会看到使用了vec![]宏——“为什么是那个?”你可能会问。不幸的是,除了vec![]宏之外,没有一种既好又安全的方法来分配这个 boxed 切片。然而,这个宏的使用允许创建一个具有适当大小的空向量,并将其转换为 boxed 切片——一个存储在Box中的切片。此后,这个切片可以从之前的切片中克隆数据。
此代码在usize长度内运行良好,这取决于程序编译的平台。
快速访问
由于底层的切片,访问索引的成本很低。实际上,它总是花费相同的时间,无论索引是什么(这使得它与之前讨论的列表不同)。因此,对at()函数的调用将简单地相应地转发:
pub fn at(&mut self, index: usize) -> Option<u64> {
if self.length > index {
self.buf[index]
} else {
None
}
}
在这里,Rust 实现又必须处理共享借用内容或克隆数据结构,这可能会需要更多的内存。在底层,一个u64会被隐式克隆。
为了满足所有要求,Iterator特质也必须实现。与双向链表不同,迭代器不能存储单个节点并从那里向前或向后移动。它必须存储指向整个列表的指针,以及当前索引:
pub struct ListIterator {
current: usize,
data: Box<[Node]>,
}
这个struct使得实现已经很明确了。根据需要移动当前指针:
impl Iterator for ListIterator {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if self.current < self.data.len() {
let item = self.data[self.current];
self.current += 1;
item
} else {
None
}
}
}
impl DoubleEndedIterator for ListIterator {
fn next_back(&mut self) -> Option<u64> {
if self.current < self.data.len() {
let item = self.data[self.current];
if self.current == 0 {
self.current = self.data.len() - 1;
} else {
self.current -= 1;
}
item
} else {
None
}
}
}
这是一个简单清晰的迭代器:没有解包、显式借用等,只是一个简单的计数器,在通过列表移动时会递增或递减。
总结
动态数组是一种非常灵活的方式来使用类似数组的结构作为列表——而且实现和使用都非常简单。实际上,添加其他功能(如prepend、在指定位置插入等)只需几行代码。
对于 Rust 来说,与其他列表类型的不同之处在于清晰定义的层次所有权:列表struct拥有内部结构,而内部结构又拥有其元素中的数据。元素之间没有链接,这可能会造成对谁拥有什么的歧义,这使得动态数组成为 Rust 代码生产力的一个很好的例子。
优点
除了只有几行代码之外,动态数组还有不少优点:
-
速度:数组/切片使事情变得非常快
-
简单且快速访问元素
-
清晰的拥有结构
-
快速追加和迭代
-
非常 CPU 缓存友好
一件事很清楚:在许多情况下它都很快。但是,当动态数组不是最佳选择时呢?
缺点
然而,这种类型的列表也非常内存低效,而且其刚性的结构也可能是一个缺点:
-
除了追加操作之外,其他操作将需要移动元素
-
增长策略不是内存高效的
-
需要一个单独的大块内存
-
大小受
usize类型限制,该类型在不同平台上有所不同 -
随着列表大小的增加,增长速度会降低
这就结束了这次对列表领域的探索之旅,希望是以成功的方式结束。在下一章开始之前,快速总结一下所有重要的部分。
概述
列表无处不在!虽然这是真的,但这是一个让一切变得更难的事实。哪种列表是完成这项工作的正确工具?它在添加和稍后查找元素时表现如何?如果我的有效负载大小真的很小,开销是什么?
这些都是程序员今天面临的问题,作者希望对这些决策提供一些指导。回顾一下:最简单的是单链表,双向链表在此基础上构建。跳表本质上是由多层单链表组成,以内存开销为代价提供出色的搜索性能。最后但同样重要的是,还有动态数组——一种列表类型,它像列表一样包装和管理数组以存储数据。
在 Rust 中实现这些结构需要许多指向堆的指针,特别是 Rc 和 RefCells,它们从本章的开始到结束都是伴侣。当你考虑单链表的结构时,每个项目都需要访问下一个项目——但具有可预测的大小。这个事实要求程序员使用引用,但如果这个列表在程序中传递,可能本身就在堆上,会如何呢?结果是简化事情并将它们从一开始就放在堆上,并使用内部可变的 Rc 和 RefCell 结构来实现这一点。
同样,双链表也是如此。除了单链表兄弟提供的正向(下一个)指针外,双链表节点还需要指向前方。因此,每个项目除了有效载荷外还有两个指针,这使得一系列强大的功能成为可能,如即时列表反转。
与此同时,跳表在本章中已被实现为单链表(但当然也可以是双链表)。它们的主要改进是快速搜索包含数据的强大能力——就像二叉搜索树一样。这意味着,几乎无论大小如何,查找性能在绝对和相对意义上都远远优于常规列表。不幸的是,这需要每个节点有更多的指针。
最流行的数据结构可能是动态数组。通常被称为 Vec<T>(Rust)、ArrayList(Java)、List<T>(C#)或简单地 list()(Python),这些是在数组周围包装的智能分配和重新分配的包装器。通过这样做,它们可以满足对快速元素访问和快速迭代的需要,但代价是在调整大小时进行浅拷贝,以及拥有大量可用内存。这些是存储少量小到中等大小项目的最佳选择。
下一章将深入探讨更非线性的数据结构:树。这些结构通过其构建方式提供了有趣的功能,并且对于读密集型任务来说是一个很好的选择。
问题
-
为什么在 Rust 中实现链表很棘手?
-
Rust 的标准库
LinkedList是如何工作的? -
双链表和跳表之间有什么区别?
-
动态数组在元素访问方面是否优于跳表?
-
动态数组为什么是 CPU 缓存的绝佳选择?
-
动态数组还有哪些增长策略?
-
Rust 对数组非常重视,那么动态数组在内部使用什么呢?
进一步阅读
您可以参考以下链接获取更多信息:
-
《用过多的链表学习 Rust》 (
cglab.ca/~abeinges/blah/too-many-lists/book/README.html) -
实现
Iterator特性 (doc.rust-lang.org/std/iter/index.html#implementing-iterator) -
跳表:正确实现 (
doc.rust-lang.org/std/iter/index.html#implementing-iterator) -
跳表:平衡树的概率替代方案,威廉·普(William Pugh)(
www.epaperpress.com/sortsearch/download/skiplist.pdf)
第五章:强健的树
列表非常适合存储大量项目,但查找特定元素怎么办?在前一章中,跳表在简单地查找项目时,比常规链表表现要好得多。为什么?因为它利用了一种类似于平衡树结构的迭代策略:在那里,内部顺序让算法能够有策略地跳过项目。然而,这仅仅是开始。许多库、数据库和搜索引擎都是基于树构建的;事实上,每当程序编译时,编译器都会创建一个抽象语法树。
基于树的数据库结构结合了各种智能想法,我们将在本章中探讨,所以你可以期待以下内容:
-
实现和理解二叉搜索树
-
了解自平衡树
-
前缀或后缀树是如何工作的
-
优先队列内部使用什么
-
图,最通用的树结构
二叉搜索树
树结构几乎就像一个链表:每个节点都有分支——在二叉树的情况下,有两个分支——代表该节点的子节点。由于这些子节点有自己的子节点,节点数量呈指数增长,构建了一个类似常规树倒置的层次结构。
二叉树是这些结构的一个子集,只有两个分支,通常称为左和右。然而,这并不本质上帮助树的性能。这就是为什么使用二叉搜索树,其中左表示小于或等于其父节点的值,而右表示大于该父节点的任何值,被建立起来的原因!
如果这让你感到困惑,别担心;会有代码。首先,一些词汇:你该如何称呼树的远端?叶子。剪掉分支?修剪。每个节点的分支数量?分支因子(二叉树的分支因子为 2)。
很好,把这些都弄清楚后,节点就可以展示了——尽管它们看起来很像前一章中的双链表:
type Tree = Option<Box<Node>>;
struct Node {
pub value: u64,
left: Tree,
right: Tree,
}
同样,树结构本身只是对根节点的指针:
pub struct BinarySearchTree {
root: Tree,
pub length: u64,
}
在你能够熟悉新的数据结构之前,前一章的产品团队又回来了!你们在改进事务日志方面做得很好,他们希望继续这种进步,并构建一个物联网(IoT)设备管理平台,以便用户可以使用数字名称注册设备,并在以后搜索它。然而,搜索必须非常快,这对于许多宣布将超过 10,000 个设备纳入新系统的客户来说尤其关键!
这不是一个用二叉搜索树获得更多经验的好机会吗?
物联网设备管理
物联网空间中的设备管理主要关于存储和检索特定的设备或设备孪生。这些对象通常存储地址、配置值、加密密钥或其他东西,以便没有人需要手动连接。因此,保持库存至关重要!
目前,产品团队决定采用数字“名称”,以便比竞争对手更快地可用,并保持要求简短:
-
存储物联网设备对象(包含 IP 地址、数字名称和类型)
-
通过数字名称检索物联网对象
-
遍历物联网对象
树的一个很好的用途:数字名称可以用来创建一个树并快速搜索它。存储此物联网设备信息的基本对象看起来像这样:
#[derive(Clone, Debug)]
pub struct IoTDevice {
pub numerical_id: u64,
pub address: String,
}
为了简单起见,这个对象将在代码中直接使用(添加泛型并不太复杂,但会超出本书的范围):
type Tree = Option<Box<Node>>;
struct Node {
pub dev: IoTDevice,
left: Tree,
right: Tree,
}
从这个基本实现开始,可以实施必要的操作,add和find。
更多设备
与列表不同,树在插入时做出一个主要决定:新元素将放在哪一侧?从根节点开始,每个节点的值与将要插入的值进行比较:这是否大于或小于那个值?任一决定都会导致向下进入不同的子树(左或右)。
此过程(通常是递归地)重复,直到目标子树为None,这正是新值插入的位置——作为树的叶子。如果这是第一个进入树中的值,它就成为根节点。这里有一些问题,更有经验的程序员可能已经有一种奇怪的感觉:如果你按升序插入数字会发生什么?
这些感觉是有道理的。按升序插入(例如,1,2,3,4)将导致一个基本上是伪装成列表的树!这也被称为(非常)不平衡的树,将不会有其他树的所有好处:
1
/ \
2
/ \
3
/ \
4
在本章中,我们将更深入地探讨平衡树以及为什么平衡树对于实现高性能很重要。为了避免与二叉搜索树相关的这种陷阱,理想情况下第一个要插入的值应该是所有元素的中位数,因为它将被用作根节点,如下面的代码片段所示:
pub fn add(&mut self, device: IoTDevice) {
self.length += 1;
let root = mem::replace(&mut self.root, None);
self.root = self.add_rec(root, device);
}
fn add_rec(&mut self, node: Tree, device: IoTDevice) -> Tree {
match node {
Some(mut n) => {
if n.dev.numerical_id <= device.numerical_id {
n.left = self.add_rec(n.left, device);
Some(n)
} else {
n.right = self.add_rec(n.right, device);
Some(n)
}
}
_ => Node::new(device),
}
}
将代码分为两部分,这部分代码递归地遍历树以找到适当的位置,并将新值作为叶子附加在那里。实际上,插入并不比常规的树遍历在搜索或迭代中不同。
递归是函数调用自身。想想电影《盗梦空间》——梦中有梦,梦中有梦。这是同样的概念。在编程中有一些影响:原始函数最后被销毁,因为只有当所有递归调用返回后它才完成。这也意味着所有内容都存在于更小的栈上,这可能导致在调用过多时栈溢出!通常,递归算法也可以迭代实现,但它们更难理解——所以要明智选择!
查找正确的一个
能够将设备添加到树中,更重要的是能够再次检索它们。就像前一章中的跳表一样,这种检索理想情况下运行在 O(log n) 时间内,这意味着在搜索时将跳过大多数元素。
因此,如果树在某一方向上倾斜,性能接近 O(n),查看的元素更多,从而使得搜索变慢。由于倾斜的树更像列表,递归插入算法会因只有单个项目的“层级”数量众多而快速溢出栈。否则,递归算法只调用树的高度次数,在平衡树中这是一个相当小的数字。算法本身类似于之前展示的插入算法:
pub fn find(&self, numerical_id: u64) -> Option<IoTDevice> {
self.find_r(&self.root, numerical_id)
}
fn find_r(&self, node: &Tree, numerical_id: u64) -> Option<IoTDevice> {
match node {
Some(n) => {
if n.dev.numerical_id == numerical_id {
Some(n.dev.clone())
} else if n.dev.numerical_id < numerical_id {
self.find_r(&n.left, numerical_id)
} else {
self.find_r(&n.right, numerical_id)
}
}
_ => None,
}
}
虽然这个片段的目的是找到特定的节点,但它与列举每个设备有着密切的关系——这是此服务用户肯定希望拥有的功能。
查找所有设备
在访问每个节点时遍历树并执行回调可以以三种方式完成:
-
前序,执行回调 在下降之前
-
中序,它执行回调 在下降到左子树之后,但在下降到右子树之前
-
后序,其中回调在 下降后 执行
这些遍历策略中的每一种都会产生不同的树元素顺序,其中中序产生排序输出,而前序和后序则创建更结构化的排序。对于我们的用户来说,中序遍历将提供最佳体验,因为它还让他们更好地推理预期的结果,如果以列表形式显示,则更容易导航。
虽然以递归方式实现这种行走非常简单,但提供一个迭代器更符合用户习惯(就像前一章中的列表一样),并且它使许多附加功能成为可能,例如 map() 和 filter()。然而,这种实现必须是迭代的,这使得它更复杂,并减少了树的一些效率。
因此,此树支持一个 walk() 函数,每次遇到节点时都会调用提供的函数,这可以用来填充迭代器的向量:
pub fn walk(&self, callback: impl Fn(&IoTDevice) -> ()) {
self.walk_in_order(&self.root, &callback);
}
fn walk_in_order(&self, node: &Tree, callback: &impl Fn(&IoTDevice) -> ()) {
if let Some(n) = node {
self.walk_in_order(&n.left, callback);
callback(&n.dev);
self.walk_in_order(&n.right, callback);
}
}
这里展示了如何使用这种行走方法构建向量的示例:
let my_devices: RefCell<Vec<IoTDevice>> = RefCell::new(vec![]); tree.walk(|n| my_devices.borrow_mut().push(n.clone()));
拥有这种行走能力,目前所有需求都已得到满足。
总结
由于它们的简单性,二叉搜索树非常高效。事实上,本节中整个树实现只用不到 90 行 Rust 代码完成,每个函数大约 10 行。
二叉树的效率允许大量使用递归,这通常会导致与迭代版本相比更容易理解的函数。在理想情况下,即当树完全平衡时,函数只需要处理 log2(n) 个节点(n 是节点总数)——在一个包含 100 万个元素的树中只有 19 个!
不平衡的树会显著降低性能,并且它们很容易意外创建。最不平衡的树是通过插入已经排序的值创建的,这会在搜索性能上造成很大的差异:
test tests::bench_sorted_insert_bst_find ... bench: 16,376 ns/iter (+/- 6,525)
test tests::bench_unsorted_insert_bst_find ... bench: 398 ns/iter (+/- 182)
这些结果反映了跳表和前一章中的双链表之间的差异。
优点
回顾一下,二叉搜索树对用户来说有许多好处:
-
简单实现
-
高效且快速的搜索
-
遍历允许不同的排序
-
非常适合大量未排序的数据
缺点
通过使用二叉搜索树,其缺点很快就会变得明显:
-
最坏情况下的性能与链表相当
-
不平衡的树很容易意外创建
-
不平衡的树不能“修复”
-
递归算法在不平衡的树上可能会溢出
显然,许多更深层次的问题都源于树以某种方式不平衡——对此有一个解决方案:自平衡二叉搜索树。
红黑树
在之前的树结构中,有一个主要的缺点:插入到树中的先前未知的键序列无法排序。想想大多数标识符是如何生成的;它们通常是递增的数字。对这些数字进行洗牌并不总是有效,尤其是在它们逐渐添加时。由于这会导致不平衡的树(极端情况表现得就像一个列表),鲁道夫·拜尔提出了一个特殊的自平衡树的想法:红黑树。
这棵树是一个二叉搜索树,它在插入后添加了重新平衡的逻辑。在这个操作中,知道何时停止“平衡”至关重要——这就是发明者想到使用两种颜色:红色和黑色。
在文献中,红黑树被描述为满足一系列规则的二叉搜索树:
-
根节点始终是黑色
-
每个其他节点要么是红色,要么是黑色
-
所有叶子(通常是
null/NIL值)都被认为是黑色的 -
红色节点只能有黑色子节点
-
从根到其叶子的任何路径都有相同数量的黑色节点
通过强制执行这些规则,可以通过编程验证树是否平衡。这些规则是如何做到这一点的呢?规则 4 和 5 提供了答案:如果每个分支都必须有相同数量的黑色节点,那么两边不可能有显著的一边比另一边长,除非有很多红色节点。
这些能有多少呢?最多和黑色节点一样多——因为它们不能有红色子节点。因此,一个分支不能显著超过另一个,使得这棵树保持平衡。验证函数的代码很好地说明了这一点:
pub fn is_a_valid_red_black_tree(&self) -> bool {
let result = self.validate(&self.root, Color::Red, 0);
let red_red = result.0;
let black_height_min = result.1;
let black_height_max = result.2;
red_red == 0 && black_height_min == black_height_max
}
// red-red violations, min black-height, max-black-height
fn validate(
&self,
node: &Tree,
parent_color: Color,
black_height: usize,
) -> (usize, usize, usize) {
if let Some(n) = node {
let n = n.borrow();
let red_red = if parent_color == Color::Red && n.color == Color::Red {
1
} else {
0
};
let black_height = black_height + match n.color {
Color::Black => 1,
_ => 0,
};
let l = self.validate(&n.left, n.color.clone(), black_height);
let r = self.validate(&n.right, n.color.clone(), black_height);
(red_red + l.0 + r.0, cmp::min(l.1, r.1), cmp::max(l.2, r.2))
} else {
(0, black_height, black_height)
}
}
就像二叉搜索树一样,树中的每个节点都有两个子节点,键要么大于、等于或小于当前节点的键。除了键(如键值对)之外,节点还存储一个颜色,插入时为红色,并指向其父节点。为什么?这是因为所需的平衡,这将在后面描述。首先,这可以是一个典型的节点:
type BareTree = Rc<RefCell<Node>>;
type Tree = Option<BareTree>;
struct Node {
pub color: Color,
pub key: u32,
pub parent: Tree,
left: Tree,
right: Tree,
}
使用这些节点,可以创建一个树,就像二叉搜索树一样。实际上,插入机制完全相同,只是设置了父指针。新插入的节点总是着色为红色,一旦就位,树可能会违反规则。只有在这种情况下,才是寻找和修复这些问题的时机。
插入后,树处于无效状态,需要一系列步骤来恢复红黑树属性。这个系列,由旋转和重新着色*组成,从插入节点开始,向上到根节点被认为是有效的。总之,红黑树是一种旋转和重新着色直到恢复平衡的二叉搜索树。
重新着色只是将指定节点的颜色更改为特定颜色,这在进行树平衡的最后一步发生。旋转是一组三个节点(当前节点、其父节点和其祖父节点)的操作。它通过围绕指定节点左旋或右旋来折叠类似列表的链到树中。结果是改变了层次结构,中心节点的左子节点或右子节点位于顶部,其子节点相应地调整:

显然,这个例子太简单了,它只能在最初的几次插入中发生。在重新定义一组节点层次结构之后,旋转需要重新着色。为了增加复杂性,旋转通常会连续发生:

前面的树已经插入了一个节点,现在违反了规则 4:红色节点上不能有红色子节点。下一步是确定需要哪些步骤来建立平衡。为此,检查父节点的兄弟节点的颜色(即叔叔的颜色)。红色表示将两个兄弟节点都着色为黑色,并将它们的父节点着色为红色不会使树无效,并修复条件。这里不是这种情况(叔叔是 None,这意味着黑色),需要进行一些旋转:

第一步是将节点排列成一条左子节点的链(在这种情况下),这是通过围绕中心节点,插入节点的父节点旋转来完成的:

一旦链对齐,第三个节点(祖父节点)向右旋转就通过提升中间节点(“最年轻”的节点/插入节点)创建了一个有效的子树,前父节点和祖父节点分别位于左侧和右侧。然后,新的星系被重新着色,程序重新开始,以新子树的根为中心(在这个例子中,尽管如此,树已经有效):

这些步骤可以重复进行,直到树有效且到达根节点(这可能与您开始时的情况不同)。这个根节点也被启发式地涂成黑色,这不会违反规则,但缩短了潜在的红色-红色违规。有关修复操作的代码,请参阅以下小节。
产品团队甚至将这段时间用来强调他们的新产品理念。物联网平台相当受欢迎,客户一直在大量使用它——并且在他们继续添加顺序编号的设备时,他们认识到了重大的减速。这导致了愤怒的客户服务电话,然后转向产品团队寻求帮助——现在是时候实施解决方案并替换当前的设备管理树了。
更好的物联网设备管理
我们用户面临的问题很清楚:如果一个二叉搜索树遇到排序数据(如增量 ID),它只能始终向一侧追加,从而创建一个不平衡的树。红黑树能够在插入时执行更多操作(如旋转子树)的代价下处理这个问题,这对用户来说是可接受的。
这棵树与二叉搜索树有类似的节点,增加了颜色字段和父字段,后者相对于二叉搜索树来说,变化更大。多亏了回指,树节点不能仅拥有指向子节点和父节点的指针(因为,谁拥有这个值,父节点还是子节点?),这需要 Rust 中一个众所周知的模式:内部可变性。正如在前一章中讨论的,RefCell 拥有数据的内存部分,并在运行时处理借用检查,以便可以获取可变和不可变引用:
type BareTree = Rc<RefCell<Node>>;
type Tree = Option<BareTree>;
struct Node {
pub color: Color,
pub dev: IoTDevice,
pub parent: Tree,
left: Tree,
right: Tree,
}
impl Node {
pub fn new(dev: IoTDevice) -> Tree {
Some(Rc::new(RefCell::new(Node {
color: Color::Red,
dev: dev,
parent: None,
left: None,
right: None,
})))
}
}
一旦设置到位,就可以添加设备。
更多的设备
一旦创建了树,add() 函数允许用户添加设备。然后,树继续像二叉搜索树一样插入新键,只是在之后立即检查和修复任何错误。在二叉搜索树中可以使用简单的 if 条件来决定前进的方向,而在红黑树中,方向有更大的影响,嵌套 if 条件会导致混乱、难以阅读的代码。
因此,让我们首先创建 enum,这样每当需要决定方向(例如,插入,节点相对于另一个节点的位置等)时,我们都可以依赖这个 enum。对于树的颜色也是如此:
#[derive(Clone, Debug, PartialEq)]
enum Color {
Red,
Black,
}
#[derive(PartialEq)]
enum RBOperation {
LeftNode,
RightNode,
}
现在,add()函数可以使用 Rust 的 match 子句来优雅地组织两个分支:
pub fn add(&mut self, device: IoTDevice) {
self.length += 1;
let root = mem::replace(&mut self.root, None);
let new_tree = self.add_r(root, device);
self.root = self.fix_tree(new_tree.1);
}
fn add_r(&mut self, mut node: Tree, device: IoTDevice) -> (Tree, BareTree) {
if let Some(n) = node.take() {
let new: BareTree;
let current_device = n.borrow().dev.clone();
match self.check(¤t_device, &device) {
RBOperation::LeftNode => {
let left = n.borrow().left.clone();
let new_tree = self.add_r(left, device);
new = new_tree.1;
let new_tree = new_tree.0.unwrap();
new_tree.borrow_mut().parent = Some(n.clone());
n.borrow_mut().left = Some(new_tree);
}
RBOperation::RightNode => {
let right = n.borrow().right.clone();
let new_tree = self.add_r(right, device);
new = new_tree.1;
let new_tree = new_tree.0.unwrap();
new_tree.borrow_mut().parent = Some(n.clone());
n.borrow_mut().right = Some(new_tree);
}
}
(Some(n), new)
} else {
let new = Node::new(device);
(new.clone(), new.unwrap())
}
}
代码的主要部分之一是“检查”两个设备,即比较它们以提供它们应该附加到的方向。这种比较是在一个单独的函数中完成的,以提高可维护性:
fn check(&self, a: &IoTDevice, b: &IoTDevice) -> RBOperation {
if a.numerical_id <= b.numerical_id {
RBOperation::LeftNode
} else {
RBOperation::RightNode
}
}
虽然这个树会将每个更大的项目附加到左侧(这似乎很奇怪),但算法并不关心;无论怎样它们都会工作——通过将其封装到自己的函数中,改变变得快速且简单。
平衡这棵树
在节点正确添加后,fix_tree()负责迭代地恢复红黑树的性质——这是非常好地描述性和演示性的,但它很长,所以让我们将其分成几个部分。最初,函数确定它是否应该停止(或甚至不开始)——这仅在两种情况下发生:
-
当它已经是根节点时
-
当当前检查节点的父节点是红色时
显然,前者是常规的退出标准,因为循环优化并移动当前指针(n代表节点)从底部向树根移动以停止在那里:
fn fix_tree(&mut self, inserted: BareTree) -> Tree {
let mut not_root = inserted.borrow().parent.is_some();
let root = if not_root {
let mut parent_is_red = self.parent_color(&inserted) == Color::Red;
let mut n = inserted.clone();
while parent_is_red && not_root {
if let Some(uncle) = self.uncle(n.clone()) {
一旦开始,循环立即寻找特定节点的叔叔节点(即祖父的第二个孩子)及其颜色。叔叔节点可以是黑色(或None)或红色,下面将讨论这两种情况。同样重要的是要找出它是哪个叔叔,因此当前指针指向哪个节点:左节点或右节点。让我们看一下下面的代码片段:
if let Some(uncle) = self.uncle(n.clone()) {
let which = uncle.1;
let uncle = uncle.0;
match which {
RBOperation::LeftNode => {
// uncle is on the left
// ...
RBOperation::RightNode => {
// uncle is on the right
// ...
这个信息对于确定这个树区域的旋转顺序至关重要。实际上,两个分支将执行相同的步骤,但相反:
// uncle is on the left
let mut parent = n.borrow().parent
.as_ref().unwrap().clone();
if uncle.is_some()
&& uncle.as_ref().unwrap().borrow()
.color == Color::Red
{
let uncle = uncle.unwrap();
parent.borrow_mut().color = Color::Black;
uncle.borrow_mut().color = Color::Black;
parent.borrow().parent.as_ref()
.unwrap().borrow_mut().color =
Color::Red;
n = parent.borrow().parent.as_ref()
.unwrap().clone();
} else {
if self.check(&parent.borrow().dev,
&n.borrow().dev)
== RBOperation::LeftNode
{
// do only if it's a right child
let tmp = n.borrow().parent.as_ref()
.unwrap().clone();
n = tmp;
self.rotate(n.clone(),
Rotation::Right);
parent = n.borrow().parent.as_ref()
.unwrap().clone();
}
// until here. then for all black uncles
parent.borrow_mut().color = Color::Black;
parent.borrow().parent.as_ref()
.unwrap().borrow_mut().color =
Color::Red;
let grandparent = n
.borrow()
.parent
.as_ref()
.unwrap()
.borrow()
.parent
.as_ref()
.unwrap()
.clone();
self.rotate(grandparent, Rotation::Left);
}
这段代码包含大量的unwrap()、clone()和borrow()实例,这是内部可变性模式的后果。在这种情况下,宏可以帮助减少代码的冗长性。
一部分树的运算完成后,下一迭代通过检查是否存在红色-红色违规来准备,以查看循环是否需要继续。
在主循环退出后,当前节点的指针向上移动到树根节点(毕竟,这将是函数的返回值)并着色为黑色。为什么?这是一个快捷解决方案,否则会导致另一个迭代需要执行许多更昂贵的步骤,而红黑树的规则要求根节点必须是黑色的:
not_root = n.borrow().parent.is_some();
if not_root {
parent_is_red = self.parent_color(&n) == Color::Red;
}
}
while n.borrow().parent.is_some() {
let t = n.borrow().parent.as_ref().unwrap().clone();
n = t;
}
Some(n)
} else {
Some(inserted)
};
root.map(|r| {
r.borrow_mut().color = Color::Black;
r
})
使用这个快捷方式,可以返回一个有效的树,可以将其设置为新的根。然而,树的主要目的是找到东西,这与常规的二叉搜索树并没有太大的不同。
现在找到正确的
这段代码几乎可以重用自二叉搜索树。除了borrow()调用(而不是简单的解引用或*运算符)增加了处理时间外,它们提供了持续一致的搜索速度。为了更好地重用现有函数,要找到的值被封装到一个虚拟节点中。这样,就不需要为比较节点创建额外的接口:
pub fn find(&self, numerical_id: u64) -> Option<IoTDevice> {
self.find_r(
&self.root,
&IoTDevice::new(numerical_id, "".to_owned(), "".to_owned()),
)
}
fn find_r(&self, node: &Tree, dev: &IoTDevice) -> Option<IoTDevice> {
match node {
Some(n) => {
let n = n.borrow();
if n.dev.numerical_id == dev.numerical_id {
Some(n.dev.clone())
} else {
match self.check(&n.dev, &dev) {
RBOperation::LeftNode => self.find_r(&n.left, dev),
RBOperation::RightNode => self.find_r(&n.right, dev),
}
}
}
_ => None,
}
}
这又是树的递归遍历,直到找到指定的值。此外,"常规"的树遍历也被添加到了红黑树变体中:
pub fn walk(&self, callback: impl Fn(&IoTDevice) -> ()) {
self.walk_in_order(&self.root, &callback);
}
fn walk_in_order(&self, node: &Tree, callback: &impl Fn(&IoTDevice) -> ()) {
if let Some(n) = node {
let n = n.borrow();
self.walk_in_order(&n.left, callback);
callback(&n.dev);
self.walk_in_order(&n.right, callback);
}
}
在这些部分修复后,平台的表现始终很快!
总结
红黑树是优秀的自平衡二叉树,类似于AVL(代表Adelson-Velsky and Landis)树。两者几乎同时出现,但 AVL 树由于分支高度差较小而被认为是优越的。无论使用哪种树结构,两者都比它们的简单兄弟二叉搜索树快得多。使用排序数据在插入(本例中为 100,000 个元素)上的基准测试显示了平衡树和不平衡树之间的差异:
test tests::bench_sorted_insert_bst_find ... bench: 370,185 ns/iter (+/- 265,997)
test tests::bench_sorted_insert_rbt_find ... bench: 900 ns/iter (+/- 423)
平衡树的另一种变体是 2-3-4 树,这是一种可以将红黑树转换成的数据结构。然而,2-3-4 树,就像本章后面将要提到的 B 树一样,是非二叉的。因此,它在本章的后面简要讨论,但我们鼓励您寻找其他来源以获取详细信息。
在 Rust 中实现红黑树的一个主要优点是,在旋转或"解包"节点祖父节点时,对借用和所有权的深入理解。在编程练习中实现自己的版本是非常推荐的!
优点
红黑树相对于常规二叉搜索树有几个可取的特性:
-
平衡使搜索始终快速
-
可预测,内存使用低
-
插入操作相对较快
-
二叉树的简单性
-
容易验证
然而,这种数据结构也有一些显著的缺点,尤其是在计划实现它的时候!
缺点
速度很快,但你的实现能否达到这一点?让我们来看看红黑树的缺点:
-
实现复杂,尤其是在 Rust 中
-
并发写入需要锁定整个树
-
与二叉搜索树相比,性能很好,但其他树在相同复杂度下表现更好
-
跳表(来自上一章)在更好的并发性和更简单的实现上表现相似
在任何情况下,红黑树都是深入复杂二叉树结构的绝佳之旅。一种更奇特的二叉树结构是堆(不要与主内存的一部分混淆)。
堆
由于二叉树是树的最基本形式,因此设计了多种变体,用于特定目的。红黑树是初始树的先进版本,而二叉堆是二叉树的一种版本,它不便于搜索。
实际上,它有一个特定的目的:找到节点的最大值或最小值。这些堆(最小堆或最大堆)是以一种方式构建的,使得根节点总是具有所需属性(最小或最大)的值,因此可以以恒定时间检索——也就是说,获取所需的时间总是相同的。一旦检索到,树将以一种方式恢复,使得下一次操作可以正常工作。那么,这是如何实现的呢?
堆(无论是最小堆还是最大堆)都有效,因为一个节点的子节点总是具有整个树相同的属性。在最大堆中,这意味着根节点是序列中的最大值,因此它必须是它的子节点中的最大值(最小堆的情况与此相同,只是方向相反)。虽然没有特定的顺序(例如左节点大于右节点),但有一个惯例是对于最大堆优先考虑右节点,对于最小堆优先考虑左节点。
在插入一个新节点后,它会被添加到最后,然后需要在树中确定其位置。完成这一操作的策略很简单:查看父节点;如果它更大(在最大堆中),则交换这两个节点,并重复此操作,直到不再适用或成为根节点。我们称此操作为上堆。
同样,删除操作也是这样进行的。一旦删除,现在为空的槽位会被树的一个叶子节点替换——这个叶子节点要么是最小值(最大堆)要么是最大值(最小堆)。然后,实现与插入相同的比较,但方向相反。将此节点与其子节点比较和交换可以恢复堆的性质,这被称为下堆。
如果你注意到了一个节点的旅程,有一个细节可能会对你显而易见:树总是“填满”的。这意味着每一层都是完全填充的(也就是说,每个节点都有两个孩子),使其成为一个完全二叉树,并保持总顺序。这是一个让我们可以在数组(动态或静态)中实现此树并使跳跃变得便宜的性质。一旦你看到一些图表,一切都会变得清晰:

通常,堆被用来创建某种类型的优先队列,这得益于快速检索最高或最低值项的能力。一个非常基本的堆可以用 Rust 作为数组实现,这将提供使其工作所需的一切,但不会像Vec那样方便。
在物联网设备平台取得巨大成功之后,计划增加一个附加功能。产品团队正在寻求一种高效处理来自设备消息的方法,以便客户只需处理消息的实际处理,跳过“管道”代码。由于处理可以在(短)间隔内执行,他们需要一个快速排序它们的方法——理想情况下,让消息最多的设备先来。
这听起来像是堆数据结构,不是吗?实际上,它可以是最大堆。
一个巨大的收件箱
通常,堆被用作各种优先队列。这样的队列存在于任何资源受限的环境中(以及其他所有地方),但它们的目的是以有序的方式输出事物。通过使用消息数量来确定消息通知的优先级,堆可以完成这个特性的繁重工作。在深入探讨难点之前,这里有一些包含信息的数据位:
#[derive(Clone, Debug)]
pub struct MessageNotification {
pub no_messages: u64,
pub device: IoTDevice,
}
理念是使用消息的数量作为指示器,以确定首先轮询哪个设备,这就是为什么需要设备。使用这种类型,堆不需要任何特定的节点或链接类型来工作:
pub struct MessageChecker {
pub length: usize,
heap: Vec<Box<MessageNotification>>,
}
这里有两个有趣的观点:其底层结构是一个常规的 Vec<T>,这是因为它具有扩展能力(Rust 的数组在编译时确定大小),以及 push 或 pop 的功能。
另一个值得注意的修改是,不需要 Option,这从代码中移除了一个检查,并使其更容易阅读。然而,由于堆的许多操作与直接、基于 1 的索引访问很好地配合工作,在到达 Vec<T> 之前必须对索引进行转换。
那么,数据是如何进入的?
消息的进入
一旦消息到达,当上堆操作“冒泡”项目直到找到其适当位置时,它会被推送到数组的末尾。在 Rust 代码中,这看起来是这样的:
pub fn add(&mut self, notification: MessageNotification) {
self.heap.push(Box::new(notification));
self.length = self.heap.len();
if self.length > 1 {
let mut i = self.length;
while i / 2 > 0 && self.has_more_messages(i, i / 2) {
self.swap(i, i / 2);
i /= 2;
}
}
}
初始时,新的通知位于 Vec<T> 的末尾的 Box 中,通过 push() 插入。然后一个简单的 while 循环通过在 has_more_messages() 函数为真时重复交换来冒泡新的添加项。何时为真?让我们看看代码:
fn has_more_messages(&self, pos1: usize, pos2: usize) -> bool {
let a = &self.heap[pos1 - 1];
let b = &self.heap[pos2 - 1];
a.no_messages >= b.no_messages
}
通过封装这个函数,如果需要,可以轻松地将堆转换为最小堆——索引转换也在这里封装起来。
取出数据需要在一个名为 pop() 的函数中反向执行这个过程。
取出消息
从 Vec<T> 中移除第一个元素并不困难——实际上,Vec<T> 随带一个 swap_remove() 函数,它正好符合堆的需求:通过用最后一个元素替换它来从 Vec<T> 中移除第一个元素!这使得代码显著缩短,因此更容易推理:
pub fn pop(&mut self) -> Option<MessageNotification> {
if self.length > 0 {
let elem = self.heap.swap_remove(0);
self.length = self.heap.len();
let mut i = 1;
while i * 2 < self.length {
let children = (i * 2, i * 2 + 1);
i = if self.has_more_messages(children.0, children.1) {
if self.has_more_messages(children.0, i) {
self.swap(i, children.0);
children.0
} else {
break;
}
} else {
if self.has_more_messages(children.1, i) {
self.swap(i, children.1);
children.1
} else {
break;
}
}
}
Some(*elem)
} else {
None
}
}
显然,这段代码并不短——那么有什么问题呢?冒泡下降。向下交换需要查看子节点(位于i * 2和i * 2 + 1的位置)以确定下一次迭代应该继续的位置。
总结
堆数据结构实现起来非常简单。没有冗长的展开、借用或其他调用,指针由Vec拥有,可以轻松交换。除此之外,上堆操作只是一个while循环,就像(稍微复杂一点的)下堆函数一样。
然而,堆的另一个典型用途是排序!考虑将一组数字放入堆中而不是MessageNotification对象,它们将按顺序输出。由于上堆/下堆操作的效率,该排序算法的最坏情况运行时间非常好——但更多内容将在第九章“排序事物”中介绍。
优点
紧凑且低复杂度的实现使二叉堆成为任何需要排序数据结构的绝佳选择。其他好处包括以下内容:
-
效率很高的列表排序方法
-
在并发情况下表现良好
-
存储有序数组的一种非常高效的方法
然而,也存在一些缺点。
缺点
堆通常很好,但有两大缺点限制了它们的使用:
-
除了队列或排序之外的使用场景很少
-
有更好的排序方法
二叉堆是二叉树中的最后一种,下一节将介绍另一种相当奇特的树形结构变体:前缀树。
前缀树
前缀树是另一种有趣的数据结构——特别是它的发音方式!根据你的母语,直觉可能会指导你一种方法,但——根据维基百科——这个名字是感谢爱德华·弗雷德金,他以前缀树的方式发音这种树,即像在检索中发音trie。许多英语使用者倾向于说“try”。
在解决这个问题之后,前缀树实际上做了什么,以至于值得有一个不同的名字?结果是使用检索并不是一个坏主意:前缀树存储字符串。
想象一下,必须以某种方式存储这本书的全部词汇,以便找出某些单词是否包含在书中。这该如何高效地完成?
在前面的章节之后,你应该已经有了答案,但如果考虑字符串——它们作为char实例的数组或列表存储——这将使用大量的内存。由于每个单词都必须使用英文字母表中的字母,我们不能利用这一点吗?
前缀树做的是类似的事情。它们使用字符作为树中的节点,其中父节点是前面的字符,所有子节点(仅限于字母表的大小)是跟随的。以下是一个存储字符串 ABB、ABC、CAACB、CAACA、BBB 和 BBA 的前缀树图:

以这种方式存储字符串可以非常高效地进行搜索。你只需遍历要存储的键中的字母,就可以找出(或存储)该字符串是否包含在例如集合中。实际上,如果字符串只能有特定的大小,那么检索时间将是恒定的,并且无论 trie 存储 10 个还是 1000 万个单词都无关紧要。通常,这对于集合数据结构或具有字符串键的键值存储(例如散列,但稍后再讨论)很有用。就像二叉搜索树一样,这种结构具有强大的分层内存管理(即没有“回指”的指针),使其非常适合 Rust。
最近,产品团队再次查看了用户的设备键,并发现典型的物联网设备使用代表路径的键,它们通常会看起来像countryA/cityB/factoryC/machine1/positionX/sensorY。想起了之前工作得很好的树,他们认为可以使用这些来改进目录。但您已经有了更好的想法!
更现实的物联网设备管理
这样的路径往往有很大的重叠,因为单个位置中有无数个传感器和设备。此外,由于分层属性,它们是唯一的,并且如果需要找到传感器,它们是可读的。非常适合 trie!
这个 trie 的基础将是一个节点类型,它存储子节点、当前字符,如果是完整键的节点,则存储本章早些时候提到的IoTDevice对象。这在 Rust 中看起来是这样的:
struct Node {
pub key: char,
next: HashMap<char, Link>,
pub value: Option<IoTDevice>,
}
这次,子节点是一个不同的数据结构:一个HashMap。映射(也称为字典、关联数组)明确存储一个键和一个值,而“散列”这个词暗示了将在下一章讨论的方法。目前,HashMap保证将单个字符与节点类型相关联,从而为迭代铺平道路。除此之外,这种数据结构还允许进行获取或添加类型的操作,这显著提高了代码的可读性。
由于可能的单词开头数量相似,根也是一个HashMap,这使得 trie 有多个根:
pub struct BestDeviceRegistry {
pub length: u64,
root: HashMap<char, Link>,
}
为了填充这些映射以数据,需要一个添加路径的方法。
添加路径
将字符串插入 trie 的算法可以用几句话来描述:遍历单词中的每个字符,并沿着 trie 向下追踪。如果一个节点尚不存在,则创建它,并将对象添加到最后一个条目。
当然,还需要决定一些特殊情况:如果字符串已经存在,会发生什么?覆盖还是忽略?在这个实现中,最后的写入将获胜——也就是说,它将覆盖之前存在的任何内容:
pub fn add(&mut self, device: IoTDevice) {
let p = device.path.clone();
let mut path = p.chars();
if let Some(start) = path.next() {
self.length += 1;
let mut n = self.root
.entry(start)
.or_insert(Node::new(start, None));
for c in path {
let tmp = n.next
.entry(c)
.or_insert(Node::new(c, None));
n = tmp;
}
n.value = Some(device);
}
}
另一个特殊情况是根节点,因为它不是一个真正的节点,而是一个HashMap。一旦 trie 设置完成,最重要的事情就是再次获取内容!
步行
添加和搜索工作非常相似:遵循链接到键的字符,并在最后返回“值”:
pub fn find(&mut self, path: &str) -> Option<IoTDevice> {
let mut path = path.chars();
if let Some(start) = path.next() {
self.root.get(&start).map_or(None, |mut n| {
for c in path {
match n.next.get(&c) {
Some(ref tmp) => n = tmp,
None => break,
}
}
n.value.clone()
})
} else {
None
}
}
由于 trie 不按任何特定顺序(甚至不一致)存储字符串,以可预测的方式获取相同的数据是棘手的!像二叉树一样遍历它效果足够好,但只有插入顺序是确定的,这在测试实现时应予以注意:
pub fn walk(&self, callback: impl Fn(&IoTDevice) -> ()) {
for r in self.root.values() {
self.walk_r(&r, &callback);
}
}
fn walk_r(&self, node: &Link, callback: &impl Fn(&IoTDevice) -> ()) {
for n in node.next.values() {
self.walk_r(&n, callback);
}
if let Some(ref dev) = node.value {
callback(dev);
}
}
如前所述,这种遍历被称为广度优先遍历。
总结
Trie 数据结构是一种通过存储公共前缀来高效存储和查找字符串的方法,并且在实践中经常被使用。一个用例是流行的 Java 搜索引擎 Lucene,它使用这种结构来存储搜索索引中的单词,但跨不同领域还有许多其他示例。此外,简单性非常适合实现自定义 trie 来存储整个单词或其他对象,而不是字符。
优点
内置的前缀对于高效存储非常有用,除此之外,还有以下好处:
-
易于实现,便于定制
-
字符串集合的最小内存需求
-
对于已知最大长度的字符串,检索时间恒定
-
提供了异构算法(例如,Burst Sort)
虽然 trie 很棒,但它也很简单,这带来了一系列缺点。
缺点
Tries 可以在很多形状和形式中工作,但不幸的是,不能处理每个用例。其他缺点包括以下内容:
-
它有一个发音奇怪的名称
-
遍历时没有确定性顺序
-
没有重复的键
这就结束了更异构的树种类。接下来是 B-Tree,它本质上是一种通用树!
B-Tree
正如你所注意到的,将子节点的数量限制为 2(就像之前的二叉树一样)会产生一个只允许算法决定是向左还是向右走的树,并且很容易硬编码。此外,在节点中仅存储单个键值对可能会被视为空间浪费——毕竟,指针可以比实际的有效负载大得多!
B-Tree 通常在每个节点中存储多个键和值,这使得它们更节省空间(有效负载到指针的比率更高)。作为树,这些(键值)对中的每一个都有子节点,它们持有位于节点之间的值。因此,B-Tree 存储键、值和子节点三重组合,还有一个额外的子指针来覆盖任何“其他”值。以下图显示了简单的 B-Tree。注意指向包含较小键的节点的额外指针:

如此所示,B-Tree 可以有不同数量的键值对(只有键是可见的),但它们将有一个最大子节点数——由顺序参数定义。因此,二叉搜索树可以被视为一个 2 阶 B-Tree,但没有自平衡的额外好处。
为了实现自平衡的特性,B-树具有某些特性(由唐纳德·克努特定义):
-
每个节点只能有order个子节点
-
任何非叶节点或根节点至少有order/2个子节点
-
根节点至少有两个子节点
-
当节点有order个子节点时,所有节点都持有order - 1个键
-
所有叶节点都位于同一级别
自平衡是如何工作的?它比红黑树简单得多。首先,新键只能插入到叶级别。其次,一旦新键找到一个节点,该节点就会根据先前的规则进行评估——特别是如果现在有超过order - 1个键。如果是这样,节点就必须分割,将中心键移动到父节点,如下面的图所示:

然后,将子节点放入它们预定的位置(特别是如果提升节点有子节点时尤为重要),然后重复这个过程直到根节点有效。
这个过程创建了一个被称为胖树(与高树相对)的东西,这意味着增加高度只能通过分割来实现,这并不经常发生。为了与节点一起工作,它们包含有关自己的额外信息:
type Tree = Box<Node>;
#[derive(Clone, PartialEq, Debug)]
enum NodeType {
Leaf,
Regular,
}
#[derive(Clone)]
struct Node {
keys: Vec<Option<(u64, String, Option<Tree>)>>,
left_child: Option<Tree>,
pub node_type: NodeType,
}
在这种情况下,节点的类型由一个属性node_type确定,但整个节点也可以被封装在一个枚举中。此外,还附加了一个特殊的变量来处理比keys向量中关联的三元组相关的键更低的键。
与二叉树一样,B-树在搜索和插入操作上表现出对数时间复杂度(O(log2(n))),并且由于简化了重新平衡,它们是数据库索引的绝佳选择。实际上,许多 SQL 数据库(如 SQLite 和 SQL Server)使用 B-树来存储这些搜索索引,并使用 B+树来存储表,因为它们以智能的方式访问磁盘。
产品团队也听说了这件事,由于之前在物联网设备管理解决方案上的尝试取得了巨大成功,他们考虑用更好的数据结构来替换红黑树!他们希望通过创建一个更简化的原始数据库版本来减少错误数量,因此要求实际上保持不变。
物联网数据库
如前所述的实施方式,此树基于IoTDevice的numerical_id属性作为键,并将设备对象作为值。在代码中,节点看起来与之前的示例非常相似:
type Tree = Box<Node>;
type KeyType = u64;
type Data = (Option<IoTDevice>, Option<Tree>);
#[derive(Clone, PartialEq, Debug)]
enum NodeType {
Leaf,
Regular,
}
#[derive(Clone, PartialEq)]
enum Direction {
Left,
Right(usize),
}
#[derive(Clone)]
struct Node {
devices: Vec<Option<IoTDevice>>,
children: Vec<Option<Tree>>,
left_child: Option<Tree>,
pub node_type: NodeType,
}
与三元组不同,此节点类型使用同步索引来查找与指定键值对关联的子节点。这些对也是通过评估包含的设备的numerical_id属性而专门创建的,从而简化了代码和最终对键的更新。节点缺少的是父指针,这使得整个红黑树代码变得更加复杂。
树本身存储为一个在 boxed 节点上的Option(别名为Tree),以及order和length属性:
pub struct DeviceDatabase {
root: Option<Tree>,
order: usize,
pub length: u64,
}
最后,为了检查树的合法性,这里有一个validate方法,它递归地找到最小和最大叶子高度,并检查子节点数量是否在范围内(如前面提到的规则所示):
pub fn is_a_valid_btree(&self) -> bool {
if let Some(tree) = self.root.as_ref() {
let total = self.validate(tree, 0);
total.0 && total.1 == total.2
} else {
false // there is no tree
}
}
fn validate(&self, node: &Tree, level: usize) -> (bool, usize, usize) {
match node.node_type {
NodeType::Leaf => (node.len() <= self.order, level, level),
NodeType::Regular => {
// Root node only requires two children,
// every other node at least half the
// order
let min_children = if level > 0 {
self.order / 2usize } else { 2 };
let key_rules = node.len() <= self.order &&
node.len() >= min_children;
let mut total = (key_rules, usize::max_value(), level);
for n in node.children.iter().chain(vec![&node.left_child]) {
if let Some(ref tree) = n {
let stats = self.validate(tree, level + 1);
total = (
total.0 && stats.0,
cmp::min(stats.1, total.1),
cmp::max(stats.2, total.2),
);
}
}
total
}
}
}
建立了这些基本结构之后,我们可以继续讨论如何向树中添加新设备。
添加内容
B-树将新条目添加到其叶子节点,随着节点变得过大,这些条目会像气泡一样上升。为了有效地找到位置,这需要递归地进行,根据需要移除和替换所有权。以下是add()函数,它负责检索根节点的所有权,并使用现有或新节点进行递归调用:
type Data = (Option<IoTDevice>, Option<Tree>);
pub fn add(&mut self, device: IoTDevice) {
let node = if self.root.is_some() {
mem::replace(&mut self.root, None).unwrap()
} else {
Node::new_leaf()
};
let (root, _) = self.add_r(node, device, true);
self.root = Some(root);
}
除了根节点的情况外,add_r()函数(递归调用)返回两份数据:它下降到的键以及在“提升”的情况下要添加到它返回的任何节点的设备和子节点。原则上,这个函数的工作方式如下:
-
递归地找到适当的叶子节点,并执行排序插入。
-
如果它不是重复项,则增加长度。
-
如果节点现在有比允许的更多的键:分割。
-
将原始节点及其新值的关键字返回给调用者。
-
将新节点放置在它原来的位置。
-
添加提升后的键。
-
从步骤 3 重复,直到到达根级别:
fn add_r(&mut self, node: Tree, device: IoTDevice, is_root: bool) -> (Tree, Option<Data>) {
let mut node = node;
let id = device.numerical_id;
match node.node_type {
NodeType::Leaf => { // 1
if node.add_key(id, (Some(device), None)) {
self.length += 1; // 2
}
}
NodeType::Regular => {
let (key, (dev, tree)) = node.remove_key(id).unwrap();
let new = self.add_r(tree.unwrap(), device, false);
if dev.is_none() { // 5
node.add_left_child(Some(new.0));
} else {
node.add_key(key, (dev, Some(new.0)));
}
// 6
if let Some(split_result) = new.1 {
let new_id = &split_result.0.clone().unwrap();
node.add_key(new_id.numerical_id, split_result);
}
}
}
if node.len() > self.order { // 3
let (new_parent, sibling) = node.split();
// Check if the root node is "full" and add a new level
if is_root {
let mut parent = Node::new_regular();
// Add the former root to the left
parent.add_left_child(Some(node));
// Add the new right part as well
parent.add_key(new_parent.numerical_id,
(Some(new_parent), Some(sibling)));
(parent, None)
} else {
// 4
(node, Some((Some(new_parent), Some(sibling))))
}
} else {
(node, None)
}
}
由于根节点是一个特殊情况,其中在树中添加了一个新层级,因此必须在最后一个分割发生的地方处理这个问题——在add_r()函数中。这就像创建一个新的非叶子节点,将前一个根节点放在左边,其兄弟节点放在右边,将新父节点放在顶部作为根节点。
在这个实现中,大量的繁重工作是由节点的几个函数的实现完成的,包括split()。虽然这很复杂,但它封装了树的内部工作原理——这一点不应该过多暴露,以便于变更:
pub fn split(&mut self) -> (IoTDevice, Tree) {
let mut sibling = Node::new(self.node_type.clone());
let no_of_devices = self.devices.len();
let split_at = no_of_devices / 2usize;
let dev = self.devices.remove(split_at);
let node = self.children.remove(split_at);
for _ in split_at..self.devices.len() {
let device = self.devices.pop().unwrap();
let child = self.children.pop().unwrap();
sibling.add_key(device.as_ref().unwrap()
.numerical_id, (device, child));
}
sibling.add_left_child(node);
(dev.unwrap(), sibling)
}
如前所述,分割会产生一个新兄弟节点和两个新父节点。兄弟节点将接收键的上半部分,原始节点保留下半部分,中间的节点成为新的父节点。
添加了几个设备后,让我们谈谈如何将它们取回。
搜索内容
B-树的搜索工作方式与二叉树搜索相同:递归地检查每个节点以确定路径。在 B-树中,这变得非常方便,因为它可以在循环中完成,在这种情况下,由get_device()函数完成:
pub fn get_device(&self, key: KeyType) -> Option<&IoTDevice> {
let mut result = None;
for d in self.devices.iter() {
if let Some(device) = d {
if device.numerical_id == key {
result = Some(device);
break;
}
}
}
result
}
此函数在节点结构中实现,并对键本身进行常规线性搜索。如果找不到该键,find_r()函数必须决定是否继续,它通过评估节点类型来决定。由于叶节点没有子节点,找不到所需的键将结束搜索,返回None。常规节点允许在树的更深层继续搜索:
pub fn find(&self, id: KeyType) -> Option<IoTDevice> {
match self.root.as_ref() {
Some(tree) => self.find_r(tree, id),
_ => None,
}
}
fn find_r(&self, node: &Tree, id: KeyType) -> Option<IoTDevice> {
match node.get_device(id) {
Some(device) => Some(device.clone()),
None if node.node_type != NodeType::Leaf => {
if let Some(tree) = node.get_child(id) {
self.find_r(tree, id)
} else {
None
}
}
_ => None,
}
}
在树值中查找某物的另一种方法是遍历树。
遍历树
与本章前面提到的二叉树类似,遍历可以使用不同的策略进行,即使需要遍历的分支更多。以下代码展示了中序树遍历算法,其中回调函数在左子节点和当前查看的子节点之前执行:
pub fn walk(&self, callback: impl Fn(&IoTDevice) -> ()) {
if let Some(ref root) = self.root {
self.walk_in_order(root, &callback);
}
}
fn walk_in_order(&self, node: &Tree, callback: &impl Fn(&IoTDevice) -> ()) {
if let Some(ref left) = node.left_child {
self.walk_in_order(left, callback);
}
for i in 0..node.devices.len() {
if let Some(ref k) = node.devices[i] {
callback(k);
}
if let Some(ref c) = node.children[i] {
self.walk_in_order(&c, callback);
}
}
}
由于内部排序,这种遍历可以按升序检索键。
总结
B-树很棒。它们在现实世界的应用中广泛使用,Rust 中的实现并不复杂,并且无论插入顺序如何都能保持良好的性能。此外,通过减少树的高度,树的顺序可以显著提高性能。建议事先估计键值对的数量并相应地调整顺序。
作为基准,让我们通过插入 100,000 个未排序的唯一元素并使用find()检索它们来评估这些树。点的大小表示方差,而沿Y轴显示的值是纳秒:

未排序查找的图表输出
除了这些,它的性能与其他树相当,但代码行数和代码复杂度都大大减少,这对其他开发者的可读性和可维护性都有影响。
优点
此类树在设置相应的顺序参数时可以达到极高的性能:
-
比其他自我平衡树实现起来更简单
-
广泛应用于数据库技术
-
由于自我平衡,性能可预测
-
可以进行范围查询
-
最小化磁盘访问的变体(B+树)
树的缺点很少。
缺点
绝对性能在很大程度上取决于树的顺序;除此之外,此树没有很多缺点。
图表
在它们最通用的形式中,树是图——有向无环图。一个通用图可以被描述为节点集合,有时被称为顶点,具有某些属性,例如是否允许循环。这些节点之间的连接也有自己的名称:边。这些边也可以具有某些属性,特别是权重和方向(如单行道)。
通过强制实施这些约束,可以构建一个模型,就像树一样,很好地反映了某种现实。有一件事通常被表示为加权图:互联网。虽然现在这可能是过度简化,因为各种版本的互联网协议(IPv4 和 IPv6)和网络地址转换(NAT)技术隐藏了大量的在线参与者,但在其早期,互联网可以被描绘为由路由器、计算机和服务器(节点)通过定义速度和延迟(权重)的链接(边)相互连接的集合。
以下图示展示了一个随机、无向、无权图:

除了人类,人类通常可以合理高效地通过这个相互连接的节点网络找到路径,而计算机需要特定的指令来在其中找到任何东西!这需要新的算法来处理这种复杂性——一旦网络中的节点数超过在时间内可以查看的节点数,这尤其棘手。这导致了许多路由算法的发展,以及寻找循环和分割网络的技术,或者流行的 NP 难问题,如旅行商问题或图着色问题。旅行商问题定义如下。
找到城市之间的最优(最短)路径,且不重复访问任何一个城市。在左侧是一些欧洲城市;在右侧,两种可能的解决方案(虚线与实线):

现在,有许多图的例子,最明显的是社交图(在社交网络中),但也是 TensorFlow 深度学习 API、状态机以及提供通用查询语言以遍历图的图数据库的一部分。甚至可以找到一些不那么明显的用例,例如存储遗传序列(节点是 DNA 的小部分)!
为了跳出理论构建,你如何在程序中高效地表示一个图?作为一个具有出度顶点列表的节点结构?那么你将如何找到特定的节点?这是一个棘手的问题!图也有一个习惯,那就是会变得相当大,任何曾经想要将对象图序列化为 JSON 的人都可以证明:它们很容易耗尽内存。
处理这种数据结构的最简单方法是惊人的:一个矩阵。这个矩阵可以是稀疏的(即大小不一的列表的列表),称为邻接表,或者是一个完整的矩阵(邻接矩阵)。特别是对于矩阵,其大小通常是任意一边的节点数以及每个交叉处的权重(或表示“连接”或“未连接”的布尔值)。许多实现还会在它自己的列表中保留“真实”的节点,使用索引作为 ID。以下图示展示了如何将图显示为矩阵:

Rust 提供了许多实现复杂图结构的优秀工具:枚举和模式匹配提供了以低开销操作节点和边类型的方法,而迭代器和函数式方法则消除了对冗长循环的需求。让我们看看 Rust 中的一个通用图结构:
struct ASimpleGraph {
adjacency_list: Vec<Vec<usize>>,
}
这个邻接列表可以存储节点以及它们是否连接,这使得它成为一个有限的无向无权图——非常适合存储对象之间简单的关系。已经,这样的数据结构有能力实现复杂的路由算法或在回溯算法中耗尽资源。在邻接列表中,列表中的每个索引代表边的起点,包含的元素(也是列表)是任何出边。要遍历图,从起点索引开始,通过搜索其边找到下一个索引。然后重复,直到到达目标节点!
当产品团队听到这个惊人的数据结构——并且他们现在非常了解你的能力——他们提出了一个新产品:字面意义上的物联网(这是一个工作标题)。他们的想法是为客户提供一种方式来模拟具有内置距离的复杂传感器放置!客户可以然后去评估彼此之间距离在某个范围内的所有传感器,找到单个故障点,或规划快速检查它们的路线。
总结来说,客户应该能够做到以下事情:
-
创建或添加节点列表
-
根据节点之间的物理距离连接节点
-
根据提供的距离找到两个节点之间的最短路径
-
获取指定节点的邻居列表,直到一定度数
伟大的想法,对吧?非常适合图。
字面意义上的物联网
为了在这些要求上取得领先,必须做出选择图表示的方法:列表还是矩阵?两者都工作得很好,但出于解释原因,示例将使用基于向量向量的邻接列表:
pub struct InternetOfThings {
adjacency_list: Vec<Vec<Edge>>,
nodes: Vec<KeyType>,
}
如前所述,将实际值、标识符甚至整个对象保留在其自己的列表中,并简单地使用usize类型的索引进行工作是有意义的。在这个示例中,边的结构可以表示为一个元组,但这样更易于阅读:
#[derive(Clone, Debug)]
struct Edge {
weight: u32,
node: usize,
}
在有了这两个结构之后,只需几行代码就可以向图中添加节点(或...事物):
fn get_node_index(&self, node: KeyType) -> Option<usize> {
self.nodes.iter().position(|n| n == &node)
}
pub fn set_edges(&mut self, from: KeyType, edges: Vec<(u32, KeyType)>) {
let edges: Vec<Edge> = edges.into_iter().filter_map(|e| {
if let Some(to) = self.get_node_index(e.1) {
Some(Edge { weight: e.0, node: to })
} else {
None
}}).collect();
match self.nodes.iter().position(|n| n == &from) {
Some(i) => self.adjacency_list[i] = edges,
None => {
self.nodes.push(from);
self.adjacency_list.push(edges)
}
}
}
在该函数中,有一个关键的检查:每个边必须连接到一个有效的节点,否则它将不会被添加到图中。为了实现这一点,代码在其内部节点存储中查找edges参数中提供的 ID,以找到其索引,这是通过 Rust 的迭代器特质的position()函数完成的。它返回当提供的谓词返回 true 时的位置!同样,迭代器的filter_map()函数将只包括其结果集中评估为Some()(而不是None)的元素。因此,节点必须有一个设置器,该设置器也会初始化邻接表:
pub fn set_nodes(&mut self, nodes: Vec<KeyType>) {
self.nodes = nodes;
self.adjacency_list = vec![vec![]; self.nodes.len()]
}
一旦完成,图就准备好了。我们首先去找邻居怎么样?
邻域搜索
邻域搜索是一个非常简单的算法:从提供的节点开始,跟随每条边,并返回你找到的内容。在我们的案例中,关系度很重要。
就像之前展示的树算法一样,递归是解决这个问题的好选择。虽然迭代解决方案通常会更节省内存(没有栈溢出),但一旦你掌握了递归,它就更加描述性强。此外,一些编译器(包括部分rustc,但不保证)会将递归展开成循环,提供两者的最佳结合(寻找尾调用优化)!显然,最重要的是要有预期的增长;10 万个递归调用很可能会填满栈。
然而,运行邻域的函数是双重的实现。首先,面向公众的函数负责验证输入数据,并查看节点是否实际上存在:
pub fn connected(&self, from: KeyType, degree: usize) -> Option<HashSet<KeyType>> {
self.nodes.iter().position(|n| n == &from).map(|i| {
self.connected_r(i, degree).into_iter().map(|n|
self.nodes[n].clone()).collect()
})
}
在处理完这些之后,递归调用可以创建一个包含所有邻居的列表,并对每个邻居运行相同的调用。返回一组节点也消除了重复:
fn connected_r(&self, from: usize, degree: usize) -> HashSet<usize> {
if degree > 0 {
self.adjacency_list[from]
.iter()
.flat_map(|e| {
let mut set = self.connected_r(e.node, degree - 1);
set.insert(e.node);
set
}).collect()
} else {
HashSet::new()
}
}
由于递归调用返回内部表示(即索引),外部函数将这些转换回用户可以理解的数据。这个函数可以作为其他功能的基础,例如交叉两个节点的邻域,以及邻近搜索。或者,更实际一点,在传感器故障的情况下,公司可以检查是否存在一个共同设备负责(交叉),或者是否有其他附近的传感器报告了类似的测量结果,以排除故障(邻域搜索)。现在,让我们继续更复杂的事情:寻找最短路径。
最短路径
这个算法的根源在于早期的网络:路由器必须决定将数据包转发到何处,而没有关于其背后的任何知识。他们只能在没有完美信息的情况下做出最佳决定!计算机科学的先驱之一 Edsger Dijkstra 提出了一个以他的名字命名的图路由算法:Dijkstra 算法。
该算法迭代工作,遍历每个节点以累加它们的权重,从而找到到达该节点的距离(或成本)。然后,它将继续在成本最低的节点上操作,这使得该算法成为一个“贪婪”算法。这会一直持续到达到所需的节点或没有更多节点可以评估。
立即收敛到当前最佳(局部最优解)以找到最佳整体解决方案(全局最优解)的算法被称为贪婪算法。这当然很棘手,因为到达全局最优解的路径可能需要接受成本的增加!没有保证找到全局最优解的方法,所以这是关于减少陷入局部最优解的概率。2018 年一个著名的贪婪算法是随机梯度下降,它用于训练神经网络。
在代码中,它看起来是这样的:
pub fn shortest_path(&self, from: KeyType, to: KeyType) -> Option<(u32, Vec<KeyType>)> {
let mut src = None;
let mut dest = None;
for (i, n) in self.nodes.iter().enumerate() {
if n == &from {
src = Some(i);
}
if n == &to {
dest = Some(i);
}
if src.is_some() && dest.is_some() {
break;
}
}
if src.is_some() && dest.is_some() {
let (src, dest) = (src.unwrap(), dest.unwrap());
let mut distance: Vec<TentativeWeight> =
vec![TentativeWeight::Infinite; self.nodes.len()];
distance[src] = TentativeWeight::Number(0);
let mut open: Vec<usize> =
(0..self.nodes.len()).into_iter().collect();
let mut parent = vec![None; self.nodes.len()];
let mut found = false;
while !open.is_empty() {
let u = min_index(&distance, &open);
let u = open.remove(u);
if u == dest {
found = true;
break;
}
let dist = distance[u].clone();
for e in &self.adjacency_list[u] {
let new_distance = match dist {
TentativeWeight::Number(n) =>
TentativeWeight::Number(n + e.weight),
_ => TentativeWeight::Infinite,
};
let old_distance = distance[e.node].clone();
if new_distance < old_distance {
distance[e.node] = new_distance;
parent[e.node] = Some(u);
}
}
}
if found {
let mut path = vec![];
let mut p = parent[dest].unwrap();
path.push(self.nodes[dest].clone());
while p != src {
path.push(self.nodes[p].clone());
p = parent[p].unwrap();
}
path.push(self.nodes[src].clone());
path.reverse();
let cost = match distance[dest] {
TentativeWeight::Number(n) => n,
_ => 0,
};
Some((cost, path))
} else {
None
}
} else {
None
}
}
由于这是一段较长的代码,让我们将其分解。这是确保源节点和目标节点都是图中的节点的样板代码:
pub fn shortest_path(&self, from: KeyType, to: KeyType) -> Option<(u32, Vec<KeyType>)> {
let mut src = None;
let mut dest = None;
for (i, n) in self.nodes.iter().enumerate() {
if n == &from {
src = Some(i);
}
if n == &to {
dest = Some(i);
}
if src.is_some() && dest.is_some() {
break;
}
}
if src.is_some() && dest.is_some() {
let (src, dest) = (src.unwrap(), dest.unwrap());
然后,每个节点都会分配一个试探性权重,初始时为无限大,除了原节点,其到达成本为零。“开放”列表,包含所有尚未处理的节点,方便地使用 Rust 的范围创建——因为它对应于我们正在处理的索引。
一旦确定了较低的成本,父数组会跟踪每个节点的父节点,这提供了一种追踪最佳可能路径的方法!
let mut distance: Vec<TentativeWeight> =
vec![TentativeWeight::Infinite; self.nodes.len()];
distance[src] = TentativeWeight::Number(0);
let mut open: Vec<usize> =
(0..self.nodes.len()).into_iter().collect();
let mut parent = vec![None; self.nodes.len()];
let mut found = false;
现在,让我们深入探讨路径查找。辅助函数min_index()接受当前距离并返回下一个最容易到达(即距离最低)的节点的索引。然后,这个节点将从开放列表中移除。如果已经到达目的地,这也是一个停止的好时机。关于这方面的更多思考,请参阅关于贪婪算法的前一个信息框。将found设置为true将有助于区分无结果和提前停止。
对于这个节点的每条边,都会计算新的距离,如果更低,则将其插入到距离列表中(从源节点看)。由于在更新向量时确保不借用,因此会有很多克隆操作。对于u64(或u32)类型,这不应该产生很大的开销(指针通常也这么大),但对于其他类型,这可能会成为性能陷阱:
while !open.is_empty() {
let u = min_index(&distance, &open);
let u = open.remove(u);
if u == dest {
found = true;
break;
}
let dist = distance[u].clone();
for e in &self.adjacency_list[u] {
let new_distance = match dist {
TentativeWeight::Number(n) =>
TentativeWeight::Number(n + e.weight),
_ => TentativeWeight::Infinite,
};
let old_distance = distance[e.node].clone();
if new_distance < old_distance {
distance[e.node] = new_distance;
parent[e.node] = Some(u);
}
}
}
在这个循环退出后,需要准备一个距离数组和父数组以便返回给调用者。首先,在父数组中从目标节点追踪回原节点,这将导致两个节点之间的反向最优路径:
if found {
let mut path = vec![];
let mut p = parent[dest].unwrap();
path.push(self.nodes[dest].clone());
while p != src {
path.push(self.nodes[p].clone());
p = parent[p].unwrap();
}
path.push(self.nodes[src].clone());
path.reverse();
let cost = match distance[dest] {
TentativeWeight::Number(n) => n,
_ => 0,
};
Some((cost, path))
} else {
None
}
} else {
None
}
}
通过严格遵循距离最低的节点,Dijkstra 算法在提前停止时实现了很好的运行时间,并且可以通过使用更有效的数据结构(如堆)来有效地获取下一个节点来进一步提高运行时间。
图中的最短路径的现代方法通常使用A(发音为“a star”)算法。虽然它基于相同的原理,但也稍微复杂一些,因此超出了本书的范围。
总结
图的实现非常简单:在邻接表或矩阵中清晰的拥有权使得它们几乎无需费力即可使用!除此之外,还有两个尚未在本实现中涵盖的额外方面:枚举及其实现,以及使用常规操作(此处:比较)与本实现。
这表明遵守标准接口提供了很好的方式,除了枚举提供的灵活性外,还可以与标准库或知名操作进行接口。只需几行代码,就可以以可读的方式表示和操作无穷大。这也有助于更算法化的方面,这些方面将在本书的后续章节中介绍。现在,让我们再次专注于图。
优点
图结构独特,很少有其他方法可以达到相同的效果。在这个环境中工作使你能够深入关注关系,并以不同的方式思考问题。以下是使用图的一些优点:
-
在建模关系方面非常出色
-
高效检索特定节点的依赖关系
-
简化复杂抽象
-
使某些问题得以解决
你选择矩阵或列表表示通常是主观的选择,例如,虽然矩阵提供了简单的删除操作,但列表首先以更有效的方式存储边。这全都是权衡的结果。
缺点
这使我们想到了这种特定数据结构的缺点:
-
无法高效地解决某些问题(例如,具有特定属性的节点列表)
-
更低效的资源利用率
-
存在未解决的问题(例如,具有大量城市的旅行商问题)
-
通常需要重新考虑问题
在此基础上,我们可以总结本章关于树及其相关内容。
概述
本章深入探讨了树,从最简单的形式开始:二叉搜索树。此树通过创建一个左分支和一个右分支来准备插入的数据,这两个分支分别持有较小的或较大的值。因此,搜索算法可以根据当前节点和传入的值选择方向,从而跳过大多数其他节点。
然而,常规的二叉搜索树有一个主要的缺点:它可能变得不平衡。红黑树为此提供了一个解决方案:通过旋转子树,保持平衡的树结构,并保证搜索性能。
堆是对树结构的一种更奇特的使用。它们的主要用途是优先队列,它们可以高效地在常数时间内产生数组中的最低或最高数字。上移和下移操作在插入或删除时修复结构,使得根节点再次是最低(最小堆)或最高(最大堆)数字。
另一个非常奇特的结构是 trie。它们专门用于存储字符串,并且通过将字符作为节点,并以所需的方式“分支”单词,非常高效地找到与特定字符串关联的数据。
要提高泛化水平,B-树是树的一种通用形式。它们持有多个值,它们之间的范围导致一个子节点。类似于红黑树,它们是平衡的,并且节点只添加到叶子节点,在那里它们可能被“提升”到更高的级别。通常,这些用于数据库索引。
最后但同样重要的是,树的最通用形式:图。图是一种灵活的方式来表达受限制的关系,例如没有循环和方向性。通常,每个节点都有加权连接(边),这提供了在节点之间转换的成本概念。
在覆盖了一些基本的数据结构之后,下一章将探讨集合和映射(有时称为字典)。实际上,其中一些已经在本章中使用过了,所以下一章将专注于实现我们自己的。
问题
-
二叉搜索树在搜索时如何跳过几个节点?
-
什么是自平衡树?
-
为什么树中的平衡很重要?
-
堆是一种二叉树吗?
-
tries 有哪些好的用例?
-
什么是 B-树?
-
图的基本组成部分是什么?
第六章:探索映射和集合
到目前为止,数据结构在搜索方面只变得更快速,本章也不例外。使其不同之处在于为什么以及如何在两种高级数据结构中找到数据:映射和集合。前者也被称为字典、关联数组、对象或哈希表,而后者通常被看作是一个数学概念。两者都可以依赖于哈希技术,这是一种允许以常数(或接近常数)时间检索项目、检查它们是否包含在集合中或在分布式哈希表中路由请求的技术。
这些数据结构也比之前的数据结构高一个层次,因为它们都是建立在现有结构之上的,例如动态数组或树,而且更不用说本章从算法开始。理解本章将为进入书的第二部分做好很好的准备,在那里算法是主要焦点。本章学习的内容包括以下:
-
哈希函数及其用途
-
如何基于不同的数据结构实现集合
-
使映射特殊的原因
哈希
生日悖论是一个众所周知的现象;两个人在这一年共享这个特殊的日子,似乎很常见,而且当它发生时我们仍然会感到兴奋。从统计学的角度来看,遇到这样的人的概率实际上非常高,因为在只有 23 人的房间里,概率就已经达到了 50%。虽然这可能是一个有趣的事实,但为什么要在介绍哈希函数的章节中提到这一点呢?
生日可以被视为一个哈希函数——尽管不是一个好的哈希函数。哈希函数是映射一个值到另一个固定大小的值的函数,例如将生日日期和月份组合成u64,如下所示:
fn bd_hash(p: &Person) -> u64 {
format!("{}{}", p.day, p.month) as u64
}
这个函数实际上将证明非常无效,如下所示:
-
不询问某人,很难确定性地知道他们的生日
-
空间限制在 366 个唯一值内,这也使得冲突非常可能
-
它们不是均匀分布在整个年份中
什么使一个好的哈希函数?这取决于用例。可以与哈希函数相关联的属性有很多,如下所示:
-
单向或双向(即,给定一个哈希值,能否得到原始值?)
-
确定性
-
均匀
-
固定或可变范围
在任何领域设计好的哈希函数都是一个非常困难的任务;在经过几年的使用后,无数算法被证明对于它们设计的目的是过于薄弱的,SHA-1 就是最新的显眼受害者。
对于各种用例,都有各种各样的哈希算法可用,从加密安全的到类似于奇偶校验位以减轻篡改的算法。本节将重点介绍我们认为有趣的几个领域;对于更全面的了解,维基百科(en.wikipedia.org/wiki/List_of_hash_functions)提供了一个显示可用哈希算法及其文章的列表。
签名是哈希算法中最重要的领域之一,它们可以像信用卡号码的最后一位数字(用于验证号码)一样简单,也可以是 512 位的强大加密摘要函数,其中单个冲突就是该特定算法的终结。
在密码学之外,哈希还应用于完全不同的领域,例如对等路由或以树状结构编码信息。地理哈希是一个很好的例子;这些地理哈希不是通过比较经纬度,而是通过比较哈希值的前几个字符来快速检查一个区域是否靠近(或位于)另一个区域。该算法已被纳入公共领域,可以在geohash.org/找到。由于在事先已知整个可能的输入空间(地球上的坐标)的情况下,可以排除该空间中的冲突。
什么是冲突?当两个不同的输入参数导致相同的输出时,就会发生冲突,使得哈希变得模糊。在密码学中,这一事实将导致大规模危机,就像你找到了另一个匹配你门锁的钥匙一样。主要区别在于,在物理世界中,尝试你邻居家的每一扇门是非常不切实际的,但使用完全连接的计算机,这可以在几秒钟内完成。这意味着潜在的输入与哈希函数本身的质量一样重要——无论是时间与实践(如物理物品),还是适用范围(地球坐标,集群中的最大节点数)——将一个函数转移到更大范围的领域会导致意想不到的结果。
总结来说,当键的潜在空间不足以承受全面枚举(暴力破解)时,或者当哈希函数的输出分布不均匀时,就会发生冲突。
创建你自己的
为了将对象表示为数字(用于哈希表或比较),大多数语言的内置类型都带有用于此目的的可靠哈希函数,因此几乎从不建议自己构建,除非投入大量时间和精力。更好的选择是使用内置的,或者使用提供经过测试和验证方法的库。
然而,了解这些函数是如何构建的很重要,所以让我们创建一个简单的实现来分析基本原理。以下是一个使用前一个和当前字节进行异或操作以保存它们的二进制差异的例子,然后将它左移四次(以填充u32类型):
pub fn hashcode(bytes: &[u8]) -> u32 {
let mut a = 0_u32;
for (i, b) in bytes.iter().enumerate() {
a ^= *b as u32;
a <<= i % 4;
}
a
}
当这个函数应用于一系列重复字母字符串时,这些值是如何分布的?直方图和散点图讲述了这个故事,如下所示:

XOR 哈希器的输出图表
这个直方图显示了当函数应用于所有AA-ZZ的组合时哈希输出的分布,但每个字母重复十次,所以第一个字符串是AAAAAAAAAAAAAAAAAAAA(20 个字母),最后一个字符串是ZZZZZZZZZZZZZZZZZZZZ,产生了 675 种 20 个字母“单词”的组合。这导致了一个不太理想的分布,其中最高频率是最低频率的五倍。虽然速度可能是使用该函数的一个因素,但它显然会对密码学产生次优结果。
在散点图中,它看起来如下:

散点图的输出图表
散点图显示了一个不同的故事。在x轴上,显示每个组合的索引,在y轴上显示哈希输出。因此,水平线表示冲突,它们无处不在!探索这种函数的进一步特性可能很有趣,但最初的结果看起来相当糟糕,寻找更好的算法是任何人最好的时间利用方式。让我们继续讨论校验和与摘要。
消息摘要
消息摘要的创建是为了保证真实性;如果一条消息被发送,这条消息的摘要或签名提供了一种检查消息是否被篡改的能力。因此,签名通常会以不同于原始消息的方式传输。
显然,这个哈希函数必须遵循一些基本规则才能被认为是好的,如下列所示:
-
无论消息大小如何,签名都必须快速且易于获取
-
签名只能有固定长度
-
函数必须最小化冲突
这个组包含的哈希函数是最受欢迎的,也是许多安全研究人员的目标:MD5、SHA-1/2/3 或 Adler 32。Adler 32 在zlib库中被广泛使用以确保文件完整性,但不应用于验证消息,因为 32 位输出空间有限。然而,它易于实现和理解,这使得它非常适合本书的目的:
const MOD_ADLER: u32 = 65521;
pub fn adler32(bytes: &[u8]) -> u32 {
let mut a = 1_u32;
let mut b = 0_u32;
for byte in bytes {
a = (a + byte as u32) % MOD_ADLER;
b = (b + a) % MOD_ADLER;
}
(b << 16) | a
}
该算法对任何字节流的字节进行求和,并通过应用模运算来避免溢出,使用一个大质数(65521),这使得字节在不改变最终结果的情况下更难改变。由于有许多方法可以改变求和的运算数而不影响结果,该算法有相当大的弱点!
此外,在模运算应用后进行回绕(滚动)会给字节顺序赋予一些权重,所以如果字节的和不够大,算法预计会产生更多的碰撞。通常,此算法主要保护随机传输错误导致的位变化,在验证消息方面并不实用。
总结
哈希是一种非常实用的工具,开发者每天都在使用——无论是自觉还是不自觉。整数比较速度快,因此可以通过比较它们的哈希值来提高检查两个字符串相等性的效率。通过哈希,可以使得不同的键变得可比较——这是分布式数据库用来为行分配分区的方法。
模运算哈希是一种技术,允许分布式数据库确定性地将一行数据分配到分区。首先对行的键进行哈希处理,然后使用最大分区数与模运算符相结合,以获得存储该行的目标位置。
之前,我们探索了一些哈希函数(基于 XOR 和 Adler 32),但我们从未比较过它们。此外,Rust 的标准库提供了一个哈希函数(为HashSet<K,V>/HashMap<K,V>构建,并为所有标准类型实现),这是一个很好的基准。
首先,直方图——显示每个哈希出现的次数。如前所述,基于 XOR 的方法产生一个非常奇怪的分布,其中一些哈希明显比其他哈希出现得更频繁,如下所示:

XOR Hasher 的输出图表
在这种情况下,Adler 校验和创建了一个正态分布,这可能是由于重复的内容,以及求和的交换性质(2 + 1 = 1 + 2)。考虑到压缩文件中的传输错误可能产生重复,它似乎是一个针对该用例的合理选择。但在大多数其他场景中,它可能表现不佳:

Adler 32 的输出图表
下面的内容是 Rust 的默认选择,基于SipHash的DefaultHasher:

Rust DefaultHasher 的输出图表
通过观察三个分布,它们在哈希表中的应用变得明显,其中频率直接转换为每个桶中列表的长度。虽然长度为一是最佳选择,但如果发生任何碰撞,相同长度的列表至少能提供最佳性能。Rust 标准库显然做出了一个很好的选择,即基于SipHash的实现(link.springer.com/chapter/10.1007/978-3-642-34931-7_28)。
比较散点图也揭示了散列函数的行为。请注意,它已按对数刻度缩放,以便将结果放入可管理的图中,如下所示:

XOR、Adler 32 和 DefaultHasher 的比较图
虽然比例尺不允许进行详细的判断,但看起来像一条线的总是碰撞密集的行为。正如从直方图所预期的那样,Adler 32 和基于 XOR 的方法都没有显示出云状。由于 y 轴显示实际的散列值(对数刻度),它越垂直分布,分布就越好。理想情况下,每个 x 值都有一个唯一的散列值,但每个 y 值大致相同的点数预示着均匀的散列函数。再次强调,Rust 的 DefaultHasher 在这个图中看起来非常好,而两个竞争者当在类似情况下使用时都显示出不太理想的行为。
最后提醒一句。这是软件开发者对散列的看法:安全研究人员和专业人士对散列的了解要多得多。他们应该负责提出创建消息签名的新方法,这样我们就可以专注于构建优秀的软件,并使用最好的可能组件来完成这项工作。简而言之:不要为任何生产系统构建自己的散列函数。
现在,让我们看看在数据结构中散列的实际应用:映射。
映射
数组中的索引操作快速、简单且易于理解,但有一个缺点:它们只适用于整数。由于 数组 是内存中连续的部分,可以通过均匀分割来访问,这使得元素之间的跳跃变得容易,那么这也可以用于任意键吗?是的!这就是映射的用武之地。
映射(也称为字典或关联数组),是一种以高效方式存储和管理唯一键值对的数据结构。这些结构旨在快速提供对与键相关联的值的访问,这些键通常以下两种方式之一存储:
-
一个散列表
-
一棵树
当键值对存储在树中时,结果与上一章讨论的非常相似:自平衡树将提供一致的性能,避免散列表的最坏情况成本。
由于在前一章中已经广泛讨论了树,因此本节主要关注散列表。它使用散列函数将提供的键转换为某种数字,然后将其“映射”到数组桶中。这就是整个键值对通常作为列表(或树)存储以有效地处理冲突的地方。每当查找键时,映射可以搜索相关的桶以找到确切的键。通过散列键插入键值对,使用模运算在数组中找到一个位置,并将对追加到桶中的列表。
如果列表中有两个或更多元素,则发生了一个或多个冲突:

虽然这通常会导致很好的访问时间,但每当需要存储相似哈希值(由于哈希函数不好)时,最坏的情况将是搜索一个无序列表——具有线性性能。这导致了一个包含所有数据的Entry类型的 boxed 切片,它是一个元组的向量。在这种情况下,实现甚至使用了泛型:
type Entry<K, V> = Vec<(K, V)>;
pub struct HashMap<K, V>
where
K: PartialEq + Clone,
V: Clone,
{
hash_fn: Box<dyn (Fn(&K) -> usize)>,
store: Box<[Entry<K, V>]>,
pub length: usize,
}
此外,哈希函数可以自由选择,并存储为 boxed 函数,这使得在对象内部存储并随时调用变得方便。这也允许用户为特定用例自定义哈希类型。
通过将索引与某个哈希值关联,映射缺乏以任何顺序遍历其内容的能力。因此,键和值不能以任何顺序迭代,需要在任何操作发生之前进行排序。
再次强调,产品团队正在创新,另一个功能真的可以为客户带来很多价值:将邮编与其关于位置的实际情况关联起来。这样,网络服务可以缓存常用数据,减少数据库的负载,同时更快地为客户提供服务!由于这些位置是手动更新的,因此不需要过期,地图可以在启动时填充。
客户还提供了一份简明的要求列表以供协助,如下所示:
-
在其唯一名称下插入位置信息
-
使用名称快速检索信息
-
获取所有位置名称及其相关信息
-
使用名称更新位置
一个哈希表在这里会做得很好,不是吗?
位置缓存
缓存值是映射的典型用例,因为即使有大量项目,也不会对性能产生太大影响,因为键总是唯一的。这些键甚至可以携带自己的信息!
对于上一节中定义的用例,每个客户使用一个国家的邮编来识别位置;它们通常覆盖的区域只包含一个办公室。邮政编码以字符串形式存储,以涵盖现实世界中广泛的系统,并且每个国家都是唯一的。
多亏了之前的通用实现,整个LocationCache类型可以是一个专门的HashMap的别名,只需要在创建时提供哈希函数,如下所示:
pub type LocationCache = HashMap<String, LocationInformation>;
HashMap本身是一个自定义实现,它包含一个类型为K的键,该键必须还实现PartialEq(用于直接比较键实例),以及Clone(出于实际原因)。
哈希函数
除了提供通用的数据结构外,该实现允许用户提供一个自定义的哈希函数,该函数仅将键类型的引用映射到usize返回类型。返回类型的选择是任意的,并且是为了避免溢出而选择的。
由于之前实现的哈希函数比 Adler 32 校验和算法表现更好,位置缓存将使用这个。回想一下,该算法在字节与其前驱之间应用 XOR 操作,然后根据字节索引进行左移位。或者,Rust 的DefaultHasher也是可用的:
pub fn hashcode(bytes: &[u8]) -> u32 {
let mut a = 0_u32;
for (i, b) in bytes.iter().enumerate() {
a ^= *b as u32;
a <<= i % 4;
}
a
}
选择一个哈希算法是一个重要的决定,正如我们将在总结部分看到的那样。但首先,需要添加位置!
添加位置
为了添加位置,有两个重要的步骤:
-
计算哈希
-
选择一个桶
进一步的操作,例如进行排序插入,也会提高性能,但可以通过在每个桶中使用树而不是列表来省略这些操作。
位置缓存实现使用哈希与数组长度之间的简单模运算来选择桶,这意味着除了常规的哈希冲突之外,选择内部存储的大小也会对性能产生重大影响。选择太小的大小,桶将重叠,不管哈希函数如何!
在 Rust 代码中,第一部分是在第一行使用提供的 boxed hashcode函数创建哈希。接下来是找到桶,通过应用类似于模运算的操作(哈希与存储数组最高索引之间的二进制 AND 操作)以及附加列表的线性搜索。如果找到键,则更新附加对;如果没有找到,则将其添加到向量中:
pub fn insert(&mut self, key: K, value: V) {
let h = (self.hash_fn)(&key);
let idx = h & (self.store.len() - 1);
match self.store[idx].iter().position(|e| e.0 == key) {
Some(pos) => self.store[idx][pos] = (key, value),
None => {
self.store[idx].push((key, value));
self.length += 1
}
}
}
一旦存储了位置和匹配的哈希值,就可以再次检索。
获取位置
就像插入一样,检索过程也有相同的步骤。无论是get()函数返回一个值还是remove()函数,两者都经过相同的步骤:哈希、匹配桶、进行线性搜索,最后与预期的返回类型匹配。get()函数可以利用 Rust 强大的迭代器通过使用find在桶的向量内匹配谓词,并且由于返回的是Option<Item>,可以使用其map函数提取值而不是返回整个对:
pub fn get(&self, key: &K) -> Option<V> {
let h = (self.hash_fn)(key);
let idx = h & (self.store.len() - 1);
self.store[idx]
.iter()
.find(|e| e.0 == *key)
.map(|e| e.1.clone())
}
pub fn remove(&mut self, key: K) -> Option<V> {
let h = (self.hash_fn)(&key);
let idx = h & (self.store.len() - 1);
match self.store[idx].iter().position(|e| e.0 == key) {
Some(pos) => {
self.length -= 1;
Some(self.store[idx].remove(pos).1)
}
_ => None,
}
}
remove函数实际上是insert函数的逆操作;如果找到,不是更新键值对,而是从桶中移除并返回给调用者。
总结
哈希表是一个非常好的数据结构,它们的值往往无法过高估计,尤其是在缓存或简化代码时,否则可能需要使用数组索引将标签(或键)与值匹配。它们的关键突破点是哈希函数本身,以及桶的选择和组织,所有这些都值得在计算机科学中撰写整个博士论文和论文。
虽然哈希表快速且易于实现,但真正的问题是:它的性能如何?这是一个有效的问题!软件工程师倾向于更喜欢自己的实现,而不是学习别人已经创建的内容,尽管这是本书的整个前提,但基准测试让我们保持诚实,并帮助我们欣赏别人所做的工作。
这个HashMap的表现如何,特别是与std::collections::HashMap<K,V>相比?我们已经看到在某些直方图中,哈希函数远非理想,但性能影响是什么?这里有一个散点图来回答所有这些问题;它显示了这里实现的HashMap与使用DefaultHasher的HashMap<K,V>(标准库中的唯一选择)的不同哈希函数(Adler 32、DefaultHasher、基于 XOR 的)进行了比较。以下基准测试是在相同长度为 10 到 26 个字符、在A到Z之间随机排列的 1,000 到 10,000 个字符串上进行的。y轴显示get()操作所需的时间(纳秒),x轴显示映射中的项目数量。大小表示结果的偏差:

Adler 32、DefaultHasher、基于 XOR 的、collections-HashMap 的结果偏差散点图
这个图显示了特定哈希函数的实际价值和用途,因为它们都应用到了这个HashMap上,以及与使用DefaultHasher的std::collections::HashMap<K,V>的惊人的 Rust 社区的工作,Adler 32 作为一个校验和算法,表现相当糟糕,这是预期的,随着插入项数量的增加,甚至方差还在增加。令人惊讶的是,基于 XOR 的算法并没有像预期的那样糟糕,但与表现一致的DefaultHasher相比,仍然有较高的方差。
所有这些都与标准库中提供的HashMap<K,V>有很大差距。这是一个好消息,因为这种哈希表实现的性能也比第五章中介绍的树和跳表差,第五章是健壮树,第四章是列表,列表,更多列表。
这证明了虽然理论听起来很棒(恒定时间检索,最佳情况)——实现细节可以决定一个特定数据结构的成败,这就是为什么我们怀疑collections::HashMap排序、插入以及使用特质而不是 boxed(哈希)函数可以显著提高性能。
优点
哈希表提供了一种很好的键值关联方式,如下所示:
-
低开销存储
-
默认通过哈希对复杂键进行哈希处理
-
易于理解
-
恒定时间检索
然而,与树或其他高效检索结构相比,可能还有一些令人烦恼的事情。
缺点
尽管常数时间检索听起来很吸引人,但基准测试显示这并不那么简单。缺点如下:
-
性能高度依赖于哈希函数和应用
-
实现简单,但难以正确实现
-
无序存储
通过使用基于树的映射可以减轻一些这些缺点,但那将是前一章中描述的树,这里还有一个数据结构需要讨论:集合。
集合
结构化查询语言 (SQL) 是一种声明性语言,旨在执行数据库操作。它的主要特点是能够表达你想要什么,而不是如何实现(“我想得到一组符合谓词 X 的项”与“使用谓词 X 过滤每个项”);这也使得非程序员能够与数据库交互,这是今天许多 NoSQL 数据库所缺乏的一个方面。
你可能会想:这有什么相关?SQL 允许我们将数据视为通过关系链接在一起的集合,这使得它非常易于使用。将集合视为一个独特的对象集合就足以理解语言以及如何操作结果。虽然这个定义也被称为朴素集合论,但它对大多数目的来说是一个有用的定义。
通常,一个集合具有作为成员的元素,可以使用句子或规则来描述,例如所有正整数,但它只会包含每个元素一次,并允许执行几个基本操作:并集、交集、差集和笛卡尔积,即两个集合的组合,使得元素以每种可能的方式组合:

由于集合元素是唯一的,因此任何集合的实现都必须确保每个元素在数据结构中是唯一的,这就是实际数据结构特殊的地方;它优化了唯一性和检索。
那么,在向量上使用线性搜索来保证唯一性怎么样?它是可行的,但在已填充集合中插入将比新集合花费更长的时间。此外,前几章讨论了树在查找事物方面比列表更好,这也是为什么一个好的集合实现不应该使用它们的原因。
Rust 标准库中的集合知道两种类型的集合:BTreeSet<K,V> 和 HashSet<K,V>,这两个名称都暗示了它们的实现。如第五章中所述,鲁棒树,B-树是一种通用的、自平衡的树实现,允许每个节点有任意数量的子节点,并在其键的搜索中非常高效。
HashSet<K,V> 是不同的。通过存储键的哈希表示,如果哈希分布均匀,则可以在常数时间内完成查找。由于哈希集合和哈希映射有相同的内部机制,本节将重点介绍基于树的实现,另一节将进一步深入探讨哈希映射的深度。
除了插入和检查集合是否包含某个元素之外,集合应该提供的主要操作还包括并集、交集和差集,以及迭代器。这些操作的存在将提供一种有效的方法来以各种方式组合多个集合,这也是它们有用的原因之一。
在 Rust 代码中,基于 trie 的集合可能看起来如下:
type Link<K> = Box<Node<K>>;
struct Node<K>
where
K: PartialEq + Clone + Ord,
{
pub key: K,
next: BTreeMap<K, Link<K>>,
ends_here: bool,
}
pub struct TrieSet<K>
where
K: PartialEq + Clone + Ord,
{
pub length: u64,
root: BTreeMap<K, Link<K>>,
}
这是第五章(84f203ac-a9f6-498b-90ff-e069c41aaca0.xhtml)的 trie 实现版本,鲁棒树,增加了泛型,并使用 BTreeMap<K,V> 作为根节点来避免创建过多的 trait 依赖。这允许任意链的简单数据类型作为 trie 存储起来,这是一个高度有效的数据结构,其中重叠仅在它们分叉时才被保留(关于 trie 的更多信息,请参阅第五章(84f203ac-a9f6-498b-90ff-e069c41aaca0.xhtml),鲁棒树)。
这个存储器能存储数字吗?是的,尽管它们必须转换成字节数组,但这样就可以在这个集合中存储任何东西。
产品团队有一个想法:他们想存储网络分析软件的网络地址。他们想存储这些地址,以便在上面运行一些基本分析:哪些网络设备同时存在于两个网络中,收集所有在所有或不在某些指定网络中的地址。由于 IP 地址是唯一的,并且由必须具有共同前缀的单独字节组成,这不是使用 trie 集合的绝佳机会吗?
存储网络地址
存储网络地址不是一个难题,而且有很多人都在寻找解决方案。它们的二进制结构提供了一个机会来创建一些非常具体的东西——如果时间不是问题的话。
然而,在许多情况下,一个现成的数据结构实现已经足够覆盖大多数基本用例,尤其是当这并不是你的主要关注点时。因此,网络地址存储可以简单地是一个类型别名,它指定了 trie 集合的键类型,如下所示:
pub type NetworkDeviceStore = TrieSet<u8>;
对 trie 的 insert(以前的 add)函数的轻微修改允许用户简单地将键类型的切片传递给该函数,如下面的代码所示:
pub fn insert(&mut self, elements: &[K]) {
let mut path = elements.into_iter();
if let Some(start) = path.next() {
let mut n = self
.root
.entry(start.clone())
.or_insert(Node::new(start.clone(), false));
for c in path {
let tmp = n
.next
.entry(c.clone())
.or_insert(Node::new(c.clone(), false));
n = tmp;
}
if !n.ends_here {
self.length += 1;
}
n.ends_here = true;
}
}
与前一章所做的工作相比,这个实现只在几个细节上有所不同。首先,重要的是要避免两次增加长度,这可以通过检查键是否以新键的最后一个节点结束来避免。这个标志也是新增的,因为其他实现是专门为了存储 IoTDevice 类型的实例而实现的,每个节点都会有一个可选的设备附加到它,以表示键的完成。
类似的推理也应用于 walk 和 contains 函数。
网络操作
产品团队的一个关键要求是能够在该集合上运行简单的分析。作为第一步,这些分析可以由集合操作和比较它们的长度来组成,以便创建简单的指标。
然而,有一件重要的事情是要也能获取回地址。为此,这次实现提供了一个迭代器实现,它消耗了字典树并将其存储为Vec<T>,如下所示:
// [...] trie set implementation
pub fn into_iter(self) -> SetIterator<K> {
let v: RefCell<Vec<Vec<K>>> = RefCell::new(vec![]);
self.walk(|n| v.borrow_mut().push(n.to_vec()));
SetIterator::new(v.into_inner(), 0)
}
}
pub struct SetIterator<K>
where
K: PartialEq + Clone + Ord,
{
data: Vec<Vec<K>>,
last_index: usize,
}
impl<K> SetIterator<K>
where
K: PartialEq + Clone + Ord,
{
fn new(data: Vec<Vec<K>>, start_at: usize) -> SetIterator<K> {
SetIterator {
data: data,
last_index: start_at,
}
}
}
impl<K> Iterator for SetIterator<K>
where
K: PartialEq + Clone + Ord,
{
type Item = Vec<K>;
fn next(&mut self) -> Option<Vec<K>> {
let result = self.data.get(self.last_index);
self.last_index += 1;
result.cloned()
}
}
一旦创建了向量,一个索引就可以用来跟踪迭代器的移动。集合操作实际上并不比这复杂多少。然而,它们实际上都使用了walk()函数,这要求我们在 lambda 表达式(或闭包)中提供可变性,因此需要一个RefCell来动态处理可变性管理。
并集
集合并集的定义是,在任一集合中出现的每个元素都必须出现在结果中。因此,挑战是从两个集合中插入元素到结果集中,而不创建重复项。
由于这是由insert过程处理的,一个简单的实现可能如下所示:
pub fn union(self, other: TrieSet<K>) -> TrieSet<K> {
let new = RefCell::new(TrieSet::new_empty());
self.walk(|k| new.borrow_mut().insert(k));
other.walk(|k| new.borrow_mut().insert(k));
new.into_inner()
}
这消耗了两个集合,只返回结果。下一个操作,交集,看起来非常相似。
交集
要找到两个集合的共同元素,交集是完成这个任务的一种方法。定义也正好描述了这一点,这就是为什么 Rust 中的简单实现也遵循这个模式,如下所示:
pub fn intersection(self, other: TrieSet<K>) -> TrieSet<K> {
let new = RefCell::new(TrieSet::new_empty());
if self.length < other.length {
self.walk(|k| {
if other.contains(k) {
new.borrow_mut().insert(k)
}
});
} else {
other.walk(|k| {
if self.contains(k) {
new.borrow_mut().insert(k)
}
});
}
new.into_inner()
}
作为最后一个函数,差异很重要,因为它从结果集中排除了共同元素。
差异
有时,不是需要公共元素,而是需要移除两个集合中出现的元素。这个操作也被称为两个集合的补集,它只在元素不在另一个集合中出现时才将元素插入到结果中:
pub fn difference(self, other: TrieSet<K>) -> TrieSet<K> {
let new = RefCell::new(TrieSet::new_empty());
self.walk(|k| {
if !other.contains(k) {
new.borrow_mut().insert(k)
}
});
new.into_inner()
}
这样,集合就完成了,并且可以提供所有所需的功能。
总结
集合并不复杂,但很有用。虽然数据库索引可能是 B 树,但结果集是主键集合,在最后一步之前被移动和操作,最后从磁盘检索相关的行信息。这些就是集合数据结构派上用场并提供简单解决方案的时刻。
类似于日常任务,当使用列表时,创建唯一元素列表可能非常低效;然而,将它们存储在集合中则不需要额外努力。实际上,大多数元素可以直接扔进集合中,而集合本身也不会插入重复项。
优点
集合是一个高级数据结构,它执行以下操作:
-
为唯一列表提供了一个简单的接口
-
实现了一个数学概念
-
有一个非常高效的存储和检索其元素的方式
缺点
集合也有一些缺点,主要是以下这些:
-
元素顺序的确定性取决于实现
-
与映射相比,并不总是增加很多价值
-
有限的使用场景
由于将更频繁地使用映射,让我们深入了解那些。
摘要
哈希艺术(以及科学)在于从任意对象(无论是字符串、类型实例还是集合)创建一个单一表示(通常是数字);有一种方法可以将它们分解成数字,这些数字应该反映特定的用例。真正的问题是你想实现什么,以及期望从结果中获得哪些特性。加密哈希处理的是最小化冲突并创建签名,这些签名在轻微修改后会产生非常不同的哈希值,而地理哈希则是将地球坐标分层结构化为字符串的一种方法。当两个(或更多)输入到一个哈希函数中导致相同的输出时,这被称为冲突——对于任何加密哈希来说都是不好的信号,但如果主要是存储在哈希映射中,只要冲突均匀分布,那就没问题。然而,最重要的是,软件工程师永远不应该自己设计哈希函数,尤其是如果安全性是一个关注点的话。
映射在底层数据结构中存储和管理键值对,这通常是树或数组,将哈希映射到称为哈希映射的键值对。通过使用哈希函数来描述键并将对排序到桶中(数组元素),哈希映射是哈希的绝佳用例。这些桶基本上是存储列表(或树)的数组的索引,当不同的输入导致相同的桶时。因此,哈希映射的最佳性能是常数时间(O(1))检索任何值,而最坏情况是线性时间(O(n)),如果哈希函数返回一个常数。在现实中,还有其他可能有益的用途,例如缓存,其中用例限制了潜在的输入,并且始终实现最佳性能。
与映射不同,集合是存储唯一元素集合以执行集合操作的优秀数据结构。它们可以像哈希映射一样实现,使用哈希函数或树。在本章中,我们基于前一章(鲁棒树)修改后的 trie 数据结构实现了一个集合,以及基本的三种操作:并集、交集和差集。
在下一章中,我们将继续探索 Rust 的std::collections库及其内容。这包括一些基准测试和查看更多实现细节,因为这些是实现书中迄今为止讨论的所有概念的最好实现。
问题
-
什么使一个好的哈希函数?
-
你如何估计一个哈希函数对特定任务的适用性?
-
校验和哈希在其他方面有用吗?
-
实现映射的两种方法是什么?
-
桶是什么?
-
一个集合能否替代列表?
-
什么使集合有用?
进一步阅读
参考以下链接获取更多信息:
-
Fletcher 校验和 (
en.wikipedia.org/wiki/Fletcher%27s_checksum) -
Rust 的
HashMap实现原理(www.reddit.com/r/rust/comments/52grcl/rusts_stdcollections_is_absolutely_horrible/d7kcei2) -
维基百科的哈希函数列表(
en.wikipedia.org/wiki/List_of_hash_functions)
第七章:Rust 的集合
在前面的章节中,我们实现了一系列数据结构,这在现实中很少发生。特别是在 Rust 中,优秀的 Vec<T> 覆盖了大量的用例,如果需要映射类型结构,HashMap<T> 也覆盖了其中大部分。那么还有什么?它们是如何实现的?为什么实现它们如果它们不会被使用呢?这些都是很好的问题,它们将在本章中得到解答。你可以期待学习以下内容:
-
如
LinkedList<T>、Vec<T>或VecDeque<T>之类的序列数据类型 -
Rust 的
BinaryHeap<T>实现 -
HashSet<T>和BTreeSet<T> -
如何使用
BTreeMap<T>和HashMap<T>映射事物
序列
任何类型的列表都是典型程序中最基本的数据结构;它们提供了灵活性,可以用作队列、栈,以及可搜索的结构。然而,限制和操作在不同的数据结构之间造成了巨大的差异,这就是为什么 std::collections 的文档提供了一个决策树,以找出解决特定问题实际所需的集合类型。
以下在第四章 “列表,列表,更多列表” 中进行了讨论:
-
动态数组 (
Vec<T>) 是最通用且最易于使用的顺序数据结构。它们结合了数组的速度和可访问性,列表的动态大小,并且是构建更高阶结构(如栈、堆或甚至树)的基本构建块。因此,在不确定的情况下,Vec<T>总是一个不错的选择。 -
VecDeque<T>是Vec<T>的一个近亲,实现为一个 环形缓冲区——一个动态数组,它围绕两端回绕,使其看起来像一个环形结构。由于底层结构仍然与Vec<T>相同,因此许多方面也适用于这里。 -
在 Rust 中,
LinkedList<T>的功能非常有限。直接索引访问将是不高效的(它是一个计数迭代),这可能是为什么它只能迭代、合并和拆分,以及从后端和前端插入或检索。
这是一个很好的入门,那么让我们深入探讨 Rust 的 std::collections 中每个数据结构!
Vec<T> 和 VecDeque<T>
就像第四章 “列表,列表,更多列表” 中的动态数组一样,Vec<T> 和 VecDeque<T> 是可增长的、类似列表的数据结构,支持索引,并且基于堆分配的数组。除了之前实现的动态数组之外,它默认是泛型的,没有对泛型类型的任何约束,允许使用任何类型。
Vec<T> 旨在尽可能减少开销,同时提供一些保证。在其核心,它是一个包含 (指针, 长度, 容量) 的三元组,提供了修改这些元素的 API。容量 是分配以存放项的内存量,这意味着它与 长度 有根本的不同,长度 是当前持有的元素数量。如果提供了零大小类型或没有初始长度,Vec<T> 实际上不会分配任何内存。指针 只指向内存中保留的区域,该区域被封装为 RawVec<T> 结构。
Vec<T> 的主要缺点是其前端的插入效率不高,这正是 VecDeque<T> 旨在提供的。它被实现为一个环形,环绕数组的边缘,当内存需要扩展或需要在指定位置插入元素时,会创建一个更复杂的情况。由于 Vec<T> 和 VecDeque<T> 的实现相当相似,它们可以在类似的环境中使用。这可以在它们的架构中体现出来。
架构
这两种结构,Vec<T> 和 RawVec<T>,都以相同的方式分配内存:通过使用 RawVec<T> 类型。这种结构是围绕低级函数的一个包装,用于在内存的堆部分分配、重新分配或释放数组,专为在高级数据结构中使用而构建。其主要目标是避免容量溢出、内存不足错误和一般性溢出,从而为开发者节省大量样板代码。
Vec<T> 使用此缓冲区的方式很简单。每当长度威胁要超过容量时,就分配更多内存并将所有元素转移,如下面的代码所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn reserve(&mut self, additional: usize) {
self.buf.reserve(self.len, additional);
}
因此,接下来会调用 reserve() 函数,然后是 try_reserve(),接着是 RawVec<T> 的 amortized_new_size(),它也会决定大小:
fn amortized_new_size(&self, used_cap: usize, needed_extra_cap: usize)
-> Result<usize, CollectionAllocErr> {
// Nothing we can really do about these checks :(
let required_cap = used_cap.checked_add(needed_extra_cap).ok_or(CapacityOverflow)?;
// Cannot overflow, because `cap <= isize::MAX`, and type of `cap` is `usize`.
let double_cap = self.cap * 2;
// `double_cap` guarantees exponential growth.
Ok(cmp::max(double_cap, required_cap))
}
让我们来看看 VecDeque<T>。除了内存分配之外,VecDeque<T> 还必须处理数据在环形中的环绕,这为在指定位置插入元素或容量需要增加时增加了相当大的复杂性。然后,需要将旧元素复制到新的内存区域,从环绕列表的最短部分开始。
与 Vec<T> 类似,当 VecDeque<T> 满时,它会将缓冲区大小加倍,但使用 double() 函数来实现。请注意,加倍不是一个保证的策略,可能会改变。
然而,无论取代它的是什么,都必须保留操作的运行时复杂性。 以下是用以确定数据结构是否已满以及是否需要增长大小的函数:
#[inline]
fn is_full(&self) -> bool {
self.cap() - self.len() == 1
}
#[inline]
fn grow_if_necessary(&mut self) {
if self.is_full() {
let old_cap = self.cap();
self.buf.double();
unsafe {
self.handle_cap_increase(old_cap);
}
debug_assert!(!self.is_full());
}
}
handle_cap_increase()函数将决定新的环应该放在哪里以及如何处理复制到新缓冲区,优先考虑尽可能少地复制数据。除了Vec<T>之外,在VecDeque<T>上调用new()函数会在RawVec<T>中分配足够的空间以容纳七个元素,然后可以插入而不会增加底层内存的大小,因此当它为空时不是一个零大小结构。
插入
向Vec<T>添加元素有两种方式:insert()和push()。前者接受两个参数:要插入元素的位置索引和数据。在插入之前,索引上的位置将通过将所有后续元素移动到末尾(向右)来释放。因此,如果元素在前面插入,每个元素都必须移动一个位置。Vec<T>代码如下所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn insert(&mut self, index: usize, element: T) {
let len = self.len();
assert!(index <= len);
// space for the new element
if len == self.buf.cap() {
self.reserve(1);
}
unsafe {
// infallible
// The spot to put the new value
{
let p = self.as_mut_ptr().add(index);
// Shift everything over to make space. (Duplicating the
// `index`th element into two consecutive places.)
ptr::copy(p, p.offset(1), len - index);
// Write it in, overwriting the first copy of the `index`th
// element.
ptr::write(p, element);
}
self.set_len(len + 1);
}
}
当通过调用push()进行高效移动时,新项目可以添加而无需移动数据,如下所示:
#[inline]
#[stable(feature = "rust1", since = "1.0.0")]
pub fn push(&mut self, value: T) {
// This will panic or abort if we would allocate > isize::MAX bytes
// or if the length increment would overflow for zero-sized types.
if self.len == self.buf.cap() {
self.reserve(1);
}
unsafe {
let end = self.as_mut_ptr().offset(self.len as isize);
ptr::write(end, value);
self.len += 1;
}
}
正规的Vec<T>的主要缺点是无法高效地向前面添加数据,这正是VecDeque<T>擅长的领域。执行此操作的代码既简洁又短小,如下所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn push_front(&mut self, value: T) {
self.grow_if_necessary();
self.tail = self.wrap_sub(self.tail, 1);
let tail = self.tail;
unsafe {
self.buffer_write(tail, value);
}
}
在这些函数中使用unsafe {},代码比仅使用安全的 Rust 要短得多,也快得多。
查找
使用数组类型数据分配的主要优点是简单的快速元素访问,这是Vec<T>和VecDeque<T>共有的。使用括号直接访问的正式方式由Index<I>特质提供(let my_first_element= v[0];)。
除了直接访问之外,迭代器还提供了搜索、折叠、映射等功能。其中一些与这一节中的LinkedList<T>部分相当。
例如,Vec<T>的所有权迭代器(IntoIter<T>)拥有缓冲区的指针并将当前元素的指针向前移动。不过,这里也有一个陷阱:如果一个元素的大小是零字节,指针应该如何移动?返回什么数据?IntoIter<T>结构提出了一个巧妙的解决方案(ZSTs是零大小类型,所以实际上不占用空间):
pub struct IntoIter<T> {
buf: NonNull<T>,
phantom: PhantomData<T>,
cap: usize,
ptr: *const T,
end: *const T,
}
// ...
#[stable(feature = "rust1", since = "1.0.0")]
impl<T> Iterator for IntoIter<T> {
type Item = T;
#[inline]
fn next(&mut self) -> Option<T> {
unsafe {
if self.ptr as *const _ == self.end {
None
} else {
if mem::size_of::<T>() == 0 {
// purposefully don't use 'ptr.offset' because for
// vectors with 0-size elements this would return the
// same pointer.
self.ptr = arith_offset(self.ptr as *const i8, 1) as *mut T;
// Make up a value of this ZST.
Some(mem::zeroed())
} else {
let old = self.ptr;
self.ptr = self.ptr.offset(1);
Some(ptr::read(old))
}
}
}
}
// ...
}
注释已经说明了正在发生的事情,迭代器避免反复返回相同的指针,而是将其增加一个并返回一个清零的内存。这显然是 Rust 编译器不会容忍的事情,因此在这里使用unsafe是一个很好的选择。此外,常规迭代器(vec![].iter())在core::slice::Iter实现中被泛化,它作用于内存的泛型、类似数组的部分。
与之相反,VecDeque<T>的迭代器会通过在环中移动索引直到完成一圈来达到目的。以下是它的实现,如下面的代码所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub struct Iter<'a, T: 'a> {
ring: &'a [T],
tail: usize,
head: usize,
}
// ...
#[stable(feature = "rust1", since = "1.0.0")]
impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;
#[inline]
fn next(&mut self) -> Option<&'a T> {
if self.tail == self.head {
return None;
}
let tail = self.tail;
self.tail = wrap_index(self.tail.wrapping_add(1), self.ring.len());
unsafe { Some(self.ring.get_unchecked(tail)) }
}
//...
}
在其他特质中,两者都实现了在两端工作的DoubleEndedIterator<T>,一个特殊的功能称为DrainFilter<T>,以便仅在应用谓词的情况下从迭代器中检索项目。
删除
Vec<T> 和 VecDeque<T> 在删除元素时都保持高效。尽管如此,它们不会改变分配给数据结构的内存量,这两种类型都提供了一个名为 shrink_to_fit() 的函数来调整容量以适应其长度。
在 remove 操作中,Vec<T> 将剩余的元素移向序列的开始。像 insert() 函数一样,它只是简单地复制整个剩余数据并偏移,如下所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn remove(&mut self, index: usize) -> T {
let len = self.len();
assert!(index < len);
unsafe {
// infallible
let ret;
{
// the place we are taking from.
let ptr = self.as_mut_ptr().add(index);
// copy it out, unsafely having a copy of the value on
// the stack and in the vector at the same time.
ret = ptr::read(ptr);
// Shift everything down to fill in that spot.
ptr::copy(ptr.offset(1), ptr, len - index - 1);
}
self.set_len(len - 1);
ret
}
}
对于 VecDeque<T>,情况要复杂得多:因为数据可以绕过底层缓冲区的末端(例如,尾部在索引三,头部在索引五,所以从三到五的空间被认为是空的),它不能盲目地单向复制。因此,有一些逻辑来处理这些不同的情况,但这里添加的代码太长了。
LinkedList
Rust 的 std::collection::LinkedList<T> 是一个使用 unsafe 指针操作来绕过我们在第四章列表、列表和更多列表中必须进行的 Rc<RefCell<Node<T>>> 解包的双向链表。虽然不安全,但这是对该问题的绝佳解决方案,因为指针操作易于理解,并提供显著的好处。让我们看看以下代码:
#[stable(feature = "rust1", since = "1.0.0")]
pub struct LinkedList<T> {
head: Option<NonNull<Node<T>>>,
tail: Option<NonNull<Node<T>>>,
len: usize,
marker: PhantomData<Box<Node<T>>>,
}
struct Node<T> {
next: Option<NonNull<Node<T>>>,
prev: Option<NonNull<Node<T>>>,
element: T,
}
NonNull 是一个结构,它起源于 std::ptr::NonNull,在 unsafe 领域提供了一个非零指针到堆内存的一部分。因此,在基本层面上可以跳过内部可变性模式,消除运行时检查的需要。
架构
基本上,LinkedList 是按照我们在第四章列表、列表和更多列表中构建双向链表的方式构建的,增加了 PhantomData<T> 类型指针。为什么?这是必要的,以便在泛型涉及时通知编译器包含标记的类型属性。有了它,编译器可以确定一系列事情,包括析构行为、生命周期等。《PhantomData指针是一个零大小的添加,它假装拥有类型T` 的内容,因此编译器可以对此进行推理。
插入
std::collections::LinkedList 使用了几个不安全的方法来避免我们在以安全方式实现双向链表时看到的 Rc<RefCell<Node<T>>> 和 next.as_ref().unwrap().borrow() 调用。这也意味着在两端添加节点需要使用 unsafe 来设置这些指针。
在这种情况下,代码易于阅读和理解,这对于避免由于执行不稳定的代码而导致的意外崩溃非常重要。这是在前面添加节点的基本函数,如下所示:
fn push_front_node(&mut self, mut node: Box<Node<T>>) {
unsafe {
node.next = self.head;
node.prev = None;
let node = Some(Box::into_raw_non_null(node));
match self.head {
None => self.tail = node,
Some(mut head) => head.as_mut().prev = node,
}
self.head = node;
self.len += 1;
}
}
这段代码被公开的 push_front() 函数包裹,如下面的代码片段所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn push_front(&mut self, elt: T) {
self.push_front_node(box Node::new(elt));
}
push_back()函数,它执行与之前相同的操作,但作用于列表的末尾,工作方式如下。此外,链表可以像添加单个节点一样轻松地附加另一个列表,因为它几乎与添加单个节点相同,但需要额外的语义(例如:列表是否为空?)来处理:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn append(&mut self, other: &mut Self) {
match self.tail {
None => mem::swap(self, other),
Some(mut tail) => {
if let Some(mut other_head) = other.head.take() {
unsafe {
tail.as_mut().next = Some(other_head);
other_head.as_mut().prev = Some(tail);
}
self.tail = other.tail.take();
self.len += mem::replace(&mut other.len, 0);
}
}
}
}
添加项目是链表的一个强项。但查找元素呢?
查找
collections::LinkedList在很大程度上依赖于Iterator特质来查找各种项目,这是非常好的,因为它节省了很多精力。这是通过广泛实现各种迭代器特质来实现的,使用几个结构,如下所示:
-
Iter -
IterMut -
IntoIter
从技术上讲,DrainFilter也实现了Iterator,但它实际上是一个便利包装器。以下是LinkedList使用的Iter结构声明:
#[stable(feature = "rust1", since = "1.0.0")]
pub struct Iter<'a, T: 'a> {
head: Option<NonNull<Node<T>>>,
tail: Option<NonNull<Node<T>>>,
len: usize,
marker: PhantomData<&'a Node<T>>,
}
如果你还记得之前列表的声明,就会很明显,它们非常相似!事实上,它们是相同的,这意味着在遍历链表时,你实际上是在创建一个新的列表,每次调用next()都会变得更短。正如预期的那样,这是一个非常高效的过程,在这里被采用,因为没有任何数据被复制,Iter结构的头部可以随着当前头部的prev/next指针来回移动。
IterMut和IntoIter的结构略有不同,这是由于它们的目的不同。IntoIter获取整个列表的所有权,并根据请求调用pop_front()或pop_back()。
IterMut必须保留对原始列表的可变引用,以便向调用者提供可变引用,但除此之外,它基本上是一个Iter类型结构。
另一个也进行迭代的结构是DrainFilter,正如其名所示,它用于移除项目。
移除
链表包含两个函数:pop_front()和pop_back(),它们简单地封装了一个名为pop_front_node()的“内部”函数:
#[inline]
fn pop_front_node(&mut self) -> Option<Box<Node<T>>> {
self.head.map(|node| unsafe {
let node = Box::from_raw(node.as_ptr());
self.head = node.next;
match self.head {
None => self.tail = None,
Some(mut head) => head.as_mut().prev = None,
}
self.len -= 1;
node
})
}
这样,要从LinkedList<T>中移除特定元素,必须通过分割和附加列表(跳过所需元素)来完成,或者使用drain_filter()函数,它几乎就是这样做。
总结
Vec<T>和VecDeque<T>都基于堆分配的数组构建,并且在insert和find操作上表现良好,这得益于消除了几个步骤。然而,书中早期提到的动态数组实现实际上可以与这些相媲美。
之前实现的双向链表与std::collections提供的LinkedList<T>相比并不理想,后者构建得更加简单,并且不使用进行运行时借用检查的RefCells:

显然,如果你需要链表,不要自己实现,std::collections::LinkedList<T> 在链表方面非常出色。通常,Vec<T> 会表现得更好,同时提供更多功能,所以除非链表绝对必要,否则 Vec<T> 应该是默认选择。
映射和集合
Rust 的映射和集合主要基于两种策略:B-Tree 搜索和哈希。它们是两种非常不同的实现,但达到了相同的结果:将键与值关联(映射)以及基于键提供快速的唯一集合(集合)。
Rust 中的哈希使用 Hasher 特征,它是一个通用的、有状态的哈希器,从任意字节流中创建哈希值。通过重复调用适当的 write() 函数,可以将数据添加到哈希器的内部状态,并通过 finish() 函数完成。
在 Rust 中,B-Tree 的优化程度很高。BTreeMap 文档提供了丰富的细节,说明了为什么常规实现(如之前所示)在缓存效率低下,并且没有针对现代 CPU 架构进行优化。因此,他们提供了一个更有效的实现,这绝对令人着迷,你应该在源代码中查看它。
HashMap 和 HashSet
HashMap 和 HashSet 都使用哈希算法来生成存储和检索值所需的唯一键。哈希是通过 Hasher 特征的实例(如果没有指定则为 DefaultHasher)为每个实现了 Hash 和 Eq 特征的键创建的。它们允许将 Hasher 实例传递给 Hash 实现者以生成所需输出,并将数据结构用于比较键的相等性。
如果要将自定义结构用作哈希键(用于映射,或者简单地存储在集合中),则也可以从该实现中派生出来,这会将结构的每个字段添加到 Hasher 的状态中。如果特徵是手动实现的,它必须为两个相等的键创建相等的哈希值。
由于这两种数据结构都基于实现了此特性的键,并且都应该高度优化,因此出现了一个问题:为什么需要两种变体?
让我们看看源代码,如下所示:
#[derive(Clone)]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct HashSet<T, S = RandomState> {
map: HashMap<T, (), S>,
}
本节剩余部分将仅讨论 HashMap。
架构
HashMap 是一种高度优化的数据结构,它使用一种称为 Robin Hood 哈希 的性能启发式方法来改善缓存行为,从而提高查找时间。
Robin Hood 哈希最好与插入算法线性探测一起解释,这与上一章中使用的哈希表算法有些相似。然而,它不是数组数组的数组(或 Vec<Vec<(K, V)>>),基本数据结构是一个扁平数组,它被包裹在一个称为 RawTable<K, V> 的结构中(以及所有不安全的代码)。
表将数据组织成代表特定哈希值的桶(空或满)。线性探测意味着每当发生冲突(两个哈希值相等,而它们的键不相等)时,算法会继续查找(“探测”)下一个桶,直到找到一个空桶。
罗宾汉的部分是计算从原始(理想)位置的距离,每当桶中的元素更接近其理想位置(即更丰富)时,桶的内容就会交换,并且搜索继续使用从其桶中移出的元素。因此,搜索从富有(只有少量步骤远离理想位置)到贫穷(那些远离理想位置的人)。
这种策略将数组组织成以哈希值为中心的簇,大大减少了键值方差,同时提高了 CPU 缓存友好性。影响这种行为的另一个主要因素是表的大小以及有多少个桶被占用(称为负载因子)。HashMap的DefaultResizePolicy在负载因子为 90.9%时将表的大小改变为 2 的更高次幂——这是一个为罗宾汉桶窃取提供理想结果的数量。还有一些关于如何管理这种增长而不必重新插入每个元素的好想法,但它们肯定超出了本章的范围。如果你对此感兴趣,建议阅读源代码的注释(见进一步阅读部分)。
插入
罗宾汉哈希策略已经描述了insert机制的大部分内容:对键值进行哈希,寻找空桶,并在过程中根据它们的探测距离重新排列元素:
pub fn insert(&mut self, k: K, v: V) -> Option<V> {
let hash = self.make_hash(&k);
self.reserve(1);
self.insert_hashed_nocheck(hash, k, v)
}
此函数只执行第一步并扩展基本数据结构——如果需要的话。insert_hashed_nocheck()函数通过在现有表中搜索哈希值并提供相应的桶来提供下一步。元素负责将自己插入正确的位置。完成这一步骤所需的步骤取决于桶是满的还是空的,这被建模为两种不同的结构:VacantEntry和OccupiedEntry。后者简单地替换值(这是一个更新),而VacantEntry必须找到一个离分配的桶不太远的空位:
pub fn insert(self, value: V) -> &'a mut V {
let b = match self.elem {
NeqElem(mut bucket, disp) => {
if disp >= DISPLACEMENT_THRESHOLD {
bucket.table_mut().set_tag(true);
}
robin_hood(bucket, disp, self.hash, self.key, value)
},
NoElem(mut bucket, disp) => {
if disp >= DISPLACEMENT_THRESHOLD {
bucket.table_mut().set_tag(true);
}
bucket.put(self.hash, self.key, value)
},
};
b.into_mut_refs().1
}
调用robin_hood()执行前面描述的搜索和交换。这里有一个有趣的变量是DISPLACEMENT_THRESHOLD。这难道意味着一个值可以有的位移上限吗?是的!这个值是128(因此需要128次错过),但这并不是随机选择的。实际上,代码注释详细说明了为什么以及如何选择这个值,如下所示:
// The threshold of 128 is chosen to minimize the chance of exceeding it.
// In particular, we want that chance to be less than 10^-8 with a load of 90%.
// For displacement, the smallest constant that fits our needs is 90, // so we round that up to 128.
//
// At a load factor of α, the odds of finding the target bucket after exactly n
// unsuccessful probes[1] are
//
// Pr_α{displacement = n} =
// (1 - α) / α * ∑_{k≥1} e^(-kα) * (kα)^(k+n) / (k + n)! * (1 - kα / (k + n + 1))
//
// We use this formula to find the probability of triggering the adaptive behavior
//
// Pr_0.909{displacement > 128} = 1.601 * 10^-11
//
// 1\. Alfredo Viola (2005). Distributional analysis of Robin Hood linear probing // hashing with buckets.
如评论所述,一个元素实际上超过该阈值的可能性非常低。一旦每个元素都找到了合适的位置,就可以进行查找。
查找
查找条目是HashMap的插入过程的一部分,它依赖于相同的函数来提供合适的条目实例以添加数据。就像插入过程一样,查找过程几乎相同,只是在最后省略了一些步骤,如下所示:
-
创建键的哈希
-
在表中找到哈希的桶
-
离开桶比较键(线性搜索)直到找到
由于所有这些都已经实现并用于其他函数,get()相当短,如下面的代码所示:
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
where K: Borrow<Q>,
Q: Hash + Eq
{
self.search(k).map(|bucket| bucket.into_refs().1)
}
同样,remove函数需要search,并且删除是在条目类型上实现的。
删除
remove函数看起来很像search函数,如下所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn remove<Q: ?Sized>(&mut self, k: &Q) -> Option<V>
where K: Borrow<Q>,
Q: Hash + Eq
{
self.search_mut(k).map(|bucket| pop_internal(bucket).1)
}
有一个主要区别:search返回一个可变的桶,可以从其中删除键(或者更确切地说,是整个桶,因为它现在为空)。HashMap最终证明是一段令人印象深刻的代码;BTreeMap能与之竞争吗?
BTreeMap 和 BTreeSet
在第五章中讨论 B 树,鲁棒树,其目的是存储键值对——非常适合映射类型的数据结构。它们通过有效地最小化到达(或排除)键所需的比较次数来实现查找和检索这些对的能力。此外,树保持键的顺序,这意味着迭代将是隐式有序的。与HashMap相比,这可能是一个优点,因为它跳过了一个可能昂贵的步骤。
由于——就像HashSet一样——BTreeSet只是使用带有空值(只有键)的BTreeMap,因此本节只讨论后者,因为假设工作方式相同。再次,让我们从架构开始。
架构
Rust 的BTreeMap选择了一种有趣的方法,通过创建大型单个节点来最大化搜索性能。回想一下节点的典型大小(即它们拥有的子节点数量),它们多于两个(只有根节点),或者树的水平数的一半到树的水平数的子节点数。在一个典型的 B-Tree 中,水平很少超过 10,这意味着节点保持相当小,节点内的比较次数也相应较少。
Rust BTreeMap的实现者选择了一种不同的策略来改善缓存行为。为了提高缓存友好性和减少所需的堆分配次数,Rust 的BTreeMap每个节点存储从level - 1到2 * level - 1个元素,这导致了一个相当大的键数组。
虽然——小数组的键——足以适应 CPU 的缓存,但树本身有更多的这些数组,因此可能需要查看更多的节点。如果一个节点中的键值对数量更高,整体节点数量就会减少,如果键数组仍然适合 CPU 的缓存,这些比较就可以尽可能快地进行。使用更智能的搜索(如二分搜索)来减轻搜索键的大数组带来的缺点,因此拥有较少节点的整体性能提升超过了缺点。
通常,当将本书中较早提到的 B-Tree 与 BTreeMap 进行比较时,只有几个相似之处脱颖而出,其中之一就是插入新元素。
插入
就像每个 B-Tree 一样,插入操作首先搜索插入的位置,然后如果节点具有超过预期数量的值(或子节点),则应用拆分过程。插入操作分为三个部分,它从要调用的第一个方法开始,该方法将一切粘合在一起并返回预期的结果:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
match self.entry(key) {
Occupied(mut entry) => Some(entry.insert(value)),
Vacant(entry) => {
entry.insert(value);
None
}
}
}
第二步是找到可以插入键值对的节点句柄,如下所示:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn entry(&mut self, key: K) -> Entry<K, V> {
// FIXME(@porglezomp) Avoid allocating if we don't insert
self.ensure_root_is_owned();
match search::search_tree(self.root.as_mut(), &key) {
Found(handle) => {
Occupied(OccupiedEntry {
handle,
length: &mut self.length,
_marker: PhantomData,
})
}
GoDown(handle) => {
Vacant(VacantEntry {
key,
handle,
length: &mut self.length,
_marker: PhantomData,
})
}
}
}
一旦知道了句柄,条目(无论是模拟空位还是占用位的结构)就会插入新的键值对。如果条目之前是占用的,值将被简单地替换——不需要进一步的操作。如果位置是空的,新值可能会触发树的重平衡,其中更改会向上冒泡到树中:
#[stable(feature = "rust1", since = "1.0.0")]
pub fn insert(self, value: V) -> &'a mut V {
*self.length += 1;
let out_ptr;
let mut ins_k;
let mut ins_v;
let mut ins_edge;
let mut cur_parent = match self.handle.insert(self.key, value) {
(Fit(handle), _) => return handle.into_kv_mut().1,
(Split(left, k, v, right), ptr) => {
ins_k = k;
ins_v = v;
ins_edge = right;
out_ptr = ptr;
left.ascend().map_err(|n| n.into_root_mut())
}
};
loop {
match cur_parent {
Ok(parent) => {
match parent.insert(ins_k, ins_v, ins_edge) {
Fit(_) => return unsafe { &mut *out_ptr },
Split(left, k, v, right) => {
ins_k = k;
ins_v = v;
ins_edge = right;
cur_parent = left.ascend().map_err(|n| n.into_root_mut());
}
}
}
Err(root) => {
root.push_level().push(ins_k, ins_v, ins_edge);
return unsafe { &mut *out_ptr };
}
}
}
}
查找键已经是插入过程的一部分,但也值得仔细看看。
查找
在树结构中,插入和删除都是基于查找正在修改的键。在 BTreeMap 的情况下,这是通过从父模块导入的 search_tree() 函数完成的:
pub fn search_tree<BorrowType, K, V, Q: ?Sized>(
mut node: NodeRef<BorrowType, K, V, marker::LeafOrInternal>,
key: &Q
) -> SearchResult<BorrowType, K, V, marker::LeafOrInternal, marker::Leaf>
where Q: Ord, K: Borrow<Q> {
loop {
match search_node(node, key) {
Found(handle) => return Found(handle),
GoDown(handle) => match handle.force() {
Leaf(leaf) => return GoDown(leaf),
Internal(internal) => {
node = internal.descend();
continue;
}
}
}
}
}
pub fn search_node<BorrowType, K, V, Type, Q: ?Sized>(
node: NodeRef<BorrowType, K, V, Type>,
key: &Q
) -> SearchResult<BorrowType, K, V, Type, Type>
where Q: Ord, K: Borrow<Q> {
match search_linear(&node, key) {
(idx, true) => Found(
Handle::new_kv(node, idx)
),
(idx, false) => SearchResult::GoDown(
Handle::new_edge(node, idx)
)
}
}
代码本身非常易于阅读,这是一个好兆头。它还避免了递归的使用,而是使用 loop{} 构造,这对于大范围查找来说是一个优点,因为 Rust 目前还没有将尾递归调用展开成循环(?)。无论如何,这个函数返回键所在的节点,让调用者从该节点中提取值和键。
删除
remove 函数封装了被占用节点的 remove_kv() 函数,该函数从 search_tree() 挖掘出的句柄中删除一个键值对。这种删除还会触发节点合并,如果节点现在拥有的子节点数量少于最小数量。
总结
如本节所示,映射和集合有很多共同之处,Rust 收集库提供了两种方式来实现它们。HashMap 和 HashSet 使用一种智能方法来查找和将值插入到称为 Robin Hood 哈希的桶中。回想一下 第六章 的比较基准测试,探索映射和集合,它提供了一个更稳定且性能显著更好的实现:

BTreeMap和BTreeSet基于 B-树的不同、更有效的实现。它有多高效(和有效)?让我们来看看!

对于 B-树的简单实现(来自第五章,鲁棒树),性能并不那么糟糕。然而,尽管可能需要在这里和那里做一些调整,但证据表明,有一个更好、更快的树存在,那么为什么不使用它呢?
摘要
Rust 标准库具有出色的集合部分,提供了一些基本数据结构的优化实现。
我们从Vec<T>和VecDeque<T>开始,它们都基于堆分配的数组,并封装在RawVec<T>结构中。由于数组基础和基于指针的unsafe操作,它们在保持内存效率高的同时表现出色。
LinkedList<T>是一个双向链表,由于直接数据操作和缺乏运行时检查,它表现得很出色。虽然它在分割和合并方面表现优异,但大多数其他操作比Vec<T>慢,并且缺少一些有用的功能。
HashSet和HashMap基于相同的实现(HashMap),除非指定不同,否则使用DefaultHasher来生成对象的哈希键。这个键使用罗宾汉哈希方法存储(并稍后检索),这种方法与原始实现相比提供了主要性能优势。
或者,BTreeSet和BTreeMap使用 B-树结构来组织键和值。这种实现也是专门化的,并且针对 CPU 缓存友好性进行优化,通过减少节点数量(从而最小化分配次数)来创建高性能的数据结构。
在下一章中,我们将解析 O 表示法,这是到目前为止使用得很少,但对于接下来的内容(算法)是必要的。
问题
-
哪个
std::collections数据结构在这里没有讨论? -
截至 2018 年,
Vec<T>或VecDeque<T>是如何增长的? -
LinkedList<T>是否是一个好的默认数据结构? -
2018 年的
HashMap默认使用哪种哈希实现? -
BTreeMap相对于HashMap有哪些三个优点? -
BTreeMap的内部树是更宽还是更高?
进一步阅读
您可以参考以下链接,了解更多关于本章涵盖主题的信息:
第八章:算法评估
当将算法视为定义实体时,是什么使得一个算法比另一个算法更好?是完成所需的步骤数?所分配的内存量?CPU 周期?它们如何在具有不同内存分配器的机器和操作系统之间进行比较?
这里有很多问题需要回答,因为比较他人的工作对于找到解决给定问题的最佳方法非常重要。在本章中,你可以期待了解以下内容:
-
实践中评估算法
-
对算法和数据结构行为进行分类
-
估算更好算法的可行性
大 O 符号
物理学不是本书的主题,但其影响深远,强大到无处不在,甚至算法这样的虚拟构造也要遵守!然而,无论设计多么伟大,它们仍然受限于两个重要因素:时间和空间。
时间?无论何时需要完成任何事情,都需要一系列步骤。通过将每个步骤的时间乘以步骤的数量,总时间——绝对时间——很容易计算。或者我们认为是这样。对于计算机来说,这大部分是正确的,但许多问题使得真正知道这一点变得非常困难,因为现代 CPU 的能力远远超过了前几代。这仅仅是由于更高的时钟频率吗?额外的核心?SIMD?仅仅计算绝对时间并不能真正实现算法之间的可比性。也许步骤的数量是我们应该使用的。
在过去几年中,空间(即内存)在许多领域已经成为一种商品,甚至在嵌入式空间也是如此。尽管情况有所改善,但仍然值得注意存储在内存中的字节数以及这对算法目标的贡献。换句话说,这是否值得?许多算法任务面临着存储在内存中的内容与按需计算的内容之间的权衡。后者可能刚好足够解决问题,也可能不够;这是开发者必须做出的决定。
他人代码
因此,每个算法都必须有一个“所需步骤数”和“所需内存字节数”属性,对吗?接近:由于它们是不断变化的变量,有必要找到一个描述他人所取得的成就的通用方法。
通常,程序员本能地知道如何做到这一点:“这个玩意儿真的做了两次吗?!”应该是一个熟悉的呼喊。这里说了什么?假设它是一个具有输入参数x的函数,听起来这个函数对x做了两次操作。从数学的角度来说,这可以表示为f(x) = 2x。
这实际上是在说,对于每个输入,完全执行函数所需的步骤数是输入的两倍——这不是我们一直在寻找的吗?有什么更好的方法来写下它?
大 O 符号
从(数学)函数的角度来看这个问题,这是数学、计算机科学、物理学等领域的一个共同需求:他们都想知道一个函数有多贵。这就是为什么爱德蒙·兰道发明了一种常见的符号:大 O 符号(或兰道符号),由大写字母 O 组成,它声明了函数的 阶。然后主要增长因子放在字母 O 后面的括号中。
还有其他一些相关的符号,使用小 o、Omega、Theta 等,但在实际应用中这些不太相关。请参阅 进一步阅读 部分,了解唐纳德·克努特关于此的文章。
渐近运行时间复杂度
对于计算机科学来说,在实现算法时,确切的绝对运行时间通常并不重要(你总是可以获取更快的计算机)。相反,运行时间复杂度更重要,因为它直接影响了性能,作为工作整体的一个衡量标准,独立于细节。
由于这并不是一个精确的测量,实际性能受其他因素的影响,因此坚持使用渐近的(即:粗略的)度量是最好的策略。除此之外,算法有最好和最坏的情况。除非你试图改进特定的情况,否则通常比较的是最坏的情况:
let my_vec = vec![5,6,10,33,53,77];
for i in my_vec.iter() {
if i == 5 {
break;
}
println!("{}", i);
}
对此进行迭代,Vec<T> 的运行时间复杂度为 O(n),其中 n 是 Vec<T> 的长度,无论循环是否会立即中断。为什么?因为悲观主义。实际上,通常很难说输入向量看起来是什么样子,以及它何时会真正退出,所以最坏的情况是它遍历整个序列而没有中断,即 n 次。现在我们已经看到了如何写下这一点,让我们看看如何找出我们自己的算法的运行时间复杂度。
自行制作
只有少数方面会改变算法的复杂度,那些已经证明会成比例增加算法所需总时间的方面。
这些如下所示:
-
一个算术运算(
10 + 30) -
一个赋值(
let x = 10) -
一个测试(
x == 10) -
基本类型的读取或写入(
u32、bool等)
如果一段代码只执行这些操作中的一个,那么它是一步,即 O(1),并且每当有一个选择(if 或 match),就必须选择更复杂的分支。无论任何输入参数如何,它都将执行相同数量的步骤——即常数时间。如果它们在循环中运行,事情就变得更有趣了。
循环
当在一个循环中,且循环的迭代次数在编译时未知时,它将对运行时间复杂度产生重大影响。如果之前提到的操作在循环中执行(例如,一个 sum 操作),可以将算术操作的复杂度声明为 O(1 * n)。在添加另一个操作后,我们可以将其表示为 O(2 * n),虽然这是正确的,但这些不是循环的主要驱动力。无论执行 n 次的操作数量如何,主要增长因子仍然是 n。因此,我们简单地说是 O(n),除非你试图比较同一算法,其中迭代次数实际上是有区别的。如果有后续的循环,则选择最昂贵的那个。
然而,在嵌套循环之后,复杂度会显著变化。考虑这个(真的很差)比较两个列表的算法:
let my_vec = vec![1,1,1,4,6,7,23,4];
let my_other_vec = vec![66,2,4,6,892];
for i in my_vec.iter() {
for j in my_other_vec.iter() {
if i == j {
panic!();
}
}
}
对于第一个集合中的每个元素,第二个集合将被完全迭代。换句话说,每个元素被查看 n * m 次,导致运行时间复杂度为 O(nm)*,或者如果两个集合大小相同,则为 O(n²)。
能变得更糟吗?是的!
递归
由于所有递归算法都可以展开成循环,因此它们可以达到相同的结果。然而,递归,或者更具体地说回溯(将在第十一章[0131b10b-0ea4-4663-966a-46d6ecda142b.xhtml]“随机与组合”中更详细地讨论),使得创建更高的运行时间复杂度变得更容易。
典型的组合问题会导致指数级的运行时间,因为存在许多变体(例如不同的颜色),必须枚举 n 次以满足约束,而这个约束只在最后评估。如果有两种颜色,那么在图中没有两种颜色可以相邻的情况下,序列的运行时间复杂度将是 O(2^n)。
递归算法也使得快速估计运行时间复杂度变得困难,因为分支发展难以可视化。
复杂度类别
通常,所有算法都落入几个类别之一。让我们按增长速度的顺序查看这些类别。根据文献的不同,可能会有更多或更少的类别,但这是一个好的起点,因为它们代表了增长行为的主要方向。
O(1)
常数时间,这意味着所有操作都将花费相同的时间。由于这个图表将在 y 值为 1 的水平线上,我们将跳过它以节省一棵树。
O(log(n))
增长由对数函数(通常以 2 为底)定义,这比线性增长更好。
这是数学函数的图像:

O(n)
线性时间,这意味着解决方案的性能以线性方式依赖于输入:

O(n log(n))
这有时被称为准线性时间,是排序能够达到的最佳复杂度:

O(n²)
平方运行时间对于搜索或排序算法的直观实现来说是典型的:

O(2n)
这是最昂贵的类别之一,通常可以在一些非常难以解决的问题中找到。这个图表的 x 值(0 - 10)显著小于 O(n log(n)) 图表,并产生更高的 y 值(或运行时间):

比较
拥有个体图表对于想象预期的运行时间和估计当输入增加时任务性能可能的样子非常有帮助。然而,如果我们把这些线都画在一张图上,它们的性能就会变得明显。
典型的比较是针对线性时间复杂度(O(n)),因为大多数直观的解决方案都预期能够达到这种性能:

考虑到这张图,我们可以在下一节中查看问题和它们的预期性能。
在野外
事实上,有很多因素可能会影响空间和运行时间复杂度的选择。通常,这些因素是资源约束的形式,例如嵌入式设备上的功耗、云托管环境中的时钟周期等等。
由于很难找出特定算法的复杂度,了解一些常见的复杂度是有帮助的,这样选择就会变得直观。通常,运行时间复杂度并不是唯一重要的方面,绝对执行时间也很重要。在这些条件下,如果 n 足够小,较高的运行时间复杂度可能是可取的。
这在 Vec<T> 只包含少量元素时表现得最好,其中线性搜索比排序后运行二分搜索要快得多。与立即搜索相比,排序的开销可能太大。
获得这种权衡和整体实现正确对于整个程序来说非常有益,并且会超过任何其他优化。让我们看看日常生活中可以找到的一些运行时间复杂度。
数据结构
所有类型的列表算法几乎总是表现出 O(n) 的行为,因为大多数操作都涉及移动或遍历其他元素。因此,在某个位置插入或删除元素,以及查找元素(当未排序时),都是 O(n)。这在链表中尤为明显,只有少数例外:动态数组元素的访问(O(1))、前缀/后缀元素或列表,以及链表中添加元素时的列表分割(O(1))。
列表的特殊情况,例如栈和队列,利用这些异常,并允许用户仅在该列表的端点插入或删除。另一方面,跳表采用类似树的策略以实现出色的搜索性能,这也会加快插入和删除的速度。但这是以内存为代价的,因为额外的元素与列表长度成比例(log(n))。
对于搜索,树是很好的。常规树(即可以是 B 树的一切)在许多操作上表现出O(log(n))的复杂度,包括插入、删除和查找。这特别出色,因为与O(n)的差异实际上随着集合中元素数量的增加而增加。
最好的可能就是映射和集合,如果底层实现使用了合适的哈希算法。如果没有冲突,任何操作都应该在常数时间内(O(1))完成。通常会有一些冲突,但运行时间复杂度不会超过O(n),因为如果所有其他方法都失败了,线性搜索仍然有效。因此,实际性能将介于两者之间,其中哈希算法是最重要的因素。对于大多数库来说,哈希映射(和集合)比基于树的对应物要快。
日常事物
每当需要排序时,有很多方法可以实现这一点,但基础是O(n²)。这与大多数人整理袜子的方式相同:挑选一个并找到匹配的,然后重复(称为选择排序)。否则,如何比较所有元素以找到它们的顺序?更好的方法,如堆排序、归并排序等,在最坏情况下都表现出O(n log(n))的行为,这是排序算法可能达到的最佳(一致)性能。此外,由于任何排序算法的最佳情况是O(n)——确保一切都已经排序——平均情况最为重要。我们将在本书后面的章节中探讨这方面的策略。
搜索(或查找)是另一个我们将在第十章“寻找东西”中探讨的主题,但相关的运行时间复杂度是很好的例子。在大多数未排序的数据结构上进行搜索将通常是O(n),而排序的集合可以利用二分搜索(树的搜索策略)并实现O(log(n))。为了节省排序的成本,理想的哈希表提供了搜索的最佳情况:O(1)。
异常事物
在早期列表中省略的一个类别是多项式时间(简称P)。这个类比指数时间类更快地解决,但比O(n²)差。这些问题包括检查一个数是否为素数,或解决数独。然而,这个类别中还有其他问题,实际上并没有“快速”(即在 P 时间内可解)的解决方案,但可以在 P 时间内验证解决方案。这些被称为NP(非确定性多项式时间的缩写)问题,其中最困难的是 NP-难(见信息框)。
P、NP、NP-完全和 NP-难之间的区别并不直观。NP 问题是可以使用非确定性图灵机在 P 时间内解决的问题。NP-难问题是没有解决方案的问题,如果解决了,将会有多项式时间解决方案,如果它也是一个 NP 问题,那么它也被认为是 NP-完全的。此外,找到其中一个类(NP-难或 NP-完全)的解决方案将意味着所有 NP-难/NP-完全问题的解决方案。
虽然没有已知算法可以快速解决这些问题,但通常有一些原始方法会导致非常长的运行时间。这个领域中的流行问题包括旅行商问题(O(n!))、背包问题(O(2n)*)和子集和问题(*O(2(n/2))),所有这些问题目前都是通过启发式方法或编程技术来解决(或近似)的。对那些感兴趣的人来说,请查看进一步阅读部分以获取链接。
摘要
大 O 符号是描述算法(或数据结构)的时间和空间需求的一种方式。这并不是一门精确的科学;它关于找到所提到的事物的主要增长因子来回答这个问题:当问题空间变大时会发生什么?
任何算法都将属于几个相关的类别,这些类别描述了该行为。通过将算法应用于一个额外的元素,需要采取多少额外的步骤?一个简单的方法是可视化单个图表,并思考它是否将是线性的(O(n))、准线性的(O(n log(n))、二次的(O(n²))甚至是指数的(O(2^n))。无论情况如何,总是最好做的工作比要查看的元素少,例如常数(O(1))或对数(O(log(n))行为!
选择操作通常基于最坏的行为,即将要发生的上限。在下一章中,我们将更详细地研究这些行为在流行搜索算法中的情况。
问题
-
为什么要在例如语句数量上估计运行时间复杂度?
-
运行时间复杂度如何与数学函数相关?
-
通常提供的复杂度类是最好还是最坏的情况?
-
为什么循环在估计复杂度时很重要?
-
O(n log(n))的运行时间复杂度比O(log(n))更好还是更差?
-
一些常见的复杂度类有哪些?
进一步阅读
您可以参考以下链接,获取本章涵盖主题的更多信息:
-
维基百科关于最佳、最坏和平均情况复杂性的列表 (
en.wikipedia.org/wiki/Best,_worst_and_average_case) -
Big O Cheatsheet (
bigocheatsheet.com/) -
西北大学的启发式算法 (
optimization.mccormick.northwestern.edu/index.php/Heuristic_algorithms) -
麻省理工学院的启发式设计和优化 (
www.mit.edu/~moshref/Heuristics.html) -
由唐纳德·克努特所著的 Big Omicron And Big Omega And Big Theta (
www.phil.uu.nl/datastructuren/10-11/knuth_big_omicron.pdf)
第九章:排序事物
整理房屋,整理心灵是一句谚语,正如其德语变体一样,意味着秩序在我们的生活中起着重要作用。任何想要最大化效率的人都必须依赖秩序,否则可能会偶尔花费大量时间在慢慢展开的混乱中进行搜索。拥有特定顺序的事物是很好的;但到达那里的过程是昂贵的。
这通常感觉不像是我们时间的良好利用,或者简单地可能不值得。虽然计算机并不确切地感觉,但排序事物所需的时间成本是相似的。最小化这一时间是发明新算法和改进其效率的目标,这对于像排序这样的常见任务来说是必要的。调用mycollection.sort()不应该需要几秒钟(或几分钟甚至几小时),因此这也是可用性的问题。在本章中,我们将探讨几个解决方案,因此你可以期待学习以下内容:
-
实现和分析排序算法
-
了解更多关于(不)著名的排序策略
从混乱到有序
有许多排序算法(及其各自的变体),每个都有其独特的特性。由于不可能在单章中涵盖每个算法,并且考虑到它们的有限实用性,本章涵盖了选定的几个。
选择应展示在排序项目集合中常见的不同策略,其中许多已经在不同语言的各个库中实现。由于你们中的许多人永远不会为生产目的实现任何排序算法,本节旨在使你们熟悉在发出mycollection.sort()调用时幕后发生的事情,以及为什么这可能需要令人惊讶的大量时间。
排序算法根据这些属性分为不同的组:
-
稳定:在比较相等值时保持相对顺序
-
混合:结合两种或多种排序方法(例如,按集合长度)
-
原地:使用索引而不是完整副本来传递集合
虽然稳定和混合算法更复杂,并且在许多情况下处于更高层次(因为它们结合了各种方法),但原地排序是常见的,它减少了算法必须执行的空间和复制量。
我们已经触及了一个非常基础的排序算法:插入排序。这是现实生活中大多数事情所使用的确切算法:当向书架上添加新书时,大多数人会拿起书,查看排序属性(例如作者的姓氏),并从字母A开始,找到他们当前收藏中的位置。这是一个非常有效的方法,用于以最小的开销构建新的收藏,但它不值得单独成章。
让我们从绝对经典且总是任何大学课程的一部分的简单算法开始:冒泡排序。
冒泡排序
冒泡排序是臭名昭著的算法,大学生通常将其作为他们第一个学习的排序算法。在性能和运行时间复杂度方面,它无疑是排序集合的最差方法之一,但非常适合教学。
原理很简单:遍历一个数组,扫描两个元素,并通过交换将它们带入正确的顺序。重复这些步骤,直到没有交换发生。以下图显示了在示例数组 [8, 9, 7, 6] 上的此过程,总共进行了四次交换,通过反复比较两个连续的元素,建立了 [6, 7, 8, 9] 的顺序。

此图还显示了算法的一个有趣(且命名)的特性:元素“冒泡”到它们预期的位置。图中的数字6通过一次交换,从集合的最后位置移动到第一个位置。
当这被转换成 Rust 代码时,其简洁性仍然保持:两个嵌套循环遍历集合,而外层循环也可以无限运行,因为内层部分做了所有的比较和交换。
冒泡排序,臭名昭著,是一段简短的代码:
pub fn bubble_sort<T: PartialOrd + Clone>(collection: &[T]) -> Vec<T> {
let mut result: Vec<T> = collection.into();
for _ in 0..result.len() {
let mut swaps = 0;
for i in 1..result.len() {
if result[i - 1] > result[i] {
result.swap(i - 1, i);
swaps += 1;
}
}
if swaps == 0 {
break;
}
}
result
}
为了更容易处理,该算法创建了输入数组的副本(使用Into<T>特质的into()方法)并使用Vec<T>提供的swap()方法交换元素。
嵌套循环已经暗示了(最坏情况)的运行时间复杂度:O(n²)。然而,由于在运行中没有交换时提前停止,部分有序集合将被惊人地快速排序。事实上,冒泡排序的最佳情况非常快,因为它基本上是单次遍历(换句话说,在这种情况下是O(n))。
下面的图表显示了三种情况:排序已排序的集合(升序数字和降序数字),以及排序一个由不同数字随机打乱的数组:

冒泡排序升序、降序和随机排序数组的输出图比较
该算法将产生一个升序序列,但打乱后的集合的绝对运行时间比传统的最坏情况还要差:按降序排序的集合。无论如何,这些运行时间的指数性质显示了为什么冒泡排序不适合实际应用。
希尔排序有时被称为冒泡排序的优化版本!
希尔排序
冒泡排序总是将一个元素与相邻的元素进行比较,但这重要吗?许多人会说,这取决于未排序集合的现有顺序:这些未来的邻居是相隔很远还是很近?
壳排序的发明者唐纳德·希尔(Donald Shell)肯定有类似的思路,并使用元素之间的“间隙”来采用冒泡排序的交换方法进行进一步的跳跃。通过利用特定的策略来选择这些间隙,运行时间可以发生显著变化。希尔的原策略是从集合长度的一半开始,通过将间隙大小减半直到零,实现了O(n²)的运行时间。其他策略包括根据当前迭代k(例如,2^k - 1)的某种形式的计算选择数字,或者根据经验收集的间隙(sun.aei.polsl.pl/~mciura/publikacje/shellsort.pdf),这些间隙还没有固定的运行时间复杂度!
以下图表解释了壳排序的一些工作原理。首先,选择初始间隙,原始论文中是n / 2。从那个间隙开始(在这个特定的例子中是2),保存元素并与间隙另一端的元素进行比较,换句话说,当前索引减去间隙:

如果间隙另一端的元素更大,它将替换原始元素。然后,这个过程以间隙大小的步长向索引零移动,所以问题变成了:什么将填补那个空隙(7被8覆盖,所以空隙是8所在的位置)——原始元素,还是它之前的元素“间隙”步数?
在这个例子中,它是7,因为没有前面的元素。在更长的集合中,在原始元素插入之前可能会发生更多的移动。在完成索引 2 的插入过程后,它会对索引 3 重复这个过程,从间隙向集合的末尾移动。之后,间隙大小会减少(在我们的例子中,减半),然后重复插入步骤,直到集合有序(并且间隙大小为零)。
单词,甚至是一张图片,都让人难以理解正在发生的事情。然而,代码却很好地展示了工作原理:
pub fn shell_sort<T: PartialOrd + Clone>(collection: &[T]) -> Vec<T> {
let n = collection.len();
let mut gap = n / 2;
let mut result: Vec<T> = collection.into();
while gap > 0 {
for i in gap..n {
let temp = result[i].clone();
let mut j = i;
while j >= gap && result[j - gap] > temp {
result[j] = result[j - gap].clone();
j -= gap;
}
result[j] = temp;
}
gap /= 2;
}
result
}
这个片段展示了壳排序的价值:使用正确的间隙策略,它可以实现与更复杂的排序算法相似的结果,但它实现起来要短得多,理解起来也容易得多。正因为如此,它对于嵌入式用例来说可能是一个不错的选择,在这些用例中,没有库,只有有限的空间可用。
在测试集上的实际性能很好:

壳排序升序、降序和随机排序数组之间的输出图比较
即使是据说会产生O(n²)运行时间的原始间隙策略,随机集合也产生了更接近线性行为的结果。绝对是一个良好的性能,但它能与堆排序相比吗?
堆排序
在本书中我们之前已经讨论过排序数字的话题(第五章,健壮的树),当时在讨论树:堆。堆是一种类似树的数据结构,其根节点具有最高(最大堆)或最低(最小堆)数值,在插入或删除元素时保持有序。因此,排序机制可以简单到将所有内容插入堆中,然后再检索出来!
由于(二叉)堆的已知运行时间复杂度为O(log n),并且整个数组都必须插入,因此估计的运行时间复杂度将是O(n log n),这是排序中最佳性能之一。以下图表显示了右侧的树表示法中的二叉堆和左侧的数组实现:

在 Rust 标准库中,有一个可用的BinaryHeap结构,这使得实现变得快速且简单:
pub fn heap_sort<T: PartialOrd + Clone + Ord>(collection: &[T]) -> Vec<T> {
let mut heap = BinaryHeap::new();
for c in collection {
heap.push(c.clone());
}
heap.into_sorted_vec()
}
使用堆进行排序的事实将产生相当均匀的结果,使其成为无序集合的优秀选择,但对于预先排序的集合则不是最佳选择。这是因为无论预先存在的排序如何,堆都会被填充和清空。绘制不同情况几乎显示没有差异:

堆排序升序、降序和随机排序数组的输出图比较
另一种非常不同的策略,称为分而治之,被一组算法所采用。我们现在将要探索的这一组算法,从归并排序开始。
归并排序
战斗中的一个基本策略,以及在排序集合中,是分而治之。归并排序正是通过递归地将集合分成两半,直到只剩下一个元素来做到这一点。合并操作可以利用预先排序的集合的优势,将这些单个元素按正确顺序放在一起。
这所做的就是将问题规模(换句话说,集合中的元素数量)减少到更易于管理的块,这些块预先排序,以便更容易比较,从而在最坏情况下的运行时间复杂度为O(n log n)。以下图表显示了拆分和合并过程(请注意,比较和排序仅在合并步骤开始):

这个原则有多种实现方式:自下而上、自上而下、使用块以及其他变体。实际上,截至 2018 年,Rust 的默认排序算法是 Timsort,这是一种稳定、混合算法,它将插入排序(直到一定大小)与归并排序相结合。
在 Rust 中实现简单的归并排序,再次是一个很好的使用递归的地方。首先,评估序列的左半部分,然后是右半部分,然后才开始合并,首先是通过比较两个已排序的结果(左和右)并从任一边选择元素。一旦这些运行用完了元素,剩下的就简单地附加,因为元素显然更大。这个结果被返回给调用者,重复在更高层次上的合并,直到达到原始调用者。
下面是典型归并排序实现的 Rust 代码:
pub fn merge_sort<T: PartialOrd + Clone + Debug>(collection: &[T]) -> Vec<T> {
if collection.len() > 1 {
let (l, r) = collection.split_at(collection.len() / 2);
let sorted_l = merge_sort(l);
let sorted_r = merge_sort(r);
let mut result: Vec<T> = collection.into();
let (mut i, mut j) = (0, 0);
let mut k = 0;
while i < sorted_l.len() && j < sorted_r.len() {
if sorted_l[i] <= sorted_r[j] {
result[k] = sorted_l[i].clone();
i += 1;
} else {
result[k] = sorted_r[j].clone();
j += 1;
}
k += 1;
}
while i < sorted_l.len() {
result[k] = sorted_l[i].clone();
k += 1;
i += 1;
}
while j < sorted_r.len() {
result[k] = sorted_r[j].clone();
k += 1;
j += 1;
}
result
} else {
collection.to_vec()
}
}
这种行为也有回报,创建了一个准线性的运行时间复杂度,如下面的图表所示:

Quicksort 升序、降序和随机输出图比较
另一个分而治之类型的算法是快速排序。由于多种原因,这是一种非常有趣的排序列表的方法。
快速排序
这种算法在最佳情况下显著优于归并排序,并迅速被采纳为 Unix 的默认排序算法,以及 Java 的参考实现。通过使用与归并排序类似的策略,快速排序实现了更快的平均和最佳速度。不幸的是,最坏情况下的复杂度与冒泡排序一样:O(n²)。为什么会这样?你可能会问。
快速排序在全集的部分上操作,有时是递归的,并交换元素以建立顺序。因此,关键问题变成了:我们如何选择这些部分?这个选择部分被称为分区方案,通常包括交换,而不仅仅是选择一个分割索引。选择是通过选择一个枢轴元素来进行的,其值是所有内容与之比较的。
小于枢轴值的所有内容都移到一边,而大于的内容则移到另一边——通过交换。一旦算法检测到一边是升序(另一边是降序),就可以在两个序列相交的地方进行分割。然后,整个过程重新开始,每个分区都从新开始。
下面的插图显示了基于先前示例集合的元素选择和排序。虽然在这个例子中,分区只有一个长度与剩余部分相比,但如果这些是更长的序列,同样的过程也会适用:

这里使用的分区方案被称为霍尔方案,以 1959 年发明快速排序的发明者,安东尼·霍尔爵士命名。还有其他方案(Lomuto 似乎是最受欢迎的替代方案)可能通过权衡其他方面(如内存效率或交换次数)来提供更好的性能。无论分区方案如何,选择枢轴值在性能中也起着重要作用,它产生的部分越均匀(如中位数),值就越好。潜在策略包括以下:
-
选择中位数
-
选择算术平均值
-
选择一个元素(随机、第一个或最后一个,如这里所示)
在 Rust 代码中,快速排序通过三个函数实现:
-
公共 API 以提供可用的接口
-
一个包装的递归函数,它接受一个低索引和高索引来对中间部分进行排序
-
实现霍尔划分方案的划分函数
由于它操作的是最初提供的同一向量,根据它们的索引交换元素,因此这个实现可以被认为是就地操作。以下是代码:
fn partition<T: PartialOrd + Clone + Debug>(
collection: &mut [T],
low: usize,
high: usize,
) -> usize {
let pivot = collection[high].clone();
let (mut i, mut j) = (low as i64 - 1, high as i64 + 1);
loop {
'lower: loop {
i += 1;
if i > j || collection[i as usize] >= pivot {
break 'lower;
}
}
'upper: loop {
j -= 1;
if i > j || collection[j as usize] <= pivot {
break 'upper;
}
}
if i > j {
return j as usize;
}
collection.swap(i as usize, j as usize);
}
}
fn quick_sort_r<T: PartialOrd + Clone + Debug>(collection: &mut [T], low: usize, high: usize) {
if low < high {
let pivot = partition(collection, low, high);
quick_sort_r(collection, low, pivot);
quick_sort_r(collection, pivot + 1, high);
}
}
pub fn quick_sort<T: PartialOrd + Clone + Debug>(collection: &[T]) -> Vec<T> {
let mut result = collection.to_vec();
quick_sort_r(&mut result, 0, collection.len() - 1);
result
}
在这个实现中,另一个新的方面是使用循环标签,这有助于提高结构和可读性。这是由于霍尔使用了 do-until 类型的循环,这种语法在 Rust 中不可用,但算法需要避免无限循环。
break/continue指令是臭名昭著的 goto 指令的亲戚,因此它们应该仅在使用时谨慎且非常小心,以增加可读性。循环标签提供了一种实现这一点的工具。它们允许读者跟踪确切是哪个循环正在退出或继续。语法略微借鉴了生命周期的语法:'mylabel: loop { break 'mylabel; }。
快速排序的性能特征确实很有趣。自其发明以来,罕见的 worst case 行为或O(n²)触发了数十年的优化,其中最新的是 2009 年的 Dual-Pivot Quicksort,它已被 Oracle 的 Java 7 库采用。有关更详细的解释,请参阅进一步阅读部分。
在原始数据集上运行快速排序,最坏情况和最佳情况行为明显可见。在降序和(令人好奇的)升序数据集上的性能明显是O(n²),而随机数组则被快速处理:

快速排序在升序、降序和随机排序数组之间的输出图比较
这种行为说明了快速排序的强项,这些强项更符合“现实世界”类型的场景,其中最坏的情况很少出现。然而,在当前各种编程语言的库中,排序是以混合方式进行的,这意味着这些通用算法根据它们的优点被使用。这种方法被称为Introsort(来自自省排序),在 C++的std::sort中,它依赖于快速排序直到某个点。然而,Rust 的标准库使用 Timsort。
摘要
将事物排序是一个非常基本的问题,它以许多不同的方式得到解决,这些方式在诸如最坏情况运行时间复杂度、所需内存、相等元素的相对顺序(稳定性)以及整体策略等方面有所不同。本章介绍了一些基本方法。
冒泡排序是实施起来最简单的算法之一,但它有很高的运行时成本,最坏情况下的行为为O(n²)。这是因为它简单地根据嵌套循环交换元素,使得元素“冒泡”到集合的任一端。
希尔排序可以看作是冒泡排序的改进版本,有一个主要优点:它不是从交换相邻元素开始。相反,有一个间隔,元素在这个间隔内进行比较和交换,覆盖了更长的距离。这个间隔大小随着每一轮的进行而改变,从原始方案的O(n²)最坏情况运行时复杂度到最快变体的O(n log n)。实际上,一些经验推导出的间隔的运行时复杂度甚至无法可靠地测量!
堆排序利用数据结构的属性来创建一个有序集合。正如之前所提到的,堆在其根节点保留最大(或最小)元素,并在每次pop()时返回它。因此,堆排序简单地将整个集合插入堆中,然后按顺序逐个检索。这导致运行时复杂度为O(n log n)。
基于树的策略也存在于归并排序中,这是一种分而治之的方法。该算法递归地将集合分成一半以排序子集,然后再处理整个集合。这项工作是在从递归调用返回时进行的,当结果子集需要合并时。因此得名。通常,这将导致运行时复杂度为O(n log n)。
快速排序也使用分而治之的方法,但不是每次都简单地将集合分成两半,而是使用一个枢轴值,在查看每个子集合之前,将其他值与枢轴值交换。这导致最坏情况下的行为为O(n²),但快速排序通常因其频繁的平均复杂度为O(n log n)而被使用。
现在,标准库使用混合方法,如 Timsort、Introsort 或模式击败的快速排序,以获得最佳绝对和相对运行时性能。Rust 的标准库提供基于归并排序的稳定排序函数(slice::sort()与slice::sort_unstable()),以及基于模式击败的快速排序的不稳定排序函数。
本章旨在成为下一章的基础,下一章将介绍如何找到特定元素,这通常需要有序集合!
问题
-
为什么排序是编程的一个重要方面?
-
在冒泡排序中是什么使得值向上冒泡?
-
为什么希尔排序是有用的?
-
堆排序在其最佳情况下能否优于冒泡排序?
-
归并排序和快速排序有什么共同之处?
-
混合排序算法是什么?
进一步阅读
这里有一些额外的参考资料,您可能需要参考本章所涵盖的内容:
-
双轴快速排序 (
web.archive.org/web/20151002230717/http://iaroslavski.narod.ru/quicksort/DualPivotQuicksort.pdf) -
C++ 排序解释 (
zhuanlan.zhihu.com/p/27633895) -
维基百科上的 Introsort (
zh.wikipedia.org/wiki/Introsort) -
维基百科上的 Timsort (
zh.wikipedia.org/wiki/Timsort) -
打败快速排序的模式 (
github.com/orlp/pdqsort)
第十章:寻找东西
搜索“某物”的问题始终与您正在搜索的空间直接相关。您肯定经历过在家里找钥匙的经历:搜索空间包含从前一天穿过的夹克到钥匙可能滑入的袜子抽屉中的任何东西。在找到物品(以及花费大量时间上楼下楼和在不同房间中搜索之后),您会发誓将来要使东西更整齐……
我们遇到的这个问题比我们愿意承认的要多,但它说明了我们可以通过算法解决的一个基本问题,而不需要任何特定的顺序来构建。在本章中,我们将探讨如何做到以下几点:
-
在混乱的无序数组中寻找项目
-
在准备和搜索之间做出权衡
寻找最佳
搜索域存在于多个抽象级别:在文本中找到一个词通常比简单地调用contains()函数要复杂,如果有多个结果,哪个是您要找的?这一类问题都归纳在信息检索的范畴下,其中通过排名、索引、理解、存储和搜索等问题来解决,以检索最佳结果(对于所有定义)。本章仅关注后者,即我们实际上查看一系列项目(例如,索引)以找到匹配项。
这意味着我们将直接比较项目(a == b)以确定接近度,而不是使用诸如距离或局部敏感哈希函数之类的工具。这些可以在更具体的领域找到,如模糊搜索或匹配文本体,这是一个独立的领域。要了解更多关于哈希的信息,请参阅第六章探索映射和集合或本章的进一步阅读部分。
从最简单的实现开始,让我们看看线性搜索。
线性搜索
线性搜索是一个听起来很高级的名字,我们在几乎每一个程序和日常生活中都会这样做:遍历一系列项目以找到第一个匹配项。不需要任何预处理或类似步骤;集合可以原样使用,这意味着标准库通常已经提供了通用的实现。在 Rust 的情况下,迭代器特质通过名为position()(或rposition())、find()、filter()或甚至any()的函数提供了这个功能。fold()也可以用来找到你要找的东西。以下是一个流程图的示例:

然而,从根本上说,这是一个遍历每个项目的循环,该循环要么退出,要么收集所有与谓词(一个评估函数,它接受一个类型的项目并返回一个布尔值)匹配的项目:
pub fn linear_search<T: Eq + Clone>(haystack: &[T], needle: &T) -> Option<usize> {
for (i, h) in haystack.iter().enumerate() {
if h.eq(needle) {
return Some(i);
}
}
None
}
这个算法显然表现出 O(n) 的时间复杂度,随着集合大小的增长而增长。迭代 10,000 个项目会花费一些时间,即使谓词执行得很快,那么这种策略应该如何改进呢?
跳跃查找
线性地逐个遍历一个集合只有在接近潜在匹配项时才是有效的,但很难确定——“接近匹配”是什么意思?在无序集合中,这确实是不可能知道的,因为任何项目都可以跟随。因此,先对集合进行排序怎么样?如第九章排序事物中讨论的那样,排序在准线性时间复杂度下可以比遍历超过一定大小的长集合中的每个项目要快得多。
跳跃查找利用了对它跳过的范围的了解,就像跳表一样:

在排序后,搜索可以显著更快,并且一旦算法接近匹配项,可以跳过一定数量的元素以线性方式搜索。每次跳跃可以跳过多少个元素?这是一件需要测试的事情,但首先这里是完成这项工作的代码:
pub fn jump_search<T: Eq + PartialOrd + Clone>(
haystack: &[T],
needle: &T,
jump_size: usize,
) -> Option<usize> {
if jump_size < haystack.len() {
let mut i = 0;
while i < haystack.len() - 1 {
if i + jump_size < haystack.len() {
i += jump_size
} else {
i = haystack.len() - 1;
}
if &haystack[i] == needle {
return Some(i);
} else if &haystack[i] > needle {
return linear_search(&haystack[
(i - jump_size)..i], needle);
}
}
}
None
}
API 预期一个预排序的切片,这意味着严格来说,排序不是算法运行时间的一部分。没有排序,时间复杂度可能大约是 O(n / k + k),其中 k 是步长,在最坏的情况下可以减少到 O(n)。
包括排序机制,排序算法将轻松超越搜索的时间复杂度,将其提高到 O(n log n)。虽然跳跃的选择可以显著提高这种搜索算法的绝对运行时间,但它不会像树结构那样表现良好。然而,作为策略,二分查找做得很好。
二分查找
二叉树通过从集合中创建分支来大大减少比较操作的次数,就像二叉树一样。这会即时创建一个树,从而实现更好的搜索性能。其重要性在于可预测性,这允许我们构建树,并为算法可以期望的结果分支提供选项。
二分查找,就像跳跃查找一样,需要输入的切片是有序的才能工作。然后算法将数组分成两半,并选择最有可能包含该元素的一侧。一旦有两个集合,其行为与二叉树遍历非常相似,如下所示:

再次强调,由于排序的努力超过了算法的时间复杂度,因此将考虑排序算法的结果:O(n log n)。然而,我们也应该对实际性能感兴趣,如果集合已经排序;它将显著降低!首先,让我们看看一些代码,以便更容易理解:
pub fn binary_search<T: Eq + PartialOrd>(
haystack: &[T],
needle: &T,
) -> Option<usize> {
let (mut left, mut right) = (0, haystack.len() - 1);
while left <= right {
let pivot = left + (right - left) / 2;
if needle < &haystack[pivot] {
right = pivot - 1;
} else if needle > &haystack[pivot] {
left = pivot + 1;
} else {
return Some(pivot); // lucky find
}
}
None
}
虽然算法的递归实现也可以工作,尽管它并不显著更短,但它包含了栈溢出的风险,因此采用了迭代方法。
在选择枢轴(中心)元素后,算法必须通过以下三种情况之一来确定下一次迭代的集合:
-
包含较小值的左侧部分
-
包含较大值的右侧部分
-
完全不是;枢轴元素也是结果
这种树状行为允许运行时间复杂度达到O(log n),因为搜索的项目数量会一直减半,直到找到所需的元素。然而,这一切是如何比较的呢?
总结
这三种方法有所不同,二分搜索是当前最先进的算法类型。实际上,它可以用于任何已排序的 Rust 切片(当然,如果它们已排序的话),并用于查找所需的内容。
比较这些算法比较棘手:线性搜索在无序数据集上效果很好,如果排序不是可选项,那么它是唯一可以搜索这些数据集的方法。如果排序是可选项,那么二分搜索会快得多(asc是排序方向:升序):
test tests::bench_binary_search_10k_asc ... bench: 80 ns/iter (+/- 32)
test tests::bench_binary_search_1k_asc ... bench: 63 ns/iter (+/- 17)
test tests::bench_binary_search_5k_asc ... bench: 86 ns/iter (+/- 28)
test tests::bench_jump_search_10k_asc ... bench: 707 ns/iter (+/- 160)
test tests::bench_jump_search_1k_asc ... bench: 92 ns/iter (+/- 10)
test tests::bench_jump_search_5k_asc ... bench: 355 ns/iter (+/- 46)
test tests::bench_linear_search_10k_asc ... bench: 2,046 ns/iter (+/- 352)
test tests::bench_linear_search_1k_asc ... bench: 218 ns/iter (+/- 22)
test tests::bench_linear_search_5k_asc ... bench: 1,076 ns/iter (+/- 527)
test tests::bench_std_binary_search_10k_asc ... bench: 93 ns/iter (+/- 10)
test tests::bench_std_binary_search_1k_asc ... bench: 62 ns/iter (+/- 7)
test tests::bench_std_binary_search_5k_asc ... bench: 89 ns/iter (+/- 27)
当绘制时,差异明显可见,线性搜索显示出其线性特征。将绝对运行时间排除在外将显示运行时间复杂度,如下面的图表所示:

此图表显示了每种算法的相对行为,以显示其运行时间复杂度:二分搜索为O(log n),线性搜索为O(n),以及跳转搜索,由于参数选择(跳转大小是数组长度的三分之一),几乎呈线性:

这就是全部内容——搜索算法的简要介绍。通常,这更多是关于数据,并且事先有一些排序方法可以创建一个快速找到所需项的强大机会。
概述
搜索作为信息检索(以及其他)过程中的一个基本方法,是一种独立于所使用的数据结构来查找东西的方式。有三种流行的算法类型:线性搜索、跳转搜索和二分搜索。在关于映射和集合的早期章节中已经讨论了完全不同的方法(如局部敏感哈希),但它们仍然需要一个快速比较的机制。
线性搜索是最简单的方法:遍历一个集合,并将元素与要查找的元素进行比较。这也在 Rust 的迭代器中得到了实现,并表现出O(n)的运行时间复杂度。
跳转搜索更优越。通过在已排序的集合上操作,它们可以使用大于 1 的步长(就像线性搜索一样)来更快地跳转到所需部分,通过检查相关部分是否已经通过。虽然绝对速度更快,但最坏情况下的运行时间复杂度仍然是O(n)。
(在撰写本文时)最快的方法是二分查找,它也作用于有序集合,并反复将所需部分一分为二,采用树状策略进行操作。实际上,该算法本身的运行时间复杂度也是 O(log n)。
在下一章中,我们将探讨一些更奇特算法:回溯、随机数生成等!
问题
-
什么是信息检索?
-
现代搜索引擎和数据库是否使用简单的搜索算法?
-
为什么线性搜索的运行时间复杂度是 O(n)?
-
跳查比线性查找做得更好是什么?
-
什么是二分查找,为什么它与树形结构相似?
进一步阅读
这里有一些额外的参考资料,您可能需要参考本章所涵盖的内容:www.aaai.org/ocs/index.php/AAAI/AAAI14/paper/view/8357/8643。
第十一章:随机和组合
虽然排序和搜索是计算机科学中两个非常基本的问题,但它们远非唯一。实际上,这些问题已经被那些深入研究这些领域的人彻底解决了。在当今世界,解决现实世界问题的解决方案更有可能涉及生成随机数、几个项目的最佳组合(组合数学)、将几个时间段“合并”成单个数字,以及可视化结果。随机数生成算法和高效解决组合问题已经成为非常重要的。特别是对于后者,实现将特定于解决方案,但有一些基本方法仍然存在。在本章中,我们将讨论这些基本方法之一,并了解以下内容:
-
实现回溯算法
-
利用动态规划技术
-
伪随机数生成器的工作原理
伪随机数
在过去几年里,随机数生成在受欢迎程度上有了显著的提升,然而许多开发者只是简单地接受他们所使用的技术提供的生成器。然而,好的随机数对于许多应用至关重要,如加密和安全(或者缺乏安全;参见 2010 年索尼 PlayStation 3 安全事件,该事件引发了一个著名的 XKCD——xkcd.com/221/),模拟、游戏、统计学和生物学。
基本原则是:序列越随机越好。原因很明显。如果一个随机数序列中的任何数字在统计上依赖于另一个数字,它就变成了可以预测的模式,而可预测的随机性是不存在的。因此,随机序列中的数字在统计上必须是独立的,才能被认为是好的随机数。
要获取这些随机数,可以使用伪随机数生成器或真正的随机数生成器(或者你可以买一本书——www.rand.org/pubs/monograph_reports/MR1418.html)。由于计算机是确定性机器,没有外部影响的情况下,后者是不可能的,这也是为什么实际上已经有过(失败的)尝试去实现真正的随机数。另一方面,伪随机数生成器(PRNGs)是确定性的,但开始时使用相当随机的输入(鼠标指针移动、网络流量等),并定期基于这个种子产生数字。
PRNGs 还享有速度优势(因为不需要物理交互,例如测量大气噪声),并且输出对于许多应用来说通常足够好。事实上,如果种子非常接近随机,PRNGs 可以做得很好,这在现代密码学中可以见到。
有许多机构正在研究 PRNG 及其在生成加密保存的随机数方面的有效性,例如,德国的 BSI 提供了一份深入的分析论文(bit.ly/2AOIcBl)。这是一个与 IT 安全密切相关且非常有趣的话题。然而,对于非安全研究人员来说,有一种简单的方法可以一眼评估随机数生成器的质量:视觉检查。在随机决定是否在散点图中绘制每个单独的像素时,不应该有任何可见的模式。
下面的图表是 Python 的 numpy.random 随机数生成器,它是为了从相同的种子提供相同的数字而创建的:

对于统计工作和一些模拟来说,它已经足够好了,但不应该依赖于它来进行加密工作。
无论工作类型如何,一个糟糕的随机生成器永远不会像这样:

如模式所示,在这个随机生成器中可以找到系统错误!遗憾的是,这并不罕见,即使在像 Windows 上的 PHP 这样的广泛使用的技术中也是如此(boallen.com/random-numbers.html)。
多亏了种子,PRNG 可以创建可重复以及接近随机的数字,这对于模拟或简单地为了数据科学目的抽取随机样本来说非常有用。一个非常古老且经过充分研究的方法是线性同余生成器,或LCG。
LCG
LCG 是生成伪随机数序列的最古老方法之一。它遵循一个简单、递归的公式:

X 表示随机数(或者更准确地说,序列中的 n^(th) 随机数)。它是基于其前驱数乘以一个因子 a,并偏移一个常数 c。模运算符确保没有溢出。第一个 X 是什么?种子!因此,随机数序列将从种子开始,如果需要的话,提供确定性。
这些参数设置需要经过显著的测试;实际上,许多库和编译器开发者有不同的设置。维基百科页面提供了一个概述(en.wikipedia.org/wiki/Linear_congruential_generator):
pub struct LCG {
xn: f32,
m: f32,
c: f32,
a: f32,
}
impl LCG {
fn seeded(seed: u32) -> LCG {
LCG {
xn: seed as f32,
m: 2e31,
a: 171f32,
c: 8f32,
}
}
fn new(seed: f32, m: f32, a: f32, c: f32) -> LCG {
LCG {
xn: seed,
m: m,
a: a,
c: c,
}
}
fn next_f32(&mut self) -> f32 {
self.xn = (self.a * self.xn + self.c) % self.m;
self.xn / self.m
}
}
这个参数设置虽然随机选择,但看起来并不糟糕:

之前作为糟糕示例生成的位图也使用了 LCG,但使用了另一个随机参数设置:
impl LCG {
fn seeded(seed: u32) -> LCG {
LCG {
xn: seed as f32,
m: 181f32,
a: 167f32,
c: 0f32,
}
}
...
}
由于结果显然很糟糕,这表明参数在这里是多么重要。通常,这些不是你应该调整的设置(或者你会知道它们)。同样,两位科学家提出了一组特定的“魔法数字”,这允许更好的随机数生成器:Wichmann-Hill PRNG。
Wichmann-Hill
当 Brian Wichmann 和 David Hill 发明他们的随机数生成器时,他们采取了一种扩展的 LCG 方法。它基于 LCG,但使用三个经过修改并按(魔法)素数组合的 LCG。
这些数字相加后,产生一个长度为 6,953,607,871,644(或 6.95 * 10¹²)的序列,这意味着在调用这个数量的 PRNG 之后,它将重新开始:
const S1_MOD: f32 = 30269f32;
const S2_MOD: f32 = 30307f32;
const S3_MOD: f32 = 30323f32;
pub struct WichmannHillRng {
s1: f32,
s2: f32,
s3: f32,
}
impl WichmannHillRng {
fn new(s1: f32, s2: f32, s3: f32) -> WichmannHillRng {
WichmannHillRng {
s1: s1,
s2: s2,
s3: s3,
}
}
pub fn seeded(seed: u32) -> WichmannHillRng {
let t = seed;
let s1 = (t % 29999) as f32;
let s2 = (t % 29347) as f32;
let s3 = (t % 29097) as f32;
WichmannHillRng::new(s1, s2, s3)
}
pub fn next_f32(&mut self) -> f32 {
self.s1 = (171f32 * self.s1) % S1_MOD;
self.s2 = (172f32 * self.s2) % S2_MOD;
self.s3 = (170f32 * self.s3) % S3_MOD;
(self.s1 / S1_MOD + self.s2 / S2_MOD + self.s3 / S3_MOD) % 1f32
}
}
生成器表现良好,正如视觉检查所显示的。事实上,Wichmann-Hill 生成器在过去被用于各种技术和应用中,所以这并不令人惊讶。
这里是视觉分析:

显然,为每个随机生成器的变体实现都是不高效的。幸运的是,在 crates.io/ 上有一个出色的 crate 叫做 rand。
rand crate
当谈到随机数生成器时,有一个非常出色的 crate 不能跳过:rand。由于 Rust 的标准库不包含随机函数,这个 crate 提供了那个,还有更多。
特别是,rand crate 提供了多种实现,从常规的 PRNG 到操作系统数字生成器的接口(在类 Unix 系统上是 /dev/random),还包括适用于其他目标(如 WebAssembly)的兼容接口!
这些特性在本章中难以描述,因此更多关于这些特性的信息可以在它们自己的书中找到(rust-random.github.io/book/)。
从后往前
有一些问题人类比计算机更容易解决。这些问题通常具有某种空间性质(例如,旅行商问题、背包问题),并且依赖于模式,这两者都是人类擅长的领域。这类问题的另一个名称是优化问题,其解决方案旨在最小化或最大化某个特定方面(例如,最小距离或最大值)。这个类别的子集是约束满足问题,其中解决方案必须符合一组规则,同时最小化或最大化另一个属性。
创建这些解决方案所使用的暴力方法是算法类别中的回溯法,其中许多小的选择通过递归组合在一起形成一个解决方案。从根本上说,这种寻找最优解的搜索可以运行以找到所有可能的组合(穷举搜索)或提前停止。为什么是递归?什么让它比常规循环更适合?
典型的约束满足问题需要逐步向现有物品集合中添加物品,然后评估它们的质量。回溯算法就是这样,一旦它早期遇到一个坏解决方案,它就可以回溯,以便在最佳可能的时间跳过。在讨论例子时,这一点会更加清晰,所以这里有可以通过常规回溯算法解决的两个著名问题:0-1 背包问题和 N 皇后问题。
打包背包或 0-1 背包问题
背包问题非常现实:每次你只带随身行李乘坐廉价航空公司时,事情就会变得复杂。我真的需要这个吗?我可以在家留下我的数码单反相机,用手机拍照,对吧?
这些是表达物品潜在价值和关于其重量(或这些航班上的体积)的考虑的陈述,我们通常希望带上最有价值的(对我们来说)物品出行。虽然这听起来像是一个算法问题,但它远非简单。让我们从目标开始:
给定 n 个物品(具有重量和价值),找到提供最高价值且不超过背包容量 W 的物品子集。
从这个基础上,构建解决方案的方式可以如下构建:作为一个穷举搜索算法,每个可能的解决方案都可以是最佳解决方案。然而,这只有在所有解决方案都被评估之后才会变得清晰。因此,让我们首先生成所有可能的解决方案,然后再考虑最佳方案。
对于任何递归场景,首先关注退出条件非常重要:递归何时停止以及它将返回什么?在背包问题的情况下,停止条件是围绕当前重量构建的:
-
重量超过容量
-
当前重量已达到容量
-
没有剩余的物品
如果容量已经超过,算法返回数据类型的最小值,并在该执行分支上进行“回溯”。然而,如果重量正好等于容量,或者没有剩余物品,则返回一个中性值。
那么返回值表示什么?它是物品的总价值,由于这是一个寻找最大价值的过程,所以比较两种可能性的返回值:
-
包括物品
-
排除物品
因此,我们将取递归调用有或没有当前物品的返回值的最大值,从而排除任何超过提供的容量组合:
pub trait Backtracking {
fn fill(&self, items: Vec<&Item>) -> u64;
fn fill_r(&self, remaining: &[&Item], current_weight: usize) -> i64;
}
关于架构的说明:由于这个例子将使用动态规划(参考以下代码)进行改进,因此一个很好的结构方法是创建并实现一个针对这两种技术的特性:
#[derive(Debug, PartialEq)]
pub struct Item {
pub weight: u32,
pub value: u32,
}
pub struct Knapsack {
capacity: usize,
}
impl Knapsack {
pub fn new(capacity: usize) -> Knapsack {
Knapsack { capacity: capacity }
}
}
impl Backtracking for Knapsack {
fn fill(&self, items: Vec<&Item>) -> u64 {
let value = self.fill_r(&items, 0);
if value < 0 {
0
} else {
value as u64
}
}
fn fill_r(&self, remaining: &[&Item], current_weight: usize)
-> i64 {
let w = current_weight;
if w > self.capacity {
return i64::min_value();
}
if remaining.len() > 0 && w < self.capacity {
let include = remaining[0].value as i64
+ self.fill_r(&remaining[1..], current_weight
+ remaining[0].weight as usize);
let exclude = self.fill_r(&remaining[1..], current_weight);
if include >= exclude {
include
} else {
exclude
}
} else {
0
}
}
}
关于此算法的运行时间复杂度的问题仍然存在——这次并不那么明确。有些人建议它是O(2^n),但有两个主要增长因素:容量以及可用物品的数量。在这本书中,图表将关注要添加到包中的物品数量,这涉及到(伪)多项式复杂度(大于O(n²))。无论如何,你应该知道这是一个使用回溯法解决的高成本问题。
在大学中,回溯法的另一个流行例子是 8 后问题(或者,在其一般形式中,N 后问题)。
N 后问题
N 后棋问题(8 后问题/谜题的推广版本)定义如下:
在一个 N×N 的棋盘上,放置 N 个王棋,使得它们不能相互攻击。
作为第一步,理解棋盘中王棋的移动方式非常重要,幸运的是,这很简单:王棋可以直线向上、向下、向左、向右以及斜线移动,如下面的图示所示:

了解这一点后,其余部分与前面的背包问题非常相似,但由各种放置选项引起的可能性更多。有几种策略可以应对这种情况:
-
单独对每个单元格进行操作,这会导致大量的递归调用迅速发生
-
单独对每一行(或列)进行操作,并对单元格进行迭代
后者显然是首选方法,因为 10×10 的棋盘会导致每个单独的单元格有 100 次递归调用(包括分配等),因此很快就会导致栈溢出。因此,第二种选择(按行)是最佳权衡,因为每一行/列至少要放置一个王棋,并且排除了其他王棋的放置:
pub struct ChessBoard {
board: Vec<Vec<bool>>,
n: usize,
}
impl ChessBoard {
pub fn new(n: usize) -> ChessBoard {
ChessBoard {
n: n,
board: vec![vec![false; n]; n],
}
}
pub fn place_queens(&mut self) -> bool {
self.place_queens_r(0)
}
pub fn place_queens_r(&mut self, column: usize) -> bool {
if column < self.n {
for r in 0..self.n {
if self.is_valid(r, column) {
self.board[r][column] = true;
if self.place_queens_r(column + 1) {
return true;
}
self.board[r][column] = false;
}
}
false
}
else {
true
}
}
fn is_valid(&self, row: usize, col: usize) -> bool {
for i in 0..self.n {
if self.board[i][col] {
return false;
}
if self.board[row][i] {
return false;
}
}
let mut i = 0;
let (mut left_lower, mut left_upper, mut right_lower,
mut right_upper) =
(true, true, true, true);
while left_lower || left_upper || right_lower || right_upper {
if left_upper && self.board[row - i][col - i] {
return false;
}
if left_lower && self.board[row + i][col - i] {
return false;
}
if right_lower && self.board[row + i][col + i] {
return false;
}
if right_upper && self.board[row - i][col + i] {
return false;
}
i += 1;
left_upper = row as i64 - i as i64 >= 0
&& col as i64 - i as i64 >= 0;
left_lower = row + i < self.n && col as i64 - i
as i64 >= 0;
right_lower = row + i < self.n && col + i < self.n;
right_upper = row as i64 - i as i64 >= 0
&& col + i < self.n;
}
true
}
// ...
}
策略很简单:对于每一行的每个单元格,检查在当前条件下是否可以放置一个有效的王棋。然后,深入递归,一旦找到有效的设置就结束。结果如下(n = 4):

然而,此算法的计算复杂度呈指数增长(O(2^n)),这意味着对于大的n,它将不会在任何合理的时间内完成:

N 后问题的输出图
尽管这个特定的问题可能更像是一个教学问题,但这种方法肯定可以应用于其他(类似)用例,尤其是在空间域中。
高级问题解决
回溯算法计算并找到特定问题的最佳整体解决方案。然而,如第八章中所述,算法评估,有些问题具有非常大的计算复杂度,这导致运行时间非常长。由于这不太可能通过仅仅提高计算机的速度和智能来解决,因此需要更智能的方法。
在多种策略和技术可供选择的情况下,选择一种最适合解决你问题的方法是你的选择。由于 Rust 在速度和内存效率方面的优势,它在这一领域的位置可能至关重要,因此关注复杂问题的解决方案可能在将来(在作者看来)会得到回报。
首先是一种旨在提高回溯算法复杂度的令人惊讶的编程技术:动态规划。
动态规划
动态规划的概念是这些你本以为有不同名称的技术之一:缓存。基本思想是将相关的临时结果保存到缓存中,并使用这个预先计算的结果,而不是反复重新计算!
这意味着必须检查问题和潜在解决方案,以找到相关的子问题,因此任何结果都可以被缓存。这种方法的优点是它找到了可能的全局最佳解决方案,但代价是可能的高运行时间复杂度。
背包问题改进
例如,让我们检查背包求解器的递归调用。为了简洁起见,这个背包将使用三个物品的列表来填充,每个物品的重量均匀为 1,容量为 2。由于回溯算法按顺序遍历物品列表(并尝试包含或排除特定物品),背包求解器可以看作是一个函数 K,它将任何剩余的物品以及剩余容量映射到特定的值:

因此,在相同级别上,相同的输入参数导致相同的值,这很容易缓存。在先前的图中,被矩形标记的节点至少被计算了两次。这个例子是从 GeeksforGeeks 的文章中摘取的(www.geeksforgeeks.org/0-1-knapsack-problem-dp-10/),关于 0-1 背包问题。
在做任何事情之前,我们现在可以给回溯算法实现一个不同的特性:
pub trait DynamicProgramming {
fn fill(&self, items: Vec<&Item>) -> u64;
}
然后进行实现,作为一个有两个输入参数的函数,每个输入参数的组合都可以保存在一个二维数组中,这降低了运行时间复杂度到遍历这个矩阵,导致 O(n * W) 的运行时间复杂度:
impl DynamicProgramming for Knapsack {
fn fill(&self, items: Vec<&Item>) -> u64 {
let mut cache = vec![vec![0u64; self.capacity + 1];
items.len() + 1];
for i in 1..items.len() + 1 {
for w in 1..self.capacity + 1 {
if items[i -1].weight as usize <= w {
let prev_weight =
w - (items[i - 1].weight as usize);
cache[i][w] = max(
items[i - 1].value as u64
+ cache[i - 1][prev_weight],
cache[i - 1][w],
);
} else {
cache[i][w] = cache[i - 1][w]
}
}
}
cache[items.len()][self.capacity]
}
}
代码从递归调用链转变为构建一个矩阵,其中特定组合的最大值只是一个查找,这极大地提高了绝对和相对运行时间(使用回溯法时,20 个物品需要 41,902 +/- 10,014 纳秒,而动态规划则需要 607 +/- 138 纳秒):

背包问题的输出图
相对而言,运行时间复杂度有了显著提升:

动态规划和回溯法的运行时间复杂度对比图
将这种策略(或类似策略)应用于允许这种优化的问题,可以允许更高的输入参数,因此能够解决现实世界问题!想象一下,一家航空公司试图计算出它能携带的最有价值货物,但它一次只能限制在 40 种不同的物品中。
由于存在许多更难的问题(例如,一个称为 NP 难问题的问题类),人们想出了找到良好解决方案的方法。
元启发式方法
动态规划非常适合约束满足问题。然而,通过类似系统猜测或元启发式方法可以找到更好的解决方案。这些与问题无关的解决方案生成器可以根据多种方式分类,例如,它们是否基于群体、是否受自然界启发,以及是在全局还是局部搜索。
无论选择哪种优化算法,它都会将问题视为搜索问题,试图在提供的解决方案中找到最佳可能的解决方案。在没有找到最佳解决方案的保证的情况下,它通常会找到一个足够好的解决方案。多亏了 NP 难问题的昂贵运行时间,有各种各样的方法可以找到比更具体解决方案更好的解决方案。
流行的元启发式方法包括以下几种:
-
模拟退火
-
遗传算法
-
粒子群优化
-
蚂蚁群优化
-
避免搜索
Rust 的生态系统具有几个实现这些元启发式策略的 crate。一些这些 crate 的进展可以在www.arewelearningyet.com/metaheuristics/上追踪。
例子——遗传算法
例如,旅行商问题,其中需要找到连接n个城市的最短路径的旅行。其运行时间复杂度为O(n!),只有 20 个城市在计算上非常昂贵,但可以通过从城市的随机顺序(旅行)开始,然后反复重新组合或随机改变(变异)这些旅行中的几个,只选择最好的,并使用这些旅行重新开始过程来解决足够好。
使用rsgeneticcrate (crates.io/crates/rsgenetic),实现解决方案变成实现TspTourtrait 的问题,这需要提供一个fitness()函数以便评估解决方案,一个crossover()函数将两个父代重新组合成新的后代旅行,以及一个mutate()函数对旅行应用随机变化:
impl Phenotype<TourFitness> for TspTour {
///
/// The Euclidean distance of an entire tour.
///
fn fitness(&self) -> TourFitness {
let tour_cities: Vec<&City> = self.tour.iter().map(|t|
&self.cities[*t]).collect();
let mut fitness = 0f32;
for i in 1..tour_cities.len() {
fitness += distance(tour_cities[i], tour_cities[i - 1]);
}
-(fitness.round() as i32)
}
///
/// Implements the crossover for a TSP tour using PMX
///
fn crossover(&self, other: &TspTour) -> TspTour {
// ...
TspTour {
tour: offspring,
cities: self.cities.clone(),
rng_cell: self.rng_cell.clone(),
}
}
///
/// Mutates the solution by swapping neighbors at a chance
///
fn mutate(&self) -> TspTour {
let mut rng = self.rng_cell.borrow_mut();
if rng.gen::<f32>() < MUTPROB {
let mut mutated: Tour = self.tour.clone();
for i in 0..mutated.len() {
if rng.gen::<f32>() < INDPB {
let mut swap_idx = rng.gen_range(0,
mutated.len() - 2);
if swap_idx >= i {
swap_idx += 1;
}
let tmp = mutated[i];
mutated[i] = mutated[swap_idx];
mutated[swap_idx] = tmp;
}
}
TspTour {
tour: mutated,
cities: self.cities.clone(),
rng_cell: self.rng_cell.clone(),
}
} else {
self.clone()
}
}
}
一旦实现了这些,框架允许你设置一个选择器来选择每一代中最佳的n个解决方案以创建下一代的人口。这些步骤会重复进行,直到适应度值停滞(收敛),并且最后一代的最高适应度可以被认为是该问题的良好解决方案。
经过几代之后,可以找到这样的解决方案:

在我的博客blog.x5ff.xyz中可以找到更深入地探讨在 JavaScript 以及 Rust(和 Wasm)中解决这个问题的方法。可以采取类似的方法来安排背包中高度有价值物品的组合,这留给你去发现。
摘要
除了常规数据结构、排序以及搜索方法之外,还有许多其他问题出现。本章讨论了这些问题的一个小子集:生成随机数和解决约束满足问题。
随机数生成在许多方面都很有用:加密、游戏、赌博、模拟、数据科学—all 都需要好的随机数。好的?有两种重要的类型:伪随机数和“真实”随机数。后者的来源必须是物理世界(计算机是确定性的),前者可以用 LCG 或 Wichmann-Hill 生成器(使用魔法数组合 LCG)来实现。
约束满足问题是寻找符合一组约束条件最佳组合的问题。一种称为回溯的技术通过递归生成所有组合来构建当前排列的状态,但会回溯那些不满足所需约束的组合。8 皇后(或 N 皇后)问题和 0-1 背包问题都是展示昂贵运行时行为的回溯算法示例。
高级技术,如动态规划或元启发式(返回足够好的解决方案)可以显著提高解决这些挑战的速度(或对于更大的规模)。作为一个快速且高效的编程语言,Rust 在未来可以在这些技术中发挥重要作用。
在下一章中,我们将探讨 Rust 标准库提供的算法。
问题
-
PRNGs 和 RNGs 之间的区别是什么?
-
哪个 crate 在 Rust 中提供随机数生成器?
-
如何使用回溯法解决组合问题?
-
什么是动态规划?
-
元启发式是如何成为解决难题的无特定问题方法的?
进一步阅读
这里有一些额外的参考资料,您可能需要参考以了解本章所涵盖的内容:
第十二章:标准库的算法
Rust 的标准库提供了一些基本的数据类型,这些类型覆盖了许多项目的基本需求,通常情况下,如果已经有了适当的数据结构,就无需实现自己的算法。如果由于某种原因,数据类型并不完全适合任务,标准库也为你提供了相应的解决方案。在这个快速总结中,你可以期待了解以下内容:
-
slice原始类型 -
Iterator特质 -
binary_search() -
sort()、稳定和不稳定的排序
切片和迭代
类似于其他语言的库中接口标准化对功能访问的方式,Rust 的标准库通过类型和特质来提供基本实现。特质 Iterator<T> 在本书的多个地方被提及和使用。然而,切片类型并没有被大量显式使用,特别是当 Vec<T> 被借用来进行函数调用时,Rust 编译器会自动使用切片。那么,如何利用这种类型呢?我们已经看到了 Iterator<T> 的实现,但它提供了更多吗?
迭代器
回顾:迭代器是一种遍历集合的模式,在遍历过程中提供对每个元素的指针。这种模式在 1994 年由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(四人帮)所著的《设计模式》一书中被提及,并且几乎在每种语言中以某种方式存在。
在 Rust 中,每个元素的指针术语获得了一个新的维度:它是借用的还是拥有的项?它是否也可以被可变借用?
使用标准库的 Iterator<T> 特质非常有意义,因为它提供了一系列有用的函数,这些函数都基于单一的 next() 实现。
next() 返回一个 Option<Self::Item>,这是在实现特质时必须声明的关联类型——它可以是你想要的任何类型!
因此,使用 &MyType、&mut MyType 和 MyType 可以分别实现以实现所需的功能。IntoIter<T> 是一个特质,它专门设计用来简化这种工作流程,并与 for 循环语法完美集成。以下代码来自 Rust 标准库的源代码:
impl<T> IntoIterator for Vec<T> {
type Item = T;
type IntoIter = IntoIter<T>;
/// Creates a consuming iterator, that is,
/// one that moves each value out of
/// the vector (from start to end).
/// The vector cannot be used after calling
/// this.
///
/// # Examples
///
/// ```
/// let v = vec!["a".to_string(), "b".to_string()];
/// for s in v.into_iter() {
/// // s has type String, not &String
/// println!("{}", s);
/// }
/// ```rs
#[inline]
fn into_iter(mut self) -> IntoIter<T> {
unsafe {
let begin = self.as_mut_ptr();
assume(!begin.is_null());
let end = if mem::size_of::<T>() == 0 {
arith_offset(begin as *const i8, self.len()
as isize) as *const T
} else {
begin.add(self.len()) as *const T
};
let cap = self.buf.cap();
mem::forget(self);
IntoIter {
buf: NonNull::new_unchecked(begin),
phantom: PhantomData,
cap,
ptr: begin,
end,
}
}
}
}
Rust 的 Vec<T> 正确实现了这种模式,但有一个巧妙的转折。前面的代码消耗了原始数据结构,可能将其转换成更容易迭代的格式,就像树可以被展开成排序后的 Vec<T> 或栈。回到原来的主题,Iterator<T> 提供了函数(在进一步的结构中实现),这些函数增加了许多在集合中进行搜索和过滤的可能方式。
任何 Rust 用户都会意识到 Vec<T> 的 iter() 函数,然而,实际上这是由 Vec 隐式转换为切片类型提供的?
切片
切片是序列的视图,以提供更统一的接口来访问、迭代或以其他方式与这些内存区域交互。因此,它们可以通过 Vec<T> 获取,特别是由于它们实现了 Deref 特性,可以隐式地将 Vec<T> 作为 [T]——T 的切片来处理。
Vec<T> 的实现也暗示了不可变和可变引用的 IntoIterator 实现:
impl<'a, T> IntoIterator for &'a Vec<T> {
type Item = &'a T;
type IntoIter = slice::Iter<'a, T>;
fn into_iter(self) -> slice::Iter<'a, T> {
self.iter()
}
}
impl<'a, T> IntoIterator for &'a mut Vec<T> {
type Item = &'a mut T;
type IntoIter = slice::IterMut<'a, T>;
fn into_iter(self) -> slice::IterMut<'a, T> {
self.iter_mut()
}
}
切片本身只是一个视图,由指向内存部分的指针及其长度表示。由于编译器知道其中包含的数据的性质,它也可以确定单个元素以提供类型安全。
切片及其工作方式的更详细解释可能需要一本书来专门介绍,因此建议至少阅读 slice 模块的文档(或源代码)(doc.rust-lang.org/std/slice/index.html).
搜索
在本书中已经讨论了如何在集合中查找事物,Rust 标准库默认提供了一些方法。这些函数附加到 Iterator<T> 特性或切片类型上,并且无论实际类型如何,只要提供了比较两个元素的功能,它们就可以工作。
这可以是 Ord 特性或自定义比较函数,例如 Iterator<T> 上的 position() 函数。
线性查找
经典的线性查找是通过 Iterator<T> 特性上的 position()(或 rposition())提供的,它甚至利用了在特性本身上实现的其它迭代器函数:
fn position<P>(&mut self, mut predicate: P) -> Option<usize> where
Self: Sized,
P: FnMut(Self::Item) -> bool,
{
// The addition might panic on overflow
self.try_fold(0, move |i, x| {
if predicate(x) { LoopState::Break(i) }
else { LoopState::Continue(i + 1) }
}).break_value()
}
try_fold() 是 fold()(或 reduce(),遵循 map/reduce 模式)函数的短路变体,它在返回 LoopState::Break 时随时返回。对 break_value() 的调用将 LoopState::Break 枚举中返回的值转换为 Option 和 None,如果它运行了整个集合。
这是搜索的暴力方法,如果集合未排序且较短,可能很有用。对于任何更长的情况,排序和使用二分搜索函数可能更有利。
二分查找
切片还提供了一个通用的快速搜索函数,称为 binary_search()。如第十章[32002bad-c2bb-46e9-918d-12d7dabfe579.xhtml]“查找事物”中讨论的,二分搜索通过反复选择一半来逼近元素的位置,返回元素的索引。
要实现这一点,输入切片必须满足两个先决条件:
-
它是有序的
-
元素类型实现了
Ord特性
binary_search()无法检查提供的集合是否已排序,这意味着如果无序集合返回预期的结果,这只能是一种巧合。此外,如果有多个具有相同值的元素,任何这些都可以是结果。
除了使用隐式提供的比较函数(通过实现Ord),binary_search()还有一个更灵活的兄弟函数—binary_search_by(),它需要一个提供的比较函数。
在底层,这个函数与我们在第十章中创建的朴素实现 Chapter 10,Finding Stuff相似;有时,它甚至快上几纳秒。然而,代码同样简单:
pub fn binary_search_by<'a, F>(&'a self, mut f: F) -> Result<usize, usize>
where F: FnMut(&'a T) -> Ordering
{
let s = self;
let mut size = s.len();
if size == 0 {
return Err(0);
}
let mut base = 0usize;
while size > 1 {
let half = size / 2;
let mid = base + half;
// mid is always in [0, size),
// that means mid is >= 0 and < size.
// mid >= 0: by definition
// mid < size: mid = size / 2 + size / 4 + size / 8 ...
let cmp = f(unsafe { s.get_unchecked(mid) });
base = if cmp == Greater { base } else { mid };
size -= half;
}
// base is always in [0, size) because base <= mid.
let cmp = f(unsafe { s.get_unchecked(base) });
if cmp == Equal { Ok(base) } else {
Err(base + (cmp == Less) as usize) }
}
该函数的其他变体包括通过键或通过Ord特质的比较函数进行搜索(如前所述)。一个主要的注意事项是需要向二分搜索函数提供一个已排序的集合,但幸运的是,Rust 在其标准库中提供了排序功能。
排序
排序是用户界面中的一个重要功能,同时也为许多算法提供了必要的可预测性。每当没有适当的数据结构(如树)可以使用时,一个通用的排序算法可以负责创建这种顺序。关于相等值的一个重要问题随之而来:它们是否每次都会出现在同一个确切的位置?当使用稳定排序算法时,答案是是的。
稳定排序
稳定排序的关键不是重新排列相等元素,所以在[1, 1, 2, 3, 4, 5]中,1s 相对于彼此的位置永远不会改变。在 Rust 中,当在Vec<T>上调用sort()时,实际上就是这样使用的。
当前(2018 版)Vec<T>的实现使用基于 Timsort 的归并排序变体。以下是源代码:
pub fn sort(&mut self)
where T: Ord
{
merge_sort(self, |a, b| a.lt(b));
}
代码相当冗长,但可以分成更小的部分。第一步是对较小的(20 个元素或更少)切片进行排序,通过删除并按顺序重新插入元素来实现(换句话说,插入排序):
fn merge_sort<T, F>(v: &mut [T], mut is_less: F)
where F: FnMut(&T, &T) -> bool
{
// Slices of up to this length get sorted using insertion sort.
const MAX_INSERTION: usize = 20;
// Very short runs are extended using insertion sort
// to span at least this many elements.
const MIN_RUN: usize = 10;
// Sorting has no meaningful behavior on zero-sized types.
if size_of::<T>() == 0 {
return;
}
let len = v.len();
// Short arrays get sorted in-place via insertion
// sort to avoid allocations.
if len <= MAX_INSERTION {
if len >= 2 {
for i in (0..len-1).rev() {
insert_head(&mut v[i..], &mut is_less);
}
}
return;
}
如果集合更长,算法将回溯遍历项目,识别自然运行。常量MIN_RUN(前述代码中的10)定义了此类运行的最小长度,因此较短的运行(例如[5, 9, 10, 11, 13, 19, 31, 55, 56]中的5, 9, 10, 11, 13, 19, 31, 55, 56)通过在 1 上执行插入排序以获得 10 个元素来扩展。然后,结果块(对于[1, 5, 9, 10, 11, 13, 19, 31, 55, 56],它将从0开始,长度为 10)将被推送到栈上以供后续合并(注意:我们建议阅读代码作者的注释):
// Allocate a buffer to use as scratch memory.
// We keep the length 0 so we can keep in it
// shallow copies of the contents of `v` without risking the dtors
// running on copies if `is_less` panics.
// When merging two sorted runs, this buffer holds a copy of the
// shorter run, which will always have length at most `len / 2`.
let mut buf = Vec::with_capacity(len / 2);
// In order to identify natural runs in `v`, we traverse it
// backwards. That might seem like a strange decision, but consider
// the fact that merges more often go in the opposite direction
// (forwards). According to benchmarks, merging forwards is
// slightly faster than merging backwards. To conclude, identifying
// runs by traversing backwards improves performance.
let mut runs = vec![];
let mut end = len;
while end > 0 {
// Find the next natural run,
// and reverse it if it's strictly descending.
let mut start = end - 1;
if start > 0 {
start -= 1;
unsafe {
if is_less(v.get_unchecked(start + 1),
v.get_unchecked(start)) {
while start > 0 && is_less(v.get_unchecked(start),
v.get_unchecked(start - 1)) {
start -= 1;
}
v[start..end].reverse();
} else {
while start > 0 && !is_less(v.get_unchecked(start),
v.get_unchecked(start - 1)) {
start -= 1;
}
}
}
}
// Insert some more elements into the run if it's too short.
// Insertion sort is faster than
// merge sort on short sequences,
// so this significantly improves performance.
while start > 0 && end - start < MIN_RUN {
start -= 1;
insert_head(&mut v[start..end], &mut is_less);
}
// Push this run onto the stack.
runs.push(Run {
start,
len: end - start,
});
end = start;
为了结束迭代,栈上的一些对已经合并,在插入排序中折叠它们:
while let Some(r) = collapse(&runs) {
let left = runs[r + 1];
let right = runs[r];
unsafe {
merge(&mut v[left.start .. right.start + right.len],
left.len, buf.as_mut_ptr(), &mut is_less);
}
runs[r] = Run {
start: left.start,
len: left.len + right.len,
};
runs.remove(r + 1);
}
}
这个collapse循环确保栈上只留下一个项目,即已排序的序列。找出要折叠的运行是 Timsort 的关键部分,因为合并只是简单地使用插入排序来完成。折叠函数检查两个基本条件:
-
运行的长度按降序排列(栈顶持有最长的运行)
-
每个生成的运行的长度大于下一个两个运行的长度之和
考虑到这一点,让我们看看折叠函数:
// [...]
fn collapse(runs: &[Run]) -> Option<usize> {
let n = runs.len();
if n >= 2 && (runs[n - 1].start == 0 ||
runs[n - 2].len <= runs[n - 1].len ||
(n >= 3 && runs[n - 3].len <=
runs[n - 2].len + runs[n - 1].len) ||
(n >= 4 && runs[n - 4].len <=
runs[n - 3].len + runs[n - 2].len)) {
if n >= 3 && runs[n - 3].len < runs[n - 1].len {
Some(n - 3)
} else {
Some(n - 2)
}
} else {
None
}
}
// [...]
}
它返回要与其后续元素合并的运行索引(r和r + 1;有关更多信息,请参阅collapse循环)。如果最顶部的运行(在最高索引处)不是从开始处开始的,折叠函数会检查前四个运行以满足上述条件。如果是,则几乎到达了终点,需要进行合并,无论违反了哪些条件,从而确保最终要合并的序列是最后一个。
Timsort 结合插入排序和归并排序,使其成为一个真正快速且高效的排序算法,它也是稳定的,并且通过构建这些自然发生的运行来在“块”上操作。另一方面,不稳定的排序使用熟悉的快速排序。
不稳定的排序
不稳定的排序不会保留相等值的相对位置,因此由于不需要稳定排序所需额外分配的内存,可以因此实现更快的速度。切片的sort_unstable()函数使用了一种由 Orson Peters 称为模式破坏快速排序的快速排序变体,结合堆排序和快速排序,在大多数情况下实现出色的性能。
切片实现简单地将其称为快速排序:
pub fn sort_unstable_by<F>(&mut self, mut compare: F)
where F: FnMut(&T, &T) -> Ordering
{
sort::quicksort(self, |a, b| compare(a, b) == Ordering::Less);
}
观察快速排序的实现,它跨越了整个模块——大约 700 行代码。因此,让我们看看最高级函数以了解基础知识;好奇的读者应该深入研究源代码(doc.rust-lang.org/src/core/slice/sort.rs.html)以了解更多信息。
快速排序函数执行一些初步检查以排除无效情况:
/// Sorts `v` using pattern-defeating quicksort, which is `O(n log n)` worst-case.
pub fn quicksort<T, F>(v: &mut [T], mut is_less: F)
where F: FnMut(&T, &T) -> bool
{
// Sorting has no meaningful behavior on zero-sized types.
if mem::size_of::<T>() == 0 {
return;
}
// Limit the number of imbalanced
// partitions to `floor(log2(len)) + 1`.
let limit = mem::size_of::<usize>() * 8 - v.len()
.leading_zeros() as usize;
recurse(v, &mut is_less, None, limit);
}
recurse函数是这个实现的核心,甚至是一个递归函数:
/// Sorts `v` recursively.
///
/// If the slice had a predecessor in the original array,
/// it is specified as `pred`.
///
/// `limit` is the number of allowed imbalanced partitions
/// before switching to `heapsort`. If zero,
/// this function will immediately switch to heapsort.
fn recurse<'a, T, F>(mut v: &'a mut [T], is_less: &mut F, mut pred: Option<&'a T>, mut limit: usize)
where F: FnMut(&T, &T) -> bool
{
// Slices of up to this length get sorted using insertion sort.
const MAX_INSERTION: usize = 20;
// True if the last partitioning was reasonably balanced.
let mut was_balanced = true;
// True if the last partitioning didn't shuffle elements
// (the slice was already partitioned).
let mut was_partitioned = true;
loop {
let len = v.len();
// Very short slices get sorted using insertion sort.
if len <= MAX_INSERTION {
insertion_sort(v, is_less);
return;
}
// If too many bad pivot choices were made,
// simply fall back to heapsort in order to
// guarantee `O(n log n)` worst-case.
if limit == 0 {
heapsort(v, is_less);
return;
}
// If the last partitioning was imbalanced,
// try breaking patterns in the slice by shuffling
// some elements around.
// Hopefully we'll choose a better pivot this time.
if !was_balanced {
break_patterns(v);
limit -= 1;
}
// Choose a pivot and try guessing
// whether the slice is already sorted.
let (pivot, likely_sorted) = choose_pivot(v, is_less);
// If the last partitioning was decently balanced
// and didn't shuffle elements, and if pivot
// selection predicts the slice is likely already sorted...
if was_balanced && was_partitioned && likely_sorted {
// Try identifying several out-of-order elements
// and shifting them to correct
// positions. If the slice ends up being completely sorted,
// we're done.
if partial_insertion_sort(v, is_less) {
return;
}
}
// If the chosen pivot is equal to the predecessor,
// then it's the smallest element in the
// slice. Partition the slice into elements equal to and
// elements greater than the pivot.
// This case is usually hit when the slice contains many
// duplicate elements.
if let Some(p) = pred {
if !is_less(p, &v[pivot]) {
let mid = partition_equal(v, pivot, is_less);
// Continue sorting elements greater than the pivot.
v = &mut {v}[mid..];
continue;
}
}
// Partition the slice.
let (mid, was_p) = partition(v, pivot, is_less);
was_balanced = cmp::min(mid, len - mid) >= len / 8;
was_partitioned = was_p;
// Split the slice into `left`, `pivot`, and `right`.
let (left, right) = {v}.split_at_mut(mid);
let (pivot, right) = right.split_at_mut(1);
let pivot = &pivot[0];
// Recurse into the shorter side only in order to
// minimize the total number of recursive
// calls and consume less stack space.
// Then just continue with the longer side (this is
// akin to tail recursion).
if left.len() < right.len() {
recurse(left, is_less, pred, limit);
v = right;
pred = Some(pivot);
} else {
recurse(right, is_less, Some(pivot), limit);
v = left;
}
}
}
幸运的是,标准库的源代码中有许多有用的注释。因此,强烈建议阅读前面片段中的所有注释。简而言之,算法做出了很多猜测以避免对枢轴做出糟糕的选择。如果你还记得,当快速排序选择一个糟糕的枢轴元素时,它将分裂成不均匀的分区,从而产生非常糟糕的运行时间行为。因此,选择一个好的枢轴是至关重要的,这就是为什么围绕这个过程有那么多启发式方法被采用,如果所有其他方法都失败了,算法将运行堆排序以确保至少有O(n log n)的运行时间复杂度。
摘要
Rust 的标准库包括对基本事物如排序或搜索其原始切片类型和Iterator<T>特质的好几种实现。特别是切片类型提供了许多非常重要的函数。
binary_search()是针对切片类型提供的二分搜索概念的泛型实现。Vec<T>可以快速且容易(隐式地)转换为切片,这使得这个函数在所有情况下都可用。然而,它要求切片中存在排序顺序才能工作(如果不存在则不会失败),并且如果使用自定义类型,则需要实现Ord特质。
如果切片不能事先排序,Iterator<T>变量的position()(find()的)实现提供了一个基本的线性搜索,返回元素的第一个位置。
排序由一个泛型函数提供,但有两种风味:稳定和不稳定。常规的sort()函数使用一个称为 Timsort 的归并排序变体,以实现高效且稳定的排序性能。
sort_unstable()利用一种模式破坏的快速排序来巧妙地结合堆排序和快速排序的效率,这通常会导致比sort()更好的绝对运行时间。
这是本书的最后一章,如果你已经读到这儿,你终于应该得到一些答案了!你可以在评估部分找到所有问题的答案。
问题
-
Rust 在集合上的泛型算法实现在哪里?
-
何时线性搜索比二分搜索更好?
-
可能的面试问题: 稳定和不稳定排序算法是什么?
-
快速排序表现出的哪种不良行为被模式破坏的快速排序缓解了?
进一步阅读
这里有一些额外的参考资料,你可以参考有关本章所涵盖内容的材料:
-
设计模式,由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 著
-
维基百科上的迭代器模式(
en.wikipedia.org/wiki/Iterator_pattern) -
OpenJDK 的 java.utils.Collection.sort()存在缺陷:好的、坏的和最坏的情况,由 de Gow 等人撰写(
envisage-project.eu/wp-content/uploads/2015/02/sorting.pdf) -
模式破坏的快速排序(
envisage-project.eu/wp-content/uploads/2015/02/sorting.pdf)
第十三章:评估
第一章
什么是特性,它们与接口有何不同?
特性是跨组件共享的功能块。它们可以包含代码以及关联类型,并且可以为任何类型和泛型独立实现。另一方面,接口描述了一个类提供的公共方法,没有实现,通常带有继承。Rust 只有特性。
为什么 Rust 没有垃圾回收器?
垃圾回收是释放由程序运行生成的未使用堆内存所必需的。Rust 通过在编译时提供静态代码分析来避免这一点,这迫使用户思考变量的生命周期。这些生命周期定义得非常严格,并且需要生命周期作用域来拥有或借用内存,以便编译器知道何时没有使用到它们,而不需要显式声明。
请列举 Rust 中创建生命周期(显式和隐式)的三个例子
隐式地!
你能想到的任何三个都是很好的,但这里是我的:函数、作用域(只需使用 {} 创建一个),以及闭包(lambda 函数)。
为什么变量的不可变性很重要?
它保证只进行读取操作,从而避免任何副作用。
同步标记特性有什么作用?
它将一个结构标记为可以从多个线程安全访问。
你可以在哪里参与 Rust 社区?
前往 github.com/rust-lang(打开问题、提交代码、讨论等)或 www.rust-lang.org/community,那里保存了所有当前社区资源(如论坛和聊天)。
为什么 RFC 比 PR 更受欢迎?
要向 Rust 编程语言、cargo 或 crates.io 贡献更改,传统的“分叉-更改-PR(pull request)”方法不会工作(特别是如果存在重大更改)。RFC 是对这三个项目中的任何一个进行重大更改所需的正式流程,并允许更广泛的社区讨论和评估所提出的更改,以及为它们做出贡献。这是 Rust 社区为有效地管理像编程语言这样基本的东西所做的努力。
第二章
cargo 做什么?
对仓库的读写访问、运行测试、依赖管理(下载、更新和管理依赖树)、执行构建过程,以及提供用于额外工具的中心接口。
cargo 提供了 linting 支持吗?
cargo 本身不做,但有额外的工具,如 clippy (github.com/rust-lang/rust-clippy),它们可以无缝地与 cargo 一起工作。
在哪些情况下,Cargo.lock 文件对发布很重要?
对于库。该文件由 cargo 用于确定依赖树的精确版本。因此,不应由于意外更新依赖项而产生任何版本问题。
发布到 crates.io 需要满足哪些要求?
通过测试,仓库中没有未提交的文件,拥有有效的账户,以及crates.io上的可用位置。
Wasm 是什么?为什么你应该关心它?
Wasm 是一个编译目标,可以在传统的 JavaScript 环境中执行,例如浏览器或 Node 运行时。这跳过了 JavaScript 及其垃圾回收所需的编译步骤,因此 Wasm 二进制文件更适合具有浏览器 UI 组件的(近)实时应用程序。它们可以在 JavaScript 世界中简单地运行。
Rust 项目中测试是如何组织的?
测试可以添加到模块中的每个文件,通过#[tests]和#[test]以及#[bench]进行注释。这些也可以放置在组件目录下的test/中的自己的文件结构中。此外,Rust 支持 doctests,当 docstring(///)包含包含代码的示例部分时,它们会被执行。
第三章
Sized 类型与其他类型有何不同?
大小意味着类型实例的大小在运行时已知,因此它不包含增长的数据类型。例如,str通常不是一个大小类型——String是。
Clone 与 Copy 有何区别?
Clone 是对clone()函数的显式调用;复制是隐式的,例如在赋值时发生。由于 Clone 是显式调用的,它通常在底层数据结构上执行深拷贝。
不可变数据结构的主要缺点是什么?
不可变数据结构可能具有较差的绝对性能,因为它们不能使用常规数据结构提供的优化。此外,对包含的数据的更新是不可能的,这使得它对于不断变化的数据是一个非常低效的选择。
应用程序如何从不可变数据结构中受益?
它们隐式地跟踪更改,并且在没有副作用或锁定需求的情况下跨线程工作得很好。
考虑一个你想要工作的不可变列表——你将如何将其 **分配到多个线程中?
根据任务,它可以分成n个块,其中n是线程的数量。然而,这需要你创建列表的n个副本——或者至少每个副本至少移动一次。或者,列表可以跨线程访问,只提供索引来表示要工作的块。
第四章
为什么在 Rust 中实现链表很棘手?
Rust 的所有权原则使得实现非层次结构,如双链表变得困难。在那里,不清楚哪个节点拥有内存的哪个区域,因为邻居都持有无法无效的引用。
Rust 的标准库 LinkedList
它是一个双链表:单个节点相互连接,就像本章中的实现一样。
双链表与跳表有什么区别?
跳表有多个层级,节点通过链接在一起以实现类似树状搜索的性能。因此,跳表必须是有序的,并存储多个指向后继和前驱的指针。双链表只有两个链接(前向和后向),不需要排序,并且在最佳情况下实现线性搜索性能。
动态数组在元素访问方面是否优于跳表?
是的,如果跳表不使用动态数组作为基础的话!
为什么动态数组是 CPU 缓存的绝佳选择?
数据存储在内存的一个大连续部分中,元素一个接一个地存储。缓存始终建立在内存块上,这就是为什么缓存几个可能依次处理的元素使动态数组非常适合这一点。
动态数组还有另一种增长策略吗?
内存可以加倍,每次增加一定量,或者以对数方式增长,这样在开始时增长快,后来变慢。
Rust 认真对待数组,那么动态数组内部使用什么?
它使用一个带框的切片。
第五章
二叉搜索树在搜索时如何跳过多个节点?
通过跟随一个分支,每次做出一个分支的决定时,它就会跳过一个子树。子树可以是单个节点,也可以是除了一个节点之外的所有节点。
什么是自平衡树?
使用某种逻辑来(大致)平衡每个子树中节点数量的树。这确保了所有树算法都以最佳可能的效率工作。
为什么树中的平衡很重要?
如果树是倾斜的,任何在其上操作的算法都会遇到根据它操作的子树而不同量的工作。这种不匹配是假设树的每个分支都会导致相同量的工作(例如,进行相同数量的比较),这就是为什么树数据结构是高效的。
堆是不是二叉树?
是的。每个节点有两个子节点。
tries 有哪些好的应用场景?
这里是我的:一个 trie 集合是保证唯一性的非常高效的数据结构,有基于 tries 的序列预测方法,它们可以进行无损数据压缩。
什么是 B-树?
B-树是一种具有定义级别的树,该级别与每个节点中的子节点数量相关。因此,它是所有树的自我平衡泛化:一个级别 2 的 B-树类似于二叉树,但更多的子节点会使数据结构更高效,并避免不必要的层数。
图的基本组成部分是什么?
图是通过边连接的节点。这些节点通常具有值;边上的值被称为权重。在一般图中,边没有方向,但进一步的约束可以使它有向、无环或有限制。图是所有列表和树的超结构。
第六章
什么使一个好的哈希函数?
这取决于用例。密码学应最小化冲突,消息摘要应最大化在微小输入差异上的哈希差异,而布隆过滤器应做相反的事情。
如何估计哈希函数对特定任务的适用性?
通过使用图表和测试来了解输出哈希的分布情况以及是否是你所寻找的。直方图和散点图可以很好地显示值的分布。此外,在网上搜索潜在的漏洞或弱点以及原始论文。
校验和哈希在其他方面有用吗?
它们还可以用来确定两个文本或文件是否相等,这可以用于快速查找匹配项或检查内容是否是传输的内容,或者内容是否已被篡改。
实现映射有两种方式吗?
使用树或使用哈希。
什么是桶?
桶是映射到底层数据结构的哈希值。哈希可能输出u64,但Vec<T>的长度只有 100。因此,多个哈希共享Vec<T>中的一个索引,这被称为桶。
集合能否替代列表?
只有当唯一性是内容所需约束时。
什么使集合有用?
快速且专业的集合操作,例如并集、交集、差集和快速的“包含”查找,以及以更好的效率保证唯一性的能力。
第七章
这里没有讨论哪个 std::collections 数据结构?
BinaryHeap (doc.rust-lang.org/std/collections/struct.BinaryHeap.html).
截至 2018 年,Vec
当需要更多空间时,它们会加倍(或更多)其大小。
LinkedList
不。它不提供索引访问,并且由于内部内存结构,通常比Vec<T>慢,但提供相同的基本功能。
2018 年的 HashMap
SipHashing。还有其他一些正在进入标准库,例如hashbrown crate (github.com/Amanieu/hashbrown)。
BTreeMap
使用任意三个,但这里有一些建议:
-
有序键
-
较低的计算强度(不需要哈希)
-
不需要哈希函数——无论何种情况都有良好的性能
BTreeMap
更宽,因为有更多的子节点(最多2级 - 1),这有助于高效的 CPU 缓存。
第八章
为什么估计运行时复杂度要超过诸如语句数量之类的因素?
运行时间复杂度更多地关注与主要输入参数并行的预期增长。从某种意义上说,它确实是在计算语句的数量,你很可能会得出相同的结论。被计算出的语句是其中最重要的子集。
运行时间复杂度如何与数学函数相关?
有两种方式:数学函数可以像编程中的函数一样描述,因为它们建立在相同的基本构造上;并且数学函数用于表达运行时间复杂度本身,特别是对数和指数函数。
通常提供的复杂度类是最好或最坏的情况吗?
最坏的情况,因为这将是速度最慢/效率最低的情况。
为什么循环在估计复杂度时很重要?
循环是重复执行语句的强大构造,根据增长参数,将推动函数的运行时间复杂度。
O(n log(n))的运行时间复杂度比 O(log(n))好还是差?
O(log(n))显然是更好的运行时间复杂度。试着用你选择的三个数字替换n,并计算log(n)与n * log(n)。
一些常见的已知复杂度类有哪些?
O(n), O(log(n), O(n²), 和 O(2^n)。
第九章
什么是信息检索?
所有围绕存储、搜索、排名、标记化、分析和对信息结构的一般理解的相关学科。这是一切好的搜索引擎都能做得很好的事情。
现代搜索引擎和数据库是否使用简单的搜索算法?
是的。无论在搜索索引之上采用何种抽象,标记的存储通常是以线性、追加的方式进行的,这允许在这些段上进行高效的搜索(二分搜索)。
为什么线性搜索有 O(n)的运行时间复杂度?
如果序列中不存在元素,它必须遍历所有n个元素以确保。
跳跃查找比线性查找做得更好是什么?
它跳过了列表的一部分,因为在有序列表中,可以根据排序规则排除某些位置。因此,它显著减少了线性搜索中要搜索的元素数量。
什么是二分查找以及为什么它和树形结构相似?
二分搜索将输入序列分成两半,并且只继续包含该元素的半个部分。将这些部分可视化(包括那些被跳过的部分)看起来就像一棵二叉树,这就是为什么这两个部分实际上是分支一样。
第十章
为什么排序是编程中的重要方面?
建立可预测的顺序,以便算法可以根据内容(例如,搜索)做出假设,这将使其性能大大提高。另一个重要方面是用户界面中的用户体验,或者建立数据点之间的语义链接(例如,时间序列现在可以具有趋势)。
冒泡排序中是什么使得值向上冒泡?
通过在遍历序列时反复交换一对元素,属于对立端(或接近它)的元素将不得不与其他每个元素交换位置。因此,较大的数字“冒泡”到上面。
为什么希尔排序是有用的?
它实现了稳定的排序性能,但不如归并排序复杂,使用的计算资源也更少。这使得它在硬件可能成为瓶颈(嵌入式设备)或没有其他排序方法可用(例如,如果标准库不受支持)的情况下非常出色。
堆排序在其最佳情况下能否优于冒泡排序?
不。冒泡排序的最佳情况仅仅是遍历列表 – O(n)。另一方面,堆排序始终需要构建一个堆,无论序列是否已经排序 – O(n log n)。
归并排序和快速排序有什么共同点?
分而治之的方法:两者都将序列分割成更小的部分,以便它们可以分别处理。
什么是混合排序算法?
混合排序算法使用至少两种不同方法的优势。例如,Timsort 使用插入排序处理较小的序列(例如,小于 20 个项),但对于较大的序列则使用归并排序。
第十一章
PRNGs 和 RNGs 之间的区别是什么?
伪随机数生成器(PRNGs)使用一个过程来生成尽可能统计独立的接近随机的数字序列。随机数生成器(RNGs)试图使用真正的随机性(例如,物理世界中无法预测的现象)来生成随机数。
哪个 crate 在 Rust 中提供随机数生成器?
rand 是最重要的一个。
回溯如何解决组合问题?
回溯递归地尝试可能的组合,并尽快评估它们的有效性。这允许你回溯不良的解决方案并保存好的解决方案。
什么是动态规划?
一种编程技术,通过保存和使用常见的中间解决方案来提高算法的运行时间复杂度。
元启发式是如何成为解决难题的无问题方法的?
元启发式使用普遍适用的策略来找到最佳解决方案。这些策略可以受到自然的启发(自然选择、动物行为、物理过程),并反复生成和评估参数以改进下一个解决方案。如果问题的生成和验证由用户提供,则该方法可以是无问题的,并且由于策略负责收敛到最佳解决方案,它们可以在可预测的时间内提供最佳猜测。
第十二章
Rust 在集合上的泛型算法实现在哪里?
切片原始类型。
何时线性搜索比二分搜索更好?
如果序列短且未排序——排序它所需的时间将长于简单的线性搜索。
潜在面试问题:什么是稳定排序算法和不稳定排序算法?
稳定排序算法在相等元素之间保持相对顺序,而不稳定排序算法则不这样做。这意味着如果有相同数字的序列,整个块在排序集合中会按照完全相同的顺序出现。
快速排序的哪种不良行为是模式破坏快速排序缓解的?
选择不良的枢轴是缓解的最重要问题。这是通过采用策略来改进选择,或者在所有其他方法都失败的情况下,使用堆排序以实现至少O(n log n)的运行时间复杂度(而不是快速排序的O(n²))。


浙公网安备 33010602011771号