JS实现判断DAG图是否有环

44次阅读

共计 4230 个字符,预计需要花费 11 分钟才能阅读完成。

业务场景

调度系统的任务可视化界面需要完成用户可在界面上连线作为任意两个 job 间的依赖关系,也就是 DAG 图

  • DAG 也就是有向无环图,有向无环图指的是一个无回路的有向图。环是一条至少含有一条边且起点和终点相同的路径。

问题描述

在添加依赖关系时,在向后端发送请求前,前端应该先判断当前添加的连线是否与已存在的依赖关系成为闭环(循环依赖为无效的任务流),减少无效的请求。
job 可以任意依赖,也就是每个 job 可以有多个字节点或者父节点。

环的理解

刚开始以为肉眼能看到的回路就是环,如下图中最终汇合到王小虎 4 这个节点,但是从有向图的依赖关系来说,实际是 1 依赖 4,1 依赖 2,2 依赖 3,3 依赖 4,只是在等待节点 4 的执行,并没有陷入死循环的依赖。但是下面这个如果是一个无向图那么就是有环。

  • 有向图和无向图:从图上来说可以简单理解有箭头指向的就是有环图,无箭头指向的为无环图

判断有向图是否有环

有两种算法:
1. 深度优先遍历该图,如果在遍历的过程中,发现某个节点有一条边指向已经访问过的节点,则判断为有环。

 // 图的深度遍历函数
    function DFS(i) {console.log('正在访问结点' + nodes[i]);
        // 结点 i 变为访问过的状态
        visited[nodes[i]] = 1;
        for (let j = 0; j < nodes.length; j++) {
            // 如果当前结点有指向的结点
            if (graph[nodes[i]][nodes[j]] != 0) {
                // 并且已经被访问过
                if (visited[nodes[j]] == 1) {
                    isDAG = false; // 有环
                    break;
                } else if (visited[nodes[j]] == -1) {
                    // 当前结点后边的结点都被访问过,直接跳至下一个结点
                    continue;
                } else {DFS(j); // 否则递归访问
                }
            }
        }
        // 遍历过所有相连的结点后,把本节点标记为 -1
        visited[nodes[i]] = -1;
    }

    // 创建图, 以邻接矩阵表示
    function create(nodes, edges) {for (let i = 0; i < nodes.length; i++) {const pre = nodes[i];
            graph

= {};
for (let j = 0; j < nodes.length; j++) {
const next = nodes[j];
graph


[next] = 0;
}
}
for (let k = 0; k < edges.length; k++) {
const edge = edges[k];
graph[edge.source][edge.target] = 1;
}
// 初始化 color 数组为 0,表示一开始所有顶点都未被访问过
for (let i = 0; i < nodes.length; i++) {
visited[nodes[i]] = 0;
}
}
// 邻接矩阵
const graph = {};
// 结点个数和边的个数
// 标记矩阵,0 为当前结点未访问,1 为访问过,- 1 表示当前结点后边的结点都被访问过。
const visited = {};
// 是否是 DAG(有向无环图)
let isDAG = true;

// 获取所有的节点
const edges = [
{
source: 'node1',
target: 'node3'
},
{
source: 'node3',
target: 'node5'
}
];
const nodes = [];
edges.forEach(e => {
const {source, target} = e;
if (!nodes.includes(source)) {
nodes.push(source);
}
if (!nodes.includes(target)) {
nodes.push(target);
}
});
create(nodes, edges);
// 保证每个节点都遍历到,排除有的结点没有边的情况
for (let i = 0; i < nodes.length; i++) {
// 该结点后边的结点都被访问过了,跳过它
if (visited[nodes[i]] == -1) {
continue;
}
DFS(i);
if (!isDAG) {
console.log('有环');
break;
}
}
if (isDAG) {
console.log('无环');
}

  • 这种算法由于矩阵元素个数为 n^2,因此时间复杂度就是 O(n^2)

2. 拓扑排序

  • 找到一个没有前驱(入度为 0)的节点,入度就是指向该节点的依赖,从图中删除该节点和所有以它为起点的有向边。
  • 在删除该节点的有向边过程中更新节点入度,找到下一个入度为 0 的节点,直到当前的 DAG 图为空或当前图中不存在无前驱的顶点为止,否则就是有环。

// 获取所有的节点
    const edges = [
        {
            source: 'node1',
            target: 'node3'
        },
        {
            source: 'node3',
            target: 'node4'
        },
        {
            source: 'node1',
            target: 'node4'
        }
    ];
    const nodes = [];
    const list = {}; // 邻接表
    const queue = []; // 入度为 0 的节点集合
    const indegree = {};
    edges.forEach(e => {const { source, target} = e;
        if (!nodes.includes(source)) {nodes.push(source);
        }
        if (!nodes.includes(target)) {nodes.push(target);
        }
        addEdge(source, target);
    });
    const V = nodes.length;

    nodes.forEach(node => {if (!indegree[node]) indegree[node] = 0;
        if (!list[node]) list[node] = [];});

    function addEdge(source, target) {if (!list) list = [];
        if (!indegree[target]) indegree[target] = 0;
        list.push(target);
        indegree[target] += 1;
    }
    function sort() {Object.keys(indegree).forEach(id => {if (indegree[id] === 0) {queue.push(id);
            }
        });
        let count = 0;
        while (queue.length) {
            ++count;
            const currentNode = queue.pop();
            const nodeTargets = list[currentNode];
            for (let i = 0; i < nodeTargets.length; i++) {const target = nodeTargets[i];
                indegree[target] -= 1;
                if (indegree[target] === 0) {queue.push(target);
                }
            }
        }
        // false 没有输出全部顶点,有向图中有回路
        return !(count < V);
    }
    console.log(sort());
  • 由于输出每个顶点的同时还要删除以它为起点的边,故上述拓扑排序的时间复杂度为 O(V+E)。

扩展:判断无向图是否有环

1. 遍历当前所有的依赖边,将所有相关节点的依赖节点加入到邻接表,这里用一个数组去存储
2. 边有起点 source 和终点 target,假设起点为 a, 终点为 b,则沿着 a 节点去深度遍历当前图 a 节点的相邻依赖都没有找到终点为 b 的点,则这条依赖加入后也是无环图。
3. 为了防止陷入死循环,在边单次循环中需要加如 visited 数组表示此节点被访问过。

const edges = [
    {
        source: 1,
        target: 3
    },
    {
        source: 3,
        target: 5
    },
    {
        source: 2,
        target: 3
    },
    {
        source: 4,
        target: 1
    },
    {
        source: 1,
        target: 6
    },
    {
        target: 5,
        source: 6
    }
];

function findRedundantConnection(edges) {
    // 使用邻接表存储图的信息
    const graph = new Map();

    // 遍历每一条边
    for (let i = 0; i < edges.length; i++) {const edge = edges[i];
        // Each element of edges is a pair [u, v] with u < v
        // const u = edge[0];
        // const v = edge[1];
        const u = edge.source;
        const v = edge.target;

        // 深度优先遍历该图,判断 u 到 v 之间是否已经存在了一条路径
        let hasPath = dfs(graph, u, v, []);

        if (hasPath == true) {return edge;} else {
            // 将该边加入到邻接表中
            if (!graph.has(u)) {graph.set(u, []);
            }
            const uArr = graph.get(u);
            uArr.push(v);
            graph.set(u, uArr);

            if (!graph.has(v)) {graph.set(v, []);
            }
            const vArr = graph.get(v);
            vArr.push(u);
            graph.set(v, vArr);
        }
    }

    return null;
}

// 深度优先遍历该图,判断 start 到 end 之间是否已经存在了一条路径
function dfs(graph, start, end, visited) {if (!graph.has(start) || !graph.has(end)) {return false;}

    if (start == end) {return true;}

    visited.push(start);

    // 遍历 start 的所有相邻节点
    const startArr = graph.get(start);
    for (let i = 0; i < startArr.length; i++) {const adj = startArr[i];
        if (!visited.includes(adj)) {if (dfs(graph, adj, end, visited) == true) {return true;}
        }
    }

    return false;
}
console.log(findRedundantConnection(edges));

参考链接:
https://blog.csdn.net/lisongl…
https://blog.csdn.net/login_s…

正文完
 0