在 “项目 ”章节中,我会暂时停止向你灌输新的理论,而是让我们一起完成一个程序。学习编程,理论是必要的,但阅读和理解实际程序同样重要。
本章的课题是建立一个自动机,一个在虚拟世界中执行任务的小程序。我们的自动机将是一个收发包裹的机器人。
梅多菲尔德
梅多菲尔德村并不大。它由 11 个地方组成,中间有 14 条路。可以用这样的道路阵列来描述它:
村里的道路网构成了一幅图。图是点(村庄中的地点)与线(道路)的集合。这个图将是我们的机器人移动的世界。
字符串数组并不容易处理。我们感兴趣的是从给定地点可以到达的目的地。让我们把道路列表转换成一个数据结构,告诉我们每个地点可以到达的目的地。
给定一个边数组后,buildGraph 会创建一个 map 对象,为每个节点存储一个连接节点数组。它使用 split 方法将道路字符串(其形式为 “Start-End”)转换为包含开始和结束的双元素数组,作为单独的字符串。
任务
我们的机器人将在村子里移动。在不同的地方都有包裹,每个包裹都寄往其他地方。机器人遇到包裹就捡起来,到达目的地后再送出去。
每到一处,自动机都必须决定下一步去哪里。当所有包裹都送完时,它就完成了任务。
为了能够模拟这一过程,我们必须定义一个能够描述这一过程的虚拟世界。这个模型会告诉我们机器人在哪里,包裹在哪里。当机器人决定移动到某个地方时,我们需要更新模型,以反映新的情况。
如果你采用面向对象编程的思维方式,你的第一反应可能是开始为世界中的各种元素定义对象:一个机器人类,一个包裹类,也许还有一个地点类。这些对象可以持有描述其当前状态的属性,例如某个地点的包裹堆,我们可以在更新世界时改变这些属性。
这是错误的。至少通常是这样。听起来像对象的东西并不自动意味着它在程序中就应该是对象。为应用程序中的每一个概念都编写类的做法,往往会使你的程序成为一个相互关联的对象集合,而每个对象都有自己的内部变化状态。这样的程序往往难以理解,因此很容易被破解。
取而代之的是,让我们把村子的状态浓缩为定义它的一组最小值。这里有机器人的当前位置和未交付包裹的集合,每个包裹都有一个当前位置和目的地地址。就是这样。
同时,让我们在机器人移动时不改变这个状态,而是为移动后的情况计算一个新的状态。
移动方法是进行操作的地方。它首先会检查是否有一条路可以从当前位置通往目的地,如果没有,就会返回旧的状态,因为这不是一个有效的移动。
接下来,该方法会创建一个新状态,将目的地作为机器人的新位置。它还需要创建一组新的包裹--机器人携带的包裹(在机器人当前位置)需要被移动到新位置。此外,还需要交付寄往新地点的包裹,也就是说,需要将它们从未曾交付的包裹中移除。地图调用负责移动,过滤调用负责递送。
包裹对象在移动时不会改变,而是会重新创建。移动方法为我们提供了一个新的村庄状态,但旧的状态则完全保留。
移动导致包裹被送达,这反映在下一个状态中。但初始状态描述的仍然是机器人在邮局、包裹未送达的情况。
持久数据
不会改变的数据结构被称为不可变或持久性数据结构。它们的行为很像字符串和数字,因为它们就是它们,并保持不变,而不是在不同的时间包含不同的内容。
在 JavaScript 中,几乎所有东西都可以更改,因此在处理本应是持久的值时需要有所克制。有一个名为 Object.freeze 的函数可以更改对象,从而忽略对其属性的写入。如果你想谨慎起见,可以用它来确保你的对象不会被更改。冻结确实需要计算机做一些额外的工作,而忽略更新和让人做错事情一样,都会让人感到困惑。我通常更倾向于告诉人们某个对象不应该被更改,并希望他们记住这一点。
为什么语言明明希望我改变对象,我却偏偏不改变呢?因为这有助于我理解我的程序。这又与复杂性管理有关。当系统中的对象是固定、稳定的事物时,我可以孤立地考虑对它们的操作--从给定的起始状态移动到爱丽丝的房子,总会产生相同的新状态。当对象随时间发生变化时,这种推理的复杂性就会增加一个全新的维度。
对于我们在本章中构建的这种小型系统,我们可以处理这种额外的复杂性。但是,我们能构建什么样的系统,最重要的限制在于我们能理解多少。只要能让代码更容易理解,我们就有可能构建一个更宏伟的系统。
不幸的是,虽然理解一个基于持久性数据结构的系统比较容易,但设计一个系统,尤其是当你的编程语言无法提供帮助时,可能会有点困难。我们将在本书中寻找使用持久化数据结构的机会,但我们也将使用可改变的数据结构。
模拟
一个送货机器人会观察世界,然后决定要向哪个方向移动。因此我们可以说,机器人是一个函数,它接收一个 VillageState 对象,并返回附近一个地方的名称。
因为我们希望机器人能够记住事物,以便制定和执行计划,所以我们也将它们的内存传递给它们,并允许它们返回一个新的内存。因此,机器人返回的是一个对象,其中既包含它想要移动的方向,也包含下次调用时将返回给它的内存值。
考虑一下机器人在 “解决 ”给定状态时需要做些什么。它必须通过访问每个有包裹的地点来拾取所有包裹,并通过访问每个有包裹的地点来递送包裹,但只有在拾取包裹后才能递送。
最笨的策略是什么?机器人可以每轮随机行走。这就意味着,它很有可能最终会碰到所有的包裹,然后也会在某一时刻到达包裹应该送达的地方。
下面就是这种情况:
请记住,Math.random() 返回一个介于 0 和 1 之间的数字,但总是低于 1。将这个数字乘以数组的长度,然后应用 Math.floor,就能得到数组的随机索引。
由于这个机器人不需要记住任何东西,因此它忽略了第二个参数(请记住,JavaScript 函数可以调用额外的参数,而不会产生不良影响),并在返回的对象中省略了内存属性。
要让这个复杂的机器人开始工作,我们首先需要一种方法来创建一个包含一些包裹的新状态。静态方法(这里是通过在构造函数中直接添加一个属性来编写的)是实现这一功能的好地方。
我们不希望任何包裹从地址相同的地方寄出。因此,当 do 循环得到与地址相同的地址时,就会不断选择新的地址。
让我们创建一个虚拟世界。
由于机器人没有提前做好计划,所以它要转很多圈才能把包裹送到。我们很快就会解决这个问题。
如果想更直观地了解模拟情况,可以使用本章编程环境中的运行机器人动画(runRobotAnimation)函数。该函数将运行模拟,但不会输出文本,而是显示机器人在村庄地图上移动的画面。
runRobotAnimation 的实现方式暂时还是个谜,但当你读完本书后面讨论 JavaScript 与网页浏览器集成的章节后,你就能猜到它是如何工作的了。
邮车的路线
我们应该能比随机机器人做得更好。一个简单的改进方法就是借鉴现实世界中的邮件投递方式。如果我们找到一条能经过村里所有地方的路线,那么机器人就可以在这条路线上运行两次,这样就能保证完成任务。下面就是这样一条路线(从邮局开始):
为了实现机器人的路线跟踪功能,我们需要利用机器人内存。机器人会将其余路线保存在内存中,并在每一轮中丢弃第一个元素。
这个机器人已经快很多了。它最多需要转 26 圈(13 步路线的两倍),但通常会更少。
寻路
不过,盲目地按照固定路线行驶并不能称得上是智能行为。如果机器人能根据需要完成的实际工作调整自己的行为,那么它的工作效率会更高。
要做到这一点,机器人就必须能够有意识地向某个包裹或必须运送包裹的地点移动。要做到这一点,即使距离目标不止一步之遥,也需要某种寻路功能。
在图中寻找路线的问题是一个典型的搜索问题。我们可以判断给定的解决方案(路线)是否有效,但不能像计算 2 + 2 那样直接计算解决方案。相反,我们必须不断创造潜在的解,直到找到一个有效的解为止。
图中可能的路线数量是无限的。但在寻找从 A 到 B 的路线时,我们只对从 A 出发的路线感兴趣。我们也不关心那些两次访问同一地点的路线,因为这些路线绝对不是最有效的路线。因此,这就减少了路线搜索器需要考虑的路线数量。
事实上,由于我们主要关注的是最短的路线,因此我们要确保在查看较长路线之前先查看较短的路线。一个好的方法是从起点开始 “增长 ”路线,探索每一个尚未到达的地方,直到路线到达目标。这样,我们只会探索那些潜在的有趣路线,而且我们知道我们找到的第一条路线是最短的。
下面的函数可以实现这一功能:
探索必须按照正确的顺序进行--先到达的地方必须先被探索。我们不能一到达一个地方就立即探索,因为这意味着从那里到达的地方也要立即探索,以此类推,即使可能还有其他更短的路径尚未探索。
因此,该函数保留了一个工作列表。这是一个数组,列出了下一步应该探索的地点,以及到达这些地点的路线。开始时,它只有起始位置和一条空路线。
然后,搜索会选择列表中的下一个项目进行探索,这意味着它会查看从该地点出发的所有道路。如果其中一条是目标,就可以返回一条完成的路线。否则,如果我们之前没有搜索过这个地方,就会在列表中添加一个新项目。如果我们以前查看过这个地方,由于我们首先查看的是短路线,所以我们要么已经找到了一条更长的通往这个地方的路线,要么已经找到了一条和现有路线一样长的路线,我们就不需要再去探索它了。
你可以把它想象成一张由已知路线组成的网,从起点位置爬出,向四面八方均匀延伸(但绝不会纠缠在一起)。一旦第一个线程到达目标位置,该线程就会被追溯到起点,从而得到我们的路线。
我们的代码不会处理工作列表中没有工作项的情况,因为我们知道我们的图是连通的,这意味着每个位置都可以从所有其他位置到达。我们总能找到两点之间的路线,而且搜索不会失败。
这个机器人将其记忆值作为移动方向列表,就像路线跟踪机器人一样。只要该列表为空,它就必须想出下一步该怎么做。它会选择第一个未投递的包裹,如果该包裹还未被取走,它就会规划出一条前往该包裹的路线。如果包裹已被取走,但仍需送达,那么机器人就会创建一条前往送货地址的路线。
让我们看看它是怎么做的。
这个机器人通常在大约 16 个回合内完成运送 5 个包裹的任务。这比 routeRobot 稍微好一些,但仍绝对不是最佳状态。我们将在练习中继续完善它。
练习
测量机器人
仅仅让机器人解决几个场景是很难对它们进行客观比较的。也许其中一个机器人只是碰巧完成了更容易的任务或它擅长的任务,而另一个机器人却没有。
编写一个函数 compareRobots,接收两个机器人(以及它们的起始内存)。它应该生成 100 个任务,让两个机器人分别解决这些任务。完成后,它应该输出每个机器人完成每个任务的平均步数。
为了公平起见,请确保每个任务都分配给两个机器人,而不是每个机器人生成不同的任务。
代码
//机器人比较方法
function runRobotForCompare(state, robot, memory) {
for (let turn = 0; ; turn++) {
//执行完毕
if (state.parcels.length == 0) {
return turn;
}
//告诉机器人移动去哪里
let action = robot(state, memory);
//机器人往目标方向移动
state = state.move(action.direction);
//更新内存信息
memory = action.memory;
}
}
//比较机器人
function compareRobots(robot1, memory1, robot2, memory2) {
let rd = VillageState.random();
let robot1Count = runRobotForCompare(rd, robot1, memory1);
let robot2Count = runRobotForCompare(rd, robot2, memory2);
console.log("robot1,count:", robot1Count, "robot2,count:", robot2Count);
}
compareRobots(routeRobot, [], goalOrientedRobot, []);
机器人效率
您能编写一个比 targetOrientedRobot 更快完成交付任务的机器人吗?如果您观察该机器人的行为,它会做哪些明显愚蠢的事情?如何改进?
如果您解决了前面的练习,您可能想使用您的 compareRobots 函数来验证您是否改进了机器人。
代码:使用迪杰斯特拉算法,减少执行次数?
持久组
标准 JavaScript 环境中提供的大多数数据结构都不太适合持久化使用。数组有 slice 和 concat 方法,可以让我们在不损坏旧数组的情况下轻松创建新数组。但是,例如 Set,却没有任何方法可以在添加或删除一个项目后创建一个新的集合。
编写一个新类 PGroup,类似于第 6 章中的 Group 类,用于存储一组值。与 Group 类一样,它也有 add、delete 和 has 方法。不过,它的 add 方法应该返回一个新的 PGroup 实例,并添加给定的成员,而旧的成员则保持不变。同样,delete 方法应创建一个不包含给定成员的新实例。该类应适用于任何类型的值,而不仅仅是字符串。在使用大量数值时,该类的效率不必很高。
构造函数不应该是类的接口的一部分(尽管你肯定希望在内部使用它)。取而代之的是一个空实例 PGroup.empty,它可以用作起始值。为什么只需要一个 PGroup.empty 值,而不是每次都创建一个新的空映射?
代码:
class PGroup {
arr = [];
add(val) {
if (!this.has(val)) {
this.arr.push(val);
return this;
}
}
delete(val) {
this.arr = this.arr.filter(n => n !== val);
return this;
}
has(val) {
return this.arr.indexOf(val) !== -1;
}
static empty() {
return new PGroup;
}
}
let a = PGroup.empty().add('a');
let ab = a.add('ab');
console.log(a.has('a'));
console.log(a.has('ab'));
let c = ab.delete('a');
console.log(c.has('a'));