Aquesta és la lliçó on el curs cobra tots els seus deutes. Al mòdul 5 vam prometre que el patró "ja ho he vist?" amb set seria el visitats de BFS; al mòdul 6 vam dir que el recorregut per nivells amb deque "és un BFS que saltarà a grafs"; i des del mòdul 3 sabem que tota recursió amaga una pila. Aquí convergeixen les tres peces: BFS (cerca en amplada) i DFS (cerca en profunditat), els dos recorreguts fonamentals sobre grafs. Amb ells respondrem preguntes reals de TaskFlow: quines tasques es desbloquegen en acabar-ne una, si el projecte té cicles de dependències, i en quin ordre executar les tasques. Treballem sobre la classe Graf i el graf dependencies de la lliçó anterior.
Contingut
- Per què
visitatsara és imprescindible - BFS: cerca en amplada amb
deque - Traça de BFS sobre el graf de dependències
- DFS: cerca en profunditat, recursiva i iterativa
- Traça de DFS i comparació BFS/DFS
- Aplicació 1: què desbloqueja acabar una tasca? (abastables)
- Aplicació 2: detecció de cicles amb estats blanc/gris/negre
- Aplicació 3: ordre topològic (algorisme de Kahn)
- Components connexes
Per què visitats ara és imprescindible
Al recorregut per nivells d'un arbre (mòdul 6) no portàvem cap conjunt de visitats. No calia: en un arbre cada node té un sol pare, així que només s'hi pot arribar per un camí i és impossible encuar-lo dues vegades. Els grafs trenquen aquesta garantia dues vegades:
- Diversos pares: a
desplegar_apis'hi arriba des demigrar_bdi des deconfigurar_servidor. Sense control, la processaríem dues vegades (i tot el que hi ha darrere, quatre; creixement exponencial). - Cicles: a
A → B → C → A, un recorregut ingenu no acaba mai.
La solució és el patró del mòdul 5: un set anomenat visitats amb pertinença i inserció en O(1) mitjà. Regla d'or: marcar en encuar/apilar, no en desencuar — si esperes a marcar quan processes el vèrtex, pot colar-se dues vegades a la cua entremig.
BFS: cerca en amplada amb deque
BFS explora el graf per capes de distància: primer l'origen (distància 0), després els seus veïns (distància 1), després els veïns d'aquests (distància 2)... És literalment el recorregut per nivells del mòdul 6, amb deque com a cua (append per la dreta, popleft per l'esquerra, tots dos O(1), mòdul 4), més el visitats:
from collections import deque
def bfs(graf, origen):
"""Recorre el graf des d'origen per capes. Retorna l'ordre de visita."""
visitats = {origen} # marcat JA, abans d'entrar a la cua
cua = deque([origen])
ordre = []
while cua:
actual = cua.popleft() # FIFO: surt el més antic -> per capes
ordre.append(actual)
for vei in graf.veins(actual):
if vei not in visitats: # el "ja ho he vist?" del mòdul 5
visitats.add(vei) # marcar en encuar
cua.append(vei)
return ordreCada vèrtex entra a la cua com a molt una vegada i cada aresta s'examina una vegada: cost O(n + a) en temps i O(n) en espai. Compara-ho amb l'arbre del mòdul 6: el codi és gairebé idèntic; només hi hem afegit tres línies de visitats. Aquesta és la continuïtat promesa.
La propietat estrella de BFS: com que avança per capes, la primera vegada que arriba a un vèrtex ho fa per un camí amb el mínim nombre d'arestes. En grafs no ponderats, BFS és l'algorisme de camí més curt (amb pesos ja no n'hi haurà prou: lliçó 07-04).
Traça de BFS sobre el graf de dependències
Executem bfs(dependencies, "dissenyar_esquema") pas a pas:
graph LR
A[dissenyar_esquema] --> B[migrar_bd]
B --> C[desplegar_api]
S[configurar_servidor] --> C
C --> D[proves_integracio]
U[dissenyar_ui] --> I[implementar_ui]
I --> D
D --> L[llancament]
| Pas | Surt de la cua | Veïns nous que entren | Cua després del pas | Visitats |
|---|---|---|---|---|
| 1 | dissenyar_esquema | migrar_bd | [migrar_bd] | {d_e, mig} |
| 2 | migrar_bd | desplegar_api | [desplegar_api] | + api |
| 3 | desplegar_api | proves_integracio | [proves_integracio] | + pro |
| 4 | proves_integracio | llancament | [llancament] | + lla |
| 5 | llancament | — | [] | — |
Resultat: ['dissenyar_esquema', 'migrar_bd', 'desplegar_api', 'proves_integracio', 'llancament']. Dues observacions:
- BFS només visita allò abastable seguint les fletxes: ni
configurar_servidor, nidissenyar_ui, niimplementar_uino hi apareixen, perquè cap fletxa no porta dedissenyar_esquemafins a elles. En un graf dirigit, "abastable" depèn de l'origen. - Les capes són les distàncies:
migrar_bda 1 aresta,desplegar_apia 2, etc.
DFS: cerca en profunditat, recursiva i iterativa
DFS pren la decisió contrària: en lloc d'esgotar la capa actual, es llança per un camí fins al fons i només retrocedeix (backtracking) quan no pot continuar. Versió recursiva, on la "pila" és la pila de crides del mòdul 3:
def dfs_recursiu(graf, origen, visitats=None, ordre=None):
if visitats is None:
visitats, ordre = set(), []
visitats.add(origen)
ordre.append(origen)
for vei in graf.veins(origen):
if vei not in visitats:
dfs_recursiu(graf, vei, visitats, ordre) # baixar un nivell
return ordreI la versió iterativa, aplicant l'equivalència recursió ↔ pila explícita del mòdul 3 (útil quan el graf és profund i la pila de crides de Python, limitada a ~1000 nivells, es queda curta):
def dfs_iteratiu(graf, origen):
visitats = set()
pila = [origen] # una list com a pila: append/pop pel final
ordre = []
while pila:
actual = pila.pop() # LIFO: surt el més RECENT -> profunditat
if actual in visitats: # pot haver entrat dues vegades abans de sortir
continue
visitats.add(actual)
ordre.append(actual)
for vei in graf.veins(actual):
if vei not in visitats:
pila.append(vei)
return ordreCompara dfs_iteratiu amb bfs: són el mateix algorisme canviant l'estructura de suport. Cua (FIFO) → amplada; pila (LIFO) → profunditat. És la simetria pila↔DFS / cua↔BFS anunciada al mòdul 6, ara en codi real.
Traça de DFS i comparació BFS/DFS
dfs_recursiu(dependencies, "dissenyar_ui"): visita dissenyar_ui, baixa a implementar_ui, baixa a proves_integracio, baixa a llancament; sense veïns nous, desfà crides fins a l'origen. Resultat: ['dissenyar_ui', 'implementar_ui', 'proves_integracio', 'llancament'] — un camí en picat, davant de l'avanç per capes de BFS.
| Criteri | BFS | DFS |
|---|---|---|
| Estructura de suport | Cua (deque, mòdul 4) |
Pila (crides o list, mòdul 3) |
| Ordre d'exploració | Per capes de distància | Un camí fins al fons, després retrocedeix |
| Troba | El camí amb menys arestes | Exploració exhaustiva; camins, no necessàriament curts |
| Memòria en el pitjor cas | O(n) (una capa ampla) |
O(n) (un camí llarg) |
| Cost en temps | O(n + a) |
O(n + a) |
| Usos típics | Distàncies, "a N passos de", nivells | Cicles, ordre topològic, backtracking |
Aplicació 1: què desbloqueja acabar una tasca? (abastables)
Pregunta de TaskFlow: "si acabo migrar_bd, quines tasques s'acosten al seu desbloqueig?" Amb la nostra convenció (la fletxa apunta al que es desbloqueja), són exactament els vèrtexs abastables des d'ella — BFS i treure l'origen:
def tasques_afectades(graf, id_tasca):
"""Tasques que depenen, directament o transitivament, d'id_tasca."""
return bfs(graf, id_tasca)[1:] # tot l'abastable, menys ella mateixa
print(tasques_afectades(dependencies, "migrar_bd"))
# ['desplegar_api', 'proves_integracio', 'llancament']Endarrerir migrar_bd endarrereix potencialment aquestes tres tasques: acabem d'implementar l'anàlisi d'impacte d'un gestor de projectes en cinc línies.
Aplicació 2: detecció de cicles amb estats blanc/gris/negre
Al mòdul 2 vam detectar cicles en llistes enllaçades amb Floyd (dos punters a diferent velocitat). Aquella tècnica servia perquè en una llista només hi ha un camí possible; en un graf amb bifurcacions necessitem una altra idea, però el problema és el mateix: puc tornar a un lloc on ja sóc? La tècnica clàssica fa servir DFS amb tres estats per vèrtex:
- Blanc: encara no visitat.
- Gris: en procés — és al camí actual de la recursió (a la pila de crides).
- Negre: acabat — ell i tots els seus descendents, explorats.
La clau: si DFS troba una aresta cap a un vèrtex gris, ha trobat una aresta que torna al camí actual: un cicle. Arribar a un de negre no és cicle, només un encreuament de camins (diversos pares).
def hi_ha_cicle(graf):
BLANC, GRIS, NEGRE = 0, 1, 2
color = {v: BLANC for v in graf.vertexs()}
def visitar(v):
color[v] = GRIS
for vei in graf.veins(v):
if color[vei] == GRIS: # aresta cap al camí actual
return True
if color[vei] == BLANC and visitar(vei):
return True
color[v] = NEGRE # v tancat: res sota seu forma cicle
return False
# el graf pot no ser connex: cal intentar-ho des de cada vèrtex blanc
return any(color[v] == BLANC and visitar(v) for v in graf.vertexs())
print(hi_ha_cicle(dependencies)) # False: el nostre projecte és un DAG
# Trenquem-lo a propòsit: "dissenyar_ui depèn de proves_integracio"
dependencies.afegir_aresta("proves_integracio", "dissenyar_ui")
print(hi_ha_cicle(dependencies)) # True
dependencies.eliminar_aresta("proves_integracio", "dissenyar_ui") # ex. 2 de 07-02Això és exactament el que TaskFlow ha d'executar abans d'acceptar una nova dependència: si en afegir-la hi_ha_cicle retorna True, es rebutja i s'avisa l'usuari. Als exercicis de la 07-07 anirem més lluny: no només detectar el cicle, sinó llistar-lo per mostrar-l'hi.
Aplicació 3: ordre topològic (algorisme de Kahn)
La gran pregunta del projecte: en quin ordre executo les tasques de manera que cap no comenci abans que les seves dependències? Aquest ordre s'anomena ordre topològic i només existeix en DAGs. L'algorisme de Kahn el construeix amb dues peces que ja tenim: els graus_entrada() de la 07-02 i una cua:
- Calcula el grau d'entrada de cada vèrtex.
- Posa a la cua els de grau 0 (executables ja).
- Treu-ne un, afegeix-lo a l'ordre i "acaba'l": resta 1 al grau de cada veí. Si algun arriba a 0, cap a la cua.
- Repeteix fins a buidar la cua.
def ordre_topologic(graf):
graus = graf.graus_entrada()
cua = deque(v for v, g in graus.items() if g == 0)
ordre = []
while cua:
actual = cua.popleft()
ordre.append(actual)
for vei in graf.veins(actual):
graus[vei] -= 1 # una dependència menys
if graus[vei] == 0: # totes complertes: executable
cua.append(vei)
if len(ordre) < len(graus): # han quedat vèrtexs amb grau > 0
raise ValueError("Hi ha un cicle de dependències: no existeix cap ordre vàlid")
return ordre
print(ordre_topologic(dependencies))
# ['dissenyar_esquema', 'configurar_servidor', 'dissenyar_ui', 'migrar_bd',
# 'implementar_ui', 'desplegar_api', 'proves_integracio', 'llancament']Detalls importants:
- Aquí no hi ha
visitats: el mateix comptador de graus en fa el paper — un vèrtex només entra a la cua quan el seu grau arriba exactament a 0, i això passa una sola vegada. - La comprovació final regala una segona detecció de cicles: si hi ha cicle, els seus vèrtexs mai no baixen a grau 0 i l'ordre surt incomplet. Kahn detecta cicles "de propina", sense colors.
- L'ordre no és únic (els tres graus-0 inicials podien sortir en qualsevol ordre). A la 07-07 desempatarem per
prioritatsubstituint ladequepelheapqdel mòdul 4. - Cost:
O(n + a), com tot el d'avui.
Components connexes
Última eina: detectar les "illes" del graf. Per fer-ho ignorem el sentit de les fletxes (dues tasques estan relacionades si comparteixen dependències en qualsevol direcció) i llancem BFS des de cada vèrtex encara sense visitar; cada llançament descobreix una component sencera:
def components_connexes(graf):
# versió no dirigida del graf: cada aresta, en tots dos sentits
nd = Graf(dirigit=False)
for v in graf.vertexs():
nd.afegir_vertex(v)
for desti in graf.veins(v):
nd.afegir_aresta(v, desti)
visitats, components = set(), []
for v in nd.vertexs():
if v not in visitats:
comp = bfs(nd, v) # descobreix tota l'illa de v
visitats.update(comp)
components.append(comp)
return components
dependencies.afegir_aresta("escriure_blog", "publicar_blog") # mini-projecte a part
print(len(components_connexes(dependencies))) # 2: el projecte principal i el blogA TaskFlow, cada component és un subprojecte independent: es pot planificar, assignar i executar sense mirar les altres. Hi tornarem als exercicis (07-07) amb "illes de tasques".
Errors Comuns i Consells
- Marcar com a visitat en desencuar en lloc de en encuar. L'algorisme acaba igual, però un mateix vèrtex pot entrar diverses vegades a la cua i el cost es dispara. En BFS, marca en encuar; en DFS iteratiu, el
continuedesprés delpopcobreix el cas equivalent. - Fer servir una
listcom a cua en BFS (pop(0)): ésO(n)per extracció, com vam insistir al mòdul 4.deque.popleft()ésO(1). - Detectar cicles comprovant "veí ja visitat" amb un sol
set. En grafs dirigits, arribar a un vèrtex negre (acabat) per un altre camí no és un cicle, és un rombe de dependències perfectament legal. Sense l'estat gris, donaràs falsos positius. - Oblidar que el graf pot no ser connex.
hi_ha_cicleicomponents_connexesiteren sobre tots els vèrtexs; si només llances des d'un, les illes queden sense explorar. - DFS recursiu sobre grafs enormes: la pila de crides de Python s'esgota (~1000 nivells, mòdul 3). Per a grafs profunds, la versió iterativa.
- Consell: quan dubtis entre BFS i DFS, pregunta't què busques. Distàncies o "el més proper"? BFS. Cicles, ordres, explorar-ho tot sense que importi l'ordre? DFS sol ser més natural.
Exercicis
Exercici 1: BFS amb distàncies
Modifica bfs perquè retorni un dict vertex → distancia (nombre mínim d'arestes des de l'origen). Fes-lo servir per respondre: a quants "salts" de dissenyar_esquema és llancament?
Exercici 2: de què depèn aquesta tasca?
tasques_afectades mira cap endavant. Escriu prerequisits(graf, id_tasca) que retorni totes les tasques de les quals id_tasca depèn, directament o transitivament. Pista: o inverteixes el graf, o cerques des de cada vèrtex.
Exercici 3: traça de Kahn
Sense executar codi, traça l'ordre_topologic del graf de dependències en una taula (cua, extret, graus que canvien) i verifica que coincideix amb la sortida mostrada a la lliçó.
Solucions
Solució 1:
def bfs_distancies(graf, origen):
distancies = {origen: 0} # fa també de visitats
cua = deque([origen])
while cua:
actual = cua.popleft()
for vei in graf.veins(actual):
if vei not in distancies:
distancies[vei] = distancies[actual] + 1
cua.append(vei)
return distancies
print(bfs_distancies(dependencies, "dissenyar_esquema")["llancament"]) # 4El dict de distàncies substitueix el set de visitats: ser-hi ja significa "vist". llancament és a 4 salts, com a la traça.
Solució 2:
def invertir(graf):
inv = Graf(dirigit=True)
for v in graf.vertexs():
inv.afegir_vertex(v)
for desti, pes in graf.veins(v).items():
inv.afegir_aresta(desti, v, pes) # la fletxa, del revés
return inv
def prerequisits(graf, id_tasca):
return bfs(invertir(graf), id_tasca)[1:]
print(prerequisits(dependencies, "desplegar_api"))
# ['migrar_bd', 'configurar_servidor', 'dissenyar_esquema']Invertir el graf costa O(n + a) i converteix "qui depèn de mi?" en "de qui depenc?": el mateix BFS respon totes dues preguntes segons el sentit de les fletxes.
Solució 3:
| Pas | Cua abans | Extret | Graus que baixen | Nous grau-0 |
|---|---|---|---|---|
| 1 | [d_esquema, c_servidor, d_ui] | dissenyar_esquema | migrar_bd: 1→0 | migrar_bd |
| 2 | [c_servidor, d_ui, migrar_bd] | configurar_servidor | desplegar_api: 2→1 | — |
| 3 | [d_ui, migrar_bd] | dissenyar_ui | implementar_ui: 1→0 | implementar_ui |
| 4 | [migrar_bd, implementar_ui] | migrar_bd | desplegar_api: 1→0 | desplegar_api |
| 5 | [implementar_ui, desplegar_api] | implementar_ui | proves: 2→1 | — |
| 6 | [desplegar_api] | desplegar_api | proves: 1→0 | proves_integracio |
| 7 | [proves_integracio] | proves_integracio | llancament: 1→0 | llancament |
| 8 | [llancament] | llancament | — | — |
Ordre final: el de la lliçó. Observa com cap tasca no surt abans que totes les seves dependències hagin sortit: aquesta és la garantia de l'ordre topològic.
Conclusió
BFS i DFS són els dos motors d'exploració de grafs: mateixa maquinària, diferent estructura de suport (cua → capes i distàncies mínimes en arestes; pila → profunditat, cicles i ordres), tots dos en O(n + a) gràcies al visitats que el mòdul 5 va deixar preparat. Sobre ells hem construït les tres operacions que TaskFlow necessitava: anàlisi d'impacte (abastables), rebuig de dependències circulars (blanc/gris/negre, la germana graf de Floyd) i l'ordre d'execució del projecte (Kahn). Però BFS mesura camins en nombre d'arestes, i a la vida real les arestes no costen el mateix: passar per una tasca de 8 hores no és com passar per una d'1. Quan les arestes tenen pes, cal alguna cosa millor — i aquesta cosa, Dijkstra, reutilitza la cua de prioritat heapq del mòdul 4. És la propera lliçó.
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
