JavaScript 实现动物专家系统「续」拓扑序列

本文首发且更新于个人博客: https://www.xerrors.fun/Expert-System-pro-max/

上一篇文章 已经实现了「动物专家系统」的基本功能。其实已经是我的改进版本了,所以上一版的名称是 ExpertSystemPro,那么现在来了第三版,就叫 ExpertSystemProMax 吧!

在线可编辑演示地址,整个项目的源码也可以在 Codepen 里面看到:

当然直接在我的个人博客网站里面就可以演示:https://www.xerrors.fun/blog/

接下来考虑用户可以自行添加概念以及规则的情况,前面我们已经了解到,想要正常的进行推理就必须要满足拓扑序列,否则就需要多次遍历才行。

1. 添加新的规则

这里主要把实现的时候的难点说一下,即使读者可能不是使用 JavaScript 语言来写算法的,但是遇到的问题应该是一样的。对于如何实现插入新的规则应该有很多种做法,这里提供一个方法抛砖引玉;首先对用户的输入进行正则匹配,要求输入的格式为1,3>10,表示由事实 1 和 3 可以推导出事实 10 成立。

// 对输入进行正则匹配
const pattern = /^[0-9]+(,[0-9]*)*>[0-9]+$/
if (!pattern.test(this.new_rule)) {
    this.$message.error('格式有误')
    return
}

之后的工作就是字符串处理了,各个语言的处理逻辑差不多:

// 对输入的字符串进行分割处理 1,3>10 
const div = this.new_rule.indexOf('>')
const conclusion = eval(this.new_rule.slice(div+1))
const conditions = this.new_rule.slice(0, div).split(',').map(item => eval(item))
// 对数据可行性进行判断
if (conclusion >= this.features.length 
    || Math.max(...conditions) >= this.features.length) {
    return this.$message.error('超出范围')
} else {
    const new_rule = {
        conditions: conditions,
        conclusion: conclusion
    };
    // 插入一条新的规则
    this.rules = this.insertRule(new_rule, this.rules)
    this.init() // 初始化 result 为空

这里的插入函数 this.insertRule(rule, rulesArray)会在后面的篇幅着重介绍。

2. 对规则进行排序

多次遍历是否可行

两次遍历为什么不可以?第一次遍历出来「中间结果」,第二次遍历出来「最终结果」。理想是好的,但是中间结果并不一定只有一层,一层中间结果可以使用两次遍历来解决,那么两层中间结果呢?

比如下面这个例子,总共有 7 个「概念(事实)」,5 条「规则」,R1 - R5 按照从前到后的顺序排列,那么第一次遍历的时候,由于 R1、R2、R3 的条件都没有成立,只有 R4、R5 成立了,得到了推导结果 3 和 4;第二次顺序遍历的时候,由于 5 和 6 还没有被推导出来,所以 R1 依然不能成立,而由于 3 和 4 已经被推导出来,所以这次遍历之后 5 和 6 成立;那么当第三次遍历的时候,R1 才被推导出来。

这个例子说明,对于越复杂的规则程序要遍历的次数也就越多;而一般情况下我们难以估计遍历的次数,次数少了,不能保证「准确性」;次数多了搜索效率大大降低。所以我们应当对这个规则序列进行排序。

拓扑排序

这里插一些对「拓扑排序」的解释,给不了解或者有些遗忘的同学复习;如下图所示,我们有两个规则:

  • R1:长脖子,且是食草动物,可得长颈鹿
  • R2:吃草,且有蹄,可得食草动物

可以看到,这里规则 R1 的一个条件「食草动物」是 R2 的结论,所以在使用规则 R1 判断是不是「长颈鹿」的时候,必须要先使用规则 R2来判断是不是「食草动物」。那么经过排序把 R2 应该放在 R1 前面,得到的序列就是拓扑序列了。

我发现上一篇文章里面的手绘太丑了,我还是安安心心使用计算机画图吧!

那么问题来了?怎么得到「拓扑序列」呢?第一反应就是「拓扑排序」。但是仔细斟酌一下,发现事情并没有那么简单;在数据结构中所学习到的拓扑排序的 「AOV 网(顶点表示活动的网络)」中,顶点都是活动,这些顶点根据前驱和后继关系得到一个网;

但是,在专家系统里面这些规则之间虽然存在前驱和后继关系,但是却没有直接的指针或者直接相连的数据结构(因为我们是使用数组来表示的),难以按照图的方式进行遍历;

插入排序

总而言之,在对这些规则实现「拓扑排序」的时候是相当的麻烦的;那么我们就可以转换一下思路了,即没法使用「拓扑排序」,有要得到「拓扑序列」,即想要马儿跑,又不想让马儿吃草;其实我们可以使用「插入排序」的思想。

当规则 A 的「结论」出现在规则 B 的「条件」中的时候,要把规则 A 放在规则 B 前面判断,其实也就是相当于 A「小于」B 咯,按照这个原理我们直接使用插入排序的思想对这个规则序列进行排序;

工作流程:首先创建一个空的数组,然后依次对每一条规则执行插入操作;每一个要插入的规则 A 要从头进行遍历比较,当遍历到某个位置的规则 B 不再满足 A「小于」B 的时候,就把规则 A 插入到当前位置,否则就插入到数组的最后位置。

看字可能不好理解,直接看代码(JavaScript),由于我们已经存在一个已知的拓扑序列,所以当添加规则的时候只需要执行一次插入操作就行;

// 对规则进行插入排序
sortRules() {
  let rulesArray = [];
  for(var item of this.rules) {
    rulesArray = this.insertRule(item, rulesArray);
  }
  console.log(rulesArray)
  return rulesArray
},
// 向已有的规则中插入一条新的规则
insertRule(rule, rulesArray) {
  if (rulesArray && rulesArray.length === 0) {
    rulesArray.push(rule)
    return rulesArray
  } else {
    const times = rulesArray.length
    for(var i = 0; i < times; i++) {
      // 如果该规则的推导结果会作为另外一个规则的条件的话,就放在那个规则的前面;
      // 否则放在最后一个;
      if (rulesArray[i].conditions.includes(rule.conclusion)) {
        rulesArray.splice(i, 0, rule)
        break
      }
    }
    if(rulesArray.length === times) {
      rulesArray.push(rule)
    }
    return rulesArray
  }
}

3. 添加新的概念

这个难度很小:

addConcept() {
    this.$prompt('请输入名称(1-5字)', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
    }).then(({ value }) => {
        if(value && value.length > 0 && value.length <= 5) {
            this.features.push(value)
        } else {
            this.$message.error('输入长度有误')
        }
    })
}

最终效果:

演示

4. 后续工作

在上一版本的专家系统里面是可以通过两次点击一个属性来实现撤销选中的,但是在这一次的专家系统中给取消了,这是因为由于自定义规则的出现,使得规则变得复杂,所以需要花费很大的工作来完成这样一个撤销的功能;在综合了时间付出以及回报之后,我觉得这并不是一个值得去做的事情,也就没有去做。

这里我还是给想要完成的同学一些实现的思路

因为上一版本的专家系统中,我是把一般概念和推导出的概念分开进行计算的,但是当用户可以输入新的概念以及规则之后,没办法像上一版本一样把一般的概念给置为 0,然后计算。所以现在需要创建出来一个新的数据结构,对于 result 数组,定义如下的数据结构:

{
    status: true || false,
    count: number // 表示有多少个规则可以推导出这个结论
}

所以当某个概念 I 置为 0 之后,需要遍历所有的规则,如果某个规则中包含这个概念 I ,且修改之前根据这个规则确实是可以推导出概念 O 的话,就把该概念 O 的结果的 count - 1,同时把这个被推导出来的概念 O 入队。遍历完成之后取出队首元素,继续执行遍历操作,直到队列为空。

看吧,工作还是很大的,所以我就直接加了一个重置选择的开关!

猜你喜欢

转载自blog.csdn.net/jaykm/article/details/106248159