从零到一:用深度优先算法检测有向图的环路(应用场景:性格测试)

图片描述

写在前面

在开始前想先说一下关于这个课题的感想——能学以至用是一件很快乐的事情。算法

深度优先算法(简称DFS),在大学的数据结构课本中有这一个章节,依稀记得另一个叫广度优先算法(简称BFS),在当时的我看来,它们都还只是理论。万万没想到的是,在毕业后的两年,我会接触到它们,并写下关于这个算法的应用文章,而契机是一个跟性格测试有关的游戏。数组

这个系列文章的重点,是如何利用DFS算法来检测有向图的回路,而具体的应用场景,就是性格测试。相比于纯讲理论,我更喜欢从实际应用出发,若是你对此感兴趣,就请继续看下去吧。数据结构

性格测试游戏

想必你确定玩过问答类的性格测试游戏,游戏规则很是简单,按照心中所想回答问题便可。回答完一个问题后会跳转到另一个问题,不一样的回答可能进入不一样的分支。回答完全部问题后会给出一个关于你性格的解答,以下图。app

图片描述

问题就来了,这种性格测试游戏的模型实际上是一张有向图。通常而言,题目及答案都是做者设定好的,所以不会出现死循环,也就是环路。例如 1->2->4->1,就是一个死循环,玩家可能一直在第一、二、4这三道题一直循环,游戏不能结束。性能

图片描述

若是游戏很复杂,有不少道题目,有可能会设计出死循环。那么像这种环路,咱们能用程序检测出来吗?答案是确定的。测试

下面先来POST一些概念。spa

什么是图?

摘自:百度百科 - 图.net

在数学中,一个图(Graph)是表示物件与物件之间的关系的数学对象,是图论的基本研究。设计

图片描述

什么是有向图?

摘自:百度百科 - 图3d

若是给图的每条边规定一个方向,那么获得的图称为有向图。

图片描述

什么是深度优先算法?

摘自:百度百科 - 深度优先搜索

深度优先搜索算法(Depth-First-Search),是搜索算法的一种。是沿着树或图的深度遍历节点,尽量深的搜索分支。当节点v的全部边都己被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的全部节点为止。若是还存在未被发现的节点,则选择其中一个做为源节点并重复以上过程,整个进程反复进行直到全部节点都被访问为止。

图片描述

如上图,按DFS的方式以A为起点去遍历的话,遍历顺序为:

A-B-D-E-C-F-G

若是还有不明白的能够自行Google一下。


实践出真知

示例代码
/**
 * 测试数据,1表明第一题,2表明第二题,-1表明结果A,-2表明结果B,以此类推
 * @type {Array}
 */
var testData = [
    [2, 3],
    [4, -3],
    [-1, -2],
    [1, -2]
];

/**
 * 递归测试,使用深度优先算法
 * @param  {Array}  data   测试数据
 * @param  {Number} qIndex 问题下标
 * @param  {Number} aIndex 答案下标
 * @param  {Array}  path   当前回答路径,例如[1,2,4]表明1->2->4的回答顺序
 */
function recurseTest(data, qIndex, aIndex, path) {
    var question = data[qIndex]; // 当前问题
    var answer = question[aIndex]; // 要遍历的答案
    // 1.判断是否跳转到结果
    if (answer > 0) { // 跳转到其余问题
        if (path.indexOf(answer) > -1) { // 逻辑错误,当前回答路径已存在,死循环
            var result = path.concat([answer, 'wrong']).join(', ');
            showResult(result);
        } else { // 逻辑正确,继续沿着这个答案遍历下去
            path.push(answer);
            recurseTest(data, answer - 1, 0, path);
        }
    } else { // 跳转到结果
        path.push(answer);
    }
    // 2.判断是否最后一个答案
    if (aIndex === question.length - 1) { // 已是当前这道题的最后一个答案,返回上层
        var result = path.concat(['true']).join(', ');
        showResult(result);
        path.pop();
    } else if (aIndex < question.length - 1) { // 还有其余答案,使用下一个答案遍历下去
        recurseTest(data, qIndex, aIndex + 1, path);
    }
}

/**
 * 显示回答结果
 * @param  {String} content 内容
 */
function showResult(content) {
    console.log(content);
    if (typeof document !== 'undefined') {
        var div = document.createElement('div');
        div.innerText = content;
        document.body.appendChild(div);
    }
}

// 测试一下
showResult('测试结果:');
recurseTest(testData, 0, 0, [1]);
测试结果

图片描述

在线示例

https://jsfiddle.net/Vincent_...


要点解读

1.栈的使用

上述代码中的数组path,应该理解成一个栈,它记录的是当前递归的回答顺序,好比[1, 2, 4],表明着,先回答第一题,再回答第二题,再回答第四题。

2.环路的判断

假以下一个要移动到的问题的序号,存在于栈中,就表明出现了环路,例如[1, 2, 4, 1],此时表明出现了死循环。

3.返回上层,遍历下一条分支

这个时候就体现出栈的做用了,好比咱们跑完了1->2->?的分支后,须要跑1->3->?的分支,即返回上层,则使2出栈,3入栈。


时间复杂度的延伸

DFS算法的时间复杂度是:O(b^m) (b-分支系数,m-图的最大深度)

所以能够看出若是分支系数越大(也就是每一题的答案越多),图深度越大(题目的数量越多),时间复杂度就越高。

为此,咱们能够来看看运行这个检测的方法,花了多少时间,递归了多少次:

图片描述

上面咱们只有几个节点,每一个节点只有2个出度,所以运算起来很快。若是增长到12个节点呢,每一个节点4个出度呢?

图片描述

没错,是两千多万次递归,时间也来到了接近300ms,越多的顶点和边将带来更多的检测时间,所以检测过多的顶点和边将带来性能问题,这是使用深度优先算法来检测的时候须要注意的。(以前就是由于一个游戏配了20道题,运行一下这个检测方法,直接跑到崩溃。。。)

小结

使用深度优先算法,咱们可以检测性格测试游戏的逻辑正确性,相比以往课堂上的理论,在这里算是一个具体的应用场景吧。其实深度优先算法的应用面也很广,早晚还会再碰面的。

另外一方面,咱们讨论了DFS算法的时间复杂度,当图的顶点数增长到必定程度时,运算量暴涨,也所以抛出了一个性能的问题。在看似简单的实现中,咱们其实要注意处理好细节,毕竟,放大到1亿次运算,都不是小事!

最后,但愿你们会喜欢这样的文章吧。

相关文章
相关标签/搜索