|
| 1 | +package gvisual; |
| 2 | + |
| 3 | +import edu.uci.ics.jung.graph.Graph; |
| 4 | + |
| 5 | +import java.util.*; |
| 6 | + |
| 7 | +/** |
| 8 | + * Graph Isomorphism Checker — determines whether two graphs have the |
| 9 | + * same structure (are isomorphic), ignoring vertex labels. |
| 10 | + * |
| 11 | + * <p>Two graphs G1 and G2 are <b>isomorphic</b> if there exists a |
| 12 | + * bijection f: V(G1) → V(G2) such that (u, v) is an edge in G1 |
| 13 | + * if and only if (f(u), f(v)) is an edge in G2.</p> |
| 14 | + * |
| 15 | + * <p>Uses a multi-stage approach:</p> |
| 16 | + * <ol> |
| 17 | + * <li><b>Fast rejection:</b> vertex count, edge count, degree sequence</li> |
| 18 | + * <li><b>Degree-based partitioning:</b> vertices grouped by degree</li> |
| 19 | + * <li><b>Backtracking search:</b> VF2-style matching with feasibility pruning</li> |
| 20 | + * </ol> |
| 21 | + * |
| 22 | + * <p>Time complexity: O(V!) worst case, but fast-rejection filters and |
| 23 | + * degree-based pruning make it practical for most real-world graphs.</p> |
| 24 | + * |
| 25 | + * @author zalenix |
| 26 | + */ |
| 27 | +public class GraphIsomorphismAnalyzer { |
| 28 | + |
| 29 | + private final Graph<String, edge> graph1; |
| 30 | + private final Graph<String, edge> graph2; |
| 31 | + |
| 32 | + /** |
| 33 | + * Create a new isomorphism analyzer for two graphs. |
| 34 | + * |
| 35 | + * @param graph1 the first graph (must not be null) |
| 36 | + * @param graph2 the second graph (must not be null) |
| 37 | + * @throws IllegalArgumentException if either graph is null |
| 38 | + */ |
| 39 | + public GraphIsomorphismAnalyzer(Graph<String, edge> graph1, |
| 40 | + Graph<String, edge> graph2) { |
| 41 | + if (graph1 == null || graph2 == null) { |
| 42 | + throw new IllegalArgumentException("Both graphs must not be null"); |
| 43 | + } |
| 44 | + this.graph1 = graph1; |
| 45 | + this.graph2 = graph2; |
| 46 | + } |
| 47 | + |
| 48 | + // ── Result class ──────────────────────────────────────────── |
| 49 | + |
| 50 | + /** |
| 51 | + * Result of an isomorphism check. |
| 52 | + */ |
| 53 | + public static class IsomorphismResult { |
| 54 | + private final boolean isomorphic; |
| 55 | + private final Map<String, String> mapping; |
| 56 | + private final String rejectionReason; |
| 57 | + private final List<Integer> degreeSequence1; |
| 58 | + private final List<Integer> degreeSequence2; |
| 59 | + |
| 60 | + IsomorphismResult(boolean isomorphic, Map<String, String> mapping, |
| 61 | + String rejectionReason, |
| 62 | + List<Integer> degreeSequence1, |
| 63 | + List<Integer> degreeSequence2) { |
| 64 | + this.isomorphic = isomorphic; |
| 65 | + this.mapping = mapping != null |
| 66 | + ? Collections.unmodifiableMap(new LinkedHashMap<String, String>(mapping)) |
| 67 | + : Collections.<String, String>emptyMap(); |
| 68 | + this.rejectionReason = rejectionReason; |
| 69 | + this.degreeSequence1 = degreeSequence1 != null |
| 70 | + ? Collections.unmodifiableList(new ArrayList<Integer>(degreeSequence1)) |
| 71 | + : Collections.<Integer>emptyList(); |
| 72 | + this.degreeSequence2 = degreeSequence2 != null |
| 73 | + ? Collections.unmodifiableList(new ArrayList<Integer>(degreeSequence2)) |
| 74 | + : Collections.<Integer>emptyList(); |
| 75 | + } |
| 76 | + |
| 77 | + /** Whether the two graphs are isomorphic. */ |
| 78 | + public boolean isIsomorphic() { return isomorphic; } |
| 79 | + |
| 80 | + /** |
| 81 | + * Vertex mapping from graph1 to graph2 (if isomorphic). |
| 82 | + * Empty map if not isomorphic. |
| 83 | + */ |
| 84 | + public Map<String, String> getMapping() { return mapping; } |
| 85 | + |
| 86 | + /** Human-readable reason for rejection (null if isomorphic). */ |
| 87 | + public String getRejectionReason() { return rejectionReason; } |
| 88 | + |
| 89 | + /** Sorted degree sequence of graph1. */ |
| 90 | + public List<Integer> getDegreeSequence1() { return degreeSequence1; } |
| 91 | + |
| 92 | + /** Sorted degree sequence of graph2. */ |
| 93 | + public List<Integer> getDegreeSequence2() { return degreeSequence2; } |
| 94 | + |
| 95 | + @Override |
| 96 | + public String toString() { |
| 97 | + if (isomorphic) { |
| 98 | + return "Isomorphic (mapping: " + mapping + ")"; |
| 99 | + } |
| 100 | + return "Not isomorphic" + |
| 101 | + (rejectionReason != null ? " (" + rejectionReason + ")" : ""); |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + // ── Public API ────────────────────────────────────────────── |
| 106 | + |
| 107 | + /** |
| 108 | + * Check whether the two graphs are isomorphic. |
| 109 | + * |
| 110 | + * @return an {@link IsomorphismResult} with the verdict, mapping |
| 111 | + * (if isomorphic), and degree sequences |
| 112 | + */ |
| 113 | + public IsomorphismResult analyze() { |
| 114 | + List<String> vertices1 = new ArrayList<String>(graph1.getVertices()); |
| 115 | + List<String> vertices2 = new ArrayList<String>(graph2.getVertices()); |
| 116 | + |
| 117 | + List<Integer> degSeq1 = getSortedDegreeSequence(graph1, vertices1); |
| 118 | + List<Integer> degSeq2 = getSortedDegreeSequence(graph2, vertices2); |
| 119 | + |
| 120 | + // Fast rejection: vertex count |
| 121 | + if (vertices1.size() != vertices2.size()) { |
| 122 | + return new IsomorphismResult(false, null, |
| 123 | + "Different vertex counts (" + vertices1.size() + |
| 124 | + " vs " + vertices2.size() + ")", |
| 125 | + degSeq1, degSeq2); |
| 126 | + } |
| 127 | + |
| 128 | + // Fast rejection: edge count |
| 129 | + int edgeCount1 = graph1.getEdgeCount(); |
| 130 | + int edgeCount2 = graph2.getEdgeCount(); |
| 131 | + if (edgeCount1 != edgeCount2) { |
| 132 | + return new IsomorphismResult(false, null, |
| 133 | + "Different edge counts (" + edgeCount1 + |
| 134 | + " vs " + edgeCount2 + ")", |
| 135 | + degSeq1, degSeq2); |
| 136 | + } |
| 137 | + |
| 138 | + // Fast rejection: degree sequence |
| 139 | + if (!degSeq1.equals(degSeq2)) { |
| 140 | + return new IsomorphismResult(false, null, |
| 141 | + "Different degree sequences", |
| 142 | + degSeq1, degSeq2); |
| 143 | + } |
| 144 | + |
| 145 | + // Empty graphs are trivially isomorphic |
| 146 | + if (vertices1.isEmpty()) { |
| 147 | + return new IsomorphismResult(true, |
| 148 | + Collections.<String, String>emptyMap(), null, |
| 149 | + degSeq1, degSeq2); |
| 150 | + } |
| 151 | + |
| 152 | + // Build adjacency sets for fast lookup |
| 153 | + Map<String, Set<String>> adj1 = buildAdjacencyMap(graph1); |
| 154 | + Map<String, Set<String>> adj2 = buildAdjacencyMap(graph2); |
| 155 | + |
| 156 | + // Group vertices by degree for pruning |
| 157 | + Map<Integer, List<String>> byDegree1 = groupByDegree(graph1, vertices1); |
| 158 | + Map<Integer, List<String>> byDegree2 = groupByDegree(graph2, vertices2); |
| 159 | + |
| 160 | + // Order vertices1 by degree (ascending) for better pruning |
| 161 | + Collections.sort(vertices1, new Comparator<String>() { |
| 162 | + @Override |
| 163 | + public int compare(String a, String b) { |
| 164 | + return Integer.compare(graph1.degree(a), graph1.degree(b)); |
| 165 | + } |
| 166 | + }); |
| 167 | + |
| 168 | + // Backtracking search |
| 169 | + Map<String, String> mapping = new LinkedHashMap<String, String>(); |
| 170 | + Set<String> used2 = new HashSet<String>(); |
| 171 | + |
| 172 | + if (backtrack(vertices1, 0, mapping, used2, adj1, adj2, byDegree2)) { |
| 173 | + return new IsomorphismResult(true, mapping, null, |
| 174 | + degSeq1, degSeq2); |
| 175 | + } |
| 176 | + |
| 177 | + return new IsomorphismResult(false, null, |
| 178 | + "No valid mapping found (structural mismatch)", |
| 179 | + degSeq1, degSeq2); |
| 180 | + } |
| 181 | + |
| 182 | + /** |
| 183 | + * Quick check — just returns true/false without computing the full |
| 184 | + * mapping details. Uses the same algorithm but avoids allocating the |
| 185 | + * result object for performance-sensitive code paths. |
| 186 | + * |
| 187 | + * @return true if the graphs are isomorphic |
| 188 | + */ |
| 189 | + public boolean areIsomorphic() { |
| 190 | + return analyze().isIsomorphic(); |
| 191 | + } |
| 192 | + |
| 193 | + // ── Private helpers ───────────────────────────────────────── |
| 194 | + |
| 195 | + /** |
| 196 | + * Compute the sorted degree sequence of a graph. |
| 197 | + */ |
| 198 | + private List<Integer> getSortedDegreeSequence(Graph<String, edge> g, |
| 199 | + List<String> vertices) { |
| 200 | + List<Integer> degrees = new ArrayList<Integer>(vertices.size()); |
| 201 | + for (String v : vertices) { |
| 202 | + degrees.add(g.degree(v)); |
| 203 | + } |
| 204 | + Collections.sort(degrees); |
| 205 | + return degrees; |
| 206 | + } |
| 207 | + |
| 208 | + /** |
| 209 | + * Build adjacency map: vertex → set of neighbors. |
| 210 | + */ |
| 211 | + private Map<String, Set<String>> buildAdjacencyMap(Graph<String, edge> g) { |
| 212 | + Map<String, Set<String>> adj = new HashMap<String, Set<String>>(); |
| 213 | + for (String v : g.getVertices()) { |
| 214 | + adj.put(v, new HashSet<String>(g.getNeighbors(v))); |
| 215 | + } |
| 216 | + return adj; |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Group vertices by their degree. |
| 221 | + */ |
| 222 | + private Map<Integer, List<String>> groupByDegree(Graph<String, edge> g, |
| 223 | + List<String> vertices) { |
| 224 | + Map<Integer, List<String>> groups = |
| 225 | + new HashMap<Integer, List<String>>(); |
| 226 | + for (String v : vertices) { |
| 227 | + int deg = g.degree(v); |
| 228 | + List<String> list = groups.get(deg); |
| 229 | + if (list == null) { |
| 230 | + list = new ArrayList<String>(); |
| 231 | + groups.put(deg, list); |
| 232 | + } |
| 233 | + list.add(v); |
| 234 | + } |
| 235 | + return groups; |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Backtracking search with degree-based candidate filtering. |
| 240 | + * |
| 241 | + * For each vertex in graph1 (in order), try mapping it to each |
| 242 | + * candidate vertex in graph2 that has the same degree and hasn't |
| 243 | + * been used yet. Check feasibility (all already-mapped neighbors |
| 244 | + * must correspond) before recursing. |
| 245 | + */ |
| 246 | + private boolean backtrack(List<String> vertices1, int idx, |
| 247 | + Map<String, String> mapping, |
| 248 | + Set<String> used2, |
| 249 | + Map<String, Set<String>> adj1, |
| 250 | + Map<String, Set<String>> adj2, |
| 251 | + Map<Integer, List<String>> byDegree2) { |
| 252 | + if (idx == vertices1.size()) { |
| 253 | + return true; // all vertices mapped successfully |
| 254 | + } |
| 255 | + |
| 256 | + String v1 = vertices1.get(idx); |
| 257 | + int deg = graph1.degree(v1); |
| 258 | + List<String> candidates = byDegree2.get(deg); |
| 259 | + if (candidates == null) return false; |
| 260 | + |
| 261 | + for (String v2 : candidates) { |
| 262 | + if (used2.contains(v2)) continue; |
| 263 | + |
| 264 | + // Feasibility check: for every neighbor of v1 that is |
| 265 | + // already mapped, the corresponding mapped vertex must |
| 266 | + // be a neighbor of v2 |
| 267 | + if (isFeasible(v1, v2, mapping, adj1, adj2)) { |
| 268 | + mapping.put(v1, v2); |
| 269 | + used2.add(v2); |
| 270 | + |
| 271 | + if (backtrack(vertices1, idx + 1, mapping, used2, |
| 272 | + adj1, adj2, byDegree2)) { |
| 273 | + return true; |
| 274 | + } |
| 275 | + |
| 276 | + mapping.remove(v1); |
| 277 | + used2.remove(v2); |
| 278 | + } |
| 279 | + } |
| 280 | + return false; |
| 281 | + } |
| 282 | + |
| 283 | + /** |
| 284 | + * Check whether mapping v1→v2 is feasible given the current |
| 285 | + * partial mapping. |
| 286 | + * |
| 287 | + * For every neighbor n1 of v1 that is already in the mapping, |
| 288 | + * the mapped vertex mapping[n1] must be a neighbor of v2. |
| 289 | + * Also, for every neighbor n2 of v2 whose preimage is mapped, |
| 290 | + * the preimage must be a neighbor of v1. |
| 291 | + */ |
| 292 | + private boolean isFeasible(String v1, String v2, |
| 293 | + Map<String, String> mapping, |
| 294 | + Map<String, Set<String>> adj1, |
| 295 | + Map<String, Set<String>> adj2) { |
| 296 | + Set<String> neighbors1 = adj1.get(v1); |
| 297 | + Set<String> neighbors2 = adj2.get(v2); |
| 298 | + |
| 299 | + // Forward check: mapped neighbors of v1 must map to neighbors of v2 |
| 300 | + for (String n1 : neighbors1) { |
| 301 | + String mapped = mapping.get(n1); |
| 302 | + if (mapped != null && !neighbors2.contains(mapped)) { |
| 303 | + return false; |
| 304 | + } |
| 305 | + } |
| 306 | + |
| 307 | + // Reverse check: mapped neighbors of v2 must come from neighbors of v1 |
| 308 | + Map<String, String> reverse = new HashMap<String, String>(); |
| 309 | + for (Map.Entry<String, String> entry : mapping.entrySet()) { |
| 310 | + reverse.put(entry.getValue(), entry.getKey()); |
| 311 | + } |
| 312 | + for (String n2 : neighbors2) { |
| 313 | + String preimage = reverse.get(n2); |
| 314 | + if (preimage != null && !neighbors1.contains(preimage)) { |
| 315 | + return false; |
| 316 | + } |
| 317 | + } |
| 318 | + |
| 319 | + return true; |
| 320 | + } |
| 321 | +} |
0 commit comments