7. 机器人项目

在 "项目 "章节中,我会暂时停止向你灌输新的理论,而是让我们一起完成一个程序。学习编程,理论是必要的,但阅读和理解实际程序同样重要。

本章的课题是建立一个自动机,一个在虚拟世界中执行任务的小程序。我们的自动机将是一个收发包裹的机器人。

梅多菲尔德

梅多菲尔德村并不大。它由 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'));
相关推荐
smilejingwei16 分钟前
用 AI 编程生成 ECharts 图表并嵌入报表的实践
前端·人工智能·echarts·bi·报表工具·商业智能
码农周20 分钟前
告别大体积PDF!基于PDFBox的Java压缩工具
java·spring boot
devilnumber30 分钟前
java中Redisson ,jedis,Lettuce和Spring Data Redis的四种深度对比和优缺点详解
java·redis·spring
摇滚侠31 分钟前
Java 进阶教程,全面剖析 Java 多线程编程
java·开发语言
yaaakaaang31 分钟前
十四、命令模式
java·命令模式
Linux运维技术栈39 分钟前
Cloudflare Argo Smart Routing全球加速:优化跨境回源链路,提升跨区域访问体验
大数据·前端·数据库
小锋java12341 小时前
【技术专题】Matplotlib3 Python 数据可视化 - Matplotlib3 绘制饼状图(Pie)
java
wuminyu1 小时前
专家视角看JVM_StartThread
java·linux·c语言·jvm·c++
awljwlj1 小时前
黑马点评复习—缓存相关【包含可能的问题和基础知识复习】
java·后端·spring·缓存
Gofarlic_OMS1 小时前
ENOVIA基于Token的许可证消费模式分析与分点策略
java·大数据·开发语言·人工智能·制造