diff --git a/docs/make.jl b/docs/make.jl index d7f62b704..e82bffed5 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -60,6 +60,7 @@ pages_files = [ "algorithms/editdist.md", "algorithms/independentset.md", "algorithms/linalg.md", + "algorithms/matching.md", "algorithms/shortestpaths.md", "algorithms/spanningtrees.md", "algorithms/steinertree.md", diff --git a/docs/src/algorithms/matching.md b/docs/src/algorithms/matching.md new file mode 100644 index 000000000..392b0561e --- /dev/null +++ b/docs/src/algorithms/matching.md @@ -0,0 +1,18 @@ +# Matchings + +_Graphs.jl_ contains functions related to matchings. + +## Index + +```@index +Pages = ["matching.md"] +``` + +## Full docs + +```@docs +AbstractMaximumMatchingAlgorithm +HopcroftKarpAlgorithm +maximum_cardinality_matching +hopcroft_karp_matching +``` diff --git a/src/Graphs.jl b/src/Graphs.jl index c8d209cf5..318d2f66e 100644 --- a/src/Graphs.jl +++ b/src/Graphs.jl @@ -422,7 +422,13 @@ export independent_set, # vertexcover - vertex_cover + vertex_cover, + + # matching + AbstractMaximumMatchingAlgorithm, + HopcroftKarpAlgorithm, + hopcroft_karp_matching, + maximum_cardinality_matching """ Graphs @@ -538,6 +544,7 @@ include("vertexcover/random_vertex_cover.jl") include("Experimental/Experimental.jl") include("Parallel/Parallel.jl") include("Test/Test.jl") +include("matching/maximum_matching.jl") using .LinAlg end # module diff --git a/src/matching/hopcroft_karp.jl b/src/matching/hopcroft_karp.jl new file mode 100644 index 000000000..0c66ae82d --- /dev/null +++ b/src/matching/hopcroft_karp.jl @@ -0,0 +1,133 @@ +const UNMATCHED = nothing +MatchedNodeType{T} = Union{T,typeof(UNMATCHED)} + +""" +Determine whether an augmenting path exists and mark distances +so we can compute shortest-length augmenting paths in the DFS. +""" +function _hk_augmenting_bfs!( + graph::AbstractGraph{T}, + set1::Vector{T}, + matching::Dict{T,MatchedNodeType{T}}, + distance::Dict{MatchedNodeType{T},Float64}, +)::Bool where {T<:Integer} + # Initialize queue with the unmatched nodes in set1 + queue = Vector{MatchedNodeType{eltype(graph)}}([ + n for n in set1 if matching[n] == UNMATCHED + ]) + + distance[UNMATCHED] = Inf + for n in set1 + if matching[n] == UNMATCHED + distance[n] = 0.0 + else + distance[n] = Inf + end + end + + while !isempty(queue) + n1 = popfirst!(queue) + + # If n1 is (a) matched or (b) in set1 + if distance[n1] < Inf && n1 != UNMATCHED + for n2 in neighbors(graph, n1) + # If n2 has not been encountered + if distance[matching[n2]] == Inf + # Give it a distance + distance[matching[n2]] = distance[n1] + 1 + + # Note that n2 could be unmatched + push!(queue, matching[n2]) + end + end + end + end + + found_augmenting_path = (distance[UNMATCHED] < Inf) + # The distance to UNMATCHED is the length of the shortest augmenting path + return found_augmenting_path +end + +""" +Compute augmenting paths and update the matching +""" +function _hk_augmenting_dfs!( + graph::AbstractGraph{T}, + root::MatchedNodeType{T}, + matching::Dict{T,MatchedNodeType{T}}, + distance::Dict{MatchedNodeType{T},Float64}, +)::Bool where {T<:Integer} + if root != UNMATCHED + for n in neighbors(graph, root) + # Traverse edges of the minimum-length alternating path + if distance[matching[n]] == distance[root] + 1 + if _hk_augmenting_dfs!(graph, matching[n], matching, distance) + # If the edge is part of an augmenting path, update the + # matching + matching[root] = n + matching[n] = root + return true + end + end + end + # If we could not find a matched edge that was part of an augmenting + # path, we need to make sure we don't consider this vertex again + distance[root] = Inf + return false + else + # Return true to indicate that we are part of an augmenting path + return true + end +end + +""" + hopcroft_karp_matching(graph::AbstractGraph)::Dict + +Compute a maximum-cardinality matching of a bipartite graph via the +[Hopcroft-Karp algorithm](https://en.wikipedia.org/wiki/Hopcroft-Karp_algorithm). + +The return type is a dict mapping nodes to nodes. All matched nodes are included +as keys. For example, if `i` is matched with `j`, `i => j` and `j => i` are both +included in the returned dict. + +### Performance + +The algorithms runs in O((m + n)n^0.5), where n is the number of vertices and +m is the number of edges. As it does not assume the number of edges is O(n^2), +this algorithm is particularly effective for sparse bipartite graphs. + +### Arguments + +* `graph`: The bipartite `Graph` for which a maximum matching is computed + +### Exceptions + +* `ArgumentError`: The provided graph is not bipartite + +""" +function hopcroft_karp_matching(graph::AbstractGraph{T})::Dict{T,T} where {T<:Integer} + bmap = bipartite_map(graph) + if length(bmap) != nv(graph) + throw(ArgumentError("Provided graph is not bipartite")) + end + set1 = [n for n in vertices(graph) if bmap[n] == 1] + + # Initialize "state" that is modified during the algorithm + matching = Dict{eltype(graph),MatchedNodeType{eltype(graph)}}( + n => UNMATCHED for n in vertices(graph) + ) + distance = Dict{MatchedNodeType{eltype(graph)},Float64}() + + # BFS to determine whether any augmenting paths exist + while _hk_augmenting_bfs!(graph, set1, matching, distance) + for n1 in set1 + if matching[n1] == UNMATCHED + # DFS to update the matching along a minimum-length + # augmenting path + _hk_augmenting_dfs!(graph, n1, matching, distance) + end + end + end + matching = Dict(i => j for (i, j) in matching if j != UNMATCHED) + return matching +end diff --git a/src/matching/maximum_matching.jl b/src/matching/maximum_matching.jl new file mode 100644 index 000000000..02bcb63bb --- /dev/null +++ b/src/matching/maximum_matching.jl @@ -0,0 +1,76 @@ +include("hopcroft_karp.jl") + +""" + AbstractMaximumMatchingAlgorithm + +Abstract type for maximum cardinality matching algorithms +""" +abstract type AbstractMaximumMatchingAlgorithm end + +""" + HopcroftKarpAlgorithm + +The [Hopcroft-Karp algorithm](https://en.wikipedia.org/wiki/Hopcroft-Karp_algorithm) +for computing a maximum cardinality matching of a bipartite graph. +""" +struct HopcroftKarpAlgorithm <: AbstractMaximumMatchingAlgorithm end + +""" + maximum_cardinality_matching( + graph::AbstractGraph, + algorithm::AbstractMaximumMatchingAlgorithm, + )::Dict{Int, Int} + +Compute a maximum-cardinality matching. + +The return type is a dict mapping nodes to nodes. All matched nodes are included +as keys. For example, if `i` is matched with `j`, `i => j` and `j => i` are both +included in the returned dict. + +### Arguments + +* `graph`: The `Graph` for which a maximum matching is computed + +* `algorithm`: The algorithm to use to compute the matching. Default is + `HopcroftKarpAlgorithm`. + +### Algorithms +Currently implemented algorithms are: + +* Hopcroft-Karp + +### Exceptions + +* `ArgumentError`: The provided graph is not bipartite but an algorithm that + only applies to bipartite graphs, e.g. Hopcroft-Karp, was chosen + +### Example +```jldoctest +julia> using Graphs + +julia> g = path_graph(6) +{6, 5} undirected simple Int64 graph + +julia> maximum_cardinality_matching(g) +Dict{Int64, Int64} with 6 entries: + 5 => 6 + 4 => 3 + 6 => 5 + 2 => 1 + 3 => 4 + 1 => 2 + +``` +""" +function maximum_cardinality_matching( + graph::AbstractGraph{T}; + algorithm::AbstractMaximumMatchingAlgorithm=HopcroftKarpAlgorithm(), +)::Dict{T,T} where {T<:Integer} + return maximum_cardinality_matching(graph, algorithm) +end + +function maximum_cardinality_matching( + graph::AbstractGraph{T}, algorithm::HopcroftKarpAlgorithm +)::Dict{T,T} where {T<:Integer} + return hopcroft_karp_matching(graph) +end diff --git a/test/matching/maximum_matching.jl b/test/matching/maximum_matching.jl new file mode 100644 index 000000000..d6e4bc283 --- /dev/null +++ b/test/matching/maximum_matching.jl @@ -0,0 +1,194 @@ +using Graphs +using Test + +function test_simple_example() + # From the wikipedia page for the Hopcroft-Karp algorithm + # https://en.wikipedia.org/wiki/Hopcroft–Karp_algorithm + g = Graph() + add_vertices!(g, 10) + add_edge!(g, (1, 6)) + add_edge!(g, (1, 7)) + add_edge!(g, (2, 6)) + add_edge!(g, (2, 10)) + add_edge!(g, (3, 8)) + add_edge!(g, (3, 9)) + add_edge!(g, (4, 6)) + add_edge!(g, (4, 10)) + add_edge!(g, (5, 6)) + add_edge!(g, (5, 9)) + + matching = maximum_cardinality_matching(g) + @test length(matching) == 10 + for i in 1:10 + @test i in keys(matching) + @test matching[i] in neighbors(g, i) + end +end + +function test_simple_example_algorithm_argument() + # From the wikipedia page for the Hopcroft-Karp algorithm + # https://en.wikipedia.org/wiki/Hopcroft–Karp_algorithm + g = Graph() + add_vertices!(g, 10) + add_edge!(g, (1, 6)) + add_edge!(g, (1, 7)) + add_edge!(g, (2, 6)) + add_edge!(g, (2, 10)) + add_edge!(g, (3, 8)) + add_edge!(g, (3, 9)) + add_edge!(g, (4, 6)) + add_edge!(g, (4, 10)) + add_edge!(g, (5, 6)) + add_edge!(g, (5, 9)) + + algorithm = HopcroftKarpAlgorithm() + matching = maximum_cardinality_matching(g, algorithm) + @test length(matching) == 10 + for i in 1:10 + @test i in keys(matching) + @test matching[i] in neighbors(g, i) + end +end + +function test_simple_example_hopcroft_karp() + # From the wikipedia page for the Hopcroft-Karp algorithm + # https://en.wikipedia.org/wiki/Hopcroft–Karp_algorithm + g = Graph() + add_vertices!(g, 10) + add_edge!(g, (1, 6)) + add_edge!(g, (1, 7)) + add_edge!(g, (2, 6)) + add_edge!(g, (2, 10)) + add_edge!(g, (3, 8)) + add_edge!(g, (3, 9)) + add_edge!(g, (4, 6)) + add_edge!(g, (4, 10)) + add_edge!(g, (5, 6)) + add_edge!(g, (5, 9)) + + matching = hopcroft_karp_matching(g) + @test length(matching) == 10 + for i in 1:10 + @test i in keys(matching) + @test matching[i] in neighbors(g, i) + end +end + +function test_simple_example_different_node_type() + # From the wikipedia page for the Hopcroft-Karp algorithm + # https://en.wikipedia.org/wiki/Hopcroft–Karp_algorithm + g = Graph{UInt8}() + add_vertices!(g, 10) + add_edge!(g, (1, 6)) + add_edge!(g, (1, 7)) + add_edge!(g, (2, 6)) + add_edge!(g, (2, 10)) + add_edge!(g, (3, 8)) + add_edge!(g, (3, 9)) + add_edge!(g, (4, 6)) + add_edge!(g, (4, 10)) + add_edge!(g, (5, 6)) + add_edge!(g, (5, 9)) + + matching = hopcroft_karp_matching(g) + @test eltype(matching) === Pair{UInt8,UInt8} + @test length(matching) == 10 + for i in 1:10 + @test i in keys(matching) + @test matching[i] in neighbors(g, i) + end +end + +function test_imperfect_matching() + g = Graph() + add_vertices!(g, 16) + add_edge!(g, (1, 9)) + add_edge!(g, (2, 9)) + add_edge!(g, (3, 9)) + add_edge!(g, (1, 10)) + add_edge!(g, (4, 10)) + add_edge!(g, (2, 11)) + add_edge!(g, (4, 11)) + add_edge!(g, (3, 12)) + add_edge!(g, (4, 12)) + add_edge!(g, (1, 13)) + add_edge!(g, (2, 13)) + add_edge!(g, (3, 13)) + add_edge!(g, (4, 13)) + add_edge!(g, (1, 14)) + add_edge!(g, (5, 14)) + add_edge!(g, (8, 14)) + add_edge!(g, (2, 15)) + add_edge!(g, (6, 15)) + add_edge!(g, (8, 15)) + add_edge!(g, (3, 16)) + add_edge!(g, (7, 16)) + add_edge!(g, (8, 16)) + + matching = maximum_cardinality_matching(g) + @test length(matching) == 14 + possibly_unmatched_1 = Set([5, 6, 7, 8]) + possibly_unmatched_2 = Set([9, 10, 11, 12, 13]) + + for i in 1:16 + if i in keys(matching) + # Sanity check + @test matching[i] in neighbors(g, i) + elseif i <= 8 + # Make sure the unmatched vertices match what we predict. + # (Possibly unmatched vertices are computed via the + # Dulmage-Mendelsohn decomposition.) + @test i in possibly_unmatched_1 + else + @test i in possibly_unmatched_2 + end + end +end + +function test_complete_bipartite() + g = complete_bipartite_graph(10, 20) + matching = maximum_cardinality_matching(g) + @test length(matching) == 20 + for i in 1:10 + @test i in keys(matching) + @test matching[i] > 10 + end +end + +function test_not_bipartite() + g = complete_graph(5) + @test_throws(ArgumentError, maximum_cardinality_matching(g)) +end + +function test_empty() + g = Graph() + matching = maximum_cardinality_matching(g) + @test length(matching) == 0 +end + +function test_disconnected() + g = Graph() + add_vertices!(g, 5) + add_edge!(g, (1, 3)) + add_edge!(g, (2, 4)) + add_edge!(g, (2, 5)) + matching = maximum_cardinality_matching(g) + @test length(matching) == 4 + @test matching[1] == 3 && matching[3] == 1 + @test matching[2] == 4 || matching[2] == 5 +end + +@testset "Maximum cardinality matching" begin + test_simple_example() + test_simple_example_algorithm_argument() + test_simple_example_hopcroft_karp() + test_simple_example_different_node_type() + + # NOTE: Right now there is only one algorithm to test. When we add more, + # we should loop over the algorithms to run these tests for each. + test_imperfect_matching() + test_complete_bipartite() + test_not_bipartite() + test_empty() + test_disconnected() +end diff --git a/test/runtests.jl b/test/runtests.jl index cbb8763bb..66c6776e1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -151,6 +151,7 @@ tests = [ "vertexcover/random_vertex_cover", "trees/prufer", "experimental/experimental", + "matching/maximum_matching", ] @testset verbose = true "Graphs" begin