Grafi: cammino minimo, albero ricoprente e TSP
I tre problemi di questo laboratorio sono i classici dell’ottimizzazione su grafi: il cammino minimo fra due nodi, l’albero ricoprente di costo minimo e il circuito hamiltoniano di costo minimo (il TSP). Il corso li affronta con algoritmi dedicati (Dijkstra, Prim, Kruskal); qui invece li risolviamo tutti con lo stesso strumento, una formulazione di programmazione lineare o lineare intera passata a Python-MIP. Il punto non è l’efficienza, che gli algoritmi specializzati vincono senza gara, ma imparare a scrivere vincoli indicizzati su nodi e archi e a gestire famiglie di vincoli esponenzialmente grandi.
I tre problemi sono legati: il cammino minimo ha una formulazione di flusso con variabili continue; l’albero ricoprente una formulazione con cut set e variabili binarie; il TSP una formulazione con vincoli di grado e subtour elimination, che si aggiungono iterativamente. Le istanze sono generate a caso, quindi ognuno ottiene un grafo diverso; in questa guida i risultati numerici si riferiscono a istanze con seme fissato, indicato ogni volta.
1. Generare un’istanza casuale#
Tutti e tre i notebook cominciano allo stesso modo: si generano punti con coordinate casuali nel quadrato , si calcolano le distanze euclidee e si collegano i punti abbastanza vicini.
import numpy as np
import math
k = 15
grid_size = 100 # lato del quadrato
d_max = .35 * grid_size # oltre questa distanza non si crea l'arco
s = 0 # sorgente
t = 12 # destinazione
# np.random.seed(12345) # con un seme fissato l'istanza è riproducibile
point = grid_size * np.random.random((k, 2))
V = [i for i in range(k)]np.random.random((k, 2)) produce una matrice di numeri in ; moltiplicandola per 100 si ottengono le coordinate. Il punto è point[i], un vettore di due componenti, e point[i, 0] e point[i, 1] sono le sue coordinate. Senza seed ogni esecuzione genera punti diversi: è normale che in aula ognuno vedesse un grafo diverso.
1.1 La matrice delle distanze#
La distanza fra tutti i punti si calcola in una riga:
distance = np.array([[math.sqrt(np.sum((point[i] - point[j])**2)) for i in V] for j in V])Conviene smontarla dall’interno verso l’esterno, come fa il notebook stesso. Per due punti e : p1 - p2 è il vettore differenza ; (p1 - p2)**2 eleva al quadrato ogni componente, ; np.sum somma, ; math.sqrt estrae la radice, . La doppia list comprehension [[... for i in V] for j in V] ripete il calcolo per ogni coppia, esattamente come [[i*j for i in range(1,6)] for j in range(1,6)] costruisce la tavola pitagorica; np.array trasforma la lista di liste in una matrice , con distance[i, i] sempre zero.
Si può fare più in fretta?
Per migliaia di punti la doppia comprensione è lenta perché chiama math.sqrt volte da Python. Il notebook mostra la versione vettorizzata basata sul cosiddetto Euclidean trick: , che si calcola con un solo prodotto matriciale.
def slow_distance_matrix(points):
return np.array([[math.sqrt(np.sum((pi - pj)**2)) for pi in points] for pj in points])
def fast_distance_matrix(x):
xy = x @ x.T # tutti i prodotti scalari p_i · p_j
x2 = xy.diagonal()[:, np.newaxis] # le norme al quadrato, come colonna
return np.abs(x2 + x2.T - 2. * xy)**0.5
# confronto (attenzione ai confronti fra numeri in virgola mobile)
eps = 1e-5
assert (np.abs(slow_distance_matrix(points) - fast_distance_matrix(points)) <= eps).all()Con 1000 punti la versione lenta impiega secondi, quella veloce millisecondi. Per i lab la differenza è irrilevante.
1.2 Nodi, archi e disegno del grafo#
L’insieme degli archi contiene le coppie con la cui distanza non supera :
import networkx as nx
A = [(i,j) for i in V for j in V if i != j and distance[i,j] <= d_max]
# coordinate come dizionario nodo -> (x, y), servono solo per il disegno
coord = {i: (point[i,0], point[i,1]) for i in V}
g = nx.Graph()
g.add_nodes_from(V)
g.add_edges_from(A)
nx.draw(g, pos=coord)Due filtri, entrambi importanti. Senza i != j ogni nodo avrebbe un arco verso se stesso (distanza zero). Senza il filtro sulla distanza il grafo sarebbe completo e il disegno illeggibile; con al 35% del lato restano pochi archi per nodo, con il 75% se ne filtrano pochissimi. Il rovescio della medaglia: con troppo piccolo qualche nodo può restare isolato, e se è la sorgente o la destinazione il problema del cammino minimo diventa inammissibile. Vale la pena guardare il disegno prima di risolvere.
Un dettaglio di vocabolario che tornerà utile. Una coppia ordinata è un arco (arc), orientato da a ; una coppia non ordinata è un lato (edge). Nell’insieme A qui sopra compaiono sia sia , quindi il grafo è orientato con archi in entrambe le direzioni; nei problemi successivi si terrà solo la coppia con .
2. Il cammino minimo come problema di flusso#
Trovare il cammino minimo dal nodo 0 al nodo 12 su un grafo casuale con nodi, in cui due nodi sono collegati se la loro distanza euclidea non supera : generare i punti, creare gli archi, scrivere il modello di flusso, risolverlo e ricavare il cammino.
2.1 L’idea: un’unità di flusso da a #
Immaginiamo di spedire un’unità di flusso dalla sorgente alla destinazione lungo gli archi, pagando la distanza dell’arco per ogni unità che lo attraversa. Il flusso più economico percorre un cammino minimo. Per formalizzarlo si introduce una variabile per ogni arco, la quantità di flusso sull’arco, e si impone la conservazione del flusso in ogni nodo. I nodi sono di tre tipi:
- nella sorgente il flusso uscente supera quello entrante di 1: è lì che l’unità viene immessa;
- nella destinazione il flusso uscente supera quello entrante di : è lì che l’unità viene assorbita;
- in ogni altro nodo ciò che entra esce: la differenza è 0.
Dato il grafo con lunghezze , dove se , se e altrimenti.
Il primo termine del vincolo somma gli archi che partono da (flusso uscente), il secondo quelli che arrivano in (flusso entrante). Osserviamo che c’è un solo tipo di vincolo, uno per nodo, e che le variabili sono continue: non serve dichiararle binarie, perché per questa struttura di vincoli il solver restituisce comunque una soluzione a valori 0 e 1 (i vertici del poliedro dei flussi sono interi). Il valore dell’obiettivo è la lunghezza del cammino.
Il vincolo di conservazione dice: «in parte un’unità, in ne arriva una, in mezzo nulla si perde e nulla si crea». Qualunque insieme di archi che soddisfa questa regola contiene un cammino da a ; minimizzare la lunghezza totale sceglie il cammino più corto e scarta tutto il resto.
2.2 Il codice#
Le variabili sono indicizzate da coppie, quindi si usa un dizionario con chiavi tuple, non una lista:
import mip
m = mip.Model()
f = {(i,j): m.add_var() for (i,j) in A} # limiti predefiniti 0 e +infIl parametro è un dizionario con tutti zeri, corretto poi nei due nodi speciali. Il vincolo di conservazione si scrive per ogni nodo con due xsum, la prima sugli archi in uscita e la seconda su quelli in ingresso; il confronto è == perché il vincolo è di uguaglianza:
b = {i: 0 for i in V}
b[s] = 1
b[t] = -1
for i in V:
m.add_constr(mip.xsum(f[i,j] for j in V if (i,j) in A) - # flusso uscente da i
mip.xsum(f[j,i] for j in V if (j,i) in A) == # flusso entrante in i
b[i])Il filtro if (i,j) in A è essenziale: f[i,j] esiste solo per gli archi creati. Nella prima somma il nodo è fissato dal ciclo e scorre le destinazioni; nella seconda scorre le origini degli archi che entrano in , per questo la variabile è f[j,i]. Infine l’obiettivo, l’ottimizzazione e la lettura del cammino:
m.objective = mip.minimize(mip.xsum(distance[i,j] * f[i,j] for (i,j) in A))
m.optimize()
print(m.objective_value) # lunghezza del cammino minimo
for (i,j) in A:
if f[i,j].x > 0.5:
print(i, "--->", j)La soglia > 0.5 (o > 0.001, o > 0.9: qualunque valore fra 0 e 1) serve perché i valori restituiti dal solver sono numeri in virgola mobile: un arco nel cammino ha f.x uguale a 1 a meno di errori di arrotondamento, gli altri hanno 0.
Il notebook confeziona il disegno della soluzione in una funzione, che disegna prima l’intero grafo, poi solo gli archi con flusso positivo in rosso e più spessi, poi la radice in verde:
def draw_solution(V, A, x):
g = nx.Graph()
g.add_nodes_from(V) # tutto il grafo, senza evidenziare
g.add_edges_from([(i,j) for (i,j) in A])
nx.draw(g, pos=coord)
g.clear() # solo gli archi della soluzione
g.add_edges_from([(i,j) for (i,j) in A if f[i,j].x > 0.001])
nx.draw(g, pos=coord, width=4, edge_color='red')
g.clear() # la radice, in verde
g.add_node(0)
nx.draw(g, pos={0: coord[0]}, node_color='green')
draw_solution(V, A, f)2.3 Un’istanza di riferimento#
Con np.random.seed(12345), e il grafo ha 62 archi (31 coppie di nodi vicini, ciascuna in entrambe le direzioni). Il cammino minimo da 0 a 12 è , di lunghezza 87.02. Lo stesso valore si ottiene con l’algoritmo di Dijkstra, ed è la verifica migliore che la formulazione sia corretta.
3. L’albero ricoprente di costo minimo con i cut set#
Scrivere e risolvere la formulazione con cut set del problema dell’albero ricoprente di costo minimo (MST) su un grafo casuale.
3.1 Alberi ricoprenti e tagli#
Un albero ricoprente (spanning tree) di un grafo è un sottoinsieme di lati che collega tutti i nodi senza formare cicli. Un grafo ne ha in genere moltissimi; il problema chiede quello di costo minimo, dove il costo è la somma delle lunghezze dei lati scelti.
La formulazione si basa sulla proprietà che rende un insieme di lati «connesso»: comunque si separi l’insieme dei nodi in due parti, almeno un lato scelto deve attraversare la separazione.
Dato , il taglio è l’insieme dei lati con esattamente un estremo in : e , oppure e .
Se una soluzione non contiene nessun lato di , i nodi di sono scollegati dagli altri e non si tratta di un albero ricoprente. La condizione deve valere per ogni sottoinsieme non vuoto e diverso da : prendendo si impone che il nodo 1 abbia almeno un lato, con che la coppia sia collegata al resto, e così via.
Con una variabile binaria per ogni lato, uguale a 1 se il lato è nell’albero,
Rispetto al cammino minimo cambiano due cose: le variabili sono binarie (un lato c’è o non c’è, non è un flusso) e il grafo è non orientato, quindi si tiene una sola copia di ogni lato, con la convenzione . Osserviamo che nessun vincolo impone il numero di lati o l’assenza di cicli: con costi positivi, la minimizzazione elimina da sola ogni lato superfluo, e la soluzione ha esattamente lati.
Quanti vincoli sono? Uno per ogni sottoinsieme proprio non vuoto di . Ogni nodo può stare dentro o fuori da , quindi i sottoinsiemi sono ; tolti l’insieme vuoto e stesso restano vincoli. Con sono 2046; con sarebbero più di un miliardo. La formulazione è elegante, un solo tipo di vincolo, ma esponenziale: si può usare solo su grafi piccoli, ed è per questo che il notebook riduce a 11.
3.2 Il codice#
La generazione dell’istanza è la stessa, con , un seme fissato e al 75% del lato, così che quasi tutti i lati siano presenti. L’insieme dei lati tiene solo :
import numpy as np
import math
k = 11
grid_size = 100
d_max = .75 * grid_size
np.random.seed(12345)
point = grid_size * np.random.random((k,2))
V = [i for i in range(k)]
distance = np.array([[math.sqrt(np.sum((point[i] - point[j])**2)) for i in V] for j in V])
# lati: i < j (una sola copia per coppia), e il filtro sulla distanza
E = [(i,j) for i in V for j in V if i < j and distance[i,j] <= d_max]Tutti i sottoinsiemi di si generano con itertools: combinations(V, r) produce i sottoinsiemi di dimensione , chain.from_iterable li concatena per che varia in V, cioè da 0 a :
from itertools import chain, combinations
powerset = list(chain.from_iterable(combinations(V, r) for r in V))Osserviamo che r in V fa variare la dimensione da 0 a : l’insieme intero (dimensione ) non viene mai generato, mentre l’insieme vuoto sì. Il modello scarta entrambi gli estremi con un if, e la condizione len(S) < k è in realtà già garantita; non fa male tenerla.
import mip
m = mip.Model()
x = {(i,j): m.add_var(var_type=mip.BINARY) for (i,j) in E}
for S in powerset:
if len(S) > 0 and len(S) < k:
m.add_constr(mip.xsum(x[i,j] for (i,j) in E
if (i in S and j not in S) or
(j in S and i not in S)) >= 1)
m.objective = mip.minimize(mip.xsum(distance[i,j]*x[i,j] for (i,j) in E))
m.optimize()
sol = [(i,j) for (i,j) in E if x[i,j].x > 0.5]
print(f"{len(sol)} edges in the solution: {sol}")La condizione dentro la xsum è la traduzione letterale di «»: un estremo in e l’altro fuori, in uno dei due ordini. Il ciclo for S in powerset è il «» della formulazione, ed è la riga che genera i 2046 vincoli.
3.3 Un’istanza di riferimento#
Con il seme 12345 e il grafo ha 43 lati. L’albero ottimo ha 10 lati, , e costo 215.81; lo stesso albero si ottiene con l’algoritmo di Kruskal. Il disegno con draw_solution (adattato a x e alle coordinate point) mostra un insieme di lati che tocca ogni nodo e non chiude alcun ciclo.
4. Il TSP: vincoli di grado e subtour elimination#
Trovare il circuito hamiltoniano più corto attraverso nodi, note le distanze fra ogni coppia. Generare punti casuali, considerare il grafo completo, scrivere il modello con i soli vincoli di conservazione del flusso (senza subtour elimination), risolverlo e poi aggiungere iterativamente vincoli di subtour elimination finché la soluzione è un unico circuito.
4.1 La formulazione completa#
Un circuito hamiltoniano visita ogni nodo esattamente una volta e torna al punto di partenza. Con la variabile binaria per ogni lato (, grafo completo), ogni nodo deve essere toccato da esattamente due lati del circuito: uno per arrivare e uno per ripartire. È l’analogo, per un circuito, della conservazione del flusso della sezione 2, con «entrante più uscente uguale a 2» al posto di «uscente meno entrante uguale a ».
I vincoli di grado (la prima famiglia) da soli non bastano. Una soluzione che li rispetta può essere formata da più cicli disgiunti, i subtour: per esempio un triangolo su tre nodi e un quadrilatero sugli altri quattro, con ogni nodo di grado 2. Nulla nei vincoli di grado lo proibisce. La seconda famiglia lo esclude: per ogni sottoinsieme di nodi, i lati scelti interni a devono essere al più , perché un ciclo che passa per tutti e soli i nodi di ne userebbe esattamente . Il ciclo su usa i tre lati ; imporre costringe almeno uno dei tre a uscire, e quindi il circuito a lasciare .
Anche qui i vincoli di subtour elimination sono esponenziali, uno per ogni sottoinsieme di nodi. Aggiungerli tutti in partenza è possibile solo per grafi minuscoli. La strategia del laboratorio è diversa e molto più generale.
4.2 Risolvere per iterazioni#
Si risolve il modello con i soli vincoli di grado. Se la soluzione è un unico circuito, è ottima anche per il problema completo (soddisfa vincoli che non abbiamo nemmeno scritto). Altrimenti si identificano i subtour nella soluzione, si aggiunge un vincolo di subtour elimination per ciascuno e si riottimizza; si ripete finché non restano subtour. In gergo i vincoli aggiunti a posteriori sono tagli e la procedura è una separazione di vincoli violati: è lo stesso schema che sta dietro ai risolutori industriali del TSP.
Il codice della prima soluzione. L’istanza ha nodi e grafo completo, i lati sono le coppie con :
import numpy as np, math, networkx as nx, mip
k = 7
grid_size = 100
point = grid_size * np.random.random((k,2))
V = [i for i in range(k)]
distance = np.array([[math.sqrt(np.sum((point[i] - point[j])**2)) for i in V] for j in V])
E = [(i,j) for i in V for j in V if i < j] # grafo completo, non orientato
coord = {i: (point[i,0], point[i,1]) for i in V}
m = mip.Model()
x = {(i,j): m.add_var(var_type=mip.BINARY) for (i,j) in E}
for i in V:
m.add_constr(mip.xsum(x[j,i] for j in V if j < i) +
mip.xsum(x[i,j] for j in V if j > i) == 2)
m.objective = mip.minimize(mip.xsum(distance[i,j] * x[i,j] for (i,j) in E))
m.optimize()Il vincolo di grado richiede attenzione agli indici: poiché esistono solo le variabili con primo indice minore del secondo, i lati incidenti in sono x[j,i] per e x[i,j] per . Scrivere x[i,j] per ogni darebbe un errore di chiave sul dizionario. Il disegno della soluzione riusa draw_solution con with_labels=True per mostrare i numeri dei nodi, e a questo punto in genere compaiono i subtour. Nell’istanza vista in aula erano il triangolo e il ciclo ; nel notebook l’esempio a mano è
m.add_constr(x[0,3] + x[3,4] + x[0,4] <= 2)
m.optimize()
draw_solution(V, E, x)che spezza il triangolo . Nell’istanza di riferimento di questa guida (seme 6) la prima soluzione ha i subtour e con costo 210.15; aggiunto il taglio su la nuova soluzione costa 212.65 e ha i subtour e ; aggiunto il taglio su si ottiene finalmente il circuito di costo 224.04. Tre risoluzioni al posto dei vincoli della formulazione completa.
4.3 Automatizzare: trovare i cicli#
Aggiungere i tagli a mano è istruttivo ma non praticabile. Serve una funzione che, data la soluzione, restituisca i cicli. Il notebook costruisce la lista di adiacenza dei lati scelti e la visita in profondità (DFS) colorando i nodi: 0 non visitato, 1 in visita, 2 visitato; quando la visita incontra un nodo già in stato 1 ha trovato un ciclo e lo ricostruisce risalendo i padri.
def get_cycles(V, E, x):
graph = [[] for i in V]
for (i,j) in E:
if x[i,j].x > 0.5:
graph[i].append(j)
graph[j].append(i)
cycles = []
color = [0 for i in V]
par = [-1 for i in V]
for i in V:
if par[i] == -1:
dfs_cycle(graph, cycles, i, -1, color, par)
return [c for c in cycles if len(c) < len(V) and len(c) > 0]
def dfs_cycle(graph, cycles, u, p, color, par):
if color[u] == 2: # nodo già completamente visitato
return
if color[u] == 1: # nodo in visita: ciclo trovato, lo ricostruisco dai padri
v = []
cur = p
v.append(cur)
while cur != u:
cur = par[cur]
v.append(cur)
cycles.append(v)
return
par[u] = p
color[u] = 1 # visita in corso
for v in graph[u]:
if v == par[u]: # non tornare indietro sul lato appena percorso
continue
dfs_cycle(graph, cycles, v, u, color, par)
color[u] = 2 # visita completataget_cycles restituisce solo i cicli con meno di nodi: un ciclo che li contiene tutti è un circuito hamiltoniano, e in quel caso la lista è vuota. Il ciclo principale risolve, cerca i subtour, aggiunge un taglio per il primo trovato e ripete:
m = mip.Model()
x = {(i,j): m.add_var(var_type=mip.BINARY, name="x{}{}".format(str(i), str(j))) for (i,j) in E}
for i in V:
m.add_constr(mip.xsum(x[j,i] for j in V if j < i) +
mip.xsum(x[i,j] for j in V if j > i) == 2)
m.objective = mip.minimize(mip.xsum(distance[i,j] * x[i,j] for (i,j) in E))
m.optimize()
cycles = get_cycles(V, E, x)
while len(cycles) > 0:
cycle = cycles.pop()
cycle_edges = [x[i,j] for (i,j) in E if i in cycle and j in cycle]
c = m.add_constr(mip.xsum(cycle_edges) <= len(cycle) - 1)
print("Added constr:")
print(c)
m.optimize()
cycles = get_cycles(V, E, x)
print(m.objective_value)
draw_solution(V, E, x)Il name= dato alle variabili serve solo a rendere leggibile la stampa del vincolo aggiunto, per esempio x03 + x34 + x04 <= 2.
Nel notebook il termine di destra del taglio è len(cycle_edges) - 1, dove cycle_edges sono tutti i lati del grafo completo con entrambi gli estremi nel ciclo, cioè lati. Per un ciclo di 3 nodi i lati interni sono 3 e il vincolo è , corretto. Per un ciclo di 4 nodi i lati interni sono 6 e il vincolo diventa : il subtour ne usa 4 e lo soddisfa, quindi il taglio non lo elimina e il ciclo while può ripetersi all’infinito sulla stessa soluzione. Il termine di destra corretto, quello della formulazione, è , ovvero len(cycle) - 1, come scritto nel listato qui sopra.
La ricerca dei cicli non ha bisogno del solver: dato l’insieme dei lati scelti, si può provare direttamente nella pagina. Il blocco seguente usa una variante più semplice, che raccoglie le componenti connesse del grafo dei lati scelti (con i vincoli di grado ogni componente è un ciclo).
Il modello «rilassato» con i soli vincoli di grado è ottimista: trova soluzioni a pezzi che costano meno del vero ottimo. Ogni taglio gli vieta uno specifico modo di andare a pezzi, il costo sale un poco, e dopo qualche giro l’unica soluzione rimasta è un circuito unico. Non si scrivono mai tutti i vincoli, solo quelli che la soluzione corrente viola.
5. Riepilogo e glossario#
| Problema | Variabili | Vincoli | Numero di vincoli | Trucco |
|---|---|---|---|---|
| Cammino minimo | continue, su archi orientati | conservazione del flusso, uno per nodo | LP basta: la soluzione è già intera | |
| MST | su lati, | almeno un lato per ogni taglio | si generano con itertools, solo per piccolo |
|
| TSP | su lati, grafo completo | grado 2 in ogni nodo; subtour elimination | + esponenziali | i tagli si aggiungono solo quando violati |
In tutti e tre i casi il codice ha la stessa forma: dati generati con NumPy, variabili in un dizionario indicizzato da coppie, un ciclo for per la famiglia di vincoli, xsum per le somme, x[i,j].x > 0.5 per leggere la soluzione.
| Termine | Significato |
|---|---|
| arco / lato | coppia ordinata (orientato) / coppia non ordinata |
| conservazione del flusso | in ogni nodo, flusso uscente meno entrante uguale a |
| cut set | lati con esattamente un estremo in |
| albero ricoprente | sottoinsieme di lati connesso e senza cicli che tocca tutti i nodi |
| circuito hamiltoniano | ciclo che visita ogni nodo esattamente una volta |
| subtour | ciclo che visita solo una parte dei nodi |
| taglio (cut) | vincolo aggiunto al modello perché violato dalla soluzione corrente |
| separazione | procedura che trova un vincolo violato (qui, la ricerca dei cicli) |