|
| 1 | +import { depthFirstSearch } from './dfs'; |
| 2 | +import { getConnectedComponents, detectStrongConnectComponents } from './connected-component'; |
| 3 | +import { Graph, IAlgorithmCallbacks, INode, NodeData, NodeID } from './types'; |
| 4 | +import { Node } from '@antv/graphlib'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Detects a directed cycle in a graph. |
| 8 | + * |
| 9 | + * @param graph The graph to detect the directed cycle in. |
| 10 | + * @returns An object representing the detected directed cycle, where each key-value pair represents a node ID and its parent node ID in the cycle. |
| 11 | + */ |
| 12 | +export const detectDirectedCycle = (graph: Graph): { |
| 13 | + [key: NodeID]: NodeID; |
| 14 | +} => { |
| 15 | + let cycle: { |
| 16 | + [key: NodeID]: NodeID; |
| 17 | + } = null; |
| 18 | + const nodes = graph.getAllNodes(); |
| 19 | + const dfsParentMap: { [key: NodeID]: NodeID } = {}; |
| 20 | + // The set of all nodes that are not being accessed |
| 21 | + const unvisitedSet: { [key: NodeID]: Node<NodeData> } = {}; |
| 22 | + // The set of nodes being accessed |
| 23 | + const visitingSet: { [key: NodeID]: NodeID } = {}; |
| 24 | + // The set of all nodes that have been accessed |
| 25 | + const visitedSet: { [key: NodeID]: NodeID } = {}; |
| 26 | + // init unvisitedSet |
| 27 | + nodes.forEach((node) => { |
| 28 | + unvisitedSet[node.id] = node; |
| 29 | + }); |
| 30 | + const callbacks: IAlgorithmCallbacks = { |
| 31 | + enter: ({ current: currentNodeId, previous: previousNodeId }) => { |
| 32 | + if (visitingSet[currentNodeId]) { |
| 33 | + // 如果当前节点正在访问中,则说明检测到环路了 |
| 34 | + cycle = {}; |
| 35 | + let currentCycleNodeId = currentNodeId; |
| 36 | + let previousCycleNodeId = previousNodeId; |
| 37 | + while (previousCycleNodeId !== currentNodeId) { |
| 38 | + cycle[currentCycleNodeId] = previousCycleNodeId; |
| 39 | + currentCycleNodeId = previousCycleNodeId; |
| 40 | + previousCycleNodeId = dfsParentMap[previousCycleNodeId]; |
| 41 | + } |
| 42 | + cycle[currentCycleNodeId] = previousCycleNodeId; |
| 43 | + } else { |
| 44 | + visitingSet[currentNodeId] = currentNodeId; |
| 45 | + delete unvisitedSet[currentNodeId]; |
| 46 | + dfsParentMap[currentNodeId] = previousNodeId; |
| 47 | + } |
| 48 | + }, |
| 49 | + leave: ({ current: currentNodeId }) => { |
| 50 | + visitedSet[currentNodeId] = currentNodeId; |
| 51 | + delete visitingSet[currentNodeId]; |
| 52 | + }, |
| 53 | + allowTraversal: () => { |
| 54 | + if (cycle) { |
| 55 | + return false; |
| 56 | + } |
| 57 | + return true; |
| 58 | + }, |
| 59 | + }; |
| 60 | + for (let key of Object.keys(unvisitedSet)) { |
| 61 | + depthFirstSearch(graph, key, callbacks, true, false); |
| 62 | + } |
| 63 | + return cycle; |
| 64 | +}; |
| 65 | + |
| 66 | +/** |
| 67 | + * Detects all undirected cycles in a graph. |
| 68 | + * @param graph The graph to detect cycles in. |
| 69 | + * @param nodeIds Optional array of node IDs to filter cycles by. |
| 70 | + * @param include Specifies whether the filtered cycles should be included (true) or excluded (false). |
| 71 | + * @returns An array of objects representing the detected cycles in the graph. |
| 72 | + */ |
| 73 | +export const detectAllUndirectedCycle = (graph: Graph, nodeIds?: NodeID[], include = true) => { |
| 74 | + const allCycles: { [key: NodeID]: INode }[] = []; |
| 75 | + const components = getConnectedComponents(graph, false); |
| 76 | + // loop through all connected components |
| 77 | + for (const component of components) { |
| 78 | + if (!component.length) continue; |
| 79 | + const root = component[0]; |
| 80 | + const rootId = root.id; |
| 81 | + const stack = [root]; |
| 82 | + const parent = { [rootId]: root }; |
| 83 | + const used = { [rootId]: new Set() }; |
| 84 | + // walk a spanning tree to find cycles |
| 85 | + while (stack.length > 0) { |
| 86 | + const curNode = stack.pop(); |
| 87 | + const curNodeId = curNode.id; |
| 88 | + const neighbors = graph.getNeighbors(curNodeId); |
| 89 | + // const neighbors = getNeighbors(curNodeId, graphData.edges); |
| 90 | + for (let i = 0; i < neighbors.length; i += 1) { |
| 91 | + const neighborId = neighbors[i].id; |
| 92 | + const neighbor = graph.getAllNodes().find(node => node.id === neighborId); |
| 93 | + if (neighborId === curNodeId) { |
| 94 | + allCycles.push({ [neighborId]: curNode }); |
| 95 | + } else if (!(neighborId in used)) { |
| 96 | + // visit a new node |
| 97 | + parent[neighborId] = curNode; |
| 98 | + stack.push(neighbor); |
| 99 | + used[neighborId] = new Set([curNode]); |
| 100 | + } else if (!used[curNodeId].has(neighbor)) { |
| 101 | + // a cycle found |
| 102 | + let cycleValid = true; |
| 103 | + const cyclePath = [neighbor, curNode]; |
| 104 | + let p = parent[curNodeId]; |
| 105 | + while (used[neighborId].size && !used[neighborId].has(p)) { |
| 106 | + cyclePath.push(p); |
| 107 | + if (p === parent[p.id]) break; |
| 108 | + else p = parent[p.id]; |
| 109 | + } |
| 110 | + cyclePath.push(p); |
| 111 | + if (nodeIds && include) { |
| 112 | + cycleValid = false; |
| 113 | + if (cyclePath.findIndex((node) => nodeIds.indexOf(node.id) > -1) > -1) { |
| 114 | + cycleValid = true; |
| 115 | + } |
| 116 | + } else if (nodeIds && !include) { |
| 117 | + if (cyclePath.findIndex((node) => nodeIds.indexOf(node.id) > -1) > -1) { |
| 118 | + cycleValid = false; |
| 119 | + } |
| 120 | + } |
| 121 | + // Format node list to cycle |
| 122 | + if (cycleValid) { |
| 123 | + const cycle: { [key: NodeID]: INode } = {}; |
| 124 | + for (let index = 1; index < cyclePath.length; index += 1) { |
| 125 | + cycle[cyclePath[index - 1].id] = cyclePath[index]; |
| 126 | + } |
| 127 | + if (cyclePath.length) { |
| 128 | + cycle[cyclePath[cyclePath.length - 1].id] = cyclePath[0]; |
| 129 | + } |
| 130 | + allCycles.push(cycle); |
| 131 | + } |
| 132 | + used[neighborId].add(curNode); |
| 133 | + } |
| 134 | + } |
| 135 | + } |
| 136 | + } |
| 137 | + return allCycles; |
| 138 | +}; |
| 139 | + |
| 140 | +/** |
| 141 | + * Detects all directed cycles in a graph. |
| 142 | + * @param graph The graph to detect cycles in. |
| 143 | + * @param nodeIds Optional array of node IDs to filter cycles by. |
| 144 | + * @param include Specifies whether the filtered cycles should be included (true) or excluded (false). |
| 145 | + * @returns An array of objects representing the detected cycles in the graph. |
| 146 | + */ |
| 147 | +export const detectAllDirectedCycle = (graph: Graph, nodeIds?: NodeID[], include = true) => { |
| 148 | + const path: INode[] = []; // stack of nodes in current pate |
| 149 | + const blocked = new Set<INode>(); |
| 150 | + const B: { [key: NodeID]: Set<INode> } = {}; // remember portions of the graph that yield no elementary circuit |
| 151 | + const allCycles: { [key: NodeID]: INode }[] = []; |
| 152 | + const idx2Node: { |
| 153 | + [key: number]: INode; |
| 154 | + } = {}; |
| 155 | + const node2Idx: { [key: NodeID]: number } = {}; |
| 156 | + // unblock all blocked nodes |
| 157 | + const unblock = (thisNode: INode) => { |
| 158 | + const stack = [thisNode]; |
| 159 | + while (stack.length > 0) { |
| 160 | + const node = stack.pop(); |
| 161 | + if (blocked.has(node)) { |
| 162 | + blocked.delete(node); |
| 163 | + B[node.id].forEach((n) => { |
| 164 | + stack.push(n); |
| 165 | + }); |
| 166 | + B[node.id].clear(); |
| 167 | + } |
| 168 | + } |
| 169 | + }; |
| 170 | + |
| 171 | + const circuit = (node: INode, start: INode, adjList: { [key: NodeID]: number[] }) => { |
| 172 | + let closed = false; // whether a path is closed |
| 173 | + if (nodeIds && include === false && nodeIds.indexOf(node.id) > -1) return closed; |
| 174 | + path.push(node); |
| 175 | + blocked.add(node); |
| 176 | + const neighbors = adjList[node.id]; |
| 177 | + for (let i = 0; i < neighbors.length; i += 1) { |
| 178 | + const neighbor = idx2Node[neighbors[i]]; |
| 179 | + if (neighbor === start) { |
| 180 | + const cycle: { [key: NodeID]: INode } = {}; |
| 181 | + for (let index = 1; index < path.length; index += 1) { |
| 182 | + cycle[path[index - 1].id] = path[index]; |
| 183 | + } |
| 184 | + if (path.length) { |
| 185 | + cycle[path[path.length - 1].id] = path[0]; |
| 186 | + } |
| 187 | + allCycles.push(cycle); |
| 188 | + closed = true; |
| 189 | + } else if (!blocked.has(neighbor)) { |
| 190 | + if (circuit(neighbor, start, adjList)) { |
| 191 | + closed = true; |
| 192 | + } |
| 193 | + } |
| 194 | + } |
| 195 | + if (closed) { |
| 196 | + unblock(node); |
| 197 | + } else { |
| 198 | + for (let i = 0; i < neighbors.length; i += 1) { |
| 199 | + const neighbor = idx2Node[neighbors[i]]; |
| 200 | + if (!B[neighbor.id].has(node)) { |
| 201 | + B[neighbor.id].add(node); |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + path.pop(); |
| 206 | + return closed; |
| 207 | + }; |
| 208 | + |
| 209 | + const nodes = graph.getAllNodes(); |
| 210 | + |
| 211 | + // Johnson's algorithm, sort nodes |
| 212 | + for (let i = 0; i < nodes.length; i += 1) { |
| 213 | + const node = nodes[i]; |
| 214 | + const nodeId = node.id; |
| 215 | + node2Idx[nodeId] = i; |
| 216 | + idx2Node[i] = node; |
| 217 | + } |
| 218 | + // If there are specified included nodes, the specified nodes are sorted first in order to end the search early |
| 219 | + if (nodeIds && include) { |
| 220 | + for (let i = 0; i < nodeIds.length; i++) { |
| 221 | + const nodeId = nodeIds[i]; |
| 222 | + node2Idx[nodes[i].id] = node2Idx[nodeId]; |
| 223 | + node2Idx[nodeId] = 0; |
| 224 | + idx2Node[0] = nodes.find(node => node.id === nodeId); |
| 225 | + idx2Node[node2Idx[nodes[i].id]] = nodes[i]; |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + // Returns the adjList of the strongly connected component of the node (order > = nodeOrder) |
| 230 | + const getMinComponentAdj = (components: INode[][]) => { |
| 231 | + let minCompIdx; |
| 232 | + let minIdx = Infinity; |
| 233 | + // Find least component and the lowest node |
| 234 | + for (let i = 0; i < components.length; i += 1) { |
| 235 | + const comp = components[i]; |
| 236 | + for (let j = 0; j < comp.length; j++) { |
| 237 | + const nodeIdx = node2Idx[comp[j].id]; |
| 238 | + if (nodeIdx < minIdx) { |
| 239 | + minIdx = nodeIdx; |
| 240 | + minCompIdx = i; |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + const component = components[minCompIdx]; |
| 245 | + const adjList: { [key: NodeID]: number[] } = {}; |
| 246 | + for (let i = 0; i < component.length; i += 1) { |
| 247 | + const node = component[i]; |
| 248 | + adjList[node.id] = []; |
| 249 | + for (const neighbor of graph.getRelatedEdges(node.id, "out").map(n => n.target).filter((n) => component.map(c => c.id).indexOf(n) > -1)) { |
| 250 | + // 对自环情况 (点连向自身) 特殊处理:记录自环,但不加入adjList |
| 251 | + if (neighbor === node.id && !(include === false && nodeIds.indexOf(node.id) > -1)) { |
| 252 | + allCycles.push({ [node.id]: node }); |
| 253 | + } else { |
| 254 | + adjList[node.id].push(node2Idx[neighbor]); |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | + return { |
| 259 | + component, |
| 260 | + adjList, |
| 261 | + minIdx, |
| 262 | + }; |
| 263 | + }; |
| 264 | + |
| 265 | + let nodeIdx = 0; |
| 266 | + while (nodeIdx < nodes.length) { |
| 267 | + const sccs = detectStrongConnectComponents(graph).filter( |
| 268 | + (component) => component.length > 1, |
| 269 | + ); |
| 270 | + if (sccs.length === 0) break; |
| 271 | + const scc = getMinComponentAdj(sccs); |
| 272 | + const { minIdx, adjList, component } = scc; |
| 273 | + if (component.length > 1) { |
| 274 | + component.forEach((node) => { |
| 275 | + B[node.id] = new Set(); |
| 276 | + }); |
| 277 | + const startNode = idx2Node[minIdx]; |
| 278 | + // StartNode is not in the specified node to include. End the search ahead of time. |
| 279 | + if (nodeIds && include && nodeIds.indexOf(startNode.id) === -1) return allCycles; |
| 280 | + circuit(startNode, startNode, adjList); |
| 281 | + nodeIdx = minIdx + 1; |
| 282 | + } else { |
| 283 | + break; |
| 284 | + } |
| 285 | + break; |
| 286 | + } |
| 287 | + return allCycles; |
| 288 | +}; |
| 289 | + |
| 290 | +/** |
| 291 | + * Detects all cycles in a graph. |
| 292 | + * @param graph The graph to detect cycles in. |
| 293 | + * @param directed Specifies whether the graph is directed (true) or undirected (false). |
| 294 | + * @param nodeIds Optional array of node IDs to filter cycles by. |
| 295 | + * @param include Specifies whether the filtered cycles should be included (true) or excluded (false). |
| 296 | + * @returns An array of objects representing the detected cycles in the graph. |
| 297 | + */ |
| 298 | +export const detectAllCycles = ( |
| 299 | + graph: Graph, |
| 300 | + directed?: boolean, |
| 301 | + nodeIds?: string[], |
| 302 | + include = true, |
| 303 | +) => { |
| 304 | + if (directed) return detectAllDirectedCycle(graph, nodeIds, include); |
| 305 | + return detectAllUndirectedCycle(graph, nodeIds, include); |
| 306 | +}; |
| 307 | + |
0 commit comments