如何设置软路由作为阶梯?组装软路由

本章涵盖将图结构建模为地图,应用类型约束,以确保模块中正确封装数据类型和函数的类型的属性,使用列表推导式来转换列表设计,更轻松地分析处理复杂状态的算法并提高计划性能我们的最后一个项目是流行UNIX 工具的最小克隆。严重的企业!但是,生活不仅仅是为我们所

本章涵盖将图结构建模为地图,应用类型约束,以确保模块中正确封装数据类型和函数的类型的属性,使用列表推导式来转换列表设计,更轻松地分析处理复杂状态的算法并提高计划性能我们的最后一个项目是流行UNIX 工具的最小克隆。严重的企业!但是,生活不仅仅是为我们所有的终端线路编号需求编写工业强度的实用程序。让我们找点乐子!人们平时都是怎么玩的呢?当然是通过玩游戏!

Word Ladder 游戏是一个有趣的迷你游戏,它可以让玩家构建单词链,通过切换单词中的各个字母来找到单词链。游戏有很多变体,我们将重点关注一款非常复杂的游戏!但是当我们可能有点懒的时候为什么还要玩游戏呢?电脑可以给我们播放!

编写人工智能,即使对于儿童来说,也不总是小菜一碟。这个项目将让我们思考如何智能地使用数据结构来解决搜索问题。虽然表面上看起来很简单,但我们会发现其中可能存在陷阱,即使是儿童游戏!

本章首先讨论使用Haskell 类型的建模图以及如何创建我们自己的模块。然后,我们将探讨类型类的基础知识、它们是什么、它们做什么以及如何使用它们。然后,我们使用关联列表为映射创建特殊的数据类型,并使用模块导出列表来正确确保数据类型的不变量。通过使用映射,我们创建图形的数据类型和列表中单词排列的查找表。然后,我们在有向图上实现广度优先搜索,将所有内容放在一起后,我们将通过分析我们的程序并改进其性能来结束本章

4.1 梯子巢 梯子游戏是一种有趣的练习,需要玩家真正挖掘他们的词汇知识。这个游戏的一轮可以由任意数量的玩家玩,并且在规则方面存在许多变化。让我们先看看最简单的变体,然后讨论如何为游戏开发人工智能。

玩家从两个相同长度的单词开始。其中一个可以被认为是开始,另一个可以被认为是结束。要解决的任务是找到连接起始词和结束词的其他单词链,其中每对相邻单词相差一个字母。这意味着玩家从一个单词开始,改变各个字母以得到一个新单词,并继续这些转换,直到找到从头到尾的完整链。如果有多个玩家玩游戏,则链最短的玩家获胜。

这里有一个例子:猫坐在下垂挖狗

这是一个有趣的游戏,但对于计算机来说是一个相当简单的练习,我们很快就会看到。事实上,这是一个经典的搜索问题。为了找到解决方案,我们需要在域(一堆单词)中搜索从一个元素到另一个元素的路径。这个问题以多种方式传播:

导航系统数据库网络路由器数独求解器 通过解决字梯游戏,我们学习了一项可以转移到任何其他学科的技能。对于计算机来说,无论我们搜索什么,搜索结果都是一样的!区别在于解决方案的建模方式。对于我们来说,单词由于游戏中的某些规则而相互连接,但在导航系统中,路径是由地图数据给出的。

一旦去掉这一层抽象,问题就变得一样了!

为了让这个问题不那么简单,我们会让事情变得更复杂:我们不仅允许玩家更改单个字母,而且还添加一个全新的字母,删除一个字母, x] 和 任何 重新排序 个字母。对游戏的修改使其变得更加有趣,因为现在可以找到不同长度的单词之间的路径!因此,现在可以找到“find”和“solution”这两个词的解决方案(例如findfinsions腰部tonsilslotionsolution)。逐步解决方案如图4.1 所示。

图4.1。改进的天梯游戏中“查找”和“解决方案”的解决方案这一修改不仅让我们能够找到更有创意的解决方案,而且还使问题在计算上更难解决。在解决这个问题时,我们需要开始考虑性能以及一种巧妙的方法来最大限度地减少我们不需要做的工作。

4.1.1 绘制图表 现在,我们可以开始思考如何为此类游戏构建人工智能。我们假设我们的智力以某种方式拥有所有英语单词的完整列表。如果它想找到一条有效的链,它需要找到从一个单词到另一个单词的路径,并进行有效的转换。为此,我们可以假设所有单词都排列在一个图中,其中两个单词之间有一条边,并且当且仅当我们可以一步从一个单词到达另一个单词时,单个单词才是图中的一个节点。图4.2 显示了几个三字母单词的图。正如我们所看到的,找到从“猫”到“狗”的链可以通过在图中找到一条路径来完成。

图4.2。一系列英文单词的梯形图 我们立即看到从“cat”到“dog”有多条路径。一种解决方案可能是使用猫帽子加热治疗抱住救济狗狗。然而,这不是最短的解决方案!较短的一个可能是猫猫标签山羊狗。我们的人工智能必须能够在我们称为梯形图的梯形图中找到最短的解决方案。

我们可以快速计划出我们的程序应该做什么:

读取用户的开头和结尾单词从字典中构建梯形图在梯形图中搜索从一个单词到另一个单词的最短路径[ x] 幸运的是,寻找这种最短路径的主题已经在计算机科学中得到了广泛的研究,所以这不应该是一个问题。问题是,我们首先需要计算这样一个梯形图才能找到解。这应该让我们思考:我们如何在Haskell 中表示图?

让我们回顾一下什么是图表。图由节点和边组成,边又连接这些节点。边可以是有向的(因此只能从一个节点构造到另一个节点,但不能从相反的方向构造路径)或无向。此外,边缘可能有成本,但对于我们的问题,我们可以忽略这些成本。我们平等地对待路径中的每条边,一条边代表梯子游戏中的一步。这为我们提供了许多将这个概念表示为Haskell 类型的可能性。首先,我们要在图表中存储什么样的元素?该元素将具有什么类型?我们可以选择固定类型,但实际上没有必要这样做。

图中元素的类型通常是不明确的,因此我们将其保留为多态类型:

typeGrapha=. 这将使我们能够更加灵活地处理可能的内容用于图形中使用的类型,但是图形是如何建模的呢?由于我们正在处理无向图并且边没有成本,因此我们只对存储有关两个节点的连接的信息感兴趣。这可以通过使用邻接表来实现。这样的列表包含所有连接的节点对。在上一章中,我们已经遇到了可以表示这种数据结构的类型:关联列表!

typeGrapha=[(a,a)] 当且仅当列表中存在包含它们的元组时,两个节点才有边。虽然这种类型很简单,但在性能方面存在一些缺点。在最坏的情况下,检查边缘是否存在并收集给定节点的所有子节点迫使我们扫描整个列表。在列表中插入新边也是如此,因为我们必须检查重复项。这对于较大的图来说是不可接受的,并且由于我们希望能够处理整个字典,所以这是行不通的。为了高效的遍历,我们需要一个允许快速索引的数据结构。根据底层实现,邻接映射可能是更好的解决方案。

typeDiGrapha=[(a,[a])] 这种类型将节点映射到节点列表,因此隐含了多个边,但仅在一个方向!因此,这种类型被命名为有向图(DiGraph),以暗示它是有向图。图4.3 显示了此映射如何对应图结构的示例。 图4.3。从邻接图构建的图的示例

遗憾的是,如果我们想要表示无向图,这种类型有一个缺点。添加单个无向边需要我们向地图添加两个元素!对于具有两个互连节点的简单无向图,节点1 和2 的值将类似于[(2,[1]),(1,[2])]。

现在让我们从这种类型开始,因为有向图适合我们的搜索问题(稍后我们将看到)。我们想要创建的不仅仅是这个类型,而且是可以用来处理这个类型的函数的集合。为此,我们希望将所有这些功能捆绑在它们自己的模块中!让我们从创建一个新项目开始。我们称之为梯子! 4.1.2 保持模块化

使用Stack New Ladder 创建新项目后,我们现在将在src 目录中创建一个新文件,该文件将成为我们图形类型的新模块。我们将此文件称为Graph.hs。这将是我们所有与图形相关的定义的模块。每个模块都以一个小的序言开头,如下所示:

moduleGraphwhere 模块名称与文件名一致非常重要。 注意

此外,模块可以捆绑在源目录的子目录中。如果模块包含在类似Foo/Bar/Module.hs 的路径中。名称需要这样反映:Foo.Bar.Module。子目录名称通常大写。

现在我们可以开始考虑我们想要实现的功能了。首先,让我们专注于构建图表。我们如何向图中添加单个节点?就像将关联元组添加到列表一样简单吗?不完全是,因为我们必须确保相同的键不会在列表中出现两次。请记住,链接列表就像地图一样。在我们的例子中,我们将节点映射到它们所连接的其他节点。因此,首先,我们需要实现一个在映射中查找键的函数,然后在插入函数中使用它。该函数如清单4.1 所示。

清单4.1。检查关联列表中是否存在键的函数

member_[]=False#1nmemberx((x’,_):xs)#2n|x’==x=True #3n|否则=memberxxs#4这个函数的类型表达式被故意省略,让我们思考这个类型应该是什么样子。我们想要为一般关联列表定义一个函数(稍后使用图形类型的定义)。这使我们得出结论,关联列表和搜索元素的类型必须是多态的:

member:a-[(a,b)]-Bool 这个表达式似乎是有道理的。键和搜索到的元素必须是同一类型,而元组中的第二个元素可以是任何其他类型。但是,如果我们尝试将此类型分配给函数并编译我们的代码(例如,使用堆栈repl),我们会收到错误!

.n?没有因使用’==’ 而产生(Eqa) 的实例n可能修复:n将(Eqa) 添加到:n成员:forallab.a-[(a,b)]-Boolnn的类型签名的上下文中n. 怎么了?问题来自于(==) 函数的使用。我们假设键的类型是自由多态的,但是我们如何确定使用此函数的类型具有可比较的值?我们一般如何知道哪些类型的值具有可比性? 注意

Haskell 中的运算符如==或只是函数,我们可以像任何其他运算符一样使用它们!编译器只是以中缀表示法解释它们,但可以像其他函数一样引用它们。为此,我们需要将运算符括在括号内,例如(==)。这使得在高阶函数中使用它们成为可能(例如zipWith(==)[1, 2, 3][1, 1, 3])

要理解这个难题,我们需要快速跳转进入类型类主题。一直以来,我们一直在与他们合作,但我们却浑然不觉!但它们是什么? 4.2 课前

让我们通过问一个简单的问题来开始讨论:类型有哪些属性?类型本身只是值集合的名称(在极少数情况下甚至没有值)。然而,仅仅因为一个值是某种类型,我们不一定知道可以对其执行什么操作。在大多数语言中,某些操作对于某些类型是隐式的,例如C 或Java 中的等号运算符==是为原始数据类型定义的。然而,在Haskell 中,我们更明确地定义类型操作。这是通过类型类完成的。它们包含需要为此类的实例定义的值的签名。我们可以使用GHCi 来检查这一点,如下所示:

ghci:infoEqntypeEq:*-ConstraintnclassEqawheren(==):a-a-Booln(/=):a-a-Booln{- # MINIMAL(==)|(/=)#-} 此输出告诉我们Eq 类型类定义了两种方法,一种用于相等(==),一种用于不等式(/=)。然而,这种类型的实例只需要提供其中一个的实现,由MINIMAL 注释指示。通过否定给定方法的结果可以简单地推断出缺失的方法。因此,当我们为类型定义相等(或不等)时,我们也会自动获得反函数!

在与此签名相同的输出中,还列出了我们范围内的现有实例。以下是其中的一些:

instanceEqBoolninstanceEqCharninstanceEqIntegerninstanceEqIntninstanceEqFloat那里,我们已经使用过熟悉的类型!这些实例中的每一个都定义了如何检查各自类型的值的相等性!但是等等,还有更多:

instanceEqa=Eq(Maybea)ninstanceEqa=Eq[a] 某些实例将类型约束作为先决条件。这两个例子告诉我们,如果任何类型a 都有Eq 类型类的实例,那么Maya 和[a] 也有。

这意味着也许Float 和[Int] 也可以进行比较! 重要信息

类型类的词汇与面向对象编程有一些重叠。但是,这些概念绝对不应该混淆!类型类不能实例化为对象,并且方法的行为与类方法不同,因为它们的实现可能因类型而异!类型类与接口的关系比面向对象上下文中的类更多。

从相等运算符(Bool)的类型签名中,我们也可以立即推测出我们只允许比较相同类型的值。例如,我们不能将Int 与float 进行比较!

存在更多类型类,我们会在适当的时候看到其中的一些,但现在让我们专注于为我们的成员函数查找类型表达式。当我们想要指定我们要搜索的元素的类型需要具有可比性时,因此它需要有一个Eq 类型类的实例。我们可以通过向类型表达式添加约束来做到这一点:

member:Eqa=a-[(a,b)]-Bool 该约束指定它对于我们的多态类型是必要的正在使用哪些属性被保留。请注意,当显式命名类型时(例如使用Int),我们不需要添加约束,因为我们已经知道该类型具有哪些实例。另请注意类型类的方法如何隐式地带有自己的类型约束:

ghci:type(==)n(==):Eqa=a-a-Bool 如果您想使用(==),多态类型需要有Eq 类型类的实例!这就是为什么我们会遇到编译错误! 注意

有时,我们故意省略类型表达式以编译某些函数。我们这样做是为了让Haskell 自己找出限制。类型推断足够强大,至少在大多数情况下是这样。 4.2.1 结果

现在我们已经完成了偏移,我们终于可以实现向图中添加新节点的功能了!为了使命名更清晰,我们定义了一个新函数hasNode,它只是一个成员的别名,其参数被翻转。现在,用于向图中添加新节点的函数只需检查该节点是否已存在,如果不存在,则添加没有传出边的节点。示例4.2 中给出了该代码。 清单4.2。检查关联列表中是否存在键的函数

member:Eqa=a-[(a,b)]-Bool#1nmember_[]=Falsenmemberx((x’,_) :xs)n|x ‘==x=Truen|否则=memberxsxnnhasNode:Eqa=DiGrapha-a-Bool#2nhasNode=flipmember#3nnaddNode:Eqa=DiGrapha-a-DiGraphanaddNodegraphnoden |graph`hasNode`节点=graph#4n|否则=(node,[]):graph#5请注意函数的参数顺序。 hasNode 故意将图作为其第一个参数,以便可以用中缀表示法编写。为了翻转这些参数,我们使用一个名为Flip 的函数:

flip:(a-b-c)-b-a-c 该函数采用二元函数并生成一个参数翻转的函数!请注意a 和b 如何作为函数的参数,并且通过使用偏函数应用,使用单个二元函数的翻转将产生类型为b a c 的新函数! 练习

Data.List 模块(以及始终导入的Prelude)已经提供了一个在关联列表中查找键的函数,称为查找!用它来重写成员函数!

另外,看看类型表达式。我们必须在定义的每个函数中为Eq 添加类型约束。如果我们想在类型变量上使用具有类型约束的函数(在我们的例子中是a ),我们还必须将所述约束添加到我们的函数类型中,因为我们不能对函数内部的类型应用约束(通过使用具有类型约束的函数) ,但不能对外部公开的类型表达式施加此类约束。

到目前为止,我们的讨论仅围绕Eq 类型类。当然,还有更多类似Ord 类型类,它为我们提供了排序的定义(例如() 和()),而Num 是数字类型的类型类,定义了某些算术运算(例如(+)、(-)、消极的)。虽然我们可以花很多时间探索Haskell 提供的开箱即用的所有类,但我们只会在这些类出现后才讨论它们! 提示

有时,您会遇到在其类型约束中具有某些您以前从未见过的类型类的函数。为了了解这个类,它有哪些方法和实例,我发现最快的方法是使用GHCi 检查该类。您可以使用ghci:i 类的名称来执行此操作。

现在我们知道如何使用类型类,我们可以解决构建图的更大问题。我们应该考虑可以向模块添加哪些函数来帮助我们处理关联列表。 4.3 重绘地图

为了构建我们的图表,我们将添加一个名为addEdge 的函数,它将在我们的图表的节点之间添加一条边。为此,我们需要检查起始节点是否已存在于图中。要么我们必须使用仅包含末端节点的新边列表将此节点添加到图中,要么该节点已经存在并且我们必须修改边列表。无论如何,我们需要以某种方式改变地图。为了方便起见,我们想要创建一个新函数来对地图执行任意更改。理想情况下,它应该能够添加新元素、删除它们以及修改某个键的值。在这种情况下,一个好主意是使用更高阶的函数,因为这个函数可以对我们的值进行修改,但是我们需要一种方法来告诉函数缺少一个值,而函数需要一种方法来告诉我们,它想要删除一个值。

幸运的是,这两者都可以通过我们已知的类型来实现:也许吧!类型函数(也许 也许a)通过将Nothing 解释为缺失值或希望删除它来实现此目的!

我们可以递归地实现alter函数,如清单4.3所示。 清单4.3。修改关联列表的函数

alter:Eqk=(Maybev-Maybev)-k-[(k,v)]-[(k,v)]nalterfkey[]=#1ncasefNothingof #2nNothing-[]# 3nJustvalue-[(key,value)]#4nalterfkey((key’,value’):xs)#5n|key==key’=#6ncasef(Justvalue ‘)of#7nNothing- xs#8nJustvalue-(key,value):xs#4n|otherwise=n(key’,value’):alterkeyxs#9为了让类型和值更清晰,我们调用自由类型变量k 和v 键和值。虽然一开始有点吓人,但这个功能非常简单。在空列表的情况下,给定的键丢失,因此没有与之关联的值。在本例中,我们提供函数faNothing,它可以将列表留空或向列表添加新映射。这样,该函数就可以添加新的映射!在非空列表的情况下,我们递归地搜索正确的映射,一旦再次找到它,我们就检查函数的结果。这没有任何意义,我们删除映射,而Just 构造函数中包装的值意味着对现有映射的更新。 注意

我们可以观察到,我们实际上从未在alter 函数中“修改”关联列表。该函数本身构建了一个全新的列表,其中包含我们想要的修改。列表本身是不可变的。这是Haskell 的纯粹方面,也是我们处理一般国家问题的方式! 4.3.1 添加、删除、更改

让我们快速了解一下如何使用此函数来删除、更新映射以及将映射添加到列表中。我们可以在清单4.4 中看到我们的函数的运行情况。 清单4.4。关联列表的更改函数示例

ghcimyAssocList=[(1,1),(2,2),(3,3)]#1nghcialter(constNothing)1myAssocList#2n[( 2,2), (3,3)]nghcialter(maybeNothing(const(Just0)))1myAssocList#3n[(1,0),(2,2),(3,3)]nghcialter(maybeNothing (const(Just0)) ))4myAssocList#3n[(1,1),(2,2),(3,3)]nghcialter(const(Just4))4myAssocList#4n[(1,1) ,(2,2 ),(3,3),(4,4)] 这是一个强大的函数,我们可以通过它为我们的关联列表构建许多不同的有用实用程序! 练习

在3.3.2节中,我们学习了may函数,它用于处理可能的值。然而,在alter的实现中,我们使用手动模式匹配。重写该函数以使用它!

在添加所有这些函数之前,我们希望为代码添加更好的结构!显然,我们定义的不是图的函数,而是一个通用的关联列表。那么让我们为我们的代码定义一个新模块! 4.3.2 输入另一个模块

为了更干净地捆绑我们的功能,我们在src 目录中的代码中添加了一个名为AssocMap 的新模块,并添加了成员和更改函数。我们将此模块放置在src 的子目录中,称为data。我们这样做是为了表示我们正在创建的模块用来定义数据类型的类型和函数。这样做的目的不仅是为我们的图和关联列表提供单独的代码,也是为了确保代码的不变性。对于我们的地图,我们需要确保每个键只出现一次!理想情况下,我们只想在其自己的模块中对此映射进行修改。否则,我们无法确保使用我们类型的其他开发人员也确保这个不变量保持正确!所以我们想以某种方式隐藏这个类型只是一个列表的事实。

为了实现这一点,我们给它一个带有自己的构造函数的新类型:

dataAssocMapkv=AssocMap[(k,v)] 这个类型相当特殊,因为它只包含一个构造函数,其中只有一个字段。在这种情况下,我们可以使用另一个关键字来定义一个名为newtype 的类型:

newtypeAssocMapkv=AssocMap[(k,v)] 我们构造的新类型具有属性Impact,因为它告诉编译器认为字段中的值和构造函数中包装的值之间存在一一对应的关系。因此,类型检查后可以省略构造函数!我们的新类型中的类型构造函数的编译后成本为零,因为它根本不存在。这在更深层次上也有一些影响,因为新类型定义比数据定义更懒惰。有关更详细的说明,请查看附录B! 注意

可以(并且是标准做法)在数据定义中将newtype 或构造函数命名为与类型本身相同的名称。这样做不是强制性的,如果您愿意,您始终可以为构造函数选择不同的名称,但在较大的项目中,它可以更清楚地显示哪个值与哪个类型相关联!

那么,我们现在如何从外部隐藏这种类型呢?答案以模块导出列表的形式提供给我们。有了它,我们可以控制从模块导出的内容以及未知的内容!如果我们想导出一个没有构造函数的类型,我们只需写下类型的名称即可。看起来像这样:

moduleData.AssocMapn(AssocMap,n.n)nwhere 如果我们想导出构造函数,我们还可以编写AssocMap(.)。

我们现在遇到的一个问题是我们的函数不是为新类型编写的,而是为简单的关联列表编写的。我们需要重写它们!我们有两个选择: 为每个处理关联列表的表达式添加一个新的构造函数使用列表中的旧函数为新类型的每个函数构造一个包装器

第一个选项可以说更规范,因为我们想为这种类型定义函数。然而,第二个选项允许我们从元组列表中的任何函数构造该类型的函数!因此,我们将选择第二个选项,因为它也使代码更易于阅读。

这也是习惯Haskell 的另一种简洁语法的好机会:where 关键字。就像我们可以使用let关键字在函数中定义内部定义一样,我们也可以使用where,但定义是在使用它之后。如图4.4所示。 图4.4。 Where 子句

对导出列表、新类型和函数的更改如清单4.5 所示。 清单4.5。 AssocMap

的模块结构moduleData.AssocMapn(AssocMap,#1nmember,#2nalter,#2n)nwherennnewtypeAssocMapkv=AssocMap[(k,v) ]#3n nmember:Eqk=k-AssocMapkv-Boolnmemberkey(AssocMapxs)=member’keyxs#4nwhere#4nmember’:Eqk=k-[(k,v)]-Boolnmember’_[]=Falsenmember’x ((x’,_):xs)n|x’==x=Truen|否则=成员’xxsnnalter:Eqk=(Maybev-Maybev)-k-AssocMapkv-AssocMapkv nalterfkey(AssocMapxs)=AssocMap (alter’fkeyxs)#5nwhere#5nalter’:Eqk=(Maybev-Maybev)-k-[(k,v)]-[(k,v)]nalter’fkey[ ]=ncasefNothingofnNothing -[]nJustvalue-[(key,value)]nalter’fkey((key’,value’):xs)n|key==key’=ncasef(Justvalue’)ofnNothing-xsnJustvalue- (key,value):xsn|otherwise=n(key’,value’):alter’fkeyxs 现在我们已经正确地封装了来自外界的类型和函数!遗憾的是,我们现在已经使我们的类型无法使用了。

为什么?这里有一个问题:我们如何创建一个新列表?请记住,我们没有用于构建此类型的构造函数,这意味着我们无法 构造 AssocMapkv 类型的任何值!我们怎样才能规避这个问题呢? 练习

我们通过创建函数包装器来构造新类型的成员并更改函数。但是,我们可以添加一个构造函数来代替匹配和构造列表。作为熟悉如何使用这些构造函数的练习,请执行此操作而不是使用包装器!

虽然我们可以提供一个将关联列表转换为AssocMap 的函数,但更简单的解决方案是为空映射提供一个值,以及用于添加和删除新映射的函数。幸运的是,我们有我们的改变功能,可以让这变得轻而易举!新函数如清单4.6 所示。 清单4.6。空映射和删除、插入函数的定义

empty:AssocMapkvnempty=AssocMap[]#1nndelete:Eqk=k-AssocMapkv-AssocMapkvndelete=alter(constNothing)#2nninsert:Eq k=k-v-AssocMap kv- AssocMapkvninsertkeyvalue=alter(const(Justvalue))key#3我们不能忘记将新定义添加到导出列表中:

moduleData.AssocMapn (AssocMap, nempty,nmember,nalter,ndelete,ninsert,n)nwhere现在我们可以尝试一下!让我们在GHCi 中加载该项目并检查一下:

ghciinsert1’Hello'(insert2’World’empty)nninteractive:70:1:error:n?Noinstancefor(Show(AssocMapIntegerString))narisingfromauseof’print’n?InastmtofaninteractiveGHCicommand:printit哎呀!看来我们错过了另一门类型课! Show类型类用于提供show函数,该函数将Haskell类型转换为String。 GHCi 使用此函数来显示它计算的值,但我们的类型没有此类的实例。 重要

show 并不意味着 提供了Haskell 值的人类可读表示! Show 类型类是Read 类型类的对偶,它为Haskell 值提供自动生成的解析器。但是,如果您不介意show 的输出是否派生,或者您不关心将其保留为Read 实例的双精度并自己实现它以使其更具可读性,您也可以使用它进行漂亮的打印!

幸运的是,我们可以自动推断!某些类型类(Eq 和Show 是其中两个)可以自动派生,以合理的方式运行。要派生类型的类型类,我们使用导出关键字:

newtypeAssocMapkv=AssocMap[(k,v)]nderiving(Show)Show 的派生实例将生成字符串值,这些值看起来与代码中也写的类型值非常相似。请注意,这仅在使用地图中的类型时才有效,而地图又具有Show 类型类的实例!

对类型进行这个小更改后,我们终于可以测试我们的函数了:

ghciinsert2’World'(insert1’Hello’empty)nAssocMap[(1,’Hello’) , (2,’World’)]nghcidelete1(insert1’Deleteme!’empty)nAssocMap[]BTW:我们还可以导出Eq! 的实例!在这种情况下,相等(==) 由结构等价定义。如果一个类型的两个值的构造函数和字段依次相等,则它们相等。以下是一些示例:

ghcidataX=A|BInt|CIntIntderivingEqnghciA==AnTruenghciB1==B2nFalsenghciB1==B1nTruenghciB1==C12nFalsenghciC12==C22nFalsenghciC12==C12nTrue现在我们定义函数来查找与其键关联的值。毕竟,这就是地图的用途!对于查找与键关联的值的查找函数,我们使用与之前相同的方法,将处理列表的函数包装到新函数中。此外,我们定义了一个函数,该函数还提供默认值,以防键不存在。这将为我们以后省去一些麻烦。这些函数的代码可以在清单4.7 中找到。由于我们正在创建一个名为lookup的函数,因此可能会与Prelude中自动导入的lookup函数发生冲突。为了避免这个问题,我们可以使用以下import 语句隐藏查找函数:

importPreludehiding(lookup) 注意 如果我们不想在导入中隐藏函数(因为我们实际上想要使用它),我们必须在这些函数出现之前加上模块名称。在我们的例子中,这看起来像Prelude.lookup 和Data.AssocMap.lookup。 清单4.7。根据关联列表构建的地图上的查找函数的定义

lookup:Eqk=k-AssocMapkv-Maybevnlookupkey(AssocMapxs)=lookup’keyxsnwherenlookup’key[]=Nothingnlookup’ key((key’ ,值):xs)n|key==key’=Justvaluen|否则=lookup’keyxsnnfindWithDefault:(Eqk)=v-k-AssocMapkv-vnfindWithDefaultdefaultValuekeymap=ncaselookupkeymapofnNothing-defaultValue nJustvalue-value[ x]太棒了!我们已经完成了从关联列表构建的地图模块。通过仔细检查,我们确保模块内的函数不会违反每个键只能出现一次的不变量,并且映射不会从模块外部“损坏”,因为构造函数不会从模块中导出。

因此,如果没有我们的功能,模式匹配或构建新地图是不可能的!现在我们可以根据地图的新定义来实现我们的图表了! 4.4 邻接图冗余 在我们的Graph 模块中,我们可以导入新的地图数据类型,但请记住,它导出一个名为Lookup 的函数,该函数将与Prelude 冲突中的查找一起使用!在较大的项目中,这个问题只会变得更糟,其中一个模块可能会导入一百个其他模块!一种非常优雅地解决这个问题的方法是合格的进口。如此重要,它看起来像这样:

importqualifiedData.AssocMapasM

这将导入Data.AssocMap 并给它命名M,因为我们不想每次都写整个名称。关键字限定迫使我们通过在模块名称前加上函数和定义的名称来引用模块中的函数和定义。这意味着我们不能只使用像alter 这样的东西。我们必须通过模块名称来引用该函数:M.alter。由于每个导入都有其唯一的名称,因此我们避免名称冲突并使某些定义更清晰在上下文中的位置! 现在,让我们使用这些导入来编写有向图的定义。首先,我们定义类型并提供空图的定义:

typeDiGrapha=M.AssocMapa[a]nnempty:DiGraphanempty=M.empty

现在,我们需要提供以下功能: 向图中添加边向图中添加多条边检索节点的所有连接节点 通过更改映射来添加边。如果该条目尚不存在,我们将添加具有单个连接节点的节点,否则我们将子节点添加到原始节点连接到的现有节点。我们唯一需要注意的是串联列表不包含任何重复项。我们可以通过删除任何重复项来做到这一点,幸运的是Data.List 模块中有一个名为nub 的函数!

ghciimportData.Listnghcinub[1,1,1,2,3,4,1,1,1,2,3,4]n[1,2,3,4]

我们还为此模块创建了一个合格的导入,以便我们可以使用其中的函数。 importqualifiedData.ListasL

现在我们可以构造添加边的函数。如果图中不存在该节点,则添加该节点。否则,连接节点列表将由新连接节点扩展,并删除重复节点。添加多条边看起来一点也不复杂。我们只需要向图中添加边列表,为应添加的每条边调用该函数。此外,我们希望定义一个将节点关联列表和节点列表转换为图的函数,其实现类似于添加多条边的函数。从地图中的节点检索所有连接的节点是一个简单的查找!如果找不到该节点,我们只需返回一个空列表,因为丢失的节点没有连接的节点!这些函数如清单4.8 所示。 清单4.8。用于向有向图添加边并检索连接节点的函数 addEdge:Eqa=(a,a)-DiGrapha-DiGraphanaddEdge(node,child)=M.alterinsertEdgenode#1nwhere ninsertEdgeNothing=Just[child]#2 ninsertEdge(Justnodes)=nJust(L.nub(child:nodes))#3nnaddEdges:Eqa=[(a,a)]-DiGrapha-DiGraphanaddEdges[]graph=graph# 4naddEdges(edge:edges)graph=addEdgeedge(addEdgesedgesgraph)#5nnbuildDiGraph:Eqa=[(a,[a])]-DiGraphanbuildDiGraphnodes=gonodesM.emptynwherengo[]graph=graph#5ngo( (key,value):xs)graph=M.insertkeyvalue(goxsgraph)#6nnchildren:Eqa=a-DiGrapha-[a]nchildren=M.findWithDefault[]#7

我们现在有一些处理有向图的函数。

让我们看看其中的一些操作: ghciimportGraphnghcig=addEdges[(1,1),(1,2),(3,2),(3,1)]emptynghcichildren3g n[ 2,1]nghcichildren2gn[]nghcichildren2(addEdge(2,1)g)n[1]

我们实现的一个优点是它完全独立于映射的底层实现。我们可以用另一种类型替换AssocMap 类型,只要该类型具有与其关联的兼容函数即可。 练习 当仔细观察图函数时,我们可以发现addEdges 和buildDiGraph 的实现非常相似。尝试概括这两个函数,提供一个适用于类似结构的列表的高阶函数,并将该函数应用于这两个定义。此外,我们不提供从图中删除节点和边的函数。自己实现这些功能!

现在我们知道如何构建图表了,让我们最终把梯形图放在一起吧!

4.4.1 排列单词以获取利润 回想一下,梯形图包含给定字典中的所有单词,并包含梯形图游戏中一步可以到达的所有单词之间的边。这些步骤构成了以下任意转换的组合:

向单词添加一个字母从单词中删除一个字母任意重新排序单词中的所有字母 需要进行转换才能产生一个依次存在的单词单词天梯游戏中的内容是基于字典中的。这给我们带来了计算挑战!对于我们可以对单词执行的每个转换,我们需要检查结果是否是字典的一部分。那么我们要检查多少个单词呢?让我们粗略估计一下,假设我们有一个5 个字母的单词,没有重复的字母。我们可以添加24 个字母中的任何一个,创建24 个长度为6 的新单词。删除一个字母有5 种可能性,创建5 个长度为4 的新单词。我们可以更改5 个字母中的任何一个,每个字母都有23 种可能性,创建115 个长度为5 的新单词。每个新单词都可以任意重新排序。 n 个字母有多少种排列方式?它是n的阶乘!单词总数归结为: 24*(6!)+5*(4!)+115*(5!) 归结为

31200 个单词,我们需要检查五个字母的单词在字典里!对于通向16168320 的八个字母单词,对于十个字母单词1796256000,以及对于十二个字母单词282131942400!只是需要太长时间了! 那么,我们该如何解决这个问题呢?如果我们有某种缓存或过滤器可以快速告诉我们哪些排列有效,哪些排列无效,这可能会有很大帮助。理想情况下,我们根本不想计算单词排列,但是我们如何实现这一点呢?假设我们扫描字典一次,对于我们想要存储的每个单词,它有什么排列。这些安排有什么共同特征?它们都是由相同的字母组成的,所以当我们对它们进行排序时,它们是相同的!我们可以扫描字典来创建单词及其排列的映射,方法是对每个单词进行排序以创建键,然后将单词保存在与键关联的列表中。如果我们对每个单词都这样做,我们就会创建一个映射,将单词映射到字典中的有效排列!

我们可以开始在新模块中实现排列图的想法,我们将其称为排列图。类型是从字符串(排列的排序表示)到其排列的映射。

typePermutationMap=M.AssocMapString[String]

这张map上的操作和我们的AssocMap一样,但是我们需要确保每当我们操作一个key时,我们都是排序的。

我们可以使用Data.List 模块中的排序函数来完成此操作。同样,我们对Data.AssocMap 和Data.List 模块以及Data.May 模块使用限定导入。然而,也许我们只想导入一个函数,并且我们使用导入列表来完成它。这些列表的作用类似于导出列表,并限制导入的定义。该模块的代码如清单4.9 所示。 清单4.9。用于排列映射的模块,具有在访问映射 中的值之前对键进行排序的映射函数modulePermutationMapwherennimportqualifiedData.AssocMapasM#1nimportqualifiedData.ListasLnimportData.Maybe(fromMaybe )nntypePermutationMap=M.AssocMapString [String]#2nnempty:PermutationMapnempty=M.empty#3nnmember:String-PermutationMap-Boolnmemberkey=M.member(L.sortkey)#4n nalter:n(也许[String]- n也许[String]n)-nString-nPermutationMap-nPermutationMapnalterfkey=M.alterf(L.sortkey)#4nndelete:String-PermutationMap-PermutationMapndeletekey=M.delete(L.sortkey)# 4nninsert:String-[String]-PermutationMap-PermutationMapninsertkey=M.insert(L.sortkey)#4nnlookup:String-PermutationMap-Maybe[String]nlookupkey=M .lookup(L.sortkey)#4 nnfindWithDefault:[String]-String-PermutationMap-[String]nfindWithDefaultdefaultValuekeymap=nfromMaybe[](PermutationMap.lookupkeymap)#5

本质上,PermutationMap类型只是AssocMap的一个特殊版本,由于我们的多个With有状态实现,我们可以自由使用具体类型。此外,就像我们的图表一样,排列映射类型完全独立于映射的实现! 练习 查看排序函数的类型表达式。为什么我们可以对字符串值使用这个函数?为什么我们可以在新模块中使用AssocMap 中的多态函数?尝试使用GHCi 检查类型来找出类型兼容的原因。

接下来,我们将构建一个函数,该函数接受单词列表并从中构建排列图。为此,我们可以假设所有单词都是小写的。每个单词都需要添加到地图中。我们希望多个单词共享相同的键(因为这就是我们找到单词的所有排列的方式),因此我们必须通过将单词添加到键指向的列表中来添加单词。实现此目的的代码如例4.10 所示。

示例4.10。从字符串列表构造排列图的函数createPermutationMap:[String]-PermutationMapncreatePermutationMap=goempty#1nwherengopermMap[]=permMap#2ngopermMap(x:xs)=go(insertPermutationxpermMap) xsn ninsertPermutationword=alter(insertListword)word#3nninsertListwordNothing=Just[word]#4ninsertListword(Justwords)=Just$word:words

这个实现与清单4.8 中的addEdges 函数非常相似,但是这个time 而不是分离函数,我们使用where 将所有必需的定义保留为本地定义。我们也可以无论如何,go函数的机制看起来很熟悉。老实说,确实存在具有这种行为的函数,但我们将等待第5 章来介绍它! 4.4.2 一次一步 现在我们可以测试我们的新函数了:

ghciwords=[‘traces’,’reacts’,’crates’,’caster’,’tool’ , ‘战利品’,’猫’]nghcipm=createPermutationMapwordsnghcipmnAssocMap[(‘acerst’,[‘caster’,’板条箱’,’反应’,’痕迹’]),(‘战利品’,[‘战利品’ ,’tool’]),(‘act’,[‘cat’])]nghciPermutationMap.lookup’tool’pmnJust[‘loot’,’tool’]nghciPermutationMap.lookup’reacts’pmnJust[ ‘caster’,’crates’,’reacts’,’traces’]

我们看到每个单词如何与其有序单词相关。构建地图后,我们可以快速检索原始单词列表中存在的所有排列。我们不是计算单词的所有排列并检查每个排列,而是在映射中执行简单的查找! 现在我们有了一些可计算管理的东西,我们可以开始实际构建我们的梯形图了。为此,我们创建了另一个名为Ladder 的模块,其中包含我们用例特有的所有功能。首先,我们为单词列表定义一个类型:

typeDictionary=[String]

我们这样做是为了区分字典用法和常规字符串列表。我们假设它们包含仅包含小写字母的单词。现在我们可以编写一个IO 操作来读取包含单词的文件并将其解析为字典。我们已经知道如何分割文件的行,但我们需要过滤字典条目,使其仅包含小写字母。我们可以使用Data.List 模块中的过滤器函数来完成此操作。该函数接收aBool 类型的函数,该函数充当列表元素的布尔谓词。仅返回此谓词返回true 的元素。

这是一个示例: ghcifilter(\x-x=5)[1.10]n[1,2,3,4,5]nghcifiltereven[1.10]n[2 ,4,6,8,10]

使用此函数,我们可以通过检查每个字符是否是小写拉丁字母的一部分来过滤字符串,使其仅包含小写字母。 ghcifilter(\x-x`elem`[‘a’.’z’])’helloworld.’n’helloworld’

这有助于我们过滤掉任何其他字符。此外,我们需要删除可以使用nub 函数执行的任何重复项。代码如例4.11 所示。我们还为Data.List、Graph 和PermutationMap 模块创建合格的导入。 清单4.11。从文件路径 moduleLadderwherennimportqualifiedData.ListasLnimportqualifiedGraphasGnimportqualifiedPermutationMapasPMnntypeDictionary=[String]nnreadDictionary:FilePath-IODictionarynreadDictionaryfilepath=dondictionaryContent- readFilefilepath#1nletnlines 读取字典的IO 操作=L.linesdictionaryContent#2nwords=L.map(L.filter(`L.elem`[‘a’.’z’]))lines#3nreturn(L.nubwords )#4

[ x]这里我们还看到如何使用函数的中缀表示法(在本例中为L.elems)和eta 缩减来摆脱lambda 抽象。 现在,我们要使用字典中的单词生成梯形图。为此,我们需要使用我们从字典本身构建的排列图来计算单词的所有可能更改(添加字母、删除字母、修改字母)以及新形成的单词的所有重新排序。我们可以使用buildDiGraph 函数通过计算字典中每个单词的所有有效新单词来构建节点及其各自的边的列表。假设我们已经有一个函数computeCandidate,它返回给定单词的所有可能候选者。完成的函数如例4.12 所示。 清单4.12。从字典构建梯形图的函数

mkLadderGraph:Dictionary-G.DiGraphStringnmkLadderGraphdict=G.buildDiGraphnodes#1nwherenmap=PM.createPermutationMapdict#2nnodes=nL.map(\w -(w,computeCandidatesmapw) )dict#3

但是,我们显然没有值得关注的computeCandidate函数!给定一个单词,我们首先需要向其添加任意字母,然后使用排列图来获取它的所有有效排列。这使我们的工作稍微容易一些,因为我们在哪里添加新单词并不重要。我们如何向字符串添加新字母?一种可能性是使用映射函数: ghcimap(\x-x:’word’)[‘a’.’z’]n[‘aword’,’bword’,’cword’ ,’dword’,’eword’,’fword’,’gword’,’hword’,’iword’,’jword’,’kword’,’lword’,’mword’,’nword’,’oword’,’ pword’,’qword’,’rword’,’sword’,’tword’,’uword’,’vword’,’wword’,’xword’,’yword’,’zword’]

删除对于映射中的字母也是如此,因为我们可以迭代单词本身,并且对于每个字母,使用Data.List 模块中的删除函数将其从单词中删除。请注意,此函数仅删除要删除的元素的第一次出现。 ghcimap(\x-deletex’word’)’word’n[‘ord’,’wrd’,’wod’,’wor’]

我们再次不’不用关心哪个字母实际上被删除了,因为无论如何我们的帕累托图在查找时都会对单词进行排序!为了计算切换单个字母的单词数,我们现在必须结合这两个操作。那是因为我们不想添加刚刚从单词中删除的字母。然而,写下来可能会有点麻烦,这就是为什么我们要使用另一种列表语法:列表推导式!它们使我们能够以非常简单的方式写下列表的定义。列表理解分为两部分。左侧是指定如何构建列表的元素,右侧是所谓的生成器和守卫。

以下是一些示例:

ghci[x+1|x-[1.10]]n[2,3,4,5,6,7,8,9 ,10,11]nghci[x+1|x-[1.10],x=5]n[2,3,4,5,6]

我们可以看到,左生成器中与守卫相匹配的每个元素都会评估侧面!使用多个生成器将导致评估左侧生成器的元素的叉积。 ghci[(x,y)|x-[1,2],y-[‘a’,’b’,’c’]]n[(1,’a’),( 1,’b’),(1,’c’),(2,’a’),(2,’b’),(2,’c’)]

我们可以使用这些理解来构建我们对这个词的修改。对于给定的单词,我们可以计算出可能的新单词如下: added=[x:word|x-[‘a’.’z’]]nremoved=[deletexword|x- word] nmmodified=n[x:deleteyword|x-[‘a’.’z’],y-word,x/=y]

这里,我们可以观察如何使用列表理解来结合map和此外,过滤函数为我们提供了一种将列表与叉积结合起来的方法。这也可以扩展到我们想要的任意多个维度,因为我们可以使用任意数量的生成器!这种为列表编写定义的方式通常使我们能够使定义更短并且更容易阅读。然而,这主要是个人喜好。 注意列表推导式也可用于模式匹配。生成器中失败的模式匹配计数为跳过值。这样你就可以像这样定义catMaybes函数:catMaybesxs=[x|justxxs]。任何与Justx 不匹配的值都将被丢弃。

现在我们可以使用我们刚刚提出的定义来完成我们的函数。此外,我们可以从已排序的单词中删除所有重复项,以保持地图中的查找次数较少。另外,在最终结果中,我们应该从列表中删除原始单词,因为梯子游戏中的步骤应该更改单词。清单4.13 列出了该函数的完整源代码。 清单4.13。计算梯子游戏中下一步的所有有效候选者的函数

computeCandidates:PM.PermutationMap-String-[String]ncomputeCandidatesmapword=nletcandidates=modified++removed++additional++[word]nuniques=L .nub[L .sortw|w-candidates]#1nperms=L.concatMap(\x-PM.findWithDefault[]xmap)uniques#2ninL.deletewordperms#3nwherenlinked=[x:word|x- [‘a’ .’z’]]#4n已删除=[L.deletexword|x-word]#5n修改=n[x:L.deleteyword|x-[‘a’.’z’], y-word, x/=y]#6

我们还没有见过的一个函数是comcartmap。它有什么作用?此函数与concat 函数密切相关,后者只是获取一个列表列表,然后通过将它们连接起来来创建一个包含前一个列表的所有元素的单个列表,从而将它们展平。 concatMap 只是map 和concat 的组合。这对于以下情况非常有用:map 函数的结果是列表,但您希望将这些列表中的所有元素合并到单个列表中。由于这种情况很常见,因此该函数是预定义的。 ghciconcat[[1,2,3],[4,5,6]]n[1,2,3,4,5,6]nghciconcat[‘Hello’,”,’World’]n’HelloWorld’nghciconcatMap(\x-[1.x])[1.5]n[1,1,2,1,2 ,3,1,2,3,4,1,2,3,4,5]

现在,我们可以为给定的字典构建一个梯形图了!让我们看一个小例子: ghcimkLadderGraph[‘cat’,’cats’,’act’,’dog’]nAssocMap[(‘dog’,[]),(‘act’,[‘ cat’,’cats’]),(‘cats’,[‘act’,’cat’]),(‘cat’,[‘act’,’cats’])]

在这里,我们为该函数提供四个单词的字典。我们可以看到单词cat、cat和behavior都可以在一步内到达,而dog则不能被任何一个步骤到达。节点到达并且图中也没有邻居。现在我们可以构建我们想要找到解决方案的结构,我们可以解决人工智能的核心问题:搜索问题! 4.5 更广泛的搜索 知道我们能够从给定的字典构建梯形图,我们需要执行的唯一计算复杂的任务就是在其中进行搜索。我们算法的另一个要求是它需要找到最短路径来产生最佳的词梯。我们的图表的特殊之处在于它具有统一的边缘成本,因为它们都没有被加权。在这种情况下,我们保证通过广度优先搜索找到最短路径!

让我们考虑一下图形。当寻找路径时,我们从某个节点开始。从那里我们需要访问每个邻居,然后对我们之前未访问过的每个新节点的每个邻居重复此过程。执行此类搜索时,我们创建一系列节点层。这样的序列如图4.5 所示。 图4.5。图中节点的广度优先排序

但是,我们需要小心!为了构建广度优先搜索,我们还必须更新正在访问的新节点的边界。我们不能只对每个节点执行递归搜索,因为这将是深度优先搜索,我们放弃在给定节点的每个邻居中搜索,而是立即继续搜索我们找到的第一个邻居。因此,在搜索时,我们必须跟踪当前正在访问的节点,并在每一步中相应地更新它们。如图4.6所示。 图4.6。广度优先搜索示例

我们不仅需要搜索路径是否存在,还需要确定哪些节点是路径的一部分,以便生成单词梯子游戏的解决方案。为了实现这一点,我们必须保留搜索节点的历史记录,并在搜索节点 时跟踪节点

的前辈。为了做到这一点,我们必须确保我们不会两次访问同一个节点,因为每个节点 都必须有一个前驱节点,否则我们需要再次搜索整个图来找到我们第一个找到的节点实际路径。为了解决这个问题,我们可以做的是删除我们在图中搜索时从图中看到的每个节点。这对于我们的目的来说可以吗?答案是 肯定是!我们不能通过访问一个节点两次来获得从一个节点到另一个节点的最短路径,因为它不可能是最短的!连接同一节点的两次访问的节点可以被删除,以生成仍然连接起始节点到结束节点的较短路径。该前身图的创建如图4.7 所示。 图4.7。在图上搜索的示例(从1 到6 搜索)以及每个步骤中构建的前驱示例 让我们回顾一下这一点。

我们的搜索算法需要执行以下操作: 从图开始,以起始节点作为初始边界收集边界中每个节点的所有邻居节点从图中删除当前边界将边界中的每个节点存储为各自的边界相邻节点的前驱节点检查目标节点是否是相邻节点的一部分。如果是,则搜索结束,并且可以回溯前趋任务来寻找路径。如果没有,则继续使用相邻节点作为新边界进行搜索(步骤2) [ x] 为了一次删除多个节点,我们需要引入一个新函数来为我们执行此操作。与之前的实现类似,对于应从图中删除的每个元素,我们从AssocMap 模块递归调用delete。代码如例4.14 所示。

清单4.14。计算阶梯游戏下一步的所有有效候选者的函数deleteNodes:Eqa=[a]-DiGrapha-DiGraphandeleteNodes[]graph=graphndeleteNodes(x:xs)graph=M.deletex(deleteNodesxsgraph)

[ x]从我们对搜索算法的描述中可以清楚地看出,我们需要处理某种状态。我们需要跟踪我们的边界,还需要跟踪我们正在搜索的图(因为我们正在从中删除节点)和我们的前辈。幸运的是,我们已经有了一种可以用于此目的的类型:我们的有向图类型!因此,我们可以将我们的搜索状态表示为它自己的类型,其中包含作为元素列表的边界、我们的图以及我们用来跟踪前趋的另一个图:

typeSearchStatea=([a] , DiGrapha,DiGrapha) 另外,我们定义搜索结果的类型。它要么失败,要么成功,返回我们可以找到的前驱。假设前端图将使回溯解决方案成为可能!

dataSearchResulta=Unsuccessful|Successful(DiGrapha) 在我们的实际搜索中,我们必须执行两个任务:执行实际搜索并相应更新状态,然后回溯前驱任务以获得搜索路径。我们函数的总体框架可能如下所示:

bfsSearch:foralla.Eqa=DiGrapha-a-a-Maybe[a]nbfsSearchgraphstartendn|start==end=Just[start]n|otherwise=ncasebfsSearch ‘ ([start],graph,empty)ofnSuccessfulpreds-Just(findSolutionpreds)nUnsuccessful-Nothing我们的函数应该在图中搜索连接起始节点和结束节点的路径。我们返回路径可能是因为搜索可能不成功,在这种情况下我们当然不会返回任何内容。我们现在专注于实施bfsSearch’。该函数旨在处理搜索状态并返回一个布尔值,告诉我们搜索是否成功以及前置任务。可以使用新创建的deleteNodes 函数从图中删除当前边界。当从当前边界中的每个节点收集所有连接的节点(或子节点)时,我们必须确保根据它们在已删除节点的现已更新的图中的成员资格来过滤它们,因为我们不想添加该节点边框不再是我们绘图的一部分。为了将所有这些新节点添加为先前节点,我们构造了一个新的辅助函数,对于节点元组及其连接节点的列表,将它们反向添加到图中,从而向图中添加一条边来表示哪个节点在前面搜索中的节点。这些函数(在我们的bfsSearch 函数中用作本地定义)如清单4.15 所示。

示例4.15。用于执行广度优先搜索以查找两个节点之间的最短路径的辅助函数

addMultiplePredecessors:Eqa=[(a,[a])]-DiGrapha-DiGraphanaddMultiplePredecessors[]g=g naddMultiplePredecessors((n,ch) :xs)g=naddMultiplePredecessorsxs(gonchg)#1nwherengon[]g=gngon(x:xs)g=gonxs(addEdge(x,n)g)#2n nbfsSearch’:Eqa=SearchStatea-SearchResulta nbfsSearch'([],_,preds)=失败#3nbfsSearch'(frontier,g,preds)=nletg’=删除Nodesfrontierg#4nch=nL.mapn( \n-(n,L .filter(`M.member`g’)(childrenng)))#5nfrontiernfrontier’=L.concatMapsndch#6npreds’=addMultiplePredecessorschpreds#7ninifend`L.elem `frontier’#8nthenSuccessfulpreds’ #9nelsebfsSearch'(frontier’,g’,preds’)#10最后的难题是用于从前图找到解的回溯算法。我们知道,该图中的节点只有一个前导节点,除了起始节点之外,起始节点是唯一不包含前导节点的节点。这允许我们从结束节点递归地追溯到起始节点。一旦找不到更多老人,我们就可以停止搜寻。该算法的代码如例4.16 所示。请注意,执行搜索的辅助函数以相反的顺序生成解决方案。此外,为了使该算法发挥作用,必须存在一个解决方案,并且我们对前图所做的假设必须成立! 清单4.16。前图查找路径的回溯算法

findSolution:Eqa=DiGrapha-[a]nfindSolutiong=L.reverse(goend)#1nwherengox=ncasechildrenxgof#2n []-#3 n(v:_)-x:gov#4现在我们可以把所有东西放在一起了!将示例4.15 和示例4.16 中的定义添加到带有where 子句的代码骨架后,我们就得到了搜索算法的最终定义!但是,这不会编译,而是出现错误:

?Couldn’tmatchexpectedtype’a1’withactualtype’a’n’a1’isarigidtypevariableboundbynthetypesignaturefor:nfindSolution:foralla1.Eqa1=DiGrapha1-[a1] nat ./ladder/src/Graph.hs:n’a’isrigidtypevariableboundbynthetypesignaturefor:nbfsSearch:foralla.Eqa=DiGrapha-a-a-Maybe[a]nat./ladder/src/Graph.hs:发生了什么事?由于某种原因, bfsSearch 和findSolution 的类型似乎不匹配,但为什么呢?它们不都是多态的吗?两者甚至具有相同的类型约束和名称,因此类型应该兼容!

4.5.1 类型问题修复 要修复此问题,让我们再次查看类型表达式。 Haskell 向我们隐瞒的是这样的类型表达式不存在: const:a-b-a

Haskell 实际上对这种类型的看法略有不同:

const:forallab.a-b-a 这称为通用量化,默认情况下隐式执行到所有包含自由类型变量的类型签名的最外层。 forall 将一个新类型变量带入声明要使用的函数的范围内。在函数声明之外,这可以被解释为一个承诺:该声明适用于a 和b 可以替换的所有类型。

当我们使用这个函数时,很容易看出这个承诺是成立的:

ghciconst(1:Int)(‘Hello’:String)n1nghciconst(True:Bool)(3.1415:Float)nTruenghciconst(() :())((\x-x):(a-a))n() 我们可以将a 和b 替换为任何类型,仍然得到结果!虽然这是函数声明之外的承诺,但它是函数声明内部定义的限制!这是有道理的,因为具体类型不是由函数声明选择的,而是由函数的被调用者选择的!在函数声明中,类型是固定的(有时在错误消息中称为刚性)。

f:a-anfx=yn其中y=x 本例中的类型隐式更改为foralla.aa,因此引入了类型变量a,也声明为Type变量a是固定的。可以推断x是a类型,由于y=x,所以y也一定是a类型。类型正确!但是如果我们向x 添加类型表达式并将其声明为类型a 会怎么样?

如何设置软路由作为阶梯?组装软路由

f:a-anfx=ynwherey=(x:a)这将再次抛出相同的错误!但为什么?添加隐式全称量化后,看起来像这样:

f:foralla.a-anfx=yn其中y=(x:foralla.a)foralla.aa将变量a限制为任意,但是已修复函数声明。这也适用于where 子句中的声明!然而,x 的a.a 做出了一个承诺,它可以是任何类型。这个承诺是对函数定义的其余部分做出的,最终导致类型不兼容! x 不能同时是固定类型和任意类型!编译器

在检查这些属性时不考虑类型的名称! 正是构建我们的搜索函数时出现的问题。幸运的是,我们可以告诉Haskell 对类型变量执行词法作用域,这意味着当forall 引入类型变量时,它可以在函数声明的类型中重用,并且仍然引用相同的类型。我们可以通过使用所谓的语言扩展来实现这种行为。这些扩展允许我们全局(通过使用编译器标志)或每个文件更改Haskell 编译器的行为。我们感兴趣的语言扩展称为作用域类型变量。我们可以通过在模块的开头添加以下行来启用此功能: {-#LANGUAGEScopedTypeVariables#-}nnmoduleGraphwhere

[x ] 现在,这允许我们在类型定义并更改其行为。 Forall 现在引入了词法范围的类型变量!这使我们能够通过显式量化最外层类型签名中的类型变量来构造函数。如清单4.17 所示。 示例4.17。使用词法作用域类型变量

f 的示例:foralla.a-a#1nfx=ynwherey=(x:a)#2 一个很好的副作用是类型上的约束会转移到其他定义中,因此我们必须只对输入签名!另请注意,未显式使用forall 的函数定义的行为仍与以前相同。 重要

forall 的用法是重载的,并且根据所使用的语言扩展具有不同的含义。除了ScopedTypeVariables 之外,还有RankNTypes 和ExistentialQuantification,它们对类型系统的工作方式有着深远的影响。一般来说,只有在需要时才应使用显式forall!然而,ScopedTypeVariables 相对安全,并且在许多项目中全局启用。 修改搜索函数的类型后,我们得到了搜索算法的完整(和编译的)定义!完整的源代码如清单4.18 所示。请注意最外层类型签名中的显式forall,以及类型约束Eqa 仅出现在该签名中,因为类型变量a 现在在整个函数声明中可用。 示例4.18。使用广度优先搜索以统一成本搜索有向图中最短路径的函数

typeSearchStatea=([a],DiGrapha,DiGrapha)#1nndataSearchResulta=Unsuccessful|Successful(DiGrapha) #2n nbfsSearch:foralla.Eqa=DiGrapha-a-a-Maybe[a]#3nbfsSearchgraphstartendn|start==end=Just[start]#4n|otherwise=ncasebfsSearch'([start],graph,empty)of# 5n成功的preds-Just(findSolutionpreds)#6n不成功-什么都没有nwherenfindSolution:DiGrapha-[a]nfindSolutiong=L.reverse(goend)#7nwherengox=ncasechildrenxgof#8n[] – #9n(v:_)-x:gov#10nnaddMultiplePredecessors:[(a,[a])]-DiGrapha-DiGraphanaddMultiplePredecessors[]g=gnaddMultiplePredecessors((n,ch):xs)g=naddMultiplePredecessorsxs( gonchg)#11n其中ngon[]g=gngon(x:xs)g=gonxs(addEdge(x,n)g)#12nnbfsSearch’:SearchStatea-SearchResultanbfsSearch'([] ,_,preds )=失败#13nbfsSearch'(frontier,g,preds)=nletg’=删除Nodesfrontierg#14nch=nL.mapn(\n-(n,L.filter(`M .member`g ‘)(childrenng)))#15nfrontiernfrontier’=L.concatMapsndch#16npreds’=addMultiplePredecessorschpreds#17ninifend`L.elem`frontier’#18nthenSuccessfulpreds’#19nelsebfsSearch’ (frontier’, g’,preds’)#20

我们通过跟踪访问的节点、修改的图和找到的边界图,为直接图构建广度优先搜索算法,以找到最短路径。前驱图是根据访问的节点构建的,然后用于通过回溯找到实际的解决方案。

练习 为了搜索图中的最短路径,我们使用广度优先搜索。然而,还存在其他搜索算法。如果我们只想找到任何路径并且对其长度不感兴趣,那么深度优先搜索可能是合适的。另外,相对于普通广度优先搜索的性能改进是双向广度优先搜索,它从两侧同时执行两次搜索,一个从头到尾,另一个从头到尾。一旦这两个搜索相遇,解决方案就找到了。实现两种搜索算法! 现在我们能够构建梯形图并找到其中的最短路径,我们拥有构建程序所需的所有部分!

4.6 玩梯子游戏 构建一个解决字梯游戏的函数相当简单。我们所需要的只是字典的开头和结尾单词,我们就完成了!我们可以使用mkLadderGraph 函数简单地构建梯形图,并使用我们的搜索算法搜索解决方案!梯形图模块中的代码如清单4.19所示。

示例4.19。用于搜索梯子游戏最佳解决方案的函数ladderSolve:Dictionary-String-String-Maybe[String]nladderSolvedictstartend=nletg=mkLadderGraphdict#1ninG.bfsSearchgstartend#2

[x ] 我们可以保留main该程序的模块相当简单。就像上一章一样,如果参数数量与我们的期望不符,我们会提供帮助文本。否则,我们只需使用readDictionary 操作构建字典并使用上面提到的ladderSolve 函数求解它。然后我们打印解决方案及其长度。该模块的完整代码如清单4.20 所示。 清单4.20。字梯解算器的主模块

moduleMainwherennimportLadder#1nimportSystem.EnvironmentnnprintHelpText:String-IO()#2nprintHelpTextmsg=donputStrLn(msg++’\n’ )nprogName-getProgNamenputStrLn (‘Usage:’++progName++’filenamestartend’)nnmain:IO()nmain=donargs-getArgs#3ncaseargsofn[dictFile,start,end]-do#4 ndict-readDictionarydictFile#5ncaseladderSolvedictstartendof #6nNothing-putStrLn’Nosolution’nJustsol-donprintsolnputStrLn(‘Length:’++show(lengthsol))n_-printHelpText’Wrongnumberofarguments!’print 该操作只是使用组合show 和putStrLn 并将值打印到stdout。现在可以测试该应用程序了!

为此,代码库准备了两个词典文件,small_dictionary.txt包含200个单词,large_dictionary.txt包含58110个单词。在我们的项目目录中,我们现在可以这样调用程序: shell$stackrun –path/to/small_dictionary.txtcatflowern[‘cat’,’oat’,’lot’,’volt ‘, ‘love’,’元音’,’lower’,’flower’]nLength:8nshell$stackrun –path/to/small_dictionary.txtdogbookn[‘dog’,’dot’,’lot’,’tool ‘, ‘look’,’book’]nLength:6

这看起来不错!那么让我们用更大的字典来测试一下吧!出色地。遗憾的是,我们无法观察该程序的全部功能,因为至少在我的机器上,它似乎没有产生结果。只是时间太长了!我们应该以某种方式改进这个. 4.6.1 的阻止程序是什么?

在决定要改进什么之前,我们应该首先分析哪个操作花费了这么长时间。为此,我们想介绍一下我们的程序。我们可以通过首先使用–profile 标志使用堆栈编译程序来做到这一点。这将在GHC 中设置一些选项,以在运行时启用应用程序分析。然后,我们可以使用+RTS-p-RTS 设置基本时间和内存分析的运行时选项。 +RTS 和-RTS 用于开始和结束向Haskell 运行时系统而不是普通应用程序提供参数。

程序的完整调用如下所示: shell$stackrun–profile–path/to/small_dictionary.txtdogbook+RTS-p-RTS

当程序完成时,会生成一个文件将被创建,在我们的例子中它被称为ladder-exe.prof。此文件包含分析信息,如下所示:

MonJul2516:222022TimeandAllocationProfilingReport(Final)nnladder-exe+RTS-N-p-RTSpath/to/small_dictionary .txtdogbooknntotaltime=0.03secs(100ticks@1000us, 8 个处理器)ntotalalloc=46,192,080bytes(不包括分析开销)nnCOSTCENTREMODULESRC%time%allocnnlookup.lookup’Data.AssocMapsrc/Data/AssocMap.hs:(5 4 ,5)-(57,34)54.00.1 ncomputeCandidates.uniquesLaddersrc/Ladder.hs:19:7-5021.026.9nmember.member’Data.AssocMapsrc/Data/AssocMap.hs:(24,5)-(27,32)14.00 .0 nalter.alter’data.Assocmapsrc/Data/Assocmap.HS: (33,5)-(43,40) 5.044.7 nlookupPerMapsrc/PermutationMap.hs3:33:1 -343.012.3 ” )-(14,22)2.00.2ncomputeCandidates.permsLaddersrc/Ladder.hs:20333 607-690.01.7 ncomputeCandidates.modifiedLaddersrc/Ladder.hs:(25,5)-(26,58)0.07.1ncomputeCandidates.canditatesLaddersrc/La dder .hs:18:7-570.02.5这是我们计划成本的简要概述中心,主要由我们的职能组成。这告诉我们每个函数花费了多少时间以及在其中分配了多少内存。由此我们可以看出,大量的时间都用在了AssocMap模块中的搜索功能上。整个运行时间的一半以上都是由这个函数组成的!这是有道理的,因为我们不断地在图中查找值,而这只是一个AssocMap。所以如果我们想要加快程序的执行速度,就必须对其进行优化!

问题是:这个函数有什么问题?这是构成我们的地图的关联列表中的简单查找。在最坏的情况下,它必须在每次查找时迭代整个列表!这是有道理的,更大的字典会导致更大的图表,这将导致查找值的时间更长!可悲的是,这只是我们在处理关联列表时面临的缓慢现实。这个缺点是他们设计中固有的!我们需要做的就是用更快的东西完全取代它。哈希图是一个很好的候选者,它以其快速访问时间而闻名。 但是,这次我们不是构建自己的哈希图。我们将简单地使用已经可用的那个!为此,我们需要将依赖项Unordered Container 和Hashable 添加到我们的项目中。为此,我们编辑package.yml 文件以包含这些依赖项。文件的相关部分应如下所示:

dependency:n-base=4.75n-unordered-containersn-hashable

这将自动使堆栈负责下载和构建我们项目的依赖关系。现在我们可以用Data.HashMap.Lazy 替换Data.AssocMap 模块。此外,在Graph 和Pareto Chart 模块中,我们需要更改类型以使用M.HashMap 而不是M.AssocMap。为了使值成为HashMap 中的键,它需要具有Hashable 类型类的实例。因此,我们需要更改Graph 模块中的类型签名,以将Hashablea 包含在其类型约束中。该类提供了HashMap 中使用的哈希方法,可以从Data.Hashable 模块导入。 注意

将AssocMap 与HashMap 切换并不是巧合,因为我们基本上实现了HashMap 模块中也存在的相同功能。

如果您了解AssocMap 的这些函数如何工作,那么您现在也知道HashMap 模块如何工作! 再次编译并运行程序后,我们甚至可以处理大型字典了!通过再次启用分析运行器再次查看成本中心,向我们展示了一些非常有趣的东西:

computeCandidates.uniquesLaddersrc/Ladder.hs:19:7-5067.434.7nreadDictionaryLaddersrc/Ladder.hs:(10,1) -(14, 22)4.70.2nliftHashWithSalt.stepData.Hashable.Classsrc/Data/Hashable/Class.hs:656:9-464.77.2nliftHashWithSaltData.Hashable.Classsrc/Data/Hashable/Class.hs:(653,5)-(656,46)4.70.0nlookup#Data.HashMap。 InternalData/HashMap/Internal.hs:597:1-822.31.3ninsert’.goData.HashMap.InternalData/HashMap/Internal.hs:(759,5)-(788,76)2.31.1

我们的搜索速度所以计算帕累托图中查找的唯一候选者似乎是一个主要的时间段!幸运的是,我们可以简单地摆脱它,因为我们添加它只是为了最大限度地减少必须在帕累托图中执行的查找次数。现在地图实施得如此之快,这不再是必要的了!又一个性能提升!对应用程序进行一些分析后,但这次使用大型字典,我们得到了另一个令人惊讶的结果:

readDictionaryLaddersrc/Ladder.hs:(10,1)-(14,22) 95.75.5nreadDictionary。 WordsLaddersrc/Ladder.hs:13:7-601.36.0nalterPermutationMapsrc/PermutationMap.hs:22:1-360.418.5nreadDictionary.linesLaddersrc/Ladder.hs:12:7- 390.414.5ninsert’ .goData.HashMap.InternalData/HashMap/内部.hs:(759,5 )-(788,76)0.35.8我们花费最多的时间只是读取文件

!这是个好消息,因为它告诉我们我们的算法本身已经非常优化了。然而,有一种方法可以改善文件的读取。到目前为止,我们已经在不知不觉中愉快地使用了性能杀手。罪魁祸首就是字符串。问题在于字符串的构造。它们是列表中的单个Char 值,问题是列表位于堆上,这意味着字符串访问非常昂贵。当性能至关重要时,必须避免使用字符串类型。适当的替换是文本包中的文本类型或字节包中的字节字符串。两者都提供了性能更高、更紧凑的字符串表示形式。它们的缺点是我们不能使用通常的列表函数,这使得它们的可移植性稍差。我们不会详细讨论如何用任何更快的方法替换我们的字符串使用,因为性能改进是微不足道的。但是,好奇的读者可以自由查看代码存储库以获取该项目的优化版本! 重要在设计性能程序时,切换类型应该是最后的手段。算法和数据结构的正确选择比技术细节重要得多。 我们希望通过仔细研究Haskell 中的评估工作原理来结束对性能的讨论。与其他语言非常不同,Haskell 是一种惰性求值语言。这意味着不会立即计算表达式,而是仅在强制计算表达式时才计算。作为一个例子,让我们看一下:

ghciconstxy=xnghciconst01000^100000000n0

const 该函数采用两个参数,其中第二个参数被完全丢弃。我们进行评估期间会发生什么?表达式const01000^100000000 减少为仅0,因为constxy 减少为x。第二个参数是一个巨大的数字,需要很长时间才能计算,因此被丢弃,因此不进行评估!您可以查看附录B 了解更多详细信息!

我们的算法也利用了这种惰性评估!在寻找解决方案之前,我们不会构建整个图。我们在搜索时正在构建图表!仅评估图中所需的元素。然而,只有当我们所有的数据结构都是惰性的时,这才有效。默认情况下,我们自己定义的列表和类型是惰性的。当使用哈希映射来提高性能时,我们专门导入了HashMap 的惰性版本,它不会强制对值进行求值。

注意 虽然看起来惰性求值通常比严格求值更受青睐,但事实并非如此。有些算法,比如本章中的搜索算法,可以从惰性中受益,而另一些算法则受到严重影响。懒惰还可能导致意外的内存使用,这就是为什么一些开发人员在语言扩展的帮助下在项目中完全禁用它。

让我们回顾一下我们在这个项目上取得的成果。我们创建了一个人工智能,在给定单词词典的情况下,它能够找到单词梯游戏修改版本的最短解决方案。我们通过实现一种算法来做到这一点,该算法创建一个表示游戏可能解决方案的图表,并通过搜索该图表找到解决方案。为此,我们使用关联列表实现了我们自己的映射版本,并将此类功能捆绑到自己的模块中以实现可重用性。基于此实现,我们创建了另一种映射类型,用于快速检索给定单词的有效排列,以使我们的程序可行。使用我们的自定义地图实现,该图被建模为邻接图。我们使用广度优先搜索来寻找最短路径。在测试和分析程序之后,我们通过哈希映射切换从关联列表构建的映射,从而提高了其性能。

现在我们已经准备好进入下一届Word Ladder世界锦标赛.如果这样的事情存在的话。 4.7 小结类型类用于定义一组类型的函数,以便为这组类型构造多态函数。 Toggle 可用于翻转二元函数的参数。当以纯粹的方式处理数据结构时,我们从不直接修改它们,而是创建原始值的修改版本。导出列表可用于隐藏类型和函数的构造函数,以确保被调用者无法使我们的值的不变量无效。对于只有一个构造函数和一个字段的数据类型,我们可以使用newtype 代替data 来明确我们想要在不需要时创建一种新类型。 where 可用于定义在表达式中使用的变量和函数。合格的导入迫使我们在导入的值前面加上模块名称,使代码更清晰并防止名称冲突。语言扩展ScopedTypeVariables 可用于显式使用forall 来对类型变量进行词法作用域。使用–profile 作为堆栈标志和+RTS-p-RTS 作为应用程序的运行时选项,我们可以让程序分析其执行情况。惰性求值仅求值绝对必要的表达式,而丢弃其他所有内容。

Tiktok网络

雷神专属IP和普通IP有多大区别? Thunderbolt IP 每月费用是多少?

2023-9-14 4:01:02

Tiktok网络

轻量级服务器和云服务有很大区别吗? ECS和轻量级服务器

2023-9-14 4:21:32

搜索