Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

All simple paths (refresh #20) #353

Merged
merged 16 commits into from
Apr 5, 2024
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.9.1"
thchr marked this conversation as resolved.
Show resolved Hide resolved

[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
147 changes: 147 additions & 0 deletions src/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
all_simple_paths(g, u, v; cutoff=nv(g)) --> 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.
thchr marked this conversation as resolved.
Show resolved Hide resolved

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.

## Keyword arguments
The maximum path length (i.e., number of edges) is limited by the keyword argument `cutoff`
(default, `nv(g)`). If a path's path length is greater than or equal to `cutoff`, it is
thchr marked this conversation as resolved.
Show resolved Hide resolved
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}}:
thchr marked this conversation as resolved.
Show resolved Hide resolved
[1, 2, 3, 4]
[1, 2, 4]
[1, 3, 2, 4]
[1, 3, 4]
[1, 4]
```
We can restrict the search to paths of length less than a specified cut-off (here, 2 edges):
thchr marked this conversation as resolved.
Show resolved Hide resolved
```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)) where {T<:Integer}
vs = vs isa Set{T} ? vs : Set{T}(vs)
return SimplePathIterator(g, u, vs, cutoff)
end

# Iterator that generates all simple paths in `g` from `u` to `vs` of a length at most
# `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, ']')

Check warning on line 62 in src/traversals/all_simple_paths.jl

View check run for this annotation

Codecov / codecov/patch

src/traversals/all_simple_paths.jl#L60-L62

Added lines #L60 - L62 were not covered by tests
thchr marked this conversation as resolved.
Show resolved Hide resolved
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{Vector{T}} # used to restore iteration of child vertices; each vector has
thchr marked this conversation as resolved.
Show resolved Hide resolved
# two elements: a parent vertex and an index of children
thchr marked this conversation as resolved.
Show resolved Hide resolved
visited::Stack{T} # current path candidate
queued::Vector{T} # remaining targets if path length reached cutoff
end
function SimplePathIteratorState(spi::SimplePathIterator{T}) where {T<:Integer}
stack = Stack{Vector{T}}()
visited = Stack{T}()
queued = Vector{T}()
push!(visited, spi.u) # add a starting vertex to the path candidate
push!(stack, [spi.u, 1]) # add a child node with index 1
return SimplePathIteratorState{T}(stack, visited, queued)
end

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

# Returns the next simple path in `spi`, according to a depth-first search
gdalle marked this conversation as resolved.
Show resolved Hide resolved
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_childe_index = first(state.stack)
children = outneighbors(spi.g, parent_node)
if length(children) < next_childe_index
# all children have been checked, step back.
_stepback!(state)
continue
end

child = children[next_childe_index]
first(state.stack)[2] += 1 # move child 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_childe_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, 1]) # add the child node as a parent for next iteration
else
pop!(state.visited) # step back and explore the remaining child nodes
gdalle marked this conversation as resolved.
Show resolved Hide resolved
end

if !isnothing(result) # found a new path, return it
return result, state
end
end
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
108 changes: 108 additions & 0 deletions test/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
@testset "All simple paths" begin
# single path
g = path_graph(4)
paths = all_simple_paths(g, 1, 4)
@test Set(p for p in paths) == Set([[1, 2, 3, 4]])
@test Set(collect(paths)) == Set([[1, 2, 3, 4]])

# single path with cutoff
thchr marked this conversation as resolved.
Show resolved Hide resolved
g = complete_graph(4)
@test collect(all_simple_paths(g, 1, 4; cutoff=2)) == [[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(p for p in paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])
@test Set(collect(paths)) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])

# two paths with cutoff
thchr marked this conversation as resolved.
Show resolved Hide resolved
g = path_graph(4)
add_vertex!(g)
add_edge!(g, 3, 5)
paths = all_simple_paths(g, 1, [4, 5]; cutoff=3)
thchr marked this conversation as resolved.
Show resolved Hide resolved
@test Set(p for p in paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])

# two targets in line emits two paths
thchr marked this conversation as resolved.
Show resolved Hide resolved
g = path_graph(4)
add_vertex!(g)
paths = all_simple_paths(g, 1, [3, 4])
@test Set(p for p in 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(p for p in 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(p for p in paths) == Set([[1, 2, 3, 4], [1, 2, 3, 5]])

# digraph with a cycle
thchr marked this conversation as resolved.
Show resolved Hide resolved
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(p for p in 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(p for p in paths) == Set([[1, 2, 3], [1, 2, 4]])

# source equals targets
g = SimpleGraph(4)
paths = all_simple_paths(g, 1, 1)
@test Set(p for p in paths) == Set([])
thchr marked this conversation as resolved.
Show resolved Hide resolved

# cutoff prones paths
thchr marked this conversation as resolved.
Show resolved Hide resolved
# Note, a path lenght is node - 1
thchr marked this conversation as resolved.
Show resolved Hide resolved
g = complete_graph(4)
paths = all_simple_paths(g, 1, 2; cutoff=1)
@test Set(p for p in paths) == Set([[1, 2]])

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

# non trivial 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(p for p in 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(p for p in 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(p for p in paths) == Set([[2, 3], [2, 4], [2, 3, 4]])
end
Loading