每个程序员都应该知道的 40 个算法(一)

原文:zh.annas-archive.org/md5/8ddea683d78e7bd756401ec665273969

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

算法在计算机科学和实践中一直扮演着重要角色。本书侧重于利用这些算法来解决现实世界的问题。要充分利用这些算法,对它们的逻辑和数学有更深入的理解是必不可少的。您将从算法介绍开始,探索各种算法设计技术。接着,您将了解线性规划、页面排名和图表,甚至使用机器学习算法,理解它们背后的数学和逻辑。本书还包含案例研究,如天气预测、推文聚类和电影推荐引擎,将向您展示如何最优地应用这些算法。完成本书后,您将自信地使用算法解决现实世界的计算问题。

本书适合对象

本书适合严肃的程序员!无论您是经验丰富的程序员,希望更深入地了解算法背后的数学,还是对编程或数据科学知识有限,想了解如何利用经过实战检验的算法来改进设计和编写代码的方式,您都会发现本书很有用。必须具备 Python 编程经验,尽管了解数据科学有所帮助,但并非必需。

本书涵盖内容

第一章《算法概述》总结了算法的基本原理。它从需要理解不同算法工作原理的基本概念开始。它总结了人们如何开始使用算法来数学地表达某些类别的问题。它还提到了不同算法的局限性。接下来的部分解释了指定算法逻辑的各种方法。由于本书使用 Python 编写算法,因此接下来解释了如何设置环境以运行示例。然后,讨论了衡量算法性能并与其他算法进行比较的各种方法。最后,本章讨论了验证算法特定实现的各种方法。

第二章《算法中使用的数据结构》着重于算法对必要的内存数据结构的需求,这些数据结构可以保存临时数据。算法可以是数据密集型、计算密集型或两者兼而有之。但对于所有不同类型的算法,选择正确的数据结构对于它们的最佳实现至关重要。许多算法具有递归和迭代逻辑,并且需要基本上是迭代性质的专门数据结构。由于本书使用 Python,本章重点介绍了可以用于实现本书讨论的算法的 Python 数据结构。

第三章《排序和搜索算法》介绍了用于排序和搜索的核心算法。这些算法以后可以成为更复杂算法的基础。本章首先介绍了不同类型的排序算法。它还比较了各种方法的性能。然后,介绍了各种搜索算法。它们进行了比较,并量化了它们的性能和复杂性。最后,本章介绍了这些算法的实际应用。

第四章《设计算法》介绍了各种算法的核心设计概念。它还解释了不同类型的算法,并讨论了它们的优缺点。在设计复杂算法时,理解这些概念是很重要的。该章首先讨论了不同类型的算法设计。然后,它提出了著名的旅行推销员问题的解决方案。接着讨论了线性规划及其局限性。最后,它提出了一个实际例子,展示了线性规划如何用于容量规划。

第五章《图算法》专注于计算机科学中常见的图问题的算法。有许多计算问题最好以图的术语表示。本章介绍了表示图和搜索图的方法。搜索图意味着系统地沿着图的边缘访问图的顶点。图搜索算法可以发现关于图结构的许多信息。许多算法首先通过搜索它们的输入图来获得这些结构信息。几种其他图算法详细介绍了基本的图搜索。搜索图的技术是图算法领域的核心。第一部分讨论了图的两种最常见的计算表示形式:邻接表和邻接矩阵。接下来介绍了一种简单的图搜索算法,称为广度优先搜索,并展示了如何创建广度优先树。接着介绍了深度优先搜索,并提供了一些关于深度优先搜索访问顶点顺序的标准结果。

第六章《无监督机器学习算法》介绍了无监督机器学习算法。这些算法被分类为无监督,因为模型或算法试图从给定数据中学习内在结构、模式和关系,而无需任何监督。首先讨论了聚类方法。这些是机器学习方法,试图在数据样本中找到相似性和关系的模式,然后将这些样本聚类成各种群组,使得每个数据样本的群组或簇都具有一定的相似性,基于内在属性或特征。接下来讨论了降维算法,当我们最终拥有大量特征时使用。接着介绍了一些处理异常检测的算法。最后,本章介绍了关联规则挖掘,这是一种数据挖掘方法,用于检查和分析大型交易数据集,以识别感兴趣的模式和规则。这些模式代表跨交易的各种项目之间的有趣关系和关联。

第七章《传统监督学习算法》描述了传统的监督机器学习算法,涉及一组机器学习问题,其中存在带有输入属性和相应输出标签或类别的标记数据集。然后利用这些输入和相应的输出来学习一个泛化系统,可以用来预测以前未见过的数据点的结果。首先,在机器学习的背景下介绍了分类的概念。然后介绍了最简单的机器学习算法之一,线性回归。接着介绍了最重要的算法之一,决策树。讨论了决策树算法的局限性和优势,然后介绍了两个重要的算法,SVM 和 XGBoost。

第八章《神经网络算法》首先介绍了典型神经网络的主要概念和组件,这种网络正成为最重要的机器学习技术。然后,它介绍了各种类型的神经网络,并解释了用于实现这些神经网络的各种激活函数。接着详细讨论了反向传播算法,这是最广泛使用的收敛神经网络问题的算法。接下来解释了迁移学习技术,可以大大简化和部分自动化模型的训练。最后,介绍了如何使用深度学习来检测多媒体数据中的对象作为实际例子。

第九章《自然语言处理算法》介绍了自然语言处理(NLP)的算法。本章以渐进的方式从理论到实践。首先介绍了基本原理,然后是基础数学知识。然后讨论了设计和实施几个重要的文本数据用例的最广泛使用的神经网络之一。还讨论了 NLP 的局限性。最后,介绍了一个案例研究,其中训练模型以根据写作风格检测论文的作者。

第十章《推荐引擎》专注于推荐引擎,这是一种对用户偏好相关信息进行建模,并利用这些信息提供有根据的推荐的方法。推荐引擎的基础始终是用户和产品之间记录的互动。本章首先介绍了推荐引擎背后的基本思想。然后讨论了各种类型的推荐引擎。最后,讨论了推荐引擎如何用于向不同用户推荐物品和产品。

第十一章《数据算法》关注与数据中心算法相关的问题。该章从简要概述与数据相关的问题开始。然后介绍了对数据进行分类的标准。接下来提供了如何将算法应用于流数据应用程序的描述,然后介绍了密码学的主题。最后,介绍了从 Twitter 数据中提取模式的实际例子。

第十二章《密码学》介绍了与密码学相关的算法。该章从背景开始。然后讨论了对称加密算法。解释了 MD5 和 SHA 哈希算法,并介绍了实施对称算法的局限性和弱点。接下来讨论了非对称加密算法以及它们如何用于创建数字证书。最后,讨论了一个总结所有这些技术的实际例子。

第十三章《大规模算法》解释了如何处理无法适应单个节点内存并涉及需要多个 CPU 进行处理的数据的大规模算法。本章首先讨论了最适合并行运行的算法类型。然后讨论了与并行化算法相关的问题。还介绍了 CUDA 架构,并讨论了如何使用单个 GPU 或一组 GPU 来加速算法以及需要对算法进行哪些更改才能有效利用 GPU 的性能。最后,本章讨论了集群计算,并讨论了 Apache Spark 如何创建弹性分布式数据集(RDD)以创建标准算法的极快并行实现。

第十四章,实际考虑,从解释性的重要主题开始,这在现在已经解释了自动决策背后的逻辑变得越来越重要。然后,本章介绍了使用算法的伦理和在实施它们时可能产生偏见的可能性。接下来,详细讨论了处理 NP-hard 问题的技术。最后,总结了实施算法的方法以及与此相关的现实挑战。

充分利用本书

章节编号 所需软件(带版本) 免费/专有 硬件规格 所需操作系统
1-14 Python 版本 3.7.2 或更高 免费 最低 4GB RAM,推荐 8GB+ Windows/Linux/Mac

如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从您的账户在www.packt.com下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

文件下载后,请确保使用最新版本进行解压缩或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/40-Algorithms-Every-Programmer-Should-Know。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781789801217_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这里有一个例子:“让我们看看如何通过使用push来向堆栈添加一个新元素,或者通过使用pop来从堆栈中移除一个元素。”

代码块设置如下:

define swap(x, y)
    buffer = x
    x = y
    y = buffer

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

define swap(x, y)
    buffer = x
    x = y
 y = buffer

任何命令行输入或输出都以以下方式编写:

pip install a_package

粗体:表示一个新术语,一个重要单词,或者屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“简化算法的一种方法是在准确性上做出妥协,从而产生一种称为近似算法的算法。”

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一部分:基本原理和核心算法

本节介绍了算法的核心方面。我们将探讨算法是什么以及如何设计它,还将了解算法中使用的数据结构。本节还深入介绍了排序和搜索算法,以及解决图形问题的算法。本节包括以下章节:

  • 第一章,算法概述

  • 第二章,算法中使用的数据结构

  • 第三章,排序和搜索算法

  • 第四章,设计算法

  • 第五章,图算法

第一章:算法概述

本书涵盖了理解、分类、选择和实施重要算法所需的信息。除了解释它们的逻辑之外,本书还讨论了适用于不同类算法的数据结构、开发环境和生产环境。我们专注于越来越重要的现代机器学习算法。除了逻辑之外,还提供了算法实际解决日常问题的实际示例。

本章介绍了算法基础知识。它从需要理解不同算法工作原理的基本概念开始。本节总结了人们如何开始使用算法来数学表达某一类问题,并提到了不同算法的局限性。接下来的部分解释了指定算法逻辑的各种方法。由于本书使用 Python 编写算法,因此解释了如何设置环境来运行示例。然后,讨论了算法性能可以如何量化并与其他算法进行比较的各种方法。最后,本章讨论了验证算法特定实现的各种方法。

总之,本章涵盖了以下主要内容:

  • 什么是算法?

  • 指定算法的逻辑

  • 介绍 Python 包

  • 算法设计技术

  • 性能分析

  • 验证算法

什么是算法?

简单来说,算法是一组用于解决问题的计算规则。它旨在根据精确定义的指令为任何有效输入产生结果。如果您在英语词典(如美国传统)中查找算法一词,它定义了以下概念:

“算法是一组明确的指令,给定一些初始条件,可以按照规定的顺序执行,以达到某个目标,并具有可识别的一组结束条件。”

设计算法是创建数学配方的努力,以最有效的方式解决现实世界的问题。这个配方可以作为开发更可重用和通用的数学解决方案的基础,可以应用于更广泛的类似问题集。

算法的阶段

以下图表说明了开发、部署和最终使用算法的不同阶段:

正如我们所看到的,这个过程始于理解问题陈述中的需求,详细说明了需要做什么。一旦问题清晰陈述,就会引导我们进入开发阶段。

开发阶段包括两个阶段:

  • 设计阶段:在设计阶段,算法的架构、逻辑和实现细节被构想和记录下来。在设计算法时,我们同时考虑准确性和性能。在寻找给定问题的解决方案时,在许多情况下,我们会得到多个备选算法。算法的设计阶段是一个迭代过程,涉及比较不同的候选算法。一些算法可能提供简单快速的解决方案,但可能会牺牲准确性。其他算法可能非常准确,但由于复杂性需要花费相当长的时间来运行。其中一些复杂算法可能比其他算法更有效。在做出选择之前,应仔细研究候选算法的所有固有权衡。特别是对于复杂问题,设计高效的算法非常重要。正确设计的算法将导致有效的解决方案,能够同时提供令人满意的性能和合理的准确性。

  • 编码阶段:在编码阶段,设计的算法被转换为计算机程序。实际程序实现设计阶段建议的所有逻辑和架构是很重要的。

算法的设计和编码阶段是迭代的。设计满足功能和非功能需求的设计可能需要大量的时间和精力。功能需求是指定给定输入数据的正确输出是什么的要求。算法的非功能需求主要是关于给定数据大小的性能。算法的验证和性能分析将在本章后面讨论。验证算法是验证算法是否满足其功能需求。算法的性能分析是验证它是否满足其主要的非功能需求:性能。

一旦设计并在您选择的编程语言中实现,算法的代码就可以部署了。部署算法涉及设计实际的生产环境,代码将在其中运行。生产环境需要根据算法的数据和处理需求进行设计。例如,对于可并行化的算法,需要具有适当数量的计算节点的集群,以便有效地执行算法。对于数据密集型算法,可能需要设计数据进入管道和缓存和存储数据的策略。生产环境的设计将在第十三章“大规模算法”和第十四章“实际考虑”中更详细地讨论。一旦设计并实施了生产环境,算法就可以部署,它将接受输入数据,处理它,并根据要求生成输出。

指定算法的逻辑

在设计算法时,重要的是找到不同的方式来指定其细节。需要能够捕捉其逻辑和架构。通常,就像建造房屋一样,在实际实施算法之前,指定算法的结构是很重要的。对于更复杂的分布式算法,预先规划它们的逻辑在运行时如何分布在集群中对于迭代式高效设计过程是重要的。通过伪代码和执行计划,这两个需求都得到满足,并将在下一节中讨论。

理解伪代码

指定算法逻辑的最简单方法是以半结构化方式编写算法的高级描述,称为伪代码。在用伪代码编写逻辑之前,首先描述其主要流程并用简单的英语写出主要步骤是有帮助的。然后,将这个英语描述转换成伪代码,这是一种以结构化方式编写这个英语描述的方法,它紧密地代表了算法的逻辑和流程。良好编写的算法伪代码应该以合理的细节描述算法的高级步骤,即使详细的代码与主要流程和结构无关。下图显示了步骤的流程:

伪代码的一个实际例子

请注意,一旦编写了伪代码(如我们将在下一节中看到的),我们就可以使用我们选择的编程语言编写算法代码了。

图 1.3 显示了一个名为SRPMP的资源分配算法的伪代码。在集群计算中,有许多情况需要在一组可用资源上运行并行任务,统称为资源池。这个算法将任务分配给一个资源,并创建一个称为Ω的映射集。请注意,所呈现的伪代码捕捉了算法的逻辑和流程,这在下一节中进一步解释:

1: BEGIN Mapping_Phase
2: Ω = { }
3: k = 1
4: FOREACH Ti∈T
5:     ωi = RA(Δk,Ti)
6:     add {ωi,Ti} to Ω
7:     state_changeTi [STATE 0: Idle/Unmapped] → [STATE 1: Idle/Mapped]
8:     k=k+1
9:     IF (k>q)
10:       k=1
11:    ENDIF
12: END FOREACH
13: END Mapping_Phase

让我们逐行解析这个算法:

  1. 我们通过执行算法开始映射。Ω映射集是空的。

  2. 第一个分区被选为T[1]任务的资源池(参见前面代码的第 3 行)。电视收视率TRPS)迭代地调用类风湿性关节炎RA)算法,对于每个T[i]任务,选择一个分区作为资源池。

  3. RA 算法返回为T[i]任务选择的资源集,由ω[i]表示(参见前面代码的第 5 行)。

  4. T[i]ω[i]被添加到映射集中(参见前面代码的第 6 行)。

  5. T[i]的状态从STATE 0:Idle/Mapping更改为STATE 1:Idle/Mapped(参见前面代码的第 7 行)。

  6. 请注意,对于第一次迭代,k=1,并且选择了第一个分区。对于每个后续迭代,k的值增加,直到k>q

  7. 如果k变得大于q,它会再次重置为1(参见前面代码的第 9 和第 10 行)。

  8. 这个过程重复进行,直到确定并存储了所有任务与它们将使用的资源集之间的映射,并存储在一个称为Ω的映射集中。

  9. 一旦每个任务在映射阶段映射到一组资源中,它就会被执行。

使用片段

随着 Python 等简单但功能强大的编程语言的流行,另一种替代方法变得流行,即直接用编程语言表示算法的逻辑,以一种简化的方式。与伪代码一样,这个选定的代码捕捉了所提出的算法的重要逻辑和结构,避免了详细的代码。这个选定的代码有时被称为片段。在本书中,尽可能使用片段代替伪代码,因为它们节省了一个额外的步骤。例如,让我们看一个简单的片段,它是一个关于可以用来交换两个变量的 Python 函数的片段:

define swap(x, y)
    buffer = x
    x = y
    y = buffer

请注意,片段并不能总是替代伪代码。在伪代码中,有时我们将许多行代码抽象为一行伪代码,表达算法的逻辑,而不会被不必要的编码细节分散注意力。

创建执行计划

伪代码和片段并不总是足以指定与更复杂的分布式算法相关的所有逻辑。例如,分布式算法通常需要在运行时分成不同的编码阶段,这些阶段具有优先顺序。将较大的问题划分为最佳数量的阶段,并具有正确的优先约束条件的正确策略对于有效执行算法至关重要。

我们需要找到一种方法来表示这种策略,以完全表示算法的逻辑和结构。执行计划是详细说明算法将如何被细分为一堆任务的一种方式。一个任务可以是映射器或减速器,可以被分组在称为阶段的块中。下图显示了在执行算法之前由 Apache Spark 运行时生成的执行计划。它详细说明了作业为执行我们的算法创建的运行时任务将被分成:

请注意,前面的图表有五个任务,它们被分成了两个不同的阶段:阶段 11 和阶段 12。

引入 Python 包

一旦设计好算法,就需要根据设计在编程语言中实现。对于本书,我选择了编程语言 Python。我选择它是因为 Python 是一种灵活的开源编程语言。Python 也是越来越重要的云计算基础设施的首选语言,如亚马逊网络服务AWS)、微软 Azure 和谷歌云平台GCP)。

官方 Python 主页可在www.python.org/找到,其中还有安装说明和有用的初学者指南。

如果您以前没有使用过 Python,浏览这本初学者指南是一个好主意。对 Python 的基本理解将有助于您更好地理解本书中提出的概念。

对于本书,我希望您使用最新版本的 Python 3。在撰写本文时,最新版本是 3.7.3,这是我们将用来运行本书中的练习的版本。

Python 软件包

Python 是一种通用语言。它设计成具有最基本的功能。根据您打算使用 Python 的用例,需要安装额外的软件包。安装额外软件包的最简单方法是通过 pip 安装程序。这个pip命令可以用来安装额外的软件包:

pip install a_package

已安装的软件包需要定期更新以获得最新功能。这可以通过使用upgrade标志来实现:

pip install a_package --upgrade

用于科学计算的另一个 Python 发行版是 Anaconda,可以从continuum.io/downloads下载。

除了使用pip命令安装新软件包外,对于 Anaconda 发行版,我们还可以使用以下命令来安装新软件包:

conda install a_package

要更新现有的软件包,Anaconda 发行版提供了以下命令选项:

conda update a_package

有各种各样的 Python 软件包可用。一些与算法相关的重要软件包在以下部分中进行了描述。

SciPy 生态系统

科学 Python(SciPy)——发音为sigh pie——是为科学界创建的一组 Python 软件包。它包含许多函数,包括各种随机数生成器、线性代数例程和优化器。SciPy 是一个综合性的软件包,随着时间的推移,人们开发了许多扩展来根据自己的需求定制和扩展软件包。

以下是该生态系统的主要软件包:

  • NumPy:对于算法来说,创建多维数据结构(如数组和矩阵)的能力非常重要。NumPy 提供了一组重要的用于统计和数据分析的数组和矩阵数据类型。有关 NumPy 的详细信息可以在www.numpy.org/找到。

  • scikit-learn:这个机器学习扩展是 SciPy 最受欢迎的扩展之一。Scikit-learn 提供了一系列重要的机器学习算法,包括分类、回归、聚类和模型验证。您可以在scikit-learn.org/找到有关 scikit-learn 的更多详细信息。

  • pandas:pandas 是一个开源软件库。它包含了广泛用于输入、输出和处理表格数据的表格复杂数据结构。pandas 库包含许多有用的函数,还提供了高度优化的性能。有关 pandas 的更多详细信息可以在pandas.pydata.org/找到。

  • Matplotlib:Matplotlib 提供了创建强大可视化的工具。数据可以呈现为折线图、散点图、条形图、直方图、饼图等。更多信息可以在matplotlib.org/找到。

  • Seaborn:Seaborn 可以被认为类似于 R 中流行的 ggplot2 库。它基于 Matplotlib,并提供了一个高级接口来绘制出色的统计图形。更多细节可以在seaborn.pydata.org/找到。

  • iPython:iPython 是一个增强的交互式控制台,旨在促进编写,测试和调试 Python 代码。

  • 运行 Python 程序:交互式编程模式对于学习和实验代码非常有用。 Python 程序可以保存在带有.py扩展名的文本文件中,并且可以从控制台运行该文件。

通过 Jupyter Notebook 实现 Python

通过 Jupyter Notebook 运行 Python 程序的另一种方式。 Jupyter Notebook 提供了基于浏览器的用户界面来开发代码。 Jupyter Notebook 用于在本书中展示代码示例。 用文本和图形注释和描述代码的能力使其成为呈现和解释算法的完美工具,也是学习的好工具。

要启动笔记本,您需要启动Juypter-notebook进程,然后打开您喜欢的浏览器并导航到http://localhost:8888

请注意,Jupyter Notebook 由称为单元格的不同块组成。

算法设计技术

算法是对现实世界问题的数学解决方案。在设计算法时,我们在设计和微调算法时牢记以下三个设计关注点:

  • 关注 1:这个算法是否产生了我们预期的结果?

  • 关注 2:这是获得这些结果的最佳方式吗?

  • 关注 3:算法在更大的数据集上的表现如何?

在设计解决方案之前更好地了解问题本身的复杂性是很重要的。例如,如果我们根据需求和复杂性对问题进行表征,这有助于我们设计适当的解决方案。通常,根据问题的特征,算法可以分为以下类型:

  • 数据密集型算法:数据密集型算法旨在处理大量数据。预计它们具有相对简单的处理要求。应用于大型文件的压缩算法是数据密集型算法的一个很好的例子。对于这样的算法,数据的大小预计会远大于处理引擎(单个节点或集群)的内存,并且可能需要开发迭代处理设计以根据要求高效处理数据。

  • 计算密集型算法:计算密集型算法具有相当大的处理需求,但不涉及大量数据。一个简单的例子是查找非常大的质数的算法。找到将算法分成不同阶段的策略,以便至少有一些阶段是并行化的,是最大化算法性能的关键。

  • 数据和计算密集型算法:有些算法处理大量数据,并且具有相当大的计算需求。用于对实时视频流执行情感分析的算法是处理任务中数据和处理要求都很大的很好的例子。这些算法是最资源密集的算法,需要仔细设计算法并智能分配可用资源。

为了更深入地研究问题的复杂性和需求,有助于我们研究其数据并计算更深入的维度,这将在下一节中进行。

数据维度

为了对问题的数据维度进行分类,我们看看其体积速度多样性3V),其定义如下:

  • 体积:体积是算法将处理的数据的预期大小。

  • 速度:速度是在使用算法时预期的新数据生成速率。它可以为零。

  • 多样性:多样性量化了设计的算法预计要处理多少不同类型的数据。

下图更详细地显示了数据的 3Vs。这个图的中心显示了最简单的数据,体积小,多样性和速度低。当我们远离中心时,数据的复杂性增加。它可以在三个维度中的一个或多个维度上增加。例如,在速度维度上,我们有批处理作为最简单的,然后是周期性处理,然后是准实时处理。最后,我们有实时处理,在数据速度的背景下处理起来最复杂。例如,由一组监控摄像头收集的实时视频流将具有高体积、高速度和高多样性,并且可能需要适当的设计来有效地存储和处理数据。另一方面,一个在 Excel 中创建的简单.csv文件将具有低体积、低速度和低多样性:

例如,如果输入数据是一个简单的csv文件,那么数据的体积、速度和多样性将很低。另一方面,如果输入数据是安全视频摄像头的实时视频流,那么数据的体积、速度和多样性将会很高,这个问题在设计算法时应该牢记在心。

计算维度

计算维度是关于问题处理和计算需求的。算法的处理需求将决定最有效的设计是什么样的。例如,深度学习算法通常需要大量的处理能力。这意味着对于深度学习算法,尽可能拥有多节点并行架构是很重要的。

一个实际的例子

假设我们想对一个视频进行情感分析。情感分析是指我们试图标记视频中不同部分的人类情感,如悲伤、快乐、恐惧、喜悦、挫折和狂喜。这是一个计算密集型的工作,需要大量的计算能力。正如你将在下图中看到的,为了设计计算维度,我们将处理分为五个任务,包括两个阶段。所有的数据转换和准备都是在三个 mapper 中实现的。为此,我们将视频分成三个不同的分区,称为拆分。在 mapper 执行完毕后,处理后的视频输入到两个聚合器,称为reducer。为了进行所需的情感分析,reducer 根据情感对视频进行分组。最后,结果在输出中合并。

请注意,mapper 的数量直接影响算法的运行并行性。最佳的 mapper 和 reducer 数量取决于数据的特性、需要使用的算法类型以及可用资源的数量。

性能分析

分析算法的性能是设计的重要部分。估计算法性能的一种方法是分析其复杂性。

复杂性理论是研究复杂算法的学科。为了有用,任何算法都应该具有三个关键特征:

  • 应该是正确的。如果算法不能给出正确的答案,那么它对你来说没有太大的好处。

  • 一个好的算法应该是可以理解的。即使是世界上最好的算法,如果对你来说太复杂而无法在计算机上实现,那也没有什么好处。

  • 一个好的算法应该是高效的。即使一个算法产生了正确的结果,如果它需要花费一千年或者需要十亿太字节的内存,也不会对你有太大帮助。

算法复杂度的两种可能的分析类型:

  • 空间复杂度分析:估计执行算法所需的运行时内存需求。

  • 时间复杂度分析:估计算法运行所需的时间。

空间复杂度分析

空间复杂度分析估计算法处理输入数据所需的内存量。在处理输入数据时,算法需要将瞬态临时数据结构存储在内存中。算法的设计方式会影响这些数据结构的数量、类型和大小。在分布式计算时代,需要处理越来越多的数据,空间复杂度分析变得越来越重要。这些数据结构的大小、类型和数量将决定底层硬件的内存需求。在分布式计算中使用的现代内存数据结构,如弹性分布式数据集(RDD),需要具有高效的资源分配机制,以便在算法的不同执行阶段了解内存需求。

空间复杂度分析是算法高效设计的必要条件。如果在设计特定算法时没有进行适当的空间复杂度分析,那么对于瞬态临时数据结构的内存可用性不足可能会触发不必要的磁盘溢出,这可能会显著影响算法的性能和效率。

在本章中,我们将更深入地研究时间复杂度。空间复杂度将在第十三章《大规模算法》中更详细地讨论,那里我们将处理具有复杂运行时内存需求的大规模分布式算法。

时间复杂度分析

时间复杂度分析估计算法基于其结构完成其分配工作所需的时间。与空间复杂度相比,时间复杂度不依赖于算法将在其上运行的任何硬件。时间复杂度分析仅仅取决于算法本身的结构。时间复杂度分析的总体目标是尝试回答这些重要问题——这个算法是否可扩展?这个算法将如何处理更大的数据集?

为了回答这些问题,我们需要确定算法在数据规模增大时对性能的影响,并确保算法不仅准确而且能够良好扩展。在当今“大数据”世界中,算法的性能对于更大的数据集变得越来越重要。

在许多情况下,我们可能有多种方法来设计算法。在这种情况下进行时间复杂度分析的目标将是:

“对于一个特定的问题和多个算法,哪一个在时间效率上最有效?”

计算算法时间复杂度的基本方法有两种:

  • 后实现的分析方法:在这种方法中,实现不同的候选算法并比较它们的性能。

  • 预实现的理论方法:在运行算法之前,通过数学近似来估计每个算法的性能。

理论方法的优势在于它仅仅取决于算法本身的结构。它不依赖于实际用于运行算法的硬件、运行时选择的软件栈的选择,或者用于实现算法的编程语言。

性能估计

典型算法的性能将取决于输入的数据类型。例如,如果数据已根据我们试图解决的问题的上下文进行了排序,算法可能会执行得非常快。如果排序后的输入用于基准测试这个特定的算法,那么它将给出一个不真实的良好性能数字,这不会真实反映其在大多数情况下的真实性能。为了处理算法对输入数据的依赖性,我们在进行性能分析时需要考虑不同类型的情况。

最佳情况

在最佳情况下,输入的数据组织方式使得算法能够发挥最佳性能。最佳情况分析给出了算法性能的上限。

最坏情况

估计算法性能的第二种方法是尝试找到在给定一组条件下完成工作所需的最长时间。算法的最坏情况分析非常有用,因为我们保证无论条件如何,算法的性能始终优于我们分析出来的数字。最坏情况分析在处理具有更大数据集的复杂问题时特别有用。最坏情况分析给出了算法性能的下限。

平均情况

这从将各种可能的输入分成各种组开始。然后,从每个组的一个代表性输入进行性能分析。最后,计算每个组的性能的平均值。

平均情况分析并不总是准确的,因为它需要考虑算法的所有不同组合和可能性,这并不总是容易做到。

选择算法

你怎么知道哪一个是更好的解决方案?你怎么知道哪个算法运行得更快?时间复杂度和大 O 符号(本章后面讨论)是回答这些问题的非常好的工具。

要看它在哪里有用,让我们举一个简单的例子,目标是对一组数字进行排序。有几种可用的算法可以完成这项工作。问题是如何选择正确的算法。

首先,可以观察到的一点是,如果列表中的数字不太多,那么我们选择哪种算法来对数字列表进行排序就无关紧要。因此,如果列表中只有 10 个数字(n=10),那么我们选择哪种算法都无关紧要,因为即使是设计非常糟糕的算法,也可能不会花费超过几微秒的时间。但是一旦列表的大小变成 100 万,现在选择正确的算法将会有所不同。一个非常糟糕的算法甚至可能需要几个小时才能运行,而一个设计良好的算法可能在几秒内完成对列表的排序。因此,对于更大的输入数据集,投入时间和精力进行性能分析,并选择正确设计的算法来高效地完成所需的工作是非常有意义的。

大 O 符号

大 O 符号用于量化各种算法的性能,随着输入规模的增长。大 O 符号是进行最坏情况分析的最流行方法之一。本节讨论了不同类型的大 O 符号。

常数时间(O(1))复杂度

如果一个算法在运行时所需的时间与输入数据的大小无关,那么它被称为以常数时间运行。它用 O(1)表示。让我们以访问数组的第 n 个元素为例。无论数组的大小如何,获取结果都需要恒定的时间。例如,以下函数将返回数组的第一个元素,并具有 O(1)的复杂度:

def getFirst(myList):
    return myList[0]

输出如下:

  • 通过使用push添加新元素到栈或使用pop从栈中移除元素。无论栈的大小如何,添加或移除元素都需要相同的时间。

  • 访问哈希表的元素(如第二章中讨论的,算法中使用的数据结构)。

  • 桶排序(如第二章中讨论的,算法中使用的数据结构)。

线性时间(O(n))复杂度

如果执行时间与输入大小成正比,则称算法具有线性时间复杂度,表示为 O(n)。一个简单的例子是在单维数据结构中添加元素:

def getSum(myList):
    sum = 0
    for item in myList:
        sum = sum + item
    return sum

请注意算法的主循环。主循环中的迭代次数随着n的增加而线性增加,产生了下图中的 O(n)复杂度:

数组操作的其他一些例子如下:

  • 搜索一个元素

  • 在数组的所有元素中找到最小值

二次时间(O(n²))复杂度

如果算法的执行时间与输入大小的平方成正比,则称算法具有二次时间复杂度;例如,一个简单的函数对二维数组求和如下:

def getSum(myList):
    sum = 0
    for row in myList:
        for item in row:
            sum += item
    return sum

请注意主循环内嵌在另一个主循环中。这个嵌套循环使得前面的代码具有 O(n²)的复杂度:

另一个例子是冒泡排序算法(如第二章中讨论的,算法中使用的数据结构)。

对数时间(O(logn))复杂度

如果算法的执行时间与输入大小的对数成正比,则称算法具有对数时间复杂度。每次迭代,输入大小都会以一个常数倍数因子减少。对数的一个例子是二分搜索。二分搜索算法用于在一维数据结构中查找特定元素,例如 Python 列表。数据结构中的元素需要按降序排序。二分搜索算法在名为searchBinary的函数中实现,如下所示:

def searchBinary(myList,item):
    first = 0
    last = len(myList)-1
    foundFlag = False
    while( first<=last and not foundFlag):
        mid = (first + last)//2
        if myList[mid] == item :
            foundFlag = True
        else:
            if item < myList[mid]:
                last = mid - 1
            else:
                first = mid + 1
    return foundFlag

主循环利用列表有序的事实。它每次迭代将列表分成一半,直到得到结果:

在定义函数之后,测试了在第 11 和 12 行搜索特定元素。二分搜索算法在第三章中进一步讨论,排序和搜索算法

请注意,在所提出的四种大 O 符号类型中,O(n²)的性能最差,O(logn)的性能最佳。事实上,O(logn)的性能可以被视为任何算法性能的黄金标准(尽管并非总是实现)。另一方面,O(n²)并不像 O(n³)那么糟糕,但是仍然,属于这一类的算法不能用于大数据,因为时间复杂度对它们能够实际处理的数据量施加了限制。

减少算法复杂度的一种方法是在准确性上做出妥协,产生一种称为近似算法的算法类型。

算法性能评估的整个过程是迭代的,如下图所示:

验证算法

验证算法确认它实际上为我们尝试解决的问题提供了数学解决方案。验证过程应该检查尽可能多的可能值和输入值类型的结果。

精确、近似和随机算法

验证算法还取决于算法的类型,因为测试技术是不同的。让我们首先区分确定性和随机算法。

对于确定性算法,特定输入总是生成完全相同的输出。但对于某些类别的算法,随机数序列也被视为输入,这使得每次运行算法时输出都不同。详见第六章中详细介绍的 k 均值聚类算法,无监督机器学习算法,就是这种算法的一个例子:

根据用于简化逻辑以使其运行更快的假设或近似,算法也可以分为以下两种类型:

  • 一种精确算法:精确算法预计能够在不引入任何假设或近似的情况下产生精确解决方案。

  • 一种近似算法:当问题复杂度对于给定资源来说太大而难以处理时,我们通过做一些假设来简化问题。基于这些简化或假设的算法称为近似算法,它并不能给出精确解决方案。

让我们看一个例子来理解精确和近似算法之间的区别——著名的旅行推销员问题,它是在 1930 年提出的。一个旅行推销员向你挑战,要求你找到一名特定推销员访问每个城市(从城市列表中)并返回原点的最短路线。首次尝试提供解决方案将包括生成所有城市的排列并选择最便宜的城市组合。这种方法提供解决方案的复杂度是 O(n!),其中n是城市的数量。显然,随着城市数量的增加,时间复杂度开始变得难以管理。

如果城市数量超过 30 个,减少复杂性的一种方法是引入一些近似和假设。

对于近似算法,在收集要求时设定准确性期望是很重要的。验证近似算法是为了验证结果的误差是否在可接受范围内。

可解释性

当算法用于关键情况时,有必要能够在需要时解释每个结果背后的原因。这是为了确保基于算法结果的决策不会引入偏见。

能够准确识别直接或间接用于做出特定决策的特征的能力被称为算法的“可解释性”。当算法用于关键用例时,需要对偏见和成见进行评估。算法的伦理分析已成为对可能影响与人们生活相关的决策的算法进行验证的标准部分。

对于处理深度学习的算法,解释性很难实现。例如,如果算法用于拒绝某人的抵押贷款申请,具有透明度和解释原因的能力就很重要。

算法的可解释性是一个活跃的研究领域。最近开发的一种有效技术是局部可解释模型无关解释LIME),该技术是在 2016 年的第 22 届计算机协会ACM知识发现和数据挖掘专业兴趣小组SIGKDD)国际会议上提出的。LIME 基于一个概念,即对每个实例的输入进行小的改变,然后努力绘制该实例的局部决策边界。它可以量化每个变量对该实例的影响。

摘要

这一章是关于学习算法的基础知识。首先,我们学习了开发算法的不同阶段。我们讨论了指定算法逻辑的不同方式,这对于设计算法是必要的。然后,我们看了如何设计算法。我们学会了分析算法性能的两种不同方式。最后,我们研究了验证算法的不同方面。

经过这一章的学习,我们应该能够理解算法的伪代码。我们应该了解开发和部署算法的不同阶段。我们还学会了如何使用大 O 符号来评估算法的性能。

下一章是关于算法中使用的数据结构。我们将首先看一下 Python 中可用的数据结构。然后我们将看看如何使用这些数据结构来创建更复杂的数据结构,比如栈、队列和树,这些都是开发复杂算法所需的。

第二章:算法中使用的数据结构

算法需要必要的内存数据结构来在执行时保存临时数据。选择合适的数据结构对于它们的高效实现至关重要。某些类别的算法是递归或迭代的逻辑,并且需要专门为它们设计的数据结构。例如,如果使用嵌套数据结构,递归算法可能更容易实现,并表现出更好的性能。在本章中,数据结构是在算法的上下文中讨论的。由于本书中使用 Python,本章重点介绍 Python 数据结构,但本章中提出的概念也可以用于其他语言,如 Java 和 C++。

在本章结束时,您应该能够理解 Python 如何处理复杂的数据结构,以及应该为某种类型的数据使用哪种数据结构。

因此,以下是本章讨论的主要要点:

  • 在 Python 中探索数据结构

  • 探索抽象数据类型

  • 栈和队列

在 Python 中探索数据结构

在任何语言中,数据结构都用于存储和操作复杂数据。在 Python 中,数据结构是存储容器,用于以高效的方式管理、组织和搜索数据。它们用于存储一组称为集合的数据元素,这些元素需要一起存储和处理。在 Python 中,有五种不同的数据结构可以用来存储集合:

  • 列表:有序的可变元素序列

  • 元组:有序的不可变元素序列

  • 集合:无序的元素集合

  • 字典:无序的键-值对集合

  • 数据框:用于存储二维数据的二维结构

让我们在接下来的小节中更详细地了解它们。

列表

在 Python 中,列表是用于存储可变序列元素的主要数据结构。存储在列表中的数据元素的序列不一定是相同类型的。

要创建一个列表,数据元素需要用[ ]括起来,并用逗号分隔。例如,以下代码创建了四个不同类型的数据元素:

>>> aList = ["John", 33,"Toronto", True]
>>> print(aList)
*['John', 33, 'Toronto', True]Ex*

在 Python 中,列表是创建一维可写数据结构的方便方式,特别是在算法的不同内部阶段需要时。

使用列表

数据结构中的实用函数使它们非常有用,因为它们可以用来管理列表中的数据。

让我们看看如何使用它们:

  • 列表索引:由于列表中元素的位置是确定的,索引可以用于获取特定位置的元素。以下代码演示了这个概念:
>>> bin_colors=['Red','Green','Blue','Yellow']
>>> bin_colors[1]
*'Green'*

此代码创建的四个元素列表如下截图所示:

请注意,索引从 0 开始,因此第二个元素Green通过索引1检索,即bin_color[1]

  • 列表切片:通过指定索引范围来检索列表的子集称为切片。以下代码可用于创建列表的切片:
>>> bin_colors=['Red','Green','Blue','Yellow']
>>> bin_colors[0:2] *['Red', 'Green']*

请注意,列表是 Python 中最受欢迎的单维数据结构之一。

在切片列表时,范围表示为:第一个数字(包括)和第二个数字(不包括)。例如,bin_colors[0:2]将包括bin_color[0]bin_color[1],但不包括bin_color[2]。在使用列表时,应该记住这一点,因为 Python 语言的一些用户抱怨这不是很直观。

让我们来看下面的代码片段:

>>> bin_colors=['Red','Green','Blue','Yellow'] >>> bin_colors[2:]
*['Blue', 'Yellow']*
>>> bin_colors[:2]
*['Red', 'Green']*

如果未指定起始索引,则表示列表的开头,如果未指定结束索引,则表示列表的结尾。前面的代码实际上演示了这个概念。

  • 负索引:在 Python 中,我们还有负索引,它们从列表的末尾开始计数。这在以下代码中得到了证明:
>>> bin_colors=['Red','Green','Blue','Yellow'] >>> bin_colors[:-1]
*['Red', 'Green', 'Blue']*
>>> bin_colors[:-2]
*['Red', 'Green']*
>>> bin_colors[-2:-1]
*['Blue']*

请注意,当我们想要使用最后一个元素作为参考点而不是第一个元素时,负索引特别有用。

  • 嵌套:列表的一个元素可以是简单数据类型或复杂数据类型。这允许在列表中进行嵌套。对于迭代和递归算法,这提供了重要的功能。

让我们来看下面的代码,这是一个列表中嵌套列表的例子(嵌套):

>>> a = [1,2,[100,200,300],6]
>>> max(a[2])
*300*
>>> a[2][1]
*200*
  • 迭代:Python 允许使用for循环来迭代列表中的每个元素。这在下面的例子中进行了演示:
>>> bin_colors=['Red','Green','Blue','Yellow']
>>> for aColor in bin_colors:
        print(aColor + " Square") Red Square
*Green Square
Blue Square
Yellow Square*

请注意,前面的代码会遍历列表并打印每个元素。

Lambda 函数

有一堆可以用在列表上的 lambda 函数。它们在算法的上下文中特别重要,并且提供了即时创建函数的能力。有时,在文献中,它们也被称为匿名函数。本节演示了它们的用法:

  • 数据过滤:要过滤数据,首先我们定义一个谓词,它是一个输入单个参数并返回布尔值的函数。下面的代码演示了它的用法:
>>> list(filter(lambda x: x > 100, [-5, 200, 300, -10, 10, 1000]))
*[200, 300, 1000]*

请注意,在这段代码中,我们使用lambda函数来过滤列表,它指定了过滤的条件。过滤函数被设计用来根据定义的条件从序列中过滤元素。Python 中的过滤函数通常与lambda一起使用。除了列表,它还可以用来从元组或集合中过滤元素。对于前面的代码,定义的条件是x > 100。代码将遍历列表的所有元素,并过滤掉不符合这个条件的元素。

  • 数据转换:可以使用map()函数来使用 lambda 函数进行数据转换。一个例子如下:
>>> list(map(lambda x: x ** 2, [11, 22, 33, 44,55]))
*[121, 484, 1089, 1936, 3025]*

使用map函数与lambda函数提供了非常强大的功能。当与map函数一起使用时,lambda函数可以用来指定一个转换器,它转换给定序列的每个元素。在前面的代码中,转换器是乘以二。因此,我们使用map函数来将列表中的每个元素乘以二。

  • 数据聚合:对于数据聚合,可以使用reduce()函数,它会递归地对列表的每个元素运行一对值的函数:
from functools import reduce
def doSum(x1,x2):
    return x1+x2
x = reduce(doSum, [100, 122, 33, 4, 5, 6])

请注意,reduce函数需要一个数据聚合函数来进行定义。在前面的代码中,数据聚合函数是functools。它定义了如何聚合给定列表的项目。聚合将从前两个元素开始,并且结果将替换前两个元素。这个缩减的过程会重复,直到达到末尾,得到一个聚合的数字。doSum函数中的x1x2代表每次迭代中的两个数字,而doSum代表它们的聚合标准。

前面的代码块会得到一个单一的值(为270)。

range 函数

range函数可以用来轻松生成一个大量的数字列表。它用于自动填充列表中的数字序列。

range函数使用简单。我们可以通过指定列表中要包含的元素数量来使用它。默认情况下,它从零开始,每次增加一个:

>>> x = range(6)
>>> x
[0,1,2,3,4,5]

我们还可以指定结束数字和步长:

>>> oddNum = range(3,29,2)
>>> oddNum
*[3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27]*

前面的 range 函数将给我们从329的奇数。

列表的时间复杂度

列表各种函数的时间复杂度可以使用大 O 符号总结如下:

不同的方法 时间复杂度
插入一个元素 O(1)
删除一个元素 O(n)(在最坏的情况下可能需要遍历整个列表)
切片列表 O(n)
元素检索 O(n)
复制 O(n)

请注意,添加单个元素所需的时间与列表的大小无关。表中提到的其他操作取决于列表的大小。随着列表的大小变大,对性能的影响变得更加显著。

元组

可以用于存储集合的第二种数据结构是元组。与列表相反,元组是不可变(只读)的数据结构。元组由( )括起来的几个元素组成。

与列表一样,元组中的元素可以是不同类型的。它们还允许为其元素使用复杂数据类型。因此,可以在元组中创建一个元组,从而提供了创建嵌套数据结构的方法。在迭代和递归算法中,创建嵌套数据结构的能力尤其有用。

以下代码演示了如何创建元组:

>>> bin_colors=('Red','Green','Blue','Yellow')
>>> bin_colors[1]
*'Green'*
>>> bin_colors[2:]
*('Blue', 'Yellow')*
>>> bin_colors[:-1]
*('Red', 'Green', 'Blue')*
# Nested Tuple Data structure
>>> a = (1,2,(100,200,300),6)
>>> max(a[2])
*300*
>>> a[2][1]
*200*

在可能的情况下,应优先选择不可变数据结构(如元组)而不是可变数据结构(如列表),因为性能更好。特别是在处理大数据时,不可变数据结构比可变数据结构要快得多。例如,更改列表中的数据元素的能力是有代价的,我们应该仔细分析是否真的需要这样做,这样我们可以将代码实现为只读元组,这将更快。

请注意,在上述代码中,a[2]指的是第三个元素,即一个元组(100,200,300)a[2][1]指的是这个元组中的第二个元素,即200

元组的时间复杂度

使用大 O 表示法,可以总结元组各种函数的时间复杂度如下:

函数 时间 复杂度
Append O(1)

请注意,Append是一个向已有元组的末尾添加元素的函数。其复杂度为 O(1)。

字典

以键值对的形式保存数据在分布式算法中尤为重要。在 Python 中,这些键值对的集合被存储为一种称为字典的数据结构。要创建字典,应选择一个最适合在整个数据处理过程中标识数据的属性作为键。值可以是任何类型的元素,例如数字或字符串。Python 还总是使用列表等复杂数据类型作为值。可以通过使用字典作为值的数据类型来创建嵌套字典。

要创建一个简单的字典,将键值对放在{ }中。例如,以下代码创建了一个由三个键值对组成的简单字典:

>>> bin_colors ={
 "manual_color": "Yellow",
 "approved_color": "Green",
 "refused_color": "Red"
 }
>>> print(bin_colors) *{'manual_color': 'Yellow', 'approved_color': 'Green', 'refused_color': 'Red'}*

前面代码创建的三个键值对也在以下截图中说明:

现在,让我们看看如何检索和更新与键相关联的值:

  1. 要检索与键关联的值,可以使用get函数,也可以使用键作为索引:
>>> bin_colors.get('approved_color')
*'Green'* >>> bin_colors['approved_color']
*'Green'*
  1. 要更新与键关联的值,请使用以下代码:
>>> bin_colors['approved_color']="Purple"
>>> print(bin_colors) *{'manual_color': 'Yellow', 'approved_color': 'Purple', 'refused_color': 'Red'}*

请注意,前面的代码显示了如何更新与字典中特定键相关联的值。

字典的时间复杂度

以下表格给出了使用大 O 表示法的字典的时间复杂度:

字典 时间 复杂度
获取值或键 O(1)
设置值或键 O(1)
复制字典 O(n)

从字典的复杂度分析中重要的一点是,获取或设置键值的时间与字典的大小完全独立。这意味着在大小为三的字典中添加键值对所需的时间与在大小为一百万的字典中添加键值对所需的时间相同。

集合

集合被定义为可以是不同类型的元素的集合。元素被包含在{ }中。例如,看一下以下代码块:

>>> green = {'grass', 'leaves'}
>>> print(green)
{'grass', 'leaves'}

集合的定义特征是它只存储每个元素的不同值。如果我们尝试添加另一个冗余元素,它将忽略该元素,如下所示:

>>> green = {'grass', 'leaves','leaves'}
>>> print(green)
*{'grass', 'leaves'}*

为了演示可以在集合上执行的操作类型,让我们定义两个集合:

  • 一个名为 yellow 的集合,其中包含黄色的东西

  • 另一个名为 red 的集合,其中包含红色的东西

请注意,这两个集合之间有一些共同之处。这两个集合及其关系可以用以下维恩图表示:

如果我们想要在 Python 中实现这两个集合,代码将如下所示:

>>> yellow = *{'dandelions', 'fire hydrant', 'leaves'}
>>> red =* *{'fire hydrant', 'blood', 'rose', 'leaves'}*

现在,让我们考虑以下代码,演示了使用 Python 进行集合操作:

>>> yellow|red
*{'dandelions', 'fire hydrant', 'blood', 'rose', 'leaves'}*
>>> yellow&red
*{'fire hydrant'}*

如前面的代码片段所示,Python 中的集合可以进行联合和交集等操作。正如我们所知,联合操作将合并两个集合的所有元素,而交集操作将给出两个集合之间的共同元素集合。请注意以下内容:

  • yellow|red用于获取前面两个定义的集合的并集。

  • yellow&red用于获取黄色和红色之间的重叠部分。

集合的时间复杂度分析

以下是集合的时间复杂度分析:

集合 复杂度
添加一个元素 O(1)
删除一个元素 O(1)
复制 O(n)

从集合的复杂度分析中重要的一点是,添加一个元素所需的时间完全独立于特定集合的大小。

数据框

数据框是一种用于存储 Python 的pandas包中可用的表格数据的数据结构。它是算法中最重要的数据结构之一,用于处理传统的结构化数据。让我们考虑以下表格:

id name age decision
1 费尔斯 32
2 艾琳娜 23
3 史蒂文 40

现在,让我们使用数据框来表示这一点。

可以使用以下代码创建一个简单的数据框:

>>> import pandas as pd
>>> df = pd.DataFrame([
...             ['1', 'Fares', 32, True],
...             ['2', 'Elena', 23, False],
...             ['3', 'Steven', 40, True]])
>>> df.columns = ['id', 'name', 'age', 'decision']
>>> df
 *id    name  age  decision
0  1   Fares   32      True
1  2   Elena   23     False
2  3  Steven   40      True*

请注意,在上述代码中,df.column是一个指定列名称的列表。

数据框也用于其他流行的语言和框架中来实现表格数据结构。例如 R 和 Apache Spark 框架。

数据框的术语

让我们来看一些在数据框的上下文中使用的术语:

  • :在 pandas 文档中,数据框的单个列或行称为一个轴。

  • :如果有多个轴,则将它们作为一个组称为轴。

  • 标签:数据框允许使用所谓的标签对列和行进行命名。

创建数据框的子集

从根本上讲,有两种主要方法可以创建数据框的子集(假设子集的名称为myDF):

  • 列选择

  • 行选择

让我们依次看一下。

列选择

在机器学习算法中,选择正确的特征集是一项重要的任务。在我们可能拥有的所有特征中,不一定所有特征在算法的特定阶段都是必需的。在 Python 中,通过列选择来实现特征选择,这在本节中有所解释。

可以按名称检索列,如下所示:

>>> df[['name','age']]
 *name  age
0   Fares   32
1   Elena   23
2  Steven   40*

数据框中列的位置是确定的。可以按其位置检索列,如下所示:

>>> df.iloc[:,3] 
*0 True*
*1 False*
*2 True*

请注意,在此代码中,我们正在检索数据框的前三行。

行选择

数据框中的每一行对应于问题空间中的一个数据点。如果我们想要创建问题空间中的数据元素的子集,我们需要执行行选择。可以使用以下两种方法之一来创建这个子集:

  • 通过指定它们的位置

  • 通过指定过滤器

可以按照其位置检索数据框的子集行,如下所示:

>>> df.iloc[1:3,:]
 *id name age decision*
*1 2 Elena 23 False*
*2 3 Steven 40 True*

请注意,上述代码将返回数据框的前两行和所有列。

通过指定过滤器创建子集,我们需要使用一个或多个列来定义选择条件。例如,可以通过以下方法选择数据元素的子集:

>>> df[df.age>30]
 *id    name  age  decision
0  1   Fares   32      True
2  3  Steven   40      True

**>>> df[(df.age<35)&(df.decision==True)]***  id   name  age  decision
0  1  Fares   32      True

请注意,此代码创建了满足过滤器中规定条件的行的子集。

矩阵

矩阵是一个具有固定列数和行数的二维数据结构。矩阵的每个元素可以通过其列和行来引用。

在 Python 中,可以使用numpy数组创建矩阵,如下代码所示:

>>> myMatrix = np.array([[11, 12, 13], [21, 22, 23], [31, 32, 33]]) 
>>> print(myMatrix) 
*[[11 12 13]* 
*[21 22 23]* 
*[31 32 33]]*
>>> print(type(myMatrix))
*<class 'numpy.ndarray'>*

请注意,上述代码将创建一个具有三行三列的矩阵。

矩阵操作

矩阵数据操作有许多可用的操作。例如,让我们尝试转置上述矩阵。我们将使用transpose()函数,它将列转换为行,行转换为列:

>>> myMatrix.transpose()
*array([[11, 21, 31],* 
 *[12, 22, 32],* 
 *[13, 23, 33]])*

请注意,矩阵操作在多媒体数据处理中经常使用。

现在我们已经了解了 Python 中的数据结构,让我们在下一节中转向抽象数据类型。

探索抽象数据类型

抽象,一般来说,是一个用于以其共同核心功能来定义复杂系统的概念。使用这个概念来创建通用数据结构,产生了抽象数据类型ADT)。通过隐藏实现级别的细节并为用户提供一个通用的、与实现无关的数据结构,ADT 的使用创建了产生更简单和更清晰代码的算法。ADT 可以在任何编程语言中实现,如 C++、Java 和 Scala。在本节中,我们将使用 Python 来实现 ADT。让我们首先从向量开始。

向量

向量是一种用于存储数据的单维结构。它们是 Python 中最受欢迎的数据结构之一。在 Python 中有两种创建向量的方式如下:

  • 使用 Python 列表:创建向量的最简单方法是使用 Python 列表,如下所示:
>>> myVector = [22,33,44,55]
>>> print(myVector) 
*[22 33 44 55]*
>>> print(type(myVector))
*<class 'list'>*

请注意,此代码将创建一个包含四个元素的列表。

  • 使用numpy数组:创建向量的另一种流行方法是使用 NumPy 数组,如下所示:
>>> myVector = np.array([22,33,44,55]) 
>>> print(myVector) 
*[22 33 44 55]*
>>> print(type(myVector))
*<class 'numpy.ndarray'>*

请注意,我们在此代码中使用np.array创建了myVector

在 Python 中,我们可以使用下划线表示整数以分隔部分。这样可以使它们更易读,减少出错的可能性。在处理大数字时尤其有用。因此,十亿可以表示为 a=1

堆栈

堆栈是一种线性数据结构,用于存储一维列表。它可以以后进先出LIFO)或先进后出FILO)的方式存储项目。堆栈的定义特征是元素的添加和移除方式。新元素被添加到一端,只能从该端移除一个元素。

以下是与堆栈相关的操作:

  • isEmpty: 如果堆栈为空则返回 true

  • push: 添加一个新元素

  • pop: 返回最近添加的元素并将其删除

以下图表显示了如何使用 push 和 pop 操作向堆栈添加和删除数据:

上图的顶部显示了使用 push 操作向堆栈添加项目。在步骤1.11.21.3中,push 操作被用于三次向堆栈添加三个元素。上图的底部用于从堆栈中检索存储的值。在步骤2.22.3中,pop 操作被用于以 LIFO 格式从堆栈中检索两个元素。

让我们在 Python 中创建一个名为Stack的类,我们将在其中定义与堆栈类相关的所有操作。该类的代码如下:

class Stack:
     def __init__(self):
         self.items = []
     def isEmpty(self):
         return self.items == []
     def push(self, item):
         self.items.append(item)
     def pop(self):
         return self.items.pop()
     def peek(self):
         return self.items[len(self.items)-1]
     def size(self):
         return len(self.items)

要向堆栈推送四个元素,可以使用以下代码:

注意,上述代码创建了一个包含四个数据元素的堆栈。

堆栈的时间复杂度

让我们来看看堆栈的时间复杂度(使用大 O 表示法):

操作 时间复杂度
push O(1)
pop O(1)
size O(1)
peek O(1)

需要注意的一点是,前面表中提到的四种操作的性能都不取决于栈的大小。

实际例子

栈在许多用例中用作数据结构。例如,当用户想要在 Web 浏览器中浏览历史记录时,这是一种 LIFO 数据访问模式,可以使用栈来存储历史记录。另一个例子是当用户想要在文字处理软件中执行“撤销”操作时。

队列

和栈一样,队列将n个元素存储在单维结构中。元素以FIFO格式添加和移除。队列的一端称为 后端,另一端称为 前端。当元素从前端移除时,该操作称为 出队。当元素在后端添加时,该操作称为 入队

在下图中,顶部部分显示了入队操作。步骤 1.1,1.2 和 1.3 将三个元素添加到队列中,结果队列显示在 1.4 中。请注意,黄色后端红色前端

下图的底部部分显示了一个出队操作。步骤 2.2,2.3 和 2.4 依次从队列的前端一个接一个地移除元素:

前面图中显示的队列可以通过以下代码实现:

class Queue(object):
   def __init__(self):
      self.items = []
   def isEmpty(self):
      return self.items == []
   def enqueue(self, item):
       self.items.insert(0,item)
   def dequeue(self):
      return self.items.pop()
   def size(self):
      return len(self.items)

让我们根据以下截图,按照前面的图示进行入队和出队操作:

请注意,前面的代码首先创建一个队列,然后将四个项目入队。

使用栈和队列的基本思想

让我们用一个类比来了解使用栈和队列的基本思想。假设我们有一张桌子,我们把我们从邮政服务收到的信放在上面,例如,加拿大邮件。我们堆积起来,直到有时间逐一打开和查看信件。有两种可能的做法:

  • 我们把信放在一个栈里,每当我们收到一封新信时,我们把它放在栈的顶部。当我们想读一封信时,我们从顶部开始。这就是我们所说的 。请注意,最新的信件将位于顶部,并且将首先被处理。从列表顶部取出一封信称为 弹出 操作。每当有新信到达时,把它放在顶部称为 推入 操作。如果我们最终有一个相当大的栈,并且有大量信件不断到达,有可能我们永远没有机会到达等待我们的非常重要的信件。

  • 我们把信放在一堆里,但我们想先处理最老的信:每次我们想看一个或多个信时,我们要确保先处理最老的那个。这就是我们所说的 队列。把一封信放到一堆里叫做 入队 操作。从一堆里取出一封信叫做 出队 操作。

在算法的上下文中,树是最有用的数据结构之一,因为它具有分层数据存储能力。在设计算法时,我们使用树来表示我们需要存储或处理的数据元素之间的分层关系。

让我们更深入地了解这个有趣且非常重要的数据结构。

每棵树都有一个有限的节点集,因此它有一个称为 的起始数据元素和一组由链接连接在一起的称为 分支 的节点。

术语

让我们来看一些与树数据结构相关的术语:

根节点 没有父节点的节点称为 节点。例如,在下图中,根节点是 A。在算法中,通常根节点保存树结构中最重要的值。
节点的级别 从根节点到节点的距离就是节点的级别。例如,在下图中,节点DEF的级别为 2。
兄弟节点 树中的两个节点如果在同一级别,则称为兄弟节点。例如,如果查看下图,节点BC是兄弟节点。
子节点和父节点 如果节点C和节点F直接连接,并且节点C的级别低于节点F,那么节点F是节点C的子节点。反之,节点C是节点F的父节点。下图中的节点CF展示了这种父子关系。
节点的度 节点的度是它拥有的子节点数量。例如,在下图中,节点B的度为 2。
树的度 树的度等于树的组成节点中可以找到的最大度。例如,下图中呈现的树的度为 2。
子树 树的子树是树的一部分,选择的节点是子树的根节点,所有子节点是树的节点。例如,在下图中呈现的树的节点E的子树包括节点E作为根节点和节点GH作为两个子节点。
叶节点 树中没有子节点的节点称为叶节点。例如,在下图中,DGHF是四个叶节点。
内部节点 既不是根节点也不是叶节点的任何节点都是内部节点。内部节点至少有一个父节点和至少一个子节点。

请注意,树是我们将在第六章中学习的网络或图的一种,无监督机器学习算法。对于图和网络分析,我们使用术语链接或边而不是分支。大多数其他术语保持不变。

树的类型

树有不同的类型,如下所述:

  • 二叉树: 如果一棵树的度为 2,则称该树为二叉树。例如,下图中呈现的树是一棵二叉树,因为它的度为 2:

请注意,前图显示了一个有四个级别和八个节点的树。

  • 满树: 所有节点的度都相同的树称为满树,其度将等于树的度。下图展示了前面讨论过的树的类型:

请注意,左侧的二叉树不是一棵满树,因为节点C的度为 1,而所有其他节点的度为 2。中间的树和左侧的树都是满树。

  • 完美树: 完美树是一种特殊类型的满树,其中所有叶节点都在同一级别。例如,右侧的二叉树如前图所示是一棵完美的满树,因为所有叶节点都在同一级别,即级别 2

  • 有序树: 如果节点的子节点根据特定标准有序排列,那么树被称为有序树。例如,树可以按照从左到右的升序顺序进行排序,同一级别的节点在从左到右遍历时值会增加。

实际例子

树是一种主要的数据结构之一,在开发决策树中被使用,将在第七章 传统监督学习算法中讨论。由于其分层结构,它在与网络分析相关的算法中也很受欢迎,将在第六章 无监督机器学习算法中详细讨论。树还被用于各种搜索和排序算法,其中需要实现分而治之的策略。

摘要

在本章中,我们讨论了可以用来实现各种类型算法的数据结构。通过阅读本章,我期望你能够选择合适的数据结构来存储和处理算法的数据。你还应该能够理解我们选择对算法性能的影响。

下一章是关于排序和搜索算法,我们将在算法的实现中使用本章介绍的一些数据结构。

第三章:排序和搜索算法

在本章中,我们将看一下用于排序和搜索的算法。这是一类重要的算法,可以单独使用,也可以成为更复杂算法的基础(在本书的后面章节中介绍)。本章首先介绍了不同类型的排序算法。它比较了各种设计排序算法的方法的性能。然后,详细介绍了一些搜索算法。最后,探讨了本章介绍的排序和搜索算法的一个实际例子。

在本章结束时,您将能够理解用于排序和搜索的各种算法,并能够理解它们的优势和劣势。由于搜索和排序算法是大多数更复杂算法的基础,详细了解它们将有助于您理解现代复杂算法。

以下是本章讨论的主要概念:

  • 介绍排序算法

  • 介绍搜索算法

  • 一个实际的例子

让我们首先看一些排序算法。

介绍排序算法

在大数据时代,能够高效地对复杂数据结构中的项目进行排序和搜索是非常重要的,因为许多现代算法都需要这样做。正确的排序和搜索数据的策略将取决于数据的大小和类型,正如本章所讨论的。虽然最终结果是完全相同的,但对于实际问题的高效解决方案,需要正确的排序和搜索算法。

本章介绍了以下排序算法:

  • 冒泡排序

  • 归并排序

  • 插入排序

  • 希尔排序

  • 选择排序

在 Python 中交换变量

在实现排序和搜索算法时,我们需要交换两个变量的值。在 Python 中,有一种简单的交换两个变量的方式,如下所示:

var1 = 1 
var2 = 2 
var1,var2 = var2,var1
>>> print (var1,var2)
>>> 2 1

让我们看看它是如何工作的:

在本章中,这种简单的交换值的方式在排序和搜索算法中被广泛使用。

让我们从下一节开始看冒泡排序算法。

冒泡排序

冒泡排序是用于排序的最简单和最慢的算法。它被设计成这样,列表中的最高值会在算法循环迭代时冒泡到顶部。正如之前讨论的,它的最坏情况性能是 O(N²),因此应该用于较小的数据集。

理解冒泡排序背后的逻辑

冒泡排序基于各种迭代,称为通行。对于大小为N的列表,冒泡排序将有N-1 个通行。让我们专注于第一次迭代:第一遍。

第一遍的目标是将最高的值推到列表的顶部。随着第一遍的进行,我们会看到列表中最高的值冒泡到顶部。

冒泡排序比较相邻的邻居值。如果较高位置的值比较低索引处的值要大,我们交换这些值。这种迭代会一直持续,直到我们到达列表的末尾。如下图所示:

现在让我们看看如何使用 Python 实现冒泡排序:

#Pass 1 of Bubble Sort
lastElementIndex = len(list)-1 
print(0,list) 
for idx in range(lastElementIndex):                 
                    if list[idx]>list[idx+1]:                                                                             list[idx],list[idx+1]=list[idx+1],list[idx]                                         
print(idx+1,list)

如果我们在 Python 中实现冒泡排序的第一遍,它将如下所示:

第一遍完成后,最高的值在列表的顶部。算法接下来进行第二遍。第二遍的目标是将第二高的值移动到列表中第二高的位置。为了做到这一点,算法将再次比较相邻的邻居值,如果它们不按顺序,则交换它们。第二遍将排除顶部元素,它已经被第一遍放在正确的位置上,不需要再次触摸。

完成第二次通行证后,算法将继续执行第三次通行证,直到列表的所有数据点按升序排列。算法将需要N-1 次通行证来完全对大小为N的列表进行排序。Python 中冒泡排序的完整实现如下:

现在让我们来看一下BubbleSort算法的性能。

冒泡排序的性能分析

很容易看出,冒泡排序涉及两个级别的循环:

  • 外部循环:这也被称为通行证。例如,通行证一是外部循环的第一次迭代。

  • 内部循环:这是当列表中剩余的未排序元素被排序时,直到最大值被冒泡到右侧。第一次通行证将有N-1 次比较,第二次通行证将有N-2 次比较,每次后续通行证将减少一次比较。

由于两个级别的循环,最坏情况下的运行时复杂度将是 O(n²)。

插入排序

插入排序的基本思想是,在每次迭代中,我们从数据结构中移除一个数据点,然后将其插入到正确的位置。这就是为什么我们称之为插入排序算法。在第一次迭代中,我们选择两个数据点并对它们进行排序。然后,我们扩展我们的选择并选择第三个数据点,并根据其值找到其正确的位置。算法进行到所有数据点都移动到它们的正确位置。这个过程如下图所示:

插入排序算法可以在 Python 中编写如下:

def InsertionSort(list):        
    for i in range(1, len(list)):             
        j = i-1             
        element_next = list[i]             

        while (list[j] > element_next) and (j >= 0):                 
            list[j+1] = list[j]                 
            j=j-1                 
        list[j+1] = element_next
    return list

请注意,在主循环中,我们遍历整个列表。在每次迭代中,两个相邻的元素分别是list[j](当前元素)和list[i](下一个元素)。

list[j] > element_nextj >= 0中,我们将当前元素与下一个元素进行比较。

让我们使用这段代码来对数组进行排序:

让我们来看一下插入排序算法的性能。

从算法描述中很明显,如果数据结构已经排序,插入排序将执行得非常快。实际上,如果数据结构已排序,那么插入排序将具有线性运行时间;即 O(n)。最坏情况是每个内部循环都必须移动列表中的所有元素。如果内部循环由i定义,插入排序算法的最坏情况性能如下所示:

总通行证数量如下图所示:

一般来说,插入可以用于小型数据结构。对于较大的数据结构,由于二次平均性能,不建议使用插入排序。

归并排序

到目前为止,我们已经介绍了两种排序算法:冒泡排序和插入排序。如果数据部分排序,它们的性能都会更好。本章介绍的第三种算法是归并排序算法,它是由约翰·冯·诺伊曼于 1940 年开发的。该算法的特点是其性能不依赖于输入数据是否排序。与 MapReduce 和其他大数据算法一样,它基于分而治之的策略。在第一阶段,称为拆分,算法继续递归地将数据分成两部分,直到数据的大小小于定义的阈值。在第二阶段,称为合并,算法继续合并和处理,直到我们得到最终结果。该算法的逻辑如下图所示:

让我们首先看一下归并排序算法的伪代码:

mergeSort(list, start, end) 
    if(start < end) 
        midPoint = (end - start) / 2 + start           
        mergeSort(list, start, midPoint)             
        mergeSort(list, midPoint + 1, start)         
        merge(list, start, midPoint, end) 

正如我们所看到的算法有以下三个步骤:

  1. 它将输入列表分成两个相等的部分

  2. 它使用递归分割,直到每个列表的长度为 1

  3. 然后,它将排序好的部分合并成一个排序好的列表并返回它

实现MergeSort的代码如下所示:

当运行上述 Python 代码时,它会生成以下输出:

请注意,代码的结果是一个排序好的列表。

谢尔排序

冒泡排序算法比较相邻的元素,如果它们的顺序不正确,则交换它们。如果我们有一个部分排序的列表,冒泡排序应该会有合理的性能,因为它会在循环中不再发生元素交换时立即退出。

但对于完全无序的大小为N的列表,你可以说冒泡排序将不得不完全迭代N-1 次才能完全排序它。

唐纳德·谢尔提出了谢尔排序(以他的名字命名),质疑了选择相邻元素进行比较和交换的重要性。

现在,让我们理解这个概念。

在第一次通过中,我们不是选择相邻的元素,而是使用固定间隔的元素,最终对由一对数据点组成的子列表进行排序。如下图所示。在第二次通过中,它对包含四个数据点的子列表进行排序(见下图)。在后续的通过中,每个子列表中的数据点数量不断增加,子列表的数量不断减少,直到我们达到只有一个包含所有数据点的子列表的情况。此时,我们可以假设列表已排序:

在 Python 中,实现谢尔排序算法的代码如下所示:

def ShellSort(list):     
    distance = len(list) // 2     
    while distance > 0:         
        for i in range(distance, len(list)):             
            temp = input_list[i]             
            j = i 
# Sort the sub list for this distance 
           while j >= distance and list[j - distance] > temp: 
              list[j] = list[j - distance] 
              j = j-distance            
          list[j] = temp 
# Reduce the distance for the next element         
        distance = distance//2
    return list

上述代码可以用于对列表进行排序,如下所示:

请注意,调用ShellSort函数已导致对输入数组进行排序。

谢尔排序的性能分析

谢尔排序不适用于大数据。它用于中等大小的数据集。粗略地说,它在具有多达 6000 个元素的列表上具有相当不错的性能。如果数据部分处于正确的顺序,性能会更好。在最佳情况下,如果列表已经排序,它只需要通过N个元素进行一次验证,产生O(N)的最佳性能。

选择排序

正如我们在本章前面看到的,冒泡排序是最简单的排序算法之一。选择排序是对冒泡排序的改进,我们试图最小化算法所需的总交换次数。它旨在使每次通过只进行一次交换,而不是冒泡排序算法的N-1 次通过。我们不是像冒泡排序中那样逐步将最大值向顶部冒泡(导致N-1 次交换),而是在每次通过中寻找最大值并将其向顶部移动。因此,在第一次通过后,最大值将位于顶部。第二次通过后,第二大的值将位于顶部值旁边。随着算法的进行,后续的值将根据它们的值移动到它们的正确位置。最后一个值将在第(N-1)次通过后移动。因此,选择排序需要N-1 次通过来排序N个项目:

Python 中选择排序的实现如下所示:

def SelectionSort(list):     
    for fill_slot in range(len(list) - 1, 0, -1):         
        max_index = 0         
        for location in range(1, fill_slot + 1):             
            if list[location] > list[max_index]:                 
                max_index = location         
        list[fill_slot],list[max_index] = list[max_index],list[fill_slot]

当选择排序算法被执行时,它将产生以下输出:

请注意,最终输出是排序好的列表。

选择排序算法的性能

选择排序的最坏情况性能是O(N**²)。注意,它的最坏性能类似于冒泡排序,不应该用于对更大的数据集进行排序。尽管如此,选择排序比冒泡排序更好设计,并且由于交换次数的减少,其平均性能比冒泡排序更好。

选择排序算法

选择正确的排序算法取决于当前输入数据的大小和状态。对于已排序的小输入列表,使用高级算法会在代码中引入不必要的复杂性,而性能改善微乎其微。例如,对于较小的数据集,我们不需要使用归并排序。冒泡排序会更容易理解和实现。如果数据部分排序,我们可以利用插入排序。对于较大的数据集,归并排序算法是最好的选择。

搜索算法简介

在复杂数据结构中高效地搜索数据是最重要的功能之一。最简单的方法是在每个数据点中搜索所需的数据,但随着数据规模的增大,我们需要更复杂的为搜索数据设计的算法。

本节介绍了以下搜索算法:

  • 线性搜索

  • 二分搜索

  • 插值搜索

让我们更详细地看看它们每一个。

线性搜索

搜索数据的最简单策略之一是简单地循环遍历每个元素寻找目标。每个数据点都被搜索匹配,当找到匹配时,结果被返回并且算法退出循环。否则,算法会继续搜索直到达到数据的末尾。线性搜索的明显缺点是由于固有的穷举搜索,它非常慢。优点是数据不需要像本章介绍的其他算法那样需要排序。

让我们来看一下线性搜索的代码:

def LinearSearch(list, item):     
    index = 0     
    found = False 
# Match the value with each data element     
    while index < len(list) and found is False:         
        if list[index] == item:             
            found = True         
    else:             
        index = index + 1     
  return found

现在让我们来看一下前面代码的输出:

注意,运行LinearSearch函数会在成功找到数据时返回True值。

线性搜索的性能

正如讨论的那样,线性搜索是一种执行穷举搜索的简单算法。它的最坏情况行为是O(N)

二分搜索

二分搜索算法的先决条件是排序数据。该算法迭代地将列表分成两部分,并跟踪最低和最高索引,直到找到所寻找的值:

def BinarySearch(list, item): 
   first = 0 
   last = len(list)-1 
   found = False 

while first<=last and not found:         
    midpoint = (first + last)//2         
    if list[midpoint] == item:             
        found = True         
    else:             
        if item < list[midpoint]:                 
            last = midpoint-1             
        else:                 
            first = midpoint+1     
return found

输出如下:

注意,调用BinarySearch函数将在输入列表中找到值时返回True

二分搜索的性能

二分搜索之所以被这样命名,是因为在每次迭代中,算法将数据分成两部分。如果数据有N个项目,迭代最多需要 O(logN)步。这意味着算法具有O(logN)的运行时间。

插值搜索

二分搜索基于将重点放在数据的中间部分的逻辑。插值搜索更加复杂。它使用目标值来估计已排序数组中元素的位置。让我们通过一个例子来理解它。假设我们想在英语词典中搜索一个单词,比如river。我们将利用这些信息进行插值,并开始搜索以r开头的单词。更一般化的插值搜索可以编程如下:


def IntPolsearch(list,x ):     
    idx0 = 0     
    idxn = (len(list) - 1)     
    found = False     
    while idx0 <= idxn and x >= list[idx0] and x <= list[idxn]: 
    # Find the mid point         
         mid = idx0 +int(((float(idxn - idx0)/( list[idxn] - list[idx0])) * ( x - list[idx0]))) 
 # Compare the value at mid point with search value 
         if list[mid] == x: 
            found = True             
            return found         
    if list[mid] < x:             
            idx0 = mid + 1     
return found

输出如下:

注意,在使用IntPolsearch之前,数组首先需要使用排序算法进行排序。

插值搜索的性能

如果数据分布不均匀,插值搜索算法的性能将很差。该算法的最坏情况性能为O(N),如果数据相对均匀,最佳性能为 O(log(log N))。

实际应用

在给定数据存储库中高效准确地搜索数据对许多现实生活应用至关重要。根据您选择的搜索算法,您可能需要首先对数据进行排序。选择正确的排序和搜索算法将取决于数据的类型和大小,以及您试图解决的问题的性质。

让我们尝试使用本章介绍的算法来解决某个国家移民局新申请人与历史记录匹配的问题。当有人申请签证进入该国时,系统会尝试将申请人与现有的历史记录进行匹配。如果至少找到一个匹配项,那么系统会进一步计算个人过去被批准或拒绝的次数。另一方面,如果没有找到匹配项,系统会将申请人分类为新申请人,并为其发放新的标识符。在历史数据中搜索、定位和识别个人的能力对系统至关重要。这些信息很重要,因为如果某人过去曾申请过并且已知申请被拒绝,那么这可能会对该个人当前的申请产生负面影响。同样,如果某人的申请过去已知被批准,那么这个批准可能会增加该个人当前申请获批准的机会。通常,历史数据库将有数百万行数据,我们需要一个精心设计的解决方案来将新申请人与历史数据库进行匹配。

假设数据库中的历史表如下所示:

个人 ID 申请 ID 名字 姓氏 出生日期 决定 决定日期
45583 677862 约翰 2000-09-19 已批准 2018-08-07
54543 877653 Xman Xsir 1970-03-10 被拒绝 2018-06-07
34332 344565 阿格罗 瓦卡 1973-02-15 被拒绝 2018-05-05
45583 677864 约翰 2000-09-19 已批准 2018-03-02
22331 344553 卡尔 索茨 1975-01-02 已批准 2018-04-15

在这个表中,第一列“个人 ID”与历史数据库中的每个唯一申请人相关联。如果历史数据库中有 3000 万个唯一申请人,那么将有 3000 万个唯一的个人 ID。每个个人 ID 标识历史数据库系统中的一个申请人。

第二列是“申请 ID”。每个申请 ID 标识系统中的一个唯一申请。一个人过去可能申请过多次。这意味着在历史数据库中,我们将有比个人 ID 更多的唯一申请 ID。如上表所示,约翰·多只有一个个人 ID,但有两个申请 ID。

上表仅显示了历史数据集的一部分样本。假设我们的历史数据集中有接近 100 万行数据,其中包含过去 10 年申请人的记录。新申请人以每分钟约 2 人的平均速度持续到达。对于每个申请人,我们需要执行以下操作:

  • 为申请人发放新的申请 ID。

  • 查看历史数据库中是否有与申请人匹配的记录。

  • 如果找到匹配项,则使用历史数据库中找到的个人 ID。我们还需要确定在历史数据库中申请已被批准或拒绝的次数。

  • 如果没有找到匹配项,那么我们需要为该个人发放新的个人 ID。

假设一个新的人员带着以下的证件到达:

  • “名字”: “约翰”

  • 姓氏:

  • 出生日期2000-09-19

现在,我们如何设计一个能够执行高效和具有成本效益的搜索的应用程序呢?

搜索数据库中新申请的一个策略可以设计如下:

  • 出生日期对历史数据库进行排序。

  • 每次有新人到来时,都要为申请人发放新的申请 ID。

  • 获取所有与该出生日期匹配的记录。这将是主要搜索。

  • 在出现匹配项的记录中,使用名字和姓氏进行次要搜索。

  • 如果找到匹配项,请使用个人 ID来引用申请人。计算批准和拒绝的次数。

  • 如果找不到匹配项,请为申请人发放新的个人 ID。

让我们尝试选择正确的算法来对历史数据库进行排序。我们可以安全地排除冒泡排序,因为数据量很大。希尔排序将表现更好,但仅当我们有部分排序的列表时。因此,归并排序可能是对历史数据库进行排序的最佳选择。

当有新人到来时,我们需要在历史数据库中定位并搜索该人。由于数据已经排序,可以使用插值搜索或二分搜索。因为申请人可能根据出生日期均匀分布,所以可以安全地使用二分搜索。

最初,我们基于出生日期进行搜索,这将返回一组共享相同出生日期的申请人。现在,我们需要在共享相同出生日期的小子集中找到所需的人。由于我们已成功将数据减少到一个小子集,任何搜索算法,包括冒泡排序,都可以用于搜索申请人。请注意,我们在这里稍微简化了次要搜索问题。如果找到多个匹配项,我们还需要通过汇总搜索结果来计算批准和拒绝的总数。

在现实场景中,每个个体都需要在次要搜索中使用一些模糊搜索算法进行识别,因为名字可能拼写略有不同。搜索可能需要使用某种距离算法来实现模糊搜索,其中相似度高于定义的阈值的数据点被视为相同。

总结

在本章中,我们介绍了一组排序和搜索算法。我们还讨论了不同排序和搜索算法的优缺点。我们量化了这些算法的性能,并学会了何时使用每个算法。

在下一章中,我们将学习动态算法。我们还将研究设计算法的实际示例以及页面排名算法的细节。最后,我们将学习线性规划算法。

第四章:设计算法

本章介绍了各种算法的核心设计概念。它讨论了设计算法的各种技术的优缺点。通过理解这些概念,您将学会如何设计高效的算法。

本章首先讨论了在设计算法时可用的不同选择。然后,它讨论了表征我们试图解决的特定问题的重要性。接下来,它以著名的“旅行推销员问题”(TSP)为案例,并应用我们将要介绍的不同设计技术。然后,它介绍了线性规划并讨论了其应用。最后,它介绍了线性规划如何用于解决现实世界问题。

通过本章结束时,您应该能够理解设计高效算法的基本概念。

本章讨论了以下概念:

  • 设计算法的各种方法

  • 了解选择算法正确设计所涉及的权衡

  • 制定现实世界问题的最佳实践

  • 解决现实世界的优化问题

让我们首先看一下设计算法的基本概念。

介绍设计算法的基本概念

根据美国传统词典的定义,算法如下:

“一组有限的明确指令,给定一些初始条件,可以按照规定的顺序执行,以实现特定目标,并具有可识别的一组结束条件。”

设计算法是为了以最有效的方式提出这个“一组有限的明确指令”来“实现特定目标”。对于一个复杂的现实世界问题,设计算法是一项繁琐的任务。为了提出一个良好的设计,我们首先需要充分了解我们试图解决的问题。我们首先需要弄清楚需要做什么(即了解需求)然后再看如何做(即设计算法)。了解问题包括解决问题的功能和非功能性需求。让我们看看这些是什么:

  • 功能性需求正式指定了我们要解决的问题的输入和输出接口以及与之相关的功能。功能性需求帮助我们理解数据处理、数据操作和需要实施的计算,以生成结果。

  • 非功能性需求设置了算法性能和安全方面的期望。

请注意,设计算法是在给定一组情况下以最佳方式解决功能和非功能性需求,并考虑到运行设计算法的可用资源。

为了提出一个能够满足功能和非功能需求的良好响应,我们的设计应该尊重以下三个关注点,如第一章《算法概述》中所讨论的:

  • 关注点 1:设计的算法是否能产生我们期望的结果?

  • 关注点 2:这是否是获得这些结果的最佳方式?

  • 关注点 3:算法在更大数据集上的表现如何?

在本节中,让我们逐一看看这些关注点。

关注点 1 - 设计的算法是否能产生我们期望的结果?

算法是对现实世界问题的数学解决方案。为了有用,它应该产生准确的结果。如何验证算法的正确性不应该是事后想到的事情;相反,它应该融入到算法的设计中。在制定如何验证算法之前,我们需要考虑以下两个方面:

  • 定义真相:为了验证算法,我们需要一些已知的给定输入的正确结果。在我们试图解决的问题的上下文中,这些已知的正确结果被称为真相。真相很重要,因为在我们迭代地努力将算法演变为更好的解决方案时,它被用作参考。

  • 选择度量标准:我们还需要考虑如何量化与定义真相的偏差。选择正确的度量标准将帮助我们准确量化算法的质量。

例如,对于机器学习算法,我们可以使用现有的标记数据作为真相。我们可以选择一个或多个度量标准,如准确度、召回率或精确度,来量化与真相的偏差。需要注意的是,在某些用例中,正确的输出不是一个单一的值。相反,正确的输出被定义为给定一组输入的范围。在设计和开发算法时,目标是迭代改进算法,直到它在需求中指定的范围内。

关注点 2 - 这是获得这些结果的最佳方式吗?

第二个关注点是找到以下问题的答案:

这是最佳解决方案吗?我们能验证不存在比我们的解决方案更好的解决方案吗?

乍一看,这个问题看起来很简单。然而,对于某类算法,研究人员已经花费了数十年的时间,试图验证算法生成的特定解决方案是否也是最佳解决方案,以及是否存在其他解决方案可以给出更好的结果。因此,首先了解问题、其需求和可用于运行算法的资源是很重要的。我们需要承认以下声明:

我们应该努力寻找这个问题的最佳解决方案吗?找到并验证最佳解决方案是如此耗时和复杂,以至于基于启发式的可行解决方案是我们最好的选择。

因此,理解问题及其复杂性是重要的,有助于我们估计资源需求。

在我们深入研究之前,首先让我们定义这里的一些术语:

  • 多项式算法:如果一个算法的时间复杂度为O(n**^k ),我们称之为多项式算法,其中k是一个常数。

  • 证书:在迭代结束时产生的候选解决方案被称为证书。当我们迭代解决特定问题时,我们通常会生成一系列证书。如果解决方案朝着收敛前进,每个生成的证书都会比前一个更好。在某个时刻,当我们的证书满足要求时,我们将选择该证书作为最终解决方案。

在第一章《算法概述》中,我们介绍了大 O 符号,它可以用来分析算法的时间复杂度。在分析时间复杂度的上下文中,我们关注以下不同的时间间隔:

  • 算法产生提议解决方案(证书)所需的时间,称为证书(t[r])

  • 验证提议解决方案(证书)所需的时间,t[s]

表征问题的复杂性

多年来,研究界根据问题的复杂性将问题分为各种类别。在尝试设计解决方案之前,首先尝试对问题进行表征是有意义的。一般来说,问题有三种类型:

  • 类型 1:我们可以保证存在一个多项式算法来解决这些问题

  • 类型 2:我们可以证明它们不能通过多项式算法解决的问题

  • 类型 3:我们无法找到多项式算法来解决这些问题,但也无法证明这些问题不存在多项式解决方案

让我们来看看各种问题类别:

  • 非确定性多项式NP):要成为 NP 问题,问题必须满足以下条件:

  • 可以保证存在一个多项式算法,可用于验证候选解决方案(证书)是否最优。

  • 多项式P):这些问题可以被视为 NP 的子集。除了满足 NP 问题的条件外,P 问题还需要满足另一个条件:

  • 可以保证至少存在一个多项式算法可用于解决它们。

PNP问题之间的关系如下图所示:

如果一个问题是 NP,那么它也是 P 吗?这是计算机科学中仍未解决的最大问题之一。由 Clay 数学研究所选定的千禧年大奖问题宣布为解决此问题提供 100 万美元奖金,因为它将对人工智能、密码学和理论计算机科学等领域产生重大影响:

让我们继续列举各种问题类别:

  • NP 完全:NP 完全类别包含所有 NP 问题中最难的问题。NP 完全问题满足以下两个条件:

  • 没有已知的多项式算法来生成证书。

  • 已知有多项式算法可以验证所提出的证书是否最优。

  • NP 难:NP 难类别包含至少与 NP 类别中的任何问题一样困难的问题,但它们本身不需要属于 NP 类别。

现在,让我们尝试绘制一个图表来说明这些不同的问题类别:

请注意,研究界尚未证明 P = NP。尽管尚未证明,但极有可能 P ≠ NP。在这种情况下,NP 完全问题不存在多项式解。请注意,前面的图表是基于这一假设的。

问题 3 - 算法在更大的数据集上的表现如何?

算法以一定方式处理数据以产生结果。一般来说,随着数据规模的增加,处理数据和计算所需结果的时间也会越来越长。术语“大数据”有时用于粗略识别预计对基础设施和算法工作具有挑战性的数据集,因为它们的规模、多样性和速度。设计良好的算法应该是可扩展的,这意味着它应该以一种能够有效运行的方式设计,利用可用资源并在合理的时间内生成正确的结果。在处理大数据时,算法的设计变得更加重要。为了量化算法的可扩展性,我们需要牢记以下两个方面:

  • 随着输入数据增加,资源需求的增加:估算这样的需求称为空间复杂性分析。

  • 随着输入数据增加,运行所需时间的增加:估算这一点称为时间复杂性分析。

请注意,我们生活在一个被数据爆炸所定义的时代。术语“大数据”已经成为主流,因为它捕捉到了现代算法通常需要处理的数据的规模和复杂性。

在开发和测试阶段,许多算法只使用少量数据样本。在设计算法时,重要的是要考虑算法的可扩展性方面。特别重要的是要仔细分析(即测试或预测)算法在数据集增大时的性能影响。

理解算法策略

一个精心设计的算法试图通过尽可能将问题分解为更小的子问题,以最有效地优化可用资源的使用。设计算法有不同的算法策略。算法策略涉及算法列表中包含缺失算法的三个方面。

本节中我们将介绍以下三种策略:

  • 分而治之策略

  • 动态规划策略

  • 贪婪算法策略

理解分而治之策略

其中一种策略是找到一种方法将一个较大的问题分解为可以独立解决的较小问题。这些子问题产生的子解决方案然后组合在一起生成问题的整体解决方案。这就是分而治之策略。

从数学上讲,如果我们正在为一个需要处理数据集dn个输入的问题(P)设计解决方案,我们将问题分解为k个子问题,P[1]P[k]。每个子问题将处理数据集d的一个分区。通常,我们将有P**[1]P**[k]处理d[1]d[k]

让我们看一个实际的例子。

实际例子 - 将分而治之应用于 Apache Spark

Apache Spark 是一个用于解决复杂分布式问题的开源框架。它实现了分而治之的策略来解决问题。为了处理问题,它将问题分解为各种子问题,并独立处理它们。我们将通过使用一个简单的例子来演示这一点,从一个列表中计算单词的数量。

假设我们有以下单词列表:

wordsList = [python, java, ottawa, news, java, ottawa]

我们想要计算列表中每个单词的频率。为此,我们将应用分而治之策略以高效地解决这个问题。

分而治之的实现如下图所示:

前面的图显示了问题被分解为以下阶段:

  1. 分割:输入数据被分成可以独立处理的分区。这就是分割。在前面的图中我们有三个分割

  2. 映射:任何可以在分割上独立运行的操作都称为映射。在前面的图中,映射操作将分区中的每个单词转换为键值对。对应于三个分割,有三个并行运行的映射器。

  3. 洗牌:洗牌是将相似的键放在一起的过程。一旦相似的键放在一起,聚合函数就可以对它们的值进行运算。请注意,洗牌是一个性能密集型的操作,因为需要将最初分布在网络中的相似键放在一起。

  4. 减少:对相似键的值运行聚合函数称为减少。在前面的图中,我们需要计算单词的数量。

让我们看看如何编写代码来实现这一点。为了演示分而治之的策略,我们需要一个分布式计算框架。我们将在 Apache Spark 上运行 Python:

  1. 首先,为了使用 Apache Spark,我们将创建一个 Apache Spark 的运行时上下文:
import findspark
findspark.init()
from pyspark.sql import SparkSession
spark = SparkSession.builder.master("local[*]").getOrCreate()
sc = spark.sparkContext
  1. 现在,让我们创建一个包含一些单词的样本列表。我们将把这个列表转换成 Spark 的本地分布式数据结构,称为弹性分布式数据集RDD):
wordsList = ['python', 'java', 'ottawa', 'ottawa', 'java','news']
wordsRDD = sc.parallelize(wordsList, 4)
# Print out the type of wordsRDD
print (wordsRDD.collect())
  1. 现在,让我们使用map函数将单词转换为键值对:

  1. 让我们使用reduce函数来聚合并得到最终结果:

这显示了我们如何使用分而治之策略来计算单词的数量。

现代云计算基础设施,如 Microsoft Azure、Amazon Web Services 和 Google Cloud,通过直接或间接地实现分而治之策略来实现可扩展性。

理解动态规划策略

动态规划是理查德·贝尔曼在 1950 年代提出的一种优化某些类别算法的策略。它基于一种智能缓存机制,试图重复使用繁重的计算。这种智能缓存机制称为记忆化

当我们试图解决的问题可以分解为子问题时,动态规划可以带来良好的性能优势。这些子问题部分涉及在这些子问题中重复的计算。其思想是执行该计算一次(这是耗时的步骤),然后在其他子问题中重复使用它。这是通过记忆化实现的,特别适用于解决可能多次评估相同输入的递归问题。

理解贪婪算法

在深入研究本节内容之前,让我们先定义两个术语:

  • 算法开销:每当我们尝试找到某个问题的最优解时,都需要一些时间。随着我们试图优化的问题变得越来越复杂,找到最优解所需的时间也会增加。我们用Ω[i]表示算法开销。

  • 与最优解的差异:对于给定的优化问题,存在一个最优解。通常,我们使用我们选择的算法迭代优化解决方案。对于给定的问题,总是存在一个完美的解决方案,称为最优解。正如讨论的那样,根据我们试图解决的问题的分类,最优解可能是未知的,或者计算和验证它可能需要不合理的时间。假设最优解已知,则在第i次迭代中,当前解决方案与最优解的差异称为与最优解的差异,用Δ[i]表示。

对于复杂问题,我们有两种可能的策略:

  • 策略 1:花费更多时间找到最接近最优解的解决方案,使Δ[i]尽可能小。

  • 策略 2:最小化算法开销Ω[i]。使用快速且简单的方法,只使用可行的解决方案。

贪婪算法是基于策略 2 的,我们不会努力寻找全局最优解,而是选择最小化算法开销。

使用贪婪算法是一种快速简单的策略,用于找到多阶段问题的全局最优值。它基于选择局部最优值,而不努力验证局部最优值是否也是全局最优值。一般来说,除非我们很幸运,贪婪算法不会得到可以被认为是全局最优的值。然而,找到全局最优值是一项耗时的任务。因此,与分而治之和动态规划算法相比,贪婪算法更快。

一般来说,贪婪算法定义如下:

  1. 假设我们有一个数据集D。在这个数据集中,选择一个元素k

  2. 假设候选解或证书是S。考虑将k包含在解决方案S中。如果可以包含,那么解决方案就是Union(S, e)

  3. 重复这个过程,直到S填满或D用尽。

实际应用-解决 TSP

让我们首先看一下 TSP 的问题陈述,这是一个在上世纪 30 年代被提出的众所周知的问题。TSP 是一个 NP 难问题。首先,我们可以随机生成一个满足访问所有城市条件的旅行路线,而不考虑最优解。然后,我们可以努力改进每次迭代的解决方案。迭代中生成的每个旅行路线称为候选解(也称为证书)。证明证书是最优的需要指数级增加的时间。相反,使用基于不同启发式的解决方案,这些解决方案生成的旅行路线接近于最优但并非最优。

旅行商需要访问给定的城市列表才能完成他们的工作:

输入 一个包含n个城市(表示为V)和每对城市之间距离的列表,d ij (1 ≤ i, j ≤ n)
输出 访问每个城市一次并返回到初始城市的最短旅行路线

注意以下内容:

  • 列表中的城市之间的距离是已知的,

  • 给定列表中的每个城市都需要被访问一次

我们能为销售员生成旅行计划吗?什么是可以最小化旅行商所走总距离的最优解?

以下是五个加拿大城市之间的距离,我们可以用于 TSP:

Ottawa Montreal Kingston Toronto Sudbury
Ottawa - 199 196 450 484
Montreal 199 - 287 542 680
Kingston 196 287 - 263 634
Toronto 450 542 263 - 400
Sudbury 484 680 634 400 -

请注意,目标是获得一个从初始城市出发并返回到初始城市的旅行路线。例如,一个典型的旅行路线可以是 Ottawa–Sudbury–Montreal–Kingston–Toronto–Ottawa,成本为484 + 680 + 287 + 263 + 450 = 2,164。这是销售员需要旅行的最短距离吗?什么是可以最小化旅行商所走总距离的最优解?我将留给你去思考和计算。

使用蛮力策略

解决 TSP 的第一个解决方案是使用蛮力策略找到销售员访问每个城市一次并返回到初始城市的最短路径。因此,蛮力策略的工作方式如下:

  1. 评估所有可能的旅行路线。

  2. 选择一个我们可以得到最短距离的方案。

问题在于对于n个城市,存在(n-1)!种可能的旅行路线。这意味着五个城市将产生4! = 24种旅行路线,我们将选择对应最短距离的那个。很明显,这种方法只适用于我们没有太多城市的情况。随着城市数量的增加,蛮力策略由于生成的排列数量庞大而变得不稳定。

让我们看看如何在 Python 中实现蛮力策略。

首先,注意到一个旅行路线{1,2,3}表示从城市 1 到城市 2 和城市 3 的旅行路线。旅行路线中的总距离是旅行路线中覆盖的总距离。我们将假设城市之间的距离是它们之间的最短距离(即欧几里得距离)。

让我们首先定义三个实用函数:

  • distance_points:计算两点之间的绝对距离

  • distance_tour:计算销售员在给定旅行中需要覆盖的总距离

  • generate_cities:随机生成一个位于宽度为500,高度为300的矩形内的n个城市的集合

让我们看一下以下代码:

import random
from itertools import permutations
alltours = permutations 

def distance_tour(aTour):
    return sum(distance_points(aTour[i - 1], aTour[i]) 
               for i in range(len(aTour)))

aCity = complex

def distance_points(first, second): return abs(first - second)

def generate_cities (number_of_cities):
    seed=111;width=500;height=300
    random.seed((number_of_cities, seed))
    return frozenset(aCity(random.randint(1, width), random.randint(1, height))
                     for c in range(number_of_cities))

在上面的代码中,我们从itertools包的permutations函数实现了alltours。我们还用复数表示了距离。这意味着:

  • 计算两个城市ab之间的距离就是简单的distance (a,b)

  • 我们可以通过调用generate_cities(n)来创建n个城市。

现在让我们定义一个名为brute_force的函数,它生成所有可能的城市旅游路线。一旦生成了所有可能的路线,它将选择最短距离的路线:

def brute_force(cities):
    "Generate all possible tours of the cities and choose the shortest 
     tour."
    return shortest_tour(alltours(cities))

def shortest_tour(tours): return min(tours, key=distance_tour)

现在让我们定义一些实用函数,可以帮助我们绘制城市。我们将定义以下函数:

  • visualize_tour:绘制特定旅游路线中的所有城市和链接。它还会突出显示旅游路线的起始城市。

  • visualize_segment:由visualize_tour使用,用于绘制路段中的城市和链接。

看看以下代码:

%matplotlib inline
import matplotlib.pyplot as plt
def visualize_tour(tour, style='bo-'): 
    if len(tour) > 1000: plt.figure(figsize=(15, 10))
    start = tour[0:1]
    visualize_segment(tour + start, style)
    visualize_segment(start, 'rD') 

def visualize_segment (segment, style='bo-'):
    plt.plot([X(c) for c in segment], [Y(c) for c in segment], style, clip_on=False)
    plt.axis('scaled')
    plt.axis('off')

def X(city): "X axis"; return city.real
def Y(city): "Y axis"; return city.imag

让我们实现一个名为tsp()的函数,它可以执行以下操作:

  1. 根据算法和请求的城市数量生成旅游路线

  2. 计算算法运行所花费的时间

  3. 生成一个图

一旦定义了tsp(),我们就可以使用它来创建一条旅游路线:

请注意,我们已经用它来为 10 个城市生成旅游路线。当n=10 时,它将生成(10-1)! = 362,880个可能的排列。如果n增加,排列的数量会急剧增加,而暴力方法无法使用。

使用贪婪算法

如果我们使用贪婪算法来解决 TSP 问题,那么在每一步,我们可以选择一个看起来合理的城市,而不是找到一个可以得到最佳整体路径的城市。因此,每当我们需要选择一个城市时,我们只需选择最近的城市,而不必验证这个选择是否会得到全局最优路径。

贪婪算法的方法很简单:

  1. 从任何城市开始。

  2. 在每一步中,通过移动到尚未访问过的最近邻居的城市来构建旅游路线。

  3. 重复步骤 2

让我们定义一个名为greedy_algorithm的函数,可以实现这个逻辑:

def greedy_algorithm(cities, start=None):
    C = start or first(cities)
    tour = [C]
    unvisited = set(cities - {C})
    while unvisited:
        C = nearest_neighbor(C, unvisited)
        tour.append(C)
        unvisited.remove(C)
    return tour

def first(collection): return next(iter(collection))

def nearest_neighbor(A, cities):
    return min(cities, key=lambda C: distance_points(C, A))

现在,让我们使用greedy_algorithm为 2,000 个城市创建一条旅游路线:

请注意,生成 2,000 个城市的旅游路线只花了 0.514 秒。如果我们使用了暴力方法,它将生成(2000-1)!个排列,几乎是无穷大。

请注意,贪婪算法是基于启发式的,没有证据表明解决方案将是最优的。

现在,让我们来看看 PageRank 算法的设计。

介绍 PageRank 算法

作为一个实际的例子,让我们来看看 PageRank 算法,最初被谷歌用来对用户查询的搜索结果进行排名。它生成一个数字,量化了搜索结果在用户执行的查询上下文中的重要性。这是由斯坦福大学的两位博士生拉里·佩奇和谢尔盖·布林在 20 世纪 90 年代末设计的,他们后来创办了谷歌。

PageRank 算法是以拉里·佩奇的名字命名的,他在斯坦福大学与谢尔盖·布林一起创建了它。

让我们首先正式定义 PageRank 最初设计的问题。

问题定义

每当用户在网络上的搜索引擎上输入查询时,通常会得到大量的结果。为了使结果对最终用户有用,重要的是使用某些标准对网页进行排名。显示的结果使用这个排名来总结用户的结果,并且依赖于底层算法定义的标准。

实现 PageRank 算法

PageRank 算法最重要的部分是找到计算每个页面重要性的最佳方法。为了计算一个从01的数字,可以量化特定页面的重要性,该算法结合了以下两个组件的信息:

  • 用户输入的查询特定信息:这个组件估计了用户输入的查询的上下文中,网页内容的相关性。页面的内容直接取决于页面的作者。

  • 与用户输入的查询无关的信息:这个组件试图量化每个网页在其链接、浏览和邻域的重要性。这个组件很难计算,因为网页是异质的,而且很难制定可以应用于整个网络的标准。

为了在 Python 中实现 PageRank 算法,首先让我们导入必要的库:

import numpy as np import networkx as nx import matplotlib.pyplot as plt %matplotlib inline

为了演示的目的,让我们假设我们只分析网络中的五个网页。让我们称这组页面为myPages,它们一起在一个名为myWeb的网络中:

myWeb = nx.DiGraph() myPages = range(1,5)

现在,让我们随机连接它们以模拟实际网络:

connections = [(1,3),(2,1),(2,3),(3,1),(3,2),(3,4),(4,5),(5,1),(5,4)] myWeb.add_nodes_from(myPages) myWeb.add_edges_from(connections)

现在,让我们绘制这个图:

pos=nx.shell_layout(myWeb) nx.draw(myWeb, pos, arrows=True, with_labels=True) plt.show()

它创建了我们网络的可视表示,如下所示:

在 PageRank 算法中,网页的模式包含在一个称为转换矩阵的矩阵中。有一些算法不断更新转换矩阵,以捕捉不断变化的网络状态。转换矩阵的大小是n x n,其中n是节点的数量。矩阵中的数字是访问者由于出站链接而下一个转到该链接的概率。

在我们的情况下,上面的图显示了我们拥有的静态网络。让我们定义一个函数,用于创建转换矩阵:

请注意,这个函数将返回G,它代表我们图的转换矩阵。

让我们为我们的图生成转换矩阵:

请注意,我们图的转换矩阵是5 x 5。每一列对应图中的每个节点。例如,第 2 列是关于第二个节点的。访问者从节点 2 导航到节点 1 或节点 3 的概率为 0.5。请注意,转换矩阵的对角线是0,因为在我们的图中,节点没有到自身的出站链接。在实际网络中,这可能是可能的。

请注意,转换矩阵是一个稀疏矩阵。随着节点数量的增加,大多数值将为0

理解线性规划

线性规划背后的基本算法是由乔治·丹齐格在 1940 年代初在加州大学伯克利分校开发的。丹齐格在为美国空军工作时,利用这个概念进行了物流供应和容量规划的实验。二战结束后,丹齐格开始为五角大楼工作,并将他的算法成熟为一种他称之为线性规划的技术。它被用于军事作战规划。

今天,它被用来解决与根据某些约束最小化或最大化变量相关的重要现实问题。这些问题的一些例子如下:

  • 根据资源最小化修理汽车的时间

  • 在分布式计算环境中分配可用的分布式资源以最小化响应时间

  • 根据公司内资源的最佳分配来最大化公司的利润

制定线性规划问题

使用线性规划的条件如下:

  • 我们应该能够通过一组方程来阐明问题。

  • 方程中使用的变量必须是线性的。

定义目标函数

请注意,前面三个例子的目标都是关于最小化或最大化一个变量。这个目标在数学上被公式化为其他变量的线性函数,并被称为目标函数。线性规划问题的目标是在保持指定约束条件的情况下最小化或最大化目标函数。

指定约束条件

在尝试最小化或最大化某些东西时,现实世界中存在一些需要遵守的约束。例如,当试图最小化修理汽车所需的时间时,我们还需要考虑到可用的技工数量是有限的。通过线性方程指定每个约束是制定线性规划问题的重要部分。

线性规划的实际应用-容量规划

让我们看一个实际应用案例,线性规划可以用来解决一个现实世界的问题。假设我们想要最大化一家制造两种不同类型机器人的尖端工厂的利润:

  • 高级模型(A):这提供了完整的功能。制造每个高级模型的单位都会带来 4200 美元的利润。

  • 基本模型(B):这只提供基本功能。制造每个基本模型的单位都会带来 2800 美元的利润。

制造机器人需要三种不同类型的人。制造每种类型机器人所需的确切天数如下:

机器人类型 技术员 AI 专家 工程师
机器人 A:高级模型 3 天 4 天 4 天
机器人 B:基本模型 2 天 3 天 3 天

工厂以 30 天为周期运行。一个 AI 专家在一个周期内可用 30 天。两名工程师在 30 天内休假 8 天。因此,工程师在一个周期内只有 22 天可用。一个技术员在 30 天周期内可用 20 天。

以下表格显示了工厂中我们拥有的人数:

技术员 AI 专家 工程师
人数 1 1 2
周期内的总天数 1 x 20 = 20 天 1 x 30 = 30 天 2 x 22 = 44 天

这可以建模如下:

  • 最大利润= 4200A + 2800B

  • 这取决于以下内容:

  • A ≥ 0:生产的高级机器人数量可以是0或更多。

  • B ≥ 0:生产的基本机器人数量可以是0或更多。

  • 3A + 2B ≤ 20:这是技术员可用性的约束。

  • 4A+3B ≤ 30:这是 AI 专家可用性的约束。

  • 4A+ 3B ≤ 44:这是工程师可用性的约束。

首先,我们导入名为pulp的 Python 包,用于实现线性规划:

import pulp

然后,我们在这个包中调用LpProblem函数来实例化问题类。我们将实例命名为利润最大化问题

# Instantiate our problem class
model = pulp.LpProblem("Profit maximising problem", pulp.LpMaximize)

然后,我们定义两个线性变量,AB。变量A表示生产的高级机器人数量,变量B表示生产的基本机器人数量:

A = pulp.LpVariable('A', lowBound=0, cat='Integer') 
B = pulp.LpVariable('B', lowBound=0, cat='Integer')

我们将目标函数和约束定义如下:

# Objective function
model += 5000 * A + 2500 * B, "Profit"

# Constraints
model += 3 * A + 2 * B <= 20 
model += 4 * A + 3 * B <= 30
model += 4 * A + 3 * B <= 44

我们使用solve函数生成解决方案:

# Solve our problem
model.solve()
pulp.LpStatus[model.status]

然后,我们打印AB的值以及目标函数的值:

线性规划在制造业中被广泛使用,以找到应该使用的产品的最佳数量,以优化可用资源的使用。

现在我们来到了本章的结尾!让我们总结一下我们学到了什么。

摘要

在本章中,我们看了各种设计算法的方法。我们看了选择正确的算法设计所涉及的权衡。我们看了制定现实世界问题的最佳实践。我们还看了如何解决现实世界的优化问题。从本章中学到的经验可以用来实现设计良好的算法。

在下一章中,我们将专注于基于图的算法。我们将首先研究表示图的不同方法。然后,我们将研究建立在各种数据点周围进行特定调查的技术。最后,我们将研究从图中搜索信息的最佳方法。

posted @ 2024-04-16 15:20  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报