Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第1章

翻译平台:ChatGPT3
原文书封面

前言

数据结构和算法不仅仅是抽象的概念。掌握它们可以让你编写高效的代码,从而使软件运行更快,占用更少的内存。对于如今存在于日益移动化平台并处理日益庞大数据量的软件应用来说,这非常重要。

然而,关于这些主题的大多数资源存在一个问题,它们……嗯……晦涩难懂。大多数文本都充斥着数学术语,如果你不是数学专业人士,很难理解到底在讲些什么。即使有些书声称让算法“易懂”,似乎也默认读者具有高深的数学学位。因此,许多人因感觉自己不“聪明”而避开这些概念。

然而,事实是关于数据结构和算法的一切都可以归结为常识。数学符号本身只是一种特定的语言,数学中的一切也可以用通俗的术语来解释。在这本书中,我使用通俗的语言(还有很多图表!)以简单而且,我敢说,有趣的方式来解释这些概念。

一旦你理解了这些概念,你就能够编写高效、快速、优雅的代码。你将能够权衡各种代码选择的利弊,并能够就给定情况下哪种代码最好做出明智的决策。

在这本书中,我不遗余力地用实用的想法使这些概念变得真实和实用,这些想法你今天就可以使用。当然,你在学习的过程中也会了解一些非常酷的计算机科学知识。但这本书是关于将那些看似抽象的东西变得直接实用。在读完本书后,你将能够编写更好、更快的代码和软件。

这本书适合谁呢?

这本书适合多个读者群体:
• 你是一名计算机科学专业的学生,希望有一本用简单英语解释数据结构和算法的教材。这本书可以作为你正在使用的“经典”教科书的补充。
• 你是一名初学者开发者,掌握基本编程知识,但想学习计算机科学基础知识,以编写更好的代码,提升编程知识和技能。
• 你是一名自学成才的开发者,从未系统学习过正式的计算机科学知识(或者学过但已经忘记了!),希望利用数据结构和算法的力量来编写更具扩展性和优雅性的代码。

无论你是谁,我尽力让这本书适合各种技能水平的人阅读和欣赏。

第二版的具体更新内容

为什么有第二版?自原版出版以来的几年里,我有机会向各种不同的受众教授这些主题。随着时间的推移,我不断完善解释,并发现了一些我认为令人兴奋和重要的额外主题。同时,人们对实践性练习的需求也相当大,这些练习可以帮助人们更好地掌握这些概念。

因此,第二版具有以下新特点:

  1. 修订内容。为了更清晰地解释,我对原版章节进行了重大修订。虽然我认为第一版在让这些复杂主题易于理解方面已经做得相当好,但我发现有些地方还有提升的空间。原始章节的许多部分已完全重写,并新增了全新的内容。我觉得这些修订单独就已经使这本书更加优秀,值得推出新版。

  2. 第二版新增了六个我特别感兴趣的章节。本书一直将理论与实践相结合,但我添加了更多实用的内容。《日常代码中的大 O 符号》和《代码优化技巧》这两章专注于日常代码,并阐述了数据结构和算法知识如何帮助你编写更高效的软件。

在递归方面,我下了很大功夫。虽然之前的版本有关于这个主题的章节,但我专门增加了一章《递归编程入门》,旨在教授如何编写递归代码,这对初学者可能会有些困惑。我认为这是一种独特而有价值的补充,至今我还没有在其他地方见过类似的解释。我还增加了《动态规划》这一章,这是一个热门主题,对于提高递归代码的效率至关重要。

存在许多数据结构,很难确定哪些应该包含在内,哪些应该排除。然而,对于学习堆和字典树的需求越来越高,我也发现它们非常有趣。因此,我增加了《保持堆的优先性》和《尝试字典树》这两章。

  1. 每章现在都包含了许多练习题,可以让你在书中每个主题上进行实践练习。我还增加了详细的解答,放在书后的附录中。这是一个重要的增强功能,使这本书成为更完整的学习体验。

这本书包括以下内容:

正如你所猜到的,这本书着重讨论数据结构和算法。具体来说,书的布局如下:

在《数据结构的重要性》和《算法的重要性》中,我解释了数据结构和算法的概念,并探讨了时间复杂度的概念——这用于确定算法的运行速度。在此过程中,我也详细讨论了数组、集合和二分查找等内容。

在《大 O 符号是什么》中,我揭示了大 O 符号的概念,并以易于理解的方式进行了解释。我们在全书中都会使用这种符号表示法,因此这一章非常重要。

在《用大 O 符号加速你的代码》、《使用和不使用大 O 进行代码优化》和《针对乐观情景进行优化》中,我们深入探讨了大 O 符号,并利用它来提高日常代码的运行速度。在这个过程中,我涵盖了各种排序算法,包括冒泡排序、选择排序和插入排序等。

在《日常代码中的大 O 符号》中,你将运用你学到的大 O 符号的知识来分析现实世界中代码的效率。

在《哈希表的快速查找和编写优雅代码》中,我讨论了一些额外的数据结构,包括哈希表、栈和队列。我展示了它们对我们代码的速度和优雅程度的影响,以及我们如何利用它们解决现实世界的问题。

《递归中的递归》介绍了递归,这是计算机科学领域中的一个核心概念。我们在这一章中对它进行了详细分析,看看它在某些情况下是一个很好的工具。《递归编程入门》教你如何编写递归代码,这在其他情况下可能会相当令人困惑。

《动态规划》向你展示如何优化递归代码,避免其失控。《用于速度的递归算法》展示了如何以递归作为快速算法(如快速排序和快速选择)的基础,并将你的算法开发技能提升到一个新水平。

接下来的章节,《基于节点的数据结构》、《加速所有事情》、《保持堆的优先性》、《尝试字典树》和《用图连接一切》,探讨了基于节点的数据结构,包括链表、二叉树、堆、字典树和图,并展示了它们在各种应用中的优势。

《处理空间约束》探讨了空间复杂性,这在为具有相对较小磁盘空间的设备编程或处理大数据时非常重要。

最后一章,《代码优化技巧》,向你介绍了各种优化代码效率的实用技巧,并为你提供了改进日常编写的代码的新思路。

怎么阅读本书:

你必须按顺序阅读这本书。有些书可以独立阅读每个章节,可以跳来跳去,但这本书不是那种类型的书。每一章都假设你已经读过前面的章节,并且这本书被精心构建,让你在阅读过程中逐步加深理解。

话虽如此,在书的后半部分,有些章节并不完全依赖于彼此。第xvii页的图表描述了哪些章节是其他章节的先决条件。

例如,你可以从第10章直接跳到第13章,如果你愿意的话。(哦!这个图表是基于一种叫做树的数据结构。你将在第15章学到关于它的知识。)

在这里插入图片描述

另一个重要说明:为了使这本书易于理解,我并不总是在首次介绍某个概念时透露所有内容。有时,解释复杂概念的最佳方式是逐步揭示一小部分内容,并只有在第一部分被理解后才透露下一部分内容。如果我将某个术语定义为某某某,不要将其视为该主题的全部定义,直到你完成了对该主题的整个章节的学习。

这是一种权衡:为了使书籍易于消化,我选择了最初过度简化某些概念,并随着时间的推移逐渐澄清,而不是确保每个句子都完全符合学术准确性。但不要过于担心,因为到最后,你会看到完整且准确的整体概念。

示例代码

这本书的概念不限于特定的编程语言。因此,我选择使用多种语言来演示书中的例子。这些示例使用了Ruby、Python和JavaScript编写,所以对这些语言有基本的了解会有所帮助。

尽管如此,我尽量以一种让即使你不熟悉某个示例语言也能够跟随的方式编写示例。为了帮助达到这个目的,我并不总是按照每种语言的流行习惯来编写代码,因为某些习惯用法可能会让对该语言不熟悉的人感到困惑。

我理解频繁切换语言可能会带来一定的思维切换成本。然而,我觉得保持书籍与编程语言无关是很重要的。再次强调,我努力确保所有语言的代码易于阅读和直观。

书中某些标题下会有较长的代码片段,标注为“代码实现”。我鼓励你研究这些代码示例,但并不一定需要理解每一行代码才能继续阅读下一节。如果这些长代码影响到你的阅读体验,暂时跳过或快速浏览它们即可。

最后,需要注意的是,并非每个代码片段都是“可用于生产环境”的。我主要关注的是澄清手头的概念,虽然我尽量使代码完整,但可能并未考虑到每一个边缘情况。在优化代码方面,你完全可以有更多的发挥空间!

网络资源

这本书有自己的网页,你可以在上面找到更多关于这本书的信息,并通过报告勘误、提出内容建议和指出拼写错误等方式帮助改进它。

https://pragprog.com/titles/jwdsal2

感谢

虽然写书可能看起来是一个孤独的任务,但这本书的完成离不开许多在写作过程中支持我的人。我想亲自感谢你们。

对我美好的妻子蕾娜,感谢你给予我的时间和情感支持。在我像隐士一样埋头写作的时候,你照顾了一切。对我可爱的孩子图维、蕾阿、夏亚和拉米,感谢你们在我写着关于“算法”的书时对我的耐心。是的,它终于完成了。

对我的父母霍华德和黛比·温格洛先生夫人,感谢你们最初引发了我对计算机编程的兴趣,并帮助我追求它。你们并不知道,在我九岁生日时给我找了一个电脑导师会为我的职业打下基础,现在又有了这本书。

对我妻子的父母,保罗和克莱德尔·平卡斯先生夫人,感谢你们一直支持我的家人和我。你们的智慧和温暖对我意义非凡。

当我第一次将手稿提交给Pragmatic Bookshelf时,我觉得它还不错。然而,通过那里所有出色的工作人员的专业知识、建议和要求,这本书变得比我一个人写得要好得多。对我的编辑布莱恩·麦克唐纳德,你向我展示了一本书应该是什么样的,你的见解使每一章变得更加精彩;这本书上到处都有你的印记。对最初的主编苏珊娜·普法尔泽和执行编辑戴夫·兰金,你们给了我对这本书可能性的展望,将我的理论手稿变成了一本可以应用于日常编程的书籍。

对出版商安迪·亨特和戴夫·托马斯,感谢你们相信这本书,并让Pragmatic Bookshelf成为最棒的出版公司,我很荣幸为其撰写。

对极其才华横溢的软件开发者和艺术家科琳·麦格金,感谢你将我的草稿变成了美丽的数字图像。如果没有你用技巧和细致的注意力创作的绚丽视觉效果,这本书就一无是处。

我很幸运能够得到许多专家对这本书进行审阅。你们的反馈非常有帮助,并确保了这本书尽可能准确。我要感谢你们所有人的贡献。

第一版的审阅者包括:Alessandro Bahgat、Ivo Balbaert、Alberto Boschetti、Javier Collado、Mohamed Fouad、Derek Graham、Neil Hainer、Peter Hampton、Rod Hilton、Jeff Holland、Jessica Janiuk、Aaron Kalair、Stephan Kämper、Arun S. Kumar、Sean Lindsay、Nigel Lowry、Joy McCaffrey、Daivid Morgan、Jasdeep Narang、Stephen Orr、Kenneth Parekh、Jason Pike、Sam Rose、Frank Ruiz、Brian Schau、Tibor Simic、Matteo Vaccari、Stephen Wolff和Peter W. A. Wood。

而第二版的审阅者包括:Rinaldo Bonazzo、Mike Browne、Craig Castelaz、Jacob Chae、Zulfikar Dharmawan、Ashish Dixit、Dan Dybas、Emily Ekhdal、Derek Graham、Rod Hilton、Jeff Holland、Grant Kazan、Sean Lindsay、Nigel Lowry、Dary Merckens、Kevin Mitchell、Nouran Mhmoud、Daivid Morgan、Brent Morris、Emanuele Origgi、Jason Pike、Ayon Roy、Brian Schau、Mitchell Volk和Peter W. A. Wood。

除了官方审阅者外,我还要感谢所有作为预览书籍读者的人,在我持续写作和编辑书籍的过程中提供的反馈。你们的建议、评论和问题都非常宝贵,对书籍的质量有着重要的贡献。

我也要感谢Actualize的所有员工、学生和校友,谢谢你们的支持。这本书最初是一个Actualize项目,你们在各种方式上都做出了贡献。特别感谢Luke Evans给了我写这本书的创意。

谢谢大家让这本书从概念变成了现实。

联系

我喜欢与读者建立联系,欢迎你在LinkedIn上找到我。我会很乐意接受你的连接请求,请发送一条消息说明你是这本书的读者。期待与你交流!

Jay Wengrow
jay@actualize.co
2020年5月

第1章 为什么数据结构重要

当人们刚开始学习编程时,他们的重点是——也应该是——让他们的代码能够正确运行。他们的代码是根据一个简单的度量标准衡量的:代码是否真的有效?

然而,随着软件工程师获得更多经验,他们开始了解关于代码质量的额外层面和细微差别。他们了解到可能有两段代码都完成同样的任务,但其中一段要比另一段更好。

代码质量有很多衡量标准。一个重要的衡量标准是代码可维护性。代码的可维护性涉及诸如代码的可读性、组织性和模块化等方面。

然而,高质量代码还有另一个方面,那就是代码的效率。例如,你可以有两段代码片段都实现同样的目标,但其中一段比另一段运行得更快。

看看这两个函数,它们都打印出从 2 到 100 的所有偶数:

def print_numbers_version_one():
number = 2
while number <= 100:
# If number is even, print it:
if number % 2 == 0:
print(number)
number += 1
def print_numbers_version_two():
number = 2
while number <= 100:
print(number)
# Increase number by 2, which, by definition,
# is the next even number:
number += 2

你觉得这两个函数中哪个运行得更快?

如果你说第二个版本,你是对的。这是因为第一个版本循环了 100 次,而第二个版本只循环了 50 次。因此,第一个版本的步骤是第二个版本的两倍。

这本书是关于编写高效代码的。编写运行速度快的代码是成为更优秀软件开发人员的重要方面。

编写快速代码的第一步是理解数据结构是什么,以及不同的数据结构如何影响我们代码的运行速度。所以,让我们开始吧。

数据结构

让我们谈谈数据。

数据是一个广泛的术语,指的是各种类型的信息,甚至可以是最基本的数字和字符串。在简单但经典的“Hello World!”程序中,字符串“Hello World!”就是一个数据。事实上,即使是最复杂的数据通常也可以分解为一堆数字和字符串。

数据结构指的是数据的组织方式。你将学习到同样的数据可以以多种方式进行组织。
让我们看下面的代码:

x = "Hello! "
y = "How are you "
z = "today?"
print x + y + z

这个简单的程序涉及三个数据片段,输出三个字符串以构成一个连贯的消息。如果我们要描述这个程序中数据的组织方式,我们会说我们有三个独立的字符串,每个都包含在一个单一的变量中。

然而,同样的数据也可以存储在一个数组中:

array = ["Hello! ", "How are you ", "today?"]
print(array[0] + array[1] + array[2])

在本书中,你将学到数据的组织方式并不仅仅是为了组织,而是可以显著影响代码运行的速度。根据你选择的数据组织方式,你的程序可能以数量级的快慢运行。如果你正在构建一个需要处理大量数据的程序,或者一个同时被数千人使用的网络应用,你选择的数据结构可能会影响软件是否能正常运行,或者是否因无法处理负载而崩溃。

当你对数据结构在你所创建的软件中的性能影响有了深刻的理解时,你将能够编写快速而优雅的代码,并且作为软件工程师的专业知识将得到极大增强。

在本章中,我们将开始分析两种数据结构:数组和集合。虽然这两种数据结构看起来几乎相同,但你将学到分析每种选择性能影响的工具。

数组:基础的数据结构

数组是计算机科学中最基本的数据结构之一。我假设您以前使用过数组,因此您知道数组是数据元素的列表。数组是多功能的,在许多情况下都可以作为有用的工具,但让我们看一个简单的例子。

如果您查看一个允许用户为杂货店创建和使用购物清单的应用程序的源代码,您可能会找到类似这样的代码:

array = ["apples", "bananas", "cucumbers", "dates", "elderberries"]

这个数组恰好包含五个字符串,每个字符串代表我可能在超市购买的东西。(顺便说一句,您一定要尝试一下接骨木果。)

数组有自己的技术术语:

  • 数组的大小指的是它包含的元素数量。在我们的杂货清单数组中,大小为5,因为它包含五个值。
  • 数组的索引表示数据元素在数组中所处的特定位置。
  • 在大多数编程语言中,索引从0开始计数。因此,对于我们的示例数组,“apples” 的索引是0,而 “elderberries” 的索引是4,如下所示:

在这里插入图片描述

数据结构操作

理解任何数据结构的性能,比如数组,需要分析代码与其交互的方式。这些交互通常包括四种操作:

  • 读取: 这涉及从数据结构的特定位置检索值。对于数组,类似于访问特定索引处的值。例如,从数组中检索索引为2的杂货物品。

  • 搜索: 搜索意味着在数据结构中查找特定值。在数组中,这意味着查找值是否存在,以及存在于哪个索引。例如,在我们的购物清单中查找“dates”的索引就涉及搜索数组。

  • 插入: 插入指的是将新值添加到数据结构中。对于数组,这意味着将新值放入另一个槽位。如果我们将“figs”添加到购物清单中,就是向数组中插入新值。

  • 删除: 删除涉及从数据结构中删除一个值。在数组中,这是移除其中的一个值。例如,从我们的购物清单中删除“bananas”就意味着从数组中删除这个值。

本章将深入探讨将这些操作应用于数组时的速度评估。

测量速度

我们如何衡量操作的速度呢?

如果你从本书中只学到一件事,那就是:当我们衡量一个操作的“快慢”时,我们指的不是操作的纯时间有多快,而是它需要多少步骤。

我们在前面讨论过这个概念,比如打印从2到100的偶数。第二个版本的函数更快,因为它执行的步骤是第一个版本的一半。

为什么我们用步骤来衡量代码的速度呢?
因为我们无法确定任何操作需要多长时间,比如说五秒。一段代码可能在某台计算机上运行需要五秒钟,但在旧的硬件上可能需要更长时间。同样的代码在明天的超级计算机上可能运行得更快。用时间来衡量操作的速度是不可靠的,因为时间会随着运行的硬件不同而变化。

但是,我们可以根据操作需要多少计算步骤来衡量它的速度。如果操作A需要5步,操作B需要500步,我们可以假设在任何硬件上,操作A都比操作B快。因此,衡量步骤数是分析操作速度的关键。

衡量操作速度也被称为衡量其时间复杂度。在本书中,我将使用速度、时间复杂度、效率、性能和运行时间这些术语来交替使用。它们都指的是给定操作所需的步骤数。

让我们来探讨数组的四种操作,看看每个操作需要多少步骤。

读取

第一个我们要看的操作是读取,它查找数组中特定索引处包含的值。
计算机可以在一步内从数组中读取。这是因为计算机能够直接跳转到数组中的任何特定索引并查看里面的内容。

在我们的示例中 [“apples”, “bananas”, “cucumbers”, “dates”, “elderberries”],如果我们查找索引2,计算机会立即跳转到索引2并报告其中包含值 “cucumbers”。

计算机如何能够在数组中查找索引?让我们看看。

计算机的内存可以看作是一个巨大的单元格集合。在下面的图表中,您可以看到一个单元格的网格,其中一些单元格是空的,而另一些包含数据位:

在这里插入图片描述

虽然这个图示是对计算机内存运行方式的简化,但它代表了基本概念。
当程序声明一个数组时,它会为程序分配一组连续的空单元格供使用。因此,如果你要创建一个用于存放五个元素的数组,计算机会找到一组连续的五个空单元格并将其指定为你的数组:
在这里插入图片描述

每个计算机内存中的单元格都有一个特定的地址。这有点像街道地址(例如,123 Main St.),只不过它是用一个数字表示的。**每个单元格的内存地址比前一个单元格的地址多一个数字。**下面是一个图示,显示了每个单元格的内存地址:

在这里插入图片描述

在下图中,您可以看到我们的购物清单数组以及其索引和内存地址:

在这里插入图片描述

当计算机读取数组中特定索引处的值时,它可以直接跳转到该索引,这是因为计算机具备以下关于计算机的事实的组合:

  1. 计算机可以在一步内跳转到任何内存地址。例如,如果您要求计算机检查内存地址1063处的内容,它可以在不进行任何搜索的情况下访问该地址。类比一下,如果我要求您竖起右手小指,您不需要搜索所有手指找到右手小指。您可以立即识别它。
  2. 每当计算机分配一个数组时,它也记录了数组开始的内存地址。因此,如果我们要求计算机找到数组的第一个元素,它将能够立即跳转到适当的内存地址找到它。

这些事实解释了计算机如何在单一步骤中找到数组的第一个值。然而,计算机也可以通过执行简单的加法找到任何索引处的值。如果我们要求计算机找到索引3处的值,计算机将简单地将索引0的内存地址加3。(毕竟,内存地址是顺序的。)

让我们应用到我们的购物清单数组中。我们的示例数组从内存地址1010开始。因此,如果我们告诉计算机读取索引3处的值,计算机会进行以下思考过程:

  1. 数组从索引0开始,对应内存地址为1010。
  2. 索引3将恰好在索引0的三个槽位之后。
  3. 根据逻辑推断,索引3位于内存地址1013处,因为1010 + 3等于1013。

一旦计算机知道索引3位于内存地址1013处,它可以直接跳转到该地址,并查看其中包含的值为“dates”。

因此,从数组中读取是一个高效的操作,因为计算机可以通过一步跳转到任何内存地址读取任何索引。尽管我将计算机的思考过程分解为三个部分,但我们目前专注于计算机跳转到内存地址的主要步骤。(在后续章节中,我们将探讨哪些步骤是值得关注的。)

自然地,一个只需一步就能完成的操作是最快的类型。除了作为基础数据结构外,数组也是一种非常强大的数据结构,因为我们可以以如此快的速度从中读取数据。

现在,如果我们不是问计算机索引3处包含的值是什么,而是反过来问“dates”可以在什么索引处找到?这就是搜索操作,我们将在接下来探讨。

搜索

正如我之前所述,搜索数组意味着查看特定值是否存在于数组中,如果存在,那么它位于哪个索引位置。

从某种意义上说,这是读取的反向操作。读取意味着提供计算机一个索引,并要求它返回该位置包含的值。另一方面,搜索意味着提供计算机一个值,并要求它返回该值所在位置的索引。

尽管这两种操作听起来相似,但在效率上存在天壤之别。从索引中读取数据很快,因为计算机可以立即跳转到任何索引并找到其中的值。然而,搜索却很繁琐,因为计算机无法直接跳转到特定的值。

这是关于计算机的一个重要事实:计算机可以立即访问所有的内存地址,但它并不知道每个内存地址处存储的具体数值。

让我们以前面的水果和蔬菜数组为例。计算机无法立即看到每个单元格的实际内容。对于计算机来说,数组可能看起来像这样:
在这里插入图片描述

对于数组内的水果进行搜索,计算机别无选择,只能逐个检查每个单元格。

以下的图表展示了计算机在我们的数组内搜索“dates”所使用的过程。

首先,计算机检查索引0:
在这里插入图片描述

由于索引0处的值为“apples”,并不是我们所寻找的“dates”,所以计算机会移动到下个索引
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

"哈哈!我们找到了“dates”,现在知道“dates”在索引3处。在这一点上,计算机不需要继续查找数组的下一个单元格,因为它已经找到了我们要找的东西。

在这个例子中,因为计算机需要检查四个不同的单元格,直到找到我们要找的值,我们可以说这个特定操作总共需要四个步骤。

在《为什么算法很重要》中,你会了解另一种搜索数组的方式,但这种基本的搜索操作——计算机逐个检查每个单元格——被称为线性搜索。

现在,计算机在数组上进行线性搜索需要执行的最大步骤是多少呢?

如果我们要寻找的值恰好位于数组的最后一个单元格(比如“elderberries”),那么计算机最终将在整个数组中搜索,直到找到它所寻找的值。同样,如果我们要找的值在数组中根本不存在,计算机也需要搜索每个单元格,以确保该值不存在于数组中。

因此,对于包含五个单元格的数组,线性搜索所需的最大步骤数是五步。对于包含500个单元格的数组,线性搜索所需的最大步骤数是500步。

另一种说法是,对于数组中的N个单元格,线性搜索需要的最大步骤数是N。在这个情况下,N只是可以替换为任何数字的变量。

无论如何,很明显,搜索比读取的效率低,因为搜索可能需要很多步骤,而读取无论数组大小都只需要一步。

接下来,我们将分析插入操作。

插入

这个操作的效率取决于你将新数据插入数组的位置。

比如说,我们想把“figs”添加到我们的购物清单的末尾。这样的插入只需要一步。

这是由于计算机的另一个事实:在分配数组时,计算机总是会跟踪数组的大小。

当我们将此与计算机还知道数组开始的内存地址相结合时,计算数组最后一项的内存地址就变得十分简单:如果数组从内存地址1010开始,并且大小为5,那么它的最终内存地址就是1014。因此,要在数组末尾添加一项,就意味着将其添加到下一个内存地址,即1015。

一旦计算机计算出要将新值插入的内存地址,它就可以在一步内完成这个操作。

这就是在数组末尾插入“figs”的过程:

在这里插入图片描述

但有一个问题。因为计算机最初只为数组分配了五个单元而现在我们要添加第六个元素,因此计算机可能需要为数组分配额外的单元格。在许多编程语言中在许多编程语言中,这都是自动完成的。处理方式不同,所以我就不细说了。

我们已经处理过在数组末尾插入数据的问题,但在数组的开头或中间插入新数据则另当别论。在这些情况下在这种情况下,我们需要移动数据块,为插入的数据腾出空间,这就需要额外的步骤。

例如,我们要在数组的索引 2 中添加 “figs”。请看下图

在这里插入图片描述

这意味着我们需要将“黄瓜”、“日期”和“接骨木果”向右移动,以腾出空间来放置“无花果”。这需要多个步骤,因为我们首先需要将“接骨木果”向右移动一个单元,以便为“日期”腾出空间。然后,我们需要移动“日期”以便为“黄瓜”腾出空间。让我们一起走一遍这个过程。

第1步:往右移动“elderbes”
在这里插入图片描述

第2步:往右移动“datas”
在这里插入图片描述

第3步:往右移动“cucumber”
在这里插入图片描述

第4步:在索引2处插入“figs”
在这里插入图片描述

在前面的例子中,插入操作共经过了四个步骤。其中三个步骤涉及向右移动数据,而一个步骤涉及实际插入新值。

数组插入的最坏情况——也就是插入需要最多步骤的情况——是在数组的开头插入数据。这是因为在数组开头插入时,我们需要将所有其他值向右移动一个单元。

我们可以说在最坏情况下,数组插入可能需要 N + 1 步,其中 N 为数组包含的元素数量。这是因为我们需要将所有 N 个元素向右移动,然后最终执行实际的插入步骤。

现在我们已经讨论了插入操作,接下来是数组的最后一个操作:删除。

删除

删除数组中的元素是指删除特定索引处的值。

让我们回到我们最初的示例数组,并删除索引为2的值。在我们的示例中,这个值是“cucumbers”。

步骤 1:我们从数组中删除了“cucumbers”:

在这里插入图片描述

虽然实际上删除“cucumbers”技术上只花了一步,但现在我们有一个问题:在数组中间出现了一个空单元。如果数组中间有空隙,它的效果就不好了,所以为了解决这个问题,我们需要将“dates”和“elderberries”向左移动。这意味着我们的删除过程需要额外的步骤。

步骤 2:我们将“dates”向左移动:

在这里插入图片描述

步骤 3: 我们将 “elderberries” 向左移动:

在这里插入图片描述

这次删除操作总共花了三个步骤。第一步是实际的删除操作,其余两步是为了填补空隙而进行的数据移动。

和插入操作一样,删除元素的最坏情况是删除数组中的第一个元素。这是因为索引0将变为空,我们需要将所有剩余的元素向左移以填补这个空隙。对于包含五个元素的数组,我们需要一步删除第一个元素,然后四步移动剩下的四个元素。对于包含500个元素的数组,我们需要一步删除第一个元素,然后499步移动剩余的数据。因此,对于包含N个元素的数组,删除操作所需的最大步骤数是N步。

恭喜!我们已经分析了第一个数据结构的时间复杂度。现在你已经学会了如何分析数据结构的效率,你会发现不同数据结构有不同的效率。这很重要,因为选择适合你的代码的正确数据结构对软件的性能有着重大影响。

下一个数据结构——集合(set)——乍看起来与数组非常相似。然而,你会发现数组和集合执行的操作具有不同的效率。

集合: 一个简单规则如何影响效率

让我们探索另一个数据结构:集合。集合是一种数据结构,不允许其中包含重复的值。

有不同类型的集合,但在这里,我将谈论基于数组的集合。这个集合就像一个数组一样——是一列简单的值。这个集合与经典数组的唯一区别在于,集合永远不允许重复的值插入其中。

例如,如果你有一个集合 [“a”, “b”, “c”] 并尝试添加另一个 “b”,计算机就不会允许,因为 “b” 已经存在于集合中。

当你需要确保没有重复数据时,集合是很有用的。例如,如果你正在创建一个在线电话簿,你不希望同一个电话号码出现两次。实际上,我目前正因为我的本地电话簿而受苦:我的家庭电话号码不仅列出了我的信息,还错误地列为名为 Zirkind 的某个家庭的电话号码。(是的,这是一个真实的故事。)让我告诉你——接到寻找 Zirkind 家人的人的电话和语音邮件真的很烦人。说实话,我敢肯定 Zirkind 家人也在想为什么没有人打电话给他们。当我给 Zirkind 家人打电话告诉他们这个错误时,我的妻子接起电话,因为我打了自己的电话号码。(好吧,最后这部分是假的。)如果只是生成电话簿的程序使用了一个集合……

无论如何,基于数组的集合是一个数组,额外添加了不允许重复的约束条件。虽然不允许重复是一个有用的特性,但这个简单的约束也导致集合在四个主要操作中的一个效率不同。

让我们在基于数组的集合中分析读取、搜索、插入和删除操作。
从集合中读取与从数组中读取完全相同——计算机只需一步就可以查找特定索引中包含的内容。

就像我之前描述的那样,这是因为计算机可以跳转到集合中的任何索引,因为它可以轻松地计算并跳转到其内存地址。

搜索集合也与搜索数组没有区别——在集合中搜索一个值最多需要 N 步。而删除操作在集合和数组之间也是相同的——最多需要 N 步来删除一个值,并将数据向左移动以关闭空隙。

然而,插入操作是数组和集合分歧的地方。首先,让我们探讨在集合末尾插入值,这对于数组来说是最佳情况。我们看到,对于数组来说,计算机可以在末尾插入一个值,只需一步即可完成。

然而,在集合中,计算机首先需要确定这个值在集合中不存在——因为这就是集合的作用:它们防止重复的数据被插入其中。

现在,计算机如何确保新数据尚未包含在集合中?记住,计算机不知道数组或集合的单元格中包含哪些值。因此,计算机首先需要搜索集合,看看我们要插入的值是否已经存在。只有在集合中尚未包含我们的新值时,计算机才允许插入操作发生。

因此,每次向集合中插入数据都需要进行一次搜索。

让我们通过一个示例来看看这个过程。想象一下,我们之前的购物清单存储为一个集合——毕竟我们不想买两次同样的东西。如果我们当前的集合是
[“apples”, “bananas”, “cucumbers”, “dates”, “elderberries”]
并且我们想将 “figs” 插入到集合中,计算机必须执行以下步骤,从搜索 “figs” 开始。

第 1 步:在索引 0 中搜索 “figs”:

在这里插入图片描述

不在那里,但可能在集合的其他地方。我们需要确保在插入之前 “figs” 不存在于任何地方。

步骤1:在索引 1 中搜索:

在这里插入图片描述
(…省略重复操作图片)
在这里插入图片描述

现在我们已经搜索了整个集合,可以确定它不包含 “figs”。此时可以安全地完成插入。这就是我们的最后一步:

步骤 6:将 “figs” 插入到集合的末尾:
在这里插入图片描述

插入值到集合末尾是最理想的情况,但即便是对于原本包含五个元素的集合,我们仍需执行六个步骤。也就是说,对于含有 N 个元素的集合,末尾插入将耗费最多 N+1 步。这是因为需要进行 N 步的搜索来确保值并不存在于集合中,然后再进行一步实际的插入。与之形成对比的是常规数组,在常规数组中进行这样的插入只需要一步。

在最坏的情况下,当我们将一个值插入到集合的开头时,计算机需要搜索 N 个单元格以确保集合不含有该值,再花费 N 步将所有数据向右移动,最后还需要一步来插入新值。这总共需要 2N+1 步。与此形成对比的是向常规数组开头进行插入,仅需 N+1 步。

那么,这是否意味着你应该避免使用集合,仅仅因为插入对于集合而言比常规数组要慢?绝对不是。集合在需要确保没有重复数据时非常重要。(希望有一天我的电话簿能够修复。)但当你不需要这样的功能时,数组可能更合适,因为数组的插入操作比集合更高效。你必须分析自己应用的需求,决定哪种数据结构更适合。

总结

这是对数据结构性能的理解核心——分析操作所需的步骤数。为程序选择合适的数据结构可能意味着承载巨大负荷与无法负荷之间的差异。在本章中,您已经学会如何使用这种分析方法,来权衡在特定应用中选择数组或集合是否合适。

**现在您已经开始学习如何思考数据结构的时间复杂性,我们可以使用相同的分析方法来比较竞争算法(甚至在同一数据结构中),以确保代码的最终速度和性能。**而这正是下一章要讨论的内容。

练习

以下练习提供了练习数组操作的机会。这些练习的解答可以在第1章的第439页找到。

  1. 对于包含100个元素的数组,提供以下操作所需的步骤数:
    a. 读取
    b. 搜索不包含在数组中的值
    c. 在数组开头插入
    d. 在数组末尾插入
    e. 删除数组开头的元素
    f. 删除数组末尾的元素
  2. 对于包含100个元素的基于数组的集合,提供以下操作所需的步骤数:
    a. 读取
    b. 搜索不包含在数组中的值
    c. 在集合开头插入新值
    d. 在集合末尾插入新值
    e. 删除集合开头的元素
    f. 删除集合末尾的元素
  3. 通常情况下,数组中的搜索操作查找给定值的第一个实例。但有时我们可能想要查找给定值的每一个实例。例如,假设我们想要计算数组中值为“apple”的出现次数。查找所有的“apple”需要多少步骤?用N来表示你的答案。

答案

  1. 让我们逐个案例进行分析:
    a. 从数组中读取数据始终只需一步。
    b. 在包含100个元素的数组中搜索一个不存在的元素需要100步,因为计算机需要检查数组中的每个元素,然后确定该元素不存在。
    c. 插入需要101步:100步将每个元素向右移动,并在数组前端插入新元素的一步。
    d. 在数组末尾插入元素始终只需一步。
    e. 删除需要100步:首先计算机删除第一个元素,然后将剩余的99个元素逐个向左移动。
    f. 在数组末尾删除元素始终只需一步。

  2. 让我们逐个案例进行分析:
    a. 与数组相似,从基于数组的集合中读取数据只需一步。
    b. 与数组相似,搜索基于数组的集合需要100步,因为我们需要检查每个元素以确定元素不存在。
    c. 插入集合时,我们首先需要进行全面搜索以确保值不存在于集合中。这个搜索需要100步。然后,我们需要将所有100个元素向右移动以为新值腾出位置。最后,将新值放在集合的开头。这总共需要201步。
    d. 这个插入需要101步。同样,在插入之前需要进行全面搜索,需要100步。然后,我们完成最后一步,在集合的末尾插入新值。
    e. 删除集合中的元素需要100步,就像经典的数组一样。
    f. 删除集合中的元素只需一步,就像经典的数组一样。

  3. 如果数组包含N个元素,在数组中查找字符串“apple”的所有实例将需要N步。当只搜索一个实例时,我们可以在找到它后立即停止搜索。但如果需要找到所有实例,我们别无选择,只能检查整个数组的每个元素。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值