En tancar el mòdul anterior vam deixar una promesa a l'aire: existeix "una altra aposta" diferent de la de l'array, una estructura que renuncia a la contigüitat en memòria precisament per fer barates les insercions i els esborrats allà on l'array fluixeja. Aquesta aposta és la llista enllaçada, i aquest mòdul sencer està dedicat a ella i a les seves variants. Però abans d'escriure ni una sola classe hem de fer un pas enrere i repetir el que vam fer amb la pila a la lliçó 01-01: separar el què (el TAD llista, un contracte d'operacions) del com (array contigu o nodes dispersos). En aquesta lliçó definim aquest contracte, entenem la peça fonamental de la nova aposta —el node amb la seva referència seguent— i posem cara a cara els costos de les dues implementacions, per decidir amb criteri quina convé a cada racó de TaskFlow.
Contingut
- El TAD llista: un contracte, dues implementacions
- L'aposta de l'array: contigüitat (repàs estratègic)
- L'altra aposta: nodes dispersos i referències
- Anatomia d'un node en Python
- Taula comparativa de costos: array dinàmic vs llista enllaçada
- Quina convé a TaskFlow?
El TAD llista: un contracte, dues implementacions
A la lliçó 01-01 vam veure que un Tipus Abstracte de Dades (TAD) defineix quines operacions ofereix una estructura sense comprometre's amb com s'implementen per dins: les nostres classes PilaAmbLlista i PilaAmbDiccionari complien el mateix contracte amb interiors completament diferents. Amb les llistes passa exactament el mateix.
El TAD llista és una seqüència ordenada d'elements on cada element ocupa una posició, i que ofereix com a mínim aquestes operacions:
- Inserir un element (al principi, al final o en una posició intermèdia).
- Esborrar un element (per posició o per valor).
- Cercar un element (hi és? a quina posició?).
- Recórrer la seqüència de principi a fi, visitant cada element en ordre.
- Consultar l'element d'una posició donada i conèixer la longitud de la seqüència.
Fixa't que el contracte no diu ni una paraula sobre memòria, caselles o referències. I això és deliberat, perquè hi ha (almenys) dues maneres radicalment diferents de complir-lo:
| Implementació A | Implementació B | |
|---|---|---|
| Nom | Array dinàmic (la list de Python) |
Llista enllaçada (la construirem a 02-02) |
| Estratègia | Elements contigus en memòria | Nodes dispersos, units per referències |
| Com troba l'element i | Fórmula base + i × mida |
Saltant de node en node des del primer |
| El seu punt fort | Accés per índex instantani | Inserir/esborrar sense desplaçar ningú |
Una advertència terminològica important: en Python, la paraula list anomena la implementació A. És un nom una mica desafortunat, perquè en la literatura d'estructures de dades "llista" sol referir-se al TAD, i "llista enllaçada" (linked list) a la implementació B. En aquest curs direm list de Python o array dinàmic per a la A, i llista enllaçada per a la B.
L'aposta de l'array: contigüitat (repàs estratègic)
No repetirem la lliçó 01-05, però sí que la condensarem en allò que ens importa per a la comparació. L'array ho aposta tot a la contigüitat:
- Guanya: accés per índex O(1) gràcies a la fórmula
base + i × mida; recorreguts molt ràpids (la memòria contigua és amiga de la memòria cau del processador);appendO(1) amortitzat. - Perd: inserir o esborrar al principi o al mig obliga a desplaçar tots els elements posteriors, un cost O(n) que vam mesurar amb
timeiti que es tornava quadràtic en repetir-lo en bucle (la trampa deinsert(0)que vam veure en tancar el mòdul 1).
La pregunta que queda flotant és: i si poguéssim inserir un element al mig de la seqüència sense tocar els altres? Per aconseguir-ho cal abandonar la idea que la posició en memòria codifica la posició en la seqüència.
L'altra aposta: nodes dispersos i referències
La llista enllaçada inverteix l'aposta. En lloc d'exigir que els elements visquin en caselles consecutives, permet que cadascun visqui on vulgui dins la memòria. Però llavors sorgeix un problema: si els elements estan dispersos, com sabem quin va després de quin? La fórmula de l'array ja no serveix, perquè no hi ha contigüitat per explotar.
La solució és que cada element porti amb ell l'adreça del següent. A aquest paquet —la dada més la referència al següent— l'anomenem node:
graph LR
subgraph "Llista enllaçada: l'ordre el dicten les fletxes, no la memòria"
A["dada: tasca 1<br>seguent ─→"] --> B["dada: tasca 2<br>seguent ─→"]
B --> C["dada: tasca 3<br>seguent: None"]
end
H[cap] --> A
Tres observacions clau sobre aquest diagrama:
- La llista només necessita "agafar" el primer node (l'anomenarem el cap, en anglès head). A partir d'ell, les fletxes permeten arribar a tots els altres.
- L'últim node apunta a
None: és el senyal de "aquí s'acaba la seqüència". - Les fletxes són la seqüència. Si en memòria el node de la tasca 3 fos físicament abans que el de la tasca 1, tant se valdria: l'ordre lògic el defineixen les referències, no les adreces.
I aquí hi ha la jugada mestra. Per inserir una tasca nova entre la 1 i la 2, no cal desplaçar res: n'hi ha prou amb recablejar dues fletxes.
graph LR
H[cap] --> A["tasca 1"]
A -. "fletxa antiga (s'elimina)" .-> B["tasca 2"]
A -- "1: ara apunta a la nova" --> N["tasca nova"]
N -- "2: la nova apunta a la 2" --> B
B --> C["tasca 3<br>seguent: None"]
Tant és que la llista tingui 3 elements o 3 milions: el recablejat en si són dues assignacions, cost O(1). Aquesta és l'essència de la promesa "insercions i esborrats barats". (Compte: arribar fins al punt d'inserció pot tenir el seu propi cost; ho analitzarem amb rigor a la propera lliçó. No et volem vendre la moto sense la lletra petita.)
Anatomia d'un node en Python
En Python no manipulem adreces de memòria a mà: manipulem referències, que és exactament el que passa cada cop que assignes un objecte a una variable. Un node és simplement un objecte amb dos atributs:
class Node:
"""Una peça de la llista enllaçada: una dada i la referència al següent."""
def __init__(self, dada):
self.dada = dada # el contingut: a TaskFlow, una tasca (dict)
self.seguent = None # referència al pròxim node (None = no n'hi ha més)Explicació detallada per a qui ho veu per primera vegada:
self.dadaguarda l'element en si. Com que Python treballa amb referències, aquí hi pot viure qualsevol cosa: un nombre, una cadena o —en el nostre cas— la tasca-com-a-dictque vam fixar al mòdul 1.self.seguentguarda una altra referència, aquest cop a un altre objecteNode(oNonesi és l'últim). No hi ha màgia: és la mateixa mecànica d'assignació que fas servir cada dia, posada al servei d'encadenar objectes.
Podem encadenar tres tasques de TaskFlow a mà, sense cap classe contenidora encara:
# Tres tasques de TaskFlow (el dict fixat al mòdul 1)
t1 = {"id": 1, "titol": "Dissenyar el logo", "prioritat": 2, "estat": "pendent"}
t2 = {"id": 2, "titol": "Configurar el servidor", "prioritat": 1, "estat": "pendent"}
t3 = {"id": 3, "titol": "Escriure la documentació", "prioritat": 3, "estat": "pendent"}
# Creem els nodes i els enllacem a mà
cap = Node(t1)
cap.seguent = Node(t2)
cap.seguent.seguent = Node(t3)
# Recorrem seguint les fletxes fins a trobar None
actual = cap
while actual is not None:
print(actual.dada["titol"])
actual = actual.seguent # "avançar" = seguir la referènciaSortida:
El bucle while de dalt és el gest més important de tot el mòdul: avançar per una llista enllaçada és reassignar actual = actual.seguent fins a topar amb None. Tot el que construirem a les properes lliçons —inserir, esborrar, cercar— són variacions d'aquest gest.
Encadenar nodes a mà és instructiu però impracticable. A 02-02 encapsularem tota aquesta mecànica en una classe LlistaEnllacada amb les operacions del TAD; avui en tenim prou d'entendre la peça.
Taula comparativa de costos: array dinàmic vs llista enllaçada
Aquesta taula és el mapa del mòdul. La columna de l'array la vam demostrar a 01-05; la columna de la llista enllaçada és, de moment, un avançament que la lliçó 02-02 demostrarà operació per operació (i verificarà amb timeit, com manen els nostres costums):
| Operació | Array dinàmic (list) |
Llista enllaçada simple | Per què? |
|---|---|---|---|
Accés per índex [i] |
O(1) | O(n) | Fórmula directa vs caminar i salts des del cap |
| Inserir al principi | O(n) | O(1) | Desplaçar-ho tot vs recablejar el cap |
| Inserir al final | O(1) amortitzat | O(n) — o O(1) amb truc* | append amb forat vs caminar fins a l'últim node |
| Inserir al mig (ja situats) | O(n) | O(1) | Desplaçar la meitat vs recablejar dues fletxes |
| Esborrar al principi | O(n) | O(1) | La trampa de pop(0) vs moure el cap |
| Cercar per valor | O(n) | O(n) | En totes dues cal mirar element a element |
| Recórrer sencera | O(n) | O(n) | Totes dues ho visiten tot, però l'array és més amic de la memòria cau |
| Memòria per element | Només la referència a la dada | Referència a la dada + referència seguent |
El node paga un extra per cada fletxa |
* El "truc" consisteix que la llista guardi també una referència a l'últim node (la cua); el veurem a 02-02.
Lectures importants de la taula:
- No hi ha guanyador absolut. Cada estructura guanya just on l'altra perd: és un intercanvi (trade-off), el pa de cada dia en estructures de dades.
- La llista enllaçada perd el superpoder de l'array: ja no hi ha cap fórmula que porti a l'element
id'un salt. Demanar "l'element 500.000" vol dir fer 500.000 salts. - Cercar per valor és O(n) en totes dues: cap de les dues no resol el problema de "trobar la tasca amb id 42" que vam plantejar a 01-02. Per a això continuarà fent falta l'índex per id que arribarà al mòdul 5.
- La llista enllaçada paga un sobrecost de memòria: cada dada carrega amb una fletxa extra. La dispersió no surt gratis.
Quina convé a TaskFlow?
Baixem la taula a terra amb els escenaris reals de la nostra aplicació:
| Escenari a TaskFlow | Operació dominant | Estructura guanyadora |
|---|---|---|
| Mostrar el tauler complet en pantalla | Recórrer | Empat (lleuger avantatge de l'array per memòria cau) |
| "Dona'm la tasca de la posició 7 de la vista" | Accés per índex | Array |
| Les tasques urgents entren sempre per davant | Inserir al principi | Llista enllaçada |
| Despatxar tasques retirant-les per davant | Esborrar al principi | Llista enllaçada |
| Reordenar: moure una tasca entre dues altres | Inserir/esborrar al mig | Llista enllaçada (si ja hi estem situats) |
| Afegir tasques sempre pel final | Inserir al final | Array (append amortitzat) o enllaçada amb cua |
La conclusió per a TaskFlow és matisada, com gairebé tot en enginyeria: mentre el tauler només creixia pel final i es llegia en ordre, la list de Python era imbatible. Però el tauler real d'un equip no funciona així: les urgències entren per davant, les tasques es despatxen per davant i es reordenen pel mig constantment. Aquest patró d'ús —moltes insercions i esborrats en posicions incòmodes— és exactament el terreny on la llista enllaçada brilla, i per això l'escollim com a base del tauler definitiu.
Errors Comuns i Consells
- Confondre la
listde Python amb una llista enllaçada. És l'error terminològic número u. Lalistde Python és un array dinàmic (lliçó 01-05); si en una entrevista et demanen "implementa una llista enllaçada", respondrellista = []és respondre una altra pregunta. - Creure que la llista enllaçada és "millor" que l'array. No ho és; és millor per a certes operacions i pitjor per a d'altres. Qui memoritza "llista enllaçada = ràpida" sense la taula de costos acaba triant malament. Guarda la taula d'aquesta lliçó al costat de la de costos de
list/dict/setde 01-04. - Oblidar que
seguentpot serNone. Escriureactual.seguent.dadasense comprovar abans queactual.seguentno ésNoneprovoca el clàssicAttributeError: 'NoneType' object has no attribute 'dada'. Acostuma't des d'ara a preguntar-te a cada línia: "i si aquí ja no hi ha node?". - Perdre el cap de la llista. Si reassignes la variable que apunta al primer node (
cap = cap.seguentsense voler), el node anterior es torna inabastable i el recol·lector de brossa de Python l'elimina. El cap és l'únic fil del qual penja tota l'estructura: tracta'l amb respecte. - Consell: quan dubtis d'un recablejat de referències, dibuixa els nodes i les fletxes en paper abans d'escriure codi. És la tècnica que fan servir fins i tot els enginyers veterans, i a les lliçons següents la practicarem amb diagrames mermaid a cada pas.
Exercicis
Exercici 1 — El contracte i les seves implementacions. Sense mirar la taula, classifica aquestes quatre frases com a pròpies del TAD llista, de l'array dinàmic o de la llista enllaçada: (a) "inserir un element a la posició i"; (b) "els elements ocupen caselles contigües de memòria"; (c) "cada element guarda una referència al següent"; (d) "recórrer els elements en ordre". Justifica cada resposta en una línia.
Exercici 2 — Encadenar i recórrer a mà. Fent servir només la classe Node d'aquesta lliçó (sense cap classe contenidora), construeix una cadena amb aquestes quatre tasques de TaskFlow: "Revisar el disseny" (id 10), "Corregir el bug del login" (id 11), "Desplegar la versió" (id 12) i "Tancar l'esprint" (id 13), totes amb prioritat 2 i estat "pendent". Després escriu: (a) un bucle que imprimeixi id - titol de cada tasca; (b) un bucle que compti quants nodes hi ha, sense fer servir len de cap estructura auxiliar.
Exercici 3 — Recablejar sense desplaçar. Partint de la cadena de l'exercici 2, insereix la tasca "Hotfix de producció" (id 14, prioritat 1) entre "Corregir el bug del login" i "Desplegar la versió", sense crear cap cadena nova: només creant un node i reassignant les referències necessàries. Quantes assignacions de seguent has necessitat? Dependria aquest nombre que la cadena tingués un milió de nodes?
Solucions
Solució 1:
- (a) TAD llista: descriu una operació del contracte, sense dir com s'aconsegueix per dins.
- (b) Array dinàmic: la contigüitat és la decisió d'implementació que dona l'accés O(1) per fórmula.
- (c) Llista enllaçada: la referència al següent és la decisió d'implementació que permet la dispersió.
- (d) TAD llista: recórrer forma part del contracte; totes dues implementacions ho ofereixen (amb interiors diferents).
Solució 2:
class Node:
def __init__(self, dada):
self.dada = dada
self.seguent = None
def tasca(id_, titol):
return {"id": id_, "titol": titol, "prioritat": 2, "estat": "pendent"}
# (Construcció) Creem els nodes i els enllacem un a un
cap = Node(tasca(10, "Revisar el disseny"))
cap.seguent = Node(tasca(11, "Corregir el bug del login"))
cap.seguent.seguent = Node(tasca(12, "Desplegar la versió"))
cap.seguent.seguent.seguent = Node(tasca(13, "Tancar l'esprint"))
# (a) Imprimir id - titol seguint les fletxes
actual = cap
while actual is not None:
print(f'{actual.dada["id"]} - {actual.dada["titol"]}')
actual = actual.seguent
# (b) Comptar nodes: mateix recorregut, amb un comptador
comptador = 0
actual = cap
while actual is not None:
comptador += 1
actual = actual.seguent
print("Nodes:", comptador) # Nodes: 4Observa que tots dos apartats fan servir el mateix patró de recorregut; només canvia què fem en visitar cada node. Aquest patró és el cor de la classe que construirem a 02-02. I sí: les cadenes cap.seguent.seguent.seguent = ... són horribles — exactament per això necessitem encapsular tot això en una classe.
Solució 3:
# 1. Localitzem el node darrere del qual hem d'inserir (el d'id 11)
node_bug = cap.seguent # "Corregir el bug del login"
# 2. Creem el node nou
nou = Node({"id": 14, "titol": "Hotfix de producció",
"prioritat": 1, "estat": "pendent"})
# 3. Recablejat: primer el nou apunta al que venia després...
nou.seguent = node_bug.seguent # assignació 1
# ...i després l'anterior apunta al nou. L'ordre importa!
node_bug.seguent = nou # assignació 2N'hi ha hagut prou amb 2 assignacions de seguent, i aquest nombre seria idèntic amb un milió de nodes: el recablejat en si és O(1). Fixa't en l'ordre de les dues línies: si féssim primer node_bug.seguent = nou, perdríem la referència a "Desplegar la versió" i la resta de la cadena quedaria despenjada. Aquest detall —l'ordre del recablejat— serà protagonista a la propera lliçó.
Conclusió
Hem separat el contracte dels seus complidors: el TAD llista és una seqüència ordenada amb operacions d'inserir, esborrar, cercar i recórrer, i tant l'array dinàmic com la llista enllaçada l'implementen amb apostes oposades — contigüitat amb accés O(1) per fórmula davant de nodes dispersos units per referències seguent, on inserir és recablejar dues fletxes en lloc de desplaçar mitja memòria. La taula de costos ens va deixar clar que no hi ha guanyador absolut, i el patró d'ús del tauler de TaskFlow (urgències per davant, reordenacions pel mig) va inclinar la balança cap a la llista enllaçada. Però fins ara només hem encadenat nodes a mà, amb aquell incòmode cap.seguent.seguent. A la propera lliçó farem les coses bé: construirem la classe LlistaEnllacada completa —inserir, esborrar, cercar, recórrer, __len__, __str__—, analitzarem el Big O de cada operació, i posarem la criatura davant de timeit per comprovar que la promesa de les insercions barates es compleix allà on l'array queia: inserint per davant.
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
