# The code serves as simple and far from optimized presentation
# of undirected graph isomorphism problem in PAL course
# https://cw.fel.cvut.cz/wiki/courses/b4m33pal



# Find Isomorphisms:

     # _____A. precomputing phase
     # In both graphs:
     #    calculate properties of all nodes
     #    group nodes into subsets of nodes with identical properties
     # These groups and properties of nodes in them must be identical in both graphs
     # when node IDs are neglected. Otherwise, no isomorphism exists.

     # Computationally, sort the list, in which the property of one node is one list item.
     # These sorted lists must be identical in both graphs.


     # _____B. Recursive backtrack
     # Recursively try to match each node x in graph g1 to a node y in G2.
     # Node y is taken from the group with same characteristics  as node x.
     # each level of recursion corresponds to one pair matched x-y.
     # If node x cannot be matched to any y in G2, return to previous recursion level.

     # Node x in G1 can be matched to node y in G2 iff
     # already matched neighbours of x are matched exactly
     # to already matched neighbours of y.



# ------------------------------------------------------------------------------------------------------
#          G R A P H    C L A S S
# ------------------------------------------------------------------------------------------------------

# the Graph class does not use/define a separate class/object for nodes or  edges.
# Each node is identified by its number. In a graph with N nodes, the nodes are
# identified by integers 0,1,..., N-1.
#  edges are stored in usual list of list structure,
#  one list item is neighbours[x], which is a list containing all neighbours of x.

class Graph:
    def __init__( self, n ):
        self.N = n                                   # number of nodes
        self.neighbours = [[] for _ in range( n )]   # list of lists of node neighbours
        # used in isomorphism check primarily:
        self.nodeProps =  [[] for _ in range( n )]   # list of lists of node properties
        self.isomorphisms = []                       # list of bijections, each bijection is registered as
                                                     # a list:    [ f(x) for x in graph nodes ]

    def addEdge( self, node1, node2 ):
        self.neighbours[node1].append(node2)
        self.neighbours[node2].append(node1)

    def printg( self ):
        for node in range( self.N ):
            print( node, '_', self.neighbours[node] )

    # used for isomorphism checking
    # the more properties of a node are calculated,
    # the bigger is the chance to find isomorphism effectively
    def calculateNodeProperties( self ):
        for node in range( self.N ):
            # register node degree
            self.nodeProps[node].append( len(self.neighbours[node] ) )
            # append degrees of neighbours, in ascending order of degrees
            neighDegrees = [ len(self.neighbours[neigh]) for neigh in self.neighbours[node] ]
            self.nodeProps[node].extend( sorted(neighDegrees) )
            # feel free to extend the list of properties even more
            # for example, how many triangles in the nofde part of,
            # distance to the most distant node, number of most distant nodes, etc, etc, ad lib.

# ------------------------------------------------------------------------------------------------------
# End of Graph class



# technical help:
# create graph, given a string containing list of edges
# in a plainest format "node1 node2 node3 node4 ... "
# edges are node1--node2, node3--node4, etc.
# nodes are either ints or small letters a,b,c,... max 26 of them
def makeGraph( stringListOfEdges ):
    if  any(char.isdigit() for char in stringListOfEdges) == False: # alphabetical input?
        nodes = [(ord(s)-97) for s in stringListOfEdges.split() ]   # space as separator expected
    else: # numerical input
        nodes = [int(s) for s in stringListOfEdges.split() ]        # space as separator expected
    
    Nnodes = max(nodes) + 1                                         # 0-based labels of nodes
    g = Graph( Nnodes )
    for i in range( 0, len(nodes), 2 ):
        g.addEdge( nodes[i], nodes[i+1] )
    return g



# ------------------------------------------------------------------------------------------------------
#          I S O M O R P H I S M   F I N D I N G
# ------------------------------------------------------------------------------------------------------

UNMATCHED = -1       # unmatched nodes gradually turn to matched as recursive search progresses

def propertiesIntoSubsets( propsAndNodes ):
    # each item in the list propsAndNodes is in form
    #        [list_of_node_properties, node ]
    # here we represent each susbset as a separate list of nodes

    # the first node belongs to the first subset,
    listOfNodeSubsets = [ [propsAndNodes[0][1]] ]

    # each next node belongs either to the current subset or to the new subset
    # depending if its props are the same od different form the previous node props.
    for iItem in range( 1, len(propsAndNodes) ):
        if propsAndNodes[iItem-1][0] == propsAndNodes[iItem][0]:      # same node props
              listOfNodeSubsets[-1].append( propsAndNodes[iItem][1] ) # append node to subset
        else: listOfNodeSubsets.append( [propsAndNodes[iItem][1]] )   # new subset with the node

    return listOfNodeSubsets



def canMatchTwoNodes( g1, g2, nodeIn_g1, nodeIn_g2, mapg1g2, mapg2g1 ):
    if mapg1g2[nodeIn_g1] != UNMATCHED or mapg2g1[nodeIn_g2] != UNMATCHED: return False

    # the set of images of matched neighbours of  nodeIn_g1
    # must be the same as set of matched neighbours of nodeIn_g2

    # can be implemented more effectiveley, here code simplicity is preferred
    MapsOfMatchedNeightsOfNode1 = set()
    for neighIn_g1 in g1.neighbours[nodeIn_g1]:
        if mapg1g2[neighIn_g1] != UNMATCHED:
           MapsOfMatchedNeightsOfNode1.add( mapg1g2[neighIn_g1] )

    matchedNeightsOfNode2 =set()
    for neighIn_g2 in g2.neighbours[nodeIn_g2]:
        if mapg2g1[neighIn_g2] != UNMATCHED:
           matchedNeightsOfNode2.add( neighIn_g2 )
    # the two sets must be equal:
    return ( MapsOfMatchedNeightsOfNode1 == matchedNeightsOfNode2 )



def recurFindIso( g1NodeSubsets, g2NodeSubsets, i_subset, j_node, mapg1g2, mapg2g1, listOfIso ):
    # processing node with index j_node in subset with index i_subset

    if j_node >= len( g1NodeSubsets[i_subset] ):
       j_node = 0; i_subset += 1

    # all subsets successfully matched?    # isomorphism found
    if i_subset >= len( g1NodeSubsets ):
       print( "isomorphism:", mapg1g2  )
       listOfIso.append( mapg1g2.copy() )
       return

    # try to match current node in current subset  of g1
    nodeIn_g1 = g1NodeSubsets[i_subset][j_node]
    # try to match currnodeIn_g1 to all yet unmatched nodes in the corresponding subset in g2
    for nodeIn_g2 in g2NodeSubsets[i_subset]:
        if canMatchTwoNodes( g1, g2, nodeIn_g1, nodeIn_g2, mapg1g2, mapg2g1 ):
           # register the match and recurse
           mapg1g2[nodeIn_g1] = nodeIn_g2
           mapg2g1[nodeIn_g2] = nodeIn_g1
           recurFindIso( g1NodeSubsets, g2NodeSubsets, i_subset, j_node+1, mapg1g2, mapg2g1, listOfIso )
           # un-register the match
           mapg1g2[nodeIn_g1] = mapg2g1[nodeIn_g2] = UNMATCHED;



def findAllIsomorphisms( g1, g2 ):

    g1.calculateNodeProperties();
    g2.calculateNodeProperties();

    # identify each node property by its node  and sort the properties
    g1props = [ [g1.nodeProps[node], node ] for node in range( g1.N ) ]
    g2props = [ [g2.nodeProps[node], node ] for node in range( g2.N ) ]
    g1props.sort(); g2props.sort() ;

    # sorted sequence of node properties must be the same in both graphs
    for i in range( g1.N ):
        if g1props[i][0] != g2props[i][0]: return []  # no isomorphism

    # turn the sorted sequence of node properties into node subsets
    # all nodes in a subset share same properties,
    # !! the properties themselves are completely ignored subsequently
    g1NodeSubsets = propertiesIntoSubsets( g1props )
    g2NodeSubsets = propertiesIntoSubsets( g2props )

    # for visual verification
    print( "node Subsets of g1", g1NodeSubsets )
    print( "node Subsets of g2", g2NodeSubsets )

    # search for isomorphisms recursively,
    # isomorphism is a bijection, a mapping from g1 nodes to g2 nodes
    # (and vice versa), here it is stored in list
    # init
    mapg1g2 = [UNMATCHED] * g1.N
    mapg2g1 = [UNMATCHED] * g1.N
    i_subset = 0; j_node = 0;
    listOfIso = [] # registers all isomorphisms
    # recursion
    recurFindIso( g1NodeSubsets, g2NodeSubsets, i_subset, j_node, mapg1g2, mapg2g1, listOfIso )

    return listOfIso

# ------------------------------------------------------------------------------------------------------
#          M A I N    --     E X P E R I M E N T A T I O N   A R E A
# ------------------------------------------------------------------------------------------------------

'''
# two presentations of K_3,3:
g1 = makeGraph( "0 1  1 5  5 4  4 0  0 2  1 3  5 2  4 3  2 3" )  # rectangle with 0 1 5 4 on the perimeter
g1.printg()
print()
g2 = makeGraph( "0 1  1 2  2 3  3 4  4 5  5 0  0 3  1 4  2 5" )  # Moebius ladder M6, 0-5 on the perimeter
g2.printg()
print()


# two presentations of triangle with attached small tree:
g1 = makeGraph( "0 1  1 2  2 0  2 3  2 4  4 5  4 6  4 7" )
g1.printg()
print()
g2 = makeGraph( "0 3  1 3  2 3  3 4  5 4  4 7  7 6  6 4" )
g2.printg()
print()
'''

# lecture Slides Example
g1 = makeGraph( "0 8  8 9  8 5  5 7  5 6  5 3  5 2  5 4  7 6  7 3  6 3  2 3  2 1  1 3  1 4  3 4" )
g1.printg()
print()
g2 = makeGraph( "j h  i h  h f  f b  f d  f e  f g  f c  b e  b c  c e  e g  g a  a d  d e  a e" )
g2.printg()
print()


allIsomorphisms = findAllIsomorphisms( g1, g2 )

# report results
for iso in allIsomorphisms:
    print( '  '.join([ str(i)+'~~'+str(iso[i]) for i in range(len(iso)) ]) )
print( "number of isomorphisms:", len(allIsomorphisms)  )

# use for chracter node names:
for iso in allIsomorphisms:
    print( '  '.join([ str(i)+'~~'+chr(iso[i]+97) for i in range(len(iso)) ]) )
print( "number of isomorphisms:", len(allIsomorphisms)  )



