



一个算法必须被看到才能被相信。
——Donald Knuth
本书涵盖了对各种重要算法进行理解、分类、选择和实现所需的信息。除了阐述这些算法的逻辑,本书还讲解适用于各种算法的数据结构、开发环境和生产环境。本书着重讲述现代机器学习算法,因为它们的重要性与日俱增。在讲解算法逻辑的同时,本书还提供例子来展示如何使用算法来求解日常生活中的实际问题。
本章概述算法基础知识。首先介绍理解不同算法如何工作所需的基本概念,以历史视角总述了人们最初如何用算法以数学的形式表达特定类型的问题,还提到了不同算法的局限性。接着讲述描述算法逻辑的各种方法。由于本书用python编写算法,因此接下来说明如何设置Python环境以运行书中给出的例子。然后讨论如何用不同方法量化算法性能并与其他算法进行比较。最后讨论了验证算法的特定实现的各种方法。
本章涵盖以下主题:
❑ 什么是算法。
❑ 算法的各个阶段。
❑ 开发环境。
❑ 算法设计技巧。
❑ 性能分析。
❑ 验证算法。
简而言之,算法是求解问题的计算规则集,每条规则都执行某种计算。它旨在根据精确定义的指令,为任何有效的输入产生对应的输出结果。在英语传统词典中,“算法”这一概念的定义如下:
算法是由无歧义指令构成的有限集合,它在给定的一组初始条件下按预定顺序执行,直到满足给定的、可识别的结束条件以实现某种目的。
算法设计致力于设计一种最高效的数学步骤来有效地求解实际问题。以所得的数学步骤为基础,可以开发一个可复用且通用性更强的数学方案来求解更广泛的类似问题。
图1.1展示了开发、部署和使用算法的不同阶段。
图1.1 开发、部署和使用算法的不同阶段
可以看到,整个过程始于从问题表述中了解算法的设计需求,明确需要完成的事项细节。一旦问题被明确表述,就可以进入开发阶段。
开发阶段由两个阶段构成:
❑ 设计阶段: 设计阶段要构思算法的架构、逻辑和实现细节并形成文档。设计算法时,我们既要考虑算法的准确性,又要考虑算法的性能。为给定问题设计算法时,很多时候,我们最终会得到多个候选算法。算法设计阶段是一个迭代的过程,需要对各种候选算法进行比较。有些算法简单而快速,但可能会牺牲一些准确性。其他算法可能非常准确,但由于复杂性高,可能需要大量的运行时间。在这些复杂的算法中,也有一些算法比其他算法更高效。在做出选择之前,应该仔细研究候选算法的所有内在平衡因素。特别地,为复杂问题设计高效算法非常重要。恰当设计的算法是一个有效的解决方案,它不仅具有令人满意的性能,还具有令人信服的准确性。
❑ 编码阶段 :编码阶段将设计好的算法转化为计算机程序。重要的是,实际计算机程序必须实现设计阶段提出的所有逻辑和结构。
业务问题的需求分为功能性需求和非功能性需求。直接指定解决方案的预期特性的需求被称为功能性需求。功能性需求详细说明了解决方案的预期行为。另外,非功能性需求是关于算法的性能、可伸缩性、可用性和准确性的需求。非功能性需求还建立了对数据安全性的期望。例如,让我们为一个信用卡公司设计一个用来识别和标记欺诈交易的算法。本例中的功能性需求将通过提供特定输入数据集的预期输出细节,来指定有效解决方案的预期行为。在这种情况下,输入数据可能是事务的详细信息,并且输出可能是一个二进制标记,它将事务标记为欺诈或非欺诈。在本例中,非功能性需求可以指定每个预测的响应时间。非功能性需求还将设置精度的允许阈值。当我们在本例中处理财务数据时,与用户身份验证、授权和数据保密性相关的安全性需求也将成为非功能性需求的一部分。
请注意,功能性需求和非功能性需求的目的在于精确界定需要完成的任务。设计解决方案涉及弄清楚如何执行这些任务。而实施设计则是利用选择的编程语言开发实际的解决方案。提出一个完全满足功能性需求和非功能性需求的设计可能需要大量的时间和精力。选择合适的编程语言和开发/生产环境可能取决于问题的需求。例如,由于C/C++是比Python更低级的语言,因此对于需要编译和底层优化的算法,它可能是更好的选择。
一旦设计阶段完成并编码完成,就可以部署算法了。部署算法涉及设计实际的生产环境,代码将在其中运行。生产环境需要根据算法的数据和处理需求进行设计。例如,对于可并行化的算法,为了有效执行算法,将需要具有适当数量计算节点的集群。对于数据密集型算法,可能需要设计数据输入管道以及数据缓存和数据存储策略。有关设计生产环境的详细讨论详见第15章和第16章。
一旦生产环境设计和实施完成,算法就会被部署,它接收输入数据,处理数据,并按照要求生成输出。
一旦设计完成,算法需要根据设计在编程语言中实现。本书选择了Python作为编程语言。我们选择Python是因为它灵活且是一种开源编程语言。Python也可以在各种云计算基础设施中使用,比如AWS、微软Azure和GCP。
Python官方主页是https://www.python.org/,其中包含安装说明和对你可能有所帮助的初学者指南。
为了更好地理解本书介绍的概念,你需要对Python有基本的了解。
我们希望你使用Python 3,因为在编写本书时,最新版本是3.10,我们将用它运行书中的练习。
在本书中,我们将始终使用Python。我们还将使用Jupyter Notebook来运行代码。其余章节假定Python已安装,并且Jupyter Notebook已经正确配置并正在运行。
Python是一种通用语言。它遵循“一切尽在其中”的理念,这意味着有一个标准库可供使用,而不需要用户下载单独的包。然而,标准库模块只提供最基本的功能。根据你正在处理的特定用例,可能需要安装额外的包。Python第三方包的官方存储库称为PyPI,即 Python Package Index 。它既托管Python包的源代码分发版,也托管预编译代码。目前,PyPI上托管了超过113000个Python包。安装额外的包,最简单的方法是通过pip包管理系统。pip是一个典型的递归缩写,这在Python文化中很常见。pip代表 Pip Installs Python 。好消息是,从Python的3.4版本开始,pip将默认安装。要检查pip的版本,可以在命令行中输入:
以下pip命令可以用来安装额外的包:
已安装的软件包需要定期更新以获得最新功能。这可以通过使用upgrade参数来实现:
以下命令可以安装特定版本的Python包:
添加正确的库和版本已成为设置Python编程环境的一部分。有助于维护这些库的一个功能是能够创建一个需求文件,列出所有需要的包。需求文件是一个简单的文本文件,包含库的名称及其相关版本。需求文件的示例如下所示:
按照惯例,requirements.txt文件放置在项目的顶级目录中。
创建后,可以使用以下命令通过需求文件设置开发环境,安装所有Python库及其相关版本:
接下来将介绍一些与算法相关的比较重要的包。
Scientific Python (SciPy) 是一组专为科学界创建的Python包。它包含许多函数,包括各种随机数生成器、线性代数例程和优化器。
SciPy是一个综合性的软件包,随着时间的推移,人们开发了许多扩展程序来根据自身需求定制和扩展该软件包。SciPy的性能很高,因为它就像是围绕用C/C++或Fortran编写的优化代码的瘦包装器。
以下是属于这个生态系统的主要的包:
❑ NumPy: 对于算法来说,创建多维数据结构(如数组和矩阵)的能力非常重要。NumPy提供了一组数组和矩阵数据类型,这些数据类型对于统计学和数据分析非常重要。关于NumPy的细节可以在http://www.numpy.org/上找到。
❑ scikit-learn: 这个机器学习扩展包是SciPy最受欢迎的扩展包之一。scikit-learn提供了一系列重要的机器学习算法,包括分类、回归、聚类和模型验证。你可以在http://scikit-learn.org/上找到更多关于scikit-learn的细节以供学习。
❑ pandas: pandas是一个开源软件库。它包含表格型的复杂数据结构,该数据结构在各种算法中被广泛用于输入、输出和处理表格数据。pandas库包含许多有用的函数,还提供了高度优化的性能。关于pandas的更多细节可以在http://pandas.pydata.org/上找到。
❑ Matplotlib: Matplotlib提供了强大的可视化工具。数据可以通过折线图、散点图、柱状图、直方图、饼图等形式呈现。要获得更多信息请访问https://matplotlib.org/。
使用Jupyter Notebook
我们将使用Jupyter Notebook和Google的Colaboratory作为集成开发环境(IDE)。
算法是求解实际问题的数学方案。我们在设计和微调算法时,需要牢记以下三个设计的关注点:
❑ 第一点 :算法能否产生预期的结果?
❑ 第二点 :这是不是获得预期结果的最佳方法?
❑ 第三点 :算法将如何在更大的数据集上执行?
在设计解决方案之前,了解问题自身的复杂性非常重要。例如,如果我们能够根据问题的需求和复杂性进行描述,就能够更好地设计出合适的解决方案。
一般来说,根据问题的特征,算法可以分为以下几种类型:
❑ 数据密集型算法 :数据密集型算法旨在处理大量数据。它们通常具有相对简单的处理要求。压缩大型文件的算法就是数据密集型算法的典型例子。对于这类算法,待处理数据的规模远大于处理引擎(单节点或集群)的内存规模,因此可能需要开发一个迭代处理设计程序来根据要求高效地处理数据。
❑ 计算密集型算法 :计算密集型算法具有大量计算需求而不涉及大量数据。一个简单的例子是查找一个非常大的质数的算法。找到一种将算法划分为不同阶段的策略,使至少其中一些阶段可以并行化,这对于最大化算法性能至关重要。
❑ 既是数据密集型又是计算密集型的算法: 有些算法需要处理大量数据,同时也具有相当大的计算需求。实时视频信号上的情感分析算法就是这种算法的一个很好的例子,其中数据和计算需求都很大。这类算法是最耗费资源的算法,需要对算法进行精心设计,并对可用资源进行智能分配。
为了更好地描述问题的复杂性和需求,如果我们对其数据和计算维度进行更深入的研究,将会很有帮助。我们接下来将对此进行详细探讨。
将算法从问题的数据维度上归类,我们需要查看数据的 体积(Volume) 、 速度(Velocity) 和 多样性(Variety) ,这三个方面被称为数据的3V,其定义如下:
❑ 体积 :体积是指算法将要处理的数据的预期规模。
❑ 速度 :速度是指使用该算法时新数据生成的预期速度,它可以为零。
❑ 多样性 :多样性量化了所设计的算法预计要处理多少种不同类型的数据。
图1.2更加详细地展示了数据的3V,其中心是最简单的数据,体积小、速度慢、多样性低;逐渐远离中心时,数据复杂性逐渐增加,这可以从三个维度中的一个或多个维度上增加。例如,在速度维度上,最简单的是 批处理 过程,其次是 周期性处理 过程,然后是 近实时处理 过程,最后是 实时处理 过程。 实时处理 从数据速度维度上看是最复杂的情况。例如,由一组监控摄像头收集的实时视频信号的集合,具有体积大、速度快和多样性高的特点,因此可能需要恰当的设计才能有效地存储和处理数据。
图1.2 数据的3V:体积、速度和多样性
让我们考虑三个使用案例,它们分别涉及三种不同类型的数据:
❑ 第一,考虑一个简单的数据处理使用案例,其中输入数据是一个.csv文件。在这种情况下,数据的体积、速度和多样性将会很低。
❑ 第二,考虑一个使用案例,其中输入数据是安全视频摄像头的实时流。现在数据的体积、速度和多样性将会非常高,在设计算法时应该牢记这一点。
❑ 第三,考虑一个典型的传感器网络使用案例。假设传感器网络的数据源是安装在大型建筑物中的温度传感器网格。尽管数据生成的速度通常非常高(因为新数据会非常快地生成),但体积预计会很低(因为每个数据元素通常只有16位长,包括8位测量值和8位元数据,如时间戳和地理坐标)。
针对以上三个例子,处理需求、存储需求和适当的软件栈(指的是一组相互关联的软件组件,它们一起工作以实现特定的功能或服务)选择都会有所不同,总体上取决于数据源的体积、速度和多样性。对数据进行表征是设计算法的第一步,这一点非常重要。
对于计算维度的表征,我们需要分析问题的处理需求。算法的处理需求决定了最适合它的设计方式。例如,一般来说,复杂的算法需要大量的处理能力。对于这类算法,拥有多节点并行架构可能非常重要。正如第16章所讨论的,现代深度学习算法通常涉及大量的数值处理,可能需要GPU或TUP等处理器的算力支持。
分析算法的性能是设计过程中的重要部分。评估算法性能的一种方法是分析其复杂性。
复杂性理论是研究算法复杂程度的学科。任何有用的算法应具备以下三个关键特征:
❑ 正确的。一个好的算法应该能够产生正确的结果。为了确认算法正常工作,需要进行广泛的测试,特别是测试边缘情况。
❑ 可理解的。一个好的算法应该是可以理解的。如果世界上最好的算法过于复杂,导致你无法在计算机上实现它,则它毫无用处。
❑ 高效的。一个好的算法应该是高效的。即使算法能产生正确的结果,但是如果它需要花费一千年或者10亿TB的内存,那它也用处不大。
有两种方法用于量化分析算法的复杂度:
❑ 空间复杂度分析:估计执行该算法所需的运行时内存需求。
❑ 时间复杂度分析:估计算法的运行时间。
空间复杂度分析就是估计算法在处理输入数据时所需的内存量。在处理输入数据的同时,算法需要在内存中存储一些临时的数据结构。算法的设计方式将会影响这些数据结构的数量、类型和规模。在分布式计算时代,需要处理的数据量越来越大,空间复杂度分析变得日益重要。这些数据结构的规模、类型和数量将决定对底层硬件的内存需求。在分布式计算中使用的现代内存数据结构需要具有高效的资源分配机制,能够在算法的不同执行阶段考虑内存需求。复杂的算法往往是迭代的。这类算法不会一次性将所有信息加载到内存中,而是通过迭代的方式逐步填充数据结构。为了计算空间复杂度,首先需要对我们计划使用的迭代算法进行分类。迭代算法可以采用以下三种迭代类型之一:
❑ 收敛迭代: 随着算法在迭代中进行,它在每个单独迭代中处理的数据量会减少。换句话说,随着算法进行迭代,空间复杂度会减小。主要挑战是解决初始迭代的空间复杂度问题。现代可扩展的云基础设施(如AWS和Google Cloud)最适合运行这类算法。
❑ 发散迭代: 随着算法在迭代中进行,它在每个单独迭代中处理的数据量会增加。随着算法进行迭代,空间复杂度会增加,因此设置约束条件以防止系统变得不稳定非常重要。这些约束条件可以通过限制迭代次数或限制初始数据的大小来实现。
❑ 平稳迭代: 随着算法在迭代中进行,它在每个单独迭代中处理的数据量保持不变。由于空间复杂度不会改变,因此不需要基础设施的弹性。
为了计算算法的空间复杂度,我们需要专注于最复杂的迭代之一。在许多算法中,随着我们接近解决方案,资源需求逐渐减少。在这种情况下,初始迭代通常是最复杂的,能够更好地估计算法的整体内存使用情况。一旦选择了特定的迭代进行分析,就需要估计算法使用的总内存量,包括临时数据结构、执行过程和输入值所使用的内存。通过考虑这些内存分配,我们可以得出对算法空间复杂度的全面评估。这种评估有助于确定算法在执行过程中如何高效地利用内存资源。
以下是最小化空间复杂度的指导原则:
❑ 尽可能地将算法设计为迭代的形式。
❑ 在设计迭代算法时,每当有选择的余地时,更倾向于选择多次迭代而不是少次迭代。较细粒度的多次迭代通常具有更低的空间复杂度。
❑ 算法应该只将当前处理所需的信息加载到内存中,不需要的信息应该及时从内存中清除。
空间复杂度分析对于高效设计算法至关重要。如果在设计特定算法时没有进行适当的空间复杂度分析,临时数据结构所需的内存可能会不足,易导致不必要的磁盘溢出,这可能会极大地影响算法的性能和效率。
在本章中,我们将更深入地研究时间复杂度。空间复杂度将在第15章详细讨论,届时将对运行时内存需求比较复杂的大规模分布式算法进行处理。
时间复杂度分析是根据算法的结构来估计算法完成指定任务所需的时间。与空间复杂度相比,时间复杂度不依赖于算法运行所需的任何硬件。时间复杂度分析仅取决于算法本身的结构。时间复杂度分析的总体目标是尝试回答以下两个重要问题:
❑ 算法是否具有良好的可扩展性?一个设计良好的算法应该能够充分利用云计算环境中现代的弹性基础架构。算法应该被设计成可以利用更多的CPU、处理核心、GPU和内存资源。例如,用于在机器学习问题中训练模型的算法应该能够随着更多CPU的可用而采用分布式训练。
如果在算法执行过程中可以利用GPU和额外的内存,这样的算法也应该加以利用。
❑ 算法处理更大规模数据集时性能如何变化?
为了回答这些问题,我们需要确定数据规模的增加对算法性能的影响,并确保算法不仅精确无误,而且具有良好的可扩展性。在当今的“大数据”时代,算法的性能对于处理更大的数据集变得愈发重要。
在许多情况下,我们可能有多种方法来设计算法。在这种情况下进行时间复杂度分析的目的如下:
“在给定特定问题的情况下,如果有多种算法可供选择,那么哪种算法在时间效率方面是最高效的?”
计算算法的时间复杂度有以下两种基本方法:
❑ 实现算法后的分析方法 :这种方法先分别实现各种候选算法,再对其性能进行比较。
❑ 实现算法前的理论方法 :这种方法是在运行算法前用数学方法近似计算每个算法的性能。
理论方法的优点是它仅依赖于算法本身的结构,而不依赖于将用于运行该算法的实际硬件、运行算法时所选择的相关软件以及用于实现该算法的编程语言。
典型算法的性能都取决于作为输入提供给它的数据的类型。例如,如果在待求解问题中的数据已经排序,则该算法的性能可能会高得惊人。如果用排序后的输入作为基准来对特定算法进行测试,则将得到不真实的、过好的性能测试结果,而这个结果在大多数情况下并不能够反映算法的实际性能。为了处理算法对输入数据的这种依赖性,我们在进行性能分析时需要考虑各种不同情况。
最好复杂度
在最好复杂度中,输入数据经组织之后能够得到算法的最佳性能。最好复杂度分析得出算法性能的上界。
最坏复杂度
评估算法性能的第二种方法是尝试找到算法在给定条件下完成工作所需的最大可能时间。最坏复杂度分析非常有用,因为我们可以保证无论在何种条件下,算法的性能总是优于所得出的分析结果。评估算法在处理更大规模数据集的复杂问题时,最坏复杂度分析特别有用。最坏复杂度给出了算法性能的下界。
平均复杂度
平均复杂度分析先将各种可能的输入划分为不同的组,然后从每组中选择一个具有代表性的输入来分析算法性能,最后计算出算法在各组输入上的平均性能。
平均复杂度分析并不总是能得到准确的结果,因为它需要考虑算法输入的所有不同组合和可能性,但这并不容易做到。
大 O 记号(Big O notation)最早是由巴赫曼(Bachmann)在1894年的一篇研究论文中引入的,用于近似算法的增长。他写道:
“我们用符号 O ( n )表示与 n 相关的数量级,其阶数不超过 n 的阶数。”
大
O
记号提供了一种描述算法性能长期增长率的方法。简单来说,它告诉我们,随着输入规模的增长,算法的运行时间会如何增加。让我们借助两个函数来分解这个概念,
f
(
n
)和
g
(
n
)。如果
f
=
O
(
g
),这意味着当
n
趋近于无穷大时,
的比值保持有限或有界。换句话说,无论我们的输入有多大,
f
(
n
)的增长速度不会远远快于
g
(
n
)。
让我们来看一些具体的函数:
f ( n )=1000 n 2 +100 n +10
g ( n )= n 2
请注意,当 n 趋近于无穷大时,这两个函数都会趋近于无穷大。让我们根据定义来判断是否 f = O ( g )。
首先,计算
,即:
显然,
是有界的,在
n
趋近于无穷大时,
不会趋近于无穷大。
因此 f ( n )= O ( n )= O ( n 2 )。
O ( n 2 )表示该函数的复杂度随着输入 n 的平方而增加。如果我们将输入元素的数量加倍,那么预期复杂度将增加4倍。
在处理大 O 记号时,请注意以下五条规则。
规则1:
关于算法中循环的复杂度。如果一个算法执行一系列步骤 n 次,那么它的性能是 O ( n )。
规则2:
对于算法中的嵌套循环。如果一个算法执行一个包含 n 1 步循环的函数,并且对于该循环的每一次迭代,它都会再执行 n 2 步,那么该算法的总性能是 O ( n 1 × n 2 )。
例如,如果一个算法同时拥有外部循环和内部循环,且两个循环都有 n 步,则该算法的复杂度将表示为 O ( n × n )= O ( n 2 )
规则3:
如果一个算法执行一个需要 n 1 步的函数 f ( n ),然后执行另一个需要 n 2 步的函数 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 2 + n 3 )= O ( n 2 )。
规则5:
在计算算法的复杂度时,忽略常数倍数。如果 k 是一个常数,则 O ( kf ( n ))与 O ( f ( n ))是相同的。
同样, O ( f ( k × n ))与 O ( f ( n ))是相同的。
因此, O (5 n 2 )= O ( n 2 )。
而 O ((3 n 2 ))= O ( n 2 )。
注意:
❑ 用大 O 记号量化的复杂度仅仅是一个估计。
❑ 对于较小规模的数据,我们并不关心时间复杂度。 n 0 定义了一个阈值,数据规模大于这个阈值时,我们才有兴趣寻找时间复杂度。
❑ T ( n )的时间复杂度高于原始函数。正确的 T ( n )将尝试为 F ( n )创建一个紧密的上界。
表1.1总结了本小节中讨论的不同类型的大 O 记号类型。
表1.1 不同类型的大 O 记号类型
如果一个算法的运行时间不受输入数据大小的影响,即独立于输入数据大小,那么它被称为具有常数时间复杂度,用 O (1)表示。让我们以访问数组的第 n 个元素为例。无论数组的大小如何,获取结果所需的时间都是恒定的。例如,以下函数将返回数组的第一个元素,并且具有 O (1)的复杂度:
注意:
❑ 将新元素添加到栈中是通过push操作完成的,从栈中移除元素是通过pop操作完成的。无论栈的大小如何,添加或移除一个元素都需要相同的时间。这表明栈操作具有常数时间复杂度( O (1))。
❑ 当访问散列表(它是一种数据结构)的元素时,通常以键-值对的形式将数据存储在关联格式中。
如果算法的执行时间与输入规模成正比,则称该算法具有线性时间复杂度,表示为 O ( n )。例如,考虑对一维数据结构中所有元素求和的算法:
请注意算法的主循环。主循环中的迭代次数随着 n 的增加而线性增加,产生了如下的 O ( n )复杂度:
其他一些数组操作的例子如下:
❑ 查找元素。
❑ 找出数组所有元素的最小值。
如果算法的执行时间与输入规模的平方成正比,则称该算法的运行时间为平方时间。例如,考虑下面对二维数组求和的简单函数:
请注意主循环内部的嵌套内部循环。这个嵌套循环使上述代码具有 O ( n 2 )的复杂度:
另一个例子是 冒泡排序算法 (参见第2章)。
如果算法的执行时间与输入规模的对数成正比,则称该算法的运行时间为对数时间。在时间复杂度为 O (log n )的算法中,随着算法的每一轮迭代,输入规模都会以常数倍数递减。例如,二分查找是对数时间复杂度,该算法用于从一维数据结构(如,Python列表)中查找特定元素,它要求数据结构内的元素按降序进行排序。下面的代码将二分查找算法实现为一个名为search_binary的函数:
主循环利用了列表已排序的特性。它在每次迭代中将列表分成两半,直至找到结果为止。
定义完函数后,代码的第11行和第12行通过查找特定元素对其进行了测试。二分搜索算法将在第3章中进一步讨论。
注意,在所介绍的4种类型的大 O 记号中, O ( n 2 )性能最差, O (log n )性能最佳。另外, O ( n 2 )并不像 O ( n 3 )那样糟糕,但由于时间复杂度限制了它们实际可以处理的数据规模,因此属于这类时间复杂度的算法仍然不能用于大数据。4种类型的大 O 复杂度如图1.3所示。
图1.3 大 O 复杂度图
降低算法复杂度的一种方法是在算法的准确度上进行折中,从而得到一种称为 近似算法 的算法。
你怎么知道哪个是更好的解决方案?你怎么知道哪个算法运行得更快?分析一个算法的时间复杂度可以回答这些问题。
为了理解它的作用,我们举一个简单的例子:对一个数字列表进行排序。有几种可用的算法可以完成这项工作,问题是如何选择合适的算法。
首先,易于观察的事实是,如果列表中的数字不是很多,那么选择哪种算法对数字进行排序并不重要。因此,如果列表中只有10个数字( n =10),那么选择哪种算法并不重要,即使是非常简单的算法,可能也不会花费超过几微秒的时间。但随着 n 的增加,选择正确的算法开始变得重要起来。一个设计不良的算法可能需要几个小时才能运行完,而一个设计良好的算法可能在几秒内就能完成对列表的排序。因此,在大规模输入数据集上,投入时间和精力、展开性能分析并选择设计合理的算法来高效地完成所需任务是非常有意义的。
验证算法指的是确保它确实是为待求解问题找到了一个数学求解方案。验证过程应该在尽可能多的输入值和输入类型上检验求解结果。
验证算法要依据算法的类型展开,因为不同类型的算法其验证技术也不同。我们先区分确定性算法和随机算法(见图1.4)。
图1.4 确定性算法和随机算法
确定性算法在特定输入上始终产生完全相同的输出结果。但是,在某些类型的算法中,随机数序列也被当作输入,这些随机数使得算法每次运行时产生的输出都不同。第6章将要详细介绍的 k -means聚类算法就是这类算法的一个例子。
算法也可以分为如下两种类型,分类的依据是简化算法逻辑使其运行速度更快时所采用的假设或近似:
❑ 精确算法: 精确算法预计在不引入任何假设或近似的情况下产生精确解。
❑ 近似算法: 当问题的复杂度在给定的资源下难以处理时,我们会通过一些假设来简化问题。基于这些简化或假设的算法被称为近似算法,它并不能给我们提供完全精确的解。
让我们通过一个例子来理解精确算法和近似算法之间的区别。1930年,人们提出了著名的旅行商问题。一名旅行商向你提出如下难题:要求你为特定的旅行商找出最短路线,让他能够沿该路线访问城市列表中的每个城市,之后返回出发点(这就是问题被命名为旅行商问题的原因)。寻找解决方案时,第一个想法就是生成所有城市的排列组合,然后选择路线最短的排列组合。这种解决方案的复杂度是 O ( n !),其中 n 是城市的数量。显然,城市数量超过30之后,时间复杂度就变得无法处理了。
如果城市数量超过30,降低复杂度的方法之一就是引入一些近似和假设。
对于近似算法来说,在需求分析时设置好期望的准确度很重要。验证近似算法就是要验证结果的误差是否在可接受的范围内。
算法在临界条件下使用时,能够在需要时解释每一个结果背后的原因变得很重要。这是很有必要的,因为这能够确保基于算法结果得出的决策不会带来偏差。
有些特征会直接或间接用于得到某种特定决策,能够准确地识别出这些特征的能力,称为算法的 可解释性 。算法在临界条件下使用时,需要评估算法是否存在偏差和偏见。如果算法可能影响人们生活相关的决策,则算法的伦理分析将成为验证过程中的标准环节。
对于处理深度学习的算法,很难实现算法的可解释性。例如,如果某个算法用于拒绝某些人的抵押贷款申请,则透明度和解释原因的能力都很重要。
算法可解释性是一个活跃的研究领域。最近发展起来的有效技术之一是 局部可理解的模型无关解释 ( Local Interpretable Model-Agnostic Explanations,LIME ),它是在2016年由 美国计算机协会(ACM)的知识发现和数据挖掘特别兴趣小组 ( SIGKDD )主办的第22届国际知识发现与数据挖掘会议中提出的。LIME基于如下概念:对每个实例的输入引入微小变化,然后尽力映射出该实例的局部决策边界,它可以量化每种微小变化对该实例的影响。
本章学习算法基础。首先,我们了解了开发算法的不同阶段,讨论了算法设计过程中用于描述算法逻辑的不同方法。然后,学习了如何设计算法和两种不同的算法性能分析方法。最后,我们学习了验证算法涉及的各个不同方面。
通过本章的学习,我们应该能够理解算法的伪代码,理解开发和部署算法的不同阶段。此外,我们还学会了如何使用大 O 记号来估计算法的性能。
下一章讨论算法中用到的数据结构。我们先讨论Python中可用的数据结构,然后再考虑如何用这些数据结构来创建栈、队列和树等更复杂的数据结构,它们将用于复杂算法的开发。