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.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
159 changes: 159 additions & 0 deletions src/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
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.
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.

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 or equal to `cutoff`, it is
omitted.
thchr marked this conversation as resolved.
Show resolved Hide resolved

## 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) - 1
) 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, ']')

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

View check run for this annotation

Codecov / codecov/patch

src/traversals/all_simple_paths.jl#L65-L67

Added lines #L65 - L67 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{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, 1)) # add a child node with index 1
thchr marked this conversation as resolved.
Show resolved Hide resolved
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]
thchr marked this conversation as resolved.
Show resolved Hide resolved
next_child_index′ = pop!(state.stack)[2] # move child index forward
thchr marked this conversation as resolved.
Show resolved Hide resolved
push!(state.stack, (parent_node, next_child_index′ + 1)) # ↩
thchr marked this conversation as resolved.
Show resolved Hide resolved
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])
thchr marked this conversation as resolved.
Show resolved Hide resolved
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
thchr marked this conversation as resolved.
Show resolved Hide resolved
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

# 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
124 changes: 124 additions & 0 deletions test/traversals/all_simple_paths.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
@testset "All simple paths" begin
# single path
g = path_graph(4)
paths = all_simple_paths(g, 1, 4)
@test Set(paths) == Set([[1, 2, 3, 4]])
@test Set(collect(paths)) == Set([[1, 2, 3, 4]])
thchr marked this conversation as resolved.
Show resolved Hide resolved

# printing
@test sprint(show, paths) == "SimplePathIterator{SimpleGraph{Int64}}(1 → 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(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(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(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
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(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
Loading