



本章介绍各种算法的核心设计概念,讨论了各种算法设计技术的优缺点。通过理解这些概念,你将学会如何设计高效的算法。
本章首先讨论了在设计算法时你可以做出的不同选择。然后,讨论了描述待求解问题的重要性。接下来,以著名的 旅行商问题 ( TSP )作为示例,应用本章介绍的不同设计技术。然后,介绍了线性规划及其应用。最后,介绍了如何使用线性规划来解决实际问题。
通过本章学习,你应该能够理解设计高效算法的基本概念。
本章涵盖以下主题:
❑ 设计算法的各种方法。
❑ 理解为算法选择正确的设计思路时所涉及的权衡。
❑ 解决实际问题的最佳做法。
❑ 解决现实世界中的优化问题。
我们先介绍算法设计的基本概念。
根据美国传统词典(American Heritage Dictionary),算法的定义如下:
“算法是由无歧义指令构成的有限集合,它在给定的一组初始条件下按预定顺序执行,直到满足给定的可识别的结束条件以实现某种目的。”
设计算法就是要给出这个“由无歧义指令构成的有限集合”,以最有效的方式“实现某种目标”。为复杂的实际问题设计算法是一项烦琐的任务。要构想得到一个好的设计,需要先充分了解待求解问题。我们首先要弄清楚需要做什么(即了解需求),然后再研究如何做(即设计算法)。理解问题包括找出待求解问题的功能性需求和非功能性需求,让我们看看这些需求分别是什么:
❑ 功能性需求正式规定了待求解问题的输入和输出接口,以及与之相关的功能。功能性需求帮助我们理解数据处理,数据操作,以及生成结果所需要执行的计算。
❑ 非功能性需求设定了对算法的性能和安全性方面的预期。
需要注意的是,设计算法就是要在给定的环境下以最佳方式同时满足功能性需求和非功能性需求,并且还要考虑到用于运行所设计算法的资源集。
为了得到一个能够满足功能性需求和非功能性需求的算法,我们的设计应该考虑以下三个关注点,正如第1章中所讲:
❑ 正确性:所设计的算法是否会产生我们期望的结果
❑ 性能:所设计算法是获取结果的最佳方法吗
❑ 可扩展性:所设计算法在更大的数据集上表现得怎么样
在本节中,我们逐一讨论这些问题。
算法是实际问题的数学求解方案,一个有效的算法应该能够产生准确的结果。如何验证算法的正确性,这不应该是事后才需要的想法,而应该是在算法的设计中就已经考虑到了。在制定如何验证算法的策略之前,我们需要考虑以下三个方面:
❑ 定义真实值 :为了验证算法,对于给定的一组输入,我们需要已知的正确结果。这些已知的正确结果在待求解问题中被称为 真实值 。 真实值 是很重要的,因为当我们不断地改进我们的算法以获得更好的求解方案时,它可以作为参考。
❑ 选择指标 :我们还需要考虑如何量化运行结果与真实值之间的差距。选择合适的度量标准将有助于我们准确地量化算法的质量。
例如,对于有监督的机器学习算法,我们可以将现有的被标记了的数据用作真实值,可以选择一个或多个度量标准(例如准确率,召回率或精度)来量化运行结果与真实值的差距。需要注意的是,在某些用例中,正确的输出不是一个单一的值,相反,对于给定的一组输入,正确的输出被定义为一个范围。在我们设计和开发算法的过程中,我们的目标是反复改进算法,直到其运行结果位于需求所明确的范围之内。
❑ 边缘情况考虑 :当我们设计的算法在操作参数的极限情况下运行时,就会出现边缘情况。边缘情况通常是一种罕见但需要进行充分测试的情景,因为它可能导致我们的算法失败。非边缘情况称为“正常路径”,涵盖了通常在操作参数处于正常范围内时发生的所有情况。大多数情况下,算法会保持在“正常路径”上。不幸的是,对于给定的算法,没有办法列出所有可能的边缘情况,但我们应考虑尽可能多的边缘情况。如果不考虑和思考边缘情况,可能会出现问题。
第二个关注点是找到以下问题的答案:
所设计算法是最佳求解方案吗?我们是否能够证明该问题不存在比我们的求解方案更好的求解方案?
乍一看,这个问题似乎很简单就能回答。然而,对于某些类别的算法而言,研究者们花费了数十年的时间,仍然未能成功验证某个特定解是不是最优解,以及是否存在其他可以提供更好性能的解。所以,我们首先要了解问题、问题的需求以及运行算法的资源,这一点非常重要。
为了为某个复杂问题提供最佳解决方案,我们需要回答一个基本问题,那就是我们是否应该努力寻找这个问题的最优解。如果找到并验证最优解是一项耗时巨大且复杂的任务,那么一个可行的解决方案可能是最佳选择。这些近似可行的解决方案被称为启发式方法。
因此,理解问题及其复杂性非常重要,这有助于我们估计所需的资源。
在开始深入讨论之前,我们先在这里定义几个术语:
❑ 多项式算法(Polynomial Algorithm) :如果算法的时间复杂度为 O ( n k ),我们将其称为多项式算法,其中 k 为常数。
❑ 可行解(Certificate) :迭代结束时产生的一个候选解称为 可行解 。随着我们在解决特定问题上的不断迭代,我们通常会产生一系列的可行解。如果解正朝着收敛的方向发展,则生成的每一个可行解都会比前一个更好。在某些时候,当我们的可行解满足要求时,我们会选择该可行解作为最终的解。
第1章“算法概述”介绍了大 O 记号用以分析算法的时间复杂度。在分析时间复杂度的背景下,我们考虑以下不同的时间区间:
❑ 候选解的生成时间:一个算法产生一个解(即可行解)所需要的时间 t r 。
❑ 候选解的验证时间:验证给出的解(可行解)所需的时间 t s 。
刻画问题复杂度
多年来,学术界根据问题的复杂度将问题分为不同的类。在我们尝试设计问题的求解方案之前,先尝试确定它属于哪一类问题是有意义的。一般来说,存在三种类型的问题:
❑ 类型1:这类问题肯定存在求解该问题的多项式算法。
❑ 类型2:这类问题可以证明无法用多项式算法来求解。
❑ 类型3:这类问题无法找到求解该问题的多项式算法,但也无法证明不可能找到该问题的多项式时间求解方案。
让我们根据复杂度来看待问题的各种类别。
❑ 非确定性多项式(NP ) 问题 :由非确定性计算机在多项式时间内可以解决的问题。广义上,这意味着通过在每一步都进行合理猜测而不努力寻找最优解,可以在多项式时间内找到并验证问题的一个合理解决方案。形式上,对于一个问题被认为是一个 NP 问题,它必须满足以下条件,称为条件A:
➢ 条件A: 保证存在一个多项式时间的算法,可以用来验证候选解(可行解)是不是最优解。
❑ 多项式 ( P ) 问题 :由确定性计算机在多项式时间内可以解决的问题。这些问题可以通过某些算法以 O ( N k )的运行时间解决,其中 k 是某个幂,无论 N 有多大。这些问题可以被认为是 NP 的一个子集。除了满足NP问题的条件A外,P问题需要满足另一个条件,称为条件B:
➢ 条件A: 保证存在一个多项式时间的算法,可以用来验证候选解(可行解)是不是最优解。
➢ 条件B: 保证至少存在一个多项式时间的算法可以用来解决它们。
探讨P与NP之间的关系
理解P和NP之间的关系仍然是一个正在进行的工作。我们可以确定的是P是NP的一个子集,即P⊆NP。这一点显而易见,因为在上面的讨论中,NP只需要满足P需要满足的两个条件中的第一个条件。
P问题与NP问题之间的关系如图4.1所示。
图4.1 P问题与NP问题之间的关系
我们目前无法确定的是,一个问题如果属于NP,是否也属于P?这是计算机科学中仍然未解决的最重要的问题之一。由克莱数学研究所(Clay Mathematics Institute)选定的千禧年大奖难题(Millennium Prize Problem),就包括了P与NP问题。该机构为解决这个问题提供了100万美元的奖金,因为它对人工智能、密码学和理论计算机科学等领域将产生重大影响。有一些问题,比如排序问题,已知属于P。而另一些问题,比如背包问题和旅行商问题(TSP),已知属于NP。
目前,有大量的研究工作致力于回答这个问题。到目前为止,没有任何研究人员发现了解决背包问题或旅行商问题的多项式时间确定性算法。这仍然是一个正在进行中的工作,并且没有人能够证明不存在这样的算法。注意,如图4.2所示,P=NP吗?目前我们还不知道。
图4.2 P=NP吗?目前我们还不知道
介绍NP完全问题和NP难问题
让我们继续列出各种类型的问题:
❑ NP完全(NP-complete)问题: NP完全类包含了所有NP问题中最难的问题。一个NP完全问题需要满足以下两个条件:
➢ 没有已知的多项式时间算法来生成可行解。
➢ 有已知的多项式时间算法来验证提出的可行解是否最优。
❑ NP难(NP-hard)问题: NP难类包含的问题至少与NP类中的任何问题一样难,但是这些问题本身并不需要属于NP类。
现在,让我们尝试画一个图来说明这些不同类别的问题,如图4.3所示。
注意,P=NP的正确性仍有待学术界证明。尽管这尚未得到证实,但很有可能P≠NP。在此种情况下,不存在求解NP完全问题的多项式时间方案。请注意,图4.3是基于这个假设所画的。
P、NP、NP完全问题和NP难问题之间的区别
很遗憾,P、NP、NP完全问题和NP难问题之间的区别并不是那么清晰。让我们总结并研究一些例子,以更好地理解本部分讨论的概念。
图4.3 P、NP、NP完全问题和NP难问题之间的关系
❑ P:可在多项式时间内解决的问题类别。例如:
➢ 散列表查找
➢ 最短路径算法,如Djikstra算法
➢ 线性搜索算法和二分搜索算法
❑ NP:问题不能在多项式时间内解决,但其解决方案可以在多项式时间内验证。例如:
➢ RSA加密算法
❑ NP难:目前还没有人提出解决方案的复杂的问题,但如果解决了,将会有一个多项式时间的解决方案。例如:
➢ 使用 k -means算法进行最优聚类
❑ NP完全:NP完全问题在NP中是“最难”的。它们既是NP难又是NP。例如:
➢ 计算旅行商问题的最优解
寻找NP难或NP完全类中的一个问题的解,就意味着所有NP难/NP完全问题都有解。
算法以规定的方式处理数据以产生结果。一般来说,随着数据规模的增加,处理数据和计算所需结果的时间就越来越长。术语“ 大数据 ”有时被用来粗略地标识那些由于数据的体积、多样性和速度太大而预计对所使用的基础结构和算法构成挑战的数据集。一个设计良好的算法应该具有可扩展性,这意味着它应该被设计成在可能的情况下能够高效运行,利用现有资源,在合理的时间范围内生成正确的结果。当处理大数据时,算法的设计变得更加重要。为了量化算法的可扩展性,我们需要记住以下两个方面:
❑ 随着输入数据的增加,资源需求的增加: 对此需求进行估计被称为空间复杂度分析。
❑ 随着输入数据的增加,运行时间的增加: 对此进行估计被称为时间复杂度分析。
请注意,我们正生活在一个被定义为数据爆炸的时代。“大数据”一词已经成为主流,因为它捕捉到了现代算法通常需要处理的数据的规模和复杂性。
在开发和测试阶段,许多算法只使用少量的数据样本。在设计算法时,重要的是要考虑算法的可扩展性方面。特别地,重要的是要仔细分析(即测试或预测)数据集规模的增加对算法性能的影响。
云计算的弹性和算法的可扩展性
云计算为处理算法的资源需求提供了新的选择。云计算基础设施能够根据处理需求增加而提供更多资源。云计算的能力被称为基础设施的弹性,现在为我们设计算法提供了更多选择。当算法部署在云上时,根据要处理的数据量,可能需要额外的CPU或虚拟机。
典型的深度学习算法是一个很好的例子。要训练一个好的深度学习模型,需要大量的标记数据。对于设计良好的深度学习算法,训练深度学习模型所需的处理量与示例数量成正比或接近成正比。在云中训练深度学习模型时,随着数据量的增加,我们会尝试提供更多资源,以保持训练时间在可管理的范围内。
一个设计良好的算法尽可能地通过将问题分解为更小的子问题来最有效地优化可用资源的使用。设计算法时有不同的算法策略。算法策略处理包含缺失算法各方面的算法列表的以下三个方面。
我们将在本节中介绍以下三种策略:
❑ 分治策略
❑ 动态规划策略
❑ 贪婪算法策略
其中一种策略是找到一种方法将一个较大的问题分解为可以独立解决的较小问题。由这些子问题产生的子解决方案然后被组合起来生成整体的问题解决方案。这被称为分治策略。
从数学上讲,如果问题(P)有 n 个输入且需要对数据集 d 进行处理,则用分治策略为问题设计求解方案会将问题分解成 k 个子问题,记为P 1 至P k ,每个子问题将处理数据集 d 的一个分区。通常,假设P 1 至P k 依次处理数据分区 d 1 至 d k 。
让我们来看看一个实际的例子。
实例——适用于Apache Spark的分治策略
Apache Spark(https://spark.apache.org/)是一个开源框架,用于解决复杂的分布式问题。它实现了分治策略来解决问题。为了处理一个问题,它将问题分解为多个子问题,并独立地处理它们。这些子问题可以在不同的机器上运行,实现了横向扩展。我们将通过一个简单的例子来演示这一点,即从一个列表中计算单词的数量。
假设我们有下面几个单词:
我们想要计算列表中每个单词的频率。为此,我们将应用分治策略以高效地解决这个问题。
图4.4展示了分治策略的实现流程。
图4.4 分治策略
在图4.4中,我们将一个问题划分为以下几个阶段:
1)分割(Splitting) :在这个阶段中,输入数据被分为可以相互独立处理的分区,这被称为分割。图4.4中有三个分区。
2)变换(Mapping) :可以在分区上独立运行的任何操作都称为变换。在图4.4中,变换操作将分区中的每个单词转换为键值对。对应于三个分区,有三个并行运行的变换器。
3)混合(Shuffling) :混合是将相似的键组合在一起的过程。一旦相似的键被聚集在一起,聚合函数就可以在它们的值上运行。请注意,混合是性能密集型的操作,因为需要将原本分布在网络各处的相似键聚集在一起。
4)聚合(Reducing) :在相似键的值上运行一个聚合函数的操作叫作聚合。在图4.4中,我们要计算每个单词的个数。
我们看看如何编写代码来实现此目的。为了演示分治策略,我们需要使用一个分布式的计算框架。为此,我们将在Apache Spark上运行Python:
1)首先,为了使用Apache Spark,我们创建一个Apache Spark的运行环境:
2)现在,我们创建一个包含一些单词的示例列表。我们将把这个列表转换成Spark的本地分布式数据结构,称为 弹性分布式数据集RDD ( Resilient Distributed Dataset ):
3)输出:
4)现在,我们使用map函数将单词转换为键值对:
5)输出:
6)我们使用reduce函数进行聚合并获得最终结果:
7)输出:
这展示了我们如何使用分治策略来计算单词的数量。请注意,分治在问题可以分为子问题,并且每个子问题至少可以部分独立地解决时非常有用。但对于需要进行密集迭代处理的算法,如优化算法,并非最佳选择。对于这类算法,适合使用动态规划,这将在下一部分介绍。
现代云计算基础设施,如Microsoft Azure、Amazon Web Services和Google Cloud,通过在分布式基础设施中直接或间接地实现分治策略,利用多个CPU/GPU并行处理,实现了可扩展性。
在前面的部分中,我们学习了分治法,这是一种自顶向下的方法。相比之下,动态规划是一种自底向上的策略。我们从最小的子问题开始,并不断地组合解决方案,一直组合到达到最终解决方案为止。动态规划和分治法一样,通过将子问题的解决方案组合来解决问题。
动态规划是由理查德·贝尔曼(Richard Bellman)在20世纪50年代提出的一种优化某些类别算法的策略。需要注意的是,在动态规划中,“规划”一词指的是使用表格方法,与编写代码无关。与分治策略相反,动态规划适用于子问题不独立的情况。它通常应用于优化问题,其中每个子问题的解决方案都具有一个值。
我们的目标是找到一个具有最优解的解决方案。动态规划算法只解决每个子问题一次,然后将其答案保存在表中,从而避免在每次遇到子问题时重新计算答案的工作。
动态规划的组成
动态规划基于两个主要组成部分:
❑ 递归:它通过递归地解决子问题。
❑ 记忆化:记忆化或缓存。它基于智能缓存机制,试图重复使用大量计算的结果。这种智能缓存机制称为记忆化。子问题部分涉及重复计算的情况。其思想是只执行一次计算(这是耗时的步骤),然后在其他子问题中重复使用它。这是通过使用记忆化实现的,这在解决递归问题时尤其有用,因为这些问题可能会多次计算相同的输入。
使用动态规划的条件
我们尝试使用动态规划解决的问题应具有两个特点:
❑ 最优结构:当我们尝试解决的问题可以分解为子问题时,动态规划会带来良好的性能优势。
❑ 重叠子问题:动态规划使用一个递归函数来解决特定问题,通过调用自身的副本并解决原问题的较小子问题。子问题的计算解决方案被存储在一个表中,这样就不必重新计算这些子问题。因此,这种技术在存在重叠子问题的情况下是必要的。
动态规划非常适用于组合优化问题,这些问题需要以最优的组合形式提供输入元素的解决方案。例如:
❑ 找到像联邦快递(FedEx)或UPS这样的公司最佳的包裹送货方式
❑ 找到最佳的航线和机场
❑ 决定如何为像Uber Eats这样的在线食品送货系统分配司机
正如名称所示,贪婪算法相对快速地产生一个好的解决方案,但它可能不是最优解。与动态规划类似,贪婪算法主要用于解决无法使用分治策略的优化问题。在贪婪算法中,解决方案是按照一系列步骤计算的。在每一步,都会做出一个局部最优的选择。
使用贪婪算法的条件
贪婪算法是一种策略,适用于具有以下两个特征的问题:
❑ 局部到全局: 通过选择局部最优解可以达到全局最优解。
❑ 最优子结构: 问题的最优解可以由子问题的最优解构成。
在深入展开讨论之前,我们先定义两个术语:
❑ 算法开销 :当我们尝试找出确定型问题的最优解时,都要花费一些时间。随着我们试图优化的优化问题变得越来越复杂,找出最优解所花费的时间也会增加。我们用Ω i 表示算法开销。
❑ 最优解增量 :给定的优化问题都存在一个最优解。通常情况下,我们用自己选择的算法迭代地对解进行优化。对于给定的问题,总是存在当前问题的一个完美解,称为 最优解 。如前所述,根据待求解问题的类型,最优解有可能是未知的,或者说计算和验证最优解需要花费的时间长到人们无法接受。假设最优解是已知的,那么在第 i 轮迭代中,当前解与最优解的差值被称为 最优解增量 ,用Δ i 表示。
对于复杂问题,我们有两种可行的策略:
❑ 策略1: 花更多时间寻找最接近最优解的解,以而使得Δ i 尽可能小。
❑ 策略2: 最小化算法开销Ω i ,采用快刀斩乱麻的方法,只需使用可行解即可。
贪婪算法基于策略2,它并不致力于找出全局最优解,而是选择最小化算法开销。
采用贪婪算法是为多阶段问题找出全局最优解的一种快速简单的策略。它基于选择局部最优解而无须费力去验证局部最优解是否也是全局最优的。一般来说,除非我们很幸运,否则贪婪算法找到的解不会被当做全局最优解。因为寻找全局最优解通常是一项非常耗时的任务。因此,与分治算法和动态规划算法相比,贪心算法的速度很快。
通常,贪婪算法定义如下:
1)假设我们有一个数据集 D 。在这个数据集中,选择一个元素 k 。
2)假设当前的候选解或可行解为 S 。考虑在 S 中包含 k ,如果可以将它包括进来,则将当前解更新为Union( S , k )。
3)重复上述过程,直到 S 被填满或 D 被用完为止。
举例来说, 分类与回归树(CART) 算法是一种贪婪算法,它在顶层搜索最优切分,然后在每个后续层级上重复这个过程。需要注意的是,CART算法并不会计算和检查某个分割在多层之后是否会导致最低的不纯度。CART使用贪婪算法是因为寻找最优树被认为是一个NP完全问题。它的算法复杂度为 O (exp( m ))。
我们先看一下TSP问题的定义。TSP是一个著名问题,在20世纪30年代作为一个挑战被提出来。TSP是一个NP难问题。首先,我们可以在不关心最优解的情况下,随机生成一个旅行路线来满足访问所有城市这一条件。然后,我们可以在每一轮迭代中对解进行改进。在迭代过程中生成的每条旅行路线被称为候选解(也可以称为可行解)。证明一个可行解是最优解需要成倍增加的时间,取而代之的是使用各种基于启发式规则的解,这些解生成的旅行路线接近最佳路线,但并非最佳路线。
旅行商需要访问所有给定城市构成的列表才能完成工作。
请注意以下两点:
❑ 列表上各城市之间的距离是已知的。
❑ 在给定的列表中,每个城市都需要访问一次。
我们可以为旅行商生成旅行计划吗?什么样的旅行计划才是使旅行商所走总路程被最小化的最优解呢?
以下是我们用于演示TSP的五个加拿大城市之间的距离。
请注意,我们的目标是得到一条在出发城市开始和结束的旅行路线。例如,一条典型的旅行路线可以是Ottawa-Sudbury-Montreal-Kingston-Toronto-Ottawa,这条路线总的代价是484+680+287+263+450=2164。这是不是旅行商要走的路程最少的旅行路线?能使旅行商所走总路程最小的最优解是什么?我们将把这些问题留给你们自己去思考和计算。
要求解TSP,我们首先想到的求解方案是使用蛮力策略来找出最短路线(任何路线都必须使得旅行商恰好访问每个城市一次且最后返回出发城市)。蛮力策略的工作原理如下:
❑ 评估所有可能的旅行路线。
❑ 选择距离最短的那一条。
问题是,对于 n 个城市,存在( n -1)!条可能的旅行路线。这意味着5个城市将产生4!=24条可能的旅行路线,我们选择距离最短的那条路线。显然,只有在城市不太多的情况下,该方法才有效。随着城市数量的增加,由于这种方法需要产生大量的排列组合,所以蛮力策略变得不稳定。
我们看一下如何在Python中实现蛮力策略。
首先注意,旅行路线{1,2,3}表示从城市1到城市2再到城市3。旅行的总距离是旅行中行驶的总距离。我们假设城市之间的距离是它们之间的最短距离(即欧几里得距离)。
我们先定义三个实用函数:
❑ distance_points:计算两点之间的绝对距离。
❑ distance_tour:计算旅行商在给定的旅行路线中需要行驶的总距离。
❑ generate_cities:随机生成位于长500、宽300的矩形中的 n 个城市。
这些函数的代码如下:
在上述代码中,我们利用了itertools包中permutations函数的alltours方法(用来生成所有城市的排列组合)。我们还使用复数来表示城市之间的距离。这意味着以下内容:
计算两个城市 a 和 b 之间的距离就像是计算distance(a, b)一样简单。
我们只需调用generate_cities(n)函数就可以创建 n 个城市。
现在,我们定义一个函数brute_force,该函数会生成所有可能的城市旅行路线。一旦生成了所有可能的旅行路线,它将选择距离最短的路线:
下面,我们定义实用函数来帮助我们绘制城市和路线,这些函数包括:
❑ visualize_tour:绘制特定旅行路线中的所有城市和路线。它还会突出显示旅行开始的城市。
❑ visualize_segment:在visualize_tour中使用,用于绘制一段路线中的城市和路线。
请看下面的代码:
最后,我们实现函数tsp(),它可以完成以下工作:
1.根据算法和请求的城市数量生成旅行路线。
2.计算算法运行所需的时间。
3.生成一个图展示运行结果。
一旦定义了tsp(),我们就可以用它来创建一条旅行路线:
请注意,在图4.5中我们使用tsp函数生成了10个城市的旅行路线。当 n =10时,它将生成(10-1)!=362880种可能的排列组合。如果 n 增加,排列组合的数量就会急剧增加,那就不能使用蛮力法了。
图4.5 TSP的解决方案
如果用贪婪算法来求解TSP,则在每一步中我们都可以选择一个看起来比较合理的城市,而不是寻找一个可以得到最佳总体路径的城市。因此,每当我们需要选择一个城市时,我们只需要选择离当前位置最近的城市,而不需要去验证这个选择是否会带来全局最优的路径。
贪婪算法的方法很简单:
1.从任何一个城市出发。
2.在每一步中,继续访问以前未访问过的下一个最近的相邻城市,以继续旅行。
3.重复第二步。
我们定义一个名为greedy_algorithm的函数来实现上述逻辑:
现在,我们用greedy_algorithm来为2000个城市创建一条旅行路线:
请注意,贪婪算法仅花费0.514s即可为2000个城市生成旅行路线(见图4.6)。如果我们使用蛮力法,它将会生成(2000-1)!=1.65e 573 种排列组合,这几乎是无穷大。
需要注意的是,贪婪算法是基于启发式规则的,并不能证明所得到的解就一定是最优的。
图4.6 在Jupyter Notebook中显示的城市
总结一下,贪婪算法的结果在计算时间上更加高效,而暴力法提供了全局最优组合。这意味着计算时间以及结果的质量会有所不同。提出的贪婪算法可能会达到几乎与暴力法相当的结果,但计算时间显著减少,但由于它不会寻找最优解,因此它是基于一种基于努力的策略,没有任何保证。
下面,我们讨论页面排序(PageRank)算法的设计。
我们看一个实际的例子,也就是网页排序算法。它最初被谷歌用来对用户查询的搜索结果进行排名。它产生数字来量化搜索结果在用户所执行的查询中的重要程度。该算法诞生于20世纪90年代末,其设计者是斯坦福大学的两位博士拉里·佩吉(Larry Page)和思尔格·布里恩(Sergey Brin),正是这二人创立了谷歌公司。PageRank算法是以Larry Page的名字命名的。
我们先正式地定义最初设计页面排名算法时要求解的问题。
每当用户在网络搜索引擎中输入查询时,通常会得到大量的搜索结果。为了使搜索结果最终对用户有用,使用一些标准对网页进行排序是很重要的,排序的方式取决于正在使用的底层算法所定义的标准。用这种排序将所有搜索结果进行汇总,最后将汇总的搜索结果呈现给用户。
首先,在使用PageRank算法时,采用以下表示方法:
❑ 网页用有向图中的节点表示。
❑ 图的边对应于超链接。
PageRank算法最重要的部分是找到计算每个页面重要性的最佳方法。网络中特定网页的排名被计算为一个人随机遍历边缘(即单击链接)到达该页面的概率。此外,该算法由阻尼因子alpha参数化,其默认值为0.85。这个阻尼因子是用户继续单击的概率。需要注意的是,具有最高PageRank值的页面是最具吸引力的:无论人们从何处开始,该页面具有最高的成为最终目的地的概率。
这个算法需要对网页集合进行多次迭代或遍历,以确定每个网页的正确重要性(或PageRank值)。
为了计算一个从0到1的数字,可以量化特定页面的重要性,该算法结合了以下两个组成部分的信息:
❑ 与用户输入的查询相关的信息: 该部分根据用户输入的查询,来估计网页内容的相关程度。网页的内容直接取决于网页的作者。
❑ 与用户输入的查询无关的信息: 该部分试图量化每个网页在其链接、浏览量和邻域的重要性。网页的邻域是直接连接到某个页面的网页组。这个部分很难计算,因为网页是异构的,很难提出可以适用于整个网络的标准。
为了在Python中实现PageRank算法,首先,我们导入必要的包:
请注意,有一个网络来自https://networkx.org/。为了进行演示,假设我们只分析网络中的五个网页。让我们将这组页面称为my_pages,并且它们一起构成了一个名为my_web的网络。
现在,我们把这些网页随机链接起来,从而模拟一个实际的网络:
现在,我们绘制一幅图来表示这些链接关系:
它创建了网络的可视化表示,如图4.7所示。
图4.7 网络的可视化表示
在PageRank算法中,网页间的链接关系表示为一个矩阵,该矩阵被称为转移矩阵。转移矩阵用一些算法来不断更新,以捕获网络不断变化的链接状态。转移矩阵的大小为 n × n ,其中 n 是节点数。矩阵中的数字是概率值,用来表示访问者由于出站链接而在接下来的过程中访问该链接的可能性。
在我们的示例中,图4.7展示了我们拥有的静态网络。我们定义一个可用于创建转移矩阵的函数:
请注意,此函数将返回网络图的转移矩阵 G 。
我们为建立的静态网络图生成转移矩阵,如图4.8所示。
图4.8 转移矩阵
对于我们所创建的图,转移矩阵是5×5的矩阵。矩阵中的每一列对应于图中的一个节点。例如,第2列与第2个节点有关。访问者从节点2导航到节点1或节点3的概率为0.5。请注意,转移矩阵的对角线全是0,因为在我们的网络图中,没有从节点到其自身的出站链接。在实际网络中,却可能会出现这种出站链接。
注意,转移矩阵是一个稀疏矩阵。随着节点数量的增加,大多数值将为0。因此,图的结构被提取为一个转移矩阵。在转移矩阵中,节点表示为列和行:
❑ 列:表示一个网页浏览者在线上的节点
❑ 行:表示由于出站链接而导致浏览者访问其他节点的概率
在真实的网络中,用于PageRank算法的转移矩阵是由网络爬虫持续探索链接而构建的。
许多现实世界的问题涉及最大化或最小化一个目标,同时受到一些给定约束的限制。一种方法是将目标规定为一些变量的线性函数。我们还将资源约束形式化为涉及这些变量的等式或不等式。这种方法被称为线性规划问题。线性规划背后的基本算法是由乔治·丹齐格(George Dantzig)在20世纪40年代初在加州大学伯克利分校开发的。丹齐格在为美国空军工作时,利用这一概念进行了部队的后勤供给和容量规划实验。
在第二次世界大战结束后,丹齐格开始为五角大楼工作,并将他的算法发展成一种他称之为线性规划的技术。它被用于军事作战规划。
如今,线性规划被用于求解一些重要的实际问题,这些问题与基于某些约束的变量最小化或最大化有关。这些问题领域的一些示例如下:
❑ 根据资源情况,尽量缩短在汽修厂修车的时间
❑ 在分布式计算环境中分配可用的分布式资源,以尽量减少响应时间
❑ 在公司内部资源优化配置的基础上,实现公司利润的最大化
使用线性规划的条件如下:
❑ 能够用一组方程来表述问题。
❑ 方程中使用的变量必须是线性的。
定义目标函数
请注意,前面给出的三个例子,其目标都将某个变量最小化或最大化。该目标在数学上表示为其他变量的线性函数,称为目标函数。线性规划问题的目的就是在给定的约束条件下最小化或最大化目标函数。
给定约束条件
当试图最小化或最大化某些事物时,在现实世界中存在某些约束条件需要加以考虑。例如,当试图最小化修理一辆汽车所需的时间时,我们还需要考虑可用的机械修理工数量有限。通过线性方程来给定每个约束条件是制定线性规划问题的重要部分。
让我们看一个实际的用例,线性规划可以用来解决一个现实世界的问题。
假设有一家先进工厂生产两类不同的机器人,我们希望工厂的利润最大化:
❑ 高级型号( A ) :该款型提供了完整的功能,制造每台高级型号的产品可获利4200美元。
❑ 基本型号( B ) :该款型只提供基本功能,制造每台基本型号的产品可获利2800美元。
制造机器人需要三种不同类型的工人,制造每种类型的机器人的各类工人所需的确切天数如下。
工厂以30天为生产周期。1名AI(人工智能)专家在1个周期内可以工作30天,2名工程师在30天内各休息8天。因此,每名工程师在每个周期内只能工作22天。在每30天的一个周期中,1名技术员可以工作20天。
下表给出了工厂的员工人数。
在给定的条件下,可以建立如下模型:
❑ 最大利润=4200 A +2800 B
❑ 还受以下条件的约束:
➢ A ≥0:高级型号机器人的数量可以为0或更多。
➢ B ≥0:基本型号机器人的数量可以为0或更多。
➢ 3 A +2 B ≤20:这是由技术员可用时间给定的限制。
➢ 4 A +3 B ≤30:这是由AI专家可用时间给定的限制。
➢ 4 A +3 B ≤44:这是由工程师可用时间给定的限制。
首先,我们导入名为pulp的Python包,它是用来实现线性规划的:
然后,我们调用这个包中的LpProblem函数来实例化问题类。我们将这个实例命名为“Profit maximising problem”:
接着,我们定义两个线性变量 A 和 B 。变量 A 代表生产的高级型号机器人的数量,变量 B 代表生产的基本型号机器人的数量:
我们定义目标函数和约束如下:
我们使用solve函数来生成解:
然后,我们打印 A 和 B 的值以及目标函数的值:
输出为:
结果为:
线性规划在制造业中被广泛应用于寻找最佳的产品数量,以优化现有资源的利用。
至此,本章结束!我们小结一下所学内容。
本章讨论了算法设计的各种方法和选择恰当算法设计方法所涉及的权衡。接着,讨论了求解实际问题的最佳做法。最后,还讨论了如何求解现实世界中的优化问题。本章学到的经验可以用来实现精心设计的算法。
下一章,我们重点讨论基于图的算法。我们先讨论图的不同表示方式。然后,学习在各种数据点周围建立邻域的技术,以进行特定的研究。最后,研究从图中搜索信息的最优方法。