PHP7-数据结构和算法(全)

PHP7 数据结构和算法(全)

原文:zh.annas-archive.org/md5/eb90534f20ff388513beb1e54fb823ef

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据结构和算法是软件应用开发的一个重要组成部分。无论是构建基于 Web 的应用程序、CMS 还是使用 PHP 构建独立的后端系统,我们都需要一直应用算法和数据结构。有时,我们在不知不觉中这样做,有时则没有给予适当的关注。大多数开发人员认为这两个主题非常困难,而且没有必要关注细节,因为 PHP 对数据结构和算法有很多内置支持。在本书中,我们将专注于 PHP 数据结构和算法的基础知识和实际示例,以便了解数据结构是什么,为什么选择它们,以及在哪里应用哪种算法。本书旨在面向初学者和有经验的 PHP 程序员。本书从基础主题开始,逐渐深入更高级的主题。我们在本书中尽量提供了许多示例、图片和解释,以便您可以以视觉形式和实际示例正确理解概念。

本书内容

第一章,数据结构和算法简介,着重介绍不同的数据结构,它们的定义、属性和示例。本章还包括我们分析算法并找到它们的复杂性的方法,特别强调大 O(O)符号。

第二章,理解 PHP 数组,着重介绍了 PHP 中一个非常基本和内置的数据结构--PHP 数组。还介绍了通过 PHP 数组可以实现什么以及它们的优缺点。我们着重介绍了如何使用数组来实现其他数据结构。

第三章,使用链表,涵盖了不同类型的链表。它着重于不同变体链表的分类及其构造过程,并提供示例。

第四章,构建栈和队列,着重介绍了本章中最重要的两种数据结构--栈和队列。我们看到如何使用不同的方法构建栈和队列,并通过示例讨论它们的操作和用法。

第五章,应用递归算法-递归,着重介绍了算法中的一个重要主题--递归。我们介绍了使用递归算法解决问题的不同方法,以及使用这种技术的优缺点。我们还介绍了一些基本的日常编程问题,可以使用递归来解决。

第六章,理解和实现树,讨论了一种非层次化的数据结构--树。我们介绍了树的属性以及如何构建它们,并了解树数据结构对我们重要的情况。

第七章,使用排序算法,演示了如何实现不同的排序算法及其复杂性,因为排序在编程世界中是一个非常重要的主题,寻找高效的排序算法一直是一个问题。在本章末尾,我们还介绍了内置的 PHP 排序算法。

第八章,探索搜索选项,阐述了搜索在编程世界中的重要性。在本章中,我们着重介绍了不同的搜索技术以及何时使用哪种算法。我们还讨论了是否应该在搜索之前进行排序。本章包含了许多不同算法的示例和实现。

第九章,将图形应用,解释了图形算法是编程范式中最广泛使用的算法之一。在本章中,我们着重介绍了不同与图形相关的问题,并使用不同的算法来解决它们。我们通过示例和解释介绍了最短路径算法和最小生成树的实现。

第十章,理解和使用堆,讨论了本书中的最后一个数据结构主题——堆。它是一种非常高效的数据结构,在现实世界中有许多实现。我们展示了如何构建堆及其用途,包括堆排序算法的实现。

第十一章,高级技术解决问题,侧重于不同的技术来解决问题。我们讨论的重点是记忆化、动态规划、贪婪算法和回溯等主题,以及实际问题的示例和解决方案。

第十二章,PHP 对数据结构和算法的内置支持,展示了我们对数据结构和算法的内置支持。我们讨论了 PHP 的函数、PECL 库,以及一些在线资源的参考资料。

第十三章,使用 PHP 进行函数式数据结构,为我们介绍了使用 PHP 进行函数式编程和函数式数据结构的一些信息,因为函数式编程如今正引起很多关注。我们介绍了一个名为 Tarsana 的函数式编程库,并展示了不同的使用示例。

您需要为本书准备什么

您所需要的只是在您的机器上安装最新的 PHP 版本(最低要求是 PHP 7.x)。您可以从命令行运行示例,这不需要一个 Web 服务器。但是,如果您愿意,您可以安装 Apache 或 Nginx,或者以下内容:

  • PHP 7.x+

  • Nginx/apache(可选)

  • PHP IDE 或代码编辑器

这本书适合谁

这本书适用于那些希望通过 PHP 学习数据结构和算法,以更好地控制应用程序解决方案、提高效率和优化的人。需要对 PHP 数据类型、控制结构和其他基本特性有基本的了解。

约定

在本书中,您会发现一些区分不同信息类型的文本样式。以下是一些这些样式的示例及其含义的解释。

代码以与书本文本字体不同的字体编写,以突出显示代码块。

代码块设置如下:

[default]

class TreeNode {

    public $data = NULL;

    public $children = [];

    public function __construct(string $data = NULL) {

         $this->data = $data;

    }

    public function addChildren(TreeNode $node) {

         $this->children[] = $node;

    }

}

当我们希望引起您对代码块中特定部分的注意时,在解释过程中,代码会在文本中以如下方式突出显示:**addChildren**

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

Final 

-Semi Final 1 

--Quarter Final 1 

--Quarter Final 2 

-Semi Final 2 

--Quarter Final 3 

--Quarter Final 4

新术语和重要单词以粗体显示。例如,屏幕上看到的菜单或对话框中的单词会在文本中出现,如:“单击“下一步”按钮将您移至下一个屏幕。”

警告或重要提示会以如下方式出现在一个框中。提示和技巧会以这种方式出现。

第一章:数据结构和算法简介

我们生活在一个数字时代。在我们生活和日常需求的每个领域,我们都有重要的技术应用。没有技术,世界将几乎停滞不前。你是否曾经尝试过准备简单的天气预报需要什么?大量的数据被分析以准备简单的信息,这些信息实时传递给我们。计算机是技术革命中最重要的发现,它们在过去几十年里彻底改变了世界。计算机处理这些大量的数据,并在每个依赖技术的任务和需求中帮助我们。为了使计算机操作高效,我们以不同的格式或不同的结构来表示数据,这就是所谓的数据结构。

数据结构是计算机和编程语言的非常重要的组成部分。除了数据结构之外,了解如何使用这些数据结构解决问题或找到解决方案也非常重要。从我们简单的手机联系人名单到复杂的 DNA 个人资料匹配系统,数据结构和算法的使用无处不在。

我们是否曾想过在超市排队结账可以代表数据结构?或者从一堆文件中取出一张账单可以是数据结构的另一种用途?事实上,我们几乎在生活中的每个地方都在遵循数据结构的概念。无论是管理支付账单的队列还是乘坐交通工具,或者为日常工作维护一堆书或文件的堆栈,数据结构无处不在,影响着我们的生活。

PHP 是一种非常流行的脚本语言,数十亿的网站和应用程序都是使用它构建的。人们使用超文本预处理器PHP)来开发简单的应用程序到非常复杂的应用程序,有些应用程序非常数据密集。一个重要的问题是-我们是否应该为任何数据密集型应用程序或算法解决方案使用 PHP?当然应该。随着 PHP 7 的新版本发布,PHP 已经进入了高效和健壮应用程序开发的新可能性。我们的任务将是展示并准备自己了解使用 PHP 7 的数据结构和算法的力量,以便我们可以在我们的应用程序和程序中利用它。

数据结构和算法的重要性

如果我们考虑现实生活中与计算机的情况,我们也会使用不同的方式来安排我们的物品和数据,以便在需要时能够高效地使用它们或轻松找到它们。如果我们以随机顺序输入我们的电话联系人名单会怎么样?我们能轻松找到联系人吗?由于联系人没有按特定顺序排列,我们可能最终会在通讯录中搜索每个联系人。只需考虑以下两幅图像:

其中一幅图表明书籍是零散的,找到特定的书籍将需要时间,因为书籍没有组织。另一幅图表明书籍是按照堆栈组织的。第二幅图不仅显示我们聪明地利用了空间,而且书籍的搜索变得更容易了。

让我们考虑另一个例子。我们要为一场重要的足球比赛买票。成千上万的人在等待售票亭开放。票将按先到先得的原则分发。如果我们考虑以下两幅图像,哪一种是处理如此庞大人群的最佳方式?:

左边的图片清楚地显示了没有适当的顺序,也没有办法知道谁先来拿票。但是如果我们知道人们是按照有序的方式排队等候,那么处理人群将更容易,我们将把票交给先来的人。这是一个被称为队列的常见现象,在编程世界中被广泛使用。编程术语并不是从外部世界产生的。事实上,大多数数据结构都受到现实生活的启发,它们大多数时候使用相同的术语。无论我们是在准备任务清单、联系人清单、书堆、饮食图表、家谱,还是组织层次结构,我们基本上都在使用计算世界中被称为数据结构的不同排列技术。

到目前为止,我们已经谈到了一些数据结构,但是算法呢?我们日常生活中难道不使用任何算法吗?当然我们会。每当我们从旧电话簿中搜索联系人时,肯定不是从头开始搜索。如果我们要搜索TOM,我们不会搜索写有ABC的页面。我们直接去T页面,然后查找TOM是否在那里列出。或者,如果我们需要从电话簿中找到一位医生,我们肯定不会在食品部分搜索。如果我们把电话簿或电话目录视为数据结构,那么我们搜索特定信息的方式就被称为算法。数据结构帮助我们高效地使用数据,而算法帮助我们高效地对这些数据执行不同的操作。

例如,如果我们的电话目录中有 10 万条记录,从头开始搜索特定条目可能需要很长时间。但是,如果我们知道医生的名字在第 200 页到第 220 页,我们可以只搜索这些页面来节省时间,而不是搜索整个目录。

我们也可以考虑另一种寻找医生的方法。前面的段落是搜索目录中的特定部分,我们甚至可以按字母顺序在目录中搜索,就像我们在字典中搜索单词一样。这可能会减少我们搜索的时间和条目。寻找问题的解决方案可能有许多不同的方法,每种方法都可以称为算法。从前面的讨论中,我们可以说对于特定的问题或任务,可能有多种方法或算法可供执行。那么我们应该考虑使用哪一种?我们很快就会讨论这个问题。在讨论这一点之前,我们将专注于PHP 数据类型抽象数据类型ADT)。为了理解数据结构的概念,我们必须对 PHP 数据类型和 ADT 有深入的理解。

理解抽象数据类型(ADT)

PHP 有八种原始数据类型,分别是布尔值、整数、浮点数、字符串、数组、对象、资源和空值。此外,我们必须记住 PHP 是一种弱类型语言,我们在创建这些数据时不需要关心数据类型声明。虽然 PHP 具有一些静态类型特性,但 PHP 主要是一种动态类型语言,这意味着在使用变量之前不需要声明。我们可以为新变量赋值并立即使用它。

到目前为止我们讨论的数据结构的例子,我们可以使用原始数据类型中的任何一个来表示这些结构吗?也许可以,也许不行。我们的原始数据类型有一个特定的目标:存储数据。为了在这些数据上执行一些灵活的操作,我们需要以一种特定的方式使用数据类型,以便我们可以将它们用作特定的模型并执行一些操作。通过概念模型处理数据的这种特定方式被称为抽象数据类型,或 ADT。ADT 还定义了数据的一组可能操作。

我们需要理解 ADT 主要是理论概念,用于算法、数据结构和软件设计的设计和分析。相比之下,数据结构是具体的表示。为了实现 ADT,我们可能需要使用数据类型或数据结构,或者两者兼而有之。ADT 的最常见例子是栈和队列。

考虑栈作为 ADT,它不仅是数据的集合,还有两个重要的操作称为推入和弹出。通常,我们将一个新条目放在栈的顶部,这被称为push,当我们想要取一个项目时,我们从顶部取出,这也被称为pop。如果我们将 PHP 数组视为栈,我们将需要额外的功能来实现这些推入和弹出操作,以将其视为栈 ADT。同样,队列也是一个 ADT,有两个必需的操作:在队列的末尾添加一个项目,也称为enqueue,从队列的开头移除一个项目,也称为dequeue。两者听起来很相似,但如果我们仔细观察,我们会发现栈作为后进先出LIFO)模型,而队列作为先进先出FIFO)模型。这两种不同的数学模型使它们成为两种不同的 ADT。

以下是一些常见的 ADT:

  • 列表

  • 映射

  • 集合

  • 队列

  • 优先队列

在接下来的章节中,我们将探索更多的 ADT,并使用 PHP 将它们实现为数据结构。

不同的数据结构

我们可以将数据结构分类为两种不同的组:

  • 线性数据结构

  • 非线性数据结构

在线性数据结构中,项目以线性或顺序方式结构化。数组、列表、栈和队列是线性结构的例子。在非线性结构中,数据不是以顺序方式结构化的。图和树是非线性数据结构的最常见例子。

现在让我们以一种总结的方式探索数据结构的世界,包括不同类型的数据结构及其目的。稍后,我们将详细探索每种数据结构。

在编程世界中存在许多不同类型的数据结构。其中,以下是最常用的:

  • 结构

  • 数组

  • 链表

  • 双向链表

  • 队列

  • 优先队列

  • 集合

  • 映射

结构

通常,一个变量只能存储一个数据类型,一个标量数据类型只能存储一个值。有许多情况下,我们可能需要将一些数据类型组合在一起作为一个复杂的数据类型。例如,我们想要将一些学生信息一起存储在一个学生数据类型中。我们需要学生的姓名、地址、电话号码、电子邮件、出生日期、当前班级等信息。为了将每个学生记录存储到一个独特的学生数据类型中,我们将需要一个特殊的结构来允许我们这样做。这可以很容易地通过struct实现。换句话说,结构是一个值的容器,通常使用名称访问。虽然结构在 C 编程语言中非常流行,但我们也可以在 PHP 中使用类似的概念。我们将在接下来的章节中探索这一点。

数组

虽然数组被认为是 PHP 中的一种数据类型,但数组实际上是一种在所有编程平台上广泛使用的数据结构。在 PHP 中,数组实际上是一个有序映射(我们将在几节后了解映射)。我们可以将多个值存储在单个数组中作为单个变量。矩阵类型的数据在数组中易于存储,因此它在所有编程平台上被广泛使用。通常数组是一个固定大小的集合,通过顺序数字索引访问。在 PHP 中,数组的实现方式不同,您可以定义动态数组而不定义数组的固定大小。我们将在下一章中更多地探讨 PHP 数组。数组可以有不同的维度。如果一个数组只有一个索引来访问元素,我们称之为单维数组。但如果需要两个或更多索引来访问元素,我们分别称之为二维或多维数组。以下是两个数组数据结构的图示:

链表

链表是一种线性数据结构,是数据元素的集合,也称为节点,并且可以具有不同的大小。通常,列出的项目通过一个指针连接,称为链接,因此它被称为链表。在链表中,一个列表元素通过指针链接到下一个元素。从下图中,我们可以看到链表实际上维护了一个有序集合。链表是编程语言中使用的最常见和最简单的数据结构形式。在单链表中,我们只能向前移动。在第三章中,使用链表,我们将深入探讨链表的概念和实现:

双向链表

双向链表是一种特殊类型的链表,我们不仅存储下一个节点是什么,还在节点结构中存储了前一个节点。因此,它可以在列表内前后移动。它比单链表或链表具有更多的灵活性,因为它同时具有前一个和后一个指针。我们将在第三章中更多地探讨这些内容,使用链表。以下图示了双向链表:

堆栈

正如我们在前面的页面中讨论过的堆栈,我们已经知道堆栈是具有 LIFO 原则的线性数据结构。因此,堆栈只有一个端口用于添加新项目或移除项目。它是计算机技术中最古老和最常用的数据结构之一。我们总是使用名为top的单点从堆栈中添加或移除项目。术语 push 用于指示要添加到堆栈顶部的项目,pop 用于从顶部移除项目;这在下图中显示。我们将在第四章中更多地讨论堆栈和队列的内容。

队列

队列是另一种遵循 FIFO 原则的线性数据结构。队列允许对集合进行两种基本操作。第一种是enqueue,它允许我们将项目添加到队列的后面。第二种是dequeue,它允许我们从队列的前面移除项目。队列是计算机技术中最常用的数据结构之一。我们将在第四章中学习有关队列的详细信息,构建堆栈和队列

集合

集合是一种抽象数据类型,用于存储特定的值。这些值不以任何特定顺序存储,但集合中不应该有重复的值。集合不像集合那样用于检索特定值;集合用于检查其中是否存在某个值。有时,集合数据结构可以被排序,我们称之为有序集。

映射

地图是一个键值对的集合,其中所有键都是唯一的。我们可以将地图视为一个关联数组,其中所有键都是唯一的。我们可以使用键和值对添加和删除,以及使用键更新和查找地图。事实上,PHP 数组是有序地图实现。我们将在下一章中探讨这一点。

树是计算世界中最广泛使用的非线性数据结构。它在分层数据结构中被广泛使用。树由节点组成,有一个特殊的节点被称为树的,它开始了树结构。其他节点从根节点下降。树数据结构是递归的,这意味着树可以包含许多子树。节点通过边连接在一起。我们将在第六章中讨论不同类型的树,它们的操作和目的,理解和实现树

图数据结构是一种特殊类型的非线性数据结构,由有限数量的顶点或节点和边或弧组成。图可以是有向的也可以是无向的。有向图清楚地指示边的方向,而无向图提到边,而不是方向。因此,在无向图中,边的两个方向被视为单个边。换句话说,我们可以说图是一对集合(V,E),其中V是顶点的集合,E是边的集合:

V = {A, B, C, D, E, F}

E = {AB, BC, CE, ED, EF, DB}

在有向图中,边AB与边BA不同,而在无向图中,ABBA都是相同的。图在编程世界中解决许多复杂问题。我们将在第九章中继续讨论图数据结构,将图形投入实践。在下图中,我们有:

堆是一种特殊的基于树的数据结构,满足堆属性。最大键是根,较小的键是叶子,这被称为最大堆。或者,最小键是根,较大的键是叶子,这被称为最小堆。尽管堆结构的根是树的最大或最小键,但它不一定是一个排序结构。堆用于以高效方式解决图算法以及排序。我们将在第十章中探讨堆数据结构,理解和使用堆

解决问题 - 算法方法

到目前为止,我们已经讨论了不同类型的数据结构及其用途。但是,我们必须记住的一件事是,仅仅将数据放入适当的结构中可能并不能解决我们的问题。我们需要利用数据结构来解决问题,换句话说,我们将使用数据结构来解决问题。我们需要算法来解决问题。

算法是一种逐步过程,它定义了一组指令,按照特定顺序执行以获得期望的输出。一般来说,算法不限于任何编程语言或平台。它们独立于编程语言。算法必须具有以下特征:

  • 输入:算法必须有明确定义的输入。可以是 0 个或多个输入。

  • 输出:算法必须有明确定义的输出。它必须与期望的输出匹配。

  • 精确性:所有步骤都被精确定义。

  • 有限性:算法必须在一定数量的步骤之后停止。它不应该无限期运行。

  • 无歧义:算法应该清晰,任何步骤都不应该有任何歧义。

  • 独立性:算法应该独立于任何编程语言或平台。

现在让我们创建一个算法。但是为了做到这一点,我们需要一个问题陈述。所以让我们假设我们的图书馆有一批新书。有 1000 本书,它们没有按任何特定顺序排序。我们需要按照列表找到书,并将它们存放在指定的书架上。我们如何从一堆书中找到它们呢?

现在,我们可以以不同的方式解决问题。每种方式都有不同的解决问题的方法。我们称这些方法为算法。为了简洁明了地讨论,我们将只考虑两种解决问题的方法。我们知道还有其他几种方法,但为了简单起见,让我们只讨论一种算法。

我们将把书籍存放在一个简单的行中,以便我们可以看到书名。现在,我们将从列表中挑选一本书名,并从一端到另一端搜索,直到找到这本书。所以基本上,我们将为每本书进行顺序搜索。我们将重复这些步骤,直到将所有书放在指定的位置。

编写伪代码

计算机程序是为了机器阅读而编写的。我们必须以一定的格式编写它们,以便为机器理解而进行编译。但是,通常这些编写的代码对于程序员以外的人来说并不容易理解。为了以一种非正式的方式展示这些代码,以便人类也能理解,我们准备伪代码。虽然它不是实际的编程语言代码,但伪代码具有编程语言的类似结构约定。由于伪代码不能像真正的程序一样运行,因此没有标准的伪代码编写方式。我们可以按照自己的方式编写伪代码。

以下是我们用于查找书籍的算法的伪代码:

Algorithm FindABook(L,book_name) 

  Input: list of Books L & name of the search book_name 

  Output: False if not found or position of the book we are looking for. 

  if L.size = 0 return null 

  found := false 

  for each item in L, do 

    if item = book_name, then 

      found := position of the item 

  return found 

现在让我们检查我们写的伪代码。我们提供了一份书籍清单和一个我们正在搜索的名字。我们正在运行一个foreach循环,以迭代每一本书,并与我们正在搜索的书名进行匹配。如果找到了,我们将返回我们找到它的位置,否则返回false。因此,我们编写了一份伪代码来查找书名。但是其他剩下的书呢?我们如何继续搜索,直到所有书都找到并放在正确的书架上呢?

  Algorithm placeAllBooks 

    Input: list of Ordered Books OL, List of received books L 

    Output: nothing. 

    for each book_name in OL, do 

       if FindABook(L,book_name), then 

         remove the book from the list L 

         place it to the bookshelf 

现在我们有了解决书籍组织问题的算法的完整伪代码。在这里,我们正在浏览有序书籍列表,并在交付部分找到书籍。如果找到书籍,我们将其从列表中删除,并将其放到正确的书架上。

编写伪代码的这种简单方法可以帮助我们以结构化的方式解决更复杂的问题。由于伪代码独立于编程语言和平台,因此大多数时间算法都以伪代码的形式表达。

将伪代码转换为实际代码

现在我们将把我们的伪代码转换为实际的 PHP 7 代码,如下所示:

function findABook(Array $bookList, String $bookName) { 

    $found = FALSE; 

    foreach($bookList as $index => $book) { 

        if($book === $bookName) { 

             $found = $index; 

             break; 

        } 

    } 

    return $found; 

} 

function placeAllBooks(Array $orderedBooks, Array &$bookList) { 

    foreach ($orderedBooks as $book) { 

    $bookFound = findABook($bookList, $book); 

    if($bookFound !== FALSE) { 

        array_splice($bookList, $bookFound, 1); 

    } 

  } 

} 

$bookList = ['PHP','MySQL','PGSQL','Oracle','Java']; 

$orderedBooks = ['MySQL','PGSQL','Java']; 

placeAllBooks($orderedBooks, $bookList);

echo implode(",", $bookList); 

现在让我们理解前面的代码发生了什么。首先,我们在代码开头定义了一个新函数findABook。该函数定义了两个参数。一个是Array $bookList,另一个是String $bookName。在函数的开头,我们将$found初始化为FALSE,这意味着还没有找到任何东西。foreach循环遍历书籍列表数组$bookList,对于每本书,它与我们提供的书名$bookName进行匹配。如果我们正在寻找的书名与$bookList中的书名匹配,我们将匹配的索引(我们找到匹配的地方)赋给我们的$found变量。既然我们找到了,继续循环就没有意义了。因此,我们使用break命令来跳出循环。刚刚跳出循环,我们返回我们的$found变量。如果找到了书,通常$found将返回大于 0 的任何整数值,否则将返回false

function placeAllBooks(Array $orderedBooks, Array &$bookList) { 

    foreach ($orderedBooks as $book) { 

    $bookFound = findABook($bookList, $book); 

    if($bookFound !== FALSE) { 

        array_splice($bookList, $bookFound, 1); 

    } 

  } 

} 

这个特定的函数placeAllBooks实际上遍历了我们的有序书籍$orderedBooks。我们正在遍历我们的有序书籍列表,并使用findABook函数在我们的交付列表中搜索每本书。如果在有序列表中找到了这本书($bookFound !== FALSE),我们将使用 PHP 的array_splice()函数从交付书籍列表中移除该书:

$bookList = ['PHP','MySQL','PGSQL','Oracle','Java'];

$orderedBooks = ['MySQL','PGSQL','Java'];

这两行实际上显示了两个 PHP 数组,用于我们收到的书籍列表$bookList和我们实际订购的书籍列表$orderedBooks。我们只是使用一些虚拟数据来测试我们实现的代码,如下所示:

placeAllBooks($orderedBooks, $bookList);

我们的代码的最后一部分实际上调用函数placeAllBooks来执行整个操作,检查每本书在我们收到的书中的位置并将其移除,如果它在列表中。所以基本上,我们已经将我们的伪代码实现为实际的 PHP 代码,我们可以用它来解决我们的问题。

算法分析

我们在前一节完成了我们的算法。但我们还没有对我们的算法进行分析。在当前情况下一个有效的问题可能是,为什么我们真的需要对我们的算法进行分析?虽然我们已经编写了实现,但我们不确定我们编写的代码将利用多少资源。当我们说资源时,我们指的是运行应用程序所利用的时间和存储资源。我们编写算法来处理任何长度的输入。为了了解当输入增长时我们的算法的行为以及利用了多少资源,我们通常通过将输入长度与步骤数(时间复杂度)或存储空间(空间复杂度)相关联来衡量算法的效率。对于找到解决问题的最有效算法,进行算法分析非常重要。

我们可以在两个不同的阶段进行算法分析。一个是在实施之前完成的,另一个是在实施之后完成的。我们在实施之前进行的分析也被称为理论分析,我们假设其他因素如处理能力和空间将保持不变。实施后的分析被称为算法的经验分析,它可以因平台或语言而异。在经验分析中,我们可以从系统中获得关于时间和空间利用的可靠统计数据。

对于我们的放置书籍和从购买物品中找到书籍的算法,我们可以进行类似的分析。这次,我们将更关注时间复杂度而不是空间复杂度。我们将在接下来的章节中探讨空间复杂度。

计算复杂度

在算法分析中,我们测量两种复杂度:

  • 时间复杂度:时间复杂度是算法中关键操作的数量。换句话说,时间复杂度量化了算法从开始到结束所花费的时间量。

  • 空间复杂度:空间复杂度定义了算法在其生命周期中所需的空间(内存)量。它取决于数据结构和平台的选择。

现在让我们专注于我们实现的算法,并了解我们为算法执行的操作。在我们的placeAllBooks函数中,我们正在搜索我们的每一本订购的书。所以如果我们有 10 本书,我们会搜索 10 次。如果数量是 1000,我们会搜索 1000 次。所以简单地说,如果有n本书,我们将搜索n次。在算法分析中,输入数量通常用n表示。

对于我们有序书籍中的每一项,我们都在使用findABook函数进行搜索。在函数内部,我们再次搜索从placeAllBooks函数接收到的每一本书的名称。现在,如果我们足够幸运,我们可以在接收到的书籍列表的开头找到书的名称。在这种情况下,我们就不必搜索剩下的项目。但是如果我们非常不幸,我们搜索的书在列表的末尾怎么办?那么我们必须搜索每一本书,最后找到它。如果接收到的书籍数量也是n,那么我们必须进行n次比较。

如果我们假设其他操作是固定的,唯一的变量应该是输入大小。然后我们可以定义一个边界或数学方程来定义计算其运行时性能的情况。我们称之为渐近分析。渐近分析是输入边界,这意味着如果没有输入,其他因素是恒定的。我们使用渐近分析来找出算法的最佳情况、最坏情况和平均情况:

  • 最佳情况:最佳情况表示执行程序所需的最短时间。对于我们的示例算法,最佳情况可能是,对于每本书,我们只搜索第一项。因此,我们最终搜索的时间非常短。我们使用Ω符号(Σ符号)来表示最佳情况。

  • 平均情况:它表示执行程序所需的平均时间。对于我们的算法,平均情况将是大部分时间在列表中间找到书,或者一半时间在列表开头,剩下一半在列表末尾。

  • 最坏情况:它表示程序的最大运行时间。最坏情况的例子将是一直在列表末尾找书。我们使用O(大 O)符号来描述最坏情况。对于我们的算法中的每本书搜索,它可能需要O(n)的运行时间。从现在开始,我们将使用这个符号来表示我们算法的复杂性。

理解大 O(大 O)符号

大 O 符号对于算法分析非常重要。我们需要对这个符号有扎实的理解,并且知道如何在将来使用它。我们将在本节中讨论大 O 符号。

我们用于查找书籍并放置它们的算法有n个项目。对于第一本书的搜索,它将在最坏情况下比较n本书。如果我们说时间复杂度是T,那么对于第一本书,时间复杂度将是:

T(1) = n

当我们从列表中删除找到的书时,列表的大小现在是n-1。对于第二本书的搜索,它将在最坏情况下比较n-1本书。然后对于第二本书,时间复杂度将是n-1。结合这两个时间复杂度,对于前两本书,它将是:

T(2) = n + (n - 1)

如果我们像这样继续下去,经过n-1步后,最后一本书的搜索将只剩下1本书需要比较。因此,总复杂度看起来像:

T(n) = n + (n - 1) + (n - 2) + . . . . . . .  . . . . + 3 + 2 + 1 

现在,如果我们看一下前面的系列,它不是很熟悉吗?它也被称为n 个数字的和方程,如下所示:

因此我们可以写成:

T(n) = n(n + 1)/2 

或者:

T(n) = n2/2 + n/2 

对于渐近分析,我们忽略低阶项和常数乘数。由于我们有n2,我们可以轻松地忽略这里的n。此外,1/2 的常数乘数也可以被忽略。现在我们可以用大 O 符号表示时间复杂度为n的平方:

T(n) = O(n2) 

在整本书中,我们将使用这个大O符号来描述算法或操作的复杂性。以下是一些常见的大O符号:

类型 符号
常数 O (1)
线性 O (n)
对数 O (log n)
n log n O (n log n)
二次 O (n² )
立方 O (n³ )
指数 O (2^n )

标准 PHP 库(SPL)和数据结构

标准 PHP 库SPL)是 PHP 语言近年来可能的最佳功能之一。SPL 被创建用来解决 PHP 中缺乏的常见问题。SPL 在许多方面扩展了语言,但 SPL 引人注目的特点之一是它对数据结构的支持。虽然 SPL 用于许多其他目的,但我们将专注于 SPL 的数据结构部分。SPL 随着核心 PHP 安装而提供,并不需要任何扩展或更改配置来启用它。

SPL 通过 PHP 中的面向对象编程提供了一组标准数据结构。支持的数据结构有:

  • 双向链表:它是在SplDoublyLinkedList中实现的。

  • :它是通过使用SplDoublyLinkedListSplStack中实现的。

  • 队列:它是通过使用SplDoublyLinkedListSplQueue中实现的。

  • :它是在SplHeap中实现的。它还支持SplMaxHeap中的最大堆和SplMinHeap中的最小堆。

  • 优先队列:它是通过使用SplHeapSplPriorityQueue中实现的。

  • 数组:它是在SplFixedArray中实现的,用于固定大小的数组。

  • 映射:它是在SplObjectStorage中实现的。

在接下来的章节中,我们将探索 SPL 数据结构的每个实现,并了解它们的优缺点,以及与我们相应数据结构的实现的性能分析。但由于这些数据结构已经内置,我们可以用它们来快速实现功能和应用程序。

在 PHP 7 发布后,人们对 PHP 应用程序的性能提升感到高兴。在许多情况下,PHP SPL 并没有类似的性能提升,但我们将在即将到来的章节中对其进行分析。

摘要

在本章中,我们专注于基本数据结构及其名称的讨论。我们还学习了解决问题的定义步骤,即算法。我们还学习了分析算法和大O符号,以及如何计算复杂性。我们简要介绍了 PHP 中内置数据结构的 SPL 形式。

在下一章中,我们将专注于 PHP 数组,这是 PHP 中最强大、灵活的数据类型之一。我们将探索 PHP 数组的不同用途,以实现不同的数据结构,如哈希表、映射、结构等。

第二章:理解 PHP 数组

PHP 数组是 PHP 中最常用的数据类型之一。大多数时候,我们在不考虑 PHP 数组对我们开发的代码或应用程序的影响的情况下使用它。它非常易于使用和动态的;我们喜欢几乎可以用 PHP 数组来实现任何目的。有时,我们甚至不想探索是否有其他可用的解决方案可以代替 PHP 数组。在本章中,我们将探索 PHP 数组的优缺点,以及如何在不同的数据结构实现中使用数组以及提高性能。我们将从解释 PHP 中不同类型的数组开始,然后创建固定大小的数组。然后我们将看到 PHP 数组元素的内存占用情况,以及如何改进它们以及一些数据结构的实现。

更好地理解 PHP 数组

PHP 数组是如此动态和灵活,以至于我们必须考虑它是常规数组,关联数组还是多维数组,就像其他一些语言一样。我们不需要定义要使用的数组的大小和数据类型。PHP 如何做到这一点,而其他语言如 C 和 Java 却不能做到呢?答案很简单:PHP 中的数组概念实际上并不是真正的数组,它实际上是一个 HashMap。换句话说,PHP 数组不是我们从其他语言中得到的简单数组概念。一个简单的数组看起来像这样:

但是,我们绝对可以用 PHP 做到。让我们通过一个例子来检查:

$array = [1,2,3,4,5];

这一行显示了典型数组的外观。类似类型的数据具有顺序索引(从 0 到 4),以访问值。那么谁说 PHP 数组不是典型数组呢?让我们探索更多的例子。考虑以下:

$mixedArray = [];

$mixedArray[0] = 200;

$mixedArray['name'] = "Mixed array";

$mixedArray[1] = 10.65;

$mixedArray[2] = ['I', 'am', 'another', 'array'];

这是我们每天都在使用的 PHP 数组;我们不定义大小,我们存储整数、浮点数、字符串,甚至另一个数组。这听起来奇怪还是 PHP 的超能力?我们可以从php.net的定义中了解。

在 PHP 中,数组实际上是一个有序映射。映射是一种将值与键关联的类型。这种类型针对多种不同的用途进行了优化;它可以被视为数组、列表(向量)、哈希表(映射的一种实现)、字典、集合、栈、队列,可能还有更多。由于数组的值可以是其他数组,因此也可以存在树和多维数组。

因此,PHP 数组具有真正的超能力,可以用于所有可能的数据结构,如列表/向量、哈希表、字典、集合、栈、队列、双向链表等。看起来 PHP 数组是以这样一种方式构建的,要么对所有事情进行了优化,要么对任何事情都没有进行优化。我们将在本章中探索这一点。

如果我们想对数组进行分类,那么主要有三种类型的数组:

  • 数字数组

  • 关联数组

  • 多维数组

我们将通过一些例子和解释来探索每种类型的数组。

数字数组

数字数组并不意味着它只包含数字数据。实际上,它意味着索引只能是数字。在 PHP 中,它们可以是顺序的或非顺序的,但它们必须是数字。在数字数组中,值以线性方式存储和访问。以下是一些 PHP 数字数组的例子:

$array = [10,20,30,40,50]; 

$array[] = 70;  

$array[] = 80; 

$arraySize = count($array); 

for($i = 0;$i<$arraySize;$i++) { 

    echo "Position ".$i." holds the value ".$array[$i]."\n"; 

} 

这将产生以下输出:

Position 0 holds the value 10 

Position 1 holds the value 20 

Position 2 holds the value 30 

Position 3 holds the value 40 

Position 4 holds the value 50 

Position 5 holds the value 70 

Position 6 holds the value 80 

这是一个非常简单的例子,我们定义了一个数组,并且索引是从 0 自动生成的,并且随着数组的值递增。当我们使用$array[]在数组中添加一个新元素时,它实际上会递增索引并将值分配给新索引。这就是为什么值 70 具有索引 5,80 具有索引 6。

如果我们的数据是连续的,我们总是可以使用for循环而不会出现任何问题。当我们说连续时,我们不仅仅是指 0,1,2,3....,n。它可以是 0,5,10,15,20,......,n,其中n是 5 的倍数。或者它可以是 1,3,5,7,9......,n,其中n是奇数。我们可以创建数百种这样的序列来使数组成为数字。

一个重要的问题可能是,如果索引不是连续的,我们不能构造一个数字数组吗?是的,我们肯定可以。我们只需要采用不同的迭代方式。考虑以下示例:

$array = []; 

$array[10] = 100; 

$array[21] = 200; 

$array[29] = 300; 

$array[500] = 1000; 

$array[1001] = 10000; 

$array[71] = 1971; 

foreach($array as $index => $value) { 

    echo "Position ".$index." holds the value ".$value."\n"; 

} 

如果我们看索引,它们不是连续的。它们具有随机索引,例如10后面是2129等等。甚至在最后,我们有索引71,它比之前的1001要小得多。所以,最后一个索引应该在 29 和 500 之间吗?以下是输出:

Position 10 holds the value 100 

Position 21 holds the value 200 

Position 29 holds the value 300 

Position 500 holds the value 1000 

Position 1001 holds the value 10000 

Position 71 holds the value 1971 

这里有几件事情需要注意:

我们按照输入数据的方式迭代数组。索引没有任何内部排序,尽管它们都是数字。

另一个有趣的事实是数组$array的大小只有6。它不像 C++、Java 或其他语言中需要在使用之前预定义数组大小的1002,最大索引可以是n-1,其中n是数组的大小。

关联数组

关联数组是通过可以是任何字符串的键来访问的。在关联数组中,值存储在键而不是线性索引之间。我们可以使用关联数组来存储任何类型的数据,就像数字数组一样。让我们创建一个学生数组,我们将在其中存储学生信息:

$studentInfo = []; 

$studentInfo['Name'] = "Adiyan"; 

$studentInfo['Age'] = 11; 

$studentInfo['Class'] = 6; 

$studentInfo['RollNumber'] = 71; 

$studentInfo['Contact'] = "info@adiyan.com"; 

foreach($studentInfo as $key => $value) { 

    echo $key.": ".$value."\n"; 

} 

以下是代码的输出:

Name: Adiyan 

Age: 11 

Class: 6 

RollNumber: 71 

Contact: info@adiyan.com 

在这里,我们使用每个键来保存一条数据。我们可以根据需要添加任意多个键而不会出现任何问题。这使我们能够使用 PHP 关联数组来表示类似结构、映射和字典的数据结构。

多维数组

多维数组包含多个数组。换句话说,它是一个数组的数组。在本书中,我们将在不同的示例中使用多维数组,因为它们是存储图形和其他树状数据结构的数据的最流行和高效的方式之一。让我们使用一个示例来探索 PHP 多维数组:

$players = [];

$players[] = ["Name" => "Ronaldo", "Age" => 31, "Country" => "Portugal", "Team" => "Real Madrid"];

$players[] = ["Name" => "Messi", "Age" => 27, "Country" => "Argentina", "Team" => "Barcelona"];

$players[] = ["Name" => "Neymar", "Age" => 24, "Country" => "Brazil", "Team" => "Barcelona"];

$players[] = ["Name" => "Rooney", "Age" => 30, "Country" => "England", "Team" => "Man United"];

foreach($players as $index => $playerInfo) { 

    echo "Info of player # ".($index+1)."\n";

    foreach($playerInfo as $key => $value) { 

        echo $key.": ".$value."\n";

    } 

    echo "\n";

} 

我们刚刚看到的示例是一个二维数组的示例。因此,我们使用两个foreach循环来迭代二维数组。以下是代码的输出:

Info of player # 1 

Name: Ronaldo 

Age: 31 

Country: Portugal 

Team: Real Madrid 

Info of player # 2 

Name: Messi 

Age: 27 

Country: Argentina 

Team: Barcelona 

Info of player # 3 

Name: Neymar 

Age: 24 

Country: Brazil 

Team: Barcelona 

Info of player # 4 

Name: Rooney 

Age: 30 

Country: England 

Team: Man United  

我们可以根据需要使用 PHP 创建 n 维数组,但是我们必须记住一件事:我们添加的维度越多,结构就会变得越复杂。我们通常可以想象三维,所以为了拥有超过三维的数组,我们必须对多维数组的工作原理有扎实的理解。

我们可以在 PHP 中将数字数组和关联数组作为单个数组使用。但在这种情况下,我们必须非常谨慎地选择正确的方法来迭代数组元素。在这种情况下,foreach将比forwhile循环更好。

使用数组作为灵活的存储

到目前为止,我们已经看到 PHP 数组作为一种动态的、混合的数据结构,用于存储任何类型的数据。这给了我们更多的自由度,可以将数组用作灵活的存储容器。我们可以在单个数组中混合不同的数据类型和不同维度的数据。我们甚至不必定义我们将要使用的数组的大小或类型。我们可以在需要时随时增加、缩小和修改数据到数组中。

PHP 不仅允许我们创建动态数组,而且还为数组提供了许多内置功能。例如:array_intersectarray_mergearray_diffarray_pusharray_popprevnextcurrentend等等。

使用多维数组表示数据结构

在接下来的章节中,我们将讨论许多不同的数据结构和算法。我们将重点讨论图形。我们已经知道图形数据结构的定义。大多数时候,我们将使用 PHP 多维数组来表示数据,作为邻接矩阵。让我们考虑以下图表:

现在,如果我们把图的每个节点看作是一个数组的值,我们可以表示节点为:

$nodes = ['A', 'B', 'C', 'D', 'E'];

但这只会给我们节点名称。我们无法连接或创建节点之间的关系。为了做到这一点,我们需要构建一个二维数组,其中节点名称将是键,值将基于两个节点的互连性为 0 或 1。由于图中没有提供方向,我们不知道A是否连接到C或连接到A。所以我们假设两者彼此连接。

首先,我们需要为图创建一个数组,并将二维数组的每个节点初始化为 0。以下代码将确切地做到这一点:

$graph = [];

$nodes = ['A', 'B', 'C', 'D', 'E'];

foreach ($nodes as $xNode) {

    foreach ($nodes as $yNode) {

        $graph[$xNode][$yNode] = 0;

    }

}

让我们使用以下代码打印数组,以便在定义节点之间的连接之前看到它的实际外观:

foreach ($nodes as $xNode) {

    foreach ($nodes as $yNode) {

        echo $graph[$xNode][$yNode] . "\t";

    }

    echo "\n";

}

由于节点之间的连接未定义,所有单元格都显示为 0。因此输出看起来像这样:

0       0       0       0       0

0       0       0       0       0

0       0       0       0       0

0       0       0       0       0

0       0       0       0       0

现在我们将定义节点之间的连接,使两个节点之间的连接表示为 1 的值,就像以下代码一样:

$graph["A"]["B"] = 1;

$graph["B"]["A"] = 1;

$graph["A"]["C"] = 1;

$graph["C"]["A"] = 1;

$graph["A"]["E"] = 1;

$graph["E"]["A"] = 1;

$graph["B"]["E"] = 1;

$graph["E"]["B"] = 1;

$graph["B"]["D"] = 1;

$graph["D"]["B"] = 1;

由于图表中没有给出方向,我们将其视为无向图,因此我们为每个连接设置了两个值为 1。对于AB之间的连接,我们将$graph["A"]["B"]$graph["B"]["A"]都设置为1。我们将在后面的章节中了解更多关于定义节点之间连接的内容以及为什么我们这样做。现在我们只关注如何使用多维数组来表示数据结构。我们可以重新打印矩阵,这次输出看起来像这样:

0       1       1       0       1

1       0       0       1       1

1       0       0       0       0

0       1       0       0       0

1       1       0       0       0

在第九章中,将图形投入实践,更有趣和有趣地了解图形及其操作。

使用 SplFixedArray 方法创建固定大小的数组

到目前为止,我们已经探讨了 PHP 数组,我们知道,我们不定义数组的大小。PHP 数组可以根据我们的需求增长或缩小。这种灵活性带来了关于内存使用的巨大不便。我们将在本节中探讨这一点。现在,让我们专注于使用 SPL 库创建固定大小的数组。

为什么我们需要一个固定大小的数组?它有什么额外的优势吗?答案是,当我们知道我们只需要数组中的一定数量的元素时,我们可以使用固定数组来减少内存使用。在进行内存使用分析之前,让我们举一些使用SplFixedArray方法的例子:

$array = new SplFixedArray(10);

for ($i = 0; $i < 10; $i++)

    $array[$i] = $i;

for ($i = 0; $i < 10; $i++)

    echo $array[$i] . "\n";

首先,我们创建一个具有定义大小为 10 的新SplFixedArray对象。其余行实际上遵循我们在常规 PHP 数组值分配和检索中使用的相同原则。如果我们想访问超出范围的索引(这里是 10),它将抛出一个异常:

PHP Fatal error:  Uncaught RuntimeException: Index invalid or out of range

PHP 数组和SplFixedArray之间的基本区别是:

  • SplFixedArray必须有一个固定的定义大小

  • SplFixedArray的索引必须是整数,并且在 0 到n的范围内,其中n是我们定义的数组的大小

当我们有许多已知大小的定义数组或数组的最大所需大小有一个上限时,SplFixedArray方法可能非常方便。但如果我们不知道数组的大小,那么最好使用 PHP 数组。

常规 PHP 数组和 SplFixedArray 之间的性能比较

在上一节中我们遇到的一个关键问题是,为什么我们应该使用SplFixedArray而不是 PHP 数组?我们现在准备探讨答案。我们发现 PHP 数组实际上不是数组,而是哈希映射。让我们在 PHP 5.x 版本中运行一个小例子代码,看看 PHP 数组的内存使用情况。

让我们创建一个包含 100,000 个唯一 PHP 整数的数组。由于我正在运行 64 位机器,我期望每个整数占用 8 个字节。因此,我们将为数组消耗大约 800,000 字节的内存。以下是代码:

$startMemory = memory_get_usage();

$array = range(1,100000);

$endMemory = memory_get_usage();

echo ($endMemory - $startMemory)." bytes";

如果我们在命令提示符中运行这段代码,我们将看到一个输出为 14,649,040 字节。是的,没错。内存使用量几乎是我们计划的 18.5 倍。这意味着对于一个 PHP 数组中的每个元素,会有 144 字节(18 * 8 字节)的开销。那么,这额外的 144 字节是从哪里来的,为什么 PHP 为每个数组元素使用这么多额外的内存?以下是 PHP 数组使用的额外字节的解释:

这张图表展示了 PHP 数组的内部工作原理。它将数据存储在一个桶中,以避免冲突并容纳更多数据。为了管理这种动态性,它在数组的内部实现了双向链表和哈希表。最终,这将为数组中的每个单独元素消耗大量额外的内存空间。以下是基于 PHP 数组实现代码(C 代码)的每个元素的内存消耗的详细情况:

32 位 64 位
zval 16 字节 24 字节
+循环 GC 信息 4 字节 8 字节
+分配头 8 字节 16 字节
zval(值)总计 28 字节 48 字节
bucket 36 字节 72 字节
+分配头 8 字节 16 字节
+指针 4 字节 8 字节
bucket(数组元素)总计 48 字节 96 字节
总计(bucket+zval) 76 字节 144 字节

为了理解 PHP 数组的内部结构,我们需要深入研究 PHP 内部。这超出了本书的范围。一个很好的推荐阅读是:nikic.github.io/2011/12/12/How-big-are-PHP-arrays-really-Hint-BIG.html

在新的 PHP 7 版本中,PHP 数组的内部构造有了很大的改进。结果,每个元素的 144 字节的开销仅降至 36 字节。这是一个很大的改进,适用于 32 位和 64 位操作系统。下面是一个比较图表,包含一个包含 100,000 个项目的数组:

$array = Range(1,100000) 32 位 64 位
PHP 5.6 或更低 7.4 MB 14 MB
PHP 7 3 MB 4 MB

换句话说,对于 32 位系统,PHP 7 的改进系数为 2.5 倍,对于 64 位系统为 3.5 倍。这是一个真正的改进。但这一切都是关于 PHP 数组的,那么SplFixedArray呢?让我们在 PHP 7 和 PHP 5.x 中使用SplFixArray运行相同的示例:

$items = 100000; 

$startMemory = memory_get_usage(); 

$array = new SplFixedArray($items); 

for ($i = 0; $i < $items; $i++) { 

    $array[$i] = $i; 

} 

$endMemory = memory_get_usage(); 

$memoryConsumed = ($endMemory - $startMemory) / (1024*1024); 

$memoryConsumed = ceil($memoryConsumed); 

echo "memory = {$memoryConsumed} MB\n"; 

我们在这里写了SplFixedArray的内存消耗功能。如果我们只是将行$array = new SplFixedArray($items);更改为$array = [];,我们将得到与 PHP 数组相同的代码运行。

基准测试结果可能因机器而异,因为可能有不同的操作系统、内存大小、调试器开/关等。建议在自己的机器上运行代码,以生成类似的基准测试进行比较。

以下是 64 位系统中包含 100,000 个整数的 PHP 数组和SplFixedArray的内存消耗比较:

100,000 个项目 使用 PHP 数组 SplFixedArray
PHP 5.6 或更低 14 MB 6 MB
PHP 7 5 MB 2 MB

SplFixedArray 不仅在内存使用上更快,而且在执行速度上也比一般的 PHP 数组操作更快,比如访问值,赋值等等。

尽管我们可以像数组一样使用SplFixedArray对象,但 PHP 数组函数不适用于SplFixedArray。我们不能直接应用任何 PHP 数组函数,比如array_sumarray_filter等等。

使用 SplFixedArray 的更多示例

由于SplFixedArray具有良好的性能提升指标,我们可以在大多数数据结构和算法中利用它,而不是使用常规的 PHP 数组。现在我们将探讨在不同场景中使用SplFixedArray的更多示例。

从 PHP 数组转换为 SplFixedArray

我们已经看到了如何创建一个具有固定长度的SplFixedArray。如果我想在运行时创建一个SplFixedArray数组呢?以下代码块显示了如何实现:

$array =[1 => 10, 2 => 100, 3 => 1000, 4 => 10000]; 

$splArray = SplFixedArray::fromArray($array); 

print_r($splArray); 

在这里,我们使用SplFixedArray类的静态方法fromArray从现有数组$array构造了一个SplFixedArray。然后我们使用 PHP 的print_r函数打印数组。它将显示如下输出:

SplFixedArray Object 

( 

    [0] => 

    [1] => 10 

    [2] => 100 

    [3] => 1000 

    [4] => 10000 

) 

我们可以看到数组现在已经转换为SplFixedArray,并且它保持了与实际数组中完全相同的索引号。由于实际数组没有定义 0 索引,因此索引 0 保持为 null。但是,如果我们想忽略以前数组的索引并为它们分配新的索引,那么我们必须将上一个代码的第二行更改为这样:

$splArray = SplFixedArray::fromArray($array,false); 

现在,如果我们再次打印数组,将会得到以下输出:

SplFixedArray Object

( 

    [0] => 10

    [1] => 100

    [2] => 1000

    [3] => 10000

) 

如果我们想在运行时将数组转换为固定数组,最好是在不再使用常规 PHP 数组时取消它。如果数组很大,这将节省内存使用。

将 SplFixedArray 转换为 PHP 数组

我们可能还需要将SplFixedArray转换为常规 PHP 数组,以应用 PHP 中的一些预定义数组函数。与前面的示例一样,这也是一件非常简单的事情:

$items = 5; 

$array = new SplFixedArray($items); 

for ($i = 0; $i < $items; $i++) { 

    $array[$i] = $i * 10; 

} 

$newArray = $array->toArray(); 

print_r($newArray); 

这将产生以下输出:

Array 

( 

    [0] => 0 

    [1] => 10 

    [2] => 20 

    [3] => 30 

    [4] => 40 

) 

在声明后更改 SplFixedArray 大小

由于我们在开始时定义了数组大小,可能需要稍后更改大小。为了做到这一点,我们必须使用SplFixedArray类的setSize()方法。示例如下:

$items = 5; 

$array = new SplFixedArray($items); 

for ($i = 0; $i < $items; $i++) { 

    $array[$i] = $i * 10; 

} 

$array->setSize(10); 

$array[7] = 100; 

使用 SplFixedArray 创建多维数组

我们可能还需要使用SplFixedArray创建两个或更多维数组。为了做到这一点,建议按照以下示例操作:

$array = new SplFixedArray(100);

for ($i = 0; $i < 100; $i++) 

$array[$i] = new SplFixedArray(100);

实际上,我们在每个数组索引内部创建了另一个SplFixedArray。我们可以添加任意多的维度。但我们必须记住,随着维度的增加,数组的大小也会增加。因此,它可能会非常快速地变得非常大。

理解哈希表

在编程语言中,哈希表是一种数据结构,用于使数组成为关联数组。这意味着我们可以使用键来映射值,而不是使用索引。哈希表必须使用哈希函数来计算数组桶或槽的索引,从中可以找到所需的值:

正如我们已经多次提到的,PHP 数组实际上是一个哈希表,因此支持关联数组。我们需要记住一件事:对于关联数组实现,我们不需要定义哈希函数。PHP 会在内部为我们做这件事。因此,当我们在 PHP 中创建关联数组时,实际上是在创建一个哈希表。例如,以下代码可以被视为哈希表:

$array = []; 

$array['Germany'] = "Position 1"; 

$array['Argentina'] = "Position 2"; 

$array['Portugal'] = "Position 6"; 

$array['Fifa_World_Cup'] = "2018 Russia";  

事实上,我们可以直接调用任何键,复杂度只有O(1)。键将始终引用桶内相同的索引,因为 PHP 将使用相同的哈希函数来计算索引。

使用 PHP 数组实现结构

正如我们已经知道的,结构是一种复杂的数据类型,我们在其中定义多个属性作为一组,以便我们可以将其用作单个数据类型。我们可以使用 PHP 数组和类来编写结构。以下是使用 PHP 数组编写结构的示例:

$player = [ 

    "name" => "Ronaldo", 

    "country" => "Portugal", 

    "age" => 31, 

    "currentTeam" => "Real Madrid" 

]; 

它只是一个具有字符串键的关联数组。可以使用单个或多个结构来构造复杂的结构作为其属性。例如,使用 player 结构,我们可以使用 team 结构:

$ronaldo = [ 

    "name" => "Ronaldo", 

    "country" => "Portugal", 

    "age" => 31, 

    "currentTeam" => "Real Madrid" 

]; 

$messi = [ 

    "name" => "Messi", 

    "country" => "Argentina", 

    "age" => 27, 

    "currentTeam" => "Barcelona" 

]; 

$team = [ 

    "player1" => $ronaldo, 

    "player2" => $messi 

]; 

The same thing we can achieve using PHP Class. The example will look like:  

Class Player { 

    public $name; 

    public $country; 

    public $age; 

    public $currentTeam; 

} 

$ronaldo = new Player; 

$ronaldo->name = "Ronaldo"; 

$ronaldo->country = "Portugal"; 

$ronaldo->age = 31; 

$ronaldo->currentTeam = "Real Madrid"; 

由于我们已经看到了定义结构的两种方式,我们必须选择其中一种来实现结构。虽然创建对象可能看起来更方便,但与数组实现相比,它的速度较慢。数组具有速度的优势,但它也有一个缺点,即它占用比对象更多的内存空间。现在我们必须根据自己的偏好做出决定。

使用 PHP 数组实现集合

集合只是一个没有特定顺序的值的集合。它可以包含任何数据类型,我们可以运行不同的集合操作,如并集、交集、补集等。由于集合只包含值,我们可以构建一个基本的 PHP 数组,并为其分配值,使其动态增长。以下示例显示了我们定义的两个集合;一个包含一些奇数,另一个包含一些质数:

$odd = []; 

$odd[] = 1; 

$odd[] = 3; 

$odd[] = 5; 

$odd[] = 7; 

$odd[] = 9; 

$prime = []; 

$prime[] = 2; 

$prime[] = 3; 

$prime[] = 5; 

为了检查值在集合中的存在以及并集、交集和补集操作,我们可以使用以下示例:

if (in_array(2, $prime)) { 

    echo "2 is a prime"; 

} 

$union = array_merge($prime, $odd); 

$intersection = array_intersect($prime, $odd); 

$compliment = array_diff($prime, $odd);  

PHP 有许多用于此类操作的内置函数,我们可以利用它们进行集合操作。但是我们必须考虑一个事实:由于集合没有以任何特定方式排序,使用in_array()函数进行搜索在最坏的情况下可能具有O(n)的复杂度。array_merge()函数也是如此,它将检查一个数组中的每个值与另一个数组。为了加快速度,我们可以稍微修改我们的代码,使其更加高效:

$odd = []; 

$odd[1] = true; 

$odd[3] = true; 

$odd[5] = true; 

$odd[7] = true; 

$odd[9] = true; 

$prime = []; 

$prime[2] = true; 

$prime[3] = true; 

$prime[5] = true; 

if (isset($prime[2])) { 

    echo "2 is a prime"; 

} 

$union = $prime + $odd; 

$intersection = array_intersect_key($prime, $odd); 

$compliment = array_diff_key($prime, $odd); 

如果我们分析这段代码,我们可以看到我们使用索引或键来定义集合。由于 PHP 数组索引或键查找的复杂度为O(1),这使得搜索速度更快。因此,所有查找、并集、交集和补集操作将比上一个示例花费更少的时间。

PHP 数组的最佳用法

虽然 PHP 数组消耗更多内存,但使用 PHP 数组的灵活性对于许多数据结构来说更为重要。因此,我们将在许多数据结构实现和算法中使用 PHP 常规数组以及SplFixedArray。如果我们只将 PHP 数组视为我们数据的容器,那么我们将更容易地利用其在许多数据结构实现中的强大功能。除了内置函数,PHP 数组绝对是使用 PHP 进行编程和开发应用程序时必不可少的数据结构。

PHP 有一些用于数组的内置排序函数。它可以使用键和值进行排序,并在排序时保持关联。我们将在第七章中探索这些内置函数,使用排序算法

PHP 数组,它是性能杀手吗?

在本章中,我们已经看到 PHP 数组中的每个元素都具有非常大的内存开销。由于这是语言本身完成的,除了在适用的情况下使用SplFixedArray而不是常规数组之外,我们几乎无能为力。但是,如果我们从 PHP 5.x 版本迁移到新的 PHP 7,那么我们的应用程序将有巨大的改进,无论我们使用常规 PHP 数组还是SplFixedArray

在 PHP 7 中,哈希表的内部实现发生了巨大的变化,它并不是为了效率而构建的。因此,每个元素的开销内存消耗显著减少。虽然我们可以争论较少的内存消耗并不会使代码更快,但我们可以反驳,如果我们有更少的内存来管理,我们可以更多地专注于执行而不是内存管理。因此,这对性能产生了一些影响。

从讨论中可以很容易地得出结论,PHP 7 中新改进的数组绝对是开发人员解决复杂和内存高效应用程序的推荐选择。

总结

在本章中,我们专注于讨论 PHP 数组以及使用 PHP 数组作为数据结构可以做什么。我们将在接下来的章节中继续探讨数组的特性。在下一章中,我们将专注于链表数据结构和不同变体的链表。我们还将探索关于链表及其最佳用法的不同类型的实际示例。

第三章:使用链表

我们已经对数组有了很多了解。现在,我们将把重点转移到一种称为list的新类型的数据结构上。它是编程世界中最常用的数据结构之一。在大多数编程语言中,数组是一个固定大小的结构。因此,它无法动态增长,从固定大小数组中缩小或删除项目也是有问题的,因为我们必须移动数组的项目来填补空白。因此,许多开发人员更喜欢使用列表而不是数组。考虑到每个数组元素可能有一些额外字节的开销,链表可以在内存效率是一个重要因素的情况下使用。在本章中,我们将探讨 PHP 中不同类型的链表及其实现。我们还将看看可以使用链表解决的现实世界问题。

什么是链表?

链表是一组称为节点的对象。每个节点都与下一个节点连接,连接是一个对象引用。如果我们考虑以下图像,每个框代表一个节点。箭头表示节点之间的链接。这是一个单向链表的示例。最后一个节点包含 NULL 的下一个链接,因此它标记了列表的结束:

节点是一个对象,意味着它可以存储任何数据类型,如字符串、整数、浮点数,或者复杂的数据类型,如数组、数组的数组、对象或对象数组。我们可以根据需要存储任何东西。

我们还可以在链表上执行各种操作,例如以下操作:

  • 检查列表是否为空

  • 显示列表中的所有项目

  • 在列表中搜索项目

  • 获取列表的大小

  • 在列表的开头或结尾插入新项目

  • 从列表的开头或结尾删除项目

  • 在特定位置或在某个项目之前/之后插入新项目

  • 反转列表

这些只是可以在链表上执行的一些操作。

让我们编写一个简单的链表来存储一些名称:

class ListNode { 

    public $data = NULL; 

    public $next = NULL; 

    public function __construct(string $data = NULL) { 

        $this->data = $data; 

    } 

}

我们之前提到链表由节点组成。我们为我们的节点创建了一个简单的类。ListNode类有两个属性:一个用于存储数据,另一个用于称为next的链接。现在,我们将使用ListNode类实现一个链表。为简单起见,我们只有两个操作:insertdisplay

class LinkedList { 

    private $_firstNode = NULL; 

    private $_totalNodes = 0; 

    public function insert(string $data = NULL) { 

       $newNode = new ListNode($data); 

        if ($this->_firstNode === NULL) {           

            $this->_firstNode = &$newNode;             

        } else { 

            $currentNode = $this->_firstNode; 

            while ($currentNode->next !== NULL) { 

                $currentNode = $currentNode->next; 

            } 

            $currentNode->next = $newNode; 

        } 

       $this->_totalNode++; 

        return TRUE; 

    } 

    public function display() { 

      echo "Total book titles: ".$this->_totalNode."\n"; 

        $currentNode = $this->_firstNode; 

        while ($currentNode !== NULL) { 

            echo $currentNode->data . "\n"; 

            $currentNode = $currentNode->next; 

        } 

    } 

} 

前面的代码实际上实现了我们的两个基本操作insertdisplay节点。在LinkedList类中,我们有两个私有属性:$_firstNode$_totalNodes。它们的默认值分别为NULL0。我们需要标记头节点或第一个节点,以便我们始终知道从哪里开始。我们也可以称之为前节点。无论我们提供什么名称,它主要用于指示链表的开始。现在,让我们转到insert操作代码。

插入方法接受一个参数,即数据本身。我们将使用ListNode类使用数据创建一个新节点。在我们的链表中插入书名之前,我们必须考虑两种可能性:

  • 列表为空,我们正在插入第一个标题

  • 列表不为空,标题将被添加到末尾

为什么我们需要考虑两种情况?答案很简单。如果我们不知道列表是否为空,我们的操作可能会得到不同的结果。我们还可能在节点之间创建无效的链接。因此,如果列表为空,我们的插入项将成为列表的第一项。这是代码的第一部分在做的事情:

$newNode = new ListNode($data); 

if ($this->_firstNode === NULL) {             

          $this->_firstNode = &$newNode; 

}

从上述代码片段中,我们可以看到我们正在创建一个带有数据的新节点,并将节点对象命名为$newNode。之后,它检查$_firstNode是否为NULL。如果是NULL,那么列表是空的。如果为空,那么我们将$newNode对象分配给$_firstNode属性。现在,insert方法的剩余部分代表了我们的第二个条件,即列表不为空,我们必须在列表的末尾添加新项目:

$currentNode = $this->_firstNode;    

while ($currentNode->next !== NULL) { 

  $currentNode = $currentNode->next; 

} 

$currentNode->next = $newNode; 

在这里,我们从$_firstNode属性获取列表的第一个节点。现在,我们将从第一个节点迭代到列表的末尾。我们将通过检查当前节点的下一个链接是否为NULL来确保这一点。如果它是NULL,那么我们已经到达了列表的末尾。为了确保我们不会一直循环到同一个节点,我们在迭代过程中将下一个节点设置为当前节点的当前项。while循环代码实现了这个逻辑。一旦我们退出while循环,我们将链表的最后一个节点设置为$currentNode。现在,我们必须将当前最后一个节点的下一个链接分配给新创建的名为$newNode的节点,所以我们简单地将对象放到节点的下一个链接中。这个对象引用将作为两个节点对象之间的链接。最后,我们通过后增加$_totalNode属性来增加总节点计数值 1。

我们本可以轻松地为列表创建另一个属性,用于跟踪最后一个节点。这样可以避免在插入新节点时每次都循环整个列表。我们故意忽略了这个选项,以便通过对链表的基本理解来进行工作。在本章的后面,我们将实现这一点以实现更快的操作。

如果我们看看我们的display方法,我们会发现我们几乎使用了类似的逻辑来迭代每个节点并显示其内容。我们首先获取链表的头节点。然后,我们迭代列表直到列表项为 NULL。在循环内,我们通过显示其$data属性来显示节点数据。现在,我们有一个节点类ListNode来为链表创建单独的节点,还有一个LinkedList类来执行基本的insertdisplay操作。让我们编写一小段代码来利用LinkedList类来创建一个书名的链表:

$BookTitles = new LinkedList(); 

$BookTitles->insert("Introduction to Algorithm"); 

$BookTitles->insert("Introduction to PHP and Data structures"); 

$BookTitles->insert("Programming Intelligence"); 

$BookTitles->display(); 

在这里,我们为LinkedList创建一个新对象,并将其命名为$BookTitles。然后,我们使用insert方法插入新的书籍项目。我们添加了三本书,然后使用display方法显示书名。如果我们运行上述代码,我们将看到以下输出:

Total book titles: 3

Introduction to Algorithm

Introduction to PHP and Data structures

Programming Intelligence

正如我们所看到的,第一行有一个计数器,显示我们有三本书的标题,以及它们的名称。如果我们仔细看,我们会发现书名的显示方式与我们输入的方式相同。这意味着我们实现的链表实际上是保持顺序的。这是因为我们总是在列表的末尾输入新节点。如果我们愿意,我们可以以不同的方式做到这一点。作为我们的第一个例子,我们已经涵盖了很多关于链表以及如何构建它们的内容。在接下来的章节中,我们将更多地探索如何创建不同类型的链表,并且使用更复杂的例子。现在,我们将专注于不同类型的链表。

不同类型的链表

到目前为止,我们已经处理了一种称为单链表或线性链表的列表类型。然而,还有基于涉及的操作的几种不同类型的链表:

  • 双向链表

  • 循环链表

  • 多链表

双向链表

在双向链表中,每个节点有两个链接:一个指向下一个节点,另一个指向前一个节点。单向链表是单向的,双向链表是双向的。我们可以在列表中前进或后退而不会出现任何问题。以下图片显示了一个示例双向链表。稍后,在在 PHP 中实现双向链表部分,我们将探讨如何实现双向链表:

循环链表

在单向或双向链表中,最后一个节点后面没有节点,因此最后一个节点没有任何后续节点可以迭代。如果允许最后一个节点指向第一个节点,我们就形成了一个循环。这样的链表称为循环链表。我们可以将单向链表和双向链表都作为循环链表。在本章中,我们还将实现一个循环链表。以下图片展示了一个循环链表:

多链表

多链表,或多重链表,是一种特殊类型的链表,每个节点与另一个节点有两个或更多个链接。它可以根据链表的目的多向增长。例如,如果我们以学生列表为例,每个学生都是一个具有姓名、年龄、性别、系别、专业等属性的节点,那么我们可以将每个学生的节点不仅与下一个和上一个节点链接,还与年龄、性别、系别和专业链接。尽管使用这样的链表需要对链表概念有很好的理解,但我们可以在需要时使用这样的特殊链表。以下图片展示了一个多链表:

插入、删除和搜索项目

到目前为止,我们只看到了插入节点和显示所有节点内容的操作。现在,我们将探索链表中的其他操作。我们主要关注以下操作:

  • 在第一个节点插入

  • 搜索节点

  • 在特定节点之前插入

  • 在特定节点之后插入

  • 删除第一个节点

  • 删除最后一个节点

  • 搜索并删除一个节点

  • 反转链表

  • 获取第 N 个位置的元素

在第一个节点插入

当我们在前面或头部添加一个节点时,我们必须考虑两种简单的可能性。列表可能为空,因此新节点是头节点。这种可能性就是简单得不能再简单了。但是,如果列表已经有一个头节点,那么我们必须执行以下操作:

  1. 创建新节点。

  2. 将新节点作为第一个节点或头节点。

  3. 将前一个头或第一个节点分配为新创建的第一个节点的下一个跟随节点。

以下是此代码:

    public function insertAtFirst(string $data = NULL) { 

        $newNode = new ListNode($data); 

        if ($this->_firstNode === NULL) {             

            $this->_firstNode = &$newNode;             

        } else { 

            $currentFirstNode = $this->_firstNode; 

            $this->_firstNode = &$newNode; 

            $newNode->next = $currentFirstNode;            

        } 

        $this->_totalNode++; 

        return TRUE; 

    } 

搜索节点

搜索节点非常简单。我们需要遍历每个节点,并检查目标数据是否与节点数据匹配。如果找到数据,将返回节点;否则,返回FALSE。实现如下:

    public function search(string $data = NULL) { 

        if ($this->_totalNode) { 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $data) { 

                    return $currentNode; 

                } 

                $currentNode = $currentNode->next; 

            } 

        } 

        return FALSE; 

    } 

在特定节点之前插入

这个过程类似于我们查看的第一个操作。主要区别在于我们需要找到特定节点,然后在其之前插入一个新节点。当找到目标节点时,我们可以更改下一个节点,使其指向新创建的节点,然后更改新创建节点后面的节点,使其指向我们搜索的节点。如下图所示:

以下是实现前面展示的逻辑的代码:

public function insertBefore(string $data = NULL, string $query = NULL) { 

        $newNode = new ListNode($data); 

        if ($this->_firstNode) { 

            $previous = NULL; 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    $newNode->next = $currentNode; 

                    $previous->next = $newNode; 

                    $this->_totalNode++; 

                    break; 

                } 

                $previous = $currentNode; 

                $currentNode = $currentNode->next; 

            } 

        } 

    } 

如果我们检查前面的代码,我们可以看到逻辑非常简单。在这个方法中有两个参数:一个是data,一个是query。我们遍历每个节点。在这样做的同时,我们还跟踪当前节点和前一个节点。跟踪前一个节点很重要,因为当找到目标节点时,我们将把前一个节点的下一个节点设置为新创建的节点。

在特定节点之后插入

这个过程类似于在目标节点之前插入一个节点。不同之处在于,我们需要在目标节点之后插入新节点。在这里,我们需要考虑目标节点以及它指向的下一个节点。当我们找到目标节点时,我们可以更改下一个节点,使其指向新创建的节点,然后我们可以更改紧随新创建节点的节点,使其指向目标节点之后的下一个节点。以下是用于实现此操作的代码:

    public function insertAfter(string $data = NULL, string $query = 

      NULL) { 

        $newNode = new ListNode($data); 

        if ($this->_firstNode) { 

            $nextNode = NULL; 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    if($nextNode !== NULL) { 

                        $newNode->next = $nextNode; 

                    } 

                    $currentNode->next = $newNode; 

                    $this->_totalNode++; 

                    break; 

                } 

                $currentNode = $currentNode->next; 

                $nextNode = $currentNode->next; 

            } 

        } 

    } 

删除第一个节点

删除节点只是意味着取出节点并重新排列前一个和后续节点的链接。如果我们只是删除一个节点并将前一个节点的下一个链接与删除节点后面的节点连接起来,我们就完成了删除操作。请看以下示例:

当我们删除第一个节点时,我们只需将第二个节点作为我们的头节点或第一个节点。我们可以通过以下代码轻松实现这一点:

public function deleteFirst() { 

        if ($this->_firstNode !== NULL) { 

            if ($this->_firstNode->next !== NULL) { 

                $this->_firstNode = $this->_firstNode->next; 

            } else { 

                $this->_firstNode = NULL; 

            } 

            $this->_totalNode--; 

            return TRUE; 

        } 

        return FALSE; 

    } 

现在,我们必须考虑一个特殊情况,即将总节点数减少一个。

删除最后一个节点

删除最后一个节点将需要我们将倒数第二个节点的下一个链接指定为NULL。我们将迭代直到最后一个节点,并在迭代过程中跟踪前一个节点。一旦到达最后一个节点,下一个的前一个节点属性将被设置为NULL,如下例所示:

    public function deleteLast() { 

        if ($this->_firstNode !== NULL) { 

            $currentNode = $this->_firstNode; 

            if ($currentNode->next === NULL) { 

                $this->_firstNode = NULL; 

            } else { 

                $previousNode = NULL; 

                while ($currentNode->next !== NULL) { 

                    $previousNode = $currentNode; 

                    $currentNode = $currentNode->next; 

                } 

                $previousNode->next = NULL; 

                $this->_totalNode--; 

                return TRUE; 

            } 

        } 

        return FALSE; 

    } 

首先,我们检查列表是否为空。之后,我们检查列表是否有多于一个节点。根据答案,我们迭代到最后一个节点并跟踪前一个节点。然后,我们将前一个节点的下一个链接指定为NULL,以便从列表中省略最后一个节点。

搜索并删除节点

我们可以使用搜索和删除操作从列表中删除任何节点。首先,我们从列表中搜索节点,然后通过删除节点的引用来删除节点。以下是实现此操作的代码:

    public function delete(string $query = NULL) { 

        if ($this->_firstNode) { 

            $previous = NULL; 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    if ($currentNode->next === NULL) { 

                        $previous->next = NULL; 

                    } else { 

                        $previous->next = $currentNode->next; 

                    } 

                    $this->_totalNode--; 

                    break; 

                } 

                $previous = $currentNode; 

                $currentNode = $currentNode->next; 

            } 

        } 

    } 

反转列表

有许多方法可以反转链表。我们将使用一种简单的方法来反转列表,即原地反转。我们遍历节点并将下一个节点更改为前一个节点,前一个节点更改为当前节点,当前节点更改为下一个节点。逻辑的伪算法如下所示:

prev   = NULL; 

current = first_node; 

next = NULL; 

while (current != NULL) 

{ 

  next  = current->next;   

  current->next = prev;    

  prev = current; 

  current = next; 

} 

first_node = prev; 

如果我们根据这个伪代码实现我们的反转函数,它将如下所示:

    public function reverse() { 

        if ($this->_firstNode !== NULL) { 

            if ($this->_firstNode->next !== NULL) { 

                $reversedList = NULL; 

                $next = NULL; 

                $currentNode = $this->_firstNode; 

                while ($currentNode !== NULL) { 

                    $next = $currentNode->next; 

                    $currentNode->next = $reversedList; 

                    $reversedList = $currentNode; 

                    $currentNode = $next; 

                } 

                $this->_firstNode = $reversedList; 

            } 

        } 

    } 

获取第 N 个位置的元素

由于列表与数组不同,直接从它们的位置获取元素并不容易。为了获取第 N 个位置的元素,我们必须迭代到该位置并获取元素。以下是此方法的代码示例:

    public function getNthNode(int $n = 0) { 

        $count = 1; 

        if ($this->_firstNode !== NULL) { 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($count === $n) { 

                    return $currentNode; 

                } 

                $count++; 

                $currentNode = $currentNode->next; 

            } 

        } 

    } 

我们现在已经为我们的LinkedList类编写了所有必需的操作。现在,让我们用不同的操作运行程序。如果我们运行以下程序,我们将大多数覆盖我们已经编写的所有操作:

$BookTitles = new LinkedList(); 

$BookTitles->insert("Introduction to Algorithm"); 

$BookTitles->insert("Introduction to PHP and Data structures"); 

$BookTitles->insert("Programming Intelligence"); 

$BookTitles->insertAtFirst("Mediawiki Administrative tutorial guide"); 

$BookTitles->insertBefore("Introduction to Calculus", "Programming Intelligence"); 

$BookTitles->insertAfter("Introduction to Calculus", "Programming Intelligence"); 

$BookTitles->display(); 

$BookTitles->deleteFirst(); 

$BookTitles->deleteLast(); 

$BookTitles->delete("Introduction to PHP and Data structures"); 

$BookTitles->reverse(); 

$BookTitles->display(); 

echo "2nd Item is: ".$BookTitles->getNthNode(2)->data; 

上述代码的输出将如下所示:

Total book titles: 6

Mediawiki Administrative tutorial guide

Introduction to Algorithm

Introduction to PHP and Data structures

Introduction to Calculus

Programming Intelligence

Introduction to Calculus

Total book titles: 3

Programming Intelligence

Introduction to Calculus

Introduction to Algorithm

2nd Item is: Introduction to Calculus

现在我们已经使用 PHP 7 完整实现了一个链表。到目前为止,我们已经了解到,与数组的实现不同,我们必须通过编写代码手动执行许多操作。我们还必须记住一件事:这不是我们实现链表的唯一方式。许多人更喜欢跟踪列表的第一个和最后一个节点,以获得更好的插入操作。现在,我们将查看链表操作在平均和最坏情况下的复杂性。

理解链表的复杂性

以下是链表操作的最佳、最坏和平均情况复杂性:

操作 时间复杂度:最坏情况 时间复杂度:平均情况
在开头或结尾插入 O(1) O(1)
在开头或结尾删除 O(1) O(1)
搜索 O(n) O(n)
访问 O(n) O(n)

我们可以通过跟踪最后一个节点来实现在链表末尾的O(1)插入复杂度,就像我们在示例中对第一个节点所做的那样。这将帮助我们直接跳转到链表的最后一个节点,而无需进行任何迭代。

将链表变成可迭代的

到目前为止,我们已经看到可以使用while循环在方法内部遍历链表的每个节点。如果我们需要从外部使用链表对象进行迭代,该怎么办?实现这一点是完全可能的。PHP 有一个非常直观的迭代器接口,允许任何外部迭代器在对象内部进行迭代。Iterator接口提供以下方法:

  • Current:返回当前元素

  • Next:向前移动到下一个元素

  • Key:返回当前元素的键

  • Rewind:将Iterator倒回到第一个元素

  • Valid:检查当前位置是否有效

现在,我们将在我们的LinkedList类中实现这些方法,使我们的对象可以直接通过节点进行迭代。为了在迭代期间跟踪当前节点和列表中的当前位置,我们需要为我们的LinkedList类添加两个新属性:

private $_currentNode = NULL; 

private $_currentPosition = 0; 

$_currentNode属性将在迭代期间跟踪当前节点,$_currentPosition将在迭代期间跟踪当前位置。我们还需要确保我们的LinkedList类也实现了Iterator接口。代码如下:

class LinkedList implements Iterator{ 

} 

现在,让我们实现这五个新方法,使我们的链表对象可迭代。这五个方法非常直接和简单。代码如下:

    public function current() { 

        return $this->_currentNode->data; 

    } 

    public function next() { 

        $this->_currentPosition++; 

        $this->_currentNode = $this->_currentNode->next; 

    } 

    public function key() { 

        return $this->_currentPosition; 

    } 

    public function rewind() { 

        $this->_currentPosition = 0; 

        $this->_currentNode = $this->_firstNode; 

    } 

    public function valid() { 

        return $this->_currentNode !== NULL; 

    } 

现在,我们有一个可迭代的列表。这意味着现在我们可以使用foreach循环或任何其他迭代过程来遍历我们的链表对象。因此,如果我们编写以下代码,我们将看到所有的书名:

foreach ($BookTitles as $title) { 

    echo $title . "\n"; 

}

另一种方法是使用可迭代接口的rewindvalidnextcurrent方法。它将产生与前面代码相同的输出:

for ($BookTitles->rewind(); $BookTitles->valid(); 

  $BookTitles->next()) { 

    echo $BookTitles->current() . "\n"; 

}

构建循环链表

构建循环链表并不像名字听起来那么难。到目前为止,我们已经看到在末尾添加新节点非常简单;我们将最后一个节点的下一个引用设置为NULL。在循环链表中,最后一个节点的下一个引用实际上将指向第一个节点,从而创建一个循环列表。让我们编写一个简单的循环链表,其中节点将被插入到列表的末尾:

class CircularLinkedList { 

    private $_firstNode = NULL; 

    private $_totalNode = 0; 

    public function insertAtEnd(string $data = NULL) { 

        $newNode = new ListNode($data); 

        if ($this->_firstNode === NULL) { 

            $this->_firstNode = &$newNode; 

        } else { 

            $currentNode = $this->_firstNode; 

            while ($currentNode->next !== $this->_firstNode) { 

                $currentNode = $currentNode->next; 

            } 

            $currentNode->next = $newNode; 

        } 

        $newNode->next = $this->_firstNode; 

        $this->_totalNode++; 

        return TRUE; 

    } 

}

如果我们仔细观察前面的代码,它看起来与我们的单向链表实现完全相同。唯一的区别是我们不检查列表的末尾,而是确保当前节点与第一个节点不同。此外,在以下行中,我们将新创建的节点的下一个引用分配给列表的第一个节点:

$newNode->next = $this->_firstNode; 

在我们实现这一点的过程中,新节点被添加到列表的末尾。我们所需要做的就是将新节点的下一个引用设置为列表中的第一个节点。通过这样做,我们实际上创建了一个循环链表。我们必须确保不会陷入无限循环。这就是为什么我们要比较$currentNode->next$this->_firstNode。当我们显示循环链表中的所有元素时,同样的原则也适用。我们需要确保在显示标题时不会陷入无限循环。以下是显示循环链表中所有标题的代码:

    public function display() { 

        echo "Total book titles: " . $this->_totalNode . "\n"; 

        $currentNode = $this->_firstNode; 

        while ($currentNode->next !== $this->_firstNode) { 

            echo $currentNode->data . "\n"; 

            $currentNode = $currentNode->next; 

        } 

        if ($currentNode) { 

            echo $currentNode->data . "\n"; 

        } 

    }

到目前为止,我们已经构建了一个单向链表并实现了一个循环链表。现在,我们将使用 PHP 实现一个双向链表。

在 PHP 中实现双向链表

我们已经从双向链表的定义中知道,双向链表节点将有两个链接:一个指向下一个节点,另一个指向前一个节点。此外,当我们添加新节点或删除新节点时,我们需要为每个受影响的节点设置下一个和上一个引用。我们在单向链表实现中看到了一种不同的方法,我们没有跟踪最后一个节点,因此,我们每次都必须使用迭代器来到达最后一个节点。这一次,我们将跟踪最后一个节点,以及我们的插入和删除操作,以确保我们的插入、删除和结束操作具有O(1)复杂度。

以下是新节点类的外观,具有两个链接指针,后面是我们双向链表类的基本结构:

class ListNode {

    public $data = NULL; 

    public $next = NULL; 

    public $prev = NULL; 

    public function __construct(string $data = NULL) {

        $this->data = $data;

    }

}

class DoublyLinkedList {

    private $_firstNode = NULL;

    private $_lastNode = NULL;

    private $_totalNode = 0;

}

在下一节中,我们将探讨双向链表的不同操作,以便我们了解单向链表和双向链表之间的基本区别。

双向链表操作

我们将在双向链表实现中探讨以下操作。虽然它们听起来与单向链表中使用的操作类似,但它们在实现上有一个重大区别:

  • 在第一个节点插入

  • 在最后一个节点插入

  • 在特定节点之前插入

  • 在特定节点之后插入

  • 删除第一个节点

  • 删除最后一个节点

  • 搜索并删除一个节点

  • 向前显示列表

  • 向后显示列表

首先插入节点

当我们在前面或头部添加节点时,我们必须检查列表是否为空。如果列表为空,则第一个和最后一个节点将指向新创建的节点。但是,如果列表已经有一个头,则我们必须执行以下操作:

  1. 创建新节点。

  2. 将新节点作为第一个节点或头。

  3. 将前一个头或第一个节点作为下一个,以跟随新创建的第一个节点。

  4. 将前一个第一个节点的前链接指向新的第一个节点。

以下是代码:

    public function insertAtFirst(string $data = NULL) {

        $newNode = new ListNode($data);

        if ($this->_firstNode === NULL) {

            $this->_firstNode = &$newNode;

            $this->_lastNode = $newNode; 

        } else {

            $currentFirstNode = $this->_firstNode; 

            $this->_firstNode = &$newNode; 

            $newNode->next = $currentFirstNode; 

            $currentFirstNode->prev = $newNode; 

        }

        $this->_totalNode++; 

        return TRUE;

    }

在最后一个节点插入

由于我们现在正在跟踪最后一个节点,因此在末尾插入新节点将更容易。首先,我们需要检查列表是否为空。如果为空,则新节点成为第一个和最后一个节点。但是,如果列表已经有最后一个节点,那么我们必须执行以下操作:

  1. 创建新节点。

  2. 将新节点作为最后一个节点。

  3. 将前一个最后一个节点作为当前最后一个节点的前链接。

  4. 将前一个最后一个节点的下一个链接指向新的最后一个节点的前链接。

以下是代码:

    public function insertAtLast(string $data = NULL) { 

        $newNode = new ListNode($data);

        if ($this->_firstNode === NULL) {

            $this->_firstNode = &$newNode; 

            $this->_lastNode = $newNode; 

        } else {

            $currentNode = $this->_lastNode; 

            $currentNode->next = $newNode; 

            $newNode->prev = $currentNode; 

            $this->_lastNode = $newNode; 

        }

        $this->_totalNode++; 

        return TRUE;

    }

在特定节点之前插入

在特定节点之前插入需要我们先找到节点,然后根据其位置,我们需要改变新节点、目标节点和目标节点之前的节点的下一个和上一个节点,如下所示:

    public function insertBefore(string $data = NULL, string $query =  

      NULL) {

        $newNode = new ListNode($data); 

        if ($this->_firstNode) { 

            $previous = NULL; 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    $newNode->next = $currentNode; 

                    $currentNode->prev = $newNode; 

                    $previous->next = $newNode; 

                    $newNode->prev = $previous; 

                    $this->_totalNode++; 

                    break; 

                }

                $previous = $currentNode; 

                $currentNode = $currentNode->next; 

            }

        }

    }

在特定节点之后插入

在特定节点之后插入类似于我们刚刚讨论的方法。在这里,我们需要改变新节点、目标节点和目标节点后面的节点的下一个和上一个节点。以下是代码:

    public function insertAfter(string $data = NULL, string $query = 

      NULL) { 

        $newNode = new ListNode($data);

        if ($this->_firstNode) { 

            $nextNode = NULL; 

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    if ($nextNode !== NULL) { 

                        $newNode->next = $nextNode; 

                    } 

                    if ($currentNode === $this->_lastNode) { 

                        $this->_lastNode = $newNode; 

                    } 

                    $currentNode->next = $newNode; 

                    $nextNode->prev = $newNode; 

                    $newNode->prev = $currentNode; 

                    $this->_totalNode++; 

                    break; 

                } 

                $currentNode = $currentNode->next; 

                $nextNode = $currentNode->next; 

            }

        }

    }

删除第一个节点

当我们从双向链表中删除第一个节点时,我们只需要将第二个节点设置为第一个节点。将新的第一个节点的前一个节点设置为NULL,并减少总节点数,就像以下代码一样:

    public function deleteFirst() { 

        if ($this->_firstNode !== NULL) { 

            if ($this->_firstNode->next !== NULL) { 

                $this->_firstNode = $this->_firstNode->next; 

                $this->_firstNode->prev = NULL; 

            } else { 

                $this->_firstNode = NULL; 

            } 

            $this->_totalNode--; 

            return TRUE; 

        } 

        return FALSE; 

    }

删除最后一个节点

删除最后一个节点需要我们将倒数第二个节点设置为新的最后一个节点。此外,新创建的最后一个节点不应该有任何下一个引用。代码示例如下:

    public function deleteLast() { 

        if ($this->_lastNode !== NULL) { 

            $currentNode = $this->_lastNode; 

            if ($currentNode->prev === NULL) { 

                $this->_firstNode = NULL; 

                $this->_lastNode = NULL; 

            } else { 

                $previousNode = $currentNode->prev; 

                $this->_lastNode = $previousNode; 

                $previousNode->next = NULL; 

                $this->_totalNode--; 

                return TRUE; 

            } 

        } 

        return FALSE; 

    }

搜索并删除一个节点

当我们从列表中间删除一个节点时,我们必须重新调整目标节点的前一个节点和后一个节点。首先,我们会找到目标节点。获取目标节点的前一个节点以及下一个节点。然后,将前一个节点后面的节点指向目标节点后面的节点,前一个节点同样也是如此。以下是代码:

    public function delete(string $query = NULL) { 

        if ($this->_firstNode) { 

            $previous = NULL;

            $currentNode = $this->_firstNode; 

            while ($currentNode !== NULL) { 

                if ($currentNode->data === $query) { 

                    if ($currentNode->next === NULL) { 

                        $previous->next = NULL; 

                    } else { 

                        $previous->next = $currentNode->next; 

                        $currentNode->next->prev = $previous; 

                    }

                    $this->_totalNode--; 

                    break; 

                }

                $previous = $currentNode; 

                $currentNode = $currentNode->next; 

            }

        }

    }

显示列表向前

双向链表使我们有机会以两个方向显示列表。到目前为止,我们已经看到在单向链表中工作时可以以单向方式显示列表。现在,我们将从两个方向查看列表。以下是用于显示列表向前的代码:

    public function displayForward() { 

        echo "Total book titles: " . $this->_totalNode . "\n"; 

        $currentNode = $this->_firstNode; 

        while ($currentNode !== NULL) { 

            echo $currentNode->data . "\n"; 

            $currentNode = $currentNode->next; 

        } 

    } 

显示列表向后

要显示列表向后,我们必须从最后一个节点开始,继续使用前一个链接向后移动,直到到达列表的末尾。这为我们在操作期间以任何方向移动提供了一种独特的方式。以下是代码:

    public function displayBackward() { 

        echo "Total book titles: " . $this->_totalNode . "\n"; 

        $currentNode = $this->_lastNode; 

        while ($currentNode !== NULL) { 

            echo $currentNode->data . "\n"; 

            $currentNode = $currentNode->prev; 

        }

    }

双向链表的复杂性

以下是双向链表操作的最佳、最坏和平均情况复杂性。与单向链表操作类似:

操作 时间复杂度:最坏情况 时间复杂度:平均情况
在开头或结尾插入 O(1) O(1)
删除开头或结尾 O(1) O(1)
搜索 O(n) O(n)
访问 O(n) O(n)

使用 PHP SplDoublyLinkedList

PHP 标准 PHP 库SPL)有一个双向链表的实现,称为SplDoublyLinkedList。如果我们使用内置类,就不需要自己实现双向链表。双向链表的实现实际上也可以作为堆栈和队列。PHP 实现的双向链表有许多额外的功能。以下是SplDoublyLinkedList的一些常见特性:

方法 描述
Add 在指定索引处添加一个新节点
Bottom 从列表开头窥视一个节点
Count 返回列表的大小
Current 返回当前节点
getIteratorMode 返回迭代模式
setIteratorMode 设置迭代模式。例如,LIFO,FIFO 等
Key 返回当前节点索引
next 移动到下一个节点
pop 从列表末尾弹出一个节点
prev 移动到前一个节点
push 在列表末尾添加一个新节点
rewind 将迭代器倒回顶部
shift 从链表开头移除一个节点
top 从列表末尾窥视一个节点
unshift 在列表中添加一个元素
valid 检查列表中是否还有节点

现在,让我们使用SplDoublyLinkedList为我们的书名应用程序编写一个小程序:

$BookTitles = new SplDoublyLinkedList(); 

$BookTitles->push("Introduction to Algorithm");

$BookTitles->push("Introduction to PHP and Data structures"); 

$BookTitles->push("Programming Intelligence");

$BookTitles->push("Mediawiki Administrative tutorial guide"); 

$BookTitles->add(1,"Introduction to Calculus");

$BookTitles->add(3,"Introduction to Graph Theory");

for($BookTitles->rewind();$BookTitles->valid();$BookTitles->next()){    

    echo $BookTitles->current()."\n";

}

前面的代码将产生以下输出:

Introduction to Algorithm

Introduction to Calculus

Introduction to PHP and Data structures

Introduction to Graph Theory

Programming Intelligence

Mediawiki Administrative tutorial guide

摘要

链表是最流行的数据结构之一,用于解决不同的问题。无论是关于堆栈、队列、优先队列,还是实现复杂的图算法,链表都是一个非常方便的数据结构,可以解决你可能遇到的任何问题。在本章中,我们探讨了关于单向链表、双向链表和循环链表的所有可能细节,以及它们的复杂性分析。在接下来的章节中,我们将利用链表来实现不同的数据结构和编写算法。

第四章:构建堆栈和队列

在日常生活中,我们使用两种最常见的数据结构。我们可以假设这些数据结构受到现实世界的启发,但它们在计算世界中有非常重要的影响。我们谈论的是堆栈和队列数据结构。我们每天都堆放我们的书籍、文件、盘子和衣服,而我们在售票处、公交车站和购物结账处维护队列。此外,我们已经听说过 PHP 中的消息队列,这是高端应用中最常用的功能之一。在本章中,我们将探索流行的堆栈和队列数据结构的不同实现。我们将学习关于队列、优先队列、循环队列和双端队列在 PHP 中的实现。

理解堆栈

堆栈是一种遵循后进先出LIFO)原则的线性数据结构。这意味着堆栈只有一个端口,用于向结构中添加项目和移除项目。在堆栈中添加新项目称为推入(push),而在移除项目时称为弹出(pop)。由于我们只能操作一个端口,我们总是在该端口推入项目,当我们弹出时,该端口的最后一个项目将被弹出。堆栈中最顶部的元素也是堆栈端口的起始位置,称为顶部。如果我们考虑以下图像,我们可以看到在每次弹出和推入操作后,顶部都会改变。此外,我们在堆栈的顶部执行操作,而不是在堆栈的开始或中间。当堆栈为空时,弹出元素时,我们必须小心,以及当堆栈已满时推入元素。如果我们想要推入的元素超过其容量,可能会发生堆栈溢出。

根据我们之前的讨论,我们现在知道堆栈中有四种基本操作:

  • 推入:在堆栈的顶部添加项目。

  • 弹出:移除堆栈的顶部项目。

  • 顶部:返回堆栈的顶部项目。它与弹出不同,因为它不会移除项目,它只是为我们获取值。

  • isEmpty:检查堆栈是否为空。

现在让我们以不同的方式使用 PHP 实现堆栈。首先,我们将尝试使用 PHP 的内置数组函数来实现堆栈。然后,我们将看看如何构建一个堆栈,而不使用 PHP 的内置函数,而是使用其他数据结构,如链表。

使用 PHP 数组实现堆栈

首先,我们将为堆栈创建一个接口,以便我们可以在不同的实现中使用它,并确保所有实现彼此相似。让我们为堆栈编写一个简单的接口:

interface Stack { 

    public function push(string $item); 

    public function pop(); 

    public function top(); 

    public function isEmpty(); 

}

正如我们从前面的接口中看到的,我们将所有堆栈函数放在接口中,因为实现它的类必须具有所有这些提到的函数,否则在运行时会抛出致命错误。由于我们正在使用 PHP 数组实现堆栈,我们将使用一些现有的 PHP 函数来进行推入、弹出和顶部操作。我们将以这样的方式实现堆栈,以便我们可以定义堆栈的大小。如果数组中没有项目,但我们仍然想要弹出,它将抛出一个下溢异常,如果我们尝试推入的项目超过其容量允许的数量,那么将抛出一个溢出异常。以下是使用数组实现堆栈的代码:

class Books implements Stack { 

    private $limit; 

    private $stack; 

    public function __construct(int $limit = 20) { 

      $this->limit = $limit; 

      $this->stack = []; 

    } 

    public function pop(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Stack is empty'); 

      } else { 

          return array_pop($this->stack); 

      } 

    } 

    public function push(string $newItem) { 

      if (count($this->stack) < $this->limit) { 

          array_push($this->stack, $newItem); 

      } else { 

          throw new OverflowException('Stack is full'); 

      } 

    } 

    public function top(): string { 

      return end($this->stack); 

    } 

    public function isEmpty(): bool { 

      return empty($this->stack); 

    } 

}

现在让我们来看一下我们为堆栈编写的代码。我们将堆栈实现命名为Books,但只要是有效的类名,我们可以随意命名。首先,我们使用__construct()方法构建堆栈,并提供限制我们可以存储在堆栈中的项目数量的选项。默认值设置为20。下一个方法定义了弹出操作:

public function pop():  string { 

  if ($this->isEmpty()) {

      throw new UnderflowException('Stack is empty');

  } else {

      return array_pop($this->stack);

  }

 }

如果堆栈不为空,pop方法将返回一个字符串。我们为此目的使用了我们在堆栈类中定义的 empty 方法。如果堆栈为空,我们从 SPL 中抛出UnderFlowException。如果没有要弹出的项目,我们可以阻止该操作发生。如果堆栈不为空,我们使用 PHP 的array_pop函数返回数组中的最后一个项目。

在推送方法中,我们做与弹出相反的操作。首先,我们检查堆栈是否已满。如果没有满,我们使用 PHP 的array_push函数将字符串项目添加到堆栈的末尾。如果堆栈已满,我们从 SPL 中抛出OverFlowExceptiontop方法返回堆栈的顶部元素。isEmpty方法检查堆栈是否为空。

由于我们遵循 PHP 7,我们在方法级别使用标量类型声明和方法的返回类型。

为了使用我们刚刚实现的堆栈类,我们必须考虑一个示例,我们可以在其中使用所有这些操作。让我们编写一个小程序来创建一个书堆栈。以下是此代码:

try { 

    $programmingBooks = new Books(10); 

    $programmingBooks->push("Introduction to PHP7"); 

    $programmingBooks->push("Mastering JavaScript"); 

    $programmingBooks->push("MySQL Workbench tutorial"); 

    echo $programmingBooks->pop()."\n"; 

    echo $programmingBooks->top()."\n"; 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

我们已经为我们的书堆栈创建了一个实例,并将我们的编程书籍标题放在其中。我们进行了三次推送操作。最后插入的书名是"MySQL workbench tutorial"。如果我们在三次推送操作后进行弹出,我们将得到这个标题名。之后,顶部将返回"Mastering JavaScript",这将成为执行弹出操作后的顶部项目。我们将整个代码嵌套在try...catch块中,以便处理溢出和下溢抛出的异常。前面的代码将产生以下输出:

MySQL Workbench tutorial

Mastering JavaScript

现在让我们专注于刚刚完成的不同堆栈操作的复杂性。

理解堆栈操作的复杂性

以下是不同堆栈操作的时间复杂度。对于最坏情况,堆栈操作的时间复杂度如下:

操作 时间复杂度
pop O(1)
推送 O(1)
top O(1)
isEmpty O(1)

由于堆栈在一端操作,始终记住堆栈的顶部,如果我们要在堆栈中搜索项目,这意味着我们必须搜索整个列表。访问堆栈中的特定项目也是一样。虽然使用堆栈进行这些操作并不是一个好的做法,但如果我们想这样做,我们必须记住时间复杂度基于更多的一般堆栈操作。

操作 时间复杂度
访问 O(n)
搜索 O(n)

堆栈的空间复杂度始终为O(n)

到目前为止,我们已经看到如何使用 PHP 数组和其内置函数array_poparray_push来实现堆栈。但是我们可以忽略内置函数,使用手动数组操作来实现,或者我们可以使用array_shiftarray_unshift内置函数。

使用链表实现堆栈

在第三章,使用链表中,我们学习了如何实现链表。我们看到在链表中,我们可以在末尾插入节点,从末尾删除节点,在列表中间插入节点,在开头插入节点等。如果我们考虑单链表数据结构的末尾插入和末尾删除操作,我们可以轻松地执行类似的操作。因此,让我们使用上一章的LinkedList类来实现堆栈。代码如下:

class BookList implements Stack { 

    private $stack; 

    public function __construct() { 

      $this->stack = new LinkedList(); 

    }

    public function pop(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Stack is empty'); 

      } else { 

          $lastItem = $this->top(); 

          $this->stack->deleteLast(); 

          return $lastItem; 

      } 

    } 

    public function push(string $newItem) { 

      $this->stack->insert($newItem); 

    } 

public function top(): string { 

  return $this->stack->getNthNode($this->stack->getSize())->data; 

} 

    public function isEmpty(): bool { 

      return $this->stack->getSize() == 0; 

    } 

}

让我们逐个查看每个代码块,以了解这里发生了什么。如果我们从顶部开始,我们可以看到在constructor方法中,我们创建了一个新的LinkedList对象,并将其分配给我们的堆栈属性,而不是上一个示例中的数组。我们假设LinkedList类是自动加载的,或者文件已经包含在脚本中。现在让我们专注于推入操作。推入操作就像它可以得到的那样简单。我们只需要在链表中插入一个新节点。由于链表没有任何大小限制,我们在这里不检查任何溢出。

在我们的链表实现中,没有显示最后一个节点的方法。我们已经插入了一个新的最后一个节点并删除了上一个最后一个节点,但是在这里,我们需要获取最后一个节点的值而不删除它。为了实现这个功能,这正是我们堆栈的顶部操作,我们可以利用LinkedList实现中的getNthNode方法以及getSize。这样,我们就可以得到节点。但是我们必须记住一件事:我们想要节点的字符串值,而不是完整的节点对象。这就是为什么我们返回返回的节点的数据属性。

与顶部操作类似,弹出操作在删除节点之前也需要返回最后一个节点的数据。为了实现这一点,我们使用top()方法,然后使用LinkedList类的deleteLast()方法。现在让我们运行一个使用这个新实现的BookList类进行堆栈操作的示例代码。以下是代码:

try { 

    $programmingBooks = new BookList(); 

    $programmingBooks->push("Introduction to PHP7"); 

    $programmingBooks->push("Mastering JavaScript"); 

    $programmingBooks->push("MySQL Workbench tutorial"); 

    echo $programmingBooks->pop()."\n"; 

    echo $programmingBooks->pop()."\n"; 

    echo $programmingBooks->top()."\n"; 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

它看起来与我们上次运行的示例非常相似,但这里我们尝试执行两次弹出操作,然后是顶部操作。因此,输出将如下所示:

MySQL Workbench tutorial

Mastering JavaScript

Introduction to PHP7

如果我们了解堆栈的基本行为以及如何实现它,我们可以使用数组、链表、双向链表来实现堆栈。由于我们已经看到了数组和链表的实现,现在我们将探索堆栈的 SPL 实现,它实际上使用了双向链表。

使用 SPL 中的 SplStack 类

如果我们不想实现自己的堆栈版本,可以使用现有的 SPL 堆栈实现。它非常容易使用,需要编写的代码很少。正如我们已经知道的,SplStack使用SplDoublyLinkedList。它具有所有可能的操作,如推入、弹出、向前移动、向后移动、移位、反移位等。为了实现我们之前看到的相同示例,我们必须编写以下行:

$books = new SplStack(); 

$books->push("Introduction to PHP7"); 

$books->push("Mastering JavaScript"); 

$books->push("MySQL Workbench tutorial"); 

echo $books->pop() . "\n"; 

echo $books->top() . "\n"; 

是的,使用SplStack类构建堆栈就是这么简单。我们可以决定是否要使用 PHP 数组、链表或内置类(如SplStack)来实现它。

堆栈的现实生活用途

堆栈在现代应用程序中有许多用途。无论是在浏览器历史记录中还是在流行的开发术语堆栈跟踪中,堆栈都被广泛使用。现在我们将尝试使用堆栈解决一个现实世界的问题。

嵌套括号匹配

当我们解决数学表达式时,我们需要考虑的第一件事是嵌套括号的正确性。如果括号没有正确嵌套,那么计算可能不可能,或者可能是错误的。让我们看一些例子:

从前面的表达式中,只有第一个是正确的;其他两个是不正确的,因为括号没有正确嵌套。为了确定括号是否嵌套,我们可以使用堆栈来实现解决方案。以下是伪算法的实现:

valid = true 

s = empty stack 

for (each character of the string) { 

   if(character = ( or { or [ ) 

       s.push(character) 

  else if (character = ) or } or ] ) { 

   if(s is empty) 

valid = false 

     last = s.pop() 

    if(last is not opening parentheses of character)  

         valid = false 

  } 

} 

if(s is not empty) 

valid = false

如果我们看伪代码,看起来非常简单。目标是忽略字符串中的任何数字、操作数或空格,并只考虑括号、大括号和方括号。如果它们是开放括号,我们将推入堆栈。如果它们是闭合括号,我们将弹出堆栈。如果弹出的括号不是我们要匹配的开放括号,则它是无效的。循环结束时,如果字符串有效,则堆栈应为空。但是如果堆栈不为空,则有额外的括号,因此字符串无效。现在让我们将其转换为程序:

function expressionChecker(string $expression): bool { 

    $valid = TRUE; 

    $stack = new SplStack(); 

    for ($i = 0; $i < strlen($expression); $i++) { 

    $char = substr($expression, $i, 1); 

    switch ($char) { 

      case '(': 

      case '{': 

      case '[': 

      $stack->push($char); 

      break; 

      case ')': 

      case '}': 

      case ']': 

      if ($stack->isEmpty()) { 

          $valid = FALSE; 

      } else { 

        $last = $stack->pop(); 

        if (($char == ")" && $last != "(")  

          || ($char == "}" && $last != "{")  

          || ($char == "]" && $last != "[")) { 

      $valid = FALSE; 

        } 

    } 

    break; 

  } 

  if (!$valid) 

      break; 

    } 

    if (!$stack->isEmpty()) { 

    $valid = FALSE; 

    } 

    return $valid; 

}

现在让我们运行我们之前讨论的三个示例:

$expressions = []; 

$expressions[] = "8 * (9 -2) + { (4 * 5) / ( 2 * 2) }"; 

$expressions[] = "5 * 8 * 9 / ( 3 * 2 ) )"; 

$expressions[] = "[{ (2 * 7) + ( 15 - 3) ]"; 

foreach ($expressions as $expression) { 

    $valid = expressionChecker($expression); 

    if ($valid) { 

    echo "Expression is valid \n"; 

    } else { 

    echo "Expression is not valid \n"; 

    } 

} 

这将产生我们想要的以下输出:

Expression is valid

Expression is not valid

Expression is not valid

理解队列

队列是另一种遵循先进先出FIFO)原则的特殊线性数据结构。操作有两端:一个用于向队列追加,一个用于从队列中移除。这与堆栈不同,堆栈中我们使用一个端口进行添加和移除操作。插入将始终在后部或后部进行。元素的移除将从前端进行。向队列添加新元素的过程称为入队,移除元素的过程称为出队。查看队列前端元素而不移除元素的过程称为 peek,类似于堆栈的 top 操作。以下图示表示队列的表示:

现在,如果我们为队列定义一个接口,它将如下所示:

interface Queue { 

    public function enqueue(string $item); 

    public function dequeue(); 

    public function peek(); 

    public function isEmpty(); 

}

现在我们可以使用不同的方法实现队列,就像我们为堆栈所做的那样。首先,我们将使用 PHP 数组实现队列,然后是LinkedList,然后是SplQueue

使用 PHP 数组实现队列

我们现在将使用 PHP 数组来实现队列数据结构。我们已经看到我们可以使用array_push()函数将元素添加到数组的末尾。为了删除数组的第一个元素,我们可以使用 PHP 的array_shift()函数,对于 peek 函数,我们可以使用 PHP 的current()函数。根据我们的讨论,代码将如下所示:

class AgentQueue implements Queue {

    private $limit; 

    private $queue; 

    public function __construct(int $limit = 20) { 

      $this->limit = $limit; 

      $this->queue = []; 

    } 

    public function dequeue(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Queue is empty'); 

      } else { 

          return array_shift($this->queue); 

      } 

    } 

    public function enqueue(string $newItem) { 

      if (count($this->queue) < $this->limit) { 

          array_push($this->queue, $newItem); 

      } else { 

          throw new OverflowException('Queue is full'); 

      } 

    } 

    public function peek(): string { 

      return current($this->queue); 

    } 

    public function isEmpty(): bool { 

      return empty($this->queue); 

    } 

}

在这里,我们保持了与堆栈相同的原则。我们希望定义一个固定大小的队列,并检查溢出和下溢。为了运行队列实现,我们可以考虑将其用作呼叫中心应用程序的代理队列。以下是利用我们的队列操作的代码:

try { 

    $agents = new AgentQueue(10); 

    $agents->enqueue("Fred"); 

    $agents->enqueue("John"); 

    $agents->enqueue("Keith"); 

    $agents->enqueue("Adiyan"); 

    $agents->enqueue("Mikhael"); 

    echo $agents->dequeue()."\n"; 

    echo $agents->dequeue()."\n"; 

    echo $agents->peek()."\n"; 

} catch (Exception $e) { 

    echo $e->getMessage(); 

} 

这将产生以下输出:

Fred

John

Keith

使用链表实现队列

与堆栈实现一样,我们将在第三章中使用我们的链表实现,使用链表,在这里实现队列。我们可以使用insert()方法来确保我们始终在末尾插入。我们可以使用deleteFirst()进行出队操作,使用getNthNode()进行查看操作。以下是使用链表实现队列的示例实现:

class AgentQueue implements Queue { 

    private $limit; 

    private $queue; 

    public function __construct(int $limit = 20) { 

      $this->limit = $limit; 

      $this->queue = new LinkedList(); 

    } 

    public function dequeue(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Queue is empty'); 

      } else { 

          $lastItem = $this->peek(); 

          $this->queue->deleteFirst(); 

          return $lastItem; 

      } 

    } 

    public function enqueue(string $newItem) { 

      if ($this->queue->getSize() < $this->limit) { 

          $this->queue->insert($newItem); 

      } else { 

          throw new OverflowException('Queue is full'); 

      } 

    } 

    public function peek(): string { 

      return $this->queue->getNthNode(1)->data; 

    } 

    public function isEmpty(): bool { 

      return $this->queue->getSize() == 0; 

    } 

}

使用 SPL 中的 SplQueue 类

如果我们不想费力实现队列功能,并且满意于内置解决方案,我们可以使用SplQueue类来满足我们的基本队列需求。我们必须记住一件事:SplQueue类中没有 peek 函数可用。我们必须使用bottom()函数来获取队列的第一个元素。以下是使用SplQueue为我们的AgentQueue实现的简单队列实现:

$agents = new SplQueue(); 

$agents->enqueue("Fred"); 

$agents->enqueue("John"); 

$agents->enqueue("Keith"); 

$agents->enqueue("Adiyan"); 

$agents->enqueue("Mikhael"); 

echo $agents->dequeue()."\n"; 

echo $agents->dequeue()."\n"; 

echo $agents->bottom()."\n";

理解优先队列

优先级队列是一种特殊类型的队列,其中项目根据其优先级插入和移除。在编程世界中,优先级队列的使用是巨大的。例如,假设我们有一个非常庞大的电子邮件队列系统,我们通过队列系统发送月度通讯。如果我们需要使用相同的队列功能向用户发送紧急电子邮件,那么会发生什么?由于一般队列原则是在末尾添加项目,发送该消息的过程将被延迟很多。为了解决这个问题,我们可以使用优先级队列。在这种情况下,我们为每个节点分配一个优先级,并根据该优先级对它们进行排序。具有更高优先级的项目将排在列表顶部,并且将更早地出列。

我们可以采取两种方法来构建优先级队列。

有序序列

如果我们为优先级队列计划一个有序序列,它可以是升序或降序。有序序列的积极面是我们可以快速找到最大或删除最大优先级的项目,因为我们可以使用O(1)的复杂度找到它。但是插入会花费更多时间,因为我们必须检查队列中的每个元素,以根据其优先级将项目放在正确的位置。

无序序列

无序序列不需要我们遍历每个队列元素以放置新添加的元素。它总是作为一般队列原则添加到后面。因此,我们可以以O(1)的复杂度实现入队操作。但是,如果我们想要找到或删除最高优先级的元素,那么我们必须遍历每个元素以找到正确的元素。因此,它不太适合搜索。

现在我们将编写代码,使用有序序列和链表来实现优先级队列。

使用链表实现优先级队列

到目前为止,我们只看到了使用一个值的链表,即节点数据。现在我们需要传递另一个值,即优先级。为了实现这一点,我们需要改变我们的ListNode实现:

class ListNode {

    public $data = NULL; 

    public $next = NULL;

    public $priority = NULL;

    public function __construct(string $data = NULL, int $priority = 

      NULL) { 

      $this->data = $data;

      $this->priority = $priority;

    }

}

现在我们的节点包含数据和优先级。为了在插入操作期间考虑这个优先级,我们还需要改变LinkedList类内的insert()实现。以下是修改后的实现:

public function insert(string $data = NULL, int $priority = NULL) { 

  $newNode = new ListNode($data, $priority); 

  $this->_totalNode++; 

  if ($this->_firstNode === NULL) { 

      $this->_firstNode = &$newNode; 

  } else { 

      $previous = $this->_firstNode; 

      $currentNode = $this->_firstNode; 

      while ($currentNode !== NULL) { 

      if ($currentNode->priority < $priority) { 

         if ($currentNode == $this->_firstNode) { 

         $previous = $this->_firstNode; 

         $this->_firstNode = $newNode; 

         $newNode->next = $previous; 

         return; 

         } 

         $newNode->next = $currentNode; 

         $previous->next = $newNode; 

         return; 

    } 

    $previous = $currentNode; 

    $currentNode = $currentNode->next; 

    } 

  } 

  return TRUE; 

}

我们可以看到,我们的insert方法已经更改为在插入操作期间同时获取数据和优先级。通常情况下,第一个过程是创建一个新节点并增加节点计数。插入有三种可能性,如下所示:

  • 列表为空,所以新节点是第一个节点。

  • 列表不为空,但新项目具有最高优先级,所以。所以它成为第一个节点,之前的第一个节点跟随它。

  • 列表不为空,优先级不是最高,所以将新节点插入列表内,或者可能在列表末尾。

在我们的实现中,我们考虑了所有三种可能性,三个事实。因此,我们始终将最高优先级的项目放在列表的开头。现在让我们使用新代码运行AgentQueue实现,如下例所示:

try { 

    $agents = new AgentQueue(10); 

    $agents->enqueue("Fred", 1); 

    $agents->enqueue("John", 2); 

    $agents->enqueue("Keith", 3); 

    $agents->enqueue("Adiyan", 4); 

    $agents->enqueue("Mikhael", 2); 

    $agents->display(); 

    echo $agents->dequeue()."\n"; 

    echo $agents->dequeue()."\n"; 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

如果没有优先级,那么队列应该是FredJohnKeithAdiyanMikhael。但由于我们已经将优先级添加到列表中,输出结果是:

Adiyan

Keith

John

Mikhael

Fred

由于Adiyan具有最高优先级,即使它是在队列的第四个位置插入的,它也被放在队列的开头。

使用 SplPriorityQueue 实现优先级队列

PHP 已经内置了使用 SPL 实现优先级队列的支持。我们可以使用SplPriorityQueue类来实现我们的优先级队列。以下是使用链表的示例之前的示例,但这次我们选择了 SPL:

class MyPQ extends SplPriorityQueue { 

    public function compare($priority1, $priority2) { 

    return $priority1 <=> $priority2; 

    }

}

$agents = new MyPQ();

$agents->insert("Fred", 1); 

$agents->insert("John", 2);

$agents->insert("Keith", 3);

$agents->insert("Adiyan", 4);

$agents->insert("Mikhael", 2);

//mode of extraction

$agents->setExtractFlags(MyPQ::EXTR_BOTH); 

//Go to TOP

$agents->top();

while ($agents->valid()) {

    $current = $agents->current();

    echo $current['data'] . "\n";

    $agents->next();

}

这将产生与链表示例相同的结果。扩展到我们自己的MyPQ类的附加优势是,我们可以定义是否要按升序或降序对其进行排序。在这里,我们选择降序排序,使用 PHP 组合比较运算符或太空船运算符。

大多数情况下,优先队列是使用堆来实现的。当我们转到堆章节时,我们还将使用堆来实现优先队列。

实现循环队列

当我们使用标准队列时,每次出队一个项目,我们都必须重新缓冲整个队列。为了解决这个问题,我们可以使用循环队列,其中后端紧随前端,形成一个循环。这种特殊类型的队列需要对入队和出队操作进行特殊计算,考虑到队列的后端、前端和限制。循环队列始终是固定队列,也称为循环缓冲区或环形缓冲区。以下图示了循环队列的表示:

我们可以使用 PHP 数组来实现循环队列。由于我们必须计算后端和前端部分的位置,数组可以有效地用于此目的。以下是循环队列的示例:

class CircularQueue implements Queue { 

    private $queue; 

    private $limit; 

    private $front = 0; 

    private $rear = 0; 

    public function __construct(int $limit = 5) { 

      $this->limit = $limit; 

      $this->queue = []; 

    } 

    public function size() { 

      if ($this->rear > $this->front) 

          return $this->rear - $this->front; 

      return $this->limit - $this->front + $this->rear; 

    } 

    public function isEmpty() { 

      return $this->rear == $this->front; 

    } 

    public function isFull() { 

      $diff = $this->rear - $this->front; 

      if ($diff == -1 || $diff == ($this->limit - 1)) 

          return true; 

      return false; 

    } 

    public function enqueue(string $item) { 

      if ($this->isFull()) { 

          throw new OverflowException("Queue is Full."); 

      } else { 

          $this->queue[$this->rear] = $item; 

          $this->rear = ($this->rear + 1) % $this->limit; 

      } 

    } 

    public function dequeue() { 

      $item = ""; 

      if ($this->isEmpty()) { 

          throw new UnderflowException("Queue is empty"); 

      } else { 

          $item = $this->queue[$this->front]; 

          $this->queue[$this->front] = NULL; 

          $this->front = ($this->front + 1) % $this->limit; 

      } 

      return $item; 

    } 

    public function peek() { 

      return $this->queue[$this->front]; 

    }

}

由于我们将0视为前端标记,队列的总大小将为limit - 1

创建双端队列(deque)

到目前为止,我们已经实现了队列,其中一个端口用于入队,称为后端,另一个端口用于出队,称为前端。因此,通常每个端口都应该用于特定的目的。但是,如果我们需要从两端进行入队和出队操作怎么办?这可以通过使用一个称为双端队列或 deque 的概念来实现。在 deque 中,两端都可以用于入队和出队操作。如果我们查看使用链表的队列实现,我们会发现我们可以使用链表实现进行在末尾插入、在开头插入、在末尾删除和在开头删除。如果我们基于此实现一个新的 deque 类,我们可以轻松实现我们的目标。以下图示了一个双端队列:

这是一个双端队列的实现:

class DeQueue { 

    private $limit; 

    private $queue; 

    public function __construct(int $limit = 20) { 

      $this->limit = $limit; 

      $this->queue = new LinkedList(); 

    } 

    public function dequeueFromFront(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Queue is empty'); 

      } else { 

          $lastItem = $this->peekFront(); 

          $this->queue->deleteFirst(); 

          return $lastItem; 

      } 

    } 

    public function dequeueFromBack(): string { 

      if ($this->isEmpty()) { 

          throw new UnderflowException('Queue is empty'); 

      } else { 

          $lastItem = $this->peekBack(); 

          $this->queue->deleteLast(); 

          return $lastItem; 

      } 

    } 

    public function enqueueAtBack(string $newItem) { 

      if ($this->queue->getSize() < $this->limit) { 

          $this->queue->insert($newItem); 

      } else { 

          throw new OverflowException('Queue is full'); 

      } 

    } 

    public function enqueueAtFront(string $newItem) { 

      if ($this->queue->getSize() < $this->limit) { 

          $this->queue->insertAtFirst($newItem); 

      } else { 

          throw new OverflowException('Queue is full'); 

      } 

    } 

    public function peekFront(): string { 

      return $this->queue->getNthNode(1)->data; 

    } 

    public function peekBack(): string { 

      return $this->queue->getNthNode($this->queue->getSize())->data; 

    } 

    public function isEmpty(): bool { 

      return $this->queue->getSize() == 0; 

    } 

}

现在我们将使用这个类来检查双端队列的操作:

try { 

    $agents = new DeQueue(10); 

    $agents->enqueueAtFront("Fred"); 

    $agents->enqueueAtFront("John"); 

    $agents->enqueueAtBack("Keith"); 

    $agents->enqueueAtBack("Adiyan"); 

    $agents->enqueueAtFront("Mikhael"); 

    echo $agents->dequeueFromBack() . "\n"; 

    echo $agents->dequeueFromFront() . "\n"; 

    echo $agents->peekFront() . "\n"; 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

如果我们查看前面的代码示例,首先我们在前端添加Fred,然后再次在前端添加John。所以现在的顺序是JohnFred。然后我们在后端添加Keith,然后是Adiyan。所以现在我们有顺序JohnFredKeithAdiyan。最后,我们在开头添加Mikhael。所以最终的顺序是MikhaelJohnFredKeithAdiyan

由于我们首先从后端进行出队操作,Adiyan将首先出队,然后是从前端的Mikhael。新的前端将是John。当您运行代码时,以下是输出:

Adiyan

Mikhael

John

摘要

栈和队列是最常用的数据结构之一。在未来的算法和数据结构中,我们可以以不同的方式使用这些抽象数据类型。在本章中,我们学习了实现栈和队列的不同方法,以及不同类型的队列。在下一章中,我们将讨论递归-一种通过将大问题分解为较小实例来解决问题的特殊方法。

第五章:应用递归算法 - 递归

解决复杂问题总是很困难的。即使对于程序员来说,解决复杂问题也可能更加困难,有时需要特殊的解决方案。递归是计算机程序员用来解决复杂问题的一种特殊方法。在本章中,我们将介绍递归的定义、属性、不同类型的递归以及许多示例。递归并不是一个新概念;在自然界中,我们看到许多递归元素。分形展现了递归行为。以下图像显示了自然递归:

理解递归

递归是通过将大问题分解为小问题来解决更大问题的一种方法。换句话说,递归是将大问题分解为更小的相似问题来解决它们并获得实际结果。通常,递归被称为函数调用自身。这可能听起来很奇怪,但事实是当函数递归时,函数必须调用自身。这是什么样子?让我们看一个例子,

在数学中,“阶乘”这个术语非常流行。数字N的阶乘被定义为小于等于N的所有正整���的乘积。它总是用!(感叹号)表示。因此,5的阶乘可以写成如下形式:

5! = 5 X 4 X 3 X 2 X 1

同样,我们可以写出给定数字的以下阶乘:

4! = 4 X 3 X 2 X 1

3! = 3 X 2 X 1

2! = 2 X 1

1! = 1

如果我们仔细观察我们的例子,我们可以看到我们可以用4的阶乘来表示5的阶乘,就像这样:

5! = 5 X 4!

同样,我们可以写成:

4! = 4 X 3!

3! = 3 X 2!

2! = 2 X 1!

1! = 1 X 0!

0! = 1

或者,我们可以简单地说一般来说:

n! = n * (n-1)!

这代表了递归。我们将每个步骤分解成更小的步骤,并解决实际的大问题。这里有一张图片展示了如何计算 3 的阶乘:

因此,步骤如下:

  1. 3! = 3 X 2!

  2. 2! = 2 X 1!

  3. 1! = 1 X 0!

  4. 0! = 1

  5. 1! = 1 X 1 = 1

  6. 2! = 2 X 1 = 2

  7. 3! = 3 X 2 = 6

递归算法的属性

现在,问题可能是,“如果一个函数调用自身,那么它如何停止或知道何时完成递归调用?”当我们编写递归解决方案时,我们必须确保它具有以下属性:

  1. 每个递归调用都应该是一个更小的子问题。就像阶乘的例子,6 的阶乘是用 6 和 5 的阶乘相乘来解决的,依此类推。

  2. 它必须有一个基本情况。当达到基本情况时,将不会有进一步的递归,并且基本情况必须能够解决问题,而不需要进一步的递归调用。在我们的阶乘示例中,我们没有从 0 进一步。所以,在这种情况下,0 是我们的基本情况。

  3. 不应该有任何循环。如果每个递归调用都调用同一个问题,那么将会有一个永无止境的循环。经过一些重复后,计算机将显示堆栈溢出错误。

因此,如果我们现在使用 PHP 7 编写我们的阶乘程序,那么它将如下所示:

function factorial(int $n): int {

   if ($n == 0)

    return 1;

   return $n * factorial($n - 1);

}

在前面的示例代码中,我们可以看到我们有一个基本条件,当\(n\)的值为\(0\)时,我们返回1。如果不满足这个条件,那么我们返回\(n\)的乘积和\(n-1\)的阶乘。所以,它满足 1 和 3 这两个数字的属性。我们避免了循环,并确保每个递归调用都创建了一个更大的子问题。我们将像这样编写递归行为的算法:

递归与迭代算法

如果我们分析我们的阶乘函数,我们可以看到它可以使用简单的迭代方法来编写,使用forwhile循环,如下所示:

function factorial(int $n): int { 

    $result = 1; 

    for ($i = $n; $i > 0; $i--) {

      $result *= $i; 

    } 

    return $result; 

}

如果这可以写成一个简单的迭代形式,那么为什么要使用递归呢?递归用于解决更复杂的问题。并非所有问题都可以如此轻松地迭代解决。例如,我们需要显示某个目录中的所有文件。我们可以通过运行循环来列出所有文件来简单地做到这一点。但是,如果里面还有另一个目录呢?那么,我们必须运行另一个循环来获取该目录中的所有文件。如果该目录中还有另一个目录,依此类推呢?在这种情况下,迭代方法可能根本无济于事,或者可能会产生一个复杂的解决方案。在这里最好选择递归方法。

递归管理一个调用堆栈来管理函数调用。因此,与迭代相比,递归将需要更多的内存和时间来完成。此外,在迭代中,每一步都可以得到一个结果,但对于递归,我们必须等到基本情况执行才能得到任何结果。如果我们考虑阶乘的迭代和递归示例,我们可以看到有一个名为$result的局部变量来存储每一步的计算。然而,在递归中,不需要局部变量或赋值。

使用递归实现斐波那契数

在数学中,斐波那契数是特殊的整数序列,其中一个数字由过去两个数字的求和组成,如下所示的表达式:

如果我们使用 PHP 7 来实现,它将如下所示:

function fibonacci(int $n): int { 

    if ($n == 0) { 

    return 1; 

    } else if ($n == 1) { 

    return 1; 

    } else { 

    return fibonacci($n - 1) + fibonacci($n - 2); 

    } 

}

如果我们考虑前面的实现,可以看到它与以前的示例有些不同。现在,我们从一个函数调用中调用两个函数。我们将很快讨论不同类型的递归。

使用递归实现 GCD 计算

递归的另一个常见用途是实现两个数字的最大公约数GCD)计算。在 GCD 计算中,我们会一直进行下去,直到余数变为 0。可以表示如下:

现在,如果我们使用 PHP 7 进行递归实现,它将如下所示:

function gcd(int $a, int $b): int { 

    if ($b == 0) { 

     return $a; 

    } else { 

     return gcd($b, $a % $b); 

    } 

}

这个实现的另一个有趣部分是,与阶乘不同,我们不是从基本情况返回到调用堆栈中的其他步骤。基本情况将返回计算出的值。这是递归的一种优化方式。

不同类型的递归

到目前为止,我们已经看到了一些递归的示例案例以及它的使用方式。尽管术语是递归,但有不同类型的递归。我们将逐一探讨它们。

线性递归

在编程世界中最常用的递归之一是线性递归。当一个函数在每次运行中只调用自身一次时,我们将其称为线性递归。就像我们的阶乘示例一样,当我们将大的计算分解为较小的计算,直到达到基本条件时,我们称之为缠绕。当我们从基本条件返回到第一个递归调用时,我们称之为展开。在本章的后续部分中,我们将研究不同的线性递归。

二进制递归

在二进制递归中,函数在每次运行中调用自身两次。因此,计算取决于对自身的两个不同递归调用的结果。如果我们看看我们的斐波那契序列生成递归函数,我们很容易发现它是一个二进制递归。除此之外,在编程世界中,我们还有许多常用的二进制递归,如二分查找、分治、归并排序等。下图显示了一个二进制递归:

尾递归

当返回时没有待处理的操作时,递归方法是尾递归。例如,在我们的阶乘代码中,返回的值用于与前一个值相乘以计算阶乘。因此,这不是尾递归。斐波那契数列递归也是如此。如果我们看看我们的最大公约数递归,我们会发现在返回后没有要执行的操作。因此,最终返回或基本情况返回实际上就是答案。因此,最大公约数是尾递归的一个例子。尾递归也是线性递归的一种形式。

相互递归

可能会出现这样的情况,我们可能需要从两个不同的方法中交替地递归调用两个不同的方法。例如,函数 A() 调用函数 B(),函数 B() 在每次调用中调用函数 A()。这被称为相互递归。

嵌套递归

当递归函数调用自身作为参数时,它被称为嵌套递归。嵌套递归的一个常见例子是 Ackermann 函数。看看下面的方程:

如果我们看最后一行,我们可以看到函数 A() 被递归调用,但第二个参数本身是另一个递归调用。因此,这是嵌套递归的一个例子。

尽管有不同类型的递归可用,但我们只会根据我们的需求使用那些必需的。现在,我们将看到递归在我们的项目中的一些实际用途。

使用递归构建 N 级类别树

构建多级嵌套的类别树或菜单总是一个问题。许多 CMS 和网站只允许一定级别的嵌套。为了避免由于多次连接而导致的性能问题,一些只允许最多 3-4 级的嵌套。现在,我们将探讨如何利用递归创建一个 N 级嵌套的类别树或菜单,而不会影响性能。以下是我们解决方案的方法:

  1. 我们将为数据库中的类别定义表结构。

  2. 我们将在不使用任何连接或多个查询的情况下获取表中的所有类别。这将是一个带有简单选择语句的单个数据库查询。

  3. 我们将构建一个类别数组,以便我们可以利用递归来显示嵌套的类别或菜单。

让我们假设我们的数据库中有一个简单的表结构来存储我们的类别,它看起来像这样:

CREATE TABLE `categories` ( 

  `id` int(11) NOT NULL, 

  `categoryName` varchar(100) NOT NULL, 

  `parentCategory` int(11) DEFAULT 0, 

  `sortInd` int(11) NOT NULL 

) ENGINE=InnoDB DEFAULT CHARSET=utf8;

为简单起见,我们假设表中不需要其他字段。此外,我们的表中有一些数据如下:

Id 类别名称 父类别 排序索引
1 第一 0 0
2 第二 1 0
3 第三 1 1
4 第四 3 0
5 第五 4 0
6 第六 5 0
7 第七 6 0
8 第八 7 0
9 第九 1 0
10 第十 2 1

现在,我们已经为我们的数据库创建了一个结构化的表,并且我们也假设输入了一些示例数据。让我们构建一个查询来检索这些数据,以便我们可以转移到我们的递归解决方案:

$dsn = "mysql:host=127.0.0.1;port=3306;dbname=packt;"; 

$username = "root"; 

$password = ""; 

$dbh = new PDO($dsn, $username, $password); 

$result = $dbh->query("Select * from categories order by parentCategory asc, sortInd asc", PDO::FETCH_OBJ); 

$categories = []; 

foreach($result as $row) { 

    $categories[$row->parentCategory][] = $row;

}

上述代码的核心部分是我们如何将我们的类别存储在数组中。我们根据它们的父类别存储结果。这将帮助我们递归地显示类别的子类别。这看起来非常简单。现在,基于类别数组,让我们编写递归函数以分层显示类别:

function showCategoryTree(Array $categories, int $n) {

    if(isset($categories[$n])) { 

      foreach($categories[$n] as $category) {        

          echo str_repeat("-", $n)."".$category->categoryName."\n"; 

          showCategoryTree($categories, $category->id);          

      }

    }

    return;

}

上述代码实际上显示了所有类别及其子类别的递归。我们取一个级别,首先打印该级别上的类别。接着,我们将检查它是否有任何子级别的类别,使用代码 showCategoryTree($categories, $category->id)。现在,如果我们用根级别(级别 0)调用递归函数,那么我们将得到以下输出:

showCategoryTree($categories, 0);

这将产生以下输出:

First

-Second

--Tenth

-Third

---Fourth

----fifth

-----Sixth

------seventh

-------Eighth

-Nineth

正如我们所看到的,不需要考虑类别级别的深度或多个查询,我们可以只用一个简单的查询和递归函数构建嵌套类别或菜单。如果需要动态显示和隐藏功能,我们可以使用<ul><li>来创建嵌套菜单。这对于在不涉及实现阻碍的情况下获得问题的有效解决方案非常重要,比如具有固定级别的连接或固定级别的类别。前面的示例是尾递归的完美展示,在这里我们不需要等待递归返回任何东西,随着我们的前进,结果已经显示出来了。

构建嵌套的评论回复系统

我们经常面临的挑战是以适当的方式显示评论回复。按时间顺序显示它们有时不符合我们的需求。我们可能需要以这样的方式显示它们,即每条评论的回复都在实际评论本身下面。换句话说,我们可以说我们需要一个嵌套的评论回复系统或者线程化评论。我们想要构建类似以下截图的东西:

我们可以按照嵌套类别部分所做的相同步骤进行。但是,这一次,我们将有一些 UI 元素,使其看起来更真实。假设我们有一个名为comments的表,其中包含以下数据和列。为简单起见,我们不涉及多个表关系。我们假设用户名存储在与评论相同的表中:

Id 评论 用户名 日期时间 父 ID 帖子 ID
1 第一条评论 Mizan 2016-10-01 15:10:20 0 1
2 第一条回复 Adiyan 2016-10-02 04:09:10 1 1
3 第一条回复的回复 Mikhael 2016-10-03 11:10:47 2 1
4 第一条回复的回复的回复 Arshad 2016-10-04 21:22:45 3 1
5 第一条回复的回复的回复的回复 Anam 2016-10-05 12:01:29 4 1
6 第二条评论 Keith 2016-10-01 15:10:20 0 1
7 第二篇帖子的第一条评论 Milon 2016-10-02 04:09:10 0 2
8 第三条评论 Ikrum 2016-10-03 11:10:47 0 1
9 第二篇帖子的第二条评论 Ahmed 2016-10-04 21:22:45 0 2
10 第二篇帖子的第二条评论的回复 Afsar 2016-10-18 05:18:24 9 2

现在让我们编写一个准备好的语句来从帖子中获取所有评论。然后,我们可以构建一个类似嵌套类别的数组:

$sql = "Select * from comments where postID = :postID order by parentID asc, datetime asc"; 

$stmt = $dbh->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY)); 

$stmt->setFetchMode(PDO::FETCH_OBJ); 

$stmt->execute(array(':postID' => 1)); 

$result = $stmt->fetchAll(); 

$comments = []; 

foreach ($result as $row) { 

    $comments[$row->parentID][] = $row;

}

现在,我们有了数组和其中的所有必需数据;我们现在可以编写一个函数,该函数将递归调用以正确缩进显示评论:

function displayComment(Array $comments, int $n) { 

   if (isset($comments[$n])) { 

      $str = "<ul>"; 

      foreach ($comments[$n] as $comment) { 

          $str .= "<li><div class='comment'><span class='pic'>

            {$comment->username}</span>"; 

          $str .= "<span class='datetime'>{$comment->datetime}</span>"; 

          $str .= "<span class='commenttext'>" . $comment->comment . "

            </span></div>"; 

          $str .= displayComment($comments, $comment->id); 

          $str .= "</li>"; 

       } 

      $str .= "</ul>"; 

      return $str; 

    } 

    return ""; 

} 

echo displayComment($comments, 0); 

由于我们在 PHP 代码中添加了一些 HTML 元素,因此我们需要一些基本的 CSS 来使其工作。这是我们编写的 CSS 代码,用于创建清晰的设计。没有花哨的东西,只是纯 CSS 来创建级联效果和对评论每个部分的基本样式:

  ul { 

      list-style: none; 

      clear: both; 

  }

  li ul { 

      margin: 0px 0px 0px 50px; 

  } 

  .pic { 

      display: block; 

      width: 50px; 

      height: 50px; 

      float: left; 

      color: #000; 

      background: #ADDFEE; 

      padding: 15px 10px; 

      text-align: center; 

      margin-right: 20px; 

  }

  .comment { 

      float: left; 

      clear: both; 

      margin: 20px; 

      width: 500px; 

  }

  .datetime { 

      clear: right; 

      width: 400px; 

      margin-bottom: 10px; 

      float: left; 

  }

正如前面提到的,我们在这里并不试图做一些复杂的东西,只是响应式的,设备友好的等等。我们假设您可以在应用程序的不同部分集成逻辑而不会出现任何问题。

这是数据和前面代码的输出:

从前面的两个示例中,我们可以看到,很容易创建嵌套内容,而无需多个查询或对嵌套的连接语句有限制。我们甚至不需要自连接来生成嵌套数据。

使用递归查找文件和目录

我们经常需要找到目录中的所有文件。这包括其中所有的子目录,以及这些子目录中的目录。因此,我们需要一个递归解决方案来找到给定目录中的文件列表。以下示例将展示一个简单的递归函数来列出目录中的所有文件:

function showFiles(string $dirName, Array &$allFiles = []) { 

    $files = scandir($dirName); 

    foreach ($files as $key => $value) { 

      $path = realpath($dirName . DIRECTORY_SEPARATOR . $value); 

      if (!is_dir($path)) { 

          $allFiles[] = $path; 

      } else if ($value != "." && $value != "..") { 

          showFiles($path, $allFiles); 

          $allFiles[] = $path; 

      } 

   } 

    return; 

} 

$files = []; 

showFiles(".", $files);

showFiles 函数实际上接受一个目录,并首先扫描目录以列出其中的所有文件和目录。然后,通过 foreach 循环,它遍历每个文件和目录。如果是一个目录,我们再次调用 . 函数以列出其中的文件和目录。这将继续,直到我们遍历所有文件和目录。现在,我们有了 $files 数组下的所有文件。现在,让我们使用 foreach 循环顺序显示文件:

foreach($files as $file) {

    echo $file."\n";

}

这将在命令行中产生以下输出:

/home/mizan/packtbook/chapter_1_1.php

/home/mizan/packtbook/chapter_1_2.php

/home/mizan/packtbook/chapter_2_1.php

/home/mizan/packtbook/chapter_2_2.php

/home/mizan/packtbook/chapter_3_.php

/home/mizan/packtbook/chapter_3_1.php

/home/mizan/packtbook/chapter_3_2.php

/home/mizan/packtbook/chapter_3_4.php

/home/mizan/packtbook/chapter_4_1.php

/home/mizan/packtbook/chapter_4_10.php

/home/mizan/packtbook/chapter_4_11.php

/home/mizan/packtbook/chapter_4_2.php

/home/mizan/packtbook/chapter_4_3.php

/home/mizan/packtbook/chapter_4_4.php

/home/mizan/packtbook/chapter_4_5.php

/home/mizan/packtbook/chapter_4_6.php

/home/mizan/packtbook/chapter_4_7.php

/home/mizan/packtbook/chapter_4_8.php

/home/mizan/packtbook/chapter_4_9.php

/home/mizan/packtbook/chapter_5_1.php

/home/mizan/packtbook/chapter_5_2.php

/home/mizan/packtbook/chapter_5_3.php

/home/mizan/packtbook/chapter_5_4.php

/home/mizan/packtbook/chapter_5_5.php

/home/mizan/packtbook/chapter_5_6.php

/home/mizan/packtbook/chapter_5_7.php

/home/mizan/packtbook/chapter_5_8.php

/home/mizan/packtbook/chapter_5_9.php

这些是我们在开发过程中面临的一些常见挑战的解决方案。然而,还有其他地方我们将大量使用递归,比如二进制搜索、树、分治算法等。我们将在接下来的章节中讨论它们。

分析递归算法

递归算法的分析取决于我们使用的递归类型。如果是线性的,复杂度将不同;如果是二进制的,复杂度也将不同。因此,递归算法没有通用的复杂度。我们必须根据具体情况进行分析。在这里,我们将分析阶乘序列。首先,让我们专注于阶乘部分。如果我们回忆一下这一节,我们对阶乘递归有这样的东西:

function factorial(int $n): int { 

    if ($n == 0) 

    return 1; 

    return $n * factorial($n - 1); 

} 

假设计算阶乘($n)需要 T(n)。我们将专注于如何使用大 O 符号表示这个 T(n)。每次调用阶乘函数时,都涉及某些步骤:

  1. 每次,我们都在检查基本情况。

  2. 然后,我们在每个循环中调用阶乘($n-1)。

  3. 我们在每个循环中用 $n 进行乘法。

  4. 然后,我们返回结果。

现在,如果我们用 T(n) 表示这个,那么我们可以说:

T(n) = 当 n = 0 时,a

T(n) = 当 n > 0 时,T(n-1) + b

在这里,ab 都是一些常数。现在,让我们用 n 生成 ab 之间的关系。我们可以轻松地写出以下方程:

T(0) = a

T(1) = T(0) + b = a + b

T(2) = T(1) + b = a + b + b = a + 2b

T(3) = T(2) + b = a + 2b + b = a + 3b

T(4) = T(3) + b = a + 3b + b = a + 4b

我们可以看到这里出现了一个模式。因此,我们可以确定:

T(n) = a + (n) b

或者,我们也可以简单地说 T(n) = O(n)

因此,阶乘递归具有线性复杂度 O(n)

具有递归的斐波那契序列大约具有 O(2^n) 的复杂度。计算非常详细,因为我们必须考虑大 O 符号的下界和上界。在接下来的章节中,我们还将分析二进制递归,如二进制搜索和归并排序。我们将在这些章节中更多地关注递归分析。

PHP 中的最大递归深度

由于递归是函数调用自身的过程,我们可以心中有一个有效的问题,比如“这个递归可以有多深?”。让我们为此编写一个小程序:

function maxDepth() {

    static $i = 0;

    print ++$i . "\n";

    maxDepth();

}

maxDepth();

我们能猜测最大深度水平吗?在耗尽内存限制之前,深度达到了 917,056 级。如果启用了XDebug,那么限制将比这个小得多。这也取决于您的内存、操作系统和 PHP 设置,如内存限制和最大执行时间。

虽然我们有选择深入进行递归,但始终重要的是要记住,我们必须控制好我们的递归函数。我们应该知道基本条件以及递归必须在何处结束。否则,可能会产生一些错误的结果或突然结束。

使用 SPL 递归迭代器

标准 PHP 库 SPL 有许多内置的迭代器,用于递归目的。我们可以根据需要使用它们,而不必费力实现它们。以下是迭代器及其功能的列表:

  • RecursiveArrayIterator:这个递归迭代器允许迭代任何类型的数组或对象,并修改键或值,或取消它们。它还允许迭代当前迭代器条目。

  • 递归回调过滤迭代器:如果我们希望递归地将回调应用于任何数组或对象,这个迭代器可以非常有帮助。

  • 递归目录迭代器:这个迭代器允许迭代任何目录或文件系统。它使得目录列表非常容易。例如,我们可以很容易地使用这个迭代器重新编写本章中编写的目录列表程序:

$path = realpath('.'); 

$files = new RecursiveIteratorIterator( 

   new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::SELF_FIRST); 

foreach ($files as $name => $file) { 

    echo "$name\n"; 

}

  • 递归过滤迭代器:如果我们在迭代过程中递归地寻找过滤选项,我们可以使用这个抽象迭代器来实现过滤部分。

  • 递归迭代迭代器:如果我们想要迭代任何递归迭代器,我们可以使用这个。它已经内置,我们可以很容易地应用它。在RecursiveDirectoryIterator部分中显示了它的使用示例。

  • 递归正则表达式迭代器:如果您想要应用正则表达式来过滤迭代器,我们可以使用这个迭代器以及其他迭代器。

  • 递归树迭代器:递归树迭代器允许我们为任何目录或多维数组创建类似树的图形表示。例如,以下足球队列表数组将产生树结构:

$teams = array( 

    'Popular Football Teams', 

    array( 

  'La Lega', 

  array('Real Madrid', 'FC Barcelona', 'Athletico Madrid', 'Real  

    Betis', 'Osasuna') 

    ), 

    array( 

  'English Premier League', 

  array('Manchester United', 'Liverpool', 'Manchester City', 'Arsenal',   

    'Chelsea') 

    ) 

); 

$tree = new RecursiveTreeIterator( 

  new RecursiveArrayIterator($teams), null, null, RecursiveIteratorIterator::LEAVES_ONLY 

); 

foreach ($tree as $leaf) 

    echo $leaf . PHP_EOL;

输出将如下所示:

|-Popular Football Teams

| |-La Lega

|   |-Real Madrid

|   |-FC Barcelona

|   |-Athletico Madrid

|   |-Real Betis

|   \-Osasuna

 |-English Premier League

 |-Manchester United

 |-Liverpool

 |-Manchester City

 |-Arsenal

 \-Chelsea

使用 PHP 内置函数 array_walk_recursive

array_walk_recursive可以是 PHP 中非常方便的内置函数,因为它可以递归地遍历任何大小的数组并应用回调函数。无论我们想要找出多维数组中是否存在元素,还是获取多维数组的总和,我们都可以毫无问题地使用这个函数。

执行以下代码示例将产生输出136

function array_sum_recursive(Array $array) { 

    $sum = 0; 

    array_walk_recursive($array, function($v) use (&$sum) { 

      $sum += $v; 

    }); 

    return $sum; 

} 

$arr =  

[1, 2, 3, 4, 5, [6, 7, [8, 9, 10, [11, 12, 13, [14, 15, 16]]]]]; 

echo array_sum_recursive($arr); 

PHP 中的另外两个内置递归数组函数是array_merge_recursivearray_replace_recursive。我们可以使用它们来合并多个数组到一个数组中,或者从多个数组中替换,分别。

总结

到目前为止,我们讨论了递归的不同属性和实际用途。我们已经看到了如何分析递归算法。计算机编程和递归是两个不可分割的部分。递归的使用几乎无处不在于编程世界中。在接下来的章节中,我们将更深入地探讨它,并在适用的地方应用它。在下一章中,我们将讨论另一个特殊的数据结构,称为“树”。

第六章:理解和实现树

到目前为止,我们对数据结构的探索只涉及了线性部分。无论我们使用数组、链表、栈还是队列,所有这些都是线性数据结构。我们已经看到了线性数据结构操作的复杂性,大多数情况下,插入和删除可以以O(1)的复杂度执行。然而,搜索有点复杂,并且需要O(n)的复杂度。唯一的例外是 PHP 数组,实际上它的工作原理是哈希表,如果索引或键以这种方式管理,可以在O(1)中进行搜索。为了解决这个问题,我们可以使用分层数据结构而不是线性数据结构。分层数据可以解决许多线性数据结构无法轻松解决的问题。每当我们谈论家族谱系、组织结构和网络连接图时,实际上我们在谈论分层数据。树是一种表示分层数据的特殊抽象数据类型ADT)。与链表不同,链表也是一种 ADT,树是分层的,而链表是线性的。在本章中,我们将探索树的世界。树结构的一个完美例子可以是家族谱系,就像下面的图片:

树的定义和属性

树是由边连接的节点或顶点的分层集合。树不能有循环,只有边存在于节点和其后代节点或子节点之间。同一父节点的两个子节点之间不能有任何边。每个节点除了顶节点(也称为根节点)外,还可以有一个父节点。每棵树只能有一个根节点。在下图中,A是根节点,BCDA的子节点。我们还可以说 A 是BCD的父节点。BCD被称为兄弟姐妹,因为它们是来自同一父节点A的子节点:

没有任何子节点的节点称为叶子。在前面的图表中,KLFGMIJ都是叶子节点。叶子节点也称为外部节点或终端节点。除了根节点之外,至少有一个子节点的节点称为内部节点。在这里,BCDEH是内部节点。在描述树数据结构时,我们使用一些其他常见术语:

  • 后代:这是一个可以通过重复进行到达父节点的节点。例如,在前面的图表中,MC的后代。

  • 祖先:这是一个可以通过重复方式从子节点到父节点到达的节点。例如,BL的祖先。

  • :特定父节点的子节点总数称为其度。在我们的例子中,A 的度为 3,B 的度为 1,C 的度为 3,D 的度为 2。

  • 路径:从源节点到目标节点的节点和边的序列称为两个节点之间的路径。路径的长度是路径中的节点数。在我们的例子中,AM的路径是A-C-H-M,路径的长度为 4:

  • 节点的高度:节点的高度由节点与后代节点的最深层之间的边的数量定义。例如,节点B的高度为 2。

  • 层级:层级表示节点的代。如果父节点在第n层,其子节点将在n+1层。因此,层级由节点与根之间的边的数量加 1 定义。在这里:

    • ALevel 0
  • BCDLevel 1

  • EFGHIJLevel 2

  • KLMLevel 3

  • 树的高度:树的高度由其根节点的高度定义。在这里,树的高度为 3。

  • 子树:在树结构中,每个子节点都递归地形成一个子树。换句话说,树由许多子树组成。例如,BEKL形成一个子树,而EKL形成一个子树。在前面的例子中,我们已经在左侧用不同的颜色标识了每个子树。我们也可以对CD及其子树做同样的事情。

  • 深度:节点的深度由节点与根节点之间的边的数量确定。例如,在我们的树图中,H的深度为 2,L的深度为 3。

  • 森林:森林是零个或多个不相交树的集合。

  • 遍历:这表示按特定顺序访问节点的过程。我们将在接下来的部分经常使用这个术语。

  • :键是用于搜索目的的节点中的值。

使用 PHP 实现树

到目前为止,您已经了解了树数据结构的不同属性。如果我们将树数据结构与现实生活中的例子进行比较,我们可以考虑我们的组织结构或家族谱来表示数据结构。对于组织结构,有一个根节点,可以是公司的 CEO,然后是 CXO 级别的员工,然后是其他级别的员工。在这里,我们不限制特定节点的程度。这意味着一个节点可以有多个子节点。因此,让我们考虑一个节点结构,我们可以定义节点属性、其父节点和其子节点。它可能看起来像这样:

class TreeNode { 

    public $data = NULL; 

    public $children = []; 

    public function __construct(string $data = NULL) { 

      $this->data = $data; 

    } 

    public function addChildren(TreeNode $node) { 

      $this->children[] = $node; 

    } 

} 

如果我们看一下前面的代码,我们可以看到我们为数据和子节点声明了两个公共属性。我们还有一个方法来向特定节点添加子节点。在这里,我们只是将新的子节点追加到数组的末尾。这将使我们有选择地为特定节点添加多个节点作为子节点。由于树是一个递归结构,它将帮助我们递归地构建树,也可以递归地遍历树。

现在,我们有了节点,让我们构建一个树结构,定义树的根节点以及遍历整个树的方法。因此,基本的树结构将如下所示:

class Tree { 

    public $root = NULL; 

    public function __construct(TreeNode $node) { 

      $this->root = $node; 

    } 

    public function traverse(TreeNode $node, int $level = 0) { 

      if ($node) { 

        echo str_repeat("-", $level); 

        echo $node->data . "\n"; 

        foreach ($node->children as $childNode) { 

          $this->traverse($childNode, $level + 1); 

        } 

      } 

    } 

} 

前面的代码显示了一个简单的树类,我们可以在其中存储根节点引用,并从任何节点遍历树。在遍历部分,我们正在访问每个子节点,然后立即递归调用遍历方法以获取当前节点的子节点。我们正在传递一个级别,以便在节点名称的开头打印一个破折号(-),这样我们就可以轻松地理解子级数据。

现在让我们创建根节点并将其分配给树作为根。代码将如下所示:

    $ceo = new TreeNode("CEO"); 

    $tree = new Tree($ceo); 

在这里,我们创建了第一个节点作为 CEO,然后创建了树,并将 CEO 节点分配为树的根节点。现在是时候从根节点开始扩展我们的树了。由于我们选择了 CEO 的例子,我们现在将在 CEO 下添加 CXO 和其他员工。以下是此代码:

$cto     = new TreeNode("CTO"); 

$cfo     = new TreeNode("CFO"); 

$cmo     = new TreeNode("CMO"); 

$coo     = new TreeNode("COO"); 

$ceo->addChildren($cto); 

$ceo->addChildren($cfo); 

$ceo->addChildren($cmo); 

$ceo->addChildren($coo); 

$seniorArchitect = new TreeNode("Senior Architect"); 

$softwareEngineer = new TreeNode("Software Engineer"); 

$userInterfaceDesigner      = new TreeNode("User Interface Designer"); 

$qualityAssuranceEngineer = new TreeNode("Quality Assurance Engineer"); 

$cto->addChildren($seniorArchitect); 

$seniorArchitect->addChildren($softwareEngineer); 

$cto->addChildren($qualityAssuranceEngineer); 

$cto->addChildren($userInterfaceDesigner); 

$tree->traverse($tree->root); 

在这里,我们在开始时创建了四个新节点(CTO、CFO、CMO 和 COO),并将它们分配为 CEO 节点的子节点。然后我们创建了高级架构师,这是软件工程师节点,接着是用户界面设计师和质量保证工程师。我们已经将高级软件工程师节点分配为高级架构师节点的子节点,并将高级架构师分配为 CTO 的子节点,以及用户界面工程师和质量保证工程师。最后一行是从根节点显示树。这将在我们的命令行中输出以下行:

CEO

-CTO

--Senior Architect

---Software Engineer

--Quality Assurance Engineer

--User Interface Designer

-CFO

-CMO

-COO

考虑到前面的输出,我们在级别 0 处有CEOCTOCFOCMOCOO在级别 1 处。Senior ArchitectUser Interface DesignerQuality Assurance Engineer在级别 2 处,Software Engineer在级别 3 处。

我们已经使用 PHP 构建了一个基本的树数据结构。现在,我们将探索我们拥有的不同类型的树。

不同类型的树结构

编程世界中存在许多类型的树数据结构。我们将在这里探讨一些最常用的树结构。

二叉树

二进制是树结构的最基本形式,其中每个节点最多有两个子节点。子节点称为左节点和右节点。二叉树将如下图所示:

二叉搜索树

二叉搜索树(BST)是一种特殊类型的二叉树,其中节点以排序的方式存储。它以这样一种方式排序,即在任何给定点,节点值必须大于或等于左子节点值,并且小于右子节点值。每个节点都必须满足此属性,才能将其视为二叉搜索树。由于节点按特定顺序排序,二叉搜索算法可以应用于以对数时间搜索 BST 中的项目。这总是优于线性搜索,它需要O(n)时间,我们将在下一章中探讨它。以下是一个二叉搜索树的示例:

自平衡二叉搜索树

自平衡二叉搜索树或高度平衡二叉搜索树是一种特殊类型的二叉搜索树,它试图通过自动调整始终保持树的高度或层级数尽可能小。例如,下图显示了左侧的二叉搜索树和右侧的自平衡二叉搜索树:

高度平衡的二叉树总是比普通 BST 更好,因为它可以使搜索操作比普通 BST 更快。有不同的自平衡或高度平衡二叉搜索树的实现。其中一些流行的如下:

  • AA 树

  • AVL 树

  • 红黑树

  • 替罪羊树

  • 伸展树

  • 2-3 树

  • Treap

我们将在以下章节讨论一些高度平衡树。

AVL 树

AVL 树是一种自平衡的二叉搜索树,其中一个节点的两个子树的高度最多相差 1。如果高度增加,在任何情况下都会重新平衡以使高度差为 1。这使 AVL 树在不同操作的复杂度上具有对数优势。以下是 AVL 树的示例:

红黑树

红黑树是一种具有额外属性的自平衡二叉搜索树,即颜色。二叉树中的每个节点存储一位额外的信息,即颜色,可以具有红色或黑色的值。与 AVL 树一样,红黑树也用于实时应用,因为平均和最坏情况的复杂度也是对数的。示例红黑树如下:

B 树

B 树是一种特殊类型的二叉树,它是自平衡的。这与自平衡的二叉搜索树不同。关键区别在于,在 B 树中,我们可以有任意数量的节点作为子节点,而不仅仅是两个。B 树用于大量数据,并主要用于文件系统和数据库。B 树中不同操作的复杂度是对数的。

N 叉树

N 叉树是一种特殊类型的树,其中一个节点最多可以有 N 个子节点。这也被称为 k 路树或 M 路树。二叉树是 N 叉树,其中 N 的值为 2。

理解二叉树

我们经常会对二叉树和二叉搜索树感到困惑。正如我们在定义中所看到的,BST 是一种排序的二叉树。如果它是排序的,那么与普通二叉树相比,我们可以有性能改进。每个二叉树节点最多可以有两个子节点,分别称为左子节点和右子节点。然而,根据二叉树的类型,可以有零个、一个或两个子节点。

我们还可以将二叉树分类为不同的类别:

  • 满二叉树: 满二叉树是一棵树,每个节点上要么没有子节点,要么有两个子节点。满二叉树也被称为完全二叉树或平衡二叉树。

  • 完美二叉树: 完美二叉树是一棵二叉树,其中所有内部节点恰好有两个子节点,所有叶子节点的级别或深度相同。

  • 完全二叉树: 完全二叉树是一棵二叉树,除了最后一层外,所有层都完全填充,所有节点尽可能地靠左。以下图表显示了满二叉树、完全二叉树和完美二叉树:

实现二叉树

我们现在将创建一个二叉树(不是二叉搜索树)。二叉树中必须具有的关键因素是,我们必须为左孩子节点和右孩子节点保留两个占位符,以及我们想要存储在节点中的数据。二叉节点的简单实现将如下所示:

class BinaryNode { 

    public $data; 

    public $left; 

    public $right; 

    public function __construct(string $data = NULL) { 

      $this->data = $data; 

      $this->left = NULL; 

      $this->right = NULL; 

    } 

    public function addChildren(BinaryNode $left, BinaryNode $right) { 

      $this->left = $left;

      $this->right = $right;

    }

}

前面的代码显示,我们有一个带有树属性的类来存储数据,左边和右边。当我们构造一个新节点时,我们将节点值添加到数据属性中,左边和右边保持NULL,因为我们不确定是否需要它们。我们还有一个addChildren方法来向特定节点添加左孩子和右孩子。

现在,我们将创建一个二叉树类,我们可以在其中定义根节点以及类似于本章早期的基本树实现的遍历方法。两种实现之间的区别在于遍历过程。在我们之前的示例中,我们使用foreach来遍历每个子节点,因为我们不知道有多少个节点。由于二叉树中的每个节点最多可以有两个节点,并且它们被命名为左和右,我们只能遍历左节点,然后遍历每个特定节点访问的右节点。更改后的代码将如下所示:

class BinaryTree { 

    public $root = NULL; 

    public function __construct(BinaryNode $node) { 

    $this->root = $node; 

    } 

    public function traverse(BinaryNode $node, int $level    

      = 0) { 

      if ($node) { 

          echo str_repeat("-", $level); 

          echo $node->data . "\n"; 

          if ($node->left) 

            $this->traverse($node->left, $level + 1); 

          if ($node->right) 

            $this->traverse($node->right, $level + 1); 

         } 

    } 

} 

这看起来与本章早期我们所拥有的基本树类非常相似。现在,让我们用一些节点填充二叉树。通常,在任何足球或板球比赛中,我们都有淘汰赛轮次,两支球队互相比赛,赢家继续前进,一直到决赛。我们可以在我们的示例中使用类似的结构作为二叉树。因此,让我们创建一些二叉节点并将它们结构化:

$final = new BinaryNode("Final"); 

$tree = new BinaryTree($final); 

$semiFinal1 = new BinaryNode("Semi Final 1"); 

$semiFinal2 = new BinaryNode("Semi Final 2"); 

$quarterFinal1 = new BinaryNode("Quarter Final 1"); 

$quarterFinal2 = new BinaryNode("Quarter Final 2"); 

$quarterFinal3 = new BinaryNode("Quarter Final 3"); 

$quarterFinal4 = new BinaryNode("Quarter Final 4"); 

$semiFinal1->addChildren($quarterFinal1, $quarterFinal2); 

$semiFinal2->addChildren($quarterFinal3, $quarterFinal4); 

$final->addChildren($semiFinal1, $semiFinal2); 

$tree->traverse($tree->root); 

首先,我们创建了一个名为 final 的节点,并将其作为根节点。然后,我们创建了两个半决赛节点和四个四分之一决赛节点。两个半决赛节点分别有两个四分之一决赛节点作为左右子节点。最终节点有两个半决赛节点作为左右子节点。addChildren方法正在为节点执行子节点分配工作。在最后一行,我们遍历了树并按层次显示了数据。如果我们在命令行中运行此代码,我们将看到以下输出:

Final

-Semi Final 1

--Quarter Final 1

--Quarter Final 2

-Semi Final 2

--Quarter Final 3

--Quarter Final 4

使用 PHP 数组创建二叉树

我们可以使用 PHP 数组实现二叉树。由于二叉树最多可以有零到两个子节点,我们可以将最大子节点数设为 2,并构建一个公式来找到给定节点的子节点。让我们从上到下、从左到右为二叉树中的节点编号。因此,根节点将具有编号0,左孩子1,右孩子2,依此类推,直到为每个节点编号,就像以下图表所示:

我们很容易看到,对于节点0,左孩子是1,右孩子是2。对于节点1,左孩子是3,右孩子是4,依此类推。我们可以很容易地将这个放入一个公式中:

如果i是我们的节点编号,那么:

左节点= 2 X i + 1

右节点= 2 X (i + 1)

现在,让我们使用 PHP 数组创建比赛日程的示例。如果按照我们的讨论进行排名,那么它将如下所示:

    $nodes = []; 

    $nodes[] = "Final"; 

    $nodes[] = "Semi Final 1"; 

    $nodes[] = "Semi Final 2"; 

    $nodes[] = "Quarter Final 1"; 

    $nodes[] = "Quarter Final 2"; 

    $nodes[] = "Quarter Final 3"; 

    $nodes[] = "Quarter Final 4"; 

基本上,我们将创建一个带有自动索引的数组,从 0 开始。这个数组将被用作二叉树的表示。现在,我们将修改我们的BinaryTree类,使用这个数组而不是我们的节点类,以及左右子节点以及遍历方法。现在,我们将基于节点编号而不是实际节点引用进行遍历:

class BinaryTree { 

    public $nodes = []; 

    public function __construct(Array $nodes) { 

      $this->nodes = $nodes; 

    } 

    public function traverse(int $num = 0, int $level = 0) { 

      if (isset($this->nodes[$num])) { 

          echo str_repeat("-", $level); 

          echo $this->nodes[$num] . "\n"; 

          $this->traverse(2 * $num + 1, $level+1); 

          $this->traverse(2 * ($num + 1), $level+1); 

      } 

    } 

} 

从前面的实现中可以看出,遍历部分使用节点位置而不是引用。这个节点位置就是数组索引。因此,我们可以直接访问数组索引并检查它是否为空。如果不为空,我们可以继续使用递归的方式深入。如果我们想使用数组创建二叉树并打印数组值,我们必须编写以下代码:

$tree = new BinaryTree($nodes); 

$tree->traverse(0); 

如果我们在命令行中运行此代码,将会看到以下输出:

Final

-Semi Final 1

--Quarter Final 1

--Quarter Final 2

-Semi Final 2

--Quarter Final 3

--Quarter Final 4

我们可以使用一个简单的while循环来遍历数组并访问每个节点,而不是递归进行。在我们所有的递归示例中,我们会发现如果以迭代的方式使用它们,有些会更有效率。我们也可以直接使用它们,而不是为二叉树创建一个类。

理解二叉搜索树

BST 是一种二叉树,它是按照树始终排序的方式构建的。这意味着左孩子节点的值小于或等于父节点的值,右孩子节点的值大于父节点的值。因此,每当我们需要搜索一个值时,要么搜索左边,要么搜索右边。由于它是排序的,我们只需要搜索树的一部分,而不是两部分,这种递归持续进行。由于它的分割性质,搜索变得非常快,我们可以实现对搜索的对数复杂度。例如,如果我们有n个节点,我们将搜索前半部分或后半部分的节点。一旦我们在前半部分或后半部分,我们可以再次将其分成两半,这意味着我们的一半现在变成了四分之一,如此循环直到达到最终节点。由于我们不是移动到每个节点进行搜索,因此操作不会花费O(n)的复杂度。在下一章中,我们将对二分搜索的复杂性进行分析,并看到为什么二叉搜索树的搜索复杂度是O(log n)。与二叉树不同,我们不能在不重建 BST 属性的情况下向树中添加任何节点或删除任何节点。

如果节点X有两个孩子,则节点X的后继是属于树的最小值,大于X的值。换句话说,后继是右子树的最小值。另一方面,前驱是左子树的最大值。现在,我们将更多关注 BST 的不同操作以及执行这些操作时需要考虑的步骤。

以下是 BST 的操作。

插入一个新节点

当我们在二叉搜索树中插入一个新节点时,我们必须考虑以下步骤:

  1. 创建一个新节点作为叶子节点(没有左孩子或右孩子)。

  2. 从根节点开始,并将其设置为当前节点。

  3. 如果节点为空,则将新节点作为根。

  4. 检查新值是小于当前节点还是大于当前节点。

  5. 如果小于,则转到左侧并将左侧设置为当前节点。

  6. 如果大于,则转到右侧并将右侧设置为当前节点。

  7. 继续步骤 3,直到所有节点都被访问并设置了新节点。

搜索一个节点

当我们在二叉搜索树中搜索一个新节点时,我们必须考虑以下步骤:

  1. 从根节点开始,并将其设置为当前节点。

  2. 如果当前节点为空,则返回 false。

  3. 如果当前节点的值是搜索值,则返回 true。

  4. 检查搜索值是小于当前节点还是大于当前节点。

  5. 如果小于,则转到左侧并将左侧设置为当前节点。

  6. 如果大于,则转到右侧并将右侧设置为当前节点。

  7. 继续步骤 3,直到所有节点都被访问。

查找最小值

由于二叉搜索树以排序方式存储数据,我们始终可以在左节点中找到较小的数据,在右节点中找到较大的数据。因此,查找最小值将需要我们从根节点开始访问所有左节点,直到找到最左边的节点及其值。以下是查找最小值的步骤:

  1. 从根节点开始,并将其设置为当前节点。

  2. 如果当前节点为空,则返回 false。

  3. 转到左侧并将左侧设置为当前节点。

  4. 如果当前节点没有左节点,则转到步骤 5;否则,继续步骤 4

  5. 继续步骤 3,直到所有左节点都被访问。

  6. 返回当前节点。

查找最大值

以下是查找最大值的步骤:

  1. 从根节点开始,并将其设置为当前节点。

  2. 如果当前节点为空,则返回 false。

  3. 转到右侧并将右侧设置为当前节点。

  4. 如果当前节点没有右节点,则转到步骤 5;否则,继续步骤 4

  5. 继续步骤 3,直到所有右节点都被访问。

  6. 返回当前节点。

删除节点

当我们删除一个节点时,我们必须考虑节点可以是内部节点或叶子节点。如果它是叶子节点,则它没有子节点。但是,如果节点是内部节点,则它可以有一个或两个子节点。在这种情况下,我们需要采取额外的步骤来确保在删除后树的构造是正确的。这就是为什么从 BST 中删除节点始终是一项具有挑战性的工作,与其他操作相比。以下是删除节点时要考虑的事项:

  1. 如果节点没有子节点,则使节点为 NULL。

  2. 如果节点只有一个子节点,则使子节点取代节点的位置。

  3. 如果节点有两个子节点,则找到节点的后继并将其替换为当前节点的位置。删除后继节点。

我们已经讨论了二叉搜索树的大部分可能操作。现在,我们将逐步实现二叉搜索树,从插入、搜索、查找最小和最大值开始,最后是删除操作。让我们开始实现吧。

构建二叉搜索树

正如我们所知,一个节点可以有两个子节点,并且本身可以以递归方式表示树。我们将定义我们的节点类更加功能强大,并具有所有必需的功能来查找最大值、最小值、前任和后继。稍后,我们还将为节点添加删除功能。让我们检查 BST 的节点类的以下代码:

class Node { 

    public $data; 

    public $left; 

    public $right; 

    public function __construct(int $data = NULL) { 

       $this->data = $data; 

       $this->left = NULL; 

       $this->right = NULL; 

    } 

    public function min() { 

       $node = $this; 

       while($node->left) { 

         $node = $node->left; 

       } 

         return $node; 

    } 

    public function max() { 

         $node = $this; 

         while($node->right) { 

            $node = $node->right; 

         } 

         return $node; 

    } 

    public function successor() { 

         $node = $this; 

         if($node->right) 

               return $node->right->min(); 

         else 

               return NULL; 

    } 

    public function predecessor() { 

         $node = $this; 

         if($node->left) 

               return $node->left->max(); 

         else 

               return NULL;

    }

}

节点类看起来很简单,并且与我们在前一节中定义的步骤相匹配。每个新节点都是叶子节点,因此在创建时没有左节点或右节点。由于我们知道可以在节点的左侧找到较小的值以找到最小值,因此我们正在到达最左边的节点和最右边的节点以获取最大值。对于后继,我们正在从给定节点的右子树中找到节点的最小值,并且对于前任部分,我们正在从左子树中找到节点的最大值。

现在,我们需要一个 BST 结构来在树中添加新节点,以便我们可以遵循插入原则:

class BST { 

    public $root = NULL; 

    public function __construct(int $data) { 

         $this->root = new Node($data); 

    } 

    public function isEmpty(): bool { 

         return $this->root === NULL; 

    } 

    public function insert(int $data) { 

         if($this->isEmpty()) { 

               $node = new Node($data); 

               $this->root = $node; 

               return $node; 

         }  

    $node = $this->root; 

    while($node) { 

      if($data > $node->data) { 

          if($node->right) { 

            $node = $node->right; 

          } else { 

            $node->right = new Node($data); 

            $node = $node->right; 

            break; 

          } 

      } elseif($data < $node->data) { 

          if($node->left) { 

            $node = $node->left; 

          } else { 

            $node->left = new Node($data); 

            $node = $node->left; 

            break; 

          } 

      } else { 

            break; 

      } 

    } 

    return $node; 

    } 

    public function traverse(Node $node) { 

      if ($node) { 

          if ($node->left) 

            $this->traverse($node->left); 

          echo $node->data . "\n"; 

          if ($node->right)

            $this->traverse($node->right);

      }

    }

}

如果我们看前面的代码,我们只有一个 BST 类的属性,它将标记根节点。在构建 BST 对象时,我们传递一个单个值,该值将用作树的根。isEmpty方法检查树是否为空。insert方法允许我们在树中添加新节点。逻辑检查值是否大于或小于根节点,并遵循 BST 的原则将新节点插入正确的位置。如果值已经插入,我们将忽略它并避免添加到树中。

我们还有一个traverse方法来遍历节点并以有序格式查看数据(首先左侧,然后是节点,然后是右侧节点的值)。它有一个指定的名称,我们将在下一节中探讨。现在,让我们准备一个样本代码来使用 BST 类,并添加一些数字,然后检查这些数字是否以正确的方式存储。如果 BST 有效,则遍历将显示一个有序的数字列表,无论我们如何插入它们:

$tree = new BST(10); 

$tree->insert(12); 

$tree->insert(6); 

$tree->insert(3); 

$tree->insert(8); 

$tree->insert(15); 

$tree->insert(13); 

$tree->insert(36); 

$tree->traverse($tree->root);

如果我们看一下前面的代码,10是我们的根节点,然后我们随机添加了新节点。最后,我们调用了遍历方法来显示节点以及它们在二叉搜索树中的存储方式。以下是前面代码的输出:

3

6

8

10

12

13

15

36

实际树在视觉上看起来是这样的,与 BST 实现所期望的完全一样:

现在,我们将在我们的 BST 类中添加搜索部分。我们想要找出值是否存在于树中。如果值不在我们的 BST 中,它将返回 false,否则返回节点。这是简单的搜索功能:

public function search(int $data) { 

  if ($this->isEmpty()) { 

      return FALSE; 

  } 

  $node = $this->root; 

  while ($node) { 

      if ($data > $node->data) { 

        $node = $node->right; 

      } elseif ($data < $node->data) { 

        $node = $node->left; 

      } else { 

        break; 

      } 

  } 

  return $node; 

}

在前面的代码中,我们可以看到我们正在从节点中搜索树中的值,并迭代地跟随树的左侧或右侧。如果没有找到具有该值的节点,则返回节点的叶子节点,即NULL。我们可以这样测试代码:

echo $tree->search(14) ? "Found" : "Not Found";

echo "\n";

echo $tree->search(36) ? "Found" : "Not Found";

这将产生以下输出。由于14不在我们的列表中,它将显示Not Found,而对于36,它将显示Found

Not Found

Found

现在,我们将进入编码中最复杂的部分,即删除节点。我们需要实现节点可以有零个、一个或两个子节点的每种情况。以下图像显示了我们需要满足的删除节点的三个条件,并确保在操作后二叉搜索树仍然是二叉搜索树。当处理具有两个子节点的节点时,我们需要小心。因为我们需要在节点之间来回移动,我们需要知道当前节点的父节点是哪个节点。因此,我们需要添加一个额外的属性来跟踪任何节点的父节点:

这是我们要添加到Node类的代码更改:

    public $data;

    public $left;

    public $right;

    public $parent;

    public function __construct(int $data = NULL, Node $parent = NULL)   

     {

      $this->data = $data; 

      $this->parent = $parent; 

      $this->left = NULL; 

      $this->right = NULL; 

     }

此代码块现在还将新创建的节点与其直接父节点建立父子关系。我们还希望将我们的删除功能与单个节点关联起来,以便我们可以找到一个节点,然后只需使用delete方法将其删除。以下是删除功能的代码:

public function delete() { 

    $node = $this; 

    if (!$node->left && !$node->right) { 

        if ($node->parent->left === $node) { 

          $node->parent->left = NULL; 

        } else { 

          $node->parent->right = NULL; 

        } 

    } elseif ($node->left && $node->right) { 

        $successor = $node->successor(); 

        $node->data = $successor->data; 

        $successor->delete(); 

    } elseif ($node->left) { 

        if ($node->parent->left === $node) { 

          $node->parent->left = $node->left; 

          $node->left->parent = $node->parent->left; 

        } else { 

          $node->parent->right = $node->left; 

          $node->left->parent = $node->parent->right; 

        } 

        $node->left = NULL; 

    } elseif ($node->right) { 

        if ($node->parent->left === $node) { 

          $node->parent->left = $node->right; 

          $node->right->parent = $node->parent->left; 

        } else { 

          $node->parent->right = $node->right; 

          $node->right->parent = $node->parent->right; 

        } 

        $node->right = NULL; 

    }

}

第一个条件检查节点是否是叶子节点。如果节点是叶子节点,那么我们只需使父节点删除子节点的引用(左侧或右侧)。这样,节点将与树断开连接,满足了我们零个子节点的第一个条件。

接下来的条件实际上检查了我们的第三个条件,即节点有两个子节点的情况。在这种情况下,我们获取节点的后继节点,将后继节点的值分配给节点本身,并删除后继节点。这只是从后继节点复制数据。

接下来的两个条件检查节点是否有单个子节点,就像我们之前的Case 2图表所示。由于节点只有一个子节点,它可以是左子节点或右子节点。因此,条件检查单个子节点是否是节点的左子节点。如果是,我们需要根据节点本身与其父节点的位置,将左子节点指向节点的父节点左侧或右侧引用。右子节点也适用相同的规则。在这里,右子节点引用设置为其父节点的左侧或右侧子节点,而不是基于节点位置的引用。

由于我们已经更新了我们的节点类,我们需要对我们的 BST 类进行一些更改,以便插入和删除节点。插入代码将如下所示:

function insert(int $data)

 {

    if ($this->isEmpty()) {

          $node = new Node($data);

          $this->root = $node;

          return $node;

    }

    $node = $this->root;

    while ($node) {

          if ($data > $node->data) {

                if ($node->right) {

                      $node = $node->right;

                }

                else {

                      $node->right = new Node($data, $node);

                      $node = $node->right;

                      break;

                }

          }

          elseif ($data < $node->data) {

                if ($node->left) {

                      $node = $node->left;

                }

                else {

                      $node->left = new Node($data, $node);

                      $node = $node->left;

                      break;

                }

          }

          else {

                break;

    }

 }

    return $node;

 }

代码看起来与我们之前使用的代码类似,只有一个小改变。现在,当我们创建一个新节点时,我们会发送当前��点的引用。这个当前节点将被用作新节点的父节点。new Node($data, $node)代码实际上就是这样做的。

对于删除一个节点,我们可以先进行搜索,然后使用节点类中的delete方法删除搜索到的节点。因此,remove函数本身将会非常小,就像这里的代码一样:

public function remove(int $data) {

    $node = $this->search($data);

    if ($node) $node->delete();

 }

如代码所示,我们首先搜索数据。如果节点存在,我们将使用delete方法将其移除。现在,让我们运行我们之前的例子,使用remove调用,看看它是否有效:

   $tree->remove(15);

   $tree->traverse($tree->root);

我们只是从我们的树中移除15,然后从根节点遍历树。我们现在将看到以下输出:

3

6

8

10

12

13

36

我们可以看到 15 不再是我们 BST 的一部分了。这样,我们可以移除任何节点,如果我们使用相同的方法进行遍历,我们将会看到一个排序的列表。如果我们看我们之前的输出,我们可以看到输出是按升序显示的。这其中有一个原因,我们将在下一个主题-不同的树遍历方式中探讨。

您可以在btv.melezinek.cz/binary-search-tree.html找到一个用于可视化二叉搜索树操作的好工具。这对于学习者来说是一个很好的开始,可以通过可视化的方式理解不同的操作。

树的遍历

树的遍历是指我们访问给定树中的每个节点的方式。根据我们进行遍历的方式,我们可以遵循三种不同的遍历方式。这些遍历在许多不同的方面都非常重要。表达式求值的波兰表示法转换就是使用树遍历的最流行的例子之一。

中序

中序树遍历首先访问左节点,然后是根节点,然后是右节点。对于每个节点,这将递归地继续进行。左节点存储的值比根节点值小,右节点存储的值比根节点大。因此,当我们应用中序遍历时,我们得到一个排序的列表。这就是为什么到目前为止,我们的二叉树遍历显示的是一个排序的数字列表。这种遍历部分实际上就是中序树遍历的例子。中序树遍历遵循以下原则:

  1. 通过递归调用中序函数来遍历左子树。

  2. 显示根(或当前节点)的数据部分。

  3. 递归调用中序函数来遍历右子树。

前面的树将显示 A、B、C、D、E、F、G、H 和 I 作为输出,因为它是按照中序遍历进行遍历的。

前序

在前序遍历中,首先访问根节点,然后是左节点,然后是右节点。前序遍历的原则如下:

  1. 显示根(或当前节点)的数据部分。

  2. 通过递归调用前序函数来遍历左子树。

  3. 通过递归调用前序函数来遍历右子树。

前面的树将以 F、B、A、D、C、E、G、I 和 H 作为输出,因为它是按照前序遍历进行遍历的。

后序

在后序遍历中,最后访问根节点。首先访问左节点,然后是右节点。后序遍历的原则如下:

  1. 通过递归调用后序函数来遍历左子树。

  2. 通过递归调用后序函数来遍历右子树。

  3. 显示根(或当前节点)的数据部分。

前序遍历将以 A、C、E、D、B、H、I、G 和 F 作为输出,因为它是按照后序遍历进行遍历的。

现在,让我们在我们的 BST 类中实现遍历逻辑:

public function traverse(Node $node, string $type="in-order") { 

switch($type) {        

    case "in-order": 

      $this->inOrder($node); 

    break; 

    case "pre-order": 

      $this->preOrder($node); 

    break; 

    case "post-order": 

      $this->postOrder($node); 

    break;       

}      

} 

public function preOrder(Node $node) { 

  if ($node) { 

      echo $node->data . " "; 

      if ($node->left) $this->traverse($node->left); 

      if ($node->right) $this->traverse($node->right); 

  }      

} 

public function inOrder(Node $node) { 

  if ($node) {           

      if ($node->left) $this->traverse($node->left); 

      echo $node->data . " "; 

      if ($node->right) $this->traverse($node->right); 

  } 

} 

public function postOrder(Node $node) { 

  if ($node) {           

      if ($node->left) $this->traverse($node->left); 

      if ($node->right) $this->traverse($node->right); 

      echo $node->data . " "; 

  } 

} 

现在,如果我们对我们之前的二叉搜索树运行三种不同的遍历方法,这里是运行遍历部分的代码:

   $tree->traverse($tree->root, 'pre-order');

   echo "\n";

   $tree->traverse($tree->root, 'in-order');

   echo "\n";

   $tree->traverse($tree->root, 'post-order');

这将在我们的命令行中产生以下输出:

10 3 6 8 12 13 15 36

3 6 8 10 12 13 15 36

3 6 8 12 13 15 36 10

不同树数据结构的复杂性

到目前为止,我们已经看到了不同的树类型及其操作。不可能逐一介绍每种树类型及其不同的操作,因为这将超出本书的范围。我们希望对其他树结构及其操作复杂性有一个最基本的了解。下面是一个包含不同类型树的平均和最坏情况下操作复杂度以及空间的图表。根据我们的需求,我们可能需要选择不同的树结构:

总结

在本章中,我们详细讨论了非线性数据结构。您了解到树是分层数据结构,有不同的树类型、操作和复杂性。我们还看到了如何定义二叉搜索树。这对于实现不同的搜索技术和数据存储将非常有用。在下一章中,我们将把重点从数据结构转移到算法上。我们将专注于第一类算法--排序算法。

第七章:使用排序算法

排序是计算机编程中最常用的算法之一。即使在日常生活中,如果事物没有排序,我们也会遇到困难。排序可以为集合中的项目提供更快的搜索或排序方式。排序可以以许多不同的方式进行,例如按升序或降序进行。排序也可以基于数据类型进行。例如,对名称集合进行排序将需要按字典顺序排序而不是按数字排序。由于排序对其他数据结构及其效率起着重要作用,因此有许多不同的排序算法可供选择。在本章中,我们将探讨一些最流行的排序算法,以及它们的复杂性和用途。

理解排序及其类型

排序意味着数据的排序顺序。通常,我们的数据是未排序的,这意味着我们需要一种排序方式。通常,排序是通过将不同的元素进行比较并得出排名来完成的。在大多数情况下,如果没有比较,我们无法决定排序部分。比较之后,我们还需要交换元素,以便重新排序它们。一个好的排序算法具有最小数量的比较和交换的特点。还有一种非比较排序,它不需要比较就可以对项目列表进行排序。我们也将在本章中探讨这些算法。

根据数据集的类型、方向、计算复杂性、内存使用、空间使用等不同标准,排序可以分为不同类型。以下是本章中我们将探讨的一些排序算法:

  • 冒泡排序

  • 插入排序

  • 选择排序

  • 快速排序

  • 归并排序

  • 桶排序

我们将把讨论限制在上面的列表中,因为它们是最常用的排序算法,可以根据不同的标准进行分组和分类,比如简单排序、高效排序、分布排序等等。我们现在将探讨每种排序功能、它们的实现以及复杂性分析,以及它们的优缺点。让我们从最常用的排序算法——冒泡排序开始。

理解冒泡排序

冒泡排序是编程世界中最常用的排序算法。大多数程序员都是从这个算法开始学习排序的。它是一种基于比较的排序算法,通常被认为是最低效的排序算法之一。它需要最大数量的比较,平均情况和最坏情况的复杂性是相同的。

在冒泡排序中,列表的每个项目都与其余项目进行比较,并在需要时进行交换。这对列表中的每个项目都会继续进行。我们可以按升序或降序进行排序。以下是冒泡排序的伪算法:

procedure bubbleSort( A : list of sortable items ) 

   n = length(A) 

   for i = 0 to n inclusive do  

     for j = 0 to n-1 inclusive do 

       if A[j] > A[j+1] then 

         swap( A[j], A[j+1] ) 

       end if 

     end for 

   end for 

end procedure

从上面的伪代码中可以看出,我们运行一个循环来确保迭代列表的每个项目。内部循环确保一旦我们指向一个项目,我们就会将该项目与列表中的其他项目进行比较。根据我们的偏好,我们可以交换这两个项目。以下图片显示了对列表中的一个项目进行排序的单次迭代。假设我们的列表包含以下项目:20,45,93,67,10,97,52,88,33,92。对于第一次通过(迭代)来排序第一个项目,将采取以下步骤:

(图片)

如果我们检查上面的图片,我们可以看到我们正在比较两个数字,然后决定是否要交换/交换项目。背景颜色的项目显示了我们正在比较的两个项目。正如我们所看到的,外部循环的第一次迭代导致将最顶部的项目存储在列表中的最顶部位置。这将持续进行,直到我们迭代列表中的每个项目。

现在让我们使用 PHP 来实现冒泡排序算法。

使用 PHP 实现冒泡排序

由于我们假设未排序的数字将在一个列表中,我们可以使用 PHP 数组来表示未排序数字的列表。由于数组既有索引又有值,我们可以利用数组来轻松地根据位置迭代每个项目,并在适用的情况下进行交换。根据我们的伪代码,代码将如下所示:

function bubbleSort(array $arr): array { 

    $len = count($arr); 

    for ($i = 0; $i < $len; $i++) { 

      for ($j = 0; $j < $len - 1; $j++) { 

          if ($arr[$j] > $arr[$j + 1]) { 

            $tmp = $arr[$j + 1]; 

            $arr[$j + 1] = $arr[$j]; 

            $arr[$j] = $tmp; 

          } 

      } 

    }     

    return $arr; 

}

正如我们所看到的,我们使用两个for循环来迭代每个项目并与其余项目进行比较。交换是在以下行中完成的:

$tmp = $arr[$j + 1];

$arr[$j + 1] = $arr[$j];

$arr[$j] = $tmp;

首先,我们将第二个值分配给名为$tmp的临时变量。然后,我们将第一个值分配给第二个值,并重新分配临时值给第一个值。这被称为使用第三个或临时变量交换两个变量。

只有在第一个值大于第二个值时才进行交换。否则,我们就忽略。图像右侧的注释显示了是否发生了交换。如果我们想按降序(较大的数字优先)对其进行排序,那么我们只需修改if条件如下:

if ($arr[$j] < $arr[$j + 1]) {

}

现在,让我们按照以下方式运行代码:

$arr = [20, 45, 93, 67, 10, 97, 52, 88, 33, 92]; 

$sortedArray = bubbleSort($arr); 

echo implode(",", $sortedArray); 

这将产生以下输出:

10,20,33,45,52,67,88,92,93,97

因此,我们可以看到数组使用冒泡排序算法进行了排序。现在,让我们讨论算法的复杂性。

冒泡排序的复杂性

对于第一次通过,在最坏的情况下,我们必须进行n-1次比较和交换。对于第n-1次通过,在最坏的情况下,我们只需要进行一次比较和交换。因此,如果我们一步一步地写出来,我们会看到:

复杂度= n - 1 + n - 2 + .......... + 2 + 1 = n * ( n - 1)/2 = O(n² )

因此,冒泡排序的复杂度是O(n² )。然而,分配临时变量、交换、遍历内部循环等都需要一些常数时间。我们可以忽略它们,因为它们是常数。

这是冒泡排序的时间复杂度表,包括最佳情况、平均情况和最坏情况:

最佳时间复杂度 Ω(n)
最坏时间复杂度为O(n² )
平均时间复杂度 Θ(n² )
空间复杂度(最坏情况) O(1)

尽管冒泡排序的时间复杂度为O(n² ),我们仍然可以应用一些改进来减少比较和交换的次数。现在让我们探讨这些选项。最佳时间为Ω(n),因为我们至少需要一个内部循环来运行以找出数组已经排序。

改进冒泡排序算法

冒泡排序最重要的一个方面是,对于外部循环中的每次迭代,至少会有一次交换。如果没有交换,那么列表已经排序。我们可以利用这一改进在我们的伪代码中重新定义它:

procedure bubbleSort( A : list of sortable items ) 

   n = length(A) 

   for i = 1 to n inclusive do  

     swapped = false 

     for j = 1 to n-1 inclusive do 

       if A[j] > A[j+1] then 

         swap( A[j], A[j+1] ) 

         swapped = true 

       end if 

     end for 

     if swapped is false 

        break 

     end if 

   end for 

end procedure

正如我们现在所看到的,我们现在为每次迭代设置了一个false标志,并且我们期望,在内部迭代中,标志将被设置为true。如果在内部循环完成后标志仍然为 false,则我们可以中断循环,以便标记列表为已排序。这是改进算法的实现:

function bubbleSort(array $arr): array { 

    $len = count($arr); 

    for ($i = 0; $i < $len; $i++) { 

      $swapped = FALSE; 

      for ($j = 0; $j < $len - 1; $j++) { 

          if ($arr[$j] > $arr[$j + 1]) { 

            $tmp = $arr[$j + 1]; 

            $arr[$j + 1] = $arr[$j]; 

            $arr[$j] = $tmp; 

            $swapped = TRUE; 

          } 

      } 

         if(! $swapped) break; 

    }     

    return $arr; 

} 

另一个观察是,在第一次迭代中,顶部项目被放置在数组的右侧。在第二次循环中,第二个顶部项目将位于数组的右侧第二个位置。如果我们可以想象每次迭代后,第 i 个单元格已经存储了已排序的项目,那么就没有必要访问该索引并进行比较。因此,我们可以减少外部迭代次数和内部迭代次数,并大幅减少比较。这是我们提出的第二个改进的伪代码:

procedure bubbleSort( A : list of sortable items ) 

   n = length(A) 

   for i = 1 to n inclusive do  

     swapped = false 

     for j = 1 to n-i-1 inclusive do 

       if A[j] > A[j+1] then 

         swap( A[j], A[j+1] ) 

         swapped = true 

       end if 

     end for 

     if swapped is false 

        break 

     end if 

   end for 

end procedure 

现在,让我们用 PHP 实现最终改进的版本:

function bubbleSort(array $arr): array {

    $len = count($arr); 

    for ($i = 0; $i < $len; $i++) { 

      $swapped = FALSE; 

      for ($j = 0; $j < $len - $i - 1; $j++) { 

          if ($arr[$j] > $arr[$j + 1]) { 

            $tmp = $arr[$j + 1]; 

            $arr[$j + 1] = $arr[$j]; 

            $arr[$j] = $tmp; 

            $swapped = TRUE; 

          } 

      } 

      if(! $swapped) break; 

    }     

    return $arr; 

} 

如果我们看一下前面代码中的内部循环,唯一的区别是 $j < $len - $i - 1 ;其他部分与第一次改进相同。所以,基本上,对于我们的 20 , 45 , 93 , 67 , 10 , 97 , 52 , 88 , 33 , 92 列表,我们可以很容易地说,在第一次迭代之后,顶部的数字 97 将不会被考虑进行第二次迭代比较。同样, 93 也将不会被考虑进行第三次迭代,就像下面的图片一样:

如果我们看前面的图片,立即冒出的问题是“92 已经排序了吗?我们需要再次比较所有数字并标记 92 已经在其位置上排序了吗?”是的,我们是对的。这是一个有效的问题。这意味着我们可以知道,在内部循环中我们上次交换的位置;之后,数组已经排序。因此,我们可以为下一个循环设置一个边界,直到那时,只比较我们设置的边界之前的部分。以下是此操作的伪代码:

procedure bubbleSort( A : list of sortable items )

   n = length(A)

   bound = n -1

   for i = 1 to n inclusive do

     swapped = false

     newbound = 0

     for j = 1 to bound inclusive do

       if A[j] > A[j+1] then

         swap( A[j], A[j+1] )

            swapped = true

            newbound = j

       end if

     end for

     bound = newbound

     if swapped is false

        break

     end if

   end for

end procedure

在这里,我们在每次内部循环完成后设置边界,并确保我们不会进行不必要的迭代。以下是使用前面伪代码的实际 PHP 代码:

function bubbleSort(array $arr): array {

    $len = count($arr);

    $count = 0;

    $bound = $len-1;

    for ($i = 0; $i < $len; $i++) {

     $swapped = FALSE;

     $newBound = 0;

      for ($j = 0; $j < $bound; $j++) {

          $count++;

          if ($arr[$j] > $arr[$j + 1]) {

            $tmp = $arr[$j + 1];

            $arr[$j + 1] = $arr[$j];

            $arr[$j] = $tmp;

            $swapped = TRUE;

            $newBound = $j;

          }

      }

     $bound = $newBound;

     if(! $swapped) break;

    }

    echo $count."\n";

    return $arr;

}

我们已经看到了冒泡排序实现的不同变体,但输出始终相同:10 , 20 , 33 , 45 , 52 , 67 , 88 , 92 , 93 , 97 。如果是这种情况,那么我们如何确定我们的改进实际上对算法产生了一些影响呢?以下是我们的初始列表 20, 45, 93, 67, 10, 97, 52, 88, 33, 92 的所有四种实现的比较次数的一些统计数据:

解决方案 比较次数
常规冒泡排序 90
第一次改进后 63
第二次改进后 42
第三次改进后 38

正如我们所看到的,我们通过改进将比较次数从 90 减少到 38 。因此,我们可以肯定地通过一些改进来提高算法,以减少所需的比较次数。

理解选择排序

选择排序是另一种基于比较的排序算法,看起来类似于冒泡排序。最大的区别在于它进行的交换次数比冒泡排序少。在选择排序中,我们首先找到数组的最小/最大项,并将其放在第一个位置。如果我们按降序排序,那么我们将从数组中取得最大值。对于升序排序,我们将取得最小值。在第二次迭代中,我们将找到数组的第二大或第二小值,并将其放在第二个位置。这样一直进行,直到我们将每个数字放在正确排序的位置上。这就是选择排序。选择排序的伪代码如下所示:

procedure selectionSort( A : list of sortable items )

   n = length(A)

   for i = 1 to n inclusive do

     min = i

     for j = i+1 to n inclusive do

       if A[j] < A[min] then

         min = j

       end if

     end for

     if min != i

        swap(a[i],a[min])

     end if

   end for

end procedure

如果我们看前面的算法,我们可以看到,在外部循环的第一次迭代之后,第一个最小项被存储在位置一。在第一次迭代中,我们选择了第一项,然后从剩余项(从 2 到 n )中找到最小值。我们假设第一项是最小值。如果我们找到另一个最小值,我们会标记它的位置,直到我们扫描了剩余列表并找到了一个新的最小值。如果没有找到最小值,那么我们的假设是正确的,那确实是最小值。这里是一个图示,说明了我们的 20 , 45 , 93 , 67 , 10 , 97 , 52 , 88 , 33 , 92 数组在选择排序的前两个步骤中的情况:

如前面的图像所示,我们从列表中的第一个项目20开始。然后,我们从数组的其余部分找到最小值10。在第一次迭代结束时,我们只交换了两个位置的值(由箭头标记)。因此,在第一次迭代结束时,我们将数组的最小值存储在第一个位置。然后,我们指向下一个项目45,并开始从其位置右侧找到与45相比的下一个最小项目。我们从剩余项目中找到20(如两个箭头所示)。在第二次迭代结束时,我们只是将第二个位置的数字与列表剩余部分中新找到的最小数字进行交换。这将持续到最后一个元素,并且在过程结束时,我们将得到一个排序好的数组列表。现在让我们将伪代码转换为 PHP 代码。

实现选择排序

我们将采用与冒泡排序相同的方法,其中我们的实现将以数组作为参数并返回一个排序好的数组。以下是 PHP 中的实现:

function selectionSort(array $arr): array {

    $len = count($arr);

    for ($i = 0; $i < $len; $i++) {

      $min = $i;

      for ($j = $i+1; $j < $len; $j++) {

          if ($arr[$j] < $arr[$min]) {

            $min = $j;

          }

      }

      if ($min != $i) {

          $tmp = $arr[$i];

          $arr[$i] = $arr[$min];

          $arr[$min] = $tmp;

      }

    }

    return $arr;

}

正如我们所看到的,这是按升序对数组进行排序的最简单方法。如果要按降序排序,我们只需要将比较$arr[$j] < $arr[$min]更改为$arr[$j] > $arr[$min],并将$min替换为$max

选择排序的复杂度

选择排序看起来也与冒泡排序相似,并且有两个 0 到nfor循环。冒泡排序和选择排序的基本区别在于,选择排序最多进行n-1次交换,而冒泡排序在最坏的情况下可能进行nn*次交换。然而,在选择排序中,最佳情况、最坏情况和平均情况的复杂度相似。以下是选择排序的复杂度图表:

最佳时间复杂度 Ω(n²)
最坏时间复杂度 O(n²)
平均时间复杂度 Θ(n²)
空间复杂度(最坏情况) O(1)

理解插入排序

到目前为止,我们已经看到了两种基于比较的排序算法。现在,我们将探讨另一种排序算法,与前两种相比效率要高一些。我们说的是插入排序。与我们刚刚看到的另外两种排序算法相比,它的实现最简单。如果项目数量较小,插入排序比冒泡排序和选择排序效果更好。如果数据集很大,那么它就会变得效率低下,就像冒泡排序一样。由于插入排序的交换几乎是线性的,建议您使用插入排序而不是冒泡排序和选择排序。

顾名思义,插入排序是根据将数字插入到左侧正确位置的原则工作的。它从数组的第二个项目开始,并检查左侧的项目是否小于当前值。如果是,它会移动项目并将较小的项目存储在其正确的位置。然后,它移动到下一个项目,并且相同的原则一直持续到整个数组排序完成。插入排序的伪代码如下:

procedure insertionSort( A : list of sortable items )

   n = length(A)

   for i = 1 to n inclusive do

     key = A[i]

     j = i - 1

     while j >= 0 and A[j] > key   do

       A[j+1] = A[j]

       j--

     end while

     A[j+1] = key

   end for

end procedure

如果我们考虑我们之前用于冒泡排序和选择排序的数字列表,那么我们必须进行插入排序。

我们数组的元素是:20459367109752883392

让我们从第二个项目开始,即45。现在,我们将从45左边的第一个项目开始,并转到数组的开头,看看左边是否有大于45的值。由于只有20,因此不需要插入,因为到目前为止的项目已经排序好了(2045)。现在,我们将指针移动到93,并且再次开始,从数组的左边开始比较并搜索是否有更大的值。由于45不大于93,因此停在那里,就像之前一样,我们得出前两个项目已经排序好了的结论。现在,我们有前三个项目(204593)排序好了。接下来是67,我们再次从左边的数字开始比较。左边的第一个数字是93,比较大,因此必须移动一个位置。我们将93移动到67的位置。然后,我们移动到左边的下一个项目,即4545小于67,不需要进一步比较。现在,我们将67插入到93的位置,93将移动到67的位置。这将一直持续到整个数组排序好。这张图片说明了使用插入排序的完整排序过程的每一步:

实现插入排序

我们将以与其他两种排序类似的方式实现插入排序,但有细微差别。这次,我们将数组作为引用传递。通过这样做,我们将不需要从函数中返回任何值。如果需要的话,我们也可以按值传递参数并在函数结束时返回数组。以下是此代码:

function insertionSort(array &$arr) { 

    $len = count($arr); 

    for ($i = 1; $i < $len; $i++) { 

      $key = $arr[$i]; 

      $j = $i - 1; 

      while($j >= 0 && $arr[$j] > $key) { 

          $arr[$j+1] = $arr[$j]; 

          $j--; 

      }      

      $arr[$j+1] = $key; 

    }     

}

参数数组通过引用(&$arr)传递给函数。因此,原始数组而不是副本将直接被修改。现在,我们想要运行代码并检查输出。为此,我们必须运行以下代码:

$arr = [20, 45, 93, 67, 10, 97, 52, 88, 33, 92];

insertionSort($arr);

echo implode(",", $arr);

这将产生与前两种情况相同的输出。唯一的区别是我们不期望从函数中返回任何数组,并且不将其存储到任何新变量中。

如果我们通过引用传递数组,那么我们就不需要返回数组。传递的数组将在函数内部被修改。我们可以选择如何实现排序。

插入排序的复杂性

插入排序的复杂性类似于冒泡排序。与冒泡排序的基本区别在于交换的次数比冒泡排序要少得多。这是插入排序的复杂性:

最佳时间复杂度 Ω(n)
最坏时间复杂度 O(n²)
平均时间复杂度 Θ(n²)
空间复杂度(最坏情况) O(1)

理解用于排序的分治技术

到目前为止,我们已经探讨了使用完整数字列表的排序选项。结果,我们每次都有一个大的数字列表进行比较。如果我们可以以某种方式使列表变小,这个问题就可以解决。分治法对我们非常有帮助。通过这种方法,我们将问题分解为两个或更多的子问题或集合,然后解决较小的问题,然后将所有这些子问题的结果组合起来得到最终结果。这就是所谓的分治法。

分治法可以让我们有效地解决排序问题,并减少算法的复杂性。最流行的两种排序算法是归并排序和快速排序,它们应用分治算法来对项目列表进行排序,因此被认为是最好的排序算法。现在,我们将在下一节中探讨这两种算法。

理解归并排序

正如我们已经知道的,归并排序应用分而治之的方法来解决排序问题,我们需要找出两个过程来解决这个问题。第一个是将问题集分解成足够小的问题,然后合并这些结果。我们将在这里应用递归方法来进行分而治之。以下图像显示了如何采取分而治之的方法。我们现在将考虑一个较小的数字列表2045936797528833来解释分而治之的部分:

根据前面的图像,我们现在可以开始准备我们的伪代码,它将有两部分 - 分割和征服。以下是实现这一点的伪代码

func mergesort ( A : sortable items 的数组):


     n = length(A)      

     if ( n == 1 ) return a 

     var l1 as array = a[0] ... a[n/2] 

     var l2 as array = a[n/2+1] ... a[n] 

     l1 = mergesort( l1 ) 

     l2 = mergesort( l2 ) 

     return merge( l1, l2 ) 

end func

func merge( a: array, b : array )

     c = array

     while ( a and b have elements )

          if ( a[0] > b[0] )

               add b[0] to the end of c

               remove b[0] from b

          else

               add a[0] to the end of c

               remove a[0] from a

     end while

     while ( a has elements )

          add a[0] to the end of c

          remove a[0] from a

     end while

     while ( b has elements )

          add b[0] to the end of c

          remove b[0] from b

     return c

     end while

end func

我们伪代码的第一部分显示了分割过程。我们将数组分割直到达到大小为 1 的程度。然后,我们开始使用合并函数合并结果。在合并函数中,我们有一个数组来存储合并的结果。因此,归并排序实际上比我们迄今为止看到的其他算法具有更多的空间复杂度。现在,让我们开始编码并使用 PHP 实现这个伪代码。

实现归并排序

我们首先写出分割部分,然后是合并或征服部分。PHP 有一些内置函数可以拆分数组。我们将使用array_slice函数来进行拆分。以下是执行此操作的代码:

function mergeSort(array $arr): array { 

    $len = count($arr); 

    $mid = (int) $len / 2; 

    if ($len == 1) 

         return $arr; 

    $left  = mergeSort(array_slice($arr, 0, $mid)); 

    $right = mergeSort(array_slice($arr, $mid)); 

    return merge($left, $right); 

}

从代码中可以看出,我们以递归的方式分割数组,直到数组大小变为 1。当数组大小为 1 时,我们开始向后合并,就像最后一个图像一样。以下是合并函数的代码,它将接受两个数组,并根据我们的伪代码将它们合并成一个:

function merge(array $left, array $right): array { 

    $combined = []; 

    $countLeft = count($left); 

    $countRight = count($right); 

    $leftIndex = $rightIndex = 0; 

    while ($leftIndex < $countLeft && $rightIndex < $countRight) { 

      if ($left[$leftIndex] > $right[$rightIndex]) { 

          $combined[] = $right[$rightIndex]; 

          $rightIndex++; 

      } else { 

          $combined[] = $left[$leftIndex]; 

          $leftIndex++; 

      } 

    } 

    while ($leftIndex < $countLeft) { 

      $combined[] = $left[$leftIndex]; 

      $leftIndex++; 

    } 

    while ($rightIndex < $countRight) { 

      $combined[] = $right[$rightIndex]; 

      $rightIndex++; 

    } 

    return $combined;

}

现在代码已经完成,因为我们已经合并了两个提供的数组,并将合并的结果返回给mergeSort函数。我们刚刚以递归的方式解决了问题。如果你运行以下代码,你将得到一个按升序排列的项目列表:

$arr = [20, 45, 93, 67, 10, 97, 52, 88, 33, 92];

$arr = mergeSort($arr);

echo implode(",", $arr);

现在,让我们探讨归并排序的复杂度。

归并排序的复杂度

由于归并排序遵循分而治之的方法,我们必须在这里解决两种复杂性。对于一个大小为 n 的数组,我们首先需要将数组分成两半,然后合并它们以获得一个大小为 n 的数组。这可以用T(n)来表示:

T(n)     = T(n/2) + T(n/2) + n    , for N>1 with T(1) = 0 

         = 2 T(n/2)+n 

T(n)/n   = 2 T(n/2)/n + 1              // divide both side by n 

         = T(n/2)/(n/2)  + 1                  

         = T(n/4)/(n/4)  + 1+ 1        // telescoping 

         = T(n/8)/(n/8)  + 1+ 1 + 1      // again telescoping 

         = ...... 

         = T(n/n)/(n/n)  + 1 + 1 + 1 + ....... + 1 

         = log (n)                     // since T(1) = 0      

So T(n)  = n log (n)                   // multiply both side with n 

因此,归并排序的复杂度是O(n log(n))。以下是归并排序的复杂度图表:

最佳时间复杂度 Ω(nlog(n))
最坏时间复杂度 O(nlog(n))
平均时间复杂度 Θ(nlog(n))
空间复杂度(最坏情况) O(n)

理解快速排序

快速排序是另一种应用分而治之方法的高效排序算法。虽然它不像归并排序那样均等地分割,但它创建动态分区来对数据进行排序。这就是快速排序的工作原理:

  1. 从数组中选择一个随机值,我们称之为枢轴。

  2. 重新排列数组,使小于枢轴的项目移到它的左边,大于或等于枢轴的项目移到它的右边。这就是分区。

  3. 递归调用步骤 1步骤 2来解决两个子数组(枢轴的左边和右边)的问题,直到所有项目都排序完成。

从数组中选择一个枢轴的方法有很多种。我们可以选择数组的最左边的项目或最右边的项目。在这两种情况下,如果数组已经排序,它将达到最坏情况的复杂度。选择一个好的枢轴可以提高算法的效率。有一些不同的分区方法。我们将解释Hoare Partition,它比其他分区方法进行了更少的交换。以下是我们的快速排序的伪算法。我们将进行原地排序,因此不需要额外的空间:

procedure Quicksort(A : array,p :int ,r: int)

    if (p < r)

       q = Partition(A,p,r)

       Quicksort(A,p,q)

       Quicksort(A,q+1,r)

    end if

end procedure

procedure Partition(A : array,p :int ,r: int)

    pivot = A[p]

    i = p-1

    j = r+1

    while (true)

           do

            i := i + 1

           while A[i] < pivot    

           do

             j := j - 1

           while A[j] > pivot

      if i < j then

          swap A[i] with A[j]

      else

          return j

      end if

   end while

end procedure

我们使用第一个项目作为枢轴元素。我们也可以选择最后一个项目或取中值来选择枢轴元素。现在让我们使用 PHP 来实现算法。

实现快速排序

如伪代码所示,我们将有两个函数来实现快速排序:一个函数用于执行快速排序本身,另一个用于分区。以下是执行快速排序的实现:

function quickSort(array &$arr, int $p, int $r) {

  if($p < $r) {

    $q = partition($arr, $p, $r);

    quickSort($arr, $p, $q);

    quickSort($arr, $q+1, $r);

  }

}

以下是执行分区的实现:

function partition(array &$arr, int $p, int $r){ 

  $pivot = $arr[$p]; 

  $i = $p-1; 

  $j = $r+1; 

  while(true) 

  { 

   do { 

    $i++; 

   } while($arr[$i] < $pivot && $arr[$i] != $pivot); 

   do { 

    $j--; 

   } while($arr[$j] > $pivot && $arr[$j] != $pivot); 

   if($i < $j) { 

    $temp = $arr[$i]; 

    $arr[$i] = $arr[$j]; 

    $arr[$j] = $temp; 

   } else { 

    return $j; 

      } 

  } 

}

 $arr = [20, 45, 93, 67, 10, 97, 52, 88, 33, 92]; 

quickSort($arr, 0, count($arr)-1); 

echo implode(",", $arr);

如果我们在分区中直观地说明枢轴和排序,我们可以看到以下图像。为简单起见,我们只显示了发生交换的步骤:

快速排序的复杂性

快速排序的最坏情况复杂度可能与冒泡排序的复杂度相似。实际上是由于枢轴的选择导致的。以下是快速排序的复杂性图表:

最佳时间复杂度 Ω(nlog(n))
最坏时间复杂度 O(n²)
平均时间复杂度 Θ(nlog(n))
空间复杂度(最坏情况) O(log(n))

理解桶排序

桶排序也被称为箱排序。桶排序是一种分布排序系统,其中数组元素被放置在不同的桶中。然后每个桶都单独排序,可以使用另一个排序算法,或者应用递归桶排序。使用 PHP 实现桶排序可能如下所示:

function bucketSort(array &$data) { 

    $n = count($data); 

    if ($n <= 0) 

         return;                          

    $min = min($data); 

    $max = max($data); 

    $bucket = []; 

    $bLen = $max - $min + 1; 

    $bucket = array_fill(0, $bLen, []); 

    for ($i = 0; $i < $n; $i++) { 

         array_push($bucket[$data[$i] - $min], $data[$i]); 

    } 

    $k = 0; 

    for ($i = 0; $i < $bLen; $i++) {

         $bCount = count($bucket[$i]);

      for ($j = 0; $j < $bCount; $j++) { 

          $data[$k] = $bucket[$i][$j];

          $k++;

      }

    }

} 

桶排序的时间复杂度比其他基于比较的排序算法要好。以下是桶排序的复杂性:

最佳时间复杂度 Ω(n+k)
最坏时间复杂度 O(n²)
平均时间复杂度 Θ(n+k)
空间复杂度(最坏情况) O(n)

使用 PHP 的内置排序函数

PHP 具有丰富的预定义函数库,其中还包括不同的排序函数。它有不同的函数来按值或按键/索引对数组中的项目进行排序。在进行排序时,我们还可以保持数组值与其相应键的关联。PHP 的另一个重要函数是用于对多维数组进行排序的内置函数。以下是这些函数的摘要:

函数名称 目的
sort() 这将按升序对数组进行排序。不保留值/键关联。
rsort() 按照逆序/降序对数组进行排序。不保留索引/键关联。
asort() 在保持索引关联的同时对数组进行排序。
arsort() 以逆序排序数组并保持索引关联。
ksort() 按键对数组进行排序。它保持键与数据的关联。这主要适用于关联数组。
krsort() 按键以逆序排序数组。
natsort() 使用自然顺序算法对数组进行排序,并保持值/键关联。
natcasesort() 使用不区分大小写的“自然顺序”算法对数组进行排序,并保持值/键关联。

| usort() | 使用用户定义的比较函数按值对数组进行排序,并且不保持值/键关联。

第二个参数是一个可调用的比较函数。 |

| uksort() | 使用用户定义的比较函数按键对数组进行排序,并保持值/键关联。

第二个参数是一个可调用的比较函数。 |

| uasort() | 使用用户定义的比较函数按值对数组进行排序,并保持值/键关联。

第二个参数是一个可调用的比较函数。 |

对于sortrsortksortkrsortasortarsort,可以使用以下排序标志:

  • SORT_REGULAR:按原样比较项目(不更改类型)

  • SORT_NUMERIC:按数字比较项目

  • SORT_STRING:将项目作为字符串进行比较

  • SORT_LOCALE_STRING:根据当前区域设置将项目作为字符串进行比较

  • SORT_NATURAL:使用“自然顺序”将项目比较为字符串

摘要

在本章中,您了解了不同的排序算法。排序是我们开发过程中的一个重要部分,了解不同的排序算法及其复杂性将帮助我们根据问题集选择最佳的排序算法。还有其他排序算法,可以在网上找到进行进一步研究。我们故意没有在本章中涵盖堆排序,因为我们将在第十章中讨论。在下一章中,我们将讨论另一个关于算法的重要主题 - 搜索。

第八章:探索搜索选项

除了排序,搜索是编程世界中最常用的算法之一。无论是搜索电话簿、电子邮件、数据库还是文件,我们实际上都在执行某种搜索技术来定位我们希望找到的项目。搜索和排序是编程中最重要的两个组成部分。在本章中,您将学习不同的搜索技术以及它们的效率。我们还将学习有关搜索树数据结构的不同搜索方式。

线性搜索

执行搜索的最常见方式之一是将每个项目与我们要查找的项目进行比较。这被称为线性搜索或顺序搜索。这是执行搜索的最基本方式。如果我们考虑列表中有n个项目,在最坏的情况下,我们必须搜索n个项目才能找到特定的项目。我们可以遍历列表或数组来查找项目。让我们考虑以下例子:

function search(array $numbers, int $needle): bool {

    $totalItems = count($numbers);

    for ($i = 0; $i < $totalItems; $i++) {

      if($numbers[$i] === $needle){

        return TRUE;

      }

     }

    return FALSE;

}

我们有一个名为search的函数,它接受两个参数。一个是数字列表,另一个是我们要在列表中查找的数字。我们运行一个 for 循环来遍历列表中的每个项目,并将它们与我们的项目进行比较。如果找到匹配项,我们返回 true 并且不继续搜索。然而,如果循环结束并且没有找到任何东西,我们在函数定义的末尾返回 false。让我们使用search函数来使用以下程序查找一些东西:

$numbers = range(1, 200, 5); 

if (search($numbers, 31)) { 

    echo "Found"; 

} else { 

    echo "Not found"; 

}

在这里,我们使用 PHP 的内置函数 range 生成一个随机数组,范围是 1 到 200。每个项目的间隔为 5,如 1、6、11、16 等;然后我们搜索 31,在列表中有 6、11、16、21、26、31 等。然而,如果我们要搜索 32 或 33,那么项目将找不到。因此,对于这种情况,我们的输出将是Found

我们需要记住的一件事是,我们不必担心我们的列表是否按任何特定顺序或特定方式组织。如果我们要查找的项目在第一个位置,那将是最好的结果。最坏的结果可能是最后一个项目或不在列表中的项目。在这两种情况下,我们都必须遍历列表的所有n个项目。以下是线性/顺序搜索的复杂性:

最佳时间复杂度 O(1)
最坏时间复杂度 O(n)
平均时间复杂度 O(n)
空间复杂度(最坏情况) O(1)

正如我们所看到的,线性搜索的平均或最坏时间复杂度为O(n),这并不会改变我们对项目列表的排序方式。现在,如果数组中的项目按特定顺序排序,那么我们可能不必进行线性搜索,而可以通过选择性或计算性搜索获得更好的结果。最流行和知名的搜索算法是"二分搜索"。是的,这听起来像你在第六章中学到的二分搜索树,理解和实现树,但我们甚至可以在不构建二分搜索树的情况下使用这个算法。所以,让我们来探索一下。

二分搜索

二分搜索是编程世界中非常流行的搜索算法。在顺序搜索中,我们从开头开始扫描每个项目以找到所需的项目。然而,如果列表已经排序,那么我们就不需要从列表的开头或结尾开始搜索。在二分搜索算法中,我们从列表的中间开始,检查中间的项目是比我们要找的项目小还是大,并决定要走哪条路。这样,我们将列表分成两半,并丢弃一半,就像下面的图片一样:

如果我们看前面的图片,我们有一个按升序排序的数字列表。我们想知道项目7是否在数组中。由于数组有 17 个项目(0 到 16 索引),我们将首先转到中间索引,对于这个示例来说是第八个索引。现在,第八个索引的值为14,大于我们要搜索的值7。这意味着如果7在这个数组中,它在14的左边,因为数字已经排序。因此,我们放弃了从第八个索引到第十六个索引的数组,因为数字不能在数组的那一部分。现在,我们重复相同的过程,并取数组剩余部分的中间部分,即剩余部分的第三个元素。现在,第三个元素的值为6,小于7。因此,我们要找的项目在剩余部分的第三个元素的右侧,而不是左侧。

现在,我们将检查数组的第四个元素到第七个元素,中间元素现在指向第五个元素。第五个元素的值为8,大于7,我们要找的值。因此,我们必须考虑第五个元素的左侧来找到我们要找的项目。这次,我们只剩下两个项目要检查,即第四个和第五个元素。当我们向左移动时,我们将检查第四个元素,我们看到值与我们要找的7匹配。如果第四个索引值不是7,函数将返回 false,因为没有更多的元素可以检查。如果我们看一下前面图片中的箭头标记,我们可以看到在四步内,我们已经找到了我们要找的值,而在线性搜索函数中,我们需要花 17 步来检查所有 17 个数字,这是最坏情况下的二分搜索,或半间隔搜索,或对数搜索。

正如我们在上一张图片中看到的,我们必须将初始列表分成两半,并继续直到达到一个不能再进一步分割以找到我们的项目的地步。我们可以使用迭代方式或递归方式来执行分割部分。我们将实际上使用两种方式。因此,让我们首先定义迭代方式中的二分搜索的伪代码:

BinarySearch(A : list of sorted items, value) { 

       low = 0 

       high = N

       while (low <= high) { 

    // lowest int value, no fraction 

           mid = (low + high) / 2              

           if (A[mid] > value) 

               high = mid - 1 

           else if (A[mid] < value) 

               low = mid + 1 

           else  

             return true 

       }

       return false 

 }

如果我们看一下伪代码,我们可以看到我们根据中间值调整了低和高。如果我们要查找的值大于中间值,我们将调整下界为mid+1。如果小于中间值,则将上界设置为mid-1。直到下界变大于上界或找到项目为止。如果未找到项目,我们在函数末尾返回 false。现在,让我们使用 PHP 实现伪代码:

function binarySearch(array $numbers, int $needle): bool { 

    $low = 0; 

    $high = count($numbers) - 1; 

    while ($low <= $high) { 

      $mid = (int) (($low + $high) / 2); 

      if ($numbers[$mid] > $needle) { 

          $high = $mid - 1;

      } else if ($numbers[$mid] < $needle) { 

          $low = $mid + 1;

      } else {

          return TRUE;

      }

    }

    return FALSE; 

}

在我们的实现中,我们遵循了前一页中的大部分伪代码。现在,让我们运行两次搜索的代码,我们知道一个值在列表中,一个值不在列表中:

$numbers = range(1, 200, 5); 

$number = 31;

if (binarySearch($numbers, $number) !== FALSE) { 

    echo "$number Found \n"; 

} else { 

    echo "$number Not found \n"; 

} 

$number = 500; 

if (binarySearch($numbers, $number) !== FALSE) { 

    echo "$number Found \n"; 

} else { 

    echo "$number Not found \n"; 

} 

根据我们之前的线性搜索代码,31在列表中,应该显示Found。然而,500不在列表中,应该显示Not found。如果我们运行代码,这是我们在控制台中看到的输出:

31 Found

500 Not found

我们现在将为二分搜索编写递归算法,这对我们也很方便。伪代码将要求我们在每次调用函数时发送额外的参数。我们需要在每次递归调用时发送低和高,这是迭代调用中没有做的:

BinarySearch(A : list of sorted items, value, low, high) { 

   if (high < low) 

          return false 

      // lowest int value, no fraction 

           mid = (low + high) / 2   

           if (A[mid] > value) 

               return BinarySearch(A, value, low, mid - 1) 

           else if (A[mid] < value) 

               return BinarySearch(A, value, mid + 1, high)  

     else 

      return TRUE;      

}

从前面的伪代码中我们可以看到,现在我们有低和高作为参数,在每次调用中,新值作为参数发送。我们有边界条件,检查低是否大于高。与迭代的代码相比,代码看起来更小更干净。现在,让我们使用 PHP 7 来实现这个:

function binarySearch(array $numbers, int $needle,  

int $low, int $high): bool { 

    if ($high < $low) { 

    return FALSE; 

    } 

    $mid = (int) (($low + $high) / 2); 

    if ($numbers[$mid] > $needle) { 

      return binarySearch($numbers, $needle, $low, $mid - 1); 

    } else if ($numbers[$mid] < $needle) { 

      return binarySearch($numbers, $needle, $mid + 1, $high); 

    } else { 

      return TRUE; 

    } 

}

现在,让我们使用以下代码来递归运行这个搜索:

$numbers = range(1, 200, 5); 

$number = 31; 

if (binarySearch($numbers, $number, 0, count($numbers) - 1) !== FALSE) { 

    echo "$number Found \n"; 

} else { 

    echo "$number Not found \n"; 

} 

$number = 500; 

if (binarySearch($numbers, $number, 0, count($numbers) - 1) !== FALSE) { 

    echo "$number Found \n"; 

} else { 

    echo "$number Not found \n"; 

}

正如我们从前面的代码中看到的,我们在递归二分搜索的每次调用中发送0count($numbers)-1。然后,这个高和低在每次递归调用时根据中间值自动调整。因此,我们已经看到了二分搜索的迭代和递归实现。根据我们的需求,我们可以在程序中使用其中一个。现在,让我们分析二分搜索算法,并找出它为什么比我们的线性或顺序搜索算法更好。

二分搜索算法的分析

到目前为止,我们已经看到,对于每次迭代,我们都将列表分成一半,并丢弃一半进行搜索。这使得我们的列表在 1、2 和 3 次迭代后看起来像n/2n/4n/8,依此类推。因此,我们可以说,在第 K 次迭代后,将剩下n/2k*个项目。我们可以轻松地说,最后一次迭代发生在*n/2k = 1时,或者我们可以说,2^K = n。因此,从两边取对数得到,k = log(n),这是二分搜索算法的最坏情况运行时间。以下是二分搜索算法的复杂性:

最佳时间复杂度 O(1)
最坏时间复杂度 O(log n)
平均时间复杂度 O(log n)
空间复杂度(最坏情况) O(1)

如果我们的数组或列表已经排序,总是更倾向于应用二分搜索以获得更好的性能。现在,无论列表是按升序还是降序排序,都会对我们计算的低和高产生一些影响。到目前为止,我们看到的逻辑是针对升序的。如果数组按降序排序,逻辑将被交换,大于将变成小于,反之亦然。这里需要注意的一点是,二分搜索算法为我们提供了搜索项的索引。然而,可能有一些情况,我们不仅需要知道数字是否存在,还需要找到列表中的第一次出现或最后一次出现。如果我们使用二分搜索算法,它将返回 true 或最大索引号,搜索算法找到数字的地方。然而,这可能不是第一次出现或最后一次出现。为此,我们将稍微修改二分搜索算法,称之为重复二叉搜索树算法。

重复二叉搜索树算法

考虑以下图片。我们有一个包含重复项的数组。如果我们尝试从数组中找到2的第一次出现,上一节的二分搜索算法将给我们第五个元素。然而,从下面的图片中,我们可以清楚地看到它不是第五个元素;相反,它是第二个元素,这才是正确的答案。因此,我们需要对我们的二分搜索算法进行修改。修改将是重复搜索,直到我们找到第一次出现:

这是使用迭代方法的修改后的解决方案:

function repetitiveBinarySearch(array $numbers, int $needle): int { 

    $low = 0;

    $high = count($numbers) - 1;

    $firstOccurrence = -1;

    while ($low <= $high) { 

      $mid = (int) (($low + $high) / 2); 

      if ($numbers[$mid] === $needle) { 

          $firstOccurrence = $mid; 

          $high = $mid - 1; 

      } else if ($numbers[$mid] > $needle) { 

          $high = $mid - 1;

      } else {

          $low = $mid + 1;

      } 

    } 

    return $firstOccurrence; 

} 

正如我们所看到的,首先我们要检查中间值是否是我们要找的值。如果是真的,那么我们将中间索引分配为第一次出现,并且我们将搜索中间元素的左侧以检查我们要找的数字的任何出现。然后我们继续迭代,直到我们搜索了每个索引($low大于$high)。如果没有找到进一步的出现,那么第一次出现的变量将具有我们找到该项的第一个索引的值。如果没有,我们像往常一样返回-1。让我们运行以下代码来检查我们的结果是否正确:

$numbers = [1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 5, 5]; 

$number = 2; 

$pos = repetitiveBinarySearch($numbers, $number); 

if ($pos >= 0) { 

    echo "$number Found at position $pos \n"; 

} else { 

    echo "$number Not found \n"; 

} 

$number = 5; 

$pos = repetitiveBinarySearch($numbers, $number); 

if ($pos >= 0) { 

    echo "$number Found at position $pos \n"; 

} else { 

    echo "$number Not found \n"; 

}

现在,我们有一个包含重复值的数组,值为 2、3、4 和 5。我们想搜索数组,并找到值第一次出现的位置或索引。例如,如果我们在一个常规的二分搜索函数中搜索 2,它会返回第八个位置,即它找到值 2 的位置。在我们的情况下,我们实际上是在寻找第二个索引,它实际上保存了项目 2 的第一次出现。我们的函数 repetitiveBinarySearch 正是这样做的,我们将返回的位置存储到一个名为 $pos 的变量中。如果找到数字,我们将显示输出以及位置。现在,如果我们在控制台中运行前面的代码,我们将得到以下输出:

2 Found at position 1

5 Found at position 16

这符合我们的预期结果。因此,我们现在有了一个重复的二分搜索算法,用于查找给定排序列表中项目的第一次和最后一次出现。这可能是一个非常方便的函数来解决许多问题。

到目前为止,从我们的例子和分析来看,我们可以得出结论,二分搜索肯定比线性搜索更快。然而,主要的前提是在应用二分搜索之前对列表进行排序。在未排序的数组中应用二分搜索会导致我们得到不准确的结果。有时候我们会收到一个数组,而我们不确定这个数组是否已经排序。现在,问题是,“在这种情况下,我们应该先对数组进行排序然后应用二分搜索算法吗?还是应该只运行线性搜索算法来找到一个项目?”让我们讨论一下这个问题,这样我们就知道如何处理这种情况。

搜索一个未排序的数组 - 我们应该先排序吗?

所以现在,我们处于这样一种情况:我们有一个包含 n 个项目的数组,它们没有排序。由于我们知道二分搜索更快,我们决定先对其进行排序,然后使用二分搜索来搜索项目。如果我们这样做,我们必须记住,最好的排序算法的最坏时间复杂度为 O(nlog n),而对于二分搜索,最坏情况的复杂度为 O(log n)。因此,如果我们先排序然后应用二分搜索,复杂度将为 O(n log n),因为这是与 O(log n) 相比最大的。然而,我们也知道,对于任何线性或顺序搜索(无论是排序还是未排序),最坏的时间复杂度都是 O(n),这比 O(n log n) 要好得多。根据 O(n)O(n log n) 的复杂度比较,我们可以清楚地说,如果数组没有排序,执行线性搜索是一个更好的选择。

让我们考虑另一种情况,我们需要多次搜索一个给定的数组。让我们用 k 表示我们想要搜索数组的次数。如果 k 为 1,那么我们可以轻松地应用上一段讨论的线性方法。如果 k 的值相对于数组的大小 n 来说比较小,那么也没问题。然而,如果 k 的值接近或大于 n,那么我们在这里应用线性方法就会有一些问题。

假设 k = n,那么对于 n 次搜索,线性搜索的复杂度将为 O(n²)。现在,如果我们选择排序然后搜索,即使 k 更大,一次排序也只需要 O(n log n) 的时间复杂度。然后,每次搜索只需要 O(log n),而 n 次搜索的最坏情况复杂度为 O(n log n)。如果我们在这里考虑最坏的情况,那么对于排序和搜索 k 个项目,我们将得到 O(n log n),这比顺序搜索要好。

因此,我们可以得出结论:如果搜索操作的次数较小,与数组的大小相比,最好不要对数组进行排序,而是执行顺序搜索。然而,如果搜索操作的次数较大,与数组的大小相比,最好先对数组进行排序,然后应用二分搜索。

多年来,二分搜索算法不断发展,并出现了不同的变体。我们可以通过计算决策来选择下一个应该使用的索引,而不是每次选择中间索引。这就是这些变体能够高效工作的原因。现在我们将讨论二分搜索算法的两种变体:插值搜索和指数搜索。

插值搜索

在二分搜索算法中,我们总是从数组的中间开始搜索过程。如果数组是均匀分布的,并且我们正在寻找一个可能接近数组末尾的项目,那么从中间开始搜索可能对我们来说并不是一个好选择。在这种情况下,插值搜索可能非常有帮助。插值搜索是对二分搜索算法的改进。插值搜索可能根据搜索关键字的值而转到不同的位置。例如,如果我们正在搜索一个接近数组开头的关键字,它将转到数组的第一部分,而不是从中间开始。位置是使用探测位置计算器方程计算的,如下所示:

pos = low + [ (key-arr[low])*(high-low) / (arr[high]-arr[low]) ]

正如我们所看到的,我们从通用的 mid = (low+high)/2 方程转变为一个更复杂的方程。如果搜索的关键字更接近 arr[high],这个公式将返回一个更高的值,如果关键字更接近 arr[low],则返回一个更低的值。现在,让我们借助我们的二分搜索代码来实现这种搜索方法:

function interpolationSearch(array $arr, int $key): int { 

    $low = 0; 

    $high = count($arr) - 1; 

    while ($arr[$high] != $arr[$low] && $key >= $arr[$low] && 

      $key <= $arr[$high]) { 

    $mid = intval($low + (($key - $arr[$low]) * ($high - $low) 

    / ($arr[$high] - $arr[$low]))); 

      if ($arr[$mid] < $key) 

          $low = $mid + 1; 

      else if ($key < $arr[$mid]) 

          $high = $mid - 1; 

      else 

          return $mid; 

    } 

    if ($key == $arr[$low]) 

      return $low; 

    else

      return -1; 

}

在这里,我们以一种不同的方式进行计算。尽管它需要更多的计算步骤,但好处是,如果列表是均匀分布的,那么该算法的平均复杂度为 O(log (log n)),这与二分搜索的复杂度 O(log n) 相比要好得多。此外,我们必须小心,如果关键字的分布不均匀,插值搜索的性能可能会下降。

现在,我们将探讨另一种二分搜索的变体,称为指数搜索,它可以改进算法。

指数搜索

在二分搜索中,我们为给定的关键字搜索整个列表。指数搜索通过决定搜索的下限和上限来改进二分搜索,以便我们不会最终搜索整个列表。它改进了我们需要找到一个元素所需的比较次数。搜索分为以下两个步骤:

  1. 我们通过寻找第一个指数 k,其中 2^k 的值大于搜索项,来确定边界大小。现在,2^k2^(k-1) 分别成为上限和下限。

  2. 2^k2^(k-1) 进行二分搜索算法。

现在让我们使用我们的递归 binarySearch 函数来实现指数搜索:

function exponentialSearch(array $arr, int $key): int { 

    $size = count($arr); 

    if ($size == 0) 

      return -1; 

    $bound = 1; 

    while ($bound < $size && $arr[$bound] < $key) { 

      $bound *= 2; 

    } 

    return binarySearch($arr, $key, intval($bound / 2),  

min($bound, $size)); 

}

在第一步中,我们需要 i 步来确定边界。因此,该算法的复杂度为 O(log i)。我们必须记住,这里的 i 要远小于 n。然后,我们使用 2^j2^(j-1) 进行二分搜索,其中 j = log i。我们知道二分搜索的复杂度为 O(log n),其中 n 是列表的大小。然而,由于我们正在进行较小范围的搜索,实际上我们搜索的是 2 ^(log i) \ - 2 ^(log i) - 1 = 2 ^(log i - 1) 大小。因此,这个边界的复杂度将是 log (2 ^(log i - 1) ) = log (i) - 1 = O(log i)

因此,指数搜索的复杂性如下:

最佳时间复杂度 O(1)
最坏时间复杂度 O(log i)
平均时间复杂度 O(log i)
空间复杂度(最坏情况) O(1)

使用哈希表进行搜索

哈希表在搜索操作时可以是非常高效的数据结构。由于哈希表以关联方式存储数据,如果我们知道在哪里查找数据,我们可以很容易地快速获取数据。在哈希表中,每个数据都有一个与之关联的唯一索引。如果我们知道要查看哪个索引,我们可以很容易地找到键。通常,在其他编程语言中,我们必须使用单独的哈希函数来计算哈希索引以存储值。哈希函数旨在为相同的键生成相同的索引,并避免冲突。然而,PHP 的一个伟大特性是 PHP 数组本身就是一个哈希表,在其底层 C 实现中。由于数组是动态的,我们不必担心数组的大小或溢出数组的值。我们需要将值存储在关联数组中,以便我们可以将值与键关联起来。如果是字符串或整数,键可以是值本身。让我们运行一个例子来理解使用哈希表进行搜索:

$arr = [];

$count = rand(10, 30); 

for($i = 0; $i<$count;$i++) {     

    $val = rand(1,500);     

    $arr[$val] = $val;     

} 

$number = 100; 

if(isset($arr[$number])) { 

    echo "$number found "; 

} else { 

    echo "$number not found"; 

}

我们刚刚构建了一个简单的随机关联数组,其中值和键是相同的。由于我们使用的是 PHP 数组,尽管值可以在 1 到 500 的范围内,实际数组大小可以是 10 到 30 之间的任何值。如果是在其他语言中,我们将构建一个大小为 501 的数组来容纳这个值作为键。这就是为什么要使用哈希函数来计算索引。如果需要的话,我们也可以使用 PHP 的内置哈希函数:

string hash(string $algo ,string $data [,bool $raw_output = false ])

第一个参数采用我们想要用于哈希的算法类型。我们可以选择 md5、sha1、sha256、crc32 等。每个算法都会产生一个固定长度的哈希输出,我们可以将其用作哈希表的键。

如果我们看一下我们的搜索部分,我们可以看到我们实际上是直接检查相关的索引。这使得我们的搜索复杂度为O(1)。在 PHP 中,使用哈希表进行快速搜索可能是有益的,即使不使用哈希函数。但是,如果需要的话,我们总是可以使用哈希函数。

到目前为止,我们已经涵盖了基于数组和线性结构的搜索。现在我们将把重点转移到层次化数据结构搜索,比如搜索树和图。虽然我们还没有讨论图(我们将在下一章讨论),但我们将把重点放在树搜索上,这也可以应用于图搜索。

树搜索

搜索层次化数据的最佳方法之一是创建搜索树。在第六章中,理解和实现树,我们看到了如何构建二叉搜索树并提高搜索效率。我们还发现了遍历树的不同方法。现在,我们将探索两种最流行的搜索树结构的方式,通常称为广度优先搜索(BFS)和深度优先搜索(DFS)。

广度优先搜索

在树结构中,根节点连接到其子节点,每个子节点都可以表示为一棵树。我们在第六章中已经看到了这一点,理解和实现树。在广度优先搜索中,通常称为 BFS,我们从一个节点(通常是根节点)开始,首先访问所有相邻或邻居节点,然后再访问其他邻居节点。换句话说,我们必须逐层移动,而我们应用 BFS。由于我们逐层搜索,这种技术被称为广度优先搜索。在下面的树结构中,我们可以使用 BFS:

对于这棵树,BFS 将按照以下节点进行:

BFS 的伪代码如下:

procedure BFS(Node root)  

  Q := empty queue 

  Q.enqueue(root); 

  while(Q != empty) 

     u := Q.dequeue() 

     for each node w that is childnode of u 

        Q.enqueue(w) 

     end for each 

  end while 

end procedure

我们可以看到我们保留了一个队列来跟踪我们需要访问的节点。我们可以保留另一个队列来保存访问的顺序,并将其返回以显示访问顺序。现在,我们将使用 PHP 7 来实现 BFS。

实现广度优先搜索

到目前为止,我们还没有详细介绍图,因此我们将严格将 BFS 和 DFS 的实现保留在树结构中。此外,我们将使用我们在第六章中看到的通用树结构,理解和实现树,(甚至不是二叉树)。我们将使用相同的TreeNode类来定义我们的节点和与子节点的关系。因此,现在让我们定义具有 BFS 功能的Tree类:

class TreeNode { 

    public $data = NULL; 

    public $children = []; 

    public function __construct(string $data = NULL) { 

      $this->data = $data; 

    } 

    public function addChildren(TreeNode $node) { 

      $this->children[] = $node; 

    } 

} 

class Tree { 

    public $root = NULL; 

    public function __construct(TreeNode $node) { 

      $this->root = $node; 

    } 

    public function BFS(TreeNode $node): SplQueue { 

      $queue = new SplQueue; 

      $visited = new SplQueue; 

      $queue->enqueue($node); 

      while (!$queue->isEmpty()) { 

          $current = $queue->dequeue(); 

          $visited->enqueue($current); 

          foreach ($current->children as $child) { 

            $queue->enqueue($child); 

          } 

      } 

    return $visited; 

    }

}

我们在树类内部实现了 BFS 方法。我们以根节点作为广度优先搜索的起点。在这里,我们有两个队列:一个用于保存我们需要访问的节点,另一个用于我们已经访问的节点。我们还在方法的最后返回了访问的队列。现在让我们模仿一下我们在本节开头看到的树。我们想要像图中显示的树一样放置数据,并检查 BFS 是否实际返回我们期望的模式:

    $root = new TreeNode("8"); 

    $tree = new Tree($root); 

    $node1 = new TreeNode("3"); 

    $node2 = new TreeNode("10"); 

    $root->addChildren($node1); 

    $root->addChildren($node2); 

    $node3 = new TreeNode("1"); 

    $node4 = new TreeNode("6"); 

    $node5 = new TreeNode("14"); 

    $node1->addChildren($node3); 

    $node1->addChildren($node4); 

    $node2->addChildren($node5); 

    $node6 = new TreeNode("4"); 

    $node7 = new TreeNode("7"); 

    $node8 = new TreeNode("13"); 

    $node4->addChildren($node6); 

    $node4->addChildren($node7); 

    $node5->addChildren($node8); 

    $visited = $tree->BFS($tree->root); 

    while (!$visited->isEmpty()) { 

      echo $visited->dequeue()->data . "\n"; 

    } 

我们在这里通过创建节点并将它们附加到根和其他节点来构建整个树结构。一旦树完成,我们就调用BFS方法来找到遍历的完整序列。最后的while循环打印了我们访问的节点序列。以下是前面代码的输出:

8

3

10

1

6

14

4

7

13

我们已经收到了我们期望的结果。现在,如果我们想搜索以查找节点是否存在,我们可以为我们的$current节点值添加一个简单的条件检查。如果匹配,那么我们可以返回访问的队列。

BFS 的最坏复杂度为O(|V| + |E),其中V是顶点或节点的数量,E是节点之间的边或连接的数量。对于空间复杂度,最坏情况是O(|V|)。

图的 BFS 类似,但有一点不同。由于图可能是循环的(可以创建循环),我们需要确保我们不会一遍又一遍地访问相同的节点以创建无限循环。为了避免重新访问图节点,我们必须跟踪我们已经访问的节点。为了标记已访问的节点,我们可以使用队列,或使用图着色算法。我们将在下一章中探讨这一点。

深度优先搜索

深度优先搜索,或 DFS,是一种搜索技术,我们从一个节点开始搜索,并尽可能深入到目标节点通过分支。DFS 不同于 BFS,我们尝试更深入地挖掘而不是首先扩散。DFS 垂直增长,并在到达分支的末端时回溯,并移动到下一个可用的相邻节点,直到搜索结束。我们可以从上一节中取相同的树图像,如下所示:

如果我们在这里应用 DFS,遍历将是。我们从根开始,然后访问第一个子节点,即3。然而,与 BFS 不同,我们将探索3的子节点,并重复此过程,直到达到分支的底部。在 BFS 中,我们采用了迭代方法。对于 DFS,我们将采用递归方法。现在让我们为 DFS 编写伪代码:

procedure DFS(Node current)       

     for each node v that is childnode of current  

        DFS(v) 

     end for each 

end procedure 

实现深度优先搜索

DFS 的伪代码看起来很简单。为了跟踪节点访问的顺序,我们需要使用一个队列,它将跟踪我们Tree类内部的节点。以下是我们带有递归 DFS 的Tree类的实现:

class TreeNode { 

    public $data = NULL; 

    public $children = []; 

    public function __construct(string $data = NULL) { 

      $this->data = $data; 

    } 

    public function addChildren(TreeNode $node) { 

      $this->children[] = $node; 

    } 

} 

class Tree { 

    public $root = NULL; 

    public $visited; 

    public function __construct(TreeNode $node) { 

      $this->root = $node; 

      $this->visited = new SplQueue; 

    } 

    public function DFS(TreeNode $node) { 

      $this->visited->enqueue($node); 

      if($node->children){ 

          foreach ($node->children as $child) { 

        $this->DFS($child); 

          } 

      } 

    }

}

正如我们所看到的,我们在树类中添加了一个额外的属性$visited来跟踪访问的节点。当我们调用DFS方法时,我们将节点添加到队列中。现在,如果我们使用上一节中的相同树结构,只需添加 DFS 调用并获取访问部分,它将如下所示:

try { 

    $root = new TreeNode("8"); 

    $tree = new Tree($root); 

    $node1 = new TreeNode("3"); 

    $node2 = new TreeNode("10"); 

    $root->addChildren($node1); 

    $root->addChildren($node2); 

    $node3 = new TreeNode("1"); 

    $node4 = new TreeNode("6"); 

    $node5 = new TreeNode("14"); 

    $node1->addChildren($node3); 

    $node1->addChildren($node4); 

    $node2->addChildren($node5); 

    $node6 = new TreeNode("4"); 

    $node7 = new TreeNode("7"); 

    $node8 = new TreeNode("13"); 

    $node4->addChildren($node6); 

    $node4->addChildren($node7); 

    $node5->addChildren($node8); 

    $tree->DFS($tree->root); 

    $visited = $tree->visited; 

    while (!$visited->isEmpty()) { 

      echo $visited->dequeue()->data . "\n"; 

    } 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

由于 DFS 不返回任何内容,我们使用类属性visited来获取队列,以便我们可以显示访问节点的序列。如果我们在控制台中运行此程序,将会得到以下输出:

8

3

1

6

4

7

10

14

13

结果符合预期。如果我们需要 DFS 的迭代解决方案,我们必须记住,我们需要使用堆栈而不是队列来跟踪下一个要访问的节点。然而,由于堆栈遵循 LIFO 原则,对于我们提到的图像,输出将与我们最初的想法不同。以下是使用迭代方法的实现:

class TreeNode { 

    public $data = NULL; 

    public $children = []; 

    public function __construct(string $data = NULL) { 

      $this->data = $data; 

    } 

    public function addChildren(TreeNode $node) { 

      $this->children[] = $node; 

    } 

} 

class Tree { 

    public $root = NULL; 

    public function __construct(TreeNode $node) { 

      $this->root = $node; 

    }

    public function DFS(TreeNode $node): SplQueue { 

      $stack = new SplStack;

      $visited = new SplQueue;

      $stack->push($node);

      while (!$stack->isEmpty()) { 

          $current = $stack->pop(); 

          $visited->enqueue($current); 

          foreach ($current->children as $child) { 

            $stack->push($child); 

          } 

      } 

      return $visited; 

    }

}

try {

    $root = new TreeNode("8"); 

    $tree = new Tree($root); 

    $node1 = new TreeNode("3"); 

    $node2 = new TreeNode("10"); 

    $root->addChildren($node1); 

    $root->addChildren($node2); 

    $node3 = new TreeNode("1"); 

    $node4 = new TreeNode("6"); 

    $node5 = new TreeNode("14"); 

    $node1->addChildren($node3); 

    $node1->addChildren($node4); 

    $node2->addChildren($node5); 

    $node6 = new TreeNode("4"); 

    $node7 = new TreeNode("7"); 

    $node8 = new TreeNode("13"); 

    $node4->addChildren($node6); 

    $node4->addChildren($node7); 

    $node5->addChildren($node8); 

    $visited = $tree->DFS($tree->root); 

    while (!$visited->isEmpty()) { 

      echo $visited->dequeue()->data . "\n"; 

    } 

} catch (Exception $e) { 

    echo $e->getMessage(); 

}

它看起来与我们的迭代 BFS 算法非常相似。主要区别在于使用堆栈数据结构而不是队列数据结构来存储已访问的节点。这也会对输出产生影响。前面的代码将产生输出8 → 10 → 14 → 13 → 3 → 6 → 7 → 4 → 1。这与上一节中显示的先前输出不同。由于我们使用堆栈,输出实际上是正确的。我们使用堆栈来推入特定节点的子节点。对于我们的根节点,其值为8,我们有值为3的第一个子节点。它被推入堆栈,然后,根的第二个子节点的值为10,也被推入堆栈。由于值10是最后被推入的,它将首先出现,遵循堆栈的 LIFO 原则。因此,如果我们使用堆栈,顺序始终将从最后的分支开始到第一个分支。然而,如果我们想要保持节点的顺序从左到右,那么我们需要在 DFS 代码中进行一些小的调整。以下是带有更改的代码块:

public function DFS(TreeNode $node): SplQueue { 

  $stack = new SplStack; 

  $visited = new SplQueue;

  $stack->push($node); 

  while (!$stack->isEmpty()) { 

      $current = $stack->pop(); 

      $visited->enqueue($current); 

      $current->children = array_reverse($current->children); 

      foreach ($current->children as $child) { 

        $stack->push($child); 

      } 

    } 

    return $visited;

}

与上一个代码块的唯一区别是,在访问特定节点的子节点之前,我们添加了以下行:

$current->children = array_reverse($current->children);

由于堆栈执行后进先出(LIFO)的操作,通过反转,我们确保首先访问第一个节点,因为我们已经反转了顺序。实际上,它将简单地作为队列工作。这将产生 DFS 部分所示的期望顺序。如果我们有一棵二叉树,那么我们可以很容易地做到这一点,而不需要任何反转,因为我们可以选择先推入右子节点,然后再推入左子节点以先弹出左子节点。

DFS 的最坏复杂度为O|V| + |E|),其中V是顶点或节点的数量,E是节点之间的边或连接的数量。对于空间复杂度,最坏情况是O|V|),这与 BFS 类似。

摘要

在本章中,我们讨论了不同的搜索算法及其复杂性。您学会了如何通过哈希表来改进搜索,以获得恒定的时间结果。我们还探讨了 BFS 和 DFS,这两种是层次数据搜索中最重要的方法之一。我们将使用类似的概念来探索下一章中即将探讨的图数据结构。图算法对于解决许多问题至关重要,并且在编程世界中被广泛使用。让我们继续探讨另一个有趣的主题 - 图。

第九章:将图应用到实际中

图是用于解决各种现实问题的最有趣的数据结构之一。无论是在地图上显示方向,寻找最短路径,规划复杂的网络流量,寻找社交媒体中的个人资料之间的联系或推荐,我们都在处理图数据结构及其相关算法。图给我们提供了解决问题的许多方法,因此它们经常被用来解决复杂问题。因此,我们非常重要的是要理解图以及我们如何在解决方案中使用它们。

理解图的属性

图是通过边连接在一起的顶点或节点的集合。这些边可以是有序的或无序的,这意味着边可以有与之相关的方向,也可以是无向的,也称为双向边。我们使用集合G与顶点V和边E的关系来表示图,如下所示:

G = (V, E)

在前面的图中,我们有五个顶点和六条边:

V = {A, B, C, D, E}

E = {AB, AC, AD, BD, BE, CD, DE}

如果我们考虑前面的图,A 和 B 之间的连接可以表示为 AB 或 BA,因为我们没有定义连接的方向。图和树数据结构之间的一个重要区别是,图可以形成循环,但树数据结构不能。与树数据结构不同,我们可以从图数据结构中的任何顶点开始。此外,我们可以在任何两个顶点之间有直接的边,而在树中,只有在子节点是父节点的直接后代时,两个节点才能连接。

图有不同的属性和与之相关的关键词。在继续讨论图及其应用之前,我们将探讨这些术语。

顶点

图中的每个节点称为一个顶点。通常,顶点表示为一个圆。在我们的图中,节点 A,B,C,D 和 E 是顶点。

边是两个顶点之间的连接。通常,它由两个顶点之间的线表示。在前面的图中,我们在 A 和 B 之间,A 和 C 之间,A 和 D 之间,B 和 D 之间,C 和 D 之间,B 和 E 之间,以及 D 和 E 之间有边。我们可以表示边为 AB 或(A,B)。边可以有三种类型:

  • 有向边:如果一条边标有箭头,那么它表示一条有向边。有向边是单向的。箭头的头部是终点,箭头的尾部是起点:

在前面的图中,我们可以看到 A 有一个指向 B 的有向边,这意味着 A,B 是一条边,但反之不成立(B,A)。因此,这是一个单向边或有向边的例子。

  • 无向边:无向边是两个顶点之间没有方向的连接。这意味着边满足双向关系。下图是无向图的一个例子,其中 A 与 B 连接的方式是(A,B)和(B,A)是相同的:

  • 加权边:当一条边携带额外信息,如成本、距离或其他信息时,我们称该边为加权边。这用于许多图算法。在下图中,边(A,B)的权重为 5。根据图的定义,这可以是距离、成本或其他任何东西:

邻接

如果两个顶点之间有一条边,则它们是相邻的。如果顶点 A 和 B 之间有直接的边,则它们被称为相邻。在下图中,我们可以看到顶点 1 和顶点 2 通过边 e1 相连,因此它们被称为相邻。由于顶点 2 与顶点 3 和 4 之间没有边,所以顶点 2 不与顶点 3 和顶点 4 相邻。

关联

如果顶点是边的端点之一,则边与顶点相关。此外,如果两条边共享一个顶点,则两条边是相关的。如果考虑下图,我们可以看到边(e1,e2),(e2,e3)和(e1,e3)共享顶点 1。我们还有边(e3,e4)共享顶点 4,以及边(e2,e4)共享顶点 3。类似地,我们可以说顶点 1 与边 e1,e2 和 e3 相关,顶点 2 与边 e1 相关,顶点 3 与边 e2 和 e4 相关,顶点 4 与边 e3 和 e4 相关:

入度和出度

特定顶点的入边总数称为该顶点的入度,特定顶点的出边总数称为该顶点的出度。如果考虑下图的有向边,我们可以说顶点 A 的入度为 0,出度为 1,顶点 B 的入度为 2,出度为 1,顶点 C 的入度为 1,出度为 1,顶点 D 的入度为 1,出度为 1,顶点 E 的入度为 1,出度为 2,最后,顶点 F 的入度为 1,出度为 0。

路径

路径是从起始顶点到我们试图到达的另一个顶点的顶点和边的序列。在下图中,从 A 到 F 的路径由(A,B),(B,C),(C,E)和(E,F)表示:

图的类型

根据它们的绘制或表示方式,有不同类型的图可用。每种类型的图都有不同的行为和用途。我们将重点讨论四种主要类型的图。

有向图

如果图只包含有向边,则图称为有向图。有向图也称为有向图或有向网络。下图表示了一个有向图。这里,(A,B),(B,C),(C,E),(E,D),(E,F)和(D,B)边是有向边。由于边是有向的,边 AB 与边 BA 不同:

无向图

如果图只包含无向边,则图是无向图。换句话说,无向图中的边是双向的。有时,无向图也被称为无向网络。在无向图中,如果顶点 A 连接到顶点 B,则假定(A,B)和(B,A)表示相同的边。下图显示了一个无向图的示例,其中所有边都没有箭头表示方向:

加权图

如果图的所有边都是加权边,则图称为加权图。我们将在接下来的部分中详细讨论加权图。加权图可以是有向图或无向图。每条边必须有一个与之关联的值。边的权重总是被称为边的成本。下图表示了一个具有五个顶点和七条边的无向加权图。这里,顶点 1 和 2 之间的边的权重为 2,顶点 1 和 4 之间的边的权重为 5,顶点 4 和 5 之间的边的权重为 58:

有向无环图(DAG)

无环图是一种没有循环或环路的图。如果我们想从特定节点访问其他节点,我们不会访问任何节点两次。有向无环图,通常称为 DAG,是一个无环的有向图。有向无环图在图算法中有许多用途。有向无环图具有拓扑排序,其中顶点的排序使得每条边的起始端点在排序中出现在边的结束端点之前。以下图表示一个 DAG:

乍一看,似乎 B,C,E 和 D 形成一个循环,但仔细观察表明它们并没有形成循环,而我们在有向图部分使用的示例是循环图的完美示例。

在 PHP 中表示图

由于图是由顶点和边表示的,我们必须考虑两者来表示图。表示图的方法有几种,但最流行的方法如下:

  • 邻接表

  • 邻接矩阵

邻接表

我们可以使用链表表示图,其中一个数组将用于顶点,每个顶点将有一个链表,表示相邻顶点之间的边。当以邻接表表示时,示例图如下:

邻接矩阵

在邻接矩阵中,我们使用二维数组表示图,其中每个节点在水平和垂直方向上表示数组索引。如果从 A 到 B 的边是有方向的,则将该数组索引[A][B]标记为 1 以标记连接;否则为 0。如果边是无方向的,则[A][B]和[B][A]都设置为 1。如果图是加权图,则[A][B]或[B][A]将存储权重而不是 1。以下图显示了使用矩阵表示的无向图表示:

这个图显示了矩阵的有向图表示:

虽然我们的图表示显示了邻接表和矩阵中数组索引的字母表示,但我们也可以使用数字索引来表示顶点。

重新讨论图的 BFS 和 DFS

我们已经看到了如何在树结构中实现广度优先搜索(BFS)和深度优先搜索(DFS)。我们将重新讨论我们的 BFS 和 DFS 用于图。树实现和图实现之间的区别在于,在图实现中,我们可以从任何顶点开始,而在树数据结构中,我们从树的根开始。另一个重要的考虑因素是,我们的图可以有循环,而树中没有循环,因此我们不能重新访问一个节点或顶点,否则会陷入无限循环。我们将使用一个称为图着色的概念,其中我们使用颜色或值来保持不同节点访问的状态,以保持简单。现在让我们编写一些代码来实现图中的 BFS 和 DFS。

广度优先搜索

现在我们将实现图的 BFS。考虑以下无向图,首先,我们需要用矩阵或列表表示图。为了简单起见,我们将使用邻接矩阵表示图:

前面的邻接图有六个顶点,顶点从 1 到 6 标记(没有 0)。由于我们的顶点编号,我们可以将它们用作数组索引以加快访问速度。我们可以构建图如下:

$graph = []; 

$visited = []; 

$vertexCount = 6; 

for($i = 1;$i<=$vertexCount;$i++) { 

    $graph[$i] = array_fill(1, $vertexCount, 0); 

    $visited[$i] = 0; 

} 

在这里,我们有两个数组,一个用于表示实际图形,另一个用于跟踪已访问的节点。我们希望确保我们不会多次访问一个节点,因为这可能会导致无限循环。由于我们的图形有六个顶点,我们将$vertexCount保持为6。然后,我们将图数组初始化为具有初始值0的二维数组。我们将从数组的索引1开始。我们还将通过将每个顶点分配给$visited数组中的0来设置每个顶点为未访问状态。现在,我们将在我们的图形表示中添加边。由于图是无向的,我们需要为每条边设置两个属性。换句话说,我们需要为标记为 1 和 2 的顶点之间的边设置双向边值,因为它们之间共享一条边。以下是先前图形的完整表示的代码:

$graph[1][2] = $graph[2][1] = 1; 

$graph[1][5] = $graph[5][1] = 1; 

$graph[5][2] = $graph[2][5] = 1; 

$graph[5][4] = $graph[4][5] = 1; 

$graph[4][3] = $graph[3][4] = 1; 

$graph[3][2] = $graph[2][3] = 1; 

$graph[6][4] = $graph[4][6] = 1; 

因此,我们已经使用邻接矩阵表示了图。现在,让我们为矩阵定义 BFS 算法:

function BFS(array &$graph, int $start, array $visited): SplQueue { 

    $queue = new SplQueue;

    $path = new SplQueue;

    $queue->enqueue($start);

    $visited[$start] = 1;

    while (!$queue->isEmpty()) { 

      $node = $queue->dequeue();

      $path->enqueue($node);

      foreach ($graph[$node] as $key => $vertex) { 

          if (!$visited[$key] && $vertex == 1) { 

          $visited[$key] = 1;

          $queue->enqueue($key);

          }

      }

    }

    return $path;

}

我们实现的 BFS 函数接受三个参数:实际图形、起始顶点和空的已访问数组。我们本可以避免第三个参数,并在 BFS 函数内部进行初始化。归根结底,我们可以选择任一种方式来完成这一点。在我们的函数实现中,有两个队列:一个用于保存我们需要访问的节点,另一个用于保存已访问节点的顺序,或者搜索的路径。在函数结束时,我们返回路径队列。

在函数内部,我们首先将起始节点添加到队列中。然后,我们从该节点开始访问其相邻节点。如果节点未被访问并且与当前节点有连接,则将其添加到我们的访问队列中。我们还将当前节点标记为已访问,并将其添加到我们的路径中。现在,我们将使用我们构建的图矩阵和一个访问节点来调用我们的 BFS 函数。以下是执行 BFS 功能的程序:

$path = BFS($graph, 1, $visited); 

while (!$path->isEmpty()) { 

    echo $path->dequeue()."\t"; 

} 

从前面的代码片段中可以看出,我们从节点 1 开始搜索。输出将如下所示:

    1       2       5       3       4       6

如果我们将BFS函数调用的第二个参数从 1 更改为 5 作为起始节点,那么输出将如下所示:

    5       1       2       4       3       6

深度优先搜索

正如我们在 BFS 中看到的那样,我们也可以为 DFS 定义任何起始顶点。不同之处在于,对于已访问节点的列表,我们将使用堆栈而不是队列。代码的其他部分将类似于我们的 BFS 代码。我们还将使用与 BFS 实现相同的图。我们将实现的 DFS 是迭代的。以下是其代码:

function DFS(array &$graph, int $start, array $visited): SplQueue { 

    $stack = new SplStack; 

    $path = new SplQueue; 

    $stack->push($start); 

    $visited[$start] = 1; 

    while (!$stack->isEmpty()) { 

      $node = $stack->pop(); 

      $path->enqueue($node); 

      foreach ($graph[$node] as $key => $vertex) { 

          if (!$visited[$key] && $vertex == 1) { 

          $visited[$key] = 1; 

          $stack->push($key); 

          } 

      } 

    } 

    return $path; 

} 

如前所述,对于 DFS,我们必须使用堆栈而不是队列,因为我们需要从堆栈中获取最后一个顶点,而不是第一个(如果我们使用了队列)。对于路径部分,我们使用队列,以便在显示过程中按顺序显示路径。以下是调用我们的图$graph的代码:

$path = DFS($graph, 1, $visited); 

while (!$path->isEmpty()) { 

    echo $path->dequeue()."\t"; 

} 

该代码将产生以下输出:

    1       5       4       6       3       2

对于上述示例,我们从顶点 1 开始,并首先访问顶点 5,这是顶点 1 的两个相邻顶点中标记为 5 和 2 的顶点之一。现在,顶点 5 有两个标记为 4 和 2 的顶点。顶点 4 将首先被访问,因为它是从顶点 5 出发的第一条边(记住我们从左到右访问节点的方向)。接下来,我们将从顶点 4 访问顶点 6。由于我们无法从顶点 6 继续前进,它将返回到顶点 4 并访问标记为 3 的未访问相邻顶点。当我们到达顶点 3 时,有两个相邻顶点可供访问。它们被标记为顶点 4 和顶点 2。我们之前已经访问了顶点 4,因此无法重新访问它,我们必须从顶点 3 访问顶点 2。由于顶点 2 有三个顶点,分别是顶点 3、5 和 1,它们都已经被访问,因此我们实际上已经完成了 DFS 的实现。

如果我们从一个起始顶点寻找特定的终点顶点,我们可以传递一个额外的参数。在之前的例子中,我们只是获取相邻的顶点并访问它们。对于特定的终点顶点,我们需要在 DFS 算法的迭代过程中将目标顶点与我们访问的每个顶点进行匹配。

使用 Kahn 算法进行拓扑排序

假设我们有一些任务要做,每个任务都有一些依赖关系,这意味着在执行实际任务之前,应该先完成依赖的任务。当任务和依赖之间存在相互关系时,问题就出现了。现在,我们需要找到一个合适的顺序来完成这些任务。我们需要一种特殊类型的排序,以便在不违反完成任务的规则的情况下对这些相互关联的任务进行排序。拓扑排序将是解决这类问题的正确选择。在拓扑排序中,从顶点 A 到 B 的有向边 AB 被排序,以便 A 始终在排序中位于 B 之前。这将适用于所有的顶点和边。应用拓扑排序的另一个重要因素是图必须是一个 DAG。任何 DAG 都至少有一个拓扑排序。大多数情况下,对于给定的图,可能存在多个拓扑排序。有两种流行的算法可用于拓扑排序:Kahn 算法和 DFS 方法。我们将在这里讨论 Kahn 算法,因为我们在本书中已经多次讨论了 DFS。

Kahn 算法有以下步骤来从 DAG 中找到拓扑排序:

  1. 计算每个顶点的入度(入边),并将所有入度为 0 的顶点放入队列中。还要将访问节点的计数初始化为 0。

  2. 从队列中移除一个顶点,并对其执行以下操作:

  3. 将访问节点计数加 1。

  4. 将所有相邻顶点的入度减 1。

  5. 如果相邻顶点的入度变为 0,则将其添加到队列中。

  6. 重复步骤 2,直到队列为空。

  7. 如果访问节点的计数与节点的计数不同,则给定 DAG 的拓扑排序是不可能的。

让我们考虑以下图。这是一个 DAG 的完美例子。现在,我们想使用拓扑排序和 Kahn 算法对其进行排序:

现在让我们使用邻接矩阵来表示这个图,就像我们之前为其他图所做的那样。矩阵将如下所示:

$graph = [ 

    [0, 0, 0, 0, 1], 

    [1, 0, 0, 1, 0], 

    [0, 1, 0, 1, 0], 

    [0, 0, 0, 0, 0], 

    [0, 0, 0, 0, 0], 

];

现在,我们将按照我们定义的步骤实现 Kahn 算法。以下是它的实现:

function topologicalSort(array $matrix): SplQueue { 

    $order = new SplQueue; 

    $queue = new SplQueue; 

    $size = count($matrix); 

    $incoming = array_fill(0, $size, 0); 

    for ($i = 0; $i < $size; $i++) { 

      for ($j = 0; $j < $size; $j++) { 

          if ($matrix[$j][$i]) { 

          $incoming[$i] ++; 

          } 

      } 

      if ($incoming[$i] == 0) { 

          $queue->enqueue($i); 

      } 

    } 

    while (!$queue->isEmpty()) { 

      $node = $queue->dequeue(); 

      for ($i = 0; $i < $size; $i++) { 

          if ($matrix[$node][$i] == 1) { 

            $matrix[$node][$i] = 0; 

            $incoming[$i] --; 

            if ($incoming[$i] == 0) { 

                $queue->enqueue($i); 

            } 

          } 

      } 

      $order->enqueue($node); 

    } 

    if ($order->count() != $size) // cycle detected 

      return new SplQueue; 

    return $order; 

} 

从前面的实现中可以看出,我们实际上考虑了我们提到的 Kahn 算法的每一步。我们首先找到了顶点的入度,并将入度为 0 的顶点放入了队列中。然后,我们检查了队列的每个节点,并减少了相邻顶点的入度,并再次将任何入度为 0 的相邻顶点添加到队列中。最后,我们返回了排序后的队列,或者如果有序顶点的计数与实际顶点的计数不匹配,则返回一个空队列。现在,我们可以调用该函数来返回排序后的顶点列表作为队列。以下是执行此操作的代码:

$sorted = topologicalSort($graph);

while (!$sorted->isEmpty()) {

    echo $sorted->dequeue() . "\t";

} 

现在,这将遍历队列中的每个元素并将它们打印出来。输出将如下所示:

    2       1       0 

      3       4

输出符合我们的期望。从之前的图表中可以看出,顶点 2 直接连接到顶点 1 和顶点 3 ,顶点 1 直接连接到顶点 0 和顶点 3 。由于顶点 2 没有入边,我们将从顶点 2 开始进行拓扑排序。顶点 1 有一个入边,顶点 3 有两个入边,所以在顶点 2 之后,我们将按照算法访问顶点 1 。相同的原则将带我们到顶点 0 ,然后是顶点 3 ,最后是顶点 4 。我们还必须记住对于给定的图,可能存在多个拓扑排序。Kahn 算法的复杂度是 O (V+E ),其中 V 是顶点的数量,E 是边的数量。

使用 Floyd-Warshall 算法的最短路径

披萨外卖公司的常见情景是尽快送达披萨。图算法可以帮助我们在这种情况下。Floyd-Warshall 算法是一种非常常见的算法,用于找到从 u 到 v 的最短路径,使用所有顶点对(u, v)。最短路径表示两个相互连接的节点之间的最短可能距离。用于计算最短路径的图必须是加权图。在某些情况下,权重也可以是负数。该算法非常简单,也是最容易实现的之一。它在这里显示:

for i:= 1 to n do 

  for j:= 1 to n do 

     dis[i][j] = w[i][j] 

for k:= 1 to n do 

   for i:= 1 to n do 

      for j:= 1 to n do 

         sum := dis[i][k] + dis[k][j] 

         if (sum < dis[i][j]) 

              dis[i][j] := sum 

首先,我们将每个权重复制到一个成本或距离矩阵中。然后,我们遍历每个顶点,并计算从顶点 i 经过顶点 k 到达顶点 j 的成本或距离。如果距离或成本小于顶点 i 到顶点 j 的直接路径,我们选择路径 ikj 而不是直接路径 ij 。让我们考虑以下图表:

在这里,我们可以看到一个带有每条边权重的无向图。现在,如果我们寻找从 AE 的最短路径,那么我们有以下选项:

  • AE 通过 B 的距离为 20

  • AE 通过 D 的距离为 25

  • AE 通过 DB 的距离为 20

  • AE 通过 BD 的距离为 35

因此,我们可以看到最小距离是 20 。现在,让我们以数值表示顶点,以编程方式实现这一点。我们将使用 0、1、2、3 和 4 代替 A、B、C、D 和 E。现在,让我们用邻接矩阵格式表示之前的图:

$totalVertices = 5; 

$graph = []; 

for ($i = 0; $i < $totalVertices; $i++) { 

    for ($j = 0; $j < $totalVertices; $j++) { 

      $graph[$i][$j] = $i == $j ? 0 : PHP_INT_MAX; 

    }

}

在这里,我们采取了不同的方法,并将所有边初始化为 PHP 整数的最大值。这样做的原因是确保非边的值为 0 不会影响算法逻辑,因为我们正在寻找最小值。现在,我们需要像之前的图表中显示的那样向图中添加权重:

$graph[0][1] = $graph[1][0] = 10;

$graph[2][1] = $graph[1][2] = 5;

$graph[0][3] = $graph[3][0] = 5;

$graph[3][1] = $graph[1][3] = 5;

$graph[4][1] = $graph[1][4] = 10;

$graph[3][4] = $graph[4][3] = 20;

由于这是一个无向图,我们给两条边分配相同的值。如果是有向图,我们只能为每个权重制作一次输入。现在,是时候实现 Floyd-Warshall 算法,以找到任意一对节点的最短路径。这是我们对该函数的实现:

function floydWarshall(array $graph): array {

    $dist = [];

    $dist = $graph;

    $size = count($dist);

    for ($k = 0; $k < $size; $k++)

      for ($i = 0; $i < $size; $i++)

          for ($j = 0; $j < $size; $j++)

        $dist[$i][$j] = min($dist[$i][$j],

    $dist[$i][$k] + $dist[$k][$j]);

    return $dist;

} 

正如我们之前提到的,实现非常简单。我们有三个内部循环来计算最小距离,并且在函数结束时返回距离数组。现在,让我们调用这个函数并检查我们的预期结果是否匹配:

$distance = floydWarshall($graph); 

echo "Shortest distance between A to E is:" . $distance[0][4] . "\n"; 

echo "Shortest distance between D to C is:" . $distance[3][2] . "\n"; 

以下是代码的输出:

Shortest distance between A to E is:20

Shortest distance between D to C is:10

如果我们检查之前的图表,我们可以看到 DC 之间的最短距离实际上是 10 ,路径是 D → B → C (5+5),这是所有可能路线中的最短距离 (D → A → B → C (20),或 D → E → B → C (35))。

Floyd-Warshall 算法的复杂度为 O (V3 ),其中 V 是图中顶点的数量。现在我们将探讨另一个以找到单源最短路径而闻名的算法。

使用 Dijkstra 算法的单源最短路径

我们可以很容易地使用 Floyd-Warshall 算法找到最短路径,但我们无法得到从节点 X 到 Y 的实际路径。这是因为 Floyd-Warshall 算法计算距离或成本,不存储最小成本的实际路径。例如,使用 Google 地图,我们总是可以找到从任何给定位置到目的地的路线。Google 地图可以显示最佳路线,关于距离、旅行时间或其他因素。这是单源最短路径算法使用的完美例子。有许多算法可以找到单源最短路径问题的解决方案;然而,Dijkstra 最短路径算法是最流行的。有许多实现 Dijkstra 算法的方法,例如使用斐波那契堆、最小堆、优先队列等。每种实现都有其自身的优势,关于 Dijkstra 解决方案的性能和改进。让我们来看一下算法的伪代码:

   function Dijkstra(Graph, source):

      create vertex set Q

      for each vertex v in Graph:   

          dist[v] := INFINITY

          prev[v] := UNDEFINED          

          add v to Q         

      dist[source] := 0           

      while Q is not empty:

          u := vertex in Q with min dist[u]

          remove u from Q

          for each neighbor v of u:

              alt := dist[u] + length(u, v)

              if alt < dist[v]:   

                  dist[v] := alt

                  prev[v] := u

      return dist[], prev[]

现在,我们将使用优先队列来实现算法。首先,让我们选择一个图来实现算法。我们可以选择以下无向加权图。它有六个节点,节点和顶点之间有许多连接。首先,我们需要用邻接矩阵表示以下图:

从前面的图表中可以看出,我们的顶点用字母AF标记,因此我们将使用顶点名称作为 PHP 关联数组中的键:

$graph = [

    'A' => ['B' => 3, 'C' => 5, 'D' => 9],

    'B' => ['A' => 3, 'C' => 3, 'D' => 4, 'E' => 7],

    'C' => ['A' => 5, 'B' => 3, 'D' => 2, 'E' => 6, 'F' => 3],

    'D' => ['A' => 9, 'B' => 4, 'C' => 2, 'E' => 2, 'F' => 2],

    'E' => ['B' => 7, 'C' => 6, 'D' => 2, 'F' => 5],

    'F' => ['C' => 3, 'D' => 2, 'E' => 5],

];

现在,我们将使用优先队列来实现 Dijkstra 算法。我们将使用我们为上一个图表创建的邻接矩阵来找到从源顶点到目标顶点的路径。我们的 Dijkstra 算法将返回一个数组,其中包括两个节点之间的最小距离和所遵循的路径。我们将路径返回为一个栈,以便我们可以按相反顺序获取实际路径。以下是实现:

function Dijkstra(array $graph, string $source,string $target):array{ 

    $dist = []; 

    $pred = []; 

    $Queue = new SplPriorityQueue(); 

    foreach ($graph as $v => $adj) { 

      $dist[$v] = PHP_INT_MAX; 

      $pred[$v] = null; 

      $Queue->insert($v, min($adj)); 

    } 

    $dist[$source] = 0; 

    while (!$Queue->isEmpty()) { 

      $u = $Queue->extract(); 

      if (!empty($graph[$u])) { 

          foreach ($graph[$u] as $v => $cost) { 

           if ($dist[$u] + $cost < $dist[$v]) { 

            $dist[$v] = $dist[$u] + $cost; 

            $pred[$v] = $u; 

        } 

          } 

      } 

    } 

    $S = new SplStack();

    $u = $target; 

    $distance = 0;

    while (isset($pred[$u]) && $pred[$u]) {

      $S->push($u);

      $distance += $graph[$u][$pred[$u]];

      $u = $pred[$u]; 

    } 

    if ($S->isEmpty()) { 

      return ["distance" => 0, "path" => $S]; 

    } else {

      $S->push($source);

      return ["distance" => $distance, "path" => $S]; 

    }

}

从前面的实现中可以看出,首先,我们创建了两个数组来存储距离和前任,以及优先队列。然后,我们将每个顶点设置为 PHP 的最大整数(PHP_INT_MAX)值(伪代码中的 INFINITY)和前任为NULL。我们还取了所有相邻节点的最小值并将它们存储在队列中。循环结束后,我们将源节点的距离设置为0。然后我们检查队列中的每个节点,并检查最近的邻居以找到最小路径。如果使用if ($dist[$u] + $cost < $dist[$v])找到了路径,我们将其分配给该顶点。

然后我们创建了一个名为$s的栈来存储路径。我们从目标顶点开始,访问相邻的顶点以到达源顶点。当我们通过相邻的顶点移动时,我们还计算了通过访问这些顶点所覆盖的距离。由于我们的函数返回了距离和路径,我们构造了一个数组来返回给定图、源和目标的距离和路径。如果没有路径存在,我们将返回距离为 0,并返回一个空栈作为输出。现在,我们将写几行代码来使用图$graph和函数Dijkstra来检查我们的实现:

$source = "A"; 

$target = "F"; 

$result = Dijkstra($graph, $source, $target); 

extract($result); 

echo "Distance from $source to $target is $distance \n"; 

echo "Path to follow : "; 

while (!$path->isEmpty()) { 

    echo $path->pop() . "\t"; 

} 

如果我们运行这段代码,它将在命令行中输出以下内容:

Distance from A to F is 8

Path to follow : A      C       F

输出看起来完全正确,从图表中我们可以看到从AF的最短路径是通过C,最短距离是5 + 3 = 8

Dijkstra 算法的运行复杂度为O(V2)。由于我们使用了最小优先队列,运行时复杂度为O(E + V log V)。

使用 Bellman-Ford 算法找到最短路径

尽管 Dijkstra 算法是最流行和高效的用于找到单源最短路径的算法,但它没有解决一个问题。如果图中有一个负循环,Dijkstra 算法无法检测到负循环,因此它无法工作。负循环是一个循环,其中所有边的总和为负。如果一个图包含一个负循环,那么找到最短路径将是不可能的,因此在寻找最短路径时解决这个问题是很重要的。这就是为什么我们使用 Bellman-Ford 算法,尽管它比 Dijkstra 算法慢。以下是 Bellman-Ford 算法寻找最短路径的算法伪代码:

function BellmanFord(list vertices, list edges, vertex source) 

  // This implementation takes a vertex source 

  // and fills distance array with shortest-path information 

  // Step 1: initialize graph 

  for each vertex v in vertices: 

    if v is source 

      distance[v] := 0 

    else 

      distance[v] := infinity 

  // Step 2: relax edges repeatedly 

  for i from 1 to size(vertices)-1: 

    for each edge (u, v) with weight w in edges: 

      if distance[u] + w < distance[v]: 

        distance[v] := distance[u] + w 

  // Step 3: check for negative-weight cycles 

    for each edge (u, v) with weight w in edges: 

        if distance[u] + w < distance[v]: 

      error "Graph contains a negative-weight cycle" 

我们可以看到 Bellman-Ford 算法在寻找节点之间的最短路径时也考虑了边和顶点。这被称为松弛过程,在 Dijkstra 算法中也使用。图算法中的松弛过程是指如果通过V的路径包括V,则更新与顶点V连接的所有顶点的成本。简而言之,松弛过程试图通过另一个顶点降低到达一个顶点的成本。现在,我们将为我们在 Dijkstra 算法中使用的相同图实现这个算法。唯一的区别是这里我们将为我们的节点和顶点使用数字标签:

现在是时候以邻接矩阵格式表示图了。以下是 PHP 中的矩阵:

$graph = [ 

    0 => [0, 3, 5, 9, 0, 0], 

    1 => [3, 0, 3, 4, 7, 0], 

    2 => [5, 3, 0, 2, 6, 3], 

    3 => [9, 4, 2, 0, 2, 2], 

    4 => [0, 7, 6, 2, 0, 5], 

    5 => [0, 0, 3, 2, 5, 0] 

]; 

以前,我们使用值 0 表示两个顶点之间没有边。如果我们在这里做同样的事情,那么在松弛过程中,取两条边中的最小值,其中一条代表 0,将始终产生 0,这实际上意味着两个顶点之间没有连接。因此,我们必须选择一个更大的数字来表示不存在的边。我们可以使用 PHP 的MAX_INT_VALUE常量来表示这些边,以便这些不存在的边不被考虑。这可以成为我们新的图表示:

define("I", PHP_INT_MAX); 

$graph = [ 

    0 => [I, 3, 5, 9, I, I], 

    1 => [3, I, 3, 4, 7, I], 

    2 => [5, 3, I, 2, 6, 3], 

    3 => [9, 4, 2, I, 2, 2], 

    4 => [I, 7, 6, 2, I, 5], 

    5 => [I, I, 3, 2, 5, I] 

]; 

现在,让我们为 Bellman-Ford 算法编写实现。我们将使用在伪代码中定义的相同方法:

function bellmanFord(array $graph, int $source): array { 

    $dist = []; 

    $len = count($graph); 

    foreach ($graph as $v => $adj) { 

      $dist[$v] = PHP_INT_MAX; 

    } 

    $dist[$source] = 0; 

    for ($k = 0; $k < $len - 1; $k++) { 

      for ($i = 0; $i < $len; $i++) { 

          for ($j = 0; $j < $len; $j++) { 

            if ($dist[$i] > $dist[$j] + $graph[$j][$i]) { 

            $dist[$i] = $dist[$j] + $graph[$j][$i]; 

        } 

          } 

      } 

    } 

    for ($i = 0; $i < $len; $i++) { 

      for ($j = 0; $j < $len; $j++) { 

          if ($dist[$i] > $dist[$j] + $graph[$j][$i]) { 

           echo 'The graph contains a negative-weight cycle!'; 

           return []; 

          } 

      } 

        } 

    return $dist; 

} 

与 Dijkstra 算法不同的是,我们不是在跟踪前任。我们在松弛过程中考虑距离。由于我们在 PHP 中使用整数的最大值,它自动取消了选择值为 0 的不存在边作为最小路径的可能性。实现的最后部分检测给定图中的任何负循环,并在这种情况下返回一个空数组:

$source = 0; 

$distances = bellmanFord($graph, $source); 

foreach($distances as $target => $distance) { 

    echo "distance from $source to $target is $distance \n"; 

} 

这将产生以下输出,显示了从我们的源节点到其他节点的最短路径距离:

distance from 0 to 0 is 0

distance from 0 to 1 is 3

distance from 0 to 2 is 5

distance from 0 to 3 is 7

distance from 0 to 4 is 9

distance from 0 to 5 is 8

Bellman-Ford 算法的运行时间复杂度为O(V, E)。

理解最小生成树(MST)

假设我们正在设计一个新的办公园区,其中有多栋建筑相互连接。如果我们考虑每栋建筑之间的互联性,将需要大量的电缆。然而,如果我们能够通过一种共同的连接方式将所有建筑物连接起来,其中每栋建筑物只与其他建筑物通过一个连接相连,那么这个解决方案将减少冗余和成本。如果我们把我们的建筑看作顶点,建筑之间的连接看作边,我们可以使用这种方法构建一个图。我们试图解决的问题也被称为最小生成树MST。考虑以下图。我们有 10 个顶点和 21 条边。然而,我们可以用只有九条边(黑线)连接所有 10 个顶点。这将使我们的成本或距离保持在最低水平:

有几种算法可以用来从给定的图中找到最小生成树。最流行的两种是 Prim 算法和 Kruskal 算法。我们将在接下来的部分探讨这两种算法。

实现 Prim 生成树算法

Prim 算法用于寻找最小生成树依赖于贪婪方法。贪婪方法被定义为一种算法范例,其中我们尝试通过考虑每个阶段的局部最优解来找到全局最优解。我们将在第十一章中探讨贪婪算法,使用高级技术解决问题。在贪婪方法中,算法创建边的子集,并找出子集中成本最低的边。这个边的子集将包括所有顶点。它从任意位置开始,并通过选择顶点之间最便宜的可能连接来逐个顶点地扩展树。让我们考虑以下图:

现在,我们将应用 Prim 算法的一个非常基本的版本,以获得最小生成树以及边的最小成本或权重。图将看起来像这样,作为邻接矩阵:

$G = [ 

    [0, 3, 1, 6, 0, 0], 

    [3, 0, 5, 0, 3, 0], 

    [1, 5, 0, 5, 6, 4], 

    [6, 0, 5, 0, 0, 2], 

    [0, 3, 6, 0, 0, 6], 

    [0, 0, 4, 2, 6, 0] 

]; 

现在,我们将实现 Prim 最小生成树的算法。我们假设我们将从顶点 0 开始找出整个生成树,因此我们只需将图的邻接矩阵传递给函数,它将显示生成树的连接边以及最小成本:

function primMST(array $graph) { 

    $parent = [];   // Array to store the MST 

    $key = [];     // used to pick minimum weight edge         

    $visited = [];   // set of vertices not yet included in MST 

    $len = count($graph); 

    // Initialize all keys as MAX 

    for ($i = 0; $i < $len; $i++) { 

      $key[$i] = PHP_INT_MAX; 

      $visited[$i] = false; 

    } 

    $key[0] = 0; 

    $parent[0] = -1; 

    // The MST will have V vertices 

    for ($count = 0; $count < $len - 1; $count++) { 

  // Pick the minimum key vertex 

  $minValue = PHP_INT_MAX; 

  $minIndex = -1; 

  foreach (array_keys($graph) as $v) { 

      if ($visited[$v] == false && $key[$v] < $minValue) { 

        $minValue = $key[$v]; 

        $minIndex = $v; 

      } 

  } 

  $u = $minIndex; 

  // Add the picked vertex to the MST Set 

  $visited[$u] = true; 

  for ($v = 0; $v < $len; $v++) { 

      if ($graph[$u][$v] != 0 && $visited[$v] == false && 

        $graph[$u][$v] < $key[$v]) { 

          $parent[$v] = $u; 

          $key[$v] = $graph[$u][$v]; 

      } 

  } 

    } 

    // Print MST 

    echo "Edge\tWeight\n"; 

    $minimumCost = 0; 

    for ($i = 1; $i < $len; $i++) { 

      echo $parent[$i] . " - " . $i . "\t" . $graph[$i][$parent[$i]] 

         "\n"; 

      $minimumCost += $graph[$i][$parent[$i]]; 

    } 

    echo "Minimum cost: $minimumCost \n"; 

} 

现在,如果我们用我们的图\(G\)调用函数primMST,则以下将是算法构建的输出和最小生成树:

Edge    Weight

0 - 1   3

0 - 2   1

5 - 3   2

1 - 4   3

2 - 5   4

Minimum cost: 13

还有其他实现 Prim 算法的方法,如使用斐波那契堆、优先队列等。这与 Dijkstra 算法寻找最短路径非常相似。我们的实现具有O()的时间复杂度。使用二叉堆和斐波那契堆,我们可以显著降低复杂度。

Kruskal 算法的生成树

另一个用于寻找最小生成树的流行算法是 Kruskal 算法。它类似于 Prim 算法,并使用贪婪方法来找到解决方案。以下是我们需要实现 Kruskal 算法的步骤:

  1. 创建一个森林T(一组树),图中的每个顶点都是一个单独的树。

  2. 创建一个包含图中所有边的集合S

  3. S非空且T尚未跨越时:

1. 从S中移除权重最小的边。

2. 如果该边连接两棵不同的树,则将其添加到森林中,将两棵树合并成一棵树;否则,丢弃该边。

我们将使用与 Prim 算法相同的图。以下是 Kruskal 算法的实现:

function Kruskal(array $graph): array { 

    $len = count($graph); 

    $tree = []; 

    $set = []; 

    foreach ($graph as $k => $adj) { 

    $set[$k] = [$k]; 

    } 

    $edges = []; 

    for ($i = 0; $i < $len; $i++) { 

      for ($j = 0; $j < $i; $j++) { 

        if ($graph[$i][$j]) { 

          $edges[$i . ',' . $j] = $graph[$i][$j]; 

        } 

    } 

    } 

    asort($edges); 

    foreach ($edges as $k => $w) { 

    list($i, $j) = explode(',', $k); 

    $iSet = findSet($set, $i); 

    $jSet = findSet($set, $j); 

    if ($iSet != $jSet) { 

        $tree[] = ["from" => $i, "to" => $j, 

    "cost" => $graph[$i][$j]]; 

        unionSet($set, $iSet, $jSet); 

    } 

    } 

    return $tree; 

} 

function findSet(array &$set, int $index) { 

    foreach ($set as $k => $v) { 

      if (in_array($index, $v)) { 

        return $k; 

      } 

    } 

    return false; 

} 

function unionSet(array &$set, int $i, int $j) { 

    $a = $set[$i]; 

    $b = $set[$j]; 

    unset($set[$i], $set[$j]); 

    $set[] = array_merge($a, $b); 

} 

正如我们所看到的,我们有两个单独的函数——unionSetfindSet——来执行两个不相交集合的并操作,以及找出一个数字是否存在于集合中。现在,让我们用我们构建的图运行程序:

$graph = [ 

    [0, 3, 1, 6, 0, 0], 

    [3, 0, 5, 0, 3, 0], 

    [1, 5, 0, 5, 6, 4], 

    [6, 0, 5, 0, 0, 2], 

    [0, 3, 6, 0, 0, 6], 

    [0, 0, 4, 2, 6, 0] 

]; 

$mst = Kruskal($graph); 

$minimumCost = 0; 

foreach($mst as $v) { 

    echo "From {$v['from']} to {$v['to']} cost is {$v['cost']} \n"; 

    $minimumCost += $v['cost']; 

} 

echo "Minimum cost: $minimumCost \n"; 

这将产生以下输出,与我们从 Prim 算法得到的输出类似:

From 2 to 0 cost is 1

From 5 to 3 cost is 2

From 1 to 0 cost is 3

From 4 to 1 cost is 3

From 5 to 2 cost is 4

Minimum cost: 13

Kruskal 算法的复杂度是O(E log V),这比通用的 Prim 算法实现更好。

总结

在本章中,我们讨论了不同的图算法及其操作。图在解决各种问题时非常方便。我们已经看到,对于相同的图,我们可以应用不同的算法并获得不同的性能。我们必须仔细选择要应用的算法,这取决于问题的性质。由于某些限制,本书中我们略过了许多其他图的主题。有一些主题,如图着色、二分匹配和流问题,应该在适用的地方进行研究和应用。在下一章中,我们将把重点转移到本书的最后一个数据结构主题,称为堆,学习堆数据结构的不同用法。

第十章:理解和使用堆

堆是一种基于树抽象数据类型的专门数据结构,用于许多算法和数据结构。可以使用堆构建的常见数据结构是优先队列。而基于堆数据结构的最流行和高效的排序算法之一是堆排序。在本章中,我们将讨论堆的属性、不同的堆变体和堆操作。随着我们在本章的进展,我们还将使用 SPL 实现堆。我们现在将在下一节探讨堆及其定义。

什么是堆?

根据定义,堆是一种支持堆属性的专门树数据结构。堆属性被定义为堆结构的根节点要么比其子节点小,要么比其子节点大。如果父节点大于子节点,则称为最大堆,如果父节点小于子节点,则称为最小堆。以下图显示了最大堆的示例:

如果我们看根节点,值100大于两个子节点1936。同样对于19,该值大于173。对3617也适用相同的规则。从树结构中可以看出,树并没有完全排序或有序。但重要的事实是我们总是可以在树的根部找到最大值或最小值,这对于许多用例来说非常高效。

堆结构有许多变体,如二叉堆、b-堆、斐波那契堆、三元堆、treap、弱堆等。二叉堆是堆实现中最流行的之一。二叉堆是一棵完全二叉树,其中树的所有内部级别都是完全填充的。最后一级可以完全填充或部分填充。由于我们考虑的是二叉堆,我们可以在对数时间内执行大多数操作。在本书中,我们将专注于二叉堆的实现和操作。

堆操作

正如我们已经多次提到的,堆是一种专门的树数据结构,我们必须确保首先从给定的项目列表构造一个堆。由于堆具有严格的堆属性,我们必须在每一步满足堆属性。以下是堆的一些核心操作:

  • 创建堆

  • 插入一个新值

  • 从堆中提取最小值或最大值

  • 删除一个值

  • 交换

从给定的项目或数字列表创建堆需要我们确保满足堆属性和二叉树属性。这意味着父节点必须大于或小于子节点,并且对树中的所有节点都成立。而且树必须始终是一棵完全二叉树。在创建堆时,我们从一个节点开始,并将新节点插入堆中。

插入节点操作有一组定义的步骤。我们不能从任意节点开始。插入操作的步骤如下:

  1. 在堆的底部插入新节点。

  2. 检查新节点与父节点值是否按正确顺序。如果它们按正确顺序,则停在那里。

  3. 如果它们不按正确顺序,交换它们并移动到上一步,检查新交换的节点与其父节点。这一步与前一步一起被称为 sift up 或 up-heap,或 bubble-up,或 heapify-up 等。

提取操作(最小或最大)从堆中取出根节点。之后,我们必须执行以下操作,以确保剩余堆的堆属性:

  1. 将堆中的最后一个节点移动为新根。

  2. 将新根节点与子节点进行比较,如果它们按正确顺序,则停止。

  3. 如果不是,则将根节点与子节点交换(对于MinHeap来说是最小子节点,对于MaxHeap来说是最大子节点),并继续进行上一步。这一步和前一步被称为筛选或下沉,或冒泡下沉,或堆化下沉等等。

在堆中,交换是一个重要的操作。在许多情况下,我们必须交换两个节点的两个值,而不影响树的属性。现在我们将使用 PHP 7 实现二叉堆。

在 PHP 中实现二叉堆

实现二叉堆的最流行的方法之一是使用数组。由于堆是完全二叉树,因此可以很容易地使用数组实现。如果我们将根项目视为索引 1,则子项目将位于索引 2 和 3。我们可以将此表示为根为i,左子为2i,右子为2i +1。此外,我们将以我们的示例实现平均堆。因此,让我们从最小堆实现的类结构开始。

首先,我们将创建一个MinHeap类,它将具有两个属性,一个用于存储堆数组,另一个用于任何给定时刻堆中元素的数量。以下是该类的代码:

class MinHeap { 

    public $heap; 

    public $count; 

    public function __construct(int $size) { 

        $this->heap = array_fill(0, $size + 1, 0); 

        $this->count = 0; 

    } 

}

如果我们看一下前面的代码,我们可以看到我们已经将堆数组初始化为从 0 索引到$size + 1的所有 0 值。由于我们考虑将根放在索引 1 处,我们将需要一个带有额外空间的数组。现在我们需要一种方法来从给定数组构建堆。由于我们必须满足堆属性,我们必须向堆中添加一个项目,并使用 C 步骤检查堆属性是否满足。以下是通过一次插入一个项目来创建堆的代码块,以及siftUp过程:

public function create(array $arr = []) { 

    if ($arr) { 

        foreach ($arr as $val) { 

            $this->insert($val); 

        } 

    } 

} 

public function insert(int $i) { 

    if ($this->count == 0) { 

        $this->heap[1] = $i; 

        $this->count = 2; 

    } 

    else { 

        $this->heap[$this->count++] = $i; 

        $this->siftUp(); 

    } 

} 

public function siftUp() { 

    $tmpPos = $this->count - 1; 

    $tmp = intval($tmpPos / 2); 

    while ($tmpPos > 0 &&  

    $this->heap[$tmp] > $this->heap[$tmpPos]) { 

        $this->swap($tmpPos, $tmp); 

        $tmpPos = intval($tmpPos / 2); 

        $tmp = intval($tmpPos / 2); 

    } 

} 

首先,我们使用create方法从数组构建堆。对于数组中的每个元素,我们使用insert方法将其插入堆中。在insert方法中,我们检查堆的当前大小是否为 0。如果当前大小为 0,则将第一个项目添加到索引 1,并将下一个计数器设置为 2。如果堆已经有一个项目,我们将新项目存储在最后一个位置并增加计数器。我们还调用siftUp()方法来确保新插入的值满足堆属性。

siftUp方法中,我们考虑最后一个位置及其父位置进行比较。如果子值小于父值,我们交换它们。我们继续这样做,直到达到顶部的根节点。这个方法确保了如果插入的值在最后是最小的,它将被筛选到树中。但如果不是,树将保持不变。虽然我们已经谈到了交换,但我们还没有看到实现。这里是实现:

public function swap(int $a, int $b) { 

    $tmp = $this->heap[$a]; 

    $this->heap[$a] = $this->heap[$b]; 

    $this->heap[$b] = $tmp; 

}

由于根元素在堆中具有最小值(我们正在实现最小堆)。extract方法将始终返回当前堆的最小值:

    public function extractMin() { 

        $min = $this->heap[1]; 

        $this->heap[1] = $this->heap[$this->count - 1]; 

        $this->heap[--$this->count] = 0; 

        $this->siftDown(1); 

        return $min; 

    }

extractMin方法返回数组的第一个索引,并用数组的最后一个项目替换它。之后,它对新放置的根进行siftDown检查,以确保堆属性。由于我们正在提取根值,我们将最后一个索引值替换为 0,这是我们用于初始化堆数组的值。现在我们将编写extract方法,我们称之为siftDown方法:

public function siftDown(int $k) { 

    $smallest = $k; 

    $left = 2 * $k; 

    $right = 2 * $k + 1; 

    if ($left < $this->count &&  

    $this->heap[$smallest] > $this->heap[$left]) { 

        $smallest = $left; 

    } 

    if ($right < $this->count && $this->heap[$smallest] > $this-  

      >heap[$right]) { 

        $smallest = $right; 

    }

    if ($smallest != $k) {

        $this->swap($k, $smallest); 

        $this->siftDown($smallest); 

    }

} 

我们认为索引$k处的项目是最小值。然后我们将最小值与左右子节点进行比较。如果有更小的值可用,我们将最小值与根节点交换,直到树满足堆属性。这个函数每次需要交换时都会递归调用自己。现在我们需要另一个方法来将当前堆显示为字符串。为此,我们可以编写一个小方法如下:

public function display() { 

    echo implode("\t", array_slice($this->heap, 1)) . "\n"; 

}

现在,如果我们把所有的部分放在一起,我们就有了一个坚实的最小堆实现。让我们现在运行一个测试,看看我们的实现是否满足最小堆的属性。这是我们可以运行的代码,来构建堆并多次从堆中提取最小值:

$numbers = [37, 44, 34, 65, 26, 86, 129, 83, 9]; 

echo "Initial array \n" . implode("\t", $numbers) . "\n"; 

$heap = new MinHeap(count($numbers)); 

$heap->create($numbers); 

echo "Constructed Heap\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

echo "Min Extract: " . $heap->extractMin() . "\n"; 

$heap->display(); 

如果我们运行这段代码,以下输出将显示在终端中:

Initial array

37      44      34      65      26      86      129     83      9

Constructed Heap

9       26      37      34      44      86      129     83      65

Min Extract: 9

26      34      37      65      44      86      129     83      0

Min Extract: 26

34      44      37      65      83      86      129     0       0

Min Extract: 34

37      44      86      65      83      129     0       0       0

Min Extract: 37

44      65      86      129     83      0       0       0       0

Min Extract: 44

65      83      86      129     0       0       0       0       0

Min Extract: 65

83      129     86      0       0       0       0       0       0

从前面的输出中可以看到,当我们构建最小堆时,值为9的最小值在根中。然后我们提取了最小值,我们从堆中取出了9。然后根被下一个最小值26取代,然后是34374465。每次我们取出最小值时,堆都会重新构建以获取最小值。由于我们已经看到了堆数据结构的所有适用操作,现在我们将分析不同堆操作的复杂度。

分析堆操作的复杂度

由于堆实现有不同的变体,复杂度在不同的实现中也会有所不同。堆的一个关键事实是提取操作总是需要O(1)的时间来从堆中获取最大或最小值。由于我们专注于二叉堆实现,我们将看到二叉堆操作的分析:

操作 复杂度 - 平均 复杂度 - 最坏
搜索 O(n) O(n)
插入 O(1) O(log n)
删除 O(log n) O(log n)
提取 O(1) O(1)
空间 O(n) O(n)

由于堆不是完全排序的,搜索操作将比常规二叉搜索树需要更多时间。

使用堆作为优先队列

使用堆数据结构的主要方式之一是创建优先队列。正如我们在第四章中所见,构建栈和队列,优先队列是特殊的队列,其中 FIFO 行为取决于元素的优先级,而不是元素添加到队列的方式。我们已经看到了使用链表和 SPL 的实现。现在我们将探索使用堆和特别是最大堆实现优先队列。

现在我们将使用MaxHeap来实现优先队列。在这里,最大优先级的项目首先从队列中移除。我们的实现将类似于我们上次实现的MinHeap,只是有一点不同。我们希望从 0 开始而不是从 1 开始。因此,左右子节点的计算也会发生变化。这将帮助我们理解使用数组构建堆的两种方法。这是MaxHeap类的实现:

class MaxHeap { 

    public $heap; 

    public $count; 

    public function __construct(int $size) { 

        $this->heap = array_fill(0, $size, 0); 

        $this->count = 0; 

    } 

    public function create(array $arr = []) { 

        if ($arr) { 

            foreach ($arr as $val) { 

                $this->insert($val); 

            } 

        } 

    } 

    public function display() { 

        echo implode("\t", array_slice($this->heap, 0)) . "\n"; 

    } 

    public function insert(int $i) { 

    if ($this->count == 0) { 

        $this->heap[0] = $i; 

        $this->count = 1; 

    } else { 

        $this->heap[$this->count++] = $i; 

        $this->siftUp(); 

    } 

    } 

public function siftUp() { 

    $tmpPos = $this->count - 1; 

    $tmp = intval($tmpPos / 2); 

    while ($tmpPos > 0 && $this->heap[$tmp] < $this->heap[$tmpPos]) { 

        $this->swap($tmpPos, $tmp); 

        $tmpPos = intval($tmpPos / 2); 

        $tmp = intval($tmpPos / 2); 

    } 

} 

public function extractMax() { 

    $min = $this->heap[0]; 

    $this->heap[0] = $this->heap[$this->count - 1]; 

    $this->heap[$this->count - 1] = 0; 

    $this->count--; 

    $this->siftDown(0); 

    return $min; 

} 

public function siftDown(int $k) { 

    $largest= $k; 

    $left = 2 * $k + 1; 

    $right = 2 * $k + 2; 

    if ($left < $this->count  

      && $this->heap[$largest] < $this->heap[$left]) { 

        $largest = $left; 

    } 

    if ($right < $this->count  

      && $this->heap[$largest] < $this->heap[$right]) { 

        $largest = $right; 

    } 

    if ($largest!= $k) { 

        $this->swap($k, $largest); 

        $this->siftDown($largest); 

    } 

} 

    public function swap(int $a, int $b) { 

      $temp = $this->heap[$a]; 

      $this->heap[$a] = $this->heap[$b]; 

      $this->heap[$b] = $temp; 

    }

}

让我们来看看MaxHeap类的实现。我们的MaxHeap实现与上一节的MinHeap实现有一些细微的差异。第一个区别是,对于MaxHeap,我们有一个大小为n的数组,而对于MinHeap,我们有一个大小为n+1的数组。这使得我们对MaxHeap的插入操作从索引 0 开始插入,而在MinHeap中,我们从索引 1 开始。siftUp功能只有在新插入项的值大于即时父值时才将值移至顶部。此外,extractMax方法返回数组中索引 0 的第一个值,即堆中的最大值。一旦我们提取了最大值,我们需要从剩余项中获取最大值并将其存储在索引 0 处。siftDown函数还用于检查左侧或右侧子值是否大于父节点值,并交换值以将最大值存储在父节点处。我们继续递归地执行此操作,以确保在函数调用结束时将最大值存储在根中。如果需要,可以将此MaxHeap实现用作独立的堆实现。由于我们计划使用堆来实现优先级队列,因此我们将添加另一个类来扩展MaxHeap类,以展示优先级队列的特性。让我们探索以下代码:

class PriorityQ extends MaxHeap { 

    public function __construct(int $size) {  

        parent::__construct($size); 

    } 

    public function enqueue(int $val) { 

        parent::insert($val); 

    } 

    public function dequeue() { 

        return parent::extractMax(); 

    }

}

在这里,我们只是扩展了MaxHeap类,并添加了一个包装器,使用insertextractMax进行enqueuedequeue操作。现在让我们用与MinHeap相同的数字运行PriorityQ代码:

$numbers = [37, 44, 34, 65, 26, 86, 129, 83, 9]; 

$pq = new PriorityQ(count($numbers)); 

foreach ($numbers as $number) { 

    $pq->enqueue($number); 

} 

echo "Constructed Heap\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display(); 

echo "DeQueued: " . $pq->dequeue() . "\n"; 

$pq->display();

从前面的代码中可以看出,我们并不是直接从数组构建堆。我们使用优先级队列类将每个数字入队。此外,出队操作将从队列中获取优先级最高的项。如果从命令行运行此代码,将会得到以下输出:

Constructed Heap

129     86      44      83      26      34      37      65      9

DeQueued: 129

86      83      44      65      26      34      37      9       0

DeQueued: 86

83      65      44      9       26      34      37      0       0

DeQueued: 83

65      37      44      9       26      34      0       0       0

DeQueued: 65

44      37      34      9       26      0       0       0       0

DeQueued: 44

37      26      34      9       0       0       0       0       0

DeQueued: 37

34      26      9       0       0       0       0       0       0

从输出中可以看出,MaxHeap实现帮助我们在每次出队操作时获取最大值项。这是实现优先级队列的一种方式。如果需要,我们还可以一次对整个堆进行排序,然后使用排序后的数组作为优先级队列。为此,我们可以实现一个称为堆排序的排序函数。这是计算机编程中最有效和最常用的排序机制之一。现在我们将在下一节中探索这一点。

使用堆排序

堆排序要求我们从给定的元素列表构建堆,然后不断检查堆属性,以使整个堆始终保持排序。与常规堆不同,常规堆在新插入值满足条件后停止检查堆属性,而在堆排序实现过程中,我们继续对下一个元素进行这样的操作。堆排序的伪代码如下:

Heapsort(A as array) 

    BuildHeap(A) 

    for i = n-1 to 0 

        swap(A[0], A[i]) 

        n = n - 1 

        Heapify(A, 0) 

BuildHeap(A as array) 

    n = elements_in(A) 

    for i = floor(n/2) to 0 

        Heapify(A,i) 

Heapify(A as array, i as int) 

    left = 2i+1 

    right = 2i+2 

    max = i 

    if (left <= n) and (A[left] > A[i]) 

        max = left 

    if (right<=n) and (A[right] > A[max]) 

        max = right 

    if (max != i) 

        swap(A[i], A[max]) 

        Heapify(A, max) 

伪代码表明,每当我们尝试对一系列元素进行排序时,起始过程取决于构建堆。每次向堆中添加一个项时,我们都会通过heapify函数检查是否满足堆属性。构建好堆后,我们会检查所有元素的堆属性。现在让我们根据前面的伪代码实现堆排序:

function heapSort(array &$a) { 

    $length = count($a); 

    buildHeap($a); 

    $heapSize = $length - 1; 

    for ($i = $heapSize; $i >= 0; $i--) { 

      $tmp = $a[0]; 

      $a[0] = $a[$heapSize]; 

      $a[$heapSize] = $tmp; 

      $heapSize--; 

      heapify($a, 0, $heapSize); 

    } 

} 

function buildHeap(array &$a) { 

    $length = count($a); 

    $heapSize = $length - 1; 

    for ($i = ($length / 2); $i >= 0; $i--) { 

        heapify($a, $i, $heapSize); 

    } 

} 

function heapify(array &$a, int $i, int $heapSize) { 

    $largest = $i; 

    $l = 2 * $i + 1; 

    $r = 2 * $i + 2; 

    if ($l <= $heapSize && $a[$l] > $a[$i]) { 

        $largest = $l; 

    } 

    if ($r <= $heapSize && $a[$r] > $a[$largest]) { 

        $largest = $r; 

    } 

    if ($largest != $i) { 

      $tmp = $a[$i]; 

      $a[$i] = $a[$largest]; 

      $a[$largest] = $tmp; 

      heapify($a, $largest, $heapSize); 

    } 

} 

现在让我们使用heapSort函数对数组进行排序。由于我们传递的参数是按引用传递的,因此我们不会从函数中返回任何内容。实际数组将在操作结束时排序:

$numbers = [37, 44, 34, 65, 26, 86, 143, 129, 9]; 

heapSort($numbers); 

echo implode("\t", $numbers); 

如果运行此代码,将在命令行中输出以下内容:

9       26      34      37      44      65      86      129     143

如果我们想要将排序改为降序,我们只需要在heapify函数中改变比较。如果我们考虑heapSort算法的时间和空间复杂度,我们会发现堆排序是排序算法中最好的复杂度:

最佳时间复杂度 Ω(nlog(n))
最坏时间复杂度 O(nlog(n))
平均时间复杂度 Θ(nlog(n))
空间复杂度(最坏情况) O(1)

与归并排序相比,堆排序具有更好的空间复杂度。因此,许多开发人员更喜欢使用堆排序来对项目列表进行排序。

使用 SplHeap、SplMaxHeap 和 SplMinHeap

如果我们不想实现自己的堆实现,我们可以使用标准 PHP 库(SPL)中的内置堆类。SPL 有三种不同的堆实现。一种是用于通用堆的SplHeap,一种是用于MaxHeapSplMaxHeap,还有一种是用于MinHeapSplMinHeap。重要的是要知道,SPL 类在 PHP 7 上运行时并不被认为是非常高效的。因此,我们不会在这里详细探讨它们。我们只会专注于一个示例,以便如果我们使用的是 PHP 7 之外的其他版本,我们可以使用这些内置类。让我们尝试使用SplMaxHeap的一个示例:

$numbers = [37, 44, 34, 65, 26, 86, 143, 129, 9]; 

$heap = new SplMaxHeap; 

foreach ($numbers as $number) { 

    $heap->insert($number); 

} 

while (!$heap->isEmpty()) { 

    echo $heap->extract() . "\t"; 

}

由于我们使用了最大堆,我们期望输出是按降序排列的。以下是从命令行输出的结果:

143     129     86      65      44      37      34      26      9

如果我们想以另一种方式进行排序,我们可以使用SplMinHeap

摘要

在本章中,我们学习了另一种高效的数据结构,名为堆。当我们使用堆来实现优先队列时,它们被认为是最大效率的实现。我们还学习了另一种高效的排序方法,名为堆排序,可以通过堆数据结构实现。在这里,我们将总结本书关于数据结构的讨论。在剩下的章节中,我们将专注于高级算法,算法的内置函数和数据结构,以及最后的函数式数据结构。首先,我们将在下一章中探索动态规划的世界。

第十一章:使用高级技术解决问题

到目前为止,我们在本书中已经探讨了不同的数据结构和算法。我们还没有探索一些最激动人心的算法领域。在计算机编程中有许多高效的方法。在本章中,我们将重点关注一些关键的高级技术和概念。这些主题非常重要,以至于可以单独写一本书来讨论它们。然而,我们将专注于对这些高级主题的基本理解。当我们说高级主题时,我们指的是记忆化、动态规划、贪婪算法、回溯、解谜、机器学习等。让我们在接下来的章节中学习一些新颖和激动人心的主题。

记忆化

记忆化是一种优化技术,我们在其中存储先前昂贵操作的结果,并在不重复操作的情况下使用它们。这有助于显著加快解决方案的速度。当我们遇到可以重复子问题的问题时,我们可以轻松地应用这种技术来存储这些结果,并在以后使用它们而不重复步骤。由于 PHP 对关联数组和动态数组属性有很好的支持,我们可以毫无问题地缓存结果。我们必须记住的一件事是,尽管我们通过缓存结果来节省时间,但我们需要更多的内存来存储这些结果。因此,我们必须在空间和内存之间进行权衡。现在,让我们重新访问第五章,应用递归算法-递归,以了解我们生成斐波那契数的递归示例。我们将只需修改该函数,添加一个计数器来知道函数被调用的次数以及函数运行时间来获取第 30 个斐波那契数。以下是此代码:

$start Time = microtime(); 

$count = 0;

function fibonacci(int $n): int { 

    global $count; 

    $count++; 

    if ($n == 0) { 

        return 1; 

    } else if ($n == 1) { 

        return 1; 

    } else { 

        return fibonacci($n - 1) + fibonacci($n - 2); 

    } 

} 

echo fibonacci(30) . "\n"; 

echo "Function called: " . $count . "\n"; 

$endTime = microtime(); 

echo "time =" . ($endTime - $startTime) . "\n";

这将在命令行中产生以下输出。请注意,计时和结果可能会因系统不同或 PHP 版本不同而有所不同。这完全取决于程序运行的位置:

1346269

Function called: 2692537

time =0.531349

第一个数字 1346269 是第 30 个斐波那契数,下一行显示在生成第 30 个数字时fibonacci函数被调用了 2692537 次。整个过程花了 0.5 秒(我们使用了 PHP 的microtime函数)。如果我们要生成第 50 个斐波那契数,函数调用次数将超过 400 亿次。这是一个非常大的数字。然而,我们知道根据斐波那契数列的公式,当我们计算 n 时,我们是通过 n-1 和 n-2 来计算的;这些在之前的步骤中已经计算过了。所以,我们在重复这些步骤,因此,这会浪费我们的时间和效率。现在,让我们将斐波那契结果存储在一个索引数组中,并检查我们要找的斐波那契数是否已经计算过。如果已经计算过,我们将使用它;否则,我们将计算并存储结果。以下是使用相同递归过程生成斐波那契数的修改后的代码,但是借助记忆化:

$startTime = microtime(); 

$fibCache = []; 

$count = 0; 

function fibonacciMemoized(int $n): int { 

    global $fibCache; 

    global $count; 

    $count++; 

    if ($n == 0 || $n == 1) { 

        return 1; 

    } else {

    if (isset($fibCache[$n - 1])) { 

        $tmp = $fibCache[$n - 1]; 

    } else {

        $tmp = fibonacciMemoized($n - 1); 

        $fibCache[$n - 1] = $tmp; 

    } 

    if (isset($fibCache[$n - 2])) { 

        $tmp1 = $fibCache[$n - 2]; 

    } else { 

        $tmp1 = fibonacciMemoized($n - 2); 

        $fibCache[$n - 2] = $tmp1; 

    } 

    return $tmp + $tmp1; 

    } 

} 

echo fibonacciMemoized(30) . "\n"; 

echo "Function called: " . $count . "\n"; 

$endTime = microtime(); 

echo "time =" . ($endTime - $startTime) . "\n"; 

如前面的代码所示,我们引入了一个名为$fibCache的新全局变量,它将存储计算出的斐波那契数。我们还检查我们要查找的数字是否已经在数组中。如果数字已经存储在我们的缓存数组中,我们就不再计算斐波那契数。如果现在运行这段代码,我们将看到以下输出:

1346269

Function called: 31

time =5.299999999997E-5

现在,让我们检查结果。第 30 个斐波那契数与上次相同。但是,看一下函数调用次数。只有 31 次,而不是 270 万次。现在,让我们看看时间。我们只用了 0.00005299 秒,比非记忆化版本快了 10000 倍。

通过一个简单的例子,我们可以看到我们可以通过利用适用的记忆化来优化我们的解决方案。我们必须记住的一件事是,记忆化将在我们有重复的子问题或者我们必须考虑以前的计算来计算当前或未来的计算的情况下更有效。尽管记忆化将占用额外的空间来存储部分计算的数据,但利用记忆化可以大幅提高性能

模式匹配算法

模式匹配是我们日常工作中执行的最常见任务之一。PHP 内置支持正则表达式,大多数情况下,我们依赖正则表达式和内置字符串函数来解决这类问题的常规需求。PHP 有一个名为strops的现成函数,它返回文本中字符串的第一次出现的位置。由于它只返回第一次出现的位置,我们可以尝试编写一个函数,它将返回所有可能的位置。我们首先将探讨蛮力方法,其中我们将检查实际字符串的每个字符与模式字符串的每个字符。以下是将为我们完成工作的函数:

function strFindAll(string $pattern, string $txt): array { 

    $M = strlen($pattern); 

    $N = strlen($txt); 

    $positions = []; 

    for ($i = 0; $i <= $N - $M; $i++) { 

      for ($j = 0; $j < $M; $j++) 

          if ($txt[$i + $j] != $pattern[$j]) 

          break; 

      if ($j == $M) 

          $positions[] = $i; 

  }

    return $positions; 

} 

这种方法非常直接。我们从实际字符串的位置 0 开始,一直进行到$N-$M位置,其中$M是我们要查找的模式的长度。即使在最坏的情况下,模式没有匹配,我们也不需要搜索整个字符串。现在,让我们用一些参数调用函数:

$txt = "AABAACAADAABABBBAABAA"; 

$pattern = "AABA"; 

$matches = strFindAll($pattern, $txt); 

if ($matches) { 

    foreach ($matches as $pos) { 

        echo "Pattern found at index : " . $pos . "\n"; 

    } 

} 

这将产生以下输出:

Pattern found at index : 0

Pattern found at index : 9

Pattern found at index : 16

如果我们查看我们的$txt字符串,我们可以发现我们的模式AABA出现了三次。第一次是在开头,第二次是在中间,第三次是在字符串末尾附近。我们编写的算法将具有O((N - M) * M)的复杂度,其中 N 是文本的长度,M 是我们正在搜索的模式的长度。如果需要,我们可以使用一种称为Knuth-Morris-PrattKMP)字符串匹配算法的流行算法来提高这种匹配的效率。

实现 Knuth-Morris-Pratt 算法

Knuth-Morris-Pratt(KMP)字符串匹配算法与我们刚刚实现的朴素算法非常相似。基本区别在于 KMP 算法使用部分匹配的信息,并决定在任何不匹配时停止匹配。它还可以预先计算模式可能存在的位置,以便我们可以减少重复比较或错误检查的次数。KMP 算法预先计算了一个在搜索操作期间有助于提高效率的表。在实现 KMP 算法时,我们需要计算最长适当前缀后缀LPS)。让我们检查生成 LPS 部分的函数:

function ComputeLPS(string $pattern, array &$lps) { 

    $len = 0; 

    $i = 1; 

    $M = strlen($pattern); 

    $lps[0] = 0; 

    while ($i < $M) { 

    if ($pattern[$i] == $pattern[$len]) { 

        $len++; 

        $lps[$i] = $len; 

        $i++; 

    } else { 

        if ($len != 0) { 

          $len = $lps[$len - 1]; 

        } else { 

          $lps[$i] = 0; 

          $i++; 

        } 

    } 

    } 

}

对于我们之前例子中的模式 AABA,LPS 将是[0,1,0,1];现在,让我们为我们的字符串/模式搜索问题编写 KMP 实现:

function KMPStringMatching(string $str, string $pattern): array { 

    $matches = []; 

    $M = strlen($pattern); 

    $N = strlen($str); 

    $i = $j = 0; 

    $lps = []; 

    ComputeLPS($pattern, $lps); 

    while ($i < $N) { 

    if ($pattern[$j] == $str[$i]) { 

        $j++; 

        $i++; 

    } 

    if ($j == $M) { 

        array_push($matches, $i - $j); 

        $j = $lps[$j - 1]; 

    } else if ($i < $N && $pattern[$j] != $str[$i]) { 

        if ($j != 0) 

        $j = $lps[$j - 1]; 

        else 

        $i = $i + 1; 

    } 

    } 

    return $matches; 

} 

上述代码是 KMP 算法的实现。现在,让我们用我们实现的算法运行以下示例:

$txt = "AABAACAADAABABBBAABAA"; 

$pattern = "AABA"; 

$matches = KMPStringMatching($txt, $pattern); 

if ($matches) { 

    foreach ($matches as $pos) { 

        echo "Pattern found at index : " . $pos . "\n"; 

    }

}

这将产生以下输出:

Pattern found at index : 0

Pattern found at index : 9

Pattern found at index : 16

KMP 算法的复杂度是O(N + M),比常规模式匹配要好得多。这里,O(M)是用于计算 LPS,O(N)是用于 KMP 算法本身。

可以在网上找到许多关于 KMP 算法的详细描述。

贪婪算法

尽管名为贪婪算法,但实际上它是一种专注于在给定时刻找到最佳解决方案的编程技术。这意味着贪婪算法在希望它将导致全局最优解的情况下做出局部最优选择。我们必须记住的一件事是,并非所有贪婪方法都会带我们到全局最优解。然而,贪婪算法仍然应用于许多问题解决领域。贪婪算法最常见的用途之一是哈夫曼编码,它用于对大文本进行编码并通过将其转换为不同的代码来压缩字符串。我们将在下一节中探讨哈夫曼编码的概念和实现。

实现哈夫曼编码算法

哈夫曼编码是一种压缩技术,用于减少发送或存储消息或字符串所需的位数。它基于这样一个想法,即频繁出现的字符将具有较短的位表示,而不太频繁的字符将具有较长的位表示。如果我们将哈夫曼编码视为树结构,则较不频繁的字符或项目将位于树的顶部,而更频繁的项目将位于树的底部或叶子中。哈夫曼编码在很大程度上依赖于优先级队列。哈夫曼编码可以通过首先创建节点树来计算。

创建节点树的过程:

  1. 我们必须为每个符号创建一个叶节点并将其添加到优先级队列。

  2. 当队列中有多个节点时,执行以下操作:

  3. 两次删除优先级最高(概率/频率最低)的节点以获得两个节点。

  4. 创建一个新的内部节点,将这两个节点作为子节点,并且概率/频率等于这两个节点概率/频率的总和。

  5. 将新节点添加到队列中。

  6. 剩下的节点是根节点,树是完整的。

然后,我们必须从根到叶遍历构建的二叉树,在每个节点分配和累积“0”和“1”。每个叶子处累积的零和一构成了这些符号和权重的哈夫曼编码。以下是使用 SPL 优先级队列实现的哈夫曼编码算法:

function huffmanEncode(array $symbols): array { 

    $heap = new SplPriorityQueue; 

    $heap->setExtractFlags(SplPriorityQueue::EXTR_BOTH); 

    foreach ($symbols as $symbol => $weight) { 

        $heap->insert(array($symbol => ''), -$weight); 

    } 

    while ($heap->count() > 1) { 

    $low = $heap->extract(); 

    $high = $heap->extract(); 

    foreach ($low['data'] as &$x) 

        $x = '0' . $x; 

    foreach ($high['data'] as &$x) 

        $x = '1' . $x; 

    $heap->insert($low['data'] + $high['data'],  

            $low['priority'] + $high['priority']); 

    } 

    $result = $heap->extract(); 

    return $result['data']; 

} 

在这里,我们为每个符号构建了一个最小堆,并使用它们的权重来设置优先级。一旦堆构建完成,我们依次提取两个节点,并将它们的数据和优先级组合以将它们添加回堆中。这将继续,直到只剩下一个节点,即根节点。现在,让我们运行以下代码生成哈夫曼编码:

$txt = 'PHP 7 Data structures and Algorithms'; 

$symbols = array_count_values(str_split($txt)); 

$codes = huffmanEncode($symbols); 

echo "Symbol\t\tWeight\t\tHuffman Code\n"; 

foreach ($codes as $sym => $code) { 

    echo "$sym\t\t$symbols[$sym]\t\t$code\n"; 

} 

在这里,我们使用str_split将字符串分割成数组,然后使用数组计数值将其转换为一个关联数组,其中字符将是键,字符串中出现的次数将是值。上述代码将产生以下输出:

Symbol          Weight          Huffman Code

i               1               00000

D               1               00001

d               1               00010

A               1               00011

t               4               001

H               1               01000

m               1               01001

P               2               0101

g               1               01100

o               1               01101

e               1               01110

n               1               01111

7               1               10000

l               1               10001

u               2               1001

 5               101

h               1               11000

c               1               11001

a               3               1101

r               3               1110

s               3               1111

贪婪算法有许多其他实际用途。我们将使用贪婪算法解决作业调度问题。让我们考虑一个敏捷软件开发团队的例子,他们在两周的迭代或冲刺中工作。他们有一些用户故事要完成,这些故事有一些任务的截止日期(按日期)和与故事相关的速度(故事的大小)。团队的目标是在给定的截止日期内获得冲刺的最大速度。让我们考虑以下具有截止日期和速度的任务:

索引 1 2 3 4 5 6
故事 S1 S2 S3 S4 S5 S6
截止日期 2 1 2 1 3 4
速度 95 32 47 42 28 64

从上表中可以看出,我们有六个用户故事,它们有四个不同的截止日期,从 1 到 4。我们必须在时间槽 1 完成用户故事S2S4,因为任务的截止日期是 1。对于故事S1S3也是一样,它们必须在时间槽2之前或之内完成。然而,由于我们有S3,而S3的速度大于S2S4,所以S3将被贪婪地选择为时间槽 1。让我们为我们的速度计算编写贪婪代码:

function velocityMagnifier(array $jobs) { 

     $n = count($jobs); 

    usort($jobs, function($opt1, $opt2) { 

        return $opt1['velocity'] < $opt2['velocity']; 

    }); 

    $dMax = max(array_column($jobs, "deadline")); 

    $slot = array_fill(1, $dMax, -1); 

    $filledTimeSlot = 0; 

    for ($i = 0; $i < $n; $i++) { 

    $k = min($dMax, $jobs[$i]['deadline']); 

    while ($k >= 1) { 

        if ($slot[$k] == -1) { 

          $slot[$k] = $i; 

          $filledTimeSlot++; 

          break; 

        } 

        $k--; 

    } 

      if ($filledTimeSlot == $dMax) { 

          break; 

      } 

    } 

    echo("Stories to Complete: "); 

    for ($i = 1; $i <= $dMax; $i++) { 

        echo $jobs[$slot[$i]]['id']; 

        if ($i < $dMax) { 

            echo "\t"; 

        } 

    } 

    $maxVelocity = 0; 

    for ($i = 1; $i <= $dMax; $i++) { 

        $maxVelocity += $jobs[$slot[$i]]['velocity']; 

    } 

    echo "\nMax Velocity: " . $maxVelocity; 

} 

在这里,我们得到了作业列表(用户故事 ID,截止日期和速度),我们将用它们来找到最大速度及其相应的用户故事 ID。首先,我们使用自定义用户排序函数usort对作业数组进行排序,并根据它们的速度按降序对数组进行排序。之后,我们计算从截止日期列中可用的最大时间槽数。然后,我们将时间槽数组初始化为-1,以保持已使用时间槽的标志。下一个代码块是遍历每个用户故事,并为用户故事找到合适的时间槽。如果可用的时间槽已满,我们就不再继续。现在,让我们使用以下代码块运行此代码:

$jobs = [ 

    ["id" => "S1", "deadline" => 2, "velocity" => 95], 

    ["id" => "S2", "deadline" => 1, "velocity" => 32], 

    ["id" => "S3", "deadline" => 2, "velocity" => 47], 

    ["id" => "S4", "deadline" => 1, "velocity" => 42], 

    ["id" => "S5", "deadline" => 3, "velocity" => 28], 

    ["id" => "S6", "deadline" => 4, "velocity" => 64] 

]; 

velocityMagnifier($jobs); 

这将在命令行中产生以下输出:

Stories to Complete: S3    S1    S5    S6

Max Velocity: 234

贪婪算法可以帮助解决诸如作业调度、网络流量控制、图算法等局部优化问题。然而,要获得全局优化的解决方案,我们需要关注算法的另一个方面,即动态规划。

理解动态规划

动态规划是通过将复杂问题分解为较小的子问题并找到这些子问题的解决方案来解决复杂问题的一种方法。我们累积子问题的解决方案以找到全局解决方案。动态规划的好处是通过存储它们的结果来减少子问题的重新计算。动态规划是优化的一个非常著名的方法。动态规划可以解决问题,如找零钱、找到最长公共子序列、找到最长递增序列、排序 DNA 字符串等。贪婪算法和动态规划的核心区别在于,动态规划总是更倾向于全局优化的解决方案。

如果问题具有最优子结构或重叠子问题,我们可以使用动态规划来解决问题。最优子结构意味着实际问题的优化可以使用其子问题的最优解的组合来解决。换句话说,如果问题对 n 进行了优化,那么对于小于 n 或大于 n 的任何大小,它都将被优化。重叠子问题表示较小的子问题将一遍又一遍地解决,因为它们彼此重叠。斐波那契数列是重叠子问题的一个很好的例子。因此,在这里基本的递归将一点帮助也没有。动态规划只解决每个子问题一次,并且不会尝试进一步解决任何问题。这可以通过自顶向下的方法或自底向上的方法来实现。

在自顶向下的方法中,我们从一个更大的问题开始,递归地解决较小的子问题。然而,我们必须使用记忆化技术来存储子问题的结果,以便将来不必重新计算该子问题。在自底向上的方法中,我们首先解决最小的子问题,然后再转向其他较小的子问题。通常,使用多维数组以表格格式存储子问题的结果。

现在,我们将探讨动态规划世界中的一些例子。有些可能在我们日常编程问题中听起来很熟悉。我们将从著名的背包问题开始。

0-1 背包

背包是一种带有肩带的袋子,通常由士兵携带,以帮助他们在旅途中携带必要的物品或贵重物品。每件物品都有一个价值和确定的重量。因此,士兵必须在其最大重量限制内选择最有价值的物品,因为他们无法把所有东西都放在包里。0/1 表示我们要么可以拿走它,要么留下它。我们不能部分拿走物品。这就是著名的 0-1 背包问题。我们将采用自底向上的方法来解决 0-1 背包问题。以下是解决方案的伪代码:

Procedure knapsack(n, W, w1,...,wN, v1,...,vN) 

for w = 0 to W 

    M[0, w] = 0 

for i = 1 to n 

    for w = 0 to W 

    if wi > w : 

        M[i, w] = M[i-1, w] 

    else : 

        M[i, w] = max (M[i-1, w], vi + M[i-1, w-wi ]) 

return M[n, W] 

end procedure  

例如,如果我们有五个物品,[1,2,3,4,5],它们的重量分别为 10,20,30,40,50,最大允许的重量为 10,将使用自底向上的方法产生以下表:

正如我们所看到的,我们从底部开始构建表格,从一个物品和一个重量开始,逐渐增加到我们想要的重量,并通过选择最佳可能的物品来最大化价值计数。最后,底部右下角的最后一个单元格是 0-1 背包问题的预期结果。以下是运行该函数的实现和代码:

function knapSack(int $maxWeight, array $weights, array $values, int $n) { 

    $DP = []; 

    for ($i = 0; $i <= $n; $i++) { 

      for ($w = 0; $w <= $maxWeight; $w++) { 

          if ($i == 0 || $w == 0) 

          $DP[$i][$w] = 0; 

          else if ($weights[$i - 1] <= $w) 

          $DP[$i][$w] =  

            max($values[$i-1]+$DP[$i - 1][$w - $weights[$i-1]] 

            , $DP[$i - 1][$w]); 

          else 

          $DP[$i][$w] = $DP[$i - 1][$w]; 

        } 

    } 

    return $DP[$n][$maxWeight]; 

} 

$values = [10, 20, 30, 40, 50]; 

$weights = [1, 2, 3, 4, 5]; 

$maxWeight = 10; 

$n = count($values); 

echo knapSack($maxWeight, $weights, $values, $n); 

这将在命令行上显示 100,这实际上与我们从前面的表中预期的结果相匹配。该算法的复杂度为 O(n **W*),其中 n 是物品的数量,W 是目标重量。

查找最长公共子序列-LCS

使用动态规划解决的另一个非常流行的算法是找到两个字符串之间的最长公共子序列或 LCS。这个过程与解决背包问题的过程非常相似,我们有一个二维表格,从一个重量开始移动到我们的目标重量。在这里,我们将从第一个字符串的第一个字符开始,并横跨整个字符串以匹配字符。我们将继续进行,直到第一个字符串的所有字符都与第二个字符串的各个字符匹配。因此,当我们找到匹配时,我们会考虑匹配单元格的左上角单元格或对角线左侧单元格。让我们考虑以下两个表格,以了解匹配是如何发生的:

|

| | | A | B |

| | 0 | 0 | 0 |

| C | 0 | 0 | 0 |

| B | 0 | 0 | 1 |

|

| | | B | D |

| | 0 | 0 | 0 |

| B | 0 | 1 | 1 |

| D | 0 | 1 | 2 |

|

在左侧的表中,我们有两个字符串 AB 和 CB。当 B 在表中匹配 B 时,匹配单元格的值将是其对角线单元格的值加一。这就是为什么第一个表的深色背景单元格的值为 1,因为对角线左侧单元格的值为 0。出于同样的原因,右侧表格的右下角单元格的值为 2,因为对角线单元格的值为 1。以下是查找 LCS 长度的伪代码:

function LCSLength(X[1..m], Y[1..n]) 

    C = array[m][n] 

    for i := 0..m 

       C[i,0] = 0 

    for j := 0..n 

       C[0,j] = 0 

    for i := 1..m 

        for j := 1..n 

            if(i = 0 or j = 0) 

                C[i,j] := 0 

            else if X[i] = Y[j] 

                C[i,j] := C[i-1,j-1] + 1 

            else 

                C[i,j] := max(C[i,j-1], C[i-1,j]) 

    return C[m,n] 

以下是我们的伪代码实现,用于查找 LCS 长度:

function LCS(string $X, string $Y): int { 

    $M = strlen($X); 

    $N = strlen($Y); 

    $L = []; 

    for ($i = 0; $i <= $M; $i++) 

      $L[$i][0] = 0; 

    for ($j = 0; $j <= $N; $j++) 

      $L[0][$j] = 0; 

    for ($i = 0; $i <= $M; $i++) { 

      for ($j = 0; $j <= $N; $j++) {         

          if($i == 0 || $j == 0) 

          $L[$i][$j] = 0; 

          else if ($X[$i - 1] == $Y[$j - 1]) 

          $L[$i][$j] = $L[$i - 1][$j - 1] + 1; 

          else 

          $L[$i][$j] = max($L[$i - 1][$j], $L[$i][$j - 1]); 

      } 

    } 

    return $L[$M][$N]; 

} 

现在,让我们运行LCS函数与两个字符串,看看是否可以找到最长的公共子序列:

$X = "AGGTAB"; 

$Y = "GGTXAYB"; 

echo "LCS Length:".LCS( $X, $Y ); 

这将在命令行中产生输出LCS Length:5。这似乎是正确的,因为两个字符串都有 GGTAB 作为公共子序列。

使用动态规划进行 DNA 测序

我们刚刚看到了如何找到最长公共子序列。使用相同的原理,我们可以实现 DNA 或蛋白质测序,这对我们解决生物信息学问题非常有帮助。为了对齐目的,我们将使用最流行的算法,即 Needleman-Wunsch 算法。它类似于我们的 LCS 算法,但得分系统不同。在这里,我们对匹配、不匹配和间隙进行不同的得分系统。算法有两部分:一部分是计算可能序列的矩阵,另一部分是回溯找到最佳序列。Needleman-Wunsch 算法为任何给定序列提供了最佳的全局对齐解决方案。由于算法本身有点复杂,加上得分系统的解释,我们可以在许多网站或书籍中找到,我们希望把重点放在算法的实现部分。我们将把问题分为两部分。首先,我们将使用动态规划生成计算表,然后我们将向后跟踪以生成实际的序列对齐。对于我们的实现,我们将使用 1 表示匹配,-1 表示间隙惩罚和不匹配得分。以下是我们实现的第一部分:

define("GC", "-"); 

define("SP", 1); 

define("GP", -1); 

define("MS", -1); 

function NWSquencing(string $s1, string $s2) { 

    $grid = []; 

    $M = strlen($s1); 

    $N = strlen($s2); 

    for ($i = 0; $i <= $N; $i++) { 

    $grid[$i] = []; 

      for ($j = 0; $j <= $M; $j++) { 

          $grid[$i][$j] = null; 

      } 

    } 

    $grid[0][0] = 0; 

    for ($i = 1; $i <= $M; $i++) { 

        $grid[0][$i] = -1 * $i; 

    } 

    for ($i = 1; $i <= $N; $i++) { 

        $grid[$i][0] = -1 * $i; 

    } 

    for ($i = 1; $i <= $N; $i++) { 

      for ($j = 1; $j <= $M; $j++) { 

          $grid[$i][$j] = max( 

            $grid[$i - 1][$j - 1] + ($s2[$i - 1] === $s1[$j - 1] ? SP : 

              MS), $grid[$i - 1][$j] + GP, $grid[$i][$j - 1] + GP 

          ); 

      } 

    } 

    printSequence($grid, $s1, $s2, $M, $N); 

} 

在这里,我们创建了一个大小为 M,N 的二维数组,其中 M 是字符串#1 的大小,N 是字符串#2 的大小。我们将网格的第一行和第一列初始化为递减顺序的负值。我们将索引乘以���隙惩罚来实现这种行为。在这里,我们的常数 SP 表示匹配得分点,MS 表示不匹配得分,GP 表示间隙惩罚,GC 表示间隙字符,在序列打印时我们将使用它。在动态规划结束时,矩阵将被生成。让我们考虑以下两个字符串:

$X = "GAATTCAGTTA"; 

$Y = "GGATCGA"; 

然后,运行 Needleman 算法后,我们的表将如下所示:

G A A T T C A G T T A
0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11
G -1 1 0 -1 -2 -3 -4 -5 -6 -7 -8 -9
G -2 0 0 -1 -2 -3 -4 -5 -4 -5 -6 -7
A -3 -1 1 1 0 -1 -2 -3 -4 -5 -6 -5
T -4 -2 0 0 2 1 0 -1 -2 -3 -4 -5
C -5 -3 -1 -1 1 1 2 1 0 -1 -2 -3
G -6 -4 -2 -2 0 0 1 1 2 1 0 -1
A -7 -5 -3 -1 -1 -1 0 2 1 1 0 1

现在,使用这个得分表,我们可以找出实际的序列。在这里,我们将从表中的右下角单元格开始,并考虑顶部单元格、左侧单元格和对角线单元格的值。如果三个单元格中的最大值是顶部单元格,则顶部字符串需要插入间隙字符(-)。如果最大值是对角线单元格,则匹配的可能性更大。因此,我们可以比较两个字符串的两个字符,如果它们匹配,则可以放置一条竖线或管字符来显示对齐。以下是序列函数的样子:

function printSequence($grid, $s1, $s2, $j, $i) { 

    $sq1 = []; 

    $sq2 = []; 

    $sq3 = []; 

    do { 

    $t = $grid[$i - 1][$j]; 

    $d = $grid[$i - 1][$j - 1]; 

    $l = $grid[$i][$j - 1]; 

    $max = max($t, $d, $l); 

    switch ($max) { 

        case $d: 

        $j--; 

        $i--; 

          array_push($sq1, $s1[$j]); 

          array_push($sq2, $s2[$i]); 

          if ($s1[$j] == $s2[$i]) 

              array_push($sq3, "|"); 

          else 

              array_push($sq3, " "); 

        break; 

        case $t: 

        $i--; 

          array_push($sq1, GC); 

          array_push($sq2, $s2[$i]); 

          array_push($sq3, " "); 

        break; 

        case $l: 

          $j--; 

          array_push($sq1, $s1[$j]); 

          array_push($sq2, GC); 

          array_push($sq3, " "); 

        break; 

    } 

    } while ($i > 0 && $j > 0); 

    echo implode("", array_reverse($sq1)) . "\n"; 

    echo implode("", array_reverse($sq3)) . "\n"; 

    echo implode("", array_reverse($sq2)) . "\n"; 

} 

由于我们是从后往前开始,慢慢向前移动,我们使用数组推送来保持对齐顺序。然后,我们通过反转数组来打印数组。算法的复杂度为 O(M*N)。如果我们为我们的两个字符串$X$Y调用NWSquencing,输出将如下所示:

G-AATTCAGTTA

| | | | |  |

GGA-T-C-G--A

回溯解决难题问题

回溯是一种递归算法策略,当找不到结果时我们回溯并继续在其他可能的方式中搜索解决方案。回溯是解决许多著名问题的一种流行方式,尤其是国际象棋、数独、填字游戏等。由于递归是回溯的关键组成部分,我们需要确保我们的问题可以分解为子问题,并将递归应用到这些子问题中。在本节中,我们将使用回溯来解决最受欢迎的游戏之一,数独。

在数独中,我们有一个部分填充的盒子,大小为 3X3。游戏的规则是在每个单元格中放置 1 到 9 的数字,其中相同的数字不能存在于同一行或同一列。因此,在 9X9 单元格中,每个数字 1 到 9 将分别出现一次,每行和每列都是如此。

7 3 8
2 5
4 9 6 1
4 3 2 1
1 5
5 8 6 7
5 1 8 9
5 3
2 9 5

例如,在前面的数独板中,第一列有 4、1、5,第一行有 7、3、8。因此,我们不能在左上角的第一个空单元格中使用这六个数字中的任何一个。因此,可能的数字可以是 2、6 和 9。我们不知道这些数字中的哪一个将满足解决方案。我们可以选择两个数字放在第一个单元格中,然后开始寻找其余空单元格的值。这将持续到所有单元格都填满,或者仍然有一种方法可以在空单元格中放置一个数字而不违反游戏原则。如果没有解决方案,我们将回溯并回到 2,再用下一个可能的选项 6 替换它,并运行相同的递归方式找到其他空单元格的数字。这将持续到解决数独。让我们写一些递归代码来解决数独:

define("N", 9); 

define("UNASSIGNED", 0); 

function FindUnassignedLocation(array &$grid, int &$row,  

int &$col): bool { 

    for ($row = 0; $row < N; $row++) 

      for ($col = 0; $col < N; $col++) 

          if ($grid[$row][$col] == UNASSIGNED) 

          return true; 

    return false; 

} 

function UsedInRow(array &$grid, int $row, int $num): bool { 

    return in_array($num, $grid[$row]); 

} 

function UsedInColumn(array &$grid, int $col, int $num): bool { 

    return in_array($num, array_column($grid, $col)); 

} 

function UsedInBox(array &$grid, int $boxStartRow,  

int $boxStartCol, int $num): bool { 

    for ($row = 0; $row < 3; $row++) 

    for ($col = 0; $col < 3; $col++) 

if ($grid[$row + $boxStartRow][$col + $boxStartCol] == $num) 

        return true; 

    return false; 

} 

function isSafe(array $grid, int $row, int $col, int $num): bool { 

    return !UsedInRow($grid, $row, $num) && 

        !UsedInColumn($grid, $col, $num) && 

        !UsedInBox($grid, $row - $row % 3, $col - $col % 3, $num); 

} 

在这里,我们可以看到实现Sudoku函数所需的所有辅助函数。首先,我们定义了网格的最大大小以及未分配单元格指示符,在这种情况下为 0。我们的第一个函数是在 9X9 网格中查找任何未分配的位置,从左上角单元格开始,逐行搜索空单元格。然后,我们有三个函数来检查数字是否在特定行、列或 3X3 框中使用。如果数字在行、列或框中没有使用,我们可以将其用作单元格中的可能值,这就是为什么在isSafe函数检查中我们返回 true。如果它在这些地方的任何一个中使用,函数将返回 false。现在,我们准备实现解决数独的递归函数:

function SolveSudoku(array &$grid): bool { 

    $row = $col = 0; 

    if (!FindUnassignedLocation($grid, $row, $col)) 

        return true; // success! no empty space 

    for ($num = 1; $num <= N; $num++) { 

      if (isSafe($grid, $row, $col, $num)) { 

          $grid[$row][$col] = $num; // make assignment 

          if (SolveSudoku($grid)) 

          return true;  // return, if success 

          $grid[$row][$col] = UNASSIGNED;  // failure 

      } 

    } 

    return false; // triggers backtracking 

} 

function printGrid(array $grid) { 

    foreach ($grid as $row) { 

        echo implode("", $row) . "\n"; 

    } 

}

SolveSudoku函数是不言自明的。在这里,我们访问了一个单元格,如果单元格是空的,就在单元格中放入一个临时数字,从 1 到 9 的任意数字。然后,我们检查数字是否在行、列或 3X3 矩阵中是多余的。如果不冲突,我们将数字保留在单元格中并移动到下一个空单元格。我们通过递归来做到这一点,这样如果需要的话,我们可以跟踪回来并在冲突的情况下更改单元格中的值。这将持续到找到解决方案为止。我们还添加了一个printGrid函数,在命令行中打印给定的网格。现在让我们用这个示例数独矩阵运行代码:

$grid = [ 

    [0, 0, 7, 0, 3, 0, 8, 0, 0], 

    [0, 0, 0, 2, 0, 5, 0, 0, 0], 

    [4, 0, 0, 9, 0, 6, 0, 0, 1], 

    [0, 4, 3, 0, 0, 0, 2, 1, 0], 

    [1, 0, 0, 0, 0, 0, 0, 0, 5], 

    [0, 5, 8, 0, 0, 0, 6, 7, 0], 

    [5, 0, 0, 1, 0, 8, 0, 0, 9], 

    [0, 0, 0, 5, 0, 3, 0, 0, 0], 

    [0, 0, 2, 0, 9, 0, 5, 0, 0] 

]; 

if (SolveSudoku($grid) == true) 

    printGrid($grid); 

else 

    echo "No solution exists"; 

我们使用了一个二维数组来表示我们的数独矩阵。如果我们运行代码,它将在命令行中产生以下输出:

297431856

361285497

485976321

743659218

126847935

958312674

534128769

879563142

612794583

或者,如果我们以一个漂亮的数独矩阵呈现,它将看起来像这样:

2 9 7 4 3 1 8 5 6
3 6 1 2 8 5 4 9 7
4 8 5 9 7 6 3 2 1
7 4 3 6 5 9 2 1 8
1 2 6 8 4 7 9 3 5
9 5 8 3 1 2 6 7 4
5 3 4 1 2 8 7 6 9
8 7 9 5 6 3 1 4 2
6 1 2 7 9 4 5 8 3

回溯法可以非常有用地找到解决方案,找到路径或解决游戏问题。有许多关于回溯法的在线参考资料,对我们非常有用。

协同过滤推荐系统

推荐系统今天在互联网上随处可见。从电子商务网站到餐馆、酒店、门票、活动等等,都向我们推荐。我们是否曾经问过自己,他们是如何知道什么对我们最好?他们是如何计算出显示我们可能喜欢的物品的?答案是大多数网站使用协同过滤(CF)来推荐。协同过滤是通过分析其他用户的选择或偏好(协同)来自动预测(过滤)用户兴趣的过程。我们将使用皮尔逊相关方法构建一个简单的推荐系统,在这个方法中,计算两个人之间的相似度得分在-1 到+1 的范围内。如果相似度得分是+1,那么意味着两个人完全匹配。如果相似度得分是 0,那么意味着他们之间没有相似之处,如果得分是-1,那么他们是负相关的。通常,得分大多是分数形式。

皮尔逊相关是使用以下公式计算的:

这里,x表示第一个人的偏好,y 表示第二个人的偏好,N 表示偏好中的项目数,这些项目在xy之间是共同的。现在让我们为达卡的餐馆实现一个样本评论系统。有一些评论者已经评论了一些餐馆。其中一些是共同的,一些不是。我们的工作将是根据其他人的评论为X找到一个推荐。我们的评论看起来像这样:

$reviews = []; 

$reviews['Adiyan'] = ["McDonalds" => 5, "KFC" => 5, "Pizza Hut" => 4.5, "Burger King" => 4.7, "American Burger" => 3.5, "Pizza Roma" => 2.5]; 

$reviews['Mikhael'] = ["McDonalds" => 3, "KFC" => 4, "Pizza Hut" => 3.5, "Burger King" => 4, "American Burger" => 4, "Jafran" => 4]; 

$reviews['Zayeed'] = ["McDonalds" => 5, "KFC" => 4, "Pizza Hut" => 2.5, "Burger King" => 4.5, "American Burger" => 3.5, "Sbarro" => 2]; 

$reviews['Arush'] = ["KFC" => 4.5, "Pizza Hut" => 3, "Burger King" => 4, "American Burger" => 3, "Jafran" => 2.5, "FFC" => 3.5]; 

$reviews['Tajwar'] = ["Burger King" => 3, "American Burger" => 2, "KFC" => 2.5, "Pizza Hut" => 3, "Pizza Roma" => 2.5, "FFC" => 3]; 

$reviews['Aayan'] = [ "KFC" => 5, "Pizza Hut" => 4, "Pizza Roma" => 4.5, "FFC" => 4]; 

现在,基于这个结构,我们可以编写我们的皮尔逊相关计算器之间的计算。这是实现:

function pearsonScore(array $reviews, string $person1, string $person2): float { 

$commonItems = array(); 

foreach ($reviews[$person1] as $restaurant1 => $rating) { 

    foreach ($reviews[$person2] as $restaurant2 => $rating) { 

        if ($restaurant1 == $restaurant2) { 

          $commonItems[$restaurant1] = 1; 

        } 

    } 

} 

$n = count($commonItems); 

if ($n == 0) 

    return 0.0; 

    $sum1 = 0; 

    $sum2 = 0; 

    $sqrSum1 = 0; 

    $sqrSum2 = 0; 

    $pSum = 0; 

    foreach ($commonItems as $restaurant => $common) { 

      $sum1 += $reviews[$person1][$restaurant]; 

      $sum2 += $reviews[$person2][$restaurant]; 

      $sqrSum1 += $reviews[$person1][$restaurant] ** 2; 

      $sqrSum2 += $reviews[$person2][$restaurant] ** 2; 

      $pSum += $reviews[$person1][$restaurant] *  

      $reviews[$person2][$restaurant]; 

    } 

    $num = $pSum - (($sum1 * $sum2) / $n); 

    $den = sqrt(($sqrSum1 - (($sum1 ** 2) / $n))  

      * ($sqrSum2 - (($sum2 ** 2) / $n))); 

    if ($den == 0) { 

      $pearsonCorrelation = 0; 

    } else { 

      $pearsonCorrelation = $num / $den; 

    } 

 return (float) $pearsonCorrelation; 

} 

在这里,我们刚刚实现了我们为皮尔逊相关计算器所展示的方程。现在,我们将根据皮尔逊得分编写推荐函数:

function getRecommendations(array $reviews, string $person): array { 

    $calculation = []; 

    foreach ($reviews as $reviewer => $restaurants) { 

    $similarityScore = pearsonScore($reviews, $person, $reviewer); 

        if ($person == $reviewer || $similarityScore <= 0) { 

            continue; 

        } 

        foreach ($restaurants as $restaurant => $rating) { 

            if (!array_key_exists($restaurant, $reviews[$person])) { 

                if (!array_key_exists($restaurant, $calculation)) { 

                    $calculation[$restaurant] = []; 

                    $calculation[$restaurant]['Total'] = 0; 

                    $calculation[$restaurant]['SimilarityTotal'] = 0; 

                } 

            $calculation[$restaurant]['Total'] += $similarityScore * 

              $rating; 

            $calculation[$restaurant]['SimilarityTotal'] += 

              $similarityScore; 

            } 

        } 

    } 

    $recommendations = []; 

    foreach ($calculation as $restaurant => $values) { 

    $recommendations[$restaurant] = $calculation[$restaurant]['Total']  

      / $calculation[$restaurant]['SimilarityTotal']; 

    } 

    arsort($recommendations); 

    return $recommendations; 

} 

在前面的函数中,我们计算了每个评论者之间的相似度分数,并加权了他们的评论。基于最高分,我们展示了对评论者的推荐。让我们运行以下代码来获得一些推荐:

$person = 'Arush'; 

echo 'Restaurant recommendations for ' . $person . "\n"; 

$recommendations = getRecommendations($reviews, $person); 

foreach ($recommendations as $restaturant => $score) { 

    echo $restaturant . " \n"; 

} 

这将产生以下输出:

Restaurant recommendations for Arush

McDonalds

Pizza Roma

Sbarro

我们可以使用皮尔逊相关评分系统来推荐物品或向用户展示如何获得更好的评论。还有许多其他方法可以使协同过滤工作,但这超出了本书的范围。

使用布隆过滤器和稀疏矩��

稀疏矩阵可以用作高效的数据结构。稀疏矩阵的 0 值比实际值多。例如,一个 100 X 100 的矩阵可能有 10,000 个单元。现在,在这 10,000 个单元中,只有 100 个有值;其余都是 0。除了这 100 个值,其余的单元都被默认值 0 占据,并且它们占据相同的字节大小来存储值 0 以表示空单元。这是对空间的巨大浪费,我们可以使用稀疏矩阵来减少它。我们可以使用不同的技术将值存储到稀疏矩阵中的一个单独的矩阵中,这将非常精简并且不会占用任何不必要的空间。我们还可以使用链表来表示稀疏矩阵。这是稀疏矩阵的一个例子:

|

| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |

| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |

| 0 | 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 |

| 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |

| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |

| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 0 | 0 |

| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |

| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |

|

| | | |

| 0 | 5 | 1 |

| 1 | 0 | 1 |

| 2 | 4 | 2 |

| 3 | 2 | 2 |

| 4 | 6 | 1 |

| 5 | 7 | 2 |

| 6 | 6 | 1 |

| 7 | 1 | 1 |

|

由于 PHP 数组的性质是动态的,因此在 PHP 中稀疏矩阵的最佳方法将只使用具有值的索引;其他索引根本不使用。当我们使用单元格时,我们可以检查单元格是否有任何值;否则,将使用默认值 0,就像下面的例子所示:

$sparseArray = []; 

$sparseArray[0][5] = 1; 

$sparseArray[1][0] = 1; 

$sparseArray[2][4] = 2; 

$sparseArray[3][2] = 2; 

$sparseArray[4][6] = 1; 

$sparseArray[5][7] = 2; 

$sparseArray[6][6] = 1; 

$sparseArray[7][1] = 1; 

function getSparseValue(array $array, int $i, int $j): int { 

    if (isset($array[$i][$j])) 

        return $array[$i][$j]; 

    else 

        return 0; 

} 

echo getSparseValue($sparseArray, 0, 2) . "\n"; 

echo getSparseValue($sparseArray, 7, 1) . "\n"; 

echo getSparseValue($sparseArray, 8, 8) . "\n"; 

这将在命令行中产生以下输出:

0

1

0

当我们有一个大型数据集时,在数据集中查找可能非常耗时和昂贵。假设我们有 1000 万个电话号码的数据集,我们想要搜索一个特定的电话号码。这可以很容易地通过数据库查询来完成。但是,如果是 10 亿个电话号码呢?从数据库中查找仍然会更快吗?这样一个庞大的数据库可能会导致性能下降的查找。为了解决这个问题,一个高效的方法可以是使用布隆过滤器。

布隆过滤器是一种高效的、概率性的数据结构,用于确定特定项是否属于集合。它返回两个值:“可能在集合中”和“绝对不在集合中”。如果一个项不属于集合,布隆过滤器返回 false。但是,如果返回 true,则该项可能在集合中,也可能不在集合中。这个原因在这里描述。

一般来说,布隆过滤器是一个大小为 m 的位数组,所有初始值都是 0。有 k 个不同的“哈希”函数,它将一个项转换为一个哈希整数值,该值被映射到位数组中。这个哈希值可以在 0 到 m 之间,因为 m 是位数组的最大大小。哈希函数类似于 md5,sha1,crc32 等,但它们非常快速和高效。通常在布隆过滤器 fnv,murmur,Siphash 等中使用哈希函数。让我们以初始值为 0 的 16(16+1 个单元)位布隆过滤器为例:

| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |

假设我们有两个哈希函数 k1 和 k2,将我们的项转换为 0 到 16 之间的整数值。让我们要存储在布隆过滤器中的第一个项是“PHP”。然后,我们的哈希函数将返回以下值:

k1("PHP") = 5 

k2("PHP") = 9 

两个哈希函数返回了两个不同的值。现在我们可以在位数组中放置 1 来标记它。位数组现在看起来是这样的:

| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |

现在让我们在列表中添加另一个项,例如“algorithm”。假设我们的哈希函数将返回以下值:

k1("algorithm") = 2 

k2("algorithm") = 5 

由于我们可以看到 5 已经被另一个项标记,我们不���再次标记它。现��,位数组将如下所示:

| 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |

例如,现在,我们想要检查一个名为“error”的项,它被哈希为以下值:

k1("error") = 2 

k2("error") = 9 

正如我们所看到的,我们的哈希函数 k1 和 k2 为字符串“error”返回了一个哈希值,而该值不在数组中。因此,这肯定是一个错误,如果我们���哈希函数只有少数,我们期望会有这样的错误。哈希函数越多,错误就越少,因为不同的哈希函数将返回不同的值。错误率、哈希函数的数量和布隆过滤器的大小之间存在关系。例如,一个包含 5000 个项和 0.0001 错误率的布隆过滤器将需要大约 14 个哈希函数和大约 96000 位。我们可以从在线布隆过滤器计算器(例如krisives.github.io/bloom-calculator/)中获得这样的数字。

总结

在本章中,我们已经看到了许多先进的算法和技术,可以用来解决不同类型的问题。有许多好的资源可供学习这些主题。动态规划是一个如此重要的主题,可以在几章中进行介绍,或者有一个单独的书籍来介绍它。我们试图解释了一些主题,但还有更多可以探索的。您还学习了稀疏矩阵和布隆过滤器,它们可以用于大数据块的高效数据存储。我们可以在需要时使用这些数据结构概念。现在,随着我们接近本书的结尾,我们将用一些可用的库、函数和参考资料来总结我们关于 PHP 7 中数据结构和算法的讨论。

第十二章:PHP 内置对数据结构和算法的支持

PHP 是一种具有丰富预定义函数库的语言,同时也得到了社区的大力支持。无论是算法还是数据结构,PHP 已经为开发人员提供了坚实的内置支持。在本章中,我们将探讨一些内置函数和功能,我们可以在数据结构和算法实现中使用。现在让我们在 PHP 中探索这些功能。

PHP 内置数据结构的功能

PHP 在标准 PHP 库 SPL 中拥有丰富的内置数据结构。在发布 PHP 7 之后,人们认为 SPL 数据结构的实现与旧版 PHP 相比并不是非常“高效”。因此,我们将讨论一个专门设计用于数据结构的新 PECL 扩展。我们还对 PHP 数组有很强的支持,可以用作集合、向量、映射、哈希表、堆栈、队列、集合、字典等等。与数组相比,SPL 相对较新,但仍然以内置功能的多样实现夺得了风头。自 PHP 5.0 以来,SPL 已经与核心 PHP 一起发布,因此不需要额外的扩展或构建。我们已经在第二章中探讨了 PHP 数组的动态特性,理解 PHP 数组。在本章中,我们将列举一些其他可用于 PHP 操作数据结构的有用函数。

使用 PHP 数组

PHP 数组具有更广泛的预定义函数集,使 PHP 数组成为 PHP 最常用的功能之一。我们不会讨论所有可用的 PHP 数组函数。我们将讨论一些对我们在数据结构操作中非常有用的函数。以下是 PHP 数组函数:

  • array_pop:这将弹出数组的最后一个元素,类似于堆栈弹出操作。数组作为引用传递给函数。它只接受一个参数,即数组的名称。

  • array_push:这将一个或多个元素推送到数组的末尾,就像堆栈推送操作一样。我们已经看到我们可以使用 push 一次推送一个元素。在 PHP 数组中,我们可以将多个值推送到当前数组的末尾。数组作为引用传递给函数,如下所示:

$countries = []; 

array_push($countries, 'Bangladesh', 'Bhutan'); 

  • current:每个数组都有一个内部指针来标识它当前所在的位置。最初,它从数组的第一个元素开始。current 函数返回数组的当前指针,并返回当前位置的元素的值。如果我们将数组视为列表,这些内部指针功能将是必需的。

  • prev:prev函数将内部指针向后移动一步。PHP 数组可以作为双向链表工作,prev用于转到前一个指针。

  • next:next函数将内部指针移动到下一个元素。

  • end:end函数将内部数组指针移动到数组的末尾。

  • reset:reset函数将内部数组移动到数组的开头。

  • array_search:这是一个非常有用的函数,用于在数组中搜索元素。如果在数组中找到元素,则返回找到它的相应索引。如果找不到任何内容,它将返回 false。如果有多个具有相同搜索键的元素,则返回第一个出现的索引。我们必须小心,因为此函数在比较过程中也可能返回 0,如果元素在第一个索引中找到。因此,在比较过程中,我们必须使用严格的类型检查来检查布尔值 false。array_search函数接受两个必需的参数,needle 和 haystack。needle 是我们要查找的元素,haystack 是我们要查找元素的数组。例如,如果我们在字典中查找一个单词,那么我们可以将搜索词视为"needle","dictionary"视为 haystack。还有一个可选的第三个参数,它可以为元素启用严格的类型检查。因此,如果设置为 true,则它不仅按值搜索元素,还按类型搜索:

$countries = ["Bangladesh", "Nepal", "Bhutan"]; 

$key = array_search("Bangladesh", $countries); 

if ($key !== FALSE) 

    echo "Found in: " . $key; 

else 

    echo "Not found"; 

这将产生以下输出:

    Found in: 0

如果我们在if条件检查中使用!=,那么结果将显示Not found

  • array_sum:这是另一个方便的 PHP 内置函数,用于获取给定数组的总和。它将返回一个单个的数值,即数组中所有元素的总和。它可以是整数或浮点数。

  • array_map:如果我们想要改变数组的元素具有某种类型的属性,这是一个非常有用的函数。例如,我们想要将数组中的所有文本都变成大写或小写。我们可以使用这个函数来做到这一点,而不是运行一个循环。array_map函数接受两个参数。第一个是可调用的函数,第二个是数组本身。该函数返回修改后的数组,如下所示:

$countries = ["bangladesh", "nepal", "bhutan"]; 

$newCountries = array_map(function($country) { 

    return strtoupper($country); 

}, $countries); 

foreach ($newCountries as $country) 

    echo $country . "\n"; 

或者,我们可以简单地这样写:

$countries = ["bangladesh", "nepal", "bhutan"]; 

$newCountries = array_map('strtoupper', $countries); 

foreach ($newCountries as $country) 

    echo $country . "\n"; 

上述代码对给定数组中的每个单词应用了array_map函数。这两个代码将产生以下输出:

BANGLADESH

NEPAL

BHUTAN

  • array_rand:如果我们需要从给定数组中随机选择一个或多个项目,这个函数非常有用。返回项目数量的默认值为 1,但我们可以随时增加它。

  • array_shift:此函数从数组的开头移除一个元素,这与队列数据结构中的出队操作非常相似。从函数中返回移除的元素:

$countries = ["bangladesh", "nepal", "bhutan"]; 

$top = array_shift($countries); 

echo $top; 

这将在命令行中显示输出bangladesh$countries数组中将只有nepalbhutan

  • array_unshift:此函数在数组的开头添加一个或多个项目,并将现有项目向后移动。

  • shuffle:如果我们需要出于任何原因对数组进行洗牌,我们可以使用这个函数。这个函数对于随机化整个数组非常有用。

  • array_intersect:此函数将两个或多个数组作为参数,并返回第一个数组中的公共项,并查找其他数组中的存在。此函数还保留键。

  • array_diff:此函数计算数组与其他给定数组之间的差异。与array_intersect函数一样,此函数还接受多个数组作为参数,其中第一个参数是基本数组,其他参数用于与其进行区分。

PHP 中有许多有用的数组函数,它们解决了许多现有的数据结构和算法问题。我们可以在 PHP 文档中找到内置数组函数的列表。对于本书的目的,我们将在接下来的章节中探讨一些用于排序的数组函数。对于其他函数,建议进一步阅读 PHP .NET。

SPL 类

毫无疑问,SPL 试图解决 PHP 程序员常见的数据结构实现问题。我们中的许多人在编程时要么害怕要么不愿意实现适当的数据结构。SPL 包含了所有基本数据结构的实现,因此,通过使用内置的类和方法,它使开发人员的生活变得更加轻松。由于 SPL 与 PHP 捆绑在一起,我们不需要单独安装它或为其启用任何扩展。在本节中,我们将简要讨论一些常见的 SPL 类:

  • SplDoublyLinkedList:这个类为我们提供了在不编写大量代码的情况下实现双向链表的选项。尽管它说是双向链表,但我们也可以利用这个类来实现堆栈和队列,方法是在setIteratorMode方法中设置迭代模式。

  • SplStack:SplStack类是SplDoublyLinkedList类的扩展版本,其中包括标准堆栈函数,实际上来自双向链表类。

  • SplQueue:SplQueue类是SplDoublyLinkedList类的扩展版本,其中包括标准队列函数,如enqueuedequeue。但是,这些函数实际上来自双向链表类。

  • SplHeap:这是 PHP 的通用堆实现。SplMaxHeapSplMinHeap是通用堆类的两种实现。

  • SplPriorityQueue:SplPriorityQueue是使用SplMaxHeap实现的,并提供了优先级队列的基本功能。

  • SplFixedArray:正如我们在第二章中所看到的,了解 PHP 数组SplFixedArray可以非常方便地解决内存和性能问题。SplFixedArray以整数作为索引,因此,与通用 PHP 数组相比,它具有更快的读写操作。

  • SplObjectStorage:通常,我们使用整数或字符串键在数组中存储任何内容。这个 SPL 类为我们提供了一种方法,可以根据对象存储值。在对象存储中,我们可以直接使用对象作为映射的键。此外,我们还可以使用这个类来存储对象集合。

内置 PHP 算法

现在,我们将检查 PHP 的一些内置功能,这些功能解决了我们日常操作所需的许多算法实现。我们可以将这些函数分类为数学、字符串、加密和哈希、排序、搜索等。现在我们将探索基数转换算法:

  • base_convert:此函数用于对数字进行基数转换。基数范围限制为 2 到 36。由于基数可以是任何基数并包含字符,因此函数的第一个参数是字符串。以下是该函数的示例:
$baseNumber = "123456754"; 

$newNumber = base_convert($baseNumber, 8, 16); 

echo $newNumber; 

这将产生以下输出:

    14e5dec

  • bin2hex:这将二进制字符串转换为十六进制字符串。它只接受二进制字符串作为参数。

  • bindec:这将二进制字符串转换为十进制数。它只接受二进制字符串作为参数。

  • decbin:这将十进制数转换为二进制字符串。它只接受十进制值作为参数。

  • dechex:这将十进制数转换为十六进制字符串。它只接受十进制值作为参数。

  • decoct:这将十进制数转换为八进制字符串。它只接受十进制值作为参数。

  • hex2bin:这将十六进制字符串转换为二进制字符串。它只接受十六进制字符串作为参数。

  • hexdec:这将十六进制字符串转换为十进制数。它只接受十六进制字符串作为参数。

  • octdec:这将八进制字符串转换为十进制数。它只接受八进制字符串作为参数。

还有许多其他用于不同目的的内置函数。其中最重要的之一是在发送电子邮件或传输层时对文本字符串进行编码和解码。由于我们需要编码并具有解码选项,因此我们不使用单向加密函数。此外,还有许多有用的函数可用于不同的字符串操作。我们现在将探讨这些函数:

  • base64_encode:此函数使用 base64 mime 类型对数据进行编码。通常,编码后的字符串比实际字符串大,并且比实际字符串多占 33%的空间。有时,生成的字符串末尾会有一个或两个等号符号,表示字符串的输出填充。

  • base64_decode:此函数接受一个 base64 编码的字符串,并生成其中的实际字符串。它是我们之前讨论的函数的相反操作。

  • levenshtein:我们面临的最常见问题之一是找出两个文本之间的相似性,例如,用户输入的产品名称在列表中不存在。然而,快速检查显示文本中有拼写错误。为了显示哪个是最接近的匹配字符串或基于最小字符数添加、编辑或删除它们的正确字符串。我们将称之为编辑距离。levenshtein函数或 levenshtein 距离被定义为将第一个字符串转换为第二个字符串所需的最小字符数,包括替换、插入或删除。该函数的复杂度为O(m*n),并且限制是每个字符串的长度必须小于 255 个字符。以下是一个例子:

$inputStr = 'Bingo'; 

$fruites = ['Apple', 'Orange', 'Grapes', 'Banana', 'Water melon', 'Mango']; 

$matchScore = -1; 

$matchedStr = ''; 

foreach ($fruites as $fruit) { 

    $tmpScore = levenshtein($inputStr, $fruit); 

    if ($tmpScore == 0 || ($matchScore < 0 || $matchScore >

     $tmpScore)) { 

     $matchScore = $tmpScore; 

     $matchedStr = $fruit; 

    } 

} 

echo $matchScore == 0 ? 'Exact match found : ' . $matchedStr : 'Did you mean: ' . $matchedStr . '?\n'; 

这将产生以下输出:

    Did you mean: Mango?

该函数的另一个变体通过额外的三个参数,我们可以提供插入、替换和删除操作的成本。这样,我们可以根据成本函数得到最佳结果。

  • similar_text:此函数计算两个字符串之间的相似度。它有一个选项以百分比方式返回相似度。该函数区分大小写,并根据匹配的字符返回相似度分数。以下是一个例子:
$str1 = "Mango"; 

$str2 = "Tango"; 

echo "Match length: " . similar_text($str1, $str2) . "\n"; 

similar_text($str1, $str2, $percent); 

echo "Percentile match: " . $percent . "%"; 

上述代码将产生芒果和探戈之间的百分比匹配。输出如下:

Match length: 4

Percentile match: 80%

  • soundex:这是一个有趣的函数,我们可以使用它找到给定字符串的 soundex 键。这个 soundex 键可以用来从集合中找到类似发音的单词,或者找出两个单词是否发音相似。soundex 键的长度为四个字符,第一个字符是字母,其余三个是数字。以下是一些熟悉单词的 soundex 键:
$word1 = "Pray"; 

$word2 = "Prey"; 

echo $word1 . " = " . soundex($word1) . "\n"; 

echo $word2 . " = " . soundex($word2) . "\n"; 

$word3 = "There"; 

$word4 = "Their"; 

echo $word3 . " = " . soundex($word3) . "\n"; 

echo $word4 . " = " . soundex($word4) . "\n"; 

上述代码将产生以下输出:

Pray = P600

Prey = P600

There = T600

Their = T600

从前面的输出中可以看到,prayprey是不同的单词,但它们具有相似的 soundex 键。在不同的用例中,Soundex 可以非常有用地找出数据库中类似发音的单词。

  • metaphone:Metaphone 是另一个类似于 soundex 的函数,可以帮助我们找到类似发音的单词。两者之间的基本区别在于,metaphone 更准确,因为它考虑了基本的英语发音规则。该函数生成可变长度的 metaphone 键。我们还可以传递第二个参数来限制键的生成长度。以下是一个与 soundex 类似的例子:
$word1 = "Pray"; 

$word2 = "Prey"; 

echo $word1 . " = " . metaphone($word1) . "\n"; 

echo $word2 . " = " . metaphone($word2) . "\n"; 

$word3 = "There"; 

$word4 = "Their"; 

echo $word3 . " = " . metaphone($word3) . "\n"; 

echo $word4 . " = " . metaphone($word4) . "\n"; 

以下是上述代码的输出:

Pray = PR

Prey = PR

There = 0R

Their = 0R

哈希

哈希是现代编程中最重要的方面之一。在数据安全和隐私方面,哈希在计算机密码学中起着关键作用。我们不愿意让我们的数据不安全并对所有人开放。PHP 有几个内置的哈希函数。让我们快速浏览一下它们:

  • md5:这计算给定字符串的 md5 哈希。它将为每个提供的字符串生成 32 个字符的唯一哈希。哈希是单向的,这意味着没有函数可以将哈希字符串解密为实际字符串。

  • sha1:此函数计算给定字符串的 sha1 哈希。生成的哈希长度为 40 个字符。与 md5 一样,sha1 也是一种单向哈希。如果将第二个参数设置为 true,则函数将生成 20 个字符的原始输出哈希字符串。要记住的一件事是 sha1、sha256 和 md5 不足以用于密码哈希。由于它们非常快速和高效,黑客倾向于使用它们进行暴力攻击,并从生成的哈希中找到实际输入。

  • 密码:此函数为给定的字符串生成一个单向哈希键,可选的盐字符串。如果您使用的是 PHP 7,则在函数调用期间未提供任何盐,该函数将产生一个E_NOTICE。对于哈希,该函数使用基于UNIX DES的算法或其他可用于哈希的算法。

  • password_hash:这是另一个有用的函数,用于为密码生成哈希。它需要两个参数,一个包括实际字符串,另一个是哈希算法。默认的哈希算法使用 bcrypt 算法,备选选项是 blowfish 算法。

  • password_verify:如果我们使用password_hash函数生成了密码,则可以使用此函数。函数的第一个参数是输入的密码,第二个参数是哈希字符串。该函数根据验证部分返回 true 或 false。

  • hash_algos:如果我们想知道系统中注册的哈希算法列表,可以使用此函数。这将列出当前系统中哈希算法的所有可能选项。

  • hash:此函数需要一个强制的哈希算法名称以及要进行哈希处理的字符串,以生成一个哈希键。还有一个可选参数,用于获取哈希的原始二进制输出。哈希键的长度将根据所选的算法而变化。

PHP 具有丰富的哈希和加密函数和库的集合。有关更多信息,请参阅 PHP.net 文档,以及下一节中提到的其他一些网站。

通过 PECL 内置支持

自 PHP 7.0 发布以来,开发人员关注的一个问题是 SPL 类的性能问题。PHP 7.0 对早期设计的 SPL 类没有带来任何改进,许多开发人员现在对进一步使用它持怀疑态度。许多开发人员已经为 PHP 编写了自定义库和扩展,以提高数据结构的效率。其中一个扩展就是 PHP DS,这是一个专门为 PHP 7 数据结构设计的扩展。该扩展由 Joe Watkins 和 Rudi Theunissen 编写。PHP DS 扩展的官方文档可以在 PHP 手册的php.net/manual/en/book.ds.php中找到。

该库可作为 PHP 数组的替代品,它是一种非常灵活、动态、混合的数据结构。此扩展提供了许多预构建的数据结构,如集合、映射、序列、集合、向量、堆栈、队列、优先队列等。我们将在接下来的几节中探讨它们。

安装

该库提供了不同的安装选项。最简单的方法是从 PECL(PHP 扩展的存储库)获取它:

pecl install ds

如果需要,我们也可以下载源代码并编译库。为此,我们只需要从 GitHub 存储库获取代码并遵循 git 命令:

clone https://github.com/php-ds/extension "php-ds"

cd php-ds

# Build and install the extension

phpize

./configure

make

make install

# Clean up the build files

make clean

phpize --clean

如果存在任何依赖性问题,我们还必须安装此软件包:

sudo apt-get install git build-essential php7.0-dev

对于 Windows,可以从 PECL 网站下载 DLL。对于 Mac OS 用户,Homebrew 支持安装此扩展:

brew install homebrew/php/php71-ds

安装完成后,我们必须将扩展添加到我们的主要php.ini文件中:

extension=ds.so  #(php_ds.dll for windows)

如果扩展正确添加,所有预构建的类将通过global \DS\ namespace可用。

现在,让我们详细了解此扩展中预构建的 DS 类。我们将从所有类的基础,即集合接口开始。

接口

集合接口是 DS 库中所有类的基本接口。所有数据结构实现都默认实现了集合接口。集合接口确保所有类都具有类似的可遍历、可计数和 JSON 可序列化的行为。集合接口有四个抽象方法,它们是clearcopyisEmptytoArray。DS 类的所有数据结构实现都实现了该接口,我们将在探索这些数据结构时看到这些方法的运作。

数据结构库的另一个重要方面是使用对象作为键。这可以通过库的可哈希接口来实现。还有另一个重要的接口,允许在数据结构类中实现列表功能,并且确保比 SPL 双向链表和固定数组的性能更好。

向量

向量是一种线性数据结构,其中值按顺序存储,大小也会自动增长和缩小。向量是最有效的线性数据结构之一,因为值的索引直接映射到缓冲区的索引,并允许更快的访问。DS 向量类允许我们使用 PHP 数组语法进行操作,但在内部,它的内存消耗比 PHP 数组少。它具有常数时间的 push、pop、get 和 set 操作。以下是向量的一个示例:

$vector = new \Ds\Vector(["a", "b", "c"]); 

echo $vector->get(1)."\n"; 

$vector[1] = "d"; 

echo $vector->get(1)."\n"; 

$vector->push('f'); 

echo "Size of vector: ".$vector->count(); 

从上面的代码可以看出,我们可以使用 PHP 数组语法来定义一个向量,并且也可以使用数组语法来获取或设置值。一个区别是我们不能使用 PHP 数组语法来添加一个新的索引。为此,我们必须使用向量类的 push 方法。尝试设置或获取不存在的索引将导致在运行时抛出OutofRangeException。以下是上述代码的输出:

b

d

Size of vector: 4

映射

映射是键值对的顺序集合。映射类似于数组,键可以是字符串、整数等,但键必须是唯一的。在 DS 映射类中,键可以是任何类型,包括对象。它允许 PHP 数组语法进行操作,同时保留插入顺序。性能和内存效率也类似于 PHP 数组。当大小降低时,它还会自动释放内存。如果我们考虑下面的性能图表,我们可以看到 DS 库中的映射实现在从大数组中移除项目时比 PHP 数组快得多:

集合

集合也是一个序列,但集合只能包含唯一的值。集合可以存储任何值,包括对象,并且支持数组语法。它保留插入顺序,并且在大小降低时也会自动释放内存。我们可以在常数时间内实现添加、删除和包含操作。然而,这个集合类不支持 push、pop、shift、insert 和 unshift 函数。集合类内置了一些非常有用的集合操作函数,如 diff、intersect、union 等。以下是集合操作的一个示例:

$set = new \Ds\Set(); 

$set->add(1); 

$set->add(1); 

$set->add("test"); 

$set->add(3); 

echo $set->get(1);

在前面的示例代码中,1的条目只会有一个,因为集合不能有重复的值。另外,当我们获取1的值时,这表示在索引1处的值。因此,输出将是前面示例的测试。这里可能会有一个问题,为什么我们不在这里使用array_unique来构建一个集合。下面的比较图表可能是我们正在寻找的答案:

从上面的图表可以看出,随着数组大小的增长,array_unique 函数的计算时间将比我们在 DS 库中的set类更长。此外,随着大小的增长,set类所占用的内存也比 PHP 数组少:

栈和队列

DS 库还实现了栈和队列数据结构。DS\Stack 内部使用 DS\VectorDS\Queue 内部使用 DS\Deque。与 SPL 实现的栈和队列相比,这两种实现的性能相似。以下图表显示了这一点:

双端队列

双端队列(发音为 deck),或双端队列,用于DS\Queue内部实现。此软件包中的双端队列实现在内存使用上非常高效,并且在常数时间内执行 get、set、push、pop、shift 和 unshift 操作。然而,DS\Deque的一个缺点是插入或删除操作的复杂度为O(n)。以下是DS\Deque和 SPL 双向链表的性能比较:

优先队列

您已经了解到优先队列对于许多算法非常重要。拥有高效的优先队列对我们来说也非常重要。到目前为止,我们已经看到我们可以使用堆来实现自己的优先队列,或者使用 SPL 优先队列来解决问题。然而,DS\PriorityQueueSplPriorityQueue快两倍以上,并且仅使用其内存的百分之五。这使得DS\PriorityQueueSplPriorityQueue内存效率高 20 倍。以下图表显示了比较:

从我们在最近几节的讨论中,我们可以得出结论,DS 扩展对于数据结构来说非常高效,与 SPL 相比有着更好的性能。尽管基准测试可能会因平台和内部配置的不同而有所变化,但它表明新的 DS 扩展是有前途的,可能对开发人员非常有帮助。需要记住的一点是,该库尚未内置堆或树数据结构,因此我们无法从该库中获得内置的分层数据结构。

更多信息,请查看以下文章,比较图表来自这里:medium.com/@rtheunissen/efficient-data-structures-for-php-7-9dda7af674cd

总结

PHP 拥有丰富的内置函数集,而且这个列表每天都在增长。在本章中,我们探讨了一些可以用于实现数据结构和算法的定义函数。还有许多其他外部库可供使用。我们可以根据自己的喜好选择任何内部或外部库。此外,还有许多在线资源可以了解数据结构和算法的概念。您还了解了 PHP 7 中 SPL 类的性能问题,并介绍了 PHP 7 数据结构的新库。我们必须记住,数据结构和算法并不是与语言无关的。我们可以使用不同的语言或同一语言的不同版本来实现相同的数据结构和算法。在下一章中,我们将探索编程的另一个领域,这在目前非常流行,即函数式编程。因此,接下来,我们将专注于 PHP 的函数式数据结构。

第十三章:使用 PHP 的函数式数据结构

近年来,对函数式编程语言的需求超过了面向对象编程。其中一个核心原因是函数式编程具有固有的并行性。虽然面向对象编程被广泛使用,但函数式编程在近年来也有相当大的影响。因此,像 Erlang、Elixir、Clojure、Scala 和 Haskell 这样的语言是程序员最受欢迎的函数式编程语言。PHP 不在这个列表上,因为 PHP 被认为是一种命令式和面向对象的语言。尽管 PHP 对函数式编程有很多支持,但它主要用于面向对象编程和命令式编程。FP 的核心精髓是 λ 演算,它表示一种数学逻辑和计算机科学中的形式系统,用于通过变量绑定和替换来表达计算。它不是一个框架或一个新概念。事实上,函数式编程早于所有其他编程范式。它已经存在很长时间,将来也会存在,因为世界需要更多的并发计算和更快的处理语言。在本章中,您将学习如何使用 PHP 实现函数式编程和数据结构。

使用 PHP 理解函数式编程

与任何面向对象编程语言不同,其中一切都通过对象表示,函数式编程开始思考一切都是函数。OOP 和 FP 不是相互排斥的。虽然 OOP 侧重于通过封装和继承实现代码的可维护性和可重用性,但与面向状态的命令式编程不同,函数式编程侧重于以值为导向的编程,将计算视为纯数学评估,并避免可变性和状态修改。在使用 OOP 时,其中一个挑战是我们创建的对象可能会带来许多额外的属性或方法,无论我们是否在特定情况下使用它。以下是函数式编程的三个关键特征:

  • 不可变性

  • 纯函数和引用透明度

  • 头等公民函数

  • 高阶函数

  • 函数组合(柯里化)

不可变性的概念告诉我们,一个对象在创建后不会改变。它在整个生命周期内保持不变。这有一个很大的优势,因为我们在使用对象时不需要重新验证对象。此外,如果需要可变性,我们可以创建对象的副本或创建具有新属性的新对象。

到目前为止,在这本书中,我们看到了很多使用代码块、循环和条件的数据结构和算法的例子。一般来说,这被称为命令式编程,其中期望定义执行的每一步。例如,考虑以下代码块:

$languages = ["php", "python", "java", "c", "erlang"];

foreach ($languages as $ind => $language) {

    $languages[$ind] = ucfirst($language);

}

前面的代码实际上将每个名称的第一个字符设置为大写。从逻辑上讲,代码是正确的,我们已经逐步呈现了它,以便我们理解发生了什么。然而,这可以使用函数式编程方法写成一行代码。

$languages = array_map('ucfirst', $languages);

这两种方法都是做同样的事情,但一种比另一种代码块要小。后者被称为声明式编程。而命令式编程侧重于算法和步骤,声明式编程侧重于函数的输入和输出以及递归(而不是迭代)。

函数式编程的另一个重要方面是它不受任何副作用的影响。这是一个重要的特性,它确保函数不会在输入的任何地方产生任何隐式影响。函数式编程的一个常见例子是在 PHP 中对数组进行排序。通常,参数是通过引用传递的,当我们得到排序后的数组时,它实际上破坏了初始数组。这是函数中副作用的一个例子。

在跳入 PHP 的函数式编程之前,让我们探索一些函数式编程术语,我们将在接下来的部分中遇到。

一流函数

具有一流函数的语言允许以下行为:

  • 将函数分配给变量

  • 将它们作为参数传递给另一个函数

  • 返回一个函数

PHP 支持所有这些行为,因此 PHP 函数是一流函数。在我们之前的例子中,ucfirst函数就是一流函数的一个例子。

高阶函数

高阶函数可以将一个或多个函数作为参数,并且还可以返回一个函数作为结果。PHP 也支持高阶函数;我们之前的例子中的array_map就是一个高阶函数。

纯函数

纯函数

Lambda 函数

Lambda 函数或匿名函数是没有名称的函数。当作为一流函数(分配给变量)使用时,或者用于回调函数时,它们非常方便,我们可以在调用参数的位置定义函数。PHP 也支持匿名函数。

闭包

闭包与 Lambda 函数非常相似,但基本区别在于闭包可以访问其外部作用域变量。在 PHP 中,我们无法直接访问外部作用域变量。为了做到这一点,PHP 引入了关键字"use",以将任何外部作用域变量传递给内部函数。

柯里化

柯里化是一种将接受多个参数的函数转换为一系列函数的技术,其中每个函数将只接受一个参数。换句话说,如果一个函数可以写成f(x,y,z),那么它的柯里化版本将是f(x)(y)(z)。让我们考虑以下例子:

function sum($a, $b, $c) {

    return $a + $b + $c;

}

在这里,我们编写了一个带有三个参数的简单函数,当用数字调用时,它将返回数字的。现在,如果我们将这个函数写成柯里化形式,它将如下所示:

function currySum($a) { 

    return function($b) use ($a) { 

        return function ($c) use ($a, $b) { 

            return $a + $b + $c; 

        }; 

    }; 

} 

$sum = currySum(10)(20)(30); 

echo $sum;

现在,如果我们将currySum作为柯里化函数运行,我们将得到前面例子中的结果 60。这是函数式编程非常有用的特性。

在 PHP 中以前不可能像f(a)(b)(c)这样调用一个函数。自 PHP 7.0 以来,统一变量语法允许立即执行可调用函数,就像我们在这个例子中看到的那样。然而,在 PHP 5.4 和更高版本中,为了做到这一点,我们必须创建临时变量来存储 Lambda 函数。

部分应用

部分应用或部分函数应用是一种技术,可以减少函数的参数数量,或者使用部分参数并创建另一个函数来处理剩余的参数,以产生与一次性调用所有参数时相同的输出。如果我们将我们的sum函数视为部分应用,它预期接受三个参数,但我们可以用两个参数调用它,然后再添加剩下的一个。以下是代码示例。在这个例子中使用的sum函数来自前面的部分:

function partial($funcName, ...$args) { 

    return function(...$innerArgs) use ($funcName, $args) { 

        $allArgs = array_merge($args, $innerArgs); 

        return call_user_func_array($funcName, $allArgs); 

    }; 

} 

$sum = partial("sum", 10, 20); 

$sum = $sum(30); 

echo $sum;

有时,我们会对柯里化和部分应用感到困惑,尽管它们在方法和原则上完全不同。

正如我们所看到的,处理 PHP 中的函数式编程时需要考虑的事情很多。使用函数式编程从头开始实现数据结构将是一个更加冗长的过程。为了解决这个问题,我们将探索一个名为Tarsana的 PHP 优秀函数式编程库。它是开源的,并且使用 MIT 许可证。我们将探索这个库,并将其作为我们在 PHP 中实现函数式数据结构的基础。

开始使用 Tarsana

Tarsana 是由 Amine Ben Hammou 编写的开源库,可在 GitHub 上下载。它受到 JavaScript 的函数式编程库 Ramda JS 的启发。它没有任何依赖关系,有 100 多个预定义的函数可用于不同的目的。FP 中的函数分布在不同的模块中,有几个模块,如函数、列表、对象、字符串、数学、运算符和常见模块。Tarsana 可以从 GitHub(github.com/Tarsana/functional)下载,也可以通过 composer 安装。

composer require Tarsana/functional

一旦库被下载,我们必须通过导入Tarsana\Functional命名空间来使用它,就像以下代码一样:

use Tarsana\Functional as F; 

Tarsana 的一个有趣特性是,我们可以将我们现有的任何函数转换为柯里化函数。例如,如果我们想使用我们的sum函数使用 Tarsana,那么它将如下所示:

require __DIR__ . '/vendor/autoload.php'; 

use Tarsana\Functional as F; 

$add = F\curry(function($x, $y, $z) { 

    return $x + $y + $z; 

}); 

echo $add(1, 2, 4)."\n"; 

$addFive = $add(5); 

$addSix = $addFive(6); 

echo $addSix(2); 

这将分别产生 7 和 13 的输出。Tarsana 还有一个选项,可以使用__()函数来保留占位符。以下示例显示了在占位符中提供的条目的数组减少和数组求和:

$reduce = F\curry('array_reduce'); 

$sum = $reduce(F\__(), F\plus()); 

echo $sum([1, 2, 3, 4, 5], 0);

Tarsana 还提供了管道功能,可以从左到右应用一系列函数。最左边的函数可以有任何 arity;其余的函数必须是一元的。管道的结果不是柯里化的。让我们考虑以下示例:

$square = function($x) { return $x * $x; }; 

$addThenSquare = F\pipe(F\plus(), $square); 

echo $addThenSquare(2, 3);

由于我们已经探索了 Tarsana 的一些功能,我们准备开始使用 Tarsana 来开始我们的函数式数据结构。我们还将使用简单的 PHP 函数来实现这些数据结构,这样我们两方面都覆盖到了,如果我们不想使用函数式编程。让我们开始实现堆栈。

实现堆栈

我们已经在第四章中看到了堆栈的实现,构建堆栈和队列。为简单起见,我们不会再讨论整个堆栈操作。我们将直接进入使用函数式编程实现推送、弹出和顶部操作。Tarsana 有许多用于列表操作的内置函数。我们将使用它们的内置函数来实现堆栈的功能。以下是实现方式:

require __DIR__ . '/vendor/autoload.php'; 

use Tarsana\Functional as F; 

$stack = []; 

$push = F\append(F\__(), F\__()); 

$top = F\last(F\__()); 

$pop = F\init(F\__()); 

$stack = $push(1, $stack); 

$stack = $push(2, $stack); 

$stack = $push(3, $stack); 

echo "Stack is ".F\toString($stack)."\n"; 

$item = $top($stack); 

$stack = $pop($stack); 

echo "Pop-ed item: ".$item."\n"; 

echo "Stack is ".F\toString($stack)."\n"; 

$stack = $push(4, $stack); 

echo "Stack is ".F\toString($stack)."\n"; 

在这里,我们使用 Tarsana 的 append 函数进行推送操作,在这里我们使用的是 top 操作的 last 函数,以及 pop 操作的init函数。以下代码的输出如下:

Stack is [1, 2, 3]

Pop-ed item: 3

Stack is [1, 2]

Stack is [1, 2, 4]

实现队列

我们可以使用 Tarsana 和列表操作的内置函数来实现队列。我们还将使用这段代码来使用数组表示队列:

require __DIR__ . '/vendor/autoload.php'; 

use Tarsana\Functional as F; 

$queue = []; 

$enqueue = F\append(F\__(), F\__()); 

$head = F\head(F\__()); 

$dequeue = F\tail(F\__()); 

$queue = $enqueue(1, $queue); 

$queue = $enqueue(2, $queue); 

$queue = $enqueue(3, $queue); 

echo "Queue is ".F\toString($queue)."\n"; 

$item = $head($queue); 

$queue = $dequeue($queue); 

echo "Dequeue-ed item: ".$item."\n"; 

echo "Queue is ".F\toString($queue)."\n"; 

$queue = $enqueue(4, $queue); 

echo "Queue is ".F\toString($queue)."\n"; 

在这里,我们使用append函数来执行入队操作,使用headtail函数分别获取队列中的第一个项目和出队操作。以下是前面代码的输出:

Queue is [1, 2, 3]

Dequeue-ed item: 1

Queue is [2, 3]

Queue is [2, 3, 4]

现在,我们将把重点转移到使用简单的 PHP 函数来实现分层数据,而不是使用类和对象。由于函数式编程在 PHP 中仍然是一个新话题,实现分层数据可能看起来具有挑战性,而且也很耗时。相反,我们将使用基本的 PHP 函数以及一些基本的函数式编程概念,如一等函数和高阶函数来转换我们的分层数据实现。因此,让我们实现一个二叉树。

实现树

我们将使用一个简单的递归函数来使用 PHP 数组实现二叉树的遍历。我们只是使用一个函数重写了功能,而不是一个类。以下是实现此功能的代码:

function treeTraverse(array &$tree, int $index = 0,

int $level = 0, &$outputStr = "") : ?bool {

    if(isset($tree[$index])) {

        $outputStr .= str_repeat("-", $level); 

        $outputStr .= $tree[$index] . "\n";

        treeTraverse($tree, 2 * $index + 1, $level+1,$outputStr);      

        treeTraverse($tree, 2 * ($index + 1), $level+1,$outputStr);

    } else { 

        return false; 

    }

 return null; 

} 

        $nodes = []; 

        $nodes[] = "Final"; 

        $nodes[] = "Semi Final 1"; 

        $nodes[] = "Semi Final 2"; 

        $nodes[] = "Quarter Final 1"; 

        $nodes[] = "Quarter Final 2"; 

        $nodes[] = "Quarter Final 3"; 

        $nodes[] = "Quarter Final 4"; 

        $treeStr = ""; 

        treeTraverse($nodes,0,0,$treeStr); 

        echo $treeStr; 

如果我们看一下前面的代码,我们只是修改了遍历函数,并将其转换为一个独立的函数。这是一个纯函数,因为我们在这里没有修改实际的输入,即$nodes变量。我们将在每个级别构造一个字符串,并将其用于输出。现在,我们可以将大部分基于类的结构转换为基于函数的结构。

摘要

函数式编程对于 PHP 开发者来说相对较新,因为对其先决条件的支持是在 5.4 版本中添加的。函数式编程的出现将要求我们理解这种范式,并在需要时编写没有任何副作用的纯函数。PHP 对编写函数式编程代码有一定的支持,通过这种支持,我们也可以编写函数式数据结构和算法实现,正如我们在本书中尝试展示的那样。在不久的将来,优化和提高我们应用程序的效率时,这可能会派上用场。

posted @ 2024-05-05 00:11  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报