Deixem les llistes i tornem a la xarxa de parades que vam modelar com a graf a 03-04 — aquesta vegada amb la pregunta estrella de qualsevol app de mobilitat: quants minuts es triga, com a mínim, d'una parada a cadascuna de les altres? L'algorisme que la respon el vam anunciar a 03-02 amb nom i cognom: Dijkstra, "el greedy que sí que és òptim". Després de veure estratègies voraces que fallaven (canvi_greedy, recorregut_supervisor), aquí estudiarem la que triomfa: per què la seva tria voraç — consolidar sempre la parada més propera pendent — és irrevocablement correcta quan els pesos no són negatius, com implementar-la eficientment amb una cua de prioritat (heapq), i com reconstruir el camí a més del seu cost. El traçarem complet des de la Plaça Major i tancarem amb la seva complexitat i la seva única criptonita: els pesos negatius.
Contingut
- La xarxa de RutaBus com a graf ponderat
- El problema: camins més curts des d'un origen
- La idea greedy i per què funciona
- Quadre previ: què és un heap / cua de prioritat
- Implementació amb
heapq - Traça completa des de la Plaça Major
- Reconstruir el camí: predecessors
- Complexitat i la limitació dels pesos negatius
La xarxa de RutaBus com a graf ponderat
A 03-04 la xarxa era un diccionari d'adjacència que només deia qui connecta amb qui. Per parlar de temps hi hem d'afegir pesos: els minuts de trajecte de cada tram. N'hi ha prou de canviar les llistes de veïnes per diccionaris veina → minuts:
xarxa = {
"Plaça Major": {"Estació Nord": 4, "Parc del Riu": 2},
"Estació Nord": {"Plaça Major": 4, "Hospital Central": 5},
"Hospital Central": {"Estació Nord": 5, "Parc del Riu": 8, "Universitat": 6},
"Parc del Riu": {"Plaça Major": 2, "Hospital Central": 8, "Universitat": 3},
"Universitat": {"Hospital Central": 6, "Parc del Riu": 3},
}Vocabulari mínim: les parades són els vèrtexs (V = 5), els trams són les arestes (E = 6) i els minuts, els seus pesos. El nostre graf és no dirigit — cada tram apareix en tots dos sentits amb el mateix pes —; si la L3 tingués un tram de sentit únic, n'hi hauria prou d'anotar-lo només en una direcció, i tot el que segueix funcionaria igual.
graph LR
PM((Plaça Major)) ---|4| EN((Estació Nord))
PM ---|2| PR((Parc del Riu))
EN ---|5| HC((Hospital Central))
PR ---|8| HC
PR ---|3| UNI((Universitat))
UNI ---|6| HC
El problema: camins més curts des d'un origen
El cost d'un camí és la suma dels pesos dels seus trams. De la Plaça Major a l'Hospital Central n'hi ha diversos: per l'Estació Nord (4 + 5 = 9), pel Parc del Riu directe (2 + 8 = 10), pel Parc del Riu i la Universitat (2 + 3 + 6 = 11). El problema del camí més curt des d'un origen (single-source shortest paths) demana, per a un origen donat, la distància mínima a totes les altres parades — exactament el que RutaBus necessita per respondre "quant trigo des d'aquí?" a un usuari plantat a la Plaça Major.
Per què no serveix res del que ja tenim? El backtracking de 03-04 (rutes_inspeccio) enumeraria tots els camins possibles — exponencial. I compte amb la trampa d'"el camí amb menys parades": el tram directe PR→HC (8 min) perd contra el tomb PR→UNI→HC (9 min... no, 3+6=9, aquí guanya el directe — però PM→HC per EN amb dos trams guanya al directe per PR amb dos trams). Menys trams no implica menys minuts: cal comptar pesos, no salts. (Comptar salts és el que faria una cerca en amplada, BFS — una línia de menció i seguim, perquè amb pesos no n'hi ha prou.)
La idea greedy i per què funciona
Dijkstra manté per a cada parada una distància provisional (la millor trobada fins al moment, ∞ si cap) i aplica aquest bucle voraç, pur 03-02:
D'entre les parades encara no consolidades, pren la de menor distància provisional, declara-la definitiva, i relaxa les seves arestes: per a cada veïna, comprova si passar per la parada acabada de consolidar millora la seva provisional.
La "relaxació" és l'operació elemental: si dist[parada] + minuts(parada, veina) < dist[veina], hem trobat una drecera i actualitzem.
Per què aquesta tria voraç no s'equivoca mai? L'argument — cosí de l'argument d'intercanvi de 03-02 — es recolza en una única hipòtesi: cap pes no és negatiu. Sigui p la parada no consolidada amb menor provisional d. Podria existir un camí cap a p més curt que d, encara no descobert? Aquest camí sortiria de la zona consolidada en algun punt i travessaria alguna altra parada no consolidada q abans d'arribar a p. Però només arribar a q ja costa almenys la seva provisional, que és ≥ d (p era la mínima!), i des de q fins a p els trams restants sumen ≥ 0 (aquí entra la hipòtesi). Total: ≥ d. Cap tomb no pot batre d, així que consolidar p amb d és segur — per sempre. És un greedy amb la propietat que a recorregut_supervisor li faltava: cada decisió local ve amb una garantia global demostrada.
Quadre previ: què és un heap / cua de prioritat
Cua de prioritat i heap, en quatre línies. Una cua de prioritat és una estructura que permet inserir elements amb una prioritat i extreure'n sempre el de prioritat mínima. La seva implementació estàndard és el heap binari: un arbre gairebé complet guardat en una llista on cada node és ≤ que els seus fills, de manera que el mínim viu sempre a l'arrel (
posició 0). Inserir (heappush) i extreure el mínim (heappop) costen O(log n) — l'element "sura" o "s'enfonsa" al llarg dels ~log₂ n nivells de l'arbre, el mateix recompte de meitats de 04-01. A Python ho dona fet el mòdulheapq, que opera sobre llistes normals; amb tuples(prioritat, dada)compara primer la prioritat.
Per a què el volem? El pas voraç exigeix localitzar "la parada pendent amb menor provisional" moltes vegades. Cercar-la amb un recorregut lineal costaria O(V) cada vegada; el heap la serveix en O(log V).
Implementació amb heapq
import heapq
def dijkstra(xarxa, origen):
dist = {parada: float("inf") for parada in xarxa} # provisionals: ∞
dist[origen] = 0
previa = {origen: None} # per reconstruir camins (apt. 7)
consolidades = set()
heap = [(0, origen)] # (distància provisional, parada)
while heap:
d, parada = heapq.heappop(heap) # la pendent MÉS PROPERA
if parada in consolidades:
continue # entrada obsoleta: ignorar
consolidades.add(parada) # decisió voraç: definitiva
for veina, minuts in xarxa[parada].items(): # relaxar les seves arestes
nova = d + minuts
if nova < dist[veina]: # drecera trobada?
dist[veina] = nova
previa[veina] = parada
heapq.heappush(heap, (nova, veina))
return dist, previaTres decisions de disseny que convé entendre:
- Entrades obsoletes. Quan la provisional d'una parada millora, no editem la seva entrada antiga al heap (els heaps no ho suporten amb gràcia): n'afegim una altra amb la distància nova. La nova, en ser menor, sortirà abans; quan més tard aflori la vella, l'
if parada in consolidades: continuela rebutja. És la tècnica de l'eliminació mandrosa: més simple i a la pràctica igual d'eficient. distcomença en infinit (float("inf")): "encara no conec cap camí". L'infinit de coma flotant compara correctament amb qualsevol suma de minuts i evita tractar els "sense descobrir" com a cas especial.previaapunta, per a cada parada, des d'on se la va assolir per darrera vegada amb millora — la molla de pa que a l'apartat 7 ens retornarà el camí complet, no només el seu cost.
Traça completa des de la Plaça Major
Executem dijkstra(xarxa, "Plaça Major"). Abreugem: PM, EN, HC, PR, UNI. Cada fila és una volta del while que consolida una parada:
| Pas | Consolida | Relaxacions | dist després del pas (PM, EN, HC, PR, UNI) | Heap pendent |
|---|---|---|---|---|
| 0 | — (inicial) | — | 0, ∞, ∞, ∞, ∞ | (0, PM) |
| 1 | PM (0) | EN: 0+4=4 ✔ · PR: 0+2=2 ✔ | 0, 4, ∞, 2, ∞ | (2, PR), (4, EN) |
| 2 | PR (2) | HC: 2+8=10 ✔ · UNI: 2+3=5 ✔ · PM: no millora | 0, 4, 10, 2, 5 | (4, EN), (5, UNI), (10, HC) |
| 3 | EN (4) | HC: 4+5=9 ✔ (millora el 10!) · PM: no | 0, 4, 9, 2, 5 | (5, UNI), (9, HC), (10, HC) |
| 4 | UNI (5) | HC: 5+6=11 ✘ · PR: no | 0, 4, 9, 2, 5 | (9, HC), (10, HC) |
| 5 | HC (9) | EN, PR, UNI: cap millora | 0, 4, 9, 2, 5 | (10, HC) |
| 6 | — | (10, HC) surt del heap i es rebutja: obsoleta | — | buit |
Resultat: des de la Plaça Major es triga 0 (PM), 4 (EN), 9 (HC), 2 (PR) i 5 (UNI) minuts com a mínim. La traça exhibeix els dos comportaments característics:
- L'ordre de consolidació (PM, PR, EN, UNI, HC) és per distància creixent — el front voraç s'expandeix com una ona des de l'origen.
- El pas 3 mostra una relaxació que corregeix: HC havia estat descobert via PR (10 min) però la ruta per EN el millora a 9. La provisional era això, provisional; només en consolidar-se (pas 5) esdevé veritat definitiva. I el pas 6 mostra l'entrada obsoleta
(10, HC)morint en silenci.
Reconstruir el camí: predecessors
dist respon "quant"; l'usuari vol "per on". La resposta és a previa, que després de l'execució val:
{"Plaça Major": None, "Estació Nord": "Plaça Major", "Parc del Riu": "Plaça Major",
"Universitat": "Parc del Riu", "Hospital Central": "Estació Nord"}És la mateixa jugada que la reconstrucció de la motxilla a 03-03: durant l'algorisme no guardem els camins (serien molts i llargs), guardem una molla per parada — qui la va assolir per darrera vegada amb millora — i al final recorrem les molles cap enrere:
def cami_fins(previa, desti):
cami = []
while desti is not None:
cami.append(desti)
desti = previa[desti] # un salt cap enrere
return list(reversed(cami)) # estava del revés
cami_fins(previa, "Hospital Central")
# ['Plaça Major', 'Estació Nord', 'Hospital Central'] → 4 + 5 = 9 ✔Fixa't en l'elegància estructural: els punters previa formen un arbre de camins mínims amb arrel a l'origen — un sol diccionari codifica el millor camí a totes les parades alhora.
Complexitat i la limitació dels pesos negatius
Temps. Comptem amb les eines de 02-01, sent V les parades i E els trams:
- Cada parada es consolida una sola vegada, i en consolidar-se relaxa les seves arestes; cada aresta es relaxa per tant O(1) vegades per extrem → O(E) relaxacions en total.
- Cada relaxació que millora fa un
heappushd'O(log·mida del heap). El heap conté com a màxim O(E) entrades (per les obsoletes), i log E ≤ log V² = 2·log V — constant fora (01-03) —, així que cada operació de heap és O(log V). - Extraccions: O(V + E)
heappop, també a O(log V).
Total: O((V + E) · log V). Per a la xarxa real de RutaBus — dispersa: cada parada connecta amb 2-4 veïnes, E ≈ 2V — això és gairebé lineal: amb 10.000 parades, de l'ordre de 4·10⁵ operacions de heap. L'alternativa sense heap (cercar el mínim amb un escombratge lineal) costaria O(V²) — 10⁸ —, que només compensa en grafs molt densos.
Espai: O(V + E) — els diccionaris dist/previa i el heap. Auxiliar lineal, sense ensurts.
La criptonita: pesos negatius. Tota la demostració de l'apartat 3 penjava de "els trams restants sumen ≥ 0". Si un pes pot ser negatiu — imagina un tram bonificat on el bus recupera 4 minuts d'horari — l'argument s'ensorra, i amb ell l'algorisme:
Dijkstra des d'A consolida B amb distància 3 (és la mínima provisional: 3 < 5) i la declara intocable. Però el camí A→C→B costa 5 + (−4) = 1. La consolidació voraç — "res no pot millorar el mínim actual" — era mentida: un tomb aparentment car amagava un descompte. Amb minuts de trajecte això no passa (el temps no recula), però sí tan bon punt els pesos modelen costos amb reemborsaments, diferències o saldos. Per a aquests grafs calen algorismes que no consolidin tan alegrement — i un d'ells, que a més respon a totes-les-parades-contra-totes d'una tacada, és exactament la propera lliçó: Floyd-Warshall.
Errors Comuns i Consells
- Usar-lo amb pesos negatius: no avisa, no falla — retorna distàncies incorrectes amb tota la seguretat en si mateix. Si els teus pesos poden ser negatius, Dijkstra queda descartat d'entrada (apartat 8).
- Oblidar el descart d'obsoletes (
if parada in consolidades: continue): l'algorisme reprocessa parades amb distàncies velles; en grafs amb molts cicles pot donar resultats correctes per casualitat però disparar el cost, o corrompreprevia. Les entrades duplicades al heap són disseny, el descart n'és l'altra meitat. - Posar la tupla del heap al revés (
(parada, distancia)):heapqcompara el primer camp — ordenaries alfabèticament per nom de parada, i el "greedy" consolidaria l'Estació Nord abans que el Parc del Riu sense mirar minuts. Prioritat SEMPRE primer. - Confondre "descoberta" amb "consolidada": la provisional d'una parada pot millorar diverses vegades (HC: ∞ → 10 → 9) mentre no estigui consolidada. Tractar la primera provisional com a definitiva és reinventar l'error dels pesos negatius sense necessitar-los.
- Consell: per depurar, imprimeix a cada volta la parada consolidada i la seva distància. Han de sortir en ordre no decreixent; si veus una reculada, tens pesos negatius o un bug — aquesta monotonia és la signatura de l'algorisme.
Exercicis
Exercici 1
Afegeix la parada "Terminal Sud" connectada amb "Universitat" (7 min) i amb "Hospital Central" (1 min). Traça dijkstra(xarxa, "Plaça Major") amb la taula de l'apartat 6: ordre de consolidació, distàncies finals i previa. En quin pas millora Terminal Sud la seva provisional?
Exercici 2
RutaBus vol el camí més curt entre dues parades concretes (origen → destí), no a totes. Modifica dijkstra perquè pugui parar abans: en quin moment exacte és segur retornar la resposta per a desti, i per què? (La justificació és el cor de la lliçó.)
Exercici 3
Un becari proposa arreglar els pesos negatius així: "sumo a tots els trams una constant K prou gran perquè cap no quedi negatiu, executo Dijkstra i llestos". Troba la fallada amb aquest contraexemple: A→B directe (pes 1) contra A→C→B (pesos −1 i 1), amb K = 2. Quin camí és realment el més curt i quin tria el Dijkstra "arreglat"?
Solucions
Solució 1
Amb TS connectat a UNI (7) i HC (1):
| Pas | Consolida | dist (PM, EN, HC, PR, UNI, TS) |
|---|---|---|
| 1 | PM (0) | 0, 4, ∞, 2, ∞, ∞ |
| 2 | PR (2) | 0, 4, 10, 2, 5, ∞ |
| 3 | EN (4) | 0, 4, 9, 2, 5, ∞ |
| 4 | UNI (5) | 0, 4, 9, 2, 5, 12 (5+7) |
| 5 | HC (9) | 0, 4, 9, 2, 5, 10 (9+1 millora el 12) |
| 6 | TS (10) | finals |
Terminal Sud millora al pas 5: descoberta via Universitat (12), corregida via Hospital Central (10). previa["Terminal Sud"] = "Hospital Central", i el camí és PM → EN → HC → TS (4+5+1 = 10). Fixa-t'hi: el veí "car" (HC a 9 min) va resultar millor porta que el "barat" (UNI a 5) — per això no es pot consolidar TS fins que li arriba el torn.
Solució 2
Es pot retornar dist[desti] en el moment en què desti es consolida (surt del heap i passa el filtre d'obsoletes):
parada = heapq.heappop(heap)[1] # (esquema)
...
consolidades.add(parada)
if parada == desti:
return dist[desti], previa # parada primerenca seguraJustificació: la consolidació és exactament la garantia que aquesta distància ja no pot millorar (argument de l'apartat 3, vàlid perquè els pesos són ≥ 0). Aturar-se abans — per exemple, la primera vegada que desti rep una provisional — seria incorrecte: HC va rebre 10 abans que 9 a la nostra traça. En el millor cas (destí proper) l'estalvi és enorme; en el pitjor (destí llunyà), es consolida tot igualment — millor/pitjor cas, 02-03.
Solució 3
Pesos reals: A→B = 1; A→C→B = −1 + 1 = 0 ← el més curt. Amb K = 2: A→B = 3; A→C→B = 1 + 3 = 4 → el Dijkstra "arreglat" tria A→B. La fallada: la constant K es cobra una vegada per tram, així que penalitza més els camins amb més trams — distorsiona la comparació entre camins de longitud diferent. Sumar K no trasllada el problema, el canvia per un altre. Els pesos negatius requereixen algorismes pensats per a ells, no maquillatge; Floyd-Warshall (propera lliçó) n'és un.
Conclusió
Dijkstra compleix el que es va prometre a 03-02: un greedy amb demostració. Mantenint distàncies provisionals i consolidant sempre la parada pendent més propera — amb la garantia, vàlida només amb pesos no negatius, que cap tomb no la pot millorar — calcula els camins mínims des d'un origen en O((V+E) log V) gràcies a la cua de prioritat de heapq, i el diccionari de predecessores reconstrueix els itineraris igual que reconstruíem la motxilla a 03-03. La traça des de la Plaça Major ens va ensenyar la seva signatura (consolidació en ordre de distància creixent, provisionals que es corregeixen pel camí) i el contraexemple final ens va ensenyar la seva frontera: un sol pes negatiu i la voracitat torna a ser el pecat que era a canvi_greedy. Però a RutaBus li queda una petició que Dijkstra només resol a força bruta: la web vol la matriu completa de temps entre totes les parades. Executem Dijkstra V vegades, o hi ha res de millor? La resposta és l'algorisme que vam anunciar a 03-03 com "programació dinàmica sobre grafs", que a més digereix pesos negatius sense parpellejar: Floyd-Warshall.
Curs d'Anàlisi i Disseny d'Algorismes
Mòdul 1: Introducció als Algorismes
Mòdul 2: Anàlisi d'Algorismes
- Anàlisi de Complexitat Temporal
- Anàlisi de Complexitat Espacial
- Casos de Complexitat: Millor, Pitjor i Mitjà
Mòdul 3: Estratègies de Disseny d'Algorismes
Mòdul 4: Algorismes Clàssics
- Cerca Binària
- Ordenació per Inserció
- Ordenació per Mescla (Merge Sort)
- Ordenació Ràpida (Quick Sort)
- Algorisme de Dijkstra
- Algorisme de Floyd-Warshall
