A la lliçó anterior vas conèixer el contracte del TAD pila: push, pop, peek, esta_buida i mida, totes amb cost O(1). Ara obrirem cada operació i la veurem funcionar pas a pas, amb traces de l'estat de la pila després de cada moviment, exactament com faries en depurar. A més prendrem una decisió de disseny que tot autor d'estructures ha d'afrontar (què fer quan algú fa pop sobre una pila buida?), formularem els invariants que tota pila ha de complir, i aplicarem tot això al desfer de TaskFlow. Per practicar sense esperar la implementació completa (lliçó 03-03), farem servir la list de Python com a pila provisional. Dominar la mecànica fina d'aquestes operacions és el que et permetrà després implementar-les i, sobretot, raonar amb seguretat sobre qualsevol algorisme que faci servir piles.
Contingut
push: apilar pas a paspop: desapilar pas a paspeek: mirar sense tocaresta_buidaimida: les consultes d'estat- El cas de la pila buida: excepció vs
None - Invariants del TAD pila
- Seqüències d'operacions: el desfer de TaskFlow
- Minipràctica guiada:
listcom a pila provisional
push: apilar pas a pas
push(element) col·loca element al cim. És l'única manera d'entrar en una pila. Vegem una traça partint d'una pila buida, apilant accions de l'historial de TaskFlow (dibuixem la pila en vertical, amb el cim a dalt, tal com creix de debò):
| Operació | Estat de la pila (cim a dalt) | mida() |
|---|---|---|
| (inici) | (buida) | 0 |
push("crear tasca 7") |
crear tasca 7 |
1 |
push("canviar prioritat") |
canviar prioritat ← cimcrear tasca 7 |
2 |
push("marcar en curs") |
marcar en curs ← cimcanviar prioritatcrear tasca 7 |
3 |
Punts clau:
- Cada
pushtapa l'element anterior:crear tasca 7continua allà, però ja no és accessible fins que es retirin els de sobre. pushmai no falla per estat de la pila: una pila (conceptual) no té límit de capacitat. (Existeixen piles acotades —ho veurem en un exercici de 03-05 amb el límit d'historial— però no formen part del contracte base.)pushno retorna res: el seu efecte és el canvi d'estat.
En diagrama, els dos primers passos:
flowchart LR
subgraph P1["Després de push(crear tasca 7)"]
direction TB
a1["crear tasca 7 ← cim"]
end
subgraph P2["Després de push(canviar prioritat)"]
direction TB
b2["canviar prioritat ← cim"]
b1["crear tasca 7"]
b2 --- b1
end
P1 -->|"push(canviar prioritat)"| P2
pop: desapilar pas a pas
pop() fa dues coses en una: retira l'element del cim i el retorna. Continuem la traça anterior (la pila tenia 3 elements):
| Operació | Retorna | Estat de la pila (cim a dalt) | mida() |
|---|---|---|---|
| (estat previ) | — | marcar en curscanviar prioritatcrear tasca 7 |
3 |
pop() |
"marcar en curs" |
canviar prioritatcrear tasca 7 |
2 |
pop() |
"canviar prioritat" |
crear tasca 7 |
1 |
pop() |
"crear tasca 7" |
(buida) | 0 |
pop() |
? | (buida) | 0 |
Observa dues coses:
- Els elements surten en ordre invers al d'entrada: van entrar 7→prioritat→en curs i van sortir en curs→prioritat→7. Aquesta és l'essència LIFO, i és exactament l'ordre que necessita un desfer.
- El quart
pop()és un problema: la pila és buida i no hi ha res a retornar. Aquest "?" mereix la seva pròpia secció (la número 5).
Un patró habitual és buidar una pila processant cada element:
Aquest bucle processa els elements en ordre invers al d'inserció. Memoritza el patró: el farem servir una vegada i una altra (desfer-ho tot, invertir seqüències, avaluar expressions...).
peek: mirar sense tocar
peek() retorna l'element del cim sense retirar-lo. La pila queda exactament igual:
| Operació | Retorna | Estat després de l'operació |
|---|---|---|
| (estat previ) | — | canviar prioritatcrear tasca 7 |
peek() |
"canviar prioritat" |
canviar prioritatcrear tasca 7 (idèntic) |
peek() |
"canviar prioritat" |
(idèntic: peek és repetible) |
pop() |
"canviar prioritat" |
crear tasca 7 |
Per a què serveix si pop ja retorna el cim? Per decidir abans d'actuar. Dos usos típics a TaskFlow:
- La interfície vol mostrar "Desfer: canviar prioritat" al botó. Necessita llegir la propera acció sense desfer-la:
peek. - Un algorisme vol desapilar només si el cim compleix una condició (ho veuràs a la conversió d'expressions de 03-04): primer
peek, es comprova la condició, i només llavorspop.
Regla d'or: si després de cridar una operació de consulta la pila ha canviat, aquesta operació està mal implementada. peek, esta_buida i mida són observadores; push i pop són modificadores.
esta_buida i mida: les consultes d'estat
esta_buida()retornaTruesimida() == 0. És la guarda natural abans de qualsevolpopopeek, i la condició d'aturada del bucle de buidatge.mida()retorna el nombre d'elements. Perquè sigui O(1), la implementació mantindrà un comptador que s'incrementa a cadapushi es decrementa a cadapop— la mateixa tècnica de l'atributmidadeLlistaEnllacadaal mòdul 2, on comptar recorrent hauria costat O(n).
Encara que semblin trivials, aquestes dues operacions són les que fan segur l'ús de la pila: gairebé tot bug amb piles és un pop sense comprovar esta_buida abans.
El cas de la pila buida: excepció vs None
Què ha de fer pop() (o peek()) sobre una pila buida? És una decisió de disseny, i les dues opcions raonables tenen conseqüències diferents:
| Estratègia | Comportament | Avantatges | Inconvenients |
|---|---|---|---|
| Llançar excepció | raise IndexError("pop sobre una pila buida") |
L'error no passa desapercebut; obliga el cridador a pensar; és el que fa list.pop() de Python |
Requereix try/except o comprovació prèvia |
Retornar None |
return None |
Codi cridador més curt | Bug silenciós: si algú apila None com a valor legítim, no pots distingir "pila buida" de "el cim era None"; l'error explota lluny de la seva causa |
En aquest curs adoptem l'excepció, per dos motius:
- Coherència amb Python:
[].pop()llançaIndexError. La nostra pila es comportarà com la resta del llenguatge. - Fallar aviat i fort: un
Noneinesperat pot viatjar per mig programa abans de trencar alguna cosa, i llavors el traceback no apunta a la causa. Una excepció alpopculpable apunta exactament a l'error.
El cridador té llavors dos estils correctes, tots dos vàlids:
# Estil 1: mirar abans de saltar (LBYL, "Look Before You Leap")
if not historial_buit():
accio = desapilar()
revertir(accio)
else:
print("Res a desfer")
# Estil 2: demanar perdo en comptes de permis (EAFP, l'estil idiomatic a Python)
try:
accio = desapilar()
revertir(accio)
except IndexError:
print("Res a desfer")L'important no és quin estil triïs, sinó que la decisió "excepció, no None" quedi escrita al contracte i tots els usuaris de la pila puguin confiar-hi.
Invariants del TAD pila
Un invariant és una propietat que es compleix sempre, abans i després de cada operació (ja vam raonar així al mòdul 2, quan manteníem cap, cua i mida coherents després de cada inserció). Els invariants de la pila són el "control de qualitat" de qualsevol implementació:
mida() >= 0en tot moment; iesta_buida()equival amida() == 0.- Després de
push(x):peek()retornax, imida()ha crescut exactament en 1. pop()després depush(x)(sense operacions intermèdies) retornaxi deixa la pila exactament com estava abans delpush. És a dir:popdesfàpush.peek()no modifica res:mida()i el contingut són idèntics abans i després.- Ordre LIFO global: si s'apilen n elements i després es desapilen n, surten en ordre exactament invers.
Aquests invariants són directament traduïbles a tests. Quan implementem les dues piles de la lliçó 03-03, qualsevol d'elles ha de passar exactament les mateixes comprovacions: aquesta és la prova pràctica que el TAD és independent de la implementació.
Seqüències d'operacions: el desfer de TaskFlow
Juguem una sessió realista de TaskFlow, encara amb pseudocodi del contracte. Cada acció de l'usuari es registra amb push; cada pulsació de Desfer executa pop i reverteix. Seguim la tasca {"id": 7, "titol": "Revisar el pressupost", "prioritat": 2, "estat": "pendent"}:
| # | L'usuari... | Operació sobre la pila | Pila d'historial (cim a dalt) |
|---|---|---|---|
| 1 | Crea la tasca 7 | push(crear id=7) |
crear 7 |
| 2 | Puja prioritat 2→1 | push(prioritat 7: 2→1) |
prioritat 2→1crear 7 |
| 3 | Marca en curs | push(estat 7: pendent→en curs) |
estat →en cursprioritat 2→1crear 7 |
| 4 | Prem Desfer | pop() → reverteix l'estat a pendent |
prioritat 2→1crear 7 |
| 5 | Assigna a l'Anna | push(assignar 7 a anna) |
assignar annaprioritat 2→1crear 7 |
| 6 | Prem Desfer | pop() → treu l'assignació |
prioritat 2→1crear 7 |
| 7 | Prem Desfer | pop() → la prioritat torna a 2 |
crear 7 |
| 8 | Prem Desfer | pop() → s'elimina la tasca 7 |
(buida) |
| 9 | Prem Desfer | esta_buida() és True → botó inactiu, no es crida pop |
(buida) |
Dues observacions importants:
- Al pas 4 es va desfer l'estat i al 5 l'usuari va fer una cosa nova. L'acció desfeta no "torna": la pila només registra el que és vigent. (I si la volgués refer? Necessitaríem una segona pila; aquesta és exactament la construcció desfer/refer de la lliçó 03-04.)
- Cada acció apilada porta la informació necessària per revertir-la (el valor previ:
2→1,pendent→en curs). A 03-03 formalitzarem això amb diccionaris{"tipus": ..., "tasca": ..., "dades_previes": ...}.
Minipràctica guiada: list com a pila provisional
Encara no tenim la nostra classe Pila (arriba a 03-03), però la list de Python pot exercir de pila provisional, perquè les seves operacions pel final són les adequades:
llista.append(x)→ fa depush→ O(1) amortitzat.llista.pop()(sense argument) → fa depop→ O(1).llista[-1]→ fa depeek→ O(1).len(llista) == 0→ fa d'esta_buida→ O(1).
Per què pel FINAL i no pel principi? Recorda la trampa del mòdul 1: insert(0, x) i pop(0) són O(n), perquè una list és un array dinàmic i tocar el principi obliga a desplaçar tots els elements. El cim de la nostra pila provisional ha de ser el final de la llista, on l'array treballa en O(1). Una pila feta amb insert(0)/pop(0) "funciona" però degrada totes les operacions a O(n): trencaria la taula de costos del contracte.
Obre un intèrpret i reprodueix la sessió de TaskFlow de la secció anterior:
historial = [] # pila buida (provisional, sobre list)
# L'usuari treballa: cada accio s'apila amb append (= push)
historial.append("crear tasca 7")
historial.append("prioritat 7: 2 -> 1")
historial.append("estat 7: pendent -> en curs")
print(len(historial)) # 3 (= mida)
print(historial[-1]) # 'estat 7: pendent -> en curs' (= peek: sense retirar)
# Prem Desfer: pop() retira i retorna el cim
accio = historial.pop()
print(f"Desfent: {accio}") # Desfent: estat 7: pendent -> en curs
print(historial[-1]) # 'prioritat 7: 2 -> 1' (nou cim)
# Desfer-ho tot amb el patro de buidatge
while len(historial) > 0: # (= not esta_buida)
print(f"Desfent: {historial.pop()}")
# Desfent: prioritat 7: 2 -> 1
# Desfent: crear tasca 7
historial.pop() # IndexError: pop from empty listExplicació línia a línia:
historial = []crea la pila buida. La llista buida és la nostra pila buida.- Els tres
appendapilen en ordre; després d'ells, el cim (final de la llista) és l'acció més recent. historial[-1]llegeix el cim sense retirar-lo: és el nostrepeek. Compte: sobre una llista buida,[-1]també llançaIndexError, coherent amb la nostra decisió de disseny.historial.pop()sense argument retira del final: LIFO garantit i O(1).- El bucle
whileés el patró de buidatge: imprimeix les accions en ordre invers al que van passar, que és justament l'ordre correcte d'un "desfer-ho tot". - L'últim
pop()sobre la llista buida llançaIndexError: Python ja implementa l'estratègia d'excepció que hem triat.
Aquesta pila provisional és totalment funcional, però té un defecte: no protegeix el contracte. Res no impedeix que un altre programador faci historial.insert(0, x) o historial[3] i trenqui la disciplina LIFO. Encapsular la llista dins d'una classe que només exposi les cinc operacions és precisament la feina de la propera lliçó.
Errors Comuns i Consells
- Fer
popsense comprovar abans (o sensetry/except): el clàssicIndexErroren producció. Tota crida apop/peekha d'estar protegida peresta_buida()o per unexcept IndexError. - Fer servir
pop(0)oinsert(0, x)sobre lalist-pila: funciona, però converteix O(1) en O(n). El cim viu al final de la llista. Si dubtes, torna a la taula de costos delistdel mòdul 1. - Fer servir
popquan només volies consultar: si després de "mirar" el cim el necessites de nou, has destruït informació. Consulta =peek(llista[-1]); extracció =pop. - Retornar
Noneen pila buida "per simplificar": acabes ambif resultat is not Noneescampats per tot el codi i bugs quanNoneés un valor vàlid. Excepció i punt. - Oblidar que
popretorna l'element: escriurepila.pop()i després intentar llegir el cim ambpeekper saber "què s'ha desapilat" — ja és tard, era el valor de retorn del mateixpop. - Consell: quan escriguis o depuris seqüències d'operacions, dibuixa la traça en una taula com les d'aquesta lliçó (operació → retorna → estat). Dos minuts de taula estalvien vint de depurador.
Exercicis
Exercici 1: traça completa
Partint d'una pila buida, construeix la taula de traça (operació, valor retornat, estat de la pila, mida) d'aquesta seqüència:
Indica en quin pas (si n'hi ha) es llança IndexError segons la nostra decisió de disseny.
Exercici 2: el "desfer-ho tot" segur
Fent servir una list com a pila provisional, escriu una funció desfer_tot(historial) que rebi la pila d'accions (llista de cadenes) i retorni una llista amb els missatges "Desfent: <accio>" en l'ordre correcte de desfer, deixant la pila buida. La funció no ha de fallar mai, ni tan sols si rep la pila ja buida. Escriu-ne dues versions: una amb estil LBYL (comprovar abans) i una altra amb estil EAFP (try/except).
Exercici 3: detectar la implementació trencada
Un company ha escrit aquestes "operacions de pila" sobre list. Assenyala tots els errors respecte al contracte i als invariants d'aquesta lliçó, i corregeix-los:
def push(pila, element):
pila.insert(0, element)
def pop(pila):
if len(pila) == 0:
return None
return pila.pop(0)
def peek(pila):
return pila.pop(0)Solucions
Solució 1:
| Pas | Operació | Retorna | Pila (cim a dalt) | Mida |
|---|---|---|---|---|
| 1 | push(10) |
— | 10 |
1 |
| 2 | push(20) |
— | 20, 10 |
2 |
| 3 | peek() |
20 |
20, 10 |
2 |
| 4 | push(30) |
— | 30, 20, 10 |
3 |
| 5 | pop() |
30 |
20, 10 |
2 |
| 6 | pop() |
20 |
10 |
1 |
| 7 | push(40) |
— | 40, 10 |
2 |
| 8 | peek() |
40 |
40, 10 |
2 |
| 9 | pop() |
40 |
10 |
1 |
| 10 | pop() |
10 |
(buida) | 0 |
| 11 | pop() |
IndexError |
(buida) | 0 |
El pas 11 llança IndexError: la pila és buida i hem triat excepció, no None.
Solució 2:
# Versio LBYL: comprovar abans de desapilar
def desfer_tot(historial):
missatges = []
while len(historial) > 0: # guarda: nomes pop si no es buida
accio = historial.pop() # retira el cim (la mes recent)
missatges.append(f"Desfent: {accio}")
return missatges
# Versio EAFP: intentar i capturar l'excepcio
def desfer_tot_eafp(historial):
missatges = []
while True:
try:
accio = historial.pop()
except IndexError: # pila esgotada: acabem
break
missatges.append(f"Desfent: {accio}")
return missatgesTotes dues retornen els missatges en ordre invers a la inserció (l'ordre correcte de desfer) i deixen historial buida. Amb una pila ja buida, el while de la primera no arriba a executar-se i el try de la segona trenca el bucle a la primera volta: cap de les dues no falla.
Solució 3: hi ha quatre errors.
pushfa servirinsert(0, ...): apila pel principi de la llista → O(n) per desplaçament de l'array. Trenca el cost del contracte.popfa servirpop(0): mateix problema, O(n). (Curiosament el conjunt "inserir i esborrar pel principi" manté l'ordre LIFO, així que funciona... lentíssim. El bug de rendiment és el més traïdor perquè els tests de correcció no l'atrapen.)popretornaNoneen pila buida: contradiu la decisió de disseny; ha de deixar que l'excepció salti (o llançar-la explícitament).peekfapop(0): retira l'element! Viola l'invariant 4 (peek no modifica). Ha de ser només lectura.
Versió corregida:
def push(pila, element):
pila.append(element) # cim = final de la llista: O(1)
def pop(pila):
return pila.pop() # retira del final: O(1); IndexError si es buida
def peek(pila):
return pila[-1] # nomes lectura: O(1); IndexError si es buidaConclusió
Ja domines la mecànica completa del TAD pila: push tapa el cim anterior, pop retira i retorna en un sol gest, peek observa sense modificar, i esta_buida/mida fan segura tota la resta. Has pres una decisió de disseny raonada (excepció IndexError en pila buida, com fa la mateixa list de Python), has formulat els invariants que qualsevol implementació haurà de complir, i has traçat sessions reals del desfer de TaskFlow operació a operació. A més ja tens una pila provisional funcionant amb list —sempre pel final, mai pel principi— encara que li falta l'essencial: una frontera que impedeixi saltar-se el contracte. A la propera lliçó construirem aquesta frontera dues vegades: una classe Pila sobre list i una PilaEnllacada sobre els nodes del mòdul 2, mesurarem totes dues amb timeit i muntarem l'HistorialAccions real de TaskFlow.
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
