Grafi
Nelle lezioni precedenti abbiamo visto che gli alberi permettono di rappresentare delle relazioni tra elementi di vario tipo. Ad esempio, le relazioni di contenimento tra una directory e i file contenuti in essa. La particolarità delle relazioni in un albero è che non ci possono essere cicli, perché sono relazioni gerarchiche. Una directory non può contenere se stessa, neanche indirettamente (se si escudono i link). Ma ci sono tanti scenari in cui gli elementi possono avere relazioni tra loro che non sono gerarchiche e che possono dar luogo a cicli. Ad esempio, le relazioni tra le città collegate da tratte aeree o le relazioni di amicizia tra le persone in una rete sociale.
Per rappresentare questi scenari più complessi, gli alberi non sono sufficienti e si usa un modello più generale che si chiama grafo. Un grafo è un insieme di elementi che sono tra loro interconnessi da una qualche relazione. Nella terminologia della teoria dei grafi, gli elementi sono chiamati nodi o vertici (lo stesso termine che abbiamo già usato per gli alberi) e le interconnessioni sono chiamate archi o spigoli (in inglese edges). Nel grafo relativo a una rete sociale, se c'è un'amicizia tra due nodi Marco e Andrea diremo che c'è un arco tra il nodo Marco e il nodo Andrea.
Alcune tipi di interconnessioni sono simmetriche come nel caso delle amicizie (almeno quasi sempre) e in altri casi sono asimmetriche come nel caso delle relazioni di contenimento o di collegamenti tramite strade a senso unico.
In questa lezione e nella prossima vedremo come rappresentare i grafi in Python e l'implementazione di alcune operazioni di base come la visita e il calcolo della distanza tra due nodi. Per illustrare ciò useremo dei dati relativi a film e attori che sono scaricabili da questo pacchetto. Questi generano un grafo i cui nodi sono i film e gli attori e un film è connesso a ognuno degli attori del suo cast. Ogni attore è quindi connesso ad ogni film in cui ha recitato.
I Nodi e gli Archi
Come rappresentare i nodi? Ovviamente esistono tanti modi e in effetti sono usati innumerevoli modi per rappresentare i grafi. Se si vgliono associare ai nodi anche delle informazioni, per un film ad esempio il regista, la durata, ecc., conviene rappresentare ogni nodo con un opportuno oggetto. E le connessioni, cioè gli archi, come possiamo rappresentarle? Potremmo semplicemente mantenere una lista di coppie di nodi. Però questa è una soluzione nè efficiente nè conveniente per la leggibilità del codice. Una'altra possibilità è far sì che ogni nodo mantenga la lista dei nodi a cui è connesso. Questo rende molto più agevole, come vedremo, la visita del grafo. Bene allora iniziamo a definire una classe per rappresentare i nodi:
class Node(object):
def __init__(self, name, attr=None):
self.name = name
self.attr = attr
self._adjacent = []
def add_adjacent(self, a):
if a not in self._adjacent:
self._adjacent.append(a)
def adjacent(self):
return self._adjacent[:]
Ogni nodo ha un nome name
che lo identifica univocamente e può avere altre informazioni che saranno mantenute in attr
sotto forma di un dizionario (in modo simile a come abbiamo fatto per gli attributi di un tag dell'HTML). La lista dei nodi connessi è mantenuta in _adjacent
. Nella terminologia dei grafi, due nodi connessi sono anche detti adiacenti. Il fatto che _adjacent
inizia con un underscore sta a indicare che pensiamo che tale attributo non debba essere acceduto dall'esterno della classe. E per questa ragione ci sono infatti i metodi add_adjacent
, che aggiunge un nodo adiacente, e adjacent
che ritorna una copia della lista dei nodi adiacenti.
Ancora però non ci siamo. Proviamo a creare due nodi con lo stesso nome:
>>> ugo = Node('Ugo')
>>> ugo2 = Node('Ugo')
>>> ugo == ugo2
False
Ecco, i due nodi avrebbero dovuto essere uguali ma Python non li riconosce come tali. La ragione è che Python di default confronta gli oggetti di tipi non built-in tramite la loro identità, cioè il loro indirizzo in memoria. Così due oggetti sono uguali solamente se sono esattamente lo stesso oggetto. Per ottenere invece un confronto in base al contenuto è necessario ridefinire alcuni dei metodi speciali (quelli il cui nome inizia e termina con due underscore) che Python usa quando confronta due oggetti. Dobbiamo quindi ridefinire i metodi __eq__
e __ne__
che sono invocati automaticamente per i test dell'uguaglianza ==
e della diseguaglianza !=
, rispettivamente.
class Node(object):
def __init__(self, name, attr=None):
self.name = name
self.attr = attr
self._adjacent = []
def add_adjacent(self, a):
if a not in self._adjacent:
self._adjacent.append(a)
def adjacent(self):
return self._adjacent[:]
def __eq__(self, x):
return x.name == self.name
def __ne__(self, x):
return not (x == self)
E ora infatti:
>>> ugo = Node('Ugo')
>>> ugo2 = Node('Ugo')
>>> ugo == ugo2
True
E questo funziona anche per le liste e affini:
>>> ciro = Node('Ciro')
>>> lst = [ugo, ciro]
>>> ugo2 in lst
True
Ma non funziona per i set
e i dict
:
>>> s = set()
>>> s.add(ugo)
>>> ugo2 in s
False
>>> d = {ugo:17}
>>> ugo2 in d
False
Siccome risulta molto utile, come vedremo, usare le collezioni come gli insiemi e i dizionari per i nodi, vorremmo poterli usare. Dobbiamo ridefinire un altro metodo speciale che si chiama __hash__
. Questo è invocato tutte le volte che si cerca un elemento in una collezione, dato che questa è implementata tramite una struttura dati che si chiama appunto tabella hash.
class Node(object):
def __init__(self, name, attr=None):
self.name = name
self.attr = attr
self._adjacent = []
def add_adjacent(self, a):
if a not in self._adjacent:
self._adjacent.append(a)
def adjacent(self):
return self._adjacent[:]
def __eq__(self, x):
return x.name == self.name
def __ne__(self, x):
return not (x == self)
def __hash__(self):
return self.name.__hash__()
Il metodo ritorna il valore hash relativo all'oggetto che identifica il nodo. Adesso anche le collezioni funzionano correttamente per i nostri nodi:
>>> ugo = Node('Ugo')
>>> ugo2 = Node('Ugo')
>>> s = set()
>>> s.add(ugo)
>>> ugo2 in s
True
>>> d = {ugo:1}
>>> ugo2 in d
True
I Grafi
Quello che occorre adesso è anche una struttura per mantenere tutti i nodi di un grafo. Una lista potrebbe essere inefficiente per grafi molto grandi. Un insieme (o un dizionario) sarebbero molto più efficienti (quando bisogna ricercare un nodo). Però non permetterebbero di gestire operazioni come l'aggiunta di un arco. Quando aggiugiamo un arco (simmetrico) dobbiamo aggiungerlo nelle liste degli adiacenti di entrambi i nodi. Per gestire questa e altre operazioni conviene introdurre una classe ad-hoc:
class Graph(object):
def __init__(self):
self._nodes = []
self._indices = {}
def get_node(self, u):
return self._nodes[self._indices[u]]
def add_node(self, u):
if u not in self._indices:
self._nodes.append(u)
self._indices[u] = len(self._nodes) - 1
return self.get_node(u)
def add_edge(self, u, v):
node_u = self.get_node(u)
node_v = self.get_node(v)
node_v.add_adjacent(self.get_node(u))
node_u.add_adjacent(self.get_node(v))
def nodes(self):
return self._nodes[:]
Un oggetto di tipo Graph
usa _nodes
per mantenere la lista dei nodi del grafo. Poi usa il dizionario _indices
che è una mappa inversa dai nodi verso gli indici e che serve a rendere efficiente l'operazione di ottenere la posizione di un nodo nella lista dato un nodo uguale (cioè, con lo stesso identificativo). Vediamo un esempio
Per creare un grafo come in figura, dobbiamo prima di tutto creare il grafo vuoto e i quattro nodi:
>>> g = Graph()
>>> ciro = Node('Ciro')
>>> marco = Node('Marco')
>>> sara = Node('Sara')
>>> andrea = Node('Andrea')
Poi aggiungiamo i nodi al grafo:
>>> g.add_node(ciro)
>>> g.add_node(marco)
>>> g.add_node(sara)
>>> g.add_node(andrea)
E infine aggiungiamo i tre archi:
>>> g.add_edge(ciro, sara)
>>> g.add_edge(marco, sara)
>>> g.add_edge(andrea, sara)
A adesso il nostro grafo è completo. Se abbiamo solamente il grafo g
e vogliamo conoscere i nodi adiacenti a Sara, come facciamo? Possiamo usare il metodo nodes
che ritorna una copia della lista di tutti i nodi oppure possiamo usare il metodo get_node
. Basta creare un nodo con l'identificativo che ci interessa e ottenere il nodo dal grafo tramite get_node
:
>>> sara = g.get_node(Node('Sara'))
>>> sara.name
'Sara'
>>> for a in sara.adjacent():
print a.name
Ciro
Marco
Andrea
Film e Attori/Attrici
Siamo ora pronti ad affrontare un grafo vero. Vogliamo creare un grafo con i dati contenuti nei files actors.txt
e films.txt
. Il file actors.txt
per ogni attore/attrice ha un blocco di 7 linee con il seguente formato:
NAME;Clark Gable
LASTFIRST;Gable, Clark
REALNAME;Gable, William Clark
NICKNAMES;Gabe;The King;The King of Hollywood
GENDER;M
BIRTH;1 February 1901, Cadiz, Ohio, USA
DIED;16 November 1960, Los Angeles, California, USA
Il file films.txt
ha per ogni film un blocco di 10 linee con il seguente formato:
TITLE;The Amazing Spider-Man;2012
ACTORS;Andrew Garfield;Emma Stone;Rhys Ifans;Denis...
DIRECTORS;Marc Webb
WRITERS;James Vanderbilt;Alvin Sargent
GENRES;Action;Adventure;Fantasy
COUNTRY;USA
LANGUAGE;English
RUNTIME;136 min
IMDB_URL;http://www.imdb.com/title/tt0948470/
POSTER;http://ia.media-imdb.com/images/M/...
Le linee ACTORS
e POSTER
sono state tagliate percheé troppo lunghe. Per creare il grafo le cui connessioni sono tra ogni film e gli attori/attrici (nella linea ACTORS
) che fanno parte del suo cast, dobbiamo prima di tutto leggere il file actors.txt
e creare i corrispondenti nodi e aggiungerli al grafo. Poi, leggiamo il file films.txt
e creiamo per ogni film un corrispondente nodo e per ogni attore nel cast un arco tra il film e il nodo dell'attore:
def create_graph(path_films, path_actors):
g = Graph()
with open(path_actors) as f:
for line in f:
line = line.strip().split(';')
if line[0] == 'NAME':
name = line[1]
elif line[0] == 'BIRTH':
g.add_node(Node(name, {'type':'ACTOR', 'birth':
(line[1] if len(line) > 1 else '')}))
with open(path_films, 'U') as f:
for line in f:
line = line.strip().split(';')
if line[0] == 'TITLE':
title, year = line[1], line[2]
elif line[0] == 'ACTORS':
actors = line[1:]
elif line[0] == 'DIRECTORS':
film = Node(title+' ('+year+')',
{'type':'FILM', 'directors':line[1:]})
g.add_node(film)
for a in actors:
actor = g.add_node(Node(a))
g.add_edge(actor, film)
return g
Per semplicità non carichiamo nei nodi tutte le infomazioni disponibili (anche se avremmo potuto). Per gli attori manteniamo solamente i dati circa la nascita (BIRTH
) e per i film (oltre al cast) il regista/i (DIRECTORS
). Inoltre, usiamo una chiave 'type'
per distinguere i nodi film dai nodi attori. Adesso possiamo creare il grafo:
graph = create_graph('films.txt', 'actors.txt')
print 'Number of nodes:', len(graph.nodes())
Ha un bel po' di nodi (24610). Una cosa molto semplice che possiamo fare è scrivere una piccola funzione che trova un nodo con il massimo grado. Nella terminologia dei grafi, il grado di un nodo è il numero dei suoi nodi adiacenti. Nel nostro caso, per un nodo attore/attrice è il numero di films in cui ha recitato e per un nodo film è il numero di attori/attrici nel suo cast.
def maxdegree(g):
maxdeg = 0
node = None
for u in g.nodes():
deg = len(u.adjacent())
if deg > maxdeg:
maxdeg = deg
node = u
return node
u = maxdegree(graph)
print u.name
print 'max =', len(u.adjacent())
for film in u.adjacent():
print film.name
Il risultato che otteniamo è il seguente:
Robert De Niro
max degree = 27
A Bronx Tale (1993)
Angel Heart (1987)
Awakenings (1990)
Brazil (1985)
Cape Fear (1991)
Casino (1995)
Everybody's Fine (2009)
Goodfellas (1990)
Heat (1995)
Jackie Brown (1997)
Mean Streets (1973)
Meet the Parents (2000)
Midnight Run (1988)
Novecento (1976)
Once Upon a Time in America (1984)
Raging Bull (1980)
Ronin (1998)
Sleepers (1996)
Stardust (2007)
Taxi Driver (1976)
The Deer Hunter (1978)
The Godfather: Part II (1974)
The King of Comedy (1983)
The Mission (1986)
The Untouchables (1987)
This Boy's Life (1993)
Wag the Dog (1997)
Nella prossima lezione vedremo come visitare il grafo per estrarre molte altre informazioni.