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

522

写在前面

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

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

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

性格测试游戏

想必你肯定玩过问答类的性格测试游戏,游戏规则非常简单,按照心中所想回答问题即可。回答完一个问题后会跳转到另外一个问题,不同的回答可能进入不同的分支。回答完所有问题后会给出一个关于你性格的解答,如下图。

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

如果游戏很复杂,有很多道题目,有可能会设计出死循环。那么像这种环路,我们能用程序检测出来吗?答案是肯定的。

下面先来POST一些概念。

什么是图?

摘自:百度百科 - 图

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

什么是有向图?

摘自:百度百科 - 图

如果给图的每条边规定一个方向,那么得到的图称为有向图。

什么是深度优先算法?

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

深度优先搜索算法(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]);
测试结果

在线示例

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亿次运算,都不是小事!

最后,希望大家会喜欢这样的文章吧。