Skip to content

Commit 075a01e

Browse files
KenooscardssmithetiennedegYingboMa
authored
Add incremental cycle detection algorithm (#36)
* Add incremental cycle detection algorithm This adds the abstract interface for and a basic, naive implementation of an algorithm to solve the incremental (online) cycle detection problem. The incremental cycle detection problem is as follows: Starting from some initial acyclic (directed) graph (here taken to be empty), process a series of edge additions, stopping after the first edge addition that introduces a cycle. The algorithms for this problem have been developed and improved mostly over the past five years or so, so they are not currently widely used (or available in other libraries). That said, ModelingToolkit was using an implementation in its DAE tearing code (which I intend to replace with a version based on this code) and there are recent papers showing applications in compiler optimizations. The main focus of the current PR is the abstract interface. The algorithm itself is Algorithm N (for "Naive") from [BFGT15] Section 3. The reference develops several more algorithms with differing performance patterns, depending on the sparsity of the graph and there are further improvements to the asymptotics in the subsequent literature. However, Algorithm N is simple to understand and extend and works ok for what is needed in MTK, so I am not currently planning to implement the more advanced algorithms. I do want to make sure the extension point exists to add them in the future, which I believe this approach accomplishes. [BFGT15] Michael A. Bender, Jeremy T. Fineman, Seth Gilbert, and Robert E. Tarjan. 2015 A New Approach to Incremental Cycle Detection and Related Problems. ACM Trans. Algorithms 12, 2, Article 14 (December 2015), 22 pages. DOI: http://dx.doi.org/10.1145/2756553 Co-authored-by: Oscar Smith <[email protected]> Co-authored-by: Etienne dg <[email protected]> Co-authored-by: Yingbo Ma <[email protected]>
1 parent 32940eb commit 075a01e

File tree

5 files changed

+312
-1
lines changed

5 files changed

+312
-1
lines changed

src/Experimental/Traversals/Traversals.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module Traversals
55
# to create new graph algorithms that rely on breadth-first or depth-first search traversals.
66

77
using Graphs
8+
import ..Graphs: topological_sort
89
using SimpleTraits
910
"""
1011
abstract type TraversalAlgorithm
@@ -141,7 +142,7 @@ mutable struct ParentState{T<:Integer} <: AbstractTraversalState
141142
parents::Vector{T}
142143
end
143144

144-
@inline function newvisitfn!(s::ParentState, u, v)
145+
@inline function newvisitfn!(s::ParentState, u, v)
145146
s.parents[v] = u
146147
return true
147148
end

src/Graphs.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ simplecycles_hawick_james, maxsimplecycles, simplecycles, simplecycles_iter,
8686
simplecyclescount, simplecycleslength, karp_minimum_cycle_mean, cycle_basis,
8787
simplecycles_limited_length,
8888

89+
# incremental cycles
90+
IncrementalCycleTracker, add_edge_checked!, topological_sort,
91+
8992
# maximum_adjacency_visit
9093
mincut, maximum_adjacency_visit,
9194

@@ -222,6 +225,7 @@ include("cycles/hawick-james.jl")
222225
include("cycles/karp.jl")
223226
include("cycles/basis.jl")
224227
include("cycles/limited_length.jl")
228+
include("cycles/incremental.jl")
225229
include("traversals/bfs.jl")
226230
include("traversals/bipartition.jl")
227231
include("traversals/greedy_color.jl")

src/cycles/incremental.jl

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
using Base.Iterators: repeated
2+
3+
# Abstract Interface
4+
5+
"""
6+
abstract type IncrementalCycleTracker
7+
8+
The supertype for incremental cycle detection problems. The abstract type
9+
constructor IncrementalCycleTracker(G) may be used to automatically select
10+
a specific incremental cycle detection algorithm. See [`add_edge_checked!`](dfsdfsdfsdfref)
11+
for a usage example.
12+
"""
13+
abstract type IncrementalCycleTracker{I} <: AbstractGraph{I} end
14+
15+
function (::Type{IncrementalCycleTracker})(s::AbstractGraph{I}; dir::Union{Symbol,Nothing}=nothing) where {I}
16+
# TODO: Once we have more algorithms, the poly-algorithm decision goes here.
17+
# For now, we only have Algorithm N.
18+
return DenseGraphICT_BFGT_N{something(dir, :out)}(s)
19+
end
20+
21+
# Cycle Detection Interface
22+
"""
23+
add_edge_checked!([f!,], ict::IncrementalCycleTracker, v, w)
24+
25+
Using the incremental cycle tracker, ict, check whether adding the edge `v=>w`
26+
would introduce a cycle in the underlying graph. If so, return false and leave
27+
the ict intact. If not, update the underlying graph and return true.
28+
29+
# Optional `f!` Argument
30+
31+
By default the `add_edge!` function is used to update the underlying graph.
32+
However, for more complicated graphs, users may wish to manually specify the
33+
graph update operation. This may be accomplished by passing the optional `f!`
34+
callback argument. This callback is called on the underlying graph when no
35+
cycle is detected and is required to modify the underlying graph in order to
36+
effectuate the proposed edge addition.
37+
38+
# Batched edge additions
39+
40+
Optionally, either `v` or `w` (depending on the `in_out_reverse` flag) may be a
41+
collection of vertices representing a batched addition of vertices sharing a
42+
common source or target more efficiently than individual updates.
43+
44+
## Example
45+
46+
```jldoctest
47+
julia> G = SimpleDiGraph(3)
48+
49+
julia> ict = IncrementalCycleTracker(G)
50+
BFGT_N cycle tracker on {3, 0} directed simple Int64 graph
51+
52+
julia> add_edge_checked!(ict, 1, 2)
53+
true
54+
55+
julia> collect(edges(G))
56+
1-element Vector{Graphs.SimpleGraphs.SimpleEdge{Int64}}:
57+
Edge 1 => 2
58+
59+
julia> add_edge_checked!(ict, 2, 3)
60+
true
61+
62+
julia> collect(edges(G))
63+
2-element Vector{Graphs.SimpleGraphs.SimpleEdge{Int64}}:
64+
Edge 1 => 2
65+
Edge 2 => 3
66+
67+
julia> add_edge_checked!(ict, 3, 1) # Would add a cycle
68+
false
69+
70+
julia> collect(edges(G))
71+
2-element Vector{Graphs.SimpleGraphs.SimpleEdge{Int64}}:
72+
Edge 1 => 2
73+
Edge 2 => 3
74+
```
75+
"""
76+
function add_edge_checked! end
77+
78+
to_edges(v::Integer, w::Integer) = (v=>w,)
79+
to_edges(v::Integer, ws) = zip(repeated(v), ws)
80+
to_edges(vs, w::Integer) = zip(vs, repeated(w))
81+
82+
add_edge_checked!(ict::IncrementalCycleTracker, vs, ws) = add_edge_checked!(ict, vs, ws) do g
83+
foreach(((v, w),)->add_edge!(g, v, w), to_edges(vs, ws))
84+
end
85+
86+
# Utilities
87+
"""
88+
struct TransactionalVector
89+
90+
A vector with one checkpoint that may be reverted to by calling `revert!`. The setpoint itself
91+
is set by calling `commit!`.
92+
"""
93+
struct TransactionalVector{T} <: AbstractVector{T}
94+
v::Vector{T}
95+
log::Vector{Pair{Int, T}}
96+
TransactionalVector(v::Vector{T}) where {T} =
97+
new{T}(v, Vector{Pair{Int, T}}())
98+
end
99+
100+
function commit!(v::TransactionalVector)
101+
empty!(v.log)
102+
return nothing
103+
end
104+
105+
function revert!(vec::TransactionalVector)
106+
for (idx, val) in reverse(vec.log)
107+
vec.v[idx] = val
108+
end
109+
return nothing
110+
end
111+
112+
function Base.setindex!(vec::TransactionalVector, val, idx)
113+
oldval = vec.v[idx]
114+
vec.v[idx] = val
115+
push!(vec.log, idx=>oldval)
116+
return nothing
117+
end
118+
Base.getindex(vec::TransactionalVector, idx) = vec.v[idx]
119+
Base.size(vec::TransactionalVector) = size(vec.v)
120+
121+
function weak_topological_levels(g::AbstractGraph{I}) where {I}
122+
levels = fill(-1, nv(g))
123+
# Fill topological levels via dynamic programming dfs
124+
worklist = Vector{Int}()
125+
all_verts_max_level = 0
126+
for v in vertices(g)
127+
levels[v] != -1 && continue
128+
push!(worklist, v)
129+
while !isempty(worklist)
130+
v = last(worklist)
131+
levels[v] = -2
132+
visited_all = true
133+
maxlevel = -1
134+
for n in outneighbors(g, v)
135+
l = levels[n]
136+
maxlevel = max(l, maxlevel)
137+
l == -2 && error("Graph is not a DAG")
138+
if l == -1
139+
visited_all = false
140+
push!(worklist, n)
141+
end
142+
end
143+
visited_all || continue
144+
pop!(worklist)
145+
new_level = maxlevel + 1
146+
levels[v] = new_level
147+
all_verts_max_level = max(all_verts_max_level, new_level)
148+
end
149+
end
150+
return all_verts_max_level .- levels
151+
end
152+
153+
# Specific Algorithms
154+
155+
const bibliography = """
156+
## References
157+
158+
[BFGT15] Michael A. Bender, Jeremy T. Fineman, Seth Gilbert, and Robert E. Tarjan. 2015
159+
A New Approach to Incremental Cycle Detection and Related Problems.
160+
ACM Trans. Algorithms 12, 2, Article 14 (December 2015), 22 pages.
161+
DOI: http://dx.doi.org/10.1145/2756553
162+
"""
163+
164+
## Bender, Algorithm N
165+
166+
"""
167+
struct DenseGraphICT_BFGT_N
168+
169+
Implements the "Naive" (Algorithm N) Bender-Fineman-Gilbert-Tarjan one-way line search incremental cycle detector
170+
for dense graphs from [BFGT15] (Section 3).
171+
172+
$bibliography
173+
"""
174+
struct DenseGraphICT_BFGT_N{InOut, I, G<:AbstractGraph{I}} <: IncrementalCycleTracker{I}
175+
graph::G
176+
levels::TransactionalVector{Int}
177+
function DenseGraphICT_BFGT_N{InOut}(g::G) where {InOut, I, G<:AbstractGraph{I}}
178+
if ne(g) == 0
179+
# Common case fast path, no edges, all level start at 0.
180+
levels = fill(0, nv(g))
181+
else
182+
levels = weak_topological_levels(g)
183+
end
184+
new{InOut, I, G}(g, TransactionalVector(levels))
185+
end
186+
end
187+
188+
function Base.show(io::IO, ict::DenseGraphICT_BFGT_N)
189+
print(io, "BFGT_N cycle tracker on ")
190+
show(io, ict.graph)
191+
end
192+
193+
function topological_sort(ict::DenseGraphICT_BFGT_N{InOut}) where {InOut}
194+
# The ICT levels are a weak topological ordering, so a sort of the levels
195+
# will give a topological sort of the vertices.
196+
perm = sortperm(ict.levels)
197+
(InOut === :in) && (perm = reverse(perm))
198+
return perm
199+
end
200+
201+
# Even when both `v` and `w` are integer, we know that `v` would come first, so
202+
# we prefer to check for `v` as the cycle vertex in this case.
203+
add_edge_checked!(f!, ict::DenseGraphICT_BFGT_N{:out}, v::Integer, ws) =
204+
_check_cycle_add!(f!, ict, to_edges(v, ws), v)
205+
add_edge_checked!(f!, ict::DenseGraphICT_BFGT_N{:in}, vs, w::Integer) =
206+
_check_cycle_add!(f!, ict, to_edges(vs, w), w)
207+
208+
### [BFGT15] Algorithm N
209+
#
210+
# Implementation Notes
211+
#
212+
# This is Algorithm N from [BFGT15] (Section 3), plus limited patching support and
213+
# a number of standard tricks. Namely:
214+
#
215+
# 1. Batching is supported as long as there is only a single source or destination
216+
# vertex. General batching is left as an open problem. The reason that the
217+
# single source/dest batching is easy to add is that we know that either the
218+
# source or the destination vertex is guaranteed to be a part of any cycle
219+
# that we may have added. Thus we're guaranteed to encounter one of the two
220+
# vertices in our cycle validation and the rest of the algorithm goes through
221+
# as usual.
222+
# 2. We opportunistically traverse each edge when we see it and only add it
223+
# to the worklist if we know that traversal will recurse further.
224+
# 3. We add some early out checks to detect we're about to do redundant work.
225+
function _check_cycle_add!(f!, ict::DenseGraphICT_BFGT_N{InOut}, edges, v) where {InOut}
226+
g = ict.graph
227+
worklist = Pair{Int, Int}[]
228+
# TODO: In the case where there's a single target vertex, we could saturate
229+
# the level first before we assign it to the tracked vector to save some
230+
# log space.
231+
for (v, w) in edges
232+
(InOut === :in) && ((v, w) = (w, v))
233+
if ict.levels[v] < ict.levels[w]
234+
continue
235+
end
236+
v == w && return false
237+
ict.levels[w] = ict.levels[v] + 1
238+
push!(worklist, v=>w)
239+
end
240+
while !isempty(worklist)
241+
(x, y) = popfirst!(worklist)
242+
xlevel = ict.levels[x]
243+
ylevel = ict.levels[y]
244+
if xlevel >= ylevel
245+
# The xlevel may have been incremented further since we added this
246+
# edge to the worklist.
247+
ict.levels[y] = ylevel = xlevel + 1
248+
elseif ylevel > xlevel + 1
249+
# Some edge traversal scheduled for later already incremented this
250+
# level past where we would have been. Delay processing until then.
251+
continue
252+
end
253+
for z in (InOut === :in ? inneighbors(g, y) : outneighbors(g, y))
254+
if z == v
255+
revert!(ict.levels)
256+
return false
257+
end
258+
if ylevel >= ict.levels[z]
259+
ict.levels[z] = ylevel + 1
260+
push!(worklist, y=>z)
261+
end
262+
end
263+
end
264+
commit!(ict.levels)
265+
f!(g)
266+
return true
267+
end

test/cycles/incremental.jl

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
@testset "ICT" begin
2+
Gempty = SimpleDiGraph(3)
3+
Gsomedges = SimpleDiGraph(6)
4+
add_edge!(Gsomedges, 4, 5)
5+
add_edge!(Gsomedges, 6, 5)
6+
for Gtemplate in (Gempty, Gsomedges)
7+
for dir in (:out, :in)
8+
G = copy(Gtemplate)
9+
ict = IncrementalCycleTracker(G; dir=dir)
10+
@test_nowarn repr(ict)
11+
@test add_edge_checked!(ict, 1, 2)
12+
@test add_edge_checked!(ict, 2, 3)
13+
@test length(edges(G)) == 2 + length(edges(Gtemplate))
14+
@test !add_edge_checked!(ict, 3, 1)
15+
@test !add_edge_checked!(ict, 3, 2)
16+
if dir === :in
17+
@test !add_edge_checked!(ict, (2, 3), 1)
18+
@test !add_edge_checked!(ict, (1, 3), 2)
19+
else
20+
@test !add_edge_checked!(ict, 3, (1,2))
21+
@test !add_edge_checked!(ict, 2, (1,3))
22+
end
23+
@test length(edges(G)) == 2 + length(edges(Gtemplate))
24+
@test filter(in((1,2,3)), topological_sort(ict)) == [1, 2, 3]
25+
end
26+
end
27+
28+
Gcycle2 = SimpleDiGraph(2)
29+
add_edge!(Gcycle2, 1, 2)
30+
add_edge!(Gcycle2, 2, 1)
31+
@test_throws ErrorException IncrementalCycleTracker(Gcycle2)
32+
33+
Gcycle3 = SimpleDiGraph(3)
34+
add_edge!(Gcycle3, 1, 2)
35+
add_edge!(Gcycle3, 2, 3)
36+
add_edge!(Gcycle3, 3, 1)
37+
@test_throws ErrorException IncrementalCycleTracker(Gcycle3)
38+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ tests = [
3636
"cycles/karp",
3737
"cycles/basis",
3838
"cycles/limited_length",
39+
"cycles/incremental",
3940
"edit_distance",
4041
"connectivity",
4142
"persistence/persistence",

0 commit comments

Comments
 (0)