Skip to content

Commit f6a34cd

Browse files
authored
feat: v5 algorithm detect cycle (#74)
* feat: add direction flag and visitOnce flag to dfs * feat: v5 algorithm detect cycle * test: unit test of detect cycle algorithm
1 parent f26eb24 commit f6a34cd

File tree

8 files changed

+880
-14
lines changed

8 files changed

+880
-14
lines changed

__tests__/data/detect-cycle-test-data.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

__tests__/unit/detect-cycle.spec.ts

Lines changed: 519 additions & 0 deletions
Large diffs are not rendered by default.

__tests__/unit/dfs.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,8 @@ const data = {
8787
const graph = new Graph<any, any>(data);
8888
describe('depthFirstSearch', () => {
8989
it('should perform DFS operation on graph', () => {
90-
9190
const enterNodeCallback = jest.fn();
9291
const leaveNodeCallback = jest.fn();
93-
9492
// Traverse graphs without callbacks first to check default ones.
9593
depthFirstSearch(graph, 'A');
9694

__tests__/utils/data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const dataTransformer = (data: { nodes: { id: NodeID, [key: string]: any
99
return {
1010
nodes: nodes.map((n) => {
1111
const { id, ...rest } = n;
12-
return { id, data: rest };
12+
return { id, data: rest ? rest : {} };
1313
}),
1414
edges: edges.map((e, i) => {
1515
const { id, source, target, ...rest } = e;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"build:ci": "pnpm -r run build:ci",
2222
"prepare": "husky install",
2323
"test": "jest",
24-
"test_one": "jest ./__tests__/unit/mst.spec.ts",
24+
"test_one": "jest ./__tests__/unit/detect-cycle.spec.ts",
2525
"coverage": "jest --coverage",
2626
"build:site": "vite build",
2727
"deploy": "gh-pages -d site/dist",

packages/graph/src/detect-cycle.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)