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

原文:annas-archive.org/md5/a52efc405cb495a26c2bdcb2c25f51df

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在计算领域,从基础理论到实际应用,算法都是推动力。在本次更新版中,我们进一步深入探讨了算法的动态世界,扩展了视野,解决了诸多紧迫的现实问题。从算法的基础入手,我们走过了各种设计技巧,深入探讨了线性规划、页面排名、图论等复杂领域,并对机器学习进行了更深层次的探索。为了确保我们走在技术发展的前沿,我们增加了对序列网络、LLMs、LSTM、GRUs 的深入讨论,现在还包括了密码学以及大规模算法在云计算环境中的部署。

推荐系统中算法的重要性,作为当今数字时代的核心元素,也被详尽阐述。要有效运用这些算法,理解其背后的数学和逻辑至关重要。我们的实际案例研究,包括天气预报、推文分析、电影推荐以及深入探索大型语言模型(LLMs)的细节,展示了它们的实际应用。

本书为您提供的见解,旨在增强您在现代计算挑战中部署算法的信心。迈入这段解读和利用算法的扩展之旅,在当今不断发展的数字化环境中充分发挥其作用。

本书适合谁阅读

如果您是程序员或开发者,热衷于利用算法解决问题并编写高效代码,那么这本书适合您。从经典的广泛应用算法到数据科学、机器学习和密码学领域的最新技术,本指南涵盖了广泛的内容。虽然熟悉 Python 编程是有益的,但并非必须。

任何编程语言的基础知识都将对您有所帮助。此外,即使您不是程序员,但有一定技术倾向,您也能从本书中获得关于问题解决算法的广阔见解。

本书内容

第一部分:基础与核心算法

第一章算法概述,介绍了算法的基本概念。它从算法的基本概念开始,讲解人们如何使用算法来表述问题,以及不同算法的局限性。由于本书使用 Python 编写算法,因此本章还会说明如何设置 Python 环境以运行示例。接下来,我们将探讨如何量化和比较一个算法的性能。

第二章算法中使用的数据结构,讨论了算法中的数据结构。由于我们在本书中使用 Python,这一章重点介绍 Python 的数据结构,但所讲的概念也适用于其他语言,如 Java 和 C++。本章将向您展示 Python 如何处理复杂的数据结构,并介绍应在不同类型的数据中使用哪些结构。

第三章排序和搜索算法,首先介绍了不同类型的排序算法及其设计方法。然后,通过实际例子讨论了搜索算法。

第四章设计算法,介绍了在设计算法时可供选择的选项,讨论了问题特性的重要性。接下来,以著名的旅行推销员问题TSP)为案例,应用了我们将要介绍的设计技术。还介绍了线性规划及其应用。

第五章图算法,涵盖了我们如何利用图来表示数据结构的方法。它涵盖了关于图算法的基础理论、技术和方法,如网络理论分析和图遍历。我们将通过一个图算法的案例研究来探讨欺诈分析。

第二部分:机器学习算法

第六章无监督 机器学习算法,解释了无监督学习如何应用于现实世界的问题。我们将学习其基本算法和方法,如聚类算法、降维和关联规则挖掘。

第七章传统监督学习算法,深入探讨了监督机器学习的基本原理,包括分类器和回归器。我们将使用真实世界的问题案例来探索它们的能力。介绍了六种不同的分类算法,以及三种回归技术。最后,我们将比较它们的结果,总结本讨论的要点。

第八章神经网络算法,介绍了典型神经网络的主要概念和组成部分。接着介绍了各种类型的神经网络和它们所使用的激活函数。详细讨论了反向传播算法,这是训练神经网络最广泛使用的算法。最后,我们将通过一个实际应用案例学习如何使用深度学习来检测欺诈文档。

第九章自然语言处理算法,介绍了自然语言处理NLP)的算法。它介绍了 NLP 的基本原理,以及如何为 NLP 任务准备数据。之后,解释了文本数据向量化和词嵌入的概念。最后,我们展示了一个详细的使用案例。

第十章理解顺序模型,深入探讨了用于顺序数据训练神经网络的方法。它涵盖了顺序模型的核心原则,并介绍了其技术和方法概述。然后考虑了深度学习如何改进自然语言处理技术。

第十一章高级顺序建模算法,考虑了顺序模型的局限性以及顺序建模如何发展以克服这些局限性。它深入探讨了顺序模型的高级方面,理解复杂配置的创建过程。首先,我们将分析关键元素,如自动编码器和序列到序列Seq2Seq)模型。接下来,我们将研究注意力机制和变换器,它们在大语言模型LLM)的发展中至关重要,随后我们将学习这些内容。

第三部分:高级主题

第十二章推荐引擎,介绍了推荐引擎的主要类型及其内部工作原理。这些系统擅长向用户推荐量身定制的物品或产品,但也并非没有挑战。我们将讨论它们的优势以及所面临的局限性。最后,我们将学习如何使用推荐引擎来解决现实世界的问题。

第十三章数据处理的算法策略,介绍了数据算法及其分类的基本概念。我们将了解用于高效管理数据的数据存储和数据压缩算法,帮助我们理解在设计和实施以数据为中心的算法时所涉及的权衡。

第十四章密码学,介绍了与密码学相关的算法。我们将首先介绍密码学的背景,然后讨论对称加密算法。我们将学习消息摘要 5MD5)算法和安全哈希算法SHA),并展示每种算法的局限性和弱点。接下来,我们将讨论非对称加密算法及其如何用于创建数字证书。最后,我们将通过一个实际示例总结所有这些技术。

第十五章大规模算法,首先介绍了大规模算法及其所需的高效基础设施。我们将探讨管理多资源处理的各种策略。我们将检查并行处理的局限性,正如阿姆达尔定律所描述的那样,并研究图形处理单元GPU)的使用。完成本章后,你将掌握设计大规模算法所必需的基本策略,打下坚实的基础。

第十六章实际考虑,介绍了算法可解释性的问题,即算法内部机制能够以可理解的方式进行解释的程度。然后,我们将讨论使用算法的伦理问题以及在实施过程中可能产生的偏见。接着,将讨论处理 NP 难题的技术。最后,我们将研究在选择算法之前需要考虑的因素。

下载示例代码文件

本书的代码包也托管在 GitHub 上,地址为:github.com/cloudanum/50Algorithms。我们还提供了其他代码包,来自我们丰富的书籍和视频目录,您可以在github.com/PacktPublishing/查看。别忘了看看!您还可以在 Google Drive 上找到相同的代码包,地址为:code.50algo.com

下载彩色图片

我们还提供了一份 PDF 文件,其中包含了本书中所使用截图和图表的彩色图片。您可以在此下载:packt.link/UBw6g

使用的约定

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

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入以及 Twitter 用户名。以下是一个例子:“让我们尝试使用 Python 中的networtx包创建一个简单的图。”

粗体:表示新术语、重要词汇或屏幕上出现的词。例如,新的术语在文本中会像这样出现:“Python 也是您可以在各种云计算基础设施中使用的语言之一,例如亚马逊网络服务AWS)和谷歌云平台GCP)。”

警告或重要的说明将会像这样呈现。

提示和技巧通常会像这样呈现

与我们联系

我们始终欢迎读者的反馈。

一般反馈:发送邮件至feedback@packtpub.com并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。

勘误表:尽管我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您报告给我们。请访问www.packtpub.com/submit-errata,点击提交勘误表并填写表单。

盗版:如果您在互联网上遇到我们作品的任何非法副本,请提供相关的地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上相关材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专长,并且有兴趣参与撰写或贡献一本书,请访问authors.packtpub.com

分享您的想法

阅读完*《每个程序员都应该知道的 50 个算法(第二版)》*后,我们非常希望听到您的反馈!请点击这里直接前往亚马逊的评价页面并分享您的意见。

您的评论对我们和技术社区至关重要,并将帮助我们确保提供高质量的内容。

下载本书的免费 PDF 副本

感谢购买本书!

你喜欢随时随地阅读,但又无法随身携带印刷版书籍吗?你购买的电子书是否与你选择的设备不兼容?

不用担心,现在每本 Packt 书籍都可以免费获得该书的无 DRM PDF 版本。

在任何地方、任何设备上都能阅读。直接从你喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。

福利不止于此,你还可以获得独家折扣、新闻通讯和每天发送到你邮箱的优质免费内容。

按照这些简单步骤获取福利:

  1. 扫描二维码或访问以下链接

    https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_QR_Free_PDF.png

    packt.link/free-ebook/9781803247762

  2. 提交你的购买证明

  3. 就是这样!我们将直接通过电子邮件向你发送免费的 PDF 和其他福利。

第一部分

基础知识与核心算法

本节介绍了算法的核心方面。我们将探讨什么是算法以及如何设计一个算法。我们还将了解算法中使用的数据结构。本节还介绍了排序和查找算法以及解决图形问题的算法。本节包含的章节有:

  • 第一章算法概述

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

  • 第三章排序与查找算法

  • 第四章算法设计

  • 第五章图算法

第一章:算法概述

必须亲眼看到算法,才能相信它。

– 唐纳德·克努斯

本书涵盖了理解、分类、选择和实现重要算法所需的信息。除了讲解算法的逻辑,本书还讨论了适用于不同类别算法的数据结构、开发环境和生产环境。这是本书的第二版,在这一版中,我们特别关注日益重要的现代机器学习算法。除了逻辑部分,本书还展示了使用算法解决实际日常问题的实际例子。

本章为算法的基础提供了深入的理解。首先介绍了理解不同算法工作原理所需的基本概念。为了提供历史视角,本节总结了人们如何开始使用算法来数学化某类问题。还提到了不同算法的局限性。接下来的部分解释了指定算法逻辑的各种方法。由于本书使用 Python 编写算法,因此解释了如何设置 Python 环境以运行示例。然后,讨论了如何量化和比较算法的性能与其他算法的不同方法。最后,本章讨论了验证算法特定实现的各种方式。

总结来说,本章涵盖了以下主要内容:

  • 什么是算法?

  • 算法的各个阶段

  • 开发环境

  • 算法设计技巧

  • 性能分析

  • 验证算法

什么是算法?

从最简单的角度看,算法是一组规则,用于执行某些计算以解决问题。它被设计为根据精确定义的指令,对任何有效的输入产生结果。如果你查阅字典(例如《美国传统词典》),它是这样定义算法的:

算法是一组有限的、不含歧义的指令,在给定一组初始条件下,按照规定的顺序执行,以实现特定目标,并且具有可识别的结束条件。

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

算法的各个阶段

开发、部署并最终使用算法的不同阶段如图 1.1所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_01.png

图 1.1:开发、部署和使用算法的不同阶段

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

开发阶段包括两个阶段:

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

  2. 编码阶段:在编码阶段,设计好的算法被转换为计算机程序。计算机程序实现设计阶段提出的所有逻辑和架构是至关重要的。

商业问题的需求可以分为功能性需求和非功能性需求。直接指定解决方案预期特征的需求称为功能性需求。功能性需求详细说明了解决方案的预期行为。另一方面,非功能性需求关注算法的性能、可扩展性、可用性和准确性。非功能性需求还规定了数据安全性的期望。例如,假设我们需要为一家信用卡公司设计一个可以识别并标记欺诈交易的算法。这个例子中的功能性需求将通过提供给定一组输入数据的预期输出的详细信息,来指定有效解决方案的预期行为。在这种情况下,输入数据可能是交易的详细信息,而输出可能是一个二进制标志,用来标记交易是欺诈的还是非欺诈的。在这个例子中,非功能性需求可能会指定每个预测的响应时间。非功能性需求还会设置准确度的容许阈值。由于我们在这个例子中处理的是金融数据,因此与用户身份验证、授权和数据保密相关的安全需求也应该是非功能性需求的一部分。

请注意,功能性和非功能性需求的目标是精确定义需要做什么。设计解决方案是关于弄清楚如何做。实现设计是使用您选择的编程语言开发实际解决方案。设计一个完全满足功能性和非功能性需求的解决方案可能需要大量时间和精力。选择合适的编程语言和开发/生产环境可能取决于问题的需求。例如,由于 C/C++ 是比 Python 更低级的语言,因此对于需要编译代码和低级优化的算法,它可能是更好的选择。

一旦设计阶段完成且编码完成,算法就可以部署了。部署算法涉及设计实际的生产环境,其中代码将运行。生产环境的设计需要根据算法的数据和处理需求来进行。例如,对于可并行化的算法,需要一个适当数量计算节点的集群,以便高效执行算法。对于数据密集型算法,可能需要设计数据输入管道,以及缓存和存储数据的策略。生产环境的设计将在第十五章《大规模算法》和第十六章《实际考虑事项》中详细讨论。

一旦生产环境设计并实施完毕,算法就可以部署,算法将根据要求处理输入数据并生成输出。

开发环境

一旦设计完成,算法需要根据设计在编程语言中实现。对于本书,我们选择了 Python 作为编程语言。我们之所以选择它,是因为 Python 灵活且是开源编程语言。Python 也是您可以在各种云计算基础设施中使用的语言之一,如Amazon Web ServicesAWS)、Microsoft Azure 和 Google Cloud PlatformGCP)。

官方 Python 首页可以通过www.python.org/访问,页面上还有安装说明和有用的初学者指南。

为了更好地理解本书中呈现的概念,您需要具备基本的 Python 知识。

对于本书,我们建议使用最新版本的 Python 3。写作时,最新版本是 3.10,我们将使用这个版本来运行本书中的练习。

本书中我们将始终使用 Python。我们还将使用 Jupyter Notebook 来运行代码。本书的其余章节假设已安装 Python,并且 Jupyter Notebook 已正确配置并正在运行。

Python 包

Python 是一种通用编程语言。它遵循“自带电池”(batteries included)的理念,这意味着有一个标准库可供使用,而无需用户下载单独的包。然而,标准库模块仅提供最低限度的功能。根据您正在处理的特定用例,可能需要安装额外的包。Python 包的官方第三方库称为 PyPI,代表 Python 包索引。它以源代码分发和预编译代码的形式托管 Python 包。目前,PyPI 上托管了超过 113,000 个 Python 包。安装额外包最简单的方式是通过 pip 包管理系统。pip 是一个典型的递归首字母缩略词,Python 文化中充斥着这样的词汇。pip 代表 Pip Installs Python。好消息是,从 Python 3.4 版本开始,pip 默认已安装。要检查 pip 的版本,可以在命令行输入:

pip --version 

这个 pip 命令可用于安装额外的包:

pip install PackageName 

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

pip install PackageName --upgrade 

并且可以安装特定版本的 Python 包:

pip install PackageName==2.1 

添加正确的库和版本已成为设置 Python 编程环境的一部分。帮助维护这些库的一个功能是能够创建一个列出所有所需包的 requirements 文件。requirements 文件是一个简单的文本文件,包含库的名称及其相关版本。requirements 文件的示例如下所示:

scikit-learn==0.24.1

tensorflow==2.5.0

tensorboard==2.5.0

按惯例,requirements.txt 文件放置在项目的顶层目录中。

创建后,可以使用以下命令通过安装所有 Python 库及其相关版本来设置开发环境:

pip install -r requirements.txt 

现在让我们来看看本书中将使用的主要包。

SciPy 生态系统

科学 PythonSciPy)——发音为 sigh pie——是为科学社区创建的一组 Python 包。它包含许多功能,包括广泛的随机数生成器、线性代数例程和优化器。

SciPy 是一个全面的包,随着时间的推移,人们开发了许多扩展,以根据自己的需求定制和扩展该包。SciPy 性能良好,因为它作为围绕 C/C++ 或 Fortran 编写的优化代码的薄包装器。

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

  • 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/

使用 Jupyter Notebook

我们将使用 Jupyter Notebook 和 Google 的 Colaboratory 作为 IDE。有关 Jupyter Notebook 和 Colab 设置和使用的更多信息,请参见 附录 AB

算法设计技术

算法是解决实际问题的数学方法。在设计算法时,我们在设计和调优算法的过程中会考虑以下三个设计问题:

  • 问题 1:这个算法是否生成了我们预期的结果?

  • 问题 2:这是获取这些结果的最优方式吗?

  • 问题 3:这个算法在更大数据集上的表现如何?

在设计解决方案之前,理解问题本身的复杂性非常重要。例如,如果我们根据问题的需求和复杂性来描述它,这将有助于我们设计合适的解决方案。

一般来说,算法可以根据问题的特征分为以下几种类型:

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

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

  • 数据和计算密集型算法:有些算法处理大量数据并且计算需求也很大。用于对实时视频流进行情感分析的算法就是一个很好的例子,其中数据和处理需求都非常庞大,完成任务所需的资源也很大。这类算法是最消耗资源的算法,需要仔细设计算法并智能地分配可用资源。

为了描述问题的复杂性和需求,深入研究它的数据和计算维度会有所帮助,我们将在接下来的章节中进行讨论。

数据维度

为了对问题的数据维度进行分类,我们查看其数据量速度多样性(即3Vs),定义如下:

  • 数据量:数据量是算法处理的数据的预期大小。

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

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

图 1.2更详细地展示了数据的 3Vs。该图的中心显示了最简单的数据,具有小数据量、低多样性和低速度。随着我们远离中心,数据的复杂性增加,可能在三维中的一个或多个维度上增加。

例如,在速度维度上,我们有批处理过程作为最简单的,其次是周期性过程,然后是近实时过程。最后,我们有实时过程,这是在数据速度的背景下最复杂的处理方式。例如,一组监控摄像头收集的实时视频流将具有高数据量、高速度和高多样性,可能需要适当的设计来有效地存储和处理数据:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_02.png

图 1.2:数据的 3Vs:数据量、速度和多样性

让我们考虑三个具有三种不同数据类型的用例示例:

  • 首先,考虑一个简单的数据处理用例,其中输入数据是一个.csv文件。在这种情况下,数据的量、速度和多样性将较低。

  • 其次,考虑一个用例,其中输入数据是一个安全监控摄像头的实时视频流。在这种情况下,数据的量、速度和多样性将非常高,设计算法时应考虑这一点。

  • 第三,考虑典型传感器网络的使用案例。假设传感器网络的数据源是安装在一座大楼中的温度传感器网格。尽管生成的数据的速度通常非常高(因为新数据生成非常快),但数据量预期相对较低(因为每个数据元素通常只有 16 位长,包含 8 位测量值和 8 位元数据,如时间戳和地理坐标)。

以上三个示例的处理要求、存储需求和合适的软件栈选择都不相同,通常取决于数据源的体量、速度和多样性。将数据进行表征是设计算法的第一步,因此非常重要。

计算维度

为了表征计算维度,我们需要分析当前问题的处理需求。一个算法的处理需求决定了最适合的设计类型。例如,复杂算法通常需要大量的处理能力。对于这类算法,可能需要具有多节点并行架构。现代深度算法通常涉及大量的数值处理,可能需要 GPU 或 TUP 的计算能力,如第十六章实际考虑因素中所讨论的。

性能分析

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

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

  • 应该正确:一个好的算法应该产生正确的结果。为了确认算法是否正确工作,需要进行广泛的测试,特别是测试边界情况。

  • 应该可理解:一个好的算法应该是可理解的。如果一个算法过于复杂,无法在计算机上实现,那么它再好也没有用。

  • 应该高效:一个好的算法应该是高效的。即使一个算法产生了正确的结果,如果它需要一千年才能完成,或者需要十亿 TB 的内存,那么它也没有多大帮助。

有两种可能的分析方法来量化算法的复杂度:

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

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

让我们逐一研究:

空间复杂度分析

空间复杂度分析估算了算法处理输入数据时所需的内存量。在处理输入数据时,算法需要将瞬时临时数据结构存储在内存中。算法的设计方式会影响这些数据结构的数量、类型和大小。在分布式计算时代,随着需要处理的数据量越来越大,空间复杂度分析变得越来越重要。这些数据结构的大小、类型和数量将决定底层硬件的内存需求。现代分布式计算中使用的内存数据结构需要具备高效的资源分配机制,能够在算法的不同执行阶段意识到内存需求。复杂的算法往往是迭代式的。此类算法并不会一次性将所有信息加载到内存中,而是通过迭代逐步填充数据结构。为了计算空间复杂度,首先需要对我们计划使用的迭代算法类型进行分类。迭代算法可以使用以下三种类型的迭代:

  • 收敛迭代:随着算法通过迭代进行,每次迭代中处理的数据量都会减少。换句话说,随着算法迭代的进行,空间复杂度逐渐降低。主要的挑战是处理初始迭代的空间复杂度。现代可扩展的云基础设施,如 AWS 和 Google Cloud,非常适合运行这类算法。

  • 发散迭代:随着算法通过迭代进行,每次迭代中处理的数据量逐渐增加。随着空间复杂度随着算法迭代的推进而增加,重要的是设置约束条件,以防止系统变得不稳定。可以通过限制迭代次数和/或限制初始数据大小来设置这些约束条件。

  • 平面迭代:随着算法通过迭代进行,每次迭代中处理的数据量保持不变。由于空间复杂度不发生变化,因此不需要基础设施的弹性。

计算空间复杂度时,我们需要关注最复杂的迭代之一。在许多算法中,随着我们逐步接近解决方案,所需的资源会逐渐减少。在这种情况下,初始迭代是最复杂的,可以帮助我们更好地估算空间复杂度。选择后,我们估算算法使用的总内存量,包括其瞬时数据结构、执行和输入值所占用的内存。这将帮助我们很好地估算算法的空间复杂度。

以下是最小化空间复杂度的指导原则:

  • 在可能的情况下,尽量将算法设计为迭代式。

  • 在设计迭代算法时,每当有选择时,应该优先选择更多的迭代次数而不是更少的迭代次数。细粒度的更多迭代预计会有较低的空间复杂度。

  • 算法应该只将当前处理所需的信息加载到内存中,任何不需要的信息应当被清除出内存。

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

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

时间复杂度分析

时间复杂度分析通过评估算法的结构,估算算法完成指定任务所需的时间。与空间复杂度不同,时间复杂度不依赖于算法运行的硬件。时间复杂度分析仅取决于算法本身的结构。时间复杂度分析的总体目标是尝试回答这两个重要问题:

  • 这个算法能扩展吗?一个设计良好的算法应该能够充分利用云计算环境中现代弹性基础设施的优势。算法应当设计成能够利用更多的 CPU、处理核心、GPU 和内存。例如,用于训练机器学习模型的算法应当能够在更多的 CPU 可用时使用分布式训练。

这样的算法在执行过程中应当能够充分利用 GPU 和额外的内存(如果有的话)。

  • 这个算法如何处理更大的数据集?

为了回答这些问题,我们需要确定当数据量增大时,算法的性能受到的影响,并确保算法的设计不仅要保证准确性,还要具有良好的扩展性。在当今“大数据”时代,算法的性能对大数据集来说变得越来越重要。

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

“给定一个特定的问题,且有多个算法可供选择,哪个算法在时间效率方面最为高效?”

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

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

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

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

估算性能

一个典型算法的性能将取决于输入数据的类型。例如,如果数据已经按照我们试图解决的问题的上下文进行了排序,那么算法可能会运行得非常快速。如果排序后的输入数据被用来基准测试该算法,那么它将给出一个不切实际的优异性能结果,这并不能真实反映其在大多数场景中的实际表现。为了处理算法对输入数据的依赖性,在进行性能分析时我们需要考虑不同的情况。

最佳情况

在最佳情况下,作为输入的数据已按算法能够提供最佳性能的方式进行组织。最佳情况分析给出性能的上界。

最坏情况

估算算法性能的第二种方法是尝试找出在给定条件下完成任务所需的最大时间。算法的最坏情况分析非常有用,因为我们可以保证无论条件如何,算法的性能总是优于我们分析中得出的数字。最坏情况分析对于估算处理复杂问题和大型数据集时的性能特别有用。最坏情况分析给出了算法性能的下界。

平均情况

这一方法首先将各种可能的输入划分为不同的组别。然后,从每个组别的一个代表性输入进行性能分析。最后,它计算每个组别性能的平均值。

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

大 O 符号

大 O 符号最早由巴赫曼(Bachmann)于 1894 年在一篇研究论文中提出,用于近似算法的增长。他写道:

“… 使用符号 O(n),我们表示一种其相对于 n 的阶数不超过 n 阶数的量。”(巴赫曼 1894,p. 401)

大 O 符号提供了一种描述算法性能长期增长率的方式。简单来说,它告诉我们,随着输入规模的增加,算法的运行时间是如何增长的。我们可以通过两个函数 f(n)g(n) 来进一步解释。如果我们说 f = O(g),意思是当 n 趋向于无穷大时,比例 https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_001.png 保持有限或有界。换句话说,无论我们的输入有多大,f(n) 的增长速度都不会比 g(n) 快得不成比例。

让我们看看一些特定的函数:

f(n) = 1000n² + 100n + 10

并且

g(n) = n²

请注意,当 n 趋近无穷大时,两个函数都会趋近无穷大。让我们通过应用定义来验证 f = O(g)

首先,让我们计算 https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_002.png

这将等于 https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_003.png = https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_004.png = (1000 + https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_005.png)。

很明显,https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_003.png 是有界的,并且当 n 趋向无穷大时,它不会趋向于无穷大。

因此,f(n) = O(g) = O(n²*)*。

(n²*)* 表示该函数的复杂度随着输入 n 的平方而增加。如果我们将输入元素数量翻倍,那么复杂度预计会增加 4 倍。

处理大 O 符号时,请注意以下 4 个规则。

规则 1

让我们看看算法中的循环复杂度。如果一个算法执行某一系列步骤 n 次,那么它的性能就是 O(n)

规则 2

让我们来分析算法中的嵌套循环。如果一个算法执行的某个函数有 n¹ 步,并且对于每次循环它执行另一个 n² 步的操作,那么该算法的总性能是 O(n¹ × n²*)*。

例如,如果一个算法有外循环和内循环,且都需要 n 步,那么该算法的复杂度将表示为:

O(nn)* = O(n²*)*

规则 3

如果一个算法执行一个需要 n¹ 步的函数 f(n),然后执行另一个需要 n² 步的函数 g(n),则该算法的总性能是 O(f(n)+g(n))

规则 4

如果一个算法的复杂度是 O(g(n) + h(n)),并且在大 n 的情况下,函数 g(n) 大于 h(n),那么该算法的性能可以简化为 O(g(n))

这意味着 O(1+n) = O(n)

并且 O(n²*+ n³)* = O(n²*)*。

规则 5

计算算法的复杂度时,要忽略常数倍数。如果 k 是常数,O(kf(n)) 等同于 O(f(n))

同样,O(f(k × n)) 等同于 O(f(n))

因此,O(5n²*)* = O(n²*)*。

并且 O((3n²*))* = O(n²*)*。

请注意:

  • 大 O 符号表示的复杂度仅为估算值。

  • 对于较小规模的数据,我们不关心时间复杂度。图中的 n⁰ 定义了我们开始关注时间复杂度的阈值。阴影区域描述了我们感兴趣的区域,在这个区域内我们将分析时间复杂度。

  • T(n) 时间复杂度大于原始函数。一个好的 T(n) 选择会尽量为 F(n) 创建一个紧密的上界。

下表总结了本节中讨论的不同类型的大 O 符号:

复杂度类别名称示例操作
O(1)常数添加、获取项、设置项。
O(logn)对数在已排序数组中查找一个元素。
O(n)线性复制、插入、删除、迭代
O(n²)二次嵌套循环

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

如果一个算法的运行时间与输入数据的大小无关,始终相同,则称其为常数时间运行。它用 O(1) 表示。以访问数组中的 n^(th) 元素为例。无论数组的大小如何,获取结果所需的时间是常数时间。例如,以下函数将返回数组的第一个元素,并具有 O(1) 复杂度:

def get_first(my_list):
    return my_list[0]
get_first([1, 2, 3]) 
1 
get_first([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 
1 

注意:

  • 向栈中添加新元素是通过 push 操作完成的,而从栈中删除元素是通过 pop 操作完成的。无论栈的大小如何,添加或删除一个元素所需的时间是相同的。

  • 当访问哈希表中的元素时,请注意,它是一种以关联格式存储数据的数据结构,通常为键值对。

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

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

def get_sum(my_list):
    sum = 0
    for item in my_list:
        sum = sum + item
    return sum 

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

get_sum([1, 2, 3]) 
6 
get_sum([1, 2, 3, 4]) 
10 

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

  • 查找一个元素

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

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

如果一个算法的执行时间与输入规模的平方成正比,则称该算法的运行时间为二次时间;例如,一个简单的函数用于求和一个二维数组,如下所示:

def get_sum(my_list):
    sum = 0
    for row in my_list:
        for item in row:
            sum += item
    return sum 

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

get_sum([[1, 2], [3, 4]]) 
10 
get_sum([[1, 2, 3], [4, 5, 6]]) 
21 

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

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

如果一个算法的执行时间与输入大小的对数成正比,那么这个算法被称为对数时间算法。在每次迭代中,输入大小会按固定倍数减少。一个对数算法的例子是二分查找。二分查找算法用于在一维数据结构中查找特定元素,例如 Python 列表。数据结构中的元素需要按降序排序。二分查找算法在一个名为 search_binary 的函数中实现,如下所示:

def search_binary(my_list, item):
    first = 0
    last = len(my_list)-1
    found_flag = False
    while(first <= last and not found_flag):
        mid = (first + last)//2
        if my_list[mid] == item:
            found_flag = True           
        else:
            if item < my_list[mid]:
                last = mid - 1
            else:
                first = mid + 1
    return found_flag
searchBinary([8,9,10,100,1000,2000,3000], 10) 
True 
searchBinary([8,9,10,100,1000,2000,3000], 5) 
False 

主循环利用了列表已排序的事实。它每次迭代都将列表分成一半,直到找到结果。

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

请注意,在展示的四种大 O 符号中,O(n²) 的性能最差,而 O(logn) 的性能最好。另一方面,O(n²) 不如 O(n³) 那么糟糕,但仍然,属于这个类别的算法无法处理大数据,因为时间复杂度限制了它们实际能够处理的数据量。四种大 O 符号的性能如 图 1.3 所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_03.png

图 1.3:大 O 复杂度图表

降低算法复杂度的一种方法是牺牲其精确度,从而产生一种被称为 近似算法 的算法。

选择算法

如何知道哪种解决方案更好?如何知道哪个算法运行得更快?分析一个算法的时间复杂度可以回答这些问题。

为了看到算法的用途,让我们以一个简单的例子为例,目标是对数字列表进行排序。有许多现成的算法可以完成这个任务。问题是如何选择正确的算法。

首先可以观察到,如果列表中的数字不多,那么选择哪种算法来排序这些数字并不重要。因此,如果列表中只有 10 个数字(n=10),那么选择哪种算法并无太大关系,因为即使使用非常简单的算法,也可能只需要几微秒的时间。但随着 n 的增加,选择正确算法开始变得重要。一个设计不良的算法可能需要几个小时来运行,而一个设计良好的算法可能只需要几秒钟就能完成排序。因此,对于较大的输入数据集,花时间和精力进行性能分析,并选择合适设计的算法来高效地完成任务是非常有意义的。

验证算法

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

精确、近似和随机化算法

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

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

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_01_04.png

图 1.4: 确定性与随机化算法

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

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

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

让我们通过一个例子来理解精确算法和近似算法之间的区别——著名的旅行推销员问题,这个问题在 1930 年被提出。旅行推销员问题挑战你找出一个特定推销员访问每个城市(从城市列表中)并返回原点的最短路线,这也是他被称为旅行推销员的原因。首次尝试提供解决方案将包括生成所有城市的排列组合,并选择最便宜的城市组合。显然,当城市超过 30 个时,时间复杂度开始变得难以管理。

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

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

可解释性

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

准确识别直接或间接用于做出特定决策的特征的能力称为算法的可解释性。当算法用于关键应用场景时,需要评估其偏见和偏袒。算法的伦理分析已经成为验证过程的标准部分,特别是那些涉及影响到人们生活决策的算法。

对于处理深度学习的算法来说,实现可解释性是困难的。例如,如果一个算法被用来拒绝某人的抵押贷款申请,那么拥有透明度和能够解释原因是非常重要的。

算法的可解释性是一个活跃的研究领域。最近开发的有效技术之一是局部可解释模型无关解释LIME),这是在 2016 年计算机协会ACM)的知识发现与数据挖掘特别兴趣小组SIGKDD)国际会议的论文集中提出的。LIME 基于这样一个概念:对于每个实例,引入小的变化,然后努力映射该实例的局部决策边界。然后,它可以量化每个变量对该实例的影响。

小结

本章内容是学习算法的基础。首先,我们了解了开发算法的不同阶段。我们讨论了设计算法所需的不同方法,以明确算法的逻辑。接着,我们看了如何设计一个算法。我们学习了两种不同的分析算法性能的方法。最后,我们研究了验证算法的不同方面。

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

下一章将讨论算法中使用的数据结构。我们将从 Python 中的数据结构开始,接着探讨如何利用这些数据结构创建更复杂的数据结构,例如栈、队列和树,这些都是开发复杂算法所需要的。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:

packt.link/WHLel

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/QR_Code1955211820597889031.png

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

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

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

本章讨论的主要内容如下:

  • 探索 Python 内置数据类型

  • 使用 Series 和 DataFrames

  • 探索矩阵和矩阵运算

  • 理解抽象数据类型

探索 Python 内置数据类型

在任何语言中,数据结构用于存储和操作复杂数据。在 Python 中,数据结构是用于高效管理、组织和查找数据的存储容器。它们用于存储一组称为集合的数据元素,这些数据元素需要一起存储和处理。在 Python 中,用于存储集合的主要数据结构总结如下表 2.1

数据结构简要说明示例
列表有序的、可能嵌套的、可变的元素序列["John", 33,"Toronto", True]
元组有序的不可变元素序列('Red','Green','Blue','Yellow')
字典无序的键值对集合{'brand': 'Apple', 'color': 'black'}
集合无序的元素集合{'a', 'b', 'c'}

表 2.1:Python 数据结构

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

列表

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

可以通过将元素放入 [ ] 中来定义列表,元素之间需要用逗号分隔。例如,以下代码将创建四个不同类型的元素:

list_a = ["John", 33,"Toronto", True]
print(list_a) 
['John', 33, 'Toronto', True] 

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

使用列表

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

让我们来看一下如何使用它们:

  • 列表索引:由于列表中元素的位置是确定的,因此可以使用索引来获取特定位置的元素。以下代码演示了这个概念:

    bin_colors=['Red','Green','Blue','Yellow'] 
    

    由这段代码创建的四元素列表显示在图 2.1中:

    https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_01.png

    图 2.1:Python 中的四元素列表

    现在,我们将运行以下代码:

    bin_colors[1] 
    
    'Green' 
    

    请注意,Python 是一个零索引语言。这意味着任何数据结构(包括列表)的初始索引为 0Green,即第二个元素,可以通过索引 1 获取——即 bin_colors[1]

  • 列表切片:通过指定索引范围来获取列表的子集,称为 切片。可以使用以下代码创建列表的切片:

    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 循环对列表中的每个元素进行迭代。以下示例演示了这一点:

    for color_a in bin_colors:
        print(color_a + " Square") 
    
    Red Square 
    Green Square 
    Blue Square 
    Yellow Square 
    

请注意,前面的代码通过列表迭代并打印每个元素。现在让我们使用 pop() 函数从栈中删除最后一个元素。

修改列表:append 和 pop 操作

让我们来看看修改一些列表,包括 append 和 pop 操作。

使用 append() 添加元素

当你想要在列表末尾插入新项时,可以使用 append() 方法。它的工作原理是将新元素添加到最近的可用内存位置。如果列表已满,Python 会扩展内存分配,在新开辟的空间中复制之前的项,然后插入新元素:

bin_colors = ['Red', 'Green', 'Blue', 'Yellow']
bin_colors.append('Purple')
print(bin_colors) 
['Red', 'Green', 'Blue', 'Yellow', 'Purple'] 
使用 pop() 删除元素

要从列表中提取元素,特别是最后一个元素,pop() 方法是一个方便的工具。调用该方法时,它会提取指定的项目(如果未给出索引,则提取最后一个项目)。被弹出项后面的元素会重新定位,以保持内存的连续性:

bin_colors.pop()
print(bin_colors) 
['Red', 'Green', 'Blue', 'Yellow'] 

range() 函数

range() 函数可以用来轻松生成大量数字列表。它被用来自动填充数字序列到列表中。

range() 函数使用起来非常简单。我们只需指定列表中所需的元素个数。默认情况下,它从零开始,并以 1 递增:

x = range(4)
for n in x:
  print(n) 
0 1 2 3 

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

odd_num = range(3,30,2)
for n in odd_num:
  print(n) 
3 5 7 9 11 13 15 17 19 21 23 25 27 29 

上述 range() 函数将返回从 329 的奇数。

要遍历列表,我们可以使用 for 函数:

for i in odd_num:
    print(i*100) 
300 500 700 900 1100 1300 1500 1700 1900 2100 2300 2500 2700 2900 

我们可以使用 range() 函数生成一个随机数字列表。例如,模拟十次掷骰子的实验可以使用以下代码:

import random
dice_output = [random.randint(1, 6) for x in range(10)]     
print(dice_output) 
[6, 6, 6, 6, 2, 4, 6, 5, 1, 4] 

列表的时间复杂度

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

  • 插入元素:在列表末尾插入一个元素通常具有常数时间复杂度,记作 O(1)。这意味着该操作所需的时间相对稳定,与列表的大小无关。

  • 删除元素:从列表中删除元素在最坏情况下的时间复杂度为 O(n)。这是因为在最不利的情况下,程序可能需要遍历整个列表才能删除目标元素。

  • 切片:当我们切割列表或提取其中一部分时,该操作可能需要的时间与切片的大小成正比;因此,它的时间复杂度是 O(n)

  • 元素检索:在没有索引的情况下查找列表中的元素,最坏情况下可能需要扫描所有元素。因此,它的时间复杂度也是 O(n)

  • 复制:创建列表的副本需要访问每个元素一次,导致时间复杂度为 O(n)

元组

另一个可以用来存储集合的数据结构是元组。与列表不同,元组是不可变(只读)的数据结构。元组由多个元素组成,这些元素被 ( ) 括起来。

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

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

bin_colors=('Red','Green','Blue','Yellow')
print(f"The second element of the tuple is {bin_colors[1]}") 
The second element of the tuple is Green 
print(f"The elements after third element onwards are {bin_colors[2:]}") 
The elements after third element onwards are ('Blue', 'Yellow') 
# Nested Tuple Data structure
nested_tuple = (1,2,(100,200,300),6)
print(f"The maximum value of the inner tuple {max(nested_tuple[2])}") 
The maximum value of the inner tuple 300 

在可能的情况下,应该优先选择不可变数据结构(如元组)而非可变数据结构(如列表),因为不可变数据结构的性能更好。尤其是在处理大数据时,不可变数据结构比可变数据结构要快得多。当一个数据结构作为不可变的传递给函数时,无需创建其副本,因为函数不能修改它。因此,输出可以直接引用输入数据结构。这被称为引用透明性,可以提高性能。我们为了能够修改列表中的数据元素,付出了代价,因此我们应仔细分析是否真的需要修改,以便将代码实现为只读元组,这样会更快。

请注意,由于 Python 是基于零索引的语言,a[2] 指的是第三个元素,它是一个元组 (100,200,300),而 a[2][1] 指的是该元组中的第二个元素,它是 200

元组的时间复杂度

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

  • 访问元素:元组通过索引允许直接访问其元素。这个操作是常数时间,O(1),意味着无论元组的大小如何,所需的时间保持一致。

  • 切片:当提取或切片元组的一部分时,操作的效率与切片的大小成正比,结果的时间复杂度是 O(n)

  • 元素检索:在没有任何索引帮助的情况下,查找元组中的一个元素,在最坏的情况下可能需要遍历所有元素。因此,它的时间复杂度是 O(n)

  • 复制:复制一个元组,或者创建其副本,需要遍历每个元素一次,因此它的时间复杂度是 O(n)

字典与集合

在本节中,我们将讨论集合和字典,它们用于存储没有显式或隐式顺序的数据。字典和集合非常相似。区别在于字典包含键值对,而集合可以看作是一个唯一键的集合。

让我们逐一了解它们。

字典

将数据作为键值对存储非常重要,尤其是在分布式算法中。在 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'} 

由前面的代码创建的三个键值对在下面的截图中也有展示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_02.png

图 2.2:简单字典中的键值对

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

  1. 要检索与某个键相关联的值,可以使用get函数,或者将键作为索引:

    bin_colors.get('approved_color') 
    
    'Green' 
    
    bin_colors['approved_color'] 
    
    'Green' 
    
  2. 要更新与某个键相关联的值,使用以下代码:

    bin_colors['approved_color']="Purple"
    print(bin_colors) 
    
    {'manual_color': 'Yellow', 'approved_color': 'Purple', 'refused_color': 'Red'} 
    

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

当遍历字典时,通常我们需要同时获取键和值。我们可以通过使用.items()来遍历字典:

for k,v in bin_colors.items():
    print(k,'->',v+' color') 
manual_color -> Yellow color 
approved_color -> Purple color 
refused_color -> Red color 

要从字典中del一个元素,我们将使用del函数:

del bin_colors['approved_color']
print(bin_colors) 
{'manual_color': 'Yellow', 'refused_color': 'Red'} 
字典的时间复杂度

对于 Python 字典,各种操作的时间复杂度如下:

  • 通过键访问值:字典设计用于快速查找。当你有了键,访问对应的值通常是一个常数时间操作,O(1)。除非发生哈希冲突,这是一种罕见的情况,否则通常成立。

  • 插入键值对:添加一个新的键值对通常是一个快速的操作,时间复杂度为O(1)

  • 删除键值对:当已知键时,从字典中移除条目,平均来说也是一个*O(1)*操作。

  • 查找键:由于哈希机制,验证键的存在通常是一个常数时间O(1)操作。然而,最坏情况下可能会将其提高到O(n),特别是在有大量哈希冲突时。

  • 复制:创建字典的副本需要遍历每个键值对,因此时间复杂度为线性O(n)

集合

与字典紧密相关的是集合,集合被定义为一个无序的、由不同类型的元素组成的独特元素集合。定义集合的一种方法是将值包裹在{ }中。例如,请看下面的代码块:

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

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

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

为了演示可以对集合进行哪些操作,让我们定义两个集合:

  • 一个名为yellow的集合,包含所有黄色的元素

  • 一个名为red的集合,包含所有红色的元素

请注意,这两个集合之间有一些共同的元素。这两个集合及其关系可以通过以下 Venn 图来表示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_03.png

图 2.3:展示元素如何存储在集合中的 Venn 图

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

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

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

print(f"The union of yellow and red sets is {yellow|red}") 
The union of yellow and red sets is {leaves, blood, dandelions, fire hydrant, rose} 
print(f"The intersection of yellow and red is {yellow&red}") 
The intersection of yellow and red is {'fire hydrant', 'leaves'} 

如前面的代码片段所示,Python 中的集合可以执行如并集和交集等操作。我们知道,合并操作将两个集合中的所有元素合并,而交集操作将返回两个集合之间的共同元素。注意以下几点:

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

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

由于集合是无序的,集合的元素没有索引。这意味着我们不能通过索引来访问元素。

我们可以使用for循环遍历集合中的元素:

for x in yellow:
    print(x) 
fire hydrant 
leaves 
dandelions 

我们还可以通过使用in关键字检查指定的值是否存在于集合中。

print("leaves" in yellow) 
True 

集合的时间复杂度分析

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

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

表 2.2:集合的时间复杂度

什么时候使用字典,什么时候使用集合

假设我们正在寻找一个数据结构来存储我们的电话簿。我们希望存储公司员工的电话号码。为此,字典是正确的数据结构。每个员工的名字将是键,值将是电话号码:

employees_dict = {
    "Ikrema Hamza": "555-555-5555",
    "Joyce Doston" : "212-555-5555",
} 

但如果我们只想存储员工的唯一值,那么应该使用集合来完成:

employees_set = {
    "Ikrema Hamza",
    "Joyce Doston"
} 

使用 Series 和 DataFrame

处理数据是实现大多数算法时需要做的核心工作之一。在 Python 中,数据处理通常通过使用pandas库的各种函数和数据结构来完成。

在本节中,我们将深入了解 pandas 库中的以下两个重要数据结构,这些数据结构将在本书后续部分中用于实现各种算法:

  • Series:一维数组

  • 数据框(DataFrame):一种二维数据结构,用于存储表格数据

让我们首先了解序列数据结构。

序列(Series)

pandas库中,序列(Series)是一个一维的同质数据数组。我们可以将序列视为电子表格中的一列。我们可以认为序列保存了某一变量的各种值。

序列(Series)可以按如下方式定义:

import pandas as pd
person_1 = pd.Series(['John',"Male",33,True])
print(person_1) 
0    John 
1    Male 
2    33 
3    True 
dtype:    object 

请注意,在pandas基于序列的数据结构中,有一个术语叫做“轴(axis)”,它表示某一维度中的值的序列。Series 只有“轴 0”,因为它只有一个维度。在下一节中,我们将看到这个轴的概念如何应用于数据框(DataFrame)。

数据框(DataFrame)

数据框(DataFrame)是基于序列数据结构构建的。它以二维表格数据形式存储,用于处理传统的结构化数据。我们来考虑一下以下表格:

idnameagedecision
1Fares32True
2Elena23False
3Doug40True

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

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

employees_df = pd.DataFrame([
    ['1', 'Fares', 32, True],
    ['2', 'Elena', 23, False],
    ['3', 'Doug', 40, True]])
employees_df.columns = ['id', 'name', 'age', 'decision']
print(employees_df) 
 id    name    age    decision
0    1    Fares    32    True
1    2    Elena    23    False
2    3    Doug    40    True 

请注意,在前面的代码中,df.column是一个列出列名的列表。在数据框中,单独的一列或一行称为轴(axis)。

数据框(DataFrame)也在其他流行的编程语言和框架中用于实现表格数据结构。例如,R 语言和 Apache Spark 框架。

创建数据框(DataFrame)的子集

从根本上讲,创建数据框子集的方式有两种主要方法:

  • 列选择

  • 行选择

让我们逐一了解它们。

列选择

在机器学习算法中,选择合适的特征集是一个重要的任务。在我们可能拥有的所有特征中,并不是每一个在算法的某个特定阶段都是必要的。在 Python 中,特征选择是通过列选择来实现的,这一点将在本节中讲解。

可以通过name来获取某一列,如下所示:

df[['name','age']] 
 name    age
0    Fares    32
1    Elena    23
2    Doug     40 

数据框中列的位置是确定的。可以通过位置来获取列,如下所示:

df.iloc[:,3] 
0    True 
1    False 
2    True 
Name: decision, dtype: bool 

请注意,在这段代码中,我们正在获取数据框的所有行。

行选择

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

  • 通过指定它们的位置

  • 通过指定过滤条件

可以通过行的位置来获取子集,方法如下:

df.iloc[1:3,:] 
 id    name    age    decision
1    2    Elena    23    False 

请注意,前面的代码将返回第二行和第三行以及所有列。它使用iloc方法,可以通过数值索引访问元素。

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

df[df.age>30] 
 id    name    age    decision
0    1    Fares    32    True
2    3    Doug     40    True 
df[(df.age<35)&(df.decision==True)]    id    name    age    decision 
 id    name    age    decision
0    1    Fares    32    True 

请注意,这段代码创建了一个符合筛选条件的行子集。

集合的时间复杂度分析

让我们揭示一些基础 DataFrame 操作的时间复杂度。

  • 选择操作

    • 选择列:访问 DataFrame 列,通常使用括号符号或点符号(对于没有空格的列名)进行,是一个 O(1) 操作。它提供了对数据的快速引用,而无需复制。

    • 选择行:使用 .loc[].iloc[] 等方法选择行,特别是在切片的情况下,时间复杂度是 O(n),其中“n”代表你正在访问的行数。

  • 插入操作

    • 插入列:向 DataFrame 添加新列通常是一个 O(1) 操作。然而,实际时间可能会根据数据类型和添加的数据大小而有所不同。

    • 插入行:使用 .append().concat() 等方法添加行可能会导致 O(n) 复杂度,因为它通常需要重新排列和重新分配内存。

  • 删除操作

    • 删除列:通过 .drop() 方法从 DataFrame 中删除一列是一个 O(1) 操作。它标记该列为垃圾回收对象,而不是立即删除。

    • 删除行:与插入行类似,删除行也可能导致 O(n) 时间复杂度,因为 DataFrame 需要重新排列其结构。

矩阵

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

在 Python 中,可以通过使用 numpy 数组或列表来创建矩阵。但 numpy 数组比列表要快得多,因为它们是位于连续内存位置的同质数据元素集合。以下代码可以用来从 numpy 数组创建矩阵:

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

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

矩阵操作

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

print(matrix_1.transpose()) 
array([[11, 21, 31], 
       [12, 22, 32], 
       [13, 23, 33]]) 

请注意,矩阵操作在多媒体数据处理过程中被广泛使用。

大 O 符号与矩阵

在讨论操作的效率时,大 O 符号提供了对数据规模扩展时影响的高层次理解:

  • 访问:无论是在 Python 列表还是 numpy 数组中,访问元素都是一个常数时间操作,O(1)。这是因为,通过元素的索引,你可以直接访问它。

  • 追加:将元素追加到 Python 列表的末尾是一个平均情况下的 O(1) 操作。然而,对于 numpy 数组,在最坏情况下,这个操作可能是 O(n),因为如果没有足够的连续空间,整个数组可能需要被复制到新的内存位置。

  • 矩阵乘法:这是 numpy 的强项。矩阵乘法可能会非常耗费计算资源。传统方法对于 n x n 矩阵的时间复杂度为 O(n³)。然而,numpy 使用了优化算法,如 Strassen 算法,这大大降低了计算复杂度。

现在我们已经学习了 Python 中的数据结构,接下来让我们在下一部分讨论抽象数据类型。

探索抽象数据类型

抽象数据类型ADT)是通过一组变量和一组相关操作来定义行为的高级抽象。ADT 定义了“需要期待什么”的实现指导,但给程序员在“如何实现”的细节上提供自由。例如,向量、队列和栈就是 ADT。意味着两个不同的程序员可以采用不同的方式来实现一个 ADT,比如栈。通过隐藏实现细节,并给用户提供一个通用、与实现无关的数据结构,ADT 的使用可以创建出更简洁和更清晰的代码。ADT 可以在任何编程语言中实现,如 C++、Java 和 Scala。本节中,我们将使用 Python 实现 ADT。让我们首先从向量开始。

向量

向量是存储数据的单一维度结构。它们是 Python 中最常见的数据结构之一。创建向量有两种方法,如下所示:

  • 使用 Python 列表:创建一个向量的最简单方法是使用 Python 列表,如下所示:

    vector_1 = [22,33,44,55]
    print(vector_1) 
    
    [22, 33, 44, 55] 
    
    print(type(vector_1)) 
    
    <class 'list'> 
    

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

  • 使用 numpy 数组:另一种创建向量的流行方法是使用 numpy 数组。numpy 数组通常比 Python 列表更快,且内存效率更高,尤其是在处理大量数据的操作时。这是因为 numpy 设计上是为了处理同质数据,并且可以利用底层优化。numpy 数组可以通过如下方式实现:

    vector_2 = np.array([22,33,44,55])
    print(vector_2) 
    
    [22 33 44 55] 
    
    print(type(vector_2)) 
    
    <class 'numpy.ndarray'> 
    

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

在 Python 中,我们可以使用下划线来分隔整数的各个部分。这使得它们更易于阅读并且减少了出错的可能性,特别是在处理大数字时。这对于表示十亿非常有用,可以写作 1000_000_000

large_number=1000_000_000
print(large_number) 
1000000000 

向量的时间复杂度

在讨论向量操作的效率时,理解时间复杂度是至关重要的:

  • 访问:在 Python 列表和 numpy 数组(向量)中访问元素都需要常量时间 O(1)。这确保了数据的快速检索。

  • 追加:向 Python 列表追加一个元素的平均时间复杂度为 O(1)。然而,对于 numpy 数组,追加操作在最坏情况下可能需要 O(n),因为 numpy 数组要求内存位置是连续的。

  • 搜索:在向量中查找一个元素的时间复杂度是 O(n),因为在最坏情况下,你可能需要遍历所有元素。

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

以下是与栈相关的操作:

  • isEmpty:如果栈为空,返回true

  • push:添加一个新元素

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

图 2.4 显示了如何使用 pushpop 操作向栈中添加和移除数据:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_04.png

图 2.4:Push 和 Pop 操作

图 2.4 的顶部显示了使用 push 操作向栈中添加元素的过程。在步骤 1.11.21.3 中,使用了三次 push 操作将三个元素添加到栈中。前面图形的底部用于从栈中检索存储的值。在步骤 2.22.3 中,使用了两次 pop 操作以 LIFO 格式从栈中取出两个元素。

我们在 Python 中创建一个名为Stack的类,在其中定义所有与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) 

要将四个元素压入栈中,可以使用以下代码:

Populate the stack
stack=Stack()
stack.push('Red')
stack.push('Green')
stack.push("Blue")
stack.push("Yellow") 

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

Pop
stack.pop()
stack.isEmpty() 

栈操作的时间复杂度

让我们看看栈操作的时间复杂度:

  • Push:该操作将一个元素添加到栈的顶部。由于不涉及任何迭代或检查,push 操作的时间复杂度为 O(1),即常数时间。无论栈的大小如何,元素都会被放置在顶部。

  • Pop:出栈指的是从栈中移除顶部元素。由于不需要与栈中的其余元素交互,pop 操作的时间复杂度为O(1)。这是一个直接作用于顶部元素的操作。

实际示例

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

队列

与栈类似,队列在一维结构中存储 n 个元素。元素按 FIFO 格式添加和移除。队列的一端称为 rear(后端),另一端称为 front(前端)。当从前端移除元素时,该操作称为 dequeue。当在后端添加元素时,该操作称为 enqueue

在下图中,顶部显示了入队操作。步骤 1.11.21.3 将三个元素添加到队列中,结果队列如1.4所示。请注意,黄色rear红色front

以下图的底部部分展示了dequeue操作。步骤 2.22.32.4 将队列中的元素一个接一个地从队列前端移除:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_05.png

图 2.5:入队和出队操作

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

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) 

让我们通过以下代码,结合前面的图示,来进行入队和出队操作:

# Using Queue
queue = Queue()
queue.enqueue("Red")
queue.enqueue('Green')
queue.enqueue('Blue')
queue.enqueue('Yellow')
print(f"Size of queue is {queue.size()}") 
Size of queue is 4 
print(queue.dequeue()) 
Red 

请注意,上述代码首先创建了一个队列,然后将四个项依次入队。

队列的时间复杂度分析

让我们来看看队列的时间复杂度:

  • 入队(Enqueue):此操作将一个元素插入队列的末尾。由于其简单明了,无需迭代或遍历,enqueue操作的时间复杂度为O(1) —— 恒定时间。

  • 出队(Dequeue):出队是指从队列中移除最前面的元素。由于该操作只涉及队列中的第一个元素,且无需检查或遍历队列,因此其时间复杂度仍保持为O(1)

使用栈和队列的基本思想

让我们通过一个类比来理解栈和队列的基本思想。假设我们有一张桌子,放着我们从邮政服务(例如加拿大邮政)收到的邮件。我们将它们堆放在一起,直到有时间一封封地打开并查看这些信件。有两种可能的做法:

  • 我们将信件放入栈中,每当有新信件到达时,我们将其放在栈的顶部。当我们想读取一封信时,我们从顶部开始。这就是我们所说的。请注意,最新到达的信件会在栈顶,并会优先处理。将信件从栈顶取出称为pop操作。每当有新信件到达,将其放在栈顶称为push操作。如果我们最终堆积了大量信件,并且许多信件不断到达,就有可能永远无法处理到堆栈底部那封非常重要的信件。

  • 我们将信件放入堆中,但我们想先处理最旧的信件;每次我们想查看一封或多封信时,我们会优先处理最旧的那一封。这就是我们所说的队列。将信件添加到堆中的操作称为enqueue操作。将信件从堆中移除的操作称为dequeue操作。

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

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

每棵树都有一个有限的节点集合,它有一个称为根节点的起始数据元素,节点之间通过链接连接在一起,称为分支

术语

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

根节点没有父节点的节点称为根节点。例如,在下图中,根节点是 A。在算法中,通常根节点保存着树结构中最重要的值。
节点的层级节点的层级是指从根节点到该节点的距离。例如,在下图中,节点 D、E 和 F 的层级为二。
兄弟节点树中两个节点如果处于同一层级,则称它们为兄弟节点。例如,在下图中,节点 B 和 C 是兄弟节点。
子节点与父节点如果节点 F 和节点 C 直接相连,并且节点 C 的层级低于节点 F,则节点 F 是节点 C 的子节点。反之,节点 C 是节点 F 的父节点。下图中,节点 C 和 F 展示了这种父子关系。
节点的度数节点的度数是指它有多少个子节点。例如,在下图中,节点 B 的度数为二。
树的度数树的度数等于树中各个节点度数的最大值。例如,下图所示的树的度数为二。
子树树的子树是树的一个部分,选定节点作为子树的根节点,所有子节点作为树的节点。例如,在下图中,树的节点 E 上的子树由节点 E 作为根节点,节点 G 和 H 作为两个子节点组成。
叶子节点树中没有子节点的节点称为叶子节点。例如,在下图中,节点 D、G、H 和 F 是四个叶子节点。
内部节点任何既不是根节点也不是叶子节点的节点称为内部节点。内部节点至少有一个父节点和一个子节点。

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

树的类型

有不同类型的树,下面解释了它们:

  • 二叉树:如果树的度数为二,则该树称为二叉树。例如,下图所示的树就是一棵二叉树,因为它的度数为二:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_06.png

图 2.6:一棵二叉树

请注意,上面的图显示了一棵有四层、八个节点的树。

  • 完全树:完全树是指所有节点的度数相同,并且等于树的度数。下图显示了前面讨论的几种树:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_02_07.png

图 2.7:完全树

注意,左侧的二叉树不是完全树,因为 C 节点的度为 1,而其他节点的度为 2。中间和右侧的树都是完全树。

  • 完美树:完美树是一种特殊的完全树,其中所有的叶节点位于同一层级。例如,前面图示中的右侧二叉树是一个完美的完全树,因为所有的叶节点都在同一层级——也就是第 2 层

  • 有序树:如果一个节点的子节点按照特定标准排列顺序,则该树被称为有序树。例如,树可以按从左到右的升序排列,其中同一层级的节点值在从左到右遍历时会逐渐增大。

实际例子

ADT 树是用于开发决策树的主要数据结构之一,正如在第七章《传统监督学习算法》中将讨论的那样。由于其层级结构,它在与网络分析相关的算法中也非常流行,正如在第六章《无监督机器学习算法》中将详细讨论的那样。树还用于各种搜索和排序算法中,在这些算法中需要实现分治策略。

总结

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

下一章将介绍排序和查找算法,在这一章中,我们将使用本章中介绍的一些数据结构来实现这些算法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问、并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/QR_Code1955211820597889031.png

第三章:排序和搜索算法

在本章中,我们将探讨用于排序和搜索的算法。这是一类重要的算法,可以单独使用,也可以成为更复杂算法的基础。这些包括自然语言处理NLP)和模式提取算法。本章首先介绍了不同类型的排序算法。它比较了设计排序算法的各种方法的性能。然后,详细介绍了一些搜索算法。最后,研究了本章中介绍的排序和搜索算法的一个实际例子。

到本章结束时,我们应该能够理解用于排序和搜索的各种算法,并能够理解它们的优缺点。由于搜索和排序算法是许多复杂算法的基础,详细了解它们将有助于我们更好地理解现代复杂算法,正如后面章节中所介绍的那样。

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

  • 介绍排序算法

  • 介绍搜索算法

  • 排序和搜索算法的性能分析

  • 排序和搜索的实际应用

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

介绍排序算法

在复杂数据结构中高效地排序和搜索项目的能力非常重要,因为许多现代算法都需要这样的功能。正确的排序和搜索数据的策略将取决于数据的大小和类型,正如本章中所讨论的那样。虽然最终结果完全相同,但需要正确的排序和搜索算法才能为实际问题提供高效的解决方案。因此,仔细分析这些算法的性能非常重要。

排序算法广泛应用于分布式数据存储系统,如现代 NoSQL 数据库,这些数据库支持集群和云计算架构。在这种数据存储系统中,数据元素需要定期排序和存储,以便能够有效地检索。

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

  • 冒泡排序

  • 归并排序

  • 插入排序

  • 希尔排序

  • 选择排序

但在我们深入研究这些算法之前,让我们先讨论 Python 中的变量交换技术,在本章中我们将在代码中使用它。

在 Python 中交换变量

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

var_1 = 1
var_2 = 2
var_1, var_2 = var_2, var_1
print(var_1,var_2) 
2, 1 

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

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

冒泡排序

冒泡排序是最简单也是最慢的排序算法之一。它的设计方式使得数据列表中最大的值在每次迭代中逐渐冒泡到列表的顶部。冒泡排序需要的运行时内存很少,因为所有的排序操作都发生在原始数据结构中。它不需要新的数据结构作为临时缓冲区。但其最坏情况性能是O(N²),即二次时间复杂度(其中N为待排序元素的数量)。如下一节所讨论的,它建议仅用于较小的数据集。冒泡排序的实际推荐数据大小限制将取决于可用的内存和处理资源,但通常建议将元素数量(N)控制在 1000 以下。

理解冒泡排序背后的逻辑

冒泡排序基于多次迭代,称为遍历。对于一个大小为N的列表,冒泡排序将有N-1次遍历。为了理解其工作原理,我们将专注于第一次迭代:第一次遍历。

第一次遍历的目标是将最大值推到列表的最高索引位置(顶部)。换句话说,随着第一次遍历的进行,我们将看到列表中的最大值逐渐冒泡到顶部。

冒泡排序的逻辑基于比较相邻的邻居值。如果较高索引位置的值大于较低索引位置的值,我们就交换它们。这一迭代会一直进行,直到遍历到列表的末尾。该过程如图 3.1所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_01.png

图 3.1:冒泡排序算法

现在我们来看一下如何使用 Python 实现冒泡排序。如果我们在 Python 中实现冒泡排序的第一次遍历,它将如下所示:

list = [25,21,22,24,23,27,26]
last_element_index = len(list)-1
print(0,list)
for idx in range(last_element_index):
                if list[idx]>list[idx+1]:
                    list[idx],list[idx+1]=list[idx+1],list[idx]
                print(idx+1,list) 
0 [25, 21, 22, 24, 23, 27, 26]
1 [21, 25, 22, 24, 23, 27, 26]
2 [21, 22, 25, 24, 23, 27, 26]
3 [21, 22, 24, 25, 23, 27, 26]
4 [21, 22, 24, 23, 25, 27, 26]
5 [21, 22, 24, 23, 25, 27, 26]
6 [21, 22, 24, 23, 25, 26, 27] 

注意,在第一次遍历后:

  • 最大值位于列表的顶部,存储在idx+1位置。

  • 在执行第一次遍历时,算法必须单独比较列表中的每个元素,以冒泡出最大值并将其移至顶部。

完成第一次遍历后,算法进入到第二次遍历。第二次遍历的目标是将第二大值移动到列表中的第二大索引位置。为了实现这一点,算法将再次比较相邻的邻居值,如果它们的顺序不正确,则交换它们。第二次遍历将排除已经由第一次遍历放置到正确位置的顶部索引值。因此,它需要处理的数据元素将少一个。

完成第二次遍历后,算法将继续进行第三次遍历及其后续遍历,直到列表中的所有数据点都按升序排列。对于一个大小为N的列表,算法需要进行N-1次遍历才能完全排序。

[21, 22, 24, 23, 25, 26, 27] 

我们提到过性能是冒泡排序算法的一个限制。接下来,我们将通过对冒泡排序算法的性能分析来量化其性能:

def bubble_sort(list):
# Exchange the elements to arrange in order
    last_element_index = len(list)-1
    for pass_no in range(last_element_index,0,-1):
        for idx in range(pass_no):
            if list[idx]>list[idx+1]:
                list[idx],list[idx+1]=list[idx+1],list[idx]
    return list
list = [25,21,22,24,23,27,26]
bubble_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

优化冒泡排序

上述使用bubble_sort函数实现的冒泡排序是一种直接的排序方法,其中相邻元素不断被比较,并在顺序错误时进行交换。该算法在最坏情况下始终需要进行O(N2)次比较和交换,其中N是列表中的元素数量。这是因为,对于N个元素的列表,算法无论初始顺序如何,总是需要进行N-1次遍历。

以下是优化版本的冒泡排序:

def optimized_bubble_sort(list):
    last_element_index = len(list)-1
    for pass_no in range(last_element_index, 0, -1):
        swapped = False
        for idx in range(pass_no):
            if list[idx] > list[idx+1]:
                list[idx], list[idx+1] = list[idx+1], list[idx]
                swapped = True
        if not swapped:
            break
    return list
list = [25,21,22,24,23,27,26]
optimized_bubble_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

optimized_bubble_sort函数对冒泡排序算法的性能进行了显著的优化。通过引入一个swapped标志,这种优化使得算法可以在完成所有N-1次遍历之前,提前检测到列表是否已经排序。当一次遍历没有任何交换时,它就表明列表已经排序,算法可以提前退出。因此,尽管对于完全无序或反向排序的列表,最坏情况下时间复杂度仍为O(N2),但由于这种优化,最佳情况下已经排序的列表的时间复杂度提高到了O(N)

本质上,虽然这两个函数在最坏情况下的时间复杂度都是O(N2),但是optimized_bubble_sort在实际应用中可能表现得更快,特别是在数据部分已排序的情况下,使其成为传统冒泡排序算法的更高效版本。

冒泡排序算法的性能分析

很容易看出,冒泡排序包含两层循环:

  • 外层循环:这些也被称为遍历。例如,第一次遍历是外层循环的第一次迭代。

  • 内层循环:这是指在列表中剩余的未排序元素被排序,直到最大值被冒泡到右侧。第一次遍历将有N-1次比较,第二次遍历将有N-2次比较,每次遍历都会减少一次比较的数量。

冒泡排序算法的时间复杂度如下:

  • 最佳情况:如果列表已经排序(或者几乎所有元素都已排序),那么运行时复杂度为O(1)

  • 最坏情况:如果没有元素或只有很少的元素被排序,那么最坏情况下的运行时复杂度为O(n2),因为算法将不得不完全执行内层和外层循环。

现在让我们来看看插入排序算法。

插入排序

插入排序的基本思想是在每次迭代中,我们从已有的数据结构中移除一个数据点,然后将其插入到正确的位置。这就是我们称之为插入排序算法的原因。

在第一次迭代中,我们选择两个数据点并对它们进行排序。然后,我们扩展选择范围,选择第三个数据点并根据其值找到它的正确位置。算法会继续进行,直到所有数据点都被移动到正确的位置。

这个过程如下面的图示所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_02.png

图 3.2:插入排序算法

插入排序算法可以通过以下方式在 Python 中实现:

def insertion_sort(elements):
    for i in range(1, len(elements)):
        j = i - 1
        next_element = elements[i]
        # Iterate backward through the sorted portion, 
        # looking for the appropriate position for 'next_element'
        while j >= 0 and elements[j] > next_element:
            elements[j + 1] = elements[j]
            j -= 1
        elements[j + 1] = next_element
    return elements
list = [25,21,22,24,23,27,26]
insertion_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

在算法的核心循环中,我们从第二个元素(索引为1)开始遍历列表中的每个元素。对于每个元素,算法检查前面的元素,找出它们在已排序子列表中的正确位置。这个检查是在条件elements[j] > next_element中进行的,确保我们将当前的‘next_element’放置在列表已排序部分的适当位置。

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

插入排序算法的性能分析

理解算法的效率对于判断其适用于不同应用场景至关重要。让我们深入探讨插入排序的性能特点。

最佳情况

当输入数据已经排序时,插入排序表现最佳。在这种情况下,算法高效地在线性时间内运行,表示为O(n),其中n表示数据结构中的元素数量。

最坏情况

当输入数据按逆序排列时,效率会受到影响,即最大元素位于开头。在这种情况下,对于每个元素i(其中i表示当前元素在循环中的索引),内层循环可能需要移动几乎所有前面的元素。插入排序在这种情况下的性能可以通过一个二次函数表示,形式如下:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_001.png

其中:

平均情况

通常,插入排序的平均性能趋向于二次,面对较大的数据集时,这可能会成为问题。

使用场景和建议

插入排序对于以下情况特别高效:

  • 小型数据集。

  • 几乎排序的数据集,只有少数元素未排序。

然而,对于较大且更加随机的数据集,具有更好平均和最坏情况性能的算法,如归并排序或快速排序,更为适用。插入排序的二次时间复杂度使其在处理大量数据时扩展性较差。

归并排序

与冒泡排序和插入排序等排序算法不同,归并排序因其独特的方法而显得格外突出。从历史上看,约翰·冯·诺依曼在 1940 年提出了这一技术。尽管许多排序算法在部分排序的数据上表现更好,但归并排序不受影响;其性能在数据初始排列方式无论如何都保持一致。这种韧性使其成为排序大数据集的首选方法。

分治法:归并排序的核心

归并排序采用分治策略,包含两个关键阶段——分割和合并:

  1. 分割阶段:与直接遍历列表不同,这一阶段递归地将数据集分割为两半。此分割继续进行,直到每个部分达到最小大小(为了说明目的,我们假设是一个元素)。虽然将数据分割到如此细粒度的程度可能看起来反直觉,但这种细粒度有助于在下一阶段进行有序的合并。

  2. 合并阶段:在此阶段,先前分割的部分被系统地合并。算法不断处理并合并这些部分,直到整个列表被排序。

请参见图 3.3,该图展示了归并排序算法的可视化表示。

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_03.png

图 3.3:归并排序算法

伪代码概览

在深入研究实际代码之前,让我们先通过一些伪代码来理解其逻辑:

merge_sort (elements, start, end)
    if(start < end)
        midPoint = (end - start) / 2 + start
        merge_sort (elements, start, midPoint)
        merge_sort (elements, midPoint + 1, end)
        merge(elements, start, midPoint, end) 

伪代码展示了该算法的步骤:

  1. 将列表围绕中央的midPoint分割。

  2. 递归地将数组分割,直到每个部分只有一个元素。

  3. 系统地将已排序的部分合并成一个完整的排序列表。

Python 实现

下面是归并排序的 Python 实现:

def merge_sort(elements):
    # Base condition to break the recursion
    if len(elements) <= 1:
        return elements
    mid = len(elements) // 2  # Split the list in half
    left = elements[:mid]
    right = elements[mid:]
    merge_sort(left)   # Sort the left half
    merge_sort(right)  # Sort the right half
    a, b, c = 0, 0, 0
    # Merge the two halves
    while a < len(left) and b < len(right):
        if left[a] < right[b]:
            elements[c] = left[a]
            a += 1
        else:
            elements[c] = right[b]
            b += 1
        c += 1
    # If there are remaining elements in the left half
    while a < len(left):
        elements[c] = left[a]
        a += 1
        c += 1
    # If there are remaining elements in the right half
    while b < len(right):
        elements[c] = right[b]
        b += 1
        c += 1
    return elements
list = [21, 22, 23, 24, 25, 26, 27]
merge_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

Shell 排序

冒泡排序算法比较相邻的元素,并在顺序错误时交换它们。另一方面,插入排序通过一次转移一个元素来创建排序好的列表。如果我们有一个部分排序的列表,插入排序应该能提供合理的性能。

但对于完全未排序的列表,大小为N,你可以认为冒泡排序必须完全遍历N-1轮,才能将其完全排序。

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

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

在第一轮中,我们不是选择直接相邻的元素,而是选择位于固定间隔的元素,最终排序由一对数据点组成的子列表。如下图所示。在第二轮中,它对包含四个数据点的子列表进行排序(见下图)。在随后的轮次中,每个子列表中的数据点数量不断增加,而子列表的数量不断减少,直到最终只剩下一个子列表,其中包含所有数据点。

在此时,我们可以假设列表已经排序:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_04.png

图 3.4:Shell 排序算法的各轮次

在 Python 中,实现 Shell 排序算法的代码如下:

def shell_sort(elements):
    distance = len(elements) // 2
    while distance > 0:
        for i in range(distance, len(elements)):
            temp = elements[i]
            j = i
# Sort the sub list for this distance
            while j >= distance and elements[j - distance] > temp:
                list[j] = elements[j - distance]
                j = j-distance
            list[j] = temp
# Reduce the distance for the next element
        distance = distance//2
    return elements
list = [21, 22, 23, 24, 25, 26, 27]
shell_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

请注意,调用ShellSort函数已经导致输入数组的排序。

Shell 排序算法的性能分析

可以观察到,在最坏的情况下,Shell 排序算法需要通过两个循环,因此它的时间复杂度为O(n2)。Shell 排序不适用于大数据集,它适用于中等大小的数据集。大致来说,对于最多包含 6,000 个元素的列表,它的性能相对较好。如果数据部分已经有序,那么性能会更好。在最佳情况下,如果列表已经排序完毕,它只需要遍历N个元素来验证顺序,产生最优的*O(N)*性能。

选择排序

如我们在本章前面看到的,冒泡排序是最简单的排序算法之一。选择排序是冒泡排序的改进版本,它试图减少算法所需的交换次数。它的设计是每一轮仅进行一次交换,而冒泡排序需要进行N-1次交换。与冒泡排序将最大值逐步“冒泡”到顶部不同(这样会导致N-1次交换),选择排序每次都会寻找最大值并将其移动到顶部。因此,在第一次遍历后,最大值将位于顶部;第二次遍历后,第二大的值将紧跟在最大值后面。随着算法的进行,后续的值将根据其大小移动到正确的位置。

最后一个值将在*(N-1)^(次)遍后被移动。因此,选择排序需要N-1次遍历来排序N*个项:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_03_05.png

图 3.5:选择排序算法

这里展示了选择排序在 Python 中的实现:

def selection_sort(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]
    return list
list = [21, 22, 23, 24, 25, 26, 27]
selection_sort(list) 
[21, 22, 23, 24, 25, 26, 27] 

选择排序算法的性能分析

选择排序的最坏情况性能是O(N2)。注意,它的最差性能与冒泡排序类似,因此不适合用于排序较大的数据集。不过,选择排序比冒泡排序更为精心设计,且由于减少了交换次数,它的平均性能也优于冒泡排序。

选择排序算法的选择

当谈到排序算法时,并没有一种“放之四海而皆准”的解决方案。最优选择通常取决于你数据的具体情况,例如数据的大小和当前状态。在这里,我们将深入探讨如何做出明智的决策,并通过一些现实世界的例子来加以说明。

小型且已排序的列表

对于较小的数据集,尤其是那些已经排好序的数据,通常使用复杂的算法会显得过于“杀鸡用牛刀”。虽然像归并排序这样强大的算法无可否认,但对于小数据集来说,它的复杂性可能会掩盖它的优势。

现实生活中的例子:想象一下通过书架上书籍的作者姓氏来进行排序。直接扫描并手动调整顺序(类似于冒泡排序)比采用复杂的排序方法要简单快捷得多。

部分排序的数据

当处理已经有些组织的数据时,像插入排序这样的算法表现尤为出色。它们利用现有的顺序,从而提高效率。

现实生活中的例子:考虑一个教室场景。如果学生按身高排队,但有一些略微不在正确位置,老师可以轻松发现并调整这些小的偏差(类似插入排序),而无需重新排队。

大数据集

对于庞大的数据集,数据量可能令人生畏,归并排序则被证明是一个可靠的帮手。其分治策略能够高效处理大列表,成为行业中的最爱。

现实生活中的例子:想象一下一个庞大的图书馆,每天接收成千上万本书籍。按出版日期或作者对它们进行排序需要一种系统的方法。在这里,像归并排序这样的算法,能够将任务分解成可管理的小块,显得尤为重要。

搜索算法简介

在许多计算任务的核心中,存在一个基本需求:在复杂结构中定位特定数据。从表面上看,最简单的方法可能就是扫描每一个数据点,直到找到目标。但正如我们所想象的那样,随着数据量的增加,这种方法的效果会大打折扣。

为什么搜索如此重要?无论是用户查询数据库、系统访问文件,还是应用程序获取特定数据,高效的搜索决定了这些操作的速度和响应能力。如果没有高效的搜索技术,系统会变得迟缓,尤其是在数据集不断膨胀的情况下。

随着对快速数据检索需求的增加,复杂搜索算法的作用变得不可忽视。它们提供了所需的敏捷性和高效性,能够在海量数据中迅速找到目标,确保系统保持灵活,用户满意。因此,搜索算法成为数字世界的导航者,带领我们在信息的海洋中找到精确的数据。

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

  • 线性搜索

  • 二分查找

  • 插值搜索

让我们更详细地看看它们。

线性搜索

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

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

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

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

list = [12, 33, 11, 99, 22, 55, 90]
print(linear_search(list, 12))
print(linear_search(list, 91)) 
True
False 

请注意,运行LinearSearch函数,如果能够成功找到数据,它将返回True值。

线性搜索算法的性能分析

如前所述,线性查找是一种简单的算法,执行的是穷举搜索。其最坏情况的时间复杂度为O(N)。更多信息请访问wiki.python.org/moin/TimeComplexity

二分查找

二分查找算法的前提是数据已经排序。该算法通过迭代地将列表分为两部分,并跟踪最低和最高索引,直到找到目标值:

def binary_search(elements, item):
    first = 0
    last = len(elements) - 1
    while first<=last:
        midpoint = (first + last) // 2
        if elements[midpoint] == item:
            return True
        else:
            if item < elements[midpoint]:
                last = midpoint - 1
            else:
                first = midpoint + 1
    return False 

输出如下:

list = [12, 33, 11, 99, 22, 55, 90]
sorted_list = bubble_sort(list)
print(binary_search(list, 12))
print(binary_search(list, 91)) 
True
False 

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

二分查找算法的性能分析

二分查找之所以命名为“二分查找”,是因为在每次迭代中,算法都将数据分为两部分。如果数据有 N 个元素,最多需要O(logN)步进行迭代。这意味着该算法的运行时间复杂度为O(logN)

插值查找

二分查找的基本逻辑是它专注于数据的中间部分,而插值查找则更为复杂。插值查找利用目标值来估算元素在排序数组中的位置。我们通过一个例子来理解它。假设我们想在英语词典中查找一个单词,如river。我们将利用这些信息进行插值,并开始查找以r开头的单词。一个更为通用的插值查找可以编程实现如下:

def int_polsearch(list,x ):
    idx0 = 0
    idxn = (len(list) - 1)
    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:
            return True
        if list[mid] < x:
            idx0 = mid + 1
    return False 

输出如下:

list = [12, 33, 11, 99, 22, 55, 90]
sorted_list = bubble_sort(list)
print(int_polsearch(list, 12))
print(int_polsearch(list, 91)) 
True
False 

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

插值查找算法的性能分析

如果数据分布不均匀,插值查找算法的性能将较差。该算法的最坏情况时间复杂度为O(N),而如果数据分布相对均匀,最佳性能则为O(log(log N))

实际应用

在给定数据存储库中高效且准确地查找数据,对于许多实际应用至关重要。根据你选择的查找算法,你可能还需要先对数据进行排序。选择合适的排序和查找算法将取决于数据的类型和大小,以及你要解决问题的性质。

让我们尝试使用本章介绍的算法来解决某个国家移民局为新申请人匹配历史记录的问题。当有人申请签证进入该国时,系统会尝试将申请人与现有的历史记录进行匹配。如果找到至少一个匹配项,系统会进一步计算该人过去被批准或拒绝的次数。另一方面,如果没有找到匹配项,系统将此申请人归类为新申请人,并为其分配一个新的标识符。

在历史数据中搜索、定位和识别一个人的能力对于系统至关重要。这个信息非常重要,因为如果某人过去申请过且申请被拒绝,这可能会对该人当前的申请产生负面影响。同样,如果某人的申请过去被批准,这个批准可能会增加该人当前申请获批的机会。通常,历史数据库将包含数百万行数据,我们需要一个设计良好的解决方案,以便在历史数据库中匹配新的申请人。

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

个人 ID申请 ID名字姓氏出生日期决定决定日期
45583677862JohnDoe2000-09-19已批准2018-08-07
54543877653XmanXsir1970-03-10被拒绝2018-06-07
34332344565AgroWaka1973-02-15被拒绝2018-05-05
45583677864JohnDoe2000-09-19已批准2018-03-02
22331344553KalSorts1975-01-02已批准2018-04-15

在这个表格中,第一列个人 ID与历史数据库中的每个独特申请人相关联。如果历史数据库中有 3000 万独特申请人,那么就会有 3000 万个独特的个人 ID。每个个人 ID 都标识历史数据库系统中的一个申请人。

我们的第二列是申请 ID。每个申请 ID 都标识系统中的唯一申请。一个人可能在过去申请过多次。因此,在历史数据库中,我们将拥有比个人 ID 更多的独特申请 ID。John Doe 只有一个个人 ID,但有两个申请 ID,如上表所示。

上表仅显示了历史数据集的一部分。假设我们在历史数据集中有接近 100 万行数据,涵盖了过去 10 年申请人的记录。新申请人以大约每分钟 2 个申请人的平均速度不断到达。对于每个申请人,我们需要做如下操作:

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

  • 查看是否与历史数据库中的某个申请人匹配。

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

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

假设有一个新的人带着以下凭证到达:

  • 名字:John

  • 姓氏:Doe

  • 出生日期:2000-09-19

那么,我们如何设计一个能够高效且具成本效益地执行搜索的应用程序呢?

一种搜索新申请的策略可以如下设计:

  1. 按照出生日期(DOB)对历史数据库进行排序。

  2. 每当有新的人到来时,向申请人发放一个新的应用 ID。

  3. 获取所有与出生日期匹配的记录。这将是主要的搜索条件。

  4. 从所有匹配的记录中,使用姓名进行二次搜索。

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

  6. 如果未找到匹配项,则向申请人发放新的个人 ID。

让我们尝试选择合适的算法来对历史数据库进行排序。由于数据量庞大,我们可以安全地排除冒泡排序。希尔排序表现更好,但前提是我们有部分已排序的列表。因此,归并排序可能是对历史数据库进行排序的最佳选择。

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

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

在现实场景中,每个个体在二次搜索中都需要使用某种模糊搜索算法进行识别,因为姓名可能会有轻微的拼写差异。搜索可能需要使用某种距离算法来实现模糊搜索,在该算法中,相似度超过定义阈值的数据点被认为是相同的。

摘要

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

在下一章,我们将学习动态算法。我们还将通过一个设计算法的实际示例来了解页面排名算法的细节。最后,我们将学习线性规划算法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/QR_Code1955211820597889031.png

第四章:4

设计算法

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

本章首先讨论我们在设计算法时可以选择的不同方案。然后,讨论了准确描述我们试图解决的具体问题的重要性。接下来,以著名的旅行商问题TSP)为用例,应用我们将要介绍的不同设计技术。然后,引入线性规划并讨论其应用。最后,介绍如何利用线性规划解决一个现实世界问题。

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

本章讨论了以下概念:

  • 设计算法的不同方法

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

  • 解决现实世界问题的最佳实践

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

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

介绍设计算法的基本概念

根据《美国传统词典》,算法被定义为:

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

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

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

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

请注意,设计算法是关于在给定的条件下,以最佳方式处理功能性和非功能性需求,并考虑到运行设计算法所需的资源。

为了得出一个既能满足功能要求又能满足非功能要求的良好响应,我们的设计应当关注以下三个方面,正如第一章 算法概述中所讨论的:

  • 正确性:设计的算法是否能产生我们预期的结果?

  • 性能:这是获取这些结果的最佳方式吗?

  • 可扩展性:该算法在处理更大数据集时表现如何?

在这一节中,我们将逐一讨论这些关注点。

关注点 1:正确性:设计的算法是否能产生我们预期的结果?

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

  • 定义真值:为了验证算法,我们需要一组已知正确的结果作为输入的基准。这些已知正确的结果在我们试图解决的问题的上下文中被称为真值。真值非常重要,因为它是我们在迭代优化算法时用作参考的依据。

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

    例如,对于监督式机器学习算法,我们可以使用现有的标注数据作为真值。我们可以选择一个或多个度量指标,如准确率、召回率或精确度,来量化与真值之间的偏差。需要注意的是,在某些使用场景下,正确的输出不是单一值,而是给定输入集的一个范围。随着我们对算法进行设计和开发,目标将是通过迭代优化算法,直到其在需求中指定的范围内。

  • 边界情况考虑:边界情况发生在我们设计的算法在操作参数的极限状态下运行时。边界情况通常是罕见的场景,但需要经过充分的测试,因为它可能导致我们的算法失败。非边界情况则被称为“正常路径”,涵盖了操作参数在正常范围内时通常会发生的所有场景。绝大多数情况下,算法会保持在“正常路径”上。不幸的是,无法列出所有可能的边界情况,但我们应尽可能多地考虑这些边界情况。如果没有考虑到边界情况,问题可能会发生。

关注点 2:性能:这是获取这些结果的最佳方式吗?

第二个关注点是关于回答以下问题:

这是否是最优解决方案,我们能否验证没有比我们的解决方案更好的其他解决方案存在?

乍一看,这个问题似乎很简单。然而,对于某些类别的算法,研究人员几十年来一直未能成功验证算法生成的特定解决方案是否为最佳解决方案,以及是否存在其他解决方案可以提供更好的性能。因此,首先理解问题、其需求以及运行算法所需的资源变得非常重要。

要提供某个复杂问题的最佳解决方案,我们需要回答一个基本问题:我们是否应该致力于找到这个问题的最优解?如果找到和验证最优解非常耗时且复杂,那么一个可行的解决方案可能是最好的选择。这些近似的可行解决方案被称为启发式

因此,理解问题及其复杂性是重要的,并有助于我们估计运行算法所需的资源。

在深入探讨之前,让我们先定义几个术语:

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

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

第一章算法概述中,我们介绍了大 O 符号,它可以用来分析算法的时间复杂度。在分析时间复杂度的背景下,我们考虑以下不同的时间间隔:

  • 候选解决方案生成时间t[r]:这是算法生成候选解决方案所需的时间。

  • 候选解决方案验证时间t[s]:这是验证候选解决方案(证书)所需的时间。

表征问题的复杂性

多年来,研究界根据其复杂性将问题分为不同的类别。

在我们尝试设计问题的解决方案之前,首先尝试对其进行特征化是有意义的。一般来说,问题可以分为三种类型:

  • 对于我们可以保证存在多项式算法来解决的问题

  • 对于我们可以证明无法通过多项式算法解决的问题

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

让我们根据其复杂性来看看各种类别的问题:

  • 非确定性多项式时间 (NP):可以通过非确定性计算机在多项式时间内解决的问题。广义上讲,这意味着通过在每一步做出合理的猜测,而不致力于找到最优解,可以在多项式时间内找到并验证问题的一个合理解。形式上,对于一个问题要成为 NP 问题,它必须满足以下条件,称为条件 A:

    • 条件 A:保证存在一个多项式时间算法,可以用来验证候选解(证书)是否最优。
  • 多项式时间 (P):可以通过确定性计算机在多项式时间内解决的问题。这些问题可以通过某些算法在运行时间为 O(N^k*)* 的情况下解决,无论 k 的值有多大。这些问题可以被看作是 NP 的子集。除了满足 NP 问题的条件 A 外,P 类问题还需要满足另一个条件,称为条件 B:

    • 条件 A:保证存在一个多项式时间算法,可以用来验证候选解(证书)是否最优。

    • 条件 B:保证至少存在一个多项式时间算法,可以用来解决该问题。

探索 P 类和 NP 类问题的关系

理解 P 类和 NP 类问题的关系仍在进行中。我们已知的是,P 类问题是 NP 类问题的子集,即!。这一点从上述讨论中显而易见,因为 NP 类问题只需满足 P 类问题需要满足的两个条件中的第一个条件。

P 类问题与 NP 类问题之间的关系如图 4.1所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_01.png

图 4.1:P 类问题与 NP 类问题的关系

我们目前不确定的问题是,如果一个问题是 NP 问题,它是否也是 P 问题?这是计算机科学中最伟大的未解问题之一。由克莱数学研究所选出的千年奖问题宣布,解决这个问题将获得一百万美元的奖金,因为它将在人工智能、密码学和理论计算机科学等领域产生重大影响。有些问题,比如排序,已知属于 P 类。其他问题,如背包问题和旅行商问题(TSP),已知属于 NP 类。

目前有大量的研究工作在努力解答这个问题。至今,没有研究人员发现能在多项式时间内确定性地解决背包问题或旅行商问题的算法。这个问题仍在进行中,且尚未有人能够证明不存在这样的算法。

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_02.png

图 4.2:P 是否等于 NP?我们目前尚不知晓

引入 NP 完全问题和 NP 难问题

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

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

    • 尚未有已知的多项式算法可以生成证书。

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

  • NP-hard:NP-hard 类别包含的问题至少与 NP 类中的任何问题一样难,但这些问题不一定要属于 NP 类。

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

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_03.png

图 4.3:P、NP、NP-complete 和 NP-hard 之间的关系

请注意,是否 P = NP 仍需通过研究界来证明。尽管这一问题尚未被证明,但极有可能 P ≠ NP。在这种情况下,NP-complete 问题将不存在多项式解决方案。请注意,前面的图示是基于这一假设的。

P、NP、NP-complete 和 NP-hard 之间的区别

不幸的是,P、NP、NP-complete 和 NP-hard 之间的区别并不清晰。让我们总结并研究一些例子,以便更好地理解本节中讨论的概念:

  • P:这是可以在多项式时间内解决的问题类。例如:

    • 哈希表查找

    • 像 Djikstra 算法这样的最短路径算法

    • 线性和二分查找算法

  • NP-problem:这些问题不能在多项式时间内解决,但它们的解决方案可以在多项式时间内进行验证。例如:

    • RSA 加密算法
  • NP-hard:这些是复杂的问题,目前尚未找到解决方案,但如果能够解决,将会有一个多项式时间的解决方案。例如:

    • 使用 K 均值算法进行最优聚类
  • NP-complete:NP-complete 问题是 NP 类中“最难”的问题。它们既是 NP-hard 也是 NP。例如:

    • 计算旅行商问题的最优解

如果解决了其中一种类别(NP-hard 或 NP-complete)的问题,将意味着所有 NP-hard/NP-complete 问题的解决方案都可以得到解决。

关注点 3 - 可扩展性:算法在处理更大数据集时的表现如何?

算法以定义的方式处理数据并生成结果。通常,随着数据量的增加,处理数据和计算所需结果所需的时间也会越来越长。大数据这一术语有时用来粗略标识由于数据集的体量、种类和流速而预计对基础设施和算法构成挑战的数据集。一个设计良好的算法应该具有可扩展性,这意味着它应该以一种方式设计,使其能够在可能的情况下高效运行,利用可用资源并在合理的时间框架内生成正确的结果。当处理大数据时,算法的设计变得尤为重要。为了量化算法的可扩展性,我们需要关注以下两个方面:

  • 随着输入数据增大,资源需求增加:估算这种需求的过程被称为空间复杂度分析。

  • 随着输入数据增加,运行所需的时间也会增加:估算这一点的过程叫做时间复杂度分析。

请注意,我们正生活在一个数据爆炸的时代。大数据一词已成为主流,它能够捕捉现代算法通常需要处理的数据的规模和复杂性。

在开发和测试阶段,许多算法仅使用少量数据样本。当设计算法时,考虑算法的可扩展性非常重要。特别是,必须仔细分析(即测试或预测)随着数据集增大,算法性能的变化。

云的弹性与算法可扩展性

云计算为应对算法的资源需求提供了新的选择。云计算基础设施能够在处理需求增加时提供更多的资源。云计算的这一能力被称为基础设施的弹性,现如今它为我们设计算法提供了更多的选择。当算法部署在云上时,可能会根据要处理的数据规模要求更多的 CPU 或虚拟机(VM)。

典型的深度学习算法就是一个很好的例子。要训练出一个优秀的深度学习模型,需要大量标注数据。对于一个设计良好的深度学习算法,训练深度学习模型所需的处理量与示例的数量是成正比的,或者说接近正比。当在云端训练深度学习模型时,随着数据量的增加,我们会尝试配置更多的资源,以保持训练时间在可管理的范围内。

理解算法策略

一个设计良好的算法会尽可能地通过将问题划分成更小的子问题来最有效地利用可用资源。设计算法时有不同的算法策略。一种算法策略涉及算法中的以下三个方面:

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

  • 分治策略

  • 动态规划策略

  • 贪心算法策略

理解分治策略

一种策略是找到一种方法,将较大的问题划分成可以独立解决的小问题。这些小问题的子解将被合并以生成问题的整体解决方案。这就是分治策略。

从数学上讲,如果我们正在为一个有 n 个输入的(P)问题设计解决方案,需要处理数据集 d,我们将问题划分为 k 个子问题,P1Pk。每个子问题将处理数据集的一个分区 d。通常,我们会让 P1Pk 分别处理 d1dk

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

一个实际的例子——分治法应用于 Apache Spark

Apache Spark (spark.apache.org/) 是一个开源框架,用于解决复杂的分布式问题。它实现了一种分治策略来解决问题。为了处理一个问题,它将问题划分为多个子问题,并独立地处理这些子问题。这些子问题可以在不同的机器上运行,从而实现水平扩展。我们将通过一个简单的示例——从列表中统计单词来演示这一点。

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

words_list = ["python", "java", "ottawa", "news", "java", "ottawa"]

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

分治法的实现如下面的示意图所示:

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_04.png图 4.4:分治法

前面的示意图展示了一个问题分解的以下阶段:

  1. 拆分:输入数据被划分为可以独立处理的分区,这叫做拆分(splitting)。在前面的示意图中,我们有三个分割。

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

  3. 洗牌:洗牌是将相似的键聚集在一起的过程。一旦相似的键被聚集在一起,就可以对它们的值运行聚合函数。注意,洗牌是一个性能密集型操作,因为相似的键需要被聚集,而这些键原本可能分布在整个网络中。

  4. 归约:对相似键的值运行聚合函数叫做归约(reducing)。在前面的示意图中,我们需要统计单词的数量。

让我们看看如何编写代码来实现这一点。为了演示分治策略,我们需要一个分布式计算框架。我们将使用 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 
    
  2. 现在,让我们创建一个包含一些单词的示例列表。我们将把这个列表转换为 Spark 的本地分布式数据结构,称为弹性分布式数据集RDD):

    wordsList = ['python', 'java', 'ottawa', 'ottawa', 'java','news']
    wordsRDD = sc.parallelize(wordsList, 4)
    # Print out the type of wordsRDD
    print (wordsRDD.collect()) 
    
  3. 它将打印:

    ['python', 'java', 'ottawa', 'ottawa', 'java', 'news'] 
    
  4. 现在,让我们使用map函数将单词转换为键值对:

    wordPairs = wordsRDD.map(lambda w: (w, 1))
    print (wordPairs.collect()) 
    
  5. 它将打印:

    [('python', 1), ('java', 1), ('ottawa', 1), ('ottawa', 1), ('java', 1), ('news', 1)] 
    
  6. 让我们使用reduce函数来进行聚合并得到结果:

    wordCountsCollected = wordPairs.reduceByKey(lambda x,y: x+y)
    print(wordCountsCollected.collect()) 
    
  7. 它打印:

    [('python', 1), ('java', 2), ('ottawa', 2), ('news', 1)] 
    

这展示了我们如何使用分治策略来统计单词的数量。请注意,当一个问题可以被分解为子问题并且每个子问题至少可以在某种程度上独立地解决时,分治法是有用的。但对于需要大量迭代处理的算法,如优化算法,分治法并不是最佳选择。对于这类算法,适用的是动态规划,接下来将介绍这一内容。

现代云计算基础设施,如 Microsoft Azure、Amazon Web Services 和 Google Cloud,通过实现分治策略(无论是直接还是间接的方式)在分布式基础设施中实现了可扩展性,能够并行使用多个 CPU/GPU。

理解动态规划策略

在上一节中,我们学习了分治法,它是一种自上而下的方法。与此相对,动态规划是一种自下而上的策略。我们从最小的子问题开始,不断地将解决方案组合起来,直到达到最终的解决方案。像分治法一样,动态规划通过将子问题的解决方案组合来解决问题。

动态规划是一种由理查德·贝尔曼(Richard Bellman)在 1950 年代提出的优化特定类别算法的策略。需要注意的是,在动态规划中,"编程"一词指的是使用表格方法,与编写代码无关。与分治策略相对,动态规划适用于子问题之间不独立的情况。它通常应用于优化问题,其中每个子问题的解决方案都有一个值。

我们的目标是找到一个具有最优值的解决方案。动态规划算法只对每个子问题求解一次,并将其结果保存在表格中,从而避免了每次遇到子问题时都重新计算答案。

动态规划的组成部分

动态规划基于两个主要组成部分:

  • 递归:它通过递归的方式解决子问题。

  • 记忆化:记忆化或缓存。它基于一种智能缓存机制,尝试重用重计算的结果。这个智能缓存机制称为记忆化。子问题部分涉及到在多个子问题中重复的计算。这个思想是只进行一次计算(这是耗时的步骤),然后在其他子问题中重用它。这是通过记忆化来实现的,这在解决可能多次评估相同输入的递归问题时尤其有用。

使用动态规划的条件

我们尝试用动态规划解决的问题应具备两个特征。

  • 最优结构:当我们尝试解决的问题可以被分解成子问题时,动态规划能够带来良好的性能收益。

  • 重叠子问题:动态规划使用一个递归函数,通过调用自身并解决原问题的较小子问题来解决特定问题。子问题的计算结果会存储在一个表格中,避免重复计算。因此,在存在重叠子问题的情况下,需要使用这一技术。

动态规划非常适合组合优化问题,这类问题需要提供输入元素的最优组合作为解决方案。

示例包括:

  • 为像 FedEx 或 UPS 这样的公司寻找最优的包裹配送方式

  • 寻找最优的航空公司航线和机场

  • 决定如何为像 Uber Eats 这样的在线外卖系统分配司机

理解贪心算法

正如名字所示,贪心算法相对较快地产生一个好的解,但它不一定是最优解。与动态规划类似,贪心算法主要用于解决无法使用分治策略的优化问题。在贪心算法中,解决方案通过一系列步骤逐步计算。在每一步中,做出局部最优选择。

使用贪心编程的条件

贪心算法是一种在具有以下两种特征的问题中表现良好的策略:

  • 从局部到全局:通过选择局部最优解,可以达到全局最优解。

  • 最优子结构:问题的最优解由其子问题的最优解组成。

为了理解贪心算法,我们首先定义两个术语:

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

贪心算法基于第二种策略,我们不努力寻找全局最优解,而是选择最小化算法开销。

使用贪心算法是一种快速简便的策略,用于解决多阶段问题的全局最优值。它基于选择局部最优值,而不努力验证局部最优值是否也为全局最优。通常,除非我们运气好,贪心算法不会得到可以视为全局最优的值。然而,找到一个全局最优值是一个耗时的任务。因此,与分治法和动态规划算法相比,贪心算法的速度较快。

通常,贪心算法定义如下:

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

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

  3. 重复该过程,直到 S 填满或 D 耗尽。

示例

分类与回归树 (CART) 算法是一个贪心算法,它在顶层寻找最优划分。它在每个后续层次重复此过程。请注意,CART 算法并不计算并检查该划分是否会导致几层下的最低可能杂质。CART 使用贪心算法,因为找到最优树被认为是一个 NP 完全问题。它的算法复杂度是 O(exp(m)) 时间。

一个实际应用——解决 TSP 问题

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

一个旅行推销员需要访问给定的城市列表才能完成任务:

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

请注意以下几点:

  • 城市之间的距离是已知的

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

我们能为旅行推销员生成旅行计划吗?什么是能够最小化旅行推销员总行程的最优解?

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

渥太华蒙特利尔金斯顿多伦多萨德伯里
渥太华-199196450484
蒙特利尔199-287542680
金斯顿196287-263634
多伦多450542263-400
萨德伯里484680634400-

注意,目标是得到一条从起始城市出发并返回起始城市的路线。例如,一个典型的路线可能是渥太华–萨德伯里–蒙特利尔–金斯顿–多伦多–渥太华,总费用为 484 + 680 + 287 + 263 + 450 = 2,164。这是销售员需要行走的最短路线吗?能够最小化销售员总行程的最优解是什么?我留给你自己去思考并计算。

使用暴力破解策略

解决旅行商问题(TSP)时,首先想到的解决方案是通过暴力破解方法找到最短路径,使得销售员恰好访问每座城市一次,并返回到起始城市。因此,暴力破解策略如下:

  • 评估所有可能的路线。

  • 选择距离最短的那个。

问题是,对于 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 

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

计算两座城市,ab,之间的距离就像 distance (a,b) 一样简单。

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

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)) 

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

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

现在让我们定义一些有助于绘制城市图的实用函数。我们将定义以下函数:

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

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

看一下以下代码:

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(),我们就可以用它来创建旅行路线:

from time import time
from collections import Counter
def tsp(algorithm, cities):
    t0 = time()
    tour = algorithm(cities)
    t1 = time()
    # Every city appears exactly once in tour
    assert Counter(tour) == Counter(cities) 
    visalize_tour(tour)
    print("{}:{} cities => tour length {;.0f} (in {:.3f} sec".format(
        name(algorithm), len(tour), distance_tour(tour), t1-t0))
def name(algorithm):
    return algorithm.__name__.replace('_tsp','')
tps(brute_force, generate_cities(10)) 

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_05.png

图 4.5:TSP 问题的解决方案

请注意,我们已经用它生成了 10 个城市的旅行路线。由于n = 10,它将生成*(10-1)! = 362,880*种可能的排列组合。如果 n 增加,排列组合的数量急剧增加,暴力破解法将无法使用。

使用贪心算法

如果我们使用贪心算法来解决旅行商问题(TSP),那么在每一步,我们可以选择一个看起来合理的城市,而不是寻找一个能够导致最佳整体路径的城市。因此,每当我们需要选择一个城市时,我们只是选择离得最近的城市,而不考虑这个选择是否能导致全局最优路径。

贪心算法的方法很简单:

  1. 从任何城市开始。

  2. 在每一步,通过移动到下一个城市来继续构建旅行路线,该城市是最近的未访问过的邻近城市。

  3. 重复步骤 2

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

def greedy_algorithm(cities, start=None):
    city_ = start or first(cities)
    tour = [city_]
    unvisited = set(cities - {city_})
    while unvisited:
        city_ = nearest_neighbor(city_, unvisited)
        tour.append(city_)
        unvisited.remove(city_)
    return tour
def first(collection): return next(iter(collection))
def nearest_neighbor(city_a, cities):
    return min(cities, key=lambda city_: distance_points(city_, city_a)) 

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

tsp(greedy_algorithm, generate_cities(2000)) 

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_06.png

图 4.6:在 Jupyter Notebook 中显示的城市

请注意,生成 2,000 个城市的旅行路线仅用了0.514秒。如果我们使用暴力破解法,它将生成*(2000-1)!* = 1.65e⁵⁷³²个排列组合,这几乎是无限的。

请注意,贪心算法是基于启发式的,并且没有证明该解是最优的。

三种策略的比较

总结来说,贪心算法的结果在计算时间上更为高效,而暴力破解法则提供了全局最优解的组合。这意味着计算时间和结果的质量有所不同。所提出的贪心算法可能会得到与暴力破解几乎相同的结果,但计算时间显著较少,但由于它不搜索最优解,因此它基于一种基于努力的策略,并且没有保证。

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

展示 PageRank 算法

作为一个实际示例,让我们来看一下 Google 用来对用户查询的搜索结果进行排名的 PageRank 算法。它生成一个数字,量化搜索结果在用户执行的查询背景下的重要性。这一算法由两位博士生,Larry Page 和 Sergey Brin,在 1990 年代末期的斯坦福大学设计,他们也随之创办了 Google。PageRank 算法是以 Larry Page 的名字命名的。

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

问题定义

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

实现 PageRank 算法

首先,在使用 PageRank 算法时,采用以下表示方法:

  • 网页在有向图中由节点表示。

  • 图中的边代表超链接。

PageRank 算法中最重要的部分是找到计算查询结果中每个网页重要性的最佳方法。某一特定网页在网络中的排名是通过计算一个随机浏览边(即点击链接)的人到达该页面的概率来得出的。此外,该算法由阻尼因子 alpha 参数化,默认值为 0.85。这个阻尼因子表示用户继续点击的概率。请注意,PageRank 最高的页面是最具吸引力的:无论用户从哪里开始,该页面都有最高的概率成为最终目标页面。

该算法需要多次迭代或遍历网页集合,以确定每个网页的正确重要性(或 PageRank 值)。

为了计算一个从 01 的数字,用来量化某个特定网页的重要性,算法结合了以下两个组件的信息:

  • 与用户输入的查询相关的信息:这个组件在用户输入的查询背景下,评估网页内容的相关性。网页的内容直接依赖于页面的作者。

  • 与用户输入的查询无关的信息:这个组件试图量化每个网页在其链接、浏览量和邻域中的重要性。一个网页的邻域是与该页面直接相连的网页群体。由于网页具有异质性,且很难制定适用于整个网页的标准,这一组件的计算非常困难。

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

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

注意,该网络来自 networkx.org/。为了演示的目的,假设我们只分析网络中的五个网页。让我们称这组页面为 my_pages,它们一起位于名为 my_web 的网络中:

my_web = nx.DiGraph()
my_pages = range(1,6) 

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

connections = [(1,3),(2,1),(2,3),(3,1),(3,2),(3,4),(4,5),(5,1),(5,4)]
my_web.add_nodes_from(my_pages)
my_web.add_edges_from(connections) 

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

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

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

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_07.png

图 4.7:网络的视觉表示

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

在我们的情况下,前面的图显示了我们拥有的静态网页。让我们定义一个可以用来创建转移矩阵的函数:

def create_page_rank(a_graph):
    nodes_set = len(a_graph)
    M = nx.to numpy_matrix(a_graph)
    outwards = np.squeeze(np.asarray (np. sum (M, axis=1)))
    prob outwards = np.array([
        1.0 / count if count>0
        else 0.0
        for count in outwards
    ])
    G = np.asarray(np.multiply (M.T, prob_outwards))
    p = np.ones(nodes_set) / float (nodes_set)
    return G, p 

注意,该函数将返回 G,它代表我们图的转移矩阵。

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

G,p = create_page_rank(my_web)
print (G) 

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/B18046_04_08.png

图 4.8:转移矩阵

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

注意,转移矩阵是一个稀疏矩阵。随着节点数量的增加,其大部分值将为 0。因此,图的结构被提取为转移矩阵。在转移矩阵中,节点以列和行表示:

  • :指示网络浏览者在线的节点

  • :指示浏览者因出站链接而访问其他节点的概率

在真实的网络中,PageRank 算法所需的转移矩阵是由蜘蛛持续探索链接建立的。

理解线性规划

许多现实世界问题涉及最大化或最小化一个目标,并且有一些给定的约束条件。一种方法是将目标指定为某些变量的线性函数。我们还将资源约束条件表述为这些变量上的等式或不等式。这种方法被称为线性规划问题。线性规划背后的基本算法是由乔治·丹齐格(George Dantzig)在 20 世纪 40 年代初在加利福尼亚大学伯克利分校开发的。丹齐格在为美国空军工作时,利用这一概念进行部队物流供给与产能规划的实验。

第二次世界大战结束时,丹齐格开始为五角大楼工作,并将他的算法发展成了一种叫做线性规划的技术。这一技术被用于军事作战规划。

今天,它被用于解决与基于某些约束条件最小化或最大化一个变量相关的重要现实世界问题。以下是一些此类问题的例子:

  • 基于资源最小化修车时间

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

  • 基于公司内部资源的最优分配来最大化公司的利润

构建线性规划问题

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

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

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

定义目标函数

请注意,上述三个例子的目标都是关于最小化或最大化一个变量。这个目标在数学上被表述为其他变量的线性函数,称为目标函数。线性规划问题的目标是最小化或最大化目标函数,同时保持在指定的约束条件内。

指定约束条件

在尝试最小化或最大化某个变量时,现实世界问题中往往会有一些需要遵守的约束条件。例如,在试图最小化修车时间时,我们还需要考虑到可用的机械师数量有限。通过线性方程来指定每个约束条件是构建线性规划问题的重要部分。

一个实际应用——使用线性规划进行产能规划

让我们看看一个实际的用例,看看如何使用线性规划来解决现实世界中的问题。

假设我们想要最大化一家先进工厂的利润,该工厂生产两种不同类型的机器人:

  • 高级模型A):该模型提供完整的功能。制造每个高级模型单位的利润为$4,200。

  • 基本模型B):该模型只提供基本功能。制造每个基本模型单位的利润为$2,800。

制造一台机器人需要三种不同类型的人。每种类型机器人制造所需的具体天数如下:

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

工厂采用 30 天的周期运行。每个 AI 专家在一个周期内有 30 天的可用时间。每个工程师在 30 天内会休息 8 天,因此,工程师每个周期只能工作 22 天。一个技术员在 30 天的周期中有 20 天可用。

下表显示了我们工厂中有多少人:

技术员AI 专家工程师
人数112
周期总天数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的值以及目标函数的值:

# Print our decision variable values
print (A.varValue)
print (B.varValue) 

输出结果是:

6.0
1.0 
# Print our objective function value
print (pulp.value(model.objective)) 

它打印出:

32500.0 

线性规划广泛应用于制造业,用于寻找应使用的最优产品数量,以优化现有资源的使用。

本章到此结束!让我们总结一下所学的内容。

总结

在本章中,我们研究了设计算法的各种方法。我们考察了选择正确算法设计时的权衡。我们还讨论了制定现实世界问题的最佳实践,并学习了如何解决实际的优化问题。本章所学的内容可以用于实现设计良好的算法。

在下一章中,我们将重点介绍基于图的算法。我们将首先研究图的不同表示方式。接下来,我们将学习在各种数据点周围建立邻域的技术,以进行特定调查。最后,我们将研究在图中查找信息的最佳方法。

在 Discord 上了解更多

要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本的发布——请扫描下面的二维码:

packt.link/WHLel

https://github.com/OpenDocCN/freelearn-ds-pt4-zh/raw/master/docs/50-algo-evy-prog-shld-know/img/QR_Code1955211820597889031.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值