Skip to content

Commit

Permalink
All simple paths (refresh #20) (#353)
Browse files Browse the repository at this point in the history
* `all_simple_paths`: update PR #20

- this updates the port of sbromberger/LightGraphs.jl#1540 from #20
- has a number of simplifications relative to original implementation
- original implementation by @i-aki-y
- cutoff now defaults to `nv(g)`

Co-authored-by: akiyuki ishikawa <[email protected]>
Co-authored-by: Etienne dg <[email protected]>

* fixes to tests & doctests

* improve docstring

* run JuliaFormatter

- `format(Graphs, overwrite=true)`

* bump to v1.9.1

* fix docs

* address code-review

* fix formatting

* special-case  `u in vs` input: include 0-length path `[u]` in iterates

* updates after code review

* Update src/traversals/all_simple_paths.jl

Co-authored-by: Guillaume Dalle <[email protected]>

* Update src/traversals/all_simple_paths.jl

Co-authored-by: Guillaume Dalle <[email protected]>

* Update src/traversals/all_simple_paths.jl

Co-authored-by: Guillaume Dalle <[email protected]>

* Apply suggestions from code review

Co-authored-by: Guillaume Dalle <[email protected]>

* more updates from code-review

* format

---------

Co-authored-by: akiyuki ishikawa <[email protected]>
Co-authored-by: Etienne dg <[email protected]>
Co-authored-by: Guillaume Dalle <[email protected]>
  • Loading branch information
4 people authored Apr 5, 2024
1 parent 9291314 commit c1cedee
Show file tree
Hide file tree
Showing 6 changed files with 296 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "Graphs"
uuid = "86223c79-3864-5bf0-83f7-82e725a168b6"
version = "1.9.0"
version = "1.10.0"

[deps]
ArnoldiMethod = "ec485272-7323-5ecc-a04f-4719b315124d"
Expand Down
1 change: 1 addition & 0 deletions docs/src/algorithms/traversals.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ Pages = [
"traversals/maxadjvisit.jl",
"traversals/randomwalks.jl",
"traversals/eulerian.jl",
"traversals/all_simple_paths.jl",
]
```
7 changes: 6 additions & 1 deletion src/Graphs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ using DataStructures:
union!,
find_root!,
BinaryMaxHeap,
BinaryMinHeap
BinaryMinHeap,
Stack
using LinearAlgebra: I, Symmetric, diagm, eigen, eigvals, norm, rmul!, tril, triu
import LinearAlgebra: Diagonal, issymmetric, mul!
using Random:
Expand Down Expand Up @@ -197,6 +198,9 @@ export
# eulerian
eulerian,

# all simple paths
all_simple_paths,

# coloring
greedy_color,

Expand Down Expand Up @@ -496,6 +500,7 @@ include("traversals/maxadjvisit.jl")
include("traversals/randomwalks.jl")
include("traversals/diffusion.jl")
include("traversals/eulerian.jl")
include("traversals/all_simple_paths.jl")
include("connectivity.jl")
include("distance.jl")
include("editdist.jl")
Expand Down
160 changes: 160 additions & 0 deletions src/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""
all_simple_paths(g, u, v; cutoff) --> Graphs.SimplePathIterator
all_simple_paths(g, u, vs; cutoff) --> Graphs.SimplePathIterator
Returns an iterator that generates all
[simple paths](https://en.wikipedia.org/wiki/Path_(graph_theory)#Walk,_trail,_and_path) in
the graph `g` from a source vertex `u` to a target vertex `v` or iterable of target vertices
`vs`. A simple path has no repeated vertices.
The iterator's elements (i.e., the paths) can be materialized via `collect` or `iterate`.
Paths are iterated in the order of a depth-first search.
If the requested path has identical source and target vertices, i.e., if `u = v`, a
zero-length path `[u]` is included among the iterates.
## Keyword arguments
The maximum path length (i.e., number of edges) is limited by the keyword argument `cutoff`
(default, `nv(g)-1`). If a path's path length is greater than `cutoff`, it is
omitted.
## Examples
```jldoctest allsimplepaths; setup = :(using Graphs)
julia> g = complete_graph(4);
julia> spi = all_simple_paths(g, 1, 4)
SimplePathIterator{SimpleGraph{Int64}}(1 → 4)
julia> collect(spi)
5-element Vector{Vector{Int64}}:
[1, 2, 3, 4]
[1, 2, 4]
[1, 3, 2, 4]
[1, 3, 4]
[1, 4]
```
We can restrict the search to path lengths less than or equal to a specified cut-off (here,
2 edges):
```jldoctest allsimplepaths; setup = :(using Graphs)
julia> collect(all_simple_paths(g, 1, 4; cutoff=2))
3-element Vector{Vector{Int64}}:
[1, 2, 4]
[1, 3, 4]
[1, 4]
```
"""
function all_simple_paths(
g::AbstractGraph{T}, u::T, vs; cutoff::T=nv(g) - one(T)
) where {T<:Integer}
vs = vs isa Set{T} ? vs : Set{T}(vs)
return SimplePathIterator(g, u, vs, cutoff)
end

# iterator over all simple paths from `u` to `vs` in `g` of length less than `cutoff`
struct SimplePathIterator{T<:Integer,G<:AbstractGraph{T}}
g::G
u::T # start vertex
vs::Set{T} # target vertices
cutoff::T # max length of resulting paths
end

function Base.show(io::IO, spi::SimplePathIterator)
print(io, "SimplePathIterator{", typeof(spi.g), "}(", spi.u, "")
if length(spi.vs) == 1
print(io, only(spi.vs))
else
print(io, '[')
join(io, spi.vs, ", ")
print(io, ']')
end
print(io, ')')
return nothing
end
Base.IteratorSize(::Type{<:SimplePathIterator}) = Base.SizeUnknown()
Base.eltype(::SimplePathIterator{T}) where {T} = Vector{T}

mutable struct SimplePathIteratorState{T<:Integer}
stack::Stack{Tuple{T,T}} # used to restore iteration of child vertices: elements are ↩
# (parent vertex, index of children)
visited::Stack{T} # current path candidate
queued::Vector{T} # remaining targets if path length reached cutoff
self_visited::Bool # in case `u ∈ vs`, we want to return a `[u]` path once only
end
function SimplePathIteratorState(spi::SimplePathIterator{T}) where {T<:Integer}
stack = Stack{Tuple{T,T}}()
visited = Stack{T}()
queued = Vector{T}()
push!(visited, spi.u) # add a starting vertex to the path candidate
push!(stack, (spi.u, one(T))) # add a child node with index 1
return SimplePathIteratorState{T}(stack, visited, queued, false)
end

function _stepback!(state::SimplePathIteratorState) # updates iterator state.
pop!(state.stack)
pop!(state.visited)
return nothing
end

# iterates to the next simple path in `spi`, according to a depth-first search
function Base.iterate(
spi::SimplePathIterator{T}, state::SimplePathIteratorState=SimplePathIteratorState(spi)
) where {T<:Integer}
while !isempty(state.stack)
if !isempty(state.queued) # consume queued targets
target = pop!(state.queued)
result = vcat(reverse(collect(state.visited)), target)
if isempty(state.queued)
_stepback!(state)
end
return result, state
end

parent_node, next_child_index = first(state.stack)
children = outneighbors(spi.g, parent_node)
if length(children) < next_child_index
_stepback!(state) # all children have been checked, step back
continue
end

child = children[next_child_index]
next_child_index_tmp = pop!(state.stack)[2] # move child ↩
push!(state.stack, (parent_node, next_child_index_tmp + one(T))) # index forward
child in state.visited && continue

if length(state.visited) == spi.cutoff
# collect adjacent targets if more exist and add them to queue
rest_children = Set(children[next_child_index:end])
state.queued = collect(
setdiff(intersect(spi.vs, rest_children), Set(state.visited))
)

if isempty(state.queued)
_stepback!(state)
end
else
result = if child in spi.vs
vcat(reverse(collect(state.visited)), child)
else
nothing
end

# update state variables
push!(state.visited, child) # move to child vertex
if !isempty(setdiff(spi.vs, state.visited)) # expand stack until all targets are found
push!(state.stack, (child, one(T))) # add the child node as a parent for next iteration
else
pop!(state.visited) # step back and explore the remaining child nodes
end

if !isnothing(result) # found a new path, return it
return result, state
end
end
end

# special-case: when `vs` includes `u`, return also a 1-vertex, 0-length path `[u]`
if spi.u in spi.vs && !state.self_visited
state.self_visited = true
return [spi.u], state
end
end
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ tests = [
"traversals/randomwalks",
"traversals/diffusion",
"traversals/eulerian",
"traversals/all_simple_paths",
"community/cliques",
"community/core-periphery",
"community/label_propagation",
Expand Down
127 changes: 127 additions & 0 deletions test/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
@testset "All simple paths" begin
# single path
g = path_graph(4)
paths = all_simple_paths(g, 1, 4)
@test Set(paths) == Set(collect(paths)) == Set([[1, 2, 3, 4]])

# printing
@test sprint(show, paths) == "SimplePathIterator{SimpleGraph{Int64}}(1 → 4)"

# complete graph with cutoff
g = complete_graph(4)
@test Set(all_simple_paths(g, 1, 4; cutoff=2)) == Set([[1, 2, 4], [1, 3, 4], [1, 4]])

# two paths
g = path_graph(4)
add_vertex!(g)
add_edge!(g, 3, 5)
paths = all_simple_paths(g, 1, [4, 5])
@test Set(paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])
@test Set(collect(paths)) == Set([[1, 2, 3, 4], [1, 2, 3, 5]]) # check `collect` also

# two paths, with one beyond a cut-off
g = path_graph(4)
add_vertex!(g)
add_edge!(g, 3, 5)
add_vertex!(g)
add_edge!(g, 5, 6)
paths = all_simple_paths(g, 1, [4, 6])
@test Set(paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5, 6]])
paths = all_simple_paths(g, 1, [4, 6]; cutoff=3)
@test Set(paths) == Set([[1, 2, 3, 4]])

# two targets in line emits two paths
g = path_graph(4)
add_vertex!(g)
paths = all_simple_paths(g, 1, [3, 4])
@test Set(paths) == Set([[1, 2, 3], [1, 2, 3, 4]])

# two paths digraph
g = SimpleDiGraph(5)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 4)
add_edge!(g, 3, 5)
paths = all_simple_paths(g, 1, [4, 5])
@test Set(paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])

# two paths digraph with cutoff
g = SimpleDiGraph(5)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 4)
add_edge!(g, 3, 5)
paths = all_simple_paths(g, 1, [4, 5]; cutoff=3)
@test Set(paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])

# digraph with a cycle
g = SimpleDiGraph(4)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 1)
add_edge!(g, 2, 4)
paths = all_simple_paths(g, 1, 4)
@test Set(paths) == Set([[1, 2, 4]])

# digraph with a cycle; paths with two targets share a node in the cycle
g = SimpleDiGraph(4)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 1)
add_edge!(g, 2, 4)
paths = all_simple_paths(g, 1, [3, 4])
@test Set(paths) == Set([[1, 2, 3], [1, 2, 4]])

# another digraph with a cycle; check cycles are excluded, regardless of cutoff
g = SimpleDiGraph(6)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 4)
add_edge!(g, 4, 5)
add_edge!(g, 5, 2)
add_edge!(g, 5, 6)
paths = all_simple_paths(g, 1, 6)
paths′ = all_simple_paths(g, 1, 6; cutoff=typemax(Int))
@test Set(paths) == Set(paths′) == Set([[1, 2, 3, 4, 5, 6]])

# same source and target vertex
g = path_graph(4)
@test Set(all_simple_paths(g, 1, 1)) == Set([[1]])
@test Set(all_simple_paths(g, 3, 3)) == Set([[3]])
@test Set(all_simple_paths(g, 1, [1, 1])) == Set([[1]])
@test Set(all_simple_paths(g, 1, [1, 4])) == Set([[1], [1, 2, 3, 4]])

# cutoff prunes paths (note: maximum path length below is `nv(g) - 1`)
g = complete_graph(4)
paths = all_simple_paths(g, 1, 2; cutoff=1)
@test Set(paths) == Set([[1, 2]])

paths = all_simple_paths(g, 1, 2; cutoff=2)
@test Set(paths) == Set([[1, 2], [1, 3, 2], [1, 4, 2]])

# nontrivial graph
g = SimpleDiGraph(6)
add_edge!(g, 1, 2)
add_edge!(g, 2, 3)
add_edge!(g, 3, 4)
add_edge!(g, 4, 5)

add_edge!(g, 1, 6)
add_edge!(g, 2, 6)
add_edge!(g, 2, 4)
add_edge!(g, 6, 5)
add_edge!(g, 5, 3)
add_edge!(g, 5, 4)

paths = all_simple_paths(g, 2, [3, 4])
@test Set(paths) == Set([
[2, 3], [2, 4, 5, 3], [2, 6, 5, 3], [2, 4], [2, 3, 4], [2, 6, 5, 4], [2, 6, 5, 3, 4]
])

paths = all_simple_paths(g, 2, [3, 4]; cutoff=3)
@test Set(paths) ==
Set([[2, 3], [2, 4, 5, 3], [2, 6, 5, 3], [2, 4], [2, 3, 4], [2, 6, 5, 4]])

paths = all_simple_paths(g, 2, [3, 4]; cutoff=2)
@test Set(paths) == Set([[2, 3], [2, 4], [2, 3, 4]])
end

0 comments on commit c1cedee

Please sign in to comment.