Skip to content

Commit f68bdc8

Browse files
committed
[Basic] Add method to detect cycles in graphs
This will help to find and report cycles in package graph.
1 parent 04781a2 commit f68bdc8

File tree

2 files changed

+80
-0
lines changed

2 files changed

+80
-0
lines changed

Sources/Basic/GraphAlgorithms.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,48 @@ public func topologicalSort<T: Hashable>(
9898

9999
return result.reversed()
100100
}
101+
102+
/// Finds the first cycle encountered in a graph.
103+
///
104+
/// This method uses DFS to look for a cycle and immediately returns when a
105+
/// cycle is encounted.
106+
///
107+
/// - Parameters:
108+
/// - nodes: The list of input nodes to sort.
109+
/// - successors: A closure for fetching the successors of a particular node.
110+
///
111+
/// - Returns: nil if a cycle is not found or a tuple with the path to the start of the cycle and the cycle itself.
112+
public func findCycle<T: Hashable>(
113+
_ nodes: [T], successors: (T) throws -> [T]
114+
) rethrows -> (path: [T], cycle: [T])? {
115+
// Ordered set to hold the current traversed path.
116+
var path = OrderedSet<T>()
117+
118+
// Function to visit nodes recursively.
119+
// FIXME: Convert to stack.
120+
func visit(_ node: T, _ successors: (T) throws -> [T]) rethrows -> (path: [T], cycle: [T])? {
121+
// If this node is already in the current path then we have found a cycle.
122+
if !path.append(node) {
123+
let index = path.index(of: node)!
124+
return (Array(path[path.startIndex..<index]), Array(path[index..<path.endIndex]))
125+
}
126+
127+
for succ in try successors(node) {
128+
if let cycle = try visit(succ, successors) {
129+
return cycle
130+
}
131+
}
132+
// No cycle found for this node, remove it from the path.
133+
let item = path.removeLast()
134+
assert(item == node)
135+
return nil
136+
}
137+
138+
for node in nodes {
139+
if let cycle = try visit(node, successors) {
140+
return cycle
141+
}
142+
}
143+
// Couldn't find any cycle in the graph.
144+
return nil
145+
}

Tests/BasicTests/GraphAlgorithmsTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ private func topologicalSort(_ node: Int, _ successors: [Int: [Int]]) throws ->
2828
return try topologicalSort([node], successors)
2929
}
3030

31+
private func findCycle(_ node: Int, _ successors: [Int: [Int]]) -> (path: [Int], cycle: [Int])? {
32+
return findCycle([node], successors: { successors[$0] ?? [] })
33+
}
34+
3135
class GraphAlgorithmsTests: XCTestCase {
3236
func testTransitiveClosure() {
3337
// A trival graph.
@@ -70,8 +74,39 @@ class GraphAlgorithmsTests: XCTestCase {
7074
XCTAssertThrows(GraphError.unexpectedCycle) { _ = try topologicalSort(1, [1: [2], 2: [1]]) }
7175
}
7276

77+
func testCycleDetection() throws {
78+
// Single node graph.
79+
XCTAssertNotCycle(findCycle(1, [:]))
80+
XCTAssertNotCycle(findCycle(1, [1: [2]]))
81+
// Trivial cycles.
82+
XCTAssertCycle(findCycle(1, [1: [1]]), path: [], cycle: [1])
83+
XCTAssertCycle(findCycle(1, [1: [2], 2: [1]]), path: [], cycle: [1, 2])
84+
XCTAssertCycle(findCycle(1, [1: [2], 2: [3], 3: [2]]), path: [1], cycle: [2, 3])
85+
XCTAssertCycle(findCycle(1, [1: [2], 2: [3], 3: [1]]), path: [], cycle: [1, 2, 3])
86+
87+
XCTAssertNotCycle(findCycle(1, [1: [2, 3], 2: [3, 4], 3: [4, 5], 4: [5, 8], 5: [7, 8], 7: [8]]))
88+
XCTAssertCycle(findCycle(1, [1: [2], 2: [3, 4], 3: [4, 5], 4: [1, 5, 8], 5: [7, 8], 7: [8]]), path: [], cycle: [1, 2, 3, 4])
89+
90+
XCTAssertNotCycle(findCycle(1, [1: [2, 3], 2: [], 3: [2]]))
91+
}
92+
7393
static var allTests = [
94+
("testCycleDetection", testCycleDetection),
7495
("testTransitiveClosure", testTransitiveClosure),
7596
("testTopologicalSort", testTopologicalSort),
7697
]
7798
}
99+
100+
private func XCTAssertCycle<T: Equatable>(_ cycleResult: (path: [T], cycle: [T])?, path: [T], cycle: [T], file: StaticString = #file, line: UInt = #line) {
101+
guard let cycleResult = cycleResult else {
102+
return XCTFail("Expected cycle but not found", file: file, line: line)
103+
}
104+
XCTAssertEqual(cycleResult.path, path, file: file, line: line)
105+
XCTAssertEqual(cycleResult.cycle, cycle, file: file, line: line)
106+
}
107+
108+
private func XCTAssertNotCycle<T>(_ cycleResult: T?, file: StaticString = #file, line: UInt = #line) {
109+
if let cycleResult = cycleResult {
110+
XCTFail("Unexpected cycle found \(cycleResult)", file: file, line: line)
111+
}
112+
}

0 commit comments

Comments
 (0)