BFS ens va donar el camí amb menys arestes, però comptar arestes és com mesurar un viatge en nombre de carreteres en lloc de en quilòmetres. Tan bon punt cada aresta té un pes —a TaskFlow, les hores o el cost de la transició entre dues tasques— "menys salts" deixa de significar "més barat". Aquesta lliçó presenta els tres algorismes clàssics de camins mínims: Dijkstra (el cavall de batalla, construït sobre el heapq del mòdul 4: la reutilització estrella del curs), Bellman-Ford (més lent però tolerant amb pesos negatius) i Floyd-Warshall (tots contra tots). Continuem sobre la classe Graf, que ja desava pesos des de la 07-02 sense que els féssim servir.
Contingut
- Grafs ponderats a TaskFlow
- Per què BFS no basta amb pesos
- Dijkstra: la idea i la seva cua de prioritat
- Implementació completa amb predecessors
- Traça pas a pas amb taula de distàncies
- La limitació de Dijkstra: pesos negatius
- Bellman-Ford: idea i codi compacte
- Floyd-Warshall: tots-a-tots (menció)
- Taula comparativa i elecció
Grafs ponderats a TaskFlow
Imaginem la planificació entre dues fites del projecte: des de la fita inici fins a llancament hi ha rutes alternatives (fer prototip o anar directes al disseny, sortir per l'API o per la UI), i cada transició té un cost en hores. L'equip vol la ruta més barata entre les dues fites:
pla = Graf(dirigit=True)
pla.afegir_aresta("inici", "dissenyar", 4)
pla.afegir_aresta("inici", "prototip", 2)
pla.afegir_aresta("prototip", "dissenyar", 1)
pla.afegir_aresta("prototip", "ui", 8)
pla.afegir_aresta("dissenyar", "ui", 3)
pla.afegir_aresta("dissenyar", "api", 5)
pla.afegir_aresta("api", "llancament", 2)
pla.afegir_aresta("ui", "llancament", 2)graph LR
I[inici] -->|4| D[dissenyar]
I -->|2| P[prototip]
P -->|1| D
P -->|8| U[ui]
D -->|3| U
D -->|5| A[api]
A -->|2| L[llancament]
U -->|2| L
Per què BFS no basta amb pesos
BFS diria que el millor camí inici → llancament és qualsevol de 3 arestes, per exemple inici → dissenyar → api → llancament, amb cost real 4 + 5 + 2 = 11 hores. Però el camí de 4 arestes inici → prototip → dissenyar → ui → llancament costa 2 + 1 + 3 + 2 = 8 hores: més salts, menys cost. BFS optimitza el nombre d'arestes perquè processa els vèrtexs per capes; amb pesos, la capa no diu res del cost. Necessitem processar els vèrtexs per cost acumulat, no per distància en salts. I quina estructura lliura sempre "el de menor valor primer"? La cua de prioritat del mòdul 4.
Dijkstra: la idea i la seva cua de prioritat
Dijkstra manté, per a cada vèrtex, la millor distància coneguda des de l'origen, i repeteix un bucle cobdiciós:
- Pren el vèrtex no tancat amb menor distància coneguda (això ho dóna el monticle en
O(log n)). - Tanca'l: la seva distància ja és definitiva.
- Relaxa les seves arestes: per a cada veí, si passar pel vèrtex acabat de tancar millora la seva distància, apunta la millora (i el predecessor).
Per què pot "tancar" amb tanta seguretat? Perquè si tots els pesos són ≥ 0, qualsevol altre camí fins a aquell vèrtex passaria per vèrtexs més llunyans i ja no pot millorar. Aquesta és exactament la hipòtesi que es trenca amb pesos negatius, com veurem.
En lloc d'una operació "decrementar clau" (que heapq no ofereix), fem servir el truc estàndard: inserir entrades duplicades i descartar les obsoletes en extreure-les — la mateixa filosofia de tuples (prioritat, dada) de la SafataUrgencies del mòdul 4.
Implementació completa amb predecessors
import heapq
def dijkstra(graf, origen):
"""Distàncies mínimes des d'origen i predecessors per reconstruir camins."""
distancies = {v: float("inf") for v in graf.vertexs()}
distancies[origen] = 0
predecessor = {origen: None}
monticle = [(0, origen)] # tuples (distancia, vertex), mòdul 4
tancats = set()
while monticle:
dist, actual = heapq.heappop(monticle) # el més barat conegut
if actual in tancats:
continue # entrada obsoleta: ja es va tancar abans
tancats.add(actual)
for vei, pes in graf.veins(actual).items():
nova = dist + pes
if nova < distancies[vei]: # millora passar per 'actual'?
distancies[vei] = nova
predecessor[vei] = actual
heapq.heappush(monticle, (nova, vei))
return distancies, predecessor
def reconstruir_cami(predecessor, desti):
"""Segueix els predecessors cap enrere i capgira el resultat al final."""
if desti not in predecessor:
return None # inabastable des de l'origen
cami = []
while desti is not None:
cami.append(desti)
desti = predecessor[desti]
return cami[::-1]
distancies, predecessor = dijkstra(pla, "inici")
print(distancies["llancament"]) # 8
print(reconstruir_cami(predecessor, "llancament"))
# ['inici', 'prototip', 'dissenyar', 'ui', 'llancament']Punts clau del codi:
distanciesarrenca a infinit (float("inf")) llevat de l'origen: "encara no conec cap camí". Tot vèrtex inabastable acaba l'algorisme amb infinit.- L'
if actual in tancats: continueés el descart de duplicats: un vèrtex pot ser diverses vegades al monticle amb distàncies diferents; només la primera extracció (la menor) compta. predecessor[v]desa des d'on es va arribar avpel millor camí;reconstruir_camiel recorre cap enrere — per això cal invertir la llista al final ([::-1]), igual que quan buidàvem una pila al mòdul 3.- Cost: cada aresta pot inserir una entrada al monticle, així que
O((n + a) · log n)— per a grafs dispersos, gairebé lineal.
Traça pas a pas amb taula de distàncies
Cada fila mostra el vèrtex que es tanca i les distàncies després de relaxar les seves arestes (en negreta les que milloren):
| Es tanca | inici | prototip | dissenyar | ui | api | llancament |
|---|---|---|---|---|---|---|
| (inicial) | 0 | inf | inf | inf | inf | inf |
| inici (0) | 0 | 2 | 4 | inf | inf | inf |
| prototip (2) | 0 | 2 | 3 | 10 | inf | inf |
| dissenyar (3) | 0 | 2 | 3 | 6 | 8 | inf |
| ui (6) | 0 | 2 | 3 | 6 | 8 | 8 |
| api (8) | 0 | 2 | 3 | 6 | 8 | 8 |
| llancament (8) | — | — | — | — | — | — |
Lectura dels moments clau:
- En tancar
prototip, la distància dedissenyarmillora de 4 a 3: la ruta indirectainici → prototip → dissenyar(2+1) guanya la directa (4). El predecessor dedissenyarpassa d'iniciaprototip. Això és la relaxació en acció. - En tancar
dissenyar,uimillora de 10 (via prototip) a 6 (via dissenyar). llancamentqueda a 8 viaui; quan després es tancaapi, la seva oferta (8 + 2 = 10) ja no millora res.- Al monticle queden entrades obsoletes —
(4, dissenyar),(10, ui),(10, llancament)— que elcontinuedescarta en sortir.
La limitació de Dijkstra: pesos negatius
Dijkstra tanca vèrtexs assumint que allunyar-se mai no abarateix. Amb una aresta negativa, aquesta lògica es trenca: un camí que primer "s'allunya" podria després descomptar cost i superar el que vam donar per definitiu — però el vèrtex ja està tancat i no es revisa. Resultat: respostes incorrectes sense cap avís, el pitjor tipus d'error.
Pesos negatius a la vida real? A TaskFlow podrien modelar transicions que estalvien (reutilitzar feina feta: fer B just després d'A descompta hores); en finances, operacions amb benefici; en logística, trams subvencionats. Quan n'hi hagi, l'algorisme honest és Bellman-Ford.
Bellman-Ford: idea i codi compacte
Bellman-Ford renuncia a l'astúcia del monticle i aplica força bruta ordenada: relaxar totes les arestes, n − 1 vegades. Després de la passada k, són correctes totes les distàncies de camins mínims amb ≤ k arestes; com que cap camí simple no en té més de n − 1, amb això n'hi ha prou. I regala una cosa que Dijkstra no pot: si en una passada extra encara millora alguna cosa, hi ha un cicle de pes negatiu (un bucle que abarateix sense fi, amb el qual "camí mínim" deixa de tenir sentit).
def bellman_ford(graf, origen):
# llista d'arestes (la tercera representació, esmentada a la 07-02)
arestes = [(u, v, p) for u in graf.vertexs()
for v, p in graf.veins(u).items()]
distancies = {v: float("inf") for v in graf.vertexs()}
distancies[origen] = 0
predecessor = {origen: None}
for _ in range(len(distancies) - 1): # n - 1 passades
canvi = False
for u, v, p in arestes: # relaxar TOTES les arestes
if distancies[u] + p < distancies[v]:
distancies[v] = distancies[u] + p
predecessor[v] = u
canvi = True
if not canvi: # res no millora: acabar abans
break
for u, v, p in arestes: # passada extra: encara millora res?
if distancies[u] + p < distancies[v]:
raise ValueError("Cicle de pes negatiu: no hi ha camins mínims")
return distancies, predecessorCost: O(n · a) — sensiblement pitjor que Dijkstra, i aquest és el tracte: robustesa a canvi de velocitat. Fixa't que reutilitza reconstruir_cami tal qual: la interfície (distàncies + predecessors) és la mateixa.
Floyd-Warshall: tots-a-tots (menció)
Quan la pregunta no és "des d'aquest origen" sinó "entre tots els parells" (una taula de costos de qualsevol fita a qualsevol fita), l'algorisme de referència és Floyd-Warshall: programació dinàmica sobre la matriu de distàncies, provant cada vèrtex k com a escala intermèdia:
| Tret | Valor |
|---|---|
| Resultat | Matriu n × n de distàncies tots-a-tots |
| Cost | O(n³) en temps, O(n²) en espai |
| Pesos negatius | Sí (sense cicles negatius; els detecta a la diagonal) |
| Representació natural | Matriu d'adjacència — el seu retorn anunciat a la 07-02 |
Tres bucles imbricats i una línia de relaxació: probablement l'algorisme més curt del curs pel resultat que produeix. Només compensa amb grafs petits o quan de debò es necessita la taula completa; no el desenvoluparem més.
Taula comparativa i elecció
| Dijkstra | Bellman-Ford | Floyd-Warshall | |
|---|---|---|---|
| Pregunta | 1 origen → tots | 1 origen → tots | tots → tots |
| Cost | O((n + a) log n) |
O(n · a) |
O(n³) |
| Pesos negatius | No | Sí | Sí |
| Detecta cicles negatius | No | Sí | Sí |
| Estructura de suport | heapq (mòdul 4) |
Llista d'arestes | Matriu (07-02) |
| Fes-lo servir quan... | Pesos ≥ 0 (el cas normal) | Hi pot haver pesos negatius | Graf petit, taula completa |
Regla pràctica: Dijkstra per defecte; Bellman-Ford si els pesos poden ser negatius; Floyd-Warshall si necessites la matriu completa i n és modest (centenars, no centenars de milers).
Errors Comuns i Consells
- Executar Dijkstra amb pesos negatius. No falla ni avisa: simplement retorna distàncies incorrectes. Si els teus pesos poden ser negatius, valida abans o canvia d'algorisme.
- Oblidar el descart d'entrades obsoletes (
if actual in tancats: continue). L'algorisme relaxaria vèrtexs ja tancats des d'entrades velles del monticle: resultats de vegades correctes, de vegades no — el pitjor tipus de bug. - Posar al monticle tuples no comparables. Si en comptes d'ids hi posessis els
dictde tasca,heapqfallaria en desempatar tuples amb la mateixa distància. És exactament el problema que vam resoldre al mòdul 4 amb(prioritat, comptador, tasca); aquí els ids (strings) són comparables i n'hi ha prou amb(distancia, id). - Reconstruir el camí oblidant invertir-lo: els predecessors es recorren del destí cap a l'origen; sense el
[::-1]final, el camí surt del revés. - Confondre "no hi ha camí" amb "distància enorme": comprova
distancies[v] == float("inf")explícitament abans de fer servir el valor. - Consell: desa sempre els predecessors. La distància diu quant costa; el camí reconstruït diu què fer, i en una aplicació real (TaskFlow inclòs) és el que l'usuari vol veure.
Exercicis
Exercici 1: llegir els resultats de Dijkstra
Amb distancies i predecessor calculats sobre pla des d'inici: (a) quina és la ruta més barata fins a api i el seu cost? (b) Afegeix l'aresta pla.afegir_aresta("prototip", "api", 4) i raona (sense executar) com canvien la distància i el predecessor d'api.
Exercici 2: cost d'una ruta concreta
Escriu cost_cami(graf, cami) que retorni el cost total d'una llista de vèrtexs consecutius, o None si algun tram no existeix com a aresta. Compara el cost de ["inici", "dissenyar", "api", "llancament"] amb l'òptim de Dijkstra.
Exercici 3: un descompte perillós
Suposa que fer ui just després d'api reutilitza components: aresta api → ui amb pes −4. (a) Continua sent vàlid Dijkstra? (b) Executa mentalment Bellman-Ford: canvia la distància de llancament? (c) Què passaria si el descompte fos ui → prototip amb pes −7?
Solucions
Solució 1:
- (a)
reconstruir_cami(predecessor, "api")→['inici', 'prototip', 'dissenyar', 'api'], cost 2 + 1 + 5 = 8. Observa que comparteix prefix amb la ruta allancament: els camins mínims des d'un origen formen un arbre (l'arbre de camins mínims), un altre retrobament amb el mòdul 6. - (b) La nova oferta per a
apiseria 2 + 4 = 6 < 8: en tancarprototip,apiquedaria a 6 amb predecessorprototip. A mésllancamentrebria l'oferta 6 + 2 = 8: mateix cost total per una altra ruta; com que 8 no és menor que 8, la relaxació no canvia el predecessor i es conserva la ruta perui.
Solució 2:
def cost_cami(graf, cami):
total = 0
for origen, desti in zip(cami, cami[1:]): # parells consecutius
if not graf.existeix_aresta(origen, desti):
return None
total += graf.veins(origen)[desti]
return total
print(cost_cami(pla, ["inici", "dissenyar", "api", "llancament"])) # 11
print(distancies["llancament"]) # 8zip(cami, cami[1:]) aparella cada vèrtex amb el següent: la manera idiomàtica de recórrer trams. La ruta "directa" costa 11 davant de l'òptim de 8: un 37 % més cara per estalviar-se una aresta.
Solució 3:
- (a) No: amb una aresta negativa al graf, la garantia de tancament de Dijkstra desapareix (encara que en algun cas concret l'encerti, ja no és fiable).
- (b) Amb
api → ui= −4: arribar auiviaapicosta 8 + (−4) = 4 < 6, i llavorsllancamentbaixa a 4 + 2 = 6. Bellman-Ford ho troba en passades successives; la resposta canvia de 8 a 6, amb rutainici → prototip → dissenyar → api → ui → llancament. - (c)
ui → prototip= −7 crearia el cicleprototip → dissenyar → ui → prototipde pes 1 + 3 − 7 = −3: cada volta "estalvia" 3 hores, les distàncies caurien sense fons i Bellman-Ford llançaria elValueErrorde cicle negatiu. Un bon recordatori que un model amb descomptes il·limitats és un model mal plantejat.
Conclusió
Amb pesos a les arestes, BFS cedeix el lloc a Dijkstra: la cua de prioritat heapq del mòdul 4 lliura sempre el vèrtex més barat, la relaxació millora distàncies i predecessors, i reconstruir_cami converteix el resultat en una ruta accionable — tot en O((n + a) log n). Bellman-Ford cobreix el terreny que Dijkstra no trepitja (pesos negatius, cicles negatius) a canvi d'O(n · a), i Floyd-Warshall respon el tots-a-tots en O(n³). Fins ara sempre hem minimitzat el cost d'una ruta entre dos punts. La propera lliçó canvia la pregunta: no anar d'A a B, sinó connectar tots els punts entre si amb el mínim cost total — l'arbre d'expansió mínima, on reapareixen els grafs no dirigits, Prim (gairebé un Dijkstra disfressat) i una estructura nova: Union-Find.
Curs d'Estructures de Dades
Mòdul 1: Introducció a les Estructures de Dades
- Què són les Estructures de Dades?
- Importància de les Estructures de Dades en la Programació
- Tipus d'Estructures de Dades
- Complexitat Algorísmica i Notació Big O
- Arrays i Memòria: la Base de les Estructures de Dades
Mòdul 2: Llistes
- Introducció a les Llistes
- Llistes Enllaçades
- Llistes Doblement Enllaçades
- Llistes Circulars
- Exercicis amb Llistes
Mòdul 3: Piles
- Introducció a les Piles
- Operacions Bàsiques amb Piles
- Implementació de Piles
- Aplicacions de les Piles
- Exercicis amb Piles
Mòdul 4: Cues
- Introducció a les Cues
- Operacions Bàsiques amb Cues
- Cues Circulars
- Cues de Prioritat
- Cues Dobles (Deques)
- Exercicis amb Cues
Mòdul 5: Taules Hash i Diccionaris
- Introducció a les Taules Hash
- Funcions Hash i Resolució de Col·lisions
- Diccionaris i Conjunts a la Pràctica
- Exercicis amb Taules Hash
Mòdul 6: Arbres
- Introducció als Arbres
- Arbres Binaris
- Recorreguts d'Arbres
- Arbres Binaris de Cerca
- Arbres AVL
- Arbres B
- Monticles (Heaps)
- Exercicis amb Arbres
Mòdul 7: Grafs
- Introducció als Grafs
- Representació de Grafs
- Algorismes de Cerca en Grafs
- Algorismes de Camins Mínims
- Arbres d'Expansió Mínima
- Aplicacions dels Grafs
- Exercicis amb Grafs
