/* * Copyright (c) 2011 Google, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.common.truth; import static com.google.common.truth.GraphMatching.maximumCardinalityBipartiteMatching; import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.Assert.fail; import com.google.common.base.Preconditions; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.ImmutableBiMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.ListMultimap; import java.util.ArrayDeque; import java.util.BitSet; import java.util.Deque; import java.util.Map; import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; /** * Tests for {@link GraphMatching}. * * @author Pete Gillin */ @RunWith(JUnit4.class) public final class GraphMatchingTest { @Test public void maximumCardinalityBipartiteMatching_empty() { TestInstance.empty().testAgainstKnownSize(0); } @Test public void maximumCardinalityBipartiteMatching_exhaustive4x4() { for (int edgeCombination = 1; edgeCombination < (1L << (4 * 4)); edgeCombination++) { TestInstance.fromBits(4, 4, intBits(edgeCombination)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_exhaustive3x5() { for (int edgeCombination = 1; edgeCombination < (1L << (3 * 5)); edgeCombination++) { TestInstance.fromBits(3, 5, intBits(edgeCombination)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_exhaustive5x3() { for (int edgeCombination = 1; edgeCombination < (1L << (5 * 3)); edgeCombination++) { TestInstance.fromBits(5, 3, intBits(edgeCombination)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_fullyConnected8x8() { TestInstance.fullyConnected(8, 8).testAgainstKnownSize(8); } @Test public void maximumCardinalityBipartiteMatching_random8x8() { Random rng = new Random(0x5ca1ab1e); for (int i = 0; i < 100; i++) { // Set each bit with probability 0.25, giving an average of 2 of the possible 8 edges per // vertex. By observation, the maximal matching most commonly has cardinality 6 (although // occasionally you do see a complete matching i.e. cardinality 8). TestInstance.fromBits(8, 8, randomBits(8 * 8, 0.25, rng)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_randomSparse8x8() { Random rng = new Random(0x0ddba11); for (int i = 0; i < 100; i++) { // Set each bit with probability 0.125, giving an average of 1 of the possible 8 edges per // vertex. By observation, the maximal matching most commonly has cardinality 4. TestInstance.fromBits(8, 8, randomBits(8 * 8, 0.125, rng)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_randomDense8x8() { Random rng = new Random(0x5add1e5); for (int i = 0; i < 100; i++) { // Set each bit with probability 0.5, giving an average of 4 of the possible 8 edges per // vertex. By observation, a complete matching is almost always possible (although // occasionally you do see a maximum cardinality of 7 or even fewer). TestInstance.fromBits(8, 8, randomBits(8 * 8, 0.5, rng)).testAgainstBruteForce(); } } @Test public void maximumCardinalityBipartiteMatching_failsWithNullLhs() { ListMultimap<String, String> edges = LinkedListMultimap.create(); edges.put(null, "R1"); try { BiMap<String, String> unused = maximumCardinalityBipartiteMatching(edges); fail("Should have thrown."); } catch (NullPointerException expected) { } } @Test public void maximumCardinalityBipartiteMatching_failsWithNullRhs() { ListMultimap<String, String> edges = LinkedListMultimap.create(); edges.put("L1", null); try { BiMap<String, String> unused = maximumCardinalityBipartiteMatching(edges); fail("Should have thrown."); } catch (NullPointerException expected) { } } /** Representation of a bipartite graph to be used for testing. */ private static class TestInstance { /** Generates a test instance with an empty bipartite graph. */ static TestInstance empty() { return new TestInstance(ImmutableListMultimap.<String, String>of()); } /** * Generates a test instance with a fully-connected bipartite graph where there are {@code * lhsSize} elements in one set of vertices (which we call the LHS) and {@code rhsSize} elements * in the other (the RHS). */ static TestInstance fullyConnected(int lhsSize, int rhsSize) { ImmutableListMultimap.Builder<String, String> edges = ImmutableListMultimap.builder(); for (int lhs = 0; lhs < lhsSize; lhs++) { for (int rhs = 0; rhs < rhsSize; rhs++) { edges.put("L" + lhs, "R" + rhs); } } return new TestInstance(edges.build()); } /** * Generates a test instance with a bipartite graph where there are {@code lhsSize} elements in * one set of vertices (which we call the LHS) and {@code rhsSize} elements in the other (the * RHS) and whether or not each of the {@code lhsSize * rhsSize} possible edges is included or * not according to whether one of the first {@code lhsSize * rhsSize} bits of {@code bits} is * set or not. */ static TestInstance fromBits(int lhsSize, int rhsSize, BitSet bits) { ImmutableListMultimap.Builder<String, String> edges = ImmutableListMultimap.builder(); for (int lhs = 0; lhs < lhsSize; lhs++) { for (int rhs = 0; rhs < rhsSize; rhs++) { if (bits.get(lhs * rhsSize + rhs)) { edges.put("L" + lhs, "R" + rhs); } } } return new TestInstance(edges.build()); } private final ImmutableListMultimap<String, String> edges; private final ImmutableList<String> lhsVertices; private TestInstance(ImmutableListMultimap<String, String> edges) { this.edges = edges; this.lhsVertices = edges.keySet().asList(); } /** * Finds the maximum bipartite matching using the method under test and asserts both that it is * actually a matching of this bipartite graph and that it has the same size as a maximum * bipartite matching found by a brute-force approach. */ void testAgainstBruteForce() { ImmutableBiMap<String, String> actual = maximumCardinalityBipartiteMatching(edges); for (Map.Entry<String, String> entry : actual.entrySet()) { assertWithMessage( "The returned bimap <%s> was not a matching of the bipartite graph <%s>", actual, edges) .that(edges) .containsEntry(entry.getKey(), entry.getValue()); } ImmutableBiMap<String, String> expected = bruteForceMaximalMatching(); assertWithMessage( "The returned matching for the bipartite graph <%s> was not the same size as " + "the brute-force maximal matching <%s>", edges, expected) .that(actual) .hasSize(expected.size()); } /** * Finds the maximum bipartite matching using the method under test and asserts both that it is * actually a matching of this bipartite graph and that it has the expected size. */ void testAgainstKnownSize(int expectedSize) { ImmutableBiMap<String, String> actual = maximumCardinalityBipartiteMatching(edges); for (Map.Entry<String, String> entry : actual.entrySet()) { assertWithMessage( "The returned bimap <%s> was not a matching of the bipartite graph <%s>", actual, edges) .that(edges) .containsEntry(entry.getKey(), entry.getValue()); } assertWithMessage( "The returned matching for the bipartite graph <%s> had the wrong size", edges) .that(actual) .hasSize(expectedSize); } /** * Returns a maximal bipartite matching of the bipartite graph, performing a brute force * evaluation of every possible matching. */ private ImmutableBiMap<String, String> bruteForceMaximalMatching() { ImmutableBiMap<String, String> best = ImmutableBiMap.of(); Matching candidate = new Matching(); while (candidate.valid()) { if (candidate.size() > best.size()) { best = candidate.asBiMap(); } candidate.advance(); } return best; } /** * Mutable representation of a non-empty matching over the graph. This is a cursor which can be * advanced through the possible matchings in a fixed sequence. When advanced past the last * matching in the sequence, this cursor is considered invalid. */ private class Matching { private final Deque<Edge> edgeStack; private final BiMap<String, String> selectedEdges; /** Constructs the first non-empty matching in the sequence. */ Matching() { this.edgeStack = new ArrayDeque<Edge>(); this.selectedEdges = HashBiMap.create(); if (!edges.isEmpty()) { Edge firstEdge = new Edge(); edgeStack.addLast(firstEdge); firstEdge.addToSelected(); } } /** * Returns whether this cursor is valid. Returns true if it has been advanced past the end of * the sequence. */ boolean valid() { // When advance() has advanced through all the non-empty maps, the final state is that // selectedEdges is empty, so we use that state as a marker of the final invalid cursor. return !selectedEdges.isEmpty(); } /** * Returns an immutable representation of the current state of the matching as a bimap giving * the edges used in the matching, where the keys identify the vertices in the first set and * the values identify the vertices in the second set. The bimap is guaranteed not to be * empty. Fails if this cursor is invalid. */ ImmutableBiMap<String, String> asBiMap() { Preconditions.checkState(valid()); return ImmutableBiMap.copyOf(selectedEdges); } /** * Returns the size (i.e. the number of edges in) the current matching, which is guaranteed to * be positive (not zer). Fails if this cursor is invalid. */ int size() { Preconditions.checkState(valid()); return selectedEdges.size(); } /** * Advances to the next matching in the sequence, or invalidates the cursor if this was the * last. Fails if this cursor is invalid. */ void advance() { Preconditions.checkState(valid()); // We essentially do a depth-first traversal through the possible matchings. // First we try to add an edge. Edge lastEdge = edgeStack.getLast(); Edge nextEdge = new Edge(lastEdge); nextEdge.advance(); if (nextEdge.valid()) { edgeStack.addLast(nextEdge); nextEdge.addToSelected(); return; } // We can't add an edge, so we try to advance the edge at the top of the stack. If we can't // advance that edge, we remove it and attempt to advance the new top of stack instead. while (valid()) { lastEdge = edgeStack.getLast(); lastEdge.removeFromSelected(); lastEdge.advance(); if (lastEdge.valid()) { lastEdge.addToSelected(); return; } else { edgeStack.removeLast(); } } // We have reached the end of the sequence, and edgeStack is empty. } /** * Mutable representation of an edge in a matching. This is a cursor which can be advanced * through the possible edges in a fixed sequence. When advanced past the last edge in the * sequence, this cursor is considered invalid. */ private class Edge { private int lhsIndex; // index into lhsVertices private int rhsIndexForLhs; // index into edges.get(lhsVertices.get(lhsIndex)) /** Constructs the first edge in the sequence. */ Edge() { this.lhsIndex = 0; this.rhsIndexForLhs = 0; } /** Constructs a copy of the given edge. */ Edge(Edge other) { this.lhsIndex = other.lhsIndex; this.rhsIndexForLhs = other.rhsIndexForLhs; } /** * Returns whether this cursor is valid. Returns true if it has been advanced past the end * of the sequence. */ boolean valid() { // When advance() has advanced through all the edges, the final state is that lhsIndex == // lhsVertices.size(), so we use that state as a marker of the final invalid cursor. return lhsIndex < lhsVertices.size(); } /** * Adds the current edge to the matching. Fails if either of the vertices in the edge is * already in the matching. Fails if this cursor is invalid. */ void addToSelected() { Preconditions.checkState(valid()); Preconditions.checkState(!selectedEdges.containsKey(lhsVertex())); Preconditions.checkState(!selectedEdges.containsValue(rhsVertex())); selectedEdges.put(lhsVertex(), rhsVertex()); } /** * Removes the current edge from the matching. Fails if this edge is not in the matching. * Fails if this cursor is invalid. */ void removeFromSelected() { Preconditions.checkState(valid()); Preconditions.checkState(selectedEdges.containsKey(lhsVertex())); Preconditions.checkState(selectedEdges.get(lhsVertex()).equals(rhsVertex())); selectedEdges.remove(lhsVertex()); } /** * Advances to the next edge in the sequence, or invalidates the cursor if this was the * last. Skips over edges which cannot be added to the matching because either vertex is * already in it. Fails if this cursor is invalid. */ void advance() { Preconditions.checkState(valid()); // We iterate over the possible edges in a lexicographical order with the LHS index as the // most significant part and the RHS index as the least significant. So we first try // advancing to the next RHS index for the current LHS index, and if we can't we advance // to the next LHS index in the map and the first RHS index for that. ++rhsIndexForLhs; while (lhsIndex < lhsVertices.size()) { if (!selectedEdges.containsKey(lhsVertex())) { while (rhsIndexForLhs < edges.get(lhsVertex()).size()) { if (!selectedEdges.containsValue(rhsVertex())) { return; } ++rhsIndexForLhs; } } ++lhsIndex; rhsIndexForLhs = 0; } // We have reached the end of the sequence, and lhsIndex == lhsVertices.size(). } private String lhsVertex() { return lhsVertices.get(lhsIndex); } private String rhsVertex() { return edges.get(lhsVertex()).get(rhsIndexForLhs); } } } } /** Returns a bitset corresponding to the binary representation of the given integer. */ private static BitSet intBits(int intValue) { BitSet bits = new BitSet(); for (int bitIndex = 0; bitIndex < Integer.SIZE; bitIndex++) { bits.set(bitIndex, (intValue & (1L << bitIndex)) != 0); } return bits; } /** * Returns a bitset of up to {@code maxBits} bits where each bit is set with a probability {@code * bitProbability} using the given RNG. */ private static BitSet randomBits(int maxBits, double bitProbability, Random rng) { BitSet bits = new BitSet(); for (int bitIndex = 0; bitIndex < maxBits; bitIndex++) { bits.set(bitIndex, rng.nextDouble() < bitProbability); } return bits; } }